agents-handoff 2.0.2 → 2.0.4
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/CHANGELOG.md +77 -6
- package/README.md +59 -12
- package/SKILL.md +12 -12
- package/capability-registry.json +1 -1
- package/docs/ARCHITECTURE.md +28 -5
- package/docs/CHANGELOG.md +91 -17
- package/docs/CLI.md +115 -12
- package/docs/COMPATIBILITY.md +24 -0
- package/docs/CONTRIBUTING.md +2 -2
- package/docs/FORMAT.md +28 -0
- package/docs/INSTALL.md +239 -24
- package/docs/INTEGRATION.md +4 -4
- package/docs/LEVEL4.md +10 -10
- package/docs/LEVEL5.md +1 -1
- package/docs/PERMISSIONS.md +2 -2
- package/docs/PROVENANCE.md +27 -0
- package/docs/SECURITY.md +1 -1
- package/docs/SESSIONS.md +31 -0
- package/docs/UNINSTALL.md +47 -21
- package/docs/UPGRADE.md +59 -21
- package/docs/_config.yml +3 -1
- package/docs/index.md +17 -6
- package/docs/sessions.json +34 -0
- package/install/CHANGELOG.md +1 -1
- package/install/README.md +7 -7
- package/install/install.mjs +746 -147
- package/install/package.json +2 -2
- package/package.json +2 -2
- package/permission-policy.json +1 -1
- package/refs/protocol.md +1 -1
- package/schemas/handoff.schema.json +1 -1
- package/skill.json +11 -11
- package/tests/acceptance/acceptance.yaml +2 -2
- package/tools/agent-handoff.mjs +16 -404
- package/tools/agents-handoff.mjs +410 -0
- package/tools/capability-registry.mjs +2 -2
- package/tools/handoff.test.mjs +203 -0
- package/tools/lib/handoff-root.mjs +1 -1
- package/tools/runtime-engine.mjs +2 -2
|
@@ -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 .
|
|
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, '.
|
|
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); };
|
package/tools/handoff.test.mjs
CHANGED
|
@@ -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/
|
|
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
|
//
|