@ifi/oh-pi-ant-colony 0.2.9 → 0.2.11

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.
@@ -68,6 +68,18 @@ export interface CasteBudget {
68
68
  }
69
69
 
70
70
  /** Full budget plan for a colony run. */
71
+ export interface RoutingTelemetrySnapshot {
72
+ totalRoutes: number;
73
+ avgLatencyMs: number;
74
+ outcomeCounts: {
75
+ claimed: number;
76
+ completed: number;
77
+ failed: number;
78
+ escalated: number;
79
+ };
80
+ escalationReasonCounts: Record<string, number>;
81
+ }
82
+
71
83
  export interface BudgetPlan {
72
84
  /** Per-caste allocations. */
73
85
  castes: Record<AntCaste, CasteBudget>;
@@ -79,6 +91,8 @@ export interface BudgetPlan {
79
91
  lowestRateLimitPct: number;
80
92
  /** Human-readable summary for prompt injection. */
81
93
  summary: string;
94
+ /** Aggregated routing telemetry used for reporting and debugging. */
95
+ routingTelemetry: RoutingTelemetrySnapshot;
82
96
  }
83
97
 
84
98
  // ═══ Constants ═══
@@ -210,6 +224,7 @@ export function buildBudgetSummary(
210
224
  maxCost: number | null,
211
225
  tasksDone: number,
212
226
  tasksTotal: number,
227
+ routingTelemetry?: RoutingTelemetrySnapshot,
213
228
  ): string {
214
229
  const parts: string[] = [];
215
230
 
@@ -233,6 +248,16 @@ export function buildBudgetSummary(
233
248
  parts.push(`Progress: ${tasksDone}/${tasksTotal} tasks completed.`);
234
249
  }
235
250
 
251
+ if (routingTelemetry && routingTelemetry.totalRoutes > 0) {
252
+ parts.push(
253
+ `Routing: ${routingTelemetry.totalRoutes} outcomes, avg latency ${routingTelemetry.avgLatencyMs}ms, escalations ${routingTelemetry.outcomeCounts.escalated}.`,
254
+ );
255
+ const topEscalation = Object.entries(routingTelemetry.escalationReasonCounts).sort((a, b) => b[1] - a[1])[0];
256
+ if (topEscalation) {
257
+ parts.push(`Top escalation reason: ${topEscalation[0]} (${topEscalation[1]}).`);
258
+ }
259
+ }
260
+
236
261
  // Severity-specific guidance
237
262
  switch (severity) {
238
263
  case "critical":
@@ -265,6 +290,33 @@ export function buildBudgetSummary(
265
290
  * @param concurrency - Current concurrency config for max bounds.
266
291
  * @returns A complete budget plan with per-caste allocations.
267
292
  */
293
+ export function buildRoutingTelemetrySnapshot(metrics: ColonyMetrics): RoutingTelemetrySnapshot {
294
+ const entries = metrics.routingTelemetry ?? [];
295
+ const outcomeCounts = {
296
+ claimed: 0,
297
+ completed: 0,
298
+ failed: 0,
299
+ escalated: 0,
300
+ };
301
+ const escalationReasonCounts: Record<string, number> = {};
302
+ let latencyTotal = 0;
303
+
304
+ for (const entry of entries) {
305
+ outcomeCounts[entry.outcome] += 1;
306
+ latencyTotal += entry.latencyMs;
307
+ for (const reason of entry.escalationReasons) {
308
+ escalationReasonCounts[reason] = (escalationReasonCounts[reason] ?? 0) + 1;
309
+ }
310
+ }
311
+
312
+ return {
313
+ totalRoutes: entries.length,
314
+ avgLatencyMs: entries.length > 0 ? Math.round(latencyTotal / entries.length) : 0,
315
+ outcomeCounts,
316
+ escalationReasonCounts,
317
+ };
318
+ }
319
+
268
320
  export function planBudget(
269
321
  usageLimits: UsageLimitsEvent | null,
270
322
  metrics: ColonyMetrics,
@@ -311,6 +363,7 @@ export function planBudget(
311
363
  };
312
364
  }
313
365
 
366
+ const routingTelemetry = buildRoutingTelemetrySnapshot(metrics);
314
367
  const summary = buildBudgetSummary(
315
368
  severity,
316
369
  lowestRateLimitPct,
@@ -318,6 +371,7 @@ export function planBudget(
318
371
  maxCost,
319
372
  metrics.tasksDone,
320
373
  metrics.tasksTotal,
374
+ routingTelemetry,
321
375
  );
322
376
 
323
377
  return {
@@ -326,6 +380,7 @@ export function planBudget(
326
380
  severity,
327
381
  lowestRateLimitPct,
328
382
  summary,
383
+ routingTelemetry,
329
384
  };
330
385
  }
331
386
 
@@ -35,6 +35,7 @@ import {
35
35
  statusLabel,
36
36
  } from "./ui.js";
37
37
  import {
38
+ cleanupIsolatedWorktree,
38
39
  formatWorkspaceReport,
39
40
  formatWorkspaceSummary,
40
41
  prepareColonyWorkspace,
@@ -549,6 +550,17 @@ export default function antColonyExtension(pi: ExtensionAPI) {
549
550
  lastBgStatusSnapshotAt = 0;
550
551
  throttledRender();
551
552
 
553
+ const cleanupWorkspace = (reason: "completion" | "crash") => {
554
+ const cleanupResult = cleanupIsolatedWorktree(workspace);
555
+ if (!cleanupResult) {
556
+ return;
557
+ }
558
+ pushLog(colony, {
559
+ level: /failed|skipped/i.test(cleanupResult) ? "warning" : "info",
560
+ text: `WORKTREE ${reason.toUpperCase()} CLEANUP · ${cleanupResult}`,
561
+ });
562
+ };
563
+
552
564
  // Wait for completion in background, inject results
553
565
  colony.promise
554
566
  .then((state) => {
@@ -565,6 +577,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
565
577
  level: ok ? "info" : "error",
566
578
  text: `${statusLabel(phase)} · ${m.tasksDone}/${m.tasksTotal} · ${formatCost(m.totalCost)}`,
567
579
  });
580
+ cleanupWorkspace("completion");
568
581
 
569
582
  colonies.delete(colonyId);
570
583
  if (colonies.size === 0) {
@@ -588,6 +601,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
588
601
  })
589
602
  .catch((e) => {
590
603
  pushLog(colony, { level: "error", text: `CRASHED · ${String(e).slice(0, 120)}` });
604
+ cleanupWorkspace("crash");
591
605
  colonies.delete(colonyId);
592
606
  if (colonies.size === 0) {
593
607
  pi.events.emit("ant-colony:clear-ui");
@@ -987,6 +1001,12 @@ export default function antColonyExtension(pi: ExtensionAPI) {
987
1001
  soldierModel: Type.Optional(
988
1002
  Type.String({ description: "Model for soldier ants (default: current session model)" }),
989
1003
  ),
1004
+ designWorkerModel: Type.Optional(Type.String({ description: "Model override for design worker class" })),
1005
+ multimodalWorkerModel: Type.Optional(
1006
+ Type.String({ description: "Model override for multimodal worker class (cheap-first default route)" }),
1007
+ ),
1008
+ backendWorkerModel: Type.Optional(Type.String({ description: "Model override for backend worker class" })),
1009
+ reviewWorkerModel: Type.Optional(Type.String({ description: "Model override for review worker class" })),
990
1010
  }),
991
1011
 
992
1012
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -1008,6 +1028,18 @@ export default function antColonyExtension(pi: ExtensionAPI) {
1008
1028
  if (params.soldierModel) {
1009
1029
  modelOverrides.soldier = params.soldierModel;
1010
1030
  }
1031
+ if (params.designWorkerModel) {
1032
+ modelOverrides.design = params.designWorkerModel;
1033
+ }
1034
+ if (params.multimodalWorkerModel) {
1035
+ modelOverrides.multimodal = params.multimodalWorkerModel;
1036
+ }
1037
+ if (params.backendWorkerModel) {
1038
+ modelOverrides.backend = params.backendWorkerModel;
1039
+ }
1040
+ if (params.reviewWorkerModel) {
1041
+ modelOverrides.review = params.reviewWorkerModel;
1042
+ }
1011
1043
 
1012
1044
  const colonyParams = {
1013
1045
  goal: params.goal,
@@ -0,0 +1,68 @@
1
+ import type { EscalationReason, Task } from "./types.js";
2
+
3
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg", ".heic"]);
4
+ const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".mpeg", ".mpg"]);
5
+
6
+ export interface IngestionArtifact {
7
+ path: string;
8
+ kind: "image" | "video";
9
+ }
10
+
11
+ export interface MultimodalIngestionReport {
12
+ hasMultimodalInput: boolean;
13
+ artifacts: IngestionArtifact[];
14
+ summary: string;
15
+ }
16
+
17
+ export function preprocessMultimodalTask(task: Task): MultimodalIngestionReport {
18
+ const artifacts: IngestionArtifact[] = [];
19
+ for (const file of task.files) {
20
+ const lower = file.toLowerCase();
21
+ const imageExt = [...IMAGE_EXTENSIONS].find((ext) => lower.endsWith(ext));
22
+ if (imageExt) {
23
+ artifacts.push({ path: file, kind: "image" });
24
+ continue;
25
+ }
26
+ const videoExt = [...VIDEO_EXTENSIONS].find((ext) => lower.endsWith(ext));
27
+ if (videoExt) {
28
+ artifacts.push({ path: file, kind: "video" });
29
+ }
30
+ }
31
+
32
+ if (artifacts.length === 0) {
33
+ return {
34
+ hasMultimodalInput: false,
35
+ artifacts,
36
+ summary: "No image/video artifacts detected.",
37
+ };
38
+ }
39
+
40
+ const imageCount = artifacts.filter((a) => a.kind === "image").length;
41
+ const videoCount = artifacts.filter((a) => a.kind === "video").length;
42
+ return {
43
+ hasMultimodalInput: true,
44
+ artifacts,
45
+ summary: `Detected multimodal artifacts: ${imageCount} image(s), ${videoCount} video(s).`,
46
+ };
47
+ }
48
+
49
+ export function shouldEscalateMultimodalRoute(task: Task, report: MultimodalIngestionReport): EscalationReason[] {
50
+ const reasons: EscalationReason[] = [];
51
+ if (!report.hasMultimodalInput) {
52
+ return reasons;
53
+ }
54
+
55
+ if (report.artifacts.some((artifact) => artifact.kind === "video")) {
56
+ reasons.push("risk_flag");
57
+ }
58
+
59
+ if ((task.description ?? "").toLowerCase().includes("policy")) {
60
+ reasons.push("policy_violation");
61
+ }
62
+
63
+ if ((task.context ?? "").length > 5000) {
64
+ reasons.push("low_confidence");
65
+ }
66
+
67
+ return reasons;
68
+ }
@@ -19,7 +19,7 @@
19
19
 
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
- import type { Ant, ColonyState, ConcurrencySample, Pheromone, Task, TaskStatus } from "./types.js";
22
+ import type { Ant, ColonyState, ConcurrencySample, EscalationReason, Pheromone, Task, TaskStatus } from "./types.js";
23
23
 
24
24
  /** Minimum pheromone strength to keep (below this, entries are garbage-collected). */
25
25
  const PHEROMONE_MIN_STRENGTH = 0.05;
@@ -249,6 +249,31 @@ export class Nest {
249
249
  this.writeTask(task);
250
250
  }
251
251
 
252
+ recordRoutingOutcome(
253
+ taskId: string,
254
+ caste: "scout" | "worker" | "soldier" | "drone",
255
+ outcome: "claimed" | "completed" | "failed" | "escalated",
256
+ latencyMs: number,
257
+ escalationReasons: EscalationReason[] = [],
258
+ ): void {
259
+ this.withStateLock(() => {
260
+ if (!this.stateCache) {
261
+ this.stateCache = this.readJson<ColonyState>(this.stateFile);
262
+ }
263
+ const routingTelemetry = [...(this.stateCache.metrics.routingTelemetry ?? [])];
264
+ routingTelemetry.push({
265
+ taskId,
266
+ caste,
267
+ outcome,
268
+ latencyMs: Math.max(0, Math.floor(latencyMs)),
269
+ escalationReasons,
270
+ timestamp: Date.now(),
271
+ });
272
+ this.stateCache.metrics.routingTelemetry = routingTelemetry.slice(-500);
273
+ this.writeJson(this.stateFile, this.stateCache);
274
+ });
275
+ }
276
+
252
277
  /** Register a child task and link it to its parent's `spawnedTasks` list. */
253
278
  addSubTask(parentId: string, child: Task): void {
254
279
  this.writeTask(child);
@@ -24,6 +24,7 @@ import {
24
24
  } from "./budget-planner.js";
25
25
  import { adapt, defaultConcurrency, sampleSystem } from "./concurrency.js";
26
26
  import { buildImportGraph, type ImportGraph, taskDependsOn } from "./deps.js";
27
+ import { preprocessMultimodalTask, shouldEscalateMultimodalRoute } from "./multimodal-routing.js";
27
28
  import { Nest } from "./nest.js";
28
29
  import { makePheromoneId, makeTaskId, resetAntCounter, runDrone, spawnAnt } from "./spawner.js";
29
30
  import type {
@@ -36,8 +37,11 @@ import type {
36
37
  ColonyState,
37
38
  ColonyWorkspace,
38
39
  ModelOverrides,
40
+ PromoteFinalizeGateDecision,
41
+ PromoteFinalizeGateInput,
39
42
  Task,
40
43
  TaskPriority,
44
+ WorkerClass,
41
45
  } from "./types.js";
42
46
  import { DEFAULT_ANT_CONFIGS } from "./types.js";
43
47
 
@@ -158,6 +162,20 @@ function makeInitialScoutTask(goal: string): Task {
158
162
  };
159
163
  }
160
164
 
165
+ function classifyWorkerClass(title: string, description: string, files: string[]): WorkerClass {
166
+ const haystack = `${title}\n${description}\n${files.join("\n")}`.toLowerCase();
167
+ if (/(ui|ux|design|layout|style|css|figma|theme|color|typography|component)/.test(haystack)) {
168
+ return "design";
169
+ }
170
+ if (/(image|video|audio|vision|ocr|multimodal|caption|embedding)/.test(haystack)) {
171
+ return "multimodal";
172
+ }
173
+ if (/(review|qa|validate|verify|audit|test|lint|check)/.test(haystack)) {
174
+ return "review";
175
+ }
176
+ return "backend";
177
+ }
178
+
161
179
  function childTaskFromParsed(
162
180
  parentId: string,
163
181
  parsed: {
@@ -179,6 +197,8 @@ function childTaskFromParsed(
179
197
  priority: parsed.priority,
180
198
  files: parsed.files,
181
199
  context: parsed.context || undefined,
200
+ workerClass:
201
+ parsed.caste === "worker" ? classifyWorkerClass(parsed.title, parsed.description, parsed.files) : undefined,
182
202
  claimedBy: null,
183
203
  result: null,
184
204
  error: null,
@@ -242,6 +262,38 @@ export function shouldUseScoutQuorum(goal: string): boolean {
242
262
  return /(\n\s*\d+[.)]|[;;]| and |以及|并且|同时|步骤|phase|then|之后)/i.test(goal);
243
263
  }
244
264
 
265
+ export function decidePromoteOrFinalize(input: PromoteFinalizeGateInput): PromoteFinalizeGateDecision {
266
+ const escalationReasons: PromoteFinalizeGateDecision["escalationReasons"] = [];
267
+ if (input.confidenceScore < 0.78) {
268
+ escalationReasons.push("low_confidence");
269
+ }
270
+ if (input.coverageScore < 0.85) {
271
+ escalationReasons.push("low_coverage");
272
+ }
273
+ if (input.riskFlags.length > 0) {
274
+ escalationReasons.push("risk_flag");
275
+ }
276
+ if (input.policyViolations.length > 0) {
277
+ escalationReasons.push("policy_violation");
278
+ }
279
+ if (input.sloBreached) {
280
+ escalationReasons.push("slo_breach");
281
+ }
282
+
283
+ if (escalationReasons.length > 0) {
284
+ return {
285
+ action: "promote",
286
+ escalationReasons,
287
+ cheapPassSummary: input.cheapPassSummary,
288
+ };
289
+ }
290
+
291
+ return {
292
+ action: "finalize",
293
+ escalationReasons,
294
+ };
295
+ }
296
+
245
297
  export function validateExecutionPlan(tasks: Task[]): PlanValidation {
246
298
  const issues: string[] = [];
247
299
  const warnings: string[] = [];
@@ -378,7 +430,7 @@ interface WaveOptions {
378
430
  modelOverrides?: ModelOverrides;
379
431
  signal?: AbortSignal;
380
432
  callbacks: QueenCallbacks;
381
- emitSignal: (phase: ColonyState["status"], message: string) => void;
433
+ emitSignal: (phase: ColonyState["status"], message: string, extras?: Partial<ColonySignal>) => void;
382
434
  authStorage?: AuthStorage;
383
435
  modelRegistry?: ModelRegistry;
384
436
  importGraph?: ImportGraph;
@@ -462,14 +514,30 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
462
514
  if (!task) {
463
515
  return "empty";
464
516
  }
517
+ nest.recordRoutingOutcome(task.id, caste, "claimed", 0);
518
+
519
+ const multimodalReport = caste === "worker" ? preprocessMultimodalTask(task) : null;
520
+ const multimodalEscalationReasons =
521
+ caste === "worker" && multimodalReport ? shouldEscalateMultimodalRoute(task, multimodalReport) : [];
522
+ if (multimodalEscalationReasons.length > 0) {
523
+ nest.recordRoutingOutcome(task.id, caste, "escalated", 0, multimodalEscalationReasons);
524
+ }
465
525
 
526
+ const shouldUseCheapMultimodalFirst = caste === "worker" && task.workerClass === "multimodal";
527
+ let selectedModel =
528
+ caste === "worker"
529
+ ? (task.workerClass ? opts.modelOverrides?.[task.workerClass] : undefined) || casteModel
530
+ : casteModel;
531
+ if (shouldUseCheapMultimodalFirst && opts.modelOverrides?.multimodal) {
532
+ selectedModel = opts.modelOverrides.multimodal;
533
+ }
466
534
  const ant: Ant = {
467
535
  id: "",
468
536
  caste,
469
537
  status: "idle",
470
538
  taskId: task.id,
471
539
  pid: null,
472
- model: casteModel,
540
+ model: selectedModel,
473
541
  usage: { input: 0, output: 0, cost: 0, turns: 0 },
474
542
  startedAt: Date.now(),
475
543
  finishedAt: null,
@@ -483,7 +551,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
483
551
  const antSignal = antAbort.signal;
484
552
  // Bio 7: Age polymorphism — conservative early, convergent late
485
553
  const progress = state.metrics.tasksTotal > 0 ? state.metrics.tasksDone / state.metrics.tasksTotal : 0;
486
- const config = { ...baseConfig };
554
+ const config = { ...baseConfig, model: selectedModel };
487
555
  if (progress < 0.3) {
488
556
  config.maxTurns = Math.max(baseConfig.maxTurns - 3, 5); // Conservative early phase
489
557
  } else if (progress > 0.7) {
@@ -491,6 +559,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
491
559
  }
492
560
  // Build budget-awareness prompt section for non-drone ants
493
561
  const budgetSection = opts.budgetPlan ? buildBudgetPromptSection(opts.budgetPlan) : undefined;
562
+ const ingestionSection = multimodalReport?.hasMultimodalInput
563
+ ? `## Multimodal Ingestion\n${multimodalReport.summary}\nArtifacts:\n${multimodalReport.artifacts
564
+ .map((artifact) => `- ${artifact.kind}: ${artifact.path}`)
565
+ .join("\n")}`
566
+ : undefined;
494
567
  const antPromise =
495
568
  caste === "drone"
496
569
  ? runDrone(cwd, nest, task)
@@ -504,7 +577,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
504
577
  callbacks.onAntUsage,
505
578
  opts.authStorage,
506
579
  opts.modelRegistry,
507
- budgetSection,
580
+ [budgetSection, ingestionSection].filter(Boolean).join("\n\n") || undefined,
508
581
  );
509
582
  let timeoutId: ReturnType<typeof setTimeout>;
510
583
  const result = await Promise.race([
@@ -519,9 +592,12 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
519
592
  callbacks.onAntDone?.(result.ant, task, result.output);
520
593
 
521
594
  if (result.rateLimited) {
595
+ nest.recordRoutingOutcome(task.id, caste, "escalated", Date.now() - ant.startedAt, ["slo_breach"]);
522
596
  return "rate_limited";
523
597
  }
524
598
 
599
+ nest.recordRoutingOutcome(task.id, caste, "completed", Date.now() - ant.startedAt);
600
+
525
601
  // Cost warning: signal when >80% of budget is spent
526
602
  const curState = nest.getStateLight();
527
603
  if (curState.maxCost != null) {
@@ -589,8 +665,10 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
589
665
  const count = retryCount.get(task.id) ?? 0;
590
666
  if (isRetryable && count < MAX_RETRIES) {
591
667
  retryCount.set(task.id, count + 1);
668
+ nest.recordRoutingOutcome(task.id, caste, "escalated", Date.now() - ant.startedAt, ["slo_breach"]);
592
669
  nest.updateTaskStatus(task.id, "pending");
593
670
  } else {
671
+ nest.recordRoutingOutcome(task.id, caste, "failed", Date.now() - ant.startedAt);
594
672
  // Negative pheromone: failed task releases warning proportional to task scope
595
673
  if (task.files.length > 0) {
596
674
  const warnStrength = Math.min(1.0, 0.5 + task.files.length * 0.1);
@@ -800,12 +878,12 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
800
878
  }
801
879
  };
802
880
 
803
- const emitSignal = (phase: ColonyState["status"], message: string) => {
881
+ const emitSignal = (phase: ColonyState["status"], message: string, extras?: Partial<ColonySignal>) => {
804
882
  const state = nest.getStateLight();
805
883
  const m = state.metrics;
806
884
  const active = state.ants.filter((a) => a.status === "working").length;
807
885
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
808
- callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id });
886
+ callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id, ...extras });
809
887
  };
810
888
 
811
889
  const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
@@ -1070,12 +1148,12 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
1070
1148
 
1071
1149
  const { signal, callbacks } = opts;
1072
1150
 
1073
- const emitSignal = (phase: ColonyState["status"], message: string) => {
1151
+ const emitSignal = (phase: ColonyState["status"], message: string, extras?: Partial<ColonySignal>) => {
1074
1152
  const state = nest.getStateLight();
1075
1153
  const m = state.metrics;
1076
1154
  const active = state.ants.filter((a) => a.status === "working").length;
1077
1155
  const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
1078
- callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id });
1156
+ callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id, ...extras });
1079
1157
  };
1080
1158
 
1081
1159
  const waveBase: Omit<WaveOptions, "caste"> & { budgetPlan?: BudgetPlan | null } = {
@@ -27,8 +27,10 @@ export const DEFAULT_ANT_CONFIGS: Record<AntCaste, Omit<AntConfig, "systemPrompt
27
27
  drone: { caste: "drone", model: "", tools: ["bash"], maxTurns: 1 },
28
28
  };
29
29
 
30
- /** Per-caste model overrides from user config */
31
- export type ModelOverrides = Partial<Record<AntCaste, string>>;
30
+ export type WorkerClass = "design" | "multimodal" | "backend" | "review";
31
+
32
+ /** Per-caste / per-worker-class model overrides from user config */
33
+ export type ModelOverrides = Partial<Record<AntCaste | WorkerClass, string>>;
32
34
 
33
35
  // ═══ Tasks (Food Sources) ═══
34
36
  export type TaskStatus = "pending" | "claimed" | "active" | "done" | "failed" | "blocked";
@@ -44,6 +46,7 @@ export interface Task {
44
46
  priority: TaskPriority;
45
47
  files: string[]; // Files locked by this task
46
48
  context?: string; // Code snippets pre-loaded by scout
49
+ workerClass?: WorkerClass; // worker specialization for model routing
47
50
  claimedBy: string | null; // ant id
48
51
  result: string | null;
49
52
  error: string | null;
@@ -162,6 +165,32 @@ export interface ConcurrencySample {
162
165
  throughput: number; // tasks completed per minute
163
166
  }
164
167
 
168
+ export type EscalationReason = "low_confidence" | "low_coverage" | "risk_flag" | "policy_violation" | "slo_breach";
169
+
170
+ export interface PromoteFinalizeGateInput {
171
+ confidenceScore: number;
172
+ coverageScore: number;
173
+ riskFlags: string[];
174
+ policyViolations: string[];
175
+ sloBreached: boolean;
176
+ cheapPassSummary: string;
177
+ }
178
+
179
+ export interface PromoteFinalizeGateDecision {
180
+ action: "promote" | "finalize";
181
+ escalationReasons: EscalationReason[];
182
+ cheapPassSummary?: string;
183
+ }
184
+
185
+ export interface RoutingTelemetry {
186
+ taskId: string;
187
+ caste: AntCaste;
188
+ outcome: "claimed" | "completed" | "failed" | "escalated";
189
+ latencyMs: number;
190
+ escalationReasons: EscalationReason[];
191
+ timestamp: number;
192
+ }
193
+
165
194
  export interface ColonyMetrics {
166
195
  tasksTotal: number;
167
196
  tasksDone: number;
@@ -171,6 +200,7 @@ export interface ColonyMetrics {
171
200
  totalTokens: number;
172
201
  startTime: number;
173
202
  throughputHistory: number[]; // Sliding window of tasks/min throughput
203
+ routingTelemetry?: RoutingTelemetry[];
174
204
  }
175
205
 
176
206
  /** Colony signal — the single abstraction observers need to handle. */
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync } from "node:fs";
3
- import { join, relative, resolve } from "node:path";
2
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
4
  import type { ColonyWorkspace } from "./types.js";
5
5
 
6
6
  const WORKTREE_ENV_FLAG = "PI_ANT_COLONY_WORKTREE";
@@ -58,6 +58,59 @@ function resolveExecutionCwd(worktreeRoot: string, repoRoot: string, originCwd:
58
58
  return existsSync(candidate) ? candidate : worktreeRoot;
59
59
  }
60
60
 
61
+ export function cleanupIsolatedWorktree(workspace: ColonyWorkspace): string | null {
62
+ if (workspace.mode !== "worktree" || !workspace.repoRoot || !workspace.worktreeRoot || !workspace.branch) {
63
+ return null;
64
+ }
65
+
66
+ const notes: string[] = [];
67
+ try {
68
+ if (existsSync(workspace.worktreeRoot)) {
69
+ git(workspace.repoRoot, ["worktree", "remove", "--force", workspace.worktreeRoot]);
70
+ notes.push("removed isolated worktree");
71
+ }
72
+ } catch (error) {
73
+ const reason = error instanceof Error ? error.message : String(error);
74
+ notes.push(`worktree remove failed (${reason})`);
75
+ }
76
+
77
+ try {
78
+ if (workspace.branch) {
79
+ git(workspace.repoRoot, ["branch", "-D", workspace.branch]);
80
+ notes.push("deleted temporary branch");
81
+ }
82
+ } catch (error) {
83
+ const reason = error instanceof Error ? error.message : String(error);
84
+ notes.push(`branch cleanup skipped (${reason})`);
85
+ }
86
+
87
+ try {
88
+ git(workspace.repoRoot, ["worktree", "prune"]);
89
+ } catch {
90
+ // ignore prune failures; this is best-effort hygiene.
91
+ }
92
+
93
+ try {
94
+ const parent = dirname(workspace.worktreeRoot);
95
+ rmSync(workspace.worktreeRoot, { recursive: true, force: true });
96
+ if (existsSync(parent) && isEmptyDir(parent)) {
97
+ rmSync(parent, { recursive: true, force: true });
98
+ }
99
+ } catch {
100
+ // ignore filesystem cleanup failures.
101
+ }
102
+
103
+ return notes.length > 0 ? `Cleanup: ${notes.join("; ")}.` : "Cleanup: no stale isolated worktree artifacts found.";
104
+ }
105
+
106
+ function isEmptyDir(path: string): boolean {
107
+ try {
108
+ return readdirSync(path).length === 0;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
61
114
  export function worktreeEnabledByDefault(): boolean {
62
115
  const raw = process.env[WORKTREE_ENV_FLAG];
63
116
  if (typeof raw !== "string") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifi/oh-pi-ant-colony",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Autonomous multi-agent swarm extension for pi — adaptive concurrency, pheromone communication.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -22,5 +22,11 @@
22
22
  "@mariozechner/pi-coding-agent": "*",
23
23
  "@mariozechner/pi-tui": "*",
24
24
  "@sinclair/typebox": "*"
25
+ },
26
+ "scripts": {
27
+ "build": "pnpm run typecheck:worktree && pnpm run test:worktree",
28
+ "typecheck": "pnpm run typecheck:worktree",
29
+ "typecheck:worktree": "tsgo --project ./tsconfig.json --noEmit",
30
+ "test:worktree": "vitest run --config ./vitest.worktree.config.ts"
25
31
  }
26
32
  }