agents-handoff 0.0.0-stage → 2.0.2

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -2
  4. package/SKILL.md +147 -0
  5. package/capability-registry.json +27 -0
  6. package/docs/ARCHITECTURE.md +164 -0
  7. package/docs/CHANGELOG.md +151 -0
  8. package/docs/CLI.md +196 -0
  9. package/docs/COMPATIBILITY.md +124 -0
  10. package/docs/CONTRIBUTING.md +134 -0
  11. package/docs/FORMAT.md +157 -0
  12. package/docs/INSTALL.md +179 -0
  13. package/docs/INTEGRATION.md +188 -0
  14. package/docs/LEVEL4.md +202 -0
  15. package/docs/LEVEL5.md +96 -0
  16. package/docs/PERMISSIONS.md +145 -0
  17. package/docs/PROVENANCE.md +83 -0
  18. package/docs/SECURITY.md +93 -0
  19. package/docs/SESSIONS.md +66 -0
  20. package/docs/TROUBLESHOOTING.md +158 -0
  21. package/docs/UNINSTALL.md +122 -0
  22. package/docs/UPGRADE.md +139 -0
  23. package/docs/_config.yml +16 -0
  24. package/docs/_data/nav.yml +36 -0
  25. package/docs/_layouts/default.html +31 -0
  26. package/docs/assets/style.css +88 -0
  27. package/docs/index.md +83 -0
  28. package/handoff.config.example.json +35 -0
  29. package/handoff.config.schema.json +117 -0
  30. package/install/CHANGELOG.md +48 -0
  31. package/install/README.md +76 -0
  32. package/install/install.mjs +856 -0
  33. package/install/package.json +39 -0
  34. package/package.json +66 -4
  35. package/permission-policy.json +33 -0
  36. package/refs/ADAPTERS.md +33 -0
  37. package/refs/bootstrap.md +59 -0
  38. package/refs/brief-checklist.md +79 -0
  39. package/refs/handbook.md +58 -0
  40. package/refs/protocol.md +117 -0
  41. package/refs/roles.md +75 -0
  42. package/refs/validator.md +73 -0
  43. package/schemas/handoff.schema.json +275 -0
  44. package/skill.json +147 -0
  45. package/templates/HANDOFF.llm.schema.json +144 -0
  46. package/templates/HANDOFF.template.md +40 -0
  47. package/tests/acceptance/acceptance.yaml +209 -0
  48. package/tests/fixtures/minimal-transcript.jsonl +2 -0
  49. package/tools/agent-handoff.mjs +410 -0
  50. package/tools/capability-registry.mjs +120 -0
  51. package/tools/handoff.mjs +398 -0
  52. package/tools/handoff.test.mjs +465 -0
  53. package/tools/lib/handoff-root.mjs +161 -0
  54. package/tools/runtime-engine.mjs +330 -0
