@tea-agent/loop-agent 0.39.0-next.26 → 0.39.0-next.27

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.
@@ -21,7 +21,6 @@ import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-em
21
21
  import { discoverProjectGovernancePresence } from "./project-governance-context.js";
22
22
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
23
23
  import { materializeTaskReferenceDocs } from "../../task/source-references.js";
24
- import { REQUIREMENT_FACT_ROLES } from "../../task/source-prepare/artifact-meta.js";
25
24
  import { observeTaskContract } from "../../task/contract/observe.js";
26
25
  import { dagHasWriterExecution } from "./task-contract-binding.js";
27
26
  import { DEFAULT_VERIFY_TIMEOUT_MS, resolveVerifyPreset, } from "../../executors/shell-verification.js";
@@ -1502,27 +1501,11 @@ function buildSourceContextBlock(sources) {
1502
1501
  .relative(path.join(sources.taskDir, "source"), reference.path)
1503
1502
  .replaceAll(path.sep, "/");
1504
1503
  const referenceRef = toDagSourcePath(sources, reference.path);
1505
- // Fact-source roles (requirement/acceptance) carry the fields contracts
1506
- // depend on (AC/scope/columns). Inject them in full so a fixed excerpt
1507
- // cannot silently drop definitions; archival roles stay excerpted.
1508
- const isFactRole = reference.role !== undefined &&
1509
- REQUIREMENT_FACT_ROLES.has(reference.role);
1510
- const referenceExcerpt = isFactRole
1511
- ? {
1512
- text: reference.markdown.trim(),
1513
- truncated: false,
1514
- originalChars: reference.markdown.trim().length,
1515
- maxChars: Number.POSITIVE_INFINITY,
1516
- }
1517
- : excerptMarkdown(reference.markdown, {
1518
- sourceRef: referenceRef,
1519
- });
1520
- boundReadPaths.push(`- reference ${relativePath}${isFactRole ? " (full source)" : ""}: ${referenceRef}`);
1521
- parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, ...(isFactRole
1522
- ? [
1523
- `Full source injected (role: ${reference.role}) — complete and authoritative; no excerpt truncation applied.`,
1524
- ]
1525
- : []), referenceExcerpt.text);
1504
+ const referenceExcerpt = excerptMarkdown(reference.markdown, {
1505
+ sourceRef: referenceRef,
1506
+ });
1507
+ boundReadPaths.push(`- reference ${relativePath}: ${referenceRef}`);
1508
+ parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, referenceExcerpt.text);
1526
1509
  }
1527
1510
  parts.push("## Bound source read paths", ...boundReadPaths, "Use these repository-readable paths for any Pi read-tool calls. Bound files under `.harness/tasks/<taskId>/source/**` are read-only inputs: reading them is allowed even though writing `.harness/**` is forbidden.", "Never resolve task-relative citations such as `source/需求.md` or `source/references/*` against the repository root, invent `source/<taskId>/...`, search for substitutes, or fall back to `docs/**` when a bound read fails.", "## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", `- Derived execution contract and immutable references live under the Bound source read paths above (not as repo-root \`source/...\`).`);
1528
1511
  if (sources.taskConfig.hardConstraints.length > 0) {
@@ -1560,49 +1543,10 @@ async function loadMaterializedSourceReferences(sourceDir) {
1560
1543
  }
1561
1544
  await collect(referenceDir);
1562
1545
  referencePaths.sort((left, right) => left.localeCompare(right));
1563
- const roleByPath = await loadReferenceRoles(sourceDir);
1564
- return Promise.all(referencePaths.map(async (filePath) => {
1565
- const relative = path
1566
- .relative(sourceDir, filePath)
1567
- .replaceAll(path.sep, "/");
1568
- const role = roleByPath.get(relative);
1569
- return {
1570
- path: filePath,
1571
- markdown: await readFile(filePath, "utf-8"),
1572
- ...(role !== undefined ? { role } : {}),
1573
- };
1574
- }));
1575
- }
1576
- /**
1577
- * Reads `source-manifest.json` into a materializedPath → role map so the DAG
1578
- * generator can tell fact-source references (requirement/acceptance) apart
1579
- * from archival ones (analysis/clarification/design). A missing or malformed
1580
- * manifest yields an empty map; such references keep the bounded-excerpt
1581
- * treatment instead of being injected in full.
1582
- */
1583
- async function loadReferenceRoles(sourceDir) {
1584
- const roleByPath = new Map();
1585
- const manifestPath = path.join(sourceDir, "source-manifest.json");
1586
- let raw;
1587
- try {
1588
- raw = await readFile(manifestPath, "utf-8");
1589
- }
1590
- catch {
1591
- return roleByPath;
1592
- }
1593
- try {
1594
- const manifest = JSON.parse(raw);
1595
- for (const document of manifest.documents ?? []) {
1596
- if (typeof document.materializedPath === "string" &&
1597
- typeof document.role === "string") {
1598
- roleByPath.set(document.materializedPath.replaceAll(path.sep, "/"), document.role);
1599
- }
1600
- }
1601
- }
1602
- catch {
1603
- // A malformed manifest must never break reference injection.
1604
- }
1605
- return roleByPath;
1546
+ return Promise.all(referencePaths.map(async (filePath) => ({
1547
+ path: filePath,
1548
+ markdown: await readFile(filePath, "utf-8"),
1549
+ })));
1606
1550
  }
