@tea-agent/loop-agent 0.8.0 → 0.10.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +101 -1
  3. package/README.md +69 -5
  4. package/dist/application/dag/args.js +13 -16
  5. package/dist/application/dag/generate-task-dag.js +32 -2
  6. package/dist/application/dag/run-dag.js +1 -27
  7. package/dist/application/dag/validate-dag.js +2 -2
  8. package/dist/application/loop/run-action.js +0 -4
  9. package/dist/cli/command-definitions.js +7 -11
  10. package/dist/cli/program.js +9 -21
  11. package/dist/commands/cursor-prompt.js +42 -82
  12. package/dist/commands/dag-approve.js +36 -0
  13. package/dist/commands/dag-reconcile-run.js +118 -0
  14. package/dist/commands/delegate.js +75 -77
  15. package/dist/commands/doctor.js +0 -18
  16. package/dist/commands/init.js +60 -40
  17. package/dist/commands/instructions.js +7 -10
  18. package/dist/commands/loop.js +4 -20
  19. package/dist/executors/config-core.js +0 -51
  20. package/dist/executors/dag-pi-executor.js +1 -1
  21. package/dist/executors/dag.js +0 -1
  22. package/dist/executors/index.js +0 -2
  23. package/dist/executors/model-routing.js +9 -9
  24. package/dist/executors/shell-executor.js +75 -9
  25. package/dist/governance/checks.js +6 -3
  26. package/dist/governance/manifest-types.js +33 -2
  27. package/dist/infrastructure/harness/loop-action-store.js +0 -3
  28. package/dist/records/harvest.js +2 -23
  29. package/dist/records/one-shot-runs.js +1 -1
  30. package/dist/shared/artifacts-core.js +24 -5
  31. package/dist/shared/output-truncation.js +37 -0
  32. package/dist/shared/package-metadata.js +353 -0
  33. package/dist/shared/reference-context.js +48 -22
  34. package/dist/{executors/cursor-executor.js → sidecars/cursor-prompt/executor.js} +2 -42
  35. package/dist/sidecars/cursor-prompt/index.js +3 -0
  36. package/dist/sidecars/cursor-prompt/stream.js +121 -0
  37. package/dist/task/config-types.js +30 -13
  38. package/dist/task/delegate.js +9 -21
  39. package/dist/task/runtime.js +2 -3
  40. package/dist/worker/cli.js +243 -0
  41. package/dist/worker/closeout/apply.js +73 -0
  42. package/dist/worker/closeout/preview.js +30 -0
  43. package/dist/worker/delivery/final-verification.js +194 -0
  44. package/dist/worker/delivery/git-transaction.js +354 -0
  45. package/dist/worker/delivery/package.js +502 -0
  46. package/dist/worker/feature/decision-loader.js +68 -0
  47. package/dist/worker/feature/discover.js +14 -0
  48. package/dist/worker/feature/next-action.js +74 -0
  49. package/dist/worker/feature/reducer.js +133 -0
  50. package/dist/worker/feature/review.js +502 -0
  51. package/dist/worker/feature/run.js +365 -0
  52. package/dist/worker/feature/types.js +1 -0
  53. package/dist/worker/follow-up/approve.js +270 -0
  54. package/dist/worker/follow-up/factory.js +234 -0
  55. package/dist/worker/follow-up/paths.js +25 -0
  56. package/dist/worker/follow-up/policy.js +26 -0
  57. package/dist/worker/follow-up/schema.js +93 -0
  58. package/dist/worker/follow-up/store.js +96 -0
  59. package/dist/worker/loop-agent/loop-agent-client.js +345 -101
  60. package/dist/worker/metrics/projector.js +139 -0
  61. package/dist/worker/observability/read-model.js +282 -15
  62. package/dist/worker/observe/paths.js +17 -5
  63. package/dist/worker/observe/routes.js +78 -20
  64. package/dist/worker/observe/server.js +8 -6
  65. package/dist/worker/observe/static/app.js +1045 -177
  66. package/dist/worker/observe/static/index.html +70 -43
  67. package/dist/worker/observe/static/styles.css +553 -610
  68. package/dist/worker/pool/run-store.js +14 -2
  69. package/dist/worker/pool/validation.js +59 -0
  70. package/dist/worker/preflight.js +49 -1
  71. package/dist/worker/report/morning-report.js +41 -6
  72. package/dist/worker/run-task/run-task.js +23 -13
  73. package/dist/worker/runner/run-ready.js +89 -11
  74. package/dist/worker/task-spec/schema.js +0 -1
  75. package/dist/workflows/dag/convergence/controller.js +1 -1
  76. package/dist/workflows/dag/executor-registry.js +0 -2
  77. package/dist/workflows/dag/governance-profile.js +10 -0
  78. package/dist/workflows/dag/init-hybrid.js +601 -26
  79. package/dist/workflows/dag/lifecycle.js +146 -0
  80. package/dist/workflows/dag/node-execution.js +64 -7
  81. package/dist/workflows/dag/prompt.js +16 -0
  82. package/dist/workflows/dag/report.js +2 -0
  83. package/dist/workflows/dag/runner.js +176 -119
  84. package/dist/workflows/dag/scheduler.js +7 -2
  85. package/dist/workflows/dag/skill-snapshot.js +527 -0
  86. package/dist/workflows/dag/types.js +45 -9
  87. package/dist/workflows/dag/validate.js +5 -8
  88. package/dist/workflows/loop/actions/dag-action.js +0 -2
  89. package/dist/workflows/loop/actions/shared.js +1 -1
  90. package/dist/workflows/loop/actions.js +14 -31
  91. package/dist/workflows/loop/benchmark.js +1 -1
  92. package/dist/workflows/loop/index.js +1 -1
  93. package/dist/workflows/loop/policy/auto-policy.js +22 -14
  94. package/dist/workflows/loop/policy/path-patterns.js +13 -0
  95. package/docs/README.md +35 -12
  96. package/docs/agent-dag-recovery-playbook.md +1 -1
  97. package/docs/architecture/README.md +26 -0
  98. package/docs/architecture/dag-execution.md +134 -0
  99. package/docs/architecture/evolution.md +52 -0
  100. package/docs/architecture/facts-and-state.md +58 -0
  101. package/docs/architecture/runtime-boundaries.md +41 -15
  102. package/docs/architecture/system-overview.md +93 -0
  103. package/docs/architecture/worker-and-feature.md +81 -0
  104. package/docs/cursor-prompt-sidecar.md +36 -0
  105. package/docs/decisions/README.md +13 -1
  106. package/docs/design/README.md +39 -13
  107. package/docs/development-principles.md +1 -1
  108. package/docs/exec-plans/active/README.md +2 -2
  109. package/docs/exec-plans/completed/README.md +21 -0
  110. package/docs/feature-workflow.md +44 -4
  111. package/docs/init-surface.manifest.json +63 -1
  112. package/docs/loop-agent-harness.md +65 -3
  113. package/docs/progress/README.md +27 -0
  114. package/docs/reports/README.md +74 -5
  115. package/docs/skills/README.md +2 -1
  116. package/docs/skills/vetted-skill-registry.md +2 -1
  117. package/docs/templates/agent-dag-report.schema.json +4 -2
  118. package/docs/templates/agent-dag.base.json +0 -5
  119. package/docs/templates/agent-dag.final-verification.json +0 -5
  120. package/docs/templates/agent-dag.schema.json +1 -2
  121. package/docs/templates/agent-dag.supervised-implementation.json +1 -6
  122. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +131 -0
  123. package/docs/templates/backend-test-dag.json +213 -0
  124. package/docs/templates/backend-test-dag.retrospect.prompt.md +128 -0
  125. package/docs/templates/backend-test-dag.review-cases.prompt.md +85 -0
  126. package/docs/templates/frontend-design-contract.md +33 -0
  127. package/docs/templates/frontend-task-constraints.md +25 -0
  128. package/docs/templates/frontend-task-requirement.md +61 -0
  129. package/docs/templates/harness.schema.json +8 -5
  130. package/docs/templates/hybrid-dag.json +1 -6
  131. package/docs/templates/init-evolution-review.md +4 -2
  132. package/docs/templates/interactive-ui-round2-experiment.md +1 -1
  133. package/docs/templates/product-line/task.yaml +0 -1
  134. package/docs/templates/worker-dogfood-evidence.md +28 -0
  135. package/docs/templates/worker-dogfood-setup.md +20 -0
  136. package/docs/verification-matrix.md +17 -0
  137. package/examples/decision-gate-agent-dag.json +87 -33
  138. package/examples/example-dag.json +0 -5
  139. package/examples/hybrid-loop-agent-dag.json +0 -5
  140. package/harness.json +6 -11
  141. package/package.json +22 -44
  142. package/scripts/check-product-line-docs.sh +10 -3
  143. package/scripts/check-task-pool-root.sh +1 -1
  144. package/skills/agent-worker/SKILL.md +37 -0
  145. package/skills/agent-worker/references/agent-worker-operator.md +43 -0
  146. package/skills/frontend-design-review/SKILL.md +59 -0
  147. package/skills/frontend-design-review/references/review-checklist.md +37 -0
  148. package/skills/frontend-implementation/SKILL.md +48 -0
  149. package/skills/frontend-implementation/references/code-standards.md +34 -0
  150. package/skills/frontend-implementation/references/design-spec.md +46 -0
  151. package/skills/frontend-implementation/references/node-contracts.md +32 -0
  152. package/skills/frontend-review/SKILL.md +53 -0
  153. package/skills/frontend-review/references/review-findings.md +42 -0
  154. package/skills/frontend-verification/SKILL.md +40 -0
  155. package/skills/frontend-verification/references/verification-checklist.md +56 -0
  156. package/skills/grill-me/SKILL.md +10 -0
  157. package/skills/grill-with-docs/SKILL.md +88 -0
  158. package/skills/grill-with-docs/adr-format.md +47 -0
  159. package/skills/grill-with-docs/context-format.md +60 -0
  160. package/skills/init-capability-evolution/SKILL.md +1 -0
  161. package/skills/loop-agent/SKILL.md +11 -9
  162. package/skills/loop-agent/references/command-reference.md +28 -15
  163. package/skills/loop-agent/references/docs-converge.md +126 -0
  164. package/skills/loop-agent/references/harness-policy.md +7 -7
  165. package/skills/loop-agent/references/hybrid-dag.md +13 -15
  166. package/skills/loop-agent/references/long-running-loop.md +4 -6
  167. package/skills/loop-agent/references/multi-worktree.md +6 -6
  168. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  169. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +14 -11
  170. package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
  171. package/skills/using-git-worktrees/SKILL.md +215 -0
  172. package/dist/commands/cursor-worker.js +0 -43
  173. package/dist/cursor-worker-entry.js +0 -8
  174. package/dist/executors/cursor-artifacts.js +0 -33
  175. package/dist/executors/cursor-execution-log.js +0 -81
  176. package/dist/executors/cursor-executor-artifacts.js +0 -134
  177. package/dist/executors/cursor-run.js +0 -115
  178. package/dist/executors/cursor-tool.js +0 -94
  179. package/dist/executors/cursor-worker-client.js +0 -223
  180. package/dist/executors/cursor-worker-protocol.js +0 -18
  181. package/dist/executors/cursor-worker-server.js +0 -54
  182. package/dist/executors/cursor-worker.js +0 -3
  183. package/dist/executors/cursor.js +0 -6
  184. package/dist/executors/dag-cursor-executor.js +0 -87
  185. package/dist/workflows/loop/actions/cursor-fix.js +0 -191
  186. package/dist/workflows/loop/policy/cursor-fix-policy.js +0 -31
  187. package/docs/cursor-executor-usage.md +0 -25
  188. package/docs/dynamic-workflow-dag-engine-roadmap.md +0 -1749
