@cr1992/agentkit 1.0.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +107 -0
  4. package/README.md +103 -0
  5. package/bin/agentkit.mjs +4 -0
  6. package/bin/cli.mjs +273 -0
  7. package/core/atomic-fs.mjs +23 -0
  8. package/core/cli-help.mjs +54 -0
  9. package/core/content-digest.mjs +66 -0
  10. package/core/digest.mjs +67 -0
  11. package/core/json-schema-lite.mjs +60 -0
  12. package/core/legacy-entry.mjs +37 -0
  13. package/core/reflection.mjs +142 -0
  14. package/core/runtime-bundle.mjs +101 -0
  15. package/docs/loop/embedded-review-adapter.md +41 -0
  16. package/docs/loop/loop-state-machine.md +43 -0
  17. package/docs/loop/recovery-and-fuses.md +34 -0
  18. package/docs/orchestrate/dispatch-contract.md +92 -0
  19. package/docs/orchestrate/failure-routing-and-recovery.md +46 -0
  20. package/docs/orchestrate/host-capability-cache.md +170 -0
  21. package/docs/orchestrate/isolation-fallback.md +18 -0
  22. package/docs/orchestrate/model-routing-config.md +186 -0
  23. package/docs/orchestrate/orchestration-runtime.md +261 -0
  24. package/docs/orchestrate/review-budget.md +90 -0
  25. package/docs/orchestrate/task-playbooks.md +85 -0
  26. package/docs/orchestrate/user-facing-reporting.md +14 -0
  27. package/docs/verify/evidence-schema.md +167 -0
  28. package/docs/verify/input-preparation.md +44 -0
  29. package/docs/verify/verification-protocol.md +76 -0
  30. package/docs/worktree/batch-integration.md +176 -0
  31. package/docs/worktree/delivery-identity.md +41 -0
  32. package/docs/worktree/profile.md +107 -0
  33. package/docs/worktree/reclaim-and-watch.md +96 -0
  34. package/docs/worktree/review-lifecycle.md +92 -0
  35. package/docs/worktree/spawn-and-stack.md +74 -0
  36. package/domains/loop/loop-runtime.mjs +1056 -0
  37. package/domains/orchestrate/contract-tool.mjs +169 -0
  38. package/domains/orchestrate/host_capability_cache.mjs +437 -0
  39. package/domains/orchestrate/orchestration-ledger.mjs +332 -0
  40. package/domains/orchestrate/orchestration-metadata.mjs +4 -0
  41. package/domains/orchestrate/orchestration-reflection.mjs +119 -0
  42. package/domains/orchestrate/resolve_model_policy.mjs +311 -0
  43. package/domains/orchestrate/review-budget.mjs +162 -0
  44. package/domains/orchestrate/worker-capability-preflight.mjs +227 -0
  45. package/domains/verify/verification-runtime.mjs +1638 -0
  46. package/domains/worktree/worktree-archive.mjs +135 -0
  47. package/domains/worktree/worktree-artifact.mjs +123 -0
  48. package/domains/worktree/worktree-batch-integrate.mjs +713 -0
  49. package/domains/worktree/worktree-batch-plan.mjs +198 -0
  50. package/domains/worktree/worktree-batch-result.mjs +241 -0
  51. package/domains/worktree/worktree-core.mjs +908 -0
  52. package/domains/worktree/worktree-doctor.mjs +493 -0
  53. package/domains/worktree/worktree-history.mjs +377 -0
  54. package/domains/worktree/worktree-learning.mjs +110 -0
  55. package/domains/worktree/worktree-lifecycle.mjs +786 -0
  56. package/domains/worktree/worktree-merge-preview.mjs +409 -0
  57. package/domains/worktree/worktree-mgr.mjs +261 -0
  58. package/domains/worktree/worktree-process.mjs +55 -0
  59. package/domains/worktree/worktree-profile.mjs +800 -0
  60. package/domains/worktree/worktree-provider-gitlab.mjs +59 -0
  61. package/domains/worktree/worktree-reclaim.mjs +683 -0
  62. package/domains/worktree/worktree-review-refresh.mjs +574 -0
  63. package/domains/worktree/worktree-review-watch.mjs +661 -0
  64. package/domains/worktree/worktree-scan.mjs +510 -0
  65. package/domains/worktree/worktree-trace-test-worker.mjs +23 -0
  66. package/domains/worktree/worktree-trace.mjs +478 -0
  67. package/manage-worktrees/SKILL.md +87 -0
  68. package/manage-worktrees/agents/openai.yaml +4 -0
  69. package/manage-worktrees/scripts/worktree-mgr.mjs +10 -0
  70. package/manage-worktrees/scripts/worktree-scan.mjs +10 -0
  71. package/orchestrate-subagents/SKILL.md +173 -0
  72. package/orchestrate-subagents/agents/openai.yaml +4 -0
  73. package/orchestrate-subagents/scripts/contract-tool.mjs +10 -0
  74. package/orchestrate-subagents/scripts/host_capability_cache.mjs +10 -0
  75. package/orchestrate-subagents/scripts/orchestration-ledger.mjs +10 -0
  76. package/orchestrate-subagents/scripts/orchestration-reflection.mjs +10 -0
  77. package/orchestrate-subagents/scripts/resolve_model_policy.mjs +10 -0
  78. package/orchestrate-subagents/scripts/review-budget.mjs +10 -0
  79. package/orchestrate-subagents/scripts/worker-capability-preflight.mjs +10 -0
  80. package/package.json +48 -0
  81. package/run-agent-verify-loop/SKILL.md +127 -0
  82. package/run-agent-verify-loop/agents/openai.yaml +4 -0
  83. package/run-agent-verify-loop/scripts/loop-runtime.mjs +10 -0
  84. package/schemas/artifact-ref-v1.schema.json +23 -0
  85. package/schemas/batch-result-v1.schema.json +138 -0
  86. package/schemas/controller-recheck-record-v1.schema.json +22 -0
  87. package/schemas/convergence-report-v1.schema.json +9 -0
  88. package/schemas/effective-worker-capability-v1.schema.json +36 -0
  89. package/schemas/embedded-verification-record-v1.schema.json +32 -0
  90. package/schemas/evidence-package-v1.schema.json +41 -0
  91. package/schemas/improvement-proposal-v1.schema.json +18 -0
  92. package/schemas/loop-state-v1.schema.json +34 -0
  93. package/schemas/model-policy-resolution-v1.schema.json +41 -0
  94. package/schemas/orchestration-ledger-v1.schema.json +110 -0
  95. package/schemas/reflection-record-v1.schema.json +24 -0
  96. package/schemas/review-result-v1.schema.json +37 -0
  97. package/schemas/task-contract-v1.schema.json +83 -0
  98. package/schemas/verification-profile-v1.schema.json +60 -0
  99. package/schemas/worker-capability-requirements-v1.schema.json +21 -0
  100. package/schemas/worktree-binding-v1.schema.json +14 -0
  101. package/shell-manifest.json +79 -0
  102. package/verify-agent-output/SKILL.md +119 -0
  103. package/verify-agent-output/agents/openai.yaml +4 -0
  104. package/verify-agent-output/scripts/verification-runtime.mjs +10 -0
