@tea-agent/loop-agent 0.24.11-beta.0 → 0.25.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 (84) hide show
  1. package/CHANGELOG.md +28 -5
  2. package/dist/commands/init.js +16 -2
  3. package/dist/executors/dag-pi-executor.js +74 -13
  4. package/dist/executors/pi-executor.js +25 -7
  5. package/dist/executors/pi-prompt-transport.js +198 -0
  6. package/dist/executors/pi-sdk-executor.js +1 -1
  7. package/dist/executors/process-tree.js +33 -0
  8. package/dist/executors/shell-executor.js +332 -24
  9. package/dist/executors/shell-verification.js +4 -2
  10. package/dist/infrastructure/harness/task-store.js +31 -0
  11. package/dist/shared/operator/capabilities.js +6 -0
  12. package/dist/verification/maven/cache.js +142 -0
  13. package/dist/verification/maven/index.js +120 -0
  14. package/dist/verification/maven/plan-commands.js +421 -0
  15. package/dist/verification/maven/pom-static.js +136 -0
  16. package/dist/verification/maven/scope-resolve.js +153 -0
  17. package/dist/verification/maven/stale.js +130 -0
  18. package/dist/verification/maven/types.js +23 -0
  19. package/dist/verification/maven/workspace-graph.js +322 -0
  20. package/dist/worker/cli.js +236 -21
  21. package/dist/worker/delivery/final-verification.js +12 -0
  22. package/dist/worker/delivery/git-transaction.js +32 -7
  23. package/dist/worker/delivery/package.js +9 -5
  24. package/dist/worker/delivery/verification-bundle.js +26 -5
  25. package/dist/worker/feature/advance.js +301 -0
  26. package/dist/worker/feature/doctor.js +223 -0
  27. package/dist/worker/feature/next-action.js +11 -3
  28. package/dist/worker/feature/scaffold.js +798 -0
  29. package/dist/worker/observe/dag-run-artifacts.js +90 -0
  30. package/dist/worker/observe/node-input.js +444 -0
  31. package/dist/worker/observe/routes.js +17 -0
  32. package/dist/worker/observe/static/api.js +9 -0
  33. package/dist/worker/observe/static/constants.js +9 -0
  34. package/dist/worker/observe/static/state.js +14 -0
  35. package/dist/worker/observe/static/styles.css +74 -0
  36. package/dist/worker/observe/static/views/dag-inspector.js +371 -15
  37. package/dist/worker/outcomes/projector.js +5 -1
  38. package/dist/worker/runner/run-ready.js +41 -1
  39. package/dist/workflows/dag/backend-test-markdown-workflow.js +202 -9
  40. package/dist/workflows/dag/backend-test-result-contract.js +103 -0
  41. package/dist/workflows/dag/failure-category.js +5 -1
  42. package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
  43. package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
  44. package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
  45. package/dist/workflows/dag/init-hybrid.js +220 -32
  46. package/dist/workflows/dag/node-execution.js +3 -2
  47. package/dist/workflows/dag/reconcile-run.js +24 -0
  48. package/dist/workflows/dag/types.js +46 -0
  49. package/dist/workflows/dag/validate.js +19 -2
  50. package/docs/architecture/dag-execution.md +5 -1
  51. package/docs/architecture/runtime-boundaries.md +11 -1
  52. package/docs/architecture/worker-and-feature.md +2 -0
  53. package/docs/templates/backend-test-dag.json +3 -3
  54. package/docs/templates/product-line/README.md +17 -1
  55. package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
  56. package/docs/templates/product-line/scaffold-samples/README.md +36 -0
  57. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
  58. package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
  59. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
  60. package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
  61. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
  62. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
  63. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
  64. package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
  65. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
  66. package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
  67. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
  68. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
  69. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
  70. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
  71. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
  72. package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
  73. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
  74. package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
  75. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
  76. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
  77. package/examples/l5-report-coms-process-definition.html +322 -0
  78. package/package.json +1 -1
  79. package/skills/agent-worker/references/agent-worker-operator.md +84 -0
  80. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  81. package/skills/loop-agent/SKILL.md +8 -8
  82. package/skills/loop-agent/references/command-reference.md +14 -2
  83. package/skills/loop-agent/references/harness-policy.md +22 -0
  84. package/skills/loop-agent/references/task-workflow.md +5 -0
