@tea-agent/loop-agent 0.1.0 → 0.2.1

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 (143) hide show
  1. package/AGENTS.md +62 -45
  2. package/CHANGELOG.md +60 -28
  3. package/README.md +160 -124
  4. package/bin/loop-agent.js +21 -21
  5. package/dist/adapters/index.js +3 -2
  6. package/dist/adapters/loop-agent.js +44 -2
  7. package/dist/application/dag/args.js +420 -0
  8. package/dist/application/dag/generate-task-dag.js +280 -0
  9. package/dist/application/dag/report-dag.js +14 -0
  10. package/dist/application/dag/run-dag.js +106 -0
  11. package/dist/application/dag/validate-dag.js +102 -0
  12. package/dist/application/loop/run-action.js +23 -0
  13. package/dist/cli/catalog.js +2 -237
  14. package/dist/cli/command-definitions.js +571 -0
  15. package/dist/cli/index.js +2 -0
  16. package/dist/cli/program.js +65 -1
  17. package/dist/cli/router.js +13 -0
  18. package/dist/cli-governance/active-residue-check.js +38 -0
  19. package/dist/commands/dag-report.js +6 -107
  20. package/dist/commands/dag-run-task.js +8 -466
  21. package/dist/commands/dag-validate.js +7 -179
  22. package/dist/commands/examples.js +90 -0
  23. package/dist/commands/init.js +1518 -0
  24. package/dist/commands/loop.js +57 -31
  25. package/dist/commands/pi-prompt.js +2 -9
  26. package/dist/commands/run-dag.js +7 -180
  27. package/dist/executors/cursor-executor-artifacts.js +3 -4
  28. package/dist/executors/cursor-worker-client.js +13 -3
  29. package/dist/executors/dag-cursor-executor.js +2 -3
  30. package/dist/executors/dag-pi-executor.js +3 -4
  31. package/dist/executors/dag-static-executor.js +2 -5
  32. package/dist/executors/pi-defaults.js +9 -0
  33. package/dist/executors/shell-executor.js +12 -20
  34. package/dist/governance/manifest-types.js +1 -0
  35. package/dist/infrastructure/harness/active-residue-policy.js +73 -0
  36. package/dist/infrastructure/harness/artifact-store.js +72 -0
  37. package/dist/infrastructure/harness/atomic-write.js +49 -0
  38. package/dist/infrastructure/harness/completed-facts-guard.js +40 -0
  39. package/dist/infrastructure/harness/loop-action-store.js +23 -0
  40. package/dist/infrastructure/harness/loop-store.js +41 -0
  41. package/dist/infrastructure/harness/one-shot-run-store.js +94 -0
  42. package/dist/infrastructure/harness/task-store.js +77 -0
  43. package/dist/records/one-shot-runs.js +26 -61
  44. package/dist/records/promotion.js +3 -4
  45. package/dist/shared/artifacts-core.js +5 -5
  46. package/dist/shared/logger.js +9 -15
  47. package/dist/task/delegate.js +4 -4
  48. package/dist/task/runtime.js +5 -7
  49. package/dist/task/state.js +6 -20
  50. package/dist/workflows/dag/convergence/controller.js +277 -0
  51. package/dist/workflows/dag/dynamic-runtime/condition.js +48 -0
  52. package/dist/workflows/dag/dynamic-runtime/loop-until.js +156 -0
  53. package/dist/workflows/dag/dynamic-runtime/map.js +185 -0
  54. package/dist/workflows/dag/dynamic-runtime/reduction.js +72 -0
  55. package/dist/workflows/dag/dynamic-runtime/shared.js +133 -0
  56. package/dist/workflows/dag/failure-routing.js +82 -0
  57. package/dist/workflows/dag/lifecycle.js +101 -8
  58. package/dist/workflows/dag/node-execution.js +262 -0
  59. package/dist/workflows/dag/report.js +73 -1
  60. package/dist/workflows/dag/run-store.js +36 -0
  61. package/dist/workflows/dag/runner.js +82 -1341
  62. package/dist/workflows/dag/scheduler.js +84 -0
  63. package/dist/workflows/dag/upstream-artifacts.js +20 -18
  64. package/dist/workflows/loop/actions/cursor-fix.js +191 -0
  65. package/dist/workflows/loop/actions/dag-action.js +130 -0
  66. package/dist/workflows/loop/actions/pi-review.js +267 -0
  67. package/dist/workflows/loop/actions/shared.js +157 -0
  68. package/dist/workflows/loop/actions/shell-verify.js +82 -0
  69. package/dist/workflows/loop/actions/types.js +1 -0
  70. package/dist/workflows/loop/actions/workflow-action.js +255 -0
  71. package/dist/workflows/loop/actions.js +55 -1212
  72. package/dist/workflows/loop/closeout.js +5 -4
  73. package/dist/workflows/loop/context.js +2 -3
  74. package/dist/workflows/loop/events.js +3 -2
  75. package/dist/workflows/loop/policy/auto-policy.js +104 -0
  76. package/dist/workflows/loop/policy/cursor-fix-policy.js +31 -0
  77. package/dist/workflows/loop/rounds.js +3 -3
  78. package/dist/workflows/loop/signals.js +4 -7
  79. package/dist/workflows/loop/state.js +11 -11
  80. package/docs/README.md +47 -44
  81. package/docs/agent-dag-recovery-playbook.md +32 -6
  82. package/docs/agent-dag-runner.md +17 -17
  83. package/docs/architecture/runtime-boundaries.md +147 -0
  84. package/docs/cursor-executor-usage.md +5 -5
  85. package/docs/decisions/README.md +2 -2
  86. package/docs/design/README.md +24 -24
  87. package/docs/development-principles.md +50 -50
  88. package/docs/dynamic-workflow-dag-engine-roadmap.md +6 -6
  89. package/docs/exec-plans/README.md +4 -4
  90. package/docs/exec-plans/active/README.md +10 -5
  91. package/docs/exec-plans/completed/README.md +9 -5
  92. package/docs/feature-workflow.md +111 -109
  93. package/docs/harness-methodology-verification.md +18 -18
  94. package/docs/loop-agent-harness.md +36 -36
  95. package/docs/production-readiness.md +96 -0
  96. package/docs/progress/README.md +2 -2
  97. package/docs/reports/README.md +4 -2
  98. package/docs/templates/agent-dag-decision-gate-dogfood-report.md +1 -1
  99. package/docs/templates/agent-dag-process-supervisor.prompt.md +2 -2
  100. package/docs/templates/agent-dag-report.schema.json +33 -2
  101. package/docs/templates/agent-dag-review-verdict.prompt.md +1 -1
  102. package/docs/templates/agent-dag.base.json +195 -195
  103. package/docs/templates/agent-dag.final-verification.json +190 -190
  104. package/docs/templates/agent-dag.schema.json +17 -17
  105. package/docs/templates/agent-dag.supervised-implementation.json +500 -500
  106. package/docs/templates/hybrid-dag.json +193 -193
  107. package/docs/templates/production-readiness-checklist.md +57 -0
  108. package/docs/templates/progress-log.md +7 -7
  109. package/docs/templates/project-start-checklist.md +8 -8
  110. package/docs/templates/qa-report.md +17 -11
  111. package/docs/templates/sprint-contract.md +19 -19
  112. package/docs/verification-matrix.md +37 -26
  113. package/examples/example-dag.json +51 -51
  114. package/examples/hybrid-loop-agent-dag.json +194 -194
  115. package/harness.json +5 -5
  116. package/package.json +62 -61
  117. package/skills/ai-engineering-context/SKILL.md +21 -21
  118. package/skills/loop-agent/SKILL.md +56 -171
  119. package/skills/loop-agent/references/README.md +6 -2
  120. package/skills/loop-agent/references/command-reference.md +107 -65
  121. package/skills/loop-agent/references/harness-policy.md +115 -115
  122. package/skills/loop-agent/references/hybrid-dag.md +30 -30
  123. package/skills/loop-agent/references/learned/README.md +13 -13
  124. package/skills/loop-agent/references/long-running-loop.md +59 -0
  125. package/skills/loop-agent/references/model-routing.md +1 -1
  126. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  127. package/skills/loop-agent/references/pi-prompt.md +9 -9
  128. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +0 -2
  129. package/skills/loop-agent/references/post-implementation-and-patterns.md +7 -7
  130. package/skills/loop-agent/references/task-workflow.md +19 -19
  131. package/skills/loop-agent/references/verification-and-failure-handling.md +54 -0
  132. package/skills/requesting-code-review/SKILL.md +40 -40
  133. package/skills/requesting-code-review/code-reviewer.md +4 -4
  134. package/skills/systematic-debugging/CREATION-LOG.md +43 -43
  135. package/skills/systematic-debugging/SKILL.md +113 -113
  136. package/skills/systematic-debugging/condition-based-waiting.md +20 -20
  137. package/skills/systematic-debugging/defense-in-depth.md +27 -27
  138. package/skills/systematic-debugging/root-cause-tracing.md +38 -38
  139. package/skills/systematic-debugging/test-academic.md +6 -6
  140. package/skills/systematic-debugging/test-pressure-1.md +6 -6
  141. package/skills/systematic-debugging/test-pressure-2.md +2 -2
  142. package/skills/systematic-debugging/test-pressure-3.md +6 -6
  143. package/skills/verification-before-completion/SKILL.md +37 -37
