infinity-harness 2.6.6 → 2.8.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 (45) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +68 -15
  3. package/extensions/infinity-harness/index.ts +600 -26
  4. package/harness/docs/ARCHITECTURE.md +13 -7
  5. package/harness/docs/CONSTRAINTS.md +13 -5
  6. package/harness/docs/DECISIONS.md +44 -0
  7. package/harness/docs/DOMAIN.md +44 -8
  8. package/package.json +1 -1
  9. package/src/core/config.ts +88 -1
  10. package/src/core/featureList.ts +85 -17
  11. package/src/core/gates.ts +8 -6
  12. package/src/core/init.ts +33 -3
  13. package/src/core/modelRouter.ts +149 -0
  14. package/src/core/paths.ts +29 -0
  15. package/src/core/plan.ts +39 -0
  16. package/src/core/runState.ts +151 -0
  17. package/src/core/settings.ts +138 -4
  18. package/src/core/types.ts +49 -0
  19. package/src/daemon/budget.ts +94 -0
  20. package/src/daemon/guard.ts +113 -0
  21. package/src/daemon/index.ts +421 -0
  22. package/src/daemon/isolation.ts +95 -0
  23. package/src/daemon/preflight.ts +132 -0
  24. package/src/daemon/server.ts +153 -0
  25. package/src/daemon/supervisorState.ts +83 -0
  26. package/src/daemon/worker.ts +239 -0
  27. package/src/daemon/worktree.ts +95 -0
  28. package/src/exec/piWorker.ts +706 -0
  29. package/src/goalState.ts +2 -22
  30. package/src/intake.ts +4 -1
  31. package/src/loop.ts +35 -34
  32. package/src/modelRouter.ts +0 -0
  33. package/src/remote.ts +28 -7
  34. package/src/replan.ts +7 -3
  35. package/src/rework.ts +9 -3
  36. package/src/runState.ts +15 -121
  37. package/src/scheduler.ts +115 -135
  38. package/src/supervisor.ts +955 -0
  39. package/src/taskList.ts +41 -3
  40. package/src/ui/dashboard.ts +127 -0
  41. package/src/ui/viewState.ts +77 -0
  42. package/src/ui/widget.ts +189 -0
  43. package/src/ui/wizard.ts +43 -7
  44. package/src/unstuck.ts +0 -0
  45. package/src/worker.ts +12 -8
package/src/taskList.ts CHANGED
@@ -33,8 +33,9 @@ import {
33
33
  validateKey,
34
34
  type FlatTask,
35
35
  } from "./core/featureList.ts";
36
- import { featureListPath } from "./core/paths.ts";
36
+ import { featureListPath, planPath } from "./core/paths.ts";
37
37
  import { withLockSync } from "./core/lock.ts";
38
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
38
39
 
39
40
  /** One task as submitted by the agent. Only `key` is mandatory. */
