@yagni-app/code-staging 1.0.6-staging.1242.1 → 1.0.6-staging.1245.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
@@ -179,11 +179,16 @@ children and subagents — emits a per-prompt span tree: interaction → LLM
179
179
  request → tool calls, following the OTel GenAI semantic conventions. Nothing is
180
180
  exported unless you configure an endpoint.
181
181
 
182
- Enable it one of three ways (first match wins):
183
-
184
- - `OTEL_EXPORTER_OTLP_ENDPOINT=http://<collector>:4317` in the environment
185
- (a personal override — handy for pointing one session at a scratch
186
- collector), or
182
+ Enable it one of four ways (first match wins):
183
+
184
+ - `OTEL_EXPORTER_OTLP_ENDPOINT=…` in the environment (a personal override —
185
+ handy for pointing one session at a scratch collector), or
186
+ - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=…` in the environment — the per-signal
187
+ form Claude Code setups use (e.g.
188
+ `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://otlp.datadoghq.com/v1/traces`
189
+ with `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf`); the launcher
190
+ bridges it to the generic var pi-otel reads, so a Claude Code OTel env
191
+ lights up YAGNI Code traces with no extra config, or
187
192
  - **workspace settings** (the zero-setup path): a workspace admin sets the
188
193
  endpoint, protocol, and any collector headers (e.g. a Datadog API key) once
189
194
  in the web app under Settings → YAGNI Code → Trace export. Every session in
@@ -197,7 +202,26 @@ Enable it one of three ways (first match wins):
197
202
  Standard OTel env vars are honored (`OTEL_EXPORTER_OTLP_PROTOCOL`,
198
203
  `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_SERVICE_NAME` — defaults to `yagni-code`),
199
204
  and `PI_OTEL_DISABLED=1` is the kill switch. `yagni doctor` shows the current