@@ -0,0 +1,185 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeDagRunJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
4
+ import { executeDagNode, } from "../node-execution.js";
5
+ import { writeRunSpec } from "../run-store.js";
6
+ import { freshNodeRecord, parseJsonFromText, renderDynamicPatternList, renderDynamicTemplate, resolveItemsFromSelector, sha256Json, } from "./shared.js";
7
+ export function buildExpandedChildTask(input) {
8
+ const { parent, expansion, item, index, nodeId } = input;
9
+ const child = expansion.childTask;
10
+ const subtaskPrompt = renderDynamicTemplate(child.subtaskPromptTemplate, item, index, expansion.itemName);
11
+ const staticResult = child.staticResultTemplate
12
+ ? renderDynamicTemplate(child.staticResultTemplate, item, index, expansion.itemName)
13
+ : undefined;
14
+ return {
15
+ id: nodeId,
16
+ depends_on: parent.depends_on,
17
+ complexity: child.complexity,
18
+ subtask_prompt: subtaskPrompt,
19
+ executor: child.executor,
20
+ role: child.role,
21
+ writePolicy: child.writePolicy,
22
+ allowedPaths: renderDynamicPatternList(child.allowedPaths, item, index, expansion.itemName) ?? [],
23
+ forbiddenPaths: renderDynamicPatternList(child.forbiddenPaths, item, index, expansion.itemName) ?? [],
24
+ writeSet: renderDynamicPatternList(child.writeSet, item, index, expansion.itemName),
25
+ outputContract: child.outputContract,
26
+ static: child.executor === "static"
27
+ ? { resultMarkdown: staticResult ?? "dynamic child completed" }
28
+ : undefined,
29
+ };
30
+ }
31
+ export function addExpansionRank(input) {
32
+ if (input.state.ranks.some((rank) => input.childNodeIds.every((nodeId) => rank.includes(nodeId)))) {
33
+ return;
34
+ }
35
+ const parentRankIndex = input.state.ranks.findIndex((rank) => rank.includes(input.parentNodeId));
36
+ const insertAt = parentRankIndex >= 0 ? parentRankIndex + 1 : input.state.ranks.length;
37
+ input.state.ranks.splice(insertAt, 0, input.childNodeIds);
38
+ }
39
+ export function normalizedWritePattern(pattern) {
40
+ return pattern.replace(/\\/g, "/").replace(/^\.\//, "");
41
+ }
42
+ export function writePatternsOverlap(left, right) {
43
+ const normalizedLeft = normalizedWritePattern(left);
44
+ const normalizedRight = normalizedWritePattern(right);
45
+ if (normalizedLeft === normalizedRight)
46
+ return true;
47
+ const leftPrefix = normalizedLeft.replace(/\*\*.*$/, "");
48
+ const rightPrefix = normalizedRight.replace(/\*\*.*$/, "");
49
+ return (leftPrefix.length > 0 &&
50
+ rightPrefix.length > 0 &&
51
+ (leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix)));
52
+ }
53
+ export function collectDynamicChildWriteSetConflicts(children) {
54
+ const conflicts = [];
55
+ const exclusiveChildren = children.filter((child) => child.writePolicy === "exclusive" && (child.writeSet?.length ?? 0) > 0);
56
+ for (let i = 0; i < exclusiveChildren.length; i += 1) {
57
+ for (let j = i + 1; j < exclusiveChildren.length; j += 1) {
58
+ const left = exclusiveChildren[i];
59
+ const right = exclusiveChildren[j];
60
+ for (const leftEntry of left.writeSet ?? []) {
61
+ for (const rightEntry of right.writeSet ?? []) {
62
+ if (writePatternsOverlap(leftEntry, rightEntry)) {
63
+ conflicts.push(`${left.id}:${leftEntry} overlaps ${right.id}:${rightEntry}`);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return conflicts;
70
+ }
71
+ export function resolveRunLocalPath(runDir, ref, label) {
72
+ if (path.isAbsolute(ref)) {
73
+ throw new Error(`${label} must be relative to the DAG run directory: ${ref}`);
74
+ }
75
+ const runRoot = path.resolve(runDir);
76
+ const resolved = path.resolve(runRoot, ref);
77
+ if (resolved !== runRoot && !resolved.startsWith(`${runRoot}${path.sep}`)) {
78
+ throw new Error(`${label} escapes the DAG run directory: ${ref}`);
79
+ }
80
+ return resolved;
81
+ }
82
+ export async function executeDynamicMapExpansion(input) {
83
+ const started = Date.now();
84
+ const items = resolveItemsFromSelector(input.expansion.itemsFrom, input.state);
85
+ if (items.length > input.expansion.maxExpandedNodes) {
86
+ throw new Error(`map_agent ${input.task.id} item count ${items.length} exceeds maxExpandedNodes ${input.expansion.maxExpandedNodes}`);
87
+ }
88
+ if (items.length > input.expansion.maxItems) {
89
+ throw new Error(`map_agent ${input.task.id} item count ${items.length} exceeds maxItems ${input.expansion.maxItems}`);
90
+ }
91
+ const childNodeIds = items.map((_, index) => `${input.expansion.childIdPrefix}-${String(index + 1).padStart(4, "0")}`);
92
+ const children = childNodeIds.map((nodeId, index) => buildExpandedChildTask({
93
+ parent: input.task,
94
+ expansion: input.expansion,
95
+ item: items[index],
96
+ index,
97
+ nodeId,
98
+ }));
99
+ const writeSetConflicts = collectDynamicChildWriteSetConflicts(children);
100
+ if (writeSetConflicts.length > 0) {
101
+ throw new Error(`dynamic map_agent ${input.task.id} expanded overlapping writeSets: ${writeSetConflicts.join("; ")}`);
102
+ }
103
+ const workspaceRefs = items.map((item, index) => input.expansion.workspaceTemplate
104
+ ? renderDynamicTemplate(input.expansion.workspaceTemplate, item, index, input.expansion.itemName)
105
+ : undefined);
106
+ for (const workspaceRef of workspaceRefs) {
107
+ if (!workspaceRef)
108
+ continue;
109
+ await mkdir(resolveRunLocalPath(input.runDir, workspaceRef, "workspaceRef"), {
110
+ recursive: true,
111
+ });
112
+ }
113
+ for (const child of children) {
114
+ if (!input.tasksById.has(child.id)) {
115
+ input.tasksById.set(child.id, child);
116
+ input.spec.tasks.push(child);
117
+ }
118
+ if (!input.state.nodes[child.id]) {
119
+ input.state.nodes[child.id] = freshNodeRecord(child);
120
+ }
121
+ }
122
+ addExpansionRank({
123
+ state: input.state,
124
+ parentNodeId: input.task.id,
125
+ childNodeIds,
126
+ });
127
+ await writeRunSpec(input.runDir, input.spec);
128
+ const manifest = {
129
+ workflowNodeId: input.expansion.workflowNodeId,
130
+ expandedAt: new Date().toISOString(),
131
+ itemsFrom: input.expansion.itemsFrom,
132
+ itemCount: items.length,
133
+ children: childNodeIds.map((nodeId, index) => ({
134
+ nodeId,
135
+ itemRef: `$.items[${index}]`,
136
+ itemValueHash: sha256Json(items[index]),
137
+ workspaceRef: workspaceRefs[index],
138
+ })),
139
+ };
140
+ await writeDagRunJsonArtifact(input.runDir, `expansions/${input.task.id}.expansion.json`, manifest);
141
+ await input.persistState();
142
+ for (const child of children) {
143
+ const childRecord = input.state.nodes[child.id];
144
+ if (childRecord?.status === "FINISHED")
145
+ continue;
146
+ await executeDagNode({
147
+ nodeId: child.id,
148
+ tasksById: input.tasksById,
149
+ state: input.state,
150
+ spec: input.spec,
151
+ cwd: input.cwd,
152
+ runDir: input.runDir,
153
+ executeNode: input.executeNode,
154
+ executeDynamicNode: input.executeDynamicNode,
155
+ observer: input.observer,
156
+ persistState: input.persistState,
157
+ onPause: () => {
158
+ throw new Error(`dynamic child node ${child.id} requested a human pause; map_agent children do not support pause in v0`);
159
+ },
160
+ });
161
+ }
162
+ const failedChildren = childNodeIds.filter((nodeId) => input.state.nodes[nodeId]?.status !== "FINISHED");
163
+ const aggregate = {
164
+ workflowNodeId: input.expansion.workflowNodeId,
165
+ itemCount: items.length,
166
+ children: childNodeIds.map((nodeId, index) => ({
167
+ nodeId,
168
+ item: items[index],
169
+ workspaceRef: workspaceRefs[index],
170
+ status: input.state.nodes[nodeId]?.status,
171
+ stdout: input.state.nodes[nodeId]?.stdout,
172
+ output: parseJsonFromText(input.state.nodes[nodeId]?.stdout),
173
+ assistantText: input.state.nodes[nodeId]?.assistantText,
174
+ })),
175
+ };
176
+ return {
177
+ ok: failedChildren.length === 0,
178
+ stdout: JSON.stringify(aggregate),
179
+ stderr: failedChildren.length > 0
180
+ ? `dynamic map children failed: ${failedChildren.join(", ")}`
181
+ : "",
182
+ failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
183
+ durationMs: Date.now() - started,
184
+ };
185
+ }
@@ -0,0 +1,72 @@
1
+ import path from "node:path";
2
+ import { writeDagNodeJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
3
+ import { repoRelativePath, toPosixPath } from "../../../shared/path-refs.js";
4
+ import { buildVerifiedFindingsReport, findingSchema, verificationSchema, } from "../../dynamic/artifacts.js";
5
+ import { parseJsonFromText, resolveOutputSelector } from "./shared.js";
6
+ function parseFindingsFromSelector(selector, state) {
7
+ const raw = resolveOutputSelector(selector, state);
8
+ if (!Array.isArray(raw)) {
9
+ throw new Error(`findings selector did not resolve to an array: ${selector}`);
10
+ }
11
+ return raw.map((entry) => {
12
+ const direct = findingSchema.safeParse(entry);
13
+ if (direct.success)
14
+ return direct.data;
15
+ if (entry && typeof entry === "object") {
16
+ const output = entry.output;
17
+ const fromOutput = findingSchema.safeParse(output);
18
+ if (fromOutput.success)
19
+ return fromOutput.data;
20
+ const stdout = entry.stdout;
21
+ if (typeof stdout === "string") {
22
+ const fromStdout = findingSchema.safeParse(parseJsonFromText(stdout));
23
+ if (fromStdout.success)
24
+ return fromStdout.data;
25
+ }
26
+ }
27
+ return findingSchema.parse(entry);
28
+ });
29
+ }
30
+ function parseVerificationFromChildEntry(entry) {
31
+ if (!entry || typeof entry !== "object")
32
+ return undefined;
33
+ const stdout = entry.stdout;
34
+ if (typeof stdout !== "string" || stdout.trim().length === 0) {
35
+ return undefined;
36
+ }
37
+ return verificationSchema.parse(JSON.parse(stdout));
38
+ }
39
+ function parseVerificationsFromSelector(selector, state) {
40
+ const raw = resolveOutputSelector(selector, state);
41
+ if (!Array.isArray(raw)) {
42
+ throw new Error(`verifications selector did not resolve to an array: ${selector}`);
43
+ }
44
+ return raw
45
+ .map(parseVerificationFromChildEntry)
46
+ .filter((entry) => Boolean(entry));
47
+ }
48
+ export async function executeDynamicReduction(input) {
49
+ const started = Date.now();
50
+ if (input.reduction.type !== "verified_findings_report") {
51
+ throw new Error(`unsupported dynamic reduction: ${input.reduction.type}`);
52
+ }
53
+ const findings = parseFindingsFromSelector(input.reduction.findingsFrom, input.state);
54
+ const verifications = parseVerificationsFromSelector(input.reduction.verificationsFrom, input.state);
55
+ const nodeDir = path.join(input.runDir, input.task.id);
56
+ const refutedFindingsPath = path.join(nodeDir, input.reduction.refutedFindingsArtifactName);
57
+ const { report, refutedFindings } = buildVerifiedFindingsReport({
58
+ findings,
59
+ verifications,
60
+ refutedFindingsRef: refutedFindingsPath.startsWith(input.runDir)
61
+ ? repoRelativePath(input.runDir, refutedFindingsPath)
62
+ : toPosixPath(refutedFindingsPath),
63
+ });
64
+ await writeDagNodeJsonArtifact(input.runDir, input.task.id, input.reduction.refutedFindingsArtifactName, refutedFindings);
65
+ return {
66
+ ok: true,
67
+ stdout: JSON.stringify(report),
68
+ stderr: "",
69
+ failureCategory: "success",
70
+ durationMs: Date.now() - started,
71
+ };
72
+ }
@@ -0,0 +1,133 @@
1
+ import { createHash } from "node:crypto";
2
+ export function freshNodeRecord(task) {
3
+ return {
4
+ id: task.id,
5
+ status: "PENDING",
6
+ executor: task.executor,
7
+ complexity: task.complexity,
8
+ };
9
+ }
10
+ export function sha256Json(value) {
11
+ return `sha256:${createHash("sha256")
12
+ .update(JSON.stringify(value))
13
+ .digest("hex")}`;
14
+ }
15
+ export function getNodeOutputAsJson(state, nodeId) {
16
+ const record = state.nodes[nodeId];
17
+ if (!record || record.status !== "FINISHED") {
18
+ throw new Error(`itemsFrom upstream node "${nodeId}" is not finished`);
19
+ }
20
+ const raw = record.stdout?.trim() || record.assistantText?.trim();
21
+ if (!raw) {
22
+ throw new Error(`itemsFrom upstream node "${nodeId}" has no JSON output`);
23
+ }
24
+ try {
25
+ return JSON.parse(raw);
26
+ }
27
+ catch (error) {
28
+ throw new Error(`itemsFrom upstream node "${nodeId}" output is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
29
+ }
30
+ }
31
+ export function resolveOutputSelector(selector, state) {
32
+ const match = selector.match(/^\$\.nodes\[['"]([^'"]+)['"]\]\.output(?:\.(.+))?$/);
33
+ if (!match?.[1]) {
34
+ throw new Error(`unsupported itemsFrom selector: ${selector}`);
35
+ }
36
+ const nodeId = match[1];
37
+ const pathSuffix = match[2];
38
+ let current = getNodeOutputAsJson(state, nodeId);
39
+ if (pathSuffix) {
40
+ for (const segment of pathSuffix.split(".")) {
41
+ if (current &&
42
+ typeof current === "object" &&
43
+ segment in current) {
44
+ current = current[segment];
45
+ continue;
46
+ }
47
+ throw new Error(`output selector path not found: ${selector} (missing "${segment}")`);
48
+ }
49
+ }
50
+ return current;
51
+ }
52
+ export function resolveItemsFromSelector(selector, state) {
53
+ let current;
54
+ try {
55
+ current = resolveOutputSelector(selector, state);
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ if (message.startsWith("output selector path not found:")) {
60
+ throw new Error(`itemsFrom path not found: ${selector}`);
61
+ }
62
+ throw error;
63
+ }
64
+ if (!Array.isArray(current)) {
65
+ throw new Error(`itemsFrom selector did not resolve to an array: ${selector}`);
66
+ }
67
+ return current;
68
+ }
69
+ export function lookupPath(value, pathExpression) {
70
+ let current = value;
71
+ for (const segment of pathExpression.split(".")) {
72
+ if (current &&
73
+ typeof current === "object" &&
74
+ segment in current) {
75
+ current = current[segment];
76
+ continue;
77
+ }
78
+ return undefined;
79
+ }
80
+ return current;
81
+ }
82
+ export function templateValueToString(value) {
83
+ if (typeof value === "string" ||
84
+ typeof value === "number" ||
85
+ typeof value === "boolean") {
86
+ return String(value);
87
+ }
88
+ if (value === null || value === undefined)
89
+ return "";
90
+ return JSON.stringify(value);
91
+ }
92
+ export function parseConditionLiteral(raw) {
93
+ const trimmed = raw.trim();
94
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
95
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
96
+ return trimmed.slice(1, -1);
97
+ }
98
+ if (trimmed === "true")
99
+ return true;
100
+ if (trimmed === "false")
101
+ return false;
102
+ if (trimmed === "null")
103
+ return null;
104
+ const numeric = Number(trimmed);
105
+ return Number.isFinite(numeric) ? numeric : trimmed;
106
+ }
107
+ export function parseJsonFromText(value) {
108
+ if (!value?.trim())
109
+ return undefined;
110
+ try {
111
+ return JSON.parse(value);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ export function renderDynamicTemplate(template, item, index, itemName) {
118
+ const primitive = typeof item === "string" ||
119
+ typeof item === "number" ||
120
+ typeof item === "boolean"
121
+ ? String(item)
122
+ : JSON.stringify(item);
123
+ return template
124
+ .replace(/\{\{\s*item\s*\}\}/g, primitive)
125
+ .replace(new RegExp(`\\{\\{\\s*${itemName}\\s*\\}\\}`, "g"), primitive)
126
+ .replace(new RegExp(`\\{\\{\\s*${itemName}\\.([A-Za-z0-9_.-]+)\\s*\\}\\}`, "g"), (_match, pathExpression) => templateValueToString(lookupPath(item, pathExpression)))
127
+ .replace(/\{\{\s*item\.([A-Za-z0-9_.-]+)\s*\}\}/g, (_match, pathExpression) => templateValueToString(lookupPath(item, pathExpression)))
128
+ .replace(/\{\{\s*itemJson\s*\}\}/g, JSON.stringify(item))
129
+ .replace(/\{\{\s*index\s*\}\}/g, String(index));
130
+ }
131
+ export function renderDynamicPatternList(patterns, item, index, itemName) {
132
+ return patterns?.map((pattern) => renderDynamicTemplate(pattern, item, index, itemName));
133
+ }
@@ -0,0 +1,82 @@
1
+ export const dagProductLineFailureCategoryValues = [
2
+ "SpecUnclear",
3
+ "ContractMismatch",
4
+ "ProductBug",
5
+ "TestBug",
6
+ "EnvFailure",
7
+ "FlakyTest",
8
+ "RiskyChange",
9
+ "DependencyFailure",
10
+ "NeedsHuman",
11
+ "Unknown",
12
+ ];
13
+ const FOLLOW_UP_BY_PRODUCT_LINE = {
14
+ SpecUnclear: "spec-clarification",
15
+ ContractMismatch: "architecture-contract-fix",
16
+ ProductBug: "dev-fix",
17
+ TestBug: "qa-fix-test",
18
+ EnvFailure: "env-fix or retry verify",
19
+ FlakyTest: "flaky-test-analysis",
20
+ RiskyChange: "human-review or architecture-review",
21
+ DependencyFailure: "unblock dependency",
22
+ NeedsHuman: "human-review",
23
+ Unknown: "human triage",
24
+ };
25
+ function routeToProductLine(input) {
26
+ const normalized = input.normalizedFailureCategory;
27
+ if (!normalized || normalized === "success")
28
+ return undefined;
29
+ const raw = input.rawFailureCategory?.toLowerCase() ?? "";
30
+ const nodeId = input.nodeId?.toLowerCase() ?? "";
31
+ switch (normalized) {
32
+ case "write-guard":
33
+ return "RiskyChange";
34
+ case "auth":
35
+ case "executor":
36
+ case "timeout":
37
+ return "EnvFailure";
38
+ case "human-required":
39
+ case "human-rejected":
40
+ case "decision-envelope":
41
+ return "NeedsHuman";
42
+ case "skipped":
43
+ return "DependencyFailure";
44
+ case "shell-command":
45
+ if (raw.includes("flaky"))
46
+ return "FlakyTest";
47
+ if (nodeId.includes("test") || raw.includes("test-bug")) {
48
+ return "TestBug";
49
+ }
50
+ return "ProductBug";
51
+ case "static-error":
52
+ return "SpecUnclear";
53
+ case "validation":
54
+ if (raw.includes("path") ||
55
+ raw.includes("write") ||
56
+ raw.includes("forbidden")) {
57
+ return "RiskyChange";
58
+ }
59
+ if (raw.includes("test-bug") || nodeId.includes("test")) {
60
+ return "TestBug";
61
+ }
62
+ if (raw.includes("test-failure") || raw.includes("verify-failure")) {
63
+ return "ProductBug";
64
+ }
65
+ return "SpecUnclear";
66
+ case "unknown":
67
+ return "Unknown";
68
+ default: {
69
+ const exhaustive = normalized;
70
+ return exhaustive;
71
+ }
72
+ }
73
+ }
74
+ export function routeDagFailure(input) {
75
+ const productLineFailureCategory = routeToProductLine(input);
76
+ if (!productLineFailureCategory)
77
+ return {};
78
+ return {
79
+ productLineFailureCategory,
80
+ recommendedFollowUp: FOLLOW_UP_BY_PRODUCT_LINE[productLineFailureCategory],
81
+ };
82
+ }
@@ -1,6 +1,9 @@
1
- import { access, mkdir, readFile, readdir, rename, writeFile, } from "node:fs/promises";
1
+ import { access, mkdir, readFile, readdir, rename, } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { writeJsonAtomic, } from "../../infrastructure/harness/atomic-write.js";
3
4
  import { parseDagSpec } from "./types.js";
5
+ import { normalizeDagFailureCategory, } from "./failure-category.js";
6
+ import { routeDagFailure } from "./failure-routing.js";
4
7
  const DAG_LIFECYCLE_SCAN_ORDER = [
5
8
  "paused",
6
9
  "active",
@@ -24,8 +27,8 @@ export async function readDagRunState(runDir) {
24
27
  const raw = JSON.parse(await readFile(path.join(runDir, "state.json"), "utf-8"));
25
28
  return raw;
26
29
  }
27
- export async function writeDagRunState(runDir, state) {
28
- await writeFile(path.join(runDir, "state.json"), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
30
+ export async function writeDagRunState(runDir, state, options) {
31
+ await writeJsonAtomic(path.join(runDir, "state.json"), state, options);
29
32
  }
30
33
  export async function readDagRunSpec(runDir) {
31
34
  const raw = JSON.parse(await readFile(path.join(runDir, "run.json"), "utf-8"));
@@ -131,14 +134,14 @@ export async function writeHumanApprovalArtifact(input) {
131
134
  const nodeDir = path.join(input.runDir, input.nodeId);
132
135
  await mkdir(nodeDir, { recursive: true });
133
136
  const artifactPath = path.join(nodeDir, "human-approval.json");
134
- await writeFile(artifactPath, `${JSON.stringify(input.artifact, null, 2)}\n`, "utf-8");
137
+ await writeJsonAtomic(artifactPath, input.artifact);
135
138
  return artifactPath;
136
139
  }
137
140
  export async function writeHumanRejectionArtifact(input) {
138
141
  const nodeDir = path.join(input.runDir, input.nodeId);
139
142
  await mkdir(nodeDir, { recursive: true });
140
143
  const artifactPath = path.join(nodeDir, "human-rejection.json");
141
- await writeFile(artifactPath, `${JSON.stringify(input.artifact, null, 2)}\n`, "utf-8");
144
+ await writeJsonAtomic(artifactPath, input.artifact);
142
145
  return artifactPath;
143
146
  }
144
147
  export async function readHumanApprovalArtifact(runDir, nodeId) {
@@ -469,16 +472,106 @@ export async function runDagStatus(repoRoot, rawArgs) {
469
472
  console.log(JSON.stringify(report, null, 2));
470
473
  }
471
474
  export function parseDagDoctorArgs(args) {
472
- for (const arg of args) {
475
+ let runId;
476
+ let markdown = false;
477
+ for (let i = 0; i < args.length; i += 1) {
478
+ const arg = args[i];
479
+ if (arg === "--run-id") {
480
+ runId = args[++i];
481
+ if (!runId || runId.startsWith("-")) {
482
+ throw new Error("dag doctor --run-id requires a value");
483
+ }
484
+ continue;
485
+ }
486
+ if (arg.startsWith("--run-id=")) {
487
+ runId = arg.slice("--run-id=".length);
488
+ if (!runId)
489
+ throw new Error("dag doctor --run-id requires a value");
490
+ continue;
491
+ }
492
+ if (arg === "--markdown") {
493
+ markdown = true;
494
+ continue;
495
+ }
473
496
  if (arg.startsWith("-")) {
474
497
  throw new Error(`unknown dag doctor flag: ${arg}`);
475
498
  }
476
499
  throw new Error(`unexpected positional argument: ${arg}`);
477
500
  }
478
- return {};
501
+ return { runId, markdown };
502
+ }
503
+ function findDoctorFailureNode(state) {
504
+ if (state.pausedByNodeId) {
505
+ const node = state.nodes[state.pausedByNodeId];
506
+ return {
507
+ nodeId: state.pausedByNodeId,
508
+ status: node?.status,
509
+ rawFailureCategory: node?.failureCategory,
510
+ };
511
+ }
512
+ const errorEntry = Object.entries(state.nodes).find(([, node]) => node.status === "ERROR");
513
+ const skippedEntry = Object.entries(state.nodes).find(([, node]) => node.status === "SKIPPED");
514
+ const selected = errorEntry ?? skippedEntry;
515
+ if (!selected) {
516
+ return {
517
+ rawFailureCategory: state.failureCategory,
518
+ status: state.status,
519
+ };
520
+ }
521
+ return {
522
+ nodeId: selected[0],
523
+ status: selected[1].status,
524
+ rawFailureCategory: selected[1].failureCategory,
525
+ };
526
+ }
527
+ async function formatDagDoctorMarkdown(repoRoot, runId) {
528
+ const located = await locateDagRun(repoRoot, runId);
529
+ if (!located) {
530
+ throw new Error(`dag run not found: ${runId}`);
531
+ }
532
+ const state = await readDagRunState(located.runDir);
533
+ const summary = await buildDagStatusReport(repoRoot, runId);
534
+ const failure = findDoctorFailureNode(state);
535
+ const rawFailureCategory = failure.rawFailureCategory ??
536
+ (state.status === "paused" ? "human-required" : state.failureCategory);
537
+ const failureStatus = state.status === "paused" ? "paused" : failure.status;
538
+ const normalizedCategory = normalizeDagFailureCategory(rawFailureCategory, failureStatus ?? state.status);
539
+ const routing = routeDagFailure({
540
+ rawFailureCategory,
541
+ normalizedFailureCategory: normalizedCategory,
542
+ nodeId: failure.nodeId,
543
+ });
544
+ const evidence = failure.nodeId
545
+ ? path.join(located.runDir, failure.nodeId, "result.summary.md")
546
+ : path.join(located.runDir, "state.json");
547
+ const nextCommand = summary.nextRecommendedAction ||
548
+ routing.recommendedFollowUp ||
549
+ "Inspect run facts and choose a recovery path.";
550
+ return [
551
+ "## Diagnosis",
552
+ "",
553
+ `- run id: ${runId}`,
554
+ `- lifecycle: ${located.lifecycle}`,
555
+ `- failed node: ${failure.nodeId ?? "-"}`,
556
+ `- raw failure: ${rawFailureCategory ?? "-"}`,
557
+ `- normalized category: ${normalizedCategory}`,
558
+ `- product-line category: ${routing.productLineFailureCategory ?? "-"}`,
559
+ `- recommended follow-up: ${routing.recommendedFollowUp ?? "-"}`,
560
+ `- evidence: ${evidence}`,
561
+ `- next command: ${nextCommand}`,
562
+ "",
563
+ ].join("\n");
479
564
  }
480
565
  export async function runDagDoctor(repoRoot, rawArgs) {
481
- parseDagDoctorArgs(rawArgs);
566
+ const parsed = parseDagDoctorArgs(rawArgs);
567
+ if (parsed.runId) {
568
+ if (parsed.markdown) {
569
+ console.log(await formatDagDoctorMarkdown(repoRoot, parsed.runId));
570
+ return;
571
+ }
572
+ console.log(JSON.stringify(await buildDagStatusReport(repoRoot, parsed.runId), null, 2));
573
+ return;
574
+ }
482
575
  const report = await buildDagDoctorReport(repoRoot);
483
576
  console.log(JSON.stringify(report, null, 2));
484
577
  }