@yagni-app/code 1.0.0 → 1.0.2

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 (66) hide show
  1. package/README.md +42 -0
  2. package/dist/cli.js +231 -6
  3. package/dist/crashReport.d.ts +8 -0
  4. package/dist/crashReport.js +13 -1
  5. package/dist/doctor.d.ts +7 -0
  6. package/dist/doctor.js +33 -0
  7. package/dist/extension/askAdvisorTool.d.ts +14 -0
  8. package/dist/extension/askAdvisorTool.js +16 -4
  9. package/dist/extension/askYagniTool.d.ts +1 -1
  10. package/dist/extension/askYagniTool.js +21 -0
  11. package/dist/extension/branding.d.ts +15 -0
  12. package/dist/extension/branding.js +76 -0
  13. package/dist/extension/childUsage.d.ts +40 -0
  14. package/dist/extension/childUsage.js +43 -0
  15. package/dist/extension/chipEditor.d.ts +22 -1
  16. package/dist/extension/chipEditor.js +58 -5
  17. package/dist/extension/condensedTools.d.ts +97 -0
  18. package/dist/extension/condensedTools.js +396 -0
  19. package/dist/extension/diffStat.d.ts +62 -0
  20. package/dist/extension/diffStat.js +158 -0
  21. package/dist/extension/footer.d.ts +15 -1
  22. package/dist/extension/footer.js +35 -15
  23. package/dist/extension/index.d.ts +6 -0
  24. package/dist/extension/index.js +126 -6
  25. package/dist/extension/permission/execPolicy.js +47 -0
  26. package/dist/extension/permission/gate.d.ts +6 -0
  27. package/dist/extension/permission/gate.js +11 -2
  28. package/dist/extension/permission/guardian.d.ts +20 -0
  29. package/dist/extension/permission/guardian.js +16 -1
  30. package/dist/extension/pipeline/goCommand.d.ts +8 -0
  31. package/dist/extension/pipeline/goCommand.js +8 -0
  32. package/dist/extension/pipeline/invocation.d.ts +7 -0
  33. package/dist/extension/pipeline/invocation.js +7 -0
  34. package/dist/extension/pipeline/personas.js +4 -4
  35. package/dist/extension/pipeline/runner.d.ts +1 -0
  36. package/dist/extension/pipeline/runner.js +15 -3
  37. package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
  38. package/dist/extension/pipeline/sessionWorktree.js +225 -0
  39. package/dist/extension/scratchpad.d.ts +66 -0
  40. package/dist/extension/scratchpad.js +93 -0
  41. package/dist/extension/slashCommandFilter.d.ts +30 -0
  42. package/dist/extension/slashCommandFilter.js +89 -0
  43. package/dist/extension/subagents.d.ts +21 -1
  44. package/dist/extension/subagents.js +34 -5
  45. package/dist/extension/todos.d.ts +1 -0
  46. package/dist/extension/todos.js +15 -0
  47. package/dist/extension/toolRuns.d.ts +92 -0
  48. package/dist/extension/toolRuns.js +201 -0
  49. package/dist/extension/webFetchTool.js +2 -0
  50. package/dist/extension/workingLine.d.ts +49 -0
  51. package/dist/extension/workingLine.js +116 -0
  52. package/dist/feedback.d.ts +77 -0
  53. package/dist/feedback.js +500 -0
  54. package/dist/goHeadless.d.ts +3 -0
  55. package/dist/goHeadless.js +13 -0
  56. package/dist/launch.d.ts +8 -0
  57. package/dist/launch.js +6 -0
  58. package/dist/otel.d.ts +150 -0
  59. package/dist/otel.js +291 -0
  60. package/dist/outputFormat.d.ts +83 -0
  61. package/dist/outputFormat.js +207 -0
  62. package/dist/paths.d.ts +10 -0
  63. package/dist/paths.js +13 -0
  64. package/dist/worktreeArgs.d.ts +43 -0
  65. package/dist/worktreeArgs.js +96 -0
  66. package/package.json +3 -2
@@ -42,6 +42,7 @@ import { spawnSync } from "node:child_process";
42
42
  import { statSync } from "node:fs";
43
43
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
44
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
+ import { createDiffStatCache, formatDiffStat } from "./diffStat.js";
45
46
  export const BRANCH_MAX_WIDTH = 60;
46
47
  const WORKTREE_MAX_WIDTH = 30;
47
48
  /** Section separator: single space + middle dot + single space. */