200
- export state.
205
+ export state and probes the endpoint live (unreachable → a warning with a
206
+ hint, never an exit-code flip).
207
+
208
+ Two recipes for common collectors:
209
+
210
+ - **Datadog, direct SaaS (no agent):** endpoint
211
+ `https://otlp.datadoghq.com/v1/traces` (US1; other sites have their own
212
+ host — `otlp.us3.datadoghq.com`, `otlp.us5.datadoghq.com`,
213
+ `otlp.datadoghq.eu`, `otlp.ap1.datadoghq.com`), protocol `http/protobuf`
214
+ (Datadog's direct trace intake does not accept gRPC), header
215
+ `dd-api-key=<your Datadog API key>`. Use the full path, not `/v1/` — the
216
+ endpoint is passed through verbatim, no path is appended for you.
217
+ - **Datadog Agent:** endpoint `http://<agent-host>:4317`, protocol `grpc`, no
218
+ key (the agent forwards with its own). OTLP ingest ships in the agent but
219
+ is off by default — enable it with
220
+ `otlp_config.receiver.protocols.grpc.endpoint: 0.0.0.0:4317` in
221
+ `datadog.yaml`.
222
+ - **Grafana Cloud:** endpoint `https://otlp-gateway-prod-…-0.grafana.net:443/otlp`,
223
+ protocol `http/protobuf`, header `authorization=<the raw MTgx… value>` (the
224
+ `Basic ` scheme is added for you).
201
225
 
202
226
  Two things are enforced and not configurable:
203
227
 
package/dist/doctor.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * Advisory checks (loose perms, missing `gh`) never flip the exit code.
14
14
  */
15
15
  import { type TokenExpiryStatus } from "./launch.js";
16
- import { type OtelLaunchConfig } from "./otel.js";
16
+ import { probeOtelEndpoint, type OtelLaunchConfig } from "./otel.js";
17
17
  import { type Profile } from "./profiles.js";
18
18
  export type CheckStatus = "ok" | "warn" | "fail";
19
19
  export interface CheckResult {
@@ -63,10 +63,15 @@ export declare function checkCliUpdate(probe: {
63
63
  export declare function checkGh(onPath: boolean): CheckResult;
64
64
  /**
65
65
  * Advisory OTel-export line: says whether sessions will stream traces to an
66
- * OTLP collector, and from which config source. Never flips the exit code
66
+ * OTLP collector, from which config source, and when a collector is
67
+ * configured — whether the endpoint answers TCP right now (scheme-default
68
+ * ports included: https with no port probes 443). Never flips the exit code —
67
69
  * most machines have no collector, and that is the healthy default.
68
70
  */
69
- export declare function checkOtelExport(config: OtelLaunchConfig | undefined): CheckResult;
71
+ export declare function checkOtelExport(config: OtelLaunchConfig | undefined, deps?: {
72
+ probeEndpoint?: typeof probeOtelEndpoint;
73
+ probeTimeoutMs?: number;
74
+ }): Promise<CheckResult>;
70
75
  /** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
71
76
  export interface McpProbe {
72
77
  /** YAGNI_CODE_MCP_DISABLED kill-switch is active. */
package/dist/doctor.js CHANGED
@@ -17,7 +17,7 @@ import { delimiter, join } from "node:path";
17
17
  import { credentialsDir } from "./credentials.js";
18
18
  import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
19
19
  import { classifyTokenExpiry } from "./launch.js";
20
- import { resolveOtelLaunchWithWorkspace } from "./otel.js";
20
+ import { probeOtelEndpoint, resolveOtelLaunchWithWorkspace, } from "./otel.js";
21
21
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
22
22
  import { readActiveProfile } from "./profiles.js";
23
23
  import { resolveMcpConfigPath } from "./mcpCommand.js";
@@ -202,10 +202,12 @@ export function checkGh(onPath) {
202
202
  }
203
203
  /**
204
204
  * Advisory OTel-export line: says whether sessions will stream traces to an
205
- * OTLP collector, and from which config source. Never flips the exit code
205
+ * OTLP collector, from which config source, and when a collector is
206
+ * configured — whether the endpoint answers TCP right now (scheme-default
207
+ * ports included: https with no port probes 443). Never flips the exit code —
206
208
  * most machines have no collector, and that is the healthy default.
207
209
  */
208
- export function checkOtelExport(config) {
210
+ export async function checkOtelExport(config, deps = {}) {
209
211
  if (!config) {
210
212
  return {
211
213
  name: "otel export (optional)",
@@ -215,16 +217,41 @@ export function checkOtelExport(config) {
215
217
  };
216
218
  }
217
219
  const source = config.source === "env"
218
- ? "OTEL_EXPORTER_OTLP_ENDPOINT"
220
+ ? (config.envVar ?? "OTEL_EXPORTER_OTLP_ENDPOINT")
219
221
  : config.source === "workspace"
220
222
  ? "workspace settings"
221
223
  : ".pi/settings.json";
222
- return {
223
- name: "otel export (optional)",
224
- status: "ok",
225
- detail: `on ${config.endpoint} (${source}, metadata-only)`,
226
- required: false,
227
- };
224
+ const base = `on → ${config.endpoint} (${source}, metadata-only)`;
225
+ const doProbe = deps.probeEndpoint ?? probeOtelEndpoint;
226
+ try {
227
+ const reachable = await doProbe(config.endpoint, deps.probeTimeoutMs ?? 1_000);
228
+ return reachable
229
+ ? {
230
+ name: "otel export (optional)",
231
+ status: "ok",
232
+ detail: `${base}, TCP reachable`,
233
+ required: false,
234
+ }
235
+ : {
236
+ name: "otel export (optional)",
237
+ status: "warn",
238
+ detail: `${base}, endpoint not reachable`,
239
+ hint: "the collector is down, unreachable from this machine, or the URL is wrong — traces will not export until it answers",
240
+ required: false,
241
+ };
242
+ }
243
+ catch {
244
+ // The probe itself errored (not "endpoint down" — the check could not
245
+ // run). Distinct from a verified-healthy line so nobody reads an
246
+ // unverified config as checked-and-good.
247
+ return {
248
+ name: "otel export (optional)",
249
+ status: "warn",
250
+ detail: `${base}, reachability probe failed`,
251
+ hint: "the reachability check could not run (network error) — export is configured but unverified; traces may still export normally",
252
+ required: false,
253
+ };
254
+ }
228
255
  }
229
256
  /**
230
257
  * Advisory (never required) MCP line: reports config health without connecting
@@ -489,7 +516,7 @@ export async function gatherChecks(deps = {}) {
489
516
  checks.push(checkStateDir(probeStateDir()));
490
517
  checks.push(checkGh(ghOnPath()));
491
518
  checks.push(checkMcpConfig(await probeMcp()));
492
- checks.push(checkOtelExport(await resolveOtelLaunchWithWorkspace({
519
+ checks.push(await checkOtelExport(await resolveOtelLaunchWithWorkspace({
493
520
  env: process.env,
494
521
  cwd: process.cwd(),
495
522
  creds: profile.token ? { baseUrl: profile.baseUrl, token: profile.token } : null,
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { Text } from "@earendil-works/pi-tui";
3
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
6
  import { makeChildUsageState } from "./childUsage.js";
@@ -35,6 +37,7 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
35
37
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
36
38
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
37
39
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
40
+ import { loadPermissionRules } from "./permissionRules/loadConfig.js";
38
41
  import { registerSubagents } from "./subagents.js";
39
42
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
40
43
  import { registerTodos } from "./todos.js";
@@ -334,6 +337,8 @@ export async function registerYagni(pi, deps = {}) {
334
337
  // The static queue only carries non-connectivity notices (e.g. the
335
338
  // `.mcp.json` approval prompt), which stay one-shot.
336
339
  let connectivityNoticesFlushed = false;
340
+ // permission-rule notices are one-shot like connectivity notices.
341
+ let rulesNoticesFlushed = false;
337
342
  pi.on("after_provider_response", (_event, ctx) => {
338
343
  try {
339
344
  if (!ctx.hasUI)
@@ -347,6 +352,21 @@ export async function registerYagni(pi, deps = {}) {
347
352
  while (mcpApprovalNotices.length > 0) {
348
353
  ctx.ui.notify(mcpApprovalNotices.shift(), "info");
349
354
  }
355
+ // one-shot permission-rule config warnings (bad files,
356
+ // never-consulted tools). One consolidated line, never a wall.
357
+ if (!rulesNoticesFlushed) {
358
+ rulesNoticesFlushed = true;
359
+ const parts = [];
360
+ if (loadedRules.diagnostics.warnings.length > 0) {
361
+ parts.push(`${loadedRules.diagnostics.warnings.length} permission-rule config warning(s)`);
362
+ }
363
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
364
+ parts.push(`rules for unknown tools: ${loadedRules.diagnostics.neverConsultedTools.join(", ")} (ignored)`);
365
+ }
366
+ if (parts.length > 0) {
367
+ ctx.ui.notify(`Permission rules: ${parts.join(" · ")}`, "warning");
368
+ }
369
+ }
350
370
  }
351
371
  catch {
352
372
  // A notice must never break a turn.
@@ -408,6 +428,25 @@ export async function registerYagni(pi, deps = {}) {
408
428
  // the startup load is the trust boundary; live reload was reviewed and
409
429
  // rejected as a same-session self-authorization path, PR #1698).
410
430
  const sessionGrants = evalMode ? [] : loadGrants();
431
+ // settings-based permission rules, loaded once at startup (same
432
+ // trust-boundary posture as grants: no mid-session reload). User config
433
+ // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
434
+ // project allow rules are trust-gated at evaluation time, deny/ask always.
435
+ const loadedRules = evalMode
436
+ ? { rules: [], diagnostics: { warnings: [], neverConsultedTools: [] } }
437
+ : loadPermissionRules({ cwd: process.cwd() });
438
+ for (const w of loadedRules.diagnostics.warnings) {
439
+ logEvent({ source: "permission-rules", level: "warn", event: "config_warning", fields: { warning: w } });
440
+ }
441
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
442
+ logEvent({
443
+ source: "permission-rules",
444
+ level: "warn",
445
+ event: "never_consulted_tools",
446
+ fields: { tools: loadedRules.diagnostics.neverConsultedTools.join(", ") },
447
+ });
448
+ }
449
+ const rulesStateHome = codeStateHome(null, env);
411
450
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
412
451
  // YAG-506: load user-configurable lifecycle hooks config and create the
413
452
  // hook runner for the permission gate. Skipped in eval mode.
@@ -420,6 +459,27 @@ export async function registerYagni(pi, deps = {}) {
420
459
  guardianState,
421
460
  guardianLimits,
422
461
  guardianTier,
462
+ ...(loadedRules.rules.length > 0
463
+ ? {
464
+ permissionRules: loadedRules.rules,
465
+ rulesUserStateHome: rulesStateHome,
466
+ rulesProjectRoot: process.cwd(),
467
+ onRuleVerdict: (ev) => {
468
+ logEvent({
469
+ source: "permission-rules",
470
+ level: "debug",
471
+ event: `rule_${ev.verdict}`,
472
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
473
+ fields: {
474
+ tool: ev.toolName,
475
+ via: ev.matchedVia,
476
+ source: ev.rule.source,
477
+ rule: ev.rule.raw,
478
+ },
479
+ });
480
+ },
481
+ }
482
+ : {}),
423
483
  guardianDisabled,
424
484
  childUsage,
425
485
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
@@ -471,6 +531,45 @@ export async function registerYagni(pi, deps = {}) {
471
531
  if (!evalMode)
472
532
  appendGrant(grant);
473
533
  },
534
+ // persist a user-level allow rule (Guardian ask dialog's third
535
+ // option). Atomic write, never overwrites other keys, fail-soft.
536
+ persistUserRule: (ruleString) => {
537
+ if (evalMode)
538
+ return;
539
+ try {
540
+ const userPath = join(rulesStateHome, "config.json");
541
+ let parsed = {};
542
+ if (existsSync(userPath)) {
543
+ parsed = JSON.parse(readFileSync(userPath, "utf-8"));
544
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
545
+ return;
546
+ }
547
+ const perms = (parsed.permissions ?? {});
548
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
549
+ if (!allow.includes(ruleString))
550
+ allow.push(ruleString);
551
+ perms.allow = allow;
552
+ parsed.permissions = perms;
553
+ const tmp = join(rulesStateHome, `.config.json.yagni-${process.pid}-${Date.now()}.tmp`);
554
+ writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
555
+ renameSync(tmp, userPath);
556
+ logEvent({
557
+ source: "permission-rules",
558
+ level: "info",
559
+ event: "user_rule_saved",
560
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
561
+ fields: { rule: ruleString },
562
+ });
563
+ }
564
+ catch (err) {
565
+ logEvent({
566
+ source: "permission-rules",
567
+ level: "warn",
568
+ event: "user_rule_save_failed",
569
+ fields: { message: err instanceof Error ? err.message : "unknown" },
570
+ });
571
+ }
572
+ },
474
573
  // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
475
574
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
476
575
  // metadata, no command content; "raw" → adds client-REDACTED command and
@@ -94,6 +94,33 @@ export interface GateDecision {
94
94
  */
95
95
  classifyJustification?: string;
96
96
  }
97
+ /**
98
+ * What the hard floors say about an ALLOW-rule verdict — the three invariants
99
+ * that outrank any user/project permission rule. Extracted from the inline
100
+ * allow path so the gate reads linearly and the floors are directly testable.
101
+ * PURE — no I/O, no session state:
102
+ *
103
+ * "allow" no floor applies — the rule's allow short-circuits everything
104
+ * "block" the exec-policy forbidden band holds despite the allow rule
105
+ * "confirm" alwaysConfirmTools keeps its fresh-consent contract
106
+ * "hold" plan mode's no-mutation contract outranks the allow rule —
107
+ * fall through to the normal plan-mode gate
108
+ *
109
+ * Order is deliberate: plan-mode first (its outcome routes to the EXISTING
110
+ * plan-mode machinery, not a bespoke block); then the terminal forbidden-band
111
+ * block; then the confirm deferral.
112
+ */
113
+ export type AllowRuleFloorOutcome = {
114
+ kind: "allow";
115
+ } | {
116
+ kind: "block";
117
+ reason: string;
118
+ } | {
119
+ kind: "confirm";
120
+ } | {
121
+ kind: "hold";
122
+ };
123
+ export declare function allowRuleFloorVerdict(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): AllowRuleFloorOutcome;
97
124
  /**
98
125
  * Pure permission decision for one tool call under a mode + policy. Auto allows
99
126
  * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
@@ -187,12 +214,38 @@ export interface RegisterPermissionDeps {
187
214
  /** Persist a new grant (fire-and-forget; the in-memory list is updated
188
215
  * either way). index.ts wires approvedPrefixes.appendGrant. */
189
216
  persistGrant?: (grant: ApprovedPrefixGrant) => void;
217
+ /**
218
+ * persist a user-level permission rule string (e.g.
219
+ * `Bash(git push:*)`) into ~/.yagni-code/config.json permissions.allow.
220
+ * Wired by index.ts; offered as a third option on Guardian ask dialogs.
221
+ * Fail-soft: the in-session approval applies even if the write fails.
222
+ */
223
+ persistUserRule?: (ruleString: string) => void;
190
224
  /**
191
225
  * Called (fire-and-forget) at every terminal prompt-band outcome with the
192
226
  * rich storage event (raw command — the wiring layer redacts/hashes).
193
227
  * Fail-soft; never blocks.
194
228
  */
195
229
  onGuardianEvent?: (event: GuardianGateEvent) => void;
230
+ /**
231
+ * settings-based permission rules (user + project config.json).
232
+ * When present, evaluated at the TOP of the tool_call handler, before
233
+ * hooks: deny → ask → allow, first match wins. Deny/ask are final; allow
234
+ * short-circuits the Guardian but can never lift the exec-policy forbidden
235
+ * band or the alwaysConfirmTools contract (both re-checked below).
236
+ */
237
+ permissionRules?: readonly import("../permissionRules/loadConfig.js").PermissionRule[];
238
+ /** ~/.yagni-code — the user `/`-anchor base for path rules. */
239
+ rulesUserStateHome?: string;
240
+ /** Project root for project-source `/`-anchored path rules; null outside a repo. */
241
+ rulesProjectRoot?: string | null;
242
+ /** Overrides ~ expansion for path rules (tests). */
243
+ rulesHomeDir?: string;
244
+ /** Called (fire-and-soft) after every rule verdict for debug logging. */
245
+ onRuleVerdict?: (event: import("../permissionRules/engine.js").RuleEvaluation & {
246
+ toolName: string;
247
+ cwd: string;
248
+ }) => void;
196
249
  /**
197
250
  * User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
198
251
  * hooks run before decideGate and can short-circuit (allow/deny/ask),