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.
- package/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/SKILL.md +147 -0
- package/capability-registry.json +27 -0
- package/docs/ARCHITECTURE.md +164 -0
- package/docs/CHANGELOG.md +151 -0
- package/docs/CLI.md +196 -0
- package/docs/COMPATIBILITY.md +124 -0
- package/docs/CONTRIBUTING.md +134 -0
- package/docs/FORMAT.md +157 -0
- package/docs/INSTALL.md +179 -0
- package/docs/INTEGRATION.md +188 -0
- package/docs/LEVEL4.md +202 -0
- package/docs/LEVEL5.md +96 -0
- package/docs/PERMISSIONS.md +145 -0
- package/docs/PROVENANCE.md +83 -0
- package/docs/SECURITY.md +93 -0
- package/docs/SESSIONS.md +66 -0
- package/docs/TROUBLESHOOTING.md +158 -0
- package/docs/UNINSTALL.md +122 -0
- package/docs/UPGRADE.md +139 -0
- package/docs/_config.yml +16 -0
- package/docs/_data/nav.yml +36 -0
- package/docs/_layouts/default.html +31 -0
- package/docs/assets/style.css +88 -0
- package/docs/index.md +83 -0
- package/handoff.config.example.json +35 -0
- package/handoff.config.schema.json +117 -0
- package/install/CHANGELOG.md +48 -0
- package/install/README.md +76 -0
- package/install/install.mjs +856 -0
- package/install/package.json +39 -0
- package/package.json +66 -4
- package/permission-policy.json +33 -0
- package/refs/ADAPTERS.md +33 -0
- package/refs/bootstrap.md +59 -0
- package/refs/brief-checklist.md +79 -0
- package/refs/handbook.md +58 -0
- package/refs/protocol.md +117 -0
- package/refs/roles.md +75 -0
- package/refs/validator.md +73 -0
- package/schemas/handoff.schema.json +275 -0
- package/skill.json +147 -0
- package/templates/HANDOFF.llm.schema.json +144 -0
- package/templates/HANDOFF.template.md +40 -0
- package/tests/acceptance/acceptance.yaml +209 -0
- package/tests/fixtures/minimal-transcript.jsonl +2 -0
- package/tools/agent-handoff.mjs +410 -0
- package/tools/capability-registry.mjs +120 -0
- package/tools/handoff.mjs +398 -0
- package/tools/handoff.test.mjs +465 -0
- package/tools/lib/handoff-root.mjs +161 -0
- 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,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
|
+
}
|