@@ -229,23 +230,34 @@ export function renderFooterLines(input, theme, width, padX = 0) {
229
230
  // terminal edge. Truncation runs against the reduced content width.
230
231
  const pad = " ".repeat(Math.max(0, Math.min(3, Math.floor(padX))));
231
232
  const contentWidth = Math.max(1, width - pad.length);
232
- // Line 1: folder · [worktree] · branch
233
+ // Line 1: folder · [worktree] · branch [+N -M]
233
234
  const line1Parts = [theme.fg("accent", input.git.folder)];
234
235
  if (input.git.inRepo) {
235
236
  if (input.git.worktree)
236
237
  line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
237
- if (input.git.branch)
238
- line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
238
+ if (input.git.branch) {
239
+ const branchSpan = theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…")));
240
+ // Diff badge rides the branch segment (Kimi's `branch [ +N -M ]` shape),
241
+ // colored dim — the same grey as the cost stats on line 2.
242
+ const diffSpan = input.diff ? ` ${dim(`[${input.diff}]`)}` : "";
243
+ line1Parts.push(branchSpan + diffSpan);
244
+ }
239
245
  }
240
246
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
241
247
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
248
+ // Child-process spend (subagents, advisor, /go) joins the driver totals so
249
+ // the footer matches the per-child receipts and /cost's session scope.
250
+ const child = input.childUsage;
251
+ const totalIn = input.usage.input + (child?.input ?? 0);
252
+ const totalOut = input.usage.output + (child?.output ?? 0);
253
+ const totalCost = input.usage.cost + (child?.cost ?? 0);
242
254
  const statParts = [];
243
- if (input.usage.input)
244
- statParts.push(`↑${formatTokens(input.usage.input)}`);
245
- if (input.usage.output)
246
- statParts.push(`↓${formatTokens(input.usage.output)}`);
247
- if (input.usage.cost)
248
- statParts.push(`$${input.usage.cost.toFixed(3)}`);
255
+ if (totalIn)
256
+ statParts.push(`↑${formatTokens(totalIn)}`);
257
+ if (totalOut)
258
+ statParts.push(`↓${formatTokens(totalOut)}`);
259
+ if (totalCost)
260
+ statParts.push(`$${totalCost.toFixed(3)}`);
249
261
  const stats = statParts.join(" ");
250
262
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
251
263
  const line2Parts = [];
@@ -266,20 +278,26 @@ export function renderFooterLines(input, theme, width, padX = 0) {
266
278
  }
267
279
  return lines;
268
280
  }
269
- export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
281
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle, childUsage) {
270
282
  return (_tui, theme, footerData) => {
271
283
  let gitCache;
272
- const unsubscribeBranch = footerData.onBranchChange?.(() => {
284
+ let diffStatCache;
285
+ const clearCaches = () => {
273
286
  gitCache = undefined;
274
- });
287
+ diffStatCache = undefined;
288
+ };
289
+ const unsubscribeBranch = footerData.onBranchChange?.(clearCaches);
275
290
  const gitInfo = () => {
276
291
  if (!gitCache) {
277
- gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
292
+ // Read cwd once; both the git info and the diff cache key off it.
293
+ const dir = ctx.sessionManager.getCwd();
294
+ gitCache = detectGitInfo(dir, process.env.HOME || process.env.USERPROFILE);
295
+ diffStatCache = createDiffStatCache(dir);
278
296
  }
279
297
  return gitCache;
280
298
  };
281
299
  if (invalidateHandle) {
282
- invalidateHandle.invalidateGit = () => { gitCache = undefined; };
300
+ invalidateHandle.invalidateGit = clearCaches;
283
301
  invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
302
  }
285
303
  return {
@@ -291,13 +309,15 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
291
309
  git: gitInfo(),
292
310
  model: ctx.model?.id ?? "no-model",
293
311
  mode: modeHolder?.get() ?? null,
312
+ diff: formatDiffStat(diffStatCache?.get() ?? null),
294
313
  usage: collectUsage(ctx.sessionManager),
314
+ childUsage: childUsage?.read(),
295
315
  contextPercent: ctx.getContextUsage()?.percent ?? null,
296
316
  statuses,
297
317
  }, theme, width, resolveFooterPadX());
298
318
  },
299
319
  invalidate() {
300
- gitCache = undefined;
320
+ clearCaches();
301
321
  },
302
322
  dispose() {
303
323
  unsubscribeBranch?.();
@@ -186,6 +186,12 @@ export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE
186
186
  export type { TokenProvider, TokenProviderDeps, ScheduleFn } from "./tokenProvider.js";
187
187
  export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL_AGE_MS, _setSpoolHomeForTest, } from "./spool.js";
188
188
  export type { SpoolEntry, SpoolClientOpts, FlushOutcome, JudgmentWriteOutcome } from "./spool.js";
