infinity-harness 2.8.1 → 2.8.2

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,28 @@ 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.2] — 2026-08-30
8
+
9
+ Republishes 2.8.1. The 2.8.1 tarball on disk and on the registry was built from 2.8.0 content while `package.json` already said 2.8.1, so `/infinity:init` still auto-started RESEARCH in your session and the wizard never asked `Start the run now?`. No code changes since `v2.8.1` (`81d16590`).
10
+
11
+ ### Fixed
12
+
13
+ - **Stale 2.8.1 artifact.** Rebuilt from `HEAD` so the `launchNow` wizard step, the parked-by-default `NOT running — /infinity:run` opener, and the `controlPanelContract` are in the published package.
14
+
15
+ ## [2.8.1] — 2026-08-30
16
+
17
+ Init no longer steals your session. The harness only runs when you say so.
18
+
19
+ ### Fixed
20
+
21
+ - **"/infinity:init" auto-started research in your session.** After the wizard, the extension sent the RESEARCH brief as followUp, so FullAuto began doing RESEARCH in the same session that just created the harness — before you could decide to walk away. The harness is now parked after init. The opener says "harness is NOT running — /infinity:run starts it" and the agent is idle in control-panel mode, regardless of workflow or phase.
22
+
23
+ - **No way to go straight from init to running.** The wizard now asks a final question "Start the run now?" ("yes — start the run now" / "no — I will run /infinity:run when ready"). Saying yes arms harness/run.json, captures baseModel for the daemon, and spawns the detached daemon (or the in-process supervisor fallback) — the same path as "/infinity:run". Saying no (or an unattended init or old scripted harness with no answer) stays parked.
24
+
25
+ ### Changed
26
+
27
+ - Opener after init always names the next command. With a goal it shows the RESEARCH or DEFINE brief plus "NOT running — /infinity:run"; without a goal it asks for the goal first, then the same tagline.
28
+
7
29
  ## [2.8.0] — 2026-08-30
8
30
 
9
31
  The harness stops shipping its own run. The repo is the driver, not the project.
