@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
@@ -71,6 +71,7 @@ const STAGE_ORDER = [
71
71
  'review-plan',
72
72
  'code',
73
73
  'review-code',
74
+ 'validation-run',
74
75
  'task',
75
76
  'other'
76
77
  ] as const;
@@ -81,6 +82,7 @@ function stageOf(name: string): string {
81
82
  if (stem.startsWith('review-analysis')) return 'review-analysis';
82
83
  if (stem.startsWith('review-plan')) return 'review-plan';
83
84
  if (stem.startsWith('review-code')) return 'review-code';
85
+ if (stem.startsWith('validation-run')) return 'validation-run';
84
86
  if (stem.startsWith('analysis')) return 'analysis';
85
87
  if (stem.startsWith('plan')) return 'plan';
86
88
  if (stem.startsWith('code')) return 'code';
@@ -0,0 +1,282 @@
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.ts';
7
+ import { detectEngine } from '../../sandbox/engine.ts';
8
+ import { runEngine } from '../../sandbox/shell.ts';
9
+ import { sandboxBranchLabel, sandboxLabel } from '../../sandbox/constants.ts';
10
+ import { fetchSandboxRows, selectSandboxContainer } from '../../sandbox/commands/list-running.ts';
11
+ import { containerNameCandidates } from '../../sandbox/constants.ts';
12
+ import { resolveSandboxTarget } from '../../sandbox/workspace-identity.ts';
13
+ import { sandboxControlPaths } from '../../sandbox/workspace-view.ts';
14
+ import {
15
+ appendSandboxControlAudit,
16
+ readActiveLease,
17
+ readSandboxControlStatus
18
+ } from '../../sandbox/control/state.ts';
19
+ import type { SandboxControlLease, SandboxControlManifest } from '../../sandbox/control/protocol.ts';
20
+ import {
21
+ SANDBOX_CONTROL_FUTURE_SKEW_MS,
22
+ SANDBOX_CONTROL_STATUS_STALE_MS
23
+ } from '../../sandbox/control/protocol.ts';
24
+ import { getProcessStartTime } from '../../server/process-state.ts';
25
+ import { assertGitWorktreeBinding } from '../../git/worktree-identity.ts';
26
+
27
+ const USAGE = `Usage: ai task validate <branch | TASK-id | N> [--scope snapshot|inplace] [--timeout <ms>] [--format text|json] -- <command> [args...]`;
28
+ const MAX_TIMEOUT_MS = 60 * 60 * 1000;
29
+ const RECOVERY_GRACE_MS = 30_000;
30
+
31
+ type ValidateOptions = {
32
+ target: string;
33
+ scope: 'snapshot' | 'inplace';
34
+ timeoutMs: number;
35
+ format: 'text' | 'json';
36
+ command: string[];
37
+ help: boolean;
38
+ };
39
+
40
+ export function parseValidateArgs(args: string[]): ValidateOptions {
41
+ if (args.includes('--help') || args.includes('-h')) {
42
+ return { target: '', scope: 'snapshot', timeoutMs: 300_000, format: 'text', command: [], help: true };
43
+ }
44
+ const separator = args.indexOf('--');
45
+ if (separator < 0) throw new Error(`${USAGE}\nA literal -- must separate validation options from the command.`);
46
+ const optionArgs = args.slice(0, separator);
47
+ const command = args.slice(separator + 1);
48
+ let scope: ValidateOptions['scope'] = 'snapshot';
49
+ let timeoutMs = 300_000;
50
+ let format: ValidateOptions['format'] = 'text';
51
+ const positionals: string[] = [];
52
+ for (let index = 0; index < optionArgs.length; index += 1) {
53
+ const arg = optionArgs[index]!;
54
+ if (arg === '--scope') {
55
+ const value = optionArgs[++index];
56
+ if (value !== 'snapshot' && value !== 'inplace') throw new Error('validate --scope must be snapshot or inplace');
57
+ scope = value;
58
+ } else if (arg === '--timeout') {
59
+ const value = Number(optionArgs[++index]);
60
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) throw new Error(`validate --timeout must be 1..${MAX_TIMEOUT_MS}`);
61
+ timeoutMs = value;
62
+ } else if (arg === '--format') {
63
+ const value = optionArgs[++index];
64
+ if (value !== 'text' && value !== 'json') throw new Error('validate --format must be text or json');
65
+ format = value;
66
+ } else if (arg.startsWith('-')) {
67
+ throw new Error(`Unknown option: ${arg}`);
68
+ } else {
69
+ positionals.push(arg);
70
+ }
71
+ }
72
+ if (positionals.length !== 1 || command.length === 0) throw new Error(USAGE);
73
+ return { target: positionals[0]!, scope, timeoutMs, format, command, help: false };
74
+ }
75
+
76
+ function runValidationCommand(command: string[], cwd: string, scope: string, timeoutMs: number, json: boolean) {
77
+ const [file, ...args] = command;
78
+ const result = spawnSync(file!, args, {
79
+ cwd,
80
+ env: { ...process.env, AGENT_INFRA_VALIDATION_SCOPE: scope },
81
+ encoding: 'utf8',
82
+ timeout: timeoutMs,
83
+ stdio: ['inherit', 'pipe', 'pipe'],
84
+ shell: process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file!)
85
+ });
86
+ if (json) {
87
+ if (result.stdout) process.stderr.write(result.stdout);
88
+ if (result.stderr) process.stderr.write(result.stderr);
89
+ } else {
90
+ if (result.stdout) process.stdout.write(result.stdout);
91
+ if (result.stderr) process.stderr.write(result.stderr);
92
+ }
93
+ return { exitCode: result.status ?? 1, signal: result.signal ?? null };
94
+ }
95
+
96
+ function git(root: string, args: string[]): string {
97
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
98
+ }
99
+
100
+ function waitForState(statusDir: string, predicate: (state: ReturnType<typeof readSandboxControlStatus>) => boolean, timeoutMs = 5_000): void {
101
+ const deadline = Date.now() + timeoutMs;
102
+ while (Date.now() < deadline) {
103
+ try {
104
+ const status = readSandboxControlStatus(statusDir);
105
+ if (predicate(status)) return;
106
+ } catch {
107
+ // Atomic status publication may be between renames.
108
+ }
109
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
110
+ }
111
+ throw new Error('SANDBOX_VALIDATION_BROKER_STATE_TIMEOUT');
112
+ }
113
+
114
+ function snapshotValidation(repoRoot: string, commit: string, options: ValidateOptions) {
115
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-validation-'));
116
+ const worktree = path.join(tempRoot, 'worktree');
117
+ let cleanup = 'not-created';
118
+ try {
119
+ git(repoRoot, ['worktree', 'add', '--detach', worktree, commit]);
120
+ cleanup = 'pending';
121
+ return { ...runValidationCommand(options.command, worktree, 'snapshot', options.timeoutMs, options.format === 'json'), cleanup: () => cleanup };
122
+ } finally {
123
+ let cleanupError: unknown = null;
124
+ if (fs.existsSync(worktree)) {
125
+ try {
126
+ git(repoRoot, ['worktree', 'remove', '--force', worktree]);
127
+ cleanup = 'completed';
128
+ } catch (error) {
129
+ cleanupError = error;
130
+ }
131
+ }
132
+ try {
133
+ git(repoRoot, ['worktree', 'prune']);
134
+ } catch (error) {
135
+ cleanupError ??= error;
136
+ }
137
+ try {
138
+ fs.rmSync(tempRoot, { recursive: true, force: true });
139
+ } catch (error) {
140
+ cleanupError ??= error;
141
+ }
142
+ if (cleanupError) throw cleanupError;
143
+ }
144
+ }
145
+
146
+ function inplaceValidation(config: ReturnType<typeof loadConfig>, target: ReturnType<typeof resolveSandboxTarget>, options: ValidateOptions) {
147
+ const engine = detectEngine(config);
148
+ const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
149
+ const row = selectSandboxContainer([...running, ...nonRunning], containerNameCandidates(config, target.branch));
150
+ if (!row) throw new Error('SANDBOX_VALIDATION_CONTAINER_NOT_FOUND');
151
+ const control = sandboxControlPaths({
152
+ base: config.controlBase, project: config.project,
153
+ container: row.name, identity: target.workspace
154
+ });
155
+ const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as SandboxControlManifest;
156
+ const initial = readSandboxControlStatus(control.statusDir);
157
+ const now = Date.now();
158
+ if (initial.generation !== manifest.generation
159
+ || now - initial.updatedAt > SANDBOX_CONTROL_STATUS_STALE_MS
160
+ || initial.updatedAt > now + SANDBOX_CONTROL_FUTURE_SKEW_MS) {
161
+ throw new Error('SANDBOX_VALIDATION_BROKER_STATUS_STALE');
162
+ }
163
+ if (initial.state !== 'healthy' || initial.activeRequestId !== null) {
164
+ throw new Error('SANDBOX_VALIDATION_BROKER_NOT_IDLE');
165
+ }
166
+ const startTime = getProcessStartTime(process.pid);
167
+ if (!startTime) throw new Error('SANDBOX_VALIDATION_OWNER_IDENTITY_UNAVAILABLE');
168
+ const leasePath = path.join(control.root, 'lease.json');
169
+ const nonce = randomUUID();
170
+ const issuedAt = Date.now();
171
+ const lease: SandboxControlLease = {
172
+ version: 1, generation: manifest.generation, nonce,
173
+ owner: { pid: process.pid, startTime }, issuedAt,
174
+ expiresAt: issuedAt + Math.min(options.timeoutMs + RECOVERY_GRACE_MS, MAX_TIMEOUT_MS + RECOVERY_GRACE_MS),
175
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
176
+ branch: target.branch, reason: 'manual-validation'
177
+ };
178
+ const originalBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
179
+ const originalHead = git(manifest.worktreeRoot, ['rev-parse', 'HEAD']);
180
+ fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
181
+ let stopped = false;
182
+ let restored = false;
183
+ try {
184
+ appendSandboxControlAudit(manifest, 'lease-acquire', { ownerPid: process.pid });
185
+ waitForState(control.statusDir, (status) => status.state === 'parked' && status.reasonCode === 'SANDBOX_CONTROL_HANDOFF_ACTIVE');
186
+ if (row.running) {
187
+ runEngine(engine, 'docker', ['stop', row.name]);
188
+ stopped = true;
189
+ }
190
+ const refreshedRows = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
191
+ const refreshed = selectSandboxContainer(
192
+ [...refreshedRows.running, ...refreshedRows.nonRunning],
193
+ containerNameCandidates(config, target.branch)
194
+ );
195
+ if (!refreshed || refreshed.running) throw new Error('SANDBOX_VALIDATION_CONTAINER_STILL_RUNNING');
196
+ const activeLease = readActiveLease(manifest);
197
+ if (!activeLease || activeLease.nonce !== nonce) throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
198
+ assertGitWorktreeBinding(config.repoRoot, manifest.worktreeRoot, target.branch);
199
+ const result = runValidationCommand(options.command, manifest.worktreeRoot, 'inplace', options.timeoutMs, options.format === 'json');
200
+ return { ...result, cleanup: () => restored ? 'completed' : 'pending' };
201
+ } finally {
202
+ const recoveryErrors: string[] = [];
203
+ try {
204
+ const currentBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
205
+ if (currentBranch !== originalBranch) git(manifest.worktreeRoot, ['switch', originalBranch]);
206
+ } catch {
207
+ try {
208
+ git(manifest.worktreeRoot, ['checkout', '--detach', originalHead]);
209
+ git(manifest.worktreeRoot, ['switch', originalBranch]);
210
+ } catch (fallbackError) {
211
+ recoveryErrors.push(`git binding restore failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
212
+ }
213
+ }
214
+ if (stopped) {
215
+ try {
216
+ runEngine(engine, 'docker', ['start', row.name]);
217
+ } catch (error) {
218
+ recoveryErrors.push(`container start failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ }
221
+ try {
222
+ const currentLease = JSON.parse(fs.readFileSync(leasePath, 'utf8')) as { nonce?: unknown };
223
+ if (currentLease.nonce !== nonce) throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
224
+ } catch (error) {
225
+ recoveryErrors.push(error instanceof Error ? error.message : String(error));
226
+ }
227
+ if (recoveryErrors.length === 0) {
228
+ try {
229
+ fs.unlinkSync(leasePath);
230
+ appendSandboxControlAudit(manifest, 'lease-release', { ownerPid: process.pid });
231
+ waitForState(control.statusDir, (status) => status.state === 'healthy');
232
+ restored = true;
233
+ } catch (error) {
234
+ try {
235
+ if (!fs.existsSync(leasePath)) {
236
+ fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
237
+ }
238
+ appendSandboxControlAudit(manifest, 'lease-release-failed', { ownerPid: process.pid });
239
+ } catch (leaseError) {
240
+ recoveryErrors.push(`lease preservation failed: ${leaseError instanceof Error ? leaseError.message : String(leaseError)}`);
241
+ }
242
+ recoveryErrors.push(error instanceof Error ? error.message : String(error));
243
+ }
244
+ }
245
+ if (recoveryErrors.length > 0) {
246
+ throw new Error(
247
+ `SANDBOX_VALIDATION_RECOVERY_INCOMPLETE: ${recoveryErrors.join('; ')}; run 'ai sandbox start ${target.branch}'`
248
+ );
249
+ }
250
+ }
251
+ }
252
+
253
+ export async function validate(args: string[]): Promise<number> {
254
+ const options = parseValidateArgs(args);
255
+ if (options.help) {
256
+ process.stdout.write(`${USAGE}\n`);
257
+ return 0;
258
+ }
259
+ const config = loadConfig();
260
+ const target = resolveSandboxTarget(options.target, config.repoRoot);
261
+ const commit = git(config.repoRoot, ['rev-parse', target.branch]);
262
+ const startedAt = new Date().toISOString();
263
+ const result = options.scope === 'snapshot'
264
+ ? snapshotValidation(config.repoRoot, commit, options)
265
+ : inplaceValidation(config, target, options);
266
+ const evidence = {
267
+ version: 1,
268
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
269
+ branch: target.branch,
270
+ scope: options.scope,
271
+ commit,
272
+ command: path.basename(options.command[0]!),
273
+ startedAt,
274
+ completedAt: new Date().toISOString(),
275
+ exitCode: result.exitCode,
276
+ signal: result.signal,
277
+ cleanup: result.cleanup()
278
+ };
279
+ if (options.format === 'json') process.stdout.write(`${JSON.stringify(evidence)}\n`);
280
+ else process.stdout.write(`Validation ${result.exitCode === 0 ? 'passed' : 'failed'} (${options.scope}, ${evidence.command}, cleanup=${evidence.cleanup}).\n`);
281
+ return result.exitCode;
282
+ }
@@ -12,7 +12,7 @@ import type { CommitAttempt } from './activity-log.ts';
12
12
  import { commitIntentPath, digest, readCommitIntent } from './commit-intent.ts';
13
13
  import type { CommitIntent } from './commit-intent.ts';
14
14
  import { parseTypedTaskFrontmatter } from './frontmatter.ts';
15
- import { parseReviewSummary } from './review-artifacts.ts';
15
+ import { artifactName, maxRound, parseReviewSummary } from './review-artifacts.ts';
16
16
  import {
17
17
  extractReviewBaseline,
18
18
  extractReviewedSnapshotTree,
@@ -43,6 +43,7 @@ type CommitFinalizationInspection = Readonly<{
43
43
  currentHead: string;
44
44
  committedHead: string | null;
45
45
  commitNote: string | null;
46
+ activityNote: string | null;
46
47
  needsAnchor: boolean;
47
48
  needsLog: boolean;
48
49
  intent: CommitIntent | null;
@@ -87,6 +88,28 @@ function isAncestor(repoRoot: string, ancestor: string, descendant: string): boo
87
88
  }).status === 0;
88
89
  }
89
90
 
91
+ function hasMatchingApprovedReview(
92
+ taskDir: string,
93
+ beforeRound: number,
94
+ baseline: string,
95
+ tree: string
96
+ ): boolean {
97
+ const entries = fs.readdirSync(taskDir);
98
+ for (let round = Math.min(beforeRound - 1, maxRound(entries, 'review-code')); round >= 1; round -= 1) {
99
+ const file = path.join(taskDir, artifactName('review-code', round));
100
+ if (!fs.existsSync(file)) continue;
101
+ const content = fs.readFileSync(file, 'utf8');
102
+ const summary = parseReviewSummary(content);
103
+ if (
104
+ summary.ok
105
+ && summary.summary.verdict === 'Approved'
106
+ && extractReviewBaseline(content) === baseline
107
+ && extractReviewedSnapshotTree(content) === tree
108
+ ) return true;
109
+ }
110
+ return false;
111
+ }
112
+
90
113
  function outcome(
91
114
  disposition: CommitFinalizationDisposition,
92
115
  overrides: Partial<CommitFinalizationInspection> = {}
@@ -98,6 +121,7 @@ function outcome(
98
121
  currentHead: '',
99
122
  committedHead: null,
100
123
  commitNote: null,
124
+ activityNote: null,
101
125
  needsAnchor: false,
102
126
  needsLog: false,
103
127
  intent: null,
@@ -212,6 +236,12 @@ function inspectCommitFinalization(
212
236
  });
213
237
  }
214
238
 
239
+ const commitNote = git(repoRoot, ['show', '-s', '--format=%h%x20%s', intent.committedHead]);
240
+ const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
241
+ const pushOnlyRetry = intent.phase === 'pushed'
242
+ && intent.baselineHead === intent.committedHead
243
+ && matchingDone.length === 1
244
+ && openRows.length === 1;
215
245
  const review = findAuthoritativeReviewCodeArtifact(taskDir);
216
246
  let needsAnchor = false;
217
247
  if (review.ok && review.path) {
@@ -228,7 +258,16 @@ function inspectCommitFinalization(
228
258
  return invalid('authoritative review-code evidence is not a valid approved snapshot', currentHead, 'COMMIT_FINALIZATION_EVIDENCE_INVALID', shared);
229
259
  }
230
260
  const committedTree = git(repoRoot, ['rev-parse', `${intent.committedHead}^{tree}`]);
231
- if (reviewBaseline !== intent.baselineHead || reviewTree !== committedTree) {
261
+ const matchingCurrentReview = reviewBaseline === intent.baselineHead && reviewTree === committedTree;
262
+ if (
263
+ !matchingCurrentReview
264
+ && !hasMatchingApprovedReview(
265
+ taskDir,
266
+ review.round,
267
+ intent.baselineHead,
268
+ committedTree
269
+ )
270
+ ) {
232
271
  return outcome('conflict', {
233
272
  ...shared,
234
273
  code: 'COMMIT_FINALIZATION_CONFLICT',
@@ -237,18 +276,16 @@ function inspectCommitFinalization(
237
276
  }
238
277
 
239
278
  const anchor = String(metadata.last_reviewed_commit ?? '').trim();
240
- if (anchor && anchor !== intent.committedHead) {
279
+ if (anchor && anchor !== intent.committedHead && anchor !== intent.baselineHead) {
241
280
  return outcome('conflict', {
242
281
  ...shared,
243
282
  code: 'COMMIT_FINALIZATION_CONFLICT',
244
283
  message: 'task review anchor conflicts with the committed intent'
245
284
  });
246
285
  }
247
- needsAnchor = anchor === '';
286
+ needsAnchor = anchor !== intent.committedHead;
248
287
  }
249
- const commitNote = git(repoRoot, ['show', '-s', '--format=%h%x20%s', intent.committedHead]);
250
- const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
251
- const needsLog = matchingDone.length === 0 && openRows.length === 1;
288
+ const needsLog = (matchingDone.length === 0 && openRows.length === 1) || pushOnlyRetry;
252
289
  if (!(needsLog || (matchingDone.length === 1 && openRows.length === 0))) {
253
290
  return outcome('conflict', {
254
291
  ...shared,
@@ -261,6 +298,7 @@ function inspectCommitFinalization(
261
298
  ...shared,
262
299
  message: 'commit finalization can be recovered',
263
300
  commitNote,
301
+ activityNote: pushOnlyRetry ? `Pushed ${commitNote}` : commitNote,
264
302
  needsAnchor,
265
303
  needsLog
266
304
  });
@@ -272,7 +310,12 @@ function planCommitTaskFinalization(
272
310
  agent: string,
273
311
  timestamp: string
274
312
  ): CommitTaskFinalizationPlan {
275
- if (inspection.disposition !== 'recoverable' || !inspection.committedHead || !inspection.commitNote) {
313
+ if (
314
+ inspection.disposition !== 'recoverable'
315
+ || !inspection.committedHead
316
+ || !inspection.commitNote
317
+ || !inspection.activityNote
318
+ ) {
276
319
  throw new Error('commit finalization task mutations require a recoverable inspection');
277
320
  }
278
321
  const taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
@@ -293,7 +336,7 @@ function planCommitTaskFinalization(
293
336
  time: timestamp,
294
337
  step: 'Commit',
295
338
  agent,
296
- note: inspection.commitNote
339
+ note: inspection.activityNote
297
340
  })
298
341
  });
299
342
  }
@@ -6,6 +6,14 @@ import { normalizeAgentToken } from '../agent-clients/tokens.ts';
6
6
  type DelegationRole = 'executor' | 'reviewer';
7
7
  type DelegationStage = 'analysis' | 'review-analysis' | 'plan' | 'review-plan' | 'code' | 'review-code' | 'commit';
8
8
  type DelegationStatus = 'prepared' | 'activated' | 'stage-completed' | 'sealed' | 'consumed' | 'aborted' | 'expired';
9
+ type DelegationHostEvidence = Readonly<{
10
+ kind: 'codex-lifecycle-v1';
11
+ hookDefinitionHash: string;
12
+ startRevision: number;
13
+ stopRevision: number | null;
14
+ consumer: string | null;
15
+ consumedAt: string | null;
16
+ }>;
9
17
 
10
18
  type DelegationReceipt = Readonly<{
11
19
  id: string;
@@ -28,6 +36,7 @@ type DelegationReceipt = Readonly<{
28
36
  agent: string | null;
29
37
  status: DelegationStatus;
30
38
  workspaceSnapshotScope?: 'task';
39
+ hostEvidence?: DelegationHostEvidence | null;
31
40
  beforeFingerprint: string;
32
41
  afterFingerprint: string | null;
33
42
  changedPaths: readonly string[];
@@ -55,7 +64,7 @@ function fail(code: string, message: string): ReceiptFailure {
55
64
  }
56
65
 
57
66
  function prepareDelegation(
58
- input: Omit<DelegationReceipt, 'id' | 'requestedModel' | 'requestedReasoningEffort' | 'actualModel' | 'actualReasoningEffort' | 'modelFallbackReason' | 'reasoningEffortFallbackReason' | 'parentId' | 'childId' | 'spawnMode' | 'agent' | 'status' | 'afterFingerprint' | 'changedPaths' | 'createdAt' | 'activatedAt' | 'sealedAt' | 'consumedAt'> & Readonly<{ requestedModel: string; requestedReasoningEffort: string }>,
67
+ input: Omit<DelegationReceipt, 'id' | 'requestedModel' | 'requestedReasoningEffort' | 'actualModel' | 'actualReasoningEffort' | 'modelFallbackReason' | 'reasoningEffortFallbackReason' | 'parentId' | 'childId' | 'spawnMode' | 'agent' | 'status' | 'hostEvidence' | 'afterFingerprint' | 'changedPaths' | 'createdAt' | 'activatedAt' | 'sealedAt' | 'consumedAt'> & Readonly<{ requestedModel: string; requestedReasoningEffort: string }>,
59
68
  options: { id?: () => string; now?: () => string } = {}
60
69
  ): DelegationReceipt {
61
70
  return Object.freeze({
@@ -70,6 +79,7 @@ function prepareDelegation(
70
79
  spawnMode: null,
71
80
  agent: null,
72
81
  status: 'prepared' as const,
82
+ hostEvidence: null,
73
83
  afterFingerprint: null,
74
84
  changedPaths: Object.freeze([]),
75
85
  createdAt: (options.now ?? (() => new Date().toISOString()))(),
@@ -90,6 +100,11 @@ function activateDelegation(
90
100
  actualReasoningEffort?: string;
91
101
  modelFallbackReason?: string;
92
102
  reasoningEffortFallbackReason?: string;
103
+ hostEvidence?: Readonly<{
104
+ kind: 'codex-lifecycle-v1';
105
+ hookDefinitionHash: string;
106
+ startRevision: number;
107
+ }>;
93
108
  }>,
94
109
  options: { now?: () => string } = {}
95
110
  ): ReceiptResult {
@@ -124,6 +139,15 @@ function activateDelegation(
124
139
  if (actualReasoningEffort === receipt.requestedReasoningEffort && event.reasoningEffortFallbackReason) {
125
140
  return fail('DELEGATION_REASONING_EFFORT_FALLBACK_INVALID', 'reasoning-effort fallback reason is only valid when actual effort differs');
126
141
  }
142
+ if (
143
+ event.hostEvidence
144
+ && (
145
+ receipt.client !== 'codex'
146
+ || !event.hostEvidence.hookDefinitionHash.trim()
147
+ || !Number.isSafeInteger(event.hostEvidence.startRevision)
148
+ || event.hostEvidence.startRevision < 1
149
+ )
150
+ ) return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex start evidence reference is invalid');
127
151
  return { ok: true, receipt: Object.freeze({
128
152
  ...receipt,
129
153
  status: 'activated',
@@ -134,6 +158,12 @@ function activateDelegation(
134
158
  actualReasoningEffort,
135
159
  modelFallbackReason: event.modelFallbackReason ?? null,
136
160
  reasoningEffortFallbackReason: event.reasoningEffortFallbackReason ?? null,
161
+ hostEvidence: event.hostEvidence ? Object.freeze({
162
+ ...event.hostEvidence,
163
+ stopRevision: null,
164
+ consumer: null,
165
+ consumedAt: null
166
+ }) : receipt.hostEvidence ?? null,
137
167
  activatedAt: (options.now ?? (() => new Date().toISOString()))()
138
168
  }) };
139
169
  }
@@ -154,7 +184,13 @@ function completeDelegationStage(
154
184
 
155
185
  function sealDelegation(
156
186
  receipt: DelegationReceipt,
157
- event: Readonly<{ childId: string; exitCode: number; afterFingerprint: string; changedPaths: readonly string[] }>,
187
+ event: Readonly<{
188
+ childId: string;
189
+ exitCode: number;
190
+ afterFingerprint: string;
191
+ changedPaths: readonly string[];
192
+ hostEvidence?: Readonly<{ stopRevision: number; consumer: string; consumedAt: string }>;
193
+ }>,
158
194
  options: { now?: () => string } = {}
159
195
  ): ReceiptResult {
160
196
  if (receipt.status !== 'stage-completed') return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected stage-completed`);
@@ -169,11 +205,24 @@ function sealDelegation(
169
205
  const disallowed = event.changedPaths.find((entry) => !allowed.has(entry));
170
206
  if (disallowed) return fail('DELEGATION_REVIEWER_WRITE_FORBIDDEN', `reviewer changed forbidden path '${disallowed}'`);
171
207
  }
208
+ if (event.hostEvidence) {
209
+ if (
210
+ !receipt.hostEvidence
211
+ || !Number.isSafeInteger(event.hostEvidence.stopRevision)
212
+ || event.hostEvidence.stopRevision <= receipt.hostEvidence.startRevision
213
+ || event.hostEvidence.consumer !== receipt.id
214
+ || !event.hostEvidence.consumedAt.trim()
215
+ ) return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex stop evidence reference is invalid');
216
+ }
172
217
  return { ok: true, receipt: Object.freeze({
173
218
  ...receipt,
174
219
  status: 'sealed',
175
220
  afterFingerprint: event.afterFingerprint,
176
221
  changedPaths: Object.freeze([...event.changedPaths]),
222
+ hostEvidence: event.hostEvidence && receipt.hostEvidence ? Object.freeze({
223
+ ...receipt.hostEvidence,
224
+ ...event.hostEvidence
225
+ }) : receipt.hostEvidence ?? null,
177
226
  sealedAt: (options.now ?? (() => new Date().toISOString()))()
178
227
  }) };
179
228
  }
@@ -196,4 +245,4 @@ export {
196
245
  prepareDelegation,
197
246
  sealDelegation
198
247
  };
199
- export type { DelegationReceipt, DelegationRole, DelegationStage, DelegationStatus, ReceiptResult };
248
+ export type { DelegationHostEvidence, DelegationReceipt, DelegationRole, DelegationStage, DelegationStatus, ReceiptResult };
@@ -35,7 +35,8 @@ const eventCatalog = [
35
35
  'review-plan.started', 'review-plan.completed',
36
36
  'code.started', 'code.completed',
37
37
  'review-code.started', 'review-code.completed',
38
- 'manual-validation.started', 'manual-validation.completed'
38
+ 'manual-validation.started', 'manual-validation.completed',
39
+ 'validation-run.started', 'validation-run.completed'
39
40
  ] as const;
40
41
  type TaskEventName = (typeof eventCatalog)[number];
41
42
  type Verdict = 'approved' | 'changes-requested' | 'rejected';
@@ -83,7 +84,9 @@ const SCHEMAS: Record<TaskEventName, { required?: string[]; optional?: string[]
83
84
  'review-code.started': { optional: ['round'] },
84
85
  'review-code.completed': { required: ['artifact', 'verdict', 'blockers', 'major', 'minor', 'manualValidation'], optional: ['round', 'orchestrated'] },
85
86
  'manual-validation.started': { optional: ['round'] },
86
- 'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] }
87
+ 'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] },
88
+ 'validation-run.started': { optional: ['round'] },
89
+ 'validation-run.completed': { required: ['artifact'], optional: ['round'] }
87
90
  };
88
91
 
89
92
  function validateTaskEventRequest(request: TaskEventRequest): TaskEventError | null {
@@ -121,7 +124,8 @@ const FAMILY = {
121
124
  'review-plan': { artifact: 'review-plan', started: ['technical-design', 'technical-design-review'], completed: ['technical-design', 'technical-design-review'], target: 'technical-design-review', label: 'Review Plan' },
122
125
  code: { artifact: 'code', started: ['technical-design-review', 'code-review'], completed: ['technical-design-review', 'code-review'], target: 'code', label: 'Code Task' },
123
126
  'review-code': { artifact: 'review-code', started: ['code', 'code-review', 'commit'], completed: ['code', 'code-review', 'commit'], target: 'code-review', label: 'Review Code' },
124
- 'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' }
127
+ 'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' },
128
+ 'validation-run': { artifact: 'validation-run', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Run Manual Validation' }
125
129
  } as const;
126
130
  type EventFamily = keyof typeof FAMILY;
127
131
 
@@ -217,6 +221,7 @@ function identity(request: TaskEventRequest) {
217
221
  else if (family === 'plan') note = `Plan completed, awaiting human review → ${request.artifact}`;
218
222
  else if (family === 'code' && request.fixFor) note = `Fixed ${request.blockers} blockers, ${request.major} major, ${request.minor} minor issues${request.manualValidation ? `, skipped ${request.manualValidation} manual-validation` : ''} → ${request.artifact}`;
219
223
  else if (family === 'code') note = `Code implemented, ${request.filesModified} files modified, ${request.testsPassed} tests passed → ${request.artifact}`;
224
+ else if (family === 'validation-run') note = `Validation evidence recorded → ${request.artifact}`;
220
225
  else {
221
226
  const verdict = request.verdict === 'approved' ? 'Approved' : request.verdict === 'changes-requested' ? 'Changes Requested' : 'Rejected';
222
227
  note = `Verdict: ${verdict}, blockers: ${request.blockers}, major: ${request.major}, minor: ${request.minor}, Manual-validation: ${request.manualValidation} → ${request.artifact}`;
@@ -342,7 +347,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
342
347
  );
343
348
  if (findingCountError) return failed(normalized, findingCountError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
344
349
  let orchestrationCompletion: OrchestrationStageCompletion | null = null;
345
- if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation') {
350
+ if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation' && eventIdentity.family !== 'validation-run') {
346
351
  const orchestrationStage = eventIdentity.family === 'analyze' ? 'analysis' : eventIdentity.family;
347
352
  const execution = validateLifecycleExecution(normalized.taskRef, {
348
353
  mode: normalized.orchestrated ? 'orchestrated' : 'standalone',
@@ -16,7 +16,8 @@ const COMPLETION_BACKFILL_FAMILIES = [
16
16
  'review-plan',
17
17
  'code',
18
18
  'review-code',
19
- 'manual-validation'
19
+ 'manual-validation',
20
+ 'validation-run'
20
21
  ] as const satisfies readonly ArtifactFamily[];
21
22
 
22
23
  type CompletionArtifactResult = {
@@ -131,13 +131,9 @@ function finalizeImplementationInput(
131
131
  }
132
132
 
133
133
  function selectPendingImplementationInput(
134
- rows: readonly ImplementationInput[], reviewCompletedAt: string
134
+ rows: readonly ImplementationInput[]
135
135
  ): ImplementationInput | null {
136
- const reviewEpoch = Date.parse(reviewCompletedAt.replace(' ', 'T'));
137
- if (!Number.isFinite(reviewEpoch)) throw new Error('latest review completion time is invalid');
138
136
  const pending = rows.filter((row) => row.needsImplementation && row.status === 'pending');
139
- const stale = pending.find((row) => Date.parse(row.decidedAt.replace(' ', 'T')) <= reviewEpoch);
140
- if (stale) throw new Error(`implementation input ${stale.id} is not later than the latest approved review`);
141
137
  return [...pending].sort((left, right) => {
142
138
  const time = Date.parse(left.decidedAt.replace(' ', 'T')) - Date.parse(right.decidedAt.replace(' ', 'T'));
143
139
  return time || Number.parseInt(left.id.slice(3), 10) - Number.parseInt(right.id.slice(3), 10);
package/lib/task/index.ts CHANGED
@@ -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
 
@@ -93,6 +95,12 @@ export async function runTask(args: string[]): Promise<void> {
93
95
  status(rest);
94
96
  break;
95
97
  }
98
+ case 'validate': {
99
+ const { validate } = await import('./commands/validate.ts');
100
+ const exitCode = await validate(rest);
101
+ if (exitCode !== 0) process.exitCode = exitCode;
102
+ break;
103
+ }
96
104
  default:
97
105
  process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
98
106
  process.stdout.write(`${USAGE}\n`);