@yagni-app/code-staging 1.0.0-staging.1177.1 → 1.0.0-staging.1179.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
@@ -16,7 +16,7 @@ import { spawn } from "node:child_process";
16
16
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { createInterface } from "node:readline/promises";
19
- import { fileURLToPath } from "node:url";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
20
  import { PI_CONFIG_NAME } from "./branding.js";
21
21
  import { claudeCompatArgs } from "./claudeCompat.js";
22
22
  import { agentDir, credentialsDir, piPackageDir } from "./credentials.js";
@@ -28,14 +28,16 @@ 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";
34
35
  import { maybeRefreshAtLaunch } from "./refresh.js";
35
36
  import { exitCodeFor, installSignalForwarding } from "./signalForward.js";
36
37
  import { PAD_X } from "./padding.js";
38
+ import { parseWorktreeFlag, validateWorktreeLaunchArgs } from "./worktreeArgs.js";
37
39
  import { ensureShadowPiPackage } from "./piPackage.js";
38
- import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
40
+ import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveSessionWorktreePath } from "./paths.js";
39
41
  import { credentialsFromProfile, getActiveProfileName, listProfiles, migrateLegacyCredentials, persistProfileTokenRotation, profilePath, readActiveProfile, useProfile, } from "./profiles.js";
40
42
  // Present as "yagni" in process listings, not "node".
41
43
  process.title = DISTRIBUTION.commandName;
@@ -160,6 +162,14 @@ export function seedHideThinkingBlock(piAgentDir) {
160
162
  return seedSetting(piAgentDir, "hideThinkingBlock", true);
161
163
  }
162
164
  async function runDefault(passthroughArgs) {
165
+ // `-w / --worktree [name]` is its own launch path: create/resume a worktree
166
+ // and enter a session there. Gate strictly on `requested` (not `name`) so a
167
+ // bare `-w` (random slug) is honored too. Returning early keeps the standard
168
+ // path below physically unreachable by any `-w` bug.
169
+ const worktree = parseWorktreeFlag(passthroughArgs);
170
+ if (worktree.requested) {
171
+ return runWorktreeLaunch(worktree.remainingArgs, worktree.name);
172
+ }
163
173
  // Parse --output-format out of argv before passing to pi (pi doesn't know
164
174
  // about it). The format determines how we handle pi's stdout: text = inherit,
165
175
  // stream-json = inherit with --mode json, json = pipe + post-process.
@@ -265,10 +275,22 @@ async function runDefault(passthroughArgs) {
265
275
  process.stderr.write(`${warning}\n`);
266
276
  }
267
277
  const { env, argv } = plan;
278
+ return spawnPiAndAwait({
279
+ argv,
280
+ env,
281
+ remainingArgs,
282
+ outputFormat,
283
+ });
284
+ }
285
+ /**
286
+ * Spawn pi and await its exit, mapping to the launcher's exit code. Extracted
287
+ * so `runDefault` (cwd = current) and `runWorktreeLaunch` (cwd = worktree) share
288
+ * the exact same json/stream-json post-processing, signal forwarding, and exit
289
+ * code mapping — the two paths can never drift on the output contract.
290
+ */
291
+ async function spawnPiAndAwait(opts) {
292
+ const { argv, env, remainingArgs, outputFormat, cwd } = opts;
268
293
  // For json/stream-json output, inject --mode json so pi emits NDJSON events.
269
- // Don't override if the user already chose --mode (mirrors userChoseProvider/
270
- // userChoseModel in buildLaunch). Appended at the end — pi's flag parser
271
- // handles --mode anywhere in argv.
272
294
  const userChoseMode = remainingArgs.some((a) => a === "--mode" || a.startsWith("--mode="));
273
295
  const childArgv = outputFormat === "text" || userChoseMode
274
296
  ? argv
@@ -284,6 +306,7 @@ async function runDefault(passthroughArgs) {
284
306
  const child = spawn(process.execPath, [piCli, ...childArgv], {
285
307
  stdio: stdio,
286
308
  env,
309
+ ...(cwd ? { cwd } : {}),
287
310
  });
288
311
  // Collect pi's stdout when piping for --output-format json.
289
312
  let stdoutChunks = "";
@@ -322,6 +345,131 @@ async function runDefault(passthroughArgs) {
322
345
  });
323
346
  });
324
347
  }