@@ -0,0 +1,713 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @param {Record<string, any>} deps
5
+ */
6
+ export function createCommands(deps) {
7
+ const {
8
+ spawn,
9
+ existsSync,
10
+ readFileSync,
11
+ loadRepositoryProfile,
12
+ validateTaskNaming,
13
+ validateTaskSlug,
14
+ WorktreeTraceError,
15
+ runFileCapture,
16
+ PREFIX,
17
+ SUBMIT_PUSH_TIMEOUT_MS,
18
+ git,
19
+ gitTry,
20
+ commandFailureReason,
21
+ log,
22
+ die,
23
+ flag,
24
+ rejectUnknownFlags,
25
+ resolveIdentity,
26
+ loadRecords,
27
+ isActiveRecord,
28
+ coexistingSessionRecords,
29
+ liveGitSnapshot,
30
+ updateRecord,
31
+ gitOperationState,
32
+ isAncestor,
33
+ cmdSpawn,
34
+ computeBatchPlan,
35
+ } = deps;
36
+
37
+ /**
38
+ * 在一次性集成候选树上开启 rerere,让多轮候选之间同一冲突自动重放已录解法。
39
+ *
40
+ * 作用域是 worktree 级(`git config --worktree`),不写全局、也不改其他 worktree 的行为;
41
+ * 但 Git 的 rerere **解法缓存位于共享 common dir 的 `rr-cache/`**,因此同一仓库任何
42
+ * worktree 录下的解法都会被这里复用——这正是「上一轮候选解过的冲突,下一轮不再手解」
43
+ * 的机制,同时也意味着一个错误解法会跨候选扩散,需要时用 `git rerere forget <path>` 清除。
44
+ *
45
+ * `--worktree` 依赖仓库 `extensions.worktreeConfig`。未启用时按 Git 的要求先确认
46
+ * `core.bare` / `core.worktree` 仍是默认值再启用;否则 fail-open 成「不启用 rerere」
47
+ * 并回报原因,绝不擅自搬动这两个键。
48
+ * @param {ReturnType<typeof loadRepositoryProfile>} loaded
49
+ * @param {string} candidatePath
50
+ */
51
+ function enableCandidateRerere(loaded, candidatePath) {
52
+ const read = (key, cwd) => {
53
+ const got = gitTry(['config', '--get', key], cwd);
54
+ return got.ok ? got.out : null;
55
+ };
56
+ const readBool = (key, cwd) => {
57
+ const got = gitTry(['config', '--bool', '--get', key], cwd);
58
+ if (!got.ok) return null;
59
+ return got.out === 'true';
60
+ };
61
+ const inherited = {
62
+ enabled: readBool('rerere.enabled', candidatePath) === true,
63
+ autoUpdate: readBool('rerere.autoUpdate', candidatePath) === true,
64
+ };
65
+ // 必须两项都已生效才算「继承即可」。只有 rerere.enabled 而没有 autoUpdate 时,rerere 会把
66
+ // 已录解法写回工作区却**不更新 index**,冲突路径依然是 unmerged,合成循环会把它当成真冲突,
67
+ // 重放形同失效。因此这里不能因为 enabled 已是 true 就提前返回。
68
+ if (inherited.enabled && inherited.autoUpdate) {
69
+ return {
70
+ enabled: true,
71
+ auto_update: true,
72
+ scope: 'inherited',
73
+ worktree_config_extension: 'not_needed',
74
+ worktree_config_previous: read('extensions.worktreeConfig', loaded.context.primary_worktree),
75
+ reason: null,
76
+ };
77
+ }
78
+ const partialScope = inherited.enabled ? 'inherited-partial' : 'none';
79
+ const primary = loaded.context.primary_worktree;
80
+ const extensionPrevious = read('extensions.worktreeConfig', primary);
81
+ let extensionProvenance = 'already_enabled';
82
+ if (readBool('extensions.worktreeConfig', primary) !== true) {
83
+ const bare = readBool('core.bare', primary);
84
+ const coreWorktree = read('core.worktree', primary);
85
+ if (bare === true || (coreWorktree !== null && coreWorktree !== '')) {
86
+ return {
87
+ enabled: inherited.enabled,
88
+ auto_update: inherited.autoUpdate,
89
+ scope: partialScope,
90
+ worktree_config_extension: 'refused',
91
+ worktree_config_previous: extensionPrevious,
92
+ reason: 'core.bare/core.worktree 非默认值;按 Git 要求需人工先迁移这两个键,拒绝自动启用 extensions.worktreeConfig。',
93
+ };
94
+ }
95
+ const enabled = gitTry(['config', 'extensions.worktreeConfig', 'true'], primary);
96
+ if (!enabled.ok) {
97
+ return {
98
+ enabled: inherited.enabled,
99
+ auto_update: inherited.autoUpdate,
100
+ scope: partialScope,
101
+ worktree_config_extension: 'failed',
102
+ worktree_config_previous: extensionPrevious,
103
+ reason: commandFailureReason(enabled, '无法启用 extensions.worktreeConfig'),
104
+ };
105
+ }
106
+ extensionProvenance = 'enabled_by_this_command';
107
+ }
108
+ for (const [key, value] of [['rerere.enabled', 'true'], ['rerere.autoUpdate', 'true']]) {
109
+ const set = gitTry(['config', '--worktree', key, value], candidatePath);
110
+ if (!set.ok) {
111
+ return {
112
+ enabled: false,
113
+ auto_update: false,
114
+ scope: 'none',
115
+ worktree_config_extension: extensionProvenance,
116
+ worktree_config_previous: extensionPrevious,
117
+ reason: commandFailureReason(set, `无法设置 ${key}`),
118
+ };
119
+ }
120
+ }
121
+ return {
122
+ enabled: true,
123
+ auto_update: true,
124
+ scope: 'worktree',
125
+ worktree_config_extension: extensionProvenance,
126
+ worktree_config_previous: extensionPrevious,
127
+ reason: null,
128
+ };
129
+ }
130
+
131
+ /** @param {string} cwd */
132
+ function unmergedPaths(cwd) {
133
+ const unmerged = gitTry(['diff', '--name-only', '--diff-filter=U'], cwd);
134
+ return unmerged.ok && unmerged.out ? unmerged.out.split('\n').filter(Boolean) : [];
135
+ }
136
+
137
+ /** @param {string} path */
138
+ function readBatchPlanFile(path) {
139
+ if (!existsSync(path)) die(`--plan 文件不存在: ${path}`, 2);
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
143
+ } catch (error) {
144
+ die(`--plan 文件不是合法 JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
145
+ }
146
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) die('--plan 文件根节点必须是 object。', 2);
147
+ if (parsed.schema_version !== 1) die(`--plan schema_version 必须是 1,当前 ${parsed.schema_version}。`, 2);
148
+ if (parsed.ready !== true) die('--plan 是未就绪计划(ready=false);请先让 plan-batch 返回 ready=true。', 2);
149
+ if (!parsed.fingerprint || !parsed.target?.sha || !parsed.target?.ref) die('--plan 缺少 fingerprint 或 target。', 2);
150
+ if (!Array.isArray(parsed.included) || parsed.included.length === 0) die('--plan 缺少 included 输入。', 2);
151
+ for (const item of parsed.included) {
152
+ if (!item?.worktree_id || !item?.head) die('--plan included 每项都需要 worktree_id 与 head。', 2);
153
+ }
154
+ if (parsed.excluded !== undefined && !Array.isArray(parsed.excluded)) die('--plan excluded 必须是 array。', 2);
155
+ for (const item of parsed.excluded ?? []) {
156
+ if (!item?.worktree_id) die('--plan excluded 每项都需要 worktree_id。', 2);
157
+ }
158
+ if (parsed.requested_selectors !== undefined) {
159
+ if (!Array.isArray(parsed.requested_selectors) || parsed.requested_selectors.some((item) => typeof item !== 'string' || !item)) {
160
+ die('--plan requested_selectors 必须是非空字符串数组。', 2);
161
+ }
162
+ }
163
+ return parsed;
164
+ }
165
+
166
+ /**
167
+ * 冻结计划只有在 target 与全部输入 HEAD 都没漂移时才可继续合成。
168
+ * 任何一处变化都必须回到 plan-batch 重新冻结,而不是就地放行。
169
+ * @param {Record<string,any>} frozen
170
+ * @param {ReturnType<typeof computeBatchPlan>} fresh
171
+ */
172
+ function assertBatchPlanFresh(frozen, fresh) {
173
+ const drifts = [];
174
+ if (frozen.repository_id && fresh.repository_id && frozen.repository_id !== fresh.repository_id) {
175
+ drifts.push(`repository_id: plan=${frozen.repository_id} live=${fresh.repository_id}`);
176
+ }
177
+ if (frozen.target.ref !== fresh.target.ref) drifts.push(`target ref: plan=${frozen.target.ref} live=${fresh.target.ref}`);
178
+ if (frozen.target.sha !== fresh.target.sha) drifts.push(`target SHA: plan=${frozen.target.sha} live=${fresh.target.sha}`);
179
+ const frozenInputs = frozen.included.map((item) => `${item.worktree_id}@${item.head}`);
180
+ const freshInputs = fresh.included.map((item) => `${item.worktree_id}@${item.head}`);
181
+ if (frozenInputs.join(',') !== freshInputs.join(',')) {
182
+ drifts.push(`有序输入: plan=[${frozenInputs.join(' ')}] live=[${freshInputs.join(' ')}]`);
183
+ }
184
+ // 被折叠(COVERED_BY_DESCENDANT / DUPLICATE_HEAD)或已在 target 的输入同样是批次决策的一部分:
185
+ // 它们后来前进、或不再被覆盖,都意味着这份计划描述的合成边界已经变了。不比对这一段,
186
+ // 「父分支被折叠后又推了新提交」会在指纹不变的假象下被静默漏出合成结果。
187
+ const summarizeExcluded = (plan) => (plan.excluded ?? [])
188
+ .map((item) => `${item.worktree_id}@${item.state}@${item.head ?? 'null'}`)
189
+ .sort()
190
+ .join(',');
191
+ const frozenExcluded = summarizeExcluded(frozen);
192
+ const freshExcluded = summarizeExcluded(fresh);
193
+ if (frozenExcluded !== freshExcluded) {
194
+ drifts.push(`折叠/已合入输入: plan=[${frozenExcluded}] live=[${freshExcluded}]`);
195
+ }
196
+ if (fresh.blockers.length > 0) {
197
+ drifts.push(`重算出现 blocker: ${fresh.blockers.map((item) => `${item.task ?? '-'}:${item.code}`).join(' ')}`);
198
+ }
199
+ if (frozen.fingerprint !== fresh.fingerprint) {
200
+ drifts.push(`fingerprint: plan=${frozen.fingerprint} live=${fresh.fingerprint}`);
201
+ }
202
+ if (drifts.length > 0) {
203
+ die(
204
+ `BATCH_PLAN_STALE: 冻结计划与当前仓库状态不一致,拒绝按旧计划合成。\n - ${drifts.join('\n - ')}\n` +
205
+ '请重新执行 plan-batch 冻结新计划(新指纹会自动走替代登记),不要在旧计划上继续。',
206
+ );
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 在候选树上按冻结顺序 merge 精确 SHA。冲突时 fail-closed:停在冲突处、不自动解、不自动 abort。
212
+ * @param {Record<string,any>} record
213
+ * @param {ReturnType<typeof computeBatchPlan>} plan
214
+ * @param {{abortOnConflict:boolean,recomposeExpectedHead:string|null}} options
215
+ */
216
+ function composeBatchCandidate(record, plan, options) {
217
+ const path = record.path;
218
+ const liveHead = gitTry(['rev-parse', 'HEAD'], path);
219
+ if (!liveHead.ok) die(`无法读取候选树 HEAD: ${path}`);
220
+ if (options.recomposeExpectedHead && liveHead.out !== options.recomposeExpectedHead) {
221
+ die(
222
+ `RECOMPOSE_HEAD_STALE: 候选 HEAD 已从授权值 ${options.recomposeExpectedHead} 变化为 ${liveHead.out},拒绝重置。\n` +
223
+ '请重新核对候选树并用当前完整 HEAD 重新授权。',
224
+ );
225
+ }
226
+ if (liveHead.out !== plan.target.sha) {
227
+ const reset = gitTry(['reset', '--hard', plan.target.sha], path);
228
+ if (!reset.ok) die(`无法把候选树重置到批次 target: ${commandFailureReason(reset, 'reset 失败')}`);
229
+ }
230
+
231
+ const steps = [];
232
+ for (const item of plan.included) {
233
+ const message = `integrate(batch): ${item.task} ${item.head.slice(0, 12)}`;
234
+ const merged = runFileCapture(
235
+ 'git',
236
+ ['merge', '--no-ff', '--no-edit', '-m', message, item.head],
237
+ { cwd: path, timeoutMs: SUBMIT_PUSH_TIMEOUT_MS },
238
+ );
239
+ if (!merged.ok) {
240
+ const unresolved = unmergedPaths(path);
241
+ // rerere 重放了全部解法时只差落 commit:这正是多轮候选免于重复手解的收口。
242
+ if (unresolved.length === 0 && gitOperationState(path) === 'merge_head') {
243
+ const committed = gitTry(['commit', '--no-edit'], path);
244
+ if (committed.ok) {
245
+ steps.push({
246
+ task: item.task,
247
+ worktree_id: item.worktree_id,
248
+ input_sha: item.head,
249
+ merge_commit: gitTry(['rev-parse', 'HEAD'], path).out || null,
250
+ rerere_replayed: true,
251
+ });
252
+ continue;
253
+ }
254
+ }
255
+ const conflict = {
256
+ task: item.task,
257
+ worktree_id: item.worktree_id,
258
+ input_sha: item.head,
259
+ onto_sha: liveHead.out === plan.target.sha && steps.length === 0
260
+ ? plan.target.sha
261
+ : (steps.at(-1)?.merge_commit ?? plan.target.sha),
262
+ files: unresolved,
263
+ candidate_path: path,
264
+ detail: (merged.out || '').slice(0, 1000),
265
+ aborted: false,
266
+ };
267
+ if (options.abortOnConflict) {
268
+ gitTry(['merge', '--abort'], path);
269
+ const reset = gitTry(['reset', '--hard', plan.target.sha], path);
270
+ conflict.aborted = reset.ok;
271
+ }
272
+ return { state: 'conflict', steps, conflict, composed_sha: null };
273
+ }
274
+ steps.push({
275
+ task: item.task,
276
+ worktree_id: item.worktree_id,
277
+ input_sha: item.head,
278
+ merge_commit: gitTry(['rev-parse', 'HEAD'], path).out || null,
279
+ rerere_replayed: false,
280
+ });
281
+ }
282
+ return { state: 'composed', steps, conflict: null, composed_sha: gitTry(['rev-parse', 'HEAD'], path).out || null };
283
+ }
284
+
285
+ /**
286
+ * 把 plan-batch 之后的手工仪式(建候选树、按序 merge、指纹落账)收进一条可重跑命令。
287
+ *
288
+ * 边界:**不执行任何门禁命令**。合成完成后只回显 Profile 声明的 post_integrate_steps
289
+ * 清单,由 controller 逐条执行并用 `batch-step` 登记结果;portable core 永不跑 Profile 内容。
290
+ * @param {{positionals:string[],flags:Map<string,unknown>}} args
291
+ */
292
+ function prepareBatchIntegration(args) {
293
+ rejectUnknownFlags(args.flags, [
294
+ 'plan', 'target', 'candidate-task', 'agent', 'agent-id', 'purpose', 'owner',
295
+ 'abort-on-conflict', 'no-rerere', 'recompose', 'recompose-head', 'json', 'config', 'root', 'codegraph',
296
+ ]);
297
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
298
+ const identity = resolveIdentity(args.flags);
299
+ const asJson = Boolean(args.flags.get('json'));
300
+ // --json 的 stdout 必须只有 JSON;诊断类回显在 JSON 模式下一律走 stderr。
301
+ const notice = (message) => (asJson ? console.error(`${PREFIX} ${message}`) : log(message));
302
+
303
+ const planPath = flag(args.flags, 'plan');
304
+ let frozenPlan = null;
305
+ let selectors = args.positionals;
306
+ let targetOverride = flag(args.flags, 'target');
307
+ if (planPath) {
308
+ if (args.positionals.length > 0) die('--plan 与 selector 位置参数互斥;冻结计划已经带有输入清单。', 2);
309
+ frozenPlan = readBatchPlanFile(planPath);
310
+ // 必须用原始 selector 全集重算,而不是只用 included:被折叠或已在 target 的输入
311
+ // 若随后前进,只回算 included 会让旧计划继续判定「新鲜」,把那部分改动静默漏出合成。
312
+ selectors = Array.isArray(frozenPlan.requested_selectors) && frozenPlan.requested_selectors.length > 0
313
+ ? frozenPlan.requested_selectors
314
+ : [...frozenPlan.included, ...(frozenPlan.excluded ?? [])].map((item) => item.worktree_id);
315
+ targetOverride = targetOverride ?? frozenPlan.target.ref;
316
+ } else if (selectors.length < 2) {
317
+ die('batch-integrate 需要 --plan <plan.json>,或至少两个 feature selector(即时规划)。', 2);
318
+ }
319
+
320
+ const plan = computeBatchPlan(loaded, selectors, targetOverride);
321
+ if (!plan.ready) {
322
+ for (const blocker of plan.blockers) console.error(` [BLOCK] ${blocker.task ?? '-'} ${blocker.code}: ${blocker.detail}`);
323
+ die('批次输入未就绪,拒绝合成;请先修复上述 blocker 再重新规划。');
324
+ }
325
+ if (frozenPlan) assertBatchPlanFresh(frozenPlan, plan);
326
+
327
+ const candidateTask = flag(args.flags, 'candidate-task') ?? 'batch-integration-candidate';
328
+ validateTaskSlug(candidateTask);
329
+ validateTaskNaming(candidateTask, loaded.profile.task_naming);
330
+ const records = loadRecords(loaded.context.common_dir);
331
+ const liveCandidates = records.filter((record) => record.batch_integration && isActiveRecord(record));
332
+ // 已冻结 batch_result 的候选 task_status=done,不再属于普通 active record,但同指纹仍必须作为
333
+ // read-only already_composed 命中;否则重跑会误以为候选不存在,甚至尝试新建同身份候选。
334
+ const reusable = records.find((record) => (
335
+ record.batch_integration?.fingerprint === plan.fingerprint &&
336
+ record.worktree_state !== 'reclaimed' &&
337
+ record.task_status !== 'abandoned'
338
+ ));
339
+ const recompose = Boolean(args.flags.get('recompose'));
340
+ const recomposeHead = flag(args.flags, 'recompose-head');
341
+ if (recomposeHead && !recompose) die('--recompose-head 只能与 --recompose 一起使用。', 2);
342
+ if (recompose && !recomposeHead) {
343
+ die('--recompose 是破坏性操作,必须同时提供 --recompose-head <候选当前完整 HEAD> 进行精确授权。', 2);
344
+ }
345
+ return {
346
+ args,
347
+ loaded,
348
+ identity,
349
+ asJson,
350
+ notice,
351
+ frozenPlan,
352
+ plan,
353
+ candidateTask,
354
+ records,
355
+ reusable,
356
+ superseded: liveCandidates.filter((record) => record.batch_integration.fingerprint !== plan.fingerprint),
357
+ recompose,
358
+ recomposeHead,
359
+ };
360
+ }
361
+
362
+ function printAlreadyComposed(context, candidate, snapshot, batch) {
363
+ const advanced = snapshot.head !== batch.composed_sha
364
+ && Boolean(snapshot.head)
365
+ && isAncestor(candidate.path, batch.composed_sha, snapshot.head);
366
+ if (snapshot.head !== batch.composed_sha && !advanced) {
367
+ die(
368
+ `候选 HEAD=${snapshot.head?.slice(0, 12) ?? 'unreadable'} 既不等于已落账 composed_sha=` +
369
+ `${batch.composed_sha.slice(0, 12)},也不是它的后继提交,无法判定候选树处于何种状态。\n` +
370
+ '请人工核对候选树历史;确需按同一计划重新合成,加 --recompose(会丢弃合成之后的提交)。',
371
+ );
372
+ }
373
+ const result = {
374
+ schema_version: 1,
375
+ outcome: 'already_composed',
376
+ fingerprint: context.plan.fingerprint,
377
+ candidate: { worktree_id: candidate.worktree_id, task: candidate.task, path: candidate.path, branch: candidate.branch },
378
+ target: context.plan.target,
379
+ composed_sha: batch.composed_sha,
380
+ head_sha: snapshot.head,
381
+ advanced_beyond_composition: advanced,
382
+ dirty: snapshot.dirty,
383
+ steps: batch.steps,
384
+ rerere: batch.rerere ?? null,
385
+ post_integrate_steps: batch.post_integrate_steps ?? [],
386
+ };
387
+ if (context.asJson) console.log(JSON.stringify(result, null, 2));
388
+ else {
389
+ log(`同指纹候选已合成,幂等返回 fingerprint=${context.plan.fingerprint.slice(7, 19)} composed=${result.composed_sha.slice(0, 12)}`);
390
+ if (advanced) {
391
+ console.log(` 候选已在合成之上前进到 ${snapshot.head.slice(0, 12)}(通常是合成后再生成步骤的提交);未重置、未改动步骤状态。`);
392
+ console.log(' 确需按同一计划重新合成请加 --recompose,它会丢弃这些提交。');
393
+ }
394
+ if (snapshot.dirty) console.log(' 注意:候选树当前非干净,验收前请先处理未提交改动。');
395
+ printPostIntegrateSteps(result.post_integrate_steps, candidate.task);
396
+ }
397
+ }
398
+
399
+ function reuseBatchCandidate(context) {
400
+ let candidate = context.reusable;
401
+ const snapshot = liveGitSnapshot(candidate);
402
+ if (!snapshot.present) die(`同指纹候选 record 存在但 worktree missing: ${candidate.path};请先 doctor/reclaim。`);
403
+ const batch = candidate.batch_integration;
404
+ const composedBefore = batch.state === 'composed' && Boolean(batch.composed_sha);
405
+ if (candidate.batch_result && context.recompose) die('batch_result 已冻结,禁止重合成;输入或合同变化必须另起候选。', 2);
406
+ const ownedByController = candidate.agent?.host === context.identity.actor.host
407
+ && candidate.agent?.id === context.identity.actor.id;
408
+ // 跨会话可只读查询一棵已完成候选,但任何会移动 HEAD、续合冲突或改写台账的路径都必须
409
+ // 先显式 handoff。否则另一个 controller 仅凭同一 fingerprint 就能重置原 owner 的提交。
410
+ if (!ownedByController && (!composedBefore || context.recompose)) {
411
+ die(
412
+ `候选属于 ${candidate.agent?.host ?? 'unknown'}/${candidate.agent?.id ?? 'unknown'},` +
413
+ `当前会话是 ${context.identity.actor.host}/${context.identity.actor.id};跨会话只允许读取 already_composed。\n` +
414
+ '需要继续合成或重合成时,请先执行 handoff 转移所有权。',
415
+ );
416
+ }
417
+ // 已合成的候选**默认永不重置**。合成之后候选树通常还会前进:controller 执行 Profile 声明的
418
+ // 合成后再生成步骤(golden / codegen / lock)并提交,HEAD 就不再等于 composed_sha。
419
+ // 旧实现只认「HEAD == composed_sha」为幂等,于是同指纹重跑会落进重新合成路径,
420
+ // reset --hard 把这些生成提交连同已登记的步骤状态一起抹掉。
421
+ if (composedBefore && !context.recompose) {
422
+ printAlreadyComposed(context, candidate, snapshot, batch);
423
+ return { done: true, candidate, recomposeContext: null };
424
+ }
425
+ if (snapshot.dirty !== false) {
426
+ die(
427
+ `候选树非干净,拒绝重置重合成: ${candidate.path}\n` +
428
+ '若上一轮冲突已手工解出,请先提交该 merge(rerere 会录下解法),再重跑本命令;' +
429
+ '若要放弃,请在候选树执行 git merge --abort。',
430
+ );
431
+ }
432
+ if (!composedBefore && context.recompose) die('--recompose 只适用于已落账为 composed 的同指纹候选。');
433
+ if (!context.recompose) return { done: false, candidate, recomposeContext: null };
434
+ if (context.recomposeHead !== snapshot.head) {
435
+ die(
436
+ `RECOMPOSE_HEAD_STALE: --recompose-head=${context.recomposeHead} 与候选当前完整 HEAD=${snapshot.head ?? 'unreadable'} 不一致,拒绝重置。\n` +
437
+ '请重新核对候选树,并用当前完整 HEAD 明示授权。',
438
+ );
439
+ }
440
+ const recomposeContext = {
441
+ authorized_head_sha: context.recomposeHead,
442
+ discarded_head_sha: snapshot.head,
443
+ previous_composed_sha: batch.composed_sha,
444
+ };
445
+ candidate = updateRecord(candidate, 'batch_candidate_recompose_authorized', (next) => {
446
+ if (next.agent?.host !== context.identity.actor.host || next.agent?.id !== context.identity.actor.id) {
447
+ throw new WorktreeTraceError('RECOMPOSE_OWNER_CHANGED', '候选所有权已变化,拒绝重置。');
448
+ }
449
+ if (
450
+ next.batch_integration?.fingerprint !== context.plan.fingerprint
451
+ || next.batch_integration?.state !== 'composed'
452
+ || next.batch_integration?.composed_sha !== batch.composed_sha
453
+ ) {
454
+ throw new WorktreeTraceError('RECOMPOSE_RECORD_CHANGED', '候选合成台账已变化,拒绝按陈旧快照授权重置。');
455
+ }
456
+ next.last_seen_at = new Date().toISOString();
457
+ }, {
458
+ fingerprint: context.plan.fingerprint,
459
+ target_sha: context.plan.target.sha,
460
+ ...recomposeContext,
461
+ requested_by: context.identity.actor,
462
+ }, context.loaded.context.common_dir);
463
+ context.notice(`--recompose:已按精确 HEAD ${context.recomposeHead.slice(0, 12)} 授权,将丢弃候选后续提交并重新合成。`);
464
+ return { done: false, candidate, recomposeContext };
465
+ }
466
+
467
+ function prepareSupersededCandidate(context) {
468
+ if (context.recompose) die('--recompose 未找到已落账为 composed 的同指纹候选,拒绝新建候选。');
469
+ if (context.superseded.length > 1) {
470
+ die(
471
+ `存在 ${context.superseded.length} 棵旧指纹集成候选,自动替代登记只处理一棵,拒绝猜测:\n - ` +
472
+ context.superseded.map((record) => `${record.task}(${record.batch_integration.fingerprint.slice(7, 19)})=${record.path}`).join('\n - ') +
473
+ '\n请先回收多余候选,只保留一棵待替代的。',
474
+ );
475
+ }
476
+ const previous = context.superseded[0] ?? null;
477
+ if (previous && previous.task === context.candidateTask) {
478
+ die(
479
+ `候选 task ${context.candidateTask} 已绑定旧指纹 ${previous.batch_integration.fingerprint.slice(7, 19)},` +
480
+ `新指纹为 ${context.plan.fingerprint.slice(7, 19)}。\n` +
481
+ '一次性候选不复用身份:请用 --candidate-task <新的 semantic slug> 建立替代候选(工具会自动登记替代关系),' +
482
+ '或先回收旧候选再重跑。',
483
+ );
484
+ }
485
+ if (!previous) return null;
486
+ const snapshot = liveGitSnapshot(previous);
487
+ if (!snapshot.present) die(`旧候选 worktree missing: ${previous.path};请先 doctor/reclaim。`);
488
+ if (snapshot.dirty !== false) die(`旧候选必须干净才能登记替代: ${previous.path}`);
489
+ if (previous.agent?.host !== context.identity.actor.host || previous.agent?.id !== context.identity.actor.id) {
490
+ die(
491
+ `旧候选属于 ${previous.agent?.host}/${previous.agent?.id},与当前会话不同;` +
492
+ '跨会话不自动登记替代关系,请先人工回收旧候选。',
493
+ );
494
+ }
495
+ if (previous.task_status !== 'abandoned') {
496
+ updateRecord(previous, 'status_updated', (next) => {
497
+ next.task_status = 'abandoned';
498
+ next.last_seen_at = new Date().toISOString();
499
+ }, {
500
+ note: `批次输入变化,指纹 ${previous.batch_integration.fingerprint.slice(7, 19)} 已失效`,
501
+ }, context.loaded.context.common_dir);
502
+ }
503
+ return previous;
504
+ }
505
+
506
+ function spawnBatchCandidate(context) {
507
+ const previous = prepareSupersededCandidate(context);
508
+ const spawnFlags = new Map();
509
+ for (const key of ['agent', 'agent-id', 'owner', 'config', 'root', 'codegraph']) {
510
+ const value = flag(context.args.flags, key);
511
+ if (value) spawnFlags.set(key, value);
512
+ }
513
+ spawnFlags.set(
514
+ 'purpose',
515
+ flag(context.args.flags, 'purpose') ?? `一次性批次集成候选 fingerprint=${context.plan.fingerprint.slice(7, 19)}`,
516
+ );
517
+ spawnFlags.set('base', context.plan.target.ref);
518
+ spawnFlags.set('base-reason', `集成候选必须从批次冻结 target ${context.plan.target.ref} 起步`);
519
+ if (previous) {
520
+ spawnFlags.set('supersedes', previous.worktree_id);
521
+ spawnFlags.set(
522
+ 'replacement-reason',
523
+ `批次输入变化:${previous.batch_integration.fingerprint.slice(7, 19)} -> ${context.plan.fingerprint.slice(7, 19)}`,
524
+ );
525
+ } else if (coexistingSessionRecords(context.records, context.identity.actor, context.candidateTask).length > 0) {
526
+ spawnFlags.set('parallel-reason', '一次性批次集成候选,与各 feature 交付可独立评审、合入和回退');
527
+ }
528
+ // spawn 的身份/路径回显对人有用,但 --json 的 stdout 必须只有 JSON;
529
+ // 转存到 stderr,既不污染机器可读输出,也不丢诊断信息。
530
+ const spawnChatter = [];
531
+ const originalLog = console.log;
532
+ if (context.asJson) console.log = (...parts) => spawnChatter.push(parts.join(' '));
533
+ try {
534
+ cmdSpawn({ positionals: [context.candidateTask], flags: spawnFlags });
535
+ } finally {
536
+ console.log = originalLog;
537
+ }
538
+ if (spawnChatter.length > 0) console.error(spawnChatter.join('\n'));
539
+
540
+ const candidate = loadRecords(context.loaded.context.common_dir).find((record) =>
541
+ record.task === context.candidateTask &&
542
+ record.agent?.host === context.identity.actor.host &&
543
+ record.agent?.id === context.identity.actor.id &&
544
+ isActiveRecord(record));
545
+ if (!candidate) die('集成候选 spawn 后未能定位到对应 record;请运行 doctor 复查。');
546
+ const candidateHead = gitTry(['rev-parse', 'HEAD'], candidate.path);
547
+ if (!candidateHead.ok || candidateHead.out !== context.plan.target.sha) {
548
+ die(
549
+ `候选树 HEAD=${candidateHead.out || 'unreadable'} 与冻结 target SHA=${context.plan.target.sha} 不一致;` +
550
+ 'target ref 可能在建树期间移动,请重新 plan-batch。',
551
+ );
552
+ }
553
+ return { done: false, candidate, recomposeContext: null };
554
+ }
555
+
556
+ function configureBatchRerere(context, candidate) {
557
+ const rerere = context.args.flags.get('no-rerere')
558
+ ? {
559
+ enabled: false,
560
+ auto_update: false,
561
+ scope: 'none',
562
+ worktree_config_extension: 'not_needed',
563
+ worktree_config_previous: null,
564
+ reason: '--no-rerere 显式关闭',
565
+ }
566
+ : enableCandidateRerere(context.loaded, candidate.path);
567
+ // 自动写共享 config(extensions.worktreeConfig)是本命令唯一会碰仓库级配置的动作,
568
+ // 必须留下独立、可区分「本轮写入」与「原本已启用」的审计事件。
569
+ if (rerere.worktree_config_extension === 'enabled_by_this_command') {
570
+ updateRecord(candidate, 'repository_config_extension_enabled', (next) => {
571
+ next.last_seen_at = new Date().toISOString();
572
+ }, {
573
+ key: 'extensions.worktreeConfig',
574
+ value: 'true',
575
+ previous: rerere.worktree_config_previous ?? 'unset',
576
+ scope: 'shared_repository_config',
577
+ written_by: 'batch-integrate',
578
+ purpose: '为集成候选启用 worktree 级 rerere(rerere.enabled + rerere.autoUpdate)',
579
+ }, context.loaded.context.common_dir);
580
+ context.notice('已在共享仓库 config 启用 extensions.worktreeConfig(本轮写入,已记审计事件)。');
581
+ }
582
+ return rerere;
583
+ }
584
+
585
+ function recordBatchComposition(context, candidate, recomposeContext, rerere, composed) {
586
+ const orderedInputs = context.plan.included.map((item) => ({
587
+ task: item.task,
588
+ worktree_id: item.worktree_id,
589
+ branch: item.branch,
590
+ head: item.head,
591
+ upstream_ref: item.upstream_ref,
592
+ }));
593
+ const declaredSteps = (context.loaded.profile.post_integrate_steps ?? []).map((step) => ({
594
+ name: step.name,
595
+ hint: step.hint,
596
+ state: 'pending',
597
+ note: null,
598
+ recorded_at: null,
599
+ }));
600
+ const now = new Date().toISOString();
601
+ const batchState = {
602
+ fingerprint: context.plan.fingerprint,
603
+ target_ref: context.plan.target.ref,
604
+ target_sha: context.plan.target.sha,
605
+ plan_generated_at: context.frozenPlan?.generated_at ?? context.plan.generated_at,
606
+ plan_source: context.frozenPlan ? 'frozen_plan_file' : 'inline_plan',
607
+ ordered_inputs: orderedInputs,
608
+ state: composed.state,
609
+ steps: composed.steps,
610
+ composed_sha: composed.composed_sha,
611
+ composed_at: composed.state === 'composed' ? now : null,
612
+ conflict: composed.conflict,
613
+ rerere,
614
+ recompose: recomposeContext,
615
+ post_integrate_steps: composed.state === 'composed' ? declaredSteps : [],
616
+ };
617
+ const updated = updateRecord(
618
+ candidate,
619
+ composed.state === 'composed' ? 'batch_candidate_composed' : 'batch_candidate_conflict',
620
+ (next) => {
621
+ next.batch_integration = batchState;
622
+ next.task_status = composed.state === 'composed' ? 'integrating' : next.task_status;
623
+ next.last_seen_at = now;
624
+ next.last_head = composed.composed_sha ?? next.last_head;
625
+ },
626
+ {
627
+ fingerprint: context.plan.fingerprint,
628
+ target_sha: context.plan.target.sha,
629
+ ordered_input_shas: orderedInputs.map((item) => item.head),
630
+ merge_commits: composed.steps.map((step) => step.merge_commit),
631
+ rerere,
632
+ recompose: recomposeContext,
633
+ conflict: composed.conflict,
634
+ },
635
+ context.loaded.context.common_dir,
636
+ );
637
+ return {
638
+ schema_version: 1,
639
+ outcome: composed.state,
640
+ fingerprint: context.plan.fingerprint,
641
+ candidate: {
642
+ worktree_id: updated.worktree_id,
643
+ task: updated.task,
644
+ path: updated.path,
645
+ branch: updated.branch,
646
+ },
647
+ target: context.plan.target,
648
+ ordered_inputs: orderedInputs,
649
+ steps: composed.steps,
650
+ composed_sha: composed.composed_sha,
651
+ rerere,
652
+ recompose: recomposeContext,
653
+ conflict: composed.conflict,
654
+ post_integrate_steps: batchState.post_integrate_steps,
655
+ };
656
+ }
657
+
658
+ function printBatchComposition(context, result) {
659
+ if (context.asJson) console.log(JSON.stringify(result, null, 2));
660
+ else if (result.outcome === 'composed') {
661
+ log(`批次合成完成 candidate=${result.candidate.task} sha=${result.composed_sha.slice(0, 12)} fingerprint=${context.plan.fingerprint.slice(7, 19)}`);
662
+ for (const step of result.steps) {
663
+ console.log(` [MERGED] ${step.task} ${step.input_sha.slice(0, 12)} -> ${step.merge_commit?.slice(0, 12)}${step.rerere_replayed ? ' (rerere 重放)' : ''}`);
664
+ }
665
+ console.log(` rerere=${result.rerere.enabled ? result.rerere.scope : `off(${result.rerere.reason})`}`);
666
+ console.log(` 候选树: ${result.candidate.path}`);
667
+ printPostIntegrateSteps(result.post_integrate_steps, result.candidate.task);
668
+ console.log(' 下一步由 controller 执行门禁;本命令不跑任何验收命令。');
669
+ } else {
670
+ log(`批次合成冲突,停在 ${result.conflict.task} ${result.conflict.input_sha.slice(0, 12)}`);
671
+ console.log(` 候选树: ${result.conflict.candidate_path}`);
672
+ console.log(` 合入侧(ours): ${result.conflict.onto_sha.slice(0, 12)} 待并侧(theirs): ${result.conflict.input_sha.slice(0, 12)}`);
673
+ for (const file of result.conflict.files) console.log(` [CONFLICT] ${file}`);
674
+ console.log(result.conflict.aborted
675
+ ? ' 已按 --abort-on-conflict 回滚到干净 target。'
676
+ : ' 已停在冲突处(未自动解、未自动 abort):请裁决后手工解并提交该 merge,再重跑本命令由 rerere 重放。');
677
+ }
678
+ if (result.outcome !== 'composed') process.exitCode = 1;
679
+ }
680
+
681
+ function cmdBatchIntegrate(args) {
682
+ const context = prepareBatchIntegration(args);
683
+ const resolved = context.reusable ? reuseBatchCandidate(context) : spawnBatchCandidate(context);
684
+ if (resolved.done) return;
685
+ const rerere = configureBatchRerere(context, resolved.candidate);
686
+ const composed = composeBatchCandidate(resolved.candidate, context.plan, {
687
+ abortOnConflict: Boolean(args.flags.get('abort-on-conflict')),
688
+ recomposeExpectedHead: resolved.recomposeContext?.authorized_head_sha ?? null,
689
+ });
690
+ const result = recordBatchComposition(
691
+ context,
692
+ resolved.candidate,
693
+ resolved.recomposeContext,
694
+ rerere,
695
+ composed,
696
+ );
697
+ printBatchComposition(context, result);
698
+ }
699
+
700
+ /** @param {Record<string,any>[]} steps @param {string} task */
701
+ function printPostIntegrateSteps(steps, task) {
702
+ if (!steps || steps.length === 0) return;
703
+ console.log(' Profile 声明的合成后再生成步骤(只回显、不代跑):');
704
+ for (const step of steps) {
705
+ console.log(` [${step.state.toUpperCase()}] ${step.name}: ${step.hint}`);
706
+ }
707
+ console.log(` 执行后登记:batch-step ${task} --step <name> --state done|skipped|failed`);
708
+ }
709
+
710
+ return {
711
+ cmdBatchIntegrate,
712
+ };
713
+ }