@fitlab-ai/agent-infra 0.9.4 → 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 (130) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +13 -3
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +492 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +175 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +255 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +3 -3
  9. package/dist/lib/git/workflow.js +0 -1
  10. package/dist/lib/git/worktree-identity.js +64 -0
  11. package/dist/lib/internal/codex-lifecycle.js +300 -0
  12. package/dist/lib/internal/release-workflow.js +108 -11
  13. package/dist/lib/internal/sandbox-control.js +43 -6
  14. package/dist/lib/internal/task-create.js +56 -0
  15. package/dist/lib/internal/task-orchestration.js +71 -31
  16. package/dist/lib/platform/issue-comments.js +2 -1
  17. package/dist/lib/release/workflow.js +29 -21
  18. package/dist/lib/sandbox/commands/create.js +16 -16
  19. package/dist/lib/sandbox/commands/prune.js +40 -16
  20. package/dist/lib/sandbox/commands/rm.js +318 -89
  21. package/dist/lib/sandbox/control/client.js +152 -22
  22. package/dist/lib/sandbox/control/executor.js +136 -0
  23. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  24. package/dist/lib/sandbox/control/protocol.js +73 -18
  25. package/dist/lib/sandbox/control/server.js +235 -80
  26. package/dist/lib/sandbox/control/state.js +181 -0
  27. package/dist/lib/sandbox/index.js +2 -2
  28. package/dist/lib/sandbox/managed-fs.js +14 -2
  29. package/dist/lib/sandbox/mounts.js +35 -24
  30. package/dist/lib/sandbox/recovery.js +262 -45
  31. package/dist/lib/sandbox/workspace-view.js +80 -11
  32. package/dist/lib/sandbox/worktree-safety.js +196 -0
  33. package/dist/lib/server/process-control.js +6 -0
  34. package/dist/lib/task/activity-log.js +32 -5
  35. package/dist/lib/task/artifact-lifecycle.js +5 -4
  36. package/dist/lib/task/codex-orchestration.js +161 -0
  37. package/dist/lib/task/commands/status.js +3 -0
  38. package/dist/lib/task/commands/validate.js +289 -0
  39. package/dist/lib/task/commit-finalization.js +50 -12
  40. package/dist/lib/task/create-service.js +137 -0
  41. package/dist/lib/task/create.js +333 -0
  42. package/dist/lib/task/delegation-receipts.js +25 -0
  43. package/dist/lib/task/events.js +10 -4
  44. package/dist/lib/task/finalization-artifacts.js +2 -1
  45. package/dist/lib/task/implementation-inputs.js +1 -7
  46. package/dist/lib/task/index.js +9 -0
  47. package/dist/lib/task/orchestration.js +283 -18
  48. package/dist/lib/task/review-fingerprint.js +4 -0
  49. package/dist/lib/task/verification-engine.js +38 -6
  50. package/dist/lib/task/verification.js +1 -0
  51. package/dist/lib/task/workspace-snapshot.js +78 -3
  52. package/lib/agent-clients/adapter.ts +4 -4
  53. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +569 -0
  54. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +323 -0
  55. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +298 -0
  56. package/lib/agent-clients/adapters/codex.ts +3 -3
  57. package/lib/git/workflow.ts +0 -1
  58. package/lib/git/worktree-identity.ts +86 -0
  59. package/lib/internal/codex-lifecycle.ts +308 -0
  60. package/lib/internal/release-workflow.ts +107 -11
  61. package/lib/internal/sandbox-control.ts +38 -6
  62. package/lib/internal/task-create.ts +61 -0
  63. package/lib/internal/task-orchestration.ts +57 -31
  64. package/lib/platform/issue-comments.ts +2 -1
  65. package/lib/release/workflow.ts +69 -20
  66. package/lib/sandbox/commands/create.ts +18 -21
  67. package/lib/sandbox/commands/prune.ts +53 -15
  68. package/lib/sandbox/commands/rm.ts +368 -103
  69. package/lib/sandbox/control/client.ts +173 -25
  70. package/lib/sandbox/control/executor.ts +163 -0
  71. package/lib/sandbox/control/lifecycle.ts +342 -0
  72. package/lib/sandbox/control/protocol.ts +110 -48
  73. package/lib/sandbox/control/server.ts +252 -85
  74. package/lib/sandbox/control/state.ts +196 -0
  75. package/lib/sandbox/index.ts +2 -2
  76. package/lib/sandbox/managed-fs.ts +17 -2
  77. package/lib/sandbox/mounts.ts +31 -13
  78. package/lib/sandbox/recovery.ts +301 -60
  79. package/lib/sandbox/workspace-view.ts +89 -12
  80. package/lib/sandbox/worktree-safety.ts +234 -0
  81. package/lib/server/process-control.ts +11 -0
  82. package/lib/task/activity-log.ts +46 -7
  83. package/lib/task/artifact-lifecycle.ts +5 -4
  84. package/lib/task/codex-orchestration.ts +233 -0
  85. package/lib/task/commands/status.ts +2 -0
  86. package/lib/task/commands/validate.ts +282 -0
  87. package/lib/task/commit-finalization.ts +75 -12
  88. package/lib/task/create-service.ts +171 -0
  89. package/lib/task/create.ts +372 -0
  90. package/lib/task/delegation-receipts.ts +52 -3
  91. package/lib/task/events.ts +9 -4
  92. package/lib/task/finalization-artifacts.ts +2 -1
  93. package/lib/task/implementation-inputs.ts +1 -5
  94. package/lib/task/index.ts +8 -0
  95. package/lib/task/orchestration.ts +381 -22
  96. package/lib/task/review-fingerprint.ts +9 -0
  97. package/lib/task/verification-engine.ts +42 -8
  98. package/lib/task/verification.ts +2 -1
  99. package/lib/task/workspace-snapshot.ts +98 -4
  100. package/package.json +7 -7
  101. package/templates/.agents/hooks/lifecycle-delegation.js +88 -8
  102. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  103. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  104. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  105. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  106. package/templates/.agents/rules/task-management.en.md +1 -0
  107. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  108. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  110. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  111. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  112. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  113. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  114. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  115. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  116. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  117. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  118. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  119. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  120. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  121. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  122. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  123. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  124. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  125. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  126. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  127. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  128. package/templates/.codex/hooks.json +50 -0
  129. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  130. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -0,0 +1,372 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { loadShortIdByTaskId, mutateShortIdRegistry } from './short-id.ts';
