infinity-harness 2.7.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.8.0] — 2026-08-30
8
+
9
+ The harness stops shipping its own run. The repo is the driver, not the project.
10
+
11
+ ### Changed
12
+
13
+ - **`harness/` is now driver-only.** Runtime state (`harness/config.json`, `harness/plan.json`, `harness/features/feature-list.json`, `harness/progress.md`, `harness/session-handoff.md`, `harness/sprint-contract.md`, `harness/lessons-decisions.md`, `harness/capability/index.json`, `harness/run.json`, `harness/daemon.json`, `harness/supervisor.json`, `harness/activity.json`, `harness/rework.json`, `harness/replan.json`, `harness/loop-state.json`, `harness/next-session.json`, `harness/daemon.log`, `harness/.run-prompt.md`, `harness/.preflight`, `harness/run-journal.jsonl`, `harness/**/*.bak`, `harness/**/*.lock`, `harness/**/*.ilock`, `harness/worktrees/`) is git-ignored and deleted. Fresh clone shows no `SHIP 37/37` widget until `/infinity:init` creates a local (ignored) harness. Templates (`harness/docs`, `harness/skills`, `harness/features/feature-list.schema.json`, `harness/model-router.json`, `harness/ci`, `harness/scripts`) stay tracked. Fixes the dogfooding leak where every clone rendered its builder's SHIP bar.
14
+
15
+ - **Refactored and simplified without changing behaviour.** Removed dead helpers (`migrateModelRouterTiers`, `tiersFromLegacyModelRouter`, `writeCanonicalWithLockSync`), replaced 11 inline `require()` calls with static imports, extracted `scheduler.ts` helpers (`isBudgetFull`, `hasSerializeTask`, `pickSerializeTask`, `isSerializeBlocked`, `eligibleTasks`, `groupKeyFor`, `roundRobin`), removed unused `WorkerSnapshot`/`tailWorkerOutput`/`listWorkers`/`nextModelForTask` exports, cleaned `goalState.ts`/`core/config.ts`/`modelRouter.ts` dead imports and empty blocks. Net −115 lines. `tsc --noEmit` clean and 35/35 tests pass.
16
+
17
+ ### Verified
18
+
19
+ - `tsc --noEmit` clean · 35 unit tests pass · `harness/` on disk equals `harness/` tracked · fresh clone renders no legacy `SHIP 37/37`
20
+
7
21
  ## [2.7.0] — 2026-08-29
8
22
 
9
23
  The work leaves your session. A run is driven by background pi processes on the models you
@@ -31,6 +31,9 @@ import { runChecks } from "../../src/core/gates.ts";
31
31
  import { advancePhase } from "../../src/core/phases.ts";
32
32
  import { configPath } from "../../src/core/paths.ts";
33
33
  import { readJsonSafe } from "../../src/core/fsx.ts";
34
+ import { resolve as resolvePath } from "node:path";
35
+ import { deriveViewState as deriveViewStateSync } from "../../src/ui/viewState.ts";
36
+ import { runStatePath as runStatePathSync } from "../../src/core/paths.ts";
34
37
  import { withLock } from "../../src/core/lock.ts";