189
+ export { registerCondensedTools, displayPath, isScratchpadPath, primaryArg, formatRowTitle, formatWriteBody, formatEditBody, formatBashErrorBody, formatBashPartialBody, formatExpandedOutput, splitBashError, countPatchAdditions, WRITE_PREVIEW_LINES, DIFF_PREVIEW_LINES, } from "./condensedTools.js";
190
+ export type { RegisterCondensedToolsDeps, RowStatus } from "./condensedTools.js";
191
+ export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.js";
192
+ export type { RowKind, ToolRow, ToolRowPatch } from "./toolRuns.js";
193
+ export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
194
+ export type { WorkingLineHandle, RegisterWorkingLineDeps } from "./workingLine.js";
189
195
  export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
190
196
  export type { CrashReporter, CrashReporterOpts, FatalCrashOpts, SanitizedCrash } from "./crashReport.js";
191
197
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
+ import { makeChildUsageState } from "./childUsage.js";
4
5
  import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
5
6
  import { redactCommand } from "./redact.js";
6
7
  import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
@@ -16,6 +17,7 @@ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
16
17
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
17
18
  import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA } from "./branding.js";
18
19
  import { claudeRulesSection } from "./claudeRules.js";
20
+ import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadSection } from "./scratchpad.js";
19
21
  import { registerCostCommand } from "./costHud.js";
20
22
  import { isDebug } from "./diagnostics.js";
21
23
  import { logEvent } from "./errorSink.js";
@@ -39,15 +41,19 @@ import { registerDecisionCommands } from "./decisions.js";
39
41
  import { makeDecisionCapture } from "./decisionCapture.js";
40
42
  import { registerAmbientRecall } from "./recall.js";
41
43
  import { resilientFetch } from "./resilientFetch.js";
42
- import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
44
+ import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
43
45
  import { flushSpool as defaultFlushSpool } from "./spool.js";
44
46
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
45
47
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
46
48
  import { buildYagniProvider } from "./provider.js";
47
49
  import { registerChipEditor } from "./chipEditor.js";
50
+ import { registerSlashCommandFilter } from "./slashCommandFilter.js";
48
51
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
49
52
  import { makeFlywheelState } from "./flywheel.js";
50
53
  import { registerSilentTurnReminder } from "./silentTurnReminder.js";
54
+ import { registerCondensedTools } from "./condensedTools.js";
55
+ import { isDesktopSurface } from "./surface.js";
56
+ import { registerWorkingLine, WORKING_INDICATOR_FRAMES } from "./workingLine.js";
51
57
  function isEvalMode(env = process.env) {
52
58
  return env.YAGNI_CODE_EVAL_MODE === "1";
53
59
  }
@@ -160,6 +166,11 @@ export async function registerYagni(pi, deps = {}) {
160
166
  // no-position record suggestions reach the model) and record_decision
161
167
  // (flywheel-attributed records send dedupe: true). Run 7.
162
168
  const flywheelState = makeFlywheelState();
169
+ // The composed streaming status line ("Shaping… (12m 54s · ↓ 47.5k tokens)").
170
+ // Registered before the subagent/advisor tools: they publish their live
171
+ // progress through this handle so the elapsed/token suffix survives their
172
+ // overrides. TUI-gated internally (agent_start checks ctx.mode).
173
+ const workingLine = registerWorkingLine(pi);
163
174
  pi.registerTool(makeAskYagniTool({ ...toolOpts, flywheel: flywheelState, getRepo: () => sessionRepo }));
164
175
  // WebFetch (YAG-578): read an arbitrary URL as clean markdown + a
165
176
  // standard-tier extraction, replacing the bash + curl + python dance.
@@ -177,7 +188,11 @@ export async function registerYagni(pi, deps = {}) {
177
188
  // hide it forever from a session that switched TO Advanced. One state handle
178
189
  // per session, shared with /advise so they draw on the same cap.
179
190
  const advisorState = makeAdvisorState();
180
- const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
191
+ // Session child-usage accumulator: every child process's spend (subagent
192
+ // tasks, advisor consults, Guardian reviews, /go runs) lands here so the
193
+ // footer's session totals include what the per-child receipts print.
194
+ const childUsage = makeChildUsageState();
195
+ const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine, childUsage });
181
196
  pi.registerTool(askAdvisorTool);
182
197
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
183
198
  // draws on the same cap rather than opening a side channel around it.
