Skip to content

feat(core): shared host-hook protocol (S8a) - #161

Open
BrainerVirus wants to merge 12 commits into
mainfrom
feature/core-hooks
Open

BrainerVirus wants to merge 12 commits into
mainfrom
feature/core-hooks

Conversation

@BrainerVirus

@BrainerVirus BrainerVirus commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Slice S8a of the workit-next design (docs/workit-next/design.md §1, §5).

What changes

  • packages/workit-core/src/hooks/: one host-hook implementation, exported as @brainervirus/workit-core/hooks.
    • protocol.ts: the protocol types.
    • handle.ts: handleHook, dispatchHook, and the fail policy.
    • context.ts: moved from core/session-context.ts. It holds one unfinished-task offer and one session predicate for every host.
    • policy.ts: branch policy for shell commands.
    • descriptor.ts: the per-host capability descriptor and capabilitiesFor.
    • run.ts: the stdin→stdout hook process, which can also run as run.ts <host>.
    • hosts/{claude-code,codex,cursor,opencode,pi}.ts: field mappings and descriptors.
  • Lenient hook parsing (D17). Parsers ignore unknown keys and check only the keys they need. Before this change, the Codex parser rejected any unknown key and denied PreToolUse on any parse error, so a Codex release that added one field would have denied every tool call. Now a pre-tool parse failure denies only when the host's descriptor sets failClosed (Cursor, which is unchanged). Other hosts pass the call through and write a diagnostic to stderr.
  • Capability descriptors replace codexCapabilities, cursorCapabilities, piCapabilities and OpenCode's inline V2 array. The old function names are kept as thin wrappers. An undocumented axis never backs an enforced capability, and partial support lowers enforced to agent_guided.
  • Reader tolerance for stored records. On read, parseStoredRecord drops exactly the keys zod reports as unrecognized and parses again. Any other schema violation still fails. Writes stay strict. A field added by a newer runtime therefore no longer turns older readers into recovery_required.
  • hostSchema gains claude_code.
  • Cursor beforeShellExecution now enforces branch policy. It denies with exit 2 and an agent_message that includes the correction; before, it always returned allow. subagentStart keeps its Cursor-only worker-assignment path.
  • Codex, Pi and OpenCode adapters now call core/hooks. Behavior is otherwise unchanged.

Acceptance (design §5, S8a)

Given/When/Then Test
protected main, Claude PreToolUse git checkout -b main piped to the hook → permissionDecision:"deny", protected_ref; no host receives allow test/workit-core/hooks/claude-code.test.ts (spawns run.ts claude_code; checks that every Claude fixture and the Codex PreToolUse output never contain "allow")
same command on Cursor beforeShellExecution → deny with exit 2; the parity table includes cursor and claude_code test/workit-cursor/task-hooks.test.ts (spawned hook, exit 2); route-denial-parity.test.ts now has cursor and claude_code rows; hooks/protocol.test.ts checks every host against the design §1.3 mapping table plus native fixtures
Codex PreToolUse with an unknown key → policy still evaluated hooks/lenient-parse.test.ts
undocumented axis → never enforced hooks/descriptor.test.ts (sets each axis of each descriptor to undocumented; also snapshots the descriptors)
SessionStart source:"compact" → <workit-task-context> hooks/claude-code.test.ts (Claude and Codex)
hook bundle loads no doctor/setup modules hooks/bundle.test.ts (metafile check; no core barrel either)

