plans/01-hook-io-discipline.md
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.write is no longer monkey-patched at the boundary. Diagnostic stderr (logger, fail-loud counter, bun-runner #2188) reaches the user as the hook contract intends.HookResult and never touch process streams directly.src/cli/hook-io.ts module is the only place that calls console.log, process.stderr.write, and process.exit for the hook execution path. hookCommand orchestrates that module.formatOutput shapes are validated once at the emit boundary.src/cli/handlers/** and src/cli/adapters/**.Out of scope:
src/utils/logger.ts keeps its API; only its stderr fallback path changes call site).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."
| 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. |
hook-io.ts module — Phase 3 creates it.userHint field on HookResult today (src/cli/types.ts). Phase 3 decides whether to add one or reuse systemMessage. Recommendation: reuse systemMessage — every adapter already routes it. Adding userHint would force adapter changes for no gain.console.warn and console.info are NOT used in src/cli/; do not introduce them. Stay with logger.* for diagnostics.process.stdout.write is NOT used in the hook path; the only stdout emit is console.log(JSON.stringify(...)) in hook-command.ts:66,86,94. Do not switch to process.stdout.write — console.log adds the trailing newline that Claude Code's parser expects.@anthropic-ai/sdk retries) DO write to stderr unprompted, and that is what the swallow was originally guarding against.| 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) |
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 OR systemMessage field, 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:
plans/01-hook-io-discipline-audit.md)Anti-pattern guards:
logger.warn/logger.error inside a handler is one row, because the swallow affects each one.npx-cli/, transcripts/, viewer/). Out of scope.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).
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. |
hookCommand to use a buffered stderrFile: src/cli/hook-command.ts
installHookStderrBuffer() helper from src/cli/hook-io.ts (created in Phase 3). Helper returns a { flush(): void; restore(): void; drop(): void } controller.process.stderr.write = originalStderrWrite with controller.restore().controller.flush() BEFORE process.exit(SUCCESS) so any recordWorkerUnreachable write that fired during this hook surfaces. (Currently the recordWorkerUnreachable path runs INSIDE executeWithWorkerFallback, which is invoked from the handler call inside executeHookPipeline — so the write happens during the buffered window. Without flush, it stays buffered.)controller.flush() BEFORE process.exit(BLOCKING_ERROR) so the model receives the logger.error line as blocking feedback per Claude Code's hook contract (exit 2 + stderr).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.
(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.ts returns no direct assignment (the no-op replacement is gone)installHookStderrBuffer is the ONLY symbol that mutates process.stderr.write in src/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:
executeHookPipeline — it must wrap the catch block too.hook-io.ts for handler use. Handlers don't need it; they use emitDiagnostic instead.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
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;
installHookStderrBuffer keeps a Buffer[] and a single bound bypass channel (the original process.stderr.write). emitDiagnostic writes via the bypass; everything else accumulates in the array.emitModelContext uses a module-scoped hasEmitted boolean flag. Throws Error('emitModelContext called twice') on second call. Reset by hookCommand between invocations (or, more cleanly: hookCommand constructs a fresh emitter via a factory — see optional refinement below).emitBlockingError: flushes buffer, writes msg to real stderr, exits with code 2 unless skipExit is set. Test seam matches hookCommand's existing skipExit option.exitGraceful: drops buffer, calls process.exit(0). NO stdout write — caller is expected to have already called emitModelContext if a JSON envelope is required (e.g. {continue:true,suppressOutput:true}).withUserHint: returns { ...result, systemMessage: hint } (or merges if result.systemMessage is already set — in that case append with \n\n).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.
hookCommand to use the emitterFile: 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.ts exports the API surface verbatim (names match Phase 4 imports)grep -n "console.log\|console.error\|process.stderr.write\|process.exit" src/cli/hook-command.ts returns ONLY commented-out historical references and the skipExit option propagationtsc --noEmit cleanemitModelContext test: call twice → throwsAnti-pattern guards:
installHookStderrBuffer from the package's top-level barrel. It's an internal-to-cli helper.emitUserHint that writes to stderr — that path is now withUserHint + adapter routing. Direct stderr USER_HINT bypasses platform shape contracts.emitDiagnostic accept structured data ({key: value}) — it takes a string. Keep logger.* as the structured-logging path; emitDiagnostic is the raw stderr escape hatch.What to implement: Concrete edits per file. Group by direction (handlers, adapters, shared utils, plugin scripts) so the implementer can work file-by-file.
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:
bannerText.{ 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.
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).
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).
src/cli/adapters/*.ts (confirm formatOutput shape)Audit each adapter's formatOutput and confirm:
console.* or process.*.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.
src/shared/worker-utils.ts:401–417 (recordWorkerUnreachable)Current behavior:
'claude-mem worker unreachable for N consecutive hooks.\n' to stderr, then process.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.
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/.
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.warn if 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.
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.
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.)
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 nothingrecordWorkerUnreachable calls emitBlockingError — grep -n "emitBlockingError" src/shared/worker-utils.ts returns 1+ hitslogger.ts fallback uses emitDiagnostic — grep -n "emitDiagnostic" src/utils/logger.ts returns 2 hitstsc --noEmit cleannpm run build-and-sync succeedsAnti-pattern guards:
process.stdout.write anywhere. Stay with console.log (which emitModelContext uses internally).bun-runner.js exit codes — the exit 0 semantics are load-bearing for Windows Terminal.version-check.js by collapsing the dual-channel emit. The Codex/Claude Code split is intentional.withUserHint — it's a pure result-mutation function.worker-service.ts:850–853 to emitDiagnostic — those are CLI usage errors, not hook errors. They run before the buffer is installed.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.
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; subsequent process.stderr.write('hello') calls do NOT reach a piped stderr capture.controller.flush(), the previously-buffered bytes appear on real stderr.controller.drop(), the buffer is empty and a subsequent flush() writes nothing.controller.restore() un-replaces process.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) calls adapter.formatOutput(result) and JSON.stringifys the result to stdout.emitModelContext called twice throws Error('emitModelContext called twice').withUserHint(result, 'hi') returns a new object with systemMessage: 'hi'.withUserHint(result, 'hi') on a result that already has systemMessage: 'world' returns systemMessage: '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.emitBlockingError flushes 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).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. |
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.
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.ts exists; all 12 unit tests passtests/hook-stream-discipline.test.ts exists; all 18 + 5 = 23 integration tests passmain (audit baseline) and PASSES on this branchmain and PASSES on this branchnpm test is greenAnti-pattern guards:
process.exit calls by mocking process.exit — use skipExit: true option on emitBlockingError/exitGraceful and assert return values.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.hook-stream-discipline.test.ts. Stream contract only.What to implement: Update CLAUDE.md, add a grep-based CI check, add a hook author guide section.
CLAUDE.md Exit Code Strategy sectionLocate 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.
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:
hook-io.ts API with examples.console.error directly, returning twice from a handler, forgetting to set exitCode on the result).New file: scripts/check-hook-io-discipline.cjs
Logic:
src/cli/handlers/**/*.ts and src/cli/adapters/**/*.ts.process.stderr.writeprocess.stdout.writeconsole.logconsole.errorconsole.warnconsole.infoprocess.exitsrc/utils/logger.ts, src/shared/worker-utils.ts. For each:
process.stderr.write ONLY if the same line includes // HOOK_IO_BYPASS (or the file is on the allowlist by full path).emitDiagnostic, so post-migration the patterns shouldn't appear at all. The allowlist is for any future emergency bypass.Wire into package.json as npm run lint:hook-io and into the CI pipeline (or as a pre-push hook).
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.cjs exits 0 on this branchnode scripts/check-hook-io-discipline.cjs exits non-zero if you intentionally add console.error('test') to src/cli/handlers/observation.tsCLAUDE.md's Exit Code Strategy section reflects the new helper functionsnpm test is still greenAnti-pattern guards:
node directly.// eslint-disable style escape hatches to the new ESLint rule (if ESLint chosen over grep). Use // HOOK_IO_BYPASS only on the deliberate bypass paths in worker-utils.ts / logger.ts if any remain.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).
npm test
Expected outcomes:
npm run lint:hook-io exits 0.#2292 regression check:
claude-mem stop (or kill the daemon).CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD=1 in the shell.claude-mem worker unreachable for 1 consecutive hooks. is visible.Banner relocation check (user-message handler):
systemMessage in the JSON envelope, NOT as a stderr write.claude-mem hook claude-code user-message < fixture.json and observe stdout vs stderr separately.Windows Terminal tab behavior:
Adapter rejection path:
cwd (e.g. /nonexistent/blah).{continue:true,suppressOutput:true}, exit 0, stderr has the warn line.Logger fallback:
CLAUDE_MEM_DATA_DIR to a path the user cannot write to.[LOGGER] Failed to write to log file: message appears on stderr (via emitDiagnostic).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-sync exits 0npm test exits 0npm run lint:hook-io exits 0Anti-pattern guards:
| 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 | 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. |
hookCommand's blanket no-op is gone; replaced with a typed bufferrecordWorkerUnreachable calls emitBlockingError (#2292 fixed)process.* or console.* directlyemitModelContext is the ONLY stdout JSON emitter; called exactly once per hook