tickmarkr 2.4.1 → 2.5.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 (75) hide show
  1. package/README.md +110 -24
  2. package/dist/adapters/claude-code.js +7 -2
  3. package/dist/adapters/codex.d.ts +1 -0
  4. package/dist/adapters/codex.js +66 -5
  5. package/dist/adapters/types.d.ts +4 -0
  6. package/dist/adapters/types.js +33 -0
  7. package/dist/cli/commands/approve.d.ts +42 -0
  8. package/dist/cli/commands/approve.js +80 -13
  9. package/dist/cli/commands/doctor.d.ts +234 -0
  10. package/dist/cli/commands/doctor.js +139 -5
  11. package/dist/cli/commands/report.js +15 -44
  12. package/dist/cli/commands/scope.js +36 -6
  13. package/dist/cli/commands/stats.d.ts +2 -0
  14. package/dist/cli/commands/stats.js +42 -23
  15. package/dist/cli/commands/status.js +20 -4
  16. package/dist/cli/commands/ui.js +42 -53
  17. package/dist/cli/commands/unlock.d.ts +1 -1
  18. package/dist/cli/commands/unlock.js +59 -9
  19. package/dist/cli/help.d.ts +219 -0
  20. package/dist/cli/help.js +212 -0
  21. package/dist/cli/index.d.ts +42 -2
  22. package/dist/cli/index.js +23 -8
  23. package/dist/drivers/herdr.d.ts +7 -2
  24. package/dist/drivers/herdr.js +78 -48
  25. package/dist/drivers/orca.d.ts +3 -2
  26. package/dist/drivers/orca.js +43 -4
  27. package/dist/drivers/subprocess.d.ts +1 -0
  28. package/dist/drivers/subprocess.js +3 -0
  29. package/dist/drivers/types.d.ts +14 -0
  30. package/dist/gates/artifact-manifest.d.ts +50 -0
  31. package/dist/gates/artifact-manifest.js +23 -0
  32. package/dist/plan/scope.d.ts +25 -0
  33. package/dist/plan/scope.js +92 -12
  34. package/dist/report/operator-record.d.ts +49 -0
  35. package/dist/report/operator-record.js +137 -0
  36. package/dist/run/daemon.d.ts +3 -4
  37. package/dist/run/daemon.js +18 -10
  38. package/dist/run/lock.d.ts +59 -2
  39. package/dist/run/lock.js +184 -26
  40. package/dist/run/operator-state.d.ts +86 -0
  41. package/dist/run/operator-state.js +165 -0
  42. package/dist/run/supervision.d.ts +32 -0
  43. package/dist/run/supervision.js +138 -17
  44. package/dist/tui/cockpit/capture.d.ts +19 -0
  45. package/dist/tui/cockpit/capture.js +89 -1
  46. package/dist/tui/cockpit/components.d.ts +15 -1
  47. package/dist/tui/cockpit/components.js +79 -9
  48. package/dist/tui/cockpit/decision-actions.d.ts +147 -0
  49. package/dist/tui/cockpit/decision-actions.js +315 -0
  50. package/dist/tui/cockpit/derive.d.ts +1 -1
  51. package/dist/tui/cockpit/derive.js +2 -0
  52. package/dist/tui/cockpit/evidence-view.d.ts +119 -0
  53. package/dist/tui/cockpit/evidence-view.js +210 -0
  54. package/dist/tui/cockpit/home-view.d.ts +88 -0
  55. package/dist/tui/cockpit/home-view.js +240 -0
  56. package/dist/tui/cockpit/keys.d.ts +125 -0
  57. package/dist/tui/cockpit/keys.js +31 -0
  58. package/dist/tui/cockpit/layout.d.ts +14 -0
  59. package/dist/tui/cockpit/layout.js +15 -0
  60. package/dist/tui/cockpit/live-runtime.d.ts +46 -0
  61. package/dist/tui/cockpit/live-runtime.js +683 -0
  62. package/dist/tui/cockpit/live-store.d.ts +289 -0
  63. package/dist/tui/cockpit/live-store.js +308 -0
  64. package/dist/tui/cockpit/live.d.ts +21 -1
  65. package/dist/tui/cockpit/live.js +12 -1
  66. package/dist/tui/cockpit/run-view.d.ts +100 -0
  67. package/dist/tui/cockpit/run-view.js +202 -0
  68. package/dist/tui/cockpit/shell.d.ts +50 -0
  69. package/dist/tui/cockpit/shell.js +74 -0
  70. package/dist/tui/cockpit/theme.d.ts +27 -0
  71. package/dist/tui/cockpit/theme.js +21 -0
  72. package/package.json +1 -1
  73. package/skills/tickmarkr-auto/SKILL.md +10 -1
  74. package/skills/tickmarkr-loop/SKILL.md +72 -1
  75. package/skills/tickmarkr-overseer/SKILL.md +12 -0
@@ -43,6 +43,9 @@ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier,
43
43
  /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