6
+ import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
7
+
8
+ const AGENTS = ['claude', 'codex', 'antigravity', 'opencode', 'cursor'] as const;
9
+ const TYPES = ['feature', 'bugfix', 'refactor', 'docs', 'chore'] as const;
10
+ const PRIORITIES = ['Urgent', 'High', 'Medium', 'Low'] as const;
11
+ const EFFORTS = ['High', 'Medium', 'Low'] as const;
12
+ const TASK_INPUT_KEYS = [
13
+ 'sources', 'facts', 'constraints', 'decisions', 'alternatives',
14
+ 'acceptanceCriteria', 'openQuestions'
15
+ ] as const;
16
+
17
+ type TaskAgent = typeof AGENTS[number];
18
+ type TaskType = typeof TYPES[number];
19
+ type TaskPriority = typeof PRIORITIES[number];
20
+ type TaskEffort = typeof EFFORTS[number];
21
+
22
+ type TaskCreateCandidateV1 = Readonly<{
23
+ version: 1;
24
+ idempotencyKey: string;
25
+ agent: TaskAgent;
26
+ title: string;
27
+ type: TaskType;
28
+ branchSlug: string;
29
+ priority: TaskPriority;
30
+ effort: TaskEffort;
31
+ description: string;
32
+ taskInput: Readonly<Record<typeof TASK_INPUT_KEYS[number], readonly string[]>>;
33
+ }>;
34
+
35
+ type LocalTaskCreateResult = Readonly<{
36
+ status: 'applied' | 'no-op';
37
+ changed: boolean;
38
+ task: { id: string; shortId: string };
39
+ }>;
40
+
41
+ type LocalTaskCreateOptions = Readonly<{
42
+ repoRoot: string;
43
+ now?: () => Date;
44
+ agentInfraVersion?: string;
45
+ }>;
46
+
47
+ function payloadError(message: string): never {
48
+ throw new Error(`TASK_CREATE_PAYLOAD_INVALID: ${message}`);
49
+ }
50
+
51
+ function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
52
+ const actual = Object.keys(value).sort();
53
+ const wanted = [...expected].sort();
54
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
55
+ payloadError(`${label} has unknown or missing fields`);
56
+ }
57
+ }
58
+
59
+ function cleanString(value: unknown, label: string, maxBytes: number, oneLine = false): string {
60
+ if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > maxBytes) {
61
+ payloadError(`${label} must be a non-empty string within ${maxBytes} bytes`);
62
+ }
63
+ if (/\0|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value) || (oneLine && /[\r\n]/u.test(value))) {
64
+ payloadError(`${label} contains unsupported control characters`);
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function enumValue<T extends string>(value: unknown, allowed: readonly T[], label: string): T {
70
+ if (typeof value !== 'string' || !allowed.includes(value as T)) payloadError(`${label} is invalid`);
71
+ return value as T;
72
+ }
73
+
74
+ function validateTaskCreateCandidate(value: unknown): TaskCreateCandidateV1 {
75
+ if (!value || typeof value !== 'object' || Array.isArray(value)) payloadError('candidate must be an object');
76
+ const candidate = value as Record<string, unknown>;
77
+ exactKeys(candidate, [
78
+ 'version', 'idempotencyKey', 'agent', 'title', 'type', 'branchSlug',
79
+ 'priority', 'effort', 'description', 'taskInput'
80
+ ], 'candidate');
81
+ if (candidate.version !== 1) payloadError('version must be 1');
82
+ const idempotencyKey = cleanString(candidate.idempotencyKey, 'idempotencyKey', 64, true);
83
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(idempotencyKey)) {
84
+ payloadError('idempotencyKey must be a UUID v4');
85
+ }
86
+ const title = cleanString(candidate.title, 'title', 400, true);
87
+ if ([...title].length > 100) payloadError('title exceeds 100 Unicode code points');
88
+ const branchSlug = cleanString(candidate.branchSlug, 'branchSlug', 64, true);
89
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(branchSlug) || branchSlug.length < 3) {
90
+ payloadError('branchSlug must be lowercase ASCII kebab-case with length 3-64');
91
+ }
92
+ const description = cleanString(candidate.description, 'description', 8 * 1024);
93
+ if (!candidate.taskInput || typeof candidate.taskInput !== 'object' || Array.isArray(candidate.taskInput)) {
94
+ payloadError('taskInput must be an object');
95
+ }
96
+ const rawInput = candidate.taskInput as Record<string, unknown>;
97
+ exactKeys(rawInput, TASK_INPUT_KEYS, 'taskInput');
98
+ const taskInput = Object.fromEntries(TASK_INPUT_KEYS.map((key) => {
99
+ const list = rawInput[key];
100
+ if (!Array.isArray(list) || list.length > 50) payloadError(`taskInput.${key} must contain at most 50 items`);
101
+ return [key, list.map((item, index) => cleanString(item, `taskInput.${key}[${index}]`, 2 * 1024, true))];
102
+ })) as unknown as TaskCreateCandidateV1['taskInput'];
103
+ return {
104
+ version: 1,
105
+ idempotencyKey,
106
+ agent: enumValue(candidate.agent, AGENTS, 'agent'),
107
+ title,
108
+ type: enumValue(candidate.type, TYPES, 'type'),
109
+ branchSlug,
110
+ priority: enumValue(candidate.priority, PRIORITIES, 'priority'),
111
+ effort: enumValue(candidate.effort, EFFORTS, 'effort'),
112
+ description,
113
+ taskInput
114
+ };
115
+ }
116
+
117
+ function canonicalTaskCreateCandidate(candidate: TaskCreateCandidateV1): string {
118
+ const valid = validateTaskCreateCandidate(candidate);
119
+ return JSON.stringify({
120
+ version: valid.version,
121
+ idempotencyKey: valid.idempotencyKey,
122
+ agent: valid.agent,
123
+ title: valid.title,
124
+ type: valid.type,
125
+ branchSlug: valid.branchSlug,
126
+ priority: valid.priority,
127
+ effort: valid.effort,
128
+ description: valid.description,
129
+ taskInput: Object.fromEntries(TASK_INPUT_KEYS.map((key) => [key, valid.taskInput[key]]))
130
+ });
131
+ }
132
+
133
+ function sha256(value: string): string {
134
+ return createHash('sha256').update(value).digest('hex');
135
+ }
136
+
137
+ function formatTimestamp(date: Date): string {
138
+ const pad = (value: number) => String(value).padStart(2, '0');
139
+ const offsetMinutes = -date.getTimezoneOffset();
140
+ const sign = offsetMinutes >= 0 ? '+' : '-';
141
+ const offset = Math.abs(offsetMinutes);
142
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(Math.floor(offset / 60))}:${pad(offset % 60)}`;
143
+ }
144
+
145
+ function taskIdFor(date: Date): string {
146
+ const pad = (value: number) => String(value).padStart(2, '0');
147
+ return `TASK-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
148
+ }
149
+
150
+ function yamlString(value: string): string {
151
+ return JSON.stringify(value);
152
+ }
153
+
154
+ function markdownBlock(value: string): string {
155
+ return value.split(/\r?\n/).map((line) => /^(?:#{1,6}\s|```)/.test(line) ? `\\${line}` : line).join('\n');
156
+ }
157
+
158
+ function renderList(items: readonly string[]): string {
159
+ return items.map((item) => `- ${item}`).join('\n');
160
+ }
161
+
162
+ function replaceEmptySubsection(content: string, heading: string, items: readonly string[]): string {
163
+ if (items.length === 0) return content;
164
+ return content.replace(`${heading}\n`, `${heading}\n\n${renderList(items)}\n`);
165
+ }
166
+
167
+ function renderTask(params: Readonly<{
168
+ template: string;
169
+ candidate: TaskCreateCandidateV1;
170
+ taskId: string;
171
+ project: string;
172
+ timestamp: string;
173
+ agentInfraVersion: string;
174
+ keyDigest: string;
175
+ candidateDigest: string;
176
+ }>): string {
177
+ const { candidate } = params;
178
+ const workflow = candidate.type === 'bugfix' ? 'bug-fix' : candidate.type === 'refactor' ? 'refactoring' : 'feature-development';
179
+ const branch = `${params.project}-${candidate.type}-${candidate.branchSlug}`;
180
+ const frontmatter = [
181
+ '---',
182
+ `id: ${params.taskId}`,
183
+ `type: ${candidate.type}`,
184
+ `branch: ${branch}`,
185
+ `workflow: ${workflow}`,
186
+ 'status: active',
187
+ `created_at: ${params.timestamp}`,
188
+ `updated_at: ${params.timestamp}`,
189
+ `agent_infra_version: ${params.agentInfraVersion}`,
190
+ `priority: ${candidate.priority}`,
191
+ `effort: ${candidate.effort}`,
192
+ 'start_date:',
193
+ 'target_date:',
194
+ 'current_step: requirement-analysis',
195
+ `assigned_to: ${candidate.agent}`,
196
+ 'pr_status: pending',
197
+ `task_create_key_digest: ${params.keyDigest}`,
198
+ `task_create_candidate_digest: ${params.candidateDigest}`,
199
+ '---'
200
+ ].join('\n');
201
+ let content = params.template.replace(/^---\n[\s\S]*?\n---/, frontmatter);
202
+ content = content.replace('# 任务:[标题]', `# 任务:${candidate.title}`);
203
+ content = content.replace('[清晰简洁地描述任务。]', markdownBlock(candidate.description));
204
+ const sections: Array<[string, keyof TaskCreateCandidateV1['taskInput']]> = [
205
+ ['### 来源', 'sources'], ['### 已确认事实与证据', 'facts'], ['### 约束', 'constraints'],
206
+ ['### 已确认决策', 'decisions'], ['### 候选与否决方案', 'alternatives'],
207
+ ['### 验收标准', 'acceptanceCriteria'], ['### 未决事项', 'openQuestions']
208
+ ];
209
+ for (const [heading, key] of sections) content = replaceEmptySubsection(content, heading, candidate.taskInput[key]);
210
+ content = content.replace('- **关联 Issue**:#XXX', '- **关联 Issue**:N/A');
211
+ content = content.replace('- **关联 PR**:#XXX', '- **关联 PR**:N/A');
212
+ content = content.replace('- **分支**:`feature/xxx`', `- **分支**:\`${branch}\``);
213
+ const log = [
214
+ `- ${params.timestamp} — **Create Task [started]** by ${candidate.agent} — started`,
215
+ `- ${params.timestamp} — **Create Task** by ${candidate.agent} — Task created from structured candidate`
216
+ ].join('\n');
217
+ content = content.replace('\n## 完成检查清单', `\n${log}\n\n## 完成检查清单`);
218
+ return `${content.trimEnd()}\n`;
219
+ }
220
+
221
+ function readProject(repoRoot: string): string {
222
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { project?: unknown };
223
+ if (typeof config.project !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.project)) {
224
+ throw new Error('TASK_CREATE_CONFIG_INVALID: project is missing or invalid');
225
+ }
226
+ return config.project;
227
+ }
228
+
229
+ function assertRealDirectory(directory: string, code: string): void {
230
+ const stat = fs.lstatSync(directory);
231
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(code);
232
+ }
233
+
234
+ function withCreateLock<T>(repoRoot: string, workspaceRoot: string, operation: () => T): T {
235
+ const lockRoot = path.join(workspaceRoot, '.task-create.lock');
236
+ fs.mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
237
+ assertRealDirectory(lockRoot, 'TASK_CREATE_LOCK_FAILED');
238
+ let callbackError: unknown;
239
+ try {
240
+ return withTaskExecutionLock(repoRoot, 'task-create', 'task-create', () => {
241
+ try { return operation(); }
242
+ catch (error) { callbackError = error; throw error; }
243
+ }, { lockRoot });
244
+ } catch (error) {
245
+ if (callbackError !== undefined) throw callbackError;
246
+ if (error instanceof TaskExecutionLockError && error.code === 'ORCHESTRATION_LOCK_BUSY') {
247
+ throw new Error('TASK_CREATE_LOCK_TIMEOUT');
248
+ }
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ throw new Error(`TASK_CREATE_LOCK_FAILED: ${message}`);
251
+ }
252
+ }
253
+
254
+ function writeReceipt(receiptPath: string, value: unknown): void {
255
+ const temporary = `${receiptPath}.tmp.${process.pid}.${randomUUID()}`;
256
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
257
+ try { fs.renameSync(temporary, receiptPath); }
258
+ catch (error) {
259
+ try { fs.unlinkSync(temporary); } catch { /* best effort */ }
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ function recoverPublishedTask(activeRoot: string, repoRoot: string, keyDigest: string, candidateDigest: string): { taskId: string; shortId: string } | null {
265
+ for (const name of fs.readdirSync(activeRoot)) {
266
+ if (!/^TASK-\d{8}-\d{6}$/.test(name)) continue;
267
+ const taskDir = path.join(activeRoot, name);
268
+ const stat = fs.lstatSync(taskDir);
269
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
270
+ const taskMd = path.join(taskDir, 'task.md');
271
+ if (!fs.existsSync(taskMd)) continue;
272
+ const taskStat = fs.lstatSync(taskMd);
273
+ if (!taskStat.isFile() || taskStat.isSymbolicLink()) continue;
274
+ const content = fs.readFileSync(taskMd, 'utf8');
275
+ if (!content.includes(`task_create_key_digest: ${keyDigest}`)) continue;
276
+ if (!content.includes(`task_create_candidate_digest: ${candidateDigest}`)) {
277
+ throw new Error('TASK_CREATE_IDEMPOTENCY_CONFLICT');
278
+ }
279
+ const shortId = loadShortIdByTaskId(repoRoot).get(name)
280
+ ?? mutateShortIdRegistry(repoRoot, name, 'alloc').shortId;
281
+ if (!shortId) throw new Error('TASK_CREATE_RECEIPT_INVALID');
282
+ return { taskId: name, shortId };
283
+ }
284
+ return null;
285
+ }
286
+
287
+ function createLocalTask(value: unknown, options: LocalTaskCreateOptions): LocalTaskCreateResult {
288
+ const candidate = validateTaskCreateCandidate(value);
289
+ const repoRoot = fs.realpathSync.native(options.repoRoot);
290
+ const workspaceRoot = path.join(repoRoot, '.agents', 'workspace');
291
+ const activeRoot = path.join(workspaceRoot, 'active');
292
+ assertRealDirectory(workspaceRoot, 'TASK_CREATE_WORKSPACE_INVALID');
293
+ assertRealDirectory(activeRoot, 'TASK_CREATE_WORKSPACE_INVALID');
294
+ const project = readProject(repoRoot);
295
+ const canonical = canonicalTaskCreateCandidate(candidate);
296
+ const candidateDigest = sha256(canonical);
297
+ const keyDigest = sha256(candidate.idempotencyKey);
298
+ const receipts = path.join(workspaceRoot, '.task-create');
299
+
300
+ return withCreateLock(repoRoot, workspaceRoot, () => {
301
+ if (fs.existsSync(receipts)) assertRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
302
+ else fs.mkdirSync(receipts, { mode: 0o700 });
303
+ const receiptPath = path.join(receipts, `${keyDigest}.json`);
304
+ if (fs.existsSync(receiptPath)) {
305
+ const stat = fs.lstatSync(receiptPath);
306
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('TASK_CREATE_RECEIPT_INVALID');
307
+ const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')) as { candidateDigest?: string; taskId?: string; shortId?: string };
308
+ if (receipt.candidateDigest !== candidateDigest) throw new Error('TASK_CREATE_IDEMPOTENCY_CONFLICT');
309
+ if (!receipt.taskId || !receipt.shortId || !fs.existsSync(path.join(activeRoot, receipt.taskId, 'task.md'))) {
310
+ throw new Error('TASK_CREATE_RECEIPT_INVALID');
311
+ }
312
+ return { status: 'no-op', changed: false, task: { id: receipt.taskId, shortId: receipt.shortId } };
313
+ }
314
+
315
+ const recovered = recoverPublishedTask(activeRoot, repoRoot, keyDigest, candidateDigest);
316
+ if (recovered) {
317
+ writeReceipt(receiptPath, {
318
+ version: 1, candidateDigest, taskId: recovered.taskId, shortId: recovered.shortId, status: 'recovered'
319
+ });
320
+ return { status: 'no-op', changed: false, task: { id: recovered.taskId, shortId: recovered.shortId } };
321
+ }
322
+
323
+ const base = options.now?.() ?? new Date();
324
+ let taskDate = new Date(base.getTime());
325
+ let taskId = taskIdFor(taskDate);
326
+ while (fs.existsSync(path.join(activeRoot, taskId))) {
327
+ taskDate = new Date(taskDate.getTime() + 1_000);
328
+ taskId = taskIdFor(taskDate);
329
+ }
330
+ const timestamp = formatTimestamp(base);
331
+ const templatePath = path.join(repoRoot, '.agents', 'templates', 'task.md');
332
+ const templateStat = fs.lstatSync(templatePath);
333
+ if (!templateStat.isFile() || templateStat.isSymbolicLink()) throw new Error('TASK_CREATE_TEMPLATE_INVALID');
334
+ const rendered = renderTask({
335
+ template: fs.readFileSync(templatePath, 'utf8'), candidate, taskId, project, timestamp,
336
+ agentInfraVersion: options.agentInfraVersion ?? 'unknown', keyDigest, candidateDigest
337
+ });
338
+ const temporary = path.join(workspaceRoot, `.task-create.tmp.${process.pid}.${randomUUID()}`);
339
+ fs.mkdirSync(temporary, { mode: 0o700 });
340
+ fs.writeFileSync(path.join(temporary, 'task.md'), rendered, { flag: 'wx', mode: 0o600 });
341
+ const destination = path.join(activeRoot, taskId);
342
+ fs.renameSync(temporary, destination);
343
+ let shortId: string;
344
+ try {
345
+ const allocated = mutateShortIdRegistry(repoRoot, taskId, 'alloc');
346
+ if (!allocated.shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
347
+ shortId = allocated.shortId;
348
+ } catch (error) {
349
+ fs.rmSync(destination, { recursive: true, force: true });
350
+ throw error;
351
+ }
352
+ try {
353
+ writeReceipt(receiptPath, { version: 1, candidateDigest, taskId, shortId, status: 'created' });
354
+ } catch (error) {
355
+ mutateShortIdRegistry(repoRoot, taskId, 'release');
356
+ fs.rmSync(destination, { recursive: true, force: true });
357
+ throw error;
358
+ }
359
+ return { status: 'applied', changed: true, task: { id: taskId, shortId } };
360
+ });
361
+ }
362
+
363
+ export {
364
+ canonicalTaskCreateCandidate,
365
+ createLocalTask,
366
+ validateTaskCreateCandidate
367
+ };
368
+ export type {
369
+ LocalTaskCreateOptions,
370
+ LocalTaskCreateResult,
371
+ TaskCreateCandidateV1
372
+ };
@@ -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`);