@@ -101,11 +101,12 @@ export async function finalizeGitTask(transaction, outcome) {
101
101
  await assertTransactionPosition(transaction.repoRoot, current);
102
102
  const currentIgnored = await readIgnoredBaseline(transaction.repoRoot);
103
103
  const ignoredBaselineByPath = new Map(current.ignoredBaseline.map((entry) => [entry.path, entry.sha256]));
104
- const changedBaselineIgnored = currentIgnored.filter((entry) => ignoredBaselineByPath.has(entry.path) && ignoredBaselineByPath.get(entry.path) !== entry.sha256).map((entry) => entry.path);
105
- const missingBaselineIgnored = current.ignoredBaseline.filter((entry) => !currentIgnored.some((candidate) => candidate.path === entry.path)).map((entry) => entry.path);
106
- if (changedBaselineIgnored.length > 0 || missingBaselineIgnored.length > 0) {
107
- throw new Error(`pre-existing ignored file changed during Git transaction: ${[...changedBaselineIgnored, ...missingBaselineIgnored].join(", ")}`);
108
- }
104
+ const changedBaselineIgnored = currentIgnored
105
+ .filter((entry) => ignoredBaselineByPath.has(entry.path) && ignoredBaselineByPath.get(entry.path) !== entry.sha256)
106
+ .map((entry) => entry.path);
107
+ const missingBaselineIgnored = current.ignoredBaseline
108
+ .filter((entry) => !currentIgnored.some((candidate) => candidate.path === entry.path))
109
+ .map((entry) => entry.path);
109
110
  const existing = current.checkpoints.find((entry) => entry.workerRunId === outcome.workerRunId);
110
111
  if (outcome.status === "reused" || existing) {
111
112
  if (!existing)
@@ -123,12 +124,28 @@ export async function finalizeGitTask(transaction, outcome) {
123
124
  const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
124
125
  if (outcome.status === "succeeded") {
125
126
  const allowedPaths = expandAllowedPathsForWorkflow(resolveWorkflow(outcome.taskSpec).workflow, outcome.taskSpec.constraints.allowed_paths);
127
+ const isAllowedEvidencePath = (entry) => !isSensitivePath(entry) &&
128
+ !isEphemeralToolCachePath(entry) &&
129
+ allowedPaths.some((glob) => matchesGlob(entry, glob));
130
+ // Pre-existing ignored evidence under allowed_paths may be rewritten by
131
+ // FINAL-VERIFY smoke / verify-final and must force-add into the checkpoint.
132
+ // Other baseline ignored mutations remain fail-closed (e.g. dist/, secrets).
133
+ const blockedBaselineChanged = changedBaselineIgnored.filter((entry) => !isAllowedEvidencePath(entry));
134
+ const blockedBaselineMissing = missingBaselineIgnored.filter((entry) => !isEphemeralToolCachePath(entry) && !isAllowedEvidencePath(entry));
135
+ if (blockedBaselineChanged.length > 0 || blockedBaselineMissing.length > 0) {
136
+ throw new Error(`pre-existing ignored file changed during Git transaction: ${[...blockedBaselineChanged, ...blockedBaselineMissing].join(", ")}`);
137
+ }
126
138
  // Evidence under allowed_paths may be gitignored for local convenience (e.g.
127
139
  // reports/welcome/** smoke JSON) but must still checkpoint for Task Pool Done.
128
140
  // Sensitive / out-of-scope ignored files remain fail-closed; ephemeral tool
129
141
  // caches neither block nor force-add.
130
142
  const forceAddIgnored = [];
131
143
  const blockedIgnored = [];
144
+ const rewrittenBaselineEvidence = changedBaselineIgnored.filter((entry) => isAllowedEvidencePath(entry));
145
+ for (const entry of rewrittenBaselineEvidence) {
146
+ if (!forceAddIgnored.includes(entry))
147
+ forceAddIgnored.push(entry);
148
+ }
132
149
  for (const entry of newIgnored) {
133
150
  if (isEphemeralToolCachePath(entry))
134
151
  continue;
@@ -137,7 +154,8 @@ export async function finalizeGitTask(transaction, outcome) {
137
154
  continue;
138
155
  }
139
156
  if (allowedPaths.some((glob) => matchesGlob(entry, glob))) {
140
- forceAddIgnored.push(entry);
157
+ if (!forceAddIgnored.includes(entry))
158
+ forceAddIgnored.push(entry);
141
159
  }
142
160
  else {
143
161
  blockedIgnored.push(entry);
@@ -166,7 +184,10 @@ export async function finalizeGitTask(transaction, outcome) {
166
184
  await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
167
185
  await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
168
186
  const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
169
- assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path)));
187
+ // force-add may promote allowed evidence from ignored → tracked; drop those
188
+ // paths from the post-commit baseline equality check.
189
+ const forceAdded = new Set(forceAddIgnored);
190
+ assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path) && !forceAdded.has(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path) && !forceAdded.has(entry.path)));
170
191
  }
