@damiangilgonzalez/harness 1.3.18 → 1.3.20

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.
@@ -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) {
@@ -189,9 +195,13 @@ function fingerprintFiles(worktree, files) {
189
195
  }));
190
196
  }
191
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
+
192
202
  function snapshot(cwd, baselineHead = null) {
193
203
  const top = git(cwd, ['rev-parse', '--show-toplevel']);
194
- 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: [] };
195
205
  const worktree = resolve(top.out);
196
206
  const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
197
207
  const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
@@ -199,11 +209,36 @@ function snapshot(cwd, baselineHead = null) {
199
209
  let committed = [];
200
210
  let commits = [];
201
211
  if (baselineHead && head && git(cwd, ['cat-file', '-e', `${baselineHead}^{commit}`]).ok) {
202
- committed = git(cwd, ['diff', '--name-only', '-z', `${baselineHead}..${head}`]).out.split('\0').map((item) => cleanString(item, 1000)).filter(Boolean);
203
- 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];
204
239
  }
205
240
  const files = [...new Set([...dirty, ...committed])].sort();
206
- 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 };
207
242
  }
208
243
 
209
244
  function ledgerSnapshot(cwd) {
@@ -248,19 +283,76 @@ function prune(root, now, config) {
248
283
  const updated = value?.timestamps?.updatedAt || value?.startedAt;
249
284
  if (!updated || new Date(updated).getTime() < cutoff) rmSync(path, { force: true });
250
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;
251
339
  }
252
340
 
253
341
  function noticeText(info) {
342
+ if (info.reason === 'placeholder-worklog') return null; // nothing the person needs to know
254
343
  if (info.reason === 'tracked-worklog') return 'rsc memory: tracked worklog detected; using ignored local state instead.';
255
344
  if (info.reason === 'untracked-worklog') return 'rsc memory: using the ignored wiki worklog store.';
256
345
  if (info.reason === 'without-git') return 'rsc memory: running without git; branch and commit metadata are unavailable.';
257
346
  if (info.reason === 'local-state-tracked') return 'rsc memory: .rsc memory was tracked; using git-private local state.';
258
- 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;
259
350
  }
260
351
 
261
352
  function consumeNotice(info) {
262
353
  const marker = join(info.root, `.notice-${info.reason}`);
263
354
  if (existsSync(marker)) return null;
355
+ if (!noticeText(info)) return null;
264
356
  writeFileSync(marker, `${iso()}\n`, { mode: 0o600 });
265
357
  return noticeText(info);
266
358
  }
@@ -278,6 +370,59 @@ function activeConcurrent(records, sessionId, target, worktree, now) {
278
370
  && !record?.timestamps?.completedAt && new Date(record?.timestamps?.updatedAt || 0).getTime() >= recent);
279
371
  }
