@fitlab-ai/agent-infra 0.9.5 → 0.9.7

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 (172) hide show
  1. package/dist/bin/internal-cli.js +8 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +171 -20
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +68 -8
  8. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -4
  11. package/dist/lib/internal/codex-lifecycle.js +169 -3
  12. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  13. package/dist/lib/internal/release-workflow.js +108 -11
  14. package/dist/lib/internal/sandbox-control.js +43 -6
  15. package/dist/lib/internal/task-create.js +12 -4
  16. package/dist/lib/internal/task-orchestration.js +40 -7
  17. package/dist/lib/platform/context.js +6 -4
  18. package/dist/lib/platform/github-client.js +55 -2
  19. package/dist/lib/platform/issue-comments.js +2 -1
  20. package/dist/lib/process-data/checkpoint.js +319 -0
  21. package/dist/lib/process-data/index.js +128 -60
  22. package/dist/lib/process-data/materialize.js +84 -0
  23. package/dist/lib/process-data/normalize.js +34 -1
  24. package/dist/lib/process-data/reconcile.js +55 -1
  25. package/dist/lib/process-data/sources.js +422 -1
  26. package/dist/lib/process-data/store.js +341 -11
  27. package/dist/lib/release/workflow.js +29 -21
  28. package/dist/lib/sandbox/commands/create.js +111 -60
  29. package/dist/lib/sandbox/commands/prune.js +40 -16
  30. package/dist/lib/sandbox/commands/rm.js +372 -94
  31. package/dist/lib/sandbox/commands/start.js +4 -3
  32. package/dist/lib/sandbox/control/client.js +138 -22
  33. package/dist/lib/sandbox/control/container-identity.js +64 -0
  34. package/dist/lib/sandbox/control/executor.js +142 -0
  35. package/dist/lib/sandbox/control/lifecycle.js +557 -0
  36. package/dist/lib/sandbox/control/protocol.js +69 -26
  37. package/dist/lib/sandbox/control/server.js +395 -99
  38. package/dist/lib/sandbox/control/state.js +191 -0
  39. package/dist/lib/sandbox/control/timing.js +8 -0
  40. package/dist/lib/sandbox/index.js +2 -2
  41. package/dist/lib/sandbox/managed-fs.js +14 -2
  42. package/dist/lib/sandbox/mounts.js +22 -19
  43. package/dist/lib/sandbox/recovery.js +284 -50
  44. package/dist/lib/sandbox/workspace-view.js +90 -10
  45. package/dist/lib/sandbox/worktree-safety.js +196 -0
  46. package/dist/lib/server/daemon.js +1 -1
  47. package/dist/lib/server/process-control.js +17 -1
  48. package/dist/lib/server/process-state.js +57 -20
  49. package/dist/lib/task/artifact-lifecycle.js +87 -15
  50. package/dist/lib/task/artifact-receipts.js +128 -0
  51. package/dist/lib/task/codex-orchestration.js +236 -0
  52. package/dist/lib/task/commands/status.js +3 -0
  53. package/dist/lib/task/commands/validate.js +289 -0
  54. package/dist/lib/task/commit-finalization.js +36 -9
  55. package/dist/lib/task/create.js +10 -2
  56. package/dist/lib/task/delegation-receipts.js +109 -2
  57. package/dist/lib/task/events.js +149 -6
  58. package/dist/lib/task/finalization-artifacts.js +2 -1
  59. package/dist/lib/task/implementation-inputs.js +1 -7
  60. package/dist/lib/task/index.js +9 -0
  61. package/dist/lib/task/ledger.js +13 -1
  62. package/dist/lib/task/orchestration.js +365 -15
  63. package/dist/lib/task/sections.js +5 -1
  64. package/dist/lib/task/task-execution-lock.js +5 -4
  65. package/dist/lib/task/verification-engine.js +78 -14
  66. package/dist/lib/task/verification.js +1 -0
  67. package/dist/lifecycle-build-manifest.json +6 -0
  68. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +202 -18
  69. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  70. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
  71. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  72. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  73. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
  74. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +83 -6
  75. package/lib/agent-clients/adapters/codex.ts +1 -1
  76. package/lib/defaults.json +10 -2
  77. package/lib/git/workflow.ts +25 -3
  78. package/lib/internal/codex-lifecycle.ts +175 -4
  79. package/lib/internal/codex-sandbox-controller.ts +109 -0
  80. package/lib/internal/release-workflow.ts +107 -11
  81. package/lib/internal/sandbox-control.ts +38 -6
  82. package/lib/internal/task-create.ts +16 -4
  83. package/lib/internal/task-orchestration.ts +34 -7
  84. package/lib/platform/context.ts +8 -5
  85. package/lib/platform/github-client.ts +61 -3
  86. package/lib/platform/issue-comments.ts +2 -1
  87. package/lib/process-data/checkpoint.ts +335 -0
  88. package/lib/process-data/index.ts +112 -60
  89. package/lib/process-data/materialize.ts +83 -0
  90. package/lib/process-data/normalize.ts +29 -1
  91. package/lib/process-data/reconcile.ts +64 -2
  92. package/lib/process-data/sources.ts +449 -2
  93. package/lib/process-data/store.ts +304 -11
  94. package/lib/process-data/types.ts +124 -2
  95. package/lib/release/workflow.ts +69 -20
  96. package/lib/sandbox/commands/create.ts +70 -25
  97. package/lib/sandbox/commands/prune.ts +53 -15
  98. package/lib/sandbox/commands/rm.ts +423 -108
  99. package/lib/sandbox/commands/start.ts +4 -3
  100. package/lib/sandbox/control/client.ts +152 -28
  101. package/lib/sandbox/control/container-identity.ts +93 -0
  102. package/lib/sandbox/control/executor.ts +167 -0
  103. package/lib/sandbox/control/lifecycle.ts +573 -0
  104. package/lib/sandbox/control/protocol.ts +121 -68
  105. package/lib/sandbox/control/server.ts +398 -109
  106. package/lib/sandbox/control/state.ts +213 -0
  107. package/lib/sandbox/control/timing.ts +12 -0
  108. package/lib/sandbox/index.ts +2 -2
  109. package/lib/sandbox/managed-fs.ts +17 -2
  110. package/lib/sandbox/mounts.ts +16 -8
  111. package/lib/sandbox/recovery.ts +335 -65
  112. package/lib/sandbox/workspace-view.ts +102 -11
  113. package/lib/sandbox/worktree-safety.ts +234 -0
  114. package/lib/server/daemon.ts +1 -1
  115. package/lib/server/process-control.ts +24 -3
  116. package/lib/server/process-state.ts +82 -27
  117. package/lib/task/artifact-lifecycle.ts +78 -14
  118. package/lib/task/artifact-receipts.ts +161 -0
  119. package/lib/task/codex-orchestration.ts +319 -0
  120. package/lib/task/commands/status.ts +2 -0
  121. package/lib/task/commands/validate.ts +282 -0
  122. package/lib/task/commit-finalization.ts +52 -9
  123. package/lib/task/create.ts +11 -2
  124. package/lib/task/delegation-receipts.ts +208 -7
  125. package/lib/task/events.ts +131 -5
  126. package/lib/task/finalization-artifacts.ts +2 -1
  127. package/lib/task/implementation-inputs.ts +1 -5
  128. package/lib/task/index.ts +8 -0
  129. package/lib/task/ledger.ts +16 -1
  130. package/lib/task/orchestration.ts +552 -18
  131. package/lib/task/sections.ts +6 -2
  132. package/lib/task/task-execution-lock.ts +11 -9
  133. package/lib/task/verification-engine.ts +93 -24
  134. package/lib/task/verification.ts +2 -1
  135. package/package.json +1 -1
  136. package/templates/.agents/README.en.md +7 -0
  137. package/templates/.agents/README.zh-CN.md +5 -0
  138. package/templates/.agents/hooks/lifecycle-delegation.js +72 -12
  139. package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
  140. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
  141. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  142. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  143. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  144. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  145. package/templates/.agents/rules/review-handshake.en.md +3 -1
  146. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  147. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  148. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  149. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  150. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  151. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  152. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  153. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  154. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  155. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +25 -0
  156. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +25 -0
  157. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  158. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  159. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +69 -0
  160. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +69 -0
  161. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  162. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  163. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -3
  164. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -3
  165. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  166. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  167. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  168. package/templates/.codex/hooks.json +8 -8
  169. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  170. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
  171. package/templates/.agents/workspace/README.en.md +0 -26
  172. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { locateActivityLog, appendActivityEntry, pairEntries, parseCommitAttemptStarted } from './activity-log.js';
