tickmarkr 2.4.2 → 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.
- package/README.md +110 -24
- package/dist/cli/commands/approve.d.ts +42 -0
- package/dist/cli/commands/approve.js +80 -13
- package/dist/cli/commands/doctor.d.ts +234 -0
- package/dist/cli/commands/doctor.js +139 -5
- package/dist/cli/commands/report.js +15 -44
- package/dist/cli/commands/scope.js +36 -6
- package/dist/cli/commands/stats.d.ts +2 -0
- package/dist/cli/commands/stats.js +42 -23
- package/dist/cli/commands/status.js +20 -4
- package/dist/cli/commands/ui.js +42 -53
- package/dist/cli/commands/unlock.d.ts +1 -1
- package/dist/cli/commands/unlock.js +59 -9
- package/dist/cli/help.d.ts +219 -0
- package/dist/cli/help.js +212 -0
- package/dist/cli/index.d.ts +42 -2
- package/dist/cli/index.js +23 -8
- package/dist/drivers/herdr.d.ts +7 -2
- package/dist/drivers/herdr.js +78 -48
- package/dist/drivers/orca.d.ts +3 -2
- package/dist/drivers/orca.js +43 -4
- package/dist/drivers/subprocess.d.ts +1 -0
- package/dist/drivers/subprocess.js +3 -0
- package/dist/drivers/types.d.ts +14 -0
- package/dist/gates/artifact-manifest.d.ts +50 -0
- package/dist/gates/artifact-manifest.js +23 -0
- package/dist/plan/scope.d.ts +25 -0
- package/dist/plan/scope.js +92 -12
- package/dist/report/operator-record.d.ts +49 -0
- package/dist/report/operator-record.js +137 -0
- package/dist/run/daemon.d.ts +3 -4
- package/dist/run/daemon.js +18 -10
- package/dist/run/lock.d.ts +59 -2
- package/dist/run/lock.js +184 -26
- package/dist/run/operator-state.d.ts +86 -0
- package/dist/run/operator-state.js +165 -0
- package/dist/run/supervision.d.ts +32 -0
- package/dist/run/supervision.js +138 -17
- package/dist/tui/cockpit/capture.d.ts +19 -0
- package/dist/tui/cockpit/capture.js +89 -1
- package/dist/tui/cockpit/components.d.ts +15 -1
- package/dist/tui/cockpit/components.js +79 -9
- package/dist/tui/cockpit/decision-actions.d.ts +147 -0
- package/dist/tui/cockpit/decision-actions.js +315 -0
- package/dist/tui/cockpit/derive.d.ts +1 -1
- package/dist/tui/cockpit/derive.js +2 -0
- package/dist/tui/cockpit/evidence-view.d.ts +119 -0
- package/dist/tui/cockpit/evidence-view.js +210 -0
- package/dist/tui/cockpit/home-view.d.ts +88 -0
- package/dist/tui/cockpit/home-view.js +240 -0
- package/dist/tui/cockpit/keys.d.ts +125 -0
- package/dist/tui/cockpit/keys.js +31 -0
- package/dist/tui/cockpit/layout.d.ts +14 -0
- package/dist/tui/cockpit/layout.js +15 -0
- package/dist/tui/cockpit/live-runtime.d.ts +46 -0
- package/dist/tui/cockpit/live-runtime.js +683 -0
- package/dist/tui/cockpit/live-store.d.ts +289 -0
- package/dist/tui/cockpit/live-store.js +308 -0
- package/dist/tui/cockpit/live.d.ts +21 -1
- package/dist/tui/cockpit/live.js +12 -1
- package/dist/tui/cockpit/run-view.d.ts +100 -0
- package/dist/tui/cockpit/run-view.js +202 -0
- package/dist/tui/cockpit/shell.d.ts +50 -0
- package/dist/tui/cockpit/shell.js +74 -0
- package/dist/tui/cockpit/theme.d.ts +27 -0
- package/dist/tui/cockpit/theme.js +21 -0
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +10 -1
- package/skills/tickmarkr-loop/SKILL.md +72 -1
- package/skills/tickmarkr-overseer/SKILL.md +12 -0
package/dist/run/supervision.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
287
|
+
rmSync(presence, { force: true });
|
|
288
|
+
released = !existsSync(presence);
|
|
276
289
|
}
|
|
277
|
-
catch { /* ages out
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
}
|