@yagni-app/code 0.3.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/extension/askAdvisorTool.js +2 -0
  2. package/dist/extension/askUserQuestionTool.d.ts +54 -0
  3. package/dist/extension/askUserQuestionTool.js +621 -0
  4. package/dist/extension/branding.d.ts +39 -0
  5. package/dist/extension/branding.js +76 -0
  6. package/dist/extension/cmux/index.d.ts +17 -1
  7. package/dist/extension/cmux/index.js +47 -8
  8. package/dist/extension/cmux/state.d.ts +5 -1
  9. package/dist/extension/cmux/state.js +15 -8
  10. package/dist/extension/crashReport.js +12 -0
  11. package/dist/extension/decisionCapture.js +3 -0
  12. package/dist/extension/decisions.js +4 -0
  13. package/dist/extension/diagnostics.d.ts +31 -0
  14. package/dist/extension/diagnostics.js +53 -55
  15. package/dist/extension/errorSink.d.ts +64 -0
  16. package/dist/extension/errorSink.js +180 -0
  17. package/dist/extension/feedbackCommand.d.ts +38 -0
  18. package/dist/extension/feedbackCommand.js +151 -0
  19. package/dist/extension/hooks.js +12 -12
  20. package/dist/extension/index.d.ts +1 -0
  21. package/dist/extension/index.js +97 -40
  22. package/dist/extension/mineBeat.js +13 -0
  23. package/dist/extension/pipeline/goCommand.js +2 -0
  24. package/dist/extension/pipeline/personas.js +9 -0
  25. package/dist/extension/pipeline/runner.js +9 -0
  26. package/dist/extension/sessionTitle/summarize.d.ts +40 -0
  27. package/dist/extension/sessionTitle/summarize.js +63 -0
  28. package/dist/extension/sessionTitle/title.d.ts +27 -0
  29. package/dist/extension/sessionTitle/title.js +57 -0
  30. package/dist/extension/silentTurnReminder.d.ts +109 -0
  31. package/dist/extension/silentTurnReminder.js +221 -0
  32. package/dist/extension/turnLog.d.ts +14 -0
  33. package/dist/extension/turnLog.js +22 -47
  34. package/dist/extension/webFetch.d.ts +85 -0
  35. package/dist/extension/webFetch.js +192 -0
  36. package/dist/extension/webFetchTool.d.ts +34 -0
  37. package/dist/extension/webFetchTool.js +104 -0
  38. package/package.json +4 -3
  39. package/dist/extension/cmux/naming.d.ts +0 -5
  40. package/dist/extension/cmux/naming.js +0 -23
@@ -75,6 +75,62 @@ export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has
75
75
  * module stays a pure string, with no env dependency of its own.
76
76
  */
77
77
  export const YAGNI_IDENTITY_DRIVER = `${YAGNI_IDENTITY}\n\n${DRIVER_DELEGATION_PARAGRAPH}`;
