Skip to content
34 changes: 34 additions & 0 deletions docs/_reflections/log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Reflections backlog

Raw, low-commitment friction observations captured by `/retro` at the end of
sessions. This file is **not** documentation and **not** config — it is the raw
material the harness is built from, incrementally, over many sessions.

## The loop

1. **Capture** — `/retro` appends one-line proposals here after each session. Each
names a concrete thing that was not obvious, a time sink, or a wrong assumption,
plus the *kind* of harness change that might address it.
2. **Spot recurrence** — when the same friction shows up across multiple entries,
that recurrence is the signal it's worth acting on. A single hit may be noise.
3. **Triage** — promote a recurring item into the harness: a `CLAUDE.md` note, a
permission in `settings.json`, a new command, a doc, or a refactor. This step is
**human judgment** — `/retro` deliberately does not auto-edit the harness.
4. **Retire** — once promoted (or judged a one-off), remove the entry so this stays
a live to-do list, not an archive.

## Entry format

```
## <ISO date> · <one-line session goal> · <success | partial | fail | clean>
- **<area>** — <one-line proposal ending in "(→ <kind>)", e.g. "it was not obvious that …; would have helped to know … (→ CLAUDE.md note)">
```

- `<area>` ∈ `init | command | hook | detector | cli | docs | test | build | other` (fixed vocabulary so recurrence is matchable).
- `<kind>` ∈ `CLAUDE.md note | permission | command | doc | refactor | none`.

This file is committed on purpose — a team-shared friction backlog is the point.

---

<!-- Entries below. Newest last. -->
6 changes: 3 additions & 3 deletions src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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}`,
};
}

Expand Down
282 changes: 277 additions & 5 deletions src/init/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<root>/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 <path-to-this-file>'. 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 \`<concrete fact>\`; would have helped to know \`<this>\` up front."
- "Spent a while on \`<X>\` until I realized \`<Y>\`."
- "Assumed \`<Z>\`; actually \`<W>\`."
- **Tagged** — end the line with \`(→ <kind>)\` where \`<kind>\` 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 (\`<area>\` from the fixed vocabulary
below so recurrence is matchable across sessions):

\`\`\`
## <ISO date> · <one-line session goal> · <success | partial | fail | clean>
- **<area>** — <one-line proposal ending in "(→ <kind>)">
\`\`\`

\`<area>\` 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 \`<theme>\` 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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
};
}
Loading
Loading