@yagni-app/code 0.2.1 → 0.3.0

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