78
+ /**
79
+ * Standing rule that tickets must be read for their images, not just their
80
+ * text. Injected into EVERY process (driver, `/go` stage children, subagents)
81
+ * via brandSystemPrompt, so the guarantee holds by whichever method a ticket is
82
+ * read (the `linear` CLI, a connector tool, MCP, a direct API call). Once the
83
+ * image reaches `read`, the existing read → image_url → visionRerouteTier
84
+ * machinery takes over and lands the turn on a seeing tier automatically.
85
+ *
86
+ * Content constraints (parity with CHILD_HONESTY_PREAMBLE in
87
+ * pipeline/invocation.ts): must not contain the standalone word "pi", must not
88
+ * open with a `- ` bullet line, no emojis.
89
+ */
90
+ export const TICKET_IMAGE_RULE = "When you read or fetch a ticket from any tracker (Linear, Jira, or another), " +
91
+ "by any method — the `linear` CLI, a connector tool, MCP, or a direct API call — " +
92
+ "treat every image embedded in or attached to that ticket as required input, not " +
93
+ "decoration. Before you rely on the ticket's contents, get each image onto disk " +
94
+ "as a local file and read it with the `read` tool. Never claim to understand a " +
95
+ "ticket whose images you have not actually looked at.";
96
+ /** Stable header that starts the ticket-image rule section (idempotency anchor). */
97
+ const TICKET_IMAGE_RULE_HEADER = "## Rule: always read a ticket's images";
98
+ /**
99
+ * The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
100
+ * carries this exact sentence in every system prompt so its whole reminder
101
+ * family (including its own silent-turn reminder) is legible as system-
102
+ * injected rather than misread as part of the tool output they ride on. We
103
+ * adopt it for the same reason: our silent-turn nudge arrives stapled to a
104
+ * `psql`/`bash` result, and without this it reads as query output.
105
+ */
106
+ export const SYSTEM_REMINDER_FRAMING = "Tool results and user messages may include <system-reminder> tags. " +
107
+ "<system-reminder> tags contain useful information and reminders. They are " +
108
+ "automatically added by the system, and bear no direct relation to the " +
109
+ "specific tool results or user messages in which they appear.";
110
+ /**
111
+ * The communication contract (YAG-574, Change B). Adapted from Claude Code's
112
+ * `SendUserMessage`/`Brief` prompt: ack in one line before going to look, then
113
+ * work, then result; a checkpoint between them only when something useful
114
+ * happened — a decision, a surprise, a phase boundary — never filler like
115
+ * "running tests…"; keep messages tight and second-person. Complements the
116
+ * silent-turn nudge by spelling out what "keep the user updated" means.
117
+ */
118
+ export const COMMUNICATION_CONTRACT = "Keep the user in the loop as you work. Acknowledge in one line before going " +
119
+ "to look, then report the result when you have it. Between the two, send a " +
120
+ "short checkpoint when something useful happened — a decision made, a " +
121
+ "surprise, a phase change — not for filler like running a test or reading a " +
122
+ "file. Keep every message tight: the decision, the file:line, the PR number. " +
123
+ "Address the user directly (second person).";
124
+ /**
125
+ * Write-findings-down (YAG-574, Change B). Claude Code's version ties the
126
+ * habit to result-clearing, which we do not do; reworded to the failure we
127
+ * actually saw, the model re-deriving the same answer turn after turn because
128
+ * nothing pushed it to commit a finding to its response (which is also what
129
+ * makes the user see it).
130
+ */
131
+ export const WRITE_FINDINGS_DOWN = "When working with tool results, write down any important information you " +
132
+ "might need later in your response, so you do not re-derive it turn after " +
133
+ "turn.";
78
134
  /** The driver identity while /ultra is on: base identity + the diamond directive. */
79
135
  export const YAGNI_IDENTITY_ULTRA = `${YAGNI_IDENTITY}\n\n${ULTRA_DELEGATION_PARAGRAPH}`;
80
136
  /**
@@ -186,6 +242,26 @@ export function brandSystemPrompt(original, opts = {}) {
186
242
  if (rules && rulesHeader && !s.includes(rulesHeader)) {
187
243
  s = `${s}\n\n${rules}`;
188
244
  }
245
+ // 5b. Ticket-image rule — a standing directive placed AFTER the user's rules
246
+ // (so a long, noisy rules block cannot bury it) but BEFORE the closing
247
+ // reminder (which stays the most-recent instruction). The idempotency guard
248
+ // keys on the stable section header, mirroring the rules guard above.
249
+ if (!s.includes(TICKET_IMAGE_RULE_HEADER)) {
250
+ s = `${s}\n\n${TICKET_IMAGE_RULE_HEADER}\n${TICKET_IMAGE_RULE}`;
251
+ }
252
+ // 5c. YAG-574: the injected-reminder framing (which makes the silent-turn
253
+ // nudge legible) and the two communication lines (Change B). Appended after
254
+ // everything user-provided but before the closing reminder, each guarded by
255
+ // its own stable anchor so re-branding never duplicates them.
256
+ if (!s.includes(SYSTEM_REMINDER_FRAMING)) {
257
+ s = `${s}\n\n${SYSTEM_REMINDER_FRAMING}`;
258
+ }
259
+ if (!s.includes(COMMUNICATION_CONTRACT)) {
260
+ s = `${s}\n\n${COMMUNICATION_CONTRACT}`;
261
+ }
262
+ if (!s.includes(WRITE_FINDINGS_DOWN)) {
263
+ s = `${s}\n\n${WRITE_FINDINGS_DOWN}`;
264
+ }
189
265
  // 6. Closing reinforcement. Weak open-weight models weight the most recent
190
266
  // instruction heavily, and the user's own project files may name other
191
267
  // harnesses; a trailing reminder keeps the agent from claiming one as its own.
@@ -1,3 +1,19 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- export declare function registerCmuxBridge(pi: ExtensionAPI): void;
2
+ import { type SessionState } from "./state.js";
3
+ import { type SummarizeTitleResult } from "../sessionTitle/summarize.js";
4
+ /** Injectable seams so the bridge's state machine is testable without a child. */
5
+ export interface CmuxBridgeDeps {
6
+ summarize?: (prompt: string, deps?: {
7
+ cwd?: string;
8
+ }) => Promise<SummarizeTitleResult>;
9
+ }
10
+ export declare function registerCmuxBridge(pi: ExtensionAPI, deps?: CmuxBridgeDeps): void;
11
+ /**
12
+ * The naming half of `publishCompletion`, extracted so the placeholder→final
13
+ * guard is directly testable without a child process. Once a session's title
14
+ * has been upgraded (or the upgrade was attempted), a later settle does nothing.
15
+ */
16
+ export declare function upgradeTitleOnce(state: SessionState, summarize: (prompt: string, deps?: {
17
+ cwd?: string;
18
+ }) => Promise<SummarizeTitleResult>, setTitle: ((title: string) => void) | undefined, onFail: (reason: string) => void, cwd: string): Promise<void>;
3
19
  //# sourceMappingURL=index.d.ts.map
