@yagni-app/code 0.3.0 → 0.3.2

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 (56) hide show
  1. package/dist/cli.js +12 -0
  2. package/dist/connectClaudeCode.d.ts +77 -0
  3. package/dist/connectClaudeCode.js +228 -0
  4. package/dist/connectCodex.d.ts +75 -0
  5. package/dist/connectCodex.js +201 -0
  6. package/dist/crashReport.d.ts +12 -0
  7. package/dist/crashReport.js +28 -1
  8. package/dist/extension/approvedPrefixes.d.ts +11 -0
  9. package/dist/extension/approvedPrefixes.js +30 -0
  10. package/dist/extension/askAdvisorTool.d.ts +18 -3
  11. package/dist/extension/askAdvisorTool.js +121 -15
  12. package/dist/extension/askYagniTool.d.ts +23 -0
  13. package/dist/extension/askYagniTool.js +42 -2
  14. package/dist/extension/branding.d.ts +11 -1
  15. package/dist/extension/branding.js +47 -7
  16. package/dist/extension/config.d.ts +12 -0
  17. package/dist/extension/config.js +2 -1
  18. package/dist/extension/crashReport.d.ts +18 -0
  19. package/dist/extension/crashReport.js +35 -2
  20. package/dist/extension/execPolicy.d.ts +17 -1
  21. package/dist/extension/execPolicy.js +227 -33
  22. package/dist/extension/flywheel.d.ts +44 -0
  23. package/dist/extension/flywheel.js +53 -0
  24. package/dist/extension/footer.d.ts +8 -1
  25. package/dist/extension/footer.js +33 -19
  26. package/dist/extension/guardian.d.ts +14 -4
  27. package/dist/extension/guardian.js +35 -11
  28. package/dist/extension/index.d.ts +20 -3
  29. package/dist/extension/index.js +92 -13
  30. package/dist/extension/mineBeat.d.ts +95 -0
  31. package/dist/extension/mineBeat.js +193 -0
  32. package/dist/extension/permission.d.ts +2 -1
  33. package/dist/extension/permission.js +75 -22
  34. package/dist/extension/pipeline/goCommand.js +6 -4
  35. package/dist/extension/pipeline/invocation.d.ts +24 -2
  36. package/dist/extension/pipeline/invocation.js +30 -2
  37. package/dist/extension/pipeline/personas.js +2 -2
  38. package/dist/extension/pipeline/resilience.d.ts +2 -1
  39. package/dist/extension/pipeline/resilience.js +21 -2
  40. package/dist/extension/pipeline/runRegistry.d.ts +9 -1
  41. package/dist/extension/pipeline/runRegistry.js +22 -1
  42. package/dist/extension/recordDecisionTool.d.ts +8 -0
  43. package/dist/extension/recordDecisionTool.js +24 -0
  44. package/dist/extension/subagents.d.ts +7 -1
  45. package/dist/extension/subagents.js +73 -5
  46. package/dist/extension/todos.d.ts +28 -1
  47. package/dist/extension/todos.js +76 -1
  48. package/dist/extension/ultra.d.ts +27 -0
  49. package/dist/extension/ultra.js +76 -0
  50. package/dist/login.d.ts +4 -2
  51. package/dist/login.js +19 -4
  52. package/dist/promptEnrichment.d.ts +1 -1
  53. package/dist/promptEnrichment.js +1 -1
  54. package/dist/token.d.ts +25 -0
  55. package/dist/token.js +45 -0
  56. package/package.json +3 -2
@@ -40,6 +40,39 @@ export function crashReportsDisabled(env = process.env) {
40
40
  const value = env[CRASH_REPORT_DISABLE_ENV];
41
41
  return value !== undefined && value !== "" && value !== "0";
42
42
  }
