@fitlab-ai/agent-infra 0.9.14 → 0.9.15

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 (250) hide show
  1. package/README.md +10 -0
  2. package/README.zh-CN.md +10 -0
  3. package/bin/internal-cli.sh +45 -0
  4. package/dist/bin/internal-cli.js +93 -30
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +165 -27
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +3 -2
  7. package/dist/lib/defaults.json +103 -103
  8. package/dist/lib/fs/durable-write.js +37 -0
  9. package/dist/lib/fs/file-lock.js +111 -0
  10. package/dist/lib/host-control/audit.js +19 -0
  11. package/dist/lib/host-control/client.js +142 -0
  12. package/dist/lib/host-control/command.js +49 -0
  13. package/dist/lib/host-control/path.js +160 -0
  14. package/dist/lib/host-control/secure-fs.js +140 -0
  15. package/dist/lib/host-control/server.js +159 -0
  16. package/dist/lib/host-control/service.js +112 -0
  17. package/dist/lib/init.js +11 -2
  18. package/dist/lib/internal/cli-route-inventory.js +3 -1
  19. package/dist/lib/internal/codex-lifecycle.js +6 -6
  20. package/dist/lib/internal/demo-promotion.js +322 -0
  21. package/dist/lib/internal/demo-transcript.js +454 -0
  22. package/dist/lib/internal/host-control.js +28 -0
  23. package/dist/lib/internal/manual-validation.js +322 -0
  24. package/dist/lib/internal/platform-pr.js +18 -4
  25. package/dist/lib/internal/release-workflow.js +63 -84
  26. package/dist/lib/internal/sandbox-control.js +149 -2
  27. package/dist/lib/internal/task-artifact.js +15 -262
  28. package/dist/lib/internal/task-create.js +6 -5
  29. package/dist/lib/internal/task-event.js +37 -37
  30. package/dist/lib/internal/task-operation-registry.js +101 -3
  31. package/dist/lib/internal/task-review.js +8 -53
  32. package/dist/lib/internal/task-validate.js +30 -10
  33. package/dist/lib/owned-path.js +33 -0
  34. package/dist/lib/platform/check-status.js +11 -0
  35. package/dist/lib/platform/comment-safety.js +4 -1
  36. package/dist/lib/platform/completion-backfill.js +1 -1
  37. package/dist/lib/platform/github-data.js +241 -0
  38. package/dist/lib/platform/github-provider.js +15 -20
  39. package/dist/lib/platform/issue-comments.js +2 -2
  40. package/dist/lib/platform/issues.js +1 -179
  41. package/dist/lib/platform/pr-checks.js +12 -65
  42. package/dist/lib/platform/pr-summary.js +55 -3
  43. package/dist/lib/platform/pull-requests.js +2 -105
  44. package/dist/lib/platform/snapshots.js +2 -0
  45. package/dist/lib/platform/verification-sync.js +127 -142
  46. package/dist/lib/release/workflow.js +1 -1
  47. package/dist/lib/sandbox/commands/create.js +7 -2
  48. package/dist/lib/sandbox/commands/rm.js +3 -1514
  49. package/dist/lib/sandbox/control/audit.js +203 -0
  50. package/dist/lib/sandbox/control/client.js +93 -54
  51. package/dist/lib/sandbox/control/executor.js +129 -65
  52. package/dist/lib/sandbox/control/finalization-response.js +27 -0
  53. package/dist/lib/sandbox/control/identity-sentinel.js +107 -0
  54. package/dist/lib/sandbox/control/lifecycle.js +36 -2
  55. package/dist/lib/sandbox/control/native-file-lock.js +6 -98
  56. package/dist/lib/sandbox/control/protocol.js +55 -4
  57. package/dist/lib/sandbox/control/server.js +491 -120
  58. package/dist/lib/sandbox/control/state.js +58 -0
  59. package/dist/lib/sandbox/control/task-workflow.js +74 -0
  60. package/dist/lib/sandbox/control/workflow-executor.js +99 -0
  61. package/dist/lib/sandbox/index.js +2 -2
  62. package/dist/lib/sandbox/recovery.js +28 -4
  63. package/dist/lib/sandbox/removal.js +1717 -0
  64. package/dist/lib/sandbox/task-cleanup.js +217 -0
  65. package/dist/lib/sandbox/workspace-view.js +13 -14
  66. package/dist/lib/task/activity-intent.js +2 -1
  67. package/dist/lib/task/activity-log.js +30 -24
  68. package/dist/lib/task/artifact-command.js +101 -0
  69. package/dist/lib/task/artifact-lifecycle.js +30 -28
  70. package/dist/lib/task/artifact-name.js +36 -0
  71. package/dist/lib/task/artifact-operations.js +23 -69
  72. package/dist/lib/task/artifact-receipts.js +3 -15
  73. package/dist/lib/task/artifact-repair-intent.js +87 -16
  74. package/dist/lib/task/artifact-schema.js +6 -14
  75. package/dist/lib/task/capabilities.js +12 -19
  76. package/dist/lib/task/codex-orchestration.js +71 -25
  77. package/dist/lib/task/commit-identity.js +28 -0
  78. package/dist/lib/task/commit-operation.js +2 -16
  79. package/dist/lib/task/control-authority.js +371 -5
  80. package/dist/lib/task/control-recovery.js +128 -0
  81. package/dist/lib/task/create.js +62 -9
  82. package/dist/lib/task/decision-details.js +2 -46
  83. package/dist/lib/task/events.js +120 -9
  84. package/dist/lib/task/finalization-state.js +9 -0
  85. package/dist/lib/task/human-override.js +3 -6
  86. package/dist/lib/task/implementation-inputs.js +3 -3
  87. package/dist/lib/task/intermediate-cleanup.js +383 -0
  88. package/dist/lib/task/invalidation.js +18 -65
  89. package/dist/lib/task/ledger-intents.js +7 -22
  90. package/dist/lib/task/ledger.js +1 -3
  91. package/dist/lib/task/lifecycle.js +19 -1
  92. package/dist/lib/task/local-artifact-finalization.js +132 -157
  93. package/dist/lib/task/manual-validation-completion.js +26 -0
  94. package/dist/lib/task/manual-validation-evidence.js +130 -0
  95. package/dist/lib/task/manual-validation-receipt.js +101 -0
  96. package/dist/lib/task/manual-validation-shared.js +40 -0
  97. package/dist/lib/task/manual-validation-transaction.js +243 -0
  98. package/dist/lib/task/markdown.js +47 -0
  99. package/dist/lib/task/orchestration.js +14 -17
  100. package/dist/lib/task/qualification-audit.js +20 -34
  101. package/dist/lib/task/review-artifacts.js +2 -18
  102. package/dist/lib/task/review-command.js +25 -0
  103. package/dist/lib/task/review-finalization.js +125 -193
  104. package/dist/lib/task/review-fingerprint.js +2 -2
  105. package/dist/lib/task/sections.js +34 -56
  106. package/dist/lib/task/verification-engine.js +116 -323
  107. package/dist/lib/task/verification.js +1 -1
  108. package/dist/lib/task/workflow-command.js +17 -0
  109. package/dist/lib/task/workflow-warning-intents.js +0 -16
  110. package/dist/lib/task/workflow-warnings.js +28 -2
  111. package/dist/lib/update.js +2 -0
  112. package/dist/lifecycle-build-manifest.json +3 -3
  113. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +223 -33
  114. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +3 -2
  115. package/lib/fs/durable-write.ts +29 -0
  116. package/lib/fs/file-lock.ts +113 -0
  117. package/lib/host-control/audit.ts +22 -0
  118. package/lib/host-control/client.ts +172 -0
  119. package/lib/host-control/command.ts +60 -0
  120. package/lib/host-control/path.ts +167 -0
  121. package/lib/host-control/secure-fs.ts +135 -0
  122. package/lib/host-control/server.ts +180 -0
  123. package/lib/host-control/service.ts +133 -0
  124. package/lib/init.ts +15 -2
  125. package/lib/internal/cli-route-inventory.ts +3 -1
  126. package/lib/internal/codex-lifecycle.ts +6 -6
  127. package/lib/internal/demo-promotion.ts +354 -0
  128. package/lib/internal/demo-transcript.ts +419 -0
  129. package/lib/internal/host-control.ts +28 -0
  130. package/lib/internal/manual-validation.ts +306 -0
  131. package/lib/internal/platform-pr.ts +15 -4
  132. package/lib/internal/release-workflow.ts +74 -82
  133. package/lib/internal/sandbox-control.ts +162 -1
  134. package/lib/internal/task-artifact.ts +13 -216
  135. package/lib/internal/task-create.ts +5 -5
  136. package/lib/internal/task-event.ts +20 -11
  137. package/lib/internal/task-operation-registry.ts +111 -3
  138. package/lib/internal/task-review.ts +6 -51
  139. package/lib/internal/task-validate.ts +26 -10
  140. package/lib/owned-path.ts +26 -0
  141. package/lib/platform/check-status.ts +9 -0
  142. package/lib/platform/comment-safety.ts +5 -0
  143. package/lib/platform/completion-backfill.ts +1 -1
  144. package/lib/platform/github-data.ts +322 -0
  145. package/lib/platform/github-provider.ts +15 -20
  146. package/lib/platform/issue-comments.ts +3 -4
  147. package/lib/platform/issues.ts +4 -245
  148. package/lib/platform/pr-checks.ts +13 -77
  149. package/lib/platform/pr-summary.ts +61 -5
  150. package/lib/platform/pull-requests.ts +3 -170
  151. package/lib/platform/snapshots.ts +53 -0
  152. package/lib/platform/verification-sync.ts +145 -155
  153. package/lib/release/workflow.ts +3 -3
  154. package/lib/sandbox/commands/create.ts +7 -6
  155. package/lib/sandbox/commands/rm.ts +3 -1909
  156. package/lib/sandbox/control/audit.ts +253 -0
  157. package/lib/sandbox/control/client.ts +109 -66
  158. package/lib/sandbox/control/executor.ts +134 -74
  159. package/lib/sandbox/control/finalization-response.ts +29 -0
  160. package/lib/sandbox/control/identity-sentinel.ts +137 -0
  161. package/lib/sandbox/control/lifecycle.ts +45 -3
  162. package/lib/sandbox/control/native-file-lock.ts +6 -107
  163. package/lib/sandbox/control/protocol.ts +75 -6
  164. package/lib/sandbox/control/server.ts +538 -123
  165. package/lib/sandbox/control/state.ts +78 -0
  166. package/lib/sandbox/control/task-workflow.ts +95 -0
  167. package/lib/sandbox/control/workflow-executor.ts +102 -0
  168. package/lib/sandbox/index.ts +2 -2
  169. package/lib/sandbox/recovery.ts +28 -5
  170. package/lib/sandbox/removal.ts +2144 -0
  171. package/lib/sandbox/task-cleanup.ts +209 -0
  172. package/lib/sandbox/workspace-view.ts +17 -15
  173. package/lib/task/activity-intent.ts +2 -2
  174. package/lib/task/activity-log.ts +27 -19
  175. package/lib/task/artifact-command.ts +104 -0
  176. package/lib/task/artifact-lifecycle.ts +31 -26
  177. package/lib/task/artifact-name.ts +55 -0
  178. package/lib/task/artifact-operations.ts +22 -68
  179. package/lib/task/artifact-receipts.ts +3 -14
  180. package/lib/task/artifact-repair-intent.ts +99 -18
  181. package/lib/task/artifact-schema.ts +5 -34
  182. package/lib/task/capabilities.ts +13 -22
  183. package/lib/task/codex-orchestration.ts +85 -24
  184. package/lib/task/commit-identity.ts +31 -0
  185. package/lib/task/commit-operation.ts +2 -16
  186. package/lib/task/control-authority.ts +502 -5
  187. package/lib/task/control-recovery.ts +174 -0
  188. package/lib/task/create.ts +63 -8
  189. package/lib/task/decision-details.ts +3 -71
  190. package/lib/task/events.ts +131 -10
  191. package/lib/task/finalization-state.ts +11 -0
  192. package/lib/task/human-override.ts +3 -8
  193. package/lib/task/implementation-inputs.ts +3 -3
  194. package/lib/task/intermediate-cleanup.ts +455 -0
  195. package/lib/task/invalidation.ts +18 -62
  196. package/lib/task/ledger-intents.ts +7 -24
  197. package/lib/task/ledger.ts +1 -3
  198. package/lib/task/lifecycle.ts +17 -1
  199. package/lib/task/local-artifact-finalization.ts +148 -182
  200. package/lib/task/manual-validation-completion.ts +40 -0
  201. package/lib/task/manual-validation-evidence.ts +209 -0
  202. package/lib/task/manual-validation-receipt.ts +152 -0
  203. package/lib/task/manual-validation-shared.ts +50 -0
  204. package/lib/task/manual-validation-transaction.ts +304 -0
  205. package/lib/task/markdown.ts +70 -0
  206. package/lib/task/orchestration.ts +19 -19
  207. package/lib/task/qualification-audit.ts +22 -33
  208. package/lib/task/review-artifacts.ts +1 -20
  209. package/lib/task/review-command.ts +23 -0
  210. package/lib/task/review-finalization.ts +131 -259
  211. package/lib/task/review-fingerprint.ts +2 -2
  212. package/lib/task/sections.ts +33 -54
  213. package/lib/task/verification-engine.ts +110 -366
  214. package/lib/task/verification-types.ts +17 -0
  215. package/lib/task/verification.ts +1 -1
  216. package/lib/task/workflow-command.ts +18 -0
  217. package/lib/task/workflow-warning-intents.ts +0 -13
  218. package/lib/task/workflow-warnings.ts +21 -2
  219. package/lib/update.ts +6 -0
  220. package/package.json +6 -6
  221. package/templates/.agents/hooks/lifecycle-delegation.js +6 -6
  222. package/templates/.agents/rules/testing-discipline.en.md +2 -0
  223. package/templates/.agents/rules/testing-discipline.zh-CN.md +2 -0
  224. package/templates/.agents/scripts/package.json +3 -0
  225. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +32 -20
  226. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +32 -20
  227. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +1 -1
  228. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +1 -1
  229. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +2 -0
  230. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +2 -0
  231. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +16 -7
  232. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +16 -7
  233. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  234. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  235. package/templates/.agents/skills/release/SKILL.en.md +7 -3
  236. package/templates/.agents/skills/release/SKILL.zh-CN.md +7 -3
  237. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +20 -3
  238. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +20 -3
  239. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +4 -1
  240. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +4 -1
  241. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -0
  242. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -0
  243. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  244. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  245. package/templates/.agents/skills/test/SKILL.en.md +17 -6
  246. package/templates/.agents/skills/test/SKILL.zh-CN.md +17 -6
  247. package/templates/.claude/commands/run-manual-validation.en.md +1 -1
  248. package/templates/.claude/commands/run-manual-validation.zh-CN.md +1 -1
  249. package/dist/lib/platform/adapters.js +0 -56
  250. package/lib/platform/adapters.ts +0 -197