@@ -1826,15 +1826,73 @@ export default function (pi: ExtensionAPI): void {
1826
1826
  notify(ctx, lines.join("\n"), plan.warnings.length ? "warning" : "info");
1827
1827
  refreshWidget(ctx);
1828
1828
 
1829
- // Hand the model the brief straight away, so the session that created
1830
- // the harness is also the session that starts using it. Without a goal
1831
- // the first thing it must do is ask for one — never guess one.
1832
- const brief = await briefText(dir);
1833
- const opener = plan.brief
1834
- ? brief
1835
- : `The human has not said what they want built yet. Ask them, in one short question, ` +
1836
- `and do not start any work or invent a scope until they answer.\n\n${brief}`;
1837
- pi.sendUserMessage(opener, { deliverAs: "followUp" });
1829
+ // Show the next step, but do NOT auto-start the run.
1830
+ // After /infinity:init the harness is initialized and parked.
1831
+ // The agent can answer questions in this session, but the pipeline
1832
+ // only runs after the human says so: /infinity:run (or "yes" to the
1833
+ // last wizard question). Auto-starting here is the bug that made
1834
+ // full-auto research steal the session that just created the harness.
1835
+ if ((wizard as { launchNow?: boolean }).launchNow) {
1836
+ // Opt-in: wizard asked "Start the run now?" and the human said yes.
1837
+ armRun(dir, sessionId);
1838
+ // Capture baseModel for daemon (same as /infinity:run does)
1839
+ try {
1840
+ const bm = baseModelOf(ctx);
1841
+ if (bm) {
1842
+ const { loadRunState: _ld, saveRunState: _sv, runIdFor: _rf } = await import("../../src/core/runState.ts");
1843
+ const rid = _rf(dir, sessionId);
1844
+ const rs = _ld(dir);
1845
+ const parts = bm.split("/");
1846
+ const baseModel = parts.length >= 2 ? { provider: parts[0]!, id: parts.slice(1).join("/") } : { provider: "anthropic", id: bm };
1847
+ if (rs) { (rs as unknown as { baseModel: unknown }).baseModel = baseModel; _sv(dir, rs); }
1848
+ else { const { newRunState } = await import("../../src/core/runState.ts"); const ns = newRunState(rid); (ns as unknown as { baseModel: unknown }).baseModel = baseModel; _sv(dir, ns); }
1849
+ }
1850
+ } catch {}
1851
+ // Try detached daemon, fall back to supervisor in this pi session.
1852
+ const tryDaemonSpawn = async (): Promise<{ spawned: boolean }> => {
1853
+ try {
1854
+ const { spawn: _sp } = await import("node:child_process");
1855
+ const { existsSync: _ex, openSync: _op, closeSync: _cl } = await import("node:fs");
1856
+ const { resolve: _re } = await import("node:path");
1857
+ const cands = [_re(dir, "dist/daemon/index.js"), _re(dir, "src/daemon/index.ts")];
1858
+ let entry: string | null = null;
1859
+ for (const c of cands) if (_ex(c)) { entry = c; break; }
1860
+ if (!entry) return { spawned: false };
1861
+ try { const { loadDaemon: _ld, isDaemonAlive: _al } = await import("../../src/daemon/guard.ts"); const live = _ld(dir); if (live && _al(live)) return { spawned: true }; } catch {}
1862
+ const fd = _op(_re(dir, "harness/daemon.log"), "a");
1863
+ const a: string[] = [];
1864
+ if (entry.endsWith(".ts")) a.push("--experimental-strip-types", "--no-warnings=ExperimentalWarning");
1865
+ a.push(entry, dir);
1866
+ const ch = _sp(process.execPath, a, { detached: true, stdio: ["ignore", fd as unknown as number, fd as unknown as number], windowsHide: true, env: { ...process.env, INFINITY_HARNESS_WORKER: "1" } } as unknown as Parameters<typeof _sp>[2]);
1867
+ try { ch.unref(); } catch {}
1868
+ try { _cl(fd); } catch {}
1869
+ for (let i = 0; i < 40; i++) { await new Promise(r=>setTimeout(r,250)); try { const { loadDaemon: l2, isDaemonAlive: a2 } = await import("../../src/daemon/guard.ts"); const v = l2(dir); if (v && a2(v)) return { spawned: true }; } catch {}
1870
+ }
1871
+ return { spawned: true };
1872
+ } catch { return { spawned: false }; }
1873
+ };
1874
+ const dr = await tryDaemonSpawn();
1875
+ if (!dr.spawned) await startEngine(ctx, dir);
1876
+ notify(ctx, "infinity-harness: run armed — background run started. /infinity:halt stops it.", "info");
1877
+ } else {
1878
+ const brief = await briefText(dir);
1879
+ if (!plan.brief) {
1880
+ pi.sendUserMessage(
1881
+ `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`,
1882
+ { deliverAs: "followUp" },
1883
+ );
1884
+ } else if (plan.phases[0] === "research") {
1885
+ pi.sendUserMessage(
1886
+ `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes — start the run now" next time you init.`,
1887
+ { deliverAs: "followUp" },
1888
+ );
1889
+ } else {
1890
+ pi.sendUserMessage(
1891
+ `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`,
1892
+ { deliverAs: "followUp" },
1893
+ );
1894
+ }
1895
+ }
1838
1896
  },
1839
1897
  });
1840
1898
 
