@tea-agent/loop-agent 0.16.24 → 0.16.26
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 +20 -0
- package/dist/cli/command-definitions.js +13 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/coverage-report.js +50 -0
- package/dist/executors/dag-pi-executor.js +63 -9
- package/dist/executors/shell-executor.js +30 -0
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +127 -19
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
- package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
- package/dist/workflows/dag/init-hybrid.js +150 -13
- package/dist/workflows/dag/l5-report-metrics.js +36 -0
- package/dist/workflows/dag/node-execution.js +32 -5
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/types.js +10 -0
- package/dist/workflows/dag/validate.js +28 -0
- package/docs/architecture/evolution.md +3 -1
- package/docs/templates/agent-dag.schema.json +15 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
- package/docs/templates/backend-test-dag.json +39 -6
- package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
### 改进
|
|
4
4
|
|
|
5
|
+
- Observe 节点检查器“规范证据”页签新增安全文件预览:已绑定任务源和已读取规范文件条目现以可聚焦按钮呈现,点击后在检查器内下钻查看内容(不新增弹窗),提供“返回规范证据”按钮并恢复触发行焦点。新增只读接口 `GET /api/dag-runs/:runId/nodes/:nodeId/spec-evidence/file?source=binding|read&path=...`,服务端重新校验证据成员身份(binding 必须在 `sourceBinding.sources`,read 必须有成功配对 read),拒 `..`/绝对/目录/二进制/仓库外符号链接;正文上限 64KB 并返回截断标志,统一走 Observe 脱敏规则;binding 返回 `sha256`/`currentSha256`/`hashMatch`,read 返回 session event 中实际读取正文与 `readAt`(不以当前工作区文件替代)。
|
|
6
|
+
|
|
7
|
+
- 通用 standard/reviewed/supervised 实现 DAG 现在会在目标仓库存在 `AGENTS.md` 时,按本次 writer 的实际变更解析适用的根级/嵌套指令和仓库内代码规范索引:standard 复用 `verify-pi` 与条件 shell gate,reviewed/supervised 复用现有 `review-pi`/review gate,不新增模型节点;无 `AGENTS.md` 或无适用规范时保持原行为,审查节点只读且不会直接改代码。专用测试、前端和知识类 DAG 不受影响。
|
|
5
8
|
- GitHub Actions CI 降低私有仓分钟消耗:合并为单 job(避免两次 `npm ci`)、同分支/`PR` concurrency 取消旧 run、Draft PR 跳过完整 CI,且仅忽略 `docs/reports/**`、`docs/progress/**`、`docs/design/archive/**`、`docs/exec-plans/completed/**` 等纯运营文档路径;治理文档与代码门禁仍全量跑。
|
|
6
9
|
- 明确 **Compatibility / Operator Assist**:openCode 等主会话只编排已发布 `loop-agent` / `agent-worker` CLI 与只读诊断,不得绕过 CLI 直接改业务实现,失败只走 doctor / reconcile / human gate / CLI 重跑。`loop-agent init` 写入的 `AGENTS.md` managed block、包内 skills 与 website 快速开始/治理说明已对齐;架构决策见 `docs/decisions/0005-governed-operator-surface.md`(accepted)。已有目标项目用 `loop-agent init check-update` / `init update --apply-safe` 或 `init reconcile` 刷新 managed block。
|
|
7
10
|
- 前端测试 DAG 收紧体验:execution preflight 仅硬校验绝对非生产 `baseUrl`;用例 map 缩短为优先用 `playwright-cli` 执行;复盘合并执行证据审查且不依赖 outcome=pass,失败也能出报告。
|
|
@@ -12,6 +15,10 @@
|
|
|
12
15
|
- Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
|
|
13
16
|
- 新增升级后 init surface notifier:普通安全仓库命令结束后,当目标项目存在有效 `.harness/init-surface.json` 且 `controllerVersion` 与当前包版本不同时自动检测。surface 缺失/损坏、源仓库、未初始化目录与版本一致时直接跳过;`checkInitUpdate().ok` 时仅在无活跃运行时静默刷新 state;非 TTY 只向 stderr 输出 `loop-agent init reconcile --repo-root ...` 提示且不写入目标;TTY 且无 human decisions、无活跃 DAG/Worker 时可经明确 `y/yes` 同意后应用 deterministic safe actions;只有 model merge 时直接输出有边界指引,不询问“应用 0 个动作”。notifier 遵循全局 `--repo-root`、不写 stdout、不改变原命令退出码,CI、禁用环境变量、help/version、`--json`/`--markdown`、`init`、`run-dag`、`dag`、`loop`、`delegate`、`pi-prompt`、`cursor-prompt` 均跳过。
|
|
14
17
|
- 新增统一命令 `loop-agent init reconcile --repo-root <target>`:surface 缺失返回 `needs-baseline` 且零写入,存在 human decisions 返回 `needs-human-decision` 且零写入,活跃 DAG/Worker 或 Worker 状态无法确认时返回 `blocked-active-runtime` 且零写入;其余情况复用现有 `applyInitUpdate({ applySafe: true })` 并复查返回 `clean`/`needs-model-merge`/`needs-safe-update`,支持 `--json`/`--markdown`。
|
|
18
|
+
- 测试报告新增 L-5 指标消费与 Python coverage.py JSON、Java JaCoCo XML 的统一 Code Coverage v1 报告入口,覆盖通过率、AC/自动化覆盖率、稳定性、失败原因、缺陷、风险和回归建议;缺失覆盖率证据保持 `unavailable`,不改变 backend-test outcome gate。
|
|
19
|
+
- 现有 backend-test DAG 在 retrospective 之后新增独立的 `l5-metrics-pi` 指标节点;它基于前序测试报告和 run-owned 证据输出 `L-5 ready/not-ready`,不创建独立 L-5 DAG,也不改变 Result v1 outcome gate。
|
|
20
|
+
- 后端测试 DAG 从 38 个收敛为 24 个真实顶层节点;使用 fail-closed `runIf` 和复合 Shell capability 减少调度,同时保留双合同、Manifest、语义评审、JUnit、Result、分类、修复安全、追踪和最终 outcome 证据。三条可选修订/修复分支仍各最多执行一次。
|
|
21
|
+
- Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成和修订节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
|
|
15
22
|
|
|
16
23
|
### 修复
|
|
17
24
|
|
|
@@ -35,6 +42,19 @@
|
|
|
35
42
|
- Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
|
|
36
43
|
- 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为唯一 JUnit/initial Result、canonical Result、traceability 与 Observe 投影保留结构化运行证据。
|
|
37
44
|
|
|
45
|
+
## [0.16.26] - 2026-07-22
|
|
46
|
+
|
|
47
|
+
### 修复
|
|
48
|
+
|
|
49
|
+
- Git checkpoint 对 `allowed_paths` 内的 **gitignored 证据文件**(如 dogfood `reports/welcome/final/*.json`)执行 `git add -f` 纳入 checkpoint;敏感路径与范围外 ignored 文件仍 fail-closed。修复 FINAL-VERIFY DAG/report 已绿却因 ignored evidence 记 `record-error`、Task Pool 卡 Running 的 Delivery 阻断。
|
|
50
|
+
|
|
51
|
+
## [0.16.25] - 2026-07-22
|
|
52
|
+
|
|
53
|
+
### 修复
|
|
54
|
+
|
|
55
|
+
- Backend-test pytest preflight 与执行体改为 `&&` fail-closed:preflight 失败不再因 `;` 继续跑 pytest(I27 曾出现 preflight 报 `WELCOME_BASE_URL`/testRoot 错配仍执行用例)。
|
|
56
|
+
- 本地 `npm start` / `node server.js` 类 `managed-command` 在 materialize 时降级为 `in-process`,清空 requiredEnv、seed server fixture,并把 scout `testRoot` 收敛到冻结根 `testcase/`;generate-pytest 提示要求 fixture 内启动服务,不再依赖 clean-env 注入 base URL。
|
|
57
|
+
|
|
38
58
|
## [0.16.24] - 2026-07-21
|
|
39
59
|
|
|
40
60
|
### 改进
|
|
@@ -3,6 +3,7 @@ import { runDocsArchive } from "../commands/docs-archive.js";
|
|
|
3
3
|
import { runDocsAudit } from "../commands/docs-audit.js";
|
|
4
4
|
import { runEval } from "../commands/eval.js";
|
|
5
5
|
import { runCoverageAudit } from "../commands/coverage-audit.js";
|
|
6
|
+
import { runCoverageReport } from "../commands/coverage-report.js";
|
|
6
7
|
import { runExamples } from "../commands/examples.js";
|
|
7
8
|
import { runCloseout } from "../commands/closeout.js";
|
|
8
9
|
import { runHandoffCheck } from "../commands/handoff-check.js";
|
|
@@ -101,6 +102,7 @@ const PLAN_SUBCOMMANDS = ["list", "create", "complete", "check"];
|
|
|
101
102
|
const SPINE_SUBCOMMANDS = ["audit"];
|
|
102
103
|
const HANDOFF_SUBCOMMANDS = ["check", "coverage"];
|
|
103
104
|
const HANDOFF_USAGE = "handoff <check|coverage> [taskId]";
|
|
105
|
+
const COVERAGE_SUBCOMMANDS = ["report"];
|
|
104
106
|
const REFERENCE_SUBCOMMANDS = ["index"];
|
|
105
107
|
const STUDY_SUBCOMMANDS = ["init"];
|
|
106
108
|
const WORKTREE_SUBCOMMANDS = ["create", "list", "remove"];
|
|
@@ -378,6 +380,17 @@ export const COMMAND_DEFINITIONS = [
|
|
|
378
380
|
throw new Error(`usage: ${HANDOFF_USAGE}`);
|
|
379
381
|
},
|
|
380
382
|
},
|
|
383
|
+
{
|
|
384
|
+
name: "coverage",
|
|
385
|
+
adapter: "required",
|
|
386
|
+
tier: "operator",
|
|
387
|
+
intent: "Normalize Python coverage.py JSON or Java JaCoCo XML for test reports.",
|
|
388
|
+
usage: "coverage report --language python|java --input <path> [options]",
|
|
389
|
+
subcommands: [...COVERAGE_SUBCOMMANDS],
|
|
390
|
+
handler: async ({ repoRoot, subcommand, rest }) => {
|
|
391
|
+
await runCoverageReport(repoRoot, [subcommand, ...rest].filter(Boolean));
|
|
392
|
+
},
|
|
393
|
+
},
|
|
381
394
|
{
|
|
382
395
|
name: "goal",
|
|
383
396
|
adapter: "required",
|
package/dist/cli/program.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readPackageVersion } from "../shared/package-metadata.js";
|
|
|
5
5
|
import { resolveAdapter } from "../adapters/index.js";
|
|
6
6
|
import { runCloseout } from "../commands/closeout.js";
|
|
7
7
|
import { runCoverageAudit } from "../commands/coverage-audit.js";
|
|
8
|
+
import { runCoverageReport } from "../commands/coverage-report.js";
|
|
8
9
|
import { parseCursorPromptArgs, printCursorPromptUsage, runCursorPrompt, } from "../commands/cursor-prompt.js";
|
|
9
10
|
import { runDagApprove } from "../commands/dag-approve.js";
|
|
10
11
|
import { runDagFinalVerification } from "../commands/dag-final-verification.js";
|
|
@@ -205,6 +206,9 @@ async function runCommanderAction(ctx, command, subcommand, rest) {
|
|
|
205
206
|
return;
|
|
206
207
|
}
|
|
207
208
|
throw new Error("usage: handoff <check|coverage> [taskId]");
|
|
209
|
+
case "coverage":
|
|
210
|
+
await runCoverageReport(ctx.repoRoot, compactArgs([subcommand, ...rest]));
|
|
211
|
+
return;
|
|
208
212
|
case "goal":
|
|
209
213
|
await runGoal(ctx.repoRoot, subcommand, rest);
|
|
210
214
|
return;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { formatCoverageMarkdown, readCoverageArtifact } from "../workflows/dag/backend-test-coverage-contract.js";
|
|
4
|
+
function value(args, flag) {
|
|
5
|
+
const index = args.findIndex((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
6
|
+
if (index < 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
const token = args[index];
|
|
9
|
+
return token.startsWith(`${flag}=`) ? token.slice(flag.length + 1) : args[index + 1];
|
|
10
|
+
}
|
|
11
|
+
function values(args, flag) {
|
|
12
|
+
return args.flatMap((arg, index) => {
|
|
13
|
+
if (arg === flag)
|
|
14
|
+
return args[index + 1] ? [args[index + 1]] : [];
|
|
15
|
+
return arg.startsWith(`${flag}=`) ? [arg.slice(flag.length + 1)] : [];
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function runCoverageReport(repoRoot, args) {
|
|
19
|
+
if (args[0] !== "report" || args.includes("--help")) {
|
|
20
|
+
console.log("usage: coverage report --language python|java --input <coverage.json|jacoco.xml> [--output <path>] [--json|--markdown] --requirement-id <AC-id> --source-scope <path[,path]> [--commit <sha>] [--expected-sha256 <sha256>]");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const input = value(args, "--input");
|
|
24
|
+
const language = value(args, "--language");
|
|
25
|
+
if (!input || (language !== "python" && language !== "java"))
|
|
26
|
+
throw new Error("coverage report requires --language python|java and --input");
|
|
27
|
+
const expectedSha = value(args, "--expected-sha256");
|
|
28
|
+
const sourcePaths = (value(args, "--source-scope") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
29
|
+
const contract = await readCoverageArtifact(path.resolve(repoRoot, input), {
|
|
30
|
+
sourceScope: { requirementIds: values(args, "--requirement-id"), paths: sourcePaths },
|
|
31
|
+
commitSha: value(args, "--commit") ?? null,
|
|
32
|
+
toolVersion: value(args, "--tool-version") ?? null,
|
|
33
|
+
artifactPath: input,
|
|
34
|
+
});
|
|
35
|
+
if ((language === "python" && contract.language !== "python") || (language === "java" && contract.language !== "java"))
|
|
36
|
+
throw new Error("coverage artifact format does not match --language");
|
|
37
|
+
if (expectedSha && expectedSha !== contract.artifact.sha256)
|
|
38
|
+
throw new Error(`artifact SHA-256 mismatch: expected ${expectedSha}, got ${contract.artifact.sha256}`);
|
|
39
|
+
const markdown = args.includes("--markdown");
|
|
40
|
+
const output = value(args, "--output");
|
|
41
|
+
const rendered = markdown ? formatCoverageMarkdown(contract) : `${JSON.stringify(contract, null, 2)}\n`;
|
|
42
|
+
if (output) {
|
|
43
|
+
const outputPath = path.resolve(repoRoot, output);
|
|
44
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
45
|
+
await writeFile(outputPath, rendered, "utf8");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
process.stdout.write(rendered);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
3
4
|
import { executePiStep, } from "./pi-executor.js";
|
|
4
5
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
5
|
-
import { readGitStatusPorcelain,
|
|
6
|
+
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
6
7
|
export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
7
8
|
export const DAG_PI_WRITE_TOOLS = [
|
|
8
9
|
"read",
|
|
@@ -240,12 +241,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
240
241
|
};
|
|
241
242
|
}
|
|
242
243
|
let beforeStatus;
|
|
244
|
+
let beforePathFingerprints;
|
|
243
245
|
if (isWriteTask) {
|
|
244
246
|
try {
|
|
245
247
|
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
248
|
+
beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
|
|
246
249
|
}
|
|
247
250
|
catch {
|
|
248
251
|
beforeStatus = undefined;
|
|
252
|
+
beforePathFingerprints = undefined;
|
|
249
253
|
}
|
|
250
254
|
}
|
|
251
255
|
const result = await piStepFn({
|
|
@@ -278,15 +282,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
278
282
|
}
|
|
279
283
|
let writeGuardOk = true;
|
|
280
284
|
let writeGuardViolations = [];
|
|
285
|
+
let changeManifestAfterStatus;
|
|
286
|
+
let changeManifestChangedFiles;
|
|
281
287
|
if (beforeStatus !== undefined) {
|
|
282
288
|
try {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
const afterStatus = await readGitStatusPorcelain(input.cwd);
|
|
290
|
+
const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
|
|
291
|
+
const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
|
|
292
|
+
const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
|
|
293
|
+
changeManifestAfterStatus = afterStatus;
|
|
294
|
+
changeManifestChangedFiles = changedFiles;
|
|
295
|
+
const guard = validateShellWriteGuardFromDiff({
|
|
296
|
+
changedFiles,
|
|
297
|
+
task: input.task,
|
|
290
298
|
concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
|
|
291
299
|
});
|
|
292
300
|
writeGuardOk = guard.ok;
|
|
@@ -300,6 +308,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
300
308
|
}
|
|
301
309
|
}
|
|
302
310
|
if (writeGuardOk) {
|
|
311
|
+
if (beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
|
|
312
|
+
await persistWriterChangeManifest({
|
|
313
|
+
runDir: meta.runDir,
|
|
314
|
+
nodeId: input.task.id,
|
|
315
|
+
writerNodeId: input.task.id,
|
|
316
|
+
changedFiles: changeManifestChangedFiles,
|
|
317
|
+
beforeStatus,
|
|
318
|
+
afterStatus: changeManifestAfterStatus ?? "",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
303
321
|
return mapped;
|
|
304
322
|
}
|
|
305
323
|
const stderrParts = [mapped.stderr];
|
|
@@ -369,3 +387,39 @@ function normalizeProtocolLine(line) {
|
|
|
369
387
|
const emphasized = trimmed.match(/^(\*{1,3})\s*(.*?)\s*\1$/);
|
|
370
388
|
return (emphasized?.[2] ?? trimmed).trim();
|
|
371
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Validate the writer's observed diff against its declared write boundary.
|
|
392
|
+
* Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff
|
|
393
|
+
* so the change manifest and the guard share one source of truth.
|
|
394
|
+
*/
|
|
395
|
+
function validateShellWriteGuardFromDiff(input) {
|
|
396
|
+
return validateShellWriteGuard({
|
|
397
|
+
changedPaths: input.changedFiles,
|
|
398
|
+
writePolicy: input.task.writePolicy,
|
|
399
|
+
writeSet: input.task.writeSet,
|
|
400
|
+
allowedPaths: input.task.allowedPaths,
|
|
401
|
+
forbiddenPaths: input.task.forbiddenPaths,
|
|
402
|
+
concurrentSiblingWriteSets: input.concurrentSiblingWriteSets,
|
|
403
|
+
allowSiblingWriteSetAttribution: input.task.writePolicy === "exclusive" &&
|
|
404
|
+
(input.concurrentSiblingWriteSets?.length ?? 0) > 0,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Persist the writer's actual changeset as a run-owned artifact so the
|
|
409
|
+
* project governance context resolver can attribute exactly this run's
|
|
410
|
+
* writes (not final `git status`, which may include pre-existing changes).
|
|
411
|
+
*/
|
|
412
|
+
async function persistWriterChangeManifest(input) {
|
|
413
|
+
const manifest = {
|
|
414
|
+
schemaVersion: 1,
|
|
415
|
+
writerNodeId: input.writerNodeId,
|
|
416
|
+
changedFiles: input.changedFiles,
|
|
417
|
+
beforeStatusSha256: createHash("sha256")
|
|
418
|
+
.update(input.beforeStatus)
|
|
419
|
+
.digest("hex"),
|
|
420
|
+
afterStatusSha256: createHash("sha256")
|
|
421
|
+
.update(input.afterStatus)
|
|
422
|
+
.digest("hex"),
|
|
423
|
+
};
|
|
424
|
+
await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "change-manifest.json", manifest);
|
|
425
|
+
}
|
|
@@ -19,6 +19,8 @@ import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBack
|
|
|
19
19
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
20
20
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
21
21
|
import { buildShellProcessEnv } from "./shell-verification.js";
|
|
22
|
+
import { readRunState } from "../workflows/dag/run-store.js";
|
|
23
|
+
import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
|
|
22
24
|
const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
|
|
23
25
|
const SUMMARY_STDOUT_MAX = 4_000;
|
|
24
26
|
const SUMMARY_STDERR_MAX = 2_000;
|
|
@@ -733,6 +735,34 @@ export async function executeDagShellNode(input, meta) {
|
|
|
733
735
|
};
|
|
734
736
|
}
|
|
735
737
|
}
|
|
738
|
+
if (shell?.projectGovernanceGate) {
|
|
739
|
+
const started = Date.now();
|
|
740
|
+
try {
|
|
741
|
+
const state = await readRunState(meta.runDir);
|
|
742
|
+
if (!state.projectGovernanceContextRef) {
|
|
743
|
+
throw new Error("project governance gate requires projectGovernanceContextRef in run state");
|
|
744
|
+
}
|
|
745
|
+
const context = await readProjectGovernanceContext(meta.runDir, state.projectGovernanceContextRef, { expectedRunId: meta.runId });
|
|
746
|
+
if (!context.applicable) {
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
stdout: "Project governance gate: not applicable for this writer changeset.",
|
|
750
|
+
stderr: "",
|
|
751
|
+
failureCategory: "success",
|
|
752
|
+
durationMs: Date.now() - started,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
return {
|
|
758
|
+
ok: false,
|
|
759
|
+
stdout: "",
|
|
760
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
761
|
+
failureCategory: "invalid-output",
|
|
762
|
+
durationMs: Date.now() - started,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
736
766
|
const commands = shell ? resolveShellCommands(shell) : [];
|
|
737
767
|
if (!shell || commands.length === 0) {
|
|
738
768
|
throw new Error(`shell task ${input.task.id} requires shell.preset, shell.verdictGate, and/or non-empty shell.commands`);
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
2
6
|
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
7
|
+
async function sha256File(filePath) {
|
|
8
|
+
const hash = createHash("sha256");
|
|
9
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
10
|
+
hash.update(chunk);
|
|
11
|
+
}
|
|
12
|
+
return hash.digest("hex");
|
|
13
|
+
}
|
|
3
14
|
export function snapshotGitStatusPorcelain(porcelain) {
|
|
4
15
|
const snapshot = new Map();
|
|
5
16
|
for (const line of porcelain.split("\n")) {
|
|
@@ -14,11 +25,15 @@ export function snapshotGitStatusPorcelain(porcelain) {
|
|
|
14
25
|
}
|
|
15
26
|
return snapshot;
|
|
16
27
|
}
|
|
17
|
-
export function pathsChangedDuringRun(before, after) {
|
|
28
|
+
export function pathsChangedDuringRun(before, after, beforeFingerprints, afterFingerprints) {
|
|
18
29
|
const changed = new Set();
|
|
19
30
|
for (const [filePath, afterCode] of after) {
|
|
20
31
|
const beforeCode = before.get(filePath);
|
|
21
|
-
if (beforeCode === undefined ||
|
|
32
|
+
if (beforeCode === undefined ||
|
|
33
|
+
beforeCode !== afterCode ||
|
|
34
|
+
(beforeFingerprints !== undefined &&
|
|
35
|
+
afterFingerprints !== undefined &&
|
|
36
|
+
beforeFingerprints.get(filePath) !== afterFingerprints.get(filePath))) {
|
|
22
37
|
changed.add(filePath);
|
|
23
38
|
}
|
|
24
39
|
}
|
|
@@ -29,6 +44,53 @@ export function pathsChangedDuringRun(before, after) {
|
|
|
29
44
|
}
|
|
30
45
|
return Array.from(changed).sort();
|
|
31
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Capture content-aware fingerprints for every path already present in a
|
|
49
|
+
* porcelain snapshot. Status codes alone cannot detect a writer editing a file
|
|
50
|
+
* that was `M` or `??` before the node and remains so afterwards.
|
|
51
|
+
*
|
|
52
|
+
* Symlinks are hashed by link target without following them, keeping the
|
|
53
|
+
* snapshot repository-contained. Missing paths and non-files receive stable
|
|
54
|
+
* sentinels so deletions and type changes remain observable.
|
|
55
|
+
*/
|
|
56
|
+
export async function snapshotGitStatusPathFingerprints(cwd, status) {
|
|
57
|
+
const root = path.resolve(cwd);
|
|
58
|
+
const entries = await Promise.all([...status.keys()].map(async (filePath) => {
|
|
59
|
+
const candidate = path.resolve(root, filePath);
|
|
60
|
+
const relative = path.relative(root, candidate);
|
|
61
|
+
if (relative.startsWith("..") ||
|
|
62
|
+
path.isAbsolute(relative)) {
|
|
63
|
+
return [filePath, "outside-repository"];
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const info = await lstat(candidate);
|
|
67
|
+
if (info.isSymbolicLink()) {
|
|
68
|
+
const target = await readlink(candidate);
|
|
69
|
+
return [
|
|
70
|
+
filePath,
|
|
71
|
+
`symlink:${createHash("sha256").update(target).digest("hex")}`,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
if (info.isFile()) {
|
|
75
|
+
return [
|
|
76
|
+
filePath,
|
|
77
|
+
`file:${await sha256File(candidate)}`,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
return [
|
|
81
|
+
filePath,
|
|
82
|
+
`other:${info.mode}:${info.size}:${info.mtimeMs}`,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const code = error && typeof error === "object" && "code" in error
|
|
87
|
+
? String(error.code)
|
|
88
|
+
: "unknown";
|
|
89
|
+
return [filePath, `unreadable:${code}`];
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
return new Map(entries);
|
|
93
|
+
}
|
|
32
94
|
const DAG_RUNS_PREFIX = ".harness/dag-runs/";
|
|
33
95
|
/**
|
|
34
96
|
* Ephemeral interpreter/tool caches that writers may create while drafting tests.
|
|
@@ -122,22 +122,51 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
122
122
|
const changes = await readChanges(transaction.repoRoot);
|
|
123
123
|
const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
|
|
124
124
|
if (outcome.status === "succeeded") {
|
|
125
|
-
|
|
125
|
+
const allowedPaths = expandAllowedPathsForWorkflow(resolveWorkflow(outcome.taskSpec).workflow, outcome.taskSpec.constraints.allowed_paths);
|
|
126
|
+
// Evidence under allowed_paths may be gitignored for local convenience (e.g.
|
|
127
|
+
// reports/welcome/** smoke JSON) but must still checkpoint for Task Pool Done.
|
|
128
|
+
// Sensitive / out-of-scope ignored files remain fail-closed; ephemeral tool
|
|
129
|
+
// caches neither block nor force-add.
|
|
130
|
+
const forceAddIgnored = [];
|
|
131
|
+
const blockedIgnored = [];
|
|
132
|
+
for (const entry of newIgnored) {
|
|
133
|
+
if (isEphemeralToolCachePath(entry))
|
|
134
|
+
continue;
|
|
135
|
+
if (isSensitivePath(entry)) {
|
|
136
|
+
blockedIgnored.push(entry);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (allowedPaths.some((glob) => matchesGlob(entry, glob))) {
|
|
140
|
+
forceAddIgnored.push(entry);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
blockedIgnored.push(entry);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (blockedIgnored.length > 0) {
|
|
147
|
+
throw new Error(`task created ignored files outside the Git checkpoint: ${blockedIgnored.join(", ")}`);
|
|
148
|
+
}
|
|
149
|
+
const checkpointFiles = [...changes, ...forceAddIgnored];
|
|
150
|
+
if (checkpointFiles.length === 0) {
|
|
126
151
|
throw new Error(`successful task produced no checkpointable changes: ${outcome.taskSpec.id}`);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
}
|
|
153
|
+
auditChangedPaths(checkpointFiles, outcome.taskSpec);
|
|
154
|
+
if (changes.length > 0) {
|
|
155
|
+
await git(transaction.repoRoot, ["add", "--", ...changes]);
|
|
156
|
+
}
|
|
157
|
+
if (forceAddIgnored.length > 0) {
|
|
158
|
+
await git(transaction.repoRoot, ["add", "-f", "--", ...forceAddIgnored]);
|
|
159
|
+
}
|
|
131
160
|
const message = commitMessage(outcome.taskSpec, outcome.workerRunId);
|
|
132
161
|
await git(transaction.repoRoot, ["commit", "-m", message]);
|
|
133
162
|
const commit = await git(transaction.repoRoot, ["rev-parse", "HEAD"]);
|
|
134
|
-
const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles:
|
|
163
|
+
const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: checkpointFiles, createdAt: (outcome.now ?? new Date()).toISOString() };
|
|
135
164
|
const previousCheckpoint = current.lastCheckpoint;
|
|
136
165
|
try {
|
|
137
166
|
await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
|
|
138
167
|
await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
|
|
139
168
|
const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
|
|
140
|
-
assertIgnoredBaselineUnchanged(current.ignoredBaseline, afterCommitIgnored);
|
|
169
|
+
assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path)));
|
|
141
170
|
}
|
|
142
171
|
catch (error) {
|
|
143
172
|
const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
|
|
@@ -162,7 +191,13 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
162
191
|
}
|
|
163
192
|
transaction.record = current;
|
|
164
193
|
await assertClean(transaction.repoRoot);
|
|
165
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
status: "checkpointed",
|
|
196
|
+
taskId: outcome.taskSpec.id,
|
|
197
|
+
workerRunId: outcome.workerRunId,
|
|
198
|
+
commit,
|
|
199
|
+
changedFiles: checkpointFiles,
|
|
200
|
+
};
|
|
166
201
|
}
|
|
167
202
|
const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
|
|
168
203
|
await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
const ALLOWED_ARTIFACT_EXTENSIONS = new Set([
|
|
4
5
|
".csv",
|
|
@@ -91,3 +92,83 @@ function isPathInside(root, target) {
|
|
|
91
92
|
const normalizedTarget = path.normalize(target);
|
|
92
93
|
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Safe repo-relative file resolver for spec-evidence previews (binding sources).
|
|
97
|
+
*
|
|
98
|
+
* Enforces AC-003: rejects `..`, absolute paths (POSIX + Windows drive),
|
|
99
|
+
* directory, binary content, and symlink escape via realpath re-check.
|
|
100
|
+
*
|
|
101
|
+
* Unlike resolveArtifactPath (which only allows .harness artifact roots), this
|
|
102
|
+
* allows any repo-relative text file that was already validated as an evidence
|
|
103
|
+
* member by the caller. Returns { absPath, size } on success, throws otherwise.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveRepoFilePreview(repoRoot, relPath) {
|
|
106
|
+
if (!relPath || typeof relPath !== "string") {
|
|
107
|
+
throw new Error("Invalid path");
|
|
108
|
+
}
|
|
109
|
+
if (path.isAbsolute(relPath)) {
|
|
110
|
+
throw new Error("Absolute paths are not allowed");
|
|
111
|
+
}
|
|
112
|
+
// Windows drive letters (e.g. C:\) or POSIX absolute already covered, plus
|
|
113
|
+
// explicit backslash-drive form used in tests.
|
|
114
|
+
if (/^[A-Za-z]:[\\/]/.test(relPath)) {
|
|
115
|
+
throw new Error("Absolute paths are not allowed");
|
|
116
|
+
}
|
|
117
|
+
// Reject any path segment that is ".." (handles ../, nested .., ./..).
|
|
118
|
+
const preSegments = relPath.split(/[\\/]/).filter(Boolean);
|
|
119
|
+
if (preSegments.includes("..")) {
|
|
120
|
+
throw new Error("Path traversal is not allowed");
|
|
121
|
+
}
|
|
122
|
+
if (!isAllowedArtifactTextPath(relPath)) {
|
|
123
|
+
throw new Error("Binary or disallowed file type");
|
|
124
|
+
}
|
|
125
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
126
|
+
let realRepoRoot;
|
|
127
|
+
try {
|
|
128
|
+
realRepoRoot = realpathSync(resolvedRepoRoot);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
realRepoRoot = resolvedRepoRoot;
|
|
132
|
+
}
|
|
133
|
+
const candidate = path.resolve(realRepoRoot, relPath);
|
|
134
|
+
const relative = path.relative(realRepoRoot, candidate);
|
|
135
|
+
if (relative === "" ||
|
|
136
|
+
relative === ".." ||
|
|
137
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
138
|
+
path.isAbsolute(relative)) {
|
|
139
|
+
throw new Error("Path escapes repo root");
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(candidate)) {
|
|
142
|
+
throw new Error("File not found");
|
|
143
|
+
}
|
|
144
|
+
let realCandidate;
|
|
145
|
+
try {
|
|
146
|
+
realCandidate = realpathSync(candidate);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new Error("File not found");
|
|
150
|
+
}
|
|
151
|
+
if (!isPathInside(realRepoRoot, realCandidate)) {
|
|
152
|
+
throw new Error("Symlink escapes repo root");
|
|
153
|
+
}
|
|
154
|
+
const fileStat = await stat(realCandidate);
|
|
155
|
+
if (!fileStat.isFile()) {
|
|
156
|
+
throw new Error("Path is not a regular file");
|
|
157
|
+
}
|
|
158
|
+
const sampleLength = Math.min(fileStat.size, 8 * 1024);
|
|
159
|
+
if (sampleLength > 0) {
|
|
160
|
+
const sample = Buffer.alloc(sampleLength);
|
|
161
|
+
const handle = await open(realCandidate, "r");
|
|
162
|
+
let bytesRead = 0;
|
|
163
|
+
try {
|
|
164
|
+
({ bytesRead } = await handle.read(sample, 0, sampleLength, 0));
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await handle.close();
|
|
168
|
+
}
|
|
169
|
+
if (sample.subarray(0, bytesRead).includes(0)) {
|
|
170
|
+
throw new Error("Binary file content is not allowed");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { absPath: realCandidate, size: fileStat.size };
|
|
174
|
+
}
|