@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,683 @@
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
+ rmSync,
14
+ statSync,
15
+ join,
16
+ resolve,
17
+ loadRepositoryProfile,
18
+ WorktreeTraceError,
19
+ appendTraceEvent,
20
+ TERMINAL_TASK_STATES,
21
+ git,
22
+ gitTry,
23
+ commandFailureReason,
24
+ deliverReclaimNotification,
25
+ log,
26
+ die,
27
+ flag,
28
+ rejectUnknownFlags,
29
+ oneLine,
30
+ parseWorktrees,
31
+ loadRecords,
32
+ sameAgentSession,
33
+ canonicalSelectorPath,
34
+ selectRecord,
35
+ liveGitSnapshot,
36
+ exactCommitOid,
37
+ resolvableCommitOid,
38
+ gitOperationState,
39
+ } = deps;
40
+
41
+ function appendReclaimEvent(commonDir, record, type, update, details = {}) {
42
+ return appendTraceEvent({ commonDir, worktreeId: record.worktree_id, eventType: type, actor: record.agent, details, mutate(current) { const next = structuredClone(current); update(next); next.updated_at = new Date().toISOString(); return next; } }).record;
43
+ }
44
+
45
+ /**
46
+ * watcher 与 unwatch 以同一条 record lock 为裁决边界:谁先写入 event,谁赢。
47
+ * 这样即使两个进程同时动作,已解除的 token 也不能靠旧 record 快照复活。
48
+ * @param {string} commonDir
49
+ * @param {Record<string,any>} record
50
+ * @param {string} token
51
+ * @param {string} type
52
+ * @param {(next:Record<string,any>)=>void} update
53
+ * @param {Record<string,any>} [details]
54
+ */
55
+ function appendWatchedEvent(commonDir, record, token, type, update, details = {}) {
56
+ return appendTraceEvent({
57
+ commonDir,
58
+ worktreeId: record.worktree_id,
59
+ eventType: type,
60
+ actor: record.agent,
61
+ details,
62
+ mutate(current) {
63
+ if (current.auto_reclaim?.token !== token || ['disarmed', 'reclaimed'].includes(current.auto_reclaim?.state)) {
64
+ throw new WorktreeTraceError('WATCHER_CANCELLED', `watch token 已失效: ${record.worktree_id}`);
65
+ }
66
+ const next = structuredClone(current);
67
+ update(next);
68
+ next.updated_at = new Date().toISOString();
69
+ return next;
70
+ },
71
+ }).record;
72
+ }
73
+
74
+ /** @param {Record<string,any>} record */
75
+ function supersededArchiveRef(record) {
76
+ return `refs/worktree-archive/superseded/${record.worktree_id}`;
77
+ }
78
+
79
+ /** @param {Record<string,any>} record */
80
+ function evidenceArchiveRef(record) {
81
+ return `refs/worktree-archive/evidence/${record.worktree_id}`;
82
+ }
83
+
84
+ /**
85
+ * 归档只用于已冻结终态的本地批次证据候选;它保存精确 Git object,不伪装成已推送或已合入。
86
+ * @param {ReturnType<typeof loadRepositoryProfile>} loaded
87
+ * @param {Record<string,any>} candidate
88
+ * @param {string} candidateInput
89
+ * @param {string} reasonInput
90
+ */
91
+ function prepareEvidenceArchiveReclaim(loaded, candidate, candidateInput, reasonInput) {
92
+ const batch = candidate.batch_integration;
93
+ const result = candidate.batch_result;
94
+ if (!batch || batch.state !== 'composed') die('reclaim --archive-evidence 只接受已合成的 batch integration candidate。', 2);
95
+ if (!result || !['passed', 'failed', 'stale'].includes(result.outcome)) {
96
+ die('候选尚未通过 batch-result 冻结 passed/failed/stale,拒绝归档回收。', 2);
97
+ }
98
+ if (candidate.task_status !== 'done') die(`证据候选必须处于 done;当前 ${candidate.task_status}。`, 2);
99
+ const sourceSha = exactCommitOid(loaded.context.current_worktree, candidateInput, '--archive-evidence');
100
+ const reason = oneLine(reasonInput, '--reason', 500);
101
+ const snapshot = liveGitSnapshot(candidate);
102
+ const liveHead = snapshot.head ?? candidate.last_head ?? candidate.reclaim_summary?.source_sha ?? null;
103
+ if (!liveHead || liveHead.toLowerCase() !== sourceSha) die(`--archive-evidence 与候选 HEAD 不一致:expected ${liveHead ?? 'unknown'}`, 2);
104
+ if (result.candidate_sha !== sourceSha) die(`--archive-evidence 与 batch_result.candidate_sha 不一致:expected ${result.candidate_sha}`, 2);
105
+ const preflight = reclaimPreflight(loaded, candidate, sourceSha);
106
+ if (preflight.reason) die(`证据候选尚未达到归档前置条件:${preflight.reason}`, 2);
107
+
108
+ const archiveRef = evidenceArchiveRef(candidate);
109
+ if (candidate.evidence_archive && (
110
+ candidate.evidence_archive.source_sha !== sourceSha ||
111
+ candidate.evidence_archive.archive_ref !== archiveRef ||
112
+ candidate.evidence_archive.batch_result_digest !== result.result_digest ||
113
+ candidate.evidence_archive.reason !== reason
114
+ )) die('该候选已经登记不同的证据归档,拒绝改写。', 2);
115
+ const existing = gitTry(['rev-parse', '--verify', archiveRef], loaded.context.current_worktree);
116
+ if (existing.ok && existing.out.toLowerCase() !== sourceSha) die(`归档 ref 已指向其他提交:${archiveRef} -> ${existing.out}`, 2);
117
+ if (!existing.ok) {
118
+ const archived = gitTry(['update-ref', archiveRef, sourceSha, '0'.repeat(sourceSha.length)], loaded.context.current_worktree);
119
+ if (!archived.ok) die(commandFailureReason(archived, `无法创建归档 ref ${archiveRef}`));
120
+ }
121
+ const verified = gitTry(['rev-parse', '--verify', archiveRef], loaded.context.current_worktree);
122
+ if (!verified.ok || verified.out.toLowerCase() !== sourceSha) die(`归档 ref 校验失败:${archiveRef}`, 2);
123
+ const evidence = {
124
+ kind: 'batch_evidence_archive',
125
+ source_sha: sourceSha,
126
+ archive_ref: archiveRef,
127
+ outcome: result.outcome,
128
+ fingerprint: batch.fingerprint,
129
+ target_sha: batch.target_sha,
130
+ ordered_input_shas: (batch.ordered_inputs ?? []).map((item) => item.head),
131
+ batch_result_digest: result.result_digest,
132
+ evidence_manifest_digest: result.evidence_manifest_digest,
133
+ reason,
134
+ };
135
+ let record = candidate;
136
+ if (!record.evidence_archive) {
137
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'batch_evidence_head_archived', (next) => {
138
+ next.evidence_archive = { ...evidence, archived_at: new Date().toISOString() };
139
+ }, evidence);
140
+ }
141
+ return { record, sourceSha, evidence };
142
+ }
143
+
144
+ /**
145
+ * superseded reclaim 比普通 pushed reclaim 多两层证据:双向替代关系必须完整,且替代树不能脏。
146
+ * 旧树的普通干净/stash/submodule 检查仍统一交给 reclaimPreflight。
147
+ * @param {ReturnType<typeof loadRepositoryProfile>} loaded
148
+ * @param {Record<string,any>} superseded
149
+ * @param {Record<string,any>} replacement
150
+ * @param {string|null} discardSha
151
+ */
152
+ function prepareSupersededReclaim(loaded, superseded, replacement, discardSha) {
153
+ if (superseded.task_status !== 'abandoned') {
154
+ die(`reclaim --superseded-by 只接受 abandoned 旧树;当前 ${superseded.task_status}。`, 2);
155
+ }
156
+ if (!sameAgentSession(superseded, replacement)) die('旧树与替代树不属于同一 Agent 会话。', 2);
157
+ if (superseded.owner && replacement.owner && superseded.owner !== replacement.owner) {
158
+ die(`旧树与替代树 owner 不一致:${superseded.owner} != ${replacement.owner}。`, 2);
159
+ }
160
+ if (
161
+ superseded.superseded_by?.worktree_id !== replacement.worktree_id ||
162
+ replacement.delivery_relation?.kind !== 'supersedes' ||
163
+ replacement.delivery_relation.superseded_worktree_id !== superseded.worktree_id ||
164
+ !replacement.delivery_relation.related_worktree_ids?.includes(superseded.worktree_id)
165
+ ) {
166
+ die('替代关系未双向登记;先运行 supersede <old> --by <new> --reason <原因>。', 2);
167
+ }
168
+ const replacementSnapshot = liveGitSnapshot(replacement);
169
+ if (replacement.worktree_state !== 'reclaimed') {
170
+ if (!replacementSnapshot.present) die(`替代 worktree missing: ${replacement.path}`, 2);
171
+ if (replacementSnapshot.dirty !== false) die(`替代 worktree 必须干净:${replacement.path}`, 2);
172
+ }
173
+ const supersededSnapshot = liveGitSnapshot(superseded);
174
+ const sourceSha = supersededSnapshot.head ?? superseded.last_head ?? superseded.reclaim_summary?.source_sha ?? null;
175
+ if (!sourceSha) die('无法确定被替代树的精确 HEAD,拒绝回收。', 2);
176
+ exactCommitOid(loaded.context.current_worktree, sourceSha, '旧树 HEAD');
177
+ const preflight = reclaimPreflight(loaded, superseded, sourceSha);
178
+ if (preflight.reason) {
179
+ die(`被替代树尚未达到归档/丢弃前置条件:${preflight.reason}`, 2);
180
+ }
181
+
182
+ let record = superseded;
183
+ let evidence;
184
+ if (discardSha) {
185
+ if (!new RegExp(`^[0-9a-f]{${sourceSha.length}}$`, 'i').test(discardSha)) {
186
+ die(`--discard 必须填写 ${sourceSha.length} 位旧树精确 HEAD。`, 2);
187
+ }
188
+ if (discardSha.toLowerCase() !== sourceSha.toLowerCase()) {
189
+ die(`--discard SHA 与旧树 HEAD 不一致:expected ${sourceSha}`, 2);
190
+ }
191
+ if (record.superseded_recovery && (
192
+ record.superseded_recovery.mode !== 'discard' ||
193
+ record.superseded_recovery.source_sha !== sourceSha
194
+ )) {
195
+ die('该旧树已经登记不同的恢复策略,拒绝改写。', 2);
196
+ }
197
+ evidence = {
198
+ kind: 'superseded_discard',
199
+ source_sha: sourceSha,
200
+ replacement_worktree_id: replacement.worktree_id,
201
+ replacement_task: replacement.task,
202
+ };
203
+ if (!record.superseded_recovery) {
204
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'superseded_head_discard_authorized', (next) => {
205
+ next.superseded_recovery = { mode: 'discard', ...evidence, authorized_at: new Date().toISOString() };
206
+ }, evidence);
207
+ }
208
+ } else {
209
+ const archiveRef = supersededArchiveRef(record);
210
+ if (record.superseded_recovery && (
211
+ record.superseded_recovery.mode !== 'archive_ref' ||
212
+ record.superseded_recovery.source_sha !== sourceSha ||
213
+ record.superseded_recovery.archive_ref !== archiveRef
214
+ )) {
215
+ die('该旧树已经登记不同的恢复策略,拒绝改写。', 2);
216
+ }
217
+ const existing = gitTry(['rev-parse', '--verify', archiveRef], loaded.context.current_worktree);
218
+ if (existing.ok && existing.out !== sourceSha) {
219
+ die(`归档 ref 已指向其他提交:${archiveRef} -> ${existing.out}`, 2);
220
+ }
221
+ if (!existing.ok) {
222
+ const archived = gitTry(['update-ref', archiveRef, sourceSha, '0'.repeat(sourceSha.length)], loaded.context.current_worktree);
223
+ if (!archived.ok) die(commandFailureReason(archived, `无法创建归档 ref ${archiveRef}`));
224
+ }
225
+ const verified = gitTry(['rev-parse', '--verify', archiveRef], loaded.context.current_worktree);
226
+ if (!verified.ok || verified.out !== sourceSha) die(`归档 ref 校验失败:${archiveRef}`, 2);
227
+ evidence = {
228
+ kind: 'superseded_archive',
229
+ source_sha: sourceSha,
230
+ archive_ref: archiveRef,
231
+ replacement_worktree_id: replacement.worktree_id,
232
+ replacement_task: replacement.task,
233
+ };
234
+ if (!record.superseded_recovery) {
235
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'superseded_head_archived', (next) => {
236
+ next.superseded_recovery = { mode: 'archive_ref', ...evidence, archived_at: new Date().toISOString() };
237
+ }, evidence);
238
+ }
239
+ }
240
+ return { record, sourceSha, evidence };
241
+ }
242
+
243
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
244
+ function reclaimPreflight(loaded, record, pushed) {
245
+ const stash = gitTry(['stash', 'list'], loaded.context.current_worktree);
246
+ const live = parseWorktrees(loaded.context.current_worktree).find((worktree) => worktree.path === canonicalSelectorPath(record.path));
247
+ const dangling = live ? inspectDanglingSubmodulePointers(live.path) : { reason: null };
248
+ const status = live ? gitTry(['status', '--porcelain'], live.path) : { ok: true, out: '' };
249
+ const operation = live ? gitOperationState(live.path) : null;
250
+ const commit = record.branch && gitTry(['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`], loaded.context.current_worktree).ok
251
+ ? record.branch
252
+ : record.last_head;
253
+ const merged = commit ? gitTry(['merge-base', '--is-ancestor', commit, pushed], loaded.context.current_worktree).ok : false;
254
+ const reason = dangling.reason
255
+ ? dangling.reason
256
+ : stash.ok && stash.out
257
+ ? 'repository has stash entries'
258
+ : operation
259
+ ? `git operation in progress: ${operation}`
260
+ : !status.ok || status.out
261
+ ? 'worktree dirty/unreadable'
262
+ : !merged
263
+ ? 'branch/head not merged into pushed sha'
264
+ : null;
265
+ return { reason, live };
266
+ }
267
+
268
+ /**
269
+ * `--pushed` 必须由候选自身分支之外的持久 ref 保护,否则传入 HEAD 自己会在删分支后丢失证据。
270
+ * @param {ReturnType<typeof loadRepositoryProfile>} loaded
271
+ * @param {Record<string,any>} record
272
+ * @param {string} pushed
273
+ */
274
+ function protectingRefsForPushed(loaded, record, pushed) {
275
+ const refs = gitTry([
276
+ 'for-each-ref', '--format=%(refname)', '--contains', pushed,
277
+ 'refs/heads', 'refs/remotes', 'refs/tags', 'refs/worktree-archive',
278
+ ], loaded.context.current_worktree);
279
+ if (!refs.ok) die(commandFailureReason(refs, '无法枚举保护 --pushed SHA 的 refs。'));
280
+ const ownBranch = record.branch ? `refs/heads/${record.branch}` : null;
281
+ return refs.out.split('\n').filter(Boolean).filter((ref) => ref !== ownBranch).sort();
282
+ }
283
+
284
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record */
285
+ function localBranchExists(loaded, record) {
286
+ return Boolean(record.branch) && gitTry(
287
+ ['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`],
288
+ loaded.context.current_worktree,
289
+ ).ok;
290
+ }
291
+
292
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
293
+ function attemptLocalBranchCleanup(loaded, record, pushed) {
294
+ const checkedAt = new Date().toISOString();
295
+ const previousAttempts = Number(record.branch_cleanup?.attempts ?? 0);
296
+ if (!record.branch || !localBranchExists(loaded, record)) {
297
+ return {
298
+ status: 'absent',
299
+ branch: record.branch ?? null,
300
+ attempts: previousAttempts,
301
+ checked_at: checkedAt,
302
+ reason: null,
303
+ };
304
+ }
305
+ if (!gitTry(['merge-base', '--is-ancestor', record.branch, pushed], loaded.context.current_worktree).ok) {
306
+ return {
307
+ status: 'failed',
308
+ branch: record.branch,
309
+ attempts: previousAttempts + 1,
310
+ checked_at: checkedAt,
311
+ reason: `local branch tip is not merged into pushed sha: ${pushed}`,
312
+ };
313
+ }
314
+ const removed = gitTry(['branch', '-D', '--', record.branch], loaded.context.current_worktree);
315
+ if (removed.ok) {
316
+ return {
317
+ status: 'deleted',
318
+ branch: record.branch,
319
+ attempts: previousAttempts + 1,
320
+ checked_at: checkedAt,
321
+ reason: null,
322
+ };
323
+ }
324
+ return {
325
+ status: 'failed',
326
+ branch: record.branch,
327
+ attempts: previousAttempts + 1,
328
+ checked_at: checkedAt,
329
+ reason: commandFailureReason(removed, 'git branch -D failed'),
330
+ };
331
+ }
332
+
333
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
334
+ function reconcileReclaimedBranchCleanup(loaded, record, pushed) {
335
+ const branchExists = localBranchExists(loaded, record);
336
+ if (['deleted', 'absent'].includes(record.branch_cleanup?.status) && !branchExists) {
337
+ return { record, branch_cleanup: record.branch_cleanup, changed: false };
338
+ }
339
+ const cleanup = attemptLocalBranchCleanup(loaded, record, pushed);
340
+ const updated = appendReclaimEvent(loaded.context.common_dir, record, 'branch_cleanup_retried', (next) => {
341
+ next.branch_cleanup = cleanup;
342
+ if (next.reclaim_summary) next.reclaim_summary.branch_cleanup = cleanup;
343
+ }, { pushed, branch_cleanup: cleanup });
344
+ return { record: updated, branch_cleanup: cleanup, changed: true };
345
+ }
346
+
347
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record */
348
+ function reconcileReclaimedTerminalState(loaded, record) {
349
+ const finalEpoch = record.ownership_epochs?.at(-1);
350
+ const needsStatus = !TERMINAL_TASK_STATES.has(record.task_status);
351
+ const needsEpoch = Boolean(finalEpoch && !finalEpoch.ended_at);
352
+ if (!needsStatus && !needsEpoch) return { record, changed: false };
353
+ const branchHead = record.branch
354
+ ? gitTry(['rev-parse', '--verify', record.branch], loaded.context.current_worktree)
355
+ : { ok: false, out: '' };
356
+ const endSha = branchHead.ok
357
+ ? branchHead.out
358
+ : record.last_head ?? record.reclaim_summary?.source_sha ?? null;
359
+ const completedAt = record.reclaimed_at ?? new Date().toISOString();
360
+ const updated = appendReclaimEvent(loaded.context.common_dir, record, 'reclaim_terminal_reconciled', (next) => {
361
+ if (next.task_status !== 'abandoned') next.task_status = 'done';
362
+ const epoch = next.ownership_epochs?.at(-1);
363
+ if (epoch && !epoch.ended_at && endSha) {
364
+ epoch.end_sha = endSha;
365
+ epoch.ended_at = completedAt;
366
+ }
367
+ }, { task_status: needsStatus ? 'done' : record.task_status, end_sha: endSha });
368
+ return { record: updated, changed: true };
369
+ }
370
+
371
+ /**
372
+ * 从 .gitmodules 枚举登记的 submodule 路径。不打开任何 submodule 仓库——
373
+ * `git submodule status` 在 .git 指针悬空(指向已删元数据)时整条命令 fatal,
374
+ * 残骸清理阶段只能走这条纯文件读取的枚举。
375
+ * @param {string} worktreePath
376
+ */
377
+ function registeredSubmodulePaths(worktreePath) {
378
+ const config = gitTry(['config', '-f', '.gitmodules', '--get-regexp', String.raw`^submodule\..*\.path$`], worktreePath);
379
+ if (!config.ok || !config.out) return [];
380
+ return config.out.split('\n')
381
+ .map((line) => line.trim())
382
+ .filter(Boolean)
383
+ .map((line) => line.split(/\s+/).slice(1).join(' '))
384
+ .filter(Boolean);
385
+ }
386
+
387
+ /**
388
+ * 读 submodule 工作目录 .git 指针文件的 gitdir 目标(绝对路径)。
389
+ * 缺失、真目录(嵌入式仓库)或格式不符时返回 null。
390
+ * @param {string} submoduleDir
391
+ */
392
+ function submoduleGitPointerTarget(submoduleDir) {
393
+ const gitPointer = join(submoduleDir, '.git');
394
+ if (!existsSync(gitPointer) || statSync(gitPointer).isDirectory()) return null;
395
+ let content;
396
+ try {
397
+ content = readFileSync(gitPointer, 'utf8');
398
+ } catch {
399
+ return null;
400
+ }
401
+ const match = content.match(/^gitdir:\s*(.+?)\s*$/m);
402
+ return match ? resolve(submoduleDir, match[1]) : null;
403
+ }
404
+
405
+ /**
406
+ * 检测悬空 .git 指针。元数据缺失时无法证明目录内容干净,因此必须 fail-closed 返回 KEEP,
407
+ * 由人工检查并修复;不能把不可审计内容当作残骸自动删除。
408
+ * @param {string} worktreePath
409
+ */
410
+ function inspectDanglingSubmodulePointers(worktreePath) {
411
+ for (const submodulePath of registeredSubmodulePaths(worktreePath)) {
412
+ const submoduleDir = join(worktreePath, submodulePath);
413
+ const target = submoduleGitPointerTarget(submoduleDir);
414
+ if (!target || existsSync(target)) continue;
415
+ return { reason: `submodule has dangling .git pointer and cannot be audited safely: ${submodulePath}` };
416
+ }
417
+ return { reason: null };
418
+ }
419
+
420
+ /**
421
+ * `git submodule status` 每行前缀标出初始化状态:'-' = 未初始化,其余(空格/'+'/'U')均已初始化。
422
+ * 未初始化的也要列出:它可能留有历史清理残骸(树私有 modules/ 元数据、工作目录里的
423
+ * .git 指针文件),同样会让 `git worktree remove` 拒绝或 fatal。
424
+ * @param {string} worktreePath
425
+ * @returns {Array<{path:string,initialized:boolean}>}
426
+ */
427
+ function listSubmoduleEntries(worktreePath) {
428
+ const status = gitTry(['submodule', 'status'], worktreePath);
429
+ if (!status.ok || !status.out) return [];
430
+ return status.out.split('\n')
431
+ .map((line) => line.trimEnd())
432
+ .filter(Boolean)
433
+ .map((line) => ({ initialized: line[0] !== '-', path: line.trim().split(/\s+/)[1] }))
434
+ .filter((entry) => Boolean(entry.path));
435
+ }
436
+
437
+ /**
438
+ * deinit 完成(或本就未初始化)的 submodule 工作目录必须为空;任何残留都无法被 Git
439
+ * 可靠审计,保守返回 KEEP,不执行递归删除。
440
+ * @param {string} worktreePath @param {string} submodulePath
441
+ */
442
+ function ensureSubmoduleWorkdirEmpty(worktreePath, submodulePath) {
443
+ const submoduleDir = join(worktreePath, submodulePath);
444
+ if (!existsSync(submoduleDir)) return { reason: null };
445
+ try {
446
+ const entries = readdirSync(submoduleDir);
447
+ if (entries.length > 0) return { reason: `uninitialized submodule workdir is not empty: ${submodulePath}` };
448
+ } catch (error) {
449
+ return { reason: `failed to inspect submodule workdir: ${submodulePath}: ${error instanceof Error ? error.message : String(error)}` };
450
+ }
451
+ return { reason: null };
452
+ }
453
+
454
+ /**
455
+ * 含 submodule 的树即使标准四项审计(台账/干净/已合入/无 stash)通过,`git worktree remove`
456
+ * 非 force 仍会因树私有的 submodule 克隆元数据($GIT_COMMON_DIR/worktrees/<id>/modules/)拒绝。
457
+ * 逐个校验已初始化 submodule 工作区干净后 deinit,再清理该元数据与各 submodule 工作目录
458
+ * 残骸,为后续 remove 让路;任一 submodule 脏则不动它,原样返回让调用方 KEEP。
459
+ * 清理触发看「元数据/残骸是否存在」而非初始化状态:submodule 已被 deinit 但元数据或
460
+ * .git 指针残留时(历史清理中断的产物),同样要收拾干净。
461
+ * @param {string} worktreePath
462
+ */
463
+ function reclaimSubmodules(worktreePath) {
464
+ const entries = listSubmoduleEntries(worktreePath);
465
+ const gitDir = gitTry(['rev-parse', '--absolute-git-dir'], worktreePath);
466
+ const modulesDir = gitDir.ok && gitDir.out ? join(gitDir.out, 'modules') : null;
467
+ const hasModulesMetadata = modulesDir !== null && existsSync(modulesDir);
468
+ if (entries.length === 0 && !hasModulesMetadata) return { reason: null };
469
+ for (const entry of entries) {
470
+ if (entry.initialized) {
471
+ const status = gitTry(['status', '--porcelain'], join(worktreePath, entry.path));
472
+ if (!status.ok || status.out) return { reason: `submodule dirty/unreadable: ${entry.path}` };
473
+ } else {
474
+ const gitPointer = join(worktreePath, entry.path, '.git');
475
+ if (existsSync(gitPointer) && statSync(gitPointer).isDirectory()) {
476
+ return { reason: `submodule workdir has embedded .git directory, refusing residue cleanup: ${entry.path}` };
477
+ }
478
+ const empty = ensureSubmoduleWorkdirEmpty(worktreePath, entry.path);
479
+ if (empty.reason) return empty;
480
+ }
481
+ }
482
+ if (entries.some((entry) => entry.initialized)) {
483
+ const deinit = gitTry(['submodule', 'deinit', '--all', '-f'], worktreePath);
484
+ if (!deinit.ok) return { reason: commandFailureReason(deinit, 'git submodule deinit failed') };
485
+ }
486
+ if (hasModulesMetadata) {
487
+ try {
488
+ rmSync(modulesDir, { recursive: true, force: true });
489
+ } catch (error) {
490
+ return { reason: `failed to remove submodule metadata: ${error instanceof Error ? error.message : String(error)}` };
491
+ }
492
+ }
493
+ for (const entry of entries) {
494
+ const empty = ensureSubmoduleWorkdirEmpty(worktreePath, entry.path);
495
+ if (empty.reason) return empty;
496
+ }
497
+ return { reason: null };
498
+ }
499
+
500
+ /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} initialRecord @param {string} pushed @param {{recordBlocked?:boolean,evidence?:Record<string,any>}} [options] */
501
+ function reclaimRecord(loaded, initialRecord, pushed, options = {}) {
502
+ let record = initialRecord;
503
+ if (record.worktree_state === 'reclaimed') {
504
+ const terminal = reconcileReclaimedTerminalState(loaded, record);
505
+ const reconciled = reconcileReclaimedBranchCleanup(loaded, terminal.record, pushed);
506
+ return {
507
+ reclaimed: true,
508
+ reason: reconciled.branch_cleanup?.status === 'failed' ? reconciled.branch_cleanup.reason : null,
509
+ record: reconciled.record,
510
+ branch_cleanup: reconciled.branch_cleanup,
511
+ branch_cleanup_changed: terminal.changed || reconciled.changed,
512
+ };
513
+ }
514
+ const registeredAtStart = parseWorktrees(loaded.context.current_worktree)
515
+ .find((worktree) => worktree.path === canonicalSelectorPath(record.path));
516
+ if (!registeredAtStart && existsSync(record.path)) {
517
+ return {
518
+ reclaimed: false,
519
+ reason: 'physical directory remains without Git worktree registration; refusing to mark reclaimed before manual recovery',
520
+ record,
521
+ };
522
+ }
523
+ if (record.worktree_state !== 'reclaim_ready') {
524
+ const preflight = reclaimPreflight(loaded, record, pushed);
525
+ if (preflight.reason) {
526
+ if (options.recordBlocked ?? true) {
527
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaim_blocked', () => {}, { reason: preflight.reason, pushed, evidence: options.evidence ?? null });
528
+ }
529
+ return { reclaimed: false, reason: preflight.reason, record };
530
+ }
531
+ const finalHead = preflight.live?.head ?? record.last_head;
532
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'final_snapshot', (next) => {
533
+ next.last_head = finalHead;
534
+ next.last_seen_at = new Date().toISOString();
535
+ }, { pushed, evidence: options.evidence ?? null });
536
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaim_ready', (next) => {
537
+ next.worktree_state = 'reclaim_ready';
538
+ }, { pushed, evidence: options.evidence ?? null });
539
+ }
540
+
541
+ const live = parseWorktrees(loaded.context.current_worktree).find((worktree) => worktree.path === canonicalSelectorPath(record.path));
542
+ if (live) {
543
+ const submodules = reclaimSubmodules(live.path);
544
+ if (submodules.reason) return { reclaimed: false, reason: submodules.reason, record };
545
+ const removed = gitTry(['worktree', 'remove', live.path], loaded.context.current_worktree);
546
+ if (!removed.ok) {
547
+ const detail = commandFailureReason(removed, 'git worktree remove refused');
548
+ const stillRegistered = parseWorktrees(loaded.context.current_worktree)
549
+ .some((worktree) => worktree.path === canonicalSelectorPath(record.path));
550
+ const residue = !stillRegistered && existsSync(record.path)
551
+ ? '; Git registration was removed but the physical directory remains'
552
+ : '';
553
+ const reason = `${detail}${residue}`;
554
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaim_failed', (next) => {
555
+ next.last_reclaim_error = {
556
+ reason,
557
+ attempted_at: new Date().toISOString(),
558
+ registration_present: stillRegistered,
559
+ physical_directory_present: existsSync(record.path),
560
+ };
561
+ }, { pushed, evidence: options.evidence ?? null, reason, registration_present: stillRegistered, physical_directory_present: existsSync(record.path) });
562
+ return { reclaimed: false, reason, record };
563
+ }
564
+ if (existsSync(record.path)) {
565
+ return {
566
+ reclaimed: false,
567
+ reason: 'git worktree remove returned success but the physical directory remains',
568
+ record,
569
+ };
570
+ }
571
+ }
572
+ gitTry(['worktree', 'prune'], loaded.context.current_worktree);
573
+ const branchCleanup = attemptLocalBranchCleanup(loaded, record, pushed);
574
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaimed', (next) => {
575
+ const completedAt = new Date().toISOString();
576
+ const finalEpoch = next.ownership_epochs?.at(-1);
577
+ if (finalEpoch && !finalEpoch.ended_at) {
578
+ finalEpoch.end_sha = next.last_head;
579
+ finalEpoch.ended_at = completedAt;
580
+ }
581
+ if (next.task_status !== 'abandoned') next.task_status = 'done';
582
+ next.worktree_state = 'reclaimed';
583
+ next.reclaimed_at = completedAt;
584
+ next.branch_cleanup = branchCleanup;
585
+ next.reclaim_summary = {
586
+ worktree_id: next.worktree_id,
587
+ task: next.task,
588
+ change_ref: next.auto_reclaim?.change_ref ?? null,
589
+ source_sha: next.auto_reclaim?.head_sha ?? next.last_head ?? null,
590
+ target_ref: options.evidence?.archive_ref ?? next.auto_reclaim?.target_ref ?? next.base_ref ?? null,
591
+ target_sha: pushed,
592
+ completed_at: completedAt,
593
+ branch_cleanup: branchCleanup,
594
+ reclaim_evidence: options.evidence ?? { kind: 'pushed', target_sha: pushed },
595
+ };
596
+ if (next.auto_reclaim) {
597
+ next.auto_reclaim.state = 'reclaimed';
598
+ next.auto_reclaim.completed_at = next.reclaimed_at;
599
+ }
600
+ }, { pushed, evidence: options.evidence ?? null, branch_cleanup: branchCleanup });
601
+ if (record.auto_reclaim) {
602
+ const notification = deliverReclaimNotification(record);
603
+ record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaim_notification', (next) => {
604
+ next.reclaim_notification = { ...notification, recorded_at: new Date().toISOString() };
605
+ }, notification);
606
+ }
607
+ return {
608
+ reclaimed: true,
609
+ reason: branchCleanup.status === 'failed' ? branchCleanup.reason : null,
610
+ record,
611
+ branch_cleanup: branchCleanup,
612
+ branch_cleanup_changed: true,
613
+ };
614
+ }
615
+
616
+ /** @param {string} commonDir @param {Record<string,any>} record @param {string} token @param {string} eventType @param {string} targetSha */
617
+
618
+ function cmdReclaim(args) {
619
+ rejectUnknownFlags(args.flags, ['pushed', 'superseded-by', 'replacement-id', 'discard', 'archive-evidence', 'reason', 'id', 'config']);
620
+ const pushed = flag(args.flags, 'pushed');
621
+ const supersededBy = flag(args.flags, 'superseded-by');
622
+ const discardSha = flag(args.flags, 'discard');
623
+ const archiveEvidence = flag(args.flags, 'archive-evidence');
624
+ const reason = flag(args.flags, 'reason');
625
+ const modes = [Boolean(pushed), Boolean(supersededBy), Boolean(archiveEvidence)].filter(Boolean).length;
626
+ if (modes !== 1) die('reclaim 必须且只能选择 --pushed、--superseded-by 或 --archive-evidence 之一。', 2);
627
+ if (discardSha && !supersededBy) die('--discard 只能与 --superseded-by 一起使用。', 2);
628
+ if (reason && !archiveEvidence) die('--reason 仅用于 --archive-evidence。', 2);
629
+ if (archiveEvidence && !reason) die('--archive-evidence 需要 --reason <归档原因>。', 2);
630
+ const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
631
+ const records = loadRecords(loaded.context.common_dir);
632
+ let record = selectRecord(records, args.positionals[0] ?? null, flag(args.flags, 'id'));
633
+ let evidenceSha = pushed;
634
+ let evidence = null;
635
+ if (supersededBy) {
636
+ const replacement = selectRecord(records, supersededBy, flag(args.flags, 'replacement-id'));
637
+ const prepared = prepareSupersededReclaim(loaded, record, replacement, discardSha);
638
+ record = prepared.record;
639
+ evidenceSha = prepared.sourceSha;
640
+ evidence = prepared.evidence;
641
+ } else if (archiveEvidence) {
642
+ const prepared = prepareEvidenceArchiveReclaim(loaded, record, archiveEvidence, reason);
643
+ record = prepared.record;
644
+ evidenceSha = prepared.sourceSha;
645
+ evidence = prepared.evidence;
646
+ } else {
647
+ evidenceSha = resolvableCommitOid(loaded.context.current_worktree, pushed, '--pushed');
648
+ const protectingRefs = protectingRefsForPushed(loaded, record, evidenceSha);
649
+ if (protectingRefs.length === 0) {
650
+ die('--pushed SHA 只由待删除候选分支保护;请先推送/合入到其他持久 ref,或对已冻结批次结果使用 --archive-evidence。', 2);
651
+ }
652
+ evidence = { kind: 'pushed', target_sha: evidenceSha, protecting_refs: protectingRefs };
653
+ }
654
+ const result = reclaimRecord(loaded, record, evidenceSha, { evidence });
655
+ if (!result.reclaimed) {
656
+ log(`KEEP ${record.path}: ${result.reason}`);
657
+ process.exitCode = 1;
658
+ return;
659
+ }
660
+ if (result.branch_cleanup?.status === 'failed') {
661
+ log(`目录已回收 ${result.record.worktree_id.slice(0, 8)};本地分支 ${result.record.branch} 清理待重试: ${result.branch_cleanup.reason}`);
662
+ return;
663
+ }
664
+ const recovery = result.record.evidence_archive?.archive_ref
665
+ ? `,证据归档=${result.record.evidence_archive.archive_ref}`
666
+ : result.record.superseded_recovery?.mode === 'archive_ref'
667
+ ? `,归档=${result.record.superseded_recovery.archive_ref}`
668
+ : result.record.superseded_recovery?.mode === 'discard'
669
+ ? ',旧 HEAD 已按精确 SHA 授权丢弃'
670
+ : '';
671
+ log(`已回收 ${result.record.worktree_id.slice(0, 8)} ${result.record.branch ?? '(detached)'};branch=${result.branch_cleanup?.status ?? 'legacy'}${recovery},审计历史保留。`);
672
+ }
673
+
674
+
675
+ return {
676
+ appendReclaimEvent,
677
+ appendWatchedEvent,
678
+ reclaimPreflight,
679
+ localBranchExists,
680
+ reclaimRecord,
681
+ cmdReclaim,
682
+ };
683
+ }