@@ -0,0 +1,73 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import YAML from "yaml";
5
+ import { z } from "zod";
6
+ import { validateDeliveryPackage } from "../delivery/package.js";
7
+ import { validateFeatureTaskGraph } from "../task-graph/validate.js";
8
+ import { previewFeatureCloseout } from "./preview.js";
9
+ export const appliedFeatureCloseoutSchema = z.object({
10
+ schema_version: z.literal(1), feature_id: z.string().min(1), status: z.literal("success"), qa_verdict: z.literal("pass"), qa_evidence: z.array(z.string().min(1)).min(1),
11
+ delivery_manifest_ref: z.string().min(1), owner: z.string().min(1), decided_at: z.string().datetime(), appliedAt: z.string().datetime(), input_facts_hash: z.string().regex(/^[a-f0-9]{64}$/), summary: z.string().min(1),
12
+ }).strict();
13
+ export async function applyFeatureCloseout(input) {
14
+ if (!input.owner.trim())
15
+ throw new Error("feature closeout --apply requires a non-empty owner");
16
+ const repoRoot = await realpath(path.resolve(input.repoRoot));
17
+ const featureDir = await realpath(path.resolve(input.featureDir));
18
+ const relative = path.relative(repoRoot, featureDir);
19
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
20
+ throw new Error("Feature Packet must be inside the target repo");
21
+ const beforeValidation = await validateFeatureTaskGraph(featureDir);
22
+ if (!beforeValidation.ok)
23
+ throw new Error(`Feature Packet is invalid before closeout apply: ${beforeValidation.errors.map((error) => error.code).join(", ")}`);
24
+ const closeoutPath = path.join(featureDir, "closeout.yaml");
25
+ const deliveryPath = path.join(repoRoot, ".harness", "task-pool", "artifacts", "features", beforeValidation.featureId, "delivery", "delivery-manifest.json");
26
+ let previous;
27
+ let reusable;
28
+ try {
29
+ previous = await readFile(closeoutPath);
30
+ const existing = appliedFeatureCloseoutSchema.safeParse(YAML.parse(previous.toString("utf-8")));
31
+ if (existing.success)
32
+ reusable = existing.data;
33
+ }
34
+ catch (error) {
35
+ if (!isNotFound(error))
36
+ throw error;
37
+ }
38
+ if (reusable && reusable.feature_id !== beforeValidation.featureId)
39
+ throw new Error(`existing Feature Closeout ownership mismatch: expected ${beforeValidation.featureId}, got ${reusable.feature_id}`);
40
+ const allowedDirtyPaths = reusable ? [path.relative(repoRoot, closeoutPath).replace(/\\/g, "/")] : undefined;
41
+ const preview = await previewFeatureCloseout({ featureDir, repoRoot, ...(input.now ? { now: input.now } : {}), ...(allowedDirtyPaths ? { allowedDirtyPaths } : {}) });
42
+ if (preview.status !== "ready")
43
+ throw new Error(`Feature Closeout gates are blocked: ${preview.gates.filter((gate) => !gate.passed).map((gate) => `${gate.id} (${gate.message})`).join(", ")}`);
44
+ const delivery = await validateDeliveryPackage({ featureDir, repoRoot, ...(input.now ? { now: input.now } : {}), ...(allowedDirtyPaths ? { allowedDirtyPaths } : {}) });
45
+ if (!delivery.valid || !delivery.manifest)
46
+ throw new Error(`Delivery Package is invalid: ${delivery.blockers.join("; ")}`);
47
+ const inputFactsHash = createHash("sha256").update(await readFile(deliveryPath)).digest("hex");
48
+ const appliedAt = (input.now ?? new Date()).toISOString();
49
+ if (reusable?.input_facts_hash === inputFactsHash)
50
+ return { schemaVersion: 1, featureId: beforeValidation.featureId, status: "reused", closeoutPath, owner: reusable.owner, appliedAt: reusable.appliedAt, inputFactsHash };
51
+ const record = appliedFeatureCloseoutSchema.parse({ schema_version: 1, feature_id: preview.featureId, status: "success", qa_verdict: "pass", qa_evidence: [delivery.manifest.qa.evidence.path, delivery.manifest.finalVerification.path], delivery_manifest_ref: path.relative(repoRoot, deliveryPath).replace(/\\/g, "/"), owner: input.owner.trim(), decided_at: appliedAt, appliedAt, input_facts_hash: inputFactsHash, summary: `Feature ${preview.featureId} passed tasks, QA, required AC, risk, final verification, and Delivery gates.` });
52
+ const temp = path.join(featureDir, `.closeout.${randomUUID()}.tmp`);
53
+ try {
54
+ await writeFile(temp, YAML.stringify(record), "utf-8");
55
+ await rename(temp, closeoutPath);
56
+ const after = await (input.validateAfterWrite ?? validateFeatureTaskGraph)(featureDir);
57
+ if (!after.ok)
58
+ throw new Error(`Feature Packet is invalid after closeout apply: ${after.errors.map((error) => error.code).join(", ")}`);
59
+ return { schemaVersion: 1, featureId: preview.featureId, status: "applied", closeoutPath, owner: record.owner, appliedAt, inputFactsHash };
60
+ }
61
+ catch (error) {
62
+ await rm(temp, { force: true });
63
+ if (previous) {
64
+ const rollback = path.join(featureDir, `.closeout.${randomUUID()}.rollback`);
65
+ await writeFile(rollback, previous);
66
+ await rename(rollback, closeoutPath);
67
+ }
68
+ else
69
+ await rm(closeoutPath, { force: true });
70
+ throw error;
71
+ }
72
+ }
73
+ function isNotFound(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); }
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+ import { validateDeliveryPackage } from "../delivery/package.js";
3
+ import { reviewFeature } from "../feature/review.js";
4
+ import { getTaskPoolRoot } from "../pool/run-store.js";
5
+ import { validateFeatureTaskGraph } from "../task-graph/validate.js";
6
+ export async function previewFeatureCloseout(input) {
7
+ const featureDir = path.resolve(input.featureDir);
8
+ const repoRoot = path.resolve(input.repoRoot);
9
+ const validation = await validateFeatureTaskGraph(featureDir);
10
+ const model = await reviewFeature({ featureDir, repoRoot });
11
+ const manifestPath = path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", validation.featureId, "delivery", "delivery-manifest.json");
12
+ const delivery = await validateDeliveryPackage({ featureDir, repoRoot, ...(input.now ? { now: input.now } : {}), ...(input.allowedDirtyPaths ? { allowedDirtyPaths: input.allowedDirtyPaths } : {}) });
13
+ const manifest = delivery.manifest;
14
+ const requiredIncomplete = model.acceptanceCoverage.filter((item) => item.required && !["covered", "waived"].includes(item.status));
15
+ const allTasksDone = model.tasks.length > 0 && model.tasks.every((task) => task.status === "Done");
16
+ const gates = [
17
+ { id: "feature-validation", passed: validation.ok, message: validation.ok ? "Feature Packet is valid" : "Feature Packet validation failed", evidence: validation.errors.flatMap((error) => error.path ? [error.path] : []) },
18
+ { id: "tasks-complete", passed: allTasksDone, message: allTasksDone ? "All development and QA tasks are Done" : "One or more required tasks are not Done", evidence: model.tasks.filter((task) => task.status !== "Done").map((task) => task.taskId) },
19
+ { id: "qa-pass", passed: manifest?.qa.verdict === "passed", message: manifest?.qa.verdict === "passed" ? "QA verdict passed" : "QA pass evidence is missing", evidence: manifest ? [manifest.qa.evidence.path] : [] },
20
+ { id: "required-ac", passed: requiredIncomplete.length === 0, message: requiredIncomplete.length === 0 ? "Required AC are covered or waived" : "Required AC coverage is incomplete", evidence: requiredIncomplete.map((item) => item.acId) },
21
+ { id: "risk", passed: model.riskSummary.high === 0, message: model.riskSummary.high === 0 ? "No unresolved high risk" : "Unresolved high risk remains", evidence: model.blockingItems.filter((item) => item.failureCategory && ["RiskyChange", "ContractMismatch", "SpecUnclear", "NeedsHuman"].includes(item.failureCategory)).flatMap((item) => item.evidence) },
22
+ { id: "final-verification", passed: Boolean(manifest?.finalVerification), message: manifest?.finalVerification ? "Final verification evidence is present" : "Final verification evidence is missing", evidence: manifest ? [manifest.finalVerification.path] : [] },
23
+ { id: "delivery", passed: delivery.valid && model.evidence.delivery === manifestPath, message: delivery.valid && model.evidence.delivery === manifestPath ? "Delivery manifest and Git facts are valid" : `Delivery is invalid: ${delivery.blockers[0] ?? "missing manifest"}`, evidence: manifest ? [manifestPath] : [] },
24
+ ];
25
+ const ready = gates.every((gate) => gate.passed);
26
+ return { schemaVersion: 1, featureId: validation.featureId, status: ready ? "ready" : "blocked", gates, nextAction: ready ? "Closeout gates are ready; retain this preview for human review until atomic --apply is implemented in M2-08" : "Resolve the first failed closeout gate and preview again" };
27
+ }
28
+ export function renderCloseoutPreview(preview) {
29
+ return [`Feature: ${preview.featureId}`, `Closeout: ${preview.status === "ready" ? "可应用" : "被阻止"}`, ...preview.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.id}: ${gate.message}`), `下一步: ${preview.nextAction}`].join("\n") + "\n";
30
+ }
@@ -0,0 +1,194 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import YAML from "yaml";
7
+ import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
8
+ import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
9
+ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
10
+ import { validateFeatureTaskGraph } from "../task-graph/validate.js";
11
+ import { getRunsJsonlPath, getTaskPoolRoot, readJsonlFile, recordTaskPoolRun } from "../pool/run-store.js";
12
+ import { buildWorkerRunId, runTaskSpec } from "../run-task/run-task.js";
13
+ import { taskSpecSchema } from "../task-spec/schema.js";
14
+ import { preflightTargetRepo } from "../preflight.js";
15
+ import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
16
+ const execFileAsync = promisify(execFile);
17
+ export async function runFeatureFinalVerification(input) {
18
+ const dependencies = {
19
+ preflight: preflightTargetRepo,
20
+ runTask: runTaskSpec,
21
+ git,
22
+ gitRaw,
23
+ ...input.dependencies,
24
+ };
25
+ let controllerIdentity = resolveControllerIdentity(input.client, input.controllerIdentity);
26
+ const identityFailure = controllerIdentityExpectationFailure(controllerIdentity, input.controllerExpectation);
27
+ if (identityFailure) {
28
+ throw new Error(`${identityFailure.code}: ${identityFailure.message}`);
29
+ }
30
+ const repoRoot = await realpath(path.resolve(input.repoRoot));
31
+ const featureDir = await realpath(path.resolve(input.featureDir));
32
+ const relativeFeature = path.relative(repoRoot, featureDir);
33
+ if (!relativeFeature || relativeFeature.startsWith("..") || path.isAbsolute(relativeFeature))
34
+ throw new Error("Feature Packet must be inside the target repo");
35
+ const validation = await validateFeatureTaskGraph(featureDir);
36
+ if (!validation.ok)
37
+ throw new Error(`Feature Packet is invalid: ${validation.errors.map((error) => error.code).join(", ")}`);
38
+ const featureId = validation.featureId;
39
+ const preflight = await dependencies.preflight({
40
+ repoRoot,
41
+ client: input.client,
42
+ ...(input.controllerExpectation ? { expectation: input.controllerExpectation } : {}),
43
+ });
44
+ if (!preflight.ok) {
45
+ throw new Error(`target repo preflight failed: ${preflight.code}: ${preflight.message}`);
46
+ }
47
+ controllerIdentity = preflight.controllerIdentity ?? controllerIdentity;
48
+ const record = gitTransactionRecordSchema.parse(JSON.parse(await readFile(transactionRecordPath(repoRoot, featureId), "utf-8")));
49
+ const branch = await dependencies.git(repoRoot, ["branch", "--show-current"]);
50
+ const headSha = await dependencies.git(repoRoot, ["rev-parse", "HEAD"]);
51
+ const status = await dependencies.gitRaw(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
52
+ if (branch !== record.branch || headSha !== record.lastCheckpoint)
53
+ throw new Error("final verification requires the recorded Feature branch at lastCheckpoint");
54
+ if (status.trim())
55
+ throw new Error(`final verification requires a clean worktree:\n${status.trim()}`);
56
+ const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", "task-graph.yaml"), "utf-8")));
57
+ const node = graph.nodes.find((candidate) => candidate.id === input.taskId);
58
+ if (!node)
59
+ throw new Error(`final verification task is not in the Feature graph: ${input.taskId}`);
60
+ const taskSpecPath = path.join(featureDir, "tasks", node.task);
61
+ const taskSpec = taskSpecSchema.parse(YAML.parse(await readFile(taskSpecPath, "utf-8")));
62
+ if (taskSpec.type !== "qa-execute")
63
+ throw new Error(`final verification requires a qa-execute TaskSpec: ${input.taskId}`);
64
+ if (taskSpec.verify.commands.filter((command) => command.required).length === 0)
65
+ throw new Error("final verification TaskSpec has no required commands");
66
+ const verificationTaskSpec = {
67
+ ...taskSpec,
68
+ constraints: {
69
+ ...taskSpec.constraints,
70
+ allowed_paths: [],
71
+ hard_constraints: [...taskSpec.constraints.hard_constraints, "Dedicated final verification is read-only; do not modify repository files"],
72
+ },
73
+ };
74
+ const now = input.now ?? new Date();
75
+ let runs = await readJsonlFile(getRunsJsonlPath(repoRoot));
76
+ let finalRun = await reusableFinalRun(repoRoot, runs, taskSpec.id, featureId, record, now, controllerIdentity);
77
+ if (!finalRun) {
78
+ const workerRunId = buildWorkerRunId(`${input.taskId}-final`, now);
79
+ const result = await dependencies.runTask({
80
+ repoRoot,
81
+ taskSpec: verificationTaskSpec,
82
+ taskSpecPath,
83
+ client: input.client,
84
+ now,
85
+ workerRunId,
86
+ skipSuccessFinalization: true,
87
+ preflight: false,
88
+ ...(controllerIdentity ? { controllerIdentity } : {}),
89
+ ...(input.controllerExpectation ? { controllerExpectation: input.controllerExpectation } : {}),
90
+ });
91
+ if (result.status !== "succeeded")
92
+ throw new Error(`dedicated final verification failed: ${workerRunId}`);
93
+ const afterHead = await dependencies.git(repoRoot, ["rev-parse", "HEAD"]);
94
+ const afterStatus = await dependencies.gitRaw(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
95
+ if (afterHead !== headSha || afterStatus.trim())
96
+ throw new Error("dedicated final verification changed the Delivery HEAD or worktree");
97
+ finalRun = { schemaVersion: 1, batchRunId: `final-verification-${workerRunId}`, workerRunId, taskId: taskSpec.id, featureId, status: "succeeded", harnessTaskId: result.harnessTaskId, runRecordPath: result.runRecordPath, dagPath: result.dagPath, recordedAt: now.toISOString(), ...(controllerIdentity ? { controllerIdentity } : {}) };
98
+ await recordTaskPoolRun({ repoRoot, run: finalRun });
99
+ runs = [...runs, finalRun];
100
+ }
101
+ const allSpecs = new Map();
102
+ for (const graphNode of graph.nodes) {
103
+ allSpecs.set(graphNode.id, taskSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", graphNode.task), "utf-8"))));
104
+ }
105
+ const acceptance = acceptanceSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8")));
106
+ const qaRuns = latestSuccessfulQaRuns(runs, allSpecs, taskSpec.id, featureId);
107
+ if (qaRuns.length === 0)
108
+ throw new Error("no prior successful qa-execute runs are available for the QA aggregate");
109
+ const coveredAcIds = [...new Set(qaRuns.flatMap((run) => allSpecs.get(run.taskId)?.acceptance_refs ?? []))];
110
+ const requiredAcIds = acceptance.acceptance.filter((item) => item.priority === "must").map((item) => item.id);
111
+ for (const acId of requiredAcIds)
112
+ if (!coveredAcIds.includes(acId))
113
+ throw new Error(`QA aggregate does not cover required acceptance: ${acId}`);
114
+ const summaryRelative = path.join(".harness", "dag-runs", "completed", finalRun.workerRunId, "verify-shell", "result.summary.md").replace(/\\/g, "/");
115
+ const summaryAbsolute = path.join(repoRoot, ...summaryRelative.split("/"));
116
+ const summary = await readFile(summaryAbsolute);
117
+ const evidenceDir = path.join(getTaskPoolRoot(repoRoot), "evidence", featureId);
118
+ const qaEvidencePath = path.join(evidenceDir, "qa-pass.json");
119
+ const finalVerificationPath = path.join(evidenceDir, "final-verification.json");
120
+ await writeEvidencePairAtomic(evidenceDir, {
121
+ qa: { schemaVersion: 1, featureId, verdict: "passed", acIds: requiredAcIds, runs: qaRuns.map(runRef) },
122
+ final: { schemaVersion: 1, featureId, kind: "final-verification", status: "passed", headSha, run: runRef(finalRun), shellSummary: { path: summaryRelative, sha256: createHash("sha256").update(summary).digest("hex") } },
123
+ });
124
+ return { schemaVersion: 1, featureId, taskId: taskSpec.id, workerRunId: finalRun.workerRunId, headSha, qaEvidencePath: repoRef(repoRoot, qaEvidencePath), finalVerificationPath: repoRef(repoRoot, finalVerificationPath), qaRunCount: qaRuns.length, ...(controllerIdentity ? { controllerIdentity } : {}) };
125
+ }
126
+ export function latestSuccessfulQaRuns(runs, specs, excludedTaskId, featureId) {
127
+ const byTask = new Map();
128
+ for (const run of runs)
129
+ if (run.featureId === featureId && run.status === "succeeded" && run.taskId !== excludedTaskId && specs.get(run.taskId)?.type === "qa-execute")
130
+ byTask.set(run.taskId, run);
131
+ return [...byTask.values()].sort((a, b) => a.taskId.localeCompare(b.taskId));
132
+ }
133
+ async function reusableFinalRun(repoRoot, runs, taskId, featureId, record, now, controllerIdentity) {
134
+ const latestCheckpointAt = Math.max(...record.checkpoints.map((entry) => new Date(entry.createdAt).getTime()));
135
+ for (const run of [...runs].reverse()) {
136
+ if (run.taskId !== taskId || run.featureId !== featureId || run.status !== "succeeded" || !run.workerRunId.includes("-final-") || !run.runRecordPath)
137
+ continue;
138
+ if (controllerIdentity && !controllerIdentitiesMatch(run.controllerIdentity, controllerIdentity))
139
+ continue;
140
+ const recordedAt = new Date(run.recordedAt).getTime();
141
+ if (recordedAt < latestCheckpointAt || now.getTime() - recordedAt > 24 * 60 * 60 * 1000)
142
+ continue;
143
+ try {
144
+ const canonical = JSON.parse(await readFile(run.runRecordPath, "utf-8"));
145
+ if (canonical.workerRunId !== run.workerRunId || canonical.businessId !== taskId || canonical.featureId !== featureId || canonical.status !== "succeeded")
146
+ continue;
147
+ await readFile(path.join(repoRoot, ".harness", "dag-runs", "completed", run.workerRunId, "verify-shell", "result.summary.md"));
148
+ return run;
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ function runRef(run) { return { taskId: run.taskId, workerRunId: run.workerRunId, recordedAt: run.recordedAt, ...(run.controllerIdentity ? { controllerIdentity: run.controllerIdentity } : {}) }; }
157
+ function repoRef(repoRoot, absolute) { return path.relative(repoRoot, absolute).replace(/\\/g, "/"); }
158
+ export async function writeEvidencePairAtomic(evidenceDir, value, fs = { rm }) {
159
+ const parent = path.dirname(evidenceDir);
160
+ const staging = path.join(parent, `.evidence.${randomUUID()}.staging`);
161
+ const backup = path.join(parent, `.evidence.${randomUUID()}.backup`);
162
+ await mkdir(staging, { recursive: true });
163
+ await writeFile(path.join(staging, "qa-pass.json"), `${JSON.stringify(value.qa, null, 2)}\n`);
164
+ await writeFile(path.join(staging, "final-verification.json"), `${JSON.stringify(value.final, null, 2)}\n`);
165
+ let backedUp = false;
166
+ try {
167
+ await rename(evidenceDir, backup);
168
+ backedUp = true;
169
+ }
170
+ catch (error) {
171
+ if (!isNotFound(error))
172
+ throw error;
173
+ }
174
+ let installed = false;
175
+ try {
176
+ await rename(staging, evidenceDir);
177
+ installed = true;
178
+ }
179
+ catch (error) {
180
+ if (installed)
181
+ await fs.rm(evidenceDir, { recursive: true, force: true }).catch(() => { });
182
+ if (backedUp)
183
+ await rename(backup, evidenceDir).catch(() => { });
184
+ throw error;
185
+ }
186
+ finally {
187
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => { });
188
+ }
189
+ if (backedUp)
190
+ await fs.rm(backup, { recursive: true, force: true }).catch(() => { });
191
+ }
192
+ async function git(repoRoot, args) { return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout.trim(); }
193
+ async function gitRaw(repoRoot, args) { return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout; }
194
+ function isNotFound(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); }
@@ -0,0 +1,354 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { cp, lstat, mkdir, readFile, readlink, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { promisify } from "node:util";
5
+ import path from "node:path";
6
+ import { z } from "zod";
7
+ import { getTaskPoolRoot } from "../pool/run-store.js";
8
+ import { assertSafeRuntimeId } from "../follow-up/paths.js";
9
+ const execFileAsync = promisify(execFile);
10
+ const checkpointSchema = z.object({
11
+ taskId: z.string().min(1),
12
+ workerRunId: z.string().min(1),
13
+ commit: z.string().regex(/^[a-f0-9]{40}$/),
14
+ changedFiles: z.array(z.string()),
15
+ createdAt: z.string().datetime(),
16
+ }).strict();
17
+ const ignoredSensitiveBaselineEntrySchema = z.object({
18
+ path: z.string().min(1),
19
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
20
+ }).strict();
21
+ export const gitTransactionRecordSchema = z.object({
22
+ schemaVersion: z.literal(1),
23
+ featureId: z.string().min(1),
24
+ branch: z.string().min(1),
25
+ baseBranch: z.string().min(1),
26
+ baseCommit: z.string().regex(/^[a-f0-9]{40}$/),
27
+ lastCheckpoint: z.string().regex(/^[a-f0-9]{40}$/),
28
+ authorizedBy: z.literal("cli --git-mode checkpoint"),
29
+ startedAt: z.string().datetime(),
30
+ ignoredBaseline: z.array(ignoredSensitiveBaselineEntrySchema),
31
+ ignoredSensitiveBaseline: z.array(ignoredSensitiveBaselineEntrySchema),
32
+ checkpoints: z.array(checkpointSchema),
33
+ }).strict();
34
+ export async function startGitTransaction(input) {
35
+ const repoRoot = await realpath(path.resolve(input.repoRoot));
36
+ assertSafeRuntimeId(input.featureId, "featureId");
37
+ const gitRoot = await git(repoRoot, ["rev-parse", "--show-toplevel"]);
38
+ if (await realpath(gitRoot) !== repoRoot)
39
+ throw new Error(`Git root does not match target repo: ${gitRoot}`);
40
+ const head = await git(repoRoot, ["rev-parse", "HEAD"]);
41
+ const baseBranch = await git(repoRoot, ["branch", "--show-current"]);
42
+ if (!baseBranch)
43
+ throw new Error("Git checkpoint requires a named current branch");
44
+ await assertClean(repoRoot);
45
+ const branch = input.branch ?? `agent/${input.featureId.toLowerCase()}`;
46
+ assertSafeBranch(branch);
47
+ const recordPath = transactionRecordPath(repoRoot, input.featureId);
48
+ const existing = await readOptionalRecord(recordPath);
49
+ const branchExists = await gitOk(repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
50
+ if (branchExists) {
51
+ if (!existing)
52
+ throw new Error(`Feature branch already exists without transaction ownership: ${branch}`);
53
+ if (existing.branch !== branch || existing.featureId !== input.featureId)
54
+ throw new Error(`Feature branch ownership mismatch: ${branch}`);
55
+ if (baseBranch === existing.baseBranch && head !== existing.baseCommit)
56
+ throw new Error(`Feature base branch moved from recorded commit: ${existing.baseCommit}`);
57
+ if (baseBranch === branch && head !== existing.lastCheckpoint)
58
+ throw new Error(`Feature branch HEAD does not match recorded checkpoint: ${head}`);
59
+ if (baseBranch !== existing.baseBranch && baseBranch !== branch)
60
+ throw new Error(`Current branch is not owned by this Feature transaction: ${baseBranch}`);
61
+ await git(repoRoot, ["switch", branch]);
62
+ const branchHead = await git(repoRoot, ["rev-parse", "HEAD"]);
63
+ if (branchHead !== existing.lastCheckpoint)
64
+ throw new Error(`Feature branch HEAD does not match recorded checkpoint: ${branchHead}`);
65
+ return { repoRoot, recordPath, record: existing };
66
+ }
67
+ if (existing)
68
+ throw new Error(`Git transaction record exists but branch is missing: ${branch}`);
69
+ await git(repoRoot, ["switch", "-c", branch]);
70
+ const ignoredBaseline = await readIgnoredBaseline(repoRoot);
71
+ const record = {
72
+ schemaVersion: 1,
73
+ featureId: input.featureId,
74
+ branch,
75
+ baseBranch,
76
+ baseCommit: head,
77
+ lastCheckpoint: head,
78
+ authorizedBy: "cli --git-mode checkpoint",
79
+ startedAt: (input.now ?? new Date()).toISOString(),
80
+ ignoredBaseline,
81
+ ignoredSensitiveBaseline: ignoredBaseline.filter((entry) => isSensitivePath(entry.path)),
82
+ checkpoints: [],
83
+ };
84
+ try {
85
+ await writeJsonAtomic(recordPath, record);
86
+ }
87
+ catch (error) {
88
+ await git(repoRoot, ["switch", baseBranch]).catch(() => { });
89
+ await git(repoRoot, ["branch", "-D", branch]).catch(() => { });
90
+ throw error;
91
+ }
92
+ return { repoRoot, recordPath, record };
93
+ }
94
+ export async function finalizeGitTask(transaction, outcome) {
95
+ const current = gitTransactionRecordSchema.parse(JSON.parse(await readFile(transaction.recordPath, "utf-8")));
96
+ if (JSON.stringify(current) !== JSON.stringify(transaction.record))
97
+ throw new Error("Git transaction record changed outside the finalizer");
98
+ await assertTransactionPosition(transaction.repoRoot, current);
99
+ const currentIgnored = await readIgnoredBaseline(transaction.repoRoot);
100
+ const ignoredBaselineByPath = new Map(current.ignoredBaseline.map((entry) => [entry.path, entry.sha256]));
101
+ const changedBaselineIgnored = currentIgnored.filter((entry) => ignoredBaselineByPath.has(entry.path) && ignoredBaselineByPath.get(entry.path) !== entry.sha256).map((entry) => entry.path);
102
+ const missingBaselineIgnored = current.ignoredBaseline.filter((entry) => !currentIgnored.some((candidate) => candidate.path === entry.path)).map((entry) => entry.path);
103
+ if (changedBaselineIgnored.length > 0 || missingBaselineIgnored.length > 0) {
104
+ throw new Error(`pre-existing ignored file changed during Git transaction: ${[...changedBaselineIgnored, ...missingBaselineIgnored].join(", ")}`);
105
+ }
106
+ const existing = current.checkpoints.find((entry) => entry.workerRunId === outcome.workerRunId);
107
+ if (outcome.status === "reused" || existing) {
108
+ if (!existing)
109
+ throw new Error(`reused Worker run has no recorded checkpoint: ${outcome.workerRunId}`);
110
+ if (existing.taskId !== outcome.taskSpec.id || current.featureId !== outcome.taskSpec.feature_id)
111
+ throw new Error(`reused Worker run does not match current TaskSpec: ${outcome.workerRunId}`);
112
+ if (!await gitOk(transaction.repoRoot, ["cat-file", "-e", `${existing.commit}^{commit}`]))
113
+ throw new Error(`recorded checkpoint is missing from Git history: ${existing.commit}`);
114
+ if (!await gitOk(transaction.repoRoot, ["merge-base", "--is-ancestor", existing.commit, current.lastCheckpoint]))
115
+ throw new Error(`recorded checkpoint is outside the Feature transaction history: ${existing.commit}`);
116
+ await assertCheckpointMetadata(transaction.repoRoot, existing.commit, outcome.taskSpec, existing.workerRunId);
117
+ return { status: "reused", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit: existing.commit, changedFiles: existing.changedFiles };
118
+ }
119
+ const changes = await readChanges(transaction.repoRoot);
120
+ const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
121
+ if (outcome.status === "succeeded") {
122
+ if (changes.length === 0)
123
+ throw new Error(`successful task produced no checkpointable changes: ${outcome.taskSpec.id}`);
124
+ if (newIgnored.length > 0)
125
+ throw new Error(`task created ignored files outside the Git checkpoint: ${newIgnored.join(", ")}`);
126
+ auditChangedPaths(changes, outcome.taskSpec);
127
+ await git(transaction.repoRoot, ["add", "--", ...changes]);
128
+ const message = commitMessage(outcome.taskSpec, outcome.workerRunId);
129
+ await git(transaction.repoRoot, ["commit", "-m", message]);
130
+ const commit = await git(transaction.repoRoot, ["rev-parse", "HEAD"]);
131
+ const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes, createdAt: (outcome.now ?? new Date()).toISOString() };
132
+ const previousCheckpoint = current.lastCheckpoint;
133
+ try {
134
+ await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
135
+ await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
136
+ const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
137
+ assertIgnoredBaselineUnchanged(current.ignoredBaseline, afterCommitIgnored);
138
+ }
139
+ catch (error) {
140
+ const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
141
+ if (branch === current.branch) {
142
+ await git(transaction.repoRoot, ["reset", "--hard", previousCheckpoint]).catch(() => { });
143
+ const afterFailureIgnored = await readIgnoredBaseline(transaction.repoRoot).catch(() => []);
144
+ const baselinePaths = new Set(current.ignoredBaseline.map((entry) => entry.path));
145
+ for (const entry of afterFailureIgnored)
146
+ if (!baselinePaths.has(entry.path))
147
+ await rm(path.join(transaction.repoRoot, entry.path), { recursive: true, force: true });
148
+ }
149
+ throw error;
150
+ }
151
+ current.lastCheckpoint = commit;
152
+ current.checkpoints.push(checkpoint);
153
+ try {
154
+ await writeJsonAtomic(transaction.recordPath, current);
155
+ }
156
+ catch (error) {
157
+ await git(transaction.repoRoot, ["reset", "--hard", previousCheckpoint]).catch(() => { });
158
+ throw error;
159
+ }
160
+ transaction.record = current;
161
+ await assertClean(transaction.repoRoot);
162
+ return { status: "checkpointed", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes };
163
+ }
164
+ const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
165
+ await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
166
+ if (outcome.keepFailedDiff) {
167
+ return { status: "kept-failed-diff", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, artifactDir, changedFiles: changes };
168
+ }
169
+ await git(transaction.repoRoot, ["reset", "--hard", current.lastCheckpoint]);
170
+ await git(transaction.repoRoot, ["clean", "-fd"]);
171
+ for (const relative of newIgnored)
172
+ await rm(path.join(transaction.repoRoot, relative), { recursive: true, force: true });
173
+ await assertClean(transaction.repoRoot);
174
+ return { status: "restored", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, artifactDir, changedFiles: changes };
175
+ }
176
+ export function transactionRecordPath(repoRoot, featureId) {
177
+ return path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", featureId, "git", "transaction.json");
178
+ }
179
+ async function captureFailureArtifacts(repoRoot, artifactDir, changes, ignoredFiles, outcome) {
180
+ await mkdir(path.join(artifactDir, "untracked"), { recursive: true });
181
+ const patchText = await gitRaw(repoRoot, ["diff", "--binary", "HEAD"]);
182
+ await writeFile(path.join(artifactDir, "changes.patch"), patchText, "utf-8");
183
+ const untracked = (await gitRaw(repoRoot, ["ls-files", "--others", "--exclude-standard"])).split(/\r?\n/).filter(Boolean);
184
+ for (const relative of untracked) {
185
+ const source = path.join(repoRoot, relative);
186
+ const target = path.join(artifactDir, "untracked", relative);
187
+ if ((await stat(source)).isFile()) {
188
+ await mkdir(path.dirname(target), { recursive: true });
189
+ await cp(source, target);
190
+ }
191
+ }
192
+ await writeJsonAtomic(path.join(artifactDir, "failure.json"), {
193
+ schemaVersion: 1,
194
+ featureId: outcome.taskSpec.feature_id,
195
+ taskId: outcome.taskSpec.id,
196
+ workerRunId: outcome.workerRunId,
197
+ changedFiles: changes,
198
+ untrackedFiles: untracked,
199
+ ignoredFiles,
200
+ ignoredSensitiveFiles: ignoredFiles.filter(isSensitivePath),
201
+ writeBoundaryAudit: auditChangedPathsReport([...changes, ...ignoredFiles], outcome.taskSpec),
202
+ lastCheckpoint: await git(repoRoot, ["rev-parse", "HEAD"]),
203
+ ...(outcome.runRecordPath ? { runRecordPath: outcome.runRecordPath } : {}),
204
+ capturedAt: (outcome.now ?? new Date()).toISOString(),
205
+ });
206
+ }
207
+ function auditChangedPaths(changes, taskSpec) {
208
+ const audit = auditChangedPathsReport(changes, taskSpec);
209
+ if (audit.violations.length > 0)
210
+ throw new Error(audit.violations[0]);
211
+ }
212
+ function auditChangedPathsReport(changes, taskSpec) {
213
+ const violations = [];
214
+ for (const changed of changes) {
215
+ if (/(^|\/)(\.env(?:\.|$)|[^/]*\.(?:pem|key|p12|pfx))$/i.test(changed))
216
+ violations.push(`changed path may contain sensitive material: ${changed}`);
217
+ else if (taskSpec.constraints.forbidden_paths.some((glob) => matchesGlob(changed, glob)))
218
+ violations.push(`changed path is forbidden for ${taskSpec.id}: ${changed}`);
219
+ else if (!taskSpec.constraints.allowed_paths.some((glob) => matchesGlob(changed, glob)))
220
+ violations.push(`changed path is outside allowed_paths for ${taskSpec.id}: ${changed}`);
221
+ }
222
+ return { ok: violations.length === 0, violations };
223
+ }
224
+ function matchesGlob(filePath, glob) {
225
+ const normalizedPath = filePath.replace(/\\/g, "/");
226
+ const normalizedGlob = glob.replace(/\\/g, "/");
227
+ let pattern = "^";
228
+ for (let index = 0; index < normalizedGlob.length; index += 1) {
229
+ const char = normalizedGlob[index];
230
+ if (char === "*" && normalizedGlob[index + 1] === "*") {
231
+ pattern += ".*";
232
+ index += 1;
233
+ continue;
234
+ }
235
+ if (char === "*") {
236
+ pattern += "[^/]*";
237
+ continue;
238
+ }
239
+ pattern += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
240
+ }
241
+ return new RegExp(`${pattern}$`).test(normalizedPath);
242
+ }
243
+ function commitMessage(taskSpec, workerRunId) {
244
+ const type = taskSpec.type === "bugfix" || taskSpec.type === "fix-from-failure" ? "fix" : taskSpec.type.startsWith("qa-") ? "test" : "feat";
245
+ return `${type}(${taskSpec.id.toLowerCase()}): ${taskSpec.title}\n\nFeature: ${taskSpec.feature_id}\nTask: ${taskSpec.id}\nAcceptance: ${taskSpec.acceptance_refs.join(", ")}\nAgent-Run: ${workerRunId}\nWorker-Run: ${workerRunId}`;
246
+ }
247
+ function assertSafeBranch(branch) {
248
+ if (!/^agent\/[a-z0-9][a-z0-9._-]*$/.test(branch) || branch.includes("..") || branch.endsWith("."))
249
+ throw new Error(`unsafe Feature branch name: ${branch}`);
250
+ }
251
+ async function assertClean(repoRoot) {
252
+ const statusText = await gitRaw(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
253
+ if (statusText.trim())
254
+ throw new Error(`Git worktree is not clean:\n${statusText.trim()}`);
255
+ }
256
+ async function assertTransactionPosition(repoRoot, record) {
257
+ const branch = await git(repoRoot, ["branch", "--show-current"]);
258
+ if (branch !== record.branch)
259
+ throw new Error(`current branch is outside the Feature transaction: expected ${record.branch}, got ${branch || "detached HEAD"}`);
260
+ const head = await git(repoRoot, ["rev-parse", "HEAD"]);
261
+ if (head !== record.lastCheckpoint)
262
+ throw new Error(`Feature branch HEAD does not match recorded checkpoint: expected ${record.lastCheckpoint}, got ${head}`);
263
+ }
264
+ async function assertCheckpointMetadata(repoRoot, commit, taskSpec, workerRunId) {
265
+ const message = await gitRaw(repoRoot, ["show", "-s", "--format=%B", commit]);
266
+ for (const trailer of [`Feature: ${taskSpec.feature_id}`, `Task: ${taskSpec.id}`, `Acceptance: ${taskSpec.acceptance_refs.join(", ")}`, `Worker-Run: ${workerRunId}`]) {
267
+ if (!message.split(/\r?\n/).includes(trailer))
268
+ throw new Error(`recorded checkpoint metadata mismatch: ${trailer}`);
269
+ }
270
+ }
271
+ function assertIgnoredBaselineUnchanged(baseline, current) {
272
+ const baselineByPath = new Map(baseline.map((entry) => [entry.path, entry.sha256]));
273
+ const changed = current.filter((entry) => baselineByPath.has(entry.path) && baselineByPath.get(entry.path) !== entry.sha256).map((entry) => entry.path);
274
+ const missing = baseline.filter((entry) => !current.some((candidate) => candidate.path === entry.path)).map((entry) => entry.path);
275
+ const added = current.filter((entry) => !baselineByPath.has(entry.path)).map((entry) => entry.path);
276
+ if (changed.length > 0 || missing.length > 0 || added.length > 0)
277
+ throw new Error(`ignored files changed during Git checkpoint: ${[...changed, ...missing, ...added].join(", ")}`);
278
+ }
279
+ async function readChanges(repoRoot) {
280
+ const raw = await gitRaw(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
281
+ const fields = raw.split("\0");
282
+ const changes = new Set();
283
+ for (let index = 0; index < fields.length; index += 1) {
284
+ const field = fields[index];
285
+ if (!field)
286
+ continue;
287
+ const status = field.slice(0, 2);
288
+ changes.add(field.slice(3).replace(/\\/g, "/"));
289
+ if (/[RC]/.test(status)) {
290
+ const source = fields[index + 1];
291
+ if (source)
292
+ changes.add(source.replace(/\\/g, "/"));
293
+ index += 1;
294
+ }
295
+ }
296
+ return [...changes];
297
+ }
298
+ async function listIgnoredFiles(repoRoot) {
299
+ const raw = await gitRaw(repoRoot, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard"]);
300
+ return raw.split("\0").filter((entry) => entry && !entry.startsWith(".harness/")).sort();
301
+ }
302
+ async function readIgnoredBaseline(repoRoot) {
303
+ const paths = await listIgnoredFiles(repoRoot);
304
+ return Promise.all(paths.map(async (relative) => {
305
+ const absolute = path.join(repoRoot, relative);
306
+ const info = await lstat(absolute);
307
+ const content = info.isSymbolicLink() ? `symlink:${await readlink(absolute)}` : await readFile(absolute);
308
+ return { path: relative, sha256: createHash("sha256").update(content).digest("hex") };
309
+ }));
310
+ }
311
+ function isSensitivePath(relative) {
312
+ return /(^|\/)(\.env(?:\.|$)|[^/]*\.(?:pem|key|p12|pfx))$/i.test(relative);
313
+ }
314
+ async function readOptionalRecord(filePath) {
315
+ try {
316
+ return gitTransactionRecordSchema.parse(JSON.parse(await readFile(filePath, "utf-8")));
317
+ }
318
+ catch (error) {
319
+ if (isNotFound(error))
320
+ return undefined;
321
+ throw error;
322
+ }
323
+ }
324
+ async function writeJsonAtomic(filePath, value) {
325
+ await mkdir(path.dirname(filePath), { recursive: true });
326
+ const temp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
327
+ try {
328
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
329
+ await rename(temp, filePath);
330
+ }
331
+ finally {
332
+ await unlink(temp).catch(() => { });
333
+ }
334
+ }
335
+ async function git(repoRoot, args) {
336
+ const result = await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
337
+ return result.stdout.trim();
338
+ }
339
+ async function gitRaw(repoRoot, args) {
340
+ const result = await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
341
+ return result.stdout;
342
+ }
343
+ async function gitOk(repoRoot, args) {
344
+ try {
345
+ await git(repoRoot, args);
346
+ return true;
347
+ }
348
+ catch {
349
+ return false;
350
+ }
351
+ }
352
+ function isNotFound(error) {
353
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
354
+ }