@yagni-app/code-staging 0.1.0-staging.1015.1 → 0.1.0-staging.1019.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +14 -9
  2. package/dist/cli.js +1 -0
  3. package/dist/doctor.d.ts +21 -0
  4. package/dist/doctor.js +52 -0
  5. package/dist/extension/askAdvisorTool.js +7 -1
  6. package/dist/extension/bless.js +16 -3
  7. package/dist/extension/boostCommand.d.ts +144 -0
  8. package/dist/extension/boostCommand.js +263 -0
  9. package/dist/extension/branding.d.ts +31 -0
  10. package/dist/extension/branding.js +37 -0
  11. package/dist/extension/chipEditor.js +7 -3
  12. package/dist/extension/config.d.ts +61 -0
  13. package/dist/extension/config.js +86 -0
  14. package/dist/extension/costHud.d.ts +128 -15
  15. package/dist/extension/costHud.js +189 -19
  16. package/dist/extension/index.d.ts +32 -2
  17. package/dist/extension/index.js +177 -17
  18. package/dist/extension/pipeline/eval.d.ts +42 -5
  19. package/dist/extension/pipeline/eval.js +44 -0
  20. package/dist/extension/pipeline/goCommand.d.ts +12 -0
  21. package/dist/extension/pipeline/goCommand.js +124 -25
  22. package/dist/extension/pipeline/goCompareCommand.d.ts +18 -8
  23. package/dist/extension/pipeline/goCompareCommand.js +42 -23
  24. package/dist/extension/pipeline/orchestrator.js +9 -0
  25. package/dist/extension/pipeline/runCostTable.d.ts +37 -0
  26. package/dist/extension/pipeline/runCostTable.js +165 -0
  27. package/dist/extension/pipeline/runState.d.ts +19 -0
  28. package/dist/extension/pipeline/runState.js +11 -0
  29. package/dist/extension/pipeline/runner.d.ts +19 -0
  30. package/dist/extension/pipeline/runner.js +13 -1
  31. package/dist/extension/pipeline/stages.d.ts +3 -1
  32. package/dist/extension/pipeline/stages.js +3 -1
  33. package/dist/extension/pipeline/types.d.ts +7 -4
  34. package/dist/extension/pipeline/verify.js +6 -1
  35. package/dist/extension/pipeline/worktree.js +3 -1
  36. package/dist/extension/provider.d.ts +7 -1
  37. package/dist/extension/provider.js +8 -1
  38. package/dist/extension/recall.js +5 -2
  39. package/dist/extension/rerouteNotice.d.ts +42 -0
  40. package/dist/extension/rerouteNotice.js +67 -0
  41. package/dist/extension/sessionRuns.d.ts +45 -0
  42. package/dist/extension/sessionRuns.js +77 -0
  43. package/dist/extension/subagents.js +25 -0
  44. package/dist/launch.d.ts +5 -3
  45. package/dist/launch.js +18 -9
  46. package/package.json +2 -2
package/README.md CHANGED
@@ -88,12 +88,14 @@ yagni -p "explain the deploy pipeline; ask_yagni if unsure" # one-shot / print
88
88
  yagni logout # revoke the token and clear it locally
