From cfc3cf0484e9d9f5d9d3678292fa4cd5687b0414 Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 17:55:12 +0300 Subject: [PATCH 1/7] =?UTF-8?q?feat(reflect):=20add=20/retro=20=E2=80=94?= =?UTF-8?q?=20unconditional=20session=20reflection=20+=20once-per-session?= =?UTF-8?q?=20nudge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detector-independent complement to the detector-gated /reflect. /retro reflects on the session the agent just lived — grounded in concrete evidence first — then emits 1-5 low-commitment one-liner proposals ("it was not obvious that …; would have helped to know …") and accumulates them in a committable backlog (docs/_reflections/log.md), where recurrence — not a single detector trip — is the signal to act on. The detector becomes optional seasoning, not the trigger, which matters pre-calibration when it misses most real friction. A fail-open, once-per-session Stop hook nudges the agent to run /retro (or acknowledge a clean session). Loop-guarded via stop_hook_active + a session-keyed marker. - .claude/commands/retro.md /retro command (grounded reflection) - .claude/harnessgap-retro-hook.cjs fail-open once/session Stop-hook wrapper (.cjs because the repo is ESM) - test/retro-hook.test.ts 7 contract tests (block-once, loop-guard, fail-open, session-keyed marker) - docs/_reflections/log.md accumulating friction backlog (template) Mirrors the fail-open discipline of harnessgap-stop-hook.js (src/init/claude.ts). Wiring into init + portable registration tracked in #48; related ESM-target init bug in #47. Co-Authored-By: Claude --- .claude/commands/retro.md | 108 ++++++++++++++++++++++++++++++ .claude/harnessgap-retro-hook.cjs | 85 +++++++++++++++++++++++ docs/_reflections/log.md | 42 ++++++++++++ test/retro-hook.test.ts | 80 ++++++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 .claude/commands/retro.md create mode 100644 .claude/harnessgap-retro-hook.cjs create mode 100644 docs/_reflections/log.md create mode 100644 test/retro-hook.test.ts diff --git a/.claude/commands/retro.md b/.claude/commands/retro.md new file mode 100644 index 0000000..c6bd22d --- /dev/null +++ b/.claude/commands/retro.md @@ -0,0 +1,108 @@ +--- +description: Reflect on this session's friction and emit grounded, low-commitment harness proposals (detector-independent). +argument-hint: "[optional focus area]" +--- + +# /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."** Friction lives in good sessions too. + +Unlike `/reflect`, this is **not gated on `harnessgap`'s detector**. You lived this +session; reflect on it directly. The detector is optional seasoning, not the +trigger — which matters pre-calibration, when the detector misses most real +friction. This command is local-only: no network, no subprocess, nothing leaves the +machine. + +> Scope (if any): **$ARGUMENTS** +> If non-empty, weight your evidence toward that area, but still reflect on the +> whole session. + +## 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. +- **"Would have saved me 10 minutes if I'd known ___ up front."** + +If `harnessgap scan` / `reflect` output for this session is available (the user +pasted it, or you were told to consult it), treat it as *additional* evidence — but +**your lived experience outranks it when they disagree.** The detector is +uncalibrated; you were there. + +## 2. Write 1–5 proposals — seeds, not decrees + +Each proposal is **one line**, and must be: + +- **Specific to this session** — name the file, command, or assumption. No + hand-waving. +- **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 ``. A `` would have caught it." +- **Triagable** — end with a parenthetical naming the *kind* of harness change that + might address it: `(→ CLAUDE.md note)`, `(→ permission)`, `(→ command)`, + `(→ doc)`, `(→ refactor)`, or `(→ none — one-off)`. + +**Ban generic lessons.** "Communicate more," "plan carefully," "read the docs" are +useless — omit them. If you cannot tie a proposal to something concrete that +happened in *this* session, drop it. **Fewer and sharper beats more.** Do not +invent friction that did not occur. + +## 3. Append to the backlog + +Append your proposals to `docs/_reflections/log.md` under a new section: + +``` +## · · +- **** — *(→ candidate change)* +``` + +- Use today's date from context for the header. +- If the file does not exist, create it and prepend this bootstrap header so the + loop is self-documenting: + + ``` + # Reflections backlog + + Raw, low-commitment friction observations captured by `/retro`. Not docs, not + config — the raw material the harness is built from, incrementally, over many + sessions. Loop: capture → spot recurrence → triage into the harness (CLAUDE.md / + settings / commands / docs) → retire the entry. Committed on purpose: a + team-shared friction backlog is the point. + ``` + +- **Do not** edit `CLAUDE.md`, `settings.json`, or any other harness config. + Deciding what actually becomes a harness change is the human's job — your output + is raw material for that, not the change itself. + +## 4. Report back, and surface recurrence + +Print the section you appended so the user sees it inline. Then **skim the backlog** +for prior entries that match what you just wrote. If a friction item has come up +before, flag it: + +> "This is the Nth time `` has come up — strong candidate to promote out of the +> backlog into the harness." + +Recurrence — not a single hit — is the signal that a friction item is worth acting +on. That recurrence check is the whole point of accumulating these. + +## If the session was genuinely clean + +Say so in one line ("Nothing notable — clean session.") and append a `· clean` entry +with no proposals. **Do not manufacture friction to justify the run.** A clean +session is a valid, useful outcome. diff --git a/.claude/harnessgap-retro-hook.cjs b/.claude/harnessgap-retro-hook.cjs new file mode 100644 index 0000000..10ed23b --- /dev/null +++ b/.claude/harnessgap-retro-hook.cjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Auto-generated companion to the /retro command. Fail-open Claude Code Stop hook. +// Nudges the agent to run /retro (or acknowledge 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. Mirrors the fail-open discipline of +// harnessgap-stop-hook.js (see src/init/claude.ts, buildWrapperSource). +// +// Extension is .cjs (not .js) on purpose: this repo's package.json has +// "type": "module", which makes .js files ESM and `require` undefined. .cjs is +// CommonJS unconditionally, so the wrapper runs in both CJS and ESM projects. +// +// Logic: +// - read all stdin, JSON.parse (defensive: malformed → null). +// - malformed 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 }. +// - top-level try/catch so any throw → {}. +// Marker dir overrides via $HARNESSGAP_RETRO_DIR (testability / relocation); +// defaults to os.tmpdir()/harnessgap-retro. +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. If the session was genuinely clean or trivial, say ' + + 'so in one short line and stop. 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: every +// decision flows into a terminal emit() call, so nothing runs after a decision. +function emit(payload) { process.stdout.write(payload, function () { process.exit(0); }); } + +try { + var raw = fs.readFileSync(0, 'utf8'); + var payload = null; + try { payload = JSON.parse(raw); } catch (e) { payload = null; } + + var nudge = true; + // Unparseable stdin → we cannot read the loop guard (stop_hook_active) → allow. + if (payload === null) nudge = false; + // Loop guard: a Stop hook already blocked this stop attempt → allow. + if (nudge && payload.stop_hook_active === true) nudge = false; + if (nudge) { + var key = payload.session_id || payload.transcript_path || ''; + // No session key → cannot enforce once-per-session → allow (never risk looping). + if (!key) { + nudge = false; + } else { + var dir = process.env.HARNESSGAP_RETRO_DIR + ? path.resolve(process.env.HARNESSGAP_RETRO_DIR) + : path.join(os.tmpdir(), 'harnessgap-retro'); + var marker = path.join( + dir, + crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 24) + '.marker' + ); + try { + if (fs.existsSync(marker)) { + nudge = false; // already nudged this session + } else { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(marker, String(Date.now())); + } + } catch (e) { + // Marker machinery failed — leave nudge=true; stop_hook_active still guards loops. + } + } + } + + if (nudge) { + emit(JSON.stringify({ decision: 'block', reason: REASON }) + '\n'); + } else { + emit('{}\n'); + } +} catch (e) { + emit('{}\n'); +} diff --git a/docs/_reflections/log.md b/docs/_reflections/log.md new file mode 100644 index 0000000..5d90729 --- /dev/null +++ b/docs/_reflections/log.md @@ -0,0 +1,42 @@ +# 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. + +## How this relates to the rest of harnessgap + +- **`/reflect`** (detector-gated) — fires only when `harnessgap`'s signals trip, and + fills a structured `ReflectFrame` against *confirmed* friction. Use it to close + the loop on friction the detector is confident about. +- **`/retro`** (this file) — fires unconditionally on the agent's own lived + experience, no detector required. Use it to catch friction the detector misses — + which, pre-calibration, is most of it. +- **`docs/_proposals/`** — full synthesized *new-doc* proposals (fact-checked, + model-drafted) for the top friction areas. Heavier weight than these one-liners. + +## Entry format + +``` +## · · +- **** — *(→ candidate: 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/test/retro-hook.test.ts b/test/retro-hook.test.ts new file mode 100644 index 0000000..19405e9 --- /dev/null +++ b/test/retro-hook.test.ts @@ -0,0 +1,80 @@ +// Fail-open + once-per-session contract for the /retro Stop-hook wrapper +// (.claude/harnessgap-retro-hook.js). Mirrors the spawn-based style of +// test/init.test.ts: feed stdin JSON, assert on stdout + exit code. The wrapper's +// marker dir is redirected via $HARNESSGAP_RETRO_DIR so each case is hermetic. +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const WRAPPER = join(process.cwd(), '.claude', 'harnessgap-retro-hook.cjs'); + +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 }, + }); + 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 }); + }); + + it('1. stop_hook_active:true → {} allow (no re-block within a stop attempt)', () => { + const r = run({ stop_hook_active: true, session_id: 's1' }, { HARNESSGAP_RETRO_DIR: dir }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({}); + }); + + it('2. first stop this session → { decision: "block", reason } mentioning /retro', () => { + const r = run({ stop_hook_active: false, session_id: 's2' }, { HARNESSGAP_RETRO_DIR: dir }); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.decision).toBe('block'); + expect(out.reason).toContain('/retro'); + }); + + it('3. second stop same session → {} allow (once-per-session via marker)', () => { + const env = { HARNESSGAP_RETRO_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 = { HARNESSGAP_RETRO_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 stdin → {} (fail-open: cannot read the loop guard)', () => { + const r = run('not json{', { HARNESSGAP_RETRO_DIR: dir }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({}); + }); + + it('6. keys the marker on transcript_path when session_id is absent', () => { + const env = { HARNESSGAP_RETRO_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 }, { HARNESSGAP_RETRO_DIR: dir }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({}); + }); +}); From bcdd5a22ccf8c5055c117827b65574d573b13578 Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 18:14:22 +0300 Subject: [PATCH 2/7] address review: fail-open marker fault, unify tag format, concrete recurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from three subagent reviewers on PR #49. Correctness (merge-blocking): - Hook: marker-write failure now fails OPEN (was: block) — the prior path inverted the fail-open contract and would re-nudge every stop on a persistent tmpdir failure. Added test 9. - Tests now assert exit 0 on every path via run() (was: only 4 of 7). Thesis (merge-blocking): - Unify the proposal tag to a single "(→ )" suffix across the command, backlog format, and entry examples (was: three divergent conventions that would corrupt the backlog and break recurrence matching). - Recurrence is now concrete: fixed vocabulary + a grep step + cap at 3 proposals (was: hand-wavy "skim" with unbounded tags and a 1-5 cap). Other: - Hook: best-effort 30-day marker pruning; REASON no longer contradicts the prompt's clean-session logging; "not yet wired into init" note. - Command: bad->good example, $ARGUMENTS empty-markdown fix, "don't edit config" guard moved before the file-path instruction, empty-session guard, /reflect dedup note, minimal-stub bootstrap. - Tests: default-path coverage, non-object JSON, strict-equality on stop_hook_active, marker-prune test. 7 -> 11 tests, all passing. Deferred to #48 (flagged in a comment there): two-Stop-hook ordering hazard, marker TTL for long/resumed sessions, README privacy sentence, docs/_reflections exclusion from --diagnose doc-grounding, frontmatter convention, and a possible harnessgap subcommand to compute backlog recurrence. Co-Authored-By: Claude --- .claude/commands/retro.md | 104 ++++++++++++++++-------------- .claude/harnessgap-retro-hook.cjs | 45 +++++++++---- docs/_reflections/log.md | 5 +- test/retro-hook.test.ts | 73 ++++++++++++++++++--- 4 files changed, 157 insertions(+), 70 deletions(-) diff --git a/.claude/commands/retro.md b/.claude/commands/retro.md index c6bd22d..632d61a 100644 --- a/.claude/commands/retro.md +++ b/.claude/commands/retro.md @@ -1,5 +1,5 @@ --- -description: Reflect on this session's friction and emit grounded, low-commitment harness proposals (detector-independent). +description: Unconditional end-of-session friction capture — appends one-line seeds to docs/_reflections/log.md. Detector-independent complement to /reflect. argument-hint: "[optional focus area]" --- @@ -17,9 +17,13 @@ trigger — which matters pre-calibration, when the detector misses most real friction. This command is local-only: no network, no subprocess, nothing leaves the machine. -> Scope (if any): **$ARGUMENTS** -> If non-empty, weight your evidence toward that area, but still reflect on the -> whole session. +> Scope note, if any: $ARGUMENTS — if blank, reflect on the whole session; +> otherwise weight your evidence toward that area. + +## 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) @@ -35,74 +39,78 @@ Before writing anything, scan **this** session for concrete friction. Look for: 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. -- **"Would have saved me 10 minutes if I'd known ___ up front."** If `harnessgap scan` / `reflect` output for this session is available (the user -pasted it, or you were told to consult it), treat it as *additional* evidence — but -**your lived experience outranks it when they disagree.** The detector is -uncalibrated; you were there. +pasted it, or `/reflect` already produced a `ReflectFrame` this session), treat it +as *additional* evidence — but **your lived experience outranks it when they +disagree.** If `/reflect` already captured the same friction, don't duplicate it; +reference it from your entry instead. -## 2. Write 1–5 proposals — seeds, not decrees +## 2. Write 1–3 proposals — seeds, not decrees -Each proposal is **one line**, and must be: +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. No - hand-waving. +- **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 ``. A `` would have caught it." -- **Triagable** — end with a parenthetical naming the *kind* of harness change that - might address it: `(→ CLAUDE.md note)`, `(→ permission)`, `(→ command)`, - `(→ doc)`, `(→ refactor)`, or `(→ none — one-off)`. + - "Assumed ``; actually ``." +- **Tagged** — end the line with `(→ )` where `` is exactly one of: + `CLAUDE.md 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: -**Ban generic lessons.** "Communicate more," "plan carefully," "read the docs" are -useless — omit them. If you cannot tie a proposal to something concrete that -happened in *this* session, drop it. **Fewer and sharper beats more.** Do not -invent friction that did not occur. +- Bad: "It was not obvious that tests need care; would have helped to know the order." *(no file, no command — generic)* +- Good: "Assumed `vitest` reruns in CI order; actually `test/init.test.ts` only passes after `test/cli.test.ts`. (→ CLAUDE.md note)" -## 3. Append to the backlog +## 3. Append to the backlog — and ONLY the backlog -Append your proposals to `docs/_reflections/log.md` under a new section: +**Your only file mutation is appending to `docs/_reflections/log.md`.** Do not Edit +or Write any other file — including `CLAUDE.md`, `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): ``` ## · · -- **** — *(→ candidate change)* +- **** — )"> ``` -- Use today's date from context for the header. -- If the file does not exist, create it and prepend this bootstrap header so the - loop is self-documenting: +`` must be one of: `init`, `command`, `hook`, `detector`, `cli`, `docs`, +`test`, `build`, `other`. - ``` - # Reflections backlog +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 + the `/reflect` vs `/retro` split) lives in the +committed file — don't try to reproduce the whole thing inline. - Raw, low-commitment friction observations captured by `/retro`. Not docs, not - config — the raw material the harness is built from, incrementally, over many - sessions. Loop: capture → spot recurrence → triage into the harness (CLAUDE.md / - settings / commands / docs) → retire the entry. Committed on purpose: a - team-shared friction backlog is the point. - ``` +## 4. Surface recurrence (the whole point of accumulating these) -- **Do not** edit `CLAUDE.md`, `settings.json`, or any other harness config. - Deciding what actually becomes a harness change is the human's job — your output - is raw material for that, not the change itself. +Before you finish, **grep the backlog** for each proposal's key nouns. If any +proposal echoes a prior entry, flag it. Run, e.g.: -## 4. Report back, and surface recurrence +``` +grep -i -E "keyword1|keyword2|keyword3" docs/_reflections/log.md +``` -Print the section you appended so the user sees it inline. Then **skim the backlog** -for prior entries that match what you just wrote. If a friction item has come up -before, flag it: +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." +> "This is the Nth time `` has come up — strong candidate to promote out of +> the backlog into the harness." -Recurrence — not a single hit — is the signal that a friction item is worth acting -on. That recurrence check is the whole point of accumulating these. +A single hit may be noise; **recurrence — not a single detector trip — is the +signal worth acting on.** If grep finds no prior match, say "no recurrence" and move +on. ## If the session was genuinely clean -Say so in one line ("Nothing notable — clean session.") and append a `· clean` entry -with no proposals. **Do not manufacture friction to justify the run.** A clean -session is a valid, useful outcome. +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.** diff --git a/.claude/harnessgap-retro-hook.cjs b/.claude/harnessgap-retro-hook.cjs index 10ed23b..e7742c0 100644 --- a/.claude/harnessgap-retro-hook.cjs +++ b/.claude/harnessgap-retro-hook.cjs @@ -1,25 +1,30 @@ #!/usr/bin/env node // Auto-generated companion to the /retro command. Fail-open Claude Code Stop hook. -// Nudges the agent to run /retro (or acknowledge a clean session) AT MOST ONCE per +// 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. Mirrors the fail-open discipline of // harnessgap-stop-hook.js (see src/init/claude.ts, buildWrapperSource). // +// NOT YET WIRED INTO harnessgap init: register it manually in settings.json to +// activate. A portable registration + install via init is tracked in issue #48. +// // Extension is .cjs (not .js) on purpose: this repo's package.json has // "type": "module", which makes .js files ESM and `require` undefined. .cjs is // CommonJS unconditionally, so the wrapper runs in both CJS and ESM projects. // // Logic: -// - read all stdin, JSON.parse (defensive: malformed → null). -// - malformed stdin → {} (cannot read the loop guard → must allow). +// - 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 → {}. -// Marker dir overrides via $HARNESSGAP_RETRO_DIR (testability / relocation); -// defaults to os.tmpdir()/harnessgap-retro. +// Side effect: one small marker file per session under the marker dir (default +// os.tmpdir()/harnessgap-retro; override with $HARNESSGAP_RETRO_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'); @@ -30,23 +35,39 @@ var REASON = '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. If the session was genuinely clean or trivial, say ' + - 'so in one short line and stop. Do not invent friction, and do not loop — this ' + - 'nudge fires at most once per session.'; + '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: every // decision flows into a terminal emit() call, so nothing runs after a decision. function emit(payload) { process.stdout.write(payload, function () { process.exit(0); }); } +// Best-effort reap of marker files older than 30 days so the cache can't grow +// unbounded. Fully fail-safe — a prune error never affects the nudge decision. +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; - // Unparseable stdin → we cannot read the loop guard (stop_hook_active) → allow. - if (payload === null) nudge = false; + // Unparseable / non-object stdin → cannot read the loop guard (stop_hook_active) → allow. + if (payload === null || typeof payload !== 'object') nudge = false; // Loop guard: a Stop hook already blocked this stop attempt → allow. if (nudge && payload.stop_hook_active === true) nudge = false; if (nudge) { @@ -67,10 +88,12 @@ try { nudge = false; // already nudged this session } else { fs.mkdirSync(dir, { recursive: true }); + try { pruneOldMarkers(dir); } catch (e) { /* best-effort */ } fs.writeFileSync(marker, String(Date.now())); } } catch (e) { - // Marker machinery failed — leave nudge=true; stop_hook_active still guards loops. + // Marker machinery failed → fail OPEN (never block on a wrapper fault). + nudge = false; } } } diff --git a/docs/_reflections/log.md b/docs/_reflections/log.md index 5d90729..5c97f35 100644 --- a/docs/_reflections/log.md +++ b/docs/_reflections/log.md @@ -32,9 +32,12 @@ material the harness is built from, incrementally, over many sessions. ``` ## · · -- **** — *(→ candidate: CLAUDE.md note | permission | command | doc | refactor | none)* +- **** — )", 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/test/retro-hook.test.ts b/test/retro-hook.test.ts index 19405e9..fafbd87 100644 --- a/test/retro-hook.test.ts +++ b/test/retro-hook.test.ts @@ -1,14 +1,32 @@ // Fail-open + once-per-session contract for the /retro Stop-hook wrapper -// (.claude/harnessgap-retro-hook.js). Mirrors the spawn-based style of +// (.claude/harnessgap-retro-hook.cjs). Mirrors the spawn-based style of // test/init.test.ts: feed stdin JSON, assert on stdout + exit code. The wrapper's // marker dir is redirected via $HARNESSGAP_RETRO_DIR so each case is hermetic. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +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'; const WRAPPER = join(process.cwd(), '.claude', 'harnessgap-retro-hook.cjs'); +const DEFAULT_DIR = join(tmpdir(), 'harnessgap-retro'); + +// 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.HARNESSGAP_RETRO_DIR; function run(payload: object | string, env: NodeJS.ProcessEnv = {}) { const stdin = typeof payload === 'string' ? payload : JSON.stringify(payload); @@ -17,6 +35,8 @@ function run(payload: object | string, env: NodeJS.ProcessEnv = {}) { 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 ?? '' }; } @@ -27,20 +47,20 @@ describe('retro Stop-hook wrapper', () => { }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); + rmSync(DEFAULT_DIR, { recursive: true, force: true }); // clean default-path writes }); it('1. stop_hook_active:true → {} allow (no re-block within a stop attempt)', () => { const r = run({ stop_hook_active: true, session_id: 's1' }, { HARNESSGAP_RETRO_DIR: dir }); - expect(r.status).toBe(0); expect(JSON.parse(r.stdout)).toEqual({}); }); - it('2. first stop this session → { decision: "block", reason } mentioning /retro', () => { + it('2. first stop this session → { decision: "block", reason } mentioning /retro + the backlog path', () => { const r = run({ stop_hook_active: false, session_id: 's2' }, { HARNESSGAP_RETRO_DIR: dir }); - expect(r.status).toBe(0); 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)', () => { @@ -58,10 +78,10 @@ describe('retro Stop-hook wrapper', () => { expect(JSON.parse(b.stdout).decision).toBe('block'); }); - it('5. malformed stdin → {} (fail-open: cannot read the loop guard)', () => { - const r = run('not json{', { HARNESSGAP_RETRO_DIR: dir }); - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toEqual({}); + 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, { HARNESSGAP_RETRO_DIR: dir }).stdout)).toEqual({}); + } }); it('6. keys the marker on transcript_path when session_id is absent', () => { @@ -74,7 +94,40 @@ describe('retro Stop-hook wrapper', () => { it('7. no session_id and no transcript_path → {} (cannot enforce once-per-session)', () => { const r = run({ stop_hook_active: false }, { HARNESSGAP_RETRO_DIR: dir }); - expect(r.status).toBe(0); 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' }, { HARNESSGAP_RETRO_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' }, + { HARNESSGAP_RETRO_DIR: '/dev/null/retro-test' } + ); + expect(JSON.parse(r.stdout)).toEqual({}); + }); + + it('10. default marker path (HARNESSGAP_RETRO_DIR unset) — block then allow', () => { + 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({}); + }); + + 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' }, { HARNESSGAP_RETRO_DIR: dir }); + expect(JSON.parse(r.stdout).decision).toBe('block'); + expect(existsSync(oldMarker)).toBe(false); + expect(existsSync(join(dir, markerName('fresh')))).toBe(true); + }); }); From b4fefc16535fbccffdb8ce22e9e5325a9bb522c6 Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 18:18:10 +0300 Subject: [PATCH 3/7] test(retro-hook): don't wipe the shared default marker dir in afterEach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared afterEach did rmSync(DEFAULT_DIR), which erased OTHER live sessions' markers — including the one for the session running the tests, which spuriously re-triggered the Stop-hook nudge mid-session (caught by dogfooding: the hook re-fired right after a test run). Test 10 (the only test using the real default path) now cleans up only its own marker via try/finally. Verified empirically: the live session's marker survives a test run. Co-Authored-By: Claude --- test/retro-hook.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/retro-hook.test.ts b/test/retro-hook.test.ts index fafbd87..1d08c8f 100644 --- a/test/retro-hook.test.ts +++ b/test/retro-hook.test.ts @@ -47,7 +47,9 @@ describe('retro Stop-hook wrapper', () => { }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); - rmSync(DEFAULT_DIR, { recursive: true, force: true }); // clean default-path writes + // 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). }); it('1. stop_hook_active:true → {} allow (no re-block within a stop attempt)', () => { @@ -112,10 +114,17 @@ describe('retro Stop-hook wrapper', () => { }); it('10. default marker path (HARNESSGAP_RETRO_DIR unset) — block then allow', () => { - 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({}); + // 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', () => { From 21f094a7b2aaf49a64ec6eee7451686af5dbcd7a Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 19:26:54 +0300 Subject: [PATCH 4/7] =?UTF-8?q?refactor(retro):=20decouple=20the=20/retro?= =?UTF-8?q?=20command=20from=20harnessgap=20=E2=80=94=20make=20it=20standa?= =?UTF-8?q?lone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command text no longer mentions or references harnessgap: dropped the "/reflect" contrast and "harnessgap's detector" framing, the harnessgap scan/ reflect / ReflectFrame seasoning clause (genericized), the repo-specific test/init.test.ts example paths, and the "/reflect vs /retro split" bootstrap note. The command now describes itself: unconditional, grounded, local-only reflection that appends to docs/_reflections/log.md. The wrapper hook and the backlog doc still reference harnessgap (they're the integration surface, tracked in #48) — this commit only decouples the command itself. Co-Authored-By: Claude --- .claude/commands/retro.md | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/.claude/commands/retro.md b/.claude/commands/retro.md index 632d61a..f51a143 100644 --- a/.claude/commands/retro.md +++ b/.claude/commands/retro.md @@ -1,5 +1,5 @@ --- -description: Unconditional end-of-session friction capture — appends one-line seeds to docs/_reflections/log.md. Detector-independent complement to /reflect. +description: Unconditional end-of-session friction capture — appends one-line seeds to docs/_reflections/log.md. argument-hint: "[optional focus area]" --- @@ -9,13 +9,9 @@ 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."** Friction lives in good sessions too. - -Unlike `/reflect`, this is **not gated on `harnessgap`'s detector**. You lived this -session; reflect on it directly. The detector is optional seasoning, not the -trigger — which matters pre-calibration, when the detector misses most real -friction. This command is local-only: no network, no subprocess, nothing leaves the -machine. +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. > Scope note, if any: $ARGUMENTS — if blank, reflect on the whole session; > otherwise weight your evidence toward that area. @@ -40,11 +36,10 @@ Before writing anything, scan **this** session for concrete friction. Look for: specific order). - **Detours** — paths you went down and backed out of. -If `harnessgap scan` / `reflect` output for this session is available (the user -pasted it, or `/reflect` already produced a `ReflectFrame` this session), treat it +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 `/reflect` already captured the same friction, don't duplicate it; -reference it from your entry instead. +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 @@ -66,7 +61,7 @@ don't let them be — "communicate more" / "plan carefully" / "read the docs" ar 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 `vitest` reruns in CI order; actually `test/init.test.ts` only passes after `test/cli.test.ts`. (→ CLAUDE.md note)" +- Good: "Assumed the suite is order-independent; actually `auth.test.ts` only passes when run after `db.test.ts`. (→ CLAUDE.md note)" ## 3. Append to the backlog — and ONLY the backlog @@ -88,8 +83,8 @@ so recurrence is matchable across sessions): 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 + the `/reflect` vs `/retro` split) lives in the -committed file — don't try to reproduce the whole thing inline. +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) @@ -105,9 +100,8 @@ 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 — not a single detector trip — is the -signal worth acting on.** If grep finds no prior match, say "no recurrence" and move -on. +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 From cc0bd94c1d9888811d838a346e965467f7503dd5 Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 19:42:35 +0300 Subject: [PATCH 5/7] =?UTF-8?q?refactor(retro):=20decouple=20the=20hook=20?= =?UTF-8?q?+=20backlog=20from=20harnessgap=20=E2=80=94=20fully=20standalon?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the standalone-ization of /retro alongside the prior command decoupling (21f094a). Hook (.claude/harnessgap-retro-hook.cjs): - Comments no longer reference harnessgap init / issue #48 / the sibling harnessgap-stop-hook.js. Activation note is now generic ("register it in settings.json as a Stop hook"). - Renamed the marker-dir env override HARNESSGAP_RETRO_DIR -> RETRO_MARKERS_DIR and the default dir os.tmpdir()/harnessgap-retro -> retro-markers, so the decoupled comments stay truthful (and the test was updated to match). Backlog (docs/_reflections/log.md): - Removed the "How this relates to the rest of harnessgap" section (it referenced /reflect and docs/_proposals). The file is now a standalone template. - Retired the one local entry — its friction was already promoted to the test isolation fix (b4fefc1), per the loop's own retire step. 11/11 tests pass. The live session marker was migrated to the new dir name so the rename doesn't cause a spurious re-nudge. NOTE: the hook *filename* (harnessgap-retro-hook.cjs) and the local settings.json command still contain "harnessgap". Renaming the file touches the registration path, so it's left for an explicit decision rather than rolled in here. Co-Authored-By: Claude --- .claude/harnessgap-retro-hook.cjs | 29 ++++++++++++++--------------- docs/_reflections/log.md | 11 ----------- test/retro-hook.test.ts | 28 ++++++++++++++-------------- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/.claude/harnessgap-retro-hook.cjs b/.claude/harnessgap-retro-hook.cjs index e7742c0..0da854c 100644 --- a/.claude/harnessgap-retro-hook.cjs +++ b/.claude/harnessgap-retro-hook.cjs @@ -1,16 +1,15 @@ #!/usr/bin/env node -// Auto-generated companion to the /retro command. Fail-open Claude Code Stop hook. -// 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. Mirrors the fail-open discipline of -// harnessgap-stop-hook.js (see src/init/claude.ts, buildWrapperSource). +// Fail-open Claude Code 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. // -// NOT YET WIRED INTO harnessgap init: register it manually in settings.json to -// activate. A portable registration + install via init is tracked in issue #48. +// 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: this repo's package.json has -// "type": "module", which makes .js files ESM and `require` undefined. .cjs is +// 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: @@ -23,8 +22,8 @@ // - 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()/harnessgap-retro; override with $HARNESSGAP_RETRO_DIR). Markers -// older than 30 days are reaped best-effort on each write. +// 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'); @@ -76,9 +75,9 @@ try { if (!key) { nudge = false; } else { - var dir = process.env.HARNESSGAP_RETRO_DIR - ? path.resolve(process.env.HARNESSGAP_RETRO_DIR) - : path.join(os.tmpdir(), 'harnessgap-retro'); + 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' diff --git a/docs/_reflections/log.md b/docs/_reflections/log.md index 5c97f35..11d9a72 100644 --- a/docs/_reflections/log.md +++ b/docs/_reflections/log.md @@ -17,17 +17,6 @@ material the harness is built from, incrementally, over many sessions. 4. **Retire** — once promoted (or judged a one-off), remove the entry so this stays a live to-do list, not an archive. -## How this relates to the rest of harnessgap - -- **`/reflect`** (detector-gated) — fires only when `harnessgap`'s signals trip, and - fills a structured `ReflectFrame` against *confirmed* friction. Use it to close - the loop on friction the detector is confident about. -- **`/retro`** (this file) — fires unconditionally on the agent's own lived - experience, no detector required. Use it to catch friction the detector misses — - which, pre-calibration, is most of it. -- **`docs/_proposals/`** — full synthesized *new-doc* proposals (fact-checked, - model-drafted) for the top friction areas. Heavier weight than these one-liners. - ## Entry format ``` diff --git a/test/retro-hook.test.ts b/test/retro-hook.test.ts index 1d08c8f..6d4663d 100644 --- a/test/retro-hook.test.ts +++ b/test/retro-hook.test.ts @@ -1,7 +1,7 @@ // Fail-open + once-per-session contract for the /retro Stop-hook wrapper // (.claude/harnessgap-retro-hook.cjs). Mirrors the spawn-based style of // test/init.test.ts: feed stdin JSON, assert on stdout + exit code. The wrapper's -// marker dir is redirected via $HARNESSGAP_RETRO_DIR so each case is hermetic. +// marker dir is redirected via $RETRO_MARKERS_DIR so each case is hermetic. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawnSync } from 'node:child_process'; import { @@ -17,7 +17,7 @@ import { join } from 'node:path'; import { createHash } from 'node:crypto'; const WRAPPER = join(process.cwd(), '.claude', 'harnessgap-retro-hook.cjs'); -const DEFAULT_DIR = join(tmpdir(), 'harnessgap-retro'); +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. @@ -26,7 +26,7 @@ function markerName(key: string) { } // Ensure the default-path test (which omits the override) sees no stale value. -delete process.env.HARNESSGAP_RETRO_DIR; +delete process.env.RETRO_MARKERS_DIR; function run(payload: object | string, env: NodeJS.ProcessEnv = {}) { const stdin = typeof payload === 'string' ? payload : JSON.stringify(payload); @@ -53,12 +53,12 @@ describe('retro Stop-hook wrapper', () => { }); it('1. stop_hook_active:true → {} allow (no re-block within a stop attempt)', () => { - const r = run({ stop_hook_active: true, session_id: 's1' }, { HARNESSGAP_RETRO_DIR: dir }); + 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' }, { HARNESSGAP_RETRO_DIR: dir }); + 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'); @@ -66,7 +66,7 @@ describe('retro Stop-hook wrapper', () => { }); it('3. second stop same session → {} allow (once-per-session via marker)', () => { - const env = { HARNESSGAP_RETRO_DIR: dir }; + 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); @@ -74,7 +74,7 @@ describe('retro Stop-hook wrapper', () => { }); it('4. a different session is nudged independently', () => { - const env = { HARNESSGAP_RETRO_DIR: dir }; + 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'); @@ -82,12 +82,12 @@ describe('retro Stop-hook wrapper', () => { 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, { HARNESSGAP_RETRO_DIR: dir }).stdout)).toEqual({}); + 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 = { HARNESSGAP_RETRO_DIR: dir }; + 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); @@ -95,12 +95,12 @@ describe('retro Stop-hook wrapper', () => { }); it('7. no session_id and no transcript_path → {} (cannot enforce once-per-session)', () => { - const r = run({ stop_hook_active: false }, { HARNESSGAP_RETRO_DIR: dir }); + 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' }, { HARNESSGAP_RETRO_DIR: dir }); + const r = run({ stop_hook_active: 'true', session_id: 's8' }, { RETRO_MARKERS_DIR: dir }); expect(JSON.parse(r.stdout).decision).toBe('block'); }); @@ -108,12 +108,12 @@ describe('retro Stop-hook wrapper', () => { // /dev/null is a file, so mkdir under it throws ENOTDIR → marker branch fails. const r = run( { stop_hook_active: false, session_id: 's9' }, - { HARNESSGAP_RETRO_DIR: '/dev/null/retro-test' } + { RETRO_MARKERS_DIR: '/dev/null/retro-test' } ); expect(JSON.parse(r.stdout)).toEqual({}); }); - it('10. default marker path (HARNESSGAP_RETRO_DIR unset) — block then allow', () => { + 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')); @@ -134,7 +134,7 @@ describe('retro Stop-hook wrapper', () => { 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' }, { HARNESSGAP_RETRO_DIR: dir }); + 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); From 33fc85f4a6a9d187bad39f4dacda7d05619e8397 Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 20:17:27 +0300 Subject: [PATCH 6/7] feat(init): wire /retro into init for claude/qwen/gigacode (multi-harness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserves harnessgap's multi-harness rule: /retro is now installed by `harnessgap init claude|qwen|gigacode` alongside /reflect, instead of being shipped as a Claude-only committed artifact. - src/init/claude.ts: add exported buildRetroWrapperSource() (String.raw — the wrapper source has a regex + \n escapes) and buildRetroCommandSource({memoryFile}) (parameterized CLAUDE.md/QWEN.md/GIGACODE.md; genericized tool names; no frontmatter / $ARGUMENTS so it's portable across harnesses). initClaude writes 5 artifacts (reflect + retro wrappers, their commands, settings.json) and installs BOTH Stop hooks — reflect first, retro second. mergeStopHook dedups by exact command string, so the two distinct wrappers coexist idempotently. - src/init/qwen.ts: installStopHook mirrors the same for qwen + giga (artifacts 3 -> 5), using the threaded memoryFile (QWEN.md / GIGACODE.md). - src/adapter/index.ts: claudeInstallHook maps the 5 artifacts into InitResult. - Removed committed .claude/commands/retro.md + .claude/harnessgap-retro-hook.cjs — they are now init-generated (untracked), matching reflect's pattern. - Tests: retro-hook.test.ts drives buildRetroWrapperSource (baked temp file, no hardcoded .claude path); init/init-qwen/cli-harness assert 5 artifacts, 2 Stop entries (reflect first), per-harness memory files, and idempotency of BOTH hooks. 49 pass + 2 todo. End-to-end verified: init claude/qwen/gigacode each produce 5 artifacts + 2 Stop entries; retro.md references the correct memory file; the installed retro wrapper blocks once per session then {}. Co-Authored-By: Claude --- .claude/commands/retro.md | 110 ------------ .claude/harnessgap-retro-hook.cjs | 107 ------------ src/adapter/index.ts | 6 +- src/init/claude.ts | 282 +++++++++++++++++++++++++++++- src/init/qwen.ts | 22 ++- test/cli-harness.test.ts | 4 + test/init-qwen.test.ts | 87 +++++---- test/init.test.ts | 82 +++++---- test/retro-hook.test.ts | 17 +- 9 files changed, 413 insertions(+), 304 deletions(-) delete mode 100644 .claude/commands/retro.md delete mode 100644 .claude/harnessgap-retro-hook.cjs diff --git a/.claude/commands/retro.md b/.claude/commands/retro.md deleted file mode 100644 index f51a143..0000000 --- a/.claude/commands/retro.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -description: Unconditional end-of-session friction capture — appends one-line seeds to docs/_reflections/log.md. -argument-hint: "[optional focus area]" ---- - -# /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. - -> Scope note, if any: $ARGUMENTS — if blank, reflect on the whole session; -> otherwise weight your evidence toward that area. - -## 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: - `CLAUDE.md 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`. (→ CLAUDE.md note)" - -## 3. Append to the backlog — and ONLY the backlog - -**Your only file mutation is appending to `docs/_reflections/log.md`.** Do not Edit -or Write any other file — including `CLAUDE.md`, `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.** diff --git a/.claude/harnessgap-retro-hook.cjs b/.claude/harnessgap-retro-hook.cjs deleted file mode 100644 index 0da854c..0000000 --- a/.claude/harnessgap-retro-hook.cjs +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -// Fail-open Claude Code 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: every -// decision flows into a terminal emit() call, so nothing runs after a decision. -function emit(payload) { process.stdout.write(payload, function () { process.exit(0); }); } - -// Best-effort reap of marker files older than 30 days so the cache can't grow -// unbounded. Fully fail-safe — a prune error never affects the nudge decision. -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; - // Unparseable / non-object stdin → cannot read the loop guard (stop_hook_active) → allow. - if (payload === null || typeof payload !== 'object') nudge = false; - // Loop guard: a Stop hook already blocked this stop attempt → allow. - if (nudge && payload.stop_hook_active === true) nudge = false; - if (nudge) { - var key = payload.session_id || payload.transcript_path || ''; - // No session key → cannot enforce once-per-session → allow (never risk looping). - 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; // already nudged this session - } else { - fs.mkdirSync(dir, { recursive: true }); - try { pruneOldMarkers(dir); } catch (e) { /* best-effort */ } - fs.writeFileSync(marker, String(Date.now())); - } - } catch (e) { - // Marker machinery failed → fail OPEN (never block on a wrapper fault). - nudge = false; - } - } - } - - if (nudge) { - emit(JSON.stringify({ decision: 'block', reason: REASON }) + '\n'); - } else { - emit('{}\n'); - } -} catch (e) { - emit('{}\n'); -} 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/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 index 6d4663d..fa152f1 100644 --- a/test/retro-hook.test.ts +++ b/test/retro-hook.test.ts @@ -1,8 +1,9 @@ -// Fail-open + once-per-session contract for the /retro Stop-hook wrapper -// (.claude/harnessgap-retro-hook.cjs). Mirrors the spawn-based style of -// test/init.test.ts: feed stdin JSON, assert on stdout + exit code. The wrapper's +// 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 } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, @@ -15,8 +16,13 @@ import { 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 WRAPPER = join(process.cwd(), '.claude', 'harnessgap-retro-hook.cjs'); const DEFAULT_DIR = join(tmpdir(), 'retro-markers'); // The wrapper keys markers on sha1(key).slice(0,24) + '.marker'; mirror that to @@ -51,6 +57,7 @@ describe('retro Stop-hook wrapper', () => { // 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 }); From 87db14eb83506855aaac77014b93a3deb94f3e3f Mon Sep 17 00:00:00 2001 From: dmitry Date: Tue, 28 Jul 2026 21:54:46 +0300 Subject: [PATCH 7/7] test(harness-dispatch): update installHook artifact count 3 -> 5 (retro) CI caught a test the targeted exploration missed: CLAUDE_SPEC.installHook now returns 5 artifacts (reflect + retro), not 3. Update the count, destructure, and on-disk assertions. Full suite now passes locally (737 + 2 todo). Co-Authored-By: Claude --- test/harness-dispatch.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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);