@cr1992/agentkit 1.1.1 → 1.2.0

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.
@@ -7,22 +7,17 @@ import { tmpdir } from 'node:os';
7
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { ContractError, canonicalJson, envelopeDigest, parseJsonStrict, sha256, validateContract } from './contract-tool.mjs';
10
- import { ORCHESTRATION_PROTOCOL_VERSION, ORCHESTRATION_RUNTIME_VERSION } from './orchestration-metadata.mjs';
10
+ import { ORCHESTRATION_PROTOCOL_VERSION, ORCHESTRATION_RUNTIME_VERSION, skillContentDigest } from './orchestration-metadata.mjs';
11
11
  import { isHelpRequest, renderCliHelp, specOptionNames } from '../../core/cli-help.mjs';
12
12
  import { writeNewJson } from '../../core/atomic-fs.mjs';
13
+ import { LEDGER_ID_PATTERN, deleteLedgerPointerFile, ledgerDirectory, listLedgerPointers, pointerDirectory, recordLedgerPointer, removeLedgerPointer, resolveGitCommonDir } from '../../core/ledger-pointer.mjs';
13
14
  import { createReflectionKit } from '../../core/reflection.mjs';
14
- import { distributionDigest, skillDistributionRoots } from '../../core/content-digest.mjs';
15
+ import { substanceWarnings } from '../../core/contract-substance.mjs';
15
16
 
16
17
  const { buildProposal, buildReflection } = createReflectionKit({ strict: true });
17
18
 
18
- const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'orchestrate-subagents');
19
- // 摘要覆盖 Skill 目录 + 共享 core + canonical schemas:执行真正依赖的全部分发内容。
20
- // PACKAGE_ROOT 是模块常量,传入自定义 root 只替换 Skill 目录那一段,便于测试摘要与安装路径无关。
21
- const PACKAGE_ROOT = resolve(SKILL_ROOT, '..');
22
- const DOMAIN_ROOT = dirname(fileURLToPath(import.meta.url));
23
- export function skillContentDigest(root = SKILL_ROOT) {
24
- return distributionDigest(skillDistributionRoots({ packageRoot: PACKAGE_ROOT, skillRoot: root, domainRoot: DOMAIN_ROOT, docsRoot: join(PACKAGE_ROOT, 'docs', 'orchestrate') }));
25
- }
19
+ // 摘要真源在 orchestration-metadata.mjs;这里继续导出,既有调用方(含测试)不受搬迁影响。
20
+ export { skillContentDigest };
26
21
 
27
22
  const NODE_STATES = new Set(['pending', 'running', 'blocked', 'awaiting_verification', 'passed', 'failed', 'cancelled']);
28
23
  const TERMINAL_NODE_STATES = new Set(['passed', 'failed', 'cancelled']);
@@ -63,7 +58,19 @@ function readJournal(dir) {
63
58
  }
64
59
  function load(dir) { const journal = readJournal(dir); const latest = journal.events.at(-1).snapshot; const path = join(dir, 'snapshot.json'); if (!existsSync(path)) return { snapshot: latest, repair: true, journal }; const file = readJson(path); const drift = canonicalJson(file) !== canonicalJson(latest); return { snapshot: drift ? latest : file, repair: drift || Boolean(journal.trailing), journal }; }
65
60
  function persist(dir, snapshot, kind) { const next = { ...snapshot, revision: snapshot.revision + 1, updated_at: new Date().toISOString() }; const previous = readJournal(dir).events.at(-1)?.event_digest ?? null; const event = { schema_version: 1, revision: next.revision, kind, recorded_at: next.updated_at, previous_event_digest: previous, snapshot: next }; event.event_digest = envelopeDigest(event, 'event_digest'); const fd = openSync(join(dir, 'events.ndjson'), 'a', 0o600); try { appendFileSync(fd, `${canonicalJson(event)}\n`); fsyncSync(fd); } finally { closeSync(fd); } atomicJson(join(dir, 'snapshot.json'), next); return next; }
66
- function mutate(dir, expected, callback) { return withLock(dir, () => { const loaded = load(dir); if (loaded.journal.trailing) writeFileSync(join(dir, 'events.ndjson'), loaded.journal.complete, { mode: 0o600 }); if (loaded.repair) atomicJson(join(dir, 'snapshot.json'), loaded.snapshot); if (expected !== null && loaded.snapshot.revision !== expected) throw new LedgerError(`revision conflict: expected ${expected}, actual ${loaded.snapshot.revision}`); if (loaded.snapshot.skill_provenance.content_digest !== skillContentDigest()) throw new LedgerError('skill_drift:必须 re-contract'); const result = callback(loaded.snapshot); return persist(dir, result.snapshot, result.kind); }); }
61
+ // skill drift 的唯一判据:冻结摘要与当前 runtime 内容摘要不相等。mutate、doctor、status/inspect 共用这一个比较,
62
+ // 三处不可能各自漂移;doctor 已经算过当前摘要时把它传进来,避免重复遍历分发内容。
63
+ export function skillDrift(snapshot, current = skillContentDigest()) { return snapshot.skill_provenance?.content_digest !== current; }
64
+ const SKILL_DRIFT_REMEDIATION = '冻结的 skill_provenance.content_digest 与当前 runtime 不一致:这个 ledger 只剩两条路——用 `close --abandon --reason <text>` 记为放弃,或用当前 runtime re-contract 一个新 ledger。';
65
+ // digest 前缀足以让人肉眼分辨"冻结的是哪一版",不需要贴出完整 64 位 hex。
66
+ function digestPrefix(digest) { return typeof digest === 'string' && digest ? digest.slice(0, 19) : String(digest ?? null); }
67
+ // mutate 拦下 drift 修改命令时的报错:给出两份摘要的可辨识前缀,避免被当成别的校验失败误读。
68
+ function driftRefusalMessage(snapshot, current = skillContentDigest()) { return `skill_drift:冻结的 skill_provenance.content_digest(${digestPrefix(snapshot.skill_provenance?.content_digest)}…)与当前 runtime 内容摘要(${digestPrefix(current)}…)不一致;这个 ledger 只剩两条路——用 close --abandon --reason <text> 记为放弃,或用当前 runtime re-contract 一个新 ledger。`; }
69
+ // 终态之后任务图冻结。这里只判断 lifecycle 是否存在,不区分 closed / abandoned:两种终态对任务图的约束相同。
70
+ function assertOpen(snapshot) { const life = snapshot.lifecycle; if (!life) return; throw new LedgerError(`ledger 已处于终态:lifecycle.state=${life.state},closed_at=${life.closed_at};任务图已冻结,之后只剩 record-reflection / propose-improvement / rebuild / doctor / status / inspect / batch-status / batch-fuse`); }
71
+ // allowSkillDrift 只由 `close --abandon` 传入——它不推进任务图,也不按冻结协议下任何结论,是 drift 检查的唯一豁免点。
72
+ // allowTerminal 只由终态白名单里的两条反思写入传入;其余修改命令(含将来新增的)默认在终态之后 fail closed。
73
+ function mutate(dir, expected, callback, { allowSkillDrift = false, allowTerminal = false } = {}) { return withLock(dir, () => { const loaded = load(dir); if (loaded.journal.trailing) writeFileSync(join(dir, 'events.ndjson'), loaded.journal.complete, { mode: 0o600 }); if (loaded.repair) atomicJson(join(dir, 'snapshot.json'), loaded.snapshot); if (expected !== null && loaded.snapshot.revision !== expected) throw new LedgerError(`revision conflict: expected ${expected}, actual ${loaded.snapshot.revision}`); if (!allowSkillDrift && skillDrift(loaded.snapshot)) throw new LedgerError(driftRefusalMessage(loaded.snapshot)); if (!allowTerminal) assertOpen(loaded.snapshot); const result = callback(loaded.snapshot); return persist(dir, result.snapshot, result.kind); }); }
67
74
 
68
75
  // CLI 命令与参数的唯一真源:parseCli 的合法性判断和 `--help` 清单都从这里推导。