@@ -3,7 +3,9 @@ import { join } from "node:path";
3
3
  import { CmuxDispatcher } from "./dispatcher.js";
4
4
  import { stateFor, snapshotContext, beginTurn, currentTurnId, finishTurn, settleTurn, lastAssistantMessage, firstString, objectValue, warn, } from "./state.js";
5
5
  import { sendHook, ensureResumeBinding, clearResumeBinding, releaseSessionRuntime, } from "./hooks.js";
6
- import { renameWorkspaceFromPrompt } from "./naming.js";
6
+ import { isDriverCaller } from "../config.js";
7
+ import { sessionTitle } from "../sessionTitle/title.js";
8
+ import { summarizeTitle } from "../sessionTitle/summarize.js";
7
9
  /** Remove a stale cmux-installed extension so hooks don't double-fire. */
8
10
  function removeStaleManagedExtension() {
9
11
  const agentDir = process.env.PI_CODING_AGENT_DIR || process.env.YAGNI_CODING_AGENT_DIR;
@@ -24,11 +26,17 @@ function removeStaleManagedExtension() {
24
26
  }
25
27
  catch { }
26
28
  }
27
- export function registerCmuxBridge(pi) {
29
+ export function registerCmuxBridge(pi, deps = {}) {
28
30
  if (!process.env.CMUX_SURFACE_ID)
29
31
  return;
30
32
  if (process.env.CMUX_PI_HOOKS_DISABLED === "1")
31
33
  return;
34
+ // A child process (guardian consult, advisor, /go stage, subagent) has no
35
+ // direct user — reporting its internals to cmux would overwrite the driver
36
+ // session's card with e.g. the Guardian consult prompt/verdict.
37
+ if (!isDriverCaller())
38
+ return;
39
+ const summarize = deps.summarize ?? summarizeTitle;
32
40
  const dispatcher = new CmuxDispatcher();
33
41
  const sessionStates = new Map();
34
42
  const lifecycleTails = new Map();
@@ -74,6 +82,18 @@ export function registerCmuxBridge(pi) {
74
82
  if (typeof event.prompt === "string" && event.prompt.trim()) {
75
83
  st.lastPrompt = event.prompt.trim();
76
84
  }
85
+ // Set the terminal title to the heuristic session title immediately at the
86
+ // first prompt (mirroring Claude's derive-then-upgrade: the tab is named
87
+ // from the prompt, not the model reply). cmux freezes auto-titling on its
88
+ // own once the user sets a manual name (customTitle), so we never clobber it.
89
+ // The optional LLM upgrade happens later on agent_settled.
90
+ if (!st.nameState && typeof event.prompt === "string" && event.prompt.trim()) {
91
+ const placeholder = sessionTitle(event.prompt.trim());
92
+ if (placeholder) {
93
+ st.nameState = "placeholder";
94
+ context.setTitle?.(placeholder);
95
+ }
96
+ }
77
97
  enqueueLifecycleTask(sessionId, context, () => sendHook(dispatcher, "prompt-submit", context, { prompt: event.prompt, turn_id: turnId }));
78
98
  });
79
99
  pi.on("agent_end", (event, ctx) => {
@@ -104,7 +124,7 @@ export function registerCmuxBridge(pi) {
104
124
  return;
105
125
  const completion = settleTurn(sessionStates, sessionId);
106
126
  if (completion) {
107
- enqueueLifecycleTask(sessionId, context, () => publishCompletion(dispatcher, sessionStates, context, sessionId, completion));
127
+ enqueueLifecycleTask(sessionId, context, () => publishCompletion(dispatcher, sessionStates, context, sessionId, completion, summarize));
108
128
  }
109
129
  });
110
130
  pi.on("session_shutdown", async (event, ctx) => {
@@ -133,7 +153,27 @@ export function registerCmuxBridge(pi) {
133
153
  });
134
154
  });
135
155
  }