@@ -0,0 +1,455 @@
1
+ import { inspectOwnedPath } from '../owned-path.ts';
2
+ import { taskFinalizationReceiptState } from './finalization-state.ts';
3
+ import { withTaskExecutionLock } from './task-execution-lock.ts';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ import { checkpointCommitMatches } from './commit-identity.ts';
8
+ import {
9
+ readCheckpointIntent,
10
+ type CheckpointIntent
11
+ } from './commit-intent.ts';
12
+ import { parseTaskFrontmatter } from './frontmatter.ts';
13
+ import {
14
+ readTaskFinalizationReceipt,
15
+ type TaskFinalizationReceipt
16
+ } from './finalization.ts';
17
+ import {
18
+ readLocalArtifactFinalizationIntent,
19
+ semanticDigest,
20
+ sha256Content,
21
+ type LocalArtifactFinalizationIntent
22
+ } from './local-artifact-finalization.ts';
23
+ import { parseArtifactName } from './artifact-name.ts';
24
+ import { enumerateAllTaskDirs, type TaskWorkspaceState } from './resolve-ref.ts';
25
+ import type { TaskControlBindingEvidence } from './finalization-state.ts';
26
+
27
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
28
+ const LOCAL_INTENT_RE = /^(TASK-\d{8}-\d{6})-(analysis|plan|code)-(.+\.md)\.json$/;
29
+ const COMMIT_INTENT_RE = /^(TASK-\d{8}-\d{6})\.json$/;
30
+ const AUXILIARY_ROOTS = [
31
+ '.local-artifact-finalization-intents',
32
+ '.task-commit-intents'
33
+ ] as const;
34
+
35
+ type CleanupKind = 'LFAI-CONSUMED' | 'COMMIT-SYNCED' | 'EMPTY-AUX-PARENT';
36
+ type CleanupDisposition = 'planned' | 'protected' | 'skipped' | 'deleted' | 'failed';
37
+ type FileIdentity = Readonly<{ dev: string; ino: string; size: number; mtimeMs: number }>;
38
+
39
+ type IntermediateCleanupItem = Readonly<{
40
+ kind: CleanupKind | 'AUXILIARY-UNKNOWN';
41
+ taskId: string | null;
42
+ path: string;
43
+ disposition: CleanupDisposition;
44
+ reason: string;
45
+ identity: FileIdentity | null;
46
+ }>;
47
+
48
+ type IntermediateCleanupReport = Readonly<{
49
+ status: 'dry-run' | 'completed' | 'partial';
50
+ items: readonly IntermediateCleanupItem[];
51
+ remaining: readonly IntermediateCleanupItem[];
52
+ }>;
53
+
54
+ type IntermediateCleanupOptions = Readonly<{
55
+ taskIds?: readonly string[];
56
+ controlBindingEvidence?: TaskControlBindingEvidence;
57
+ }>;
58
+
59
+ type ScanOptions = IntermediateCleanupOptions & Readonly<{ preflight?: boolean }>;
60
+
61
+ type TaskRecord = Readonly<{
62
+ taskId: string;
63
+ taskDir: string;
64
+ state: TaskWorkspaceState;
65
+ frontmatter: Record<string, string>;
66
+ }>;
67
+
68
+ function auxiliaryRoot(repoRoot: string, name: typeof AUXILIARY_ROOTS[number]): string {
69
+ return path.join(repoRoot, '.agents', 'workspace', name);
70
+ }
71
+
72
+ function taskRoot(repoRoot: string): string {
73
+ return path.join(repoRoot, '.agents', 'workspace');
74
+ }
75
+
76
+ function fileIdentity(stat: fs.Stats): FileIdentity {
77
+ return {
78
+ dev: String(stat.dev),
79
+ ino: String(stat.ino),
80
+ size: stat.size,
81
+ mtimeMs: stat.mtimeMs
82
+ };
83
+ }
84
+
85
+ function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
86
+ return left.dev === right.dev && left.ino === right.ino
87
+ && left.size === right.size && left.mtimeMs === right.mtimeMs;
88
+ }
89
+
90
+ function errorCode(error: unknown): string | null {
91
+ const code = (error as NodeJS.ErrnoException | null)?.code;
92
+ return typeof code === 'string' ? code : null;
93
+ }
94
+
95
+ function safeLstat(root: string, target: string, expect: 'file' | 'directory'): FileIdentity | null {
96
+ const stat = inspectOwnedPath(root, target, expect);
97
+ return stat ? fileIdentity(stat) : null;
98
+ }
99
+
100
+ function inspectTaskRecords(repoRoot: string, taskIds?: ReadonlySet<string>): Map<string, TaskRecord> {
101
+ const records = new Map<string, TaskRecord>();
102
+ for (const entry of enumerateAllTaskDirs(repoRoot)) {
103
+ if (taskIds && !taskIds.has(entry.taskId)) continue;
104
+ const taskMdPath = path.join(entry.taskDir, 'task.md');
105
+ if (safeLstat(taskRoot(repoRoot), taskMdPath, 'file') === null) continue;
106
+ const content = fs.readFileSync(taskMdPath, 'utf8');
107
+ records.set(entry.taskId, {
108
+ taskId: entry.taskId,
109
+ taskDir: entry.taskDir,
110
+ state: entry.state,
111
+ frontmatter: parseTaskFrontmatter(content)
112
+ });
113
+ }
114
+ return records;
115
+ }
116
+
117
+ function item(
118
+ kind: CleanupKind | 'AUXILIARY-UNKNOWN',
119
+ taskId: string | null,
120
+ filePath: string,
121
+ disposition: CleanupDisposition,
122
+ reason: string,
123
+ identity: FileIdentity | null = null
124
+ ): IntermediateCleanupItem {
125
+ return { kind, taskId, path: path.resolve(filePath), disposition, reason, identity };
126
+ }
127
+
128
+ function taskGate(task: TaskRecord | undefined): string | null {
129
+ if (!task) return 'TASK_NOT_FOUND';
130
+ if (task.state !== 'completed' && task.state !== 'archive') return 'TASK_STATE_PROTECTED';
131
+ if (task.frontmatter.id !== task.taskId || task.frontmatter.status !== 'completed') return 'TASK_STATE_PROTECTED';
132
+ if (!task.frontmatter.branch) return 'TASK_IDENTITY_INVALID';
133
+ return null;
134
+ }
135
+
136
+ function receiptGate(
137
+ repoRoot: string,
138
+ task: TaskRecord,
139
+ options: ScanOptions
140
+ ): string | null {
141
+ let receipt: TaskFinalizationReceipt | null;
142
+ try { receipt = readTaskFinalizationReceipt(repoRoot, task.taskId); }
143
+ catch { return 'FINALIZATION_RECEIPT_INVALID'; }
144
+ if (!receipt) return 'FINALIZATION_RECEIPT_MISSING';
145
+ const state = taskFinalizationReceiptState(receipt);
146
+ if (state === 'pending') return 'FINALIZATION_RECEIPT_PENDING';
147
+ if (state === 'unresolved') return 'LFAI_RETRY_OR_WARNING_OPEN';
148
+ if (receipt.controlBinding) {
149
+ const evidence = options.controlBindingEvidence?.(task.taskId, receipt.controlBinding);
150
+ if (evidence !== 'terminal' && !(options.preflight && evidence === 'pending')) return 'CONTROL_BINDING_MISMATCH';
151
+ }
152
+ return null;
153
+ }
154
+
155
+ function canonicalLocalIntentPath(taskId: string, family: string, artifact: string): string {
156
+ return `${taskId}-${family}-${artifact}.json`;
157
+ }
158
+
159
+ function localIntentCandidate(
160
+ repoRoot: string,
161
+ task: TaskRecord | undefined,
162
+ filePath: string,
163
+ taskId: string,
164
+ family: 'analysis' | 'plan' | 'code',
165
+ artifact: string,
166
+ identity: FileIdentity,
167
+ options: ScanOptions
168
+ ): IntermediateCleanupItem {
169
+ const protectedReason = taskGate(task);
170
+ if (protectedReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', protectedReason, identity);
171
+ if (path.basename(filePath) !== canonicalLocalIntentPath(taskId, family, artifact)
172
+ || !parseArtifactName(artifact)?.family || parseArtifactName(artifact)!.family !== family) {
173
+ return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity);
174
+ }
175
+ let intent: LocalArtifactFinalizationIntent | null;
176
+ try { intent = readLocalArtifactFinalizationIntent(repoRoot, taskId, family, artifact); }
177
+ catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_SCHEMA_INVALID', identity); }
178
+ if (!intent || intent.state !== 'consumed') {
179
+ return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_STATE_PROTECTED', identity);
180
+ }
181
+ const artifactPath = path.join(task!.taskDir, artifact);
182
+ if (safeLstat(task!.taskDir, artifactPath, 'file') === null) {
183
+ return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_IDENTITY_MISMATCH', identity);
184
+ }
185
+ let content: string;
186
+ try { content = fs.readFileSync(artifactPath, 'utf8'); }
187
+ catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_UNREADABLE', identity); }
188
+ if (intent.artifactSha256 !== sha256Content(content) || intent.semanticDigest !== semanticDigest(content)) {
189
+ return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_DIGEST_MISMATCH', identity);
190
+ }
191
+ const receiptReason = receiptGate(repoRoot, task!, options);
192
+ if (receiptReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', receiptReason, identity);
193
+ return item('LFAI-CONSUMED', taskId, filePath, 'planned', 'LFAI_CONSUMED_AND_VERIFIED', identity);
194
+ }
195
+
196
+ function commitIntentCandidate(
197
+ repoRoot: string,
198
+ task: TaskRecord | undefined,
199
+ filePath: string,
200
+ taskId: string,
201
+ identity: FileIdentity,
202
+ options: ScanOptions
203
+ ): IntermediateCleanupItem {
204
+ const protectedReason = taskGate(task);
205
+ if (protectedReason) return item('COMMIT-SYNCED', taskId, filePath, 'protected', protectedReason, identity);
206
+ let intent: CheckpointIntent | null;
207
+ try { intent = readCheckpointIntent(repoRoot, taskId); }
208
+ catch { return item('COMMIT-SYNCED', taskId, filePath, 'protected', 'COMMIT_INTENT_INVALID', identity); }
209
+ if (!intent || intent.state !== 'synced' || intent.taskId !== taskId
210
+ || intent.branch !== task!.frontmatter.branch || !intent.committedHead
211
+ || task!.frontmatter.checkpoint_commit !== intent.committedHead
212
+ || !checkpointCommitMatches(repoRoot, intent, intent.committedHead)) {
213
+ return item('COMMIT-SYNCED', taskId, filePath, 'protected', 'COMMIT_EVIDENCE_MISMATCH', identity);
214
+ }
215
+ const receiptReason = receiptGate(repoRoot, task!, options);
216
+ if (receiptReason) return item('COMMIT-SYNCED', taskId, filePath, 'protected', receiptReason, identity);
217
+ return item('COMMIT-SYNCED', taskId, filePath, 'planned', 'COMMIT_SYNCED_AND_VERIFIED', identity);
218
+ }
219
+
220
+ function readAuxiliaryCandidates(
221
+ repoRoot: string,
222
+ tasks: Map<string, TaskRecord>,
223
+ options: ScanOptions
224
+ ): IntermediateCleanupItem[] {
225
+ const candidates: IntermediateCleanupItem[] = [];
226
+ const selected = options.taskIds ? new Set(options.taskIds) : null;
227
+ for (const rootName of AUXILIARY_ROOTS) {
228
+ const root = auxiliaryRoot(repoRoot, rootName);
229
+ let rootIdentity: FileIdentity | null;
230
+ try { rootIdentity = safeLstat(taskRoot(repoRoot), root, 'directory'); }
231
+ catch { rootIdentity = null; }
232
+ if (rootIdentity === null) {
233
+ let stat: fs.Stats | null = null;
234
+ try { stat = fs.lstatSync(root); } catch { stat = null; }
235
+ if (stat?.isSymbolicLink()) candidates.push(item('AUXILIARY-UNKNOWN', null, root, 'protected', 'PATH_SYMLINK'));
236
+ else if (stat) candidates.push(item('AUXILIARY-UNKNOWN', null, root, 'protected', 'PATH_IDENTITY_MISMATCH'));
237
+ continue;
238
+ }
239
+ let entries: fs.Dirent[];
240
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); }
241
+ catch {
242
+ candidates.push(item('AUXILIARY-UNKNOWN', null, root, 'protected', 'AUXILIARY_ROOT_UNREADABLE', rootIdentity));
243
+ continue;
244
+ }
245
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
246
+ const filePath = path.join(root, entry.name);
247
+ if (entry.isSymbolicLink()) {
248
+ candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_SYMLINK'));
249
+ continue;
250
+ }
251
+ if (!entry.isFile()) {
252
+ candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH'));
253
+ continue;
254
+ }
255
+ const identity = safeLstat(root, filePath, 'file');
256
+ if (!identity) {
257
+ candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH'));
258
+ continue;
259
+ }
260
+ if (rootName === '.local-artifact-finalization-intents') {
261
+ const match = LOCAL_INTENT_RE.exec(entry.name);
262
+ if (!match || (selected && !selected.has(match[1]!))) {
263
+ if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
264
+ continue;
265
+ }
266
+ candidates.push(localIntentCandidate(
267
+ repoRoot, tasks.get(match[1]!), filePath, match[1]!, match[2] as 'analysis' | 'plan' | 'code', match[3]!, identity, options
268
+ ));
269
+ } else {
270
+ const match = COMMIT_INTENT_RE.exec(entry.name);
271
+ if (!match || (selected && !selected.has(match[1]!))) {
272
+ if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
273
+ continue;
274
+ }
275
+ candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
276
+ }
277
+ }
278
+ if (entries.length === 0) {
279
+ candidates.push(item('EMPTY-AUX-PARENT', null, root, 'planned', 'EMPTY_CANONICAL_AUXILIARY_ROOT', rootIdentity));
280
+ }
281
+ }
282
+ return candidates;
283
+ }
284
+
285
+ function buildIntermediateCleanupReport(items: readonly IntermediateCleanupItem[], dryRun = false, partial = false): IntermediateCleanupReport {
286
+ const remaining = items.filter((candidate) => candidate.disposition !== 'deleted' && candidate.disposition !== 'skipped');
287
+ return {
288
+ status: dryRun ? 'dry-run' : partial || remaining.some((candidate) => candidate.disposition === 'failed') ? 'partial' : 'completed',
289
+ items,
290
+ remaining
291
+ };
292
+ }
293
+
294
+ function scanInternal(repoRootInput: string, options: ScanOptions): IntermediateCleanupItem[] {
295
+ const repoRoot = path.resolve(repoRootInput);
296
+ if (safeLstat(path.dirname(repoRoot), repoRoot, 'directory') === null) {
297
+ throw new Error('INTERMEDIATE_CLEANUP_REPOSITORY_INVALID');
298
+ }
299
+ const selected = options.taskIds ? new Set(options.taskIds.filter((taskId) => TASK_ID_RE.test(taskId))) : undefined;
300
+ const tasks = inspectTaskRecords(repoRoot, selected);
301
+ return readAuxiliaryCandidates(repoRoot, tasks, options);
302
+ }
303
+
304
+ function scanIntermediateCleanup(repoRoot: string, options: ScanOptions = {}): IntermediateCleanupReport {
305
+ const candidates = scanInternal(repoRoot, options);
306
+ return buildIntermediateCleanupReport(candidates, true);
307
+ }
308
+
309
+ function removeCandidate(candidate: IntermediateCleanupItem): IntermediateCleanupItem {
310
+ if (candidate.disposition !== 'planned') return candidate;
311
+ const root = candidate.kind === 'EMPTY-AUX-PARENT'
312
+ ? path.dirname(path.dirname(candidate.path))
313
+ : path.dirname(candidate.path);
314
+ const expected = candidate.identity;
315
+ if (!expected) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_MISSING' };
316
+ let raw: fs.Stats;
317
+ try { raw = fs.lstatSync(candidate.path); }
318
+ catch (error) {
319
+ if (errorCode(error) === 'ENOENT') return { ...candidate, disposition: 'skipped', reason: 'ALREADY_GONE' };
320
+ return { ...candidate, disposition: 'failed', reason: 'CLEANUP_RECHECK_FAILED' };
321
+ }
322
+ if (raw.isSymbolicLink()) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
323
+ const actual = safeLstat(root, candidate.path, candidate.kind === 'EMPTY-AUX-PARENT' ? 'directory' : 'file');
324
+ if (!actual) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
325
+ if (!sameFileIdentity(expected, actual)) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
326
+ try {
327
+ if (candidate.kind === 'EMPTY-AUX-PARENT') {
328
+ if (fs.readdirSync(candidate.path).length !== 0) return { ...candidate, disposition: 'protected', reason: 'AUXILIARY_ROOT_NOT_EMPTY' };
329
+ fs.rmdirSync(candidate.path);
330
+ } else {
331
+ fs.unlinkSync(candidate.path);
332
+ }
333
+ return { ...candidate, disposition: 'deleted', reason: 'DELETED_AFTER_LOCKED_RECHECK' };
334
+ } catch (error) {
335
+ if (errorCode(error) === 'ENOENT') return { ...candidate, disposition: 'skipped', reason: 'ALREADY_GONE' };
336
+ return { ...candidate, disposition: 'failed', reason: 'CLEANUP_DELETE_FAILED' };
337
+ }
338
+ }
339
+
340
+
341
+ type IntermediateCleanupCoordinatorOptions = IntermediateCleanupOptions & Readonly<{
342
+ dryRun?: boolean;
343
+ lockedTaskIds?: ReadonlySet<string>;
344
+ }>;
345
+
346
+ function intermediateCleanupKey(item: IntermediateCleanupItem): string {
347
+ return `${item.kind}\0${item.path}`;
348
+ }
349
+
350
+ function cleanupIntermediateUnderRemovalCoordinator(
351
+ repoRoot: string,
352
+ options: IntermediateCleanupCoordinatorOptions = {}
353
+ ): IntermediateCleanupReport {
354
+ const scanOptions: IntermediateCleanupOptions = {
355
+ ...(options.taskIds === undefined ? {} : { taskIds: options.taskIds }),
356
+ ...(options.controlBindingEvidence === undefined ? {} : { controlBindingEvidence: options.controlBindingEvidence })
357
+ };
358
+ const initial = scanIntermediateCleanup(repoRoot, scanOptions);
359
+ if (options.dryRun) return initial;
360
+
361
+ const items = [...initial.items];
362
+ const lockedTaskIds = options.lockedTaskIds ?? new Set<string>();
363
+ const byTask = new Map<string, number[]>();
364
+ for (const [index, candidate] of items.entries()) {
365
+ if (!candidate.taskId || candidate.disposition !== 'planned') continue;
366
+ const indexes = byTask.get(candidate.taskId) ?? [];
367
+ indexes.push(index);
368
+ byTask.set(candidate.taskId, indexes);
369
+ }
370
+
371
+ for (const taskId of [...byTask.keys()].sort()) {
372
+ const execute = (): void => {
373
+ const refreshed = scanIntermediateCleanup(repoRoot, {
374
+ ...scanOptions,
375
+ taskIds: [taskId]
376
+ });
377
+ const refreshedByKey = new Map(refreshed.items.map((candidate) => [intermediateCleanupKey(candidate), candidate]));
378
+ for (const index of byTask.get(taskId) ?? []) {
379
+ const initialCandidate = items[index]!;
380
+ if (initialCandidate.disposition !== 'planned') continue;
381
+ const current = refreshedByKey.get(intermediateCleanupKey(initialCandidate));
382
+ items[index] = current
383
+ ? removeCandidate(current)
384
+ : { ...initialCandidate, disposition: 'protected', reason: 'CANDIDATE_NO_LONGER_ELIGIBLE' };
385
+ }
386
+ };
387
+ try {
388
+ if (lockedTaskIds.has(taskId)) execute();
389
+ else withTaskExecutionLock(repoRoot, taskId, 'sandbox-removal', execute);
390
+ } catch {
391
+ for (const index of byTask.get(taskId) ?? []) {
392
+ const candidate = items[index]!;
393
+ if (candidate.disposition === 'planned') items[index] = { ...candidate, disposition: 'protected', reason: 'TASK_LOCK_BUSY' };
394
+ }
395
+ }
396
+ }
397
+
398
+ for (const [index, candidate] of items.entries()) {
399
+ if (candidate.taskId || candidate.disposition !== 'planned') continue;
400
+ items[index] = removeCandidate(candidate);
401
+ }
402
+
403
+ const knownPaths = new Set(items.map((candidate) => intermediateCleanupKey(candidate)));
404
+ for (const candidate of scanIntermediateCleanup(repoRoot, scanOptions).items) {
405
+ if (candidate.kind !== 'EMPTY-AUX-PARENT' || candidate.disposition !== 'planned'
406
+ || knownPaths.has(intermediateCleanupKey(candidate))) continue;
407
+ items.push(removeCandidate(candidate));
408
+ }
409
+
410
+ return buildIntermediateCleanupReport(items);
411
+ }
412
+
413
+ function protectIntermediateCleanupReport(
414
+ report: IntermediateCleanupReport,
415
+ reason: string
416
+ ): IntermediateCleanupReport {
417
+ const items = report.items.map((item) => item.disposition === 'planned'
418
+ ? { ...item, disposition: 'protected' as const, reason }
419
+ : item
420
+ );
421
+ return buildIntermediateCleanupReport(items, false, true);
422
+ }
423
+
424
+ function mergeIntermediateCleanupReports(
425
+ reports: readonly IntermediateCleanupReport[]
426
+ ): IntermediateCleanupReport {
427
+ const items = reports.flatMap((report) => report.items);
428
+ return buildIntermediateCleanupReport(items, false, reports.some((report) => report.status === 'partial'));
429
+ }
430
+
431
+ function formatIntermediateCleanupReport(report: IntermediateCleanupReport): string[] {
432
+ return [
433
+ `remaining ${report.remaining.length} item(s)`,
434
+ ...report.items.map((candidate) => (
435
+ `${candidate.disposition} ${candidate.kind} ${candidate.path}`
436
+ + ` (${candidate.taskId ?? 'unbound'}; ${candidate.reason})`
437
+ ))
438
+ ];
439
+ }
440
+
441
+ export {
442
+ formatIntermediateCleanupReport,
443
+ cleanupIntermediateUnderRemovalCoordinator,
444
+ protectIntermediateCleanupReport,
445
+ mergeIntermediateCleanupReports,
446
+ scanIntermediateCleanup
447
+ };
448
+ export type {
449
+ CleanupDisposition,
450
+ CleanupKind,
451
+ FileIdentity,
452
+ IntermediateCleanupItem,
453
+ IntermediateCleanupOptions,
454
+ IntermediateCleanupReport
455
+ };
@@ -1,4 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { findSectionRange, parseTable } from './sections.ts';
3
+ import { scanVisibleMarkdown } from './markdown.ts';
2
4
 
