@yagni-app/code-staging 1.0.0-staging.1174.1 → 1.0.0-staging.1175.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.
- package/dist/extension/askAdvisorTool.js +2 -0
- package/dist/extension/cmux/state.js +9 -16
- 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 +6 -3
- package/dist/extension/diagnostics.js +47 -85
- 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.js +72 -40
- package/dist/extension/mineBeat.js +13 -0
- package/dist/extension/pipeline/goCommand.js +2 -0
- package/dist/extension/pipeline/runner.js +9 -0
- package/dist/extension/turnLog.js +17 -46
- package/package.json +2 -2
|
@@ -32,6 +32,7 @@ import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
|
|
|
32
32
|
import { withResilience } from "./pipeline/resilience.js";
|
|
33
33
|
import { runStage as defaultRunStage } from "./pipeline/runner.js";
|
|
34
34
|
import { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
|
|
35
|
+
import { logEvent } from "./errorSink.js";
|
|
35
36
|
import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, receiptLine, renderSubagentResult, runningLines, } from "./subagentRender.js";
|
|
36
37
|
/**
|
|
37
38
|
* Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
|
|
@@ -288,6 +289,7 @@ export function registerAdviseCommand(pi, tool) {
|
|
|
288
289
|
}
|
|
289
290
|
catch (err) {
|
|
290
291
|
const message = err instanceof Error ? err.message : String(err);
|
|
292
|
+
logEvent({ source: "advisor", level: "error", event: "advise_failed", fields: { kind: "consult" } });
|
|
291
293
|
notify(`/advise failed: ${message}`, "error");
|
|
292
294
|
await pi.sendUserMessage(`/advise failed: ${message}`);
|
|
293
295
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { codeStateHome } from "../stateHome.js";
|
|
1
|
+
import { logEvent } from "../errorSink.js";
|
|
4
2
|
export function firstString(...values) {
|
|
5
3
|
for (const value of values) {
|
|
6
4
|
if (typeof value === "string" && value.trim().length > 0)
|
|
@@ -132,20 +130,15 @@ export function settleTurn(sessionStates, sessionId) {
|
|
|
132
130
|
return completion;
|
|
133
131
|
}
|
|
134
132
|
export function warn(ctx, message, details = {}, notifyUser = false) {
|
|
135
|
-
const payload = { source: "yagni-cmux-bridge", level: "warning", message, ...details };
|
|
136
133
|
// The TUI is in raw mode: writing to stdout/stderr corrupts the terminal (the
|
|
137
|
-
// JSON was landing at the prompt cursor). Route warnings/errors to
|
|
138
|
-
// local
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
/* logging must never throw into the editor */
|
|
148
|
-
}
|
|
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
|
+
});
|
|
149
142
|
if (notifyUser) {
|
|
150
143
|
try {
|
|
151
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,10 +12,10 @@
|
|
|
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
|
-
/** Dedicated log for the ask_user_question interactive tool's state machine. */
|
|
18
|
-
export declare function askQuestionLogPath(): string;
|
|
19
19
|
/** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
|
|
20
20
|
export declare function isDebug(env?: NodeJS.ProcessEnv): boolean;
|
|
21
21
|
export interface ImagePasteEvent {
|
|
@@ -56,7 +56,8 @@ export interface AskQuestionEvent {
|
|
|
56
56
|
checked?: boolean;
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
|
-
* Append one sanitized ask-user-question event to
|
|
59
|
+
* Append one sanitized ask-user-question event to the unified sink
|
|
60
|
+
* (source:"ask-question").
|
|
60
61
|
* Fail-soft and hermetically gated under `node --test` exactly like
|
|
61
62
|
* `logImagePaste`. Never logs user-typed text, question text, or option labels
|
|
62
63
|
* — only indices, lengths, key bytes (escaped), and resolution status.
|
|
@@ -66,4 +67,6 @@ export declare function logAskQuestion(ev: AskQuestionEvent): void;
|
|
|
66
67
|
export declare function readRecentDiagnostics(maxBytes?: number): string;
|
|
67
68
|
/** List existing diagnostic log files (active + rotations), for a report. */
|
|
68
69
|
export declare function listDiagnosticFiles(): string[];
|
|
70
|
+
/** Re-export the session-scoped trail reader for the /feedback flow. */
|
|
71
|
+
export { readSessionTrail };
|
|
69
72
|
//# sourceMappingURL=diagnostics.d.ts.map
|
|
@@ -12,26 +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
|
|
28
|
-
}
|
|
29
|
-
/** Dedicated log for the ask_user_question interactive tool's state machine. */
|
|
30
|
-
export function askQuestionLogPath() {
|
|
31
|
-
return join(yagniCodeHome(), "logs", "ask-question.log");
|
|
26
|
+
return errorSinkPath();
|
|
32
27
|
}
|
|
33
|
-
const MAX_LOG_BYTES = 256 * 1024; // rotate the active file past this
|
|
34
|
-
const KEEP_ROTATIONS = 2; // keep image-paste.log.1 and .2 alongside the active file
|
|
35
28
|
/** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
|
|
36
29
|
export function isDebug(env = process.env) {
|
|
37
30
|
const v = env.YAGNI_DEBUG;
|
|
@@ -42,88 +35,55 @@ export function isDebug(env = process.env) {
|
|
|
42
35
|
* prompt. `detail` is included only when YAGNI_DEBUG is on.
|
|
43
36
|
*/
|
|
44
37
|
export function logImagePaste(ev) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
62
|
-
const path = diagnosticsLogPath();
|
|
63
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
64
|
-
rotateIfNeeded(path);
|
|
65
|
-
appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
/* logging must never throw into the editor */
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** Shift image-paste.log -> .1 -> .2 once the active file passes the size cap. */
|
|
72
|
-
function rotateIfNeeded(path) {
|
|
73
|
-
try {
|
|
74
|
-
if (statSync(path).size < MAX_LOG_BYTES)
|
|
75
|
-
return;
|
|
76
|
-
for (let i = KEEP_ROTATIONS; i >= 1; i--) {
|
|
77
|
-
const from = i === 1 ? path : `${path}.${i - 1}`;
|
|
78
|
-
const to = `${path}.${i}`;
|
|
79
|
-
try {
|
|
80
|
-
renameSync(from, to);
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
/* absent source — fine */
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
/* rotation is best-effort */
|
|
89
|
-
}
|
|
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
|
+
});
|
|
90
54
|
}
|
|
91
55
|
/**
|
|
92
|
-
* Append one sanitized ask-user-question event to
|
|
56
|
+
* Append one sanitized ask-user-question event to the unified sink
|
|
57
|
+
* (source:"ask-question").
|
|
93
58
|
* Fail-soft and hermetically gated under `node --test` exactly like
|
|
94
59
|
* `logImagePaste`. Never logs user-typed text, question text, or option labels
|
|
95
60
|
* — only indices, lengths, key bytes (escaped), and resolution status.
|
|
96
61
|
*/
|
|
97
62
|
export function logAskQuestion(ev) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
/* logging must never throw into the tool */
|
|
121
|
-
}
|
|
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
|
+
});
|
|
122
82
|
}
|
|
123
83
|
/** Read the most recent log content (for a user-triggered report). */
|
|
124
84
|
export function readRecentDiagnostics(maxBytes = 64 * 1024) {
|
|
125
85
|
try {
|
|
126
|
-
const data = readFileSync(
|
|
86
|
+
const data = readFileSync(errorSinkPath(), "utf8");
|
|
127
87
|
return data.length > maxBytes ? data.slice(data.length - maxBytes) : data;
|
|
128
88
|
}
|
|
129
89
|
catch {
|
|
@@ -133,9 +93,9 @@ export function readRecentDiagnostics(maxBytes = 64 * 1024) {
|
|
|
133
93
|
/** List existing diagnostic log files (active + rotations), for a report. */
|
|
134
94
|
export function listDiagnosticFiles() {
|
|
135
95
|
try {
|
|
136
|
-
const dir = dirname(
|
|
96
|
+
const dir = dirname(errorSinkPath());
|
|
137
97
|
return readdirSync(dir)
|
|
138
|
-
.filter((f) => f.startsWith("
|
|
98
|
+
.filter((f) => f.startsWith("errors-"))
|
|
139
99
|
.sort()
|
|
140
100
|
.map((f) => join(dir, f));
|
|
141
101
|
}
|
|
@@ -143,4 +103,6 @@ export function listDiagnosticFiles() {
|
|
|
143
103
|
return [];
|
|
144
104
|
}
|
|
145
105
|
}
|
|
106
|
+
/** Re-export the session-scoped trail reader for the /feedback flow. */
|
|
107
|
+
export { readSessionTrail };
|
|
146
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
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
import { codeStateHome } from "./stateHome.js";
|
|
36
|
+
import { scrubSecrets } from "./pipeline/scrubSecrets.js";
|
|
37
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
38
|
+
const KEEP_ROTATIONS = 2;
|
|
39
|
+
const RING_MAX_BYTES = 256 * 1024;
|
|
40
|
+
/** Test seam: point the log at a tmpdir (mirrors _setDiagnosticsHomeForTest). */
|
|
41
|
+
let homeOverride = null;
|
|
42
|
+
export function _setErrorSinkHomeForTest(dir) {
|
|
43
|
+
homeOverride = dir;
|
|
44
|
+
}
|
|
45
|
+
function logDir() {
|
|
46
|
+
return join(codeStateHome(homeOverride), "logs");
|
|
47
|
+
}
|
|
48
|
+
function dayStamp(now = new Date()) {
|
|
49
|
+
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
50
|
+
}
|
|
51
|
+
export function errorSinkPath(now = new Date()) {
|
|
52
|
+
return join(logDir(), `errors-${dayStamp(now)}.jsonl`);
|
|
53
|
+
}
|
|
54
|
+
/** In-memory ring buffer, byte-budgeted (trailing bytes kept when over cap). */
|
|
55
|
+
class RingBuffer {
|
|
56
|
+
maxBytes;
|
|
57
|
+
chunks = [];
|
|
58
|
+
bytes = 0;
|
|
59
|
+
constructor(maxBytes) {
|
|
60
|
+
this.maxBytes = maxBytes;
|
|
61
|
+
}
|
|
62
|
+
push(line) {
|
|
63
|
+
const b = Buffer.byteLength(line, "utf8");
|
|
64
|
+
if (b >= this.maxBytes) {
|
|
65
|
+
this.chunks = [line];
|
|
66
|
+
this.bytes = b;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
while (this.bytes + b > this.maxBytes && this.chunks.length > 0) {
|
|
70
|
+
const dropped = this.chunks.shift();
|
|
71
|
+
this.bytes -= Buffer.byteLength(dropped, "utf8");
|
|
72
|
+
}
|
|
73
|
+
this.chunks.push(line);
|
|
74
|
+
this.bytes += b;
|
|
75
|
+
}
|
|
76
|
+
snapshot() {
|
|
77
|
+
return this.chunks.join("");
|
|
78
|
+
}
|
|
79
|
+
clear() {
|
|
80
|
+
this.chunks = [];
|
|
81
|
+
this.bytes = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const ring = new RingBuffer(RING_MAX_BYTES);
|
|
85
|
+
export function _clearErrorSinkRingForTest() {
|
|
86
|
+
ring.clear();
|
|
87
|
+
}
|
|
88
|
+
export function errorSinkInMemory() {
|
|
89
|
+
return ring.snapshot();
|
|
90
|
+
}
|
|
91
|
+
function rotateIfNeeded(path) {
|
|
92
|
+
try {
|
|
93
|
+
if (!statSync(path).isFile() || statSync(path).size < MAX_LOG_BYTES)
|
|
94
|
+
return;
|
|
95
|
+
for (let i = KEEP_ROTATIONS; i >= 1; i--) {
|
|
96
|
+
const from = i === 1 ? path : `${path}.${i - 1}`;
|
|
97
|
+
const to = `${path}.${i}`;
|
|
98
|
+
try {
|
|
99
|
+
renameSync(from, to);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* absent source — fine */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* rotation is best-effort */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function isDebug(env = process.env) {
|
|
111
|
+
const v = env.YAGNI_DEBUG;
|
|
112
|
+
return v === "1" || v === "true";
|
|
113
|
+
}
|
|
114
|
+
function sessionIdFor(ev) {
|
|
115
|
+
return ev.sessionId ?? process.env.YAGNI_SESSION_ID ?? "";
|
|
116
|
+
}
|
|
117
|
+
function serialize(ev) {
|
|
118
|
+
const line = {
|
|
119
|
+
ts: new Date().toISOString(),
|
|
120
|
+
source: ev.source,
|
|
121
|
+
level: ev.level,
|
|
122
|
+
event: ev.event,
|
|
123
|
+
sessionId: sessionIdFor(ev),
|
|
124
|
+
...(ev.fields ?? {}),
|
|
125
|
+
};
|
|
126
|
+
return JSON.stringify(line) + "\n";
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Append one event to both the ring and the durable trail. Fail-soft: a logging
|
|
130
|
+
* failure must never break the session. `flush: "sync"` (default for
|
|
131
|
+
* error-level events and lifecycle turns) bypasses any future buffering so a
|
|
132
|
+
* turn that starts but never ends still leaves a durable `turn_start`.
|
|
133
|
+
*/
|
|
134
|
+
export function logEvent(ev) {
|
|
135
|
+
try {
|
|
136
|
+
if (process.env.NODE_TEST_CONTEXT && homeOverride === null)
|
|
137
|
+
return;
|
|
138
|
+
const line = serialize(ev);
|
|
139
|
+
ring.push(line);
|
|
140
|
+
const path = errorSinkPath();
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
142
|
+
rotateIfNeeded(path);
|
|
143
|
+
appendFileSync(path, line, "utf8");
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* logging must never throw into the editor */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Read recent trail lines for ONE session, filtered by `sessionId`, up to
|
|
151
|
+
* `maxBytes`. Never returns `level: debug` lines — the default-on tier is the
|
|
152
|
+
* upload-safe tier, and DEBUG may contain content that must not leave the machine.
|
|
153
|
+
*/
|
|
154
|
+
export function readSessionTrail(sessionId, maxBytes = 64 * 1024) {
|
|
155
|
+
try {
|
|
156
|
+
const data = readFileSync(errorSinkPath(), "utf8");
|
|
157
|
+
// Defense-in-depth: scrub each kept line so /diagnostics and /feedback
|
|
158
|
+
// never surface a secret or local path, even if a future caller slipped a
|
|
159
|
+
// content-bearing value onto an always-on line.
|
|
160
|
+
const lines = data
|
|
161
|
+
.split("\n")
|
|
162
|
+
.filter((l) => l.length > 0)
|
|
163
|
+
.filter((l) => {
|
|
164
|
+
try {
|
|
165
|
+
const obj = JSON.parse(l);
|
|
166
|
+
return obj.sessionId === sessionId && obj.level !== "debug";
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
.map((l) => scrubSecrets(l))
|
|
173
|
+
.join("\n");
|
|
174
|
+
return lines.length > maxBytes ? lines.slice(lines.length - maxBytes) : lines;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=errorSink.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/feedback` (alias `/bug`) command + the `/diagnostics` companion
|
|
3
|
+
* (YAG-580).
|
|
4
|
+
*
|
|
5
|
+
* `/feedback` captures the session transcript (pi's own append-only JSONL, read
|
|
6
|
+
* via `ctx.sessionManager` — never reconstructed from YAGNI_SESSION_ID, which is
|
|
7
|
+
* the proxy-attribution id), the session-scoped error trail (from the unified
|
|
8
|
+
* sink), any child `/go` run transcripts, a sanitized `yagni doctor` report, and
|
|
9
|
+
* git metadata — sanitizes the whole bundle with the shared scrub contract, and
|
|
10
|
+
* POSTs it to the YAGNI backend (opt-in, gated, named-human).
|
|
11
|
+
*
|
|
12
|
+
* `/diagnostics` is the read-only companion: it prints the last N sink lines for
|
|
13
|
+
* THIS session so the user can see what failed before deciding to attach it.
|
|
14
|
+
*
|
|
15
|
+
* Both treat the transcript as best-effort enrichment: pi's flush-to-file timing
|
|
16
|
+
* at command-invoke is not guaranteed, so a report never claims the reporting
|
|
17
|
+
* turn is captured unless it verifiably is.
|
|
18
|
+
*/
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
export interface FeedbackDeps {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
getToken: () => string | undefined;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
/** Sanitized `yagni doctor` output (string), or undefined to omit. */
|
|
26
|
+
getDoctorReport?: () => Promise<string | undefined>;
|
|
27
|
+
/** Git facts for the report; undefined fields are omitted. */
|
|
28
|
+
getGitState?: (cwd: string) => Promise<{
|
|
29
|
+
branch?: string;
|
|
30
|
+
commit?: string;
|
|
31
|
+
remote?: string;
|
|
32
|
+
dirty?: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
/** Child `/go` run transcripts keyed by run id, for the current run tree. */
|
|
35
|
+
getChildTranscripts?: (cwd: string, sessionFile: string | undefined) => Promise<Record<string, string>>;
|
|
36
|
+
}
|
|
37
|
+
export declare function registerFeedbackCommands(pi: ExtensionAPI, deps: FeedbackDeps): void;
|
|
38
|
+
//# sourceMappingURL=feedbackCommand.d.ts.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/feedback` (alias `/bug`) command + the `/diagnostics` companion
|
|
3
|
+
* (YAG-580).
|
|
4
|
+
*
|
|
5
|
+
* `/feedback` captures the session transcript (pi's own append-only JSONL, read
|
|
6
|
+
* via `ctx.sessionManager` — never reconstructed from YAGNI_SESSION_ID, which is
|
|
7
|
+
* the proxy-attribution id), the session-scoped error trail (from the unified
|
|
8
|
+
* sink), any child `/go` run transcripts, a sanitized `yagni doctor` report, and
|
|
9
|
+
* git metadata — sanitizes the whole bundle with the shared scrub contract, and
|
|
10
|
+
* POSTs it to the YAGNI backend (opt-in, gated, named-human).
|
|
11
|
+
*
|
|
12
|
+
* `/diagnostics` is the read-only companion: it prints the last N sink lines for
|
|
13
|
+
* THIS session so the user can see what failed before deciding to attach it.
|
|
14
|
+
*
|
|
15
|
+
* Both treat the transcript as best-effort enrichment: pi's flush-to-file timing
|
|
16
|
+
* at command-invoke is not guaranteed, so a report never claims the reporting
|
|
17
|
+
* turn is captured unless it verifiably is.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { scrubSecrets } from "./pipeline/scrubSecrets.js";
|
|
21
|
+
import { readSessionTrail } from "./errorSink.js";
|
|
22
|
+
const MAX_DESCRIPTION = 512;
|
|
23
|
+
const MAX_TRANSCRIPT_READ_BYTES = 512 * 1024;
|
|
24
|
+
const notify = (ctx, message, type) => {
|
|
25
|
+
if (ctx.hasUI)
|
|
26
|
+
ctx.ui.notify(message, type);
|
|
27
|
+
};
|
|
28
|
+
function redact(text) {
|
|
29
|
+
return scrubSecrets(text);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the durable transcript, clamped by byte size. Returns empty on any
|
|
33
|
+
* failure or when too large (mirrors Claude's MAX_TRANSCRIPT_READ_BYTES guard).
|
|
34
|
+
*/
|
|
35
|
+
function readTranscript(sessionFile) {
|
|
36
|
+
if (!sessionFile)
|
|
37
|
+
return "";
|
|
38
|
+
try {
|
|
39
|
+
const data = readFileSync(sessionFile, "utf8");
|
|
40
|
+
if (Buffer.byteLength(data, "utf8") > MAX_TRANSCRIPT_READ_BYTES)
|
|
41
|
+
return "";
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function handleFeedback(args, ctx, deps) {
|
|
49
|
+
const sessionId = ctx.sessionManager.getSessionId?.() ?? deps.env?.YAGNI_SESSION_ID ?? "";
|
|
50
|
+
const sessionFile = ctx.sessionManager.getSessionFile?.();
|
|
51
|
+
const cwd = ctx.cwd;
|
|
52
|
+
const description = args.trim()
|
|
53
|
+
? args.trim()
|
|
54
|
+
: await ctx.ui.input("Describe the issue", "What went wrong, in one or two lines?");
|
|
55
|
+
if (!description || description.trim().length === 0) {
|
|
56
|
+
notify(ctx, "Feedback cancelled.", "info");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!ctx.isIdle()) {
|
|
60
|
+
notify(ctx, "YAGNI Code is busy; wait for the current turn to finish before /feedback.", "warning");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const transcript = readTranscript(sessionFile);
|
|
64
|
+
const trail = readSessionTrail(sessionId);
|
|
65
|
+
const doctorReport = deps.getDoctorReport
|
|
66
|
+
? await deps.getDoctorReport().catch(() => undefined)
|
|
67
|
+
: undefined;
|
|
68
|
+
const git = deps.getGitState
|
|
69
|
+
? await deps.getGitState(cwd).catch(() => ({}))
|
|
70
|
+
: {};
|
|
71
|
+
const childTranscripts = deps.getChildTranscripts
|
|
72
|
+
? await deps.getChildTranscripts(cwd, sessionFile).catch(() => ({}))
|
|
73
|
+
: {};
|
|
74
|
+
// Consent: enumerate exactly what is about to leave the machine.
|
|
75
|
+
const lines = [
|
|
76
|
+
"Your feedback description",
|
|
77
|
+
`This session's transcript${transcript ? "" : " (could not be read — possibly one turn stale)"}`,
|
|
78
|
+
`${Object.keys(childTranscripts).length} child run transcript(s)`,
|
|
79
|
+
"Recent error trail for this session",
|
|
80
|
+
...(doctorReport ? ["Sanitized yagni doctor report"] : []),
|
|
81
|
+
...(git.branch ? ["Git metadata (branch/commit/remote/dirty)"] : []),
|
|
82
|
+
];
|
|
83
|
+
const ok = await ctx.ui.confirm("Submit feedback?", lines.join("\n - ") + "\n\nSend this report?");
|
|
84
|
+
if (!ok) {
|
|
85
|
+
notify(ctx, "Feedback cancelled.", "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const payload = {
|
|
89
|
+
client: "cli",
|
|
90
|
+
clientVersion: deps.env?.YAGNI_CODE_VERSION?.trim() || "unknown",
|
|
91
|
+
platform: `${process.platform} ${process.arch}`,
|
|
92
|
+
description: redact(description).slice(0, MAX_DESCRIPTION),
|
|
93
|
+
sessionId,
|
|
94
|
+
...(transcript ? { transcriptJsonl: redact(transcript) } : {}),
|
|
95
|
+
...(trail ? { errorTrailJsonl: redact(trail) } : {}),
|
|
96
|
+
...(Object.keys(childTranscripts).length > 0
|
|
97
|
+
? { childTranscripts: Object.fromEntries(Object.entries(childTranscripts).map(([k, v]) => [k, redact(v)])) }
|
|
98
|
+
: {}),
|
|
99
|
+
...(doctorReport ? { doctorReport: redact(doctorReport) } : {}),
|
|
100
|
+
...(git.branch ? { gitBranch: git.branch } : {}),
|
|
101
|
+
...(git.commit ? { gitCommit: git.commit } : {}),
|
|
102
|
+
...(git.remote ? { gitRemote: git.remote } : {}),
|
|
103
|
+
...(git.dirty !== undefined ? { gitDirty: git.dirty } : {}),
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
107
|
+
const res = await fetchImpl(`${deps.baseUrl.replace(/\/$/, "")}/api/yagni-code/feedback`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"content-type": "application/json",
|
|
111
|
+
authorization: `Bearer ${deps.getToken() ?? ""}`,
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(payload),
|
|
114
|
+
signal: AbortSignal.timeout(30_000),
|
|
115
|
+
});
|
|
116
|
+
if (res.ok) {
|
|
117
|
+
notify(ctx, "Feedback submitted. Thank you!", "info");
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
notify(ctx, "Could not submit feedback. Please try again.", "error");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
notify(ctx, "Could not submit feedback (network error). Please try again.", "error");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function registerFeedbackCommands(pi, deps) {
|
|
128
|
+
pi.registerCommand("feedback", {
|
|
129
|
+
description: "File a bug report with your session transcript + error trail attached.",
|
|
130
|
+
handler: (args, ctx) => handleFeedback(args, ctx, deps),
|
|
131
|
+
});
|
|
132
|
+
pi.registerCommand("bug", {
|
|
133
|
+
description: "Alias for /feedback.",
|
|
134
|
+
handler: (args, ctx) => handleFeedback(args, ctx, deps),
|
|
135
|
+
});
|
|
136
|
+
pi.registerCommand("diagnostics", {
|
|
137
|
+
description: "Show recent error-trail lines for this session.",
|
|
138
|
+
handler: async (_args, ctx) => {
|
|
139
|
+
const sessionId = ctx.sessionManager.getSessionId?.() ?? deps.env?.YAGNI_SESSION_ID ?? "";
|
|
140
|
+
const trail = readSessionTrail(sessionId, 16 * 1024);
|
|
141
|
+
if (!trail) {
|
|
142
|
+
notify(ctx, "No recent diagnostics for this session.", "info");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const lines = trail.split("\n").filter(Boolean);
|
|
146
|
+
const tail = lines.slice(-20);
|
|
147
|
+
notify(ctx, `Recent diagnostics (${lines.length} events):\n${tail.join("\n")}`, "info");
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=feedbackCommand.js.map
|
package/dist/extension/hooks.js
CHANGED
|
@@ -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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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) {
|
package/dist/extension/index.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
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";
|
|
@@ -20,6 +18,8 @@ import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, Y
|
|
|
20
18
|
import { claudeRulesSection } from "./claudeRules.js";
|
|
21
19
|
import { registerCostCommand } from "./costHud.js";
|
|
22
20
|
import { isDebug } from "./diagnostics.js";
|
|
21
|
+
import { logEvent } from "./errorSink.js";
|
|
22
|
+
import { registerFeedbackCommands } from "./feedbackCommand.js";
|
|
23
23
|
import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
|
|
24
24
|
import { codeStateHome } from "./stateHome.js";
|
|
25
25
|
import { logTurnLifecycle } from "./turnLog.js";
|
|
@@ -252,6 +252,19 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
252
252
|
// M6 eval (report-only): /go-compare runs a ticket grounded vs blind and reports
|
|
253
253
|
// the business-fit delta. Never wired to routing.
|
|
254
254
|
registerGoCompareCommand(pi);
|
|
255
|
+
// YAG-580: /feedback (alias /bug) + /diagnostics. The capture/upload is the
|
|
256
|
+
// whole point, so it is gated to non-eval mode like every external side
|
|
257
|
+
// effect; the command's deps (doctor report, git state, child transcripts)
|
|
258
|
+
// are injected so the handler stays unit-testable without spawning git or
|
|
259
|
+
// reading the real session dir.
|
|
260
|
+
if (!evalMode) {
|
|
261
|
+
registerFeedbackCommands(pi, {
|
|
262
|
+
baseUrl,
|
|
263
|
+
getToken: getTokenFn,
|
|
264
|
+
fetchImpl: deps.fetchImpl,
|
|
265
|
+
env: deps.env,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
255
268
|
// W4 judgment loop: the decisions surface (/decide + /decisions) and the
|
|
256
269
|
// bless-with-remember capture are the same product-intent write as the record
|
|
257
270
|
// tools, so both are gated together (skipped in eval mode).
|
|
@@ -296,18 +309,19 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
296
309
|
...(guardianMaxAttemptsAdvertised !== undefined ? { maxAttempts: guardianMaxAttemptsAdvertised } : {}),
|
|
297
310
|
});
|
|
298
311
|
guardianLimits.timeoutMs = guardianTimeoutMs;
|
|
299
|
-
// YAG-510:
|
|
300
|
-
// never the command). The remote guardian-events stream below is
|
|
301
|
-
// separate, opt-in, per-workspace analytics sink; the two are independent.
|
|
312
|
+
// YAG-510: Guardian events go to the unified sink under source:"guardian"
|
|
313
|
+
// (hash-only, never the command). The remote guardian-events stream below is
|
|
314
|
+
// the separate, opt-in, per-workspace analytics sink; the two are independent.
|
|
302
315
|
const guardianLogSink = (payload) => {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
316
|
+
const event = typeof payload.event === "string" ? payload.event : "guardian_event";
|
|
317
|
+
const { event: _ignored, ...fields } = payload;
|
|
318
|
+
logEvent({
|
|
319
|
+
source: "guardian",
|
|
320
|
+
level: event === "guardian_event_post_failed" ? "error" : "info",
|
|
321
|
+
event,
|
|
322
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
323
|
+
fields,
|
|
324
|
+
});
|
|
311
325
|
};
|
|
312
326
|
// YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
|
|
313
327
|
// at startup (grants added by other concurrent sessions appear next launch —
|
|
@@ -339,7 +353,8 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
339
353
|
// "off" → nothing (not even sent); "hash" → sha256 + family prefix +
|
|
340
354
|
// metadata, no command content; "raw" → adds client-REDACTED command and
|
|
341
355
|
// rationale. Fire-and-forget: one attempt, short timeout, failures logged
|
|
342
|
-
// fail-soft to guardian
|
|
356
|
+
// fail-soft to the unified sink (source:"guardian") — a storage outage
|
|
357
|
+
// never touches the session.
|
|
343
358
|
onGuardianEvent: guardianStorageTier === "off" || evalMode
|
|
344
359
|
? undefined
|
|
345
360
|
: (ev) => {
|
|
@@ -377,10 +392,10 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
377
392
|
guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
|
|
378
393
|
}
|
|
379
394
|
}
|
|
380
|
-
catch
|
|
395
|
+
catch {
|
|
381
396
|
guardianLogSink({
|
|
382
397
|
event: "guardian_event_post_failed",
|
|
383
|
-
|
|
398
|
+
kind: "network",
|
|
384
399
|
});
|
|
385
400
|
}
|
|
386
401
|
})();
|
|
@@ -462,15 +477,13 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
462
477
|
onDivergence: (driverServerUsd, localUsd) => {
|
|
463
478
|
if (!isDebug(env))
|
|
464
479
|
return;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
/* a diagnostic must never break /cost */
|
|
473
|
-
}
|
|
480
|
+
logEvent({
|
|
481
|
+
source: "cost",
|
|
482
|
+
level: "debug",
|
|
483
|
+
event: "cost_divergence",
|
|
484
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
485
|
+
fields: { driverServerUsd, localUsd },
|
|
486
|
+
});
|
|
474
487
|
},
|
|
475
488
|
// Carry-over (/cost re-review): surfaces sessionRuns.ts's dropped-run-id
|
|
476
489
|
// count as costHud's "Excludes N earlier /go runs." note.
|
|
@@ -699,21 +712,16 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
699
712
|
// session token is expired and refresh failed — the most critical
|
|
700
713
|
// failure signal is no longer silently dropped.
|
|
701
714
|
void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
|
|
702
|
-
// YAG-500 Fix F:
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
catch {
|
|
714
|
-
// A diagnostic must never break the session.
|
|
715
|
-
}
|
|
716
|
-
}
|
|
715
|
+
// YAG-500 Fix F: auth-401 signal is content-free (status + refresh
|
|
716
|
+
// boolean), so it is always-on and upload-safe by the default-on
|
|
717
|
+
// invariant. No message content, no tokens, no headers.
|
|
718
|
+
logEvent({
|
|
719
|
+
source: "auth",
|
|
720
|
+
level: "info",
|
|
721
|
+
event: "auth_401",
|
|
722
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
723
|
+
fields: { status: 401, refresh: rotated ? "succeeded" : "failed" },
|
|
724
|
+
});
|
|
717
725
|
return { message: { ...msg, errorMessage: explanation } };
|
|
718
726
|
}
|
|
719
727
|
// YAG-460: the backend proxy answers an oversized conversation with an
|
|
@@ -907,6 +915,30 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
907
915
|
footerInvalidateHandle.invalidateGit();
|
|
908
916
|
}
|
|
909
917
|
});
|
|
918
|
+
// Seed the unified error trail from tool-exec failures. A tool's SUCCESS
|
|
919
|
+
// is content (it lives in the transcript); its FAILURE is an error and belongs
|
|
920
|
+
// in the sink. We log only the tool name + error class — never args, partial
|
|
921
|
+
// results, or result bodies (those are content and stay out of the upload-safe
|
|
922
|
+
// tier). This is the tool-failure half of the error trail the ticket asks for.
|
|
923
|
+
pi.on("tool_execution_end", (event) => {
|
|
924
|
+
if (!event.isError)
|
|
925
|
+
return;
|
|
926
|
+
// `event.result` is any; its `.error`/message can echo a path or secret
|
|
927
|
+
// (a tool's own failure text). Keep the always-on trail content-free: use
|
|
928
|
+
// ONLY the Error subclass name, never the message or a String() of the
|
|
929
|
+
// result's error payload.
|
|
930
|
+
const errorClass = event.result instanceof Error
|
|
931
|
+
? event.result.name || "Error"
|
|
932
|
+
: "tool_error";
|
|
933
|
+
logEvent({
|
|
934
|
+
source: "tool",
|
|
935
|
+
level: "error",
|
|
936
|
+
event: "tool_failed",
|
|
937
|
+
sessionId: sessionIdForLog(),
|
|
938
|
+
flush: "sync",
|
|
939
|
+
fields: { toolName: event.toolName, errorClass },
|
|
940
|
+
});
|
|
941
|
+
});
|
|
910
942
|
}
|
|
911
943
|
export default async function (pi) {
|
|
912
944
|
await registerYagni(pi);
|
|
@@ -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
|
}
|
|
@@ -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).
|
|
@@ -25,6 +25,7 @@ import * as path from "node:path";
|
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
import { trackChild } from "./childRegistry.js";
|
|
27
27
|
import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
|
|
28
|
+
import { logEvent } from "../errorSink.js";
|
|
28
29
|
import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
|
|
29
30
|
import { personaBody } from "./personas.js";
|
|
30
31
|
import { clampTier, resolveTierCap } from "./tierCap.js";
|
|
@@ -311,6 +312,14 @@ export async function runStage(stage, ctx, deps) {
|
|
|
311
312
|
final_output: finalOut,
|
|
312
313
|
}, null, 2));
|
|
313
314
|
dbgLog("stage_end");
|
|
315
|
+
if (overlongLinesDropped > 0) {
|
|
316
|
+
logEvent({
|
|
317
|
+
source: "pipeline",
|
|
318
|
+
level: "debug",
|
|
319
|
+
event: "overlong_lines_dropped",
|
|
320
|
+
fields: { stage: stage.id, dropped: overlongLinesDropped },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
314
323
|
}
|
|
315
324
|
catch { /* ignore */ }
|
|
316
325
|
}
|
|
@@ -12,41 +12,20 @@
|
|
|
12
12
|
* rotating file under `~/.yagni-code/logs/`, fail-soft, hermetic under
|
|
13
13
|
* `node --test`.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { readdirSync } from "node:fs";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { _setErrorSinkHomeForTest, logEvent, errorSinkPath, } from "./errorSink.js";
|
|
18
18
|
/** Test seam: point the log at a tmpdir (mirrors _setDiagnosticsHomeForTest). */
|
|
19
19
|
let homeOverride = null;
|
|
20
20
|
export function _setTurnLogHomeForTest(dir) {
|
|
21
21
|
homeOverride = dir;
|
|
22
|
+
_setErrorSinkHomeForTest(dir);
|
|
22
23
|
}
|
|
23
24
|
function turnLogDir() {
|
|
24
|
-
return
|
|
25
|
+
return dirname(errorSinkPath());
|
|
25
26
|
}
|
|
26
27
|
export function turnLogPath() {
|
|
27
|
-
return
|
|
28
|
-
}
|
|
29
|
-
const MAX_LOG_BYTES = 256 * 1024;
|
|
30
|
-
const KEEP_ROTATIONS = 2;
|
|
31
|
-
/** Shift turn-lifecycle.log -> .1 -> .2 once the active file passes the cap. */
|
|
32
|
-
function rotateIfNeeded(path) {
|
|
33
|
-
try {
|
|
34
|
-
if (!statSync(path).isFile() || statSync(path).size < MAX_LOG_BYTES)
|
|
35
|
-
return;
|
|
36
|
-
for (let i = KEEP_ROTATIONS; i >= 1; i--) {
|
|
37
|
-
const from = i === 1 ? path : `${path}.${i - 1}`;
|
|
38
|
-
const to = `${path}.${i}`;
|
|
39
|
-
try {
|
|
40
|
-
renameSync(from, to);
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* absent source — fine */
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
/* rotation is best-effort */
|
|
49
|
-
}
|
|
28
|
+
return errorSinkPath();
|
|
50
29
|
}
|
|
51
30
|
/**
|
|
52
31
|
* Append one sanitized lifecycle record. Fail-soft — a logging failure must
|
|
@@ -54,16 +33,15 @@ function rotateIfNeeded(path) {
|
|
|
54
33
|
* raw error message.
|
|
55
34
|
*/
|
|
56
35
|
export function logTurnLifecycle(ev) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
...(ev.sessionId ? { sessionId: ev.sessionId } : {}),
|
|
36
|
+
// Turn lifecycle is the crash-survivable WAL: it must flush SYNCHRONOUSLY so
|
|
37
|
+
// a turn that starts but never ends still leaves a durable turn_start.
|
|
38
|
+
logEvent({
|
|
39
|
+
source: "turn",
|
|
40
|
+
level: "info",
|
|
41
|
+
event: ev.kind,
|
|
42
|
+
sessionId: ev.sessionId,
|
|
43
|
+
flush: "sync",
|
|
44
|
+
fields: {
|
|
67
45
|
...((ev.kind === "turn_start" || ev.kind === "turn_end") && ev.turnIndex !== undefined ? { turnIndex: ev.turnIndex } : {}),
|
|
68
46
|
...(ev.kind === "turn_end" && ev.stopReason !== undefined ? { stopReason: ev.stopReason } : {}),
|
|
69
47
|
...(ev.kind === "turn_end" && ev.elapsedMs !== undefined ? { elapsedMs: ev.elapsedMs } : {}),
|
|
@@ -71,22 +49,15 @@ export function logTurnLifecycle(ev) {
|
|
|
71
49
|
...(ev.kind === "silent_turn" && ev.turnsSinceSpoke !== undefined ? { turnsSinceSpoke: ev.turnsSinceSpoke } : {}),
|
|
72
50
|
...(ev.kind === "silent_turn_nudge" && ev.nudgesInStretch !== undefined ? { nudgesInStretch: ev.nudgesInStretch } : {}),
|
|
73
51
|
...(ev.kind === "silent_turn_nudge" && ev.secondsSinceSpoke !== undefined ? { secondsSinceSpoke: ev.secondsSinceSpoke } : {}),
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
-
rotateIfNeeded(path);
|
|
78
|
-
appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
/* logging must never throw into the editor */
|
|
82
|
-
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
83
54
|
}
|
|
84
55
|
/** List existing lifecycle log files (active + rotations), for a report. */
|
|
85
56
|
export function listTurnLogFiles() {
|
|
86
57
|
try {
|
|
87
58
|
const dir = turnLogDir();
|
|
88
59
|
return readdirSync(dir)
|
|
89
|
-
.filter((f) => f.startsWith("
|
|
60
|
+
.filter((f) => f.startsWith("errors-"))
|
|
90
61
|
.sort()
|
|
91
62
|
.map((f) => join(dir, f));
|
|
92
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.0.0-staging.
|
|
3
|
+
"version": "1.0.0-staging.1175.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -40,5 +40,5 @@
|
|
|
40
40
|
"turndown": "^7.2.4",
|
|
41
41
|
"typebox": "^1.3.15"
|
|
42
42
|
},
|
|
43
|
-
"yagniSourceSha": "
|
|
43
|
+
"yagniSourceSha": "15a46cded8f9e0d2555d02772ca2b0a658684109"
|
|
44
44
|
}
|