@damiangilgonzalez/harness 1.3.12 → 1.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +31 -9
  2. package/manifest.json +1 -1
  3. package/package.json +3 -2
  4. package/scripts/doctor.js +109 -2
  5. package/scripts/install-apply.js +127 -7
  6. package/scripts/lib/capabilities.js +19 -2
  7. package/scripts/lib/divergence.js +8 -1
  8. package/scripts/lib/onboarding-apply.js +18 -1
  9. package/scripts/lib/onboarding.js +27 -2
  10. package/scripts/lib/versions.js +31 -0
  11. package/scripts/rsc.js +66 -16
  12. package/skills/animar/SKILL.md +1 -0
  13. package/skills/claude-project-setup/SKILL.md +1 -0
  14. package/skills/code-review/SKILL.md +1 -0
  15. package/skills/diseno-apple/SKILL.md +1 -0
  16. package/skills/diseno-landing/SKILL.md +1 -0
  17. package/skills/domain-modeling/SKILL.md +1 -0
  18. package/skills/flujo-interfaz/SKILL.md +1 -0
  19. package/skills/grill-me/SKILL.md +1 -0
  20. package/skills/grill-with-docs/SKILL.md +1 -0
  21. package/skills/grilling/SKILL.md +1 -0
  22. package/skills/handoff/SKILL.md +1 -0
  23. package/skills/implement/SKILL.md +1 -0
  24. package/skills/ingeniero-diseno-web/SKILL.md +1 -0
  25. package/skills/leyes-de-percepcion/SKILL.md +1 -0
  26. package/skills/leyes-de-retencion/SKILL.md +1 -0
  27. package/skills/mejor-accesibilidad/SKILL.md +1 -0
  28. package/skills/mejor-colores/SKILL.md +1 -0
  29. package/skills/mejor-layout/SKILL.md +1 -0
  30. package/skills/mejor-redaccion/SKILL.md +1 -0
  31. package/skills/mejor-tipografia/SKILL.md +1 -0
  32. package/skills/mejor-ui/SKILL.md +1 -0
  33. package/skills/muscle-memory/SKILL.md +1 -0
  34. package/skills/prototype/SKILL.md +1 -0
  35. package/skills/research/SKILL.md +1 -0
  36. package/skills/revision-de-cambios/SKILL.md +1 -0
  37. package/skills/revision-interfaz/SKILL.md +1 -0
  38. package/skills/sitios-calidad-premio/SKILL.md +1 -0
  39. package/skills/suggest/SKILL.md +10 -9
  40. package/skills/tastemaker/SKILL.md +1 -0
  41. package/skills/teach/SKILL.md +1 -0
  42. package/skills/to-questionnaire/SKILL.md +1 -0
  43. package/skills/to-spec/SKILL.md +1 -0
  44. package/skills/to-tickets/SKILL.md +1 -0
  45. package/skills/video-a-superprompt/SKILL.md +1 -0
  46. package/skills/vocabulario-animacion/SKILL.md +1 -0
  47. package/skills/wait-what/SKILL.md +1 -0
  48. package/skills/wayfinder/SKILL.md +1 -0
  49. package/skills/write-adr/SKILL.md +1 -0
  50. package/skills/writing-for-agents/SKILL.md +1 -0
  51. package/targets/_md-block.js +8 -1
  52. package/targets/agents-md-shadow.js +153 -0
  53. package/targets/agents.js +9 -2
  54. package/targets/claude.js +52 -11
  55. package/targets/clone-bootstrap.mjs +454 -0
  56. package/targets/gitmoji-guard.mjs +30 -3
  57. package/targets/opt-outs.js +40 -0
  58. package/targets/session-memory-adapter.mjs +70 -14
  59. package/targets/session-memory-core.mjs +39 -7
  60. package/targets/session-start.mjs +63 -10
  61. package/targets/worktree-reaper.mjs +174 -10
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { pathToFileURL } from 'node:url';
3
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
4
  import { resolve, join, dirname } from 'node:path';
5
5
  import { capture, resume } from './session-memory-core.mjs';
6
6
 
@@ -56,15 +56,35 @@ function hookEventName(target, event, native) {
56
56
  return names[event] || event;
57
57
  }
58
58
 
