@tea-agent/loop-agent 0.38.2 → 0.38.3

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 (133) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -0
  3. package/bin/loop-agent.js +7 -3
  4. package/dist/application/task-lifecycle/advance.js +4 -0
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/commands/task-advance.js +31 -0
  7. package/dist/executors/dag-pi-executor.js +58 -1
  8. package/dist/executors/pi-event-serializer.js +42 -1
  9. package/dist/executors/pi-executor.js +51 -0
  10. package/dist/executors/pi-extension-resolver.js +233 -0
  11. package/dist/executors/pi-sdk-executor.js +187 -59
  12. package/dist/executors/shell-executor.js +56 -2
  13. package/dist/executors/shell-write-guard.js +7 -0
  14. package/dist/task/contract/constants.js +1 -0
  15. package/dist/task/contract/diff.js +26 -0
  16. package/dist/task/contract/project.js +5 -0
  17. package/dist/task/contract/schema.js +6 -1
  18. package/dist/task/source-prepare/build-draft.js +15 -0
  19. package/dist/worker/console/pi-readiness.js +4 -4
  20. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4bmo3iY.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  21. package/dist/worker/console/static/assets/{arc-SAD7McOS.js → arc-Bj2M8iO5.js} +1 -1
  22. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DAVLUdK1.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  23. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CPPdv4vj.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  24. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D1GRGZax.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  25. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  26. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D_Bq2ZqK.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CPLVtamK.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DnaZWcL3.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BRTmFgnq.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BRlfYZoH.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CjNybD4u.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-D3e-74fG.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Br3ir6f2.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-1NgVuRbh.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BTYuvuWN.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  36. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  37. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  38. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BC6Bgosf.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  39. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DV-HypfL.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  40. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DhTaddO-.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  41. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BnEcOmiI.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  42. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-xphGRVKp.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CrdD1mRG.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-D4YdLsaF.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-LCKyGZ5U.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BeiTe7t0.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  47. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BO2cq8GS.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  48. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CmWww3Nt.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  49. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C2kkUYg5.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  50. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DY_JZ2iQ.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  51. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BizhCSw9.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  52. package/dist/worker/console/static/assets/{index-BXN89VPX.js → index-DTZOKgAn.js} +142 -63
  53. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  54. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Dlo2eK_d.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  55. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  56. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DiMDe5Yh.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  57. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-sN553oIN.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  58. package/dist/worker/console/static/assets/{linear-D9Py31Ld.js → linear-DdnapKIH.js} +1 -1
  59. package/dist/worker/console/static/assets/{mermaid.core-D7Occzk_.js → mermaid.core-BVjAT9b8.js} +5 -5
  60. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DjDRboiR.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  61. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Cgc4qlzp.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  62. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DxZj8pKd.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  63. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CJ50MngR.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  64. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BY-OD8Tr.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  65. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CQFButfK.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  66. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BB_QUK_b.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  67. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CqhXZjyx.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  68. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-2l1U4rgJ.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  69. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BX1FbrhJ.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  70. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  71. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DOSnT7hb.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  72. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  73. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DVJQA7AN.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  74. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-74uxl9gx.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  75. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DsBxlcg_.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  76. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-snuW9Mjw.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  77. package/dist/worker/console/static/index.html +2 -2
  78. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  79. package/dist/worker/observe/node-input.js +72 -3
  80. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  81. package/dist/worker/observe/static/state.js +2 -2
  82. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  83. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  84. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +56 -4
  85. package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
  86. package/dist/workflows/dag/contract-output-registry.js +15 -0
  87. package/dist/workflows/dag/failure-category.js +4 -0
  88. package/dist/workflows/dag/frontend-implementation-contract.js +140 -39
  89. package/dist/workflows/dag/frontend-prewrite-gate.js +87 -135
  90. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  91. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  92. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  93. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  94. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  95. package/dist/workflows/dag/init-hybrid.js +79 -101
  96. package/dist/workflows/dag/node-execution.js +119 -8
  97. package/dist/workflows/dag/prompt.js +15 -1
  98. package/dist/workflows/dag/rerun-plan.js +22 -3
  99. package/dist/workflows/dag/structured-output-repair.js +712 -0
  100. package/dist/workflows/dag/types.js +23 -0
  101. package/dist/workflows/dag/validate.js +41 -1
  102. package/docs/operations/local-development-environment.md +1 -5
  103. package/docs/skills/vetted-skill-registry.md +14 -0
  104. package/docs/templates/README.md +1 -1
  105. package/docs/templates/agent-dag.schema.json +44 -0
  106. package/docs/templates/backend-test-dag.json +2 -2
  107. package/docs/templates/frontend-test-dag.json +8 -10
  108. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  109. package/package.json +8 -4
  110. package/skills/analyze-product-requirements/SKILL.md +8 -5
  111. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  112. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  113. package/skills/analyze-product-requirements/references/example.md +50 -0
  114. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  115. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  116. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  117. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  118. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  119. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  120. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  121. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  122. package/skills/codebase-scout/SKILL.md +1 -1
  123. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  124. package/skills/improve-codebase-architecture/deepening.md +37 -0
  125. package/skills/improve-codebase-architecture/html-report.md +123 -0
  126. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  127. package/skills/improve-codebase-architecture/language.md +53 -0
  128. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +0 -1
  129. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +0 -1
  130. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +0 -1
  131. package/dist/worker/console/static/assets/index-CuH5CNFu.css +0 -1
  132. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +0 -1
  133. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +0 -8
