@tea-agent/loop-agent 0.26.5-beta.2 → 0.27.1-beta.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 (79) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +33 -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/executors/dag-pi-executor.js +18 -22
  32. package/dist/executors/shell-executor.js +41 -25
  33. package/dist/governance/manifest-types.js +2 -2
  34. package/dist/shared/operator/capabilities.js +1358 -243
  35. package/dist/task/contract/adopt.js +1 -1
  36. package/dist/task/contract/apply.js +1 -1
  37. package/dist/task/contract/import-revision.js +1 -1
  38. package/dist/task/contract/recover.js +2 -2
  39. package/dist/task/read-model.js +18 -23
  40. package/dist/task/runtime.js +1 -1
  41. package/dist/task/source-prepare/completeness.js +1 -1
  42. package/dist/task/source-prepare/parse-intent.js +6 -1
  43. package/dist/task/source-prepare/prepare.js +23 -20
  44. package/dist/worker/console/operator-actions.js +288 -95
  45. package/dist/worker/console/recovery-cta.js +4 -4
  46. package/dist/worker/console/static/assets/{index-CSRIhuzh.js → index-CNO7n6qB.js} +1 -1
  47. package/dist/worker/console/static/index.html +1 -1
  48. package/dist/worker/materialize/harness-task-materializer.js +6 -5
  49. package/dist/worker/run-task/run-task.js +204 -112
  50. package/dist/worker/runner/run-ready.js +1 -1
  51. package/dist/workflows/dag/backend-test-markdown-workflow.js +49 -23
  52. package/dist/workflows/dag/backend-test-pytest-collection.js +57 -9
  53. package/dist/workflows/dag/frontend-implementation-contract.js +3 -3
  54. package/dist/workflows/dag/frontend-prewrite-gate.js +18 -1
  55. package/dist/workflows/dag/frontend-test-l5-report.js +46 -7
  56. package/dist/workflows/dag/init-hybrid.js +6 -35
  57. package/dist/workflows/dag/output-protocol.js +23 -0
  58. package/docs/templates/backend-test-dag.json +2 -2
  59. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  60. package/docs/templates/evaluation/agents-map-verbose-v0.md +3 -3
  61. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  62. package/docs/templates/harness.schema.json +2 -2
  63. package/docs/templates/init-managed-agents.md +13 -16
  64. package/docs/templates/production-readiness-checklist.md +3 -3
  65. package/harness.json +1 -1
  66. package/package.json +1 -1
  67. package/scripts/kb-bootstrap-init-skeleton.sh +2 -1
  68. package/scripts/kb-graph-incremental-prepare.mjs +2 -2
  69. package/skills/loop-agent/SKILL.md +18 -13
  70. package/skills/loop-agent/references/README.md +1 -1
  71. package/skills/loop-agent/references/command-reference.md +55 -84
  72. package/skills/loop-agent/references/harness-policy.md +19 -23
  73. package/skills/loop-agent/references/hybrid-dag.md +32 -34
  74. package/skills/loop-agent/references/long-running-loop.md +2 -2
  75. package/skills/loop-agent/references/one-shot-runs.md +4 -5
  76. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  77. package/skills/loop-agent/references/post-implementation-and-patterns.md +4 -4
  78. package/skills/loop-agent/references/source-and-plan-practice.md +45 -51
  79. package/skills/loop-agent/references/task-workflow.md +14 -15
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ export function normalizeWriteSet(writeSet) {
3
+ return [
4
+ ...new Set(writeSet.map((entry) => entry.replace(/\\/g, "/").trim()).filter(Boolean)),
5
+ ].sort((a, b) => a.localeCompare(b));
6
+ }
7
+ export function stableStringify(value) {
8
+ if (value === null || typeof value !== "object") {
9
+ return JSON.stringify(value);
10
+ }
11
+ if (Array.isArray(value)) {
12
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
13
+ }
14
+ const record = value;
15
+ const keys = Object.keys(record).sort();
16
+ return `{${keys
17
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
18
+ .join(",")}}`;
19
+ }
20
+ export function sha256Hex(input) {
21
+ return createHash("sha256").update(input, "utf8").digest("hex");
22
+ }
23
+ export function hashCanonicalPayload(value) {
24
+ return `sha256:${sha256Hex(stableStringify(value))}`;
25
+ }
26
+ export function buildGateBinding(input) {
27
+ return {
28
+ taskId: input.taskId,
29
+ contractRevision: input.contractRevision,
30
+ contractCanonicalHash: input.contractCanonicalHash,
31
+ sourceBindingHash: input.sourceBindingHash,
32
+ dagSpecHash: input.dagSpecHash,
33
+ writeSet: normalizeWriteSet(input.writeSet),
34
+ controllerIdentityAnchor: input.controllerIdentityAnchor,
35
+ };
36
+ }
37
+ export function computeGateDigest(bound) {
38
+ return hashCanonicalPayload({
39
+ kind: "task-lifecycle-gate",
40
+ version: 1,
41
+ ...bound,
42
+ writeSet: normalizeWriteSet(bound.writeSet),
43
+ });
44
+ }
45
+ export function buildWriteSetGate(input) {
46
+ const digest = computeGateDigest(input.bound);
47
+ const id = "write-set-review";
48
+ return {
49
+ id,
50
+ type: "human-approval",
51
+ digest,
52
+ writeSet: normalizeWriteSet(input.bound.writeSet),
53
+ rationale: input.rationale ?? "validated DAG is ready; writer has not started",
54
+ approveCommand: `loop-agent task advance ${input.taskId} --approve-gate ${id}:${digest} --json`,
55
+ rejectCommand: `loop-agent task advance ${input.taskId} --reject-gate ${id}:${digest} --reason <text> --json`,
56
+ bound: input.bound,
57
+ };
58
+ }
59
+ export function parseGateToken(token) {
60
+ const separator = token.indexOf(":");
61
+ if (separator <= 0 || separator === token.length - 1) {
62
+ throw new Error(`invalid gate token ${token}; expected <gate-id>:<digest>`);
63
+ }
64
+ return {
65
+ id: token.slice(0, separator),
66
+ digest: token.slice(separator + 1),
67
+ };
68
+ }
69
+ export function gateMatches(gate, token) {
70
+ return gate.id === token.id && gate.digest === token.digest;
71
+ }
72
+ export function controllerIdentityAnchor(input) {
73
+ if (!input?.packageName || !input.packageVersion)
74
+ return null;
75
+ return [
76
+ input.packageName,
77
+ input.packageVersion,
78
+ input.packageFingerprintValue ?? "",
79
+ ].join("@");
80
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./gates.js";
3
+ export * from "./record.js";
4
+ export * from "./observe.js";
5
+ export * from "./plan-transitions.js";
6
+ export { buildTaskLifecycleRecommendations, applyAcceptedRecommendations, } from "./recommendations.js";
7
+ export * from "./advance.js";
@@ -0,0 +1,395 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getTaskDir, getTaskPaths, getTaskStatus, loadTaskConfig, } from "../../task/runtime.js";
4
+ import { logicalRevisionForState, observeTaskContractState, } from "../../task/contract/index.js";
5
+ import { assessDagRunLiveness, listAllDagRunEntries, readDagRunSpec, readDagRunState, } from "../../workflows/dag/lifecycle.js";
6
+ import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
7
+ import { loadLifecycleRecord } from "./record.js";
8
+ import { buildGateBinding, buildWriteSetGate, controllerIdentityAnchor, hashCanonicalPayload, normalizeWriteSet, sha256Hex, } from "./gates.js";
9
+ async function exists(filePath) {
10
+ try {
11
+ await access(filePath);
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function closeoutPath(repoRoot, taskId) {
19
+ const slug = taskId.replace(/^\d{4}-\d{2}-\d{2}-/, "");
20
+ const datePrefix = taskId.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ??
21
+ new Date().toISOString().slice(0, 10);
22
+ return path.join(repoRoot, ".harness", "task-pool", "artifacts", "closeout", `${datePrefix}-${slug}.md`);
23
+ }
24
+ function collectWriterWriteSet(spec) {
25
+ const defaults = spec.defaults?.writePolicy ?? "none";
26
+ const entries = [];
27
+ for (const task of spec.tasks) {
28
+ const writePolicy = task.writePolicy ?? defaults;
29
+ if (writePolicy !== "exclusive")
30
+ continue;
31
+ for (const entry of task.writeSet ?? []) {
32
+ if (typeof entry === "string" && entry.trim())
33
+ entries.push(entry);
34
+ }
35
+ }
36
+ return normalizeWriteSet(entries);
37
+ }
38
+ function hashDagSpecBytes(raw) {
39
+ return `sha256:${sha256Hex(raw)}`;
40
+ }
41
+ function hashSourceBinding(spec) {
42
+ if (!spec.sourceBinding)
43
+ return null;
44
+ return hashCanonicalPayload(spec.sourceBinding);
45
+ }
46
+ async function taskExists(repoRoot, taskId) {
47
+ const paths = getTaskPaths(repoRoot, taskId);
48
+ return exists(paths.taskConfigPath);
49
+ }
50
+ /**
51
+ * Explicit Task↔DAG association only:
52
+ * - lifecycle.json recorded runAssociations
53
+ * - DagSpec.taskContractBinding.taskId
54
+ * - DagSpec.sourceBinding.taskId
55
+ * - state.workerAssociation.taskId
56
+ *
57
+ * Never uses taskId substring or full JSON string includes.
58
+ */
59
+ export async function listExplicitlyAssociatedRuns(repoRoot, taskId) {
60
+ const record = await loadLifecycleRecord(repoRoot, taskId);
61
+ const recorded = new Map((record?.runAssociations ?? []).map((entry) => [entry.runId, entry.source]));
62
+ const entries = await listAllDagRunEntries(repoRoot);
63
+ const related = [];
64
+ for (const entry of entries) {
65
+ let source = recorded.get(entry.runId) ?? null;
66
+ let stateStatus;
67
+ let failureCategory;
68
+ let lastMeaningfulProgressAt;
69
+ try {
70
+ const state = await readDagRunState(entry.runDir);
71
+ stateStatus = state.status;
72
+ const liveness = assessDagRunLiveness({ state });
73
+ const activeNode = Object.values(state.nodes ?? {}).find((node) => node.status === "RUNNING");
74
+ lastMeaningfulProgressAt =
75
+ activeNode?.lastMeaningfulProgressAt ??
76
+ activeNode?.lastProviderActivityAt ??
77
+ activeNode?.lastToolActivityAt ??
78
+ activeNode?.lastOutputActivityAt ??
79
+ activeNode?.lastActivityAt ??
80
+ activeNode?.startedAt ??
81
+ null;
82
+ if (liveness.status === "orphaned" ||
83
+ liveness.status === "stale" ||
84
+ liveness.status === "needs-attention" ||
85
+ liveness.status === "suspected-stall") {
86
+ failureCategory =
87
+ state.failureCategory ??
88
+ (liveness.status === "orphaned"
89
+ ? "orphaned"
90
+ : liveness.status === "stale"
91
+ ? "stale-heartbeat"
92
+ : liveness.status);
93
+ }
94
+ else {
95
+ failureCategory = state.failureCategory ?? null;
96
+ }
97
+ const workerTaskId = state.workerAssociation?.taskId;
98
+ if (!source && workerTaskId === taskId) {
99
+ source = "worker-association";
100
+ }
101
+ }
102
+ catch {
103
+ // state unreadable; may still match via binding
104
+ }
105
+ if (!source) {
106
+ try {
107
+ const spec = await readDagRunSpec(entry.runDir);
108
+ if (spec.taskContractBinding?.taskId === taskId) {
109
+ source = "task-contract-binding";
110
+ }
111
+ else if (spec.sourceBinding?.taskId === taskId) {
112
+ source = "source-binding";
113
+ }
114
+ }
115
+ catch {
116
+ // ignore
117
+ }
118
+ }
119
+ if (!source)
120
+ continue;
121
+ related.push({
122
+ runId: entry.runId,
123
+ lifecycle: entry.lifecycle,
124
+ runDir: entry.runDir,
125
+ status: stateStatus,
126
+ failureCategory,
127
+ lastMeaningfulProgressAt,
128
+ source,
129
+ });
130
+ }
131
+ return related.sort((a, b) => a.runId.localeCompare(b.runId));
132
+ }
133
+ function deriveLifecycleState(input) {
134
+ if (!input.exists)
135
+ return "absent";
136
+ if (input.needsAttention)
137
+ return "needs-attention";
138
+ if (input.awaitingDecision)
139
+ return "awaiting-decision";
140
+ if (input.activeRun)
141
+ return "running";
142
+ if (input.closeoutExists && input.promotionReady)
143
+ return "evidence-closed";
144
+ if (input.latestRun?.lifecycle === "completed") {
145
+ if (input.latestRun.status === "finished")
146
+ return "run-succeeded";
147
+ return "run-failed";
148
+ }
149
+ if (input.gateOpen)
150
+ return "awaiting-write-set-approval";
151
+ if (input.dagValidated)
152
+ return "dag-validated";
153
+ if (input.contractManaged && input.sourceReady)
154
+ return "contract-managed";
155
+ return "intake-incomplete";
156
+ }
157
+ export async function observeTaskLifecycle(repoRoot, taskId) {
158
+ const existsFlag = await taskExists(repoRoot, taskId);
159
+ if (!existsFlag) {
160
+ return {
161
+ schemaVersion: 1,
162
+ taskId,
163
+ exists: false,
164
+ lifecycleState: "absent",
165
+ runs: [],
166
+ completedTransitions: [],
167
+ blockers: [],
168
+ warnings: [],
169
+ next: {
170
+ kind: "advance",
171
+ command: `loop-agent task advance ${taskId} --prd <path> --allowed-path <glob> --json`,
172
+ },
173
+ };
174
+ }
175
+ const paths = getTaskPaths(repoRoot, taskId);
176
+ const taskDir = getTaskDir(repoRoot, taskId);
177
+ const record = await loadLifecycleRecord(repoRoot, taskId);
178
+ const contractState = await observeTaskContractState(repoRoot, taskId);
179
+ let title;
180
+ let taskConfigAllowed = [];
181
+ try {
182
+ const config = await loadTaskConfig(repoRoot, taskId);
183
+ title = config.title;
184
+ taskConfigAllowed = config.allowedPaths ?? [];
185
+ }
186
+ catch {
187
+ // keep optional
188
+ }
189
+ const requirementPath = path.join(paths.sourceDir, "需求.md");
190
+ const constraintsPath = path.join(paths.sourceDir, "执行约束.md");
191
+ const requirementReady = await exists(requirementPath);
192
+ const constraintsReady = await exists(constraintsPath);
193
+ const missing = [
194
+ ...(requirementReady ? [] : [requirementPath]),
195
+ ...(constraintsReady ? [] : [constraintsPath]),
196
+ ];
197
+ let importedPrdCount = 0;
198
+ const referencesDir = path.join(paths.sourceDir, "references");
199
+ if (await exists(referencesDir)) {
200
+ // Count is best-effort; absence is fine.
201
+ try {
202
+ const { readdir } = await import("node:fs/promises");
203
+ const entries = await readdir(referencesDir);
204
+ importedPrdCount = entries.filter((name) => name.endsWith(".md")).length;
205
+ }
206
+ catch {
207
+ importedPrdCount = 0;
208
+ }
209
+ }
210
+ const dagDraftPath = paths.dagDraftPath;
211
+ const dagDraftExists = await exists(dagDraftPath);
212
+ let specHash = null;
213
+ let sourceBindingTaskId = null;
214
+ let taskContractBindingTaskId = null;
215
+ let writeSet = [];
216
+ let rawSpecText = null;
217
+ let parsedSpec = null;
218
+ if (dagDraftExists) {
219
+ try {
220
+ rawSpecText = await readFile(dagDraftPath, "utf-8");
221
+ specHash = hashDagSpecBytes(rawSpecText);
222
+ parsedSpec = JSON.parse(rawSpecText);
223
+ sourceBindingTaskId = parsedSpec.sourceBinding?.taskId ?? null;
224
+ taskContractBindingTaskId =
225
+ parsedSpec.taskContractBinding?.taskId ?? null;
226
+ writeSet = collectWriterWriteSet(parsedSpec);
227
+ if (writeSet.length === 0 && taskConfigAllowed.length > 0) {
228
+ writeSet = normalizeWriteSet(taskConfigAllowed);
229
+ }
230
+ }
231
+ catch {
232
+ specHash = null;
233
+ }
234
+ }
235
+ const runs = await listExplicitlyAssociatedRuns(repoRoot, taskId);
236
+ const activeRun = runs.find((run) => run.lifecycle === "active" || run.lifecycle === "paused") ?? null;
237
+ const latestRun = runs.length > 0 ? runs[runs.length - 1] : null;
238
+ const strictValidated = Boolean(record?.strictValidatedAt) &&
239
+ Boolean(record?.lastDagSpecHash) &&
240
+ record?.lastDagSpecHash === specHash;
241
+ const identity = resolveRunningControllerIdentity();
242
+ const gateBound = dagDraftExists && specHash
243
+ ? buildGateBinding({
244
+ taskId,
245
+ contractRevision: logicalRevisionForState(contractState),
246
+ contractCanonicalHash: contractState.ref?.canonicalHash ??
247
+ contractState.observedCanonicalHash ??
248
+ null,
249
+ sourceBindingHash: parsedSpec ? hashSourceBinding(parsedSpec) : null,
250
+ dagSpecHash: specHash,
251
+ writeSet,
252
+ controllerIdentityAnchor: controllerIdentityAnchor(identity
253
+ ? {
254
+ packageName: identity.packageName,
255
+ packageVersion: identity.packageVersion,
256
+ packageFingerprintValue: identity.packageFingerprint.value,
257
+ }
258
+ : null),
259
+ })
260
+ : null;
261
+ const lastGate = record?.lastGate;
262
+ const currentDigest = gateBound != null
263
+ ? buildWriteSetGate({ taskId, bound: gateBound }).digest
264
+ : null;
265
+ const rejectedCurrent = lastGate?.status === "rejected" &&
266
+ Boolean(currentDigest) &&
267
+ lastGate.digest === currentDigest;
268
+ const approvedCurrent = lastGate?.status === "approved" &&
269
+ Boolean(currentDigest) &&
270
+ lastGate.digest === currentDigest;
271
+ // Open gate only when draft is strict-validated and the current binding is
272
+ // not already approved/rejected. Rejected same-digest gates stay closed
273
+ // until contract/source/DAG/writeSet/controller binding changes.
274
+ const gate = gateBound && strictValidated && !approvedCurrent && !rejectedCurrent
275
+ ? buildWriteSetGate({ taskId, bound: gateBound })
276
+ : null;
277
+ const gateOpen = Boolean(gate);
278
+ let legacyState;
279
+ try {
280
+ legacyState = await getTaskStatus(repoRoot, taskId);
281
+ }
282
+ catch {
283
+ legacyState = null;
284
+ }
285
+ const promotionReady = Boolean(legacyState?.artifacts.modifyLog && legacyState?.artifacts.verifyResult);
286
+ const closeout = closeoutPath(repoRoot, taskId);
287
+ const closeoutExists = await exists(closeout);
288
+ const awaitingDecision = activeRun?.lifecycle === "paused" ||
289
+ activeRun?.status === "paused" ||
290
+ Boolean(activeRun?.status === "waiting-for-human");
291
+ const needsAttention = activeRun?.status === "error" ||
292
+ (activeRun?.lifecycle === "active" &&
293
+ (activeRun.failureCategory === "termination-unconfirmed" ||
294
+ activeRun.failureCategory === "orphaned" ||
295
+ activeRun.failureCategory === "stale-heartbeat" ||
296
+ activeRun.failureCategory === "needs-attention" ||
297
+ activeRun.failureCategory === "suspected-stall" ||
298
+ activeRun.failureCategory === "monitor-timeout"));
299
+ const contractManaged = contractState.effectiveStatus === "managed" || Boolean(contractState.ref);
300
+ const lifecycleState = deriveLifecycleState({
301
+ exists: true,
302
+ contractManaged,
303
+ sourceReady: requirementReady,
304
+ dagValidated: strictValidated,
305
+ gateOpen: Boolean(gate),
306
+ activeRun,
307
+ latestRun,
308
+ promotionReady,
309
+ closeoutExists: closeoutExists || Boolean(record?.closeout),
310
+ needsAttention: Boolean(needsAttention),
311
+ awaitingDecision: Boolean(awaitingDecision),
312
+ });
313
+ const next = deriveNextFromSnapshot({
314
+ taskId,
315
+ lifecycleState,
316
+ gate,
317
+ });
318
+ return {
319
+ schemaVersion: 1,
320
+ taskId,
321
+ exists: true,
322
+ lifecycleState,
323
+ title,
324
+ taskDir,
325
+ contract: {
326
+ effectiveStatus: contractState.effectiveStatus,
327
+ revision: logicalRevisionForState(contractState),
328
+ canonicalHash: contractState.ref?.canonicalHash ?? null,
329
+ observedCanonicalHash: contractState.observedCanonicalHash ?? null,
330
+ transactionIncomplete: contractState.effectiveStatus === "transaction-incomplete",
331
+ taskConfigSha256: contractState.ref?.taskConfigSha256 ?? null,
332
+ projectionVersion: contractState.ref?.projectionVersion ?? null,
333
+ canonicalizerVersion: contractState.ref?.canonicalizerVersion ?? null,
334
+ taskConfigSchemaVersion: contractState.ref?.taskConfigSchemaVersion ?? null,
335
+ },
336
+ source: {
337
+ requirementReady,
338
+ constraintsReady,
339
+ importedPrdCount,
340
+ missing,
341
+ },
342
+ dagDraft: {
343
+ path: dagDraftPath,
344
+ exists: dagDraftExists,
345
+ specHash,
346
+ sourceBindingTaskId,
347
+ taskContractBindingTaskId,
348
+ writeSet,
349
+ strictValidated,
350
+ },
351
+ gate,
352
+ activeRun,
353
+ latestRun,
354
+ runs,
355
+ promotion: {
356
+ required: Boolean(latestRun && latestRun.lifecycle === "completed"),
357
+ ready: promotionReady || Boolean(record?.promotion),
358
+ },
359
+ closeout: {
360
+ path: closeout,
361
+ exists: closeoutExists || Boolean(record?.closeout),
362
+ },
363
+ completedTransitions: record?.completedTransitions ?? [],
364
+ blockers: [],
365
+ warnings: [],
366
+ next,
367
+ };
368
+ }
369
+ function deriveNextFromSnapshot(input) {
370
+ if (input.lifecycleState === "evidence-closed") {
371
+ return { kind: "none" };
372
+ }
373
+ if (input.gate && input.lifecycleState === "awaiting-write-set-approval") {
374
+ return {
375
+ kind: "approve-gate",
376
+ command: input.gate.approveCommand,
377
+ };
378
+ }
379
+ if (input.lifecycleState === "running") {
380
+ return {
381
+ kind: "status",
382
+ command: `loop-agent task status ${input.taskId} --json`,
383
+ };
384
+ }
385
+ return {
386
+ kind: "advance",
387
+ command: `loop-agent task advance ${input.taskId} --json`,
388
+ };
389
+ }
390
+ export function hashDagSpecFileContent(content) {
391
+ return hashDagSpecBytes(content);
392
+ }
393
+ export function collectNormalizedWriteSetFromSpec(spec) {
394
+ return collectWriterWriteSet(spec);
395
+ }