@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.
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +135 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +92 -0
- package/dist/extension/approvedPrefixes.js +252 -0
- package/dist/extension/askAdvisorTool.d.ts +2 -2
- package/dist/extension/askAdvisorTool.js +5 -5
- package/dist/extension/askYagniTool.js +49 -0
- package/dist/extension/branding.d.ts +24 -3
- package/dist/extension/branding.js +71 -10
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +32 -1
- package/dist/extension/config.js +36 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +119 -0
- package/dist/extension/execPolicy.js +805 -0
- package/dist/extension/footer.d.ts +111 -0
- package/dist/extension/footer.js +294 -0
- package/dist/extension/guardian.d.ts +129 -0
- package/dist/extension/guardian.js +213 -0
- package/dist/extension/index.d.ts +15 -4
- package/dist/extension/index.js +250 -24
- package/dist/extension/permission.d.ts +123 -10
- package/dist/extension/permission.js +586 -40
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +35 -6
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +25 -0
- package/dist/extension/pipeline/runRegistry.d.ts +14 -0
- package/dist/extension/pipeline/runRegistry.js +35 -0
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +4 -7
- package/dist/extension/subagents.js +103 -33
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/package.json +5 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- 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):
|
|
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
|
|
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.
|
|
35
|
-
*
|
|
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
|
|
59
|
-
export const
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
79
|
-
* model
|
|
80
|
-
*
|
|
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
|
|
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 ===
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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([
|
|
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,
|
|
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:
|
|
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(
|
|
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 (
|
|
709
|
+
if (modeAtEntry !== "auto" && sideEffects.has(event.toolName)) {
|
|
173
710
|
return {
|
|
174
711
|
block: true,
|
|
175
|
-
reason: `permission gate failed while ${
|
|
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:
|
|
182
|
-
//
|
|
183
|
-
// writes.
|
|
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
|
-
|
|
186
|
-
|
|
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:
|
|
730
|
+
message: { customType: MODE_CONTEXT_TYPE, content: buildModeContextMessage(mode), display: false },
|
|
189
731
|
};
|
|
190
732
|
});
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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
|
-
|
|
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");
|