@fitlab-ai/agent-infra 0.9.5 → 0.9.6

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 (106) hide show
  1. package/dist/bin/internal-cli.js +3 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +126 -16
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +48 -4
  5. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  6. package/dist/lib/git/workflow.js +0 -1
  7. package/dist/lib/internal/codex-lifecycle.js +100 -2
  8. package/dist/lib/internal/release-workflow.js +108 -11
  9. package/dist/lib/internal/sandbox-control.js +43 -6
  10. package/dist/lib/internal/task-create.js +12 -4
  11. package/dist/lib/internal/task-orchestration.js +5 -4
  12. package/dist/lib/platform/issue-comments.js +2 -1
  13. package/dist/lib/release/workflow.js +29 -21
  14. package/dist/lib/sandbox/commands/create.js +15 -16
  15. package/dist/lib/sandbox/commands/prune.js +40 -16
  16. package/dist/lib/sandbox/commands/rm.js +318 -89
  17. package/dist/lib/sandbox/control/client.js +138 -22
  18. package/dist/lib/sandbox/control/executor.js +136 -0
  19. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  20. package/dist/lib/sandbox/control/protocol.js +62 -26
  21. package/dist/lib/sandbox/control/server.js +222 -102
  22. package/dist/lib/sandbox/control/state.js +181 -0
  23. package/dist/lib/sandbox/index.js +2 -2
  24. package/dist/lib/sandbox/managed-fs.js +14 -2
  25. package/dist/lib/sandbox/mounts.js +35 -24
  26. package/dist/lib/sandbox/recovery.js +262 -45
  27. package/dist/lib/sandbox/workspace-view.js +73 -10
  28. package/dist/lib/sandbox/worktree-safety.js +196 -0
  29. package/dist/lib/server/process-control.js +6 -0
  30. package/dist/lib/task/artifact-lifecycle.js +5 -4
  31. package/dist/lib/task/codex-orchestration.js +161 -0
  32. package/dist/lib/task/commands/status.js +3 -0
  33. package/dist/lib/task/commands/validate.js +289 -0
  34. package/dist/lib/task/commit-finalization.js +36 -9
  35. package/dist/lib/task/delegation-receipts.js +25 -0
  36. package/dist/lib/task/events.js +10 -4
  37. package/dist/lib/task/finalization-artifacts.js +2 -1
  38. package/dist/lib/task/implementation-inputs.js +1 -7
  39. package/dist/lib/task/index.js +9 -0
  40. package/dist/lib/task/orchestration.js +133 -4
  41. package/dist/lib/task/verification-engine.js +38 -6
  42. package/dist/lib/task/verification.js +1 -0
  43. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +142 -15
  44. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  45. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +51 -3
  46. package/lib/agent-clients/adapters/codex.ts +1 -1
  47. package/lib/git/workflow.ts +0 -1
  48. package/lib/internal/codex-lifecycle.ts +107 -3
  49. package/lib/internal/release-workflow.ts +107 -11
  50. package/lib/internal/sandbox-control.ts +38 -6
  51. package/lib/internal/task-create.ts +16 -4
  52. package/lib/internal/task-orchestration.ts +4 -4
  53. package/lib/platform/issue-comments.ts +2 -1
  54. package/lib/release/workflow.ts +69 -20
  55. package/lib/sandbox/commands/create.ts +17 -21
  56. package/lib/sandbox/commands/prune.ts +53 -15
  57. package/lib/sandbox/commands/rm.ts +368 -103
  58. package/lib/sandbox/control/client.ts +152 -28
  59. package/lib/sandbox/control/executor.ts +163 -0
  60. package/lib/sandbox/control/lifecycle.ts +342 -0
  61. package/lib/sandbox/control/protocol.ts +96 -69
  62. package/lib/sandbox/control/server.ts +239 -110
  63. package/lib/sandbox/control/state.ts +196 -0
  64. package/lib/sandbox/index.ts +2 -2
  65. package/lib/sandbox/managed-fs.ts +17 -2
  66. package/lib/sandbox/mounts.ts +31 -13
  67. package/lib/sandbox/recovery.ts +301 -60
  68. package/lib/sandbox/workspace-view.ts +81 -11
  69. package/lib/sandbox/worktree-safety.ts +234 -0
  70. package/lib/server/process-control.ts +11 -0
  71. package/lib/task/artifact-lifecycle.ts +5 -4
  72. package/lib/task/codex-orchestration.ts +233 -0
  73. package/lib/task/commands/status.ts +2 -0
  74. package/lib/task/commands/validate.ts +282 -0
  75. package/lib/task/commit-finalization.ts +52 -9
  76. package/lib/task/delegation-receipts.ts +52 -3
  77. package/lib/task/events.ts +9 -4
  78. package/lib/task/finalization-artifacts.ts +2 -1
  79. package/lib/task/implementation-inputs.ts +1 -5
  80. package/lib/task/index.ts +8 -0
  81. package/lib/task/orchestration.ts +199 -4
  82. package/lib/task/verification-engine.ts +42 -8
  83. package/lib/task/verification.ts +2 -1
  84. package/package.json +1 -1
  85. package/templates/.agents/hooks/lifecycle-delegation.js +48 -12
  86. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  87. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  88. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  89. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  90. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  91. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  92. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  93. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  94. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  95. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  96. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  97. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  98. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  99. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  100. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  101. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  102. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  103. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  104. package/templates/.codex/hooks.json +8 -8
  105. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  106. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -0,0 +1,289 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { loadConfig } from '../../sandbox/config.js';
