@yagni-app/code 0.2.1 → 0.3.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 (98) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +147 -3
  3. package/dist/connectClaudeCode.d.ts +77 -0
  4. package/dist/connectClaudeCode.js +228 -0
  5. package/dist/connectCodex.d.ts +75 -0
  6. package/dist/connectCodex.js +201 -0
  7. package/dist/doctor.d.ts +1 -1
  8. package/dist/doctor.js +1 -1
  9. package/dist/extension/advisor.d.ts +4 -4
  10. package/dist/extension/advisor.js +6 -7
  11. package/dist/extension/approvedPrefixes.d.ts +103 -0
  12. package/dist/extension/approvedPrefixes.js +282 -0
  13. package/dist/extension/askAdvisorTool.d.ts +20 -5
  14. package/dist/extension/askAdvisorTool.js +126 -20
  15. package/dist/extension/askYagniTool.d.ts +23 -0
  16. package/dist/extension/askYagniTool.js +91 -2
  17. package/dist/extension/branding.d.ts +34 -3
  18. package/dist/extension/branding.js +114 -13
  19. package/dist/extension/chipEditor.d.ts +30 -9
  20. package/dist/extension/chipEditor.js +173 -59
  21. package/dist/extension/claudeRules.d.ts +0 -2
  22. package/dist/extension/claudeRules.js +0 -8
  23. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  24. package/dist/extension/cmux/dispatcher.js +266 -0
  25. package/dist/extension/cmux/hooks.d.ts +12 -0
  26. package/dist/extension/cmux/hooks.js +192 -0
  27. package/dist/extension/cmux/index.d.ts +3 -0
  28. package/dist/extension/cmux/index.js +155 -0
  29. package/dist/extension/cmux/naming.d.ts +5 -0
  30. package/dist/extension/cmux/naming.js +23 -0
  31. package/dist/extension/cmux/state.d.ts +33 -0
  32. package/dist/extension/cmux/state.js +142 -0
  33. package/dist/extension/config.d.ts +44 -1
  34. package/dist/extension/config.js +37 -4
  35. package/dist/extension/costHud.d.ts +16 -22
  36. package/dist/extension/costHud.js +8 -47
  37. package/dist/extension/crashReport.js +1 -3
  38. package/dist/extension/execPolicy.d.ts +135 -0
  39. package/dist/extension/execPolicy.js +936 -0
  40. package/dist/extension/flywheel.d.ts +44 -0
  41. package/dist/extension/flywheel.js +53 -0
  42. package/dist/extension/footer.d.ts +118 -0
  43. package/dist/extension/footer.js +308 -0
  44. package/dist/extension/guardian.d.ts +139 -0
  45. package/dist/extension/guardian.js +237 -0
  46. package/dist/extension/index.d.ts +35 -7
  47. package/dist/extension/index.js +338 -33
  48. package/dist/extension/mineBeat.d.ts +95 -0
  49. package/dist/extension/mineBeat.js +193 -0
  50. package/dist/extension/permission.d.ts +124 -10
  51. package/dist/extension/permission.js +603 -52
  52. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  53. package/dist/extension/pipeline/childRegistry.js +118 -0
  54. package/dist/extension/pipeline/finish.js +5 -1
  55. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  56. package/dist/extension/pipeline/goCommand.js +40 -9
  57. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  58. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  59. package/dist/extension/pipeline/personas.js +26 -1
  60. package/dist/extension/pipeline/resilience.d.ts +2 -1
  61. package/dist/extension/pipeline/resilience.js +21 -2
  62. package/dist/extension/pipeline/runRegistry.d.ts +23 -1
  63. package/dist/extension/pipeline/runRegistry.js +57 -1
  64. package/dist/extension/pipeline/runner.js +4 -0
  65. package/dist/extension/pipeline/verify.d.ts +4 -0
  66. package/dist/extension/pipeline/verify.js +48 -26
  67. package/dist/extension/recordDecisionTool.d.ts +8 -0
  68. package/dist/extension/recordDecisionTool.js +24 -0
  69. package/dist/extension/redact.d.ts +20 -0
  70. package/dist/extension/redact.js +64 -0
  71. package/dist/extension/rerouteNotice.d.ts +3 -4
  72. package/dist/extension/rerouteNotice.js +20 -11
  73. package/dist/extension/subagentRender.d.ts +129 -0
  74. package/dist/extension/subagentRender.js +441 -0
  75. package/dist/extension/subagents.d.ts +11 -8
  76. package/dist/extension/subagents.js +162 -37
  77. package/dist/extension/ticketTools.d.ts +37 -0
  78. package/dist/extension/ticketTools.js +117 -0
  79. package/dist/extension/todos.d.ts +28 -1
  80. package/dist/extension/todos.js +76 -1
  81. package/dist/extension/tokenProvider.js +46 -5
  82. package/dist/extension/ultra.d.ts +27 -0
  83. package/dist/extension/ultra.js +76 -0
  84. package/dist/launch.d.ts +7 -0
  85. package/dist/launch.js +24 -12
  86. package/dist/login.d.ts +4 -2
  87. package/dist/login.js +19 -4
  88. package/dist/padding.d.ts +22 -0
  89. package/dist/padding.js +25 -0
  90. package/dist/promptEnrichment.d.ts +40 -0
  91. package/dist/promptEnrichment.js +85 -0
  92. package/dist/signalForward.d.ts +60 -0
  93. package/dist/signalForward.js +130 -0
  94. package/dist/token.d.ts +25 -0
  95. package/dist/token.js +45 -0
  96. package/package.json +6 -5
  97. package/dist/extension/boostCommand.d.ts +0 -144
  98. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,33 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ export interface PendingCompletion {
3
+ lastAssistantMessage?: string;
4
+ notificationType: string;
5
+ turnId: string;
6
+ }
7
+ export interface SessionState {
8
+ nextTurn: number;
9
+ activeTurnId?: string;
10
+ pendingCompletion?: PendingCompletion;
11
+ stopped: boolean;
12
+ lastPrompt?: string;
13
+ hasNamed?: boolean;
14
+ }
15
+ export interface PiExtensionContextSnapshot {
16
+ readonly sessionId: string | null;
17
+ readonly cwd: string;
18
+ readonly notifyWarning?: () => void;
19
+ }
20
+ export declare function firstString(...values: unknown[]): string | null;
21
+ export declare function objectValue(value: unknown, keys: string[]): unknown;
22
+ export declare function textFromContent(content: unknown): string | null;
23
+ export declare function lastAssistantMessage(event: unknown): string | undefined;
24
+ export declare function sessionIdFrom(ctx: ExtensionContext): string | null;
25
+ export declare function cwdFrom(ctx: ExtensionContext): string;
26
+ export declare function snapshotContext(ctx: ExtensionContext): PiExtensionContextSnapshot;
27
+ export declare function stateFor(sessionStates: Map<string, SessionState>, sessionId: string): SessionState;
28
+ export declare function beginTurn(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string;
29
+ export declare function currentTurnId(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string;
30
+ export declare function finishTurn(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string;
31
+ export declare function settleTurn(sessionStates: Map<string, SessionState>, sessionId: string): PendingCompletion | undefined;
32
+ export declare function warn(ctx: PiExtensionContextSnapshot | null, message: string, details?: Record<string, unknown>, notifyUser?: boolean): void;
33
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1,142 @@
1
+ export function firstString(...values) {
2
+ for (const value of values) {
3
+ if (typeof value === "string" && value.trim().length > 0)
4
+ return value.trim();
5
+ }
6
+ return null;
7
+ }
8
+ export function objectValue(value, keys) {
9
+ if (!value || typeof value !== "object")
10
+ return undefined;
11
+ const typed = value;
12
+ for (const key of keys) {
13
+ if (typed[key] !== undefined && typed[key] !== null)
14
+ return typed[key];
15
+ }
16
+ return undefined;
17
+ }
18
+ export function textFromContent(content) {
19
+ if (typeof content === "string")
20
+ return content;
21
+ if (!Array.isArray(content))
22
+ return null;
23
+ const parts = [];
24
+ for (const block of content) {
25
+ if (!block || typeof block !== "object")
26
+ continue;
27
+ const typed = block;
28
+ if (typed.type === "text" && typeof typed.text === "string")
29
+ parts.push(typed.text);
30
+ }
31
+ return parts.join("\n") || null;
32
+ }
33
+ export function lastAssistantMessage(event) {
34
+ const messagesValue = objectValue(event, ["messages"]);
35
+ const messages = Array.isArray(messagesValue) ? messagesValue : [];
36
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
37
+ const message = messages[index];
38
+ if (!message || typeof message !== "object")
39
+ continue;
40
+ const typed = message;
41
+ if (typed.role !== "assistant")
42
+ continue;
43
+ const text = firstString(textFromContent(typed.content));
44
+ if (text)
45
+ return text;
46
+ }
47
+ return undefined;
48
+ }
49
+ export function sessionIdFrom(ctx) {
50
+ try {
51
+ return firstString(ctx.sessionManager?.getSessionId?.());
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export function cwdFrom(ctx) {
58
+ try {
59
+ return firstString(ctx.cwd, process.cwd()) || process.cwd();
60
+ }
61
+ catch {
62
+ return process.cwd();
63
+ }
64
+ }
65
+ export function snapshotContext(ctx) {
66
+ if (!ctx || typeof ctx !== "object") {
67
+ return { sessionId: null, cwd: process.cwd() };
68
+ }
69
+ let notifyWarning;
70
+ try {
71
+ const ui = ctx.ui;
72
+ if (typeof ui?.notify === "function") {
73
+ notifyWarning = () => ui.notify?.("cmux integration warning — check the terminal for details", "warning");
74
+ }
75
+ }
76
+ catch { }
77
+ return { sessionId: sessionIdFrom(ctx), cwd: cwdFrom(ctx), notifyWarning };
78
+ }
79
+ export function stateFor(sessionStates, sessionId) {
80
+ let state = sessionStates.get(sessionId);
81
+ if (!state) {
82
+ state = { nextTurn: 0, stopped: false };
83
+ sessionStates.set(sessionId, state);
84
+ }
85
+ return state;
86
+ }
87
+ function eventTurnId(event) {
88
+ return firstString(objectValue(event, ["turn_id", "turnId", "turnID"]));
89
+ }
90
+ export function beginTurn(sessionStates, sessionId, event) {
91
+ const state = stateFor(sessionStates, sessionId);
92
+ const turnId = eventTurnId(event) || `${sessionId}:turn-${state.nextTurn + 1}`;
93
+ if (!eventTurnId(event))
94
+ state.nextTurn += 1;
95
+ state.activeTurnId = turnId;
96
+ state.pendingCompletion = undefined;
97
+ state.stopped = false;
98
+ return turnId;
99
+ }
100
+ export function currentTurnId(sessionStates, sessionId, event) {
101
+ const state = stateFor(sessionStates, sessionId);
102
+ const turnId = eventTurnId(event) || state.activeTurnId || `${sessionId}:turn-${state.nextTurn + 1}`;
103
+ if (!eventTurnId(event) && !state.activeTurnId)
104
+ state.nextTurn += 1;
105
+ return turnId;
106
+ }
107
+ export function finishTurn(sessionStates, sessionId, event) {
108
+ const state = stateFor(sessionStates, sessionId);
109
+ const turnId = eventTurnId(event) || state.activeTurnId || `${sessionId}:turn-${state.nextTurn + 1}`;
110
+ if (!eventTurnId(event) && !state.activeTurnId)
111
+ state.nextTurn += 1;
112
+ state.activeTurnId = undefined;
113
+ state.pendingCompletion = undefined;
114
+ state.stopped = true;
115
+ return turnId;
116
+ }
117
+ export function settleTurn(sessionStates, sessionId) {
118
+ const state = sessionStates.get(sessionId);
119
+ const completion = state?.pendingCompletion;
120
+ if (!state || !completion || state.stopped)
121
+ return undefined;
122
+ state.activeTurnId = undefined;
123
+ state.pendingCompletion = undefined;
124
+ state.stopped = true;
125
+ return completion;
126
+ }
127
+ export function warn(ctx, message, details = {}, notifyUser = false) {
128
+ const payload = { source: "yagni-cmux-bridge", level: "warning", message, ...details };
129
+ try {
130
+ console.warn(JSON.stringify(payload));
131
+ }
132
+ catch {
133
+ console.warn(`[yagni-cmux-bridge] ${message}`);
134
+ }
135
+ if (notifyUser) {
136
+ try {
137
+ ctx?.notifyWarning?.();
138
+ }
139
+ catch { }
140
+ }
141
+ }
142
+ //# sourceMappingURL=state.js.map
@@ -72,13 +72,32 @@ export interface FetchCatalogOptions {
72
72
  getToken: () => string | undefined;
73
73
  fetchImpl?: typeof fetch;
74
74
  }
75
+ /** Guardian storage tier for this workspace (YAG-510). */
76
+ export type GuardianStorageTier = "off" | "hash" | "raw";
77
+ /** The startup catalog response from GET /api/yagni-code/models. */
78
+ export interface CatalogResult {
79
+ models: ModelEntry[];
80
+ /**
81
+ * Per-workspace Guardian kill switch (yagni_code.guardian). Fail-safe:
82
+ * only an explicit `false` from the backend disables the Guardian — a
83
+ * missing field (older backend) reads as enabled.
84
+ */
85
+ guardianEnabled: boolean;
86
+ /**
87
+ * Guardian event storage tier (YAG-510). Polarity is deliberately the
88
+ * OPPOSITE of guardianEnabled: only an explicit "hash" or "raw" turns
89
+ * storage on — a missing field (older backend) or unknown value reads as
90
+ * "off" (fail toward not sending).
91
+ */
92
+ guardianStorage: GuardianStorageTier;
93
+ }
75
94
  /**
76
95
  * Fetch the YAGNI model catalog at startup.
77
96
  *
78
97
  * @throws an actionable Error (mentioning `yagni login`) on any non-2xx
79
98
  * response so the launcher can surface a clear re-authentication prompt.
80
99
  */
81
- export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<ModelEntry[]>;
100
+ export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<CatalogResult>;
82
101
  /** The startup company brief returned by GET /api/yagni-code/context. */
83
102
  export interface ContextBrief {
84
103
  brief: string;
@@ -94,6 +113,12 @@ export interface ContextBrief {
94
113
  decisions: number;
95
114
  corrections: number;
96
115
  };
116
+ /**
117
+ * Active decisions scoped to the repo named on the request (additive, Run
118
+ * 7; absent without `?repo=`, on older backends, and on a count failure).
119
+ * The opt-in mining beat fires ONLY on an explicit 0.
120
+ */
121
+ repoDecisionCount?: number;
97
122
  }
98
123
  /**
99
124
  * Attribution headers for the model proxy (YAG-471). On the SERVER side these
@@ -156,11 +181,29 @@ export declare function isDriverCaller(env?: NodeJS.ProcessEnv): boolean;
156
181
  * sanitizing client-side is what keeps the attribution instead of losing it.
157
182
  */
158
183
  export declare function sanitizeCallerSegment(name: string, maxLength?: number): string;
184
+ /**
185
+ * Attribution headers for the boot-time /context fetch. Extends
186
+ * {@link attributionHeaders} (session id + caller) with the surface the
187
+ * session is painting for (the desktop driver sets YAGNI_SURFACE=desktop;
188
+ * the terminal CLI sets nothing) and the shipping client version. The server
189
+ * uses these to emit its "YAGNI Code Session Started" analytics event —
190
+ * labels only on that side: a missing or malformed value can never fail the
191
+ * fetch, it just goes uncounted or unversioned.
192
+ */
193
+ export declare function sessionTelemetryHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
159
194
  /** Options for {@link fetchContextBrief}. */
160
195
  export interface FetchContextBriefOptions {
161
196
  baseUrl: string;
162
197
  getToken: () => string | undefined;
163
198
  fetchImpl?: typeof fetch;
199
+ /** Env seam for the telemetry headers; defaults to process.env. */
200
+ env?: NodeJS.ProcessEnv;
201
+ /**
202
+ * The session repo (`owner/name`), when resolvable. Rides as `?repo=` so
203
+ * the response can carry `repoDecisionCount` for the mining beat — no
204
+ * second round-trip at boot.
205
+ */
206
+ repo?: string;
164
207
  }
165
208
  /**
166
209
  * Fetch the workspace company brief at startup.
@@ -85,7 +85,13 @@ export async function fetchCatalog(opts) {
85
85
  throw new Error(`Failed to fetch YAGNI model catalog (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`);
86
86
  }
87
87
  const data = (await res.json());
88
- return data.models;
88
+ return {
89
+ models: data.models,
90
+ guardianEnabled: data.guardianEnabled !== false,
91
+ guardianStorage: data.guardianStorage === "raw" || data.guardianStorage === "hash"
92
+ ? data.guardianStorage
93
+ : "off",
94
+ };
89
95
  }
90
96
  /** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
91
97
  const CALLER_LABEL_RE = /^[a-z0-9][a-z0-9:_.-]{0,63}$/i;
@@ -129,8 +135,6 @@ export function attributionHeaders(env = process.env) {
129
135
  headers["x-yagni-run-id"] = runId;
130
136
  const caller = env.YAGNI_CALLER ?? "";
131
137
  headers["x-yagni-caller"] = CALLER_LABEL_RE.test(caller) ? caller : "driver";
132
- if (env.YAGNI_BOOST === "1")
133
- headers["x-yagni-boost"] = "1";
134
138
  return headers;
135
139
  }
136
140
  /**
@@ -173,6 +177,28 @@ export function sanitizeCallerSegment(name, maxLength = 64) {
173
177
  const trimmed = cleaned.slice(0, Math.max(0, maxLength));
174
178
  return trimmed || "agent";
175
179
  }
180
+ /**
181
+ * Shape-check for a client version label (semver-ish; matches the server's
182
+ * own allowlist). A value that fails is dropped, never sent raw.
183
+ */
184
+ const CLIENT_VERSION_RE = /^[0-9a-z][0-9a-z.+-]{0,31}$/i;
185
+ /**
186
+ * Attribution headers for the boot-time /context fetch. Extends
187
+ * {@link attributionHeaders} (session id + caller) with the surface the
188
+ * session is painting for (the desktop driver sets YAGNI_SURFACE=desktop;
189
+ * the terminal CLI sets nothing) and the shipping client version. The server
190
+ * uses these to emit its "YAGNI Code Session Started" analytics event —
191
+ * labels only on that side: a missing or malformed value can never fail the
192
+ * fetch, it just goes uncounted or unversioned.
193
+ */
194
+ export function sessionTelemetryHeaders(env = process.env) {
195
+ const headers = attributionHeaders(env);
196
+ headers["x-yagni-surface"] = env.YAGNI_SURFACE === "desktop" ? "desktop" : "cli";
197
+ const version = env.YAGNI_CODE_VERSION ?? "";
198
+ if (CLIENT_VERSION_RE.test(version))
199
+ headers["x-yagni-client-version"] = version;
200
+ return headers;
201
+ }
176
202
  /**
177
203
  * Fetch the workspace company brief at startup.
178
204
  *
@@ -182,7 +208,14 @@ export function sanitizeCallerSegment(name, maxLength = 64) {
182
208
  */
183
209
  export async function fetchContextBrief(opts) {
184
210
  try {
185
- const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context`, { method: "GET", headers: { authorization: `Bearer ${opts.getToken() ?? ""}` } }, { fetchImpl: opts.fetchImpl });
211
+ const query = opts.repo ? `?repo=${encodeURIComponent(opts.repo)}` : "";
212
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context${query}`, {
213
+ method: "GET",
214
+ headers: {
215
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
216
+ ...sessionTelemetryHeaders(opts.env),
217
+ },
218
+ }, { fetchImpl: opts.fetchImpl });
186
219
  if (!res.ok)
187
220
  return null;
188
221
  return (await res.json());
@@ -52,7 +52,20 @@ export interface Headroom {
52
52
  remaining: number;
53
53
  unit: string;
54
54
  }
55
- export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string, source?: string, boosted?: boolean): string;
55
+ /**
56
+ * Render the /cost line. Pure, no em-dashes. `headroom` null means it was
57
+ * unavailable. `advisorLine` is the ask_advisor subtotal (empty when the session
58
+ * never escalated). `source`, when given, is parenthesized right after "Session
59
+ * usage", used to explicitly label this as the LOCAL, driver-only fallback when
60
+ * the server-authoritative spend fetch is unavailable (see
61
+ * {@link formatServerCostLines}, the preferred path).
62
+ *
63
+ * HONESTY NOTE: this counter can only see the DRIVER session, because it is fed
64
+ * by pi's `turn_end`. Work that runs in a child process — every /go stage, and
65
+ * every advisor consult — never emits a parent `turn_end`, so it is invisible
66
+ * here. The advisor subtotal is threaded in explicitly for exactly that reason.
67
+ */
68
+ export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string, source?: string): string;
56
69
  /**
57
70
  * One row of `GET /api/yagni-code/spend`'s per-caller x rate-tier breakdown.
58
71
  * Mirrored locally rather than importing `@yagni/shared`, since this extension
@@ -102,25 +115,15 @@ export declare const usd: (millicents: number) => string;
102
115
  * (joined with "\n").
103
116
  *
104
117
  * Line order: total spend, one line per rate tier (aggregated across callers,
105
- * sorted by spend descending), a boosted-spend subtotal per tier that has any
106
- * boosted rows, a live-toggle boost line (see below), the savings line (only
118
+ * sorted by spend descending), the savings line (only
107
119
  * when a counterfactual total resolved), an incomplete-counterfactual note,
108
120
  * an unbilled note, a dropped-run-ids note (carry-over from the /cost
109
121
  * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
110
122
  *
111
123
  * `droppedRunCount` defaults to 0 (no note) so every existing direct caller
112
124
  * of this pure function keeps behaving identically without passing it.
113
- *
114
- * `boosted` (default false) is the LIVE client-side toggle from
115
- * `boostCommand.ts`'s `isBoosted()`, threaded in exactly like `advisorLine`
116
- * is for {@link formatCostLine}. It is independent of the per-tier "Boosted
117
- * (tier) spend" subtotal above: that subtotal only reflects server rows
118
- * carrying `x-yagni-boost`, which only `/go` children (and other spawned
119
- * processes) ever send — the driver's own turns never do (see
120
- * boostCommand.ts's KNOWN-asymmetry docblock). Without this flag, a session
121
- * that only chats while boosted would show no boost line at all.
122
125
  */
123
- export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number, boosted?: boolean): string;
126
+ export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number): string;
124
127
  export interface RegisterCostDeps {
125
128
  /** Fetch remaining credit headroom; return null when unavailable. Fail-soft. */
126
129
  fetchHeadroom?: (signal?: AbortSignal) => Promise<Headroom | null>;
@@ -132,15 +135,6 @@ export interface RegisterCostDeps {
132
135
  * ordinary caller row.
133
136
  */
134
137
  advisorSubtotal?: () => string;
135
- /**
136
- * Whether the session is currently boosted to Peak (boostCommand.ts's
137
- * `isBoosted()`, threaded in the same way as `advisorSubtotal`). Unlike
138
- * `advisorSubtotal`, this is read on BOTH the server-authoritative and the
139
- * local-fallback branch: the driver's own turns never carry a server-side
140
- * boost marker (see boostCommand.ts's KNOWN-asymmetry docblock), so this is
141
- * the only signal that would otherwise be missing from the server branch.
142
- */
143
- isBoosted?: () => boolean;
144
138
  /**
145
139
  * Fetch the server-authoritative session spend (YAG-383). Absent, throwing,
146
140
  * or resolving null all fall back to the local `turn_end` accumulator, with
@@ -75,21 +75,18 @@ const fmt = (n) => n.toLocaleString("en-US");
75
75
  * every advisor consult — never emits a parent `turn_end`, so it is invisible
76
76
  * here. The advisor subtotal is threaded in explicitly for exactly that reason.
77
77
  */
78
- /** The boost-active line appended to /cost, both branches (see boostCommand.ts's docblock for why). */
79
- const BOOST_LINE = "Boost is on. Driver turns bill at the peak tier.";
80
- export function formatCostLine(snap, headroom, advisorLine, source, boosted = false) {
78
+ export function formatCostLine(snap, headroom, advisorLine, source) {
81
79
  const turns = `${snap.turns} turn${snap.turns === 1 ? "" : "s"}`;
82
80
  const cached = snap.cacheRead > 0 ? ` (${fmt(snap.cacheRead)} cached)` : "";
83
81
  const label = source ? `Session usage (${source})` : "Session usage";
84
82
  const base = `${label}: ${turns}, ${fmt(snap.input)} in / ${fmt(snap.output)} out tokens${cached}, ` +
85
83
  `$${snap.cost.toFixed(2)} this session.`;
86
84
  const advisor = advisorLine?.trim() ? ` ${advisorLine.trim()}` : "";
87
- const boost = boosted ? ` ${BOOST_LINE}` : "";
88
85
  if (headroom)
89
- return `${base}${advisor}${boost} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
86
+ return `${base}${advisor} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
90
87
  if (headroom === null)
91
- return `${base}${advisor}${boost} Credit headroom unavailable right now.`;
92
- return `${base}${advisor}${boost}`;
88
+ return `${base}${advisor} Credit headroom unavailable right now.`;
89
+ return `${base}${advisor}`;
93
90
  }
94
91
  /**
95
92
  * Millicents (1/1000 of a cent) -> dollars, 2 decimals. Guards against a
@@ -115,25 +112,15 @@ export const usd = (millicents) => {
115
112
  * (joined with "\n").
116
113
  *
117
114
  * Line order: total spend, one line per rate tier (aggregated across callers,
118
- * sorted by spend descending), a boosted-spend subtotal per tier that has any
119
- * boosted rows, a live-toggle boost line (see below), the savings line (only
115
+ * sorted by spend descending), the savings line (only
120
116
  * when a counterfactual total resolved), an incomplete-counterfactual note,
121
117
  * an unbilled note, a dropped-run-ids note (carry-over from the /cost
122
118
  * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
123
119
  *
124
120
  * `droppedRunCount` defaults to 0 (no note) so every existing direct caller
125
121
  * of this pure function keeps behaving identically without passing it.
126
- *
127
- * `boosted` (default false) is the LIVE client-side toggle from
128
- * `boostCommand.ts`'s `isBoosted()`, threaded in exactly like `advisorLine`
129
- * is for {@link formatCostLine}. It is independent of the per-tier "Boosted
130
- * (tier) spend" subtotal above: that subtotal only reflects server rows
131
- * carrying `x-yagni-boost`, which only `/go` children (and other spawned
132
- * processes) ever send — the driver's own turns never do (see
133
- * boostCommand.ts's KNOWN-asymmetry docblock). Without this flag, a session
134
- * that only chats while boosted would show no boost line at all.
135
122
  */
136
- export function formatServerCostLines(spend, headroom, droppedRunCount = 0, boosted = false) {
123
+ export function formatServerCostLines(spend, headroom, droppedRunCount = 0) {
137
124
  const lines = [`Session spend: $${usd(spend.totalSellMillicents)} (server).`];
138
125
  const tierTotals = new Map();
139
126
  for (const row of spend.rows) {
@@ -147,23 +134,6 @@ export function formatServerCostLines(spend, headroom, droppedRunCount = 0, boos
147
134
  const calls = `${fmt(agg.dispatches)} call${agg.dispatches === 1 ? "" : "s"}`;
148
135
  lines.push(` ${tier}: $${usd(agg.sellMillicents)} over ${calls}.`);
149
136
  }
150
- const boostTotals = new Map();
151
- for (const row of spend.rows) {
152
- if (!row.boost)
153
- continue;
154
- boostTotals.set(row.rateTier, (boostTotals.get(row.rateTier) ?? 0) + row.sellMillicents);
155
- }
156
- if (boostTotals.size > 0) {
157
- for (const [tier] of tiersSorted) {
158
- const tierBoosted = boostTotals.get(tier);
159
- if (tierBoosted === undefined)
160
- continue;
161
- lines.push(` Boosted (${tier}) spend: $${usd(tierBoosted)}.`);
162
- }
163
- }
164
- if (boosted) {
165
- lines.push(BOOST_LINE);
166
- }
167
137
  // typeof guard (not just !== null): a network response is untyped at
168
138
  // runtime, and a stray string/boolean here must never sneak "NN%" into the
169
139
  // rendered line.
@@ -233,15 +203,6 @@ export function registerCostCommand(pi, deps = {}) {
233
203
  : Promise.resolve(null),
234
204
  ]);
235
205
  const localSnap = acc.snapshot();
236
- // Read fresh on every /cost call, both branches (see RegisterCostDeps's
237
- // isBoosted doc comment for why the local fallback needs it too).
238
- let boosted = false;
239
- try {
240
- boosted = deps.isBoosted?.() ?? false;
241
- }
242
- catch {
243
- /* a diagnostic must never break /cost */
244
- }
245
206
  // Quiet divergence check: both totals must be available, and it never
246
207
  // affects what the user sees. Scoped to `caller === "driver"` rows only
247
208
  // (like-for-like with the local `turn_end` accumulator, which can only
@@ -276,7 +237,7 @@ export function registerCostCommand(pi, deps = {}) {
276
237
  catch {
277
238
  /* a diagnostic must never break /cost */
278
239
  }
279
- await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped, boosted));
240
+ await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped));
280
241
  return;
281
242
  }
282
243
  let advisorLine = "";
@@ -286,7 +247,7 @@ export function registerCostCommand(pi, deps = {}) {
286
247
  catch {
287
248
  /* usage accounting must never break /cost */
288
249
  }
289
- await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only", boosted));
250
+ await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only"));
290
251
  },
291
252
  });
