projectstore-claude 0.29.3 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +13 -12
  2. package/bin/projectstore-claude.mjs +2 -2
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +153 -77
  6. package/node_modules/projectstore/commands/bind.md +6 -17
  7. package/node_modules/projectstore/commands/doctor.md +12 -7
  8. package/node_modules/projectstore/commands/statusline.md +1 -1
  9. package/node_modules/projectstore/commands/story.md +15 -2
  10. package/node_modules/projectstore/docs/extending.md +11 -6
  11. package/node_modules/projectstore/docs/getting-started.md +21 -1
  12. package/node_modules/projectstore/docs/harnesses.md +13 -10
  13. package/node_modules/projectstore/docs/how-it-works.md +6 -4
  14. package/node_modules/projectstore/harnesses/claude-code.json +16 -15
  15. package/node_modules/projectstore/harnesses/codex.json +12 -13
  16. package/node_modules/projectstore/hooks/band.tsx +48 -20
  17. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  18. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  19. package/node_modules/projectstore/hooks/session-start.mjs +50 -18
  20. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  21. package/node_modules/projectstore/package.json +11 -2
  22. package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
  23. package/node_modules/projectstore/scripts/band.mjs +72 -21
  24. package/node_modules/projectstore/scripts/binding.mjs +616 -85
  25. package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
  26. package/node_modules/projectstore/scripts/cli.mjs +1015 -185
  27. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  28. package/node_modules/projectstore/scripts/convergence.mjs +253 -83
  29. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  30. package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
  31. package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
  32. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  33. package/node_modules/projectstore/scripts/fetch-shell.mjs +188 -39
  34. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  35. package/node_modules/projectstore/scripts/harness.mjs +123 -48
  36. package/node_modules/projectstore/scripts/install-harness.mjs +3532 -618
  37. package/node_modules/projectstore/scripts/kanban.mjs +2 -1
  38. package/node_modules/projectstore/scripts/lib.mjs +694 -159
  39. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  40. package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
  41. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  42. package/node_modules/projectstore/scripts/query.mjs +378 -80
  43. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  44. package/node_modules/projectstore/scripts/run-host.mjs +62 -17
  45. package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
  46. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  47. package/node_modules/projectstore/scripts/stages.mjs +5 -1
  48. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  49. package/node_modules/projectstore/scripts/statusline.mjs +32 -14
  50. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  51. package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
  52. package/node_modules/projectstore/scripts/term.mjs +382 -97
  53. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  54. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  55. package/node_modules/projectstore/scripts/version-guard.mjs +74 -27
  56. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  57. package/package.json +3 -3
@@ -31,9 +31,9 @@
31
31
  // railAvailable(stdin, caps) → true when the question rail may be drawn
32
32
  // openRail(opts) → { select, multi, confirm, path, answered, close, cancelled }
33
33
  // CANCELLED, BACK → what a rail question resolves to on Ctrl+C or end of input, and on Esc
34
+ // drainInput(stdin, opts) → discards what was typed ahead, before a line-mode question
34
35
 
35
- import { createInterface } from "node:readline/promises";
36
- import { emitKeypressEvents } from "node:readline";
36
+ import { createInterface, emitKeypressEvents } from "node:readline";
37
37
 
38
38
  // FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
39
39
  // "2", "3" or "true" turn colour on even without a terminal, anything else
@@ -50,7 +50,10 @@ export function caps(stream = process.stdout, env = process.env) {
50
50
  const tty = Boolean(stream && stream.isTTY);
51
51
  const dumb = env.TERM === "dumb";
52
52
  const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
53
- const color = env.FORCE_COLOR !== undefined ? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR)) : tty && !dumb && !off;
53
+ const color =
54
+ env.FORCE_COLOR !== undefined
55
+ ? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR))
56
+ : tty && !dumb && !off;
54
57
  const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
55
58
  const ascii = dumb || (env.PROJECTSTORE_ASCII !== undefined && env.PROJECTSTORE_ASCII !== "");
56
59
  // `width` lays out prose (never below 40); `columns` is the terminal's
@@ -61,8 +64,15 @@ export function caps(stream = process.stdout, env = process.env) {
61
64
  // The sixteen ANSI colours, bold and dim — never 256-colour or truecolor, so
62
65
  // the reader's terminal theme decides the shades, light or dark.
63
66
  const SGR = {
64
- bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39],
65
- blue: [34, 39], magenta: [35, 39], cyan: [36, 39], gray: [90, 39],
67
+ bold: [1, 22],
68
+ dim: [2, 22],
69
+ red: [31, 39],
70
+ green: [32, 39],
71
+ yellow: [33, 39],
72
+ blue: [34, 39],
73
+ magenta: [35, 39],
74
+ cyan: [36, 39],
75
+ gray: [90, 39],
66
76
  };
67
77
 
68
78
  // The colour roles (presentation spec contract 3), each one style above.
