@yagni-app/code-staging 1.0.5-staging.1238.1 → 1.0.5-staging.1240.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -516,6 +516,10 @@ export const HELP_TEXT = [
516
516
  " yagni connect claude-code Route Claude Code through the YAGNI model proxy",
517
517
  " (--project scopes to this repo; --off disconnects).",
518
518
  " yagni connect codex Route Codex CLI through the YAGNI model proxy.",
519
+ " yagni mcp <subcommand> Manage MCP servers: add, remove, list, get,",
520
+ " add-from-claude, and more — run `yagni mcp` for",
521
+ " details. OAuth servers authenticate via the",
522
+ " /mcp panel in a session.",
519
523
  " yagni token Output the active environment's API token (for helpers).",
520
524
  " yagni use <name> Switch the active environment (sticky).",
521
525
  " Presets: prod, local. Others need --base-url <url>.",
@@ -537,6 +541,7 @@ export const HELP_TEXT = [
537
541
  " /mode plan|review|auto Hold writes for planning, or confirm each change.",
538
542
  " /todos Show the agent's live task list.",
539
543
  " /cost Session usage and credit headroom.",
544
+ " /mcp Manage MCP servers; authenticate OAuth servers.",
540
545
  "",
541
546
  "The active environment is sticky; `use` switches it (prod is the default).",
542
547
  "Set YAGNI_BASE_URL to override the base URL for a single run.",
@@ -386,12 +386,18 @@ export async function registerYagni(pi, deps = {}) {
386
386
  const guardianLogSink = (payload) => {
387
387
  const event = typeof payload.event === "string" ? payload.event : "guardian_event";
388
388
  const { event: _ignored, ...fields } = payload;
389
+ // `malformed` (repair failed — still really failing) and a failed
390
+ // telemetry POST are errors. `repaired: true` (the ladder salvaged the
391
+ // verdict) is a warn — it worked, but we keep counting it. Everything
392
+ // else is routine info.
393
+ const level = event === "guardian_event_post_failed" || payload.outcome === "malformed"
394
+ ? "error"
395
+ : payload.repaired === true && payload.outcome !== undefined
396
+ ? "warn"
397
+ : "info";
389
398
  logEvent({
390
399
  source: "guardian",
391
- // `malformed` is a real failure (this is the "unclear verdict" bug the
392
- // whole capture exists to diagnose) — an error, not routine info. The
393
- // failed-telemetry event is already an error.
394
- level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
400
+ level,
395
401
  event,
396
402
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
397
403
  fields,
@@ -419,15 +425,20 @@ export async function registerYagni(pi, deps = {}) {
419
425
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
420
426
  onGuardianReview: (ev) => {
421
427
  guardianLogSink(ev);
422
- // A malformed verdict is a real failure on our side that must reach
423
- // Sentry for EVERY workspace (independent of the opt-in storage tier).
424
- // Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
428
+ // A malformed verdict (repair FAILED still really failing) is a real
429
+ // failure on our side that must reach Sentry for EVERY workspace
430
+ // (independent of the opt-in storage tier). A REPAIRED verdict (repair
431
+ // salvaged it) posts too, with outcome "malformed_repaired" so the
432
+ // backend can count salvage hits on their own Sentry issue. Both
433
+ // fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
425
434
  // suppressed under test/eval so a CI run never phones prod.
426
- if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
435
+ const isMalformed = ev.outcome === "malformed";
436
+ const isRepaired = ev.outcome !== "malformed" && ev.repaired === true;
437
+ if ((isMalformed || isRepaired) && !evalMode && !runningUnderTest(env)) {
427
438
  void (async () => {
428
439
  try {
429
440
  const body = {
430
- outcome: ev.outcome,
441
+ outcome: isRepaired ? "malformed_repaired" : ev.outcome,
431
442
  ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
432
443
  ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
433
444
  ...(ev.tier ? { tier: ev.tier } : {}),
@@ -25,6 +25,7 @@ import { runningUnderTest } from "../crashReport.js";
25
25
  import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
26
26
  import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
27
27
  import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
28
+ import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
28
29
  /** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
29
30
  export class AuthenticationCancelledError extends Error {
30
31
  constructor(message = "OAuth cancelled") {
@@ -424,20 +425,23 @@ function waitForCode(port, expectedState, opts = {}) {
424
425
  // The provider-controlled values are HTML-escaped: any local process
425
426
  // can hit the loopback with a crafted ?error=<script> payload, and the
426
427
  // page must never reflect it (same spot Claude Code sanitizes).
427
- res.end(`<h1>Authentication Error</h1><p>${escapeHtml(error)}${errorDescription ? `: ${escapeHtml(errorDescription)}` : ""}</p><p>You can close this window.</p>`);
428
+ res.end(renderAuthErrorPage({
429
+ error: escapeHtml(error),
430
+ errorDescription: errorDescription ? escapeHtml(errorDescription) : undefined,
431
+ }));
428
432
  finish(undefined);
429
433
  return;
430
434
  }
431
435
  if (state !== expectedState) {
432
436
  onEvent?.("oauth_callback_state_mismatch", {});
433
437
  res.writeHead(400, { "Content-Type": "text/html" });
434
- res.end("<h1>Authentication Error</h1><p>Invalid state parameter. Please try again.</p>");
438
+ res.end(renderAuthStateMismatchPage());
435
439
  fail(new Error("OAuth state mismatch - possible CSRF attack"));
436
440
  return;
437
441
  }
438
442
  onEvent?.("oauth_callback_code", {});
439
443
  res.writeHead(200, { "Content-Type": "text/html" });
440
- res.end("<h1>Authentication Successful</h1><p>You can close this window and return to YAGNI Code.</p>");
444
+ res.end(renderAuthSuccessPage());
441
445
  finish(code);
442
446
  });
443
447
  server.on("error", () => {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The loopback OAuth callback page — the only surface a user ever sees served
3
+ * from the local `127.0.0.1:<port>/callback` listener. Rendered with the same
4
+ * Glass & Print language as the app: tinted blue field, floating glass card,
5
+ * one blue accent, three type voices (Instrument Sans interface / Newsreader
6
+ * authored / IBM Plex Mono fact).
7
+ *
8
+ * Self-contained: no external assets, fonts, or network calls — the page must
9
+ * render offline and never phone home. Tokens are inlined from
10
+ * packages/frontend/src/styles/tokens.css so this module can't drift a runtime
11
+ * dependency on the frontend build; token names are kept in comments so a
12
+ * re-derivation stays mechanical.
13
+ *
14
+ * `escapeHtml` is applied by the caller to every provider-controlled value
15
+ * before interpolation (a local process can hit the loopback with a crafted
16
+ * ?error=<script> payload — the page never reflects raw input).
17
+ */
18
+ /** The success page shown after the provider redirects back with a code. */
19
+ export declare function renderAuthSuccessPage(): string;
20
+ /**
21
+ * The error page shown when the provider bounces the flow. `error` and
22
+ * `errorDescription` are provider-controlled and already HTML-escaped by the
23
+ * caller.
24
+ */
25
+ export declare function renderAuthErrorPage(opts: {
26
+ error?: string;
27
+ errorDescription?: string;
28
+ }): string;
29
+ /** The state-mismatch page — a hard failure, shown before the flow aborts. */
30
+ export declare function renderAuthStateMismatchPage(): string;
31
+ //# sourceMappingURL=callbackPage.d.ts.map
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The loopback OAuth callback page — the only surface a user ever sees served
3
+ * from the local `127.0.0.1:<port>/callback` listener. Rendered with the same
4
+ * Glass & Print language as the app: tinted blue field, floating glass card,
5
+ * one blue accent, three type voices (Instrument Sans interface / Newsreader
6
+ * authored / IBM Plex Mono fact).
7
+ *
8
+ * Self-contained: no external assets, fonts, or network calls — the page must
9
+ * render offline and never phone home. Tokens are inlined from
10
+ * packages/frontend/src/styles/tokens.css so this module can't drift a runtime
11
+ * dependency on the frontend build; token names are kept in comments so a
12
+ * re-derivation stays mechanical.
13
+ *
14
+ * `escapeHtml` is applied by the caller to every provider-controlled value
15
+ * before interpolation (a local process can hit the loopback with a crafted
16
+ * ?error=<script> payload — the page never reflects raw input).
17
+ */
18
+ const CSS = `
19
+ :root {
20
+ /* tokens.css — field, ink, accent, materials */
21
+ --field-base: #f4f6fc;
22
+ --field-tint-blue: #dfe8ff;
23
+ --field-tint-violet: #e8e4ff;
24
+ --field-tint-cyan: #e3f0f6;
25
+ --ink: #23252e;
26
+ --ink-strong: #181a22;
27
+ --text-secondary: #565b72;
28
+ --text-quiet: #8b90a5;
29
+ --text-label: #7b8098;
30
+ --accent: #2f56d3;
31
+ --accent-muted: rgba(47, 86, 211, 0.12);
32
+ --accent-border: rgba(47, 86, 211, 0.3);
33
+ --error: #b4452f;
34
+ --error-muted: rgba(180, 69, 47, 0.07);
35
+ --glass-bg: rgba(255, 255, 255, 0.55);
36
+ --glass-border: rgba(255, 255, 255, 0.85);
37
+ --glass-blur: 18px;
38
+ --radius-pane: 16px;
39
+ --shadow-staged: 0 1px 2px rgba(24, 34, 64, 0.07), 0 18px 40px -30px rgba(38, 52, 110, 0.35);
40
+ --shadow-staged-high: 0 1px 2px rgba(24, 34, 64, 0.08), 0 28px 60px -28px rgba(38, 52, 110, 0.42);
41
+ --body: "Instrument Sans Variable", "Instrument Sans", system-ui, sans-serif;
42
+ --prose: "Newsreader", ui-serif, Georgia, serif;
43
+ --mono: "IBM Plex Mono", ui-monospace, monospace;
44
+ }
45
+
46
+ * { box-sizing: border-box; }
47
+
48
+ html, body { height: 100%; }
49
+
50
+ body {
51
+ margin: 0;
52
+ min-height: 100%;
53
+ display: grid;
54
+ place-items: center;
55
+ padding: 24px;
56
+ font-family: var(--body);
57
+ color: var(--ink);
58
+ background:
59
+ radial-gradient(1200px 700px at 8% -5%, var(--field-tint-blue) 0, rgba(223, 232, 255, 0) 60%),
60
+ radial-gradient(900px 600px at 95% 0%, var(--field-tint-violet) 0, rgba(232, 228, 255, 0) 55%),
61
+ radial-gradient(900px 700px at 60% 100%, var(--field-tint-cyan) 0, rgba(227, 240, 246, 0) 60%),
62
+ var(--field-base);
63
+ }
64
+
65
+ .card {
66
+ width: 100%;
67
+ max-width: 400px;
68
+ text-align: center;
69
+ padding: 40px 32px 32px;
70
+ background: var(--glass-bg);
71
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
72
+ backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
73
+ border: 1px solid var(--glass-border);
74
+ border-radius: var(--radius-pane);
75
+ box-shadow: var(--shadow-staged-high);
76
+ animation: rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
77
+ }
78
+
79
+ @keyframes rise {
80
+ from { opacity: 0; transform: translateY(8px); }
81
+ to { opacity: 1; transform: none; }
82
+ }
83
+
84
+ .wordmark {
85
+ font-family: var(--mono);
86
+ font-size: 10px;
87
+ font-weight: 600;
88
+ letter-spacing: 0.22em;
89
+ text-transform: uppercase;
90
+ color: var(--text-label);
91
+ margin: 0 0 28px;
92
+ }
93
+
94
+ .badge {
95
+ width: 52px;
96
+ height: 52px;
97
+ margin: 0 auto 20px;
98
+ display: grid;
99
+ place-items: center;
100
+ border-radius: 14px;
101
+ background: var(--accent-muted);
102
+ border: 1px solid var(--accent-border);
103
+ color: var(--accent);
104
+ }
105
+
106
+ .card--error .badge {
107
+ background: var(--error-muted);
108
+ border-color: rgba(180, 69, 47, 0.28);
109
+ color: var(--error);
110
+ }
111
+
112
+ h1 {
113
+ font-family: var(--prose);
114
+ font-size: 30px;
115
+ font-weight: 600;
116
+ line-height: 1.15;
117
+ letter-spacing: -0.01em;
118
+ color: var(--ink-strong);
119
+ margin: 0 0 10px;
120
+ }
121
+
122
+ .lede {
123
+ font-size: 14px;
124
+ line-height: 1.55;
125
+ color: var(--text-secondary);
126
+ margin: 0 0 24px;
127
+ }
128
+
129
+ .detail {
130
+ font-family: var(--mono);
131
+ font-size: 12px;
132
+ line-height: 1.5;
133
+ color: var(--error);
134
+ background: var(--error-muted);
135
+ border: 1px solid rgba(180, 69, 47, 0.2);
136
+ border-radius: 8px;
137
+ padding: 8px 10px;
138
+ margin: 0 0 24px;
139
+ word-break: break-word;
140
+ }
141
+
142
+ .divider {
143
+ height: 1px;
144
+ margin: 0 0 20px;
145
+ background: linear-gradient(90deg, transparent, var(--accent-border), transparent);
146
+ }
147
+
148
+ .hint {
149
+ font-size: 12.5px;
150
+ color: var(--text-quiet);
151
+ margin: 0;
152
+ }
153
+
154
+ @media (prefers-reduced-motion: reduce) {
155
+ .card { animation: none; }
156
+ }
157
+ `;
158
+ const CHECK_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
159
+ const ERROR_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16.5" x2="12.01" y2="16.5"/></svg>`;
160
+ function shell(opts) {
161
+ return `<!DOCTYPE html>
162
+ <html lang="en">
163
+ <head>
164
+ <meta charset="utf-8">
165
+ <meta name="viewport" content="width=device-width, initial-scale=1">
166
+ <meta name="color-scheme" content="light">
167
+ <title>${opts.title} — YAGNI Code</title>
168
+ <style>${CSS}</style>
169
+ </head>
170
+ <body>
171
+ <main class="card${opts.error ? " card--error" : ""}">
172
+ <p class="wordmark">YAGNI Code</p>
173
+ <div class="badge">${opts.error ? ERROR_ICON : CHECK_ICON}</div>
174
+ <h1>${opts.heading}</h1>
175
+ <p class="lede">${opts.lede}</p>
176
+ ${opts.detail ? `<p class="detail">${opts.detail}</p>` : ""}
177
+ <div class="divider"></div>
178
+ <p class="hint">${opts.hint}</p>
179
+ </main>
180
+ </body>
181
+ </html>`;
182
+ }
183
+ /** The success page shown after the provider redirects back with a code. */
184
+ export function renderAuthSuccessPage() {
185
+ return shell({
186
+ title: "Connected",
187
+ heading: "Connected to YAGNI Code",
188
+ lede: "Your MCP server is authorized and ready to use.",
189
+ hint: "You can close this window and return to your session.",
190
+ });
191
+ }
192
+ /**
193
+ * The error page shown when the provider bounces the flow. `error` and
194
+ * `errorDescription` are provider-controlled and already HTML-escaped by the
195
+ * caller.
196
+ */
197
+ export function renderAuthErrorPage(opts) {
198
+ const detail = opts.error
199
+ ? opts.errorDescription
200
+ ? `${opts.error}: ${opts.errorDescription}`
201
+ : opts.error
202
+ : undefined;
203
+ return shell({
204
+ title: "Connection failed",
205
+ error: true,
206
+ heading: "Authentication didn't complete",
207
+ lede: "The provider didn't finish the sign-in flow.",
208
+ detail,
209
+ hint: "You can close this window and try again from YAGNI Code.",
210
+ });
211
+ }
212
+ /** The state-mismatch page — a hard failure, shown before the flow aborts. */
213
+ export function renderAuthStateMismatchPage() {
214
+ return shell({
215
+ title: "Connection failed",
216
+ error: true,
217
+ heading: "Authentication didn't complete",
218
+ lede: "The sign-in response didn't match the request, so it was rejected.",
219
+ hint: "You can close this window and try again from YAGNI Code.",
220
+ });
221
+ }
222
+ //# sourceMappingURL=callbackPage.js.map
@@ -577,6 +577,7 @@ export function registerPermissionGate(pi, deps = {}) {
577
577
  void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
578
578
  durationMs,
579
579
  tier: guardianTier,
580
+ ...(reviewResult.repaired ? { repaired: true } : {}),
580
581
  ...(rationale ? { rationale } : {}),
581
582
  ...(rawOutput ? { rawOutput } : {}),
582
583
  debug: isDebug(),
@@ -585,7 +586,11 @@ export function registerPermissionGate(pi, deps = {}) {
585
586
  const verdict = reviewResult.verdict;
586
587
  if (verdict?.outcome === "allow") {
587
588
  guardianState.recordReview("allow");
588
- emitDiag("allow", verdict.rationale);
589
+ // A repaired verdict is a REAL allow: it flows through the normal
590
+ // path; the diag event carries `repaired` + the pre-repair shape so
591
+ // telemetry can count salvage hits (the rawOutput is already
592
+ // scrubbed + capped at the source).
593
+ emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
589
594
  emitGateEvent({
590
595
  ...eventBase,
591
596
  outcome: "allow",
@@ -599,7 +604,7 @@ export function registerPermissionGate(pi, deps = {}) {
599
604
  if (verdict?.outcome === "deny") {
600
605
  guardianState.recordReview("deny");
601
606
  const rationale = verdict.rationale;
602
- emitDiag("deny", rationale);
607
+ emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
603
608
  emitGateEvent({
604
609
  ...eventBase,
605
610
  outcome: "deny",
@@ -624,7 +629,7 @@ export function registerPermissionGate(pi, deps = {}) {
624
629
  }
625
630
  if (verdict?.outcome === "ask") {
626
631
  guardianState.recordReview("ask");
627
- emitDiag("ask", verdict.rationale);
632
+ emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
628
633
  if (!ctx?.hasUI) {
629
634
  // Headless (includes every /go child stage): fail closed.
630
635
  emitGateEvent({
@@ -99,6 +99,14 @@ export interface CircuitBreakerResult {
99
99
  reason?: string;
100
100
  }
101
101
  export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
102
+ export interface ParsedVerdict {
103
+ verdict: GuardianVerdict;
104
+ /** True when the strict parse failed and the lenient repair ladder salvaged it. */
105
+ repaired: boolean;
106
+ }
107
+ export declare function parseVerdictDetailed(raw: string): ParsedVerdict | null;
108
+ /** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
109
+ * the repaired signal use {@link parseVerdictDetailed}. */
102
110
  export declare function parseVerdict(raw: string): GuardianVerdict | null;
103
111
  export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
104
112
  export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
@@ -106,11 +114,17 @@ export interface ReviewResult {
106
114
  verdict: GuardianVerdict | null;
107
115
  error?: GuardianError;
108
116
  cost: number;
117
+ /** True when the verdict came from the lenient repair ladder (strict parse
118
+ * failed first). The verdict is real and flows through the normal
119
+ * allow/ask/deny handling; this flag only marks it for telemetry. */
120
+ repaired?: boolean;
109
121
  /**
110
122
  * Scrubbed + capped copy of the model output when the verdict failed to
111
- * parse (`error: "malformed"`). Present so the sink can capture the exact
112
- * failure shape. Never contains the raw command unredacted: `scrubSecrets`
113
- * removes secret-shaped values before this is stored.
123
+ * parse (`error: "malformed"`), or of the pre-repair extracted block when
124
+ * the repair ladder salvaged it (`repaired: true`). Present so the sink can
125
+ * capture the exact failure shape either way. Never contains the raw
126
+ * command unredacted: `scrubSecrets` removes secret-shaped values before
127
+ * this is stored.
114
128
  */
115
129
  rawOutput?: string;
116
130
  }
@@ -152,14 +166,18 @@ export interface GuardianDiagnosticEvent {
152
166
  outcome: GuardianOutcome | GuardianError;
153
167
  durationMs?: number;
154
168
  tier?: string;
169
+ /** True when the lenient repair ladder salvaged a broken verdict — the
170
+ * outcome is still the REAL verdict (allow/ask/deny); this flag marks it
171
+ * for telemetry so repair hit-rate is measurable. */
172
+ repaired?: true;
155
173
  /** Debug-only: command hash for correlation (never the raw command). */
156
174
  commandHash?: string;
157
175
  /** Debug-only: the Guardian's rationale. */
158
176
  rationale?: string;
159
177
  /**
160
178
  * Scrubbed + capped copy of the unparseable model output, present only for
161
- * `outcome: "malformed"`. Always-on (NOT debug-gated): it is already
162
- * `scrubSecrets`-redacted and size-capped at the source.
179
+ * `outcome: "malformed"` or `repaired: true`. Always-on (NOT debug-gated):
180
+ * it is already `scrubSecrets`-redacted and size-capped at the source.
163
181
  */
164
182
  rawOutput?: string;
165
183
  }
@@ -170,6 +188,7 @@ export interface GuardianDiagnosticEvent {
170
188
  export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
171
189
  durationMs?: number;
172
190
  tier?: string;
191
+ repaired?: boolean;
173
192
  rationale?: string;
174
193
  commandHash?: string;
175
194
  rawOutput?: string;
@@ -128,33 +128,158 @@ export function checkCircuitBreaker(state, limits) {
128
128
  }
129
129
  return { tripped: false };
130
130
  }
131
- // --- Verdict parsing (fail closed on malformed) ---
132
- export function parseVerdict(raw) {
131
+ const VALID_RISK_LEVELS = ["low", "medium", "high", "critical"];
132
+ /** Validate an already-parsed object into a verdict. The SINGLE validation
133
+ * gate: strict and repaired parses both end here, so the enum checks stay
134
+ * exactly as strict after repair as before. */
135
+ function validateVerdictObject(parsed) {
136
+ if (!parsed)
137
+ return null;
138
+ const outcome = parsed.outcome;
139
+ if (outcome !== "allow" && outcome !== "ask" && outcome !== "deny")
140
+ return null;
141
+ const riskLevel = parsed.riskLevel;
142
+ return {
143
+ outcome,
144
+ riskLevel: typeof riskLevel === "string" && VALID_RISK_LEVELS.includes(riskLevel)
145
+ ? riskLevel
146
+ : "medium",
147
+ rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
148
+ ? parsed.rationale.trim()
149
+ : "No rationale provided.",
150
+ };
151
+ }
152
+ /** Extract the first {...} block (efficient-tier models may wrap JSON in
153
+ * markdown fences despite instructions to output raw JSON). */
154
+ function extractJsonBlock(raw) {
155
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
156
+ return jsonMatch ? jsonMatch[0] : raw;
157
+ }
158
+ /**
159
+ * The lenient repair ladder — applied ONLY after a strict `JSON.parse` of the
160
+ * extracted block has already thrown. Each rung fixes one observed model
161
+ * failure shape and immediately retries the strict parse; the ladder runs in
162
+ * cheapest-first order and the content-synthesizing re-quote is always LAST.
163
+ * Every rung is end-anchored or last-occurrence-anchored and linear-time.
164
+ * Returns the repaired JSON string (parsable) or null if no rung fits.
165
+ *
166
+ * Observed shapes (captured live on deepseek-v4-flash, all previously "unclear
167
+ * verdict"): illegal backslash escapes (\d written literally), a doubled
168
+ * closing quote, and unescaped quotes inside the rationale. The trailing
169
+ * comma is the most common LLM JSON slip in the wild, not (yet) observed here.
170
+ *
171
+ * What the ladder deliberately does NOT fix (all fail closed):
172
+ * single quotes as structure (ambiguous with English apostrophes), glued
173
+ * multi-object output (never pick one verdict of two), raw newlines inside
174
+ * strings (indistinguishable from legal pretty-printed whitespace without
175
+ * parsing), a broken prefix before the rationale anchor, and truncations.
176
+ */
177
+ function repairVerdictJson(s) {
178
+ // The ladder is SEQUENTIAL: each rung transforms the previous result and
179
+ // immediately retries the strict parse. Composing matters — a blob can
180
+ // carry more than one failure shape (an illegal backslash escape AND a
181
+ // trailing comma, say), and each rung alone would leave the other broken.
182
+ let cur = s;
183
+ // Rung 1 — illegal backslash escapes (\d, \w, \( … become \\d). Legal JSON
184
+ // escapes (" \\ \/ b f n r t and \uXXXX) are left untouched by the lookahead.
185
+ const escapedBackslashes = cur.replace(/\\(?!["\\\/bfnrtu])/g, "\\\\");
186
+ if (escapedBackslashes !== cur) {
187
+ try {
188
+ JSON.parse(escapedBackslashes);
189
+ return escapedBackslashes;
190
+ }
191
+ catch {
192
+ cur = escapedBackslashes;
193
+ }
194
+ }
195
+ // Rung 2 — doubled closing quote before }: anchored to END so a doubled
196
+ // quote mid-rationale is untouched (the re-quote rung handles that shape).
197
+ const collapsedQuote = cur.replace(/""(\s*\})$/, '"$1');
198
+ if (collapsedQuote !== cur) {
199
+ try {
200
+ JSON.parse(collapsedQuote);
201
+ return collapsedQuote;
202
+ }
203
+ catch {
204
+ cur = collapsedQuote;
205
+ }
206
+ }
207
+ // Rung 3 — trailing comma before }: anchored to END so a comma inside a
208
+ // rationale value ("a, b") can never be stripped.
209
+ const strippedComma = cur.replace(/,(\s*\})$/, '$1');
210
+ if (strippedComma !== cur) {
211
+ try {
212
+ JSON.parse(strippedComma);
213
+ return strippedComma;
214
+ }
215
+ catch {
216
+ cur = strippedComma;
217
+ }
218
+ }
219
+ // Rung 4 (last resort, the only rung that synthesizes content) — re-quote
220
+ // the trailing rationale value. Applies only when the shape is
221
+ // {…"rationale":"<rest-to-end>}: the prefix before the anchor must carry no
222
+ // closing brace (rules out prose and glued multi-object output — an earlier
223
+ // sibling object always leaves a } behind), and the extracted rationale text
224
+ // must carry no braces either (rules out a glued TAIL). Every " and \ in the
225
+ // tail is escaped in ONE pass so whatever the model wrote inside the
226
+ // rationale becomes literal text. Anchor ambiguity resolves to the LAST
227
+ // "rationale":" occurrence; single pass, no loops.
228
+ const anchor = cur.lastIndexOf('"rationale":"');
229
+ if (anchor !== -1) {
230
+ const prefix = cur.slice(0, anchor);
231
+ const tail = cur.slice(anchor + '"rationale":"'.length);
232
+ // The tail must be the trailing value closing the object: non-empty
233
+ // content, an optional-whitespace + } at the very end.
234
+ const tailMatch = tail.match(/^(.+?)(\s*\})$/);
235
+ if (tailMatch && !prefix.includes("}")) {
236
+ const rawRationale = tailMatch[1].replace(/"+$/, "");
237
+ if (rawRationale.length > 0 &&
238
+ !rawRationale.includes("{") &&
239
+ !rawRationale.includes("}")) {
240
+ const reQuoted = prefix +
241
+ '"rationale":"' +
242
+ rawRationale.replace(/["\\]/g, "\\$&") +
243
+ '"}';
244
+ try {
245
+ JSON.parse(reQuoted);
246
+ return reQuoted;
247
+ }
248
+ catch {
249
+ /* fail closed */
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return null;
255
+ }
256
+ export function parseVerdictDetailed(raw) {
257
+ const jsonStr = extractJsonBlock(raw);
133
258
  try {
134
- // Efficient-tier models may wrap JSON in markdown fences despite
135
- // instructions to output raw JSON. Extract the first {...} block.
136
- const jsonMatch = raw.match(/\{[\s\S]*\}/);
137
- const jsonStr = jsonMatch ? jsonMatch[0] : raw;
138
259
  const parsed = JSON.parse(jsonStr);
139
- const outcome = parsed?.outcome;
140
- if (outcome !== "allow" && outcome !== "ask" && outcome !== "deny")
141
- return null;
142
- const riskLevel = parsed.riskLevel;
143
- const validLevels = ["low", "medium", "high", "critical"];
144
- return {
145
- outcome,
146
- riskLevel: typeof riskLevel === "string" && validLevels.includes(riskLevel)
147
- ? riskLevel
148
- : "medium",
149
- rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
150
- ? parsed.rationale.trim()
151
- : "No rationale provided.",
152
- };
260
+ const verdict = validateVerdictObject(parsed);
261
+ return verdict ? { verdict, repaired: false } : null;
153
262
  }
154
263
  catch {
155
- return null;
264
+ // Strict parse failed — try the repair ladder on the same extracted block.
265
+ const repairedJson = repairVerdictJson(jsonStr);
266
+ if (repairedJson === null)
267
+ return null;
268
+ try {
269
+ const parsed = JSON.parse(repairedJson);
270
+ const verdict = validateVerdictObject(parsed);
271
+ return verdict ? { verdict, repaired: true } : null;
272
+ }
273
+ catch {
274
+ return null;
275
+ }
156
276
  }
157
277
  }
278
+ /** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
279
+ * the repaired signal use {@link parseVerdictDetailed}. */
280
+ export function parseVerdict(raw) {
281
+ return parseVerdictDetailed(raw)?.verdict ?? null;
282
+ }
158
283
  // --- /cost subtotal ---
159
284
  export function formatGuardianSubtotal(state, limits) {
160
285
  if (state.reviews === 0)
@@ -236,8 +361,8 @@ export async function reviewCommand(command, deps) {
236
361
  }
237
362
  return { verdict: null, error: "empty", cost };
238
363
  }
239
- const verdict = parseVerdict(output);
240
- if (!verdict) {
364
+ const parsed = parseVerdictDetailed(output);
365
+ if (!parsed) {
241
366
  // Scrubbed + capped so the local sink and Sentry can see the exact
242
367
  // failure shape without carrying a raw command or a secret it echoed.
243
368
  return {
@@ -247,7 +372,19 @@ export async function reviewCommand(command, deps) {
247
372
  rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
248
373
  };
249
374
  }
250
- return { verdict, cost };
375
+ if (parsed.repaired) {
376
+ // The repair ladder salvaged a broken-but-salvageable verdict. The
377
+ // verdict is real (validation is exactly as strict as the happy path);
378
+ // capture the pre-repair shape so telemetry can keep watching what the
379
+ // model is still emitting wrong.
380
+ return {
381
+ verdict: parsed.verdict,
382
+ cost,
383
+ repaired: true,
384
+ rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
385
+ };
386
+ }
387
+ return { verdict: parsed.verdict, cost };
251
388
  }
252
389
  catch (err) {
253
390
  // Distinguish the caller aborting (user hit ESC — must NOT be treated as
@@ -275,6 +412,7 @@ export function buildDiagnosticEvent(outcome, opts) {
275
412
  outcome,
276
413
  ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
277
414
  ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
415
+ ...(opts.repaired ? { repaired: true } : {}),
278
416
  ...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
279
417
  };
280
418
  if (opts.debug) {
@@ -183,6 +183,11 @@ Rationale rules:
183
183
  Output ONLY a JSON object with this exact shape:
184
184
  {"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
185
185
 
186
+ Your output must parse as strict JSON on the first try:
187
+ - Inside the rationale string, never write a bare double quote. Quote commands, flags, and paths with backticks instead: \`cat ~/.zshrc\`, not "cat ~/.zshrc".
188
+ - A backslash inside a JSON string is legal only as the two-character JSON escapes \\, \\n, \\t, or \\uXXXX, and a quote inside the rationale as \\". Never write regex-style escapes like \\d or \\w literally — write "digit" or "word character" in words instead.
189
+ - Output exactly one JSON object: no trailing comma, no doubled closing quote, no second object, nothing after the closing brace.
190
+
186
191
  Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
187
192
  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.
188
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.5-staging.1238.1",
3
+ "version": "1.0.5-staging.1240.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)",
@@ -42,5 +42,5 @@
42
42
  "turndown": "^7.2.4",
43
43
  "typebox": "^1.3.15"
44
44
  },
45
- "yagniSourceSha": "ee5c5072300f275c0b31d554fc6c2cce5f8c416a"
45
+ "yagniSourceSha": "24a0ebcde7f7df67fd0fcd230caa85c0c897742b"
46
46
  }