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.
- package/CHANGELOG.md +48 -6
- package/README.md +53 -13
- package/SKILL.md +12 -12
- package/capability-registry.json +1 -1
- package/docs/ARCHITECTURE.md +28 -5
- package/docs/CHANGELOG.md +62 -17
- package/docs/CLI.md +115 -12
- 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 +15 -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
package/install/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-handoff",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Internal manifest for the in-tree installer. The published package is the repository root (agents-handoff), whose bin `agents-handoff` points here.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"test": "node ../tools/handoff.test.mjs"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
|
-
"
|
|
20
|
+
"agents-handoff",
|
|
21
21
|
"installer",
|
|
22
22
|
"npx",
|
|
23
23
|
"skill"
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-handoff",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Write, verify, and hand off complete AI working sessions across any harness (Claude Code, Codex, DeepSeek Harness, plain JSONL or text logs). Cross-harness session capture with sha256 provenance and verified continuation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "tools/handoff.mjs",
|
|
7
7
|
"bin": {
|
|
8
8
|
"agents-handoff": "./install/install.mjs",
|
|
9
|
-
"
|
|
9
|
+
"handoff": "./tools/handoff.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"install/",
|
package/permission-policy.json
CHANGED
package/refs/protocol.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Handoff Protocol — live dispatch state, frontmatter, status, evidence (port of choughton/llm-handoff)
|
|
2
2
|
|
|
3
3
|
> Adapted from `choughton/llm-handoff` (Apache-2.0). This fuses its file-based
|
|
4
|
-
> dispatch protocol into
|
|
4
|
+
> dispatch protocol into agents-handoff's fileset. Where our engine already
|
|
5
5
|
> existed (RESULT/WHAT_CHANGED..., sha256 manifests), this ADDS the live
|
|
6
6
|
> routing layer: a single `HANDOFF.md` file that doubles as **the mutex and the
|
|
7
7
|
> debugger** — every transition is visible as text, and a run only advances when
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"title": "Agent-Handoff",
|
|
4
|
-
"description": "Portable execution state for
|
|
4
|
+
"description": "Portable execution state for agents-handoff — canonical handoff schema v1.0",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"required": [
|
|
7
7
|
"schema_version",
|
package/skill.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"version": "2.0.
|
|
2
|
+
"name": "agents-handoff",
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Write, verify, and hand off complete AI working sessions across any harness (Claude Code, Codex, DeepSeek Harness, plain JSONL or text logs). Cross-harness session capture + verified continuation.",
|
|
5
5
|
"domain": "orchestration",
|
|
6
6
|
"installer": {
|
|
7
7
|
"type": "npx",
|
|
8
8
|
"package": "agents-handoff",
|
|
9
|
-
"packageVersion": "2.0.
|
|
9
|
+
"packageVersion": "2.0.3",
|
|
10
10
|
"repository": "https://github.com/Alot1z/agent-handoff",
|
|
11
11
|
"installCommand": "npx agents-handoff"
|
|
12
12
|
},
|
|
13
13
|
"locations": {
|
|
14
|
-
"source": "skills/
|
|
14
|
+
"source": "skills/agents-handoff/",
|
|
15
15
|
"global": {
|
|
16
|
-
"windows": "<store>/<account-id>/<profile-id>/
|
|
17
|
-
"posix": "~/.config/<client>/account-skills/<account-id>/<profile-id>/
|
|
16
|
+
"windows": "<store>/<account-id>/<profile-id>/agents-handoff/ (resolved, see docs/INSTALL.md)",
|
|
17
|
+
"posix": "~/.config/<client>/account-skills/<account-id>/<profile-id>/agents-handoff/ (resolved)"
|
|
18
18
|
},
|
|
19
|
-
"local": "./local/skills/
|
|
20
|
-
"project": "./skills/
|
|
19
|
+
"local": "./local/skills/agents-handoff/",
|
|
20
|
+
"project": "./skills/agents-handoff/"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"node": ">=18.0.0",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"retitle": "node tools/handoff.mjs retitle <id-prefix> <new-name>"
|
|
34
34
|
},
|
|
35
35
|
"l4": {
|
|
36
|
-
"runtime": "node tools/
|
|
36
|
+
"runtime": "node tools/agents-handoff.mjs <verb> [args]",
|
|
37
37
|
"verbs": [
|
|
38
38
|
"auto",
|
|
39
39
|
"verify-gate",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"SKILL.md",
|
|
69
69
|
"README.md",
|
|
70
70
|
"tools/handoff.mjs",
|
|
71
|
-
"tools/
|
|
71
|
+
"tools/agents-handoff.mjs",
|
|
72
72
|
"tools/handoff.test.mjs",
|
|
73
73
|
"tools/capability-registry.mjs",
|
|
74
74
|
"tools/runtime-engine.mjs",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"handoff.config.schema.json",
|
|
92
92
|
"handoff.config.example.json",
|
|
93
93
|
"tools/handoff.mjs",
|
|
94
|
-
"tools/
|
|
94
|
+
"tools/agents-handoff.mjs",
|
|
95
95
|
"tools/handoff.test.mjs",
|
|
96
96
|
"tools/capability-registry.mjs",
|
|
97
97
|
"tools/runtime-engine.mjs",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Acceptance Manifest
|
|
2
|
-
# Version 1.1 —
|
|
2
|
+
# Version 1.1 — agents-handoff acceptance tests
|
|
3
3
|
#
|
|
4
4
|
# Every `command` below is a shell command: run it from the repository root and compare the
|
|
5
5
|
# exit code, stdout and stderr with what the entry claims. There is no bundled runner — the
|
|
@@ -23,7 +23,7 @@ environment:
|
|
|
23
23
|
placeholders:
|
|
24
24
|
CLI: "node tools/handoff.mjs"
|
|
25
25
|
BUILD_COMMAND: "node tools/handoff.mjs"
|
|
26
|
-
RUNTIME: "node tools/
|
|
26
|
+
RUNTIME: "node tools/agents-handoff.mjs"
|
|
27
27
|
TEST_COMMAND: "node tools/handoff.test.mjs"
|
|
28
28
|
PROJECT_ROOT: "."
|
|
29
29
|
|
package/tools/agent-handoff.mjs
CHANGED
|
@@ -1,410 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// agent-handoff.mjs
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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';
|
|
2
|
+
// tools/agent-handoff.mjs — the runtime layer under its PRE-RENAME name.
|
|
3
|
+
//
|
|
4
|
+
// The product is agents-handoff, and the runtime lives at tools/agents-handoff.mjs. This file
|
|
5
|
+
// stays so that a script, a note, a hook or a CI step written against the old path keeps
|
|
6
|
+
// working: it forwards every argument to the real runtime and passes stdout, stderr and the
|
|
7
|
+
// exit code through unchanged. No verbs live here — do not add any; edit the real file.
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
17
9
|
import path from 'node:path';
|
|
18
|
-
import
|
|
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
|
-
}
|
|
10
|
+
import fs from 'node:fs';
|
|
344
11
|
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
}
|
|
12
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
13
|
+
const REAL = path.join(HERE, 'agents-handoff.mjs');
|
|
376
14
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 }));
|
|
15
|
+
if (!fs.existsSync(REAL)) {
|
|
16
|
+
console.error('agent-handoff: the runtime this path forwards to is missing (' +
|
|
17
|
+
path.relative(process.cwd(), REAL) + ')');
|
|
18
|
+
process.exit(2);
|
|
395
19
|
}
|
|
396
20
|
|
|
397
|
-
const
|
|
398
|
-
|
|
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
|
-
}
|
|
21
|
+
const r = spawnSync(process.execPath, [REAL, ...process.argv.slice(2)], { stdio: 'inherit' });
|
|
22
|
+
process.exit(r.status === null ? 1 : r.status);
|