@@ -1,110 +1,110 @@
1
- # Decisions
2
-
3
- Architectural decisions and the reasoning behind them. Outcomes without reasons are useless to the
4
- next person — record why, and what it cost.
5
-
6
- ---
7
-
8
- ## 1. The gate is the only referee
9
-
10
- **Context.** An agent asked whether its work is finished will say yes. Repeatedly, and wrongly, over
11
- a long run.
12
-
13
- **Decision.** Completion is decided by deterministic checks that are a pure function of the project
14
- on disk. The agent cannot mark its own work complete, and the extension blocks hand-edits to
15
- `currentPhase` while the gate is failing.
16
-
17
- **Cost.** Gates must be cheap enough to run every iteration, and must never fail for a reason the
18
- agent cannot fix — a gate that deadlocks the loop is worse than no gate. Hence advisory checks:
19
- an unconfigured lint command is reported, not enforced.
20
-
21
- ---
22
-
23
- ## 2. Phases move forward, one step at a time
24
-
25
- **Decision.** `isValidTransition` permits only the next enabled phase, or re-running the current one.
26
- Backward movement exists only as an explicit, budgeted rework that records why it happened.
27
-
28
- **Why.** It removes a decision the model is bad at. Without it, a struggling agent reorders the
29
- pipeline to reach a phase whose gate it can pass.
30
-
31
- ---
32
-
33
- ## 3. The plan is submitted whole; omission means deletion
34
-
35
- **Decision.** `infinity_plan` takes the complete task list, not a patch.
36
-
37
- **Why.** Incremental edits require the model to track what exists. Over hours it stops being able to:
38
- it re-adds deleted tasks, forgets others, and the file diverges from reality. A full submission is
39
- self-correcting, and one unambiguous rule beats a set of merge semantics nobody can predict.
40
-
41
- **Cost.** Every write carries the whole plan. Capped at 200 tasks, which is far beyond a sensible
42
- sprint.
43
-
44
- ---
45
-
46
- ## 4. `baseRevision` is not a compare-and-swap
47
-
48
- **Context.** The original write did read → check `baseRevision` → write, with no mutual exclusion,
49
- and the docs claimed this protected parallel workers. It does not. Two processes that both read
50
- revision N both pass the check and both write N+1. Measured: 2 lost updates in a 6-way fan-out.
51
-
52
- **Decision.** `writeTaskList` holds an exclusive lock across the whole read-apply-write and fails
53
- closed — a write that cannot take the lock is refused, not raced.
54
-
55
- **Cost.** Plan writes serialise. The critical section is milliseconds, so this is not felt; a
56
- `LockTimeoutError` is retryable and names the stuck lock.
57
-
58
- **Also.** The sync lock uses `<path>.ilock`. `proper-lockfile` owns `<path>.lock` and it is a
59
- directory there too, so sharing the name made a nested async+sync lock deadlock against itself.
60
-
61
- ---
62
-
63
- ## 5. One implementation, in `src/`
64
-
65
- **Context.** The extension carried inlined copies of the plan engine and the widget. The tests
66
- exercised `src/`; the shipped code path never called it. The two drifted, and the drift was
67
- invisible because the suite was green. The same pattern later reappeared in `rework.ts` and
68
- `replan.ts`, which kept private plan loaders — and one of them mishandled status aliases, rejecting
69
- every amendment to a plan that used `"done"`.
70
-
71
- **Decision.** `src/` is the single implementation. The extension owns pi's lifecycle and nothing
72
- else. A private `loadFeatureList` is a bug, not a shortcut.
73
-
74
- ---
75
-
76
- ## 6. Knowing when to stop is the product
77
-
78
- **Decision.** The loop halts on no-progress, wall clock, iteration count, retry budgets, or a human
79
- brake — and every stop names its reason.
80
-
81
- **Why.** Continuing is trivial. The default failure mode of an autonomous loop with a weak model is
82
- re-running a failing gate against an unchanged tree until the budget is gone. The no-progress
83
- detector compares a fingerprint of the working tree and the plan; the first failing iteration is a
84
- baseline and never counts as a stall.
85
-
86
- ---
87
-
88
- ## 7. Ship vendor-neutral defaults
89
-
90
- **Context.** The router shipped enabled, with one third-party vendor's model ids hardcoded in every
91
- slot.
92
-
93
- **Decision.** Routing is disabled by default and every slot is empty, meaning "use whatever model pi
94
- is already configured with". Installing an extension must never silently redirect someone's work to
95
- a model they did not choose.
96
-
97
- ---
98
-
99
- ## 8. The dashboard cannot perturb the run
100
-
101
- **Decision.** Read-only, loopback-only, and it does not run the gate — it reports the last recorded
102
- verdict instead.
103
-
104
- **Why.** Running lint and the test suite because someone opened a web page is a surprising and
105
- expensive side effect. And a page rendering model output on a public interface leaks the project;
106
- binding elsewhere requires an explicit opt-in, and the CSP is tight enough that an escaping slip
107
- cannot become script execution.
1
+ # Decisions
2
+
3
+ Architectural decisions and the reasoning behind them. Outcomes without reasons are useless to the
4
+ next person — record why, and what it cost.
5
+
6
+ ---
7
+
8
+ ## 1. The gate is the only referee
9
+
10
+ **Context.** An agent asked whether its work is finished will say yes. Repeatedly, and wrongly, over
11
+ a long run.
12
+
13
+ **Decision.** Completion is decided by deterministic checks that are a pure function of the project
14
+ on disk. The agent cannot mark its own work complete, and the extension blocks hand-edits to
15
+ `currentPhase` while the gate is failing.
16
+
17
+ **Cost.** Gates must be cheap enough to run every iteration, and must never fail for a reason the
18
+ agent cannot fix — a gate that deadlocks the loop is worse than no gate. Hence advisory checks:
19
+ an unconfigured lint command is reported, not enforced.
20
+
21
+ ---
22
+
23
+ ## 2. Phases move forward, one step at a time
24
+
25
+ **Decision.** `isValidTransition` permits only the next enabled phase, or re-running the current one.
26
+ Backward movement exists only as an explicit, budgeted rework that records why it happened.
27
+
28
+ **Why.** It removes a decision the model is bad at. Without it, a struggling agent reorders the
29
+ pipeline to reach a phase whose gate it can pass.
30
+
31
+ ---
32
+
33
+ ## 3. The plan is submitted whole; omission means deletion
34
+
35
+ **Decision.** `infinity_plan` takes the complete task list, not a patch.
36
+
37
+ **Why.** Incremental edits require the model to track what exists. Over hours it stops being able to:
38
+ it re-adds deleted tasks, forgets others, and the file diverges from reality. A full submission is
39
+ self-correcting, and one unambiguous rule beats a set of merge semantics nobody can predict.
40
+
41
+ **Cost.** Every write carries the whole plan. Capped at 200 tasks, which is far beyond a sensible
42
+ sprint.
43
+
44
+ ---
45
+
46
+ ## 4. `baseRevision` is not a compare-and-swap
47
+
48
+ **Context.** The original write did read → check `baseRevision` → write, with no mutual exclusion,
49
+ and the docs claimed this protected parallel workers. It does not. Two processes that both read
50
+ revision N both pass the check and both write N+1. Measured: 2 lost updates in a 6-way fan-out.
51
+
52
+ **Decision.** `writeTaskList` holds an exclusive lock across the whole read-apply-write and fails
53
+ closed — a write that cannot take the lock is refused, not raced.
54
+
55
+ **Cost.** Plan writes serialise. The critical section is milliseconds, so this is not felt; a
56
+ `LockTimeoutError` is retryable and names the stuck lock.
57
+
58
+ **Also.** The sync lock uses `<path>.ilock`. `proper-lockfile` owns `<path>.lock` and it is a
59
+ directory there too, so sharing the name made a nested async+sync lock deadlock against itself.
60
+
61
+ ---
62
+
63
+ ## 5. One implementation, in `src/`
64
+
65
+ **Context.** The extension carried inlined copies of the plan engine and the widget. The tests
66
+ exercised `src/`; the shipped code path never called it. The two drifted, and the drift was
67
+ invisible because the suite was green. The same pattern later reappeared in `rework.ts` and
68
+ `replan.ts`, which kept private plan loaders — and one of them mishandled status aliases, rejecting
69
+ every amendment to a plan that used `"done"`.
70
+
71
+ **Decision.** `src/` is the single implementation. The extension owns pi's lifecycle and nothing
72
+ else. A private `loadFeatureList` is a bug, not a shortcut.
73
+
74
+ ---
75
+
76
+ ## 6. Knowing when to stop is the product
77
+
78
+ **Decision.** The loop halts on no-progress, wall clock, iteration count, retry budgets, or a human
79
+ brake — and every stop names its reason.
80
+
81
+ **Why.** Continuing is trivial. The default failure mode of an autonomous loop with a weak model is
82
+ re-running a failing gate against an unchanged tree until the budget is gone. The no-progress
83
+ detector compares a fingerprint of the working tree and the plan; the first failing iteration is a
84
+ baseline and never counts as a stall.
85
+
86
+ ---
87
+
88
+ ## 7. Ship vendor-neutral defaults
89
+
90
+ **Context.** The router shipped enabled, with one third-party vendor's model ids hardcoded in every
91
+ slot.
92
+
93
+ **Decision.** Routing is disabled by default and every slot is empty, meaning "use whatever model pi
94
+ is already configured with". Installing an extension must never silently redirect someone's work to
95
+ a model they did not choose.
96
+
97
+ ---
98
+
99
+ ## 8. The dashboard cannot perturb the run
100
+
101
+ **Decision.** Read-only, loopback-only, and it does not run the gate — it reports the last recorded
102
+ verdict instead.
103
+
104
+ **Why.** Running lint and the test suite because someone opened a web page is a surprising and
105
+ expensive side effect. And a page rendering model output on a public interface leaks the project;
106
+ binding elsewhere requires an explicit opt-in, and the CSP is tight enough that an escaping slip
107
+ cannot become script execution.
108
108
 