59
+ const COMPACTION_HINT = 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.';
60
+
61
+ /**
62
+ * Two different things used to share one channel, and that is the whole bug.
63
+ *
64
+ * The resume CONTEXT is information for the model, and it is only ever produced on `start` —
65
+ * `SessionStart`, one of the few events whose output may carry `hookSpecificOutput.additionalContext`.
66
+ * The compaction HINT is a message for the PERSON, and it was going out through the same field on
67
+ * whatever event happened to be running. On `Stop`, `PreCompact` and `SessionEnd` the client rejects
68
+ * that outright ("Hook JSON output validation failed — hookSpecificOutput.hookEventName: expected one
69
+ * of …"), so the hook meant to help ended up printing an error after every turn, and after
70
+ * `/compact` in particular — which is exactly what the hint had just told the person to run.
71
+ *
72
+ * So the hint moves to `systemMessage`, which every event accepts. This needs no table of which
73
+ * event carries what, and it cannot rot when the client adds an event: anything addressed to the
74
+ * person travels by the universal channel, and only genuine model context uses the narrow one.
75
+ */
59
76
  function nativeOutput(target, eventName, context = '', notice = null, compactionHint = false) {
60
- const hint = compactionHint ? 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.' : '';
61
- const extra = [context, hint].filter(Boolean).join('\n');
62
- if (!extra && !notice) return {};
63
- if (target === 'cursor') return { ...(extra ? { additional_context: extra } : {}), ...(notice ? { user_message: notice } : {}) };
64
- if (target === 'opencode') return { context: extra, notice };
77
+ const hint = compactionHint ? COMPACTION_HINT : '';
78
+ if (!context && !hint && !notice) return {};
79
+ if (target === 'cursor') {
80
+ const message = [notice, hint].filter(Boolean).join('\n');
81
+ return { ...(context ? { additional_context: context } : {}), ...(message ? { user_message: message } : {}) };
82
+ }
83
+ if (target === 'opencode') return { context, notice: [notice, hint].filter(Boolean).join('\n') || null };
84
+ const message = [notice, hint].filter(Boolean).join('\n');
65
85
  return {
66
- ...(notice ? { systemMessage: notice } : {}),
67
- ...(extra ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: extra } } : {}),
86
+ ...(message ? { systemMessage: message } : {}),
87
+ ...(context ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: context } } : {}),
68
88
  };
69
89
  }
70
90
 
@@ -78,15 +98,18 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
78
98
  try {
79
99
  if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
80
100
  if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
81
- const project = nearestHarness(resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd()));
101
+ // Two answers from one path, kept apart on purpose: `project` is where the journal is KEPT (the
102
+ // nearest harness), `here` is where the agent WORKS and therefore who the session is.
103
+ const here = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
104
+ const project = nearestHarness(here);
82
105
  if (!existsSync(project)) throw new Error('project directory unavailable');
83
106
  const config = settings || projectSettings(project);
84
107
  if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };
85
108
  const id = sessionId(native, target);
86
109
  const eventName = hookEventName(target, event, native);