292
253
  }
@@ -125,8 +125,6 @@ export function makeCrashReporter(opts) {
125
125
  if (crashReportsDisabled(env))
126
126
  return;
127
127
  const token = opts.getToken();
128
- if (!token)
129
- return;
130
128
  const sanitized = sanitizeCrashError(error, { env, repoRoot });
131
129
  const payload = {
132
130
  client: isDesktopSurface() ? "desktop" : "cli",
@@ -148,7 +146,7 @@ export function makeCrashReporter(opts) {
148
146
  method: "POST",
149
147
  headers: {
150
148
  "content-type": "application/json",
151
- authorization: `Bearer ${token}`,
149
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
152
150
  },
153
151
  body: JSON.stringify(payload),
154
152
  signal: controller.signal,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Exec policy engine — classifies bash commands via prefix rules + lightweight
3
+ * shell tokenization (YAG-504, restructured in YAG-510).
4
+ *
5
+ * Pure: no I/O, no network, no model. Loads at startup and classifies
6
+ * synchronously. The curated default set auto-allows read-only commands
7
+ * (ls, cat, rg, git status/log/diff), forbids destructive ones (rm -rf,
8
+ * git reset --hard, git push --force, pipe-to-shell), and prompts for the
9
+ * ambiguous middle band (npm install, git commit, curl, …).
10
+ *
11
+ * The `prompt` band is what the Guardian arbitrates — see guardian.ts.
12
+ *
13
+ * Classification composes three signals and takes the STRICTEST:
14
+ * 1. prefix-rule matching on every segment (newlines, ;, &&, ||, | split);
15
+ * 2. a construct floor — commands using redirects, substitution, or
16
+ * background & can never be auto-allowed (floor: prompt);
17
+ * 3. dangerScan — a best-effort sweep of command-substitution inner text
18
+ * ($(...) and backticks, including inside double quotes) against the
19
+ * FORBIDDEN rules only. Danger anywhere upgrades to forbidden; the scan
20
+ * can never make anything more permissive.
21
+ * This is the codex two-parser lesson: fail closed to prove safety, scan
22
+ * best-effort to prove danger. A forbidden match must win even when the
23
+ * command also carries constructs (`rm -rf / &` is forbidden, not prompt).
24
+ *
25
+ * Command words are matched through a leading-token strip (env assignments,
26
+ * sudo/env/command wrappers, shell reserved words, a leading backslash) and
27
+ * basename normalization (/bin/rm → rm) — both applied ASYMMETRICALLY: they
28
+ * can make a command land on forbidden/prompt rules, but a stripped or
29
+ * path-prefixed command is never auto-allowed (`sudo ls` and `./ls` stay in
30
+ * the prompt band; an attacker-named local `./rm` binary must not ride the
31
+ * allow list, and `/bin/ls` pays the same price by design).
32
+ *
33
+ * Tokenization is a lightweight inline parser — not shell-quote — because the
34
+ * extension is bundled into @yagni-app/code's dist (a file copy, not a real
35
+ * bundler), and external dependencies aren't resolvable from the bundled path.
36
+ */
37
+ export type TokenEntry = string | {
38
+ op: "pipe" | "and" | "or" | "semi" | "substitution";
39
+ } | {
40
+ op: "redirect";
41
+ direction: "out";
42
+ fd: "stdout" | "stderr";
43
+ target: string;
44
+ append: boolean;
45
+ } | {
46
+ op: "redirect";
47
+ direction: "in";
48
+ } | {
49
+ op: "background";
50
+ };
51
+ /**
52
+ * Parse a shell command string into tokens and control operators.
53
+ *
54
+ * Handles:
55
+ * - Single and double quoted strings (preserves spaces inside)
56
+ * - Control operators: |, &&, ||, ;, and newlines (a newline separates
57
+ * commands exactly like `;` — treating it as whitespace let multiline
58
+ * commands smuggle anything behind an allow-listed first line)
59
+ * - `#` comments (start-of-word to end-of-line, outside quotes)
60
+ * - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
61
+ * inside double quotes — bash executes those), >, <, background &
62
+ * - Redirect metadata: stdout/stderr redirects carry fd + target so that
63
+ * safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
64
+ * (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
65
+ * floor. Background & emits a distinct `background` op (not `semi`) so
66
+ * hasUnhandledConstructs can always catch it.
67
+ *
68
+ * Does NOT handle: variable expansion, glob patterns, heredocs beyond the
69
+ * redirect flag, nested subshells beyond depth tracking. Commands using
70
+ * those are classified as "prompt" at minimum (construct floor).
71
+ */
72
+ export declare function shellParse(command: string): TokenEntry[];
73
+ /**
74
+ * Extract the inner text of every command substitution — $(...) and
75
+ * backticks — respecting single-quote literalness and backslash escapes.
76
+ * Includes substitutions inside double quotes (bash executes those).
77
+ * Best-effort, used ONLY by dangerScan to prove danger, never safety.
78
+ */
79
+ export declare function extractSubstitutions(command: string): string[];
80
+ export type ExecDecision = "allow" | "prompt" | "forbidden";
81
+ export interface PrefixRule {
82
+ /** Ordered tokens; a string[] element means alternatives (any match). */
83
+ pattern: (string | string[])[];
84
+ decision: ExecDecision;
85
+ justification: string;
86
+ /**
87
+ * Escape hatch for allow rules whose command has a mutating flag: if any
88
+ * token AFTER the matched prefix equals one of these (or, for entries ending
89
+ * in "*", starts with the part before the star), the rule does NOT match and
90
+ * evaluation falls through to later rules (usually landing in the prompt
91
+ * band). Example: sed is read-only except with -i/--in-place.
92
+ */
93
+ unlessTokens?: string[];
94
+ /**
95
+ * Position-independent flag requirement: the rule matches only when, in
96
+ * addition to the pattern prefix, at least one token AFTER the prefix
97
+ * matches an entry (same "*"-suffix glob convention as unlessTokens).
98
+ * Used by forbidden rules to catch permuted flags: `git push origin
99
+ * --force` and `rm x -rf` place the dangerous flag after positional args,
100
+ * where exact-position patterns never see it.
101
+ */
102
+ flagsAnywhere?: string[];
103
+ /** Positive test invocations (validated at load if present). */
104
+ match?: string[][];
105
+ /** Negative test invocations (validated at load if present). */
106
+ notMatch?: string[][];
107
+ }
108
+ export interface ExecPolicy {
109
+ rules: PrefixRule[];
110
+ }
111
+ export interface ExecClassification {
112
+ decision: ExecDecision;
113
+ justification: string;
114
+ matchedRule?: PrefixRule;
115
+ }
116
+ /**
117
+ * Parse a command string into tokens using our lightweight tokenizer. Returns
118
+ * string tokens only (control operators and constructs are filtered out —
119
+ * detected separately).
120
+ */
121
+ export declare function tokenize(command: string): string[];
122
+ /**
123
+ * Classify a full bash command string against the exec policy.
124
+ *
125
+ * Compound commands (pipes, &&, ||, ;, newlines) are split into segments and
126
+ * each is classified independently; the strictest decision wins (forbidden >
127
+ * prompt > allow). Commands with shell constructs (substitution, redirects,
128
+ * background &) have a floor of `prompt`, and their substitution inner text
129
+ * is danger-scanned against the forbidden rules. Pipe-to-shell is always
130
+ * forbidden.
131
+ */
132
+ export declare function classifyCommand(command: string, policy: ExecPolicy): ExecClassification;
133
+ /** Curated default rules — the shipped safety floor. */
134
+ export declare const DEFAULT_EXEC_POLICY: ExecPolicy;
135
+ //# sourceMappingURL=execPolicy.d.ts.map