@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.
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+ // 仓级 ledger 指针:只回答「这个仓上的 ledger 放在哪」。
3
+ //
4
+ // state root 按设计落在业务仓库之外,因此一个新会话在主 checkout 里看不到任何线索,
5
+ // 只能靠人手传 `--ledger`。指针把「ledger_id → state_root」这一条映射写到
6
+ // `<git-common-dir>/agentkit/ledgers/<ledger_id>.json`,让 `agentkit status` 能把它找回来。
7
+ //
8
+ // **指针不是真源。** 它只存定位信息与两个用于展示/交叉核对的字段;ledger 的一切状态仍以
9
+ // state root 里的事件链为准。指针全部删掉不丢任何状态,代价只是重新手传 `--ledger`。
10
+ // 因此这里不提供任何「按指针内容下判断」的入口:调用方拿到 state_root 之后必须回读 state root。
11
+ //
12
+ // 目录单独设立(`agentkit/ledgers/`),不与 worktree 域的 `worktree-trace/v1/` 共享归属:
13
+ // 两者记录的是两类不同对象,合并目录会让它们变成同一个注册表。
14
+ //
15
+ // 本模块放在 core/ 而不是 orchestrate 域内,是因为 worktree 域要复用 ledger id 的格式规则、
16
+ // bin/ 的顶层 status 要读指针目录,而域与域之间禁止互相 import。
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
20
+ import { basename, isAbsolute, join, resolve } from 'node:path';
21
+
22
+ import { atomicWriteJson } from './atomic-fs.mjs';
23
+
24
+ export const LEDGER_POINTER_SCHEMA_VERSION = 1;
25
+ // ledger id 的格式真源。orchestrate 域的 `--ledger-id` 与 worktree 域的 `spawn --ledger` 共用这一条,
26
+ // 两边不可能各自漂移;worktree 域只做格式校验,不 import orchestrate。
27
+ export const LEDGER_ID_PATTERN = /^[A-Za-z0-9._-]+$/u;
28
+ export const LEDGER_POINTER_FIELDS = ['schema_version', 'ledger_id', 'state_root', 'contract_digest', 'created_at'];
29
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
30
+ // 指针目录相对 git common dir 的位置。红线:只放在 .git/ 下,永远不进版本控制。
31
+ const POINTER_SEGMENTS = ['agentkit', 'ledgers'];
32
+
33
+ export class LedgerPointerError extends Error {}
34
+
35
+ /** @param {unknown} value */
36
+ export function isLedgerId(value) {
37
+ return typeof value === 'string' && value.length > 0 && LEDGER_ID_PATTERN.test(value);
38
+ }
39
+
40
+ /** @param {unknown} value @param {string} label */
41
+ export function assertLedgerId(value, label = 'ledger id') {
42
+ if (!isLedgerId(value)) {
43
+ throw new LedgerPointerError(`${label} 无效:当前值 ${JSON.stringify(value ?? null)};要求非空字符串且只含字母、数字、点、下划线与连字符(${LEDGER_ID_PATTERN.source})`);
44
+ }
45
+ return String(value);
46
+ }
47
+
48
+ /**
49
+ * 解析一个路径所属仓库的 git common dir。linked worktree 下它指向主仓 `.git`,
50
+ * 指针因此永远只有一份,不随 worktree 分裂。
51
+ * @param {string} [from]
52
+ * @returns {{ common_dir: string|null, reason: string|null }}
53
+ */
54
+ export function resolveGitCommonDir(from) {
55
+ const start = resolve(from ?? process.cwd());
56
+ if (!existsSync(start)) return { common_dir: null, reason: `路径不存在:${start}` };
57
+ try {
58
+ if (!statSync(start).isDirectory()) return { common_dir: null, reason: `路径不是目录:${start}` };
59
+ } catch (error) {
60
+ return { common_dir: null, reason: `无法读取路径 ${start}:${error instanceof Error ? error.message : String(error)}` };
61
+ }
62
+ const result = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: start, encoding: 'utf8' });
63
+ if (result.error) return { common_dir: null, reason: `在 ${start} 执行 git 失败:${result.error.message}` };
64
+ if (result.status !== 0) {
65
+ return { common_dir: null, reason: `${start} 不在 git 仓库内:git rev-parse --git-common-dir 退出码 ${result.status}` };
66
+ }
67
+ const output = String(result.stdout ?? '').trim();
68
+ if (!output) return { common_dir: null, reason: `git rev-parse --git-common-dir 在 ${start} 返回空值` };
69
+ return { common_dir: resolve(start, output), reason: null };
70
+ }
71
+
72
+ /** @param {string} commonDir */
73
+ export function pointerDirectory(commonDir) {
74
+ return join(commonDir, ...POINTER_SEGMENTS);
75
+ }
76
+
77
+ /** @param {string} commonDir @param {string} ledgerId */
78
+ export function pointerPath(commonDir, ledgerId) {
79
+ return join(pointerDirectory(commonDir), `${assertLedgerId(ledgerId)}.json`);
80
+ }
81
+
82
+ /**
83
+ * 指针只存 state root;ledger 目录由 state root 与 ledger id 推导,与 `ledger init` 的回显一致。
84
+ * @param {{ state_root: string, ledger_id: string }} pointer
85
+ */
86
+ export function ledgerDirectory(pointer) {
87
+ return join(pointer.state_root, 'ledgers', pointer.ledger_id);
88
+ }
89
+
90
+ /** @param {unknown} value @param {string} label */
91
+ export function validateLedgerPointer(value, label = 'ledger pointer') {
92
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
93
+ throw new LedgerPointerError(`${label} 必须是 JSON 对象,当前是 ${Array.isArray(value) ? 'array' : typeof value}`);
94
+ }
95
+ const keys = Object.keys(value);
96
+ const unknown = keys.filter((key) => !LEDGER_POINTER_FIELDS.includes(key));
97
+ if (unknown.length) {
98
+ throw new LedgerPointerError(`${label} 含未知字段:${unknown.join('、')};字段集合固定为 ${LEDGER_POINTER_FIELDS.join('、')}`);
99
+ }
100
+ const missing = LEDGER_POINTER_FIELDS.filter((key) => !keys.includes(key));
101
+ if (missing.length) throw new LedgerPointerError(`${label} 缺少字段:${missing.join('、')}`);
102
+ const pointer = /** @type {Record<string, unknown>} */ (value);
103
+ if (pointer.schema_version !== LEDGER_POINTER_SCHEMA_VERSION) {
104
+ throw new LedgerPointerError(`${label}.schema_version 当前值 ${JSON.stringify(pointer.schema_version)},要求 ${LEDGER_POINTER_SCHEMA_VERSION}`);
105
+ }
106
+ assertLedgerId(pointer.ledger_id, `${label}.ledger_id`);
107
+ if (typeof pointer.state_root !== 'string' || !pointer.state_root || !isAbsolute(pointer.state_root)) {
108
+ throw new LedgerPointerError(`${label}.state_root 当前值 ${JSON.stringify(pointer.state_root ?? null)};要求非空绝对路径`);
109
+ }
110
+ if (typeof pointer.contract_digest !== 'string' || !DIGEST_PATTERN.test(pointer.contract_digest)) {
111
+ throw new LedgerPointerError(`${label}.contract_digest 当前值 ${JSON.stringify(pointer.contract_digest ?? null)};要求形如 sha256:<64 位十六进制>`);
112
+ }
113
+ if (typeof pointer.created_at !== 'string' || Number.isNaN(Date.parse(pointer.created_at))) {
114
+ throw new LedgerPointerError(`${label}.created_at 当前值 ${JSON.stringify(pointer.created_at ?? null)};要求可解析的 ISO 8601 时间戳`);
115
+ }
116
+ return /** @type {{ schema_version: number, ledger_id: string, state_root: string, contract_digest: string, created_at: string }} */ (value);
117
+ }
118
+
119
+ /**
120
+ * @param {{ commonDir: string, ledgerId: string, stateRoot: string, contractDigest: string, createdAt?: string }} options
121
+ */
122
+ export function writeLedgerPointer({ commonDir, ledgerId, stateRoot, contractDigest, createdAt }) {
123
+ const pointer = validateLedgerPointer({
124
+ schema_version: LEDGER_POINTER_SCHEMA_VERSION,
125
+ ledger_id: assertLedgerId(ledgerId),
126
+ state_root: resolve(stateRoot),
127
+ contract_digest: contractDigest,
128
+ created_at: createdAt ?? new Date().toISOString(),
129
+ });
130
+ const directory = pointerDirectory(commonDir);
131
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
132
+ const path = join(directory, `${pointer.ledger_id}.json`);
133
+ // 指针按 ledger_id 索引,同一个仓上同名 ledger 只能有一份指针。覆盖是允许的(换 state root
134
+ // 重建同名 ledger 是正常操作),但被顶掉的那一份要交回给调用方说明,否则旧 ledger 会静默失联。
135
+ let replaced = null;
136
+ try {
137
+ const previous = validateLedgerPointer(JSON.parse(readFileSync(path, 'utf8')), `既有指针 ${path}`);
138
+ if (previous.state_root !== pointer.state_root) replaced = previous.state_root;
139
+ } catch { /* 不存在或已损坏:直接覆盖,损坏的那份本来就会被 doctor 标为 malformed。 */ }
140
+ atomicWriteJson(path, pointer);
141
+ return { path, pointer, replaced };
142
+ }
143
+
144
+ /** @param {string} path */
145
+ export function deleteLedgerPointerFile(path) {
146
+ try {
147
+ unlinkSync(path);
148
+ return { removed: true, path, reason: null };
149
+ } catch (error) {
150
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
151
+ return { removed: false, path, reason: `指针文件不存在:${path}` };
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 读取一个仓库下的全部指针。损坏的文件不抛异常,作为条目返回给 doctor 报告。
159
+ * @param {string} commonDir
160
+ */
161
+ export function listLedgerPointers(commonDir) {
162
+ const directory = pointerDirectory(commonDir);
163
+ if (!existsSync(directory)) return [];
164
+ return readdirSync(directory)
165
+ .filter((name) => name.endsWith('.json'))
166
+ .sort()
167
+ .map((name) => {
168
+ const path = join(directory, name);
169
+ const ledgerId = basename(name, '.json');
170
+ try {
171
+ return { path, ledger_id: ledgerId, pointer: validateLedgerPointer(JSON.parse(readFileSync(path, 'utf8')), `指针 ${path}`), error: null };
172
+ } catch (error) {
173
+ return { path, ledger_id: ledgerId, pointer: null, error: error instanceof Error ? error.message : String(error) };
174
+ }
175
+ });
176
+ }
177
+
178
+ /**
179
+ * `contract.environment.repository` 不是 git 仓时不写指针,并把原因原样交回给调用方回显。
180
+ * 指针不是真源,写失败不能让 ledger 建立失败,因此这里只在解析阶段返回原因,
181
+ * 真正的写入异常由调用方按 warning 处理。
182
+ * @param {{ repository: unknown, ledgerId: string, stateRoot: string, contractDigest: string, createdAt?: string }} options
183
+ */
184
+ export function recordLedgerPointer({ repository, ledgerId, stateRoot, contractDigest, createdAt }) {
185
+ const skip = (reason) => ({ written: false, path: null, git_common_dir: null, reason });
186
+ if (typeof repository !== 'string' || !repository || repository === 'none') {
187
+ return skip(`contract.environment.repository 当前值 ${JSON.stringify(repository ?? null)}:没有指向 git 仓库,跳过仓级指针;后续命令需要手传 --ledger <ledger 目录>`);
188
+ }
189
+ const found = resolveGitCommonDir(repository);
190
+ if (!found.common_dir) {
191
+ return skip(`${found.reason};跳过仓级指针,后续命令需要手传 --ledger <ledger 目录>`);
192
+ }
193
+ const { path, replaced } = writeLedgerPointer({ commonDir: found.common_dir, ledgerId, stateRoot, contractDigest, createdAt });
194
+ return { written: true, path, git_common_dir: found.common_dir, replaced, reason: null };
195
+ }
196
+
197
+ /**
198
+ * @param {{ repository: unknown, ledgerId: string }} options
199
+ */
200
+ export function removeLedgerPointer({ repository, ledgerId }) {
201
+ const skip = (reason) => ({ removed: false, path: null, git_common_dir: null, reason });
202
+ if (typeof repository !== 'string' || !repository || repository === 'none') {
203
+ return skip(`contract.environment.repository 当前值 ${JSON.stringify(repository ?? null)}:init 时就没有写仓级指针,无需删除`);
204
+ }
205
+ const found = resolveGitCommonDir(repository);
206
+ if (!found.common_dir) return skip(`${found.reason};无法定位仓级指针目录,跳过删除`);
207
+ const path = pointerPath(found.common_dir, ledgerId);
208
+ const result = deleteLedgerPointerFile(path);
209
+ return { ...result, git_common_dir: found.common_dir };
210
+ }
@@ -0,0 +1,175 @@
1
+ # 契约访谈(contract interview)
2
+
3
+ `agentkit contract interview-*` 是一台状态机:**问什么由实质性判据的拒绝清单驱动,什么时候算问完由它自己的完成判据决定。**
4
+
5
+ 两者不能合一。`core/contract-substance.mjs` 的 error 只匹配 scaffold 的字面量,任意填一轮文字就能通过;
6
+ 拿它当结束条件,访谈就退化成一张一次性表单。
7
+
8
+ | 环节 | 谁决定 |
9
+ |---|---|
10
+ | 问哪些字段 | 实质性判据的 error / warning 清单,另加一道必问题:`permissions` |
11
+ | 提问顺序 | `permissions → objective → acceptance → scope.include → scope.exclude → stop_conditions` |
12
+ | 每题的选项 | **调用本命令的模型**:根据用户诉求和仓库现状给出 2–4 个互斥选项 |
13
+ | 选哪个 | 用户 |
14
+ | 何时结束 | 三条完成判据同时满足 |
15
+
16
+ 命令本身不调用任何模型,也不对自然语言做启发式判定。它只出题、校验回填、冻结。
17
+
18
+ ## 交互形态
19
+
20
+ 多次调用、文件往返,不是 TTY 交互。轮次与作答记录写在**契约草稿自己的 `extensions.interview` 里**,
21
+ 不另建状态目录:草稿在哪里,进度就在哪里。
22
+
23
+ ```
24
+ contract scaffold → 契约草稿(原样占位)
25
+ contract interview-ask → 本轮问题批(最多 4 题,选项槽是空的)
26
+ ↑ ↓ 你把选项填进去,交给用户选
27
+ └──── contract interview-answer ← 作答文件
28
+ contract interview-freeze → 带 contract_digest 的冻结契约
29
+ ```
30
+
31
+ ### `contract scaffold [--workdir <dir>]`
32
+
33
+ 契约骨架。与 `verify scaffold --kind contract` 同源(`core/contract-scaffold.mjs`),
34
+ 只有 `skill_set` 不同:本命令冻结当前 `orchestrate-subagents` 的 content digest,`ledger init` 要求它;
35
+ verify 侧冻结 `verify-agent-output`。
36
+
37
+ ### `contract interview-ask --input <草稿>`
38
+
39
+ 输出本轮问题批:
40
+
41
+ | 字段 | 含义 |
42
+ |---|---|
43
+ | `round` / `max_rounds` | 本轮序号与上限 |
44
+ | `questions[]` | `{ field, question, field_semantics, options: [], selected: null, source: "user" }` |
45
+ | `answer_spec[]` | 填写规范原文,直接转述给用户 |
46
+ | `remaining_criteria[]` | 仍缺的完成判据,每条带 `criterion` / `field` / `detail` |
47
+ | `required_fields[]` | 本次权限模式下的必问题清单 |
48
+
49
+ 每道题带一个 `deferrable` 布尔位:`false` 的题只有一条路——用户在选项里选(见下)。
50
+
51
+ `options` 是空槽——命令不生成选项。
52
+
53
+ ### `contract interview-answer --input <草稿> --answers <作答文件>`
54
+
55
+ 作答文件是 `{ "answers": [...] }` 或裸数组,一轮最多 4 条,同一字段一轮只能答一次。
56
+
57
+ ```jsonc
58
+ { "field": "permissions", "options": ["read_only", "write"], "selected": 1, "source": "user" }
59
+ { "field": "objective", "options": ["A", "B"], "selected": "custom", "custom_value": "用户原话", "source": "user" }
60
+ { "field": "scope.exclude", "options": ["A", "B"], "deferred": true, "assumed": ["schemas/", "四个 SKILL.md"] }
61
+ ```
62
+
63
+ 命令把选中的内容写进对应字段,追加 `extensions.interview.answers[]`,重新校验,
64
+ 输出下一批问题(`next`)或 `complete: true`。
65
+
66
+ ### `deferred`:用户回答"都行"或拒答
67
+
68
+ 只有 **`scope.include` / `scope.exclude` / `stop_conditions`** 可以 `deferred`。
69
+ 每道题的 `deferrable` 字段直接标出它走不走得了这条路。
70
+
71
+ `permissions` / `objective` / `acceptance` **必须有 `source: "user"` 的作答记录**,不接受 assumption。
72
+ 否则模型可以先把 `objective` 写进草稿,再记一条"用户说都行",在没有任何用户选择的情况下把契约冻掉——
73
+ 这正是访谈要防的"模型替用户回答",而且是机制拦得住的那一部分。对这三个字段提交 `deferred`,
74
+ `interview-answer` 直接拒绝。
75
+
76
+ `deferred` 时命令把 `assumed` **原样写进该字段**(整段替换),并记下
77
+ `{ field, assumed, reason: "user_deferred" }`。字段而不是 `extensions` 才是执行方会读的东西:
78
+ 字段留空、`extensions` 里却写着"假定排除 X",是一份自己跟自己打架的契约。
79
+
80
+ - `assumed` 是字符串数组,单条可直接写字符串;
81
+ - `assumed` **可以是空数组**,表示"没有要排除的 / 没有额外终止条件"。这时 write 模式的 warning 保留,
82
+ 不影响冻结;
83
+ - 完成判据第 3 条对 assumption 同样生效:字段当前值必须与 `assumed` 逐项相等,事后手改就不成立;
84
+ - `scope.include` 的 `assumed` 若仍含 scaffold 占位,实质性判据照常拦住,这里不特判;
85
+ - 带 assumption 的字段视为已作答。
86
+
87
+ ### `contract interview-freeze --input <草稿>`
88
+
89
+ 三条完成判据都满足时重新签名并输出契约,否则非零退出并列出仍缺的判据。
90
+ 冻结产物能通过 `contract validate` 与 `ledger init`。
91
+
92
+ 配一份合规 profile 时也能通过 `verify preflight`,但 preflight 另外要求契约冻结 `verify-agent-output`。
93
+ `skill_set` 不是访谈会问、会改的字段,所以这条绑定要在开访谈之前就写进草稿:
94
+
95
+ ```
96
+ agentkit contract scaffold --workdir <repo> # 只带 orchestrate-subagents
97
+ # 再往 skill_set 追加 verify-agent-output 的 name/version/content_digest
98
+ agentkit verify capabilities --json # 取当前 runtime_version 与 content_digest
99
+ ```
100
+
101
+ ## 必问题清单
102
+
103
+ | 权限模式 | 必问字段 |
104
+ |---|---|
105
+ | `read_only` | `permissions` / `objective` / `acceptance` / `scope.include` |
106
+ | `write` | 以上,再加 `scope.exclude` / `stop_conditions` |
107
+
108
+ **`permissions` 必问且最先问。** `scope.exclude` 与 `stop_conditions` 的判据只在 write 模式下生效,
109
+ 而 scaffold 默认 `read_only`,能悄无声息地通过校验。不先问权限,一个写任务走完整个访谈,
110
+ 也不会被问到边界和刹车。
111
+
112
+ 实际出题的字段 = 必问题里尚未作答的 ∪ 实质性判据仍在报的字段。
113
+ error 指向的字段一律重问——填了一轮文字不等于问完了;warning 指向的字段若已有作答记录就不再问。
114
+
115
+ ## 完成判据
116
+
117
+ 三条同时满足才冻结:
118
+
119
+ 1. **实质性判据的创建入口 error 为零。** warning 允许保留:用户可以明确回答"没有要排除的",
120
+ 这时 `scope.exclude` 为空的 warning 仍在,但该字段已有作答记录或 assumption。
121
+ 2. **每道必问题都有一条作答记录**(`source: "user"`)。
122
+ `scope.include` / `scope.exclude` / `stop_conditions` 可以改由一条 `user_deferred` 的 assumption 满足;
123
+ `permissions` / `objective` / `acceptance` 不行。
124
+ 3. **每条记录与契约当前值一致。** 作答记录看 `selected` 对应的内容:单值字段
125
+ (`permissions` / `objective`)按相等判定,列表字段(`acceptance` / `scope.*` / `stop_conditions`)
126
+ 按包含判定。assumption 看 `assumed`:整段按顺序全等判定。
127
+
128
+ 第 3 条是这台状态机的实际门禁:事后手改字段而不更新记录,冻结就不成立。
129
+
130
+ ## 作答记录格式
131
+
132
+ ```jsonc
133
+ "extensions": {
134
+ "interview": {
135
+ "schema_version": 1,
136
+ "round": 2,
137
+ "answers": [
138
+ { "field": "permissions", "options": ["read_only", "write"], "selected": 1, "source": "user" },
139
+ { "field": "objective", "options": ["A", "B"], "selected": "custom", "custom_value": "用户原话", "source": "user" }
140
+ ],
141
+ "assumptions": [
142
+ { "field": "scope.exclude", "assumed": ["schemas/", "四个 SKILL.md"], "reason": "user_deferred" }
143
+ ]
144
+ }
145
+ }
146
+ ```
147
+
148
+ - `options` 是当时给出的 2–4 个选项原文,**逐字保留**;
149
+ - `selected` 是选项下标,或 `"custom"` 配 `custom_value` 写用户原话;
150
+ - `source` 只能是 `"user"`;
151
+ - `assumptions[].field` 只能是三个可 deferred 字段之一,`assumed` 是字符串数组,`reason` 只能是 `"user_deferred"`;
152
+ - 单值字段再次作答会**替换**旧记录——两条记录只有一条能与字段当前值一致,留着另一条会让判据 3 永远不成立;
153
+ - 对一个字段 `deferred` 会**整段替换**该字段并丢掉它此前的作答记录;反过来,对已 deferred 的字段作答会移除那条 assumption。
154
+ 同一个字段不会同时挂着作答记录和 assumption。
155
+
156
+ `extensions.interview` **进入 `contract_digest`**。这是预期的:作答记录是契约的一部分,冻结后不可变。
157
+
158
+ ## 选项校验
159
+
160
+ 每题 2–4 个选项、互不相同、非空。0 或 1 个选项按开放式问题拒绝——一道只有一个答案的题,
161
+ 不是在让用户选择。命令只看形状,不判断选项内容"好不好"。
162
+
163
+ ## 3 轮上限
164
+
165
+ 一轮 = 一次"出题 → 回填 → 校验"。第 3 轮回填后完成判据仍未满足,命令非零退出,
166
+ 列出仍缺的判据,并建议把任务拆开:一份契约要问到第 4 轮还定不下来,通常说明它同时在做两件事。
167
+
168
+ ## 命令检查不了什么
169
+
170
+ **`source: "user"` 的真伪。** 命令能检查作答记录是否存在、是否与字段当前值自洽,
171
+ 但无法判断这条记录背后是不是真的有一个人做过选择——伪造一份 `answers[]` 与真实作答在字节上没有区别。
172
+ 这是本命令的剩余风险,不由它承担。
173
+
174
+ 同样不承担的还有:选项是否真正互斥、`assumed` 描述的默认值是否就是字段当前的值、
175
+ 用户选中的内容是否切题。这些都需要对自然语言做判断,命令一律不做。
@@ -24,20 +24,60 @@ init --contract <json> [--state-root <dir>] [--ledger-id <id>]
24
24
  add-node / add-edge / dispatch-record / update / attach
25
25
  batch-init / batch-record / batch-status / batch-fuse
26
26
  record-reflection / propose-improvement
27
- status / inspect / rebuild / doctor / capabilities
27
+ close [--abandon --reason <text>]
28
+ status / inspect / rebuild / capabilities
29
+ doctor --ledger <dir> | doctor --repository <path>
30
+ reclaim-pointers --repository <path>
28
31
  ```
29
32
 
30
33
  `init` 回显 `ledger_id / ledger_dir / ledger / state_root / revision`。后续所有命令的 `--ledger`
31
34
  传 `ledger` 字段的值,也就是 `<state-root>/ledgers/<ledger-id>`,**不是 `--state-root` 本身**;
32
35
  把 state root 当 `--ledger` 传时,ledger 直接给出应传的绝对路径(多个 ledger 时列出候选)。
33
36
 
37
+ ## 仓级 ledger 指针与 `agentkit status`
38
+
39
+ state root 按设计落在业务仓库之外,新会话在主 checkout 里没有任何线索能找回上一个会话的 ledger。
40
+ `init` 因此在 `contract.environment.repository` 所属仓库的 git common dir 下写一份指针:
41
+
42
+ ```text
43
+ <git-common-dir>/agentkit/ledgers/<ledger_id>.json
44
+ { "schema_version": 1, "ledger_id", "state_root", "contract_digest", "created_at" }
45
+ ```
46
+
47
+ 结构真源是 [`schemas/ledger-pointer-v1.schema.json`](../../schemas/ledger-pointer-v1.schema.json),
48
+ 读写实现是 `core/ledger-pointer.mjs`(放在 core/ 是因为 worktree 域与顶层 CLI 都要用它,而域之间不互相 import)。
49
+
50
+ - **指针不是真源。** 它只回答"ledger 在哪"。`contract_digest` 等字段只用于展示与交叉核对,任何判定
51
+ 都回读 state root 的事件链。指针全部删掉不丢任何状态,代价只是重新手传 `--ledger`。
52
+ - 指针只写在 `.git/` 下,不进版本控制;linked worktree 里 `--git-common-dir` 指向主仓 `.git`,
53
+ 因此一个仓库永远只有一份指针目录。
54
+ - `environment.repository` 为 `'none'`、路径不存在或不是 git 仓时不写指针,`init` 在 `pointer.reason`
55
+ 与 `warnings` 里说明原因。
56
+ - **顺序与失败语义**:先把 ledger 建成(事件链 + 快照落盘),最后才写指针。指针写失败降级为 warning,
57
+ 不让 `init` 失败后留下半个 ledger。`close`(含 `--abandon`)成功后删除指针,删除失败同样只是 warning。
58
+ - 指针按 `ledger_id` 索引,一个仓上同名 ledger 只有一份。换 state root 重建同名 ledger 会顶掉旧那一份,
59
+ `init` 在 `pointer.replaced` 与 `warnings` 里点名被顶掉的 state root——旧 ledger 本身不受影响,
60
+ 只是之后要手传 `--ledger`。
61
+ - `doctor --repository <path>` 扫描该仓的全部指针并分类:`active` / `skill_drift` / `terminal` /
62
+ `dangling_state_root` / `unreadable` / `malformed`。它是只读的,只报告不删。
63
+ **drift 但未进入终态的 ledger 指针一律保留**:它还需要有人来 `close --abandon` 或 re-contract,
64
+ 回收指针等于把它藏起来。
65
+ - `reclaim-pointers --repository <path>` 是显式回收入口,删掉 `doctor` 标为 `reclaimable` 的那些。
66
+ 回收不放在只读的 `doctor` 里,是为了让一次例行体检不会悄悄改掉仓库状态。
67
+
68
+ 顶层 `agentkit status [--json]` 从 cwd 找 git common dir,读全部指针,回读各 state root,筛出未终态
69
+ 的 ledger,单屏给出当前阶段、活跃 worktree、阻塞项、未覆盖节点(`summary.uncovered_implementation_nodes`)
70
+ 与下一步命令。同时存在多个时全部列出,不做猜测;处在受管 worktree 里时用 record 的 `ledger` 字段收窄。
71
+ `skill_drift` 的 ledger 单独成组,下一步只给 `close --abandon` 与 re-contract,不给续跑命令。
72
+ 找不到任何指针时直接提示"未发现 ledger"。轻量档不在覆盖范围内。
73
+
34
74
  五个脚本(`contract-tool` / `orchestration-ledger` / `worker-capability-preflight` /
35
75
  `review-budget` / `orchestration-reflection`)在 `--help`、`-h`、`help` 或无参数时打印自己的命令与
36
76
  参数清单并退出 0;清单由脚本内的 `CLI_SPEC` 表机械渲染,不另写一份。未知命令与非法输入仍保持
37
77
  各脚本原有的错误形状与非零退出。
38
78
 
39
79
  `capabilities` 同时输出独立的 `protocol_version`、`runtime_version` 和 Skill tree `content_digest`。
40
- 当前协议版本为 `1.1.0`,ledger runtime 为 `1.7.0`;三者分别表达兼容语义、脚本实现和精确安装内容,
80
+ 当前协议版本为 `1.1.0`,ledger runtime 为 `1.8.0`;三者分别表达兼容语义、脚本实现和精确安装内容,
41
81
  不能互相替代。
42
82
 
43
83
  ## Reviewer 预算门禁
@@ -180,6 +220,84 @@ Evidence 必须为 `terminal_outcome: pass` 且不再要求 human gate。节点
180
220
  `status.summary.verification_assurance` 分别计数四档(含 `not_applicable`)和 `none`,
181
221
  `doctor` 重新执行同一门禁。
182
222
 
223
+ **完成判定与独立验证覆盖**:`status.summary.completion_ready` 只在下面三条同时成立时为 `true`。
224
+
225
+ 1. 图里至少有一个 `required` 节点。空 ledger 和只有非 required 节点的 ledger 一律不算完成。
226
+ 2. 所有 `required` 节点都已 `passed`。
227
+ 3. 公共合同声明了 `extensions.verification.provider: verify-agent-output` 时,每个 `required` 的
228
+ **实现节点**都被独立验证覆盖。实现节点指 `verification.requirement !== 'not_applicable'` 的节点——
229
+ `not_applicable` 是只读评审节点的专用档,其余节点都有交付物;判定只看这一个字段,不看 `role`。
230
+ 覆盖指满足其一:节点本身是已 `passed` 的 `independent_evidence` 节点;或沿 `dependency` /
231
+ `barrier` 边(`from → to`,`to` 依赖 `from`)向下可达某个已 `passed`、
232
+ `requirement: independent_evidence` 且 `artifact_scope: integration_candidate` 的节点,
233
+ 可达性是传递的。合同没有声明 provider 时这条不生效,节点仍按 controller 选定的风险分级各自收口。
234
+
235
+ 覆盖规则只证明 controller 声明的拓扑关系,不证明上游产物真的进入了集成候选。
236
+
237
+ `status.summary` 同时给出三份名单,都是节点 id 数组:
238
+
239
+ - `uncovered_implementation_nodes`:被第 3 条拦下的 `required` 实现节点。合同未声明 provider 时为空数组。
240
+ - `non_required_implementation_nodes`:`required: false` 的实现节点。`add-node` 直接接受调用方传入的
241
+ `required: false`,这类节点不受覆盖规则约束,是合法选择,但必须看得见。
242
+ - `nodes_without_independent_evidence`:合同**未**声明 provider 时,逐个列出 `verification_assurance`
243
+ 不是 `independent_evidence` 的**实现节点**;只读评审节点没有交付物,不进这份名单。声明了
244
+ provider 时为空数组,改看 `uncovered_implementation_nodes`。
245
+
246
+ `status.summary.unmet_completion_conditions` 把上面三条判据里当前没过的逐条列出来,`close` 被拒时
247
+ 给的是同一份文案——两处读的是同一个完成判定,不可能各自漂移。
248
+
249
+ ## ledger 生命周期与终态
250
+
251
+ ledger 有且只有两种终态,都由 `close` 写入,并在事件链上追加 `closed` / `abandoned` 事件:
252
+
253
+ ```text
254
+ close --ledger <dir> [--expected-revision <n>]
255
+ close --ledger <dir> --abandon --reason <text> [--expected-revision <n>]
256
+ ```
257
+
258
+ - 正常 `close` 要求 `status.summary.completion_ready` 为 `true`(含上面三条判据)。不满足时非零退出,
259
+ 并逐条列出未满足的条件:未 `passed` 的 `required` 节点(带当前 state)、未被独立验证覆盖的实现节点,
260
+ 或"图里没有任何 required 节点"。
261
+ - `--abandon` 记录放弃,`--reason` 必填且非空;`--reason` 不能脱离 `--abandon` 单独使用。
262
+
263
+ 快照新增可选字段 `lifecycle`,缺省表示 ledger 仍然活跃:
264
+
265
+ ```json
266
+ {
267
+ "lifecycle": {
268
+ "state": "closed | abandoned",
269
+ "closed_at": "RFC3339",
270
+ "reason": "abandoned 必填非空;closed 固定为 null",
271
+ "closed_by_content_digest": "sha256:... 写入终态时的 runtime 内容摘要",
272
+ "skill_drift_at_close": false
273
+ }
274
+ }
275
+ ```
276
+
277
+ **close 只冻结任务图。** 终态之后 `add-node`、`add-edge`、`dispatch-record`、`update`、`attach`、
278
+ `batch-init`、`batch-record` 与再次 `close` 一律非零退出。白名单是 `record-reflection`、
279
+ `propose-improvement`、`rebuild`、`doctor`、`status`、`inspect`、`batch-status`、`batch-fuse`:
280
+ 架构 §15.2 列出的高优先级反思触发按定义都发生在完成之后,已关闭的 ledger 也仍然需要 `rebuild` 做
281
+ 崩溃修复,而 `batch-fuse` 只按已记录的 records 重算熔断判定、不写事件链,与 `batch-status` 同列。
282
+ 冻结集合由 CLI_SPEC 全集减白名单推导,新增的修改命令默认落进冻结集合。
283
+
284
+ **drift 与终态。** `skill_drift` 指冻结的 `skill_provenance.content_digest` 与当前 runtime 的分发内容
285
+ 摘要不一致,`mutate`、`doctor`、`status` / `inspect` 共用同一个比较:
286
+
287
+ | 命令 | 有 drift 时 |
288
+ |---|---|
289
+ | `close`(正常完成) | 拒绝。"完成"是按冻结的协议判定的,换了 runtime 就不能再替旧协议下结论 |
290
+ | `close --abandon` | **允许。** 它不推进任务图,也不按协议做任何判定,只记录"这个 ledger 不再继续" |
291
+ | 其余修改命令(含 `record-reflection` / `propose-improvement`) | 保持拒绝 |
292
+
293
+ `close --abandon` 是 drift 检查的唯一豁免点。它写入的终态事件里 `skill_drift_at_close: true`,且
294
+ `closed_by_content_digest` 是写入时的 runtime 摘要,与快照里冻结的摘要不同——这条终态由 drift 的
295
+ runtime 写入这件事留在事件链上,冻结摘要本身不被改写。跨版本的反思记到新 ledger,不为反思开豁免。
296
+
297
+ `status` 与 `inspect` 输出 `skill_drift: true | false`;为 `true` 时 `skill_drift_remediation` 写明
298
+ 这个 ledger 只剩两条路:`close --abandon --reason <text>`,或用当前 runtime re-contract 一个新 ledger。
299
+ 终态本身不会让 `doctor` 报 unhealthy。
300
+
183
301
  **Token 消耗与成本核算(v1.2)**:`update` 支持记录节点消耗的 `tokens`(非负安全整数,或字段完整的 `{ input_tokens, output_tokens, total_tokens }`,其中 `total_tokens = input_tokens + output_tokens`)及非负安全整数 `duration_ms`。`status` 命令在 `summary.token_accounting` 中自动汇总总 Token 与按角色分级的消耗分布,支持计算多 Agent 分发相比全量顶配模型的 Token 节省率;`doctor` 使用同一校验器复核持久化节点。
184
302
 
185
303
  **合同投影(v1.2)**:Evidence 的合同绑定有两条合法路径,缺省仍是全等——verify-agent-output
@@ -230,9 +348,9 @@ ledger 目录里的 `contract.json`,使用前先要求 `envelopeDigest(contrac
230
348
  否则换掉那份文件就能给私货投影背书。因此投影只能**收窄**验收面,不能改写或扩张它——仍然不要通过
231
349
  复制改写公共合同来伪造"等值",那会破坏合同摘要的审计意义。
232
350
 
233
- `add-node` 必须显式给出 `verification`,没有缺省档。Ledger v1 schema 可以读取任何旧快照,但
234
- Skill content digest 已变化的旧 ledger 只允许审计,继续写入前必须 re-contract,不做原地补字段或
235
- 静默升级。
351
+ `add-node` 必须显式给出 `verification`,没有缺省档。Ledger v1 schema 可以读取任何旧快照——`lifecycle`
352
+ 是可选新增字段,没有它的旧快照仍然合法。Skill content digest 已变化的旧 ledger 只允许审计与
353
+ `close --abandon`,继续推进任务图前必须 re-contract,不做原地补字段或静默升级。
236
354
 
237
355
  批级熔断属于此 ledger;每个 Loop 仍只维护自己的单个收敛对象。
238
356
 
@@ -7,7 +7,9 @@
7
7
 
8
8
  `prepare` 生成 Contract/Profile 骨架、逐项 TODO 和后续命令,不猜测试命令、不内置项目 preset。
9
9
  `l0_checks` 必须由 controller 按项目实际填写。`scaffold` 支持 `contract | profile | artifact | review |
10
- bundle`;骨架结构和摘要合法,但 TODO acceptance 与示例 L0 必须替换。
10
+ bundle`;骨架结构和摘要合法,但 TODO acceptance 与示例 L0 必须替换。创建入口(`preflight`、`init`、
11
+ `prepare-run`,以及 `contract validate`、`ledger init`、`loop init`)会拒绝原样保留的 scaffold 占位,
12
+ 判据与级别见下节。
11
13
 
12
14
  `artifact/bundle` 要求 `--workdir` 与 `--base-sha`,默认冻结当前 HEAD。`review` 从 `review-input` 原样
13
15
  取得 Contract/Profile digest、Artifact 与 challenge nonce。`digest` 支持 `contract | profile | review`,
@@ -20,6 +22,35 @@ agentkit verify scaffold \
20
22
  --kind bundle --workdir <clean-pinned-workdir> --base-sha <full-base-sha>
21
23
  ```
22
24
 
25
+ ## 实质性检查
26
+
27
+ 形状合法不等于有内容。五条判据在创建入口执行,逐条给出字段路径与当前值,不给可照抄的合规值。
28
+
29
+ | # | 判据 | 需要的输入 | 级别 |
30
+ |---|---|---|---|
31
+ | 1 | `objective`、`acceptance[].requirement`、`scope.include` 中残留 scaffold 占位字面量 | 契约 | error |
32
+ | 2 | `l0_checks[].check_id` 是 scaffold 占位标识,或全部 `argv` 都是 scaffold 占位命令(两个分支独立触发) | profile | error |
33
+ | 3 | 某条 `acceptance[].contract_item_id` 未被任何 `l1_review` 条目引用 | 契约 + profile | error |
34
+ | 4 | `permissions.mode` 为 `write` 且 `scope.exclude` 为空 | 契约 | warning |
35
+ | 5 | `permissions.mode` 为 `write` 且 `stop_conditions` 为空 | 契约 | warning |
36
+
37
+ #4、#5 只检查是否存在,边界划得对不对 runtime 判断不了,所以只给 warning:`valid` 结论和退出码都不
38
+ 变。#3 要同时拿到两份文件才成立,只有 `preflight`、`init`、`prepare-run` 和 `loop init` 能执行;
39
+ `contract validate`、`ledger init` 手上只有契约,执行 #1、#4、#5。#3 保证每条 acceptance 都被 L1 审过,
40
+ 不保证被 L0 测到——`l0_checks` 条目没有 `contract_item_id`,无从建立对应关系。
41
+
42
+ warning 通过两个输出键带出:
43
+
44
+ - `warnings`:仅非空时出现,见 `contract validate`、`ledger init`、`verify init`、`loop init` 的返回值,
45
+ 以及 `preflight` 报告(该键在报告里恒在,可能是空数组)。`prepare-run` 的完整报告把它放在
46
+ `preflight.warnings`,默认 compact 输出不带,取证加 `--verbose`。
47
+ - `substance_warnings`:恒在,见 `ledger doctor`、`verify doctor`、`loop doctor`。doctor 把手上能执行的
48
+ 全部判据整体降级成 warning,不进 `findings`,不改变 `healthy`。
49
+
50
+ 续跑与恢复入口(`add-node`、`record-review`、`validate`、`adopt-root`、`record-embedded-review`)不重判
51
+ 实质性:契约冻结后不可变,实质性只在冻结那一刻判定一次;`validate`、`adopt-root`、`doctor` 这些只读回看
52
+ 路径还会读到判据出现之前冻结的状态,在那里拒绝等于让历史 Evidence 的审计结论随 runtime 版本变化。
53
+
23
54
  ## Readiness 与 Preflight
24
55
 
25
56
  `readiness` 只检查环境前提:Git worktree 根、可执行文件、已存在的 argv 文件、L0 `cwd_rel` 和可写
@@ -28,6 +28,9 @@
28
28
  5. 输出 fail、no_defect_found、undecidable 三态之一。无法获得会改变结论的真源或证据时必须
29
29
  undecidable,不用低保证结果替代。
30
30
  6. safety finding 不能被其他通过项抵消。
31
+ 7. 已成立的 finding 涉及结构调整时,expected 必须写出具名重构手法(例如提取函数、内联变量、
32
+ 搬移函数、以多态取代条件式),且该手法只针对本条 finding 的 contract_item_id;给不出具名
33
+ 手法的结构评价不写入 findings。
31
34
  ```
32
35
 
33
36
  视觉与主观结果仍须从设计稿、协议、计划等裁决真源独立推导。涉及图层合成时核对整组图层,按
@@ -21,6 +21,26 @@ manager 的一行摘要,失败时只返回有界错误,避免 ANSI 进度条
21
21
  `agent-id` 必须来自宿主真实 session/thread/task ID,不得编造。相同会话已有其他树时,先按
22
22
  [delivery-identity.md](delivery-identity.md) 判断复用、并存或替代。
23
23
 
24
+ ## worktree 级 ledger 指针
25
+
26
+ 这棵树属于某次编排时,`spawn` 可以带上 `--ledger <id>`:
27
+
28
+ ```bash
29
+ agentkit worktree spawn ci-gate-hardening \
30
+ --agent codex --agent-id <real-thread-id> \
31
+ --purpose "加固 CI 门禁" --ledger <orchestration-ledger-id>
32
+ ```
33
+
34
+ 它写进 record 的 `ledger` 字段(缺省 `null`,升级前的老 record 同样按 `null` 处理),只有一个用途:
35
+ `agentkit status` 在受管 worktree 里据此把候选 ledger 收窄到一个,不必在多个未终态 ledger 之间猜。
36
+
37
+ worktree record 仍然是 worktree 域的内部结构,由代码定义、没有对外 schema;`ledger` 字段是其中一个
38
+ 普通可选字段。worktree 域只校验 id 的**格式**(规则真源是 `core/ledger-pointer.mjs` 里的
39
+ `LEDGER_ID_PATTERN`,与 `agentkit orchestrate ledger init --ledger-id` 同一套),不去解析 ledger
40
+ 状态——那属于 orchestrate 域,两个域之间不互相 import。传一个还不存在的 ledger id 不会被拒绝,
41
+ 它只是暂时收窄不到任何东西。仓级指针与 ledger 状态见
42
+ `agentkit docs orchestrate orchestration-runtime`。
43
+
24
44
  ## Root 与 branch
25
45
 
26
46
  无 Profile 时:base 按 remote HEAD、`origin/main`、`origin/master`、upstream、HEAD 依次选择;branch