43
+ /**
44
+ * True when this process is a test run.
45
+ *
46
+ * A crash reporter that phones home from a test suite reports FICTIONAL
47
+ * crashes against real production telemetry. That is not theoretical: the
48
+ * `/go` terminal catch builds a live reporter whenever its caller doesn't
49
+ * inject one, so `goCommand.test.ts`'s deliberate `throw new Error("boom")`
50
+ * POSTed to https://yagni.app on every run — 915 events across Sentry
51
+ * YAGNI-BACKEND-2P / -2Q, from a team that had never witnessed a crash.
52
+ *
53
+ * Injecting a stub at each call site fixes one test; this makes the whole
54
+ * class impossible, so a future test that forgets cannot re-open the leak.
55
+ * Detection is deliberately broad — a false positive costs one unreported
56
+ * crash on a developer machine, a false negative pollutes production.
57
+ */
58
+ export function runningUnderTest(env = process.env) {
59
+ if (env.NODE_ENV === "test")
60
+ return true;
61
+ // node:test sets this in every spawned test-file process.
62
+ if (env.NODE_TEST_CONTEXT)
63
+ return true;
64
+ if (env.VITEST || env.JEST_WORKER_ID)
65
+ return true;
66
+ // npm/pnpm export the script name being run (`pnpm test`, `pnpm test:file`).
67
+ const script = env.npm_lifecycle_event;
68
+ if (script === "test" || (script !== undefined && script.startsWith("test:")))
69
+ return true;
70
+ return false;
71
+ }
72
+ /** Reporting is off when the user disabled it OR this is a test process. */
73
+ export function crashReportsSuppressed(env = process.env) {
74
+ return crashReportsDisabled(env) || runningUnderTest(env);
75
+ }
43
76
  const HOME_DIR_RE = /(?:\/(?:Users|home)\/|[A-Za-z]:\\Users\\)[^\s/\\]+/g;
44
77
  // Spaces deliberately allowed inside the token (real directories contain
45
78
  // them); prose after a path may fold into the kept basename — over-redacts
