@yagni-app/code 1.0.5 → 1.0.6

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.
Files changed (50) hide show
  1. package/README.md +30 -6
  2. package/dist/claudePlugins.d.ts +3 -1
  3. package/dist/claudePlugins.js +3 -1
  4. package/dist/cli.js +12 -0
  5. package/dist/doctor.d.ts +28 -3
  6. package/dist/doctor.js +117 -7
  7. package/dist/extension/index.d.ts +5 -5
  8. package/dist/extension/index.js +89 -29
  9. package/dist/extension/mcp/approval.d.ts +45 -0
  10. package/dist/extension/mcp/approval.js +164 -0
  11. package/dist/extension/mcp/auth.d.ts +124 -0
  12. package/dist/extension/mcp/auth.js +560 -0
  13. package/dist/extension/mcp/authStore.d.ts +61 -0
  14. package/dist/extension/mcp/authStore.js +105 -0
  15. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  16. package/dist/extension/mcp/callbackPage.js +222 -0
  17. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  18. package/dist/extension/mcp/cliConfig.js +12 -0
  19. package/dist/extension/mcp/config.d.ts +131 -0
  20. package/dist/extension/mcp/config.js +309 -0
  21. package/dist/extension/mcp/log.d.ts +28 -0
  22. package/dist/extension/mcp/log.js +82 -0
  23. package/dist/extension/mcp/manager.d.ts +98 -0
  24. package/dist/extension/mcp/manager.js +273 -0
  25. package/dist/extension/mcp/names.d.ts +25 -0
  26. package/dist/extension/mcp/names.js +40 -0
  27. package/dist/extension/mcp/panel.d.ts +34 -0
  28. package/dist/extension/mcp/panel.js +258 -0
  29. package/dist/extension/mcp/prompts.d.ts +23 -0
  30. package/dist/extension/mcp/prompts.js +93 -0
  31. package/dist/extension/mcp/startup.d.ts +55 -0
  32. package/dist/extension/mcp/startup.js +150 -0
  33. package/dist/extension/mcp/tools.d.ts +31 -0
  34. package/dist/extension/mcp/tools.js +117 -0
  35. package/dist/extension/mcp/transports.d.ts +17 -0
  36. package/dist/extension/mcp/transports.js +44 -0
  37. package/dist/extension/permission/gate.d.ts +7 -0
  38. package/dist/extension/permission/gate.js +12 -5
  39. package/dist/extension/permission/guardian.d.ts +24 -5
  40. package/dist/extension/permission/guardian.js +162 -24
  41. package/dist/extension/pipeline/personas.js +5 -0
  42. package/dist/mcpCommand.d.ts +113 -0
  43. package/dist/mcpCommand.js +755 -0
  44. package/dist/otel.d.ts +36 -7
  45. package/dist/otel.js +90 -12
  46. package/dist/upgrade.d.ts +11 -2
  47. package/dist/upgrade.js +48 -8
  48. package/package.json +3 -2
  49. package/dist/extension/mcpTools.d.ts +0 -57
  50. package/dist/extension/mcpTools.js +0 -132
