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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +79 -17
  78. package/dist/workflows/dag/frontend-prewrite-gate.js +104 -27
  79. package/dist/workflows/dag/init-hybrid.js +59 -31
  80. package/dist/workflows/dag/node-execution.js +52 -2
  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({
@@ -1722,9 +1722,37 @@ function extractSingleOpenspecCitationsBlock(text) {
1722
1722
  }
1723
1723
  return blocks[0];
1724
1724
  }
1725
+ /**
1726
+ * Frontend plan/revision output has two JSON-bearing regions: the primary
1727
+ * payload and line-oriented OpenSpec citations. Keep their parsing domains
1728
+ * separate so a citation row can never become a competing contract candidate.
1729
+ * A bare JSON payload is retained only for historical node-output compatibility.
1730
+ */
1731
+ export function extractFrontendPlanPayload(text) {
1732
+ const citationsBlock = extractSingleOpenspecCitationsBlock(text);
1733
+ return {
1734
+ payload: extractFrontendPlanPrimaryPayload(text.replace(citationsBlock, "")),
1735
+ citationsBlock,
1736
+ };
1737
+ }
1738
+ function extractFrontendPlanPrimaryPayload(text) {
1739
+ const fences = [...text.matchAll(/```json[ \t]*\r?\n([\s\S]*?)\r?\n```/gi)];
1740
+ if (fences.length > 1) {
1741
+ throw new Error(`frontend plan output must contain exactly one primary fenced json payload (found ${fences.length})`);
1742
+ }
1743
+ if (fences.length === 1) {
1744
+ try {
1745
+ return JSON.parse(fences[0][1]);
1746
+ }
1747
+ catch (error) {
1748
+ throw new Error(`frontend plan primary json fence is invalid: ${error instanceof Error ? error.message : String(error)}`);
1749
+ }
1750
+ }
1751
+ return extractFrontendImplementationJson(text);
1752
+ }
1725
1753
  async function writeFrontendPlanCandidateRaw(input) {
1726
1754
  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`);
1755
+ await writeTextArtifactFile(path.join(input.runDir, relativePath), input.text);
1728
1756
  return relativePath;
1729
1757
  }
1730
1758
  /**
@@ -1770,13 +1798,15 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1770
1798
  const skeleton = input.structuredContractOutput?.skeleton;
1771
1799
  if (!skeleton)
1772
1800
  throw new Error("frontend plan patch validator requires a deterministic runtime skeleton");
1773
- const patch = extractFrontendImplementationJson(input.text);
1801
+ const patch = extractFrontendPlanPrimaryPayload(input.text);
1774
1802
  if (!isPlainObject(patch))
1775
1803
  throw new Error("frontend plan patch must extract to one JSON object");
1776
1804
  extractedPatchPath = path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`);
1777
1805
  const extractedArtifact = await writeDeterministicJsonArtifact(input.runDir, extractedPatchPath, patch);
1778
1806
  extractedPatchSha256 = extractedArtifact.sha256;
1779
1807
  const citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1808
+ const citationsArtifactPath = path.posix.join(candidateDir, `attempt-${attempt}.openspec-citations.md`);
1809
+ await writeTextArtifactFile(path.join(input.runDir, citationsArtifactPath), `${citationsBlock}\n`);
1780
1810
  const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1781
1811
  if (protectedViolations.length > 0)
1782
1812
  throw new Error(`frontend plan patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
@@ -1807,6 +1837,19 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1807
1837
  return {
1808
1838
  ok: true,
1809
1839
  contract: analysis.canonical,
1840
+ structuredArtifact: {
1841
+ path: normalizedArtifact.path,
1842
+ sha256: normalizedArtifact.sha256,
1843
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1844
+ },
1845
+ frontendPlanOutput: {
1846
+ schemaVersion: 1,
1847
+ payloadKind: "canonical-contract",
1848
+ citationsArtifactPath: path.join(input.runDir, citationsArtifactPath),
1849
+ citationsArtifactSha256: sha256Hex(`${citationsBlock}\n`),
1850
+ rawCandidatePath: path.join(input.runDir, rawPath),
1851
+ rawCandidateSha256: sha256Hex(input.text),
1852
+ },
1810
1853
  normalizedText: [
1811
1854
  "Frontend implementation contract compiled from the runtime skeleton.",
1812
1855
  "",
@@ -1862,27 +1905,26 @@ export async function validateFrontendContractNodeOutput(input) {
1862
1905
  * the prewrite gate, which remains the only authority.
1863
1906
  */
1864
1907
  export async function validateFrontendRevisionPatchNodeOutput(input) {
1908
+ const nodeId = input.nodeId ?? "frontend-plan-revision-pi";
1909
+ const attempt = input.attempt ?? 1;
1910
+ const candidateDir = path.posix.join("contracts", "candidates", nodeId);
1911
+ const rawPath = await writeFrontendPlanCandidateRaw({
1912
+ runDir: input.runDir,
1913
+ nodeId,
1914
+ attempt,
1915
+ text: input.text,
1916
+ });
1865
1917
  let citationsBlock;
1866
- try {
1867
- citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1868
- }
1869
- catch (error) {
1870
- return {
1871
- ok: false,
1872
- reason: error instanceof Error ? error.message : String(error),
1873
- };
1874
- }
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, "");
1878
1918
  let patch;
1879
1919
  try {
1880
- patch = extractFrontendImplementationJson(textWithoutCitations);
1920
+ const parsed = extractFrontendPlanPayload(input.text);
1921
+ citationsBlock = parsed.citationsBlock;
1922
+ patch = parsed.payload;
1881
1923
  }
1882
1924
  catch (error) {
1883
1925
  return {
1884
1926
  ok: false,
1885
- reason: `revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`,
1927
+ reason: error instanceof Error ? error.message : String(error),
1886
1928
  };
1887
1929
  }
1888
1930
  if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
@@ -1898,7 +1940,27 @@ export async function validateFrontendRevisionPatchNodeOutput(input) {
1898
1940
  reason: `frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`,
1899
1941
  };
1900
1942
  }
1901
- return { ok: true, contract: patch };
1943
+ const payloadPath = path.posix.join(candidateDir, `attempt-${attempt}.patch.json`);
1944
+ const payloadArtifact = await writeDeterministicJsonArtifact(input.runDir, payloadPath, patch);
1945
+ const citationsArtifactPath = path.posix.join(candidateDir, `attempt-${attempt}.openspec-citations.md`);
1946
+ await writeTextArtifactFile(path.join(input.runDir, citationsArtifactPath), `${citationsBlock}\n`);
1947
+ return {
1948
+ ok: true,
1949
+ contract: patch,
1950
+ structuredArtifact: {
1951
+ path: payloadArtifact.path,
1952
+ sha256: payloadArtifact.sha256,
1953
+ schemaId: "frontend-implementation-contract-revision-patch-v1",
1954
+ },
1955
+ frontendPlanOutput: {
1956
+ schemaVersion: 1,
1957
+ payloadKind: "merge-patch",
1958
+ citationsArtifactPath: path.join(input.runDir, citationsArtifactPath),
1959
+ citationsArtifactSha256: sha256Hex(`${citationsBlock}\n`),
1960
+ rawCandidatePath: path.join(input.runDir, rawPath),
1961
+ rawCandidateSha256: sha256Hex(input.text),
1962
+ },
1963
+ };
1902
1964
  }
1903
1965
  export async function materializeFrontendImplementationContract(input) {
1904
1966
  const analysis = await analyzeFrontendImplementationContract(input);
@@ -11,8 +11,6 @@ import { pathMatchesPattern } from "../../shared/git-progress.js";
11
11
  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
- /** Canonical contract serialization must stay at or below this byte size. */
15
- export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
16
14
  /**
17
15
  * Machine-readable failure classification for blocked/retryable-invalid
18
16
  * prewrite outcomes. Kept alongside the human-readable failureReason so
@@ -39,7 +37,6 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
39
37
  "component-spec-reference-invalid",
40
38
  "component-spec-not-cited",
41
39
  "component-choices-missing",
42
- "contract-too-large",
43
40
  "revision-patch-unmergeable",
44
41
  ]);
45
42
  export const frontendPrewriteResultV1Schema = z
@@ -176,6 +173,82 @@ async function readNodeRawText(runDir, nodeId) {
176
173
  const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
177
174
  return record.assistantText ?? record.stdout ?? "";
178
175
  }
176
+ function resolveRunOwnedArtifact(runDir, candidate, label) {
177
+ const root = path.resolve(runDir);
178
+ const resolved = path.resolve(candidate);
179
+ const relative = path.relative(root, resolved);
180
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
181
+ throw new Error(`frontend prewrite gate ${label} must be contained by the current run directory`);
182
+ }
183
+ return resolved;
184
+ }
185
+ async function readHashBoundArtifact(input) {
186
+ const resolved = resolveRunOwnedArtifact(input.runDir, input.artifactPath, input.label);
187
+ const text = await readFile(resolved, "utf8");
188
+ const actual = sha256Hex(text);
189
+ if (actual !== input.expectedSha256) {
190
+ throw new Error(`frontend prewrite gate ${input.label} SHA-256 mismatch (expected ${input.expectedSha256}, got ${actual})`);
191
+ }
192
+ return text;
193
+ }
194
+ async function readFrontendPlanNodeInput(runDir, nodeId) {
195
+ const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
196
+ if (record.status !== "FINISHED") {
197
+ throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
198
+ }
199
+ const frontend = record.frontendPlanOutput;
200
+ if (!frontend) {
201
+ const payloadText = record.assistantText?.trim() || record.stdout?.trim() || "";
202
+ if (!payloadText)
203
+ throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
204
+ return {
205
+ artifactFirst: false,
206
+ payloadText,
207
+ citationsText: payloadText,
208
+ rawCandidateSha256: sha256Hex(record.assistantText ?? record.stdout ?? ""),
209
+ payloadKind: "canonical-contract",
210
+ };
211
+ }
212
+ if (frontend.schemaVersion !== 1 ||
213
+ !record.structuredArtifactPath ||
214
+ !record.structuredArtifactSha256 ||
215
+ !record.structuredArtifactSchemaId) {
216
+ throw new Error(`frontend prewrite gate ${nodeId} has incomplete artifact-first metadata`);
217
+ }
218
+ const expectedSchema = frontend.payloadKind === "canonical-contract"
219
+ ? "frontend-implementation-contract-v1"
220
+ : "frontend-implementation-contract-revision-patch-v1";
221
+ if (record.structuredArtifactSchemaId !== expectedSchema) {
222
+ throw new Error(`frontend prewrite gate ${nodeId} artifact schema ${record.structuredArtifactSchemaId} does not match ${frontend.payloadKind}`);
223
+ }
224
+ const [payloadText, citationsText] = await Promise.all([
225
+ readHashBoundArtifact({
226
+ runDir,
227
+ artifactPath: record.structuredArtifactPath,
228
+ expectedSha256: record.structuredArtifactSha256,
229
+ label: `${nodeId} primary payload`,
230
+ }),
231
+ readHashBoundArtifact({
232
+ runDir,
233
+ artifactPath: frontend.citationsArtifactPath,
234
+ expectedSha256: frontend.citationsArtifactSha256,
235
+ label: `${nodeId} citations`,
236
+ }),
237
+ readHashBoundArtifact({
238
+ runDir,
239
+ artifactPath: frontend.rawCandidatePath,
240
+ expectedSha256: frontend.rawCandidateSha256,
241
+ label: `${nodeId} raw candidate`,
242
+ }).then(() => undefined),
243
+ ]);
244
+ return {
245
+ artifactFirst: true,
246
+ payloadText,
247
+ citationsText,
248
+ rawCandidateSha256: frontend.rawCandidateSha256,
249
+ payloadKind: frontend.payloadKind,
250
+ };
251
+ }
179
252
  /**
180
253
  * Same shape predicate as the internal `isContract` inside
181
254
  * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
@@ -458,9 +531,9 @@ export async function runFrontendPrewriteGate(input) {
458
531
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
459
532
  openspecPolicy,
460
533
  };
461
- let planText;
534
+ let planInput;
462
535
  try {
463
- planText = await readNodeText(input.runDir, planNodeId);
536
+ planInput = await readFrontendPlanNodeInput(input.runDir, planNodeId);
464
537
  }
465
538
  catch (error) {
466
539
  return finalizePrewrite(input, {
@@ -470,6 +543,9 @@ export async function runFrontendPrewriteGate(input) {
470
543
  failureCode: "plan-text-unreadable",
471
544
  });
472
545
  }
546
+ const planText = planInput.payloadText;
547
+ const citationsText = planInput.citationsText;
548
+ basePending.candidateRawSha256 = planInput.rawCandidateSha256;
473
549
  let reviewText;
474
550
  try {
475
551
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -502,7 +578,20 @@ export async function runFrontendPrewriteGate(input) {
502
578
  if (revisionPatchMode) {
503
579
  const originalNodeId = input.config.planFallbackFromNodeIds[0];
504
580
  try {
505
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
581
+ const originalInput = await readFrontendPlanNodeInput(input.runDir, originalNodeId);
582
+ if (planInput.artifactFirst && !originalInput.artifactFirst) {
583
+ throw new Error(`artifact-first revision ${planNodeId} requires artifact-first original contract ${originalNodeId}`);
584
+ }
585
+ if (planInput.artifactFirst) {
586
+ if (planInput.payloadKind !== "merge-patch" ||
587
+ originalInput.payloadKind !== "canonical-contract") {
588
+ throw new Error("artifact-first revision/original payload kinds are invalid");
589
+ }
590
+ effectiveContractText = serializeDeterministicJson(applyFrontendImplementationContractPatch(JSON.parse(originalInput.payloadText), JSON.parse(planInput.payloadText)));
591
+ }
592
+ else {
593
+ effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
594
+ }
506
595
  }
507
596
  catch (error) {
508
597
  return finalizePrewrite(input, {
@@ -610,14 +699,17 @@ export async function runFrontendPrewriteGate(input) {
610
699
  workspaceRoot,
611
700
  });
612
701
  }
613
- // Candidate -> canonical conversion. The gate consumes the selected
614
- // plan/revision assistant output directly and is the only conversion point.
702
+ // Candidate -> canonical conversion. Artifact-first inputs must keep using
703
+ // the already hash-verified payload: re-reading the selected node record
704
+ // here would silently fall back to its prose and defeat the integrity gate.
705
+ // Historical records intentionally retain their node-text compatibility path.
615
706
  let analysis;
616
707
  try {
708
+ const rawContractText = effectiveContractText ?? (planInput.artifactFirst ? planText : undefined);
617
709
  analysis = await analyzeFrontendImplementationContract({
618
710
  runDir: input.runDir,
619
- fromNodeId: effectiveContractText ? undefined : planNodeId,
620
- rawContractText: effectiveContractText ?? undefined,
711
+ fromNodeId: rawContractText ? undefined : planNodeId,
712
+ rawContractText,
621
713
  sourceBinding: input.sourceBinding,
622
714
  });
623
715
  }
@@ -637,21 +729,6 @@ export async function runFrontendPrewriteGate(input) {
637
729
  }
638
730
  throw error;
639
731
  }
640
- // Size guard: a canonical contract above 64KB is a retryable-invalid defect
641
- // (compress the contract) rather than a writer authorization failure.
642
- const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
643
- if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
644
- return finalizePrewrite(input, {
645
- ...basePending,
646
- verdict,
647
- candidateJsonSha256: analysis.candidateJsonSha256,
648
- normalizationActions: [],
649
- mockStrategy: analysis.canonical.mockApi.strategy,
650
- classification: "retryable-invalid",
651
- failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
652
- failureCode: "contract-too-large",
653
- });
654
- }
655
732
  const mockStrategy = analysis.canonical.mockApi.strategy;
656
733
  if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
657
734
  const quotedAllowed = input.config.allowedMockStrategies
@@ -762,7 +839,7 @@ export async function runFrontendPrewriteGate(input) {
762
839
  repoRoot: candidateRoot,
763
840
  });
764
841
  if (candidatePaths.length > 0) {
765
- const parsed = parseOpenspecCitations(planText, candidateRoot);
842
+ const parsed = parseOpenspecCitations(citationsText, candidateRoot);
766
843
  if (!parsed.blockPresent || parsed.unparseable) {
767
844
  return finalizePrewrite(input, {
768
845
  ...basePending,
@@ -903,7 +980,7 @@ export async function runFrontendPrewriteGate(input) {
903
980
  });
904
981
  }
905
982
  if (openspecPolicy === "cited") {
906
- const parsedCitations = parseOpenspecCitations(planText, candidateRoot);
983
+ const parsedCitations = parseOpenspecCitations(citationsText, candidateRoot);
907
984
  const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
908
985
  const readSet = new Set(openspecReadPaths);
909
986
  const uncorroborated = [