@tea-agent/loop-agent 0.39.0-beta.6 → 0.39.0-beta.7
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/CHANGELOG.md +8 -1
- package/dist/build-stamp.json +3 -3
- package/dist/commands/client-recovery.js +3 -0
- package/dist/commands/task-advance.js +19 -0
- package/dist/executors/dag-pi-executor.js +44 -11
- package/dist/executors/pi-executor.js +7 -0
- package/dist/executors/shell-executor.js +1 -1
- package/dist/shared/dag-failure-category.js +12 -0
- package/dist/task/frontend-project-capability.js +19 -0
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CfDSkLtp.js → abnfDiagram-N423BO3Z-bPer1waP.js} +1 -1
- package/dist/worker/console/static/assets/{arc-BlefM6I5.js → arc-BLdkwucW.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C4tHfzHn.js → architectureDiagram-T3A2C74G-Bw4wo4I1.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-G-G77Rh1.js → blockDiagram-VBNYF7ZC-DUUkc0gr.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DWgxmY72.js → c4Diagram-5PPSVZJV-BC3O0nob.js} +1 -1
- package/dist/worker/console/static/assets/channel-AbFtVnFm.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CJQcfbCY.js → chunk-2GRJ4B5K-Dd4g2nti.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-vUPviH9y.js → chunk-2Q5K7J3B-s2EDm7-n.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DzRXSTeh.js → chunk-5RXB4S5H-GqtHM2yQ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CWfgqm07.js → chunk-5VM5RSS4-BzRFrN8j.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BtTDauXA.js → chunk-6Q2QTUOP-Bw9nFjCJ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bc-NNt84.js → chunk-GF5L2VYU-B4n-03Qm.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-B_f-emL4.js → chunk-JWPE2WC7-Dt75ZdBD.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DV-jgJ66.js → chunk-KBJHAD2P-B_afMhHB.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DMqzn4RZ.js → chunk-RYQCIY6F-Bd8Rqqg9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CVNtlAm4.js → chunk-XXDRQBXY-9boOMji4.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BBZBhVTd.js → cose-bilkent-JH36ORCC-BtCycfCc.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DbMU2PZ2.js → cynefin-VYW2F7L2-eTOts9L1.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dj0v8rjc.js → cynefinDiagram-MW4NZA55-Cibd2mjg.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BW8R4WvJ.js → dagre-VZM6K2ZE-CDjDZtDV.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DEgWz3ld.js → diagram-7IWD3JNH-Btexn-JJ.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BAA01rlR.js → diagram-B4RE2ZJO-Cyl8sdpM.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Cau6qqpW.js → diagram-LBJQPF4R-Ev-83E1t.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-8HiPsLos.js → diagram-Q27KOJAE-41LqE0Mc.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CmkXuTYL.js → diagram-UB23O5K3-BC39T5R3.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DCV_0seW.js → ebnfDiagram-BXEA7PRR-DzcNRuri.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-gckfWESj.js → erDiagram-JOGREHBK-B5Frvvnz.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D2mT2BUu.js → flowDiagram-UKHOOZJN-BapxROIM.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-B_HR5qEw.js → ganttDiagram-PKOTCBZU-Cd4d4wQ0.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BUw4fpSa.js → gitGraphDiagram-DS77QQ5N-CrL6buFW.js} +1 -1
- package/dist/worker/console/static/assets/{index-DPbsruV5.js → index-DBh_x_Ek.js} +46 -46
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BSRAqEj0.js → infoDiagram-6WML65LV-BrSiNxyd.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CLxE8hUp.js → ishikawaDiagram-WSZJBQD7-gvlvY-n9.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-juFzVXIj.js → journeyDiagram-NVQOT4AX-D-YaPzRQ.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CwLHVhOU.js → kanban-definition-27J2QSJJ-BsmDv9E_.js} +1 -1
- package/dist/worker/console/static/assets/{linear-9LfUyPAs.js → linear-UWaoDN3h.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-TPPHfQbe.js → mermaid.core-BlvpGVG8.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-Dn_Z9RxF.js → mindmap-definition-FAOFIHXS-CUCIP5CP.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CiEMKTgm.js → pegDiagram-VL7TDLO6-CB1aQUAK.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-JASaGxjZ.js → pieDiagram-7S7Q4E2Y-BwehHxhf.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-ChmH0ply.js → quadrantDiagram-CIZ2JOQS-CFz30APB.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CtyM-HGQ.js → railroadDiagram-AXF67PYL-BwNmw1i6.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BN9y_Bvs.js → requirementDiagram-LRYGKXZP-jg-tPXlI.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-IyS0GQ1-.js → sankeyDiagram-W5VNT64P-BlyReDKl.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DjxGXurN.js → sequenceDiagram-SI44F4Z6-DBUzW1f3.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Cr3Z0S8y.js → sizeCapture-X5ZJPWSS-BYcI2RAr.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BjJFC6pW.js → stateDiagram-OKZ733FA-D7AiyJdo.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DeMUJQiW.js → swimlanes-SLNWSIFB-BHGt7Kkv.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bc2Rv2Oo.js → timeline-definition-Z64GVDOM-OPjooWcX.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CDhRUeqG.js → vennDiagram-T6HMQDX7-ggsWDvrO.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DaN_z3Xy.js → wardleyDiagram-T6FBY63Y-DjNe-BjZ.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-C1vTLBDG.js → xychartDiagram-ELKLHX3M-Byp63Hei.js} +1 -1
- package/dist/worker/console/static/index.html +1 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
- package/dist/workflows/dag/backend-test-pytest-collection.js +87 -4
- package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
- package/dist/workflows/dag/frontend-design-policy.js +19 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +74 -2
- package/dist/workflows/dag/frontend-review-findings.js +238 -0
- package/dist/workflows/dag/frontend-shape.js +5 -3
- package/dist/workflows/dag/init-hybrid.js +90 -1
- package/dist/workflows/dag/node-execution.js +7 -0
- package/dist/workflows/dag/report.js +37 -1
- package/dist/workflows/dag/rerun-feedback.js +101 -1
- package/dist/workflows/dag/retry-policy.js +21 -3
- package/dist/workflows/dag/runner.js +46 -1
- package/dist/workflows/dag/validate.js +9 -3
- package/docs/templates/agent-dag-report.schema.json +8 -2
- package/docs/templates/agent-dag.schema.json +1 -1
- package/package.json +1 -1
- package/dist/worker/console/static/assets/channel-DsdX3ZUX.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CrvCyJ-8.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CrvCyJ-8.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Bq44O9Nt.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BT4tWyW3.js +0 -8
|
@@ -131,6 +131,23 @@ function normalizeHazardOccurrenceIdentity(line) {
|
|
|
131
131
|
// Unclosed quote or no out-of-string `#`: do not invent a comment boundary.
|
|
132
132
|
return trimmed;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Bare `__NNN_*` helper module names imported by a Python source snippet.
|
|
136
|
+
* Only the head token of an `import`/`from` statement is considered a module
|
|
137
|
+
* name; dotted submodules (`__551_manager_helpers.x`) collapse to the head.
|
|
138
|
+
*/
|
|
139
|
+
function importedBareHelperModules(source) {
|
|
140
|
+
const names = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const match of normalizeSourceNewlines(source).matchAll(/\b(?:import|from)\s+([A-Za-z0-9_.]+)/g)) {
|
|
143
|
+
const head = match[1].split(".")[0];
|
|
144
|
+
if (/^__\d+[A-Za-z0-9_]*$/.test(head) && !seen.has(head)) {
|
|
145
|
+
seen.add(head);
|
|
146
|
+
names.push(head);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return names;
|
|
150
|
+
}
|
|
134
151
|
function listPatternOccurrences(source, patternSource, flags = "") {
|
|
135
152
|
const normalized = normalizeSourceNewlines(source);
|
|
136
153
|
const pattern = new RegExp(patternSource, flags.includes("g") ? flags : `${flags}g`);
|
|
@@ -221,6 +238,8 @@ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScri
|
|
|
221
238
|
const inputHashes = {};
|
|
222
239
|
const hazardOccurrenceCounts = {};
|
|
223
240
|
const hazardOccurrences = {};
|
|
241
|
+
const scriptBareImports = {};
|
|
242
|
+
const mappedScriptSet = new Set(normalizedScripts);
|
|
224
243
|
for (const file of assetFiles) {
|
|
225
244
|
const bytes = await readFile(path.resolve(workspaceRoot, file));
|
|
226
245
|
const source = bytes.toString("utf8");
|
|
@@ -228,8 +247,10 @@ export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScri
|
|
|
228
247
|
const lists = scanRepairHazardOccurrenceLists(source);
|
|
229
248
|
hazardOccurrences[file] = lists;
|
|
230
249
|
hazardOccurrenceCounts[file] = Object.fromEntries(Object.entries(lists).map(([label, identities]) => [label, identities.length]));
|
|
250
|
+
if (mappedScriptSet.has(file))
|
|
251
|
+
scriptBareImports[file] = importedBareHelperModules(source);
|
|
231
252
|
}
|
|
232
|
-
return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences };
|
|
253
|
+
return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences, scriptBareImports };
|
|
233
254
|
}
|
|
234
255
|
function bounded(value) {
|
|
235
256
|
const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
|
|
@@ -308,8 +329,68 @@ function classifyFixtureResolutionFailure(output, layout) {
|
|
|
308
329
|
repairPaths: paths,
|
|
309
330
|
};
|
|
310
331
|
}
|
|
311
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Generated-local bare helper module signatures found in the bound pytest
|
|
334
|
+
* asset inventory. A file such as `testcase/__551_manager_helpers.py` in the
|
|
335
|
+
* test root/script dir is generated-local: when collection reports
|
|
336
|
+
* `No module named '__551_manager_helpers'`, the defect can be precisely
|
|
337
|
+
* attributed to the generated mapped script instead of a missing third-party
|
|
338
|
+
* package.
|
|
339
|
+
*/
|
|
340
|
+
function generatedLocalBareHelperNames(inventory) {
|
|
341
|
+
if (!inventory)
|
|
342
|
+
return [];
|
|
343
|
+
const names = [];
|
|
344
|
+
for (const file of inventory.assetFiles) {
|
|
345
|
+
const stem = path.posix.basename(file).replace(/\.py$/, "");
|
|
346
|
+
if (/^__\d+[A-Za-z0-9_]*$/.test(stem))
|
|
347
|
+
names.push(stem);
|
|
348
|
+
}
|
|
349
|
+
return [...new Set(names)];
|
|
350
|
+
}
|
|
351
|
+
function classifyCollectionFailure(output, evidence) {
|
|
312
352
|
const normalized = output.replaceAll("\\", "/");
|
|
353
|
+
const helperNames = generatedLocalBareHelperNames(evidence?.inventory);
|
|
354
|
+
const helperByLower = new Map(helperNames.map((name) => [name.toLowerCase(), name]));
|
|
355
|
+
// A bare `__NNN_...` module import that collection cannot find is only a
|
|
356
|
+
// candidate generated-local helper signature. REPAIRABLE requires two
|
|
357
|
+
// deterministic pieces of evidence: (1) a helper asset of the same name in
|
|
358
|
+
// the bound inventory, and (2) at least one mapped generated script whose
|
|
359
|
+
// source truly imports that bare module. Without both — or when attribution
|
|
360
|
+
// is impossible — fail closed as BLOCKED so an un-attributable dependency
|
|
361
|
+
// error is never widened into a repair. Genuine third-party modules (no
|
|
362
|
+
// `__NNN_` signature) stay BLOCKED below.
|
|
363
|
+
const missingModule = /No module named ['\"]([^'\"]+)['\"]/i.exec(normalized)?.[1];
|
|
364
|
+
if (missingModule && /^__\d+[A-Za-z0-9_]*$/.test(missingModule)) {
|
|
365
|
+
const helperAsset = helperByLower.get(missingModule.toLowerCase());
|
|
366
|
+
const inventory = evidence?.inventory;
|
|
367
|
+
const scriptBareImports = inventory?.scriptBareImports ?? {};
|
|
368
|
+
const missingLower = missingModule.toLowerCase();
|
|
369
|
+
const importedByMappedScript = inventory
|
|
370
|
+
? inventory.mappedScripts.some((script) => (scriptBareImports[script] ?? []).some((name) => name.toLowerCase() === missingLower))
|
|
371
|
+
: false;
|
|
372
|
+
if (helperAsset && importedByMappedScript && inventory) {
|
|
373
|
+
const repairPaths = [...new Set([
|
|
374
|
+
...(helperAsset
|
|
375
|
+
? inventory.assetFiles.filter((file) => path.posix.basename(file).replace(/\.py$/, "") === helperAsset)
|
|
376
|
+
: []),
|
|
377
|
+
...inventory.mappedScripts,
|
|
378
|
+
])].sort();
|
|
379
|
+
return {
|
|
380
|
+
status: "REPAIRABLE",
|
|
381
|
+
kind: "missing-generated-local-helper",
|
|
382
|
+
detail: `generated testcase asset imports missing bare generated-local helper module ${missingModule}; inline the helper or remove the generated shared dependency`,
|
|
383
|
+
repairPaths,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
status: "BLOCKED",
|
|
388
|
+
kind: "missing-generated-local-helper-without-evidence",
|
|
389
|
+
detail: helperAsset
|
|
390
|
+
? `missing bare generated-local helper module ${missingModule} has a name match but no mapped generated script imports it (no precise generated-local attribution)`
|
|
391
|
+
: `missing bare __NNN_* module ${missingModule} has no generated-local helper asset in the bound inventory (cannot be precisely attributed)`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
313
394
|
const blockedPatterns = [
|
|
314
395
|
[/No module named ['\"](?!testcase(?:\.|['\"]))/i, "missing-third-party-module"],
|
|
315
396
|
[/pytest(?:_|-)plugin|pluggy|unknown hook|unrecognized arguments/i, "pytest-plugin-or-config"],
|
|
@@ -401,7 +482,7 @@ export function assessBackendPytestCollection(input) {
|
|
|
401
482
|
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
402
483
|
});
|
|
403
484
|
}
|
|
404
|
-
const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}
|
|
485
|
+
const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`, { layout: input.layout, inventory: input.inventory });
|
|
405
486
|
return backendPytestCollectionFactsSchema.parse({
|
|
406
487
|
schemaId: "backend-test-pytest-collection-v3",
|
|
407
488
|
phase: input.phase,
|
|
@@ -412,7 +493,9 @@ export function assessBackendPytestCollection(input) {
|
|
|
412
493
|
fixtureResolutionAttempted: false,
|
|
413
494
|
fixtureResolutionStatus: "NOT_RUN",
|
|
414
495
|
fixtureResolutionExitCode: null,
|
|
415
|
-
repairPaths:
|
|
496
|
+
repairPaths: classification.repairPaths && classification.repairPaths.length > 0
|
|
497
|
+
? classification.repairPaths
|
|
498
|
+
: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
|
|
416
499
|
mappedScripts: input.inventory.mappedScripts,
|
|
417
500
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
418
501
|
missingMappedScripts: [],
|
|
@@ -385,12 +385,17 @@ export const FORBIDDEN_GENERATED_IMPORT_NAMESPACES = [
|
|
|
385
385
|
"testcase.helpers",
|
|
386
386
|
"testcase.factories",
|
|
387
387
|
];
|
|
388
|
-
const FORBIDDEN_GENERATED_IMPORT_PATTERN = /^\s*(?:from\s+(testcase_helpers|testcase\.helpers|testcase\.factories)\b|import\s+(testcase_helpers|testcase\.helpers|testcase\.factories)\b)/gm;
|
|
388
|
+
const FORBIDDEN_GENERATED_IMPORT_PATTERN = /^\s*(?:from\s+(testcase_helpers|testcase\.helpers|testcase\.factories|__\d+[A-Za-z0-9_]*)\b|import\s+(testcase_helpers|testcase\.helpers|testcase\.factories|__\d+[A-Za-z0-9_]*)\b)/gm;
|
|
389
389
|
/**
|
|
390
|
-
* Scan Python source for imports from forbidden generated shared namespaces
|
|
391
|
-
* Anchored at line start with a word
|
|
392
|
-
* substring inside a docstring
|
|
393
|
-
*
|
|
390
|
+
* Scan Python source for imports from forbidden generated shared namespaces
|
|
391
|
+
* or bare generated-local helper modules. Anchored at line start with a word
|
|
392
|
+
* boundary so `testcase.helpers_other` or a substring inside a docstring is not
|
|
393
|
+
* falsely flagged. In addition to the `testcase_helpers`/`testcase.helpers`/
|
|
394
|
+
* `testcase.factories` namespaces, bare `__<digits>...` helper modules (for
|
|
395
|
+
* example `from __551_manager_helpers import ...` / `import __551_manager_helpers`)
|
|
396
|
+
* are generated-local shared dependencies: a self-contained pytest child must
|
|
397
|
+
* inline or remove them rather than depend on a generated shared module.
|
|
398
|
+
* Returns the list of offending module names (deduplicated, order-preserving).
|
|
394
399
|
*/
|
|
395
400
|
export function findForbiddenGeneratedImports(source) {
|
|
396
401
|
const hits = [];
|
|
@@ -2,6 +2,10 @@ import { deterministicSha256, } from "./frontend-implementation-contract.js";
|
|
|
2
2
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
3
3
|
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
4
4
|
const NPM_PACKAGE_NAME = /^(?:@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
5
|
+
/** Max concrete implementation files a single writeSet may carry before the
|
|
6
|
+
* plan is asked to split. Kept at 15: beyond this, a single implement node's
|
|
7
|
+
* read/edit loop inflates provider context toward overflow. */
|
|
8
|
+
const MAX_WRITE_SET_FILES = 15;
|
|
5
9
|
function normalizeRelativePath(value) {
|
|
6
10
|
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
7
11
|
}
|
|
@@ -209,7 +213,22 @@ export function checkComponentChoice(contract, componentSpecCandidatePaths) {
|
|
|
209
213
|
* must fall inside the declared writeSet patterns. */
|
|
210
214
|
export function checkTargetPaths(contract, writeSetPatterns) {
|
|
211
215
|
const findings = [];
|
|
216
|
+
// No frozen writeSet patterns (legacy / unconfigured run) → nothing to
|
|
217
|
+
// check; an empty pattern set must not report every file as out of scope.
|
|
218
|
+
if (writeSetPatterns.length === 0)
|
|
219
|
+
return findings;
|
|
212
220
|
const matches = (file) => writeSetPatterns.some((pattern) => pathMatchesPattern(file, pattern));
|
|
221
|
+
// A writeSet this large forces a single implement node to read/edit many
|
|
222
|
+
// files, inflating the provider context until it overflows (400
|
|
223
|
+
// request-too-large). Flag it so the plan is asked to split (the contract
|
|
224
|
+
// node already has record_split_proposal) instead of letting the writer
|
|
225
|
+
// blow up mid-run.
|
|
226
|
+
if (contract.targets.files.length > MAX_WRITE_SET_FILES) {
|
|
227
|
+
findings.push({
|
|
228
|
+
code: "write-set-too-large",
|
|
229
|
+
message: `implementation writeSet has ${contract.targets.files.length} files (limit ${MAX_WRITE_SET_FILES}); split the task (record_split_proposal) so no single implement node exceeds the bound`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
213
232
|
for (const file of contract.targets.files) {
|
|
214
233
|
if (!matches(file)) {
|
|
215
234
|
findings.push({
|
|
@@ -12,7 +12,7 @@ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
|
|
|
12
12
|
// Cycle-safe value imports: the check functions are hoisted declarations used
|
|
13
13
|
// only at call time, so the contract module and the policy module can depend
|
|
14
14
|
// on each other without module-init ordering issues.
|
|
15
|
-
import { checkDependencies, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
|
|
15
|
+
import { checkDependencies, checkTargetPaths, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
|
|
16
16
|
import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, persistRuntimeSkeleton, } from "./structured-output-repair.js";
|
|
17
17
|
import { isConfigurationVerificationFile, verificationSymbolMatchesContent, } from "./frontend-verification-trace.js";
|
|
18
18
|
export const frontendNormalizationActionSchema = z.enum([
|
|
@@ -142,6 +142,35 @@ async function deriveAllowedDependenciesFromRun(runDir) {
|
|
|
142
142
|
}
|
|
143
143
|
return [...deps];
|
|
144
144
|
}
|
|
145
|
+
/** Read the frozen implementation writeSet patterns from run.json so the plan
|
|
146
|
+
* validator can front-load checkTargetPaths (write-set-too-large) before the
|
|
147
|
+
* design-policy shell terminates the run. */
|
|
148
|
+
async function deriveWriteSetFromRun(runDir) {
|
|
149
|
+
const specPath = path.join(runDir, "run.json");
|
|
150
|
+
let raw;
|
|
151
|
+
try {
|
|
152
|
+
raw = await readFile(specPath, "utf8");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
let spec;
|
|
158
|
+
try {
|
|
159
|
+
spec = JSON.parse(raw);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
const writeSet = new Set();
|
|
165
|
+
for (const task of spec.tasks ?? []) {
|
|
166
|
+
const patterns = task.shell?.frontendDesignPolicy?.implementationWriteSet;
|
|
167
|
+
if (patterns) {
|
|
168
|
+
for (const pattern of patterns)
|
|
169
|
+
writeSet.add(pattern);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...writeSet];
|
|
173
|
+
}
|
|
145
174
|
export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
|
|
146
175
|
export const FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID = "frontend-implementation-contract-plan-patch-v1";
|
|
147
176
|
/**
|
|
@@ -309,8 +338,50 @@ function stripNullValuesDeep(value) {
|
|
|
309
338
|
}
|
|
310
339
|
return value;
|
|
311
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Normalize a model-emitted `uiComponentChoices` where a `new` choice carries a
|
|
343
|
+
* degenerate empty `specReference` object (`{ path: "", section: "", line:
|
|
344
|
+
* null }`) instead of the required `null`. The model intent is unambiguous
|
|
345
|
+
* (new components must not carry a specReference; the empty object is a format
|
|
346
|
+
* slip, not a real spec hit), so dropping the empty object before validation
|
|
347
|
+
* avoids a full plan retry on an otherwise-correct emission. A `specReference`
|
|
348
|
+
* with a non-empty `path` is preserved (it is a genuine `specified` hit).
|
|
349
|
+
*/
|
|
350
|
+
export function normalizeNewComponentSpecReference(value) {
|
|
351
|
+
if (!Array.isArray(value))
|
|
352
|
+
return value;
|
|
353
|
+
return value.map((item) => {
|
|
354
|
+
if (!item || typeof item !== "object")
|
|
355
|
+
return item;
|
|
356
|
+
const record = item;
|
|
357
|
+
if (record.decision !== "new")
|
|
358
|
+
return item;
|
|
359
|
+
const ref = record.specReference;
|
|
360
|
+
if (!ref || typeof ref !== "object")
|
|
361
|
+
return item;
|
|
362
|
+
const refRecord = ref;
|
|
363
|
+
const path = typeof refRecord.path === "string" ? refRecord.path.trim() : "";
|
|
364
|
+
if (path.length > 0)
|
|
365
|
+
return item;
|
|
366
|
+
// Empty/degenerate specReference on a new choice → absent (null intent).
|
|
367
|
+
const { specReference: _dropped, ...rest } = record;
|
|
368
|
+
return rest;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
312
371
|
export const frontendImplementationContractSchema = z
|
|
313
|
-
.preprocess(
|
|
372
|
+
.preprocess((value) => {
|
|
373
|
+
const record = asRecord(value);
|
|
374
|
+
if (!record)
|
|
375
|
+
return stripNullValuesDeep(value);
|
|
376
|
+
return stripNullValuesDeep({
|
|
377
|
+
...record,
|
|
378
|
+
...(record.uiComponentChoices !== undefined
|
|
379
|
+
? {
|
|
380
|
+
uiComponentChoices: normalizeNewComponentSpecReference(record.uiComponentChoices),
|
|
381
|
+
}
|
|
382
|
+
: {}),
|
|
383
|
+
});
|
|
384
|
+
}, z
|
|
314
385
|
.object({
|
|
315
386
|
schemaVersion: z.literal(1),
|
|
316
387
|
sourceBinding: z
|
|
@@ -1974,6 +2045,7 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
|
|
|
1974
2045
|
...checkUiDesignCoverage(analysis.canonical),
|
|
1975
2046
|
...checkUiStateAttribution(analysis.canonical),
|
|
1976
2047
|
...checkDependencies(analysis.canonical, await deriveAllowedDependenciesFromRun(input.runDir)),
|
|
2048
|
+
...checkTargetPaths(analysis.canonical, await deriveWriteSetFromRun(input.runDir)),
|
|
1977
2049
|
];
|
|
1978
2050
|
if (policyPreFindings.length > 0)
|
|
1979
2051
|
throw new Error(`frontend plan policy pre-check failed (fix these plan facts, then re-commit and finalize): ${policyPreFindings
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readTypedEventStoreFromJsonl, } from "./frontend-typed-event-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Frontend review findings → downstream work (direction A).
|
|
6
|
+
*
|
|
7
|
+
* When `frontend-review-pi` commits `request_review_changes`, the implementation
|
|
8
|
+
* must not silently finish: the run becomes "not passed" (partial_failed +
|
|
9
|
+
* terminalReason=review-request-changes), and the committed findings are
|
|
10
|
+
* rendered into a downstream-task PRD draft that a later DAG round can consume
|
|
11
|
+
* instead of repeating the previous flow. Rendering is deterministic (pure);
|
|
12
|
+
* only the JSONL read and PRD write are IO.
|
|
13
|
+
*/
|
|
14
|
+
export const FRONTEND_REVIEW_FINDINGS_PRD_REL = "contracts/frontend-review-findings-prd.md";
|
|
15
|
+
/** Review node id whose committed typed facts drive the findings PRD. */
|
|
16
|
+
export const FRONTEND_REVIEW_NODE_ID = "frontend-review-pi";
|
|
17
|
+
/** Deterministic issueCategory → failure owner mapping (A+B AC-009 §4.10). */
|
|
18
|
+
export function classifyReviewIssueCategoryFailureOwner(issueCategory) {
|
|
19
|
+
switch (issueCategory) {
|
|
20
|
+
case "implementation-mismatch":
|
|
21
|
+
return "implementation";
|
|
22
|
+
case "approved-design-defect":
|
|
23
|
+
return "plan";
|
|
24
|
+
case "target-surface-defect":
|
|
25
|
+
return "scout";
|
|
26
|
+
case "contract-requirement-gap":
|
|
27
|
+
return "contract";
|
|
28
|
+
case "unknown":
|
|
29
|
+
default:
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const API_SIGNAL_RE = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i;
|
|
34
|
+
/** Negations for the API signal. CJK "无 API/无接口" has no \b boundary, so
|
|
35
|
+
* it is matched separately from the ASCII no api/not-needed forms. */
|
|
36
|
+
const API_NEGATION_RE = /(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])|无\s*api|无\s*接口/i;
|
|
37
|
+
const SMALL_SIGNAL_WORDS = [
|
|
38
|
+
"单文件局部修复",
|
|
39
|
+
"局部样式",
|
|
40
|
+
"文案调整",
|
|
41
|
+
"无远程接口",
|
|
42
|
+
];
|
|
43
|
+
function topLevelDir(p) {
|
|
44
|
+
const normalized = p.replace(/\\/g, "/");
|
|
45
|
+
const first = normalized.split("/").filter(Boolean)[0] ?? "";
|
|
46
|
+
return first;
|
|
47
|
+
}
|
|
48
|
+
/** Collect unique non-empty finding `file` values (sorted, de-duplicated). */
|
|
49
|
+
function findingTargetFiles(fact) {
|
|
50
|
+
const files = new Set();
|
|
51
|
+
for (const finding of fact.findings) {
|
|
52
|
+
if (typeof finding.file === "string" && finding.file.trim()) {
|
|
53
|
+
files.add(finding.file.trim());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...files].sort();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Deterministically derive the fix scope for the downstream task. Same fact
|
|
60
|
+
* always yields the same scope. The shape mirrors `resolveFrontendTaskShape`
|
|
61
|
+
* signals (concentrated paths, no remote API → micro/small) so that when the
|
|
62
|
+
* PRD draft is used as the next task's requirement, the frontend DAG generator
|
|
63
|
+
* routes a small fix to micro/small instead of the full 11-node standard
|
|
64
|
+
* topology.
|
|
65
|
+
*/
|
|
66
|
+
export function deriveFrontendReviewFixScope(fact) {
|
|
67
|
+
const targetFiles = findingTargetFiles(fact);
|
|
68
|
+
const blob = fact.findings
|
|
69
|
+
.map((f) => `${f.issue} ${f.requiredChange ?? ""} ${f.file ?? ""}`)
|
|
70
|
+
.join("\n");
|
|
71
|
+
const hasApiSignal = API_SIGNAL_RE.test(blob) && !API_NEGATION_RE.test(blob);
|
|
72
|
+
const topDirs = new Set(targetFiles.map(topLevelDir));
|
|
73
|
+
const concentrated = targetFiles.length > 0 && topDirs.size <= 1 && targetFiles.length <= 4;
|
|
74
|
+
let shape;
|
|
75
|
+
let complexity;
|
|
76
|
+
let reason;
|
|
77
|
+
if (targetFiles.length === 0) {
|
|
78
|
+
shape = "standard";
|
|
79
|
+
complexity = "medium";
|
|
80
|
+
reason =
|
|
81
|
+
"findings carry no file references; scope unknown, keep standard topology";
|
|
82
|
+
}
|
|
83
|
+
else if (hasApiSignal) {
|
|
84
|
+
shape = "standard";
|
|
85
|
+
complexity = "medium";
|
|
86
|
+
reason = `findings reference a remote API signal; standard topology required (files=${targetFiles.length})`;
|
|
87
|
+
}
|
|
88
|
+
else if (concentrated && targetFiles.length <= 1) {
|
|
89
|
+
shape = "micro";
|
|
90
|
+
complexity = "trivial";
|
|
91
|
+
reason = "single-file concentrated fix without remote API";
|
|
92
|
+
}
|
|
93
|
+
else if (concentrated) {
|
|
94
|
+
shape = "small";
|
|
95
|
+
complexity = "low";
|
|
96
|
+
reason = `concentrated fix across ${targetFiles.length} file(s) in one top-level directory`;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
shape = "standard";
|
|
100
|
+
complexity = "medium";
|
|
101
|
+
reason = `fix spans ${targetFiles.length} file(s) across ${topDirs.size} top-level directories; standard topology`;
|
|
102
|
+
}
|
|
103
|
+
const allowedPaths = [
|
|
104
|
+
...new Set([
|
|
105
|
+
...targetFiles,
|
|
106
|
+
...targetFiles.map((f) => `${topLevelDir(f)}/**`),
|
|
107
|
+
]),
|
|
108
|
+
].sort();
|
|
109
|
+
return {
|
|
110
|
+
shape,
|
|
111
|
+
complexity,
|
|
112
|
+
targetFiles,
|
|
113
|
+
allowedPaths,
|
|
114
|
+
smallSignals: [...SMALL_SIGNAL_WORDS],
|
|
115
|
+
hasApiSignal,
|
|
116
|
+
reason,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read the committed `request_review_changes` fact from the review node's typed
|
|
121
|
+
* event store. Returns undefined when no committed request fact exists (e.g.
|
|
122
|
+
* approve_review, missing store, or an unreadable store).
|
|
123
|
+
*/
|
|
124
|
+
export async function readCommittedReviewRequestFact(input) {
|
|
125
|
+
const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
|
|
126
|
+
let records;
|
|
127
|
+
try {
|
|
128
|
+
records = await readTypedEventStoreFromJsonl(path.join(input.runDir, reviewNodeId, "review-typed-facts.jsonl"));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const committed = records.filter((record) => record.phase === "committed");
|
|
134
|
+
if (committed.length === 0)
|
|
135
|
+
return undefined;
|
|
136
|
+
// A committed approve_review means the review passed; only a committed
|
|
137
|
+
// request_review_changes carries a "not passed" verdict.
|
|
138
|
+
const request = committed.find((record) => record.fact.kind === "request_review_changes");
|
|
139
|
+
if (!request)
|
|
140
|
+
return undefined;
|
|
141
|
+
const fact = request.fact;
|
|
142
|
+
if (typeof fact !== "object" || fact === null)
|
|
143
|
+
return undefined;
|
|
144
|
+
return fact;
|
|
145
|
+
}
|
|
146
|
+
function bulletList(items) {
|
|
147
|
+
if (!items || items.length === 0)
|
|
148
|
+
return "_(none)_";
|
|
149
|
+
return items.map((item) => `- ${item}`).join("\n");
|
|
150
|
+
}
|
|
151
|
+
function findingLines(findings) {
|
|
152
|
+
if (findings.length === 0)
|
|
153
|
+
return "_(none)_";
|
|
154
|
+
return findings
|
|
155
|
+
.map((finding) => {
|
|
156
|
+
const location = [finding.file, finding.line]
|
|
157
|
+
.filter((value) => value !== undefined)
|
|
158
|
+
.join(":");
|
|
159
|
+
const prefix = location ? `[${location}] ` : "";
|
|
160
|
+
const requiredChange = finding.requiredChange
|
|
161
|
+
? `\n - requiredChange: ${finding.requiredChange}`
|
|
162
|
+
: "";
|
|
163
|
+
return `- ${prefix}${finding.severity}: ${finding.issue}${requiredChange}`;
|
|
164
|
+
})
|
|
165
|
+
.join("\n");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Deterministically render a downstream-task PRD draft from a committed
|
|
169
|
+
* `request_review_changes` fact. Same fact always renders byte-identical
|
|
170
|
+
* output; the parent run id and review node id are the only context.
|
|
171
|
+
*/
|
|
172
|
+
export function renderFrontendReviewFindingsPrd(input) {
|
|
173
|
+
const { parentRunId, fact } = input;
|
|
174
|
+
const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
|
|
175
|
+
const restartPhase = classifyReviewIssueCategoryFailureOwner(fact.issueCategory);
|
|
176
|
+
const scope = deriveFrontendReviewFixScope(fact);
|
|
177
|
+
const targetSummary = scope.targetFiles.length > 0
|
|
178
|
+
? scope.targetFiles.join(", ")
|
|
179
|
+
: fact.issueCategory;
|
|
180
|
+
return [
|
|
181
|
+
`# 修复 review 发现(${scope.shape}):${targetSummary}`,
|
|
182
|
+
"",
|
|
183
|
+
"## 背景",
|
|
184
|
+
"",
|
|
185
|
+
`- parentRunId: \`${parentRunId}\``,
|
|
186
|
+
`- reviewNodeId: \`${reviewNodeId}\``,
|
|
187
|
+
`- typedVerdict: \`request_review_changes\``,
|
|
188
|
+
`- issueCategory: \`${fact.issueCategory}\``,
|
|
189
|
+
`- restartPhase/failureOwner: \`${restartPhase}\``,
|
|
190
|
+
"",
|
|
191
|
+
"## 修复范围(供下一轮任务生成路由)",
|
|
192
|
+
"",
|
|
193
|
+
`- 建议 shape:\`${scope.shape}\`(micro=7 / small=10 / standard=11 节点)`,
|
|
194
|
+
`- 建议 complexity:\`${scope.complexity}\``,
|
|
195
|
+
`- 推导依据:${scope.reason}`,
|
|
196
|
+
...(scope.hasApiSignal ? ["- ⚠️ findings 含远程 API/接口信号,走 standard 拓扑"] : []),
|
|
197
|
+
scope.targetFiles.length > 0
|
|
198
|
+
? [`- 目标文件:${scope.targetFiles.map((f) => `\`${f}\``).join(", ")}`]
|
|
199
|
+
: ["- 目标文件:_(findings 未提供 file 引用,需人工补全)_"],
|
|
200
|
+
scope.allowedPaths.length > 0
|
|
201
|
+
? [`- 建议 allowedPaths:${scope.allowedPaths.map((p) => `\`${p}\``).join(", ")}`]
|
|
202
|
+
: [],
|
|
203
|
+
scope.shape === "micro" || scope.shape === "small"
|
|
204
|
+
? [`- 范围信号:${scope.smallSignals.join("、")}(供 shape 分类器识别,避免退化为全节点流程)`]
|
|
205
|
+
: [],
|
|
206
|
+
"",
|
|
207
|
+
"## 问题清单",
|
|
208
|
+
"",
|
|
209
|
+
findingLines(fact.findings),
|
|
210
|
+
"",
|
|
211
|
+
"## 证据引用",
|
|
212
|
+
"",
|
|
213
|
+
bulletList(fact.evidenceRefs),
|
|
214
|
+
"",
|
|
215
|
+
"## 修复目标",
|
|
216
|
+
"",
|
|
217
|
+
`- 按 issueCategory 映射的重启阶段(${restartPhase})定点修复,不复用上一轮未通过的实现。`,
|
|
218
|
+
"- 逐条关闭上面「问题清单」中的 findings,并保留上一轮已通过的验证目标继续有效。",
|
|
219
|
+
"- 完成修复后重新走 frontend DAG 的 implement → verify → review 链,直到 review 提交 approve_review。",
|
|
220
|
+
"",
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
223
|
+
/** Write the findings PRD draft under the run dir (run-owned artifact). */
|
|
224
|
+
export async function writeFrontendReviewFindingsPrd(input) {
|
|
225
|
+
const markdown = renderFrontendReviewFindingsPrd(input);
|
|
226
|
+
const abs = path.join(input.runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL);
|
|
227
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
228
|
+
await writeFile(abs, markdown, "utf8");
|
|
229
|
+
return FRONTEND_REVIEW_FINDINGS_PRD_REL;
|
|
230
|
+
}
|
|
231
|
+
export async function readFrontendReviewFindingsPrd(runDir) {
|
|
232
|
+
try {
|
|
233
|
+
return await readFile(path.join(runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL), "utf8");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -29,7 +29,7 @@ const SHAPE_ESCALATION_PATTERNS = [
|
|
|
29
29
|
];
|
|
30
30
|
const SHAPE_SMALL_PATTERNS = [
|
|
31
31
|
{ id: "single-component", re: /single\s+component|单一组件|局部样式|style\s+tweak|css\s+only|文案|copy\s+change/i },
|
|
32
|
-
{ id: "no-remote", re: /no\s+api
|
|
32
|
+
{ id: "no-remote", re: /no\s+api|无\s*api|无\s*接口|本地状态|local\s+state\s+only|pure\s+ui/i },
|
|
33
33
|
];
|
|
34
34
|
const SHAPE_SPLIT_REQUIRED_PATTERN = /拆分|split\s+(?:requirement|task|需求)|large\s+requirement|大型需求|多步骤需求/i;
|
|
35
35
|
const EXECUTABLE_SHAPE_RANK = {
|
|
@@ -77,7 +77,8 @@ function resolveBaselineFrontendTaskShape(input) {
|
|
|
77
77
|
const spread = pathSpreadScore(paths);
|
|
78
78
|
const concentrated = spread <= 1 && paths.length <= 4;
|
|
79
79
|
const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
|
|
80
|
-
|
|
80
|
+
!(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
|
|
81
|
+
/无\s*api|无\s*接口/i.test(blob));
|
|
81
82
|
const complexity = (input.complexity ?? "").toLowerCase();
|
|
82
83
|
// split-required wins over everything: it is a termination/escalation
|
|
83
84
|
// signal, not an executable topology.
|
|
@@ -258,7 +259,8 @@ export function deriveFrontendRuntimeShapeFacts(input) {
|
|
|
258
259
|
const escalationSignals = collectEscalationSignals(blob, supervised);
|
|
259
260
|
const paths = input.allowedPaths ?? [];
|
|
260
261
|
const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
|
|
261
|
-
|
|
262
|
+
!(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
|
|
263
|
+
/无\s*api|无\s*接口/i.test(blob));
|
|
262
264
|
const requiresFull = escalationSignals.length > 0 || pathSpreadScore(paths) > 1 || hasApiSignal;
|
|
263
265
|
return {
|
|
264
266
|
splitRequired: input.splitRequired,
|
|
@@ -2257,6 +2257,83 @@ function frontendMockStrategyMustBeNotNeeded(sources) {
|
|
|
2257
2257
|
capabilityStatus === "ambiguous" ||
|
|
2258
2258
|
!hasDeterministicMockVerification));
|
|
2259
2259
|
}
|
|
2260
|
+
/**
|
|
2261
|
+
* 候选规范懒加载:prompt 只内联与任务相关的候选,不全部塞给模型。
|
|
2262
|
+
*
|
|
2263
|
+
* - mandatory(任务源显式声明/引用)始终保留——模型必须知道它们存在。
|
|
2264
|
+
* - scan-strict 候选按「路径段是否命中任务源关键词」过滤:列表页任务只
|
|
2265
|
+
* 带出列表相关组件/页面规范,创建页规范不进 prompt。
|
|
2266
|
+
* - runtime 的选型/read 门禁仍消费完整 openspecCandidatePaths(prewrite
|
|
2267
|
+
* gate 用全量);这里只缩小 prompt 体积,不改变门禁语义。未提及的候选
|
|
2268
|
+
* 由 runtime 默认 irrelevant,模型无需枚举。
|
|
2269
|
+
* - 真正读取发生在 plan 侧:模型对 required 选型调用 read 工具,prewrite
|
|
2270
|
+
* 门禁验证 read 事件——按需读取,不预加载正文。
|
|
2271
|
+
*/
|
|
2272
|
+
export function filterRelevantOpenspecCandidates(input) {
|
|
2273
|
+
const mandatory = new Set(input.mandatoryPaths);
|
|
2274
|
+
const relevant = [];
|
|
2275
|
+
for (const candidate of input.candidates) {
|
|
2276
|
+
if (mandatory.has(candidate)) {
|
|
2277
|
+
relevant.push(candidate);
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
if (openspecCandidateMatchesSource(candidate, input.sourceMarkdown)) {
|
|
2281
|
+
relevant.push(candidate);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
// 保持候选发现顺序(确定性);mandatory 与相关候选都按原始顺序出现。
|
|
2285
|
+
return relevant;
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* 候选路径是否与任务源相关:从任务源提取文件名/路径 token(去扩展名、
|
|
2289
|
+
* 去连字符/下划线),若候选的路径段包含任一 token 即视为相关。纯字符串
|
|
2290
|
+
* 判定,无 IO;找不到 token 时保守保留(避免漏掉模型可能需要的规范)。
|
|
2291
|
+
*/
|
|
2292
|
+
function openspecCandidateMatchesSource(candidate, sourceMarkdown) {
|
|
2293
|
+
const sourceTokens = extractOpenspecSourceTokens(sourceMarkdown);
|
|
2294
|
+
if (sourceTokens.size === 0)
|
|
2295
|
+
return true;
|
|
2296
|
+
const candidateLower = candidate.toLowerCase().replace(/\\/g, "/");
|
|
2297
|
+
const candidateSegments = candidateLower.split("/");
|
|
2298
|
+
const candidateBasename = candidateSegments[candidateSegments.length - 1] ?? "";
|
|
2299
|
+
for (const token of sourceTokens) {
|
|
2300
|
+
if (candidateBasename.includes(token) ||
|
|
2301
|
+
candidateLower.includes(`/${token}/`) ||
|
|
2302
|
+
candidateLower.includes(`${token}.`)) {
|
|
2303
|
+
return true;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
return false;
|
|
2307
|
+
}
|
|
2308
|
+
/** 从任务源 markdown 提取显著 token:反引号代码段、路径、组件/页面名。 */
|
|
2309
|
+
function extractOpenspecSourceTokens(markdown) {
|
|
2310
|
+
const tokens = new Set();
|
|
2311
|
+
const push = (raw) => {
|
|
2312
|
+
const cleaned = raw
|
|
2313
|
+
.trim()
|
|
2314
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "")
|
|
2315
|
+
.toLowerCase();
|
|
2316
|
+
if (cleaned.length >= 2 && cleaned.length <= 40)
|
|
2317
|
+
tokens.add(cleaned);
|
|
2318
|
+
};
|
|
2319
|
+
// 反引号内联代码(文件名/组件名)
|
|
2320
|
+
for (const match of markdown.matchAll(/`([^`\n]+)`/g)) {
|
|
2321
|
+
push(match[1] ?? "");
|
|
2322
|
+
}
|
|
2323
|
+
// markdown 链接文本
|
|
2324
|
+
for (const match of markdown.matchAll(/\[([^\]]+)\]\([^)]+\)/g)) {
|
|
2325
|
+
push(match[1] ?? "");
|
|
2326
|
+
}
|
|
2327
|
+
// 路径 token(openspec/ 或 xxx/xxx.md)
|
|
2328
|
+
for (const match of markdown.matchAll(/(?:[A-Za-z0-9_-]+\/)+[A-Za-z0-9_.-]+/g)) {
|
|
2329
|
+
const segments = (match[0] ?? "").split("/");
|
|
2330
|
+
const basename = segments[segments.length - 1] ?? "";
|
|
2331
|
+
push(basename.replace(/\.[a-z0-9]+$/i, ""));
|
|
2332
|
+
for (const segment of segments)
|
|
2333
|
+
push(segment);
|
|
2334
|
+
}
|
|
2335
|
+
return tokens;
|
|
2336
|
+
}
|
|
2260
2337
|
async function resolveFrontendOpenspecGateConfig(sources) {
|
|
2261
2338
|
const taskConfig = sources.taskConfig;
|
|
2262
2339
|
const policy = taskConfig.frontendOpenspec?.policy ?? "cited";
|
|
@@ -2870,10 +2947,22 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2870
2947
|
const openspecGate = await resolveFrontendOpenspecGateConfig(sources);
|
|
2871
2948
|
const requiresOpenspecClassification = openspecGate.openspecPolicy === "cited" &&
|
|
2872
2949
|
openspecGate.openspecCandidatePaths.length > 0;
|
|
2950
|
+
// Prompt 内联的候选只保留与任务相关的子集:mandatory(任务显式声明/
|
|
2951
|
+
// 引用)始终保留;scan-strict 候选按路径段是否命中任务源关键词过滤。
|
|
2952
|
+
// runtime 的选型/read 门禁仍消费完整 openspecCandidatePaths——这里只
|
|
2953
|
+
// 减小 prompt 体积,不改变门禁语义;未提及的候选 runtime 默认 irrelevant。
|
|
2954
|
+
const promptCandidates = filterRelevantOpenspecCandidates({
|
|
2955
|
+
candidates: openspecGate.openspecCandidatePaths,
|
|
2956
|
+
mandatoryPaths: openspecGate.openspecMandatoryPaths,
|
|
2957
|
+
sourceMarkdown: [
|
|
2958
|
+
sources.requirementMarkdown,
|
|
2959
|
+
sources.constraintMarkdown ?? "",
|
|
2960
|
+
].join("\n"),
|
|
2961
|
+
});
|
|
2873
2962
|
const openspecSelectionContext = JSON.stringify({
|
|
2874
2963
|
schemaVersion: 1,
|
|
2875
2964
|
schemaId: "frontend-openspec-selection-v1",
|
|
2876
|
-
candidates:
|
|
2965
|
+
candidates: promptCandidates,
|
|
2877
2966
|
mandatoryPaths: openspecGate.openspecMandatoryPaths,
|
|
2878
2967
|
});
|
|
2879
2968
|
// Generation-frozen component/theme specification bucket (ADR 0016). Derived
|