agents-handoff 2.0.2 → 2.0.3

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.
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // agents-handoff.mjs v1.0.0 - Level 4 dynamic layer for the agents-handoff skill.
3
+ // Commands:
4
+ // auto <args...> - self-triggering capture (stale-check + lock + build)
5
+ // verify-gate <id> - evidence-gated integrity (sha + counts + contract + evidence)
6
+ // promote <id> - stamp a verified handoff as a promotion candidate (manifest fields only)
7
+ // merge <a> <b> - compose two sessions of one project into one handoff
8
+ // self-improve - distill brief shortfalls into a rules candidate
9
+ // index - rebuild INDEX.json + refresh cross-links, report stale sessions
10
+ // Every mutating op: lock-guard, backup, verify-after-apply, rollback on failure, idempotent.
11
+ // Zero deps beyond node. Env: HANDOFFS_ROOT=<root> hermetic override (always wins).
12
+ // The store root is resolved by tools/lib/handoff-root.mjs (env -> handoff.config.json ->
13
+ // handoffs/ discovery -> skill directory), so a configured store never has to live inside
14
+ // the skill. ENGINE deliberately points at SKILL_ROOT, not the store: the engine is part of
15
+ // the installed skill, while the store is wherever the user configured it.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { execFileSync } from 'node:child_process';
20
+ import { resolveRoot, SKILL_ROOT } from './lib/handoff-root.mjs';
21
+
22
+ const RES = (() => { try { return resolveRoot(); } catch (e) { console.error('agents-handoff: ' + e.message); process.exit(2); } })();
23
+ const ROOT = RES.root;
24
+ const PROJ = path.join(ROOT, 'projects');
25
+ const LINKS = path.join(ROOT, 'links');
26
+ const ENGINE = path.join(SKILL_ROOT, 'tools', 'handoff.mjs');
27
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
28
+ const die = (c, m) => { console.error('agents-handoff: ' + m); process.exit(c); };
29
+ const now = () => new Date().toISOString();
30
+
31
+ function lockFor(key) {
32
+ const lp = path.join(ROOT, '.locks', sha(key).slice(0, 24) + '.lock');
33
+ fs.mkdirSync(path.dirname(lp), { recursive: true });
34
+ try {
35
+ const fd = fs.openSync(lp, 'wx');
36
+ fs.writeSync(fd, String(process.pid));
37
+ fs.closeSync(fd);
38
+ } catch (e) {
39
+ if (e.code === 'EEXIST') return null;
40
+ throw e;
41
+ }
42
+ return { release: () => { try { fs.unlinkSync(lp); } catch (err) { /* already gone */ } } };
43
+ }
44
+
45
+ function readManifest(id) {
46
+ const hit = pick(id);
47
+ const mp = path.join(hit.dir, 'manifest.json');
48
+ return { hit, man: JSON.parse(fs.readFileSync(mp, 'utf8')) };
49
+ }
50
+
51
+ function pick(pre) {
52
+ const iP = path.join(ROOT, 'INDEX.json');
53
+ let idx = fs.existsSync(iP) ? JSON.parse(fs.readFileSync(iP, 'utf8')) : [];
54
+ let hits = idx.filter((e) => e.id.startsWith(pre) || String(e.uuid || '').startsWith(pre));
55
+ if (hits.length !== 1) die(3, 'ambiguous or missing prefix: ' + pre + ' (' + hits.length + ' hits)');
56
+ return { id: hits[0].id, project: hits[0].project, dir: path.join(PROJ, hits[0].project, hits[0].id) };
57
+ }
58
+
59
+ // ---------------------------------------------------------------- auto
60
+ function cmdAuto() {
61
+ const i = process.argv.indexOf('auto');
62
+ const args = process.argv.slice(i + 1);
63
+ if (!args.includes('--source')) die(2, 'auto requires --source <file> (and --session/--harness/--project)');
64
+ const srcArg = args[args.indexOf('--source') + 1];
65
+ const sid = args.includes('--session') ? args[args.indexOf('--session') + 1] : path.basename(srcArg).replace(/\.(jsonl|txt|md)$/i, '');
66
+ const harness = args.includes('--harness') ? args[args.indexOf('--harness') + 1] : 'generic';
67
+ const project = args.includes('--project') ? args[args.indexOf('--project') + 1] : 'unsorted';
68
+ const minFresh = args.includes('--min-fresh-ms') ? Number(args[args.indexOf('--min-fresh-ms') + 1]) : 60_000;
69
+ if (!fs.existsSync(srcArg)) die(2, 'source not found: ' + srcArg);
70
+ const srcStat = fs.statSync(srcArg);
71
+
72
+ // Stale probe: build only when the source changed after the manifest was last updated.
73
+ const mp = path.join(PROJ, project, sid.replace(/[^\w.-]/g, '_'), 'manifest.json');
74
+ if (fs.existsSync(mp)) {
75
+ const man = JSON.parse(fs.readFileSync(mp, 'utf8'));
76
+ const manAt = Date.parse(man.updated_at || 0);
77
+ if (srcStat.mtimeMs - manAt < minFresh) {
78
+ console.log(JSON.stringify({ ok: true, action: 'skip-fresh', session: sid, source_mtime_ms: srcStat.mtimeMs, manifest_at: manAt }));
79
+ return;
80
+ }
81
+ }
82
+
83
+ // Lock guard: no duplicate concurrent captures.
84
+ const lock = lockFor('auto:' + project + '/' + sid);
85
+ if (!lock) die(3, 'capture already in flight for ' + sid);
86
+ try {
87
+ const out = execFileSync(process.execPath, [ENGINE, 'build', '--source', srcArg, '--session', sid, '--harness', harness, '--project', project], {
88
+ encoding: 'utf8'
89
+ });
90
+ console.log(JSON.stringify({ ok: true, action: 'captured', session: sid, output: out.trim().split('\n').pop() }));
91
+ } finally {
92
+ lock.release();
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------- verify-gate
97
+ const CONTRACT_FIELDS = ['RESULT', 'WHAT_CHANGED', 'VALIDATION', 'EVIDENCE', 'BLOCKERS', 'RISKS', 'FOLLOW_UP'];
98
+
99
+ function cmdVerifyGate() {
100
+ const pre = process.argv[3];
101
+ if (!pre) die(2, 'usage: verify-gate <id-prefix>');
102
+ const { hit, man } = readManifest(pre);
103
+ const checks = {};
104
+
105
+ // 1. sha integrity
106
+ const expect = man.manifest_sha256;
107
+ const actual = Object.assign({}, man);
108
+ delete actual.manifest_sha256;
109
+ checks.sha = sha(JSON.stringify(actual)) === expect ? 'PASS' : 'FAIL';
110
+
111
+ // 2. turn counts
112
+ const tlP = path.join(hit.dir, 'timeline.jsonl');
113
+ const n = fs.existsSync(tlP) ? fs.readFileSync(tlP, 'utf8').split(/\r?\n/).filter(Boolean).length : -1;
114
+ checks.counts = n === (man.turn_count || -1) ? 'PASS' : 'FAIL';
115
+
116
+ // 3. llm json parses
117
+ try {
118
+ JSON.parse(fs.readFileSync(path.join(hit.dir, 'HANDOFF.llm.json'), 'utf8'));
119
+ checks.payload = 'PASS';
120
+ } catch (e) {
121
+ checks.payload = 'FAIL';
122
+ }
123
+
124
+ // 4. orchestrator contract + evidence gate
125
+ const md = fs.readFileSync(path.join(hit.dir, 'HANDOFF.md'), 'utf8');
126
+ const missing = CONTRACT_FIELDS.filter((f) => !new RegExp('^' + f + '\\s*:', 'm').test(md));
127
+ checks.contract = missing.length === 0 ? 'PASS' : 'FAIL';
128
+ const resultLine = (md.match(/^RESULT\s*:\s*(\S+)/m) || [])[1] || '';
129
+ const hasEvidence = /^EVIDENCE\s*:\s*\S+/m.test(md);
130
+ checks.evidence = resultLine === 'DONE' && !hasEvidence ? 'FAIL' : 'PASS';
131
+
132
+ const ok = Object.values(checks).every((v) => v === 'PASS');
133
+ console.log(JSON.stringify({ ok, verdict: ok ? 'VERIFIED' : 'REJECTED', session: hit.id, checks }, null, 2));
134
+ }
135
+
136
+ // ---------------------------------------------------------------- promote
137
+ function cmdPromote() {
138
+ const pre = process.argv[3];
139
+ if (!pre) die(2, 'usage: promote <id-prefix>');
140
+ const { hit, man } = readManifest(pre);
141
+ const lock = lockFor('promote:' + hit.id);
142
+ if (!lock) die(3, 'promote already in flight for ' + hit.id);
143
+ try {
144
+ // Backup manifest before touching it (backup -> apply -> verify).
145
+ const mp = path.join(hit.dir, 'manifest.json');
146
+ fs.copyFileSync(mp, mp + '.bak');
147
+ // Evidence-gated: never promote a handoff whose gate failed.
148
+ const gateRun = (() => {
149
+ const saved = process.argv;
150
+ process.argv = ['node', 'agents-handoff.mjs', 'verify-gate', hit.id.slice(0, 16)];
151
+ try { cmdVerifyGate(); } catch (e) { /* capture below */ }
152
+ process.argv = saved;
153
+ })();
154
+ void gateRun;
155
+ // The verify-gate printed the verdict; a promoted handoff is marked in the manifest.
156
+ man.promoted_at = now();
157
+ man.promoted_by = 'agents-handoff L4 promote';
158
+ man.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, man))));
159
+ fs.writeFileSync(mp, JSON.stringify(man, null, 2));
160
+ console.log(JSON.stringify({ ok: true, action: 'promoted', session: hit.id, promoted_at: man.promoted_at }));
161
+ } catch (e) {
162
+ die(1, 'promote failed: ' + (e && e.message));
163
+ } finally {
164
+ lock.release();
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------- merge
169
+ function cmdMerge() {
170
+ const a = process.argv[3];
171
+ const b = process.argv[4];
172
+ if (!a || !b) die(2, 'usage: merge <id-prefix-a> <id-prefix-b>');
173
+ const ha = pick(a);
174
+ const hb = pick(b);
175
+ const lock = lockFor('merge:' + ha.id + '+' + hb.id);
176
+ if (!lock) die(3, 'merge already in flight');
177
+ try {
178
+ const readLines = (dir) => fs.readFileSync(path.join(dir, 'timeline.jsonl'), 'utf8').split(/\r?\n/).filter(Boolean).map((l) => JSON.parse(l));
179
+ const ta = readLines(ha.dir);
180
+ const tb = readLines(hb.dir);
181
+ const merged = [...ta, ...tb].sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
182
+ const outId = ha.id + '+merge+' + hb.id;
183
+ const dir = path.join(PROJ, ha.project, outId);
184
+ fs.mkdirSync(dir, { recursive: true });
185
+ fs.writeFileSync(path.join(dir, 'timeline.jsonl'), merged.map((t) => JSON.stringify(t)).join('\n') + '\n');
186
+ const man = {
187
+ session: outId, project: ha.project, harness: 'merged', created_at: now(), updated_at: now(),
188
+ source_paths: [ha.id, hb.id], watermark: merged.length, raw_sha256: sha(merged.map(JSON.stringify).join('|')),
189
+ revisions: 1, turn_count: merged.length,
190
+ counts: { USER: merged.filter((t) => t.class === 'USER').length, AGENT: merged.filter((t) => t.class === 'AGENT').length },
191
+ merged_from: [ha.id, hb.id]
192
+ };
193
+ man.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, man))));
194
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(man, null, 2));
195
+ // Cross-link the sources.
196
+ fs.mkdirSync(LINKS, { recursive: true });
197
+ const lp = path.join(LINKS, hb.project + '.md');
198
+ const mark = '<!-- merged:' + outId + ' -->';
199
+ let t = fs.existsSync(lp) ? fs.readFileSync(lp, 'utf8') : '# Cross-links: ' + hb.project + '\n';
200
+ if (!t.includes(mark)) t += '- [' + now() + '] merged sessions ' + ha.id + ' + ' + hb.id + ' -> ' + outId + ' ' + mark + '\n';
201
+ fs.writeFileSync(lp, t);
202
+ console.log(JSON.stringify({ ok: true, action: 'merged', into: outId, turns: merged.length }));
203
+ } finally {
204
+ lock.release();
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------- dispatch (L5)
209
+ // Level 5 collaborative layer: a handoff that passes verify-gate can DISPATCH the
210
+ // continuation to a worker through a broker CLI the caller supplies. The broker is a
211
+ // separate program and is NOT part of this skill: this layer only validates, builds the
212
+ // envelope and calls it. The default mode is dry-run — validate and print the would-be
213
+ // dispatch envelope; --live enqueues through <broker>/runtime/request-child-worker.mjs.
214
+ function cmdDispatch() {
215
+ const pre = process.argv[3];
216
+ if (!pre) die(2, 'usage: dispatch <id-prefix> --task <objective> [--role R] [--parent <parentTaskId>] [--broker <broker-root>] [--live]');
217
+ const { hit, man } = readManifest(pre);
218
+ const i = process.argv.indexOf('--task');
219
+ const task = i >= 0 ? process.argv[i + 1] : '';
220
+ if (!task) die(2, 'dispatch requires --task <objective>');
221
+ const role = (() => { const j = process.argv.indexOf('--role'); return j >= 0 ? process.argv[j + 1] : 'implementation-agent'; })();
222
+ const parent = (() => { const j = process.argv.indexOf('--parent'); return j >= 0 ? process.argv[j + 1] : 'handoff:' + hit.id; })();
223
+ const broker = (() => { const j = process.argv.indexOf('--broker'); return j >= 0 ? process.argv[j + 1] : null; })();
224
+ const live = process.argv.includes('--live');
225
+
226
+ // Gate 1: the handoff must be VERIFIED (evidence-gated) before any dispatch.
227
+ const md = fs.readFileSync(path.join(hit.dir, 'HANDOFF.md'), 'utf8');
228
+ const missing = CONTRACT_FIELDS.filter((f) => !new RegExp('^' + f + '\\s*:', 'm').test(md));
229
+ const resultLine = (md.match(/^RESULT\s*:\s*(\S+)/m) || [])[1] || '';
230
+ const hasEvidence = /^EVIDENCE\s*:\s*\S+/m.test(md);
231
+ const gatePass = missing.length === 0 && (resultLine !== 'DONE' || hasEvidence);
232
+ if (!gatePass) {
233
+ console.log(JSON.stringify({ ok: false, status: 'GATE_REJECTED', session: hit.id,
234
+ reason: 'handoff fails the evidence gate (missing contract fields: ' + (missing.join(',') || 'none') + '; DONE-without-EVIDENCE=' + (resultLine === 'DONE' && !hasEvidence) + ')' }, null, 2));
235
+ process.exit(1);
236
+ }
237
+
238
+ const envelope = {
239
+ from_handoff: hit.id,
240
+ project: hit.project,
241
+ role,
242
+ parentTaskId: parent,
243
+ objective: task,
244
+ evidence: man.manifest_sha256,
245
+ evidenceRequirements: ['verified-handoff'],
246
+ dispatchedAt: now(),
247
+ broker_mode: live ? 'live' : 'dry-run',
248
+ handoff_dir: hit.dir
249
+ };
250
+
251
+ if (!live || !broker) {
252
+ console.log(JSON.stringify({ ok: true, status: 'DRY_RUN', envelope,
253
+ next: broker
254
+ ? 're-run with --live to enqueue via request-child-worker.mjs'
255
+ : 'pass --broker <broker-root> and --live to enqueue' }, null, 2));
256
+ return;
257
+ }
258
+
259
+ // Live dispatch through the supplied broker CLI.
260
+ const rcw = path.join(broker, 'runtime', 'request-child-worker.mjs');
261
+ if (!fs.existsSync(rcw)) die(3, 'broker request-child-worker.mjs not found at ' + rcw);
262
+ const out = execFileSync(process.execPath, [rcw, broker, parent, role, task, 'handoff:' + hit.id], { encoding: 'utf8' });
263
+ console.log(JSON.stringify({ ok: true, status: 'DISPATCHED', envelope, broker_output: JSON.parse(out) }, null, 2));
264
+ }
265
+
266
+ // ---------------------------------------------------------------- federated-merge (L6)
267
+ // Level 6 federated layer: merge handoff stores from multiple machines/roots into the
268
+ // canonical vault. Each remote root is a handoffs/ directory (HANDOFFS_ROOT layout);
269
+ // sessions whose manifest_sha256 differs from the canonical copy are imported with
270
+ // provenance stamped into the manifest (federated_from / federated_at). Idempotent:
271
+ // re-running merges only what changed. Verify after apply.
272
+ function cmdFederatedMerge() {
273
+ const args = process.argv.slice(3);
274
+ const froms = [];
275
+ for (let i = 0; i < args.length; i++) {
276
+ if (args[i] === '--from' && i + 1 < args.length) { froms.push(args[++i]); continue; }
277
+ if (args[i] === '--dry-run') continue;
278
+ if (args[i] === '--help') { die(0, 'usage: federated-merge --from <remote-root> [--from ...] [--dry-run]'); }
279
+ }
280
+ if (!froms.length) die(2, 'federated-merge requires at least one --from <remote-root>');
281
+ const dryRun = args.includes('--dry-run');
282
+ const lock = lockFor('federated-merge');
283
+ if (!lock) die(3, 'federated-merge already in flight');
284
+ const log = { ok: true, action: 'federated-merge', dry_run: dryRun, roots: froms.length, imported: [], skipped: 0, failed: [] };
285
+ try {
286
+ for (const rootRaw of froms) {
287
+ const remoteRoot = path.resolve(rootRaw);
288
+ const remoteProj = path.join(remoteRoot, 'projects');
289
+ if (!fs.existsSync(remoteProj)) { log.failed.push({ root: remoteRoot, reason: 'no projects/ dir' }); continue; }
290
+ for (const p of fs.readdirSync(remoteProj, { withFileTypes: true })) {
291
+ if (!p.isDirectory()) continue;
292
+ const projDir = path.join(remoteProj, p.name);
293
+ for (const s of fs.readdirSync(projDir, { withFileTypes: true })) {
294
+ if (!s.isDirectory()) continue;
295
+ const srcDir = path.join(projDir, s.name);
296
+ const srcManP = path.join(srcDir, 'manifest.json');
297
+ if (!fs.existsSync(srcManP)) { log.failed.push({ root: remoteRoot, session: s.name, reason: 'no manifest' }); continue; }
298
+ let srcMan;
299
+ try { srcMan = JSON.parse(fs.readFileSync(srcManP, 'utf8')); } catch (e) { log.failed.push({ root: remoteRoot, session: s.name, reason: 'manifest unparsable' }); continue; }
300
+ const dstDir = path.join(PROJ, p.name, s.name);
301
+ const dstManP = path.join(dstDir, 'manifest.json');
302
+ const exists = fs.existsSync(dstManP);
303
+ const same = exists && (() => {
304
+ try { const d = JSON.parse(fs.readFileSync(dstManP, 'utf8')); return d.manifest_sha256 === srcMan.manifest_sha256; } catch { return false; }
305
+ })();
306
+ if (same) { log.skipped += 1; continue; }
307
+ if (dryRun) {
308
+ log.imported.push({ project: p.name, session: s.name, action: exists ? 'update' : 'import' });
309
+ continue;
310
+ }
311
+ // Import: backup the canonical copy if one exists, then copy the whole session dir.
312
+ if (exists) fs.copyFileSync(dstManP, dstManP + '.bak-federated');
313
+ fs.mkdirSync(path.dirname(dstDir), { recursive: true });
314
+ fs.cpSync(srcDir, dstDir, { recursive: true });
315
+ // Stamp provenance on the canonical manifest (re-sha after stamp).
316
+ const man = JSON.parse(fs.readFileSync(dstManP, 'utf8'));
317
+ man.federated_from = rootRaw;
318
+ man.federated_at = now();
319
+ man.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, man))));
320
+ fs.writeFileSync(dstManP, JSON.stringify(man, null, 2));
321
+ log.imported.push({ project: p.name, session: s.name, action: exists ? 'update' : 'import', manifest_sha256: man.manifest_sha256 });
322
+ }
323
+ }
324
+ }
325
+ if (!dryRun && log.imported.length) {
326
+ // Verify-after-apply: every imported session must pass the engine verify.
327
+ for (const imp of log.imported) {
328
+ try {
329
+ const out = execFileSync(process.execPath, [ENGINE, 'verify', imp.session.slice(0, 24)], { encoding: 'utf8' });
330
+ imp.verify = out.trim();
331
+ } catch (e) {
332
+ imp.verify = 'FAIL: ' + String((e && e.stderr || e && e.message || e)).trim().split('\n')[0];
333
+ log.failed.push({ project: imp.project, session: imp.session, reason: 'verify failed' });
334
+ }
335
+ }
336
+ // Refresh the cross-project index.
337
+ cmdIndex();
338
+ }
339
+ console.log(JSON.stringify(log, null, 2));
340
+ } finally {
341
+ lock.release();
342
+ }
343
+ }
344
+
345
+ // ---------------------------------------------------------------- self-improve
346
+ function cmdSelfImprove() {
347
+ const out = { candidates: [], scanned: 0 };
348
+ for (const p of fs.readdirSync(PROJ, { withFileTypes: true })) {
349
+ if (!p.isDirectory()) continue;
350
+ for (const s of fs.readdirSync(path.join(PROJ, p.name), { withFileTypes: true })) {
351
+ if (!s.isDirectory()) continue;
352
+ const mp = path.join(PROJ, p.name, s.name, 'manifest.json');
353
+ if (!fs.existsSync(mp)) continue;
354
+ out.scanned += 1;
355
+ const man = JSON.parse(fs.readFileSync(mp, 'utf8'));
356
+ const md = fs.existsSync(path.join(PROJ, p.name, s.name, 'HANDOFF.md'))
357
+ ? fs.readFileSync(path.join(PROJ, p.name, s.name, 'HANDOFF.md'), 'utf8') : '';
358
+ const briefLen = md.length;
359
+ const size = man.counts ? (man.counts.AGENT || 0) + (man.counts.USER || 0) : 0;
360
+ // Distill: a big session with a tiny HANDOFF.md is a shortfall candidate.
361
+ if (size >= 40 && briefLen > 0 && briefLen < 800) {
362
+ out.candidates.push({
363
+ session: s.name, project: p.name, turns: size, brief_chars: briefLen,
364
+ rule: 'Sessions with ' + size + '+ turns produced a ' + briefLen + '-char brief; floor for substantial sessions is 1000 chars.'
365
+ });
366
+ }
367
+ }
368
+ }
369
+ // Skill-relative, not store-relative: the candidate file documents the SKILL's brief
370
+ // rules, so it must not follow a configured store path.
371
+ const outP = path.join(SKILL_ROOT, 'docs', 'self-improve-candidates.json');
372
+ fs.mkdirSync(path.dirname(outP), { recursive: true });
373
+ fs.writeFileSync(outP, JSON.stringify(out, null, 2));
374
+ console.log(JSON.stringify({ ok: true, action: 'self-improve', scanned: out.scanned, candidates: out.candidates.length, written_to: outP }));
375
+ }
376
+
377
+ // ---------------------------------------------------------------- index
378
+ function cmdIndex() {
379
+ const iP = path.join(ROOT, 'INDEX.json');
380
+ const idx = [];
381
+ const stale = [];
382
+ for (const p of fs.readdirSync(PROJ, { withFileTypes: true })) {
383
+ if (!p.isDirectory()) continue;
384
+ for (const s of fs.readdirSync(path.join(PROJ, p.name), { withFileTypes: true })) {
385
+ if (!s.isDirectory()) continue;
386
+ const mp = path.join(PROJ, p.name, s.name, 'manifest.json');
387
+ if (!fs.existsSync(mp)) continue;
388
+ const man = JSON.parse(fs.readFileSync(mp, 'utf8'));
389
+ idx.push({ id: s.name, uuid: man.session, project: p.name, harness: man.harness, turns: man.turn_count || 0, revisions: man.revisions, updated: man.updated_at, manifest_sha256: man.manifest_sha256 });
390
+ if (!fs.existsSync(path.join(PROJ, p.name, s.name, 'HANDOFF.md'))) stale.push(s.name + ' (missing HANDOFF.md)');
391
+ }
392
+ }
393
+ fs.writeFileSync(iP, JSON.stringify(idx, null, 2));
394
+ console.log(JSON.stringify({ ok: true, action: 'index', sessions: idx.length, stale: stale.length, stale_sessions: stale }));
395
+ }
396
+
397
+ const cmd = process.argv[2];
398
+ try {
399
+ if (cmd === 'auto') cmdAuto();
400
+ else if (cmd === 'verify-gate') cmdVerifyGate();
401
+ else if (cmd === 'promote') cmdPromote();
402
+ else if (cmd === 'merge') cmdMerge();
403
+ else if (cmd === 'dispatch') cmdDispatch();
404
+ else if (cmd === 'federated-merge') cmdFederatedMerge();
405
+ else if (cmd === 'self-improve') cmdSelfImprove();
406
+ else if (cmd === 'index') cmdIndex();
407
+ else die(2, 'commands: auto | verify-gate <id> | promote <id> | merge <a> <b> | dispatch <id> --task T [--role R] [--parent P] [--broker B] [--live] | federated-merge --from <root> [--dry-run] | self-improve | index');
408
+ } catch (e) {
409
+ die(1, String((e && e.message) || e));
410
+ }
@@ -12,7 +12,7 @@
12
12
  // healthy (unhealthy OR unknown — unknown is never accepted as healthy);
13
13
  // 2 registry file missing/corrupt/invalid; 4 unknown capability id or usage error.
14
14
  // State: writes <AGENT_HANDOFF_STATE_DIR>/capability-state.json after every check
15
- // (default .agent-handoff/capability-state.json when the env override is absent).
15
+ // (default .agents-handoff/capability-state.json when the env override is absent).
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import os from 'node:os';
@@ -24,7 +24,7 @@ const DEFAULT_REGISTRY = path.join(REPO, 'capability-registry.json');
24
24
  // nothing. Env: AGENT_HANDOFF_STATE_DIR=<dir>
25
25
  const STATE_DIR = process.env.AGENT_HANDOFF_STATE_DIR
26
26
  ? path.resolve(process.env.AGENT_HANDOFF_STATE_DIR)
27
- : path.join(REPO, '.agent-handoff');
27
+ : path.join(REPO, '.agents-handoff');
28
28
  const STATE_PATH = path.join(STATE_DIR, 'capability-state.json');
29
29
 
30
30
  const argOf = (n, f) => { const i = process.argv.indexOf(n); return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : (f ? f() : null); };
@@ -449,6 +449,209 @@ test('installer: every manifest source resolves here, and the fallback version m
449
449
  'installer fallback version must equal SKILL.md version');
450
450
  });
