@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
@@ -4,7 +4,8 @@
4
4
  * YAGNI Code registers no tool_call handler today, so the interactive session has
5
5
  * no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
6
6
  * plus a `/mode` command:
7
- * - auto (default): never blocks. Byte-identical to today, so this is additive.
7
+ * - auto (default): ordinary coding tools run directly; external tracker
8
+ * changes still require fresh human confirmation.
8
9
  * - plan : blocks write/edit/bash so the agent can explore + propose without
9
10
  * touching the tree.
10
11
  * - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
@@ -14,7 +15,7 @@
14
15
  * adds a session-scoped bless rule AND drafts a decision capture.
15
16
  *
16
17
  * `decideGate` is PURE; the live wiring holds the mode in a small closure (no
17
- * module-global state). The default auto mode remains fail-open, but stricter
18
+ * module-global state). The default auto mode remains direct for coding tools, but stricter
18
19
  * modes fail closed for side-effect tools if the gate itself errors. Bless rules
19
20
  * are session-scoped, path-prefix-bound, never persisted, and never consulted in
20
21
  * plan mode (plan blocks outright before isBlessed is reached).
@@ -25,19 +26,35 @@
25
26
  * When the mode leaves plan, stale plan-context messages are filtered out of
26
27
  * the context so the model doesn't keep believing it is restricted.
27
28
  */
29
+ import { describePrefix, matchesGrant, validateGrant, } from "./approvedPrefixes.js";
28
30
  import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
31
+ import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
32
+ import { isDebug } from "./diagnostics.js";
33
+ import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
34
+ export function createModeHolder(initial = "auto") {
35
+ let current = initial;
36
+ const listeners = new Set();
37
+ return {
38
+ get: () => current,
39
+ set: (m) => {
40
+ current = m;
41
+ for (const fn of listeners)
42
+ fn(m);
43
+ },
44
+ onSet: (fn) => { listeners.add(fn); },
45
+ };
46
+ }
29
47
  export const DEFAULT_PERMISSION_POLICY = {
30
- planBlockTools: ["write", "edit", "bash"],
31
- reviewConfirmTools: ["write", "edit", "bash"],
48
+ planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
49
+ reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
50
+ alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
32
51
  };
33
52
  /**
34
- * Pure permission decision for one tool call under a mode + policy. auto always
35
- * allows; plan blocks the write/exec set; review marks writes for confirmation
53
+ * Pure permission decision for one tool call under a mode + policy. Auto allows
54
+ * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
36
55
  * unless a recorded decision blesses them.
37
56
  */
