@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.
- package/dist/extension/askAdvisorTool.js +2 -0
- package/dist/extension/askUserQuestionTool.d.ts +54 -0
- package/dist/extension/askUserQuestionTool.js +621 -0
- package/dist/extension/branding.d.ts +39 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/cmux/index.d.ts +17 -1
- package/dist/extension/cmux/index.js +47 -8
- package/dist/extension/cmux/state.d.ts +5 -1
- package/dist/extension/cmux/state.js +15 -8
- package/dist/extension/crashReport.js +12 -0
- package/dist/extension/decisionCapture.js +3 -0
- package/dist/extension/decisions.js +4 -0
- package/dist/extension/diagnostics.d.ts +31 -0
- package/dist/extension/diagnostics.js +53 -55
- package/dist/extension/errorSink.d.ts +64 -0
- package/dist/extension/errorSink.js +180 -0
- package/dist/extension/feedbackCommand.d.ts +38 -0
- package/dist/extension/feedbackCommand.js +151 -0
- package/dist/extension/hooks.js +12 -12
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +97 -40
- package/dist/extension/mineBeat.js +13 -0
- package/dist/extension/pipeline/goCommand.js +2 -0
- package/dist/extension/pipeline/personas.js +9 -0
- package/dist/extension/pipeline/runner.js +9 -0
- package/dist/extension/sessionTitle/summarize.d.ts +40 -0
- package/dist/extension/sessionTitle/summarize.js +63 -0
- package/dist/extension/sessionTitle/title.d.ts +27 -0
- package/dist/extension/sessionTitle/title.js +57 -0
- package/dist/extension/silentTurnReminder.d.ts +109 -0
- package/dist/extension/silentTurnReminder.js +221 -0
- package/dist/extension/turnLog.d.ts +14 -0
- package/dist/extension/turnLog.js +22 -47
- package/dist/extension/webFetch.d.ts +85 -0
- package/dist/extension/webFetch.js +192 -0
- package/dist/extension/webFetchTool.d.ts +34 -0
- package/dist/extension/webFetchTool.js +104 -0
- package/package.json +4 -3
- package/dist/extension/cmux/naming.d.ts +0 -5
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 {
|
|
15
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
16
16
|
import { dirname, join, basename } from "node:path";
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
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(
|
|
96
|
+
const dir = dirname(errorSinkPath());
|
|
101
97
|
return readdirSync(dir)
|
|
102
|
-
.filter((f) => f.startsWith("
|
|
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
|