Six numbered plan documents covering: - 01 Hook IO Discipline (#2376) - 02 Spawn-Contract Templating (#2377) - 03 Worker / Daemon Lifecycle Hardening (#2378) - 04 Installer Failure Transparency (#2379) - 05 Observer SDK Tool Enforcement (#2380) - 06 Worker Env Isolation (#2381) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
53 KiB
Hook IO Discipline — Stop Conflating stdout / stderr / Exit Codes
Goal: Establish a single, typed IO discipline across claude-mem's 6 lifecycle hooks (Setup, SessionStart, UserPromptSubmit, PreToolUse:Read, PostToolUse, Stop). Every emit point must declare an intent (DIAGNOSTIC, MODEL_CONTEXT, USER_HINT, BLOCKING_FEEDBACK, EXIT_SIGNAL) and route through a wrapper module that maps intent → channel correctly. Fix issue #2292 (recordWorkerUnreachable diagnostic silently swallowed) along the way.
Net effect:
process.stderr.writeis no longer monkey-patched at the boundary. Diagnostic stderr (logger, fail-loud counter, bun-runner #2188) reaches the user as the hook contract intends.- Handlers become pure: they return a
HookResultand never touch process streams directly. - A single
src/cli/hook-io.tsmodule is the only place that callsconsole.log,process.stderr.write, andprocess.exitfor the hook execution path.hookCommandorchestrates that module. - Adapter
formatOutputshapes are validated once at the emit boundary. - The CLAUDE.md exit-code strategy (worker/hook errors exit 0 to prevent Windows Terminal tab pileup) is preserved verbatim and codified in the wrapper.
- A grep-based CI check forbids direct stream writes in
src/cli/handlers/**andsrc/cli/adapters/**.
Out of scope:
- Logger redesign (the existing
src/utils/logger.tskeeps its API; only its stderr fallback path changes call site). - Worker-side HTTP API responses (this plan is only about the hook execution edge).
- bun-runner.js stdin handling (issue #2188 diagnostic stays — only its emit channel is reviewed).
- Subagent / Task tool propagation (orthogonal).
Phase 0 — Documentation Discovery (already complete)
The orchestrator did the discovery during planning; subsequent phases cite by line number rather than re-deriving. The audit table in Phase 1 is the canonical artifact — treat it as the source of truth for "where things write right now."
Allowed APIs / patterns to copy
| Item | Location | What to copy |
|---|---|---|
| Existing exit-code constants | src/shared/hook-constants.ts:15–20 |
HOOK_EXIT_CODES = { SUCCESS: 0, FAILURE: 1, BLOCKING_ERROR: 2, USER_MESSAGE_ONLY: 3 } — no new constants needed. |
Adapter formatOutput contract |
src/cli/types.ts:39–42 and src/cli/adapters/claude-code.ts:27–41 |
formatOutput(result: HookResult): unknown — the new emitModelContext MUST call this and JSON.stringify the result, exactly once. |
HookResult shape (already supports systemMessage) |
src/cli/types.ts:23–37 |
systemMessage is the existing field for user-visible advisory. New work adds an explicit userHint only if systemMessage semantics differ per platform — see Phase 3. |
| Logger fallback write | src/utils/logger.ts:271,274 |
process.stderr.write happens here when log file write fails and as the normal stderr fallback when no log file is configured. Phase 4 routes both through emitDiagnostic. |
| Fail-loud counter | src/shared/worker-utils.ts:401–417 |
recordWorkerUnreachable is the canonical "must surface to user" path. The threshold-triggered branch (lines 410–415) is the only current call site that legitimately writes to stderr + exits non-zero. The plan keeps that intent but routes through emitBlockingError. |
HookCommandOptions.skipExit test seam |
src/cli/hook-command.ts:8–10 |
Tests use this to assert exit codes without calling process.exit. The new wrapper preserves it. |
| Plan format & verification-checklist style | plans/2026-04-29-installer-streamline.md |
Phase numbering, edit-by-line-number specificity, explicit "Anti-pattern guards" per phase. |
Anti-patterns / methods that DO NOT exist (avoid inventing)
- There is no existing
hook-io.tsmodule — Phase 3 creates it. - There is no
userHintfield onHookResulttoday (src/cli/types.ts). Phase 3 decides whether to add one or reusesystemMessage. Recommendation: reusesystemMessage— every adapter already routes it. AddinguserHintwould force adapter changes for no gain. console.warnandconsole.infoare NOT used insrc/cli/; do not introduce them. Stay withlogger.*for diagnostics.process.stdout.writeis NOT used in the hook path; the only stdout emit isconsole.log(JSON.stringify(...))inhook-command.ts:66,86,94. Do not switch toprocess.stdout.write—console.logadds the trailing newline that Claude Code's parser expects.- Do not "fix" the swallow by deleting it without an audit. Phase 1 first, Phase 2 second. Some libraries imported by handlers (e.g.
@anthropic-ai/sdkretries) DO write to stderr unprompted, and that is what the swallow was originally guarding against. - The exit-0-on-error strategy is non-negotiable per CLAUDE.md ("Worker/hook errors exit with code 0 to prevent Windows Terminal tab accumulation. The wrapper/plugin layer handles restart logic."). Any phase that proposes exit 1/2 must justify it as either (a) blocking feedback the model must see, or (b) the existing fail-loud counter that already does this.
File inventory used by this plan
| File | Lines | Disposition |
|---|---|---|
src/cli/hook-command.ts |
117 | Edited heavily (Phase 2, Phase 3) |
src/cli/hook-io.ts |
NEW | CREATED (Phase 3) |
src/cli/handlers/user-message.ts |
38 | Edited (Phase 4 — drop direct stderr write) |
src/cli/handlers/context.ts |
83 | Light edit (Phase 4 — annotate intent, no behavior change) |
src/cli/handlers/observation.ts |
54 | Light edit (Phase 4 — confirm pure) |
src/cli/handlers/file-context.ts |
248 | Light edit (Phase 4 — confirm pure) |
src/cli/handlers/session-init.ts |
124 | Light edit (Phase 4 — confirm pure) |
src/cli/handlers/summarize.ts |
90 | Light edit (Phase 4 — confirm pure) |
src/cli/adapters/claude-code.ts |
43 | Light edit (Phase 4 — confirm formatOutput returns plain object) |
src/cli/adapters/codex.ts, cursor.ts, gemini-cli.ts, raw.ts, windsurf.ts, codex-file-context.ts |
misc | Confirm-only (Phase 4 audit pass) |
src/shared/worker-utils.ts |
~600 | Edited (Phase 4 — recordWorkerUnreachable routes through emitBlockingError) |
src/utils/logger.ts |
~310 | Edited (Phase 4 — stderr fallback routes through emitDiagnostic) |
src/services/worker-service.ts |
~900 | Light edit (Phase 4 — case 'hook' block at 846–864 documents intent only; no behavior change) |
plugin/scripts/bun-runner.js |
206 | Edited (Phase 4 — diagnostic emit annotated, exit-code policy documented inline) |
plugin/scripts/version-check.js |
70 | Edited (Phase 4 — extract emitUpgradeHint into shared helper or document why dual-channel stays) |
plugin/hooks/hooks.json |
88 | Confirm-only (Phase 4 — verify echo statements and exit 1 on missing _P are EXIT_SIGNAL intent) |
tests/hook-io.test.ts |
NEW | CREATED (Phase 5) |
tests/hook-stream-discipline.test.ts |
NEW | CREATED (Phase 5) |
scripts/check-hook-io-discipline.cjs |
NEW | CREATED (Phase 6 — grep-based CI check) |
CLAUDE.md |
misc | Edited (Phase 6 — Exit Code Strategy section) |
Phase 1 — Audit every emit point
What to implement: A complete table of every process.stderr.write, process.stdout.write, console.log, console.error, console.warn, process.exit, and throw reachable from a hook execution. The audit is the deliverable; no code changes in this phase. The table goes into the PR description (and is summarized below).
Files to grep:
src/cli/hook-command.ts
src/cli/handlers/*.ts
src/cli/adapters/*.ts
src/shared/worker-utils.ts
src/shared/hook-constants.ts
src/services/worker-service.ts # only the `case 'hook':` arm at 846–864
src/utils/logger.ts
plugin/scripts/bun-runner.js
plugin/scripts/version-check.js
plugin/scripts/worker-cli.js
plugin/hooks/hooks.json # the bash dispatchers' echo + exit 1
Audit columns (one row per call site):
| File:Line | Call | Intent (declared) | Channel (current) | Audience (real) | Gap |
|---|
Intent vocabulary (use these exact tokens):
DIAGNOSTIC— operator-visible logs, never reaches the model. Stderr.MODEL_CONTEXT— content the assistant should consume. Stdout JSON only.USER_HINT— short advisory shown to the human user (e.g. "OAuth token stale"). Stderr ORsystemMessagefield, NEVER mixed with model context.BLOCKING_FEEDBACK— error message Claude Code feeds back to the model (per its hook contract: stderr + exit 2).EXIT_SIGNAL— pure status, no payload (e.g.process.exit(0)).
Pre-populated audit findings (the orchestrator already grepped — copy this into the PR and verify each row before Phase 2):
| File:Line | Call | Intent (declared) | Channel (current) | Audience (real) | Gap |
|---|---|---|---|---|---|
src/cli/hook-command.ts:66 |
console.log(JSON.stringify(output)) |
MODEL_CONTEXT | stdout | model | ok |
src/cli/hook-command.ts:69 |
process.exit(exitCode) |
EXIT_SIGNAL | exit | OS | ok |
src/cli/hook-command.ts:75–76 |
replace process.stderr.write with no-op |
(defensive guard) | n/a | n/a | #2292: swallows ALL stderr including legitimate diagnostic + fail-loud |
src/cli/hook-command.ts:86,94 |
console.log(JSON.stringify({continue:true,suppressOutput:true})) |
MODEL_CONTEXT | stdout | model | ok |
src/cli/hook-command.ts:88,96,103 |
process.exit(SUCCESS) |
EXIT_SIGNAL | exit | OS | ok per CLAUDE.md |
src/cli/hook-command.ts:108 |
logger.error('HOOK', …) |
DIAGNOSTIC | stderr (via logger) | operator | swallowed by lines 75–76 |
src/cli/hook-command.ts:110 |
process.exit(BLOCKING_ERROR) |
BLOCKING_FEEDBACK | exit (no stderr msg!) | model | gap: model gets exit 2 but no stderr message — useless |
src/cli/hook-command.ts:114 |
restore process.stderr.write |
(cleanup) | n/a | n/a | only runs after exit; restore is dead code in production |
src/cli/handlers/user-message.ts:27 |
process.stderr.write("…Claude-Mem Context Loaded…") |
USER_HINT (banner) | stderr | user (Claude Code shows stderr inline) | mixed concern: handler is not pure; bypasses HookResult shape |
src/cli/handlers/context.ts:74–80 |
return hookSpecificOutput.additionalContext + systemMessage |
MODEL_CONTEXT + USER_HINT | result object | model + user | ok in shape, but no enforcement that handlers can't ALSO write stderr |
src/cli/handlers/observation.ts |
(pure — only logger.* calls) |
DIAGNOSTIC | stderr (logger) | operator | swallowed by hookCommand wrapper |
src/cli/handlers/file-context.ts |
(pure — only logger.* calls) |
DIAGNOSTIC | stderr (logger) | operator | swallowed |
src/cli/handlers/session-init.ts |
(pure — only logger.* calls) |
DIAGNOSTIC | stderr (logger) | operator | swallowed |
src/cli/handlers/summarize.ts |
(pure — only logger.* calls) |
DIAGNOSTIC | stderr (logger) | operator | swallowed |
src/cli/adapters/claude-code.ts:27–41 |
formatOutput returns plain object |
(data shape) | n/a | model (via stdout JSON) | ok |
src/shared/worker-utils.ts:411 |
process.stderr.write('claude-mem worker unreachable for N consecutive hooks.\n') |
BLOCKING_FEEDBACK / USER_HINT (the one message that MUST surface) | stderr | user + model | #2292: swallowed by hookCommand wrapper |
src/shared/worker-utils.ts:414 |
process.exit(BLOCKING_ERROR) |
BLOCKING_FEEDBACK | exit 2 | model | exits 2 but stderr is swallowed → model gets nothing |
src/shared/worker-utils.ts:469,479… |
logger.warn('SYSTEM', …) |
DIAGNOSTIC | stderr (logger) | operator | swallowed |
src/utils/logger.ts:271 |
process.stderr.write('[LOGGER] Failed to write to log file…') |
DIAGNOSTIC | stderr | operator | swallowed when called inside hook |
src/utils/logger.ts:274 |
process.stderr.write(logLine + '\n') |
DIAGNOSTIC | stderr | operator | swallowed when called inside hook |
src/services/worker-service.ts:850–853 |
console.error('Usage: …') + process.exit(1) |
DIAGNOSTIC + EXIT_SIGNAL | stderr + exit 1 | operator (CLI misuse, not a hook) | ok — this is CLI usage, not the hook lifecycle |
plugin/scripts/bun-runner.js:172 |
console.error(diagnostic) (issue #2188 empty-stdin) |
USER_HINT (visible) + DIAGNOSTIC (logged) | stderr | user (Claude Code shows it) | ok — bun-runner is BEFORE hookCommand swallow; runs in its own node process |
plugin/scripts/bun-runner.js:186 |
console.error('[bun-runner] failed to persist diagnostic…') |
DIAGNOSTIC | stderr | operator | ok |
plugin/scripts/bun-runner.js:191 |
process.exit(0) |
EXIT_SIGNAL | exit 0 | OS | ok per CLAUDE.md (Windows Terminal rationale documented inline at lines 174–178) |
plugin/scripts/bun-runner.js:196–198 |
console.error('Failed to start Bun…') + process.exit(1) |
BLOCKING_FEEDBACK | stderr + exit 1 | user | gap: exit 1 violates exit-0-on-error policy. Bun-not-found is a user problem, not a hook bug — exit 1 is arguably correct here, but CLAUDE.md says exit 0. Decide in Phase 2. |
plugin/scripts/bun-runner.js:204 |
`process.exit(code | 0)` | EXIT_SIGNAL | exit | |
plugin/scripts/version-check.js:24,32 |
console.log(JSON.stringify({hookSpecificOutput:…})) for Codex; console.error(message) for default |
MODEL_CONTEXT (Codex path) / USER_HINT (default path) | stdout / stderr | model / user | ok in intent, but the dual-channel branch is duplicated logic — extract or document |
plugin/hooks/hooks.json Setup line 11 |
echo "claude-mem: version-check.js not found" >&2; exit 1 |
BLOCKING_FEEDBACK (resolution failure) | stderr + exit 1 | user | gap: exit 1 here is correct (we cannot run; user MUST see). Document the exception. |
plugin/hooks/hooks.json other hook lines |
echo "claude-mem: plugin scripts not found" >&2; exit 1 |
BLOCKING_FEEDBACK | stderr + exit 1 | user | same — document exception |
plugin/hooks/hooks.json SessionStart line 24 |
echo '{"continue":true,"suppressOutput":true}' |
MODEL_CONTEXT | stdout | model | ok |
Verification checklist:
- Re-run each grep listed above and confirm row count matches the audit table
- For every row marked "gap", Phase 2/3/4 has a concrete edit
- Audit table is committed to the PR description (or as
plans/01-hook-io-discipline-audit.md)
Anti-pattern guards:
- Do not skip rows because they're in third-party code paths — if they're imported by a handler, they're in scope.
- Do not collapse rows with "(misc logger calls)". Each
logger.warn/logger.errorinside a handler is one row, because the swallow affects each one. - Do not extend the audit to non-hook code paths (e.g.
npx-cli/,transcripts/,viewer/). Out of scope.
Phase 2 — Fix #2292 stderr swallow
What to implement: Replace the blanket no-op (src/cli/hook-command.ts:75–76) with a typed, opt-in capture buffer. Diagnostic writes from logger.* and recordWorkerUnreachable flow through unimpeded; the original "guard against unsolicited library stderr" intent is preserved by capturing unmarked writes to a buffer and discarding them on graceful exit (or flushing them on blocking error).
Decision: Option (c) — capture buffer with typed bypass
Three options were considered:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| (a) Drop swallow entirely | Simplest. Fixes #2292 immediately. | Reverts the guard against noisy library writes (e.g. SDK retry warnings, node:util deprecation prints). Those WILL leak to model context if any handler imports a chatty library. |
Reject — leaves a regression door open. |
| (b) Stream-filter proxy via sentinel marker | Preserves selective filtering. | Requires every legitimate diagnostic site to opt in (logger, fail-loud, bun-runner). Sentinel detection is fragile; a missed prefix = silent loss. | Reject — too easy to forget the sentinel. |
| (c) Capture buffer + typed bypass | All process.stderr.write calls go to a buffer instead of the real fd. The buffer is FLUSHED to real stderr only on emitDiagnostic/emitBlockingError (i.e. when claude-mem CHOSE to surface). On graceful exit (exit 0, success), buffer is dropped (current behavior preserved). |
Slightly more state. | Accept — gives us the swallow behavior on success and the surface behavior on legitimate diagnostics, with no per-call sentinel discipline. |
Edit 2A — Refactor hookCommand to use a buffered stderr
File: src/cli/hook-command.ts
- Lines 75–76: replace direct no-op assignment with a call into the new
installHookStderrBuffer()helper fromsrc/cli/hook-io.ts(created in Phase 3). Helper returns a{ flush(): void; restore(): void; drop(): void }controller. - Lines 113–115: replace
process.stderr.write = originalStderrWritewithcontroller.restore(). - Lines 100–106 (worker-unavailable branch): call
controller.flush()BEFOREprocess.exit(SUCCESS)so anyrecordWorkerUnreachablewrite that fired during this hook surfaces. (Currently therecordWorkerUnreachablepath runs INSIDEexecuteWithWorkerFallback, which is invoked from the handler call insideexecuteHookPipeline— so the write happens during the buffered window. Without flush, it stays buffered.) - Lines 108–112 (catch-all error branch): call
controller.flush()BEFOREprocess.exit(BLOCKING_ERROR)so the model receives thelogger.errorline as blocking feedback per Claude Code's hook contract (exit 2 + stderr).
Edit 2B — Document the rationale at the call site
Add a comment block immediately above the new installHookStderrBuffer() call in hookCommand:
// Hook IO Discipline (issue #2292):
// We BUFFER stderr during handler execution so that unsolicited writes from
// third-party libraries don't leak into model context. The buffer is FLUSHED
// only when we choose to surface (logger errors at the catch-all branch,
// fail-loud counter from worker-utils, blocking-error path). Successful exits
// drop the buffer — preserving the original "quiet on success" behavior.
//
// To bypass the buffer for a specific write, use emitDiagnostic / emitBlockingError
// from src/cli/hook-io.ts. Direct process.stderr.write calls are buffered.
Edit 2C — Decide bun-runner.js exit-1-on-Bun-not-found
(From audit row bun-runner.js:196–198.) The current code exits 1 when Bun cannot be spawned. Per CLAUDE.md exit-code strategy, hook errors should exit 0. But this is before any hook runs — Bun is the prerequisite, not the hook itself.
Decision: Keep exit 1 for the Bun-not-found case (and exit 1 for the missing-arg usage at line 83). Justification: this is BLOCKING_FEEDBACK to the user (their environment is broken), not a transient hook failure. Document the exception inline:
// EXCEPTION to CLAUDE.md exit-0-on-error: Bun-not-found is a user environment
// problem, not a hook execution failure. Surfacing exit 1 here forces Claude
// Code to display the stderr message rather than silently retrying.
Verification checklist:
grep -n "process.stderr.write = " src/cli/hook-command.tsreturns no direct assignment (the no-op replacement is gone)installHookStderrBufferis the ONLY symbol that mutatesprocess.stderr.writeinsrc/- Manual: invoke a hook with
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD=1, kill the worker, observe the "claude-mem worker unreachable" message on stderr (it was previously swallowed)
Anti-pattern guards:
- Do not flush the buffer on every handler call. Buffering is the whole point — flush only when claude-mem code explicitly chooses to surface.
- Do not move the buffer install into
executeHookPipeline— it must wrap the catch block too. - Do not export the buffer controller from
hook-io.tsfor handler use. Handlers don't need it; they useemitDiagnosticinstead.
Phase 3 — Create src/cli/hook-io.ts (typed IO discipline)
What to implement: A new module that owns every stdout/stderr/exit emission for the hook execution path. hookCommand is its only consumer; handlers stay pure.
File to create: src/cli/hook-io.ts
API surface (these names are used by Phase 2 and Phase 4 — do not rename)
import type { PlatformAdapter, HookResult } from './types.js';
import { HOOK_EXIT_CODES } from '../shared/hook-constants.js';
export interface HookStderrBuffer {
flush(): void; // write buffered bytes to real stderr
drop(): void; // discard buffered bytes
restore(): void; // un-replace process.stderr.write (idempotent)
}
/**
* Replace process.stderr.write with a buffered writer. Diagnostics from
* emitDiagnostic / emitBlockingError bypass the buffer. Direct
* process.stderr.write calls (including library noise) are captured.
*/
export function installHookStderrBuffer(): HookStderrBuffer;
/**
* Operator-visible diagnostic. Always reaches real stderr (bypasses the
* Phase 2 buffer). Use for logger fallback, fail-loud counter, and any
* "we want this in the operator's terminal" message.
*/
export function emitDiagnostic(line: string): void;
/**
* Emit the model-bound JSON payload to stdout, exactly once per hook
* invocation. Calls adapter.formatOutput(result) and JSON.stringify.
* Throws if called twice in the same hook (caught by hookCommand).
*/
export function emitModelContext(adapter: PlatformAdapter, result: HookResult): void;
/**
* User-visible advisory routed via the HookResult.systemMessage path. This
* function does NOT write to a stream — it returns a HookResult mutation
* that the caller MUST merge into the result before emitModelContext.
* Reason: systemMessage is platform-specific (claude-code surfaces it,
* codex ignores it) and must go through the adapter.
*/
export function withUserHint(result: HookResult, hint: string): HookResult;
/**
* Stderr message + exit 2. The model receives `msg` per Claude Code's hook
* contract. Flushes the stderr buffer first so any logger.error lines
* preceding this call also reach the model.
*/
export function emitBlockingError(msg: string, options?: { skipExit?: boolean }): never | void;
/**
* Exit 0 with no further output. The Phase 2 buffer is DROPPED (the
* Windows Terminal tab-accumulation rationale: silent success).
* Use this for the worker-unavailable success path.
*/
export function exitGraceful(options?: { skipExit?: boolean }): never | void;
Implementation notes (for the implementer; do NOT inline in the plan)
installHookStderrBufferkeeps aBuffer[]and a single bound bypass channel (the originalprocess.stderr.write).emitDiagnosticwrites via the bypass; everything else accumulates in the array.emitModelContextuses a module-scopedhasEmittedboolean flag. ThrowsError('emitModelContext called twice')on second call. Reset byhookCommandbetween invocations (or, more cleanly:hookCommandconstructs a fresh emitter via a factory — see optional refinement below).emitBlockingError: flushes buffer, writesmsgto real stderr, exits with code 2 unlessskipExitis set. Test seam matcheshookCommand's existingskipExitoption.exitGraceful: drops buffer, callsprocess.exit(0). NO stdout write — caller is expected to have already calledemitModelContextif a JSON envelope is required (e.g.{continue:true,suppressOutput:true}).withUserHint: returns{ ...result, systemMessage: hint }(or merges ifresult.systemMessageis already set — in that case append with\n\n).
Optional refinement: factory pattern
If global mutable state in hook-io.ts is unwelcome, expose a factory:
export interface HookEmitter {
emitDiagnostic(line: string): void;
emitModelContext(adapter: PlatformAdapter, result: HookResult): void;
withUserHint(result: HookResult, hint: string): HookResult;
emitBlockingError(msg: string, options?: { skipExit?: boolean }): void;
exitGraceful(options?: { skipExit?: boolean }): void;
buffer: HookStderrBuffer;
}
export function createHookEmitter(): HookEmitter;
hookCommand calls createHookEmitter() once per invocation. This avoids the "called twice" race in long-running test contexts. Prefer this pattern.
Edit 3A — Update hookCommand to use the emitter
File: src/cli/hook-command.ts
After Phase 2's buffer integration, switch the console.log(JSON.stringify(...)) at lines 66, 86, 94 to emitter.emitModelContext(adapter, result) (or emitter.emitModelContext(adapter, { continue: true, suppressOutput: true }) for the early-return cases).
The process.exit(...) calls become emitter.exitGraceful(options) and emitter.emitBlockingError(message, options) respectively. The skipExit option propagates from HookCommandOptions.
The logger.error('HOOK', …) at line 108 stays — it routes through emitDiagnostic because the logger's stderr fallback (Phase 4 edit to logger.ts) does so.
Verification checklist:
src/cli/hook-io.tsexports the API surface verbatim (names match Phase 4 imports)grep -n "console.log\|console.error\|process.stderr.write\|process.exit" src/cli/hook-command.tsreturns ONLY commented-out historical references and theskipExitoption propagationtsc --noEmitcleanemitModelContexttest: call twice → throws
Anti-pattern guards:
- Do not export
installHookStderrBufferfrom the package's top-level barrel. It's an internal-to-cli helper. - Do not add a
emitUserHintthat writes to stderr — that path is nowwithUserHint+ adapter routing. Direct stderr USER_HINT bypasses platform shape contracts. - Do not let
emitDiagnosticaccept structured data ({key: value}) — it takes a string. Keeplogger.*as the structured-logging path;emitDiagnosticis the raw stderr escape hatch.
Phase 4 — Migrate call sites
What to implement: Concrete edits per file. Group by direction (handlers, adapters, shared utils, plugin scripts) so the implementer can work file-by-file.
Edit 4A — src/cli/handlers/user-message.ts (drop direct stderr write)
Currently lines 27–33 do process.stderr.write("…Claude-Mem Context Loaded…") to surface the banner inline. This is a USER_HINT that bypasses HookResult.
Replace with: Build the banner string, return it via systemMessage on the HookResult. The formatOutput of the claude-code adapter already maps systemMessage to the platform JSON shape (see src/cli/adapters/claude-code.ts:31–33,37–39).
Specifically:
- Drop lines 27–33 entirely.
- Build the same string as
bannerText. - Return
{ exitCode: HOOK_EXIT_CODES.SUCCESS, systemMessage: bannerText }.
This makes the handler PURE. The adapter routes systemMessage to the right field; Claude Code surfaces it identically to a stderr write but inside the contract.
Edit 4B — src/cli/handlers/context.ts (annotate intent, no behavior change)
The dual-emit (hookSpecificOutput.additionalContext for model + systemMessage for user) is already correct and pure. Add a docstring at the top of the handler explicitly calling out the two intents:
// IO discipline:
// - additionalContext → MODEL_CONTEXT (model consumes; passed via stdout JSON)
// - systemMessage → USER_HINT (user-visible; passed via stdout JSON systemMessage field)
// This handler MUST NOT call process.stderr.write or console.* directly.
No code change beyond the docstring. Confirm logger.* calls (lines 43) are the only stderr emissions and they route through the buffer (which is fine — they're DIAGNOSTIC).
Edit 4C — src/cli/handlers/{observation,file-context,session-init,summarize}.ts (confirm pure)
For each, add the same IO-discipline docstring as 4B. Audit confirms these handlers are already pure (only logger.* and throw for unrecoverable input, which hookCommand catches and routes through emitBlockingError).
Edit 4D — src/cli/adapters/*.ts (confirm formatOutput shape)
Audit each adapter's formatOutput and confirm:
- Returns a plain object (not a promise, not a string).
- Every field corresponds to a documented Claude Code / Codex / Cursor / Gemini hook output field.
- Does not call
console.*orprocess.*.
This is a CONFIRM-ONLY pass. The adapters are clean today; the goal is to lock that in via the Phase 6 grep CI check.
Edit 4E — src/shared/worker-utils.ts:401–417 (recordWorkerUnreachable)
Current behavior:
- Increments persistent counter.
- If counter ≥ threshold: writes
'claude-mem worker unreachable for N consecutive hooks.\n'to stderr, thenprocess.exit(BLOCKING_ERROR).
Edit: Replace the direct process.stderr.write + process.exit with emitBlockingError from src/cli/hook-io.ts:
import { emitBlockingError } from '../cli/hook-io.js';
// …
if (next.consecutiveFailures >= threshold) {
emitBlockingError(
`claude-mem worker unreachable for ${next.consecutiveFailures} consecutive hooks.`
);
}
return next.consecutiveFailures;
emitBlockingError flushes the buffered stderr (so any preceding logger.warn lines reach the operator) and exits 2.
This is the #2292 fix. The diagnostic is no longer swallowed because emitBlockingError writes via the bypass channel.
Note on the dependency direction: src/shared/ importing from src/cli/ is unusual (shared usually has fewer deps). If this is a problem, invert: move hook-io.ts to src/shared/hook-io.ts. The orchestrator favors leaving it in src/cli/ because the emitter is conceptually part of the hook pipeline; if the linter/architecture rules complain, move it.
Edit 4F — src/utils/logger.ts:271,274 (fallback stderr writes)
Current behavior: when logFilePath is null OR appendFileSync throws, write to process.stderr.write. Inside a hook this hits the buffer.
Edit: Replace both process.stderr.write calls with emitDiagnostic from src/cli/hook-io.ts. Logger remains usable outside the hook context (worker daemon, CLI commands) because emitDiagnostic falls back to process.stderr.write (bypass channel) which is unaffected when the buffer is not installed.
import { emitDiagnostic } from '../cli/hook-io.js';
// line 271
emitDiagnostic(`[LOGGER] Failed to write to log file: ${error instanceof Error ? error.message : String(error)}\n`);
// line 274
emitDiagnostic(logLine + '\n');
Same dependency-direction caveat as 4E. If src/utils/ → src/cli/ is forbidden by lint, move hook-io.ts to src/shared/.
Edit 4G — src/services/worker-service.ts:846–864 (case 'hook')
Confirm-only edit. The case 'hook': arm currently does:
console.error('Usage: …')+process.exit(1)— ok, this is CLI usage feedback, not a hook execution path.logger.warnif worker fails to start — ok.await hookCommand(platform, event)— ok; hookCommand owns its own IO from here.
Add a comment block above line 846:
// IO discipline: this case is the entry point to the hook execution path.
// Once hookCommand is invoked, src/cli/hook-io.ts owns all stdout/stderr/exit.
// Pre-hookCommand error paths (missing args, worker failed to start) are
// CLI-style: console.error + exit 1 is acceptable because these errors
// occur BEFORE the buffered window opens.
Edit 4H — plugin/scripts/bun-runner.js (annotate)
No behavior change. Add a comment block above line 159 explaining that the issue-#2188 diagnostic is intentionally USER_HINT-on-stderr + persistent-marker-file (dual channel), and exit 0 is intentional per CLAUDE.md.
The existing comment at lines 174–178 already documents this; expand it slightly to reference Phase 1's intent vocabulary:
// IO discipline:
// - stderr write here is a USER_HINT (Claude Code surfaces it inline).
// - CAPTURE_BROKEN marker file is a DIAGNOSTIC durable signal for the next session.
// - exit 0 is the EXIT_SIGNAL per CLAUDE.md (Windows Terminal tab management);
// the marker file, not the exit code, is the durable failure signal.
For lines 196–198 (Bun-not-found exit 1), see Phase 2 Edit 2C — keep exit 1 and document the exception inline.
Edit 4I — plugin/scripts/version-check.js (extract emitUpgradeHint helper or document)
The current emitUpgradeHint function (lines 22–33) already handles the dual-channel emit (Codex JSON-on-stdout vs default stderr). This is the canonical pattern.
Edit: Add a comment block explaining the pattern, and rename the function to emitVersionHint for consistency with Phase 3's emitDiagnostic/emitUserHint vocabulary if desired (low priority).
// IO discipline:
// - Codex hook contract: hookSpecificOutput JSON on stdout (MODEL_CONTEXT path)
// - All other platforms: bare stderr (USER_HINT — Claude Code surfaces inline)
// This dual-channel emit is the version-check.js way of being polyglot
// across hook frameworks. Other plugin scripts should copy this pattern
// rather than invent a new one.
No code change required beyond the comment. (If Phase 6's CI check flags this file, add it to the allowlist as documented dual-channel.)
Edit 4J — plugin/hooks/hooks.json (confirm bash dispatcher echo+exit)
Confirm-only. The echo "claude-mem: … not found" >&2; exit 1 pattern in each hook's bash command is correct BLOCKING_FEEDBACK: if the plugin scripts can't be located, the user MUST see the error and Claude Code MUST stop trying to run the hook.
This is the only legitimate exit 1 in the hook execution path. Document the rationale in CLAUDE.md (Phase 6).
Verification checklist:
grep -n "process.stderr.write\|console\\.error\|console\\.log" src/cli/handlers/returns ONLY logger calls (none)grep -n "process.stderr.write\|console\\.error\|console\\.log" src/cli/adapters/returns nothingrecordWorkerUnreachablecallsemitBlockingError—grep -n "emitBlockingError" src/shared/worker-utils.tsreturns 1+ hitslogger.tsfallback usesemitDiagnostic—grep -n "emitDiagnostic" src/utils/logger.tsreturns 2 hitstsc --noEmitcleannpm run build-and-syncsucceeds
Anti-pattern guards:
- Do not introduce
process.stdout.writeanywhere. Stay withconsole.log(whichemitModelContextuses internally). - Do not change
bun-runner.jsexit codes — theexit 0semantics are load-bearing for Windows Terminal. - Do not "tidy"
version-check.jsby collapsing the dual-channel emit. The Codex/Claude Code split is intentional. - Do not add a stderr write inside
withUserHint— it's a pure result-mutation function. - Do not migrate
worker-service.ts:850–853toemitDiagnostic— those are CLI usage errors, not hook errors. They run before the buffer is installed.
Phase 5 — Test plan
What to implement: Two new test files. The first (hook-io.test.ts) exercises the wrapper module in isolation. The second (hook-stream-discipline.test.ts) exercises the 6 hooks end-to-end as a child process and asserts stream separation.
Edit 5A — tests/hook-io.test.ts (unit tests for hook-io.ts)
Cover, with the existing test framework (likely bun:test or vitest per package.json scripts):
installHookStderrBuffer()returns a controller; subsequentprocess.stderr.write('hello')calls do NOT reach a piped stderr capture.- After
controller.flush(), the previously-buffered bytes appear on real stderr. - After
controller.drop(), the buffer is empty and a subsequentflush()writes nothing. controller.restore()un-replacesprocess.stderr.write; subsequent writes go to real stderr immediately.emitDiagnostic('x\n')writes to real stderr even when the buffer is installed (bypass channel works).emitModelContext(adapter, result)callsadapter.formatOutput(result)andJSON.stringifys the result to stdout.emitModelContextcalled twice throwsError('emitModelContext called twice').withUserHint(result, 'hi')returns a new object withsystemMessage: 'hi'.withUserHint(result, 'hi')on a result that already hassystemMessage: 'world'returnssystemMessage: 'world\n\nhi'(or whatever the chosen merge rule is — pin it down in Phase 3 implementation).emitBlockingError('boom', { skipExit: true })writes'boom\n'to real stderr and does NOT exit.emitBlockingErrorflushes the buffer before its own write (assert ordering by interleaving buffered writes).exitGraceful({ skipExit: true })drops the buffer (assert by checking that buffered bytes never reach captured stderr).
Edit 5B — tests/hook-stream-discipline.test.ts (integration: 6 hooks × 3 scenarios)
Spawn the built plugin/scripts/worker-service.cjs as a child process via child_process.spawn, pipe a JSON payload to stdin, capture stdout and stderr separately, and assert the contract.
Test harness sketch:
import { spawn } from 'child_process';
import { join } from 'path';
interface HookOutcome {
stdout: string;
stderr: string;
exitCode: number | null;
}
async function runHook(
platform: 'claude-code' | 'codex' | 'cursor' | 'gemini-cli' | 'raw',
event: 'context' | 'session-init' | 'observation' | 'file-context' | 'summarize' | 'user-message',
stdinJson: object,
envOverrides: Record<string, string> = {},
): Promise<HookOutcome> {
const workerCjs = join(__dirname, '..', 'plugin', 'scripts', 'worker-service.cjs');
const child = spawn(process.execPath, [workerCjs, 'hook', platform, event], {
env: { ...process.env, ...envOverrides },
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdin.end(JSON.stringify(stdinJson));
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on('data', (c) => stdout.push(c));
child.stderr.on('data', (c) => stderr.push(c));
const exitCode = await new Promise<number | null>((resolve) => child.on('close', resolve));
return {
stdout: Buffer.concat(stdout).toString('utf-8'),
stderr: Buffer.concat(stderr).toString('utf-8'),
exitCode,
};
}
Test matrix (6 hooks × 3 scenarios = 18 tests):
For each event ∈ {context, session-init, observation, file-context, summarize, user-message}:
| Scenario | Setup | Assertions |
|---|---|---|
| (a) Success | Worker running, valid input | exitCode === 0. stdout parses as JSON. stdout contains no diagnostic strings ('[INFO]', '[WARN]', 'claude-mem worker unreachable'). stderr may contain DIAGNOSTIC lines — that's fine. The MODEL_CONTEXT field structure matches the adapter's formatOutput shape. |
| (b) Worker unreachable below threshold | Worker not running, CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD=10, counter starts at 0 |
exitCode === 0. stdout is empty OR contains {continue:true, suppressOutput:true}. stderr is silent (no fail-loud message yet). |
| (c) Worker unreachable at fail-loud threshold | Worker not running, CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD=1, counter forced to threshold |
exitCode === 2. stderr contains 'claude-mem worker unreachable for'. This is the #2292 regression test. Today this test FAILS (stderr is empty); after Phase 2/4 it passes. |
Additional cross-cutting tests:
| Scenario | Setup | Assertions |
|---|---|---|
| (d) Adapter rejection (invalid cwd) | Send { cwd: '/no/such/path' } |
exitCode === 0. stdout parses as {continue:true, suppressOutput:true}. stderr contains the warn line about adapter rejection. |
| (e) Unknown event | Run hook claude-code blarghhh |
exitCode === 0 (the dispatcher returns a no-op handler — see worker-service.cjs cne function). stderr contains 'Unknown event type: blarghhh'. |
| (f) Unrecoverable handler error | Mock the worker to throw on /api/sessions/observations |
exitCode === 2. stderr contains 'Hook error:' from logger.error. Model receives the error message per the hook contract. |
| (g) Banner from user-message handler | Run user-message with worker up | stdout JSON contains systemMessage field with the banner text (NOT process.stderr.write of the banner). stderr does NOT contain the banner emoji 📝 line. This is the Edit 4A regression test. |
| (h) Stream separation invariant | Run any hook that returns hookSpecificOutput | stderr MUST NOT contain the substring of additionalContext. The model-bound text must not leak to stderr. |
Edit 5C — Tab-accumulation rationale
The Windows Terminal tab-accumulation behavior cannot be tested cross-platform in CI. Add a comment block at the top of hook-stream-discipline.test.ts:
// Windows Terminal tab-accumulation rationale (per CLAUDE.md):
// Hooks that fail with non-zero exit codes cause Windows Terminal to keep
// the tab open in an error state, which accumulates over time. The exit-0-
// on-error policy is intentional. These tests assert exit codes match the
// policy: SUCCESS for transient errors, BLOCKING_ERROR (2) only for the
// fail-loud counter or unrecoverable handler errors.
The decision point from the spec ("worker unreachable at fail-loud threshold — still exit 2 or exit 0 per current behavior — call out the discrepancy and decide"): exit 2 stays. The fail-loud counter exists precisely BECAUSE silent retries (exit 0) hide systemic failures. After N consecutive failures the user MUST see the message, and the model MUST stop trying. Exit 2 is the right contract for that one threshold-tripped path. Single-failure paths remain exit 0.
Edit 5D — Optional: fuzz test for double emit
Spin up createHookEmitter, call emitModelContext twice, assert it throws. Already covered by 5A test 7; only add as a fuzz harness if the implementer wants more confidence around the global-state-vs-factory choice.
Verification checklist:
tests/hook-io.test.tsexists; all 12 unit tests passtests/hook-stream-discipline.test.tsexists; all 18 + 5 = 23 integration tests pass- The #2292 regression test (scenario c) FAILS on a checkout of
main(audit baseline) and PASSES on this branch - The user-message banner test (scenario g) FAILS on
mainand PASSES on this branch npm testis green
Anti-pattern guards:
- Do not test
process.exitcalls by mockingprocess.exit— useskipExit: trueoption onemitBlockingError/exitGracefuland assert return values. - Do not skip platform variants (
codex,cursor,gemini-cli). Stream separation must hold for all adapters; codex's JSON-on-stdout for upgrade hints is a known dual-channel pattern. - Do not test handler internals (worker calls, DB writes) in
hook-stream-discipline.test.ts. Stream contract only. - Do not run integration tests against a real worker by default — mock or run a fixture worker on a test port.
Phase 6 — Docs + lint
What to implement: Update CLAUDE.md, add a grep-based CI check, add a hook author guide section.
Edit 6A — Update CLAUDE.md Exit Code Strategy section
Locate the existing section ("Exit Code Strategy"). Replace the body with:
## Exit Code Strategy
Claude-mem hooks use specific exit codes per Claude Code's hook contract:
- **Exit 0**: Success or graceful shutdown (Windows Terminal closes tabs).
- **Exit 1**: Pre-hook environment failure (Bun missing, plugin scripts not found). Reserved for the bash dispatchers in `plugin/hooks/hooks.json` and the bun-runner.js Bun-not-found path. Hook handlers themselves NEVER exit 1.
- **Exit 2**: Blocking error fed to the model. Reserved for (a) the fail-loud counter in `recordWorkerUnreachable` after N consecutive failures, and (b) unrecoverable handler errors in `hookCommand`'s catch-all.
**Philosophy**: Worker/hook errors exit with code 0 to prevent Windows Terminal tab accumulation. The wrapper/plugin layer handles restart logic. ERROR-level logging is maintained for diagnostics.
### Hook IO Discipline
All stdout / stderr / exit emits during a hook execution route through `src/cli/hook-io.ts`:
- `emitDiagnostic(line)` — operator-visible stderr (logger fallback, version-check, fail-loud).
- `emitModelContext(adapter, result)` — JSON to stdout via the platform adapter's `formatOutput`. Exactly once per hook.
- `withUserHint(result, hint)` — user-visible advisory, returned via `HookResult.systemMessage`. Adapters route per-platform.
- `emitBlockingError(msg)` — stderr message + exit 2. The model receives `msg`.
- `exitGraceful()` — exit 0, drops any buffered stderr.
Handler authors: write your handler as a pure function returning `HookResult`. **Never call `process.stderr.write`, `console.log`, `console.error`, or `process.exit` from a handler.** A grep-based CI check enforces this in `src/cli/handlers/**` and `src/cli/adapters/**`.
The Phase 2 stderr buffer (installed by `installHookStderrBuffer`) captures unsolicited library writes during handler execution. Buffered bytes are dropped on `exitGraceful` and flushed on `emitDiagnostic` / `emitBlockingError`. Use `emitDiagnostic` whenever you'd want a message visible in the operator's terminal.
Edit 6B — Add a hook author guide
New file: docs/architecture/hook-author-guide.md (or co-locate in docs/public/hooks-architecture.mdx if that file exists — discovery showed it does, per the prior installer-streamline plan).
Cover:
- The 6 lifecycle hooks and what each is for.
- The intent vocabulary (DIAGNOSTIC, MODEL_CONTEXT, USER_HINT, BLOCKING_FEEDBACK, EXIT_SIGNAL).
- The
hook-io.tsAPI with examples. - The exit-code policy (with Windows Terminal rationale).
- Common mistakes (calling
console.errordirectly, returning twice from a handler, forgetting to setexitCodeon the result). - How to write a new handler in 15 lines (template).
Edit 6C — Add grep-based CI check
New file: scripts/check-hook-io-discipline.cjs
Logic:
- Walk
src/cli/handlers/**/*.tsandsrc/cli/adapters/**/*.ts. - For each file, fail if any of these patterns appear (outside of comments):
process.stderr.writeprocess.stdout.writeconsole.logconsole.errorconsole.warnconsole.infoprocess.exit
- Allowlist: none. Handlers and adapters are pure.
- Walk
src/utils/logger.ts,src/shared/worker-utils.ts. For each:- Allow
process.stderr.writeONLY if the same line includes// HOOK_IO_BYPASS(or the file is on the allowlist by full path). - This is a defense in depth — Phase 4 routes them through
emitDiagnostic, so post-migration the patterns shouldn't appear at all. The allowlist is for any future emergency bypass.
- Allow
- Return non-zero on any violation, with file:line and the offending pattern.
Wire into package.json as npm run lint:hook-io and into the CI pipeline (or as a pre-push hook).
Edit 6D — Update README/docs index if needed
If README.md mentions hook authoring or has a "for contributors" section, link to the new author guide. Otherwise no edit.
Verification checklist:
node scripts/check-hook-io-discipline.cjsexits 0 on this branchnode scripts/check-hook-io-discipline.cjsexits non-zero if you intentionally addconsole.error('test')tosrc/cli/handlers/observation.tsCLAUDE.md's Exit Code Strategy section reflects the new helper functions- Hook author guide exists and covers all 6 lifecycle hooks
npm testis still green- CI pipeline runs the new lint check (visible in PR checks)
Anti-pattern guards:
- Do not allowlist individual handlers or adapters. The whole point is the rule has no exceptions for those directories.
- Do not write the lint check in TypeScript — it should run before any compile step. Pure CJS or pure JS via
nodedirectly. - Do not edit CHANGELOG.md (per CLAUDE.md).
- Do not add
// eslint-disablestyle escape hatches to the new ESLint rule (if ESLint chosen over grep). Use// HOOK_IO_BYPASSonly on the deliberate bypass paths inworker-utils.ts/logger.tsif any remain.
Phase 7 — Build, test, manual verify
Edit 7A — Build
npm run build-and-sync
This rebuilds plugin/scripts/worker-service.cjs from src/services/worker-service.ts (which transitively pulls in the new src/cli/hook-io.ts and the migrated handlers).
Edit 7B — Run tests
npm test
Expected outcomes:
- All 12 hook-io.test.ts unit tests pass.
- All 23 hook-stream-discipline.test.ts integration tests pass.
- All pre-existing tests still pass.
npm run lint:hook-ioexits 0.
Edit 7C — Manual verification
-
#2292 regression check:
- Stop the worker:
claude-mem stop(or kill the daemon). - Set
CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD=1in the shell. - In Claude Code, send a prompt that triggers UserPromptSubmit.
- Expected: stderr message
claude-mem worker unreachable for 1 consecutive hooks.is visible. - Pre-fix behavior: message was silently swallowed.
- Stop the worker:
-
Banner relocation check (user-message handler):
- Trigger a user-message hook on claude-code platform.
- Expected: banner ("📝 Claude-Mem Context Loaded …") appears via
systemMessagein the JSON envelope, NOT as a stderr write. - Inspect via
claude-mem hook claude-code user-message < fixture.jsonand observe stdout vs stderr separately.
-
Windows Terminal tab behavior:
- On Windows (or WSL with Windows Terminal): kill the worker, send several prompts under threshold, observe NO tab accumulation (exit 0 path).
- Once the threshold trips, observe the tab stays open with the error message visible (exit 2 path) — this is desired.
-
Adapter rejection path:
- Send a hook payload with an invalid
cwd(e.g./nonexistent/blah). - Expected: stdout JSON
{continue:true,suppressOutput:true}, exit 0, stderr has the warn line.
- Send a hook payload with an invalid
-
Logger fallback:
- Set
CLAUDE_MEM_DATA_DIRto a path the user cannot write to. - Trigger any hook.
- Expected: the
[LOGGER] Failed to write to log file:message appears on stderr (viaemitDiagnostic).
- Set
Edit 7D — Commit and PR
Per the standard PR creation flow. Don't auto-merge; this is a cross-cutting refactor that benefits from a review loop.
Verification checklist:
npm run build-and-syncexits 0npm testexits 0npm run lint:hook-ioexits 0- All 5 manual checks pass
- PR description includes the Phase 1 audit table
Anti-pattern guards:
- Do not skip the manual #2292 regression check. The whole point of this PR is that the diagnostic surfaces.
- Do not bump the version — version-bump skill handles that separately.
- Do not merge without confirming Windows behavior (or noting in the PR that Windows verification is deferred to a Windows reviewer).
Summary of file changes
| Type | Path | Phase |
|---|---|---|
| Created | src/cli/hook-io.ts |
3 |
| Edited | src/cli/hook-command.ts |
2, 3 |
| Edited | src/cli/handlers/user-message.ts |
4A |
| Edited | src/cli/handlers/context.ts |
4B |
| Edited | src/cli/handlers/observation.ts |
4C |
| Edited | src/cli/handlers/file-context.ts |
4C |
| Edited | src/cli/handlers/session-init.ts |
4C |
| Edited | src/cli/handlers/summarize.ts |
4C |
| Confirm-only | src/cli/adapters/*.ts |
4D |
| Edited | src/shared/worker-utils.ts |
4E |
| Edited | src/utils/logger.ts |
4F |
| Edited | src/services/worker-service.ts |
4G |
| Edited | plugin/scripts/bun-runner.js |
4H, 2C |
| Edited | plugin/scripts/version-check.js |
4I |
| Confirm-only | plugin/hooks/hooks.json |
4J |
| Created | tests/hook-io.test.ts |
5A |
| Created | tests/hook-stream-discipline.test.ts |
5B |
| Edited | CLAUDE.md |
6A |
| Created | docs/architecture/hook-author-guide.md (or section in hooks-architecture.mdx) |
6B |
| Created | scripts/check-hook-io-discipline.cjs |
6C |
| Edited | package.json (add lint:hook-io script) |
6C |
Estimated diff: +650 / −80 lines (net addition; mostly new tests and the wrapper module).
Risk assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| Buffer flush ordering bug (logger.error fires AFTER emitBlockingError so the error message lands before the diagnostic context) | Medium | Phase 5 test (b) interleaves a buffered write and asserts ordering |
src/shared/ → src/cli/ import causes circular dep |
Medium | If the dep cycle is real, move hook-io.ts to src/shared/. Decision deferred to implementation. |
| Tests rely on a running worker; CI doesn't have one | High | Use executeWithWorkerFallback's natural fall-through (worker unreachable returns the fallback object); test scenarios (b) and (c) rely on this. Scenarios (a) and (g) need a fixture worker — sketch one in tests/fixtures/fake-worker.ts. |
| Phase 4 dependency direction breaks build | Medium | tsc --noEmit after each handler edit catches this immediately. |
console.log inside emitModelContext adds extra newlines that break Codex's JSON parser |
Low | Codex adapter test in scenario (a) catches this. If broken, switch to process.stdout.write(JSON.stringify(...) + '\n'). |
| The Windows Terminal tab-accumulation rationale gets argued away in review | Medium | CLAUDE.md preserves it; Phase 6 doc edit reinforces. Cite the rationale in PR description. |
Review checklist (for the reviewer)
- Audit table (Phase 1) covers every emit point in scope
hookCommand's blanket no-op is gone; replaced with a typed bufferrecordWorkerUnreachablecallsemitBlockingError(#2292 fixed)- No handler or adapter calls
process.*orconsole.*directly emitModelContextis the ONLY stdout JSON emitter; called exactly once per hook- CLAUDE.md Exit Code Strategy section reflects the new helpers
- CI lint check is wired and green
- All 18 + 5 integration tests pass (3 scenarios × 6 hooks + 5 cross-cutting)
- Manual #2292 reproduction confirms the diagnostic surfaces
- Windows Terminal tab-accumulation rationale is preserved (no exit-1-on-recoverable-error in handler paths)