89
89
  ```
90
90
 
91
- The agent runs on three opaque model tiers routed by YAGNI: `advanced` (the strongest,
92
- for judgment), `standard` (for execution) and `efficient` (cheapest).
93
- Interactive sessions default to `advanced`; pass `--model standard` or `--model
94
- efficient` to switch a run. The `/go` pipeline routes its own steps automatically —
95
- judgment steps (plan, review) on `advanced`, execution steps (map, implement, fix) on
96
- `standard`.
91
+ The agent runs on model tiers routed by YAGNI: `advanced`, `standard`, and
92
+ `efficient` (cheapest) are the three selectable via `--model`; `peak`, the
93
+ strongest tier, is reserved for the `/go` pipeline's own judgment steps and is
94
+ not one of the three.
95
+ Interactive sessions default to `balanced`, which drives on `advanced`; pass
96
+ `--model standard` or `--model efficient` to switch a run. The `/go` pipeline
97
+ routes its own steps automatically — judgment steps (plan, review) on `peak`,
98
+ execution steps (map, implement, fix) on `standard`.
97
99
 
98
100
  `login` opens a device-code flow: it prints a short code and a URL. Open the URL
99
101
  in a browser where you are signed in to YAGNI, enter the code, and approve. The
@@ -139,9 +141,12 @@ yagni use prod # switch back (sticky); prod is the def
139
141
 
140
142
  ## Configuration
141
143
 
142
- | Variable | Default | Purpose |
143
- | ---------------- | ------------------------ | -------------------------------------------------- |
144
- | `YAGNI_BASE_URL` | active environment's URL | Override the base URL for a **single run** (escape hatch). Prefer `yagni use` for anything sticky. |
144
+ | Variable | Default | Purpose |
145
+ | ----------------------------- | ------------------------ | -------------------------------------------------- |
146
+ | `YAGNI_BASE_URL` | active environment's URL | Override the base URL for a **single run** (escape hatch). Prefer `yagni use` for anything sticky. |
147
+ | `YAGNI_DISABLE_BRANDING` | unset | `1` skips the YAGNI Code system-prompt rewrite entirely, so the engine's assembled prompt passes through byte-exact (no identity swap, no company-brief injection). |
148
+ | `YAGNI_DISABLE_UPDATE_CHECK` | unset | `1` silences the new-version notice and the background update check. |
149
+ | `YAGNI_DISABLE_CLAUDE_COMPAT` | unset | `1` turns off the zero-config `.claude` assets bridge (skills and commands). |
145
150
 
146
151
  Credentials live in `~/.yagni-code/profiles/<name>.json` (mode `0600`); the active
147
152
  environment is recorded in `~/.yagni-code/config.json`. A pre-profiles
package/dist/cli.js CHANGED
@@ -176,6 +176,7 @@ export const HELP_TEXT = [
176
176
  "Set YAGNI_BASE_URL to override the base URL for a single run.",
177
177
  "Set YAGNI_DISABLE_UPDATE_CHECK=1 to silence the new-version notice.",
178
178
  "Set YAGNI_DISABLE_CLAUDE_COMPAT=1 to skip loading .claude assets.",
179
+ "Set YAGNI_DISABLE_BRANDING=1 to pass the system prompt through unmodified.",
179
180
  "Set YAGNI_DISABLE_CRASH_REPORTS=1 to turn off sanitized crash reports.",
180
181
  ].join("\n");
181
182
  /** Parse `use <name> [--base-url <url>]` argv into its parts. */
package/dist/doctor.d.ts CHANGED
@@ -60,6 +60,13 @@ export declare function checkCliUpdate(probe: {
60
60
  latest: string | null;
61
61
  }): CheckResult;
62
62
  export declare function checkGh(onPath: boolean): CheckResult;
63
+ /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
64
+ export interface BashProbe {
65
+ found: boolean;
66
+ /** The resolved bash path, when found. */
67
+ where?: string;
68
+ }
69
+ export declare function checkBash(probe: BashProbe): CheckResult;
63
70
  export interface DoctorReport {
64
71
  checks: CheckResult[];
65
72
  exitCode: number;
@@ -75,12 +82,26 @@ export interface DoctorDeps {
75
82
  probeBackend?: (baseUrl: string, token: string) => Promise<BackendProbe>;
76
83
  probeStateDir?: () => StateDirProbe;
77
84
  ghOnPath?: () => boolean;
85
+ /** Platform seam for the win32-only bash check (defaults to process.platform). */
86
+ platform?: NodeJS.Platform;
87
+ /** Windows bash probe; only ever called when the platform is win32. */
88
+ probeBash?: () => BashProbe;
78
89
  currentVersion?: string;
79
90
  probeLatestVersion?: () => Promise<string | null>;
80
91
  log?: (msg: string) => void;
81
92
  }
82
93
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
83
94
  export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
95
+ /**
96
+ * Locate the bash pi will actually use on Windows. The order and locations
97
+ * MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
98
+ * `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
99
+ * then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
100
+ * per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
101
+ * to pi, and a doctor that reported it green would bless a machine where the
102
+ * first bash tool call throws. Pure function of env, like the gh probe.
103
+ */
104
+ export declare function bashOnWindowsDefault(env?: NodeJS.ProcessEnv): BashProbe;
84
105
  /**
85
106
  * Gather every check result against the (injectable) probes. Pure ordering; each
86
107
  * individual check is a pure function of its probe.
package/dist/doctor.js CHANGED
@@ -198,6 +198,24 @@ export function checkGh(onPath) {
198
198
  required: false,
199
199
  };
200
200
  }
201
+ export function checkBash(probe) {
202
+ if (!probe.found) {
203
+ return {
204
+ name: "bash",
205
+ status: "fail",
206
+ detail: "no bash found (pi runs its shell commands through bash)",
207
+ hint: "Install Git for Windows — pi needs its bash: https://gitforwindows.org "
208
+ + "(a per-user install must also put bash.exe on PATH)",
209
+ required: true,
210
+ };
211
+ }
212
+ return {
213
+ name: "bash",
214
+ status: "ok",
215
+ detail: probe.where ? `found (${probe.where})` : "found",
216
+ required: true,
217
+ };
218
+ }
201
219
  function toOctal(mode) {
202
220
  return `0${(mode & 0o777).toString(8).padStart(3, "0")}`;
203
221
  }
@@ -296,6 +314,32 @@ export function ghOnPathDefault(env = process.env) {
296
314
  }
297
315
  return false;
298
316
  }
317
+ /**
318
+ * Locate the bash pi will actually use on Windows. The order and locations
319
+ * MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
320
+ * `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
321
+ * then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
322
+ * per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
323
+ * to pi, and a doctor that reported it green would bless a machine where the
324
+ * first bash tool call throws. Pure function of env, like the gh probe.
325
+ */
326
+ export function bashOnWindowsDefault(env = process.env) {
327
+ for (const root of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
328
+ if (!root)
329
+ continue;
330
+ const candidate = join(root, "Git", "bin", "bash.exe");
331
+ if (existsSync(candidate))
332
+ return { found: true, where: candidate };
333
+ }
334
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
335
+ if (!dir)
336
+ continue;
337
+ const candidate = join(dir, "bash.exe");
338
+ if (existsSync(candidate))
339
+ return { found: true, where: candidate };
340
+ }
341
+ return { found: false };
342
+ }
299
343
  /**
300
344
  * Gather every check result against the (injectable) probes. Pure ordering; each
301
345
  * individual check is a pure function of its probe.
@@ -308,10 +352,18 @@ export async function gatherChecks(deps = {}) {
308
352
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
309
353
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
310
354
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
355
+ const platform = deps.platform ?? process.platform;
356
+ const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
311
357
  const probeLatestVersion = deps.probeLatestVersion ?? (() => fetchLatestVersion());
312
358
  const checks = [];
313
359
  checks.push(checkPiEngine(probePiEngine()));
314
360
  checks.push(checkExtension(probeExtension()));
361
+ // win32 only, and skipped means NOT SHOWN: on macOS/Linux there is nothing
362
+ // to say. pi shells out through bash, so a Windows machine without Git Bash
363
+ // cannot launch at all — a required red, like a missing engine.
364
+ if (platform === "win32") {
365
+ checks.push(checkBash(probeBash()));
366
+ }
315
367
  checks.push(checkCliUpdate({
316
368
  current: deps.currentVersion ?? currentCliVersion(),
317
369
  latest: await probeLatestVersion(),
@@ -113,7 +113,13 @@ export function makeAskAdvisorTool(opts) {
113
113
  content: [{ type: "text", text: "Consulting the advisor…" }],
114
114
  details: { consults: opts.state.read().consults, cost: 0 },
115
115
  });
116
- const result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, { cwd: ctx?.cwd ?? process.cwd(), ...(signal ? { signal } : {}) });
116
+ const result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, {
117
+ cwd: ctx?.cwd ?? process.cwd(),
118
+ ...(signal ? { signal } : {}),
119
+ // YAG-471: attribute the consult's completions to the advisor, not
120
+ // the "plan" stage id advisorStage() borrows (see its docblock).
121
+ callerLabel: "advisor",
122
+ });
117
123
  const cost = result.usage?.cost ?? 0;
118
124
  const state = opts.state.record(cost);
119
125
  if (result.exitCode !== 0 && !result.finalOutput.trim()) {
@@ -17,6 +17,14 @@
17
17
  * one (and vice versa). Everything is pure except the in-memory rule list.
18
18
  */
