@yagni-app/code 0.3.5 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/cli.js +231 -6
- package/dist/crashReport.d.ts +8 -0
- package/dist/crashReport.js +13 -1
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +33 -0
- package/dist/extension/askAdvisorTool.d.ts +7 -0
- package/dist/extension/askAdvisorTool.js +13 -3
- package/dist/extension/askUserQuestionTool.d.ts +54 -0
- package/dist/extension/askUserQuestionTool.js +621 -0
- package/dist/extension/askYagniTool.js +2 -0
- package/dist/extension/branding.d.ts +15 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/chipEditor.d.ts +22 -1
- package/dist/extension/chipEditor.js +58 -5
- package/dist/extension/cmux/state.js +9 -16
- package/dist/extension/condensedTools.d.ts +93 -0
- package/dist/extension/condensedTools.js +392 -0
- 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/diffStat.d.ts +62 -0
- package/dist/extension/diffStat.js +158 -0
- 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/footer.d.ts +2 -0
- package/dist/extension/footer.js +21 -8
- package/dist/extension/hooks.js +12 -12
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +161 -42
- package/dist/extension/mineBeat.js +13 -0
- package/dist/extension/permission/execPolicy.js +47 -0
- package/dist/extension/pipeline/goCommand.js +2 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -0
- package/dist/extension/pipeline/invocation.js +7 -0
- package/dist/extension/pipeline/personas.js +4 -4
- package/dist/extension/pipeline/runner.d.ts +1 -0
- package/dist/extension/pipeline/runner.js +24 -3
- package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
- package/dist/extension/pipeline/sessionWorktree.js +225 -0
- package/dist/extension/scratchpad.d.ts +66 -0
- package/dist/extension/scratchpad.js +93 -0
- package/dist/extension/silentTurnReminder.js +18 -14
- package/dist/extension/subagents.d.ts +10 -0
- package/dist/extension/subagents.js +18 -4
- package/dist/extension/todos.d.ts +1 -0
- package/dist/extension/todos.js +15 -0
- package/dist/extension/toolRuns.d.ts +92 -0
- package/dist/extension/toolRuns.js +201 -0
- package/dist/extension/turnLog.js +17 -46
- 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 +106 -0
- package/dist/extension/workingLine.d.ts +49 -0
- package/dist/extension/workingLine.js +116 -0
- package/dist/feedback.d.ts +77 -0
- package/dist/feedback.js +500 -0
- package/dist/goHeadless.d.ts +3 -0
- package/dist/goHeadless.js +13 -0
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +6 -0
- package/dist/otel.d.ts +150 -0
- package/dist/otel.js +291 -0
- package/dist/outputFormat.d.ts +83 -0
- package/dist/outputFormat.js +207 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +13 -0
- package/dist/worktreeArgs.d.ts +43 -0
- package/dist/worktreeArgs.js +96 -0
- package/package.json +4 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Committed branch diff stat for the footer's git badge.
|
|
3
|
+
*
|
|
4
|
+
* Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
|
|
5
|
+
* line delta between `HEAD` and the merge base with the repository's *default*
|
|
6
|
+
* branch, so the badge answers "what does this branch contain" — the same
|
|
7
|
+
* number a GitHub PR UI would show. Uncommitted working-tree edits are
|
|
8
|
+
* intentionally ignored (Codex: "the status-line item summarizes the checked-
|
|
9
|
+
* out branch, not the current dirty worktree").
|
|
10
|
+
*
|
|
11
|
+
* Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
|
|
12
|
+
*
|
|
13
|
+
* Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
|
|
14
|
+
* demanded on each footer render. The only divergence from Codex is that their
|
|
15
|
+
* probe is `async`; ours is `spawnSync` because the footer render path is
|
|
16
|
+
* synchronous.
|
|
17
|
+
*/
|
|
18
|
+
/** Committed branch delta relative to the default branch. */
|
|
19
|
+
export interface BranchDiffStat {
|
|
20
|
+
readonly added: number;
|
|
21
|
+
readonly deleted: number;
|
|
22
|
+
}
|
|
23
|
+
/** Diff-stat source with a workDir, callable on demand. */
|
|
24
|
+
export interface DiffStatCache {
|
|
25
|
+
/** Current stat, or `null` when workDir isn't a git repo. */
|
|
26
|
+
get(): BranchDiffStat | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Injectable git runner seam (tests stub it; production shells out to `git`).
|
|
30
|
+
* Returns stdout, or `null` on any failure.
|
|
31
|
+
*/
|
|
32
|
+
export type GitRunner = (args: string[], cwd: string) => string | null;
|
|
33
|
+
/** True when `cwd` is inside a git work tree. Never throws. */
|
|
34
|
+
export declare function detectGitRepo(run: GitRunner, cwd: string): boolean;
|
|
35
|
+
/** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
|
|
36
|
+
export declare function parseNumstatCount(value: string | undefined): number;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the default branch name, preferring remote-tracking refs over local
|
|
39
|
+
* branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
|
|
40
|
+
* Falls back to common local names when no remote advertises a default.
|
|
41
|
+
* Returns null when nothing resolves.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveDefaultBranch(run: GitRunner, cwd: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Sum the committed line delta between `HEAD` and the merge base with the
|
|
46
|
+
* default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
|
|
47
|
+
* the default branch or merge base can't be resolved (hide the badge).
|
|
48
|
+
*/
|
|
49
|
+
export declare function readDiffStat(run: GitRunner, cwd: string): BranchDiffStat | null;
|
|
50
|
+
/**
|
|
51
|
+
* Kimi-style display: `+N -M`, or null when the branch is even with its base
|
|
52
|
+
* (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
|
|
53
|
+
* matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatDiffStat(diff: BranchDiffStat | null): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Time-bucketed cache: `get()` re-reads git only when the last read was more
|
|
58
|
+
* than 15s ago, null off-repo. All git failures → null (badge hidden), never
|
|
59
|
+
* throws.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createDiffStatCache(workDir: string, run?: GitRunner): DiffStatCache;
|
|
62
|
+
//# sourceMappingURL=diffStat.d.ts.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Committed branch diff stat for the footer's git badge.
|
|
3
|
+
*
|
|
4
|
+
* Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
|
|
5
|
+
* line delta between `HEAD` and the merge base with the repository's *default*
|
|
6
|
+
* branch, so the badge answers "what does this branch contain" — the same
|
|
7
|
+
* number a GitHub PR UI would show. Uncommitted working-tree edits are
|
|
8
|
+
* intentionally ignored (Codex: "the status-line item summarizes the checked-
|
|
9
|
+
* out branch, not the current dirty worktree").
|
|
10
|
+
*
|
|
11
|
+
* Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
|
|
12
|
+
*
|
|
13
|
+
* Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
|
|
14
|
+
* demanded on each footer render. The only divergence from Codex is that their
|
|
15
|
+
* probe is `async`; ours is `spawnSync` because the footer render path is
|
|
16
|
+
* synchronous.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
/** Status refresh interval — same value Kimi uses (STATUS_TTL_MS). */
|
|
20
|
+
const STATUS_TTL_MS = 15_000;
|
|
21
|
+
/** git spawn timeout — same value Kimi uses (SPAWN_TIMEOUT_MS). */
|
|
22
|
+
const SPAWN_TIMEOUT_MS = 500;
|
|
23
|
+
const SPAWN_MAX_BUFFER = 4 * 1024 * 1024;
|
|
24
|
+
/** Production runner: `spawnSync` with a short timeout, all errors → null. */
|
|
25
|
+
function runGit(args, cwd) {
|
|
26
|
+
try {
|
|
27
|
+
const r = spawnSync("git", ["--no-optional-locks", ...args], {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
31
|
+
maxBuffer: SPAWN_MAX_BUFFER,
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
});
|
|
34
|
+
if (r.error || r.status !== 0)
|
|
35
|
+
return null;
|
|
36
|
+
return r.stdout;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** True when `cwd` is inside a git work tree. Never throws. */
|
|
43
|
+
export function detectGitRepo(run, cwd) {
|
|
44
|
+
const out = run(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
45
|
+
return out !== null && out.trim() === "true";
|
|
46
|
+
}
|
|
47
|
+
/** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
|
|
48
|
+
export function parseNumstatCount(value) {
|
|
49
|
+
if (value === undefined || value === "-")
|
|
50
|
+
return 0;
|
|
51
|
+
const n = Number.parseInt(value, 10);
|
|
52
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
53
|
+
}
|
|
54
|
+
/** List remotes, `origin` first (mirrors Codex's `get_git_remotes`). */
|
|
55
|
+
function gitRemotes(run, cwd) {
|
|
56
|
+
const out = run(["remote"], cwd);
|
|
57
|
+
if (out === null)
|
|
58
|
+
return [];
|
|
59
|
+
const remotes = out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
60
|
+
const originIdx = remotes.indexOf("origin");
|
|
61
|
+
if (originIdx > 0) {
|
|
62
|
+
remotes.splice(originIdx, 1);
|
|
63
|
+
remotes.unshift("origin");
|
|
64
|
+
}
|
|
65
|
+
return remotes;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the default branch name, preferring remote-tracking refs over local
|
|
69
|
+
* branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
|
|
70
|
+
* Falls back to common local names when no remote advertises a default.
|
|
71
|
+
* Returns null when nothing resolves.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveDefaultBranch(run, cwd) {
|
|
74
|
+
// Tier 1: remote default via `refs/remotes/<remote>/HEAD` symbolic ref.
|
|
75
|
+
for (const remote of gitRemotes(run, cwd)) {
|
|
76
|
+
const out = run(["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`], cwd);
|
|
77
|
+
if (out === null)
|
|
78
|
+
continue;
|
|
79
|
+
const ref = out.trim();
|
|
80
|
+
if (!ref)
|
|
81
|
+
continue;
|
|
82
|
+
// `refs/remotes/origin/main` → `main`.
|
|
83
|
+
const name = ref.replace(/^refs\/remotes\/[^/]+\//, "");
|
|
84
|
+
if (name && name !== "HEAD")
|
|
85
|
+
return name;
|
|
86
|
+
}
|
|
87
|
+
// Tier 2: local main/master/trunk/develop.
|
|
88
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
89
|
+
const out = run(["rev-parse", "--verify", "--quiet", candidate], cwd);
|
|
90
|
+
if (out !== null && out.trim())
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Sum the committed line delta between `HEAD` and the merge base with the
|
|
97
|
+
* default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
|
|
98
|
+
* the default branch or merge base can't be resolved (hide the badge).
|
|
99
|
+
*/
|
|
100
|
+
export function readDiffStat(run, cwd) {
|
|
101
|
+
const defaultBranch = resolveDefaultBranch(run, cwd);
|
|
102
|
+
if (defaultBranch === null)
|
|
103
|
+
return null;
|
|
104
|
+
const mergeBase = run(["merge-base", "HEAD", defaultBranch], cwd);
|
|
105
|
+
if (mergeBase === null || !mergeBase.trim())
|
|
106
|
+
return null;
|
|
107
|
+
const out = run(["diff", "--numstat", `${mergeBase.trim()}..HEAD`, "--"], cwd);
|
|
108
|
+
if (out === null)
|
|
109
|
+
return null;
|
|
110
|
+
let added = 0;
|
|
111
|
+
let deleted = 0;
|
|
112
|
+
for (const line of out.split("\n")) {
|
|
113
|
+
if (!line)
|
|
114
|
+
continue;
|
|
115
|
+
const [addedText, deletedText] = line.split("\t");
|
|
116
|
+
added += parseNumstatCount(addedText);
|
|
117
|
+
deleted += parseNumstatCount(deletedText);
|
|
118
|
+
}
|
|
119
|
+
return { added, deleted };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Kimi-style display: `+N -M`, or null when the branch is even with its base
|
|
123
|
+
* (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
|
|
124
|
+
* matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
|
|
125
|
+
*/
|
|
126
|
+
export function formatDiffStat(diff) {
|
|
127
|
+
if (diff === null)
|
|
128
|
+
return null;
|
|
129
|
+
const parts = [];
|
|
130
|
+
if (diff.added > 0)
|
|
131
|
+
parts.push(`+${String(diff.added)}`);
|
|
132
|
+
if (diff.deleted > 0)
|
|
133
|
+
parts.push(`-${String(diff.deleted)}`);
|
|
134
|
+
return parts.length > 0 ? parts.join(" ") : null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Time-bucketed cache: `get()` re-reads git only when the last read was more
|
|
138
|
+
* than 15s ago, null off-repo. All git failures → null (badge hidden), never
|
|
139
|
+
* throws.
|
|
140
|
+
*/
|
|
141
|
+
export function createDiffStatCache(workDir, run = runGit) {
|
|
142
|
+
const isRepo = detectGitRepo(run, workDir);
|
|
143
|
+
let cached = null;
|
|
144
|
+
let fetchedAt = 0;
|
|
145
|
+
return {
|
|
146
|
+
get() {
|
|
147
|
+
if (!isRepo)
|
|
148
|
+
return null;
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (cached === null || now - fetchedAt >= STATUS_TTL_MS) {
|
|
151
|
+
cached = readDiffStat(run, workDir);
|
|
152
|
+
fetchedAt = now;
|
|
153
|
+
}
|
|
154
|
+
return cached;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=diffStat.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
|
|
@@ -96,6 +96,8 @@ export declare function renderFooterLines(input: {
|
|
|
96
96
|
model: string;
|
|
97
97
|
/** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
|
|
98
98
|
mode?: PermissionMode | null;
|
|
99
|
+
/** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
|
|
100
|
+
diff?: string | null;
|
|
99
101
|
usage: UsageTotals;
|
|
100
102
|
contextPercent: number | null;
|
|
101
103
|
statuses: string[];
|