171
192
  catch (error) {
172
193
  const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
@@ -199,6 +220,10 @@ export async function finalizeGitTask(transaction, outcome) {
199
220
  changedFiles: checkpointFiles,
200
221
  };
201
222
  }
223
+ // Failed / keep-diff paths still fail closed on unexpected baseline ignored mutations.
224
+ if (changedBaselineIgnored.length > 0 || missingBaselineIgnored.length > 0) {
225
+ throw new Error(`pre-existing ignored file changed during Git transaction: ${[...changedBaselineIgnored, ...missingBaselineIgnored].join(", ")}`);
226
+ }
202
227
  const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
203
228
  await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
204
229
  if (outcome.keepFailedDiff) {
@@ -284,11 +284,15 @@ export async function validateDeliveryPackage(input) {
284
284
  blockers.push(`Acceptance coverage status is not derivable: ${ac.id}`);
285
285
  if (JSON.stringify([...item.blockedBy].sort()) !== JSON.stringify([...blockedBy].sort()))
286
286
  blockers.push(`Acceptance blockers changed: ${ac.id}`);
287
- for (const ref of expectedRefs)
288
- if (!item.evidence.some((candidate) => candidate.path === ref.path && candidate.sha256 === ref.sha256))
289
- blockers.push(`Acceptance run evidence is missing: ${ac.id}`);
290
- if (qa?.data.acIds.includes(ac.id) && !item.evidence.some((candidate) => candidate.path === qa.ref.path && candidate.sha256 === qa.ref.sha256))
291
- blockers.push(`Acceptance QA evidence is missing: ${ac.id}`);
287
+ // partial / missing / blocked should-ACs intentionally omit full evidence lists
288
+ // (only must ACs covered by QA require run+QA hash binding).
289
+ if (expectedStatus === "covered") {
290
+ for (const ref of expectedRefs)
291
+ if (!item.evidence.some((candidate) => candidate.path === ref.path && candidate.sha256 === ref.sha256))
292
+ blockers.push(`Acceptance run evidence is missing: ${ac.id}`);
293
+ if (qa?.data.acIds.includes(ac.id) && !item.evidence.some((candidate) => candidate.path === qa.ref.path && candidate.sha256 === qa.ref.sha256))
294
+ blockers.push(`Acceptance QA evidence is missing: ${ac.id}`);
295
+ }
292
296
  for (const ref of item.evidence)
293
297
  if (!await hashedRefValid(repoRoot, ref))
294
298
  blockers.push(`Acceptance evidence changed: ${item.acId}`);
@@ -404,7 +404,23 @@ export async function verifyBundleOutcomes(repoRoot, bundle) {
404
404
  return false;
405
405
  if (JSON.stringify(verified.artifacts) !== JSON.stringify(outcome.artifacts))
406
406
  return false;
407
- if (!await verifyArtifactRefs(repoRoot, outcome.artifacts))
407
+ // Rehash only durable typed evidence. run_record/dag_json may be rewritten
408
+ // after outcome projection (worker-run-record appends report commands).
409
+ const evidenceArtifacts = outcome.artifacts.filter((artifact) => {
410
+ const kind = artifact.kind ?? "";
411
+ if (kind === "run_record" || kind === "dag_json")
412
+ return false;
413
+ if (kind === "backend-test-result" ||
414
+ kind === "frontend-test-result" ||
415
+ kind.includes("backend-test-result") ||
416
+ kind.includes("frontend-test-result")) {
417
+ return true;
418
+ }
419
+ // Unknown kinds: still rehash unless clearly infrastructure paths.
420
+ return (!artifact.path.includes("worker-run-record") &&
421
+ !artifact.path.includes("-dag.json"));
422
+ });
423
+ if (!await verifyArtifactRefs(repoRoot, evidenceArtifacts))
408
424
  return false;
409
425
  }
410
426
  return true;
@@ -417,9 +433,12 @@ export function verifyBundleTaskSpecBindings(bundle, specs) {
417
433
  return false;
418
434
  taskIds.add(outcome.taskId);
419
435
  const spec = specs.get(outcome.taskId);
436
+ // Typed verification tasks may be qa-execute (frontend-test) or qa-testcode
437
+ // (backend-test Markdown-first). Delivery binds on feature_id + workflow.
438
+ const typedVerificationTypes = new Set(["qa-execute", "qa-testcode"]);
420
439
  if (!spec ||
421
440
  spec.feature_id !== bundle.featureId ||
422
- spec.type !== "qa-execute" ||
441
+ !typedVerificationTypes.has(spec.type) ||
423
442
  spec.execution?.workflow !== outcome.workflow)
424
443
  return false;
425
444
  }
@@ -460,9 +479,11 @@ async function verifyArtifactRefs(repoRoot, artifacts) {
460
479
  return false;
461
480
  }
462
481
  for (const artifact of artifacts) {
463
- if (path.isAbsolute(artifact.path))
464
- return false;
465
- const lexical = path.resolve(canonicalRepoRoot, artifact.path);
482
+ // Accept absolute paths only when they resolve inside the repo (legacy
483
+ // envelopes may store abs paths). Prefer relative for new projections.
484
+ const lexical = path.isAbsolute(artifact.path)
485
+ ? path.resolve(artifact.path)
486
+ : path.resolve(canonicalRepoRoot, artifact.path);
466
487
  let resolved;
467
488
  let content;
468
489
  try {
@@ -0,0 +1,301 @@
1
+ import path from "node:path";
2
+ import { defaultFeatureEvidencePaths, runFeatureFinalVerification, } from "../delivery/final-verification.js";
3
+ import { prepareFeatureDelivery, } from "../delivery/package.js";
4
+ import { applyFeatureCloseout, } from "../closeout/apply.js";
5
+ import { previewFeatureCloseout, } from "../closeout/preview.js";
6
+ import { validateFeatureTaskGraph } from "../task-graph/validate.js";
7
+ import { reviewFeature } from "./review.js";
8
+ const defaultDeps = {
9
+ validateFeature: validateFeatureTaskGraph,
10
+ review: reviewFeature,
11
+ verifyFinal: runFeatureFinalVerification,
12
+ delivery: prepareFeatureDelivery,
13
+ previewCloseout: previewFeatureCloseout,
14
+ applyCloseout: applyFeatureCloseout,
15
+ };
16
+ /**
17
+ * ADR 0007 orchestration: verify-final → delivery → closeout preview[/apply].
18
+ * Does not run implementation tasks (use `feature run` for Ready writers).
19
+ */
20
+ export async function advanceFeature(input, deps = defaultDeps) {
21
+ const featureDir = path.resolve(input.featureDir);
22
+ const repoRoot = path.resolve(input.repoRoot);
23
+ const dryRun = input.dryRun ?? false;
24
+ const apply = Boolean(input.apply) && !dryRun;
25
+ const steps = [];
26
+ const blockers = [];
27
+ const started = Date.now();
28
+ const push = (id, status, message, from = started) => {
29
+ steps.push({
30
+ id,
31
+ status,
32
+ ...(message ? { message } : {}),
33
+ durationMs: Math.max(0, Date.now() - from),
34
+ });
35
+ };
36
+ const validateStarted = Date.now();
37
+ const validation = await deps.validateFeature(featureDir);
38
+ if (!validation.ok) {
39
+ push("validate", "failed", validation.errors.map((e) => e.code).join(", "), validateStarted);
40
+ return {
41
+ schemaVersion: 1,
42
+ featureId: validation.featureId || "unknown",
43
+ mode: dryRun ? "dry-run" : "executed",
44
+ status: "failed",
45
+ finalTaskId: null,
46
+ steps,
47
+ blockers: validation.errors.map((e) => e.message),
48
+ nextCommand: `agent-worker task validate-feature ${JSON.stringify(featureDir)}`,
49
+ };
50
+ }
51
+ push("validate", "completed", `featureId=${validation.featureId}`, validateStarted);
52
+ const featureId = validation.featureId;
53
+ const resolveStarted = Date.now();
54
+ let finalTaskId = input.taskId?.trim() || "";
55
+ if (!finalTaskId) {
56
+ const model = await deps.review({ featureDir, repoRoot });
57
+ finalTaskId = resolveFinalVerifyTaskId(model) ?? "";
58
+ }
59
+ if (!finalTaskId) {
60
+ push("resolve-final-task", "failed", "could not resolve FINAL-VERIFY task id", resolveStarted);
61
+ return {
62
+ schemaVersion: 1,
63
+ featureId,
64
+ mode: dryRun ? "dry-run" : "executed",
65
+ status: "failed",
66
+ finalTaskId: null,
67
+ steps,
68
+ blockers: [
69
+ "missing --task-id and no FINAL-VERIFY* task found in Feature review",
70
+ ],
71
+ nextCommand: `agent-worker feature review --feature-dir ${JSON.stringify(featureDir)} --repo ${JSON.stringify(repoRoot)}`,
72
+ };
73
+ }
74
+ push("resolve-final-task", "completed", finalTaskId, resolveStarted);
75
+ if (!input.client) {
76
+ push("verify-final", "failed", "loop-agent client required for verify-final");
77
+ return {
78
+ schemaVersion: 1,
79
+ featureId,
80
+ mode: dryRun ? "dry-run" : "executed",
81
+ status: "failed",
82
+ finalTaskId,
83
+ steps,
84
+ blockers: [
85
+ "feature advance requires --loop-agent-bin / client for verify-final",
86
+ ],
87
+ };
88
+ }
89
+ const verifyStarted = Date.now();
90
+ let verifyFinal;
91
+ try {
92
+ verifyFinal = await deps.verifyFinal({
93
+ featureDir,
94
+ repoRoot,
95
+ taskId: finalTaskId,
96
+ client: input.client,
97
+ controllerIdentity: input.controllerIdentity ?? input.client.getIdentity?.(),
98
+ ...(input.controllerExpectation
99
+ ? { controllerExpectation: input.controllerExpectation }
100
+ : {}),
101
+ evidenceMode: input.evidenceMode ?? "auto",
102
+ ...(input.now ? { now: input.now } : {}),
103
+ });
104
+ push("verify-final", "completed", `mode=${verifyFinal.evidenceMode ?? "qa-execute"}`, verifyStarted);
105
+ }
106
+ catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ push("verify-final", "failed", message, verifyStarted);
109
+ return {
110
+ schemaVersion: 1,
111
+ featureId,
112
+ mode: dryRun ? "dry-run" : "executed",
113
+ status: "failed",
114
+ finalTaskId,
115
+ steps,
116
+ blockers: [message],
117
+ nextCommand: `agent-worker feature verify-final --feature-dir ${JSON.stringify(featureDir)} --repo ${JSON.stringify(repoRoot)} --task-id ${JSON.stringify(finalTaskId)} --json`,
118
+ };
119
+ }
120
+ const defaults = defaultFeatureEvidencePaths(featureId);
121
+ const qaEvidencePath = input.qaEvidencePath ??
122
+ verifyFinal.qaEvidencePath ??
123
+ defaults.qaEvidencePath;
124
+ const finalVerificationPath = input.finalVerificationPath ??
125
+ verifyFinal.finalVerificationPath ??
126
+ defaults.finalVerificationPath;
127
+ const deliveryStarted = Date.now();
128
+ const delivery = await deps.delivery({
129
+ featureDir,
130
+ repoRoot,
131
+ qaEvidencePath,
132
+ finalVerificationPath,
133
+ ...(input.waiverPath ? { waiverPath: input.waiverPath } : {}),
134
+ dryRun,
135
+ ...(input.now ? { now: input.now } : {}),
136
+ });
137
+ if (delivery.status === "blocked") {
138
+ push("delivery", "failed", delivery.blockers.join("; ") || "blocked", deliveryStarted);
139
+ return {
140
+ schemaVersion: 1,
141
+ featureId,
142
+ mode: dryRun ? "dry-run" : "executed",
143
+ status: "blocked",
144
+ finalTaskId,
145
+ steps,
146
+ verifyFinal,
147
+ delivery,
148
+ blockers: delivery.blockers,
149
+ nextCommand: `agent-worker feature delivery --feature-dir ${JSON.stringify(featureDir)} --repo ${JSON.stringify(repoRoot)} --json`,
150
+ };
151
+ }
152
+ push("delivery", "completed", delivery.status, deliveryStarted);
153
+ const previewStarted = Date.now();
154
+ const closeoutPreview = await deps.previewCloseout({
155
+ featureDir,
156
+ repoRoot,
157
+ ...(input.now ? { now: input.now } : {}),
158
+ });
159
+ if (closeoutPreview.status !== "ready") {
160
+ const failedGates = closeoutPreview.gates
161
+ .filter((g) => !g.passed)
162
+ .map((g) => `${g.id}: ${g.message}`);
163
+ push("closeout-preview", "failed", failedGates.join("; ") || "blocked", previewStarted);
164
+ blockers.push(...failedGates);
165
+ return {
166
+ schemaVersion: 1,
167
+ featureId,
168
+ mode: dryRun ? "dry-run" : "executed",
169
+ status: "blocked",
170
+ finalTaskId,
171
+ steps,
172
+ verifyFinal,
173
+ delivery,
174
+ closeoutPreview,
175
+ blockers,
176
+ nextCommand: `agent-worker feature closeout --feature-dir ${JSON.stringify(featureDir)} --repo ${JSON.stringify(repoRoot)} --json`,
177
+ };
178
+ }
179
+ push("closeout-preview", "completed", "ready", previewStarted);
180
+ if (!apply) {
181
+ push("closeout-apply", "skipped", dryRun
182
+ ? "dry-run: closeout apply skipped"
183
+ : "pass --apply --owner to apply closeout");
184
+ const reviewAfter = await deps.review({ featureDir, repoRoot });
185
+ return {
186
+ schemaVersion: 1,
187
+ featureId,
188
+ mode: dryRun ? "dry-run" : "executed",
189
+ status: "ready",
190
+ finalTaskId,
191
+ steps,
192
+ verifyFinal,
193
+ delivery,
194
+ closeoutPreview,
195
+ reviewAfter: {
196
+ status: reviewAfter.status,
197
+ statusLabel: reviewAfter.statusLabel,
198
+ nextAction: reviewAfter.nextAction,
199
+ },
200
+ blockers: [],
201
+ nextCommand: `agent-worker feature advance --feature-dir ${JSON.stringify(featureDir)} --repo ${JSON.stringify(repoRoot)} --task-id ${JSON.stringify(finalTaskId)} --apply --owner <owner>`,
202
+ };
203
+ }
204
+ if (!input.owner?.trim()) {
205
+ push("closeout-apply", "failed", "owner required for --apply");
206
+ return {
207
+ schemaVersion: 1,
208
+ featureId,
209
+ mode: "executed",
210
+ status: "failed",
211
+ finalTaskId,
212
+ steps,
213
+ verifyFinal,
214
+ delivery,
215
+ closeoutPreview,
216
+ blockers: ["feature advance --apply requires non-empty --owner"],
217
+ };
218
+ }
219
+ const applyStarted = Date.now();
220
+ try {
221
+ const closeoutApply = await deps.applyCloseout({
222
+ featureDir,
223
+ repoRoot,
224
+ owner: input.owner.trim(),
225
+ ...(input.now ? { now: input.now } : {}),
226
+ });
227
+ push("closeout-apply", "completed", closeoutApply.status, applyStarted);
228
+ const reviewAfter = await deps.review({ featureDir, repoRoot });
229
+ return {
230
+ schemaVersion: 1,
231
+ featureId,
232
+ mode: "executed",
233
+ status: "applied",
234
+ finalTaskId,
235
+ steps,
236
+ verifyFinal,
237
+ delivery,
238
+ closeoutPreview,
239
+ closeoutApply,
240
+ reviewAfter: {
241
+ status: reviewAfter.status,
242
+ statusLabel: reviewAfter.statusLabel,
243
+ nextAction: reviewAfter.nextAction,
244
+ },
245
+ blockers: [],
246
+ };
247
+ }
248
+ catch (error) {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ push("closeout-apply", "failed", message, applyStarted);
251
+ return {
252
+ schemaVersion: 1,
253
+ featureId,
254
+ mode: "executed",
255
+ status: "failed",
256
+ finalTaskId,
257
+ steps,
258
+ verifyFinal,
259
+ delivery,
260
+ closeoutPreview,
261
+ blockers: [message],
262
+ nextCommand: `agent-worker feature closeout --feature-dir ${JSON.stringify(featureDir)} --repo ${JSON.stringify(repoRoot)} --apply --owner ${JSON.stringify(input.owner)} --json`,
263
+ };
264
+ }
265
+ }
266
+ export function resolveFinalVerifyTaskId(model) {
267
+ const hint = /(final[-_]?(verify|verification))|closeout/i;
268
+ const byId = model.tasks.find((t) => hint.test(t.taskId));
269
+ if (byId)
270
+ return byId.taskId;
271
+ // Fallback: last Done-or-any task that depends on both test-ish types is not available here;
272
+ // review model lacks depends_on depth — require explicit task when id heuristic fails.
273
+ return null;
274
+ }
275
+ export function renderFeatureAdvance(result) {
276
+ const lines = [
277
+ `Feature: ${result.featureId}`,
278
+ `Advance: ${result.status} (${result.mode})`,
279
+ `Final task: ${result.finalTaskId ?? "none"}`,
280
+ ...result.steps.map((s) => `${statusMark(s.status)} ${s.id}${s.message ? `: ${s.message}` : ""}`),
281
+ ];
282
+ if (result.blockers.length) {
283
+ lines.push(`Blockers: ${result.blockers.join("; ")}`);
284
+ }
285
+ if (result.nextCommand) {
286
+ lines.push(`Next: ${result.nextCommand}`);
287
+ }
288
+ return `${lines.join("\n")}\n`;
289
+ }
290
+ function statusMark(status) {
291
+ switch (status) {
292
+ case "completed":
293
+ return "✓";
294
+ case "failed":
295
+ return "✗";
296
+ case "skipped":
297
+ return "·";
298
+ default:
299
+ return "○";
300
+ }
301
+ }