19
19
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
20
+ /**
21
+ * Windows-only separator normalization so prefixes compare and display with
22
+ * `/` on every platform: pi's tools emit forward-slash paths even on Windows,
23
+ * and a rule keyed `C:\repo\src\api` would silently never match a call for
24
+ * `C:/repo/src/api/a.ts`. On POSIX this is the identity (a `\` there is a
25
+ * legal filename character, not a separator).
26
+ */
27
+ const norm = sep === "\\" ? (p) => p.split("\\").join("/") : (p) => p;
20
28
  /** The file path a call targets, or null for path-less tools (bash). */
21
29
  export function blessPath(params) {
22
30
  const p = params.path;
@@ -25,7 +33,12 @@ export function blessPath(params) {
25
33
  /** Build a fresh, empty session bless store rooted at `cwd`. */
26
34
  export function makeBlessStore(cwd) {
27
35
  const rules = [];
28
- const abs = (p) => (isAbsolute(p) ? p : resolve(cwd, p));
36
+ // Always THROUGH resolve, even for absolute inputs: on Windows a bare
37
+ // "/repo/…" is drive-relative and resolve() drive-qualifies it, so a rule
38
+ // minted from a relative path and a call carrying an absolute one land on
39
+ // the same canonical form. (For an already-absolute POSIX path this is just
40
+ // normalization.)
41
+ const abs = (p) => norm(resolve(cwd, p));
29
42
  /** Absolute directory prefix a bless of this call would cover, or null. */
30
43
  function prefixFor(params) {
31
44
  const p = blessPath(params);
@@ -41,7 +54,7 @@ export function makeBlessStore(cwd) {
41
54
  const prefix = prefixFor(params);
42
55
  if (prefix === null)
43
56
  return null;
44
- const rel = relative(cwd, prefix);
57
+ const rel = norm(relative(cwd, prefix));
45
58
  // Inside the tree → the relative dir (or "." for the repo root); outside →
46
59
  // the absolute path so the user sees exactly what they are blessing.
47
60
  if (rel === "")
@@ -64,7 +77,7 @@ export function makeBlessStore(cwd) {
64
77
  if (p === null)
65
78
  return false; // path-less (bash) never auto-approves
66
79
  const target = abs(p);
67
- return rules.some((r) => r.tool === tool && (target === r.prefix || target.startsWith(r.prefix + sep)));
80
+ return rules.some((r) => r.tool === tool && (target === r.prefix || target.startsWith(`${r.prefix}/`)));
68
81
  },
69
82
  rules() {
70
83
  return rules.slice();
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
3
+ *
4
+ * The session default is the `balanced` tier. `/boost` flips the
5
+ * DRIVER session's live model to `peak` until `/boost off` or the process
6
+ * exits; `peak` stays directly pickable through pi's own model picker too,
7
+ * this just adds a one-word lever plus an attribution flag.
8
+ *
9
+ * Two halves, same split as `advisor.ts` / `permission.ts`:
10
+ *
11
+ * - `boostOn` / `boostOff` are PURE. They own every transition and the exact
12
+ * notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
13
+ * - `registerBoostCommand` is the wiring: it applies the pure result's
14
+ * `targetModelId` through pi's real model-switch API, toggles
15
+ * `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
16
+ *
17
+ * The model-switch API (verified against pi 0.83.0's typings, not guessed):
18
+ * `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
19
+ * `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
20
+ * `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
21
+ * it to the live session, resolving `false` when no API key is configured.
22
+ * The `yagni` provider's catalog entries key `id` on the tier id itself (see
23
+ * `provider.ts`), so `peak` is the pi model id for the peak tier.
24
+ *
25
+ * State lives in the registration closure — module/session-scoped, mirroring
26
+ * `costHud.ts`'s accumulator — because a session's process exit is the only
27
+ * "end" there is; nothing needs to persist across it. `registerBoostCommand`
28
+ * returns a small `{ isBoosted() }` handle onto that same closure so other
29
+ * registrations (currently just `/cost`, see below) can read the live flag
30
+ * without a second source of truth.
31
+ *
32
+ * Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
33
+ * `/model`) can change the live model independently of `/boost` in either
34
+ * direction, so `state.active` can go stale relative to `ctx.model.id`. Both
35
+ * halves read the live model and reconcile rather than trusting `state.active`
36
+ * blindly:
37
+ *
38
+ * - `boostOn` while already active but the live model has drifted OFF peak
39
+ * (the user cycled models mid-boost without `/boost off`): re-applies
40
+ * peak instead of a silent "Already boosted." that would leave the
41
+ * driver quietly running a cheaper tier under an attribution header that
42
+ * claims otherwise. The remembered `priorModelId` is left untouched — the
43
+ * tier to restore is still whatever was active before the ORIGINAL boost,
44
+ * not the tier the user happened to drift to.
45
+ * - `boostOff` while active but the live model is no longer peak (same
46
+ * drift, encountered from the other command): the user already left
47
+ * boost manually, so forcing a switch back to the remembered prior tier
48
+ * would clobber a choice they just made on purpose. Instead this clears
49
+ * `/boost`'s own bookkeeping (state, env, chip) without touching the
50
+ * model, and names where they actually landed.
51
+ *
52
+ * KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
53
+ * while boosted makes every CHILD process spawned during the boost (a /go
54
+ * run, a subagent, an advisor consult) inherit it, and those children's
55
+ * completions carry `x-yagni-boost` because their provider is registered
56
+ * fresh per child. The DRIVER's own completions do not gain that header
57
+ * retroactively — `buildYagniProvider`'s headers are baked into the
58
+ * `registerProvider` call once, at session start, from the env snapshot at
59
+ * that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
60
+ * provider mid-session to pick up a new header is explicitly NOT the fix
61
+ * here: it would race the in-flight request the switch itself triggers and
62
+ * has no test coverage as a live-swap path.
63
+ *
64
+ * This is exactly why `/cost` cannot rely on server rows alone: the server's
65
+ * per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
66
+ * only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
67
+ * the driver's own turns. A session that only chats while boosted would
68
+ * otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
69
+ * dep (threaded from THIS module's return handle, the same way
70
+ * `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
71
+ * appends a fixed "Boost is on. Driver turns bill at the peak tier." line
72
+ * whenever the live toggle is on, on BOTH the server-authoritative and the
73
+ * local-fallback branch, independent of what server rows happen to show.
74
+ */
75
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
76
+ /** Session-scoped boost state, held in the registration closure. */
77
+ export interface BoostState {
78
+ active: boolean;
79
+ /** The tier to restore on `/boost off`. Set only while `active`. */
80
+ priorModelId?: string;
81
+ }
82
+ /** The pure outcome of a `/boost` transition. */
83
+ export interface BoostResult {
84
+ state: BoostState;
85
+ /** Exact notice text to surface to the user. */
86
+ notice: string;
87
+ /** The tier id to switch the session to, or undefined for a pure no-op. */
88
+ targetModelId?: string;
89
+ }
90
+ /**
91
+ * Turn boost on. PURE.
92
+ *
93
+ * - Already active AND the live model is still `peak`: genuine no-op,
94
+ * "Already boosted." (no switch — nothing about the prior tier changes).
95
+ * - Already active but the live model has drifted off `peak` (picker-drift
96
+ * guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
97
+ * `priorModelId` rather than adopting the drifted-to tier.
98
+ * - Not active, current model IS already `peak`: activates anyway (so
99
+ * attribution and `/boost off` behave correctly) with a distinct notice,
100
+ * remembering `peak` itself as the tier to restore.
101
+ * - Not active, otherwise: remembers the current tier, targets `peak`.
102
+ */
103
+ export declare function boostOn(state: BoostState, currentModelId: string | undefined): BoostResult;
104
+ /**
105
+ * Turn boost off. PURE.
106
+ *
107
+ * - Not active: no-op, "Boost is not on."
108
+ * - Active but the live model is no longer `peak` (picker-drift guard 3a,
109
+ * see module docblock): the user already left boost manually. Clears
110
+ * `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
111
+ * remembered prior tier would clobber a choice just made on purpose — and
112
+ * names the model they actually landed on.
113
+ * - Active and still on `peak`: restores the remembered prior tier (falling
114
+ * back to the session default when none was recorded, which should not
115
+ * normally happen). When the prior tier is itself `peak` (the
116
+ * already-on-Peak activation path), the resulting switch is a no-op in
117
+ * effect — still applied, harmlessly.
118
+ */
119
+ export declare function boostOff(state: BoostState, currentModelId: string | undefined): BoostResult;
120
+ /** Injectable seams for `registerBoostCommand`. */
121
+ export interface RegisterBoostCommandDeps {
122
+ /** Environment `YAGNI_BOOST` is toggled on. Defaults to `process.env`. */
123
+ env?: NodeJS.ProcessEnv;
124
+ }
125
+ /** What `registerBoostCommand` hands back so other registrations (`/cost`) can read the live flag. */
126
+ export interface BoostCommandHandle {
127
+ /** Whether the session is currently boosted, read fresh off the same closure `/boost` mutates. */
128
+ isBoosted(): boolean;
129
+ }
130
+ /**
131
+ * Register the `/boost` command.
132
+ *
133
+ * `/boost` (no args) turns boost on; `/boost off` turns it off; any other
134
+ * argument shows a usage notice and changes nothing. Every transition that
135
+ * requires a model switch runs it through pi's real API and is guarded end to
136
+ * end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
137
+ * switch succeeds (or is deliberately skipped — the picker-drift guards), so
138
+ * a half-applied boost (env set but model unchanged, or vice versa) can never
139
+ * happen. A failed ON reports "Boost failed."; a failed OFF reports a
140
+ * DISTINCT message, because the failure mode is materially worse — the
141
+ * driver is left still running (and billing) on Peak, not merely unchanged.
142
+ */
143
+ export declare function registerBoostCommand(pi: ExtensionAPI, deps?: RegisterBoostCommandDeps): BoostCommandHandle;
144
+ //# sourceMappingURL=boostCommand.d.ts.map
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
3
+ *
4
+ * The session default is the `balanced` tier. `/boost` flips the
5
+ * DRIVER session's live model to `peak` until `/boost off` or the process
6
+ * exits; `peak` stays directly pickable through pi's own model picker too,
7
+ * this just adds a one-word lever plus an attribution flag.
8
+ *
9
+ * Two halves, same split as `advisor.ts` / `permission.ts`:
10
+ *
11
+ * - `boostOn` / `boostOff` are PURE. They own every transition and the exact
12
+ * notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
13
+ * - `registerBoostCommand` is the wiring: it applies the pure result's
14
+ * `targetModelId` through pi's real model-switch API, toggles
15
+ * `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
16
+ *
17
+ * The model-switch API (verified against pi 0.83.0's typings, not guessed):
18
+ * `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
19
+ * `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
20
+ * `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
21
+ * it to the live session, resolving `false` when no API key is configured.
22
+ * The `yagni` provider's catalog entries key `id` on the tier id itself (see
23
+ * `provider.ts`), so `peak` is the pi model id for the peak tier.
24
+ *
25
+ * State lives in the registration closure — module/session-scoped, mirroring
26
+ * `costHud.ts`'s accumulator — because a session's process exit is the only
27
+ * "end" there is; nothing needs to persist across it. `registerBoostCommand`
28
+ * returns a small `{ isBoosted() }` handle onto that same closure so other
29
+ * registrations (currently just `/cost`, see below) can read the live flag
30
+ * without a second source of truth.
31
+ *
32
+ * Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
33
+ * `/model`) can change the live model independently of `/boost` in either
34
+ * direction, so `state.active` can go stale relative to `ctx.model.id`. Both
35
+ * halves read the live model and reconcile rather than trusting `state.active`
36
+ * blindly:
37
+ *
38
+ * - `boostOn` while already active but the live model has drifted OFF peak
39
+ * (the user cycled models mid-boost without `/boost off`): re-applies
40
+ * peak instead of a silent "Already boosted." that would leave the
41
+ * driver quietly running a cheaper tier under an attribution header that
42
+ * claims otherwise. The remembered `priorModelId` is left untouched — the
43
+ * tier to restore is still whatever was active before the ORIGINAL boost,
44
+ * not the tier the user happened to drift to.
45
+ * - `boostOff` while active but the live model is no longer peak (same
46
+ * drift, encountered from the other command): the user already left
47
+ * boost manually, so forcing a switch back to the remembered prior tier
48
+ * would clobber a choice they just made on purpose. Instead this clears
49
+ * `/boost`'s own bookkeeping (state, env, chip) without touching the
50
+ * model, and names where they actually landed.
51
+ *
52
+ * KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
53
+ * while boosted makes every CHILD process spawned during the boost (a /go
54
+ * run, a subagent, an advisor consult) inherit it, and those children's
55
+ * completions carry `x-yagni-boost` because their provider is registered
56
+ * fresh per child. The DRIVER's own completions do not gain that header
57
+ * retroactively — `buildYagniProvider`'s headers are baked into the
58
+ * `registerProvider` call once, at session start, from the env snapshot at
59
+ * that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
60
+ * provider mid-session to pick up a new header is explicitly NOT the fix
61
+ * here: it would race the in-flight request the switch itself triggers and
62
+ * has no test coverage as a live-swap path.
63
+ *
64
+ * This is exactly why `/cost` cannot rely on server rows alone: the server's
65
+ * per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
66
+ * only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
67
+ * the driver's own turns. A session that only chats while boosted would
68
+ * otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
69
+ * dep (threaded from THIS module's return handle, the same way
70
+ * `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
71
+ * appends a fixed "Boost is on. Driver turns bill at the peak tier." line
72
+ * whenever the live toggle is on, on BOTH the server-authoritative and the
73
+ * local-fallback branch, independent of what server rows happen to show.
74
+ */
75
+ /** The pi provider name the YAGNI catalog registers under (see `provider.ts`). */
76
+ const YAGNI_PROVIDER = "yagni";
77
+ /** The tier `/boost` escalates to. */
78
+ const BOOST_TIER = "peak";
79
+ /** The session default tier, restored when no prior tier is known. */
80
+ const DEFAULT_TIER = "balanced";
81
+ /** Tier ids are lowercase; every known tier's display name is just Title Case of the id. */
82
+ function displayTierName(id) {
83
+ return id.length === 0 ? id : id.charAt(0).toUpperCase() + id.slice(1);
84
+ }
85
+ /**
86
+ * Turn boost on. PURE.
87
+ *
88
+ * - Already active AND the live model is still `peak`: genuine no-op,
89
+ * "Already boosted." (no switch — nothing about the prior tier changes).
90
+ * - Already active but the live model has drifted off `peak` (picker-drift
91
+ * guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
92
+ * `priorModelId` rather than adopting the drifted-to tier.
93
+ * - Not active, current model IS already `peak`: activates anyway (so
94
+ * attribution and `/boost off` behave correctly) with a distinct notice,
95
+ * remembering `peak` itself as the tier to restore.
96
+ * - Not active, otherwise: remembers the current tier, targets `peak`.
97
+ */
98
+ export function boostOn(state, currentModelId) {
99
+ if (state.active) {
100
+ if (currentModelId === BOOST_TIER) {
101
+ return { state, notice: "Already boosted." };
102
+ }
103
+ return {
104
+ state,
105
+ notice: `Re-boosted to Peak. /boost off to return to ${displayTierName(state.priorModelId ?? DEFAULT_TIER)}.`,
106
+ targetModelId: BOOST_TIER,
107
+ };
108
+ }
109
+ if (currentModelId === BOOST_TIER) {
110
+ return {
111
+ state: { active: true, priorModelId: BOOST_TIER },
112
+ notice: "Already on Peak. /boost off returns to Peak.",
113
+ targetModelId: BOOST_TIER,
114
+ };
115
+ }
116
+ return {
117
+ state: { active: true, priorModelId: currentModelId },
118
+ notice: `Boosted to Peak. /boost off to return to ${displayTierName(currentModelId ?? DEFAULT_TIER)}.`,
119
+ targetModelId: BOOST_TIER,
120
+ };
121
+ }
122
+ /**
123
+ * Turn boost off. PURE.
124
+ *
125
+ * - Not active: no-op, "Boost is not on."
126
+ * - Active but the live model is no longer `peak` (picker-drift guard 3a,
127
+ * see module docblock): the user already left boost manually. Clears
128
+ * `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
129
+ * remembered prior tier would clobber a choice just made on purpose — and
130
+ * names the model they actually landed on.
131
+ * - Active and still on `peak`: restores the remembered prior tier (falling
132
+ * back to the session default when none was recorded, which should not
133
+ * normally happen). When the prior tier is itself `peak` (the
134
+ * already-on-Peak activation path), the resulting switch is a no-op in
135
+ * effect — still applied, harmlessly.
136
+ */
137
+ export function boostOff(state, currentModelId) {
138
+ if (!state.active) {
139
+ return { state, notice: "Boost is not on." };
140
+ }
141
+ if (currentModelId !== BOOST_TIER) {
142
+ return {
143
+ state: { active: false, priorModelId: undefined },
144
+ notice: `Boost off. Leaving the model on ${displayTierName(currentModelId ?? DEFAULT_TIER)}.`,
145
+ };
146
+ }
147
+ const priorId = state.priorModelId ?? DEFAULT_TIER;
148
+ return {
149
+ state: { active: false, priorModelId: undefined },
150
+ notice: `Back to ${displayTierName(priorId)}.`,
151
+ targetModelId: priorId,
152
+ };
153
+ }
154
+ /**
155
+ * Resolve `tierId` to a live `Model` and apply it via `pi.setModel`.
156
+ *
157
+ * On a registry lookup miss, `allowFallback` (true only on the OFF path —
158
+ * MINOR 5) retries once against the session default tier rather than
159
+ * stranding the user on Peak over one missing catalog entry. Returns the
160
+ * tier id that was ACTUALLY applied, so the caller can tell whether the
161
+ * fallback fired and adjust the notice to name it honestly.
162
+ *
163
+ * @throws when neither the requested tier nor (if allowed) the fallback
164
+ * resolves, or when `pi.setModel` itself throws or resolves `false`.
165
+ */
166
+ async function applyTier(pi, ctx, tierId, allowFallback) {
167
+ let model = ctx.modelRegistry?.find(YAGNI_PROVIDER, tierId);
168
+ let applied = tierId;
169
+ if (!model && allowFallback && tierId !== DEFAULT_TIER) {
170
+ model = ctx.modelRegistry?.find(YAGNI_PROVIDER, DEFAULT_TIER);
171
+ applied = DEFAULT_TIER;
172
+ }
173
+ if (!model) {
174
+ throw new Error(`Unknown YAGNI tier "${tierId}".`);
175
+ }
176
+ const ok = await pi.setModel(model);
177
+ if (!ok) {
178
+ throw new Error("setModel reported no API key configured.");
179
+ }
180
+ return applied;
181
+ }
182
+ /**
183
+ * Register the `/boost` command.
184
+ *
185
+ * `/boost` (no args) turns boost on; `/boost off` turns it off; any other
186
+ * argument shows a usage notice and changes nothing. Every transition that
187
+ * requires a model switch runs it through pi's real API and is guarded end to
188
+ * end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
189
+ * switch succeeds (or is deliberately skipped — the picker-drift guards), so
190
+ * a half-applied boost (env set but model unchanged, or vice versa) can never
191
+ * happen. A failed ON reports "Boost failed."; a failed OFF reports a
192
+ * DISTINCT message, because the failure mode is materially worse — the
193
+ * driver is left still running (and billing) on Peak, not merely unchanged.
194
+ */
195
+ export function registerBoostCommand(pi, deps = {}) {
196
+ const env = deps.env ?? process.env;
197
+ let state = { active: false };
198
+ const paintStatus = (ctx, active) => {
199
+ try {
200
+ if (ctx.hasUI)
201
+ ctx.ui.setStatus?.("yagni-boost", active ? "⚡ Peak" : undefined);
202
+ }
203
+ catch {
204
+ // The chip is chrome; never let it break /boost.
205
+ }
206
+ };
207
+ pi.registerCommand("boost", {
208
+ description: "Escalate this session's model to Peak until /boost off or the session ends. /boost off returns to the prior tier.",
209
+ handler: async (args, ctx) => {
210
+ const notify = (message, type) => {
211
+ if (ctx.hasUI)
212
+ ctx.ui.notify(message, type);
213
+ };
214
+ // MINOR 4: case-insensitive "off" match, mirroring /mode's arg handling.
215
+ const arg = args.trim().toLowerCase();
216
+ if (arg !== "" && arg !== "off") {
217
+ notify("Usage: /boost (turn on) or /boost off.", "warning");
218
+ return;
219
+ }
220
+ const isOff = arg === "off";
221
+ const wasActive = state.active;
222
+ const result = isOff ? boostOff(state, ctx.model?.id) : boostOn(state, ctx.model?.id);
223
+ try {
224
+ let notice = result.notice;
225
+ if (result.targetModelId) {
226
+ const applied = await applyTier(pi, ctx, result.targetModelId, isOff);
227
+ if (applied !== result.targetModelId) {
228
+ // MINOR 5: the remembered/target tier no longer resolves in the
229
+ // catalog; we landed on the session default instead of
230
+ // stranding the user on Peak. Name the fallback so the notice
231
+ // stays honest about what actually happened.
232
+ notice = `Back to ${displayTierName(applied)} (could not restore ${displayTierName(result.targetModelId)}).`;
233
+ }
234
+ }
235
+ state = result.state;
236
+ // MINOR 6: only touch the env on a real active/inactive flip. Both
237
+ // pure no-op paths ("Already boosted.", "Boost is not on.") return
238
+ // `state` unchanged, so this never fires for them — an inherited
239
+ // YAGNI_BOOST from outside this session is left exactly as found.
240
+ if (state.active !== wasActive) {
241
+ if (state.active) {
242
+ env.YAGNI_BOOST = "1";
243
+ }
244
+ else {
245
+ delete env.YAGNI_BOOST;
246
+ }
247
+ }
248
+ notify(notice, "info");
249
+ paintStatus(ctx, state.active);
250
+ }
251
+ catch {
252
+ // IMPORTANT 1: a failed OFF is materially worse than a failed ON —
253
+ // the driver is STILL on Peak, still billing at Peak rates, which a
254
+ // generic "Boost failed." does not convey. Neither `state` nor
255
+ // `YAGNI_BOOST` was touched above (the throw lands before both), so
256
+ // the chip is also left exactly as it was: still lit.
257
+ notify(isOff ? "Could not leave boost. Still on Peak; try /boost off again." : "Boost failed.", "error");
258
+ }
259
+ },
260
+ });
261
+ return { isBoosted: () => state.active };
262
+ }
263
+ //# sourceMappingURL=boostCommand.js.map