40
41
  export type TaskInput = {
@@ -47,6 +48,8 @@ export type TaskInput = {
47
48
  difficulty?: string;
48
49
  modelHint?: string;
49
50
  criteria?: string[];
51
+ phase?: string;
52
+ serialize?: boolean;
50
53
  };
51
54
 
52
55
  /**
@@ -300,7 +303,20 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
300
303
 
301
304
  if (raw.difficulty !== undefined) task.difficulty = raw.difficulty as Task["difficulty"];
302
305
  if (raw.modelHint !== undefined) task.modelHint = raw.modelHint;
303
- if (raw.criteria !== undefined) task.criteria = raw.criteria;
306
+ if (raw.phase !== undefined) {
307
+ const pa = String(raw.phase).trim().toLowerCase();
308
+ const VALID_PHASES = ["init","research","define","plan","build","verify","simplify","review","ship"];
309
+ if (!VALID_PHASES.includes(pa)) throw new ValidationError(`${path}.phase is invalid: ${String(raw.phase)}`);
310
+ (task as unknown as { phase: string }).phase = pa;
311
+ }
312
+ if (raw.serialize !== undefined) {
313
+ (task as unknown as { serialize: boolean }).serialize = Boolean(raw.serialize);
314
+ }
315
+ if (raw.criteria !== undefined) {
316
+ if (!Array.isArray(raw.criteria)) throw new ValidationError(`${path}.criteria must be an array`);
317
+ const criteria = validateCriteria(raw.criteria, `${path}.criteria`);
318
+ (task as unknown as { criteria: string[] }).criteria = criteria;
319
+ }
304
320
 
305
321
  staged.push({ featureId, task, compositeKey: key });
306
322
  }
@@ -479,7 +495,29 @@ function stripView(t: FlatTask): Task {
479
495
  * losing an edit.
480
496
  */
481
497
  export function writeTaskList(targetDir: string, input: ApplyInput): ApplyResult {
482
- return withLockSync(featureListPath(targetDir), () => {
498
+ // Acquire a lock that covers both legacy and canonical to avoid racing with
499
+ // hand-edits that touch legacy. We lock canonical (which is plan.json), then
500
+ // sync any legacy hand-edit before reading.
501
+ return withLockSync(planPath(targetDir), () => {
502
+ // Back-compat: tests modify legacy via readFileSync+writeFileSync then call writeTaskList.
503
+ // Since loadFeatureList prefers canonical when both exist, a legacy-only hand-edit would be lost.
504
+ // Detect and mirror a legacy that differs from canonical.
505
+ const legacy = featureListPath(targetDir);
506
+ const canonical = planPath(targetDir);
507
+ if (existsSync(legacy) && existsSync(canonical)) {
508
+ try {
509
+ const rawLegacy = readFileSync(legacy, "utf-8");
510
+ const rawCanon = readFileSync(canonical, "utf-8");
511
+ if (rawLegacy !== rawCanon) {
512
+ try {
513
+ const p = JSON.parse(rawLegacy);
514
+ if (p && typeof p === "object" && Array.isArray(p.features) && typeof p.baseRevision === "number") {
515
+ writeFileSync(canonical, rawLegacy, "utf-8");
516
+ }
517
+ } catch {}
518
+ }
519
+ } catch {}
520
+ }
483
521
  const { list } = loadFeatureList(targetDir);
484
522
  const result = applyTaskList(list, input);
485
523
  if (result.changed) saveFeatureList(targetDir, result.list);
@@ -68,8 +68,30 @@ export type DashboardState = {
68
68
  * reads, so a level turned off in one is off in the other.
69
69
  */
70
70
  display?: DisplayPolicy | null;
71
+ /** Where the work is happening. */
72
+ engine?: "background" | "main-session" | null;
73
+ /** The background pi sessions doing the work, and what each is on. */
74
+ workers?: DashWorker[] | null;
75
+ /** The tail of the background log. */
76
+ activity?: DashActivity[] | null;
71
77
  };
72
78
 
79
+ export type DashWorker = {
80
+ name: string;
81
+ unitLabel: string;
82
+ level: string;
83
+ model: string;
84
+ servedModel?: string | null;
85
+ difficulty?: string | null;
86
+ state: string;
87
+ doing?: string | null;
88
+ turns?: number;
89
+ tokens?: { inputTokens: number; outputTokens: number } | null;
90
+ contextRatio?: number | null;
91
+ };
92
+
93
+ export type DashActivity = { at: string; level: string; worker: string | null; text: string };
94
+
73
95
  // ── escaping ────────────────────────────────────────────────────────────────
74
96
 
75
97
  const HTML_ESCAPES: Record<string, string> = {
@@ -799,6 +821,50 @@ body{
799
821
  color:var(--faint);font-weight:600;margin-bottom:6px;
800
822
  }
801
823
 
824
+
825
+ /* -- background sessions --------------------------------------------------- */
826
+ /*
827
+ * The run's work happens in other pi processes now, so this panel is the only
828
+ * place it is visible. It is deliberately the loudest thing under the meter:
829
+ * a live dot, the model each session is on, and a reverse-chronological log.
830
+ */
831
+ .card.background h2{margin:0 0 4px;font-size:14px;letter-spacing:.01em}
832
+ .bg-note{margin:0 0 12px;color:var(--muted);font-size:13px}
833
+ .bg-workers{list-style:none;margin:0;padding:0;display:grid;gap:8px}
834
+ .bg-worker{
835
+ display:grid;grid-template-columns:auto auto 1fr;gap:4px 10px;align-items:baseline;
836
+ padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);
837
+ }
838
+ .bg-dot{
839
+ width:8px;height:8px;border-radius:50%;background:var(--muted);
840
+ align-self:center;grid-row:1;
841
+ }
842
+ .bg-worker.is-working .bg-dot,.bg-worker.is-starting .bg-dot{
843
+ background:var(--c-accent);box-shadow:0 0 0 3px var(--ring);animation:bgpulse 1.6s ease-in-out infinite;
844
+ }
845
+ .bg-worker.is-failed .bg-dot{background:var(--t-blocked,#C0392B)}
846
+ @keyframes bgpulse{0%,100%{opacity:1}50%{opacity:.35}}
847
+ @media (prefers-reduced-motion:reduce){.bg-worker .bg-dot{animation:none}}
848
+ .bg-name{font-weight:650}
849
+ .bg-model{
850
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;
851
+ color:var(--c-accent);overflow-wrap:anywhere;
852
+ }
853
+ .bg-meta{grid-column:2/-1;color:var(--muted);font-size:12.5px}
854
+ .bg-doing{grid-column:2/-1;font-size:13px;overflow-wrap:anywhere}
855
+ .bg-log{
856
+ list-style:none;margin:12px 0 0;padding:10px 0 0;border-top:1px solid var(--border);
857
+ max-height:260px;overflow:auto;display:grid;gap:2px;
858
+ }
859
+ .bg-line{display:grid;grid-template-columns:44px 34px 1fr;gap:8px;font-size:12.5px;align-items:baseline}
860
+ .bg-time{color:var(--faint);font-variant-numeric:tabular-nums}
861
+ .bg-who{color:var(--c-accent);font-weight:600}
862
+ .bg-text{color:var(--muted);overflow-wrap:anywhere}
863
+ .bg-line.lvl-work .bg-text{color:var(--text)}
864
+ .bg-line.lvl-good .bg-text{color:var(--t-success,#177245)}
865
+ .bg-line.lvl-warn .bg-text{color:var(--t-rework,#8E44AD)}
866
+ .bg-line.lvl-error .bg-text{color:var(--t-blocked,#C0392B)}
867
+
802
868
  /* -- masthead ------------------------------------------------------------- */
803
869
  .masthead{
804
870
  position:sticky;top:0;z-index:20;
@@ -1210,6 +1276,66 @@ function formatTimestamp(iso: string): string {
1210
1276
  return d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
1211
1277
  }
1212
1278
 
1279
+ /**
1280
+ * The background sessions, and the log of what they have done.
1281
+ *
1282
+ * When the work moved out of the human's terminal this became the only place
1283
+ * a run is visible at all — so it says, per session, which unit it owns, what
1284
+ * model is answering for it, and the last thing it did. The model is on the
1285
+ * card because "routed to the difficult tier" is a claim, and a claim you
1286
+ * cannot see is one nobody believes.
1287
+ */
1288
+ export function renderBackground(
1289
+ engine: DashboardState["engine"],
1290
+ workers: readonly DashWorker[],
1291
+ activity: readonly DashActivity[],
1292
+ ): string {
1293
+ if (!engine && workers.length === 0 && activity.length === 0) return "";
1294
+ const head =
1295
+ engine === "main-session"
1296
+ ? `<p class="bg-note">Work runs in the pi session you started the harness from.</p>`
1297
+ : `<p class="bg-note">Work runs in separate background pi sessions, each on the model its difficulty tier names.</p>`;
1298
+ const cards = workers.length
1299
+ ? workers
1300
+ .map((w) => {
1301
+ const tokens = w.tokens ? w.tokens.inputTokens + w.tokens.outputTokens : 0;
1302
+ const ctx = typeof w.contextRatio === "number" ? `${Math.round(w.contextRatio * 100)}% ctx` : "";
1303
+ const bits = [
1304
+ `${w.level} · ${w.unitLabel}`,
1305
+ w.difficulty ? `${w.difficulty} tier` : "",
1306
+ `${w.turns ?? 0} turn${(w.turns ?? 0) === 1 ? "" : "s"}`,
1307
+ tokens ? `${tokens.toLocaleString("en-US")} tokens` : "",
1308
+ ctx,
1309
+ ].filter(Boolean);
1310
+ return (
1311
+ `<li class="bg-worker is-${esc(w.state)}">` +
1312
+ `<span class="bg-dot" aria-hidden="true"></span>` +
1313
+ `<span class="bg-name">${esc(w.name)}</span>` +
1314
+ `<span class="bg-model">${esc(w.servedModel || w.model || "pi default")}</span>` +
1315
+ `<span class="bg-meta">${esc(bits.join(" · "))}</span>` +
1316
+ (w.doing ? `<span class="bg-doing">${esc(w.doing)}</span>` : "") +
1317
+ `</li>`
1318
+ );
1319
+ })
1320
+ .join("")
1321
+ : `<li class="bg-worker is-idle"><span class="bg-dot" aria-hidden="true"></span><span class="bg-name">idle</span><span class="bg-meta">no background session running</span></li>`;
1322
+ const log = activity.length
1323
+ ? `<ol class="bg-log">` +
1324
+ activity
1325
+ .slice(-40)
1326
+ .reverse()
1327
+ .map(
1328
+ (l) =>
1329
+ `<li class="bg-line lvl-${esc(l.level)}"><span class="bg-time">${esc(l.at.slice(11, 16))}</span>` +
1330
+ (l.worker ? `<span class="bg-who">${esc(l.worker)}</span>` : `<span class="bg-who"></span>`) +
1331
+ `<span class="bg-text">${esc(l.text)}</span></li>`,
1332
+ )
1333
+ .join("") +
1334
+ `</ol>`
1335
+ : "";
1336
+ return `<section class="card background"><h2>Background</h2>${head}<ul class="bg-workers">${cards}</ul>${log}</section>`;
1337
+ }
1338
+
1213
1339
  export function renderDashboard(state: DashboardState): string {
1214
1340
  const list = normalizeForRender(state.list ?? { version: "0", baseRevision: 0, features: [] });
1215
1341
  const features = list.features ?? [];
@@ -1349,6 +1475,7 @@ ${
1349
1475
  : ""
1350
1476
  }
1351
1477
  ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1478
+ ${renderBackground(state.engine ?? null, state.workers ?? [], state.activity ?? [])}
1352
1479
  ${renderGate(gate)}
1353
1480
  ${goalTabsHtml}${phaseTabsHtml}
1354
1481
  ${body}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * infinity-harness — ui/viewState.ts
3
+ *
4
+ * View states — "not running" is a state, not an absence.
5
+ * Every Interface reads daemon.json BEFORE rendering, and derives a view state.
6
+ * Heartbeat 20s, stale after 90s — v2.7 shipped values, kept deliberately.
7
+ * Only the single place these numbers are defined.
8
+ */
9
+
10
+ import { readJsonSafe } from "../core/fsx.ts";
11
+ import { daemonPath, runStatePath, supervisorPath } from "../core/paths.ts";
12
+
13
+ export const HEARTBEAT_MS = 20_000;
14
+ export const OWNER_STALE_MS = 90_000;
15
+
16
+ export type ViewState =
17
+ | "running"
18
+ | "stale"
19
+ | "not-running"
20
+ | "never-armed"
21
+ | "awaiting-approval"
22
+ | "stopped";
23
+
24
+ export type ViewSnapshot = {
25
+ state: ViewState;
26
+ daemon: { pid?: number; heartbeatAt?: string; runId?: string } | null;
27
+ run: { armed?: boolean; stopReason?: string | null; runId?: string } | null;
28
+ supervisor: { state?: string; updatedAt?: string } | null;
29
+ reason?: string;
30
+ };
31
+
32
+ export function deriveViewState(targetDir: string): ViewSnapshot {
33
+ const run = readJsonSafe<Record<string, unknown> | null>(runStatePath(targetDir), null);
34
+ const daemon = readJsonSafe<Record<string, unknown> | null>(daemonPath(targetDir), null);
35
+ const supervisor = readJsonSafe<Record<string, unknown> | null>(supervisorPath(targetDir), null);
36
+
37
+ if (!run || !run.runId) {
38
+ return { state: "never-armed", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: "No run has been armed. Run the wizard." };
39
+ }
40
+ if (run && (run as { armed?: boolean }).armed === false) {
41
+ const stopReason = typeof (run as { stopReason?: unknown }).stopReason === "string" ? (run as { stopReason?: string }).stopReason : null;
42
+ return { state: "stopped", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: stopReason ?? "Run stopped." };
43
+ }
44
+ // Armed but no daemon file
45
+ if (!daemon) {
46
+ return { state: "not-running", daemon: null, run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: "Daemon not running — /infinity:run to start." };
47
+ }
48
+ const heartbeatAt = typeof (daemon as { heartbeatAt?: unknown }).heartbeatAt === "string" ? (daemon as { heartbeatAt: string }).heartbeatAt : null;
49
+ const ageMs = heartbeatAt ? Date.now() - new Date(heartbeatAt).getTime() : Infinity;
50
+ if (Number.isFinite(ageMs) && ageMs > OWNER_STALE_MS) {
51
+ // Check pid liveness as tiebreaker: stale heartbeat but pid still alive => stale, else not-running.
52
+ const pid = typeof (daemon as { pid?: unknown }).pid === "number" ? (daemon as { pid: number }).pid : undefined;
53
+ let pidAlive = false;
54
+ if (typeof pid === "number") { try { process.kill(pid, 0); pidAlive = true; } catch { pidAlive = false; } }
55
+ if (pidAlive) {
56
+ return { state: "stale", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: `Daemon unresponsive since ${heartbeatAt ?? "?"}` };
57
+ }
58
+ return { state: "not-running", daemon: null, run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"], reason: "Daemon process is gone." };
59
+ }
60
+ // Check awaiting-approval (supervisor state)
61
+ const supState = typeof (supervisor as { state?: unknown } | null)?.state === "string" ? String((supervisor as { state: string }).state) : null;
62
+ if (supState === "awaiting-approval") {
63
+ return { state: "awaiting-approval", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"] };
64
+ }
65
+ return { state: "running", daemon: daemon as ViewSnapshot["daemon"], run: run as ViewSnapshot["run"], supervisor: supervisor as ViewSnapshot["supervisor"] };
66
+ }
67
+
68
+ export function viewStateLabel(s: ViewState): string {
69
+ switch (s) {
70
+ case "running": return "running";
71
+ case "stale": return "stale";
72
+ case "not-running": return "not running";
73
+ case "never-armed": return "never armed";
74
+ case "awaiting-approval": return "awaiting approval";
75
+ case "stopped": return "stopped";
76
+ }
77
+ }
package/src/ui/widget.ts CHANGED
@@ -46,6 +46,12 @@ export type WidgetState = {
46
46
  enabledPhases?: readonly string[] | null;
47
47
  paused?: boolean;
48
48
  gate?: { overall: boolean; failures: string[] } | null;
49
+ viewState?: import("./viewState.ts").ViewSnapshot | null;
50
+ pilot?: string | null;
51
+ phaseModes?: Record<string, string> | null;
52
+ tierSpend?: Record<string, { input: number; output: number; cost: number; calls: number }> | null;
53
+ reworkDepth?: number | null;
54
+ replanDiff?: string | null;
49
55
  /** Dashboard URL to show near the top, clickable. Meaningful host:port, not just numbers. */
50
56
  dashboardUrl?: string | null;
51
57
  /** Model routing note: e.g. "Model per task — subtasks share parent". Shown once. */
@@ -86,6 +92,27 @@ export type WidgetState = {
86
92
  * there too — configuring how you read a plan once, rather than twice.
87
93
  */
88
94
  display?: DisplayPolicy | null;
95
+ /**
96
+ * Where the work is actually happening.
97
+ *
98
+ * `background` means the run is being driven by separate pi sessions and
99
+ * this one is a control panel. It is the single most important thing the
100
+ * widget can say, because it is the difference between "my model is being
101
+ * spent on this" and "it is not".
102
+ */
103
+ engine?: "background" | "main-session" | null;
104
+ /**
105
+ * The background sessions doing the work right now.
106
+ *
107
+ * This is the answer to "what is happening?" when the answer is no longer
108
+ * visible in the transcript — which, once the work moved out of this
109
+ * session, is always.
110
+ */
111
+ workers?: WorkerLine[] | null;
112
+ /** The tail of the background log: what the workers have been doing. */
113
+ activity?: ActivityLine[] | null;
114
+ /** Rows of background log to draw. 0 hides the section. */
115
+ activityRows?: number;
89
116
  /**
90
117
  * What the human asked for, before a plan exists to hold a goal.
91
118
  *
@@ -96,6 +123,26 @@ export type WidgetState = {
96
123
  intake?: string | null;
97
124
  };
98
125
 
126
+ /** One background pi session, as the widget draws it. */
127
+ export type WorkerLine = {
128
+ name: string;
129
+ unit: string;
130
+ level: string;
131
+ model: string;
132
+ difficulty?: string | null;
133
+ state: "starting" | "working" | "idle" | "closed" | "failed";
134
+ doing?: string | null;
135
+ tokens?: number;
136
+ contextRatio?: number | null;
137
+ };
138
+
139
+ export type ActivityLine = {
140
+ at: string;
141
+ level: "info" | "work" | "warn" | "error" | "good";
142
+ worker: string | null;
143
+ text: string;
144
+ };
145
+
99
146
  export type WidgetView = {
100
147
  /** First visible row, or null to follow the active task. */
101
148
  scroll: number | null;
@@ -378,6 +425,78 @@ export function rowWindow(
378
425
  * Returns plain strings so the host can hand them straight to
379
426
  * `ctx.ui.setWidget`. Colour is embedded as ANSI when the styler is colouring.
380
427
  */
428
+
429
+ /** Rows of background log drawn by default. Enough to see the shape of a minute. */
430
+ export const ACTIVITY_ROWS = 5;
431
+
432
+ const ACTIVITY_ROLE: Record<ActivityLine["level"], Role> = {
433
+ info: "muted",
434
+ work: "text",
435
+ warn: "rework",
436
+ error: "blocked",
437
+ good: "success",
438
+ };
439
+
440
+ const WORKER_ROLE: Record<WorkerLine["state"], Role> = {
441
+ starting: "active",
442
+ working: "active",
443
+ idle: "muted",
444
+ closed: "muted",
445
+ failed: "blocked",
446
+ };
447
+
448
+ /** `HH:MM` in local time — a full ISO stamp is six columns of nothing. */
449
+ function clock(iso: string): string {
450
+ const d = new Date(iso);
451
+ if (Number.isNaN(d.getTime())) return "--:--";
452
+ return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
453
+ }
454
+
455
+ /**
456
+ * The background sessions, one line each.
457
+ *
458
+ * The model is on the line on purpose. The whole point of routing work out of
459
+ * the human's session is that a different model does it, and a claim you
460
+ * cannot see is a claim nobody believes — least of all after the version
461
+ * where it silently did not happen.
462
+ */
463
+ export function renderWorkers(workers: readonly WorkerLine[], inner: number, g: GlyphSet, s: Styler): string[] {
464
+ const out: string[] = [];
465
+ for (const w of workers) {
466
+ const role = WORKER_ROLE[w.state] ?? "muted";
467
+ const dot = s.fg(role, w.state === "working" || w.state === "starting" ? g.inProgress : g.pending);
468
+ const head =
469
+ dot +
470
+ " " +
471
+ s.bold(s.fg(role, w.name)) +
472
+ s.fg("rule", " · ") +
473
+ s.fg("text", w.unit) +
474
+ (w.difficulty ? s.fg("muted", " (" + w.difficulty + ")") : "");
475
+ const model = s.fg("accent", w.model || "pi default");
476
+ const ctx =
477
+ typeof w.contextRatio === "number" ? s.fg(w.contextRatio > 0.75 ? "rework" : "muted", " " + Math.round(w.contextRatio * 100) + "%") : "";
478
+ const right = model + ctx;
479
+ const gap = Math.max(1, inner - width(head) - width(right));
480
+ out.push(truncate(head + " ".repeat(gap) + right, inner));
481
+ if (w.doing) out.push(truncate(" " + s.fg("muted", g.arrow + " " + w.doing), inner));
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /** The background log: the only place a human sees what the workers did. */
487
+ export function renderActivity(lines: readonly ActivityLine[], rows: number, inner: number, s: Styler): string[] {
488
+ const tail = lines.slice(-Math.max(0, rows));
489
+ return tail.map((l) =>
490
+ truncate(
491
+ s.fg("rule", clock(l.at)) +
492
+ " " +
493
+ (l.worker ? s.fg("accent", l.worker) + " " : "") +
494
+ s.fg(ACTIVITY_ROLE[l.level] ?? "muted", l.text),
495
+ inner,
496
+ ),
497
+ );
498
+ }
499
+
381
500
  export function renderWidget(state: WidgetState, options: WidgetOptions = {}): string[] {
382
501
  const total = options.width ?? DEFAULT_WIDTH;
383
502
  const s = options.styler ?? createStyler();
@@ -397,6 +516,55 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
397
516
 
398
517
  const progress = computeProgress(state.list);
399
518
  const tasks = flattenTasks(state.list);
519
+ const viewSnap = (state as WidgetState & { viewState?: import("./viewState.ts").ViewSnapshot | null }).viewState ?? null;
520
+
521
+ // -- viewState banner -----------------------------------------------------
522
+ // Reads daemon.json first. Widget never renders dead run as live.
523
+ if (viewSnap && viewSnap.state !== "running" && viewSnap.state !== "awaiting-approval") {
524
+ const hb = (viewSnap.daemon as { heartbeatAt?: string } | null)?.heartbeatAt ?? null;
525
+ const ts = hb ? s.fg("muted", ` (as of ${String(hb).slice(11, 16)})`) : "";
526
+ const msg =
527
+ viewSnap.state === "stale"
528
+ ? s.fg("rework", `\u26A0 Daemon unresponsive since ${hb ? String(hb).slice(11, 16) : "?"}`) + ts
529
+ : viewSnap.state === "not-running"
530
+ ? s.fg("blocked", "Daemon not running \u2014 /infinity:run to start.") + ts
531
+ : viewSnap.state === "never-armed"
532
+ ? s.fg("muted", viewSnap.reason ?? "Never armed \u2014 run the wizard.")
533
+ : viewSnap.state === "stopped"
534
+ ? s.fg("blocked", `STOPPED: ${viewSnap.reason ?? (viewSnap.run as { stopReason?: string } | null)?.stopReason ?? "stopped"}`) + ts
535
+ : s.fg("muted", viewSnap.reason ?? "");
536
+ push(truncate(msg, inner));
537
+ }
538
+ if (viewSnap && viewSnap.state === "awaiting-approval") {
539
+ const phase2 = ((viewSnap.supervisor as { phase?: string } | null)?.phase ?? state.phase ?? "?");
540
+ push(truncate(s.fg("active", `\u23F8 Awaiting approval: ${String(phase2).toUpperCase()} \u2192 /infinity:approve`) + s.fg("muted", " (run is healthy, not stuck)"), inner));
541
+ }
542
+
543
+ // -- pilot badge + per-tier spend + rework queue ---------------------------
544
+ if ((state as WidgetState).pilot) {
545
+ const pilot = (state as WidgetState).pilot as string;
546
+ const tag = s.bold(s.fg(pilot === "full" ? "rework" : pilot === "autopilot" ? "success" : "accent", pilot.toUpperCase()));
547
+ const pModes = (state as WidgetState).phaseModes ?? null;
548
+ const modesStr = pModes ? Object.entries(pModes).map(([ph, m]) => s.fg(m === "autopilot" ? "success" : "active", `${ph}:${m}`)).join(s.fg("rule", " \u00b7 ")) : "";
549
+ push(truncate(s.fg("muted", "Pilot ") + tag + (modesStr ? s.fg("rule", " ") + modesStr : ""), inner));
550
+ }
551
+ {
552
+ const ts2 = (state as WidgetState).tierSpend ?? null;
553
+ if (ts2) {
554
+ const parts: string[] = [];
555
+ for (const tier of ["A", "B", "C", "D", "X"] as const) {
556
+ const t = (ts2 as Record<string,{input:number;output:number;cost:number;calls:number}>)[tier];
557
+ if (!t) continue;
558
+ const label = `${tier}:${t.input + t.output}`;
559
+ parts.push(tier === "X" && (t.cost > 0 || t.input + t.output > 0) ? s.bold(s.fg("blocked", label)) : s.fg("muted", label));
560
+ }
561
+ if (parts.length) push(truncate(s.fg("muted", "Spend ") + parts.join(s.fg("rule", " \u00b7 ")), inner));
562
+ }
563
+ }
564
+ if (typeof (state as WidgetState).reworkDepth === "number" && ((state as WidgetState).reworkDepth as number) > 0) {
565
+ push(truncate(s.fg("rework", `\u21BB Rework queue: ${(state as WidgetState).reworkDepth} item(s) holding gate open`), inner));
566
+ }
567
+ if ((state as WidgetState).replanDiff) push(truncate(s.fg("muted", "Replan: ") + s.fg("text", String((state as WidgetState).replanDiff).slice(0, 80)), inner));
400
568
 
401
569
  // -- header ---------------------------------------------------------------
402
570
  const brand = s.bold(s.fg("brand", "∞ INFINITY"));
@@ -535,6 +703,27 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
535
703
  }
536
704
  }
537
705
 
706
+ // -- background sessions --------------------------------------------------
707
+ //
708
+ // Placed above the rail because, on a run driven by workers, this is the
709
+ // part of the widget that changes second by second. A human glancing at the
710
+ // terminal is checking that something is alive.
711
+ const workers = state.workers ?? [];
712
+ if (workers.length) {
713
+ push();
714
+ push(s.fg("rule", "background") + s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 12))));
715
+ for (const line of renderWorkers(workers, inner, g, s)) push(line);
716
+ } else if (state.engine === "background" && state.list.features.length > 0) {
717
+ push();
718
+ push(truncate(s.fg("muted", "background · no worker running — /infinity:run starts one"), inner));
719
+ }
720
+
721
+ const activityRows = state.activityRows ?? (state.activity?.length ? ACTIVITY_ROWS : 0);
722
+ if (activityRows > 0 && state.activity?.length) {
723
+ push();
724
+ for (const line of renderActivity(state.activity, view.expanded ? activityRows * 3 : activityRows, inner, s)) push(line);
725
+ }
726
+
538
727
  // -- phase rail -----------------------------------------------------------
539
728
  if (display.rail) {
540
729
  push();
package/src/ui/wizard.ts CHANGED
@@ -109,14 +109,49 @@ async function pickThinkingLevel(prompt: Prompter, title: string): Promise<Think
109
109
  return picked as ThinkingLevel;
110
110
  }
111
111
 
112
- async function pickModelsStep(prompt: Prompter, modelsFn?: WizardOptions["models"]): Promise<{ router: NonNullable<IntakeAnswers["router"]> } | undefined> {
112
+ /**
113
+ * What a difficulty tier actually buys, at the level the human just chose.
114
+ *
115
+ * Difficulty is scored per task, but the *model* changes only where the
116
+ * session changes — that is what makes it a model boundary at all. So at
117
+ * feature-level handoff a feature runs on its hardest task's model and the
118
+ * easy tasks inside it get the hard model too. Saying that here, in the
119
+ * question, is the difference between a setting that behaves surprisingly and
120
+ * one the human chose with their eyes open.
121
+ */
122
+ export function tierScopeNote(handoff: string): string {
123
+ switch (handoff) {
124
+ case "off":
125
+ case "goal":
126
+ return "one session for the whole run, so the run takes the hardest tier in the plan";
127
+ case "phase":
128
+ return "one session per phase, so every task in a phase takes that phase's hardest tier";
129
+ case "sprint":
130
+ return "one session per sprint, so every task in a sprint takes that sprint's hardest tier";
131
+ case "feature":
132
+ return "one session per feature, so every task in a feature takes that feature's hardest tier";
133
+ case "subtask":
134
+ return "one session per subtask, so each subtask can take its own tier";
135
+ default:
136
+ return "one session per task, so each task takes its own tier";
137
+ }
138
+ }
139
+
140
+ async function pickModelsStep(
141
+ prompt: Prompter,
142
+ modelsFn?: WizardOptions["models"],
143
+ handoff: string = "task",
144
+ ): Promise<{ router: NonNullable<IntakeAnswers["router"]> } | undefined> {
113
145
  const models = modelsFn ? (await modelsFn()) ?? [] : [];
114
146
  // First ask whether routing is even wanted — most runs don't need it, and
115
147
  // skipping the 8 follow-up questions keeps the wizard short. Old tests that
116
148
  // don't know about this step get routing off by default so they keep passing.
117
149
  const ROUTE_ON = "yes — pick models per tier";
118
150
  const ROUTE_OFF = "no — use pi's current model for everything";
119
- const enablePick = await prompt.select("Route work by difficulty to different models?", [ROUTE_ON, ROUTE_OFF]);
151
+ const enablePick = await prompt.select(
152
+ `Route work by difficulty to different models? (${tierScopeNote(handoff)})`,
153
+ [ROUTE_ON, ROUTE_OFF],
154
+ );
120
155
  if (enablePick === undefined) {
121
156
  // No answer scripted (e.g. an older test) → treat as "off" so the
122
157
  // wizard doesn't look cancelled to callers that only scripted four steps.
@@ -178,10 +213,11 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
178
213
  const handoffLabels = handoffOptions.map((o) => line(o.label, o.help));
179
214
  const handoffPick = await prompt.select(HANDOFF_QUESTION.title, handoffLabels);
180
215
  if (handoffPick === undefined) return { cancelled: true };
181
- const handoff = (handoffOptions[handoffLabels.indexOf(handoffPick)]?.value ?? "phase") as
182
- | "off"
183
- | "phase"
184
- | "task";
216
+ // Every level the question offers, not the three this cast used to allow:
217
+ // picking "every feature" and getting the task-level narrowing is how a
218
+ // setting silently becomes a different setting.
219
+ const handoff = (handoffOptions[handoffLabels.indexOf(handoffPick)]?.value ??
220
+ "phase") as import("../core/types.ts").HandoffGranularity;
185
221
 
186
222
  // -- 3b. research depth (only when research is in the pipeline) ----------
187
223
  let researchDepth: import("../intake.ts").ResearchDepth | undefined;
@@ -201,7 +237,7 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
201
237
  }
202
238
 
203
239
  // -- 4. models ----------------------------------------------------------
204
- const modelsAnswer = await pickModelsStep(prompt, options.models);
240
+ const modelsAnswer = await pickModelsStep(prompt, options.models, handoff);
205
241
  if (modelsAnswer === undefined) return { cancelled: true };
206
242
 
207
243
  // -- 5. execution (parallelism) -----------------------------------------
package/src/unstuck.ts CHANGED
File without changes
package/src/worker.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
12
12
  import { resolve, join, dirname } from "node:path";
13
13
  import { spawn, execSync } from "node:child_process";
14
- import { stripBom } from "./core/fsx.ts";
14
+ import { loadFeatureList, resolvePlanFile } from "./core/featureList.ts";
15
+ import { planPath } from "./core/paths.ts";
15
16
 
16
17
  // ── constants ───────────────────────────────────────────────────────────────
17
18
  export const WORKER_ROOT_SEGMENT = "tmp/infinity-harness";
@@ -53,10 +54,8 @@ function gitHeadSync(projectDir: string): string | undefined {
53
54
 
54
55
  function readBaseRevision(projectDir: string): number {
55
56
  try {
56
- const p = resolve(projectDir, "harness", "features", "feature-list.json");
57
- if (!existsSync(p)) return 0;
58
- const raw = JSON.parse(stripBom(readFileSync(p, "utf-8")));
59
- return typeof raw.baseRevision === "number" ? raw.baseRevision : 0;
57
+ const { list } = loadFeatureList(projectDir);
58
+ return typeof list.baseRevision === "number" ? list.baseRevision : 0;
60
59
  } catch {
61
60
  return 0;
62
61
  }
@@ -75,8 +74,8 @@ export function buildFingerprint(opts: {
75
74
  const baseRevision = opts.baseRevision ?? readBaseRevision(projectDir);
76
75
  let featureListHash: number | undefined;
77
76
  try {
78
- const p = resolve(projectDir, "harness", "features", "feature-list.json");
79
- if (existsSync(p)) featureListHash = hashLite(readFileSync(p, "utf-8"));
77
+ const { path: planFile } = resolvePlanFile(projectDir);
78
+ if (existsSync(planFile)) featureListHash = hashLite(readFileSync(planFile, "utf-8"));
80
79
  } catch {}
81
80
  return {
82
81
  runId: opts.runId,
@@ -178,7 +177,12 @@ async function withLock<T>(targetPath: string, fn: () => Promise<T> | T): Promis
178
177
  }
179
178
 
180
179
  export async function withFeatureListLock<T>(projectDir: string, fn: () => Promise<T> | T): Promise<T> {
181
- const p = resolve(projectDir, "harness", "features", "feature-list.json");
180
+ const p = planPath(projectDir);
181
+ try {
182
+ const d = dirname(p);
183
+ mkdirSync(d, { recursive: true });
184
+ if (!existsSync(p)) writeFileSync(p, "", "utf-8");
185
+ } catch {}
182
186
  return withLock(p, fn);
183
187
  }
184
188