@tea-agent/loop-agent 0.9.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 (183) hide show
  1. package/AGENTS.md +8 -0
  2. package/CHANGELOG.md +92 -11
  3. package/README.md +69 -5
  4. package/dist/application/dag/args.js +4 -14
  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 +1 -1
  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/{executors/cursor-executor.js → sidecars/cursor-prompt/executor.js} +2 -42
  34. package/dist/sidecars/cursor-prompt/index.js +3 -0
  35. package/dist/sidecars/cursor-prompt/stream.js +121 -0
  36. package/dist/task/config-types.js +29 -12
  37. package/dist/task/delegate.js +9 -21
  38. package/dist/task/runtime.js +1 -2
  39. package/dist/worker/cli.js +243 -0
  40. package/dist/worker/closeout/apply.js +73 -0
  41. package/dist/worker/closeout/preview.js +30 -0
  42. package/dist/worker/delivery/final-verification.js +194 -0
  43. package/dist/worker/delivery/git-transaction.js +354 -0
  44. package/dist/worker/delivery/package.js +502 -0
  45. package/dist/worker/feature/decision-loader.js +68 -0
  46. package/dist/worker/feature/discover.js +14 -0
  47. package/dist/worker/feature/next-action.js +74 -0
  48. package/dist/worker/feature/reducer.js +133 -0
  49. package/dist/worker/feature/review.js +502 -0
  50. package/dist/worker/feature/run.js +365 -0
  51. package/dist/worker/feature/types.js +1 -0
  52. package/dist/worker/follow-up/approve.js +270 -0
  53. package/dist/worker/follow-up/factory.js +234 -0
  54. package/dist/worker/follow-up/paths.js +25 -0
  55. package/dist/worker/follow-up/policy.js +26 -0
  56. package/dist/worker/follow-up/schema.js +93 -0
  57. package/dist/worker/follow-up/store.js +96 -0
  58. package/dist/worker/loop-agent/loop-agent-client.js +329 -126
  59. package/dist/worker/metrics/projector.js +139 -0
  60. package/dist/worker/observability/read-model.js +282 -15
  61. package/dist/worker/observe/paths.js +17 -5
  62. package/dist/worker/observe/static/app.js +443 -61
  63. package/dist/worker/observe/static/index.html +3 -1
  64. package/dist/worker/observe/static/styles.css +85 -18
  65. package/dist/worker/pool/run-store.js +14 -2
  66. package/dist/worker/pool/validation.js +59 -0
  67. package/dist/worker/preflight.js +49 -1
  68. package/dist/worker/report/morning-report.js +41 -6
  69. package/dist/worker/run-task/run-task.js +23 -13
  70. package/dist/worker/runner/run-ready.js +89 -11
  71. package/dist/worker/task-spec/schema.js +0 -1
  72. package/dist/workflows/dag/convergence/controller.js +1 -1
  73. package/dist/workflows/dag/executor-registry.js +0 -2
  74. package/dist/workflows/dag/governance-profile.js +10 -0
  75. package/dist/workflows/dag/init-hybrid.js +601 -26
  76. package/dist/workflows/dag/lifecycle.js +146 -0
  77. package/dist/workflows/dag/node-execution.js +64 -7
  78. package/dist/workflows/dag/prompt.js +16 -0
  79. package/dist/workflows/dag/report.js +2 -0
  80. package/dist/workflows/dag/runner.js +176 -119
  81. package/dist/workflows/dag/scheduler.js +7 -2
  82. package/dist/workflows/dag/skill-snapshot.js +527 -0
  83. package/dist/workflows/dag/types.js +45 -9
  84. package/dist/workflows/dag/validate.js +5 -8
  85. package/dist/workflows/loop/actions/dag-action.js +0 -2
  86. package/dist/workflows/loop/actions/shared.js +1 -1
  87. package/dist/workflows/loop/actions.js +14 -31
  88. package/dist/workflows/loop/benchmark.js +1 -1
  89. package/dist/workflows/loop/index.js +1 -1
  90. package/dist/workflows/loop/policy/auto-policy.js +22 -14
  91. package/dist/workflows/loop/policy/path-patterns.js +13 -0
  92. package/docs/README.md +35 -16
  93. package/docs/agent-dag-recovery-playbook.md +1 -1
  94. package/docs/architecture/README.md +26 -0
  95. package/docs/architecture/dag-execution.md +134 -0
  96. package/docs/architecture/evolution.md +52 -0
  97. package/docs/architecture/facts-and-state.md +58 -0
  98. package/docs/architecture/runtime-boundaries.md +41 -15
  99. package/docs/architecture/system-overview.md +93 -0
  100. package/docs/architecture/worker-and-feature.md +81 -0
  101. package/docs/cursor-prompt-sidecar.md +36 -0
  102. package/docs/decisions/README.md +13 -1
  103. package/docs/design/README.md +39 -13
  104. package/docs/development-principles.md +1 -1
  105. package/docs/exec-plans/active/README.md +2 -2
  106. package/docs/exec-plans/completed/README.md +14 -1
  107. package/docs/feature-workflow.md +44 -4
  108. package/docs/init-surface.manifest.json +63 -1
  109. package/docs/loop-agent-harness.md +65 -3
  110. package/docs/progress/README.md +27 -0
  111. package/docs/reports/README.md +74 -8
  112. package/docs/skills/README.md +2 -1
  113. package/docs/skills/vetted-skill-registry.md +2 -1
  114. package/docs/templates/agent-dag-report.schema.json +4 -2
  115. package/docs/templates/agent-dag.base.json +0 -5
  116. package/docs/templates/agent-dag.final-verification.json +0 -5
  117. package/docs/templates/agent-dag.schema.json +1 -2
  118. package/docs/templates/agent-dag.supervised-implementation.json +1 -6
  119. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +131 -0
  120. package/docs/templates/backend-test-dag.json +213 -0
  121. package/docs/templates/backend-test-dag.retrospect.prompt.md +128 -0
  122. package/docs/templates/backend-test-dag.review-cases.prompt.md +85 -0
  123. package/docs/templates/frontend-design-contract.md +33 -0
  124. package/docs/templates/frontend-task-constraints.md +25 -0
  125. package/docs/templates/frontend-task-requirement.md +61 -0
  126. package/docs/templates/harness.schema.json +8 -5
  127. package/docs/templates/hybrid-dag.json +1 -6
  128. package/docs/templates/init-evolution-review.md +4 -2
  129. package/docs/templates/interactive-ui-round2-experiment.md +1 -1
  130. package/docs/templates/product-line/task.yaml +0 -1
  131. package/docs/templates/worker-dogfood-evidence.md +28 -0
  132. package/docs/templates/worker-dogfood-setup.md +20 -0
  133. package/docs/verification-matrix.md +17 -0
  134. package/examples/decision-gate-agent-dag.json +87 -33
  135. package/examples/example-dag.json +0 -5
  136. package/examples/hybrid-loop-agent-dag.json +0 -5
  137. package/harness.json +6 -11
  138. package/package.json +22 -44
  139. package/scripts/check-product-line-docs.sh +10 -3
  140. package/skills/agent-worker/SKILL.md +37 -0
  141. package/skills/agent-worker/references/agent-worker-operator.md +43 -0
  142. package/skills/frontend-design-review/SKILL.md +59 -0
  143. package/skills/frontend-design-review/references/review-checklist.md +37 -0
  144. package/skills/frontend-implementation/SKILL.md +48 -0
  145. package/skills/frontend-implementation/references/code-standards.md +34 -0
  146. package/skills/frontend-implementation/references/design-spec.md +46 -0
  147. package/skills/frontend-implementation/references/node-contracts.md +32 -0
  148. package/skills/frontend-review/SKILL.md +53 -0
  149. package/skills/frontend-review/references/review-findings.md +42 -0
  150. package/skills/frontend-verification/SKILL.md +40 -0
  151. package/skills/frontend-verification/references/verification-checklist.md +56 -0
  152. package/skills/grill-me/SKILL.md +10 -0
  153. package/skills/grill-with-docs/SKILL.md +88 -0
  154. package/skills/grill-with-docs/adr-format.md +47 -0
  155. package/skills/grill-with-docs/context-format.md +60 -0
  156. package/skills/init-capability-evolution/SKILL.md +1 -0
  157. package/skills/loop-agent/SKILL.md +11 -9
  158. package/skills/loop-agent/references/command-reference.md +28 -15
  159. package/skills/loop-agent/references/docs-converge.md +126 -0
  160. package/skills/loop-agent/references/harness-policy.md +7 -7
  161. package/skills/loop-agent/references/hybrid-dag.md +13 -15
  162. package/skills/loop-agent/references/long-running-loop.md +4 -6
  163. package/skills/loop-agent/references/multi-worktree.md +6 -6
  164. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  165. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +14 -11
  166. package/skills/using-git-worktrees/SKILL.md +215 -0
  167. package/dist/commands/cursor-worker.js +0 -43
  168. package/dist/cursor-worker-entry.js +0 -8
  169. package/dist/executors/cursor-artifacts.js +0 -33
  170. package/dist/executors/cursor-execution-log.js +0 -81
  171. package/dist/executors/cursor-executor-artifacts.js +0 -134
  172. package/dist/executors/cursor-run.js +0 -115
  173. package/dist/executors/cursor-tool.js +0 -94
  174. package/dist/executors/cursor-worker-client.js +0 -223
  175. package/dist/executors/cursor-worker-protocol.js +0 -18
  176. package/dist/executors/cursor-worker-server.js +0 -54
  177. package/dist/executors/cursor-worker.js +0 -3
  178. package/dist/executors/cursor.js +0 -6
  179. package/dist/executors/dag-cursor-executor.js +0 -87
  180. package/dist/workflows/loop/actions/cursor-fix.js +0 -191
  181. package/dist/workflows/loop/policy/cursor-fix-policy.js +0 -31
  182. package/docs/cursor-executor-usage.md +0 -25
  183. package/docs/dynamic-workflow-dag-engine-roadmap.md +0 -1749
@@ -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
+ }