@yagni-app/code 0.2.1 → 0.3.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.
Files changed (98) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +147 -3
  3. package/dist/connectClaudeCode.d.ts +77 -0
  4. package/dist/connectClaudeCode.js +228 -0
  5. package/dist/connectCodex.d.ts +75 -0
  6. package/dist/connectCodex.js +201 -0
  7. package/dist/doctor.d.ts +1 -1
  8. package/dist/doctor.js +1 -1
  9. package/dist/extension/advisor.d.ts +4 -4
  10. package/dist/extension/advisor.js +6 -7
  11. package/dist/extension/approvedPrefixes.d.ts +103 -0
  12. package/dist/extension/approvedPrefixes.js +282 -0
  13. package/dist/extension/askAdvisorTool.d.ts +20 -5
  14. package/dist/extension/askAdvisorTool.js +126 -20
  15. package/dist/extension/askYagniTool.d.ts +23 -0
  16. package/dist/extension/askYagniTool.js +91 -2
  17. package/dist/extension/branding.d.ts +34 -3
  18. package/dist/extension/branding.js +114 -13
  19. package/dist/extension/chipEditor.d.ts +30 -9
  20. package/dist/extension/chipEditor.js +173 -59
  21. package/dist/extension/claudeRules.d.ts +0 -2
  22. package/dist/extension/claudeRules.js +0 -8
  23. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  24. package/dist/extension/cmux/dispatcher.js +266 -0
  25. package/dist/extension/cmux/hooks.d.ts +12 -0
  26. package/dist/extension/cmux/hooks.js +192 -0
  27. package/dist/extension/cmux/index.d.ts +3 -0
  28. package/dist/extension/cmux/index.js +155 -0
  29. package/dist/extension/cmux/naming.d.ts +5 -0
  30. package/dist/extension/cmux/naming.js +23 -0
  31. package/dist/extension/cmux/state.d.ts +33 -0
  32. package/dist/extension/cmux/state.js +142 -0
  33. package/dist/extension/config.d.ts +44 -1
  34. package/dist/extension/config.js +37 -4
  35. package/dist/extension/costHud.d.ts +16 -22
  36. package/dist/extension/costHud.js +8 -47
  37. package/dist/extension/crashReport.js +1 -3
  38. package/dist/extension/execPolicy.d.ts +135 -0
  39. package/dist/extension/execPolicy.js +936 -0
  40. package/dist/extension/flywheel.d.ts +44 -0
  41. package/dist/extension/flywheel.js +53 -0
  42. package/dist/extension/footer.d.ts +118 -0
  43. package/dist/extension/footer.js +308 -0
  44. package/dist/extension/guardian.d.ts +139 -0
  45. package/dist/extension/guardian.js +237 -0
  46. package/dist/extension/index.d.ts +35 -7
  47. package/dist/extension/index.js +338 -33
  48. package/dist/extension/mineBeat.d.ts +95 -0
  49. package/dist/extension/mineBeat.js +193 -0
  50. package/dist/extension/permission.d.ts +124 -10
  51. package/dist/extension/permission.js +603 -52
  52. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  53. package/dist/extension/pipeline/childRegistry.js +118 -0
  54. package/dist/extension/pipeline/finish.js +5 -1
  55. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  56. package/dist/extension/pipeline/goCommand.js +40 -9
  57. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  58. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  59. package/dist/extension/pipeline/personas.js +26 -1
  60. package/dist/extension/pipeline/resilience.d.ts +2 -1
  61. package/dist/extension/pipeline/resilience.js +21 -2
  62. package/dist/extension/pipeline/runRegistry.d.ts +23 -1
  63. package/dist/extension/pipeline/runRegistry.js +57 -1
  64. package/dist/extension/pipeline/runner.js +4 -0
  65. package/dist/extension/pipeline/verify.d.ts +4 -0
  66. package/dist/extension/pipeline/verify.js +48 -26
  67. package/dist/extension/recordDecisionTool.d.ts +8 -0
  68. package/dist/extension/recordDecisionTool.js +24 -0
  69. package/dist/extension/redact.d.ts +20 -0
  70. package/dist/extension/redact.js +64 -0
  71. package/dist/extension/rerouteNotice.d.ts +3 -4
  72. package/dist/extension/rerouteNotice.js +20 -11
  73. package/dist/extension/subagentRender.d.ts +129 -0
  74. package/dist/extension/subagentRender.js +441 -0
  75. package/dist/extension/subagents.d.ts +11 -8
  76. package/dist/extension/subagents.js +162 -37
  77. package/dist/extension/ticketTools.d.ts +37 -0
  78. package/dist/extension/ticketTools.js +117 -0
  79. package/dist/extension/todos.d.ts +28 -1
  80. package/dist/extension/todos.js +76 -1
  81. package/dist/extension/tokenProvider.js +46 -5
  82. package/dist/extension/ultra.d.ts +27 -0
  83. package/dist/extension/ultra.js +76 -0
  84. package/dist/launch.d.ts +7 -0
  85. package/dist/launch.js +24 -12
  86. package/dist/login.d.ts +4 -2
  87. package/dist/login.js +19 -4
  88. package/dist/padding.d.ts +22 -0
  89. package/dist/padding.js +25 -0
  90. package/dist/promptEnrichment.d.ts +40 -0
  91. package/dist/promptEnrichment.js +85 -0
  92. package/dist/signalForward.d.ts +60 -0
  93. package/dist/signalForward.js +130 -0
  94. package/dist/token.d.ts +25 -0
  95. package/dist/token.js +45 -0
  96. package/package.json +6 -5
  97. package/dist/extension/boostCommand.d.ts +0 -144
  98. package/dist/extension/boostCommand.js +0 -263