@@ -8,6 +8,7 @@ import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-sto
8
8
  import { findPackageRoot } from "../../shared/package-metadata.js";
9
9
  import { pathMatchesPattern } from "../../shared/git-progress.js";
10
10
  import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
11
+ import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, openspecCitationsRel, parseOpenspecCitationsBlock, persistRuntimeSkeleton, } from "./structured-output-repair.js";
11
12
  export const frontendNormalizationActionSchema = z.enum([
12
13
  "remove-trailing-commas",
13
14
  "strip-comments",
@@ -1485,7 +1486,13 @@ export async function analyzeFrontendImplementationContract(input) {
1485
1486
  rawOutput = input.rawContractText;
1486
1487
  }
1487
1488
  else if (input.fromNodeId !== undefined) {
1488
- const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
1489
+ let record;
1490
+ try {
1491
+ record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
1492
+ }
1493
+ catch (error) {
1494
+ throw new Error(`cannot parse node record ${input.fromNodeId}: ${error instanceof Error ? error.message : String(error)}`);
1495
+ }
1489
1496
  rawOutput = record.assistantText ?? record.stdout ?? "";
1490
1497
  }
1491
1498
  else {
@@ -1731,7 +1738,7 @@ async function writeFrontendPlanCandidateRaw(input) {
1731
1738
  * Compile the planner's editable RFC 7386 patch against the deterministic
1732
1739
  * runtime skeleton. Every attempt is preserved under contracts/candidates so
1733
1740
  * format/schema failures can be inspected and replayed without trusting model
1734
- * prose. A successful result returns canonical full-contract text for all
1741
+ * prose. A successful result returns a path-only pointer plus the hash-bound canonical artifact for
1735
1742
  * downstream review and prewrite nodes.
1736
1743
  */
1737
1744
  export async function validateFrontendPlanPatchNodeOutput(input) {
@@ -1804,27 +1811,60 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1804
1811
  ],
1805
1812
  errors: [],
1806
1813
  });
1814
+ const canonicalRel = canonicalContractRel(nodeId);
1815
+ const canonicalArtifact = await writeDeterministicJsonArtifact(input.runDir, canonicalRel, analysis.canonical);
1816
+ const parsedCitations = parseOpenspecCitationsBlock(citationsBlock);
1817
+ await writeDeterministicJsonArtifact(input.runDir, openspecCitationsRel(nodeId), {
1818
+ citations: parsedCitations.citations,
1819
+ block: citationsBlock,
1820
+ });
1821
+ await persistRuntimeSkeleton({
1822
+ runDir: input.runDir,
1823
+ nodeId,
1824
+ skeleton,
1825
+ });
1826
+ const artifact = {
1827
+ path: canonicalArtifact.path,
1828
+ sha256: canonicalArtifact.sha256,
1829
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1830
+ };
1807
1831
  return {
1808
1832
  ok: true,
1809
1833
  contract: analysis.canonical,
1810
- normalizedText: [
1811
- "Frontend implementation contract compiled from the runtime skeleton.",
1812
- "",
1813
- "```json",
1814
- serializeDeterministicJson(analysis.canonical),
1815
- "```",
1816
- citationsBlock,
1817
- ].join("\n"),
1834
+ artifact,
1835
+ normalizedText: formatStructuredArtifactPointer(artifact),
1818
1836
  };
1819
1837
  }
1820
1838
  catch (error) {
1821
1839
  const reason = error instanceof Error ? error.message : String(error);
1840
+ const classified = classifyStructuredContractFailure({
1841
+ reason,
1842
+ kind: error instanceof FrontendContractFailure ? error.kind : undefined,
1843
+ });
1822
1844
  await writeReport({
1823
- classification: "retryable-invalid",
1845
+ classification: classified.classification,
1824
1846
  normalizationActions: [],
1825
- errors: [{ path: "$", code: "invalid-output", message: reason }],
1847
+ errors: classified.errors,
1826
1848
  });
1827
- return { ok: false, reason };
1849
+ return {
1850
+ ok: false,
1851
+ reason,
1852
+ classification: classified.classification,
1853
+ errors: classified.errors,
1854
+ };
1855
+ }
1856
+ }
1857
+ export async function readFrontendCanonicalCandidate(runDir, nodeId) {
1858
+ const candidatePath = path.join(runDir, canonicalContractRel(nodeId));
1859
+ try {
1860
+ const raw = await readFile(candidatePath, "utf8");
1861
+ const parsed = frontendImplementationContractSchema.safeParse(JSON.parse(raw));
1862
+ return parsed.success ? parsed.data : undefined;
1863
+ }
1864
+ catch (error) {
1865
+ if (error.code === "ENOENT")
1866
+ return undefined;
1867
+ return undefined;
1828
1868
  }
1829
1869
  }
