@tea-agent/loop-agent 0.26.3 → 0.26.5-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathMatchesPattern } from "../shared/git-progress.js";
4
+ function normalizePath(value) {
5
+ return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
6
+ }
7
+ function isConcreteRepoRelativePath(value) {
8
+ const normalized = normalizePath(value);
9
+ if (!normalized)
10
+ return false;
11
+ if (normalized === "." ||
12
+ normalized === "**" ||
13
+ normalized === "*" ||
14
+ normalized === "/*") {
15
+ return false;
16
+ }
17
+ if (normalized.includes("**") || normalized.includes("*"))
18
+ return false;
19
+ if (path.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized))
20
+ return false;
21
+ if (normalized.split("/").some((segment) => segment === ".."))
22
+ return false;
23
+ return true;
24
+ }
25
+ function assertValidDirectoryIndexRule(rule, indexLabel) {
26
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
27
+ throw new Error(`document-index-closure: invalid completeDirectoryIndexes entry at ${indexLabel}`);
28
+ }
29
+ const record = rule;
30
+ if (typeof record.directory !== "string" ||
31
+ record.directory.trim().length === 0) {
32
+ throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].directory must be a non-empty string`);
33
+ }
34
+ if (typeof record.index !== "string" || record.index.trim().length === 0) {
35
+ throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].index must be a non-empty string`);
36
+ }
37
+ const directory = normalizePath(record.directory);
38
+ const index = normalizePath(record.index);
39
+ if (!isConcreteRepoRelativePath(directory)) {
40
+ throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].directory must be a concrete repo-relative path (got "${record.directory}")`);
41
+ }
42
+ if (!isConcreteRepoRelativePath(index)) {
43
+ throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].index must be a concrete repo-relative path without globs (got "${record.index}")`);
44
+ }
45
+ return { directory, index };
46
+ }
47
+ /**
48
+ * Load completeDirectoryIndexes from docs/document-catalog.json only.
49
+ * Does not hard-code a second directory table.
50
+ */
51
+ export function loadCompleteDirectoryIndexes(repoRoot) {
52
+ const catalogPath = path.join(repoRoot, "docs", "document-catalog.json");
53
+ // The catalog is an opt-in governance surface. Projects that have not
54
+ // initialized it must still be able to generate DAGs for docs paths.
55
+ if (!existsSync(catalogPath))
56
+ return [];
57
+ let raw;
58
+ try {
59
+ raw = readFileSync(catalogPath, "utf-8");
60
+ }
61
+ catch (error) {
62
+ throw new Error(`document-index-closure: failed to read docs/document-catalog.json: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ }
68
+ catch (error) {
69
+ throw new Error(`document-index-closure: docs/document-catalog.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
72
+ throw new Error("document-index-closure: docs/document-catalog.json root must be an object");
73
+ }
74
+ const rulesRaw = parsed
75
+ .completeDirectoryIndexes;
76
+ if (rulesRaw === undefined) {
77
+ return [];
78
+ }
79
+ if (!Array.isArray(rulesRaw)) {
80
+ throw new Error("document-index-closure: completeDirectoryIndexes must be an array when present");
81
+ }
82
+ return rulesRaw.map((rule, index) => assertValidDirectoryIndexRule(rule, String(index)));
83
+ }
84
+ function candidateDirectoryPrefixes(candidatePath) {
85
+ const normalized = normalizePath(candidatePath);
86
+ if (!normalized)
87
+ return [];
88
+ // Treat trailing /** globs as directory roots for prefix matching.
89
+ const withoutGlob = normalized
90
+ .replace(/\/\*\*$/, "")
91
+ .replace(/\/\*$/, "")
92
+ .replace(/\*\*$/, "")
93
+ .replace(/\*$/, "");
94
+ const base = normalizePath(withoutGlob);
95
+ if (!base || base === "." || base === "**")
96
+ return [];
97
+ const segments = base.split("/").filter(Boolean);
98
+ const prefixes = [];
99
+ for (let i = segments.length; i >= 1; i -= 1) {
100
+ prefixes.push(segments.slice(0, i).join("/"));
101
+ }
102
+ return prefixes;
103
+ }
104
+ function longestMatchingRule(candidatePath, rules) {
105
+ const prefixes = new Set(candidateDirectoryPrefixes(candidatePath));
106
+ if (prefixes.size === 0)
107
+ return undefined;
108
+ let best;
109
+ for (const rule of rules) {
110
+ if (!prefixes.has(rule.directory) &&
111
+ !candidateUnderDirectory(candidatePath, rule.directory)) {
112
+ continue;
113
+ }
114
+ if (!best || rule.directory.length > best.directory.length) {
115
+ best = rule;
116
+ }
117
+ }
118
+ return best;
119
+ }
120
+ function candidateUnderDirectory(candidatePath, directory) {
121
+ const normalized = normalizePath(candidatePath)
122
+ .replace(/\/\*\*$/, "")
123
+ .replace(/\/\*$/, "");
124
+ const dir = normalizePath(directory);
125
+ return normalized === dir || normalized.startsWith(`${dir}/`);
126
+ }
127
+ /**
128
+ * Derive concrete companion index paths for candidate docs paths using catalog rules.
129
+ * Longest directory prefix wins. Throws fail-closed on forbidden conflicts.
130
+ */
131
+ export function resolveDocumentIndexCompanions(input) {
132
+ const forbidden = input.forbiddenPaths ?? [];
133
+ const companions = new Set();
134
+ for (const candidate of input.candidatePaths) {
135
+ const rule = longestMatchingRule(candidate, input.rules);
136
+ if (!rule)
137
+ continue;
138
+ const indexPath = rule.index;
139
+ if (forbidden.some((pattern) => pathMatchesPattern(indexPath, pattern))) {
140
+ throw new Error(`document-index-closure: companion index "${indexPath}" for "${candidate}" conflicts with forbiddenPaths`);
141
+ }
142
+ companions.add(indexPath);
143
+ }
144
+ return [...companions].sort((a, b) => a.localeCompare(b));
145
+ }
146
+ /**
147
+ * Convenience: load catalog and merge companion indexes into path lists.
148
+ */
149
+ export function mergeDocumentIndexCompanions(input) {
150
+ const rules = loadCompleteDirectoryIndexes(input.repoRoot);
151
+ const companions = resolveDocumentIndexCompanions({
152
+ candidatePaths: input.paths,
153
+ rules,
154
+ forbiddenPaths: input.forbiddenPaths,
155
+ });
156
+ const merged = new Set(input.paths.map(normalizePath));
157
+ for (const companion of companions) {
158
+ merged.add(companion);
159
+ }
160
+ return {
161
+ paths: [...merged],
162
+ companions,
163
+ };
164
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { redactSecrets, truncateUtf8Preview, } from "../../shared/preview.js";
3
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
4
4
  import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
5
5
  import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
6
6
  export const NODE_INPUT_SCHEMA_VERSION = 1;
@@ -142,9 +142,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
142
142
  ? spec.defaults
143
143
  : undefined;
144
144
  const executorDeclared = typeof task.executor === "string";
145
- const executor = executorDeclared
146
- ? String(task.executor)
147
- : "pi";
145
+ const executor = executorDeclared ? String(task.executor) : "pi";
148
146
  const executorSource = executorDeclared
149
147
  ? "task"
150
148
  : "schema-default";
@@ -190,7 +188,9 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
190
188
  const shellRaw = task.shell && typeof task.shell === "object" && !Array.isArray(task.shell)
191
189
  ? task.shell
192
190
  : null;
193
- const staticRaw = task.static && typeof task.static === "object" && !Array.isArray(task.static)
191
+ const staticRaw = task.static &&
192
+ typeof task.static === "object" &&
193
+ !Array.isArray(task.static)
194
194
  ? task.static
195
195
  : null;
196
196
  let kind = "unknown";
@@ -206,9 +206,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
206
206
  preset: typeof shellRaw.preset === "string"
207
207
  ? redactSecrets(shellRaw.preset)
208
208
  : null,
209
- cwd: typeof shellRaw.cwd === "string"
210
- ? redactSecrets(shellRaw.cwd)
211
- : null,
209
+ cwd: typeof shellRaw.cwd === "string" ? redactSecrets(shellRaw.cwd) : null,
212
210
  timeoutMs: typeof shellRaw.timeoutMs === "number" &&
213
211
  Number.isFinite(shellRaw.timeoutMs)
214
212
  ? shellRaw.timeoutMs
@@ -216,6 +214,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
216
214
  nonZeroExitPolicy: typeof shellRaw.nonZeroExitPolicy === "string"
217
215
  ? shellRaw.nonZeroExitPolicy
218
216
  : null,
217
+ failFast: typeof shellRaw.failFast === "boolean" ? shellRaw.failFast : null,
219
218
  gates: shellGateNames(shellRaw),
220
219
  }
221
220
  : null;
@@ -245,9 +244,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
245
244
  executor: redactString(executor),
246
245
  executorSource,
247
246
  role: typeof task.role === "string" ? redactSecrets(task.role) : null,
248
- complexity: typeof task.complexity === "string"
249
- ? task.complexity
250
- : null,
247
+ complexity: typeof task.complexity === "string" ? task.complexity : null,
251
248
  writePolicy: writePolicy ? redactSecrets(writePolicy) : null,
252
249
  writePolicySource,
253
250
  toolProfile: typeof task.toolProfile === "string"
@@ -1,4 +1,4 @@
1
- import { access, appendFile, cp, mkdir } from "node:fs/promises";
1
+ import { access, appendFile, cp, mkdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProcessVerdict } from "../node-execution.js";
4
4
  import { freshNodeRecord } from "../dynamic-runtime/shared.js";
@@ -120,7 +120,8 @@ export async function runConvergencePassController(input) {
120
120
  isLegitimateReviewRequestRevision(input.state)) {
121
121
  return handleReviewRequestRevision(input, reviewGate);
122
122
  }
123
- if (reviewGate.status === "FINISHED") {
123
+ if (reviewGate.status === "FINISHED" &&
124
+ isLegitimateReviewPass(input.state)) {
124
125
  convergence.terminalReason = "review-pass";
125
126
  await appendConvergenceKnowledgePattern({
126
127
  cwd: input.cwd,
@@ -128,6 +129,9 @@ export async function runConvergencePassController(input) {
128
129
  });
129
130
  return { retry: false };
130
131
  }
132
+ if (reviewGate.status === "FINISHED") {
133
+ return handleReviewRequestRevision(input, reviewGate);
134
+ }
131
135
  // Compatibility: gates without routingAccept still ERROR on revision.
132
136
  if (reviewGate.status === "ERROR") {
133
137
  return handleReviewRequestRevision(input, reviewGate);
@@ -151,10 +155,14 @@ async function resolveNoRepairTerminal(input) {
151
155
  isLegitimateReviewRequestRevision(input.state)) {
152
156
  return handleReviewRequestRevision(input, reviewGate);
153
157
  }
154
- if (reviewGate.status === "FINISHED") {
158
+ if (reviewGate.status === "FINISHED" &&
159
+ isLegitimateReviewPass(input.state)) {
155
160
  convergence.terminalReason = "review-pass";
156
161
  return { retry: false };
157
162
  }
163
+ if (reviewGate.status === "FINISHED") {
164
+ return handleReviewRequestRevision(input, reviewGate);
165
+ }
158
166
  if (reviewGate.status === "ERROR") {
159
167
  return handleReviewRequestRevision(input, reviewGate);
160
168
  }
@@ -178,10 +186,61 @@ async function resolveNoRepairTerminal(input) {
178
186
  * Hard verification failed. Apply the existing non-retry / regression /
179
187
  * max-passes guards, then reset the convergence chain for another pass.
180
188
  */
189
+ function repairDispositionOf(artifact) {
190
+ return artifact?.disposition ?? "repairable";
191
+ }
192
+ function hardVerifyFailureFingerprint(input) {
193
+ return [
194
+ input.failureCategory ?? "unknown",
195
+ input.verifyPhase ?? "",
196
+ input.verifyQuota ?? "",
197
+ String(input.verifyCommandCount ?? ""),
198
+ (input.verifyCommandLabels ?? []).join("\n"),
199
+ (input.stderrExcerpt ?? "").slice(0, 500),
200
+ ].join("|");
201
+ }
202
+ function fingerprintFromHardVerify(hardVerify) {
203
+ const verifyEvidence = hardVerify.verifyEvidence;
204
+ // Keep fingerprint fields aligned with passHistory so consecutive passes
205
+ // compare the same identity (category + verifyEvidence command contract).
206
+ // stderr is intentionally excluded: pass history does not retain it, and
207
+ // empty repair manifests already prove no workspace progress.
208
+ return hardVerifyFailureFingerprint({
209
+ failureCategory: hardVerify.failureCategory,
210
+ verifyPhase: verifyEvidence?.phase,
211
+ verifyQuota: verifyEvidence?.quota,
212
+ verifyCommandCount: verifyEvidence?.commandCount,
213
+ verifyCommandLabels: verifyEvidence?.commandLabels,
214
+ });
215
+ }
216
+ function fingerprintFromPassHistory(pass) {
217
+ return hardVerifyFailureFingerprint({
218
+ failureCategory: pass.hardVerifyFailureCategory,
219
+ verifyPhase: pass.verifyPhase,
220
+ verifyQuota: pass.verifyQuota,
221
+ verifyCommandCount: pass.verifyCommandCount,
222
+ verifyCommandLabels: pass.verifyCommandLabels,
223
+ });
224
+ }
225
+ async function readRepairChangedFiles(runDir, repairNodeId = "repair-pi") {
226
+ try {
227
+ const raw = await readFile(path.join(runDir, repairNodeId, "change-manifest.json"), "utf-8");
228
+ const parsed = JSON.parse(raw);
229
+ if (!Array.isArray(parsed.changedFiles))
230
+ return [];
231
+ return parsed.changedFiles.filter((entry) => typeof entry === "string");
232
+ }
233
+ catch {
234
+ return undefined;
235
+ }
236
+ }
181
237
  async function handleHardVerifyFailure(input, hardVerify) {
182
238
  const convergence = input.state.convergence;
183
239
  const currentPass = convergence.currentPass || 1;
184
240
  const hardFailure = hardVerify.failureCategory ?? "unknown";
241
+ const processSupervisor = input.state.nodes["process-supervisor-pi"];
242
+ const repairArtifact = processSupervisor?.repairArtifact;
243
+ const disposition = repairDispositionOf(repairArtifact);
185
244
  const passRecord = await buildConvergencePassRecord({
186
245
  pass: currentPass,
187
246
  status: "retrying",
@@ -198,6 +257,36 @@ async function handleHardVerifyFailure(input, hardVerify) {
198
257
  await input.persistState();
199
258
  return { retry: false };
200
259
  }
260
+ // AC-002: blocked-boundary stops re-entry of same-permission repair.
261
+ if (disposition === "blocked-boundary") {
262
+ passRecord.status = "terminal";
263
+ passRecord.reason = "blocked-boundary";
264
+ convergence.passHistory.push(passRecord);
265
+ convergence.terminalReason = "blocked-boundary";
266
+ await input.persistState();
267
+ return { retry: false };
268
+ }
269
+ // AC-003: identical hard-verify failure fingerprint + empty repair
270
+ // change-manifest => no-progress early stop (does not exhaust maxFixLoops).
271
+ const currentFingerprint = fingerprintFromHardVerify(hardVerify);
272
+ const previousFailed = [...convergence.passHistory]
273
+ .reverse()
274
+ .find((pass) => pass.hardVerifyStatus === "ERROR");
275
+ const previousFingerprint = previousFailed
276
+ ? fingerprintFromPassHistory(previousFailed)
277
+ : undefined;
278
+ const changedFiles = await readRepairChangedFiles(input.runDir);
279
+ if (previousFingerprint !== undefined &&
280
+ previousFingerprint === currentFingerprint &&
281
+ changedFiles !== undefined &&
282
+ changedFiles.length === 0) {
283
+ passRecord.status = "terminal";
284
+ passRecord.reason = "no-progress";
285
+ convergence.passHistory.push(passRecord);
286
+ convergence.terminalReason = "no-progress";
287
+ await input.persistState();
288
+ return { retry: false };
289
+ }
201
290
  if (input.spec.convergence?.pauseOnRegression !== false &&
202
291
  detectConvergenceRegression(convergence.passHistory, passRecord)) {
203
292
  passRecord.status = "paused";
@@ -239,6 +328,14 @@ function isLegitimateReviewRequestRevision(state) {
239
328
  reviewVerdictNode?.status === "FINISHED" &&
240
329
  parseProcessVerdict(reviewVerdictNode) === "request-revision");
241
330
  }
331
+ function isLegitimateReviewPass(state) {
332
+ const reviewNode = state.nodes["review-pi"];
333
+ const reviewVerdictNode = state.nodes[REVIEW_VERDICT_NODE_ID];
334
+ return (reviewNode?.status === "FINISHED" &&
335
+ parseProcessVerdict(reviewNode) === "pass" &&
336
+ reviewVerdictNode?.status === "FINISHED" &&
337
+ parseProcessVerdict(reviewVerdictNode) === "pass");
338
+ }
242
339
  /**
243
340
  * Hard verification passed but the review path produced a legitimate
244
341
  * `request-revision`. With routingAccept the review gate finishes as a routing
@@ -155,7 +155,7 @@ export const frontendImplementationContractSchema = z
155
155
  "not-needed",
156
156
  ]),
157
157
  productionDefaultOff: z.literal(true),
158
- activation: z.string().min(1),
158
+ activation: z.preprocess((value) => (value === "" ? "explicit activation boundary" : value), z.string().min(1)),
159
159
  endpoints: z.array(z
160
160
  .object({
161
161
  method: z.enum([
@@ -168,7 +168,10 @@ export const frontendImplementationContractSchema = z
168
168
  "OPTIONS",
169
169
  ]),
170
170
  path: z.string().startsWith("/"),
171
- fixture: safePath.optional(),
171
+ // Models occasionally emit an empty fixture when Mock is
172
+ // intentionally not needed. Treat it like an omitted optional
173
+ // field; active Mock strategies still fail the refinement below.
174
+ fixture: z.preprocess((value) => (value === "" ? undefined : value), safePath.optional()),
172
175
  consumer: safePath.optional(),
173
176
  })
174
177
  .strict()),
@@ -187,7 +190,7 @@ export const frontendImplementationContractSchema = z
187
190
  type: z.enum(["static", "unit", "component", "integration", "mock"]),
188
191
  commandLabel: z.string().min(1),
189
192
  file: safePath,
190
- symbol: z.string().min(1).optional(),
193
+ symbol: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).optional()),
191
194
  requirementIds: z.array(id),
192
195
  uiStates: z.array(z.string().min(1)),
193
196
  })
@@ -46,7 +46,12 @@ function firstNonEmptyVerdictLine(text) {
46
46
  // The prompt requires VERDICT to be the first non-empty line, but model
47
47
  // output can still prepend a summary. Keep the protocol strict in the
48
48
  // prompt while making the deterministic gate resilient to that drift.
49
- return normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
49
+ const verdictLine = normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
50
+ if (/^VERDICT:\s*pass(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
51
+ return "VERDICT: pass";
52
+ if (/^VERDICT:\s*request-revision(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
53
+ return "VERDICT: request-revision";
54
+ return verdictLine;
50
55
  }
51
56
  function eventArgs(event) {
52
57
  return event.args ?? event.toolInput ?? event.input ?? {};
@@ -13,6 +13,7 @@ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRU
13
13
  import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
14
14
  import { resolveAdapter } from "../../adapters/index.js";
15
15
  import { loadHarnessManifest } from "../../governance/harness.js";
16
+ import { mergeDocumentIndexCompanions } from "../../governance/document-index-closure.js";
16
17
  import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
17
18
  import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-embedded.js";
18
19
  import { discoverProjectGovernancePresence } from "./project-governance-context.js";
@@ -721,9 +722,28 @@ function mergeForbiddenPaths(taskConfig) {
721
722
  ]);
722
723
  return [...merged];
723
724
  }
724
- function resolveImplementPaths(taskConfig) {
725
+ function resolveImplementPaths(taskConfig, options = {}) {
725
726
  if (taskConfig.allowedPaths.length > 0) {
726
727
  const allowed = [...taskConfig.allowedPaths];
728
+ const forbidden = mergeForbiddenPaths(taskConfig);
729
+ const repoRoot = options.repoRoot;
730
+ // Only consult the catalog when writer paths can touch docs/* so non-docs
731
+ // temp fixtures without a catalog keep working; docs writers still fail closed.
732
+ const mayNeedDocIndex = allowed.some((entry) => {
733
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
734
+ return normalized === "docs" || normalized.startsWith("docs/");
735
+ });
736
+ if (repoRoot && mayNeedDocIndex) {
737
+ const merged = mergeDocumentIndexCompanions({
738
+ repoRoot,
739
+ paths: allowed,
740
+ forbiddenPaths: forbidden,
741
+ });
742
+ return {
743
+ allowedPaths: merged.paths,
744
+ writeSet: [...merged.paths],
745
+ };
746
+ }
727
747
  return {
728
748
  allowedPaths: allowed,
729
749
  writeSet: [...allowed],
@@ -786,7 +806,9 @@ function applyMavenVerificationPlanning(input) {
786
806
  if (!input.repoRoot || !input.commands || input.commands.length === 0) {
787
807
  return { commands: input.commands };
788
808
  }
789
- const implement = resolveImplementPaths(input.taskConfig);
809
+ const implement = resolveImplementPaths(input.taskConfig, {
810
+ repoRoot: input.repoRoot,
811
+ });
790
812
  const planned = planMavenVerification({
791
813
  repoRoot: input.repoRoot,
792
814
  commands: input.commands,
@@ -1515,7 +1537,9 @@ function mergeFinalVerifyCommands(repoRoot, taskConfig, adapterCommands) {
1515
1537
  export function buildStandardHybridDagFromTask(sources) {
1516
1538
  const { taskConfig } = sources;
1517
1539
  const forbiddenPaths = mergeForbiddenPaths(taskConfig);
1518
- const implementPaths = resolveImplementPaths(taskConfig);
1540
+ const implementPaths = resolveImplementPaths(taskConfig, {
1541
+ repoRoot: sources.repoRoot,
1542
+ });
1519
1543
  const scoutPaths = deriveParallelScoutPaths(taskConfig);
1520
1544
  const scoutComplexity = mapTaskComplexity(taskConfig.complexity);
1521
1545
  const implementComplexity = resolveWriterComplexity(taskConfig);
@@ -2076,7 +2100,9 @@ async function buildFrontendHybridDagFromTask(sources) {
2076
2100
  frontendMockMode: mockMode,
2077
2101
  };
2078
2102
  const forbiddenPaths = mergeForbiddenPaths(taskConfig);
2079
- const implementPaths = resolveImplementPaths(taskConfig);
2103
+ const implementPaths = resolveImplementPaths(taskConfig, {
2104
+ repoRoot: sources.repoRoot,
2105
+ });
2080
2106
  const implementId = frontendImplementationNodeId();
2081
2107
  const mockContextBlock = resolveFrontendMockContextBlock(frontendSources);
2082
2108
  const capabilityContextBlock = resolveFrontendCapabilityContextBlock(frontendSources);
@@ -3600,7 +3626,7 @@ async function buildBackendTestHybridDag(sources) {
3600
3626
  outputContract: "Convert every final automatable Markdown case into pytest assets whose actual test function region contains the exact Case ID, preferably in the function name or docstring. Each testcase/md/<module>.md (excluding README.md) maps one-to-one to testcase/test_<module>.py; never merge or split modules. No JSON and no pytest execution.",
3601
3627
  subtask_prompt: [
3602
3628
  "Convert testcase/md/** to pytest using upstream environment and advisory validation evidence plus only bounded pytest config/conftest. A FAIL advisory report does not authorize inventing missing behavior; use the final Markdown facts that are present.",
3603
- "Ensure every final Markdown Case ID appears in exactly one primary pytest test function or pytest test class method region, using the exact `primary symbol` declared by Markdown. The symbol must start with `test_BE_<MODULE>_<NNN>_` so every parameterized collected item remains associated with its Case. Module-level functions and class-based pytest methods are both supported. Only `变体测试点` may use stable `pytest.param(..., id=\"TP-...\")` IDs, and every atomic variant ID must appear exactly once with a genuine input/state/outcome change. Use `pytest.param(..., id=...)` for every row; do not use decorator-level `ids=[...]`, generated suffixes, or IDs that extend/shorten the exact Markdown TP. Do not parameterize `场景断言测试点` or `横切证据测试点`; execute all assertion checkpoints within the same business journey/item and use shared helpers for cross-cutting evidence. The primary symbol docstring must contain exact metadata lines `Case-ID: BE-...`, `Assertion-Test-Points: TP-...;TP-...` and `Cross-Cutting-Test-Points: TP-...;TP-...` (use `none` when empty). No Test Point may be invented, renamed, omitted or bound in two modes. The generated pytest collection shape must equal the Markdown prediction `sum(max(1, variant count per Case))`; keep it at or below the task's explicit budget by removing duplicate execution, never by collapsing multiple parameter rows under a coarse family TP. Assertions come only from 预期结果 and setup comes only from 前置条件/测试数据/自动化映射.",
3629
+ 'Ensure every final Markdown Case ID appears in exactly one primary pytest test function or pytest test class method region, using the exact `primary symbol` declared by Markdown. The symbol must start with `test_BE_<MODULE>_<NNN>_` so every parameterized collected item remains associated with its Case. Module-level functions and class-based pytest methods are both supported. Only `变体测试点` may use stable `pytest.param(..., id="TP-...")` IDs, and every atomic variant ID must appear exactly once with a genuine input/state/outcome change. Use `pytest.param(..., id=...)` for every row; do not use decorator-level `ids=[...]`, generated suffixes, or IDs that extend/shorten the exact Markdown TP. Do not parameterize `场景断言测试点` or `横切证据测试点`; execute all assertion checkpoints within the same business journey/item and use shared helpers for cross-cutting evidence. The primary symbol docstring must contain exact metadata lines `Case-ID: BE-...`, `Assertion-Test-Points: TP-...;TP-...` and `Cross-Cutting-Test-Points: TP-...;TP-...` (use `none` when empty). No Test Point may be invented, renamed, omitted or bound in two modes. The generated pytest collection shape must equal the Markdown prediction `sum(max(1, variant count per Case))`; keep it at or below the task\'s explicit budget by removing duplicate execution, never by collapsing multiple parameter rows under a coarse family TP. Assertions come only from 预期结果 and setup comes only from 前置条件/测试数据/自动化映射.',
3604
3630
  "Name each generated pytest file so it corresponds one-to-one with its source Markdown module file: for each `testcase/md/<module>.md` (excluding README.md), emit exactly one `testcase/test_<module>.py`. The <module> stem is the Markdown filename without the `.md` extension, lowercased and with non-alphanumeric characters replaced by underscores. For example, `testcase/md/resource_notes.md` maps to `testcase/test_resource_notes.py`, `testcase/md/health.md` maps to `testcase/test_health.py`, `testcase/md/BE-HEALTH.md` maps to `testcase/test_be_health.py`, and `testcase/md/order-api.md` maps to `testcase/test_order_api.py`. If Markdown automation mapping names a different path than this module stem path, still write the module stem path and do not invent prefixes such as `test_be_*` unless the module filename itself normalizes to that stem. Never merge multiple Markdown modules into one pytest file, never split one module across several files, and never invent pytest filenames unrelated to the Markdown modules.",
3605
3631
  "Generate a reusable HTTP logging helper (or equivalent client wrapper) and call it for every interface request. The request log must include method, URL/path, and request parameters (query plus JSON/body/payload summary). The response log must include status code and response result (JSON/text/body summary), and both records must be visible in pytest stdout/stderr without changing assertions.",
3606
3632
  "HTTP response header names are case-insensitive. If the helper stores a lower-case normalized header map, every Content-Type or other header assertion must query the lower-case key (for example `content-type`) or use an explicitly case-insensitive accessor; never call a case-sensitive plain dict with `Content-Type` when the stored key is lower-case. Preserve the actual media-type assertion rather than dropping it.",
@@ -4139,12 +4165,20 @@ function buildFrontendTestHybridDag(sources) {
4139
4165
  forbiddenPaths: forbidden,
4140
4166
  outputContract: "Deterministic frontend L-5 Markdown and self-contained HTML dashboard derived only from frontend-test-result-v1.",
4141
4167
  subtask_prompt: "Render the authoritative frontend L-5 report from frontend-test-result-v1. Do not use Pi prose or invent code coverage. Missing line/branch coverage remains unavailable and makes L-5 NOT READY.",
4142
- shell: { frontendTestL5Report: {}, commands: [], cwd: ".", timeoutMs: 120000 },
4168
+ shell: {
4169
+ frontendTestL5Report: {},
4170
+ commands: [],
4171
+ cwd: ".",
4172
+ timeoutMs: 120000,
4173
+ },
4143
4174
  });
4144
4175
  if (strictOutcomeGate) {
4145
4176
  tasks.push({
4146
4177
  id: "frontend-test-result-outcome-gate-shell",
4147
- depends_on: ["materialize-frontend-test-result-shell", "frontend-test-l5-report-shell"],
4178
+ depends_on: [
4179
+ "materialize-frontend-test-result-shell",
4180
+ "frontend-test-l5-report-shell",
4181
+ ],
4148
4182
  role: "verifier",
4149
4183
  executor: "shell",
4150
4184
  complexity: "LOW",
@@ -4162,7 +4196,10 @@ function buildFrontendTestHybridDag(sources) {
4162
4196
  }
4163
4197
  tasks.push({
4164
4198
  id: "frontend-test-retrospect-pi",
4165
- depends_on: ["materialize-frontend-test-result-shell", "frontend-test-l5-report-shell"],
4199
+ depends_on: [
4200
+ "materialize-frontend-test-result-shell",
4201
+ "frontend-test-l5-report-shell",
4202
+ ],
4166
4203
  role: "closeout",
4167
4204
  executor: "pi",
4168
4205
  toolProfile: "write",
@@ -5393,7 +5430,7 @@ function buildReviewVerdictRecoveryNode(sources) {
5393
5430
  writePolicy: "read-only",
5394
5431
  allowedPaths: commonReadOnlyPaths(sources),
5395
5432
  forbiddenPaths: commonForbiddenPaths(sources),
5396
- outputContract: 'Structured JSON review verdict only, preserving the original review conclusion/findings without substantive changes. No file writes.',
5433
+ outputContract: "Structured JSON review verdict only, preserving the original review conclusion/findings without substantive changes. No file writes.",
5397
5434
  outputProtocol: REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL,
5398
5435
  subtask_prompt: [
5399
5436
  "Normalize the output format of review-pi; this is the single read-only format-recovery attempt for the review verdict protocol.",
@@ -5748,8 +5785,9 @@ function buildProcessSupervisorNode(sources) {
5748
5785
  "Supervise the implementation process after soft verification.",
5749
5786
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
5750
5787
  "Immediately after the verdict, include one fenced block labelled REPAIR_ARTIFACT_JSON with this JSON shape:",
5751
- '{"schemaVersion":1,"verdict":"pass|request-revision","failureClass":"syntax|runtime|logic|boundary|environment|governance|unknown","rootCause":"one concise reason","fixScope":["path/or/component"],"invariant":"behavior or contract to preserve","evidenceRefs":["relative/path/or/node"],"rawLogFallbackAllowed":false}',
5788
+ '{"schemaVersion":1,"verdict":"pass|request-revision","failureClass":"syntax|runtime|logic|boundary|environment|governance|unknown","rootCause":"one concise reason","fixScope":["path/or/component"],"invariant":"behavior or contract to preserve","evidenceRefs":["relative/path/or/node"],"rawLogFallbackAllowed":false,"disposition":"repairable|blocked-boundary|no-op-pass","requiredScope":["out-of-boundary/path"]}',
5752
5789
  `For request-revision, fixScope must be inside ${repairId} allowedPaths/writeSet. For pass, use an empty fixScope array.`,
5790
+ "disposition defaults to repairable when omitted (backward compatible). Use blocked-boundary when the needed fix is outside repair write authority; put the out-of-boundary paths in requiredScope (report-only, never expands write authority). Use no-op-pass only when no code change is warranted.",
5753
5791
  `Audit boundary drift, verification gaps, and whether ${repairId} should perform bounded fixes. Read-only: do not modify files.`,
5754
5792
  buildSourceContextBlock(sources),
5755
5793
  ].join("\n\n"),
@@ -5785,7 +5823,9 @@ function buildProcessGateNode(sources) {
5785
5823
  };
5786
5824
  }
5787
5825
  function buildRepairNode(sources) {
5788
- const implement = resolveImplementPaths(sources.taskConfig);
5826
+ const implement = resolveImplementPaths(sources.taskConfig, {
5827
+ repoRoot: sources.repoRoot,
5828
+ });
5789
5829
  return {
5790
5830
  id: repairNodeId(),
5791
5831
  depends_on: ["process-gate-shell", "process-supervisor-pi"],