87
110
  if (event === 'start') {
88
- const started = capture({ cwd: project, sessionId: id, target, event: 'start', settings: config });
89
- const resumed = resume({ cwd: project, target, settings: config });
111
+ const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
112
+ const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
90
113
  return {
91
114
  output: nativeOutput(target, eventName, resumed.context),
92
115
  capture: started,
@@ -97,6 +120,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
97
120
  }
98
121
  const captureInput = {
99
122
  cwd: project,
123
+ worktreeCwd: here,
100
124
  sessionId: id,
101
125
  target,
102
126
  event: event === 'end' ? 'sessionEnd' : event,
@@ -106,8 +130,13 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
106
130
  if (typeof native.cost === 'number') captureInput.cost = native.cost;
107
131
  if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
108
132
  const captured = capture(captureInput);
133
+ // `stop_hook_active` is the client saying "this turn is already running BECAUSE a stop hook
134
+ // asked for it". Speaking again here is how a Stop hook wakes itself: the student's session did
135
+ // nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
136
+ // nothing.
137
+ const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
109
138
  return {
110
- output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
139
+ output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
111
140
  capture: captured,
112
141
  remote: false,
113
142
  degraded: false,
@@ -126,7 +155,34 @@ function stdinJson() {
126
155
  }
127
156
  }
128
157
 
129
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
158
+ /**
159
+ * "Was this file run directly?" — the same question `clone-bootstrap.mjs` answers, and the same two
160
+ * traps, so the same answer. Written out here rather than imported because hooks are materialized
161
+ * file by file under `.rsc/`: an import would be a second file to copy for six lines.
162
+ *
163
+ * `process.argv[1]` MAY NOT EXIST. Under `node -e`, `--input-type=module` or the REPL there is no
164
+ * script path, and `pathToFileURL(undefined)` throws — at module load, so merely IMPORTING this
165
+ * file takes the importer down with it. A module that cannot be imported is a module nobody can
166
+ * build a tool on top of, and people do exactly that with this package.
167
+ *
168
+ * SYMLINKS MAKE THE TWO SIDES DISAGREE. `import.meta.url` is what the loader resolved (symlinks
169
+ * RESOLVED); `process.argv[1]` is the raw string the client passed (symlinks INTACT). One
170
+ * symlinked component — `/tmp` and `/var` on macOS, or an ordinary `~/code -> /Volumes/…` — and a
171
+ * string compare says "no", the main block never runs, node exits 0 having printed nothing, and
172
+ * the hook becomes a silent no-op that `doctor` still reports as wired.
173
+ */
174
+ function isMainModule(metaUrl) {
175
+ const invoked = process.argv[1];
176
+ if (!invoked) return false;
177
+ const self = fileURLToPath(metaUrl);
178
+ try {
179
+ return realpathSync(self) === realpathSync(invoked);
180
+ } catch {
181
+ return self === invoked;
182
+ }
183
+ }
184
+
185
+ if (isMainModule(import.meta.url)) {
130
186
  const result = handleLifecycle({ target: process.argv[2], event: process.argv[3], native: stdinJson() });
131
187
  process.stdout.write(`${JSON.stringify(result.output)}\n`);
132
188
  }
@@ -284,6 +284,12 @@ function readRecords(root) {
284
284
 
285
285
  export function capture(input = {}) {
286
286
  const cwd = resolve(input.cwd || process.cwd());
287
+ // WHERE the journal lives and WHO the session is are two different questions. `cwd` answers the
288
+ // first — the nearest harness, so a child with no manifest of its own does not scatter journals.
289
+ // The second has to be asked of git from where the agent actually works: asked from the harness,
290
+ // every worktree nested under it came back as the harness itself, and a sibling's record then
291
+ // "matched exactly". See GHSA-8gjp-3r7c-33f4.
292
+ const here = resolve(input.worktreeCwd || cwd);
287
293
  const config = settings(input.settings);
288
294
  if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
289
295
  const store = chooseMemoryRoot(cwd);
@@ -299,7 +305,7 @@ export function capture(input = {}) {
299
305
  const recordPath = join(store.root, 'sessions', `${storageId}.json`);
300
306
  let anchor = readJson(anchorPath);
301
307
  let existing = readJson(recordPath);
302
- const firstSnapshot = snapshot(cwd, null);
308
+ const firstSnapshot = snapshot(here, null);
303
309
  if (!anchor) {
304
310
  anchor = {
305
311
  sessionId,
@@ -312,10 +318,23 @@ export function capture(input = {}) {
312
318
  }
313
319
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
314
320
 
315
- const repo = snapshot(cwd, anchor.baselineHead);
321
+ const repo = snapshot(here, anchor.baselineHead);
322
+ // Count what CHANGED, not which tool changed it. `editDelta` is +1 per Edit/Write tool event, and
323
+ // on its own it was blind to everything done through the shell — a heredoc, `sed -i`, a script, a
324
+ // formatter — which is how automated sessions work. The session that found this had touched dozens
325
+ // of files and its record said editCount=1, so the compaction hint never reached the long sessions
326
+ // it exists for.
327
+ //
328
+ // `snapshot` already fingerprints every modified file on every event. Comparing those fingerprints
329
+ // with the previous event's counts a change from any source. `max`, not `+`: an Edit that changed a
330
+ // file shows up in BOTH signals, and must count once.
331
+ const previousFingerprints = anchor.lastFingerprints || anchor.baselineFingerprints || {};
332
+ const observed = Object.entries(repo.fingerprints || {})
333
+ .filter(([path, fingerprint]) => fingerprint && previousFingerprints[path] !== fingerprint).length;
334
+ const editDelta = Math.max(finiteOrNull(input.editDelta, true) || 0, observed);
316
335
  // Compaction closes a context window, so the edit budget starts again from zero.
317
336
  const previousEdits = input.event === 'compact' ? 0 : (existing?.editCount || 0);
318
- const editCount = Math.max(0, previousEdits + (finiteOrNull(input.editDelta, true) || 0));
337
+ const editCount = Math.max(0, previousEdits + editDelta);
319
338
  // Hint once per threshold crossed, and only on the edit that crosses it: re-emitting on
320
339
  // every later event (Stop in particular) wakes the model again and loops.
321
340
  const crossedThreshold = editCount > previousEdits
@@ -358,11 +377,18 @@ export function capture(input = {}) {
358
377
  if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
359
378
  atomicJson(recordPath, record);
360
379
  existing = record;
380
+ const compactionHint = Boolean(config.compactionHint && crossedThreshold);
381
+ // The fingerprints the next event compares against live on the ANCHOR, not on the record: the
382
+ // record's schema is closed and every field of it is required, so a new field there would fail
383
+ // validation on every record already written. Written only when they moved, so a read-only shell
384
+ // command costs no extra disk.
385
+ const fingerprintsMoved = observed > 0 || !anchor.lastFingerprints;
386
+ if (fingerprintsMoved) atomicJson(anchorPath, { ...anchor, lastFingerprints: repo.fingerprints || {} });
361
387
  return {
362
388
  record,
363
389
  path: recordPath,
364
390
  notice: consumeNotice(store),
365
- compactionHint: Boolean(config.compactionHint && crossedThreshold),
391
+ compactionHint,
366
392
  };
367
393
  }
368
394
 
@@ -414,13 +440,19 @@ export function resume(input = {}) {
414
440
  ensureStore(store.root);
415
441
  const now = iso(input.now);
416
442
  prune(store.root, now, config);
417
- const current = snapshot(cwd, null);
418
- const records = readRecords(store.root).filter((record) => validAnchor(cwd, record))
443
+ const here = resolve(input.worktreeCwd || cwd);
444
+ const current = snapshot(here, null);
445
+ const records = readRecords(store.root).filter((record) => validAnchor(here, record))
419
446
  .sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
420
447
  let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
421
448
  let match = record ? 'exact' : 'none';
449
+ // Only ever this worktree's own records. The chain used to end in `|| records[0]` — the newest
450
+ // record in the store, whoever wrote it — which a moved or renamed checkout reached every time,
451
+ // because its stale records match nothing. A shared store is a place to KEEP several worktrees'
452
+ // journals, never a reason to hand one of them to another. No continuation is the honest answer
453
+ // when there is nothing of yours to continue.
422
454
  if (!record) {
423
- record = records.find((candidate) => candidate.worktree === current.worktree) || records[0] || null;
455
+ record = records.find((candidate) => candidate.worktree === current.worktree) || null;
424
456
  if (record) match = 'nearby';
425
457
  }
426
458
  const lessons = selectedLessons(store.root, config);
@@ -30,14 +30,17 @@ const has = (...p) => existsSync(join(root, ...p));
30
30
 
31
31
  // The npm lookup (up to 1.5 s) starts now and runs while the local checks below do their work;
32
32
  // the update block at the end only awaits it.
33
- const latestVersion = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve(null)
34
- : process.env.RSC_LATEST ? Promise.resolve(process.env.RSC_LATEST)
33
+ // The registry returns the whole package.json on `/latest`, so `rscFixes` arrives in the same
34
+ // response as the version. RSC_LATEST_JSON / RSC_LATEST override the lookup (tests / mirrors).
35
+ const latestDoc = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve(null)
36
+ : process.env.RSC_LATEST_JSON ? Promise.resolve().then(() => JSON.parse(process.env.RSC_LATEST_JSON)).catch(() => null)
37
+ : process.env.RSC_LATEST ? Promise.resolve({ version: process.env.RSC_LATEST })
35
38
  : (async () => {
36
39
  const ctrl = new AbortController();
37
40
  const timer = setTimeout(() => ctrl.abort(), 1500);
38
41
  try {
39
42
  const res = await fetch('https://registry.npmjs.org/@damiangilgonzalez%2fharness/latest', { signal: ctrl.signal });
40
- return (await res.json()).version;
43
+ return await res.json();
41
44
  } catch { return null; } finally { clearTimeout(timer); }
42
45
  })();
43
46
 
@@ -208,19 +211,31 @@ ACTION: run \`npx @damiangilgonzalez/harness audit\`. Opt out with .rsc/.no-audi
208
211
  // it's a relocation, not a delete). Opt out with .rsc/.no-claudemd-check.
209
212
  const CLAUDEMD_MAX_LINES = 200;
210
213
  if (!has('.rsc', '.no-claudemd-check')) {
214
+ // WHICH file is the project's instructions is no longer a constant. From Claude Code 2.1.277, a
215
+ // project with no CLAUDE.md is read through its root AGENTS.md instead — same per-turn cost, same
216
+ // adherence rot when it grows, and until now nothing measured it. Measure the file that is
217
+ // actually being loaded: CLAUDE.md when there is one, otherwise AGENTS.md.
218
+ const name = has('CLAUDE.md') ? 'CLAUDE.md' : (has('AGENTS.md') ? 'AGENTS.md' : null);
211
219
  try {
212
- const lines = readFileSync(join(root, 'CLAUDE.md'), 'utf8').split('\n').length;
220
+ const lines = readFileSync(join(root, name), 'utf8').split('\n').length;
213
221
  if (lines > CLAUDEMD_MAX_LINES) {
222
+ // Only the CLAUDE.md claim is unconditional. A client older than 2.1.277 does not read
223
+ // AGENTS.md at all, and this hook cannot tell which it is talking to (the SessionStart
224
+ // payload carries no version), so the AGENTS.md line says when the cost applies instead of
225
+ // asserting it — a notice that overstates its case is the kind that gets opted out of.
226
+ const why = name === 'CLAUDE.md'
227
+ ? "it's read every turn, so each line costs context"
228
+ : "Claude Code 2.1.277+ loads it every turn when there is no CLAUDE.md, so each line costs context";
214
229
  process.stdout.write(`
215
- ===== rsc CLAUDE.md hygiene =====
216
- CLAUDE.md is ${lines} lines — over the ~${CLAUDEMD_MAX_LINES}-line budget (it's read every turn, so each line costs context).
230
+ ===== rsc ${name} hygiene =====
231
+ ${name} is ${lines} lines — over the ~${CLAUDEMD_MAX_LINES}-line budget (${why}).
217
232
  ACTION: offload the Knowledge map / overgrown sections into docs/wiki/index.md and leave a short
218
- pointer in CLAUDE.md (no info lost — it's a move). The \`harness\` skill owns the procedure.
233
+ pointer in ${name} (no info lost — it's a move). The \`harness\` skill owns the procedure.
219
234
  Opt out with .rsc/.no-claudemd-check.
220
235
  =================================
221
236
  `);
222
237
  }
223
- } catch { /* no CLAUDE.md → nothing to check */ }
238
+ } catch { /* no instructions file (name === null) → nothing to check */ }
224
239
  }
225
240
 
226
241
  // Worktree sweep: a worktree whose work already reached the trunk is landed work wearing the same
@@ -287,12 +302,49 @@ function isNewer(a, b) {
287
302
  return false;
288
303
  }
289
304
 
305
+ // "rsc X is out" was being ignored — the maintainer's own workspace sat on 2.0.4 for days with this
306
+ // notice on every session, and two people in two days reported bugs that were already fixed. A
307
+ // version number names nothing anyone recognises. A symptom does: someone who sees a red error after
308
+ // every turn updates the moment they are told that is what the update fixes (`suggest` §3: say what
309
+ // is wrong as a symptom, not as a cause).
310
+ //
311
+ // The symptoms travel for free: the registry's `/latest` document is the published package.json, so
312
+ // its `rscFixes` field — `{ fixedIn, symptom }` — arrives in the response this check already makes.
313
+ // It is text a model will read, so every entry is reduced to one short plain line or dropped.
314
+ const SEMVER_ONLY = /^\d+\.\d+\.\d+$/;
315
+ function fixesSince(list, installed, latest) {
316
+ if (!Array.isArray(list)) return [];
317
+ const out = [];
318
+ for (const item of list) {
319
+ if (!item || typeof item !== 'object' || typeof item.symptom !== 'string') continue;
320
+ if (!SEMVER_ONLY.test(String(item.fixedIn))) continue;
321
+ if (!isNewer(item.fixedIn, installed) || isNewer(item.fixedIn, latest)) continue;
322
+ const symptom = item.symptom.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
323
+ if (symptom) out.push({ fixedIn: item.fixedIn, symptom });
324
+ }
325
+ return out.slice(0, 8);
326
+ }
327
+
290
328
  if (!process.env.RSC_NO_UPDATE_CHECK) {
291
329
  try {
292
330
  const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
293
- const latest = await latestVersion;
331
+ const doc = await latestDoc;
332
+ const latest = doc?.version;
294
333
  if (installed && latest && isNewer(latest, installed)) {
295
- process.stdout.write(`
334
+ let fixes = [];
335
+ try { fixes = fixesSince(doc.rscFixes, installed, latest); } catch { fixes = []; }
336
+ if (fixes.length) {
337
+ process.stdout.write(`
338
+ ===== rsc update available =====
339
+ rsc ${latest} is out — you have ${installed}. It fixes problems this install still has:
340
+ ${fixes.map((f) => ` - ${f.symptom} (fixed in ${f.fixedIn})`).join('\n')}
341
+ ACTION: before starting the task, tell the user in one line that the update fixes what they
342
+ may be seeing, and if they say yes, run it in this project:
343
+ npx @damiangilgonzalez/harness@latest
344
+ ================================
345
+ `);
346
+ } else {
347
+ process.stdout.write(`
296
348
  ===== rsc update available =====
297
349
  rsc ${latest} is out — you have ${installed}.
298
350
  ACTION: tell the user a new version is available and, if they say yes, run:
@@ -300,6 +352,7 @@ ACTION: tell the user a new version is available and, if they say yes, run:
300
352
  (That reinstalls and refreshes the skill content to the latest.)
301
353
  ================================
302
354
  `);
355
+ }
303
356
  }
304
357
  } catch { /* offline / no baseline / parse error → stay silent */ }
305
358
  }
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
18
18
  // rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
19
- import { existsSync, realpathSync } from 'node:fs';
19
+ import { existsSync, realpathSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
20
20
  import { join, resolve, dirname, basename, relative, sep } from 'node:path';
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { pathToFileURL } from 'node:url';
@@ -54,8 +54,20 @@ export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
54
54
  // Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
55
55
  const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
56
56
 
57
- // Branch shapes `worktrees` imposes on the isolation it creates.
58
- const RSC_BRANCH = /^(?:feat|feature)\//;
57
+ // Branch shapes the catalog's isolation produces.
58
+ //
59
+ // `worktrees` documents `feat/<slug>`, and while SDD was the only lane that was the whole set:
60
+ // every isolated branch was a feature. 2.0.0 made FTD the default and FTD names a branch for what
61
+ // it is — a fix is not a feature — so `fix/`, `docs/` and `chore/` fell outside, scored as half a
62
+ // signal, and were never swept, while `ftd` promises in writing that the cleanup is automatic once
63
+ // the branch lands. Found by using it: the two fixes released on 2026-09-18 both had to be removed
64
+ // by hand. These are the Conventional Commits types, which is what the commit guard already
65
+ // enforces on every commit in a project rsc governs — so the branch shape and the commit shape now
66
+ // come from the same vocabulary instead of two that drifted apart.
67
+ //
68
+ // Widening one signal, never the conjunction: a branch outside this vocabulary is still `ambiguous`
69
+ // wherever it sits, and one of these in a directory rsc does not own is still `ambiguous` too.
70
+ const RSC_BRANCH = /^(?:feat|feature|fix|docs|chore|refactor|test|perf|ci|build|style)\//;
59
71
 
60
72
  function git(cwd, args) {
61
73
  // 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
@@ -183,13 +195,23 @@ export function provenanceOf(root, wt) {
183
195
  * The reflog is git's own record of the ref moving; when it cannot answer, the answer is no, because
184
196
  * accumulating a stale worktree costs a directory and the other mistake costs someone's afternoon.
185
197
  */
186
- export function hasLandedWork(root, wt, trunk) {
187
- const tip = git(root, ['rev-parse', `${trunk}^{commit}`]);
188
- if (tip.ok && wt.head && tip.out === wt.head) return false;
198
+ export function hasLandedWork(root, wt) {
189
199
  if (!wt.branch) return false;
200
+ // Ask the reflog FIRST. It is the only record that distinguishes the two states that look
201
+ // identical from the outside: a worktree sitting at the trunk because it was just created there,
202
+ // and one sitting at the trunk because its work landed by fast-forward and took the trunk with
203
+ // it. Until 2.0.3 the tip comparison below ran first and answered "nothing landed" for both, so
204
+ // every branch landed the ordinary FTD way — commit, push, fast-forward — was kept for ever.
205
+ // Found on 2026-09-18 watching this repo refuse to sweep its own branch for the wrong reason.
190
206
  const log = git(root, ['reflog', 'show', '--format=%gs', wt.branch]);
191
- if (!log.ok || !log.out) return false;
192
- return log.out.split('\n').some((line) => line.startsWith('commit'));
207
+ if (log.ok && log.out) return log.out.split('\n').some((line) => line.startsWith('commit'));
208
+ // No reflog to ask — a fresh clone, or a branch whose reflog has expired. There is then no
209
+ // evidence this branch ever carried anything, and this whole module fails towards keeping the
210
+ // directory: unproven is not proven. (The tip comparison that used to sit here is deliberately
211
+ // gone rather than kept as a fallback: both of its outcomes are this same answer, and a line
212
+ // that cannot change the result while reading like a guard is the kind of decoration that made
213
+ // the bug above hard to see.)
214
+ return false;
193
215
  }
194
216
 
195
217
  export function integrationOf(root, wt, trunk) {
@@ -283,7 +305,7 @@ export function classifyWorktrees(root) {
283
305
 
284
306
  const integration = integrationOf(root, wt, trunk);
285
307
  if (integration !== 'integrated') return { ...base, reasons: [integration] };
286
- if (!hasLandedWork(root, wt, trunk)) return { ...base, reasons: ['nothing-landed'] };
308
+ if (!hasLandedWork(root, wt)) return { ...base, reasons: ['nothing-landed'] };
287
309
 
288
310
  const { dirty, outside, readable } = contentOutsideHistory(wt.path);
289
311
  if (!readable) return { ...base, reasons: ['unreadable'] };
@@ -373,6 +395,144 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
373
395
  * README.md", judged that a nuisance, confirmed, and the confirmation landed on a `production.env`
374
396
  * the message never mentioned. The module knew. It just did not say.
375
397
  */
398
+ /**
399
+ * The unattended half: remove what classification already called `safe`, and nothing else.
400
+ *
401
+ * `sweep` offers and never acts, on purpose — there is a human reading it. This runs from a git hook
402
+ * the moment work lands, where there is nobody to ask, so the two verdicts that would have become a
403
+ * question become a refusal instead. It adds NO judgement of its own: the judgement is the dangerous
404
+ * part, it is written above, and it is already tested in both directions.
405
+ *
406
+ * Two properties its caller depends on, both load-bearing:
407
+ * - it never throws. The caller is git, mid-merge. A throw here would turn a cleanup into a failed
408
+ * merge, which is a far worse bug than the one this fixes.
409
+ * - it is silent when there is nothing to do, so the common merge prints nothing.
410
+ */
411
+ export function autoReap(root) {
412
+ const result = { reaped: [], skipped: [], disabled: false };
413
+ try {
414
+ if (!isCleanupEnabled(root)) {
415
+ result.disabled = true;
416
+ return result;
417
+ }
418
+ for (const candidate of classifyWorktrees(root)) {
419
+ // Not the protection, and it must not be mistaken for one: `reapWorktree` refuses an `ask`
420
+ // on its own, and that refusal is the gate — mutation-tested by 18b and 23. Verified here on
421
+ // 2026-09-17 by removing this line: every test still passed. It stays because skipping early
422
+ // avoids re-classifying the whole repository once per candidate, and because the recorded
423
+ // reason is then the verdict itself rather than a message written for a human to read.
424
+ if (candidate.verdict !== 'safe') {
425
+ result.skipped.push({ path: candidate.path, reason: candidate.reasons.join(', ') || candidate.verdict });
426
+ continue;
427
+ }
428
+ const out = reapWorktree(root, candidate.path);
429
+ if (out.removed) result.reaped.push(candidate.path);
430
+ else result.skipped.push({ path: candidate.path, reason: out.reason });
431
+ }
432
+ } catch (err) {
433
+ // Swallowed deliberately, and recorded rather than discarded: the merge must survive whatever
434
+ // went wrong in here, but a silent failure that leaves no trace is how this rots unnoticed.
435
+ result.skipped.push({ path: root, reason: `cleanup could not run: ${err.message}` });
436
+ }
437
+ return result;
438
+ }
439
+
440
+ export const HOOK_MARKER = '# rsc-managed worktree cleanup (post-merge) v1';
441
+
442
+ // Every branch exits 0. That is the whole contract with git: this hook runs in the middle of
443
+ // somebody's merge, and a cleanup that can turn a good merge into a failed one is a worse bug than
444
+ // the accumulation it exists to fix. Missing node, missing reaper, broken reaper, unreadable repo —
445
+ // all of them are "do nothing", never "fail".
446
+ const HOOK_BODY = `#!/bin/sh
447
+ ${'# rsc-managed worktree cleanup (post-merge) v1'}
448
+ # Retires worktrees whose work has just landed. Installed by rsc; safe to delete.
449
+ # Turn it off for this project with: .rsc/.no-worktree-cleanup
450
+ hook_dir=$(dirname "$0")
451
+ if [ -x "$hook_dir/post-merge.rsc-local" ]; then
452
+ "$hook_dir/post-merge.rsc-local" "$@" || true
453
+ fi
454
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
455
+ [ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
456
+ command -v node >/dev/null 2>&1 || exit 0
457
+ node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
458
+ exit 0
459
+ `;
460
+
461
+ /**
462
+ * Where git will ACTUALLY look for hooks, asked of git rather than assumed.
463
+ *
464
+ * The assumption was `<root>/.git/hooks`, and it is wrong in three ordinary situations at once:
465
+ * a repo that sets `core.hooksPath` (husky v9 writes `.husky/_`, lefthook and committed
466
+ * `.githooks/` dispatchers do the same), a linked worktree where `.git` is a FILE and the mkdir
467
+ * raised ENOTDIR, and a submodule. Reported from a user's repo on 2026-09-18 against 2.0.1: rsc
468
+ * wrote the hook where git does not look, `doctor` read the same wrong path back, and the report
469
+ * said `armed` about something that could never run. Verified there and here that a single
470
+ * `rev-parse` answers all three.
471
+ *
472
+ * `--path-format=absolute` needs git 2.31 (Mar 2021). Older git gets the old path — which is
473
+ * correct whenever `core.hooksPath` is unset, i.e. the case it was always right about.
474
+ */
475
+ export function hooksDir(root) {
476
+ const r = git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
477
+ if (r.ok && r.out) return r.out;
478
+ return join(real(root), '.git', 'hooks');
479
+ }
480
+
481
+ // Whether that directory is ours to write in. Inside the repository's own git dir: yes, it is
482
+ // private plumbing that is never cloned and never committed. Anywhere else it belongs to the user
483
+ // or to another tool — a committed `.githooks/` is their source tree (writing there would put rsc
484
+ // in their diff, and moving an existing hook aside would rename a TRACKED file), and `.husky/_` is
485
+ // regenerated by husky, so anything we left there disappears on their next install. Report it and
486
+ // let the person decide; that is P4, and it is the same reasoning the comment on this function has
487
+ // claimed since the hook shipped, finally applied instead of only stated.
488
+ export function hooksDirIsOurs(root, dir) {
489
+ const r = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
490
+ if (!r.ok || !r.out) return false;
491
+ const gitDir = real(r.out);
492
+ const target = real(dir);
493
+ return target === gitDir || target.startsWith(gitDir + sep);
494
+ }
495
+
496
+ /**
497
+ * Install the trigger. The judgement has existed and been tested for a while; what never existed was
498
+ * something that runs it at the moment work lands. `post-merge` is that moment, and it covers both
499
+ * of ship's landing paths — a local `merge --no-ff` and the `pull --ff-only` after a forge merge.
500
+ *
501
+ * A hook lives in `.git/hooks/`, which is not cloned. So this is called on install AND on repair,
502
+ * and `doctor` reports its absence: a trigger nobody re-installs is a trigger that quietly stops
503
+ * existing on every machine but the one that ran the installer.
504
+ *
505
+ * Somebody else's post-merge is moved aside and chained, never overwritten. Husky and lefthook put
506
+ * real work in there, and eating it to install a convenience would be indefensible.
507
+ */
508
+ export function installMergeHook(root) {
509
+ try {
510
+ const dir = hooksDir(root);
511
+ if (!hooksDirIsOurs(root, dir)) {
512
+ return { installed: false, state: 'unreachable', reason: `git reads hooks from ${dir}, which belongs to this repository or to another tool — rsc will not write there.` };
513
+ }
514
+ const hook = join(dir, 'post-merge');
515
+ mkdirSync(dir, { recursive: true });
516
+ if (existsSync(hook)) {
517
+ const current = readFileSync(hook, 'utf8');
518
+ if (current.includes(HOOK_MARKER)) {
519
+ // Already ours. Rewrite so an older body converges, but never chain ourselves behind
520
+ // ourselves — repair runs this repeatedly and stacking would run the cleanup N times.
521
+ writeFileSync(hook, HOOK_BODY);
522
+ chmodSync(hook, 0o755);
523
+ return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
524
+ }
525
+ renameSync(hook, join(dir, 'post-merge.rsc-local'));
526
+ chmodSync(join(dir, 'post-merge.rsc-local'), 0o755);
527
+ }
528
+ writeFileSync(hook, HOOK_BODY);
529
+ chmodSync(hook, 0o755);
530
+ return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
531
+ } catch (err) {
532
+ return { installed: false, state: 'failed', reason: err.message };
533
+ }
534
+ }
535
+
376
536
  export function refusal(candidate) {
377
537
  const d = candidate.details || {};
378
538
  const parts = (candidate.reasons || []).map((reason) => {
@@ -449,7 +609,11 @@ export function summarize(candidate, root) {
449
609
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
450
610
  const root = resolve(process.argv[2] || process.cwd());
451
611
  const candidates = classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
452
- if (process.argv[3] === 'reap') {
612
+ if (process.argv[3] === 'auto') {
613
+ // The unattended entry point, called by the post-merge hook. Prints only what it actually did.
614
+ const out = autoReap(root);
615
+ for (const p of out.reaped) process.stdout.write(`rsc: retired worktree ${p}\n`);
616
+ } else if (process.argv[3] === 'reap') {
453
617
  const one = process.argv[4];
454
618
  const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
455
619
  for (const t of targets) {