5
5
  import { commitIntentPath, digest, readCommitIntent } from './commit-intent.js';
6
6
  import { parseTypedTaskFrontmatter } from './frontmatter.js';
7
- import { parseReviewSummary } from './review-artifacts.js';
7
+ import { artifactName, maxRound, parseReviewSummary } from './review-artifacts.js';
8
8
  import { extractReviewBaseline, extractReviewedSnapshotTree, findAuthoritativeReviewCodeArtifact, parseReviewedGitTree } from './review-fingerprint.js';
9
9
  const SHA_RE = /^[a-f0-9]{40,64}$/;
10
10
  function git(repoRoot, args) {
@@ -26,6 +26,22 @@ function isAncestor(repoRoot, ancestor, descendant) {
26
26
  stdio: 'ignore'
27
27
  }).status === 0;
28
28
  }
29
+ function hasMatchingApprovedReview(taskDir, beforeRound, baseline, tree) {
30
+ const entries = fs.readdirSync(taskDir);
31
+ for (let round = Math.min(beforeRound - 1, maxRound(entries, 'review-code')); round >= 1; round -= 1) {
32
+ const file = path.join(taskDir, artifactName('review-code', round));
33
+ if (!fs.existsSync(file))
34
+ continue;
35
+ const content = fs.readFileSync(file, 'utf8');
36
+ const summary = parseReviewSummary(content);
37
+ if (summary.ok
38
+ && summary.summary.verdict === 'Approved'
39
+ && extractReviewBaseline(content) === baseline
40
+ && extractReviewedSnapshotTree(content) === tree)
41
+ return true;
42
+ }
43
+ return false;
44
+ }
29
45
  function outcome(disposition, overrides = {}) {
30
46
  return {
31
47
  disposition,
@@ -34,6 +50,7 @@ function outcome(disposition, overrides = {}) {
34
50
  currentHead: '',
35
51
  committedHead: null,
36
52
  commitNote: null,
53
+ activityNote: null,
37
54
  needsAnchor: false,
38
55
  needsLog: false,
39
56
  intent: null,
@@ -130,6 +147,12 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
130
147
  message: 'repository HEAD or ancestry conflicts with the commit intent'
131
148
  });
132
149
  }
150
+ const commitNote = git(repoRoot, ['show', '-s', '--format=%h%x20%s', intent.committedHead]);
151
+ const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
152
+ const pushOnlyRetry = intent.phase === 'pushed'
153
+ && intent.baselineHead === intent.committedHead
154
+ && matchingDone.length === 1
155
+ && openRows.length === 1;
133
156
  const review = findAuthoritativeReviewCodeArtifact(taskDir);
134
157
  let needsAnchor = false;
