@yagni-app/code 0.3.5 → 1.0.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 (75) 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 +7 -0
  8. package/dist/extension/askAdvisorTool.js +13 -3
  9. package/dist/extension/askUserQuestionTool.d.ts +54 -0
  10. package/dist/extension/askUserQuestionTool.js +621 -0
  11. package/dist/extension/askYagniTool.js +2 -0
  12. package/dist/extension/branding.d.ts +15 -0
  13. package/dist/extension/branding.js +76 -0
  14. package/dist/extension/chipEditor.d.ts +22 -1
  15. package/dist/extension/chipEditor.js +58 -5
  16. package/dist/extension/cmux/state.js +9 -16
  17. package/dist/extension/condensedTools.d.ts +93 -0
  18. package/dist/extension/condensedTools.js +392 -0
  19. package/dist/extension/crashReport.js +12 -0
  20. package/dist/extension/decisionCapture.js +3 -0
  21. package/dist/extension/decisions.js +4 -0
  22. package/dist/extension/diagnostics.d.ts +31 -0
  23. package/dist/extension/diagnostics.js +53 -55
  24. package/dist/extension/diffStat.d.ts +62 -0
  25. package/dist/extension/diffStat.js +158 -0
  26. package/dist/extension/errorSink.d.ts +64 -0
  27. package/dist/extension/errorSink.js +180 -0
  28. package/dist/extension/feedbackCommand.d.ts +38 -0
  29. package/dist/extension/feedbackCommand.js +151 -0
  30. package/dist/extension/footer.d.ts +2 -0
  31. package/dist/extension/footer.js +21 -8
  32. package/dist/extension/hooks.js +12 -12
  33. package/dist/extension/index.d.ts +7 -0
  34. package/dist/extension/index.js +161 -42
  35. package/dist/extension/mineBeat.js +13 -0
  36. package/dist/extension/permission/execPolicy.js +47 -0
  37. package/dist/extension/pipeline/goCommand.js +2 -0
  38. package/dist/extension/pipeline/invocation.d.ts +7 -0
  39. package/dist/extension/pipeline/invocation.js +7 -0
  40. package/dist/extension/pipeline/personas.js +4 -4
  41. package/dist/extension/pipeline/runner.d.ts +1 -0
  42. package/dist/extension/pipeline/runner.js +24 -3
  43. package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
  44. package/dist/extension/pipeline/sessionWorktree.js +225 -0
  45. package/dist/extension/scratchpad.d.ts +66 -0
  46. package/dist/extension/scratchpad.js +93 -0
  47. package/dist/extension/silentTurnReminder.js +18 -14
  48. package/dist/extension/subagents.d.ts +10 -0
  49. package/dist/extension/subagents.js +18 -4
  50. package/dist/extension/todos.d.ts +1 -0
  51. package/dist/extension/todos.js +15 -0
  52. package/dist/extension/toolRuns.d.ts +92 -0
  53. package/dist/extension/toolRuns.js +201 -0
  54. package/dist/extension/turnLog.js +17 -46
  55. package/dist/extension/webFetch.d.ts +85 -0
  56. package/dist/extension/webFetch.js +192 -0
  57. package/dist/extension/webFetchTool.d.ts +34 -0
  58. package/dist/extension/webFetchTool.js +106 -0
  59. package/dist/extension/workingLine.d.ts +49 -0
  60. package/dist/extension/workingLine.js +116 -0
  61. package/dist/feedback.d.ts +77 -0
  62. package/dist/feedback.js +500 -0
  63. package/dist/goHeadless.d.ts +3 -0
  64. package/dist/goHeadless.js +13 -0
  65. package/dist/launch.d.ts +8 -0
  66. package/dist/launch.js +6 -0
  67. package/dist/otel.d.ts +150 -0
  68. package/dist/otel.js +291 -0
  69. package/dist/outputFormat.d.ts +83 -0
  70. package/dist/outputFormat.js +207 -0
  71. package/dist/paths.d.ts +10 -0
  72. package/dist/paths.js +13 -0
  73. package/dist/worktreeArgs.d.ts +43 -0
  74. package/dist/worktreeArgs.js +96 -0
  75. package/package.json +4 -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,13 +230,18 @@ 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%
