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 +14 -0
- package/extensions/infinity-harness/index.ts +212 -11
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +130 -3
- package/src/core/types.ts +33 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +1 -2
- package/src/goalState.ts +2 -22
- package/src/intake.ts +1 -1
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +3 -2
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +107 -134
- package/src/taskList.ts +41 -3
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +55 -0
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
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)
|
|
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
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
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` —
|
|
156
|
-
- `npm run e2e` —
|
|
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:**
|
|
7
|
-
- **Dependencies:**
|
|
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.
|
package/harness/docs/DOMAIN.md
CHANGED
|
@@ -1,13 +1,49 @@
|
|
|
1
1
|
# Domain Glossary
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
###
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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": [
|