38
57
  export function decideGate(toolName, params, mode, policy) {
39
- if (mode === "auto")
40
- return { block: false };
41
58
  if (mode === "plan") {
42
59
  if (policy.planBlockTools.includes(toolName)) {
43
60
  return {
@@ -47,6 +64,47 @@ export function decideGate(toolName, params, mode, policy) {
47
64
  }
48
65
  return { block: false };
49
66
  }
67
+ if (policy.alwaysConfirmTools?.includes(toolName)) {
68
+ return { block: false, confirm: true };
69
+ }
70
+ // Exec policy: classify bash commands before the tool-granular logic.
71
+ if (toolName === "bash") {
72
+ const command = typeof params.command === "string" ? params.command.trim() : "";
73
+ if (command) {
74
+ try {
75
+ const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
76
+ const classification = classifyCommand(command, execPolicy);
77
+ if (classification.decision === "allow")
78
+ return { block: false };
79
+ if (classification.decision === "forbidden") {
80
+ return {
81
+ block: true,
82
+ reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
83
+ };
84
+ }
85
+ // prompt — signal to the handler so it can run the Guardian.
86
+ // In auto mode the handler runs the Guardian; in review mode the
87
+ // handler runs the Guardian first, then falls back to user confirm.
88
+ if (mode === "auto") {
89
+ return { block: false, classify: "prompt", classifyJustification: classification.justification };
90
+ }
91
+ // review mode
92
+ if (policy.isBlessed?.(toolName, params))
93
+ return { block: false };
94
+ return {
95
+ block: false,
96
+ confirm: true,
97
+ classify: "prompt",
98
+ classifyJustification: classification.justification,
99
+ };
100
+ }
101
+ catch {
102
+ // classifyCommand threw — fall through to tool-granular logic (graceful degradation).
103
+ }
104
+ }
105
+ }
106
+ if (mode === "auto")
107
+ return { block: false };
50
108
  // review
51
109
  if (policy.reviewConfirmTools.includes(toolName)) {
52
110
  if (policy.isBlessed?.(toolName, params))
@@ -55,9 +113,13 @@ export function decideGate(toolName, params, mode, policy) {
55
113
  }
56
114
  return { block: false };
57
115
  }
58
- /** The customType tag on injected plan-mode context (filterable later). */
59
- export const PLAN_CONTEXT_TYPE = "yagni-plan-context";
116
+ /** The customType tag on injected mode-context messages (filterable later). */
117
+ export const MODE_CONTEXT_TYPE = "yagni-mode-context";
118
+ /** Legacy alias — the original plan-mode tag, kept for backward compat. */
119
+ export const PLAN_CONTEXT_TYPE = MODE_CONTEXT_TYPE;
60
120
  const PLAN_MARKER = "[PLAN MODE ACTIVE]";
121
+ const AUTO_MARKER = "[AUTO MODE]";
122
+ const REVIEW_MARKER = "[REVIEW MODE]";
61
123
  export const PLAN_CONTEXT_MESSAGE = `${PLAN_MARKER}
62
124
  You are in plan mode: explore and design, change nothing.
63
125
  - write, edit, and bash are held by the permission gate; do not attempt them.
@@ -65,38 +127,86 @@ You are in plan mode: explore and design, change nothing.
65
127
  - Produce a concrete numbered plan of the steps you would take, with the files involved.
66
128
  - End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.
67
129
  - Once executing, track the plan's steps with todo_write.`;
68
- function mentionsPlanMarker(content) {
130
+ const AUTO_CONTEXT_MESSAGE = `${AUTO_MARKER}
131
+ You are in auto mode. Coding commands run directly.
132
+ - Proactively verify your work: run tests, lint, and typecheck after changes.
133
+ - Destructive commands (rm -rf, git reset --hard, git push --force) are blocked by the exec policy.
134
+ - Ambiguous commands are reviewed by the Guardian before running.`;
135
+ const REVIEW_CONTEXT_MESSAGE = `${REVIEW_MARKER}
136
+ You are in review mode. Each bash, write, and edit may be confirmed before running.
137
+ - Safe commands (ls, cat, git status) run without prompting.
138
+ - Ambiguous commands are reviewed by the Guardian first; if the Guardian allows, they run without prompting.
139
+ - If the Guardian denies or is unavailable, you will be asked to confirm.
140
+ - Propose verification steps but wait for approval before running them.`;
141
+ /** Build the mode-awareness context message for the current permission mode. */
142
+ export function buildModeContextMessage(mode) {
143
+ switch (mode) {
144
+ case "plan":
145
+ return PLAN_CONTEXT_MESSAGE;
146
+ case "review":
147
+ return REVIEW_CONTEXT_MESSAGE;
148
+ case "auto":
149
+ return AUTO_CONTEXT_MESSAGE;
150
+ }
151
+ }
152
+ const MODE_MARKERS = {
153
+ plan: PLAN_MARKER,
154
+ auto: AUTO_MARKER,
155
+ review: REVIEW_MARKER,
156
+ };
157
+ function mentionsModeMarker(content) {
69
158
  if (typeof content === "string")
70
- return content.includes(PLAN_MARKER);
159
+ return Object.values(MODE_MARKERS).some((m) => content.includes(m));
71
160
  if (Array.isArray(content)) {
72
161
  return content.some((c) => typeof c?.text === "string" &&
73
- (c.text.includes(PLAN_MARKER)));
162
+ Object.values(MODE_MARKERS).some((m) => c.text.includes(m)));
74
163
  }
75
164
  return false;
76
165
  }
166
+ /** Which mode marker does this message carry, if any? */
167
+ function modeMarkerFor(content) {
168
+ const text = typeof content === "string"
169
+ ? content
170
+ : Array.isArray(content)
171
+ ? content.find((c) => typeof c?.text === "string")?.text
172
+ : undefined;
173
+ if (!text)
174
+ return null;
175
+ for (const [mode, marker] of Object.entries(MODE_MARKERS)) {
176
+ if (text.includes(marker))
177
+ return marker;
178
+ }
179
+ return null;
180
+ }
77
181
  /**
78
- * Drop previously injected plan-mode context once plan mode is off, so the
79
- * model stops believing writes are held. Pure; returns the SAME array when
80
- * nothing needs filtering so callers can cheaply detect a no-op.
182
+ * Drop previously injected mode-context messages from a DIFFERENT mode so the
183
+ * model does not keep believing it is in a prior mode. Messages matching the
184
+ * current mode are kept (the fresh injection from before_agent_start should
185
+ * survive). Pure; returns the SAME array when nothing needs filtering so callers
186
+ * can cheaply detect a no-op.
81
187
  */
82
- export function filterStalePlanContext(messages) {
188
+ export function filterStaleModeContext(messages, currentMode) {
189
+ const currentMarker = currentMode ? MODE_MARKERS[currentMode] : undefined;
83
190
  const keep = messages.filter((m) => {
84
191
  const msg = m;
85
- if (msg?.customType === PLAN_CONTEXT_TYPE)
86
- return false;
87
- if (msg?.role === "user" && mentionsPlanMarker(msg.content))
88
- return false;
192
+ if (msg?.customType === MODE_CONTEXT_TYPE) {
193
+ // Keep if it matches the current mode; strip if from a different mode
194
+ // (or if we don't know the current mode — strip all to be safe).
195
+ const marker = modeMarkerFor(msg.content);
196
+ return currentMarker !== undefined && marker === currentMarker;
197
+ }
198
+ if (msg?.role === "user" && mentionsModeMarker(msg.content)) {
199
+ const marker = modeMarkerFor(msg.content);
200
+ return currentMarker !== undefined && marker === currentMarker;
201
+ }
89
202
  return true;
90
203
  });
91
204
  return keep.length === messages.length ? messages : keep;
92
205
  }
93
- const MODE_STATUS = {
94
- auto: undefined,
95
- plan: "⏸ plan",
96
- review: "✓ review",
97
- };
206
+ /** Legacy alias — the original plan-mode filter name. */
207
+ export const filterStalePlanContext = filterStaleModeContext;
98
208
  const MODE_COPY = {
99
- auto: "auto: changes apply without prompting (default).",
209
+ auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
100
210
  plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
101
211
  review: "review: you confirm each write, edit, or bash command before it applies.",
102
212
  };
@@ -104,7 +214,41 @@ function isMode(value) {
104
214
  return value === "auto" || value === "plan" || value === "review";
105
215
  }
106
216
  function sideEffectTools(policy) {
107
- return new Set([...policy.planBlockTools, ...policy.reviewConfirmTools]);
217
+ return new Set([
218
+ ...policy.planBlockTools,
219
+ ...policy.reviewConfirmTools,
220
+ ...(policy.alwaysConfirmTools ?? []),
221
+ ]);
222
+ }
223
+ function boundedPromptValue(value, fallback) {
224
+ if (typeof value !== "string")
225
+ return fallback;
226
+ const normalized = value.replace(/\s+/g, " ").trim();
227
+ if (normalized.length === 0)
228
+ return fallback;
229
+ return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}…`;
230
+ }
231
+ function externalTrackerPrompt(toolName, input) {
232
+ if (toolName === "file_ticket") {
233
+ const title = boundedPromptValue(input.title, "Untitled ticket");
234
+ const target = boundedPromptValue(input.target_key, "default project/team");
235
+ return `File “${title}” in ${target}?`;
236
+ }
237
+ if (toolName === "update_ticket_status") {
238
+ const ref = boundedPromptValue(input.ref, "ticket");
239
+ const status = boundedPromptValue(input.status, "requested status");
240
+ return `Move ${ref} to ${status}?`;
241
+ }
242
+ return "Confirm external tracker change";
243
+ }
244
+ function guardianErrorMessage(error) {
245
+ switch (error) {
246
+ case "timeout": return "review timed out";
247
+ case "malformed": return "unclear verdict";
248
+ case "empty": return "no response";
249
+ case "network": return "service unavailable";
250
+ default: return "unknown error";
251
+ }
108
252
  }
109
253
  /**
110
254
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
@@ -114,6 +258,11 @@ export function registerPermissionGate(pi, deps = {}) {
114
258
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
115
259
  let mode = deps.mode ?? "auto";
116
260
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
261
+ deps.modeHolder?.onSet((m) => {
262
+ if (m !== mode)
263
+ approvedCommands.clear();
264
+ mode = m;
265
+ });
117
266
  // The session bless store is created lazily on the first tool_call (it needs
118
267
  // the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
119
268
  // injected its own isBlessed (e.g. a test policy) — that always wins.
@@ -121,22 +270,420 @@ export function registerPermissionGate(pi, deps = {}) {
121
270
  const effectivePolicy = {
122
271
  planBlockTools: basePolicy.planBlockTools,
123
272
  reviewConfirmTools: basePolicy.reviewConfirmTools,
273
+ alwaysConfirmTools: basePolicy.alwaysConfirmTools,
274
+ // execPolicy MUST be carried through: decideGate reads policy.execPolicy
275
+ // and dropping it here silently reverts every custom policy to the
276
+ // default (round-2 review blocker).
277
+ execPolicy: basePolicy.execPolicy,
124
278
  isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
125
279
  };
126
280
  const sideEffects = sideEffectTools(effectivePolicy);
281
+ const guardianState = deps.guardianState;
282
+ const guardianLimits = deps.guardianLimits;
283
+ const guardianDisabled = deps.guardianDisabled ?? false;
284
+ const guardianReview = deps.guardianReview;
285
+ const guardianTier = deps.guardianTier;
286
+ // --- YAG-510 gate state ---
287
+ // Grants: in-memory list seeded from deps, appended on "don't ask again".
288
+ // Deliberately NOT live-reloaded from disk: auto mode can write files, so a
289
+ // mid-session re-read of rules.json would let the agent (or a prompt
290
+ // injection) author its own grants and self-authorize within the same
291
+ // session. New grants from concurrent sessions apply at next launch — the
292
+ // startup load is the trust boundary (PR #1698 review).
293
+ const grants = [...(deps.grants ?? [])];
294
+ // Keyed by cwd: a session can change working directory (cd, /go worktrees),
295
+ // and a repoKey memoized from the first cwd would let repo-A grants match
296
+ // commands running in repo B (PR #1694 review).
297
+ const repoKeys = new Map();
298
+ const resolveRepoKeyFor = (cwd) => {
299
+ let key = repoKeys.get(cwd);
300
+ if (key === undefined) {
301
+ key = deps.resolveRepoKey ? deps.resolveRepoKey(cwd) : cwd;
302
+ repoKeys.set(cwd, key);
303
+ }
304
+ return key;
305
+ };
306
+ // Session exact-command approval cache (ticket 4.5): a user-approved ask
307
+ // covers an identical later command. Keyed by cwd + trimmed command,
308
+ // LRU-capped, cleared on every /mode transition.
309
+ const APPROVED_CACHE_MAX = 50;
310
+ const approvedCommands = new Map();
311
+ const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
312
+ const rememberApproved = (cwd, command) => {
313
+ const key = cacheKey(cwd, command);
314
+ approvedCommands.delete(key);
315
+ approvedCommands.set(key, true);
316
+ if (approvedCommands.size > APPROVED_CACHE_MAX) {
317
+ const oldest = approvedCommands.keys().next().value;
318
+ if (oldest !== undefined)
319
+ approvedCommands.delete(oldest);
320
+ }
321
+ };
322
+ // Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
323
+ // user prompt — NOT per LLM turn): genuine ask verdicts are uncapped (the
324
+ // user's patience is the bound); error-fallback asks are capped so
325
+ // a provider outage can't become an ask storm; the breaker escalation is
326
+ // offered once, and a decline latches back to hard blocks.
327
+ const ERROR_ASK_CAP = 3;
328
+ let errorFallbackAsks = 0;
329
+ let breakerEscalationOffered = false;
330
+ const emitGateEvent = (event) => {
331
+ if (!deps.onGuardianEvent)
332
+ return;
333
+ try {
334
+ void Promise.resolve(deps.onGuardianEvent(event)).catch(() => { });
335
+ }
336
+ catch {
337
+ // Fail-soft: storage must never affect the gate.
338
+ }
339
+ };
340
+ /** Bounded single-line command rendering for dialog titles. */
341
+ const boundedCommand = (command) => {
342
+ const flat = command.replace(/\s+/g, " ").trim();
343
+ return flat.length <= 240 ? flat : `${flat.slice(0, 237)}…`;
344
+ };
345
+ const ASK_TIMEOUT_MS = 120_000;
346
+ const ASK_YES = "Yes, run it";
347
+ const ASK_NO = "No";
348
+ /**
349
+ * The single human-in-the-loop ask surface (YAG-510): used for ask
350
+ * verdicts, Guardian-unavailable/disabled fallbacks, and the breaker
351
+ * escalation — one UI, one cache, one event stream. Always passes the
352
+ * turn's abort signal (without it a turn-abort leaves the dialog hanging)
353
+ * and a timeout (pi renders a countdown; expiry fails closed).
354
+ */
355
+ const askUser = async (ctx, title, rememberLabel) => {
356
+ if (ctx.signal?.aborted)
357
+ return "aborted";
358
+ const options = rememberLabel ? [ASK_YES, rememberLabel, ASK_NO] : [ASK_YES, ASK_NO];
359
+ let choice;
360
+ try {
361
+ choice = await ctx.ui.select(title, options, {
362
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
363
+ timeout: ASK_TIMEOUT_MS,
364
+ });
365
+ }
366
+ catch {
367
+ choice = undefined;
368
+ }
369
+ if (choice === ASK_YES)
370
+ return "yes";
371
+ if (rememberLabel !== null && choice === rememberLabel)
372
+ return "remember";
373
+ if (choice === ASK_NO)
374
+ return "no";
375
+ return ctx.signal?.aborted ? "aborted" : "dismissed";
376
+ };
377
+ const buildAskTitle = (command, rationale, riskLevel) => {
378
+ const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
379
+ return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
380
+ };
127
381
  pi.on("tool_call", async (event, ctx) => {
382
+ // Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
383
+ // of the closure variable would disagree with the decision already made.
384
+ const modeAtEntry = mode;
128
385
  try {
129
386
  const input = event.input ?? {};
130
- const decision = decideGate(event.toolName, input, mode, effectivePolicy);
387
+ const decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
131
388
  if (decision.block)
132
389
  return { block: true, reason: decision.reason };
390
+ // Prompt band (YAG-510 order): grants → exact-command cache → cap/
391
+ // breaker → Guardian consult → allow/ask/deny. Grants and the cache are
392
+ // checked BEFORE the cap and breaker: a user-approved command must
393
+ // never be blocked by "review cap reached".
394
+ if (decision.classify === "prompt") {
395
+ const command = typeof input.command === "string"
396
+ ? input.command.trim()
397
+ : "";
398
+ const cwd = ctx?.cwd ?? ".";
399
+ const execJustification = decision.classifyJustification;
400
+ const eventBase = {
401
+ command,
402
+ ...(execJustification ? { execJustification } : {}),
403
+ mode: modeAtEntry,
404
+ ...(guardianTier ? { tier: guardianTier } : {}),
405
+ };
406
+ // 1. Persisted grants — auto mode only (review's contract is
407
+ // confirm-each-command). A grant can never cover forbidden commands:
408
+ // decideGate already returned block for those.
409
+ if (modeAtEntry === "auto" && command) {
410
+ const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
411
+ if (grant) {
412
+ emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
413
+ return {};
414
+ }
415
+ }
416
+ // 2. Session exact-command approval cache (ticket 4.5).
417
+ if (command && approvedCommands.has(cacheKey(cwd, command))) {
418
+ emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
419
+ return {};
420
+ }
421
+ const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
422
+ const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
423
+ if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
424
+ // Sliding-window consult cap (capacity recovers as old reviews age
425
+ // out — a long-lived session is never bricked). Review mode falls
426
+ // through to its ordinary confirm (no LLM cost); auto blocks.
427
+ if (modeAtEntry === "auto") {
428
+ if (ctx?.hasUI)
429
+ ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
430
+ return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} in the last hour). Capacity recovers as older reviews age out; switch to /mode review to approve manually, or retry this step later.` };
431
+ }
432
+ // fall through to decision.confirm below
433
+ }
434
+ else if (guardianAvailable) {
435
+ // Circuit breaker (pre-consult). With a UI, escalate to ONE ask per
436
+ // user prompt — asking beats stopping; a decline latches back to
437
+ // hard blocks for the rest of the prompt.
438
+ const breaker = checkCircuitBreaker(guardianState.read(), limits);
439
+ if (breaker.tripped) {
440
+ if (ctx?.hasUI && !breakerEscalationOffered && !ctx.signal?.aborted) {
441
+ breakerEscalationOffered = true;
442
+ const title = `Guardian denied ${guardianState.read().consecutiveDenials} commands in a row.\nAllow the latest command anyway?\n$ ${boundedCommand(command)}`;
443
+ const resolution = await askUser(ctx, title, null);
444
+ if (resolution === "yes") {
445
+ guardianState.resetTurn();
446
+ rememberApproved(cwd, command);
447
+ emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
448
+ return {};
449
+ }
450
+ if (resolution === "aborted") {
451
+ emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
452
+ return { block: true };
453
+ }
454
+ }
455
+ emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
456
+ if (ctx?.hasUI)
457
+ ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
458
+ return { block: true, reason: breaker.reason };
459
+ }
460
+ // Show the reviewing chip.
461
+ if (ctx?.hasUI)
462
+ ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
463
+ const startMs = Date.now();
464
+ let reviewResult;
465
+ try {
466
+ reviewResult = await guardianReview(command, {
467
+ cwd,
468
+ ...(ctx?.signal ? { signal: ctx.signal } : {}),
469
+ ...(guardianTier ? { modelTier: guardianTier } : {}),
470
+ timeoutMs: limits.timeoutMs,
471
+ ...(execJustification ? { execJustification } : {}),
472
+ });
473
+ }
474
+ catch {
475
+ reviewResult = { verdict: null, error: "network", cost: 0 };
476
+ }
477
+ finally {
478
+ if (ctx?.hasUI)
479
+ ctx.ui.setStatus?.("yagni-guardian", undefined);
480
+ }
481
+ const durationMs = Date.now() - startMs;
482
+ const emitDiag = (outcome, rationale) => {
483
+ if (!deps.onGuardianReview)
484
+ return;
485
+ void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
486
+ durationMs,
487
+ tier: guardianTier,
488
+ ...(rationale ? { rationale } : {}),
489
+ debug: isDebug(),
490
+ }))).catch(() => { });
491
+ };
492
+ const verdict = reviewResult.verdict;
493
+ if (verdict?.outcome === "allow") {
494
+ guardianState.recordReview("allow");
495
+ emitDiag("allow", verdict.rationale);
496
+ emitGateEvent({
497
+ ...eventBase,
498
+ outcome: "allow",
499
+ riskLevel: verdict.riskLevel,
500
+ rationale: verdict.rationale,
501
+ durationMs,
502
+ consulted: true,
503
+ });
504
+ return {};
505
+ }
506
+ if (verdict?.outcome === "deny") {
507
+ guardianState.recordReview("deny");
508
+ const rationale = verdict.rationale;
509
+ emitDiag("deny", rationale);
510
+ emitGateEvent({
511
+ ...eventBase,
512
+ outcome: "deny",
513
+ riskLevel: verdict.riskLevel,
514
+ rationale,
515
+ durationMs,
516
+ consulted: true,
517
+ });
518
+ // Check circuit breaker after recording.
519
+ const breaker2 = checkCircuitBreaker(guardianState.read(), limits);
520
+ if (breaker2.tripped) {
521
+ if (ctx?.hasUI)
522
+ ctx.ui.notify(breaker2.reason ?? "Guardian circuit breaker tripped.", "warning");
523
+ }
524
+ else if (ctx?.hasUI) {
525
+ ctx.ui.notify(`Guardian denied: ${rationale}`, "warning");
526
+ }
527
+ return {
528
+ block: true,
529
+ reason: `Guardian denied: ${rationale} Do not attempt the same outcome via a workaround or indirect execution — find a materially safer alternative, or ask the user to proceed.`,
530
+ };
531
+ }
532
+ if (verdict?.outcome === "ask") {
533
+ guardianState.recordReview("ask");
534
+ emitDiag("ask", verdict.rationale);
535
+ if (!ctx?.hasUI) {
536
+ // Headless (includes every /go child stage): fail closed.
537
+ emitGateEvent({
538
+ ...eventBase,
539
+ outcome: "ask_headless_blocked",
540
+ riskLevel: verdict.riskLevel,
541
+ rationale: verdict.rationale,
542
+ durationMs,
543
+ consulted: true,
544
+ });
545
+ return {
546
+ block: true,
547
+ reason: `Guardian needs user approval: ${verdict.rationale} No UI available — the command was held. Find a safer alternative or leave this step for the user.`,
548
+ };
549
+ }
550
+ // Offer "don't ask again" only when the grant would actually
551
+ // cover this command (grant-time validation).
552
+ const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
553
+ const rememberLabel = grantCandidate
554
+ ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
555
+ : null;
556
+ const resolution = await askUser(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel);
557
+ if (resolution === "yes") {
558
+ rememberApproved(cwd, command);
559
+ emitGateEvent({
560
+ ...eventBase,
561
+ outcome: "ask_approved",
562
+ riskLevel: verdict.riskLevel,
563
+ rationale: verdict.rationale,
564
+ durationMs,
565
+ consulted: true,
566
+ });
567
+ return {};
568
+ }
569
+ if (resolution === "remember" && grantCandidate) {
570
+ const grantRecord = {
571
+ ...grantCandidate,
572
+ cwd,
573
+ addedAt: new Date().toISOString(),
574
+ };
575
+ grants.push(grantRecord);
576
+ try {
577
+ deps.persistGrant?.(grantRecord);
578
+ }
579
+ catch {
580
+ // Fail-soft: the in-memory grant still applies this session.
581
+ }
582
+ emitGateEvent({
583
+ ...eventBase,
584
+ outcome: "ask_approved_remembered",
585
+ riskLevel: verdict.riskLevel,
586
+ rationale: verdict.rationale,
587
+ durationMs,
588
+ consulted: true,
589
+ });
590
+ return {};
591
+ }
592
+ if (resolution === "aborted") {
593
+ // The user is abandoning the turn — no steering text (do not
594
+ // tell an aborting model it was "denied").
595
+ emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
596
+ return { block: true };
597
+ }
598
+ emitGateEvent({
599
+ ...eventBase,
600
+ outcome: "ask_denied",
601
+ riskLevel: verdict.riskLevel,
602
+ rationale: verdict.rationale,
603
+ durationMs,
604
+ consulted: true,
605
+ });
606
+ if (resolution === "no") {
607
+ return {
608
+ block: true,
609
+ reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
610
+ };
611
+ }
612
+ // dismissed / dialog timeout — neutral reason, no "denied" spin.
613
+ return {
614
+ block: true,
615
+ reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
616
+ };
617
+ }
618
+ // Guardian failed (timeout/malformed/network/empty/aborted).
619
+ const error = reviewResult.error ?? "network";
620
+ emitDiag(error);
621
+ if (error === "aborted" || ctx?.signal?.aborted) {
622
+ // The user aborted mid-consult — silent block: no dialog, no
623
+ // "Guardian unavailable" warning on a turn they deliberately
624
+ // killed. (Belt and braces with reviewCommand's own aborted
625
+ // detection — an aborted child can die in shapes that look like
626
+ // other errors.)
627
+ emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
628
+ return { block: true };
629
+ }
630
+ if (modeAtEntry === "review" && decision.confirm) {
631
+ // Review mode: fall through to the ordinary confirm below —
632
+ // uncapped; the mode's contract is manual approval and an outage
633
+ // must not lock the user out of their own confirm flow.
634
+ }
635
+ else if (ctx?.hasUI && !ctx.signal?.aborted && errorFallbackAsks < ERROR_ASK_CAP) {
636
+ // Auto mode with a user present: ask instead of stopping —
637
+ // bounded per prompt so an outage can't become an ask storm.
638
+ errorFallbackAsks += 1;
639
+ const errorMsg = guardianErrorMessage(error);
640
+ const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
641
+ if (resolution === "yes") {
642
+ rememberApproved(cwd, command);
643
+ emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
644
+ return {};
645
+ }
646
+ if (resolution === "aborted") {
647
+ emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
648
+ return { block: true };
649
+ }
650
+ emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
651
+ const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
652
+ return {
653
+ block: true,
654
+ reason: `The user declined while the Guardian was unavailable (${errorMsg}).${timeoutNote} Find a safer alternative or ask the user.`,
655
+ };
656
+ }
657
+ else {
658
+ const errorMsg = guardianErrorMessage(error);
659
+ emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
660
+ if (ctx?.hasUI)
661
+ ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
662
+ const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
663
+ return {
664
+ block: true,
665
+ reason: `Guardian unavailable (${errorMsg}).${timeoutNote} Switch to /mode review to approve manually.`,
666
+ };
667
+ }
668
+ }
669
+ else if (modeAtEntry === "auto") {
670
+ // Guardian disabled (kill switch / env var) or not wired: pre-Guardian
671
+ // behavior (#1692). Auto mode allows prompt-band commands — the kill
672
+ // switch must never leave sessions stricter than before Guardian
673
+ // existed, and it must not replace Guardian with dialogs either. The
674
+ // forbidden band still blocks above (decideGate); grants and the
675
+ // approval cache were already consulted above.
676
+ return {};
677
+ }
678
+ // review mode with Guardian disabled/capped: fall through to confirm.
679
+ }
133
680
  if (decision.confirm) {
134
681
  // Review mode needs a confirmation. With no dialog-capable UI (headless),
135
682
  // fail CLOSED: the user explicitly chose a stricter mode, so a write we
136
683
  // cannot get consent for is held rather than silently auto-applied (this
137
684
  // mirrors plan mode, which blocks regardless of UI).
138
685
  if (!ctx?.hasUI) {
139
- return { block: true, reason: `review mode: ${event.toolName} held (no UI to confirm). Switch to /mode auto to apply.` };
686
+ return { block: true, reason: `${event.toolName} held (no UI to confirm this action).` };
140
687
  }
141
688
  // Lazily bind the bless store to this session's cwd.
142
689
  if (!blessStore)
@@ -144,14 +691,16 @@ export function registerPermissionGate(pi, deps = {}) {
144
691
  // Three-way prompt (pi's confirm is boolean-only, so use select): Yes,
145
692
  // Yes-and-remember (only when a path-prefix bless is meaningful), or No.
146
693
  const dir = blessStore.describeDir(input);
147
- const blessable = dir !== null;
694
+ const blessable = dir !== null && !effectivePolicy.alwaysConfirmTools?.includes(event.toolName);
148
695
  const yes = "Yes";
149
696
  const no = "No";
150
697
  const remember = blessable
151
698
  ? `Yes, and don't ask again for ${event.toolName} in ${dir}`
152
699
  : undefined;
153
700
  const options = blessable ? [yes, remember, no] : [yes, no];
154
- const choice = await ctx.ui.select("YAGNI Code review mode", options);
701
+ const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
702
+ ? externalTrackerPrompt(event.toolName, input)
703
+ : "YAGNI Code review mode", options);
155
704
  if (choice === yes)
156
705
  return {};
157
706
  if (blessable && choice === remember) {
@@ -169,43 +718,40 @@ export function registerPermissionGate(pi, deps = {}) {
169
718
  return {};
170
719
  }
171
720
  catch {
172
- if (mode !== "auto" && sideEffects.has(event.toolName)) {
721
+ if (modeAtEntry !== "auto" && sideEffects.has(event.toolName)) {
173
722
  return {
174
723
  block: true,
175
- reason: `permission gate failed while ${mode} mode was active; held ${event.toolName}. Switch to /mode auto to apply.`,
724
+ reason: `permission gate failed while ${modeAtEntry} mode was active; held ${event.toolName}. Switch to /mode auto to apply.`,
176
725
  };
177
726
  }
178
727
  return {};
179
728
  }
180
729
  });
181
- // Model awareness: while plan mode is on, every agent start carries a hidden
182
- // plan-mode context message so the model plans instead of attempting held
183
- // writes. Off-plan turns inject nothing.
730
+ // Model awareness: every agent turn carries a hidden mode-context message so
731
+ // the model knows what it can do (auto: verify proactively, review: wait for
732
+ // approval, plan: hold writes). The context hook strips stale mode context
733
+ // from prior turns so old mode messages don't accumulate.
184
734
  pi.on("before_agent_start", async () => {
185
- if (mode !== "plan")
186
- return;
735
+ // before_agent_start fires once per USER PROMPT (not per LLM turn).
736
+ // Reset the per-prompt bounds here: the Guardian denial streak, the
737
+ // error-fallback ask budget, and the breaker-escalation latch.
738
+ deps.guardianState?.resetTurn();
739
+ errorFallbackAsks = 0;
740
+ breakerEscalationOffered = false;
187
741
  return {
188
- message: { customType: PLAN_CONTEXT_TYPE, content: PLAN_CONTEXT_MESSAGE, display: false },
742
+ message: { customType: MODE_CONTEXT_TYPE, content: buildModeContextMessage(mode), display: false },
189
743
  };
190
744
  });
191
- // Once plan mode is off, strip the stale plan context so the model does not
192
- // keep believing writes are held.
745
+ // Strip stale mode-context messages from prior turns so the model does not
746
+ // see contradictory mode instructions.
193
747
  pi.on("context", async (event) => {
194
- if (mode === "plan")
195
- return;
196
- const filtered = filterStalePlanContext(event.messages);
748
+ const filtered = filterStaleModeContext(event.messages, mode);
197
749
  if (filtered === event.messages)
198
750
  return;
199
751
  return { messages: filtered };
200
752
  });
201
753
  const paintMode = (ctx) => {
202
- try {
203
- if (ctx.hasUI)
204
- ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
205
- }
206
- catch {
207
- // The chip is chrome; never let it break /mode.
208
- }
754
+ deps.modeHolder?.set(mode);
209
755
  };
210
756
  pi.registerCommand("mode", {
211
757
  description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
@@ -224,6 +770,11 @@ export function registerPermissionGate(pi, deps = {}) {
224
770
  return;
225
771
  }
226
772
  const leavingPlan = mode === "plan" && arg !== "plan";
773
+ if (arg !== mode) {
774
+ // A mode change is a trust-posture change: session ask-approvals do
775
+ // not carry across it (grants persist but are suppressed in review).
776
+ approvedCommands.clear();
777
+ }
227
778
  mode = arg;
228
779
  paintMode(ctx);
229
780
  notify(`Permission mode set to ${mode}. ${MODE_COPY[mode]}`, "info");