1607
1551
  export async function loadTaskHybridSources(repoRoot, taskId) {
1608
1552
  const paths = getTaskPaths(repoRoot, taskId);
@@ -2433,7 +2377,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2433
2377
  "## Runtime contract skeleton (deterministic and protected)",
2434
2378
  JSON.stringify(frontendContractSkeleton),
2435
2379
  "",
2436
- "The initial planner emits an editable RFC 7386 patch against this skeleton. It MUST omit schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime merges and validates the final contract, then replaces the planner output with canonical full-contract JSON for downstream review.",
2380
+ "The initial planner emits an editable RFC 7386 patch against this skeleton. It MUST omit schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime merges and validates the final contract, then writes a hash-bound canonical JSON artifact; downstream review and prewrite consume that artifact path, not planner stdout.",
2437
2381
  "",
2438
2382
  "## Forbidden fields (these are NOT in the schema; do not emit)",
2439
2383
  "- schemaId",
@@ -2550,11 +2494,6 @@ async function buildFrontendHybridDagFromTask(sources) {
2550
2494
  `Non-static targets (type unit/component/integration/mock) MUST set file to a concrete code file inside the implementation writeSet (task allowedPaths); the prewrite gate rejects any non-static target whose file falls outside the writeSet.`,
2551
2495
  `Command-level checks that run project-wide (all tests, typecheck, build, governance) MUST use type "static" and must NOT be bound as non-static targets with file=package.json/tsconfig.json/vite.config.ts/scripts/*. Static targets are exempt from the writeSet containment check.`,
2552
2496
  ].join("\n");
2553
- const mandatorySourceReadInstruction = [
2554
- "## Mandatory full source read before contracting",
2555
- "Before producing this contract/plan, use the Pi read tool to read the FULL bound source files (需求.md, 执行约束.md, and every `references/*` Bound readPath listed above) — the inline copies above may be truncated excerpts, and requirement/acceptance references are authoritative only in their full form.",
2556
- "Do not drop scope fields, acceptance criteria, non-goals, UI states, or column/field definitions that exist in the full sources but are absent from the inline excerpts; if a field appears in the full source, it belongs in the contract.",
2557
- ].join("\n");
2558
2497
  const strategy = resolveDagVerifyStrategy(taskConfig);
2559
2498
  const readOnlyPaths = taskConfig.allowedPaths.length > 0 ? taskConfig.allowedPaths : ["**"];
2560
2499
  const behaviorPaths = deriveFrontendBehaviorPaths(taskConfig);
@@ -2751,7 +2690,6 @@ async function buildFrontendHybridDagFromTask(sources) {
2751
2690
  "Read task source and produce a concise frontend implementation contract.",
2752
2691
  "Cover scope, non-goals, acceptance criteria, UI states, target runtime environment, risks, and verification expectations.",
2753
2692
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2754
- mandatorySourceReadInstruction,
2755
2693
  sourceContext,
2756
2694
  ].join("\n\n"),
2757
2695
  },
@@ -2792,7 +2730,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2792
2730
  allowedPaths: readOnlyPaths,
2793
2731
  forbiddenPaths,
