@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,661 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @param {Record<string, any>} deps
5
+ */
6
+ export function createCommands(deps) {
7
+ const {
8
+ spawn,
9
+ randomUUID,
10
+ predictReviewRefresh,
11
+ loadRepositoryProfile,
12
+ gitlabSubmitPushArgs,
13
+ parseGitlabMergeRequestUrl,
14
+ WorktreeTraceError,
15
+ appendTraceEvent,
16
+ managerScript,
17
+ runFileCapture,
18
+ PREFIX,
19
+ FETCH_TIMEOUT_MS,
20
+ SUBMIT_PUSH_TIMEOUT_MS,
21
+ WATCH_MIN_INTERVAL_MS,
22
+ WATCH_MAX_INTERVAL_MS,
23
+ WATCH_TARGET_CACHE_MAX_AGE_MS,
24
+ git,
25
+ gitTry,
26
+ sleep,
27
+ readWatcherHeartbeat,
28
+ writeWatcherHeartbeat,
29
+ removeWatcherHeartbeat,
30
+ parseWatchInterval,
31
+ watcherHealth,
32
+ refreshTargetRef,
33
+ refreshTargetRefCached,
34
+ parseNotifyMode,
35
+ log,
36
+ die,
37
+ flag,
38
+ rejectUnknownFlags,
39
+ oneLine,
40
+ aliasedFlag,
41
+ loadRecords,
42
+ selectRecord,
43
+ liveGitSnapshot,
44
+ assertHistoryOperationIdle,
45
+ appendReclaimEvent,
46
+ appendWatchedEvent,
47
+ reclaimPreflight,
48
+ reclaimRecord,
49
+ } = deps;
50
+
51
+ function autoArmReviewWatch(loaded, record, args, snapshot) {
52
+ if (args.flags.get('no-watch')) {
53
+ log('watch 未武装:--no-watch 显式退出;合入后需要人工回收。');
54
+ return;
55
+ }
56
+ const existing = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
57
+ ? record.auto_reclaim
58
+ : null;
59
+ // 原子失效旧 watcher 后仍沿用它的 target/interval/change-ref 默认值,避免自动重冻结
60
+ // 把人工显式选择的 target 静默改回该树登记的 base。
61
+ const previous = record.auto_reclaim ?? null;
62
+ if (existing?.state === 'merge_detected') {
63
+ log('watch 保持原冻结 SHA:目标分支已确认包含该 head,自动回收已进入提交阶段。');
64
+ return;
65
+ }
66
+ const skip = (reason) => log(`watch 未武装:${reason};自动回收保持关闭,可补齐前提后重新 touch。`);
67
+ const targetRef = aliasedFlag(args.flags, 'target', 'watch-target') ?? previous?.target_ref ?? record.base_ref ?? loaded.profile.default_base;
68
+ if (!targetRef || !targetRef.includes('/')) {
69
+ skip(`无法确定远端主干 target(Profile default_base=${loaded.profile.default_base ?? 'null'})`);
70
+ return;
71
+ }
72
+ if (!snapshot.present) { skip('worktree 不存在'); return; }
73
+ if (snapshot.dirty !== false) { skip('工作树非干净'); return; }
74
+ if (!snapshot.head) { skip('无法读取 HEAD'); return; }
75
+ const upstream = gitTry(['rev-parse', '@{upstream}^{commit}'], record.path);
76
+ if (!upstream.ok || upstream.out !== snapshot.head) {
77
+ skip('当前 HEAD 尚未完整 push 到 upstream');
78
+ return;
79
+ }
80
+ const refreshedTarget = refreshTargetRef(targetRef, loaded.context.current_worktree);
81
+ if (!refreshedTarget.ok) { skip(`目标 ref 不存在或无法刷新: ${targetRef}`); return; }
82
+ // auto_touch 是可重算的默认动作,可在当轮直接改 target;人工显式 watch(以及没有来源字段的
83
+ // legacy watcher)是用户指令,touch 不能静默改写。即使它刚因 HEAD 漂移被原子撤防,也保留
84
+ // 这条 provenance 边界;此时改目标应显式执行 watch --target 重新建立指令。
85
+ if (previous && previous.target_ref !== targetRef && previous.armed_by !== 'auto_touch') {
86
+ skip(
87
+ `人工武装 target=${previous.target_ref} 与请求的 ${targetRef} 不同;` +
88
+ `${previous.state === 'disarmed' ? '请显式执行 watch --target 建立新监听' : '换目标请先 unwatch'}`,
89
+ );
90
+ return;
91
+ }
92
+
93
+ const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
94
+ const health = watcherHealth(record, heartbeat);
95
+ // 已武装且 HEAD 未变、watcher 健康:保持原样,不重复起进程。
96
+ if (existing && health.healthy && existing.head_sha === snapshot.head && existing.target_ref === targetRef) {
97
+ log(`watch 已在运行 pid=${heartbeat.state.pid} target=${targetRef} head=${snapshot.head.slice(0, 12)}`);
98
+ return;
99
+ }
100
+ try {
101
+ const started = startWatcher(loaded, record, {
102
+ targetRef,
103
+ targetSha: refreshedTarget.target_sha,
104
+ headSha: snapshot.head,
105
+ intervalMs: parseWatchInterval(flag(args.flags, 'interval-ms') ?? previous?.interval_ms),
106
+ changeRef: aliasedFlag(args.flags, 'change-ref', 'mr')
107
+ ? oneLine(aliasedFlag(args.flags, 'change-ref', 'mr'), 'change-ref', 1000)
108
+ : previous?.change_ref ?? null,
109
+ notifyMode: parseNotifyMode(flag(args.flags, 'notify') ?? previous?.notify),
110
+ explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
111
+ previousHealth: health.reason,
112
+ armedBy: 'auto_touch',
113
+ });
114
+ const rearming = Boolean(existing) || (
115
+ previous?.state === 'disarmed' && previous.disarm_reason === 'stale_frozen_head'
116
+ );
117
+ const refroze = rearming && previous?.head_sha !== snapshot.head;
118
+ log(
119
+ `watch 已${rearming ? '重新' : ''}武装 pid=${started.pid} target=${targetRef} head=${snapshot.head.slice(0, 12)}` +
120
+ `${refroze ? `(HEAD 变化,已从 ${previous.head_sha.slice(0, 12)} 重冻结)` : ''}`,
121
+ );
122
+ } catch (error) {
123
+ skip(`watcher 启动失败: ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ }
126
+
127
+
128
+ function appendAutoStatus(commonDir, record, token, eventType, targetSha) {
129
+ return appendWatchedEvent(commonDir, record, token, eventType, (next) => {
130
+ next.task_status = eventType === 'auto_integrating' ? 'integrating' : 'done';
131
+ next.last_seen_at = new Date().toISOString();
132
+ }, { source: 'auto_reclaim_watcher', target_sha: targetSha });
133
+ }
134
+
135
+ /**
136
+ * @param {ReturnType<typeof loadRepositoryProfile>} loaded
137
+ * @param {Record<string,any>} initialRecord
138
+ * @param {{targetRef:string,targetSha?:string|null,headSha:string,intervalMs:number,changeRef:string|null,notifyMode:string,explicitConfig:string|null,previousHealth:string|null,armedBy?:string}} options
139
+ */
140
+ function startWatcher(loaded, initialRecord, options) {
141
+ let record = initialRecord;
142
+ const reviveStaleAutoTouch = options.armedBy === 'auto_touch'
143
+ && record.auto_reclaim?.state === 'disarmed'
144
+ && record.auto_reclaim?.disarm_reason === 'stale_frozen_head';
145
+ const existing = record.auto_reclaim && (
146
+ !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state) || reviveStaleAutoTouch
147
+ ) ? record.auto_reclaim : null;
148
+ const token = randomUUID();
149
+ const now = new Date().toISOString();
150
+ const eventType = existing ? 'auto_reclaim_rearmed' : 'auto_reclaim_armed';
151
+ const resumeAfterMerge = existing?.state === 'merge_detected';
152
+ record = appendTraceEvent({
153
+ commonDir: loaded.context.common_dir,
154
+ worktreeId: record.worktree_id,
155
+ eventType,
156
+ actor: record.agent,
157
+ details: {
158
+ target_ref: options.targetRef,
159
+ head_sha: options.headSha,
160
+ interval_ms: options.intervalMs,
161
+ change_ref: options.changeRef,
162
+ notify: options.notifyMode,
163
+ previous_health: options.previousHealth,
164
+ },
165
+ mutate(current) {
166
+ if (existing) {
167
+ const revivableCurrent = reviveStaleAutoTouch
168
+ && current.auto_reclaim?.state === 'disarmed'
169
+ && current.auto_reclaim?.disarm_reason === 'stale_frozen_head';
170
+ if (
171
+ current.auto_reclaim?.token !== existing.token
172
+ || current.auto_reclaim?.state === 'reclaimed'
173
+ || (current.auto_reclaim?.state === 'disarmed' && !revivableCurrent)
174
+ ) {
175
+ throw new WorktreeTraceError('WATCHER_CANCELLED', `watch token 已被并发更新或解除: ${record.worktree_id}`);
176
+ }
177
+ } else if (current.auto_reclaim && !['disarmed', 'reclaimed'].includes(current.auto_reclaim.state)) {
178
+ throw new WorktreeTraceError('WATCHER_ALREADY_ARMED', `watcher 已被另一进程 arm: ${record.worktree_id}`);
179
+ }
180
+ const next = structuredClone(current);
181
+ next.auto_reclaim = {
182
+ ...existing,
183
+ state: resumeAfterMerge ? 'merge_detected' : 'armed',
184
+ // 记录武装来源:自动武装是默认动作,人工显式武装是当轮指引,二者的改目标权限不同。
185
+ armed_by: options.armedBy ?? existing?.armed_by ?? 'explicit',
186
+ target_ref: options.targetRef,
187
+ target_base_sha: options.targetSha ?? existing?.target_base_sha ?? current.base_sha ?? null,
188
+ head_sha: options.headSha,
189
+ interval_ms: options.intervalMs,
190
+ change_ref: options.changeRef,
191
+ notify: options.notifyMode,
192
+ target_advance: null,
193
+ token,
194
+ armed_at: existing?.armed_at ?? now,
195
+ rearmed_at: existing ? now : null,
196
+ disarmed_at: null,
197
+ disarm_reason: null,
198
+ pid: null,
199
+ };
200
+ next.updated_at = now;
201
+ return next;
202
+ },
203
+ }).record;
204
+
205
+ const script = managerScript;
206
+ const workerArgs = [script, 'watch-worker', '--id', record.worktree_id, '--token', token];
207
+ if (options.explicitConfig) workerArgs.push('--config', options.explicitConfig);
208
+ const child = spawn(process.execPath, workerArgs, {
209
+ cwd: loaded.context.primary_worktree,
210
+ detached: true,
211
+ stdio: 'ignore',
212
+ });
213
+ if (!child.pid) throw new WorktreeTraceError('WATCHER_START_FAILED', '无法启动 auto-reclaim watcher。');
214
+ try {
215
+ record = appendWatchedEvent(loaded.context.common_dir, record, token, 'auto_reclaim_watcher_started', (next) => {
216
+ next.auto_reclaim.state = resumeAfterMerge ? 'merge_detected' : 'watching';
217
+ next.auto_reclaim.pid = child.pid;
218
+ next.auto_reclaim.started_at = new Date().toISOString();
219
+ }, { pid: child.pid, token });
220
+ } catch (error) {
221
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
222
+ throw error;
223
+ }
224
+ writeWatcherHeartbeat(loaded.context.common_dir, {
225
+ schema_version: 1,
226
+ worktree_id: record.worktree_id,
227
+ pid: child.pid,
228
+ token,
229
+ target_ref: options.targetRef,
230
+ head_sha: options.headSha,
231
+ state: 'starting',
232
+ blocked_reason: null,
233
+ started_at: record.auto_reclaim.started_at,
234
+ });
235
+ child.unref();
236
+ return { record, pid: child.pid, token };
237
+ }
238
+
239
+ /** @param {string|null} configured @param {string} remote @param {string|null} baseRef */
240
+ function resolveSubmitTargetBranch(configured, remote, baseRef) {
241
+ const candidate = configured ?? (baseRef?.startsWith(`${remote}/`) ? baseRef.slice(remote.length + 1) : null);
242
+ if (!candidate) die('无法从 Profile change_request.target_branch 或 base_ref 推断 MR target branch。');
243
+ return oneLine(candidate, 'target', 240);
244
+ }
245
+
246
+ function cmdSubmit(args) {
247
+ rejectUnknownFlags(args.flags, [
248
+ 'title',
249
+ 'description',
250
+ 'target',
251
+ 'remote',
252
+ 'interval-ms',
253
+ 'notify',
254
+ 'id',
255
+ 'config',
256
+ ]);
257
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
258
+ const provider = loaded.profile.change_request;
259
+ if (provider.provider !== 'gitlab') {
260
+ die('当前 Profile 未启用 GitLab change_request provider;请配置 provider=gitlab 或继续人工创建 MR。');
261
+ }
262
+ let record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
263
+ assertHistoryOperationIdle(record, 'submit');
264
+ if (!['active', 'ready_for_review'].includes(record.task_status)) {
265
+ die(`submit 只接受 active/ready_for_review,当前为 ${record.task_status}。`);
266
+ }
267
+ if (record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)) {
268
+ die('record 已有 watcher;更新 MR 前先确认旧冻结 SHA,必要时 unwatch 后再 submit。');
269
+ }
270
+
271
+ const snapshot = liveGitSnapshot(record);
272
+ if (!snapshot.present) die('submit 要求 worktree 仍存在。');
273
+ if (snapshot.dirty !== false) die('submit 要求工作树干净(含 untracked 必须为空)。');
274
+ if (!record.branch || !snapshot.head) die('submit 不支持 detached HEAD,且必须能解析有效 HEAD。');
275
+ const currentBranch = gitTry(['branch', '--show-current'], record.path);
276
+ if (!currentBranch.ok || currentBranch.out !== record.branch) {
277
+ die(`worktree 当前 branch 与 record 不一致: current=${currentBranch.out || '(detached)'} record=${record.branch}`);
278
+ }
279
+ if (!gitTry(['check-ref-format', '--branch', record.branch], record.path).ok) {
280
+ die(`非法 source branch: ${record.branch}`);
281
+ }
282
+
283
+ const remote = oneLine(flag(args.flags, 'remote') ?? provider.remote, 'remote', 120);
284
+ if (!gitTry(['remote', 'get-url', remote], record.path).ok) die(`Git remote 不存在: ${remote}`);
285
+ const targetBranch = resolveSubmitTargetBranch(
286
+ flag(args.flags, 'target') ?? provider.target_branch,
287
+ remote,
288
+ record.base_ref ?? null,
289
+ );
290
+ if (!gitTry(['check-ref-format', '--branch', targetBranch], record.path).ok) {
291
+ die(`非法 target branch: ${targetBranch}`);
292
+ }
293
+ const targetRef = `${remote}/${targetBranch}`;
294
+ const refreshed = refreshTargetRef(targetRef, record.path);
295
+ if (!refreshed.ok) die(`目标 ref 不存在或 fetch 失败: ${targetRef}`);
296
+
297
+ const upstreamHead = gitTry(['rev-parse', '@{upstream}^{commit}'], record.path);
298
+ const remoteHead = gitTry(
299
+ ['ls-remote', '--heads', remote, `refs/heads/${record.branch}`],
300
+ record.path,
301
+ { timeoutMs: FETCH_TIMEOUT_MS },
302
+ );
303
+ const remoteHeadSha = remoteHead.ok && remoteHead.out ? remoteHead.out.split(/\s+/)[0] : null;
304
+ if ((upstreamHead.ok && upstreamHead.out === snapshot.head) || remoteHeadSha === snapshot.head) {
305
+ die('当前 HEAD 已完整存在于 remote;GitLab 只在实际 push 时处理 MR push-options。请使用 API/UI 创建 MR 后运行 watch,工具不会为触发 push-option 改写历史或 force push。');
306
+ }
307
+
308
+ const title = flag(args.flags, 'title')
309
+ ? oneLine(flag(args.flags, 'title'), 'title', 240)
310
+ : oneLine(git(['show', '-s', '--format=%s', snapshot.head], record.path), 'title', 240);
311
+ const description = flag(args.flags, 'description')
312
+ ? oneLine(flag(args.flags, 'description'), 'description', 1000)
313
+ : null;
314
+ const pushArgs = gitlabSubmitPushArgs({
315
+ remote,
316
+ sourceBranch: record.branch,
317
+ targetBranch,
318
+ title,
319
+ description,
320
+ removeSourceBranch: provider.remove_source_branch,
321
+ });
322
+ const pushed = runFileCapture('git', pushArgs, { cwd: record.path, timeoutMs: SUBMIT_PUSH_TIMEOUT_MS });
323
+ if (!pushed.ok) {
324
+ const detail = pushed.out || pushed.error?.message || `exit=${pushed.status}`;
325
+ die(`GitLab MR push 失败;trace/watcher 未更新。\n${detail}`);
326
+ }
327
+
328
+ const submittedAt = new Date().toISOString();
329
+ const mergeRequestUrl = parseGitlabMergeRequestUrl(pushed.out);
330
+ const changeRef = mergeRequestUrl ?? `GitLab MR ${record.branch} -> ${targetBranch}`;
331
+ record = appendTraceEvent({
332
+ commonDir: loaded.context.common_dir,
333
+ worktreeId: record.worktree_id,
334
+ eventType: 'change_submitted',
335
+ actor: record.agent,
336
+ details: {
337
+ provider: 'gitlab',
338
+ change_ref: changeRef,
339
+ source_branch: record.branch,
340
+ target_branch: targetBranch,
341
+ head_sha: snapshot.head,
342
+ title,
343
+ },
344
+ mutate(current) {
345
+ if (!['active', 'ready_for_review'].includes(current.task_status)) {
346
+ throw new WorktreeTraceError('SUBMIT_STATE_CHANGED', `submit 期间 task_status 变为 ${current.task_status}`);
347
+ }
348
+ const next = structuredClone(current);
349
+ next.task_status = 'ready_for_review';
350
+ next.last_seen_at = submittedAt;
351
+ next.last_head = snapshot.head;
352
+ next.change_request = {
353
+ provider: 'gitlab',
354
+ state: 'submitted',
355
+ change_ref: changeRef,
356
+ url: mergeRequestUrl,
357
+ source_branch: record.branch,
358
+ target_branch: targetBranch,
359
+ head_sha: snapshot.head,
360
+ title,
361
+ submitted_at: submittedAt,
362
+ };
363
+ next.updated_at = submittedAt;
364
+ return next;
365
+ },
366
+ }).record;
367
+
368
+ const intervalMs = parseWatchInterval(flag(args.flags, 'interval-ms'));
369
+ const notifyMode = parseNotifyMode(flag(args.flags, 'notify'));
370
+ try {
371
+ const started = startWatcher(loaded, record, {
372
+ targetRef,
373
+ targetSha: refreshed.target_sha,
374
+ headSha: snapshot.head,
375
+ intervalMs,
376
+ changeRef,
377
+ notifyMode,
378
+ explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
379
+ previousHealth: null,
380
+ armedBy: 'explicit',
381
+ });
382
+ log(`GitLab MR 已提交: ${changeRef}`);
383
+ log(`auto-reclaim watcher 已启动 pid=${started.pid} id=${record.worktree_id.slice(0, 8)} head=${snapshot.head.slice(0, 12)} target=${targetRef}`);
384
+ } catch (error) {
385
+ console.error(`${PREFIX} MR 已成功 push 且 trace 已标记 ready_for_review,但 watcher 启动失败;请运行 watch/resume-all 恢复。`);
386
+ throw error;
387
+ }
388
+ }
389
+
390
+ /** @param {Record<string,any>} record */
391
+
392
+ function cmdWatch(args) {
393
+ rejectUnknownFlags(args.flags, ['target', 'interval-ms', 'change-ref', 'notify', 'id', 'config']);
394
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
395
+ let record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
396
+ assertHistoryOperationIdle(record, 'watch');
397
+ if (record.worktree_state === 'reclaimed') { log(`已回收,无需 watch: ${record.worktree_id}`); return; }
398
+ const existing = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
399
+ ? record.auto_reclaim
400
+ : null;
401
+ if (!existing && record.task_status !== 'ready_for_review') {
402
+ die(`首次 watch 要求 task_status=ready_for_review,当前为 ${record.task_status}。`);
403
+ }
404
+ if (existing && !['ready_for_review', 'integrating', 'done'].includes(record.task_status)) {
405
+ die(`恢复 watch 要求状态为 ready_for_review/integrating/done,当前为 ${record.task_status}。`);
406
+ }
407
+
408
+ const snapshot = liveGitSnapshot(record);
409
+ if (!snapshot.present) die('watch 要求 worktree 仍存在。');
410
+ if (!existing && snapshot.dirty !== false) die('首次 watch 要求工作树干净(含 untracked 必须为空)。');
411
+ const headSha = existing?.head_sha ?? snapshot.head;
412
+ if (!headSha || !gitTry(['cat-file', '-e', `${headSha}^{commit}`], loaded.context.current_worktree).ok) {
413
+ die('无法冻结有效的 MR head SHA。');
414
+ }
415
+ if (!existing) {
416
+ const upstream = gitTry(['rev-parse', '@{upstream}^{commit}'], record.path);
417
+ if (!upstream.ok || upstream.out !== headSha) {
418
+ die('watch 前必须把当前 HEAD 完整 push 到 upstream;本地 HEAD 与 upstream SHA 不一致。');
419
+ }
420
+ }
421
+
422
+ const targetRef = oneLine(flag(args.flags, 'target') ?? existing?.target_ref ?? record.base_ref, 'target', 240);
423
+ if (existing && flag(args.flags, 'target') && targetRef !== existing.target_ref) {
424
+ // 人工显式武装过的目标不被静默改写;但 touch 的自动武装只是默认动作,
425
+ // 调用者当轮显式给出的 target 优先,直接改指而不是要求先 unwatch。
426
+ if (existing.armed_by !== 'auto_touch') {
427
+ die(`已 arm 的 target=${existing.target_ref};先 unwatch 再更换目标。`);
428
+ }
429
+ log(`自动武装的 target=${existing.target_ref} 按当轮显式指引改为 ${targetRef}。`);
430
+ }
431
+ const intervalMs = parseWatchInterval(flag(args.flags, 'interval-ms') ?? existing?.interval_ms);
432
+ const changeRef = flag(args.flags, 'change-ref')
433
+ ? oneLine(flag(args.flags, 'change-ref'), 'change-ref', 240)
434
+ : existing?.change_ref ?? null;
435
+ const notifyMode = parseNotifyMode(flag(args.flags, 'notify') ?? existing?.notify);
436
+ const refreshed = refreshTargetRef(targetRef, loaded.context.current_worktree);
437
+ if (!refreshed.ok) die(`目标 ref 不存在: ${targetRef}`);
438
+
439
+ const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
440
+ const health = watcherHealth(record, heartbeat);
441
+ if (existing && health.healthy && existing.head_sha === headSha && existing.target_ref === targetRef && (existing.change_ref ?? null) === changeRef && (existing.notify ?? 'auto') === notifyMode) {
442
+ log(`watcher 已运行 pid=${heartbeat.state.pid} id=${record.worktree_id.slice(0, 8)} target=${targetRef}`);
443
+ return;
444
+ }
445
+ try {
446
+ const started = startWatcher(loaded, record, {
447
+ targetRef,
448
+ targetSha: refreshed.target_sha,
449
+ headSha,
450
+ intervalMs,
451
+ changeRef,
452
+ notifyMode,
453
+ explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
454
+ previousHealth: health.reason,
455
+ armedBy: 'explicit',
456
+ });
457
+ record = started.record;
458
+ log(`auto-reclaim watcher 已启动 pid=${started.pid} id=${record.worktree_id.slice(0, 8)} head=${headSha.slice(0, 12)} target=${targetRef}`);
459
+ } catch (error) {
460
+ if (error instanceof WorktreeTraceError && error.code === 'WATCHER_CANCELLED') {
461
+ die('watcher 启动期间被另一进程解除,请重新检查 record 状态。');
462
+ }
463
+ throw error;
464
+ }
465
+ }
466
+
467
+ function cmdResumeAll(args) {
468
+ rejectUnknownFlags(args.flags, ['json', 'config']);
469
+ if (args.positionals.length) die('resume-all 不接受 selector;它只扫描已 arm record。', 2);
470
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
471
+ const result = { resumed: [], healthy: [], skipped: [] };
472
+ const records = loadRecords(loaded.context.common_dir).filter((record) =>
473
+ record.auto_reclaim &&
474
+ !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state) &&
475
+ // archive 的前置条件本身已经要求 watcher 先 disarm,正常路径不会走到这里;这里保留
476
+ // 一道防线,避免归档记录被任何遗留/异常状态误当作待恢复 watcher。
477
+ record.worktree_state !== 'reclaimed' && record.worktree_state !== 'archived');
478
+ for (const record of records) {
479
+ const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
480
+ const health = watcherHealth(record, heartbeat);
481
+ if (health.healthy) {
482
+ result.healthy.push({ worktree_id: record.worktree_id, task: record.task, pid: heartbeat.state.pid });
483
+ continue;
484
+ }
485
+ const auto = record.auto_reclaim;
486
+ const intervalMs = Number(auto.interval_ms);
487
+ const snapshot = liveGitSnapshot(record);
488
+ let reason = null;
489
+ if (!['ready_for_review', 'integrating', 'done'].includes(record.task_status)) reason = `task_status=${record.task_status}`;
490
+ else if (!snapshot.present && record.worktree_state !== 'reclaim_ready') reason = 'worktree missing';
491
+ else if (!auto.head_sha || !gitTry(['cat-file', '-e', `${auto.head_sha}^{commit}`], loaded.context.current_worktree).ok) reason = 'frozen head unreachable';
492
+ else if (!auto.target_ref || typeof auto.target_ref !== 'string') reason = 'target ref missing';
493
+ else if (!Number.isInteger(intervalMs) || intervalMs < WATCH_MIN_INTERVAL_MS || intervalMs > WATCH_MAX_INTERVAL_MS) reason = 'interval invalid';
494
+ if (reason) {
495
+ result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason });
496
+ continue;
497
+ }
498
+ try {
499
+ const started = startWatcher(loaded, record, {
500
+ targetRef: auto.target_ref,
501
+ targetSha: auto.target_base_sha ?? record.base_sha ?? null,
502
+ headSha: auto.head_sha,
503
+ intervalMs,
504
+ changeRef: auto.change_ref ?? null,
505
+ notifyMode: auto.notify ?? 'auto',
506
+ explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
507
+ previousHealth: health.reason,
508
+ armedBy: auto.armed_by ?? 'explicit',
509
+ });
510
+ result.resumed.push({ worktree_id: record.worktree_id, task: record.task, pid: started.pid, previous_health: health.reason, dirty: snapshot.dirty });
511
+ } catch (error) {
512
+ result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: error instanceof Error ? error.message : String(error) });
513
+ }
514
+ }
515
+ if (args.flags.get('json')) {
516
+ console.log(JSON.stringify(result, null, 2));
517
+ return;
518
+ }
519
+ log(`resume-all resumed=${result.resumed.length} healthy=${result.healthy.length} skipped=${result.skipped.length}`);
520
+ for (const item of result.resumed) console.log(` [RESUMED] ${item.worktree_id.slice(0, 8)} ${item.task} pid=${item.pid} previous=${item.previous_health}${item.dirty ? ' dirty=blocked-until-clean' : ''}`);
521
+ for (const item of result.healthy) console.log(` [HEALTHY] ${item.worktree_id.slice(0, 8)} ${item.task} pid=${item.pid}`);
522
+ for (const item of result.skipped) console.log(` [SKIPPED] ${item.worktree_id.slice(0, 8)} ${item.task} ${item.reason}`);
523
+ }
524
+
525
+ function cmdUnwatch(args) {
526
+ rejectUnknownFlags(args.flags, ['id', 'config']);
527
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
528
+ let record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
529
+ const token = record.auto_reclaim?.token;
530
+ if (!token || ['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)) {
531
+ log(`watcher 未 arm: ${record.worktree_id.slice(0, 8)}`);
532
+ return;
533
+ }
534
+ if (record.auto_reclaim.state === 'merge_detected') {
535
+ die('目标分支已确认包含冻结的 MR head,自动回收已进入提交阶段,不能再 unwatch。');
536
+ }
537
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'auto_reclaim_disarmed', (next) => {
538
+ next.auto_reclaim.state = 'disarmed';
539
+ next.auto_reclaim.disarmed_at = new Date().toISOString();
540
+ });
541
+ removeWatcherHeartbeat(loaded.context.common_dir, record.worktree_id, token);
542
+ log(`auto-reclaim watcher 已解除: ${record.worktree_id.slice(0, 8)}`);
543
+ }
544
+
545
+ function cmdWatchWorker(args) {
546
+ rejectUnknownFlags(args.flags, ['id', 'token', 'config']);
547
+ const worktreeId = flag(args.flags, 'id');
548
+ const token = flag(args.flags, 'token');
549
+ if (!worktreeId || !token) die('watch-worker 需要 --id 与 --token。', 2);
550
+ while (true) {
551
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
552
+ let record = loadRecords(loaded.context.common_dir).find((candidate) => candidate.worktree_id === worktreeId);
553
+ if (!record || record.worktree_state === 'reclaimed' || record.auto_reclaim?.token !== token || record.auto_reclaim?.state === 'disarmed') {
554
+ removeWatcherHeartbeat(loaded.context.common_dir, worktreeId, token);
555
+ return;
556
+ }
557
+ const baseHeartbeat = {
558
+ schema_version: 1,
559
+ worktree_id: worktreeId,
560
+ pid: process.pid,
561
+ token,
562
+ target_ref: record.auto_reclaim.target_ref,
563
+ head_sha: record.auto_reclaim.head_sha,
564
+ started_at: record.auto_reclaim.started_at ?? record.auto_reclaim.armed_at,
565
+ };
566
+ const cacheMaxAgeMs = Math.min(WATCH_TARGET_CACHE_MAX_AGE_MS, Math.max(Math.floor(record.auto_reclaim.interval_ms / 2), WATCH_MIN_INTERVAL_MS));
567
+ const refreshed = refreshTargetRefCached(loaded.context.common_dir, record.auto_reclaim.target_ref, loaded.context.current_worktree, cacheMaxAgeMs);
568
+ if (!refreshed.fetch_ok || !refreshed.ok) {
569
+ writeWatcherHeartbeat(loaded.context.common_dir, { ...baseHeartbeat, state: 'waiting', blocked_reason: 'target fetch/ref unavailable', fetch_cache_hit: refreshed.cache_hit ?? false });
570
+ sleep(record.auto_reclaim.interval_ms);
571
+ continue;
572
+ }
573
+ const headMerged = gitTry(['merge-base', '--is-ancestor', record.auto_reclaim.head_sha, refreshed.target_sha], loaded.context.current_worktree).ok;
574
+ if (!headMerged) {
575
+ const targetBaseSha = record.auto_reclaim.target_base_sha ?? record.base_sha ?? null;
576
+ const targetAdvanced = refreshed.target_sha !== targetBaseSha;
577
+ let advance = record.auto_reclaim.target_advance ?? null;
578
+ if (targetAdvanced && advance?.target_sha !== refreshed.target_sha) {
579
+ const prediction = predictReviewRefresh(record.path, targetBaseSha, refreshed.target_sha, record.auto_reclaim.head_sha);
580
+ try {
581
+ record = appendWatchedEvent(loaded.context.common_dir, record, token, 'target_advanced', (next) => {
582
+ next.auto_reclaim.target_advance = {
583
+ target_sha: refreshed.target_sha,
584
+ recorded_base_sha: targetBaseSha,
585
+ prediction,
586
+ detected_at: new Date().toISOString(),
587
+ };
588
+ }, {
589
+ target_ref: record.auto_reclaim.target_ref,
590
+ target_sha: refreshed.target_sha,
591
+ recorded_base_sha: targetBaseSha,
592
+ head_sha: record.auto_reclaim.head_sha,
593
+ prediction,
594
+ });
595
+ advance = record.auto_reclaim.target_advance;
596
+ } catch (error) {
597
+ if (error instanceof WorktreeTraceError && error.code === 'WATCHER_CANCELLED') {
598
+ removeWatcherHeartbeat(loaded.context.common_dir, worktreeId, token);
599
+ return;
600
+ }
601
+ throw error;
602
+ }
603
+ }
604
+ const predictionState = advance?.prediction?.state ?? null;
605
+ const blockedReason = predictionState
606
+ ? `target advanced; refresh prediction=${predictionState}`
607
+ : null;
608
+ writeWatcherHeartbeat(loaded.context.common_dir, { ...baseHeartbeat, state: 'waiting', blocked_reason: blockedReason, target_sha: refreshed.target_sha, refresh_prediction: predictionState, fetch_cache_hit: refreshed.cache_hit ?? false, fetch_count: refreshed.fetch_count ?? null });
609
+ sleep(record.auto_reclaim.interval_ms);
610
+ continue;
611
+ }
612
+ try {
613
+ if (record.auto_reclaim.state !== 'merge_detected') {
614
+ record = appendWatchedEvent(loaded.context.common_dir, record, token, 'merge_detected', (next) => {
615
+ next.auto_reclaim.state = 'merge_detected';
616
+ next.auto_reclaim.target_sha = refreshed.target_sha;
617
+ next.auto_reclaim.detected_at = new Date().toISOString();
618
+ }, { target_ref: record.auto_reclaim.target_ref, target_sha: refreshed.target_sha, head_sha: record.auto_reclaim.head_sha });
619
+ }
620
+ if (record.task_status === 'ready_for_review') record = appendAutoStatus(loaded.context.common_dir, record, token, 'auto_integrating', refreshed.target_sha);
621
+ if (record.task_status === 'integrating') record = appendAutoStatus(loaded.context.common_dir, record, token, 'auto_done', refreshed.target_sha);
622
+ } catch (error) {
623
+ if (error instanceof WorktreeTraceError && error.code === 'WATCHER_CANCELLED') {
624
+ removeWatcherHeartbeat(loaded.context.common_dir, worktreeId, token);
625
+ return;
626
+ }
627
+ throw error;
628
+ }
629
+ if (record.task_status !== 'done') {
630
+ writeWatcherHeartbeat(loaded.context.common_dir, { ...baseHeartbeat, state: 'blocked', blocked_reason: `task_status=${record.task_status}`, target_sha: refreshed.target_sha, fetch_cache_hit: refreshed.cache_hit ?? false });
631
+ sleep(record.auto_reclaim.interval_ms);
632
+ continue;
633
+ }
634
+ const preflight = reclaimPreflight(loaded, record, refreshed.target_sha);
635
+ if (preflight.reason) {
636
+ writeWatcherHeartbeat(loaded.context.common_dir, { ...baseHeartbeat, state: 'blocked', blocked_reason: preflight.reason, target_sha: refreshed.target_sha, fetch_cache_hit: refreshed.cache_hit ?? false });
637
+ sleep(record.auto_reclaim.interval_ms);
638
+ continue;
639
+ }
640
+ const result = reclaimRecord(loaded, record, refreshed.target_sha, { recordBlocked: false });
641
+ if (result.reclaimed) {
642
+ removeWatcherHeartbeat(loaded.context.common_dir, worktreeId, token);
643
+ return;
644
+ }
645
+ writeWatcherHeartbeat(loaded.context.common_dir, { ...baseHeartbeat, state: 'blocked', blocked_reason: result.reason, target_sha: refreshed.target_sha, fetch_cache_hit: refreshed.cache_hit ?? false });
646
+ sleep(record.auto_reclaim.interval_ms);
647
+ }
648
+ }
649
+
650
+
651
+
652
+ return {
653
+ autoArmReviewWatch,
654
+ startWatcher,
655
+ cmdSubmit,
656
+ cmdWatch,
657
+ cmdResumeAll,
658
+ cmdUnwatch,
659
+ cmdWatchWorker,
660
+ };
661
+ }