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.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- 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
|
+
}
|