@tea-agent/loop-agent 0.38.2 → 0.39.0-beta.0

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 (111) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -0
  3. package/dist/application/task-lifecycle/advance.js +4 -0
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/commands/task-advance.js +31 -0
  6. package/dist/executors/pi-event-serializer.js +42 -1
  7. package/dist/executors/pi-executor.js +51 -0
  8. package/dist/executors/pi-sdk-executor.js +7 -26
  9. package/dist/executors/shell-executor.js +2 -2
  10. package/dist/task/contract/constants.js +1 -0
  11. package/dist/task/contract/diff.js +26 -0
  12. package/dist/task/contract/project.js +5 -0
  13. package/dist/task/contract/schema.js +6 -1
  14. package/dist/task/source-prepare/build-draft.js +15 -0
  15. package/dist/worker/console/pi-readiness.js +4 -4
  16. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4bmo3iY.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  17. package/dist/worker/console/static/assets/{arc-SAD7McOS.js → arc-Bj2M8iO5.js} +1 -1
  18. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DAVLUdK1.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  19. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CPPdv4vj.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  20. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D1GRGZax.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  21. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  22. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D_Bq2ZqK.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CPLVtamK.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DnaZWcL3.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BRTmFgnq.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BRlfYZoH.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CjNybD4u.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-D3e-74fG.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Br3ir6f2.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-1NgVuRbh.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BTYuvuWN.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  32. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  33. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  34. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BC6Bgosf.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  35. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DV-HypfL.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  36. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DhTaddO-.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  37. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BnEcOmiI.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-xphGRVKp.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CrdD1mRG.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  40. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-D4YdLsaF.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  41. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-LCKyGZ5U.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  42. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BeiTe7t0.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  43. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BO2cq8GS.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  44. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CmWww3Nt.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  45. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C2kkUYg5.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  46. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DY_JZ2iQ.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  47. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BizhCSw9.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  48. package/dist/worker/console/static/assets/{index-BXN89VPX.js → index-DTZOKgAn.js} +142 -63
  49. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  50. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Dlo2eK_d.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  51. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  52. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DiMDe5Yh.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  53. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-sN553oIN.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  54. package/dist/worker/console/static/assets/{linear-D9Py31Ld.js → linear-DdnapKIH.js} +1 -1
  55. package/dist/worker/console/static/assets/{mermaid.core-D7Occzk_.js → mermaid.core-BVjAT9b8.js} +5 -5
  56. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DjDRboiR.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  57. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Cgc4qlzp.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  58. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DxZj8pKd.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  59. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CJ50MngR.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  60. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BY-OD8Tr.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  61. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CQFButfK.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  62. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BB_QUK_b.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  63. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CqhXZjyx.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  64. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-2l1U4rgJ.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  65. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BX1FbrhJ.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  66. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  67. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DOSnT7hb.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  68. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  69. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DVJQA7AN.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  70. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-74uxl9gx.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  71. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DsBxlcg_.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  72. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-snuW9Mjw.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  73. package/dist/worker/console/static/index.html +2 -2
  74. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  75. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +56 -4
  76. package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
  77. package/dist/workflows/dag/frontend-implementation-contract.js +92 -14
  78. package/dist/workflows/dag/frontend-prewrite-gate.js +106 -11
  79. package/dist/workflows/dag/init-hybrid.js +44 -11
  80. package/dist/workflows/dag/node-execution.js +26 -0
  81. package/dist/workflows/dag/prompt.js +23 -1
  82. package/dist/workflows/dag/types.js +6 -0
  83. package/dist/workflows/dag/upstream-artifacts.js +4 -0
  84. package/docs/operations/local-development-environment.md +1 -5
  85. package/docs/skills/vetted-skill-registry.md +14 -0
  86. package/docs/templates/README.md +1 -1
  87. package/docs/templates/backend-test-dag.json +2 -2
  88. package/package.json +8 -4
  89. package/skills/analyze-product-requirements/SKILL.md +8 -5
  90. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  91. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  92. package/skills/analyze-product-requirements/references/example.md +50 -0
  93. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  94. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  95. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  96. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  97. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  98. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  99. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  100. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  101. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  102. package/skills/improve-codebase-architecture/deepening.md +37 -0
  103. package/skills/improve-codebase-architecture/html-report.md +123 -0
  104. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  105. package/skills/improve-codebase-architecture/language.md +53 -0
  106. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +0 -1
  107. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +0 -1
  108. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +0 -1
  109. package/dist/worker/console/static/assets/index-CuH5CNFu.css +0 -1
  110. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +0 -1
  111. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +0 -8
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { z } from "zod";
6
6
  import YAML from "yaml";
