@tea-agent/loop-agent 0.26.5-beta.2 → 0.27.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 (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +13 -23
  4. package/dist/application/dag/args.js +7 -7
  5. package/dist/application/dag/generate-task-dag.js +2 -2
  6. package/dist/application/dag/run-dag.js +3 -4
  7. package/dist/application/dag/validate-dag.js +1 -1
  8. package/dist/application/task-lifecycle/advance.js +845 -0
  9. package/dist/application/task-lifecycle/gates.js +80 -0
  10. package/dist/application/task-lifecycle/index.js +7 -0
  11. package/dist/application/task-lifecycle/observe.js +395 -0
  12. package/dist/application/task-lifecycle/plan-transitions.js +224 -0
  13. package/dist/application/task-lifecycle/recommendations.js +180 -0
  14. package/dist/application/task-lifecycle/record.js +73 -0
  15. package/dist/application/task-lifecycle/types.js +1 -0
  16. package/dist/cli/command-definitions.js +14 -111
  17. package/dist/cli/help.js +6 -7
  18. package/dist/cli/program.js +26 -90
  19. package/dist/cli/update/policy.js +0 -1
  20. package/dist/commands/dag-final-verification.js +1 -1
  21. package/dist/commands/dag-init-hybrid.js +1 -1
  22. package/dist/commands/delegate.js +2 -2
  23. package/dist/commands/init.js +21 -19
  24. package/dist/commands/run-dag-progress.js +1 -1
  25. package/dist/commands/status.js +18 -17
  26. package/dist/commands/study-init.js +2 -2
  27. package/dist/commands/task-advance.js +335 -0
  28. package/dist/commands/task-contract.js +3 -5
  29. package/dist/commands/task-source-prepare.js +9 -4
  30. package/dist/commands/task-status.js +133 -0
  31. package/dist/governance/manifest-types.js +2 -2
  32. package/dist/shared/operator/capabilities.js +1358 -243
  33. package/dist/task/contract/adopt.js +1 -1
  34. package/dist/task/contract/apply.js +1 -1
  35. package/dist/task/contract/import-revision.js +1 -1
  36. package/dist/task/contract/recover.js +2 -2
  37. package/dist/task/read-model.js +18 -23
  38. package/dist/task/runtime.js +1 -1
  39. package/dist/task/source-prepare/completeness.js +1 -1
  40. package/dist/task/source-prepare/parse-intent.js +6 -1
  41. package/dist/task/source-prepare/prepare.js +23 -20
  42. package/dist/worker/console/operator-actions.js +288 -95
  43. package/dist/worker/console/recovery-cta.js +4 -4
  44. package/dist/worker/console/static/assets/{index-CSRIhuzh.js → index-CNO7n6qB.js} +1 -1
  45. package/dist/worker/console/static/index.html +1 -1
  46. package/dist/worker/materialize/harness-task-materializer.js +6 -5
  47. package/dist/worker/run-task/run-task.js +204 -112
  48. package/dist/worker/runner/run-ready.js +1 -1
  49. package/dist/workflows/dag/frontend-implementation-contract.js +6 -72
  50. package/dist/workflows/dag/frontend-prewrite-gate.js +4 -17
  51. package/dist/workflows/dag/init-hybrid.js +11 -66
  52. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  53. package/docs/templates/evaluation/agents-map-verbose-v0.md +3 -3
  54. package/docs/templates/harness.schema.json +2 -2
  55. package/docs/templates/init-managed-agents.md +13 -16
  56. package/docs/templates/production-readiness-checklist.md +3 -3
  57. package/harness.json +4 -4
  58. package/package.json +1 -1
  59. package/scripts/kb-bootstrap-init-skeleton.sh +2 -1
  60. package/scripts/kb-graph-incremental-prepare.mjs +2 -2
  61. package/skills/loop-agent/SKILL.md +18 -13
  62. package/skills/loop-agent/references/README.md +1 -1
  63. package/skills/loop-agent/references/command-reference.md +55 -84
  64. package/skills/loop-agent/references/harness-policy.md +19 -23
  65. package/skills/loop-agent/references/hybrid-dag.md +31 -33
  66. package/skills/loop-agent/references/long-running-loop.md +2 -2
  67. package/skills/loop-agent/references/one-shot-runs.md +4 -5
  68. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  69. package/skills/loop-agent/references/post-implementation-and-patterns.md +4 -4
  70. package/skills/loop-agent/references/source-and-plan-practice.md +45 -51
  71. package/skills/loop-agent/references/task-workflow.md +14 -15
@@ -0,0 +1,845 @@
1
+ import { access, copyFile, mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createTask, getTaskPaths, loadTaskConfig, } from "../../task/runtime.js";
4
+ import { importPrdDocument } from "../../task/source-references.js";
5
+ import { prepareTaskSource } from "../../task/source-prepare/index.js";
6
+ import { logicalRevisionForState, observeTaskContractState, recoverTaskContract, } from "../../task/contract/index.js";
7
+ import { generateTaskDagUseCase } from "../dag/generate-task-dag.js";
8
+ import { validateDagUseCase } from "../dag/validate-dag.js";
9
+ import { runDagUseCase } from "../dag/run-dag.js";
10
+ import { promoteRunEvidence, closeoutTask } from "../../records/promotion.js";
11
+ import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
12
+ import { assessDagRunLiveness, locateDagRun, readDagRunState, } from "../../workflows/dag/lifecycle.js";
13
+ import { buildGateBinding, buildWriteSetGate, controllerIdentityAnchor, gateMatches, hashCanonicalPayload, parseGateToken, } from "./gates.js";
14
+ import { appendTransition, emptyLifecycleRecord, loadLifecycleRecord, recordRunAssociation, saveLifecycleRecord, } from "./record.js";
15
+ import { collectNormalizedWriteSetFromSpec, hashDagSpecFileContent, observeTaskLifecycle, } from "./observe.js";
16
+ import { planTransitions, } from "./plan-transitions.js";
17
+ import { applyAcceptedRecommendations, buildTaskLifecycleRecommendations, } from "./recommendations.js";
18
+ const ATTENTION_LIVENESS = new Set([
19
+ "orphaned",
20
+ "stale",
21
+ "needs-attention",
22
+ "suspected-stall",
23
+ ]);
24
+ async function pathExists(filePath) {
25
+ try {
26
+ await access(filePath);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function hasEngineeringBoundary(input) {
34
+ return Boolean((input.allowedPaths && input.allowedPaths.length > 0) ||
35
+ (input.forbiddenPaths && input.forbiddenPaths.length > 0) ||
36
+ (input.verifyCommands && input.verifyCommands.length > 0) ||
37
+ input.fromText ||
38
+ input.fromDraftPath);
39
+ }
40
+ function uniqueTransitions(existing, extra) {
41
+ const set = new Set(existing);
42
+ for (const item of extra)
43
+ set.add(item);
44
+ return [...set];
45
+ }
46
+ function extractMeaningfulProgressAt(state) {
47
+ const activeNode = Object.values(state.nodes ?? {}).find((node) => node.status === "RUNNING");
48
+ if (activeNode) {
49
+ return (activeNode.lastMeaningfulProgressAt ??
50
+ activeNode.lastProviderActivityAt ??
51
+ activeNode.lastToolActivityAt ??
52
+ activeNode.lastOutputActivityAt ??
53
+ activeNode.lastActivityAt ??
54
+ activeNode.startedAt ??
55
+ null);
56
+ }
57
+ const latestFinished = Object.values(state.nodes ?? {})
58
+ .filter((node) => node.finishedAt || node.startedAt)
59
+ .sort((a, b) => String(b.finishedAt ?? b.startedAt ?? "").localeCompare(String(a.finishedAt ?? a.startedAt ?? "")))[0];
60
+ return (latestFinished?.lastMeaningfulProgressAt ??
61
+ latestFinished?.finishedAt ??
62
+ latestFinished?.startedAt ??
63
+ null);
64
+ }
65
+ function toRunAssociation(input) {
66
+ return {
67
+ runId: input.runId,
68
+ lifecycle: input.lifecycle,
69
+ runDir: input.runDir,
70
+ status: input.state.status,
71
+ failureCategory: input.failureCategory ?? input.state.failureCategory ?? null,
72
+ lastMeaningfulProgressAt: extractMeaningfulProgressAt(input.state),
73
+ source: input.source ?? "lifecycle-record",
74
+ };
75
+ }
76
+ async function buildCurrentGate(repoRoot, taskId) {
77
+ const paths = getTaskPaths(repoRoot, taskId);
78
+ if (!(await pathExists(paths.dagDraftPath)))
79
+ return null;
80
+ const raw = await readFile(paths.dagDraftPath, "utf-8");
81
+ let spec;
82
+ try {
83
+ spec = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ const contractState = await observeTaskContractState(repoRoot, taskId);
89
+ const identity = resolveRunningControllerIdentity();
90
+ let writeSet = collectNormalizedWriteSetFromSpec(spec);
91
+ if (writeSet.length === 0) {
92
+ try {
93
+ const config = await loadTaskConfig(repoRoot, taskId);
94
+ writeSet = config.allowedPaths ?? [];
95
+ }
96
+ catch {
97
+ // leave empty
98
+ }
99
+ }
100
+ const bound = buildGateBinding({
101
+ taskId,
102
+ contractRevision: logicalRevisionForState(contractState),
103
+ contractCanonicalHash: contractState.ref?.canonicalHash ??
104
+ contractState.observedCanonicalHash ??
105
+ null,
106
+ sourceBindingHash: spec.sourceBinding
107
+ ? hashCanonicalPayload(spec.sourceBinding)
108
+ : null,
109
+ dagSpecHash: hashDagSpecFileContent(raw),
110
+ writeSet,
111
+ controllerIdentityAnchor: controllerIdentityAnchor(identity
112
+ ? {
113
+ packageName: identity.packageName,
114
+ packageVersion: identity.packageVersion,
115
+ packageFingerprintValue: identity.packageFingerprint.value,
116
+ }
117
+ : null),
118
+ });
119
+ return buildWriteSetGate({ taskId, bound });
120
+ }
121
+ async function monitorRun(input) {
122
+ const started = Date.now();
123
+ while (Date.now() - started < input.timeoutMs) {
124
+ const located = await locateDagRun(input.repoRoot, input.runId);
125
+ if (!located) {
126
+ throw new Error(`run not found while monitoring: ${input.runId}`);
127
+ }
128
+ const state = await readDagRunState(located.runDir);
129
+ const liveness = assessDagRunLiveness({ state });
130
+ const meaningfulAt = extractMeaningfulProgressAt(state);
131
+ input.onProgress?.(`monitoring run ${input.runId} lifecycle=${located.lifecycle} status=${state.status} liveness=${liveness.status} meaningful=${meaningfulAt ?? "n/a"}`);
132
+ if (located.lifecycle === "completed" || located.lifecycle === "paused") {
133
+ return toRunAssociation({
134
+ runId: input.runId,
135
+ lifecycle: located.lifecycle,
136
+ runDir: located.runDir,
137
+ state,
138
+ });
139
+ }
140
+ if (state.status === "finished" || state.status === "failed") {
141
+ return toRunAssociation({
142
+ runId: input.runId,
143
+ lifecycle: located.lifecycle,
144
+ runDir: located.runDir,
145
+ state,
146
+ });
147
+ }
148
+ if (ATTENTION_LIVENESS.has(liveness.status)) {
149
+ return toRunAssociation({
150
+ runId: input.runId,
151
+ lifecycle: located.lifecycle,
152
+ runDir: located.runDir,
153
+ state,
154
+ failureCategory: state.failureCategory ??
155
+ (liveness.status === "orphaned"
156
+ ? "orphaned"
157
+ : liveness.status === "stale"
158
+ ? "stale-heartbeat"
159
+ : liveness.status),
160
+ });
161
+ }
162
+ await new Promise((resolve) => setTimeout(resolve, input.intervalMs));
163
+ }
164
+ const located = await locateDagRun(input.repoRoot, input.runId);
165
+ if (!located) {
166
+ throw new Error(`run not found after monitor timeout: ${input.runId}`);
167
+ }
168
+ const state = await readDagRunState(located.runDir);
169
+ return toRunAssociation({
170
+ runId: input.runId,
171
+ lifecycle: located.lifecycle,
172
+ runDir: located.runDir,
173
+ state,
174
+ failureCategory: "monitor-timeout",
175
+ });
176
+ }
177
+ function nextMutationAction(actions) {
178
+ for (const action of actions) {
179
+ if (action.type !== "stop")
180
+ return action;
181
+ }
182
+ return actions[0] ?? null;
183
+ }
184
+ export async function advanceTaskLifecycle(input) {
185
+ const completed = [];
186
+ const blockers = [];
187
+ const warnings = [];
188
+ let promotion = null;
189
+ let closeout = null;
190
+ let run = null;
191
+ let consumeApproveGate = Boolean(input.approveGateToken);
192
+ /** After a successful approve, keep approve intent for finalize replan only. */
193
+ let approvedThisCall = false;
194
+ /**
195
+ * import-prd runs once per invocation when --prd is provided. Without this
196
+ * latch, re-plan keeps scheduling import-prd first (hasPrd stays true) and
197
+ * never reaches prepare-contract / gate.
198
+ */
199
+ let importedPrdThisCall = false;
200
+ let snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
201
+ let record = (await loadLifecycleRecord(input.repoRoot, input.taskId)) ??
202
+ emptyLifecycleRecord(input.taskId);
203
+ const hasPrd = Boolean(input.prdPaths && input.prdPaths.length > 0);
204
+ const dryRun = Boolean(input.dryRun);
205
+ // P1 deterministic recommendations (never auto-elevated without digest accept).
206
+ const prdTexts = [];
207
+ for (const prd of input.prdPaths ?? []) {
208
+ try {
209
+ prdTexts.push(await readFile(path.resolve(prd.filePath), "utf-8"));
210
+ }
211
+ catch {
212
+ // ignore unreadable PRD for recommendation purposes
213
+ }
214
+ }
215
+ const recommendations = await buildTaskLifecycleRecommendations({
216
+ repoRoot: input.repoRoot,
217
+ taskId: input.taskId,
218
+ prdTexts,
219
+ });
220
+ const applied = applyAcceptedRecommendations({
221
+ recommendations,
222
+ acceptDigest: input.acceptRecommendationsDigest,
223
+ explicitAllowedPaths: input.allowedPaths,
224
+ explicitVerifyCommands: input.verifyCommands,
225
+ });
226
+ if (applied.staleAccept) {
227
+ return {
228
+ taskId: input.taskId,
229
+ lifecycleState: snapshot.lifecycleState,
230
+ completedTransitions: record.completedTransitions,
231
+ gate: snapshot.gate,
232
+ blockers: [
233
+ {
234
+ code: "STALE_RECOMMENDATIONS",
235
+ message: "--accept-recommendations digest does not match current recommendations",
236
+ details: { expectedDigest: recommendations.digest },
237
+ },
238
+ ],
239
+ warnings,
240
+ next: {
241
+ kind: "advance",
242
+ command: `loop-agent task advance ${input.taskId} --json`,
243
+ },
244
+ dryRun,
245
+ recommendations,
246
+ };
247
+ }
248
+ const effectiveInput = {
249
+ ...input,
250
+ allowedPaths: applied.allowedPaths ?? input.allowedPaths,
251
+ verifyCommands: applied.verifyCommands ?? input.verifyCommands,
252
+ };
253
+ const hasBoundary = hasEngineeringBoundary(effectiveInput);
254
+ if (applied.accepted) {
255
+ warnings.push({
256
+ code: "RECOMMENDATIONS_ACCEPTED",
257
+ message: `accepted recommendations digest ${recommendations.digest}`,
258
+ });
259
+ const elevatedPaths = applied.allowedPaths?.length ?? 0;
260
+ if (elevatedPaths === 0) {
261
+ warnings.push({
262
+ code: "RECOMMENDATIONS_PATHS_STILL_REQUIRED",
263
+ message: "accepted recommendations elevated verifyCommands only; provide --allowed-path (PRD path tokens remain weak candidates and are not auto-elevated)",
264
+ });
265
+ }
266
+ }
267
+ // Reject gate path
268
+ if (input.rejectGateToken) {
269
+ if (!input.rejectReason?.trim()) {
270
+ return {
271
+ taskId: input.taskId,
272
+ lifecycleState: snapshot.lifecycleState,
273
+ completedTransitions: record.completedTransitions,
274
+ gate: snapshot.gate,
275
+ blockers: [
276
+ {
277
+ code: "REJECT_REASON_REQUIRED",
278
+ message: "--reason is required with --reject-gate",
279
+ },
280
+ ],
281
+ warnings,
282
+ next: snapshot.next,
283
+ recommendations,
284
+ };
285
+ }
286
+ const token = parseGateToken(input.rejectGateToken);
287
+ const currentGate = snapshot.gate ?? (await buildCurrentGate(input.repoRoot, input.taskId));
288
+ if (!currentGate || !gateMatches(currentGate, token)) {
289
+ return {
290
+ taskId: input.taskId,
291
+ lifecycleState: "awaiting-write-set-approval",
292
+ completedTransitions: record.completedTransitions,
293
+ gate: currentGate,
294
+ blockers: [
295
+ {
296
+ code: "STALE_GATE",
297
+ message: "reject gate digest does not match current snapshot",
298
+ },
299
+ ],
300
+ warnings,
301
+ next: currentGate
302
+ ? { kind: "approve-gate", command: currentGate.approveCommand }
303
+ : {
304
+ kind: "advance",
305
+ command: `loop-agent task advance ${input.taskId} --json`,
306
+ },
307
+ recommendations,
308
+ };
309
+ }
310
+ if (!dryRun) {
311
+ record = appendTransition(record, "write-set-rejected");
312
+ record = {
313
+ ...record,
314
+ lastGate: {
315
+ id: currentGate.id,
316
+ digest: currentGate.digest,
317
+ bound: currentGate.bound,
318
+ status: "rejected",
319
+ reason: input.rejectReason,
320
+ },
321
+ };
322
+ await saveLifecycleRecord(input.repoRoot, record);
323
+ completed.push("write-set-rejected");
324
+ }
325
+ return {
326
+ taskId: input.taskId,
327
+ lifecycleState: "intake-incomplete",
328
+ completedTransitions: uniqueTransitions(record.completedTransitions, completed),
329
+ gate: null,
330
+ blockers: [],
331
+ warnings,
332
+ next: {
333
+ kind: "advance",
334
+ command: `loop-agent task advance ${input.taskId} --json`,
335
+ },
336
+ dryRun,
337
+ recommendations,
338
+ };
339
+ }
340
+ const planOnce = (current) => planTransitions({
341
+ snapshot: current,
342
+ hasPrdInput: hasPrd && !importedPrdThisCall,
343
+ hasEngineeringBoundary: hasBoundary,
344
+ approveGate: consumeApproveGate || approvedThisCall,
345
+ rejectGate: false,
346
+ dryRun,
347
+ skipFinalize: Boolean(input.skipFinalize),
348
+ forcePrepareContract: Boolean(input.fromDraftPath),
349
+ });
350
+ if (dryRun) {
351
+ const plan = planOnce(snapshot);
352
+ const plannedGate = snapshot.gate ??
353
+ (snapshot.dagDraft?.exists
354
+ ? await buildCurrentGate(input.repoRoot, input.taskId)
355
+ : null);
356
+ return {
357
+ taskId: input.taskId,
358
+ lifecycleState: snapshot.lifecycleState,
359
+ completedTransitions: record.completedTransitions,
360
+ gate: plannedGate,
361
+ blockers: plan.blockers,
362
+ warnings,
363
+ next: plan.next,
364
+ dryRun: true,
365
+ plannedTransitions: plan.expectedTransitions,
366
+ recommendations,
367
+ };
368
+ }
369
+ // Re-plan after every successful transition until a stop condition.
370
+ const maxSteps = 32;
371
+ for (let step = 0; step < maxSteps; step += 1) {
372
+ const plan = planOnce(snapshot);
373
+ const action = nextMutationAction(plan.actions);
374
+ if (!action || action.type === "stop") {
375
+ if (action?.type === "stop" && action.reason === "blocker") {
376
+ const softCodes = new Set(["INTAKE_REQUIRED", "BOUNDARY_REQUIRED"]);
377
+ const soft = plan.blockers.filter((b) => softCodes.has(b.code));
378
+ if (soft.length > 0 &&
379
+ (completed.includes("task-created") ||
380
+ completed.includes("prd-imported"))) {
381
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
382
+ return {
383
+ taskId: input.taskId,
384
+ lifecycleState: "intake-incomplete",
385
+ completedTransitions: uniqueTransitions(record.completedTransitions, completed),
386
+ gate: null,
387
+ blockers: [],
388
+ warnings: [
389
+ ...warnings,
390
+ ...soft.map((b) => ({
391
+ code: b.code,
392
+ message: b.message,
393
+ })),
394
+ ],
395
+ next: plan.next,
396
+ recommendations,
397
+ };
398
+ }
399
+ blockers.push(...plan.blockers);
400
+ }
401
+ break;
402
+ }
403
+ if (action.type === "create-task") {
404
+ if (!snapshot.exists) {
405
+ await createTask(input.repoRoot, input.taskId, input.title);
406
+ record = appendTransition(record, "task-created");
407
+ completed.push("task-created");
408
+ await saveLifecycleRecord(input.repoRoot, record);
409
+ }
410
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
411
+ continue;
412
+ }
413
+ if (action.type === "recover-contract") {
414
+ await recoverTaskContract({
415
+ repoRoot: input.repoRoot,
416
+ taskId: input.taskId,
417
+ });
418
+ record = appendTransition(record, "contract-recovered");
419
+ completed.push("contract-recovered");
420
+ await saveLifecycleRecord(input.repoRoot, record);
421
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
422
+ continue;
423
+ }
424
+ if (action.type === "import-prd") {
425
+ for (const prd of input.prdPaths ?? []) {
426
+ await importPrdDocument({
427
+ repoRoot: input.repoRoot,
428
+ taskId: input.taskId,
429
+ filePath: prd.filePath,
430
+ role: prd.role,
431
+ });
432
+ }
433
+ // Latch even when prdPaths is empty so a mis-planned import cannot loop.
434
+ importedPrdThisCall = true;
435
+ if ((input.prdPaths?.length ?? 0) > 0) {
436
+ record = appendTransition(record, "prd-imported");
437
+ completed.push("prd-imported");
438
+ await saveLifecycleRecord(input.repoRoot, record);
439
+ }
440
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
441
+ continue;
442
+ }
443
+ if (action.type === "prepare-contract") {
444
+ let draftIntent = null;
445
+ if (input.fromDraftPath) {
446
+ try {
447
+ const draftRaw = await readFile(path.resolve(input.fromDraftPath), "utf-8");
448
+ draftIntent = {
449
+ kind: "draft",
450
+ draft: JSON.parse(draftRaw),
451
+ sourcePath: input.fromDraftPath,
452
+ };
453
+ }
454
+ catch (error) {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ blockers.push({
457
+ code: "INVALID_DRAFT",
458
+ message: `failed to parse --from-draft: ${message}`,
459
+ });
460
+ break;
461
+ }
462
+ }
463
+ const prepareResult = await prepareTaskSource({
464
+ repoRoot: input.repoRoot,
465
+ taskId: input.taskId,
466
+ mode: "apply",
467
+ intent: draftIntent
468
+ ? {
469
+ kind: "draft",
470
+ draft: draftIntent.draft,
471
+ sourcePath: draftIntent.sourcePath,
472
+ }
473
+ : {
474
+ kind: "facts",
475
+ useImportedPrd: hasPrd || (snapshot.source?.importedPrdCount ?? 0) > 0,
476
+ text: input.fromText,
477
+ textLabel: input.fromText ? "from-text" : undefined,
478
+ },
479
+ flags: {
480
+ title: input.title,
481
+ taskKind: input.taskKind,
482
+ featureId: input.featureId,
483
+ allowedPaths: effectiveInput.allowedPaths,
484
+ forbiddenPaths: input.forbiddenPaths,
485
+ verifyCommands: effectiveInput.verifyCommands,
486
+ },
487
+ });
488
+ if (!prepareResult.ok) {
489
+ blockers.push({
490
+ code: prepareResult.error?.code ?? "PREPARE_FAILED",
491
+ message: prepareResult.error?.message ?? "task source prepare failed",
492
+ details: {
493
+ gaps: prepareResult.gaps,
494
+ outcome: prepareResult.outcome,
495
+ },
496
+ });
497
+ break;
498
+ }
499
+ record = appendTransition(record, "contract-managed");
500
+ completed.push("contract-managed");
501
+ await saveLifecycleRecord(input.repoRoot, record);
502
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
503
+ continue;
504
+ }
505
+ if (action.type === "generate-dag") {
506
+ try {
507
+ const profile = input.profile ?? "auto";
508
+ const generated = await generateTaskDagUseCase({
509
+ repoRoot: input.repoRoot,
510
+ taskId: input.taskId,
511
+ strictModels: input.strictModels ?? true,
512
+ execute: false,
513
+ initOnly: false,
514
+ dryRun: false,
515
+ cwd: input.repoRoot,
516
+ profile,
517
+ profileExplicit: input.profileExplicit ??
518
+ Boolean(input.profile && input.profile !== "auto"),
519
+ ...(input.maxConcurrent !== undefined
520
+ ? { maxConcurrent: input.maxConcurrent }
521
+ : {}),
522
+ });
523
+ if (!("outputPath" in generated)) {
524
+ blockers.push({
525
+ code: "DAG_GENERATE_FAILED",
526
+ message: "dag generation did not produce an output path",
527
+ });
528
+ break;
529
+ }
530
+ if (input.dagOutputPath) {
531
+ await mkdir(path.dirname(input.dagOutputPath), { recursive: true });
532
+ await copyFile(generated.outputPath, input.dagOutputPath);
533
+ }
534
+ }
535
+ catch (error) {
536
+ const message = error instanceof Error ? error.message : String(error);
537
+ blockers.push({
538
+ code: "DAG_GENERATE_FAILED",
539
+ message,
540
+ });
541
+ break;
542
+ }
543
+ record = appendTransition(record, "dag-generated");
544
+ completed.push("dag-generated");
545
+ await saveLifecycleRecord(input.repoRoot, record);
546
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
547
+ continue;
548
+ }
549
+ if (action.type === "strict-validate-dag") {
550
+ try {
551
+ const paths = getTaskPaths(input.repoRoot, input.taskId);
552
+ const validation = await validateDagUseCase({
553
+ repoRoot: input.repoRoot,
554
+ dagPath: paths.dagDraftPath,
555
+ strictModelMatrix: input.strictModels ?? true,
556
+ strictGovernance: true,
557
+ forbidExecutors: [],
558
+ });
559
+ const raw = await readFile(paths.dagDraftPath, "utf-8");
560
+ const specHash = hashDagSpecFileContent(raw);
561
+ record = appendTransition(record, "dag-strict-validated");
562
+ record = {
563
+ ...record,
564
+ lastDagSpecHash: specHash,
565
+ strictValidatedAt: new Date().toISOString(),
566
+ };
567
+ completed.push("dag-strict-validated");
568
+ if (validation.warnings?.length) {
569
+ for (const warning of validation.warnings) {
570
+ warnings.push({
571
+ code: "DAG_VALIDATE_WARNING",
572
+ message: "message" in warning
573
+ ? String(warning.message)
574
+ : JSON.stringify(warning),
575
+ });
576
+ }
577
+ }
578
+ await saveLifecycleRecord(input.repoRoot, record);
579
+ }
580
+ catch (error) {
581
+ const message = error instanceof Error ? error.message : String(error);
582
+ blockers.push({
583
+ code: "DAG_VALIDATE_FAILED",
584
+ message,
585
+ });
586
+ break;
587
+ }
588
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
589
+ continue;
590
+ }
591
+ if (action.type === "open-write-set-gate") {
592
+ const gate = await buildCurrentGate(input.repoRoot, input.taskId);
593
+ if (!gate) {
594
+ blockers.push({
595
+ code: "GATE_UNAVAILABLE",
596
+ message: "unable to build write-set gate; ensure DAG draft exists",
597
+ });
598
+ break;
599
+ }
600
+ record = appendTransition(record, "write-set-gate-opened");
601
+ record = {
602
+ ...record,
603
+ lastGate: {
604
+ id: gate.id,
605
+ digest: gate.digest,
606
+ bound: gate.bound,
607
+ status: "open",
608
+ },
609
+ };
610
+ completed.push("write-set-gate-opened");
611
+ await saveLifecycleRecord(input.repoRoot, record);
612
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
613
+ // Stop before writers.
614
+ return {
615
+ taskId: input.taskId,
616
+ lifecycleState: "awaiting-write-set-approval",
617
+ completedTransitions: uniqueTransitions(record.completedTransitions, completed),
618
+ gate,
619
+ blockers,
620
+ warnings,
621
+ next: {
622
+ kind: "approve-gate",
623
+ command: gate.approveCommand,
624
+ },
625
+ recommendations,
626
+ };
627
+ }
628
+ if (action.type === "start-or-monitor-run") {
629
+ // Validate approval digest if provided.
630
+ if (consumeApproveGate && input.approveGateToken) {
631
+ const token = parseGateToken(input.approveGateToken);
632
+ const currentGate = snapshot.gate ??
633
+ (await buildCurrentGate(input.repoRoot, input.taskId));
634
+ if (!currentGate || !gateMatches(currentGate, token)) {
635
+ const freshGate = currentGate ??
636
+ (await buildCurrentGate(input.repoRoot, input.taskId));
637
+ return {
638
+ taskId: input.taskId,
639
+ lifecycleState: "awaiting-write-set-approval",
640
+ completedTransitions: uniqueTransitions(record.completedTransitions, completed),
641
+ gate: freshGate,
642
+ blockers: [
643
+ {
644
+ code: "STALE_GATE",
645
+ message: "approve gate digest does not match current contract/DAG/writeSet/controller binding",
646
+ },
647
+ ],
648
+ warnings,
649
+ next: freshGate
650
+ ? { kind: "approve-gate", command: freshGate.approveCommand }
651
+ : {
652
+ kind: "advance",
653
+ command: `loop-agent task advance ${input.taskId} --json`,
654
+ },
655
+ recommendations,
656
+ };
657
+ }
658
+ record = appendTransition(record, "write-set-approved");
659
+ record = {
660
+ ...record,
661
+ lastGate: {
662
+ id: currentGate.id,
663
+ digest: currentGate.digest,
664
+ bound: currentGate.bound,
665
+ status: "approved",
666
+ },
667
+ };
668
+ completed.push("write-set-approved");
669
+ await saveLifecycleRecord(input.repoRoot, record);
670
+ consumeApproveGate = false;
671
+ approvedThisCall = true;
672
+ }
673
+ // If already active, only monitor.
674
+ if (snapshot.activeRun) {
675
+ run = await monitorRun({
676
+ repoRoot: input.repoRoot,
677
+ runId: snapshot.activeRun.runId,
678
+ intervalMs: input.monitorIntervalMs ?? 5_000,
679
+ timeoutMs: input.monitorTimeoutMs ?? 24 * 60 * 60 * 1000,
680
+ onProgress: input.onProgress,
681
+ });
682
+ record = appendTransition(record, "dag-run-monitored");
683
+ record = recordRunAssociation(record, {
684
+ runId: run.runId,
685
+ source: "lifecycle-record",
686
+ });
687
+ completed.push("dag-run-monitored");
688
+ if (run.failureCategory === "monitor-timeout" ||
689
+ run.failureCategory === "orphaned" ||
690
+ run.failureCategory === "stale-heartbeat" ||
691
+ run.failureCategory === "needs-attention" ||
692
+ run.failureCategory === "suspected-stall" ||
693
+ run.failureCategory === "termination-unconfirmed") {
694
+ await saveLifecycleRecord(input.repoRoot, record);
695
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
696
+ blockers.push({
697
+ code: "RUN_NEEDS_ATTENTION",
698
+ message: `active run ${run.runId} requires attention (${run.failureCategory})`,
699
+ details: {
700
+ runId: run.runId,
701
+ failureCategory: run.failureCategory,
702
+ status: run.status,
703
+ },
704
+ });
705
+ break;
706
+ }
707
+ if (run.lifecycle === "completed" || run.status === "finished") {
708
+ if (run.status === "finished") {
709
+ record = appendTransition(record, "run-succeeded");
710
+ completed.push("run-succeeded");
711
+ }
712
+ else {
713
+ record = appendTransition(record, "run-failed");
714
+ completed.push("run-failed");
715
+ }
716
+ }
717
+ await saveLifecycleRecord(input.repoRoot, record);
718
+ }
719
+ else {
720
+ const paths = getTaskPaths(input.repoRoot, input.taskId);
721
+ const result = await runDagUseCase({
722
+ repoRoot: input.repoRoot,
723
+ dagPath: paths.dagDraftPath,
724
+ cwd: input.repoRoot,
725
+ initOnly: false,
726
+ dryRun: false,
727
+ ...(input.maxConcurrent !== undefined
728
+ ? { maxConcurrent: input.maxConcurrent }
729
+ : {}),
730
+ ...(input.runId ? { runId: input.runId } : {}),
731
+ ...(input.eventsJsonlPath
732
+ ? { eventsJsonlPath: input.eventsJsonlPath }
733
+ : {}),
734
+ ...(input.workerAssociation
735
+ ? { workerAssociation: input.workerAssociation }
736
+ : {}),
737
+ });
738
+ const runId = result.runId;
739
+ record = appendTransition(record, "dag-run-started");
740
+ record = recordRunAssociation(record, {
741
+ runId,
742
+ source: "lifecycle-record",
743
+ });
744
+ completed.push("dag-run-started");
745
+ await saveLifecycleRecord(input.repoRoot, record);
746
+ // runDagUseCase already runs to completion for normal path; still normalize association.
747
+ const located = await locateDagRun(input.repoRoot, runId);
748
+ if (located) {
749
+ const state = await readDagRunState(located.runDir);
750
+ run = toRunAssociation({
751
+ runId,
752
+ lifecycle: located.lifecycle,
753
+ runDir: located.runDir,
754
+ state,
755
+ });
756
+ }
757
+ else {
758
+ run = {
759
+ runId,
760
+ lifecycle: "completed",
761
+ runDir: "",
762
+ status: "status" in result ? String(result.status) : "unknown",
763
+ source: "lifecycle-record",
764
+ };
765
+ }
766
+ record = appendTransition(record, "dag-run-monitored");
767
+ completed.push("dag-run-monitored");
768
+ if (run.status === "finished") {
769
+ record = appendTransition(record, "run-succeeded");
770
+ completed.push("run-succeeded");
771
+ }
772
+ else {
773
+ record = appendTransition(record, "run-failed");
774
+ completed.push("run-failed");
775
+ }
776
+ await saveLifecycleRecord(input.repoRoot, record);
777
+ }
778
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
779
+ // Re-plan so promote/closeout can run in the same call after success.
780
+ continue;
781
+ }
782
+ if (action.type === "promote") {
783
+ const runId = run?.runId ?? snapshot.latestRun?.runId ?? snapshot.activeRun?.runId;
784
+ if (!runId) {
785
+ blockers.push({
786
+ code: "PROMOTE_RUN_MISSING",
787
+ message: "no associated run id for promotion",
788
+ });
789
+ break;
790
+ }
791
+ const promoted = await promoteRunEvidence(input.repoRoot, input.taskId, runId);
792
+ promotion = {
793
+ runId: promoted.runId,
794
+ modifyLogPath: promoted.modifyLogPath,
795
+ verifyResultPath: promoted.verifyResultPath,
796
+ };
797
+ record = appendTransition(record, "promotion-completed");
798
+ record = {
799
+ ...record,
800
+ promotion: {
801
+ runId: promoted.runId,
802
+ completedAt: new Date().toISOString(),
803
+ },
804
+ };
805
+ completed.push("promotion-completed");
806
+ await saveLifecycleRecord(input.repoRoot, record);
807
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
808
+ continue;
809
+ }
810
+ if (action.type === "closeout") {
811
+ const closed = await closeoutTask(input.repoRoot, input.taskId);
812
+ closeout = { path: closed.progressPath };
813
+ record = appendTransition(record, "closeout-completed");
814
+ record = {
815
+ ...record,
816
+ closeout: {
817
+ path: closed.progressPath,
818
+ completedAt: new Date().toISOString(),
819
+ },
820
+ };
821
+ completed.push("closeout-completed");
822
+ await saveLifecycleRecord(input.repoRoot, record);
823
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
824
+ }
825
+ }
826
+ // Re-observe final snapshot
827
+ snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
828
+ const finalGate = snapshot.lifecycleState === "awaiting-write-set-approval"
829
+ ? (snapshot.gate ??
830
+ (await buildCurrentGate(input.repoRoot, input.taskId)))
831
+ : snapshot.gate;
832
+ return {
833
+ taskId: input.taskId,
834
+ lifecycleState: snapshot.lifecycleState,
835
+ completedTransitions: uniqueTransitions(record.completedTransitions, completed),
836
+ gate: finalGate,
837
+ blockers: blockers.length > 0 ? blockers : snapshot.blockers,
838
+ warnings,
839
+ next: snapshot.next,
840
+ run,
841
+ promotion,
842
+ closeout,
843
+ recommendations,
844
+ };
845
+ }