1830
1870
  /**
@@ -1845,60 +1885,121 @@ export async function validateFrontendContractNodeOutput(input) {
1845
1885
  return { ok: true, contract: analysis.canonical };
1846
1886
  }
1847
1887
  catch (error) {
1888
+ const reason = error instanceof Error ? error.message : String(error);
1889
+ const classified = classifyStructuredContractFailure({
1890
+ reason,
1891
+ kind: error instanceof FrontendContractFailure ? error.kind : undefined,
1892
+ });
1848
1893
  return {
1849
1894
  ok: false,
1850
- reason: error instanceof Error ? error.message : String(error),
1895
+ reason,
1896
+ classification: classified.classification,
1897
+ errors: classified.errors,
1851
1898
  };
1852
1899
  }
1853
1900
  }
1854
1901
  /**
1855
- * Node-output self-check for the plan revision node, which emits an RFC 7386
1856
- * merge-patch delta against the original contract instead of a full contract.
1857
- * Deliberately loose and format-level: the output must contain exactly one
1858
- * ```openspec-citations fenced block and must extract (after the citations
1859
- * blocks are stripped) to exactly one JSON object. The delta carries no
1860
- * schemaVersion/targets, so no full-contract schema/semantic checks run here;
1861
- * content parsing, patch application, and merged-contract validation stay with
1862
- * the prewrite gate, which remains the only authority.
1902
+ * Node-output self-check for the plan revision node. Format-level checks still
1903
+ * require one openspec-citations fence and one JSON object. When the original
1904
+ * canonical contract and sourceBinding are present, this node is the merge
1905
+ * authority: it applies the RFC 7386 patch, validates the full contract, and
1906
+ * writes the revision node's own hash-bound canonical artifact.
1863
1907
  */