280
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
+
281
426
  export function validateSessionRecord(record) {
282
427
  const errors = [];
283
428
  if (!record || typeof record !== 'object' || Array.isArray(record)) return ['record: object required'];
@@ -309,7 +454,7 @@ export function capture(input = {}) {
309
454
  // The second has to be asked of git from where the agent actually works: asked from the harness,
310
455
  // every worktree nested under it came back as the harness itself, and a sibling's record then
311
456
  // "matched exactly". See GHSA-8gjp-3r7c-33f4.
312
- const here = resolve(input.worktreeCwd || cwd);
457
+ let here = resolve(input.worktreeCwd || cwd);
313
458
  const config = settings(input.settings);
314
459
  if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
315
460
  const store = chooseMemoryRoot(cwd);
@@ -325,8 +470,33 @@ export function capture(input = {}) {
325
470
  const recordPath = join(store.root, 'sessions', `${storageId}.json`);
326
471
  let anchor = readJson(anchorPath);
327
472
  let existing = readJson(recordPath);
328
- // The first snapshot only seeds a new anchor; an existing one already has its baseline.
329
- 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
+ }
330
500
  if (!anchor) {
331
501
  anchor = {
332
502
  sessionId,
@@ -334,8 +504,13 @@ export function capture(input = {}) {
334
504
  baselineHead: cleanString(input.baselineHead, 64) || firstSnapshot.head,
335
505
  baselineFiles: firstSnapshot.files,
336
506
  baselineFingerprints: firstSnapshot.fingerprints,
507
+ ...(movedTo ? { movedTo } : {}),
337
508
  };
338
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);
339
514
  }
340
515
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
341
516
 
@@ -403,7 +578,11 @@ export function capture(input = {}) {
403
578
  worktree: cleanString(repo.worktree, 1000),
404
579
  baselineHead: cleanString(anchor.baselineHead, 64),
405
580
  head: cleanString(repo.head, 64),
406
- 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)),
407
586
  commits: repo.commits.filter((item) => !secretShaped(item)),
408
587
  ledger: ledgerSnapshot(cwd),
409
588
  timestamps: { startedAt: anchor.startedAt, updatedAt: now, completedAt: completed },
@@ -460,19 +639,54 @@ function truncateUtf8(text, maxBytes) {
460
639
  return output;
461
640
  }
462
641
 
463
- 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()) {
464
674
  if (!record && !lessons.length) return '';
675
+ const done = record ? subjects(cwd, record.commits) : [];
676
+ const next = record ? nextSteps(cwd, record.files) : [];
465
677
  const lines = record ? [
466
678
  `[rsc local ${match} continuation]`,
467
- `source: ${record.target}/${record.sessionId}`,
679
+ `source: ${record.target}/${record.sessionId} (${record.timestamps.startedAt} → ${record.timestamps.updatedAt})`,
468
680
  `branch: ${record.branch || 'unavailable'}`,
469
681
  `worktree: ${record.worktree || 'unavailable'}`,
470
682
  `head: ${record.head || 'unavailable'}`,
683
+ `done: ${done.length ? done.join(' | ') : 'no commits'}`,
471
684
  `files: ${record.files.join(', ') || 'none'}`,
472
- `commits: ${record.commits.join(', ') || 'none'}`,
685
+ ...(next.length ? [`next: ${next.join(' | ')}`] : []),
473
686
  ] : ['[rsc local approved lessons]'];
474
687
  if (record?.ledger.length) lines.push(`ledger: ${record.ledger.map((item) => `${item.path}=${item.status}/${item.openItems}`).join(', ')}`);
475
- 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'}`);
476
690
  if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
477
691
  for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
478
692
  return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
@@ -502,7 +716,7 @@ export function resume(input = {}) {
502
716
  if (record) match = 'nearby';
503
717
  }
504
718
  const lessons = selectedLessons(store.root, config);
505
- return { match, record, lessons, context: renderContext(record, match, lessons, config) };
719
+ return { match, record, lessons, context: renderContext(record, match, lessons, config, here) };
506
720
  }
507
721
 