@@ -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
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `yagni mcp …` — configure MCP servers for YAGNI Code, mirroring Claude
3
+ * Code's CLI surface (add / add-json / remove / get / list /
4
+ * reset-project-choices / add-from-claude).
5
+ *
6
+ * The config module itself lives in pi-extension-yagni (`src/mcp/config.ts`)
7
+ * so the extension session and this CLI read and write the exact same files
8
+ * through the exact same code — loaded here by file path, the same pattern as
9
+ * the session-worktree and headless-go entries. `list` and `get` run real
10
+ * health-check connections (stdio servers are spawned), so they carry the
11
+ * same caution Claude Code prints: only run in directories you trust.
12
+ *
13
+ * Not implemented (deliberate deviations from Claude Code):
14
+ * serve — the YAGNI backend already exposes an MCP endpoint
15
+ * login — enterprise-IdP only in Claude Code; not applicable
16
+ */
17
+ export interface McpDeps {
18
+ cwd?: string;
19
+ home?: string;
20
+ env?: NodeJS.ProcessEnv;
21
+ stdout?: (text: string) => void;
22
+ stderr?: (text: string) => void;
23
+ /** Test seam: the extension module to import (defaults to the built copy). */
24
+ loadMcpModule?: () => Promise<McpCliModule>;
25
+ /** Test seam: health probe override (defaults to mod.probeServer). */
26
+ probeServer?: (name: string, config: unknown, opts?: {
27
+ env?: NodeJS.ProcessEnv;
28
+ }) => Promise<{
29
+ status: "connected" | "needs_auth" | "failed";
30
+ error?: string;
31
+ }>;
32
+ }
33
+ /** The structural slice of the extension's mcp config surface this file needs. */
34
+ export interface McpCliModule {
35
+ loadMcpServers(cwd: string, env: NodeJS.ProcessEnv): McpLoadResult;
36
+ mcpConfigPath(): string;
37
+ PROJECT_CONFIG_FILENAME: string;
38
+ writeUserMcpConfig(file: unknown): void;
39
+ readUserMcpConfig(): {
40
+ file: unknown;
41
+ errors: unknown[];
42
+ };
43
+ resolveProjectRoot(cwd: string): string;
44
+ resetProjectChoices(repoRoot: string): {
45
+ errors: unknown[];
46
+ };
47
+ decisionFor(state: unknown, serverName: string): "enabled" | "disabled" | "undecided";
48
+ readProjectApproval(repoRoot: string): {
49
+ state: unknown;
50
+ errors: unknown[];
51
+ };
52
+ updateStoredOAuthEntry(serverName: string, config: unknown, mutate: (entry: {
53
+ clientSecret?: string;
54
+ }) => void): {
55
+ errors: unknown[];
56
+ };
57
+ revokeTokensOnRemove(serverName: string, config: unknown, deps?: {
58
+ fetch?: typeof fetch;
59
+ }): Promise<void>;
60
+ getStoredOAuthEntry(serverName: string, config: unknown): {
61
+ clientSecret?: string;
62
+ } | undefined;
63
+ probeServer(name: string, config: unknown, opts?: {
64
+ connectTimeoutMs?: number;
65
+ env?: NodeJS.ProcessEnv;
66
+ }): Promise<{
67
+ status: "connected" | "needs_auth" | "failed";
68
+ error?: string;
69
+ }>;
70
+ /** ${VAR} expansion over a full server config (url, headers, command, args, env values). */
71
+ expandServerEnv?(config: unknown, env?: NodeJS.ProcessEnv): {
72
+ config: unknown;
73
+ missingVars: string[];
74
+ };
75
+ }
76
+ export interface McpLoadResult {
77
+ servers: {
78
+ name: string;
79
+ scope: "user" | "project" | "local";
80
+ config: Record<string, unknown>;
81
+ sourcePath: string;
82
+ }[];
83
+ errors: {
84
+ sourcePath: string;
85
+ serverName?: string;
86
+ message: string;
87
+ }[];
88
+ }
89
+ export declare function resolveMcpConfigPath(): string;
90
+ export interface ParsedMcpArgs {
91
+ subcommand: string | undefined;
92
+ name?: string;
93
+ rest: string[];
94
+ scope: "local" | "user" | "project";
95
+ /** Whether -s/--scope appeared explicitly (remove without it acts across scopes). */
96
+ scopeExplicit: boolean;
97
+ transport: "stdio" | "sse" | "http";
98
+ /** Whether -t/--transport appeared explicitly (drives the URL-misread warning). */
99
+ transportExplicit: boolean;
100
+ env: Record<string, string>;
101
+ headers: Record<string, string>;
102
+ /** Positionals after the subcommand's own first two (stdio command args). */
103
+ commandArgs: string[];
104
+ /** OAuth client id (-only). */
105
+ clientId?: string;
106
+ /** Whether --client-secret was passed (prompt/env-read the secret). */
107
+ clientSecret: boolean;
108
+ /** Fixed loopback callback port. */
109
+ callbackPort?: number;
110
+ }
111
+ export declare function parseMcpArgs(argv: string[]): ParsedMcpArgs;
112
+ export declare function mcpCommand(args: string[], deps?: McpDeps): Promise<number>;
113
+ //# sourceMappingURL=mcpCommand.d.ts.map