@yagni-app/code-staging 0.1.0-staging.997.1 → 0.2.0-staging.1025.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 (64) hide show
  1. package/README.md +58 -9
  2. package/dist/claudeCompat.d.ts +36 -5
  3. package/dist/claudeCompat.js +85 -23
  4. package/dist/claudePlugins.d.ts +109 -0
  5. package/dist/claudePlugins.js +336 -0
  6. package/dist/cli.js +14 -4
  7. package/dist/crashReport.d.ts +135 -0
  8. package/dist/crashReport.js +291 -0
  9. package/dist/doctor.d.ts +21 -0
  10. package/dist/doctor.js +52 -0
  11. package/dist/extension/askAdvisorTool.js +7 -1
  12. package/dist/extension/bless.js +16 -3
  13. package/dist/extension/boostCommand.d.ts +144 -0
  14. package/dist/extension/boostCommand.js +263 -0
  15. package/dist/extension/branding.d.ts +31 -0
  16. package/dist/extension/branding.js +37 -0
  17. package/dist/extension/chipEditor.js +7 -3
  18. package/dist/extension/claudeRules.d.ts +54 -0
  19. package/dist/extension/claudeRules.js +180 -0
  20. package/dist/extension/config.d.ts +61 -0
  21. package/dist/extension/config.js +86 -0
  22. package/dist/extension/costHud.d.ts +128 -15
  23. package/dist/extension/costHud.js +189 -19
  24. package/dist/extension/crashReport.d.ts +89 -0
  25. package/dist/extension/crashReport.js +241 -0
  26. package/dist/extension/index.d.ts +43 -4
  27. package/dist/extension/index.js +241 -32
  28. package/dist/extension/initPass.d.ts +65 -47
  29. package/dist/extension/initPass.js +145 -145
  30. package/dist/extension/mcpTools.d.ts +57 -0
  31. package/dist/extension/mcpTools.js +132 -0
  32. package/dist/extension/pipeline/eval.d.ts +42 -5
  33. package/dist/extension/pipeline/eval.js +44 -0
  34. package/dist/extension/pipeline/goCommand.d.ts +18 -0
  35. package/dist/extension/pipeline/goCommand.js +139 -26
  36. package/dist/extension/pipeline/goCompareCommand.d.ts +18 -8
  37. package/dist/extension/pipeline/goCompareCommand.js +42 -23
  38. package/dist/extension/pipeline/orchestrator.js +9 -0
  39. package/dist/extension/pipeline/runCostTable.d.ts +37 -0
  40. package/dist/extension/pipeline/runCostTable.js +165 -0
  41. package/dist/extension/pipeline/runState.d.ts +19 -0
  42. package/dist/extension/pipeline/runState.js +11 -0
  43. package/dist/extension/pipeline/runner.d.ts +19 -0
  44. package/dist/extension/pipeline/runner.js +13 -1
  45. package/dist/extension/pipeline/scrubSecrets.js +2 -2
  46. package/dist/extension/pipeline/stages.d.ts +3 -1
  47. package/dist/extension/pipeline/stages.js +3 -1
  48. package/dist/extension/pipeline/types.d.ts +7 -4
  49. package/dist/extension/pipeline/verify.js +6 -1
  50. package/dist/extension/pipeline/worktree.js +3 -1
  51. package/dist/extension/provider.d.ts +7 -1
  52. package/dist/extension/provider.js +8 -1
  53. package/dist/extension/recall.js +5 -2
  54. package/dist/extension/rerouteNotice.d.ts +42 -0
  55. package/dist/extension/rerouteNotice.js +67 -0
  56. package/dist/extension/sessionRuns.d.ts +45 -0
  57. package/dist/extension/sessionRuns.js +77 -0
  58. package/dist/extension/subagents.d.ts +17 -7
  59. package/dist/extension/subagents.js +52 -7
  60. package/dist/launch.d.ts +17 -3
  61. package/dist/launch.js +22 -9
  62. package/dist/login.d.ts +7 -0
  63. package/dist/login.js +3 -1
  64. package/package.json +2 -2
@@ -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
@@ -14,9 +14,40 @@ export declare const BRAND_NAME = "YAGNI Code";
14
14
  * "You are an expert coding assistant operating inside pi..." opener. It states
15
15
  * the differentiator (connected to the YAGNI app) and the working contract
16
16
  * (consult ask_yagni before guessing) so the agent behaves more autonomously.
17
+ *
18
+ * This is the BASE identity, used for every non-driver caller (a `/go` stage
19
+ * child, a subagent, an advisor consult) as well as the driver, since the
20
+ * extension loads identically in every one of those pi processes. It carries
21
+ * no delegation directive: see {@link YAGNI_IDENTITY_DRIVER}.
17
22
  */
