hooks/claude-hook.sh answered every event by running resolve-plan-dir.sh
and inject-plan.sh, about 130 forks per UserPromptSubmit. Under Git Bash
on Windows a fork costs about 90 ms, so a prompt took 7 to 12 s against
the 10 s hook timeout and Claude Code discarded the plan context, and
PreToolUse added 5 s before every tool call. Measured on the reporting
machine: 8.1, 10.2 and 7.1 s per prompt before, 0.33 s after.
scripts/inject-plan.py is a byte-identical twin of inject-plan.sh plus
the dispatcher logic of claude-hook.sh. The launcher finds a CPython 3
with a fork-free PATH walk, runs the twin with -I -B and falls back to
the shell chain on any non-zero exit; PWF_FAST_PATH=0 forces the shell
chain; a directory without planning state exits before any fork. The
launcher hands the twin its own $PWD spelling so both routes share one
turn-marker and one progress-guard slot per plan. skill-hook.sh uses the
twin after eligibility and isolates its own two interpreter calls. Both
dispatchers run the json_string awk under LC_ALL=C so a character outside
the BMP no longer leaves as a lone surrogate on Windows. The twin ships in
every scripts directory that ships inject-plan.sh.
tests/test_inject_plan_python_parity.py runs both implementations over
the same fixtures and asserts identical stdout, including what the
second-model review found and this commit fixes: NUL bytes in
.active_plan, the awk "phases: /N" quirk for a plan with no completed
phase, the CRLF progress tail under Git for Windows sed, cache slots
shared across routes, and non-BMP characters through the JSON encoder.
The python - heredocs and -c snippets in inject-plan.sh and
resolve-plan-dir.sh put the current directory first on sys.path, so a
repository carrying its own secrets.py, hashlib.py or ctypes.py had that
file imported by the hook on every prompt, with the hook's privileges.
Every interpreter start in the two scripts now passes -I, in the canonical
skill and in every synced copy.
Four tests pinned the behaviour #239 changed and went red on master after
v3.16.0. The shipped code is correct; the assertions were describing the old
contract.
- test_claude_plugin_operations: PostToolUse is "Write|Edit", not
"Write|Edit|Bash". PreToolUse keeps Bash and its assertion is untouched.
- test_codex_hooks: the adapter emits hookSpecificOutput.additionalContext with
"hookEventName": "PostToolUse". The test now also asserts systemMessage is
ABSENT, so a silent revert to the user-facing field fails here rather than
only in test_post_tool_nudge.py.
- test_codex_plugin_operations: PreToolUse and PostToolUse no longer share one
matcher, so the single WRITE_MATCHER constant becomes PRE_TOOL_MATCHER and
POST_TOOL_MATCHER behind an EVENT_MATCHERS map. The spaced-cached-root case
reads the new field too.
These were caught by the full local suite finishing after the tag was pushed,
which is on me: the release went out on the targeted suites alone.
Version bumped to 3.16.0 across the 20 tracked parity targets plus the ClawHub
stage. The OpenCode plugin package stays at 1.0.1 and the Pi extension at
1.2.5: this cycle touched hooks/ and .codex/hooks/, neither package.
CHANGELOG entry for #239 including the two scope calls stated rather than left
to be discovered (FileChanged deferred, the standalone skill route unchanged);
README releases-table row; Raymond Manaloto's CONTRIBUTORS entry extended with
#239. Suite size pinned at 706 in README and llms.txt with its guard.
closes#239, reported by Raymond Manaloto (@sortakool)
The progress reminder is addressed to Claude:
[planning-with-files] Update progress.md with what you just did. ...
hooks/claude-hook.sh emitted it as systemMessage, which Claude Code documents
as a warning shown to the USER. So the person saw the instruction after every
Write, Edit and Bash call for a whole session, and the model it is written for
never saw it once. emit_session_start eleven lines above already emitted
hookSpecificOutput.additionalContext for its own event, so the correct shape
was in the same file the whole time. .codex/hooks/post_tool_use.py had the
identical defect, next to a pre_tool_use.py and a run_sh.py that already used
the right shape.
Three defects, one line apart, fixed together:
1. The field. Both routes now emit additionalContext under hookSpecificOutput
with "hookEventName": "PostToolUse".
2. The throttle. Once per turn rather than once per tool call. The string is a
constant, so every repeat after the first carries no information: it names
no tool, no file and no phase. UserPromptSubmit and SessionStart fire once
per turn and clear a marker; the first post-tool fire of the turn sets it.
The marker lives in the user's private cache under pwf-turn/, never in the
plan directory, and is keyed on the plan path plus PWF_SESSION_ID so two
sessions sharing a plan do not silence each other. A cache root that cannot
be created skips the throttle rather than the reminder, so a broken cache
cannot quietly remove the signal instead of the noise.
3. The matcher. Bash comes off PostToolUse on all three manifests: ls, git
status and grep were tripping a "you changed something, record it" nudge.
hooks/hooks.json goes to Write|Edit and both Codex manifests to
apply_patch|Edit|Write. PreToolUse keeps Bash, because a plan reminder
before a shell command is a different and wanted behaviour. The standalone
skill route already used Write|Edit, which is further evidence the wider
matchers were drift rather than intent.
Fixing this surfaced a fourth thing. active_plan_dir() in hooks/claude-hook.sh
resolves through the shared resolver and then fell back to the legacy root
task_plan.md: the #237 fallback removed from the script and Codex routes in
v3.15.0 and missed on the plugin dispatcher. A rejected PLAN_ID or
PWF_PLAN_ROOT now stops there too.
Deliberately not in this change: FileChanged, which would catch a Bash command
that rewrites a file, is a new event surface with its own behaviour and belongs
in a release where it can be tested on its own. The standalone skill route
still emits the sentence as plain stdout from a YAML frontmatter scalar with no
throttle; it already carries the narrow matcher so the volume reported here
never applied to it, and giving it the throttle means either putting cache-key
logic inside a hook scalar or routing it through a new script context across
eleven SKILL.md files.
Tests: 10 in tests/test_post_tool_nudge.py, driving both routes as processes
rather than asserting on source. One proves the two Codex scripts agree on the
marker key by asserting the re-arm deletes the exact file the throttle wrote:
they derive it independently and one resolves a PWF_PLAN_ROOT pin while the
other does not, so a divergence would leave the nudge firing once per session
with nothing in any log.
Version bumped to 3.15.0 across the 20 tracked parity targets plus the ClawHub
stage. The OpenCode plugin package goes to 1.0.1 and the bundled Pi extension
to 1.2.5, since both changed in this cycle.
CHANGELOG entry for #236, #237 and #238; README releases-table row; Raymond
Manaloto added to CONTRIBUTORS.md for all three. Suite size pinned at 696 in
README and llms.txt with its guard.
The resolver description in the English SKILL.md files said PLAN_ID "checks
first, then falls back"; it now states that a set PLAN_ID is a binding. The
five translated variants keep the old sentence until it can be translated
properly rather than shipped as English text under a localized heading.
Three defects reported by Raymond Manaloto (@sortakool), each reproduced on
Windows with Git Bash before any code changed. Two of them let a session work
on a plan nobody selected; the third made the diagnostic that should have
caught them report PASS.
closes#237: an explicit PLAN_ID was a hint, not a binding
resolve_from_env returned 1 both when no selector was set and when the selector
was rejected, so a PLAN_ID of valid slug shape naming no directory fell through
to .active_plan and then to newest-by-mtime. attest-plan.sh locked that other
plan at rc=0 and injection followed it. The guard in attest-plan.sh written for
exactly this case was unreachable, because the resolver never returned empty.
commands/plan-attest.md has promised the opposite since v3.9.0.
A non-empty PLAN_ID now resolves or stops, for every rejection reason: slug
shape (traversal included), naming no directory, or failing containment. An
empty value still means unset, which init-session.sh depends on. The rule went
into every resolver, because they disagreed otherwise: resolve-plan-dir.sh,
resolve-plan-dir.ps1, the inline resolver inside inject-plan.sh, and the
Hermes, OpenCode and Pi plugin resolvers. v3.14.0 had made the two plugin
resolvers fall through to match the shell; that parity was right and its target
was wrong.
Fixing the resolver alone would have moved the bug rather than removed it, so
every consumer was audited. check-complete.sh would have read the root plan's
completion state, which decides whether an autonomous run may stop.
ledger-summary.sh feeds phase counts into autonomous turns. phase-status.sh and
ledger-append.sh write into the directory they pick. The five Codex hooks call
the resolver directly and each fell through to the legacy root, stop.sh and
pre-compact.sh included. All of them now stop on a rejected selector, and the
once-per-turn routes print a notice, because a fail-closed path with no stated
cause would trade a wrong-plan bug for a silent one.
closes#238: a slug plan with no .mode bypassed the project's root .mode
A project makes attestation mandatory by committing a root .mode, a reviewed
project setting. inject-plan.sh read the slug's .mode and never the root's, and
init-session.sh writes no .mode unless --autonomous or --gated was passed, so
init-session.sh <name>, which /plan runs, produced a plan with no attestation
requirement and full injection. The policy was a flag the agent chose at plan
creation.
The root .mode is now a floor. Strictness-raising tokens count from either
file, so a slug may raise and never lower; the one strictness-lowering token,
plan-guard-off, needs the root's agreement. The same floor applies to the
completion gate in check-complete.sh and check-complete.ps1, which read .mode
the same way and had the same bypass, and to the Hermes and OpenCode plugins.
init-session.sh seeds a new slug from the root .mode so the effective policy is
visible in the plan directory, not only in the resolver. With no root .mode
present the effective token set is exactly the slug's, so existing projects are
unchanged.
closes#236: plan-doctor.sh reported PASS on a fully dark-hooks state
The injection check substring-matched five control strings against output that
carries the plan body verbatim inside the BEGIN-PWF-DATA fences. A plan whose
phase line read "fix the false PLAN TAMPERED warning in plan-doctor" reported a
hash mismatch while correctly attested. Worse, the PWF_PLAN_ROOT arm matched a
literal that is not a substring of what inject-plan.sh emits, so the arm was
dead code and execution fell to the success arm: PWF_PLAN_ROOT=/nonexistent
printed "PASS injection: emits plan context (123 bytes)" while nothing was
injected, counting the refusal notice's own bytes as plan context. The file's
own comment four lines above already warned against exactly that.
Classification now branches on the frame. Every refusal path exits before
frame_file runs, so a frame proves injection happened and unframed output is by
construction a notice. The default arm warns, so a reworded or translated
banner degrades noisily instead of silently. The stale literal is corrected
against the emitting echo.
Tests: Python suite 653 to 696, OpenCode Vitest 28 to 34. New files
test_plan_selector_binding.py, test_root_mode_floor.py and
test_plan_doctor_classification.py; new arms in test_resolver_parity.py,
test_resolve_plan_dir.py, test_resolver_plan_root_pin.py and
test_hermes_first_class.py. Five existing tests asserted the fall-through and
were rewritten to assert the refusal, keeping their original security
assertion. Each suite carries a control arm: two first drafts passed on
fixtures that could not have failed, a gate assertion over a plan with every
phase complete and a parallel-write-guard assertion over a plan with nothing
checked to lose.
OpenCode plugin: a failed session lookup is no longer cached (it pinned
the session to the server directory for the rest of the process, and
could record a subagent as a parent); unknown sessions are never gated.
The pwf_init, pwf_status and pwf_check tools resolve the same root as
the hooks, so PWF_PLAN_ROOT and PLANNING_DISABLED apply to them too
(a broken pin or the opt-out is an explicit error). chat.message injects
at most once per message. A rootless Windows pin ("\foo") is refused
like the shell does. Ledger lines are counted like grep -c "" so the
gate counters stay interchangeable with the shell. The attestation temp
file is process-unique. The link-escape path is now tested.
Both the OpenCode and the Hermes resolver now fall through when PLAN_ID
names a slug that does not resolve, like resolve-plan-dir.sh; before,
a stale exported PLAN_ID blacked out injection for the whole session.
docs/opencode.md claimed that npx skills add -g installs the skill to
~/.config/opencode/skills/; it installs to ~/.agents/skills/, which
OpenCode reads natively alongside ~/.claude/skills/,
~/.config/opencode/skills/ and the project-local .agents/skills/,
.claude/skills/ and .opencode/skills/. The page is rewritten around the
real paths and the plugin route (opencode.json "plugin", commands, the
gate and its limits, session catchup, oh-my-opencode and superpowers
notes kept). The .opencode skill's restore-context snippets and file
location table probe every location, and an OpenCode Notes section
explains that the hooks: frontmatter is a Claude Code convention OpenCode
ignores.
README, MIGRATION.md, docs/installation.md and the English SKILL.md
host tier tables now say what each OpenCode install gets: the native
plugin is Tier 2 (follow-up inject), skill-only installs stay notify
only.
Reported by @luyanfeng in #235.
New npm package opencode-planning-with-files under
.opencode/packages/opencode-planning-with-files/ (TypeScript, Vitest):
- chat.message appends the framed active plan (plan head, normalized
progress tail, findings pointer) to every user message, or a
once-per-turn ambiguity notice when a direct child project carries its
own live plan
- tool.execute.after appends the progress reminder to write, edit,
patch, multiedit and apply_patch output
- experimental.session.compacting keeps the plan pointer and attestation
hash in the compaction context
- event session.idle runs the completion gate in gated mode and
re-prompts the session with the gate reason through the SDK (Tier 2);
child sessions are never re-prompted, the shared .stop_blocks and
.gate_last_ledger counters and PWF_GATE_CAP bound it
- tools pwf_init (root or .planning/<date>-<slug>/, mode autonomous or
gated with the v3 markers and attestation), pwf_status, pwf_check
The resolver, ambiguity rule, gate decision table, init markers,
attestation check and frame format mirror resolve-plan-dir.sh,
inject-plan.sh, check-complete.sh --gate, init-session.sh and the Hermes
plugin. Each session resolves its plan from its own OpenCode directory.
PWF_PLAN_ROOT fails closed, PLANNING_DISABLED silences every hook.
Also: .opencode/commands/pwf.md and pwf-status.md in OpenCode's command
format, a dogfood entry .opencode/plugins/planning-with-files.ts that
loads the plugin from source when this repository is opened in OpenCode,
.opencode/package.json tracked with the @opencode-ai/plugin dependency,
and a vitest (OpenCode plugin) CI job.
Verified live in OpenCode 1.18.21: plugin loaded, pwf_init/pwf_status/
pwf_check listed, /pwf and /pwf-status listed, a real session message
received the framed plan as a synthetic part.
Version 3.13.0 across the tracked parity set and the ClawHub stage;
CHANGELOG entry; Hermes Agent listed under Tier 2 in the English
SKILL.md host tables and MIGRATION.md.
Install routes, the collapsible platform, language and FAQ sections and
the new feature sections come first; benchmarks, the repository layout,
releases, community and documentation form the reference half at the
bottom. Nothing removed, only moved. New sections: "Built for
long-running agent tasks" (what breaks long runs and which shipped
mechanism answers it), "Hermes Agent: first-class support (CLI and
Desktop)" and "Multi-agent runs: orchestrators, workers and subagents",
each grounded in mechanisms that already ship. Hermes rows in the tier
and command tables, the v3.13.0 releases row, and the Hermes install
route in Quick Install.
The native Hermes plugin now resolves .planning/<slug>/ plans with the
same rules as the shell route (PLAN_ID, .planning/.active_plan, newest
slug, legacy root; slug validation, containment, BOM-tolerant pointer,
the inject-plan.sh nested-root ambiguity rule with a once-per-turn
notice, PWF_PLAN_ROOT fail-closed, PLANNING_DISABLED), answers Hermes'
pre_verify hook with the completion gate in gated mode using the
check-complete.sh decision table and state files, registers /pwf,
/pwf-status and /plan-status through ctx.register_command (the Markdown
command files were never loaded by Hermes), registers the bundled skill
from the plugin's own directory, creates gated and autonomous plans with
attestation from planning_with_files_init(name, mode), evaluates
completion in Python when sh is absent, and ships a shell-hook bridge
for Hermes config-file hooks.
The .hermes SKILL.md fixes the native Windows home (%LOCALAPPDATA%\hermes)
and documents the plugin surface; docs/hermes.md is rewritten from the
Hermes 0.19.1 source and a live install (CLI, Desktop, Windows, gate
limits, shell-hook route, import-agent migration); AGENTS.md, llms.txt
and docs/installation.md carry the Hermes route.
Verified live through Hermes' own plugin manager and scanned SAFE by
Hermes' skills-guard. An adversarial second-model review found four
divergences from the shell route in the first build; all are fixed and
covered by tests/test_hermes_first_class.py, including a differential
test against inject-plan.sh.