@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,209 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { inspectOwnedPath } from '../owned-path.ts';
4
+ import { parseTaskFrontmatter } from '../task/frontmatter.ts';
5
+ import { readTaskFinalizationReceipt, type TaskFinalizationReceipt } from '../task/finalization.ts';
6
+ import { taskFinalizationReceiptState, type TaskControlBindingEvidence, type TaskControlBinding as SandboxControlBinding } from '../task/finalization-state.ts';
7
+ import { enumerateAllTaskDirs } from '../task/resolve-ref.ts';
8
+ import { resolveSandboxCleanupTarget } from './workspace-identity.ts';
9
+ import { readSandboxControlManifest } from './control/lifecycle.ts';
10
+ import { readJsonFile, readSandboxControlStatus } from './control/state.ts';
11
+ import { finalizationTerminalResponse } from './control/finalization-response.ts';
12
+
13
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
14
+
15
+ function matchingControlManifest(repoRoot: string, controlRoot: string, taskId: string, binding: SandboxControlBinding) {
16
+ if (!controlRootIdentity(controlRoot)) return null;
17
+ const manifestPath = path.join(controlRoot, 'manifest.json');
18
+ let manifest;
19
+ try { manifest = readSandboxControlManifest(manifestPath); }
20
+ catch { return null; }
21
+ if (manifest.mode !== 'task-bound' || manifest.taskId !== taskId
22
+ || canonicalPath(manifest.repoRoot) !== repoRoot || manifest.generation !== binding.generation
23
+ || path.resolve(manifest.channelDir) !== path.join(controlRoot, 'channel')
24
+ || path.resolve(manifest.publicStatusDir) !== path.join(controlRoot, 'public')
25
+ || path.resolve(manifest.processingDir) !== path.join(controlRoot, 'processing')
26
+ || path.resolve(manifest.runtimeDir) !== path.join(controlRoot, 'runtime')) return null;
27
+ return manifest;
28
+ }
29
+
30
+ type DirectoryIdentity = Readonly<{ dev: string; ino: string }>;
31
+ type SandboxRemovalJournalEvidence = Readonly<{
32
+ phase: string;
33
+ generation: string;
34
+ target: Readonly<{
35
+ branch: string;
36
+ controlRoot: string;
37
+ }>;
38
+ }>;
39
+
40
+ function errorCode(error: unknown): string | null {
41
+ const code = (error as NodeJS.ErrnoException | null)?.code;
42
+ return typeof code === 'string' ? code : null;
43
+ }
44
+
45
+ function controlBindingKey(taskId: string, binding: SandboxControlBinding): string {
46
+ return `${taskId}\0${binding.generation}\0${binding.requestId}`;
47
+ }
48
+
49
+ function controlRootIdentity(root: string): DirectoryIdentity | null {
50
+ try {
51
+ const stat = fs.lstatSync(root);
52
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
53
+ return { dev: String(stat.dev), ino: String(stat.ino) };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function controlRootState(
60
+ root: string,
61
+ expected: DirectoryIdentity
62
+ ): 'missing' | 'same' | 'replaced' {
63
+ let stat: fs.Stats;
64
+ try { stat = fs.lstatSync(root); }
65
+ catch (error) { return errorCode(error) === 'ENOENT' ? 'missing' : 'replaced'; }
66
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return 'replaced';
67
+ return String(stat.dev) === expected.dev && String(stat.ino) === expected.ino ? 'same' : 'replaced';
68
+ }
69
+
70
+ function controlRootIsAbsent(root: string): boolean {
71
+ try {
72
+ fs.lstatSync(root);
73
+ return false;
74
+ } catch (error) {
75
+ return errorCode(error) === 'ENOENT';
76
+ }
77
+ }
78
+
79
+ function canonicalPath(input: string): string {
80
+ try { return fs.realpathSync.native(input); }
81
+ catch { return path.resolve(input); }
82
+ }
83
+
84
+ function taskFinalizationReceiptComplete(
85
+ receipt: TaskFinalizationReceipt,
86
+ taskId: string,
87
+ binding: SandboxControlBinding
88
+ ): boolean {
89
+ return receipt.taskId === taskId
90
+ && taskFinalizationReceiptState(receipt) === 'complete'
91
+ && receipt.controlBinding?.generation === binding.generation
92
+ && receipt.controlBinding.requestId === binding.requestId;
93
+ }
94
+
95
+ function terminalControlBindingEvidence(
96
+ repoRootInput: string,
97
+ controlRootInput: string,
98
+ taskId: string,
99
+ binding: SandboxControlBinding
100
+ ): boolean {
101
+ const repoRoot = canonicalPath(repoRootInput);
102
+ const controlRoot = path.resolve(controlRootInput);
103
+ if (!TASK_ID_RE.test(taskId) || !controlRootIdentity(controlRoot)) return false;
104
+ const manifestPath = path.join(controlRoot, 'manifest.json');
105
+ if (!inspectOwnedPath(controlRoot, manifestPath, 'file')) return false;
106
+ const manifest = matchingControlManifest(repoRoot, controlRoot, taskId, binding);
107
+ if (!manifest) return false;
108
+ try {
109
+ const leasePath = path.join(controlRoot, 'lease.json');
110
+ try {
111
+ fs.lstatSync(leasePath);
112
+ return false;
113
+ } catch (error) {
114
+ if (errorCode(error) !== 'ENOENT') return false;
115
+ }
116
+ const receipt = readTaskFinalizationReceipt(repoRoot, taskId);
117
+ if (!receipt || !taskFinalizationReceiptComplete(receipt, taskId, binding)) {
118
+ return false;
119
+ }
120
+ const status = readSandboxControlStatus(manifest.publicStatusDir);
121
+ const viewReceipt = status.taskView.receipt;
122
+ const terminalTaskView = (status.taskView.state === 'current' && status.taskView.observedSource === 'completed')
123
+ || (status.taskView.state === 'finalized-stale' && status.taskView.observedSource === 'active');
124
+ if (status.generation !== binding.generation || status.state !== 'healthy'
125
+ || status.activeRequestId !== null || !terminalTaskView || status.taskView.taskId !== taskId
126
+ || !viewReceipt || viewReceipt.generation !== binding.generation
127
+ || viewReceipt.requestId !== binding.requestId
128
+ || viewReceipt.receiptId !== receipt.receiptId
129
+ || viewReceipt.revision !== receipt.revision) return false;
130
+ const responsePath = path.join(manifest.channelDir, 'responses', `${binding.requestId}.json`);
131
+ if (!inspectOwnedPath(path.join(controlRoot, 'channel'), responsePath, 'file')) return false;
132
+ const actual = readJsonFile(responsePath);
133
+ const expected = finalizationTerminalResponse(taskId, binding.requestId, receipt);
134
+ return JSON.stringify(actual) === JSON.stringify(expected);
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ function removedControlBindingEvidence(
141
+ repoRootInput: string,
142
+ journal: SandboxRemovalJournalEvidence,
143
+ taskId: string,
144
+ binding: SandboxControlBinding
145
+ ): boolean {
146
+ const repoRoot = canonicalPath(repoRootInput);
147
+ if (journal.phase !== 'completed'
148
+ || journal.generation !== binding.generation
149
+ || !path.isAbsolute(journal.target.controlRoot)) return false;
150
+ const controlRoot = path.resolve(journal.target.controlRoot);
151
+ if (!controlRootIsAbsent(controlRoot)) return false;
152
+ const task = enumerateAllTaskDirs(repoRoot).find((entry) => entry.taskId === taskId);
153
+ if (!task || (task.state !== 'completed' && task.state !== 'archive')) return false;
154
+ let frontmatter: Record<string, string>;
155
+ try { frontmatter = parseTaskFrontmatter(fs.readFileSync(path.join(task.taskDir, 'task.md'), 'utf8')); }
156
+ catch { return false; }
157
+ if (frontmatter.id !== taskId || frontmatter.branch !== journal.target.branch) return false;
158
+ try {
159
+ const receipt = readTaskFinalizationReceipt(repoRoot, taskId);
160
+ return receipt !== null && taskFinalizationReceiptComplete(receipt, taskId, binding);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ export function createSandboxControlBindingEvidence(
167
+ repoRoot: string,
168
+ controlRoots: readonly string[],
169
+ removalJournals: readonly SandboxRemovalJournalEvidence[] = []
170
+ ): TaskControlBindingEvidence {
171
+ const canonicalRepoRoot = canonicalPath(repoRoot);
172
+ const roots = [...new Set(controlRoots.map((candidate) => path.resolve(candidate)))];
173
+ const captured = roots.flatMap((controlRoot) => {
174
+ let manifest;
175
+ try { manifest = readSandboxControlManifest(path.join(controlRoot, 'manifest.json')); }
176
+ catch { return []; }
177
+ const rootIdentity = controlRootIdentity(controlRoot);
178
+ if (!rootIdentity) return [];
179
+ const receipt = (() => {
180
+ try { return readSandboxControlStatus(manifest.publicStatusDir).taskView.receipt; }
181
+ catch { return null; }
182
+ })();
183
+ if (!receipt || !terminalControlBindingEvidence(canonicalRepoRoot, controlRoot, manifest.taskId ?? '', receipt)) return [];
184
+ return [{
185
+ controlRoot,
186
+ taskId: manifest.taskId!,
187
+ key: controlBindingKey(manifest.taskId!, receipt),
188
+ rootIdentity
189
+ }];
190
+ });
191
+ return (taskId, binding) => {
192
+ const terminal = captured.some((candidate) => {
193
+ if (candidate.taskId !== taskId || candidate.key !== controlBindingKey(taskId, binding)) return false;
194
+ const state = controlRootState(candidate.controlRoot, candidate.rootIdentity);
195
+ return state === 'missing'
196
+ || state === 'same' && terminalControlBindingEvidence(canonicalRepoRoot, candidate.controlRoot, taskId, binding);
197
+ }) || removalJournals.some((journal) => removedControlBindingEvidence(canonicalRepoRoot, journal, taskId, binding));
198
+ if (terminal) return 'terminal';
199
+ if (roots.some((root) => matchingControlManifest(canonicalRepoRoot, root, taskId, binding))) return 'pending';
200
+ try {
201
+ const branch = resolveSandboxCleanupTarget(taskId, canonicalRepoRoot, { allowProtected: true }).branch;
202
+ if (removalJournals.some((journal) => journal.phase !== 'completed'
203
+ && journal.generation === binding.generation && journal.target.branch === branch)) return 'pending';
204
+ } catch {
205
+ // Missing or conflicting task identity cannot authorize removal.
206
+ }
207
+ return 'mismatch';
208
+ };
209
+ }
@@ -3,10 +3,13 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import type { SandboxWorkspaceIdentity, SandboxWorkspaceKey } from './workspace-identity.ts';
5
5
  import type { SandboxControlManifest } from './control/protocol.ts';
6
+ import {
7
+ createSandboxControlIdentitySentinel,
8
+ writeSandboxControlIdentitySentinel
9
+ } from './control/identity-sentinel.ts';
6
10
 
7
11
  export type SandboxWorkspaceView = Readonly<{
8
12
  root: string;
9
- taskMountPath: string | null;
10
13
  }>;
11
14
 
12
15
  export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
@@ -38,12 +41,7 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
38
41
  const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
39
42
  const projectRoot = path.resolve(params.base, params.project);
40
43
  const root = path.resolve(projectRoot, params.container, digest);
41
- return {
42
- root,
43
- taskMountPath: params.identity.mode === 'task-bound'
44
- ? path.join(root, 'active', params.identity.taskId)
45
- : null
46
- };
44
+ return { root };
47
45
  }
48
46
 
49
47
  export type SandboxControlSetup = Readonly<{
@@ -55,6 +53,7 @@ export type SandboxControlSetup = Readonly<{
55
53
  manifestDraft: SandboxControlManifestDraft;
56
54
  token: string;
57
55
  generation: string;
56
+ controlRootId: string;
58
57
  }>;
59
58
 
60
59
  export type SandboxControlManifestDraft = Readonly<Omit<SandboxControlManifest, 'containerIdentity' | 'engine' | 'authorityEvidence'> & {
@@ -158,7 +157,7 @@ export function materializeSandboxWorkspaceView(params: Readonly<{
158
157
  identity: SandboxWorkspaceIdentity;
159
158
  }>): SandboxWorkspaceView {
160
159
  const projectRoot = path.resolve(params.base, params.project);
161
- const { root, taskMountPath } = sandboxWorkspaceViewPaths(params);
160
+ const { root } = sandboxWorkspaceViewPaths(params);
162
161
  assertSafeDirectory(projectRoot, path.resolve(params.base));
163
162
  assertSafeDirectory(root, projectRoot);
164
163
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
@@ -179,11 +178,7 @@ export function materializeSandboxWorkspaceView(params: Readonly<{
179
178
  encoding: 'utf8',
180
179
  mode: 0o600
181
180
  });
182
- if (taskMountPath) {
183
- assertSafeDirectory(taskMountPath, active);
184
- fs.mkdirSync(taskMountPath, { recursive: true, mode: 0o700 });
185
- }
186
- return { root, taskMountPath };
181
+ return { root };
187
182
  }
188
183
 
189
184
  export function materializeSandboxControl(params: Readonly<{
@@ -234,6 +229,12 @@ export function materializeSandboxControl(params: Readonly<{
234
229
  }
235
230
  const token = randomBytes(32).toString('hex');
236
231
  const generation = randomBytes(16).toString('hex');
232
+ const identitySentinel = createSandboxControlIdentitySentinel({
233
+ mode: params.identity.mode,
234
+ taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
235
+ generation
236
+ });
237
+ writeSandboxControlIdentitySentinel(statusDir, identitySentinel);
237
238
  const repoRoot = fs.realpathSync.native(params.repoRoot);
238
239
  const manifestDraft: SandboxControlManifestDraft = {
239
240
  engine: params.engine ?? 'docker',
@@ -246,12 +247,13 @@ export function materializeSandboxControl(params: Readonly<{
246
247
  taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
247
248
  token,
248
249
  generation,
250
+ controlRootId: identitySentinel.controlRootId,
249
251
  channelDir,
250
252
  publicStatusDir: statusDir,
251
253
  processingDir,
252
- runtimeDir
254
+ runtimeDir,
253
255
  };
254
- return { root, channelDir, statusDir, runtimeDir, manifestPath, manifestDraft, token, generation };
256
+ return { root, channelDir, statusDir, runtimeDir, manifestPath, manifestDraft, token, generation, controlRootId: identitySentinel.controlRootId };
255
257
  }
256
258
 
257
259
  export function finalizeSandboxControlManifest(
@@ -6,9 +6,9 @@ import { locateActivityLog, appendActivityEntry } from './activity-log.ts';
6
6
  import {
7
7
  assertWritableInventory,
8
8
  buildArtifactLinkSection,
9
- inspectTaskArtifacts,
10
- parseArtifactName
9
+ inspectTaskArtifacts
11
10
  } from './artifact-lifecycle.ts';
11
+ import { parseArtifactName } from './artifact-name.ts';
12
12
  import type { ArtifactIdentity } from './artifact-lifecycle.ts';
13
13
  import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
14
14
  import type { TaskExecutionLockOptions } from './task-execution-lock.ts';
@@ -1,5 +1,4 @@
1
- const HEADING_RE = /^##\s+(活动日志|Activity Log)\s*$/;
2
- const NEXT_H2_RE = /^##\s/;
1
+ import { scanVisibleMarkdown } from './markdown.ts';
3
2
  const ENTRY_RE = /^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}) — \*\*(.+?)\*\* by (.+?) — (.*)$/;
4
3
  const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
5
4
  const ABORTED_SUFFIX_RE = /\s*\[aborted\]\s*$/;
@@ -17,24 +16,32 @@ function parseActivityLog(content: string): { sectionFound: boolean; entries: Lo
17
16
  return section ? { sectionFound: true, entries: section.entries } : { sectionFound: false, entries: [] };
18
17
  }
19
18
 
20
- function locateActivityLog(content: string): ActivityLogSection | null {
21
- const lines = content.split('\n');
22
- const headings = lines.map((line, index) => HEADING_RE.test(line) ? index : -1).filter((index) => index >= 0);
23
- if (headings.length !== 1) return null;
24
- const i = headings[0]!;
25
- let end = lines.length;
26
- for (let j = i + 1; j < lines.length; j += 1) {
27
- if (NEXT_H2_RE.test(lines[j]!)) { end = j; break; }
28
- }
29
- const bodyLines = lines.slice(i + 1, end);
30
- const parsed: { entry: LogEntry; epoch: number; order: number }[] = [];
31
- for (const line of bodyLines) {
32
- const m = ENTRY_RE.exec(line);
33
- if (!m) continue;
34
- parsed.push({ entry: { time: m[1]!, step: m[2]!, agent: m[3]!, note: m[4]! }, epoch: Date.parse(m[1]!.replace(' ', 'T')), order: parsed.length });
19
+ /** Returns source-order entries and malformed bullets without applying display or gate policy. */
20
+ function inspectActivityLog(content: string): { section: ActivityLogSection | null; invalidEntries: string[] } {
21
+ const scanned = scanVisibleMarkdown(content);
22
+ const headings = scanned.headings.filter((heading) => heading.level === 2);
23
+ const matches = headings.filter((heading) => heading.text === '活动日志' || heading.text === 'Activity Log');
24
+ if (matches.length !== 1) return { section: null, invalidEntries: [] };
25
+ const heading = matches[0]!;
26
+ const end = headings.find((candidate) => candidate.start > heading.start)?.start ?? content.length;
27
+ const entries: LogEntry[] = [];
28
+ const invalidEntries: string[] = [];
29
+ for (const line of scanned.lines) {
30
+ if (line.start <= heading.end || line.start >= end) continue;
31
+ const text = line.text.trimStart();
32
+ const match = ENTRY_RE.exec(text);
33
+ if (match) entries.push({ time: match[1]!, step: match[2]!, agent: match[3]!, note: match[4]! });
34
+ else if (text.startsWith('- ')) invalidEntries.push(text);
35
35
  }
36
- parsed.sort((a, b) => a.epoch - b.epoch || a.order - b.order);
37
- return { heading: HEADING_RE.exec(lines[i]!)![1]!, body: bodyLines.join('\n').replace(/^\n+|\n+$/g, ''), entries: parsed.map((item) => item.entry) };
36
+ const body = content.slice(heading.end + 1, end).replace(/\r\n/g, '\n').replace(/^\n+|\n+$/g, '');
37
+ return { section: { heading: heading.text, body, entries }, invalidEntries };
38
+ }
39
+
40
+ function locateActivityLog(content: string): ActivityLogSection | null {
41
+ const { section } = inspectActivityLog(content);
42
+ if (!section) return null;
43
+ section.entries.sort((left, right) => Date.parse(left.time.replace(' ', 'T')) - Date.parse(right.time.replace(' ', 'T')));
44
+ return section;
38
45
  }
39
46
 
40
47
  function appendActivityEntry(section: ActivityLogSection, entry: LogEntry): string {
@@ -105,6 +112,7 @@ export {
105
112
  appendActivityEntry,
106
113
  hasOpenLifecycleExecution,
107
114
  commitAttemptStartedNote,
115
+ inspectActivityLog,
108
116
  locateActivityLog,
109
117
  pairEntries,
110
118
  parseActivityLog,
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readStableFileSync } from '../host-control/secure-fs.ts';
4
+
5
+ import { resolveArtifactContext, hasOpenArtifactRound } from './artifact-lifecycle.ts';
6
+ import { parseArtifactName } from './artifact-name.ts';
7
+ import { resolveTaskRef } from './resolve-ref.ts';
8
+ import { getArtifactSchema } from './artifact-schema.ts';
9
+ import { finalizeLocalArtifact } from './local-artifact-finalization.ts';
10
+ import { applyArtifactRepair, initializeArtifactSkeleton, inspectArtifactStructure } from './artifact-operations.ts';
11
+
12
+ export type ArtifactCommand = Readonly<{
13
+ taskRef: string;
14
+ operation: 'inspect' | 'init' | 'repair' | 'finalize-local';
15
+ family: string;
16
+ artifact: string;
17
+ locale?: 'zh-CN' | 'en';
18
+ expectedSha256: string;
19
+ expectedSemanticDigest: string;
20
+ }>;
21
+
22
+ /** One option contract for the CLI and trusted projection executor. */
23
+ export function parseArtifactCommand(args: readonly string[]): ArtifactCommand {
24
+ const [taskRef, operation] = args;
25
+ if (!taskRef || !['inspect', 'init', 'repair', 'finalize-local'].includes(operation ?? '')) {
26
+ throw new Error('task ref and a supported artifact operation are required');
27
+ }
28
+ const fields: Record<string, string> = {};
29
+ const allowed = {
30
+ inspect: ['--family'],
31
+ init: ['--family', '--artifact', '--locale'],
32
+ repair: ['--family', '--artifact', '--expected-sha256', '--expected-semantic-digest'],
33
+ 'finalize-local': ['--family', '--artifact']
34
+ }[operation as ArtifactCommand['operation']];
35
+ for (let index = 2; index < args.length; index += 1) {
36
+ const flag = args[index]!;
37
+ if (!allowed.includes(flag)) throw new Error(`unknown option '${flag}'`);
38
+ if (Object.hasOwn(fields, flag)) throw new Error(`duplicate option '${flag}'`);
39
+ const value = args[++index];
40
+ if (!value || value.startsWith('--')) throw new Error(`option '${flag}' requires a value`);
41
+ fields[flag] = value;
42
+ }
43
+ if (!fields['--family']) throw new Error("option '--family' is required");
44
+ if (operation !== 'inspect' && !fields['--artifact']) throw new Error("option '--artifact' is required");
45
+ const locale = fields['--locale'];
46
+ if (locale !== undefined && locale !== 'zh-CN' && locale !== 'en') throw new Error("option '--locale' must be 'zh-CN' or 'en'");
47
+ if (operation === 'repair' && !['--expected-sha256', '--expected-semantic-digest'].every((flag) => /^[a-f0-9]{64}$/u.test(fields[flag] ?? ''))) {
48
+ throw new Error('repair requires lowercase 64-character digests');
49
+ }
50
+ return {
51
+ taskRef, operation: operation as ArtifactCommand['operation'], family: fields['--family'],
52
+ artifact: fields['--artifact'] ?? '', locale,
53
+ expectedSha256: fields['--expected-sha256'] ?? '', expectedSemanticDigest: fields['--expected-semantic-digest'] ?? ''
54
+ };
55
+ }
56
+ /** Resolve authoritative task metadata separately from a trusted candidate directory. */
57
+ export function executeArtifactCommand(
58
+ command: ArtifactCommand,
59
+ options: Readonly<{ repoRoot?: string; artifactDir?: string }> = {}
60
+ ): Record<string, unknown> {
61
+ const { taskRef, operation, family, artifact, locale, expectedSha256, expectedSemanticDigest } = command;
62
+ const fail = (code: string, message: string) => ({ status: 'failed', changed: false, error: { code, message } });
63
+ if (operation === 'inspect') {
64
+ const result = resolveArtifactContext(taskRef, family, { repoRoot: options.repoRoot });
65
+ return family === 'code' && result.codeMode ? {
66
+ ...result, mode: result.codeMode.mode, code_max: result.codeMode.codeMax, rev_max: result.codeMode.reviewMax,
67
+ verdict: result.codeMode.verdict, next_round: result.next?.round ?? null, next_artifact: result.next?.name ?? null,
68
+ review_artifact: result.codeMode.reviewArtifact, implementation_input: result.codeMode.implementationInput,
69
+ decision_id: result.codeMode.decisionId, decision_evidence: result.codeMode.decisionEvidence, message: result.codeMode.message
70
+ } : { ...result };
71
+ }
72
+ const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
73
+ if (!resolved.ok) return fail(resolved.code, resolved.message);
74
+ const schema = getArtifactSchema(family);
75
+ const parsed = parseArtifactName(artifact);
76
+ if (!schema || !parsed || parsed.family !== family) {
77
+ return fail('ARTIFACT_IDENTITY_INVALID', `artifact '${artifact}' does not match ${family}`);
78
+ }
79
+ const taskDir = options.artifactDir ?? resolved.taskDir;
80
+ const identity = { taskId: resolved.taskId, taskDir, family, artifact };
81
+ const input = { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskDir, family: schema.family, artifact };
82
+ if (operation === 'init') {
83
+ const context = resolveArtifactContext(taskRef, family, { repoRoot: resolved.repoRoot });
84
+ if (context.status !== 'ready' || (context.next?.name !== artifact && !hasOpenArtifactRound(fs.readFileSync(resolved.taskMdPath, 'utf8'), family, parsed.round))) {
85
+ return fail('ARTIFACT_INIT_CONTEXT_INVALID', context.error?.message ?? `artifact '${artifact}' is not the next ${family} artifact`);
86
+ }
87
+ return { ...initializeArtifactSkeleton({ ...input, ...(locale ? { locale } : {}) }), ...identity };
88
+ }
89
+ if (operation === 'repair') {
90
+ let content: string;
91
+ try { content = readStableFileSync(path.join(taskDir, artifact), { maxBytes: 1024 * 1024 }).bytes.toString('utf8'); }
92
+ catch (error) { return fail('ARTIFACT_REPAIR_TARGET_INVALID', String(error)); }
93
+ const inspection = inspectArtifactStructure(content, schema);
94
+ if (!inspection.repair) {
95
+ return { ...fail('ARTIFACT_REPAIR_UNSAFE', inspection.diagnostics.map((item) => `${item.code}: ${item.message}`).join('; ') || 'no deterministic structural repair is available'),
96
+ artifact, family, diagnostics: inspection.diagnostics };
97
+ }
98
+ return { ...applyArtifactRepair({ ...input, expectedSha256, expectedSemanticDigest, operation: inspection.repair }), ...identity };
99
+ }
100
+ if (family !== 'analysis' && family !== 'plan' && family !== 'code') {
101
+ return fail('ARTIFACT_PAYLOAD_INVALID', "finalize-local only supports 'analysis', 'plan', and 'code'");
102
+ }
103
+ return { ...finalizeLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot }) };
104
+ }
@@ -1,9 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { ARTIFACT_FAMILY_CATALOG, artifactName, parseArtifactName } from './artifact-name.ts';
4
5
  import { resolveTaskRef } from './resolve-ref.ts';
5
6
  import type { ResolveTaskRefErrorCode, TaskWorkspaceState } from './resolve-ref.ts';
6
- import { locateActivityLog } from './activity-log.ts';
7
+ import { locateActivityLog, pairEntries, startedBackedRows } from './activity-log.ts';
7
8
  import { parseImplementationInputs, selectPendingImplementationInput } from './implementation-inputs.ts';
8
9
  import { parseVerdict } from './review-artifacts.ts';
9
10
  import { extractSection, findSectionHeading } from './sections.ts';
@@ -11,10 +12,27 @@ import { receiptForOutput, sha256File } from './artifact-receipts.ts';
11
12
  import { isArtifactInvalidated, parseInvalidationDocument } from './invalidation.ts';
12
13
  import type { InvalidationDocument } from './invalidation.ts';
13
14
  import { validateQualificationAudit } from './qualification-audit.ts';
14
- import { ARTIFACT_FAMILY_CATALOG } from './artifact-schema.ts';
15
- import type { ArtifactFamily, ArtifactFamilySpec } from './artifact-schema.ts';
15
+ import type { ArtifactFamily, ArtifactFamilySpec } from './artifact-name.ts';
16
16
 
17
17
  const artifactFamilyCatalog = ARTIFACT_FAMILY_CATALOG;
18
+ const ARTIFACT_STEPS: Readonly<Record<string, string>> = {
19
+ analysis: 'Analyze Task', 'review-analysis': 'Review Analysis',
20
+ plan: 'Plan Task', 'review-plan': 'Review Plan',
21
+ code: 'Code Task', 'review-code': 'Review Code'
22
+ };
23
+
24
+ /** Init, repair and finalization share the same authoritative round identity. */
25
+ export function hasOpenArtifactRound(content: string, family: string, round: number): boolean {
26
+ const activity = locateActivityLog(content);
27
+ const step = ARTIFACT_STEPS[family];
28
+ if (!activity || !step) return false;
29
+ const qualifier = family === 'code'
30
+ ? '(?:, (?:fix for review-code(?:-r(?:[2-9]|[1-9]\\d+))?\\.md|decision II-[1-9]\\d*))?'
31
+ : '';
32
+ const expected = new RegExp(`^${step} \\(Round ${round}${qualifier}\\)$`);
33
+ return startedBackedRows(pairEntries(activity.entries)).filter((row) => expected.test(row.step) && !row.done).length === 1;
34
+ }
35
+
18
36
  type ArtifactIdentity = {
19
37
  family: ArtifactFamily;
20
38
  round: number;
@@ -75,7 +93,6 @@ type ArtifactContextResult = Omit<ArtifactInventoryResult, 'status'> & {
75
93
  };
76
94
  type InspectOptions = { repoRoot?: string };
77
95
 
78
- const FAMILY_SET = new Set<string>(artifactFamilyCatalog.map((item) => item.family));
79
96
  const BLOCKING_DIAGNOSTICS = new Set<ArtifactDiagnosticCode>([
80
97
  'NONCANONICAL_NAME', 'ROUND_OUT_OF_RANGE', 'MISSING_BASE', 'ROUND_GAP',
81
98
  'DUPLICATE_LOGICAL_ROUND', 'NON_REGULAR_FILE', 'SYMBOLIC_LINK',
@@ -86,27 +103,6 @@ function familySpec(family: string): ArtifactFamilySpec | null {
86
103
  return artifactFamilyCatalog.find((item) => item.family === family) ?? null;
87
104
  }
88
105
 
89
- function artifactName(family: ArtifactFamily, round: number): string {
90
- if (!FAMILY_SET.has(family)) throw new Error(`unknown artifact family '${family}'`);
91
- if (!Number.isSafeInteger(round) || round < 1) throw new Error('artifact round must be a safe positive integer');
92
- return round === 1 ? `${family}.md` : `${family}-r${round}.md`;
93
- }
94
-
95
- function parseArtifactName(name: string): { family: ArtifactFamily; round: number; name: string } | null {
96
- if (path.basename(name) !== name) return null;
97
- const matches: Array<{ family: ArtifactFamily; round: number; name: string }> = [];
98
- for (const spec of artifactFamilyCatalog) {
99
- if (name === `${spec.family}.md`) matches.push({ family: spec.family, round: 1, name });
100
- const match = new RegExp(`^${escapeRegExp(spec.family)}-r([1-9]\\d*)\\.md$`).exec(name);
101
- if (!match) continue;
102
- const round = Number(match[1]);
103
- if (Number.isSafeInteger(round) && round >= 2 && String(round) === match[1]) {
104
- matches.push({ family: spec.family, round, name });
105
- }
106
- }
107
- return matches.length === 1 ? matches[0]! : null;
108
- }
109
-
110
106
  function escapeRegExp(value: string): string {
111
107
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
108
  }
@@ -518,6 +514,15 @@ function validateCompletedArtifact(taskDir: string, family: ArtifactFamily, name
518
514
  return artifact ? { ok: true, artifact } : { ok: false, error: { code: 'ARTIFACT_NOT_FOUND', message: `artifact '${name}' is not in inventory` } };
519
515
  }
520
516
 
517
+ /** A candidate may replace its current slot or fill the next slot, never skip rounds. */
518
+ export function validateArtifactPublication(taskDir: string, family: ArtifactFamily, name: string): ArtifactError | null {
519
+ const inventory = inspectArtifactDirectory(taskDir, family);
520
+ const error = assertWritableInventory(inventory);
521
+ if (error) return error;
522
+ if (inventory.next?.name === name || inventory.artifacts.some((artifact) => artifact.name === name)) return null;
523
+ return { code: 'ARTIFACT_IDENTITY_INVALID', message: `artifact '${name}' is not a current or next ${family} slot` };
524
+ }
525
+
521
526
  function buildArtifactLinkSection(content: string, artifact: ArtifactIdentity): {
522
527
  aliases: readonly string[];
523
528
  heading: string;
@@ -550,7 +555,7 @@ function buildArtifactLinkSection(content: string, artifact: ArtifactIdentity):
550
555
  }
551
556
 
552
557
  export {
553
- artifactFamilyCatalog, artifactName, parseArtifactName, familySpec,
558
+ artifactFamilyCatalog, familySpec,
554
559
  inspectTaskArtifacts, inspectArtifactDirectory, resolveArtifactContext,
555
560
  parseReviewedInputReference, parseCodePlanInputReference, resolveCodePlanInput, reviewEventName,
556
561
  assertWritableInventory, validateCompletedArtifact, buildArtifactLinkSection
@@ -0,0 +1,55 @@
1
+ type ArtifactFamily =
2
+ | 'analysis'
3
+ | 'review-analysis'
4
+ | 'plan'
5
+ | 'review-plan'
6
+ | 'code'
7
+ | 'review-code'
8
+ | 'manual-validation'
9
+ | 'validation-run'
10
+ | 'pr-review';
11
+
12
+ type ArtifactFamilySpec = Readonly<{
13
+ family: ArtifactFamily;
14
+ sectionAliases: readonly [string, string];
15
+ heading: string;
16
+ labels: readonly [string, string];
17
+ }>;
18
+
19
+ const ARTIFACT_FAMILY_CATALOG = [
20
+ { family: 'analysis', sectionAliases: ['分析', 'Analysis'], heading: '分析', labels: ['需求分析报告', 'Requirements Analysis'] },
21
+ { family: 'review-analysis', sectionAliases: ['审查反馈', 'Review Feedback'], heading: '审查反馈', labels: ['需求分析审查', 'Analysis Review'] },
22
+ { family: 'plan', sectionAliases: ['设计', 'Design'], heading: '设计', labels: ['技术方案', 'Technical Plan'] },
23
+ { family: 'review-plan', sectionAliases: ['审查反馈', 'Review Feedback'], heading: '审查反馈', labels: ['技术方案审查', 'Plan Review'] },
24
+ { family: 'code', sectionAliases: ['实现备注', 'Implementation Notes'], heading: '实现备注', labels: ['实现报告', 'Implementation Report'] },
25
+ { family: 'review-code', sectionAliases: ['审查反馈', 'Review Feedback'], heading: '审查反馈', labels: ['代码审查', 'Code Review'] },
26
+ { family: 'manual-validation', sectionAliases: ['实现备注', 'Implementation Notes'], heading: '实现备注', labels: ['人工验证', 'Manual Validation'] },
27
+ { family: 'validation-run', sectionAliases: ['实现备注', 'Implementation Notes'], heading: '实现备注', labels: ['验证运行证据', 'Validation Run Evidence'] },
28
+ { family: 'pr-review', sectionAliases: ['审查反馈', 'Review Feedback'], heading: '审查反馈', labels: ['PR 审查报告', 'PR Review Report'] }
29
+ ] as const satisfies readonly ArtifactFamilySpec[];
30
+
31
+ const FAMILIES = new Set<string>(ARTIFACT_FAMILY_CATALOG.map(({ family }) => family));
32
+
33
+ export function artifactName(family: ArtifactFamily, round: number): string {
34
+ if (!FAMILIES.has(family)) throw new Error(`unknown artifact family '${family}'`);
35
+ if (!Number.isSafeInteger(round) || round < 1) throw new Error('artifact round must be a safe positive integer');
36
+ return round === 1 ? `${family}.md` : `${family}-r${round}.md`;
37
+ }
38
+
39
+ export function parseArtifactName(name: string): { family: ArtifactFamily; round: number; name: string } | null {
40
+ const match = /^(.+?)(?:-r([1-9]\d*))?\.md$/.exec(name);
41
+ if (!match || match[0] !== name || !FAMILIES.has(match[1]!)) return null;
42
+ const round = match[2] ? Number(match[2]) : 1;
43
+ if (!Number.isSafeInteger(round) || (match[2] && round < 2)) return null;
44
+ return { family: match[1] as ArtifactFamily, round, name };
45
+ }
46
+
47
+ export function maxArtifactRound(names: readonly string[], family: string): number {
48
+ return names.reduce((max, name) => {
49
+ const identity = parseArtifactName(name);
50
+ return identity?.family === family ? Math.max(max, identity.round) : max;
51
+ }, 0);
52
+ }
53
+
54
+ export { ARTIFACT_FAMILY_CATALOG };
55
+ export type { ArtifactFamily, ArtifactFamilySpec };