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,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// capability-registry.mjs v1.0.0 — runtime MVP Task 4: capability registry.
|
|
3
|
+
// Zero-dep (node:* only), filesystem-backed, exercises capabilities through real
|
|
4
|
+
// health probes. HONESTY CONTRACT: every verdict is healthy / unhealthy / unknown
|
|
5
|
+
// with observed evidence — never a guessed verdict. An unknown probe kind, a probe
|
|
6
|
+
// error, or an unreadable target is reported, not interpreted away.
|
|
7
|
+
//
|
|
8
|
+
// Commands:
|
|
9
|
+
// node tools/capability-registry.mjs check [--registry F] [--json] [id]
|
|
10
|
+
// node tools/capability-registry.mjs list [--registry F] [--json]
|
|
11
|
+
// Exit codes: 0 all required capabilities healthy; 1 any required capability not
|
|
12
|
+
// healthy (unhealthy OR unknown — unknown is never accepted as healthy);
|
|
13
|
+
// 2 registry file missing/corrupt/invalid; 4 unknown capability id or usage error.
|
|
14
|
+
// State: writes <AGENT_HANDOFF_STATE_DIR>/capability-state.json after every check
|
|
15
|
+
// (default .agent-handoff/capability-state.json when the env override is absent).
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..');
|
|
22
|
+
const DEFAULT_REGISTRY = path.join(REPO, 'capability-registry.json');
|
|
23
|
+
// State root: overridable so oracles run hermetically and the repo accumulates
|
|
24
|
+
// nothing. Env: AGENT_HANDOFF_STATE_DIR=<dir>
|
|
25
|
+
const STATE_DIR = process.env.AGENT_HANDOFF_STATE_DIR
|
|
26
|
+
? path.resolve(process.env.AGENT_HANDOFF_STATE_DIR)
|
|
27
|
+
: path.join(REPO, '.agent-handoff');
|
|
28
|
+
const STATE_PATH = path.join(STATE_DIR, 'capability-state.json');
|
|
29
|
+
|
|
30
|
+
const argOf = (n, f) => { const i = process.argv.indexOf(n); return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : (f ? f() : null); };
|
|
31
|
+
const hasFlag = n => process.argv.includes(n);
|
|
32
|
+
const die = (c, m) => { if (hasFlag('--json')) console.log(JSON.stringify({ error: m })); else console.error('capability-registry: ' + m); process.exit(c); };
|
|
33
|
+
const rel = p => path.isAbsolute(p) ? p : path.join(REPO, p);
|
|
34
|
+
|
|
35
|
+
function loadRegistry(p) {
|
|
36
|
+
if (!fs.existsSync(p)) die(2, 'registry not found: ' + p);
|
|
37
|
+
let reg;
|
|
38
|
+
try { reg = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
39
|
+
catch (e) { die(2, 'registry is not valid JSON: ' + p + ' (' + e.message + ')'); }
|
|
40
|
+
if (!reg || !Array.isArray(reg.capabilities)) die(2, 'registry has no capabilities array: ' + p);
|
|
41
|
+
return reg;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Each probe returns { verdict, evidence } — verdict is exactly one of
|
|
45
|
+
// healthy | unhealthy | unknown. Probes OBSERVE; they never assume.
|
|
46
|
+
function runProbe(cap) {
|
|
47
|
+
const id = cap.id || '(no id)';
|
|
48
|
+
const kind = String(cap.kind || '');
|
|
49
|
+
const target = cap.target ? rel(cap.target) : null;
|
|
50
|
+
try {
|
|
51
|
+
if (kind === 'file-exists') {
|
|
52
|
+
if (!target) return { id, verdict: 'unknown', evidence: 'file-exists capability has no target declared' };
|
|
53
|
+
const ok = fs.existsSync(target) && fs.statSync(target).isFile();
|
|
54
|
+
return { id, verdict: ok ? 'healthy' : 'unhealthy', evidence: (ok ? 'file present: ' : 'file missing: ') + target };
|
|
55
|
+
}
|
|
56
|
+
if (kind === 'dir-writable') {
|
|
57
|
+
if (!target) return { id, verdict: 'unknown', evidence: 'dir-writable capability has no target declared' };
|
|
58
|
+
fs.mkdirSync(target, { recursive: true });
|
|
59
|
+
const probe = path.join(target, '.write-probe-' + process.pid + '-' + Date.now());
|
|
60
|
+
fs.writeFileSync(probe, 'probe');
|
|
61
|
+
const wrote = fs.existsSync(probe) && fs.readFileSync(probe, 'utf8') === 'probe';
|
|
62
|
+
fs.unlinkSync(probe);
|
|
63
|
+
return { id, verdict: wrote ? 'healthy' : 'unhealthy', evidence: (wrote ? 'wrote+read+deleted probe in ' : 'write probe failed in ') + target };
|
|
64
|
+
}
|
|
65
|
+
if (kind === 'command') {
|
|
66
|
+
if (!cap.command) return { id, verdict: 'unknown', evidence: 'command capability has no command declared' };
|
|
67
|
+
const cmd = cap.command === '<node>' ? process.execPath : cap.command;
|
|
68
|
+
const r = spawnSync(cmd, cap.args || [], { cwd: REPO, encoding: 'utf8', timeout: 15000 });
|
|
69
|
+
if (r.error) return { id, verdict: 'unhealthy', evidence: 'spawn error: ' + String(r.error) };
|
|
70
|
+
if (r.status === 0) return { id, verdict: 'healthy', evidence: 'command exited 0: ' + cap.command };
|
|
71
|
+
return { id, verdict: 'unhealthy', evidence: 'command exited ' + r.status + ': ' + String(r.stderr || r.stdout || '').slice(0, 200) };
|
|
72
|
+
}
|
|
73
|
+
return { id, verdict: 'unknown', evidence: 'no probe implemented for kind "' + kind + '" (no guessed verdicts)' };
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { id, verdict: 'unhealthy', evidence: 'probe error: ' + String(e && e.message || e) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function check() {
|
|
80
|
+
const regPath = rel(argOf('--registry', () => DEFAULT_REGISTRY));
|
|
81
|
+
const reg = loadRegistry(regPath);
|
|
82
|
+
const only = process.argv.find((a, i) => i > 2 && !a.startsWith('-') && a !== regPath && process.argv[i - 1] !== '--registry');
|
|
83
|
+
const caps = only ? reg.capabilities.filter(c => c.id === only) : reg.capabilities;
|
|
84
|
+
if (only && !caps.length) die(4, 'unknown capability id: ' + only);
|
|
85
|
+
const results = caps.map(c => { const r = runProbe(c); return Object.assign(r, { required: c.required !== false, kind: c.kind }); });
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
88
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify({ schema_version: '1.0-capability-state', generated_at: new Date().toISOString(), registry: path.relative(REPO, regPath), results }, null, 2) + '\n');
|
|
89
|
+
} catch (e) { die(1, 'could not persist capability state: ' + e.message); }
|
|
90
|
+
const ok = results.filter(r => r.required).every(r => r.verdict === 'healthy');
|
|
91
|
+
if (hasFlag('--json')) {
|
|
92
|
+
console.log(JSON.stringify({ ok, results, state: path.relative(REPO, STATE_PATH) }, null, 2));
|
|
93
|
+
} else {
|
|
94
|
+
console.log('capability-registry: ' + (ok ? 'OK' : 'FAIL') + ' registry=' + path.relative(REPO, regPath));
|
|
95
|
+
for (const r of results) console.log(' [' + r.verdict + '] ' + r.id + (r.required ? '' : ' (optional)') + ' — ' + r.evidence);
|
|
96
|
+
}
|
|
97
|
+
process.exit(ok ? 0 : 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function list() {
|
|
101
|
+
const regPath = rel(argOf('--registry', () => DEFAULT_REGISTRY));
|
|
102
|
+
const reg = loadRegistry(regPath);
|
|
103
|
+
let state = null;
|
|
104
|
+
if (fs.existsSync(STATE_PATH)) { try { state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { state = null; } }
|
|
105
|
+
if (hasFlag('--json')) {
|
|
106
|
+
console.log(JSON.stringify({ registry: path.relative(REPO, regPath), capabilities: reg.capabilities, last_state: state }, null, 2));
|
|
107
|
+
} else {
|
|
108
|
+
console.log('capability-registry: ' + reg.capabilities.length + ' declared (' + path.relative(REPO, regPath) + ')');
|
|
109
|
+
for (const c of reg.capabilities) {
|
|
110
|
+
const last = state && Array.isArray(state.results) ? state.results.find(r => r.id === c.id) : null;
|
|
111
|
+
console.log(' ' + c.id + ' kind=' + c.kind + (c.required === false ? ' (optional)' : ' (required)') + (last ? ' last=' + last.verdict + ' at ' + state.generated_at : ' last=NEVER-CHECKED'));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const cmd = process.argv[2];
|
|
118
|
+
if (cmd === 'check') check();
|
|
119
|
+
else if (cmd === 'list') list();
|
|
120
|
+
else die(2, 'usage: capability-registry.mjs check [--registry F] [--json] [id] | list [--registry F] [--json]');
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// handoff.mjs v4.0.0 - cross-harness session handoff builder with project grouping
|
|
3
|
+
// Layout: <ROOT>/projects/<project>/<session>/{HANDOFF.md,HANDOFF.summary.json,HANDOFF.llm.json,
|
|
4
|
+
// timeline.jsonl,manifest.json,TOOLS.md} + <ROOT>/projects/<project>/PROJECT.md
|
|
5
|
+
// <ROOT>/links/<other-project>.md (cross-project relation notes)
|
|
6
|
+
// Commands:
|
|
7
|
+
// build --source F [--session id] [--harness n] [--model m] [--project name] [--objective text]
|
|
8
|
+
// --handoff --source F (alias of build)
|
|
9
|
+
// list [project-or-prefix] | show <id-prefix> | verify <id-prefix> | rename <id-prefix> <new-project>
|
|
10
|
+
// config - report the resolved handoff root, and why
|
|
11
|
+
// Env: HANDOFFS_ROOT=<root> hermetic override (always wins). Otherwise the root comes
|
|
12
|
+
// from handoff.config.json (see handoff.config.schema.json) discovered by walking up
|
|
13
|
+
// from the current directory, or from a handoffs/ directory found the same way; with
|
|
14
|
+
// neither, the skill directory is the store. One implementation: tools/lib/handoff-root.mjs.
|
|
15
|
+
// Zero deps. Malformed input -> clean nonzero exit.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { resolveRoot, configProjectName, linkingEnabled } from './lib/handoff-root.mjs';
|
|
20
|
+
// A present-but-invalid config is fatal (exit 2) rather than silently ignored: falling back
|
|
21
|
+
// would send the session to a different store than the one that was configured.
|
|
22
|
+
const RES = (() => { try { return resolveRoot(); } catch (e) { console.error('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 sha = s => crypto.createHash('sha256').update(s).digest('hex');
|
|
27
|
+
const die = (c, m) => { console.error('handoff: ' + m); process.exit(c); };
|
|
28
|
+
const argOf = (n, f) => { const i = process.argv.indexOf(n); return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : (f ? f() : null); };
|
|
29
|
+
const hasFlag = n => process.argv.includes(n);
|
|
30
|
+
const fence = String.fromCharCode(96).repeat(3);
|
|
31
|
+
|
|
32
|
+
function classify(r) {
|
|
33
|
+
const k = String(r.kind || '').toLowerCase(), role = String(r.role || '').toLowerCase();
|
|
34
|
+
if (k.includes('tool') || role === 'tool') return 'TOOL';
|
|
35
|
+
if (k.includes('reason') || k.includes('think')) return 'THOUGHT';
|
|
36
|
+
if (role === 'user' || k === 'human') return 'USER';
|
|
37
|
+
if (role === 'assistant' || k === 'ai') return 'AGENT';
|
|
38
|
+
return 'OTHER';
|
|
39
|
+
}
|
|
40
|
+
function textOf(r) {
|
|
41
|
+
if (typeof r.text === 'string') return r.text;
|
|
42
|
+
if (typeof r.content === 'string') return r.content;
|
|
43
|
+
if (Array.isArray(r.parts)) return r.parts.map(p => p && p.text ? p.text : '').join(' ');
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
function loadTurns(src) {
|
|
47
|
+
const raw = fs.readFileSync(src, 'utf8');
|
|
48
|
+
if (/\.jsonl$/i.test(src)) {
|
|
49
|
+
return raw.split(/\r?\n/).filter(Boolean).map((l, i) => {
|
|
50
|
+
let r = null;
|
|
51
|
+
try { r = JSON.parse(l); } catch (e) { return null; }
|
|
52
|
+
const t = textOf(r);
|
|
53
|
+
if (!t.trim()) return null;
|
|
54
|
+
return { seq: Number.isFinite(+r.seq) ? +r.seq : i, ts: String(r.ts || r.timestamp || ''), cls: classify(r), text: t };
|
|
55
|
+
}).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
const turns = []; let cur = null;
|
|
58
|
+
raw.split(/\r?\n/).forEach(l => {
|
|
59
|
+
const m = l.match(/^\s*(?:#*\s*)?(user|human|assistant|ai|system|tool)\s*[:>]\s*(.*)$/i);
|
|
60
|
+
if (m) { cur = { seq: turns.length, ts: '', cls: /^u|^h/i.test(m[1]) ? 'USER' : (/^a/i.test(m[1]) ? 'AGENT' : (/^tool/i.test(m[1]) ? 'TOOL' : 'OTHER')), text: m[2] }; turns.push(cur); }
|
|
61
|
+
else if (cur) cur.text += '\n' + l;
|
|
62
|
+
});
|
|
63
|
+
return turns.filter(t => t.text.trim());
|
|
64
|
+
}
|
|
65
|
+
const NOTICE_RE = /^(the )?(approval policy|approval|policy changed|system[: ]|notice\b|context low|<\w)/i;
|
|
66
|
+
function pickObjective(turns, override) {
|
|
67
|
+
if (override) return String(override).slice(0, 400);
|
|
68
|
+
const users = turns.filter(t => t.cls === 'USER');
|
|
69
|
+
const good = users.find(t => t.text.length >= 20 && !NOTICE_RE.test(t.text.trim()));
|
|
70
|
+
const agents = turns.filter(t => t.cls === 'AGENT');
|
|
71
|
+
return ((good || users[0] || agents[0] || { text: '(none)' }).text || '').slice(0, 400);
|
|
72
|
+
}
|
|
73
|
+
function slugify(s) { return String(s).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); }
|
|
74
|
+
function tagToProject(tag) {
|
|
75
|
+
let s = String(tag).replace(/^([A-Za-z])-/, '');
|
|
76
|
+
const parts = s.split('-').filter(Boolean);
|
|
77
|
+
let pick = parts[parts.length - 1] || '';
|
|
78
|
+
if (pick.length < 3 && parts.length > 1) pick = parts.slice(-2).join('-');
|
|
79
|
+
return slugify(pick) || 'unsorted';
|
|
80
|
+
}
|
|
81
|
+
function inferProject(src, firstRaw, harnessArg) {
|
|
82
|
+
const p = argOf('--project', () => null);
|
|
83
|
+
if (p) return slugify(p) || 'unsorted';
|
|
84
|
+
const cfgName = configProjectName(RES);
|
|
85
|
+
if (cfgName) return slugify(cfgName) || 'unsorted'; // explicit config beats inference
|
|
86
|
+
const thread = String((firstRaw && firstRaw.thread) || '');
|
|
87
|
+
let m = thread.match(/--([A-Za-z0-9._~-]+)--/);
|
|
88
|
+
if (m) return tagToProject(m[1]);
|
|
89
|
+
const parent = path.basename(path.dirname(path.resolve(src)));
|
|
90
|
+
m = parent.match(/--([A-Za-z0-9._~-]+)--/);
|
|
91
|
+
if (m) return tagToProject(m[1]);
|
|
92
|
+
return slugify(harnessArg || '') || 'unsorted';
|
|
93
|
+
}
|
|
94
|
+
function migrateLegacy() {
|
|
95
|
+
if (!fs.existsSync(ROOT)) return;
|
|
96
|
+
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
97
|
+
if (!e.isDirectory()) continue;
|
|
98
|
+
if (['projects', 'links', 'tools', 'templates', 'refs'].includes(e.name)) continue;
|
|
99
|
+
const mp = path.join(ROOT, e.name, 'manifest.json');
|
|
100
|
+
if (!fs.existsSync(mp)) continue;
|
|
101
|
+
let man = {};
|
|
102
|
+
try { man = JSON.parse(fs.readFileSync(mp, 'utf8')); } catch (err) { continue; }
|
|
103
|
+
const proj = slugify(man.project || man.harness || 'unsorted') || 'unsorted';
|
|
104
|
+
const dst = path.join(PROJ, proj, e.name);
|
|
105
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
106
|
+
if (!fs.existsSync(dst)) { fs.renameSync(path.join(ROOT, e.name), dst); console.log('handoff: migrated legacy ' + e.name + ' -> projects/' + proj); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const STOP = new Set('the a an and or of to in for with on is are was were this that it its as at by be from not will shall into your when what'.split(' '));
|
|
110
|
+
function keywords(s) { return new Set(String(s).toLowerCase().split(/[^a-z0-9_.-]+/).filter(w => w.length >= 4 && !STOP.has(w))); }
|
|
111
|
+
function jaccard(a, b) { let n = 0; for (const w of a) if (b.has(w)) n++; return n / (a.size + b.size - n || 1); }
|
|
112
|
+
function crossLink(proj, sid, objText) {
|
|
113
|
+
if (!linkingEnabled(RES)) return 0; // handoff.config.json: linking.enabled === false
|
|
114
|
+
try { fs.mkdirSync(LINKS, { recursive: true }); } catch (e) { return 0; }
|
|
115
|
+
const me = keywords(objText);
|
|
116
|
+
if (me.size < 3) return 0;
|
|
117
|
+
let added = 0;
|
|
118
|
+
for (const d of fs.readdirSync(PROJ, { withFileTypes: true })) {
|
|
119
|
+
if (!d.isDirectory() || d.name === proj) continue;
|
|
120
|
+
const pp = path.join(PROJ, d.name, 'PROJECT.md');
|
|
121
|
+
if (!fs.existsSync(pp)) continue;
|
|
122
|
+
const score = jaccard(me, keywords(fs.readFileSync(pp, 'utf8')));
|
|
123
|
+
if (score < 0.06) continue;
|
|
124
|
+
const mark = '<!-- xlink:' + proj + '/' + sid + ' -->';
|
|
125
|
+
const lp = path.join(LINKS, d.name + '.md');
|
|
126
|
+
let t = fs.existsSync(lp) ? fs.readFileSync(lp, 'utf8') : ('# Cross-links: ' + d.name + '\n\nRelated sessions from other projects:\n');
|
|
127
|
+
if (t.includes(mark)) continue;
|
|
128
|
+
t += '- [' + new Date().toISOString() + '] related: projects/' + proj + '/' + sid + ' (overlap=' + score.toFixed(2) + ') :: ' + objText.slice(0, 100).split('\n').join(' ') + ' ' + mark + '\n';
|
|
129
|
+
fs.writeFileSync(lp, t);
|
|
130
|
+
added++;
|
|
131
|
+
}
|
|
132
|
+
return added;
|
|
133
|
+
}
|
|
134
|
+
function index() {
|
|
135
|
+
fs.mkdirSync(PROJ, { recursive: true });
|
|
136
|
+
const iP = path.join(ROOT, 'INDEX.json');
|
|
137
|
+
let idx = [];
|
|
138
|
+
if (fs.existsSync(iP)) { try { idx = JSON.parse(fs.readFileSync(iP, 'utf8')); } catch (e) { idx = []; } }
|
|
139
|
+
idx = idx.filter(e => e.dir && fs.existsSync(e.dir));
|
|
140
|
+
for (const p of fs.readdirSync(PROJ, { withFileTypes: true })) {
|
|
141
|
+
if (!p.isDirectory()) continue;
|
|
142
|
+
for (const s of fs.readdirSync(path.join(PROJ, p.name), { withFileTypes: true })) {
|
|
143
|
+
if (!s.isDirectory()) continue;
|
|
144
|
+
const mp = path.join(PROJ, p.name, s.name, 'manifest.json');
|
|
145
|
+
if (!fs.existsSync(mp)) continue;
|
|
146
|
+
let m;
|
|
147
|
+
try { m = JSON.parse(fs.readFileSync(mp, 'utf8')); } catch (e) { continue; }
|
|
148
|
+
idx = idx.filter(e => e.id !== s.name);
|
|
149
|
+
idx.push({ id: s.name, uuid: m.session, project: p.name, harness: m.harness, turns: m.turn_count || 0, revisions: m.revisions, updated: m.updated_at, manifest_sha256: m.manifest_sha256, dir: path.join(PROJ, p.name, s.name) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
fs.writeFileSync(iP, JSON.stringify(idx.map(({ dir, ...rest }) => rest), null, 2));
|
|
153
|
+
return idx;
|
|
154
|
+
}
|
|
155
|
+
function sessionLine(sid, turns, man, objective) {
|
|
156
|
+
return '- session ' + sid + ' (turns=' + turns.length + ', rev=' + man.revisions + ') :: ' + String(objective).slice(0, 80).split('\n').join(' ');
|
|
157
|
+
}
|
|
158
|
+
function build() {
|
|
159
|
+
migrateLegacy();
|
|
160
|
+
let src = argOf('--source', () => { const i = process.argv.indexOf('build'); return i >= 0 ? process.argv[i + 1] : null; });
|
|
161
|
+
if (src && !fs.existsSync(src)) die(2, 'source not found: ' + src);
|
|
162
|
+
if (!src) die(2, 'usage: build --source <file> [--session id] [--harness n] [--model m] [--project name] [--objective text]');
|
|
163
|
+
const turnsAll = loadTurns(src);
|
|
164
|
+
if (!turnsAll.length) die(4, 'no usable turns parsed from ' + src);
|
|
165
|
+
let firstRaw = null;
|
|
166
|
+
try { firstRaw = JSON.parse(fs.readFileSync(src, 'utf8').split(/\r?\n/)[0]); } catch (e) {}
|
|
167
|
+
const mArg = argOf('--model', () => null);
|
|
168
|
+
let sid = argOf('--session', () => null);
|
|
169
|
+
if (!sid && firstRaw && firstRaw.session) sid = String(firstRaw.session);
|
|
170
|
+
if (!sid) sid = path.basename(src).replace(/\.(jsonl|txt|md)$/i, '');
|
|
171
|
+
const hArg = argOf('--harness', () => null);
|
|
172
|
+
let proj = inferProject(src, firstRaw, hArg) || 'unsorted';
|
|
173
|
+
let dirName = sid.replace(/[^\w.-]/g, '_');
|
|
174
|
+
if (!hasFlag('--project')) {
|
|
175
|
+
const rows = index();
|
|
176
|
+
const known = rows.find(e => e.id === sid || e.uuid === sid);
|
|
177
|
+
if (known) { proj = known.project; dirName = known.id; }
|
|
178
|
+
}
|
|
179
|
+
const dir = path.join(PROJ, proj, dirName);
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
181
|
+
const manP = path.join(dir, 'manifest.json');
|
|
182
|
+
let man;
|
|
183
|
+
if (fs.existsSync(manP)) {
|
|
184
|
+
try { man = JSON.parse(fs.readFileSync(manP, 'utf8')); } catch (e) { die(1, 'corrupt manifest at ' + manP); }
|
|
185
|
+
} else {
|
|
186
|
+
man = { session: sid, project: proj, harness: hArg || 'unknown', model: argOf('--model', () => null) || '', created_at: new Date().toISOString(), source_paths: [], watermark: -1, raw_sha256: null, revisions: 0 };
|
|
187
|
+
}
|
|
188
|
+
if (man.project !== proj) { man.prev_project = man.project; man.project = proj; }
|
|
189
|
+
const rsha = sha(fs.readFileSync(src, 'utf8'));
|
|
190
|
+
if (!man.source_paths.includes(src)) man.source_paths.push(src);
|
|
191
|
+
if (hArg && (man.harness === 'unknown' || hasFlag('--force-harness'))) man.harness = hArg;
|
|
192
|
+
if (mArg && (man.model === '' || hasFlag('--force-model'))) man.model = mArg; // persist model on recreate (never drop/empty it)
|
|
193
|
+
const fresh = turnsAll.filter(t => t.seq > man.watermark);
|
|
194
|
+
if (!fresh.length && man.raw_sha256 === rsha) { index(); console.log('handoff: up-to-date (' + sid + ', watermark ' + man.watermark + ', rev ' + man.revisions + ')'); return; }
|
|
195
|
+
const tlP = path.join(dir, 'timeline.jsonl');
|
|
196
|
+
const prior = fs.existsSync(tlP) ? fs.readFileSync(tlP, 'utf8').split(/\r?\n/).filter(Boolean) : [];
|
|
197
|
+
fs.writeFileSync(tlP, [...prior, ...fresh.map(t => JSON.stringify({ seq: t.seq, ts: t.ts, class: t.cls, text: t.text }))].join('\n') + '\n');
|
|
198
|
+
const turns = fs.readFileSync(tlP, 'utf8').split(/\r?\n/).filter(Boolean).map(l => { const o = JSON.parse(l); return { seq: o.seq, ts: o.ts, cls: o.class, text: o.text }; });
|
|
199
|
+
const users = turns.filter(t => t.cls === 'USER');
|
|
200
|
+
const agents = turns.filter(t => t.cls === 'AGENT');
|
|
201
|
+
const objective = pickObjective(turns, argOf('--objective', () => null));
|
|
202
|
+
const stateNow = ((agents.at(-1) || users.at(-1) || { text: '(none)' }).text || '').slice(-1200);
|
|
203
|
+
const openLoops = [...new Set(turns.flatMap(t => String(t.text).split(/\r?\n/)).filter(l => /(next step|todo|blocked|open loop|remaining work)/i.test(l)).map(l => l.trim()))].slice(0, 20);
|
|
204
|
+
const toolCalls = turns.filter(t => t.cls === 'TOOL');
|
|
205
|
+
man.watermark = Math.max(...turnsAll.map(t => t.seq));
|
|
206
|
+
man.raw_sha256 = rsha;
|
|
207
|
+
man.revisions++;
|
|
208
|
+
man.updated_at = new Date().toISOString();
|
|
209
|
+
man.turn_count = turns.length;
|
|
210
|
+
man.counts = { USER: users.length, AGENT: agents.length, THOUGHT: turns.filter(t => t.cls === 'THOUGHT').length, TOOL: toolCalls.length };
|
|
211
|
+
man.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, man))));
|
|
212
|
+
const sum = { schema_version: '2.0.0-summary', session: { id: sid, harness: man.harness, model: man.model }, project: proj, generated_at: man.updated_at, objective, state_now: stateNow.slice(0, 600), open_loops: openLoops, evidence_class: 'OBSERVED', counts: man.counts, artifacts: { full_payload: 'HANDOFF.llm.json', timeline: 'timeline.jsonl', tool_calls: 'TOOLS.md' }, provenance: { sources: man.source_paths, raw_sha256: rsha, manifest_sha256: man.manifest_sha256, revision: man.revisions, watermark: man.watermark, total_turns: turns.length } };
|
|
213
|
+
fs.writeFileSync(path.join(dir, 'HANDOFF.summary.json'), JSON.stringify(sum, null, 2));
|
|
214
|
+
const llm = Object.assign({}, sum, { schema_version: '2.0.0', state_now: stateNow, timeline: turns.map(({ cls, ...rest }) => ({ ...rest, class: cls })), tool_calls: toolCalls.map(({ cls, ...rest }) => ({ ...rest, class: cls })) });
|
|
215
|
+
fs.writeFileSync(path.join(dir, 'HANDOFF.llm.json'), JSON.stringify(llm, null, 2));
|
|
216
|
+
const md = [
|
|
217
|
+
'# Handoff: ' + sid, '',
|
|
218
|
+
'**Project:** ' + proj + ' | **Harness:** ' + man.harness + (man.model ? ' | **Model:** ' + man.model : ''),
|
|
219
|
+
'**Updated:** ' + man.updated_at + ' | **Turns:** ' + turns.length + ' | **Revision:** ' + man.revisions + ' | **Watermark:** seq <= ' + man.watermark, '',
|
|
220
|
+
'## Objective (verbatim)', '', fence, objective, fence, '',
|
|
221
|
+
'## State right now (verbatim tail)', '', fence, stateNow, fence, ''
|
|
222
|
+
];
|
|
223
|
+
if (openLoops.length) md.push('## Open loops / next steps', ...openLoops.map(l => '- ' + l), '');
|
|
224
|
+
md.push('## Recent timeline (last 40 of ' + turns.length + ' turns)', '',
|
|
225
|
+
'| seq | class | text (head 120) |', '|---:|---|---|',
|
|
226
|
+
...turns.slice(-40).map(t => '| ' + t.seq + ' | ' + t.cls + ' | ' + String(t.text).slice(0, 120).split('|').join('\\|').split('\n').join(' ') + ' |'), '',
|
|
227
|
+
'## Where everything lives',
|
|
228
|
+
'- Full timeline (append-only): timeline.jsonl (' + turns.length + ' turns)',
|
|
229
|
+
'- All ' + toolCalls.length + ' tool calls verbatim: TOOLS.md',
|
|
230
|
+
'- Machine payload: HANDOFF.llm.json / Compact payload: HANDOFF.summary.json', '',
|
|
231
|
+
'## Provenance',
|
|
232
|
+
'- sources: ' + man.source_paths.join(', '),
|
|
233
|
+
'- raw sha256: ' + rsha,
|
|
234
|
+
'- manifest sha256: ' + man.manifest_sha256,
|
|
235
|
+
'- incremental: revisions=' + man.revisions + ' (update-not-recreate)', '');
|
|
236
|
+
fs.writeFileSync(path.join(dir, 'HANDOFF.md'), md.join('\n'));
|
|
237
|
+
// TOOLS.md carries EVERY tool call verbatim (full text, never truncated) —
|
|
238
|
+
// this is the lossless fidelity tier that answers "does the handoff contain all calls/bits".
|
|
239
|
+
fs.writeFileSync(path.join(dir, 'TOOLS.md'),
|
|
240
|
+
['# Tool calls: ' + sid, '', 'Total: ' + toolCalls.length, '',
|
|
241
|
+
'Entire tool-call corpus (every shell command, every edit, every tool return) verbatim.',
|
|
242
|
+
'The head-120 table in HANDOFF.md is a digest view; THIS file is the full record.']
|
|
243
|
+
.concat(toolCalls.map(t => '## [' + t.seq + '] ' + (t.ts || '') + '\n' + String(t.text)))
|
|
244
|
+
.join('\n\n') + '\n');
|
|
245
|
+
fs.writeFileSync(manP, JSON.stringify(man, null, 2));
|
|
246
|
+
const pmP = path.join(PROJ, proj, 'PROJECT.md');
|
|
247
|
+
if (!fs.existsSync(pmP)) {
|
|
248
|
+
fs.writeFileSync(pmP, '# Project: ' + proj + '\n\n<!-- suggested-title: replace me -->\n\n## Sessions\n' + sessionLine(sid, turns, man, objective) + '\n');
|
|
249
|
+
} else {
|
|
250
|
+
let pm = fs.readFileSync(pmP, 'utf8');
|
|
251
|
+
if (!pm.includes(sid)) pm = pm.replace(/\s*$/, '\n' + sessionLine(sid, turns, man, objective) + '\n');
|
|
252
|
+
fs.writeFileSync(pmP, pm);
|
|
253
|
+
}
|
|
254
|
+
let suggest = '';
|
|
255
|
+
if (/^[a-z0-9]{8}-[a-z0-9]{4}/i.test(sid) || /^[a-z]{1,4}\d{4,}$/i.test(sid)) {
|
|
256
|
+
const kw = [...keywords(objective)].slice(0, 3).join('-');
|
|
257
|
+
const dstr = new Date().toISOString().slice(0, 10);
|
|
258
|
+
const sug = slugify(dstr + '-' + (kw || 'session'));
|
|
259
|
+
suggest = ' suggest: node handoff.mjs retitle ' + sid.slice(0, 12) + ' ' + sug;
|
|
260
|
+
const pmS = path.join(PROJ, proj, 'PROJECT.md');
|
|
261
|
+
if (fs.existsSync(pmS)) { let pms = fs.readFileSync(pmS, 'utf8');
|
|
262
|
+
if (!pms.includes('<!-- suggested-session-name: ' + sug + ' -->')) pms += '\n<!-- suggested-session-name: ' + sug + ' -->\n'; fs.writeFileSync(pmS, pms); }
|
|
263
|
+
}
|
|
264
|
+
const xl = crossLink(proj, sid, objective);
|
|
265
|
+
index();
|
|
266
|
+
console.log('handoff: built ' + sid + ' project=' + proj + ' turns=' + turns.length + ' new=' + fresh.length + ' rev=' + man.revisions + (xl ? ' xlink-notes=' + xl : '') + suggest + ' -> ' + dir);
|
|
267
|
+
}
|
|
268
|
+
function list() {
|
|
269
|
+
index();
|
|
270
|
+
const iP = path.join(ROOT, 'INDEX.json');
|
|
271
|
+
let rows = fs.existsSync(iP) ? JSON.parse(fs.readFileSync(iP, 'utf8')) : [];
|
|
272
|
+
if (!rows.length) die(4, 'no handoffs yet');
|
|
273
|
+
const pre = process.argv[3];
|
|
274
|
+
if (pre) rows = rows.filter(e => e.project === pre || e.id.startsWith(pre));
|
|
275
|
+
if (!rows.length) die(4, 'no handoffs matching ' + pre);
|
|
276
|
+
for (const e of rows.sort((a, b) => String(b.updated).localeCompare(String(a.updated))))
|
|
277
|
+
console.log('[' + e.project + '] ' + e.id.slice(0, 44) + ' harness=' + e.harness + ' turns=' + e.turns + ' rev=' + e.revisions + ' updated=' + e.updated);
|
|
278
|
+
}
|
|
279
|
+
function pick(pre) {
|
|
280
|
+
const idxFresh = index();
|
|
281
|
+
const iP = path.join(ROOT, 'INDEX.json');
|
|
282
|
+
let hits = idxFresh.filter(e => e.id.startsWith(pre) || (e.uuid && String(e.uuid).startsWith(pre)));
|
|
283
|
+
if (!hits.length && !/^session-/i.test(pre)) { const alt = 'session-' + pre; hits = idxFresh.filter(e => e.id.startsWith(alt) || (e.uuid && String(e.uuid).startsWith(alt))); }
|
|
284
|
+
if (!hits.length) die(4, 'no handoff matches prefix: ' + pre);
|
|
285
|
+
if (hits.length > 1) die(3, 'ambiguous prefix (' + hits.length + ' hits): ' + pre);
|
|
286
|
+
hits[0].dir = path.join(PROJ, hits[0].project, hits[0].id);
|
|
287
|
+
return hits[0];
|
|
288
|
+
}
|
|
289
|
+
function show() {
|
|
290
|
+
const pre = process.argv[3];
|
|
291
|
+
if (!pre) die(2, 'usage: show <id-prefix>');
|
|
292
|
+
const h = pick(pre);
|
|
293
|
+
console.log(fs.readFileSync(path.join(h.dir, 'HANDOFF.md'), 'utf8'));
|
|
294
|
+
}
|
|
295
|
+
function verify() {
|
|
296
|
+
const pre = process.argv[3];
|
|
297
|
+
if (!pre) die(2, 'usage: verify <id-prefix>');
|
|
298
|
+
const h = pick(pre), d = h.dir;
|
|
299
|
+
const man = JSON.parse(fs.readFileSync(path.join(d, 'manifest.json'), 'utf8'));
|
|
300
|
+
const expect = man.manifest_sha256;
|
|
301
|
+
const actual = Object.assign({}, man);
|
|
302
|
+
delete actual.manifest_sha256;
|
|
303
|
+
if (sha(JSON.stringify(actual)) !== expect) die(1, 'FAIL ' + h.id + ': manifest tampered (sha mismatch)');
|
|
304
|
+
const tlP = path.join(d, 'timeline.jsonl');
|
|
305
|
+
if (!fs.existsSync(tlP)) die(1, 'FAIL ' + h.id + ': timeline.jsonl missing');
|
|
306
|
+
const n = fs.readFileSync(tlP, 'utf8').split(/\r?\n/).filter(Boolean).length;
|
|
307
|
+
if (n !== (man.turn_count || -1)) die(1, 'FAIL ' + h.id + ': turn count drift (' + n + ' vs manifest ' + man.turn_count + ')');
|
|
308
|
+
JSON.parse(fs.readFileSync(path.join(d, 'HANDOFF.llm.json'), 'utf8'));
|
|
309
|
+
console.log('PASS ' + h.id + ' [' + man.project + ']: manifest sha ok / timeline=' + n + ' turns / llm json parses');
|
|
310
|
+
}
|
|
311
|
+
function retitleCmd() {
|
|
312
|
+
const pre = process.argv[3];
|
|
313
|
+
const nn = slugify(process.argv[4] || '');
|
|
314
|
+
if (!pre || !nn) die(2, 'usage: retitle <id-prefix> <new-human-readable-name>');
|
|
315
|
+
const h = pick(pre);
|
|
316
|
+
const nd2 = path.join(PROJ, h.project, nn);
|
|
317
|
+
if (fs.existsSync(nd2)) die(4, 'target name already exists: ' + nd2);
|
|
318
|
+
fs.renameSync(h.dir, nd2);
|
|
319
|
+
const mp = path.join(nd2, 'manifest.json');
|
|
320
|
+
const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
|
|
321
|
+
m.titled_from = m.titled_from || [];
|
|
322
|
+
if (!m.titled_from.includes(h.id)) m.titled_from.push(h.id);
|
|
323
|
+
m.revisions++;
|
|
324
|
+
m.updated_at = new Date().toISOString();
|
|
325
|
+
m.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, m))));
|
|
326
|
+
fs.writeFileSync(mp, JSON.stringify(m, null, 2));
|
|
327
|
+
// refresh EVERY render from the bumped manifest - no stale provenance may survive
|
|
328
|
+
for (const [f, isJson] of [['HANDOFF.summary.json', true], ['HANDOFF.llm.json', true]]) {
|
|
329
|
+
const fp = path.join(nd2, f);
|
|
330
|
+
if (!fs.existsSync(fp)) continue;
|
|
331
|
+
const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
332
|
+
j.session = j.session || {};
|
|
333
|
+
j.session.title = nn;
|
|
334
|
+
j.generated_at = m.updated_at;
|
|
335
|
+
j.provenance = j.provenance || {};
|
|
336
|
+
j.provenance.manifest_sha256 = m.manifest_sha256;
|
|
337
|
+
j.provenance.revision = m.revisions;
|
|
338
|
+
fs.writeFileSync(fp, JSON.stringify(j, null, 2));
|
|
339
|
+
}
|
|
340
|
+
const mdP = path.join(nd2, 'HANDOFF.md');
|
|
341
|
+
let md = fs.readFileSync(mdP, 'utf8');
|
|
342
|
+
md = md.replace(/^\*session-id:.*\n?/gm, '');
|
|
343
|
+
md = md.replace(/^# Handoff: .*$/m, '# Handoff: ' + nn + '\n\n*session-id: ' + m.session + '*');
|
|
344
|
+
md = md.replace(/\*\*Updated:\*\* [^|]+\|/, '**Updated:** ' + m.updated_at + ' |');
|
|
345
|
+
md = md.replace(/\*\*Revision:\*\* \d+/, '**Revision:** ' + m.revisions);
|
|
346
|
+
md = md.replace(/- manifest sha256: [0-9a-f]{64}/, '- manifest sha256: ' + m.manifest_sha256);
|
|
347
|
+
md = md.replace(/- incremental: revisions=\d+/, '- incremental: revisions=' + m.revisions);
|
|
348
|
+
fs.writeFileSync(mdP, md);
|
|
349
|
+
index();
|
|
350
|
+
console.log('handoff: retitled ' + h.id + ' -> ' + nn + ' [project ' + h.project + '] renders refreshed rev=' + m.revisions);
|
|
351
|
+
}
|
|
352
|
+
function renameCmd() {
|
|
353
|
+
const pre = process.argv[3];
|
|
354
|
+
const np = slugify(process.argv[4] || '');
|
|
355
|
+
if (!pre || !np) die(2, 'usage: rename <id-prefix> <new-project-name>');
|
|
356
|
+
const h = pick(pre);
|
|
357
|
+
const nd = path.join(PROJ, np, h.id);
|
|
358
|
+
fs.mkdirSync(path.dirname(nd), { recursive: true });
|
|
359
|
+
fs.renameSync(h.dir, nd);
|
|
360
|
+
const mp = path.join(nd, 'manifest.json');
|
|
361
|
+
const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
|
|
362
|
+
m.prev_project = m.project;
|
|
363
|
+
m.project = np;
|
|
364
|
+
m.revisions++;
|
|
365
|
+
m.updated_at = new Date().toISOString();
|
|
366
|
+
m.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, m))));
|
|
367
|
+
fs.writeFileSync(mp, JSON.stringify(m, null, 2));
|
|
368
|
+
const llp = path.join(nd, 'HANDOFF.llm.json');
|
|
369
|
+
const ll = JSON.parse(fs.readFileSync(llp, 'utf8'));
|
|
370
|
+
ll.project = np;
|
|
371
|
+
ll.provenance.manifest_sha256 = m.manifest_sha256;
|
|
372
|
+
fs.writeFileSync(llp, JSON.stringify(ll, null, 2));
|
|
373
|
+
const sp = path.join(nd, 'HANDOFF.summary.json');
|
|
374
|
+
if (fs.existsSync(sp)) { const sm = JSON.parse(fs.readFileSync(sp, 'utf8')); sm.project = np; sm.provenance.manifest_sha256 = m.manifest_sha256; fs.writeFileSync(sp, JSON.stringify(sm, null, 2)); }
|
|
375
|
+
let md = fs.readFileSync(path.join(nd, 'HANDOFF.md'), 'utf8').replace(/\*\*Project:\*\* [^|]*\|/, '**Project:** ' + np + ' |');
|
|
376
|
+
fs.writeFileSync(path.join(nd, 'HANDOFF.md'), md);
|
|
377
|
+
index();
|
|
378
|
+
console.log('handoff: renamed ' + h.id + ' -> project ' + np);
|
|
379
|
+
}
|
|
380
|
+
function configCmd() {
|
|
381
|
+
console.log('handoff: config root=' + ROOT);
|
|
382
|
+
console.log('handoff: config source=' + RES.source);
|
|
383
|
+
console.log('handoff: config file=' + (RES.configPath || 'none'));
|
|
384
|
+
console.log('handoff: config schema=' + RES.schemaPath);
|
|
385
|
+
console.log('handoff: config project=' + (configProjectName(RES) || 'auto-detected'));
|
|
386
|
+
console.log('handoff: config linking=' + (linkingEnabled(RES) ? 'enabled' : 'disabled'));
|
|
387
|
+
}
|
|
388
|
+
const cmd = process.argv[2];
|
|
389
|
+
try {
|
|
390
|
+
if (cmd === 'build' || hasFlag('--handoff')) build();
|
|
391
|
+
else if (cmd === 'config') configCmd();
|
|
392
|
+
else if (cmd === 'list') list();
|
|
393
|
+
else if (cmd === 'show') show();
|
|
394
|
+
else if (cmd === 'verify') verify();
|
|
395
|
+
else if (cmd === 'rename') renameCmd();
|
|
396
|
+
else if (cmd === 'retitle') retitleCmd();
|
|
397
|
+
else die(2, 'commands: build --source F [--session id][--harness n][--model m][--project p][--objective t] | --handoff | list [proj] | show P | verify P | rename P NAME | retitle P NAME | config');
|
|
398
|
+
} catch (e) { die(1, String(e && e.message || e)); }
|