@yagni-app/code 0.3.0 → 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 (48) hide show
  1. package/dist/cli.js +12 -0
  2. package/dist/connectClaudeCode.d.ts +77 -0
  3. package/dist/connectClaudeCode.js +228 -0
  4. package/dist/connectCodex.d.ts +75 -0
  5. package/dist/connectCodex.js +201 -0
  6. package/dist/extension/approvedPrefixes.d.ts +11 -0
  7. package/dist/extension/approvedPrefixes.js +30 -0
  8. package/dist/extension/askAdvisorTool.d.ts +18 -3
  9. package/dist/extension/askAdvisorTool.js +121 -15
  10. package/dist/extension/askYagniTool.d.ts +23 -0
  11. package/dist/extension/askYagniTool.js +42 -2
  12. package/dist/extension/branding.d.ts +11 -1
  13. package/dist/extension/branding.js +47 -7
  14. package/dist/extension/config.d.ts +12 -0
  15. package/dist/extension/config.js +2 -1
  16. package/dist/extension/execPolicy.d.ts +17 -1
  17. package/dist/extension/execPolicy.js +164 -33
  18. package/dist/extension/flywheel.d.ts +44 -0
  19. package/dist/extension/flywheel.js +53 -0
  20. package/dist/extension/footer.d.ts +8 -1
  21. package/dist/extension/footer.js +33 -19
  22. package/dist/extension/guardian.d.ts +14 -4
  23. package/dist/extension/guardian.js +35 -11
  24. package/dist/extension/index.d.ts +20 -3
  25. package/dist/extension/index.js +92 -13
  26. package/dist/extension/mineBeat.d.ts +95 -0
  27. package/dist/extension/mineBeat.js +193 -0
  28. package/dist/extension/permission.d.ts +1 -0
  29. package/dist/extension/permission.js +23 -18
  30. package/dist/extension/pipeline/goCommand.js +6 -4
  31. package/dist/extension/pipeline/personas.js +1 -1
  32. package/dist/extension/pipeline/resilience.d.ts +2 -1
  33. package/dist/extension/pipeline/resilience.js +21 -2
  34. package/dist/extension/pipeline/runRegistry.d.ts +9 -1
  35. package/dist/extension/pipeline/runRegistry.js +22 -1
  36. package/dist/extension/recordDecisionTool.d.ts +8 -0
  37. package/dist/extension/recordDecisionTool.js +24 -0
  38. package/dist/extension/subagents.d.ts +7 -1
  39. package/dist/extension/subagents.js +60 -5
  40. package/dist/extension/todos.d.ts +28 -1
  41. package/dist/extension/todos.js +76 -1
  42. package/dist/extension/ultra.d.ts +27 -0
  43. package/dist/extension/ultra.js +76 -0
  44. package/dist/login.d.ts +4 -2
  45. package/dist/login.js +19 -4
  46. package/dist/token.d.ts +25 -0
  47. package/dist/token.js +45 -0
  48. package/package.json +3 -2
@@ -45,6 +45,11 @@
45
45
  * - `#` comments (start-of-word to end-of-line, outside quotes)
46
46
  * - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
47
47
  * inside double quotes — bash executes those), >, <, background &
