@tea-agent/loop-agent 0.26.2 → 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.26.3] - 2026-08-02
6
+
7
+ ### 重点更新
8
+
9
+ - DAG 无害工具副产物自动恢复
10
+ - 提升前端验证命令的精确度与失败分类
11
+ - 隔离测试固件与运行环境标记
12
+
13
+ ### 新增
14
+
15
+ - DAG 无害工具副产物自动恢复:自动清理 Git Bash 误用 `> nul` / `2> nul` 在仓库根目录生成的普通非 symlink untracked `nul` 文件,避免其干扰 Git 状态复查与变更清单生成
16
+
17
+ ### 改进
18
+
19
+ - 精确限定前端验证命令的作用域,提升验证执行的准确性
20
+ - 更精准地分类前端验证失败原因,避免模糊报错
21
+ - 将运行器标记与测试固件进行安全隔离,防止测试环境干扰
22
+
23
+ ### 修复
24
+
25
+ - 修复配置验证目标错误处理符号引用的问题
26
+ - 修复 DAG 执行中无害的根目录 nul 副产物导致流程异常的问题
27
+
5
28
  ## [0.26.2] - 2026-08-02
6
29
 
7
30
  ### 重点更新
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
4
4
  import { executePiStep, } from "./pi-executor.js";
5
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
6
- import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
6
+ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
7
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
8
8
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
9
9
  export const DAG_PI_WRITE_TOOLS = [
@@ -259,6 +259,7 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
259
259
  }
260
260
  const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
261
261
  readGitStatusPorcelain,
262
+ recoverRootNulArtifact,
262
263
  };
263
264
  export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
264
265
  const started = Date.now();
@@ -360,10 +361,105 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
360
361
  let changeManifestAfterStatus;
361
362
  let changeManifestChangedFiles;
362
363
  if (beforeStatus !== undefined) {
364
+ let recoveryEvidence;
365
+ let removalPendingRecheck;
363
366
  try {
364
- const afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
365
- const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
366
- const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
367
+ let afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
368
+ let afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
369
+ let afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
370
+ // Benign tool artifact recovery: a new untracked bounded regular file
371
+ // literally named `nul` at the repository root is removed with
372
+ // the Node file API, then git status is recaptured so the guard,
373
+ // outcome validation and change manifest only ever see the
374
+ // sanitized diff. Every non-matching candidate stays fail-closed.
375
+ if (afterSnapshot.has("nul")) {
376
+ const recoverRootNulArtifactFn = writeGuardDependencies.recoverRootNulArtifact ??
377
+ recoverRootNulArtifact;
378
+ const recovery = await recoverRootNulArtifactFn({
379
+ rootCwd: input.cwd,
380
+ beforeSnapshot: snapshotGitStatusPorcelain(beforeStatus),
381
+ afterSnapshot,
382
+ afterPathFingerprints,
383
+ candidateAuthorized: validateShellWriteGuardFromDiff({
384
+ changedFiles: ["nul"],
385
+ task: input.task,
386
+ concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
387
+ }).ok,
388
+ });
389
+ const beforeStatusSha256 = createHash("sha256")
390
+ .update(beforeStatus)
391
+ .digest("hex");
392
+ const afterStatusSha256 = createHash("sha256")
393
+ .update(afterStatus)
394
+ .digest("hex");
395
+ if (recovery.action === "removed") {
396
+ const evidenceBase = {
397
+ candidatePath: "nul",
398
+ reason: recovery.reason,
399
+ action: "recovered",
400
+ result: "removed",
401
+ observedSizeBytes: recovery.observedSizeBytes,
402
+ removedAt: recovery.removedAt,
403
+ beforeStatusSha256,
404
+ afterStatusSha256,
405
+ };
406
+ removalPendingRecheck = evidenceBase;
407
+ const recheckedStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, {
408
+ phase: "pi-writer-after-recheck",
409
+ });
410
+ const recheckedSnapshot = snapshotGitStatusPorcelain(recheckedStatus);
411
+ afterStatus = recheckedStatus;
412
+ afterSnapshot = recheckedSnapshot;
413
+ afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, recheckedSnapshot);
414
+ recoveryEvidence = {
415
+ ...evidenceBase,
416
+ recheck: {
417
+ statusSha256: createHash("sha256")
418
+ .update(recheckedStatus)
419
+ .digest("hex"),
420
+ nulStillPresent: recheckedSnapshot.has("nul"),
421
+ },
422
+ };
423
+ removalPendingRecheck = undefined;
424
+ }
425
+ else if (recovery.action === "skipped") {
426
+ recoveryEvidence = {
427
+ candidatePath: "nul",
428
+ reason: recovery.reason,
429
+ action: "skipped",
430
+ result: "kept",
431
+ ...(recovery.observedSizeBytes === undefined
432
+ ? {}
433
+ : { observedSizeBytes: recovery.observedSizeBytes }),
434
+ beforeStatusSha256,
435
+ afterStatusSha256,
436
+ recheck: {
437
+ statusSha256: afterStatusSha256,
438
+ nulStillPresent: true,
439
+ },
440
+ };
441
+ }
442
+ else {
443
+ recoveryEvidence = {
444
+ candidatePath: "nul",
445
+ reason: recovery.reason,
446
+ action: "failed",
447
+ result: "removal-failed",
448
+ observedSizeBytes: recovery.observedSizeBytes,
449
+ errorDetail: recovery.errorDetail,
450
+ beforeStatusSha256,
451
+ afterStatusSha256,
452
+ recheck: {
453
+ statusSha256: afterStatusSha256,
454
+ nulStillPresent: true,
455
+ },
456
+ };
457
+ writeGuardOk = false;
458
+ writeGuardViolations = [
459
+ `nul artifact removal failed: ${recovery.errorDetail}`,
460
+ ];
461
+ }
462
+ }
367
463
  const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
