omegon 0.6.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 (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,808 @@
1
+ /**
2
+ * cleave/dispatcher — Child process dispatch and monitoring.
3
+ *
4
+ * Spawns `pi` subprocesses for each child task, using the same
5
+ * subagent pattern as pi's example extension. Each child runs in
6
+ * its own git worktree with an isolated context.
7
+ *
8
+ * Supports two backends:
9
+ * - "cloud": spawns a full `pi` process (uses cloud API)
10
+ * - "local": spawns `pi` with --model pointing to a local Ollama model
11
+ *
12
+ * The dispatcher handles:
13
+ * - Dependency-ordered wave execution
14
+ * - Concurrency limiting
15
+ * - Timeout enforcement
16
+ * - Result harvesting from task files
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
23
+ import { DASHBOARD_UPDATE_EVENT, sharedState } from "../shared-state.ts";
24
+ import type { ChildState, CleaveState, ModelTier } from "./types.ts";
25
+ import { computeDispatchWaves } from "./planner.ts";
26
+ import { executeWithReview, type ReviewConfig, type ReviewExecutor, DEFAULT_REVIEW_CONFIG } from "./review.ts";
27
+ import { saveState } from "./workspace.ts";
28
+ import { resolveTier, getDefaultPolicy, type ProviderRoutingPolicy, type RegistryModel } from "../lib/model-routing.ts";
29
+
30
+ // ─── Large-run threshold ────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Number of children at or above which a run is considered "large".
34
+ * When session policy requirePreflightForLargeRuns is true and this threshold
35
+ * is exceeded, the operator is asked for their preferred provider before dispatch.
36
+ *
37
+ * Also triggers for runs with review enabled where children >= 3.
38
+ */
39
+ export const LARGE_RUN_THRESHOLD = 4;
40
+
41
+ // ─── Explicit model resolution ──────────────────────────────────────────────
42
+
43
+ /**
44
+ * Resolve an abstract tier to a concrete model ID string using the session
45
+ * routing policy and the available model registry.
46
+ *
47
+ * This is the replacement for mapModelTierToFlag() — it produces explicit model
48
+ * IDs rather than fuzzy aliases, satisfying the design decision:
49
+ * "Prefer explicit model IDs over fuzzy tier aliases at execution time".
50
+ *
51
+ * @param tier Abstract tier (local|retribution|victory|gloriana)
52
+ * @param models Snapshot of the pi model registry
53
+ * @param policy Session routing policy
54
+ * @param localModel Local model name (for "local" tier fallback)
55
+ * @returns Explicit model ID to pass to --model, or undefined
56
+ */
57
+ export function resolveModelIdForTier(
58
+ tier: ModelTier,
59
+ models: RegistryModel[],
60
+ policy: ProviderRoutingPolicy,
61
+ localModel?: string,
62
+ ): string | undefined {
63
+ // "local" tier: use the provided local model name directly
64
+ if (tier === "local") {
65
+ return localModel;
66
+ }
67
+
68
+ // Use the shared resolver to get an explicit model ID
69
+ const resolved = resolveTier(tier, models, policy);
70
+ if (resolved) {
71
+ return resolved.modelId;
72
+ }
73
+
74
+ // Fallback: if resolver found nothing (empty registry, no API keys),
75
+ // return undefined so no --model flag is passed. Callers must NEVER pass
76
+ // a bare tier alias — that violates the spec decision "Prefer explicit model IDs
77
+ // over fuzzy tier aliases at execution time."
78
+ return undefined;
79
+ }
80
+
81
+ export function emitCleaveChildProgress(
82
+ pi: Pick<ExtensionAPI, "events">,
83
+ childId: number,
84
+ patch: { status?: "pending" | "running" | "done" | "failed"; elapsed?: number; startedAt?: number; lastLine?: string; worktreePath?: string },
85
+ ): void {
86
+ const cleaveState = (sharedState as any).cleave;
87
+ if (!cleaveState?.children?.[childId]) return;
88
+ if (patch.status !== undefined) {
89
+ cleaveState.children[childId].status = patch.status;
90
+ }
91
+ if (patch.elapsed !== undefined) {
92
+ cleaveState.children[childId].elapsed = patch.elapsed;
93
+ }
94
+ if (patch.startedAt !== undefined) {
95
+ cleaveState.children[childId].startedAt = patch.startedAt;
96
+ }
97
+ if (patch.worktreePath !== undefined) {
98
+ cleaveState.children[childId].worktreePath = patch.worktreePath;
99
+ }
100
+ if (patch.lastLine !== undefined) {
101
+ // Update lastLine for backward compat
102
+ cleaveState.children[childId].lastLine = patch.lastLine;
103
+ // Append to ring buffer (cap at 30)
104
+ const child = cleaveState.children[childId];
105
+ if (!child.recentLines) child.recentLines = [];
106
+ child.recentLines.push(patch.lastLine);
107
+ if (child.recentLines.length > 30) child.recentLines.splice(0, child.recentLines.length - 30);
108
+ }
109
+ cleaveState.updatedAt = Date.now();
110
+ pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "cleave", childId, patch });
111
+ }
112
+
113
+ // ─── Result section parsing ─────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Extract just the ## Result section from a task file.
117
+ *
118
+ * The Contract section contains instructional text like
119
+ * "set status to NEEDS_DECOMPOSITION" which must NOT be matched
120
+ * as an actual status. By isolating the Result section, we only
121
+ * match status strings the child agent actually wrote.
122
+ *
123
+ * Returns the content from "## Result" to the next "##" heading or EOF.
124
+ * Returns empty string if no Result section found.
125
+ */
126
+ export function extractResultSection(content: string): string {
127
+ const resultIdx = content.indexOf("## Result");
128
+ if (resultIdx === -1) return "";
129
+ const afterResult = content.slice(resultIdx);
130
+ // Find the next ## heading after the Result heading itself
131
+ const nextHeading = afterResult.indexOf("\n## ", 1);
132
+ return nextHeading === -1 ? afterResult : afterResult.slice(0, nextHeading);
133
+ }
134
+
135
+ // ─── Model resolution ───────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Scope-based autoclassification thresholds.
139
+ *
140
+ * Ground rule: once classified as "local", the child STAYS local.
141
+ * If the local model fails, the task fails — it never silently escalates
142
+ * to cloud. This prevents autoclassification from being a leaky abstraction
143
+ * that degrades to cloud spend under pressure.
144
+ */
145
+ const LOCAL_SCOPE_THRESHOLD = 3; // ≤ this many files → local
146
+ const SONNET_SCOPE_THRESHOLD = 8; // ≤ this many files → victory, > → gloriana
147
+
148
+ /**
149
+ * Tier ordering for floor comparison. Higher number = higher tier.
150
+ * Used by applyEffortFloor to determine "higher of the two".
151
+ */
152
+ const TIER_ORDER: Record<ModelTier, number> = {
153
+ local: 0,
154
+ retribution: 1,
155
+ victory: 2,
156
+ gloriana: 3,
157
+ };
158
+
159
+ /**
160
+ * Classify a child's execution tier based on scope analysis.
161
+ *
162
+ * Returns a tier suggestion or undefined if scope doesn't give a clear signal.
163
+ * The caller decides whether to use this or defer to other resolution steps.
164
+ */
165
+ export function classifyByScope(
166
+ scope: string[],
167
+ ): ModelTier | undefined {
168
+ if (scope.length === 0) return undefined;
169
+
170
+ // Count the unique non-test files in scope
171
+ const nonTestFiles = scope.filter((f) => !f.endsWith(".test.ts") && !f.endsWith(".test.js") && !f.endsWith(".spec.ts") && !f.endsWith(".spec.js"));
172
+ const effectiveSize = nonTestFiles.length;
173
+
174
+ if (effectiveSize <= LOCAL_SCOPE_THRESHOLD) return "local";
175
+ if (effectiveSize <= SONNET_SCOPE_THRESHOLD) return "victory";
176
+ return "gloriana";
177
+ }
178
+
179
+ /**
180
+ * Apply effort-tier floor to a classified model tier.
181
+ *
182
+ * Reads sharedState.effort (written by the effort extension) and:
183
+ * 1. If effort is undefined → return classified unchanged (backward compat)
184
+ * 2. If effort.cleavePreferLocal is true → force "local" (Low/Average tiers)
185
+ * 3. Otherwise → return the higher of classified vs effort.cleaveFloor
186
+ *
187
+ * This is called at the end of resolveExecuteModel (after scope/skill
188
+ * classification) to enforce the operator's global effort policy.
189
+ * Explicit executeModel annotations bypass this — they are checked
190
+ * before applyEffortFloor is reached.
191
+ */
192
+ export function applyEffortFloor(classified: ModelTier): ModelTier {
193
+ const effort = (sharedState as any).effort as
194
+ | { cleavePreferLocal: boolean; cleaveFloor: ModelTier }
195
+ | undefined;
196
+
197
+ // (1) No effort state — backward compatible passthrough
198
+ if (!effort) return classified;
199
+
200
+ // (2) Effort forces all-local (Low/Average tiers)
201
+ if (effort.cleavePreferLocal && classified !== "local") return "local";
202
+
203
+ // (3) Floor enforcement — return the higher of classified vs floor
204
+ const floor = effort.cleaveFloor;
205
+ if (floor && TIER_ORDER[floor] > TIER_ORDER[classified]) return floor;
206
+
207
+ return classified;
208
+ }
209
+
210
+ /**
211
+ * Resolve the execution model tier for a child.
212
+ *
213
+ * Resolution order (first non-null wins):
214
+ * 1. Explicit annotation — child.executeModel already set (from plan or task annotation)
215
+ * 2. Scope-based autoclassification — ≤3 files → local, ≤8 → victory, >8 → gloriana
216
+ * (only when local model is available)
217
+ * 3. Skill tier hint — highest preferredTier from matched skills
218
+ * 4. Default — victory
219
+ *
220
+ * NO-FAIL-PAST RULE: Once a child is assigned "local" tier here, the dispatcher
221
+ * will NOT escalate to cloud on failure. The child either succeeds locally or fails.
222
+ * This is enforced structurally — dispatchSingleChild has no retry/escalation path.
223
+ */
224
+ export function resolveExecuteModel(
225
+ child: { scope?: string[]; skills?: string[]; executeModel?: ModelTier },
226
+ preferLocal: boolean,
227
+ localModelAvailable: boolean,
228
+ getPreferredTierFn?: (skills: string[]) => ModelTier | undefined,
229
+ ): ModelTier {
230
+ // 1. Explicit annotation on the child plan — always respected
231
+ // Bypasses effort floor — explicit annotations are deliberate overrides.
232
+ if (child.executeModel) return child.executeModel;
233
+
234
+ // Effort-based preferLocal override: if the effort tier says cleavePreferLocal,
235
+ // treat this dispatch as prefer-local regardless of the caller's flag.
236
+ const effectivePreferLocal = preferLocal || !!(sharedState as any).effort?.cleavePreferLocal;
237
+
238
+ let classified: ModelTier | undefined;
239
+
240
+ // 2. Scope-based autoclassification (when local is available)
241
+ if (localModelAvailable && child.scope && child.scope.length > 0) {
242
+ const scopeTier = classifyByScope(child.scope);
243
+ if (scopeTier) {
244
+ // preferLocal mode: cap at local (never auto-classify UP to cloud)
245
+ classified = (effectivePreferLocal && scopeTier !== "local") ? "local" : scopeTier;
246
+ }
247
+ }
248
+
249
+ // 2b. Global prefer_local flag (no scope info but local requested)
250
+ if (!classified && effectivePreferLocal && localModelAvailable) {
251
+ classified = "local";
252
+ }
253
+
254
+ // 3. Skill-based tier hint
255
+ if (!classified && child.skills && child.skills.length > 0 && getPreferredTierFn) {
256
+ const tier = getPreferredTierFn(child.skills);
257
+ if (tier) classified = tier;
258
+ }
259
+
260
+ // 4. Default
261
+ if (!classified) classified = "victory";
262
+
263
+ // 5. Apply effort floor (raises tier if below minimum, or forces local)
264
+ return applyEffortFloor(classified);
265
+ }
266
+
267
+ // ─── Child prompt construction ──────────────────────────────────────────────
268
+
269
+ /**
270
+ * Build the prompt sent to a child pi process.
271
+ *
272
+ * Uses a sandwich pattern: contract first, context middle, contract reminder last.
273
+ * Skill directives (D2) instruct the child to read SKILL.md files for
274
+ * domain-specific guidance rather than inlining them (200+ lines each).
275
+ */
276
+ export function buildChildPrompt(
277
+ taskFileContent: string,
278
+ rootDirective: string,
279
+ workspacePath: string,
280
+ ): string {
281
+ // Detect if the task file has a Specialist Skills section
282
+ const hasSkills = taskFileContent.includes("## Specialist Skills");
283
+
284
+ const contractLines = [
285
+ "## Contract",
286
+ "",
287
+ "You are a child agent managed by the Cleave orchestrator. Follow these rules:",
288
+ "",
289
+ "1. **Scope**: Only work on files within your task scope. Do not modify files outside it.",
290
+ "2. **Task file**: Update your task file when done:",
291
+ " - Set **Status:** to exactly one of: SUCCESS, PARTIAL, FAILED, or NEEDS_DECOMPOSITION",
292
+ " - Fill in Summary, Artifacts, Decisions Made, Interfaces Published",
293
+ "3. **Commits**: Commit your work with clear messages. Do not push.",
294
+ "4. **No side effects**: Do not install global packages or modify system state.",
295
+ "5. **Verification**: Run tests or checks and report results in the Verification section.",
296
+ `6. **Workspace**: ${workspacePath}`,
297
+ ];
298
+
299
+ if (hasSkills) {
300
+ contractLines.push(
301
+ "7. **Skills**: Your task includes a Specialist Skills section. Use the `read` tool to load each listed SKILL.md file before starting work. Follow the conventions and patterns described in those skill files.",
302
+ );
303
+ }
304
+
305
+ return [
306
+ contractLines.join("\n"),
307
+ "",
308
+ "## Root Directive",
309
+ "",
310
+ `> ${rootDirective}`,
311
+ "",
312
+ "## Your Task",
313
+ "",
314
+ taskFileContent,
315
+ "",
316
+ "## REMINDER",
317
+ "",
318
+ "Update your task file with the correct status when done. Stay within scope.",
319
+ ].join("\n");
320
+ }
321
+
322
+ // ─── Process spawning ───────────────────────────────────────────────────────
323
+
324
+ interface ChildResult {
325
+ exitCode: number;
326
+ stdout: string;
327
+ stderr: string;
328
+ }
329
+
330
+ /**
331
+ * Spawn a `pi` process for a child task.
332
+ *
333
+ * Uses `pi -p --no-session` for non-interactive execution.
334
+ * The prompt is passed via stdin.
335
+ */
336
+ /**
337
+ * Decide whether a raw stdout line from a child pi process is meaningful
338
+ * enough to show as a live status update.
339
+ *
340
+ * pi -p --no-session output includes JSON tool-call records, blank separators,
341
+ * and short metadata lines — these are noisy. We keep only lines that look
342
+ * like human-readable prose or file-action descriptions.
343
+ */
344
+ function isChildStatusLine(raw: string): boolean {
345
+ const s = raw.trim();
346
+ if (s.length < 12) return false;
347
+ // JSON objects / arrays — tool call records
348
+ if (s.startsWith("{") || s.startsWith("[")) return false;
349
+ // ANSI / box-drawing heavy lines (progress bars, borders)
350
+ // eslint-disable-next-line no-control-regex
351
+ if (/\x1b\[/.test(s)) return false;
352
+ // Separator / divider lines
353
+ if (/^[-─═━=*#>|]+\s*$/.test(s)) return false;
354
+ // Very long lines are likely encoded / binary data
355
+ if (s.length > 240) return false;
356
+ return true;
357
+ }
358
+
359
+ /** Strip ANSI codes from a line for display in the dashboard. */
360
+ function stripAnsiForStatus(s: string): string {
361
+ // eslint-disable-next-line no-control-regex
362
+ return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").trim();
363
+ }
364
+
365
+ async function spawnChild(
366
+ prompt: string,
367
+ cwd: string,
368
+ timeoutMs: number,
369
+ signal?: AbortSignal,
370
+ localModel?: string,
371
+ onLine?: (line: string) => void,
372
+ ): Promise<ChildResult> {
373
+ const args = ["-p", "--no-session"];
374
+ if (localModel) {
375
+ args.push("--model", localModel);
376
+ }
377
+
378
+ return new Promise<ChildResult>((resolve) => {
379
+ let stdout = "";
380
+ let stderr = "";
381
+ let killed = false;
382
+
383
+ const proc = spawn("pi", args, {
384
+ cwd,
385
+ stdio: ["pipe", "pipe", "pipe"],
386
+ env: {
387
+ ...process.env,
388
+ // Prevent nested detection issues
389
+ PI_CHILD: "1",
390
+ // https://warhammer40k.fandom.com/wiki/Alpha_Legion
391
+ I_AM: "alpharius",
392
+ },
393
+ });
394
+
395
+ // Write prompt to stdin
396
+ if (proc.stdin) {
397
+ proc.stdin.write(prompt);
398
+ proc.stdin.end();
399
+ }
400
+
401
+ let lineBuf = "";
402
+ proc.stdout?.on("data", (data: Buffer) => {
403
+ const chunk = data.toString();
404
+ stdout += chunk;
405
+ if (onLine) {
406
+ // Parse line by line and forward meaningful lines
407
+ lineBuf += chunk;
408
+ const parts = lineBuf.split("\n");
409
+ lineBuf = parts.pop() ?? "";
410
+ for (const part of parts) {
411
+ const clean = stripAnsiForStatus(part);
412
+ if (isChildStatusLine(clean)) onLine(clean);
413
+ }
414
+ }
415
+ });
416
+ proc.stderr?.on("data", (data) => { stderr += data.toString(); });
417
+
418
+ // Timeout enforcement
419
+ const timer = setTimeout(() => {
420
+ killed = true;
421
+ proc.kill("SIGTERM");
422
+ setTimeout(() => {
423
+ if (!proc.killed) proc.kill("SIGKILL");
424
+ }, 5_000);
425
+ }, timeoutMs);
426
+
427
+ // Abort signal support
428
+ const onAbort = () => {
429
+ killed = true;
430
+ proc.kill("SIGTERM");
431
+ };
432
+ signal?.addEventListener("abort", onAbort, { once: true });
433
+
434
+ proc.on("close", (code) => {
435
+ clearTimeout(timer);
436
+ signal?.removeEventListener("abort", onAbort);
437
+ resolve({
438
+ exitCode: killed ? -1 : (code ?? 1),
439
+ stdout,
440
+ stderr: killed ? `Killed (timeout or abort)\n${stderr}` : stderr,
441
+ });
442
+ });
443
+
444
+ proc.on("error", (err) => {
445
+ clearTimeout(timer);
446
+ signal?.removeEventListener("abort", onAbort);
447
+ resolve({
448
+ exitCode: 1,
449
+ stdout: "",
450
+ stderr: `Failed to spawn pi: ${err.message}`,
451
+ });
452
+ });
453
+ });
454
+ }
455
+
456
+ // ─── Concurrency control ────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * Simple async semaphore. Guarantees that at most `limit` tasks run
460
+ * concurrently. Uses a queue of resolve callbacks — no polling, no races.
461
+ */
462
+ export class AsyncSemaphore {
463
+ private count: number;
464
+ private readonly limit: number;
465
+ private readonly waiters: Array<() => void> = [];
466
+
467
+ constructor(limit: number) {
468
+ this.limit = limit;
469
+ this.count = 0;
470
+ }
471
+
472
+ async acquire(): Promise<void> {
473
+ if (this.count < this.limit) {
474
+ this.count++;
475
+ return;
476
+ }
477
+ return new Promise<void>((resolve) => {
478
+ this.waiters.push(resolve);
479
+ });
480
+ }
481
+
482
+ release(): void {
483
+ const next = this.waiters.shift();
484
+ if (next) {
485
+ // Hand the slot directly to the next waiter (count stays the same)
486
+ next();
487
+ } else {
488
+ this.count--;
489
+ }
490
+ }
491
+
492
+ /** Current number of acquired slots (for testing/debugging). */
493
+ get activeCount(): number { return this.count; }
494
+ /** Current number of waiters in queue (for testing/debugging). */
495
+ get waitingCount(): number { return this.waiters.length; }
496
+ }
497
+
498
+ // ─── Dispatch orchestration ─────────────────────────────────────────────────
499
+
500
+ /**
501
+ * Dispatch all children in dependency-ordered waves.
502
+ *
503
+ * Children within a wave run in parallel (up to maxParallel).
504
+ * Waves are executed sequentially.
505
+ */
506
+ export async function dispatchChildren(
507
+ pi: ExtensionAPI,
508
+ state: CleaveState,
509
+ maxParallel: number,
510
+ childTimeoutMs: number,
511
+ localModel?: string,
512
+ signal?: AbortSignal,
513
+ onProgress?: (msg: string) => void,
514
+ reviewConfig?: ReviewConfig,
515
+ ): Promise<void> {
516
+ const statusResult = await pi.exec("git", ["status", "--porcelain"], {
517
+ cwd: state.repoPath,
518
+ timeout: 5_000,
519
+ });
520
+ if (statusResult.stdout.trim()) {
521
+ throw new Error(
522
+ "Dispatch blocked: repository became dirty before child execution. Resolve the dirty-tree preflight before dispatching.\n" +
523
+ statusResult.stdout.trim(),
524
+ );
525
+ }
526
+
527
+ // ── Large-run preflight ──────────────────────────────────────────────────
528
+ // Before dispatching, check if this run qualifies as "large" and the session
529
+ // policy requires operator input before committing to a provider.
530
+ const policy: ProviderRoutingPolicy = (sharedState as any).routingPolicy ?? getDefaultPolicy();
531
+ const childCount = state.children.length;
532
+ const reviewEnabled = reviewConfig?.enabled ?? false;
533
+ const isLargeRun =
534
+ childCount >= LARGE_RUN_THRESHOLD ||
535
+ (reviewEnabled && childCount >= LARGE_RUN_THRESHOLD - 1);
536
+
537
+ if (isLargeRun && policy.requirePreflightForLargeRuns) {
538
+ onProgress?.(
539
+ `Preflight: ${childCount} children${reviewEnabled ? " + review" : ""} — asking operator for provider preference…`,
540
+ );
541
+ // Guard: pi.ui.input must exist and be a function. Optional chaining on
542
+ // pi.ui?.input silently returns undefined if input is absent — that path
543
+ // would fall through without any log and is indistinguishable from the
544
+ // operator pressing Enter. Explicit typeof check surfaces the skip.
545
+ const uiInput = (pi as any).ui?.input;
546
+ if (typeof uiInput !== "function") {
547
+ onProgress?.("Preflight skipped (input not available in non-interactive mode)");
548
+ } else {
549
+ try {
550
+ const answer = await uiInput.call(
551
+ (pi as any).ui,
552
+ `🗂️ Large Cleave run (${childCount} children${reviewEnabled ? ", review on" : ""}). ` +
553
+ `Which provider should be favored?\n` +
554
+ ` [1] anthropic [2] openai [3] local [Enter] keep current (${policy.providerOrder[0] ?? "anthropic"}): `,
555
+ ) as string | undefined;
556
+ const trimmed = (answer ?? "").trim().toLowerCase();
557
+ let chosenProvider: string | undefined;
558
+ if (trimmed === "1" || trimmed === "anthropic") chosenProvider = "anthropic";
559
+ else if (trimmed === "2" || trimmed === "openai") chosenProvider = "openai";
560
+ else if (trimmed === "3" || trimmed === "local") chosenProvider = "local";
561
+
562
+ if (chosenProvider) {
563
+ // Update the session-wide routing policy: move chosen provider to front
564
+ const newOrder = [
565
+ chosenProvider as any,
566
+ ...policy.providerOrder.filter((p) => p !== chosenProvider),
567
+ ];
568
+ policy.providerOrder = newOrder;
569
+ (sharedState as any).routingPolicy = policy;
570
+ onProgress?.(`Provider order updated: ${newOrder.join(" → ")}`);
571
+ } else {
572
+ onProgress?.(`Keeping current provider order: ${policy.providerOrder.join(" → ")}`);
573
+ }
574
+ } catch {
575
+ // pi.input() threw unexpectedly; proceed with defaults
576
+ onProgress?.("Preflight skipped (input threw an unexpected error)");
577
+ }
578
+ }
579
+ }
580
+
581
+ const waves = computeDispatchWaves(
582
+ state.children.map((c) => ({ label: c.label, dependsOn: c.dependsOn })),
583
+ );
584
+
585
+ const semaphore = new AsyncSemaphore(maxParallel);
586
+ const effectiveReviewConfig = reviewConfig ?? DEFAULT_REVIEW_CONFIG;
587
+
588
+ let childrenDispatched = 0;
589
+ const totalChildren = state.children.length;
590
+
591
+ for (let waveIdx = 0; waveIdx < waves.length; waveIdx++) {
592
+ const waveLabels = waves[waveIdx];
593
+ const waveChildren = state.children.filter((c) => waveLabels.includes(c.label));
594
+ onProgress?.(
595
+ `dispatching ${waveChildren.map((c) => c.label).join(", ")}`,
596
+ );
597
+ childrenDispatched += waveChildren.length;
598
+
599
+ const promises = waveChildren.map(async (child) => {
600
+ await semaphore.acquire();
601
+ try {
602
+ await dispatchSingleChild(pi, state, child, childTimeoutMs, localModel, signal, effectiveReviewConfig);
603
+ } finally {
604
+ semaphore.release();
605
+ }
606
+ });
607
+
608
+ await Promise.all(promises);
609
+
610
+ // Persist state after each wave
611
+ saveState(state);
612
+
613
+ // Check for abort
614
+ if (signal?.aborted) break;
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Dispatch a single child: read task file, spawn pi, harvest result.
620
+ *
621
+ * Per-child model routing: each child's `executeModel` tier determines
622
+ * which model is passed via `--model`. The `localModel` param provides
623
+ * the Ollama model name for children with "local" tier.
624
+ *
625
+ * When review is enabled, the execution is wrapped in executeWithReview
626
+ * which runs an adversarial review loop with severity gating and churn detection.
627
+ */
628
+ async function dispatchSingleChild(
629
+ pi: ExtensionAPI,
630
+ state: CleaveState,
631
+ child: ChildState,
632
+ timeoutMs: number,
633
+ localModel?: string,
634
+ signal?: AbortSignal,
635
+ reviewConfig?: ReviewConfig,
636
+ ): Promise<void> {
637
+ // Skip children that are already settled — idempotent on resume.
638
+ // "completed" covers a successful prior run; "failed" covers worktree
639
+ // creation failures or a previous dispatch that returned non-zero.
640
+ if (child.status === "completed" || child.status === "failed") return;
641
+
642
+ child.status = "running";
643
+ child.startedAt = new Date().toISOString();
644
+ const startedAtMs = Date.now();
645
+
646
+ // Mirror to sharedState for live dashboard updates (include startedAt for elapsed ticker)
647
+ emitCleaveChildProgress(pi, child.childId, { status: "running", startedAt: startedAtMs, worktreePath: child.worktreePath });
648
+
649
+ // Debounced last-line emitter: buffers stdout lines and pushes to shared
650
+ // state at most once per 500ms to avoid flooding the event bus.
651
+ let pendingLine: string | undefined;
652
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
653
+ const flushLine = () => {
654
+ if (pendingLine !== undefined) {
655
+ emitCleaveChildProgress(pi, child.childId, { lastLine: pendingLine });
656
+ pendingLine = undefined;
657
+ }
658
+ };
659
+ const onChildLine = (line: string) => {
660
+ pendingLine = line;
661
+ if (!debounceTimer) {
662
+ debounceTimer = setTimeout(() => {
663
+ debounceTimer = undefined;
664
+ flushLine();
665
+ }, 500);
666
+ }
667
+ };
668
+ const stopDebounce = () => {
669
+ clearTimeout(debounceTimer);
670
+ debounceTimer = undefined;
671
+ };
672
+
673
+ // Resolve an explicit model ID for this child using the shared resolver.
674
+ // This replaces the old mapModelTierToFlag() fuzzy-alias approach.
675
+ const effectiveTier = (child.executeModel as ModelTier) ?? "victory";
676
+ const activePolicy: ProviderRoutingPolicy = (sharedState as any).routingPolicy ?? getDefaultPolicy();
677
+ let registryModels: RegistryModel[] = [];
678
+ try {
679
+ const registry = (pi as any).modelRegistry;
680
+ if (registry != null) {
681
+ registryModels = registry.getAll();
682
+ }
683
+ // If modelRegistry is absent (e.g. test environment), registryModels stays []
684
+ // and resolveTier will use policy-based fallbacks.
685
+ } catch (err) {
686
+ // getAll() threw — log and continue with empty registry so resolver can still
687
+ // apply policy-based fallbacks rather than silently passing no --model flag.
688
+ console.warn("[cleave] modelRegistry.getAll() threw:", err);
689
+ }
690
+ const modelFlag = resolveModelIdForTier(effectiveTier, registryModels, activePolicy, localModel);
691
+ child.backend = child.executeModel === "local" ? "local" : "cloud";
692
+
693
+ // Read the task file
694
+ const taskFilePath = join(state.workspacePath, `${child.childId}-task.md`);
695
+ let taskContent: string;
696
+ try {
697
+ taskContent = readFileSync(taskFilePath, "utf-8");
698
+ } catch {
699
+ child.status = "failed";
700
+ child.error = `Task file not found: ${taskFilePath}`;
701
+ return;
702
+ }
703
+
704
+ // Build prompt
705
+ const prompt = buildChildPrompt(taskContent, state.directive, state.workspacePath);
706
+
707
+ // Determine working directory
708
+ const cwd = child.worktreePath || state.repoPath;
709
+
710
+ // Build executor adapter for the review loop
711
+ const executor: ReviewExecutor = {
712
+ execute: async (execPrompt: string, execCwd: string, execModelFlag?: string) => {
713
+ return spawnChild(execPrompt, execCwd, timeoutMs, signal, execModelFlag, onChildLine);
714
+ },
715
+ review: async (reviewPrompt: string, reviewCwd: string) => {
716
+ // Reviews always use gloriana (D4: highest available tier) — resolve to explicit ID
717
+ const reviewModelId = resolveModelIdForTier("gloriana", registryModels, activePolicy, localModel);
718
+ // Review runs don't stream lastLine — they're short and we don't want
719
+ // review commentary to overwrite the last execution status line.
720
+ return spawnChild(reviewPrompt, reviewCwd, timeoutMs, signal, reviewModelId);
721
+ },
722
+ readFile: (path: string) => readFileSync(path, "utf-8"),
723
+ };
724
+
725
+ const effectiveReviewConfig = reviewConfig ?? DEFAULT_REVIEW_CONFIG;
726
+
727
+ // Execute with optional review loop
728
+ const reviewResult = await executeWithReview(
729
+ executor,
730
+ taskFilePath,
731
+ state.directive,
732
+ cwd,
733
+ effectiveReviewConfig,
734
+ modelFlag,
735
+ );
736
+
737
+ // Stop the debounce timer — child process is done
738
+ stopDebounce();
739
+
740
+ // Use the initial execution result for status determination
741
+ const result = reviewResult.executeResult;
742
+
743
+ child.completedAt = new Date().toISOString();
744
+ if (child.startedAt) {
745
+ child.durationSec = Math.round(
746
+ (new Date(child.completedAt).getTime() - new Date(child.startedAt).getTime()) / 1000,
747
+ );
748
+ }
749
+
750
+ // Persist review metadata on the child state
751
+ child.reviewIterations = reviewResult.reviewHistory.length;
752
+ child.reviewDecision = reviewResult.finalDecision;
753
+ child.reviewHistory = reviewResult.reviewHistory.map((r) => ({
754
+ round: r.round,
755
+ status: r.verdict.status,
756
+ issueCount: r.verdict.issues.length,
757
+ reappeared: r.reappeared,
758
+ }));
759
+ if (reviewResult.escalationReason) {
760
+ child.reviewEscalationReason = reviewResult.escalationReason;
761
+ }
762
+
763
+ // Determine child status from process exit code
764
+ if (result.exitCode === 0) {
765
+ child.status = "completed";
766
+ } else if (result.exitCode === -1) {
767
+ child.status = "failed";
768
+ child.error = "Timed out or aborted";
769
+ } else {
770
+ child.status = "failed";
771
+ child.error = result.stderr.slice(0, 2000) || `Exit code ${result.exitCode}`;
772
+ }
773
+
774
+ // If review escalated, mark the child as failed
775
+ if (reviewResult.finalDecision === "escalated") {
776
+ child.status = "failed";
777
+ child.error = `Review escalated: ${reviewResult.escalationReason}`;
778
+ }
779
+
780
+ // Re-read the task file to check if the child updated the status.
781
+ // IMPORTANT: Only parse the ## Result section to avoid false positives
782
+ // from the Contract section boilerplate which mentions NEEDS_DECOMPOSITION
783
+ // as an instruction (not as an actual status).
784
+ try {
785
+ const updatedContent = readFileSync(taskFilePath, "utf-8");
786
+ const resultSection = extractResultSection(updatedContent);
787
+ if (resultSection.includes("**Status:** NEEDS_DECOMPOSITION")) {
788
+ child.status = "needs_decomposition";
789
+ } else if (resultSection.includes("**Status:** FAILED")) {
790
+ child.status = "failed";
791
+ child.error = "Child reported FAILED in task file";
792
+ } else if (resultSection.includes("**Status:** SUCCESS") || resultSection.includes("**Status:** PARTIAL")) {
793
+ // Child explicitly reported success — trust the task file over exit code
794
+ // But only if review didn't escalate
795
+ if (reviewResult.finalDecision !== "escalated") {
796
+ child.status = "completed";
797
+ }
798
+ }
799
+ } catch {
800
+ // Task file not readable — keep whatever status we have
801
+ }
802
+
803
+ // Mirror final status to sharedState for live dashboard updates
804
+ emitCleaveChildProgress(pi, child.childId, {
805
+ status: child.status === "completed" ? "done" : child.status === "failed" ? "failed" : "pending",
806
+ elapsed: child.durationSec,
807
+ });
808
+ }