135
158
  if (review.ok && review.path) {
@@ -147,7 +170,9 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
147
170
  return invalid('authoritative review-code evidence is not a valid approved snapshot', currentHead, 'COMMIT_FINALIZATION_EVIDENCE_INVALID', shared);
148
171
  }
149
172
  const committedTree = git(repoRoot, ['rev-parse', `${intent.committedHead}^{tree}`]);
150
- if (reviewBaseline !== intent.baselineHead || reviewTree !== committedTree) {
173
+ const matchingCurrentReview = reviewBaseline === intent.baselineHead && reviewTree === committedTree;
174
+ if (!matchingCurrentReview
175
+ && !hasMatchingApprovedReview(taskDir, review.round, intent.baselineHead, committedTree)) {
151
176
  return outcome('conflict', {
152
177
  ...shared,
153
178
  code: 'COMMIT_FINALIZATION_CONFLICT',
@@ -155,18 +180,16 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
155
180
  });
156
181
  }
157
182
  const anchor = String(metadata.last_reviewed_commit ?? '').trim();
158
- if (anchor && anchor !== intent.committedHead) {
183
+ if (anchor && anchor !== intent.committedHead && anchor !== intent.baselineHead) {
159
184
  return outcome('conflict', {
160
185
  ...shared,
161
186
  code: 'COMMIT_FINALIZATION_CONFLICT',
162
187
  message: 'task review anchor conflicts with the committed intent'
163
188
  });
164
189
  }
165
- needsAnchor = anchor === '';
190
+ needsAnchor = anchor !== intent.committedHead;
166
191
  }
167
- const commitNote = git(repoRoot, ['show', '-s', '--format=%h%x20%s', intent.committedHead]);
168
- const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
169
- const needsLog = matchingDone.length === 0 && openRows.length === 1;
192
+ const needsLog = (matchingDone.length === 0 && openRows.length === 1) || pushOnlyRetry;
170
193
  if (!(needsLog || (matchingDone.length === 1 && openRows.length === 0))) {
171
194
  return outcome('conflict', {
172
195
  ...shared,
@@ -179,12 +202,16 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
179
202
  ...shared,
180
203
  message: 'commit finalization can be recovered',
181
204
  commitNote,
205
+ activityNote: pushOnlyRetry ? `Pushed ${commitNote}` : commitNote,
182
206
  needsAnchor,
183
207
  needsLog
184
208
  });
185
209
  }
186
210
  function planCommitTaskFinalization(taskDir, inspection, agent, timestamp) {
187
- if (inspection.disposition !== 'recoverable' || !inspection.committedHead || !inspection.commitNote) {
211
+ if (inspection.disposition !== 'recoverable'
212
+ || !inspection.committedHead
213
+ || !inspection.commitNote
214
+ || !inspection.activityNote) {
188
215
  throw new Error('commit finalization task mutations require a recoverable inspection');
189
216
  }
190
217
  const taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
@@ -207,7 +234,7 @@ function planCommitTaskFinalization(taskDir, inspection, agent, timestamp) {
207
234
  time: timestamp,
208
235
  step: 'Commit',
209
236
  agent,
210
- note: inspection.commitNote
237
+ note: inspection.activityNote
211
238
  })
212
239
  });
213
240
  }
@@ -181,6 +181,13 @@ function assertRealDirectory(directory, code) {
181
181
  if (!stat.isDirectory() || stat.isSymbolicLink())
182
182
  throw new Error(code);
183
183
  }