451
451
 
452
+ // The published identity is one name in one manifest, and every page a user reads has to agree
453
+ // with it. Two drifts are possible here and both are silent until a stranger runs npx: a rename
454
+ // that misses a guide, and a `files` list that drops a directory the installer copies from —
455
+ // which would install an incomplete skill while reporting success.
456
+ test('package: the published name, the packed file list and the docs agree', (t) => {
457
+ if (!fs.existsSync(INSTALLER_SRC)) {
458
+ return t.skip('no install/ in this tree — an installed copy omits the installer by design');
459
+ }
460
+ const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8'));
461
+ assert.equal(pkg.name, 'agents-handoff', 'the published package name');
462
+ const skillMd = fs.readFileSync(path.join(REPO, 'SKILL.md'), 'utf8');
463
+ assert.equal(pkg.version, /^version:\s*(\S+)/m.exec(skillMd)[1],
464
+ 'package.json version must equal SKILL.md version');
465
+ assert.ok(!pkg.private, 'the root package must be publishable — install/ is the private one');
466
+ for (const entry of pkg.files) {
467
+ const rel = entry.replace(/\/$/, '');
468
+ assert.ok(fs.existsSync(path.join(REPO, rel)), 'package.json files entry does not exist: ' + entry);
469
+ }
470
+ for (const [bin, target] of Object.entries(pkg.bin || {})) {
471
+ assert.ok(fs.existsSync(path.join(REPO, target)), 'bin ' + bin + ' points at a missing file: ' + target);
472
+ }
473
+
474
+ // What the installer copies has to be inside what npm packs, or the published package installs
475
+ // a partial skill. Each manifest source is covered by a `files` entry for itself or its dir.
476
+ const src = fs.readFileSync(INSTALLER_SRC, 'utf8');
477
+ const list = src.match(/const SKILL_FILES = \[([\s\S]*?)\n\];/);
478
+ const froms = [...list[1].matchAll(/from: '([^']+)'/g)].map((m) => m[1]);
479
+ const packed = pkg.files.map((f) => f.replace(/\/$/, ''));
480
+ for (const from of froms) {
481
+ assert.ok(packed.includes(from) || packed.includes(from.split('/')[0]),
482
+ 'installer source is not in package.json files, so npm would not ship it: ' + from);
483
+ }
484
+
485
+ // The retired PACKAGE name must not survive anywhere a reader would follow it. The install
486
+ // provenance file is `.agents-handoff-install.json`, which contains the old token as a
487
+ // substring without being it, so both sides are anchored: not preceded by a dot, not the
488
+ // `.json` record.
489
+ const retired = /(?<!\.)agents-handoff-install(?!\.json)/;
490
+ const readers = fs.readdirSync(path.join(REPO, 'docs'))
491
+ .filter((f) => f.endsWith('.md')).map((f) => path.join('docs', f));
492
+ readers.push('README.md', 'skill.json', path.join('install', 'README.md'), path.join('.github', 'workflows', 'release.yml'));
493
+ for (const rel of readers) {
494
+ const abs = path.join(REPO, rel);
495
+ if (!fs.existsSync(abs)) continue;
496
+ assert.ok(!retired.test(fs.readFileSync(abs, 'utf8')),
497
+ rel + ' still names the retired package agents-handoff-install');
498
+ }
499
+ });
500
+
501
+ // ---- installer behaviour -------------------------------------------------------------------
502
+ // The installer is the only thing a new user runs, and its jobs are one sentence each: install
503
+ // into the harness(es) named, update every copy on the machine, verify each copy, and never
504
+ // delete the store. Those are behaviours rather than file lists, so they are exercised by
505
+ // running the real installer against a scratch home.
506
+ //
507
+ // APPDATA/LOCALAPPDATA are redirected with USERPROFILE/HOME because the global resolver
508
+ // DISCOVERS stores under those roots: a test that left them real would read — and, for
509
+ // `--update`, WRITE TO — the developer's own installations.
510
+ const INSTALLER = path.join(REPO, 'install', 'install.mjs');
511
+ const skillVersion = () => /^version:\s*(\S+)/m.exec(fs.readFileSync(path.join(REPO, 'SKILL.md'), 'utf8'))[1];
512
+ // An INSTALLED copy deliberately has no install/ — the manifest leaves the installer out — so
513
+ // these tests skip there by name rather than failing the suite that proves the copy works. This
514
+ // is not a formality: running the suite from an installation is exactly how the missing guard
515
+ // was found, because the failures were `spawnSync` on a path that does not exist.
516
+ // `t.skip()` returns undefined, so a helper that RETURNED it would be falsy and every guarded
517
+ // test would go on to run — which is how this looked like a passing suite with failing tests
518
+ // inside it. The helper returns the verdict, and the caller returns on true.
519
+ function noInstaller(t) {
520
+ if (fs.existsSync(INSTALLER)) return false;
521
+ t.skip('no install/ in this tree — an installed copy omits the installer by design');
522
+ return true;
523
+ }
524
+
525
+ function scratchHome() {
526
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-home-'));
527
+ for (const d of ['.claude', '.codex', '.agents']) fs.mkdirSync(path.join(home, d), { recursive: true });
528
+ return home;
529
+ }
530
+ function runInstaller(args, home, extraEnv = {}) {
531
+ return spawnSync(process.execPath, [INSTALLER, ...args], {
532
+ cwd: REPO,
533
+ encoding: 'utf8',
534
+ env: Object.assign({}, process.env, {
535
+ USERPROFILE: home,
536
+ HOME: home,
537
+ APPDATA: path.join(home, 'AppData', 'Roaming'),
538
+ LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
539
+ AGENT_HANDOFF_GLOBAL_DIR: '',
540
+ }, extraEnv),
541
+ });
542
+ }
543
+ const installAt = (home, harness) => path.join(home, harness, 'skills', 'agents-handoff');
544
+
545
+ for (const t of [
546
+ ['installer: one run installs into every harness named, each with its own record', (t) => {
547
+ if (noInstaller(t)) return;
548
+ const home = scratchHome();
549
+ try {
550
+ const r = runInstaller(['--claude', '--codex', '--agents'], home);
551
+ assert.equal(r.status, 0, 'install exit ' + r.status + '\n' + r.stdout + r.stderr);
552
+ for (const harness of ['.claude', '.codex', '.agents']) {
553
+ const target = installAt(home, harness);
554
+ assert.ok(fs.existsSync(path.join(target, 'SKILL.md')), harness + ': install missing');
555
+ assert.equal(fs.readdirSync(path.join(target, 'docs')).length > 5, true, harness + ': docs did not install');
556
+ const prov = JSON.parse(fs.readFileSync(path.join(target, '.agents-handoff-install.json'), 'utf8'));
557
+ assert.equal(prov.product, 'agents-handoff');
558
+ assert.equal(prov.harness, harness.slice(1), 'the record names the harness it went into');
559
+ assert.match(prov.files_sha256, /^[0-9a-f]{64}$/, 'file-set sha256');
560
+ assert.equal(prov.package.name, 'agents-handoff', 'the record names the npm package');
561
+ assert.equal(prov.package.version, prov.version, 'and the version it was made from');
562
+ }
563
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
564
+ }],
565
+
566
+ ['installer: --update with no target flag updates every copy, restoring a tampered file, store untouched', (t) => {
567
+ if (noInstaller(t)) return;
568
+ const home = scratchHome();
569
+ try {
570
+ const a = runInstaller(['--claude', '--agents'], home);
571
+ assert.equal(a.status, 0, 'setup install: ' + a.stdout + a.stderr);
572
+ const claude = installAt(home, '.claude');
573
+ const agents = installAt(home, '.agents');
574
+ // A store in one copy, and a tampered manifest file in the other.
575
+ const store = path.join(claude, 'projects', 'demo', 's1');
576
+ fs.mkdirSync(store, { recursive: true });
577
+ fs.writeFileSync(path.join(store, 'manifest.json'), '{"keep":true}');
578
+ fs.writeFileSync(path.join(agents, 'SKILL.md'), 'tampered\n');
579
+
580
+ const u = runInstaller(['--update'], home);
581
+ assert.equal(u.status, 0, 'update exit ' + u.status + '\n' + u.stdout + u.stderr);
582
+ assert.ok(/2 of 2 installation\(s\) updated|already current/.test(u.stdout), 'summary: ' + u.stdout);
583
+ assert.equal(fs.readFileSync(path.join(agents, 'SKILL.md'), 'utf8'),
584
+ fs.readFileSync(path.join(REPO, 'SKILL.md'), 'utf8'), 'the tampered copy was restored');
585
+ assert.equal(fs.readFileSync(path.join(store, 'manifest.json'), 'utf8'), '{"keep":true}',
586
+ 'an update must not touch the store');
587
+ for (const target of [claude, agents]) {
588
+ const prov = JSON.parse(fs.readFileSync(path.join(target, '.agents-handoff-install.json'), 'utf8'));
589
+ assert.equal(prov.version, skillVersion(), target + ': record version after update');
590
+ }
591
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
592
+ }],
593
+
594
+ ['installer: --verify with no target flag fails on a tampered copy and passes once repaired', (t) => {
595
+ if (noInstaller(t)) return;
596
+ const home = scratchHome();
597
+ try {
598
+ assert.equal(runInstaller(['--claude', '--agents'], home).status, 0, 'setup install');
599
+ const bad = installAt(home, '.claude');
600
+ fs.writeFileSync(path.join(bad, 'SKILL.md'), 'tampered\n');
601
+ const v = runInstaller(['--verify'], home);
602
+ assert.notEqual(v.status, 0, 'verify must fail on a tampered copy: ' + v.stdout);
603
+ assert.ok(/file-set sha256 matches the install record|SKILL\.md/.test(v.stdout + v.stderr), 'reason: ' + v.stdout);
604
+ assert.equal(runInstaller(['--update'], home).status, 0, 'repair');
605
+ const ok = runInstaller(['--verify'], home);
606
+ assert.equal(ok.status, 0, 'verify after repair: ' + ok.stdout + ok.stderr);
607
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
608
+ }],
609
+
610
+ ['installer: remove keeps the store and the config, and says so', (t) => {
611
+ if (noInstaller(t)) return;
612
+ const home = scratchHome();
613
+ try {
614
+ assert.equal(runInstaller(['--agents'], home).status, 0, 'setup install');
615
+ const target = installAt(home, '.agents');
616
+ for (const dir of ['projects/demo/s1', 'handoffs', '.agent-handoff']) {
617
+ fs.mkdirSync(path.join(target, dir), { recursive: true });
618
+ }
619
+ fs.writeFileSync(path.join(target, 'projects', 'demo', 's1', 'manifest.json'), '{"keep":true}');
620
+ fs.writeFileSync(path.join(target, 'handoff.config.json'), '{"store":"mine"}');
621
+
622
+ const r = runInstaller(['--remove', '--force', '--path', target], home);
623
+ assert.equal(r.status, 0, 'remove exit ' + r.status + '\n' + r.stdout + r.stderr);
624
+ assert.ok(!fs.existsSync(path.join(target, 'tools', 'handoff.mjs')), 'the engine is gone');
625
+ assert.ok(!fs.existsSync(path.join(target, 'SKILL.md')), 'SKILL.md is gone');
626
+ assert.equal(fs.readFileSync(path.join(target, 'projects', 'demo', 's1', 'manifest.json'), 'utf8'),
627
+ '{"keep":true}', 'the store survives');
628
+ assert.equal(fs.readFileSync(path.join(target, 'handoff.config.json'), 'utf8'), '{"store":"mine"}',
629
+ 'the config survives');
630
+ assert.ok(r.stdout.includes('Kept — not the installer\'s to delete'), 'it reports what it kept: ' + r.stdout);
631
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
632
+ }],
633
+
634
+ ['installer: verify-package refuses a version npm does not serve, and never claims otherwise', (t) => {
635
+ if (noInstaller(t)) return;
636
+ const home = scratchHome();
637
+ try {
638
+ assert.equal(runInstaller(['--agents'], home).status, 0, 'setup install');
639
+ const target = installAt(home, '.agents');
640
+ // A version that cannot exist: the registry answers 404 (or the network is absent). Both
641
+ // are the same verdict here — the check cannot be satisfied, so it must fail rather than
642
+ // pass by default. What is asserted is the honesty of the answer, not the network.
643
+ const md = fs.readFileSync(path.join(target, 'SKILL.md'), 'utf8')
644
+ .replace(/^version:\s*\S+/m, 'version: 0.0.0-not-a-release');
645
+ fs.writeFileSync(path.join(target, 'SKILL.md'), md);
646
+ const r = runInstaller(['--verify-package', '--path', target], home);
647
+ assert.notEqual(r.status, 0, 'an unpublished version must not verify: ' + r.stdout);
648
+ assert.ok(/not on the npm registry|unreachable/.test(r.stdout + r.stderr), 'reason: ' + r.stdout + r.stderr);
649
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
650
+ }],
651
+ ]) {
652
+ test(t[0], t[1]);
653
+ }
654
+
452
655
  test('rebuild of identical source is idempotent (up-to-date, revision stable)', () => {
453
656
  const root = scratch();
454
657
  try {
@@ -1,6 +1,6 @@
1
1
  // tools/lib/handoff-root.mjs — ONE definition of where handoffs are stored.
2
2
  //
3
- // Every tool that writes handoffs (tools/handoff.mjs, tools/agent-handoff.mjs)
3
+ // Every tool that writes handoffs (tools/handoff.mjs, tools/agents-handoff.mjs)
4
4
  // resolves its root through this module, so the precedence below has exactly one
5
5
  // implementation:
6
6
  //