@tea-agent/loop-agent 0.35.0 → 0.35.1-beta.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/AGENTS.md +2 -0
- package/CHANGELOG.md +20 -0
- package/bin/loop-agent.js +37 -1
- package/dist/build-stamp.json +6 -0
- package/dist/cli/program.js +2 -2
- package/dist/shared/package-metadata.js +32 -0
- package/dist/worker/loop-agent/loop-agent-client.js +13 -0
- package/dist/workflows/dag/contract-output-registry.js +14 -0
- package/dist/workflows/dag/contract-validator-registrations.js +8 -0
- package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
- package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
- package/dist/workflows/dag/frontend-repair.js +219 -18
- package/dist/workflows/dag/frontend-verification-trace.js +47 -32
- package/dist/workflows/dag/init-hybrid.js +41 -24
- package/dist/workflows/dag/node-execution.js +89 -0
- package/dist/workflows/dag/runner.js +52 -3
- package/dist/workflows/dag/scheduler.js +98 -3
- package/dist/workflows/dag/types.js +23 -2
- package/package.json +2 -2
|
@@ -20,6 +20,14 @@ export const frontendRepairFailureClassSchema = z.enum([
|
|
|
20
20
|
"spec-unclear",
|
|
21
21
|
"unknown",
|
|
22
22
|
]);
|
|
23
|
+
export const frontendCommandCategorySchema = z.enum([
|
|
24
|
+
"typecheck",
|
|
25
|
+
"build",
|
|
26
|
+
"lint",
|
|
27
|
+
"component-test",
|
|
28
|
+
"unit-test",
|
|
29
|
+
"trace",
|
|
30
|
+
]);
|
|
23
31
|
const REPAIRABLE = new Set([
|
|
24
32
|
"typecheck",
|
|
25
33
|
"build",
|
|
@@ -27,12 +35,72 @@ const REPAIRABLE = new Set([
|
|
|
27
35
|
"component-test",
|
|
28
36
|
"unit-test",
|
|
29
37
|
"trace",
|
|
30
|
-
"review",
|
|
31
38
|
]);
|
|
32
39
|
export function isFrontendRepairable(failureClass) {
|
|
33
40
|
return REPAIRABLE.has(failureClass);
|
|
34
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Deterministic commandLabel → commandCategory mapping (AC-3).
|
|
44
|
+
* `targetType` comes from the contract verificationTargets[].type
|
|
45
|
+
* (static | unit | component | integration | mock); the literal commandLabel
|
|
46
|
+
* wins over type so a `static` typecheck target is never misread as a test.
|
|
47
|
+
*/
|
|
48
|
+
export function mapCommandLabelToCategory(commandLabel, targetType) {
|
|
49
|
+
const label = commandLabel.toLowerCase();
|
|
50
|
+
if (/(?:typecheck|check-types|type-check|\btsc\b)/.test(label))
|
|
51
|
+
return "typecheck";
|
|
52
|
+
if (/(?:vite\s+build|next\s+build|webpack|rollup|\bbuild\b)/.test(label))
|
|
53
|
+
return "build";
|
|
54
|
+
if (/(?:eslint|\blint\b)/.test(label))
|
|
55
|
+
return "lint";
|
|
56
|
+
if (targetType === "component")
|
|
57
|
+
return "component-test";
|
|
58
|
+
if (targetType === "unit")
|
|
59
|
+
return "unit-test";
|
|
60
|
+
if (/(?:component|testing-library|render\()/.test(label))
|
|
61
|
+
return "component-test";
|
|
62
|
+
return "unit-test";
|
|
63
|
+
}
|
|
64
|
+
export function isReviewRepairable(finding, implementWriteSet) {
|
|
65
|
+
const hasFile = Boolean(finding.file && finding.file.trim().length > 0);
|
|
66
|
+
const hasLocation = Boolean((finding.line !== undefined && finding.line > 0) ||
|
|
67
|
+
(finding.symbol && finding.symbol.trim().length > 0));
|
|
68
|
+
const hasRequirement = Boolean((finding.requirementId && finding.requirementId.trim().length > 0) ||
|
|
69
|
+
(finding.acceptanceCriteriaId &&
|
|
70
|
+
finding.acceptanceCriteriaId.trim().length > 0));
|
|
71
|
+
const fixScope = finding.fixScope ?? (hasFile ? [finding.file] : []);
|
|
72
|
+
const fixScopeSubset = fixScope.length > 0 &&
|
|
73
|
+
fixScope.every((entry) => isPathWithinWriteSet(entry, implementWriteSet));
|
|
74
|
+
return (hasFile &&
|
|
75
|
+
hasLocation &&
|
|
76
|
+
hasRequirement &&
|
|
77
|
+
fixScopeSubset &&
|
|
78
|
+
finding.requiresContractReplan !== true);
|
|
79
|
+
}
|
|
35
80
|
export function classifyFrontendFailure(input) {
|
|
81
|
+
// AC-2: structured evidence takes priority over string-blob heuristics.
|
|
82
|
+
if (input.failureCategory === "write-guard") {
|
|
83
|
+
return "path";
|
|
84
|
+
}
|
|
85
|
+
if (input.traceTarget || input.commandCategory === "trace") {
|
|
86
|
+
return "trace";
|
|
87
|
+
}
|
|
88
|
+
if (input.commandCategory) {
|
|
89
|
+
switch (input.commandCategory) {
|
|
90
|
+
case "typecheck":
|
|
91
|
+
return "typecheck";
|
|
92
|
+
case "build":
|
|
93
|
+
return "build";
|
|
94
|
+
case "lint":
|
|
95
|
+
return "lint";
|
|
96
|
+
case "component-test":
|
|
97
|
+
return "component-test";
|
|
98
|
+
case "unit-test":
|
|
99
|
+
return "unit-test";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Fallback: string-blob heuristic, preserved in its original branch order so
|
|
103
|
+
// legacy assertions (error TS2304 → typecheck, write-guard → path, etc.) stay green.
|
|
36
104
|
const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
|
|
37
105
|
if (input.nodeId.includes("contract") ||
|
|
38
106
|
blob.includes("contract mismatch") ||
|
|
@@ -120,6 +188,23 @@ export const frontendRepairAssessmentSchema = z
|
|
|
120
188
|
allowedRepairWriteSet: z.array(z.string().min(1)),
|
|
121
189
|
evidenceRefs: z.array(z.string().min(1)),
|
|
122
190
|
browserStatus: z.literal("not-run"),
|
|
191
|
+
// AC-5 structured evidence fields (all optional so existing minimal
|
|
192
|
+
// fixtures and frontend-review-context safeParse stay compatible).
|
|
193
|
+
verificationTargetId: z.string().min(1).optional(),
|
|
194
|
+
commandLabel: z.string().min(1).optional(),
|
|
195
|
+
exitCode: z.number().int().optional(),
|
|
196
|
+
commandCategory: frontendCommandCategorySchema.optional(),
|
|
197
|
+
requirementIds: z.array(z.string().min(1)).optional(),
|
|
198
|
+
acceptanceCriteriaIds: z.array(z.string().min(1)).optional(),
|
|
199
|
+
fixScope: z.array(z.string().min(1)).optional(),
|
|
200
|
+
changedPaths: z.array(z.string().min(1)).optional(),
|
|
201
|
+
traceRef: z
|
|
202
|
+
.object({
|
|
203
|
+
relativePath: z.string().min(1),
|
|
204
|
+
schemaId: z.string().min(1),
|
|
205
|
+
})
|
|
206
|
+
.strict()
|
|
207
|
+
.optional(),
|
|
123
208
|
})
|
|
124
209
|
.strict();
|
|
125
210
|
function nodeHadFailure(record) {
|
|
@@ -135,19 +220,24 @@ function nodeHadFailure(record) {
|
|
|
135
220
|
function normalize(value) {
|
|
136
221
|
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
137
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Whether a concrete path or glob entry is contained by the implement writeSet.
|
|
225
|
+
* Exact glob equality is the norm (repair writeSet === implement writeSet);
|
|
226
|
+
* concrete (non-glob) paths may also be covered by an implement glob.
|
|
227
|
+
*/
|
|
228
|
+
export function isPathWithinWriteSet(entry, implementWriteSet) {
|
|
229
|
+
const normalizedEntry = normalize(entry);
|
|
230
|
+
const normalizedImplement = implementWriteSet.map(normalize);
|
|
231
|
+
if (normalizedImplement.includes(normalizedEntry))
|
|
232
|
+
return true;
|
|
233
|
+
if (!normalizedEntry.includes("*")) {
|
|
234
|
+
return implementWriteSet.some((pattern) => pathMatchesPattern(normalizedEntry, pattern));
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
138
238
|
export function assertWriteSetSubset(repairWriteSet, implementWriteSet) {
|
|
139
239
|
for (const entry of repairWriteSet) {
|
|
140
|
-
|
|
141
|
-
pathMatchesPattern(normalize(entry), pattern) ||
|
|
142
|
-
pathMatchesPattern(normalize(pattern), entry));
|
|
143
|
-
// exact equality preferred for generation-time copy
|
|
144
|
-
if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
|
|
145
|
-
// allow identical globs only
|
|
146
|
-
if (!ok || normalize(entry) !== normalize(entry)) {
|
|
147
|
-
// require exact membership for exclusive writeSet entries
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
|
|
240
|
+
if (!isPathWithinWriteSet(entry, implementWriteSet)) {
|
|
151
241
|
throw new Error(`repair writeSet entry "${entry}" is not ⊆ implement writeSet`);
|
|
152
242
|
}
|
|
153
243
|
}
|
|
@@ -170,6 +260,63 @@ async function loadImplementWriteSet(runDir, fallback) {
|
|
|
170
260
|
}
|
|
171
261
|
return fallback;
|
|
172
262
|
}
|
|
263
|
+
function matchVerificationTarget(contract, commandLabel, command) {
|
|
264
|
+
for (const target of contract.verificationTargets) {
|
|
265
|
+
if (commandLabel && target.commandLabel === commandLabel)
|
|
266
|
+
return target;
|
|
267
|
+
if (command &&
|
|
268
|
+
(command.includes(target.commandLabel) ||
|
|
269
|
+
target.commandLabel.includes(command))) {
|
|
270
|
+
return target;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
function deriveFailureEvidence(input) {
|
|
276
|
+
const evidence = {};
|
|
277
|
+
for (const { record } of input.failed) {
|
|
278
|
+
const results = record.commandResults ?? [];
|
|
279
|
+
const failedResult = results.find((item) => item.ok === false) ?? results[0];
|
|
280
|
+
if (failedResult &&
|
|
281
|
+
evidence.exitCode === undefined &&
|
|
282
|
+
typeof failedResult.exitCode === "number") {
|
|
283
|
+
evidence.exitCode = failedResult.exitCode;
|
|
284
|
+
}
|
|
285
|
+
if (failedResult?.commandLabel && !evidence.commandLabel) {
|
|
286
|
+
evidence.commandLabel = failedResult.commandLabel;
|
|
287
|
+
}
|
|
288
|
+
// Structured trace facts passed by the verification bundle / trace gate.
|
|
289
|
+
const failedTraceTarget = (record.traceTargets ?? []).find((target) => target.status === "failed");
|
|
290
|
+
if (failedTraceTarget && !evidence.verificationTargetId) {
|
|
291
|
+
evidence.verificationTargetId = failedTraceTarget.id;
|
|
292
|
+
if (!evidence.commandLabel)
|
|
293
|
+
evidence.commandLabel = failedTraceTarget.commandLabel;
|
|
294
|
+
evidence.traceTarget = failedTraceTarget;
|
|
295
|
+
evidence.traceRef = {
|
|
296
|
+
relativePath: "contracts/frontend-verification-trace.json",
|
|
297
|
+
schemaId: "frontend-verification-trace-v1",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (record.changedPaths?.length && !evidence.changedPaths) {
|
|
301
|
+
evidence.changedPaths = [...record.changedPaths];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Resolve commandLabel → contract verification target → category + scope.
|
|
305
|
+
const target = matchVerificationTarget(input.contract, evidence.commandLabel);
|
|
306
|
+
if (target) {
|
|
307
|
+
if (!evidence.verificationTargetId)
|
|
308
|
+
evidence.verificationTargetId = target.id;
|
|
309
|
+
if (!evidence.commandLabel)
|
|
310
|
+
evidence.commandLabel = target.commandLabel;
|
|
311
|
+
evidence.requirementIds ??= [...target.requirementIds];
|
|
312
|
+
evidence.acceptanceCriteriaIds ??= [...target.requirementIds];
|
|
313
|
+
evidence.fixScope ??= [target.file];
|
|
314
|
+
}
|
|
315
|
+
if (evidence.commandLabel) {
|
|
316
|
+
evidence.commandCategory ??= mapCommandLabelToCategory(evidence.commandLabel, target?.type);
|
|
317
|
+
}
|
|
318
|
+
return evidence;
|
|
319
|
+
}
|
|
173
320
|
export async function runFrontendFailureAssessGate(input) {
|
|
174
321
|
const attempt = input.attempt ?? 1;
|
|
175
322
|
const implementWriteSet = await loadImplementWriteSet(input.runDir, input.implementWriteSet ?? []);
|
|
@@ -179,12 +326,14 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
179
326
|
const contractRel = "contracts/frontend-implementation-contract.json";
|
|
180
327
|
const contractPath = path.join(input.runDir, contractRel);
|
|
181
328
|
let contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
|
|
329
|
+
let contract;
|
|
182
330
|
try {
|
|
183
331
|
const raw = JSON.parse(await readFile(contractPath, "utf8"));
|
|
184
332
|
const parsed = frontendImplementationContractSchema.safeParse(raw);
|
|
185
333
|
if (!parsed.success) {
|
|
186
334
|
throw new Error("invalid frontend implementation contract");
|
|
187
335
|
}
|
|
336
|
+
contract = parsed.data;
|
|
188
337
|
contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
|
|
189
338
|
}
|
|
190
339
|
catch {
|
|
@@ -218,7 +367,6 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
218
367
|
// missing optional nodes ok
|
|
219
368
|
}
|
|
220
369
|
}
|
|
221
|
-
assertWriteSetSubset(implementWriteSet, implementWriteSet);
|
|
222
370
|
if (failed.length === 0) {
|
|
223
371
|
const assessment = {
|
|
224
372
|
schemaVersion: 1,
|
|
@@ -241,16 +389,49 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
241
389
|
return assessment;
|
|
242
390
|
}
|
|
243
391
|
const primary = failed[0];
|
|
392
|
+
const evidence = deriveFailureEvidence({ contract, failed });
|
|
244
393
|
const failureClass = classifyFrontendFailure({
|
|
245
394
|
nodeId: primary.nodeId,
|
|
246
395
|
failureCategory: primary.record.failureCategory,
|
|
247
396
|
stdout: primary.record.stdout,
|
|
248
397
|
stderr: primary.record.stderr,
|
|
398
|
+
commandLabel: evidence.commandLabel,
|
|
399
|
+
commandCategory: evidence.commandCategory,
|
|
400
|
+
exitCode: evidence.exitCode,
|
|
401
|
+
traceTarget: evidence.traceTarget ?? null,
|
|
402
|
+
changedPaths: evidence.changedPaths,
|
|
249
403
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
404
|
+
// AC-5: repairable=true requires structured evidence (a resolved exitCode,
|
|
405
|
+
// commandCategory, or trace-bound verification target). review has its own
|
|
406
|
+
// stricter gate (AC-4) and is never unconditionally repairable.
|
|
407
|
+
const baseEligible = isFrontendRepairable(failureClass) && attempt <= 1;
|
|
408
|
+
let eligible;
|
|
409
|
+
let reason;
|
|
410
|
+
if (failureClass === "review") {
|
|
411
|
+
const finding = primary.record.reviewFinding ?? {};
|
|
412
|
+
eligible = attempt <= 1 && isReviewRepairable(finding, implementWriteSet);
|
|
413
|
+
reason = eligible
|
|
414
|
+
? `repairable review finding on ${primary.nodeId}`
|
|
415
|
+
: `review finding lacks file/line-symbol/requirement evidence or is outside writeSet on ${primary.nodeId}`;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const hasStructuredEvidence = evidence.exitCode !== undefined ||
|
|
419
|
+
evidence.commandCategory !== undefined ||
|
|
420
|
+
Boolean(evidence.verificationTargetId);
|
|
421
|
+
eligible = baseEligible && hasStructuredEvidence;
|
|
422
|
+
reason = eligible
|
|
423
|
+
? `repairable failureClass=${failureClass} on ${primary.nodeId}`
|
|
424
|
+
: !baseEligible
|
|
425
|
+
? `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`
|
|
426
|
+
: `missing structured evidence for failureClass=${failureClass} on ${primary.nodeId}`;
|
|
427
|
+
}
|
|
428
|
+
// fixScope and changedPaths must stay inside the implement writeSet.
|
|
429
|
+
if (evidence.fixScope?.length) {
|
|
430
|
+
assertWriteSetSubset(evidence.fixScope, implementWriteSet);
|
|
431
|
+
}
|
|
432
|
+
if (evidence.changedPaths?.length) {
|
|
433
|
+
assertWriteSetSubset(evidence.changedPaths, implementWriteSet);
|
|
434
|
+
}
|
|
254
435
|
const assessment = {
|
|
255
436
|
schemaVersion: 1,
|
|
256
437
|
schemaId: FRONTEND_REPAIR_ASSESSMENT_SCHEMA_ID,
|
|
@@ -267,6 +448,25 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
267
448
|
allowedRepairWriteSet: [...implementWriteSet],
|
|
268
449
|
evidenceRefs: [contractRel, ...failed.map((item) => `${item.nodeId}.json`)],
|
|
269
450
|
browserStatus: "not-run",
|
|
451
|
+
...(evidence.verificationTargetId
|
|
452
|
+
? { verificationTargetId: evidence.verificationTargetId }
|
|
453
|
+
: {}),
|
|
454
|
+
...(evidence.commandLabel ? { commandLabel: evidence.commandLabel } : {}),
|
|
455
|
+
...(evidence.exitCode !== undefined ? { exitCode: evidence.exitCode } : {}),
|
|
456
|
+
...(evidence.commandCategory
|
|
457
|
+
? { commandCategory: evidence.commandCategory }
|
|
458
|
+
: {}),
|
|
459
|
+
...(evidence.requirementIds?.length
|
|
460
|
+
? { requirementIds: evidence.requirementIds }
|
|
461
|
+
: {}),
|
|
462
|
+
...(evidence.acceptanceCriteriaIds?.length
|
|
463
|
+
? { acceptanceCriteriaIds: evidence.acceptanceCriteriaIds }
|
|
464
|
+
: {}),
|
|
465
|
+
...(evidence.fixScope?.length ? { fixScope: evidence.fixScope } : {}),
|
|
466
|
+
...(evidence.changedPaths?.length
|
|
467
|
+
? { changedPaths: evidence.changedPaths }
|
|
468
|
+
: {}),
|
|
469
|
+
...(evidence.traceRef ? { traceRef: evidence.traceRef } : {}),
|
|
270
470
|
};
|
|
271
471
|
await writeAssessment(input.runDir, assessment);
|
|
272
472
|
return assessment;
|
|
@@ -322,7 +522,8 @@ export async function runFrontendRepairContractGate(input) {
|
|
|
322
522
|
if (!assessment.eligible) {
|
|
323
523
|
throw new Error(`repair-contract: non-repairable failure (${assessment.failureClass}): ${assessment.reason}`);
|
|
324
524
|
}
|
|
325
|
-
if (!isFrontendRepairable(assessment.failureClass)
|
|
525
|
+
if (!isFrontendRepairable(assessment.failureClass) &&
|
|
526
|
+
assessment.failureClass !== "review") {
|
|
326
527
|
throw new Error(`repair-contract: failureClass ${assessment.failureClass} is not repairable`);
|
|
327
528
|
}
|
|
328
529
|
return { ok: true, assessment };
|
|
@@ -40,6 +40,48 @@ function symbolEvidenceCandidates(symbol) {
|
|
|
40
40
|
function isConfigurationVerificationFile(file) {
|
|
41
41
|
return /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|(?:vite|webpack|rollup|docusaurus)\.config\.[cm]?[jt]s)$/.test(file.replace(/\\/g, "/"));
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Pure verification-target evaluation shared by the trace gate and the repair
|
|
45
|
+
* assess gate. It derives, for each contract verification target, whether the
|
|
46
|
+
* target bound to frozen evidence (commandLabel owners), and whether the file
|
|
47
|
+
* and optional symbol exist in the workspace. It performs no writes and throws
|
|
48
|
+
* nothing: failures are returned as `hardIssues` plus per-target `status`.
|
|
49
|
+
*/
|
|
50
|
+
export async function evaluateVerificationTargets(input) {
|
|
51
|
+
const targets = [];
|
|
52
|
+
const hardIssues = [];
|
|
53
|
+
for (const target of input.contract.verificationTargets) {
|
|
54
|
+
const issues = [];
|
|
55
|
+
const owners = input.labelOwners.get(target.commandLabel);
|
|
56
|
+
if (!owners?.length) {
|
|
57
|
+
issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
|
|
58
|
+
}
|
|
59
|
+
const fileIssues = await assertFileAndSymbol({
|
|
60
|
+
workspaceRoot: input.workspaceRoot,
|
|
61
|
+
file: target.file,
|
|
62
|
+
// Configuration files prove that the command's entrypoint exists;
|
|
63
|
+
// they do not expose source symbols. LLMs sometimes derive a
|
|
64
|
+
// filename fragment such as "build" from tsconfig.build.json.
|
|
65
|
+
symbol: isConfigurationVerificationFile(target.file)
|
|
66
|
+
? undefined
|
|
67
|
+
: target.symbol,
|
|
68
|
+
});
|
|
69
|
+
issues.push(...fileIssues);
|
|
70
|
+
const status = issues.length ? "failed" : "ok";
|
|
71
|
+
if (issues.length)
|
|
72
|
+
hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
|
|
73
|
+
targets.push({
|
|
74
|
+
id: target.id,
|
|
75
|
+
commandLabel: target.commandLabel,
|
|
76
|
+
file: target.file,
|
|
77
|
+
symbol: target.symbol,
|
|
78
|
+
status,
|
|
79
|
+
matchedNodeIds: owners ?? [],
|
|
80
|
+
issues,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return { targets, hardIssues };
|
|
84
|
+
}
|
|
43
85
|
async function assertFileAndSymbol(input) {
|
|
44
86
|
const issues = [];
|
|
45
87
|
const absolute = path.resolve(input.workspaceRoot, input.file);
|
|
@@ -175,38 +217,11 @@ export async function runFrontendVerificationTraceGate(input) {
|
|
|
175
217
|
}
|
|
176
218
|
}
|
|
177
219
|
}
|
|
178
|
-
const targets =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!owners?.length) {
|
|
184
|
-
issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
|
|
185
|
-
}
|
|
186
|
-
const fileIssues = await assertFileAndSymbol({
|
|
187
|
-
workspaceRoot: input.workspaceRoot,
|
|
188
|
-
file: target.file,
|
|
189
|
-
// Configuration files prove that the command's entrypoint exists;
|
|
190
|
-
// they do not expose source symbols. LLMs sometimes derive a
|
|
191
|
-
// filename fragment such as "build" from tsconfig.build.json.
|
|
192
|
-
symbol: isConfigurationVerificationFile(target.file)
|
|
193
|
-
? undefined
|
|
194
|
-
: target.symbol,
|
|
195
|
-
});
|
|
196
|
-
issues.push(...fileIssues);
|
|
197
|
-
const status = issues.length ? "failed" : "ok";
|
|
198
|
-
if (issues.length)
|
|
199
|
-
hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
|
|
200
|
-
targets.push({
|
|
201
|
-
id: target.id,
|
|
202
|
-
commandLabel: target.commandLabel,
|
|
203
|
-
file: target.file,
|
|
204
|
-
symbol: target.symbol,
|
|
205
|
-
status,
|
|
206
|
-
matchedNodeIds: owners ?? [],
|
|
207
|
-
issues,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
220
|
+
const { targets, hardIssues } = await evaluateVerificationTargets({
|
|
221
|
+
contract,
|
|
222
|
+
labelOwners,
|
|
223
|
+
workspaceRoot: input.workspaceRoot,
|
|
224
|
+
});
|
|
210
225
|
if (hardIssues.length) {
|
|
211
226
|
throw new Error(`trace: ${hardIssues.join("; ")}`);
|
|
212
227
|
}
|
|
@@ -2199,6 +2199,26 @@ function pruneFrontendTasksForRisk(tasks, risk) {
|
|
|
2199
2199
|
return { ...task, depends_on };
|
|
2200
2200
|
});
|
|
2201
2201
|
}
|
|
2202
|
+
/**
|
|
2203
|
+
* AC-1: shared frontend writer node defaults. frontend-implement-pi and
|
|
2204
|
+
* frontend-repair-pi share one prompt/contract/writeSet-guard surface and an
|
|
2205
|
+
* identical writeSet; only id, depends_on, runIf, outputContract, and
|
|
2206
|
+
* subtask_prompt differ per phase.
|
|
2207
|
+
*/
|
|
2208
|
+
function buildFrontendWriterNodeDefaults(input) {
|
|
2209
|
+
return {
|
|
2210
|
+
role: "implementer",
|
|
2211
|
+
executor: "pi",
|
|
2212
|
+
toolProfile: "write",
|
|
2213
|
+
complexity: input.complexity,
|
|
2214
|
+
writePolicy: "exclusive",
|
|
2215
|
+
writeSet: input.writeSet,
|
|
2216
|
+
allowedPaths: input.allowedPaths,
|
|
2217
|
+
forbiddenPaths: input.forbiddenPaths,
|
|
2218
|
+
skills: FRONTEND_BOUNDED_IMPLEMENT_SKILLS,
|
|
2219
|
+
writerOutcomePolicy: { type: "implementation-outcome-v1" },
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2202
2222
|
async function buildFrontendHybridDagFromTask(sources) {
|
|
2203
2223
|
const { taskConfig } = sources;
|
|
2204
2224
|
const mockCapability = sources.frontendMockCapability ?? {
|
|
@@ -2532,6 +2552,10 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2532
2552
|
writePolicy: "read-only",
|
|
2533
2553
|
outputMode: "structured-required",
|
|
2534
2554
|
retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
|
|
2555
|
+
structuredContractOutput: {
|
|
2556
|
+
schemaId: "frontend-implementation-contract-v1",
|
|
2557
|
+
retryOnInvalid: true,
|
|
2558
|
+
},
|
|
2535
2559
|
allowedPaths: readOnlyPaths,
|
|
2536
2560
|
forbiddenPaths,
|
|
2537
2561
|
skills: FRONTEND_IMPLEMENTATION_SKILLS,
|
|
@@ -2545,6 +2569,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2545
2569
|
"End with exactly one fenced json object conforming to frontend-implementation-contract-v1. This node is the single contract JSON producer: the fenced block is the authoritative contract the prewrite gate materializes. Do not emit any raw JSON, JSON in prose, or a second fenced block anywhere in the response; the plan text must not contain other balanced JSON objects.",
|
|
2546
2570
|
"Each requirement must state its user-observable or logic-observable expectedOutcome. Each interaction must state its trigger and expectedBehavior. IDs plus file paths are not sufficient behavior semantics.",
|
|
2547
2571
|
requirementCoverageInstruction,
|
|
2572
|
+
"verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
|
|
2548
2573
|
"Read-only: do not modify code, docs, artifacts, or repository files.",
|
|
2549
2574
|
fixedVerificationContext,
|
|
2550
2575
|
sourceContext,
|
|
@@ -2584,6 +2609,10 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2584
2609
|
writePolicy: "read-only",
|
|
2585
2610
|
outputMode: "structured-required",
|
|
2586
2611
|
retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
|
|
2612
|
+
structuredContractOutput: {
|
|
2613
|
+
schemaId: "frontend-implementation-contract-v1",
|
|
2614
|
+
retryOnInvalid: true,
|
|
2615
|
+
},
|
|
2587
2616
|
allowedPaths: readOnlyPaths,
|
|
2588
2617
|
forbiddenPaths,
|
|
2589
2618
|
skills: FRONTEND_IMPLEMENTATION_SKILLS,
|
|
@@ -2716,18 +2745,12 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2716
2745
|
? ["frontend-lint-baseline-shell"]
|
|
2717
2746
|
: []),
|
|
2718
2747
|
],
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
allowedPaths: implementPaths.allowedPaths,
|
|
2726
|
-
forbiddenPaths,
|
|
2727
|
-
skills: FRONTEND_BOUNDED_IMPLEMENT_SKILLS,
|
|
2728
|
-
writerOutcomePolicy: {
|
|
2729
|
-
type: "implementation-outcome-v1",
|
|
2730
|
-
},
|
|
2748
|
+
...buildFrontendWriterNodeDefaults({
|
|
2749
|
+
complexity: resolveWriterComplexity(taskConfig),
|
|
2750
|
+
writeSet: implementPaths.writeSet,
|
|
2751
|
+
allowedPaths: implementPaths.allowedPaths,
|
|
2752
|
+
forbiddenPaths,
|
|
2753
|
+
}),
|
|
2731
2754
|
outputContract: "First non-empty line must be exactly one of: IMPLEMENTATION_OUTCOME: changed; IMPLEMENTATION_OUTCOME: already-satisfied; IMPLEMENTATION_OUTCOME: blocked. Then a Markdown delivery summary with Contract Ref (path/schema/hash), Changed Files, Requirements Implemented, UI States, Tests Changed, Verification Attempts, Deviations, and Residual Risks. Follow fixed stages: contract confirm → tests → component/state → API/Mock → focused checks → diff cleanup.",
|
|
2732
2755
|
subtask_prompt: [
|
|
2733
2756
|
"Implement against the validated run-owned Frontend Implementation Contract from frontend-prewrite-gate-shell (path/schema/hash). Do not rebuild the contract from Markdown alone.",
|
|
@@ -2781,18 +2804,12 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2781
2804
|
id: "frontend-repair-pi",
|
|
2782
2805
|
depends_on: ["frontend-verify-assess-shell", implementId],
|
|
2783
2806
|
runIf: "$.nodes['frontend-verify-assess-shell'].json.eligible == true",
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
allowedPaths: implementPaths.allowedPaths,
|
|
2791
|
-
forbiddenPaths,
|
|
2792
|
-
skills: FRONTEND_BOUNDED_IMPLEMENT_SKILLS,
|
|
2793
|
-
writerOutcomePolicy: {
|
|
2794
|
-
type: "implementation-outcome-v1",
|
|
2795
|
-
},
|
|
2807
|
+
...buildFrontendWriterNodeDefaults({
|
|
2808
|
+
complexity: resolveWriterComplexity(taskConfig),
|
|
2809
|
+
writeSet: implementPaths.writeSet,
|
|
2810
|
+
allowedPaths: implementPaths.allowedPaths,
|
|
2811
|
+
forbiddenPaths,
|
|
2812
|
+
}),
|
|
2796
2813
|
outputContract: "First non-empty line must be exactly one of: IMPLEMENTATION_OUTCOME: changed; IMPLEMENTATION_OUTCOME: already-satisfied; IMPLEMENTATION_OUTCOME: blocked. Then a repair summary for an eligible repairable assessment. Must not expand writeSet, re-interpret requirements, skip tests, or enable Mock by default.",
|
|
2797
2814
|
subtask_prompt: [
|
|
2798
2815
|
"Read contracts/frontend-repair-assessment.json and the validated frontend implementation contract.",
|
|
@@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
6
6
|
import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
|
|
7
|
+
import { FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isFrontendWriterAuthorized, readFrontendPrewriteResult, } from "./scheduler.js";
|
|
7
8
|
import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
|
|
8
9
|
import { resolveContextPolicy } from "./context-policy.js";
|
|
9
10
|
import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./prompt.js";
|
|
@@ -12,6 +13,8 @@ import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry,
|
|
|
12
13
|
import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, } from "./retry-policy.js";
|
|
13
14
|
import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
|
|
14
15
|
import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
|
|
16
|
+
import { getStructuredContractValidator } from "./contract-output-registry.js";
|
|
17
|
+
import "./contract-validator-registrations.js";
|
|
15
18
|
import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
16
19
|
import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
|
|
17
20
|
import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
|
|
@@ -138,6 +141,20 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
|
|
|
138
141
|
buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
|
|
139
142
|
].join("\n");
|
|
140
143
|
}
|
|
144
|
+
if (previousFailureCategory === "invalid-output" &&
|
|
145
|
+
task.structuredContractOutput &&
|
|
146
|
+
previousProtocolReason) {
|
|
147
|
+
return [
|
|
148
|
+
basePrompt,
|
|
149
|
+
"",
|
|
150
|
+
"<retry_instruction>",
|
|
151
|
+
"Previous attempt produced an invalid frontend implementation contract:",
|
|
152
|
+
previousProtocolReason,
|
|
153
|
+
"Return exactly one fenced json block conforming to the frontend-implementation-contract-v1 schema.",
|
|
154
|
+
"Fix every reported field violation: do not emit null for optional fields, do not misspell field names, and match the required types exactly.",
|
|
155
|
+
"</retry_instruction>",
|
|
156
|
+
].join("\n");
|
|
157
|
+
}
|
|
141
158
|
if (previousFailureCategory === "writer-empty-diff") {
|
|
142
159
|
const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
|
|
143
160
|
// When a completeness progress exists for this writer, fold the concrete
|
|
@@ -395,6 +412,21 @@ export async function executeDagNode(input) {
|
|
|
395
412
|
await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
|
|
396
413
|
};
|
|
397
414
|
const failSkillSnapshot = (error) => failBeforePrompt(error, "skill-snapshot-integrity");
|
|
415
|
+
const skipFrontendWriter = async (admission) => {
|
|
416
|
+
const skippedAt = new Date().toISOString();
|
|
417
|
+
node.startedAt ??= skippedAt;
|
|
418
|
+
node.frontendWriterAdmission = admission;
|
|
419
|
+
node.status = "SKIPPED";
|
|
420
|
+
node.skippedReason = "frontend-prewrite-not-authorized";
|
|
421
|
+
node.finishedAt = skippedAt;
|
|
422
|
+
node.lastActivityAt = skippedAt;
|
|
423
|
+
node.durationMs = durationBetween(node.startedAt, node.finishedAt);
|
|
424
|
+
node.timing = { retryBackoffMs: 0, settlementCleanupMs: 0 };
|
|
425
|
+
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
426
|
+
await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
|
|
427
|
+
await input.persistState();
|
|
428
|
+
await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
|
|
429
|
+
};
|
|
398
430
|
if (task.finalWriteSetApproval) {
|
|
399
431
|
const authorization = parseAndValidateFinalWriteSetApproval({ task, spec, state });
|
|
400
432
|
if (!authorization.ok) {
|
|
@@ -423,6 +455,30 @@ export async function executeDagNode(input) {
|
|
|
423
455
|
effectiveWriteSet: [...authorization.effectiveWriteSet],
|
|
424
456
|
};
|
|
425
457
|
}
|
|
458
|
+
if (FRONTEND_WRITER_NODE_IDS.includes(nodeId)) {
|
|
459
|
+
const admission = await readFrontendPrewriteResult(runDir);
|
|
460
|
+
if (!admission.ok) {
|
|
461
|
+
await skipFrontendWriter(undefined);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const decision = isFrontendWriterAuthorized(admission.result);
|
|
465
|
+
const record = {
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
writerNodeId: nodeId,
|
|
468
|
+
decision,
|
|
469
|
+
sourceArtifact: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
|
|
470
|
+
artifactHash: admission.artifactHash,
|
|
471
|
+
checkedAt: new Date().toISOString(),
|
|
472
|
+
reason: decision === "denied"
|
|
473
|
+
? `classification: ${admission.result.classification}`
|
|
474
|
+
: null,
|
|
475
|
+
};
|
|
476
|
+
if (decision === "denied") {
|
|
477
|
+
await skipFrontendWriter(record);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
node.frontendWriterAdmission = record;
|
|
481
|
+
}
|
|
426
482
|
let projectGovernanceContext;
|
|
427
483
|
if (task.governanceStandardReview) {
|
|
428
484
|
try {
|
|
@@ -689,6 +745,39 @@ export async function executeDagNode(input) {
|
|
|
689
745
|
previousProtocolReason = undefined;
|
|
690
746
|
}
|
|
691
747
|
}
|
|
748
|
+
// R1: structured contract nodes self-validate their output so schema,
|
|
749
|
+
// typo, and null violations surface as retryable invalid-output at the
|
|
750
|
+
// producing node instead of failing the whole run at the prewrite gate.
|
|
751
|
+
if (result.ok && task.structuredContractOutput) {
|
|
752
|
+
const validator = getStructuredContractValidator(task.structuredContractOutput.schemaId);
|
|
753
|
+
if (validator) {
|
|
754
|
+
const contractText = canonicalNodeOutput(result);
|
|
755
|
+
const contractCheck = await validator({
|
|
756
|
+
runDir,
|
|
757
|
+
text: contractText,
|
|
758
|
+
sourceBinding: spec.sourceBinding,
|
|
759
|
+
});
|
|
760
|
+
if (!contractCheck.ok) {
|
|
761
|
+
result = {
|
|
762
|
+
...result,
|
|
763
|
+
ok: false,
|
|
764
|
+
failureCategory: "invalid-output",
|
|
765
|
+
stderr: [result.stderr, contractCheck.reason]
|
|
766
|
+
.filter(Boolean)
|
|
767
|
+
.join("\n"),
|
|
768
|
+
};
|
|
769
|
+
previousProtocolReason = contractCheck.reason;
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
previousProtocolReason = undefined;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
// DAG spec validation rejects unknown schemaIds; this is a
|
|
777
|
+
// defensive fallback for a registry that has not been populated.
|
|
778
|
+
console.warn(`[run-dag] warning: no structured contract validator registered for schemaId ${task.structuredContractOutput.schemaId}; skipping node self-check`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
692
781
|
const attemptFinishedAt = new Date().toISOString();
|
|
693
782
|
const attemptWallDurationMs = durationBetween(attemptStartedAt, attemptFinishedAt);
|
|
694
783
|
totalAttemptWallDurationMs += attemptWallDurationMs;
|