@yagni-app/code-staging 1.1.4-staging.1410.1 → 1.1.4-staging.1412.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.
@@ -925,7 +925,16 @@ export async function registerYagni(pi, deps = {}) {
925
925
  // byte-identical to the pre-consult behavior.
926
926
  ...(evalMode || env.YAGNI_DISABLE_PREFIX_CONSULT === "1" || env.YAGNI_DISABLE_PREFIX_CONSULT === "true"
927
927
  ? {}
928
- : { prefixConsult: {} }),
928
+ : {
929
+ prefixConsult: {
930
+ // The in-process direct transport (no child spawn): the consult
931
+ // rides /v1/chat/completions with the prefix persona as the
932
+ // system prompt, the session's token, and the session's
933
+ // attribution headers. The gate sets x-yagni-caller itself.
934
+ transport: { baseUrl, getToken: getTokenFn },
935
+ },
936
+ }),
937
+ prefixConsultTransportAttribution: () => attributionHeaders(env),
929
938
  onPrefixConsult: makePrefixErrorSink(),
930
939
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
931
940
  onGuardianReview: (ev) => {
@@ -1040,6 +1049,7 @@ export async function registerYagni(pi, deps = {}) {
1040
1049
  ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
1041
1050
  ...(ev.tier ? { tier: ev.tier } : {}),
1042
1051
  ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
1052
+ ...(ev.prefixConsult ? { prefixConsult: ev.prefixConsult } : {}),
1043
1053
  };
1044
1054
  if (guardianStorageTier === "raw") {
1045
1055
  body.command = redactCommand(ev.command);
@@ -137,6 +137,15 @@ export declare function decideGate(toolName: string, params: Record<string, unkn
137
137
  * call actually happened (grants/cache hits skip it).
138
138
  */
139
139
  export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "sandbox_auto_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | "escape_grant_allow" | "escape_cached_allow" | "escape_ask_approved" | "escape_ask_approved_remembered" | "escape_hook_allowed" | "escape_auto_sandboxed" | "escape_ask_denied" | "escape_ask_headless_blocked" | "escape_forbidden_blocked" | "escape_aborted" | "escape_ask_failed" | GuardianError;
140
+ /** The L2 prefix fold's summary at dialog-build time (the funnel field on
141
+ * escape ask events): disabled — no consult layer; skipped — the consult
142
+ * layer is on but every segment was pre-gated out (heredoc/substitution/
143
+ * control flow) or already L1-token-covered; in_window — at least one
144
+ * validated candidate landed before the dialog opened; late — every
145
+ * candidate that ever landed arrived after open (or the consult was still
146
+ * unanswered at emit time — the dialog was unrefined either way);
147
+ * none_answered — the fold settled with zero candidates ever. */
148
+ export type PrefixFoldStatus = "in_window" | "late" | "none_answered" | "skipped" | "disabled";
140
149
  /**
141
150
  * Rich per-decision event for opt-in storage (YAG-510). Carries the RAW
142
151
  * command — the wiring layer (index.ts) hashes/redacts per the workspace's
@@ -156,6 +165,11 @@ export interface GuardianGateEvent {
156
165
  durationMs?: number;
157
166
  /** Whether a Guardian LLM consult actually ran for this decision. */
158
167
  consulted: boolean;
168
+ /** Escape ask dialogs only: the L2 prefix fold's summary at dialog-build
169
+ * time — whether a validated consult candidate was visible in the dialog's
170
+ * options (the funnel's "displayed in-window" step). Absent on every other
171
+ * outcome. */
172
+ prefixConsult?: "in_window" | "late" | "none_answered" | "skipped" | "disabled";
159
173
  /** Present when the terminal outcome followed a Guardian error (e.g. an
160
174
  * error-fallback ask that the user then approved). */
161
175
  guardianError?: GuardianError;
@@ -349,7 +363,23 @@ export interface RegisterPermissionDeps {
349
363
  */
350
364
  prefixConsult?: {
351
365
  tier?: string;
366
+ /** Grace window override for the dialog open (the race's clock). The
367
+ * default is PREFIX_CONSULT_GRACE_MS; tests inject a small value so the
368
+ * in-window vs late paths are drivable in milliseconds. */
369
+ graceMs?: number;
370
+ /** The consult transport: an in-process direct HTTP call to the backend
371
+ * proxy (see pipeline/directConsult.ts). Absent → the transport is not
372
+ * wired and the consult fails soft (the dialog opens with L1 seeds) —
373
+ * the kill-switch shape. */
374
+ transport?: {
375
+ baseUrl: string;
376
+ getToken: () => string | undefined;
377
+ };
352
378
  };
379
+ /** Attribution headers for the consult transport (session/run ids from the
380
+ * wiring layer's env). Optional; the consult always sets x-yagni-caller
381
+ * itself. */
382
+ prefixConsultTransportAttribution?: () => Record<string, string>;
353
383
  /**
354
384
  * Injectable consult function (tests pass a stub; the default is
355
385
  * consultPrefixMemoized — the wiring layer keeps ONE memo per session).
@@ -27,7 +27,7 @@
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
29
  import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
- import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, } from "./prefixExtract.js";
30
+ import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
32
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
33
33
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
@@ -526,6 +526,17 @@ export function registerPermissionGate(pi, deps = {}) {
526
526
  cwd,
527
527
  signal,
528
528
  memo: prefixMemo,
529
+ ...(deps.prefixConsult?.transport
530
+ ? {
531
+ transport: {
532
+ baseUrl: deps.prefixConsult.transport.baseUrl,
533
+ getToken: deps.prefixConsult.transport.getToken,
534
+ ...(deps.prefixConsultTransportAttribution
535
+ ? { attribution: deps.prefixConsultTransportAttribution }
536
+ : {}),
537
+ },
538
+ }
539
+ : {}),
529
540
  ...(tier ? { modelTier: tier } : {}),
530
541
  onEvent: (ev) => {
531
542
  try {
@@ -569,55 +580,106 @@ export function registerPermissionGate(pi, deps = {}) {
569
580
  const foldConsultSeeds = async (args) => {
570
581
  const { l1Seeds } = args;
571
582
  if (!deps.prefixConsult)
572
- return l1Seeds;
583
+ return { seeds: l1Seeds, fold: () => "disabled" };
573
584
  const l1TokenPatterns = new Set((l1Seeds?.seeds ?? []).map((s) => s.pattern.join(" ")).filter((p) => p.length > 0));
574
585
  const l1LiteralSeeds = (l1Seeds?.seeds ?? []).filter((s) => s.pattern.length === 0);
575
586
  // RAW segments, deduped, capped at the seed cap.
576
587
  const segments = [...new Set(args.uncoveredSegments.map((s) => s.trim()).filter((s) => s.length > 0))].slice(0, 5);
577
- const toConsult = segments.filter((seg) => !(l1TokenPatterns.size > 0 && [...l1TokenPatterns].some((p) => seg.startsWith(p))));
578
- if (toConsult.length === 0)
579
- return l1Seeds;
580
- let answeredSegments = 0;
581
- const fold = Promise.allSettled(toConsult.map((seg) => consultSegment(seg, args.cwd, args.signal, args.tier))).then((results) => {
582
- // Two INDEPENDENT passes counting and seed-filtering are different
583
- // questions. `answered` counts every yielded candidate (including one
584
- // duplicating an L1 token: the consult DID answer that segment), in
585
- // its own pass over the results. The second pass de-dupes L1-token
586
- // lookalikes from the SEED list only the duplicate is noise there,
587
- // not an unanswered segment. Comparing the de-duped `extra` against
588
- // `toConsult` would misread a fully-answered fold as partial whenever
589
- // an answer coincided with an L1 token.
590
- const answered = results
591
- .map((r) => (r.status === "fulfilled" ? r.value.candidate : null))
592
- .filter((c) => c !== null);
593
- answeredSegments = answered.length;
594
- return answered
595
- .filter((c) => !l1TokenPatterns.has(c.pattern.join(" ")))
596
- .map((c) => ({ pattern: c.pattern, repoKey: "", addedAt: "", cwd: "" }));
597
- }, () => []);
598
- const extra = await Promise.race([
599
- fold,
600
- new Promise((resolve) => {
601
- const t = setTimeout(() => resolve([]), PREFIX_CONSULT_GRACE_MS);
602
- t.unref?.();
603
- }),
604
- ]);
588
+ // The ONLY skip rule is the syntactic pre-gate (SEGMENT level): a
589
+ // segment carrying heredocs, substitution, or control flow can never
590
+ // yield a candidate — the persona's own rules answer `none` for exactly
591
+ // these shapes — so spending the call proves nothing. Every OTHER
592
+ // uncovered segment consults, including ones an L1 token seed already
593
+ // covers: the consult's answer confirms or refines the seed, and its
594
+ // landing feeds the funnel's in-window field (an L1-covered segment
595
+ // whose consult answers in-window is exactly the healthy path the
596
+ // metric watches skipping it would read every L1-covered compound as
597
+ // "no consult", and the ticket's acceptance is the opposite: clean
598
+ // segments inside compounds still consult).
599
+ const consultable = segments.filter((seg) => !segmentCannotYieldCandidate(seg));
600
+ if (consultable.length === 0)
601
+ return { seeds: l1Seeds, fold: () => "skipped" };
602
+ // Snapshot in-window candidates PER SEGMENT: Promise.allSettled would
603
+ // wait for the SLOWEST consult, so one past-window segment would sink
604
+ // every candidate that landed in time (the exact live failure — a 728ms
605
+ // curl answer invisible because a sibling took 4.5s). The window closes
606
+ // at the grace and the dialog opens with whatever validated by then.
607
+ let validatedCount = 0;
608
+ const inWindowCandidates = [];
609
+ const foldSettledAt = { done: false };
610
+ let windowOpen = true;
611
+ // close() flips the window; settleEarly lets a consult callback close it
612
+ // the moment the first validated candidate lands (the dialog has its
613
+ // refinement at that point — with the grace raised to 4s, holding the
614
+ // open for a slow sibling is pure latency).
615
+ let settleEarly = () => { };
616
+ const consults = consultable.map((seg) => consultSegment(seg, args.cwd, args.signal, args.tier).then((r) => {
617
+ if (r.candidate) {
618
+ validatedCount += 1;
619
+ if (windowOpen) {
620
+ inWindowCandidates.push(r.candidate);
621
+ settleEarly();
622
+ }
623
+ }
624
+ return r;
625
+ }));
626
+ await new Promise((resolve) => {
627
+ const close = () => {
628
+ windowOpen = false;
629
+ resolve();
630
+ };
631
+ settleEarly = close;
632
+ const t = setTimeout(close, deps.prefixConsult?.graceMs ?? PREFIX_CONSULT_GRACE_MS);
633
+ t.unref?.();
634
+ // Settle EARLY when every consult landed (the common case), or on the
635
+ // first in-window candidate (see settleEarly above).
636
+ Promise.allSettled(consults).then(close);
637
+ });
638
+ // extra = in-window candidates, de-duped against L1 token patterns; the
639
+ // fold keeps running past the window for the memo + the fold status.
640
+ const extra = inWindowCandidates
641
+ .filter((c) => !l1TokenPatterns.has(c.pattern.join(" ")))
642
+ .map((c) => ({ pattern: c.pattern, repoKey: "", addedAt: "", cwd: "" }));
643
+ void Promise.allSettled(consults).then(() => { foldSettledAt.done = true; });
644
+ // foldStatus is READ AT EMIT TIME (the terminal gate event fires after
645
+ // the user answers the dialog), so the window is always closed by then:
646
+ // in_window iff a candidate landed before close (visible in the
647
+ // dialog); late iff validated candidates exist but all arrived after,
648
+ // or the fold was still pending at emit (the dialog was unrefined
649
+ // either way — a pending fold must never read none_answered);
650
+ // none_answered iff the fold settled with zero validated candidates
651
+ // ever. The fold keeps running past the window; whatever lands late
652
+ // memoizes and serves a retry instantly.
653
+ const foldStatus = () => {
654
+ if (inWindowCandidates.length > 0)
655
+ return "in_window";
656
+ if (validatedCount > 0)
657
+ return "late";
658
+ return foldSettledAt.done ? "none_answered" : "late";
659
+ };
605
660
  if (extra.length === 0)
606
- return l1Seeds;
607
- // Did every consulted segment yield a candidate? Only then do the L2
608
- // tokens supersede the literal; a partial fold keeps the literal as the
609
- // coverage floor (token + literal, deduped, capped at 5).
610
- const complete = answeredSegments >= toConsult.length;
661
+ return { seeds: l1Seeds, fold: foldStatus };
662
+ // Did every consulted segment yield a candidate IN-WINDOW? Only then do
663
+ // the L2 tokens supersede the literal; a partial fold keeps the literal
664
+ // as the coverage floor (token + literal, deduped, capped at 5). The
665
+ // completeness check counts IN-WINDOW candidates only — `combined`
666
+ // carries exactly those, so a late-landing sibling must not flip the
667
+ // fold "complete" and drop the literal without its candidate present
668
+ // (the coverage-regression class).
669
+ const complete = inWindowCandidates.length >= consultable.length;
611
670
  const combined = [
612
671
  ...(l1Seeds?.seeds ?? []).filter((s) => s.pattern.length > 0),
613
672
  ...extra,
614
673
  ...(complete ? [] : l1LiteralSeeds),
615
674
  ].slice(0, 5);
616
675
  return {
617
- seeds: combined,
618
- description: combined
619
- .map((s) => (s.pattern.length > 0 ? describePrefix(s.pattern) : (s.literal ?? "")))
620
- .join(", "),
676
+ seeds: {
677
+ seeds: combined,
678
+ description: combined
679
+ .map((s) => (s.pattern.length > 0 ? describePrefix(s.pattern) : (s.literal ?? "")))
680
+ .join(", "),
681
+ },
682
+ fold: foldStatus,
621
683
  };
622
684
  };
623
685
  // Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
@@ -1255,13 +1317,13 @@ export function registerPermissionGate(pi, deps = {}) {
1255
1317
  // uncovered mutating segments; failures are invisible (the
1256
1318
  // dialog opens with L1 seeds).
1257
1319
  const seedsForAsk = modeAtEntry === "auto"
1258
- ? await foldConsultSeeds({
1320
+ ? (await foldConsultSeeds({
1259
1321
  l1Seeds: askSeeds,
1260
1322
  uncoveredSegments: askSeeds === null ? [] : segmentsUncoveredForAsk(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY),
1261
1323
  cwd,
1262
1324
  signal: ctx.signal,
1263
1325
  tier: deps.prefixConsult?.tier,
1264
- })
1326
+ })).seeds
1265
1327
  : askSeeds;
1266
1328
  // The settings-rule option rides TOKEN seeds ONLY: a literal
1267
1329
  // seed persisted as `Bash(<literal>:*)` is a prefix rule with no
@@ -1756,7 +1818,7 @@ export function registerPermissionGate(pi, deps = {}) {
1756
1818
  // uncovered segments, grace-bounded, literal-fallback preserved. Runs
1757
1819
  // only with a UI (the headless gate returned above), and consult failure
1758
1820
  // is invisible — the dialog opens with the L1 seeds.
1759
- const seeds = await foldConsultSeeds({
1821
+ const { seeds, fold: foldStatus } = await foldConsultSeeds({
1760
1822
  l1Seeds,
1761
1823
  uncoveredSegments: compound.uncovered,
1762
1824
  cwd,
@@ -1852,7 +1914,7 @@ export function registerPermissionGate(pi, deps = {}) {
1852
1914
  grants.push(customGrant);
1853
1915
  persistGrantFailSoft(customGrant);
1854
1916
  rememberApproved(cwd, command);
1855
- emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
1917
+ emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
1856
1918
  return {};
1857
1919
  }
1858
1920
  // A custom prefix that does not cover this command is a dead rule —
@@ -1911,7 +1973,7 @@ export function registerPermissionGate(pi, deps = {}) {
1911
1973
  }
1912
1974
  if (resolution === "yes") {
1913
1975
  rememberApproved(cwd, command);
1914
- emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved", consulted: false });
1976
+ emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved", consulted: false, prefixConsult: foldStatus() });
1915
1977
  return {};
1916
1978
  }
1917
1979
  if (resolution === "remember" && seeds) {
@@ -1926,7 +1988,7 @@ export function registerPermissionGate(pi, deps = {}) {
1926
1988
  persistGrantFailSoft(grantRecord);
1927
1989
  }
1928
1990
  rememberApproved(cwd, command);
1929
- emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
1991
+ emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
1930
1992
  return {};
1931
1993
  }
1932
1994
  // A dialog-layer FAILURE (a select threw for a non-abort reason — RPC
@@ -1941,6 +2003,7 @@ export function registerPermissionGate(pi, deps = {}) {
1941
2003
  ...eventBase,
1942
2004
  outcome: resolution === "error" ? "escape_ask_failed" : "escape_ask_denied",
1943
2005
  consulted: false,
2006
+ prefixConsult: foldStatus(),
1944
2007
  });
1945
2008
  return {
1946
2009
  block: true,
@@ -1949,7 +2012,7 @@ export function registerPermissionGate(pi, deps = {}) {
1949
2012
  : "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
1950
2013
  };
1951
2014
  }
1952
- emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_denied", consulted: false });
2015
+ emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_denied", consulted: false, prefixConsult: foldStatus() });
1953
2016
  if (resolution === "no") {
1954
2017
  return {
1955
2018
  block: true,
@@ -38,19 +38,38 @@ export interface PrefixConsultResult {
38
38
  /** True when the model answered command_injection_detected (the candidate
39
39
  * is null but the outcome class is injection, not a plain decline). */
40
40
  injection?: boolean;
41
+ /** True when the model ANSWERED but the validation ladder rejected it
42
+ * (banned shape, word shape, self-coverage) — the `invalid` outcome class,
43
+ * distinct from `none` (the model declined) and from the error classes. */
44
+ invalid?: boolean;
41
45
  /** Error CLASS only (constructor name) when the consult threw — never the
42
46
  * thrown message (it can carry command content or provider payloads). */
43
47
  errorClass?: string;
44
48
  }
45
49
  /** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
46
50
  export declare const PREFIX_MODEL_TIER = "efficient";
51
+ export declare function _setPrefixPersonaBodyResolverForTest(resolver: (() => string) | null): void;
47
52
  /**
48
53
  * Grace window for the dialog open: the consult fires at interception and the
49
- * dialog opens after at most this delay with whatever seeds have landed (p50
50
- * ~1s measured on prod; the window trades a bounded open delay for a much
51
- * better remember label). Fails open instantly on any consult error.
54
+ * dialog opens after at most this delay with whatever seeds have landed. The
55
+ * window races the CLIENT-side total (transport + LLM round trip), NOT the
56
+ * server-side llm_call_log.duration_ms (the LLM call alone) the old 1500ms
57
+ * constant was tuned against the wrong clock, and the child-spawn transport
58
+ * (~4s boot before the first event) made every first-encounter consult lose
59
+ * the race. 4000ms is a ceiling we hope the in-process transport's in-window
60
+ * rate never needs (post-fix server durations: p50 ~1.1s, ~87% under 2s).
61
+ * Fails open instantly on any consult error.
52
62
  */
53
- export declare const PREFIX_CONSULT_GRACE_MS = 1500;
63
+ export declare const PREFIX_CONSULT_GRACE_MS = 4000;
64
+ /**
65
+ * The syntactic pre-gate (SEGMENT level): a command segment carrying
66
+ * heredocs, command substitution, or control flow can never yield a
67
+ * validated candidate — the prefix persona's own rules answer `none` for
68
+ * exactly these shapes, and the ladder would dispose the answer anyway.
69
+ * Consulting such a segment spends the call to learn nothing. Clean
70
+ * segments inside compounds still consult.
71
+ */
72
+ export declare function segmentCannotYieldCandidate(segment: string): boolean;
54
73
  /** Consult deadline (the consult keeps running past the grace window — its
55
74
  * result memoizes and serves any retry of the same segment). */
56
75
  export declare const PREFIX_CONSULT_TIMEOUT_MS = 15000;
@@ -76,6 +95,23 @@ export declare function validatePrefixAnswer(answer: string, command: string): {
76
95
  } | {
77
96
  kind: "reject";
78
97
  };
98
+ /**
99
+ * Classify ONE model answer (already fetched, whatever the transport) against
100
+ * a command segment: parse → injection → none → ladder → candidate. The
101
+ * SINGLE ladder both transports feed — the child-spawn path and the direct
102
+ * HTTP path differ only in how they obtain the text, never in how the answer
103
+ * is disposed (the two copies had already begun to drift when `invalid`
104
+ * landed; this is the fix for that).
105
+ */
106
+ export declare function classifyPrefixAnswer(output: string, segment: string, cost: number): PrefixConsultResult;
107
+ /**
108
+ * Classify a transport THREW error into the consult's error classes —
109
+ * shared by both transports. The caller's abort wins, then the consult's
110
+ * own deadline, then everything else is `network` with the error class
111
+ * (constructor name only — never the thrown message, which can carry
112
+ * command content or provider payloads).
113
+ */
114
+ export declare function classifyPrefixConsultThrow(err: unknown, callerAborted: boolean, deadlineAborted: boolean): PrefixConsultResult;
79
115
  /** A settled memo entry: successes AND settled rejections (both are stable
80
116
  * answers — `none` must not re-consult forever, and a malformed answer is a
81
117
  * stable property of the model+segment pair). The terminal OUTCOME CLASS is
@@ -108,12 +144,26 @@ export declare function createPrefixMemo(capacity?: number): {
108
144
  size(): number;
109
145
  };
110
146
  export interface PrefixConsultDeps {
147
+ /** Legacy child-spawn transport seam — retained for the existing test
148
+ * stubs. When `transport` is absent but `runStage` is injected, the
149
+ * stubbed runStage path still runs (the unit tests' seam). */
111
150
  runStage?: typeof defaultRunStage;
112
151
  cwd: string;
113
152
  signal?: AbortSignal;
114
153
  modelTier?: string;
115
154
  timeoutMs?: number;
116
155
  memo?: ReturnType<typeof createPrefixMemo>;
156
+ /** The in-process direct transport. The gate supplies it from its
157
+ * prefixConsult config; when BOTH transport and runStage are absent, the
158
+ * consult fails soft (network class — the dialog opens with L1 seeds). */
159
+ transport?: {
160
+ baseUrl: string;
161
+ getToken: () => string | undefined;
162
+ /** Attribution headers (session/run ids); the consult sets its own
163
+ * x-yagni-caller. */
164
+ attribution?: () => Record<string, string>;
165
+ fetchImpl?: typeof fetch;
166
+ };
117
167
  }
118
168
  /**
119
169
  * The synthetic stage a prefix consult runs as. Borrows the `plan` StageId
@@ -141,6 +191,14 @@ export declare function buildPrefixDiagnosticEvent(outcome: PrefixDiagnosticEven
141
191
  errorClass?: string;
142
192
  debug?: boolean;
143
193
  }): PrefixDiagnosticEvent;
194
+ /**
195
+ * The consult's terminal outcome class, flat (no nested ternaries — the
196
+ * workspace TS rules prohibit them): a candidate is a hit; a malformed
197
+ * answer is unparseable; an answered-but-disposed ladder rejection is
198
+ * invalid; an injection flag is injection_rejected; a plain decline is
199
+ * none; everything else is the transport error class verbatim.
200
+ */
201
+ export declare function prefixOutcomeClass(res: PrefixConsultResult): PrefixDiagnosticEvent["outcome"];
144
202
  /**
145
203
  * Run ONE prefix consult on a single command segment. Pure on top of an
146
204
  * injectable runStage; the caller (the gate) fans out per-segment and races
@@ -24,16 +24,93 @@
24
24
  * is capped at "wasted a suggestion".
25
25
  */
26
26
  import { runStage as defaultRunStage } from "../pipeline/runner.js";
27
+ import { directConsult } from "../pipeline/directConsult.js";
28
+ import { PERSONA_BODIES } from "../pipeline/personas.js";
29
+ import { logEvent } from "../errorSink.js";
27
30
  import { BANNED_PREFIXES, canonicalizeForGrants, matchesGrant, } from "./approvedPrefixes.js";
28
31
  /** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
29
32
  export const PREFIX_MODEL_TIER = "efficient";
33
+ /**
34
+ * The prefix persona body — the system prompt on the direct transport. A
35
+ * missing/renamed persona must never silently degrade to an empty system
36
+ * prompt (every consult would run unprompted and the funnel would just
37
+ * read as a low hit-rate), so a warn fires ONCE when it resolves empty.
38
+ */
39
+ let prefixPersonaBodyWarned = false;
40
+ /** The persona-body resolver — injectable so tests can drive the
41
+ * missing-persona branch (the memo's reset-export pattern). */
42
+ let resolvePrefixPersonaBody = () => PERSONA_BODIES.prefix ?? "";
43
+ export function _setPrefixPersonaBodyResolverForTest(resolver) {
44
+ resolvePrefixPersonaBody = resolver ?? (() => PERSONA_BODIES.prefix ?? "");
45
+ prefixPersonaBodyWarned = false;
46
+ }
47
+ function prefixPersonaBody() {
48
+ const body = resolvePrefixPersonaBody();
49
+ if (body.length === 0 && !prefixPersonaBodyWarned) {
50
+ prefixPersonaBodyWarned = true;
51
+ // logEvent is failure-isolated end to end (errorSink never throws), so
52
+ // no guard here: the consult path must never pay for telemetry.
53
+ logEvent({
54
+ source: "prefix",
55
+ level: "warn",
56
+ event: "prefix_persona_body_missing",
57
+ });
58
+ }
59
+ return body;
60
+ }
30
61
  /**
31
62
  * Grace window for the dialog open: the consult fires at interception and the
32
- * dialog opens after at most this delay with whatever seeds have landed (p50
33
- * ~1s measured on prod; the window trades a bounded open delay for a much
34
- * better remember label). Fails open instantly on any consult error.
63
+ * dialog opens after at most this delay with whatever seeds have landed. The
64
+ * window races the CLIENT-side total (transport + LLM round trip), NOT the
65
+ * server-side llm_call_log.duration_ms (the LLM call alone) the old 1500ms
66
+ * constant was tuned against the wrong clock, and the child-spawn transport
67
+ * (~4s boot before the first event) made every first-encounter consult lose
68
+ * the race. 4000ms is a ceiling we hope the in-process transport's in-window
69
+ * rate never needs (post-fix server durations: p50 ~1.1s, ~87% under 2s).
70
+ * Fails open instantly on any consult error.
71
+ */
72
+ export const PREFIX_CONSULT_GRACE_MS = 4_000;
73
+ /**
74
+ * The syntactic pre-gate (SEGMENT level): a command segment carrying
75
+ * heredocs, command substitution, or control flow can never yield a
76
+ * validated candidate — the prefix persona's own rules answer `none` for
77
+ * exactly these shapes, and the ladder would dispose the answer anyway.
78
+ * Consulting such a segment spends the call to learn nothing. Clean
79
+ * segments inside compounds still consult.
35
80
  */
36
- export const PREFIX_CONSULT_GRACE_MS = 1_500;
81
+ export function segmentCannotYieldCandidate(segment) {
82
+ // Heredoc — the gate's compound splitter refuses to split heredoc bodies,
83
+ // so a heredoc-carrying command arrives as ONE segment that is all body.
84
+ if (/<<[-~]?\s*("|')?(\w+)/.test(segment))
85
+ return true;
86
+ // Command substitution — $(...) or `...` feeding another command.
87
+ if (/\$\(/.test(segment) || /`/.test(segment))
88
+ return true;
89
+ // Control flow — the shell reserved words that make a command multi-part.
90
+ // ANCHORED to command positions (the segment start, or after a statement
91
+ // separator): the words must be what makes the COMMAND multi-part, not
92
+ // any word anywhere — `git commit -m "do the thing"`, `man while`, and
93
+ // `grep in file` are plain single commands the persona can answer for,
94
+ // and over-skipping them is the false-positive class the acceptance
95
+ // criteria argue against.
96
+ const first = segment.trim().split(/[\s;|&]+/)[0] ?? "";
97
+ if (/^(if|then|elif|else|fi|for|while|until|do|done|case|esac)$/.test(first))
98
+ return true;
99
+ // A reserved word after a statement separator (`; done`, `&& do`) — a
100
+ // segment can carry a trailing control-flow clause only via ; & | &&.
101
+ // QUOTE-AWARE: quoted argument bodies are data, not shell structure —
102
+ // `curl 'https://x/a;if=1'` is a plain single command (its separator-like
103
+ // text lives inside quotes), so the scan runs on the segment with quoted
104
+ // spans blanked out.
105
+ // PAIRED spans only: an unpaired apostrophe (`echo don't; done`) is prose,
106
+ // not an opening quote — blanking through it would hide a real separator
107
+ // and fail the pre-gate open (extra consult spend). A paired span with no
108
+ // closing quote is left intact for the same reason.
109
+ const unquoted = segment.replace(/"[^"]*"|'[^']*'/g, (m) => " ".repeat(m.length));
110
+ if (/(?:^|[;|&]{1,2})\s*(?:if|then|elif|else|fi|for|while|until|do|done|case|esac)\b/.test(unquoted))
111
+ return true;
112
+ return false;
113
+ }
37
114
  /** Consult deadline (the consult keeps running past the grace window — its
38
115
  * result memoizes and serves any retry of the same segment). */
39
116
  export const PREFIX_CONSULT_TIMEOUT_MS = 15_000;
@@ -122,6 +199,54 @@ export function validatePrefixAnswer(answer, command) {
122
199
  candidate: { pattern: words, description: `${words.join(" ")} …` },
123
200
  };
124
201
  }
202
+ /**
203
+ * Classify ONE model answer (already fetched, whatever the transport) against
204
+ * a command segment: parse → injection → none → ladder → candidate. The
205
+ * SINGLE ladder both transports feed — the child-spawn path and the direct
206
+ * HTTP path differ only in how they obtain the text, never in how the answer
207
+ * is disposed (the two copies had already begun to drift when `invalid`
208
+ * landed; this is the fix for that).
209
+ */
210
+ export function classifyPrefixAnswer(output, segment, cost) {
211
+ const parsed = parsePrefixAnswer(output);
212
+ if (!parsed) {
213
+ return { candidate: null, error: "malformed", cost };
214
+ }
215
+ const verdict = validatePrefixAnswer(parsed, segment);
216
+ if (verdict.kind === "candidate") {
217
+ return { candidate: verdict.candidate, cost };
218
+ }
219
+ if (verdict.kind === "injection") {
220
+ return { candidate: null, injection: true, cost };
221
+ }
222
+ // Rejection by the ladder — an expected outcome, not an error, but a
223
+ // DISTINCT class from the model's own `none`: the counters must be able
224
+ // to say "the model answered and pure code disposed" (invalid) vs "the
225
+ // model declined" (none). The literal `none` answer is the decline.
226
+ if (parsed === NONE_TOKEN) {
227
+ return { candidate: null, cost };
228
+ }
229
+ return { candidate: null, invalid: true, cost };
230
+ }
231
+ /**
232
+ * Classify a transport THREW error into the consult's error classes —
233
+ * shared by both transports. The caller's abort wins, then the consult's
234
+ * own deadline, then everything else is `network` with the error class
235
+ * (constructor name only — never the thrown message, which can carry
236
+ * command content or provider payloads).
237
+ */
238
+ export function classifyPrefixConsultThrow(err, callerAborted, deadlineAborted) {
239
+ if (callerAborted)
240
+ return { candidate: null, error: "aborted", cost: 0 };
241
+ if (deadlineAborted)
242
+ return { candidate: null, error: "timeout", cost: 0 };
243
+ return {
244
+ candidate: null,
245
+ error: "network",
246
+ cost: 0,
247
+ errorClass: err instanceof Error ? err.constructor.name : typeof err,
248
+ };
249
+ }
125
250
  /**
126
251
  * LRU memo (Claude's memoizeWithLRU shape, reject-evict): caches SETTLED
127
252
  * consults keyed by the command segment string. In-flight promises are
@@ -181,6 +306,26 @@ export function buildPrefixDiagnosticEvent(outcome, opts) {
181
306
  }
182
307
  return ev;
183
308
  }
309
+ /**
310
+ * The consult's terminal outcome class, flat (no nested ternaries — the
311
+ * workspace TS rules prohibit them): a candidate is a hit; a malformed
312
+ * answer is unparseable; an answered-but-disposed ladder rejection is
313
+ * invalid; an injection flag is injection_rejected; a plain decline is
314
+ * none; everything else is the transport error class verbatim.
315
+ */
316
+ export function prefixOutcomeClass(res) {
317
+ if (res.candidate)
318
+ return "hit";
319
+ if (res.error === "malformed")
320
+ return "unparseable";
321
+ if (res.error !== undefined)
322
+ return res.error;
323
+ if (res.injection === true)
324
+ return "injection_rejected";
325
+ if (res.invalid === true)
326
+ return "invalid";
327
+ return "none";
328
+ }
184
329
  /**
185
330
  * Run ONE prefix consult on a single command segment. Pure on top of an
186
331
  * injectable runStage; the caller (the gate) fans out per-segment and races
@@ -199,6 +344,40 @@ export async function consultPrefix(segment, deps) {
199
344
  else
200
345
  deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
201
346
  }
347
+ // The in-process direct transport (the production path): one
348
+ // /v1/chat/completions POST with the prefix persona as the system prompt
349
+ // and the raw segment as the user content — no child spawn, no grounded
350
+ // context, ~1k input tokens. The result flows through the SAME parser +
351
+ // ladder + error classes as the child path.
352
+ if (deps.transport) {
353
+ const t = deps.transport;
354
+ try {
355
+ const res = await directConsult({ systemPrompt: prefixPersonaBody(), userContent: segment, modelTier: deps.modelTier ?? PREFIX_MODEL_TIER }, {
356
+ baseUrl: t.baseUrl,
357
+ getToken: t.getToken,
358
+ attribution: t.attribution ?? (() => ({})),
359
+ callerLabel: "prefix",
360
+ signal: controller.signal,
361
+ ...(t.fetchImpl ? { fetchImpl: t.fetchImpl } : {}),
362
+ });
363
+ const output = res.text.trim();
364
+ if (deps.signal?.aborted) {
365
+ return { candidate: null, error: "aborted", cost: res.cost };
366
+ }
367
+ if (!output) {
368
+ return { candidate: null, error: "empty", cost: res.cost };
369
+ }
370
+ return classifyPrefixAnswer(output, segment, res.cost);
371
+ }
372
+ catch (err) {
373
+ return classifyPrefixConsultThrow(err, deps.signal?.aborted === true, controller.signal.aborted);
374
+ }
375
+ finally {
376
+ clearTimeout(timer);
377
+ }
378
+ }
379
+ // Legacy child-spawn path (retained for the injectable test seam; no
380
+ // production wiring uses it once the gate supplies `transport`).
202
381
  try {
203
382
  const result = await runStage(stage, { ticket: segment }, {
204
383
  cwd: deps.cwd,
@@ -218,35 +397,10 @@ export async function consultPrefix(segment, deps) {
218
397
  }
219
398
  return { candidate: null, error: "empty", cost };
220
399
  }
221
- const parsed = parsePrefixAnswer(output);
222
- if (!parsed) {
223
- return {
224
- candidate: null,
225
- error: "malformed",
226
- cost,
227
- };
228
- }
229
- const verdict = validatePrefixAnswer(parsed, segment);
230
- if (verdict.kind === "candidate") {
231
- return { candidate: verdict.candidate, cost };
232
- }
233
- if (verdict.kind === "injection") {
234
- return { candidate: null, injection: true, cost };
235
- }
236
- // rejection by the ladder — an expected outcome, not an error
237
- return { candidate: null, injection: false, cost };
400
+ return classifyPrefixAnswer(output, segment, cost);
238
401
  }
239
402
  catch (err) {
240
- if (deps.signal?.aborted)
241
- return { candidate: null, error: "aborted", cost: 0 };
242
- if (controller.signal.aborted)
243
- return { candidate: null, error: "timeout", cost: 0 };
244
- return {
245
- candidate: null,
246
- error: "network",
247
- cost: 0,
248
- errorClass: err instanceof Error ? err.constructor.name : typeof err,
249
- };
403
+ return classifyPrefixConsultThrow(err, deps.signal?.aborted === true, controller.signal.aborted);
250
404
  }
251
405
  finally {
252
406
  clearTimeout(timer);
@@ -292,15 +446,7 @@ export async function consultPrefixMemoized(segment, deps) {
292
446
  if (res.error === "empty" && !deps.signal?.aborted) {
293
447
  res = await consultPrefix(segment, deps);
294
448
  }
295
- const outcome = res.candidate
296
- ? "hit"
297
- : res.error === "malformed"
298
- ? "unparseable"
299
- : res.error === undefined
300
- ? res.injection === true
301
- ? "injection_rejected"
302
- : "none"
303
- : res.error;
449
+ const outcome = prefixOutcomeClass(res);
304
450
  deps.onEvent?.(buildPrefixDiagnosticEvent(outcome, {
305
451
  durationMs: Date.now() - started,
306
452
  tier: deps.modelTier ?? PREFIX_MODEL_TIER,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * In-process direct LLM consult transport (the replacement for spawning a
3
+ * child pi per consult): one non-streaming /v1/chat/completions POST to the
4
+ * backend proxy with a system prompt + user content, Bearer auth, and the
5
+ * session's attribution headers (caller label included).
6
+ *
7
+ * The pattern is webFetch.ts's extract() — the extension's existing
8
+ * in-process proxy call — generalized so any consult (the prefix persona
9
+ * today; the Guardian's, tracked separately, can adopt the same seam) runs
10
+ * without paying the ~4s child-boot tax that made every first-encounter
11
+ * consult lose the dialog's grace race.
12
+ *
13
+ * The caller owns parsing/validation/timeout policy; this module only moves
14
+ * the request. Every error throws — the consult's own error classifier
15
+ * (timeout/network) derives the outcome class from the failure mode.
16
+ */
17
+ export interface DirectConsultRequest {
18
+ /** The system prompt (the persona body). */
19
+ systemPrompt: string;
20
+ /** The user content (the consulted material — a command segment, a verdict
21
+ * request, …). */
22
+ userContent: string;
23
+ /** Model tier label the proxy resolves (efficient/standard/advanced). */
24
+ modelTier: string;
25
+ }
26
+ export interface DirectConsultOptions {
27
+ baseUrl: string;
28
+ /** Bearer token source (the session's token provider). */
29
+ getToken: () => string | undefined;
30
+ /** Attribution headers (x-yagni-session-id / x-yagni-run-id /
31
+ * x-yagni-caller). The consult overrides the caller label. */
32
+ attribution: () => Record<string, string>;
33
+ /** The caller label (x-yagni-caller) — attribution, labeling the consult's
34
+ * spend and failures in the backend's dashboards. */
35
+ callerLabel: string;
36
+ signal?: AbortSignal;
37
+ /** Injectable for tests (defaults to the global fetch). */
38
+ fetchImpl?: typeof fetch;
39
+ }
40
+ export interface DirectConsultResult {
41
+ /** The model's reply text (trimmed by the caller, not here). */
42
+ text: string;
43
+ /** Cost in USD when the response carries usage, else 0. */
44
+ cost: number;
45
+ }
46
+ export declare function directConsult(req: DirectConsultRequest, opts: DirectConsultOptions): Promise<DirectConsultResult>;
47
+ //# sourceMappingURL=directConsult.d.ts.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * In-process direct LLM consult transport (the replacement for spawning a
3
+ * child pi per consult): one non-streaming /v1/chat/completions POST to the
4
+ * backend proxy with a system prompt + user content, Bearer auth, and the
5
+ * session's attribution headers (caller label included).
6
+ *
7
+ * The pattern is webFetch.ts's extract() — the extension's existing
8
+ * in-process proxy call — generalized so any consult (the prefix persona
9
+ * today; the Guardian's, tracked separately, can adopt the same seam) runs
10
+ * without paying the ~4s child-boot tax that made every first-encounter
11
+ * consult lose the dialog's grace race.
12
+ *
13
+ * The caller owns parsing/validation/timeout policy; this module only moves
14
+ * the request. Every error throws — the consult's own error classifier
15
+ * (timeout/network) derives the outcome class from the failure mode.
16
+ */
17
+ export async function directConsult(req, opts) {
18
+ const fetchImpl = opts.fetchImpl ?? fetch;
19
+ const res = await fetchImpl(`${opts.baseUrl}/v1/chat/completions`, {
20
+ method: "POST",
21
+ headers: {
22
+ "content-type": "application/json",
23
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
24
+ ...opts.attribution(),
25
+ "x-yagni-caller": opts.callerLabel,
26
+ },
27
+ body: JSON.stringify({
28
+ model: req.modelTier,
29
+ messages: [
30
+ { role: "system", content: req.systemPrompt },
31
+ { role: "user", content: req.userContent },
32
+ ],
33
+ }),
34
+ signal: opts.signal,
35
+ });
36
+ if (!res.ok) {
37
+ // The status rides the message (the consult keeps thrown messages out of
38
+ // telemetry; the local sink line + errorClass carry it) so a persistent
39
+ // 401/403 is distinguishable from a transient gateway blip when reading
40
+ // the sink — the closed outcome vocabulary stays untouched.
41
+ throw new Error(`direct consult failed: HTTP ${res.status}`);
42
+ }
43
+ const data = (await res.json());
44
+ // A 200 with no/blank content is an EMPTY result, not a transport error:
45
+ // the consult's error classifier treats `empty` as retryable-once (the
46
+ // backend's own 502-shape body invites exactly that), and a thrown error
47
+ // here would misclassify that shape as `network`. A genuinely malformed
48
+ // (non-JSON) body still throws below.
49
+ const text = data.choices?.[0]?.message?.content ?? "";
50
+ return { text, cost: typeof data.usage?.cost === "number" ? data.usage.cost : 0 };
51
+ }
52
+ //# sourceMappingURL=directConsult.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.4-staging.1410.1",
3
+ "version": "1.1.4-staging.1412.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": "dc4bae35f54da68509b2309aa4060607bb0a6067"
61
+ "yagniSourceSha": "4266630468e144c9ac86fc902b44a9de1b05409f"
62
62
  }