1864
1908
  export async function validateFrontendRevisionPatchNodeOutput(input) {
1909
+ const fail = (reason) => {
1910
+ const classified = classifyStructuredContractFailure({ reason });
1911
+ return {
1912
+ ok: false,
1913
+ reason,
1914
+ classification: classified.classification,
1915
+ errors: classified.errors,
1916
+ };
1917
+ };
1918
+ const nodeId = input.nodeId ?? "frontend-plan-revision-pi";
1919
+ const attempt = input.attempt ?? 1;
1920
+ await writeFrontendPlanCandidateRaw({
1921
+ runDir: input.runDir,
1922
+ nodeId,
1923
+ attempt,
1924
+ text: input.text,
1925
+ });
1865
1926
  let citationsBlock;
1866
1927
  try {
1867
1928
  citationsBlock = extractSingleOpenspecCitationsBlock(input.text);
1868
1929
  }
1869
1930
  catch (error) {
1870
- return {
1871
- ok: false,
1872
- reason: error instanceof Error ? error.message : String(error),
1873
- };
1931
+ return fail(error instanceof Error ? error.message : String(error));
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
1933
  const textWithoutCitations = input.text.replace(citationsBlock, "");
1878
1934
  let patch;
1879
1935
  try {
1880
1936
  patch = extractFrontendImplementationJson(textWithoutCitations);
1881
1937
  }
1882
1938
  catch (error) {
1883
- return {
1884
- ok: false,
1885
- reason: `revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`,
1886
- };
1939
+ return fail(`revision patch output must contain exactly one valid JSON object: ${error instanceof Error ? error.message : String(error)}`);
1887
1940
  }
1888
1941
  if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
1889
- return {
1890
- ok: false,
1891
- reason: "revision patch output must extract to exactly one JSON object (RFC 7386 merge-patch delta)",
1892
- };
1942
+ return fail("revision patch output must extract to exactly one JSON object (RFC 7386 merge-patch delta)");
1893
1943
  }
1894
1944
  const protectedViolations = frontendPlanPatchProtectedPathViolations(patch);
1895
1945
  if (protectedViolations.length > 0) {
1946
+ return fail(`frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`);
1947
+ }
1948
+ const original = (await readFrontendCanonicalCandidate(input.runDir, "frontend-plan-pi")) ??
1949
+ (await readFrontendCanonicalCandidate(input.runDir, nodeId));
1950
+ if (!input.sourceBinding) {
1951
+ return { ok: true, contract: patch };
1952
+ }
1953
+ if (!original) {
1954
+ return fail("frontend revision patch requires the original frontend-plan-pi canonical contract");
1955
+ }
1956
+ try {
1957
+ const merged = applyFrontendContractMergePatch(original, patch);
1958
+ const droppedUnresolvedSymbols = await dropUnresolvedVerificationSymbols({
1959
+ runDir: input.runDir,
1960
+ contract: merged,
1961
+ });
1962
+ const analysis = await analyzeFrontendImplementationContract({
1963
+ runDir: input.runDir,
1964
+ rawContractText: serializeDeterministicJson(merged),
1965
+ sourceBinding: input.sourceBinding,
1966
+ });
1967
+ const candidateDir = path.posix.join("contracts", "candidates", nodeId);
1968
+ await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.extracted.json`), patch);
1969
+ await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.normalized.json`), analysis.canonical);
1970
+ await writeDeterministicJsonArtifact(input.runDir, path.posix.join(candidateDir, `attempt-${attempt}.validation.json`), {
1971
+ classification: "accepted-normalized",
1972
+ normalizationActions: [
1973
+ "apply-original-canonical",
1974
+ ...(droppedUnresolvedSymbols
1975
+ ? ["drop-unresolved-verification-symbols"]
1976
+ : []),
1977
+ ...analysis.normalizationActions,
1978
+ ],
1979
+ errors: [],
1980
+ });
1981
+ const canonicalArtifact = await writeDeterministicJsonArtifact(input.runDir, canonicalContractRel(nodeId), analysis.canonical);
1982
+ const parsedCitations = parseOpenspecCitationsBlock(citationsBlock);
1983
+ await writeDeterministicJsonArtifact(input.runDir, openspecCitationsRel(nodeId), {
1984
+ citations: parsedCitations.citations,
1985
+ block: citationsBlock,
1986
+ });
1987
+ const artifact = {
1988
+ path: canonicalArtifact.path,
1989
+ sha256: canonicalArtifact.sha256,
1990
+ schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
1991
+ };
1896
1992
  return {
1897
- ok: false,
1898
- reason: `frontend revision patch must not modify runtime-protected paths: ${protectedViolations.join(", ")}`,
1993
+ ok: true,
1994
+ contract: analysis.canonical,
1995
+ artifact,
1996
+ normalizedText: formatStructuredArtifactPointer(artifact),
1899
1997
  };
