Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ 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). 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.
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). PreToolUse matcher `mcp__clikernel__execute` runs `aai-hook claude-dojo-sample`, the desktop dojo substitute described below. 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: the desktop currently has no launch flags, so no sysp replacement and no dojo-preloaded start (`claude -r $(claudedojo)`). The hooks detect it (`CLAUDE_CODE_ENTRYPOINT` = `claude-desktop`) and substitute rather than enforce: SessionStart prints `prompts/core.md`; the bootstrap gate, native-edit blocking, and the bash guard stay off; the first kernel call in a Python project is denied once with the worked round and a completion id, so `dojo_start(id)` skips the live round - study replaces play, as in the codex sample. Revisit if the desktop gains launch options.

Outcome, codex, in `~/.codex/hooks.json`: PostCompact, SessionStart with matcher `compact`, and PreToolUse with matcher `mcp__clikernel__execute` each run `<venv>/bin/aai-hook codex-orientation`; UserPromptSubmit runs `<venv>/bin/aai-hook 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.

Expand All @@ -36,7 +38,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, so this deny list would reach desktop sessions the step 3 hooks deliberately leave native. Such users put these permissions in a `--settings` file on the CLI alias instead.

Check: the file still parses as JSON after editing.

Expand Down
57 changes: 51 additions & 6 deletions aai_coding/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,32 @@ def _forget(session_id):
except Exception: pass


def _desktop():
"True in a Claude desktop app session, which runs the relaxed harness: the desktop can neither replace the system prompt nor start dojo-preloaded"
return os.environ.get('CLAUDE_CODE_ENTRYPOINT') == 'claude-desktop'


def _is_nbdev(d):
try: return any(l.startswith('[tool.nbdev]') for l in (d/'pyproject.toml').open())
except OSError: return False


CORE_MD = Path(__file__).parent.parent/'prompts'/'core.md'


def claude_desktop_start(o, d):
"SessionStart, desktop app: core behavioral rules and the nbdev caution; kernel-only enforcement stays off"
if o.get('source') == 'compact': _forget(o.get('session_id', ''))
print(CORE_MD.read_text())
if _is_nbdev(d): print(NBDEV_MSG)


def claude_session_start(o):
"SessionStart: orientation notice by source, then Python-project bootstrap and nbdev addenda"
"SessionStart: orientation notice by source, then Python-project bootstrap and nbdev addenda (relaxed in the desktop app)"
d = Path(os.environ.get('CLAUDE_PROJECT_DIR') or os.getcwd())
src = o.get('source', '')
if src in ('resume', 'compact'): print(f'[{src} at {datetime.now():%H:%M:%S}]')
if _desktop(): return claude_desktop_start(o, d)
if src == 'compact':
_forget(o.get('session_id', ''))
print(COMPACT_MSG)
Expand All @@ -92,9 +113,7 @@ def claude_session_start(o):
print(SYNTH_MSG)
elif src == 'resume' and (d/'pyproject.toml').is_file(): print(RESUME_MSG)
if (d/'pyproject.toml').is_file(): print(BOOTSTRAP_MSG)
try: nb = any(l.startswith('[tool.nbdev]') for l in (d/'pyproject.toml').open())
except OSError: nb = False
if nb: print(NBDEV_MSG)
if _is_nbdev(d): print(NBDEV_MSG)


def _prompt_submit(o, q_notice):
Expand All @@ -114,14 +133,16 @@ def codex_prompt_submit(o):


def claude_bash_guard(o):
"PreToolUse(Bash): reject output-truncating pipes"
"PreToolUse(Bash): reject output-truncating pipes (desktop sessions are exempt)"
if _desktop(): return
if m := bash_guard_msg(o.get('tool_input', {}).get('command') or ''):
print(m, file=sys.stderr)
sys.exit(2)


def claude_block_native_edit(o):
"PreToolUse(Write|Edit|NotebookEdit): route edits to the kernel tooling"
"PreToolUse(Write|Edit|NotebookEdit): route edits to the kernel tooling (desktop sessions keep native tools)"
if _desktop(): return
print(BLOCK_EDIT_MSG, file=sys.stderr)
sys.exit(2)

Expand Down Expand Up @@ -280,6 +301,30 @@ def claude_slop(o):
if notes: print(json.dumps(dict(hookSpecificOutput=dict(
hookEventName='UserPromptSubmit', additionalContext='\n'.join(notes)))))
except Exception as e: print(f'[slop] fail-open: {e!r}', file=sys.stderr)


DOJO_SAMPLE_MSG = ('This desktop session studies a worked dojo round instead of playing one. Read the round below as reference '
'for correct kernel tool usage; do not repeat or score it. Then run `dojo_start({cid!r})` in the kernel to record the skip, '
'and retry this call.\n\n{sample}')


def claude_dojo_sample(o):
"PreToolUse(mcp__clikernel__execute), desktop only: gate the first kernel call on studying the worked round"
try:
if not _desktop() or o.get('agent_id'): return
if not (Path(os.environ.get('CLAUDE_PROJECT_DIR') or os.getcwd())/'pyproject.toml').is_file(): return
f = _state_file('dojo-sample', o.get('session_id', ''))
if f.exists(): return
from llmdojo.claudedojo import _load_reg
_,meta = _load_reg(None) # side effect: registers the template's completion id, so dojo_start honors the skip
import llmdojo
sample = (Path(llmdojo.__file__).parent/'dojo_data/codexdojo_sample.md').read_text()
print(json.dumps(dict(hookSpecificOutput=dict(hookEventName='PreToolUse', permissionDecision='deny',
permissionDecisionReason=DOJO_SAMPLE_MSG.format(cid=meta['cid'], sample=sample)))))
f.write_text('{}')
except Exception as e: print(f'[dojo-sample] fail-open: {e!r}', file=sys.stderr)


def codex_orientation(o):
"codex PostCompact/SessionStart/PreToolUse: post-compaction doc-state reset and one-shot reorientation"
state = Path(os.environ.get('LLMDOJO_STATE_DIR', Path.home()/'.local/state/llmdojo'))
Expand Down
42 changes: 42 additions & 0 deletions tests/test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,48 @@ def out(): return capsys.readouterr().out
assert out() == '' # unparseable transcript: fail-open, silent on stdout


def test_desktop_relaxed(tmp_path, monkeypatch, capsys):
"Desktop sessions keep native edits and swap the bootstrap gate for core.md; terminal sessions are unchanged"
from aai_coding.harness import claude_bash_guard, claude_block_native_edit, claude_session_start
monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(tmp_path))
(tmp_path/'pyproject.toml').write_text('[tool.nbdev]\n')
monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'claude-desktop')
claude_block_native_edit({}) # returns: native edits pass
claude_bash_guard(dict(tool_input=dict(command='pytest | head -5'))) # returns: truncating pipes pass
claude_session_start(dict(source='startup', session_id='s1'))
out = capsys.readouterr().out
assert 'final text message' in out # core.md loaded
assert 'NEVER touch local files' not in out and 'nbdev project' in out
monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'cli')
with pytest.raises(SystemExit): claude_block_native_edit({})
with pytest.raises(SystemExit): claude_bash_guard(dict(tool_input=dict(command='pytest | head -5')))
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


def test_dojo_sample(tmp_path, monkeypatch, capsys):
"First desktop kernel call in a Python project is denied with the worked round; replays, subagents, plain dirs, and terminal sessions pass"
from aai_coding.harness import claude_dojo_sample
monkeypatch.setenv('LLMDOJO_STATE_DIR', str(tmp_path))
monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(tmp_path))
monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'claude-desktop')
ev = dict(hook_event_name='PreToolUse', session_id='s1')
claude_dojo_sample(ev)
assert capsys.readouterr().out == '' # no pyproject.toml: not a kernel-regime project
(tmp_path/'pyproject.toml').write_text('')
claude_dojo_sample(ev)
r = json.loads(capsys.readouterr().out)['hookSpecificOutput']
assert r['permissionDecision'] == 'deny' and 'dojo_start' in r['permissionDecisionReason']
claude_dojo_sample(ev)
assert capsys.readouterr().out == '' # studied once: later calls pass
claude_dojo_sample(dict(hook_event_name='PreToolUse', session_id='s2', agent_id='sub1'))
assert capsys.readouterr().out == '' # subagents pass
monkeypatch.setenv('CLAUDE_CODE_ENTRYPOINT', 'cli')
claude_dojo_sample(dict(hook_event_name='PreToolUse', session_id='s3'))
assert capsys.readouterr().out == '' # terminal sessions play the real round


@pytest.mark.skipif(not which('slopometer'), reason='slopometer not installed')
def test_slop(tmp_path, monkeypatch, capsys):
"Sloppy previous message -> context rows at the next prompt; repeats, subagents, short and clean prose stay silent"
Expand Down