69
76
  const CLI_SPEC = {
@@ -80,16 +87,33 @@ const CLI_SPEC = {
80
87
  'batch-fuse': { required: ['ledger', 'batch'] },
81
88
  'record-reflection': { required: ['ledger', 'input'], optional: ['expected-revision'] },
82
89
  'propose-improvement': { required: ['ledger', 'reflection', 'input'], optional: ['expected-revision'] },
90
+ close: { required: ['ledger'], optional: ['expected-revision', 'reason'], flags: ['abandon'] },
83
91
  status: { required: ['ledger'] },
84
92
  inspect: { required: ['ledger'] },
85
93
  rebuild: { required: ['ledger'], optional: ['expected-revision'] },
86
- doctor: { required: ['ledger'] },
94
+ // doctor 有两个互斥档位:--ledger 诊断单个 ledger,--repository 扫描该仓的全部仓级指针。
95
+ doctor: { optional: ['ledger', 'repository'] },
96
+ 'reclaim-pointers': { required: ['repository'] },
87
97
  };
88
98
  const CLI_NOTES = [
89
99
  '--ledger 传 init 回显的 ledger 字段(<state-root>/ledgers/<ledger-id>),不是 --state-root 本身。',
90
100
  '--state-root 必须落在业务仓库之外;确需仓内时显式加 --allow-repository-state。',
91
101
  '所有修改命令都接受 --expected-revision 做乐观并发控制,冲突即 fail closed。',
102
+ 'close 要求 status 的 summary.completion_ready 为 true;不满足时会逐条列出未满足的条件并非零退出。',
103
+ 'close --abandon --reason <text> 记录放弃,reason 必填且非空;它是 skill_drift 下唯一仍能写入的命令。',
104
+ '终态之后任务图冻结,只剩 record-reflection / propose-improvement / rebuild / doctor / status / inspect / batch-status / batch-fuse。',
105
+ 'init 在 contract.environment.repository 指向的仓库里写仓级指针 <git-common-dir>/agentkit/ledgers/<ledger-id>.json;close 成功后删除它。指针不是真源,写/删失败只报 warning。',
106
+ 'doctor --repository <path> 扫描该仓的全部仓级指针并报告悬空项;它是只读的,回收要显式用 reclaim-pointers --repository <path>。',
107
+ 'drift 但未进入终态的 ledger 指针一律保留:它还需要有人来 close --abandon 或 re-contract,回收指针等于把它藏起来。',
92
108
  ];
109
+ // 终态之后仍然放行的命令:架构 §15.2 的高优先级反思触发按定义发生在完成之后;rebuild 是崩溃修复;
110
+ // 其余是只读回看——batch-fuse 只按已记录的 records 重算熔断判定,不写事件链,与 batch-status 同列。
111
+ // reclaim-pointers 不作用于某个 ledger 的任务图,而是清理仓级指针目录,因此不属于终态冻结集合。
112
+ const POST_TERMINAL_ALLOWED = new Set(['capabilities', 'init', 'record-reflection', 'propose-improvement', 'rebuild', 'doctor', 'status', 'inspect', 'batch-status', 'batch-fuse', 'reclaim-pointers']);
113
+ // 终态冻结集合由 CLI_SPEC 全集减白名单推导,不另抄一份清单:新增命令默认落进冻结集合。
114
+ export const FROZEN_AFTER_TERMINAL = Object.keys(CLI_SPEC).filter((name) => !POST_TERMINAL_ALLOWED.has(name));
115
+ // drift 冻结集合 = 走 mutate 写事件链的命令,判据是 CLI_SPEC 里接受 --expected-revision;rebuild 只按事件链重放快照,不经 mutate。
116
+ export const DRIFT_BLOCKED_COMMANDS = Object.entries(CLI_SPEC).filter(([name, spec]) => name !== 'rebuild' && (spec.optional ?? []).includes('expected-revision')).map(([name]) => name);
93
117
  function parseCli(argv) { const command = argv[0] ?? ''; const spec = CLI_SPEC[command]; if (!spec) throw new LedgerError('未知 ledger 命令'); const options = {}; const flags = new Set(); for (let i = 1; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith('--')) throw new LedgerError(`未知位置参数 ${token}`); const name = token.slice(2); if ((spec.flags ?? []).includes(name)) { if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) throw new LedgerError(`--${name} 不接受值`); flags.add(name); } else if (specOptionNames(spec).includes(name)) { if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) throw new LedgerError(`--${name} 缺少值`); options[name] = argv[++i]; } else throw new LedgerError(`未知选项: --${name}`); } return { command, options, flags }; }
94
118
  // `--ledger` 要的是 init 回显的 ledger 目录。init 早期只回显 state root,最容易误传 state root,
95
119
  // 而老实现要等到读 events.ndjson 才报 ENOENT;这里先识别 state root 形状并直接指出应传的路径。
