@pi-agents/orchid 0.1.0-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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Config loading — thin wrappers over the unified loader.
3
+ *
4
+ * These functions preserve the existing snake_case return shapes
5
+ * (`OrchestratorConfig`, `TaskRunnerConfig` from types.ts) so all
6
+ * downstream consumers remain unchanged during the JSON migration.
7
+ *
8
+ * The unified loader (`loadProjectConfig`) handles JSON-first loading
9
+ * with YAML fallback and defaults merging.
10
+ *
11
+ * @module orch/config
12
+ */
13
+
14
+ import {
15
+ loadProjectConfig,
16
+ toOrchestratorConfig,
17
+ toTaskRunnerConfig,
18
+ hasConfigFiles,
19
+ } from "./config-loader.ts";
20
+ export { hasConfigFiles, resolveConfigRoot } from "./config-loader.ts";
21
+ import type { OrchestratorConfig, TaskRunnerConfig } from "./types.ts";
22
+ import type { SupervisorConfig } from "./supervisor.ts";
23
+ import { DEFAULT_SUPERVISOR_CONFIG } from "./supervisor.ts";
24
+
25
+ // ── Config Loading ───────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Load orchestrator config.
29
+ *
30
+ * Reads `.pi/orchid-config.json` first; falls back to
31
+ * `.pi/task-orchestrator.yaml` + `.pi/task-runner.yaml`; then defaults.
32
+ *
33
+ * In workspace mode, `pointerConfigRoot` (from the resolved pointer file)
34
+ * is inserted into the config resolution chain between cwd-local and
35
+ * TASKPLANE_WORKSPACE_ROOT. See `resolveConfigRoot()` in config-loader.ts.
36
+ *
37
+ * Returns the legacy `OrchestratorConfig` (snake_case) shape.
38
+ */
39
+ export function loadOrchestratorConfig(
40
+ cwd: string,
41
+ pointerConfigRoot?: string,
42
+ ): OrchestratorConfig {
43
+ const unified = loadProjectConfig(cwd, pointerConfigRoot);
44
+ return toOrchestratorConfig(unified);
45
+ }
46
+
47
+ /**
48
+ * Load task-runner config (orchestrator subset: task_areas + reference_docs).
49
+ *
50
+ * Reads `.pi/orchid-config.json` first; falls back to
51
+ * `.pi/task-runner.yaml`; then defaults.
52
+ *
53
+ * In workspace mode, `pointerConfigRoot` (from the resolved pointer file)
54
+ * is inserted into the config resolution chain between cwd-local and
55
+ * TASKPLANE_WORKSPACE_ROOT. See `resolveConfigRoot()` in config-loader.ts.
56
+ *
57
+ * Returns the legacy `TaskRunnerConfig` (snake_case) shape.
58
+ */
59
+ export function loadTaskRunnerConfig(cwd: string, pointerConfigRoot?: string): TaskRunnerConfig {
60
+ const unified = loadProjectConfig(cwd, pointerConfigRoot);
61
+ return toTaskRunnerConfig(unified);
62
+ }
63
+
64
+ /**
65
+ * Load supervisor config from unified project config.
66
+ *
67
+ * Extracts the `orchestrator.supervisor` section from the unified config.
68
+ * Falls back to defaults if the section is missing (backward compatibility
69
+ * with configs created before TP-041).
70
+ *
71
+ * @since TP-041
72
+ */
73
+ export function loadSupervisorConfig(cwd: string, pointerConfigRoot?: string): SupervisorConfig {
74
+ const unified = loadProjectConfig(cwd, pointerConfigRoot);
75
+ const section = unified.orchestrator.supervisor;
76
+ if (!section) return { ...DEFAULT_SUPERVISOR_CONFIG };
77
+ return {
78
+ model: section.model ?? DEFAULT_SUPERVISOR_CONFIG.model,
79
+ autonomy: section.autonomy ?? DEFAULT_SUPERVISOR_CONFIG.autonomy,
80
+ };
81
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Context Window Resolution Utilities
3
+ *
4
+ * Canonical home for context window resolution logic used when spawning workers.
5
+ *
6
+ * The original `resolveContextWindow(config: TaskConfig, ctx: ExtensionContext)` signature
7
+ * has been adapted to accept only the fields it actually uses, avoiding a dependency
8
+ * on the task-runner-internal `TaskConfig` type. Behavior is identical.
9
+ *
10
+ * @since TP-161
11
+ */
12
+
13
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ // ── Context Window Constants ──────────────────────────────────────────
16
+
17
+ /** Default fallback context window when neither config nor model provides a value. */
18
+ export const FALLBACK_CONTEXT_WINDOW: number = 200_000;
19
+
20
+ // ── Context Window Resolution ─────────────────────────────────────────
21
+
22
+ /**
23
+ * Resolve the effective context window size for worker spawning.
24
+ *
25
+ * Resolution order (first non-zero value wins):
26
+ * 1. Explicit user config (configuredWindow > 0)
27
+ * 2. Auto-detect from pi model registry (ctx.model.contextWindow)
28
+ * 3. Fallback to 200K tokens
29
+ *
30
+ * A configuredWindow of 0 (or undefined) signals "auto-detect" — the default
31
+ * when no explicit value is configured. This allows pi's model registry to
32
+ * provide the real context window for the active model.
33
+ *
34
+ * @param configuredWindow - The `worker_context_window` from config (0 = auto-detect)
35
+ * @param ctx - The pi ExtensionContext for model registry auto-detection, or null
36
+ * @returns Object with `contextWindow` (resolved size) and `source` (diagnostic label)
37
+ *
38
+ * @example
39
+ * // With explicit config
40
+ * resolveContextWindow(500_000, ctx) // → { contextWindow: 500000, source: "explicit config" }
41
+ *
42
+ * // With auto-detect (config = 0)
43
+ * resolveContextWindow(0, ctx) // → { contextWindow: 200000, source: "auto-detected from anthropic/claude-opus-4-6" }
44
+ *
45
+ * // With fallback (no config, no model)
46
+ * resolveContextWindow(undefined, null) // → { contextWindow: 200000, source: "fallback 200000" }
47
+ */
48
+ export function resolveContextWindow(
49
+ configuredWindow: number | undefined,
50
+ ctx: ExtensionContext | null,
51
+ ): { contextWindow: number; source: string } {
52
+ // 1. Explicit user config — non-zero means the user set it deliberately
53
+ if (configuredWindow && configuredWindow > 0) {
54
+ return { contextWindow: configuredWindow, source: "explicit config" };
55
+ }
56
+
57
+ // 2. Auto-detect from pi model registry
58
+ const modelWindow = ctx?.model?.contextWindow;
59
+ if (modelWindow && modelWindow > 0) {
60
+ const modelId = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown";
61
+ return { contextWindow: modelWindow, source: `auto-detected from ${modelId}` };
62
+ }
63
+
64
+ // 3. Fallback
65
+ return { contextWindow: FALLBACK_CONTEXT_WINDOW, source: `fallback ${FALLBACK_CONTEXT_WINDOW}` };
66
+ }
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Diagnostic report generation for batch completion/failure.
3
+ *
4
+ * Emits two artifacts at batch-terminal time:
5
+ * 1. JSONL event log: `.pi/diagnostics/{opId}-{batchId}-events.jsonl`
6
+ * 2. Human-readable summary: `.pi/diagnostics/{opId}-{batchId}-report.md`
7
+ *
8
+ * Write failures are non-fatal — errors are logged but never crash
9
+ * the batch finalization flow.
10
+ *
11
+ * @module orch/diagnostic-reports
12
+ */
13
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
14
+ import { join } from "path";
15
+
16
+ import { execLog } from "./execution.ts";
17
+ import { resolveOperatorId } from "./naming.ts";
18
+ import type {
19
+ AllocatedLane,
20
+ LaneTaskOutcome,
21
+ OrchBatchRuntimeState,
22
+ OrchestratorConfig,
23
+ PersistedTaskRecord,
24
+ BatchDiagnostics,
25
+ PersistedTaskExitSummary,
26
+ } from "./types.ts";
27
+ import { defaultBatchDiagnostics } from "./types.ts";
28
+
29
+ // ── Types ────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * A single JSONL event representing one task's diagnostic record.
33
+ * Deterministically ordered by taskId for reproducible output.
34
+ */
35
+ export interface DiagnosticEvent {
36
+ /** Batch identifier */
37
+ batchId: string;
38
+ /** Final batch phase at emission time */
39
+ phase: string;
40
+ /** Execution mode: "repo" or "workspace" */
41
+ mode: string;
42
+ /** Task identifier */
43
+ taskId: string;
44
+ /** Task execution status */
45
+ status: string;
46
+ /** Exit classification (from diagnostics.taskExits or exitDiagnostic, fallback: "unknown") */
47
+ classification: string;
48
+ /** Estimated cost in USD (0 if unavailable) */
49
+ cost: number;
50
+ /** Wall-clock duration in seconds (0 if unavailable) */
51
+ durationSec: number;
52
+ /** Number of retry attempts (0 if never retried) */
53
+ retries: number;
54
+ /** Repo ID for workspace mode (null in repo mode or if unresolved) */
55
+ repoId: string | null;
56
+ /** Human-readable exit reason */
57
+ exitReason: string;
58
+ /** Epoch ms when task started (null if never started) */
59
+ startedAt: number | null;
60
+ /** Epoch ms when task ended (null if still running or never started) */
61
+ endedAt: number | null;
62
+ }
63
+
64
+ /**
65
+ * Input data for diagnostic report generation.
66
+ *
67
+ * Assembled by the caller (engine.ts / resume.ts) from available
68
+ * runtime state at the batch-terminal checkpoint.
69
+ */
70
+ export interface DiagnosticReportInput {
71
+ /** Orchestrator config (for opId resolution) */
72
+ orchConfig: OrchestratorConfig;
73
+ /** Batch ID */
74
+ batchId: string;
75
+ /** Final batch phase */
76
+ phase: string;
77
+ /** Execution mode */
78
+ mode: string;
79
+ /** Epoch ms when batch started */
80
+ startedAt: number;
81
+ /** Epoch ms when batch ended (null if still running) */
82
+ endedAt: number | null;
83
+ /** Per-task records from serialized state */
84
+ tasks: PersistedTaskRecord[];
85
+ /** Batch-level diagnostics (may have empty taskExits) */
86
+ diagnostics: BatchDiagnostics;
87
+ /** Summary counters */
88
+ succeededTasks: number;
89
+ failedTasks: number;
90
+ skippedTasks: number;
91
+ blockedTasks: number;
92
+ totalTasks: number;
93
+ /** State root path where `.pi/` lives */
94
+ stateRoot: string;
95
+ }
96
+
97
+ // ── Diagnostics Directory ────────────────────────────────────────────
98
+
99
+ /** Resolve the diagnostics directory path. */
100
+ export function diagnosticsDir(stateRoot: string): string {
101
+ return join(stateRoot, ".pi", "diagnostics");
102
+ }
103
+
104
+ /** Ensure `.pi/diagnostics/` exists, creating it if needed. */
105
+ function ensureDiagnosticsDir(stateRoot: string): string {
106
+ const dir = diagnosticsDir(stateRoot);
107
+ if (!existsSync(dir)) {
108
+ mkdirSync(dir, { recursive: true });
109
+ }
110
+ return dir;
111
+ }
112
+
113
+ // ── Event Generation ─────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Build diagnostic events from task records and diagnostics data.
117
+ *
118
+ * Data source precedence for each task:
119
+ * 1. `diagnostics.taskExits[taskId]` — canonical v3 exit summary (classification, cost, duration, retries)
120
+ * 2. `task.exitDiagnostic.classification` — per-task exit diagnostic on the task record
121
+ * 3. Fallback defaults: classification="unknown", cost=0, durationSec computed from startedAt/endedAt, retries=0
122
+ *
123
+ * Tasks are sorted by taskId for deterministic output.
124
+ */
125
+ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticEvent[] {
126
+ const { batchId, phase, mode, tasks, diagnostics } = input;
127
+ const taskExits = diagnostics.taskExits ?? {};
128
+
129
+ // Sort tasks by taskId for deterministic ordering
130
+ const sortedTasks = [...tasks].sort((a, b) => a.taskId.localeCompare(b.taskId));
131
+
132
+ return sortedTasks.map((task): DiagnosticEvent => {
133
+ const exitSummary: PersistedTaskExitSummary | undefined = taskExits[task.taskId];
134
+
135
+ // Classification: prefer taskExits, then exitDiagnostic, then "unknown"
136
+ let classification = "unknown";
137
+ if (exitSummary) {
138
+ classification = exitSummary.classification;
139
+ } else if (task.exitDiagnostic?.classification) {
140
+ classification = task.exitDiagnostic.classification;
141
+ }
142
+
143
+ // Cost: from taskExits, else 0
144
+ const cost = exitSummary?.cost ?? 0;
145
+
146
+ // Duration: from taskExits, else compute from timestamps, else 0
147
+ let durationSec = 0;
148
+ if (exitSummary) {
149
+ durationSec = exitSummary.durationSec;
150
+ } else if (task.startedAt !== null && task.endedAt !== null) {
151
+ durationSec = Math.round((task.endedAt - task.startedAt) / 1000);
152
+ }
153
+
154
+ // Retries: from taskExits, else 0
155
+ const retries = exitSummary?.retries ?? 0;
156
+
157
+ // Repo ID: prefer resolvedRepoId, then repoId (workspace mode), else null
158
+ const repoId = task.resolvedRepoId ?? task.repoId ?? null;
159
+
160
+ return {
161
+ batchId,
162
+ phase,
163
+ mode,
164
+ taskId: task.taskId,
165
+ status: task.status,
166
+ classification,
167
+ cost,
168
+ durationSec,
169
+ retries,
170
+ repoId,
171
+ exitReason: task.exitReason,
172
+ startedAt: task.startedAt,
173
+ endedAt: task.endedAt,
174
+ };
175
+ });
176
+ }
177
+
178
+ // ── JSONL Generation ─────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Serialize diagnostic events to JSONL format (one JSON object per line).
182
+ */
183
+ export function eventsToJsonl(events: DiagnosticEvent[]): string {
184
+ return events.map((e) => JSON.stringify(e)).join("\n") + "\n";
185
+ }
186
+
187
+ // ── Human-Readable Summary ───────────────────────────────────────────
188
+
189
+ /**
190
+ * Format a duration in seconds to a human-readable string.
191
+ * e.g., 3661 → "1h 1m 1s", 42 → "42s"
192
+ */
193
+ function formatDuration(seconds: number): string {
194
+ if (seconds <= 0) return "0s";
195
+ const h = Math.floor(seconds / 3600);
196
+ const m = Math.floor((seconds % 3600) / 60);
197
+ const s = seconds % 60;
198
+ const parts: string[] = [];
199
+ if (h > 0) parts.push(`${h}h`);
200
+ if (m > 0) parts.push(`${m}m`);
201
+ if (s > 0 || parts.length === 0) parts.push(`${s}s`);
202
+ return parts.join(" ");
203
+ }
204
+
205
+ /**
206
+ * Format a cost value to a display string.
207
+ * Shows "$0.00" for zero, otherwise up to 4 decimal places.
208
+ */
209
+ function formatCost(cost: number): string {
210
+ if (cost === 0) return "$0.00";
211
+ return `$${cost.toFixed(4)}`;
212
+ }
213
+
214
+ /**
215
+ * Generate a human-readable markdown summary report.
216
+ */
217
+ export function buildMarkdownReport(
218
+ input: DiagnosticReportInput,
219
+ events: DiagnosticEvent[],
220
+ ): string {
221
+ const { batchId, phase, mode, startedAt, endedAt, diagnostics } = input;
222
+ const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input;
223
+
224
+ const batchDurationSec = endedAt ? Math.round((endedAt - startedAt) / 1000) : 0;
225
+ const batchCost = diagnostics.batchCost ?? 0;
226
+
227
+ const lines: string[] = [];
228
+
229
+ // ── Header ──
230
+ lines.push(`# Batch Diagnostic Report`);
231
+ lines.push(``);
232
+
233
+ // ── Batch Overview ──
234
+ lines.push(`## Batch Overview`);
235
+ lines.push(``);
236
+ lines.push(`| Field | Value |`);
237
+ lines.push(`|-------|-------|`);
238
+ lines.push(`| Batch ID | \`${batchId}\` |`);
239
+ lines.push(`| Final Phase | ${phase} |`);
240
+ lines.push(`| Mode | ${mode} |`);
241
+ lines.push(`| Duration | ${formatDuration(batchDurationSec)} |`);
242
+ lines.push(`| Total Cost | ${formatCost(batchCost)} |`);
243
+ lines.push(`| Total Tasks | ${totalTasks} |`);
244
+ lines.push(`| Succeeded | ${succeededTasks} |`);
245
+ lines.push(`| Failed | ${failedTasks} |`);
246
+ lines.push(`| Skipped | ${skippedTasks} |`);
247
+ lines.push(`| Blocked | ${blockedTasks} |`);
248
+ lines.push(``);
249
+
250
+ // ── Per-Task Table ──
251
+ lines.push(`## Per-Task Results`);
252
+ lines.push(``);
253
+
254
+ if (events.length === 0) {
255
+ lines.push(`_No task records available._`);
256
+ lines.push(``);
257
+ } else {
258
+ lines.push(`| Task | Status | Classification | Cost | Duration | Retries |`);
259
+ lines.push(`|------|--------|---------------|------|----------|---------|`);
260
+ for (const evt of events) {
261
+ lines.push(
262
+ `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |`,
263
+ );
264
+ }
265
+ lines.push(``);
266
+ }
267
+
268
+ // ── Per-Repo Breakdown (workspace mode only) ──
269
+ if (mode === "workspace") {
270
+ lines.push(`## Per-Repo Breakdown`);
271
+ lines.push(``);
272
+
273
+ // Group events by repoId
274
+ const byRepo = new Map<string, DiagnosticEvent[]>();
275
+ for (const evt of events) {
276
+ const key = evt.repoId ?? "(unresolved)";
277
+ if (!byRepo.has(key)) byRepo.set(key, []);
278
+ byRepo.get(key)!.push(evt);
279
+ }
280
+
281
+ // Sort repo keys for deterministic output
282
+ const repoKeys = [...byRepo.keys()].sort();
283
+
284
+ if (repoKeys.length === 0) {
285
+ lines.push(`_No per-repo data available._`);
286
+ lines.push(``);
287
+ } else {
288
+ for (const repoKey of repoKeys) {
289
+ const repoEvents = byRepo.get(repoKey)!;
290
+ const repoSucceeded = repoEvents.filter((e) => e.status === "succeeded").length;
291
+ const repoFailed = repoEvents.filter((e) => e.status === "failed").length;
292
+ const repoCost = repoEvents.reduce((sum, e) => sum + e.cost, 0);
293
+
294
+ lines.push(`### ${repoKey}`);
295
+ lines.push(``);
296
+ lines.push(`- Tasks: ${repoEvents.length} (${repoSucceeded} succeeded, ${repoFailed} failed)`);
297
+ lines.push(`- Cost: ${formatCost(repoCost)}`);
298
+ lines.push(``);
299
+
300
+ lines.push(`| Task | Status | Classification | Cost | Duration |`);
301
+ lines.push(`|------|--------|---------------|------|----------|`);
302
+ for (const evt of repoEvents) {
303
+ lines.push(
304
+ `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |`,
305
+ );
306
+ }
307
+ lines.push(``);
308
+ }
309
+ }
310
+ }
311
+
312
+ // ── Footer ──
313
+ lines.push(`---`);
314
+ lines.push(`_Generated at ${new Date().toISOString()}_`);
315
+ lines.push(``);
316
+
317
+ return lines.join("\n");
318
+ }
319
+
320
+ // ── Report Emission ──────────────────────────────────────────────────
321
+
322
+ /**
323
+ * Emit diagnostic reports (JSONL event log + markdown summary) at batch terminal.
324
+ *
325
+ * This function is called exactly once per batch run, immediately after
326
+ * the `persistRuntimeState("batch-terminal", ...)` call in both engine.ts
327
+ * and resume.ts.
328
+ *
329
+ * **Non-fatal:** All errors during report generation or writing are caught
330
+ * and logged via `execLog()`. They never propagate to the caller or crash
331
+ * the batch finalization flow.
332
+ *
333
+ * @param input - Diagnostic report input assembled from runtime state
334
+ */
335
+ export function emitDiagnosticReports(input: DiagnosticReportInput): void {
336
+ try {
337
+ const opId = resolveOperatorId(input.orchConfig);
338
+ const dir = ensureDiagnosticsDir(input.stateRoot);
339
+
340
+ const events = buildDiagnosticEvents(input);
341
+
342
+ // ── JSONL event log ──
343
+ const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
344
+ const jsonlContent = eventsToJsonl(events);
345
+ writeFileSync(jsonlPath, jsonlContent, "utf-8");
346
+
347
+ // ── Markdown summary ──
348
+ const reportPath = join(dir, `${opId}-${input.batchId}-report.md`);
349
+ const reportContent = buildMarkdownReport(input, events);
350
+ writeFileSync(reportPath, reportContent, "utf-8");
351
+
352
+ execLog("diagnostics", input.batchId, `emitted diagnostic reports`, {
353
+ jsonl: jsonlPath,
354
+ report: reportPath,
355
+ taskCount: events.length,
356
+ });
357
+ } catch (err: unknown) {
358
+ const msg = err instanceof Error ? err.message : String(err);
359
+ execLog("diagnostics", input.batchId, `failed to emit diagnostic reports: ${msg}`);
360
+ // Non-fatal: do not rethrow. The batch finalization continues.
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Assemble diagnostic report input from batch runtime state.
366
+ *
367
+ * Convenience helper for engine.ts and resume.ts to call at the
368
+ * batch-terminal checkpoint. Builds the full task registry from the
369
+ * wave plan + allocated lanes + task outcomes — matching the canonical
370
+ * model used by `serializeBatchState()`. This ensures diagnostics cover
371
+ * all tasks (including pending/blocked tasks that were never allocated)
372
+ * and preserve repo attribution fields for workspace per-repo breakdown.
373
+ *
374
+ * @param orchConfig - Orchestrator configuration
375
+ * @param batchState - Current runtime batch state (at batch-terminal)
376
+ * @param wavePlan - Wave plan (array of waves, each an array of taskIds)
377
+ * @param lanes - Allocated lanes with task/repo metadata
378
+ * @param allTaskOutcomes - All task outcomes accumulated during execution
379
+ * @param stateRoot - State root path where `.pi/` lives
380
+ */
381
+ export function assembleDiagnosticInput(
382
+ orchConfig: OrchestratorConfig,
383
+ batchState: OrchBatchRuntimeState,
384
+ wavePlan: string[][],
385
+ lanes: AllocatedLane[],
386
+ allTaskOutcomes: LaneTaskOutcome[],
387
+ stateRoot: string,
388
+ ): DiagnosticReportInput {
389
+ // Build lookup maps for fast per-task enrichment (mirrors serializeBatchState logic).
390
+ const laneByTaskId = new Map<string, AllocatedLane>();
391
+ const allocatedTaskByTaskId = new Map<
392
+ string,
393
+ { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }
394
+ >();
395
+ for (const lane of lanes) {
396
+ for (const allocTask of lane.tasks) {
397
+ laneByTaskId.set(allocTask.taskId, lane);
398
+ allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
399
+ }
400
+ }
401
+
402
+ // Latest outcome wins (allTaskOutcomes is append/replace ordered by time).
403
+ const outcomeByTaskId = new Map<string, LaneTaskOutcome>();
404
+ for (const outcome of allTaskOutcomes) {
405
+ outcomeByTaskId.set(outcome.taskId, outcome);
406
+ }
407
+
408
+ // Build full task ID set from wave plan + outcomes (covers pending/blocked tasks).
409
+ const taskIdSet = new Set<string>();
410
+ for (const wave of wavePlan) {
411
+ for (const taskId of wave) taskIdSet.add(taskId);
412
+ }
413
+ for (const outcome of allTaskOutcomes) {
414
+ taskIdSet.add(outcome.taskId);
415
+ }
416
+
417
+ // Build task records sorted by taskId for deterministic output.
418
+ const tasks: PersistedTaskRecord[] = [...taskIdSet].sort().map((taskId): PersistedTaskRecord => {
419
+ const lane = laneByTaskId.get(taskId);
420
+ const outcome = outcomeByTaskId.get(taskId);
421
+ const allocated = allocatedTaskByTaskId.get(taskId);
422
+
423
+ const record: PersistedTaskRecord = {
424
+ taskId,
425
+ laneNumber: lane?.laneNumber ?? 0,
426
+ sessionName: outcome?.sessionName || lane?.laneSessionId || "",
427
+ status: outcome?.status ?? "pending",
428
+ taskFolder: "",
429
+ startedAt: outcome?.startTime ?? null,
430
+ endedAt: outcome?.endTime ?? null,
431
+ doneFileFound: outcome?.doneFileFound ?? false,
432
+ exitReason: outcome?.exitReason ?? "",
433
+ };
434
+
435
+ // Repo attribution from allocated task metadata (workspace mode).
436
+ if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
437
+ record.repoId = allocated.allocatedTask.task.promptRepoId;
438
+ }
439
+ if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
440
+ record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
441
+ }
442
+
443
+ // Partial progress fields from outcome.
444
+ if (outcome?.partialProgressCommits !== undefined) {
445
+ record.partialProgressCommits = outcome.partialProgressCommits;
446
+ }
447
+ if (outcome?.partialProgressBranch !== undefined) {
448
+ record.partialProgressBranch = outcome.partialProgressBranch;
449
+ }
450
+
451
+ // v3: Exit diagnostic from outcome.
452
+ if (outcome?.exitDiagnostic !== undefined) {
453
+ record.exitDiagnostic = outcome.exitDiagnostic;
454
+ }
455
+
456
+ return record;
457
+ });
458
+
459
+ return {
460
+ orchConfig,
461
+ batchId: batchState.batchId,
462
+ phase: batchState.phase,
463
+ mode: batchState.mode ?? "repo",
464
+ startedAt: batchState.startedAt,
465
+ endedAt: batchState.endedAt,
466
+ tasks,
467
+ diagnostics: batchState.diagnostics ?? defaultBatchDiagnostics(),
468
+ succeededTasks: batchState.succeededTasks,
469
+ failedTasks: batchState.failedTasks,
470
+ skippedTasks: batchState.skippedTasks,
471
+ blockedTasks: batchState.blockedTasks,
472
+ totalTasks: batchState.totalTasks,
473
+ stateRoot,
474
+ };
475
+ }