@yagni-app/code 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -12
  59. package/dist/extension/rerouteNotice.js +36 -15
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Custom footer for the YAGNI CLI — replaces pi's built-in footer.
3
+ *
4
+ * Renders three lines:
5
+ * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
+ * 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
7
+ * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
+ *
9
+ * --- How to customize the status bar (for future tickets) ---
10
+ *
11
+ * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
12
+ * The `pi` (ExtensionAPI) object passed to the factory has no UI methods —
13
+ * they live on `ctx.ui` (ExtensionUIContext), which is only bound after
14
+ * `_applyExtensionBindings` runs, immediately before the `session_start`
15
+ * event. So the footer must be set inside a `pi.on("session_start", ...)`
16
+ * handler, guarded by `ctx.mode === "tui"` (no footer in RPC/print mode).
17
+ * See index.ts's session_start handler for the wiring, and pi's
18
+ * `docs/extensions.md` § "Widgets, Status, and Footer" + the `custom-header.ts`
19
+ * example for the canonical pattern.
20
+ *
21
+ * FACTORY SIGNATURE: `setFooter((tui, theme, footerData) => Component)` where:
22
+ * - `tui` — the TUI instance (screen dimensions, focus)
23
+ * - `theme` — the current Theme (use `theme.fg("dim", text)` etc.)
24
+ * - `footerData` — ReadonlyFooterDataProvider: `getGitBranch()`,
25
+ * `getExtensionStatuses()` (statuses set via `ctx.ui.setStatus(key, text)`),
26
+ * `getAvailableProviderCount()`
27
+ * Model info, token stats, and context usage are NOT on `footerData` — they're
28
+ * on `ctx` (the ExtensionContext passed to the session_start handler):
29
+ * `ctx.model`, `ctx.sessionManager`, `ctx.getContextUsage()`. Thread them
30
+ * through a closure if the footer needs them (this module's
31
+ * `createYagniFooterFactory` does exactly that).
32
+ *
33
+ * COMPONENT CONTRACT: the returned object needs `render(width: number): string[]`
34
+ * (returns the lines to display, one string per row) and `invalidate()` (called
35
+ * when the component should re-render). Optionally `dispose()` for cleanup.
36
+ *
37
+ * REFERENCE: pi's built-in footer lives at
38
+ * `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
39
+ * — useful reference for what data to replicate and how to format it.
40
+ */
41
+ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
+ import type { ModeHolder, PermissionMode } from "./permission.js";
43
+ export declare const BRANCH_MAX_WIDTH = 60;
44
+ /**
45
+ * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
46
+ * aligns with the editor input and the chat/output area on one shared column.
47
+ * The launcher seeds the same value as pi's `editorPaddingX`; this footer can't
48
+ * read pi settings, so the value crosses over env. Falls back to the output
49
+ * area's default (1) when unset/invalid. Clamped to pi's 0–3 editor range.
50
+ */
51
+ export declare function resolveFooterPadX(raw?: string | undefined): number;
52
+ /** Format token counts for compact footer display (mirrors pi's formatTokens). */
53
+ export declare function formatTokens(count: number): string;
54
+ /** Shorten cwd relative to home, like pi's built-in footer. */
55
+ export declare function formatCwd(cwd: string, home: string | undefined): string;
56
+ interface UsageTotals {
57
+ input: number;
58
+ output: number;
59
+ cacheRead: number;
60
+ cacheWrite: number;
61
+ cost: number;
62
+ }
63
+ /** Accumulate usage from all session entries (mirrors pi's built-in footer). */
64
+ export declare function collectUsage(sessionManager: ExtensionContext["sessionManager"]): UsageTotals;
65
+ export interface GitInfo {
66
+ /** Repo-root folder basename (the MAIN repo, stable across worktrees), or ~-path off-repo. */
67
+ folder: string;
68
+ inRepo: boolean;
69
+ /** Current branch, null off-repo. "detached" on detached HEAD. */
70
+ branch: string | null;
71
+ /** Linked-worktree label for [brackets], or null to hide. */
72
+ worktree: string | null;
73
+ }
74
+ /** Git probes `resolveWorktreeLabel` needs, injectable so the rules can be unit-tested. */
75
+ export interface WorktreeProbes {
76
+ isLinkedWorktree(repoRoot: string): boolean;
77
+ worktrees(repoRoot: string): string[];
78
+ }
79
+ /**
80
+ * Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
81
+ * >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
82
+ * context and earns no label; a [bracket] only disambiguates a secondary working tree.
83
+ */
84
+ export declare function resolveWorktreeLabel(repoRoot: string, branch: string | null, probes?: WorktreeProbes): string | null;
85
+ /**
86
+ * Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
87
+ * binary, and corrupt repos all degrade to a safe partial/empty result.
88
+ */
89
+ export declare function detectGitInfo(cwd: string, home: string | undefined): GitInfo;
90
+ /** Pure line-builder, exported for tests. All data injected; colors via theme. */
91
+ export declare function renderFooterLines(input: {
92
+ git: GitInfo;
93
+ model: string;
94
+ /** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
95
+ mode?: PermissionMode | null;
96
+ usage: UsageTotals;
97
+ contextPercent: number | null;
98
+ statuses: string[];
99
+ }, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
100
+ /**
101
+ * Create a footer factory that captures the session `ctx` (for session data)
102
+ * and returns the component `setFooter` expects. Called from the
103
+ * `session_start` handler in index.ts.
104
+ */
105
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
106
+ render(width: number): string[];
107
+ invalidate(): void;
108
+ dispose(): void;
109
+ };
110
+ export {};
111
+ //# sourceMappingURL=footer.d.ts.map
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Custom footer for the YAGNI CLI — replaces pi's built-in footer.
3
+ *
4
+ * Renders three lines:
5
+ * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
+ * 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
7
+ * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
+ *
9
+ * --- How to customize the status bar (for future tickets) ---
10
+ *
11
+ * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
12
+ * The `pi` (ExtensionAPI) object passed to the factory has no UI methods —
13
+ * they live on `ctx.ui` (ExtensionUIContext), which is only bound after
14
+ * `_applyExtensionBindings` runs, immediately before the `session_start`
15
+ * event. So the footer must be set inside a `pi.on("session_start", ...)`
16
+ * handler, guarded by `ctx.mode === "tui"` (no footer in RPC/print mode).
17
+ * See index.ts's session_start handler for the wiring, and pi's
18
+ * `docs/extensions.md` § "Widgets, Status, and Footer" + the `custom-header.ts`
19
+ * example for the canonical pattern.
20
+ *
21
+ * FACTORY SIGNATURE: `setFooter((tui, theme, footerData) => Component)` where:
22
+ * - `tui` — the TUI instance (screen dimensions, focus)
23
+ * - `theme` — the current Theme (use `theme.fg("dim", text)` etc.)
24
+ * - `footerData` — ReadonlyFooterDataProvider: `getGitBranch()`,
25
+ * `getExtensionStatuses()` (statuses set via `ctx.ui.setStatus(key, text)`),
26
+ * `getAvailableProviderCount()`
27
+ * Model info, token stats, and context usage are NOT on `footerData` — they're
28
+ * on `ctx` (the ExtensionContext passed to the session_start handler):
29
+ * `ctx.model`, `ctx.sessionManager`, `ctx.getContextUsage()`. Thread them
30
+ * through a closure if the footer needs them (this module's
31
+ * `createYagniFooterFactory` does exactly that).
32
+ *
33
+ * COMPONENT CONTRACT: the returned object needs `render(width: number): string[]`
34
+ * (returns the lines to display, one string per row) and `invalidate()` (called
35
+ * when the component should re-render). Optionally `dispose()` for cleanup.
36
+ *
37
+ * REFERENCE: pi's built-in footer lives at
38
+ * `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
39
+ * — useful reference for what data to replicate and how to format it.
40
+ */
41
+ import { spawnSync } from "node:child_process";
42
+ import { statSync } from "node:fs";
43
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
+ export const BRANCH_MAX_WIDTH = 60;
46
+ const WORKTREE_MAX_WIDTH = 30;
47
+ /** Section separator: single space + middle dot + single space. */
48
+ const SEP = " · ";
49
+ /** Default horizontal pad when the launcher didn't forward one (matches outputPad=1). */
50
+ const DEFAULT_PAD_X = 1;
51
+ /**
52
+ * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
53
+ * aligns with the editor input and the chat/output area on one shared column.
54
+ * The launcher seeds the same value as pi's `editorPaddingX`; this footer can't
55
+ * read pi settings, so the value crosses over env. Falls back to the output
56
+ * area's default (1) when unset/invalid. Clamped to pi's 0–3 editor range.
57
+ */
58
+ export function resolveFooterPadX(raw = process.env.YAGNI_PAD_X) {
59
+ const n = raw === undefined ? NaN : Number.parseInt(raw, 10);
60
+ return Number.isFinite(n) && n >= 0 && n <= 3 ? n : DEFAULT_PAD_X;
61
+ }
62
+ /** Format token counts for compact footer display (mirrors pi's formatTokens). */
63
+ export function formatTokens(count) {
64
+ if (count < 1000)
65
+ return count.toString();
66
+ if (count < 10000)
67
+ return `${(count / 1000).toFixed(1)}k`;
68
+ if (count < 1000000)
69
+ return `${Math.round(count / 1000)}k`;
70
+ if (count < 10000000)
71
+ return `${(count / 1000000).toFixed(1)}M`;
72
+ return `${Math.round(count / 1000000)}M`;
73
+ }
74
+ /** Shorten cwd relative to home, like pi's built-in footer. */
75
+ export function formatCwd(cwd, home) {
76
+ if (!home)
77
+ return cwd;
78
+ const resolvedCwd = resolve(cwd);
79
+ const resolvedHome = resolve(home);
80
+ const rel = relative(resolvedHome, resolvedCwd);
81
+ const inside = rel === "" ||
82
+ (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
83
+ return inside ? (rel === "" ? "~" : `~/${rel}`) : cwd;
84
+ }
85
+ /** Accumulate usage from all session entries (mirrors pi's built-in footer). */
86
+ export function collectUsage(sessionManager) {
87
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
88
+ for (const entry of sessionManager.getEntries()) {
89
+ let u;
90
+ if (entry.type === "message" && entry.message.role === "assistant") {
91
+ u = entry.message.usage;
92
+ }
93
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
94
+ u = entry.message.usage;
95
+ }
96
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
97
+ u = entry.usage;
98
+ }
99
+ if (u) {
100
+ totals.input += u.input;
101
+ totals.output += u.output;
102
+ totals.cacheRead += u.cacheRead;
103
+ totals.cacheWrite += u.cacheWrite;
104
+ totals.cost += u.cost.total;
105
+ }
106
+ }
107
+ return totals;
108
+ }
109
+ /** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
110
+ function truncateEnd(text, maxWidth) {
111
+ if (visibleWidth(text) <= maxWidth)
112
+ return text;
113
+ return truncateToWidth(text, maxWidth, "…");
114
+ }
115
+ function runGit(args, cwd) {
116
+ try {
117
+ const r = spawnSync("git", ["--no-optional-locks", ...args], {
118
+ cwd,
119
+ encoding: "utf8",
120
+ stdio: ["ignore", "pipe", "ignore"],
121
+ });
122
+ if (r.error || r.status !== 0)
123
+ return null;
124
+ return r.stdout.trim();
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ function gitRepoRoot(cwd) {
131
+ return runGit(["rev-parse", "--show-toplevel"], cwd) || null;
132
+ }
133
+ function gitBranch(repoRoot) {
134
+ const out = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
135
+ if (out)
136
+ return out;
137
+ // Detached HEAD, or repo with no commits yet.
138
+ return runGit(["rev-parse", "--verify", "HEAD"], repoRoot) ? "detached" : null;
139
+ }
140
+ function gitWorktrees(repoRoot) {
141
+ const out = runGit(["worktree", "list", "--porcelain"], repoRoot);
142
+ if (!out)
143
+ return [];
144
+ return out
145
+ .split("\n")
146
+ .filter((l) => l.startsWith("worktree "))
147
+ .map((l) => l.slice("worktree ".length).trim())
148
+ .filter(Boolean);
149
+ }
150
+ /** The MAIN repository root (stable across worktrees) — used for the folder name. */
151
+ function gitMainRepoRoot(repoRoot) {
152
+ const common = runGit(["rev-parse", "--git-common-dir"], repoRoot);
153
+ if (!common)
154
+ return repoRoot;
155
+ const abs = isAbsolute(common) ? common : join(repoRoot, common);
156
+ return basename(abs) === ".git" ? dirname(abs) : repoRoot;
157
+ }
158
+ /** Main checkout: .git is a directory. Linked worktree: .git is a "gitdir:" file. */
159
+ function isLinkedWorktree(repoRoot) {
160
+ try {
161
+ return statSync(join(repoRoot, ".git")).isFile();
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ const liveWorktreeProbes = { isLinkedWorktree, worktrees: gitWorktrees };
168
+ /**
169
+ * Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
170
+ * >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
171
+ * context and earns no label; a [bracket] only disambiguates a secondary working tree.
172
+ */
173
+ export function resolveWorktreeLabel(repoRoot, branch, probes = liveWorktreeProbes) {
174
+ if (!probes.isLinkedWorktree(repoRoot))
175
+ return null;
176
+ if (probes.worktrees(repoRoot).length < 2)
177
+ return null;
178
+ const currentDir = basename(repoRoot);
179
+ if (branch && currentDir === branch)
180
+ return null;
181
+ return truncateEnd(currentDir, WORKTREE_MAX_WIDTH);
182
+ }
183
+ /**
184
+ * Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
185
+ * binary, and corrupt repos all degrade to a safe partial/empty result.
186
+ */
187
+ export function detectGitInfo(cwd, home) {
188
+ const root = gitRepoRoot(cwd);
189
+ if (!root) {
190
+ return { folder: formatCwd(cwd, home), inRepo: false, branch: null, worktree: null };
191
+ }
192
+ const branch = gitBranch(root);
193
+ const worktree = resolveWorktreeLabel(root, branch);
194
+ return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
195
+ }
196
+ /** Context color: dim below 70, warning 70-90, error above 90. */
197
+ function contextColor(percent) {
198
+ if (percent === null)
199
+ return "dim";
200
+ if (percent > 90)
201
+ return "error";
202
+ if (percent > 70)
203
+ return "warning";
204
+ return "dim";
205
+ }
206
+ /** Pure line-builder, exported for tests. All data injected; colors via theme. */
207
+ export function renderFooterLines(input, theme, width, padX = 0) {
208
+ const dim = (s) => theme.fg("dim", s);
209
+ const sep = dim(SEP);
210
+ // Reserve the left pad so the status bar's text starts on the same column as
211
+ // the (padded) editor input and the chat/output area, instead of hugging the
212
+ // terminal edge. Truncation runs against the reduced content width.
213
+ const pad = " ".repeat(Math.max(0, Math.min(3, Math.floor(padX))));
214
+ const contentWidth = Math.max(1, width - pad.length);
215
+ // Line 1: folder · [worktree] · branch
216
+ const line1Parts = [theme.fg("accent", input.git.folder)];
217
+ if (input.git.inRepo) {
218
+ if (input.git.worktree)
219
+ line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
220
+ if (input.git.branch)
221
+ line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
222
+ }
223
+ const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
224
+ // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
225
+ const statParts = [];
226
+ if (input.usage.input)
227
+ statParts.push(`↑${formatTokens(input.usage.input)}`);
228
+ if (input.usage.output)
229
+ statParts.push(`↓${formatTokens(input.usage.output)}`);
230
+ if (input.usage.cost)
231
+ statParts.push(`$${input.usage.cost.toFixed(3)}`);
232
+ const stats = statParts.join(" ");
233
+ const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
234
+ const line2Parts = [];
235
+ if (input.mode)
236
+ line2Parts.push(dim(`${input.mode} mode`));
237
+ line2Parts.push(dim(input.model));
238
+ if (stats)
239
+ line2Parts.push(dim(stats));
240
+ line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
241
+ const line2 = pad + truncateToWidth(line2Parts.join(sep), contentWidth, dim("…"));
242
+ // Line 3: extension statuses (branding, todo counter, mode), joined by " · ".
243
+ const statuses = input.statuses.map((s) => s.replace(/[\r\n\t]/g, " ").trim()).filter(Boolean);
244
+ const lines = [line1, line2];
245
+ if (statuses.length > 0) {
246
+ lines.push(pad + truncateToWidth(dim(statuses.join(SEP)), contentWidth, dim("…")));
247
+ }
248
+ return lines;
249
+ }
250
+ /**
251
+ * Create a footer factory that captures the session `ctx` (for session data)
252
+ * and returns the component `setFooter` expects. Called from the
253
+ * `session_start` handler in index.ts.
254
+ */
255
+ export function createYagniFooterFactory(ctx, modeHolder) {
256
+ return (_tui, theme, footerData) => {
257
+ // Recompute git/worktree info only when the branch actually changes. Optional-
258
+ // chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
259
+ // provider is whatever pi version is installed — guard so a mismatch can't break
260
+ // footer construction (worst case, git info just doesn't auto-invalidate).
261
+ let gitCache;
262
+ const unsubscribeBranch = footerData.onBranchChange?.(() => {
263
+ gitCache = undefined;
264
+ });
265
+ const gitInfo = () => {
266
+ if (!gitCache) {
267
+ gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
268
+ }
269
+ return gitCache;
270
+ };
271
+ return {
272
+ render(width) {
273
+ const statuses = [...footerData.getExtensionStatuses().entries()]
274
+ .sort(([a], [b]) => a.localeCompare(b))
275
+ .map(([, text]) => text);
276
+ return renderFooterLines({
277
+ git: gitInfo(),
278
+ model: ctx.model?.id ?? "no-model",
279
+ mode: modeHolder?.get() ?? null,
280
+ usage: collectUsage(ctx.sessionManager),
281
+ contextPercent: ctx.getContextUsage()?.percent ?? null,
282
+ statuses,
283
+ }, theme, width, resolveFooterPadX());
284
+ },
285
+ invalidate() {
286
+ gitCache = undefined;
287
+ },
288
+ dispose() {
289
+ unsubscribeBranch?.();
290
+ },
291
+ };
292
+ };
293
+ }
294
+ //# sourceMappingURL=footer.js.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The Guardian — LLM auto-review of prompt-band bash commands (YAG-504).
3
+ *
4
+ * Pure half: verdict types, state tracking, circuit breaker, JSON parsing.
5
+ * I/O half: reviewCommand spawns a locked-down child pi (same runStage seam
6
+ * the advisor uses) on the efficient tier with read-only tools and a risk
7
+ * policy persona. The child returns a JSON verdict; the gate acts on it.
8
+ *
9
+ * Same pure/IO split as advisor.ts (decideConsult pure, askAdvisorTool I/O)
10
+ * and permission.ts (decideGate pure, registerPermissionGate I/O), for the
11
+ * same reason: the rules are what need exhaustive tests, and they must not
12
+ * require a child process to exercise.
13
+ *
14
+ * Trigger: the exec policy classifies a bash command as "prompt" (not clearly
15
+ * safe, not clearly forbidden). The Guardian reviews it instead of interrupting
16
+ * the user. On allow, the command runs. On ask (YAG-510), the user arbitrates:
17
+ * the gate shows the Guardian's question-rationale and the user approves or
18
+ * declines. On deny, the agent sees the rationale and is told to find a safer
19
+ * alternative or ask the user. On timeout/error, auto mode falls back to an
20
+ * ask when a UI exists, else fails closed; review mode falls back to the
21
+ * ordinary user confirm.
22
+ *
23
+ * Circuit breaker: 3 consecutive denials within one user prompt → escalation
24
+ * (ask the user once) or, headless, interruption. Denial streaks reset on
25
+ * `before_agent_start`, which fires once per USER PROMPT (not per LLM turn).
26
+ * An `ask` outcome leaves the streak untouched — neither a denial nor an
27
+ * exoneration — so an ask-preferring model cannot disarm the breaker by
28
+ * alternating deny/ask.
29
+ */
30
+ import { runStage as defaultRunStage } from "./pipeline/runner.js";
31
+ import type { PipelineStage } from "./pipeline/types.js";
32
+ export type GuardianOutcome = "allow" | "ask" | "deny";
33
+ export type GuardianRiskLevel = "low" | "medium" | "high" | "critical";
34
+ export interface GuardianVerdict {
35
+ outcome: GuardianOutcome;
36
+ riskLevel: GuardianRiskLevel;
37
+ rationale: string;
38
+ }
39
+ export interface GuardianLimits {
40
+ /** Session cap on total Guardian reviews. */
41
+ maxReviews: number;
42
+ /** Consecutive denials per turn before the circuit breaker trips. */
43
+ maxConsecutiveDenials: number;
44
+ /** Hard timeout for the Guardian consult in ms. */
45
+ timeoutMs: number;
46
+ }
47
+ export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
48
+ /**
49
+ * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
50
+ * overrides the session review cap; anything non-numeric or < 1 falls back to
51
+ * the default (a bad value must never zero out the cap and lock the session).
52
+ */
53
+ export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
54
+ /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
55
+ export declare const GUARDIAN_MODEL_TIER = "efficient";
56
+ /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
57
+ export declare const GUARDIAN_TOOLS: string[];
58
+ export interface GuardianState {
59
+ reviews: number;
60
+ consecutiveDenials: number;
61
+ }
62
+ export interface GuardianStateHandle {
63
+ read(): GuardianState;
64
+ recordReview(outcome: GuardianOutcome): GuardianState;
65
+ resetTurn(): void;
66
+ }
67
+ export declare function makeGuardianState(): GuardianStateHandle;
68
+ export interface CircuitBreakerResult {
69
+ tripped: boolean;
70
+ reason?: string;
71
+ }
72
+ export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
73
+ export declare function parseVerdict(raw: string): GuardianVerdict | null;
74
+ export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
75
+ export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
76
+ export interface ReviewResult {
77
+ verdict: GuardianVerdict | null;
78
+ error?: GuardianError;
79
+ cost: number;
80
+ }
81
+ export interface ReviewCommandDeps {
82
+ runStage?: typeof defaultRunStage;
83
+ cwd: string;
84
+ signal?: AbortSignal;
85
+ /** Override the model tier (default: efficient). */
86
+ modelTier?: string;
87
+ /** Consult timeout in ms (default: DEFAULT_GUARDIAN_LIMITS.timeoutMs). */
88
+ timeoutMs?: number;
89
+ /**
90
+ * The exec policy's justification for routing this command to the Guardian
91
+ * ("pushes to remote — confirm intent"). Included in the consult prompt so
92
+ * an `ask` rationale can add information beyond the static rule text.
93
+ */
94
+ execJustification?: string;
95
+ }
96
+ /**
97
+ * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
98
+ * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
99
+ * agent name selects the guardian persona from PERSONA_BODIES.
100
+ */
101
+ export declare function guardianStage(modelTier?: string): PipelineStage;
102
+ /**
103
+ * Run a Guardian consult: spawn a locked-down child pi with the risk policy
104
+ * persona and the command as the task. Parse the JSON verdict from the output.
105
+ * Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
106
+ */
107
+ export declare function reviewCommand(command: string, deps: ReviewCommandDeps): Promise<ReviewResult>;
108
+ export interface GuardianDiagnosticEvent {
109
+ event: "guardian_review";
110
+ outcome: GuardianOutcome | GuardianError;
111
+ durationMs?: number;
112
+ tier?: string;
113
+ /** Debug-only: command hash for correlation (never the raw command). */
114
+ commandHash?: string;
115
+ /** Debug-only: the Guardian's rationale. */
116
+ rationale?: string;
117
+ }
118
+ /**
119
+ * Create a sanitized diagnostic event. Never includes the raw command text
120
+ * (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
121
+ */
122
+ export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
123
+ durationMs?: number;
124
+ tier?: string;
125
+ rationale?: string;
126
+ commandHash?: string;
127
+ debug?: boolean;
128
+ }): GuardianDiagnosticEvent;
129
+ //# sourceMappingURL=guardian.d.ts.map