3
5
  const INVALIDATION_HEADINGS = ['产物失效记录', 'Artifact Invalidation'] as const;
4
6
  const OPERATION_COLUMNS = [
@@ -84,58 +86,14 @@ function createInvalidationOperation(
84
86
  };
85
87
  }
86
88
 
87
- function lineCells(line: string): string[] | null {
88
- const first = line.indexOf('|');
89
- const last = line.lastIndexOf('|');
90
- if (first < 0 || first === last || line.slice(0, first).trim() || line.slice(last + 1).trim()) return null;
91
- const inner = line.slice(first + 1, last);
92
- const cells: string[] = [];
93
- let start = 0;
94
- let escaped = false;
95
- for (let index = 0; index < inner.length; index += 1) {
96
- const char = inner[index]!;
97
- if (char === '|' && !escaped) {
98
- cells.push(inner.slice(start, index));
99
- start = index + 1;
100
- }
101
- escaped = char === '\\' && !escaped;
102
- if (char !== '\\') escaped = false;
89
+ function parseInvalidationTable(body: string, heading: string, columns: readonly string[]) {
90
+ const section = findSectionRange(body, [heading], 3);
91
+ const lines = scanVisibleMarkdown(body).lines.filter((line) => line.text.trim());
92
+ const table = parseTable(lines.map((line) => line.text).join('\n'), { sectionAliases: [heading], columns });
93
+ if (!section || !table || lines.filter((line) => line.start >= section.bodyStart && line.start < section.end).length !== table.rows.length + 2) {
94
+ throw new Error(`invalidation ${heading} section must contain only its canonical table`);
103
95
  }
104
- cells.push(inner.slice(start));
105
- return cells.map((cell) => cell.replace(/\\([\\|])/g, '$1').trim());
106
- }
107
-
108
- function isSeparator(cells: readonly string[], count: number): boolean {
109
- return cells.length === count && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
110
- }
111
-
112
- function sectionBody(content: string): string | null {
113
- const heading = INVALIDATION_HEADINGS.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
114
- const match = new RegExp(`^##\\s+(${heading})\\s*$`, 'm').exec(content);
115
- if (!match) return null;
116
- const start = (match.index ?? 0) + match[0].length;
117
- const rest = content.slice(start);
118
- const end = rest.search(/^##\s+/m);
119
- return rest.slice(0, end < 0 ? rest.length : end);
120
- }
121
-
122
- function parseTable(body: string, heading: string, columns: readonly string[]): Record<string, string>[] | null | false {
123
- const marker = new RegExp(`^###\\s+${heading}\\s*$`, 'm').exec(body);
124
- if (!marker) return null;
125
- const start = (marker.index ?? 0) + marker[0].length;
126
- const rest = body.slice(start);
127
- const end = rest.search(/^###\s+/m);
128
- const lines = rest.slice(0, end < 0 ? rest.length : end).split(/\r?\n/).filter((line) => line.trim());
129
- const header = lines.length > 0 ? lineCells(lines[0]!) : null;
130
- const separator = lines.length > 1 ? lineCells(lines[1]!) : null;
131
- if (!header || !separator || header.length !== columns.length || !header.every((value, index) => value === columns[index]) || !isSeparator(separator, columns.length)) return false;
132
- const rows: Record<string, string>[] = [];
133
- for (const line of lines.slice(2)) {
134
- const cells = lineCells(line);
135
- if (!cells || cells.length !== columns.length) return false;
136
- rows.push(Object.fromEntries(columns.map((column, index) => [column, cells[index]!])));
137
- }
138
- return rows;
96
+ return table.rows;
139
97
  }
140
98
 
141
99
  function required(value: string, field: string): string {
@@ -155,15 +113,13 @@ function numberValue(value: string, field: string, allowZero = true): number {
155
113
  }
156
114
 
157
115
  function parseInvalidationDocument(content: string): InvalidationParseResult {
158
- const body = sectionBody(content);
159
- if (body === null) return { ok: true, present: false, document: { operations: [], targets: [] } };
116
+ const section = findSectionRange(content, INVALIDATION_HEADINGS);
117
+ if (section === null) return { ok: true, present: false, document: { operations: [], targets: [] } };
160
118
  try {
161
- const operationRows = parseTable(body, 'Operations', OPERATION_COLUMNS);
162
- const targetRows = parseTable(body, 'Targets', TARGET_COLUMNS);
163
- if (operationRows === null || targetRows === null || operationRows === false || targetRows === false) {
164
- return invalid('invalidation section must contain Operations and Targets tables');
165
- }
166
- const operations = operationRows.map((row) => ({
119
+ const body = content.slice(section.bodyStart, section.end);
120
+ const operationRows = parseInvalidationTable(body, 'Operations', OPERATION_COLUMNS);
121
+ const targetRows = parseInvalidationTable(body, 'Targets', TARGET_COLUMNS);
122
+ const operations = operationRows.map(({ values: row }) => ({
167
123
  operationId: required(row.operation_id!, 'operation_id'), sourceFamily: required(row.source_family!, 'source_family'),
168
124
  sourceArtifact: required(row.source_artifact!, 'source_artifact'), sourceRound: numberValue(row.source_round!, 'source_round', false),
169
125
  sourceSha256: required(row.source_sha256!, 'source_sha256'), status: status(row.status!, 'operation status'),
@@ -171,7 +127,7 @@ function parseInvalidationDocument(content: string): InvalidationParseResult {
171
127
  createdAt: required(row.created_at!, 'created_at'), updatedAt: required(row.updated_at!, 'updated_at'),
172
128
  completedAt: row.completed_at ?? '', error: row.error ?? ''
173
129
  } satisfies InvalidationOperation));
174
- const targets = targetRows.map((row) => ({
130
+ const targets = targetRows.map(({ values: row }) => ({
175
131
  targetId: required(row.target_id!, 'target_id'), operationId: required(row.operation_id!, 'operation_id'),
176
132
  targetKind: row.target_kind as InvalidationTargetKind, targetFamily: required(row.target_family!, 'target_family'),
177
133
  targetArtifact: required(row.target_artifact!, 'target_artifact'), targetRound: numberValue(row.target_round!, 'target_round', false),
@@ -297,11 +253,11 @@ function isArtifactInvalidated(document: InvalidationDocument, family: string, a
297
253
  }
298
254
 
299
255
  function invalidationMutation(content: string, document: InvalidationDocument) {
300
- const existing = sectionBody(content) !== null;
256
+ const existing = findSectionRange(content, INVALIDATION_HEADINGS);
301
257
  return {
302
258
  kind: 'section' as const,
303
259
  aliases: INVALIDATION_HEADINGS,
304
- heading: existing && /^##\s+Artifact Invalidation\s*$/m.test(content) ? INVALIDATION_HEADINGS[1] : INVALIDATION_HEADINGS[0],
260
+ heading: existing?.heading ?? INVALIDATION_HEADINGS[0],
305
261
  body: renderInvalidation(document)
306
262
  };
307
263
  }
@@ -8,6 +8,7 @@ import {
8
8
  parseImplementationInputs,
9
9
  renderImplementationInputs
10
10
  } from './implementation-inputs.ts';
11
+ import { parseArtifactName } from './artifact-name.ts';
11
12
  import { LEDGER_COLUMNS, LEDGER_HEADINGS, LEDGER_SECTION_MISSING_CODE, LEDGER_SECTION_MISSING_MESSAGE, nextHdId, parseLedgerDocument, validateLedgerRows } from './ledger.ts';
12
13
  import type { LedgerRow, ReviewStage } from './ledger.ts';
13
14
  import { resolveTaskRef } from './resolve-ref.ts';
@@ -44,16 +45,6 @@ type LedgerIntentResult = {
44
45
  const PREFIX: Record<ReviewStage, string> = { analysis: 'AN', plan: 'PL', code: 'CD' };
45
46
  const RESPONSE = new Set<ExecutorResponse>(['accepted', 'adjusted', 'refuted', 'cannot-judge']);
46
47
  const DISPOSITION = new Set<ReviewDisposition>(['confirmed', 'closed', 'open', 'needs-human-decision']);
47
- const REVIEW_ARTIFACT: Record<ReviewStage, RegExp> = {
48
- analysis: /^review-analysis(?:-r(?:[2-9]|[1-9]\d+))?\.md$/,
49
- plan: /^review-plan(?:-r(?:[2-9]|[1-9]\d+))?\.md$/,
50
- code: /^review-code(?:-r(?:[2-9]|[1-9]\d+))?\.md$/
51
- };
52
- const ARTIFACT: Record<ReviewStage, RegExp> = {
53
- analysis: /^analysis(?:-r(?:[2-9]|[1-9]\d+))?\.md$/,
54
- plan: /^plan(?:-r(?:[2-9]|[1-9]\d+))?\.md$/,
55
- code: /^code(?:-r(?:[2-9]|[1-9]\d+))?\.md$/
56
- };
57
48
 
58
49
  function failed(intent: LedgerIntent, code: string, message: string, taskId: string | null = null, entityId: string | null = null): LedgerIntentResult {
59
50
  return { status: 'failed', changed: false, intent: intent.kind, taskId, entityId, before: null, after: null, operations: [], error: { code, message } };
@@ -87,11 +78,6 @@ function nextFindingId(rows: readonly LedgerRow[], stage: ReviewStage): string {
87
78
  return `${prefix}-${max + 1}`;
88
79
  }
89
80
 
90
- function reviewArtifactRound(reviewArtifact: string): number {
91
- const match = /-r([1-9]\d*)\.md$/.exec(reviewArtifact);
92
- return match ? Number(match[1]) : 1;
93
- }
94
-
95
81
  function rowMutation(row: LedgerRow): TaskMutation {
96
82
  return {
97
83
  kind: 'table-row', action: 'upsert', sectionAliases: LEDGER_HEADINGS,
@@ -100,10 +86,6 @@ function rowMutation(row: LedgerRow): TaskMutation {
100
86
  };
101
87
  }
102
88
 
103
- function ledgerSectionMutation(content: string): TaskMutation[] {
104
- return [];
105
- }
106
-
107
89
  function implementationInputMutation(content: string, rows: Parameters<typeof renderImplementationInputs>[0]): TaskMutation {
108
90
  const english = /^##\s+Activity Log\s*$/m.test(content);
109
91
  return {
@@ -147,7 +129,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
147
129
  const finding = rows.find((row) => row.id === intent.findingId);
148
130
  if (!finding || finding.severity === 'decision') return failed(intent, 'LEDGER_NOT_FOUND', `finding '${intent.findingId}' was not found`, resolved.taskId, intent.findingId);
149
131
  const sourceFromEvidence = finding.evidence.split('#')[0];
150
- if (sourceFromEvidence !== intent.sourceArtifact || !REVIEW_ARTIFACT[finding.stage as ReviewStage]?.test(intent.sourceArtifact)) {
132
+ if (sourceFromEvidence !== intent.sourceArtifact || parseArtifactName(intent.sourceArtifact)?.family !== `review-${finding.stage}`) {
151
133
  return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rework intent source artifact does not match finding evidence', resolved.taskId, intent.intentId);
152
134
  }
153
135
  try {
@@ -173,7 +155,8 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
173
155
  let before: LedgerRow | null = null;
174
156
  let after: LedgerRow;
175
157
  if (intent.kind === 'finding-upsert') {
176
- if (!REVIEW_ARTIFACT[intent.stage]?.test(intent.reviewArtifact) || !Number.isInteger(intent.ordinal) || intent.ordinal < 1 || !['blocker', 'major', 'minor'].includes(intent.severity)) {
158
+ const identity = parseArtifactName(intent.reviewArtifact);
159
+ if (identity?.family !== `review-${intent.stage}` || !Number.isInteger(intent.ordinal) || intent.ordinal < 1 || !['blocker', 'major', 'minor'].includes(intent.severity)) {
177
160
  return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'finding identity, stage, ordinal, or severity is invalid', resolved.taskId);
178
161
  }
179
162
  const evidence = normalizedEvidence(intent.evidence);
@@ -191,12 +174,12 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
191
174
  } else {
192
175
  const id = nextFindingId(rows, intent.stage);
193
176
  after = {
194
- id, stage: intent.stage, round: String(reviewArtifactRound(intent.reviewArtifact)),
177
+ id, stage: intent.stage, round: String(identity.round),
195
178
  severity: intent.severity, status: 'open', evidence, sourceLine: -1
196
179
  };
197
180
  }
198
181
  } else if (intent.kind === 'decision-upsert') {
199
- if (!/^HD-[1-9]\d*$/.test(intent.id) || !ARTIFACT[intent.stage]?.test(intent.artifact)) {
182
+ if (!/^HD-[1-9]\d*$/.test(intent.id) || !['analysis', 'plan', 'code'].includes(intent.stage) || parseArtifactName(intent.artifact)?.family !== intent.stage) {
200
183
  return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'decision id, stage, or artifact is invalid', resolved.taskId, intent.id);
201
184
  }
202
185
  const evidence = `${intent.artifact}#${intent.id}`;
@@ -266,7 +249,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
266
249
 
267
250
  const result = writeTask({
268
251
  taskRef: intent.taskRef, expectedState: stateOverride ? resolved.state : 'active',
269
- mutations: [...ledgerSectionMutation(content), rowMutation(after), ...(implementationMutation ? [implementationMutation] : [])],
252
+ mutations: [rowMutation(after), ...(implementationMutation ? [implementationMutation] : [])],
270
253
  dryRun: 'dryRun' in intent ? intent.dryRun : false
271
254
  }, { ...options, taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state } });
272
255
  return mapWrite(intent, after.id, before, after, result);
@@ -51,9 +51,7 @@ function isReviewStage(stage: string): stage is ReviewStage {
51
51
  return stage === 'analysis' || stage === 'plan' || stage === 'code';
52
52
  }
53
53
 
54
- // Parse all rows of the disagreement ledger table. Skips the heading, the
55
- // header row (`| id | ... |`) and the `|---|` separator; ignores non-`|` lines.
56
- // Rows with fewer than 6 columns are skipped (mirrors the JS gate parser).
54
+ // Parse ledger rows using the shared strict table structure contract.
57
55
  function parseLedgerDocument(content: string): LedgerDocument {
58
56
  const table = parseTable(content, { sectionAliases: LEDGER_HEADINGS, columns: LEDGER_COLUMNS });
59
57
  if (!table) return { present: false, rows: [] };