Verification (measured, Node 24.20.0)

  • bun run lint, bun run format:check, tsc --noEmit, bun run knip (63 core files reachable from 26 entries): all clean.
  • Full bun test after bun run build, with origin/main merged (including test: cull duplicate/tautological tests, fix env-brittle checks, split unit and packaging tiers #155): 1449 pass, 0 fail. CI is green on all 15 checks, including Windows. The first CI run found a Windows-only fixture-path bug, fixed in 4526aee.
  • Behavior parity:
    • Capability arrays: before deleting the old code, codexCapabilities (both surfaces), cursorCapabilities (all 32 availability combinations), piCapabilities and the OpenCode V2 array were compared against the new descriptors. All 100 comparisons were identical.
    • Hook outputs: the old and new handleCodexHook / handleCursorHook were compared on 54 payloads (session start with and without tasks, shell commands, writes, subagents, malformed input). There were 0 differences, apart from the intended Cursor deny.
    • All existing adapter suites pass: codex, cursor, pi, opencode-v2.
  • Mutation checks: I broke the code nine ways and the matching tests failed each time:
    • strict unknown-key parsing
    • Cursor shell mapped to a non-shell event (which removes its branch policy)
    • Claude render emitting allow
    • undocumented counted as usable
    • the compact context dropped
    • tolerance disabled
    • the core barrel imported into a hook
    • Codex made fail-closed
    • the offer limited to startup removed

Changes to existing tests (intended behavior changes)

  • codex/cli.test.ts: an unknown key now parses ok:true (D17).
  • protocol-ergonomics.test.ts: a record from a newer runtime that only adds a field is now readable. The "upgrade Workit" message is still checked, on a newer record that actually violates the schema.

Deviations from the design

  • Bundle size. The ≤300 KB target is not met. Minified hook bundles are about 586–591 KB; before this change they were 570 KB, and about 980 KB unminified. Most of that is the task engine plus zod, which compact task context needs; even core modules without task-engine come to about 459 KB. Hook bundles no longer load the core barrel, doctor or setup. A regression ceiling of 620 KB is pinned until the graph is split.
  • Protocol additions.
    • A tool.pre event for non-shell pre-tool gates (Codex/Claude PreToolUse with other tools, Cursor preToolUse). It always returns none.
    • Descriptor fields label, context.task (session-bound | single-active, so Codex and Cursor keep showing the workspace's single active task), interaction.{questions,writeBoundary} (Pi and OpenCode need them for enforced claims), and capabilities (the rules).
  • Not built. effective() and the knip.json change were not needed.
  • Fixtures. The files in test/fixtures/hooks/<host> were written from documented and design-verified field lists, not captured from live sessions.
  • Fail-policy details.
    • Cursor subagentStop/preCompact and unknown-event parse failures now return {}. Before, they returned a deny JSON with exit 0, which was not blocking.
    • A malformed Codex payload or invalid JSON now passes through (design §1.2).

Risks

  • Field loss on write. An older writer that mutates a record drops fields only newer runtimes know. The fields are optional, so this is safe, but it is data loss.
  • Cursor {permission:"allow"}. Cursor still answers {permission:"allow"} for compliant shell commands and allowed pre-tool calls; this contract is unchanged and existing tests pin it. Whether Cursor's allow skips its own approval prompt has not been verified.
  • Per-process offer state. The unfinished-task offer is remembered per process, as before. For process hosts it is effectively per invocation.

Review fixes (016f0c7, 31214d8)

  • M1. Codex shell policy needs only cwd and the command (D17). A Codex PreToolUse gate now requires only cwd, tool_name and the command. Every other field is optional, including model, session, turn, transcript, tool-use id and permission mode; an unknown permission mode or start source is accepted. tool_input.command may be an argv array: [sh, -c|-lc, script] checks the script, and other arrays are joined with quoting. A test covers permission_mode:"weird", argv commands, bash -lc, unified-exec, and the bare {cwd, tool_name, tool_input} payload; all of them still deny main.
  • M2. Critical fields fail closed. Records may carry a top-level critical: string[] listing dotted paths, with * for array items. A reader that would strip a critical path, or its parent, fails with "requires fields this Workit cannot read …; upgrade Workit". Mutations read first, so they refuse and the file stays untouched. Recovery parses snapshots in rewrite mode and refuses any record it could only read by stripping keys. The rule is documented in parseStoredRecord and in design §0 feat: editable templates + canonical multi-platform rules (Phase 2) #7: a new field must be ignorable, or the writer must declare it critical. Tests cover both cases, the parent-path case, and recovery.
  • L1. Union branch choice. When stripping under a plain z.union, the branch that drops the fewest keys now wins. The synthetic repro {a,b,future} now keeps b.
  • L2. Claude PowerShell. It gets the same shell policy as Bash. The design's hooks.json now has a PowerShell PreToolUse matcher.
  • L3. Cursor multi-root workspaces. Workspaces with several roots are now accepted. beforeShellExecution checks the policy against the payload cwd when it is set, otherwise against workspace_roots[0]. A test uses a strict workspace glob that matches only a nested repository.
  • Claude PreCompact. The notice is now carried in the top-level systemMessage.
  • T1. Surviving mutants. New tests cover unified-exec, union branch selection, the path.isAbsolute check on cwd, and argv handling.
  • Cursor allow. Unchanged, since it was already there. A design §0 feat: flow rails — issue prompt fix + subagent-driven enforcement #11 follow-up asks to verify against a live Cursor whether {permission:"allow"} skips Cursor's own approval prompt.

Measured after merging #153 (Node 24.20.0).

  • bun run lint (type-aware), format:check, tsc and knip are clean.
  • Full bun test after build: 1458 pass, 0 fail.
  • Mutation checks on the fixes: 10 of 10 were killed:
    • the permission-mode enum restored
    • the critical check removed
    • recovery switched to read mode
    • first-branch union selection
    • PowerShell dropped
    • Cursor cwd ignored (the first version of that test let this mutant survive; the test now uses a nested-only workspace glob and catches it)
    • systemMessage dropped
    • unified-exec dropped
    • the isAbsolute check removed
    • argv support removed
  • CI: all checks green. Windows CA-06, an isolated npm install, timed out at 122 s on the first run; it passed on rerun.

Re-verification residuals (7c2f29e)

  • L-a. Shell wrappers. commandText scans past leading options to the first script flag (-…c, -Command, /c), so ["bash","-e","-c",s], ["powershell.exe","-NoProfile","-Command",s] and ["cmd","/c",s] are checked as s. Scanning stops at the first positional argument, so ["bash","script.sh","-c","x"] is not unwrapped.
  • L-b. Array indices in critical paths. Numeric segments in declared critical paths are normalized to *, so a record declaring intent.data.authorityRefs.0.future fails closed.
  • L-c. Codex sessions without an id. The context no longer includes the writer-acquire actor clause. Such sessions are never recorded in the per-process offer set, so they are offered on each startup instead of sharing one empty key.
  • L-d. Cursor subagentStart. Workers are assigned in the workspace root that contains the payload cwd, falling back to the first root. This is the same rule as other events. Test: a multi-root workspace with the task in the second root.

Measured.

  • lint, format, tsc and knip are clean.
  • Full bun test after build: 1463 pass, 0 fail.
  • Mutation checks: 6 of 6 killed:
    • a positional argument no longer ends the scan
    • the -Command//c flags removed
    • index normalization removed
    • the actor clause always included
    • id-less sessions sharing one offer key
    • Cursor subagentStart using workspace_roots[0]
  • CI: all checks green.

🤖 Generated with Claude Code

BrainerVirus and others added 10 commits October 3, 2026 16:41
Records written by a newer runtime may carry keys an older reader does not
know. Strict record schemas rejected them as recovery_required, so one
additive field would brick every older host reading the same store (D17).
Reads now drop exactly the keys zod reports as unrecognized and parse
again; any other violation still fails, and writes stay strict.

hostSchema gains claude_code so the Claude Code adapter does not need to
touch the task contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
core/hooks is the one implementation of session start (including the
compact restore), per-turn context, pre-shell branch policy, and subagent
start for every host. Hosts only parse native payloads and render
decisions. Parsers ignore unknown keys and check only the keys a mapping
needs. A pre-tool parse failure denies only on hosts whose descriptor is
fail-closed; other events fail open with a diagnostic.

Each host declares a descriptor of what it supports, with citations.
capabilitiesFor derives engine capabilities from it; an undocumented axis
never backs an enforced capability. session-context moves to
hooks/context with one unfinished-task offer and session predicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex rejected any unknown payload key and denied PreToolUse on every
parse error, so a Codex release that adds a field would deny every tool
call. The Codex hook is now a thin mapping onto core/hooks: unknown keys
are ignored and a broken payload passes through with a stderr diagnostic.

Pi and OpenCode take branch policy, session context, the unfinished-task
offer, and their capabilities from core/hooks and their descriptors. The
capability arrays are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hooks-cursor.json registered beforeShellExecution, but the hook always
answered allow. It now maps onto core/hooks and denies direct branch
creation onto a protected or noncompliant name with exit 2, like the other
hosts. subagentStart keeps its Cursor-only worker assignment path.

Hook bundles now import core/hooks and direct modules instead of the core
barrel, and a test pins that they load no doctor or setup code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Substituting a raw Windows cwd into fixture JSON produced invalid escape
sequences; the path is now JSON-escaped. Bundle metafile inputs are
normalized to forward slashes before matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	packages/workit-codex/hooks/workit-hook.ts
#	packages/workit-cursor/hooks/workit-hook.ts
…ripped records

Reader tolerance dropped every unknown key, which removed the fail-closed
upgrade guard for fields an older reader must not ignore. Records may now
list such paths in a top-level critical array: a reader that would strip
one fails with the upgrade message, so it neither acts on nor rewrites the
record. Recovery copies a snapshot back verbatim, so it refuses any record
it could only read by dropping fields.

Under a plain union, stripping now keeps the branch that drops the fewest
keys instead of the first that parses, so a key one branch knows is never
lost. The design records the rule for new fields and the Claude PowerShell
matcher, and a follow-up to verify Cursor's allow live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex parsing still rejected payloads over fields branch policy never
reads, such as a permission mode Codex adds later, and the parse failure
then let protected-branch creation through. A Codex shell gate now needs
only cwd and the command; the command may be an argv array, and a shell
-c script is checked as the script.

Claude treats PowerShell like Bash, and shows the PreCompact notice through
systemMessage. Cursor accepts multi-root workspaces and checks a shell
command against its own cwd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BrainerVirus and others added 2 commits October 3, 2026 18:05
… sessions

- commandText finds the script after the first script flag, so
  [bash, -e, -c, s], [powershell.exe, -Command, s] and [cmd, /c, s] are
  checked as s.
- Numeric segments in declared critical paths match any array element, so
  authorityRefs.0.future fails closed like authorityRefs.*.future.
- A Codex session without an id gets no writer-acquire actor, and id-less
  sessions no longer share one per-process offer key.
- Cursor subagentStart assigns workers in the workspace root that contains
  the payload cwd, the same rule as other events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant