@tea-agent/loop-agent 0.8.0 → 0.10.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 (74) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +51 -1
  3. package/README.md +20 -0
  4. package/dist/application/dag/args.js +9 -2
  5. package/dist/cli/command-definitions.js +7 -0
  6. package/dist/cli/program.js +6 -1
  7. package/dist/commands/dag-reconcile-run.js +118 -0
  8. package/dist/commands/init.js +12 -3
  9. package/dist/executors/shell-executor.js +74 -8
  10. package/dist/governance/manifest-types.js +4 -0
  11. package/dist/shared/reference-context.js +48 -22
  12. package/dist/task/config-types.js +1 -1
  13. package/dist/task/runtime.js +1 -1
  14. package/dist/worker/cli.js +216 -0
  15. package/dist/worker/closeout/apply.js +73 -0
  16. package/dist/worker/closeout/preview.js +30 -0
  17. package/dist/worker/delivery/final-verification.js +158 -0
  18. package/dist/worker/delivery/git-transaction.js +354 -0
  19. package/dist/worker/delivery/package.js +449 -0
  20. package/dist/worker/feature/decision-loader.js +68 -0
  21. package/dist/worker/feature/discover.js +14 -0
  22. package/dist/worker/feature/next-action.js +74 -0
  23. package/dist/worker/feature/reducer.js +133 -0
  24. package/dist/worker/feature/review.js +502 -0
  25. package/dist/worker/feature/run.js +313 -0
  26. package/dist/worker/feature/types.js +1 -0
  27. package/dist/worker/follow-up/approve.js +270 -0
  28. package/dist/worker/follow-up/factory.js +234 -0
  29. package/dist/worker/follow-up/paths.js +25 -0
  30. package/dist/worker/follow-up/policy.js +26 -0
  31. package/dist/worker/follow-up/schema.js +93 -0
  32. package/dist/worker/follow-up/store.js +96 -0
  33. package/dist/worker/loop-agent/loop-agent-client.js +51 -10
  34. package/dist/worker/metrics/projector.js +139 -0
  35. package/dist/worker/observability/read-model.js +256 -15
  36. package/dist/worker/observe/paths.js +17 -5
  37. package/dist/worker/observe/routes.js +78 -20
  38. package/dist/worker/observe/server.js +8 -6
  39. package/dist/worker/observe/static/app.js +1045 -177
  40. package/dist/worker/observe/static/index.html +70 -43
  41. package/dist/worker/observe/static/styles.css +553 -610
  42. package/dist/worker/pool/run-store.js +14 -2
  43. package/dist/worker/pool/validation.js +59 -0
  44. package/dist/worker/report/morning-report.js +41 -6
  45. package/dist/worker/run-task/run-task.js +1 -1
  46. package/dist/worker/runner/run-ready.js +19 -5
  47. package/dist/workflows/dag/init-hybrid.js +3 -1
  48. package/dist/workflows/dag/lifecycle.js +146 -0
  49. package/dist/workflows/dag/node-execution.js +3 -0
  50. package/dist/workflows/dag/prompt.js +16 -0
  51. package/dist/workflows/dag/report.js +2 -0
  52. package/dist/workflows/dag/runner.js +133 -104
  53. package/dist/workflows/dag/types.js +3 -0
  54. package/docs/README.md +21 -0
  55. package/docs/agent-dag-recovery-playbook.md +1 -1
  56. package/docs/architecture/runtime-boundaries.md +3 -2
  57. package/docs/design/README.md +13 -7
  58. package/docs/exec-plans/active/README.md +2 -2
  59. package/docs/exec-plans/completed/README.md +15 -0
  60. package/docs/loop-agent-harness.md +45 -2
  61. package/docs/progress/README.md +2 -0
  62. package/docs/reports/README.md +13 -0
  63. package/docs/templates/agent-dag-report.schema.json +5 -3
  64. package/docs/templates/harness.schema.json +7 -2
  65. package/docs/templates/init-evolution-review.md +4 -2
  66. package/docs/verification-matrix.md +7 -0
  67. package/harness.json +4 -3
  68. package/package.json +4 -2
  69. package/scripts/check-product-line-docs.sh +7 -3
  70. package/scripts/check-task-pool-root.sh +1 -1
  71. package/skills/init-capability-evolution/SKILL.md +1 -0
  72. package/skills/loop-agent/references/command-reference.md +21 -0
  73. package/skills/loop-agent/references/hybrid-dag.md +4 -3
  74. package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
@@ -17,6 +17,15 @@ import { getTaskPoolRoot, prepareTaskPoolRetry } from "./pool/run-store.js";
17
17
  import { taskSpecSchema } from "./task-spec/schema.js";
18
18
  import { validateTaskSpec } from "./task-spec/validate.js";
19
19
  import { validateFeatureTaskGraph } from "./task-graph/validate.js";
20
+ import { renderFeatureReview, reviewFeature } from "./feature/review.js";
21
+ import { renderFeatureRun, runFeature } from "./feature/run.js";
22
+ import { draftFollowUpDecision } from "./follow-up/factory.js";
23
+ import { approveFollowUp } from "./follow-up/approve.js";
24
+ import { prepareFeatureDelivery } from "./delivery/package.js";
25
+ import { previewFeatureCloseout, renderCloseoutPreview } from "./closeout/preview.js";
26
+ import { applyFeatureCloseout } from "./closeout/apply.js";
27
+ import { projectMonthlyMetrics } from "./metrics/projector.js";
28
+ import { runFeatureFinalVerification } from "./delivery/final-verification.js";
20
29
  export function buildAgentWorkerProgram() {
21
30
  const program = new Command();
22
31
  program
@@ -28,6 +37,129 @@ export function buildAgentWorkerProgram() {
28
37
  const batch = program.command("batch").description("Task Pool batch utilities");
29
38
  const report = program.command("report").description("Task Pool reporting utilities");
30
39
  const observe = program.command("observe").description("Observe UI server and snapshot utilities");
40
+ const feature = program.command("feature").description("Feature-level review and delivery workflow");
41
+ feature
42
+ .command("review")
43
+ .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
44
+ .requiredOption("--repo <repo-root>", "Target repo root")
45
+ .option("--json", "Emit stable JSON instead of the human summary")
46
+ .description("Derive Feature status, blockers, acceptance coverage, and next action")
47
+ .action(async (options) => {
48
+ const result = await reviewFeature({
49
+ featureDir: path.resolve(options.featureDir),
50
+ repoRoot: path.resolve(options.repo),
51
+ });
52
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderFeatureReview(result));
53
+ });
54
+ feature
55
+ .command("run")
56
+ .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
57
+ .requiredOption("--repo <repo-root>", "Target repo root")
58
+ .option("--dry-run", "Show validation, Ready tasks, steps, and expected artifacts without writes")
59
+ .option("--json", "Emit stable JSON instead of the human summary")
60
+ .option("--limit <count>", "Maximum Ready tasks (without checkpoint mode capped at 1)", "1")
61
+ .option("--git-mode <mode>", "Git mode: none or checkpoint", "none")
62
+ .option("--keep-failed-diff", "Keep a failed task diff and stop instead of restoring")
63
+ .option("--loop-agent-bin <bin>", "loop-agent binary", "loop-agent")
64
+ .option("--batch-run-id <id>", "Batch run id")
65
+ .option("--check-repo", "Run bash scripts/check-repo.sh during target repo preflight")
66
+ .option("--check-repo-command <command...>", "Override the check-repo preflight command")
67
+ .option("--quiet", "Suppress human-readable progress on stderr")
68
+ .option("--pi-model <model>", "Smoke override for pi executor nodes")
69
+ .description("Validate and advance one Ready Feature task through the existing Worker pipeline")
70
+ .action(async (options) => {
71
+ if (options.gitMode !== "none" && options.gitMode !== "checkpoint")
72
+ throw new Error("feature run --git-mode must be none or checkpoint");
73
+ if (options.keepFailedDiff && options.gitMode !== "checkpoint")
74
+ throw new Error("--keep-failed-diff requires --git-mode checkpoint");
75
+ const repoRoot = path.resolve(options.repo);
76
+ const batchRunId = options.batchRunId ?? buildBatchRunId(new Date());
77
+ const client = new LoopAgentClient({
78
+ loopAgentBin: options.loopAgentBin,
79
+ artifactRoot: path.join(getTaskPoolRoot(repoRoot), "artifacts", batchRunId),
80
+ });
81
+ let progress;
82
+ try {
83
+ progress = createCompositeProgressReporter({
84
+ quiet: options.quiet ?? options.dryRun ?? false,
85
+ eventStore: createRoutedWorkerEventStore(repoRoot),
86
+ context: { batchRunId },
87
+ });
88
+ }
89
+ catch {
90
+ progress = createProgressReporter({ quiet: options.quiet ?? options.dryRun ?? false });
91
+ }
92
+ const result = await runFeature({
93
+ featureDir: path.resolve(options.featureDir),
94
+ repoRoot,
95
+ client,
96
+ dryRun: options.dryRun ?? false,
97
+ limit: Number.parseInt(options.limit, 10),
98
+ batchRunId,
99
+ runCheckRepo: options.checkRepo ?? false,
100
+ ...(options.checkRepoCommand ? { checkRepoCommand: options.checkRepoCommand } : {}),
101
+ progress,
102
+ ...(options.piModel ? { piModel: options.piModel } : {}),
103
+ gitMode: options.gitMode === "checkpoint" ? "checkpoint" : "none",
104
+ keepFailedDiff: options.keepFailedDiff ?? false,
105
+ });
106
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderFeatureRun(result));
107
+ if (result.status === "failed" || result.status === "needs-action")
108
+ process.exitCode = 1;
109
+ });
110
+ feature
111
+ .command("verify-final")
112
+ .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
113
+ .requiredOption("--repo <repo-root>", "Target repo root")
114
+ .requiredOption("--task-id <id>", "Completed qa-execute TaskSpec to rerun as dedicated final verification")
115
+ .option("--loop-agent-bin <bin>", "loop-agent binary", "loop-agent")
116
+ .option("--json", "Emit stable JSON")
117
+ .description("Run an independent HEAD-bound final QA verification and project Delivery evidence")
118
+ .action(async (options) => {
119
+ const repoRoot = path.resolve(options.repo);
120
+ const client = new LoopAgentClient({ loopAgentBin: options.loopAgentBin, artifactRoot: path.join(getTaskPoolRoot(repoRoot), "artifacts", `final-verification-${Date.now()}`) });
121
+ const result = await runFeatureFinalVerification({ featureDir: path.resolve(options.featureDir), repoRoot, taskId: options.taskId, client });
122
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}\n`);
123
+ });
124
+ feature
125
+ .command("delivery")
126
+ .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
127
+ .requiredOption("--repo <repo-root>", "Target repo root")
128
+ .requiredOption("--qa-evidence <path>", "Repo-local QA pass evidence")
129
+ .requiredOption("--final-verification <path>", "Repo-local fresh final verification evidence")
130
+ .option("--waivers <path>", "Repo-local acceptance waiver decisions JSON")
131
+ .option("--dry-run", "Validate and show the Delivery Package plan without writes")
132
+ .option("--json", "Emit stable JSON")
133
+ .description("Validate checkpointed Feature evidence and prepare a local Delivery Package")
134
+ .action(async (options) => {
135
+ const result = await prepareFeatureDelivery({
136
+ featureDir: path.resolve(options.featureDir), repoRoot: path.resolve(options.repo),
137
+ qaEvidencePath: options.qaEvidence, finalVerificationPath: options.finalVerification,
138
+ ...(options.waivers ? { waiverPath: options.waivers } : {}), dryRun: options.dryRun ?? false,
139
+ });
140
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nDelivery: ${result.status}\nArtifacts: ${Object.values(result.artifacts).join(", ")}\nBlockers: ${result.blockers.join("; ") || "none"}\n`);
141
+ if (result.status === "blocked")
142
+ process.exitCode = 1;
143
+ });
144
+ feature
145
+ .command("closeout")
146
+ .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
147
+ .requiredOption("--repo <repo-root>", "Target repo root")
148
+ .option("--apply", "Apply Feature Closeout (reserved for M2-08)")
149
+ .option("--owner <owner>", "Closeout owner for apply")
150
+ .option("--json", "Emit stable JSON")
151
+ .description("Preview Feature Closeout gates without modifying the Feature Packet")
152
+ .action(async (options) => {
153
+ if (options.apply) {
154
+ const result = await applyFeatureCloseout({ featureDir: path.resolve(options.featureDir), repoRoot: path.resolve(options.repo), owner: options.owner ?? "" });
155
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
156
+ return;
157
+ }
158
+ const result = await previewFeatureCloseout({ featureDir: path.resolve(options.featureDir), repoRoot: path.resolve(options.repo) });
159
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderCloseoutPreview(result));
160
+ if (result.status === "blocked")
161
+ process.exitCode = 1;
162
+ });
31
163
  task
32
164
  .command("validate")
33
165
  .argument("<task-yaml>", "TaskSpec YAML file")
@@ -74,6 +206,58 @@ export function buildAgentWorkerProgram() {
74
206
  });
75
207
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
76
208
  });
209
+ task
210
+ .command("draft-followup")
211
+ .argument("<task-id>", "Failed parent Task id")
212
+ .requiredOption("--worker-run-id <id>", "Failed Worker run id")
213
+ .requiredOption("--repo <repo-root>", "Target repo root")
214
+ .requiredOption("--feature-dir <dir>", "Feature Packet directory")
215
+ .option("--json", "Emit JSON instead of the human summary")
216
+ .description("Create or reuse a classified Follow-up Task draft or human action card")
217
+ .action(async (taskId, options) => {
218
+ try {
219
+ const result = await draftFollowUpDecision({
220
+ repoRoot: path.resolve(options.repo),
221
+ featureDir: path.resolve(options.featureDir),
222
+ taskId,
223
+ workerRunId: options.workerRunId,
224
+ });
225
+ process.stdout.write(options.json
226
+ ? `${JSON.stringify(result, null, 2)}\n`
227
+ : result.kind === "TaskDraft"
228
+ ? `Follow-up: ${result.followUpId}\n结果: ${result.created ? "已生成任务草稿" : "复用现有任务草稿"}\n下一步: agent-worker feature approve-followup --feature-dir ${JSON.stringify(path.resolve(options.featureDir))} --followup-id ${result.followUpId} --repo ${JSON.stringify(path.resolve(options.repo))} --owner <owner>\n证据: ${result.draftPath}\n`
229
+ : `Follow-up: ${result.followUpId}\n结果: ${result.created ? "已生成人工行动卡" : "复用现有行动卡"}\n下一步: ${result.actionCard.command ?? result.actionCard.label}\n证据: ${result.draftPath}\n`);
230
+ }
231
+ catch (error) {
232
+ handleFollowUpCliError(error, options.json ?? false, "draft-followup");
233
+ }
234
+ });
235
+ feature
236
+ .command("approve-followup")
237
+ .requiredOption("--feature-dir <dir>", "Feature Packet directory")
238
+ .requiredOption("--followup-id <id>", "Follow-up approval id")
239
+ .requiredOption("--repo <repo-root>", "Target repo root")
240
+ .requiredOption("--owner <name>", "Human owner approving the Follow-up")
241
+ .option("--dry-run", "Validate baseline and show the transaction plan without writes")
242
+ .option("--json", "Emit JSON instead of the human summary")
243
+ .description("Approve a Follow-up and atomically add its TaskSpec to the Feature")
244
+ .action(async (options) => {
245
+ try {
246
+ const result = await approveFollowUp({
247
+ repoRoot: path.resolve(options.repo),
248
+ featureDir: path.resolve(options.featureDir),
249
+ followUpId: options.followupId,
250
+ owner: options.owner,
251
+ dryRun: options.dryRun ?? false,
252
+ });
253
+ process.stdout.write(options.json
254
+ ? `${JSON.stringify(result, null, 2)}\n`
255
+ : `Follow-up: ${result.followUpId}\n结果: ${result.status === "planned" ? "批准事务预览通过" : result.idempotent ? "已批准(幂等复用)" : "已批准并进入 Ready"}\n下一步: agent-worker feature review --feature-dir ${JSON.stringify(path.resolve(options.featureDir))} --repo ${JSON.stringify(path.resolve(options.repo))}\n证据: ${result.approvalPath}\n`);
256
+ }
257
+ catch (error) {
258
+ handleFollowUpCliError(error, options.json ?? false, "approve-followup");
259
+ }
260
+ });
77
261
  batch
78
262
  .command("run-ready")
79
263
  .requiredOption("--feature-dir <dir>", "Feature directory containing tasks/task-graph.yaml")
@@ -169,6 +353,16 @@ export function buildAgentWorkerProgram() {
169
353
  });
170
354
  process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
171
355
  });
356
+ report
357
+ .command("metrics")
358
+ .requiredOption("--repo <repo-root>", "Target repo root")
359
+ .requiredOption("--month <yyyy-mm>", "UTC reporting month")
360
+ .option("--json", "Emit the metrics JSON on stdout")
361
+ .description("Project monthly Feature delivery metrics to JSON and Markdown")
362
+ .action(async (options) => {
363
+ const result = await projectMonthlyMetrics({ repoRoot: path.resolve(options.repo), month: options.month });
364
+ process.stdout.write(options.json ? `${JSON.stringify(result.metrics, null, 2)}\n` : `${JSON.stringify({ ok: true, jsonPath: result.jsonPath, markdownPath: result.markdownPath }, null, 2)}\n`);
365
+ });
172
366
  return program;
173
367
  }
174
368
  export async function main(argv = process.argv) {
@@ -177,6 +371,28 @@ export async function main(argv = process.argv) {
177
371
  async function readTaskYaml(taskSpecPath) {
178
372
  return YAML.parse(await readFile(taskSpecPath, "utf-8"));
179
373
  }
374
+ function handleFollowUpCliError(error, json, action) {
375
+ const detail = error instanceof Error ? error.message : String(error);
376
+ if (!json)
377
+ throw error;
378
+ const code = detail.includes("only supports ProductBug")
379
+ ? "unsupported-failure-category"
380
+ : detail.includes("baseline changed")
381
+ ? "stale-feature-baseline"
382
+ : detail.includes("already exists") || detail.includes("collision")
383
+ ? "follow-up-conflict"
384
+ : detail.includes("evidence") || detail.includes("draft hash")
385
+ ? "follow-up-evidence-invalid"
386
+ : "follow-up-failed";
387
+ process.stdout.write(`${JSON.stringify({
388
+ schemaVersion: 1,
389
+ status: "failed",
390
+ action,
391
+ error: { code, message: detail },
392
+ nextAction: action === "draft-followup" ? "Inspect the failed Worker run and failure category." : "Regenerate and review the Follow-up draft before approving.",
393
+ }, null, 2)}\n`);
394
+ process.exitCode = 1;
395
+ }
180
396
  function isDirectRun() {
181
397
  const entry = process.argv[1];
182
398
  if (!entry)
@@ -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,158 @@
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 { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
8
+ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
9
+ import { validateFeatureTaskGraph } from "../task-graph/validate.js";
10
+ import { getRunsJsonlPath, getTaskPoolRoot, readJsonlFile, recordTaskPoolRun } from "../pool/run-store.js";
11
+ import { buildWorkerRunId, runTaskSpec } from "../run-task/run-task.js";
12
+ import { taskSpecSchema } from "../task-spec/schema.js";
13
+ import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
14
+ const execFileAsync = promisify(execFile);
15
+ export async function runFeatureFinalVerification(input) {
16
+ const repoRoot = await realpath(path.resolve(input.repoRoot));
17
+ const featureDir = await realpath(path.resolve(input.featureDir));
18
+ const relativeFeature = path.relative(repoRoot, featureDir);
19
+ if (!relativeFeature || relativeFeature.startsWith("..") || path.isAbsolute(relativeFeature))
20
+ throw new Error("Feature Packet must be inside the target repo");
21
+ const validation = await validateFeatureTaskGraph(featureDir);
22
+ if (!validation.ok)
23
+ throw new Error(`Feature Packet is invalid: ${validation.errors.map((error) => error.code).join(", ")}`);
24
+ const featureId = validation.featureId;
25
+ const record = gitTransactionRecordSchema.parse(JSON.parse(await readFile(transactionRecordPath(repoRoot, featureId), "utf-8")));
26
+ const branch = await git(repoRoot, ["branch", "--show-current"]);
27
+ const headSha = await git(repoRoot, ["rev-parse", "HEAD"]);
28
+ const status = await gitRaw(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
29
+ if (branch !== record.branch || headSha !== record.lastCheckpoint)
30
+ throw new Error("final verification requires the recorded Feature branch at lastCheckpoint");
31
+ if (status.trim())
32
+ throw new Error(`final verification requires a clean worktree:\n${status.trim()}`);
33
+ const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", "task-graph.yaml"), "utf-8")));
34
+ const node = graph.nodes.find((candidate) => candidate.id === input.taskId);
35
+ if (!node)
36
+ throw new Error(`final verification task is not in the Feature graph: ${input.taskId}`);
37
+ const taskSpecPath = path.join(featureDir, "tasks", node.task);
38
+ const taskSpec = taskSpecSchema.parse(YAML.parse(await readFile(taskSpecPath, "utf-8")));
39
+ if (taskSpec.type !== "qa-execute")
40
+ throw new Error(`final verification requires a qa-execute TaskSpec: ${input.taskId}`);
41
+ if (taskSpec.verify.commands.filter((command) => command.required).length === 0)
42
+ throw new Error("final verification TaskSpec has no required commands");
43
+ const verificationTaskSpec = {
44
+ ...taskSpec,
45
+ constraints: {
46
+ ...taskSpec.constraints,
47
+ allowed_paths: [],
48
+ hard_constraints: [...taskSpec.constraints.hard_constraints, "Dedicated final verification is read-only; do not modify repository files"],
49
+ },
50
+ };
51
+ const now = input.now ?? new Date();
52
+ let runs = await readJsonlFile(getRunsJsonlPath(repoRoot));
53
+ let finalRun = await reusableFinalRun(repoRoot, runs, taskSpec.id, featureId, record, now);
54
+ if (!finalRun) {
55
+ const workerRunId = buildWorkerRunId(`${input.taskId}-final`, now);
56
+ const result = await runTaskSpec({ repoRoot, taskSpec: verificationTaskSpec, taskSpecPath, client: input.client, now, workerRunId, skipSuccessFinalization: true });
57
+ if (result.status !== "succeeded")
58
+ throw new Error(`dedicated final verification failed: ${workerRunId}`);
59
+ const afterHead = await git(repoRoot, ["rev-parse", "HEAD"]);
60
+ const afterStatus = await gitRaw(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
61
+ if (afterHead !== headSha || afterStatus.trim())
62
+ throw new Error("dedicated final verification changed the Delivery HEAD or worktree");
63
+ finalRun = { schemaVersion: 1, batchRunId: `final-verification-${workerRunId}`, workerRunId, taskId: taskSpec.id, featureId, status: "succeeded", harnessTaskId: result.harnessTaskId, runRecordPath: result.runRecordPath, dagPath: result.dagPath, recordedAt: new Date().toISOString() };
64
+ await recordTaskPoolRun({ repoRoot, run: finalRun });
65
+ runs = [...runs, finalRun];
66
+ }
67
+ const allSpecs = new Map();
68
+ for (const graphNode of graph.nodes) {
69
+ allSpecs.set(graphNode.id, taskSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", graphNode.task), "utf-8"))));
70
+ }
71
+ const acceptance = acceptanceSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8")));
72
+ const qaRuns = latestSuccessfulQaRuns(runs, allSpecs, taskSpec.id, featureId);
73
+ if (qaRuns.length === 0)
74
+ throw new Error("no prior successful qa-execute runs are available for the QA aggregate");
75
+ const coveredAcIds = [...new Set(qaRuns.flatMap((run) => allSpecs.get(run.taskId)?.acceptance_refs ?? []))];
76
+ const requiredAcIds = acceptance.acceptance.filter((item) => item.priority === "must").map((item) => item.id);
77
+ for (const acId of requiredAcIds)
78
+ if (!coveredAcIds.includes(acId))
79
+ throw new Error(`QA aggregate does not cover required acceptance: ${acId}`);
80
+ const summaryRelative = path.join(".harness", "dag-runs", "completed", finalRun.workerRunId, "verify-shell", "result.summary.md").replace(/\\/g, "/");
81
+ const summaryAbsolute = path.join(repoRoot, ...summaryRelative.split("/"));
82
+ const summary = await readFile(summaryAbsolute);
83
+ const evidenceDir = path.join(getTaskPoolRoot(repoRoot), "evidence", featureId);
84
+ const qaEvidencePath = path.join(evidenceDir, "qa-pass.json");
85
+ const finalVerificationPath = path.join(evidenceDir, "final-verification.json");
86
+ await writeEvidencePairAtomic(evidenceDir, {
87
+ qa: { schemaVersion: 1, featureId, verdict: "passed", acIds: requiredAcIds, runs: qaRuns.map(runRef) },
88
+ final: { schemaVersion: 1, featureId, kind: "final-verification", status: "passed", headSha, run: runRef(finalRun), shellSummary: { path: summaryRelative, sha256: createHash("sha256").update(summary).digest("hex") } },
89
+ });
90
+ return { schemaVersion: 1, featureId, taskId: taskSpec.id, workerRunId: finalRun.workerRunId, headSha, qaEvidencePath: repoRef(repoRoot, qaEvidencePath), finalVerificationPath: repoRef(repoRoot, finalVerificationPath), qaRunCount: qaRuns.length };
91
+ }
92
+ export function latestSuccessfulQaRuns(runs, specs, excludedTaskId, featureId) {
93
+ const byTask = new Map();
94
+ for (const run of runs)
95
+ if (run.featureId === featureId && run.status === "succeeded" && run.taskId !== excludedTaskId && specs.get(run.taskId)?.type === "qa-execute")
96
+ byTask.set(run.taskId, run);
97
+ return [...byTask.values()].sort((a, b) => a.taskId.localeCompare(b.taskId));
98
+ }
99
+ async function reusableFinalRun(repoRoot, runs, taskId, featureId, record, now) {
100
+ const latestCheckpointAt = Math.max(...record.checkpoints.map((entry) => new Date(entry.createdAt).getTime()));
101
+ for (const run of [...runs].reverse()) {
102
+ if (run.taskId !== taskId || run.featureId !== featureId || run.status !== "succeeded" || !run.workerRunId.includes("-final-") || !run.runRecordPath)
103
+ continue;
104
+ const recordedAt = new Date(run.recordedAt).getTime();
105
+ if (recordedAt < latestCheckpointAt || now.getTime() - recordedAt > 24 * 60 * 60 * 1000)
106
+ continue;
107
+ try {
108
+ const canonical = JSON.parse(await readFile(run.runRecordPath, "utf-8"));
109
+ if (canonical.workerRunId !== run.workerRunId || canonical.businessId !== taskId || canonical.featureId !== featureId || canonical.status !== "succeeded")
110
+ continue;
111
+ await readFile(path.join(repoRoot, ".harness", "dag-runs", "completed", run.workerRunId, "verify-shell", "result.summary.md"));
112
+ return run;
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+ function runRef(run) { return { taskId: run.taskId, workerRunId: run.workerRunId, recordedAt: run.recordedAt }; }
121
+ function repoRef(repoRoot, absolute) { return path.relative(repoRoot, absolute).replace(/\\/g, "/"); }
122
+ export async function writeEvidencePairAtomic(evidenceDir, value, fs = { rm }) {
123
+ const parent = path.dirname(evidenceDir);
124
+ const staging = path.join(parent, `.evidence.${randomUUID()}.staging`);
125
+ const backup = path.join(parent, `.evidence.${randomUUID()}.backup`);
126
+ await mkdir(staging, { recursive: true });
127
+ await writeFile(path.join(staging, "qa-pass.json"), `${JSON.stringify(value.qa, null, 2)}\n`);
128
+ await writeFile(path.join(staging, "final-verification.json"), `${JSON.stringify(value.final, null, 2)}\n`);
129
+ let backedUp = false;
130
+ try {
131
+ await rename(evidenceDir, backup);
132
+ backedUp = true;
133
+ }
134
+ catch (error) {
135
+ if (!isNotFound(error))
136
+ throw error;
137
+ }
138
+ let installed = false;
139
+ try {
140
+ await rename(staging, evidenceDir);
141
+ installed = true;
142
+ }
143
+ catch (error) {
144
+ if (installed)
145
+ await fs.rm(evidenceDir, { recursive: true, force: true }).catch(() => { });
146
+ if (backedUp)
147
+ await rename(backup, evidenceDir).catch(() => { });
148
+ throw error;
149
+ }
150
+ finally {
151
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => { });
152
+ }
153
+ if (backedUp)
154
+ await fs.rm(backup, { recursive: true, force: true }).catch(() => { });
155
+ }
156
+ async function git(repoRoot, args) { return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout.trim(); }
157
+ async function gitRaw(repoRoot, args) { return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout; }
158
+ function isNotFound(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); }