35
38
  import {
36
39
  DEFAULT_ENABLED_PHASES,
@@ -234,6 +237,16 @@ export default function (pi: ExtensionAPI): void {
234
237
  const { config } = loadConfig(dir);
235
238
  const handoffModelNote: string | null = handoffNoteFor((config.session?.handoff as import("../../src/core/types.ts").HandoffGranularity) ?? "task");
236
239
  const spent = escalationSummary(dir);
240
+ // v3 viewState: widget must read daemon.json before rendering — deriveViewState is that check.
241
+ let viewState: WidgetState["viewState"] = null;
242
+ try { viewState = deriveViewStateSync(dir) as unknown as WidgetState["viewState"]; } catch {}
243
+ let pilotTag2: string | null = (config as unknown as { pilot?: string }).pilot ?? null;
244
+ let phaseModes2: Record<string,string> | null = (config as unknown as { phaseModes?: Record<string,string> }).phaseModes ?? null;
245
+ if (!pilotTag2) pilotTag2 = (config as unknown as { mode?: string }).mode === "copilot" ? "copilot" : (config as unknown as { mode?: string }).mode === "autopilot" ? "autopilot" : null;
246
+ let tierSpend2: WidgetState["tierSpend"] = null;
247
+ let reworkDepth2: number | null = null;
248
+ try { const rs = readJsonSafe<{ budget?: { byTier?: Record<string,{input:number;output:number;cost:number;calls:number}> } } | null>(runStatePathSync(dir), null); tierSpend2 = (rs?.budget?.byTier as WidgetState["tierSpend"]) ?? null; } catch {}
249
+ try { const rw = readJsonSafe<{ impactedCount?:number, queue?: unknown[] } | null>(resolvePath(dir,"harness/rework.json"), null); if (Array.isArray((rw as { queue?:unknown[]})?.queue)) reworkDepth2 = (rw as { queue: unknown[]}).queue.length; else if (typeof (rw as { impactedCount?:number})?.impactedCount === "number") reworkDepth2 = Number((rw as { impactedCount:number}).impactedCount); } catch {}
237
250
  const loop = readJsonSafe<{ escalations?: { strategy: string }[] } | null>(
238
251
  loopStatePath(dir),
239
252
  null,
@@ -246,6 +259,11 @@ export default function (pi: ExtensionAPI): void {
246
259
  const worker = sup?.worker ?? null;
247
260
  return {
248
261
  list,
262
+ viewState,
263
+ pilot: pilotTag2,
264
+ phaseModes: phaseModes2,
265
+ tierSpend: tierSpend2,
266
+ reworkDepth: reworkDepth2,
249
267
  view,
250
268
  engine: executionPolicyOf(config).engine,
251
269
  workers: worker
@@ -975,13 +993,20 @@ export default function (pi: ExtensionAPI): void {
975
993
  try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
976
994
  }
977
995
  try {
996
+ const viewState = (()=>{ try{ return deriveViewStateSync(dir); }catch{return null; }})();
997
+ const daemonAlive = (()=>{ try { const d=readJsonSafe<{heartbeatAt?:string}|null>(resolvePath(dir,"harness/daemon.json"), null); if(!d?.heartbeatAt) return false; return Date.now()-new Date(d.heartbeatAt).getTime()<90_000; } catch { return false; }})() ? "daemon alive" : viewState ? `daemon ${viewState.state}` : "daemon unknown";
998
+ const armed = (()=>{ try{ const r=readJsonSafe<{armed?:boolean}|null>(runStatePathSync(dir), null); return r?.armed===true ? "armed" : "not armed"; }catch{return "not armed";}})();
999
+ const prefix = `[infinity-harness] ${daemonAlive}, ${armed}, phase=${loadConfig(dir).config.currentPhase ?? "?"}${viewState?.reason ? ` — ${viewState.reason}` : ""}\n`;
978
1000
  const contract =
979
1001
  engineFor(dir) === "main-session" ? harnessContract(dir) : controlPanelContract(dir);
980
- if (!contract) return;
1002
+ if (!contract) {
1003
+ const base0 = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
1004
+ return { systemPrompt: `${prefix}\n${base0}` };
1005
+ }
981
1006
  const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
982
1007
  const routed = await routingSummaryForBrief(dir);
983
1008
  const suffix = routed ? `\n\n${routed}` : "";
984
- return { systemPrompt: `${base}${suffix}\n\n${contract}` };
1009
+ return { systemPrompt: `${prefix}${base}${suffix}\n\n${contract}` };
985
1010
  } catch {
986
1011
  return;
987
1012
  }
@@ -1309,6 +1334,7 @@ export default function (pi: ExtensionAPI): void {
1309
1334
  difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
1310
1335
  modelHint: { type: "string" },
1311
1336
  criteria: { type: "array", items: { type: "string" } },
1337
+ phase: { type: "string", enum: ["research","define","plan","build","verify","simplify","review","ship"], description: "Which phase owns this task — task.phase, absent means build" },
1312
1338
  },
1313
1339
  },
1314
1340
  },
@@ -2082,13 +2108,24 @@ export default function (pi: ExtensionAPI): void {
2082
2108
  });
2083
2109
 
2084
2110
  pi.registerCommand("infinity:approve", {
2085
- description: "Approve the phase waiting for you — or send it back with a note",
2111
+ description: "Approve the phase waiting for you — or send it back with a note (via daemon when running)",
2086
2112
  handler: async (args: string, ctx: ExtensionContext) => {
2087
2113
  const dir = projectDir(ctx);
2088
2114
  if (!isHarnessProject(dir)) {
2089
2115
  notify(ctx, NO_HARNESS, "warning");
2090
2116
  return;
2091
2117
  }
2118
+ // Prefer daemon when alive: forward approval to localhost daemon (token-guarded).
2119
+ try {
2120
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
2121
+ if (d?.port) {
2122
+ const h: Record<string,string> = { "Content-Type": "application/json" };
2123
+ if (d.token) h["Authorization"]=`Bearer ${d.token}`;
2124
+ const r = await fetch(`http://127.0.0.1:${d.port}/approve`, { method: "POST", headers: h, body: JSON.stringify({ note: args.trim() }) });
2125
+ const j = await r.json().catch(()=>({})) as { ok?: boolean; error?: string };
2126
+ if (r.ok && j.ok !== false) { notify(ctx, "infinity-harness: approved via daemon.", "info"); refreshWidget(ctx); return; }
2127
+ }
2128
+ } catch {}
2092
2129
  const { config } = loadConfig(dir);
2093
2130
  if (!config.awaitingApproval) {
2094
2131
  const signing = approvedPhases(config);
@@ -2101,7 +2138,6 @@ export default function (pi: ExtensionAPI): void {
2101
2138
  );
2102
2139
  return;
2103
2140
  }
2104
- // Approving re-arms the run: the human answering is them saying carry on.
2105
2141
  if (!loopArmed(dir)) armRun(dir, sessionId);
2106
2142
  await applyApproval(ctx, dir, args.trim());
2107
2143
  },
@@ -2587,13 +2623,73 @@ export default function (pi: ExtensionAPI): void {
2587
2623
  });
2588
2624
 
2589
2625
  pi.registerCommand("infinity:run", {
2590
- description: "Start the continuous loop — validate, advance, re-brief, until done or stuck",
2626
+ description: "Start the continuous loop — validate, advance, re-brief, until done or stuck (captures baseModel for daemon)",
2591
2627
  handler: async (_args: string, ctx: ExtensionContext) => {
2592
2628
  const dir = projectDir(ctx);
2593
2629
  if (!isHarnessProject(dir)) {
2594
2630
  notify(ctx, NO_HARNESS, "warning");
2595
2631
  return;
2596
2632
  }
2633
+ // Capture baseModel (X) at arm time — the detached Daemon has no ctx.model.
2634
+ // Without this, Daemon refuses to arm and silently falls back to pi default.
2635
+ try {
2636
+ const bm = baseModelOf(ctx);
2637
+ if (bm) {
2638
+ const { loadRunState: _load, saveRunState: _save, runIdFor: _runIdFor } = await import("../../src/core/runState.ts");
2639
+ const runId = _runIdFor(dir, sessionId);
2640
+ const rs = _load(dir);
2641
+ const parts = bm.split("/");
2642
+ const baseModel = parts.length >= 2 ? { provider: parts[0]!, id: parts.slice(1).join("/") } : { provider: "anthropic", id: bm };
2643
+ if (rs) { rs.baseModel = baseModel as never; _save(dir, rs); }
2644
+ else {
2645
+ const { newRunState } = await import("../../src/core/runState.ts");
2646
+ const ns = newRunState(runId);
2647
+ ns.baseModel = baseModel as never;
2648
+ _save(dir, ns);
2649
+ }
2650
+ }
2651
+ } catch {}
2652
+ // When a detached Daemon can serve this repo, prefer it: it is the only
2653
+ // path where /infinity:halt|approve|pilot|replan go to Daemon and the
2654
+ // model stays on X. Spawning it is one detached `node` process.
2655
+ const tryDaemonSpawn = async (): Promise<{ spawned: boolean; url?: string }> => {
2656
+ try {
2657
+ const { spawn } = await import("node:child_process");
2658
+ const { existsSync, openSync, closeSync } = await import("node:fs");
2659
+ const { resolve: _resolve } = await import("node:path");
2660
+ // Daemon entry must exist; when not built (dev) fall back to supervisor.
2661
+ const candidates = [
2662
+ _resolve(dir, "dist/daemon/index.js"),
2663
+ _resolve(dir, "src/daemon/index.ts"),
2664
+ ];
2665
+ let entry: string | null = null;
2666
+ for (const c of candidates) if (existsSync(c)) { entry = c; break; }
2667
+ if (!entry) return { spawned: false };
2668
+ // Guard: single owner — if a daemon is already alive, do not spawn rival.
2669
+ try {
2670
+ const { loadDaemon: _ld, isDaemonAlive: _alive } = await import("../../src/daemon/guard.ts");
2671
+ const live = _ld(dir);
2672
+ if (live && _alive(live)) return { spawned: true, url: `http://127.0.0.1:${live.port}/dashboard` };
2673
+ } catch {}
2674
+ const logFd = openSync(_resolve(dir, "harness/daemon.log"), "a");
2675
+ const args: string[] = [];
2676
+ if (entry.endsWith(".ts")) args.push("--experimental-strip-types", "--no-warnings=ExperimentalWarning");
2677
+ args.push(entry, dir);
2678
+ const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, env: { ...process.env, INFINITY_HARNESS_WORKER: "1" } } as unknown as Parameters<typeof spawn>[2]);
2679
+ try { child.unref(); } catch {}
2680
+ try { closeSync(logFd); } catch {}
2681
+ // Brief poll for daemon.json liveness (port picked as 0).
2682
+ for (let i = 0; i < 40; i++) {
2683
+ await new Promise(r=>setTimeout(r, 250));
2684
+ try {
2685
+ const { loadDaemon: ld2, isDaemonAlive: alive2 } = await import("../../src/daemon/guard.ts");
2686
+ const live2 = ld2(dir);
2687
+ if (live2 && alive2(live2)) return { spawned: true, url: `http://127.0.0.1:${live2.port}/dashboard` };
2688
+ } catch {}
2689
+ }
2690
+ return { spawned: true };
2691
+ } catch { return { spawned: false }; }
2692
+ };
2597
2693
  armRun(dir, sessionId);
2598
2694
  const engine = engineFor(dir);
2599
2695
  notify(
@@ -2607,9 +2703,13 @@ export default function (pi: ExtensionAPI): void {
2607
2703
  pi.sendUserMessage(text, { deliverAs: "followUp" });
2608
2704
  return;
2609
2705
  }
2610
- // The background engine: this session says nothing to its own model. It
2611
- // starts an orchestrator that spawns pi children on the routed models,
2612
- // and from here on it is a control panel.
2706
+ const daemonResult = await tryDaemonSpawn();
2707
+ if (daemonResult.spawned) {
2708
+ notify(ctx, daemonResult.url ? `infinity-harness: daemon running at ${daemonResult.url} — this session stays free; /infinity:halt stops it.` : "infinity-harness: daemon started — this session stays free; /infinity:halt stops it.", "info");
2709
+ refreshWidget(ctx);
2710
+ return;
2711
+ }
2712
+ // Fallback: legacy background supervisor (spawns pi child sessions in this pi process).
2613
2713
  const unit = currentUnit(dir, baseModelOf(ctx));
2614
2714
  await startEngine(ctx, dir);
2615
2715
  notify(
@@ -2704,7 +2804,7 @@ export default function (pi: ExtensionAPI): void {
2704
2804
  });
2705
2805
 
2706
2806
  pi.registerCommand("infinity:rework", {
2707
- description: "Send a task and its dependents back to rework",
2807
+ description: "Send a task and its dependents back to rework (via daemon when running)",
2708
2808
  handler: async (args: string, ctx: ExtensionContext) => {
2709
2809
  const dir = projectDir(ctx);
2710
2810
  if (!isHarnessProject(dir)) {
@@ -2712,6 +2812,19 @@ export default function (pi: ExtensionAPI): void {
2712
2812
  return;
2713
2813
  }
2714
2814
  const key = args.trim();
2815
+ // Daemon owns rework.json mutation — forward when alive.
2816
+ if (key && key !== "clear") {
2817
+ try {
2818
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
2819
+ if (d?.port) {
2820
+ const h: Record<string,string> = { "Content-Type": "application/json" };
2821
+ if (d.token) h["Authorization"] = `Bearer ${d.token}`;
2822
+ const r = await fetch(`http://127.0.0.1:${d.port}/rework`, { method: "POST", headers: h, body: JSON.stringify({ task: key }) });
2823
+ const j = await r.json().catch(()=>({})) as { ok?: boolean; error?: string };
2824
+ if (r.ok && j.ok !== false) { notify(ctx, `infinity-harness: rework ${key} via daemon.`, "info"); refreshWidget(ctx); return; }
2825
+ }
2826
+ } catch {}
2827
+ }
2715
2828
  const { list } = loadFeatureList(dir);
2716
2829
  const tasks = flattenTasks(list);
2717
2830
 
@@ -2766,6 +2879,17 @@ export default function (pi: ExtensionAPI): void {
2766
2879
  notify(ctx, NO_HARNESS, "warning");
2767
2880
  return;
2768
2881
  }
2882
+ // Prefer daemon path: POST /halt to daemon (token-guarded), fall back to local.
2883
+ try {
2884
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
2885
+ if (d?.port) {
2886
+ const headers: Record<string,string> = { "Content-Type": "application/json" };
2887
+ if (d.token) headers["Authorization"] = `Bearer ${d.token}`;
2888
+ const r = await fetch(`http://127.0.0.1:${d.port}/halt`, { method: "POST", headers, body: JSON.stringify({}) });
2889
+ const j = await r.json().catch(()=> ({})) as { ok?: boolean; error?: string };
2890
+ if (r.ok && j.ok !== false) { notify(ctx, "infinity-harness: halted via daemon.", "info"); refreshWidget(ctx); return; }
2891
+ }
2892
+ } catch {}
2769
2893
  disarmRun(dir, "halted from /infinity:halt");
2770
2894
  clearHandoff(dir);
2771
2895
  await stopEngine("halted from /infinity:halt");
@@ -2982,16 +3106,24 @@ export default function (pi: ExtensionAPI): void {
2982
3106
  });
2983
3107
 
2984
3108
  pi.registerCommand("infinity:dashboard", {
2985
- description: "Open the read-only web dashboard for this run",
3109
+ description: "Open the read-only web dashboard for this run (served by daemon when running, fallback remote)",
2986
3110
  handler: async (_args: string, ctx: ExtensionContext) => {
2987
3111
  const dir = projectDir(ctx);
2988
3112
  if (!isHarnessProject(dir)) {
2989
3113
  notify(ctx, NO_HARNESS, "warning");
2990
3114
  return;
2991
3115
  }
3116
+ try {
3117
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
3118
+ if (d?.port) {
3119
+ const url = `http://127.0.0.1:${d.port}/dashboard`;
3120
+ notify(ctx, `infinity-harness dashboard (daemon): ${url}`, "info");
3121
+ return;
3122
+ }
3123
+ } catch {}
2992
3124
  const remote = await import("../../src/remote.ts");
2993
3125
  if (remoteServer) {
2994
- notify(ctx, `Dashboard already live at ${remoteServer.url}`, "info");
3126
+ notify(ctx, `Dashboard already live at ${remoteServer.url} (fallback remote)`, "info");
2995
3127
  return;
2996
3128
  }
2997
3129
  const srv = await remote.createRemoteServer({ projectDir: dir, host: "127.0.0.1", port: 0 });
@@ -3000,6 +3132,75 @@ export default function (pi: ExtensionAPI): void {
3000
3132
  notify(ctx, `infinity-harness dashboard: ${srv.url}`, "info");
3001
3133
  },
3002
3134
  });
3135
+
3136
+ pi.registerCommand("infinity:pilot", {
3137
+ description: "Set pilot mode — copilot | autopilot | full (takes effect at next phase boundary)",
3138
+ handler: async (args: string, ctx: ExtensionContext) => {
3139
+ const dir = projectDir(ctx);
3140
+ if (!isHarnessProject(dir)) { notify(ctx, NO_HARNESS, "warning"); return; }
3141
+ // Prefer daemon when alive.
3142
+ const raw = String(args ?? "").trim().toLowerCase();
3143
+ if (raw && ["copilot","autopilot","full"].includes(raw)) {
3144
+ try {
3145
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
3146
+ if (d?.port) {
3147
+ const h: Record<string,string> = { "Content-Type": "application/json" };
3148
+ if (d.token) h["Authorization"]=`Bearer ${d.token}`;
3149
+ const r = await fetch(`http://127.0.0.1:${d.port}/pilot`, { method:"POST", headers:h, body: JSON.stringify({ pilot: raw }) });
3150
+ const j = await r.json().catch(()=>({})) as { ok?:boolean; error?:string };
3151
+ if (r.ok && j.ok!==false) { notify(ctx, `infinity-harness: pilot → ${raw} via daemon (next boundary).`, "info"); refreshWidget(ctx); return; }
3152
+ }
3153
+ } catch {}
3154
+ }
3155
+ // Fallback: write config locally. Import core helper for pilot preset.
3156
+ try {
3157
+ const { applyPilotPreset } = await import("../../src/core/config.ts");
3158
+ const { withLock: _wl } = await import("../../src/core/lock.ts");
3159
+ if (!raw) {
3160
+ const { config: c } = loadConfig(dir);
3161
+ const cur = (c as unknown as { pilot?: string }).pilot ?? "autopilot";
3162
+ notify(ctx, `infinity-harness pilot is ${cur}. Usage: /infinity:pilot copilot|autopilot|full`, "info");
3163
+ return;
3164
+ }
3165
+ if (!["copilot","autopilot","full"].includes(raw)) { notify(ctx, `"${raw}" is not a pilot mode — copilot | autopilot | full`, "warning"); return; }
3166
+ const wl = _wl as unknown as (path: string, fn: ()=>unknown)=>Promise<{ ok:boolean; value: unknown }>;
3167
+ await wl(configPath(dir), () => {
3168
+ const l = loadConfig(dir);
3169
+ if (!l.ok) return false;
3170
+ (l.config as unknown as { pilot: string }).pilot = raw;
3171
+ applyPilotPreset(l.config as unknown as Parameters<typeof applyPilotPreset>[0], raw as "copilot"|"autopilot"|"full");
3172
+ return saveConfig(dir, l.config).ok;
3173
+ });
3174
+ notify(ctx, `infinity-harness: pilot → ${raw} (next phase boundary).`, "info");
3175
+ refreshWidget(ctx);
3176
+ } catch (e) { notify(ctx, e instanceof Error ? e.message : String(e), "error"); }
3177
+ },
3178
+ });
3179
+
3180
+ pi.registerCommand("infinity:replan", {
3181
+ description: "Propose a plan amendment (mid-run addFeatures/addTasks) — via daemon when running",
3182
+ handler: async (args: string, ctx: ExtensionContext) => {
3183
+ const dir = projectDir(ctx);
3184
+ if (!isHarnessProject(dir)) { notify(ctx, NO_HARNESS, "warning"); return; }
3185
+ const raw = String(args ?? "").trim();
3186
+ // Try daemon first so replan invariants (cancel-not-delete, maxReplansPerPhase) are honoured under daemon lock.
3187
+ try {
3188
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
3189
+ if (d?.port) {
3190
+ const h: Record<string,string> = { "Content-Type": "application/json" };
3191
+ if (d.token) h["Authorization"]=`Bearer ${d.token}`;
3192
+ let body: Record<string, unknown> = {};
3193
+ if (raw) { try { body = JSON.parse(raw) as Record<string, unknown>; } catch { body = { reason: raw }; } }
3194
+ const r = await fetch(`http://127.0.0.1:${d.port}/replan`, { method:"POST", headers:h, body: JSON.stringify(body) });
3195
+ const j = await r.json().catch(()=>({})) as { ok?:boolean; error?:string };
3196
+ if (r.ok && j.ok!==false) { notify(ctx, `infinity-harness: replan via daemon — ${JSON.stringify(j)}`, "info"); refreshWidget(ctx); return; }
3197
+ }
3198
+ } catch {}
3199
+ notify(ctx, `Replan: ${raw || "use infinity_replan tool with addFeatures/addTasks, or POST JSON to /replan"}`, "info");
3200
+ },
3201
+ });
3202
+
3203
+
3003
3204
  }
3004
3205
 
3005
3206
  function errMsg(e: unknown): string {
@@ -33,7 +33,7 @@ decisions honest across a very long run.
33
33
  └───────────────────────┬───────────────────────┘
34
34
 
35
35
  harness/ (state on disk)
36
- config.json · features/feature-list.json
36
+ config.json · plan.json (legacy: harness/features/feature-list.json) · run.json · daemon.json · supervisor.json
37
37
  ```
38
38
 
39
39
  **The extension is thin on purpose.** An earlier version inlined its own copies of the plan engine
@@ -53,11 +53,14 @@ mistake has already been made twice and fixed twice.
53
53
  | `fsx.ts` | Atomic JSON writes, `.bak` snapshots, absent-vs-corrupt reads. |
54
54
  | `exec.ts` | Every shell-out, bounded by a timeout. Never throws; failures are data. |
55
55
  | `lock.ts` | `withLockSync` (exclusive, fail-closed) and `withLock` (advisory, best-effort). |
56
- | `config.ts` | `harness/config.json` — pipeline state, retry budgets, gate history. |
56
+ | `config.ts` | `harness/config.json` — pipeline state, pilot/limits/tiers/isolation, retry budgets, gate history. |
57
57
  | `phases.ts` | The forward-only state machine and `transitionPhase`. |
58
58
  | `gates.ts` | The deterministic checks and the runner. |
59
- | `featureList.ts` | The plan on disk: load, save, flatten, progress, dependency integrity. |
59
+ | `featureList.ts` | The plan on disk (`harness/plan.json` canonical): load, save, flatten, progress, dependency integrity. |
60
+ | `plan.ts` | Canonical alias for `featureList.ts` — the name the ARCHITECTURE diagram calls `plan`. |
61
+ | `runState.ts` | `harness/run.json` — is a run armed, tiers/budget, wallClock. |
60
62
  | `brief.ts` | "What do I do now?", assembled from state and rendered for a model. |
63
+ | `modelRouter.ts` | Tier routing `A/B/C/D/X` (pure routing; preflight + budget live in `daemon/`). |
61
64
 
62
65
  These were ported to TypeScript from a sibling CLI project that used to be reached through a symlink.
63
66
  The symlink made the package unshippable — it pointed at an absolute path on one developer's
@@ -74,6 +77,9 @@ machine — so the needed logic is now owned, typed, and tested here.
74
77
  mid-build amendment.
75
78
  - **`unstuck.ts` / `review.ts`** — escalation strategy matrix; the REVIEW bounce guard.
76
79
  - **`remote.ts`** — the read-only dashboard server.
80
+ - **`daemon/`** — `daemon.json` liveness, localhost `port 0` token server, worker adapter, preflight + budget guardrails, detached heartbeat, `worktree` isolation.
81
+ - **`supervisor.ts` / `runState.ts` / `handoff.ts` / `loop.ts`** — who is armed, when to hand off, when to stop, and which unit owns which session/model.
82
+ - **`scheduler.ts` / `daemon/worker.ts` / `daemon/isolation.ts`** — ready-set, worktree-per-worker, gate-in-worktree + merge lock, model fallback handling.
77
83
 
78
84
  ### `src/ui/` — the visible surface
79
85
 
@@ -87,7 +93,7 @@ machine — so the needed logic is now owned, typed, and tested here.
87
93
  session_start ──► buildBrief ──► renderBrief ──► sendMessage the agent is told what to do
88
94
 
89
95
 
90
- agent works ─────────┼──► infinity_plan ──► writeTaskList ──► feature-list.json
96
+ agent works ─────────┼──► infinity_plan ──► writeTaskList ──► plan.json (legacy: feature-list.json)
91
97
  │ (locked) │
92
98
  │ ▼
93
99
  agent_settled ──► decideNext ──► runChecks ──► gate verdict widget · dashboard
@@ -99,7 +105,7 @@ agent_settled ──► decideNext ──► runChecks ──► gate verdict
99
105
  ```
100
106
 
101
107
  Nothing caches a second copy of the plan. The widget, the dashboard and the brief all read
102
- `feature-list.json`, so the visible state is always the real state — even when the agent's own
108
+ `plan.json (legacy: feature-list.json)`, so the visible state is always the real state — even when the agent's own
103
109
  narration has drifted.
104
110
 
105
111
  ## Concurrency
@@ -152,8 +158,8 @@ Every stop carries a reason. A human coming back finds an explanation, not a mys
152
158
 
153
159
  ## Verification
154
160
 
155
- - `npm test` — 20 unit files, plain `node:assert`, no framework.
156
- - `npm run e2e` — 15 scenarios over real temp projects, real git repos, real child processes: the
161
+ - `npm test` — 35 unit files, plain `node:assert`, no framework.
162
+ - `npm run e2e` — 17 scenarios over real temp projects, real git repos, real child processes: the
157
163
  full pipeline walkthrough, loop convergence, every stop condition, SIGKILL-and-restart, a 6-way
158
164
  concurrent write fan-out with an unlocked control, data round-trip, the dashboard, widget
159
165
  rendering across shapes, adversarial input, and the extension adapter itself.
@@ -2,18 +2,26 @@
2
2
 
3
3
  ## Technical
4
4
 
5
- - **Language:** node
6
- - **Platform:** <!-- target platform -->
7
- - **Dependencies:** <!-- key dependency constraints -->
5
+ - **Language:** node ≥22 (ESM, `--experimental-strip-types`, `string-width` + `proper-lockfile` only)
6
+ - **Platform:** pi extension (adapter) + Node (core) + `pi --mode rpc` workers
7
+ - **Dependencies:** `proper-lockfile`, `string-width`; no new runtime dep without a reason
8
+ - **Isolation:** `execution.isolation` in `worktree` (default) or `none`; `maxWorkers` 1–16, clamped when isolation is `none`
9
+ - **Tiers:** `config.tiers` `A/B/C/D/X` with `run.json:baseModel` fallback; legacy `harness/model-router.json` migrated once
10
+ - **Limits:** `limits.unitWallClockMs`, `maxRecycles`, `maxReworkPerUnit`, `maxReplansPerPhase`, `tokenCap`, `costCap`
8
11
 
9
12
  ## Process
10
13
 
11
14
  - Commits must be atomic (one concern per commit)
12
15
  - All code reviewed before merging
13
- - Tests must pass before shipping
16
+ - Tests must pass before shipping (`npm run check && npm test`)
17
+ - Gates are deterministic; advisory checks never deadlock the loop
18
+ - One implementation in `src/`; the extension is thin
19
+ - State is externalised (`harness/` on disk, `run.json`/`daemon.json` survive session handoff) — no closures holding run state
14
20
 
15
21
  ## Design
16
22
 
17
23
  - Favor simplicity over generality
18
24
  - Explicit over implicit
19
- - Fail fast, fail loud
25
+ - Fail fast, fail loud where a caller cannot continue; `{ ok, error }` where it can
26
+ - Bounded stop: wall clock, iteration ceiling, no-progress, retry budgets, human brake — every stop names its reason
27
+ - Read-only, loopback-only surfaces (`remote`/`dashboard`)
@@ -105,3 +105,47 @@ verdict instead.
105
105
  expensive side effect. And a page rendering model output on a public interface leaks the project;
106
106
  binding elsewhere requires an explicit opt-in, and the CSP is tight enough that an escaping slip
107
107
  cannot become script execution.
108
+
109
+ ---
110
+
111
+ ## 9. The canonical plan is `harness/plan.json`
112
+
113
+ **Context.** The plan lived as `harness/features/feature-list.json`, a nested path that leaked storage layout into every reader. V3 renamed the canonical to `harness/plan.json` and kept the legacy path as a read-through + write-through alias with a `movedTo` stub. Reads try canonical first, then legacy; writes materialise both so `loadFeatureList` callers and `plan.json` callers see the same truth. One implementation in `src/core/featureList.ts`, one alias in `src/core/plan.ts`.
114
+
115
+ **Cost.** Dual-write until all callers migrate; `.bak` handling for both paths.
116
+
117
+ ---
118
+
119
+ ## 10. Config tiers + limits are validated and clamped with a warning, not an exception
120
+
121
+ **Decision.** `loadConfig` validates `pilot`, `limits`, `tiers`, and `execution.isolation`. Unknown pilot falls back to `autopilot`; `parallelAt` finer than `handoff` is clamped and logged; `isolation:none` forces `maxWorkers` to 1. `src/core/config.normalizeTiers` migrates `byDifficulty`/`master` from `harness/model-router.json` once, per tier.
122
+
123
+ **Why.** A hand-edited `config.json` must produce a widget, never an exception that takes the session down. Logging the clamp tells the human what was changed without breaking the run.
124
+
125
+ ---
126
+
127
+ ## 11. The Daemon owns the run; the session becomes a control panel
128
+
129
+ **Context.** Before 2.7 the run lived in the human's session and its model. The loop pushed the brief back into that session, so the session's model did every task, its context carried the whole run, and handoff replaced the human's terminal.
130
+
131
+ **Decision.** A detached Daemon (`daemon/index.ts` via `spawn(detached:true)` + `unref()`) owns the run: heartbeat every 20s (stale 90s), localhost server on `127.0.0.1:0` with a 0600 token (`daemon.json`), `supervisor.json` + `activity.json` workers log. The extension captures `ctx.model` to `run.json.baseModel` at arm time; workers run as `pi --mode rpc` children with `--model` from their tier. `src/supervisor.ts` drives them — plain JS, zero tokens in the human session.
132
+
133
+ **Cost.** One more process; `harness/daemon.log` for diagnostics; `guardSingleOwner` to prevent rival Daemons.
134
+
135
+ ---
136
+
137
+ ## 12. Handoff and model boundary are the same boundary
138
+
139
+ **Decision.** `session.handoff` names the unit (goal/phase/sprint/feature/task/subtask). One worker owns one unit from start to finish. Crossing a unit boundary closes that worker and starts a new one; because the model is chosen at spawn, the session boundary and the model boundary are the same boundary by construction. A feature-level handoff is one session for the whole feature, and its tasks share that feature's hardest tier.
140
+
141
+ ---
142
+
143
+ ## 13. Per-phase planning invariants
144
+
145
+ **Decision.** Every phase owns its tasks (`Task.phase` required on write, handoff collapse, progressive expansion via an `A` worker). `decideNext` is phase-scoped; `isPhaseDone` includes the rework queue; `rework` flips `complete` to `pending` with a record (forward-only); `replan` cancels (adds a `replan.json` record) rather than deleting, capped at 3 per phase. Gates hold until rework is drained; rework is capped at 2 and `maxBounces` at 2 with `bounceRequiresDelta`.
146
+
147
+ ---
148
+
149
+ ## 14. Parallel steel: ready-set, worktree, gate, merge
150
+
151
+ **Decision.** `scheduler.ts` `ready` set respects `phase`/`dependsOn`/`parallelAt`/`maxWorkers`; `daemon/worktree.ts` creates a git worktree per concurrent worker, gates in the worktree, then merges under a merge lock with `post-merge gate` verification. Merge conflicts rework; worktree per worker is `worktree`/`none` isolation. After the worktree path was proven, `maxWorkers` was raised 1 → 3 with an `e2e --only realpi` proof.
@@ -1,13 +1,49 @@
1
1
  # Domain Glossary
2
2
 
3
- <!-- The project's ubiquitous language. One concept, one name — everywhere:
4
- spec, code, tests, docs. Add terms the moment they're resolved (see
5
- harness/skills/domain-modeling.md). Glossary ONLY — no implementation
6
- details, no scratch notes. -->
3
+ The project's ubiquitous language. One concept, one name — everywhere: spec, code, tests, docs.
7
4
 
8
5
  ## Terms
9
6
 
10
- ### ExampleTerm
11
- <!-- Definition. What it IS, what it is NOT, and which nearby concept it
12
- must not be confused with. Delete this example when adding the first
13
- real term. -->
7
+ ### Plan
8
+
9
+ The single source of truth on disk for what will be built. Canonical: `harness/plan.json` (legacy `harness/features/feature-list.json` still read/written). Structure: Goals → Sprints → Features → Tasks → Subtasks. Every task has `key`/`id`, `status`, `dependsOn`, `phase`; every feature has `criteria`. Grows via progressive expansion (one `A` worker seeds the next phase). Do not confuse with "plan file alias" — there is one plan.
10
+
11
+ ### Phase
12
+
13
+ The gated pipeline: `research → define → plan → build → verify → simplify → review → ship`. Enabled by `config.phases.enabled`; progress and `decideNext` are phase-scoped; `isPhaseDone` includes the rework queue. Phases advance forward-only; backward movement is only via bounded rework/replan.
14
+
15
+ ### Gate
16
+
17
+ Deterministic checks per phase (`src/core/gates.ts`). The only referee for phase advance. Advisory when unconfigured (never blocks). Decides pass/fail; the phase machine decides what happens next.
18
+
19
+ ### BaseRevision
20
+
21
+ Optimistic-concurrency counter on the plan. Every mutating write of the plan via `taskList.writeTaskList` bumps it; a write presenting a stale revision is rejected. Held under `withLockSync` (`.ilock`, not `.lock`) so two parallel workers reading `N` do not both write `N+1` losing edits.
22
+
23
+ ### Worker / Run / Unit
24
+
25
+ A *worker* is one background `pi --mode rpc` child process for one *unit* — goal, phase, sprint, feature, task or subtask as named by `session.handoff`. The *run* is the whole armed execution (`harness/run.json`). Workers write attempt history under `tmp/infinity-harness/<runId>/<feature>/<task>/attempt-N/` and are recorded with fingerprint (`baseRevision` + `featureListHash`). Parallel workers when `isolation=worktree`, each in its own git worktree.
26
+
27
+ ### Daemon / Supervisor / Control Room
28
+
29
+ *Daemon* — detached `harness/daemon.json` owner (heartbeat 20s, stale 90s) + localhost server (port 0, token 0600). *Supervisor* — `harness/supervisor.json` + `activity.json` live worker + background log (the surfaces read). *Control Room* — the extension's UI is a thin viewer when the Daemon is live: it never renders a dead run as live, forwards throttle/approve/rework/replan to the Daemon, and respects the control-panel contract + `X` tripwire.
30
+
31
+ ### Tier / Pilot / Mode
32
+
33
+ *Tiers* `A/B/C/D/X` in `config.tiers` — pure routing (difficulty → tier → `provider/id`); `X` is MASTER and is never directly assigned, only via one-step consultation ladder (`easy→moderate→difficult→MASTER`) when a fixup is needed. *Pilot* `copilot|autopilot|full` and *Mode* per-phase (`phaseModes`) decide whether a passing gate stops for a human signature.
34
+
35
+ ### Limits / Budget / Recycle
36
+
37
+ *Limits* — `unitWallClockMs`, `maxRecycles`, `maxReworkPerUnit`, `maxReplansPerPhase`, caps. Guarded by `preflight` (tiers must serve one token) and `budget` (per-tier tokens/cost, `X` leak tripwire). *Recycle* — compaction recycles the worker (capped 2); `CredentialSynchronizationError` retries are not charged.
38
+
39
+ ### Rework / Replan / Bounce / Unstuck
40
+
41
+ *Rework* — backward edge: BFS over `dependsOn` limited by `maxImpactDepth`, flips origin+impacted to `rework`/`pending` and records `harness/rework.json` return-to-origin. *Replan* — additive amendment (`harness/replan.json`) cancels (not deletes) with DAG validation, capped at 3 per phase. *Bounce* — review-phase `reviewBounce` flips to rework only when `fileDelta` + `bounceRequiresDelta`. *Unstuck* — orchestrator that tries strategies in `config.unstuck.strategies` order with fingerprint dedup, budgets, hysteresis and one-step-only master guard.
42
+
43
+ ### Handoff / Escalate
44
+
45
+ *Handoff* — `session.handoff` granularity; when to start a fresh pi session (same unit keeps the session and the model). *Escalate* — the ladder the loop climbs on stalled failures; a model switch is a new worker session — session boundary = model boundary by construction.
46
+
47
+ ### Brief / Widget / Dashboard
48
+
49
+ *Brief* — `src/core/brief.ts` “what do I do right now?” injected at session start and on phase change. *Widget* — terminal plan view; *Dashboard* — web view of the same state. Both include remote `router` + `rework` exposure (read-only, advisory).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [