From 8efd62b021113b4dfd6a7c69353731d313e58a23 Mon Sep 17 00:00:00 2001 From: Piotr Czapla Date: Mon, 7 Sep 2026 15:29:35 +0200 Subject: [PATCH] Support Claude Desktop and Codex prose feedback --- SETUP.md | 23 ++++++-- aai_coding/harness.py | 85 +++++++++++++++++++++------- plugins/safecmd/scripts/validator.py | 4 +- scripts/clikernel-mcp-shim | 6 ++ tests/test_harness.py | 79 +++++++++++++++++++++++++- 5 files changed, 168 insertions(+), 29 deletions(-) create mode 100755 scripts/clikernel-mcp-shim diff --git a/SETUP.md b/SETUP.md index 0922fb1..dfcbe1d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -90,7 +90,7 @@ Codex has two supported modes. This choice applies only to codex; Claude Code re ## 1. Kernel server -Outcome: the clikernel MCP server is registered. Claude Code: a user-scope server named `clikernel` running `/bin/clikernel-mcp`. Kernel-centric codex: a `[mcp_servers.clikernel]` block in `~/.codex/config.toml` with `command` set to that binary, `startup_timeout_sec = 30`, `tool_timeout_sec = 3600`, and `approval_mode = "approve"` for its `execute`, `connect`, `restart`, and `interrupt` tools. +Outcome: the clikernel MCP server is registered. Claude Code: a user-scope server named `clikernel` running `/scripts/clikernel-mcp-shim`, which execs `/bin/clikernel-mcp`, adding `--quiet` when `CLAUDE_CODE_ENTRYPOINT` is `claude-desktop`: desktop kernels skip the startup notice, since desktop sessions take their instructions from the hooks (step 3). Kernel-centric codex: a `[mcp_servers.clikernel]` block in `~/.codex/config.toml` with `command` set to that binary, `startup_timeout_sec = 30`, `tool_timeout_sec = 3600`, and `approval_mode = "approve"` for its `execute`, `connect`, `restart`, and `interrupt` tools. Hybrid codex: use the following configuration, replacing `` with the absolute workspace environment path: @@ -136,9 +136,13 @@ Settle first: existing non-symlink files at those paths. ## 3. Hooks -Outcome, Claude Code, in `~/.claude/settings.json` under `hooks`: PreToolUse matcher `Write|Edit|NotebookEdit` runs `aai-hook claude-block-native-edit`; PreToolUse matcher `Bash` runs `aai-hook claude-bash-guard`; UserPromptSubmit runs `aai-hook claude-prompt-submit`; SessionStart runs `aai-hook claude-session-start`; UserPromptSubmit, MessageDisplay, and PostToolBatch each also run `aai-hook claude-air` (the come-up-for-air nudge: after 8 tool-call rounds with no text response of 100+ chars, it injects a reminder to surface and reassess, repeating every 5 further rounds). The air nudge is Claude-only: codex has no message-level hook event, so it cannot observe the "text happened" reset condition - the codex-shaped substitute is a sentence in AGENTS.md; revisit if codex grows one. PostToolBatch and Stop also each run `aai-hook claude-drop-sentinel`, a Python port of podlayer/message-drop-sentinel (MIT): it detects the thinking-sandwich message-drop platform bug from the transcript scar (two adjacent thinking blocks) and tells the agent its text was probably eaten: restate it in the turn-final message, or say it now and end the turn if the user needs it immediately. Retire the sentinel entries when the upstream bug is fixed (re-test recipe and issue links in that repo's README). UserPromptSubmit and MessageDisplay also each run `aai-hook claude-slop`: MessageDisplay buffers each displayed assistant message, and at the next prompt the hook scores the previous turn's final message with the `slopometer` CLI, injecting the flagged patterns as context. A prompt that is a bare `;` means the user did not understand the previous reply, and the hook injects an instruction to restate it in plain English. Bare `aai-hook` resolves because the user's shell profile puts the workspace venv on PATH; if it does not, use the absolute venv path. +Send a bare `;` to request a plain-English restatement of the previous reply. When response annotations are present, include `;` in the main prompt to rewrite the selected text and answer any accompanying question. The hooks read the request after `## My request:` without parsing the selections. -Outcome, kernel-centric codex, in `~/.codex/hooks.json`: PostCompact, SessionStart with matcher `compact`, and PreToolUse with matcher `mcp__clikernel__execute` each run `/bin/aai-hook codex-orientation`; UserPromptSubmit runs `/bin/aai-hook codex-prompt-submit`. Hybrid codex does not install `codex-orientation`, since it does not run the dojo; it may still install `codex-prompt-submit`. codex asks the user to trust hooks on the first start after any `hooks.json` change; tell them to expect that prompt. +Outcome, Claude Code, in `~/.claude/settings.json` under `hooks`: PreToolUse matcher `Write|Edit|NotebookEdit` runs `aai-hook claude-block-native-edit`; PreToolUse matcher `Bash` runs `aai-hook claude-bash-guard`; UserPromptSubmit runs `aai-hook claude-prompt-submit`; SessionStart runs `aai-hook claude-session-start`; UserPromptSubmit, MessageDisplay, and PostToolBatch each also run `aai-hook claude-air` (the come-up-for-air nudge: after 8 tool-call rounds with no text response of 100+ chars, it injects a reminder to surface and reassess, repeating every 5 further rounds). The air nudge is Claude-only: codex has no message-level hook event, so it cannot observe the "text happened" reset condition - the codex-shaped substitute is a sentence in AGENTS.md; revisit if codex grows one. PostToolBatch and Stop also each run `aai-hook claude-drop-sentinel`, a Python port of podlayer/message-drop-sentinel (MIT): it detects the thinking-sandwich message-drop platform bug from the transcript scar (two adjacent thinking blocks) and tells the agent its text was probably eaten: restate it in the turn-final message, or say it now and end the turn if the user needs it immediately. Retire the sentinel entries when the upstream bug is fixed (re-test recipe and issue links in that repo's README). UserPromptSubmit and MessageDisplay also each run `aai-hook claude-slop`: MessageDisplay buffers each displayed assistant message, and at the next prompt the hook scores the previous turn's final message with the `slopometer` CLI, injecting the flagged patterns as context. A bare `;` means the user did not understand the previous reply, so the hook asks the agent to restate it in plain English. A bare `'` asks the agent to say whether its closing caveat was a real issue or empty hedging. Bare `aai-hook` resolves because the user's shell profile puts the workspace venv on PATH; if it does not, use the absolute venv path. + +Desktop app: it has no launch flags, so no sysp replacement and no `claudedojo` launch. Hooks detect it (`CLAUDE_CODE_ENTRYPOINT` = `claude-desktop`): SessionStart prints `prompts/core.md` on startup, resume, and compaction. Desktop sessions receive no dojo instructions. Native Write and Edit stay usable. NotebookEdit stays blocked everywhere: its writer saves non-ASCII as JSON escapes, churning whole notebooks. The bash guard runs in both frontends. Revisit if the desktop gains launch options. + +Outcome, kernel-centric codex, in `~/.codex/hooks.json`: PostCompact, SessionStart with matcher `compact`, and PreToolUse with matcher `mcp__clikernel__execute` each run `/bin/aai-hook codex-orientation`; UserPromptSubmit runs `/bin/aai-hook codex-prompt-submit`; Stop and UserPromptSubmit each run `/bin/aai-hook codex-slop`. Hybrid codex does not install `codex-orientation`, since it does not run the dojo; it may still install `codex-prompt-submit` and `codex-slop`. The Stop hook stores `last_assistant_message`. On the next prompt, `codex-slop` scores that text and handles bare `;`. The `codex-prompt-submit` hook handles `'` independently of scoring. codex asks the user to trust hooks on the first start after any `hooks.json` change; tell them to expect that prompt. Check: `aai-hook claude-prompt-submit` fed `{"prompt": "test?"}` on stdin prints the question notice. @@ -150,7 +154,7 @@ Outcome, in `settings.json`: `permissions.deny` includes `Read`, `Edit`, `Write` Recommended, ask the user: `disableBundledSkills` set to `true` in `settings.json`, turning off the built-in skills (`init`, `review`, `code-review`, `security-review`, `simplify`, `verify`, `run`, `dataviz`, `artifact-design`, `fewer-permission-prompts`, `update-config`, `keybindings-help`), which assume the native file tools this deny list removes. -Settle first: any existing rule that conflicts. In particular a broad `Bash` allow rule defeats both the bash guard and safecmd; surface that one explicitly. +Settle first: any existing rule that conflicts. In particular a broad `Bash` allow rule defeats both the bash guard and safecmd; surface that one explicitly. Also whether the user works in the desktop app: settings cannot branch by frontend, and this deny list would strip desktop sessions too. Such users carry these rules in `~/.config/claudedojo/config.toml` instead (step 5). Check: the file still parses as JSON after editing. @@ -160,7 +164,16 @@ Outcome: symlinks from `~/.claude/skills/persistent-python` and `~/.claude/skill safecmd auto-approves allowlisted Bash commands. The starter workspace installs the `safecmd` package; its allowlist lives at `~/.config/safecmd/config.ini` and the defaults are fine to start. The starter also installs `slopometer` for the prose-scoring hook. Its first score downloads a spaCy language model into `~/.cache/slopometer`; tell the user to expect that download. Without the executable, the prose hook silently skips scoring, so check it explicitly rather than assuming the hook registration proves it works. -Optional, Claude Code: the user might like `/prompts/core.md` appended to the system prompt; a shell alias adding `--append-system-prompt-file /prompts/core.md` to `claude` does it. The stronger option is the team's full behavioral prompt: symlink `~/.claude/sysp` to `/prompts/sysp.md` and alias `claude` to `claude --system-prompt-file ~/.claude/sysp --append-system-prompt-file /prompts/core.md`, which replaces Claude Code's default prompt entirely. Explain the trade to the user before wiring it: the default's tool schemas survive replacement, but its dynamic environment block and scratchpad path do not, and the behavioral text takes over from the default's guidance. +Optional, Claude Code: `claudedojo` launches `claude` on a session opening with the worked dojo round, adding the `claude_args` list from `~/.config/claudedojo/config.toml` (each `~`-expanded). That file carries the whole CLI launch: no shell alias, no `--settings` file: + + claude_args = [ + "--system-prompt-file", "~/.claude/sysp", + "--append-system-prompt-file", "/prompts/core.md", + "--allowedTools", "WebSearch", "WebFetch", "mcp__clikernel__restart", + "--disallowedTools", "Read", "Edit", "Write", "Grep", "Glob", "NotebookEdit", "Bash(cat *)", "Bash(python -c:*)", + ] + +`--system-prompt-file` replaces Claude Code's default prompt with sysp.md (symlink `~/.claude/sysp` to `/prompts/sysp.md`). Explain the trade before wiring it: the default's tool schemas survive replacement, but its dynamic environment block and scratchpad path do not. Optional, codex: the analogue of the full behavioral prompt is `model_instructions_file = "/prompts/codex-sysp.md"` (absolute path) in `~/.codex/config.toml`, replacing codex's built-in instructions entirely; `~/.codex/AGENTS.md` (and so `core.md`) still loads on top, and no symlink is involved since the key points straight into the checkout. Explain the trade to the user before wiring it: the file is the team's edited reconstruction of the built-in instructions, so upstream changes to codex's own prompt stop arriving until the file is revised. diff --git a/aai_coding/harness.py b/aai_coding/harness.py index ef76dc1..901affe 100644 --- a/aai_coding/harness.py +++ b/aai_coding/harness.py @@ -27,8 +27,16 @@ def bash_guard_msg(cmd): SLOP_CAVEAT = 'The user sent a bare "\'": your previous reply appears to end with an unnecessary caveat. Identify what you meant: a concrete obstacle requiring a user decision, an ordinary implementation or testing task, or an unsupported hypothetical concern. If it requires a decision, explain the obstacle, its consequence, and the decision needed. If it is routine work, say so without implying the plan’s feasibility is uncertain. If it is unsupported or irrelevant to the question, withdraw it. Do not invent a justification for having included it.' +def _user_prompt(prompt): + "Read the request after browser or response-annotation context." + if not prompt.lstrip().startswith(('= worst_min or j['density'] >= dens_min): return [] @@ -230,6 +254,21 @@ def row(f): return [SLOP_MSG.format(d=j['density'], t=dens_min, w=j['worst'], rows=rows)] +def _slop_prompt(o, f, st): + raw = o.get('prompt') or '' + prompt = _user_prompt(raw).strip() + notes = [] + if ';' in prompt and '' in raw: notes.append('Rewrite the selected text in plain English. Also answer any question in the user request.') + elif prompt == ';': notes.append(SLOP_RESTATE) + txt, fresh = st['last'], st['lastmid'] != st['done'] + if txt and fresh: + st['done'] = st['lastmid'] + _slop_save(f, st) + notes += _slop_report(txt) + if notes: print(json.dumps(dict(hookSpecificOutput=dict( + hookEventName='UserPromptSubmit', additionalContext='\n'.join(notes))))) + + def claude_slop(o): "MessageDisplay/UserPromptSubmit: track the displaying message, then report the previous turn's score with the new prompt" try: @@ -240,22 +279,26 @@ def claude_slop(o): if o.get('message_id') != st['mid']: st.update(mid=o.get('message_id'), buf='') st['buf'] += o.get('delta') or '' if o.get('final'): st['last'], st['lastmid'] = st['buf'], st['mid'] - tmp = f.with_suffix(f'.{os.getpid()}.tmp') - tmp.write_text(json.dumps(st)) - tmp.replace(f) + _slop_save(f, st) + return + _slop_prompt(o, f, st) + except Exception as e: print(f'[slop] fail-open: {e!r}', file=sys.stderr) + + +def codex_slop(o): + "Stop/UserPromptSubmit: store the final assistant message, then report its score with the next prompt" + try: + f = _state_file('slop', o.get('session_id', '')) + st = _slop_state(f) + if o['hook_event_name'] == 'Stop': + st['last'], st['lastmid'] = o.get('last_assistant_message') or '', o.get('turn_id') or '' + _slop_save(f, st) + print('{}') return - notes = [] - if (o.get('prompt') or '').strip() == ';': notes.append(SLOP_RESTATE) - txt, fresh = st['last'], st['lastmid'] != st['done'] - if txt and fresh: - st['done'] = st['lastmid'] - tmp = f.with_suffix(f'.{os.getpid()}.tmp') - tmp.write_text(json.dumps(st)) - tmp.replace(f) - notes += _slop_report(txt) - if notes: print(json.dumps(dict(hookSpecificOutput=dict( - hookEventName='UserPromptSubmit', additionalContext='\n'.join(notes))))) + _slop_prompt(o, f, st) except Exception as e: print(f'[slop] fail-open: {e!r}', file=sys.stderr) + + def codex_orientation(o): "codex PostCompact/SessionStart/PreToolUse: one-shot post-compaction reorientation" state = Path(os.environ.get('LLMDOJO_STATE_DIR', Path.home()/'.local/state/llmdojo')) diff --git a/plugins/safecmd/scripts/validator.py b/plugins/safecmd/scripts/validator.py index 2c6e4a4..72264c4 100755 --- a/plugins/safecmd/scripts/validator.py +++ b/plugins/safecmd/scripts/validator.py @@ -11,7 +11,7 @@ def respond(decision, reason): sys.exit(0) try: from safecmd import validate, DisallowedError -except ImportError as e: respond("defer", f"safecmd import failed: {e}") +except ImportError: sys.exit(0) # no opinion: exit 0 with no output falls through to the normal permission flow def main(): try: hook_input = json.load(sys.stdin) @@ -24,6 +24,6 @@ def main(): validate(cmd) respond("allow", "safecmd: validated") except DisallowedError: - respond("defer", "Not on safecmd allowlist") + sys.exit(0) # off-allowlist: no opinion, normal permission flow ("defer" means resume-later and ends the run in the desktop app) if __name__ == "__main__": main() diff --git a/scripts/clikernel-mcp-shim b/scripts/clikernel-mcp-shim new file mode 100755 index 0000000..49c32a4 --- /dev/null +++ b/scripts/clikernel-mcp-shim @@ -0,0 +1,6 @@ +#!/bin/sh +# Runs clikernel-mcp, adding --quiet in the desktop app: desktop sessions take +# their instructions from the hooks, not from the kernel's startup notice. +mcp="$(CDPATH= cd "$(dirname "$0")/../.." && pwd)/.venv/bin/clikernel-mcp" +[ "$CLAUDE_CODE_ENTRYPOINT" = claude-desktop ] && exec "$mcp" --quiet "$@" +exec "$mcp" "$@" diff --git a/tests/test_harness.py b/tests/test_harness.py index 3884200..4d57ff3 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -82,6 +82,25 @@ def out(): return capsys.readouterr().out assert 'thinking blocks in a row' in json.loads(out())['hookSpecificOutput']['additionalContext'] # new turn: count restarts +def test_desktop_relaxed(tmp_path, monkeypatch, capsys): + "Desktop sessions keep native Write/Edit (never NotebookEdit) and get core.md instead of the bootstrap gate; terminal sessions enforce both" + from aai_coding.harness import claude_block_native_edit, claude_session_start + monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(tmp_path)) + (tmp_path/'pyproject.toml').write_text('') + monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'claude-desktop') + claude_block_native_edit(dict(tool_name='Edit')) + with pytest.raises(SystemExit): claude_block_native_edit(dict(tool_name='NotebookEdit')) # its writer's escape churn corrupts notebooks + for source in ('startup', 'resume', 'compact'): + claude_session_start(dict(source=source, session_id='s1')) + out = capsys.readouterr().out + assert 'final text message' in out and 'dojo' not in out + monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'cli') + with pytest.raises(SystemExit): claude_block_native_edit(dict(tool_name='Edit')) + claude_session_start(dict(source='startup', session_id='s1')) + out = capsys.readouterr().out + assert 'NEVER touch local files' in out and 'final text message' not in out + + @pytest.mark.skipif(not which('slopometer'), reason='slopometer not installed') def test_slop(tmp_path, monkeypatch, capsys): "Buffer message deltas, score only the final message, and report it once" @@ -89,7 +108,7 @@ def test_slop(tmp_path, monkeypatch, capsys): def disp(mid, txt, final=True, **kw): claude_slop(dict(hook_event_name='MessageDisplay', session_id='s1', message_id=mid, delta=txt, final=final, **kw)) def psub(**kw): claude_slop(dict(hook_event_name='UserPromptSubmit', session_id='s1', **kw)) def out(): return capsys.readouterr().out - sloppy = "This isn't just a linter - it's a comprehensive paradigm that will streamline your workflow. " * 3 + sloppy = "This isn't just a linter - it's a comprehensive paradigm that will streamline your workflow. " * 12 disp('m1', 'a mid-turn note that nobody should score') disp('m2', sloppy[:40], final=False) disp('m2', sloppy[40:]) @@ -99,3 +118,61 @@ def out(): return capsys.readouterr().out assert 'previous turn' in r['additionalContext'] and 'splice' in r['additionalContext'] psub() assert out() == '' # the same message reports once + + +@pytest.mark.skipif(not which('slopometer'), reason='slopometer not installed') +def test_codex_slop(tmp_path, monkeypatch, capsys): + "Codex Stop captures the final reply; the next prompt reports it once and handles the punctuation notices" + from aai_coding.harness import codex_slop + monkeypatch.setenv('LLMDOJO_STATE_DIR', str(tmp_path)) + def stop(tid, txt): codex_slop(dict(hook_event_name='Stop', session_id='s1', turn_id=tid, last_assistant_message=txt)) + def psub(prompt='hi'): codex_slop(dict(hook_event_name='UserPromptSubmit', session_id='s1', prompt=prompt)) + def out(): return capsys.readouterr().out + sloppy = "This isn't just a linter - it's a comprehensive paradigm that will streamline your workflow. " * 12 + stop('t1', sloppy) + assert json.loads(out()) == {} + psub() + ctx = json.loads(out())['hookSpecificOutput']['additionalContext'] + assert 'previous turn' in ctx and 'splice' in ctx + psub() + assert out() == '' + stop('t2', 'The parser rejects malformed input. ' * 10) + assert json.loads(out()) == {} + psub(';') + ctx = json.loads(out())['hookSpecificOutput']['additionalContext'] + assert 'did not understand' in ctx and 'previous turn' not in ctx + + +@pytest.mark.skipif(not which('slopometer'), reason='slopometer not installed') +def test_slop_short_report(): + "The scorer can decline a message above the hook's local word threshold." + from aai_coding.harness import _slop_report + assert _slop_report('The parser rejects malformed input. ' * 10) == [] + + +def test_browser_prompt_shortcuts(tmp_path, monkeypatch, capsys): + "Browser context must not hide a punctuation request or become the request itself." + from aai_coding.harness import codex_slop, codex_prompt_submit + monkeypatch.setenv('LLMDOJO_STATE_DIR', str(tmp_path)) + wrapper = ''' + +This block is automatically supplied ambient UI state, not part of the user's request. +# In app browser: +- Current URL: http://127.0.0.1:5197/?pr=example + + +## My request: +''' + annotation = '# Response annotations:\n[]\n## My request:\n' + for prefix in ('', wrapper, annotation): + codex_slop(dict(hook_event_name='UserPromptSubmit', session_id='browser', prompt=prefix+';\n')) + expected = 'selected text' if prefix == annotation else 'did not understand' + assert expected in json.loads(capsys.readouterr().out)['hookSpecificOutput']['additionalContext'] + codex_prompt_submit(dict(prompt=prefix+"'\n")) + assert 'caveat' in json.loads(capsys.readouterr().out)['hookSpecificOutput']['additionalContext'] + for prompt in (wrapper+'Explain the semicolon ;', 'Quoted example:\n'+wrapper+';'): + codex_slop(dict(hook_event_name='UserPromptSubmit', session_id='browser', prompt=prompt)) + assert capsys.readouterr().out == '' + codex_slop(dict(hook_event_name='UserPromptSubmit', session_id='browser', prompt=annotation+'; Why does this happen?')) + ctx = json.loads(capsys.readouterr().out)['hookSpecificOutput']['additionalContext'] + assert 'selected text' in ctx and 'answer' in ctx and 'question' in ctx