136
- async function publishCompletion(dispatcher, sessionStates, context, sessionId, completion) {
156
+ /**
157
+ * The naming half of `publishCompletion`, extracted so the placeholder→final
158
+ * guard is directly testable without a child process. Once a session's title
159
+ * has been upgraded (or the upgrade was attempted), a later settle does nothing.
160
+ */
161
+ export async function upgradeTitleOnce(state, summarize, setTitle, onFail, cwd) {
162
+ if (state.nameState !== "placeholder")
163
+ return;
164
+ state.nameState = "final";
165
+ const prompt = state.lastPrompt;
166
+ if (!prompt)
167
+ return;
168
+ const result = await summarize(prompt, { cwd });
169
+ if (result.ok) {
170
+ setTitle?.(result.title);
171
+ }
172
+ else {
173
+ onFail(result.reason);
174
+ }
175
+ }
176
+ async function publishCompletion(dispatcher, sessionStates, context, sessionId, completion, summarize) {
137
177
  const stopPayload = {
138
178
  last_assistant_message: completion.lastAssistantMessage,
139
179
  turn_id: completion.turnId,
@@ -147,9 +187,8 @@ async function publishCompletion(dispatcher, sessionStates, context, sessionId,
147
187
  stopPayload.cmux_notification_routed = true;
148
188
  await sendHook(dispatcher, "stop", context, stopPayload);
149
189
  const state = stateFor(sessionStates, sessionId);
150
- if (!state.hasNamed) {
151
- renameWorkspaceFromPrompt(dispatcher, sessionStates, context, sessionId);
152
- state.hasNamed = true;
153
- }
190
+ // Upgrade the heuristic placeholder to an LLM-summarized title, once (see
191
+ // upgradeTitleOnce). A failed summary keeps the placeholder in place.
192
+ await upgradeTitleOnce(state, summarize, context.setTitle, (reason) => warn(context, "title summary failed; keeping heuristic title", { session_id: sessionId, reason }), context.cwd);
154
193
  }
155
194
  //# sourceMappingURL=index.js.map
@@ -4,18 +4,22 @@ export interface PendingCompletion {
4
4
  notificationType: string;
5
5
  turnId: string;
6
6
  }
7
+ /** The naming lifecycle of a session's cmux workspace title. */
8
+ export type NameState = "unnamed" | "placeholder" | "final";
7
9
  export interface SessionState {
8
10
  nextTurn: number;
9
11
  activeTurnId?: string;
10
12
  pendingCompletion?: PendingCompletion;
11
13
  stopped: boolean;
12
14
  lastPrompt?: string;
13
- hasNamed?: boolean;
15
+ nameState?: NameState;
14
16
  }
15
17
  export interface PiExtensionContextSnapshot {
16
18
  readonly sessionId: string | null;
17
19
  readonly cwd: string;
18
20
  readonly notifyWarning?: () => void;
21
+ /** Set the terminal tab/window title (pi's OSC 0), mirroring Claude's title channel. */
22
+ readonly setTitle?: (title: string) => void;
19
23
  }
20
24
  export declare function firstString(...values: unknown[]): string | null;
21
25
  export declare function objectValue(value: unknown, keys: string[]): unknown;
@@ -1,3 +1,4 @@
1
+ import { logEvent } from "../errorSink.js";
1
2
  export function firstString(...values) {
2
3
  for (const value of values) {
3
4
  if (typeof value === "string" && value.trim().length > 0)
@@ -67,14 +68,18 @@ export function snapshotContext(ctx) {
67
68
  return { sessionId: null, cwd: process.cwd() };
68
69
  }
69
70
  let notifyWarning;
71
+ let setTitle;
70
72
  try {
71
73
  const ui = ctx.ui;
72
74
  if (typeof ui?.notify === "function") {
73
75
  notifyWarning = () => ui.notify?.("cmux integration warning — check the terminal for details", "warning");
74
76
  }
77
+ if (typeof ui?.setTitle === "function") {
78
+ setTitle = (title) => ui.setTitle?.(title);
79
+ }
75
80
  }
76
81
  catch { }
77
- return { sessionId: sessionIdFrom(ctx), cwd: cwdFrom(ctx), notifyWarning };
82
+ return { sessionId: sessionIdFrom(ctx), cwd: cwdFrom(ctx), notifyWarning, setTitle };
78
83
  }
79
84
  export function stateFor(sessionStates, sessionId) {
80
85
  let state = sessionStates.get(sessionId);
@@ -125,13 +130,15 @@ export function settleTurn(sessionStates, sessionId) {
125
130
  return completion;
126
131
  }
127
132
  export function warn(ctx, message, details = {}, notifyUser = false) {
128
- const payload = { source: "yagni-cmux-bridge", level: "warning", message, ...details };
129
- try {
130
- console.warn(JSON.stringify(payload));
131
- }
132
- catch {
133
- console.warn(`[yagni-cmux-bridge] ${message}`);
134
- }
133
+ // The TUI is in raw mode: writing to stdout/stderr corrupts the terminal (the
134
+ // JSON was landing at the prompt cursor). Route warnings/errors to the unified
135
+ // local sink instead — never to the terminal.
136
+ logEvent({
137
+ source: "cmux",
138
+ level: "warn",
139
+ event: "cmux_bridge",
140
+ fields: { message, ...details },
141
+ });
135
142
  if (notifyUser) {
136
143
  try {
137
144
  ctx?.notifyWarning?.();
@@ -27,6 +27,7 @@
27
27
  import { spawn } from "node:child_process";
28
28
  import { scrubSecrets } from "./pipeline/scrubSecrets.js";
29
29
  import { isDesktopSurface } from "./surface.js";
30
+ import { logEvent } from "./errorSink.js";
30
31
  const defaultSpawn = (command, args, options) => spawn(command, args, options);
31
32
  export const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
32
33
  export const CRASH_REPORT_TIMEOUT_MS = 1_500;
@@ -267,6 +268,17 @@ export function reportFatalCrash(error, opts, context) {
267
268
  export function installUncaughtExceptionMonitor(opts, proc = process) {
268
269
  proc.on("uncaughtExceptionMonitor", (err) => {
269
270
  reportFatalCrash(err, opts, "uncaught-exception");
271
+ // Also seed the local error trail (best-effort): the crash report is a
272
+ // sanitized POST, but the ON-DISK trail is what /feedback binds for a
273
+ // report someone files next session.
274
+ logEvent({
275
+ source: "tool",
276
+ level: "error",
277
+ event: "uncaught_exception",
278
+ sessionId: process.env.YAGNI_SESSION_ID ?? undefined,
279
+ flush: "sync",
280
+ fields: { errorClass: err instanceof Error ? err.name || "Error" : "Error" },
281
+ });
270
282
  });
271
283
  }
272
284
  //# sourceMappingURL=crashReport.js.map
@@ -17,6 +17,7 @@
17
17
  * never affects the tool call that triggered it.
18
18
  */
19
19
  import { bankDecision } from "./decisions.js";
20
+ import { logEvent } from "./errorSink.js";
20
21
  /** At most one capture prompt per this window (spec: 10 minutes). */
21
22
  export const CAPTURE_DEBOUNCE_MS = 10 * 60 * 1000;
22
23
  /** Build a session-scoped decision capture (holds the debounce timestamp). */
@@ -54,10 +55,12 @@ export function makeDecisionCapture(deps) {
54
55
  ctx.ui.notify("Saved the decision locally; it will sync automatically.", "info");
55
56
  }
56
57
  else {
58
+ logEvent({ source: "decision-capture", level: "error", event: "capture_failed", fields: { kind: outcome.kind } });
57
59
  ctx.ui.notify(outcome.message, "error");
58
60
  }
59
61
  }
60
62
  catch {
63
+ logEvent({ source: "decision-capture", level: "error", event: "capture_failed", fields: { kind: "threw" } });
61
64
  /* fail-soft: a capture failure never affects the tool call */
62
65
  }
63
66
  },
@@ -20,6 +20,7 @@
20
20
  import { randomUUID } from "node:crypto";
21
21
  import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
22
22
  import { sendOrSpool } from "./spool.js";
23
+ import { logEvent } from "./errorSink.js";
23
24
  /**
24
25
  * Bank a `cli_correction` decision durably. Every logical write carries a
25
26
  * generated idempotencyKey; the backend dedups on it, so a spool replay or a
@@ -151,6 +152,7 @@ export function registerDecisionCommands(pi, opts) {
151
152
  notify(notice.message, notice.type);
152
153
  }
153
154
  catch (err) {
155
+ logEvent({ source: "decisions", level: "error", event: "decide_failed", fields: { op: "decide" } });
154
156
  notify(`Could not record the decision: ${err instanceof Error ? err.message : String(err)}`, "error");
155
157
  }
156
158
  },
@@ -183,6 +185,7 @@ export function registerDecisionCommands(pi, opts) {
183
185
  notify(`Superseded decision ${shortId(id)}.`, "info");
184
186
  }
185
187
  catch (err) {
188
+ logEvent({ source: "decisions", level: "error", event: "supersede_failed", fields: { op: "supersede" } });
186
189
  notify(`Could not supersede decision: ${err instanceof Error ? err.message : String(err)}`, "error");
187
190
  }
188
191
  return;
@@ -192,6 +195,7 @@ export function registerDecisionCommands(pi, opts) {
192
195
  await pi.sendUserMessage(formatDecisionsList(items));
193
196
  }
194
197
  catch (err) {
198
+ logEvent({ source: "decisions", level: "error", event: "list_failed", fields: { op: "list" } });
195
199
  notify(`Could not list decisions: ${err instanceof Error ? err.message : String(err)}`, "error");
196
200
  }
197
201
  },
@@ -12,7 +12,9 @@
12
12
  * A support flow can tail this file and POST it with the user's consent;
13
13
  * nothing is uploaded automatically.
14
14
  */
15
+ import { readSessionTrail } from "./errorSink.js";
15
16
  export declare function _setDiagnosticsHomeForTest(dir: string | null): void;
17
+ /** Active unified-sink path (one rotating per-day JSONL for all sources). */
16
18
  export declare function diagnosticsLogPath(): string;
17
19
  /** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
18
20
  export declare function isDebug(env?: NodeJS.ProcessEnv): boolean;
@@ -34,8 +36,37 @@ export interface ImagePasteEvent {
34
36
  * prompt. `detail` is included only when YAGNI_DEBUG is on.
35
37
  */
36
38
  export declare function logImagePaste(ev: ImagePasteEvent): void;
39
+ export interface AskQuestionEvent {
40
+ /** Stable event name: "key" | "finish" | "render" | "navigate" | "other_key". */
41
+ event: string;
42
+ /** Human-readable description; NEVER user-typed text or question content. */
43
+ detail?: string;
44
+ selectedIndex?: number;
45
+ otherIndex?: number;
46
+ otherLen?: number;
47
+ /** Raw key bytes, escaped (only for debug). */
48
+ key?: string;
49
+ /** Resolution status when the tool finishes: selected | other | cancelled. */
50
+ status?: string;
51
+ /** Whether the question is multiSelect at the time of the event. */
52
+ multi?: boolean;
53
+ /** 0-based index of the question within this tool call (multi-question runs). */
54
+ qIndex?: number;
55
+ /** Whether the "Other" row auto-check is on (multi-select). */
56
+ checked?: boolean;
57
+ }
58
+ /**
59
+ * Append one sanitized ask-user-question event to the unified sink
60
+ * (source:"ask-question").
61
+ * Fail-soft and hermetically gated under `node --test` exactly like
62
+ * `logImagePaste`. Never logs user-typed text, question text, or option labels
63
+ * — only indices, lengths, key bytes (escaped), and resolution status.
64
+ */
65
+ export declare function logAskQuestion(ev: AskQuestionEvent): void;
37
66
  /** Read the most recent log content (for a user-triggered report). */
38
67
  export declare function readRecentDiagnostics(maxBytes?: number): string;
39
68
  /** List existing diagnostic log files (active + rotations), for a report. */
40
69
  export declare function listDiagnosticFiles(): string[];
70
+ /** Re-export the session-scoped trail reader for the /feedback flow. */
71
+ export { readSessionTrail };
41
72
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -12,22 +12,19 @@
12
12
  * A support flow can tail this file and POST it with the user's consent;
13
13
  * nothing is uploaded automatically.
14
14
  */
15
- import { appendFileSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from "node:fs";
15
+ import { readFileSync, readdirSync } from "node:fs";
16
16
  import { dirname, join, basename } from "node:path";
17
- import { codeStateHome } from "./stateHome.js";
17
+ import { _setErrorSinkHomeForTest, logEvent, errorSinkPath, readSessionTrail, } from "./errorSink.js";
18
18
  /** Test seam (mirrors `_setYagniCodeHomeForTest`): point the log at a tmpdir. */
19
19
  let homeOverride = null;
20
20
  export function _setDiagnosticsHomeForTest(dir) {
21
21
  homeOverride = dir;
22
+ _setErrorSinkHomeForTest(dir);
22
23
  }
23
- function yagniCodeHome() {
24
- return codeStateHome(homeOverride);
25
- }
24
+ /** Active unified-sink path (one rotating per-day JSONL for all sources). */
26
25
  export function diagnosticsLogPath() {
27
- return join(yagniCodeHome(), "logs", "image-paste.log");
26
+ return errorSinkPath();
28
27
  }
29
- const MAX_LOG_BYTES = 256 * 1024; // rotate the active file past this
30
- const KEEP_ROTATIONS = 2; // keep image-paste.log.1 and .2 alongside the active file
31
28
  /** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
32
29
  export function isDebug(env = process.env) {
33
30
  const v = env.YAGNI_DEBUG;
@@ -38,56 +35,55 @@ export function isDebug(env = process.env) {
38
35
  * prompt. `detail` is included only when YAGNI_DEBUG is on.
39
36
  */
40
37
  export function logImagePaste(ev) {
41
- try {
42
- // Hermetic under `node --test`: never touch the real home dir from a test
43
- // unless the test explicitly stubbed it. Writing to the runner's $HOME made
44
- // the suite's exit depend on the CI filesystem (a read-only or slow $HOME
45
- // could stall the write), which is the leading suspect for the CI hang.
46
- if (process.env.NODE_TEST_CONTEXT && homeOverride === null)
47
- return;
48
- const line = {
49
- ts: new Date().toISOString(),
50
- event: ev.event,
51
- ...(ev.outcome !== undefined ? { outcome: ev.outcome } : {}),
52
- ...(ev.mimeType !== undefined ? { mimeType: ev.mimeType } : {}),
53
- ...(ev.bytes !== undefined ? { bytes: ev.bytes } : {}),
54
- ...(ev.imageCount !== undefined ? { imageCount: ev.imageCount } : {}),
55
- ...(ev.file !== undefined ? { file: basename(ev.file) } : {}),
56
- ...(ev.detail !== undefined && isDebug() ? { detail: ev.detail } : {}),
57
- };
58
- const path = diagnosticsLogPath();
59
- mkdirSync(dirname(path), { recursive: true });
60
- rotateIfNeeded(path);
61
- appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
62
- }
63
- catch {
64
- /* logging must never throw into the editor */
65
- }
38
+ // The unified sink: content-free fields are always-on, the debug-only
39
+ // `detail` rides level:debug (never bound by /feedback).
40
+ const fields = {
41
+ ...(ev.outcome !== undefined ? { outcome: ev.outcome } : {}),
42
+ ...(ev.mimeType !== undefined ? { mimeType: ev.mimeType } : {}),
43
+ ...(ev.bytes !== undefined ? { bytes: ev.bytes } : {}),
44
+ ...(ev.imageCount !== undefined ? { imageCount: ev.imageCount } : {}),
45
+ ...(ev.file !== undefined ? { file: basename(ev.file) } : {}),
46
+ ...(ev.detail !== undefined && isDebug() ? { detail: ev.detail } : {}),
47
+ };
48
+ logEvent({
49
+ source: "image-paste",
50
+ level: ev.detail !== undefined && isDebug() ? "debug" : "info",
51
+ event: ev.event,
52
+ fields,
53
+ });
66
54
  }
67
- /** Shift image-paste.log -> .1 -> .2 once the active file passes the size cap. */
68
- function rotateIfNeeded(path) {
69
- try {
70
- if (statSync(path).size < MAX_LOG_BYTES)
71
- return;
72
- for (let i = KEEP_ROTATIONS; i >= 1; i--) {
73
- const from = i === 1 ? path : `${path}.${i - 1}`;
74
- const to = `${path}.${i}`;
75
- try {
76
- renameSync(from, to);
77
- }
78
- catch {
79
- /* absent source fine */
80
- }
81
- }
82
- }
83
- catch {
84
- /* rotation is best-effort */
85
- }
55
+ /**
56
+ * Append one sanitized ask-user-question event to the unified sink
57
+ * (source:"ask-question").
58
+ * Fail-soft and hermetically gated under `node --test` exactly like
59
+ * `logImagePaste`. Never logs user-typed text, question text, or option labels
60
+ * only indices, lengths, key bytes (escaped), and resolution status.
61
+ */
62
+ export function logAskQuestion(ev) {
63
+ // Raw key bytes (`key`) and `detail` are content-ish: gate them behind DEBUG.
64
+ const debug = isDebug();
65
+ const fields = {
66
+ ...(ev.selectedIndex !== undefined ? { selectedIndex: ev.selectedIndex } : {}),
67
+ ...(ev.otherIndex !== undefined ? { otherIndex: ev.otherIndex } : {}),
68
+ ...(ev.otherLen !== undefined ? { otherLen: ev.otherLen } : {}),
69
+ ...(ev.status !== undefined ? { status: ev.status } : {}),
70
+ ...(ev.multi !== undefined ? { multi: ev.multi } : {}),
71
+ ...(ev.qIndex !== undefined ? { qIndex: ev.qIndex } : {}),
72
+ ...(ev.checked !== undefined ? { checked: ev.checked } : {}),
73
+ ...(ev.key !== undefined && debug ? { key: ev.key } : {}),
74
+ ...(ev.detail !== undefined && debug ? { detail: ev.detail } : {}),
75
+ };
76
+ logEvent({
77
+ source: "ask-question",
78
+ level: debug ? "debug" : "info",
79
+ event: ev.event,
80
+ fields,
81
+ });
86
82
  }
87
83
  /** Read the most recent log content (for a user-triggered report). */
88
84
  export function readRecentDiagnostics(maxBytes = 64 * 1024) {
89
85
  try {
90
- const data = readFileSync(diagnosticsLogPath(), "utf8");
86
+ const data = readFileSync(errorSinkPath(), "utf8");
91
87
  return data.length > maxBytes ? data.slice(data.length - maxBytes) : data;
92
88
  }
93
89
  catch {
@@ -97,9 +93,9 @@ export function readRecentDiagnostics(maxBytes = 64 * 1024) {
97
93
  /** List existing diagnostic log files (active + rotations), for a report. */
98
94
  export function listDiagnosticFiles() {
99
95
  try {
100
- const dir = dirname(diagnosticsLogPath());
96
+ const dir = dirname(errorSinkPath());
101
97
  return readdirSync(dir)
102
- .filter((f) => f.startsWith("image-paste.log"))
98
+ .filter((f) => f.startsWith("errors-"))
103
99
  .sort()
104
100
  .map((f) => join(dir, f));
105
101
  }
@@ -107,4 +103,6 @@ export function listDiagnosticFiles() {
107
103
  return [];
108
104
  }
109
105
  }
106
+ /** Re-export the session-scoped trail reader for the /feedback flow. */
107
+ export { readSessionTrail };
110
108
  //# sourceMappingURL=diagnostics.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The unified local error/log sink for YAGNI Code (YAG-580).
3
+ *
4
+ * Replaces the previous ~9 hand-rolled JSONL writers (image-paste.log,
5
+ * ask-question.log, turn-lifecycle.log, guardian.log, cost-divergence.log,
6
+ * auth-events.log, cmux-bridge.log, hooks.log, *.stream.log) with ONE sink.
7
+ *
8
+ * Two storage layers, purpose-named so their roles stay clear:
9
+ *
10
+ * 1. The DURABLE TRAIL — one rotating per-day JSONL under
11
+ * `~/.yagni-code/logs/errors-<date>.jsonl` (size-capped + 2 rotations).
12
+ * This is the crash-survivable WAL: `turn_start` without a matching
13
+ * `turn_end` still leaves a record even if the process is killed. Critical
14
+ * events append SYNCHRONOUSLY for exactly that reason.
15
+ *
16
+ * 2. The IN-MEMORY CAPTURE — a byte-budgeted ring buffer (not line-counted).
17
+ * This is the `/feedback` binding convenience, NOT durability (an in-memory
18
+ * ring does not survive a crash). Mirrors Codex's CodexFeedback ring and
19
+ * Claude's inMemoryErrorLog.
20
+ *
21
+ * Every line carries a REQUIRED `sessionId` and a `source`/`level`/`event`
22
+ * triple so a shared file stays filterable: `jq 'select(.source=="guardian")'`
23
+ * reproduces today's per-file tail exactly, and /feedback reads the trail
24
+ * filtered by sessionId (never the raw file) so one session's report never
25
+ * leaks another session's errors.
26
+ *
27
+ * Default-on vs DEBUG invariant (the thing that makes "log everything by
28
+ * default" safe): default-on == scrub-safe == upload-safe. Any field carrying
29
+ * raw content (tool arguments, partial/result bodies, provider payloads, raw
30
+ * key bytes) must be gated behind YAGNI_DEBUG, and the /feedback reader refuses
31
+ * to bind any `level: debug` line. DEBUG == may-contain-content == never-uploads.
32
+ */
33
+ export type SinkLevel = "error" | "warn" | "info" | "debug";
34
+ export interface SinkEvent {
35
+ /** Former filename / subsystem: tool, turn, guardian, image-paste, ask-question, cost, auth, cmux, hooks. */
36
+ source: string;
37
+ level: SinkLevel;
38
+ /** Stable machine name (e.g. "turn_start", "bash.exit_1", "denied"). */
39
+ event: string;
40
+ /** Additional structured fields. NEVER raw content on a non-debug line. */
41
+ fields?: Record<string, unknown>;
42
+ /** Session id so the trail is filterable and scoped per feedback. Defaults to YAGNI_SESSION_ID. */
43
+ sessionId?: string;
44
+ /** "sync" flushes immediately (critical events); "buffered" is fine for high-volume debug. */
45
+ flush?: "sync" | "buffered";
46
+ }
47
+ export declare function _setErrorSinkHomeForTest(dir: string | null): void;
48
+ export declare function errorSinkPath(now?: Date): string;
49
+ export declare function _clearErrorSinkRingForTest(): void;
50
+ export declare function errorSinkInMemory(): string;
51
+ /**
52
+ * Append one event to both the ring and the durable trail. Fail-soft: a logging
53
+ * failure must never break the session. `flush: "sync"` (default for
54
+ * error-level events and lifecycle turns) bypasses any future buffering so a
55
+ * turn that starts but never ends still leaves a durable `turn_start`.
56
+ */
57
+ export declare function logEvent(ev: SinkEvent): void;
58
+ /**
59
+ * Read recent trail lines for ONE session, filtered by `sessionId`, up to
60
+ * `maxBytes`. Never returns `level: debug` lines — the default-on tier is the
61
+ * upload-safe tier, and DEBUG may contain content that must not leave the machine.
62
+ */
63
+ export declare function readSessionTrail(sessionId: string, maxBytes?: number): string;
64
+ //# sourceMappingURL=errorSink.d.ts.map