@@ -107,14 +131,32 @@ function req(options, name) { if (!options[name]) throw new LedgerError(`缺少
107
131
  function revision(options) { if (options['expected-revision'] === undefined) return null; const value = Number(options['expected-revision']); if (!Number.isInteger(value) || value < 0) throw new LedgerError('expected-revision 无效'); return value; }
108
132
 
109
133
  function init(options, flags) {
110
- const contract = validateContract(readJson(req(options, 'contract')));
134
+ // init warning 清单:实质性检查的告警与「指针未写入」共用它——两者都是「ledger 建成了,但有事要告诉你」。
135
+ const initWarnings = [];
136
+ const contract = validateContract(readJson(req(options, 'contract')), { substance: true, warnings: initWarnings });
111
137
  const binding = contract.skill_set.find((item) => item.name === 'orchestrate-subagents');
112
138
  if (!binding || binding.content_digest !== skillContentDigest()) throw new LedgerError('Task Contract 未冻结当前 orchestrate-subagents content digest');
113
139
  const root = resolve(options['state-root'] ?? join(tmpdir(), 'orchestration-ledger-state')); const repository = contract.environment.repository;
114
140
  if (repository && repository !== 'none' && existsSync(repository) && inside(root, repository) && !flags.has('allow-repository-state')) throw new LedgerError('state root 位于业务仓库内');
115
- mkdirSync(join(root, 'ledgers'), { recursive: true, mode: 0o700 }); const id = options['ledger-id'] ?? randomUUID(); if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new LedgerError('ledger-id 无效'); const dir = join(root, 'ledgers', id); mkdirSync(dir, { mode: 0o700 }); const now = new Date().toISOString();
141
+ mkdirSync(join(root, 'ledgers'), { recursive: true, mode: 0o700 }); const id = options['ledger-id'] ?? randomUUID(); if (!LEDGER_ID_PATTERN.test(id)) throw new LedgerError(`ledger-id 无效:当前值 ${JSON.stringify(id)};要求非空且只含字母、数字、点、下划线与连字符(${LEDGER_ID_PATTERN.source})`); const dir = join(root, 'ledgers', id); mkdirSync(dir, { mode: 0o700 }); const now = new Date().toISOString();
116
142
  const snapshot = { schema_version: 1, runtime_version: ORCHESTRATION_RUNTIME_VERSION, ledger_id: id, revision: 0, contract_digest: contract.contract_digest, nodes: {}, edges: [], attachments: [], batches: {}, reflection_refs: [], improvement_proposal_refs: [], skill_provenance: { name: 'orchestrate-subagents', version: ORCHESTRATION_PROTOCOL_VERSION, content_digest: skillContentDigest() }, created_at: now, updated_at: now };
117
- writeNew(join(dir, 'contract.json'), contract); const initialEvent = { schema_version: 1, revision: 0, kind: 'initialized', recorded_at: now, previous_event_digest: null, snapshot }; initialEvent.event_digest = envelopeDigest(initialEvent, 'event_digest'); writeFileSync(join(dir, 'events.ndjson'), `${canonicalJson(initialEvent)}\n`, { flag: 'wx', mode: 0o600 }); atomicJson(join(dir, 'snapshot.json'), snapshot); return { ledger_id: id, ledger_dir: dir, ledger: dir, state_root: root, revision: 0 };
143
+ writeNew(join(dir, 'contract.json'), contract); const initialEvent = { schema_version: 1, revision: 0, kind: 'initialized', recorded_at: now, previous_event_digest: null, snapshot }; initialEvent.event_digest = envelopeDigest(initialEvent, 'event_digest'); writeFileSync(join(dir, 'events.ndjson'), `${canonicalJson(initialEvent)}\n`, { flag: 'wx', mode: 0o600 }); atomicJson(join(dir, 'snapshot.json'), snapshot);
144
+ // 顺序与失败语义:先把 ledger 建成(事件链 + 快照落盘),最后才写仓级指针。
145
+ // 指针不是真源,写失败不能让 init 失败后留下半个 ledger——那样反而丢掉真正有状态的那一份;
146
+ // 因此写失败降级为 warning,ledger 本身照常可用,代价只是这一轮要手传 --ledger。
147
+ const pointer = writePointerForInit(repository, id, root, contract.contract_digest, now);
148
+ if (!pointer.written) initWarnings.push(`仓级指针未写入:${pointer.reason}`);
149
+ // 指针按 ledger_id 索引:同名 ledger 换 state root 重建会顶掉旧那一份,旧 ledger 从此只能手传 --ledger。
150
+ else if (pointer.replaced) initWarnings.push(`仓级指针 ${pointer.path} 原先指向 state root ${pointer.replaced},已被本次 init 覆盖:同名 ledger 只能有一份指针,旧 ledger 之后需要手传 --ledger`);
151
+ return { ledger_id: id, ledger_dir: dir, ledger: dir, state_root: root, revision: 0, pointer, ...(initWarnings.length ? { warnings: initWarnings } : {}) };
152
+ }
153
+
154
+ function writePointerForInit(repository, ledgerId, stateRoot, contractDigest, createdAt) {
155
+ try {
156
+ return recordLedgerPointer({ repository, ledgerId, stateRoot, contractDigest, createdAt });
157
+ } catch (error) {
158
+ return { written: false, path: null, git_common_dir: null, reason: `写入仓级指针失败:${error instanceof Error ? error.message : String(error)}` };
159
+ }
118
160
  }
119
161
 
120
162
  function cycle(nodes, edges) { const adj = new Map(Object.keys(nodes).map((id) => [id, []])); for (const edge of edges) adj.get(edge.from)?.push(edge.to); const visiting = new Set(), done = new Set(); const visit = (id) => { if (visiting.has(id)) return true; if (done.has(id)) return false; visiting.add(id); for (const next of adj.get(id) ?? []) if (visit(next)) return true; visiting.delete(id); done.add(id); return false; }; return [...adj.keys()].some(visit); }
@@ -250,18 +292,19 @@ function assuranceForNode(dir, snapshot, node, verificationRef) {
250
292
  }
251
293
  if (node.stable_outputs.length === 0) throw new LedgerError('实现节点没有稳定交付物,不能 passed');
252
294
  if (requirement === 'worker_self_check') return requirement;
253
- if (!DIGEST_PATTERN.test(verificationRef ?? '')) throw new LedgerError(`${requirement} 必须用 verification_ref 绑定精确 attachment digest`);
254
295
  if (requirement === 'controller_recheck') {
296
+ if (!DIGEST_PATTERN.test(verificationRef ?? '')) throw new LedgerError(`node[${node.node_id}].verification_ref 当前为 ${JSON.stringify(verificationRef ?? null)}:controller_recheck 要求它是一个 sha256 attachment digest,精确指向该节点已 attach 的 Controller Recheck Record;先用 attach 登记该记录,再在 update 里带上它的 digest`);
255
297
  const outputs = controllerOutputs(dir, node);
256
298
  const report = attachmentValues(dir, node, 'report').find(({ entry, value }) => entry.digest === verificationRef && value.report_type === 'controller_recheck');
257
- if (!report) throw new LedgerError('verification_ref 未指向 Controller Recheck Record');
299
+ if (!report) throw new LedgerError(`node[${node.node_id}].verification_ref=${verificationRef} 未指向 Controller Recheck Record:controller_recheck 要求 verification_ref 精确匹配该节点已 attach 的一份 controller_recheck 记录;先用 attach 登记该记录,再在 update 里带上它的 digest`);
258
300
  validateControllerRecheck(report.value, snapshot, outputs);
259
301
  return requirement;
260
302
  }
303
+ if (!DIGEST_PATTERN.test(verificationRef ?? '')) throw new LedgerError(`node[${node.node_id}].verification_ref 当前为 ${JSON.stringify(verificationRef ?? null)}:independent_evidence 要求它是一个 sha256 attachment digest,精确指向该节点已 attach 的 Evidence Package;先用 attach 登记该 Evidence Package,再在 update 里带上它的 digest`);
261
304
  const artifacts = attachmentValues(dir, node, 'artifact').map(({ value }) => validateArtifactRef(value));
262
305
  if (artifacts.length !== 1) throw new LedgerError('independent_evidence 节点必须且只能绑定一个 Artifact Ref');
263
306
  const evidence = attachmentValues(dir, node, 'evidence').find(({ entry }) => entry.digest === verificationRef);
264
- if (!evidence) throw new LedgerError('verification_ref 未指向 Evidence Package');
307
+ if (!evidence) throw new LedgerError(`node[${node.node_id}].verification_ref=${verificationRef} 未指向 Evidence Package:independent_evidence 要求 verification_ref 精确匹配该节点已 attach 的一份 Evidence Package;先用 attach 登记该 Evidence Package,再在 update 里带上它的 digest`);
265
308
  const projections = projectedContractsForNode(dir, snapshot, node.node_id);
266
309
  validateEvidencePackage(evidence.value, snapshot, artifacts, { requirePass: true, projections });
267
310
  return requirement;
@@ -308,15 +351,164 @@ function batchRecord(options) { const dir = ledgerDir(options); const input = re
308
351
  function batchStatus(options) { const snapshot = load(ledgerDir(options)).snapshot; const batch = snapshot.batches[req(options, 'batch')]; if (!batch) throw new LedgerError('batch 不存在'); return batch; }
309
352
  function batchFuse(options) { const batch = batchStatus(options); return { batch_id: batch.batch_id, state: batch.state, fuse: batch.fuse ?? evaluateBatchFuse(batch) }; }
310
353
 
311
- function recordReflection(options) { const dir = ledgerDir(options); const input = readJson(req(options, 'input')); return mutate(dir, revision(options), (snapshot) => { const event = readJournal(dir).events.at(-1); const eventDigest = sha256(Buffer.from(canonicalJson(event), 'utf8')); let value; try { value = buildReflection({ input: { ...input, trigger: input.trigger ?? 'unexpected_outcome', impact: input.impact ?? 'medium', confidence: 'high', recommended_disposition: input.recommended_disposition ?? 'continue', evidence_refs: [{ type: 'event', id: `events.ndjson#revision=${event.revision}`, digest: eventDigest }] }, stateDir: dir, scope: { contract_digest: snapshot.contract_digest }, skill: snapshot.skill_provenance, parseJsonStrict, canonicalJson, envelopeDigest }); } catch (error) { throw new LedgerError(error.message); } mkdirSync(join(dir, 'reflections'), { recursive: true, mode: 0o700 }); const ref = `reflections/${value.reflection_id}.json`; writeNewJson(join(dir, ref), value); return { snapshot: { ...snapshot, reflection_refs: [...snapshot.reflection_refs, { reflection_id: value.reflection_id, reflection_digest: value.reflection_digest, ref }] }, kind: 'reflection_recorded' }; }); }
312
- function propose(options) { const dir = ledgerDir(options); const input = readJson(req(options, 'input')); const reflectionId = req(options, 'reflection'); return mutate(dir, revision(options), (snapshot) => { const ref = snapshot.reflection_refs.find((item) => item.reflection_id === reflectionId); if (!ref) throw new LedgerError('Reflection 未登记'); const reflection = readJson(join(dir, ref.ref)); let value; try { value = buildProposal({ input: { ...input, problem_type: input.problem_type ?? 'skill_gap', validation_plan: { replay_cases: input.validation_plan?.replay_cases ?? [], regression_suites: input.validation_plan?.regression_suites ?? [], independent_review: 'required' } }, reflections: [reflection], skill: snapshot.skill_provenance, envelopeDigest }); } catch (error) { throw new LedgerError(error.message); } mkdirSync(join(dir, 'proposals'), { recursive: true, mode: 0o700 }); const path = `proposals/${value.proposal_id}.json`; writeNewJson(join(dir, path), value); return { snapshot: { ...snapshot, improvement_proposal_refs: [...snapshot.improvement_proposal_refs, { proposal_id: value.proposal_id, proposal_digest: value.proposal_digest, ref: path }] }, kind: 'improvement_proposed' }; }); }
354
+ function recordReflection(options) { const dir = ledgerDir(options); const input = readJson(req(options, 'input')); return mutate(dir, revision(options), (snapshot) => { const event = readJournal(dir).events.at(-1); const eventDigest = sha256(Buffer.from(canonicalJson(event), 'utf8')); let value; try { value = buildReflection({ input: { ...input, trigger: input.trigger ?? 'unexpected_outcome', impact: input.impact ?? 'medium', confidence: 'high', recommended_disposition: input.recommended_disposition ?? 'continue', evidence_refs: [{ type: 'event', id: `events.ndjson#revision=${event.revision}`, digest: eventDigest }] }, stateDir: dir, scope: { contract_digest: snapshot.contract_digest }, skill: snapshot.skill_provenance, parseJsonStrict, canonicalJson, envelopeDigest }); } catch (error) { throw new LedgerError(error.message); } mkdirSync(join(dir, 'reflections'), { recursive: true, mode: 0o700 }); const ref = `reflections/${value.reflection_id}.json`; writeNewJson(join(dir, ref), value); return { snapshot: { ...snapshot, reflection_refs: [...snapshot.reflection_refs, { reflection_id: value.reflection_id, reflection_digest: value.reflection_digest, ref }] }, kind: 'reflection_recorded' }; }, { allowTerminal: true }); }
355
+ function propose(options) { const dir = ledgerDir(options); const input = readJson(req(options, 'input')); const reflectionId = req(options, 'reflection'); return mutate(dir, revision(options), (snapshot) => { const ref = snapshot.reflection_refs.find((item) => item.reflection_id === reflectionId); if (!ref) throw new LedgerError('Reflection 未登记'); const reflection = readJson(join(dir, ref.ref)); let value; try { value = buildProposal({ input: { ...input, problem_type: input.problem_type ?? 'skill_gap', validation_plan: { replay_cases: input.validation_plan?.replay_cases ?? [], regression_suites: input.validation_plan?.regression_suites ?? [], independent_review: 'required' } }, reflections: [reflection], skill: snapshot.skill_provenance, envelopeDigest }); } catch (error) { throw new LedgerError(error.message); } mkdirSync(join(dir, 'proposals'), { recursive: true, mode: 0o700 }); const path = `proposals/${value.proposal_id}.json`; writeNewJson(join(dir, path), value); return { snapshot: { ...snapshot, improvement_proposal_refs: [...snapshot.improvement_proposal_refs, { proposal_id: value.proposal_id, proposal_digest: value.proposal_digest, ref: path }] }, kind: 'improvement_proposed' }; }, { allowTerminal: true }); }
313
356
 
314
- function status(options) { const loaded = load(ledgerDir(options)); const nodes = Object.values(loaded.snapshot.nodes); const totalTokens = nodes.reduce((acc, n) => acc + (typeof n.tokens === 'number' ? n.tokens : (n.tokens?.total_tokens ?? 0)), 0); const tokensByRole = {}; for (const n of nodes) { const t = typeof n.tokens === 'number' ? n.tokens : (n.tokens?.total_tokens ?? 0); tokensByRole[n.role] = (tokensByRole[n.role] || 0) + t; } return { ...loaded.snapshot, recovery_needed: loaded.repair, summary: { pending: nodes.filter((n) => n.state === 'pending').length, active: nodes.filter((n) => ['running', 'blocked', 'awaiting_verification'].includes(n.state)).length, terminal: nodes.filter((n) => TERMINAL_NODE_STATES.has(n.state)).length, completion_ready: nodes.filter((n) => n.required).every((n) => n.state === 'passed'), verification_assurance: { none: nodes.filter((n) => !n.verification_assurance || n.verification_assurance === 'none').length, worker_self_check: nodes.filter((n) => n.verification_assurance === 'worker_self_check').length, controller_recheck: nodes.filter((n) => n.verification_assurance === 'controller_recheck').length, independent_evidence: nodes.filter((n) => n.verification_assurance === 'independent_evidence').length, not_applicable: nodes.filter((n) => n.verification_assurance === 'not_applicable').length }, token_accounting: { total_tokens: totalTokens, by_role: tokensByRole } } }; }
357
+ // 实现节点只看 verification.requirement:not_applicable 是只读评审节点的专用档,其余节点都有交付物。role 不参与判定。
358
+ function implementationNode(node) { return node.verification?.requirement !== 'not_applicable'; }
359
+ // 集成验证节点:已 passed 的 independent_evidence,且验的是集成候选而不是单节点产物。
360
+ function integrationVerified(node) { return node.state === 'passed' && node.verification?.requirement === 'independent_evidence' && node.verification?.artifact_scope === 'integration_candidate'; }
361
+ // 沿 dependency / barrier 边反向 BFS(边是 from→to,to 依赖 from),得到所有能到达某个集成验证节点的上游集合。可达性是传递的。
362
+ function integrationCoverage(nodes, edges) {
363
+ const upstream = new Map();
364
+ for (const edge of edges) { if (!['dependency', 'barrier'].includes(edge.kind)) continue; if (!upstream.has(edge.to)) upstream.set(edge.to, []); upstream.get(edge.to).push(edge.from); }
365
+ const covered = new Set(Object.values(nodes).filter(integrationVerified).map((node) => node.node_id));
366
+ const queue = [...covered];
367
+ while (queue.length) { const current = queue.pop(); for (const from of upstream.get(current) ?? []) if (!covered.has(from)) { covered.add(from); queue.push(from); } }
368
+ return covered;
369
+ }
370
+ // 完成判定只在这里算一次:status.summary.completion_ready 与 close 的门禁读的是同一个结果,
371
+ // 两边不可能各自漂移。覆盖规则只在契约声明了 verify-agent-output provider 时生效。
372
+ function completionGate(dir, snapshot) {
373
+ const nodes = Object.values(snapshot.nodes);
374
+ const declared = readJson(join(dir, 'contract.json')).extensions?.verification?.provider === 'verify-agent-output';
375
+ const covered = declared ? integrationCoverage(snapshot.nodes, snapshot.edges) : new Set();
376
+ const required = nodes.filter((n) => n.required);
377
+ // 未覆盖 = required 的实现节点,既不是自身已通过的 independent_evidence,也到不了任何集成验证节点。
378
+ const uncovered = declared ? required.filter((n) => implementationNode(n) && !(n.state === 'passed' && n.verification?.requirement === 'independent_evidence') && !covered.has(n.node_id)).map((n) => n.node_id) : [];
379
+ const unpassed = required.filter((n) => n.state !== 'passed').map((n) => `${n.node_id}(state=${n.state})`);
380
+ // 空 ledger(没有任何 required 节点)不算完成:`[].every(...)` 的空真会让覆盖规则和 close 门禁一起失效。
381
+ return { declared, required_total: required.length, unpassed_required_nodes: unpassed, uncovered_implementation_nodes: uncovered, completion_ready: required.length > 0 && !unpassed.length && !uncovered.length };
382
+ }
383
+ // 只说清楚哪条判据没过、当前值是什么,不给可照抄的合规值。
384
+ function unmetCompletionConditions(gate) {
385
+ const unmet = [];
386
+ if (!gate.required_total) unmet.push('nodes 里没有任何 required 节点(required 节点数=0):完成判定至少需要一个 required 节点,空 ledger 与只有非 required 节点的 ledger 都不算完成;用 add-node 登记节点');
387
+ if (gate.unpassed_required_nodes.length) unmet.push(`nodes[].state:required 节点尚未 passed —— ${gate.unpassed_required_nodes.join('、')};这些节点要先满足各自 verification.requirement 要求的证据,才能用 update 把 state 改为 passed`);
388
+ if (gate.uncovered_implementation_nodes.length) unmet.push(`summary.uncovered_implementation_nodes:契约声明了 verify-agent-output provider,但这些 required 实现节点没有被任何集成验证节点覆盖 —— ${gate.uncovered_implementation_nodes.join('、')};需要一个 verification.requirement=independent_evidence 且 artifact_scope=integration_candidate 的已 passed 节点,通过 add-edge 的依赖边可达它们`);
389
+ return unmet;
390
+ }
391
+ function status(options) { const dir = ledgerDir(options); const loaded = load(dir); const nodes = Object.values(loaded.snapshot.nodes); const totalTokens = nodes.reduce((acc, n) => acc + (typeof n.tokens === 'number' ? n.tokens : (n.tokens?.total_tokens ?? 0)), 0); const tokensByRole = {}; for (const n of nodes) { const t = typeof n.tokens === 'number' ? n.tokens : (n.tokens?.total_tokens ?? 0); tokensByRole[n.role] = (tokensByRole[n.role] || 0) + t; }
392
+ const gate = completionGate(dir, loaded.snapshot);
393
+ // drift 是 controller 冷启动第一眼就要看见的事实:不能等到第一次 mutate 失败才知道这个 ledger 已经不能继续。
394
+ const drift = skillDrift(loaded.snapshot);
395
+ return { ...loaded.snapshot, recovery_needed: loaded.repair, skill_drift: drift, skill_drift_remediation: drift ? SKILL_DRIFT_REMEDIATION : null, summary: { pending: nodes.filter((n) => n.state === 'pending').length, active: nodes.filter((n) => ['running', 'blocked', 'awaiting_verification'].includes(n.state)).length, terminal: nodes.filter((n) => TERMINAL_NODE_STATES.has(n.state)).length, completion_ready: gate.completion_ready, unmet_completion_conditions: unmetCompletionConditions(gate), uncovered_implementation_nodes: gate.uncovered_implementation_nodes, non_required_implementation_nodes: nodes.filter((n) => !n.required && implementationNode(n)).map((n) => n.node_id), nodes_without_independent_evidence: gate.declared ? [] : nodes.filter((n) => implementationNode(n) && n.verification_assurance !== 'independent_evidence').map((n) => n.node_id), verification_assurance: { none: nodes.filter((n) => !n.verification_assurance || n.verification_assurance === 'none').length, worker_self_check: nodes.filter((n) => n.verification_assurance === 'worker_self_check').length, controller_recheck: nodes.filter((n) => n.verification_assurance === 'controller_recheck').length, independent_evidence: nodes.filter((n) => n.verification_assurance === 'independent_evidence').length, not_applicable: nodes.filter((n) => n.verification_assurance === 'not_applicable').length }, token_accounting: { total_tokens: totalTokens, by_role: tokensByRole } } }; }
396
+ // 终态:正常 close 以冻结协议下的 completion_ready 为前提,drift 时拒绝——换了 runtime 就不能再替旧协议下结论;
397
+ // close --abandon 不推进任务图也不做协议判定,只记录"这个 ledger 不再继续",因此是 drift 检查的唯一豁免点。
398
+ function closeLedger(options, flags) {
399
+ const dir = ledgerDir(options);
400
+ const abandon = flags.has('abandon');
401
+ const reason = options.reason;
402
+ if (abandon && (typeof reason !== 'string' || !reason.trim())) throw new LedgerError('--abandon 必须同时给出非空 --reason:终态事件要留下这个 ledger 为什么不再继续');
403
+ if (!abandon && reason !== undefined) throw new LedgerError('--reason 只属于 --abandon;正常 close 记录的是完成判定结果,不接受放弃理由');
404
+ const closed = mutate(dir, revision(options), (snapshot) => {
405
+ if (!abandon) { const gate = completionGate(dir, snapshot); if (!gate.completion_ready) throw new LedgerError(`summary.completion_ready=false,不能正常 close;未满足:${unmetCompletionConditions(gate).join(';')}。确实要放弃这个 ledger 时改用 close --abandon --reason <text>`); }
406
+ // 终态事件同时记下写入时的 runtime 内容摘要与是否由 drift 的 runtime 写入:drift 下的 abandon 必须自带这个标注。
407
+ const lifecycle = { state: abandon ? 'abandoned' : 'closed', closed_at: new Date().toISOString(), reason: abandon ? reason.trim() : null, closed_by_content_digest: skillContentDigest(), skill_drift_at_close: skillDrift(snapshot) };
408
+ return { snapshot: { ...snapshot, lifecycle }, kind: lifecycle.state };
409
+ }, { allowSkillDrift: abandon });
410
+ // 终态事件写成之后才删指针:终态是真源,指针只是定位信息。删失败只报 warning——
411
+ // 残留指针会在下一次 doctor --repository 里被识别为「已终态」并显式回收,不会让 close 失败。
412
+ let pointer;
413
+ let failure = null;
414
+ try {
415
+ pointer = removeLedgerPointer({ repository: readJson(join(dir, 'contract.json')).environment?.repository, ledgerId: closed.ledger_id });
416
+ } catch (error) {
417
+ failure = `删除仓级指针失败:${error instanceof Error ? error.message : String(error)}`;
418
+ pointer = { removed: false, path: null, git_common_dir: null, reason: failure };
419
+ }
420
+ return { ...closed, pointer, ...(failure ? { warnings: [failure] } : {}) };
421
+ }
315
422
  function rebuild(options) { const dir = ledgerDir(options); return withLock(dir, () => { const loaded = load(dir); const expected = revision(options); if (expected !== null && loaded.snapshot.revision !== expected) throw new LedgerError(`revision conflict: expected ${expected}, actual ${loaded.snapshot.revision}`); if (loaded.journal.trailing) writeFileSync(join(dir, 'events.ndjson'), loaded.journal.complete, { mode: 0o600 }); atomicJson(join(dir, 'snapshot.json'), loaded.snapshot); return { rebuilt: true, revision: loaded.snapshot.revision }; }); }
316
- function doctor(options) { const dir = ledgerDir(options); const loaded = load(dir); const snapshot = loaded.snapshot; const contract = validateContract(readJson(join(dir, 'contract.json'))); const findings = []; if (cycle(snapshot.nodes, snapshot.edges)) findings.push('graph_cycle'); for (const node of Object.values(snapshot.nodes)) { if (node.state === 'running' && !node.dispatch) findings.push(`missing_dispatch:${node.node_id}`); if (node.dispatch) { try { validateDispatch(node.dispatch); } catch { findings.push(`dispatch_invalid:${node.node_id}`); } } try { validateNodeVerification(node, contract); } catch { findings.push(`verification_policy_invalid:${node.node_id}`); } if (node.state === 'passed') { try { const assurance = assuranceForNode(dir, snapshot, node, node.verification_ref); if (assurance !== node.verification_assurance) findings.push(`verification_assurance_mismatch:${node.node_id}`); } catch { findings.push(`verification_gate_invalid:${node.node_id}`); } } if (node.tokens !== null && node.tokens !== undefined) { try { validateTokens(node.tokens); } catch { findings.push(`tokens_invalid:${node.node_id}`); } } if (node.duration_ms !== null && node.duration_ms !== undefined) { try { validateDuration(node.duration_ms); } catch { findings.push(`duration_invalid:${node.node_id}`); } } } for (const item of snapshot.attachments) { const path = join(dir, item.ref); if (!existsSync(path) || sha256(Buffer.from(canonicalJson(readJson(path)), 'utf8')) !== item.digest) findings.push(`attachment_invalid:${item.attachment_id}`); } for (const ref of snapshot.reflection_refs) { const value = readJson(join(dir, ref.ref)); if (envelopeDigest(value, 'reflection_digest') !== value.reflection_digest || value.reflection_digest !== ref.reflection_digest) findings.push(`reflection_invalid:${ref.reflection_id}`); for (const evidence of value.evidence_refs ?? []) { const match = /^events\.ndjson#revision=(\d+)$/u.exec(evidence.id ?? ''); const event = match ? loaded.journal.events.find((item) => item.revision === Number(match[1])) : null; if (!event || sha256(Buffer.from(canonicalJson(event), 'utf8')) !== evidence.digest) findings.push(`reflection_evidence_invalid:${ref.reflection_id}`); } } for (const ref of snapshot.improvement_proposal_refs) { const value = readJson(join(dir, ref.ref)); if (value.lifecycle !== 'proposed' || envelopeDigest(value, 'proposal_digest') !== value.proposal_digest || value.proposal_digest !== ref.proposal_digest) findings.push(`proposal_invalid:${ref.proposal_id}`); } const currentDigest = skillContentDigest(); if (currentDigest !== snapshot.skill_provenance.content_digest) findings.push('skill_drift'); return { healthy: !loaded.repair && !existsSync(join(dir, '.lock')) && !findings.length, recovery_needed: loaded.repair, findings, frozen_content_digest: snapshot.skill_provenance.content_digest, current_content_digest: currentDigest }; }
317
- function capabilities() { return { skill: 'orchestrate-subagents', protocol_version: ORCHESTRATION_PROTOCOL_VERSION, runtime_version: ORCHESTRATION_RUNTIME_VERSION, contracts: { task_contract: [1], orchestration_ledger: [1], dispatch_record: [2], controller_recheck_record: [1], reflection_record: [1], improvement_proposal: [1], effective_worker_capability: [1], worker_capability_requirements: [1], review_policy: [1] }, features: ['task-graph', 'barriers', 'revision-lock', 'journal-rebuild', 'stable-attachments', 'verification-obligations', 'evidence-binding-gate', 'contract-projection', 'verification-assurance-audit', 'read-only-node-not-applicable-verification', 'dispatch-audit', 'local-tier-routing', 'evidence-bound-dynamic-reroute', 'worker-capability-preflight', 'lightweight-reflection', 'batch-fuse', 'incident-reflection', 'proposed-only-improvement', 'token-accounting', 'review-budget-gate'], content_digest: skillContentDigest() }; }
423
+ // 仓级指针的分类真源:doctor --repository reclaim-pointers 共用这一个函数,
424
+ // 「报告」与「回收」不可能各自漂移。判定一律回读 state root 的事件链,指针内容只用于定位与交叉核对。
425
+ function classifyPointer(entry) {
426
+ const base = { path: entry.path, ledger_id: entry.ledger_id };
427
+ if (entry.error) return { ...base, state_root: null, ledger_dir: null, state: 'malformed', reclaimable: true, detail: `指针文件无法解析:${entry.error}` };
428
+ const pointer = entry.pointer;
429
+ const dir = ledgerDirectory(pointer);
430
+ const common = { ...base, ledger_id: pointer.ledger_id, state_root: pointer.state_root, ledger_dir: dir };
431
+ if (!existsSync(join(dir, 'events.ndjson'))) {
432
+ return { ...common, state: 'dangling_state_root', reclaimable: true, detail: `state_root ${pointer.state_root} 下找不到 ledger 事件链 ${join(dir, 'events.ndjson')}` };
433
+ }
434
+ let snapshot;
435
+ try { snapshot = load(realpathSync(dir)).snapshot; }
436
+ catch (error) { return { ...common, state: 'unreadable', reclaimable: true, detail: `ledger 目录 ${dir} 无法读取:${error instanceof Error ? error.message : String(error)}` }; }
437
+ // contract_digest 只做交叉核对展示,不参与任何判定:指针的新鲜度不能决定 ledger 的状态。
438
+ const digestMatches = snapshot.contract_digest === pointer.contract_digest;
439
+ const drift = skillDrift(snapshot);
440
+ if (snapshot.lifecycle) {
441
+ return { ...common, state: 'terminal', reclaimable: true, skill_drift: drift, contract_digest_matches: digestMatches, lifecycle_state: snapshot.lifecycle.state, detail: `ledger 已于 ${snapshot.lifecycle.closed_at} 进入终态 ${snapshot.lifecycle.state}` };
442
+ }
443
+ if (drift) {
444
+ // drift 但未终态:指针必须保留,它还需要有人来 close --abandon 或 re-contract。
445
+ return { ...common, state: 'skill_drift', reclaimable: false, skill_drift: true, contract_digest_matches: digestMatches, lifecycle_state: null, detail: SKILL_DRIFT_REMEDIATION };
446
+ }
447
+ return { ...common, state: 'active', reclaimable: false, skill_drift: false, contract_digest_matches: digestMatches, lifecycle_state: null, detail: null };
448
+ }
449
+
450
+ function scanPointers(options) {
451
+ const repository = req(options, 'repository');
452
+ const found = resolveGitCommonDir(repository);
453
+ if (!found.common_dir) throw new LedgerError(`--repository ${repository} 无法解析 git common dir:${found.reason};要求传一个 git 仓库(主 checkout 或 linked worktree 均可)的路径`);
454
+ const pointers = listLedgerPointers(found.common_dir).map(classifyPointer);
455
+ return { repository: resolve(repository), git_common_dir: found.common_dir, pointer_dir: pointerDirectory(found.common_dir), pointers };
456
+ }
457
+
458
+ // 只读:只报告,不删任何东西。回收必须显式走 reclaim-pointers——把删除藏进只读命令里,
459
+ // 等于让一次例行体检悄悄改掉仓库状态。
460
+ function pointerDoctor(options) {
461
+ const scan = scanPointers(options);
462
+ const reclaimable = scan.pointers.filter((item) => item.reclaimable);
463
+ return {
464
+ mode: 'repository',
465
+ ...scan,
466
+ healthy: reclaimable.length === 0,
467
+ reclaimable: reclaimable.map((item) => item.path),
468
+ retained_skill_drift: scan.pointers.filter((item) => item.state === 'skill_drift').map((item) => item.path),
469
+ remediation: reclaimable.length ? `显式回收:agentkit orchestrate ledger reclaim-pointers --repository ${scan.repository}` : null,
470
+ };
471
+ }
472
+
473
+ function reclaimPointers(options) {
474
+ const scan = scanPointers(options);
475
+ const reclaimed = [];
476
+ const failures = [];
477
+ for (const item of scan.pointers.filter((candidate) => candidate.reclaimable)) {
478
+ try {
479
+ const result = deleteLedgerPointerFile(item.path);
480
+ reclaimed.push({ path: item.path, ledger_id: item.ledger_id, state: item.state, removed: result.removed, detail: item.detail });
481
+ } catch (error) {
482
+ failures.push({ path: item.path, ledger_id: item.ledger_id, reason: error instanceof Error ? error.message : String(error) });
483
+ }
484
+ }
485
+ return {
486
+ mode: 'repository',
487
+ ...scan,
488
+ reclaimed,
489
+ failures,
490
+ retained: scan.pointers.filter((item) => !item.reclaimable).map((item) => ({ path: item.path, ledger_id: item.ledger_id, state: item.state })),
491
+ };
492
+ }
493
+
494
+ // `doctor` 子命令的档位路由。两个档位各自是独立函数:doctor() 仍然只诊断单个 ledger,
495
+ // pointerDoctor() 只扫仓级指针,没有一个函数同时管两件事。
496
+ function doctorCommand(options) {
497
+ if (options.repository !== undefined) {
498
+ if (options.ledger !== undefined) throw new LedgerError('--ledger 与 --repository 互斥:--ledger 诊断单个 ledger 的事件链,--repository 扫描该仓的全部仓级指针;请只传其中一个');
499
+ return pointerDoctor(options);
500
+ }
501
+ if (options.ledger === undefined) throw new LedgerError('doctor 需要 --ledger <ledger 目录> 或 --repository <仓库路径>:前者诊断单个 ledger,后者扫描仓级指针');
502
+ return doctor(options);
503
+ }
504
+
505
+ function doctor(options) { const dir = ledgerDir(options); const loaded = load(dir); const snapshot = loaded.snapshot; const contract = validateContract(readJson(join(dir, 'contract.json'))); const findings = []; if (cycle(snapshot.nodes, snapshot.edges)) findings.push('graph_cycle'); for (const node of Object.values(snapshot.nodes)) { if (node.state === 'running' && !node.dispatch) findings.push(`missing_dispatch:${node.node_id}`); if (node.dispatch) { try { validateDispatch(node.dispatch); } catch { findings.push(`dispatch_invalid:${node.node_id}`); } } try { validateNodeVerification(node, contract); } catch { findings.push(`verification_policy_invalid:${node.node_id}`); } if (node.state === 'passed') { try { const assurance = assuranceForNode(dir, snapshot, node, node.verification_ref); if (assurance !== node.verification_assurance) findings.push(`verification_assurance_mismatch:${node.node_id}`); } catch { findings.push(`verification_gate_invalid:${node.node_id}`); } } if (node.tokens !== null && node.tokens !== undefined) { try { validateTokens(node.tokens); } catch { findings.push(`tokens_invalid:${node.node_id}`); } } if (node.duration_ms !== null && node.duration_ms !== undefined) { try { validateDuration(node.duration_ms); } catch { findings.push(`duration_invalid:${node.node_id}`); } } } for (const item of snapshot.attachments) { const path = join(dir, item.ref); if (!existsSync(path) || sha256(Buffer.from(canonicalJson(readJson(path)), 'utf8')) !== item.digest) findings.push(`attachment_invalid:${item.attachment_id}`); } for (const ref of snapshot.reflection_refs) { const value = readJson(join(dir, ref.ref)); if (envelopeDigest(value, 'reflection_digest') !== value.reflection_digest || value.reflection_digest !== ref.reflection_digest) findings.push(`reflection_invalid:${ref.reflection_id}`); for (const evidence of value.evidence_refs ?? []) { const match = /^events\.ndjson#revision=(\d+)$/u.exec(evidence.id ?? ''); const event = match ? loaded.journal.events.find((item) => item.revision === Number(match[1])) : null; if (!event || sha256(Buffer.from(canonicalJson(event), 'utf8')) !== evidence.digest) findings.push(`reflection_evidence_invalid:${ref.reflection_id}`); } } for (const ref of snapshot.improvement_proposal_refs) { const value = readJson(join(dir, ref.ref)); if (value.lifecycle !== 'proposed' || envelopeDigest(value, 'proposal_digest') !== value.proposal_digest || value.proposal_digest !== ref.proposal_digest) findings.push(`proposal_invalid:${ref.proposal_id}`); } const currentDigest = skillContentDigest(); if (skillDrift(snapshot, currentDigest)) findings.push('skill_drift');
506
+ // 实质性问题单独成列,不进 findings:findings 直接决定 healthy,而 doctor 是只读回看路径,
507
+ // 会读到判据出现之前冻结的契约。在这里判 unhealthy 等于让历史结论随 runtime 版本变化。
508
+ return { mode: 'ledger', healthy: !loaded.repair && !existsSync(join(dir, '.lock')) && !findings.length, recovery_needed: loaded.repair, findings, substance_warnings: substanceWarnings(contract), frozen_content_digest: snapshot.skill_provenance.content_digest, current_content_digest: currentDigest }; }
509
+ function capabilities() { return { skill: 'orchestrate-subagents', protocol_version: ORCHESTRATION_PROTOCOL_VERSION, runtime_version: ORCHESTRATION_RUNTIME_VERSION, contracts: { task_contract: [1], orchestration_ledger: [1], ledger_pointer: [1], dispatch_record: [2], controller_recheck_record: [1], reflection_record: [1], improvement_proposal: [1], effective_worker_capability: [1], worker_capability_requirements: [1], review_policy: [1] }, features: ['task-graph', 'barriers', 'revision-lock', 'journal-rebuild', 'stable-attachments', 'verification-obligations', 'evidence-binding-gate', 'contract-projection', 'verification-assurance-audit', 'read-only-node-not-applicable-verification', 'dispatch-audit', 'local-tier-routing', 'evidence-bound-dynamic-reroute', 'worker-capability-preflight', 'lightweight-reflection', 'batch-fuse', 'incident-reflection', 'proposed-only-improvement', 'token-accounting', 'review-budget-gate', 'ledger-terminal-close', 'drift-exempt-abandon', 'drift-visible-status', 'repository-ledger-pointer', 'explicit-pointer-reclaim'], content_digest: skillContentDigest() }; }
318
510
 
319
- export function main(argv = process.argv.slice(2)) { if (isHelpRequest(argv)) return { help: renderCliHelp('orchestration-ledger.mjs', CLI_SPEC, CLI_NOTES) }; const { command, options, flags } = parseCli(argv); if (command === 'capabilities') return capabilities(); if (command === 'init') return init(options, flags); if (command === 'add-node') return addNode(options); if (command === 'add-edge') return addEdge(options); if (command === 'dispatch-record') return dispatchRecord(options); if (command === 'update') return update(options); if (command === 'attach') return attach(options); if (command === 'batch-init') return batchInit(options); if (command === 'batch-record') return batchRecord(options); if (command === 'batch-status') return batchStatus(options); if (command === 'batch-fuse') return batchFuse(options); if (command === 'record-reflection') return recordReflection(options); if (command === 'propose-improvement') return propose(options); if (command === 'status') return status(options); if (command === 'inspect') return status(options); if (command === 'rebuild') return rebuild(options); if (command === 'doctor') return doctor(options); throw new LedgerError('未知 ledger 命令'); }
511
+ export function main(argv = process.argv.slice(2)) { if (isHelpRequest(argv)) return { help: renderCliHelp('orchestration-ledger.mjs', CLI_SPEC, CLI_NOTES) }; const { command, options, flags } = parseCli(argv); if (command === 'capabilities') return capabilities(); if (command === 'init') return init(options, flags); if (command === 'add-node') return addNode(options); if (command === 'add-edge') return addEdge(options); if (command === 'dispatch-record') return dispatchRecord(options); if (command === 'update') return update(options); if (command === 'attach') return attach(options); if (command === 'batch-init') return batchInit(options); if (command === 'batch-record') return batchRecord(options); if (command === 'batch-status') return batchStatus(options); if (command === 'batch-fuse') return batchFuse(options); if (command === 'record-reflection') return recordReflection(options); if (command === 'propose-improvement') return propose(options); if (command === 'close') return closeLedger(options, flags); if (command === 'status') return status(options); if (command === 'inspect') return status(options); if (command === 'rebuild') return rebuild(options); if (command === 'doctor') return doctorCommand(options); if (command === 'reclaim-pointers') return reclaimPointers(options); throw new LedgerError('未知 ledger 命令'); }
320
512
  function entry() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return pathToFileURL(resolve(process.argv[1] ?? '')).href === import.meta.url; } }
321
513
  export function runCli(argv = process.argv.slice(2)) {
322
514
  try {
@@ -1,4 +1,20 @@
1
1
  // @ts-check
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { distributionDigest, skillDistributionRoots } from '../../core/content-digest.mjs';
2
6
 
3
7
  export const ORCHESTRATION_PROTOCOL_VERSION = '1.1.0';
4
- export const ORCHESTRATION_RUNTIME_VERSION = '1.7.0';
8
+ export const ORCHESTRATION_RUNTIME_VERSION = '1.8.0';
9
+
10
+ const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'orchestrate-subagents');
11
+ // 摘要覆盖 Skill 目录 + 共享 core + canonical schemas:执行真正依赖的全部分发内容。
12
+ // PACKAGE_ROOT 是模块常量,传入自定义 root 只替换 Skill 目录那一段,便于测试摘要与安装路径无关。
13
+ const PACKAGE_ROOT = resolve(SKILL_ROOT, '..');
14
+ const DOMAIN_ROOT = dirname(fileURLToPath(import.meta.url));
15
+
16
+ // 台账与 contract-tool 都要冻结同一个域摘要,而台账已经 import contract-tool(反向会成环),
17
+ // 所以摘要住在这个无依赖的元数据模块里,两边各自 import。
18
+ export function skillContentDigest(root = SKILL_ROOT) {
19
+ return distributionDigest(skillDistributionRoots({ packageRoot: PACKAGE_ROOT, skillRoot: root, domainRoot: DOMAIN_ROOT, docsRoot: join(PACKAGE_ROOT, 'docs', 'orchestrate') }));
20
+ }
@@ -32,8 +32,13 @@ import { collectJsonSchemaErrors, validateJsonSchema } from '../../core/json-sch
32
32
  import { atomicWriteJson, atomicWriteText, writeNewJson } from '../../core/atomic-fs.mjs';
33
33
  import { createDigestKit } from '../../core/digest.mjs';
34
34
  import { distributionDigest, skillDistributionRoots } from '../../core/content-digest.mjs';
35
+ import {
36
+ SCAFFOLD_ARGV, SCAFFOLD_CHECK_ID,
37
+ contractSubstance, coverageSubstance, profileSubstance, substanceWarnings,
38
+ } from '../../core/contract-substance.mjs';
39
+ import { buildScaffoldContract } from '../../core/contract-scaffold.mjs';
35
40
 
36
- export const RUNTIME_VERSION = '1.3.0';
41
+ export const RUNTIME_VERSION = '1.4.0';
37
42
  export const PROTOCOL_VERSION = 1;
38
43
  const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
39
44
  const FINDING_CLASSES = new Set(['functional', 'scope', 'verification_definition', 'safety']);
@@ -840,27 +845,25 @@ function addDigest(value, field) {
840
845
  return output;
841
846
  }
842
847
 
843
- /** @param {string} workdir */
848
+ /**
849
+ * 契约骨架住在 core/contract-scaffold.mjs:orchestrate 域的 `contract scaffold` 别名用的是同一份,
850
+ * 两边只在 skill_set 上分叉(各自冻结自己域的 content digest)。骨架抄两份就会各自漂移,
851
+ * 而 interview 出的题、core/contract-substance.mjs 的占位判据都以这份骨架为前提。
852
+ * @param {string} workdir
853
+ */
844
854
  function scaffoldContract(workdir) {
845
- return addDigest({
846
- schema_version: 1,
847
- contract_id: randomUUID(),
848
- objective: 'TODO: describe the frozen artifact objective',
849
- scope: { include: ['TODO'], exclude: [] },
850
- acceptance: [{ contract_item_id: 'acceptance-1', requirement: 'TODO: replace with an observable requirement' }],
851
- permissions: { mode: 'read_only', writable_paths: [] },
852
- environment: { repository: resolve(workdir), isolation: 'caller_supplied' },
853
- skill_set: [{ name: 'verify-agent-output', version: RUNTIME_VERSION, content_digest: skillContentDigest(), provider_mode: 'primary' }],
854
- stop_conditions: [],
855
- extensions: {},
856
- }, 'contract_digest');
855
+ return addDigest(buildScaffoldContract({
856
+ workdir,
857
+ contractId: randomUUID(),
858
+ skillSet: [{ name: 'verify-agent-output', version: RUNTIME_VERSION, content_digest: skillContentDigest(), provider_mode: 'primary' }],
859
+ }), 'contract_digest');
857
860
  }
858
861
 
859
862
  function scaffoldProfile() {
860
863
  return addDigest({
861
864
  schema_version: 1,
862
865
  profile_id: randomUUID(),
863
- l0_checks: [{ check_id: 'replace-with-real-check', argv: ['node', '--version'], cwd_rel: '.', stage: 'both', timeout_ms: 30_000, expected_exit_codes: [0] }],
866
+ l0_checks: [{ check_id: SCAFFOLD_CHECK_ID, argv: [...SCAFFOLD_ARGV], cwd_rel: '.', stage: 'both', timeout_ms: 30_000, expected_exit_codes: [0] }],
864
867
  l1_review: [{ contract_item_id: 'acceptance-1', lenses: ['functional', 'scope', 'verification_definition', 'safety'] }],
865
868
  protected_verifier_paths: [],
866
869
  allowed_validation_changes: [],
@@ -974,9 +977,18 @@ function digestEnvelope(options) {
974
977
  /** @param {Record<string,any>|null} contract @param {Record<string,any>|null} profile @param {Record<string,any>|null} artifact @param {Set<string>} flags */
975
978
  function inspectValues(contract, profile, artifact, flags) {
976
979
  const issues = [];
977
- if (contract) issues.push(...collectContractIssues(contract));
980
+ const warnings = [];
981
+ // inspectValues 只服务创建入口(preflight、init、prepare-run),所以实质性检查放在这里;
982
+ // record-review、validate 等续跑入口直接调 validateContract,只做形状校验。
983
+ if (contract) {
984
+ const substance = contractSubstance(contract);
985
+ issues.push(...collectContractIssues(contract), ...substance.errors);
986
+ warnings.push(...substance.warnings);
987
+ }
978
988
  const acceptanceIds = new Set(Array.isArray(contract?.acceptance) ? contract.acceptance.map((item) => item?.contract_item_id).filter(Boolean) : []);
979
- if (profile) issues.push(...collectProfileIssues(profile, acceptanceIds));
989
+ if (profile) issues.push(...collectProfileIssues(profile, acceptanceIds), ...profileSubstance(profile).errors);
990
+ // 覆盖判据要同时拿到两份文件才成立,所以只有这条路径能执行它。
991
+ if (contract && profile) issues.push(...coverageSubstance(contract, profile).errors);
980
992
  if (artifact) issues.push(...collectArtifactIssues(artifact));
981
993
  if (contract) {
982
994
  try { validateSkillBinding(contract, skillContentDigest()); }
@@ -984,8 +996,10 @@ function inspectValues(contract, profile, artifact, flags) {
984
996
  }
985
997
  if (profile?.runtime?.network_policy === 'denied' && !flags.has('network-isolated')) issues.push('network_policy=denied 时必须由宿主提供 --network-isolated assurance');
986
998
  return {
999
+ // warning 只描述"没写",不足以拒绝创建,所以不参与 valid,也不参与退出码。
987
1000
  valid: issues.length === 0,
988
1001
  errors: [...new Set(issues)],
1002
+ warnings: [...new Set(warnings)],
989
1003
  content_digest: skillContentDigest(),
990
1004
  contract_digest: contract?.contract_digest ?? null,
991
1005
  verification_profile_digest: profile?.verification_profile_digest ?? null,
@@ -1249,7 +1263,7 @@ function initialize(options, flags) {
1249
1263
  initialEvent.event_digest = envelopeDigest(initialEvent, 'event_digest');
1250
1264
  writeFileSync(join(runDir, 'events.ndjson'), `${canonicalJson(initialEvent)}\n`, { flag: 'wx', mode: 0o600 });
1251
1265
  atomicWriteJson(join(runDir, 'snapshot.json'), snapshot);
1252
- return { run_id: runId, run_dir: runDir, revision: 0, status: snapshot.status, review_challenge_nonce: snapshot.review_challenge_nonce };
1266
+ return { run_id: runId, run_dir: runDir, revision: 0, status: snapshot.status, review_challenge_nonce: snapshot.review_challenge_nonce, ...(checked.report.warnings.length ? { warnings: checked.report.warnings } : {}) };
1253
1267
  }
1254
1268
 
1255
1269
  /** @param {Record<string,string>} options */
@@ -1548,6 +1562,9 @@ function doctor(options) {
1548
1562
  snapshot_matches_journal: !loaded.needsRepair,
1549
1563
  lock_present: existsSync(join(runDir, '.lock')),
1550
1564
  skill_drift: currentDigest !== loaded.snapshot.skill_provenance.content_digest,
1565
+ // 冻结的两份文件就在 run 目录里,实质性判据都能重跑;但 doctor 是只读回看路径,只报不判,
1566
+ // 否则判据出现之前冻结的历史 run 的审计结论会随 runtime 版本变化。
1567
+ substance_warnings: substanceWarnings(readJson(join(runDir, 'contract.json')), readJson(join(runDir, 'profile.json'))),
1551
1568
  // 旧 run 即使已漂移也要能只读检查:同时给出冻结时的摘要与当前摘要,便于判断漂移了什么。
1552
1569
  frozen_content_digest: loaded.snapshot.skill_provenance.content_digest,
1553
1570
  current_content_digest: currentDigest,
@@ -1599,6 +1616,8 @@ function compactRunResult(result) {
1599
1616
  evidence_digest: result?.terminal?.evidence_digest ?? result?.evidence_digest ?? null,
1600
1617
  ...(result?.run_dir ? { run_dir: result.run_dir } : {}),
1601
1618
  ...(result?.prepared !== undefined ? { prepared: result.prepared } : {}),
1619
+ // prepare-run 是 happy path 的入口。不带 preflight 原因的话,调用方只看得到 invalid_input,不知道该改哪个字段。
1620
+ ...(result?.prepared === false && result?.preflight?.errors?.length ? { errors: result.preflight.errors } : {}),
1602
1621
  ...(result?.terminal && result.terminal.outcome !== 'pass' ? {
1603
1622
  next_mode_hint: '本次单 Artifact 验收已终止并保留 Evidence;若已授权修复且预期多轮,请用 run-agent-verify-loop 创建新 Artifact/run。',
1604
1623
  } : {}),
@@ -67,8 +67,25 @@ export function createCommands(deps) {
67
67
  localBranchExists,
68
68
  autoArmReviewWatch,
69
69
  isSettledWorktreeState,
70
+ LEDGER_ID_PATTERN,
71
+ isLedgerId,
70
72
  } = deps;
71
73
 
74
+ /**
75
+ * worktree 级指针:把这棵树绑到某个 orchestration ledger 上,供 `agentkit status` 收窄范围。
76
+ * 这里只校验 id 的格式(规则真源在 core/ledger-pointer.mjs),不去解析 ledger 状态——
77
+ * 那属于 orchestrate 域,worktree 域不跨域 import,也不为一个可能还没建成的 ledger 背书。
78
+ * @param {Map<string, string>} flags
79
+ */
80
+ function resolveLedgerBinding(flags) {
81
+ const raw = flag(flags, 'ledger');
82
+ if (raw === null || raw === undefined) return null;
83
+ if (!isLedgerId(raw)) {
84
+ die(`--ledger 无效:当前值 ${JSON.stringify(raw)};要求非空且只含字母、数字、点、下划线与连字符(${LEDGER_ID_PATTERN.source}),与 orchestrate ledger 的 --ledger-id 同一套格式。`, 2);
85
+ }
86
+ return raw;
87
+ }
88
+
72
89
  function cmdSupersede(args) {
73
90
  rejectUnknownFlags(args.flags, ['by', 'reason', 'id', 'by-id', 'config']);
74
91
  const bySelector = flag(args.flags, 'by');
@@ -134,11 +151,12 @@ export function createCommands(deps) {
134
151
  function prepareSpawnRequest(args) {
135
152
  rejectUnknownFlags(args.flags, [
136
153
  'agent', 'agent-id', 'purpose', 'owner', 'base', 'base-reason', 'config', 'codegraph', 'root',
137
- 'parallel-reason', 'supersedes', 'replacement-reason',
154
+ 'parallel-reason', 'supersedes', 'replacement-reason', 'ledger',
138
155
  ]);
139
156
  const task = args.positionals[0];
140
157
  if (!task) die('spawn 需要 <task>。', 2);
141
158
  validateTaskSlug(task);
159
+ const ledger = resolveLedgerBinding(args.flags);
142
160
  const identity = resolveIdentity(args.flags, { requirePurpose: true });
143
161
  const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
144
162
  requireFreshPrimaryProfile(loaded);
@@ -175,6 +193,7 @@ export function createCommands(deps) {
175
193
  loaded,
176
194
  codegraphMode,
177
195
  existingRecords,
196
+ ledger,
178
197
  deliveryRelation: resolveDeliveryRelation(args.flags, existingRecords, coexisting),
179
198
  };
180
199
  }
@@ -359,6 +378,8 @@ export function createCommands(deps) {
359
378
  last_head: git(['rev-parse', 'HEAD'], plan.path),
360
379
  ownership_epochs: [{ agent: request.identity.actor, started_at: now, start_sha: baseSha.out, end_sha: null, ended_at: null }],
361
380
  delivery_relation: request.deliveryRelation,
381
+ // worktree 级 ledger 指针。缺省 null,老 record 没有这个字段同样按 null 处理。
382
+ ledger: request.ledger,
362
383
  };
363
384
  appendTraceEvent({
364
385
  commonDir: plan.context.common_dir,
@@ -371,6 +392,7 @@ export function createCommands(deps) {
371
392
  base_reason: baseReason,
372
393
  stack_parent_worktree_id: record.stack_parent?.worktree_id ?? null,
373
394
  delivery_relation: request.deliveryRelation,
395
+ ledger: request.ledger,
374
396
  },
375
397
  mutate: () => record,
376
398
  });
@@ -8,6 +8,9 @@ import { homedir, tmpdir } from 'node:os';
8
8
  import { basename, dirname, join, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
+ // ledger id 的格式规则下沉在 core/:worktree 域只做格式校验,不 import orchestrate 域。
12
+ import { LEDGER_ID_PATTERN, isLedgerId } from '../../core/ledger-pointer.mjs';
13
+
11
14
  import * as core from './worktree-core.mjs';
12
15
  import * as mergePreview from './worktree-merge-preview.mjs';
13
16
  import * as profile from './worktree-profile.mjs';
@@ -82,6 +85,8 @@ const dependencies = {
82
85
  processPlatform: process.platform,
83
86
  processExecPath: process.execPath,
84
87
  processGetuid: () => typeof process.getuid === 'function' ? process.getuid() : 0,
88
+ LEDGER_ID_PATTERN,
89
+ isLedgerId,
85
90
  ...mergePreview,
86
91
  ...profile,
87
92
  ...provider,
@@ -133,14 +138,15 @@ export const verifyArtifactEnvelope = artifactCommands.verifyArtifactEnvelope;
133
138
 
134
139
  function cmdCapabilities(args) {
135
140
  rejectUnknownFlags(args.flags, ['json']);
136
- console.log(JSON.stringify({ skill: 'manage-worktrees', runtime_version: '1.4.0', contracts: { worktree_binding: [1], artifact_ref: [1], reflection_record: [1], improvement_proposal: [1], batch_result: [1] }, features: ['git-common-dir-ledger', 'ownership-epochs', 'artifact-verification', 'incident-reflection', 'proposed-only-improvement', 'batch-integrate', 'batch-conflict-scan', 'declared-post-integrate-steps', 'batch-result', 'evidence-archive-reclaim', 'durable-pushed-ref-proof', 'auto-armed-review-watch', 'persistent-review-watch-intent', 'launchd-watch-service', 'review-target-advance-prediction', 'explicit-review-refresh', 'managed-history-rewrite', 'stack-parent-attribution', 'structured-change-registration'], content_digest: worktreeSkillDigest() }, null, 2));
141
+ console.log(JSON.stringify({ skill: 'manage-worktrees', runtime_version: '1.5.0', contracts: { worktree_binding: [1], artifact_ref: [1], reflection_record: [1], improvement_proposal: [1], batch_result: [1] }, features: ['git-common-dir-ledger', 'ownership-epochs', 'artifact-verification', 'incident-reflection', 'proposed-only-improvement', 'batch-integrate', 'batch-conflict-scan', 'declared-post-integrate-steps', 'batch-result', 'evidence-archive-reclaim', 'durable-pushed-ref-proof', 'auto-armed-review-watch', 'persistent-review-watch-intent', 'launchd-watch-service', 'review-target-advance-prediction', 'explicit-review-refresh', 'managed-history-rewrite', 'stack-parent-attribution', 'structured-change-registration'], content_digest: worktreeSkillDigest() }, null, 2));
137
142
  }
138
143
 
139
144
  function usage() {
140
145
  console.log(`${PREFIX} portable multi-Agent worktree manager
141
146
 
142
- spawn <task> --agent <host> --agent-id <id> --purpose <text> [--owner <name>] [--base <ref> --base-reason <text>] [--root <path>] [--codegraph auto|on|off]
147
+ spawn <task> --agent <host> --agent-id <id> --purpose <text> [--owner <name>] [--base <ref> --base-reason <text>] [--root <path>] [--codegraph auto|on|off] [--ledger <id>]
143
148
  同会话已有未回收树时默认拒绝;独立并行加 --parallel-reason <text>;替代加 --supersedes <selector> --replacement-reason <text>
149
+ --ledger <id>:把这棵树绑到某个 orchestration ledger,写进 record,供 agentkit status 收窄范围;只校验 id 格式
144
150
  adopt <path> --agent <host> --agent-id <id> --purpose <text> [--task <slug>] [--base <ref> --base-reason <text>]
145
151
  list [--json] [--all] [--present] [--archived]
146
152
  --present:只列目录仍然存在的 record(TRACKED/UNTRACKED/MAIN 分类不变),隐藏全部历史记录