348
+ async function defaultLoadSessionWorktree() {
349
+ const mod = (await import(pathToFileURL(resolveSessionWorktreePath()).href));
350
+ if (typeof mod?.createOrResume !== "function") {
351
+ throw new Error("The bundled extension is missing its session-worktree entry point (is the CLI up to date?).");
352
+ }
353
+ return mod;
354
+ }
355
+ /**
356
+ * `yagni -w [name]` — create/resume a worktree and enter a session there.
357
+ *
358
+ * Delegates all git behavior to the extension's `sessionWorktree` entry; this
359
+ * launcher path only resolves credentials, builds the plan, and spawns pi with
360
+ * `cwd` = the worktree. The worktree is DURABLE — nothing is ever removed.
361
+ */
362
+ async function runWorktreeLaunch(passthroughArgs, worktreeName, loadSessionWorktree = defaultLoadSessionWorktree) {
363
+ // Validate the name + argv before any side effect (slug guard + `-c`
364
+ // disallow). The extension re-validates the mapped slug for PR refs.
365
+ const launchError = validateWorktreeLaunchArgs(worktreeName, passthroughArgs);
366
+ if (launchError !== undefined) {
367
+ process.stderr.write(`${launchError}\n`);
368
+ return 1;
369
+ }
370
+ const { format: outputFormat, remainingArgs } = parseOutputFormat(passthroughArgs);
371
+ // Load the session-worktree entry first, so a stale bundled extension fails
372
+ // honestly before we mutate anything.
373
+ let sessionWorktree;
374
+ try {
375
+ sessionWorktree = await loadSessionWorktree();
376
+ }
377
+ catch (err) {
378
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
379
+ return 1;
380
+ }
381
+ // Create/resume resolves the main repo root + branch + dir, and performs
382
+ // `git worktree add` / resume. Never throws on a normal path; catch-all maps
383
+ // to a clean stderr + exit 1.
384
+ let result;
385
+ try {
386
+ result = await sessionWorktree.createOrResume(worktreeName, {
387
+ repoCwd: process.cwd(),
388
+ });
389
+ }
390
+ catch (err) {
391
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
392
+ return 1;
393
+ }
394
+ // Reuse the standard credential + env + plan flow (same token refresh, shadow
395
+ // package, compat args, preflight). The worktree becomes the project cwd, so
396
+ // compat assets resolve from there.
397
+ await maybeNudgeAndRefresh({ current: cliVersion() });
398
+ const profile = await readActiveProfile();
399
+ let creds = credentialsFromProfile(profile);
400
+ if (!creds?.token) {
401
+ process.stderr.write(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.\n`);
402
+ return 1;
403
+ }
404
+ const refresh = await maybeRefreshAtLaunch(creds, {
405
+ persist: (c) => persistProfileTokenRotation(profile.name, c),
406
+ });
407
+ for (const warning of refresh.warnings) {
408
+ process.stderr.write(`${warning}\n`);
409
+ }
410
+ creds = refresh.creds;
411
+ const piAgentDir = agentDir(profile.name);
412
+ mkdirSync(piAgentDir, { recursive: true, mode: 0o700 });
413
+ seedEditorPadding(piAgentDir);
414
+ seedCollapseChangelog(piAgentDir);
415
+ seedHideThinkingBlock(piAgentDir);
416
+ let shadowPiDir;
417
+ try {
418
+ shadowPiDir = ensureShadowPiPackage({
419
+ realPiDir: resolvePiPackageDir(),
420
+ shadowDir: piPackageDir(),
421
+ name: PI_CONFIG_NAME,
422
+ });
423
+ }
424
+ catch {
425
+ shadowPiDir = undefined;
426
+ }
427
+ let compat = { argv: [], env: {} };
428
+ try {
429
+ compat = await claudeCompatArgs({
430
+ cwd: result.worktreePath,
431
+ agentDir: piAgentDir,
432
+ confirm: confirmOnTty,
433
+ });
434
+ }
435
+ catch {
436
+ compat = { argv: [], env: {} };
437
+ }
438
+ let plan;
439
+ try {
440
+ plan = buildLaunch(creds, remainingArgs, {
441
+ extensionPath: resolveExtensionPath(),
442
+ agentDir: piAgentDir,
443
+ piPackageDir: shadowPiDir,
444
+ extraAgentArgs: compat.argv,
445
+ extraEnv: compat.env,
446
+ profilePath: profilePath(profile.name),
447
+ stateDir: credentialsDir(),
448
+ cliVersion: cliVersion(),
449
+ baseEnv: process.env,
450
+ });
451
+ }
452
+ catch (err) {
453
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
454
+ return 1;
455
+ }
456
+ for (const warning of plan.warnings) {
457
+ process.stderr.write(`${warning}\n`);
458
+ }
459
+ const exitCode = await spawnPiAndAwait({
460
+ argv: plan.argv,
461
+ env: plan.env,
462
+ remainingArgs,
463
+ outputFormat,
464
+ cwd: result.worktreePath,
465
+ });
466
+ // Durable by default: nothing is removed. Tell the user where their work
467
+ // lives (stderr only — never stdout, to keep json clean).
468
+ process.stderr.write(`[yagni] worktree ${result.existed ? "resumed" : "created"}: ${result.worktreePath}\n` +
469
+ `[yagni] branch: ${result.branch}\n` +
470
+ `[yagni] resume: cd ${result.worktreePath} && yagni\n`);
471
+ return exitCode;
472
+ }
325
473
  export const HELP_TEXT = [
326
474
  "YAGNI Code — a business-context-grounded terminal coding agent.",
327
475
  "",
@@ -329,12 +477,16 @@ export const HELP_TEXT = [
329
477
  " yagni [args…] Launch the agent in the current repo.",
330
478
  " yagni -c Continue the most recent session.",
331
479
  " yagni -r Browse and resume a previous session.",
480
+ " yagni -w [name] Create/resume a worktree and enter a session there.",
332
481
  ' yagni -p "prompt" Print one response and exit (reads piped stdin too).',
333
482
  ' yagni -p "prompt" Use --output-format json for a machine-readable',
334
483
  ' --output-format json result object with tools, cost, guardian reviews.',
335
484
  " yagni login Authorize the active environment (device-code flow).",
336
485
  " yagni logout Revoke and clear the active environment's token.",
337
486
  " yagni doctor Check that everything is ready (green/red checklist).",
487
+ " yagni feedback [sessionId] File a bug report from the shell. Lists recent",
488
+ " sessions to pick, or pass a session ID directly.",
489
+ " Attaches transcript + error trail, then submits.",
338
490
  " yagni go --headless Run the /go pipeline without a session, for scripts",
339
491
  " and CI: --ticket-file <path> [--plan-file <path>]",
340
492
  " [--memo-file <path>] [--run-id <id>] [--json].",
@@ -486,6 +638,9 @@ export async function main(argv) {
486
638
  if (command === "go") {
487
639
  return goCommand(rest, {}, cliVersion());
488
640
  }
641
+ if (command === "feedback") {
642
+ return feedbackCommand(rest, {}, cliVersion());
643
+ }
489
644
  if (command === "token") {
490
645
  return tokenCommand();
491
646
  }
@@ -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,64 @@
1
+ /**
2
+ * `-w / --worktree` session-worktree plumbing.
3
+ *
4
+ * The launcher (`yagni-code-cli`) reaches this module by file path (the same
5
+ * seam as `headlessGo.ts` → `runHeadlessGo`) so it can create/resume a named
6
+ * YAGNI worktree BEFORE spawning pi into it. The launcher owns the process
7
+ * lifecycle (spawn + `cwd` + exit summary); this module owns the git behavior.
8
+ *
9
+ * Design invariants (see the YAG-594 plan):
10
+ * - **Add-only creation.** Every git op is `worktree add`, `fetch`, `show-ref`,
11
+ * `symbolic-ref`, or `rev-parse`. Nothing deletes, force-resets, or
12
+ * `branch -D`s — the worktree is DURABLE by default and never auto-removed.
13
+ * - **Get-or-resume.** An existing worktree dir is resumed, never recreated.
14
+ * - **Lazy fetch.** Base `origin/<default>` is read from the local ref when
15
+ * present; `git fetch` only runs when that ref is absent, and always with
16
+ * credential prompts disabled.
17
+ * - **Validate before any side effect.** The slug is checked (again, defense
18
+ * in depth against the launcher) before the first git subprocess.
19
+ * - **Canonical root.** `-w` invoked from inside an existing worktree lands in
20
+ * the main repo, never nested.
21
+ *
22
+ * Convention reused from `/wt-new`: branch `agent/<slug>`, dir `.worktrees/<slug>`
23
+ * (both gitignored in-repo). PR refs (`#N`, GitHub PR URLs) map to `pr-<N>` and
24
+ * base on `FETCH_HEAD`.
25
+ */
26
+ export interface SessionWorktreeResult {
27
+ /** Absolute destination (under `<mainRepo>/.worktrees/<slug>`). */
28
+ worktreePath: string;
29
+ /** The `agent/<slug>` branch. */
30
+ branch: string;
31
+ /** True when the worktree already existed (resumed, not created). */
32
+ existed: boolean;
33
+ }
34
+ export type SessionGit = (argv: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<string>;
35
+ export interface CreateOrResumeDeps {
36
+ /** Repo the user ran `yagni -w` from (any path inside it works for git). */
37
+ repoCwd: string;
38
+ /** Injectable git seam (defaults to a real `git` exec). */
39
+ gitImpl?: SessionGit;
40
+ /** Injectable fs seam for existence checks (defaults to node:fs). */
41
+ pathExists?: (p: string) => boolean;
42
+ /** Injectable randomness (defaults to Math.random). */
43
+ random?: () => number;
44
+ }
45
+ /** Turn arbitrary name text into a git-ref-safe slug. Empty input → "worktree". */
46
+ export declare function slugify(name: string): string;
47
+ /**
48
+ * Validate a worktree slug before any side effect. Mirrors Claude's guard:
49
+ * length cap, per-segment allowlist, `.`/`..` rejection. Throws synchronously.
50
+ */
51
+ export declare function validateWorktreeSlug(slug: string): void;
52
+ /**
53
+ * Parse a PR reference: `#N` or a GitHub-style PR URL. Returns the number or null.
54
+ */
55
+ export declare function parsePRReference(input: string): number | null;
56
+ /**
57
+ * Create or resume the session worktree for `name`.
58
+ *
59
+ * Throws with a user-surfaced message on any failure; the caller (launcher)
60
+ * catches and prints it to stderr + the diagnostic sink. Never leaves a partial
61
+ * branch/worktree: validation happens first, and `git worktree add` is atomic.
62
+ */
63
+ export declare function createOrResume(name: string | undefined, deps: CreateOrResumeDeps): Promise<SessionWorktreeResult>;
64
+ //# sourceMappingURL=sessionWorktree.d.ts.map
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `-w / --worktree` session-worktree plumbing.
3
+ *
4
+ * The launcher (`yagni-code-cli`) reaches this module by file path (the same
5
+ * seam as `headlessGo.ts` → `runHeadlessGo`) so it can create/resume a named
6
+ * YAGNI worktree BEFORE spawning pi into it. The launcher owns the process
7
+ * lifecycle (spawn + `cwd` + exit summary); this module owns the git behavior.
8
+ *
9
+ * Design invariants (see the YAG-594 plan):
10
+ * - **Add-only creation.** Every git op is `worktree add`, `fetch`, `show-ref`,
11
+ * `symbolic-ref`, or `rev-parse`. Nothing deletes, force-resets, or
12
+ * `branch -D`s — the worktree is DURABLE by default and never auto-removed.
13
+ * - **Get-or-resume.** An existing worktree dir is resumed, never recreated.
14
+ * - **Lazy fetch.** Base `origin/<default>` is read from the local ref when
15
+ * present; `git fetch` only runs when that ref is absent, and always with
16
+ * credential prompts disabled.
17
+ * - **Validate before any side effect.** The slug is checked (again, defense
18
+ * in depth against the launcher) before the first git subprocess.
19
+ * - **Canonical root.** `-w` invoked from inside an existing worktree lands in
20
+ * the main repo, never nested.
21
+ *
22
+ * Convention reused from `/wt-new`: branch `agent/<slug>`, dir `.worktrees/<slug>`
23
+ * (both gitignored in-repo). PR refs (`#N`, GitHub PR URLs) map to `pr-<N>` and
24
+ * base on `FETCH_HEAD`.
25
+ */
26
+ import { execFile } from "node:child_process";
27
+ import { existsSync, mkdirSync } from "node:fs";
28
+ import { basename, dirname, isAbsolute, join } from "node:path";
29
+ import { bootstrapWorktree } from "./worktree.js";
30
+ /** Cap on the slug half (keeps refs & dirs readable). */
31
+ const SLUG_MAX = 40;
32
+ /** Maximum slug characters, mirrored from Claude's guard. */
33
+ const MAX_SLUG_LENGTH = 64;
34
+ /** Allowlist per `/`-separated segment (mirrors Claude's `validateWorktreeSlug`). */
35
+ const VALID_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
36
+ /** Env that prevents git/ssh from prompting for credentials (which would hang). */
37
+ const GIT_NO_PROMPT_ENV = {
38
+ GIT_TERMINAL_PROMPT: "0",
39
+ GIT_ASKPASS: "",
40
+ };
41
+ /** Turn arbitrary name text into a git-ref-safe slug. Empty input → "worktree". */
42
+ export function slugify(name) {
43
+ const slug = name
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9._-]+/g, "-")
46
+ .replace(/^-+|-+$/g, "")
47
+ .slice(0, SLUG_MAX)
48
+ .replace(/-+$/, "");
49
+ return slug || "worktree";
50
+ }
51
+ /**
52
+ * Validate a worktree slug before any side effect. Mirrors Claude's guard:
53
+ * length cap, per-segment allowlist, `.`/`..` rejection. Throws synchronously.
54
+ */
55
+ export function validateWorktreeSlug(slug) {
56
+ if (slug.length > MAX_SLUG_LENGTH) {
57
+ throw new Error(`Invalid worktree name: must be ${MAX_SLUG_LENGTH} characters or fewer (got ${slug.length})`);
58
+ }
59
+ for (const segment of slug.split("/")) {
60
+ if (segment === "." || segment === "..") {
61
+ throw new Error(`Invalid worktree name "${slug}": must not contain "." or ".." path segments`);
62
+ }
63
+ if (!VALID_SLUG_SEGMENT.test(segment)) {
64
+ throw new Error(`Invalid worktree name "${slug}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`);
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Parse a PR reference: `#N` or a GitHub-style PR URL. Returns the number or null.
70
+ */
71
+ export function parsePRReference(input) {
72
+ const urlMatch = input.match(/^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)\/?(?:[?#].*)?$/i);
73
+ if (urlMatch?.[1])
74
+ return parseInt(urlMatch[1], 10);
75
+ const hashMatch = input.match(/^#(\d+)$/);
76
+ if (hashMatch?.[1])
77
+ return parseInt(hashMatch[1], 10);
78
+ return null;
79
+ }
80
+ function randomSlug(random = Math.random) {
81
+ const adjectives = ["swift", "bright", "calm", "keen", "bold", "quiet", "warm", "true"];
82
+ const nouns = ["fox", "owl", "elm", "oak", "ray", "fern", "pine", "brook"];
83
+ const adj = adjectives[Math.floor(random() * adjectives.length)];
84
+ const noun = nouns[Math.floor(random() * nouns.length)];
85
+ const suffix = Math.floor(random() * 0x10000).toString(36).padStart(4, "0");
86
+ return `${adj}-${noun}-${suffix}`;
87
+ }
88
+ function defaultGit(argv, cwd, env) {
89
+ return new Promise((resolve, reject) => {
90
+ execFile("git", argv, { cwd, env, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
91
+ if (err) {
92
+ reject(new Error(stderr.toString().trim() || err.message));
93
+ return;
94
+ }
95
+ resolve(stdout.toString().trim());
96
+ });
97
+ });
98
+ }
99
+ /** The main repo root, resolved through an existing linked worktree via commondir. */
100
+ async function resolveMainRepo(gitImpl, repoCwd) {
101
+ let topLevel;
102
+ try {
103
+ topLevel = await gitImpl(["rev-parse", "--show-toplevel"], repoCwd);
104
+ }
105
+ catch (err) {
106
+ throw new Error(`Cannot create a worktree: not inside a git repository. ` +
107
+ `${err instanceof Error ? err.message : String(err)}`);
108
+ }
109
+ const common = await gitImpl(["rev-parse", "--git-common-dir"], repoCwd);
110
+ const abs = isAbsolute(common) ? common : join(topLevel, common);
111
+ // A linked worktree's commondir points at the shared `.git`; the main repo root
112
+ // is its parent. A main checkout resolves to its own toplevel.
113
+ return basename(abs) === ".git" ? dirname(abs) : topLevel;
114
+ }
115
+ /** Resolve the default branch: origin/HEAD symref, else main, else master. */
116
+ async function resolveDefaultBranch(gitImpl, repoCwd) {
117
+ try {
118
+ const symref = await gitImpl(["symbolic-ref", "refs/remotes/origin/HEAD"], repoCwd);
119
+ const name = symref.replace(/^refs\/remotes\//, "");
120
+ if (name)
121
+ return name;
122
+ }
123
+ catch {
124
+ /* no origin/HEAD symref */
125
+ }
126
+ for (const candidate of ["main", "master"]) {
127
+ try {
128
+ await gitImpl(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`], repoCwd);
129
+ return candidate;
130
+ }
131
+ catch {
132
+ /* keep looking */
133
+ }
134
+ }
135
+ return "main";
136
+ }
137
+ /** True when a local branch `refs/heads/<branch>` exists. */
138
+ async function branchExists(gitImpl, repoCwd, branch) {
139
+ try {
140
+ await gitImpl(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoCwd);
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * Create or resume the session worktree for `name`.
149
+ *
150
+ * Throws with a user-surfaced message on any failure; the caller (launcher)
151
+ * catches and prints it to stderr + the diagnostic sink. Never leaves a partial
152
+ * branch/worktree: validation happens first, and `git worktree add` is atomic.
153
+ */
154
+ export async function createOrResume(name, deps) {
155
+ const gitImpl = deps.gitImpl ?? defaultGit;
156
+ const pathExists = deps.pathExists ?? ((p) => existsSync(p));
157
+ const random = deps.random ?? Math.random;
158
+ const repoCwd = deps.repoCwd;
159
+ const prNumber = name !== undefined ? parsePRReference(name) : null;
160
+ const slug = prNumber !== null
161
+ ? `pr-${prNumber}`
162
+ : slugify(name ?? randomSlug(random));
163
+ validateWorktreeSlug(slug);
164
+ const repoRoot = await resolveMainRepo(gitImpl, repoCwd);
165
+ const branch = `agent/${slug}`;
166
+ const worktreePath = join(repoRoot, ".worktrees", slug);
167
+ // Get-or-resume: an existing dir is resumed, never recreated/fetched/overwritten.
168
+ if (pathExists(worktreePath)) {
169
+ return { worktreePath, branch, existed: true };
170
+ }
171
+ // Collision: the branch already exists locally (dirty leftover from a prior
172
+ // crash) — refuse rather than force-reset.
173
+ if (await branchExists(gitImpl, repoCwd, branch)) {
174
+ throw new Error(`Branch ${branch} already exists. Pick a different name with \`-w <other>\`, or clean it up first.`);
175
+ }
176
+ mkdirSync(dirname(worktreePath), { recursive: true, mode: 0o700 });
177
+ // Resolve base. PR path fetches the PR head into FETCH_HEAD; default path uses
178
+ // the local origin/<default> ref when present, else fetches, else falls back
179
+ // to HEAD (a repo with no remote or no commits still works).
180
+ let base;
181
+ const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV };
182
+ if (prNumber !== null) {
183
+ try {
184
+ await gitImpl(["fetch", "origin", `pull/${prNumber}/head`], repoCwd, fetchEnv);
185
+ }
186
+ catch (err) {
187
+ throw new Error(`Failed to fetch PR #${prNumber}: ${err instanceof Error ? err.message : String(err)}. ` +
188
+ `The PR may not exist or this repo may not have a remote named "origin".`);
189
+ }
190
+ base = "FETCH_HEAD";
191
+ }
192
+ else {
193
+ const defaultBranch = await resolveDefaultBranch(gitImpl, repoCwd);
194
+ let originRef = null;
195
+ try {
196
+ await gitImpl(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${defaultBranch}`], repoCwd);
197
+ originRef = `origin/${defaultBranch}`;
198
+ }
199
+ catch {
200
+ try {
201
+ await gitImpl(["fetch", "origin", defaultBranch], repoCwd, fetchEnv);
202
+ originRef = `origin/${defaultBranch}`;
203
+ }
204
+ catch {
205
+ originRef = "HEAD"; // no remote / no commits: degrade to local HEAD
206
+ }
207
+ }
208
+ base = originRef;
209
+ }
210
+ try {
211
+ await gitImpl(["worktree", "add", "-b", branch, worktreePath, base], repoCwd);
212
+ }
213
+ catch (err) {
214
+ throw new Error(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`);
215
+ }
216
+ // Best-effort: install deps + env so a fresh worktree can actually run.
217
+ try {
218
+ await bootstrapWorktree(worktreePath);
219
+ }
220
+ catch {
221
+ /* bootstrap is best-effort; a failed install must not fail the launch */
222
+ }
223
+ return { worktreePath, branch, existed: false };
224
+ }
225
+ //# sourceMappingURL=sessionWorktree.js.map
@@ -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/dist/paths.d.ts CHANGED
@@ -29,6 +29,16 @@ export declare function resolveExtensionPath(): string;
29
29
  * it pulls in the pipeline alone, with none of pi's TUI surface.
30
30
  */
31
31
  export declare function resolveHeadlessGoPath(): string;
32
+ /**
33
+ * Absolute path to the extension's session-worktree entry
34
+ * (`pipeline/sessionWorktree.js`), the module `yagni -w` imports.
35
+ *
36
+ * Mirrors `resolveHeadlessGoPath`: derived from the extension entry so the
37
+ * bundled-vs-workspace fallback is resolved once. Only this one module is
38
+ * loaded (not the whole extension): it pulls in the worktree plumbing alone,
39
+ * with none of pi's TUI surface.
40
+ */
41
+ export declare function resolveSessionWorktreePath(): string;
32
42
  /**
33
43
  * Absolute path to pi's package root — the dir whose package.json names the
34
44
  * package. The shadow package dir is built from this (we read its package.json
package/dist/paths.js CHANGED
@@ -43,6 +43,19 @@ export function resolveHeadlessGoPath() {
43
43
  const entry = resolveExtensionPath();
44
44
  return join(dirname(entry), "pipeline", "headlessGo.js");
45
45
  }
46
+ /**
47
+ * Absolute path to the extension's session-worktree entry
48
+ * (`pipeline/sessionWorktree.js`), the module `yagni -w` imports.
49
+ *
50
+ * Mirrors `resolveHeadlessGoPath`: derived from the extension entry so the
51
+ * bundled-vs-workspace fallback is resolved once. Only this one module is
52
+ * loaded (not the whole extension): it pulls in the worktree plumbing alone,
53
+ * with none of pi's TUI surface.
54
+ */
55
+ export function resolveSessionWorktreePath() {
56
+ const entry = resolveExtensionPath();
57
+ return join(dirname(entry), "pipeline", "sessionWorktree.js");
58
+ }
46
59
  /**
47
60
  * Absolute path to pi's package root — the dir whose package.json names the
48
61
  * package. The shadow package dir is built from this (we read its package.json
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure parsing + validation for `-w / --worktree [name]` (YAG-594).
3
+ *
4
+ * No side effects, no I/O: `parseWorktreeFlag` extracts the flag and its
5
+ * optional value from argv (leaving everything else for pi unchanged), and
6
+ * `validateWorktreeSlug` mirrors Claude's allowlist so a hostile name is
7
+ * rejected before the launcher touches git in any way.
8
+ */
9
+ export interface WorktreeFlag {
10
+ /** Distinguishes "-w with no name" from "-w absent" so bare `-w` is honored. */
11
+ requested: boolean;
12
+ /** The name value, when supplied (`-w foo` / `--worktree=foo`). */
13
+ name?: string;
14
+ /** Everything that isn't the worktree flag/name, passed through to pi. */
15
+ remainingArgs: string[];
16
+ }
17
+ /**
18
+ * Extract `-w`/`--worktree [name]` from argv without disturbing the rest. Runs
19
+ * BEFORE `parseOutputFormat` so the two argv-mutators never double-handle a
20
+ * flag: this strips only the worktree flag, and the caller feeds `remainingArgs`
21
+ * to `parseOutputFormat` next.
22
+ */
23
+ export declare function parseWorktreeFlag(argv: string[]): WorktreeFlag;
24
+ /**
25
+ * Parse a PR reference: `#N` or a GitHub-style PR URL, mirroring the extension's
26
+ * `parsePRReference` (the two packages stay independent; this is the launcher's
27
+ * copy so `-w` can recognize a PR ref before slug-validating the raw name).
28
+ */
29
+ export declare function parsePRReference(input: string): number | null;
30
+ /**
31
+ * Validate a worktree slug before any side effect. Mirrors Claude's guard:
32
+ * length cap, per-segment allowlist, `.`/`..` rejection. Throws synchronously
33
+ * with a clear message (surfaced by the caller).
34
+ */
35
+ export declare function validateWorktreeSlug(slug: string): void;
36
+ /**
37
+ * Validate the `-w` name + argv before any side effect. Returns a user-facing
38
+ * error message when the launch should be refused, or `undefined` to proceed.
39
+ * PR refs (`#N` / URL) skip slug validation here — they map to `pr-<N>` in the
40
+ * extension, whose own validation covers the mapped slug.
41
+ */
42
+ export declare function validateWorktreeLaunchArgs(name: string | undefined, argv: string[]): string | undefined;
43
+ //# sourceMappingURL=worktreeArgs.d.ts.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure parsing + validation for `-w / --worktree [name]` (YAG-594).
3
+ *
4
+ * No side effects, no I/O: `parseWorktreeFlag` extracts the flag and its
5
+ * optional value from argv (leaving everything else for pi unchanged), and
6
+ * `validateWorktreeSlug` mirrors Claude's allowlist so a hostile name is
7
+ * rejected before the launcher touches git in any way.
8
+ */
9
+ /** Maximum slug characters (mirrors Claude's guard). */
10
+ const MAX_SLUG_LENGTH = 64;
11
+ /** Allowlist per `/`-separated segment. */
12
+ const VALID_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
13
+ /**
14
+ * Extract `-w`/`--worktree [name]` from argv without disturbing the rest. Runs
15
+ * BEFORE `parseOutputFormat` so the two argv-mutators never double-handle a
16
+ * flag: this strips only the worktree flag, and the caller feeds `remainingArgs`
17
+ * to `parseOutputFormat` next.
18
+ */
19
+ export function parseWorktreeFlag(argv) {
20
+ const remainingArgs = [];
21
+ let requested = false;
22
+ let name;
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ if (arg === "-w" || arg === "--worktree") {
26
+ requested = true;
27
+ // Consume the next token as the name only if it isn't another flag.
28
+ const next = argv[i + 1];
29
+ if (next !== undefined && !next.startsWith("-")) {
30
+ name = next;
31
+ i++;
32
+ }
33
+ continue;
34
+ }
35
+ if (arg.startsWith("--worktree=")) {
36
+ requested = true;
37
+ name = arg.slice("--worktree=".length);
38
+ continue;
39
+ }
40
+ remainingArgs.push(arg);
41
+ }
42
+ return { requested, name, remainingArgs };
43
+ }
44
+ /**
45
+ * Parse a PR reference: `#N` or a GitHub-style PR URL, mirroring the extension's
46
+ * `parsePRReference` (the two packages stay independent; this is the launcher's
47
+ * copy so `-w` can recognize a PR ref before slug-validating the raw name).
48
+ */
49
+ export function parsePRReference(input) {
50
+ const urlMatch = input.match(/^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)\/?(?:[?#].*)?$/i);
51
+ if (urlMatch?.[1])
52
+ return parseInt(urlMatch[1], 10);
53
+ const hashMatch = input.match(/^#(\d+)$/);
54
+ if (hashMatch?.[1])
55
+ return parseInt(hashMatch[1], 10);
56
+ return null;
57
+ }
58
+ /**
59
+ * Validate a worktree slug before any side effect. Mirrors Claude's guard:
60
+ * length cap, per-segment allowlist, `.`/`..` rejection. Throws synchronously
61
+ * with a clear message (surfaced by the caller).
62
+ */
63
+ export function validateWorktreeSlug(slug) {
64
+ if (slug.length > MAX_SLUG_LENGTH) {
65
+ throw new Error(`Invalid worktree name: must be ${MAX_SLUG_LENGTH} characters or fewer (got ${slug.length})`);
66
+ }
67
+ for (const segment of slug.split("/")) {
68
+ if (segment === "." || segment === "..") {
69
+ throw new Error(`Invalid worktree name "${slug}": must not contain "." or ".." path segments`);
70
+ }
71
+ if (!VALID_SLUG_SEGMENT.test(segment)) {
72
+ throw new Error(`Invalid worktree name "${slug}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`);
73
+ }
74
+ }
75
+ }
76
+ /**
77
+ * Validate the `-w` name + argv before any side effect. Returns a user-facing
78
+ * error message when the launch should be refused, or `undefined` to proceed.
79
+ * PR refs (`#N` / URL) skip slug validation here — they map to `pr-<N>` in the
80
+ * extension, whose own validation covers the mapped slug.
81
+ */
82
+ export function validateWorktreeLaunchArgs(name, argv) {
83
+ if (name !== undefined && parsePRReference(name) === null) {
84
+ try {
85
+ validateWorktreeSlug(name);
86
+ }
87
+ catch (err) {
88
+ return err instanceof Error ? err.message : String(err);
89
+ }
90
+ }
91
+ if (argv.includes("-c") || argv.includes("--continue")) {
92
+ return "`-c`/`--continue` is not supported with `-w` yet. Use `-w <name>` then `--session <id>` (or `-r`) to resume.";
93
+ }
94
+ return undefined;
95
+ }
96
+ //# sourceMappingURL=worktreeArgs.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.1179.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": "0a0e2cda58c070bc25dbc00fe76d89269fb43c6f"
44
44
  }