508
722
  export function learn(input = {}) {
@@ -535,10 +749,11 @@ export function metricsSummary(input = {}) {
535
749
  cost: sessions.filter((row) => row.cost === null).length,
536
750
  toolCalls: sessions.filter((row) => row.toolCalls === null).length,
537
751
  };
538
- const knownTotal = {
539
- cost: sessions.reduce((sum, row) => sum + (row.cost ?? 0), 0),
540
- toolCalls: sessions.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0),
541
- };
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') };
542
757
  const total = {
543
758
  cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
544
759
  toolCalls: !sessions.length || unknown.toolCalls ? null : knownTotal.toolCalls,
@@ -0,0 +1,86 @@
1
+ // Is the default branch closed for the agent in this project? (team-safe-default, part A)
2
+ //
3
+ // The spec leaves the call to the agent, change by change: complex or in production → branches;
4
+ // simple (scripts, research, personal) → straight on the default branch. That is judgement, and
5
+ // constitution P1 sends judgement to the skills. This module is only the FLOOR: the signals anyone
6
+ // can count — there is CI, there is a deployment, there is a team — which are the spec's own examples
7
+ // of "complejo o con producción". No signal: the floor stays open and the skill decides. A signal: the
8
+ // floor closes, and if the project is in fact simple the way out is the person asking to unlock it.
9
+ //
10
+ // `.rsc/.no-trunk-guard` is that unlock: a PROJECT switch (it travels in `.rsc.json` optOuts), set
11
+ // when somebody asks the agent «desbloquea main».
12
+ //
13
+ // Standalone on purpose: copied next to `branch-guard.mjs` and `knowledge-sync.mjs` under `.rsc/`.
14
+ import { execFileSync } from 'node:child_process';
15
+ import { existsSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ export const UNLOCK = '.no-trunk-guard';
19
+ /** «ramas y PR», chosen at install or with `rsc main lock`: closed whatever the signals say. */
20
+ export const LOCK = '.no-trunk-open';
21
+
22
+ const CI = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'azure-pipelines.yml', 'Jenkinsfile',
23
+ 'bitbucket-pipelines.yml', '.buildkite', '.travis.yml', '.woodpecker.yml', '.drone.yml'];
24
+ const DEPLOY = ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
25
+ 'vercel.json', 'netlify.toml', 'fly.toml', 'render.yaml', 'railway.json', 'railway.toml', 'Procfile',
26
+ 'app.yaml', 'serverless.yml', 'wrangler.toml', 'k8s', 'helm', 'Chart.yaml'];
27
+ const TEAM_COMMITS = 50;
28
+
29
+ function git(root, args) {
30
+ try {
31
+ return execFileSync('git', args, { windowsHide: true, cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
32
+ } catch { return null; }
33
+ }
34
+
35
+ const present = (root, rel) => {
36
+ const path = join(root, rel);
37
+ if (!existsSync(path)) return false;
38
+ try { return readdirSync(path).length > 0; } catch { return true; } // a file, not a folder
39
+ };
40
+
41
+ /** The countable reasons this project looks complex or in production. Empty = looks simple. */
42
+ export function trunkSignals(root) {
43
+ const out = [];
44
+ const ci = CI.find((rel) => present(root, rel));
45
+ if (ci) out.push(`CI (${ci})`);
46
+ const deploy = DEPLOY.find((rel) => present(root, rel));
47
+ if (deploy) out.push(`despliegue (${deploy})`);
48
+ const authors = new Set((git(root, ['log', `-${TEAM_COMMITS}`, '--format=%ae']) || '').split('\n').filter(Boolean));
49
+ if (authors.size >= 2) out.push(`equipo (${authors.size} personas en los últimos ${TEAM_COMMITS} commits)`);
50
+ return out;
51
+ }
52
+
53
+ /** The default branch's name: the remote's HEAD, else main/master, else null. */
54
+ export function defaultBranchName(root) {
55
+ const head = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
56
+ if (head) return head.replace(/^refs\/remotes\/origin\//, '');
57
+ for (const b of ['main', 'master']) {
58
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`])) return b;
59
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${b}`])) return b;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** `{ closed, reason, signals }` — reason: 'unlocked' | 'locked' | 'simple' | 'signals'. */
65
+ export function trunkPolicy(root) {
66
+ if (existsSync(join(root, '.rsc', UNLOCK))) return { closed: false, reason: 'unlocked', signals: [] };
67
+ if (existsSync(join(root, '.rsc', LOCK))) return { closed: true, reason: 'locked', signals: ['ramas y PR, decidido para este proyecto'] };
68
+ const signals = trunkSignals(root);
69
+ return signals.length ? { closed: true, reason: 'signals', signals } : { closed: false, reason: 'simple', signals };
70
+ }
71
+
72
+ export const trunkClosed = (root) => trunkPolicy(root).closed;
73
+
74
+ /**
75
+ * The branch rule for THIS project, said every turn (2026-10-06, Eric): where the default branch is
76
+ * open the agent works on it and never asks; where it is closed it asks before each code change —
77
+ * this branch, a new one, or unlocking — and never branches on its own.
78
+ */
79
+ export function branchRuleLine(root) {
80
+ const trunk = defaultBranchName(root) || 'main';
81
+ const policy = trunkPolicy(root);
82
+ if (!policy.closed) return `- Here "${trunk}" is OPEN: code changes go straight on it. Do not ask about branches.\n`;
83
+ const here = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
84
+ const options = here && here !== trunk ? `this branch ("${here}"), a new branch, or unlock "${trunk}"` : `a new branch, or unlock "${trunk}"`;
85
+ return `- Here "${trunk}" is CLOSED (${policy.signals.join(', ')}): before EACH code change ask: ${options}? Never choose alone.\n`;
86
+ }