44
44
  export interface ArmedSupervision {
45
45
  disarm: () => void;
46
+ readonly id?: string;
47
+ /** True only after this handle removed its own presence. */
48
+ released?: () => boolean;
46
49
  }
47
50
  export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
48
51
  /** Arm the live status board's own tier through the normal supervision writer and lifecycle. */
@@ -53,3 +56,32 @@ export declare function supervisionStatus(repoRoot: string, tier: SupervisionTie
53
56
  export declare const readSupervision: (repoRoot: string, now?: number) => TierLiveness[];
54
57
  /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
55
58
  export declare const supervisionText: (tiers: readonly TierLiveness[], divider?: string) => string;
59
+ /** A board's durable identity is independent of its short title and of other observers. */
60
+ export interface WatchBoardOwner {
61
+ repo: string;
62
+ runId: string;
63
+ driver: string;
64
+ workspace: string;
65
+ pane: string;
66
+ name: string;
67
+ token: string;
68
+ pid?: number;
69
+ armId?: string;
70
+ }
71
+ export declare const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
72
+ export declare function readWatchBoard(repo: string, runId: string): WatchBoardOwner | undefined;
73
+ /** Only a driver that has just verified placement may reserve this binding. */
74
+ export declare function reserveWatchBoard(owner: Omit<WatchBoardOwner, "repo" | "token" | "pid" | "armId"> & {
75
+ repo: string;
76
+ }): WatchBoardOwner;
77
+ export declare function requestWatchBoardStop(owner: WatchBoardOwner): void;
78
+ export declare function watchBoardAcknowledged(owner: WatchBoardOwner): boolean;
79
+ export declare function stopWatchBoard(owner: WatchBoardOwner, time?: {
80
+ now: () => number;
81
+ sleep: (ms: number) => Promise<void>;
82
+ }, timeoutMs?: number): Promise<void>;
83
+ /** The UI alone publishes acknowledgement, after releasing its own presence. */
84
+ export declare function observeNamedRun(repo: string, runId: string, env?: NodeJS.ProcessEnv): {
85
+ stopRequested: () => boolean;
86
+ close: () => void;
87
+ };
@@ -1,6 +1,7 @@
1
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { parseRunId } from "./journal.js";
4
5
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
5
6
  // SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
6
7
  // tier. A watcher beats its OWN file; every other seat derives that tier's state by stat()ing it. So
@@ -102,8 +103,8 @@ const presenceNames = (repoRoot, tier) => {
102
103
  try {
103
104
  return readdirSync(supervisionDir(repoRoot)).filter((n) => n.startsWith(presencePrefix(tier)));
104
105
  }
105
- catch {
106
- return [];
106
+ catch (error) {
107
+ return error.code === "ENOENT" ? [] : undefined;
107
108
  }
108
109
  };
109
110
  /**
@@ -113,7 +114,10 @@ const presenceNames = (repoRoot, tier) => {
113
114
  function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
114
115
  const own = `${presencePrefix(tier)}${id}`;
115
116
  const stale = [];
116
- for (const name of presenceNames(repoRoot, tier)) {
117
+ const peers = presenceNames(repoRoot, tier);
118
+ if (!peers)
119
+ return undefined;
120
+ for (const name of peers) {
117
121
  if (name === own)
118
122
  continue;
119
123
  try {
@@ -121,7 +125,10 @@ function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
121
125
  return undefined;
122
126
  stale.push(name);
123
127
  }
124
- catch { /* a vanished peer needs no cleanup and is not evidence of a live watcher */ }
128
+ catch (error) {
129
+ if (error.code !== "ENOENT")
130
+ return undefined;
131
+ }
125
132
  }
126
133
  return stale;
127
134
  }
@@ -254,8 +261,10 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
254
261
  // Presence is refreshed with the beat, so it ages by the same clock and needs no separate loop.
255
262
  const mark = () => {
256
263
  try {
257
- writeSupervisionBeat(repoRoot, tier, seat, id); // creates the directory presence is written into
264
+ tickmarkrDir(repoRoot);
265
+ mkdirSync(dirname(presence), { recursive: true });
258
266
  writeFileSync(presence, JSON.stringify({ tier, exitedWriterPid: process.pid, id }) + "\n");
267
+ writeSupervisionBeat(repoRoot, tier, seat, id);
259
268
  }
260
269
  catch { /* repo gone / disk full / no seat — the tier ages out rather than crashing its watcher */ }
261
270
  };
@@ -263,7 +272,10 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
263
272
  const timer = setInterval(mark, beatMs);
264
273
  timer.unref();
265
274
  let stoodDown = false;
