@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/client-recovery.js +3 -0
  4. package/dist/commands/task-advance.js +19 -0
  5. package/dist/executors/dag-pi-executor.js +44 -11
  6. package/dist/executors/pi-executor.js +7 -0
  7. package/dist/executors/shell-executor.js +1 -1
  8. package/dist/shared/dag-failure-category.js +12 -0
  9. package/dist/task/frontend-project-capability.js +19 -0
  10. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CfDSkLtp.js → abnfDiagram-N423BO3Z-bPer1waP.js} +1 -1
  11. package/dist/worker/console/static/assets/{arc-BlefM6I5.js → arc-BLdkwucW.js} +1 -1
  12. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C4tHfzHn.js → architectureDiagram-T3A2C74G-Bw4wo4I1.js} +1 -1
  13. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-G-G77Rh1.js → blockDiagram-VBNYF7ZC-DUUkc0gr.js} +1 -1
  14. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DWgxmY72.js → c4Diagram-5PPSVZJV-BC3O0nob.js} +1 -1
  15. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +1 -0
  16. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CJQcfbCY.js → chunk-2GRJ4B5K-Dd4g2nti.js} +1 -1
  17. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-vUPviH9y.js → chunk-2Q5K7J3B-s2EDm7-n.js} +1 -1
  18. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DzRXSTeh.js → chunk-5RXB4S5H-GqtHM2yQ.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CWfgqm07.js → chunk-5VM5RSS4-BzRFrN8j.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BtTDauXA.js → chunk-6Q2QTUOP-Bw9nFjCJ.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bc-NNt84.js → chunk-GF5L2VYU-B4n-03Qm.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-B_f-emL4.js → chunk-JWPE2WC7-Dt75ZdBD.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DV-jgJ66.js → chunk-KBJHAD2P-B_afMhHB.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DMqzn4RZ.js → chunk-RYQCIY6F-Bd8Rqqg9.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CVNtlAm4.js → chunk-XXDRQBXY-9boOMji4.js} +1 -1
  26. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +1 -0
  27. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +1 -0
  28. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BBZBhVTd.js → cose-bilkent-JH36ORCC-BtCycfCc.js} +1 -1
  29. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DbMU2PZ2.js → cynefin-VYW2F7L2-eTOts9L1.js} +1 -1
  30. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dj0v8rjc.js → cynefinDiagram-MW4NZA55-Cibd2mjg.js} +1 -1
  31. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BW8R4WvJ.js → dagre-VZM6K2ZE-CDjDZtDV.js} +1 -1
  32. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DEgWz3ld.js → diagram-7IWD3JNH-Btexn-JJ.js} +1 -1
  33. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BAA01rlR.js → diagram-B4RE2ZJO-Cyl8sdpM.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Cau6qqpW.js → diagram-LBJQPF4R-Ev-83E1t.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-8HiPsLos.js → diagram-Q27KOJAE-41LqE0Mc.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CmkXuTYL.js → diagram-UB23O5K3-BC39T5R3.js} +1 -1
  37. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DCV_0seW.js → ebnfDiagram-BXEA7PRR-DzcNRuri.js} +1 -1
  38. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-gckfWESj.js → erDiagram-JOGREHBK-B5Frvvnz.js} +1 -1
  39. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D2mT2BUu.js → flowDiagram-UKHOOZJN-BapxROIM.js} +1 -1
  40. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-B_HR5qEw.js → ganttDiagram-PKOTCBZU-Cd4d4wQ0.js} +1 -1
  41. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BUw4fpSa.js → gitGraphDiagram-DS77QQ5N-CrL6buFW.js} +1 -1
  42. package/dist/worker/console/static/assets/{index-DPbsruV5.js → index-DBh_x_Ek.js} +46 -46
  43. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BSRAqEj0.js → infoDiagram-6WML65LV-BrSiNxyd.js} +1 -1
  44. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CLxE8hUp.js → ishikawaDiagram-WSZJBQD7-gvlvY-n9.js} +1 -1
  45. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-juFzVXIj.js → journeyDiagram-NVQOT4AX-D-YaPzRQ.js} +1 -1
  46. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CwLHVhOU.js → kanban-definition-27J2QSJJ-BsmDv9E_.js} +1 -1
  47. package/dist/worker/console/static/assets/{linear-9LfUyPAs.js → linear-UWaoDN3h.js} +1 -1
  48. package/dist/worker/console/static/assets/{mermaid.core-TPPHfQbe.js → mermaid.core-BlvpGVG8.js} +5 -5
  49. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-Dn_Z9RxF.js → mindmap-definition-FAOFIHXS-CUCIP5CP.js} +1 -1
  50. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CiEMKTgm.js → pegDiagram-VL7TDLO6-CB1aQUAK.js} +1 -1
  51. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-JASaGxjZ.js → pieDiagram-7S7Q4E2Y-BwehHxhf.js} +1 -1
  52. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-ChmH0ply.js → quadrantDiagram-CIZ2JOQS-CFz30APB.js} +1 -1
  53. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CtyM-HGQ.js → railroadDiagram-AXF67PYL-BwNmw1i6.js} +1 -1
  54. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BN9y_Bvs.js → requirementDiagram-LRYGKXZP-jg-tPXlI.js} +1 -1
  55. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-IyS0GQ1-.js → sankeyDiagram-W5VNT64P-BlyReDKl.js} +1 -1
  56. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DjxGXurN.js → sequenceDiagram-SI44F4Z6-DBUzW1f3.js} +1 -1
  57. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Cr3Z0S8y.js → sizeCapture-X5ZJPWSS-BYcI2RAr.js} +1 -1
  58. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BjJFC6pW.js → stateDiagram-OKZ733FA-D7AiyJdo.js} +1 -1
  59. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +1 -0
  60. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DeMUJQiW.js → swimlanes-SLNWSIFB-BHGt7Kkv.js} +2 -2
  61. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +8 -0
  62. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bc2Rv2Oo.js → timeline-definition-Z64GVDOM-OPjooWcX.js} +1 -1
  63. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CDhRUeqG.js → vennDiagram-T6HMQDX7-ggsWDvrO.js} +1 -1
  64. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DaN_z3Xy.js → wardleyDiagram-T6FBY63Y-DjNe-BjZ.js} +1 -1
  65. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-C1vTLBDG.js → xychartDiagram-ELKLHX3M-Byp63Hei.js} +1 -1
  66. package/dist/worker/console/static/index.html +1 -1
  67. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
  68. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  69. package/dist/workflows/dag/backend-test-pytest-collection.js +87 -4
  70. package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
  71. package/dist/workflows/dag/frontend-design-policy.js +19 -0
  72. package/dist/workflows/dag/frontend-implementation-contract.js +74 -2
  73. package/dist/workflows/dag/frontend-review-findings.js +238 -0
  74. package/dist/workflows/dag/frontend-shape.js +5 -3
  75. package/dist/workflows/dag/init-hybrid.js +90 -1
  76. package/dist/workflows/dag/node-execution.js +7 -0
  77. package/dist/workflows/dag/report.js +37 -1
  78. package/dist/workflows/dag/rerun-feedback.js +101 -1
  79. package/dist/workflows/dag/retry-policy.js +21 -3
  80. package/dist/workflows/dag/runner.js +46 -1
  81. package/dist/workflows/dag/validate.js +9 -3
  82. package/docs/templates/agent-dag-report.schema.json +8 -2
  83. package/docs/templates/agent-dag.schema.json +1 -1
  84. package/package.json +1 -1
  85. package/dist/worker/console/static/assets/channel-DsdX3ZUX.js +0 -1
  86. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CrvCyJ-8.js +0 -1
  87. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CrvCyJ-8.js +0 -1
  88. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Bq44O9Nt.js +0 -1
  89. 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
- function classifyCollectionFailure(output) {
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: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
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 boundary so `testcase.helpers_other` or a
392
- * substring inside a docstring sentence is not falsely flagged. Returns the
393
- * list of offending namespace module names (deduplicated, order-preserving).
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(stripNullValuesDeep, z
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|无接口|本地状态|local\s+state\s+only|pure\s+ui/i },
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
- !/\b(no\s+api|无接口|not-needed)\b/i.test(blob);
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
- !/\b(no\s+api|无接口|not-needed)\b/i.test(blob);
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: openspecGate.openspecCandidatePaths,
2965
+ candidates: promptCandidates,
2877
2966
  mandatoryPaths: openspecGate.openspecMandatoryPaths,
2878
2967
  });
2879
2968
  // Generation-frozen component/theme specification bucket (ADR 0016). Derived