@cr1992/agentkit 1.1.0 → 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.
@@ -28,8 +28,9 @@ import { validateJsonSchema } from '../../core/json-schema-lite.mjs';
28
28
  import { atomicWriteJson, atomicWriteText, writeNewJson } from '../../core/atomic-fs.mjs';
29
29
  import { createDigestKit } from '../../core/digest.mjs';
30
30
  import { distributionDigest, skillDistributionRoots } from '../../core/content-digest.mjs';
31
+ import { contractSubstance, coverageSubstance, formatSubstanceErrors, profileSubstance, substanceWarnings } from '../../core/contract-substance.mjs';
31
32
 
32
- export const RUNTIME_VERSION = '1.0.0';
33
+ export const RUNTIME_VERSION = '1.1.0';
33
34
  const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'run-agent-verify-loop');
34
35
  // 摘要覆盖 Skill 目录 + 共享 core + canonical schemas:执行真正依赖的全部分发内容。
35
36
  // PACKAGE_ROOT 是模块常量,传入自定义 root 只替换 Skill 目录那一段,便于测试摘要与安装路径无关。
@@ -715,6 +716,10 @@ function initialize(options, flags) {
715
716
  const profile = readJson(required(options, 'profile'));
716
717
  const ids = validateContract(contract);
717
718
  validateProfile(profile, ids);
719
+ // 实质性只在 init 这个冻结点判定;adopt-root、record-embedded-review、validate 等续跑入口不重判。
720
+ const contractReport = contractSubstance(contract);
721
+ const substance = [...contractReport.errors, ...profileSubstance(profile).errors, ...coverageSubstance(contract, profile).errors];
722
+ if (substance.length) throw new LoopValidationError(formatSubstanceErrors(substance));
718
723
  const contentDigest = skillContentDigest();
719
724
  validateSkillBinding(contract, contentDigest);
720
725
  const provider = required(options, 'provider');
@@ -774,7 +779,7 @@ function initialize(options, flags) {
774
779
  initialEvent.event_digest = envelopeDigest(initialEvent, 'event_digest');
775
780
  writeFileSync(join(loopDir, 'events.ndjson'), `${canonicalJson(initialEvent)}\n`, { flag: 'wx', mode: 0o600 });
776
781
  atomicWriteJson(join(loopDir, 'snapshot.json'), snapshot);
777
- return { loop_id: loopId, loop_dir: loopDir, revision: 0, state: 'active', provider };
782
+ return { loop_id: loopId, loop_dir: loopDir, revision: 0, state: 'active', provider, ...(contractReport.warnings.length ? { warnings: contractReport.warnings } : {}) };
778
783
  });
779
784
  }
780
785
 
@@ -1005,7 +1010,10 @@ function doctor(options) {
1005
1010
  const drift = currentDigest !== loaded.snapshot.skill_provenance.content_digest;
1006
1011
  const lockPresent = existsSync(join(stateRoot, '.loop-runtime.lock'));
1007
1012
  const findings = identityError ? ['state_root_identity_invalid'] : [];
1008
- return { healthy: !loaded.needsRepair && !lockPresent && !drift && !identityError, loop_id: loaded.snapshot.loop_id, revision: loaded.snapshot.revision, snapshot_matches_journal: !loaded.needsRepair, lock_present: lockPresent, skill_drift: drift, frozen_content_digest: loaded.snapshot.skill_provenance.content_digest, current_content_digest: currentDigest, state_root_identity_valid: !identityError, findings, diagnostics: identityError, recovery_command: identityError ? `adopt-root --state-root ${stateRoot}` : null };
1013
+ // 实质性问题不进 findings、不参与 healthy:doctor 是只读回看路径,会读到判据出现之前冻结的 loop,
1014
+ // 在这里判 unhealthy 等于让历史结论随 runtime 版本变化。
1015
+ const substance = substanceWarnings(readJson(join(loopDir, 'contract.json')), readJson(join(loopDir, 'profile.json')));
1016
+ return { healthy: !loaded.needsRepair && !lockPresent && !drift && !identityError, loop_id: loaded.snapshot.loop_id, revision: loaded.snapshot.revision, snapshot_matches_journal: !loaded.needsRepair, lock_present: lockPresent, skill_drift: drift, frozen_content_digest: loaded.snapshot.skill_provenance.content_digest, current_content_digest: currentDigest, state_root_identity_valid: !identityError, findings, substance_warnings: substance, diagnostics: identityError, recovery_command: identityError ? `adopt-root --state-root ${stateRoot}` : null };
1009
1017
  }
1010
1018
 
