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