@tea-agent/loop-agent 0.26.2 → 0.26.4

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.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { constants, createReadStream } from "node:fs";
4
- import { access, lstat, readlink } from "node:fs/promises";
4
+ import { access, lstat, readlink, unlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { pathMatchesPattern } from "../shared/git-progress.js";
7
7
  async function sha256File(filePath) {
@@ -58,8 +58,7 @@ export async function snapshotGitStatusPathFingerprints(cwd, status) {
58
58
  const entries = await Promise.all([...status.keys()].map(async (filePath) => {
59
59
  const candidate = path.resolve(root, filePath);
60
60
  const relative = path.relative(root, candidate);
61
- if (relative.startsWith("..") ||
62
- path.isAbsolute(relative)) {
61
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
63
62
  return [filePath, "outside-repository"];
64
63
  }
65
64
  try {
@@ -72,10 +71,7 @@ export async function snapshotGitStatusPathFingerprints(cwd, status) {
72
71
  ];
73
72
  }
74
73
  if (info.isFile()) {
75
- return [
76
- filePath,
77
- `file:${await sha256File(candidate)}`,
78
- ];
74
+ return [filePath, `file:${await sha256File(candidate)}`];
79
75
  }
80
76
  return [
81
77
  filePath,
@@ -101,7 +97,8 @@ export function isEphemeralToolCachePath(filePath) {
101
97
  const normalized = normalizePath(filePath);
102
98
  if (!normalized)
103
99
  return false;
104
- if (normalized === ".pytest_cache" || normalized.startsWith(".pytest_cache/")) {
100
+ if (normalized === ".pytest_cache" ||
101
+ normalized.startsWith(".pytest_cache/")) {
105
102
  return true;
106
103
  }
107
104
  if (normalized === ".mypy_cache" || normalized.startsWith(".mypy_cache/")) {
@@ -112,7 +109,8 @@ export function isEphemeralToolCachePath(filePath) {
112
109
  }
113
110
  // playwright-cli default session dumps (console/page/network) under repo cwd.
114
111
  // Real browser case evidence must still be written under testcase/** explicitly.
115
- if (normalized === ".playwright-cli" || normalized.startsWith(".playwright-cli/")) {
112
+ if (normalized === ".playwright-cli" ||
113
+ normalized.startsWith(".playwright-cli/")) {
116
114
  return true;
117
115
  }
118
116
  if (normalized === ".coverage" || normalized.startsWith(".coverage.")) {
@@ -164,6 +162,136 @@ export function validateShellWriteGuard(input) {
164
162
  }
165
163
  return { ok: violations.length === 0, violations };
166
164
  }
165
+ /** SHA-256 of an empty file, retained for fingerprint tests and diagnostics. */
166
+ export const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
167
+ /**
168
+ * A mistaken Git Bash `> nul` / `2> nul` target may contain bounded command
169
+ * output. Keep the recovery cap small enough that a material product artifact
170
+ * cannot be silently discarded while covering ordinary tool diagnostics.
171
+ */
172
+ export const MAX_BENIGN_ROOT_NUL_BYTES = 1024 * 1024;
173
+ /**
174
+ * Decide whether the exact repository-root `nul` entry is a benign tool
175
+ * artifact that may be removed. Every proof must hold: absent from the before
176
+ * snapshot, brand-new untracked (`??`) in the after snapshot, outside the
177
+ * writer's declared boundary, and currently inspected as a regular file no
178
+ * larger than {@link MAX_BENIGN_ROOT_NUL_BYTES}. Nested keys such as `src/nul`
179
+ * never match the exact root key; missing or unreadable facts stay fail-closed.
180
+ */
181
+ export function evaluateRootNulCandidate(input) {
182
+ if (!input.afterSnapshot.has("nul")) {
183
+ return { action: "skip", candidatePath: "nul", reason: "nested-path" };
184
+ }
185
+ if (input.beforeSnapshot.has("nul")) {
186
+ return { action: "skip", candidatePath: "nul", reason: "pre-existing" };
187
+ }
188
+ if (input.afterSnapshot.get("nul") !== "??") {
189
+ return {
190
+ action: "skip",
191
+ candidatePath: "nul",
192
+ reason: "not-new-untracked",
193
+ };
194
+ }
195
+ if (input.candidateAuthorized === true) {
196
+ return {
197
+ action: "skip",
198
+ candidatePath: "nul",
199
+ reason: "authorized-by-write-boundary",
200
+ };
201
+ }
202
+ const fingerprint = input.afterPathFingerprints?.get("nul");
203
+ if (!fingerprint || fingerprint.startsWith("unreadable:")) {
204
+ return {
205
+ action: "skip",
206
+ candidatePath: "nul",
207
+ reason: "unreadable",
208
+ };
209
+ }
210
+ if (input.candidateIsRegularFile === false ||
211
+ !fingerprint.startsWith("file:")) {
212
+ return {
213
+ action: "skip",
214
+ candidatePath: "nul",
215
+ reason: "not-regular-file",
216
+ };
217
+ }
218
+ const observedSizeBytes = input.candidateSizeBytes ??
219
+ (fingerprint === `file:${EMPTY_FILE_SHA256}` ? 0 : undefined);
220
+ if (observedSizeBytes === undefined ||
221
+ !Number.isSafeInteger(observedSizeBytes) ||
222
+ observedSizeBytes < 0) {
223
+ return { action: "skip", candidatePath: "nul", reason: "unreadable" };
224
+ }
225
+ if (observedSizeBytes > MAX_BENIGN_ROOT_NUL_BYTES) {
226
+ return { action: "skip", candidatePath: "nul", reason: "too-large" };
227
+ }
228
+ return {
229
+ action: "remove",
230
+ candidatePath: "nul",
231
+ reason: "new-untracked-bounded-regular-file",
232
+ observedSizeBytes,
233
+ };
234
+ }
235
+ /**
236
+ * Remove a benign repository-root `nul` artifact with the Node file API only.
237
+ * On win32 libuv addresses the literal name through the `\\?\` NT path form, so
238
+ * a file literally named `nul` can be stat/unlink'ed precisely without shell
239
+ * redirection or `rm`. Non-matching candidates are left untouched; unlink
240
+ * failures surface as a bounded `failed` result so callers stay fail-closed.
241
+ */
242
+ export async function recoverRootNulArtifact(input) {
243
+ let candidateSizeBytes;
244
+ let candidateIsRegularFile;
245
+ if (input.afterSnapshot.has("nul") &&
246
+ !input.beforeSnapshot.has("nul") &&
247
+ input.afterSnapshot.get("nul") === "??" &&
248
+ input.candidateAuthorized !== true) {
249
+ try {
250
+ const info = await lstat(path.resolve(input.rootCwd, "nul"));
251
+ candidateSizeBytes = info.size;
252
+ candidateIsRegularFile = info.isFile() && !info.isSymbolicLink();
253
+ }
254
+ catch {
255
+ candidateIsRegularFile = undefined;
256
+ }
257
+ }
258
+ const evaluation = evaluateRootNulCandidate({
259
+ beforeSnapshot: input.beforeSnapshot,
260
+ afterSnapshot: input.afterSnapshot,
261
+ afterPathFingerprints: input.afterPathFingerprints,
262
+ candidateSizeBytes,
263
+ candidateIsRegularFile,
264
+ candidateAuthorized: input.candidateAuthorized,
265
+ });
266
+ if (evaluation.action === "skip") {
267
+ return {
268
+ action: "skipped",
269
+ candidatePath: "nul",
270
+ reason: evaluation.reason,
271
+ ...(candidateSizeBytes === undefined ? {} : { observedSizeBytes: candidateSizeBytes }),
272
+ };
273
+ }
274
+ const removeFile = input.removeFile ?? ((filePath) => unlink(filePath));
275
+ try {
276
+ await removeFile(path.resolve(input.rootCwd, "nul"));
277
+ return {
278
+ action: "removed",
279
+ candidatePath: "nul",
280
+ reason: evaluation.reason,
281
+ observedSizeBytes: evaluation.observedSizeBytes,
282
+ removedAt: new Date().toISOString(),
283
+ };
284
+ }
285
+ catch (error) {
286
+ return {
287
+ action: "failed",
288
+ candidatePath: "nul",
289
+ reason: "removal-failed",
290
+ observedSizeBytes: evaluation.observedSizeBytes,
291
+ errorDetail: boundedErrorDetail(error),
292
+ };
293
+ }
294
+ }
167
295
  export class GitStatusUnavailableError extends Error {
168
296
  diagnostics;
169
297
  constructor(input) {
@@ -179,7 +307,8 @@ export class GitStatusUnavailableError extends Error {
179
307
  cwd: path.resolve(input.cwd),
180
308
  platform: input.platform ?? process.platform,
181
309
  executableCandidates: input.executableCandidates ?? [],
182
- requiredWindowsEnvironment: input.requiredWindowsEnvironment ?? requiredWindowsEnvironment(process.env),
310
+ requiredWindowsEnvironment: input.requiredWindowsEnvironment ??
311
+ requiredWindowsEnvironment(process.env),
183
312
  attempts: input.attempts,
184
313
  };
185
314
  }
@@ -216,11 +345,15 @@ function formatWindowsExitCode(exitCode) {
216
345
  return `0x${(exitCode >>> 0).toString(16).padStart(8, "0").toUpperCase()}`;
217
346
  }
218
347
  function isWindowsDllInitializationFailure(platform, exitCode) {
219
- return platform === "win32" && exitCode !== undefined && (exitCode >>> 0) === 0xc0000142;
348
+ return (platform === "win32" &&
349
+ exitCode !== undefined &&
350
+ exitCode >>> 0 === 0xc0000142);
220
351
  }
221
352
  function boundedErrorDetail(error) {
222
353
  const detail = error instanceof Error ? error.message : String(error);
223
- return detail.length <= 1000 ? detail : `${detail.slice(0, 1000)}...[truncated]`;
354
+ return detail.length <= 1000
355
+ ? detail
356
+ : `${detail.slice(0, 1000)}...[truncated]`;
224
357
  }
225
358
  export function deriveSameInstallationGitCandidates(primary) {
226
359
  const normalized = path.win32.normalize(primary);
@@ -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
@@ -34,43 +34,31 @@ export function isFrontendRepairable(failureClass) {
34
34
  }
35
35
  export function classifyFrontendFailure(input) {
36
36
  const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
37
- const failureDiagnosticBlob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
38
37
  if (input.nodeId.includes("contract") ||
39
38
  blob.includes("contract mismatch") ||
40
39
  blob.includes("source binding")) {
41
40
  return "contract";
42
41
  }
43
- if (input.failureCategory === "write-guard" ||
44
- failureDiagnosticBlob.includes("forbidden") ||
45
- failureDiagnosticBlob.includes("write guard") ||
46
- failureDiagnosticBlob.includes("write-set") ||
47
- failureDiagnosticBlob.includes("writeset")) {
42
+ // Shell verification may contain test-runner logs from unrelated tests. Only
43
+ // the shell executor's structured category proves a write-boundary failure.
44
+ if (input.failureCategory === "write-guard") {
48
45
  return "path";
49
46
  }
50
- if (blob.includes("package.json") ||
51
- blob.includes("unapproved dependency") ||
52
- blob.includes("npm install")) {
53
- return "dependency";
54
- }
55
- if (blob.includes("credential") ||
56
- blob.includes("api key") ||
57
- blob.includes("secret")) {
58
- return "credential";
59
- }
60
- if (blob.includes("deploy") || blob.includes("production release")) {
61
- return "deploy";
62
- }
63
- if (blob.includes("spec unclear") ||
64
- blob.includes("requirement ambiguous") ||
65
- blob.includes("needs human")) {
66
- return "spec-unclear";
67
- }
68
47
  if (input.nodeId.includes("trace") || blob.includes("trace:")) {
69
48
  return "trace";
70
49
  }
71
50
  if (input.nodeId.includes("review")) {
72
51
  return "review";
73
52
  }
53
+ // Classify the failed verification command before inspecting its output.
54
+ // Full test output includes unrelated passing-test titles such as
55
+ // "secret-shaped" and "write-guard failure".
56
+ if (blob.includes("vitest") ||
57
+ blob.includes("jest") ||
58
+ blob.includes("npm test") ||
59
+ input.nodeId.includes("behavior")) {
60
+ return "unit-test";
61
+ }
74
62
  if (blob.includes("typecheck") ||
75
63
  blob.includes("tsc") ||
76
64
  /error ts\d+/i.test(blob)) {
@@ -89,11 +77,23 @@ export function classifyFrontendFailure(input) {
89
77
  blob.includes("render(")) {
90
78
  return "component-test";
91
79
  }
92
- if (blob.includes("vitest") ||
93
- blob.includes("jest") ||
94
- blob.includes("npm test") ||
95
- input.nodeId.includes("behavior")) {
96
- return "unit-test";
80
+ if (blob.includes("package.json") ||
81
+ blob.includes("unapproved dependency") ||
82
+ blob.includes("npm install")) {
83
+ return "dependency";
84
+ }
85
+ if (blob.includes("credential") ||
86
+ blob.includes("api key") ||
87
+ blob.includes("secret")) {
88
+ return "credential";
89
+ }
90
+ if (blob.includes("deploy") || blob.includes("production release")) {
91
+ return "deploy";
92
+ }
93
+ if (blob.includes("spec unclear") ||
94
+ blob.includes("requirement ambiguous") ||
95
+ blob.includes("needs human")) {
96
+ return "spec-unclear";
97
97
  }
98
98
  if (input.failureCategory === "invalid-output")
99
99
  return "contract";