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