368
464
  changeManifestAfterStatus = afterStatus;
369
465
  changeManifestChangedFiles = changedFiles;
@@ -372,16 +468,35 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
372
468
  task: input.task,
373
469
  concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
374
470
  });
375
- writeGuardOk = guard.ok;
376
- writeGuardViolations = guard.violations;
471
+ writeGuardOk = writeGuardOk && guard.ok;
472
+ writeGuardViolations = [...writeGuardViolations, ...guard.violations];
377
473
  }
378
474
  catch (error) {
379
475
  await persistGitWriteGuardDiagnostics(meta.runDir, input.task.id, error);
476
+ if (removalPendingRecheck) {
477
+ // The artifact was already deleted but the recheck read failed:
478
+ // record the recovery facts with the recheck failure so the
479
+ // evidence trail stays complete while the node remains failed.
480
+ recoveryEvidence = {
481
+ ...removalPendingRecheck,
482
+ recheck: {
483
+ failed: true,
484
+ errorDetail: boundedRecoveryErrorDetail(error),
485
+ },
486
+ };
487
+ }
380
488
  writeGuardOk = false;
381
489
  writeGuardViolations = [
382
490
  `git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
383
491
  ];
384
492
  }
493
+ if (recoveryEvidence) {
494
+ await persistBenignToolArtifactRecovery({
495
+ runDir: meta.runDir,
496
+ nodeId: input.task.id,
497
+ evidence: recoveryEvidence,
498
+ });
499
+ }
385
500
  }
386
501
  let writerOutcomeViolation;
387
502
  if (mapped.ok && input.task.writerOutcomePolicy) {
@@ -624,9 +739,7 @@ async function persistWriterGitBaseline(input) {
624
739
  nodeId: input.nodeId,
625
740
  phase: "pi-writer-before",
626
741
  capturedAt: new Date().toISOString(),
627
- statusSha256: createHash("sha256")
628
- .update(input.beforeStatus)
629
- .digest("hex"),
742
+ statusSha256: createHash("sha256").update(input.beforeStatus).digest("hex"),
630
743
  dirtyPaths: [...input.beforeSnapshot.keys()].sort(),
631
744
  pathFingerprintsSha256: createHash("sha256")
632
745
  .update(JSON.stringify(fingerprintEntries))
@@ -639,6 +752,40 @@ async function persistGitWriteGuardDiagnostics(runDir, nodeId, error) {
639
752
  return;
640
753
  await writeDagNodeJsonArtifact(runDir, nodeId, "git-write-guard-diagnostics.json", error.diagnostics);
641
754
  }
755
+ function boundedRecoveryErrorDetail(error) {
756
+ const detail = error instanceof Error ? error.message : String(error);
757
+ return detail.length <= 1000
758
+ ? detail
759
+ : `${detail.slice(0, 1000)}...[truncated]`;
760
+ }
761
+ /**
762
+ * Persist bounded run-owned evidence for a recognized repository-root `nul`
763
+ * candidate: candidate path, reason, action, result and recheck facts plus
764
+ * status hashes. Never records environment variables or secrets.
765
+ */
766
+ async function persistBenignToolArtifactRecovery(input) {
767
+ const artifact = {
768
+ schemaVersion: 1,
769
+ nodeId: input.nodeId,
770
+ candidatePath: input.evidence.candidatePath,
771
+ reason: input.evidence.reason,
772
+ action: input.evidence.action,
773
+ result: input.evidence.result,
774
+ ...(input.evidence.observedSizeBytes === undefined
775
+ ? {}
776
+ : { observedSizeBytes: input.evidence.observedSizeBytes }),
777
+ ...(input.evidence.errorDetail
778
+ ? { errorDetail: input.evidence.errorDetail }
779
+ : {}),
780
+ ...(input.evidence.removedAt
781
+ ? { removedAt: input.evidence.removedAt }
782
+ : {}),
783
+ beforeStatusSha256: input.evidence.beforeStatusSha256,
784
+ afterStatusSha256: input.evidence.afterStatusSha256,
785
+ recheck: input.evidence.recheck,
786
+ };
787
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "benign-tool-artifact-recovery.json", artifact);
788
+ }
642
789
  /**
643
790
  * Validate the writer's observed diff against its declared write boundary.
644
791
  * Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff
@@ -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);
@@ -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";
@@ -37,6 +37,9 @@ function symbolEvidenceCandidates(symbol) {
37
37
  return [symbol, describeTitle, "describe("];
38
38
  return [symbol];
39
39
  }
40
+ function isConfigurationVerificationFile(file) {
41
+ return /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|(?:vite|webpack|rollup|docusaurus)\.config\.[cm]?[jt]s)$/.test(file.replace(/\\/g, "/"));
42
+ }
40
43
  async function assertFileAndSymbol(input) {
41
44
  const issues = [];
42
45
  const absolute = path.resolve(input.workspaceRoot, input.file);
@@ -94,6 +97,7 @@ export async function runFrontendVerificationTraceGate(input) {
94
97
  .join("; ")}`);
95
98
  }
