vibe-coding-master 0.2.12 → 0.3.0

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.
@@ -0,0 +1,765 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { CODEX_REVIEW_GATES } from "../../shared/types/codex-review.js";
4
+ import { VcmError } from "../errors.js";
5
+ import { resolveRepoPath } from "../adapters/filesystem.js";
6
+ import { submitTerminalInput } from "../runtime/terminal-submit.js";
7
+ import { getTaskRuntimeRepoRoot } from "./task-service.js";
8
+ const CODEX_DIR = ".ai/codex";
9
+ const CODEX_CONFIG_PATH = ".ai/codex/config.toml";
10
+ const CODEX_REVIEW_DIR = ".ai/vcm/codex-reviews";
11
+ const REQUESTS_DIR = ".ai/vcm/codex-reviews/requests";
12
+ const CODEX_REVIEW_VERSION = 1;
13
+ const activeRuns = new Set();
14
+ const SOURCE_ARTIFACTS = {
15
+ "architecture-plan": [
16
+ ".ai/vcm/handoffs/architecture-plan.md"
17
+ ],
18
+ "validation-adequacy": [
19
+ ".ai/vcm/handoffs/architecture-plan.md",
20
+ ".ai/vcm/handoffs/review-report.md"
21
+ ],
22
+ "final-diff": [
23
+ ".ai/vcm/handoffs/architecture-plan.md",
24
+ ".ai/vcm/handoffs/review-report.md",
25
+ ".ai/vcm/handoffs/docs-sync-report.md",
26
+ ".ai/vcm/handoffs/final-acceptance.md"
27
+ ]
28
+ };
29
+ const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]);
30
+ export function createCodexReviewService(deps) {
31
+ const now = deps.now ?? (() => new Date().toISOString());
32
+ async function getContext(repoRoot, taskSlug) {
33
+ const projectConfig = await deps.projectService.loadConfig(repoRoot);
34
+ const task = await deps.taskService.loadTask(repoRoot, taskSlug);
35
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
36
+ return {
37
+ repoRoot,
38
+ taskSlug,
39
+ taskRepoRoot,
40
+ stateRoot: projectConfig.stateRoot,
41
+ config: await loadRuntimeConfig(deps.fs, taskRepoRoot)
42
+ };
43
+ }
44
+ async function requestReviewGateInternal(repoRoot, taskSlug, gate, options = {}) {
45
+ const context = await getContext(repoRoot, taskSlug);
46
+ let index = await loadIndex(deps.fs, context, now());
47
+ const record = index.gates[gate];
48
+ if (!context.config.enabled) {
49
+ index = applyGateState(index, gate, {
50
+ status: "disabled",
51
+ required: false,
52
+ decision: undefined,
53
+ error: undefined
54
+ }, now());
55
+ await saveIndex(deps.fs, context.taskRepoRoot, index);
56
+ return { status: "disabled", gate, record: index.gates[gate], message: "Codex review is disabled." };
57
+ }
58
+ if (!record.required) {
59
+ index = applyGateState(index, gate, {
60
+ status: "not_required",
61
+ decision: undefined,
62
+ error: undefined
63
+ }, now());
64
+ await saveIndex(deps.fs, context.taskRepoRoot, index);
65
+ return { status: "not_required", gate, record: index.gates[gate], message: "This gate is not required." };
66
+ }
67
+ if (index.activeGate && index.activeGate !== gate) {
68
+ return {
69
+ status: "running",
70
+ gate,
71
+ record,
72
+ message: `Codex review is already running for ${index.activeGate}.`
73
+ };
74
+ }
75
+ if (record.status === "running" && !options.force) {
76
+ return { status: "running", gate, record, message: "Codex review is already running." };
77
+ }
78
+ const inputHash = await computeInputHash(deps, context.taskRepoRoot, gate);
79
+ if (!options.force
80
+ && record.status === "completed"
81
+ && record.decision === "approve"
82
+ && record.inputHash === inputHash) {
83
+ return {
84
+ status: "already_approved",
85
+ gate,
86
+ record,
87
+ message: "Codex review already approved the current inputs."
88
+ };
89
+ }
90
+ const timestamp = now();
91
+ const requestId = createRequestId(gate);
92
+ const requestPath = path.posix.join(REQUESTS_DIR, `${requestId}.json`);
93
+ const nextRecord = {
94
+ ...record,
95
+ status: "running",
96
+ decision: undefined,
97
+ error: undefined,
98
+ exceptionReason: undefined,
99
+ requestId,
100
+ requestPath,
101
+ inputHash,
102
+ requestedAt: timestamp,
103
+ startedAt: undefined,
104
+ completedAt: undefined,
105
+ callbackStatus: "not_sent",
106
+ callbackError: undefined,
107
+ updatedAt: timestamp
108
+ };
109
+ index = {
110
+ ...index,
111
+ activeGate: gate,
112
+ gates: {
113
+ ...index.gates,
114
+ [gate]: nextRecord
115
+ },
116
+ updatedAt: timestamp
117
+ };
118
+ await deps.fs.writeJsonAtomic(resolveRepoPath(context.taskRepoRoot, requestPath), {
119
+ version: CODEX_REVIEW_VERSION,
120
+ requestId,
121
+ gate,
122
+ status: "requested",
123
+ requestedAt: timestamp,
124
+ inputHash,
125
+ reportPath: nextRecord.reportPath,
126
+ promptPath: nextRecord.promptPath
127
+ });
128
+ await saveIndex(deps.fs, context.taskRepoRoot, index);
129
+ void runCodexReview(context, gate, requestId).catch(() => {
130
+ // runCodexReview records failures in the persisted gate state.
131
+ });
132
+ return {
133
+ status: "started",
134
+ gate,
135
+ record: nextRecord,
136
+ message: "Codex review started."
137
+ };
138
+ }
139
+ async function runCodexReview(context, gate, requestId) {
140
+ const runKey = `${context.taskRepoRoot}:${context.taskSlug}:${gate}`;
141
+ if (activeRuns.has(runKey)) {
142
+ return;
143
+ }
144
+ activeRuns.add(runKey);
145
+ try {
146
+ const timestamp = now();
147
+ await updateGateRecord(context, gate, {
148
+ status: "running",
149
+ startedAt: timestamp,
150
+ updatedAt: timestamp
151
+ });
152
+ await updateRequestStatus(deps.fs, context, requestId, "running", { startedAt: timestamp });
153
+ const codexDir = resolveRepoPath(context.taskRepoRoot, CODEX_DIR);
154
+ const reviewDir = resolveRepoPath(context.taskRepoRoot, CODEX_REVIEW_DIR);
155
+ const prompt = await buildCodexPrompt(deps.fs, context.taskRepoRoot, gate, requestId);
156
+ const outputMessagePath = path.join(reviewDir, "logs", `${requestId}.last-message.txt`);
157
+ await deps.fs.ensureDir(reviewDir);
158
+ await deps.fs.ensureDir(path.dirname(outputMessagePath));
159
+ if (!(await deps.fs.pathExists(codexDir))) {
160
+ throw new VcmError({
161
+ code: "CODEX_REVIEW_CONFIG_MISSING",
162
+ message: `${CODEX_DIR} does not exist.`,
163
+ statusCode: 409,
164
+ hint: "Apply the VCM harness before requesting Codex review gates."
165
+ });
166
+ }
167
+ const args = buildCodexExecArgs({
168
+ codexDir,
169
+ reviewDir,
170
+ prompt,
171
+ outputMessagePath,
172
+ config: context.config
173
+ });
174
+ const result = await deps.runner.run(context.config.command, args, {
175
+ cwd: context.taskRepoRoot,
176
+ env: {
177
+ ...process.env,
178
+ VCM_TASK_REPO_ROOT: context.taskRepoRoot,
179
+ VCM_TASK_SLUG: context.taskSlug,
180
+ VCM_CODEX_REVIEW_GATE: gate,
181
+ VCM_CODEX_REVIEW_REQUEST_ID: requestId
182
+ }
183
+ });
184
+ if (result.exitCode !== 0) {
185
+ throw new VcmError({
186
+ code: "CODEX_REVIEW_FAILED",
187
+ message: `Codex CLI exited with ${result.exitCode}.`,
188
+ statusCode: 500,
189
+ hint: trimCommandOutput(`${result.stderr}\n${result.stdout}`)
190
+ });
191
+ }
192
+ const parsed = await parseGateReport(deps.fs, context.taskRepoRoot, gate, requestId, now());
193
+ const completedAt = now();
194
+ await updateGateRecord(context, gate, {
195
+ status: "completed",
196
+ decision: parsed.decision,
197
+ summary: parsed.summary,
198
+ findings: parsed.findings,
199
+ error: undefined,
200
+ completedAt,
201
+ callbackStatus: "not_sent",
202
+ callbackError: undefined,
203
+ updatedAt: completedAt
204
+ }, { clearActiveGate: true });
205
+ await updateRequestStatus(deps.fs, context, requestId, "completed", {
206
+ completedAt,
207
+ decision: parsed.decision,
208
+ reportPath: parsed.reportPath
209
+ });
210
+ await callbackProjectManager(context, gate, "completed", parsed.decision, parsed.reportPath);
211
+ }
212
+ catch (error) {
213
+ const timestamp = now();
214
+ const message = errorMessage(error);
215
+ await updateGateRecord(context, gate, {
216
+ status: "failed",
217
+ error: message,
218
+ completedAt: timestamp,
219
+ callbackStatus: "not_sent",
220
+ callbackError: undefined,
221
+ updatedAt: timestamp
222
+ }, { clearActiveGate: true });
223
+ await updateRequestStatus(deps.fs, context, requestId, "failed", {
224
+ completedAt: timestamp,
225
+ error: message
226
+ });
227
+ await callbackProjectManager(context, gate, "failed", undefined, reportPathForGate(gate), message);
228
+ }
229
+ finally {
230
+ activeRuns.delete(runKey);
231
+ }
232
+ }
233
+ async function updateGateRecord(context, gate, patch, options = {}) {
234
+ const index = await loadIndex(deps.fs, context, now());
235
+ const next = applyGateState(index, gate, patch, now(), options.clearActiveGate);
236
+ await saveIndex(deps.fs, context.taskRepoRoot, next);
237
+ return next;
238
+ }
239
+ async function callbackProjectManager(context, gate, status, decision, reportPath, error) {
240
+ const session = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, "project-manager");
241
+ if (!session || session.status !== "running") {
242
+ await updateGateRecord(context, gate, {
243
+ callbackStatus: "skipped",
244
+ callbackError: "project-manager session is not running",
245
+ updatedAt: now()
246
+ });
247
+ return;
248
+ }
249
+ const prompt = renderProjectManagerCallback({
250
+ taskSlug: context.taskSlug,
251
+ gate,
252
+ status,
253
+ decision,
254
+ reportPath,
255
+ error
256
+ });
257
+ try {
258
+ await submitTerminalInput(deps.runtime, session.id, prompt);
259
+ await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, "project-manager");
260
+ await updateGateRecord(context, gate, {
261
+ callbackStatus: "sent",
262
+ callbackError: undefined,
263
+ updatedAt: now()
264
+ });
265
+ }
266
+ catch (caught) {
267
+ await updateGateRecord(context, gate, {
268
+ callbackStatus: "failed",
269
+ callbackError: errorMessage(caught),
270
+ updatedAt: now()
271
+ });
272
+ }
273
+ }
274
+ return {
275
+ async getState(repoRoot, taskSlug) {
276
+ const context = await getContext(repoRoot, taskSlug);
277
+ return loadIndex(deps.fs, context, now());
278
+ },
279
+ requestReviewGate(repoRoot, taskSlug, gate) {
280
+ return requestReviewGateInternal(repoRoot, taskSlug, gate);
281
+ },
282
+ retryReviewGate(repoRoot, taskSlug, gate) {
283
+ return requestReviewGateInternal(repoRoot, taskSlug, gate, { force: true });
284
+ },
285
+ async skipReviewGate(repoRoot, taskSlug, gate, input) {
286
+ const context = await getContext(repoRoot, taskSlug);
287
+ assertExceptionReason(input.reason);
288
+ const current = await loadIndex(deps.fs, context, now());
289
+ if (current.gates[gate].status === "running") {
290
+ throw new VcmError({
291
+ code: "CODEX_REVIEW_RUNNING",
292
+ message: "Cannot skip a running Codex review gate.",
293
+ statusCode: 409,
294
+ hint: "Wait for the Codex run to finish, then choose retry, skip, or override."
295
+ });
296
+ }
297
+ const index = await updateGateRecord(context, gate, {
298
+ status: "skipped",
299
+ decision: undefined,
300
+ exceptionReason: input.reason,
301
+ error: undefined,
302
+ completedAt: now(),
303
+ callbackStatus: "not_sent",
304
+ callbackError: undefined,
305
+ updatedAt: now()
306
+ }, { clearActiveGate: true });
307
+ await callbackProjectManager(context, gate, "skipped", undefined, index.gates[gate].reportPath);
308
+ return loadIndex(deps.fs, context, now());
309
+ },
310
+ async overrideReviewGate(repoRoot, taskSlug, gate, input) {
311
+ const context = await getContext(repoRoot, taskSlug);
312
+ assertExceptionReason(input.reason);
313
+ const current = await loadIndex(deps.fs, context, now());
314
+ if (current.gates[gate].status === "running") {
315
+ throw new VcmError({
316
+ code: "CODEX_REVIEW_RUNNING",
317
+ message: "Cannot override a running Codex review gate.",
318
+ statusCode: 409,
319
+ hint: "Wait for the Codex run to finish, then choose retry, skip, or override."
320
+ });
321
+ }
322
+ const index = await updateGateRecord(context, gate, {
323
+ status: "overridden",
324
+ decision: "approve",
325
+ exceptionReason: input.reason,
326
+ error: undefined,
327
+ completedAt: now(),
328
+ callbackStatus: "not_sent",
329
+ callbackError: undefined,
330
+ updatedAt: now()
331
+ }, { clearActiveGate: true });
332
+ await callbackProjectManager(context, gate, "overridden", "approve", index.gates[gate].reportPath);
333
+ return loadIndex(deps.fs, context, now());
334
+ },
335
+ async readReport(repoRoot, taskSlug, gate) {
336
+ const context = await getContext(repoRoot, taskSlug);
337
+ return parseGateReport(deps.fs, context.taskRepoRoot, gate, undefined, now());
338
+ }
339
+ };
340
+ }
341
+ export function isCodexReviewGate(value) {
342
+ return CODEX_REVIEW_GATES.includes(value);
343
+ }
344
+ async function loadRuntimeConfig(fs, taskRepoRoot) {
345
+ const configPath = resolveRepoPath(taskRepoRoot, CODEX_CONFIG_PATH);
346
+ if (!(await fs.pathExists(configPath))) {
347
+ return {
348
+ enabled: false,
349
+ requiredGates: [],
350
+ command: "codex"
351
+ };
352
+ }
353
+ const content = await fs.readText(configPath);
354
+ const section = extractTomlSection(content, "vcm.codex_review");
355
+ const enabled = parseTomlBoolean(section, "enabled") ?? false;
356
+ const parsedGates = parseTomlStringArray(section, "required_gates").filter(isCodexReviewGate);
357
+ return {
358
+ enabled,
359
+ requiredGates: parsedGates.length > 0 ? parsedGates : [...CODEX_REVIEW_GATES],
360
+ model: parseTomlString(content, "model"),
361
+ modelReasoningEffort: parseTomlString(content, "model_reasoning_effort"),
362
+ command: parseTomlString(section, "command") ?? "codex"
363
+ };
364
+ }
365
+ async function loadIndex(fs, context, timestamp) {
366
+ const indexPath = getIndexPath(context.taskRepoRoot);
367
+ const raw = await readJsonOrNull(fs, indexPath);
368
+ return normalizeIndex(raw, context.config, timestamp);
369
+ }
370
+ function normalizeIndex(raw, config, timestamp) {
371
+ const existingGates = raw?.gates && typeof raw.gates === "object"
372
+ ? raw.gates
373
+ : {};
374
+ const gates = {};
375
+ const requiredSet = new Set(config.enabled ? config.requiredGates : []);
376
+ for (const gate of CODEX_REVIEW_GATES) {
377
+ const existing = existingGates[gate];
378
+ const required = requiredSet.has(gate);
379
+ const fallbackStatus = config.enabled
380
+ ? required ? "pending" : "not_required"
381
+ : "disabled";
382
+ const existingStatus = normalizeGateStatus(existing?.status);
383
+ const status = config.enabled && required
384
+ ? (existingStatus === "disabled" || existingStatus === "not_required" ? "pending" : existingStatus ?? fallbackStatus)
385
+ : fallbackStatus;
386
+ gates[gate] = {
387
+ gate,
388
+ required,
389
+ status,
390
+ decision: normalizeDecision(existing?.decision),
391
+ reportPath: reportPathForGate(gate),
392
+ promptPath: promptPathForGate(gate),
393
+ requestId: typeof existing?.requestId === "string" ? existing.requestId : undefined,
394
+ requestPath: typeof existing?.requestPath === "string" ? existing.requestPath : undefined,
395
+ inputHash: typeof existing?.inputHash === "string" ? existing.inputHash : undefined,
396
+ summary: typeof existing?.summary === "string" ? existing.summary : undefined,
397
+ findings: Array.isArray(existing?.findings) ? existing.findings.filter(isFinding) : undefined,
398
+ error: typeof existing?.error === "string" ? existing.error : undefined,
399
+ exceptionReason: typeof existing?.exceptionReason === "string" ? existing.exceptionReason : undefined,
400
+ requestedAt: typeof existing?.requestedAt === "string" ? existing.requestedAt : undefined,
401
+ startedAt: typeof existing?.startedAt === "string" ? existing.startedAt : undefined,
402
+ completedAt: typeof existing?.completedAt === "string" ? existing.completedAt : undefined,
403
+ updatedAt: typeof existing?.updatedAt === "string" ? existing.updatedAt : timestamp,
404
+ callbackStatus: normalizeCallbackStatus(existing?.callbackStatus),
405
+ callbackError: typeof existing?.callbackError === "string" ? existing.callbackError : undefined
406
+ };
407
+ }
408
+ const activeGate = isCodexReviewGate(String(raw?.activeGate)) && gates[raw?.activeGate].status === "running"
409
+ ? raw?.activeGate
410
+ : null;
411
+ return {
412
+ version: CODEX_REVIEW_VERSION,
413
+ enabled: config.enabled,
414
+ activeGate,
415
+ gates,
416
+ updatedAt: typeof raw?.updatedAt === "string" ? raw.updatedAt : timestamp
417
+ };
418
+ }
419
+ function applyGateState(index, gate, patch, timestamp, clearActiveGate = false) {
420
+ const record = {
421
+ ...index.gates[gate],
422
+ ...patch,
423
+ gate,
424
+ updatedAt: patch.updatedAt ?? timestamp
425
+ };
426
+ return {
427
+ ...index,
428
+ activeGate: clearActiveGate && index.activeGate === gate
429
+ ? null
430
+ : patch.status === "running" ? gate : index.activeGate,
431
+ gates: {
432
+ ...index.gates,
433
+ [gate]: record
434
+ },
435
+ updatedAt: timestamp
436
+ };
437
+ }
438
+ async function saveIndex(fs, taskRepoRoot, index) {
439
+ await fs.writeJsonAtomic(getIndexPath(taskRepoRoot), index);
440
+ }
441
+ async function computeInputHash(deps, taskRepoRoot, gate) {
442
+ const digest = createHash("sha256");
443
+ const common = [
444
+ "CLAUDE.md",
445
+ ".ai/codex/AGENTS.md",
446
+ ".ai/codex/config.toml",
447
+ promptPathForGate(gate)
448
+ ];
449
+ for (const relativePath of [...common, ...SOURCE_ARTIFACTS[gate]]) {
450
+ digest.update(relativePath);
451
+ const absolutePath = resolveRepoPath(taskRepoRoot, relativePath);
452
+ if (await deps.fs.pathExists(absolutePath)) {
453
+ digest.update(await deps.fs.readText(absolutePath));
454
+ }
455
+ else {
456
+ digest.update("<missing>");
457
+ }
458
+ }
459
+ if (gate === "final-diff") {
460
+ digest.update(await commandStdout(deps.runner, taskRepoRoot, ["status", "--porcelain=v1"]));
461
+ digest.update(await commandStdout(deps.runner, taskRepoRoot, ["diff", "--binary"]));
462
+ digest.update(await commandStdout(deps.runner, taskRepoRoot, ["diff", "--cached", "--binary"]));
463
+ }
464
+ return digest.digest("hex");
465
+ }
466
+ async function commandStdout(runner, cwd, args) {
467
+ const result = await runner.run("git", args, { cwd });
468
+ return result.exitCode === 0 ? result.stdout : "";
469
+ }
470
+ async function buildCodexPrompt(fs, taskRepoRoot, gate, requestId) {
471
+ const promptPath = resolveRepoPath(taskRepoRoot, promptPathForGate(gate));
472
+ if (!(await fs.pathExists(promptPath))) {
473
+ throw new VcmError({
474
+ code: "CODEX_REVIEW_PROMPT_MISSING",
475
+ message: `Codex review prompt is missing: ${promptPathForGate(gate)}`,
476
+ statusCode: 409,
477
+ hint: "Apply the VCM harness before requesting Codex review gates."
478
+ });
479
+ }
480
+ const basePrompt = await fs.readText(promptPath);
481
+ const reportPath = path.posix.relative(CODEX_DIR, reportPathForGate(gate));
482
+ return `${basePrompt.trimEnd()}
483
+
484
+ ## VCM Runtime Contract
485
+
486
+ - Gate: ${gate}
487
+ - Request: ${requestId}
488
+ - Report path from this working directory: ${reportPath}
489
+
490
+ Your report must begin with these exact fields:
491
+
492
+ \`\`\`text
493
+ Gate: ${gate}
494
+ Request: ${requestId}
495
+ Decision: approve|request_changes
496
+ \`\`\`
497
+
498
+ Write only that report file. Do not edit any other file.`;
499
+ }
500
+ function buildCodexExecArgs(input) {
501
+ const args = [
502
+ "exec",
503
+ "--cd",
504
+ input.codexDir,
505
+ "--add-dir",
506
+ input.reviewDir,
507
+ "--sandbox",
508
+ "workspace-write",
509
+ "--ask-for-approval",
510
+ "never",
511
+ "--skip-git-repo-check",
512
+ "--color",
513
+ "never",
514
+ "--output-last-message",
515
+ input.outputMessagePath
516
+ ];
517
+ if (input.config.model) {
518
+ args.push("--model", input.config.model);
519
+ }
520
+ if (input.config.modelReasoningEffort) {
521
+ args.push("--config", `model_reasoning_effort="${input.config.modelReasoningEffort}"`);
522
+ }
523
+ args.push(input.prompt);
524
+ return args;
525
+ }
526
+ async function parseGateReport(fs, taskRepoRoot, gate, requestId, timestamp) {
527
+ const reportPath = reportPathForGate(gate);
528
+ const absolutePath = resolveRepoPath(taskRepoRoot, reportPath);
529
+ if (!(await fs.pathExists(absolutePath))) {
530
+ throw new VcmError({
531
+ code: "CODEX_REVIEW_REPORT_MISSING",
532
+ message: `Codex review report was not written: ${reportPath}`,
533
+ statusCode: 500
534
+ });
535
+ }
536
+ const content = await fs.readText(absolutePath);
537
+ const parsedGate = matchField(content, "Gate");
538
+ if (parsedGate && parsedGate !== gate) {
539
+ throw new VcmError({
540
+ code: "CODEX_REVIEW_REPORT_GATE_MISMATCH",
541
+ message: `Codex review report gate is ${parsedGate}, expected ${gate}.`,
542
+ statusCode: 500
543
+ });
544
+ }
545
+ const parsedRequest = matchField(content, "Request");
546
+ if (requestId && parsedRequest !== requestId) {
547
+ throw new VcmError({
548
+ code: "CODEX_REVIEW_REPORT_STALE",
549
+ message: `Codex review report request is ${parsedRequest ?? "missing"}, expected ${requestId}.`,
550
+ statusCode: 500
551
+ });
552
+ }
553
+ const decision = normalizeDecision(matchField(content, "Decision"));
554
+ if (!decision) {
555
+ throw new VcmError({
556
+ code: "CODEX_REVIEW_DECISION_MISSING",
557
+ message: `Codex review report must contain Decision: approve or Decision: request_changes.`,
558
+ statusCode: 500
559
+ });
560
+ }
561
+ return {
562
+ gate,
563
+ requestId: parsedRequest,
564
+ decision,
565
+ summary: extractSummary(content),
566
+ findings: extractFindings(content),
567
+ reportPath,
568
+ content,
569
+ parsedAt: timestamp
570
+ };
571
+ }
572
+ async function updateRequestStatus(fs, context, requestId, status, patch) {
573
+ const requestPath = resolveRepoPath(context.taskRepoRoot, path.posix.join(REQUESTS_DIR, `${requestId}.json`));
574
+ const current = await readJsonOrNull(fs, requestPath) ?? {
575
+ version: CODEX_REVIEW_VERSION,
576
+ requestId
577
+ };
578
+ await fs.writeJsonAtomic(requestPath, {
579
+ ...current,
580
+ ...patch,
581
+ status,
582
+ updatedAt: new Date().toISOString()
583
+ });
584
+ }
585
+ function reportPathForGate(gate) {
586
+ return path.posix.join(CODEX_REVIEW_DIR, `${gate}-review.md`);
587
+ }
588
+ function promptPathForGate(gate) {
589
+ return path.posix.join(CODEX_DIR, "prompts", `${gate}-gate.md`);
590
+ }
591
+ function getIndexPath(taskRepoRoot) {
592
+ return resolveRepoPath(taskRepoRoot, path.posix.join(CODEX_REVIEW_DIR, "index.json"));
593
+ }
594
+ function createRequestId(gate) {
595
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
596
+ return `${stamp}-${gate}-${randomUUID().slice(0, 8)}`;
597
+ }
598
+ async function readJsonOrNull(fs, targetPath) {
599
+ try {
600
+ if (!(await fs.pathExists(targetPath))) {
601
+ return null;
602
+ }
603
+ return await fs.readJson(targetPath);
604
+ }
605
+ catch {
606
+ return null;
607
+ }
608
+ }
609
+ function extractTomlSection(content, sectionName) {
610
+ const lines = content.split(/\r?\n/);
611
+ const sectionHeader = `[${sectionName}]`;
612
+ const collected = [];
613
+ let inSection = false;
614
+ for (const line of lines) {
615
+ const trimmed = line.trim();
616
+ if (/^\[[^\]]+\]$/.test(trimmed)) {
617
+ if (inSection) {
618
+ break;
619
+ }
620
+ inSection = trimmed === sectionHeader;
621
+ continue;
622
+ }
623
+ if (inSection) {
624
+ collected.push(line);
625
+ }
626
+ }
627
+ return collected.join("\n");
628
+ }
629
+ function parseTomlBoolean(section, key) {
630
+ const match = section.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*(true|false)\\s*(?:#.*)?$`, "mi"));
631
+ return match ? match[1] === "true" : undefined;
632
+ }
633
+ function parseTomlString(content, key) {
634
+ const match = content.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*"([^"]*)"\\s*(?:#.*)?$`, "mi"));
635
+ return match?.[1];
636
+ }
637
+ function parseTomlStringArray(section, key) {
638
+ const match = section.match(new RegExp(`${escapeRegex(key)}\\s*=\\s*\\[([\\s\\S]*?)\\]`, "m"));
639
+ if (!match) {
640
+ return [];
641
+ }
642
+ return [...match[1].matchAll(/"([^"]+)"/g)].map((candidate) => candidate[1]);
643
+ }
644
+ function matchField(content, field) {
645
+ const match = content.match(new RegExp(`^\\s*${escapeRegex(field)}\\s*:\\s*(.+?)\\s*$`, "mi"));
646
+ return match?.[1]?.trim();
647
+ }
648
+ function extractSummary(content) {
649
+ const field = matchField(content, "Summary");
650
+ if (field) {
651
+ return field;
652
+ }
653
+ const section = content.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|\s*$)/im);
654
+ return section?.[1]?.trim() || undefined;
655
+ }
656
+ function extractFindings(content) {
657
+ const findings = [];
658
+ const blocks = content.split(/\n(?=#{2,4}\s+|-+\s*severity\s*:|severity\s*:)/i);
659
+ for (const block of blocks) {
660
+ const severity = normalizeSeverity(matchField(block, "severity"));
661
+ const title = matchField(block, "title") ?? block.match(/^#{2,4}\s+(.+)$/m)?.[1]?.trim();
662
+ if (!severity || !title) {
663
+ continue;
664
+ }
665
+ findings.push({
666
+ severity,
667
+ title,
668
+ file: matchField(block, "file"),
669
+ line: parsePositiveInteger(matchField(block, "line")),
670
+ evidence: matchField(block, "evidence") ?? "",
671
+ expected: matchField(block, "expected") ?? "",
672
+ gap: matchField(block, "gap") ?? "",
673
+ risk: matchField(block, "risk") ?? ""
674
+ });
675
+ }
676
+ return findings;
677
+ }
678
+ function normalizeGateStatus(value) {
679
+ return typeof value === "string" && [
680
+ "disabled",
681
+ "not_required",
682
+ "pending",
683
+ "running",
684
+ "completed",
685
+ "failed",
686
+ "skipped",
687
+ "overridden"
688
+ ].includes(value)
689
+ ? value
690
+ : undefined;
691
+ }
692
+ function normalizeDecision(value) {
693
+ return value === "approve" || value === "request_changes" ? value : undefined;
694
+ }
695
+ function normalizeSeverity(value) {
696
+ const normalized = typeof value === "string" ? value.toLowerCase() : "";
697
+ return VALID_SEVERITIES.has(normalized)
698
+ ? normalized
699
+ : undefined;
700
+ }
701
+ function normalizeCallbackStatus(value) {
702
+ return value === "not_sent" || value === "sent" || value === "skipped" || value === "failed"
703
+ ? value
704
+ : undefined;
705
+ }
706
+ function isFinding(value) {
707
+ if (!value || typeof value !== "object") {
708
+ return false;
709
+ }
710
+ const candidate = value;
711
+ return Boolean(normalizeSeverity(candidate.severity) && candidate.title);
712
+ }
713
+ function parsePositiveInteger(value) {
714
+ if (!value) {
715
+ return undefined;
716
+ }
717
+ const parsed = Number.parseInt(value, 10);
718
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
719
+ }
720
+ function assertExceptionReason(reason) {
721
+ if (!reason?.trim()) {
722
+ throw new VcmError({
723
+ code: "CODEX_REVIEW_REASON_REQUIRED",
724
+ message: "A reason is required.",
725
+ statusCode: 400
726
+ });
727
+ }
728
+ }
729
+ function renderProjectManagerCallback(input) {
730
+ const lines = [
731
+ "[VCM CODEX REVIEW CALLBACK]",
732
+ `task: ${input.taskSlug}`,
733
+ `gate: ${input.gate}`,
734
+ `status: ${input.status}`,
735
+ `decision: ${input.decision ?? "none"}`,
736
+ `report: ${input.reportPath}`,
737
+ ...(input.error ? [`error: ${input.error}`] : []),
738
+ "",
739
+ "Use the vcm-codex-review-gate skill to handle this callback.",
740
+ "If status is completed and decision is approve, continue the VCM flow.",
741
+ "If decision is request_changes, analyze the report and route follow-up through the normal VCM roles.",
742
+ "If status is failed, stop and ask the user to retry, skip, or override in VCM.",
743
+ "[/VCM CODEX REVIEW CALLBACK]"
744
+ ];
745
+ return lines.join("\n");
746
+ }
747
+ function trimCommandOutput(output) {
748
+ const trimmed = output.trim();
749
+ if (!trimmed) {
750
+ return undefined;
751
+ }
752
+ return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}...` : trimmed;
753
+ }
754
+ function errorMessage(error) {
755
+ if (error instanceof VcmError) {
756
+ return error.hint ? `${error.message} ${error.hint}` : error.message;
757
+ }
758
+ if (error instanceof Error) {
759
+ return error.message;
760
+ }
761
+ return "Unknown Codex review error.";
762
+ }
763
+ function escapeRegex(value) {
764
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
765
+ }