184
+ function ensureRealDirectory(directory, code) {
185
+ if (fs.existsSync(directory)) {
186
+ assertRealDirectory(directory, code);
187
+ return;
188
+ }
189
+ fs.mkdirSync(directory, { mode: 0o700 });
190
+ }
184
191
  function withCreateLock(repoRoot, workspaceRoot, operation) {
185
192
  const lockRoot = path.join(workspaceRoot, '.task-create.lock');
186
193
  fs.mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
@@ -254,8 +261,9 @@ function createLocalTask(value, options) {
254
261
  const repoRoot = fs.realpathSync.native(options.repoRoot);
255
262
  const workspaceRoot = path.join(repoRoot, '.agents', 'workspace');
256
263
  const activeRoot = path.join(workspaceRoot, 'active');
257
- assertRealDirectory(workspaceRoot, 'TASK_CREATE_WORKSPACE_INVALID');
258
- assertRealDirectory(activeRoot, 'TASK_CREATE_WORKSPACE_INVALID');
264
+ ensureRealDirectory(path.join(repoRoot, '.agents'), 'TASK_CREATE_WORKSPACE_INVALID');
265
+ ensureRealDirectory(workspaceRoot, 'TASK_CREATE_WORKSPACE_INVALID');
266
+ ensureRealDirectory(activeRoot, 'TASK_CREATE_WORKSPACE_INVALID');
259
267
  const project = readProject(repoRoot);
260
268
  const canonical = canonicalTaskCreateCandidate(candidate);
261
269
  const candidateDigest = sha256(canonical);
@@ -11,6 +11,7 @@ function fail(code, message) {
11
11
  return { ok: false, code, message };
12
12
  }
13
13
  function prepareDelegation(input, options = {}) {
14
+ const monotonic = (options.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)))();
14
15
  return Object.freeze({
15
16
  ...input,
16
17
  id: (options.id ?? randomUUID)(),
@@ -23,20 +24,59 @@ function prepareDelegation(input, options = {}) {
23
24
  spawnMode: null,
24
25
  agent: null,
25
26
  status: 'prepared',
27
+ hostEvidence: null,
26
28
  afterFingerprint: null,
27
29
  changedPaths: Object.freeze([]),
28
30
  createdAt: (options.now ?? (() => new Date().toISOString()))(),
31
+ preparedMonotonicMs: monotonic,
32
+ spawnDispatchMonotonicMs: null,
33
+ activationDeadlineMonotonicMs: null,
34
+ spawnDispatchedAt: null,
35
+ activationDeadlineAt: null,
36
+ startEvidenceMonotonicMs: null,
37
+ activatedMonotonicMs: null,
29
38
  activatedAt: null,
30
39
  sealedAt: null,
31
40
  consumedAt: null
32
41
  });
33
42
  }
43
+ function dispatchDelegation(receipt, options = {}) {
44
+ if (receipt.status !== 'prepared') {
45
+ return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected prepared`);
46
+ }
47
+ if (receipt.spawnDispatchMonotonicMs != null || receipt.spawnDispatchedAt != null) {
48
+ return fail('DELEGATION_DISPATCH_REPLAY', `delegation ${receipt.id} was already dispatched`);
49
+ }
50
+ const monotonic = (options.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)))();
51
+ const dispatchedAt = (options.now ?? (() => new Date().toISOString()))();
52
+ const window = options.activationWindowMs ?? 15_000;
53
+ return { ok: true, receipt: Object.freeze({
54
+ ...receipt,
55
+ spawnDispatchMonotonicMs: monotonic,
56
+ activationDeadlineMonotonicMs: monotonic + window,
57
+ spawnDispatchedAt: dispatchedAt,
58
+ activationDeadlineAt: new Date(Date.parse(dispatchedAt) + window).toISOString()
59
+ }) };
60
+ }
34
61
  function activateDelegation(receipt, event, options = {}) {
35
62
  const managedRole = managedDelegationRole(event.nativeAgent);
36
63
  if (!managedRole)
37
64
  return fail('DELEGATION_IGNORED', `subagent '${event.nativeAgent}' is not lifecycle-managed`);
38
65
  if (receipt.status !== 'prepared')
39
66
  return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected prepared`);
67
+ if (receipt.spawnDispatchMonotonicMs == null
68
+ || receipt.spawnDispatchedAt == null
69
+ || receipt.activationDeadlineAt == null
70
+ || receipt.activationDeadlineMonotonicMs == null
71
+ || !Number.isFinite(Date.parse(receipt.spawnDispatchedAt))
72
+ || !Number.isFinite(Date.parse(receipt.activationDeadlineAt))) {
73
+ return fail('DELEGATION_NOT_DISPATCHED', `delegation ${receipt.id} has not reached the native spawn dispatch boundary`);
74
+ }
75
+ const monotonic = (options.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)))();
76
+ const wallNow = Date.parse((options.now ?? (() => new Date().toISOString()))());
77
+ if (wallNow > Date.parse(receipt.activationDeadlineAt)) {
78
+ return fail('DELEGATION_ACTIVATION_TIMEOUT', `delegation ${receipt.id} activation evidence arrived after its deadline`);
79
+ }
40
80
  if (managedRole !== receipt.role)
41
81
  return fail('DELEGATION_ROLE_MISMATCH', `managed role ${managedRole} does not match ${receipt.role}`);