@@ -0,0 +1,209 @@
1
+ # Acceptance Manifest
2
+ # Version 1.1 — agent-handoff acceptance tests
3
+ #
4
+ # Every `command` below is a shell command: run it from the repository root and compare the
5
+ # exit code, stdout and stderr with what the entry claims. There is no bundled runner — the
6
+ # suite (node tools/handoff.test.mjs) is what CI executes, and this manifest is the readable
7
+ # list of what the shipped tree promises.
8
+ #
9
+ # Version 1.0 described the DEVELOPMENT tree: it listed docs that only exist there, scanned a
10
+ # directory only the development tree has, and asserted two behaviours the shipped CLI does
11
+ # not have (`--help` exiting 0, `list` finding sessions in an empty store). Every entry was
12
+ # re-run against a clean checkout, and each expectation below is one that was observed.
13
+
14
+ version: "1.1"
15
+
16
+ # Test environment
17
+ environment:
18
+ runtime: "node"
19
+ runtime_version: ">=18.0.0"
20
+ working_directory: "."
21
+
22
+ # Placeholder resolver
23
+ placeholders:
24
+ CLI: "node tools/handoff.mjs"
25
+ BUILD_COMMAND: "node tools/handoff.mjs"
26
+ RUNTIME: "node tools/agent-handoff.mjs"
27
+ TEST_COMMAND: "node tools/handoff.test.mjs"
28
+ PROJECT_ROOT: "."
29
+
30
+ # Tests
31
+ tests:
32
+ # A — CLI availability. The engine reports its command list on stderr and exits 2 when it is
33
+ # given no command; there is no `--help` verb.
34
+ - id: cli-availability
35
+ purpose: "CLI starts with no arguments and states its commands"
36
+ command: "${CLI}"
37
+ working_directory: "${PROJECT_ROOT}"
38
+ expected_exit_code: 2
39
+ expected_stderr:
40
+ - "commands:"
41
+ forbidden_effects: []
42
+
43
+ # B — Capability discovery. The registry probes what the machine can actually do.
44
+ - id: capability-discovery
45
+ purpose: "Capability registry reports the capability it can probe"
46
+ command: "node tools/capability-registry.mjs check --registry capability-registry.json"
47
+ working_directory: "${PROJECT_ROOT}"
48
+ expected_exit_code: 0
49
+ expected_stdout:
50
+ - "capability-registry: OK"
51
+ forbidden_effects: []
52
+
53
+ # C — Permission denial. A `denied_roots` entry outranks an approved workspace, so the
54
+ # decision is DENIED and the exit code is 3. The probe policy is written to the OS temp
55
+ # directory and removed in the same command, so nothing is left in the tree.
56
+ - id: permission-denial
57
+ purpose: "A denied root is refused before anything executes"
58
+ command: >
59
+ node -e "const fs=require('fs'),os=require('os'),p=require('path'),{spawnSync}=require('child_process');
60
+ const f=p.join(os.tmpdir(),'ah-acceptance-deny.json');
61
+ fs.writeFileSync(f,JSON.stringify({schema_version:'1.0-permission-policy',approved_workspaces:['.'],denied_roots:['README.md'],grants:{READ_ONLY:'allow'},risk_to_level:{R0:'READ_ONLY'}}));
62
+ const r=spawnSync(process.execPath,['tools/runtime-engine.mjs','evaluate','--policy',f,'--risk','R0','--target','README.md'],{encoding:'utf8'});
63
+ process.stdout.write(r.stdout); fs.unlinkSync(f); process.exit(r.status)"
64
+ working_directory: "${PROJECT_ROOT}"
65
+ expected_exit_code: 3
66
+ expected_stdout:
67
+ - "DENIED_ROOT"
68
+ forbidden_effects: []
69
+
70
+ # D — Safe execution: an irreversible operation is never silently allowed.
71
+ - id: safe-execution
72
+ purpose: "An irreversible risk class needs authorization rather than proceeding"
73
+ command: "node tools/runtime-engine.mjs evaluate --risk R4 --target README.md"
74
+ working_directory: "${PROJECT_ROOT}"
75
+ expected_exit_code: 4
76
+ expected_stdout:
77
+ - "NEEDS_AUTH"
78
+ forbidden_effects: []
79
+
80
+ # E — Handoff creation with an explicit session id.
81
+ - id: handoff-creation
82
+ purpose: "A handoff is created from the shipped fixture"
83
+ command: |
84
+ ${CLI} build --source tests/fixtures/minimal-transcript.jsonl --harness test --project acceptance-test --session test-session
85
+ working_directory: "${PROJECT_ROOT}"
86
+ expected_exit_code: 0
87
+ expected_stdout:
88
+ - "handoff: built"
89
+ expected_artifacts:
90
+ - "projects/acceptance-test/test-session/HANDOFF.md"
91
+ - "projects/acceptance-test/test-session/HANDOFF.summary.json"
92
+ - "projects/acceptance-test/test-session/HANDOFF.llm.json"
93
+ - "projects/acceptance-test/test-session/timeline.jsonl"
94
+ - "projects/acceptance-test/test-session/TOOLS.md"
95
+ - "projects/acceptance-test/test-session/manifest.json"
96
+ forbidden_effects: []
97
+
98
+ # F — A second session, to prove sessions are addressed by id and not by a single slot.
99
+ - id: session-addressing
100
+ purpose: "A second session lands beside the first, under its own id"
101
+ command: |
102
+ ${CLI} build --source tests/fixtures/minimal-transcript.jsonl --harness test --project acceptance-test --session handoff-create-test
103
+ working_directory: "${PROJECT_ROOT}"
104
+ expected_exit_code: 0
105
+ expected_artifacts:
106
+ - "projects/acceptance-test/handoff-create-test/HANDOFF.md"
107
+ forbidden_effects: []
108
+
109
+ # G — Handoff validation: the manifest hash, the timeline length and the machine payload.
110
+ - id: handoff-validation
111
+ purpose: "The created handoff verifies against its own manifest"
112
+ command: "${CLI} verify handoff-create-test"
113
+ working_directory: "${PROJECT_ROOT}"
114
+ expected_exit_code: 0
115
+ expected_stdout:
116
+ - "PASS"
117
+ forbidden_effects: []
118
+
119
+ # H — Reload: the brief a fresh agent reads is printable and names its objective.
120
+ - id: resume
121
+ purpose: "A captured session can be reloaded and read back"
122
+ command: "${CLI} show handoff-create-test"
123
+ working_directory: "${PROJECT_ROOT}"
124
+ expected_exit_code: 0
125
+ expected_stdout:
126
+ - "Objective (verbatim)"
127
+ forbidden_effects: []
128
+
129
+ # I — Failure recovery: malformed input is a clean nonzero exit, not a partial session.
130
+ - id: failure-recovery
131
+ purpose: "A missing source fails cleanly"
132
+ command: "${CLI} build --source nonexistent-file.jsonl"
133
+ working_directory: "${PROJECT_ROOT}"
134
+ expected_exit_code: 2
135
+ expected_stderr:
136
+ - "source not found"
137
+ forbidden_effects: []
138
+
139
+ # J — Clean checkout: the shipped suite is hermetic and is the machine-checkable oracle.
140
+ - id: clean-checkout
141
+ purpose: "The shipped suite passes from a checkout with no state"
142
+ command: "${TEST_COMMAND}"
143
+ working_directory: "${PROJECT_ROOT}"
144
+ expected_exit_code: 0
145
+ expected_stdout:
146
+ - "fail 0"
147
+ forbidden_effects: []
148
+
149
+ # K — The sample store and the page rendered from it agree.
150
+ - id: session-index
151
+ purpose: "The session index page matches the sample store"
152
+ command: "node .github/scripts/build-sessions-index.mjs --check"
153
+ working_directory: "${PROJECT_ROOT}"
154
+ expected_exit_code: 0
155
+ expected_stdout:
156
+ - "OK"
157
+ forbidden_effects: []
158
+
159
+ # Fixtures
160
+ fixtures:
161
+ - id: minimal-transcript
162
+ path: "tests/fixtures/minimal-transcript.jsonl"
163
+ purpose: "Minimal valid transcript for testing"
164
+ content: |
165
+ {"seq":0,"ts":"2026-10-08T10:00:00Z","kind":"human","text":"Test task"}
166
+ {"seq":1,"ts":"2026-10-08T10:00:05Z","kind":"ai","text":"I'll help with that."}
167
+ - id: sample-store
168
+ path: "examples/sessions/INDEX.json"
169
+ purpose: "Sample handoff store the session index page is rendered from"
170
+
171
+ # Verification
172
+ verification:
173
+ - id: artifact-presence
174
+ purpose: "Required artifacts exist in the shipped tree"
175
+ artifacts:
176
+ - "SKILL.md"
177
+ - "README.md"
178
+ - "CHANGELOG.md"
179
+ - "LICENSE"
180
+ - "package.json"
181
+ - "skill.json"
182
+ - "install/install.mjs"
183
+ - "docs/index.md"
184
+ - "docs/INSTALL.md"
185
+ - "docs/CLI.md"
186
+ - "docs/ARCHITECTURE.md"
187
+ - "docs/SESSIONS.md"
188
+ - "schemas/handoff.schema.json"
189
+ - "examples/sessions/INDEX.json"
190
+
191
+ - id: no-secrets
192
+ purpose: "No secrets in the published files"
193
+ scan_paths:
194
+ - "."
195
+ # This manifest names the patterns it forbids, so a scan of the whole tree matches its own
196
+ # list. That one file is excluded; every other published file is in scope.
197
+ exclude:
198
+ - "tests/acceptance/acceptance.yaml"
199
+ forbidden_patterns:
200
+ - "ghp_"
201
+ - "npm_"
202
+ - "sk-"
203
+ - "password="
204
+ - "token="
205
+ - "PRIVATE KEY"
206
+
207
+ - id: schema-valid
208
+ purpose: "Handoff schema is valid JSON"
209
+ schema: "schemas/handoff.schema.json"
@@ -0,0 +1,2 @@
1
+ {"seq":0,"ts":"2026-10-08T10:00:00Z","kind":"human","text":"Test task"}
2
+ {"seq":1,"ts":"2026-10-08T10:00:05Z","kind":"ai","text":"I'll help with that."}
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // agent-handoff.mjs v1.0.0 - Level 4 dynamic layer for the agent-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('agent-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('agent-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', 'agent-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 = 'agent-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
+ }