@@ -269,17 +275,23 @@ export function renderFooterLines(input, theme, width, padX = 0) {
269
275
  export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
270
276
  return (_tui, theme, footerData) => {
271
277
  let gitCache;
272
- const unsubscribeBranch = footerData.onBranchChange?.(() => {
278
+ let diffStatCache;
279
+ const clearCaches = () => {
273
280
  gitCache = undefined;
274
- });
281
+ diffStatCache = undefined;
282
+ };
283
+ const unsubscribeBranch = footerData.onBranchChange?.(clearCaches);
275
284
  const gitInfo = () => {
276
285
  if (!gitCache) {
277
- gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
286
+ // Read cwd once; both the git info and the diff cache key off it.
287
+ const dir = ctx.sessionManager.getCwd();
288
+ gitCache = detectGitInfo(dir, process.env.HOME || process.env.USERPROFILE);
289
+ diffStatCache = createDiffStatCache(dir);
278
290
  }
279
291
  return gitCache;
280
292
  };
281
293
  if (invalidateHandle) {
282
- invalidateHandle.invalidateGit = () => { gitCache = undefined; };
294
+ invalidateHandle.invalidateGit = clearCaches;
283
295
  invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
296
  }
285
297
  return {
@@ -291,13 +303,14 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
291
303
  git: gitInfo(),
292
304
  model: ctx.model?.id ?? "no-model",
293
305
  mode: modeHolder?.get() ?? null,
306
+ diff: formatDiffStat(diffStatCache?.get() ?? null),
294
307
  usage: collectUsage(ctx.sessionManager),
295
308
  contextPercent: ctx.getContextUsage()?.percent ?? null,
296
309
  statuses,
297
310
  }, theme, width, resolveFooterPadX());
298
311
  },
299
312
  invalidate() {
300
- gitCache = undefined;
313
+ clearCaches();
301
314
  },
