@yagni-app/code-staging 1.1.1-staging.1359.1 → 1.1.1-staging.1361.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.
@@ -580,6 +580,64 @@ export async function registerYagni(pi, deps = {}) {
580
580
  fields,
581
581
  });
582
582
  };
583
+ /**
584
+ * The prefix-consult failure sink (L2): fire-and-forget POST of one
585
+ * consult's outcome to the backend intake (log + hit-rate counters + the
586
+ * `Prefix Consult` Sentry area). A hit/none outcome is NOT a failure —
587
+ * its counter rides the same POST so the dashboard has both sides.
588
+ * Outcomes are a closed vocabulary, never command content. Fail-soft
589
+ * (the failure itself is sink-logged with the status or error class —
590
+ * never the thrown message, which can carry provider payloads);
591
+ * suppressed under test/eval so a CI run never phones prod.
592
+ */
593
+ const makePrefixErrorSink = () => (ev) => {
594
+ if (evalMode || runningUnderTest(env))
595
+ return;
596
+ void (async () => {
597
+ try {
598
+ const body = {
599
+ outcome: ev.outcome,
600
+ ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
601
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
602
+ ...(ev.tier ? { tier: ev.tier } : {}),
603
+ };
604
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/prefix-error`, {
605
+ method: "POST",
606
+ headers: {
607
+ "content-type": "application/json",
608
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
609
+ ...attributionHeaders(deps.env),
610
+ },
611
+ body: JSON.stringify(body),
612
+ }, {
613
+ fetchImpl: authedFetch,
614
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
615
+ });
616
+ if (!res.ok) {
617
+ logEvent({
618
+ source: "prefix",
619
+ level: "warn",
620
+ event: "prefix_error_post_failed",
621
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
622
+ fields: { status: res.status, outcome: ev.outcome },
623
+ });
624
+ }
625
+ }
626
+ catch (err) {
627
+ logEvent({
628
+ source: "prefix",
629
+ level: "warn",
630
+ event: "prefix_error_post_failed",
631
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
632
+ fields: {
633
+ kind: "network",
634
+ outcome: ev.outcome,
635
+ errorClass: err instanceof Error ? err.constructor.name : typeof err,
636
+ },
637
+ });
638
+ }
639
+ })();
640
+ };
583
641
  // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
584
642
  // at startup (grants added by other concurrent sessions appear next launch —
585
643
  // the startup load is the trust boundary; live reload was reviewed and
@@ -700,6 +758,20 @@ export async function registerYagni(pi, deps = {}) {
700
758
  : {}),
701
759
  guardianDisabled,
702
760
  childUsage,
761
+ // L2 prefix consult: enabled for interactive sessions with a live model
762
+ // backend (headless /go children and eval lanes never render the dialogs
763
+ // whose seeds it refines — the consult there is pure spend). The sink
764
+ // line + the failure POST live in onPrefixConsult below.
765
+ // YAGNI_DISABLE_PREFIX_CONSULT=1 is the runtime kill switch — a consult
766
+ // regression (cost spike, latency, bad seeds) disables the layer with no
767
+ // redeploy. Registration-scoped (read once here, like every other env
768
+ // gate at activation): toggling it mid-session does nothing until the
769
+ // session restarts. When set, the gate seeds from the L1 ladder only,
770
+ // byte-identical to the pre-consult behavior.
771
+ ...(evalMode || env.YAGNI_DISABLE_PREFIX_CONSULT === "1" || env.YAGNI_DISABLE_PREFIX_CONSULT === "true"
772
+ ? {}
773
+ : { prefixConsult: {} }),
774
+ onPrefixConsult: makePrefixErrorSink(),
703
775
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
704
776
  onGuardianReview: (ev) => {
705
777
  guardianLogSink(ev);
@@ -51,7 +51,15 @@ export interface ApprovedPrefixFile {
51
51
  version: 1;
52
52
  grants: ApprovedPrefixGrant[];
53
53
  }
54
- /** Tools whose second token is a subcommand worth capturing in a prefix. */
54
+ /**
55
+ * Tools whose second token is a subcommand worth capturing in a prefix.
56
+ * Kept ONLY as a fast-path marker (the flag-first fallback `pnpm --filter X
57
+ * test` → ["pnpm"]); the two-token rung itself is GENERIC — any clean command
58
+ * word + subcommand-shaped second token derives [tool, subcommand]
59
+ * (Claude's getCommandPrefix shape, not gated on a tool list), so
60
+ * `linear issue view` earns ["linear","issue"] without this repo ever
61
+ * having heard of `linear`.
62
+ */
55
63
  export declare const MULTI_SUBCOMMAND_TOOLS: Set<string>;
56
64
  /**
57
65
  * Prefixes that must never be grantable (Claude Code's BARE_SHELL_PREFIXES
@@ -173,6 +181,25 @@ export declare function validateGrantForEscape(command: string, policy: ExecPoli
173
181
  seeds: ApprovedPrefixGrant[];
174
182
  description: string;
175
183
  } | null;
184
+ /**
185
+ * The GUARDIAN-ASK seed ladder (the ask-flow counterpart of
186
+ * {@link validateGrantForEscape}). One deliberate difference from the escape
187
+ * flow, a Claude mirror: READ-ONLY segments of a compound are skipped — a
188
+ * `cd src && npm test` ask suggests only `npm test`, because the cd/exec-allow
189
+ * segments are not the thing the user is being asked about (Claude's
190
+ * sandboxed-permission ask does the same). The escape flow keeps its
191
+ * stricter all-segments stance: the unit of consent THERE is running with
192
+ * full unsandboxed authority, so even read-only segments ride the consent.
193
+ *
194
+ * Same five rungs as the escape ladder otherwise (token patterns per
195
+ * uncovered segment → heredoc prefix → first line → full literal), all
196
+ * self-match-proved — a seed that cannot cover its own command is never
197
+ * offered.
198
+ */
199
+ export declare function validateGrantForAsk(command: string, policy: ExecPolicy, repoKey: string): {
200
+ seeds: ApprovedPrefixGrant[];
201
+ description: string;
202
+ } | null;
176
203
  /**
177
204
  * Compound evaluation for the escape flow (Claude's subcommandResults
178
205
  * analog): every segment must be covered — by a grant, by exec-policy
@@ -32,7 +32,15 @@ import { classifyCommand, isSafeRedirect, shellParse, tokenize } from "./execPol
32
32
  import { SAFE_ENV_VARS } from "../permissionRules/shellRules.js";
33
33
  import { codeStateHome } from "../stateHome.js";
34
34
  // --- Derivation ---
35
- /** Tools whose second token is a subcommand worth capturing in a prefix. */
35
+ /**
36
+ * Tools whose second token is a subcommand worth capturing in a prefix.
37
+ * Kept ONLY as a fast-path marker (the flag-first fallback `pnpm --filter X
38
+ * test` → ["pnpm"]); the two-token rung itself is GENERIC — any clean command
39
+ * word + subcommand-shaped second token derives [tool, subcommand]
40
+ * (Claude's getCommandPrefix shape, not gated on a tool list), so
41
+ * `linear issue view` earns ["linear","issue"] without this repo ever
42
+ * having heard of `linear`.
43
+ */
36
44
  export const MULTI_SUBCOMMAND_TOOLS = new Set([
37
45
  "git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
38
46
  ]);
@@ -163,21 +171,24 @@ export function derivePrefix(command) {
163
171
  return null;
164
172
  if (BANNED_PREFIXES.has(first))
165
173
  return null;
166
- if (MULTI_SUBCOMMAND_TOOLS.has(first)) {
167
- const second = tokens[1];
168
- if (second && !second.startsWith("-") && SAFE_SUBCOMMAND_RE.test(second)) {
169
- return [first, second];
170
- }
171
- // flag-first multi-tool shape (`pnpm --filter X test`): Claude's
172
- // first-word fallback applies — the editable field is the narrowing
173
- // control, and a bare multi-tool grant is exactly what a human edits
174
- // down from there.
175
- return [first];
176
- }
177
- // first-word shape check (Claude's regex): rejects paths, flags, numbers,
178
- // filenames; only clean lowercase command words.
179
- if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first))
174
+ // GENERIC two-token rung (Claude parity): a known multi-subcommand tool
175
+ // short-circuits the word-shape check (its first tokens are known clean),
176
+ // but ANY first token passing the command-word regex earns the
177
+ // [tool, subcommand] pattern when the second token is subcommand-shaped —
178
+ // `linear issue view` → ["linear","issue"] from a tool list that does
179
+ // not contain `linear`.
180
+ const isCleanWord = MULTI_SUBCOMMAND_TOOLS.has(first)
181
+ || /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first);
182
+ if (!isCleanWord)
180
183
  return null;
184
+ const second = tokens[1];
185
+ if (second && !second.startsWith("-") && SAFE_SUBCOMMAND_RE.test(second)) {
186
+ return [first, second];
187
+ }
188
+ // flag-first shape (`pnpm --filter X test`, `linear --json issue view`):
189
+ // Claude's first-word fallback applies — the editable field is the
190
+ // narrowing control, and a bare tool grant is exactly what a human
191
+ // edits down from there.
181
192
  return [first];
182
193
  }
183
194
  /**
@@ -196,7 +207,11 @@ export function storagePrefix(command) {
196
207
  const slash = rawFirst.lastIndexOf("/");
197
208
  const first = slash >= 0 ? rawFirst.slice(slash + 1) : rawFirst;
198
209
  const second = tokens[1];
199
- if (MULTI_SUBCOMMAND_TOOLS.has(first) && second && SAFE_SUBCOMMAND_RE.test(second) && !second.startsWith("-")) {
210
+ // LOCKSTEP with derivePrefix's generic two-token rung: any clean command
211
+ // word + subcommand-shaped second token labels as "tool subcommand".
212
+ const cleanWord = MULTI_SUBCOMMAND_TOOLS.has(first)
213
+ || /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first);
214
+ if (cleanWord && second && SAFE_SUBCOMMAND_RE.test(second) && !second.startsWith("-")) {
200
215
  return `${first} ${second}`;
201
216
  }
202
217
  return first;
@@ -595,6 +610,60 @@ export function validateGrantForEscape(command, policy, repoKey) {
595
610
  // offered (null = no remember option at all).
596
611
  return deriveRememberSeeds(command, [command]);
597
612
  }
613
+ /**
614
+ * The GUARDIAN-ASK seed ladder (the ask-flow counterpart of
615
+ * {@link validateGrantForEscape}). One deliberate difference from the escape
616
+ * flow, a Claude mirror: READ-ONLY segments of a compound are skipped — a
617
+ * `cd src && npm test` ask suggests only `npm test`, because the cd/exec-allow
618
+ * segments are not the thing the user is being asked about (Claude's
619
+ * sandboxed-permission ask does the same). The escape flow keeps its
620
+ * stricter all-segments stance: the unit of consent THERE is running with
621
+ * full unsandboxed authority, so even read-only segments ride the consent.
622
+ *
623
+ * Same five rungs as the escape ladder otherwise (token patterns per
624
+ * uncovered segment → heredoc prefix → first line → full literal), all
625
+ * self-match-proved — a seed that cannot cover its own command is never
626
+ * offered.
627
+ */
628
+ export function validateGrantForAsk(command, policy, repoKey) {
629
+ // Same defense-in-depth refusal as the escape ladder.
630
+ try {
631
+ if (classifyCommand(command, policy).decision === "forbidden")
632
+ return null;
633
+ }
634
+ catch {
635
+ return null;
636
+ }
637
+ // Compound: seed every segment that is neither covered by a grant,
638
+ // read-only (exec-policy allow — the Claude mirror skip), nor an env
639
+ // assignment no-op. `forbidden` was refused above; classify-error
640
+ // segments count as uncovered (fail closed at the seed level too).
641
+ const compound = evaluateCompoundForEscape(command, [], repoKey, policy);
642
+ const mutating = [];
643
+ for (const verdict of compound.segments) {
644
+ if (verdict.kind === "uncovered" || verdict.kind === "classify-error") {
645
+ mutating.push(verdict.segment);
646
+ }
647
+ }
648
+ if (mutating.length > 0) {
649
+ const res = deriveRememberSeeds(command, mutating, repoKey);
650
+ if (res && res.seeds.length > 0)
651
+ return res;
652
+ return null;
653
+ }
654
+ // A compound whose every segment is covered/readonly/assignment, or a
655
+ // single command: the single-command rungs (token → literal ladder).
656
+ if (isSinglePlainCommand(command)) {
657
+ const pattern = derivePrefix(command);
658
+ if (pattern) {
659
+ const candidate = { pattern, repoKey, addedAt: new Date().toISOString(), cwd: "" };
660
+ if (matchesGrant(command, [candidate], repoKey)) {
661
+ return { seeds: [candidate], description: describePrefix(pattern) };
662
+ }
663
+ }
664
+ }
665
+ return deriveRememberSeeds(command, [command]);
666
+ }
598
667
  /**
599
668
  * Quote-preserving segment splitter for the ESCAPE flow (a local, faithful
600
669
  * counterpart to shellRules' splitSubcommands — which rejoins tokens with
@@ -725,8 +794,14 @@ export function isDerivablePattern(pattern) {
725
794
  if (BANNED_PREFIXES.has(first))
726
795
  return false;
727
796
  if (pattern.length === 2) {
797
+ // LOCKSTEP with derivePrefix's generic two-token rung: the first token
798
+ // only needs the command-word shape (the MULTI_SUBCOMMAND_TOOLS
799
+ // membership check was dropped when the rung went generic). Keeping the
800
+ // old set-membership check here would silently drop every
801
+ // generic-rung grant (["linear","issue"]) as "tampered" at next
802
+ // startup — the user's approval would evaporate.
728
803
  const second = pattern[1];
729
- if (!MULTI_SUBCOMMAND_TOOLS.has(first))
804
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(first) && !MULTI_SUBCOMMAND_TOOLS.has(first))
730
805
  return false;
731
806
  if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
732
807
  return false;
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import type { ExtensionAPI, ExtensionContext, ToolCallEvent, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
30
30
  import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
31
+ import { type PrefixDiagnosticEvent } from "./prefixExtract.js";
31
32
  import type { HookRunner } from "../hooks.js";
32
33
  import { type BlessStore } from "../bless.js";
33
34
  import { type ExecPolicy } from "./execPolicy.js";
@@ -304,6 +305,35 @@ export interface RegisterPermissionDeps {
304
305
  * and PermissionRequest hooks run before the confirm dialog.
305
306
  */
306
307
  hookRunner?: HookRunner;
308
+ /**
309
+ * LLM prefix-consult configuration (layer 2 of the prefix-suggestion
310
+ * stack). When present AND the session has a UI, an ask dialog whose L1
311
+ * ladder could not derive a usable token seed fires one efficient-tier
312
+ * consult per uncovered segment (Claude's getCommandSubcommandPrefix
313
+ * shape) and folds validated candidates into the remember options after
314
+ * a bounded grace window. Absent = disabled: dialogs seed from L1 only,
315
+ * byte-identical to the pre-consult behavior.
316
+ */
317
+ prefixConsult?: {
318
+ tier?: string;
319
+ };
320
+ /**
321
+ * Injectable consult function (tests pass a stub; the default is
322
+ * consultPrefixMemoized — the wiring layer keeps ONE memo per session).
323
+ * Mirrors the guardianReview seam.
324
+ */
325
+ prefixConsultFn?: (segment: string, deps: import("./prefixExtract.js").PrefixConsultDeps & {
326
+ onEvent?: (ev: PrefixDiagnosticEvent) => void;
327
+ }) => Promise<import("./prefixExtract.js").PrefixConsultResult & {
328
+ outcome: PrefixDiagnosticEvent["outcome"];
329
+ }>;
330
+ /**
331
+ * Called (fire-and-forget) after each prefix consult with a sanitized
332
+ * diagnostic event (the wiring layer posts client-only failures to the
333
+ * backend's prefix-error intake and bumps counters). Fail-soft; never
334
+ * blocks the gate.
335
+ */
336
+ onPrefixConsult?: (event: PrefixDiagnosticEvent) => void;
307
337
  }
308
338
  /** The customType tag on injected mode-context messages (filterable later). */
309
339
  export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
@@ -26,7 +26,8 @@
26
26
  * When the mode leaves plan, stale plan-context messages are filtered out of
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
- import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrant, validateGrantForEscape, } from "./approvedPrefixes.js";
29
+ import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
+ import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, } from "./prefixExtract.js";
30
31
  import { logEvent } from "../errorSink.js";
31
32
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
32
33
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
@@ -480,6 +481,112 @@ export function registerPermissionGate(pi, deps = {}) {
480
481
  approvedCommands.delete(oldest);
481
482
  }
482
483
  };
484
+ // L2 prefix-consult memo: one LRU per session (keyed by segment string —
485
+ // cwd-independent by design; a `pnpm test` prefix is the same answer in
486
+ // any directory, and repo scoping happens at GRANT time, not consult time).
487
+ const prefixMemo = createPrefixMemo();
488
+ const runPrefixConsult = deps.prefixConsultFn ?? consultPrefixMemoized;
489
+ /** Shared consult-call builder: routes through the injectable seam with
490
+ * the sink line + the onPrefixConsult hook — ONE place so the escape and
491
+ * Guardian-ask fan-outs cannot drift. */
492
+ const consultSegment = (seg, cwd, signal, tier) => runPrefixConsult(seg, {
493
+ cwd,
494
+ signal,
495
+ memo: prefixMemo,
496
+ ...(tier ? { modelTier: tier } : {}),
497
+ onEvent: (ev) => {
498
+ try {
499
+ logEvent({
500
+ source: "prefix",
501
+ level: "info",
502
+ event: "prefix_consult",
503
+ fields: { outcome: ev.outcome, ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}) },
504
+ });
505
+ }
506
+ catch { /* telemetry must never affect the gate */ }
507
+ try {
508
+ deps.onPrefixConsult?.(ev);
509
+ }
510
+ catch { /* fail-soft */ }
511
+ },
512
+ });
513
+ /** The uncovered MUTATING segments of a compound for the ask-flow consult
514
+ * fan-out — the same set validateGrantForAsk seeds, extracted so the
515
+ * consult does not re-derive them (one source of truth for the skip rule). */
516
+ const segmentsUncoveredForAsk = (command, policy) => {
517
+ const verdicts = evaluateCompoundForEscape(command, [], "", policy).segments;
518
+ return verdicts
519
+ .filter((v) => v.kind === "uncovered" || v.kind === "classify-error")
520
+ .map((v) => v.segment);
521
+ };
522
+ /**
523
+ * The SHARED L2 fold — ONE implementation for the escape and Guardian-ask
524
+ * fan-outs (extracted so the two cannot drift). Fans out over the raw
525
+ * uncovered segments (raw, not canonicalized: Claude consults the command
526
+ * as typed, the persona's spec covers env-prefixed shapes, and the memo
527
+ * key matches what a retry re-sends), skips segments an L1 TOKEN seed
528
+ * already covers, folds validated candidates into the seeds bounded by
529
+ * the grace window, and keeps the L1 LITERAL seed as a fallback whenever
530
+ * any consulted segment produced NO candidate — a partial fold that
531
+ * dropped the literal would cover fewer segments than the ladder did, and
532
+ * picking remember would then re-ask on retry (the fold-regression).
533
+ * L2 token candidates supersede the literal ONLY when every consulted
534
+ * segment yielded one; otherwise the literal keeps the coverage floor.
535
+ */
536
+ const foldConsultSeeds = async (args) => {
537
+ const { l1Seeds } = args;
538
+ if (!deps.prefixConsult)
539
+ return l1Seeds;
540
+ const l1TokenPatterns = new Set((l1Seeds?.seeds ?? []).map((s) => s.pattern.join(" ")).filter((p) => p.length > 0));
541
+ const l1LiteralSeeds = (l1Seeds?.seeds ?? []).filter((s) => s.pattern.length === 0);
542
+ // RAW segments, deduped, capped at the seed cap.
543
+ const segments = [...new Set(args.uncoveredSegments.map((s) => s.trim()).filter((s) => s.length > 0))].slice(0, 5);
544
+ const toConsult = segments.filter((seg) => !(l1TokenPatterns.size > 0 && [...l1TokenPatterns].some((p) => seg.startsWith(p))));
545
+ if (toConsult.length === 0)
546
+ return l1Seeds;
547
+ let answeredSegments = 0;
548
+ const fold = Promise.allSettled(toConsult.map((seg) => consultSegment(seg, args.cwd, args.signal, args.tier))).then((results) => {
549
+ // Two INDEPENDENT passes — counting and seed-filtering are different
550
+ // questions. `answered` counts every yielded candidate (including one
551
+ // duplicating an L1 token: the consult DID answer that segment), in
552
+ // its own pass over the results. The second pass de-dupes L1-token
553
+ // lookalikes from the SEED list only — the duplicate is noise there,
554
+ // not an unanswered segment. Comparing the de-duped `extra` against
555
+ // `toConsult` would misread a fully-answered fold as partial whenever
556
+ // an answer coincided with an L1 token.
557
+ const answered = results
558
+ .map((r) => (r.status === "fulfilled" ? r.value.candidate : null))
559
+ .filter((c) => c !== null);
560
+ answeredSegments = answered.length;
561
+ return answered
562
+ .filter((c) => !l1TokenPatterns.has(c.pattern.join(" ")))
563
+ .map((c) => ({ pattern: c.pattern, repoKey: "", addedAt: "", cwd: "" }));
564
+ }, () => []);
565
+ const extra = await Promise.race([
566
+ fold,
567
+ new Promise((resolve) => {
568
+ const t = setTimeout(() => resolve([]), PREFIX_CONSULT_GRACE_MS);
569
+ t.unref?.();
570
+ }),
571
+ ]);
572
+ if (extra.length === 0)
573
+ return l1Seeds;
574
+ // Did every consulted segment yield a candidate? Only then do the L2
575
+ // tokens supersede the literal; a partial fold keeps the literal as the
576
+ // coverage floor (token + literal, deduped, capped at 5).
577
+ const complete = answeredSegments >= toConsult.length;
578
+ const combined = [
579
+ ...(l1Seeds?.seeds ?? []).filter((s) => s.pattern.length > 0),
580
+ ...extra,
581
+ ...(complete ? [] : l1LiteralSeeds),
582
+ ].slice(0, 5);
583
+ return {
584
+ seeds: combined,
585
+ description: combined
586
+ .map((s) => (s.pattern.length > 0 ? describePrefix(s.pattern) : (s.literal ?? "")))
587
+ .join(", "),
588
+ };
589
+ };
483
590
  // Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
484
591
  // user prompt — NOT per LLM turn): genuine ask verdicts are uncapped (the
485
592
  // user's patience is the bound); error-fallback asks are capped so
@@ -1095,20 +1202,45 @@ export function registerPermissionGate(pi, deps = {}) {
1095
1202
  }
1096
1203
  }
1097
1204
  // Offer "don't ask again" only when the grant would actually
1098
- // cover this command (grant-time validation). adds a
1099
- // third option: persist a user-level permission rule (survives
1100
- // across repos, unlike the repo-scoped grant) — offered only when
1101
- // a grantCandidate also exists (the same prefix discipline; the
1102
- // rule is the same pattern in settings form).
1103
- const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
1104
- const ruleCandidate = grantCandidate
1205
+ // cover this command (grant-time validation). The ask-flow ladder
1206
+ // (validateGrantForAsk — the escape flow's shape, with the
1207
+ // read-only segment skip Claude's ask does) provides the L1
1208
+ // seeds: token patterns per uncovered mutating segment → literal
1209
+ // rungs. The L2 prefix consult folds validated candidates in over
1210
+ // the same grace window as the escape flow (auto-mode only — ask
1211
+ // grants are auto-mode-only, so a consult in review/plan mode
1212
+ // buys seeds that can never be used).
1213
+ const askSeeds = validateGrantForAsk(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
1214
+ // L2 prefix consult (auto mode only — the seeds it refines are
1215
+ // auto-mode grants): the SHARED fold, over the ask-ladder's
1216
+ // uncovered mutating segments; failures are invisible (the
1217
+ // dialog opens with L1 seeds).
1218
+ const seedsForAsk = modeAtEntry === "auto"
1219
+ ? await foldConsultSeeds({
1220
+ l1Seeds: askSeeds,
1221
+ uncoveredSegments: askSeeds === null ? [] : segmentsUncoveredForAsk(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY),
1222
+ cwd,
1223
+ signal: ctx.signal,
1224
+ tier: deps.prefixConsult?.tier,
1225
+ })
1226
+ : askSeeds;
1227
+ // The settings-rule option rides TOKEN seeds ONLY: a literal
1228
+ // seed persisted as `Bash(<literal>:*)` is a prefix rule with no
1229
+ // inert-remainder fence (the grant-side literal rung HAS the
1230
+ // fence — approvedPrefixes) — anything starting with that
1231
+ // literal would be allowed in settings. Literal seeds keep the
1232
+ // grant path (repo-scoped, fenced).
1233
+ const grantCandidate = seedsForAsk && seedsForAsk.seeds.length === 1
1234
+ ? { pattern: seedsForAsk.seeds[0].pattern, literal: seedsForAsk.seeds[0].literal }
1235
+ : null;
1236
+ const ruleCandidate = grantCandidate && grantCandidate.pattern.length > 0
1105
1237
  ? `Bash(${grantCandidate.pattern.join(" ")}:*)`
1106
1238
  : null;
1107
- const rememberLabel = grantCandidate
1108
- ? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
1239
+ const rememberLabel = seedsForAsk
1240
+ ? `Yes, and don't ask again for \`${seedsForAsk.description}\` in this repo`
1109
1241
  : null;
1110
- const ruleLabel = grantCandidate && deps.persistUserRule
1111
- ? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in this project's local settings`
1242
+ const ruleLabel = ruleCandidate && deps.persistUserRule
1243
+ ? `Yes, and always allow \`${ruleCandidate.replace(/^Bash\(/, "").replace(/:\*\)$/, "")}\` in this project's local settings`
1112
1244
  : null;
1113
1245
  const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel, "guardian_verdict", "bash");
1114
1246
  if (resolution === "yes") {
@@ -1167,14 +1299,17 @@ export function registerPermissionGate(pi, deps = {}) {
1167
1299
  });
1168
1300
  return {};
1169
1301
  }
1170
- if (resolution === "remember" && grantCandidate) {
1171
- const grantRecord = {
1172
- ...grantCandidate,
1173
- cwd,
1174
- addedAt: new Date().toISOString(),
1175
- };
1176
- grants.push(grantRecord);
1177
- persistGrantFailSoft(grantRecord);
1302
+ if (resolution === "remember" && seedsForAsk) {
1303
+ for (const seed of seedsForAsk.seeds) {
1304
+ const grantRecord = {
1305
+ ...seed,
1306
+ repoKey: resolveRepoKeyFor(cwd),
1307
+ cwd,
1308
+ addedAt: new Date().toISOString(),
1309
+ };
1310
+ grants.push(grantRecord);
1311
+ persistGrantFailSoft(grantRecord);
1312
+ }
1178
1313
  emitGateEvent(slot, {
1179
1314
  ...eventBase,
1180
1315
  outcome: "ask_approved_remembered",
@@ -1497,9 +1632,28 @@ export function registerPermissionGate(pi, deps = {}) {
1497
1632
  }
1498
1633
  }
1499
1634
  // The remember seeds + dialog options. validateGrantForEscape owns the
1500
- // full ladder (token patterns → heredoc prefix → first line → full
1501
- // literal) with the fencing/forbidden proofs.
1502
- const seeds = validateGrantForEscape(command, execPolicy, repoKey);
1635
+ // full L1 ladder (token patterns → heredoc prefix → first line → full
1636
+ // literal) with the fencing/forbidden proofs. The L2 prefix consult
1637
+ // (layer 2) fans out over the command's uncovered segments in parallel
1638
+ // (Claude's getCommandSubcommandPrefix shape: full command + one consult
1639
+ // per segment, Promise.all): a segment the L1 token rung already covers
1640
+ // needs no consult (its seed is already as good as L2 can get); the
1641
+ // consults keep running past the grace window — whatever lands late
1642
+ // memoizes and serves a retry instantly, and the dialog opens after at
1643
+ // most PREFIX_CONSULT_GRACE_MS with the best seeds available at open
1644
+ // time. Consult failure is invisible: the dialog opens with L1 seeds.
1645
+ const l1Seeds = validateGrantForEscape(command, execPolicy, repoKey);
1646
+ // The shared L2 fold (see foldConsultSeeds): consult fan-out over the
1647
+ // uncovered segments, grace-bounded, literal-fallback preserved. Runs
1648
+ // only with a UI (the headless gate returned above), and consult failure
1649
+ // is invisible — the dialog opens with the L1 seeds.
1650
+ const seeds = await foldConsultSeeds({
1651
+ l1Seeds,
1652
+ uncoveredSegments: compound.uncovered,
1653
+ cwd,
1654
+ signal: ctx.signal,
1655
+ tier: deps.prefixConsult?.tier,
1656
+ });
1503
1657
  const ASK_YES_ESC = "Yes, run it";
1504
1658
  const ASK_NO_ESC = "No";
1505
1659
  const rememberLabel = seeds
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Prefix consult — LLM extraction of "don't ask again" prefix suggestions
3
+ * (Claude Code's createCommandPrefixExtractor, ported to our consult
4
+ * infrastructure). The second layer of the prefix-suggestion stack:
5
+ *
6
+ * Layer 1 — syntactic prefix ladder (approvedPrefixes.ts, pure, always first)
7
+ * Layer 2 — THIS module (LLM extraction, memoized, validated, optional)
8
+ * Layer 3 — the dialog (gate.ts; seeded from L1, refined by L2)
9
+ *
10
+ * Same pure/IO split as guardian.ts: the parser and the validation ladder are
11
+ * pure and exhaustively testable; the consult spawns a locked-down child pi
12
+ * on the efficient tier with no tools (the prefix persona answers from the
13
+ * command text alone).
14
+ *
15
+ * The LLM proposes, pure code disposes: every candidate must pass ALL of —
16
+ * 1. the first-line parser (prose/degenerate tokens → no candidate)
17
+ * 2. command_injection_detected → no candidate
18
+ * 3. the banned-shape check (BANNED_PREFIXES)
19
+ * 4. the word-shape check (clean command words, subcommand-shaped seconds)
20
+ * 5. the self-coverage proof (a seed that cannot cover THIS command via
21
+ * matchesGrant is dropped — a `git push` seed for `git push origin
22
+ * +main:main` never reaches the dialog)
23
+ * …and nothing saves until the user picks the option. The LLM's blast radius
24
+ * is capped at "wasted a suggestion".
25
+ */
26
+ import { runStage as defaultRunStage } from "../pipeline/runner.js";
27
+ import type { PipelineStage } from "../pipeline/types.js";
28
+ export type PrefixConsultError = "timeout" | "malformed" | "network" | "empty" | "aborted";
29
+ /** A validated suggestion: token pattern + the label the dialog shows. */
30
+ export interface PrefixCandidate {
31
+ pattern: string[];
32
+ description: string;
33
+ }
34
+ export interface PrefixConsultResult {
35
+ candidate: PrefixCandidate | null;
36
+ error?: PrefixConsultError;
37
+ cost: number;
38
+ /** True when the model answered command_injection_detected (the candidate
39
+ * is null but the outcome class is injection, not a plain decline). */
40
+ injection?: boolean;
41
+ /**
42
+ * Scrubbed + capped copy of the unparseable model output (malformed shape),
43
+ * so the sink and Sentry can see the exact failure. Never the raw command.
44
+ */
45
+ rawOutput?: string;
46
+ /** Error CLASS only (constructor name) when the consult threw — never the
47
+ * thrown message (it can carry command content or provider payloads). */
48
+ errorClass?: string;
49
+ }
50
+ /** Cap on the malformed-output capture (same size discipline as the Guardian). */
51
+ export declare const PREFIX_RAW_OUTPUT_CAP = 1024;
52
+ /** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
53
+ export declare const PREFIX_MODEL_TIER = "efficient";
54
+ /**
55
+ * Grace window for the dialog open: the consult fires at interception and the
56
+ * dialog opens after at most this delay with whatever seeds have landed (p50
57
+ * ~1s measured on prod; the window trades a bounded open delay for a much
58
+ * better remember label). Fails open instantly on any consult error.
59
+ */
60
+ export declare const PREFIX_CONSULT_GRACE_MS = 1500;
61
+ /** Consult deadline (the consult keeps running past the grace window — its
62
+ * result memoizes and serves any retry of the same segment). */
63
+ export declare const PREFIX_CONSULT_TIMEOUT_MS = 15000;
64
+ /**
65
+ * First-line parser: the first non-empty line of the model's output, with
66
+ * quotes, bullets, and markdown fences stripped, then SHAPE-CHECKED — the
67
+ * answer must look like 1-2 command words (the persona's contract). Prose
68
+ * sentences, degenerate tokens (`<|open|>`), and glue symbols parse to
69
+ * null: unparseable → no suggestion, nothing downstream sees it.
70
+ */
71
+ export declare function parsePrefixAnswer(raw: string): string | null;
72
+ /**
73
+ * Validate a parsed answer against a command. Returns a candidate the dialog
74
+ * may offer, or null (which is NOT an error — `none` and rejection-by-ladder
75
+ * are expected outcomes). `injectionDetected` is surfaced separately from
76
+ * null because the counters distinguish it.
77
+ */
78
+ export declare function validatePrefixAnswer(answer: string, command: string): {
79
+ kind: "candidate";
80
+ candidate: PrefixCandidate;
81
+ } | {
82
+ kind: "injection";
83
+ } | {
84
+ kind: "reject";
85
+ };
86
+ /** A settled memo entry: successes AND settled rejections (both are stable
87
+ * answers — `none` must not re-consult forever, and a malformed answer is a
88
+ * stable property of the model+segment pair). The terminal OUTCOME CLASS is
89
+ * stored verbatim and replayed — a malformed answer must keep counting as
90
+ * `unparseable` on every memoized retry, not silently become `none`
91
+ * (the hit-rate dashboard undercounts the exact segments the parser chokes
92
+ * on otherwise). */
93
+ export interface PrefixMemoEntry {
94
+ candidate: PrefixCandidate | null;
95
+ injection: boolean;
96
+ /** The terminal outcome the consult settled to (hit/none/injection_rejected/
97
+ * unparseable) — replayed as-is on memoized hits. */
98
+ outcome: Exclude<PrefixDiagnosticEvent["outcome"], PrefixConsultError>;
99
+ }
100
+ /** The memo's stored value: either a settled entry or a shared in-flight promise. */
101
+ export type PrefixMemoValue = PrefixMemoEntry | Promise<PrefixConsultResult & {
102
+ outcome: PrefixDiagnosticEvent["outcome"];
103
+ }>;
104
+ /**
105
+ * LRU memo (Claude's memoizeWithLRU shape, reject-evict): caches SETTLED
106
+ * consults keyed by the command segment string. In-flight promises are
107
+ * shared (the same key consults once, however many askers race); a
108
+ * thrown/rejected promise is evicted so a transient outage never poisons
109
+ * the key.
110
+ */
111
+ export declare function createPrefixMemo(capacity?: number): {
112
+ get(key: string): PrefixMemoValue | undefined;
113
+ set(key: string, entry: PrefixMemoValue): void;
114
+ delete(key: string): void;
115
+ size(): number;
116
+ };
117
+ export interface PrefixConsultDeps {
118
+ runStage?: typeof defaultRunStage;
119
+ cwd: string;
120
+ signal?: AbortSignal;
121
+ modelTier?: string;
122
+ timeoutMs?: number;
123
+ memo?: ReturnType<typeof createPrefixMemo>;
124
+ }
125
+ /**
126
+ * The synthetic stage a prefix consult runs as. Borrows the `plan` StageId
127
+ * (same pattern as the guardian) so it doesn't ripple into feed/reducers.
128
+ * The `prefix` agent selects the persona; no tools — the persona answers
129
+ * from the command text alone.
130
+ */
131
+ export declare function prefixStage(modelTier?: string): PipelineStage;
132
+ /** Sanitized per-consult diagnostic event for the sink (never the raw command). */
133
+ export interface PrefixDiagnosticEvent {
134
+ event: "prefix_consult";
135
+ outcome: "hit" | "none" | "injection_rejected" | "invalid" | "unparseable" | PrefixConsultError;
136
+ durationMs?: number;
137
+ tier?: string;
138
+ /** Debug-only: segment hash for correlation (never the segment text). */
139
+ segmentHash?: string;
140
+ /** Scrubbed + capped copy of the unparseable model output (malformed shape). */
141
+ rawOutput?: string;
142
+ /** Error CLASS only (constructor name) when the consult threw — never the
143
+ * thrown message (it can carry command content or provider payloads). */
144
+ errorClass?: string;
145
+ }
146
+ export declare function buildPrefixDiagnosticEvent(outcome: PrefixDiagnosticEvent["outcome"], opts: {
147
+ durationMs?: number;
148
+ tier?: string;
149
+ segmentHash?: string;
150
+ rawOutput?: string;
151
+ errorClass?: string;
152
+ debug?: boolean;
153
+ }): PrefixDiagnosticEvent;
154
+ /**
155
+ * Run ONE prefix consult on a single command segment. Pure on top of an
156
+ * injectable runStage; the caller (the gate) fans out per-segment and races
157
+ * the grace window. Memoized by the caller's memo (keyed by segment string).
158
+ */
159
+ export declare function consultPrefix(segment: string, deps: PrefixConsultDeps): Promise<PrefixConsultResult>;
160
+ /**
161
+ * Memoized consult for one segment: consults at most once per settled key,
162
+ * shares in-flight promises across concurrent askers, evicts rejections so a
163
+ * transient outage never poisons the key, and retries once on the empty
164
+ * (502-shape) error — the backend's own error body invites a retry.
165
+ */
166
+ export declare function consultPrefixMemoized(segment: string, deps: PrefixConsultDeps & {
167
+ onEvent?: (ev: PrefixDiagnosticEvent) => void;
168
+ }): Promise<PrefixConsultResult & {
169
+ outcome: PrefixDiagnosticEvent["outcome"];
170
+ }>;
171
+ //# sourceMappingURL=prefixExtract.d.ts.map
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Prefix consult — LLM extraction of "don't ask again" prefix suggestions
3
+ * (Claude Code's createCommandPrefixExtractor, ported to our consult
4
+ * infrastructure). The second layer of the prefix-suggestion stack:
5
+ *
6
+ * Layer 1 — syntactic prefix ladder (approvedPrefixes.ts, pure, always first)
7
+ * Layer 2 — THIS module (LLM extraction, memoized, validated, optional)
8
+ * Layer 3 — the dialog (gate.ts; seeded from L1, refined by L2)
9
+ *
10
+ * Same pure/IO split as guardian.ts: the parser and the validation ladder are
11
+ * pure and exhaustively testable; the consult spawns a locked-down child pi
12
+ * on the efficient tier with no tools (the prefix persona answers from the
13
+ * command text alone).
14
+ *
15
+ * The LLM proposes, pure code disposes: every candidate must pass ALL of —
16
+ * 1. the first-line parser (prose/degenerate tokens → no candidate)
17
+ * 2. command_injection_detected → no candidate
18
+ * 3. the banned-shape check (BANNED_PREFIXES)
19
+ * 4. the word-shape check (clean command words, subcommand-shaped seconds)
20
+ * 5. the self-coverage proof (a seed that cannot cover THIS command via
21
+ * matchesGrant is dropped — a `git push` seed for `git push origin
22
+ * +main:main` never reaches the dialog)
23
+ * …and nothing saves until the user picks the option. The LLM's blast radius
24
+ * is capped at "wasted a suggestion".
25
+ */
26
+ import { runStage as defaultRunStage } from "../pipeline/runner.js";
27
+ import { scrubSecrets } from "../pipeline/scrubSecrets.js";
28
+ import { BANNED_PREFIXES, canonicalizeForGrants, matchesGrant, } from "./approvedPrefixes.js";
29
+ /** Cap on the malformed-output capture (same size discipline as the Guardian). */
30
+ export const PREFIX_RAW_OUTPUT_CAP = 1024;
31
+ /** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
32
+ export const PREFIX_MODEL_TIER = "efficient";
33
+ /**
34
+ * Grace window for the dialog open: the consult fires at interception and the
35
+ * dialog opens after at most this delay with whatever seeds have landed (p50
36
+ * ~1s measured on prod; the window trades a bounded open delay for a much
37
+ * better remember label). Fails open instantly on any consult error.
38
+ */
39
+ export const PREFIX_CONSULT_GRACE_MS = 1_500;
40
+ /** Consult deadline (the consult keeps running past the grace window — its
41
+ * result memoizes and serves any retry of the same segment). */
42
+ export const PREFIX_CONSULT_TIMEOUT_MS = 15_000;
43
+ // --- Pure half: parsing + validation ---
44
+ const INJECTION_TOKEN = "command_injection_detected";
45
+ const NONE_TOKEN = "none";
46
+ /** The command-word shape derivePrefix requires (Claude's first-word regex). */
47
+ const COMMAND_WORD_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
48
+ /** Second tokens must look like plain subcommand words (no URLs, no secrets). */
49
+ const SAFE_SUBCOMMAND_RE = /^[A-Za-z0-9:_-]+$/;
50
+ /**
51
+ * First-line parser: the first non-empty line of the model's output, with
52
+ * quotes, bullets, and markdown fences stripped, then SHAPE-CHECKED — the
53
+ * answer must look like 1-2 command words (the persona's contract). Prose
54
+ * sentences, degenerate tokens (`<|open|>`), and glue symbols parse to
55
+ * null: unparseable → no suggestion, nothing downstream sees it.
56
+ */
57
+ export function parsePrefixAnswer(raw) {
58
+ const firstLine = raw
59
+ .split("\n")
60
+ .map((l) => l.trim())
61
+ // skip fence-only lines — the persona contract is one line, but the
62
+ // efficient tier loves wrapping it in a ``` block whose OPENING fence
63
+ // would otherwise be the "first line"
64
+ .find((l) => l.length > 0 && !/^```[a-z]*$/i.test(l));
65
+ if (!firstLine)
66
+ return null;
67
+ let s = firstLine;
68
+ // strip markdown fences and bullets the efficient tier likes to add. An
69
+ // inline fully-fenced token (```psql```) loses BOTH fences in one pass; a
70
+ // fence-opener prefix (```psql) loses the opening fence only (the closing
71
+ // fence, if any, was a later line the first-line scan never picked).
72
+ s = s.replace(/^```([a-z]*)\s*/i, "$1").replace(/```$/, "").trim();
73
+ s = s.replace(/^[-*•]\s*/, "").trim();
74
+ // strip wrapping quotes
75
+ s = s.replace(/^["'`]+/, "").replace(/["'`]+$/, "").trim();
76
+ if (s.length === 0 || s.length > 64)
77
+ return null;
78
+ // SHAPE CHECK — the answer must be 1-2 plain command words. Prose (spaces
79
+ // inside words, punctuation, sentences), degenerate special-token glue,
80
+ // and anything the word ladder could never consume parse to null here.
81
+ if (!/^[A-Za-z][A-Za-z0-9:_.-]*( [A-Za-z0-9:_.-]+)?$/.test(s))
82
+ return null;
83
+ if (/\s.{20,}/.test(s))
84
+ return null; // a second "word" 20+ chars is prose, not a subcommand
85
+ return s.toLowerCase();
86
+ }
87
+ /**
88
+ * Validate a parsed answer against a command. Returns a candidate the dialog
89
+ * may offer, or null (which is NOT an error — `none` and rejection-by-ladder
90
+ * are expected outcomes). `injectionDetected` is surfaced separately from
91
+ * null because the counters distinguish it.
92
+ */
93
+ export function validatePrefixAnswer(answer, command) {
94
+ if (answer === INJECTION_TOKEN)
95
+ return { kind: "injection" };
96
+ if (answer === NONE_TOKEN)
97
+ return { kind: "reject" };
98
+ const words = answer.split(/\s+/);
99
+ if (words.length < 1 || words.length > 2)
100
+ return { kind: "reject" };
101
+ const [first, second] = words;
102
+ if (BANNED_PREFIXES.has(first))
103
+ return { kind: "reject" };
104
+ if (!COMMAND_WORD_RE.test(first))
105
+ return { kind: "reject" };
106
+ if (second !== undefined) {
107
+ if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
108
+ return { kind: "reject" };
109
+ }
110
+ // The self-coverage proof: a candidate that cannot cover THIS command via
111
+ // matchesGrant is never offered. The candidate is tokenized-shape already
112
+ // (≤2 clean words), so the proof runs against the canonical form the
113
+ // matcher will see at match time.
114
+ const canonical = canonicalizeForGrants(command);
115
+ const grant = {
116
+ pattern: words,
117
+ repoKey: "",
118
+ addedAt: "",
119
+ cwd: "",
120
+ };
121
+ if (matchesGrant(canonical, [grant], "") === null)
122
+ return { kind: "reject" };
123
+ return {
124
+ kind: "candidate",
125
+ candidate: { pattern: words, description: `${words.join(" ")} …` },
126
+ };
127
+ }
128
+ /**
129
+ * LRU memo (Claude's memoizeWithLRU shape, reject-evict): caches SETTLED
130
+ * consults keyed by the command segment string. In-flight promises are
131
+ * shared (the same key consults once, however many askers race); a
132
+ * thrown/rejected promise is evicted so a transient outage never poisons
133
+ * the key.
134
+ */
135
+ export function createPrefixMemo(capacity = 200) {
136
+ const map = new Map();
137
+ const get = (key) => map.get(key);
138
+ const set = (key, entry) => {
139
+ map.delete(key);
140
+ map.set(key, entry);
141
+ if (map.size > capacity) {
142
+ const oldest = map.keys().next().value;
143
+ if (oldest !== undefined)
144
+ map.delete(oldest);
145
+ }
146
+ };
147
+ const del = (key) => {
148
+ map.delete(key);
149
+ };
150
+ return {
151
+ get,
152
+ set,
153
+ delete: del,
154
+ size: () => map.size,
155
+ };
156
+ }
157
+ /**
158
+ * The synthetic stage a prefix consult runs as. Borrows the `plan` StageId
159
+ * (same pattern as the guardian) so it doesn't ripple into feed/reducers.
160
+ * The `prefix` agent selects the persona; no tools — the persona answers
161
+ * from the command text alone.
162
+ */
163
+ export function prefixStage(modelTier = PREFIX_MODEL_TIER) {
164
+ return {
165
+ id: "plan",
166
+ agent: "prefix",
167
+ model: modelTier,
168
+ tools: [],
169
+ taskTemplate: "{ticket}",
170
+ };
171
+ }
172
+ export function buildPrefixDiagnosticEvent(outcome, opts) {
173
+ const ev = {
174
+ event: "prefix_consult",
175
+ outcome,
176
+ ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
177
+ ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
178
+ };
179
+ if (opts.rawOutput !== undefined)
180
+ ev.rawOutput = opts.rawOutput;
181
+ if (opts.errorClass !== undefined)
182
+ ev.errorClass = opts.errorClass;
183
+ if (opts.debug) {
184
+ if (opts.segmentHash)
185
+ ev.segmentHash = opts.segmentHash;
186
+ }
187
+ return ev;
188
+ }
189
+ /**
190
+ * Run ONE prefix consult on a single command segment. Pure on top of an
191
+ * injectable runStage; the caller (the gate) fans out per-segment and races
192
+ * the grace window. Memoized by the caller's memo (keyed by segment string).
193
+ */
194
+ export async function consultPrefix(segment, deps) {
195
+ const runStage = deps.runStage ?? defaultRunStage;
196
+ const stage = prefixStage(deps.modelTier);
197
+ const timeoutMs = deps.timeoutMs ?? PREFIX_CONSULT_TIMEOUT_MS;
198
+ const controller = new AbortController();
199
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
200
+ timer.unref?.();
201
+ if (deps.signal) {
202
+ if (deps.signal.aborted)
203
+ controller.abort();
204
+ else
205
+ deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
206
+ }
207
+ try {
208
+ const result = await runStage(stage, { ticket: segment }, {
209
+ cwd: deps.cwd,
210
+ signal: controller.signal,
211
+ callerLabel: "prefix",
212
+ });
213
+ const cost = result.usage?.cost ?? 0;
214
+ const output = result.finalOutput.trim();
215
+ if (deps.signal?.aborted) {
216
+ return { candidate: null, error: "aborted", cost };
217
+ }
218
+ if (!output) {
219
+ if (result.stopReason === "error" || result.errorMessage) {
220
+ const msg = (result.errorMessage ?? "").toLowerCase();
221
+ const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
222
+ return { candidate: null, error: isTimeout ? "timeout" : "network", cost };
223
+ }
224
+ return { candidate: null, error: "empty", cost };
225
+ }
226
+ const parsed = parsePrefixAnswer(output);
227
+ if (!parsed) {
228
+ return {
229
+ candidate: null,
230
+ error: "malformed",
231
+ cost,
232
+ rawOutput: scrubSecrets(output).slice(0, PREFIX_RAW_OUTPUT_CAP),
233
+ };
234
+ }
235
+ const verdict = validatePrefixAnswer(parsed, segment);
236
+ if (verdict.kind === "candidate") {
237
+ return { candidate: verdict.candidate, cost };
238
+ }
239
+ if (verdict.kind === "injection") {
240
+ return { candidate: null, injection: true, cost };
241
+ }
242
+ // rejection by the ladder — an expected outcome, not an error
243
+ return { candidate: null, injection: false, cost };
244
+ }
245
+ catch (err) {
246
+ if (deps.signal?.aborted)
247
+ return { candidate: null, error: "aborted", cost: 0 };
248
+ if (controller.signal.aborted)
249
+ return { candidate: null, error: "timeout", cost: 0 };
250
+ return {
251
+ candidate: null,
252
+ error: "network",
253
+ cost: 0,
254
+ errorClass: err instanceof Error ? err.constructor.name : typeof err,
255
+ };
256
+ }
257
+ finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+ /**
262
+ * Memoized consult for one segment: consults at most once per settled key,
263
+ * shares in-flight promises across concurrent askers, evicts rejections so a
264
+ * transient outage never poisons the key, and retries once on the empty
265
+ * (502-shape) error — the backend's own error body invites a retry.
266
+ */
267
+ export async function consultPrefixMemoized(segment, deps) {
268
+ const memo = deps.memo ?? createPrefixMemo();
269
+ const key = segment;
270
+ const memoized = memo.get(key);
271
+ if (memoized !== undefined) {
272
+ if (memoized instanceof Promise) {
273
+ return memoized;
274
+ }
275
+ // settled: replay the stored terminal outcome class verbatim — the
276
+ // memoized hit is the SAME answer the live consult settled to
277
+ // (unparseable stays unparseable, injection stays injection), and it
278
+ // EMITS through onEvent like the live consult did (cost 0, duration 0):
279
+ // the sink line and the backend hit-rate counters see every retry of a
280
+ // settled segment, not just its first consult — otherwise the dashboard
281
+ // undercounts exactly the segments the parser chokes on.
282
+ const e = memoized;
283
+ try {
284
+ deps.onEvent?.(buildPrefixDiagnosticEvent(e.outcome, {
285
+ durationMs: 0,
286
+ tier: deps.modelTier ?? PREFIX_MODEL_TIER,
287
+ }));
288
+ }
289
+ catch {
290
+ /* telemetry must never affect the replay */
291
+ }
292
+ return { candidate: e.candidate, cost: 0, outcome: e.outcome };
293
+ }
294
+ const started = Date.now();
295
+ const promise = (async () => {
296
+ let res = await consultPrefix(segment, deps);
297
+ // one retry on the empty shape (the backend's own error body invites this)
298
+ if (res.error === "empty" && !deps.signal?.aborted) {
299
+ res = await consultPrefix(segment, deps);
300
+ }
301
+ const outcome = res.candidate
302
+ ? "hit"
303
+ : res.error === "malformed"
304
+ ? "unparseable"
305
+ : res.error === undefined
306
+ ? res.injection === true
307
+ ? "injection_rejected"
308
+ : "none"
309
+ : res.error;
310
+ deps.onEvent?.(buildPrefixDiagnosticEvent(outcome, {
311
+ durationMs: Date.now() - started,
312
+ tier: deps.modelTier ?? PREFIX_MODEL_TIER,
313
+ ...(res.rawOutput ? { rawOutput: res.rawOutput } : {}),
314
+ ...(res.errorClass ? { errorClass: res.errorClass } : {}),
315
+ }));
316
+ if (res.error && res.error !== "malformed") {
317
+ // transient (timeout/network/empty/aborted) — evict, never poison
318
+ memo.delete(key);
319
+ return { ...res, outcome };
320
+ }
321
+ // settled: hit, none, injection_rejected, or unparseable — stored with
322
+ // its terminal outcome class so memoized replays count identically.
323
+ // (All error classes returned above; the remaining outcome is one of
324
+ // the four settled classes, hence the narrow.)
325
+ const settledOutcome = outcome;
326
+ memo.set(key, {
327
+ candidate: res.candidate,
328
+ injection: res.injection === true,
329
+ outcome: settledOutcome,
330
+ });
331
+ return { ...res, outcome };
332
+ })();
333
+ // reject-evict with identity guard (Claude's pattern): a stale rejection
334
+ // must not delete a newer promise that replaced it after LRU eviction.
335
+ promise.catch(() => {
336
+ if (memo.get(key) === promise)
337
+ memo.delete(key);
338
+ });
339
+ memo.set(key, promise);
340
+ return promise;
341
+ }
342
+ //# sourceMappingURL=prefixExtract.js.map
@@ -40,6 +40,31 @@ export declare function parseShellRule(ruleContent: string): ShellRuleShape;
40
40
  export declare function matchWildcardPattern(pattern: string, command: string): boolean;
41
41
  /** Split a command into its subcommand strings (operators are boundaries). */
42
42
  export declare function splitSubcommands(command: string): string[];
43
+ /**
44
+ * Whitelist of environment variables that are safe to strip before rule/
45
+ * grant matching: they CANNOT execute code or load libraries (the same bar
46
+ * Claude Code's list documents). Claude's list verbatim, with TWO
47
+ * deliberate divergences, both directions:
48
+ *
49
+ * KEPT from ours: `CI` — a boolean non-interactivity flag, same safety
50
+ * class as FORCE_COLOR/NO_COLOR, and a real idiom for our users.
51
+ *
52
+ * DROPPED from ours (Claude excludes them too, and for good reason):
53
+ * `DEBUG`/`VERBOSE` — the most overloaded var names in existence; dozens
54
+ * of tools overload them for behavior switches, not just logging. `PWD`
55
+ * — some tools read it for path resolution, and nobody legitimately
56
+ * writes `PWD=x cmd`; stripping accidental state is exactly what a
57
+ * safe-list must not do.
58
+ *
59
+ * DROPPED from Claude's: `ANTHROPIC_API_KEY` — it is on their list for
60
+ * their own CLI's use case; for us its only effect would be hiding WHICH
61
+ * account a command authenticates as. No value, an honesty cost.
62
+ *
63
+ * SECURITY: never add PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_*, PYTHONPATH,
64
+ * NODE_PATH, CLASSPATH, RUBYLIB, GOFLAGS, RUSTFLAGS, NODE_OPTIONS, HOME,
65
+ * TMPDIR, SHELL, BASH_ENV, DOCKER_HOST, KUBECONFIG (execution / library
66
+ * loading / behavior-steering / endpoint-hiding).
67
+ */
43
68
  export declare const SAFE_ENV_VARS: Set<string>;
44
69
  /** Strip ALL leading env assignments — deny/ask rules only. Fixed-point. */
45
70
  export declare function stripAllEnvVars(command: string): string;
@@ -145,9 +145,52 @@ function stripSafeWrappers(cmd) {
145
145
  s = next.trim();
146
146
  }
147
147
  }
148
+ /**
149
+ * Whitelist of environment variables that are safe to strip before rule/
150
+ * grant matching: they CANNOT execute code or load libraries (the same bar
151
+ * Claude Code's list documents). Claude's list verbatim, with TWO
152
+ * deliberate divergences, both directions:
153
+ *
154
+ * KEPT from ours: `CI` — a boolean non-interactivity flag, same safety
155
+ * class as FORCE_COLOR/NO_COLOR, and a real idiom for our users.
156
+ *
157
+ * DROPPED from ours (Claude excludes them too, and for good reason):
158
+ * `DEBUG`/`VERBOSE` — the most overloaded var names in existence; dozens
159
+ * of tools overload them for behavior switches, not just logging. `PWD`
160
+ * — some tools read it for path resolution, and nobody legitimately
161
+ * writes `PWD=x cmd`; stripping accidental state is exactly what a
162
+ * safe-list must not do.
163
+ *
164
+ * DROPPED from Claude's: `ANTHROPIC_API_KEY` — it is on their list for
165
+ * their own CLI's use case; for us its only effect would be hiding WHICH
166
+ * account a command authenticates as. No value, an honesty cost.
167
+ *
168
+ * SECURITY: never add PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_*, PYTHONPATH,
169
+ * NODE_PATH, CLASSPATH, RUBYLIB, GOFLAGS, RUSTFLAGS, NODE_OPTIONS, HOME,
170
+ * TMPDIR, SHELL, BASH_ENV, DOCKER_HOST, KUBECONFIG (execution / library
171
+ * loading / behavior-steering / endpoint-hiding).
172
+ */
148
173
  export const SAFE_ENV_VARS = new Set([
149
- "NODE_ENV", "CI", "FORCE_COLOR", "NO_COLOR", "DEBUG", "VERBOSE",
150
- "GOOS", "GOARCH", "CGO_ENABLED", "PWD", "LANG", "LC_ALL", "TERM",
174
+ // Go — build/runtime settings only
175
+ "GOEXPERIMENT", "GOOS", "GOARCH", "CGO_ENABLED", "GO111MODULE",
176
+ // Rust — logging/debugging only
177
+ "RUST_BACKTRACE", "RUST_LOG",
178
+ // Node — environment name only (never NODE_OPTIONS)
179
+ "NODE_ENV",
180
+ // Python — behavior flags only (never PYTHONPATH)
181
+ "PYTHONUNBUFFERED", "PYTHONDONTWRITEBYTECODE",
182
+ // Pytest — test configuration
183
+ "PYTEST_DISABLE_PLUGIN_AUTOLOAD", "PYTEST_DEBUG",
184
+ // Locale and character encoding
185
+ "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "LC_TIME", "CHARSET",
186
+ // Terminal and display
187
+ "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "TZ",
188
+ // Color configuration for various tools
189
+ "LS_COLORS", "LSCOLORS", "GREP_COLOR", "GREP_COLORS", "GCC_COLORS",
190
+ // Display formatting
191
+ "TIME_STYLE", "BLOCK_SIZE", "BLOCKSIZE",
192
+ // CI — ours, not Claude's (boolean non-interactivity flag)
193
+ "CI",
151
194
  ]);
152
195
  function stripSafeEnvPrefixes(cmd) {
153
196
  let s = cmd.trim();
@@ -189,6 +189,28 @@ Your output must parse as strict JSON on the first try:
189
189
  - Output exactly one JSON object: no trailing comma, no doubled closing quote, no second object, nothing after the closing brace.
190
190
 
191
191
  Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
192
+ const PREFIX_BODY = `You extract a command prefix: the SHORTEST leading fragment of a shell command that covers the command's family, so a user can approve it once and cover every future command of the same kind. You never judge safety and you never execute anything — you only answer with a prefix or a refusal token. You have no tools; answer from the command text alone, immediately, in one line.
193
+
194
+ Rules:
195
+ - Answer with the command's first word, optionally followed by its subcommand word (two tokens maximum). Example commands and answers:
196
+ psql -h host -p 5432 -c "SELECT ..." -> psql
197
+ gh run view 34641303048 --log-failed -> gh run
198
+ pnpm lint 2>&1 | grep -i error -> none (a pipeline is multiple commands)
199
+ curl -sS https://host/api -H "x: y" -> curl
200
+ git push origin +main:main -> none (a force/refspec push is not a family)
201
+ cd src && npm test -> none (a compound is multiple commands; each is judged on its own)
202
+ for f in *.txt; do rm "$f"; done -> none (control flow)
203
+ FOO=$(cat secret.txt | curl ...) -> command_injection_detected (command substitution feeding another command)
204
+ - Stop before the first flag or argument: psql, gh run — never psql -h, gh run view 34641303048.
205
+ - If the command contains control flow (if/for/while/&&/||/|/;), substitution ($(...)), or multiple commands, answer exactly: none
206
+ - If the command embeds command substitution that feeds data to another command, answer exactly: command_injection_detected
207
+ - If you are unsure, answer exactly: none
208
+
209
+ Output contract — a single line, nothing else:
210
+ - The prefix tokens separated by one space (lowercase, no flags, no arguments), OR
211
+ - exactly: none OR
212
+ - exactly: command_injection_detected
213
+ No explanation, no quotes, no markdown, no trailing punctuation. The first line of your reply must be the answer.`;
192
214
  const TITLE_BODY = `You produce a session title from a user's prompt. Output ONLY a concise, sentence-case title of 3-7 words that captures the main topic or goal. Capitalize only the first word and proper nouns. Do not include a ticket code in the title text itself (the caller prepends it). No markdown, no prose, no quotes — just the title on one line.
193
215
 
194
216
  Good:
@@ -205,6 +227,7 @@ export const PERSONA_BODIES = {
205
227
  reviewer: REVIEWER_BODY,
206
228
  advisor: ADVISOR_BODY,
207
229
  guardian: GUARDIAN_BODY,
230
+ prefix: PREFIX_BODY,
208
231
  title: TITLE_BODY,
209
232
  orchestrator: [ORCHESTRATOR_BODY, PARTITION_CONTRACT].join("\n\n"),
210
233
  synthesizer: SYNTHESIZER_BODY,
@@ -294,6 +317,7 @@ Be terse and decisive. The caller is mid-task and paying peak rates for your tur
294
317
  */
295
318
  export const BLIND_PERSONA_BODIES = {
296
319
  scout: SCOUT_BLIND,
320
+ prefix: PREFIX_BODY, // grounding-free by construction (no grounding text at all)
297
321
  planner: PLANNER_BLIND,
298
322
  worker: WORKER_BLIND,
299
323
  reviewer: REVIEWER_BLIND,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.1-staging.1359.1",
3
+ "version": "1.1.1-staging.1361.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "beb4c9e3abfde8628b72e5b434d05ee2835f4bfc"
61
+ "yagniSourceSha": "45da756463bd91f8c8e4d110e41da939a3228a25"
62
62
  }