48
+ * - Redirect metadata: stdout/stderr redirects carry fd + target so that
49
+ * safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
50
+ * (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
51
+ * floor. Background & emits a distinct `background` op (not `semi`) so
52
+ * hasUnhandledConstructs can always catch it.
48
53
  *
49
54
  * Does NOT handle: variable expansion, glob patterns, heredocs beyond the
50
55
  * redirect flag, nested subshells beyond depth tracking. Commands using
@@ -149,11 +154,13 @@ export function shellParse(command) {
149
154
  i += 2;
150
155
  }
151
156
  else {
152
- // Single & — background operator. A construct (floor: prompt), but
153
- // the command before it must still be rule-matched: `rm -rf / &`
154
- // has to stay forbidden, so emit a separator rather than gluing.
157
+ // Single & — background operator. Emits a distinct `background` op
158
+ // (not `semi`) so hasUnhandledConstructs can always catch it and
159
+ // floor the command. The command before it must still be rule-
160
+ // matched: `rm -rf / &` has to stay forbidden, so emit a separator
161
+ // rather than gluing.
155
162
  pushCurrent();
156
- tokens.push({ op: "semi" });
163
+ tokens.push({ op: "background" });
157
164
  hasConstruct = true;
158
165
  i++;
159
166
  }
@@ -163,18 +170,124 @@ export function shellParse(command) {
163
170
  tokens.push({ op: "semi" });
164
171
  i++;
165
172
  continue;
166
- case ">":
167
- case "<":
173
+ case ">": {
168
174
  pushCurrent();
169
- tokens.push({ op: "redirect" });
175
+ // Check for a preceding fd digit: `2>` → stderr, `1>` → stdout.
176
+ // The digit was emitted as a string token — pop it and use as fd.
177
+ let fd = "stdout";
178
+ if (tokens.length > 0 && typeof tokens[tokens.length - 1] === "string") {
179
+ const last = tokens[tokens.length - 1];
180
+ if (last === "2") {
181
+ fd = "stderr";
182
+ tokens.pop();
183
+ }
184
+ else if (last === "1") {
185
+ fd = "stdout";
186
+ tokens.pop();
187
+ }
188
+ }
189
+ let append = false;
190
+ i++;
191
+ if (command[i] === ">") {
192
+ append = true;
193
+ i++;
194
+ }
195
+ while (command[i] === " " || command[i] === "\t")
196
+ i++;
197
+ // Read the target — handles quoted targets (mirrors the main loop's
198
+ // quote logic), fd merges (&N), and bare words.
199
+ let target = "";
200
+ if (command[i] === "&") {
201
+ // fd merge: &1, &2, etc.
202
+ i++;
203
+ let digits = "";
204
+ while (command[i] >= "0" && command[i] <= "9") {
205
+ digits += command[i];
206
+ i++;
207
+ }
208
+ target = "&" + digits;
209
+ }
210
+ else if (command[i] === "'") {
211
+ i++;
212
+ while (i < command.length && command[i] !== "'") {
213
+ target += command[i];
214
+ i++;
215
+ }
216
+ if (i < command.length)
217
+ i++;
218
+ }
219
+ else if (command[i] === '"') {
220
+ i++;
221
+ while (i < command.length && command[i] !== '"') {
222
+ if (command[i] === "\\" && i + 1 < command.length) {
223
+ target += command[i + 1];
224
+ i += 2;
225
+ }
226
+ else {
227
+ target += command[i];
228
+ i++;
229
+ }
230
+ }
231
+ if (i < command.length)
232
+ i++;
233
+ }
234
+ else {
235
+ while (i < command.length &&
236
+ command[i] !== " " && command[i] !== "\t" &&
237
+ command[i] !== "\n" && command[i] !== "\r" &&
238
+ command[i] !== "|" && command[i] !== "&" &&
239
+ command[i] !== ";" && command[i] !== ">" &&
240
+ command[i] !== "<") {
241
+ target += command[i];
242
+ i++;
243
+ }
244
+ }
245
+ tokens.push({ op: "redirect", direction: "out", fd, target, append });
170
246
  hasConstruct = true;
171
- // Skip the operator char(s) and any following space
247
+ continue;
248
+ }
249
+ case "<": {
250
+ pushCurrent();
251
+ // Stdin redirect — always floors (changes program behavior).
172
252
  i++;
173
- if (command[i] === ch)
174
- i++; // >> or <<
253
+ if (command[i] === "<")
254
+ i++; // << heredoc — skip delimiter
175
255
  while (command[i] === " " || command[i] === "\t")
176
256
  i++;
257
+ // Consume the target (filename or heredoc delimiter) so it doesn't
258
+ // appear as a segment token — mirrors the > case's target reading.
259
+ if (command[i] === "'") {
260
+ i++;
261
+ while (i < command.length && command[i] !== "'")
262
+ i++;
263
+ if (i < command.length)
264
+ i++;
265
+ }
266
+ else if (command[i] === '"') {
267
+ i++;
268
+ while (i < command.length && command[i] !== '"') {
269
+ if (command[i] === "\\" && i + 1 < command.length)
270
+ i += 2;
271
+ else
272
+ i++;
273
+ }
274
+ if (i < command.length)
275
+ i++;
276
+ }
277
+ else {
278
+ while (i < command.length &&
279
+ command[i] !== " " && command[i] !== "\t" &&
280
+ command[i] !== "\n" && command[i] !== "\r" &&
281
+ command[i] !== "|" && command[i] !== "&" &&
282
+ command[i] !== ";" && command[i] !== ">" &&
283
+ command[i] !== "<") {
284
+ i++;
285
+ }
286
+ }
287
+ tokens.push({ op: "redirect", direction: "in" });
288
+ hasConstruct = true;
177
289
  continue;
290
+ }
178
291
  case "$":
179
292
  if (command[i + 1] === "(") {
180
293
  pushCurrent();
@@ -213,9 +326,10 @@ export function shellParse(command) {
213
326
  }
214
327
  pushCurrent();
215
328
  // If we detected constructs but didn't emit them as operator tokens
216
- // (e.g. background & or double-quoted substitution), surface that via a
217
- // trailing substitution token so hasUnhandledConstructs sees it.
218
- if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution"))) {
329
+ // (e.g. double-quoted substitution), surface that via a trailing
330
+ // substitution token so hasUnhandledConstructs sees it. Background & now
331
+ // emits its own distinct op, so it no longer relies on this fallback.
332
+ if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution" || t.op === "background"))) {
219
333
  tokens.push({ op: "substitution" });
220
334
  }
221
335
  return tokens;
@@ -294,47 +408,58 @@ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
294
408
  export function tokenize(command) {
295
409
  return shellParse(command).filter((t) => typeof t === "string");
296
410
  }
297
- /** Operator tokens we can safely split on (compound command segments). */
298
- const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
411
+ /** Ops that split compound commands into segments (pipe, &&, ||, ;, background &). */
412
+ const SPLIT_OPS = new Set(["pipe", "and", "or", "semi", "background"]);
413
+ /** Ops that are safe splittable separators — they do NOT trigger the construct floor.
414
+ * Background & is in SPLIT_OPS (splits segments) but NOT here (always floors). */
415
+ const SAFE_SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
416
+ /** A redirect to /dev/null (discard) or &N (fd merge) is safe — no file created. */
417
+ function isSafeRedirect(t) {
418
+ if (typeof t === "string")
419
+ return false;
420
+ if (t.op !== "redirect")
421
+ return false;
422
+ if (t.direction === "in")
423
+ return false; // stdin redirect — always floor
424
+ if (t.target === "/dev/null")
425
+ return true; // discard stderr/stdout — safe
426
+ if (t.target.startsWith("&") && t.target.length > 1)
427
+ return true; // fd merge (2>&1, 1>&2) — safe; bare "&" (>& with no digit) is NOT safe
428
+ return false; // > file.txt, >> file.txt — unsafe
429
+ }
299
430
  /**
300
431
  * Detect whether the command uses shell constructs we can't statically
301
- * classify (command substitution, redirects, background &) — anything that
302
- * is NOT a splittable operator. These impose a floor of `prompt`: a command
303
- * carrying them is never auto-allowed, but forbidden matches still win.
432
+ * classify (command substitution, unsafe redirects, background &) — anything
433
+ * that is NOT a safe splittable operator or a safe redirect. These impose a
434
+ * floor of `prompt`: a command carrying them is never auto-allowed, but
435
+ * forbidden matches still win.
304
436
  */
305
437
  function hasUnhandledConstructs(command) {
306
- return shellParse(command).some((t) => typeof t === "object" && "op" in t && !SPLIT_OPS.has(t.op));
438
+ return shellParse(command).some((t) => typeof t === "object" && !SAFE_SPLIT_OPS.has(t.op) && !isSafeRedirect(t));
307
439
  }
308
440
  /**
309
441
  * Split a command into token-array segments at control operators (|, &&, ||,
310
- * ;, newline). Redirect targets (the token after > or <) are dropped from the
311
- * segment they are filenames, not arguments to rule-match. Token arrays are
312
- * carried through (never re-joined into strings) so quoting survives.
442
+ * ;, newline, background &). Redirect targets are consumed inside the
443
+ * tokenizer's > / < cases (stored on the redirect op), so no skipNext logic
444
+ * is needed. Token arrays are carried through (never re-joined into strings)
445
+ * so quoting survives.
313
446
  */
314
447
  function splitSegmentsTokens(command) {
315
448
  const parsed = shellParse(command);
316
449
  const segments = [];
317
450
  let current = [];
318
- let skipNext = false;
319
451
  for (const t of parsed) {
320
452
  if (typeof t === "object") {
321
453
  if (SPLIT_OPS.has(t.op)) {
322
454
  if (current.length > 0)
323
455
  segments.push(current);
324
456
  current = [];
325
- skipNext = false;
326
- }
327
- else if (t.op === "redirect") {
328
- skipNext = true;
329
457
  }
330
- // substitution ops are construct markers; the inner text is handled
331
- // by dangerScan via extractSubstitutions.
458
+ // redirect and substitution ops are construct markers; their targets
459
+ // are consumed inside the tokenizer, and the inner text of $(...) is
460
+ // handled by dangerScan via extractSubstitutions.
332
461
  }
333
462
  else {
334
- if (skipNext) {
335
- skipNext = false;
336
- continue;
337
- }
338
463
  current.push(t);
339
464
  }
340
465
  }
@@ -510,6 +635,9 @@ function classifySegmentTokens(rawTokens, policy, opts) {
510
635
  const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
511
636
  if (tailResult.decision === "forbidden")
512
637
  return tailResult;
638
+ if (tailResult.decision === "allow" && !neverAllow) {
639
+ return { decision: "allow", justification: "xargs forwards to a read-only command" };
640
+ }
513
641
  }
514
642
  if (opts.forbiddenOnly)
515
643
  return { decision: "allow", justification: "no forbidden match" };
@@ -727,6 +855,7 @@ export const DEFAULT_EXEC_POLICY = {
727
855
  { pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
728
856
  { pattern: ["stat"], decision: "allow", justification: "show file metadata" },
729
857
  { pattern: ["file"], decision: "allow", justification: "identify file type" },
858
+ { pattern: ["strings"], decision: "allow", justification: "extract printable strings from binary files (read-only)" },
730
859
  { pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
731
860
  { pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
732
861
  { pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
@@ -760,7 +889,9 @@ export const DEFAULT_EXEC_POLICY = {
760
889
  { pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
761
890
  { pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
762
891
  { pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
892
+ { pattern: ["npm", "list"], decision: "allow", justification: "list installed packages" },
763
893
  { pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
894
+ { pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
764
895
  { pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
765
896
  { pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
766
897
  // --- prompt: potentially destructive but context-dependent ---
@@ -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
@@ -41,6 +41,9 @@
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
42
  import type { ModeHolder, PermissionMode } from "./permission.js";
43
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;
44
47
  /**
45
48
  * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
46
49
  * aligns with the editor input and the chat/output area on one shared column.
@@ -102,7 +105,11 @@ export declare function renderFooterLines(input: {
102
105
  * and returns the component `setFooter` expects. Called from the
103
106
  * `session_start` handler in index.ts.
104
107
  */
105
- export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
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) => {
106
113
  render(width: number): string[];
107
114
  invalidate(): void;
108
115
  dispose(): void;
@@ -41,11 +41,20 @@
41
41
  import { spawnSync } from "node:child_process";
42
42
  import { statSync } from "node:fs";
43
43
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
44
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
45
  export const BRANCH_MAX_WIDTH = 60;
46
46
  const WORKTREE_MAX_WIDTH = 30;
47
47
  /** Section separator: single space + middle dot + single space. */
48
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/;
49
58
  /** Default horizontal pad when the launcher didn't forward one (matches outputPad=1). */
50
59
  const DEFAULT_PAD_X = 1;
51
60
  /**
@@ -107,10 +116,10 @@ export function collectUsage(sessionManager) {
107
116
  return totals;
108
117
  }
109
118
  /** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
110
- function truncateEnd(text, maxWidth) {
119
+ function truncateEnd(text, maxWidth, ellipsis = "…") {
111
120
  if (visibleWidth(text) <= maxWidth)
112
121
  return text;
113
- return truncateToWidth(text, maxWidth, "…");
122
+ return truncateToWidth(text, maxWidth, ellipsis);
114
123
  }
115
124
  function runGit(args, cwd) {
116
125
  try {
@@ -193,15 +202,23 @@ export function detectGitInfo(cwd, home) {
193
202
  const worktree = resolveWorktreeLabel(root, branch);
194
203
  return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
195
204
  }
196
- /** Context color: dim below 70, warning 70-90, error above 90. */
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. */
197
214
  function contextColor(percent) {
198
215
  if (percent === null)
199
- return "dim";
216
+ return "success";
200
217
  if (percent > 90)
201
218
  return "error";
202
219
  if (percent > 70)
203
220
  return "warning";
204
- return "dim";
221
+ return "success";
205
222
  }
206
223
  /** Pure line-builder, exported for tests. All data injected; colors via theme. */
207
224
  export function renderFooterLines(input, theme, width, padX = 0) {
@@ -218,7 +235,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
218
235
  if (input.git.worktree)
219
236
  line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
220
237
  if (input.git.branch)
221
- line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
238
+ line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
222
239
  }
223
240
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
224
241
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
@@ -232,8 +249,10 @@ export function renderFooterLines(input, theme, width, padX = 0) {
232
249
  const stats = statParts.join(" ");
233
250
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
234
251
  const line2Parts = [];
235
- if (input.mode)
236
- line2Parts.push(dim(`${input.mode} mode`));
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
+ }
237
256
  line2Parts.push(dim(input.model));
238
257
  if (stats)
239
258
  line2Parts.push(dim(stats));
@@ -247,17 +266,8 @@ export function renderFooterLines(input, theme, width, padX = 0) {
247
266
  }
248
267
  return lines;
249
268
  }
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) {
269
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
256
270
  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
271
  let gitCache;
262
272
  const unsubscribeBranch = footerData.onBranchChange?.(() => {
263
273
  gitCache = undefined;
@@ -268,6 +278,10 @@ export function createYagniFooterFactory(ctx, modeHolder) {
268
278
  }
269
279
  return gitCache;
270
280
  };
281
+ if (invalidateHandle) {
282
+ invalidateHandle.invalidateGit = () => { gitCache = undefined; };
283
+ invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
+ }
271
285
  return {
272
286
  render(width) {
273
287
  const statuses = [...footerData.getExtensionStatuses().entries()]
@@ -37,7 +37,7 @@ export interface GuardianVerdict {
37
37
  rationale: string;
38
38
  }
39
39
  export interface GuardianLimits {
40
- /** Session cap on total Guardian reviews. */
40
+ /** Cap on Guardian reviews within the sliding window ({@link GUARDIAN_REVIEW_WINDOW_MS}). */
41
41
  maxReviews: number;
42
42
  /** Consecutive denials per turn before the circuit breaker trips. */
43
43
  maxConsecutiveDenials: number;
@@ -47,15 +47,25 @@ export interface GuardianLimits {
47
47
  export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
48
48
  /**
49
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).
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).
52
53
  */
53
54
  export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
54
55
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
55
56
  export declare const GUARDIAN_MODEL_TIER = "efficient";
56
57
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
57
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;
58
67
  export interface GuardianState {
68
+ /** Guardian consults within the last {@link GUARDIAN_REVIEW_WINDOW_MS}. */
59
69
  reviews: number;
60
70
  consecutiveDenials: number;
61
71
  }
@@ -64,7 +74,7 @@ export interface GuardianStateHandle {
64
74
  recordReview(outcome: GuardianOutcome): GuardianState;
65
75
  resetTurn(): void;
66
76
  }
67
- export declare function makeGuardianState(): GuardianStateHandle;
77
+ export declare function makeGuardianState(now?: () => number): GuardianStateHandle;
68
78
  export interface CircuitBreakerResult {
69
79
  tripped: boolean;
70
80
  reason?: string;
@@ -29,14 +29,15 @@
29
29
  */
30
30
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
31
31
  export const DEFAULT_GUARDIAN_LIMITS = {
32
- maxReviews: 30,
32
+ maxReviews: 120,
33
33
  maxConsecutiveDenials: 3,
34
34
  timeoutMs: 15_000,
35
35
  };
36
36
  /**
37
37
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
38
- * overrides the session review cap; anything non-numeric or < 1 falls back to
39
- * the default (a bad value must never zero out the cap and lock the session).
38
+ * overrides the sliding-window review cap; anything non-numeric or < 1 falls
39
+ * back to the default (a bad value must never zero out the cap and lock the
40
+ * session).
40
41
  */
41
42
  export function resolveGuardianLimits(env = process.env) {
42
43
  const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
@@ -48,25 +49,48 @@ export function resolveGuardianLimits(env = process.env) {
48
49
  export const GUARDIAN_MODEL_TIER = "efficient";
49
50
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
50
51
  export const GUARDIAN_TOOLS = ["read"];
51
- export function makeGuardianState() {
52
- const state = { reviews: 0, consecutiveDenials: 0 };
52
+ // --- State ---
53
+ /**
54
+ * The review-cap window. `reviews` counts consults inside a SLIDING window
55
+ * rather than for the session's lifetime: a 24/7 session (a fleet operator's
56
+ * always-on terminal) must regain review capacity as old consults age out,
57
+ * not hard-block forever after the first N. The cap is a cost/runaway bound,
58
+ * not a safety bound — safety is the verdicts themselves.
59
+ */
60
+ export const GUARDIAN_REVIEW_WINDOW_MS = 60 * 60_000;
61
+ export function makeGuardianState(now = Date.now) {
62
+ const reviewTimes = [];
63
+ let consecutiveDenials = 0;
64
+ const prune = () => {
65
+ const cutoff = now() - GUARDIAN_REVIEW_WINDOW_MS;
66
+ while (reviewTimes.length > 0 && reviewTimes[0] <= cutoff)
67
+ reviewTimes.shift();
68
+ };
69
+ const snapshot = () => ({
70
+ reviews: reviewTimes.length,
71
+ consecutiveDenials,
72
+ });
53
73
  return {
54
- read: () => ({ ...state }),
74
+ read: () => {
75
+ prune();
76
+ return snapshot();
77
+ },
55
78
  recordReview(outcome) {
56
- state.reviews += 1;
79
+ prune();
80
+ reviewTimes.push(now());
57
81
  if (outcome === "deny") {
58
- state.consecutiveDenials += 1;
82
+ consecutiveDenials += 1;
59
83
  }
60
84
  else if (outcome === "allow") {
61
- state.consecutiveDenials = 0;
85
+ consecutiveDenials = 0;
62
86
  }
63
87
  // "ask" leaves the denial streak UNCHANGED: it is neither a denial nor
64
88
  // an exoneration. If it reset the streak, deny/ask/deny/ask would never
65
89
  // trip the breaker (round-2 review blocker).
66
- return { ...state };
90
+ return snapshot();
67
91
  },
68
92
  resetTurn() {
69
- state.consecutiveDenials = 0;
93
+ consecutiveDenials = 0;
70
94
  },
71
95
  };
72
96
  }