@yagni-app/code-staging 1.0.7-staging.1268.1 → 1.0.7-staging.1272.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/README.md CHANGED
@@ -203,7 +203,14 @@ prefix instead of `claude_code`:
203
203
  - **Log events:** `user_prompt`, `assistant_response`, `api_request`,
204
204
  `api_error`, `tool_result`, `tool_decision`, `permission_mode_changed`
205
205
  (as `event.name`, body `yagni_code.<name>`), with `session.id`,
206
- `organization.id`, `terminal.type`, and a per-prompt `prompt.id`.
206
+ `organization.id`, `user.email`, `terminal.type`, and a per-prompt
207
+ `prompt.id`.
208
+
209
+ Every signal carries `organization.id` (the workspace) and `user.email` (the
210
+ signed-in developer's account email, learned from the backend at session
211
+ start) so a collector can attribute sessions and spend per person.
212
+ `OTEL_METRICS_INCLUDE_ACCOUNT_UUID=false` drops the email from all three
213
+ signals. Prompt, response, and tool content never export.
207
214
 
208
215
  Nothing is exported unless you configure an endpoint. Enable it one of three
209
216
  ways (first match wins):
@@ -127,6 +127,12 @@ export interface ContextBrief {
127
127
  * The opt-in mining beat fires ONLY on an explicit 0.
128
128
  */
129
129
  repoDecisionCount?: number;
130
+ /**
131
+ * The signed-in developer's account email (additive; absent on older
132
+ * backends). Feeds the OTel export's `user.email` attribute when the
133
+ * launcher did not already forward one.
134
+ */
135
+ userEmail?: string;
130
136
  }
131
137
  /**
132
138
  * Attribution headers for the model proxy (YAG-471). On the SERVER side these
@@ -26,8 +26,8 @@ export interface HookEntry {
26
26
  export interface HookGroup {
27
27
  matcher?: string;
28
28
  hooks: HookEntry[];
29
- /** Where this group was loaded from. Project-level groups are gated on workspace trust. */
30
- _source?: "user" | "project";
29
+ /** Where this group was loaded from. Project AND local groups are gated on workspace trust. */
30
+ _source?: "user" | "project" | "local";
31
31
  }
32
32
  /** The hooks section of config.json. */
33
33
  export type HooksConfig = Record<string, HookGroup[]>;