275
+ let released = false;
266
276
  return {
277
+ id,
278
+ released: () => released,
267
279
  // Stand down: stop beating AND say so. Idempotence lets a watcher safely share cleanup across
268
280
  // multiple exit paths; the recorded instant belongs to the first stand-down.
269
281
  disarm: () => {
@@ -272,9 +284,12 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
272
284
  stoodDown = true;
273
285
  clearInterval(timer);
274
286
  try {
275
- rmSync(presence, { force: true, recursive: true });
287
+ rmSync(presence, { force: true });
288
+ released = !existsSync(presence);
276
289
  }
277
- catch { /* ages out on its own */ }
290
+ catch { /* no acknowledgement: ages out */ }
291
+ if (!released)
292
+ return;
278
293
  // The marker speaks for the TIER, so only the last watcher out may write one: a peer still
279
294
  // present means the tier is not down, and saying it is would render that live board's own tier
280
295
  // DISARMED. The snapshot also fixes the cleanup set: a board arming after this decision receives
@@ -296,14 +311,6 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
296
311
  renameSync(tmp, p);
297
312
  }
298
313
  catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
299
- // Sweep only stale names in the pre-publication snapshot. Re-reading here used to catch and
300
- // delete a newer board that armed between the peer check and this older board's rename.
301
- for (const name of stalePeers) {
302
- try {
303
- rmSync(join(supervisionDir(repoRoot), name), { force: true, recursive: true });
304
- }
305
- catch { /* next sweep */ }
306
- }
307
314
  },
308
315
  };
309
316
  }
@@ -441,7 +448,8 @@ export function supervisionStatus(repoRoot, tier, now = Date.now()) {
441
448
  if (standDown === "UNREADABLE")
442
449
  return withClearObligation(repoRoot, tier, { tier, state: "UNREADABLE" });
443
450
  const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
444
- beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId));
451
+ beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId &&
452
+ existsSync(supervisionPresencePath(repoRoot, tier, beat.armId))));
445
453
  if (standDown !== "NONE" && !beatOutranksStandDown) {
446
454
  // the seat that stood down is named by the marker, falling back to whatever its last beat named
447
455
  const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
@@ -460,3 +468,116 @@ export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS
460
468
  export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}${t.seat ? ` (${t.seat})` : ""}` +
461
469
  `${t.clearOwedSince ? ` CLEAR-OWED since ${t.clearOwedSince}` : ""}` +
462
470
  `${t.clearOwedUnreadable ? " CLEAR-OWED unreadable" : ""}`).join(divider)}`;
