@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 +23 -0
- package/dist/executors/dag-pi-executor.js +156 -9
- package/dist/executors/shell-write-guard.js +145 -12
- package/dist/workflows/dag/frontend-repair.js +29 -29
- package/dist/workflows/dag/frontend-verification-trace.js +12 -2
- package/dist/workflows/dag/governance-profile.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +19 -5
- package/dist/workflows/dag/types.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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" ||
|
|
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" ||
|
|
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 ??
|
|
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" &&
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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("
|
|
93
|
-
blob.includes("
|
|
94
|
-
blob.includes("npm
|
|
95
|
-
|
|
96
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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()).
|
|
166
|
+
behaviorCommands: z.array(z.string()).default([]),
|
|
167
167
|
mockEvidence: dagShellVerifyEvidenceSchema.optional(),
|
|
168
168
|
lintEvidence: dagShellVerifyEvidenceSchema.optional(),
|
|
169
169
|
staticEvidence: dagShellVerifyEvidenceSchema,
|