@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.
- package/README.md +117 -657
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +1 -1
- package/scripts/install-apply.js +9 -0
- package/scripts/rsc.js +90 -2
- package/skills/building-agents/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +0 -1
- package/skills/harness/references/daily-curation-automation.md +2 -1
- package/skills/harness/references/wiki-protocol.md +3 -1
- package/skills/implement/SKILL.md +0 -1
- package/skills/implement-spec/SKILL.md +1 -2
- package/skills/init/SKILL.md +7 -5
- package/skills/postgresdb/SKILL.md +1 -1
- package/skills/suggest/SKILL.md +17 -13
- package/skills/suggest/evals/cases.yaml +6 -0
- package/skills/to-spec/SKILL.md +0 -1
- package/skills/to-tickets/SKILL.md +0 -1
- package/skills/wayfinder/SKILL.md +0 -1
- package/targets/_md-block.js +1 -1
- package/targets/claude.js +2 -1
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +114 -52
- package/targets/hook-once.mjs +44 -13
- package/targets/knowledge-sync.mjs +759 -0
- package/targets/knowledge-wiring.js +196 -0
- package/targets/memory.js +7 -1
- package/targets/opencode-memory-plugin.js +67 -22
- package/targets/opt-outs.js +4 -0
- package/targets/session-memory-adapter.mjs +51 -6
- package/targets/session-memory-core.mjs +284 -37
- package/targets/session-start.mjs +2 -1
- package/targets/ship-guard.mjs +26 -9
- package/targets/trunk-policy.mjs +86 -0
- package/targets/userprompt-gate.mjs +7 -2
- package/targets/worklog-checkpoint.mjs +16 -1
- 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
|
-
|
|
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) {
|
|
@@ -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
|
|
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 ===
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
`;
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
118
|
+
hint();
|