@@ -1,39 +1,48 @@
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
- import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER } from "./branding.js";
13
- import { appendClaudeRules, claudeRulesSection } from "./claudeRules.js";
17
+ import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA } from "./branding.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, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } 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";
32
+ import { createUltraHolder, registerUltraCommand } from "./ultra.js";
26
33
  import { registerTodos } from "./todos.js";
27
34
  import { registerDecisionCommands } from "./decisions.js";
28
35
  import { makeDecisionCapture } from "./decisionCapture.js";
29
36
  import { registerAmbientRecall } from "./recall.js";
30
37
  import { resilientFetch } from "./resilientFetch.js";
31
- import { installUncaughtExceptionMonitor } from "./crashReport.js";
38
+ import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
32
39
  import { flushSpool as defaultFlushSpool } from "./spool.js";
33
40
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
34
41
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
35
42
  import { buildYagniProvider } from "./provider.js";
36
43
  import { registerChipEditor } from "./chipEditor.js";
44
+ import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
45
+ import { makeFlywheelState } from "./flywheel.js";
37
46
  function isEvalMode(env = process.env) {
38
47
  return env.YAGNI_CODE_EVAL_MODE === "1";
39
48
  }
@@ -111,19 +120,48 @@ export async function registerYagni(pi, deps = {}) {
111
120
  if (!evalMode) {
112
121
  installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
113
122
  }
114
- const catalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
123
+ // YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
124
+ // Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
125
+ // so auth failures are visible in Sentry/backend logs. Gated on !evalMode
126
+ // like every other external side effect; injectable for tests.
127
+ const authReporter = deps.authReporter ??
128
+ (!evalMode
129
+ ? makeCrashReporter({ baseUrl, getToken: getTokenFn, fetchImpl: deps.fetchImpl, env: deps.env })
130
+ : async () => { });
131
+ // YAG-500 Fix A+C: the model-path 401 recovery outcome, set by the
132
+ // message_end handler so it can produce the right user-facing message. The
133
+ // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
134
+ // before onResponse is reached), so message_end is the only seam.
135
+ let lastAuthRecovery = null;
136
+ const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
137
+ // Lock the interactive session to the `advanced` tier only. The backend
138
+ // catalog returns all tiers, but only `advanced` is registered with the
139
+ // `yagni` provider, so /model and Ctrl+P show a single entry. Child
140
+ // processes (/go, subagents, advisor) fetch their own catalog and register
141
+ // their own provider, so they are unaffected by this filter.
142
+ const catalog = fullCatalog.filter((m) => m.id === "advanced");
115
143
  // YAG-471: the driver's own completions carry attribution headers read from
116
144
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
117
145
  // defaults to "driver" when unset, i.e. every session that is not a /go
118
146
  // child, a subagent, or an advisor consult).
119
147
  pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
120
148
  const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
121
- pi.registerTool(makeAskYagniTool(toolOpts));
122
- // The peak-tier escalation for Balanced sessions (YAG-380). Registered
149
+ // One flywheel state per session, shared by ask_yagni (caps how many
150
+ // no-position record suggestions reach the model) and record_decision
151
+ // (flywheel-attributed records send dedupe: true). Run 7.
152
+ const flywheelState = makeFlywheelState();
153
+ pi.registerTool(makeAskYagniTool({ ...toolOpts, flywheel: flywheelState, getRepo: () => sessionRepo }));
154
+ // Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
155
+ // workspace tracker, attributed to the developer via per-user credentials.
156
+ if (!evalMode) {
157
+ pi.registerTool(makeFileTicketTool(toolOpts));
158
+ pi.registerTool(makeUpdateTicketStatusTool(toolOpts));
159
+ }
160
+ // The peak-tier escalation for Advanced sessions (YAG-380). Registered
123
161
  // UNCONDITIONALLY and gated at execute time on the live session model: pi's
124
162
  // 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
163
+ // check would both leave the tool live after switching away from Advanced and
164
+ // hide it forever from a session that switched TO Advanced. One state handle
127
165
  // per session, shared with /advise so they draw on the same cap.
128
166
  const advisorState = makeAdvisorState();
129
167
  const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
@@ -131,12 +169,6 @@ export async function registerYagni(pi, deps = {}) {
131
169
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
132
170
  // draws on the same cap rather than opening a side channel around it.
133
171
  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
172
  // The differentiated business-grounded tools (loop bricks): review a change
141
173
  // for business fit, rank the next work by business priority, and record the
142
174
  // engineering rationale back onto the work-item.
@@ -146,7 +178,7 @@ export async function registerYagni(pi, deps = {}) {
146
178
  pi.registerTool(makeRecordEngineeringContextTool(toolOpts));
147
179
  // The capture half of the judgment loop: bank a product-intent decision so
148
180
  // ask_yagni answers the same question next time instead of interrupting a human.
149
- pi.registerTool(makeRecordDecisionTool(toolOpts));
181
+ pi.registerTool(makeRecordDecisionTool({ ...toolOpts, flywheel: flywheelState }));
150
182
  }
151
183
  // The visible checklist for multi-step work: the todo_write tool, its
152
184
  // above-editor widget, and /todos. Branch-replayed, so forks and resumes
@@ -160,7 +192,12 @@ export async function registerYagni(pi, deps = {}) {
160
192
  // The general subagent tool: delegate self-contained tasks (optionally in
161
193
  // parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
162
194
  // riding the /go pipeline's child runner. /agents lists what's available.
163
- registerSubagents(pi);
195
+ // Ultra mode (/ultra) is an explicit orchestration dial: it swaps the
196
+ // driver's delegation identity for the diamond directive (see the
197
+ // before_agent_start handler below) and widens the tool's fan-out ceiling.
198
+ const ultraHolder = createUltraHolder();
199
+ registerSubagents(pi, { isUltra: () => ultraHolder.get() });
200
+ registerUltraCommand(pi, ultraHolder);
164
201
  // Shared fetch timeout for the small, interactive display-path reads below
165
202
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
166
203
  // 5s, not the 10s other boot/grounding fetches use, because the user is
@@ -212,7 +249,101 @@ export async function registerYagni(pi, deps = {}) {
212
249
  // drafts a decision on "don't ask again". Default auto, so still additive.
213
250
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
214
251
  // holds them, review mode confirms them.
252
+ const modeHolder = createModeHolder();
253
+ const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
254
+ const guardianState = makeGuardianState();
255
+ // Disabled by the local env override OR the workspace kill switch
256
+ // (yagni_code.guardian, read from the catalog response at launch). The env
257
+ // var wins for a single developer's debugging; the flag turns it off for
258
+ // every session in the workspace.
259
+ const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" ||
260
+ env.YAGNI_DISABLE_GUARDIAN === "true" ||
261
+ !workspaceGuardianEnabled;
262
+ const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
263
+ const guardianLimits = resolveGuardianLimits(env);
264
+ // YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
265
+ // never the command). The remote guardian-events stream below is the
266
+ // separate, opt-in, per-workspace analytics sink; the two are independent.
267
+ const guardianLogSink = (payload) => {
268
+ try {
269
+ if (process.env.NODE_TEST_CONTEXT)
270
+ return;
271
+ const logPath = join(codeStateHome(null), "logs", "guardian.log");
272
+ mkdirSync(dirname(logPath), { recursive: true });
273
+ appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
274
+ }
275
+ catch { /* logging must never break the session */ }
276
+ };
277
+ // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
278
+ // at startup (grants added by other concurrent sessions appear next launch —
279
+ // the startup load is the trust boundary; live reload was reviewed and
280
+ // rejected as a same-session self-authorization path, PR #1698).
281
+ const sessionGrants = evalMode ? [] : loadGrants();
282
+ const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
215
283
  registerPermissionGate(pi, {
284
+ modeHolder,
285
+ guardianState,
286
+ guardianLimits,
287
+ guardianTier,
288
+ guardianDisabled,
289
+ guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
290
+ onGuardianReview: (ev) => guardianLogSink(ev),
291
+ grants: sessionGrants,
292
+ resolveRepoKey,
293
+ persistGrant: (grant) => {
294
+ if (!evalMode)
295
+ appendGrant(grant);
296
+ },
297
+ // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
298
+ // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
299
+ // metadata, no command content; "raw" → adds client-REDACTED command and
300
+ // rationale. Fire-and-forget: one attempt, short timeout, failures logged
301
+ // fail-soft to guardian.log — a storage outage never touches the session.
302
+ onGuardianEvent: guardianStorageTier === "off" || evalMode
303
+ ? undefined
304
+ : (ev) => {
305
+ void (async () => {
306
+ try {
307
+ const body = {
308
+ sessionId: env.YAGNI_SESSION_ID ?? null,
309
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
310
+ commandPrefix: storagePrefix(ev.command),
311
+ outcome: ev.outcome,
312
+ mode: ev.mode,
313
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
314
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
315
+ ...(ev.tier ? { tier: ev.tier } : {}),
316
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
317
+ };
318
+ if (guardianStorageTier === "raw") {
319
+ body.command = redactCommand(ev.command);
320
+ if (ev.rationale)
321
+ body.rationale = redactCommand(ev.rationale);
322
+ }
323
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
324
+ method: "POST",
325
+ headers: {
326
+ "content-type": "application/json",
327
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
328
+ ...attributionHeaders(deps.env),
329
+ },
330
+ body: JSON.stringify(body),
331
+ }, {
332
+ fetchImpl: authedFetch,
333
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
334
+ });
335
+ if (!res.ok) {
336
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
337
+ }
338
+ }
339
+ catch (err) {
340
+ guardianLogSink({
341
+ event: "guardian_event_post_failed",
342
+ error: err instanceof Error ? err.message : "unknown",
343
+ });
344
+ }
345
+ })();
346
+ },
216
347
  ...(mcpMutatingTools.length > 0
217
348
  ? {
218
349
  policy: {
@@ -224,6 +355,7 @@ export async function registerYagni(pi, deps = {}) {
224
355
  ...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
225
356
  ...mcpMutatingTools,
226
357
  ],
358
+ alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
227
359
  },
228
360
  }
229
361
  : {}),
@@ -247,12 +379,11 @@ export async function registerYagni(pi, deps = {}) {
247
379
  // turn_end accumulator. Thread the subtotal in explicitly for the local
248
380
  // fallback line; the server-authoritative line already counts advisor
249
381
  // 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(),
382
+ advisorSubtotal: () => {
383
+ const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
384
+ const guardian = formatGuardianSubtotal(guardianState.read(), guardianLimits);
385
+ return [advisor, guardian].filter(Boolean).join(" ");
386
+ },
256
387
  fetchHeadroom: async (signal) => {
257
388
  try {
258
389
  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 } });
@@ -336,11 +467,27 @@ export async function registerYagni(pi, deps = {}) {
336
467
  // must never block the agent), then inject it into the system prompt so the
337
468
  // very first turn already knows how this company and codebase work.
338
469
  const fetchBrief = deps.fetchContextBrief ?? defaultFetchContextBrief;
470
+ // Resolved BEFORE the brief fetch so /context can answer repoDecisionCount
471
+ // in the same round-trip (the mining beat's signal). Best-effort: undefined
472
+ // outside a git repo or without an origin remote.
473
+ const resolveRepoFn = deps.resolveRepoFullName ?? ((cwd) => defaultMineBeatGit.repoFullName(cwd));
474
+ let sessionRepo;
475
+ try {
476
+ sessionRepo = evalMode ? undefined : resolveRepoFn(process.cwd());
477
+ }
478
+ catch {
479
+ sessionRepo = undefined;
480
+ }
339
481
  let contextBrief;
340
482
  let briefResult = null;
341
483
  if (!evalMode) {
342
484
  try {
343
- briefResult = await fetchBrief({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
485
+ briefResult = await fetchBrief({
486
+ baseUrl,
487
+ getToken: getTokenFn,
488
+ fetchImpl: authedFetch,
489
+ repo: sessionRepo,
490
+ });
344
491
  contextBrief = briefResult?.brief?.trim() ? briefResult.brief : undefined;
345
492
  }
346
493
  catch {
@@ -401,11 +548,21 @@ export async function registerYagni(pi, deps = {}) {
401
548
  // assembled prompt passes through byte-exact (no rewrite, no brief
402
549
  // injection, no rules section, no delegation identity).
403
550
  const noBranding = brandingDisabled(env);
404
- const identity = isDriverCaller(env) ? YAGNI_IDENTITY_DRIVER : undefined;
551
+ // Resolved per turn (not once at activation): /ultra can flip the driver
552
+ // between the delegation and diamond identities mid-session.
553
+ const isDriver = isDriverCaller(env);
405
554
  pi.on("before_agent_start", (event) => noBranding
406
555
  ? undefined
407
556
  : {
408
- systemPrompt: appendClaudeRules(brandSystemPrompt(event.systemPrompt, { contextBrief, identity }), rulesSection),
557
+ systemPrompt: brandSystemPrompt(event.systemPrompt, {
558
+ contextBrief,
559
+ identity: isDriver
560
+ ? ultraHolder.get()
561
+ ? YAGNI_IDENTITY_ULTRA
562
+ : YAGNI_IDENTITY_DRIVER
563
+ : undefined,
564
+ rulesSection,
565
+ }),
409
566
  });
410
567
  // Two finalized-message guards share this handler (their conditions are
411
568
  // mutually exclusive: YAG-460 takes error-stopped messages, YAG-466 takes
@@ -425,7 +582,7 @@ export async function registerYagni(pi, deps = {}) {
425
582
  // renders. The message text deliberately matches neither pi's overflow nor
426
583
  // retryable-error patterns: a deterministic empty response should not burn
427
584
  // auto-retries or trigger compaction — the user decides what to do next.
428
- pi.on("message_end", (event, ctx) => {
585
+ pi.on("message_end", async (event, ctx) => {
429
586
  const msg = event.message;
430
587
  if (msg.role !== "assistant")
431
588
  return;
@@ -440,6 +597,71 @@ export async function registerYagni(pi, deps = {}) {
440
597
  // post-replacement message, and dropping the marker would defeat the very
441
598
  // recovery this error exists to trigger.
442
599
  if (msg.stopReason === "error" && msg.errorMessage) {
600
+ // YAG-500: a 401 from the model proxy means the session token expired
601
+ // (or was revoked). Unlike tool 401s (handled by makeAuthedFetch), the
602
+ // model completion path has no 401-retry seam — pi's retryProviderRequest
603
+ // treats 401 as non-retryable, and after_provider_response never fires
604
+ // (the SDK throws before onResponse is reached). So message_end is the
605
+ // only place to detect it and trigger recovery. The regex matches
606
+ // "yagni login" (the backend's auth-error message) but NOT
607
+ // "request_too_large" (YAG-460's overflow marker).
608
+ const isAuthError = /yagni login/i.test(msg.errorMessage)
609
+ && !/request_too_large|context_too_large/i.test(msg.errorMessage);
610
+ if (isAuthError) {
611
+ lastAuthRecovery = null;
612
+ let rotated = false;
613
+ try {
614
+ rotated = await tokenProvider.refresh();
615
+ }
616
+ catch {
617
+ rotated = false;
618
+ }
619
+ lastAuthRecovery = rotated ? "refreshed" : "failed";
620
+ const explanation = rotated
621
+ ? "Your session token expired but was refreshed automatically. Re-send your prompt to continue."
622
+ : "Your session token expired and could not be refreshed. Run `yagni login`, then re-send your prompt. If the issue persists, restart YAGNI Code.";
623
+ if (ctx.hasUI) {
624
+ try {
625
+ ctx.ui.notify(explanation, rotated ? "info" : "error");
626
+ }
627
+ catch {
628
+ // Surfacing the problem must never break the session itself.
629
+ }
630
+ }
631
+ // YAG-500 Fix E: fire a non-fatal crash report so auth failures are
632
+ // visible in Sentry/backend logs. Reuses the crash endpoint with a
633
+ // distinct context. Fire-and-forget, fail-soft. The crash endpoint is
634
+ // now public (no token required), so the report lands even when the
635
+ // session token is expired and refresh failed — the most critical
636
+ // failure signal is no longer silently dropped.
637
+ void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
638
+ // YAG-500 Fix F: local diagnostics log under YAGNI_DEBUG.
639
+ if (isDebug(env)) {
640
+ try {
641
+ const logPath = join(codeStateHome(null, env), "logs", "auth-events.log");
642
+ mkdirSync(dirname(logPath), { recursive: true });
643
+ appendFileSync(logPath, JSON.stringify({
644
+ ts: new Date().toISOString(),
645
+ status: 401,
646
+ refresh: rotated ? "succeeded" : "failed",
647
+ }) + "\n", "utf8");
648
+ }
649
+ catch {
650
+ // A diagnostic must never break the session.
651
+ }
652
+ }
653
+ return { message: { ...msg, errorMessage: explanation } };
654
+ }
655
+ // YAG-460: the backend proxy answers an oversized conversation with an
656
+ // OpenAI-format 413 whose type is `request_too_large`. pi's own overflow
657
+ // detection matches that marker and runs full recovery — compact, then
658
+ // auto-retry the failed turn — so this branch must NOT call ctx.compact()
659
+ // (it would race the built-in recovery and lose the retry). Its only job
660
+ // is UX: replace the raw `413: {"error":{...}}` JSON with a readable
661
+ // message and tell the user what is happening. The rewritten text KEEPS
662
+ // the `request_too_large` marker verbatim: pi's _checkCompaction reads the
663
+ // post-replacement message, and dropping the marker would defeat the very
664
+ // recovery this error exists to trigger.
443
665
  const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
444
666
  if (!isContextTooLarge)
445
667
  return;
@@ -486,7 +708,47 @@ export async function registerYagni(pi, deps = {}) {
486
708
  ctx.ui?.setStatus?.("brand", BRAND_NAME);
487
709
  // setHeader replaces the built-in header in place (verified against pi
488
710
  // 0.80.2 setExtensionHeader); the factory returns a simple Text component.
489
- ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme)));
711
+ // Pass the launcher-forwarded CLI version (YAGNI_CODE_VERSION) and the
712
+ // home-collapsed cwd so the masthead shows them like the reference.
713
+ const mastheadVersion = process.env.YAGNI_CODE_VERSION?.trim() || undefined;
714
+ const mastheadCwd = formatCwd(process.cwd(), process.env.HOME);
715
+ ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme, { version: mastheadVersion, cwd: mastheadCwd })));
716
+ // Replace the built-in footer with the YAGNI status bar: folder +
717
+ // [worktree] + branch on line 1, model + token/cost stats + integer
718
+ // context % on line 2, and extension statuses (brand, todos, mode) on
719
+ // line 3. The factory captures ctx so the footer can read session data
720
+ // (token stats, context usage) that isn't on the footerData provider.
721
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
722
+ ctx.ui?.onTerminalInput?.((data) => {
723
+ if (isShiftTab(data)) {
724
+ modeHolder.set(cyclePermissionMode(modeHolder.get()));
725
+ footerInvalidateHandle.requestRender();
726
+ return { consume: true };
727
+ }
728
+ return undefined;
729
+ });
730
+ // Label the collapsed chain-of-thought line so users know it is reasoning
731
+ // and how to reveal the full trace. Harmless when reasoning is expanded
732
+ // (the label only shows on hidden thinking blocks). Fails closed to pi's
733
+ // default "Thinking..." label.
734
+ try {
735
+ ctx.ui?.setHiddenThinkingLabel?.("Reasoning — Ctrl+T to expand");
736
+ }
737
+ catch {
738
+ // A label must never disrupt session start.
739
+ }
740
+ // One-time hint naming what changed now that reasoning is collapsed by
741
+ // default (option 2). Gated on the launcher's YAGNI_HIDE_THINKING_SEEDED
742
+ // marker so it fires only on the launch that actually seeded the key — it
743
+ // never re-nags on subsequent runs, even though the label above persists.
744
+ if (env.YAGNI_HIDE_THINKING_SEEDED === "1") {
745
+ try {
746
+ ctx.ui?.notify("Reasoning is collapsed by default — Ctrl+T shows the full trace at any time.", "info");
747
+ }
748
+ catch {
749
+ // A hint must never disrupt session start.
750
+ }
751
+ }
490
752
  }
491
753
  // Best-effort, once at session start: if the token is at or near expiry, say
492
754
  // so via a single notice so a long session does not silently start 401-ing
@@ -538,14 +800,54 @@ export async function registerYagni(pi, deps = {}) {
538
800
  // The init pass is best-effort; a failure never blocks the session.
539
801
  }
540
802
  }
803
+ // Run 7: the opt-in repo seeding beat. Fires only on a genuine startup
804
+ // when /context reported an explicitly-empty repo-scoped ledger, once per
805
+ // repo (marker store). Fully fail-soft — a beat failure never blocks the
806
+ // session.
807
+ if (event.reason === "startup" && !evalMode) {
808
+ try {
809
+ const offerBeat = deps.offerMiningBeat ?? defaultMaybeOfferMiningBeat;
810
+ const markers = deps.mineBeatMarkers ?? fileMineBeatMarkers(codeStateHome(null, env));
811
+ await offerBeat(ctx, {
812
+ repoFullName: sessionRepo,
813
+ repoDecisionCount: briefResult?.repoDecisionCount,
814
+ cwd: process.cwd(),
815
+ baseUrl,
816
+ getToken: getTokenFn,
817
+ fetchImpl: authedFetch,
818
+ markers,
819
+ });
820
+ }
821
+ catch {
822
+ // Seeding is best-effort; a failure never blocks the session.
823
+ }
824
+ }
825
+ });
826
+ pi.on("tool_result", (event) => {
827
+ if (event.toolName !== "bash")
828
+ return;
829
+ const command = typeof event.input?.command === "string"
830
+ ? event.input.command
831
+ : "";
832
+ if (GIT_MUTATING_PATTERN.test(command)) {
833
+ footerInvalidateHandle.invalidateGit();
834
+ }
541
835
  });
542
836
  }
543
837
  export default async function (pi) {
544
838
  await registerYagni(pi);
839
+ // cmux session bridge: report lifecycle state (running/idle), workspace
840
+ // naming, and resume bindings to cmux when running inside it. Complete
841
+ // no-op outside cmux — guarded by CMUX_SURFACE_ID. Registered in the
842
+ // default export (not registerYagni) so tests that call registerYagni
843
+ // directly don't get the bridge's handlers.
844
+ registerCmuxBridge(pi);
545
845
  }
546
846
  export { makeAskYagniTool } from "./askYagniTool.js";
847
+ export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
547
848
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
548
849
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
850
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
549
851
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
550
852
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
551
853
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
@@ -556,7 +858,9 @@ export { recordDecision } from "./recordDecisionTool.js";
556
858
  export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
557
859
  // Onramp Door B (F2a): the one-time init-pass idempotency marker.
558
860
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
559
- export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
861
+ export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
862
+ // Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
863
+ export { createUltraHolder, registerUltraCommand } from "./ultra.js";
560
864
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
561
865
  export { buildYagniProvider } from "./provider.js";
562
866
  export { registerGoCommand } from "./pipeline/goCommand.js";
@@ -571,12 +875,13 @@ export { buildVerifyEnv, detectVerifyCommand, makeRunVerify, parseVerifyFailures
571
875
  export { blindStages, reportOnlyStages, makeGroundedVsBlindEval, formatComparisonReport } from "./pipeline/eval.js";
572
876
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
573
877
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
574
- export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
878
+ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
575
879
  // The session todo checklist: todo_write tool, widget renderer, /todos.
576
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
880
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
577
881
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
578
882
  // the session bless-with-remember capture hook.
579
- export { decideGate, registerPermissionGate, filterStalePlanContext, DEFAULT_PERMISSION_POLICY, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
883
+ export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
884
+ export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
580
885
  // W4 judgment loop: the session bless store (tool + path-prefix, session-only).
581
886
  export { makeBlessStore, blessPath } from "./bless.js";
582
887
  // W4 judgment loop: the decisions surface (/decide + /decisions) + shared bank.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The opt-in repo seeding beat (org plane, Run 7).
3
+ *
4
+ * On the first interactive session in a repo whose repo-scoped decision
5
+ * ledger is empty (`/context?repo=` reports `repoDecisionCount: 0`), YAGNI
6
+ * Code offers — once — to read the repo's own decision record and seed the
7
+ * ledger: "Read this repo's ADRs and docs and seed the decision ledger?".
8
+ * One keystroke, then the backend extracts, dedupes, and banks at most 12
9
+ * `repo_mined` asserted rows, and the beat prints the banked count plus the
10
+ * Library review deep link.
11
+ *
12
+ * Never automatic, never a nag: a decline writes a per-repo marker so the
13
+ * beat does not recur; an accepted pass marks the repo only after the server
14
+ * confirms (a transport failure leaves the offer available next session).
15
+ * The corpus is collected at git HEAD (same echo-chamber guard as
16
+ * repoDocs.ts) — ADR directories, agent-instruction root docs, top-level
17
+ * docs/*.md, and recent merge subjects — bounded client-side to the same
18
+ * limits the backend enforces.
19
+ */
20
+ /**
21
+ * Client-side bounds on the mining corpus — mirror of the backend's
22
+ * MINE_INPUT_LIMITS so nothing is silently truncated server-side.
23
+ */
24
+ export declare const MINE_BEAT_LIMITS: {
25
+ readonly maxDocs: 40;
26
+ readonly maxPathChars: 300;
27
+ readonly maxExcerptChars: 8000;
28
+ readonly maxCommitSubjects: 200;
29
+ readonly maxSubjectChars: 200;
30
+ };
31
+ /** One document offered to the mining pass. */
32
+ export interface MineBeatDoc {
33
+ path: string;
34
+ excerpt: string;
35
+ }
36
+ /** Injectable git seam (default: execFileSync; every call fail-soft). */
37
+ export interface MineBeatGit {
38
+ /** `owner/name` from the origin remote, or undefined outside a repo. */
39
+ repoFullName: (cwd: string) => string | undefined;
40
+ /** Tracked paths at HEAD, or null when cwd is not a usable git tree. */
41
+ listAtHead: (cwd: string) => string[] | null;
42
+ /** One file's HEAD content, or null when unreadable. */
43
+ readAtHead: (cwd: string, rel: string) => string | null;
44
+ /** Recent merge-commit subjects (fallback: plain subjects on squash repos). */
45
+ mergeSubjects: (cwd: string, max: number) => string[];
46
+ }
47
+ /** Parse `owner/name` from an origin remote URL (ssh or https). */
48
+ export declare function parseRepoFullName(remote: string | null | undefined): string | undefined;
49
+ export declare const defaultMineBeatGit: MineBeatGit;
50
+ /** Is this HEAD-tracked path part of the repo's decision record? */
51
+ export declare function isMineCandidate(rel: string): boolean;
52
+ /** Collect the HEAD-era mining corpus, bounded to MINE_BEAT_LIMITS. */
53
+ export declare function collectMineCorpus(cwd: string, gitImpl?: MineBeatGit): {
54
+ docs: MineBeatDoc[];
55
+ commitSubjects: string[];
56
+ };
57
+ /** Injectable marker-store seam (default: a JSON file per repo on disk). */
58
+ export interface MineBeatMarkers {
59
+ has: (repoFullName: string) => boolean;
60
+ write: (repoFullName: string, status: "declined" | "seeded") => void;
61
+ }
62
+ /** Filesystem path of one repo's beat marker under the state home. */
63
+ export declare function mineMarkerPath(stateHome: string, repoFullName: string): string;
64
+ export declare function fileMineBeatMarkers(stateHome: string): MineBeatMarkers;
65
+ /** The subset of the session-start ctx the beat needs. */
66
+ export interface MineBeatUi {
67
+ hasUI: boolean;
68
+ ui: {
69
+ confirm?: (title: string, message: string) => Promise<boolean>;
70
+ notify: (message: string, level: "info" | "error") => void;
71
+ };
72
+ }
73
+ export interface OfferMiningBeatOptions {
74
+ repoFullName: string | undefined;
75
+ /** From `/context?repo=`; the beat fires ONLY on an explicit 0. */
76
+ repoDecisionCount: number | undefined;
77
+ cwd: string;
78
+ baseUrl: string;
79
+ getToken: () => string | undefined;
80
+ fetchImpl?: typeof fetch;
81
+ markers: MineBeatMarkers;
82
+ gitImpl?: MineBeatGit;
83
+ collect?: typeof collectMineCorpus;
84
+ }
85
+ export interface MiningBeatOutcome {
86
+ offered: boolean;
87
+ accepted?: boolean;
88
+ banked?: number;
89
+ }
90
+ /**
91
+ * Offer (once per repo) and, on yes, run the seeding pass. Fail-soft
92
+ * throughout: any error surfaces as a notify at most, never a broken session.
93
+ */
94
+ export declare function maybeOfferMiningBeat(ctx: MineBeatUi, opts: OfferMiningBeatOptions): Promise<MiningBeatOutcome>;
95
+ //# sourceMappingURL=mineBeat.d.ts.map