@damiangilgonzalez/harness 1.3.17 → 1.3.19

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 (37) hide show
  1. package/README.md +117 -657
  2. package/manifest.json +1 -1
  3. package/package.json +1 -1
  4. package/scripts/doctor.js +1 -1
  5. package/scripts/install-apply.js +9 -0
  6. package/scripts/rsc.js +90 -2
  7. package/skills/building-agents/SKILL.md +1 -1
  8. package/skills/grill-with-docs/SKILL.md +0 -1
  9. package/skills/harness/references/daily-curation-automation.md +2 -1
  10. package/skills/harness/references/wiki-protocol.md +3 -1
  11. package/skills/implement/SKILL.md +0 -1
  12. package/skills/implement-spec/SKILL.md +1 -2
  13. package/skills/init/SKILL.md +7 -5
  14. package/skills/postgresdb/SKILL.md +1 -1
  15. package/skills/suggest/SKILL.md +17 -13
  16. package/skills/suggest/evals/cases.yaml +6 -0
  17. package/skills/to-spec/SKILL.md +0 -1
  18. package/skills/to-tickets/SKILL.md +0 -1
  19. package/skills/wayfinder/SKILL.md +0 -1
  20. package/targets/_md-block.js +1 -1
  21. package/targets/claude.js +2 -1
  22. package/targets/clone-bootstrap.mjs +1 -1
  23. package/targets/danger-guard.mjs +114 -52
  24. package/targets/hook-once.mjs +44 -13
  25. package/targets/knowledge-sync.mjs +759 -0
  26. package/targets/knowledge-wiring.js +196 -0
  27. package/targets/memory.js +7 -1
  28. package/targets/opencode-memory-plugin.js +67 -22
  29. package/targets/opt-outs.js +4 -0
  30. package/targets/session-memory-adapter.mjs +51 -6
  31. package/targets/session-memory-core.mjs +284 -37
  32. package/targets/session-start.mjs +2 -1
  33. package/targets/ship-guard.mjs +26 -9
  34. package/targets/trunk-policy.mjs +86 -0
  35. package/targets/userprompt-gate.mjs +7 -2
  36. package/targets/worklog-checkpoint.mjs +16 -1
  37. package/targets/worktree-reaper.mjs +144 -4
@@ -1,5 +1,5 @@
1
1
  import {
2
- appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
2
+ appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
3
3
  } from 'node:fs';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -84,21 +84,27 @@ export function chooseMemoryRoot(cwd = process.cwd()) {
84
84
  return { root: join(project, '.rsc', 'memory'), kind: 'local-state', reason: 'without-git', git: false };
85
85
  }
86
86
 
87
+ // The harness itself commits `worklog/.gitkeep` so the folder exists in every clone. A placeholder
88
+ // is not the team committing its worklog, and reading it as one warned every teammate in every clone
89
+ // about a tracked worklog that was an empty file (team simulation D13).
87
90
  const worklog = join(project, 'docs', 'raw', 'worklog');