96
99
  const contract = parsed.data;
100
+ const requiresBehaviorVerification = contract.verificationTargets.some((target) => target.type !== "static");
97
101
  // Prefer post-repair reverify nodes when present (M3); else initial verify nodes (M2).
98
102
  const staticCandidates = [
99
103
  "frontend-static-reverify-shell",
@@ -146,7 +150,8 @@ export async function runFrontendVerificationTraceGate(input) {
146
150
  if (input.evidence.static.commandLabels.length === 0) {
147
151
  throw new Error("trace: no successful static verification commands in current run");
148
152
  }
149
- if (input.evidence.behavior.commandLabels.length === 0) {
153
+ if (requiresBehaviorVerification &&
154
+ input.evidence.behavior.commandLabels.length === 0) {
150
155
  throw new Error("trace: no successful behavior verification commands in current run");
151
156
  }
152
157
  }
@@ -181,7 +186,12 @@ export async function runFrontendVerificationTraceGate(input) {
181
186
  const fileIssues = await assertFileAndSymbol({
182
187
  workspaceRoot: input.workspaceRoot,
183
188
  file: target.file,
184
- symbol: target.symbol,
189
+ // Configuration files prove that the command's entrypoint exists;
190
+ // they do not expose source symbols. LLMs sometimes derive a
191
+ // filename fragment such as "build" from tsconfig.build.json.
192
+ symbol: isConfigurationVerificationFile(target.file)
193
+ ? undefined
194
+ : target.symbol,
185
195
  });
186
196
  issues.push(...fileIssues);
187
197
  const status = issues.length ? "failed" : "ok";
@@ -106,7 +106,7 @@ function writeSetMatchesAnyPattern(writeSet, patterns) {
106
106
  function shellCommandLooksDeterministic(command) {
107
107
  const normalized = command.replace(/["']/g, "");
108
108
  return (/\bnpx vitest run\b/.test(normalized) ||
109
- /\bnpm (?:run )?(?:lint|typecheck|test)\b/.test(normalized) ||
109
+ /\bnpm(?:\s+--prefix\s+\S+)*\s+(?:run\s+)?(?:lint|typecheck|test|build)\b/.test(normalized) ||
110
110
  /check-repo\.sh/.test(normalized) ||
111
111
  /\bshell\.preset\b/.test(normalized) ||
112
112
  /loop-agent-standard-verify/.test(normalized));
@@ -890,7 +890,9 @@ function chooseFrontendVerifyCommands(input) {
890
890
  if (input.parsedCommands.length > 0) {
891
891
  return { commands: input.parsedCommands, commandSource: "inline" };
892
892
  }
893
- if (input.adapterCommands && input.adapterCommands.length > 0) {
893
+ if (input.allowAdapter !== false &&
894
+ input.adapterCommands &&
895
+ input.adapterCommands.length > 0) {
894
896
  return { commands: input.adapterCommands, commandSource: "adapter" };
895
897
  }
896
898
  return { commandSource: "inline" };
@@ -2158,7 +2160,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2158
2160
  "Frontend planning must consume the read-only Mock assessment strategy produced after scouting; MOCK_STRATEGY: blocked must not pass the deterministic Mock contract gate.",
2159
2161
  "Mock implementations must preserve the real request path as the default, require explicit test/dev activation, and never rely on commenting out the real request.",
2160
2162
  "Mock-backed behavior evidence proves only the documented frontend contract, never real API integration.",
2161
- "frontend-implementation DAGs must complete deterministic static verification and behavior verification before final review.",
2163
+ "frontend-implementation DAGs must complete deterministic static verification before final review. Behavior verification is also required when the task declares a behavior entrypoint or the implementation contract contains a non-static verification target; static-only contracts must map every target to the declared static entrypoint.",
2162
2164
  "frontend review must block closeout unless review verdict is exactly VERDICT: pass.",
2163
2165
  `Frontend risk classification: ${frontendRisk.selectedRisk} — ${frontendRisk.reason}`,
2164
2166
  frontendRisk.forceFullGates
@@ -2183,6 +2185,10 @@ async function buildFrontendHybridDagFromTask(sources) {
2183
2185
  ...explicitFrontendVerifyCommands.behaviorCommands,
2184
2186
  ].map(verifyCommandKey));
2185
2187
  const adapterVerifyCommands = (sources.verifyCommands?.final ?? []).filter((command) => !explicitCommandKeys.has(verifyCommandKey(command)));
2188
+ const hasDeclaredFrontendVerification = explicitFrontendVerifyCommands.staticCommands.length > 0 ||
2189
+ explicitFrontendVerifyCommands.behaviorCommands.length > 0 ||
2190
+ parsedFrontendVerifyCommands.staticCommands.length > 0 ||
2191
+ parsedFrontendVerifyCommands.behaviorCommands.length > 0;
2186
2192
  const staticVerifyCommands = chooseFrontendVerifyCommands({
2187
2193
  explicitCommands: explicitFrontendVerifyCommands.staticCommands,
2188
2194
  parsedCommands: parsedFrontendVerifyCommands.staticCommands,
@@ -2197,6 +2203,9 @@ async function buildFrontendHybridDagFromTask(sources) {
2197
2203
  explicitCommands: explicitFrontendVerifyCommands.behaviorCommands,
2198
2204
  parsedCommands: parsedFrontendVerifyCommands.behaviorCommands,
2199
2205
  adapterCommands: adapterVerifyCommands,
2206
+ // A declared task verifier owns this task's verification boundary. A
2207
+ // static-only task must not inherit unrelated root-level test commands.
2208
+ allowAdapter: !hasDeclaredFrontendVerification,
2200
2209
  });
2201
2210
  const staticShellCommands = buildVerifyShellCommands({
2202
2211
  repoRoot: sources.repoRoot,
@@ -2211,8 +2220,13 @@ async function buildFrontendHybridDagFromTask(sources) {
2211
2220
  const behaviorShellCommands = buildVerifyShellCommands({
2212
2221
  repoRoot: sources.repoRoot,
2213
2222
  commands: behaviorVerifyCommands.commands,
2214
- fallbackCommands: behaviorFallbackCommands,
2223
+ fallbackCommands: hasDeclaredFrontendVerification
2224
+ ? []
2225
+ : behaviorFallbackCommands,
2215
2226
  });
2227
+ const effectiveBehaviorFallbackCommands = hasDeclaredFrontendVerification
2228
+ ? []
2229
+ : behaviorFallbackCommands;
2216
2230
  const staticVerifyEvidence = buildVerifyEvidence({
2217
2231
  phase: "intermediate",
2218
2232
  quota: strategy.intermediateQuota ?? "full",
@@ -2238,7 +2252,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2238
2252
  quota: "full",
2239
2253
  commandSource: behaviorVerifyCommands.commandSource,
2240
2254
  commands: behaviorVerifyCommands.commands,
2241
- fallbackCommands: behaviorFallbackCommands,
2255
+ fallbackCommands: effectiveBehaviorFallbackCommands,
2242
2256
  commandTexts: behaviorShellCommands,
2243
2257
  finalFullRequired: true,
2244
2258
  commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
@@ -2352,7 +2366,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2352
2366
  subtask_prompt: [
2353
2367
  "Audit the frontend plan before implementation.",
2354
2368
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2355
- "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 the selected strategy. Mock strategies require Mock-backed evidence. not-needed 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.",
2369
+ "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.",
2356
2370
  "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.",
2357
2371
  "Read-only: do not modify repository files.",
2358
2372
  fixedVerificationContext,
@@ -163,7 +163,7 @@ export const dagFrontendVerificationBundleSchema = z
163
163
  mockCommands: z.array(z.string()).default([]),
164
164
  lintCommands: z.array(z.string().min(1)).optional(),
165
165
  staticCommands: z.array(z.string()).min(1),
166
- behaviorCommands: z.array(z.string()).min(1),
166
+ behaviorCommands: z.array(z.string()).default([]),
167
167
  mockEvidence: dagShellVerifyEvidenceSchema.optional(),
168
168
  lintEvidence: dagShellVerifyEvidenceSchema.optional(),
169
169
  staticEvidence: dagShellVerifyEvidenceSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.26.2",
3
+ "version": "0.26.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",