diff --git a/docs/_reflections/log.md b/docs/_reflections/log.md new file mode 100644 index 0000000..11d9a72 --- /dev/null +++ b/docs/_reflections/log.md @@ -0,0 +1,34 @@ +# Reflections backlog + +Raw, low-commitment friction observations captured by `/retro` at the end of +sessions. This file is **not** documentation and **not** config — it is the raw +material the harness is built from, incrementally, over many sessions. + +## The loop + +1. **Capture** — `/retro` appends one-line proposals here after each session. Each + names a concrete thing that was not obvious, a time sink, or a wrong assumption, + plus the *kind* of harness change that might address it. +2. **Spot recurrence** — when the same friction shows up across multiple entries, + that recurrence is the signal it's worth acting on. A single hit may be noise. +3. **Triage** — promote a recurring item into the harness: a `CLAUDE.md` note, a + permission in `settings.json`, a new command, a doc, or a refactor. This step is + **human judgment** — `/retro` deliberately does not auto-edit the harness. +4. **Retire** — once promoted (or judged a one-off), remove the entry so this stays + a live to-do list, not an archive. + +## Entry format + +``` +## · · +- **** — )", e.g. "it was not obvious that …; would have helped to know … (→ CLAUDE.md note)"> +``` + +- `` ∈ `init | command | hook | detector | cli | docs | test | build | other` (fixed vocabulary so recurrence is matchable). +- `` ∈ `CLAUDE.md note | permission | command | doc | refactor | none`. + +This file is committed on purpose — a team-shared friction backlog is the point. + +--- + + diff --git a/src/adapter/index.ts b/src/adapter/index.ts index 26cb37e..b8eee5e 100644 --- a/src/adapter/index.ts +++ b/src/adapter/index.ts @@ -92,7 +92,7 @@ const ALL_SUPPORTED: CapabilityMatrix = { /** * Spec-facing Claude installHook. Delegates to `initClaude` and maps * `InitClaudeResult` → `InitResult`: - * - `artifacts`: the three written paths (wrapper, settings, command). + * - `artifacts`: the five written paths (reflect + retro wrappers, their commands, settings.json). * - `settingsBackupPath`: carried through when a pre-existing settings.json * was unparseable and got backed up to `.bak`; `undefined` otherwise. * - `degraded`: always `false` for Claude (the verified branch). @@ -102,10 +102,10 @@ function claudeInstallHook(opts: { cwd: string }): InitResult { const r = initClaude(opts); return { harness: 'claude-code', - artifacts: [r.wrapperPath, r.settingsPath, r.commandPath], + artifacts: [r.wrapperPath, r.settingsPath, r.commandPath, r.retroWrapperPath, r.retroCommandPath], settingsBackupPath: r.settingsBackupPath, degraded: false, - message: `installed harnessgap for Claude Code: ${r.wrapperPath} | ${r.settingsPath} | ${r.commandPath}`, + message: `installed harnessgap for Claude Code (reflect + retro): ${r.wrapperPath} | ${r.settingsPath} | ${r.commandPath} | ${r.retroWrapperPath} | ${r.retroCommandPath}`, }; } diff --git a/src/init/claude.ts b/src/init/claude.ts index d5555b0..0d53a07 100644 --- a/src/init/claude.ts +++ b/src/init/claude.ts @@ -29,6 +29,11 @@ export const CLI_PATH = join(PKG_ROOT, 'dist', 'cli.js'); const WRAPPER_REL = join('.claude', 'harnessgap-stop-hook.js'); const SETTINGS_REL = join('.claude', 'settings.json'); const COMMAND_REL = join('.claude', 'commands', 'reflect.md'); +// /retro artifacts (unconditional-reflection complement to /reflect). The wrapper +// is .cjs — a "type":"module" project treats .js as ESM where `require` is +// undefined. Installed alongside the reflect artifacts under the same .claude/. +const RETRO_WRAPPER_REL = join('.claude', 'harnessgap-retro-hook.cjs'); +const RETRO_COMMAND_REL = join('.claude', 'commands', 'retro.md'); /** * Build the fail-open Stop-hook wrapper source for a given CLI path. Exported @@ -146,6 +151,251 @@ If \`trip === false\` or the session was clean, say so briefly and emit a frame with \`change.kind: "none"\` (no harness change needed). Do not invent friction. `; +// --- /retro: fail-open once-per-session Stop hook + reflection command -------- +// +// /retro complements /reflect: /reflect is detector-gated (fires only when the +// binary's signals trip); /retro fires unconditionally on the agent's lived +// session. init installs BOTH Stop hooks (reflect first, retro second) so a +// tripping session goes to /reflect and the rest get /retro's nudge. The Stop-hook +// stdin/stdout contract is byte-identical across Claude/Qwen/GigaCode (see the +// qwen.ts header), so the retro wrapper is shared and unparameterized; only the +// command's project memory-file name differs (CLAUDE.md / QWEN.md / GIGACODE.md). + +/** + * Build the fail-open retro Stop-hook wrapper source. Companion to /retro: nudges + * the agent to run /retro (or record a clean session) AT MOST ONCE per session. + * Self-contained — no CLI spawn, no parameterization. Exported (like + * {@link buildWrapperSource}) so tests can bake it into a temp file and exercise + * the fail-open / once-per-session contract. + * + * Uses String.raw because the source contains a regex (`\.marker$`) and `\n` + * escapes that would otherwise need double-escaping in a plain template literal. + * The source is written verbatim to `/harnessgap-retro-hook.cjs` (`.cjs` + * because a `"type":"module"` project treats `.js` as ESM where `require` is + * undefined). + */ +export function buildRetroWrapperSource(): string { + return String.raw`#!/usr/bin/env node +// Fail-open Stop hook, companion to the /retro command. Nudges the agent to run +// /retro (or record a clean session) AT MOST ONCE per session. Any fault +// short-circuits to {} so a wrapper error is never read as a block. The +// reflection itself is done by the agent via /retro — this hook only decides +// whether to nudge. +// +// To activate: register it in settings.json as a Stop hook that runs +// 'node '. It is otherwise inert. +// +// Extension is .cjs (not .js) on purpose: a project whose package.json has +// "type": "module" treats .js as ESM, where require is undefined. .cjs is +// CommonJS unconditionally, so the wrapper runs in both CJS and ESM projects. +// +// Logic: +// - read all stdin, JSON.parse (defensive: malformed/non-object -> fail open). +// - malformed/non-object stdin -> {} (cannot read the loop guard -> must allow). +// - stop_hook_active === true -> {} (a Stop hook already blocked this attempt). +// - no session_id AND no transcript_path -> {} (cannot enforce once-per-session). +// - marker file (keyed by sha1(session_id || transcript_path)) already exists -> {}. +// - otherwise create the marker and emit { decision: 'block', reason }. +// - marker write fails -> {} (fail-open: NEVER block on a wrapper fault). +// - top-level try/catch so any throw -> {}. +// Side effect: one small marker file per session under the marker dir (default +// os.tmpdir()/retro-markers; override with $RETRO_MARKERS_DIR). Markers older +// than 30 days are reaped best-effort on each write. +var fs = require('fs'); +var os = require('os'); +var path = require('path'); +var crypto = require('crypto'); + +var REASON = + 'Session-end reflection nudge. If this session had any friction — something ' + + 'that was not obvious, a time sink, a wrong assumption, a file you could not ' + + 'find, a command that surprised you, or a detour you backed out of — run the ' + + '/retro command now to capture each as a one-line proposal in ' + + 'docs/_reflections/log.md. /retro also records a clean session — do not just ' + + 'say "clean" and stop without logging it. Do not invent friction, and do not ' + + 'loop — this nudge fires at most once per session.'; + +// emit() writes the payload then exits inside stdout's flush callback so piped +// output is never truncated by process.exit. It is the ONLY exit path. +function emit(payload) { process.stdout.write(payload, function () { process.exit(0); }); } + +// Best-effort reap of marker files older than 30 days. Fully fail-safe. +function pruneOldMarkers(dir) { + var cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + var entries; + try { entries = fs.readdirSync(dir); } catch (e) { return; } + for (var i = 0; i < entries.length; i++) { + if (!/\.marker$/.test(entries[i])) continue; + var p = path.join(dir, entries[i]); + try { + var st = fs.statSync(p); + if (st.isFile() && st.mtimeMs < cutoff) fs.unlinkSync(p); + } catch (e) { /* best-effort */ } + } +} + +try { + var raw = fs.readFileSync(0, 'utf8'); + var payload = null; + try { payload = JSON.parse(raw); } catch (e) { payload = null; } + + var nudge = true; + if (payload === null || typeof payload !== 'object') nudge = false; + if (nudge && payload.stop_hook_active === true) nudge = false; + if (nudge) { + var key = payload.session_id || payload.transcript_path || ''; + if (!key) { + nudge = false; + } else { + var dir = process.env.RETRO_MARKERS_DIR + ? path.resolve(process.env.RETRO_MARKERS_DIR) + : path.join(os.tmpdir(), 'retro-markers'); + var marker = path.join( + dir, + crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 24) + '.marker' + ); + try { + if (fs.existsSync(marker)) { + nudge = false; + } else { + fs.mkdirSync(dir, { recursive: true }); + try { pruneOldMarkers(dir); } catch (e) { /* best-effort */ } + fs.writeFileSync(marker, String(Date.now())); + } + } catch (e) { + nudge = false; + } + } + } + + if (nudge) { + emit(JSON.stringify({ decision: 'block', reason: REASON }) + '\n'); + } else { + emit('{}\n'); + } +} catch (e) { + emit('{}\n'); +} +`; +} + +/** + * Build the /retro agent-guidance command for a harness, parameterized by the + * project memory-file name (CLAUDE.md / QWEN.md / GIGACODE.md). Single shared + * builder for all three harnesses (the command is identical except memoryFile); + * qwen.ts imports this. Detector-independent and harness-agnostic — no + * frontmatter and no Claude-specific $ARGUMENTS substitution, so the emitted + * markdown is portable across Claude/Qwen/GigaCode (mirrors COMMAND_SOURCE's + * shape). Exported so tests can assert per-harness content. + */ +export function buildRetroCommandSource(opts: { memoryFile: string }): string { + const { memoryFile } = opts; + return `# /retro — unconditional session reflection + +You just finished (or are about to finish) a session. Your job is **not** to +summarize what you did. It is to surface the **friction** — things that were not +obvious, time sinks, wrong assumptions, detours — and turn each into a one-line +proposal a human can later triage into the harness. This runs **whether or not the +session was a "success."** You lived this session, so reflect on it directly and +unconditionally. This command is local-only: no network, no subprocess, nothing +leaves the machine. If the user asked you to focus on a specific area, weight your +evidence there. + +## 0. If this session has no prior turns + +If you were just started and there's nothing to reflect on yet, say so in one line +("Session just started — nothing to reflect on.") and stop. Don't run the rest. + +## 1. Ground yourself in evidence (do this first) + +Before writing anything, scan **this** session for concrete friction. Look for: + +- **Files** you opened repeatedly; searched for and couldn't find; or assumed + existed but didn't (a class that "should be here" but isn't part of the project; + a generated file you treated as source). +- **Commands / tools** you retried, that failed unexpectedly, or whose output + surprised you (an API that didn't behave as named; a flag that meant the + opposite; a tool that needs a prerequisite you didn't have). +- **Wrong assumptions** you had to correct (a dependency that needed installing; a + convention opposite to what you assumed; a test that only passes offline / in a + specific order). +- **Detours** — paths you went down and backed out of. + +If the user pastes other friction or detection output for this session, treat it +as *additional* evidence — but **your lived experience outranks it when they +disagree.** If something already captured the same friction this session, don't +duplicate it; reference it from your entry instead. + +## 2. Write 1–3 proposals — seeds, not decrees + +Emit **at most 3** proposals (fewer is fine; a 4th only if each is independently +sharp). Each proposal is **one line**, and must be: + +- **Specific to this session** — name the file, command, or assumption. If you + cannot name a concrete file, command, or assumption, **drop it.** +- **Low-commitment** — a seed for a human, not an instruction to paste. Use a shape + like: + - "It was not obvious that \`\`; would have helped to know \`\` up front." + - "Spent a while on \`\` until I realized \`\`." + - "Assumed \`\`; actually \`\`." +- **Tagged** — end the line with \`(→ )\` where \`\` is exactly one of: + \`${memoryFile} note\`, \`permission\`, \`command\`, \`doc\`, \`refactor\`, \`none\`. + +**Ban generic lessons.** The seed shapes above can be filled with platitudes, so +don't let them be — "communicate more" / "plan carefully" / "read the docs" are +useless and must not appear: + +- Bad: "It was not obvious that tests need care; would have helped to know the order." *(no file, no command — generic)* +- Good: "Assumed the suite is order-independent; actually \`auth.test.ts\` only passes when run after \`db.test.ts\`. (→ ${memoryFile} note)" + +## 3. Append to the backlog — and ONLY the backlog + +**Your only file mutation is appending to \`docs/_reflections/log.md\`.** Do not +modify any other file — including ${memoryFile}, \`settings.json\`, or any command. +Deciding what becomes a harness change is the human's job; your output is raw +material, not the change. + +Append a new section in this exact format (\`\` from the fixed vocabulary +below so recurrence is matchable across sessions): + +\`\`\` +## · · +- **** — )"> +\`\`\` + +\`\` must be one of: \`init\`, \`command\`, \`hook\`, \`detector\`, \`cli\`, \`docs\`, +\`test\`, \`build\`, \`other\`. + +If the file does not exist, create a **minimal stub** first (a \`# Reflections +backlog\` title, a one-line purpose, and the entry-format block above), then append. +The canonical header (the full loop + entry format) lives in the committed +file — don't try to reproduce the whole thing inline. + +## 4. Surface recurrence (the whole point of accumulating these) + +Before you finish, **grep the backlog** for each proposal's key nouns. If any +proposal echoes a prior entry, flag it. Run, e.g.: + +\`\`\` +grep -i -E "keyword1|keyword2|keyword3" docs/_reflections/log.md +\`\`\` + +If a friction theme has come up before, say so explicitly: + +> "This is the Nth time \`\` has come up — strong candidate to promote out of +the backlog into the harness." + +A single hit may be noise; **recurrence is the signal worth acting on.** If grep +finds no prior match, say "no recurrence" and move on. + +## If the session was genuinely clean + +Record it: append a \`· clean\` entry with no proposals ("Nothing notable — clean +session."). A clean session is a valid outcome and keeps the backlog honest about +which sessions happened. **Do not manufacture friction to justify the run.** +`; +} + // --- settings.json merge (idempotent, preserves user data) --- interface HookEntry { @@ -213,11 +463,14 @@ export interface InitClaudeOpts { cwd: string; } -/** Result of {@link initClaude}: absolute paths of the three written artifacts. */ +/** Result of {@link initClaude}: absolute paths of the five written artifacts. */ export interface InitClaudeResult { wrapperPath: string; settingsPath: string; commandPath: string; + /** /retro artifacts (installed alongside the /reflect artifacts above). */ + retroWrapperPath: string; + retroCommandPath: string; /** * Set when an *existing* settings.json failed to parse and was backed up to * `settings.json.bak` (byte-for-byte) before the fresh Stop entry was written. @@ -228,7 +481,7 @@ export interface InitClaudeResult { } /** - * Install (or refresh) the three Claude Code artifacts under \`opts.cwd/.claude/\`. + * Install (or refresh) the five Claude Code artifacts (reflect + retro) under \`opts.cwd/.claude/\`. * Idempotent: the wrapper + command are rewritten on every call, and the * \`hooks.Stop\` array is merged so the harnessgap entry appears exactly once * with all user settings preserved. Returns the absolute artifact paths. @@ -237,14 +490,22 @@ export function initClaude(opts: InitClaudeOpts): InitClaudeResult { const wrapperPath = join(opts.cwd, WRAPPER_REL); const settingsPath = join(opts.cwd, SETTINGS_REL); const commandPath = join(opts.cwd, COMMAND_REL); + const retroWrapperPath = join(opts.cwd, RETRO_WRAPPER_REL); + const retroCommandPath = join(opts.cwd, RETRO_COMMAND_REL); // Ensure .claude/ and .claude/commands/ exist. mkdirSync(dirname(wrapperPath), { recursive: true }); mkdirSync(dirname(commandPath), { recursive: true }); - // Wrapper + command are refreshed verbatim each run. + // Reflect + retro wrappers/commands are refreshed verbatim each run. writeFileSync(wrapperPath, buildWrapperSource({ cliPath: CLI_PATH }), 'utf8'); writeFileSync(commandPath, COMMAND_SOURCE, 'utf8'); + writeFileSync(retroWrapperPath, buildRetroWrapperSource(), 'utf8'); + writeFileSync( + retroCommandPath, + buildRetroCommandSource({ memoryFile: 'CLAUDE.md' }), + 'utf8', + ); // settings.json: read → parse-or-default → merge → write (pretty-printed). // A *missing* file starts fresh. An *existing-but-unparseable* file is backed @@ -275,8 +536,19 @@ export function initClaude(opts: InitClaudeOpts): InitClaudeResult { copyFileSync(settingsPath, settingsBackupPath); } } - const merged = mergeStopHook(raw, wrapperPath); + // Install BOTH Stop hooks: reflect (detector-gated) first, then retro + // (once/session). mergeStopHook dedups by exact command string, so the two + // distinct wrappers coexist idempotently across re-runs. + const mergedReflect = mergeStopHook(raw, wrapperPath); + const merged = mergeStopHook(mergedReflect, retroWrapperPath); writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8'); - return { wrapperPath, settingsPath, commandPath, settingsBackupPath }; + return { + wrapperPath, + settingsPath, + commandPath, + retroWrapperPath, + retroCommandPath, + settingsBackupPath, + }; } diff --git a/src/init/qwen.ts b/src/init/qwen.ts index e629f6b..51ad6ea 100644 --- a/src/init/qwen.ts +++ b/src/init/qwen.ts @@ -55,15 +55,19 @@ import type { HarnessId, InitResult } from '../types.js'; import { CLI_PATH, buildWrapperSource, + buildRetroWrapperSource, + buildRetroCommandSource, mergeStopHook, } from './claude.js'; -/** Relative locations of the three artifacts under //. */ +/** Relative locations of the five artifacts under // (reflect + retro). */ function artifactPaths(cwd: string, root: string) { return { wrapperPath: join(cwd, root, 'harnessgap-stop-hook.js'), settingsPath: join(cwd, root, 'settings.json'), commandPath: join(cwd, root, 'commands', 'reflect.md'), + retroWrapperPath: join(cwd, root, 'harnessgap-retro-hook.cjs'), + retroCommandPath: join(cwd, root, 'commands', 'retro.md'), }; } @@ -156,19 +160,22 @@ function installStopHook( }, ): InitResult { const { cwd, root, harness, displayName, memoryFile } = opts; - const { wrapperPath, settingsPath, commandPath } = artifactPaths(cwd, root); + const { wrapperPath, settingsPath, commandPath, retroWrapperPath, retroCommandPath } = + artifactPaths(cwd, root); // Ensure / and /commands/ exist. mkdirSync(dirname(wrapperPath), { recursive: true }); mkdirSync(dirname(commandPath), { recursive: true }); - // Wrapper + command are refreshed verbatim each run (idempotent). + // Reflect + retro wrappers/commands are refreshed verbatim each run (idempotent). writeFileSync(wrapperPath, buildWrapperSource({ cliPath: CLI_PATH }), 'utf8'); writeFileSync( commandPath, buildCommandSource({ displayName, memoryFile }), 'utf8', ); + writeFileSync(retroWrapperPath, buildRetroWrapperSource(), 'utf8'); + writeFileSync(retroCommandPath, buildRetroCommandSource({ memoryFile }), 'utf8'); // settings.json: read → parse-or-default → merge → write (pretty-printed). // A *missing* file starts fresh. An *existing-but-unparseable* file is backed @@ -197,13 +204,16 @@ function installStopHook( copyFileSync(settingsPath, settingsBackupPath); } } - const merged = mergeStopHook(raw, wrapperPath); + // Install BOTH Stop hooks: reflect first, then retro. mergeStopHook dedups by + // exact command string, so the two distinct wrappers coexist idempotently. + const mergedReflect = mergeStopHook(raw, wrapperPath); + const merged = mergeStopHook(mergedReflect, retroWrapperPath); writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8'); - const message = `installed harnessgap for ${displayName}: ${wrapperPath} | ${settingsPath} | ${commandPath}`; + const message = `installed harnessgap for ${displayName} (reflect + retro): ${wrapperPath} | ${settingsPath} | ${commandPath} | ${retroWrapperPath} | ${retroCommandPath}`; return { harness, - artifacts: [wrapperPath, settingsPath, commandPath], + artifacts: [wrapperPath, settingsPath, commandPath, retroWrapperPath, retroCommandPath], settingsBackupPath, degraded: false, message, diff --git a/test/cli-harness.test.ts b/test/cli-harness.test.ts index 69b7e56..b039638 100644 --- a/test/cli-harness.test.ts +++ b/test/cli-harness.test.ts @@ -399,6 +399,8 @@ describe('harnessgap CLI harness flags + widened init (Task 9)', () => { expect(existsSync(join(qwenCwd, '.qwen', 'harnessgap-stop-hook.js'))).toBe(true); expect(existsSync(join(qwenCwd, '.qwen', 'settings.json'))).toBe(true); expect(existsSync(join(qwenCwd, '.qwen', 'commands', 'reflect.md'))).toBe(true); + expect(existsSync(join(qwenCwd, '.qwen', 'harnessgap-retro-hook.cjs'))).toBe(true); + expect(existsSync(join(qwenCwd, '.qwen', 'commands', 'retro.md'))).toBe(true); // init gigacode writes under /.gigacode/. const gigaCwd = makeTempDir('init-gigacode'); @@ -408,6 +410,8 @@ describe('harnessgap CLI harness flags + widened init (Task 9)', () => { expect(existsSync(join(gigaCwd, '.gigacode', 'harnessgap-stop-hook.js'))).toBe(true); expect(existsSync(join(gigaCwd, '.gigacode', 'settings.json'))).toBe(true); expect(existsSync(join(gigaCwd, '.gigacode', 'commands', 'reflect.md'))).toBe(true); + expect(existsSync(join(gigaCwd, '.gigacode', 'harnessgap-retro-hook.cjs'))).toBe(true); + expect(existsSync(join(gigaCwd, '.gigacode', 'commands', 'retro.md'))).toBe(true); // init wat → non-zero exit + "unsupported agent" message naming the agent. const watCwd = makeTempDir('init-wat'); diff --git a/test/harness-dispatch.test.ts b/test/harness-dispatch.test.ts index 3bcc867..dbff1a7 100644 --- a/test/harness-dispatch.test.ts +++ b/test/harness-dispatch.test.ts @@ -189,26 +189,31 @@ describe('resolveHarness + the three specs', () => { expect(files.some((f) => f.endsWith('stray.jsonl'))).toBe(false); }); - it('6. CLAUDE_SPEC.installHook maps InitClaudeResult → InitResult shape (3-tuple, harness, message)', () => { + it('6. CLAUDE_SPEC.installHook maps InitClaudeResult → InitResult shape (5-tuple, harness, message)', () => { // Closes the Minor gap flagged in review: the dispatcher's installHook // mapping (InitClaudeResult paths → InitResult contract) was untested. - // Run init in a tmp cwd so the three artifacts are actually written and - // the mapping sees real paths. + // Run init in a tmp cwd so the five artifacts (reflect + retro) are actually + // written and the mapping sees real paths. const cwd = tmpDir(); const result = CLAUDE_SPEC.installHook({ cwd }); // harness stamped to claude-code (the verified branch). expect(result.harness).toBe('claude-code'); - // artifacts is the 3-tuple [wrapper, settings, command] — NOT a generic - // array of every written file. Each path is real on disk (init wrote it). - expect(result.artifacts).toHaveLength(3); - const [wrapper, settings, command] = result.artifacts; + // artifacts is the 5-tuple [reflect wrapper, settings, reflect cmd, retro + // wrapper, retro cmd] — NOT a generic array of every written file. Each path + // is real on disk (init wrote it). + expect(result.artifacts).toHaveLength(5); + const [wrapper, settings, command, retroWrapper, retroCommand] = result.artifacts; expect(wrapper).toBeDefined(); expect(settings).toBeDefined(); expect(command).toBeDefined(); + expect(retroWrapper).toBeDefined(); + expect(retroCommand).toBeDefined(); expect(fs.existsSync(wrapper!)).toBe(true); expect(fs.existsSync(settings!)).toBe(true); expect(fs.existsSync(command!)).toBe(true); + expect(fs.existsSync(retroWrapper!)).toBe(true); + expect(fs.existsSync(retroCommand!)).toBe(true); // No existing settings.json in the fresh tmp cwd → no backup, no degraded. expect(result.settingsBackupPath).toBeUndefined(); expect(result.degraded).toBe(false); diff --git a/test/init-qwen.test.ts b/test/init-qwen.test.ts index 6c8eeb0..9cc38da 100644 --- a/test/init-qwen.test.ts +++ b/test/init-qwen.test.ts @@ -39,7 +39,7 @@ function assertVerifiedResult( ): void { expect(r.degraded).toBe(false); expect(r.harness).toBe(harnessId); - expect(r.artifacts.length).toBe(3); + expect(r.artifacts.length).toBe(5); // Every artifact path is absolute and lives under //. for (const p of r.artifacts) { expect(p.startsWith(cwd)).toBe(true); @@ -56,13 +56,15 @@ describe('initQwen — artifact writes + InitResult', () => { const r = initQwen({ cwd }); assertVerifiedResult(r, cwd, '.qwen', 'qwen-code'); - // The three artifacts exist at the canonical paths. + // All five artifacts (reflect + retro) exist at the canonical paths. expect(existsSync(join(cwd, '.qwen', 'harnessgap-stop-hook.js'))).toBe(true); expect(existsSync(join(cwd, '.qwen', 'settings.json'))).toBe(true); expect(existsSync(join(cwd, '.qwen', 'commands', 'reflect.md'))).toBe(true); + expect(existsSync(join(cwd, '.qwen', 'harnessgap-retro-hook.cjs'))).toBe(true); + expect(existsSync(join(cwd, '.qwen', 'commands', 'retro.md'))).toBe(true); }); - it('settings.json has a hooks.Stop array with one node command entry', () => { + it('settings.json has BOTH reflect + retro Stop entries (reflect first, retro second)', () => { const cwd = makeTempDir('init-qwen'); initQwen({ cwd }); const settings = readSettings(cwd); @@ -70,20 +72,19 @@ describe('initQwen — artifact writes + InitResult', () => { const hooks = settings.hooks as { Stop: unknown[] } | undefined; expect(hooks).toBeDefined(); expect(Array.isArray(hooks!.Stop)).toBe(true); - expect(hooks!.Stop).toHaveLength(1); + expect(hooks!.Stop).toHaveLength(2); - const entry = hooks!.Stop[0] as { - matcher: string; - hooks: { type: string; command: string }[]; + const expectEntry = (idx: number, wrapperFile: string) => { + const entry = hooks!.Stop[idx] as { + matcher: string; + hooks: { type: string; command: string }[]; + }; + expect(entry.matcher).toBe(''); + expect(entry.hooks).toHaveLength(1); + expect(entry.hooks[0].command).toBe(`node ${join(cwd, '.qwen', wrapperFile)}`); }; - expect(entry.matcher).toBe(''); - expect(Array.isArray(entry.hooks)).toBe(true); - expect(entry.hooks).toHaveLength(1); - const cmd = entry.hooks[0].command; - expect(cmd.startsWith('node ')).toBe(true); - expect(cmd.endsWith('harnessgap-stop-hook.js')).toBe(true); - // The wrapper path embedded in the command points where we wrote it. - expect(cmd).toBe(`node ${join(cwd, '.qwen', 'harnessgap-stop-hook.js')}`); + expectEntry(0, 'harnessgap-stop-hook.js'); // reflect + expectEntry(1, 'harnessgap-retro-hook.cjs'); // retro }); it('wrapper file embeds the package CLI path and the reflect --format hook-stop spawn', () => { @@ -112,6 +113,13 @@ describe('initQwen — artifact writes + InitResult', () => { ); expect(cmd).toContain('QWEN.md'); }); + + it('retro command references QWEN.md (harness memory file)', () => { + const cwd = makeTempDir('init-qwen'); + initQwen({ cwd }); + const cmd = readFileSync(join(cwd, '.qwen', 'commands', 'retro.md'), 'utf8'); + expect(cmd).toContain('QWEN.md'); + }); }); describe('initQwen — settings.json merge + idempotency', () => { @@ -135,14 +143,13 @@ describe('initQwen — settings.json merge + idempotency', () => { expect(existsSync(join(cwd, '.qwen', 'settings.json.bak'))).toBe(false); // Unrelated user key preserved. expect(settings.model).toBe('qwen3-coder'); - // Stop array has both the user entry and the harnessgap entry. + // Stop array has the user entry plus BOTH harnessgap entries (reflect + retro). const stop = (settings.hooks as { Stop: { hooks: { command: string }[] }[] }).Stop; - expect(stop).toHaveLength(2); + expect(stop).toHaveLength(3); const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); expect(commands).toContain(userCmd); - expect( - commands.some((c) => c.startsWith('node ') && c.endsWith('harnessgap-stop-hook.js')), - ).toBe(true); + expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); }); it('invalid existing settings.json is backed up verbatim, then replaced with a valid merge', () => { @@ -157,10 +164,10 @@ describe('initQwen — settings.json merge + idempotency', () => { expect(existsSync(bakPath)).toBe(true); expect(readFileSync(bakPath, 'utf8')).toBe(invalid); expect(r.settingsBackupPath).toBe(bakPath); - // The new settings.json is valid with exactly one harnessgap Stop entry. + // The new settings.json is valid with both harnessgap Stop entries. const stop = (readSettings(cwd).hooks as { Stop: unknown[] }).Stop; expect(Array.isArray(stop)).toBe(true); - expect(stop).toHaveLength(1); + expect(stop).toHaveLength(2); }); it('missing settings.json starts fresh with no backup', () => { @@ -171,20 +178,20 @@ describe('initQwen — settings.json merge + idempotency', () => { expect(existsSync(join(cwd, '.qwen', 'settings.json.bak'))).toBe(false); const stop = (readSettings(cwd).hooks as { Stop: unknown[] }).Stop; expect(Array.isArray(stop)).toBe(true); - expect(stop).toHaveLength(1); + expect(stop).toHaveLength(2); }); - it('re-running initQwen keeps exactly one harnessgap Stop entry (idempotent)', () => { + it('re-running initQwen keeps exactly one of EACH harnessgap Stop entry (idempotent)', () => { const cwd = makeTempDir('init-qwen'); initQwen({ cwd }); initQwen({ cwd }); initQwen({ cwd }); const stop = (readSettings(cwd).hooks as { Stop: { hooks: { command: string }[] }[] }).Stop; - const harnessEntries = stop.flatMap((e) => - e.hooks.filter((h) => h.command.endsWith('harnessgap-stop-hook.js')), - ); - expect(harnessEntries).toHaveLength(1); + const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); + expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); + expect(stop).toHaveLength(2); }); it('idempotent re-run still dedupes against a pre-existing user Stop entry', () => { @@ -202,11 +209,12 @@ describe('initQwen — settings.json merge + idempotency', () => { initQwen({ cwd }); const stop = (readSettings(cwd).hooks as { Stop: { hooks: { command: string }[] }[] }).Stop; - // user entry preserved exactly once, harnessgap entry exactly once → 2 total. - expect(stop).toHaveLength(2); + // user entry once + each harnessgap entry once → 3 total. + expect(stop).toHaveLength(3); const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); expect(commands.filter((c) => c === userCmd)).toHaveLength(1); expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); }); }); @@ -219,6 +227,8 @@ describe('initGigacode — .gigacode root + GIGACODE.md', () => { expect(existsSync(join(cwd, '.gigacode', 'harnessgap-stop-hook.js'))).toBe(true); expect(existsSync(join(cwd, '.gigacode', 'settings.json'))).toBe(true); expect(existsSync(join(cwd, '.gigacode', 'commands', 'reflect.md'))).toBe(true); + expect(existsSync(join(cwd, '.gigacode', 'harnessgap-retro-hook.cjs'))).toBe(true); + expect(existsSync(join(cwd, '.gigacode', 'commands', 'retro.md'))).toBe(true); }); it('reflect command references GIGACODE.md (not QWEN.md)', () => { @@ -231,7 +241,14 @@ describe('initGigacode — .gigacode root + GIGACODE.md', () => { expect(cmd).toContain('GIGACODE.md'); }); - it('settings.json has exactly one harnessgap Stop entry (idempotent)', () => { + it('retro command references GIGACODE.md', () => { + const cwd = makeTempDir('init-giga'); + initGigacode({ cwd }); + const cmd = readFileSync(join(cwd, '.gigacode', 'commands', 'retro.md'), 'utf8'); + expect(cmd).toContain('GIGACODE.md'); + }); + + it('settings.json has exactly one of EACH harnessgap Stop entry (idempotent)', () => { const cwd = makeTempDir('init-giga'); initGigacode({ cwd }); initGigacode({ cwd }); @@ -239,10 +256,10 @@ describe('initGigacode — .gigacode root + GIGACODE.md', () => { const stop = (readSettings(cwd, '.gigacode').hooks as { Stop: { hooks: { command: string }[] }[]; }).Stop; - const harnessEntries = stop.flatMap((e) => - e.hooks.filter((h) => h.command.endsWith('harnessgap-stop-hook.js')), - ); - expect(harnessEntries).toHaveLength(1); + const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); + expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); + expect(stop).toHaveLength(2); }); }); diff --git a/test/init.test.ts b/test/init.test.ts index 5117c33..1d889a4 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,4 +1,4 @@ -// Tests for `harnessgap init claude` (Task 5): the installer writes three +// Tests for `harnessgap init claude` (Task 5): the installer writes five (reflect + retro) // artifacts under /.claude/ — a fail-open Stop-hook wrapper, an idempotent // settings.json Stop-array merge, and the /reflect agent-guidance command — and // the `init` CLI subcommand wires it up. Mirrors the spawn/build pattern from @@ -97,41 +97,51 @@ function readSettings(cwd: string): Record { } describe('initClaude — artifact writes', () => { - it('writes wrapper, command, and settings.json under /.claude/', () => { + it('writes reflect + retro wrappers, their commands, and settings.json under /.claude/', () => { const cwd = makeTempDir('init'); - const { wrapperPath, settingsPath, commandPath } = initClaude({ cwd }); + const { wrapperPath, settingsPath, commandPath, retroWrapperPath, retroCommandPath } = + initClaude({ cwd }); + // /reflect artifacts. expect(existsSync(wrapperPath)).toBe(true); expect(existsSync(commandPath)).toBe(true); + // /retro artifacts. + expect(existsSync(retroWrapperPath)).toBe(true); + expect(existsSync(retroCommandPath)).toBe(true); expect(existsSync(settingsPath)).toBe(true); // Paths are exactly where the brief specifies. expect(wrapperPath).toBe(join(cwd, '.claude', 'harnessgap-stop-hook.js')); expect(commandPath).toBe(join(cwd, '.claude', 'commands', 'reflect.md')); + expect(retroWrapperPath).toBe(join(cwd, '.claude', 'harnessgap-retro-hook.cjs')); + expect(retroCommandPath).toBe(join(cwd, '.claude', 'commands', 'retro.md')); expect(settingsPath).toBe(join(cwd, '.claude', 'settings.json')); + // /retro command references the Claude memory file (parameterized per harness). + expect(readFileSync(retroCommandPath, 'utf8')).toContain('CLAUDE.md'); }); - it('settings.json has a hooks.Stop array with one node command entry', () => { + it('settings.json has BOTH reflect + retro Stop entries (reflect first, retro second)', () => { const cwd = makeTempDir('init'); - const { wrapperPath } = initClaude({ cwd }); + const { wrapperPath, retroWrapperPath } = initClaude({ cwd }); const settings = readSettings(cwd); const hooks = settings.hooks as { Stop: unknown[] } | undefined; expect(hooks).toBeDefined(); expect(Array.isArray(hooks!.Stop)).toBe(true); - expect(hooks!.Stop).toHaveLength(1); - - const entry = hooks!.Stop[0] as { - matcher: string; - hooks: { type: string; command: string }[]; + // Two distinct harnessgap Stop entries: reflect (detector-gated) + retro + // (once/session), reflect ordered first so a tripping session reaches reflect. + expect(hooks!.Stop).toHaveLength(2); + + const expectEntry = (idx: number, cmd: string) => { + const entry = hooks!.Stop[idx] as { + matcher: string; + hooks: { type: string; command: string }[]; + }; + expect(entry.matcher).toBe(''); + expect(entry.hooks).toHaveLength(1); + expect(entry.hooks[0].command).toBe(cmd); }; - expect(entry.matcher).toBe(''); - expect(Array.isArray(entry.hooks)).toBe(true); - expect(entry.hooks).toHaveLength(1); - const cmd = entry.hooks[0].command; - expect(cmd.startsWith('node ')).toBe(true); - expect(cmd.endsWith('harnessgap-stop-hook.js')).toBe(true); - // The wrapper path embedded in the command is the absolute path we wrote. - expect(cmd).toBe(`node ${wrapperPath}`); + expectEntry(0, `node ${wrapperPath}`); // reflect + expectEntry(1, `node ${retroWrapperPath}`); // retro }); }); @@ -242,12 +252,13 @@ describe('initClaude — settings.json merge + idempotency', () => { expect(existsSync(join(cwd, '.claude', 'settings.json.bak'))).toBe(false); // Unrelated key preserved. expect(settings.permissions).toEqual({ allow: ['Bash(npm:*)'] }); - // Stop array has both the user entry and the harnessgap entry. + // Stop array has the user entry plus BOTH harnessgap entries (reflect + retro). const stop = (settings.hooks as { Stop: { hooks: { command: string }[] }[] }).Stop; - expect(stop).toHaveLength(2); + expect(stop).toHaveLength(3); const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); expect(commands).toContain(userCmd); - expect(commands.some((c) => c.startsWith('node ') && c.endsWith('harnessgap-stop-hook.js'))).toBe(true); + expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); }); it('invalid existing settings.json is backed up verbatim, then replaced with a valid merge', () => { @@ -264,11 +275,11 @@ describe('initClaude — settings.json merge + idempotency', () => { expect(readFileSync(bakPath, 'utf8')).toBe(invalid); // initClaude surfaced the backup path to callers. expect(result.settingsBackupPath).toBe(bakPath); - // The new settings.json is valid JSON with exactly one harnessgap Stop entry. + // The new settings.json is valid JSON with both harnessgap Stop entries. const settings = readSettings(cwd); const stop = (settings.hooks as { Stop: unknown[] }).Stop; expect(Array.isArray(stop)).toBe(true); - expect(stop).toHaveLength(1); + expect(stop).toHaveLength(2); }); it('missing settings.json starts fresh with no backup (settingsBackupPath undefined)', () => { @@ -278,13 +289,13 @@ describe('initClaude — settings.json merge + idempotency', () => { expect(result.settingsBackupPath).toBeUndefined(); expect(existsSync(join(cwd, '.claude', 'settings.json.bak'))).toBe(false); - // Install still succeeds: a valid settings.json with the harnessgap Stop entry. + // Install still succeeds: a valid settings.json with both harnessgap Stop entries. const stop = (readSettings(cwd).hooks as { Stop: unknown[] }).Stop; expect(Array.isArray(stop)).toBe(true); - expect(stop).toHaveLength(1); + expect(stop).toHaveLength(2); }); - it('re-running initClaude keeps exactly one harnessgap Stop entry (idempotent)', () => { + it('re-running initClaude keeps exactly one of EACH harnessgap Stop entry (idempotent)', () => { const cwd = makeTempDir('init'); initClaude({ cwd }); initClaude({ cwd }); @@ -292,10 +303,11 @@ describe('initClaude — settings.json merge + idempotency', () => { const settings = readSettings(cwd); const stop = (settings.hooks as { Stop: { hooks: { command: string }[] }[] }).Stop; - const harnessEntries = stop.flatMap((e) => - e.hooks.filter((h) => h.command.endsWith('harnessgap-stop-hook.js')), - ); - expect(harnessEntries).toHaveLength(1); + const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); + // Each harnessgap wrapper appears exactly once despite 3 runs → 2 total. + expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); + expect(stop).toHaveLength(2); }); it('idempotent re-run still dedupes against a pre-existing user Stop entry', () => { @@ -313,11 +325,12 @@ describe('initClaude — settings.json merge + idempotency', () => { initClaude({ cwd }); const stop = (readSettings(cwd).hooks as { Stop: { hooks: { command: string }[] }[] }).Stop; - // user entry preserved exactly once, harnessgap entry exactly once → 2 total. - expect(stop).toHaveLength(2); + // user entry once + each harnessgap entry once → 3 total. + expect(stop).toHaveLength(3); const commands = stop.flatMap((e) => e.hooks.map((h) => h.command)); expect(commands.filter((c) => c === userCmd)).toHaveLength(1); expect(commands.filter((c) => c.endsWith('harnessgap-stop-hook.js'))).toHaveLength(1); + expect(commands.filter((c) => c.endsWith('harnessgap-retro-hook.cjs'))).toHaveLength(1); }); }); @@ -328,15 +341,18 @@ describe('init CLI subcommand', () => { expect(stdout).toContain('init'); }); - it('init claude writes the three artifacts into the cwd and exits 0', async () => { + it('init claude writes the five reflect+retro artifacts into the cwd and exits 0', async () => { const cwd = makeTempDir('init-cli'); const { code, stdout } = await runCli(['init', 'claude'], { cwd }); expect(code).toBe(0); expect(stdout).toContain('harnessgap-stop-hook.js'); + expect(stdout).toContain('harnessgap-retro-hook.cjs'); expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(true); expect(existsSync(join(cwd, '.claude', 'harnessgap-stop-hook.js'))).toBe(true); expect(existsSync(join(cwd, '.claude', 'commands', 'reflect.md'))).toBe(true); + expect(existsSync(join(cwd, '.claude', 'harnessgap-retro-hook.cjs'))).toBe(true); + expect(existsSync(join(cwd, '.claude', 'commands', 'retro.md'))).toBe(true); }); it('init → non-zero exit, stderr mentions the agent', async () => { diff --git a/test/retro-hook.test.ts b/test/retro-hook.test.ts new file mode 100644 index 0000000..fa152f1 --- /dev/null +++ b/test/retro-hook.test.ts @@ -0,0 +1,149 @@ +// Fail-open + once-per-session contract for the /retro Stop-hook wrapper SOURCE +// emitted by buildRetroWrapperSource() (src/init/claude.ts). The generated source +// is baked into a temp file once and spawned per case — mirrors how +// test/init.test.ts exercises buildWrapperSource with a baked path. The wrapper's +// marker dir is redirected via $RETRO_MARKERS_DIR so each case is hermetic. +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, + rmSync, + existsSync, + mkdirSync, + writeFileSync, + utimesSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { buildRetroWrapperSource } from '../src/init/claude.js'; +import { makeTempDir, cleanupTempDirs } from './helpers/builder.js'; + +// Bake the generated retro wrapper into a temp file once; every case spawns it. +const WRAPPER = join(makeTempDir('retro-wrapper'), 'retro-hook.cjs'); +writeFileSync(WRAPPER, buildRetroWrapperSource(), 'utf8'); + +const DEFAULT_DIR = join(tmpdir(), 'retro-markers'); + +// The wrapper keys markers on sha1(key).slice(0,24) + '.marker'; mirror that to +// build/inspect marker files directly in the prune test. +function markerName(key: string) { + return createHash('sha1').update(String(key)).digest('hex').slice(0, 24) + '.marker'; +} + +// Ensure the default-path test (which omits the override) sees no stale value. +delete process.env.RETRO_MARKERS_DIR; + +function run(payload: object | string, env: NodeJS.ProcessEnv = {}) { + const stdin = typeof payload === 'string' ? payload : JSON.stringify(payload); + const res = spawnSync(process.execPath, [WRAPPER], { + input: stdin, + encoding: 'utf8', + env: { ...process.env, ...env }, + }); + // Fail-open contract: the wrapper must never exit non-zero on any path. + expect(res.status).toBe(0); + return { stdout: res.stdout ?? '', status: res.status, stderr: res.stderr ?? '' }; +} + +describe('retro Stop-hook wrapper', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'retro-test-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + // NOTE: do NOT rmSync(DEFAULT_DIR) here — that shared dir holds OTHER live + // sessions' markers. Tests that use the default path clean up only their own + // marker (see test 10). + }); + afterAll(cleanupTempDirs); // removes the baked-wrapper temp dir + + it('1. stop_hook_active:true → {} allow (no re-block within a stop attempt)', () => { + const r = run({ stop_hook_active: true, session_id: 's1' }, { RETRO_MARKERS_DIR: dir }); + expect(JSON.parse(r.stdout)).toEqual({}); + }); + + it('2. first stop this session → { decision: "block", reason } mentioning /retro + the backlog path', () => { + const r = run({ stop_hook_active: false, session_id: 's2' }, { RETRO_MARKERS_DIR: dir }); + const out = JSON.parse(r.stdout); + expect(out.decision).toBe('block'); + expect(out.reason).toContain('/retro'); + expect(out.reason).toContain('docs/_reflections/log.md'); + }); + + it('3. second stop same session → {} allow (once-per-session via marker)', () => { + const env = { RETRO_MARKERS_DIR: dir }; + const first = run({ stop_hook_active: false, session_id: 's3' }, env); + expect(JSON.parse(first.stdout).decision).toBe('block'); + const second = run({ stop_hook_active: false, session_id: 's3' }, env); + expect(JSON.parse(second.stdout)).toEqual({}); + }); + + it('4. a different session is nudged independently', () => { + const env = { RETRO_MARKERS_DIR: dir }; + run({ stop_hook_active: false, session_id: 'sA' }, env); + const b = run({ stop_hook_active: false, session_id: 'sB' }, env); + expect(JSON.parse(b.stdout).decision).toBe('block'); + }); + + it('5. malformed / non-object stdin → {} (fail-open: cannot read the loop guard)', () => { + for (const bad of ['not json{', 'null', '42', '"hello"', '[]', 'true']) { + expect(JSON.parse(run(bad, { RETRO_MARKERS_DIR: dir }).stdout)).toEqual({}); + } + }); + + it('6. keys the marker on transcript_path when session_id is absent', () => { + const env = { RETRO_MARKERS_DIR: dir }; + const a = run({ stop_hook_active: false, transcript_path: '/x/y.jsonl' }, env); + expect(JSON.parse(a.stdout).decision).toBe('block'); + const b = run({ stop_hook_active: false, transcript_path: '/x/y.jsonl' }, env); + expect(JSON.parse(b.stdout)).toEqual({}); + }); + + it('7. no session_id and no transcript_path → {} (cannot enforce once-per-session)', () => { + const r = run({ stop_hook_active: false }, { RETRO_MARKERS_DIR: dir }); + expect(JSON.parse(r.stdout)).toEqual({}); + }); + + it('8. stop_hook_active:"true" (string) is NOT the loop guard (strict equality)', () => { + const r = run({ stop_hook_active: 'true', session_id: 's8' }, { RETRO_MARKERS_DIR: dir }); + expect(JSON.parse(r.stdout).decision).toBe('block'); + }); + + it('9. marker write failure → {} (fail-open, never block on a wrapper fault)', () => { + // /dev/null is a file, so mkdir under it throws ENOTDIR → marker branch fails. + const r = run( + { stop_hook_active: false, session_id: 's9' }, + { RETRO_MARKERS_DIR: '/dev/null/retro-test' } + ); + expect(JSON.parse(r.stdout)).toEqual({}); + }); + + it('10. default marker path (RETRO_MARKERS_DIR unset) — block then allow', () => { + // Uses the REAL default marker dir. Clean up ONLY this test's marker so we + // never erase another live session's marker (which would spuriously re-nudge it). + const m = join(DEFAULT_DIR, markerName('s10')); + try { + const first = run({ stop_hook_active: false, session_id: 's10' }); // no env override + expect(JSON.parse(first.stdout).decision).toBe('block'); + const second = run({ stop_hook_active: false, session_id: 's10' }); + expect(JSON.parse(second.stdout)).toEqual({}); + } finally { + try { rmSync(m, { force: true }); } catch { /* already gone */ } + } + }); + + it('11. prunes marker files older than 30 days on write', () => { + mkdirSync(dir, { recursive: true }); + const oldMarker = join(dir, markerName('stale-session')); + writeFileSync(oldMarker, 'x'); + const stale = Math.floor(Date.now() / 1000) - 31 * 24 * 3600; // 31 days ago (seconds) + utimesSync(oldMarker, stale, stale); + // A different session triggers a write, which prunes the stale marker. + const r = run({ stop_hook_active: false, session_id: 'fresh' }, { RETRO_MARKERS_DIR: dir }); + expect(JSON.parse(r.stdout).decision).toBe('block'); + expect(existsSync(oldMarker)).toBe(false); + expect(existsSync(join(dir, markerName('fresh')))).toBe(true); + }); +});