@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,44 @@
1
+ /**
2
+ * Session state for the miss-to-record flywheel (org plane, Run 7).
3
+ *
4
+ * When `ask_yagni` answers `no_position`, the server attaches a
5
+ * `recordSuggestion` telling the agent to bank the assumption it proceeds on.
6
+ * The extension owns two client-side guards the server cannot:
7
+ *
8
+ * - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
9
+ * surfaced per session; past it the suggestion is suppressed so a busy
10
+ * session cannot flood the ledger with asserted rows.
11
+ * - **Dedupe attribution.** A `record_decision` call that answers the
12
+ * QUESTION a surfaced suggestion asked about is a mid-run agent with no
13
+ * human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
14
+ * backend then returns the existing row instead of inserting). The
15
+ * attribution is correlated to the suggested question — an unrelated
16
+ * record_decision (a different judgment the agent banks mid-run, or a
17
+ * human `/decide`) never inherits the flag.
18
+ */
19
+ /** Most flywheel suggestions surfaced per session. */
20
+ export declare const FLYWHEEL_RUN_CAP = 3;
21
+ export interface FlywheelState {
22
+ /** Suggestions surfaced so far this session. */
23
+ suggestionsShown: number;
24
+ /**
25
+ * The question of the most recent surfaced suggestion, until a matching
26
+ * record_decision consumes it. Only a record answering THIS question is
27
+ * flywheel-attributed (sends `dedupe: true`).
28
+ */
29
+ pendingQuestion: string | null;
30
+ }
31
+ export declare function makeFlywheelState(): FlywheelState;
32
+ /** May another suggestion be surfaced? */
33
+ export declare function canSurfaceSuggestion(state: FlywheelState): boolean;
34
+ /** Record that a suggestion (asking about `question`) reached the model. */
35
+ export declare function noteSuggestionSurfaced(state: FlywheelState, question: string): void;
36
+ /**
37
+ * Consume the flywheel attribution for a record_decision call. Returns true
38
+ * (and clears the pending question) only when the recorded question matches
39
+ * the surfaced suggestion's; a mismatch leaves the attribution pending — the
40
+ * agent may record other judgments before circling back, and a fail-safe
41
+ * mismatch simply means a normal insert (no dedupe), never a swallowed write.
42
+ */
43
+ export declare function consumeFlywheelAttribution(state: FlywheelState, recordedQuestion: string): boolean;
44
+ //# sourceMappingURL=flywheel.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Session state for the miss-to-record flywheel (org plane, Run 7).
3
+ *
4
+ * When `ask_yagni` answers `no_position`, the server attaches a
5
+ * `recordSuggestion` telling the agent to bank the assumption it proceeds on.
6
+ * The extension owns two client-side guards the server cannot:
7
+ *
8
+ * - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
9
+ * surfaced per session; past it the suggestion is suppressed so a busy
10
+ * session cannot flood the ledger with asserted rows.
11
+ * - **Dedupe attribution.** A `record_decision` call that answers the
12
+ * QUESTION a surfaced suggestion asked about is a mid-run agent with no
13
+ * human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
14
+ * backend then returns the existing row instead of inserting). The
15
+ * attribution is correlated to the suggested question — an unrelated
16
+ * record_decision (a different judgment the agent banks mid-run, or a
17
+ * human `/decide`) never inherits the flag.
18
+ */
19
+ /** Most flywheel suggestions surfaced per session. */
20
+ export const FLYWHEEL_RUN_CAP = 3;
21
+ export function makeFlywheelState() {
22
+ return { suggestionsShown: 0, pendingQuestion: null };
23
+ }
24
+ /** May another suggestion be surfaced? */
25
+ export function canSurfaceSuggestion(state) {
26
+ return state.suggestionsShown < FLYWHEEL_RUN_CAP;
27
+ }
28
+ /** Loose textual identity: case- and whitespace-insensitive, terminal punctuation ignored. */
29
+ function normalizeQuestion(question) {
30
+ return question.toLowerCase().replace(/\s+/g, " ").replace(/[.?!\s]+$/g, "").trim();
31
+ }
32
+ /** Record that a suggestion (asking about `question`) reached the model. */
33
+ export function noteSuggestionSurfaced(state, question) {
34
+ state.suggestionsShown += 1;
35
+ state.pendingQuestion = question;
36
+ }
37
+ /**
38
+ * Consume the flywheel attribution for a record_decision call. Returns true
39
+ * (and clears the pending question) only when the recorded question matches
40
+ * the surfaced suggestion's; a mismatch leaves the attribution pending — the
41
+ * agent may record other judgments before circling back, and a fail-safe
42
+ * mismatch simply means a normal insert (no dedupe), never a swallowed write.
43
+ */
44
+ export function consumeFlywheelAttribution(state, recordedQuestion) {
45
+ if (state.pendingQuestion === null)
46
+ return false;
47
+ if (normalizeQuestion(state.pendingQuestion) !== normalizeQuestion(recordedQuestion)) {
48
+ return false;
49
+ }
50
+ state.pendingQuestion = null;
51
+ return true;
52
+ }
53
+ //# sourceMappingURL=flywheel.js.map
@@ -0,0 +1,118 @@
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
+ export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
45
+ export declare function isShiftTab(data: string): boolean;
46
+ export declare const GIT_MUTATING_PATTERN: RegExp;
47
+ /**
48
+ * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
49
+ * aligns with the editor input and the chat/output area on one shared column.
50
+ * The launcher seeds the same value as pi's `editorPaddingX`; this footer can't
51
+ * read pi settings, so the value crosses over env. Falls back to the output
52
+ * area's default (1) when unset/invalid. Clamped to pi's 0–3 editor range.
53
+ */
54
+ export declare function resolveFooterPadX(raw?: string | undefined): number;
55
+ /** Format token counts for compact footer display (mirrors pi's formatTokens). */
56
+ export declare function formatTokens(count: number): string;
57
+ /** Shorten cwd relative to home, like pi's built-in footer. */
58
+ export declare function formatCwd(cwd: string, home: string | undefined): string;
59
+ interface UsageTotals {
60
+ input: number;
61
+ output: number;
62
+ cacheRead: number;
63
+ cacheWrite: number;
64
+ cost: number;
65
+ }
66
+ /** Accumulate usage from all session entries (mirrors pi's built-in footer). */
67
+ export declare function collectUsage(sessionManager: ExtensionContext["sessionManager"]): UsageTotals;
68
+ export interface GitInfo {
69
+ /** Repo-root folder basename (the MAIN repo, stable across worktrees), or ~-path off-repo. */
70
+ folder: string;
71
+ inRepo: boolean;
72
+ /** Current branch, null off-repo. "detached" on detached HEAD. */
73
+ branch: string | null;
74
+ /** Linked-worktree label for [brackets], or null to hide. */
75
+ worktree: string | null;
76
+ }
77
+ /** Git probes `resolveWorktreeLabel` needs, injectable so the rules can be unit-tested. */
78
+ export interface WorktreeProbes {
79
+ isLinkedWorktree(repoRoot: string): boolean;
80
+ worktrees(repoRoot: string): string[];
81
+ }
82
+ /**
83
+ * Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
84
+ * >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
85
+ * context and earns no label; a [bracket] only disambiguates a secondary working tree.
86
+ */
87
+ export declare function resolveWorktreeLabel(repoRoot: string, branch: string | null, probes?: WorktreeProbes): string | null;
88
+ /**
89
+ * Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
90
+ * binary, and corrupt repos all degrade to a safe partial/empty result.
91
+ */
92
+ export declare function detectGitInfo(cwd: string, home: string | undefined): GitInfo;
93
+ /** Pure line-builder, exported for tests. All data injected; colors via theme. */
94
+ export declare function renderFooterLines(input: {
95
+ git: GitInfo;
96
+ model: string;
97
+ /** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
98
+ mode?: PermissionMode | null;
99
+ usage: UsageTotals;
100
+ contextPercent: number | null;
101
+ statuses: string[];
102
+ }, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
103
+ /**
104
+ * Create a footer factory that captures the session `ctx` (for session data)
105
+ * and returns the component `setFooter` expects. Called from the
106
+ * `session_start` handler in index.ts.
107
+ */
108
+ export interface FooterInvalidateHandle {
109
+ invalidateGit(): void;
110
+ requestRender(): void;
111
+ }
112
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
113
+ render(width: number): string[];
114
+ invalidate(): void;
115
+ dispose(): void;
116
+ };
117
+ export {};
118
+ //# sourceMappingURL=footer.d.ts.map
@@ -0,0 +1,308 @@
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 { matchesKey, 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
+ const MODE_CYCLE = ["auto", "review", "plan"];
50
+ export function cyclePermissionMode(current) {
51
+ const idx = MODE_CYCLE.indexOf(current);
52
+ return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length];
53
+ }
54
+ export function isShiftTab(data) {
55
+ return matchesKey(data, "shift+tab");
56
+ }
57
+ export const GIT_MUTATING_PATTERN = /\bgit\s+(?:checkout|switch|branch|worktree|reset|restore|rebase|merge|cherry-pick|bisect)\b/;
58
+ /** Default horizontal pad when the launcher didn't forward one (matches outputPad=1). */
59
+ const DEFAULT_PAD_X = 1;
60
+ /**
61
+ * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
62
+ * aligns with the editor input and the chat/output area on one shared column.
63
+ * The launcher seeds the same value as pi's `editorPaddingX`; this footer can't
64
+ * read pi settings, so the value crosses over env. Falls back to the output
65
+ * area's default (1) when unset/invalid. Clamped to pi's 0–3 editor range.
66
+ */
67
+ export function resolveFooterPadX(raw = process.env.YAGNI_PAD_X) {
68
+ const n = raw === undefined ? NaN : Number.parseInt(raw, 10);
69
+ return Number.isFinite(n) && n >= 0 && n <= 3 ? n : DEFAULT_PAD_X;
70
+ }
71
+ /** Format token counts for compact footer display (mirrors pi's formatTokens). */
72
+ export function formatTokens(count) {
73
+ if (count < 1000)
74
+ return count.toString();
75
+ if (count < 10000)
76
+ return `${(count / 1000).toFixed(1)}k`;
77
+ if (count < 1000000)
78
+ return `${Math.round(count / 1000)}k`;
79
+ if (count < 10000000)
80
+ return `${(count / 1000000).toFixed(1)}M`;
81
+ return `${Math.round(count / 1000000)}M`;
82
+ }
83
+ /** Shorten cwd relative to home, like pi's built-in footer. */
84
+ export function formatCwd(cwd, home) {
85
+ if (!home)
86
+ return cwd;
87
+ const resolvedCwd = resolve(cwd);
88
+ const resolvedHome = resolve(home);
89
+ const rel = relative(resolvedHome, resolvedCwd);
90
+ const inside = rel === "" ||
91
+ (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
92
+ return inside ? (rel === "" ? "~" : `~/${rel}`) : cwd;
93
+ }
94
+ /** Accumulate usage from all session entries (mirrors pi's built-in footer). */
95
+ export function collectUsage(sessionManager) {
96
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
97
+ for (const entry of sessionManager.getEntries()) {
98
+ let u;
99
+ if (entry.type === "message" && entry.message.role === "assistant") {
100
+ u = entry.message.usage;
101
+ }
102
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
103
+ u = entry.message.usage;
104
+ }
105
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
106
+ u = entry.usage;
107
+ }
108
+ if (u) {
109
+ totals.input += u.input;
110
+ totals.output += u.output;
111
+ totals.cacheRead += u.cacheRead;
112
+ totals.cacheWrite += u.cacheWrite;
113
+ totals.cost += u.cost.total;
114
+ }
115
+ }
116
+ return totals;
117
+ }
118
+ /** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
119
+ function truncateEnd(text, maxWidth, ellipsis = "…") {
120
+ if (visibleWidth(text) <= maxWidth)
121
+ return text;
122
+ return truncateToWidth(text, maxWidth, ellipsis);
123
+ }
124
+ function runGit(args, cwd) {
125
+ try {
126
+ const r = spawnSync("git", ["--no-optional-locks", ...args], {
127
+ cwd,
128
+ encoding: "utf8",
129
+ stdio: ["ignore", "pipe", "ignore"],
130
+ });
131
+ if (r.error || r.status !== 0)
132
+ return null;
133
+ return r.stdout.trim();
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ function gitRepoRoot(cwd) {
140
+ return runGit(["rev-parse", "--show-toplevel"], cwd) || null;
141
+ }
142
+ function gitBranch(repoRoot) {
143
+ const out = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
144
+ if (out)
145
+ return out;
146
+ // Detached HEAD, or repo with no commits yet.
147
+ return runGit(["rev-parse", "--verify", "HEAD"], repoRoot) ? "detached" : null;
148
+ }
149
+ function gitWorktrees(repoRoot) {
150
+ const out = runGit(["worktree", "list", "--porcelain"], repoRoot);
151
+ if (!out)
152
+ return [];
153
+ return out
154
+ .split("\n")
155
+ .filter((l) => l.startsWith("worktree "))
156
+ .map((l) => l.slice("worktree ".length).trim())
157
+ .filter(Boolean);
158
+ }
159
+ /** The MAIN repository root (stable across worktrees) — used for the folder name. */
160
+ function gitMainRepoRoot(repoRoot) {
161
+ const common = runGit(["rev-parse", "--git-common-dir"], repoRoot);
162
+ if (!common)
163
+ return repoRoot;
164
+ const abs = isAbsolute(common) ? common : join(repoRoot, common);
165
+ return basename(abs) === ".git" ? dirname(abs) : repoRoot;
166
+ }
167
+ /** Main checkout: .git is a directory. Linked worktree: .git is a "gitdir:" file. */
168
+ function isLinkedWorktree(repoRoot) {
169
+ try {
170
+ return statSync(join(repoRoot, ".git")).isFile();
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ const liveWorktreeProbes = { isLinkedWorktree, worktrees: gitWorktrees };
177
+ /**
178
+ * Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
179
+ * >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
180
+ * context and earns no label; a [bracket] only disambiguates a secondary working tree.
181
+ */
182
+ export function resolveWorktreeLabel(repoRoot, branch, probes = liveWorktreeProbes) {
183
+ if (!probes.isLinkedWorktree(repoRoot))
184
+ return null;
185
+ if (probes.worktrees(repoRoot).length < 2)
186
+ return null;
187
+ const currentDir = basename(repoRoot);
188
+ if (branch && currentDir === branch)
189
+ return null;
190
+ return truncateEnd(currentDir, WORKTREE_MAX_WIDTH);
191
+ }
192
+ /**
193
+ * Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
194
+ * binary, and corrupt repos all degrade to a safe partial/empty result.
195
+ */
196
+ export function detectGitInfo(cwd, home) {
197
+ const root = gitRepoRoot(cwd);
198
+ if (!root) {
199
+ return { folder: formatCwd(cwd, home), inRepo: false, branch: null, worktree: null };
200
+ }
201
+ const branch = gitBranch(root);
202
+ const worktree = resolveWorktreeLabel(root, branch);
203
+ return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
204
+ }
205
+ const MODE_DISPLAY = {
206
+ auto: { text: "⏵⏵ auto mode", color: "accent" },
207
+ review: { text: "✓ review mode", color: "warning" },
208
+ plan: { text: "⏸ plan mode", color: "success" },
209
+ };
210
+ function modeDisplay(mode) {
211
+ return MODE_DISPLAY[mode];
212
+ }
213
+ /** Context color: success below 70, warning 70-90, error above 90. */
214
+ function contextColor(percent) {
215
+ if (percent === null)
216
+ return "success";
217
+ if (percent > 90)
218
+ return "error";
219
+ if (percent > 70)
220
+ return "warning";
221
+ return "success";
222
+ }
223
+ /** Pure line-builder, exported for tests. All data injected; colors via theme. */
224
+ export function renderFooterLines(input, theme, width, padX = 0) {
225
+ const dim = (s) => theme.fg("dim", s);
226
+ const sep = dim(SEP);
227
+ // Reserve the left pad so the status bar's text starts on the same column as
228
+ // the (padded) editor input and the chat/output area, instead of hugging the
229
+ // terminal edge. Truncation runs against the reduced content width.
230
+ const pad = " ".repeat(Math.max(0, Math.min(3, Math.floor(padX))));
231
+ const contentWidth = Math.max(1, width - pad.length);
232
+ // Line 1: folder · [worktree] · branch
233
+ const line1Parts = [theme.fg("accent", input.git.folder)];
234
+ if (input.git.inRepo) {
235
+ if (input.git.worktree)
236
+ line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
237
+ if (input.git.branch)
238
+ line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
239
+ }
240
+ const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
241
+ // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
242
+ const statParts = [];
243
+ if (input.usage.input)
244
+ statParts.push(`↑${formatTokens(input.usage.input)}`);
245
+ if (input.usage.output)
246
+ statParts.push(`↓${formatTokens(input.usage.output)}`);
247
+ if (input.usage.cost)
248
+ statParts.push(`$${input.usage.cost.toFixed(3)}`);
249
+ const stats = statParts.join(" ");
250
+ const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
251
+ const line2Parts = [];
252
+ if (input.mode) {
253
+ const modeLabel = modeDisplay(input.mode);
254
+ line2Parts.push(theme.fg(modeLabel.color, modeLabel.text) + dim(" (shift+tab to change)"));
255
+ }
256
+ line2Parts.push(dim(input.model));
257
+ if (stats)
258
+ line2Parts.push(dim(stats));
259
+ line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
260
+ const line2 = pad + truncateToWidth(line2Parts.join(sep), contentWidth, dim("…"));
261
+ // Line 3: extension statuses (branding, todo counter, mode), joined by " · ".
262
+ const statuses = input.statuses.map((s) => s.replace(/[\r\n\t]/g, " ").trim()).filter(Boolean);
263
+ const lines = [line1, line2];
264
+ if (statuses.length > 0) {
265
+ lines.push(pad + truncateToWidth(dim(statuses.join(SEP)), contentWidth, dim("…")));
266
+ }
267
+ return lines;
268
+ }
269
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
270
+ return (_tui, theme, footerData) => {
271
+ let gitCache;
272
+ const unsubscribeBranch = footerData.onBranchChange?.(() => {
273
+ gitCache = undefined;
274
+ });
275
+ const gitInfo = () => {
276
+ if (!gitCache) {
277
+ gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
278
+ }
279
+ return gitCache;
280
+ };
281
+ if (invalidateHandle) {
282
+ invalidateHandle.invalidateGit = () => { gitCache = undefined; };
283
+ invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
+ }
285
+ return {
286
+ render(width) {
287
+ const statuses = [...footerData.getExtensionStatuses().entries()]
288
+ .sort(([a], [b]) => a.localeCompare(b))
289
+ .map(([, text]) => text);
290
+ return renderFooterLines({
291
+ git: gitInfo(),
292
+ model: ctx.model?.id ?? "no-model",
293
+ mode: modeHolder?.get() ?? null,
294
+ usage: collectUsage(ctx.sessionManager),
295
+ contextPercent: ctx.getContextUsage()?.percent ?? null,
296
+ statuses,
297
+ }, theme, width, resolveFooterPadX());
298
+ },
299
+ invalidate() {
300
+ gitCache = undefined;
301
+ },
302
+ dispose() {
303
+ unsubscribeBranch?.();
304
+ },
305
+ };
306
+ };
307
+ }
308
+ //# sourceMappingURL=footer.js.map
@@ -0,0 +1,139 @@
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
+ /** Cap on Guardian reviews within the sliding window ({@link GUARDIAN_REVIEW_WINDOW_MS}). */
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 sliding-window review cap; anything non-numeric or < 1 falls
51
+ * back to the default (a bad value must never zero out the cap and lock the
52
+ * session).
53
+ */
54
+ export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
55
+ /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
56
+ export declare const GUARDIAN_MODEL_TIER = "efficient";
57
+ /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
58
+ export declare const GUARDIAN_TOOLS: string[];
59
+ /**
60
+ * The review-cap window. `reviews` counts consults inside a SLIDING window
61
+ * rather than for the session's lifetime: a 24/7 session (a fleet operator's
62
+ * always-on terminal) must regain review capacity as old consults age out,
63
+ * not hard-block forever after the first N. The cap is a cost/runaway bound,
64
+ * not a safety bound — safety is the verdicts themselves.
65
+ */
66
+ export declare const GUARDIAN_REVIEW_WINDOW_MS: number;
67
+ export interface GuardianState {
68
+ /** Guardian consults within the last {@link GUARDIAN_REVIEW_WINDOW_MS}. */
69
+ reviews: number;
70
+ consecutiveDenials: number;
71
+ }
72
+ export interface GuardianStateHandle {
73
+ read(): GuardianState;
74
+ recordReview(outcome: GuardianOutcome): GuardianState;
75
+ resetTurn(): void;
76
+ }
77
+ export declare function makeGuardianState(now?: () => number): GuardianStateHandle;
78
+ export interface CircuitBreakerResult {
79
+ tripped: boolean;
80
+ reason?: string;
81
+ }
82
+ export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
83
+ export declare function parseVerdict(raw: string): GuardianVerdict | null;
84
+ export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
85
+ export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
86
+ export interface ReviewResult {
87
+ verdict: GuardianVerdict | null;
88
+ error?: GuardianError;
89
+ cost: number;
90
+ }
91
+ export interface ReviewCommandDeps {
92
+ runStage?: typeof defaultRunStage;
93
+ cwd: string;
94
+ signal?: AbortSignal;
95
+ /** Override the model tier (default: efficient). */
96
+ modelTier?: string;
97
+ /** Consult timeout in ms (default: DEFAULT_GUARDIAN_LIMITS.timeoutMs). */
98
+ timeoutMs?: number;
99
+ /**
100
+ * The exec policy's justification for routing this command to the Guardian
101
+ * ("pushes to remote — confirm intent"). Included in the consult prompt so
102
+ * an `ask` rationale can add information beyond the static rule text.
103
+ */
104
+ execJustification?: string;
105
+ }
106
+ /**
107
+ * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
108
+ * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
109
+ * agent name selects the guardian persona from PERSONA_BODIES.
110
+ */
111
+ export declare function guardianStage(modelTier?: string): PipelineStage;
112
+ /**
113
+ * Run a Guardian consult: spawn a locked-down child pi with the risk policy
114
+ * persona and the command as the task. Parse the JSON verdict from the output.
115
+ * Returns { verdict, cost } on success, { verdict: null, error, cost } on failure.
116
+ */
117
+ export declare function reviewCommand(command: string, deps: ReviewCommandDeps): Promise<ReviewResult>;
118
+ export interface GuardianDiagnosticEvent {
119
+ event: "guardian_review";
120
+ outcome: GuardianOutcome | GuardianError;
121
+ durationMs?: number;
122
+ tier?: string;
123
+ /** Debug-only: command hash for correlation (never the raw command). */
124
+ commandHash?: string;
125
+ /** Debug-only: the Guardian's rationale. */
126
+ rationale?: string;
127
+ }
128
+ /**
129
+ * Create a sanitized diagnostic event. Never includes the raw command text
130
+ * (could contain secrets). YAGNI_DEBUG=1 adds rationale and a command hash.
131
+ */
132
+ export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
133
+ durationMs?: number;
134
+ tier?: string;
135
+ rationale?: string;
136
+ commandHash?: string;
137
+ debug?: boolean;
138
+ }): GuardianDiagnosticEvent;
139
+ //# sourceMappingURL=guardian.d.ts.map