@@ -122,7 +155,7 @@ export function makeCrashReporter(opts) {
122
155
  return async (error, context, repoRoot) => {
123
156
  try {
124
157
  const env = opts.env ?? process.env;
125
- if (crashReportsDisabled(env))
158
+ if (crashReportsSuppressed(env))
126
159
  return;
127
160
  const token = opts.getToken();
128
161
  const sanitized = sanitizeCrashError(error, { env, repoRoot });
@@ -191,7 +224,7 @@ const DETACHED_SENDER_SRC = [
191
224
  export function reportFatalCrash(error, opts, context) {
192
225
  try {
193
226
  const env = opts.env ?? process.env;
194
- if (crashReportsDisabled(env))
227
+ if (crashReportsSuppressed(env))
195
228
  return;
196
229
  const token = opts.getToken();
197
230
  if (!token)
@@ -35,7 +35,18 @@
35
35
  * bundler), and external dependencies aren't resolvable from the bundled path.
36
36
  */
37
37
  export type TokenEntry = string | {
38
- op: "pipe" | "and" | "or" | "semi" | "redirect" | "substitution";
38
+ op: "pipe" | "and" | "or" | "semi" | "substitution";
39
+ } | {
40
+ op: "redirect";
41
+ direction: "out";
42
+ fd: "stdout" | "stderr";
43
+ target: string;
44
+ append: boolean;
45
+ } | {
46
+ op: "redirect";
47
+ direction: "in";
48
+ } | {
49
+ op: "background";
39
50
  };
40
51
  /**
41
52
  * Parse a shell command string into tokens and control operators.
@@ -48,6 +59,11 @@ export type TokenEntry = string | {
48
59
  * - `#` comments (start-of-word to end-of-line, outside quotes)
49
60
  * - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
50
61
  * inside double quotes — bash executes those), >, <, background &
62
+ * - Redirect metadata: stdout/stderr redirects carry fd + target so that
63
+ * safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
64
+ * (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
65
+ * floor. Background & emits a distinct `background` op (not `semi`) so
66
+ * hasUnhandledConstructs can always catch it.
51
67
  *
52
68
  * Does NOT handle: variable expansion, glob patterns, heredocs beyond the
53
69
  * redirect flag, nested subshells beyond depth tracking. Commands using
@@ -45,6 +45,11 @@
45
45
  * - `#` comments (start-of-word to end-of-line, outside quotes)
46
46
  * - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
47
47
  * inside double quotes — bash executes those), >, <, background &
48
+ * - Redirect metadata: stdout/stderr redirects carry fd + target so that
49
+ * safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
50
+ * (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
51
+ * floor. Background & emits a distinct `background` op (not `semi`) so
52
+ * hasUnhandledConstructs can always catch it.
48
53
  *
49
54
  * Does NOT handle: variable expansion, glob patterns, heredocs beyond the
50
55
  * redirect flag, nested subshells beyond depth tracking. Commands using
@@ -149,11 +154,13 @@ export function shellParse(command) {
149
154
  i += 2;
150
155
  }
151
156
  else {
152
- // Single & — background operator. A construct (floor: prompt), but
153
- // the command before it must still be rule-matched: `rm -rf / &`
154
- // has to stay forbidden, so emit a separator rather than gluing.
157
+ // Single & — background operator. Emits a distinct `background` op
158
+ // (not `semi`) so hasUnhandledConstructs can always catch it and
159
+ // floor the command. The command before it must still be rule-
160
+ // matched: `rm -rf / &` has to stay forbidden, so emit a separator
161
+ // rather than gluing.
155
162
  pushCurrent();
156
- tokens.push({ op: "semi" });
163
+ tokens.push({ op: "background" });
157
164
  hasConstruct = true;
158
165
  i++;
159
166
  }
@@ -163,18 +170,124 @@ export function shellParse(command) {
163
170
  tokens.push({ op: "semi" });
164
171
  i++;
165
172
  continue;
166
- case ">":
167
- case "<":
173
+ case ">": {
168
174
  pushCurrent();
169
- tokens.push({ op: "redirect" });
175
+ // Check for a preceding fd digit: `2>` → stderr, `1>` → stdout.
176
+ // The digit was emitted as a string token — pop it and use as fd.
177
+ let fd = "stdout";
178
+ if (tokens.length > 0 && typeof tokens[tokens.length - 1] === "string") {
179
+ const last = tokens[tokens.length - 1];
180
+ if (last === "2") {
181
+ fd = "stderr";
182
+ tokens.pop();
183
+ }
184
+ else if (last === "1") {
185
+ fd = "stdout";
186
+ tokens.pop();
187
+ }
188
+ }
189
+ let append = false;
190
+ i++;
191
+ if (command[i] === ">") {
192
+ append = true;
193
+ i++;
194
+ }
195
+ while (command[i] === " " || command[i] === "\t")
196
+ i++;
197
+ // Read the target — handles quoted targets (mirrors the main loop's
198
+ // quote logic), fd merges (&N), and bare words.
199
+ let target = "";
200
+ if (command[i] === "&") {
201
+ // fd merge: &1, &2, etc.
202
+ i++;
203
+ let digits = "";
204
+ while (command[i] >= "0" && command[i] <= "9") {
205
+ digits += command[i];
206
+ i++;
207
+ }
208
+ target = "&" + digits;
209
+ }
210
+ else if (command[i] === "'") {
211
+ i++;
212
+ while (i < command.length && command[i] !== "'") {
213
+ target += command[i];
214
+ i++;
215
+ }
216
+ if (i < command.length)
217
+ i++;
218
+ }
219
+ else if (command[i] === '"') {
220
+ i++;
221
+ while (i < command.length && command[i] !== '"') {
222
+ if (command[i] === "\\" && i + 1 < command.length) {
223
+ target += command[i + 1];
224
+ i += 2;
225
+ }
226
+ else {
227
+ target += command[i];
228
+ i++;
229
+ }
230
+ }
231
+ if (i < command.length)
232
+ i++;
233
+ }
234
+ else {
235
+ while (i < command.length &&
236
+ command[i] !== " " && command[i] !== "\t" &&
237
+ command[i] !== "\n" && command[i] !== "\r" &&
238
+ command[i] !== "|" && command[i] !== "&" &&
239
+ command[i] !== ";" && command[i] !== ">" &&
240
+ command[i] !== "<") {
241
+ target += command[i];
242
+ i++;
243
+ }
244
+ }
245
+ tokens.push({ op: "redirect", direction: "out", fd, target, append });
170
246
  hasConstruct = true;
171
- // Skip the operator char(s) and any following space
247
+ continue;
248
+ }
249
+ case "<": {
250
+ pushCurrent();
251
+ // Stdin redirect — always floors (changes program behavior).
172
252
  i++;
173
- if (command[i] === ch)
174
- i++; // >> or <<
253
+ if (command[i] === "<")
254
+ i++; // << heredoc — skip delimiter
175
255
  while (command[i] === " " || command[i] === "\t")
176
256
  i++;
257
+ // Consume the target (filename or heredoc delimiter) so it doesn't
258
+ // appear as a segment token — mirrors the > case's target reading.
259
+ if (command[i] === "'") {
260
+ i++;
261
+ while (i < command.length && command[i] !== "'")
262
+ i++;
263
+ if (i < command.length)
264
+ i++;
265
+ }
266
+ else if (command[i] === '"') {
267
+ i++;
268
+ while (i < command.length && command[i] !== '"') {
269
+ if (command[i] === "\\" && i + 1 < command.length)
270
+ i += 2;
271
+ else
272
+ i++;
273
+ }
274
+ if (i < command.length)
275
+ i++;
276
+ }
277
+ else {
278
+ while (i < command.length &&
279
+ command[i] !== " " && command[i] !== "\t" &&
280
+ command[i] !== "\n" && command[i] !== "\r" &&
281
+ command[i] !== "|" && command[i] !== "&" &&
282
+ command[i] !== ";" && command[i] !== ">" &&
283
+ command[i] !== "<") {
284
+ i++;
285
+ }
286
+ }
287
+ tokens.push({ op: "redirect", direction: "in" });
288
+ hasConstruct = true;
177
289
  continue;
290
+ }
178
291
  case "$":
179
292
  if (command[i + 1] === "(") {
180
293
  pushCurrent();
@@ -213,9 +326,10 @@ export function shellParse(command) {
213
326
  }
214
327
  pushCurrent();
215
328
  // If we detected constructs but didn't emit them as operator tokens
216
- // (e.g. background & or double-quoted substitution), surface that via a
217
- // trailing substitution token so hasUnhandledConstructs sees it.
218
- if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution"))) {
329
+ // (e.g. double-quoted substitution), surface that via a trailing
330
+ // substitution token so hasUnhandledConstructs sees it. Background & now
331
+ // emits its own distinct op, so it no longer relies on this fallback.
332
+ if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution" || t.op === "background"))) {
219
333
  tokens.push({ op: "substitution" });
220
334
  }
221
335
  return tokens;
@@ -294,47 +408,58 @@ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
294
408
  export function tokenize(command) {
295
409
  return shellParse(command).filter((t) => typeof t === "string");
296
410
  }
297
- /** Operator tokens we can safely split on (compound command segments). */
298
- const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
411
+ /** Ops that split compound commands into segments (pipe, &&, ||, ;, background &). */
412
+ const SPLIT_OPS = new Set(["pipe", "and", "or", "semi", "background"]);
413
+ /** Ops that are safe splittable separators — they do NOT trigger the construct floor.
414
+ * Background & is in SPLIT_OPS (splits segments) but NOT here (always floors). */
415
+ const SAFE_SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
416
+ /** A redirect to /dev/null (discard) or &N (fd merge) is safe — no file created. */
417
+ function isSafeRedirect(t) {
418
+ if (typeof t === "string")
419
+ return false;
420
+ if (t.op !== "redirect")
421
+ return false;
422
+ if (t.direction === "in")
423
+ return false; // stdin redirect — always floor
424
+ if (t.target === "/dev/null")
425
+ return true; // discard stderr/stdout — safe
426
+ if (t.target.startsWith("&") && t.target.length > 1)
427
+ return true; // fd merge (2>&1, 1>&2) — safe; bare "&" (>& with no digit) is NOT safe
428
+ return false; // > file.txt, >> file.txt — unsafe
429
+ }
299
430
  /**
300
431
  * Detect whether the command uses shell constructs we can't statically
301
- * classify (command substitution, redirects, background &) — anything that
302
- * is NOT a splittable operator. These impose a floor of `prompt`: a command
303
- * carrying them is never auto-allowed, but forbidden matches still win.
432
+ * classify (command substitution, unsafe redirects, background &) — anything
433
+ * that is NOT a safe splittable operator or a safe redirect. These impose a
434
+ * floor of `prompt`: a command carrying them is never auto-allowed, but
435
+ * forbidden matches still win.
304
436
  */
305
437
  function hasUnhandledConstructs(command) {
306
- return shellParse(command).some((t) => typeof t === "object" && "op" in t && !SPLIT_OPS.has(t.op));
438
+ return shellParse(command).some((t) => typeof t === "object" && !SAFE_SPLIT_OPS.has(t.op) && !isSafeRedirect(t));
307
439
  }
308
440
  /**
309
441
  * Split a command into token-array segments at control operators (|, &&, ||,
310
- * ;, newline). Redirect targets (the token after > or <) are dropped from the
311
- * segment they are filenames, not arguments to rule-match. Token arrays are
312
- * carried through (never re-joined into strings) so quoting survives.
442
+ * ;, newline, background &). Redirect targets are consumed inside the
443
+ * tokenizer's > / < cases (stored on the redirect op), so no skipNext logic
444
+ * is needed. Token arrays are carried through (never re-joined into strings)
445
+ * so quoting survives.
313
446
  */
314
447
  function splitSegmentsTokens(command) {
315
448
  const parsed = shellParse(command);
316
449
  const segments = [];
317
450
  let current = [];
318
- let skipNext = false;
319
451
  for (const t of parsed) {
320
452
  if (typeof t === "object") {
321
453
  if (SPLIT_OPS.has(t.op)) {
322
454
  if (current.length > 0)
323
455
  segments.push(current);
324
456
  current = [];
325
- skipNext = false;
326
457
  }
327
- else if (t.op === "redirect") {
328
- skipNext = true;
329
- }
330
- // substitution ops are construct markers; the inner text is handled
331
- // by dangerScan via extractSubstitutions.
458
+ // redirect and substitution ops are construct markers; their targets
459
+ // are consumed inside the tokenizer, and the inner text of $(...) is
460
+ // handled by dangerScan via extractSubstitutions.
332
461
  }
333
462
  else {
334
- if (skipNext) {
335
- skipNext = false;
336
- continue;
337
- }
338
463
  current.push(t);
339
464
  }
340
465
  }
@@ -487,6 +612,16 @@ function classifySegmentTokens(rawTokens, policy, opts) {
487
612
  }
488
613
  const { tokens: strippedTokens, stripped } = stripLeadingTokens(rawTokens);
489
614
  if (strippedTokens.length === 0) {
615
+ // env standalone: `env` or `env VAR=val` with no following command prints
616
+ // environment variables — a read-only operation. stripLeadingTokens removes
617
+ // `env` as a wrapper word, leaving empty tokens. Recognize this case instead
618
+ // of returning "empty command segment" (YAG-549).
619
+ if (!opts.forbiddenOnly && rawTokens.length > 0 && basenameToken(rawTokens[0]) === "env") {
620
+ const onlyEnvAndAssignments = rawTokens.every((t) => basenameToken(t) === "env" || ENV_ASSIGNMENT_RE.test(t));
621
+ if (onlyEnvAndAssignments) {
622
+ return { decision: "allow", justification: "print environment variables (read-only)" };
623
+ }
624
+ }
490
625
  return opts.forbiddenOnly
491
626
  ? { decision: "allow", justification: "no forbidden match" }
492
627
  : { decision: "prompt", justification: "empty command segment" };
@@ -510,6 +645,9 @@ function classifySegmentTokens(rawTokens, policy, opts) {
510
645
  const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
511
646
  if (tailResult.decision === "forbidden")
512
647
  return tailResult;
648
+ if (tailResult.decision === "allow" && !neverAllow) {
649
+ return { decision: "allow", justification: "xargs forwards to a read-only command" };
650
+ }
513
651
  }
514
652
  if (opts.forbiddenOnly)
515
653
  return { decision: "allow", justification: "no forbidden match" };
@@ -727,6 +865,7 @@ export const DEFAULT_EXEC_POLICY = {
727
865
  { pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
728
866
  { pattern: ["stat"], decision: "allow", justification: "show file metadata" },
729
867
  { pattern: ["file"], decision: "allow", justification: "identify file type" },
868
+ { pattern: ["strings"], decision: "allow", justification: "extract printable strings from binary files (read-only)" },
730
869
  { pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
731
870
  { pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
732
871
  { pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
@@ -760,9 +899,64 @@ export const DEFAULT_EXEC_POLICY = {
760
899
  { pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
761
900
  { pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
762
901
  { pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
902
+ { pattern: ["npm", "list"], decision: "allow", justification: "list installed packages" },
763
903
  { pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
904
+ { pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
764
905
  { pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
765
906
  { pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
907
+ // --- allow: CLI tool reads (YAG-549, based on prod Guardian data) ---
908
+ // linear CLI — read subcommands; write subcommands (create/update/delete/start/pr/attach/comment) stay prompt
909
+ { pattern: ["linear", "issue", "comment", "list"], decision: "allow", justification: "list Linear issue comments (read-only)" },
910
+ {
911
+ pattern: ["linear", "issue"],
912
+ decision: "allow",
913
+ justification: "read Linear issue data",
914
+ unlessTokens: ["start", "create", "update", "delete", "pull-request", "pr", "attach", "comment"],
915
+ },
916
+ { pattern: ["linear", ["--help", "-h"]], decision: "allow", justification: "show Linear CLI help" },
917
+ { pattern: ["linear", ["--version", "-V"]], decision: "allow", justification: "show Linear CLI version" },
918
+ // gh api — REST GET (no params, no method override) and GraphQL queries (no mutation)
919
+ // gh api defaults to POST when -f/-F params are present, so params are disqualifying.
920
+ // GraphQL is always POST, but queries are reads; mutations/subscriptions are disqualifying.
921
+ {
922
+ pattern: ["gh", "api", "graphql"],
923
+ decision: "allow",
924
+ justification: "GraphQL query (read-only)",
925
+ unlessTokens: ["query=mutation*", "query=subscription*"],
926
+ },
927
+ {
928
+ pattern: ["gh", "api"],
929
+ decision: "allow",
930
+ justification: "GitHub API GET request (read-only)",
931
+ unlessTokens: ["--method", "-X", "-X*", "graphql", "POST", "PATCH", "DELETE", "PUT", "-f", "-F", "--raw-field", "--field"],
932
+ },
933
+ { pattern: ["gh", "release", ["list", "view"]], decision: "allow", justification: "read GitHub release data" },
934
+ { pattern: ["gh", "label", "list"], decision: "allow", justification: "list GitHub labels" },
935
+ { pattern: ["gh", "milestone", "list"], decision: "allow", justification: "list GitHub milestones" },
936
+ { pattern: ["gh", "auth", "status"], decision: "allow", justification: "show GitHub auth status" },
937
+ // git read-only subcommands
938
+ { pattern: ["git", "tag"], decision: "allow", justification: "list tags (read-only)" },
939
+ { pattern: ["git", "reflog"], decision: "allow", justification: "show reference log" },
940
+ { pattern: ["git", "ls-remote"], decision: "allow", justification: "list remote refs" },
941
+ { pattern: ["git", "cat-file"], decision: "allow", justification: "inspect git objects (read-only)" },
942
+ { pattern: ["git", "for-each-ref"], decision: "allow", justification: "enumerate refs (read-only)" },
943
+ { pattern: ["git", "rev-list"], decision: "allow", justification: "walk commit history (read-only)" },
944
+ { pattern: ["git", "show-ref"], decision: "allow", justification: "list all refs" },
945
+ { pattern: ["git", "name-rev"], decision: "allow", justification: "map commit to name (read-only)" },
946
+ { pattern: ["git", "merge-base"], decision: "allow", justification: "find common ancestor (read-only)" },
947
+ // package manager test/lint — routine dev-loop operations
948
+ { pattern: ["pnpm", "test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
949
+ { pattern: ["pnpm", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
950
+ { pattern: ["npm", "test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
951
+ { pattern: ["npm", "run", "lint"], decision: "allow", justification: "run linter (routine dev-loop operation)" },
952
+ { pattern: ["npx", "tsc", "--noEmit"], decision: "allow", justification: "typecheck only (no file writes)" },
953
+ { pattern: ["npx", "tsx", "--test"], decision: "allow", justification: "run tests (routine dev-loop operation)" },
954
+ { pattern: ["npx", "vitest"], decision: "allow", justification: "run vitest tests (routine dev-loop operation)" },
955
+ { pattern: ["npx", "vitest", "run"], decision: "allow", justification: "run vitest tests (routine dev-loop operation)" },
956
+ { pattern: ["npx", "jest"], decision: "allow", justification: "run jest tests (routine dev-loop operation)" },
957
+ // misc read-only commands
958
+ { pattern: ["printenv"], decision: "allow", justification: "print environment variables (read-only)" },
959
+ { pattern: ["npm", ["view", "info"]], decision: "allow", justification: "read package metadata from registry" },
766
960
  // --- prompt: potentially destructive but context-dependent ---
767
961
  { pattern: ["rm"], decision: "prompt", justification: "file deletion — review the target" },
768
962
  { pattern: ["git", "commit"], decision: "prompt", justification: "creates a commit — confirm intent" },
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Session state for the miss-to-record flywheel (org plane, Run 7).
3
+ *
4
+ * When `ask_yagni` answers `no_position`, the server attaches a
5
+ * `recordSuggestion` telling the agent to bank the assumption it proceeds on.
6
+ * The extension owns two client-side guards the server cannot:
7
+ *
8
+ * - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
9
+ * surfaced per session; past it the suggestion is suppressed so a busy
10
+ * session cannot flood the ledger with asserted rows.
11
+ * - **Dedupe attribution.** A `record_decision` call that answers the
12
+ * QUESTION a surfaced suggestion asked about is a mid-run agent with no
13
+ * human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
14
+ * backend then returns the existing row instead of inserting). The
15
+ * attribution is correlated to the suggested question — an unrelated
16
+ * record_decision (a different judgment the agent banks mid-run, or a
17
+ * human `/decide`) never inherits the flag.
18
+ */
19
+ /** Most flywheel suggestions surfaced per session. */
20
+ export declare const FLYWHEEL_RUN_CAP = 3;
21
+ export interface FlywheelState {
22
+ /** Suggestions surfaced so far this session. */
23
+ suggestionsShown: number;
24
+ /**
25
+ * The question of the most recent surfaced suggestion, until a matching
26
+ * record_decision consumes it. Only a record answering THIS question is
27
+ * flywheel-attributed (sends `dedupe: true`).
28
+ */
29
+ pendingQuestion: string | null;
30
+ }
31
+ export declare function makeFlywheelState(): FlywheelState;
32
+ /** May another suggestion be surfaced? */
33
+ export declare function canSurfaceSuggestion(state: FlywheelState): boolean;
34
+ /** Record that a suggestion (asking about `question`) reached the model. */
35
+ export declare function noteSuggestionSurfaced(state: FlywheelState, question: string): void;
36
+ /**
37
+ * Consume the flywheel attribution for a record_decision call. Returns true
38
+ * (and clears the pending question) only when the recorded question matches
39
+ * the surfaced suggestion's; a mismatch leaves the attribution pending — the
40
+ * agent may record other judgments before circling back, and a fail-safe
41
+ * mismatch simply means a normal insert (no dedupe), never a swallowed write.
42
+ */
43
+ export declare function consumeFlywheelAttribution(state: FlywheelState, recordedQuestion: string): boolean;
44
+ //# sourceMappingURL=flywheel.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Session state for the miss-to-record flywheel (org plane, Run 7).
3
+ *
4
+ * When `ask_yagni` answers `no_position`, the server attaches a
5
+ * `recordSuggestion` telling the agent to bank the assumption it proceeds on.
6
+ * The extension owns two client-side guards the server cannot:
7
+ *
8
+ * - **The per-run cap.** At most {@link FLYWHEEL_RUN_CAP} suggestions are
9
+ * surfaced per session; past it the suggestion is suppressed so a busy
10
+ * session cannot flood the ledger with asserted rows.
11
+ * - **Dedupe attribution.** A `record_decision` call that answers the
12
+ * QUESTION a surfaced suggestion asked about is a mid-run agent with no
13
+ * human to adjudicate a near-duplicate, so it sends `dedupe: true` (the
14
+ * backend then returns the existing row instead of inserting). The
15
+ * attribution is correlated to the suggested question — an unrelated
16
+ * record_decision (a different judgment the agent banks mid-run, or a
17
+ * human `/decide`) never inherits the flag.
18
+ */
19
+ /** Most flywheel suggestions surfaced per session. */
20
+ export const FLYWHEEL_RUN_CAP = 3;
21
+ export function makeFlywheelState() {
22
+ return { suggestionsShown: 0, pendingQuestion: null };
23
+ }
24
+ /** May another suggestion be surfaced? */
25
+ export function canSurfaceSuggestion(state) {
26
+ return state.suggestionsShown < FLYWHEEL_RUN_CAP;
27
+ }
28
+ /** Loose textual identity: case- and whitespace-insensitive, terminal punctuation ignored. */
29
+ function normalizeQuestion(question) {
30
+ return question.toLowerCase().replace(/\s+/g, " ").replace(/[.?!\s]+$/g, "").trim();
31
+ }
32
+ /** Record that a suggestion (asking about `question`) reached the model. */
33
+ export function noteSuggestionSurfaced(state, question) {
34
+ state.suggestionsShown += 1;
35
+ state.pendingQuestion = question;
36
+ }
37
+ /**
38
+ * Consume the flywheel attribution for a record_decision call. Returns true
39
+ * (and clears the pending question) only when the recorded question matches
40
+ * the surfaced suggestion's; a mismatch leaves the attribution pending — the
41
+ * agent may record other judgments before circling back, and a fail-safe
42
+ * mismatch simply means a normal insert (no dedupe), never a swallowed write.
43
+ */
44
+ export function consumeFlywheelAttribution(state, recordedQuestion) {
45
+ if (state.pendingQuestion === null)
46
+ return false;
47
+ if (normalizeQuestion(state.pendingQuestion) !== normalizeQuestion(recordedQuestion)) {
48
+ return false;
49
+ }
50
+ state.pendingQuestion = null;
51
+ return true;
52
+ }
53
+ //# sourceMappingURL=flywheel.js.map
@@ -41,6 +41,9 @@
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
42
  import type { ModeHolder, PermissionMode } from "./permission.js";
43
43
  export declare const BRANCH_MAX_WIDTH = 60;
44
+ export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
45
+ export declare function isShiftTab(data: string): boolean;
46
+ export declare const GIT_MUTATING_PATTERN: RegExp;
44
47
  /**
45
48
  * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
46
49
  * aligns with the editor input and the chat/output area on one shared column.
@@ -102,7 +105,11 @@ export declare function renderFooterLines(input: {
102
105
  * and returns the component `setFooter` expects. Called from the
103
106
  * `session_start` handler in index.ts.
104
107
  */
105
- export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
108
+ export interface FooterInvalidateHandle {
109
+ invalidateGit(): void;
110
+ requestRender(): void;
111
+ }
112
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
106
113
  render(width: number): string[];
107
114
  invalidate(): void;
108
115
  dispose(): void;