109
109
  ---
110
110
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.8.1",
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.",
3
+ "version": "2.8.2",
4
+ "description": "A pi agent extension that runs a gated build pipeline unattended enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
package/src/ui/wizard.ts CHANGED
@@ -66,7 +66,7 @@ export type WizardOptions = {
66
66
 
67
67
  export type WizardResult =
68
68
  | { cancelled: true }
69
- | { cancelled: false; plan: IntakePlan; answers: IntakeAnswers };
69
+ | { cancelled: false; plan: IntakePlan; answers: IntakeAnswers; launchNow?: boolean };
70
70
 
71
71
  const CONFIRM = "start with these settings";
72
72
  const RESTART = "change something";
@@ -267,7 +267,7 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
267
267
  const answers: IntakeAnswers = { workflow, researchDepth, brief, handoff, display, router: modelsAnswer.router, parallelAt, maxWorkers };
268
268
  const plan = planIntake(answers);
269
269
 
270
- if (options.skipConfirm) return { cancelled: false, plan, answers };
270
+ if (options.skipConfirm) return { cancelled: false, plan, answers, launchNow: false };
271
271
 
272
272
  const body = [plan.summary, ...(plan.warnings.length ? ["", ...plan.warnings.map((w) => `! ${w}`)] : [])].join(
273
273
  "\n",
@@ -277,7 +277,16 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
277
277
  const confirm = await prompt.select("Ready?", [CONFIRM, RESTART, CANCEL]);
278
278
  if (confirm === undefined || confirm === CANCEL) return { cancelled: true };
279
279
  if (confirm === RESTART) continue;
280
- return { cancelled: false, plan, answers };
280
+ // Last step: ask whether to arm and start immediately (opt-in).
281
+ // After init the harness is never auto-started — only /infinity:run (or
282
+ // "yes" here) arms it. Older tests/E2E scripts that do not answer this
283
+ // are treated as "later" so they keep passing.
284
+ const LAUNCH_NOW = "yes — start the run now";
285
+ const LAUNCH_LATER = "no — I'll run /infinity:run when ready";
286
+ const launchPick = await prompt.select("Start the run now?", [LAUNCH_NOW, LAUNCH_LATER]);
287
+ let launchNow = false;
288
+ if (launchPick !== undefined) launchNow = launchPick === LAUNCH_NOW;
289
+ return { cancelled: false, plan, answers, launchNow };
281
290
  }
282
291
  }
283
292