7
7
  import { resolveBackendTestLayout, } from "./backend-test-layout.js";
8
- import { assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
8
+ import { applyOpenApiPartitionDomainPolicy, assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
9
9
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
10
10
  const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
11
11
  const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
@@ -486,6 +486,55 @@ export async function extractBackendTestOpenApiRules(input) {
486
486
  }
487
487
  return rules.sort((left, right) => left.ruleKey.localeCompare(right.ruleKey));
488
488
  }
489
+ export async function extractBackendTestOpenApiPartitionAxes(input) {
490
+ const axes = [];
491
+ const seen = new Set();
492
+ for (const referencePath of input.sourceBinding.referencePaths) {
493
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
494
+ let document;
495
+ try {
496
+ const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
497
+ if (!isRecord(parsed) || !isRecord(parsed.paths))
498
+ continue;
499
+ document = parsed;
500
+ }
501
+ catch {
502
+ continue;
503
+ }
504
+ for (const [apiPath, pathItem] of Object.entries(document.paths)) {
505
+ if (!isRecord(pathItem))
506
+ continue;
507
+ const operation = pathItem.get;
508
+ if (!isRecord(operation))
509
+ continue;
510
+ const parameterValues = [
511
+ ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
512
+ ...(Array.isArray(operation.parameters) ? operation.parameters : []),
513
+ ];
514
+ for (const rawParameter of parameterValues) {
515
+ const parameter = resolveOpenApiSchema(document, rawParameter);
516
+ if (!parameter || typeof parameter.name !== "string")
517
+ continue;
518
+ const location = parameter.in === "path" ? "path" : parameter.in === "query" ? "query" : undefined;
519
+ if (!location)
520
+ continue;
521
+ const property = resolveOpenApiSchema(document, parameter.schema);
522
+ const enumValues = property && Array.isArray(property.enum) ? property.enum.map(String) : null;
523
+ const identity = `GET ${apiPath}|${location}|${parameter.name}`;
524
+ if (seen.has(identity))
525
+ continue;
526
+ seen.add(identity);
527
+ axes.push({
528
+ operation: `GET ${apiPath}`,
529
+ name: parameter.name,
530
+ in: location,
531
+ enumValues,
532
+ });
533
+ }
534
+ }
535
+ }
536
+ return axes;
537
+ }
489
538
  function orderedUnique(values) {
490
539
  const result = [];
491
540
  const seen = new Set();
@@ -985,8 +1034,11 @@ export async function analyzeBackendTestCaseCoverage(input) {
985
1034
  // Plan C: deterministic query/filter axis partitions (fail-closed on missing slots).
986
1035
  const parsedPartitions = parseScenarioPartitions(readme);
987
1036
  findings.push(...parsedPartitions.issues);
1037
+ const openApiPartitionAxes = await extractBackendTestOpenApiPartitionAxes(input);
1038
+ const partitionedBySource = applyOpenApiPartitionDomainPolicy(parsedPartitions.partitions, openApiPartitionAxes);
1039
+ findings.push(...partitionedBySource.issues);
988
1040
  const declaredPointUniverse = new Set(cases.flatMap((item) => item.testPoints));
989
- const partitionAssessment = assessScenarioPartitionCoverage(parsedPartitions.partitions, declaredPointUniverse);
1041
+ const partitionAssessment = assessScenarioPartitionCoverage(partitionedBySource.partitions, declaredPointUniverse);
990
1042
  findings.push(...partitionAssessment.findings);
991
1043
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
992
1044
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
@@ -1003,7 +1055,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
1003
1055
  sourceBinding: input.sourceBinding,
1004
1056
  inputFiles: await inputFileFacts(input.workspaceRoot, [
1005
1057
  ...((await exists(readmePath)) ? [readmePath] : []),
1006
- ...(await markdownModuleFiles(input.workspaceRoot)),
1058
+ ...(await markdownModuleFiles(input.workspaceRoot, layout)),
1007
1059
  ...(await Promise.all(input.sourceBinding.referencePaths.map(async (referencePath) => {
1008
1060
  const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
1009
1061
  return (await exists(physicalPath)) ? physicalPath : "";
@@ -1794,7 +1846,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1794
1846
  status: findings.length === 0 ? "PASS" : "FAIL",
1795
1847
  inputFiles: await inputFileFacts(input.workspaceRoot, [
1796
1848
  path.join(input.workspaceRoot, ...layout.readmePath.split("/")),
1797
- ...(await markdownModuleFiles(input.workspaceRoot)),
1849
+ ...(await markdownModuleFiles(input.workspaceRoot, layout)),
1798
1850
  ...scriptFiles,
1799
1851
  ].filter((file) => file && path.isAbsolute(file))),
1800
1852
  summary: {
@@ -35,6 +35,8 @@ export const scenarioPartitionRowSchema = z.object({
35
35
  const PARTITION_HEADING = /^##\s+Scenario Partitions\s*$/;
36
36
  const PARTITION_TABLE_HEADER = /^\|\s*Partition ID\s*\|\s*Operation\s*\|\s*Axis\s*\|\s*Domain\s*\|\s*Required Slots\s*\|\s*Expected by Slot\s*\|\s*Bind Rule\s*\|\s*$/;
37
37
  const TABLE_ROW = /^\|(.+)\|$/;
38
+ const FINITE_DOMAIN_MEMBER = /^[A-Za-z0-9]+(?:[._\- ][A-Za-z0-9]+)*$/;
39
+ const RESERVED_SLOT_TOKENS = new Set(["OMITTED", "NOT-IN-SET"]);
38
40
  function splitCells(raw) {
39
41
  return raw
40
42
  .split("|")
@@ -100,6 +102,11 @@ export function parseScenarioPartitions(readmeMarkdown) {
100
102
  continue;
101
103
  }
102
104
  seen.add(partitionId);
105
+ const domainIssue = describeInvalidPartitionDomain(domain);
106
+ if (domainIssue) {
107
+ issues.push(`INVALID_PARTITION_DOMAIN: partition ${partitionId} ${domainIssue}`);
108
+ continue;
109
+ }
103
110
  partitions.push({
104
111
  partitionId,
105
112
  operation,
@@ -119,6 +126,40 @@ function slotToken(value) {
119
126
  .replace(/[^A-Z0-9]+/g, "-")
120
127
  .replace(/^-+|-+$/g, "");
121
128
  }
129
+ function describeInvalidPartitionDomain(domain) {
130
+ const tokens = [];
131
+ const seenTokens = new Set();
132
+ for (const value of domain) {
133
+ if (!FINITE_DOMAIN_MEMBER.test(value)) {
134
+ return `value is not a finite identifier member: ${value}`;
135
+ }
136
+ const token = slotToken(value);
137
+ if (!token) {
138
+ return `value cannot produce a stable non-empty slot token: ${value}`;
139
+ }
140
+ if (RESERVED_SLOT_TOKENS.has(token)) {
141
+ return `value maps to reserved slot token ${token}: ${value}`;
142
+ }
143
+ if (seenTokens.has(token)) {
144
+ return `values collapse to duplicate slot token ${token}`;
145
+ }
146
+ seenTokens.add(token);
147
+ tokens.push(token);
148
+ }
149
+ return tokens.length === 0 ? "domain produced no slot tokens" : undefined;
150
+ }
151
+ function normalizePartitionAxisName(value) {
152
+ return value.trim().replace(/^(?:query|path)\s*:\s*/i, "").toLowerCase();
153
+ }
154
+ function normalizePartitionOperation(value) {
155
+ return value.trim().replace(/\s+/g, " ").toUpperCase();
156
+ }
157
+ function sameFiniteSet(left, right) {
158
+ if (left.length !== right.length)
159
+ return false;
160
+ const expected = new Set(left);
161
+ return right.every((value) => expected.has(value));
162
+ }
122
163
  const STABLE_PARTITION_ID = /^SP-[A-Z0-9][A-Z0-9._-]*$/i;
123
164
  /**
124
165
  * Slot IDs are `TP-<Partition ID>-<VALUE|OMITTED|NOT-IN-SET>`.
@@ -138,13 +179,16 @@ function partitionToken(row) {
138
179
  export function expandScenarioPartitions(partitions) {
139
180
  const slots = [];
140
181
  for (const row of partitions) {
182
+ if (describeInvalidPartitionDomain(row.domain))
183
+ continue;
141
184
  const prefix = partitionToken(row);
142
185
  for (const value of row.domain) {
186
+ const token = slotToken(value);
143
187
  slots.push({
144
188
  partitionId: row.partitionId,
145
189
  operation: row.operation,
146
190
  axis: row.axis,
147
- slotId: `${prefix}-${slotToken(value)}`,
191
+ slotId: `${prefix}-${token}`,
148
192
  kind: "each-value",
149
193
  value,
150
194
  intent: "nominal-filter",
@@ -176,6 +220,34 @@ export function expandScenarioPartitions(partitions) {
176
220
  }
177
221
  return slots;
178
222
  }
223
+ /**
224
+ * Bind parsed partitions to GET query/path OpenAPI axes.
225
+ * Unconfirmed axes keep identifier-valid Domain rows (requirement finite sets).
226
+ */
227
+ export function applyOpenApiPartitionDomainPolicy(partitions, axes) {
228
+ const issues = [];
229
+ const valid = [];
230
+ for (const row of partitions) {
231
+ const operation = normalizePartitionOperation(row.operation);
232
+ const axisName = normalizePartitionAxisName(row.axis);
233
+ const matched = axes.find((axis) => normalizePartitionOperation(axis.operation) === operation &&
234
+ normalizePartitionAxisName(axis.name) === axisName);
235
+ if (!matched) {
236
+ valid.push(row);
237
+ continue;
238
+ }
239
+ if (matched.enumValues === null) {
240
+ issues.push(`NO_FINITE_DOMAIN: partition ${row.partitionId} axis ${row.axis} exists on ${row.operation} without a documented enum`);
241
+ continue;
242
+ }
243
+ if (!sameFiniteSet(matched.enumValues, row.domain)) {
244
+ issues.push(`PARTITION_SOURCE_MISMATCH: partition ${row.partitionId} domain does not match OpenAPI enum for ${row.operation} ${row.axis}`);
245
+ continue;
246
+ }
247
+ valid.push(row);
248
+ }
249
+ return { partitions: valid, issues };
250
+ }
179
251
  /** Facts projection consumed by N6 and plan D gap-fill. */
180
252
  export const scenarioPartitionFactsSchema = z.object({
181
253
  partitions: z.array(z.object({
@@ -41,6 +41,9 @@ export function serializeDeterministicJson(value) {
41
41
  export function deterministicSha256(value) {
42
42
  return sha256Hex(serializeDeterministicJson(value));
43
43
  }
44
+ /** Provider-safe limits for the two planner outputs. Larger work must split. */
45
+ export const FRONTEND_PLAN_RESPONSE_MAX_BYTES = 14 * 1024;
46
+ export const FRONTEND_CANONICAL_CONTRACT_MAX_BYTES = 16 * 1024;
44
47
  /** A contract materialization failure carries its classification plus the
45
48
  * audit context collected up to the failure point, so the prewrite gate can
46
49
  * persist a complete frontend-prewrite-result-v1 even when the canonical
@@ -1722,9 +1725,37 @@ function extractSingleOpenspecCitationsBlock(text) {
1722
1725
  }
1723
1726
  return blocks[0];
1724
1727
  }
1728
+ /**
1729
+ * Frontend plan/revision output has two JSON-bearing regions: the primary
1730
+ * payload and line-oriented OpenSpec citations. Keep their parsing domains
1731
+ * separate so a citation row can never become a competing contract candidate.
1732
+ * A bare JSON payload is retained only for historical node-output compatibility.
1733
+ */
1734
+ export function extractFrontendPlanPayload(text) {
1735
+ const citationsBlock = extractSingleOpenspecCitationsBlock(text);
1736
+ return {
1737
+ payload: extractFrontendPlanPrimaryPayload(text.replace(citationsBlock, "")),
1738
+ citationsBlock,
1739
+ };
1740
+ }
1741
+ function extractFrontendPlanPrimaryPayload(text) {
1742
+ const fences = [...text.matchAll(/```json[ \t]*\r?\n([\s\S]*?)\r?\n```/gi)];
1743
+ if (fences.length > 1) {
1744
+ throw new Error(`frontend plan output must contain exactly one primary fenced json payload (found ${fences.length})`);
1745
+ }
1746
+ if (fences.length === 1) {
1747
+ try {
1748
+ return JSON.parse(fences[0][1]);
1749
+ }
1750
+ catch (error) {
1751
+ throw new Error(`frontend plan primary json fence is invalid: ${error instanceof Error ? error.message : String(error)}`);
1752
+ }
1753
+ }
1754
+ return extractFrontendImplementationJson(text);
1755
+ }
1725
1756
  async function writeFrontendPlanCandidateRaw(input) {
1726
1757
  const relativePath = path.posix.join("contracts", "candidates", input.nodeId, `attempt-${input.attempt}.raw.md`);
1727
- await writeTextArtifactFile(path.join(input.runDir, relativePath), input.text.endsWith("\n") ? input.text : `${input.text}\n`);
1758
+ await writeTextArtifactFile(path.join(input.runDir, relativePath), input.text);
1728
1759
  return relativePath;
1729
1760
  }
1730
1761
  /**
@@ -1765,18 +1796,23 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1765
1796
  });
1766
1797
  };
1767
1798
  try {
1799
+ if (Buffer.byteLength(input.text, "utf8") > FRONTEND_PLAN_RESPONSE_MAX_BYTES) {
1800
+ throw new Error(`frontend plan response exceeds the ${FRONTEND_PLAN_RESPONSE_MAX_BYTES}-byte limit`);
1801
+ }
1768
1802
  if (!input.sourceBinding)
1769
1803
  throw new Error("frontend plan patch validator requires DAG sourceBinding");
1770
1804
  const skeleton = input.structuredContractOutput?.skeleton;
1771
1805
  if (!skeleton)
1772
1806
  throw new Error("frontend plan patch validator requires a deterministic runtime skeleton");
1773
- const patch = extractFrontendImplementationJson(input.text);
1807
+ const patch = extractFrontendPlanPrimaryPayload(input.text);
1774
1808
  if (!isPlainObject(patch))
1775
1809
  throw new Error("frontend plan patch must extract to one JSON object");
1776
1810
  extractedPatchPath = path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`);
1777
1811
  const extractedArtifact = await writeDeterministicJsonArtifact(input.runDir, extractedPatchPath, patch);
1778
1812
  extractedPatchSha256 = extractedArtifact.sha256;
1779
1813
  const citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1814
+ const citationsArtifactPath = path.posix.join(candidateDir, `attempt-${attempt}.openspec-citations.md`);
1815
+ await writeTextArtifactFile(path.join(input.runDir, citationsArtifactPath), `${citationsBlock}\n`);
1780
1816
  const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1781
1817
  if (protectedViolations.length > 0)
1782
1818
  throw new Error(`frontend plan patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
@@ -1790,6 +1826,10 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1790
1826
  rawContractText: serializeDeterministicJson(merged),
1791
1827
  sourceBinding: input.sourceBinding,
1792
1828
  });
1829
+ if (Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8") >
1830
+ FRONTEND_CANONICAL_CONTRACT_MAX_BYTES) {
1831
+ throw new Error(`frontend canonical contract exceeds the ${FRONTEND_CANONICAL_CONTRACT_MAX_BYTES}-byte limit; split the task`);
1832
+ }
1793
1833
  const normalizedArtifact = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.normalized.json`), analysis.canonical);
1794
1834
  await writeReport({
1795
1835
  classification: "accepted-normalized",
@@ -1807,6 +1847,19 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1807
1847
  return {
1808
1848
  ok: true,
1809
1849
  contract: analysis.canonical,
1850
+ structuredArtifact: {
1851
+ path: normalizedArtifact.path,
1852
+ sha256: normalizedArtifact.sha256,
1853
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1854
+ },
1855
+ frontendPlanOutput: {
1856
+ schemaVersion: 1,
1857
+ payloadKind: "canonical-contract",
1858
+ citationsArtifactPath: path.join(input.runDir, citationsArtifactPath),
1859
+ citationsArtifactSha256: sha256Hex(`${citationsBlock}\n`),
1860
+ rawCandidatePath: path.join(input.runDir, rawPath),
1861
+ rawCandidateSha256: sha256Hex(input.text),
1862
+ },
1810
1863
  normalizedText: [
1811
1864
  "Frontend implementation contract compiled from the runtime skeleton.",
1812
1865
  "",
@@ -1862,27 +1915,32 @@ export async function validateFrontendContractNodeOutput(input) {
1862
1915
  * the prewrite gate, which remains the only authority.
1863
1916
  */
1864
1917
  export async function validateFrontendRevisionPatchNodeOutput(input) {
1865
- let citationsBlock;
1866
- try {
1867
- citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1868
- }
1869
- catch (error) {
1918
+ const nodeId = input.nodeId ?? "frontend-plan-revision-pi";
1919
+ const attempt = input.attempt ?? 1;
1920
+ const candidateDir = path.posix.join("contracts", "candidates", nodeId);
1921
+ const rawPath = await writeFrontendPlanCandidateRaw({
1922
+ runDir: input.runDir,
1923
+ nodeId,
1924
+ attempt,
1925
+ text: input.text,
1926
+ });
1927
+ if (Buffer.byteLength(input.text, "utf8") > FRONTEND_PLAN_RESPONSE_MAX_BYTES) {
1870
1928
  return {
1871
1929
  ok: false,
1872
- reason: error instanceof Error ? error.message : String(error),
1930
+ reason: `frontend revision response exceeds the ${FRONTEND_PLAN_RESPONSE_MAX_BYTES}-byte limit`,
1873
1931
  };
1874
1932
  }
1875
- // Strip the citation blocks before extraction: their per-row JSON objects
1876
- // would otherwise count as competing balanced objects in the extractor.
1877
- const textWithoutCitations = input.text.replace(citationsBlock, "");
1933
+ let citationsBlock;
1878
1934
  let patch;
1879
1935
  try {
1880
- patch = extractFrontendImplementationJson(textWithoutCitations);
1936
+ const parsed = extractFrontendPlanPayload(input.text);
1937
+ citationsBlock = parsed.citationsBlock;
1938
+ patch = parsed.payload;
1881
1939
  }
1882
1940
  catch (error) {
1883
1941
  return {
1884
1942
  ok: false,
1885
- reason: `revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`,
1943
+ reason: error instanceof Error ? error.message : String(error),
1886
1944
  };
1887
1945
  }
1888
1946
  if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
@@ -1898,7 +1956,27 @@ export async function validateFrontendRevisionPatchNodeOutput(input) {
1898
1956
  reason: `frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`,
1899
1957
  };
1900
1958
  }
1901
- return { ok: true, contract: patch };
1959
+ const payloadPath = path.posix.join(candidateDir, `attempt-${attempt}.patch.json`);
1960
+ const payloadArtifact = await writeDeterministicJsonArtifact(input.runDir, payloadPath, patch);
1961
+ const citationsArtifactPath = path.posix.join(candidateDir, `attempt-${attempt}.openspec-citations.md`);
1962
+ await writeTextArtifactFile(path.join(input.runDir, citationsArtifactPath), `${citationsBlock}\n`);
1963
+ return {
1964
+ ok: true,
1965
+ contract: patch,
1966
+ structuredArtifact: {
1967
+ path: payloadArtifact.path,
1968
+ sha256: payloadArtifact.sha256,
1969
+ schemaId: "frontend-implementation-contract-revision-patch-v1",
1970
+ },
1971
+ frontendPlanOutput: {
1972
+ schemaVersion: 1,
1973
+ payloadKind: "merge-patch",
1974
+ citationsArtifactPath: path.join(input.runDir, citationsArtifactPath),
1975
+ citationsArtifactSha256: sha256Hex(`${citationsBlock}\n`),
1976
+ rawCandidatePath: path.join(input.runDir, rawPath),
1977
+ rawCandidateSha256: sha256Hex(input.text),
1978
+ },
1979
+ };
1902
1980
  }
1903
1981
  export async function materializeFrontendImplementationContract(input) {
1904
1982
  const analysis = await analyzeFrontendImplementationContract(input);
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
5
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
6
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
7
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
7
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_CANONICAL_CONTRACT_MAX_BYTES, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
8
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
9
9
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
10
10
  import { pathMatchesPattern } from "../../shared/git-progress.js";
@@ -12,7 +12,7 @@ import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
12
12
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
13
13
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
14
14
  /** Canonical contract serialization must stay at or below this byte size. */
15
- export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
15
+ export const FRONTEND_CONTRACT_MAX_BYTES = FRONTEND_CANONICAL_CONTRACT_MAX_BYTES;
16
16
  /**
17
17
  * Machine-readable failure classification for blocked/retryable-invalid
18
18
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -176,6 +176,82 @@ async function readNodeRawText(runDir, nodeId) {
176
176
  const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
177
177
  return record.assistantText ?? record.stdout ?? "";
178
178
  }
179
+ function resolveRunOwnedArtifact(runDir, candidate, label) {
180
+ const root = path.resolve(runDir);
181
+ const resolved = path.resolve(candidate);
182
+ const relative = path.relative(root, resolved);
183
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
184
+ throw new Error(`frontend prewrite gate ${label} must be contained by the current run directory`);
185
+ }
186
+ return resolved;
187
+ }
188
+ async function readHashBoundArtifact(input) {
189
+ const resolved = resolveRunOwnedArtifact(input.runDir, input.artifactPath, input.label);
190
+ const text = await readFile(resolved, "utf8");
191
+ const actual = sha256Hex(text);
192
+ if (actual !== input.expectedSha256) {
193
+ throw new Error(`frontend prewrite gate ${input.label} SHA-256 mismatch (expected ${input.expectedSha256}, got ${actual})`);
194
+ }
195
+ return text;
196
+ }
197
+ async function readFrontendPlanNodeInput(runDir, nodeId) {
198
+ const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
199
+ if (record.status !== "FINISHED") {
200
+ throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
201
+ }
202
+ const frontend = record.frontendPlanOutput;
203
+ if (!frontend) {
204
+ const payloadText = record.assistantText?.trim() || record.stdout?.trim() || "";
205
+ if (!payloadText)
206
+ throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
207
+ return {
208
+ artifactFirst: false,
209
+ payloadText,
210
+ citationsText: payloadText,
211
+ rawCandidateSha256: sha256Hex(record.assistantText ?? record.stdout ?? ""),
212
+ payloadKind: "canonical-contract",
213
+ };
214
+ }
215
+ if (frontend.schemaVersion !== 1 ||
216
+ !record.structuredArtifactPath ||
217
+ !record.structuredArtifactSha256 ||
218
+ !record.structuredArtifactSchemaId) {
219
+ throw new Error(`frontend prewrite gate ${nodeId} has incomplete artifact-first metadata`);
220
+ }
221
+ const expectedSchema = frontend.payloadKind === "canonical-contract"
222
+ ? "frontend-implementation-contract-v1"
223
+ : "frontend-implementation-contract-revision-patch-v1";
224
+ if (record.structuredArtifactSchemaId !== expectedSchema) {
225
+ throw new Error(`frontend prewrite gate ${nodeId} artifact schema ${record.structuredArtifactSchemaId} does not match ${frontend.payloadKind}`);
226
+ }
227
+ const [payloadText, citationsText] = await Promise.all([
228
+ readHashBoundArtifact({
229
+ runDir,
230
+ artifactPath: record.structuredArtifactPath,
231
+ expectedSha256: record.structuredArtifactSha256,
232
+ label: `${nodeId} primary payload`,
233
+ }),
234
+ readHashBoundArtifact({
235
+ runDir,
236
+ artifactPath: frontend.citationsArtifactPath,
237
+ expectedSha256: frontend.citationsArtifactSha256,
238
+ label: `${nodeId} citations`,
239
+ }),
240
+ readHashBoundArtifact({
241
+ runDir,
242
+ artifactPath: frontend.rawCandidatePath,
243
+ expectedSha256: frontend.rawCandidateSha256,
244
+ label: `${nodeId} raw candidate`,
245
+ }).then(() => undefined),
246
+ ]);
247
+ return {
248
+ artifactFirst: true,
249
+ payloadText,
250
+ citationsText,
251
+ rawCandidateSha256: frontend.rawCandidateSha256,
252
+ payloadKind: frontend.payloadKind,
253
+ };
254
+ }
179
255
  /**
180
256
  * Same shape predicate as the internal `isContract` inside
181
257
  * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
@@ -458,9 +534,9 @@ export async function runFrontendPrewriteGate(input) {
458
534
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
459
535
  openspecPolicy,
460
536
  };
461
- let planText;
537
+ let planInput;
462
538
  try {
463
- planText = await readNodeText(input.runDir, planNodeId);
539
+ planInput = await readFrontendPlanNodeInput(input.runDir, planNodeId);
464
540
  }
465
541
  catch (error) {
466
542
  return finalizePrewrite(input, {
@@ -470,6 +546,9 @@ export async function runFrontendPrewriteGate(input) {
470
546
  failureCode: "plan-text-unreadable",
471
547
  });
472
548
  }
549
+ const planText = planInput.payloadText;
550
+ const citationsText = planInput.citationsText;
551
+ basePending.candidateRawSha256 = planInput.rawCandidateSha256;
473
552
  let reviewText;
474
553
  try {
475
554
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -502,7 +581,20 @@ export async function runFrontendPrewriteGate(input) {
502
581
  if (revisionPatchMode) {
503
582
  const originalNodeId = input.config.planFallbackFromNodeIds[0];
504
583
  try {
505
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
584
+ const originalInput = await readFrontendPlanNodeInput(input.runDir, originalNodeId);
585
+ if (planInput.artifactFirst && !originalInput.artifactFirst) {
586
+ throw new Error(`artifact-first revision ${planNodeId} requires artifact-first original contract ${originalNodeId}`);
587
+ }
588
+ if (planInput.artifactFirst) {
589
+ if (planInput.payloadKind !== "merge-patch" ||
590
+ originalInput.payloadKind !== "canonical-contract") {
591
+ throw new Error("artifact-first revision/original payload kinds are invalid");
592
+ }
593
+ effectiveContractText = serializeDeterministicJson(applyFrontendImplementationContractPatch(JSON.parse(originalInput.payloadText), JSON.parse(planInput.payloadText)));
594
+ }
595
+ else {
596
+ effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
597
+ }
506
598
  }
507
599
  catch (error) {
508
600
  return finalizePrewrite(input, {
@@ -610,14 +702,17 @@ export async function runFrontendPrewriteGate(input) {
610
702
  workspaceRoot,
611
703
  });
612
704
  }
613
- // Candidate -> canonical conversion. The gate consumes the selected
614
- // plan/revision assistant output directly and is the only conversion point.
705
+ // Candidate -> canonical conversion. Artifact-first inputs must keep using
706
+ // the already hash-verified payload: re-reading the selected node record
707
+ // here would silently fall back to its prose and defeat the integrity gate.
708
+ // Historical records intentionally retain their node-text compatibility path.
615
709
  let analysis;
616
710
  try {
711
+ const rawContractText = effectiveContractText ?? (planInput.artifactFirst ? planText : undefined);
617
712
  analysis = await analyzeFrontendImplementationContract({
618
713
  runDir: input.runDir,
619
- fromNodeId: effectiveContractText ? undefined : planNodeId,
620
- rawContractText: effectiveContractText ?? undefined,
714
+ fromNodeId: rawContractText ? undefined : planNodeId,
715
+ rawContractText,
621
716
  sourceBinding: input.sourceBinding,
622
717
  });
623
718
  }
@@ -762,7 +857,7 @@ export async function runFrontendPrewriteGate(input) {
762
857
  repoRoot: candidateRoot,
763
858
  });
764
859
  if (candidatePaths.length > 0) {
765
- const parsed = parseOpenspecCitations(planText, candidateRoot);
860
+ const parsed = parseOpenspecCitations(citationsText, candidateRoot);
766
861
  if (!parsed.blockPresent || parsed.unparseable) {
767
862
  return finalizePrewrite(input, {
768
863
  ...basePending,
@@ -903,7 +998,7 @@ export async function runFrontendPrewriteGate(input) {
903
998
  });
904
999
  }
905
1000
  if (openspecPolicy === "cited") {
906
- const parsedCitations = parseOpenspecCitations(planText, candidateRoot);
1001
+ const parsedCitations = parseOpenspecCitations(citationsText, candidateRoot);
907
1002
  const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
908
1003
  const readSet = new Set(openspecReadPaths);
909
1004
  const uncorroborated = [