1011
1019
  export function main(argv = process.argv.slice(2)) {
@@ -0,0 +1,448 @@
1
+ // @ts-check
2
+ // `agentkit contract interview-*`:由拒绝清单驱动提问、由自己的完成判据决定何时冻结的状态机。
3
+ //
4
+ // 本模块不调用任何模型,也不对自然语言做启发式判定。它只做三件事:
5
+ // 出题(哪些字段还缺、按什么顺序问)、校验回填(选项合规、写进对应字段)、判定冻结。
6
+ // 选项由调用它的模型填,选哪个由用户定——命令既不生成选项,也不替用户选。
7
+ //
8
+ // 为什么"问什么"和"什么时候算问完"不能合一:
9
+ // core/contract-substance.mjs 的 error 只匹配 scaffold 的字面量,任意填一轮文字就能通过。
10
+ // 拿它当结束条件,interview 就退化成一张一次性表单。所以实质性判据只负责"还缺哪些字段",
11
+ // 结束条件另立三条完成判据(见 completion)。
12
+ //
13
+ // 交互形态是多次调用、文件往返,不是 TTY 交互:轮次状态就写在契约草稿自己的
14
+ // extensions.interview 里,不另建状态目录——草稿在哪里,进度就在哪里,换机器、换会话都不丢。
15
+ // extensions.interview 会进入 contract_digest,这是预期的:作答记录是契约的一部分,冻结后不可变。
16
+ //
17
+ // 命令检查不了的事:source: "user" 的真伪。它只能检查记录是否存在、是否与字段当前值自洽。
18
+ import { contractSubstance } from '../../core/contract-substance.mjs';
19
+
20
+ /** 一轮 = 一次"出题 → 回填 → 校验"。第 3 轮回填后仍不满足完成判据就退出,建议拆分任务。 */
21
+ export const MAX_ROUNDS = 3;
22
+ /** 一批最多 4 题:再多,模型给出的选项质量和用户的分辨力都会掉下去。 */
23
+ export const MAX_QUESTIONS_PER_ROUND = 4;
24
+ const MIN_OPTIONS = 2;
25
+ const MAX_OPTIONS = 4;
26
+
27
+ /**
28
+ * 提问顺序是固定的,不按"哪条判据先报"排。
29
+ * permissions 必问且最先问:exclude / stop_conditions 两条判据只在 write 模式下生效,而 scaffold 默认 read_only,
30
+ * 能悄无声息地通过校验。不先问权限,一个写任务走完整个 interview 也不会被问到边界和刹车。
31
+ */
32
+ const FIELD_ORDER = ['permissions', 'objective', 'acceptance', 'scope.include', 'scope.exclude', 'stop_conditions'];
33
+ const READ_ONLY_REQUIRED = ['permissions', 'objective', 'acceptance', 'scope.include'];
34
+ const WRITE_REQUIRED = [...READ_ONLY_REQUIRED, 'scope.exclude', 'stop_conditions'];
35
+
36
+ /** 字段语义:随题一起发给用户,让选项可被判断。不含任何可照抄的合规值。 */
37
+ const FIELD_SEMANTICS = {
38
+ permissions: 'permissions.mode 决定本次任务能不能写仓库。read_only 只允许读与报告;write 允许改动,并会追加边界与刹车两道必问题。',
39
+ objective: 'objective 是冻结产物要达成的目标,一句话说清"做完是什么样",不是过程描述。',
40
+ acceptance: 'acceptance[].requirement 是可观察的验收要求:第三方只看仓库与命令输出就能判定通过或不通过。',
41
+ 'scope.include': 'scope.include 是本次任务允许触碰的面,按路径或模块写。',
42
+ 'scope.exclude': 'scope.exclude 是明确不可触碰的面。它不是"没想到的地方",而是"想到了并且禁止"。',
43
+ stop_conditions: 'stop_conditions 是机械停机点:命中即停止并上报,不由执行方自行判断要不要继续。',
44
+ };
45
+
46
+ /** 题面。出题词只描述要决定什么,不暗示答案。 */
47
+ const FIELD_QUESTION = {
48
+ permissions: '本次任务需要写仓库吗?',
49
+ objective: '本次任务要冻结的产物目标是什么?',
50
+ acceptance: '用什么可观察的事实判定本次任务做完了?',
51
+ 'scope.include': '允许触碰哪些面?',
52
+ 'scope.exclude': '哪些面明确不可触碰?',
53
+ stop_conditions: '命中什么条件就必须停下来上报?',
54
+ };
55
+
56
+ /**
57
+ * 判据本身留在 core/contract-substance.mjs,这里只把它的 finding 路由到一道题上。
58
+ * 路由键取 finding 里稳定的字段路径前缀,措辞部分不参与匹配。
59
+ * 新增判据而这里没跟上时,routeFinding 返回 null,由测试钉死"没有 finding 落空"。
60
+ */
61
+ const FINDING_ROUTES = [
62
+ { field: 'objective', match: /^objective = /u },
63
+ { field: 'acceptance', match: /^acceptance\[\d+\]\.requirement = /u },
64
+ { field: 'scope.include', match: /^scope\.include\[\d+\] = /u },
65
+ { field: 'scope.exclude', match: /scope\.exclude 为空/u },
66
+ { field: 'stop_conditions', match: /stop_conditions 为空/u },
67
+ ];
68
+
69
+ /** @param {string} finding @returns {string|null} */
70
+ export function routeFinding(finding) {
71
+ return FINDING_ROUTES.find((route) => route.match.test(finding))?.field ?? null;
72
+ }
73
+
74
+ /**
75
+ * 可以走 assumption 的字段。
76
+ *
77
+ * permissions / objective / acceptance **不在此列**:这三项必须有 source: "user" 的作答记录。
78
+ * 否则模型可以先把 objective 写进草稿,再记一条"用户说都行",在没有任何用户选择的情况下把契约冻掉——
79
+ * 这正是访谈要防的"模型替用户回答",而且是机制拦得住的那一部分。
80
+ *
81
+ * 剩下三项都是列表字段,assumption 的内容能原样落进字段,执行方读得到。
82
+ */
83
+ const DEFERRABLE_FIELDS = new Set(['scope.include', 'scope.exclude', 'stop_conditions']);
84
+
85
+ export const INTERVIEW_ANSWER_SPEC = [
86
+ `每题必须给出 ${MIN_OPTIONS}–${MAX_OPTIONS} 个互不相同的非空选项;0 或 1 个选项按开放式问题拒绝。`,
87
+ '选项由你根据用户诉求和仓库现状生成,命令不生成选项,也不替用户选。',
88
+ 'selected 是选项下标,或者 "custom" 配 custom_value 写用户原话;source 只能是 "user"。',
89
+ `permissions / objective / acceptance 必须由用户在给出的选项中作答,不接受 deferred:这三项没有 assumption 这条路。`,
90
+ `用户回答"都行"或拒答时,只有 ${[...DEFERRABLE_FIELDS].join(' / ')} 可以写成 { field, options, deferred: true, assumed }。`,
91
+ 'assumed 是字符串数组(单条可直接写字符串),命令把它原样写进该字段并记进 assumptions[];执行方读的是契约字段,不是 extensions。',
92
+ 'assumed 可以是空数组,表示"没有要排除的 / 没有额外终止条件";此时 write 模式的 warning 保留,不影响冻结。',
93
+ ];
94
+
95
+ /** 单值字段:再次作答会替换上一条记录,而不是并存——并存会让完成判据第 3 条必然不成立。 */
96
+ const SINGLE_VALUED = new Set(['permissions', 'objective']);
97
+
98
+ const quote = (/** @type {unknown} */ value) => JSON.stringify(value);
99
+
100
+ /**
101
+ * 读出草稿里的 interview 状态。缺失即视为尚未开始。
102
+ * 这里按不可信数据读:草稿可能是手写的,也可能被手改过。
103
+ * @param {any} contract
104
+ */
105
+ export function readInterviewState(contract) {
106
+ const raw = contract?.extensions?.interview;
107
+ const round = Number.isSafeInteger(raw?.round) && raw.round >= 0 ? raw.round : 0;
108
+ return {
109
+ schema_version: 1,
110
+ round,
111
+ answers: Array.isArray(raw?.answers) ? raw.answers : [],
112
+ assumptions: Array.isArray(raw?.assumptions) ? raw.assumptions : [],
113
+ };
114
+ }
115
+
116
+ /** @param {any} contract @returns {string[]} 本次权限模式下的必问题清单 */
117
+ export function requiredFields(contract) {
118
+ return contract?.permissions?.mode === 'write' ? [...WRITE_REQUIRED] : [...READ_ONLY_REQUIRED];
119
+ }
120
+
121
+ /** 一条作答记录选中的内容。deferred 记录不落在这里,它进 assumptions。 */
122
+ function selectedText(answer) {
123
+ if (answer?.selected === 'custom') return typeof answer.custom_value === 'string' ? answer.custom_value : null;
124
+ const options = Array.isArray(answer?.options) ? answer.options : [];
125
+ return Number.isSafeInteger(answer?.selected) && answer.selected >= 0 && answer.selected < options.length ? options[answer.selected] : null;
126
+ }
127
+
128
+ /** 三个可 deferred 字段都是列表字段,assumption 整段落在这里。 */
129
+ function fieldList(contract, field) {
130
+ if (field === 'scope.include') return Array.isArray(contract?.scope?.include) ? contract.scope.include : [];
131
+ if (field === 'scope.exclude') return Array.isArray(contract?.scope?.exclude) ? contract.scope.exclude : [];
132
+ if (field === 'stop_conditions') return Array.isArray(contract?.stop_conditions) ? contract.stop_conditions : [];
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * 完成判据第 3 条对 assumption 同样生效:字段当前值必须与 assumed 逐项相等。
138
+ * assumption 是整段替换写进字段的,所以这里按顺序全等判定,事后手改字段就不成立。
139
+ */
140
+ function assumptionHolds(contract, field, assumed) {
141
+ const current = fieldList(contract, field);
142
+ if (current === null || !Array.isArray(assumed)) return false;
143
+ return current.length === assumed.length && current.every((item, index) => item === assumed[index]);
144
+ }
145
+
146
+ /**
147
+ * 完成判据第 3 条:记录的 field 在契约里的当前值要与 selected 对应的内容一致。
148
+ * 列表字段按"包含"判定:同一字段可以多轮追加,每条记录各自对应一个元素。
149
+ */
150
+ function fieldHolds(contract, field, text) {
151
+ if (text === null) return false;
152
+ if (field === 'permissions') return contract?.permissions?.mode === text;
153
+ if (field === 'objective') return contract?.objective === text;
154
+ if (field === 'acceptance') return (Array.isArray(contract?.acceptance) ? contract.acceptance : []).some((item) => item?.requirement === text);
155
+ if (field === 'scope.include') return (Array.isArray(contract?.scope?.include) ? contract.scope.include : []).includes(text);
156
+ if (field === 'scope.exclude') return (Array.isArray(contract?.scope?.exclude) ? contract.scope.exclude : []).includes(text);
157
+ if (field === 'stop_conditions') return (Array.isArray(contract?.stop_conditions) ? contract.stop_conditions : []).includes(text);
158
+ return false;
159
+ }
160
+
161
+ /**
162
+ * 三条完成判据,同时满足才允许冻结:
163
+ * 1. core/contract-substance.mjs 的创建入口判据 error 为零(warning 允许保留:用户可以明确回答"没有要排除的");
164
+ * 2. 每道必问题都有 source: "user" 的作答记录;scope.include / scope.exclude / stop_conditions
165
+ * 可以改由一条 user_deferred 的 assumption 满足,permissions / objective / acceptance 不行;
166
+ * 3. 每条作答记录的 field 在契约里的当前值与 selected 对应内容一致,每条 assumption 的字段当前值与
167
+ * assumed 逐项相等——事后手改字段而不更新记录则不成立。
168
+ * @param {any} contract
169
+ */
170
+ export function completion(contract) {
171
+ const report = contractSubstance(contract);
172
+ const state = readInterviewState(contract);
173
+ const missing = [];
174
+
175
+ for (const finding of report.errors) {
176
+ missing.push({ criterion: 'substance_error', field: routeFinding(finding), detail: finding });
177
+ }
178
+
179
+ const answeredFields = new Set();
180
+ for (const answer of state.answers) if (answer?.source === 'user' && typeof answer?.field === 'string') answeredFields.add(answer.field);
181
+ for (const assumption of state.assumptions) if (typeof assumption?.field === 'string' && DEFERRABLE_FIELDS.has(assumption.field)) answeredFields.add(assumption.field);
182
+ for (const field of requiredFields(contract)) {
183
+ if (!answeredFields.has(field)) {
184
+ const detail = DEFERRABLE_FIELDS.has(field)
185
+ ? `${field}:缺少 source: "user" 的作答记录,也没有 user_deferred 的 assumption`
186
+ : `${field}:缺少 source: "user" 的作答记录,该字段必须由用户在给出的选项中作答`;
187
+ missing.push({ criterion: 'missing_answer', field, detail });
188
+ }
189
+ }
190
+
191
+ state.assumptions.forEach((assumption, index) => {
192
+ const field = assumption?.field;
193
+ if (typeof field !== 'string' || !DEFERRABLE_FIELDS.has(field)) {
194
+ missing.push({ criterion: 'assumption_invalid', field: field ?? null, detail: `extensions.interview.assumptions[${index}].field = ${quote(field ?? null)}:该字段必须由用户在给出的选项中作答,不接受 assumption` });
195
+ return;
196
+ }
197
+ if (assumption?.reason !== 'user_deferred') {
198
+ missing.push({ criterion: 'assumption_invalid', field, detail: `extensions.interview.assumptions[${index}].reason = ${quote(assumption?.reason ?? null)}:只接受 "user_deferred"` });
199
+ return;
200
+ }
201
+ if (!assumptionHolds(contract, field, assumption.assumed)) {
202
+ missing.push({ criterion: 'assumption_field_mismatch', field, detail: `extensions.interview.assumptions[${index}]:假定内容 ${quote(assumption.assumed ?? null)} 与 ${field} 的当前值不一致,记录与契约已经脱钩` });
203
+ }
204
+ });
205
+
206
+ state.answers.forEach((answer, index) => {
207
+ if (answer?.source !== 'user' || typeof answer?.field !== 'string') {
208
+ missing.push({ criterion: 'answer_invalid', field: answer?.field ?? null, detail: `extensions.interview.answers[${index}]:source 必须是 "user",field 必须是字段路径` });
209
+ return;
210
+ }
211
+ const text = selectedText(answer);
212
+ if (text === null) {
213
+ missing.push({ criterion: 'answer_invalid', field: answer.field, detail: `extensions.interview.answers[${index}].selected 无法解析为选项内容` });
214
+ return;
215
+ }
216
+ if (!fieldHolds(contract, answer.field, text)) {
217
+ missing.push({ criterion: 'answer_field_mismatch', field: answer.field, detail: `extensions.interview.answers[${index}]:选中内容 ${quote(text)} 与 ${answer.field} 的当前值不一致,记录与契约已经脱钩` });
218
+ }
219
+ });
220
+
221
+ return { complete: missing.length === 0, missing, warnings: report.warnings, round: state.round };
222
+ }
223
+
224
+ /**
225
+ * 本轮该问哪些字段:必问题里尚未作答的,并上实质性判据仍在报的字段(含 warning 指向的字段)。
226
+ * 已有作答记录但判据仍然报错的字段会被重新问一遍——填了一轮文字不等于问完了。
227
+ * @param {any} contract
228
+ */
229
+ export function outstandingFields(contract) {
230
+ const state = readInterviewState(contract);
231
+ const report = contractSubstance(contract);
232
+ const answered = new Set();
233
+ for (const answer of state.answers) if (answer?.source === 'user' && typeof answer?.field === 'string') answered.add(answer.field);
234
+ for (const assumption of state.assumptions) if (typeof assumption?.field === 'string' && DEFERRABLE_FIELDS.has(assumption.field)) answered.add(assumption.field);
235
+
236
+ const pending = new Set(requiredFields(contract).filter((field) => !answered.has(field)));
237
+ // error 指向的字段一律重问:填了一轮文字不等于问完了,占位还在就说明这道题没答。
238
+ for (const finding of report.errors) { const field = routeFinding(finding); if (field) pending.add(field); }
239
+ // warning 指向的字段若已有作答记录就不再问:用户可以明确回答"没有要排除的",
240
+ // 这时"write 模式 exclude 为空"的 warning 仍在,但该字段已经问过了。
241
+ for (const finding of report.warnings) { const field = routeFinding(finding); if (field && !answered.has(field)) pending.add(field); }
242
+ return FIELD_ORDER.filter((field) => pending.has(field));
243
+ }
244
+
245
+ export class InterviewError extends Error {}
246
+
247
+ /**
248
+ * 出题:输入一份契约草稿(可以是原样 scaffold),输出本轮问题批。
249
+ * 选项槽是空的——命令不生成选项。
250
+ * @param {any} contract
251
+ */
252
+ export function ask(contract) {
253
+ const state = readInterviewState(contract);
254
+ const status = completion(contract);
255
+ if (state.round >= MAX_ROUNDS) {
256
+ throw new InterviewError(renderExhausted(status, state.round));
257
+ }
258
+ const fields = outstandingFields(contract).slice(0, MAX_QUESTIONS_PER_ROUND);
259
+ return {
260
+ schema_version: 1,
261
+ round: state.round + 1,
262
+ max_rounds: MAX_ROUNDS,
263
+ permissions_mode: contract?.permissions?.mode ?? null,
264
+ required_fields: requiredFields(contract),
265
+ complete: status.complete,
266
+ questions: fields.map((field) => ({
267
+ field,
268
+ question: FIELD_QUESTION[field],
269
+ field_semantics: FIELD_SEMANTICS[field],
270
+ // deferrable=false 的题只有一条路:用户在选项里选。模型不能替他记一条 assumption。
271
+ deferrable: DEFERRABLE_FIELDS.has(field),
272
+ options: [],
273
+ selected: null,
274
+ source: 'user',
275
+ })),
276
+ answer_spec: [...INTERVIEW_ANSWER_SPEC],
277
+ remaining_criteria: status.missing,
278
+ warnings: status.warnings,
279
+ };
280
+ }
281
+
282
+ /** 选项校验:只看形状,不对自然语言做判定。 */
283
+ function validateOptions(entry, index) {
284
+ const options = entry?.options;
285
+ if (!Array.isArray(options)) throw new InterviewError(`answers[${index}].options 缺失:每题必须带上当时给出的选项原文`);
286
+ if (options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
287
+ throw new InterviewError(`answers[${index}].options 有 ${options.length} 项:必须是 ${MIN_OPTIONS}–${MAX_OPTIONS} 个选项,0 或 1 个是开放式问题`);
288
+ }
289
+ const cleaned = options.map((option, position) => {
290
+ if (typeof option !== 'string' || !option.trim()) throw new InterviewError(`answers[${index}].options[${position}] 为空:选项必须是非空字符串`);
291
+ return option.trim();
292
+ });
293
+ if (new Set(cleaned).size !== cleaned.length) throw new InterviewError(`answers[${index}].options 存在重复项:选项必须互不相同,否则这道题没有在做选择`);
294
+ return options;
295
+ }
296
+
297
+ /**
298
+ * assumed 的形状:字符串数组,单条可直接写字符串。
299
+ * 空数组是合法的——它表示"没有要排除的 / 没有额外终止条件",这时 write 模式的 warning 保留。
300
+ */
301
+ function normalizeAssumed(entry, index) {
302
+ const raw = entry?.assumed;
303
+ const list = typeof raw === 'string' ? [raw] : raw;
304
+ if (!Array.isArray(list)) {
305
+ throw new InterviewError(`answers[${index}].assumed = ${quote(raw ?? null)}:deferred 必须写明替用户假定进该字段的内容,字符串数组;空数组表示"没有"`);
306
+ }
307
+ list.forEach((item, position) => {
308
+ if (typeof item !== 'string' || !item.trim()) throw new InterviewError(`answers[${index}].assumed[${position}] 为空:假定内容必须是非空字符串`);
309
+ });
310
+ if (new Set(list).size !== list.length) throw new InterviewError(`answers[${index}].assumed 存在重复项`);
311
+ return [...list];
312
+ }
313
+
314
+ /** deferred 整段替换字段:assumption 与字段是一一对应的,追加会让完成判据第 3 条无从判定。 */
315
+ function applyDeferral(contract, field, assumed) {
316
+ if (field === 'scope.include') { contract.scope.include = [...assumed]; return; }
317
+ if (field === 'scope.exclude') { contract.scope.exclude = [...assumed]; return; }
318
+ contract.stop_conditions = [...assumed];
319
+ }
320
+
321
+ /** permissions 是唯一取值受限的字段:它决定后续必问题清单,不能是自由文本。 */
322
+ function applyPermissions(contract, text, index) {
323
+ if (!['read_only', 'write'].includes(text)) {
324
+ throw new InterviewError(`answers[${index}] 选中 ${quote(text)}:permissions 的选项内容必须恰好是 permissions.mode 的取值之一,当前值为 ${quote(contract?.permissions?.mode ?? null)}`);
325
+ }
326
+ contract.permissions.mode = text;
327
+ if (text === 'read_only') contract.permissions.writable_paths = [];
328
+ }
329
+
330
+ /** 把选中的内容写进对应字段。列表字段追加,已存在则不重复写。 */
331
+ function applyToField(contract, field, text, index) {
332
+ if (field === 'permissions') return applyPermissions(contract, text, index);
333
+ if (field === 'objective') { contract.objective = text; return; }
334
+ if (field === 'acceptance') {
335
+ // 占位条目由 core/contract-substance.mjs 的判据识别,这里不自己比对字面量:
336
+ // 占位文本改了而这里没跟上,就会变成"在占位条目旁边再加一条",实质性判据依旧报错。
337
+ const placeholder = contract.acceptance.findIndex((item) => contractSubstance({ acceptance: [item] }).errors.length > 0);
338
+ if (placeholder >= 0) { contract.acceptance[placeholder].requirement = text; return; }
339
+ if (contract.acceptance.some((item) => item?.requirement === text)) return;
340
+ const ids = new Set(contract.acceptance.map((item) => item?.contract_item_id));
341
+ let ordinal = contract.acceptance.length + 1;
342
+ while (ids.has(`acceptance-${ordinal}`)) ordinal += 1;
343
+ contract.acceptance.push({ contract_item_id: `acceptance-${ordinal}`, requirement: text });
344
+ return;
345
+ }
346
+ if (field === 'scope.include') {
347
+ // 占位条目在 core/contract-substance.mjs 的判据里,这里靠它识别,不自己比对字面量。
348
+ const placeholders = contract.scope.include.filter((item) => contractSubstance({ scope: { include: [item] } }).errors.length > 0);
349
+ contract.scope.include = contract.scope.include.filter((item) => !placeholders.includes(item));
350
+ if (!contract.scope.include.includes(text)) contract.scope.include.push(text);
351
+ return;
352
+ }
353
+ if (field === 'scope.exclude') { if (!contract.scope.exclude.includes(text)) contract.scope.exclude.push(text); return; }
354
+ if (field === 'stop_conditions') { if (!contract.stop_conditions.includes(text)) contract.stop_conditions.push(text); return; }
355
+ throw new InterviewError(`answers[${index}].field = ${quote(field)}:不是本命令认识的字段路径,可选:${FIELD_ORDER.join(' / ')}`);
356
+ }
357
+
358
+ /**
359
+ * 回填:输入草稿 + 本轮作答,把选中内容写进对应字段,追加作答记录,重新校验。
360
+ * 返回更新后的草稿(未签名,签名交给调用方的 normalize)与下一批问题或冻结判定。
361
+ * @param {any} draft @param {any[]} answers
362
+ */
363
+ export function answer(draft, answers) {
364
+ const contract = structuredClone(draft);
365
+ const state = readInterviewState(contract);
366
+ if (state.round >= MAX_ROUNDS) {
367
+ throw new InterviewError(renderExhausted(completion(contract), state.round));
368
+ }
369
+ if (!Array.isArray(answers) || !answers.length) throw new InterviewError('本轮作答为空:--answers 必须是 answers 数组或 { answers: [...] }');
370
+ if (answers.length > MAX_QUESTIONS_PER_ROUND) throw new InterviewError(`本轮作答有 ${answers.length} 条:一轮最多 ${MAX_QUESTIONS_PER_ROUND} 题`);
371
+
372
+ const nextAnswers = [...state.answers.map((item) => structuredClone(item))];
373
+ const nextAssumptions = [...state.assumptions.map((item) => structuredClone(item))];
374
+ const seen = new Set();
375
+
376
+ answers.forEach((entry, index) => {
377
+ const field = entry?.field;
378
+ if (typeof field !== 'string' || !FIELD_ORDER.includes(field)) {
379
+ throw new InterviewError(`answers[${index}].field = ${quote(field ?? null)}:不是本命令认识的字段路径,可选:${FIELD_ORDER.join(' / ')}`);
380
+ }
381
+ if (seen.has(field)) throw new InterviewError(`answers[${index}].field = ${quote(field)}:同一轮里重复作答同一个字段`);
382
+ seen.add(field);
383
+ validateOptions(entry, index);
384
+
385
+ if (entry.deferred === true) {
386
+ if (!DEFERRABLE_FIELDS.has(field)) {
387
+ throw new InterviewError(`answers[${index}].field = ${quote(field)}:该字段必须由用户在给出的选项中作答,不接受 deferred;可以 deferred 的只有 ${[...DEFERRABLE_FIELDS].join(' / ')}`);
388
+ }
389
+ // 不替用户选,但也不让契约自相矛盾:assumed 原样写进字段。
390
+ // 执行方读的是契约字段而不是 extensions,字段为空、extensions 里却写着"假定排除 X",
391
+ // 是一份自己跟自己打架的契约。
392
+ const assumed = normalizeAssumed(entry, index);
393
+ applyDeferral(contract, field, assumed);
394
+ const record = { field, assumed, reason: 'user_deferred' };
395
+ const existing = nextAssumptions.findIndex((item) => item?.field === field);
396
+ if (existing >= 0) nextAssumptions[existing] = record; else nextAssumptions.push(record);
397
+ // 整段替换会冲掉此前作答写进该字段的值,留着旧记录会让完成判据第 3 条必然不成立。
398
+ for (let position = nextAnswers.length - 1; position >= 0; position -= 1) if (nextAnswers[position]?.field === field) nextAnswers.splice(position, 1);
399
+ return;
400
+ }
401
+
402
+ if (entry.source !== 'user') throw new InterviewError(`answers[${index}].source = ${quote(entry?.source ?? null)}:只接受 "user";命令检查不了它的真伪,但不接受别的取值`);
403
+ const text = selectedText(entry);
404
+ if (text === null) {
405
+ throw new InterviewError(`answers[${index}].selected = ${quote(entry?.selected ?? null)}:必须是 options 的下标,或 "custom" 配非空 custom_value`);
406
+ }
407
+ applyToField(contract, field, text, index);
408
+ const record = { field, options: entry.options.map((option) => option), selected: entry.selected, source: 'user' };
409
+ if (entry.selected === 'custom') record.custom_value = text;
410
+ // 单值字段再次作答替换旧记录:两条记录只有一条能与字段当前值一致,留着另一条只会让判据 3 永远不成立。
411
+ const duplicate = SINGLE_VALUED.has(field) ? nextAnswers.findIndex((item) => item?.field === field) : -1;
412
+ if (duplicate >= 0) nextAnswers[duplicate] = record; else nextAnswers.push(record);
413
+ // 字段被真正作答后,之前的 deferral 不再成立。
414
+ const deferred = nextAssumptions.findIndex((item) => item?.field === field);
415
+ if (deferred >= 0) nextAssumptions.splice(deferred, 1);
416
+ });
417
+
418
+ contract.extensions.interview = { schema_version: 1, round: state.round + 1, answers: nextAnswers, assumptions: nextAssumptions };
419
+ const status = completion(contract);
420
+ if (!status.complete && contract.extensions.interview.round >= MAX_ROUNDS) {
421
+ throw new InterviewError(renderExhausted(status, contract.extensions.interview.round));
422
+ }
423
+ return { contract, status };
424
+ }
425
+
426
+ /** @param {{ missing: { criterion: string, field: string|null, detail: string }[] }} status @param {number} round */
427
+ function renderExhausted(status, round) {
428
+ const lines = status.missing.map((item) => `- ${item.detail}`);
429
+ return [
430
+ `interview 已用满 ${round}/${MAX_ROUNDS} 轮,完成判据仍未满足(${status.missing.length} 项):`,
431
+ ...lines,
432
+ '建议把任务拆开:一份契约要问到第 4 轮还定不下来,通常说明它同时在做两件事。',
433
+ ].join('\n');
434
+ }
435
+
436
+ /**
437
+ * 冻结判定。满足三条完成判据才返回,否则抛出并列出仍缺的判据。
438
+ * 真正的签名交给调用方的 normalize:摘要口径只有一个出处。
439
+ * @param {any} contract
440
+ */
441
+ export function assertFreezable(contract) {
442
+ const status = completion(contract);
443
+ if (status.complete) return status;
444
+ throw new InterviewError([
445
+ `interview 完成判据未满足(${status.missing.length} 项),不能冻结:`,
446
+ ...status.missing.map((item) => `- ${item.detail}`),
447
+ ].join('\n'));
448
+ }
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { createHash, randomBytes } from 'node:crypto';
4
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { realpathSync } from 'node:fs';
7
7
  import { resolve } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { isHelpRequest, renderCliHelp } from '../../core/cli-help.mjs';
10
10
  import { createDigestKit } from '../../core/digest.mjs';
11
+ import { contractSubstance, formatSubstanceErrors } from '../../core/contract-substance.mjs';
12
+ import { buildScaffoldContract } from '../../core/contract-scaffold.mjs';
13
+ import { ORCHESTRATION_RUNTIME_VERSION, skillContentDigest } from './orchestration-metadata.mjs';
14
+ import { InterviewError, MAX_ROUNDS, answer as interviewAnswer, ask as interviewAsk, assertFreezable } from './contract-interview.mjs';
11
15
 
12
16
  export class ContractError extends Error {}
13
17
 
@@ -47,7 +51,13 @@ class Parser {
47
51
  }
48
52
 
49
53
  export function parseJsonStrict(text) { return new Parser(text).parse(); }
50
- export function validateContract(contract, { requireDigest = true } = {}) {
54
+ // substance 只由创建入口打开(contract validate、ledger init)。add-node、doctor、投影等
55
+ // 在已冻结契约上的操作保持形状校验:契约冻结后不可变,实质性只在冻结那一刻判定一次;
56
+ // doctor 这类只读回看路径还会读到判据出现之前冻结的 ledger,在那里拒绝等于让历史结论随
57
+ // runtime 版本变化。
58
+ // warnings 是出参数组:warning 不改变 valid 结论也不改变退出码,只能由调用方带进输出,
59
+ // 因此不能走抛异常这条路,也不适合改 validateContract 的返回值(返回的是契约本身)。
60
+ export function validateContract(contract, { requireDigest = true, substance = false, warnings = null } = {}) {
51
61
  const required = ['schema_version', 'contract_id', 'objective', 'scope', 'acceptance', 'permissions', 'environment', 'skill_set', 'stop_conditions', 'extensions'];
52
62
  if (contract?.schema_version !== 1) throw new ContractError('Task Contract schema_version 必须为 1');
53
63
  for (const field of required) if (!Object.hasOwn(contract, field)) throw new ContractError(`Task Contract 缺少 ${field}`);
@@ -87,6 +97,11 @@ export function validateContract(contract, { requireDigest = true } = {}) {
87
97
  const common = new Set(required.concat('contract_digest'));
88
98
  for (const key of Object.keys(contract.extensions)) if (common.has(key)) throw new ContractError(`extension 覆盖公共字段: ${key}`);
89
99
  if (requireDigest && (!/^sha256:[0-9a-f]{64}$/u.test(String(contract.contract_digest ?? '')) || envelopeDigest(contract) !== contract.contract_digest)) throw new ContractError('contract_digest 无效');
100
+ if (substance) {
101
+ const report = contractSubstance(contract);
102
+ if (report.errors.length) throw new ContractError(formatSubstanceErrors(report.errors));
103
+ if (warnings) warnings.push(...report.warnings);
104
+ }
90
105
  return contract;
91
106
  }
92
107
 
@@ -122,6 +137,21 @@ export function projectContract(parent, itemIds, { contractId = null } = {}) {
122
137
  return validateContract(projected);
123
138
  }
124
139
 
140
+ // scaffold 别名:骨架本身住在 core/contract-scaffold.mjs,与 verify scaffold --kind contract 同源。
141
+ // 两边只有 skill_set 不同——各自冻结自己域的 content digest:ledger init 要求契约里有当前
142
+ // orchestrate-subagents 的摘要,verify 侧则绑 verify-agent-output。把对方的摘要算进来就得跨域取路径,
143
+ // 所以这一条差异是有意的,其余字段逐字段一致,由测试钉住。
144
+ export function scaffoldContract({ workdir = process.cwd() } = {}) {
145
+ const contract = buildScaffoldContract({
146
+ workdir,
147
+ contractId: randomUUID(),
148
+ skillSet: [{ name: 'orchestrate-subagents', version: ORCHESTRATION_RUNTIME_VERSION, content_digest: skillContentDigest(), provider_mode: 'primary' }],
149
+ });
150
+ validateContract(contract, { requireDigest: false });
151
+ contract.contract_digest = envelopeDigest(contract);
152
+ return contract;
153
+ }
154
+
125
155
  export function contractDiff(left, right) {
126
156
  const changed = [];
127
157
  for (const key of [...new Set([...Object.keys(left), ...Object.keys(right)])].sort()) if (canonicalJson(left[key]) !== canonicalJson(right[key])) changed.push(key);
@@ -131,27 +161,68 @@ export function contractDiff(left, right) {
131
161
 
132
162
  // CLI 命令与参数的唯一真源:`--help` 清单和未知命令错误信息都从这里推导。
133
163
  const CLI_SPEC = {
164
+ scaffold: { optional: ['workdir'] },
134
165
  normalize: { required: ['input'] },
135
166
  validate: { required: ['input'] },
136
167
  digest: { required: ['input'] },
137
168
  'review-view': { required: ['input'] },
138
169
  diff: { required: ['left', 'right'] },
139
170
  project: { required: ['input', 'items'], optional: ['contract-id'] },
171
+ 'interview-ask': { required: ['input'] },
172
+ 'interview-answer': { required: ['input', 'answers'] },
173
+ 'interview-freeze': { required: ['input'] },
140
174
  capabilities: {},
141
175
  };
142
- const CLI_NOTES = ['--items 是逗号分隔的 acceptance contract_item_id 列表,投影合同只能收窄这些条目。'];
176
+ const CLI_NOTES = [
177
+ '--items 是逗号分隔的 acceptance contract_item_id 列表,投影合同只能收窄这些条目。',
178
+ 'interview 是"多次调用、文件往返"的状态机:ask 出题 → 你把选项填进题目并交给用户选 → answer 回填 → freeze 冻结。',
179
+ 'interview-answer 的 --answers 是 { "answers": [...] } 或裸数组;每题 2–4 个互不相同的非空选项,selected 为下标或 "custom"。',
180
+ 'permissions / objective / acceptance 必须由用户在选项中作答;只有 scope.include / scope.exclude / stop_conditions 可以 deferred,assumed 会原样写进字段。',
181
+ '轮次与作答记录写在契约草稿自己的 extensions.interview 里,会进入 contract_digest;上限 3 轮。',
182
+ '用法与完成判据见 agentkit docs orchestrate contract-interview。',
183
+ ];
143
184
  function parseCli(argv) { const command = argv[0]; const options = {}; for (let i = 1; i < argv.length; i += 2) { if (!argv[i]?.startsWith('--') || argv[i + 1] === undefined) throw new ContractError('参数必须是 --name value'); options[argv[i].slice(2)] = argv[i + 1]; } return { command, options }; }
144
185
  function read(path) { return parseJsonStrict(readFileSync(resolve(path), 'utf8')); }
145
186
  export function main(argv = process.argv.slice(2)) {
146
187
  if (isHelpRequest(argv)) return { help: renderCliHelp('contract-tool.mjs', CLI_SPEC, CLI_NOTES) };
147
188
  const { command, options } = parseCli(argv);
189
+ if (command === 'scaffold') return scaffoldContract({ workdir: options.workdir ?? process.cwd() });
148
190
  if (command === 'normalize') return normalizeContract(read(options.input));
149
- if (command === 'validate') { const value = validateContract(read(options.input)); return { valid: true, contract_id: value.contract_id, contract_digest: value.contract_digest }; }
191
+ if (command === 'validate') { const warnings = []; const value = validateContract(read(options.input), { substance: true, warnings }); return { valid: true, contract_id: value.contract_id, contract_digest: value.contract_digest, ...(warnings.length ? { warnings } : {}) }; }
150
192
  if (command === 'digest') return { contract_digest: envelopeDigest(read(options.input)) };
151
193
  if (command === 'review-view') { const value = validateContract(read(options.input)); return { schema_version: 1, contract_id: value.contract_id, objective: value.objective, scope: value.scope, acceptance: value.acceptance, contract_permissions: value.permissions, reviewer_permissions: { mode: 'read_only', writable_paths: [] }, environment: value.environment, contract_digest: value.contract_digest }; }
152
194
  if (command === 'diff') return contractDiff(read(options.left), read(options.right));
153
195
  if (command === 'project') return projectContract(read(options.input), String(options.items ?? '').split(',').map((item) => item.trim()).filter(Boolean), { contractId: options['contract-id'] ?? null });
154
- if (command === 'capabilities') return { tool: 'contract-tool', runtime_version: '1.1.0', task_contract_versions: [1], features: ['strict-json', 'canonical-digest', 'review-view', 'resign-diff', 'contract-projection'] };
196
+ // interview 的三个入口都先做形状校验、不要求 digest:草稿在往返途中是未签名的,
197
+ // 只有 freeze 那一次才重新签名。实质性判据由 interview 自己按完成判据取用,不在这里提前拒绝。
198
+ if (command === 'interview-ask') { const draft = read(options.input); validateContract(draft, { requireDigest: false }); return interviewAsk(draft); }
199
+ if (command === 'interview-answer') {
200
+ const draft = read(options.input);
201
+ validateContract(draft, { requireDigest: false });
202
+ const payload = read(options.answers);
203
+ const entries = Array.isArray(payload) ? payload : payload?.answers;
204
+ const { contract, status } = interviewAnswer(draft, entries);
205
+ const signed = normalizeContract(contract);
206
+ return {
207
+ round: signed.extensions.interview.round,
208
+ max_rounds: MAX_ROUNDS,
209
+ complete: status.complete,
210
+ remaining_criteria: status.missing,
211
+ warnings: status.warnings,
212
+ next: status.complete ? null : interviewAsk(signed),
213
+ contract: signed,
214
+ };
215
+ }
216
+ if (command === 'interview-freeze') {
217
+ const draft = read(options.input);
218
+ validateContract(draft, { requireDigest: false });
219
+ assertFreezable(draft);
220
+ const frozen = normalizeContract(draft);
221
+ const warnings = [];
222
+ validateContract(frozen, { substance: true, warnings });
223
+ return { frozen: true, contract_id: frozen.contract_id, contract_digest: frozen.contract_digest, ...(warnings.length ? { warnings } : {}), contract: frozen };
224
+ }
225
+ if (command === 'capabilities') return { tool: 'contract-tool', runtime_version: '1.2.0', task_contract_versions: [1], features: ['strict-json', 'canonical-digest', 'review-view', 'resign-diff', 'contract-projection', 'contract-scaffold', 'contract-interview'] };
155
226
  throw new ContractError(`命令必须是 ${Object.keys(CLI_SPEC).join('/')}`);
156
227
  }
157
228
 
@@ -162,7 +233,8 @@ export function runCli(argv = process.argv.slice(2)) {
162
233
  process.stdout.write(typeof result?.help === 'string' ? result.help : `${JSON.stringify(result, null, 2)}\n`);
163
234
  return 0;
164
235
  } catch (error) {
165
- process.stderr.write(`${JSON.stringify({ error: 'invalid_contract', message: error.message })}\n`);
236
+ // interview 的拒绝不是"契约非法",而是"还没问完 / 这轮作答不合规",单独标记以便调用方分流。
237
+ process.stderr.write(`${JSON.stringify({ error: error instanceof InterviewError ? 'interview_rejected' : 'invalid_contract', message: error.message })}\n`);
166
238
  return 2;
167
239
  }
168
240
  }