Skip to content
Open
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
23 changes: 18 additions & 5 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<venv>/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 `<this repo>/scripts/clikernel-mcp-shim`, which execs `<venv>/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 `<venv>` with the absolute workspace environment path:

Expand Down Expand Up @@ -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 `<venv>/bin/aai-hook codex-orientation`; UserPromptSubmit runs `<venv>/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 `<venv>/bin/aai-hook codex-orientation`; UserPromptSubmit runs `<venv>/bin/aai-hook codex-prompt-submit`; Stop and UserPromptSubmit each run `<venv>/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.

Expand All @@ -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.

Expand All @@ -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 `<this repo>/prompts/core.md` appended to the system prompt; a shell alias adding `--append-system-prompt-file <this repo>/prompts/core.md` to `claude` does it. The stronger option is the team's full behavioral prompt: symlink `~/.claude/sysp` to `<this repo>/prompts/sysp.md` and alias `claude` to `claude --system-prompt-file ~/.claude/sysp --append-system-prompt-file <this repo>/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", "<this repo>/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 `<this repo>/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 = "<this repo>/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.

Expand Down
85 changes: 64 additions & 21 deletions aai_coding/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(('<in-app-browser-context', '# Response annotations:')): return prompt
_, sep, request = prompt.partition('## My request:\n')
return request if sep else prompt


def prompt_notices(prompt, q_notice=Q_NOTICE):
"Notices for questions, reading requests, bare approvals, BTW side-requests, and the apostrophe caveat check"
prompt = _user_prompt(prompt)
out = []
if prompt.rstrip().endswith('?'): out.append(q_notice)
if 'please read' in prompt.lower(): out.append(READ_NOTICE)
Expand Down Expand Up @@ -61,16 +69,25 @@ def synthetic_resume(path):
BLOCK_EDIT_MSG = 'Native file write/edit tools are blocked in this environment: make the edit via the clikernel session instead (exhash / %%exhash, pyskills.edit, pyskills.ipynb).'


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'


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


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; the desktop app gets core.md instead of the bootstrap gate"
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 src == 'compact':
print(COMPACT_MSG)
elif src == 'resume' and synthetic_resume(o.get('transcript_path', '')): print(SYNTH_MSG)
elif src == 'resume' and (d/'pyproject.toml').is_file(): print(RESUME_MSG)
if (d/'pyproject.toml').is_file(): print(BOOTSTRAP_MSG)
if _desktop(): print(CORE_MD.read_text())
else:
if src == 'compact': print(COMPACT_MSG)
elif src == 'resume' and synthetic_resume(o.get('transcript_path', '')): 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)
Expand Down Expand Up @@ -100,7 +117,8 @@ def claude_bash_guard(o):


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 Write and Edit but never NotebookEdit: its writer saves non-ASCII as JSON escapes, churning every notebook it touches"
if _desktop() and o.get('tool_name') != 'NotebookEdit': return
print(BLOCK_EDIT_MSG, file=sys.stderr)
sys.exit(2)

Expand Down Expand Up @@ -211,6 +229,11 @@ def _slop_state(f):
if not isinstance(st, dict): st = {}
return {k: st.get(k, d) for k, d in _SLOP_KEYS.items()}

def _slop_save(f, st):
tmp = f.with_suffix(f'.{os.getpid()}.tmp')
tmp.write_text(json.dumps(st))
tmp.replace(f)

def _slop_report(txt):
"Zero or one scored-message notices for `txt`, applying the env-tunable thresholds"
if len(txt.split()) < int(os.environ.get('SLOP_WORDS', SLOP_WORDS)): return []
Expand All @@ -220,6 +243,7 @@ def _slop_report(txt):
r = subprocess.run(['slopometer', '--json'], input=txt, capture_output=True, text=True, timeout=60)
if r.returncode: return []
j = json.loads(r.stdout)
if j.get('too_short'): return []
worst_min = int(os.environ.get('SLOP_WORST', SLOP_WORST))
dens_min = float(os.environ.get('SLOP_DENSITY', SLOP_DENSITY))
if not (j['worst'] >= worst_min or j['density'] >= dens_min): return []
Expand All @@ -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 '<response-annotations>' 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:
Expand All @@ -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'))
Expand Down
4 changes: 2 additions & 2 deletions plugins/safecmd/scripts/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
6 changes: 6 additions & 0 deletions scripts/clikernel-mcp-shim
Original file line number Diff line number Diff line change
@@ -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" "$@"
Loading