18
23
  export declare const YAGNI_IDENTITY: string;
24
+ /**
25
+ * The delegation-first directive: fan mechanical/wide work out to subagents
26
+ * (which run on cheaper tiers) and keep judgment, synthesis, and the user
27
+ * conversation in this session. DRIVER-ONLY (see {@link YAGNI_IDENTITY_DRIVER}):
28
+ * a `/go` stage child has no `subagent` tool, so this instruction would be
29
+ * pure prompt noise there, or worse, an attempt to call a tool that doesn't
30
+ * exist.
31
+ */
32
+ export declare const DRIVER_DELEGATION_PARAGRAPH: string;
33
+ /**
34
+ * The identity used for the interactive DRIVER session ONLY: {@link
35
+ * YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
36
+ * selects between this and the base `YAGNI_IDENTITY` per-process based on the
37
+ * effective `x-yagni-caller` attribution (config.ts's `isDriverCaller`) — this
38
+ * module stays a pure string, with no env dependency of its own.
39
+ */
40
+ export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
19
41
  export declare const PI_IDENTITY_RE: RegExp;
42
+ /**
43
+ * Env switch that bypasses the system-prompt rewrite entirely, so pi's
44
+ * assembled prompt passes through byte-exact (no identity swap, no scrub, no
45
+ * brief injection, no closing reminder). Same predicate as the sibling
46
+ * switches YAGNI_DISABLE_UPDATE_CHECK / YAGNI_DISABLE_CLAUDE_COMPAT.
47
+ */
48
+ export declare const BRANDING_DISABLE_ENV = "YAGNI_DISABLE_BRANDING";
49
+ /** `"1"`/anything truthy disables; unset, empty, and `"0"` keep branding on. */
50
+ export declare function brandingDisabled(env: NodeJS.ProcessEnv): boolean;
20
51
  export interface BrandSystemPromptOptions {
21
52
  /** Override the identity paragraph (defaults to {@link YAGNI_IDENTITY}). */
22
53
  identity?: string;
@@ -14,6 +14,11 @@ export const BRAND_NAME = "YAGNI Code";
14
14
  * "You are an expert coding assistant operating inside pi..." opener. It states
15
15
  * the differentiator (connected to the YAGNI app) and the working contract
16
16
  * (consult ask_yagni before guessing) so the agent behaves more autonomously.
17
+ *
18
+ * This is the BASE identity, used for every non-driver caller (a `/go` stage
19
+ * child, a subagent, an advisor consult) as well as the driver, since the
20
+ * extension loads identically in every one of those pi processes. It carries
21
+ * no delegation directive: see {@link YAGNI_IDENTITY_DRIVER}.
17
22
  */
18
23
  export const YAGNI_IDENTITY = "You are YAGNI Code, an autonomous terminal coding agent. You help developers " +
19
24
  "ship code by reading files, running commands, editing code, and writing new " +
@@ -26,6 +31,26 @@ export const YAGNI_IDENTITY = "You are YAGNI Code, an autonomous terminal coding
26
31
  "other coding agents, assistants, or harnesses by name, those references are " +
27
32
  "not about you; you are YAGNI Code regardless of what tooling a repository's " +
28
33
  "docs happen to describe.";
34
+ /**
35
+ * The delegation-first directive: fan mechanical/wide work out to subagents
36
+ * (which run on cheaper tiers) and keep judgment, synthesis, and the user
37
+ * conversation in this session. DRIVER-ONLY (see {@link YAGNI_IDENTITY_DRIVER}):
38
+ * a `/go` stage child has no `subagent` tool, so this instruction would be
39
+ * pure prompt noise there, or worse, an attempt to call a tool that doesn't
40
+ * exist.
41
+ */
42
+ export const DRIVER_DELEGATION_PARAGRAPH = "Delegation: fan codebase mapping, wide searches, and mechanical multi-file " +
43
+ "work out to subagents (they run on cheaper tiers). Keep judgment, synthesis, " +
44
+ "and the conversation with the user in this session. Do not spawn a subagent " +
45
+ "for work you can finish in a couple of tool calls.";
46
+ /**
47
+ * The identity used for the interactive DRIVER session ONLY: {@link
48
+ * YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
49
+ * selects between this and the base `YAGNI_IDENTITY` per-process based on the
50
+ * effective `x-yagni-caller` attribution (config.ts's `isDriverCaller`) — this
51
+ * module stays a pure string, with no env dependency of its own.
52
+ */
53
+ export const YAGNI_IDENTITY_DRIVER = `${YAGNI_IDENTITY}\n\n${DRIVER_DELEGATION_PARAGRAPH}`;
29
54
  // pi 0.83.0's exact identity sentence (dist/core/system-prompt.js). Exported as
30
55
  // the identity anchor the CLI's pi-contract tripwire test reads back from pi's
31
56
  // built system prompt, so a pi bump that reworded the opener (silently defeating
@@ -56,6 +81,18 @@ function scrubOutsideProjectContext(s) {
56
81
  .map((p) => (p.startsWith("<project_context>") ? p : scrubPiHarness(p)))
57
82
  .join("");
58
83
  }
84
+ /**
85
+ * Env switch that bypasses the system-prompt rewrite entirely, so pi's
86
+ * assembled prompt passes through byte-exact (no identity swap, no scrub, no
87
+ * brief injection, no closing reminder). Same predicate as the sibling
88
+ * switches YAGNI_DISABLE_UPDATE_CHECK / YAGNI_DISABLE_CLAUDE_COMPAT.
89
+ */
90
+ export const BRANDING_DISABLE_ENV = "YAGNI_DISABLE_BRANDING";
91
+ /** `"1"`/anything truthy disables; unset, empty, and `"0"` keep branding on. */
92
+ export function brandingDisabled(env) {
93
+ const value = env[BRANDING_DISABLE_ENV];
94
+ return value !== undefined && value !== "" && value !== "0";
95
+ }
59
96
  /**
60
97
  * Rebrand pi's assembled system prompt as YAGNI Code's, and optionally inject a
61
98
  * live company brief.
@@ -26,7 +26,7 @@ import { matchesKey } from "@earendil-works/pi-tui";
26
26
  import { spawnSync } from "node:child_process";
27
27
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
- import { join, basename } from "node:path";
29
+ import { join, basename, isAbsolute } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { logImagePaste } from "./diagnostics.js";
32
32
  /** Matches the `[Image #N]` chip token in the editor text. */
@@ -81,10 +81,14 @@ export function readPastedImagePath(pastedText) {
81
81
  // must confirm the un-escaped form is a well-formed tmp image path before any
82
82
  // unescaping or path resolution. cmux tmp paths contain no escapable chars
83
83
  // (no spaces/metacharacters), so a legitimate paste has NO backslashes at all.
84
- if (trimmed.includes("\\"))
84
+ // On Windows the backslash IS the path separator (and cmd/PowerShell do no
85
+ // backslash-escaping), so the escape-smuggling rejection applies only where
86
+ // a backslash could be an escape: POSIX shells.
87
+ const isWindows = process.platform === "win32";
88
+ if (!isWindows && trimmed.includes("\\"))
85
89
  return null; // escapes => not a plain cmux tmp path
86
90
  const path = trimmed;
87
- if (!path.startsWith("/"))
91
+ if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
88
92
  return null;
89
93
  const name = basename(path);
90
94
  if (!name.startsWith("clipboard-"))
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Claude Code `.claude/rules/*.md` compat: fold rule files into the system
3
+ * prompt the extension already owns.
4
+ *
5
+ * Claude Code loads a rule without `paths` frontmatter at launch (same weight
6
+ * as CLAUDE.md) and injects a `paths`-scoped rule when the agent reads a
7
+ * matching file. pi has no read-triggered injection, so the faithful bounded
8
+ * mapping (see docs/superpowers/specs/2026-08-08-claude-plugins-design.md) is:
9
+ *
10
+ * - unscoped rules: content inlined verbatim into one system-prompt section
11
+ * (per-file and total character caps, truncation markers) — exactly the
12
+ * load-at-launch behavior;
13
+ * - path-scoped rules: a pointer line — "when working with files matching
14
+ * these patterns, read and follow <file>" — so the prompt stays bounded on
15
+ * rule-heavy repos and scoping stays honest.
16
+ *
17
+ * The launcher decides WHICH dirs load (user `~/.claude/rules` freely, repo
18
+ * `.claude/rules` only once the folder is trusted) and passes them via
19
+ * `YAGNI_CLAUDE_RULES_DIRS`, user dir first so project rules land last and
20
+ * win. Everything here is fail-soft and honors the compat kill switch.
21
+ */
22
+ export declare const CLAUDE_RULES_DIRS_ENV = "YAGNI_CLAUDE_RULES_DIRS";
23
+ export declare const CLAUDE_RULES_HEADER = "## Repository rules (.claude/rules)";
24
+ export interface ClaudeRule {
25
+ /** Absolute file path — the pointer target for scoped rules. */
26
+ path: string;
27
+ /** Glob patterns from `paths` frontmatter; empty means always-on. */
28
+ paths: string[];
29
+ /** Markdown body (frontmatter stripped). */
30
+ body: string;
31
+ }
32
+ export interface CollectRulesOptions {
33
+ /** Total file cap across all dirs (default 40). */
34
+ maxFiles?: number;
35
+ /** Recursion depth cap per dir (default 5). */
36
+ maxDepth?: number;
37
+ /** Skip files larger than this (default 64 KiB). */
38
+ maxFileBytes?: number;
39
+ }
40
+ /** Enumerate + parse rule files from `dirs`, in dir order. Never throws. */
41
+ export declare function collectClaudeRules(dirs: string[], opts?: CollectRulesOptions): ClaudeRule[];
42
+ export interface BuildSectionOptions {
43
+ /** Per-file inline cap in characters (default 8000). */
44
+ maxInlineChars?: number;
45
+ /** Total inline budget across unscoped rules (default 32000). */
46
+ maxTotalChars?: number;
47
+ }
48
+ /** The system-prompt section for `rules`, or null when there is nothing to say. */
49
+ export declare function buildClaudeRulesSection(rules: ClaudeRule[], opts?: BuildSectionOptions): string | null;
50
+ /** Env-driven entry: dirs from YAGNI_CLAUDE_RULES_DIRS, null when off/empty. */
51
+ export declare function claudeRulesSection(env: NodeJS.ProcessEnv, opts?: CollectRulesOptions & BuildSectionOptions): string | null;
52
+ /** Append `section` to a system prompt exactly once; no-op without a section. */
53
+ export declare function appendClaudeRules(systemPrompt: string, section: string | null): string;
54
+ //# sourceMappingURL=claudeRules.d.ts.map
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Claude Code `.claude/rules/*.md` compat: fold rule files into the system
3
+ * prompt the extension already owns.
4
+ *
5
+ * Claude Code loads a rule without `paths` frontmatter at launch (same weight
6
+ * as CLAUDE.md) and injects a `paths`-scoped rule when the agent reads a
7
+ * matching file. pi has no read-triggered injection, so the faithful bounded
8
+ * mapping (see docs/superpowers/specs/2026-08-08-claude-plugins-design.md) is:
9
+ *
10
+ * - unscoped rules: content inlined verbatim into one system-prompt section
11
+ * (per-file and total character caps, truncation markers) — exactly the
12
+ * load-at-launch behavior;
13
+ * - path-scoped rules: a pointer line — "when working with files matching
14
+ * these patterns, read and follow <file>" — so the prompt stays bounded on
15
+ * rule-heavy repos and scoping stays honest.
16
+ *
17
+ * The launcher decides WHICH dirs load (user `~/.claude/rules` freely, repo
18
+ * `.claude/rules` only once the folder is trusted) and passes them via
19
+ * `YAGNI_CLAUDE_RULES_DIRS`, user dir first so project rules land last and
20
+ * win. Everything here is fail-soft and honors the compat kill switch.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import { delimiter, join } from "node:path";
24
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
25
+ export const CLAUDE_RULES_DIRS_ENV = "YAGNI_CLAUDE_RULES_DIRS";
26
+ const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
27
+ export const CLAUDE_RULES_HEADER = "## Repository rules (.claude/rules)";
28
+ function asPathList(value) {
29
+ if (typeof value === "string" && value.trim())
30
+ return [value.trim()];
31
+ if (Array.isArray(value)) {
32
+ return value.filter((v) => typeof v === "string" && v.trim().length > 0);
33
+ }
34
+ return [];
35
+ }
36
+ function walkRuleFiles(dir, depth, maxDepth, out, cap) {
37
+ if (depth > maxDepth || out.length >= cap)
38
+ return;
39
+ let entries;
40
+ try {
41
+ entries = fs.readdirSync(dir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
47
+ for (const entry of entries) {
48
+ if (out.length >= cap)
49
+ return;
50
+ const full = join(dir, entry.name);
51
+ let isDir = entry.isDirectory();
52
+ let isFile = entry.isFile();
53
+ if (entry.isSymbolicLink()) {
54
+ try {
55
+ const stat = fs.statSync(full);
56
+ isDir = stat.isDirectory();
57
+ isFile = stat.isFile();
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ }
63
+ if (isDir) {
64
+ walkRuleFiles(full, depth + 1, maxDepth, out, cap);
65
+ }
66
+ else if (isFile && entry.name.endsWith(".md")) {
67
+ out.push(full);
68
+ }
69
+ }
70
+ }
71
+ /** Enumerate + parse rule files from `dirs`, in dir order. Never throws. */
72
+ export function collectClaudeRules(dirs, opts = {}) {
73
+ const maxFiles = opts.maxFiles ?? 40;
74
+ const maxDepth = opts.maxDepth ?? 5;
75
+ const maxFileBytes = opts.maxFileBytes ?? 64 * 1024;
76
+ const rules = [];
77
+ try {
78
+ for (const dir of dirs) {
79
+ const files = [];
80
+ walkRuleFiles(dir, 0, maxDepth, files, Math.max(0, maxFiles - rules.length));
81
+ for (const file of files) {
82
+ try {
83
+ if (fs.statSync(file).size > maxFileBytes)
84
+ continue;
85
+ const content = fs.readFileSync(file, "utf8");
86
+ let paths = [];
87
+ let body = content;
88
+ try {
89
+ const parsed = parseFrontmatter(content);
90
+ paths = asPathList(parsed.frontmatter.paths);
91
+ body = parsed.body;
92
+ }
93
+ catch {
94
+ // Malformed YAML: treat as an unscoped rule with the raw content.
95
+ }
96
+ rules.push({ path: file, paths, body: body.trim() });
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ catch {
105
+ return rules;
106
+ }
107
+ return rules;
108
+ }
109
+ /** The system-prompt section for `rules`, or null when there is nothing to say. */
110
+ export function buildClaudeRulesSection(rules, opts = {}) {
111
+ if (rules.length === 0)
112
+ return null;
113
+ const maxInlineChars = opts.maxInlineChars ?? 8_000;
114
+ const maxTotalChars = opts.maxTotalChars ?? 32_000;
115
+ const inlined = [];
116
+ const overflow = [];
117
+ const scoped = [];
118
+ let spent = 0;
119
+ for (const r of rules) {
120
+ if (r.paths.length > 0) {
121
+ scoped.push(`- Files matching ${r.paths.map((p) => `\`${p}\``).join(", ")} → read and follow ${r.path}`);
122
+ continue;
123
+ }
124
+ if (!r.body)
125
+ continue;
126
+ if (spent >= maxTotalChars) {
127
+ overflow.push(`- ${r.path}`);
128
+ continue;
129
+ }
130
+ let body = r.body;
131
+ const room = Math.min(maxInlineChars, maxTotalChars - spent);
132
+ if (body.length > room)
133
+ body = `${body.slice(0, room)}\n[truncated — full rule: ${r.path}]`;
134
+ spent += body.length;
135
+ inlined.push(`<rule file="${r.path}">\n${body}\n</rule>`);
136
+ }
137
+ if (inlined.length === 0 && overflow.length === 0 && scoped.length === 0)
138
+ return null;
139
+ const parts = [
140
+ CLAUDE_RULES_HEADER,
141
+ "This project/user carries Claude Code rules files. They are house rules for this codebase; follow them.",
142
+ ];
143
+ if (inlined.length > 0)
144
+ parts.push(inlined.join("\n\n"));
145
+ if (scoped.length > 0) {
146
+ parts.push("Path-scoped rules — BEFORE creating or editing files matching a pattern below, read that rule file and follow it:\n" +
147
+ scoped.join("\n"));
148
+ }
149
+ if (overflow.length > 0) {
150
+ parts.push(`Additional rules files (read as needed):\n${overflow.join("\n")}`);
151
+ }
152
+ return parts.join("\n\n");
153
+ }
154
+ /** Env-driven entry: dirs from YAGNI_CLAUDE_RULES_DIRS, null when off/empty. */
155
+ export function claudeRulesSection(env, opts = {}) {
156
+ try {
157
+ const disabled = env[CLAUDE_COMPAT_DISABLE_ENV];
158
+ if (disabled !== undefined && disabled !== "" && disabled !== "0")
159
+ return null;
160
+ const raw = env[CLAUDE_RULES_DIRS_ENV];
161
+ if (!raw)
162
+ return null;
163
+ const dirs = raw.split(delimiter).filter(Boolean);
164
+ if (dirs.length === 0)
165
+ return null;
166
+ return buildClaudeRulesSection(collectClaudeRules(dirs, opts), opts);
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /** Append `section` to a system prompt exactly once; no-op without a section. */
173
+ export function appendClaudeRules(systemPrompt, section) {
174
+ if (!section)
175
+ return systemPrompt;
176
+ if (systemPrompt.includes(CLAUDE_RULES_HEADER))
177
+ return systemPrompt;
178
+ return `${systemPrompt}\n\n${section}`;
179
+ }
180
+ //# sourceMappingURL=claudeRules.js.map