@yagni-app/code-staging 1.0.0-staging.1177.1 → 1.0.0-staging.1178.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/cli.js CHANGED
@@ -28,6 +28,7 @@ import { logout } from "./logout.js";
28
28
  import { tokenCommand } from "./token.js";
29
29
  import { buildLaunch } from "./launch.js";
30
30
  import { parseOutputFormat, parseJsonEvents, buildResultObject, readGuardianEvents, } from "./outputFormat.js";
31
+ import { feedbackCommand } from "./feedback.js";
31
32
  import { runDoctor } from "./doctor.js";
32
33
  import { installProcessCrashHandlers } from "./crashReport.js";
33
34
  import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
@@ -335,6 +336,9 @@ export const HELP_TEXT = [
335
336
  " yagni login Authorize the active environment (device-code flow).",
336
337
  " yagni logout Revoke and clear the active environment's token.",
337
338
  " yagni doctor Check that everything is ready (green/red checklist).",
339
+ " yagni feedback [sessionId] File a bug report from the shell. Lists recent",
340
+ " sessions to pick, or pass a session ID directly.",
341
+ " Attaches transcript + error trail, then submits.",
338
342
  " yagni go --headless Run the /go pipeline without a session, for scripts",
339
343
  " and CI: --ticket-file <path> [--plan-file <path>]",
340
344
  " [--memo-file <path>] [--run-id <id>] [--json].",
@@ -486,6 +490,9 @@ export async function main(argv) {
486
490
  if (command === "go") {
487
491
  return goCommand(rest, {}, cliVersion());
488
492
  }
493
+ if (command === "feedback") {
494
+ return feedbackCommand(rest, {}, cliVersion());
495
+ }
489
496
  if (command === "token") {
490
497
  return tokenCommand();
491
498
  }
@@ -41,6 +41,7 @@ export declare function crashReportsDisabled(env?: NodeJS.ProcessEnv): boolean;
41
41
  export declare function runningUnderTest(env?: NodeJS.ProcessEnv): boolean;
42
42
  /** Reporting is off when the user disabled it OR this is a test process. */
43
43
  export declare function crashReportsSuppressed(env?: NodeJS.ProcessEnv): boolean;
44
+ export declare const SECRET_PATTERNS: Array<[RegExp, string]>;
44
45
  export interface SanitizeCrashOptions {
45
46
  /** Environment whose values get redacted (defaults to process.env). */
46
47
  env?: NodeJS.ProcessEnv;
@@ -57,6 +58,13 @@ export interface SanitizeCrashOptions {
57
58
  * `node_modules/` on, so dependency frames stay diagnosable)
58
59
  * Over-redacts rather than under-redacts; pure; never throws.
59
60
  */
61
+ /**
62
+ * Lightweight secret-only scrub — applies SECRET_PATTERNS and nothing else.
63
+ * Matches the extension's `scrubSecrets` exactly (no path collapse, no env
64
+ * redaction). Use this for feedback transcripts where file paths and code
65
+ * context must stay readable; the backend re-normalizes home paths on receipt.
66
+ */
67
+ export declare function scrubSecrets(text: string): string;
60
68
  export declare function sanitizeCrashText(text: string, opts?: SanitizeCrashOptions): string;
61
69
  export interface SanitizedCrash {
62
70
  errorClass: string;
@@ -62,7 +62,7 @@ export function crashReportsSuppressed(env = process.env) {
62
62
  }
63
63
  // Mirrors scrubSecrets (backend yagniCode/scrubSecrets.ts and
64
64
  // pi-extension-yagni pipeline/scrubSecrets.ts) — keep in sync.
65
- const SECRET_PATTERNS = [
65
+ export const SECRET_PATTERNS = [
66
66
  [/\b([a-z][a-z0-9+.\-]*:\/\/[^\s:@/]+):[^\s:@/]+@/gi, "$1:[REDACTED]@"],
67
67
  [/\b(sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|rk_(?:live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_\-]{20,})\b/g, "[REDACTED]"],
68
68
  [/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
@@ -106,6 +106,18 @@ function collapsePathToken(token) {
106
106
  * `node_modules/` on, so dependency frames stay diagnosable)
107
107
  * Over-redacts rather than under-redacts; pure; never throws.
108
108
  */
109
+ /**
110
+ * Lightweight secret-only scrub — applies SECRET_PATTERNS and nothing else.
111
+ * Matches the extension's `scrubSecrets` exactly (no path collapse, no env
112
+ * redaction). Use this for feedback transcripts where file paths and code
113
+ * context must stay readable; the backend re-normalizes home paths on receipt.
114
+ */
115
+ export function scrubSecrets(text) {
116
+ let out = text;
117
+ for (const [re, repl] of SECRET_PATTERNS)
118
+ out = out.replace(re, repl);
119
+ return out;
120
+ }
109
121
  export function sanitizeCrashText(text, opts = {}) {
110
122
  let out = text;
111
123
  const env = opts.env ?? process.env;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `yagni feedback [sessionId]` — file a bug report from the shell (YAG-592).
3
+ *
4
+ * A shortcut to trigger what `/feedback` does inside a session, but from
5
+ * outside the TUI. Two modes:
6
+ *
7
+ * Case A (no session arg): list the 10 most recent sessions for the current
8
+ * cwd, let the user pick, prompt for a description, confirm, submit.
9
+ * Case B (session ID provided): skip the list, go straight to description
10
+ * prompt → confirm → submit.
11
+ *
12
+ * Self-contained: no cross-package imports. The scrub (`scrubSecrets`) and the
13
+ * error-trail reader (`readSessionTrail`) are local copies of the extension's
14
+ * logic — keep in sync with `pi-extension-yagni/src/pipeline/scrubSecrets.ts`
15
+ * and `pi-extension-yagni/src/errorSink.ts`. The backend re-scrubs server-side
16
+ * (`backend/src/yagniCode/feedback.ts`), so the client-side scrub is the first
17
+ * line of defense, not the only one.
18
+ */
19
+ export interface FeedbackDeps {
20
+ loadCredentials?: () => Promise<{
21
+ token?: string;
22
+ baseUrl: string;
23
+ name: string;
24
+ }>;
25
+ fetchImpl?: typeof fetch;
26
+ env?: NodeJS.ProcessEnv;
27
+ cwd?: string;
28
+ /** Override the agent dir (sessions live under `<agentDir>/sessions/...`). */
29
+ agentDirPath?: string;
30
+ /** Override the state dir (error sink lives under `<stateDir>/logs/...`). */
31
+ stateDir?: string;
32
+ writeOut?: (line: string) => void;
33
+ writeErr?: (line: string) => void;
34
+ /** Seam for readline — tests inject a fake that returns scripted answers. */
35
+ readline?: {
36
+ question: (q: string) => Promise<string>;
37
+ close: () => void;
38
+ };
39
+ }
40
+ export interface SessionInfo {
41
+ id: string;
42
+ filePath: string;
43
+ startTime: string;
44
+ durationMs: number | null;
45
+ firstMessage: string;
46
+ }
47
+ /**
48
+ * Encode a cwd into pi's session directory name format:
49
+ * `/Users/foo/bar` → `--Users-foo-bar--`
50
+ * Mirrors pi's `migrations.js`: `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
51
+ */
52
+ export declare function encodeCwd(cwd: string): string;
53
+ /**
54
+ * List the most recent sessions for the current cwd.
55
+ * Scans `<agentDir>/sessions/<encoded-cwd>/*.jsonl`, sorted by start time desc.
56
+ */
57
+ export declare function listRecentSessions(agentDirPath: string, cwd: string, limit?: number, termCols?: number): SessionInfo[];
58
+ /**
59
+ * Find a session file by UUID across all project dirs.
60
+ */
61
+ export declare function findSessionById(agentDirPath: string, sessionId: string): {
62
+ filePath: string;
63
+ startTime: string;
64
+ } | null;
65
+ /**
66
+ * Read the durable transcript, clamped by byte size. Returns empty on any
67
+ * failure or when too large. Mirrors the extension's `readTranscript`.
68
+ */
69
+ export declare function readTranscript(sessionFile: string | undefined): string;
70
+ /**
71
+ * Read the session-scoped error trail from today's error sink file.
72
+ * Filters by sessionId and excludes debug-level entries. Scrubs each line.
73
+ * Mirrors the extension's `readSessionTrail` — keep in sync.
74
+ */
75
+ export declare function readSessionTrail(sessionId: string, stateDir: string, maxBytes?: number): string;
76
+ export declare function feedbackCommand(args: string[], deps?: FeedbackDeps, cliVersion?: string): Promise<number>;
77
+ //# sourceMappingURL=feedback.d.ts.map
@@ -0,0 +1,500 @@
1
+ /**
2
+ * `yagni feedback [sessionId]` — file a bug report from the shell (YAG-592).
3
+ *
4
+ * A shortcut to trigger what `/feedback` does inside a session, but from
5
+ * outside the TUI. Two modes:
6
+ *
7
+ * Case A (no session arg): list the 10 most recent sessions for the current
8
+ * cwd, let the user pick, prompt for a description, confirm, submit.
9
+ * Case B (session ID provided): skip the list, go straight to description
10
+ * prompt → confirm → submit.
11
+ *
12
+ * Self-contained: no cross-package imports. The scrub (`scrubSecrets`) and the
13
+ * error-trail reader (`readSessionTrail`) are local copies of the extension's
14
+ * logic — keep in sync with `pi-extension-yagni/src/pipeline/scrubSecrets.ts`
15
+ * and `pi-extension-yagni/src/errorSink.ts`. The backend re-scrubs server-side
16
+ * (`backend/src/yagniCode/feedback.ts`), so the client-side scrub is the first
17
+ * line of defense, not the only one.
18
+ */
19
+ import { readFileSync, readdirSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { createInterface } from "node:readline/promises";
22
+ import { stdin as input, stdout as output } from "node:process";
23
+ import { isatty } from "node:tty";
24
+ import { agentDir, credentialsDir } from "./credentials.js";
25
+ import { scrubSecrets } from "./crashReport.js";
26
+ import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
27
+ const MAX_DESCRIPTION = 512;
28
+ const MAX_TRANSCRIPT_READ_BYTES = 512 * 1024;
29
+ const MAX_TRAIL_BYTES = 64 * 1024;
30
+ const FIRST_MESSAGE_PREVIEW_FALLBACK = 60;
31
+ /** Max chars for the first-message preview, capped by terminal width. */
32
+ function previewMaxWidth(termCols) {
33
+ const cols = termCols ?? process.stdout.columns;
34
+ if (!cols || cols < 40)
35
+ return FIRST_MESSAGE_PREVIEW_FALLBACK;
36
+ // Account for: " " + " N. " (5) + time (18) + dur (6) = ~29 chars of prefix
37
+ const available = cols - 32;
38
+ return Math.max(available, 20);
39
+ }
40
+ const LIST_LIMIT = 10;
41
+ // --- session discovery ---
42
+ /**
43
+ * Encode a cwd into pi's session directory name format:
44
+ * `/Users/foo/bar` → `--Users-foo-bar--`
45
+ * Mirrors pi's `migrations.js`: `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
46
+ */
47
+ export function encodeCwd(cwd) {
48
+ return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
49
+ }
50
+ /**
51
+ * Parse the first few lines of a session JSONL to extract metadata.
52
+ * Returns null on any failure (corrupt/empty file).
53
+ */
54
+ function parseSessionHeader(filePath) {
55
+ try {
56
+ const data = readFileSync(filePath, "utf8");
57
+ const lines = data.split("\n").filter((l) => l.length > 0);
58
+ for (const line of lines) {
59
+ try {
60
+ const obj = JSON.parse(line);
61
+ if (obj.type === "session" && typeof obj.id === "string" && typeof obj.timestamp === "string") {
62
+ return { id: obj.id, timestamp: obj.timestamp };
63
+ }
64
+ }
65
+ catch {
66
+ // skip unparseable lines
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Extract the first user message text from a session JSONL (skips
77
+ * `custom_message` entries — only real `type: "message"` with `role: "user"`).
78
+ */
79
+ function extractFirstUserMessage(filePath, maxChars) {
80
+ try {
81
+ const data = readFileSync(filePath, "utf8");
82
+ const lines = data.split("\n").filter((l) => l.length > 0);
83
+ for (const line of lines) {
84
+ try {
85
+ const obj = JSON.parse(line);
86
+ if (obj.type === "message" &&
87
+ obj.message?.role === "user" &&
88
+ Array.isArray(obj.message?.content)) {
89
+ const textPart = obj.message.content.find((c) => c.type === "text");
90
+ if (textPart?.text) {
91
+ // Strip skill invocation XML tags (e.g. `<skill name="…" …>`)
92
+ // — they're machinery, not the user's message.
93
+ let text = textPart.text.trim()
94
+ .replace(/<skill\s[^>]*>\s*/g, "")
95
+ .replace(/<\/skill>/g, "")
96
+ .replace(/\n+/g, " ")
97
+ .trim();
98
+ const cap = maxChars ?? FIRST_MESSAGE_PREVIEW_FALLBACK;
99
+ if (text.length > 0) {
100
+ return text.length > cap
101
+ ? `${text.slice(0, cap)}…`
102
+ : text;
103
+ }
104
+ }
105
+ }
106
+ }
107
+ catch {
108
+ // skip unparseable lines
109
+ }
110
+ }
111
+ return "";
112
+ }
113
+ catch {
114
+ return "";
115
+ }
116
+ }
117
+ /**
118
+ * Get the timestamp of the last non-empty line in a session JSONL.
119
+ * Used to compute session duration.
120
+ */
121
+ function extractLastTimestamp(filePath) {
122
+ try {
123
+ const data = readFileSync(filePath, "utf8");
124
+ const lines = data.split("\n").filter((l) => l.length > 0);
125
+ for (let i = lines.length - 1; i >= 0; i--) {
126
+ try {
127
+ const obj = JSON.parse(lines[i]);
128
+ if (typeof obj.timestamp === "string")
129
+ return obj.timestamp;
130
+ }
131
+ catch {
132
+ // skip
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ /**
142
+ * List the most recent sessions for the current cwd.
143
+ * Scans `<agentDir>/sessions/<encoded-cwd>/*.jsonl`, sorted by start time desc.
144
+ */
145
+ export function listRecentSessions(agentDirPath, cwd, limit = LIST_LIMIT, termCols) {
146
+ const sessionsDir = join(agentDirPath, "sessions", encodeCwd(cwd));
147
+ let files;
148
+ try {
149
+ files = readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ const sessions = [];
155
+ for (const file of files) {
156
+ const filePath = join(sessionsDir, file);
157
+ const header = parseSessionHeader(filePath);
158
+ if (!header)
159
+ continue;
160
+ const firstMessage = extractFirstUserMessage(filePath, previewMaxWidth(termCols));
161
+ const lastTs = extractLastTimestamp(filePath);
162
+ let durationMs = null;
163
+ if (lastTs) {
164
+ const start = Date.parse(header.timestamp);
165
+ const end = Date.parse(lastTs);
166
+ if (!isNaN(start) && !isNaN(end))
167
+ durationMs = end - start;
168
+ }
169
+ sessions.push({
170
+ id: header.id,
171
+ filePath,
172
+ startTime: header.timestamp,
173
+ durationMs,
174
+ firstMessage,
175
+ });
176
+ }
177
+ sessions.sort((a, b) => b.startTime.localeCompare(a.startTime));
178
+ return sessions.slice(0, limit);
179
+ }
180
+ /**
181
+ * Find a session file by UUID across all project dirs.
182
+ */
183
+ export function findSessionById(agentDirPath, sessionId) {
184
+ const sessionsRoot = join(agentDirPath, "sessions");
185
+ let projectDirs;
186
+ try {
187
+ projectDirs = readdirSync(sessionsRoot, { withFileTypes: true })
188
+ .filter((d) => d.isDirectory())
189
+ .map((d) => join(sessionsRoot, d.name));
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ for (const dir of projectDirs) {
195
+ let files;
196
+ try {
197
+ files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ for (const file of files) {
203
+ if (file.includes(sessionId)) {
204
+ const filePath = join(dir, file);
205
+ const header = parseSessionHeader(filePath);
206
+ if (header)
207
+ return { filePath, startTime: header.timestamp };
208
+ }
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ // --- transcript + error trail ---
214
+ /**
215
+ * Read the durable transcript, clamped by byte size. Returns empty on any
216
+ * failure or when too large. Mirrors the extension's `readTranscript`.
217
+ */
218
+ export function readTranscript(sessionFile) {
219
+ if (!sessionFile)
220
+ return "";
221
+ try {
222
+ const data = readFileSync(sessionFile, "utf8");
223
+ if (Buffer.byteLength(data, "utf8") > MAX_TRANSCRIPT_READ_BYTES)
224
+ return "";
225
+ return data;
226
+ }
227
+ catch {
228
+ return "";
229
+ }
230
+ }
231
+ /**
232
+ * Read the session-scoped error trail from today's error sink file.
233
+ * Filters by sessionId and excludes debug-level entries. Scrubs each line.
234
+ * Mirrors the extension's `readSessionTrail` — keep in sync.
235
+ */
236
+ export function readSessionTrail(sessionId, stateDir, maxBytes = MAX_TRAIL_BYTES) {
237
+ try {
238
+ const dayStamp = new Date().toISOString().slice(0, 10);
239
+ const sinkPath = join(stateDir, "logs", `errors-${dayStamp}.jsonl`);
240
+ const data = readFileSync(sinkPath, "utf8");
241
+ const lines = data
242
+ .split("\n")
243
+ .filter((l) => l.length > 0)
244
+ .filter((l) => {
245
+ try {
246
+ const obj = JSON.parse(l);
247
+ return obj.sessionId === sessionId && obj.level !== "debug";
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ })
253
+ .map((l) => scrubSecrets(l))
254
+ .join("\n");
255
+ return lines.length > maxBytes ? lines.slice(lines.length - maxBytes) : lines;
256
+ }
257
+ catch {
258
+ return "";
259
+ }
260
+ }
261
+ function buildFeedbackPayload(opts) {
262
+ const transcript = readTranscript(opts.sessionFile);
263
+ const trail = readSessionTrail(opts.sessionId, opts.stateDir);
264
+ return {
265
+ client: "cli",
266
+ clientVersion: opts.env.YAGNI_CODE_VERSION?.trim() || opts.clientVersion || "unknown",
267
+ platform: `${process.platform} ${process.arch}`,
268
+ description: scrubSecrets(opts.description).slice(0, MAX_DESCRIPTION),
269
+ sessionId: opts.sessionId,
270
+ ...(transcript ? { transcriptJsonl: scrubSecrets(transcript) } : {}),
271
+ ...(trail ? { errorTrailJsonl: trail } : {}),
272
+ };
273
+ }
274
+ async function submitFeedback(payload, baseUrl, token, fetchImpl) {
275
+ try {
276
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/api/yagni-code/feedback`, {
277
+ method: "POST",
278
+ headers: {
279
+ "content-type": "application/json",
280
+ authorization: `Bearer ${token}`,
281
+ },
282
+ body: JSON.stringify(payload),
283
+ signal: AbortSignal.timeout(30_000),
284
+ });
285
+ if (res.ok) {
286
+ const body = (await res.json().catch(() => ({})));
287
+ return { ok: true, id: body.id };
288
+ }
289
+ return { ok: false, error: `Server returned ${res.status}` };
290
+ }
291
+ catch (err) {
292
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
293
+ }
294
+ }
295
+ /**
296
+ * Create a readline-like interface that survives piped stdin (where the
297
+ * real readline's `question` hangs after EOF). For TTY stdin it delegates to
298
+ * the real `readline/promises`; for piped stdin it reads all lines upfront
299
+ * and serves them one-by-one.
300
+ */
301
+ function makeReadline() {
302
+ if (isatty(0)) {
303
+ const rl = createInterface({ input, output });
304
+ return {
305
+ question: (q) => rl.question(q),
306
+ close: () => rl.close(),
307
+ };
308
+ }
309
+ // Piped stdin: read all available data and serve lines FIFO.
310
+ const lines = [];
311
+ try {
312
+ const data = readFileSync(0, "utf8"); // fd 0 = stdin
313
+ for (const line of data.split("\n")) {
314
+ if (line.length > 0)
315
+ lines.push(line);
316
+ }
317
+ }
318
+ catch {
319
+ // stdin not readable as a file — fall back to empty
320
+ }
321
+ let idx = 0;
322
+ return {
323
+ question: async (q) => {
324
+ process.stdout.write(q);
325
+ const answer = lines[idx++] ?? "";
326
+ // Echo a newline so subsequent prompts start on a new line (in TTY
327
+ // mode, readline echoes the user's Enter; piped mode does not).
328
+ process.stdout.write("\n");
329
+ return answer;
330
+ },
331
+ close: () => { },
332
+ };
333
+ }
334
+ // --- display ---
335
+ function formatDuration(ms) {
336
+ if (ms === null)
337
+ return "";
338
+ if (ms < 1000)
339
+ return "<1s";
340
+ const s = Math.floor(ms / 1000);
341
+ if (s < 60)
342
+ return `${s}s`;
343
+ const m = Math.floor(s / 60);
344
+ return `${m}m`;
345
+ }
346
+ function formatStartTime(iso) {
347
+ try {
348
+ const d = new Date(iso);
349
+ return d.toLocaleString("en-US", {
350
+ month: "short",
351
+ day: "2-digit",
352
+ hour: "2-digit",
353
+ minute: "2-digit",
354
+ hour12: false,
355
+ });
356
+ }
357
+ catch {
358
+ return iso;
359
+ }
360
+ }
361
+ // Minimal ANSI escape sequences for the session list.
362
+ const RESET = "\x1b[0m";
363
+ const BOLD = "\x1b[1m";
364
+ const DIM = "\x1b[2m";
365
+ const GREEN = "\x1b[32m";
366
+ const CYAN = "\x1b[36m";
367
+ function renderSessionList(sessions) {
368
+ const sep = DIM + " " + "─".repeat(70) + RESET;
369
+ const lines = [BOLD + " Recent sessions" + RESET, sep];
370
+ const countWidth = String(sessions.length).length;
371
+ for (let i = 0; i < sessions.length; i++) {
372
+ const s = sessions[i];
373
+ const num = ` ${i + 1}.`.padEnd(countWidth + 2);
374
+ const idx = GREEN + num + RESET + " ";
375
+ const time = DIM + formatStartTime(s.startTime).padEnd(17) + RESET + " ";
376
+ const dur = DIM + formatDuration(s.durationMs).padEnd(5) + RESET + " ";
377
+ const msg = s.firstMessage || "(no text)";
378
+ lines.push(` ${idx}${time}${dur}${msg}`);
379
+ }
380
+ lines.push("");
381
+ return lines.join("\n");
382
+ }
383
+ // --- orchestration ---
384
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
385
+ export async function feedbackCommand(args, deps = {}, cliVersion) {
386
+ const writeOut = deps.writeOut ?? ((line) => void process.stdout.write(`${line}\n`));
387
+ const writeErr = deps.writeErr ?? ((line) => void process.stderr.write(`${line}\n`));
388
+ const cwd = deps.cwd ?? process.cwd();
389
+ const env = deps.env ?? process.env;
390
+ // Load credentials (same pattern as goCommand).
391
+ const profile = await (deps.loadCredentials ?? (async () => {
392
+ const active = await readActiveProfile(env);
393
+ const creds = credentialsFromProfile(active);
394
+ return {
395
+ name: active.name,
396
+ baseUrl: active.baseUrl,
397
+ ...(creds?.token ? { token: creds.token } : {}),
398
+ };
399
+ }))();
400
+ if (!profile.token) {
401
+ writeErr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.`);
402
+ return 1;
403
+ }
404
+ const agentDirPath = deps.agentDirPath ?? agentDir(profile.name);
405
+ const stateDir = deps.stateDir ?? credentialsDir();
406
+ // Parse args: is the first positional a session UUID?
407
+ const sessionIdArg = args.find((a) => !a.startsWith("-") && UUID_RE.test(a));
408
+ let sessionFile;
409
+ let sessionId;
410
+ // One readline interface for all prompts. For piped stdin, makeReadline
411
+ // reads all lines upfront to avoid the readline/promises hang on EOF.
412
+ const rl = deps.readline ?? makeReadline();
413
+ const closeRl = () => {
414
+ if (deps.readline)
415
+ deps.readline.close();
416
+ else
417
+ rl.close();
418
+ };
419
+ try {
420
+ if (sessionIdArg) {
421
+ // Case B: session ID provided directly.
422
+ const found = findSessionById(agentDirPath, sessionIdArg);
423
+ if (!found) {
424
+ writeErr(`Session ${sessionIdArg} not found.`);
425
+ return 1;
426
+ }
427
+ sessionFile = found.filePath;
428
+ sessionId = sessionIdArg;
429
+ }
430
+ else {
431
+ // Case A: list sessions for the current cwd.
432
+ const sessions = listRecentSessions(agentDirPath, cwd);
433
+ if (sessions.length === 0) {
434
+ writeOut("No recent sessions found for this directory.");
435
+ return 1;
436
+ }
437
+ writeOut(renderSessionList(sessions));
438
+ const answer = (await rl.question(CYAN + "Select a session (1-" + sessions.length + "): " + RESET)).trim();
439
+ const idx = parseInt(answer, 10) - 1;
440
+ if (isNaN(idx) || idx < 0 || idx >= sessions.length) {
441
+ writeOut("Feedback cancelled.");
442
+ return 0;
443
+ }
444
+ sessionFile = sessions[idx].filePath;
445
+ sessionId = sessions[idx].id;
446
+ }
447
+ // Prompt for description.
448
+ const descAnswer = await rl.question(DIM + "Describe the issue (one or two lines):" + RESET + "\n> ");
449
+ const description = descAnswer.trim();
450
+ if (!description) {
451
+ writeOut("Feedback cancelled.");
452
+ return 0;
453
+ }
454
+ // Confirm.
455
+ const transcriptBytes = sessionFile
456
+ ? Buffer.byteLength(readTranscript(sessionFile), "utf8")
457
+ : 0;
458
+ const confirmLines = [
459
+ DIM + "Sending:" + RESET,
460
+ ` - Your feedback description`,
461
+ ` - This session's transcript${transcriptBytes > 0 ? "" : " (could not be read)"}`,
462
+ ` - Recent error trail for this session`,
463
+ "",
464
+ CYAN + "Send this report? [Y/n]" + RESET,
465
+ ];
466
+ const confirmed = (await rl.question(confirmLines.join("\n") + "\n")).trim();
467
+ if (/^n/i.test(confirmed)) {
468
+ writeOut("Feedback cancelled.");
469
+ return 0;
470
+ }
471
+ // Build payload + submit.
472
+ const payload = buildFeedbackPayload({
473
+ sessionId,
474
+ sessionFile,
475
+ description,
476
+ stateDir,
477
+ env,
478
+ clientVersion: cliVersion ?? "unknown",
479
+ });
480
+ const fetchImpl = deps.fetchImpl ?? fetch;
481
+ const result = await submitFeedback(payload, profile.baseUrl, profile.token, fetchImpl);
482
+ if (result.ok) {
483
+ writeOut(GREEN + "Feedback submitted. Thank you!" + RESET);
484
+ return 0;
485
+ }
486
+ else {
487
+ writeErr(`Could not submit feedback. ${result.error ?? "Please try again."}`);
488
+ return 1;
489
+ }
490
+ }
491
+ catch {
492
+ // readline closed or EOF — treat as cancel.
493
+ writeOut("Feedback cancelled.");
494
+ return 0;
495
+ }
496
+ finally {
497
+ closeRl();
498
+ }
499
+ }
500
+ //# sourceMappingURL=feedback.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.0-staging.1177.1",
3
+ "version": "1.0.0-staging.1178.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": "92209df02c3660ed50b91a72f3197d47c2003e1d"
43
+ "yagniSourceSha": "d40167f97866479e71acceaf2132ed58f00cc0cc"
44
44
  }