471
+ export const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
472
+ const boardPath = (repo, runId) => join(supervisionDir(repo), `watch-board.${parseRunId(runId)}.json`);
473
+ const boardMessagePath = (owner, kind) => {
474
+ if (!/^[a-f0-9-]{36}$/.test(owner.token))
475
+ throw new Error("invalid watch owner token");
476
+ return join(supervisionDir(owner.repo), `watch-board.${owner.token}.${kind}`);
477
+ };
478
+ function atomicRecord(path, value) {
479
+ mkdirSync(dirname(path), { recursive: true });
480
+ const tmp = `${path}.${randomUUID()}.tmp`;
481
+ try {
482
+ writeFileSync(tmp, JSON.stringify(value) + "\n");
483
+ renameSync(tmp, path);
484
+ }
485
+ finally {
486
+ rmSync(tmp, { force: true });
487
+ }
488
+ }
489
+ export function readWatchBoard(repo, runId) {
490
+ try {
491
+ const owner = JSON.parse(readFileSync(boardPath(repo, runId), "utf8"));
492
+ if (owner.repo !== realpathSync(repo) || owner.runId !== runId ||
493
+ typeof owner.name !== "string" || typeof owner.driver !== "string" ||
494
+ typeof owner.workspace !== "string" || typeof owner.pane !== "string" ||
495
+ !/^[a-f0-9-]{36}$/.test(owner.token))
496
+ return undefined;
497
+ return owner;
498
+ }
499
+ catch {
500
+ return undefined;
501
+ }
502
+ }
503
+ /** Only a driver that has just verified placement may reserve this binding. */
504
+ export function reserveWatchBoard(owner) {
505
+ const record = { ...owner, repo: realpathSync(owner.repo), token: randomUUID() };
506
+ atomicRecord(boardPath(record.repo, record.runId), record);
507
+ return record;
508
+ }
509
+ export function requestWatchBoardStop(owner) {
510
+ if (readWatchBoard(owner.repo, owner.runId)?.token !== owner.token)
511
+ throw new Error("watch ownership changed; cleanup protected");
512
+ atomicRecord(boardMessagePath(owner, "stop"), { token: owner.token });
513
+ }
514
+ export function watchBoardAcknowledged(owner) {
515
+ try {
516
+ const current = readWatchBoard(owner.repo, owner.runId);
517
+ const ack = JSON.parse(readFileSync(boardMessagePath(owner, "ack"), "utf8"));
518
+ return current?.token === owner.token && typeof current.armId === "string" &&
519
+ ack.token === owner.token && ack.armId === current.armId && ack.pid === current.pid &&
520
+ !existsSync(supervisionPresencePath(owner.repo, "watch", current.armId));
521
+ }
522
+ catch {
523
+ return false;
524
+ }
525
+ }
526
+ export async function stopWatchBoard(owner, time = {
527
+ now: () => Date.now(), sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)),
528
+ }, timeoutMs = 5000) {
529
+ requestWatchBoardStop(owner);
530
+ const deadline = time.now() + timeoutMs;
531
+ while (!watchBoardAcknowledged(owner)) {
532
+ if (time.now() >= deadline)
533
+ throw new Error(`watch cleanup unacknowledged for ${owner.name}; owned pane protected`);
534
+ await time.sleep(Math.min(100, deadline - time.now()));
535
+ }
536
+ }
537
+ /** The UI alone publishes acknowledgement, after releasing its own presence. */
538
+ export function observeNamedRun(repo, runId, env = process.env) {
539
+ const armed = armWatchSupervision(repo);
540
+ let owner;
541
+ try {
542
+ const token = env[WATCH_OWNER_ENV];
543
+ if (token) {
544
+ const candidate = readWatchBoard(repo, runId);
545
+ if (!candidate || candidate.token !== token || candidate.pid !== undefined)
546
+ throw new Error("watch ownership unknown; refusing narrator acknowledgement");
547
+ owner = { ...candidate, pid: process.pid, armId: armed.id };
548
+ atomicRecord(boardPath(repo, runId), owner);
549
+ }
550
+ }
551
+ catch (error) {
552
+ armed.disarm();
553
+ throw error;
554
+ }
555
+ let closed = false;
556
+ return {
557
+ stopRequested: () => {
558
+ if (!owner)
559
+ return false;
560
+ try {
561
+ return JSON.parse(readFileSync(boardMessagePath(owner, "stop"), "utf8")).token === owner.token;
562
+ }
563
+ catch (error) {
564
+ if (error.code === "ENOENT")
565
+ return false;
566
+ throw error;
567
+ }
568
+ },
569
+ close: () => {
570
+ if (closed)
571
+ return;
572
+ closed = true;
573
+ armed.disarm();
574
+ if (!armed.released?.())
575
+ throw new Error("watch presence cleanup unacknowledged");
576
+ if (owner) {
577
+ if (readWatchBoard(repo, runId)?.token !== owner.token)
578
+ throw new Error("watch owner changed before cleanup acknowledgement");
579
+ atomicRecord(boardMessagePath(owner, "ack"), { token: owner.token, armId: armed.id, pid: process.pid });
580
+ }
581
+ },
582
+ };
583
+ }
@@ -233,3 +233,22 @@ export type RegeneratedColourFrame = ColourFrameCase & {
233
233
  * bytes.
234
234
  */
235
235
  export declare function regenerateColourFrames(binaryVersion?: string): Promise<readonly RegeneratedColourFrame[]>;
236
+ /** Capture the actual FINAL mount, retaining one frame, through its production input path. */
237
+ export declare function captureShellOutput(options: {
238
+ cwd: string;
239
+ runId: string;
240
+ columns: number;
241
+ rows: number;
242
+ view: "home" | "run" | "evidence";
243
+ binaryVersion?: string;
244
+ environment?: NodeJS.ProcessEnv;
245
+ stripAnsi?: boolean;
246
+ }): Promise<string>;
247
+ /** Contract-moved retirement, not UAT re-freezing: historical anchors/source bytes stay intact. */
248
+ export declare const FINAL_SHELL_RETIREMENT: {
249
+ readonly reason: "FINAL §3.1–3.3 moves the integrated shell's geometry, palette and installed keys";
250
+ readonly corrections: "Ship: flow the six Home tiles at 80x24, report measured overflow, select committed rows, and advertise only supported actions; retain original evidence";
251
+ readonly retained: readonly ["tests/fixtures/cockpit/anchors/", "tests/fixtures/cockpit/sources/", "tests/fixtures/cockpit/colour/sources/"];
252
+ readonly replacement: readonly ["home.120x40", "home.80x24", "run.120x40", "run.80x24", "evidence.120x40", "evidence.80x24"];
253
+ readonly refreeze: "pending accepted UAT; no protected bytes rewritten";
254
+ };
@@ -13,7 +13,8 @@ import { planFrame, RAIL_COLUMNS, resolveCockpitLayout, } from "./layout.js";
13
13
  import { deriveRunCockpitData, deriveRunViewRows, RunCockpitFrame, } from "./run-cockpit.js";
14
14
  import { deriveSetupCockpitData, SetupCockpitFrame, } from "./setup-cockpit.js";
15
15
  import { formatKeybarEntries, initialRunInteractionState, projectRunKeyEntries, } from "./keys.js";
16
- import { cellWidth } from "./width.js";
16
+ import { cellWidth, fitCells } from "./width.js";
17
+ import { resolveShellColourMode } from "./theme.js";
17
18
  export const WIDTH_BAND_CASES = [
18
19
  { arrangement: "stacked", columns: 80 },
19
20
  { arrangement: "folded-keys", columns: 100 },
@@ -640,3 +641,90 @@ export async function regenerateColourFrames(binaryVersion) {
640
641
  }
641
642
  return regenerated;
642
643
  }
644
+ /** Capture the actual FINAL mount, retaining one frame, through its production input path. */
645
+ export async function captureShellOutput(options) {
646
+ const { Writable } = await import("node:stream");
647
+ const { runLiveCockpit } = await import("./live.js");
648
+ let lastFrame = "";
649
+ let frameRevision = 0;
650
+ const frameListeners = new Set();
651
+ const output = new Writable({ write(chunk, _encoding, next) {
652
+ const text = String(chunk);
653
+ if (text.includes("tickmarkr") && text.includes("q Quit")) {
654
+ lastFrame = text.slice(-20000);
655
+ frameRevision += 1;
656
+ for (const listener of frameListeners)
657
+ listener();
658
+ }
659
+ next();
660
+ } });
661
+ Object.assign(output, { isTTY: true, columns: options.columns, rows: options.rows });
662
+ const input = new PassThrough();
663
+ Object.assign(input, { isTTY: true, setRawMode: () => input, ref: () => input, unref: () => input });
664
+ let delivery;
665
+ const previousColourLevel = chalk.level;
666
+ const mode = resolveShellColourMode(options.environment);
667
+ chalk.level = mode === "truecolor" ? 3 : mode === "reduced" ? 1 : 0;
668
+ const mounted = runLiveCockpit({ ...options, input, output, binaryVersion: options.binaryVersion ?? "capture", debug: true, refreshMs: 2 ** 30, onShellDelivery: d => { delivery = d; } });
669
+ const waitForFrame = (view, afterRevision = -1) => {
670
+ const expected = `| ${view.toUpperCase()} |`;
671
+ if (frameRevision > afterRevision && lastFrame.includes(expected))
672
+ return Promise.resolve();
673
+ return new Promise((resolve, reject) => {
674
+ const timeout = setTimeout(() => {
675
+ frameListeners.delete(inspect);
676
+ reject(new Error(`timed out waiting for committed ${view} capture frame`));
677
+ }, 2_000);
678
+ const inspect = () => {
679
+ if (frameRevision <= afterRevision || !lastFrame.includes(expected))
680
+ return;
681
+ clearTimeout(timeout);
682
+ frameListeners.delete(inspect);
683
+ resolve();
684
+ };
685
+ frameListeners.add(inspect);
686
+ inspect();
687
+ });
688
+ };
689
+ try {
690
+ await waitForFrame("home");
691
+ const beforeSelection = frameRevision;
692
+ delivery?.key({ input: { home: "1", run: "4", evidence: "5" }[options.view], key: {} });
693
+ await waitForFrame(options.view, beforeSelection);
694
+ }
695
+ finally {
696
+ try {
697
+ delivery?.key({ input: "q", key: {} });
698
+ await mounted;
699
+ }
700
+ finally {
701
+ input.destroy();
702
+ output.destroy();
703
+ chalk.level = previousColourLevel;
704
+ }
705
+ }
706
+ // The evidence identity remains absolute in the live UI and callbacks. A
707
+ // committed capture records the fixture role instead of its mkdtemp path,
708
+ // making regeneration byte-identical on macOS and Linux.
709
+ const portableFrame = options.stripAnsi === false
710
+ ? lastFrame
711
+ : lastFrame.replace(/Original journal [^│\n]*/gu, (line) => fitCells("Original journal <engagement>/journal.jsonl", cellWidth(line)));
712
+ // Ink drops a row's trailing padding only when no escape survives it, so the
713
+ // same frame carries padding under truecolor and none under NO_COLOR. A plain
714
+ // capture trims its own rows; otherwise its bytes encode the capturing
715
+ // machine's TERM/COLORTERM and byte-identity holds on that machine alone.
716
+ const frame = options.stripAnsi === false
717
+ ? portableFrame
718
+ : portableFrame
719
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
720
+ .replace(/[^\S\n]+$/gmu, "");
721
+ return frame.replace(/\n$/, "");
722
+ }
723
+ /** Contract-moved retirement, not UAT re-freezing: historical anchors/source bytes stay intact. */
724
+ export const FINAL_SHELL_RETIREMENT = {
725
+ reason: "FINAL §3.1–3.3 moves the integrated shell's geometry, palette and installed keys",
726
+ corrections: "Ship: flow the six Home tiles at 80x24, report measured overflow, select committed rows, and advertise only supported actions; retain original evidence",
727
+ retained: ["tests/fixtures/cockpit/anchors/", "tests/fixtures/cockpit/sources/", "tests/fixtures/cockpit/colour/sources/"],
728
+ replacement: ["home.120x40", "home.80x24", "run.120x40", "run.80x24", "evidence.120x40", "evidence.80x24"],
729
+ refreeze: "pending accepted UAT; no protected bytes rewritten",
730
+ };
@@ -1,6 +1,20 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
- import { TEXT_EMPHASIS_TOKENS } from "./theme.js";
2
+ import { type ShellColourMode, type ShellPalette, TEXT_EMPHASIS_TOKENS } from "./theme.js";
3
3
  export { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
4
+ /** The consolidated shell opts in; legacy print/capture consumers retain their presentation. */
5
+ export declare const ShellPresentation: import("react").Context<boolean>;
6
+ export declare const ShellTheme: import("react").Context<{
7
+ readonly mode: ShellColourMode;
8
+ readonly palette: ShellPalette;
9
+ }>;
10
+ export declare function ShellGridFlow({ width, columns, rowHeight, children }: {
11
+ width: number;
12
+ columns: number;
13
+ rowHeight: number;
14
+ children: ReactNode;
15
+ }): import("react").JSX.Element;
16
+ /** The shell observes the leaf's selected original identity, including removal. */
17
+ export declare const ShellJournalSelection: import("react").Context<((id: string | undefined) => void) | undefined>;
4
18
  /**
5
19
  * The hover highlight's non-colour half: one cell wide, drawn in the separator
6
20
  * the row already spends, from the block family the cockpit already draws with.
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { Children } from "react";
3
+ import { Children, createContext, useContext, useCallback, useId, useLayoutEffect, useState } from "react";
4
4
  import { GLYPHS } from "../../brand.js";
5
- import { COCKPIT_DATA_RAMP, COCKPIT_INKS, TEXT_EMPHASIS_TOKENS, } from "./theme.js";
5
+ import { COCKPIT_DATA_RAMP, COCKPIT_INKS, SHELL_PALETTE, TEXT_EMPHASIS_TOKENS, } from "./theme.js";
6
6
  import { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
7
7
  export { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
8
8
  import { formatKeybarEntries, keybarEntries, SETUP_KEY_BINDINGS, } from "./keys.js";
@@ -17,6 +17,33 @@ import { formatKeybarEntries, keybarEntries, SETUP_KEY_BINDINGS, } from "./keys.
17
17
  * no longer imported here.
18
18
  */
19
19
  import { cellWidth as stringWidth, sliceCells } from "./width.js";
20
+ /** The consolidated shell opts in; legacy print/capture consumers retain their presentation. */
21
+ export const ShellPresentation = createContext(false);
22
+ export const ShellTheme = createContext({ mode: "truecolor", palette: SHELL_PALETTE });
23
+ /** Consecutive leaf grids may share a row at the shell's narrower breakpoint.
24
+ * Registration counts children, not labels or fixture identities; the leaves
25
+ * keep supplying the actual tiles and their input controllers. */
26
+ const GridFlow = createContext(undefined);
27
+ const InlineTileTitle = createContext(0);
28
+ export function ShellGridFlow({ width, columns, rowHeight, children }) {
29
+ const [groups, setGroups] = useState([]);
30
+ const register = useCallback((id, count) => {
31
+ setGroups(old => [...old.filter(group => group.id !== id), { id, count }]);
32
+ return () => setGroups(old => old.filter(group => group.id !== id));
33
+ }, []);
34
+ return _jsx(GridFlow.Provider, { value: { width, columns, rowHeight, groups, register }, children: children });
35
+ }
36
+ /** Data visuals retain their measured ramp in truecolor and the explicit
37
+ * reduced-color mapping; legacy printed consumers keep their original ink. */
38
+ function useDataColour() {
39
+ const compact = useContext(ShellPresentation);
40
+ const shell = useContext(ShellTheme);
41
+ return (hex) => !compact || shell.mode === "truecolor"
42
+ ? hex
43
+ : shell.mode === "none" ? undefined : shell.palette.passed;
44
+ }
45
+ /** The shell observes the leaf's selected original identity, including removal. */
46
+ export const ShellJournalSelection = createContext(undefined);
20
47
  const SPARKLINE_GLYPHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
21
48
  /**
22
49
  * The hover highlight's non-colour half: one cell wide, drawn in the separator
@@ -44,8 +71,10 @@ export const KEYBAR_KEYS = {
44
71
  setupAdditional: SETUP_ADDITIONAL_KEYS,
45
72
  };
46
73
  export function BodyText({ children, emphasis = "normal", }) {
74
+ const compact = useContext(ShellPresentation);
75
+ const shell = useContext(ShellTheme);
47
76
  const token = TEXT_EMPHASIS_TOKENS[emphasis];
48
- return (_jsx(Text, { bold: token.weight === "bold", dimColor: token.dimmed, children: children }));
77
+ return (_jsx(Text, { wrap: compact ? "truncate-end" : "wrap", color: compact && shell.mode !== "none" ? shell.palette.text : undefined, bold: token.weight === "bold", dimColor: token.dimmed, children: children }));
49
78
  }
50
79
  function longestBandLine(column) {
51
80
  return Math.max(1, ...[column.title, ...column.lines].map((line) => stringWidth(line)));
@@ -194,21 +223,54 @@ function statePresentation(state) {
194
223
  }
195
224
  }
196
225
  export function StateGlyph({ state }) {
197
- const presentation = statePresentation(state);
198
- return (_jsx(Text, { color: presentation.color, dimColor: presentation.dimmed, children: presentation.glyph }));
226
+ const compact = useContext(ShellPresentation);
227
+ const shell = useContext(ShellTheme);
228
+ const presentation = compact ? {
229
+ active: { glyph: "● running", color: shell.palette.running },
230
+ inactive: { glyph: "- disabled", color: shell.palette.disabled },
231
+ pass: { glyph: "✓ passed", color: shell.palette.passed },
232
+ fail: { glyph: "✗ terminal failure", color: shell.palette.failure },
233
+ warn: { glyph: "! human park", color: shell.palette.human },
234
+ neutral: { glyph: "? unknown", color: shell.palette.unknown },
235
+ }[state] : statePresentation(state);
236
+ const glyph = (_jsx(Text, { color: compact && shell.mode === "none" ? undefined : presentation.color, dimColor: "dimmed" in presentation ? presentation.dimmed : undefined, children: presentation.glyph }));
237
+ if (!compact)
238
+ return glyph;
239
+ return (_jsx(Box, { flexShrink: 0, children: glyph }));
199
240
  }
200
241
  export function Panel({ title, focused = false, width, flexGrow, children, }) {
242
+ const compact = useContext(ShellPresentation);
243
+ const shell = useContext(ShellTheme);
244
+ const inlineTitle = useContext(InlineTileTitle);
201
245
  const content = Children.map(children, (child) => typeof child === "string" || typeof child === "number"
202
246
  ? _jsx(BodyText, { children: child })
203
247
  : child);
248
+ if (compact && inlineTitle) {
249
+ const items = Children.toArray(content);
250
+ return _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { color: shell.mode === "none" ? undefined : shell.palette.chrome, bold: true, children: [title.replace(" (historical)", ""), " "] }) }), _jsx(Box, { flexShrink: 1, overflow: "hidden", children: items[0] })] }), inlineTitle > 1 && items.slice(1)] });
251
+ }
252
+ if (compact)
253
+ return _jsxs(Box, { flexDirection: "column", flexShrink: 0, flexGrow: flexGrow, width: width, children: [_jsx(Text, { wrap: "truncate-end", color: shell.mode === "none" ? undefined : shell.palette.chrome, bold: true, children: title }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: content })] });
204
254
  return (_jsxs(Box, { flexDirection: "column", flexGrow: flexGrow, width: width, paddingX: 1, borderStyle: focused ? "double" : "round", borderColor: focused ? COCKPIT_DATA_RAMP[0].hex : COCKPIT_DATA_RAMP[3].hex, borderDimColor: !focused, children: [_jsx(Box, { children: focused ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: COCKPIT_DATA_RAMP[0].hex, children: GLYPHS.pointer }), _jsxs(BodyText, { emphasis: "strong", children: [" ", title] })] })) : (_jsx(BodyText, { emphasis: "strong", children: title })) }), _jsx(Box, { flexDirection: "column", children: content })] }));
205
255
  }
206
256
  export function CockpitGrid({ children, columns, columnContents, allocation, }) {
207
257
  const items = Children.toArray(children);
258
+ const flow = useContext(GridFlow);
259
+ const id = useId();
260
+ const register = flow?.register;
261
+ useLayoutEffect(() => register?.(id, items.length), [register, id, items.length]);
208
262
  const widths = columns !== undefined
209
263
  && columnContents?.length === items.length
210
264
  ? allocateBandColumns(columns, columnContents, allocation)
211
265
  : undefined;
266
+ if (flow) {
267
+ const groupIndex = flow.groups.findIndex(group => group.id === id);
268
+ const start = flow.groups.slice(0, Math.max(0, groupIndex)).reduce((sum, group) => sum + group.count, 0);
269
+ const firstRow = Math.floor(start / flow.columns);
270
+ const tileWidth = Math.floor((flow.width - flow.columns + 1) / flow.columns);
271
+ const height = (Math.ceil((start + items.length) / flow.columns) - firstRow) * flow.rowHeight;
272
+ return _jsx(Box, { width: flow.width, height: height, flexShrink: 0, marginTop: start % flow.columns ? -flow.rowHeight : 0, children: _jsx(InlineTileTitle.Provider, { value: flow.columns < 3 || flow.rowHeight === 1 ? flow.rowHeight : 0, children: items.map((child, index) => _jsx(Box, { position: "absolute", marginLeft: ((start + index) % flow.columns) * (tileWidth + 1), marginTop: (Math.floor((start + index) / flow.columns) - firstRow) * flow.rowHeight, width: tileWidth, height: flow.rowHeight, overflow: "hidden", flexDirection: "column", children: child }, index)) }) });
273
+ }
212
274
  return (_jsx(Box, { flexDirection: "row", flexWrap: "nowrap", gap: BAND_GAP_COLUMNS, width: columns, children: items.map((child, index) => (_jsx(Box, { flexDirection: widths ? "column" : undefined, flexGrow: widths ? 0 : 1, flexShrink: widths ? 0 : 1, flexBasis: widths ? undefined : 0, width: widths?.[index], children: child }, index))) }));
213
275
  }
214
276
  function sparklineLevel(value, minimum, maximum) {
@@ -217,6 +279,7 @@ function sparklineLevel(value, minimum, maximum) {
217
279
  return Math.round(((value - minimum) / (maximum - minimum)) * (SPARKLINE_GLYPHS.length - 1));
218
280
  }
219
281
  export function Sparkline({ samples, }) {
282
+ const dataColour = useDataColour();
220
283
  const buckets = samples
221
284
  .slice(-SPARKLINE_BUCKET_WINDOW)
222
285
  .map((sample) => typeof sample === "number" && Number.isFinite(sample) ? sample : null);
@@ -229,20 +292,21 @@ export function Sparkline({ samples, }) {
229
292
  const level = sparklineLevel(sample, minimum, maximum);
230
293
  const rampIndex = COCKPIT_DATA_RAMP.length - 1 - Math.round((level / (SPARKLINE_GLYPHS.length - 1)) * (COCKPIT_DATA_RAMP.length - 1));
231
294
  const ink = COCKPIT_DATA_RAMP[rampIndex] ?? COCKPIT_DATA_RAMP[0];
232
- return (_jsx(Text, { color: ink.hex, children: SPARKLINE_GLYPHS[level] }, `${index}:${sample}`));
295
+ return (_jsx(Text, { color: dataColour(ink.hex), children: SPARKLINE_GLYPHS[level] }, `${index}:${sample}`));
233
296
  }) }));
234
297
  }
235
298
  export function StatTile({ label, value, samples, focused, width, }) {
236
299
  return (_jsxs(Panel, { title: label, focused: focused, width: width, flexGrow: 1, children: [_jsx(BodyText, { emphasis: "strong", children: value }), _jsx(Sparkline, { samples: samples })] }));
237
300
  }
238
301
  export function ProgressMeter({ value, width = 24, }) {
302
+ const dataColour = useDataColour();
239
303
  const safeWidth = Math.max(1, Math.floor(width));
240
304
  const percentage = Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
241
305
  const filled = Math.round((percentage / 100) * safeWidth);
242
306
  const valueLabel = Number.isInteger(percentage)
243
307
  ? percentage.toFixed(0)
244
308
  : percentage.toFixed(1);
245
- return (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", "aria-role": "progressbar", "aria-label": `${valueLabel}% complete`, children: [_jsx(Text, { color: COCKPIT_DATA_RAMP[0].hex, children: "█".repeat(filled) }), _jsx(Text, { dimColor: true, children: "░".repeat(safeWidth - filled) }), _jsxs(BodyText, { emphasis: "strong", children: [" ", valueLabel, "%"] })] }));
309
+ return (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", "aria-role": "progressbar", "aria-label": `${valueLabel}% complete`, children: [_jsx(Text, { color: dataColour(COCKPIT_DATA_RAMP[0].hex), children: "█".repeat(filled) }), _jsx(Text, { dimColor: true, children: "░".repeat(safeWidth - filled) }), _jsxs(BodyText, { emphasis: "strong", children: [" ", valueLabel, "%"] })] }));
246
310
  }
247
311
  function OneLineItems({ children, width, label, }) {
248
312
  return (_jsx(Box, { flexDirection: "row", flexWrap: "nowrap", width: width, height: 1, overflow: "hidden", "aria-label": label, children: children }));
@@ -284,7 +348,13 @@ export function StatusStrip({ items, width, }) {
284
348
  return (_jsx(OneLineItems, { width: width, label: "cockpit status", children: items.map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(StateGlyph, { state: item.state }), _jsxs(BodyText, { children: [" ", item.text] })] }, `${item.state}:${item.text}`))) }));
285
349
  }
286
350
  export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", selection, hover, }) {
287
- return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { emphasis: index === hover ? "strong" : "normal", children: index === hover ? HOVER_MARKER : " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })), index === hover
288
- ? (_jsx(Text, { inverse: true, color: COCKPIT_DATA_RAMP[0].hex, children: row.text }))
351
+ const dataColour = useDataColour();
352
+ const reportSelection = useContext(ShellJournalSelection);
353
+ const compact = useContext(ShellPresentation);
354
+ const selectedId = selection === undefined ? undefined : rows[selection]?.id;
355
+ useLayoutEffect(() => { reportSelection?.(selectedId); }, [selectedId, reportSelection]);
356
+ return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { ref: compact ? node => { if (node)
357
+ node.attributes.shellEvidence = row.id; } : undefined, flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { emphasis: index === hover ? "strong" : "normal", children: index === hover ? HOVER_MARKER : " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })), index === hover
358
+ ? (_jsx(Text, { inverse: true, color: dataColour(COCKPIT_DATA_RAMP[0].hex), children: row.text }))
289
359
  : _jsx(BodyText, { children: row.text })] }, row.id))) }));
290
360
  }