@@ -74,7 +84,14 @@ const SGR = {
74
84
  // error ✕, a failed step, an issue count
75
85
  // explanation notes, reasons, consent lines, the rail, the project path
76
86
  // emphasis headings, a question's title, a finding's title
77
- export const ROLES = Object.freeze({ action: "cyan", done: "green", attention: "yellow", error: "red", explanation: "gray", emphasis: "bold" });
87
+ export const ROLES = Object.freeze({
88
+ action: "cyan",
89
+ done: "green",
90
+ attention: "yellow",
91
+ error: "red",
92
+ explanation: "gray",
93
+ emphasis: "bold",
94
+ });
78
95
 
79
96
  // Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
80
97
  // the package promises >=20.0.0. `style` is a role or a style name; anything
@@ -126,9 +143,17 @@ export const GLYPHS = Object.freeze({
126
143
  // warning / refusal / note, unchanged / skipped) and the plan's own action
127
144
  // names, so a caller says what happened rather than which shape to draw.
128
145
  export const GLYPH_ALIASES = Object.freeze({
129
- issue: "fail", refuse: "warning", refusal: "warning", note: "warning",
130
- skip: "unchanged", skipped: "unchanged", create: "add", migrate: "update", refresh: "update",
131
- cleanup: "remove", prune: "remove",
146
+ issue: "fail",
147
+ refuse: "warning",
148
+ refusal: "warning",
149
+ note: "warning",
150
+ skip: "unchanged",
151
+ skipped: "unchanged",
152
+ create: "add",
153
+ migrate: "update",
154
+ refresh: "update",
155
+ cleanup: "remove",
156
+ prune: "remove",
132
157
  // doctor's info finding: not a warning, so not ▲ — the two would differ by
133
158
  // colour alone (decided 2026-10-11 with doctor's report).
134
159
  info: "dot",
@@ -165,7 +190,8 @@ export function glyphText(c, text) {
165
190
  export function duration(ms) {
166
191
  if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
167
192
  if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
168
- const m = Math.floor(ms / 60_000), s = Math.round((ms % 60_000) / 1000);
193
+ const m = Math.floor(ms / 60_000),
194
+ s = Math.round((ms % 60_000) / 1000);
169
195
  return `${m}m ${String(s).padStart(2, "0")}s`;
170
196
  }
171
197
 
@@ -182,8 +208,10 @@ export function wrap(text, width, indent = "") {
182
208
  const lines = [];
183
209
  let line = "";
184
210
  for (const word of String(text).split(" ")) {
185
- if (line && plain(line).length + 1 + plain(word).length > room) { lines.push(line); line = word; }
186
- else line = line ? `${line} ${word}` : word;
211
+ if (line && plain(line).length + 1 + plain(word).length > room) {
212
+ lines.push(line);
213
+ line = word;
214
+ } else line = line ? `${line} ${word}` : word;
187
215
  }
188
216
  lines.push(line);
189
217
  return lines.map((l, k) => (k ? indent + l : l)).join("\n");
@@ -264,7 +292,14 @@ export function rows(c, list, opts = {}) {
264
292
 
265
293
  export function rowLines(c, list, { indent = 2 } = {}) {
266
294
  const paint = painter(c);
267
- const cells = list.map((r) => ({ glyph: r.glyph ? icon(c, r.glyph) : "", role: r.role || null, action: String(r.action ?? ""), target: String(r.target ?? ""), note: String(r.note ?? ""), prose: Boolean(r.prose) }));
295
+ const cells = list.map((r) => ({
296
+ glyph: r.glyph ? icon(c, r.glyph) : "",
297
+ role: r.role || null,
298
+ action: String(r.action ?? ""),
299
+ target: String(r.target ?? ""),
300
+ note: String(r.note ?? ""),
301
+ prose: Boolean(r.prose),
302
+ }));
268
303
  const widest = (xs) => Math.max(0, ...xs.map((s) => plain(s).length));
269
304
  const gw = widest(cells.map((r) => r.glyph));
270
305
  const aw = widest(cells.map((r) => r.action));
@@ -278,11 +313,24 @@ export function rowLines(c, list, { indent = 2 } = {}) {
278
313
  if (aw) lead += cell(r.action, aw, r.role) + " ";
279
314
  const col = plain(lead).length;
280
315
  const pad = " ".repeat(col);
281
- const alone = (r.prose && width ? lead + wrap(r.target, width, pad) : lead + r.target).split("\n").map((l) => l.trimEnd());
282
- if (!r.note) { out.push(alone); continue; }
316
+ const alone = (r.prose && width ? lead + wrap(r.target, width, pad) : lead + r.target)
317
+ .split("\n")
318
+ .map((l) => l.trimEnd());
319
+ if (!r.note) {
320
+ out.push(alone);
321
+ continue;
322
+ }
283
323
  const line = `${lead}${cell(r.target, tw, null)} ${paint("explanation", r.note)}`;
284
- if (!width || plain(line).length <= width) { out.push([line]); continue; }
285
- out.push([...alone, ...wrap(r.note, width, pad).split("\n").map((l, k) => pad + paint("explanation", k ? l.slice(col) : l))]);
324
+ if (!width || plain(line).length <= width) {
325
+ out.push([line]);
326
+ continue;
327
+ }
328
+ out.push([
329
+ ...alone,
330
+ ...wrap(r.note, width, pad)
331
+ .split("\n")
332
+ .map((l, k) => pad + paint("explanation", k ? l.slice(col) : l)),
333
+ ]);
286
334
  }
287
335
  return out;
288
336
  }
@@ -332,9 +380,16 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
332
380
  const paint = painter(c);
333
381
  let open = null;
334
382
  const line = rowFor(c, indent);
335
- const finished = (ok, note, kept, label, ms, over) => {
336
- // A step that ran and left its target in place is neither ✓ nor ✕.
337
- const mark = !ok ? paint("error", icon(c, "fail")) : kept ? paint("explanation", icon(c, "skip")) : paint("done", icon(c, "ok"));
383
+ const finished = (ok, note, kept, label, ms, over, warn = false) => {
384
+ // A step that ran and left its target in place is neither ✓ nor ✕; one
385
+ // that failed without stopping the run (setup's git init) is ▲.
386
+ const mark = !ok
387
+ ? paint("error", icon(c, "fail"))
388
+ : warn
389
+ ? paint("attention", icon(c, "warning"))
390
+ : kept
391
+ ? paint("explanation", icon(c, "skip"))
392
+ : paint("done", icon(c, "ok"));
338
393
  const right = paint("explanation", [note, duration(ms)].filter(Boolean).join(" "));
339
394
  stream.write((over && c.live ? "\r\x1b[2K" : "") + line(mark, label, right) + "\n");
340
395
  };
@@ -346,15 +401,19 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
346
401
  open = { label, t0: now() };
347
402
  if (c.live) stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
348
403
  },
349
- end(ok = true, note = "", { kept = false, label = null } = {}) {
404
+ end(ok = true, note = "", { kept = false, label = null, warn = false } = {}) {
350
405
  if (!open) return;
351
- finished(ok, note, kept, label ?? open.label, now() - open.t0, true);
406
+ finished(ok, note, kept, label ?? open.label, now() - open.t0, true, warn);
352
407
  open = null;
353
408
  },
354
- finish(label, t0, { ok = true, note = "" } = {}) { finished(ok, note, false, label, now() - t0, false); },
409
+ finish(label, t0, { ok = true, note = "" } = {}) {
410
+ finished(ok, note, false, label, now() - t0, false);
411
+ },
355
412
  // An exception left a step open: end its line as failed, so whatever is
356
413
  // printed next starts on a line of its own.
357
- abort() { if (open) self.end(false); },
414
+ abort() {
415
+ if (open) self.end(false);
416
+ },
358
417
  };
359
418
  return self;
360
419
  }
@@ -374,29 +433,53 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
374
433
  // is known. The timers are unref'd and cleared by both `end` and `abort`, so
375
434
  // a forgotten line never holds the process; the cursor is never hidden, so no
376
435
  // exit path has anything to restore. `now` and `timers` are the test's clock.
377
- export function liveLine(stream = process.stdout, c = caps(stream), label = "", { env = process.env, now = Date.now, timers = globalThis, indent = 2 } = {}) {
436
+ export function liveLine(
437
+ stream = process.stdout,
438
+ c = caps(stream),
439
+ label = "",
440
+ { env = process.env, now = Date.now, timers = globalThis, indent = 2 } = {},
441
+ ) {
378
442
  const paint = painter(c);
379
443
  const line = rowFor(c, indent);
380
444
  const t0 = now();
381
445
  const still = Boolean(env.PROJECTSTORE_NO_ANIMATION);
382
- let drawn = false, frame = 0, wait = null, tick = null, settled = false;
383
- const unref = (h) => { try { h?.unref?.(); } catch {} return h; };
446
+ let drawn = false,
447
+ frame = 0,
448
+ wait = null,
449
+ tick = null,
450
+ settled = false;
451
+ const unref = (h) => {
452
+ try {
453
+ h?.unref?.();
454
+ } catch {}
455
+ return h;
456
+ };
384
457
  const stop = () => {
385
- if (wait !== null) { timers.clearTimeout(wait); wait = null; }
386
- if (tick !== null) { timers.clearInterval(tick); tick = null; }
458
+ if (wait !== null) {
459
+ timers.clearTimeout(wait);
460
+ wait = null;
461
+ }
462
+ if (tick !== null) {
463
+ timers.clearInterval(tick);
464
+ tick = null;
465
+ }
387
466
  };
388
467
  const draw = () => {
389
468
  const mark = c.ascii ? icon(c, "running") : paint("action", SPINNER[frame++ % SPINNER.length]);
390
469
  stream.write("\r\x1b[2K" + line(mark, label, paint("explanation", duration(now() - t0)), { cut: true }));
391
470
  drawn = true;
392
471
  };
393
- if (c.live && still) { stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true })); drawn = true; }
394
- else if (c.live) {
395
- wait = unref(timers.setTimeout(() => {
396
- wait = null;
397
- draw();
398
- tick = unref(timers.setInterval(draw, SPIN_MS));
399
- }, QUIET_MS));
472
+ if (c.live && still) {
473
+ stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
474
+ drawn = true;
475
+ } else if (c.live) {
476
+ wait = unref(
477
+ timers.setTimeout(() => {
478
+ wait = null;
479
+ draw();
480
+ tick = unref(timers.setInterval(draw, SPIN_MS));
481
+ }, QUIET_MS),
482
+ );
400
483
  }
401
484
  return {
402
485
  end(ok = true, doneLabel = label, note = "") {
@@ -423,17 +506,27 @@ export function liveLine(stream = process.stdout, c = caps(stream), label = "",
423
506
  // the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
424
507
  // anything is written). End of input — Ctrl+D, a closed pipe — is null, which
425
508
  // every caller reads as no; it never leaves the question pending (an unsettled
426
- // promise is exit 13 with a warning). The promise API rejects on close in
427
- // some Node versions and stays pending in others; both settle here.
509
+ // promise is exit 13 with a warning). The question is node:readline's callback
510
+ // form: readline/promises is experimental before Node 22.17 and 24, below the
511
+ // engines floor. Its callback never runs on close, so the close handler
512
+ // settles it.
428
513
  export async function askLine(question, input, output) {
429
514
  const rl = createInterface({ input, output, terminal: false });
430
515
  return await new Promise((settle) => {
431
516
  let done = false;
432
- const finish = (answer) => { if (done) return; done = true; settle(answer); rl.close(); };
517
+ const finish = (answer) => {
518
+ if (done) return;
519
+ done = true;
520
+ settle(answer);
521
+ rl.close();
522
+ };
433
523
  // End of input echoes no newline: end the question's line, so whatever is
434
524
  // printed next ("Nothing written.") starts on a line of its own.
435
- rl.on("close", () => { if (!done) output.write("\n"); finish(null); });
436
- rl.question(question).then((a) => finish(a), () => finish(null));
525
+ rl.on("close", () => {
526
+ if (!done) output.write("\n");
527
+ finish(null);
528
+ });
529
+ rl.question(question, (a) => finish(a));
437
530
  });
438
531
  }
439
532
 
@@ -448,7 +541,10 @@ export async function askLine(question, input, output) {
448
541
  // isInteractive). `caps` is the asking stream's; without one the question is
449
542
  // unpainted, in Unicode. `ask` stands in for the terminal in tests and means
450
543
  // "this is one"; it gets the question and returns the answer.
451
- export async function askApply(n, { stdin = null, stdout = null, ask = null, caps: c = { color: false, ascii: false } } = {}) {
544
+ export async function askApply(
545
+ n,
546
+ { stdin = null, stdout = null, ask = null, caps: c = { color: false, ascii: false } } = {},
547
+ ) {
452
548
  const paint = painter(c);
453
549
  const question = `${paint("action", icon(c, "ask"))} ${paint("emphasis", `Apply ${n === 1 ? "1 change" : `${n} changes`}?`)} ${paint("explanation", "[Y/n]")} `;
454
550
  const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
@@ -498,12 +594,20 @@ export function railAvailable(stdin, c) {
498
594
  }
499
595
 
500
596
  // A choice as the rail draws and answers it: a string is its own value.
501
- const choiceOf = (x) => (typeof x === "object" && x !== null
502
- ? { value: x.value ?? x.label, label: String(x.label ?? x.value), reason: x.reason ? String(x.reason) : "", answer: String(x.answer ?? x.label ?? x.value), on: Boolean(x.on) }
503
- : { value: x, label: String(x), reason: "", answer: String(x), on: false });
597
+ const choiceOf = (x) =>
598
+ typeof x === "object" && x !== null
599
+ ? {
600
+ value: x.value ?? x.label,
601
+ label: String(x.label ?? x.value),
602
+ reason: x.reason ? String(x.reason) : "",
603
+ answer: String(x.answer ?? x.label ?? x.value),
604
+ on: Boolean(x.on),
605
+ }
606
+ : { value: x, label: String(x), reason: "", answer: String(x), on: false };
504
607
 
505
608
  const isEnter = (k) => k.name === "return" || k.name === "enter";
506
- const printable = (k) => typeof k.str === "string" && k.str !== "" && !k.ctrl && !k.meta && !/[\x00-\x1f\x7f]/.test(k.str);
609
+ const printable = (k) =>
610
+ typeof k.str === "string" && k.str !== "" && !k.ctrl && !k.meta && !/[\x00-\x1f\x7f]/.test(k.str);
507
611
 
508
612
  // Open a rail on a terminal: draws its top line and holds the terminal for its
509
613
  // questions until close(). Everything the run prints while the rail is open
@@ -536,8 +640,17 @@ const printable = (k) => typeof k.str === "string" && k.str !== "" && !k.ctrl &&
536
640
  // process ends as the signal would have ended it. `proc` is the process those
537
641
  // handlers are added to, a test's emitter in tests. Never process.exit: a
538
642
  // cancel is an answer, and the caller returns 130 (contract 9).
539
- export function openRail({ stdin = process.stdin, stdout = process.stdout, caps: c = caps(stdout), proc = process, head = "projectstore", parts = [], escapeMs = ESCAPE_MS } = {}) {
540
- if (!railAvailable(stdin, c)) throw new Error("no question rail: stdin must be a terminal with setRawMode and stdout a live one (railAvailable)");
643
+ export function openRail({
644
+ stdin = process.stdin,
645
+ stdout = process.stdout,
646
+ caps: c = caps(stdout),
647
+ proc = process,
648
+ head = "projectstore",
649
+ parts = [],
650
+ escapeMs = ESCAPE_MS,
651
+ } = {}) {
652
+ if (!railAvailable(stdin, c))
653
+ throw new Error("no question rail: stdin must be a terminal with setRawMode and stdout a live one (railAvailable)");
541
654
  const paint = painter(c);
542
655
  const bar = paint("explanation", icon(c, "rail"));
543
656
  const sep = ` ${icon(c, "dot")} `;
@@ -547,15 +660,35 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
547
660
  // that miscounted them would leave half a frame behind.
548
661
  const columns = () => (Number(stdout.columns) > 0 ? Number(stdout.columns) : c.columns || 100);
549
662
  const room = () => Math.max(20, Math.min(columns(), 100) - 1);
550
- const rowsOf = (lines) => { const w = columns(); return lines.reduce((n, l) => n + Math.max(1, Math.ceil([...plain(l)].length / w)), 0); };
663
+ const rowsOf = (lines) => {
664
+ const w = columns();
665
+ return lines.reduce((n, l) => n + Math.max(1, Math.ceil([...plain(l)].length / w)), 0);
666
+ };
551
667
 
552
668
  // ── The keys ──
553
669
  const queue = [];
554
670
  let waiter = null;
555
- const wake = () => { if (waiter) { const w = waiter; waiter = null; w(); } };
556
- const onKey = (str, key) => { queue.push({ ...(key || {}), str }); wake(); };
557
- const onEnd = () => { queue.push(EOF); wake(); };
558
- const next = () => (queue.length ? Promise.resolve(queue.shift()) : new Promise((settle) => { waiter = () => settle(queue.shift()); }));
671
+ const wake = () => {
672
+ if (waiter) {
673
+ const w = waiter;
674
+ waiter = null;
675
+ w();
676
+ }
677
+ };
678
+ const onKey = (str, key) => {
679
+ queue.push({ ...(key || {}), str });
680
+ wake();
681
+ };
682
+ const onEnd = () => {
683
+ queue.push(EOF);
684
+ wake();
685
+ };
686
+ const next = () =>
687
+ queue.length
688
+ ? Promise.resolve(queue.shift())
689
+ : new Promise((settle) => {
690
+ waiter = () => settle(queue.shift());
691
+ });
559
692
  emitKeypressEvents(stdin, { escapeCodeTimeout: escapeMs });
560
693
  stdin.on("keypress", onKey);
561
694
  stdin.on("end", onEnd);
@@ -566,10 +699,16 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
566
699
 
567
700
  // ── The restore, on every path ──
568
701
  // `live`: the question being asked, or null between questions.
569
- let closed = false, cancelled = false, live = null;
702
+ let closed = false,
703
+ cancelled = false,
704
+ live = null;
570
705
  const restore = () => {
571
- try { stdin.setRawMode(false); } catch {}
572
- try { write(SHOW_CURSOR); } catch {}
706
+ try {
707
+ stdin.setRawMode(false);
708
+ } catch {}
709
+ try {
710
+ write(SHOW_CURSOR);
711
+ } catch {}
573
712
  };
574
713
  const onExit = () => restore();
575
714
  const onSignal = {};
@@ -582,9 +721,14 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
582
721
  for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) {
583
722
  onSignal[sig] = () => {
584
723
  restore();
585
- if (live) try { write("\n"); } catch {}
724
+ if (live)
725
+ try {
726
+ write("\n");
727
+ } catch {}
586
728
  release();
587
- try { proc.kill(proc.pid, sig); } catch {}
729
+ try {
730
+ proc.kill(proc.pid, sig);
731
+ } catch {}
588
732
  };
589
733
  }
590
734
  proc.once("exit", onExit);
@@ -594,17 +738,32 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
594
738
  // `shown`: the rows of the question on screen, written with no newline
595
739
  // after it, so the cursor is on its last row. `last`: the rows of the line
596
740
  // answered before it, which a path's Esc takes back.
597
- let shown = 0, last = 0;
741
+ let shown = 0,
742
+ last = 0;
598
743
  const erase = () => {
599
744
  if (!shown) return;
600
745
  write(`\r${shown > 1 ? `\x1b[${shown - 1}A` : ""}\x1b[J`);
601
746
  shown = 0;
602
747
  };
603
- const frame = (lines) => { erase(); write(lines.join("\n")); shown = rowsOf(lines); };
604
- const item = (lines) => { erase(); write(`${lines.join("\n")}\n`); last = rowsOf(lines); };
748
+ const frame = (lines) => {
749
+ erase();
750
+ write(lines.join("\n"));
751
+ shown = rowsOf(lines);
752
+ };
753
+ const item = (lines) => {
754
+ erase();
755
+ write(`${lines.join("\n")}\n`);
756
+ last = rowsOf(lines);
757
+ };
605
758
 
606
- const titled = (title) => wrap(String(title), room() - 2).split("\n").map((t, k) => `${k ? bar : paint("action", icon(c, "ask"))} ${paint("emphasis", t)}`);
607
- const prose = (text, role = "explanation") => wrap(String(text), room() - 2).split("\n").map((t) => `${bar} ${paint(role, t)}`);
759
+ const titled = (title) =>
760
+ wrap(String(title), room() - 2)
761
+ .split("\n")
762
+ .map((t, k) => `${k ? bar : paint("action", icon(c, "ask"))} ${paint("emphasis", t)}`);
763
+ const prose = (text, role = "explanation") =>
764
+ wrap(String(text), room() - 2)
765
+ .split("\n")
766
+ .map((t) => `${bar} ${paint(role, t)}`);
608
767
  const warning = (text) => prose(`${icon(c, "warning")} ${text}`, "attention");
609
768
  // The key hint: one line, broken only between its keys on a terminal too
610
769
  // narrow for it, each separator ending the line it closes.
@@ -617,8 +776,10 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
617
776
  }
618
777
  return lines.map((t) => `${bar} ${paint("explanation", t)}`);
619
778
  };
620
- const answeredLine = (title, text, glyph = "answered", role = "done", answerRole = "explanation") =>
621
- [bar, `${paint(role, icon(c, glyph))} ${title}${text ? ` ${paint(answerRole, text)}` : ""}`];
779
+ const answeredLine = (title, text, glyph = "answered", role = "done", answerRole = "explanation") => [
780
+ bar,
781
+ `${paint(role, icon(c, glyph))} ${title}${text ? ` ${paint(answerRole, text)}` : ""}`,
782
+ ];
622
783
  // Choice rows: the marks (one plain width), the label, and the reason in a
623
784
  // column after the widest label that keeps its reason beside it. A row that
624
785
  // would pass the width puts its reason beneath the label, wrapped, and is
@@ -638,11 +799,15 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
638
799
  const lw = widest(list.filter((_, i) => beside[i]));
639
800
  const out = [];
640
801
  list.forEach((x, i) => {
641
- if (beside[i]) { out.push(`${lead(i)}${x.label}${" ".repeat(lw - plain(x.label).length)} ${paint("explanation", x.reason)}`); return; }
802
+ if (beside[i]) {
803
+ out.push(`${lead(i)}${x.label}${" ".repeat(lw - plain(x.label).length)} ${paint("explanation", x.reason)}`);
804
+ return;
805
+ }
642
806
  out.push(lead(i) + x.label);
643
807
  if (!x.reason) return;
644
808
  const col = plain(lead(i)).length;
645
- for (const t of wrap(x.reason, room() - col).split("\n")) out.push(`${bar} ${" ".repeat(col - 2)}${paint("explanation", t)}`);
809
+ for (const t of wrap(x.reason, room() - col).split("\n"))
810
+ out.push(`${bar} ${" ".repeat(col - 2)}${paint("explanation", t)}`);
646
811
  });
647
812
  return out;
648
813
  };
@@ -665,7 +830,10 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
665
830
  for (;;) {
666
831
  const k = await next();
667
832
  // close() cancelled this question while it waited: it drew the line.
668
- if (closed) { settled = true; return CANCELLED; }
833
+ if (closed) {
834
+ settled = true;
835
+ return CANCELLED;
836
+ }
669
837
  if (k === EOF || (k.ctrl && k.name === "c")) {
670
838
  cancelled = true;
671
839
  item(answeredLine(q.title, "cancelled", "warning", "attention", "attention"));
@@ -673,12 +841,21 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
673
841
  return CANCELLED;
674
842
  }
675
843
  const r = await q.key(k);
676
- if (closed) { settled = true; return CANCELLED; }
677
- if (r === "redraw") { draw(); continue; }
844
+ if (closed) {
845
+ settled = true;
846
+ return CANCELLED;
847
+ }
848
+ if (r === "redraw") {
849
+ draw();
850
+ continue;
851
+ }
678
852
  if (!r) continue;
679
853
  if (r.back) {
680
854
  erase();
681
- if (last) { write(`\x1b[${last}A\x1b[J`); last = 0; }
855
+ if (last) {
856
+ write(`\x1b[${last}A\x1b[J`);
857
+ last = 0;
858
+ }
682
859
  settled = true;
683
860
  return BACK;
684
861
  }
@@ -689,9 +866,16 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
689
866
  } finally {
690
867
  live = null;
691
868
  // A throw left the frame drawn: end its line, so what follows starts on its own.
692
- if (!settled && shown) { try { write("\n"); } catch {} shown = 0; }
869
+ if (!settled && shown) {
870
+ try {
871
+ write("\n");
872
+ } catch {}
873
+ shown = 0;
874
+ }
693
875
  restore();
694
- try { stdin.pause(); } catch {}
876
+ try {
877
+ stdin.pause();
878
+ } catch {}
695
879
  }
696
880
  }
697
881
 
@@ -699,25 +883,46 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
699
883
  if (!list.length) throw new TypeError(`${title}: no choices`);
700
884
  return ask({
701
885
  title,
702
- draw: () => [bar, ...titled(title), ...(note ? prose(note) : []),
703
- ...choiceLines(list.map((_, i) => (i === at ? paint("action", icon(c, "choiceOn")) : paint("explanation", icon(c, "choiceOff")))), list),
704
- ...hint(keys)],
886
+ draw: () => [
887
+ bar,
888
+ ...titled(title),
889
+ ...(note ? prose(note) : []),
890
+ ...choiceLines(
891
+ list.map((_, i) =>
892
+ i === at ? paint("action", icon(c, "choiceOn")) : paint("explanation", icon(c, "choiceOff")),
893
+ ),
894
+ list,
895
+ ),
896
+ ...hint(keys),
897
+ ],
705
898
  key: (k) => {
706
- if (k.name === "up") { at = (at + list.length - 1) % list.length; return "redraw"; }
707
- if (k.name === "down") { at = (at + 1) % list.length; return "redraw"; }
899
+ if (k.name === "up") {
900
+ at = (at + list.length - 1) % list.length;
901
+ return "redraw";
902
+ }
903
+ if (k.name === "down") {
904
+ at = (at + 1) % list.length;
905
+ return "redraw";
906
+ }
708
907
  if (isEnter(k)) return { value: list[at].value, answer: list[at].answer };
709
908
  return null;
710
909
  },
711
910
  });
712
911
  };
713
- const defaultAt = (list, value) => Math.max(0, list.findIndex((x) => Object.is(x.value, value)));
912
+ const defaultAt = (list, value) =>
913
+ Math.max(
914
+ 0,
915
+ list.findIndex((x) => Object.is(x.value, value)),
916
+ );
714
917
 
715
918
  // The top: `┌` and the badge — `┌ projectstore setup` — in place of the
716
919
  // badge line a screen without a rail starts with.
717
920
  write(`${paint("explanation", icon(c, "railTop"))} ${badge(c, head, parts)}\n`);
718
921
 
719
922
  return {
720
- get cancelled() { return cancelled; },
923
+ get cancelled() {
924
+ return cancelled;
925
+ },
721
926
 
722
927
  select(title, choices, { default: value, note = "" } = {}) {
723
928
  const list = choices.map(choiceOf);
@@ -733,27 +938,60 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
733
938
  const need = Math.min(Math.max(0, Number(min) || 0), list.length);
734
939
  const on = list.map((x) => x.on);
735
940
  const pointer = icon(c, "pointer");
736
- let at = 0, refused = "";
941
+ let at = 0,
942
+ refused = "";
737
943
  return ask({
738
944
  title,
739
- draw: () => [bar, ...titled(title), ...(note ? prose(note) : []),
740
- ...choiceLines(list.map((_, i) => `${i === at ? paint("action", pointer) : " ".repeat(pointer.length)} ${paint(on[i] ? "action" : "explanation", icon(c, on[i] ? "checkOn" : "checkOff"))}`), list),
945
+ draw: () => [
946
+ bar,
947
+ ...titled(title),
948
+ ...(note ? prose(note) : []),
949
+ ...choiceLines(
950
+ list.map(
951
+ (_, i) =>
952
+ `${i === at ? paint("action", pointer) : " ".repeat(pointer.length)} ${paint(on[i] ? "action" : "explanation", icon(c, on[i] ? "checkOn" : "checkOff"))}`,
953
+ ),
954
+ list,
955
+ ),
741
956
  ...(refused ? warning(refused) : []),
742
- ...hint(["up/down move", "space toggle", "enter confirm", "ctrl+c cancel"])],
957
+ ...hint(["up/down move", "space toggle", "enter confirm", "ctrl+c cancel"]),
958
+ ],
743
959
  key: (k) => {
744
- if (k.name === "up") { at = (at + list.length - 1) % list.length; return "redraw"; }
745
- if (k.name === "down") { at = (at + 1) % list.length; return "redraw"; }
746
- if (k.name === "space") { on[at] = !on[at]; refused = ""; return "redraw"; }
960
+ if (k.name === "up") {
961
+ at = (at + list.length - 1) % list.length;
962
+ return "redraw";
963
+ }
964
+ if (k.name === "down") {
965
+ at = (at + 1) % list.length;
966
+ return "redraw";
967
+ }
968
+ if (k.name === "space") {
969
+ on[at] = !on[at];
970
+ refused = "";
971
+ return "redraw";
972
+ }
747
973
  if (!isEnter(k)) return null;
748
974
  const picked = list.filter((_, i) => on[i]);
749
- if (picked.length < need) { refused = need === 1 ? "tick at least one with space" : `tick at least ${need} with space`; return "redraw"; }
750
- return { value: picked.map((x) => x.value), answer: picked.length ? picked.map((x) => x.answer).join(", ") : "none" };
975
+ if (picked.length < need) {
976
+ refused = need === 1 ? "tick at least one with space" : `tick at least ${need} with space`;
977
+ return "redraw";
978
+ }
979
+ return {
980
+ value: picked.map((x) => x.value),
981
+ answer: picked.length ? picked.map((x) => x.answer).join(", ") : "none",
982
+ };
751
983
  },
752
984
  });
753
985
  },
754
986
 
755
- confirm(title, { default: yes = true, yes: yesLabel = "Yes", no: noLabel = "No", yesReason = "", noReason = "", note = "" } = {}) {
756
- const list = [choiceOf({ value: true, label: yesLabel, reason: yesReason }), choiceOf({ value: false, label: noLabel, reason: noReason })];
987
+ confirm(
988
+ title,
989
+ { default: yes = true, yes: yesLabel = "Yes", no: noLabel = "No", yesReason = "", noReason = "", note = "" } = {},
990
+ ) {
991
+ const list = [
992
+ choiceOf({ value: true, label: yesLabel, reason: yesReason }),
993
+ choiceOf({ value: false, label: noLabel, reason: noReason }),
994
+ ];
757
995
  return choose(title, list, yes ? 0 : 1, note, ["up/down move", "enter choose", "ctrl+c cancel"]);
758
996
  },
759
997
 
@@ -763,21 +1001,33 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
763
1001
  // Enter with nothing typed takes it. Backspace takes back a character;
764
1002
  // Esc goes back.
765
1003
  path(title, { default: fallback = "", check = null, note = "", back = true } = {}) {
766
- let value = "", refused = "";
1004
+ let value = "",
1005
+ refused = "";
767
1006
  return ask({
768
1007
  title,
769
1008
  cursor: true,
770
- draw: () => [bar, ...titled(title), ...(note ? prose(note) : []), ...(refused ? warning(refused) : []),
1009
+ draw: () => [
1010
+ bar,
1011
+ ...titled(title),
1012
+ ...(note ? prose(note) : []),
1013
+ ...(refused ? warning(refused) : []),
771
1014
  ...(fallback ? prose(`default ${fallback}`) : []),
772
1015
  ...hint(["enter confirm", back ? "esc back" : null, "ctrl+c cancel"]),
773
- `${bar} ${value}`],
1016
+ `${bar} ${value}`,
1017
+ ],
774
1018
  key: async (k) => {
775
1019
  if (k.name === "escape") return back ? { back: true } : null;
776
1020
  if (isEnter(k)) {
777
1021
  const chosen = value.trim() || String(fallback);
778
- if (!chosen) { refused = "type a path"; return "redraw"; }
1022
+ if (!chosen) {
1023
+ refused = "type a path";
1024
+ return "redraw";
1025
+ }
779
1026
  const why = check ? await check(chosen) : null;
780
- if (why) { refused = String(why); return "redraw"; }
1027
+ if (why) {
1028
+ refused = String(why);
1029
+ return "redraw";
1030
+ }
781
1031
  return { value: chosen, answer: chosen };
782
1032
  }
783
1033
  if (k.name === "backspace") {
@@ -831,9 +1081,44 @@ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps:
831
1081
  queue.length = 0;
832
1082
  const woken = waiter;
833
1083
  waiter = null;
834
- try { stdin.pause(); } catch {}
1084
+ try {
1085
+ stdin.pause();
1086
+ } catch {}
835
1087
  release();
836
1088
  if (woken) woken();
837
1089
  },
838
1090
  };
839
1091
  }
1092
+
1093
+ // What was typed ahead of a line-mode question, discarded (the setup story,
1094
+ // rule 2: the rail's raw mode ends at `└`, and the confirm after it is
1095
+ // askLine's). Keys typed while the run fetched, read the registry and drew
1096
+ // PLAN wait in the terminal's buffer — an Enter double-tapped on the last rail
1097
+ // question among them — and the next askLine would take them as the answer
1098
+ // to a question nobody saw. So: raw mode on (the terminal hands over what it
1099
+ // holds, line or not), the stream resumed for `ticks` turns of the event loop,
1100
+ // then paused, raw mode off, and whatever arrived dropped. Returns the bytes
1101
+ // dropped. A stream that cannot be put in raw mode is left alone (no rail ran
1102
+ // on it). `wait` is the test's turn of the loop.
1103
+ export async function drainInput(stdin, { ticks = 2, wait = () => new Promise(setImmediate) } = {}) {
1104
+ if (!stdin || typeof stdin.setRawMode !== "function") return 0;
1105
+ let dropped = 0;
1106
+ const sink = (chunk) => {
1107
+ dropped += Buffer.byteLength(chunk);
1108
+ };
1109
+ try {
1110
+ stdin.setRawMode(true);
1111
+ stdin.on("data", sink);
1112
+ stdin.resume();
1113
+ for (let k = 0; k < ticks; k++) await wait();
1114
+ } finally {
1115
+ stdin.removeListener("data", sink);
1116
+ try {
1117
+ stdin.pause();
1118
+ } catch {}
1119
+ try {
1120
+ stdin.setRawMode(false);
1121
+ } catch {}
1122
+ }
1123
+ return dropped;
1124
+ }