imprint-mcp 0.6.1 → 0.6.2
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 +2 -0
- package/package.json +3 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +48 -34
- package/src/imprint/cdp-browser-fetch.ts +135 -42
- package/src/imprint/claude-cli-compile.ts +38 -16
- package/src/imprint/codex-cli-compile.ts +120 -21
- package/src/imprint/compile-agent.ts +71 -26
- package/src/imprint/compile-tools.ts +169 -38
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +16 -0
- package/src/imprint/mcp-compile-server.ts +380 -81
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +12 -2
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/runtime.ts +61 -11
- package/src/imprint/teach-state.ts +18 -1
- package/src/imprint/teach.ts +77 -19
- package/src/imprint/types.ts +39 -2
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join as pathJoin } from 'node:path';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { type AgentTool, doneTool, runAgentLoop } from './agent.ts';
|
|
13
|
+
import { loadBackendsCacheStatus } from './backend-cache.ts';
|
|
14
|
+
import { resolveWorkflowTool } from './backend-ladder.ts';
|
|
15
|
+
import { type ParamVerification, runBunTestWithResults } from './compile-tools.ts';
|
|
16
|
+
import {
|
|
17
|
+
LIVE_EVIDENCE_PATH_ENV,
|
|
18
|
+
LIVE_PREFERRED_BACKEND_ONLY_ENV,
|
|
19
|
+
type LiveIntegrationEvidence,
|
|
20
|
+
readLiveIntegrationEvidence,
|
|
21
|
+
runCapturedIntegrationCase,
|
|
22
|
+
} from './compile-verification.ts';
|
|
23
|
+
import { redactFreeformText } from './freeform-redact.ts';
|
|
24
|
+
import {
|
|
25
|
+
type ProviderName,
|
|
26
|
+
isToolUseProvider,
|
|
27
|
+
preferredVerificationModel,
|
|
28
|
+
resolveProvider,
|
|
29
|
+
} from './llm.ts';
|
|
30
|
+
import {
|
|
31
|
+
canRebindBackendsCacheToWorkflow,
|
|
32
|
+
type probeResolvedTool,
|
|
33
|
+
rebindBackendsCacheToWorkflow,
|
|
34
|
+
} from './probe-backends.ts';
|
|
35
|
+
import { loadCredentialStore } from './runtime.ts';
|
|
36
|
+
import { isSensitiveKey } from './sensitive-keys.ts';
|
|
37
|
+
import { buildZodValidator } from './tool-loader.ts';
|
|
38
|
+
import { type BackendsCache, type ConcreteBackend, WorkflowSchema } from './types.ts';
|
|
39
|
+
|
|
40
|
+
const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
|
|
41
|
+
const CLI_PATH = pathJoin(REPO_ROOT, 'src', 'cli.ts');
|
|
42
|
+
const SYSTEM_PROMPT_PATH = pathJoin(REPO_ROOT, 'prompts', 'live-verifier-agent.md');
|
|
43
|
+
const MCP_SERVER_NAME = 'imprint-live-verifier';
|
|
44
|
+
export const LIVE_VERIFICATION_EVIDENCE_FILE = '.live-verification-evidence.json';
|
|
45
|
+
const LIVE_VERIFIER_LOG_FILE = '.live-verifier-log.jsonl';
|
|
46
|
+
const BACKEND_PREPARATION_BUDGET_MS = 2 * 60_000;
|
|
47
|
+
const MAX_CREDITED_BACKEND_PREPARATIONS = 2;
|
|
48
|
+
const BACKEND_PROBE_SHUTDOWN_GRACE_MS = 5_000;
|
|
49
|
+
let evidenceTempSequence = 0;
|
|
50
|
+
|
|
51
|
+
const BaselineSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
verdict: z.enum(['semantically_correct', 'tool_broken', 'bad_parameters', 'infrastructure']),
|
|
54
|
+
reason: z.string().min(1),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
|
|
58
|
+
const ParameterSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
name: z.string().min(1),
|
|
61
|
+
verdict: z.enum(['works', 'no_op', 'broken', 'untestable']),
|
|
62
|
+
reason: z.string().min(1),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
|
|
66
|
+
const IssueSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
summary: z.string().min(1),
|
|
69
|
+
expected: z.string().min(1),
|
|
70
|
+
observed: z.string().min(1),
|
|
71
|
+
suggestedFix: z.string().min(1),
|
|
72
|
+
})
|
|
73
|
+
.strict();
|
|
74
|
+
|
|
75
|
+
export const LiveVerificationReportSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
status: z.enum(['approved', 'approved_with_gaps', 'changes_required', 'inconclusive']),
|
|
78
|
+
summary: z.string().min(1),
|
|
79
|
+
baseline: BaselineSchema,
|
|
80
|
+
parameters: z.array(ParameterSchema),
|
|
81
|
+
issues: z.array(IssueSchema),
|
|
82
|
+
gaps: z.array(z.string().min(1)),
|
|
83
|
+
evidenceArtifact: z.string().min(1).optional(),
|
|
84
|
+
logArtifact: z.string().min(1).optional(),
|
|
85
|
+
})
|
|
86
|
+
.strict()
|
|
87
|
+
.superRefine((report, ctx) => {
|
|
88
|
+
if (
|
|
89
|
+
(report.status === 'approved' || report.status === 'approved_with_gaps') &&
|
|
90
|
+
report.baseline.verdict !== 'semantically_correct'
|
|
91
|
+
) {
|
|
92
|
+
ctx.addIssue({
|
|
93
|
+
code: z.ZodIssueCode.custom,
|
|
94
|
+
message: 'an approval requires a semantically_correct baseline',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (report.status === 'approved' && report.gaps.length > 0) {
|
|
98
|
+
ctx.addIssue({
|
|
99
|
+
code: z.ZodIssueCode.custom,
|
|
100
|
+
message: 'approved cannot contain gaps',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
report.status === 'approved' &&
|
|
105
|
+
report.parameters.some((parameter) => parameter.verdict !== 'works')
|
|
106
|
+
) {
|
|
107
|
+
ctx.addIssue({
|
|
108
|
+
code: z.ZodIssueCode.custom,
|
|
109
|
+
message: 'approved requires every reported parameter to work',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (report.status === 'approved_with_gaps' && report.gaps.length === 0) {
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: z.ZodIssueCode.custom,
|
|
115
|
+
message: 'approved_with_gaps must name at least one gap',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
report.status === 'approved_with_gaps' &&
|
|
120
|
+
report.parameters.some(
|
|
121
|
+
(parameter) => parameter.verdict !== 'works' && parameter.verdict !== 'untestable',
|
|
122
|
+
)
|
|
123
|
+
) {
|
|
124
|
+
ctx.addIssue({
|
|
125
|
+
code: z.ZodIssueCode.custom,
|
|
126
|
+
message: 'approved_with_gaps permits only works or untestable parameter verdicts',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (report.status === 'approved_with_gaps') {
|
|
130
|
+
for (const parameter of report.parameters) {
|
|
131
|
+
if (
|
|
132
|
+
parameter.verdict === 'untestable' &&
|
|
133
|
+
!report.gaps.some((gap) => gap.toLowerCase().includes(parameter.name.toLowerCase()))
|
|
134
|
+
) {
|
|
135
|
+
ctx.addIssue({
|
|
136
|
+
code: z.ZodIssueCode.custom,
|
|
137
|
+
message: `approved_with_gaps must name untestable parameter ${parameter.name} in gaps`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
(report.status === 'approved' || report.status === 'approved_with_gaps') &&
|
|
144
|
+
report.parameters.some((parameter) => parameter.verdict === 'broken')
|
|
145
|
+
) {
|
|
146
|
+
ctx.addIssue({
|
|
147
|
+
code: z.ZodIssueCode.custom,
|
|
148
|
+
message: 'a report with a broken parameter cannot approve the tool',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (report.status === 'changes_required' && report.issues.length === 0) {
|
|
152
|
+
ctx.addIssue({
|
|
153
|
+
code: z.ZodIssueCode.custom,
|
|
154
|
+
message: 'changes_required must include at least one actionable issue',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
type LiveVerificationReport = z.infer<typeof LiveVerificationReportSchema>;
|
|
160
|
+
|
|
161
|
+
interface LiveIntegrationSuiteResult {
|
|
162
|
+
exitCode: number;
|
|
163
|
+
timedOut: boolean;
|
|
164
|
+
passedTests: string[];
|
|
165
|
+
failedTests: string[];
|
|
166
|
+
stdout: string;
|
|
167
|
+
stderr: string;
|
|
168
|
+
evidence: LiveIntegrationEvidence[];
|
|
169
|
+
receipt: LiveIntegrationSuiteReceipt;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type PersistedLiveVerificationRecord = Record<string, unknown> & {
|
|
173
|
+
label: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
interface LiveIntegrationSuiteReceipt extends PersistedLiveVerificationRecord {
|
|
177
|
+
kind: 'suite';
|
|
178
|
+
label: string;
|
|
179
|
+
status: 'running' | 'passed' | 'failed' | 'timed_out' | 'aborted';
|
|
180
|
+
startedAt: string;
|
|
181
|
+
finishedAt?: string;
|
|
182
|
+
verifierSession?: string;
|
|
183
|
+
preferredBackend?: ConcreteBackend;
|
|
184
|
+
backendCacheProbedAt?: string;
|
|
185
|
+
exitCode?: number;
|
|
186
|
+
timedOut?: boolean;
|
|
187
|
+
passedTests: string[];
|
|
188
|
+
failedTests: string[];
|
|
189
|
+
completedCallLabels: string[];
|
|
190
|
+
stdout: string;
|
|
191
|
+
stderr: string;
|
|
192
|
+
reason?: string;
|
|
193
|
+
error?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface BackendPreparationResult {
|
|
197
|
+
label: string;
|
|
198
|
+
cache: BackendsCache;
|
|
199
|
+
preferredBackend: ConcreteBackend;
|
|
200
|
+
reusedCache: boolean;
|
|
201
|
+
durationMs: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function effectiveParamsForEvidence(
|
|
205
|
+
parameters: Array<{ name: string; default?: unknown }>,
|
|
206
|
+
requested: Record<string, string | number | boolean>,
|
|
207
|
+
): Record<string, string | number | boolean> {
|
|
208
|
+
const defaults: Record<string, string | number | boolean> = {};
|
|
209
|
+
for (const parameter of parameters) {
|
|
210
|
+
const value = parameter.default;
|
|
211
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
212
|
+
defaults[parameter.name] = value;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { ...defaults, ...requested };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function namespaceLiveIntegrationEvidence(
|
|
219
|
+
evidence: LiveIntegrationEvidence[],
|
|
220
|
+
namespace: string,
|
|
221
|
+
): LiveIntegrationEvidence[] {
|
|
222
|
+
return evidence.map((item) => ({
|
|
223
|
+
...item,
|
|
224
|
+
label: `${namespace}/${item.label}`,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function assertReportCoversWorkflowParameters(
|
|
229
|
+
report: LiveVerificationReport,
|
|
230
|
+
parameters: Array<{ name: string }>,
|
|
231
|
+
): void {
|
|
232
|
+
const expected = parameters.map((parameter) => parameter.name);
|
|
233
|
+
const actual = report.parameters.map((parameter) => parameter.name);
|
|
234
|
+
const counts = new Map<string, number>();
|
|
235
|
+
for (const name of actual) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
236
|
+
const missing = expected.filter((name) => !counts.has(name));
|
|
237
|
+
const unknown = actual.filter((name) => !expected.includes(name));
|
|
238
|
+
const duplicates = [...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name);
|
|
239
|
+
if (missing.length > 0 || unknown.length > 0 || duplicates.length > 0) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`semantic report parameter verdicts must match workflow parameters exactly once (missing: ${missing.join(', ') || 'none'}; unknown: ${unknown.join(', ') || 'none'}; duplicates: ${duplicates.join(', ') || 'none'})`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function runLiveIntegrationSuite(opts: {
|
|
247
|
+
toolDir: string;
|
|
248
|
+
timeoutMs?: number;
|
|
249
|
+
logPath?: string;
|
|
250
|
+
attempt?: number;
|
|
251
|
+
reason?: string;
|
|
252
|
+
sessionLabel?: string;
|
|
253
|
+
}): Promise<LiveIntegrationSuiteResult> {
|
|
254
|
+
const integrationPath = pathJoin(opts.toolDir, 'integration.test.ts');
|
|
255
|
+
if (!existsSync(integrationPath)) throw new Error('integration.test.ts is missing');
|
|
256
|
+
const workflowPath = pathJoin(opts.toolDir, 'workflow.json');
|
|
257
|
+
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
|
|
258
|
+
const durableEvidencePath = pathJoin(opts.toolDir, LIVE_VERIFICATION_EVIDENCE_FILE);
|
|
259
|
+
const label = nextRecordLabel(durableEvidencePath, 'suite');
|
|
260
|
+
const startedAt = new Date().toISOString();
|
|
261
|
+
const cacheStatus = loadBackendsCacheStatus(
|
|
262
|
+
workflow.site ?? '',
|
|
263
|
+
dirname(dirname(opts.toolDir)),
|
|
264
|
+
opts.toolDir,
|
|
265
|
+
{ warn: false, toolName: workflow.toolName },
|
|
266
|
+
);
|
|
267
|
+
const preferredBackend =
|
|
268
|
+
cacheStatus.status === 'ok' ? cacheStatus.cache.preferredOrder[0] : undefined;
|
|
269
|
+
let runningReceipt: LiveIntegrationSuiteReceipt = {
|
|
270
|
+
kind: 'suite',
|
|
271
|
+
label,
|
|
272
|
+
status: 'running',
|
|
273
|
+
startedAt,
|
|
274
|
+
verifierSession: opts.sessionLabel,
|
|
275
|
+
preferredBackend,
|
|
276
|
+
backendCacheProbedAt: cacheStatus.status === 'ok' ? cacheStatus.cache.probedAt : undefined,
|
|
277
|
+
passedTests: [],
|
|
278
|
+
failedTests: [],
|
|
279
|
+
completedCallLabels: [],
|
|
280
|
+
stdout: '',
|
|
281
|
+
stderr: '',
|
|
282
|
+
reason: opts.reason,
|
|
283
|
+
};
|
|
284
|
+
persistLiveVerificationEvidence(durableEvidencePath, [runningReceipt]);
|
|
285
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
286
|
+
type: 'suite.started',
|
|
287
|
+
attempt: opts.attempt,
|
|
288
|
+
label,
|
|
289
|
+
preferredBackend,
|
|
290
|
+
cacheStatus: cacheStatus.status,
|
|
291
|
+
});
|
|
292
|
+
const evidencePath = pathJoin(
|
|
293
|
+
opts.toolDir,
|
|
294
|
+
`.imprint-live-evidence-verifier-${process.pid}-${Date.now()}.jsonl`,
|
|
295
|
+
);
|
|
296
|
+
try {
|
|
297
|
+
if (!preferredBackend) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Live verifier backend is not prepared for ${workflow.toolName}; call prepare_live_backend before running the suite.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const run = await runBunTestWithResults(
|
|
303
|
+
integrationPath,
|
|
304
|
+
opts.toolDir,
|
|
305
|
+
opts.timeoutMs ?? 10 * 60_000,
|
|
306
|
+
{
|
|
307
|
+
[LIVE_EVIDENCE_PATH_ENV]: evidencePath,
|
|
308
|
+
[LIVE_PREFERRED_BACKEND_ONLY_ENV]: '1',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
bail: true,
|
|
312
|
+
onOutput: (stream, chunk) => {
|
|
313
|
+
runningReceipt = {
|
|
314
|
+
...runningReceipt,
|
|
315
|
+
[stream]: boundedTail(`${runningReceipt[stream]}${chunk}`, 16_000),
|
|
316
|
+
};
|
|
317
|
+
persistLiveVerificationEvidence(durableEvidencePath, [runningReceipt]);
|
|
318
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
319
|
+
type: `suite.${stream}`,
|
|
320
|
+
attempt: opts.attempt,
|
|
321
|
+
label,
|
|
322
|
+
chunk: boundedTail(chunk, 4_000),
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
let evidence: LiveIntegrationEvidence[] = [];
|
|
328
|
+
let evidenceReadError = '';
|
|
329
|
+
try {
|
|
330
|
+
// A compiler revision may run this suite again. Keep every call from
|
|
331
|
+
// every run instead of letting stable case labels overwrite history.
|
|
332
|
+
evidence = namespaceLiveIntegrationEvidence(readLiveIntegrationEvidence(evidencePath), label);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
evidenceReadError = error instanceof Error ? error.message : String(error);
|
|
335
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
336
|
+
type: 'suite.evidence-read-failed',
|
|
337
|
+
attempt: opts.attempt,
|
|
338
|
+
label,
|
|
339
|
+
error: evidenceReadError,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const receipt: LiveIntegrationSuiteReceipt = {
|
|
343
|
+
...runningReceipt,
|
|
344
|
+
status: run.timedOut ? 'timed_out' : run.exitCode === 0 ? 'passed' : 'failed',
|
|
345
|
+
finishedAt: new Date().toISOString(),
|
|
346
|
+
exitCode: run.exitCode,
|
|
347
|
+
timedOut: run.timedOut,
|
|
348
|
+
passedTests: [...run.passed],
|
|
349
|
+
failedTests: [...run.failed],
|
|
350
|
+
completedCallLabels: evidence.map((item) => item.label),
|
|
351
|
+
stdout: boundedTail(run.stdout || runningReceipt.stdout, 16_000),
|
|
352
|
+
stderr: boundedTail(
|
|
353
|
+
`${run.stderr || runningReceipt.stderr}${evidenceReadError ? `\nEvidence read failed: ${evidenceReadError}` : ''}`,
|
|
354
|
+
16_000,
|
|
355
|
+
),
|
|
356
|
+
};
|
|
357
|
+
persistLiveVerificationEvidence(durableEvidencePath, [...evidence, receipt]);
|
|
358
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
359
|
+
type: 'suite.completed',
|
|
360
|
+
attempt: opts.attempt,
|
|
361
|
+
receipt,
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
exitCode: receipt.exitCode ?? -1,
|
|
365
|
+
timedOut: receipt.timedOut ?? false,
|
|
366
|
+
passedTests: receipt.passedTests,
|
|
367
|
+
failedTests: receipt.failedTests,
|
|
368
|
+
stdout: receipt.stdout,
|
|
369
|
+
stderr: receipt.stderr,
|
|
370
|
+
evidence,
|
|
371
|
+
receipt,
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
375
|
+
const receipt: LiveIntegrationSuiteReceipt = {
|
|
376
|
+
...runningReceipt,
|
|
377
|
+
status: 'aborted',
|
|
378
|
+
finishedAt: new Date().toISOString(),
|
|
379
|
+
error: message,
|
|
380
|
+
};
|
|
381
|
+
persistLiveVerificationEvidence(durableEvidencePath, [receipt]);
|
|
382
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
383
|
+
type: 'suite.aborted',
|
|
384
|
+
attempt: opts.attempt,
|
|
385
|
+
receipt,
|
|
386
|
+
});
|
|
387
|
+
throw error;
|
|
388
|
+
} finally {
|
|
389
|
+
try {
|
|
390
|
+
if (existsSync(evidencePath)) unlinkSync(evidencePath);
|
|
391
|
+
} catch {
|
|
392
|
+
// Raw evidence is replaced by a durable sanitized sidecar.
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function sanitizeEvidenceValue(value: unknown, key?: string, depth = 0): unknown {
|
|
398
|
+
if (key && isSensitiveKey(key)) return '[REDACTED]';
|
|
399
|
+
if (depth > 8) return '[TRUNCATED_DEPTH]';
|
|
400
|
+
if (typeof value === 'string') {
|
|
401
|
+
const bounded =
|
|
402
|
+
value.length > 2_000 ? `${value.slice(0, 2_000)}…[TRUNCATED:${value.length}]` : value;
|
|
403
|
+
return redactFreeformText(bounded).redacted;
|
|
404
|
+
}
|
|
405
|
+
if (Array.isArray(value)) {
|
|
406
|
+
const items = value
|
|
407
|
+
.slice(0, 50)
|
|
408
|
+
.map((item) => sanitizeEvidenceValue(item, undefined, depth + 1));
|
|
409
|
+
if (value.length > 50) items.push(`[TRUNCATED_ITEMS:${value.length - 50}]`);
|
|
410
|
+
return items;
|
|
411
|
+
}
|
|
412
|
+
if (value && typeof value === 'object') {
|
|
413
|
+
return Object.fromEntries(
|
|
414
|
+
Object.entries(value as Record<string, unknown>).map(([name, item]) => [
|
|
415
|
+
name,
|
|
416
|
+
sanitizeEvidenceValue(item, name, depth + 1),
|
|
417
|
+
]),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return value;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function persistLiveVerificationEvidence(
|
|
424
|
+
path: string,
|
|
425
|
+
evidence: Array<LiveIntegrationEvidence | PersistedLiveVerificationRecord>,
|
|
426
|
+
): void {
|
|
427
|
+
const existing = readPersistedLiveVerificationEvidence(path);
|
|
428
|
+
const merged = new Map(existing.map((item) => [item.label, item]));
|
|
429
|
+
for (const item of evidence) {
|
|
430
|
+
merged.set(item.label, sanitizeEvidenceValue(item) as PersistedLiveVerificationRecord);
|
|
431
|
+
}
|
|
432
|
+
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}-${++evidenceTempSequence}`;
|
|
433
|
+
try {
|
|
434
|
+
writeFileSync(tempPath, `${JSON.stringify([...merged.values()], null, 2)}\n`, {
|
|
435
|
+
encoding: 'utf8',
|
|
436
|
+
mode: 0o600,
|
|
437
|
+
flag: 'wx',
|
|
438
|
+
});
|
|
439
|
+
renameSync(tempPath, path);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
try {
|
|
442
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
443
|
+
} catch {
|
|
444
|
+
// Preserve the original write failure; temp cleanup is best effort.
|
|
445
|
+
}
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function readPersistedLiveVerificationEvidence(
|
|
451
|
+
path: string,
|
|
452
|
+
): PersistedLiveVerificationRecord[] {
|
|
453
|
+
if (!existsSync(path)) return [];
|
|
454
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
455
|
+
if (!Array.isArray(parsed)) throw new Error('malformed persisted live verification evidence');
|
|
456
|
+
return parsed.filter(
|
|
457
|
+
(item): item is PersistedLiveVerificationRecord =>
|
|
458
|
+
Boolean(item) && typeof item === 'object' && typeof item.label === 'string',
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function hasSuiteReceiptForSession(path: string, sessionLabel: string): boolean {
|
|
463
|
+
return readPersistedLiveVerificationEvidence(path).some(
|
|
464
|
+
(item) => item.kind === 'suite' && item.verifierSession === sessionLabel,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function boundedTail(value: string, max: number): string {
|
|
469
|
+
return value.length <= max ? value : value.slice(-max);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function nextRecordLabel(path: string, prefix: string): string {
|
|
473
|
+
let highest = 0;
|
|
474
|
+
for (const item of readPersistedLiveVerificationEvidence(path)) {
|
|
475
|
+
const suffix = item.label.slice(prefix.length + 1);
|
|
476
|
+
if (item.label.startsWith(`${prefix}-`) && /^\d+$/.test(suffix)) {
|
|
477
|
+
highest = Math.max(highest, Number(suffix));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return `${prefix}-${highest + 1}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function appendLiveVerifierLog(
|
|
484
|
+
path: string | undefined,
|
|
485
|
+
event: Record<string, unknown>,
|
|
486
|
+
): void {
|
|
487
|
+
if (!path) return;
|
|
488
|
+
const sanitized = sanitizeEvidenceValue({
|
|
489
|
+
timestamp: new Date().toISOString(),
|
|
490
|
+
...event,
|
|
491
|
+
});
|
|
492
|
+
appendFileSync(path, `${JSON.stringify(sanitized)}\n`, {
|
|
493
|
+
encoding: 'utf8',
|
|
494
|
+
mode: 0o600,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function prepareLiveVerificationBackend(opts: {
|
|
499
|
+
workflowPath: string;
|
|
500
|
+
params: Record<string, string | number | boolean>;
|
|
501
|
+
reason: string;
|
|
502
|
+
forceReprobe?: boolean;
|
|
503
|
+
logPath?: string;
|
|
504
|
+
attempt?: number;
|
|
505
|
+
probe?: typeof probeResolvedTool;
|
|
506
|
+
}): Promise<BackendPreparationResult> {
|
|
507
|
+
const tool = resolveWorkflowTool(opts.workflowPath);
|
|
508
|
+
const toolDir = tool.dir;
|
|
509
|
+
const assetRoot = pathJoin(toolDir, '..', '..');
|
|
510
|
+
const evidencePath = pathJoin(toolDir, LIVE_VERIFICATION_EVIDENCE_FILE);
|
|
511
|
+
const startedAt = Date.now();
|
|
512
|
+
const label = nextRecordLabel(evidencePath, 'backend-preparation');
|
|
513
|
+
const status = loadBackendsCacheStatus(tool.site, assetRoot, toolDir, {
|
|
514
|
+
warn: false,
|
|
515
|
+
toolName: tool.workflow.toolName,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
519
|
+
type: 'backend.prepare.started',
|
|
520
|
+
attempt: opts.attempt,
|
|
521
|
+
label,
|
|
522
|
+
reason: opts.reason,
|
|
523
|
+
forceReprobe: opts.forceReprobe ?? false,
|
|
524
|
+
cacheStatus: status.status,
|
|
525
|
+
});
|
|
526
|
+
persistLiveVerificationEvidence(evidencePath, [
|
|
527
|
+
{
|
|
528
|
+
kind: 'backend-preparation',
|
|
529
|
+
label,
|
|
530
|
+
status: 'running',
|
|
531
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
532
|
+
reason: opts.reason,
|
|
533
|
+
forceReprobe: opts.forceReprobe ?? false,
|
|
534
|
+
cacheStatus: status.status,
|
|
535
|
+
},
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const reusableStaleCache =
|
|
540
|
+
status.status === 'stale' && canRebindBackendsCacheToWorkflow(tool, status.cache);
|
|
541
|
+
const reusedCache = (status.status === 'ok' || reusableStaleCache) && !opts.forceReprobe;
|
|
542
|
+
const cache = reusedCache
|
|
543
|
+
? reusableStaleCache
|
|
544
|
+
? rebindBackendsCacheToWorkflow(tool, status.cache)
|
|
545
|
+
: status.cache
|
|
546
|
+
: opts.probe
|
|
547
|
+
? (
|
|
548
|
+
await opts.probe(
|
|
549
|
+
{ site: tool.site, paramOverrides: opts.params },
|
|
550
|
+
assetRoot,
|
|
551
|
+
tool,
|
|
552
|
+
pathJoin(toolDir, 'backends.json'),
|
|
553
|
+
)
|
|
554
|
+
).cache
|
|
555
|
+
: (
|
|
556
|
+
await runBackendProbeSubprocess({
|
|
557
|
+
workflowPath: opts.workflowPath,
|
|
558
|
+
params: opts.params,
|
|
559
|
+
outPath: pathJoin(toolDir, 'backends.json'),
|
|
560
|
+
logPath: opts.logPath,
|
|
561
|
+
attempt: opts.attempt,
|
|
562
|
+
label,
|
|
563
|
+
})
|
|
564
|
+
).cache;
|
|
565
|
+
const preferredBackend = cache.preferredOrder[0];
|
|
566
|
+
if (!preferredBackend) throw new Error('backend probe produced no preferred backend');
|
|
567
|
+
const durationMs = Date.now() - startedAt;
|
|
568
|
+
const result: BackendPreparationResult = {
|
|
569
|
+
label,
|
|
570
|
+
cache,
|
|
571
|
+
preferredBackend,
|
|
572
|
+
reusedCache,
|
|
573
|
+
durationMs,
|
|
574
|
+
};
|
|
575
|
+
persistLiveVerificationEvidence(evidencePath, [
|
|
576
|
+
{
|
|
577
|
+
kind: 'backend-preparation',
|
|
578
|
+
label,
|
|
579
|
+
status: 'passed',
|
|
580
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
581
|
+
finishedAt: new Date().toISOString(),
|
|
582
|
+
reason: opts.reason,
|
|
583
|
+
forceReprobe: opts.forceReprobe ?? false,
|
|
584
|
+
reusedCache,
|
|
585
|
+
preferredBackend,
|
|
586
|
+
probedAt: cache.probedAt,
|
|
587
|
+
durationMs,
|
|
588
|
+
},
|
|
589
|
+
]);
|
|
590
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
591
|
+
type: 'backend.prepare.completed',
|
|
592
|
+
attempt: opts.attempt,
|
|
593
|
+
...result,
|
|
594
|
+
});
|
|
595
|
+
return result;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
598
|
+
const durationMs = Date.now() - startedAt;
|
|
599
|
+
persistLiveVerificationEvidence(evidencePath, [
|
|
600
|
+
{
|
|
601
|
+
kind: 'backend-preparation',
|
|
602
|
+
label,
|
|
603
|
+
status: 'failed',
|
|
604
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
605
|
+
finishedAt: new Date().toISOString(),
|
|
606
|
+
reason: opts.reason,
|
|
607
|
+
forceReprobe: opts.forceReprobe ?? false,
|
|
608
|
+
error: message,
|
|
609
|
+
durationMs,
|
|
610
|
+
},
|
|
611
|
+
]);
|
|
612
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
613
|
+
type: 'backend.prepare.failed',
|
|
614
|
+
attempt: opts.attempt,
|
|
615
|
+
label,
|
|
616
|
+
error: message,
|
|
617
|
+
durationMs,
|
|
618
|
+
});
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function runBackendProbeSubprocess(opts: {
|
|
624
|
+
workflowPath: string;
|
|
625
|
+
params: Record<string, string | number | boolean>;
|
|
626
|
+
outPath: string;
|
|
627
|
+
logPath?: string;
|
|
628
|
+
attempt?: number;
|
|
629
|
+
label: string;
|
|
630
|
+
}): Promise<Awaited<ReturnType<typeof probeResolvedTool>>> {
|
|
631
|
+
const child = spawn(
|
|
632
|
+
process.execPath,
|
|
633
|
+
[
|
|
634
|
+
'run',
|
|
635
|
+
CLI_PATH,
|
|
636
|
+
'__probe-live-verification-backends',
|
|
637
|
+
'--workflow-path',
|
|
638
|
+
opts.workflowPath,
|
|
639
|
+
'--out-path',
|
|
640
|
+
opts.outPath,
|
|
641
|
+
],
|
|
642
|
+
{
|
|
643
|
+
cwd: REPO_ROOT,
|
|
644
|
+
detached: true,
|
|
645
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
646
|
+
},
|
|
647
|
+
);
|
|
648
|
+
const unregisterParentCleanup = registerOwnedProcessTreeCleanup(child);
|
|
649
|
+
child.stdin?.end(JSON.stringify(opts.params));
|
|
650
|
+
|
|
651
|
+
let stderr = '';
|
|
652
|
+
const logChunk = (stream: 'stdout' | 'stderr', raw: unknown): void => {
|
|
653
|
+
const chunk = redactFreeformText(boundedTail(String(raw), 4_000)).redacted;
|
|
654
|
+
if (stream === 'stderr') stderr = boundedTail(`${stderr}${chunk}`, 8_000);
|
|
655
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
656
|
+
type: 'backend.probe.progress',
|
|
657
|
+
attempt: opts.attempt,
|
|
658
|
+
label: opts.label,
|
|
659
|
+
stream,
|
|
660
|
+
chunk,
|
|
661
|
+
});
|
|
662
|
+
};
|
|
663
|
+
child.stdout?.on('data', (chunk) => logChunk('stdout', chunk));
|
|
664
|
+
child.stderr?.on('data', (chunk) => logChunk('stderr', chunk));
|
|
665
|
+
|
|
666
|
+
let outcome: Awaited<ReturnType<typeof waitForOwnedProcessTree>>;
|
|
667
|
+
try {
|
|
668
|
+
outcome = await waitForOwnedProcessTree(
|
|
669
|
+
child,
|
|
670
|
+
BACKEND_PREPARATION_BUDGET_MS,
|
|
671
|
+
BACKEND_PROBE_SHUTDOWN_GRACE_MS,
|
|
672
|
+
);
|
|
673
|
+
} finally {
|
|
674
|
+
unregisterParentCleanup();
|
|
675
|
+
}
|
|
676
|
+
if (outcome.timedOut) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`backend probe exceeded its separate ${Math.round(BACKEND_PREPARATION_BUDGET_MS / 1_000)}s budget and was terminated`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
if (outcome.exitCode !== 0) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`backend probe exited ${outcome.exitCode}${stderr ? `: ${stderr.slice(-2_000)}` : ''}`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const tool = resolveWorkflowTool(opts.workflowPath);
|
|
688
|
+
const assetRoot = pathJoin(tool.dir, '..', '..');
|
|
689
|
+
const cacheStatus = loadBackendsCacheStatus(tool.site, assetRoot, tool.dir, {
|
|
690
|
+
warn: false,
|
|
691
|
+
toolName: tool.workflow.toolName,
|
|
692
|
+
});
|
|
693
|
+
if (cacheStatus.status !== 'ok') {
|
|
694
|
+
throw new Error(`backend probe completed without a valid cache (${cacheStatus.status})`);
|
|
695
|
+
}
|
|
696
|
+
return { cache: cacheStatus.cache, outPath: opts.outPath };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
interface LiveSemanticVerificationResult {
|
|
700
|
+
report: LiveVerificationReport;
|
|
701
|
+
provider: ProviderName;
|
|
702
|
+
model: string;
|
|
703
|
+
attempts: number;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export async function runLiveSemanticVerification(opts: {
|
|
707
|
+
provider: ProviderName;
|
|
708
|
+
toolDir: string;
|
|
709
|
+
evidence: LiveIntegrationEvidence[];
|
|
710
|
+
deadlineMs?: number;
|
|
711
|
+
}): Promise<LiveSemanticVerificationResult> {
|
|
712
|
+
const workflowPath = pathJoin(opts.toolDir, 'workflow.json');
|
|
713
|
+
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
|
|
714
|
+
const model = preferredVerificationModel(opts.provider);
|
|
715
|
+
const reportPath = pathJoin(opts.toolDir, '.live-verification.json');
|
|
716
|
+
const evidencePath = pathJoin(opts.toolDir, LIVE_VERIFICATION_EVIDENCE_FILE);
|
|
717
|
+
const logPath = pathJoin(opts.toolDir, LIVE_VERIFIER_LOG_FILE);
|
|
718
|
+
const verifierSessionLabel = nextRecordLabel(evidencePath, 'verifier-session');
|
|
719
|
+
const sessionStartedAtMs = Date.now();
|
|
720
|
+
const sessionStartedAt = new Date(sessionStartedAtMs).toISOString();
|
|
721
|
+
const preparationBudget = { creditedCount: 0, creditMs: 0, grantedMs: 0 };
|
|
722
|
+
let verifierSession: PersistedLiveVerificationRecord = {
|
|
723
|
+
label: verifierSessionLabel,
|
|
724
|
+
kind: 'verifier-session',
|
|
725
|
+
status: 'running',
|
|
726
|
+
startedAt: sessionStartedAt,
|
|
727
|
+
provider: opts.provider,
|
|
728
|
+
model,
|
|
729
|
+
};
|
|
730
|
+
const invocationEvidence = namespaceLiveIntegrationEvidence(opts.evidence, verifierSessionLabel);
|
|
731
|
+
let accumulated: Array<LiveIntegrationEvidence | PersistedLiveVerificationRecord> =
|
|
732
|
+
mergePersistedRecords(readPersistedLiveVerificationEvidence(evidencePath), [
|
|
733
|
+
verifierSession,
|
|
734
|
+
...invocationEvidence,
|
|
735
|
+
]);
|
|
736
|
+
const attemptErrors: string[] = [];
|
|
737
|
+
// Verification gets its own reasoning window. Backend preparation is added
|
|
738
|
+
// separately below and both attempts share this one absolute deadline.
|
|
739
|
+
const verifierDeadlineMs = opts.deadlineMs ?? Date.now() + 10 * 60_000;
|
|
740
|
+
|
|
741
|
+
persistLiveVerificationEvidence(evidencePath, accumulated);
|
|
742
|
+
appendLiveVerifierLog(logPath, {
|
|
743
|
+
type: 'verifier.session.started',
|
|
744
|
+
session: verifierSessionLabel,
|
|
745
|
+
provider: opts.provider,
|
|
746
|
+
model,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
750
|
+
try {
|
|
751
|
+
if (existsSync(reportPath)) unlinkSync(reportPath);
|
|
752
|
+
} catch {
|
|
753
|
+
// best effort
|
|
754
|
+
}
|
|
755
|
+
appendLiveVerifierLog(logPath, {
|
|
756
|
+
type: 'verifier.attempt.started',
|
|
757
|
+
attempt,
|
|
758
|
+
provider: opts.provider,
|
|
759
|
+
model,
|
|
760
|
+
});
|
|
761
|
+
try {
|
|
762
|
+
const initialMessage = buildInitialMessage(workflow, accumulated, opts.toolDir);
|
|
763
|
+
const retryMessage =
|
|
764
|
+
attempt === 1
|
|
765
|
+
? initialMessage
|
|
766
|
+
: `${initialMessage}\n\nRETRY NOTE: The prior verifier attempt ended without a usable report. Existing backend preparation, suite receipts, targeted-call outputs, and diagnostics are supplied above. Inspect them and use the live tools when useful; do not assume a successful call must be repeated.`;
|
|
767
|
+
const report =
|
|
768
|
+
opts.provider === 'anthropic-api'
|
|
769
|
+
? await runAnthropicVerifier({
|
|
770
|
+
workflowPath,
|
|
771
|
+
reportPath,
|
|
772
|
+
initialMessage: retryMessage,
|
|
773
|
+
model,
|
|
774
|
+
deadlineMs: verifierDeadlineMs,
|
|
775
|
+
preparationBudget,
|
|
776
|
+
logPath,
|
|
777
|
+
attempt,
|
|
778
|
+
sessionLabel: verifierSessionLabel,
|
|
779
|
+
})
|
|
780
|
+
: await runCliVerifier({
|
|
781
|
+
provider: opts.provider,
|
|
782
|
+
workflowPath,
|
|
783
|
+
reportPath,
|
|
784
|
+
initialMessage: retryMessage,
|
|
785
|
+
model,
|
|
786
|
+
deadlineMs: verifierDeadlineMs,
|
|
787
|
+
logSinceMs: sessionStartedAtMs,
|
|
788
|
+
logPath,
|
|
789
|
+
attempt,
|
|
790
|
+
sessionLabel: verifierSessionLabel,
|
|
791
|
+
});
|
|
792
|
+
accumulated = mergePersistedRecords(
|
|
793
|
+
accumulated,
|
|
794
|
+
readPersistedLiveVerificationEvidence(evidencePath),
|
|
795
|
+
);
|
|
796
|
+
assertReportCoversWorkflowParameters(report, workflow.parameters);
|
|
797
|
+
persistLiveVerificationEvidence(evidencePath, accumulated);
|
|
798
|
+
const persistedReport = {
|
|
799
|
+
...report,
|
|
800
|
+
evidenceArtifact: LIVE_VERIFICATION_EVIDENCE_FILE,
|
|
801
|
+
logArtifact: LIVE_VERIFIER_LOG_FILE,
|
|
802
|
+
};
|
|
803
|
+
writeFileSync(reportPath, `${JSON.stringify(persistedReport, null, 2)}\n`, {
|
|
804
|
+
encoding: 'utf8',
|
|
805
|
+
mode: 0o600,
|
|
806
|
+
});
|
|
807
|
+
appendLiveVerifierLog(logPath, {
|
|
808
|
+
type: 'verifier.attempt.completed',
|
|
809
|
+
attempt,
|
|
810
|
+
status: persistedReport.status,
|
|
811
|
+
});
|
|
812
|
+
verifierSession = {
|
|
813
|
+
...verifierSession,
|
|
814
|
+
status: persistedReport.status,
|
|
815
|
+
finishedAt: new Date().toISOString(),
|
|
816
|
+
attempts: attempt,
|
|
817
|
+
};
|
|
818
|
+
accumulated = mergePersistedRecords(accumulated, [verifierSession]);
|
|
819
|
+
persistLiveVerificationEvidence(evidencePath, accumulated);
|
|
820
|
+
appendLiveVerifierLog(logPath, {
|
|
821
|
+
type: 'verifier.session.completed',
|
|
822
|
+
session: verifierSessionLabel,
|
|
823
|
+
status: persistedReport.status,
|
|
824
|
+
attempts: attempt,
|
|
825
|
+
});
|
|
826
|
+
return {
|
|
827
|
+
report: persistedReport,
|
|
828
|
+
provider: opts.provider,
|
|
829
|
+
model,
|
|
830
|
+
attempts: attempt,
|
|
831
|
+
};
|
|
832
|
+
} catch (error) {
|
|
833
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
834
|
+
attemptErrors.push(`attempt ${attempt}: ${message}`);
|
|
835
|
+
accumulated = mergePersistedRecords(
|
|
836
|
+
accumulated,
|
|
837
|
+
readPersistedLiveVerificationEvidence(evidencePath),
|
|
838
|
+
);
|
|
839
|
+
appendLiveVerifierLog(logPath, {
|
|
840
|
+
type: 'verifier.attempt.failed',
|
|
841
|
+
attempt,
|
|
842
|
+
error: message,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const infrastructureEvidence: PersistedLiveVerificationRecord = {
|
|
848
|
+
label: `${verifierSessionLabel}/infrastructure`,
|
|
849
|
+
kind: 'verifier-infrastructure',
|
|
850
|
+
errors: attemptErrors,
|
|
851
|
+
};
|
|
852
|
+
verifierSession = {
|
|
853
|
+
...verifierSession,
|
|
854
|
+
status: 'inconclusive',
|
|
855
|
+
finishedAt: new Date().toISOString(),
|
|
856
|
+
attempts: 2,
|
|
857
|
+
};
|
|
858
|
+
accumulated = mergePersistedRecords(accumulated, [infrastructureEvidence, verifierSession]);
|
|
859
|
+
persistLiveVerificationEvidence(evidencePath, accumulated);
|
|
860
|
+
const failureSummary = attemptErrors.join('; ') || 'verifier did not return a valid report';
|
|
861
|
+
const inconclusive: LiveVerificationReport = {
|
|
862
|
+
status: 'inconclusive',
|
|
863
|
+
summary: `Independent semantic verifier failed closed: ${failureSummary}`,
|
|
864
|
+
baseline: {
|
|
865
|
+
verdict: 'infrastructure',
|
|
866
|
+
reason: failureSummary,
|
|
867
|
+
},
|
|
868
|
+
parameters: workflow.parameters.map((parameter) => ({
|
|
869
|
+
name: parameter.name,
|
|
870
|
+
verdict: 'untestable',
|
|
871
|
+
reason: 'the independent verifier did not return a valid report',
|
|
872
|
+
})),
|
|
873
|
+
issues: [],
|
|
874
|
+
gaps: ['No valid independent semantic verification report was produced.'],
|
|
875
|
+
evidenceArtifact: LIVE_VERIFICATION_EVIDENCE_FILE,
|
|
876
|
+
logArtifact: LIVE_VERIFIER_LOG_FILE,
|
|
877
|
+
};
|
|
878
|
+
writeFileSync(reportPath, `${JSON.stringify(inconclusive, null, 2)}\n`, {
|
|
879
|
+
encoding: 'utf8',
|
|
880
|
+
mode: 0o600,
|
|
881
|
+
});
|
|
882
|
+
appendLiveVerifierLog(logPath, {
|
|
883
|
+
type: 'verifier.completed',
|
|
884
|
+
status: 'inconclusive',
|
|
885
|
+
errors: attemptErrors,
|
|
886
|
+
});
|
|
887
|
+
appendLiveVerifierLog(logPath, {
|
|
888
|
+
type: 'verifier.session.completed',
|
|
889
|
+
session: verifierSessionLabel,
|
|
890
|
+
status: 'inconclusive',
|
|
891
|
+
attempts: 2,
|
|
892
|
+
});
|
|
893
|
+
return { report: inconclusive, provider: opts.provider, model, attempts: 2 };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function mergePersistedRecords(
|
|
897
|
+
current: Array<LiveIntegrationEvidence | PersistedLiveVerificationRecord>,
|
|
898
|
+
incoming: Array<LiveIntegrationEvidence | PersistedLiveVerificationRecord>,
|
|
899
|
+
): Array<LiveIntegrationEvidence | PersistedLiveVerificationRecord> {
|
|
900
|
+
const merged = new Map(current.map((item) => [item.label, item]));
|
|
901
|
+
for (const item of incoming) merged.set(item.label, item);
|
|
902
|
+
return [...merged.values()];
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function readVerifierArtifact(path: string, maxChars = 30_000): string | undefined {
|
|
906
|
+
if (!existsSync(path)) return undefined;
|
|
907
|
+
const content = readFileSync(path, 'utf8');
|
|
908
|
+
return content.length <= maxChars
|
|
909
|
+
? content
|
|
910
|
+
: `${content.slice(0, maxChars)}\n...[truncated ${content.length - maxChars} chars]`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function buildVerifierArtifactContext(toolDir: string, toolName: string): unknown {
|
|
914
|
+
let buildPlan: unknown;
|
|
915
|
+
const buildPlanPath = pathJoin(dirname(toolDir), '.build-plan.json');
|
|
916
|
+
if (existsSync(buildPlanPath)) {
|
|
917
|
+
try {
|
|
918
|
+
const parsed = JSON.parse(readFileSync(buildPlanPath, 'utf8')) as {
|
|
919
|
+
perTool?: Array<{ toolName?: string }>;
|
|
920
|
+
dynamicValueFindings?: unknown;
|
|
921
|
+
};
|
|
922
|
+
buildPlan = {
|
|
923
|
+
tool: parsed.perTool?.find((entry) => entry.toolName === toolName),
|
|
924
|
+
dynamicValueFindings: parsed.dynamicValueFindings,
|
|
925
|
+
};
|
|
926
|
+
} catch {
|
|
927
|
+
buildPlan = { error: 'build plan could not be parsed' };
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
workflow: JSON.parse(readFileSync(pathJoin(toolDir, 'workflow.json'), 'utf8')),
|
|
932
|
+
parser: readVerifierArtifact(pathJoin(toolDir, 'parser.ts')),
|
|
933
|
+
parserTests: readVerifierArtifact(pathJoin(toolDir, 'parser.test.ts')),
|
|
934
|
+
integrationTests: readVerifierArtifact(pathJoin(toolDir, 'integration.test.ts')),
|
|
935
|
+
playbook: readVerifierArtifact(pathJoin(toolDir, 'playbook.yaml')),
|
|
936
|
+
buildPlan,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function buildInitialMessage(
|
|
941
|
+
workflow: z.infer<typeof WorkflowSchema>,
|
|
942
|
+
evidence: Array<LiveIntegrationEvidence | PersistedLiveVerificationRecord>,
|
|
943
|
+
toolDir: string,
|
|
944
|
+
): string {
|
|
945
|
+
return `Review this generated tool's real live outputs and submit a structured report.\n\n${JSON.stringify(
|
|
946
|
+
{
|
|
947
|
+
tool: {
|
|
948
|
+
name: workflow.toolName,
|
|
949
|
+
site: workflow.site,
|
|
950
|
+
intent: workflow.intent,
|
|
951
|
+
parameters: workflow.parameters,
|
|
952
|
+
},
|
|
953
|
+
reviewContext: {
|
|
954
|
+
currentDate: new Date().toISOString(),
|
|
955
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
956
|
+
},
|
|
957
|
+
artifactContext: buildVerifierArtifactContext(toolDir, workflow.toolName),
|
|
958
|
+
baselineEvidence: evidence,
|
|
959
|
+
},
|
|
960
|
+
null,
|
|
961
|
+
2,
|
|
962
|
+
)}`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function creditBackendPreparation(
|
|
966
|
+
budget: { creditedCount: number; creditMs: number },
|
|
967
|
+
durationMs: number,
|
|
968
|
+
reusedCache: boolean,
|
|
969
|
+
): void {
|
|
970
|
+
if (reusedCache || budget.creditedCount >= MAX_CREDITED_BACKEND_PREPARATIONS) return;
|
|
971
|
+
budget.creditedCount++;
|
|
972
|
+
budget.creditMs += Math.min(Math.max(0, durationMs), BACKEND_PREPARATION_BUDGET_MS);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function runAnthropicVerifier(opts: {
|
|
976
|
+
workflowPath: string;
|
|
977
|
+
reportPath: string;
|
|
978
|
+
initialMessage: string;
|
|
979
|
+
model: string;
|
|
980
|
+
deadlineMs?: number;
|
|
981
|
+
logPath: string;
|
|
982
|
+
attempt: number;
|
|
983
|
+
sessionLabel: string;
|
|
984
|
+
preparationBudget: { creditedCount: number; creditMs: number; grantedMs: number };
|
|
985
|
+
}): Promise<LiveVerificationReport> {
|
|
986
|
+
const provider = resolveProvider({
|
|
987
|
+
provider: 'anthropic-api',
|
|
988
|
+
model: opts.model,
|
|
989
|
+
});
|
|
990
|
+
if (!isToolUseProvider(provider)) throw new Error('Anthropic provider lacks tool use');
|
|
991
|
+
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
|
|
992
|
+
const validator = buildZodValidator(workflow.parameters);
|
|
993
|
+
const credentials = (await loadCredentialStore(workflow.site)) ?? undefined;
|
|
994
|
+
let submitted: LiveVerificationReport | undefined;
|
|
995
|
+
let loggedConversationEntries = 0;
|
|
996
|
+
const evidencePath = pathJoin(dirname(opts.reportPath), LIVE_VERIFICATION_EVIDENCE_FILE);
|
|
997
|
+
const hasSuiteReceipt = (): boolean => hasSuiteReceiptForSession(evidencePath, opts.sessionLabel);
|
|
998
|
+
|
|
999
|
+
const tools: AgentTool[] = [
|
|
1000
|
+
{
|
|
1001
|
+
name: 'prepare_live_backend',
|
|
1002
|
+
description:
|
|
1003
|
+
'Reuse the current preferred backend, probing only when no valid preference exists. Set forceReprobe only after the preferred backend fails because of a transport, network, or browser-infrastructure error; semantic output failures must go back to the compiler without reprobe.',
|
|
1004
|
+
input_schema: {
|
|
1005
|
+
type: 'object',
|
|
1006
|
+
properties: {
|
|
1007
|
+
params: { type: 'object' },
|
|
1008
|
+
reason: { type: 'string' },
|
|
1009
|
+
forceReprobe: { type: 'boolean' },
|
|
1010
|
+
},
|
|
1011
|
+
required: ['params', 'reason'],
|
|
1012
|
+
},
|
|
1013
|
+
handler: async (raw) => {
|
|
1014
|
+
const input = raw as {
|
|
1015
|
+
params?: unknown;
|
|
1016
|
+
reason?: unknown;
|
|
1017
|
+
forceReprobe?: unknown;
|
|
1018
|
+
};
|
|
1019
|
+
if (typeof input.reason !== 'string' || !input.reason.trim()) {
|
|
1020
|
+
return { result: 'reason is required', isError: true };
|
|
1021
|
+
}
|
|
1022
|
+
const parsed = validator.safeParse(input.params ?? {});
|
|
1023
|
+
if (!parsed.success) return { result: parsed.error.message, isError: true };
|
|
1024
|
+
const startedAt = Date.now();
|
|
1025
|
+
try {
|
|
1026
|
+
const result = await prepareLiveVerificationBackend({
|
|
1027
|
+
workflowPath: opts.workflowPath,
|
|
1028
|
+
params: parsed.data as Record<string, string | number | boolean>,
|
|
1029
|
+
reason: input.reason,
|
|
1030
|
+
forceReprobe: input.forceReprobe === true,
|
|
1031
|
+
logPath: opts.logPath,
|
|
1032
|
+
attempt: opts.attempt,
|
|
1033
|
+
});
|
|
1034
|
+
creditBackendPreparation(opts.preparationBudget, result.durationMs, result.reusedCache);
|
|
1035
|
+
return { result: JSON.stringify(result) };
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
creditBackendPreparation(opts.preparationBudget, Date.now() - startedAt, false);
|
|
1038
|
+
return {
|
|
1039
|
+
result: error instanceof Error ? error.message : String(error),
|
|
1040
|
+
isError: true,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
name: 'run_live_integration_suite',
|
|
1047
|
+
description:
|
|
1048
|
+
'Run the compiler-proposed final integration suite. A justified rerun is allowed after failure, timeout, or backend reprobe.',
|
|
1049
|
+
input_schema: {
|
|
1050
|
+
type: 'object',
|
|
1051
|
+
properties: { reason: { type: 'string' } },
|
|
1052
|
+
additionalProperties: false,
|
|
1053
|
+
},
|
|
1054
|
+
handler: async (raw) => {
|
|
1055
|
+
const input = raw as { reason?: unknown };
|
|
1056
|
+
try {
|
|
1057
|
+
const suite = await runLiveIntegrationSuite({
|
|
1058
|
+
toolDir: dirname(opts.workflowPath),
|
|
1059
|
+
logPath: opts.logPath,
|
|
1060
|
+
attempt: opts.attempt,
|
|
1061
|
+
reason: typeof input.reason === 'string' ? input.reason : undefined,
|
|
1062
|
+
sessionLabel: opts.sessionLabel,
|
|
1063
|
+
});
|
|
1064
|
+
return { result: JSON.stringify(suite) };
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
return {
|
|
1067
|
+
result: error instanceof Error ? error.message : String(error),
|
|
1068
|
+
isError: true,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
name: 'run_live_integration_test',
|
|
1075
|
+
description:
|
|
1076
|
+
'Run a targeted live call for a specific unresolved semantic question. Repeated parameters are allowed when the reason explains why.',
|
|
1077
|
+
input_schema: {
|
|
1078
|
+
type: 'object',
|
|
1079
|
+
properties: { params: { type: 'object' }, reason: { type: 'string' } },
|
|
1080
|
+
required: ['params', 'reason'],
|
|
1081
|
+
},
|
|
1082
|
+
handler: async (raw) => {
|
|
1083
|
+
if (!hasSuiteReceipt())
|
|
1084
|
+
return {
|
|
1085
|
+
result: 'Run the final integration suite before a targeted follow-up.',
|
|
1086
|
+
isError: true,
|
|
1087
|
+
};
|
|
1088
|
+
const input = raw as { params?: unknown; reason?: unknown };
|
|
1089
|
+
if (typeof input.reason !== 'string' || !input.reason.trim()) {
|
|
1090
|
+
return { result: 'reason is required', isError: true };
|
|
1091
|
+
}
|
|
1092
|
+
const parsed = validator.safeParse(input.params ?? {});
|
|
1093
|
+
if (!parsed.success) return { result: parsed.error.message, isError: true };
|
|
1094
|
+
const label = nextRecordLabel(evidencePath, 'targeted-call');
|
|
1095
|
+
const startedAt = Date.now();
|
|
1096
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
1097
|
+
type: 'targeted-call.started',
|
|
1098
|
+
attempt: opts.attempt,
|
|
1099
|
+
label,
|
|
1100
|
+
reason: input.reason,
|
|
1101
|
+
params: parsed.data,
|
|
1102
|
+
});
|
|
1103
|
+
let run: Awaited<ReturnType<typeof runCapturedIntegrationCase>>;
|
|
1104
|
+
try {
|
|
1105
|
+
run = await runCapturedIntegrationCase({
|
|
1106
|
+
caseName: label,
|
|
1107
|
+
workflowPath: opts.workflowPath,
|
|
1108
|
+
params: parsed.data as Record<string, string | number | boolean>,
|
|
1109
|
+
credentials,
|
|
1110
|
+
preferredOnlyBackend: true,
|
|
1111
|
+
});
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1114
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
1115
|
+
type: 'targeted-call.failed',
|
|
1116
|
+
attempt: opts.attempt,
|
|
1117
|
+
label,
|
|
1118
|
+
error: message,
|
|
1119
|
+
});
|
|
1120
|
+
return { result: message, isError: true };
|
|
1121
|
+
}
|
|
1122
|
+
const supplemental: LiveIntegrationEvidence = {
|
|
1123
|
+
schemaVersion: 1,
|
|
1124
|
+
kind: 'call',
|
|
1125
|
+
label,
|
|
1126
|
+
caseName: label,
|
|
1127
|
+
toolName: workflow.toolName,
|
|
1128
|
+
requestedParams: parsed.data as Record<string, string | number | boolean>,
|
|
1129
|
+
effectiveParams: effectiveParamsForEvidence(
|
|
1130
|
+
workflow.parameters,
|
|
1131
|
+
parsed.data as Record<string, string | number | boolean>,
|
|
1132
|
+
),
|
|
1133
|
+
result: run.result,
|
|
1134
|
+
usedBackend: run.usedBackend,
|
|
1135
|
+
attempts: run.attempts,
|
|
1136
|
+
durationMs: Date.now() - startedAt,
|
|
1137
|
+
};
|
|
1138
|
+
persistLiveVerificationEvidence(evidencePath, [supplemental]);
|
|
1139
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
1140
|
+
type: 'targeted-call.completed',
|
|
1141
|
+
attempt: opts.attempt,
|
|
1142
|
+
record: supplemental,
|
|
1143
|
+
});
|
|
1144
|
+
return {
|
|
1145
|
+
result: JSON.stringify({
|
|
1146
|
+
label: supplemental.label,
|
|
1147
|
+
toolName: workflow.toolName,
|
|
1148
|
+
requestedParams: parsed.data,
|
|
1149
|
+
result: run.result,
|
|
1150
|
+
usedBackend: run.usedBackend,
|
|
1151
|
+
attempts: run.attempts,
|
|
1152
|
+
}),
|
|
1153
|
+
};
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
name: 'submit_verification_report',
|
|
1158
|
+
description: 'Submit the final structured semantic report exactly once.',
|
|
1159
|
+
input_schema: {
|
|
1160
|
+
type: 'object',
|
|
1161
|
+
properties: {
|
|
1162
|
+
status: { type: 'string' },
|
|
1163
|
+
summary: { type: 'string' },
|
|
1164
|
+
baseline: { type: 'object' },
|
|
1165
|
+
parameters: { type: 'array' },
|
|
1166
|
+
issues: { type: 'array' },
|
|
1167
|
+
gaps: { type: 'array' },
|
|
1168
|
+
},
|
|
1169
|
+
required: ['status', 'summary', 'baseline', 'parameters', 'issues', 'gaps'],
|
|
1170
|
+
},
|
|
1171
|
+
handler: async (raw) => {
|
|
1172
|
+
const parsed = LiveVerificationReportSchema.safeParse(raw);
|
|
1173
|
+
if (!parsed.success) return { result: parsed.error.message, isError: true };
|
|
1174
|
+
if (!hasSuiteReceipt()) {
|
|
1175
|
+
return {
|
|
1176
|
+
result: 'Run the final integration suite before submitting.',
|
|
1177
|
+
isError: true,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
assertReportCoversWorkflowParameters(parsed.data, workflow.parameters);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
return {
|
|
1184
|
+
result: error instanceof Error ? error.message : String(error),
|
|
1185
|
+
isError: true,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
submitted = parsed.data;
|
|
1189
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
1190
|
+
type: 'report.submitted',
|
|
1191
|
+
attempt: opts.attempt,
|
|
1192
|
+
report: parsed.data,
|
|
1193
|
+
});
|
|
1194
|
+
return { result: 'Report accepted. Call done now.' };
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
doneTool(),
|
|
1198
|
+
];
|
|
1199
|
+
const result = await runAgentLoop({
|
|
1200
|
+
systemPrompt: readFileSync(SYSTEM_PROMPT_PATH, 'utf8'),
|
|
1201
|
+
initialUserMessage: opts.initialMessage,
|
|
1202
|
+
tools,
|
|
1203
|
+
deadlineMs: opts.deadlineMs ?? Date.now() + 10 * 60_000,
|
|
1204
|
+
softTurnCap: 12,
|
|
1205
|
+
llm: provider,
|
|
1206
|
+
onDeadlineReached: async () => {
|
|
1207
|
+
const extensionMs = opts.preparationBudget.creditMs - opts.preparationBudget.grantedMs;
|
|
1208
|
+
if (extensionMs <= 0) return null;
|
|
1209
|
+
opts.preparationBudget.grantedMs += extensionMs;
|
|
1210
|
+
return extensionMs;
|
|
1211
|
+
},
|
|
1212
|
+
onConversationUpdate: (conversation) => {
|
|
1213
|
+
for (const entry of conversation.slice(loggedConversationEntries)) {
|
|
1214
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
1215
|
+
type: 'agent.conversation-entry',
|
|
1216
|
+
attempt: opts.attempt,
|
|
1217
|
+
entry,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
loggedConversationEntries = conversation.length;
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
1223
|
+
if (!submitted) throw new Error(`semantic verifier ended without a report (${result.outcome})`);
|
|
1224
|
+
return submitted;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function runCliVerifier(opts: {
|
|
1228
|
+
provider: ProviderName;
|
|
1229
|
+
workflowPath: string;
|
|
1230
|
+
reportPath: string;
|
|
1231
|
+
initialMessage: string;
|
|
1232
|
+
model: string;
|
|
1233
|
+
deadlineMs?: number;
|
|
1234
|
+
logPath: string;
|
|
1235
|
+
attempt: number;
|
|
1236
|
+
sessionLabel: string;
|
|
1237
|
+
logSinceMs: number;
|
|
1238
|
+
}): Promise<LiveVerificationReport> {
|
|
1239
|
+
if (opts.provider !== 'codex-cli' && opts.provider !== 'claude-cli') {
|
|
1240
|
+
throw new Error(`CLI verifier is unsupported for ${opts.provider}`);
|
|
1241
|
+
}
|
|
1242
|
+
const mcpArgs = [
|
|
1243
|
+
'run',
|
|
1244
|
+
CLI_PATH,
|
|
1245
|
+
'__mcp-live-verifier-server',
|
|
1246
|
+
'--workflow-path',
|
|
1247
|
+
opts.workflowPath,
|
|
1248
|
+
'--report-path',
|
|
1249
|
+
opts.reportPath,
|
|
1250
|
+
'--log-path',
|
|
1251
|
+
opts.logPath,
|
|
1252
|
+
'--attempt',
|
|
1253
|
+
String(opts.attempt),
|
|
1254
|
+
'--session-label',
|
|
1255
|
+
opts.sessionLabel,
|
|
1256
|
+
];
|
|
1257
|
+
const child =
|
|
1258
|
+
opts.provider === 'codex-cli'
|
|
1259
|
+
? spawnCodexVerifier(opts, mcpArgs)
|
|
1260
|
+
: spawnClaudeVerifier(opts, mcpArgs);
|
|
1261
|
+
const unregisterParentCleanup = registerOwnedProcessTreeCleanup(child);
|
|
1262
|
+
const deadlineMs = opts.deadlineMs ?? Date.now() + 10 * 60_000;
|
|
1263
|
+
let exitCode: number | null;
|
|
1264
|
+
let stderr: string;
|
|
1265
|
+
try {
|
|
1266
|
+
({ exitCode, stderr } = await waitForChild(
|
|
1267
|
+
child,
|
|
1268
|
+
deadlineMs,
|
|
1269
|
+
opts.logPath,
|
|
1270
|
+
opts.attempt,
|
|
1271
|
+
opts.logSinceMs,
|
|
1272
|
+
));
|
|
1273
|
+
} finally {
|
|
1274
|
+
unregisterParentCleanup();
|
|
1275
|
+
}
|
|
1276
|
+
if (!existsSync(opts.reportPath)) {
|
|
1277
|
+
throw new Error(
|
|
1278
|
+
`${opts.provider} semantic verifier exited ${exitCode} without a report${stderr ? `: ${stderr.slice(-1000)}` : ''}`,
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
const parsed = LiveVerificationReportSchema.safeParse(
|
|
1282
|
+
JSON.parse(readFileSync(opts.reportPath, 'utf8')),
|
|
1283
|
+
);
|
|
1284
|
+
if (!parsed.success) throw new Error(`malformed semantic report: ${parsed.error.message}`);
|
|
1285
|
+
return parsed.data;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function spawnCodexVerifier(
|
|
1289
|
+
opts: { initialMessage: string; model: string },
|
|
1290
|
+
mcpArgs: string[],
|
|
1291
|
+
): ChildProcess {
|
|
1292
|
+
const args = [
|
|
1293
|
+
'-a',
|
|
1294
|
+
'never',
|
|
1295
|
+
'-C',
|
|
1296
|
+
REPO_ROOT,
|
|
1297
|
+
'-s',
|
|
1298
|
+
'read-only',
|
|
1299
|
+
'-m',
|
|
1300
|
+
opts.model,
|
|
1301
|
+
'-c',
|
|
1302
|
+
`mcp_servers.${MCP_SERVER_NAME}.command=${JSON.stringify(process.execPath)}`,
|
|
1303
|
+
'-c',
|
|
1304
|
+
`mcp_servers.${MCP_SERVER_NAME}.args=${JSON.stringify(mcpArgs)}`,
|
|
1305
|
+
'-c',
|
|
1306
|
+
`mcp_servers.${MCP_SERVER_NAME}.default_tools_approval_mode=${JSON.stringify('approve')}`,
|
|
1307
|
+
'-c',
|
|
1308
|
+
`mcp_servers.${MCP_SERVER_NAME}.tool_timeout_sec=600`,
|
|
1309
|
+
'-c',
|
|
1310
|
+
`mcp_servers.${MCP_SERVER_NAME}.required=true`,
|
|
1311
|
+
'-c',
|
|
1312
|
+
'shell_environment_policy.inherit=all',
|
|
1313
|
+
'exec',
|
|
1314
|
+
'--json',
|
|
1315
|
+
'--ephemeral',
|
|
1316
|
+
'--disable',
|
|
1317
|
+
'plugins',
|
|
1318
|
+
'--ignore-user-config',
|
|
1319
|
+
'--ignore-rules',
|
|
1320
|
+
'--skip-git-repo-check',
|
|
1321
|
+
'-',
|
|
1322
|
+
];
|
|
1323
|
+
const child = spawn('codex', args, {
|
|
1324
|
+
cwd: REPO_ROOT,
|
|
1325
|
+
detached: true,
|
|
1326
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1327
|
+
});
|
|
1328
|
+
child.stdin?.end(
|
|
1329
|
+
`<system_instructions>\n${readFileSync(SYSTEM_PROMPT_PATH, 'utf8')}\n</system_instructions>\n\n${opts.initialMessage}`,
|
|
1330
|
+
);
|
|
1331
|
+
return child;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function spawnClaudeVerifier(
|
|
1335
|
+
opts: { initialMessage: string; model: string },
|
|
1336
|
+
mcpArgs: string[],
|
|
1337
|
+
): ChildProcess {
|
|
1338
|
+
const mcpConfig = {
|
|
1339
|
+
mcpServers: {
|
|
1340
|
+
[MCP_SERVER_NAME]: {
|
|
1341
|
+
command: process.execPath,
|
|
1342
|
+
args: mcpArgs,
|
|
1343
|
+
alwaysLoad: true,
|
|
1344
|
+
},
|
|
1345
|
+
},
|
|
1346
|
+
};
|
|
1347
|
+
const args = [
|
|
1348
|
+
'--print',
|
|
1349
|
+
'--output-format',
|
|
1350
|
+
'stream-json',
|
|
1351
|
+
'--verbose',
|
|
1352
|
+
'--strict-mcp-config',
|
|
1353
|
+
'--mcp-config',
|
|
1354
|
+
JSON.stringify(mcpConfig),
|
|
1355
|
+
'--system-prompt-file',
|
|
1356
|
+
SYSTEM_PROMPT_PATH,
|
|
1357
|
+
'--tools',
|
|
1358
|
+
'',
|
|
1359
|
+
'--allowedTools',
|
|
1360
|
+
`mcp__${MCP_SERVER_NAME}__prepare_live_backend`,
|
|
1361
|
+
'--allowedTools',
|
|
1362
|
+
`mcp__${MCP_SERVER_NAME}__run_live_integration_suite`,
|
|
1363
|
+
'--allowedTools',
|
|
1364
|
+
`mcp__${MCP_SERVER_NAME}__run_live_integration_test`,
|
|
1365
|
+
'--allowedTools',
|
|
1366
|
+
`mcp__${MCP_SERVER_NAME}__submit_verification_report`,
|
|
1367
|
+
'--max-turns',
|
|
1368
|
+
'12',
|
|
1369
|
+
'--permission-mode',
|
|
1370
|
+
'bypassPermissions',
|
|
1371
|
+
'--no-session-persistence',
|
|
1372
|
+
'--disable-slash-commands',
|
|
1373
|
+
'--model',
|
|
1374
|
+
opts.model,
|
|
1375
|
+
opts.initialMessage,
|
|
1376
|
+
];
|
|
1377
|
+
return spawn('claude', args, {
|
|
1378
|
+
cwd: REPO_ROOT,
|
|
1379
|
+
detached: true,
|
|
1380
|
+
env: {
|
|
1381
|
+
...process.env,
|
|
1382
|
+
MCP_TOOL_TIMEOUT: process.env.MCP_TOOL_TIMEOUT ?? '600000',
|
|
1383
|
+
},
|
|
1384
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function waitForChild(
|
|
1389
|
+
child: ChildProcess,
|
|
1390
|
+
deadlineMs: number,
|
|
1391
|
+
logPath: string,
|
|
1392
|
+
attempt: number,
|
|
1393
|
+
logSinceMs: number,
|
|
1394
|
+
): Promise<{ exitCode: number | null; stderr: string }> {
|
|
1395
|
+
let stderr = '';
|
|
1396
|
+
// Always drain both pipes. CLI JSON streams can exceed the OS pipe buffer;
|
|
1397
|
+
// leaving stdout unread would deadlock an otherwise completed verifier.
|
|
1398
|
+
child.stdout?.on('data', (chunk) => {
|
|
1399
|
+
appendLiveVerifierLog(logPath, {
|
|
1400
|
+
type: 'provider.stdout',
|
|
1401
|
+
attempt,
|
|
1402
|
+
chunk: boundedTail(String(chunk), 8_000),
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
child.stderr?.on('data', (chunk) => {
|
|
1406
|
+
stderr = `${stderr}${String(chunk)}`.slice(-8000);
|
|
1407
|
+
appendLiveVerifierLog(logPath, {
|
|
1408
|
+
type: 'provider.stderr',
|
|
1409
|
+
attempt,
|
|
1410
|
+
chunk: boundedTail(String(chunk), 8_000),
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
return await new Promise((resolve, reject) => {
|
|
1414
|
+
let timedOut = false;
|
|
1415
|
+
let forceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1416
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1417
|
+
const clearTimers = (): void => {
|
|
1418
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
1419
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
1420
|
+
};
|
|
1421
|
+
const timeOut = (): void => {
|
|
1422
|
+
timedOut = true;
|
|
1423
|
+
const preparationCreditMs = backendPreparationDeadlineCreditMs(
|
|
1424
|
+
logPath,
|
|
1425
|
+
logSinceMs,
|
|
1426
|
+
Date.now(),
|
|
1427
|
+
);
|
|
1428
|
+
appendLiveVerifierLog(logPath, {
|
|
1429
|
+
type: 'provider.timeout',
|
|
1430
|
+
attempt,
|
|
1431
|
+
deadlineMs,
|
|
1432
|
+
preparationCreditMs,
|
|
1433
|
+
});
|
|
1434
|
+
signalVerifierProcessTree(child, 'SIGTERM');
|
|
1435
|
+
forceTimer = setTimeout(() => {
|
|
1436
|
+
signalVerifierProcessTree(child, 'SIGKILL');
|
|
1437
|
+
reject(new Error('semantic verifier timed out'));
|
|
1438
|
+
}, 5_000);
|
|
1439
|
+
forceTimer.unref?.();
|
|
1440
|
+
};
|
|
1441
|
+
const checkDeadline = (): void => {
|
|
1442
|
+
const now = Date.now();
|
|
1443
|
+
const preparationCreditMs = backendPreparationDeadlineCreditMs(logPath, logSinceMs, now);
|
|
1444
|
+
const remainingMs = deadlineMs + preparationCreditMs - now;
|
|
1445
|
+
if (remainingMs <= 0) {
|
|
1446
|
+
timeOut();
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
// Recheck periodically because preparation activity is written by the
|
|
1450
|
+
// verifier MCP subprocess while this parent is waiting on the CLI.
|
|
1451
|
+
deadlineTimer = setTimeout(checkDeadline, Math.min(remainingMs, 500));
|
|
1452
|
+
deadlineTimer.unref?.();
|
|
1453
|
+
};
|
|
1454
|
+
checkDeadline();
|
|
1455
|
+
child.once('error', (error) => {
|
|
1456
|
+
clearTimers();
|
|
1457
|
+
signalVerifierProcessTree(child, 'SIGKILL');
|
|
1458
|
+
reject(error);
|
|
1459
|
+
});
|
|
1460
|
+
child.once('close', (code) => {
|
|
1461
|
+
clearTimers();
|
|
1462
|
+
// Codex/Claude can exit while an MCP tool call is still in flight. Their
|
|
1463
|
+
// stdio MCP child (and a Chrome it launched) remains in the detached
|
|
1464
|
+
// verifier process group unless we explicitly reap the group here. A
|
|
1465
|
+
// retry must never start a second probe beside an orphaned first probe.
|
|
1466
|
+
signalVerifierProcessTree(child, 'SIGKILL');
|
|
1467
|
+
appendLiveVerifierLog(logPath, {
|
|
1468
|
+
type: 'provider.closed',
|
|
1469
|
+
attempt,
|
|
1470
|
+
exitCode: code,
|
|
1471
|
+
});
|
|
1472
|
+
if (timedOut) reject(new Error('semantic verifier timed out'));
|
|
1473
|
+
else resolve({ exitCode: code, stderr });
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
export function backendPreparationDeadlineCreditMs(
|
|
1479
|
+
logPath: string,
|
|
1480
|
+
sinceMs: number,
|
|
1481
|
+
nowMs = Date.now(),
|
|
1482
|
+
): number {
|
|
1483
|
+
if (!existsSync(logPath)) return 0;
|
|
1484
|
+
const preparations = new Map<
|
|
1485
|
+
string,
|
|
1486
|
+
{ startedAtMs: number; durationMs?: number; reusedCache?: boolean }
|
|
1487
|
+
>();
|
|
1488
|
+
for (const line of readFileSync(logPath, 'utf8').split('\n')) {
|
|
1489
|
+
if (!line.trim()) continue;
|
|
1490
|
+
let event: Record<string, unknown>;
|
|
1491
|
+
try {
|
|
1492
|
+
event = JSON.parse(line) as Record<string, unknown>;
|
|
1493
|
+
} catch {
|
|
1494
|
+
// The append-only log can end in a partial line while the MCP process is writing.
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
const timestampMs =
|
|
1498
|
+
typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : Number.NaN;
|
|
1499
|
+
if (!Number.isFinite(timestampMs) || timestampMs < sinceMs || typeof event.label !== 'string') {
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
if (event.type === 'backend.prepare.started') {
|
|
1503
|
+
preparations.set(event.label, { startedAtMs: timestampMs });
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
if (event.type !== 'backend.prepare.completed' && event.type !== 'backend.prepare.failed') {
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
const preparation = preparations.get(event.label);
|
|
1510
|
+
if (!preparation) continue;
|
|
1511
|
+
preparation.durationMs =
|
|
1512
|
+
typeof event.durationMs === 'number'
|
|
1513
|
+
? event.durationMs
|
|
1514
|
+
: Math.max(0, timestampMs - preparation.startedAtMs);
|
|
1515
|
+
preparation.reusedCache = event.reusedCache === true;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
return [...preparations.values()]
|
|
1519
|
+
.sort((a, b) => a.startedAtMs - b.startedAtMs)
|
|
1520
|
+
.filter((preparation) => !preparation.reusedCache)
|
|
1521
|
+
.slice(0, MAX_CREDITED_BACKEND_PREPARATIONS)
|
|
1522
|
+
.reduce(
|
|
1523
|
+
(total, preparation) =>
|
|
1524
|
+
total +
|
|
1525
|
+
Math.min(
|
|
1526
|
+
Math.max(0, preparation.durationMs ?? nowMs - preparation.startedAtMs),
|
|
1527
|
+
BACKEND_PREPARATION_BUDGET_MS,
|
|
1528
|
+
),
|
|
1529
|
+
0,
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
export async function waitForOwnedProcessTree(
|
|
1534
|
+
child: ChildProcess,
|
|
1535
|
+
timeoutMs: number,
|
|
1536
|
+
shutdownGraceMs: number,
|
|
1537
|
+
): Promise<{ exitCode: number | null; timedOut: boolean }> {
|
|
1538
|
+
return await new Promise((resolve, reject) => {
|
|
1539
|
+
let settled = false;
|
|
1540
|
+
let forceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1541
|
+
const finish = (value: {
|
|
1542
|
+
exitCode: number | null;
|
|
1543
|
+
timedOut: boolean;
|
|
1544
|
+
}): void => {
|
|
1545
|
+
if (settled) return;
|
|
1546
|
+
settled = true;
|
|
1547
|
+
clearTimeout(timer);
|
|
1548
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
1549
|
+
resolve(value);
|
|
1550
|
+
};
|
|
1551
|
+
const timer = setTimeout(() => {
|
|
1552
|
+
signalVerifierProcessTree(child, 'SIGTERM');
|
|
1553
|
+
forceTimer = setTimeout(() => {
|
|
1554
|
+
signalVerifierProcessTree(child, 'SIGKILL');
|
|
1555
|
+
finish({ exitCode: child.exitCode, timedOut: true });
|
|
1556
|
+
}, shutdownGraceMs);
|
|
1557
|
+
forceTimer.unref?.();
|
|
1558
|
+
}, timeoutMs);
|
|
1559
|
+
child.once('error', (error) => {
|
|
1560
|
+
if (settled) return;
|
|
1561
|
+
settled = true;
|
|
1562
|
+
clearTimeout(timer);
|
|
1563
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
1564
|
+
reject(error);
|
|
1565
|
+
});
|
|
1566
|
+
child.once('close', (code) => {
|
|
1567
|
+
const timedOut = forceTimer !== undefined;
|
|
1568
|
+
// Reap any browser/crashpad descendants that survived the worker itself.
|
|
1569
|
+
signalVerifierProcessTree(child, timedOut ? 'SIGKILL' : 'SIGTERM');
|
|
1570
|
+
finish({ exitCode: code, timedOut });
|
|
1571
|
+
});
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
export function registerOwnedProcessTreeCleanup(
|
|
1576
|
+
child: ChildProcess,
|
|
1577
|
+
shutdownGraceMs = BACKEND_PROBE_SHUTDOWN_GRACE_MS,
|
|
1578
|
+
): () => void {
|
|
1579
|
+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
|
|
1580
|
+
const onExit = (): void => signalVerifierProcessTree(child, 'SIGKILL');
|
|
1581
|
+
const signalHandlers = new Map<NodeJS.Signals, () => void>();
|
|
1582
|
+
const forceTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
1583
|
+
let shutdownRequested = false;
|
|
1584
|
+
const hadExistingHandler = new Map(
|
|
1585
|
+
signals.map((signal) => [signal, process.listenerCount(signal) > 0]),
|
|
1586
|
+
);
|
|
1587
|
+
const unregisterSignals = (): void => {
|
|
1588
|
+
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
|
1589
|
+
};
|
|
1590
|
+
const unregister = (): void => {
|
|
1591
|
+
unregisterSignals();
|
|
1592
|
+
// Once parent shutdown has begun, the direct worker can exit before a
|
|
1593
|
+
// browser or other detached descendant in its process group. Preserve the
|
|
1594
|
+
// scheduled escalation and exit fallback in that case. Normal completion
|
|
1595
|
+
// still removes every listener and timer immediately.
|
|
1596
|
+
if (shutdownRequested) return;
|
|
1597
|
+
process.removeListener('exit', onExit);
|
|
1598
|
+
for (const timer of forceTimers) clearTimeout(timer);
|
|
1599
|
+
forceTimers.clear();
|
|
1600
|
+
};
|
|
1601
|
+
process.once('exit', onExit);
|
|
1602
|
+
for (const signal of signals) {
|
|
1603
|
+
const handler = (): void => {
|
|
1604
|
+
shutdownRequested = true;
|
|
1605
|
+
unregisterSignals();
|
|
1606
|
+
signalVerifierProcessTree(child, signal);
|
|
1607
|
+
const forceTimer = setTimeout(() => {
|
|
1608
|
+
forceTimers.delete(forceTimer);
|
|
1609
|
+
signalVerifierProcessTree(child, 'SIGKILL');
|
|
1610
|
+
if (forceTimers.size === 0) process.removeListener('exit', onExit);
|
|
1611
|
+
}, shutdownGraceMs);
|
|
1612
|
+
forceTimers.add(forceTimer);
|
|
1613
|
+
forceTimer.unref?.();
|
|
1614
|
+
// Let an existing teach/compile signal handler perform its own graceful
|
|
1615
|
+
// shutdown. If this verifier is running standalone, restore the default
|
|
1616
|
+
// behavior so installing cleanup does not accidentally swallow SIGINT.
|
|
1617
|
+
if (!hadExistingHandler.get(signal)) process.kill(process.pid, signal);
|
|
1618
|
+
};
|
|
1619
|
+
signalHandlers.set(signal, handler);
|
|
1620
|
+
process.once(signal, handler);
|
|
1621
|
+
}
|
|
1622
|
+
return unregister;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function signalVerifierProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
|
|
1626
|
+
if (child.pid !== undefined) {
|
|
1627
|
+
try {
|
|
1628
|
+
process.kill(-child.pid, signal);
|
|
1629
|
+
return;
|
|
1630
|
+
} catch {
|
|
1631
|
+
// Fall back to the direct child when process groups are unavailable.
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
child.kill(signal);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
export function semanticVerificationFailures(report: LiveVerificationReport): string[] {
|
|
1638
|
+
if (report.status === 'approved' || report.status === 'approved_with_gaps') return [];
|
|
1639
|
+
if (report.status === 'inconclusive') {
|
|
1640
|
+
return [
|
|
1641
|
+
`independent live semantic verification was inconclusive: ${report.summary}\nBaseline: ${report.baseline.reason}\nGaps: ${report.gaps.join('; ') || 'none'}\nEvidence: ${report.evidenceArtifact ?? LIVE_VERIFICATION_EVIDENCE_FILE}\nVerifier log: ${report.logArtifact ?? LIVE_VERIFIER_LOG_FILE}`,
|
|
1642
|
+
];
|
|
1643
|
+
}
|
|
1644
|
+
return report.issues.map(
|
|
1645
|
+
(issue) =>
|
|
1646
|
+
`semantic verification: ${issue.summary}\nExpected: ${issue.expected}\nObserved: ${issue.observed}\nSuggested fix: ${issue.suggestedFix}\nEvidence: ${report.evidenceArtifact ?? LIVE_VERIFICATION_EVIDENCE_FILE}\nVerifier log: ${report.logArtifact ?? LIVE_VERIFIER_LOG_FILE}`,
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
export function mergeSemanticParamVerification(
|
|
1651
|
+
mechanical: ParamVerification[],
|
|
1652
|
+
report: LiveVerificationReport,
|
|
1653
|
+
): ParamVerification[] {
|
|
1654
|
+
const byName = new Map(mechanical.map((item) => [item.name, item]));
|
|
1655
|
+
for (const parameter of report.parameters) {
|
|
1656
|
+
const current = byName.get(parameter.name);
|
|
1657
|
+
byName.set(parameter.name, {
|
|
1658
|
+
...current,
|
|
1659
|
+
name: parameter.name,
|
|
1660
|
+
verified: parameter.verdict === 'works',
|
|
1661
|
+
reason: parameter.verdict === 'works' ? undefined : 'semantic-gap',
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
return [...byName.values()];
|
|
1665
|
+
}
|