302
315
  dispose() {
303
316
  unsubscribeBranch?.();
@@ -17,11 +17,12 @@
17
17
  * Fail-soft: a broken hook degrades to "no hook," never to "broken session."
18
18
  */
19
19
  import { spawn } from "node:child_process";
20
- import { mkdirSync, appendFileSync, existsSync, readFileSync } from "node:fs";
21
- import { dirname, join } from "node:path";
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
  import { codeStateHome } from "./stateHome.js";
24
24
  import { isDebug } from "./diagnostics.js";
25
+ import { logEvent } from "./errorSink.js";
25
26
  const SUPPORTED_EVENTS = [
26
27
  "SessionStart",
27
28
  "UserPromptSubmit",
@@ -286,16 +287,15 @@ export function parseCompactCancel(stdout) {
286
287
  function logHookEvent(env, payload) {
287
288
  if (!isDebug(env))
288
289
  return;
289
- try {
290
- if (process.env.NODE_TEST_CONTEXT)
291
- return;
292
- const logPath = join(codeStateHome(null, env), "logs", "hooks.log");
293
- mkdirSync(dirname(logPath), { recursive: true });
294
- appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
295
- }
296
- catch {
297
- // logging must never break the session
298
- }
290
+ const event = typeof payload.event === "string" ? payload.event : "hook_event";
291
+ const { event: _ignored, ...fields } = payload;
292
+ logEvent({
293
+ source: "hooks",
294
+ level: "debug",
295
+ event,
296
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
297
+ fields,
298
+ });
299
299
  }
300
300
  /** Filter hook groups by workspace trust: project-level groups are skipped when untrusted. */
301
301
  function filterByTrust(groups, isTrusted) {
@@ -120,6 +120,7 @@ export default function (pi: ExtensionAPI): Promise<void>;
120
120
  export { makeAskYagniTool } from "./askYagniTool.js";
121
121
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
122
122
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
123
+ export { makeAskUserQuestionTool } from "./askUserQuestionTool.js";
123
124
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
124
125
  export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
125
126
  export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./permission/guardian.js";
@@ -185,6 +186,12 @@ export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE
185
186
  export type { TokenProvider, TokenProviderDeps, ScheduleFn } from "./tokenProvider.js";
186
187
  export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL_AGE_MS, _setSpoolHomeForTest, } from "./spool.js";
187
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";
188
195
  export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
189
196
  export type { CrashReporter, CrashReporterOpts, FatalCrashOpts, SanitizedCrash } from "./crashReport.js";
190
197
  //# sourceMappingURL=index.d.ts.map
@@ -1,13 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { appendFileSync, mkdirSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
2
  import { Text } from "@earendil-works/pi-tui";
5
3
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
4
  import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
7
5
  import { redactCommand } from "./redact.js";
8
6
  import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
9
7
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
8
+ import { makeAskUserQuestionTool } from "./askUserQuestionTool.js";
10
9
  import { makeAskYagniTool } from "./askYagniTool.js";
10
+ import { makeWebFetchTool } from "./webFetchTool.js";
11
11
  import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
12
12
  import { makeReviewBusinessMatchTool } from "./reviewTool.js";
13
13
  import { registerCmuxBridge } from "./cmux/index.js";
@@ -16,8 +16,11 @@ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
16
16
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
17
17
  import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA } from "./branding.js";
18
18
  import { claudeRulesSection } from "./claudeRules.js";
19
+ import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadSection } from "./scratchpad.js";
19
20
  import { registerCostCommand } from "./costHud.js";
20
21
  import { isDebug } from "./diagnostics.js";
22
+ import { logEvent } from "./errorSink.js";
23
+ import { registerFeedbackCommands } from "./feedbackCommand.js";
21
24
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
22
25
  import { codeStateHome } from "./stateHome.js";
23
26
  import { logTurnLifecycle } from "./turnLog.js";
@@ -46,6 +49,9 @@ import { registerChipEditor } from "./chipEditor.js";
46
49
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
47
50
  import { makeFlywheelState } from "./flywheel.js";
48
51
  import { registerSilentTurnReminder } from "./silentTurnReminder.js";
52
+ import { registerCondensedTools } from "./condensedTools.js";
53
+ import { isDesktopSurface } from "./surface.js";
54
+ import { registerWorkingLine, WORKING_INDICATOR_FRAMES } from "./workingLine.js";
49
55
  function isEvalMode(env = process.env) {
50
56
  return env.YAGNI_CODE_EVAL_MODE === "1";
51
57
  }
@@ -158,7 +164,15 @@ export async function registerYagni(pi, deps = {}) {
158
164
  // no-position record suggestions reach the model) and record_decision
159
165
  // (flywheel-attributed records send dedupe: true). Run 7.
160
166
  const flywheelState = makeFlywheelState();
167
+ // The composed streaming status line ("Shaping… (12m 54s · ↓ 47.5k tokens)").
168
+ // Registered before the subagent/advisor tools: they publish their live
169
+ // progress through this handle so the elapsed/token suffix survives their
170
+ // overrides. TUI-gated internally (agent_start checks ctx.mode).
171
+ const workingLine = registerWorkingLine(pi);
161
172
  pi.registerTool(makeAskYagniTool({ ...toolOpts, flywheel: flywheelState, getRepo: () => sessionRepo }));
173
+ // WebFetch (YAG-578): read an arbitrary URL as clean markdown + a
174
+ // standard-tier extraction, replacing the bash + curl + python dance.
175
+ pi.registerTool(makeWebFetchTool(toolOpts));
162
176
  // Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
163
177
  // workspace tracker, attributed to the developer via per-user credentials.
164
178
  if (!evalMode) {
@@ -172,11 +186,14 @@ export async function registerYagni(pi, deps = {}) {
172
186
  // hide it forever from a session that switched TO Advanced. One state handle
173
187
  // per session, shared with /advise so they draw on the same cap.
174
188
  const advisorState = makeAdvisorState();
175
- const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
189
+ const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine });
176
190
  pi.registerTool(askAdvisorTool);
177
191
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
178
192
  // draws on the same cap rather than opening a side channel around it.
179
193
  registerAdviseCommand(pi, askAdvisorTool);
194
+ // Structured human questions: the model poses a closed 2-4-option question
195
+ // and gets a clean machine-readable answer via ctx.ui.custom.
196
+ pi.registerTool(makeAskUserQuestionTool());
180
197
  // The differentiated business-grounded tools (loop bricks): review a change
181
198
  // for business fit, rank the next work by business priority, and record the
182
199
  // engineering rationale back onto the work-item.
@@ -209,7 +226,7 @@ export async function registerYagni(pi, deps = {}) {
209
226
  // driver's delegation identity for the diamond directive (see the
210
227
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
211
228
  const ultraHolder = createUltraHolder();
212
- registerSubagents(pi, { isUltra: () => ultraHolder.get() });
229
+ registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine });
213
230
  registerUltraCommand(pi, ultraHolder);
214
231
  // Shared fetch timeout for the small, interactive display-path reads below
215
232
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -244,6 +261,19 @@ export async function registerYagni(pi, deps = {}) {
244
261
  // M6 eval (report-only): /go-compare runs a ticket grounded vs blind and reports
245
262
  // the business-fit delta. Never wired to routing.
246
263
  registerGoCompareCommand(pi);
264
+ // YAG-580: /feedback (alias /bug) + /diagnostics. The capture/upload is the
265
+ // whole point, so it is gated to non-eval mode like every external side
266
+ // effect; the command's deps (doctor report, git state, child transcripts)
267
+ // are injected so the handler stays unit-testable without spawning git or
268
+ // reading the real session dir.
269
+ if (!evalMode) {
270
+ registerFeedbackCommands(pi, {
271
+ baseUrl,
272
+ getToken: getTokenFn,
273
+ fetchImpl: deps.fetchImpl,
274
+ env: deps.env,
275
+ });
276
+ }
247
277
  // W4 judgment loop: the decisions surface (/decide + /decisions) and the
248
278
  // bless-with-remember capture are the same product-intent write as the record
249
279
  // tools, so both are gated together (skipped in eval mode).
@@ -288,18 +318,19 @@ export async function registerYagni(pi, deps = {}) {
288
318
  ...(guardianMaxAttemptsAdvertised !== undefined ? { maxAttempts: guardianMaxAttemptsAdvertised } : {}),
289
319
  });
290
320
  guardianLimits.timeoutMs = guardianTimeoutMs;
291
- // YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
292
- // never the command). The remote guardian-events stream below is the
293
- // separate, opt-in, per-workspace analytics sink; the two are independent.
321
+ // YAG-510: Guardian events go to the unified sink under source:"guardian"
322
+ // (hash-only, never the command). The remote guardian-events stream below is
323
+ // the separate, opt-in, per-workspace analytics sink; the two are independent.
294
324
  const guardianLogSink = (payload) => {
295
- try {
296
- if (process.env.NODE_TEST_CONTEXT)
297
- return;
298
- const logPath = join(codeStateHome(null), "logs", "guardian.log");
299
- mkdirSync(dirname(logPath), { recursive: true });
300
- appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
301
- }
302
- catch { /* logging must never break the session */ }
325
+ const event = typeof payload.event === "string" ? payload.event : "guardian_event";
326
+ const { event: _ignored, ...fields } = payload;
327
+ logEvent({
328
+ source: "guardian",
329
+ level: event === "guardian_event_post_failed" ? "error" : "info",
330
+ event,
331
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
332
+ fields,
333
+ });
303
334
  };
304
335
  // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
305
336
  // at startup (grants added by other concurrent sessions appear next launch —
@@ -331,7 +362,8 @@ export async function registerYagni(pi, deps = {}) {
331
362
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
332
363
  // metadata, no command content; "raw" → adds client-REDACTED command and
333
364
  // rationale. Fire-and-forget: one attempt, short timeout, failures logged
334
- // fail-soft to guardian.log — a storage outage never touches the session.
365
+ // fail-soft to the unified sink (source:"guardian") — a storage outage
366
+ // never touches the session.
335
367
  onGuardianEvent: guardianStorageTier === "off" || evalMode
336
368
  ? undefined
337
369
  : (ev) => {
@@ -369,10 +401,10 @@ export async function registerYagni(pi, deps = {}) {
369
401
  guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
370
402
  }
371
403
  }
372
- catch (err) {
404
+ catch {
373
405
  guardianLogSink({
374
406
  event: "guardian_event_post_failed",
375
- error: err instanceof Error ? err.message : "unknown",
407
+ kind: "network",
376
408
  });
377
409
  }
378
410
  })();
@@ -454,15 +486,13 @@ export async function registerYagni(pi, deps = {}) {
454
486
  onDivergence: (driverServerUsd, localUsd) => {
455
487
  if (!isDebug(env))
456
488
  return;
457
- try {
458
- const path = join(codeStateHome(null, env), "logs", "cost-divergence.log");
459
- mkdirSync(dirname(path), { recursive: true });
460
- const line = { ts: new Date().toISOString(), driverServerUsd, localUsd };
461
- appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
462
- }
463
- catch {
464
- /* a diagnostic must never break /cost */
465
- }
489
+ logEvent({
490
+ source: "cost",
491
+ level: "debug",
492
+ event: "cost_divergence",
493
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
494
+ fields: { driverServerUsd, localUsd },
495
+ });
466
496
  },
467
497
  // Carry-over (/cost re-review): surfaces sessionRuns.ts's dropped-run-id
468
498
  // count as costHud's "Excludes N earlier /go runs." note.
@@ -572,6 +602,51 @@ export async function registerYagni(pi, deps = {}) {
572
602
  // pointers. Computed once per activation (rules are launch-time state, like
573
603
  // pi's own skill discovery); fail-soft to null.
574
604
  const rulesSection = claudeRulesSection(deps.env ?? process.env);
605
+ // YAG-575: session scratchpad — a permission-free dir the agent writes its
606
+ // working state to. Computed + ensured once at activation (like rulesSection,
607
+ // which must exist before the first before_agent_start), not on session_start,
608
+ // so the very first turn already carries the section. Fails closed: no
609
+ // sessionId (a bare pi run) or a failed mkdir means no section, and the mkdir
610
+ // failure is logged so a missing scratchpad is not silent.
611
+ const scratchpadDirPath = scratchpadDirFor({
612
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
613
+ cwd: process.cwd(),
614
+ tmp: env[SCRATCHPAD_TMPDIR_ENV] || undefined,
615
+ });
616
+ let scratchpadSectionText;
617
+ if (scratchpadDirPath) {
618
+ const ensured = ensureScratchpadDir(scratchpadDirPath);
619
+ if (ensured) {
620
+ scratchpadSectionText = scratchpadSection(ensured);
621
+ }
622
+ else {
623
+ logEvent({
624
+ source: "scratchpad",
625
+ level: "error",
626
+ event: "scratchpad_mkdir_failed",
627
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
628
+ });
629
+ }
630
+ }
631
+ // The condensed Claude Code-style transcript: the seven built-ins re-render
632
+ // as flat "● Tool(arg)" rows, quiet runs collapse into one "Read 2 files,
633
+ // ran 2 shell commands" summary line, and project writes/edits keep visible
634
+ // previews. Execution delegates to the same factory-made built-ins pi would
635
+ // have used (settings-honoring), so ONLY presentation changes — which is why
636
+ // eval mode keeps pi's stock registration (measured behavior stays
637
+ // byte-identical) and the desktop surface keeps the structured pipeline.
638
+ // YAGNI_CLASSIC_TOOL_ROWS=1 is the debugging escape hatch back to pi's
639
+ // boxed renderers.
640
+ if (!evalMode && !isDesktopSurface() && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
641
+ try {
642
+ registerCondensedTools(pi, {
643
+ ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
644
+ });
645
+ }
646
+ catch {
647
+ // Rendering must never break activation; pi's built-ins remain.
648
+ }
649
+ }
575
650
  // Own the identity + inject live company context (and repo rules) on every
576
651
  // turn. The extension loads identically in every pi process this app spawns —
577
652
  // the interactive driver AND every `/go` stage child, subagent, and advisor
@@ -596,6 +671,7 @@ export async function registerYagni(pi, deps = {}) {
596
671
  : YAGNI_IDENTITY_DRIVER
597
672
  : undefined,
598
673
  rulesSection,
674
+ scratchpadSection: scratchpadSectionText,
599
675
  }),
600
676
  });
601
677
  // Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
@@ -691,21 +767,16 @@ export async function registerYagni(pi, deps = {}) {
691
767
  // session token is expired and refresh failed — the most critical
692
768
  // failure signal is no longer silently dropped.
693
769
  void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
694
- // YAG-500 Fix F: local diagnostics log under YAGNI_DEBUG.
695
- if (isDebug(env)) {
696
- try {
697
- const logPath = join(codeStateHome(null, env), "logs", "auth-events.log");
698
- mkdirSync(dirname(logPath), { recursive: true });
699
- appendFileSync(logPath, JSON.stringify({
700
- ts: new Date().toISOString(),
701
- status: 401,
702
- refresh: rotated ? "succeeded" : "failed",
703
- }) + "\n", "utf8");
704
- }
705
- catch {
706
- // A diagnostic must never break the session.
707
- }
708
- }
770
+ // YAG-500 Fix F: auth-401 signal is content-free (status + refresh
771
+ // boolean), so it is always-on and upload-safe by the default-on
772
+ // invariant. No message content, no tokens, no headers.
773
+ logEvent({
774
+ source: "auth",
775
+ level: "info",
776
+ event: "auth_401",
777
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
778
+ fields: { status: 401, refresh: rotated ? "succeeded" : "failed" },
779
+ });
709
780
  return { message: { ...msg, errorMessage: explanation } };
710
781
  }
711
782
  // YAG-460: the backend proxy answers an oversized conversation with an
@@ -759,6 +830,16 @@ export async function registerYagni(pi, deps = {}) {
759
830
  // and a "Pi can explain its own features…" line) with a YAGNI Code masthead,
760
831
  // set the terminal title, and add a footer brand mark. TUI only.
761
832
  pi.on("session_start", async (event, ctx) => {
833
+ // Surface Pi's native find/grep/ls on the driver. These are Pi's
834
+ // equivalents of Claude Code's Glob/Grep/LS; the /go stages and subagents
835
+ // already pass them explicitly, but the driver defaults to read/bash/edit/write
836
+ // and otherwise reaches for `bash` find/grep/ls.
837
+ try {
838
+ pi.setActiveTools([...pi.getActiveTools(), "grep", "find", "ls"]);
839
+ }
840
+ catch {
841
+ // Tool enrichment must never break session start.
842
+ }
762
843
  ctx.ui?.setTitle(BRAND_NAME);
763
844
  if (ctx.mode === "tui") {
764
845
  ctx.ui?.setStatus?.("brand", BRAND_NAME);
@@ -793,6 +874,14 @@ export async function registerYagni(pi, deps = {}) {
793
874
  catch {
794
875
  // A label must never disrupt session start.
795
876
  }
877
+ // The pulsing star indicator that pairs with the composed working line.
878
+ // Fails closed to pi's default spinner.
879
+ try {
880
+ ctx.ui?.setWorkingIndicator?.({ frames: [...WORKING_INDICATOR_FRAMES], intervalMs: 140 });
881
+ }
882
+ catch {
883
+ // An indicator must never disrupt session start.
884
+ }
796
885
  // One-time hint naming what changed now that reasoning is collapsed by
797
886
  // default (option 2). Gated on the launcher's YAGNI_HIDE_THINKING_SEEDED
798
887
  // marker so it fires only on the launch that actually seeded the key — it
@@ -889,6 +978,30 @@ export async function registerYagni(pi, deps = {}) {
889
978
  footerInvalidateHandle.invalidateGit();
890
979
  }
891
980
  });
981
+ // Seed the unified error trail from tool-exec failures. A tool's SUCCESS
982
+ // is content (it lives in the transcript); its FAILURE is an error and belongs
983
+ // in the sink. We log only the tool name + error class — never args, partial
984
+ // results, or result bodies (those are content and stay out of the upload-safe
985
+ // tier). This is the tool-failure half of the error trail the ticket asks for.
986
+ pi.on("tool_execution_end", (event) => {
987
+ if (!event.isError)
988
+ return;
989
+ // `event.result` is any; its `.error`/message can echo a path or secret
990
+ // (a tool's own failure text). Keep the always-on trail content-free: use
991
+ // ONLY the Error subclass name, never the message or a String() of the
992
+ // result's error payload.
993
+ const errorClass = event.result instanceof Error
994
+ ? event.result.name || "Error"
995
+ : "tool_error";
996
+ logEvent({
997
+ source: "tool",
998
+ level: "error",
999
+ event: "tool_failed",
1000
+ sessionId: sessionIdForLog(),
1001
+ flush: "sync",
1002
+ fields: { toolName: event.toolName, errorClass },
1003
+ });
1004
+ });
892
1005
  }
893
1006
  export default async function (pi) {
894
1007
  await registerYagni(pi);
@@ -902,6 +1015,7 @@ export default async function (pi) {
902
1015
  export { makeAskYagniTool } from "./askYagniTool.js";
903
1016
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
904
1017
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
1018
+ export { makeAskUserQuestionTool } from "./askUserQuestionTool.js";
905
1019
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
906
1020
  export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
907
1021
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
@@ -964,5 +1078,10 @@ export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE
964
1078
  // W4 trust plumbing: the durable write-spool for the judgment-capture tools
965
1079
  // (idempotencyKey-replayed; server-side dedup makes flush safe).
966
1080
  export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL_AGE_MS, _setSpoolHomeForTest, } from "./spool.js";
1081
+ // The condensed Claude Code-style transcript: flat tool rows, run summaries,
1082
+ // and the composed streaming status line.
1083
+ export { registerCondensedTools, displayPath, isScratchpadPath, primaryArg, formatRowTitle, formatWriteBody, formatEditBody, formatBashErrorBody, formatBashPartialBody, formatExpandedOutput, splitBashError, countPatchAdditions, WRITE_PREVIEW_LINES, DIFF_PREVIEW_LINES, } from "./condensedTools.js";
1084
+ export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.js";
1085
+ export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
967
1086
  export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
968
1087
  //# sourceMappingURL=index.js.map
@@ -20,6 +20,7 @@
20
20
  import { execFileSync } from "node:child_process";
21
21
  import * as fs from "node:fs";
22
22
  import { dirname, join } from "node:path";
23
+ import { logEvent } from "./errorSink.js";
23
24
  import { METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
24
25
  /**
25
26
  * Client-side bounds on the mining corpus — mirror of the backend's
@@ -173,6 +174,12 @@ export async function maybeOfferMiningBeat(ctx, opts) {
173
174
  }, { fetchImpl: opts.fetchImpl, policy: METERED_POST_FETCH_POLICY });
174
175
  if (!res.ok) {
175
176
  // No marker: the offer stays available next session.
177
+ logEvent({
178
+ source: "mine-beat",
179
+ level: "error",
180
+ event: "mine_failed",
181
+ fields: { status: res.status },
182
+ });
176
183
  ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
177
184
  return { offered: true, accepted: true };
178
185
  }
@@ -186,6 +193,12 @@ export async function maybeOfferMiningBeat(ctx, opts) {
186
193
  return { offered: true, accepted: true, banked };
187
194
  }
188
195
  catch {
196
+ logEvent({
197
+ source: "mine-beat",
198
+ level: "error",
199
+ event: "mine_failed",
200
+ fields: { kind: "network" },
201
+ });
189
202
  ctx.ui.notify("Seeding the decision ledger failed; YAGNI Code will offer again next session.", "error");
190
203
  return { offered: true, accepted: true };
191
204
  }
@@ -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",
@@ -71,6 +71,7 @@ import { formatRunCostTable } from "./runCostTable.js";
71
71
  import { makeCombinedCheckpointStore, makeFileCheckpointStore, makePiJournalCheckpointStore, } from "./checkpoint.js";
72
72
  import { getToken as defaultGetToken, resolveBaseUrl } from "../config.js";
73
73
  import { makeCrashReporter } from "../crashReport.js";
74
+ import { logEvent } from "../errorSink.js";
74
75
  import { scrubSecrets } from "./scrubSecrets.js";
75
76
  import { isDesktopSurface } from "../surface.js";
76
77
  import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js";
@@ -1104,6 +1105,7 @@ export function registerGoCommand(pi, deps = {}) {
1104
1105
  // return results instead) — report it, fire-and-forget. The default
1105
1106
  // reporter never rejects; the catch guards an injected one.
1106
1107
  void reportCrash(err, "go", runCwd).catch(() => { });
1108
+ logEvent({ source: "go", level: "error", event: "go_failed", fields: { runId } });
1107
1109
  // The stopReason travels to the backend run record; scrub it like
1108
1110
  // every other captured text (a raw error can echo a connection
1109
1111
  // string or key).
@@ -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
  ];