7
+ import { detectEngine } from '../../sandbox/engine.js';
8
+ import { runEngine } from '../../sandbox/shell.js';
9
+ import { sandboxBranchLabel, sandboxLabel } from '../../sandbox/constants.js';
10
+ import { fetchSandboxRows, selectSandboxContainer } from '../../sandbox/commands/list-running.js';
11
+ import { containerNameCandidates } from '../../sandbox/constants.js';
12
+ import { resolveSandboxTarget } from '../../sandbox/workspace-identity.js';
13
+ import { sandboxControlPaths } from '../../sandbox/workspace-view.js';
14
+ import { appendSandboxControlAudit, readActiveLease, readSandboxControlStatus } from '../../sandbox/control/state.js';
15
+ import { SANDBOX_CONTROL_FUTURE_SKEW_MS, SANDBOX_CONTROL_STATUS_STALE_MS } from '../../sandbox/control/protocol.js';
16
+ import { getProcessStartTime } from '../../server/process-state.js';
17
+ import { assertGitWorktreeBinding } from '../../git/worktree-identity.js';
18
+ const USAGE = `Usage: ai task validate <branch | TASK-id | N> [--scope snapshot|inplace] [--timeout <ms>] [--format text|json] -- <command> [args...]`;
19
+ const MAX_TIMEOUT_MS = 60 * 60 * 1000;
20
+ const RECOVERY_GRACE_MS = 30_000;
21
+ export function parseValidateArgs(args) {
22
+ if (args.includes('--help') || args.includes('-h')) {
23
+ return { target: '', scope: 'snapshot', timeoutMs: 300_000, format: 'text', command: [], help: true };
24
+ }
25
+ const separator = args.indexOf('--');
26
+ if (separator < 0)
27
+ throw new Error(`${USAGE}\nA literal -- must separate validation options from the command.`);
28
+ const optionArgs = args.slice(0, separator);
29
+ const command = args.slice(separator + 1);
30
+ let scope = 'snapshot';
31
+ let timeoutMs = 300_000;
32
+ let format = 'text';
33
+ const positionals = [];
34
+ for (let index = 0; index < optionArgs.length; index += 1) {
35
+ const arg = optionArgs[index];
36
+ if (arg === '--scope') {
37
+ const value = optionArgs[++index];
38
+ if (value !== 'snapshot' && value !== 'inplace')
39
+ throw new Error('validate --scope must be snapshot or inplace');
40
+ scope = value;
41
+ }
42
+ else if (arg === '--timeout') {
43
+ const value = Number(optionArgs[++index]);
44
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS)
45
+ throw new Error(`validate --timeout must be 1..${MAX_TIMEOUT_MS}`);
46
+ timeoutMs = value;
47
+ }
48
+ else if (arg === '--format') {
49
+ const value = optionArgs[++index];
50
+ if (value !== 'text' && value !== 'json')
51
+ throw new Error('validate --format must be text or json');
52
+ format = value;
53
+ }
54
+ else if (arg.startsWith('-')) {
55
+ throw new Error(`Unknown option: ${arg}`);
56
+ }
57
+ else {
58
+ positionals.push(arg);
59
+ }
60
+ }
61
+ if (positionals.length !== 1 || command.length === 0)
62
+ throw new Error(USAGE);
63
+ return { target: positionals[0], scope, timeoutMs, format, command, help: false };
64
+ }
65
+ function runValidationCommand(command, cwd, scope, timeoutMs, json) {
66
+ const [file, ...args] = command;
67
+ const result = spawnSync(file, args, {
68
+ cwd,
69
+ env: { ...process.env, AGENT_INFRA_VALIDATION_SCOPE: scope },
70
+ encoding: 'utf8',
71
+ timeout: timeoutMs,
72
+ stdio: ['inherit', 'pipe', 'pipe'],
73
+ shell: process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file)
74
+ });
75
+ if (json) {
76
+ if (result.stdout)
77
+ process.stderr.write(result.stdout);
78
+ if (result.stderr)
79
+ process.stderr.write(result.stderr);
80
+ }
81
+ else {
82
+ if (result.stdout)
83
+ process.stdout.write(result.stdout);
84
+ if (result.stderr)
85
+ process.stderr.write(result.stderr);
86
+ }
87
+ return { exitCode: result.status ?? 1, signal: result.signal ?? null };
88
+ }
89
+ function git(root, args) {
90
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
91
+ }
92
+ function waitForState(statusDir, predicate, timeoutMs = 5_000) {
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ try {
96
+ const status = readSandboxControlStatus(statusDir);
97
+ if (predicate(status))
98
+ return;
99
+ }
100
+ catch {
101
+ // Atomic status publication may be between renames.
102
+ }
103
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
104
+ }
105
+ throw new Error('SANDBOX_VALIDATION_BROKER_STATE_TIMEOUT');
106
+ }
107
+ function snapshotValidation(repoRoot, commit, options) {
108
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-validation-'));
109
+ const worktree = path.join(tempRoot, 'worktree');
110
+ let cleanup = 'not-created';
111
+ try {
112
+ git(repoRoot, ['worktree', 'add', '--detach', worktree, commit]);
113
+ cleanup = 'pending';
114
+ return { ...runValidationCommand(options.command, worktree, 'snapshot', options.timeoutMs, options.format === 'json'), cleanup: () => cleanup };
115
+ }
116
+ finally {
117
+ let cleanupError = null;
118
+ if (fs.existsSync(worktree)) {
119
+ try {
120
+ git(repoRoot, ['worktree', 'remove', '--force', worktree]);
121
+ cleanup = 'completed';
122
+ }
123
+ catch (error) {
124
+ cleanupError = error;
125
+ }
126
+ }
127
+ try {
128
+ git(repoRoot, ['worktree', 'prune']);
129
+ }
130
+ catch (error) {
131
+ cleanupError ??= error;
132
+ }
133
+ try {
134
+ fs.rmSync(tempRoot, { recursive: true, force: true });
135
+ }
136
+ catch (error) {
137
+ cleanupError ??= error;
138
+ }
139
+ if (cleanupError)
140
+ throw cleanupError;
141
+ }
142
+ }
143
+ function inplaceValidation(config, target, options) {
144
+ const engine = detectEngine(config);
145
+ const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
146
+ const row = selectSandboxContainer([...running, ...nonRunning], containerNameCandidates(config, target.branch));
147
+ if (!row)
148
+ throw new Error('SANDBOX_VALIDATION_CONTAINER_NOT_FOUND');
149
+ const control = sandboxControlPaths({
150
+ base: config.controlBase, project: config.project,
151
+ container: row.name, identity: target.workspace
152
+ });
153
+ const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8'));
154
+ const initial = readSandboxControlStatus(control.statusDir);
155
+ const now = Date.now();
156
+ if (initial.generation !== manifest.generation
157
+ || now - initial.updatedAt > SANDBOX_CONTROL_STATUS_STALE_MS
158
+ || initial.updatedAt > now + SANDBOX_CONTROL_FUTURE_SKEW_MS) {
159
+ throw new Error('SANDBOX_VALIDATION_BROKER_STATUS_STALE');
160
+ }
161
+ if (initial.state !== 'healthy' || initial.activeRequestId !== null) {
162
+ throw new Error('SANDBOX_VALIDATION_BROKER_NOT_IDLE');
163
+ }
164
+ const startTime = getProcessStartTime(process.pid);
165
+ if (!startTime)
166
+ throw new Error('SANDBOX_VALIDATION_OWNER_IDENTITY_UNAVAILABLE');
167
+ const leasePath = path.join(control.root, 'lease.json');
168
+ const nonce = randomUUID();
169
+ const issuedAt = Date.now();
170
+ const lease = {
171
+ version: 1, generation: manifest.generation, nonce,
172
+ owner: { pid: process.pid, startTime }, issuedAt,
173
+ expiresAt: issuedAt + Math.min(options.timeoutMs + RECOVERY_GRACE_MS, MAX_TIMEOUT_MS + RECOVERY_GRACE_MS),
174
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
175
+ branch: target.branch, reason: 'manual-validation'
176
+ };
177
+ const originalBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
178
+ const originalHead = git(manifest.worktreeRoot, ['rev-parse', 'HEAD']);
179
+ fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
180
+ let stopped = false;
181
+ let restored = false;
182
+ try {
183
+ appendSandboxControlAudit(manifest, 'lease-acquire', { ownerPid: process.pid });
184
+ waitForState(control.statusDir, (status) => status.state === 'parked' && status.reasonCode === 'SANDBOX_CONTROL_HANDOFF_ACTIVE');
185
+ if (row.running) {
186
+ runEngine(engine, 'docker', ['stop', row.name]);
187
+ stopped = true;
188
+ }
189
+ const refreshedRows = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
190
+ const refreshed = selectSandboxContainer([...refreshedRows.running, ...refreshedRows.nonRunning], containerNameCandidates(config, target.branch));
191
+ if (!refreshed || refreshed.running)
192
+ throw new Error('SANDBOX_VALIDATION_CONTAINER_STILL_RUNNING');
193
+ const activeLease = readActiveLease(manifest);
194
+ if (!activeLease || activeLease.nonce !== nonce)
195
+ throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
196
+ assertGitWorktreeBinding(config.repoRoot, manifest.worktreeRoot, target.branch);
197
+ const result = runValidationCommand(options.command, manifest.worktreeRoot, 'inplace', options.timeoutMs, options.format === 'json');
198
+ return { ...result, cleanup: () => restored ? 'completed' : 'pending' };
199
+ }
200
+ finally {
201
+ const recoveryErrors = [];
202
+ try {
203
+ const currentBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
204
+ if (currentBranch !== originalBranch)
205
+ git(manifest.worktreeRoot, ['switch', originalBranch]);
206
+ }
207
+ catch {
208
+ try {
209
+ git(manifest.worktreeRoot, ['checkout', '--detach', originalHead]);
210
+ git(manifest.worktreeRoot, ['switch', originalBranch]);
211
+ }
212
+ catch (fallbackError) {
213
+ recoveryErrors.push(`git binding restore failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
214
+ }
215
+ }
216
+ if (stopped) {
217
+ try {
218
+ runEngine(engine, 'docker', ['start', row.name]);
219
+ }
220
+ catch (error) {
221
+ recoveryErrors.push(`container start failed: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ }
224
+ try {
225
+ const currentLease = JSON.parse(fs.readFileSync(leasePath, 'utf8'));
226
+ if (currentLease.nonce !== nonce)
227
+ throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
228
+ }
229
+ catch (error) {
230
+ recoveryErrors.push(error instanceof Error ? error.message : String(error));
231
+ }
232
+ if (recoveryErrors.length === 0) {
233
+ try {
234
+ fs.unlinkSync(leasePath);
235
+ appendSandboxControlAudit(manifest, 'lease-release', { ownerPid: process.pid });
236
+ waitForState(control.statusDir, (status) => status.state === 'healthy');
237
+ restored = true;
238
+ }
239
+ catch (error) {
240
+ try {
241
+ if (!fs.existsSync(leasePath)) {
242
+ fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
243
+ }
244
+ appendSandboxControlAudit(manifest, 'lease-release-failed', { ownerPid: process.pid });
245
+ }
246
+ catch (leaseError) {
247
+ recoveryErrors.push(`lease preservation failed: ${leaseError instanceof Error ? leaseError.message : String(leaseError)}`);
248
+ }
249
+ recoveryErrors.push(error instanceof Error ? error.message : String(error));
250
+ }
251
+ }
252
+ if (recoveryErrors.length > 0) {
253
+ throw new Error(`SANDBOX_VALIDATION_RECOVERY_INCOMPLETE: ${recoveryErrors.join('; ')}; run 'ai sandbox start ${target.branch}'`);
254
+ }
255
+ }
256
+ }
257
+ export async function validate(args) {
258
+ const options = parseValidateArgs(args);
259
+ if (options.help) {
260
+ process.stdout.write(`${USAGE}\n`);
261
+ return 0;
262
+ }
263
+ const config = loadConfig();
264
+ const target = resolveSandboxTarget(options.target, config.repoRoot);
265
+ const commit = git(config.repoRoot, ['rev-parse', target.branch]);
266
+ const startedAt = new Date().toISOString();
267
+ const result = options.scope === 'snapshot'
268
+ ? snapshotValidation(config.repoRoot, commit, options)
269
+ : inplaceValidation(config, target, options);
270
+ const evidence = {
271
+ version: 1,
272
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
273
+ branch: target.branch,
274
+ scope: options.scope,
275
+ commit,
276
+ command: path.basename(options.command[0]),
277
+ startedAt,
278
+ completedAt: new Date().toISOString(),
279
+ exitCode: result.exitCode,
280
+ signal: result.signal,
281
+ cleanup: result.cleanup()
282
+ };
283
+ if (options.format === 'json')
284
+ process.stdout.write(`${JSON.stringify(evidence)}\n`);
285
+ else
286
+ process.stdout.write(`Validation ${result.exitCode === 0 ? 'passed' : 'failed'} (${options.scope}, ${evidence.command}, cleanup=${evidence.cleanup}).\n`);
287
+ return result.exitCode;
288
+ }
289
+ //# sourceMappingURL=validate.js.map
@@ -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
  }
@@ -23,6 +23,7 @@ function prepareDelegation(input, options = {}) {
23
23
  spawnMode: null,
24
24
  agent: null,
25
25
  status: 'prepared',
26
+ hostEvidence: null,
26
27
  afterFingerprint: null,
27
28
  changedPaths: Object.freeze([]),
28
29
  createdAt: (options.now ?? (() => new Date().toISOString()))(),
@@ -65,6 +66,12 @@ function activateDelegation(receipt, event, options = {}) {
65
66
  if (actualReasoningEffort === receipt.requestedReasoningEffort && event.reasoningEffortFallbackReason) {
66
67
  return fail('DELEGATION_REASONING_EFFORT_FALLBACK_INVALID', 'reasoning-effort fallback reason is only valid when actual effort differs');
67
68
  }
69
+ if (event.hostEvidence
70
+ && (receipt.client !== 'codex'
71
+ || !event.hostEvidence.hookDefinitionHash.trim()
72
+ || !Number.isSafeInteger(event.hostEvidence.startRevision)
73
+ || event.hostEvidence.startRevision < 1))
74
+ return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex start evidence reference is invalid');
68
75
  return { ok: true, receipt: Object.freeze({
69
76
  ...receipt,
70
77
  status: 'activated',
@@ -75,6 +82,12 @@ function activateDelegation(receipt, event, options = {}) {
75
82
  actualReasoningEffort,
76
83
  modelFallbackReason: event.modelFallbackReason ?? null,
77
84
  reasoningEffortFallbackReason: event.reasoningEffortFallbackReason ?? null,
85
+ hostEvidence: event.hostEvidence ? Object.freeze({
86
+ ...event.hostEvidence,
87
+ stopRevision: null,
88
+ consumer: null,
89
+ consumedAt: null
90
+ }) : receipt.hostEvidence ?? null,
78
91
  activatedAt: (options.now ?? (() => new Date().toISOString()))()
79
92
  }) };
80
93
  }
@@ -105,11 +118,23 @@ function sealDelegation(receipt, event, options = {}) {
105
118
  if (disallowed)
106
119
  return fail('DELEGATION_REVIEWER_WRITE_FORBIDDEN', `reviewer changed forbidden path '${disallowed}'`);
107
120
  }
121
+ if (event.hostEvidence) {
122
+ if (!receipt.hostEvidence
123
+ || !Number.isSafeInteger(event.hostEvidence.stopRevision)
124
+ || event.hostEvidence.stopRevision <= receipt.hostEvidence.startRevision
125
+ || event.hostEvidence.consumer !== receipt.id
126
+ || !event.hostEvidence.consumedAt.trim())
127
+ return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex stop evidence reference is invalid');
128
+ }
108
129
  return { ok: true, receipt: Object.freeze({
109
130
  ...receipt,
110
131
  status: 'sealed',
111
132
  afterFingerprint: event.afterFingerprint,
112
133
  changedPaths: Object.freeze([...event.changedPaths]),
134
+ hostEvidence: event.hostEvidence && receipt.hostEvidence ? Object.freeze({
135
+ ...receipt.hostEvidence,
136
+ ...event.hostEvidence
137
+ }) : receipt.hostEvidence ?? null,
113
138
  sealedAt: (options.now ?? (() => new Date().toISOString()))()
114
139
  }) };
115
140
  }
@@ -18,7 +18,8 @@ const eventCatalog = [
18
18
  'review-plan.started', 'review-plan.completed',
19
19
  'code.started', 'code.completed',
20
20
  'review-code.started', 'review-code.completed',
21
- 'manual-validation.started', 'manual-validation.completed'
21
+ 'manual-validation.started', 'manual-validation.completed',
22
+ 'validation-run.started', 'validation-run.completed'
22
23
  ];
23
24
  const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun']);
24
25
  const SCHEMAS = {
@@ -36,7 +37,9 @@ const SCHEMAS = {
36
37
  'review-code.started': { optional: ['round'] },
37
38
  'review-code.completed': { required: ['artifact', 'verdict', 'blockers', 'major', 'minor', 'manualValidation'], optional: ['round', 'orchestrated'] },
38
39
  'manual-validation.started': { optional: ['round'] },
39
- 'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] }
40
+ 'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] },
41
+ 'validation-run.started': { optional: ['round'] },
42
+ 'validation-run.completed': { required: ['artifact'], optional: ['round'] }
40
43
  };
41
44
  function validateTaskEventRequest(request) {
42
45
  if (!eventCatalog.includes(request.event))
@@ -84,7 +87,8 @@ const FAMILY = {
84
87
  '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
88
  code: { artifact: 'code', started: ['technical-design-review', 'code-review'], completed: ['technical-design-review', 'code-review'], target: 'code', label: 'Code Task' },
86
89
  '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' }
90
+ 'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' },
91
+ 'validation-run': { artifact: 'validation-run', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Run Manual Validation' }
88
92
  };
89
93
  const REVIEW_LEDGER_STAGES = {
90
94
  'review-analysis': 'analysis',
@@ -180,6 +184,8 @@ function identity(request) {
180
184
  note = `Fixed ${request.blockers} blockers, ${request.major} major, ${request.minor} minor issues${request.manualValidation ? `, skipped ${request.manualValidation} manual-validation` : ''} → ${request.artifact}`;
181
185
  else if (family === 'code')
182
186
  note = `Code implemented, ${request.filesModified} files modified, ${request.testsPassed} tests passed → ${request.artifact}`;
187
+ else if (family === 'validation-run')
188
+ note = `Validation evidence recorded → ${request.artifact}`;
183
189
  else {
184
190
  const verdict = request.verdict === 'approved' ? 'Approved' : request.verdict === 'changes-requested' ? 'Changes Requested' : 'Rejected';
185
191
  note = `Verdict: ${verdict}, blockers: ${request.blockers}, major: ${request.major}, minor: ${request.minor}, Manual-validation: ${request.manualValidation} → ${request.artifact}`;
@@ -329,7 +335,7 @@ function applyTaskEventUnlocked(request, options = {}) {
329
335
  if (findingCountError)
330
336
  return failed(normalized, findingCountError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
331
337
  let orchestrationCompletion = null;
332
- if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation') {
338
+ if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation' && eventIdentity.family !== 'validation-run') {
333
339
  const orchestrationStage = eventIdentity.family === 'analyze' ? 'analysis' : eventIdentity.family;
334
340
  const execution = validateLifecycleExecution(normalized.taskRef, {
335
341
  mode: normalized.orchestrated ? 'orchestrated' : 'standalone',
@@ -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`);