@@ -55,7 +55,7 @@ export interface HookRunner {
55
55
  preToolUse(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PreToolUseHookResult>;
56
56
  permissionRequest(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PermissionRequestHookResult>;
57
57
  }
58
- /** Read and merge hooks config from user and project files. Pure I/O, fail-soft. */
58
+ /** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
59
59
  export declare function loadHooksConfig(userHome?: string, cwd?: string, env?: NodeJS.ProcessEnv): HooksConfig;
60
60
  /**
61
61
  * Check if a matcher matches a tool name. Matches Claude Code / Codex:
@@ -36,7 +36,7 @@ const SUPPORTED_EVENTS = [
36
36
  // ---------------------------------------------------------------------------
37
37
  // Config loading
38
38
  // ---------------------------------------------------------------------------
39
- /** Read and merge hooks config from user and project files. Pure I/O, fail-soft. */
39
+ /** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
40
40
  export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env = process.env) {
41
41
  if (env.YAGNI_CODE_EVAL_MODE === "1")
42
42
  return {};
@@ -47,6 +47,13 @@ export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env =
47
47
  // Project-level: .yagni-code/config.json — tagged for trust gating at execution time
48
48
  const projectPath = join(cwd, ".yagni-code", "config.json");
49
49
  mergeHooksFromFile(merged, projectPath, "project");
50
+ // Local-level: .yagni-code/config.local.json — the personal per-project
51
+ // tier. Trust-gated like project (NOT always-trusted): a hostile repo can
52
+ // COMMIT a local file — the gitignore convention only covers untracked
53
+ // files. Deliberately stricter than Claude Code, which does not
54
+ // trust-gate its localSettings hooks.
55
+ const localPath = join(cwd, ".yagni-code", "config.local.json");
56
+ mergeHooksFromFile(merged, localPath, "local");
50
57
  return merged;
51
58
  }
52
59
  function mergeHooksFromFile(merged, path, source) {
@@ -68,8 +75,20 @@ function mergeHooksFromFile(merged, path, source) {
68
75
  }
69
76
  }
70
77
  }
71
- catch {
72
- // Fail-soft: missing or malformed config is logged and skipped.
78
+ catch (err) {
79
+ // Fail-soft: missing or malformed config is skipped and LOGGED: a
80
+ // corrupt local/project config silently dropping its hooks (including
81
+ // deny-enforcing ones) with zero signal is undiagnosable. Path + error
82
+ // class only, matching loadConfig.ts's warning behavior.
83
+ logEvent({
84
+ source: "hooks",
85
+ level: "warn",
86
+ event: "config_warning",
87
+ fields: {
88
+ path,
89
+ error: err instanceof Error ? err.constructor.name : typeof err,
90
+ },
91
+ });
73
92
  }
74
93
  }
75
94
  function isValidHookGroup(value) {
@@ -297,11 +316,17 @@ function logHookEvent(env, payload) {
297
316
  fields,
298
317
  });
299
318
  }
300
- /** Filter hook groups by workspace trust: project-level groups are skipped when untrusted. */
319
+ /**
320
+ * Filter hook groups by workspace trust: project AND local groups are
321
+ * skipped when untrusted. Local is gated exactly like project (not like the
322
+ * always-trusted user tier) because a hostile repo can COMMIT a
323
+ * config.local.json — the global-gitignore convention only covers
324
+ * untracked files. Deliberately stricter than Claude Code's localSettings.
325
+ */
301
326
  function filterByTrust(groups, isTrusted) {
302
327
  if (isTrusted)
303
328
  return groups;
304
- return groups.filter((g) => g._source !== "project");
329
+ return groups.filter((g) => g._source !== "project" && g._source !== "local");
305
330
  }
306
331
  /**
307
332
  * Create a HookRunner for injection into the permission gate. The `isTrusted`
@@ -3,6 +3,7 @@ import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
4
  import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
5
  import { type GuardianGateEvent } from "./permission/gate.js";
6
+ import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
6
7
  import { type PluginCliRunner } from "./plugins/panel.js";
7
8
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
8
9
  import { type TokenProvider } from "./tokenProvider.js";
@@ -67,6 +68,11 @@ export interface RegisterYagniDeps {
67
68
  * fresh-workspace first-run and is skipped otherwise, without a network or disk.
68
69
  */
69
70
  runInitPass?: typeof defaultRunInitPass;
71
+ /**
72
+ * OTel export registration. Injectable so tests assert the boot handoff of
73
+ * the /context `userEmail` into the telemetry identity without an SDK.
74
+ */
75
+ registerTelemetry?: typeof defaultRegisterTelemetry;
70
76
  /** The workspace id the token is bound to (keys the one-time init marker). */
71
77
  getWorkspaceId?: () => string | undefined;
72
78
  /** Has the init pass already run once for this workspace? Injectable (no disk in tests). */
@@ -1,6 +1,4 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
2
  import { Text } from "@earendil-works/pi-tui";
5
3
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
4
  import { makeChildUsageState } from "./childUsage.js";
@@ -26,6 +24,7 @@ import { logEvent } from "./errorSink.js";
26
24
  import { registerFeedbackCommands } from "./feedbackCommand.js";
27
25
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
28
26
  import { codeStateHome } from "./stateHome.js";
27
+ import { mutateLocalConfig } from "./settingsFiles.js";
29
28
  import { logTurnLifecycle } from "./turnLog.js";
30
29
  import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
31
30
  import { RerouteNotifier } from "./rerouteNotice.js";
@@ -38,7 +37,7 @@ import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
38
37
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
39
38
  import { registerSandbox } from "./sandbox/session.js";
40
39
  import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
41
- import { registerTelemetry } from "./telemetry/register.js";
40
+ import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
42
41
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
43
42
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
44
43
  import { registerPluginPanel } from "./plugins/panel.js";
@@ -422,7 +421,7 @@ export async function registerYagni(pi, deps = {}) {
422
421
  // OTel export (traces, metrics, log events; Claude Code parity). A no-op
423
422
  // unless the launcher's gate set YAGNI_OTEL_EXPORT=1 — see the CLI's
424
423
  // otel.ts for the sources and src/telemetry for the exporter.
425
- const telemetry = registerTelemetry(pi, { env });
424
+ const telemetry = (deps.registerTelemetry ?? defaultRegisterTelemetry)(pi, { env });
426
425
  {
427
426
  let previousMode = modeHolder.get();
428
427
  modeHolder.onSet((m) => {
@@ -607,45 +606,41 @@ export async function registerYagni(pi, deps = {}) {
607
606
  if (!evalMode)
608
607
  appendGrant(grant);
609
608
  },
610
- // persist a user-level allow rule (Guardian ask dialog's third
611
- // option). Atomic write, never overwrites other keys, fail-soft.
612
- persistUserRule: (ruleString) => {
613
- if (evalMode)
614
- return;
615
- try {
616
- const userPath = join(rulesStateHome, "config.json");
617
- let parsed = {};
618
- if (existsSync(userPath)) {
619
- parsed = JSON.parse(readFileSync(userPath, "utf-8"));
620
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
621
- return;
622
- }
623
- const perms = (parsed.permissions ?? {});
624
- const allow = Array.isArray(perms.allow) ? perms.allow : [];
625
- if (!allow.includes(ruleString))
626
- allow.push(ruleString);
627
- perms.allow = allow;
628
- parsed.permissions = perms;
629
- const tmp = join(rulesStateHome, `.config.json.yagni-${process.pid}-${Date.now()}.tmp`);
630
- writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
631
- renameSync(tmp, userPath);
609
+ // persist a project-local allow rule (Guardian ask dialog's third
610
+ // option Claude Code's own default save destination for personal
611
+ // rules is localSettings; ours matches). Atomic write, never overwrites
612
+ // other keys. THROWS on failure — the gate's catch owns the fail-soft
613
+ // behavior (notify the user, record ask_approved not remembered); a
614
+ // catch HERE would swallow the throw and make that path unreachable,
615
+ // leaving the user believing a rule saved that will not survive restart.
616
+ // Local writes fire-and-forget the global gitignore helper so the file
617
+ // is never committed by accident.
618
+ //
619
+ // The cwd is the GATE's per-call session cwd (a session can switch
620
+ // directories; process.cwd() would silently land the rule in another
621
+ // project's local settings while the dialog claims this one). Not
622
+ // registered in eval mode: the gate hides the save option when the dep
623
+ // is absent, so a no-op implementation that would still emit
624
+ // ask_approved_remembered never exists.
625
+ ...(evalMode ? {} : {
626
+ persistUserRule: (ruleString, cwd) => {
627
+ mutateLocalConfig(cwd, (config) => {
628
+ const perms = (config.permissions ?? {});
629
+ const allow = Array.isArray(perms.allow) ? [...perms.allow] : [];
630
+ if (!allow.includes(ruleString))
631
+ allow.push(ruleString);
632
+ perms.allow = allow;
633
+ config.permissions = perms;
634
+ });
632
635
  logEvent({
633
636
  source: "permission-rules",
634
637
  level: "info",
635
- event: "user_rule_saved",
638
+ event: "rule_saved",
636
639
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
637
- fields: { rule: ruleString },
638
- });
639
- }
640
- catch (err) {
641
- logEvent({
642
- source: "permission-rules",
643
- level: "warn",
644
- event: "user_rule_save_failed",
645
- fields: { message: err instanceof Error ? err.message : "unknown" },
640
+ fields: { rule: ruleString, destination: "local" },
646
641
  });
647
- }
648
- },
642
+ },
643
+ }),
649
644
  // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
650
645
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
651
646
  // metadata, no command content; "raw" → adds client-REDACTED command and
@@ -845,6 +840,29 @@ export async function registerYagni(pi, deps = {}) {
845
840
  contextBrief = undefined;
846
841
  }
847
842
  }
843
+ // The developer's account email for the OTel export (`user.email`), so a
844
+ // collector can attribute sessions per person. Set before session_start
845
+ // fires, so even the session.count metric carries it. No-op when export is
846
+ // off or the launcher already forwarded YAGNI_USER_EMAIL.
847
+ const bootUserEmail = typeof briefResult?.userEmail === "string" ? briefResult.userEmail : undefined;
848
+ telemetry.setUserEmail(bootUserEmail);
849
+ if (telemetry.enabled && !bootUserEmail && !telemetry.config.identity.userEmail) {
850
+ // Attribution would otherwise degrade silently: leave a trail in the local
851
+ // error sink so "why is user.email missing in the collector" is answerable.
852
+ // Info, not warn: one line per session start, and an account with no
853
+ // email hits it by design. The fields name each way the email can be
854
+ // absent — the boot fetch failed or lacked the field, and whether the
855
+ // launcher's YAGNI_USER_EMAIL was unset or forwarded blank.
856
+ logEvent({
857
+ source: "telemetry",
858
+ level: "info",
859
+ event: "user_email_missing",
860
+ fields: {
861
+ contextFetch: briefResult === null ? "failed_or_skipped" : "no_email_field",
862
+ launcherEmail: env.YAGNI_USER_EMAIL === undefined ? "unset" : "blank",
863
+ },
864
+ });
865
+ }
848
866
  // M1 ambient judgment recall: after a `read`, append the recorded judgment for
849
867
  // that path to the tool result (result modification is supported on pi's
850
868
  // `tool_result` event — verified against 0.80.2). No-op when the corpus is thin
@@ -231,7 +231,11 @@ export interface RegisterPermissionDeps {
231
231
  * Wired by index.ts; offered as a third option on Guardian ask dialogs.
232
232
  * Fail-soft: the in-session approval applies even if the write fails.
233
233
  */
234
- persistUserRule?: (ruleString: string) => void;
234
+ /** Persist an allow rule for the caller; the cwd is the SESSION cwd of
235
+ * the gate call (may differ from process.cwd() when the session switches
236
+ * directories) — the rule must land in the project the user is working
237
+ * in, the same cwd the gate uses for grants and approvals. */
238
+ persistUserRule?: (ruleString: string, cwd: string) => void;
235
239
  /**
236
240
  * Called (fire-and-forget) at every terminal prompt-band outcome with the
237
241
  * rich storage event (raw command — the wiring layer redacts/hashes).
@@ -642,7 +642,7 @@ export function registerPermissionGate(pi, deps = {}) {
642
642
  }
643
643
  catch { /* logging must never affect the gate */ }
644
644
  if (ruleVerdict.verdict === "deny") {
645
- const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
645
+ const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
646
646
  return {
647
647
  block: true,
648
648
  reason: `${event.toolName} was denied by a permission rule in ${origin} (${ruleVerdict.rule.raw}). Do not attempt the same outcome via a workaround or indirect execution — ask the user to change the rule if this action is genuinely needed.`,
@@ -665,7 +665,7 @@ export function registerPermissionGate(pi, deps = {}) {
665
665
  const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
666
666
  if (ruleAskApprovals.has(askKey))
667
667
  return {};
668
- const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
668
+ const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
669
669
  const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
670
670
  if (choice === "yes") {
671
671
  if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
@@ -982,7 +982,7 @@ export function registerPermissionGate(pi, deps = {}) {
982
982
  ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
983
983
  : null;
984
984
  const ruleLabel = grantCandidate && deps.persistUserRule
985
- ? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in my user settings`
985
+ ? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in this project's local settings`
986
986
  : null;
987
987
  const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel);
988
988
  if (resolution === "yes") {
@@ -1000,29 +1000,35 @@ export function registerPermissionGate(pi, deps = {}) {
1000
1000
  if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
1001
1001
  let persisted = false;
1002
1002
  try {
1003
- deps.persistUserRule(ruleCandidate);
1003
+ deps.persistUserRule(ruleCandidate, cwd);
1004
1004
  persisted = true;
1005
1005
  // Also covers this session like a grant would:
1006
1006
  rememberApproved(cwd, command);
1007
1007
  }
1008
1008
  catch (err) {
1009
- // Fail-soft: the in-memory approval still applies THIS
1010
- // session. But the user just made an explicit durable choice
1011
- // in the dialog a silent failure would leave them believing
1012
- // a rule exists that will not survive restart. Log the
1013
- // failure (rule string only, it is user-configured text, plus
1014
- // error class never the thrown message) and tell the user.
1009
+ // Fail-soft: the in-memory approval covers ONLY this call —
1010
+ // rememberApproved sits after the throwing call, so a failed
1011
+ // persist does NOT remember the command for the session (a
1012
+ // narrower fallback than a grant, deliberately: the user's
1013
+ // durable choice did not land). But the user just made an
1014
+ // explicit durable choice in the dialog a silent failure
1015
+ // would leave them believing a rule exists that will not
1016
+ // survive restart. Log the failure (rule string + the thrown
1017
+ // message — it is mutateConfigJson's own path/reason text,
1018
+ // never the raw parse error or file content) and tell the
1019
+ // user.
1015
1020
  logEvent({
1016
1021
  source: "permission-rules",
1017
1022
  level: "warn",
1018
- event: "user_rule_save_failed",
1023
+ event: "rule_save_failed",
1019
1024
  fields: {
1020
1025
  rule: ruleCandidate,
1021
- error: err instanceof Error ? err.constructor.name : typeof err,
1026
+ destination: "local",
1027
+ error: err instanceof Error ? err.message : String(err),
1022
1028
  },
1023
1029
  });
1024
1030
  if (ctx?.hasUI) {
1025
- ctx.ui.notify(`Could not save the permission rule to your settings — it applies to this session only.`, "warning");
1031
+ ctx.ui.notify(`Could not save the permission rule to your settings — it did not persist; you'll be asked again on the next identical command.`, "warning");
1026
1032
  }
1027
1033
  }
1028
1034
  emitGateEvent(slot, {
@@ -1,23 +1,29 @@
1
1
  /**
2
2
  * Permission-rule settings loader.
3
3
  *
4
- * Reads `permissions: { allow, deny, ask }` from the two YAGNI Code settings
5
- * files, mirroring the hooks loader (hooks.ts) shape exactly:
6
- * - user: ~/.yagni-code/config.json — always active
7
- * - project: .yagni-code/config.json — deny/ask always active;
8
- * allow gated on workspace trust (Claude Code semantics:
9
- * "deny and ask rules apply right away; allow rules from a
10
- * project file wait for trust")
4
+ * Reads `permissions: { allow, deny, ask }` from the three YAGNI Code
5
+ * settings files, mirroring the hooks loader (hooks.ts) shape exactly:
6
+ * - user: ~/.yagni-code/config.json — always active
7
+ * - project: .yagni-code/config.json — deny/ask always active;
8
+ * allow gated on workspace trust (Claude Code semantics:
9
+ * "deny and ask rules apply right away; allow rules from a
10
+ * project file wait for trust")
11
+ * - local: .yagni-code/config.local.json — the personal per-project
12
+ * tier; trust-gated EXACTLY like project (a hostile repo can
13
+ * commit a local file — the gitignore convention only covers
14
+ * untracked files). Deliberately stricter than Claude Code,
15
+ * which does not trust-gate localSettings.
11
16
  *
12
17
  * Lists UNION across sources (Claude Code: lists merge, never replace).
13
- * User-source rules are ordered before project-source rules so path-pattern
14
- * negation (`!exception`) behaves like gitignore lines across files.
18
+ * User-source rules are ordered before project-source rules, project before
19
+ * local, so path-pattern negation (`!exception`) behaves like gitignore
20
+ * lines across files — higher-precedence sources come later (later wins).
15
21
  *
16
22
  * Fail-soft: a malformed config file is skipped whole with a warning (same
17
23
  * posture as hooks.ts). Malformed individual rule strings parse as bare
18
24
  * tool names (parser degrades, never throws) and are flagged.
19
25
  */
20
- export type RuleSource = "user" | "project";
26
+ export type RuleSource = "user" | "project" | "local";
21
27
  export type RuleBehavior = "allow" | "deny" | "ask";
22
28
  /** A parsed, sourced permission rule. */
23
29
  export interface PermissionRule {
@@ -40,7 +46,7 @@ export interface LoadedRules {
40
46
  }
41
47
  /** The tools a rule can actually be consulted for at the gate. */
42
48
  export declare const KNOWN_RULE_TOOLS: Set<string>;
43
- /** Load + union rules from both files. Pure I/O; no throw. */
49
+ /** Load + union rules from all three files. Pure I/O; no throw. */
44
50
  export declare function loadPermissionRules(opts?: {
45
51
  cwd?: string;
46
52
  env?: NodeJS.ProcessEnv;
@@ -48,6 +54,11 @@ export declare function loadPermissionRules(opts?: {
48
54
  /** Override the user state home (tests). */
49
55
  stateHomeOverride?: string | null;
50
56
  }): LoadedRules;
51
- /** Filter rules by behavior + trust (project allow needs trust; deny/ask always). */
57
+ /**
58
+ * Filter rules by behavior + trust. Untrusted repos keep deny/ask from every
59
+ * source but lose allow rules from BOTH project and local files — a
60
+ * committed config.local.json must not smuggle allow rules past the trust
61
+ * gate (see the module header; stricter than Claude Code's localSettings).
62
+ */
52
63
  export declare function effectiveRules(rules: readonly PermissionRule[], isProjectTrusted: boolean): PermissionRule[];
53
64
  //# sourceMappingURL=loadConfig.d.ts.map
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Permission-rule settings loader.
3
3
  *
4
- * Reads `permissions: { allow, deny, ask }` from the two YAGNI Code settings
5
- * files, mirroring the hooks loader (hooks.ts) shape exactly:
6
- * - user: ~/.yagni-code/config.json — always active
7
- * - project: .yagni-code/config.json — deny/ask always active;
8
- * allow gated on workspace trust (Claude Code semantics:
9
- * "deny and ask rules apply right away; allow rules from a
10
- * project file wait for trust")
4
+ * Reads `permissions: { allow, deny, ask }` from the three YAGNI Code
5
+ * settings files, mirroring the hooks loader (hooks.ts) shape exactly:
6
+ * - user: ~/.yagni-code/config.json — always active
7
+ * - project: .yagni-code/config.json — deny/ask always active;
8
+ * allow gated on workspace trust (Claude Code semantics:
9
+ * "deny and ask rules apply right away; allow rules from a
10
+ * project file wait for trust")
11
+ * - local: .yagni-code/config.local.json — the personal per-project
12
+ * tier; trust-gated EXACTLY like project (a hostile repo can
13
+ * commit a local file — the gitignore convention only covers
14
+ * untracked files). Deliberately stricter than Claude Code,
15
+ * which does not trust-gate localSettings.
11
16
  *
12
17
  * Lists UNION across sources (Claude Code: lists merge, never replace).
13
- * User-source rules are ordered before project-source rules so path-pattern
14
- * negation (`!exception`) behaves like gitignore lines across files.
18
+ * User-source rules are ordered before project-source rules, project before
19
+ * local, so path-pattern negation (`!exception`) behaves like gitignore
20
+ * lines across files — higher-precedence sources come later (later wins).
15
21
  *
16
22
  * Fail-soft: a malformed config file is skipped whole with a warning (same
17
23
  * posture as hooks.ts). Malformed individual rule strings parse as bare
@@ -64,27 +70,36 @@ function readRulesFromFile(path, source, behavior, out, warnings) {
64
70
  warnings.push(`${source} settings: malformed JSON — permissions skipped (${path})`);
65
71
  }
66
72
  }
67
- /** Load + union rules from both files. Pure I/O; no throw. */
73
+ /** Load + union rules from all three files. Pure I/O; no throw. */
68
74
  export function loadPermissionRules(opts = {}) {
69
75
  const warnings = [];
70
76
  const rules = [];
71
77
  const stateHome = opts.stateHomeOverride ?? codeStateHome(null, opts.env, opts.userHome);
78
+ const cwd = opts.cwd ?? process.cwd();
72
79
  const userPath = join(stateHome, "config.json");
73
- const projectPath = join(opts.cwd ?? process.cwd(), ".yagni-code", "config.json");
74
- // User first, project second — ordering matters for path-rule negation.
80
+ const projectPath = join(cwd, ".yagni-code", "config.json");
81
+ const localPath = join(cwd, ".yagni-code", "config.local.json");
82
+ // User, then project, then local — ordering matters for path-rule
83
+ // negation (later sources win, matching scalar precedence).
75
84
  for (const behavior of ["deny", "ask", "allow"]) {
76
85
  readRulesFromFile(userPath, "user", behavior, rules, warnings);
77
86
  readRulesFromFile(projectPath, "project", behavior, rules, warnings);
87
+ readRulesFromFile(localPath, "local", behavior, rules, warnings);
78
88
  }
79
89
  const neverConsultedTools = [
80
90
  ...new Set(rules.map((r) => r.toolName).filter((t) => !KNOWN_RULE_TOOLS.has(t) && !t.startsWith("mcp__"))),
81
91
  ];
82
92
  return { rules, diagnostics: { warnings, neverConsultedTools } };
83
93
  }
84
- /** Filter rules by behavior + trust (project allow needs trust; deny/ask always). */
94
+ /**
95
+ * Filter rules by behavior + trust. Untrusted repos keep deny/ask from every
96
+ * source but lose allow rules from BOTH project and local files — a
97
+ * committed config.local.json must not smuggle allow rules past the trust
98
+ * gate (see the module header; stricter than Claude Code's localSettings).
99
+ */
85
100
  export function effectiveRules(rules, isProjectTrusted) {
86
101
  if (isProjectTrusted)
87
102
  return [...rules];
88
- return rules.filter((r) => !(r.source === "project" && r.behavior === "allow"));
103
+ return rules.filter((r) => !(r.source !== "user" && r.behavior === "allow"));
89
104
  }
90
105
  //# sourceMappingURL=loadConfig.js.map
@@ -8,15 +8,17 @@
8
8
  * //path absolute from filesystem root
9
9
  * ~/path relative to the user's home directory
10
10
  * /path relative to the SETTINGS FILE's directory (user config →
11
- * ~/.yagni-code/, project config → the project root)
11
+ * ~/.yagni-code/, project AND local configs → the project root
12
+ * local lives in the same .yagni-code dir as project)
12
13
  * path relative to the session cwd; a bare filename matches at ANY
13
14
  * depth (`.env` equals `**` / `.env` in glob terms)
14
15
  *
15
16
  * Compile semantics: all patterns for one (tool-class, behavior) compile
16
17
  * into ONE ordered `ignore` instance — user-source patterns first, then
17
- * project — so gitignore's later-line-wins and `!exception` negation work
18
- * across rules from both files (Claude Code's matchingRuleForInput builds
19
- * one ignore().add(patterns) per root the same way).
18
+ * project, then local — so gitignore's later-line-wins and `!exception`
19
+ * negation work across rules from all three files (Claude Code's
20
+ * matchingRuleForInput builds one ignore().add(patterns) per root the same
21
+ * way; local patterns are the last/highest-precedence "lines").
20
22
  *
21
23
  * Behavior asymmetry (Claude Code parity): allow rules with a rootless
22
24
  * single-segment pattern anchor at the settings dir (narrow); deny/ask
@@ -40,9 +42,9 @@ export interface ResolvedPathPattern {
40
42
  }
41
43
  /**
42
44
  * Split an anchored pattern into (root, relativePattern). Mirrors Claude
43
- * Code's patternWithRoot. Project-source `/` anchors at the project root
44
- * (passed in); user-source `/` anchors at the YAGNI Code state home
45
- * (~/.yagni-code).
45
+ * Code's patternWithRoot. Project- and local-source `/` anchors at the
46
+ * project root (passed in) local shares project's .yagni-code dir;
47
+ * user-source `/` anchors at the YAGNI Code state home (~/.yagni-code).
46
48
  */
47
49
  export declare function resolvePatternRoot(pattern: string, source: RuleSource, opts: {
48
50
  userStateHome: string;
@@ -8,15 +8,17 @@
8
8
  * //path absolute from filesystem root
9
9
  * ~/path relative to the user's home directory
10
10
  * /path relative to the SETTINGS FILE's directory (user config →
11
- * ~/.yagni-code/, project config → the project root)
11
+ * ~/.yagni-code/, project AND local configs → the project root
12
+ * local lives in the same .yagni-code dir as project)
12
13
  * path relative to the session cwd; a bare filename matches at ANY
13
14
  * depth (`.env` equals `**` / `.env` in glob terms)
14
15
  *
15
16
  * Compile semantics: all patterns for one (tool-class, behavior) compile
16
17
  * into ONE ordered `ignore` instance — user-source patterns first, then
17
- * project — so gitignore's later-line-wins and `!exception` negation work
18
- * across rules from both files (Claude Code's matchingRuleForInput builds
19
- * one ignore().add(patterns) per root the same way).
18
+ * project, then local — so gitignore's later-line-wins and `!exception`
19
+ * negation work across rules from all three files (Claude Code's
20
+ * matchingRuleForInput builds one ignore().add(patterns) per root the same
21
+ * way; local patterns are the last/highest-precedence "lines").
20
22
  *
21
23
  * Behavior asymmetry (Claude Code parity): allow rules with a rootless
22
24
  * single-segment pattern anchor at the settings dir (narrow); deny/ask
@@ -42,9 +44,9 @@ export function pathClassForTool(toolName) {
42
44
  }
43
45
  /**
44
46
  * Split an anchored pattern into (root, relativePattern). Mirrors Claude
45
- * Code's patternWithRoot. Project-source `/` anchors at the project root
46
- * (passed in); user-source `/` anchors at the YAGNI Code state home
47
- * (~/.yagni-code).
47
+ * Code's patternWithRoot. Project- and local-source `/` anchors at the
48
+ * project root (passed in) local shares project's .yagni-code dir;
49
+ * user-source `/` anchors at the YAGNI Code state home (~/.yagni-code).
48
50
  */
49
51
  export function resolvePatternRoot(pattern, source, opts) {
50
52
  if (pattern.startsWith("//")) {
@@ -55,7 +57,7 @@ export function resolvePatternRoot(pattern, source, opts) {
55
57
  return { relativePattern: rel, root: opts.homeDir ?? homedir(), source };
56
58
  }
57
59
  if (pattern.startsWith("/") && !pattern.startsWith("//")) {
58
- const base = source === "project" ? opts.projectRoot : opts.userStateHome;
60
+ const base = source === "user" ? opts.userStateHome : opts.projectRoot;
59
61
  return { relativePattern: pattern, root: base ?? opts.userStateHome, source };
60
62
  }
61
63
  // Rootless: `./x` normalized to `x`; bare names keep any-depth semantics.
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Sandbox settings schema + layered config load (user + project), shaped after
3
- * Claude Code's SandboxSettingsSchema subset so a Claude `sandbox` block
4
- * copied verbatim parses and resolves identically.
2
+ * Sandbox settings schema + layered config load (user + project + local),
3
+ * shaped after Claude Code's SandboxSettingsSchema subset so a Claude
4
+ * `sandbox` block copied verbatim parses and resolves identically.
5
5
  *
6
- * Files: the `sandbox` key in ~/.yagni-code/config.json (user) and
7
- * .yagni-code/config.json (project) the same pair permissionRules reads,
8
- * so one settings file drives both the permission layer and the sandbox.
6
+ * Files: the `sandbox` key in ~/.yagni-code/config.json (user),
7
+ * .yagni-code/config.json (project), and .yagni-code/config.local.json
8
+ * (local the personal per-project tier; the /sandbox panel and toggle
9
+ * persist HERE).
9
10
  *
10
- * Merge semantics (Claude parity): scalars — project wins over user over
11
- * defaults; arrays — union + dedupe across sources (lists merge, never
12
- * replace). Fail-soft: a malformed file is skipped whole with a warning
13
- * (same posture as permissionRules/loadConfig.ts).
11
+ * Merge semantics (Claude parity): scalars — local wins over project over
12
+ * user over defaults; arrays — union + dedupe across sources (lists merge,
13
+ * never replace). Fail-soft: a malformed file is skipped whole with a
14
+ * warning (same posture as permissionRules/loadConfig.ts).
14
15
  *
15
16
  * Read/write filesystem semantics (srt, verified in the M0 spike): reads are
16
17
  * allowed by default with denyRead regions + allowRead re-allow; writes are
@@ -64,10 +65,10 @@ export interface LoadedSandboxConfig {
64
65
  */
65
66
  export declare function readSandboxSettingsFromFile(configPath: string, warnings: string[], unknownKeys: string[]): SandboxSettings | undefined;
66
67
  /**
67
- * Load + merge sandbox settings from both config files. Scalars: project
68
- * beats user; arrays: union. Defaults for scalars land here too (Claude
69
- * parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands true,
70
- * everything else unset/false.
68
+ * Load + merge sandbox settings from all three config files. Scalars: local
69
+ * beats project beats user; arrays: union. Defaults for scalars land here
70
+ * too (Claude parity): autoAllowBashIfSandboxed true, allowUnsandboxedCommands
71
+ * true, everything else unset/false.
71
72
  */
72
73
  export declare function loadSandboxSettings(opts?: {
73
74
  cwd?: string;