88
- if (existsSync(worklog)) {
89
- const tracked = git(project, ['ls-files', '--', 'docs/raw/worklog']).out;
90
- if (!tracked) {
91
- const candidate = join(worklog, '.rsc-memory');
92
- if (ensureExcluded(project, candidate)) {
93
- return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
94
- }
91
+ const local = join(project, '.rsc', 'memory');
92
+ const trackedFiles = existsSync(worklog)
93
+ ? git(project, ['ls-files', '--', 'docs/raw/worklog']).out.split('\n').filter(Boolean) : [];
94
+ const trackedNotes = trackedFiles.filter((file) => !/(^|\/)\.(gitkeep|keep)$/u.test(file));
95
+ // A clone whose journal already lives in .rsc/memory keeps it there: moving would orphan its history.
96
+ const placeholderOnly = trackedFiles.length > 0 && trackedNotes.length === 0;
97
+ const settled = placeholderOnly && existsSync(join(local, 'sessions'));
98
+ if (existsSync(worklog) && !trackedNotes.length && !settled) {
99
+ const candidate = join(worklog, '.rsc-memory');
100
+ if (ensureExcluded(project, candidate)) {
101
+ return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
95
102
  }
96
103
  }
97
104
 
98
- const local = join(project, '.rsc', 'memory');
99
105
  if (ensureExcluded(project, local)) {
100
- const trackedWorklog = existsSync(worklog) && Boolean(git(project, ['ls-files', '--', 'docs/raw/worklog']).out);
101
- return { root: local, kind: 'local-state', reason: trackedWorklog ? 'tracked-worklog' : 'no-wiki', git: true };
106
+ const reason = trackedNotes.length ? 'tracked-worklog' : settled ? 'placeholder-worklog' : 'no-wiki';
107
+ return { root: local, kind: 'local-state', reason, git: true };
102
108
  }
103
109
 
104
110
  const gitRoot = git(project, ['rev-parse', '--git-path', 'rsc-memory']).out;
@@ -109,7 +115,7 @@ export function chooseMemoryRoot(cwd = process.cwd()) {
109
115
  }
110
116
 
111
117
  function ensureStore(root) {
112
- for (const dir of ['sessions', 'anchors', 'lessons']) mkdirSync(join(root, dir), { recursive: true });
118
+ for (const dir of ['sessions', 'anchors', 'lessons', 'presence']) mkdirSync(join(root, dir), { recursive: true });
113
119
  }
114
120
 
115
121
  function atomicJson(path, value) {
@@ -167,20 +173,35 @@ function parseStatus(output) {
167
173
  // runs on every hook event: a few hundred dirty files were enough to blow the 5 s hook timeout.
168
174
  // `--stdin-paths` stops at the first path it cannot read (a deleted file), so on failure each file
169
175
  // is hashed on its own, which keeps the old per-file `null` for the unreadable ones.
176
+ //
177
+ // A deleted file is the ordinary case, not the exotic one — any `rm` or `git rm` puts one in the
178
+ // status — and it used to send EVERY file down the per-file path, back over the timeout (400 dirty
179
+ // files and one deletion: ~20 s per event, so PreCompact was cancelled). So paths that are not a
180
+ // readable regular file are settled here, as `null`, before the batch ever sees them.
170
181
  function fingerprintFiles(worktree, files) {
171
182
  if (!files.length) return {};
172
- const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${files.join('\n')}\n`);
183
+ const readable = files.filter((path) => {
184
+ try { return statSync(join(worktree, path)).isFile(); } catch { return false; }
185
+ });
186
+ const isReadable = new Set(readable);
187
+ const missing = Object.fromEntries(files.filter((path) => !isReadable.has(path)).map((path) => [path, null]));
188
+ if (!readable.length) return missing;
189
+ const batch = git(worktree, ['hash-object', '--no-filters', '--stdin-paths'], `${readable.join('\n')}\n`);
173
190
  const hashes = batch.ok ? batch.out.split('\n') : [];
174
- if (hashes.length === files.length) return Object.fromEntries(files.map((path, i) => [path, hashes[i]]));
191
+ if (hashes.length === readable.length) return { ...missing, ...Object.fromEntries(readable.map((path, i) => [path, hashes[i]])) };
175
192
  return Object.fromEntries(files.map((path) => {
176
193
  const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
177
194
  return [path, result.ok ? result.out : null];
178
195
  }));
179
196
  }
180
197
 
198
+ // `📥 docs(auto): sync desde rsc/knowledge` — written by knowledge-sync.mjs when it imports the team's
199
+ // documents. Authored locally, but its content is other people's.
200
+ const KNOWLEDGE_IMPORT = /^\S*\s*docs\(auto\): sync desde /u;
201
+
181
202
  function snapshot(cwd, baselineHead = null) {
182
203
  const top = git(cwd, ['rev-parse', '--show-toplevel']);
183
- if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], fingerprints: {}, commits: [] };
204
+ if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], dirty: [], committed: [], fingerprints: {}, commits: [] };
184
205
  const worktree = resolve(top.out);
185
206
  const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
186
207
  const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
@@ -188,11 +209,36 @@ function snapshot(cwd, baselineHead = null) {
188
209
  let committed = [];
189
210
  let commits = [];
190
211
  if (baselineHead && head && git(cwd, ['cat-file', '-e', `${baselineHead}^{commit}`]).ok) {
191
- committed = git(cwd, ['diff', '--name-only', '-z', `${baselineHead}..${head}`]).out.split('\0').map((item) => cleanString(item, 1000)).filter(Boolean);
192
- commits = git(cwd, ['log', '--format=%H', `${baselineHead}..${head}`]).out.split('\n').map((item) => cleanString(item, 64)).filter(Boolean);
212
+ // The session's OWN commits: the first-parent chain, merges left out. `baseline..head` after a
213
+ // `git merge origin/main` also walks every teammate commit the merge brought in, and the memory
214
+ // then told the next session it had done their work and should carry out their next step (team
215
+ // simulation, 2026-10-07). The files follow from those commits only — never `diff baseline..head`,
216
+ // which counts everything the merge changed. The knowledge-sync import commit is machine-made and
217
+ // carries other people's feature documents, so it is not the session's work either.
218
+ //
219
+ // A merge the session itself made IS its work, though (second team simulation: a session that
220
+ // merged main and resolved five conflicts was recorded as «no commits, files: none»). So merges on
221
+ // the first-parent chain stay, and from a merge only what the session resolved counts — the
222
+ // combined diff (`--cc`), never everything the merge brought in.
223
+ const own = git(cwd, ['log', '--first-parent', '--format=%H%x09%P%x09%s', `${baselineHead}..${head}`]).out
224
+ .split('\n').filter(Boolean).map((line) => line.split('\t'))
225
+ .filter(([, , subject = '']) => !KNOWLEDGE_IMPORT.test(subject));
226
+ commits = own.map(([sha]) => cleanString(sha, 64)).filter(Boolean);
227
+ const merges = new Set(own.filter(([, parents = '']) => parents.trim().split(/\s+/).length > 1).map(([sha]) => sha));
228
+ const touched = new Set();
229
+ for (const sha of commits) {
230
+ const args = merges.has(sha)
231
+ ? ['diff-tree', '--no-commit-id', '--name-only', '--cc', '-z', sha]
232
+ : ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', '--root', sha];
233
+ for (const item of git(cwd, args).out.split('\0')) {
234
+ const clean = cleanString(item, 1000);
235
+ if (clean) touched.add(clean);
236
+ }
237
+ }
238
+ committed = [...touched];
193
239
  }
194
240
  const files = [...new Set([...dirty, ...committed])].sort();
195
- return { git: true, branch, worktree, head, files, fingerprints: fingerprintFiles(worktree, dirty), commits };
241
+ return { git: true, branch, worktree, head, files, dirty, committed, fingerprints: fingerprintFiles(worktree, dirty), commits };
196
242
  }
197
243
 
198
244
  function ledgerSnapshot(cwd) {
@@ -237,19 +283,76 @@ function prune(root, now, config) {
237
283
  const updated = value?.timestamps?.updatedAt || value?.startedAt;
238
284
  if (!updated || new Date(updated).getTime() < cutoff) rmSync(path, { force: true });
239
285
  }
286
+ // Presence only means anything inside the active window; a day is generous and keeps the dir small.
287
+ const presenceCutoff = new Date(now).getTime() - 86400000;
288
+ for (const path of presenceFiles(root)) {
289
+ const value = readJson(path);
290
+ if (!value?.updatedAt || new Date(value.updatedAt).getTime() < presenceCutoff) rmSync(path, { force: true });
291
+ }
292
+ }
293
+
294
+ function presenceFiles(root) {
295
+ const dir = join(root, 'presence');
296
+ if (!existsSync(dir)) return [];
297
+ return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
298
+ }
299
+
300
+ const PRESENCE_FIELDS = ['schemaVersion', 'sessionId', 'target', 'branch', 'worktree', 'updatedAt'];
301
+
302
+ /**
303
+ * "A session is here", written on every event from the first one (team simulation W2). The journal
304
+ * only writes a RECORD once there is work, so a session that had just started — or was still reading
305
+ * before its first edit — was invisible, and a second session opened twenty seconds later was never
306
+ * told to isolate. A record cannot carry this: resume would hand that empty record to the next
307
+ * session as "your continuation". So presence is its own tiny file: who and where, never what — no
308
+ * prompt, no file, no content (P3) — and it is removed when the session ends.
309
+ */
310
+ function writePresence(root, storageId, presence) {
311
+ const value = Object.fromEntries(PRESENCE_FIELDS.map((key) => [key, presence[key] ?? null]));
312
+ if (Object.values(value).some((item) => secretShaped(item))) return;
313
+ atomicJson(join(root, 'presence', `${storageId}.json`), value);
314
+ }
315
+
316
+ function readPresence(root) {
317
+ return presenceFiles(root).map(readJson).filter((item) => item && item.schemaVersion === 1
318
+ && typeof item.sessionId === 'string' && typeof item.updatedAt === 'string'
319
+ && Object.keys(item).every((key) => PRESENCE_FIELDS.includes(key)));
320
+ }
321
+
322
+ // Where a session is, in the parent project's terms: the worktree under `<project>/.worktrees/` that
323
+ // holds `path`, or null when `path` is not inside one.
324
+ function nestedWorktreeOf(project, path) {
325
+ if (!path) return null;
326
+ const rel = relativeSlash(project, path);
327
+ if (!rel.startsWith('.worktrees/')) return null;
328
+ // git needs a directory to stand in: the file's own folder, or the nearest one that exists yet.
329
+ let probe = existsSync(path) && statSync(path).isDirectory() ? path : dirname(path);
330
+ while (!existsSync(probe)) {
331
+ const parent = dirname(probe);
332
+ if (parent === probe) return null;
333
+ probe = parent;
334
+ }
335
+ const top = git(probe, ['rev-parse', '--show-toplevel']);
336
+ if (!top.ok) return null;
337
+ const worktree = resolve(top.out);
338
+ return relativeSlash(project, worktree).startsWith('.worktrees/') ? worktree : null;
240
339
  }
241
340
 
242
341
  function noticeText(info) {
342
+ if (info.reason === 'placeholder-worklog') return null; // nothing the person needs to know
243
343
  if (info.reason === 'tracked-worklog') return 'rsc memory: tracked worklog detected; using ignored local state instead.';
244
344
  if (info.reason === 'untracked-worklog') return 'rsc memory: using the ignored wiki worklog store.';
245
345
  if (info.reason === 'without-git') return 'rsc memory: running without git; branch and commit metadata are unavailable.';
246
346
  if (info.reason === 'local-state-tracked') return 'rsc memory: .rsc memory was tracked; using git-private local state.';
247
- return 'rsc memory: no wiki worklog found; using ignored local state.';
347
+ // The ordinary case (no wiki worklog, local state) is not news: said to every new clone it was noise
348
+ // (second team simulation), like the placeholder case above.
349
+ return null;
248
350
  }
249
351
 
250
352
  function consumeNotice(info) {
251
353
  const marker = join(info.root, `.notice-${info.reason}`);
252
354
  if (existsSync(marker)) return null;
355
+ if (!noticeText(info)) return null;
253
356
  writeFileSync(marker, `${iso()}\n`, { mode: 0o600 });
254
357
  return noticeText(info);
255
358
  }
@@ -267,6 +370,59 @@ function activeConcurrent(records, sessionId, target, worktree, now) {
267
370
  && !record?.timestamps?.completedAt && new Date(record?.timestamps?.updatedAt || 0).getTime() >= recent);
268
371
  }
269
372
 
373
+ /** How long a session counts as "working here" after its last recorded event (team-safe-default B). */
374
+ export const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
375
+
376
+ /**
377
+ * Other sessions working in THIS checkout right now: any assistant with rsc, same worktree, an event
378
+ * in the last 30 minutes, not closed, and not the asking session itself. It answers WHO is working,
379
+ * never WHAT is in the project (constitution P3), reads only the local journal (no network, no
380
+ * content), and its worst failure is one worktree too many or too few.
381
+ *
382
+ * A session that only talks has no RECORD — the journal only writes when there is work — but it has a
383
+ * PRESENCE from its first event, so a session opened seconds after another one is still told to
384
+ * isolate (team simulation W2). Sessions are counted where they are NOW: one that moved into
385
+ * `.worktrees/<b>/` no longer counts in the main checkout.
386
+ */
387
+ export function otherActiveSessions(input = {}) {
388
+ try {
389
+ const cwd = resolve(input.cwd || process.cwd());
390
+ const top = git(resolve(input.worktreeCwd || cwd), ['rev-parse', '--show-toplevel']);
391
+ if (!top.ok) return [];
392
+ const here = resolve(top.out);
393
+ const store = chooseMemoryRoot(cwd);
394
+ if (!existsSync(store.root)) return [];
395
+ const now = new Date(input.now || Date.now()).getTime();
396
+ const windowMs = Number.isFinite(input.windowMs) ? input.windowMs : ACTIVE_WINDOW_MS;
397
+ // Each session once, at its LATEST known position: its record (work) or its presence (any event),
398
+ // whichever is newer. A session that moved into a worktree is no longer here, whatever an older
399
+ // record of it says.
400
+ const latest = new Map();
401
+ for (const r of readRecords(store.root)) {
402
+ latest.set(`${r.target}--${r.sessionId}`, {
403
+ target: r.target, sessionId: r.sessionId, branch: r.branch, worktree: r.worktree,
404
+ updatedAt: r.timestamps?.updatedAt, completedAt: r.timestamps?.completedAt || null,
405
+ });
406
+ }
407
+ for (const p of readPresence(store.root)) {
408
+ const key = `${p.target}--${p.sessionId}`;
409
+ const known = latest.get(key);
410
+ const newer = !known || new Date(p.updatedAt).getTime() >= new Date(known.updatedAt || 0).getTime();
411
+ // A presence newer than the record's close means the session came back (resumed by id).
412
+ if (newer) latest.set(key, { ...p, completedAt: known?.completedAt && new Date(known.completedAt).getTime() >= new Date(p.updatedAt).getTime() ? known.completedAt : null });
413
+ }
414
+ const self = (s) => s.sessionId === cleanId(input.sessionId, 'session') && s.target === input.target;
415
+ return [...latest.values()]
416
+ .filter((s) => s.worktree === here)
417
+ .filter((s) => !self(s))
418
+ .filter((s) => !s.completedAt)
419
+ .filter((s) => now - new Date(s.updatedAt || 0).getTime() <= windowMs)
420
+ .map((s) => ({ target: s.target, sessionId: s.sessionId, branch: s.branch, updatedAt: s.updatedAt }));
421
+ } catch {
422
+ return [];
423
+ }
424
+ }
425
+
270
426
  export function validateSessionRecord(record) {
271
427
  const errors = [];
272
428
  if (!record || typeof record !== 'object' || Array.isArray(record)) return ['record: object required'];
@@ -298,7 +454,7 @@ export function capture(input = {}) {
298
454
  // The second has to be asked of git from where the agent actually works: asked from the harness,
299
455
  // every worktree nested under it came back as the harness itself, and a sibling's record then
300
456
  // "matched exactly". See GHSA-8gjp-3r7c-33f4.
301
- const here = resolve(input.worktreeCwd || cwd);
457
+ let here = resolve(input.worktreeCwd || cwd);
302
458
  const config = settings(input.settings);
303
459
  if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
304
460
  const store = chooseMemoryRoot(cwd);
@@ -314,8 +470,33 @@ export function capture(input = {}) {
314
470
  const recordPath = join(store.root, 'sessions', `${storageId}.json`);
315
471
  let anchor = readJson(anchorPath);
316
472
  let existing = readJson(recordPath);
317
- // The first snapshot only seeds a new anchor; an existing one already has its baseline.
318
- const firstSnapshot = anchor ? null : snapshot(here, null);
473
+ // A session that moved into `.worktrees/<b>/` is THERE, also on the events that do not say so.
474
+ // An edit names its file — the first sign of the move when the agent writes by absolute path and
475
+ // its cwd never changed — and `Stop`/`SessionEnd` name nothing, so the move is remembered on the
476
+ // anchor until the session edits the main checkout again. Without this the record stayed on the
477
+ // main checkout, open, and every later session there was told for half an hour that someone was
478
+ // working beside it (team simulation W1).
479
+ const projectTop = git(cwd, ['rev-parse', '--show-toplevel']);
480
+ const project = projectTop.ok ? resolve(projectTop.out) : cwd;
481
+ const fileHint = typeof input.filePath === 'string' && isAbsolute(input.filePath) ? resolve(input.filePath) : null;
482
+ let movedTo = anchor?.movedTo || null;
483
+ const fromFile = nestedWorktreeOf(project, fileHint);
484
+ if (fromFile) here = fromFile;
485
+ const nestedHere = nestedWorktreeOf(project, here);
486
+ if (nestedHere) movedTo = nestedHere;
487
+ else if (fileHint && !relativeSlash(project, fileHint).startsWith('..')) movedTo = null; // back in the main checkout
488
+ else if (movedTo && existsSync(movedTo)) here = movedTo;
489
+ else movedTo = null;
490
+ const firstSnapshot = snapshot(here, null);
491
+ // Presence: on every event, before any early return — the point is to be seen before there is work.
492
+ const ending = ['end', 'sessionEnd', 'stop'].includes(input.event);
493
+ if (ending) rmSync(join(store.root, 'presence', `${storageId}.json`), { force: true });
494
+ else {
495
+ writePresence(store.root, storageId, {
496
+ schemaVersion: 1, sessionId, target, branch: cleanString(firstSnapshot.branch, 255),
497
+ worktree: cleanString(firstSnapshot.worktree, 1000), updatedAt: now,
498
+ });
499
+ }
319
500
  if (!anchor) {
320
501
  anchor = {
321
502
  sessionId,
@@ -323,8 +504,13 @@ export function capture(input = {}) {
323
504
  baselineHead: cleanString(input.baselineHead, 64) || firstSnapshot.head,
324
505
  baselineFiles: firstSnapshot.files,
325
506
  baselineFingerprints: firstSnapshot.fingerprints,
507
+ ...(movedTo ? { movedTo } : {}),
326
508
  };
327
509
  atomicJson(anchorPath, anchor);
510
+ } else if ((anchor.movedTo || null) !== movedTo) {
511
+ const { movedTo: _previous, ...rest } = anchor;
512
+ anchor = movedTo ? { ...rest, movedTo } : rest;
513
+ atomicJson(anchorPath, anchor);
328
514
  }
329
515
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
330
516
 
@@ -346,13 +532,27 @@ export function capture(input = {}) {
346
532
  const observed = Object.entries(repo.fingerprints || {})
347
533
  .filter(([path, fingerprint]) => fingerprint && previousFingerprints[path] !== fingerprint).length;
348
534
  const editDelta = Math.max(finiteOrNull(input.editDelta, true) || 0, observed);
349
- // Compaction closes a context window, so the edit budget starts again from zero.
350
- const previousEdits = input.event === 'compact' ? 0 : (existing?.editCount || 0);
535
+ // Compaction closes a context window, so the edit budget starts again from zero. Two signals say
536
+ // it happened, and either is enough: PreCompact (`compact`) and the SessionStart that follows it
537
+ // (`reset`, from `source: "compact"`). Relying on PreCompact alone meant a cancelled PreCompact
538
+ // hook — which is what the 5 s timeout did on a dirty tree — left the old count standing.
539
+ const resetsBudget = input.event === 'compact' || input.reset === true;
540
+ const previousEdits = resetsBudget ? 0 : (existing?.editCount || 0);
351
541
  const editCount = Math.max(0, previousEdits + editDelta);
352
542
  // Hint once per threshold crossed, and only on the edit that crosses it: re-emitting on
353
543
  // every later event (Stop in particular) wakes the model again and loops.
354
544
  const crossedThreshold = editCount > previousEdits
355
545
  && Math.floor(editCount / config.editThreshold) > Math.floor(previousEdits / config.editThreshold);
546
+ // And never ON a turn end, even the one that crosses. A Stop hook that speaks is a Stop hook that
547
+ // can start another turn, and that turn ends in another Stop: the field report is ~150 empty
548
+ // assistant replies in a row. A crossing seen at a turn end is kept on the anchor and said by the
549
+ // next event that is not one (the person's next prompt, or the next tool call). Compaction and
550
+ // start never say it either — "consider compacting" while compacting is noise — and a reset
551
+ // drops whatever was pending, because the window it was about is gone.
552
+ const mutedEvents = ['turn', 'stop', 'compact', 'start', 'end', 'sessionEnd'];
553
+ const hintDue = !resetsBudget && (crossedThreshold || anchor.hintPending === true);
554
+ const speaks = !mutedEvents.includes(input.event);
555
+ const hintPending = hintDue && !speaks;
356
556
  const baselineFiles = new Set(anchor.baselineFiles || []);
357
557
  const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
358
558
  const baselineFingerprints = anchor.baselineFingerprints || {};
@@ -378,7 +578,11 @@ export function capture(input = {}) {
378
578
  worktree: cleanString(repo.worktree, 1000),
379
579
  baselineHead: cleanString(anchor.baselineHead, 64),
380
580
  head: cleanString(repo.head, 64),
381
- files: repo.files.filter((item) => !secretShaped(item)),
581
+ // What THIS session touched: what it committed, plus what is dirty now and was not dirty — or not
582
+ // this content — when it started. The plain union listed whatever an install had left untracked,
583
+ // so a session that never opened `.rsc.json` reported it as its work (E2E 2026-10-07).
584
+ files: [...new Set([...repo.committed, ...repo.dirty.filter((path) => !baselineFiles.has(path)
585
+ || baselineFingerprints[path] !== repo.fingerprints[path])])].sort().filter((item) => !secretShaped(item)),
382
586
  commits: repo.commits.filter((item) => !secretShaped(item)),
383
587
  ledger: ledgerSnapshot(cwd),
384
588
  timestamps: { startedAt: anchor.startedAt, updatedAt: now, completedAt: completed },
@@ -391,13 +595,20 @@ export function capture(input = {}) {
391
595
  if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
392
596
  atomicJson(recordPath, record);
393
597
  existing = record;
394
- const compactionHint = Boolean(config.compactionHint && crossedThreshold);
598
+ const compactionHint = Boolean(config.compactionHint && hintDue && speaks);
395
599
  // The fingerprints the next event compares against live on the ANCHOR, not on the record: the
396
600
  // record's schema is closed and every field of it is required, so a new field there would fail
397
601
  // validation on every record already written. Written only when they moved, so a read-only shell
398
- // command costs no extra disk.
602
+ // command costs no extra disk. The pending hint lives there for the same reason.
399
603
  const fingerprintsMoved = observed > 0 || !anchor.lastFingerprints;
400
- if (fingerprintsMoved) atomicJson(anchorPath, { ...anchor, lastFingerprints: repo.fingerprints || {} });
604
+ if (fingerprintsMoved || Boolean(anchor.hintPending) !== hintPending) {
605
+ const { hintPending: _previous, ...rest } = anchor;
606
+ atomicJson(anchorPath, {
607
+ ...rest,
608
+ lastFingerprints: fingerprintsMoved ? (repo.fingerprints || {}) : anchor.lastFingerprints,
609
+ ...(hintPending ? { hintPending: true } : {}),
610
+ });
611
+ }
401
612
  return {
402
613
  record,
403
614
  path: recordPath,
@@ -428,19 +639,54 @@ function truncateUtf8(text, maxBytes) {
428
639
  return output;
429
640
  }
430
641
 
431
- function renderContext(record, match, lessons, config) {
642
+ // WHAT the last session did, not only where it stopped: the subjects of its commits and the next step
643
+ // its feature document wrote down. Read at resume time from git and the document — never from the
644
+ // prompts, which the record promises not to keep — so the record's closed schema does not change.
645
+ function subjects(cwd, commits) {
646
+ return commits.slice(0, 8).map((sha) => {
647
+ const out = git(cwd, ['log', '-1', '--format=%h %s', sha]);
648
+ return out.ok ? cleanString(out.out, 160) : cleanString(sha.slice(0, 7), 7);
649
+ }).filter((item) => item && !secretShaped(item));
650
+ }
651
+
652
+ // A document whose frontmatter says `status: done` is finished work, not a next step. On a closed
653
+ // default branch the close travels by rsc/knowledge and is not in the checkout yet (knowledge-sync
654
+ // `closeLanded`), so the exchange branch's copy is asked too — a local ref, no network.
655
+ const isDone = (text) => /^status:[ \t]*done[ \t]*$/mu.test(String(text || '').replace(/\r\n/gu, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/u)?.[1] || '');
656
+
657
+ function nextSteps(cwd, files) {
658
+ const out = [];
659
+ for (const rel of files.filter((f) => /^docs\/wiki\/(ftd|sdd\/(specs|plans|progress))\/[^/]+\.md$/u.test(f)).slice(0, 3)) {
660
+ let body = '';
661
+ try { body = readFileSync(join(cwd, rel), 'utf8'); } catch { continue; }
662
+ if (isDone(body)) continue;
663
+ const exchange = git(cwd, ['show', `refs/remotes/origin/rsc/knowledge:${rel}`]);
664
+ if (exchange.ok && isDone(exchange.out)) continue;
665
+ const section = body.match(/^##\s*(Next|Siguiente|Próximo)[^\n]*\n+([\s\S]*?)(?=\n##\s|$)/imu)?.[2];
666
+ const first = section?.split('\n').map((l) => l.replace(/^[-*]\s*/u, '').trim()).find(Boolean);
667
+ const open = (body.match(/^- \[ \]/gmu) || []).length;
668
+ if (first || open) out.push(`${rel}: ${first ? cleanString(first, 200) : 'no next step written'}${open ? ` (${open} open)` : ''}`);
669
+ }
670
+ return out;
671
+ }
672
+
673
+ function renderContext(record, match, lessons, config, cwd = process.cwd()) {
432
674
  if (!record && !lessons.length) return '';
675
+ const done = record ? subjects(cwd, record.commits) : [];
676
+ const next = record ? nextSteps(cwd, record.files) : [];
433
677
  const lines = record ? [
434
678
  `[rsc local ${match} continuation]`,
435
- `source: ${record.target}/${record.sessionId}`,
679
+ `source: ${record.target}/${record.sessionId} (${record.timestamps.startedAt} → ${record.timestamps.updatedAt})`,
436
680
  `branch: ${record.branch || 'unavailable'}`,
437
681
  `worktree: ${record.worktree || 'unavailable'}`,
438
682
  `head: ${record.head || 'unavailable'}`,
683
+ `done: ${done.length ? done.join(' | ') : 'no commits'}`,
439
684
  `files: ${record.files.join(', ') || 'none'}`,
440
- `commits: ${record.commits.join(', ') || 'none'}`,
685
+ ...(next.length ? [`next: ${next.join(' | ')}`] : []),
441
686
  ] : ['[rsc local approved lessons]'];
442
687
  if (record?.ledger.length) lines.push(`ledger: ${record.ledger.map((item) => `${item.path}=${item.status}/${item.openItems}`).join(', ')}`);
443
- if (record) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
688
+ // Only when something is known: «cost=unknown toolCalls=unknown» on every resume told the model nothing.
689
+ if (record && (record.cost != null || record.toolCalls != null)) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
444
690
  if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
445
691
  for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
446
692
  return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
@@ -470,7 +716,7 @@ export function resume(input = {}) {
470
716
  if (record) match = 'nearby';
471
717
  }
472
718
  const lessons = selectedLessons(store.root, config);
473
- return { match, record, lessons, context: renderContext(record, match, lessons, config) };
719
+ return { match, record, lessons, context: renderContext(record, match, lessons, config, here) };
474
720
  }
475
721
 
476
722
  export function learn(input = {}) {
@@ -503,10 +749,11 @@ export function metricsSummary(input = {}) {
503
749
  cost: sessions.filter((row) => row.cost === null).length,
504
750
  toolCalls: sessions.filter((row) => row.toolCalls === null).length,
505
751
  };
506
- const knownTotal = {
507
- cost: sessions.reduce((sum, row) => sum + (row.cost ?? 0), 0),
508
- toolCalls: sessions.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0),
509
- };
752
+ // A sum over nothing known is unknown, not zero: doctor printed `cost: 0` for three sessions whose
753
+ // cost nobody had measured.
754
+ const knownSum = (key) => (unknown[key] === sessions.length ? null
755
+ : sessions.reduce((sum, row) => sum + (row[key] ?? 0), 0));
756
+ const knownTotal = { cost: knownSum('cost'), toolCalls: knownSum('toolCalls') };
510
757
  const total = {
511
758
  cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
512
759
  toolCalls: !sessions.length || unknown.toolCalls ? null : knownTotal.toolCalls,
@@ -274,7 +274,8 @@ ${candidates.map((c) => W.summarize(c, root)).join('\n')}
274
274
  ACTION: offer to retire them in ONE line and wait for a yes. On a yes run:
275
275
  npx @damiangilgonzalez/harness worktrees reap (the ones marked safe)
276
276
  npx @damiangilgonzalez/harness worktrees reap <path> (one the user confirmed by name)
277
- A yes in bulk covers only the safe ones; anything marked \`ask\` is confirmed on its own.
277
+ npx @damiangilgonzalez/harness worktrees reap --merged --confirm (safe + "provenance unclear" only, after a fetch; say so when offering)
278
+ A yes in bulk covers only those; any other \`ask\` (uncommitted changes, files in no commit) is confirmed on its own.
278
279
  A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
279
280
  ================================
280
281
  `;
@@ -5,9 +5,10 @@
5
5
  //
6
6
  // Enforces the "close the feature before you leave it" rule at the one deterministic
7
7
  // moment it matters: when a Bash command tries to switch to the trunk (main/master)
8
- // or merge into it. If the current feature branch has uncommitted changes or commits
9
- // that were never pushed, the guard DENIES the command and tells the agent to run
10
- // `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
8
+ // or merge into it. If the current feature branch has commits that were never pushed,
9
+ // the guard DENIES the command and tells the agent to run `ship` (commit → push → PR).
10
+ // Uncommitted changes alone only earn a hint: they may be the user's own work, and git
11
+ // already refuses a switch that would overwrite them. Opening the PR itself is `ship`'s job and the skill's
11
12
  // hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
12
13
  //
13
14
  // Design: precise (only fires on a trunk switch/merge), local-only (no network, no gh),
@@ -77,10 +78,26 @@ if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') al
77
78
 
78
79
  const tail = '\n(If this is intentional and you accept the risk, create .rsc/.no-ship-guard to disable this guard.)';
79
80
 
80
- // 1) Uncommitted work would be carried off the feature branch.
81
+ // 1) Uncommitted changes are NOT a reason to deny. They may well be the user's own work in the main
82
+ // checkout, not the agent's: in real sessions this denied five branch switches over a diff the agent
83
+ // had no business committing, and "commit them first" pushed it to do exactly that. Git itself
84
+ // refuses a switch that would overwrite local changes, and carries the rest along untouched, so the
85
+ // switch is left to git and the agent gets a hint: leave that diff alone and work in a worktree.
86
+ // The unpushed-commits rule below is the guard's real purpose and still denies.
81
87
  const dirty = git('status', '--porcelain');
82
- if (dirty && dirty.length > 0) {
83
- deny(`You're leaving feature branch "${branch}" with uncommitted changes. Commit them first — run the \`ship\` skill (commit → push → PR), don't abandon the diff.${tail}`);
88
+ const worktreeHint = dirty && dirty.length > 0
89
+ ? `The working tree on "${branch}" has uncommitted changes, and they may be the user's, not yours: ` +
90
+ `do not commit, stash or discard them to make room. If git refuses the switch, work in a separate ` +
91
+ `worktree instead: \`git worktree add .claude/worktrees/<name> -b <branch> origin/main\` ` +
92
+ `(or \`npx @damiangilgonzalez/harness worktrees new <name>\`).`
93
+ : '';
94
+ function hint() {
95
+ if (!worktreeHint) allow();
96
+ process.stdout.write(JSON.stringify({
97
+ systemMessage: 'ship-guard: hay cambios sin commitear en el árbol; no se tocan.',
98
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: worktreeHint },
99
+ }));
100
+ process.exit(0);
84
101
  }
85
102
 
86
103
  // 2) Commits exist but were never pushed.
@@ -88,14 +105,14 @@ const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'
88
105
  if (upstream) {
89
106
  const ahead = git('rev-list', '--count', `${upstream}..HEAD`);
90
107
  if (ahead && Number(ahead) > 0) {
91
- deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${tail}`);
108
+ deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${worktreeHint ? `\n${worktreeHint}` : ''}${tail}`);
92
109
  }
93
110
  } else {
94
111
  // No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
95
112
  const aheadOfTrunk = git('rev-list', '--count', 'main..HEAD') ?? git('rev-list', '--count', 'master..HEAD');
96
113
  if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
97
- deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${tail}`);
114
+ deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${worktreeHint ? `\n${worktreeHint}` : ''}${tail}`);
98
115
  }
99
116
  }
100
117
 
101
- allow();
118
+ hint();