2794
2732
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2795
- outputContract: "JSON-only patch output: one-line lead-in, then exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. The runtime applies the patch, validates it, and promotes canonical full-contract JSON for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes.",
2733
+ outputContract: "JSON-only patch output: one-line lead-in, then exactly ONE fenced json object (```json ... ```) containing only the editable RFC 7386 plan patch for the runtime contract skeleton. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. The runtime applies the patch, validates it, and writes a hash-bound canonical JSON artifact for downstream review. Do NOT emit a full contract, Markdown plan explanation, raw JSON, or any other fenced block. No file writes.",
2796
2734
  subtask_prompt: [
2797
2735
  "Use frontend-contract-pi, frontend-scout-pi, task sources, and the generation-time Mock capability evidence to fill the runtime-owned frontend contract skeleton. Return JSON-only output containing only an editable RFC 7386 plan patch. The runtime already owns schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff; omit those protected paths even when their values look obvious.",
2798
2736
  "The patch fields become the complete implementation plan after deterministic merge. Do not produce a separate plan document, prose mirror, or full contract.",
@@ -2806,7 +2744,6 @@ async function buildFrontendHybridDagFromTask(sources) {
2806
2744
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2807
2745
  verificationTargetFileInstruction,
2808
2746
  "Read-only: do not modify code, docs, artifacts, or repository files.",
2809
- mandatorySourceReadInstruction,
2810
2747
  fixedVerificationContext,
2811
2748
  sourceContext,
2812
2749
  mockContextBlock,
@@ -2828,7 +2765,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2828
2765
  outputProtocol: REVIEW_VERDICT_OUTPUT_PROTOCOL,
2829
2766
  outputContract: "Plain Markdown whose first non-empty line is VERDICT: pass or VERDICT: request-revision, followed by Findings, Required Plan Corrections, and Checked Items. No file writes.",
2830
2767
  subtask_prompt: [
2831
- "Audit the frontend plan before implementation. frontend-plan-pi is emitted to you as canonical full-contract JSON after the runtime applied and validated the planner's editable patch against its protected skeleton; there is no separate plan prose.",
2768
+ "Audit the frontend plan before implementation. frontend-plan-pi is emitted as a hash-bound canonical JSON artifact after the runtime applied and validated the planner's editable patch against its protected skeleton; read that artifact with the read tool and do not infer the contract from stdout. There is no separate plan prose.",
2832
2769
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2833
2770
  "Request revision when the Mock strategy is MOCK_STRATEGY: blocked, missing, unsupported by repository evidence, inconsistent with the API contract, outside authorized paths/dependencies, unable to prove production-default-off behavior with the fixed production/default-real-path static check, or missing deterministic behavior verification for a declared behavior target or selected Mock strategy. Mock strategies require Mock-backed evidence. A static-only contract is allowed only when every verification target is static and maps to a declared static entrypoint. not-needed otherwise requires applicable real/no-remote behavior evidence unless auto mode explicitly skipped Mock because no project Mock capability exists; in that case the plan must preserve the real request path and record the Real Integration Gap.",
2834
2771
  "Also request revision for missing applicable UI states, unsupported dependency additions, design-system drift without reason, weak interaction coverage, broad scope, inline fake data, schema drift, or missing deterministic verification commands.",
@@ -2857,15 +2794,15 @@ async function buildFrontendHybridDagFromTask(sources) {
2857
2794
  allowedPaths: readOnlyPaths,
2858
2795
  forbiddenPaths,
2859
2796
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2860
- outputContract: "When the initial design review requests revision, return a one-line lead-in followed by exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively). Immediately after it, append exactly one ```openspec-citations``` fenced citation block. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only; the gate applies the patch on the original contract and renders plan.md deterministically. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
2797
+ outputContract: "When the initial design review requests revision, return a one-line lead-in followed by exactly ONE fenced json object (```json ... ```) containing an RFC 7386 merge-patch delta against the original frontend-implementation-contract-v1 (only the fields you change; null deletes a key; arrays and scalars replace; plain objects merge recursively). Immediately after it, append exactly one ```openspec-citations``` fenced citation block. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only; this node compiles the patch onto the original canonical artifact and writes a hash-bound revised contract. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
2861
2798
  subtask_prompt: [
2862
2799
  "Consume frontend-plan-pi (original contract JSON) and frontend-design-review-pi (first design review findings).",
2863
2800
  "This node runs only when frontend-design-review-pi emitted VERDICT: request-revision. Produce an RFC 7386 merge-patch delta against the original contract JSON that addresses every Required Plan Correction from the design findings.",
2864
- "The patch delta may update editable contract fields such as requirements, implementationSteps, targets.routes/publicApiChanges, uiStates, interactions, mockApi.strategy/activation/endpoints, dependencyPolicy, stylingStrategy, uiComponentChoices, verificationTargets, evidenceGaps, residualRisks, and realIntegrationGap. It must not modify protected schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff. Only include fields you change; omit unchanged fields (the gate applies the patch on the original contract). null deletes a key; arrays and scalars replace; plain objects merge recursively.",
2801
+ "The patch delta may update editable contract fields such as requirements, implementationSteps, targets.routes/publicApiChanges, uiStates, interactions, mockApi.strategy/activation/endpoints, dependencyPolicy, stylingStrategy, uiComponentChoices, verificationTargets, evidenceGaps, residualRisks, and realIntegrationGap. It must not modify protected schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff. Only include fields you change; omit unchanged fields (this node applies the patch on the original canonical artifact). null deletes a key; arrays and scalars replace; plain objects merge recursively.",
2865
2802
  requirementCoverageInstruction,
2866
2803
  "Do not turn MOCK_STRATEGY: blocked into an implementable strategy without new repository or contract evidence that resolves every blocker.",
2867
2804
  "Read-only: do not modify code, docs, artifacts, or repository files. This node revises the plan only.",
2868
- "Output in this exact order: (1) exactly one fenced json object containing the merge-patch delta — this is the only plan output the prewrite gate applies on the original contract; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only. Do not emit any raw JSON or JSON objects in prose. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block. Do not include secrets or unsafe paths.",
2805
+ "Output in this exact order: (1) exactly one fenced json object containing the merge-patch delta — this node compiles it onto the original canonical artifact; (2) exactly one openspec-citations citation fenced block appended immediately after it. Do NOT emit a full contract, Markdown explanation, or prose — the output is JSON-only. Do not emit any raw JSON or JSON objects in prose. Apart from the patch JSON fenced block and the openspec-citations block, do not emit any other fenced block. Do not include secrets or unsafe paths.",
2869
2806
  "Preserve each requirement expectedOutcome and each interaction trigger/expectedBehavior in the effective (merged) contract; do not reduce behavior semantics to IDs and paths.",
2870
2807
  "verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
2871
2808
  verificationTargetFileInstruction,
@@ -6490,6 +6427,13 @@ async function buildHybridDagForTemplate(sources, template, options = {}) {
6490
6427
  spec = await buildSupervisedHybridDag(standard, sources);
6491
6428
  }
6492
6429
  applyProjectGovernanceReview(spec, template, sources);
6430
+ // Backend implementation templates only (D1 outer gate): assign the two
6431
+ // Pi extension buckets after all template-specific nodes exist.
6432
+ if (template === "standard-dag" ||
6433
+ template === "review-gated-dag" ||
6434
+ template === "supervised-implementation") {
6435
+ applyBackendPiExtensionBuckets(spec);
6436
+ }
6493
6437
  // New generate path always emits DagSpec v4 + bindings.
6494
6438
  spec.version = 4;
6495
6439
  if (!spec.runtimeContract) {
@@ -6557,6 +6501,28 @@ function cloneTask(task, patch = {}) {
6557
6501
  * Dynamic, shell, static, and decision-gate nodes are skipped. Idempotent:
6558
6502
  * never overwrites an explicit retryPolicy a task already declares.
6559
6503
  */
6504
+ /**
6505
+ * Backend-implementation Pi extension buckets (plan 2026-08-21 D3):
6506
+ * - read side: every non-writer, non-closeout Pi node gets navigation
6507
+ * extensions (pi-codegraph + pi-lens);
6508
+ * - write side: bounded writers get only pi-codegraph (pi-lens registers
6509
+ * ast_grep_replace, which can bypass the writeSet — not loading it is
6510
+ * simpler and safer than filtering after load);
6511
+ * - everything else (closeout, shell, static, other templates) stays closed.
6512
+ * Idempotent: never overwrites an explicit piExtensions a task declares.
6513
+ */
6514
+ function applyBackendPiExtensionBuckets(spec) {
6515
+ for (const task of spec.tasks) {
6516
+ if (task.piExtensions !== undefined)
6517
+ continue;
6518
+ if (task.executor !== "pi")
6519
+ continue;
6520
+ if (task.id === "closeout-pi")
6521
+ continue;
6522
+ task.piExtensions =
6523
+ task.toolProfile === "write" ? ["pi-codegraph"] : ["pi-codegraph", "pi-lens"];
6524
+ }
6525
+ }
6560
6526
  function applyDefaultReadOnlyRetryPolicy(spec) {
6561
6527
  for (const task of spec.tasks) {
6562
6528
  if (task.retryPolicy !== undefined)
@@ -15,6 +15,8 @@ import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from
15
15
  import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
16
16
  import { getStructuredContractValidator } from "./contract-output-registry.js";
17
17
  import "./contract-validator-registrations.js";
18
+ import { allowedRepairReadPaths, auditRepairAttemptToolUse, buildStructuredOutputRepairPrompt, freezeStructuredOutputRepairContext, hasNonEmptyStructuredCandidate, isFrontendStructuredRepairSchemaId, isStructuredRepairableFailureCategory, persistStructuredAttemptRaw, sessionEventsByteLength, GOVERNANCE_BLOCKED_CATEGORY, STRUCTURED_REPAIR_EXHAUSTED_CATEGORY, } from "./structured-output-repair.js";
19
+ import { readFrontendCanonicalCandidate } from "./frontend-implementation-contract.js";
18
20
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
19
21
  import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
20
22
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
@@ -135,6 +137,11 @@ function frontendStructuredArtifactRetryGuidance(schemaId) {
135
137
  "Return exactly this artifact and nothing else: one short lead-in line, then exactly one fenced json block containing only the editable RFC 7386 plan patch, immediately followed by exactly one fenced openspec-citations block.",
136
138
  "The runtime applies your patch to its protected contract skeleton. Do not emit schemaVersion, sourceBinding, riskLevel, targets.files, or mockApi.productionDefaultOff; do not emit the full contract.",
137
139
  "Do not emit Markdown headings, bullets, plan prose, explanations, raw JSON, or any other fenced block.",
140
+ "The openspec-citations fenced block contains one JSON object per line, each with path/section/line — for example:",
141
+ "```openspec-citations",
142
+ '{"path":".harness/tasks/<task>/source/需求.md","section":"Objective","line":5}',
143
+ '{"path":".harness/tasks/<task>/source/references/product-requirement.md","section":"FR-1","line":16}',
144
+ "```",
138
145
  ];
139
146
  }
140
147
  if (schemaId === "frontend-implementation-contract-revision-patch-v1") {
@@ -142,6 +149,11 @@ function frontendStructuredArtifactRetryGuidance(schemaId) {
142
149
  "Return exactly this artifact and nothing else: one short lead-in line, then exactly one fenced json block containing the RFC 7386 merge-patch delta, immediately followed by exactly one fenced openspec-citations block.",
143
150
  "The delta contains only the fields you change; null deletes a key, arrays and scalars replace, and plain objects merge recursively. Do not emit a full contract or a schemaVersion/targets section.",
144
151
  "Do not emit Markdown headings, bullets, plan prose, explanations, raw JSON, or any other fenced block.",
152
+ "The openspec-citations fenced block contains one JSON object per line, each with path/section/line — for example:",
153
+ "```openspec-citations",
154
+ '{"path":".harness/tasks/<task>/source/需求.md","section":"Objective","line":5}',
155
+ '{"path":".harness/tasks/<task>/source/references/product-requirement.md","section":"FR-1","line":16}',
156
+ "```",
145
157
  ];
146
158
  }
147
159
  return [
@@ -731,10 +743,17 @@ export async function executeDagNode(input) {
731
743
  let terminalResult;
732
744
  let previousFailureCategory;
733
745
  let previousProtocolReason;
746
+ let pendingStructuredArtifact;
747
+ let lastRepairAllowlist = [];
734
748
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
735
749
  const attemptStartedAt = new Date().toISOString();
736
750
  node.currentAttempt = attemptNumber;
737
751
  node.livenessStatus = "active";
752
+ // Capture the session-events.jsonl length BEFORE the executor appends this
753
+ // attempt's events, so the post-attempt repair tool audit can be scoped to
754
+ // exactly this attempt's segment (B1: earlier attempts legitimately use
755
+ // ls/find and must not be attributed to the repair attempt).
756
+ const attemptSessionEventsOffset = await sessionEventsByteLength(runDir, nodeId);
738
757
  let result;
739
758
  try {
740
759
  validateRepairArtifactGateBeforeShell({
@@ -757,12 +776,53 @@ export async function executeDagNode(input) {
757
776
  (acc[i.path] ??= []).push(i.detail);
758
777
  return acc;
759
778
  }, {});
779
+ let attemptPrompt = buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics);
780
+ if (attemptNumber > 1 &&
781
+ isFrontendStructuredRepairSchemaId(task.structuredContractOutput?.schemaId) &&
782
+ isStructuredRepairableFailureCategory(previousFailureCategory) &&
783
+ (await hasNonEmptyStructuredCandidate({
784
+ runDir,
785
+ nodeId,
786
+ beforeAttempt: attemptNumber,
787
+ }))) {
788
+ const originalCanonical = task.structuredContractOutput?.schemaId ===
789
+ "frontend-implementation-contract-revision-patch-v1"
790
+ ? (await readFrontendCanonicalCandidate(runDir, "frontend-plan-pi"))
791
+ : undefined;
792
+ const frozen = await freezeStructuredOutputRepairContext({
793
+ runDir,
794
+ runId: state.runId,
795
+ taskId: spec.sourceBinding?.taskId ?? task.id,
796
+ nodeId,
797
+ nodeAttempt: attemptNumber,
798
+ targetSchemaId: task.structuredContractOutput.schemaId,
799
+ skeleton: task.structuredContractOutput?.skeleton,
800
+ originalCanonical,
801
+ latestStopReason: previousFailureCategory === "structured-output-truncated"
802
+ ? "length"
803
+ : previousFailureCategory,
804
+ });
805
+ lastRepairAllowlist = allowedRepairReadPaths({
806
+ runDir,
807
+ context: frozen.context,
808
+ contextPath: frozen.relativePath,
809
+ });
810
+ attemptPrompt = await buildStructuredOutputRepairPrompt({
811
+ runDir,
812
+ context: frozen.context,
813
+ contextPath: frozen.relativePath,
814
+ contextSha256: frozen.sha256,
815
+ maxAttempts,
816
+ schemaGuidance: frontendStructuredArtifactRetryGuidance(task.structuredContractOutput.schemaId),
817
+ previousReason: previousProtocolReason,
818
+ });
819
+ }
760
820
  result = await executeNode({
761
821
  task,
762
822
  cwd,
763
823
  model,
764
824
  ...(thinking ? { thinking } : {}),
765
- prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths, recoveryDiagnostics),
825
+ prompt: attemptPrompt,
766
826
  attempt: attemptNumber,
767
827
  reportActivity,
768
828
  timeoutMs: livenessPolicy.absoluteMaxWallClockMs,
@@ -778,6 +838,35 @@ export async function executeDagNode(input) {
778
838
  durationMs: 0,
779
839
  };
780
840
  }
841
+ if (isFrontendStructuredRepairSchemaId(task.structuredContractOutput?.schemaId)) {
842
+ const rawText = canonicalNodeOutput(result);
843
+ if (rawText.trim().length > 0) {
844
+ await persistStructuredAttemptRaw({
845
+ runDir,
846
+ nodeId,
847
+ attempt: attemptNumber,
848
+ text: rawText,
849
+ });
850
+ }
851
+ if (attemptNumber > 1 && lastRepairAllowlist.length > 0) {
852
+ const audit = await auditRepairAttemptToolUse({
853
+ runDir,
854
+ nodeId,
855
+ allowedPaths: lastRepairAllowlist,
856
+ fromByteOffset: attemptSessionEventsOffset,
857
+ });
858
+ if (!audit.ok) {
859
+ result = {
860
+ ...result,
861
+ ok: false,
862
+ failureCategory: GOVERNANCE_BLOCKED_CATEGORY,
863
+ stderr: [result.stderr, audit.reason]
864
+ .filter(Boolean)
865
+ .join("\n"),
866
+ };
867
+ }
868
+ }
869
+ }
781
870
  // R0: executor ok=true still fails closed when outputProtocol is violated.
782
871
  // Valid semantic results (e.g. VERDICT: request-revision) pass validation.
783
872
  if (result.ok && task.outputProtocol) {
@@ -814,16 +903,17 @@ export async function executeDagNode(input) {
814
903
  structuredContractOutput: task.structuredContractOutput,
815
904
  });
816
905
  if (!contractCheck.ok) {
817
- // A provider stopReason=length means the response was cut before the
818
- // JSON contract could complete; separate it from an ordinary bad
819
- // contract so the retry switches to a JSON-only output strategy.
820
906
  const attemptStopReason = result.stopReason;
907
+ const failureCategory = contractCheck.classification === "governance-blocked"
908
+ ? GOVERNANCE_BLOCKED_CATEGORY
909
+ : attemptStopReason === "length" ||
910
+ contractCheck.classification === "truncated"
911
+ ? "structured-output-truncated"
912
+ : "invalid-output";
821
913
  result = {
822
914
  ...result,
823
915
  ok: false,
824
- failureCategory: attemptStopReason === "length"
825
- ? "structured-output-truncated"
826
- : "invalid-output",
916
+ failureCategory,
827
917
  stderr: [result.stderr, contractCheck.reason]
828
918
  .filter(Boolean)
829
919
  .join("\n"),
@@ -838,6 +928,9 @@ export async function executeDagNode(input) {
838
928
  stdout: contractCheck.normalizedText,
839
929
  };
840
930
  }
931
+ if (contractCheck.artifact) {
932
+ pendingStructuredArtifact = contractCheck.artifact;
933
+ }
841
934
  previousProtocolReason = undefined;
842
935
  }
843
936
  }
@@ -908,13 +1001,26 @@ export async function executeDagNode(input) {
908
1001
  break;
909
1002
  const canRetry = retryPolicy !== undefined && attemptNumber < maxAttempts;
910
1003
  const retryable = retryPolicy !== undefined &&
1004
+ result.failureCategory !== GOVERNANCE_BLOCKED_CATEGORY &&
911
1005
  (isRetryablePiFailureCategory(result.failureCategory, {
912
1006
  retryCategories: retryPolicy.retryCategories,
913
1007
  }) ||
914
1008
  (result.failureCategory === "protocol-invalid" &&
915
1009
  task.outputProtocol?.retryOnInvalid === true));
916
- if (!canRetry || !retryable)
1010
+ if (!canRetry || !retryable) {
1011
+ if (!canRetry &&
1012
+ isFrontendStructuredRepairSchemaId(task.structuredContractOutput?.schemaId) &&
1013
+ isStructuredRepairableFailureCategory(result.failureCategory)) {
1014
+ result = {
1015
+ ...result,
1016
+ failureCategory: STRUCTURED_REPAIR_EXHAUSTED_CATEGORY,
1017
+ };
1018
+ terminalResult = result;
1019
+ previousFailureCategory = result.failureCategory;
1020
+ node.failureCategory = result.failureCategory;
1021
+ }
917
1022
  break;
1023
+ }
918
1024
  const delayMs = computeBackoffDelayMs(attemptNumber + 1, retryPolicy);
919
1025
  if (delayMs > 0) {
920
1026
  const backoffStartedAt = new Date().toISOString();
@@ -1063,6 +1169,11 @@ export async function executeDagNode(input) {
1063
1169
  node.structuredArtifactSchemaId = "backend-test-result-v1";
1064
1170
  }
1065
1171
  }
1172
+ else if (pendingStructuredArtifact) {
1173
+ node.structuredArtifactPath = pendingStructuredArtifact.path;
1174
+ node.structuredArtifactSha256 = pendingStructuredArtifact.sha256;
1175
+ node.structuredArtifactSchemaId = pendingStructuredArtifact.schemaId;
1176
+ }
1066
1177
  }
1067
1178
  const finishedAt = new Date().toISOString();
1068
1179
  node.finishedAt = finishedAt;
@@ -116,7 +116,21 @@ export function buildUpstreamContext(task, upstream, maxChars = MAX_UPSTREAM_CHA
116
116
  ].join("\n"));
117
117
  continue;
118
118
  }
119
- if (!record || record.status !== "FINISHED" || !upstreamText)
119
+ if (!record || record.status !== "FINISHED")
120
+ continue;
121
+ if (record.structuredArtifactPath &&
122
+ record.structuredArtifactSchemaId ===
123
+ "frontend-implementation-contract-v1") {
124
+ sections.push([
125
+ `## Upstream output: ${depId}`,
126
+ "Validated structured artifact (read-only runner evidence — use read tool to fetch; do not edit). Do not infer the contract from stdout.",
127
+ `- path: ${record.structuredArtifactPath}`,
128
+ `- schema: ${record.structuredArtifactSchemaId}`,
129
+ `- sha256: ${record.structuredArtifactSha256 ?? "unknown"}`,
130
+ ].join("\n"));
131
+ continue;
132
+ }
133
+ if (!upstreamText)
120
134
  continue;
121
135
  const artifactKind = stdout ? "stdout" : "assistant";
122
136
  const artifactPath = stdout