@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.
- package/README.md +57 -5
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +9 -0
- package/scripts/rsc.js +27 -1
- package/skills/suggest/SKILL.md +12 -8
- package/skills/suggest/evals/cases.yaml +6 -0
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/knowledge-sync.mjs +759 -0
- package/targets/knowledge-wiring.js +196 -0
- package/targets/memory.js +1 -0
- package/targets/opencode-memory-plugin.js +67 -22
- package/targets/opt-outs.js +4 -0
- package/targets/session-memory-adapter.mjs +47 -5
- package/targets/session-memory-core.mjs +244 -29
- package/targets/trunk-policy.mjs +86 -0
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
101
|
-
return { root: local, kind: 'local-state', reason
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
+
}
|