@@ -210,6 +225,11 @@ export async function registerYagni(pi, deps = {}) {
210
225
  // temp path, and registers the input transform that turns chips into image
211
226
  // attachments for the model. No-op outside TUI mode.
212
227
  registerChipEditor(pi);
228
+ // Hide pi's built-in slash commands that contradict YAGNI's single-provider,
229
+ // single-identity model (e.g. /share gist upload, /login <provider>) from
230
+ // slash-discovery. TUI autocomplete only; the desktop palette already
231
+ // excludes built-ins, and this is a no-op in RPC/print modes.
232
+ registerSlashCommandFilter(pi);
213
233
  // The general subagent tool: delegate self-contained tasks (optionally in
214
234
  // parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
215
235
  // riding the /go pipeline's child runner. /agents lists what's available.
@@ -217,7 +237,7 @@ export async function registerYagni(pi, deps = {}) {
217
237
  // driver's delegation identity for the diamond directive (see the
218
238
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
219
239
  const ultraHolder = createUltraHolder();
220
- registerSubagents(pi, { isUltra: () => ultraHolder.get() });
240
+ registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
221
241
  registerUltraCommand(pi, ultraHolder);
222
242
  // Shared fetch timeout for the small, interactive display-path reads below
223
243
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -227,6 +247,7 @@ export async function registerYagni(pi, deps = {}) {
227
247
  // The grounded multi-agent pipeline entry point: /go <ticket> runs
228
248
  // map → plan → implement → review → fix, each child grounded by inheritance.
229
249
  registerGoCommand(pi, {
250
+ childUsage,
230
251
  // /ultra is one dial for the whole session: the same holder the subagent
231
252
  // tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
232
253
  // the fan and its fix turns. Read per run, so a toggle lands on the next /go.
@@ -317,7 +338,10 @@ export async function registerYagni(pi, deps = {}) {
317
338
  const { event: _ignored, ...fields } = payload;
318
339
  logEvent({
319
340
  source: "guardian",
320
- level: event === "guardian_event_post_failed" ? "error" : "info",
341
+ // `malformed` is a real failure (this is the "unclear verdict" bug the
342
+ // whole capture exists to diagnose) — an error, not routine info. The
343
+ // failed-telemetry event is already an error.
344
+ level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
321
345
  event,
322
346
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
323
347
  fields,
@@ -341,8 +365,45 @@ export async function registerYagni(pi, deps = {}) {
341
365
  guardianLimits,
342
366
  guardianTier,
343
367
  guardianDisabled,
368
+ childUsage,
344
369
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
345
- onGuardianReview: (ev) => guardianLogSink(ev),
370
+ onGuardianReview: (ev) => {
371
+ guardianLogSink(ev);
372
+ // A malformed verdict is a real failure on our side that must reach
373
+ // Sentry for EVERY workspace (independent of the opt-in storage tier).
374
+ // Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
375
+ // suppressed under test/eval so a CI run never phones prod.
376
+ if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
377
+ void (async () => {
378
+ try {
379
+ const body = {
380
+ outcome: ev.outcome,
381
+ ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
382
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
383
+ ...(ev.tier ? { tier: ev.tier } : {}),
384
+ };
385
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-error`, {
386
+ method: "POST",
387
+ headers: {
388
+ "content-type": "application/json",
389
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
390
+ ...attributionHeaders(deps.env),
391
+ },
392
+ body: JSON.stringify(body),
393
+ }, {
394
+ fetchImpl: authedFetch,
395
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
396
+ });
397
+ if (!res.ok) {
398
+ guardianLogSink({ event: "guardian_error_post_failed", status: res.status });
399
+ }
400
+ }
401
+ catch {
402
+ guardianLogSink({ event: "guardian_error_post_failed", kind: "network" });
403
+ }
404
+ })();
405
+ }
406
+ },
346
407
  grants: sessionGrants,
347
408
  resolveRepoKey,
348
409
  persistGrant: (grant) => {
@@ -593,6 +654,51 @@ export async function registerYagni(pi, deps = {}) {
593
654
  // pointers. Computed once per activation (rules are launch-time state, like
594
655
  // pi's own skill discovery); fail-soft to null.
595
656
  const rulesSection = claudeRulesSection(deps.env ?? process.env);
657
+ // YAG-575: session scratchpad — a permission-free dir the agent writes its
658
+ // working state to. Computed + ensured once at activation (like rulesSection,
659
+ // which must exist before the first before_agent_start), not on session_start,
660
+ // so the very first turn already carries the section. Fails closed: no
661
+ // sessionId (a bare pi run) or a failed mkdir means no section, and the mkdir
662
+ // failure is logged so a missing scratchpad is not silent.
663
+ const scratchpadDirPath = scratchpadDirFor({
664
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
665
+ cwd: process.cwd(),
666
+ tmp: env[SCRATCHPAD_TMPDIR_ENV] || undefined,
667
+ });
668
+ let scratchpadSectionText;
669
+ if (scratchpadDirPath) {
670
+ const ensured = ensureScratchpadDir(scratchpadDirPath);
671
+ if (ensured) {
672
+ scratchpadSectionText = scratchpadSection(ensured);
673
+ }
674
+ else {
675
+ logEvent({
676
+ source: "scratchpad",
677
+ level: "error",
678
+ event: "scratchpad_mkdir_failed",
679
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
680
+ });
681
+ }
682
+ }
683
+ // The condensed Claude Code-style transcript: the seven built-ins re-render
684
+ // as flat "● Tool(arg)" rows, quiet runs collapse into one "Read 2 files,
685
+ // ran 2 shell commands" summary line, and project writes/edits keep visible
686
+ // previews. Execution delegates to the same factory-made built-ins pi would
687
+ // have used (settings-honoring), so ONLY presentation changes — which is why
688
+ // eval mode keeps pi's stock registration (measured behavior stays
689
+ // byte-identical) and the desktop surface keeps the structured pipeline.
690
+ // YAGNI_CLASSIC_TOOL_ROWS=1 is the debugging escape hatch back to pi's
691
+ // boxed renderers.
692
+ if (!evalMode && !isDesktopSurface() && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
693
+ try {
694
+ registerCondensedTools(pi, {
695
+ ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
696
+ });
697
+ }
698
+ catch {
699
+ // Rendering must never break activation; pi's built-ins remain.
700
+ }
701
+ }
596
702
  // Own the identity + inject live company context (and repo rules) on every
597
703
  // turn. The extension loads identically in every pi process this app spawns —
598
704
  // the interactive driver AND every `/go` stage child, subagent, and advisor
@@ -617,6 +723,7 @@ export async function registerYagni(pi, deps = {}) {
617
723
  : YAGNI_IDENTITY_DRIVER
618
724
  : undefined,
619
725
  rulesSection,
726
+ scratchpadSection: scratchpadSectionText,
620
727
  }),
621
728
  });
622
729
  // Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
@@ -800,7 +907,7 @@ export async function registerYagni(pi, deps = {}) {
800
907
  // context % on line 2, and extension statuses (brand, todos, mode) on
801
908
  // line 3. The factory captures ctx so the footer can read session data
802
909
  // (token stats, context usage) that isn't on the footerData provider.
803
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
910
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle, childUsage)(tui, theme, footerData));
804
911
  ctx.ui?.onTerminalInput?.((data) => {
805
912
  if (isShiftTab(data)) {
806
913
  modeHolder.set(cyclePermissionMode(modeHolder.get()));
@@ -819,6 +926,14 @@ export async function registerYagni(pi, deps = {}) {
819
926
  catch {
820
927
  // A label must never disrupt session start.
821
928
  }
929
+ // The pulsing star indicator that pairs with the composed working line.
930
+ // Fails closed to pi's default spinner.
931
+ try {
932
+ ctx.ui?.setWorkingIndicator?.({ frames: [...WORKING_INDICATOR_FRAMES], intervalMs: 140 });
933
+ }
934
+ catch {
935
+ // An indicator must never disrupt session start.
936
+ }
822
937
  // One-time hint naming what changed now that reasoning is collapsed by
823
938
  // default (option 2). Gated on the launcher's YAGNI_HIDE_THINKING_SEEDED
824
939
  // marker so it fires only on the launch that actually seeded the key — it
@@ -1015,5 +1130,10 @@ export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE
1015
1130
  // W4 trust plumbing: the durable write-spool for the judgment-capture tools
1016
1131
  // (idempotencyKey-replayed; server-side dedup makes flush safe).
1017
1132
  export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL_AGE_MS, _setSpoolHomeForTest, } from "./spool.js";
1133
+ // The condensed Claude Code-style transcript: flat tool rows, run summaries,
1134
+ // and the composed streaming status line.
1135
+ export { registerCondensedTools, displayPath, isScratchpadPath, primaryArg, formatRowTitle, formatWriteBody, formatEditBody, formatBashErrorBody, formatBashPartialBody, formatExpandedOutput, splitBashError, countPatchAdditions, WRITE_PREVIEW_LINES, DIFF_PREVIEW_LINES, } from "./condensedTools.js";
1136
+ export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.js";
1137
+ export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
1018
1138
  export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
1019
1139
  //# sourceMappingURL=index.js.map
@@ -727,12 +727,59 @@ function isPipeToShell(command) {
727
727
  * backticks, including inside double quotes). Matches FORBIDDEN rules only —
728
728
  * can upgrade the classification, never relax it.
729
729
  */
730
+ const QUOTED_HEREDOC_OPEN = /^cat[ \t]+<<[ \t]*(?:'([^']+)'|"([^"]+)"|\\([A-Za-z_][A-Za-z0-9_]*))[ \t]*\r?\n/;
731
+ /**
732
+ * Recognize the `cat <<'QUOTED' … QUOTED` idiom inside a command substitution.
733
+ *
734
+ * This is the one way to pass multi-line body content to `gh`/`git` as a
735
+ * literal string: the quoted delimiter (`<<'EOF'`, `<<"EOF"`, or `<<\EOF`)
736
+ * makes the heredoc body literal — no `$`/backtick expansion — and `cat` merely
737
+ * prints it. A body like `rm -rf /tmp/test-db` inside such a heredoc is PROSE,
738
+ * not a command; sweeping it for forbidden rules is a false positive.
739
+ *
740
+ * Returns true ONLY for the exact safe shape: the whole substitution is `cat`
741
+ * with no arguments, followed by a heredoc whose delimiter is quoted and whose
742
+ * terminator is the delimiter on its own final line. Any deviation (unquoted
743
+ * delimiter, extra args to `cat`, a second command after the terminator)
744
+ * returns false and falls through to the ordinary danger scan. Safety does not
745
+ * hinge on this exemption: the outer command (the `gh pr create`, the `rm`) is
746
+ * still classified on its own merits, heredoc or not.
747
+ */
748
+ function isLiteralQuotedCatHeredoc(inner) {
749
+ const text = inner.trim();
750
+ const m = QUOTED_HEREDOC_OPEN.exec(text);
751
+ if (!m)
752
+ return false;
753
+ const delim = m[1] ?? m[2] ?? m[3];
754
+ const bodyStart = m[0].length;
755
+ const lines = text.slice(bodyStart).split(/\r?\n/);
756
+ if (lines.length === 0)
757
+ return false;
758
+ // The final line must be the bare delimiter (the closing `)` is already
759
+ // stripped by extractSubstitutions).
760
+ if (lines[lines.length - 1].trim() !== delim)
761
+ return false;
762
+ // No line BEFORE the terminator may equal the delimiter: in real bash, a
763
+ // delimiter line terminates the heredoc there, turning everything after it
764
+ // into an executed command (e.g. a body with a bare `EOF` line followed by
765
+ // `rm -rf /x` would run the `rm`). Reject that shape and fall to the scan.
766
+ for (const line of lines.slice(0, -1)) {
767
+ if (line.trim() === delim)
768
+ return false;
769
+ }
770
+ return true;
771
+ }
730
772
  function dangerScanSubstitutions(command, policy, depth) {
731
773
  if (depth > MAX_SCAN_DEPTH)
732
774
  return null;
733
775
  for (const inner of extractSubstitutions(command)) {
734
776
  if (inner.trim().length === 0)
735
777
  continue;
778
+ // Quoted-heredoc cat: the body is literal data, not an executable
779
+ // command — skipping it avoids false "forbidden" hits on prose that happens
780
+ // to contain a forbidden-looking token. The outer command still classifies.
781
+ if (isLiteralQuotedCatHeredoc(inner))
782
+ continue;
736
783
  if (isPipeToShell(inner)) {
737
784
  return {
738
785
  decision: "forbidden",
@@ -151,6 +151,12 @@ export interface RegisterPermissionDeps {
151
151
  guardianState?: import("./guardian.js").GuardianStateHandle;
152
152
  /** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
153
153
  guardianLimits?: import("./guardian.js").GuardianLimits;
154
+ /**
155
+ * Session child-usage accumulator (childUsage.ts): each completed Guardian
156
+ * review's spend is recorded so the footer's session totals include it.
157
+ * Absent = not wired: no recording, nothing else changes.
158
+ */
159
+ childUsage?: import("../childUsage.js").ChildUsageHandle;
154
160
  /** Override the Guardian model tier (default: efficient). */
155
161
  guardianTier?: string;
156
162
  /** Injectable Guardian review function (tests pass a stub). */
@@ -366,6 +366,10 @@ export function registerPermissionGate(pi, deps = {}) {
366
366
  const ERROR_ASK_CAP = 3;
367
367
  let errorFallbackAsks = 0;
368
368
  let breakerEscalationOffered = false;
369
+ // Uniquifies each Guardian review's childUsage key: parallel gated tool
370
+ // calls can start their reviews in the same millisecond, and a timestamp
371
+ // alone would silently drop the second review's spend as a "duplicate".
372
+ let reviewSpendSeq = 0;
369
373
  const emitGateEvent = (event) => {
370
374
  if (!deps.onGuardianEvent)
371
375
  return;
@@ -561,13 +565,18 @@ export function registerPermissionGate(pi, deps = {}) {
561
565
  ctx.ui.setStatus?.("yagni-guardian", undefined);
562
566
  }
563
567
  const durationMs = Date.now() - startMs;
564
- const emitDiag = (outcome, rationale) => {
568
+ // Footer session totals include the review's spend. Each review
569
+ // records exactly once, right here, so the key only has to be unique
570
+ // per review — the seq keeps same-millisecond parallel reviews apart.
571
+ deps.childUsage?.record("guardian", `${startMs}#${reviewSpendSeq++}`, { cost: reviewResult.cost });
572
+ const emitDiag = (outcome, rationale, rawOutput) => {
565
573
  if (!deps.onGuardianReview)
566
574
  return;
567
575
  void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
568
576
  durationMs,
569
577
  tier: guardianTier,
570
578
  ...(rationale ? { rationale } : {}),
579
+ ...(rawOutput ? { rawOutput } : {}),
571
580
  debug: isDebug(),
572
581
  }))).catch(() => { });
573
582
  };
@@ -734,7 +743,7 @@ export function registerPermissionGate(pi, deps = {}) {
734
743
  }
735
744
  // Guardian failed (timeout/malformed/network/empty/aborted).
736
745
  const error = reviewResult.error ?? "network";
737
- emitDiag(error);
746
+ emitDiag(error, undefined, reviewResult.rawOutput);
738
747
  if (error === "aborted" || ctx?.signal?.aborted) {
739
748
  // The user aborted mid-consult — silent block: no dialog, no
740
749
  // "Guardian unavailable" warning on a turn they deliberately
@@ -106,7 +106,20 @@ export interface ReviewResult {
106
106
  verdict: GuardianVerdict | null;
107
107
  error?: GuardianError;
108
108
  cost: number;
109
+ /**
110
+ * Scrubbed + capped copy of the model output when the verdict failed to
111
+ * parse (`error: "malformed"`). Present so the sink can capture the exact
112
+ * failure shape. Never contains the raw command unredacted: `scrubSecrets`
113
+ * removes secret-shaped values before this is stored.
114
+ */
115
+ rawOutput?: string;
109
116
  }
117
+ /**
118
+ * Cap on the malformed-output capture. The model's unparseable verdict is
119
+ * usually a single short JSON-ish blob; this bounds a pathological output so
120
+ * it cannot balloon a log line or a Sentry payload.
121
+ */
122
+ export declare const GUARDIAN_RAW_OUTPUT_CAP = 2048;
110
123
  export interface ReviewCommandDeps {
111
124
  runStage?: typeof defaultRunStage;
112
125
  cwd: string;
@@ -143,6 +156,12 @@ export interface GuardianDiagnosticEvent {
143
156
  commandHash?: string;
144
157
  /** Debug-only: the Guardian's rationale. */
145
158
  rationale?: string;
159
+ /**
160
+ * Scrubbed + capped copy of the unparseable model output, present only for
161
+ * `outcome: "malformed"`. Always-on (NOT debug-gated): it is already
162
+ * `scrubSecrets`-redacted and size-capped at the source.
163
+ */
164
+ rawOutput?: string;
146
165
  }
147
166
  /**
148
167
  * Create a sanitized diagnostic event. Never includes the raw command text
@@ -153,6 +172,7 @@ export declare function buildDiagnosticEvent(outcome: GuardianOutcome | Guardian
153
172
  tier?: string;
154
173
  rationale?: string;
155
174
  commandHash?: string;
175
+ rawOutput?: string;
156
176
  debug?: boolean;
157
177
  }): GuardianDiagnosticEvent;
158
178
  //# sourceMappingURL=guardian.d.ts.map
@@ -28,6 +28,7 @@
28
28
  * alternating deny/ask.
29
29
  */
30
30
  import { runStage as defaultRunStage } from "../pipeline/runner.js";
31
+ import { scrubSecrets } from "../pipeline/scrubSecrets.js";
31
32
  export const DEFAULT_GUARDIAN_LIMITS = {
32
33
  maxReviews: 120,
33
34
  maxConsecutiveDenials: 3,
@@ -161,6 +162,12 @@ export function formatGuardianSubtotal(state, limits) {
161
162
  const plural = state.reviews === 1 ? "review" : "reviews";
162
163
  return `Guardian: ${state.reviews} ${plural}.`;
163
164
  }
165
+ /**
166
+ * Cap on the malformed-output capture. The model's unparseable verdict is
167
+ * usually a single short JSON-ish blob; this bounds a pathological output so
168
+ * it cannot balloon a log line or a Sentry payload.
169
+ */
170
+ export const GUARDIAN_RAW_OUTPUT_CAP = 2048;
164
171
  /**
165
172
  * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
166
173
  * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
@@ -231,7 +238,14 @@ export async function reviewCommand(command, deps) {
231
238
  }
232
239
  const verdict = parseVerdict(output);
233
240
  if (!verdict) {
234
- return { verdict: null, error: "malformed", cost };
241
+ // Scrubbed + capped so the local sink and Sentry can see the exact
242
+ // failure shape without carrying a raw command or a secret it echoed.
243
+ return {
244
+ verdict: null,
245
+ error: "malformed",
246
+ cost,
247
+ rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
248
+ };
235
249
  }
236
250
  return { verdict, cost };
237
251
  }
@@ -261,6 +275,7 @@ export function buildDiagnosticEvent(outcome, opts) {
261
275
  outcome,
262
276
  ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
263
277
  ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
278
+ ...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
264
279
  };
265
280
  if (opts.debug) {
266
281
  if (opts.rationale)
@@ -110,6 +110,14 @@ export interface RegisterGoDeps {
110
110
  * cares injects its own fake.
111
111
  */
112
112
  fetchRunSpend?: (runId: string, signal?: AbortSignal) => Promise<SpendResponse | null>;
113
+ /**
114
+ * Session child-usage accumulator (childUsage.ts). Each attempt's newly-run
115
+ * stage usage is recorded (keyed by runId + attempt start, because a resumed
116
+ * attempt's stages are new spend, never a replay) so the footer's session
117
+ * totals include /go spend — the same figure `runCostNote` already prints.
118
+ * Absent = not wired.
119
+ */
120
+ childUsage?: import("../childUsage.js").ChildUsageHandle;
113
121
  /** Injectable fs existence check (worktree adoption on resume). */
114
122
  exists?: (path: string) => boolean;
115
123
  /** Clock seam for registry rows + staleness. */
@@ -969,6 +969,14 @@ export function registerGoCommand(pi, deps = {}) {
969
969
  if (result.stopReason === "clean" && runSignal.aborted) {
970
970
  result = { ...result, stopReason: "aborted" };
971
971
  }
972
+ // Footer session totals include this run's spend. Keyed per ATTEMPT
973
+ // (runId + this invocation's start), not per runId: `result.stages`
974
+ // only ever holds the stages THIS attempt actually ran (a resume's
975
+ // replayed spend lives in the checkpoint's priorUsage, never here),
976
+ // so an abort → same-session resume must record both attempts — a
977
+ // runId-only key would silently drop the resume's new spend. Partial
978
+ // spend on an aborted attempt is still real spend worth counting.
979
+ deps.childUsage?.record("go", `${runId}@${startedAt}`, aggregateRunUsage(result.stages, result.rounds));
972
980
  // FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
973
981
  // (worktree always; --here only over a clean pre-run baseline) with the
974
982
  // provenance trailer, and push + PR when --pr asked for it. Iron
@@ -65,9 +65,16 @@ export declare function buildStageInvocation(stage: PipelineStage, ctx: {
65
65
  * `yagni` provider) before a stage passthrough. Mirrors buildLaunch's
66
66
  * `userChoseProvider` guard so a passthrough that already chose a provider is
67
67
  * left untouched.
68
+ *
69
+ * `otelExtensionPath` (absent on a non-exporting launch) additionally loads
70
+ * pi-otel so child LLM spend is traced too — stage children are the bulk of a
71
+ * /go run's cost, and an OTel export that misses them undercounts. The path
72
+ * arrives via `YAGNI_OTEL_EXTENSION_PATH` from the launcher's gate (see the
73
+ * CLI's otel.ts); the capture-mode and endpoint env rides the inherited env.
68
74
  */
69
75
  export declare function groundedChildArgv(stageArgv: string[], opts: {
70
76
  piCli: string;
71
77
  extensionPath: string;
78
+ otelExtensionPath?: string;
72
79
  }): string[];
73
80
  //# sourceMappingURL=invocation.d.ts.map
@@ -84,6 +84,12 @@ export function buildStageInvocation(stage, ctx) {
84
84
  * `yagni` provider) before a stage passthrough. Mirrors buildLaunch's
85
85
  * `userChoseProvider` guard so a passthrough that already chose a provider is
86
86
  * left untouched.
87
+ *
88
+ * `otelExtensionPath` (absent on a non-exporting launch) additionally loads
89
+ * pi-otel so child LLM spend is traced too — stage children are the bulk of a
90
+ * /go run's cost, and an OTel export that misses them undercounts. The path
91
+ * arrives via `YAGNI_OTEL_EXTENSION_PATH` from the launcher's gate (see the
92
+ * CLI's otel.ts); the capture-mode and endpoint env rides the inherited env.
87
93
  */
88
94
  export function groundedChildArgv(stageArgv, opts) {
89
95
  const userChoseProvider = stageArgv.includes("--provider");
@@ -91,6 +97,7 @@ export function groundedChildArgv(stageArgv, opts) {
91
97
  opts.piCli,
92
98
  "-e",
93
99
  opts.extensionPath,
100
+ ...(opts.otelExtensionPath ? ["-e", opts.otelExtensionPath] : []),
94
101
  ...(userChoseProvider ? [] : ["--provider", "yagni"]),
95
102
  ...stageArgv,
96
103
  ];