42
82
  if (!event.parentId || (receipt.parentId !== null && event.parentId !== receipt.parentId) || !event.childId || event.childId === event.parentId) {
@@ -55,6 +95,9 @@ function activateDelegation(receipt, event, options = {}) {
55
95
  if (!actualReasoningEffort || actualReasoningEffort.trim() !== actualReasoningEffort) {
56
96
  return fail('DELEGATION_REASONING_EFFORT_MISSING', 'native start event must provide a non-empty actual reasoning effort');
57
97
  }
98
+ if (receipt.client === 'codex'
99
+ && event.hostEvidence?.kind !== 'codex-lifecycle-v2')
100
+ return fail('DELEGATION_HOST_EVIDENCE_REQUIRED', 'Codex activation requires trusted lifecycle-v2 host evidence');
58
101
  if (actualReasoningEffort !== receipt.requestedReasoningEffort
59
102
  && (!event.reasoningEffortFallbackReason || event.reasoningEffortFallbackReason.trim() === '')) {
60
103
  return fail('DELEGATION_REASONING_EFFORT_FALLBACK_UNRECORDED', 'actual reasoning effort differs from requested effort without a fallback reason');
@@ -65,6 +108,38 @@ function activateDelegation(receipt, event, options = {}) {
65
108
  if (actualReasoningEffort === receipt.requestedReasoningEffort && event.reasoningEffortFallbackReason) {
66
109
  return fail('DELEGATION_REASONING_EFFORT_FALLBACK_INVALID', 'reasoning-effort fallback reason is only valid when actual effort differs');
67
110
  }
111
+ if (event.hostEvidence
112
+ && (receipt.client !== 'codex'
113
+ || !event.hostEvidence.hookDefinitionHash.trim()
114
+ || !Number.isSafeInteger(event.hostEvidence.startRevision)
115
+ || event.hostEvidence.startRevision < 1))
116
+ return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex start evidence reference is invalid');
117
+ if (event.hostEvidence?.kind === 'codex-lifecycle-v2') {
118
+ const expected = receipt.lifecycleProvenance;
119
+ const spawnObservedAt = Date.parse(event.hostEvidence.spawnObservedAt ?? '');
120
+ const spawnDispatchedAt = Date.parse(receipt.spawnDispatchedAt);
121
+ const activationDeadlineAt = Date.parse(receipt.activationDeadlineAt);
122
+ if (!expected
123
+ || event.hostEvidence.protocolVersion !== expected.protocolVersion
124
+ || event.hostEvidence.packageVersion !== expected.packageVersion
125
+ || event.hostEvidence.internalExecutableBuildHash !== expected.internalExecutableBuildHash
126
+ || event.hostEvidence.lifecycleContractHash !== expected.lifecycleContractHash
127
+ || event.hostEvidence.hookDefinitionHash !== expected.hookDefinitionHash
128
+ || event.hostEvidence.hookSource !== expected.hookSource
129
+ || event.hostEvidence.hookSourcePathDigest !== expected.hookSourcePathDigest
130
+ || event.hostEvidence.hookSourceHash !== expected.hookSourceHash
131
+ || event.hostEvidence.capabilitySessionId !== expected.capabilitySessionId
132
+ || event.hostEvidence.capabilityTurnId !== expected.capabilityTurnId
133
+ || !event.hostEvidence.spawnToolUseId?.trim()
134
+ || event.hostEvidence.spawnToolUseId === expected.capabilityToolUseId
135
+ || !Number.isFinite(spawnObservedAt)
136
+ || spawnObservedAt < spawnDispatchedAt
137
+ || spawnObservedAt > activationDeadlineAt
138
+ || event.parentId !== expected.capabilitySessionId
139
+ || (event.hostEvidence.controllerInstanceDigest ?? null) !== expected.controllerInstanceDigest
140
+ || (event.hostEvidence.controlGeneration ?? null) !== expected.controlGeneration)
141
+ return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex lifecycle provenance does not match the prepared receipt');
142
+ }
68
143
  return { ok: true, receipt: Object.freeze({
69
144
  ...receipt,
70
145
  status: 'activated',
@@ -75,9 +150,26 @@ function activateDelegation(receipt, event, options = {}) {
75
150
  actualReasoningEffort,
76
151
  modelFallbackReason: event.modelFallbackReason ?? null,
77
152
  reasoningEffortFallbackReason: event.reasoningEffortFallbackReason ?? null,
78
- activatedAt: (options.now ?? (() => new Date().toISOString()))()
153
+ hostEvidence: event.hostEvidence ? Object.freeze({
154
+ ...event.hostEvidence,
155
+ stopRevision: null,
156
+ consumer: null,
157
+ consumedAt: null
158
+ }) : receipt.hostEvidence ?? null,
159
+ startEvidenceMonotonicMs: monotonic,
160
+ activatedMonotonicMs: (options.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)))(),
161
+ activatedAt: new Date(wallNow).toISOString()
79
162
  }) };
80
163
  }
164
+ function abortPreparedDelegation(receipt) {
165
+ if (receipt.status !== 'prepared') {
166
+ return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected prepared`);
167
+ }
168
+ return {
169
+ ok: true,
170
+ receipt: Object.freeze({ ...receipt, status: 'aborted' })
171
+ };
172
+ }
81
173
  function completeDelegationStage(receipt, event) {
82
174
  if (receipt.status !== 'activated')
83
175
  return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected activated`);
@@ -92,6 +184,9 @@ function completeDelegationStage(receipt, event) {
92
184
  function sealDelegation(receipt, event, options = {}) {
93
185
  if (receipt.status !== 'stage-completed')
94
186
  return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected stage-completed`);
187
+ if (receipt.client === 'codex' && options.requireHostEvidence !== false && !event.hostEvidence) {
188
+ return fail('DELEGATION_HOST_EVIDENCE_REQUIRED', 'Codex sealing requires consumed lifecycle-v2 host evidence');
189
+ }
95
190
  if (event.childId !== receipt.childId || event.exitCode !== 0)
96
191
  return fail('DELEGATION_STOP_INVALID', 'native stop identity or exit status is invalid');
97
192
  if (receipt.role === 'reviewer') {
@@ -105,11 +200,23 @@ function sealDelegation(receipt, event, options = {}) {
105
200
  if (disallowed)
106
201
  return fail('DELEGATION_REVIEWER_WRITE_FORBIDDEN', `reviewer changed forbidden path '${disallowed}'`);
107
202
  }
203
+ if (event.hostEvidence) {
204
+ if (!receipt.hostEvidence
205
+ || !Number.isSafeInteger(event.hostEvidence.stopRevision)
206
+ || event.hostEvidence.stopRevision <= receipt.hostEvidence.startRevision
207
+ || event.hostEvidence.consumer !== receipt.id
208
+ || !event.hostEvidence.consumedAt.trim())
209
+ return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex stop evidence reference is invalid');
210
+ }
108
211
  return { ok: true, receipt: Object.freeze({
109
212
  ...receipt,
110
213
  status: 'sealed',
111
214
  afterFingerprint: event.afterFingerprint,
112
215
  changedPaths: Object.freeze([...event.changedPaths]),
216
+ hostEvidence: event.hostEvidence && receipt.hostEvidence ? Object.freeze({
217
+ ...receipt.hostEvidence,
218
+ ...event.hostEvidence
219
+ }) : receipt.hostEvidence ?? null,
113
220
  sealedAt: (options.now ?? (() => new Date().toISOString()))()
114
221
  }) };
115
222
  }
@@ -124,5 +231,5 @@ function consumeDelegation(receipt, options = {}) {
124
231
  consumedAt: (options.now ?? (() => new Date().toISOString()))()
125
232
  }) };
126
233
  }
127
- export { activateDelegation, completeDelegationStage, consumeDelegation, managedDelegationRole, prepareDelegation, sealDelegation };
234
+ export { activateDelegation, abortPreparedDelegation, completeDelegationStage, consumeDelegation, dispatchDelegation, managedDelegationRole, prepareDelegation, sealDelegation };
128
235
  //# sourceMappingURL=delegation-receipts.js.map
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { appendActivityEntry, locateActivityLog, pairEntries } from './activity-log.js';
3
- import { buildArtifactLinkSection, artifactName, parseArtifactName, resolveArtifactContext, validateCompletedArtifact } from './artifact-lifecycle.js';
3
+ import { buildArtifactLinkSection, artifactName, inspectArtifactDirectory, parseReviewedInputReference, parseCodePlanInputReference, parseArtifactName, resolveArtifactContext, validateCompletedArtifact } from './artifact-lifecycle.js';
4
+ import { ArtifactReceiptError, sha256File, upsertArtifactReceipt } from './artifact-receipts.js';
4
5
  import { parseTypedTaskFrontmatter } from './frontmatter.js';
5
6
  import { consumeImplementationInput, IMPLEMENTATION_INPUT_ALIASES, parseImplementationInputs, renderImplementationInputs } from './implementation-inputs.js';
6
7
  import { parseLedger, summarizeLedgerStage, validateLedgerRows } from './ledger.js';
@@ -18,7 +19,8 @@ const eventCatalog = [
18
19
  'review-plan.started', 'review-plan.completed',
19
20
  'code.started', 'code.completed',
20
21
  'review-code.started', 'review-code.completed',
21
- 'manual-validation.started', 'manual-validation.completed'
22
+ 'manual-validation.started', 'manual-validation.completed',
23
+ 'validation-run.started', 'validation-run.completed'
22
24
  ];
23
25
  const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun']);
24
26
  const SCHEMAS = {
@@ -36,7 +38,9 @@ const SCHEMAS = {
36
38
  'review-code.started': { optional: ['round'] },
37
39
  'review-code.completed': { required: ['artifact', 'verdict', 'blockers', 'major', 'minor', 'manualValidation'], optional: ['round', 'orchestrated'] },
38
40
  'manual-validation.started': { optional: ['round'] },
39
- 'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] }
41
+ 'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] },
42
+ 'validation-run.started': { optional: ['round'] },
43
+ 'validation-run.completed': { required: ['artifact'], optional: ['round'] }
40
44
  };
41
45
  function validateTaskEventRequest(request) {
42
46
  if (!eventCatalog.includes(request.event))
@@ -84,7 +88,8 @@ const FAMILY = {
84
88
  'review-plan': { artifact: 'review-plan', started: ['technical-design', 'technical-design-review'], completed: ['technical-design', 'technical-design-review'], target: 'technical-design-review', label: 'Review Plan' },
85
89
  code: { artifact: 'code', started: ['technical-design-review', 'code-review'], completed: ['technical-design-review', 'code-review'], target: 'code', label: 'Code Task' },
86
90
  'review-code': { artifact: 'review-code', started: ['code', 'code-review', 'commit'], completed: ['code', 'code-review', 'commit'], target: 'code-review', label: 'Review Code' },
87
- 'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' }
91
+ 'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' },
92
+ 'validation-run': { artifact: 'validation-run', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Run Manual Validation' }
88
93
  };
89
94
  const REVIEW_LEDGER_STAGES = {
90
95
  'review-analysis': 'analysis',
@@ -180,6 +185,8 @@ function identity(request) {
180
185
  note = `Fixed ${request.blockers} blockers, ${request.major} major, ${request.minor} minor issues${request.manualValidation ? `, skipped ${request.manualValidation} manual-validation` : ''} → ${request.artifact}`;
181
186
  else if (family === 'code')
182
187
  note = `Code implemented, ${request.filesModified} files modified, ${request.testsPassed} tests passed → ${request.artifact}`;
188
+ else if (family === 'validation-run')
189
+ note = `Validation evidence recorded → ${request.artifact}`;
183
190
  else {
184
191
  const verdict = request.verdict === 'approved' ? 'Approved' : request.verdict === 'changes-requested' ? 'Changes Requested' : 'Rejected';
185
192
  note = `Verdict: ${verdict}, blockers: ${request.blockers}, major: ${request.major}, minor: ${request.minor}, Manual-validation: ${request.manualValidation} → ${request.artifact}`;
@@ -231,6 +238,92 @@ function openStartedIdentity(rows, family) {
231
238
  });
232
239
  return matches.length === 1 ? matches[0] : matches.length > 1 ? { conflict: true } : null;
233
240
  }
241
+ function reviewInputFamily(family) {
242
+ return family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code';
243
+ }
244
+ function buildCompletionReceipt(taskDir, family, artifact, completedAt, frontmatter) {
245
+ if (family === 'code') {
246
+ let content;
247
+ try {
248
+ content = fs.readFileSync(artifact.path, 'utf8');
249
+ }
250
+ catch (error) {
251
+ return { ok: false, message: `cannot read ${artifact.name}: ${String(error)}` };
252
+ }
253
+ const input = parseCodePlanInputReference(content);
254
+ if (!input)
255
+ return { ok: false, message: `${artifact.name} does not reference a canonical plan artifact` };
256
+ const startedInput = typeof frontmatter.code_input_artifact === 'string' ? frontmatter.code_input_artifact : '';
257
+ const startedSha256 = typeof frontmatter.code_input_sha256 === 'string' ? frontmatter.code_input_sha256 : '';
258
+ if (!startedInput || !startedSha256)
259
+ return { ok: false, message: 'code.started plan input context is missing' };
260
+ if (input.name !== startedInput)
261
+ return { ok: false, message: `${artifact.name} plan input '${input.name}' does not match code.started input '${startedInput}'` };
262
+ const plan = inspectArtifactDirectory(taskDir, 'plan');
263
+ if (plan.status !== 'ready' || !plan.latest || plan.latest.name !== input.name) {
264
+ return { ok: false, message: `code input '${input.name}' is not the latest plan artifact` };
265
+ }
266
+ try {
267
+ const inputSha256 = sha256File(plan.latest.path);
268
+ if (inputSha256 !== startedSha256)
269
+ return { ok: false, message: `code input ${input.name} changed after code.started` };
270
+ return {
271
+ ok: true,
272
+ receipt: {
273
+ event: 'code.completed', output: artifact.name, input: input.name,
274
+ inputSha256, completedAt
275
+ }
276
+ };
277
+ }
278
+ catch (error) {
279
+ return { ok: false, message: `cannot hash code input plan: ${error instanceof Error ? error.message : String(error)}` };
280
+ }
281
+ }
282
+ if (!family.startsWith('review-'))
283
+ return null;
284
+ const expectedFamily = reviewInputFamily(family);
285
+ let content;
286
+ try {
287
+ content = fs.readFileSync(artifact.path, 'utf8');
288
+ }
289
+ catch (error) {
290
+ return { ok: false, message: `cannot read ${artifact.name}: ${String(error)}` };
291
+ }
292
+ const input = parseReviewedInputReference(content, expectedFamily);
293
+ if (!input)
294
+ return { ok: false, message: `${artifact.name} does not reference a canonical ${expectedFamily} artifact` };
295
+ const startedInput = typeof frontmatter.review_input_artifact === 'string' ? frontmatter.review_input_artifact : '';
296
+ const startedSha256 = typeof frontmatter.review_input_sha256 === 'string' ? frontmatter.review_input_sha256 : '';
297
+ if (!startedInput || !startedSha256)
298
+ return { ok: false, message: 'review started input context is missing' };
299
+ if (input.name !== startedInput)
300
+ return { ok: false, message: `${artifact.name} input '${input.name}' does not match review.started input '${startedInput}'` };
301
+ const current = inspectArtifactDirectory(taskDir, expectedFamily);
302
+ if (current.status !== 'ready' || !current.latest || current.latest.name !== input.name) {
303
+ return { ok: false, message: `review input '${input.name}' is not the latest ${expectedFamily} artifact` };
304
+ }
305
+ const event = family === 'review-analysis'
306
+ ? 'review-analysis.completed'
307
+ : family === 'review-plan' ? 'review-plan.completed' : 'review-code.completed';
308
+ try {
309
+ const stat = fs.lstatSync(current.latest.path);
310
+ if (!stat.isFile() || stat.isSymbolicLink())
311
+ throw new Error('review input is not a regular file');
312
+ const inputSha256 = sha256File(current.latest.path);
313
+ if (inputSha256 !== startedSha256)
314
+ return { ok: false, message: `review input ${input.name} changed after review.started` };
315
+ return {
316
+ ok: true,
317
+ receipt: {
318
+ event, output: artifact.name, input: input.name,
319
+ inputSha256, completedAt
320
+ }
321
+ };
322
+ }
323
+ catch (error) {
324
+ return { ok: false, message: `cannot hash review input ${input.name}: ${error instanceof Error ? error.message : String(error)}` };
325
+ }
326
+ }
234
327
  function applyTaskEventUnlocked(request, options = {}) {
235
328
  const invalid = validateTaskEventRequest(request);
236
329
  if (invalid)
@@ -329,7 +422,7 @@ function applyTaskEventUnlocked(request, options = {}) {
329
422
  if (findingCountError)
330
423
  return failed(normalized, findingCountError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
331
424
  let orchestrationCompletion = null;
332
- if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation') {
425
+ if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation' && eventIdentity.family !== 'validation-run') {
333
426
  const orchestrationStage = eventIdentity.family === 'analyze' ? 'analysis' : eventIdentity.family;
334
427
  const execution = validateLifecycleExecution(normalized.taskRef, {
335
428
  mode: normalized.orchestrated ? 'orchestrated' : 'standalone',
@@ -357,19 +450,69 @@ function applyTaskEventUnlocked(request, options = {}) {
357
450
  catch (error) {
358
451
  return failed(normalized, { code: 'METADATA_CAPTURE_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
359
452
  }
453
+ let completionReceipt = null;
454
+ if (eventIdentity.phase === 'completed' && completedArtifact) {
455
+ const receipt = buildCompletionReceipt(resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter);
456
+ if (receipt && !receipt.ok) {
457
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: receipt.message }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
458
+ }
459
+ completionReceipt = receipt?.receipt ?? null;
460
+ }
360
461
  const step = eventIdentity.phase === 'started' || eventIdentity.target === null ? currentStep : eventIdentity.target;
361
462
  const logStep = eventIdentity.phase === 'started' ? `${eventIdentity.action} [started]` : eventIdentity.action;
362
463
  const body = appendActivityEntry(section, { time: metadata.timestamp, step: logStep, agent: normalized.agent, note: eventIdentity.note });
363
464
  const frontmatterSet = { current_step: step, assigned_to: normalized.agent };
465
+ let frontmatterRemove;
466
+ if (eventIdentity.phase === 'started' && eventIdentity.family === 'code') {
467
+ const planInput = artifactContext?.inputs.find((input) => input.family === 'plan');
468
+ if (!planInput)
469
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: 'code.started plan input context is unavailable' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
470
+ try {
471
+ frontmatterSet.code_input_artifact = planInput.name;
472
+ frontmatterSet.code_input_sha256 = sha256File(planInput.path);
473
+ }
474
+ catch (error) {
475
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot hash code.started plan input: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
476
+ }
477
+ }
478
+ else if (eventIdentity.phase === 'completed' && eventIdentity.family === 'code') {
479
+ frontmatterRemove = ['code_input_artifact', 'code_input_sha256'];
480
+ }
481
+ else if (eventIdentity.phase === 'started' && eventIdentity.family.startsWith('review-')) {
482
+ const expectedFamily = reviewInputFamily(eventIdentity.family);
483
+ const input = artifactContext?.inputs.find((candidate) => candidate.family === expectedFamily);
484
+ if (!input)
485
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `review.started ${expectedFamily} input context is unavailable` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
486
+ try {
487
+ frontmatterSet.review_input_artifact = input.name;
488
+ frontmatterSet.review_input_sha256 = sha256File(input.path);
489
+ }
490
+ catch (error) {
491
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot hash review.started input: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
492
+ }
493
+ }
494
+ else if (eventIdentity.phase === 'completed' && eventIdentity.family.startsWith('review-')) {
495
+ frontmatterRemove = ['review_input_artifact', 'review_input_sha256'];
496
+ }
364
497
  if (eventIdentity.phase === 'started' && normalized.implementationInput)
365
498
  frontmatterSet.last_reviewed_commit = '';
366
499
  const mutations = [
367
- { kind: 'frontmatter', set: frontmatterSet }
500
+ { kind: 'frontmatter', set: frontmatterSet, remove: frontmatterRemove }
368
501
  ];
369
502
  if (completedArtifact) {
370
503
  const link = buildArtifactLinkSection(content, completedArtifact);
371
504
  mutations.push({ kind: 'section', aliases: link.aliases, heading: link.heading, body: link.body });
372
505
  }
506
+ if (completionReceipt) {
507
+ try {
508
+ const receiptSection = upsertArtifactReceipt(content, completionReceipt);
509
+ mutations.push({ kind: 'section', aliases: receiptSection.aliases, heading: receiptSection.heading, body: receiptSection.body });
510
+ }
511
+ catch (error) {
512
+ const message = error instanceof ArtifactReceiptError ? error.message : String(error);
513
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
514
+ }
515
+ }
373
516
  if (eventIdentity.phase === 'completed' && normalized.implementationInput) {
374
517
  let implementationRows;
375
518
  try {
@@ -6,7 +6,8 @@ const COMPLETION_BACKFILL_FAMILIES = [
6
6
  'review-plan',
7
7
  'code',
8
8
  'review-code',
9
- 'manual-validation'
9
+ 'manual-validation',
10
+ 'validation-run'
10
11
  ];
11
12
  function inspectCompletionArtifacts(taskRef, options = {}) {
12
13
  const artifacts = [];
@@ -114,14 +114,8 @@ function finalizeImplementationInput(rows, id, decisionEvidence, decidedAt) {
114
114
  next.forEach(validateInput);
115
115
  return next;
116
116
  }
117
- function selectPendingImplementationInput(rows, reviewCompletedAt) {
118
- const reviewEpoch = Date.parse(reviewCompletedAt.replace(' ', 'T'));
119
- if (!Number.isFinite(reviewEpoch))
120
- throw new Error('latest review completion time is invalid');
117
+ function selectPendingImplementationInput(rows) {
121
118
  const pending = rows.filter((row) => row.needsImplementation && row.status === 'pending');
122
- const stale = pending.find((row) => Date.parse(row.decidedAt.replace(' ', 'T')) <= reviewEpoch);
123
- if (stale)
124
- throw new Error(`implementation input ${stale.id} is not later than the latest approved review`);
125
119
  return [...pending].sort((left, right) => {
126
120
  const time = Date.parse(left.decidedAt.replace(' ', 'T')) - Date.parse(right.decidedAt.replace(' ', 'T'));
127
121
  return time || Number.parseInt(left.id.slice(3), 10) - Number.parseInt(right.id.slice(3), 10);
@@ -10,6 +10,7 @@ Commands:
10
10
  ls [--all | --blocked | --completed] List tasks (default: active)
11
11
  show [--task <ref>] Print a task.md
12
12
  status [--task <ref>] Aggregated status view (metadata / artifacts / git / platform)
13
+ validate <ref> [options] -- <command> Run host validation in snapshot or inplace scope
13
14
 
14
15
  Examples:
15
16
  ai task cat 11 analysis
@@ -29,6 +30,7 @@ Examples:
29
30
  ai task show 11
30
31
  ai task show TASK-20260612-162737
31
32
  ai task status 11
33
+ ai task validate 11 -- npm test
32
34
 
33
35
  Run 'ai task <command> --help' for details.`;
34
36
  export async function runTask(args) {
@@ -89,6 +91,13 @@ export async function runTask(args) {
89
91
  status(rest);
90
92
  break;
91
93
  }
94
+ case 'validate': {
95
+ const { validate } = await import('./commands/validate.js');
96
+ const exitCode = await validate(rest);
97
+ if (exitCode !== 0)
98
+ process.exitCode = exitCode;
99
+ break;
100
+ }
92
101
  default:
93
102
  process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
94
103
  process.stdout.write(`${USAGE}\n`);
@@ -31,8 +31,20 @@ function validateLedgerRows(rows) {
31
31
  if (seen.has(row.id))
32
32
  return { code: 'LEDGER_DUPLICATE_ID', message: `duplicate ledger id '${row.id}'` };
33
33
  seen.add(row.id);
34
- if (/^PRC-[1-9]\d*$/.test(row.id))
34
+ if (/^PRC-[1-9]\d*$/.test(row.id)) {
35
+ if (row.stage !== 'post-review-commit' ||
36
+ row.round !== '-' ||
37
+ row.severity !== '-' ||
38
+ row.status !== 'human-decided' ||
39
+ row.evidence === '' ||
40
+ /[\r\n]/.test(row.evidence)) {
41
+ return { code: 'LEDGER_DOCUMENT_INVALID', message: `post-review exemption row '${row.id}' is invalid` };
42
+ }
35
43
  continue;
44
+ }
45
+ if (row.stage === 'post-review-commit') {
46
+ return { code: 'LEDGER_DOCUMENT_INVALID', message: `post-review exemption row '${row.id}' must use a PRC id` };
47
+ }
36
48
  if (!/^(AN|PL|CD|HD)-[1-9]\d*$/.test(row.id))
37
49
  return { code: 'LEDGER_ID_INVALID', message: `ledger id '${row.id}' is invalid` };
38
50
  if (!['analysis', 'plan', 'code', 'post-review-commit'].includes(row.stage))