@yagni-app/code 0.2.1 → 0.3.0

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 (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -4
  59. package/dist/extension/rerouteNotice.js +20 -11
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -1,34 +1,40 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { appendFileSync, mkdirSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { Text } from "@earendil-works/pi-tui";
4
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
+ import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./approvedPrefixes.js";
7
+ import { redactCommand } from "./redact.js";
8
+ import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
5
9
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
6
- import { registerBoostCommand } from "./boostCommand.js";
7
10
  import { makeAskYagniTool } from "./askYagniTool.js";
11
+ import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
8
12
  import { makeReviewBusinessMatchTool } from "./reviewTool.js";
13
+ import { registerCmuxBridge } from "./cmux/index.js";
9
14
  import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
10
15
  import { makeRecordDecisionTool } from "./recordDecisionTool.js";
11
16
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
12
17
  import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER } from "./branding.js";
13
- import { appendClaudeRules, claudeRulesSection } from "./claudeRules.js";
18
+ import { claudeRulesSection } from "./claudeRules.js";
14
19
  import { registerCostCommand } from "./costHud.js";
15
20
  import { isDebug } from "./diagnostics.js";
16
21
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
17
22
  import { codeStateHome } from "./stateHome.js";
23
+ import { createYagniFooterFactory, formatCwd } from "./footer.js";
18
24
  import { RerouteNotifier } from "./rerouteNotice.js";
19
25
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
20
26
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
21
27
  import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
22
28
  import { registerGoCommand } from "./pipeline/goCommand.js";
23
29
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
24
- import { DEFAULT_PERMISSION_POLICY, registerPermissionGate } from "./permission.js";
30
+ import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
25
31
  import { registerSubagents } from "./subagents.js";
26
32
  import { registerTodos } from "./todos.js";
27
33
  import { registerDecisionCommands } from "./decisions.js";
28
34
  import { makeDecisionCapture } from "./decisionCapture.js";
29
35
  import { registerAmbientRecall } from "./recall.js";
30
36
  import { resilientFetch } from "./resilientFetch.js";
31
- import { installUncaughtExceptionMonitor } from "./crashReport.js";
37
+ import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
32
38
  import { flushSpool as defaultFlushSpool } from "./spool.js";
33
39
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
34
40
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
@@ -111,7 +117,26 @@ export async function registerYagni(pi, deps = {}) {
111
117
  if (!evalMode) {
112
118
  installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
113
119
  }
114
- const catalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
120
+ // YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
121
+ // Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
122
+ // so auth failures are visible in Sentry/backend logs. Gated on !evalMode
123
+ // like every other external side effect; injectable for tests.
124
+ const authReporter = deps.authReporter ??
125
+ (!evalMode
126
+ ? makeCrashReporter({ baseUrl, getToken: getTokenFn, fetchImpl: deps.fetchImpl, env: deps.env })
127
+ : async () => { });
128
+ // YAG-500 Fix A+C: the model-path 401 recovery outcome, set by the
129
+ // message_end handler so it can produce the right user-facing message. The
130
+ // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
131
+ // before onResponse is reached), so message_end is the only seam.
132
+ let lastAuthRecovery = null;
133
+ const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
134
+ // Lock the interactive session to the `advanced` tier only. The backend
135
+ // catalog returns all tiers, but only `advanced` is registered with the
136
+ // `yagni` provider, so /model and Ctrl+P show a single entry. Child
137
+ // processes (/go, subagents, advisor) fetch their own catalog and register
138
+ // their own provider, so they are unaffected by this filter.
139
+ const catalog = fullCatalog.filter((m) => m.id === "advanced");
115
140
  // YAG-471: the driver's own completions carry attribution headers read from
116
141
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
117
142
  // defaults to "driver" when unset, i.e. every session that is not a /go
@@ -119,11 +144,17 @@ export async function registerYagni(pi, deps = {}) {
119
144
  pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
120
145
  const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
121
146
  pi.registerTool(makeAskYagniTool(toolOpts));
122
- // The peak-tier escalation for Balanced sessions (YAG-380). Registered
147
+ // Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
148
+ // workspace tracker, attributed to the developer via per-user credentials.
149
+ if (!evalMode) {
150
+ pi.registerTool(makeFileTicketTool(toolOpts));
151
+ pi.registerTool(makeUpdateTicketStatusTool(toolOpts));
152
+ }
153
+ // The peak-tier escalation for Advanced sessions (YAG-380). Registered
123
154
  // UNCONDITIONALLY and gated at execute time on the live session model: pi's
124
155
  // picker can switch the model after activation, so a registration-time tier
125
- // check would both leave the tool live after switching away from Balanced and
126
- // hide it forever from a session that switched TO Balanced. One state handle
156
+ // check would both leave the tool live after switching away from Advanced and
157
+ // hide it forever from a session that switched TO Advanced. One state handle
127
158
  // per session, shared with /advise so they draw on the same cap.
128
159
  const advisorState = makeAdvisorState();
129
160
  const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
@@ -131,12 +162,6 @@ export async function registerYagni(pi, deps = {}) {
131
162
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
132
163
  // draws on the same cap rather than opening a side channel around it.
133
164
  registerAdviseCommand(pi, askAdvisorTool);
134
- // /boost — the sanctioned session-scoped escalation to Peak (spec §7,
135
- // YAG-380 follow-on). Unlike /advise's per-call consult, this flips the
136
- // DRIVER's own live model to peak until /boost off or the session ends.
137
- // The returned handle threads isBoosted() into /cost below, the same way
138
- // advisorState threads into advisorSubtotal.
139
- const boostCommand = registerBoostCommand(pi, { env });
140
165
  // The differentiated business-grounded tools (loop bricks): review a change
141
166
  // for business fit, rank the next work by business priority, and record the
142
167
  // engineering rationale back onto the work-item.
@@ -212,7 +237,98 @@ export async function registerYagni(pi, deps = {}) {
212
237
  // drafts a decision on "don't ask again". Default auto, so still additive.
213
238
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
214
239
  // holds them, review mode confirms them.
240
+ const modeHolder = createModeHolder();
241
+ const guardianState = makeGuardianState();
242
+ // Disabled by the local env override OR the workspace kill switch
243
+ // (yagni_code.guardian, read from the catalog response at launch). The env
244
+ // var wins for a single developer's debugging; the flag turns it off for
245
+ // every session in the workspace.
246
+ const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" ||
247
+ env.YAGNI_DISABLE_GUARDIAN === "true" ||
248
+ !workspaceGuardianEnabled;
249
+ const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
250
+ const guardianLimits = resolveGuardianLimits(env);
251
+ // YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
252
+ // never the command). The remote guardian-events stream below is the
253
+ // separate, opt-in, per-workspace analytics sink; the two are independent.
254
+ const guardianLogSink = (payload) => {
255
+ try {
256
+ if (process.env.NODE_TEST_CONTEXT)
257
+ return;
258
+ const logPath = join(codeStateHome(null), "logs", "guardian.log");
259
+ mkdirSync(dirname(logPath), { recursive: true });
260
+ appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
261
+ }
262
+ catch { /* logging must never break the session */ }
263
+ };
264
+ // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
265
+ // at startup (grants added by other concurrent sessions appear next launch).
266
+ const sessionGrants = evalMode ? [] : loadGrants();
267
+ const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
215
268
  registerPermissionGate(pi, {
269
+ modeHolder,
270
+ guardianState,
271
+ guardianLimits,
272
+ guardianTier,
273
+ guardianDisabled,
274
+ guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
275
+ onGuardianReview: (ev) => guardianLogSink(ev),
276
+ grants: sessionGrants,
277
+ resolveRepoKey,
278
+ persistGrant: (grant) => {
279
+ if (!evalMode)
280
+ appendGrant(grant);
281
+ },
282
+ // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
283
+ // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
284
+ // metadata, no command content; "raw" → adds client-REDACTED command and
285
+ // rationale. Fire-and-forget: one attempt, short timeout, failures logged
286
+ // fail-soft to guardian.log — a storage outage never touches the session.
287
+ onGuardianEvent: guardianStorageTier === "off" || evalMode
288
+ ? undefined
289
+ : (ev) => {
290
+ void (async () => {
291
+ try {
292
+ const body = {
293
+ sessionId: env.YAGNI_SESSION_ID ?? null,
294
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
295
+ commandPrefix: storagePrefix(ev.command),
296
+ outcome: ev.outcome,
297
+ mode: ev.mode,
298
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
299
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
300
+ ...(ev.tier ? { tier: ev.tier } : {}),
301
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
302
+ };
303
+ if (guardianStorageTier === "raw") {
304
+ body.command = redactCommand(ev.command);
305
+ if (ev.rationale)
306
+ body.rationale = redactCommand(ev.rationale);
307
+ }
308
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
309
+ method: "POST",
310
+ headers: {
311
+ "content-type": "application/json",
312
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
313
+ ...attributionHeaders(deps.env),
314
+ },
315
+ body: JSON.stringify(body),
316
+ }, {
317
+ fetchImpl: authedFetch,
318
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
319
+ });
320
+ if (!res.ok) {
321
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
322
+ }
323
+ }
324
+ catch (err) {
325
+ guardianLogSink({
326
+ event: "guardian_event_post_failed",
327
+ error: err instanceof Error ? err.message : "unknown",
328
+ });
329
+ }
330
+ })();
331
+ },
216
332
  ...(mcpMutatingTools.length > 0
217
333
  ? {
218
334
  policy: {
@@ -224,6 +340,7 @@ export async function registerYagni(pi, deps = {}) {
224
340
  ...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
225
341
  ...mcpMutatingTools,
226
342
  ],
343
+ alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
227
344
  },
228
345
  }
229
346
  : {}),
@@ -247,12 +364,11 @@ export async function registerYagni(pi, deps = {}) {
247
364
  // turn_end accumulator. Thread the subtotal in explicitly for the local
248
365
  // fallback line; the server-authoritative line already counts advisor
249
366
  // spend as an ordinary caller row.
250
- advisorSubtotal: () => formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS),
251
- // YAG-380 follow-on: a boosted session that only chats never produces a
252
- // server-side boosted row (see boostCommand.ts's KNOWN-asymmetry
253
- // docblock), so /cost needs the live client-side toggle on top of
254
- // whatever the server rows show.
255
- isBoosted: () => boostCommand.isBoosted(),
367
+ advisorSubtotal: () => {
368
+ const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
369
+ const guardian = formatGuardianSubtotal(guardianState.read(), guardianLimits);
370
+ return [advisor, guardian].filter(Boolean).join(" ");
371
+ },
256
372
  fetchHeadroom: async (signal) => {
257
373
  try {
258
374
  const res = await resilientFetch(`${baseUrl}/api/yagni-code/credits`, { method: "GET", headers: { authorization: `Bearer ${getTokenFn() ?? ""}` } }, { fetchImpl: authedFetch, signal, policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: COST_FETCH_TIMEOUT_MS, jitterRatio: 0 } });
@@ -405,7 +521,11 @@ export async function registerYagni(pi, deps = {}) {
405
521
  pi.on("before_agent_start", (event) => noBranding
406
522
  ? undefined
407
523
  : {
408
- systemPrompt: appendClaudeRules(brandSystemPrompt(event.systemPrompt, { contextBrief, identity }), rulesSection),
524
+ systemPrompt: brandSystemPrompt(event.systemPrompt, {
525
+ contextBrief,
526
+ identity,
527
+ rulesSection,
528
+ }),
409
529
  });
410
530
  // Two finalized-message guards share this handler (their conditions are
411
531
  // mutually exclusive: YAG-460 takes error-stopped messages, YAG-466 takes
@@ -425,7 +545,7 @@ export async function registerYagni(pi, deps = {}) {
425
545
  // renders. The message text deliberately matches neither pi's overflow nor
426
546
  // retryable-error patterns: a deterministic empty response should not burn
427
547
  // auto-retries or trigger compaction — the user decides what to do next.
428
- pi.on("message_end", (event, ctx) => {
548
+ pi.on("message_end", async (event, ctx) => {
429
549
  const msg = event.message;
430
550
  if (msg.role !== "assistant")
431
551
  return;
@@ -440,6 +560,71 @@ export async function registerYagni(pi, deps = {}) {
440
560
  // post-replacement message, and dropping the marker would defeat the very
441
561
  // recovery this error exists to trigger.
442
562
  if (msg.stopReason === "error" && msg.errorMessage) {
563
+ // YAG-500: a 401 from the model proxy means the session token expired
564
+ // (or was revoked). Unlike tool 401s (handled by makeAuthedFetch), the
565
+ // model completion path has no 401-retry seam — pi's retryProviderRequest
566
+ // treats 401 as non-retryable, and after_provider_response never fires
567
+ // (the SDK throws before onResponse is reached). So message_end is the
568
+ // only place to detect it and trigger recovery. The regex matches
569
+ // "yagni login" (the backend's auth-error message) but NOT
570
+ // "request_too_large" (YAG-460's overflow marker).
571
+ const isAuthError = /yagni login/i.test(msg.errorMessage)
572
+ && !/request_too_large|context_too_large/i.test(msg.errorMessage);
573
+ if (isAuthError) {
574
+ lastAuthRecovery = null;
575
+ let rotated = false;
576
+ try {
577
+ rotated = await tokenProvider.refresh();
578
+ }
579
+ catch {
580
+ rotated = false;
581
+ }
582
+ lastAuthRecovery = rotated ? "refreshed" : "failed";
583
+ const explanation = rotated
584
+ ? "Your session token expired but was refreshed automatically. Re-send your prompt to continue."
585
+ : "Your session token expired and could not be refreshed. Run `yagni login`, then re-send your prompt. If the issue persists, restart YAGNI Code.";
586
+ if (ctx.hasUI) {
587
+ try {
588
+ ctx.ui.notify(explanation, rotated ? "info" : "error");
589
+ }
590
+ catch {
591
+ // Surfacing the problem must never break the session itself.
592
+ }
593
+ }
594
+ // YAG-500 Fix E: fire a non-fatal crash report so auth failures are
595
+ // visible in Sentry/backend logs. Reuses the crash endpoint with a
596
+ // distinct context. Fire-and-forget, fail-soft. The crash endpoint is
597
+ // now public (no token required), so the report lands even when the
598
+ // session token is expired and refresh failed — the most critical
599
+ // failure signal is no longer silently dropped.
600
+ void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
601
+ // YAG-500 Fix F: local diagnostics log under YAGNI_DEBUG.
602
+ if (isDebug(env)) {
603
+ try {
604
+ const logPath = join(codeStateHome(null, env), "logs", "auth-events.log");
605
+ mkdirSync(dirname(logPath), { recursive: true });
606
+ appendFileSync(logPath, JSON.stringify({
607
+ ts: new Date().toISOString(),
608
+ status: 401,
609
+ refresh: rotated ? "succeeded" : "failed",
610
+ }) + "\n", "utf8");
611
+ }
612
+ catch {
613
+ // A diagnostic must never break the session.
614
+ }
615
+ }
616
+ return { message: { ...msg, errorMessage: explanation } };
617
+ }
618
+ // YAG-460: the backend proxy answers an oversized conversation with an
619
+ // OpenAI-format 413 whose type is `request_too_large`. pi's own overflow
620
+ // detection matches that marker and runs full recovery — compact, then
621
+ // auto-retry the failed turn — so this branch must NOT call ctx.compact()
622
+ // (it would race the built-in recovery and lose the retry). Its only job
623
+ // is UX: replace the raw `413: {"error":{...}}` JSON with a readable
624
+ // message and tell the user what is happening. The rewritten text KEEPS
625
+ // the `request_too_large` marker verbatim: pi's _checkCompaction reads the
626
+ // post-replacement message, and dropping the marker would defeat the very
627
+ // recovery this error exists to trigger.
443
628
  const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
444
629
  if (!isContextTooLarge)
445
630
  return;
@@ -486,7 +671,39 @@ export async function registerYagni(pi, deps = {}) {
486
671
  ctx.ui?.setStatus?.("brand", BRAND_NAME);
487
672
  // setHeader replaces the built-in header in place (verified against pi
488
673
  // 0.80.2 setExtensionHeader); the factory returns a simple Text component.
489
- ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme)));
674
+ // Pass the launcher-forwarded CLI version (YAGNI_CODE_VERSION) and the
675
+ // home-collapsed cwd so the masthead shows them like the reference.
676
+ const mastheadVersion = process.env.YAGNI_CODE_VERSION?.trim() || undefined;
677
+ const mastheadCwd = formatCwd(process.cwd(), process.env.HOME);
678
+ ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme, { version: mastheadVersion, cwd: mastheadCwd })));
679
+ // Replace the built-in footer with the YAGNI status bar: folder +
680
+ // [worktree] + branch on line 1, model + token/cost stats + integer
681
+ // context % on line 2, and extension statuses (brand, todos, mode) on
682
+ // line 3. The factory captures ctx so the footer can read session data
683
+ // (token stats, context usage) that isn't on the footerData provider.
684
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder)(tui, theme, footerData));
685
+ // Label the collapsed chain-of-thought line so users know it is reasoning
686
+ // and how to reveal the full trace. Harmless when reasoning is expanded
687
+ // (the label only shows on hidden thinking blocks). Fails closed to pi's
688
+ // default "Thinking..." label.
689
+ try {
690
+ ctx.ui?.setHiddenThinkingLabel?.("Reasoning — Ctrl+T to expand");
691
+ }
692
+ catch {
693
+ // A label must never disrupt session start.
694
+ }
695
+ // One-time hint naming what changed now that reasoning is collapsed by
696
+ // default (option 2). Gated on the launcher's YAGNI_HIDE_THINKING_SEEDED
697
+ // marker so it fires only on the launch that actually seeded the key — it
698
+ // never re-nags on subsequent runs, even though the label above persists.
699
+ if (env.YAGNI_HIDE_THINKING_SEEDED === "1") {
700
+ try {
701
+ ctx.ui?.notify("Reasoning is collapsed by default — Ctrl+T shows the full trace at any time.", "info");
702
+ }
703
+ catch {
704
+ // A hint must never disrupt session start.
705
+ }
706
+ }
490
707
  }
491
708
  // Best-effort, once at session start: if the token is at or near expiry, say
492
709
  // so via a single notice so a long session does not silently start 401-ing
@@ -542,10 +759,18 @@ export async function registerYagni(pi, deps = {}) {
542
759
  }
543
760
  export default async function (pi) {
544
761
  await registerYagni(pi);
762
+ // cmux session bridge: report lifecycle state (running/idle), workspace
763
+ // naming, and resume bindings to cmux when running inside it. Complete
764
+ // no-op outside cmux — guarded by CMUX_SURFACE_ID. Registered in the
765
+ // default export (not registerYagni) so tests that call registerYagni
766
+ // directly don't get the bridge's handlers.
767
+ registerCmuxBridge(pi);
545
768
  }
546
769
  export { makeAskYagniTool } from "./askYagniTool.js";
770
+ export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
547
771
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
548
772
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
773
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
549
774
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
550
775
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
551
776
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
@@ -576,7 +801,8 @@ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkd
576
801
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
577
802
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
578
803
  // the session bless-with-remember capture hook.
579
- export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
804
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
805
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
580
806
  // W4 judgment loop: the session bless store (tool + path-prefix, session-only).
581
807
  export { makeBlessStore, blessPath } from "./bless.js";
582
808
  // W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
@@ -4,7 +4,8 @@
4
4
  * YAGNI Code registers no tool_call handler today, so the interactive session has
5
5
  * no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
6
6
  * plus a `/mode` command:
7
- * - auto (default): never blocks. Byte-identical to today, so this is additive.
7
+ * - auto (default): ordinary coding tools run directly; external tracker
8
+ * changes still require fresh human confirmation.
8
9
  * - plan : blocks write/edit/bash so the agent can explore + propose without
9
10
  * touching the tree.
10
11
  * - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
@@ -14,7 +15,7 @@
14
15
  * adds a session-scoped bless rule AND drafts a decision capture.
15
16
  *
16
17
  * `decideGate` is PURE; the live wiring holds the mode in a small closure (no
17
- * module-global state). The default auto mode remains fail-open, but stricter
18
+ * module-global state). The default auto mode remains direct for coding tools, but stricter
18
19
  * modes fail closed for side-effect tools if the gate itself errors. Bless rules
19
20
  * are session-scoped, path-prefix-bound, never persisted, and never consulted in
20
21
  * plan mode (plan blocks outright before isBlessed is reached).
@@ -26,20 +27,42 @@
26
27
  * the context so the model doesn't keep believing it is restricted.
27
28
  */
28
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
+ import { type ApprovedPrefixGrant } from "./approvedPrefixes.js";
29
31
  import { type BlessStore } from "./bless.js";
32
+ import { type ExecPolicy } from "./execPolicy.js";
33
+ import { type GuardianError, type GuardianRiskLevel } from "./guardian.js";
30
34
  export type PermissionMode = "auto" | "plan" | "review";
35
+ /**
36
+ * A shared, mutable holder for the current permission mode. Both
37
+ * `registerPermissionGate` (which writes on /mode) and the footer factory
38
+ * (which reads on every render) hold a reference, so mode changes appear
39
+ * immediately in the footer's line 2.
40
+ */
41
+ export interface ModeHolder {
42
+ get(): PermissionMode;
43
+ set(m: PermissionMode): void;
44
+ }
45
+ export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
31
46
  /** Which tools each tier acts on, plus the optional grounding-bless predicate. */
32
47
  export interface PermissionPolicy {
33
48
  /** Tools blocked outright in plan mode (write/exec). */
34
49
  planBlockTools: string[];
35
50
  /** Tools that prompt for confirmation in review mode. */
36
51
  reviewConfirmTools: string[];
52
+ /** Consequential external writes that require fresh consent in every mode. */
53
+ alwaysConfirmTools?: string[];
37
54
  /**
38
55
  * Optional: a recorded decision already blesses this action, so it auto-runs in
39
56
  * review mode instead of prompting. The hook for tying the gate to captured
40
57
  * judgment; default undefined (nothing pre-blessed).
41
58
  */
42
59
  isBlessed?: (toolName: string, params: Record<string, unknown>) => boolean;
60
+ /**
61
+ * Exec policy for bash command classification. When present, bash commands are
62
+ * classified by the exec policy engine before the tool-granular logic runs.
63
+ * Defaults to DEFAULT_EXEC_POLICY when absent.
64
+ */
65
+ execPolicy?: ExecPolicy;
43
66
  }
44
67
  export declare const DEFAULT_PERMISSION_POLICY: PermissionPolicy;
45
68
  /** A pure gate verdict: block outright, ask to confirm, or allow. */
@@ -48,13 +71,55 @@ export interface GateDecision {
48
71
  reason?: string;
49
72
  /** review mode only: the caller should ctx.ui.confirm before allowing. */
50
73
  confirm?: boolean;
74
+ /**
75
+ * Exec policy classification result for bash commands. When present, the
76
+ * tool_call handler should run the Guardian for "prompt" before falling
77
+ * through to the user confirm. Absent for non-bash tools or when no
78
+ * command string is available.
79
+ */
80
+ classify?: "allow" | "prompt" | "forbidden";
81
+ /**
82
+ * The exec policy's justification for a "prompt" classification — why the
83
+ * command was routed to the Guardian. Threaded into the consult prompt and
84
+ * the storage event (it is the layer-tuning signal, YAG-510).
85
+ */
86
+ classifyJustification?: string;
51
87
  }
52
88
  /**
53
- * Pure permission decision for one tool call under a mode + policy. auto always
54
- * allows; plan blocks the write/exec set; review marks writes for confirmation
89
+ * Pure permission decision for one tool call under a mode + policy. Auto allows
90
+ * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
55
91
  * unless a recorded decision blesses them.
56
92
  */
57
93
  export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
94
+ /**
95
+ * Terminal outcome of one prompt-band decision (YAG-510). One storage event
96
+ * is emitted per terminal outcome; `consulted` says whether a Guardian LLM
97
+ * call actually happened (grants/cache hits skip it).
98
+ */
99
+ export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | GuardianError;
100
+ /**
101
+ * Rich per-decision event for opt-in storage (YAG-510). Carries the RAW
102
+ * command — the wiring layer (index.ts) hashes/redacts per the workspace's
103
+ * storage tier before anything leaves the machine. Distinct from the
104
+ * sanitized GuardianDiagnosticEvent (local debug log), which never carries
105
+ * the command.
106
+ */
107
+ export interface GuardianGateEvent {
108
+ command: string;
109
+ outcome: GuardianGateOutcome;
110
+ /** Why the exec policy routed this command to the Guardian. */
111
+ execJustification?: string;
112
+ riskLevel?: GuardianRiskLevel;
113
+ rationale?: string;
114
+ mode: PermissionMode;
115
+ tier?: string;
116
+ durationMs?: number;
117
+ /** Whether a Guardian LLM consult actually ran for this decision. */
118
+ consulted: boolean;
119
+ /** Present when the terminal outcome followed a Guardian error (e.g. an
120
+ * error-fallback ask that the user then approved). */
121
+ guardianError?: GuardianError;
122
+ }
58
123
  /** What was blessed with "don't ask again", handed to the capture hook. */
59
124
  export interface BlessRememberInfo {
60
125
  tool: string;
@@ -73,16 +138,64 @@ export interface RegisterPermissionDeps {
73
138
  * never blocks the approved tool call.
74
139
  */
75
140
  onBlessRemember?: (ctx: ExtensionContext, info: BlessRememberInfo) => void | Promise<void>;
141
+ /** Shared holder so the footer can read the live mode on every render. */
142
+ modeHolder?: ModeHolder;
143
+ /**
144
+ * Guardian state handle for the session. When present, prompt-band bash
145
+ * commands are auto-reviewed by the Guardian LLM instead of interrupting
146
+ * the user. When absent, prompt-band commands fall through to the existing
147
+ * tool-granular behavior (review: user prompt, auto: allow).
148
+ */
149
+ guardianState?: import("./guardian.js").GuardianStateHandle;
150
+ /** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
151
+ guardianLimits?: import("./guardian.js").GuardianLimits;
152
+ /** Override the Guardian model tier (default: efficient). */
153
+ guardianTier?: string;
154
+ /** Injectable Guardian review function (tests pass a stub). */
155
+ guardianReview?: (command: string, deps: import("./guardian.js").ReviewCommandDeps) => Promise<import("./guardian.js").ReviewResult>;
156
+ /**
157
+ * Called (fire-and-forget) after each Guardian review with a sanitized
158
+ * diagnostic event. Fail-soft; never blocks.
159
+ */
160
+ onGuardianReview?: (event: import("./guardian.js").GuardianDiagnosticEvent) => void;
161
+ /** Disable the Guardian entirely (env var YAGNI_DISABLE_GUARDIAN). */
162
+ guardianDisabled?: boolean;
163
+ /**
164
+ * Persisted "don't ask again" grants for this session's repo (YAG-510).
165
+ * Consulted ONLY after the exec policy classified a command as "prompt" —
166
+ * a grant can never override the forbidden band. Suppressed in review mode.
167
+ */
168
+ grants?: readonly ApprovedPrefixGrant[];
169
+ /** Resolve the grant scope key for a cwd (git remote origin URL fallback
170
+ * realpath). Lazily invoked on the first prompt-band bash call. */
171
+ resolveRepoKey?: (cwd: string) => string;
172
+ /** Persist a new grant (fire-and-forget; the in-memory list is updated
173
+ * either way). index.ts wires approvedPrefixes.appendGrant. */
174
+ persistGrant?: (grant: ApprovedPrefixGrant) => void;
175
+ /**
176
+ * Called (fire-and-forget) at every terminal prompt-band outcome with the
177
+ * rich storage event (raw command — the wiring layer redacts/hashes).
178
+ * Fail-soft; never blocks.
179
+ */
180
+ onGuardianEvent?: (event: GuardianGateEvent) => void;
76
181
  }
77
- /** The customType tag on injected plan-mode context (filterable later). */
78
- export declare const PLAN_CONTEXT_TYPE = "yagni-plan-context";
182
+ /** The customType tag on injected mode-context messages (filterable later). */
183
+ export declare const MODE_CONTEXT_TYPE = "yagni-mode-context";
184
+ /** Legacy alias — the original plan-mode tag, kept for backward compat. */
185
+ export declare const PLAN_CONTEXT_TYPE = "yagni-mode-context";
79
186
  export declare const PLAN_CONTEXT_MESSAGE = "[PLAN MODE ACTIVE]\nYou are in plan mode: explore and design, change nothing.\n- write, edit, and bash are held by the permission gate; do not attempt them.\n- Read, search, and ask_yagni freely to ground the plan in how this company works.\n- Produce a concrete numbered plan of the steps you would take, with the files involved.\n- End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.\n- Once executing, track the plan's steps with todo_write.";
187
+ /** Build the mode-awareness context message for the current permission mode. */
188
+ export declare function buildModeContextMessage(mode: PermissionMode): string;
80
189
  /**
81
- * Drop previously injected plan-mode context once plan mode is off, so the
82
- * model stops believing writes are held. Pure; returns the SAME array when
83
- * nothing needs filtering so callers can cheaply detect a no-op.
190
+ * Drop previously injected mode-context messages from a DIFFERENT mode so the
191
+ * model does not keep believing it is in a prior mode. Messages matching the
192
+ * current mode are kept (the fresh injection from before_agent_start should
193
+ * survive). Pure; returns the SAME array when nothing needs filtering so callers
194
+ * can cheaply detect a no-op.
84
195
  */
85
- export declare function filterStalePlanContext<T>(messages: T[]): T[];
196
+ export declare function filterStaleModeContext<T>(messages: T[], currentMode?: PermissionMode): T[];
197
+ /** Legacy alias — the original plan-mode filter name. */
198
+ export declare const filterStalePlanContext: typeof filterStaleModeContext;
86
199
  /**
87
200
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
88
201
  * auto, so absent any /mode this is a no-op over today's behavior.