1900
1998
  }
1901
- return { ok: true, contract: patch };
1999
+ catch (error) {
2000
+ const reason = error instanceof Error ? error.message : String(error);
2001
+ return fail(reason);
2002
+ }
1902
2003
  }
1903
2004
  export async function materializeFrontendImplementationContract(input) {
1904
2005
  const analysis = await analyzeFrontendImplementationContract(input);
@@ -1,10 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { z } from "zod";
4
5
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
6
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
7
  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";
8
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
9
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
9
10
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
10
11
  import { pathMatchesPattern } from "../../shared/git-progress.js";
@@ -157,60 +158,68 @@ async function selectNode(runDir, primary, fallbacks) {
157
158
  }
158
159
  throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
159
160
  }
160
- async function readNodeText(runDir, nodeId) {
161
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
161
+ async function readFinishedNodeRecord(runDir, nodeId) {
162
+ let record;
163
+ try {
164
+ record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
165
+ }
166
+ catch (error) {
167
+ throw new Error(`frontend prewrite gate cannot parse node record ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
162
169
  if (record.status !== "FINISHED") {
163
170
  throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
164
171
  }
165
- // Pi execution mirrors a successful structured-contract normalization into
166
- // both assistantText and stdout. Treat node output the same way as the DAG
167
- // runtime's canonicalNodeOutput(): assistantText is authoritative and stdout
168
- // is only a fallback. Concatenating the two duplicate fields makes a single
169
- // canonical plan appear as two complete JSON contracts to the prewrite gate.
172
+ return record;
173
+ }
174
+ async function readNodeText(runDir, nodeId) {
175
+ const record = await readFinishedNodeRecord(runDir, nodeId);
170
176
  const text = record.assistantText?.trim() || record.stdout?.trim() || "";
171
177
  if (!text)
172
178
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
173
179
  return text;
174
180
  }
175
- async function readNodeRawText(runDir, nodeId) {
176
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
177
- return record.assistantText ?? record.stdout ?? "";
178
- }
179
- /**
180
- * Same shape predicate as the internal `isContract` inside
181
- * `extractFrontendImplementationJson` (frontend-implementation-contract.ts):
182
- * schemaVersion 1 + a plain `targets` object + `requirements` and
183
- * `verificationTargets` arrays. A merge-patch delta does not carry these
184
- * fields, so this is the deterministic boundary between a full contract and a
185
- * patch. Keep the two predicates in sync.
186
- */
187
- function isFullContractShape(value) {
188
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
189
- return false;
190
- }
191
- const record = value;
192
- const targets = record.targets;
193
- return (record.schemaVersion === 1 &&
194
- targets !== null &&
195
- typeof targets === "object" &&
196
- !Array.isArray(targets) &&
197
- Array.isArray(record.requirements) &&
198
- Array.isArray(record.verificationTargets));
199
- }
200
- /**
201
- * Shared merge pipeline for revisionPatch mode and content-driven
202
- * compatibility: read the original contract node, extract the original
203
- * contract and the effective node's patch, apply the RFC 7386 merge-patch, and
204
- * serialize deterministically. Throws on any failure; callers map the error to
205
- * their own classification (retryable-invalid for explicit revisionPatch mode,
206
- * blocked for content-driven fallback).
207
- */
208
- async function mergeRevisionPatch(runDir, planText, originalNodeId) {
209
- const originalText = await readNodeText(runDir, originalNodeId);
210
- const originalValue = extractFrontendImplementationJson(originalText);
211
- const patchValue = extractFrontendImplementationJson(planText);
212
- const merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
213
- return serializeDeterministicJson(merged);
181
+ async function readCanonicalContractFromNode(runDir, nodeId) {
182
+ const record = await readFinishedNodeRecord(runDir, nodeId);
183
+ if (!record.structuredArtifactPath) {
184
+ throw new Error(`frontend prewrite gate missing structured artifact for ${nodeId}`);
185
+ }
186
+ const artifactPath = path.isAbsolute(record.structuredArtifactPath)
187
+ ? record.structuredArtifactPath
188
+ : path.join(runDir, record.structuredArtifactPath);
189
+ const resolvedRun = path.resolve(runDir);
190
+ const resolvedArtifact = path.resolve(artifactPath);
191
+ const relative = path.relative(resolvedRun, resolvedArtifact);
192
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
193
+ throw new Error(`frontend prewrite gate structured artifact escapes runDir: ${record.structuredArtifactPath}`);
194
+ }
195
+ const bytes = await readFile(resolvedArtifact);
196
+ const digest = createHash("sha256").update(bytes).digest("hex");
197
+ if (record.structuredArtifactSha256 &&
198
+ record.structuredArtifactSha256 !== digest) {
199
+ throw new Error(`frontend prewrite gate structured artifact hash mismatch for ${nodeId}`);
200
+ }
201
+ if (record.structuredArtifactSchemaId &&
202
+ record.structuredArtifactSchemaId !== FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID) {
203
+ throw new Error(`frontend prewrite gate structured artifact schema mismatch for ${nodeId}: ${record.structuredArtifactSchemaId}`);
204
+ }
205
+ const contractText = bytes.toString("utf8");
206
+ let citationsText = "";
207
+ try {
208
+ const citationsRaw = await readFile(path.join(path.dirname(resolvedArtifact), "openspec-citations.json"), "utf8");
209
+ try {
210
+ const parsed = JSON.parse(citationsRaw);
211
+ citationsText =
212
+ typeof parsed.block === "string" ? parsed.block : citationsRaw;
213
+ }
214
+ catch {
215
+ citationsText = citationsRaw;
216
+ }
217
+ }
218
+ catch (error) {
219
+ if (error.code !== "ENOENT")
220
+ throw error;
221
+ }
222
+ return { contractText, citationsText, rawSha256: digest };
214
223
  }
215
224
  function firstNonEmptyVerdictLine(text) {
216
225
  const lines = text
@@ -439,7 +448,30 @@ export async function runFrontendPrewriteGate(input) {
439
448
  // remains an ERROR and is intentionally not classified into prewrite-result.
440
449
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
441
450
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
442
- const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
451
+ let planArtifact;
452
+ try {
453
+ planArtifact = await readCanonicalContractFromNode(input.runDir, planNodeId);
454
+ }
455
+ catch (error) {
456
+ return finalizePrewrite(input, {
457
+ planNodeId,
458
+ reviewNodeId,
459
+ verdict: "",
460
+ candidateRawSha256: sha256Hex(""),
461
+ candidateJsonSha256: null,
462
+ canonicalSha256: null,
463
+ normalizationActions: [],
464
+ mockStrategy: undefined,
465
+ artifact: undefined,
466
+ failureReason: error instanceof Error ? error.message : String(error),
467
+ failureCode: "plan-text-unreadable",
468
+ classification: "retryable-invalid",
469
+ openspecReadPaths: [],
470
+ openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
471
+ openspecPolicy: input.config.openspecPolicy ?? "scan-strict",
472
+ });
473
+ }
474
+ const candidateRawSha256 = planArtifact.rawSha256;
443
475
  const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
444
476
  const basePending = {
445
477
  planNodeId,
@@ -458,18 +490,9 @@ export async function runFrontendPrewriteGate(input) {
458
490
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
459
491
  openspecPolicy,
460
492
  };
461
- let planText;
462
- try {
463
- planText = await readNodeText(input.runDir, planNodeId);
464
- }
465
- catch (error) {
466
- return finalizePrewrite(input, {
467
- ...basePending,
468
- classification: "retryable-invalid",
469
- failureReason: error instanceof Error ? error.message : String(error),
470
- failureCode: "plan-text-unreadable",
471
- });
472
- }
493
+ const planText = [planArtifact.contractText, planArtifact.citationsText]
494
+ .filter(Boolean)
495
+ .join("\n");
473
496
  let reviewText;
474
497
  try {
475
498
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -492,77 +515,7 @@ export async function runFrontendPrewriteGate(input) {
492
515
  failureCode: "verdict-not-pass",
493
516
  });
494
517
  }
495
- // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
496
- // delta against the original contract node (planFallbackFromNodeIds[0]).
497
- // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
498
- const revisionPatchMode = input.config.revisionPatch === true &&
499
- planNodeId === input.config.planFromNodeId &&
500
- input.config.planFallbackFromNodeIds.length > 0;
501
- let effectiveContractText = null;
502
- if (revisionPatchMode) {
503
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
504
- try {
505
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
506
- }
507
- catch (error) {
508
- return finalizePrewrite(input, {
509
- ...basePending,
510
- verdict,
511
- classification: "retryable-invalid",
512
- failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
513
- failureCode: "contract-invalid",
514
- });
515
- }
516
- }
517
- else {
518
- // Content-driven compatibility (Bug 1): legacy DAGs without
519
- // revisionPatch can still receive a merge-patch delta from the revision
520
- // node. Detect the output shape instead of trusting the DAG flag alone.
521
- // - Full contract shape → keep legacy full-contract semantics unchanged.
522
- // - Patch shape with a usable original contract node → auto-merge and
523
- // run the existing analysis/materialization pipeline.
524
- // - Patch shape but unmergeable → blocked + revision-patch-unmergeable
525
- // (no retryable loop).
526
- let planJsonValue = null;
527
- try {
528
- planJsonValue = extractFrontendImplementationJson(planText);
529
- }
530
- catch {
531
- // Not machine-readable JSON: fall back to the legacy full-contract
532
- // path where analyzeFrontendImplementationContract surfaces the
533
- // original diagnostic (fail-closed without a new failure mode).
534
- }
535
- if (planJsonValue !== null && !isFullContractShape(planJsonValue)) {
536
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
537
- if (!originalNodeId || originalNodeId === planNodeId) {
538
- return finalizePrewrite(input, {
539
- ...basePending,
540
- verdict,
541
- classification: "blocked",
542
- failureReason: "frontend prewrite gate: the effective plan output is a merge-patch delta but no distinct original contract node is available. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.",
543
- failureCode: "revision-patch-unmergeable",
544
- });
545
- }
546
- try {
547
- const originalText = await readNodeText(input.runDir, originalNodeId);
548
- const originalValue = extractFrontendImplementationJson(originalText);
549
- if (!isFullContractShape(originalValue)) {
550
- throw new Error(`original contract node ${originalNodeId} is not a full frontend implementation contract`);
551
- }
552
- effectiveContractText = await mergeRevisionPatch(input.runDir, planText, originalNodeId);
553
- }
554
- catch (error) {
555
- return finalizePrewrite(input, {
556
- ...basePending,
557
- verdict,
558
- classification: "blocked",
559
- failureReason: `frontend prewrite gate: the effective plan output is a merge-patch delta but it cannot be merged onto the original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}. Check the DAG revisionPatch/planFallbackFromNodeIds configuration or regenerate the DAG.`,
560
- failureCode: "revision-patch-unmergeable",
561
- });
562
- }
563
- }
564
- }
565
- const requirementIdSearchText = effectiveContractText ?? planText;
518
+ const requirementIdSearchText = planArtifact.contractText;
566
519
  const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
567
520
  if (missingIds.length > 0) {
568
521
  return finalizePrewrite(input, {
@@ -610,14 +563,13 @@ export async function runFrontendPrewriteGate(input) {
610
563
  workspaceRoot,
611
564
  });
612
565
  }
613
- // Candidate -> canonical conversion. The gate consumes the selected
614
- // plan/revision assistant output directly and is the only conversion point.
566
+ // Candidate -> canonical conversion. The gate consumes the producing node's
567
+ // hash-bound canonical artifact and never re-merges plan/revision stdout.
615
568
  let analysis;
616
569
  try {
617
570
  analysis = await analyzeFrontendImplementationContract({
618
571
  runDir: input.runDir,
619
- fromNodeId: effectiveContractText ? undefined : planNodeId,
620
- rawContractText: effectiveContractText ?? undefined,
572
+ rawContractText: planArtifact.contractText,
621
573
  sourceBinding: input.sourceBinding,
622
574
  });
623
575
  }
@@ -1,25 +1,17 @@
1
1
  import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { markdownSection } from "./frontend-test-markdown.js";
3
4
  const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
4
5
  const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
5
6
  const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
6
7
  const SECTION_ALIASES = {
7
8
  purpose: ["Test Purpose", "测试目的", "测试场景"],
8
9
  source: ["Source References", "需求依据"],
9
- preconditions: ["Preconditions", "前置条件"],
10
- steps: ["Steps", "操作步骤"],
10
+ preconditions: ["Preconditions", "前置条件", "前置条件与重置"],
11
+ steps: ["Test Steps", "测试步骤", "Steps", "操作步骤"],
11
12
  expected: ["Expected Results", "预期结果"],
12
13
  automation: ["Automation Notes", "自动化映射", "自动化说明"],
13
14
  };
14
- function section(body, names) {
15
- const marker = new RegExp(`^###\\s+(?:${names.map((v) => v.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")).join("|")})\\s*$`, "mi");
16
- const hit = marker.exec(body);
17
- if (!hit)
18
- return "";
19
- const rest = body.slice(hit.index + hit[0].length);
20
- const next = /^###\s+/m.exec(rest);
21
- return rest.slice(0, next?.index ?? rest.length).trim();
22
- }
23
15
  function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
24
16
  function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
25
17
  export async function validateFrontendCaseContent(input) {
@@ -69,9 +61,9 @@ export async function validateFrontendCaseContent(input) {
69
61
  if (PLACEHOLDER.test(body))
70
62
  findings.push({ ruleId: "placeholder-wording", caseId: id, detail: "case contains placeholder or non-assertable wording" });
71
63
  for (const [key, names] of Object.entries(SECTION_ALIASES))
72
- if (!section(body, names))
64
+ if (!markdownSection(body, names))
73
65
  findings.push({ ruleId: `missing-${key}`, caseId: id, detail: `missing section: ${names.join(" or ")}` });
74
- const steps = section(body, SECTION_ALIASES.steps), expected = section(body, SECTION_ALIASES.expected);
66
+ const steps = markdownSection(body, SECTION_ALIASES.steps), expected = markdownSection(body, SECTION_ALIASES.expected);
75
67
  if (!hasList(steps))
76
68
  findings.push({ ruleId: "unstructured-steps", caseId: id, detail: "steps should contain numbered or bulleted executable actions" });
77
69
  if (!hasList(expected) || !hasAssertion(expected))