@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,493 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Domain command factory. Dependencies are injected once by the thin CLI composition root,
5
+ * keeping this module acyclic and independently reviewable.
6
+ * @param {Record<string, any>} deps
7
+ */
8
+ export function createCommands(deps) {
9
+ const {
10
+ existsSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ join,
14
+ PROFILE_FILENAME,
15
+ WorktreeProfileError,
16
+ classifyStorage,
17
+ loadRepositoryProfile,
18
+ validateTaskNaming,
19
+ inspectRecordLock,
20
+ listRecordCacheEntries,
21
+ readEventChain,
22
+ traceLayout,
23
+ gitTry,
24
+ contentDigest,
25
+ watcherPath,
26
+ readWatcherHeartbeat,
27
+ watcherHealth,
28
+ log,
29
+ flag,
30
+ rejectUnknownFlags,
31
+ canonicalSelectorPath,
32
+ canonicalJson,
33
+ gitOperationState,
34
+ primaryProfileDriftFinding,
35
+ buildListing,
36
+ learningRoot,
37
+ isSettledWorktreeState,
38
+ } = deps;
39
+
40
+ function collectDoctorRecordMetadataFindings(loaded, listing, recordsById, record, findings) {
41
+ if (record.storage_class === 'ephemeral' && record.worktree_state !== 'reclaimed') findings.push({ code: 'EPHEMERAL_WORKTREE', severity: 'warning', worktree_id: record.worktree_id, path: record.path });
42
+ if (record.history_operation) {
43
+ findings.push({
44
+ code: 'MANAGED_HISTORY_OPERATION_PENDING',
45
+ severity: 'error',
46
+ worktree_id: record.worktree_id,
47
+ path: record.path,
48
+ operation: record.history_operation.kind ?? 'unknown',
49
+ state: record.history_operation.state ?? 'unknown',
50
+ token: record.history_operation.token ?? null,
51
+ detail: '受控历史操作尚未 finalize;用 selector + rebase --continue 恢复(可选参数若提供会校验一致性),或显式 --abort。',
52
+ });
53
+ }
54
+ if (record.review_refresh) {
55
+ findings.push({
56
+ code: 'REVIEW_REFRESH_PENDING',
57
+ severity: 'error',
58
+ worktree_id: record.worktree_id,
59
+ path: record.path,
60
+ state: record.review_refresh.state ?? 'unknown',
61
+ target_ref: record.review_refresh.target_ref ?? null,
62
+ target_sha: record.review_refresh.target_sha ?? null,
63
+ detail: record.review_refresh.state === 'aborting'
64
+ ? `评审刷新正在回滚;运行 refresh-review ${record.task} --abort 收口。`
65
+ : `评审刷新尚未完成;运行 refresh-review ${record.task} --continue 恢复,或在冲突/暂停 push 状态用 --abort 放弃。`,
66
+ });
67
+ }
68
+ if (record.stack_parent) {
69
+ const parent = recordsById.get(record.stack_parent.worktree_id);
70
+ if (!parent) {
71
+ findings.push({
72
+ code: 'STACK_PARENT_MISSING',
73
+ severity: 'error',
74
+ worktree_id: record.worktree_id,
75
+ parent_worktree_id: record.stack_parent.worktree_id,
76
+ detail: '堆叠父记录不存在,无法证明 base attribution。',
77
+ });
78
+ } else {
79
+ if (parent.branch !== record.stack_parent.branch) {
80
+ findings.push({
81
+ code: 'STACK_PARENT_BRANCH_MISMATCH',
82
+ severity: 'error',
83
+ worktree_id: record.worktree_id,
84
+ parent_worktree_id: parent.worktree_id,
85
+ recorded_branch: record.stack_parent.branch,
86
+ current_branch: parent.branch,
87
+ });
88
+ }
89
+ const parentRow = listing.rows.find((row) => row.record?.worktree_id === parent.worktree_id);
90
+ const parentHead = parentRow?.head ?? parent.last_head ?? null;
91
+ if (parentHead && parentHead !== record.stack_parent.parent_head_sha) {
92
+ findings.push({
93
+ code: 'STACK_PARENT_ADVANCED',
94
+ severity: 'warning',
95
+ worktree_id: record.worktree_id,
96
+ parent_worktree_id: parent.worktree_id,
97
+ recorded_parent_head: record.stack_parent.parent_head_sha,
98
+ current_parent_head: parentHead,
99
+ detail: '父任务 HEAD 已前进;根据新目标选择 managed rebase 或 retarget。',
100
+ });
101
+ }
102
+ }
103
+ }
104
+ if (
105
+ record.worktree_state !== 'reclaimed' &&
106
+ loaded.profile.default_base &&
107
+ record.base_ref &&
108
+ record.base_ref !== loaded.profile.default_base
109
+ ) {
110
+ findings.push({
111
+ code: 'BASE_OVERRIDE',
112
+ severity: 'warning',
113
+ worktree_id: record.worktree_id,
114
+ path: record.path,
115
+ base_ref: record.base_ref,
116
+ default_base: loaded.profile.default_base,
117
+ base_reason: record.base_reason ?? null,
118
+ detail: record.base_reason ? `非默认基线:${record.base_reason}` : '非默认基线未记录原因(legacy record)。',
119
+ });
120
+ }
121
+ }
122
+
123
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {ReturnType<typeof buildListing>} listing @param {Record<string,any>} record @param {Record<string,any>[]} findings */
124
+ function collectDoctorRecordLifecycleFindings(loaded, listing, record, findings) {
125
+ const present = listing.rows.some((row) => row.path === canonicalSelectorPath(record.path));
126
+ const liveRow = listing.rows.find((row) => row.path === canonicalSelectorPath(record.path));
127
+ if (present && record.batch_integration?.state === 'composed' && record.task_status === 'done' && !record.batch_result) {
128
+ findings.push({
129
+ code: 'DONE_BATCH_CANDIDATE_RESULT_UNRECORDED',
130
+ severity: 'warning',
131
+ worktree_id: record.worktree_id,
132
+ path: record.path,
133
+ candidate_sha: liveRow?.head ?? record.last_head ?? null,
134
+ detail: `候选已标记 done,但 passed/failed/stale 尚未冻结;运行 batch-result ${record.task} --state <state> --candidate <exact-sha>。`,
135
+ });
136
+ }
137
+ if (present && record.task_status === 'done' && ['passed', 'failed', 'stale'].includes(record.batch_result?.outcome)) {
138
+ const candidateSha = record.batch_result.candidate_sha;
139
+ findings.push({
140
+ code: 'DONE_EVIDENCE_WORKTREE_RECLAIM_PENDING',
141
+ severity: 'warning',
142
+ worktree_id: record.worktree_id,
143
+ path: record.path,
144
+ candidate_sha: candidateSha,
145
+ archive_ref: record.evidence_archive?.archive_ref ?? null,
146
+ detail: `固定验收候选仍占用 worktree;运行 reclaim ${record.task} --archive-evidence ${candidateSha} --reason <原因>。`,
147
+ });
148
+ }
149
+ if (present && record.worktree_state !== 'reclaimed' && record.task_status === 'abandoned') {
150
+ findings.push({
151
+ code: record.superseded_by ? 'SUPERSEDED_WORKTREE_RECLAIM_PENDING' : 'ABANDONED_WORKTREE_RECLAIM_PENDING',
152
+ severity: 'warning',
153
+ worktree_id: record.worktree_id,
154
+ path: record.path,
155
+ replacement_worktree_id: record.superseded_by?.worktree_id ?? null,
156
+ detail: record.superseded_by
157
+ ? '被替代 worktree 仍占用目录;保存独有提交后应执行 reclaim --superseded-by。'
158
+ : 'abandoned 只冻结任务,不会回收目录;补登记 supersede 关系或使用已有 pushed 证据回收。',
159
+ });
160
+ }
161
+ if (liveRow && record.worktree_state !== 'reclaimed') {
162
+ const operation = gitOperationState(record.path);
163
+ if (operation) {
164
+ findings.push({
165
+ code: 'GIT_OPERATION_IN_PROGRESS',
166
+ severity: 'error',
167
+ worktree_id: record.worktree_id,
168
+ path: record.path,
169
+ detail: `检测到 ${operation};禁止把该树作为交付或批次验收输入。`,
170
+ });
171
+ }
172
+ if (record.branch && liveRow.branch !== record.branch) {
173
+ findings.push({
174
+ code: 'LIVE_BRANCH_MISMATCH',
175
+ severity: 'error',
176
+ worktree_id: record.worktree_id,
177
+ path: record.path,
178
+ recorded_branch: record.branch,
179
+ live_branch: liveRow.branch,
180
+ });
181
+ }
182
+ if (record.last_head && liveRow.head && record.last_head !== liveRow.head) {
183
+ findings.push({
184
+ code: 'HEAD_DRIFT',
185
+ severity: ['ready_for_review', 'integrating'].includes(record.task_status) ? 'error' : 'warning',
186
+ worktree_id: record.worktree_id,
187
+ path: record.path,
188
+ recorded_head: record.last_head,
189
+ live_head: liveRow.head,
190
+ detail: 'live HEAD 与最后登记 SHA 不一致;先 touch/audit 并重新确认交付边界。',
191
+ });
192
+ }
193
+ if (liveRow.dirty === true && ['ready_for_review', 'integrating'].includes(record.task_status)) {
194
+ findings.push({
195
+ code: 'REVIEW_STATE_DIRTY',
196
+ severity: 'error',
197
+ worktree_id: record.worktree_id,
198
+ path: record.path,
199
+ task_status: record.task_status,
200
+ detail: 'ready_for_review/integrating worktree 必须保持干净;当前验收边界已失效。',
201
+ });
202
+ }
203
+ }
204
+ if (record.worktree_state === 'reclaim_ready') {
205
+ findings.push({
206
+ code: 'RECLAIM_INTERRUPTED',
207
+ severity: 'warning',
208
+ worktree_id: record.worktree_id,
209
+ path: record.path,
210
+ phase: present ? 'before_remove' : 'after_remove',
211
+ last_reclaim_error: record.last_reclaim_error ?? null,
212
+ });
213
+ } else if (!present && record.worktree_state !== 'reclaimed') {
214
+ findings.push({ code: 'WORKTREE_MISSING', severity: 'warning', worktree_id: record.worktree_id, path: record.path });
215
+ }
216
+ if (present && record.worktree_state === 'reclaimed') {
217
+ const reusedByNewerRecord = listing.records.some((candidate) =>
218
+ candidate.worktree_id !== record.worktree_id &&
219
+ candidate.worktree_state !== 'reclaimed' &&
220
+ canonicalSelectorPath(candidate.path) === canonicalSelectorPath(record.path) &&
221
+ String(candidate.created_at) > String(record.created_at),
222
+ );
223
+ if (!reusedByNewerRecord) findings.push({ code: 'RECLAIMED_PATH_CONFLICT', severity: 'error', worktree_id: record.worktree_id, path: record.path });
224
+ }
225
+ if (record.worktree_state === 'reclaimed') {
226
+ const branchExists = Boolean(record.branch) && gitTry(
227
+ ['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`],
228
+ loaded.context.current_worktree,
229
+ ).ok;
230
+ if (record.branch_cleanup?.status === 'failed' || branchExists) {
231
+ findings.push({
232
+ code: 'LOCAL_BRANCH_CLEANUP_FAILED',
233
+ severity: 'warning',
234
+ worktree_id: record.worktree_id,
235
+ branch: record.branch ?? null,
236
+ status: record.branch_cleanup?.status ?? 'legacy',
237
+ branch_exists: branchExists,
238
+ detail: record.branch_cleanup?.reason ?? (branchExists ? 'local branch ref still exists' : 'cleanup failure not reconciled'),
239
+ });
240
+ }
241
+ }
242
+ }
243
+
244
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {Record<string,any>[]} findings */
245
+ function collectDoctorRecordWatcherFindings(loaded, record, findings) {
246
+ if (record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state) && record.worktree_state !== 'reclaimed') {
247
+ const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
248
+ const health = watcherHealth(record, heartbeat);
249
+ const advance = record.auto_reclaim.target_advance;
250
+ if (advance) {
251
+ const prediction = advance.prediction?.state ?? 'unknown';
252
+ findings.push({
253
+ code: prediction === 'conflict'
254
+ ? 'REBASE_NEEDED'
255
+ : prediction === 'clean'
256
+ ? 'TARGET_ADVANCED_REFRESH_CLEAN'
257
+ : 'TARGET_ADVANCED_PREDICTION_UNKNOWN',
258
+ severity: 'warning',
259
+ worktree_id: record.worktree_id,
260
+ path: record.path,
261
+ target_ref: record.auto_reclaim.target_ref,
262
+ target_sha: advance.target_sha,
263
+ recorded_base_sha: advance.recorded_base_sha,
264
+ prediction: advance.prediction,
265
+ detail: prediction === 'conflict'
266
+ ? `目标已前进且 merge-tree 预判冲突;预留人工解冲突时间后运行 refresh-review ${record.task}。`
267
+ : prediction === 'clean'
268
+ ? `目标已前进且 merge-tree 干跑无冲突;可运行 refresh-review ${record.task}。`
269
+ : `目标已前进但无法可靠预判;核对后运行 refresh-review ${record.task}。`,
270
+ });
271
+ }
272
+ if (!health.healthy) {
273
+ findings.push({
274
+ code: 'AUTO_RECLAIM_WATCHER_STALE',
275
+ severity: 'error',
276
+ worktree_id: record.worktree_id,
277
+ path: watcherPath(loaded.context.common_dir, record.worktree_id),
278
+ detail: health.reason,
279
+ });
280
+ } else if (heartbeat.state?.blocked_reason && !advance) {
281
+ findings.push({
282
+ code: 'AUTO_RECLAIM_BLOCKED',
283
+ severity: 'warning',
284
+ worktree_id: record.worktree_id,
285
+ detail: heartbeat.state.blocked_reason,
286
+ });
287
+ }
288
+ }
289
+ }
290
+
291
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {ReturnType<typeof buildListing>} listing @param {Map<string,Record<string,any>>} recordsById @param {Record<string,any>[]} findings */
292
+ function collectDoctorRecordFindings(loaded, listing, recordsById, findings) {
293
+ for (const entry of listRecordCacheEntries(loaded.context.common_dir)) {
294
+ if (entry.error) { findings.push({ code: 'RECORD_CACHE_INVALID', severity: 'error', path: entry.path, detail: entry.error }); continue; }
295
+ const record = entry.record;
296
+ try { readEventChain(loaded.context.common_dir, record.worktree_id); } catch (error) { findings.push({ code: error.code ?? 'EVENT_CHAIN_INVALID', severity: 'error', worktree_id: record.worktree_id, detail: error.message }); }
297
+ // archived 是"已经确认安全、停止刷屏"的历史 record:event chain / record cache 完整性仍然
298
+ // 检查(上面两步),但命名 DoD 与全部生命周期/元数据/watcher finding 一律不再生成——
299
+ // 这些都是"这条 worktree 还要不要处理"的提示,archive 已经回答过这个问题。
300
+ if (record.worktree_state === 'archived') continue;
301
+ if (record.worktree_state !== 'reclaimed') {
302
+ try {
303
+ validateTaskNaming(record.task, loaded.profile.task_naming);
304
+ } catch (error) {
305
+ findings.push({
306
+ code: error instanceof WorktreeProfileError ? error.code : 'TASK_NAMING_DOD_FAILED',
307
+ severity: 'error',
308
+ worktree_id: record.worktree_id,
309
+ path: record.path,
310
+ task: record.task,
311
+ detail: error instanceof Error ? error.message : String(error),
312
+ });
313
+ }
314
+ }
315
+ collectDoctorRecordMetadataFindings(loaded, listing, recordsById, record, findings);
316
+ collectDoctorRecordLifecycleFindings(loaded, listing, record, findings);
317
+ collectDoctorRecordWatcherFindings(loaded, record, findings);
318
+ }
319
+ }
320
+
321
+
322
+ /** @param {ReturnType<typeof buildListing>} listing @param {Record<string,any>[]} findings */
323
+ function collectDoctorSupersessionFindings(listing, findings) {
324
+ const relationPairs = new Map();
325
+ for (const record of listing.records) {
326
+ if (record.delivery_relation?.kind === 'supersedes' && record.delivery_relation.superseded_worktree_id) {
327
+ const oldId = record.delivery_relation.superseded_worktree_id;
328
+ relationPairs.set(`${oldId}\u0000${record.worktree_id}`, { oldId, replacementId: record.worktree_id });
329
+ }
330
+ if (record.superseded_by?.worktree_id) {
331
+ const replacementId = record.superseded_by.worktree_id;
332
+ relationPairs.set(`${record.worktree_id}\u0000${replacementId}`, { oldId: record.worktree_id, replacementId });
333
+ }
334
+ }
335
+ for (const { oldId, replacementId } of relationPairs.values()) {
336
+ const oldRecord = listing.records.find((record) => record.worktree_id === oldId);
337
+ const replacement = listing.records.find((record) => record.worktree_id === replacementId);
338
+ const reciprocal = Boolean(
339
+ oldRecord &&
340
+ replacement &&
341
+ oldRecord.superseded_by?.worktree_id === replacementId &&
342
+ replacement.delivery_relation?.kind === 'supersedes' &&
343
+ replacement.delivery_relation.superseded_worktree_id === oldId &&
344
+ replacement.delivery_relation.related_worktree_ids?.includes(oldId),
345
+ );
346
+ if (!reciprocal) {
347
+ findings.push({
348
+ code: 'SUPERSESSION_RELATION_BROKEN',
349
+ severity: 'error',
350
+ worktree_id: oldId,
351
+ replacement_worktree_id: replacementId,
352
+ detail: 'superseded_by 与 delivery_relation 必须双向一致;使用 supersede 命令补齐后再回收。',
353
+ });
354
+ }
355
+ }
356
+ }
357
+
358
+ /** @param {ReturnType<typeof buildListing>} listing @param {Record<string,any>[]} findings */
359
+ function collectDoctorSessionFindings(listing, findings) {
360
+ const unreclaimedBySession = new Map();
361
+ for (const record of listing.records.filter((candidate) => !isSettledWorktreeState(candidate.worktree_state))) {
362
+ const key = `${record.agent?.host ?? 'unknown'}\u0000${record.agent?.id ?? 'unknown'}`;
363
+ const group = unreclaimedBySession.get(key) ?? [];
364
+ group.push(record);
365
+ unreclaimedBySession.set(key, group);
366
+ }
367
+ for (const group of unreclaimedBySession.values()) {
368
+ if (group.length < 2) continue;
369
+ const ordered = [...group].sort((left, right) => String(left.created_at).localeCompare(String(right.created_at)));
370
+ const earlierIds = new Set();
371
+ const undeclared = [];
372
+ for (const record of ordered) {
373
+ const related = record.delivery_relation?.related_worktree_ids ?? [];
374
+ if (earlierIds.size > 0 && !related.some((id) => earlierIds.has(id))) undeclared.push(record);
375
+ earlierIds.add(record.worktree_id);
376
+ }
377
+ if (undeclared.length > 0) {
378
+ findings.push({
379
+ code: 'UNDECLARED_SESSION_WORKTREE_MULTIPLICITY',
380
+ severity: 'warning',
381
+ agent: ordered[0].agent,
382
+ worktree_ids: ordered.map((record) => record.worktree_id),
383
+ tasks: ordered.map((record) => record.task),
384
+ undeclared_worktree_ids: undeclared.map((record) => record.worktree_id),
385
+ detail: '同一 Agent 会话存在多棵未回收 worktree,且后建树未声明 parallel/supersedes 关系;复查是否应复用或回收。',
386
+ });
387
+ }
388
+ }
389
+ }
390
+
391
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>[]} findings */
392
+ function collectDoctorRuntimeFindings(loaded, findings) {
393
+ const locks = traceLayout(loaded.context.common_dir).locks;
394
+ if (existsSync(locks)) for (const name of readdirSync(locks)) if (name.endsWith('.lock')) {
395
+ const id = name.slice(0, -5); const finding = inspectRecordLock(loaded.context.common_dir, id);
396
+ if (finding.state === 'stale' || finding.state === 'malformed') findings.push({ code: `LOCK_${finding.state.toUpperCase()}`, severity: 'error', worktree_id: id, detail: finding });
397
+ }
398
+ if (loaded.context.current_worktree !== loaded.context.primary_worktree) {
399
+ const local = join(loaded.context.current_worktree, PROFILE_FILENAME);
400
+ if (existsSync(local) && existsSync(loaded.profile_path) && readFileSync(local, 'utf8') !== readFileSync(loaded.profile_path, 'utf8')) findings.push({ code: 'PROFILE_DRIFT', severity: 'warning', path: local, authoritative: loaded.profile_path });
401
+ }
402
+ const learning = learningRoot(loaded.context.common_dir);
403
+ const reflectionById = new Map();
404
+ const reflectionDir = join(learning, 'reflections');
405
+ if (existsSync(reflectionDir)) for (const name of readdirSync(reflectionDir).filter((item) => item.endsWith('.json'))) {
406
+ const path = join(reflectionDir, name);
407
+ try {
408
+ const value = JSON.parse(readFileSync(path, 'utf8'));
409
+ const clone = { ...value }; delete clone.reflection_digest;
410
+ if (contentDigest(Buffer.from(JSON.stringify(canonicalJson(clone)))) !== value.reflection_digest) throw new Error('reflection digest mismatch');
411
+ const eventRef = value.evidence_refs?.find((item) => item.type === 'event');
412
+ const match = /^([^:]+):(.+)$/u.exec(eventRef?.id ?? '');
413
+ if (!match) throw new Error('event ref invalid');
414
+ const event = readEventChain(loaded.context.common_dir, match[1]).find((item) => item.event_id === match[2]);
415
+ if (!event || contentDigest(Buffer.from(JSON.stringify(canonicalJson(event)))) !== eventRef.digest) throw new Error('event evidence digest mismatch');
416
+ reflectionById.set(value.reflection_id, value);
417
+ } catch (error) {
418
+ findings.push({ code: 'LEARNING_REFLECTION_INVALID', severity: 'error', path, detail: error instanceof Error ? error.message : String(error) });
419
+ }
420
+ }
421
+ const proposalDir = join(learning, 'proposals');
422
+ if (existsSync(proposalDir)) for (const name of readdirSync(proposalDir).filter((item) => item.endsWith('.json'))) {
423
+ const path = join(proposalDir, name);
424
+ try {
425
+ const value = JSON.parse(readFileSync(path, 'utf8'));
426
+ const clone = { ...value }; delete clone.proposal_digest;
427
+ if (value.lifecycle !== 'proposed' || contentDigest(Buffer.from(JSON.stringify(canonicalJson(clone)))) !== value.proposal_digest) throw new Error('proposal digest/lifecycle invalid');
428
+ if (!(value.source_reflections ?? []).every((item) => reflectionById.get(item.reflection_id)?.reflection_digest === item.reflection_digest)) throw new Error('proposal reflection binding invalid');
429
+ } catch (error) {
430
+ findings.push({ code: 'LEARNING_PROPOSAL_INVALID', severity: 'error', path, detail: error instanceof Error ? error.message : String(error) });
431
+ }
432
+ }
433
+ }
434
+
435
+ /** WORKTREE_MISSING/BASE_OVERRIDE/EPHEMERAL_WORKTREE 三类 warning 只在目录已经不存在的
436
+ * record 上折叠;archived 已经被上游整段跳过、不会再产生这三类 finding,这里只需要覆盖
437
+ * "还没 archive、但目录已经不见了"的存量噪声。 */
438
+ const FOLDABLE_MISSING_CODES = new Set(['WORKTREE_MISSING', 'BASE_OVERRIDE', 'EPHEMERAL_WORKTREE']);
439
+
440
+ /** @param {ReturnType<typeof buildListing>} listing */
441
+ function missingWorktreeIds(listing) {
442
+ return new Set(
443
+ listing.records
444
+ .filter((record) => !isSettledWorktreeState(record.worktree_state))
445
+ .filter((record) => !listing.rows.some((row) => row.path === canonicalSelectorPath(record.path)))
446
+ .map((record) => record.worktree_id),
447
+ );
448
+ }
449
+
450
+ function cmdDoctor(args) {
451
+ rejectUnknownFlags(args.flags, ['json', 'verbose', 'config']);
452
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
453
+ const findings = [];
454
+ const listing = buildListing(true, loaded);
455
+ const recordsById = new Map(listing.records.map((record) => [record.worktree_id, record]));
456
+ const profileDrift = primaryProfileDriftFinding(loaded);
457
+ if (profileDrift) findings.push(profileDrift);
458
+ for (const row of listing.rows.filter((row) => row.kind === 'UNTRACKED')) {
459
+ findings.push({ code: 'UNTRACKED_WORKTREE', severity: 'warning', path: row.path, branch: row.branch });
460
+ if (classifyStorage(row.path, loaded.profile.ephemeral_path_patterns) === 'ephemeral') {
461
+ findings.push({ code: 'EPHEMERAL_UNTRACKED', severity: 'warning', path: row.path, branch: row.branch });
462
+ }
463
+ }
464
+ collectDoctorRecordFindings(loaded, listing, recordsById, findings);
465
+ collectDoctorSupersessionFindings(listing, findings);
466
+ collectDoctorSessionFindings(listing, findings);
467
+ collectDoctorRuntimeFindings(loaded, findings);
468
+ if (args.flags.get('json')) { console.log(JSON.stringify({ findings }, null, 2)); return; }
469
+ // JSON 输出永远是完整 findings,机器消费不受展示折叠影响;下面的折叠只发生在给人看的文本模式。
470
+ log(`doctor findings=${findings.length}`);
471
+ if (args.flags.get('verbose')) {
472
+ for (const finding of findings) console.log(` [${finding.severity}] ${finding.code} ${finding.path ?? finding.worktree_id ?? ''}`);
473
+ return;
474
+ }
475
+ const missingIds = missingWorktreeIds(listing);
476
+ const foldedIds = new Set();
477
+ for (const finding of findings) {
478
+ const foldable = finding.severity === 'warning'
479
+ && FOLDABLE_MISSING_CODES.has(finding.code)
480
+ && finding.worktree_id
481
+ && missingIds.has(finding.worktree_id);
482
+ if (foldable) { foldedIds.add(finding.worktree_id); continue; }
483
+ console.log(` [${finding.severity}] ${finding.code} ${finding.path ?? finding.worktree_id ?? ''}`);
484
+ }
485
+ if (foldedIds.size > 0) console.log(` [summary] missing_worktrees=${foldedIds.size} (run doctor --verbose to expand)`);
486
+ }
487
+
488
+
489
+
490
+ return {
491
+ cmdDoctor,
492
+ };
493
+ }