tickmarkr 1.96.0 → 2.0.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/dist/brand.d.ts +31 -0
- package/dist/brand.js +44 -1
- package/dist/cli/commands/compile.js +32 -1
- package/dist/cli/commands/resume.js +9 -3
- package/dist/cli/commands/run.d.ts +61 -1
- package/dist/cli/commands/run.js +368 -17
- package/dist/cli/commands/status.js +303 -145
- package/dist/compile/collateral.d.ts +25 -0
- package/dist/compile/collateral.js +46 -11
- package/dist/compile/native.js +10 -0
- package/dist/drivers/herdr.d.ts +19 -13
- package/dist/drivers/herdr.js +88 -26
- package/dist/drivers/types.d.ts +2 -0
- package/dist/gates/acceptance.js +17 -7
- package/dist/gates/llm.d.ts +19 -0
- package/dist/gates/llm.js +104 -6
- package/dist/gates/run-gates.d.ts +18 -0
- package/dist/gates/run-gates.js +195 -29
- package/dist/gates/scope.d.ts +9 -1
- package/dist/gates/scope.js +22 -2
- package/dist/graph/graph.d.ts +1 -0
- package/dist/graph/graph.js +19 -2
- package/dist/report/compare.js +17 -2
- package/dist/run/daemon.d.ts +1 -8
- package/dist/run/daemon.js +231 -246
- package/dist/run/environment.d.ts +18 -1
- package/dist/run/environment.js +19 -2
- package/dist/run/journal.d.ts +50 -3
- package/dist/run/journal.js +181 -5
- package/dist/run/protocol.d.ts +4 -4
- package/dist/run/stall.d.ts +30 -0
- package/dist/run/stall.js +173 -0
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +20 -15
- package/skills/tickmarkr-overseer/scripts/seat-send.sh +98 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { GLYPHS, LIVE } from "../../brand.js";
|
|
4
4
|
import { DEFAULT_CONFIG, loadConfig } from "../../config/config.js";
|
|
5
5
|
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
6
6
|
import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
|
|
@@ -13,10 +13,44 @@ import { desiredPanes } from "../../run/reconcile.js";
|
|
|
13
13
|
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
14
14
|
import { readSupervision, supervisionText } from "../../run/supervision.js";
|
|
15
15
|
import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
|
|
16
|
-
import { COCKPIT_COLUMN_FLOOR
|
|
16
|
+
import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
|
|
17
17
|
import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
import { version } from "./version.js";
|
|
19
|
+
// The board is a LIVE surface, so it draws through brand.ts's operator role map and never the vivid
|
|
20
|
+
// one-shot tokens. Semantic aliases can share a colour, but every board cell, gate mark, effort bar,
|
|
21
|
+
// header and footer styles through one of the five exported authorities.
|
|
22
|
+
const ok = LIVE.pass;
|
|
23
|
+
const running = LIVE.running;
|
|
24
|
+
const information = LIVE.information;
|
|
25
|
+
const fail = LIVE.failure;
|
|
26
|
+
const warn = LIVE.attention;
|
|
27
|
+
const dim = LIVE.secondaryText;
|
|
28
|
+
const title = LIVE.primaryText;
|
|
29
|
+
const legend = LIVE.secondaryText;
|
|
30
|
+
const rule = (columns) => LIVE.chrome("─".repeat(columns));
|
|
31
|
+
// Two clocks, deliberately unequal. The journal frame is cheap — a single file read and a fold — so
|
|
32
|
+
// it redraws on a 500ms cadence and the board feels live. Scraping a worker's pane is neither cheap
|
|
33
|
+
// nor bounded: it crosses the herdr socket and can hang on a wedged pane, so it keeps its own 2s
|
|
34
|
+
// budget and runs OFF the redraw path (see scheduleWorkerScrape). A slow scrape updates liveness
|
|
35
|
+
// one frame late; it never delays a frame, and it never overlaps itself.
|
|
36
|
+
// ponytail: fixed cadences; promote to config.visibility.* only when an operator asks.
|
|
37
|
+
const FRAME_MS = 500;
|
|
38
|
+
const SCRAPE_MS = 2000;
|
|
39
|
+
// The binary's own version, read ONCE when status starts (see `status`), then carried into every
|
|
40
|
+
// frame: a board redrawing twice a second must not pay a manifest read per frame, and the version
|
|
41
|
+
// it names must not change under the operator mid-run. It reads through the SAME manifest reader
|
|
42
|
+
// `tickmarkr version` prints from — one version answer for the whole CLI — and a manifest that
|
|
43
|
+
// cannot be read, cannot be parsed, or names no version PROPAGATES rather than resolving to a
|
|
44
|
+
// placeholder: a board that cannot name its own binary is a broken installation, and the board's
|
|
45
|
+
// whole job is to state what is true. A rendered `vunknown` would be a claim about the binary
|
|
46
|
+
// nobody could check.
|
|
47
|
+
const readBinaryVersion = async () => {
|
|
48
|
+
const semver = await version();
|
|
49
|
+
if (typeof semver !== "string" || semver === "") {
|
|
50
|
+
throw new Error("package.json names no version — reinstall tickmarkr");
|
|
51
|
+
}
|
|
52
|
+
return semver;
|
|
53
|
+
};
|
|
20
54
|
// The claim leads, the guidance follows: an incomparable journal is named in three words a wrapped
|
|
21
55
|
// board keeps on one row, and the remedy trails it. Both surfaces state the claim; nothing derived
|
|
22
56
|
// from such a journal — task states, gate outcomes, tip verification — may be presented as this
|
|
@@ -368,7 +402,7 @@ const gateSnapshot = (task, events, rehashAt) => {
|
|
|
368
402
|
};
|
|
369
403
|
};
|
|
370
404
|
export const gateStates = (task, events) => gateSnapshot(task, events).states;
|
|
371
|
-
//
|
|
405
|
+
// Verdict semantics only: pass uses brand teal, failure uses amethyst, and skip/open use chrome.
|
|
372
406
|
const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
|
|
373
407
|
// TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
|
|
374
408
|
// legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
|
|
@@ -376,11 +410,6 @@ export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicod
|
|
|
376
410
|
? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
|
|
377
411
|
: `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
|
|
378
412
|
export const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
379
|
-
// plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
|
|
380
|
-
const failedSuffix = (states) => {
|
|
381
|
-
const f = failedGates(states);
|
|
382
|
-
return f.length ? ` · ${f.join(", ")}` : "";
|
|
383
|
-
};
|
|
384
413
|
// a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
|
|
385
414
|
// awaited approval is named from task state alone — never from gate results. Failed gates win the
|
|
386
415
|
// cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
|
|
@@ -401,19 +430,46 @@ export const shortGoal = (goal, max) => {
|
|
|
401
430
|
};
|
|
402
431
|
/** Columns the machine row always spends on the task title, however long its machinery segment runs. */
|
|
403
432
|
const MACHINE_TITLE_FLOOR = 24;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
433
|
+
// The approved task board derives an area from files[] rather than adding another graph field.
|
|
434
|
+
// Known tickmarkr domains keep their reviewed names; other repositories fall back to their first
|
|
435
|
+
// meaningful path segment so the shipped surface never turns a foreign project into UNMAPPED rows.
|
|
436
|
+
const TASK_AREA_RULES = [
|
|
437
|
+
["SPEC", [/^src\/(compile|graph|plan)\//u]],
|
|
438
|
+
["FLEET", [/^src\/(route|config|adapters)\//u]],
|
|
439
|
+
["RUN", [/^src\/(run|drivers|eval)\//u]],
|
|
440
|
+
["GATE", [/^src\/gates\//u]],
|
|
441
|
+
["UI", [/^src\/(cli|tui|report)\//u, /^src\/brand\.ts/u]],
|
|
442
|
+
["DOCS", [/^(docs|skills)\//u]],
|
|
443
|
+
["REPO", [/^(scripts|schema)\//u, /^\.github\//u, /^tests\/(lint|repo|helpers)\//u, /^(vitest\.config|package\.json|tsconfig)/u]],
|
|
444
|
+
];
|
|
445
|
+
const expandAreaBraces = (path) => {
|
|
446
|
+
const match = /^(.*?)\{([^}]*)\}(.*)$/u.exec(path);
|
|
447
|
+
return match
|
|
448
|
+
? match[2].split(",").flatMap((part) => expandAreaBraces(`${match[1]}${part.trim()}${match[3]}`))
|
|
449
|
+
: [path];
|
|
450
|
+
};
|
|
451
|
+
const areaForPath = (path) => {
|
|
452
|
+
const candidates = [path, path.replace(/^tests\//u, "src/")];
|
|
453
|
+
for (const candidate of candidates) {
|
|
454
|
+
for (const [name, patterns] of TASK_AREA_RULES) {
|
|
455
|
+
if (patterns.some((pattern) => pattern.test(candidate)))
|
|
456
|
+
return name;
|
|
457
|
+
}
|
|
415
458
|
}
|
|
416
|
-
|
|
459
|
+
const parts = path.replace(/^[!./]+/u, "").split("/").filter((part) => part && !/[*?[\]]/u.test(part));
|
|
460
|
+
const root = parts[0];
|
|
461
|
+
if (!root)
|
|
462
|
+
return "UNMAPPED";
|
|
463
|
+
const domain = ["src", "app", "packages", "tests"].includes(root) && parts[1] ? parts[1] : root;
|
|
464
|
+
return domain.replace(/\.[^.]+$/u, "").toUpperCase();
|
|
465
|
+
};
|
|
466
|
+
const taskArea = (task) => {
|
|
467
|
+
const paths = task.files.flatMap(expandAreaBraces);
|
|
468
|
+
if (paths.length === 0)
|
|
469
|
+
return "—";
|
|
470
|
+
const primary = paths.filter((path) => !path.startsWith("tests/") && !path.startsWith("src/cli/"));
|
|
471
|
+
const source = primary.length ? primary : paths;
|
|
472
|
+
return [...new Set(source.map(areaForPath))].join("+");
|
|
417
473
|
};
|
|
418
474
|
/**
|
|
419
475
|
* Every board line leaves through the cockpit's own wrap, measured in display cells: no drawn line
|
|
@@ -471,6 +527,10 @@ const foldTaskEffort = (tasks, events) => {
|
|
|
471
527
|
return [...effort.values()].sort((left, right) => right.total - left.total
|
|
472
528
|
|| order.get(left.taskId) - order.get(right.taskId));
|
|
473
529
|
};
|
|
530
|
+
// Attention (review) and failure (park) wear the ONE amethyst, so the bar segments and their legend
|
|
531
|
+
// markers carry the distinction in SHAPE: a full block for dispatch, light shade for a review round,
|
|
532
|
+
// dark shade for a human park. Same display width, so the fitted bar keeps its measured columns.
|
|
533
|
+
const EFFORT_GLYPH = { dispatch: "\u2588", review: "\u2592", park: "\u2593" };
|
|
474
534
|
/**
|
|
475
535
|
* Prototype panel, fitted by the cockpit's display-cell authority before board-wide wrapping.
|
|
476
536
|
*
|
|
@@ -482,7 +542,7 @@ const foldTaskEffort = (tasks, events) => {
|
|
|
482
542
|
const effortPanel = (tasks, events, columns) => {
|
|
483
543
|
if (!events) {
|
|
484
544
|
return [
|
|
485
|
-
` ${title("WHERE THE EFFORT WENT")}`,
|
|
545
|
+
` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
|
|
486
546
|
legend(` dispatch · review · park counts unavailable — ${NOT_COMPARABLE_CLAIM}`),
|
|
487
547
|
];
|
|
488
548
|
}
|
|
@@ -496,18 +556,18 @@ const effortPanel = (tasks, events, columns) => {
|
|
|
496
556
|
const dispatchEnd = Math.round((task.dispatches / maxTotal) * barColumns);
|
|
497
557
|
const reviewEnd = Math.round(((task.dispatches + task.reviews) / maxTotal) * barColumns);
|
|
498
558
|
const parkEnd = Math.round((task.total / maxTotal) * barColumns);
|
|
499
|
-
const stack = ok(
|
|
500
|
-
+ warn(
|
|
501
|
-
+ fail(
|
|
559
|
+
const stack = ok(EFFORT_GLYPH.dispatch.repeat(dispatchEnd))
|
|
560
|
+
+ warn(EFFORT_GLYPH.review.repeat(Math.max(0, reviewEnd - dispatchEnd)))
|
|
561
|
+
+ fail(EFFORT_GLYPH.park.repeat(Math.max(0, parkEnd - reviewEnd)));
|
|
502
562
|
return `${prefix}${fitCells(stack, barColumns)} ${counts}`;
|
|
503
563
|
});
|
|
504
564
|
return [
|
|
505
|
-
` ${title("WHERE THE EFFORT WENT")}`,
|
|
565
|
+
` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
|
|
506
566
|
legend(` dispatches · review rounds · human parks · top ${top.length} of ${tasks.length} tasks`),
|
|
507
567
|
"",
|
|
508
568
|
...rows,
|
|
509
569
|
"",
|
|
510
|
-
` ${ok(
|
|
570
|
+
` ${ok(EFFORT_GLYPH.dispatch)} ${dim("dispatch")} ${warn(EFFORT_GLYPH.review)} ${dim("review round")} ${fail(EFFORT_GLYPH.park)} ${dim("human park")}`,
|
|
511
571
|
];
|
|
512
572
|
};
|
|
513
573
|
// VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
|
|
@@ -807,7 +867,10 @@ const recordedTasks = (graph, rows) => graph.tasks.filter((task) => {
|
|
|
807
867
|
|| row.parkKind !== undefined);
|
|
808
868
|
});
|
|
809
869
|
const recordedDone = (tasks, rows) => tasks.filter((task) => graphTaskStatus(rows.get(task.id)?.state, task.status) === "done").length;
|
|
810
|
-
const renderFrame = (cwd,
|
|
870
|
+
const renderFrame = (cwd,
|
|
871
|
+
// No default: the version is the binary's, resolved once by its caller, and a frame that had to
|
|
872
|
+
// invent one would be naming a version nobody can check.
|
|
873
|
+
binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
|
|
811
874
|
const g = loadGraph(cwd);
|
|
812
875
|
const record = readRunRecord(cwd, g, namedRunId);
|
|
813
876
|
const runId = record?.runId;
|
|
@@ -945,129 +1008,198 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
|
|
|
945
1008
|
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
946
1009
|
};
|
|
947
1010
|
}
|
|
948
|
-
// TTY:
|
|
949
|
-
//
|
|
950
|
-
// rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
|
|
951
|
-
// padEnd math)
|
|
952
|
-
// The cockpit owns this board's column contract: `COCKPIT_COLUMN_FLOOR` is the narrowest board it
|
|
953
|
-
// plans, so below it the terminal — not a second floor stated here — decides what happens to the
|
|
954
|
-
// drawn rows. Every column figure below is derived from this one.
|
|
1011
|
+
// TTY: the operator-approved task table. The daemon gives this surface a ~110-column pane first;
|
|
1012
|
+
// narrower terminals keep the same facts in stacked rows. Every cut/wrap goes through width.ts.
|
|
955
1013
|
const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
|
|
956
1014
|
const dot = dim(" · ");
|
|
957
|
-
const
|
|
958
|
-
const gaugeCells = 10;
|
|
959
|
-
const fill = total ? Math.round((done / total) * gaugeCells) : 0;
|
|
1015
|
+
const separator = ` ${dim("│")} `;
|
|
960
1016
|
const tipFailed = tipPhase?.state === "failed";
|
|
1017
|
+
const anyFailed = cells.some((cell) => cell.redTier);
|
|
961
1018
|
const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
|
|
962
|
-
|
|
1019
|
+
// Attention and failure wear the ONE amethyst here too, so the header leads with its own glyph:
|
|
1020
|
+
// ✗ once a verdict is in, ! while the tip is still unverified. Colour cannot carry this.
|
|
1021
|
+
const progressGlyph = anyFailed || tipFailed ? GLYPHS.fail : GLYPHS.attention;
|
|
1022
|
+
const tally = tipPhase
|
|
1023
|
+
? `${progressTone(`${progressGlyph} ${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
|
|
1024
|
+
: done === total && total > 0 ? ok(`${done}/${total} done`) : information(`${done}/${total} done`);
|
|
963
1025
|
const live = liveness(events, now)
|
|
964
|
-
.replace(/\bdead\b
|
|
965
|
-
.replace(/\bfinished\b
|
|
966
|
-
.replace(/\balive\b
|
|
1026
|
+
.replace(/\bdead\b/u, fail("dead"))
|
|
1027
|
+
.replace(/\bfinished\b/u, dim("finished"))
|
|
1028
|
+
.replace(/\balive\b/u, running("alive"))
|
|
967
1029
|
.replaceAll(" · ", dot);
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
// segments on the row an operator reads first; nothing is dropped to get there.
|
|
974
|
-
const header = ` ${[
|
|
975
|
-
title(runId ? `run ${runId}` : "tickmarkr"),
|
|
976
|
-
...(runId ? [] : ["no runs yet"]),
|
|
1030
|
+
const facts = [
|
|
1031
|
+
title(runId ?? "no runs yet"),
|
|
1032
|
+
tally,
|
|
1033
|
+
...(events.length ? [`${events.filter((event) => event.event === "run-resume").length} restarts`] : []),
|
|
1034
|
+
...(events.length ? [`${events.filter((event) => event.event === "escalation").length} escalations`] : []),
|
|
977
1035
|
...(runId && supersededBy ? [warn(`superseded by ${supersededBy}`)] : []),
|
|
978
1036
|
...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
|
|
979
1037
|
...(verify === undefined
|
|
980
1038
|
? []
|
|
981
|
-
: [verify === "verify passed"
|
|
982
|
-
|
|
1039
|
+
: [verify === "verify passed"
|
|
1040
|
+
? ok(verify)
|
|
1041
|
+
: tipFailed ? fail(`${GLYPHS.fail} ${verify}`) : warn(`${GLYPHS.attention} ${verify}`)]),
|
|
983
1042
|
...(runId ? [live] : []),
|
|
984
1043
|
...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
|
|
985
|
-
].join(
|
|
986
|
-
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
1044
|
+
].join(separator);
|
|
1045
|
+
// The brand lockup is two rows and stays two rows at every width: the muted chip, and the running
|
|
1046
|
+
// binary's version directly beneath it. The run's own facts continue to the right of both rows and
|
|
1047
|
+
// wrap under themselves — the version can never be pushed off its row by a fact that grew, which
|
|
1048
|
+
// is the whole point of wrapping the facts against their own column rather than the board's.
|
|
1049
|
+
const chipText = " tickmarkr ";
|
|
1050
|
+
const versionText = ` v${binaryVersion}`;
|
|
1051
|
+
const chipCells = cellWidth(chipText);
|
|
1052
|
+
const versionCells = cellWidth(versionText);
|
|
1053
|
+
const lockupCells = Math.max(chipCells, versionCells);
|
|
1054
|
+
const lockupGap = " ";
|
|
1055
|
+
const lockupGapCells = cellWidth(lockupGap);
|
|
1056
|
+
const factColumns = Math.max(1, boardColumns - lockupCells - lockupGapCells);
|
|
1057
|
+
// No continuation prefix: the lockup gutter below already indents every fact row, so the fact
|
|
1058
|
+
// column starts at the same cell on all of them.
|
|
1059
|
+
const factLines = wrapCells(facts, factColumns);
|
|
1060
|
+
const besideLockup = (lead, factLine) => factLine === undefined ? lead : `${lead}${lockupGap}${factLine}`;
|
|
1061
|
+
const fillLockup = (lead, cells) => `${lead}${" ".repeat(lockupCells - cells)}`;
|
|
1062
|
+
const headerRows = [
|
|
1063
|
+
besideLockup(fillLockup(LIVE.chip(chipText), chipCells), factLines[0]),
|
|
1064
|
+
besideLockup(fillLockup(dim(versionText), versionCells), factLines[1]),
|
|
1065
|
+
...factLines.slice(2).map((line) => `${" ".repeat(lockupCells + lockupGapCells)}${line}`),
|
|
1066
|
+
];
|
|
990
1067
|
const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
|
|
991
|
-
const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
|
|
992
|
-
// Every known tier, beaten or not: a tier missing from this line would read as a healthy one.
|
|
993
1068
|
const supervisionLegend = legend(` ${supervisionText(supervision)}`);
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const avail = Math.max(8, boardColumns - (5 + idW) - 2 - stW);
|
|
1008
|
-
const titles = cells.map((c) => shortGoal(c.t.title, avail));
|
|
1009
|
-
const titleW = Math.max(8, ...titles.map(cellWidth));
|
|
1010
|
-
// Line 2 starts under the title column — but never past what the board can pay for: an id near
|
|
1011
|
-
// the schema's 64-character maximum would otherwise indent the machinery clean off a narrow row.
|
|
1012
|
-
const chainCells = cellWidth(gateChain(GATE_NAMES.map(() => "open"), true));
|
|
1013
|
-
const indent = " ".repeat(Math.max(0, Math.min(idW + 5, boardColumns - chainCells - 2)));
|
|
1014
|
-
const rows = cells.map((c, i) => {
|
|
1015
|
-
const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase, pane } = c;
|
|
1016
|
-
const word = statusWord(c);
|
|
1017
|
-
// The alarm reads the SAME evidence the detail phrase names — a worktree delta the daemon proved
|
|
1018
|
-
// counts, or the card would flag a worker it just called alive one line below (OBS-538 review).
|
|
1019
|
-
const staleWorker = livePhase?.phase === "worker"
|
|
1020
|
-
&& workerSilenceMs(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
|
|
1021
|
-
const stWord = staleWorker ? warn(word) : merged ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
|
|
1022
|
-
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
1023
|
-
const f = failedGates(states);
|
|
1024
|
-
const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
|
|
1025
|
-
const statusCell = stWord +
|
|
1026
|
-
(isStarved ? dot + fail("starved") : "") +
|
|
1027
|
-
(f.length ? dot + fail(f.join(", ")) : "") +
|
|
1028
|
-
(human ? dot + warn("awaiting approval") : "");
|
|
1029
|
-
const taskTitle = titles[i];
|
|
1030
|
-
const taskLabel = `${t.id.padEnd(idW)} ${taskTitle}${" ".repeat(titleW - cellWidth(taskTitle))} ${statusCell}`;
|
|
1031
|
-
const line1 = livePhase
|
|
1032
|
-
? ` ${(staleWorker ? warn : dim)(SPINNER[animationFrame % SPINNER.length])} ${taskLabel}`
|
|
1033
|
-
: ` ${statusRow(taskVerdict(c), taskLabel)}`;
|
|
1034
|
-
// activity already names its channel for in-flight attempts — never repeat it
|
|
1035
|
-
const chain = gateChain(states, true);
|
|
1036
|
-
// A finished task blocks nobody — the same journal-folded done-ness the map was built on.
|
|
1069
|
+
const taskSectionSummary = `${done} of ${total} merged · rows in graph order · gates left→right in pipeline order`;
|
|
1070
|
+
const taskSection = ` ${ok("▌")} ${title("TASKS")} ${dim(taskSectionSummary)}`;
|
|
1071
|
+
const effortByTask = new Map(foldTaskEffort(g.tasks, record && !comparable ? [] : events)
|
|
1072
|
+
.map((task) => [task.taskId, task]));
|
|
1073
|
+
const gateMatrix = (states) => states.map((state) => {
|
|
1074
|
+
const glyph = gateBox(state, true);
|
|
1075
|
+
const tone = state === "pass" ? ok : state === "fail" ? fail : dim;
|
|
1076
|
+
return `${tone(glyph)} `;
|
|
1077
|
+
}).join("");
|
|
1078
|
+
const gateHeader = GATE_NAMES.map((gate) => fitCells(gate.slice(0, 2), 3)).join("");
|
|
1079
|
+
const noteFor = (cell) => {
|
|
1080
|
+
const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane } = cell;
|
|
1081
|
+
const effort = effortByTask.get(t.id);
|
|
1037
1082
|
const blocking = graphTaskStatus(st, t.status) === "done" ? [] : dependents.get(t.id) ?? [];
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1083
|
+
const failed = failedGates(states);
|
|
1084
|
+
const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
|
|
1085
|
+
return [
|
|
1086
|
+
livePhase ? `${SPINNER[animationFrame % SPINNER.length]} ${phrase ?? "running"}` : "",
|
|
1087
|
+
!livePhase && isStarved ? "starved" : "",
|
|
1088
|
+
// Attention and failure wear the ONE amethyst, so the tier word leads with its own glyph —
|
|
1089
|
+
// ! for warn, ✗ for failed. That is the distinction that survives NO_COLOR and a pipe.
|
|
1090
|
+
!livePhase && !isStarved && !merged
|
|
1091
|
+
? redTier
|
|
1092
|
+
? `${GLYPHS.fail} failed`
|
|
1093
|
+
: st === "failed" || st === "human"
|
|
1094
|
+
? `${GLYPHS.attention} ${phrase ?? (st === "failed" ? "warn" : surfaceStatusWord(st))}`
|
|
1095
|
+
: phrase ?? surfaceStatusWord(st)
|
|
1096
|
+
: "",
|
|
1097
|
+
redTier && phrase ? phrase : "",
|
|
1098
|
+
priorGraph ? PRIOR_GRAPH_MARKER : "",
|
|
1099
|
+
failed.length ? `failed · ${failed.join(", ")}` : "",
|
|
1100
|
+
human ? "awaiting approval" : "",
|
|
1101
|
+
!merged && failureKind && !phrase?.includes(failureKind) ? failureKind : "",
|
|
1102
|
+
(effort?.parks ?? 0) >= 3 ? `parked ×${effort.parks}` : "",
|
|
1103
|
+
(effort?.reviews ?? 0) > 2 ? `${effort.reviews} review rounds` : "",
|
|
1104
|
+
blocking.length ? `blocks ${blocking.join(", ")}` : "",
|
|
1105
|
+
pane ? `pane ${pane}` : "",
|
|
1106
|
+
ctx !== undefined ? `ctx ${ctx}` : "",
|
|
1107
|
+
].filter(Boolean).join(" · ");
|
|
1108
|
+
};
|
|
1109
|
+
const staleWorker = (cell) => cell.livePhase?.phase === "worker"
|
|
1110
|
+
&& workerSilenceMs(cell.livePhase, now, workerLiveness.get(cell.t.id)) >= 60_000;
|
|
1111
|
+
const tableRows = () => {
|
|
1112
|
+
const idWidth = Math.min(8, Math.max(5, ...cells.map((cell) => cellWidth(cell.t.id))));
|
|
1113
|
+
const areaWidth = 16;
|
|
1114
|
+
const depsWidth = 14;
|
|
1115
|
+
const gatesWidth = GATE_NAMES.length * 3;
|
|
1116
|
+
const channelWidth = boardColumns >= 140 ? 22 : 12;
|
|
1117
|
+
const attemptWidth = 5;
|
|
1118
|
+
const noteWidth = boardColumns >= 140 ? 24 : boardColumns >= 120 ? 20 : 14;
|
|
1119
|
+
const fixed = 4 + idWidth + areaWidth + depsWidth + gatesWidth + 2 + channelWidth + attemptWidth + noteWidth;
|
|
1120
|
+
const taskWidth = boardColumns - fixed;
|
|
1121
|
+
const fitColumn = (text, columns, gap = 2) => fitCells(text, Math.max(1, columns - gap)) + " ".repeat(Math.min(gap, columns - 1));
|
|
1122
|
+
if (taskWidth < 12) {
|
|
1123
|
+
return cells.flatMap((cell) => {
|
|
1124
|
+
const effort = effortByTask.get(cell.t.id);
|
|
1125
|
+
const deps = cell.t.deps.length ? cell.t.deps.join(", ") : "—";
|
|
1126
|
+
const tone = cell.redTier
|
|
1127
|
+
? fail
|
|
1128
|
+
: cell.merged
|
|
1129
|
+
? ok
|
|
1130
|
+
: staleWorker(cell) || cell.st === "failed" || cell.st === "human"
|
|
1131
|
+
? warn
|
|
1132
|
+
: cell.livePhase || cell.st === "running"
|
|
1133
|
+
? running
|
|
1134
|
+
: dim;
|
|
1135
|
+
const machinery = [
|
|
1136
|
+
`deps ${deps}`,
|
|
1137
|
+
gateMatrix(cell.states),
|
|
1138
|
+
cell.channel,
|
|
1139
|
+
`att ${effort?.dispatches ?? 0}`,
|
|
1140
|
+
].join(" ");
|
|
1141
|
+
return [
|
|
1142
|
+
` ${tone(cell.t.id)} ${dim(taskArea(cell.t))} ${sanitizeTaskText(cell.t.title)}`,
|
|
1143
|
+
...wrapCells(` ${dim(machinery)}`, boardColumns, { continuationPrefix: " " }),
|
|
1144
|
+
...wrapCells(` ${dim(noteFor(cell))}`, boardColumns, { continuationPrefix: " " }).filter((line) => line.trim()),
|
|
1145
|
+
"",
|
|
1146
|
+
];
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
const headerRow = ` ${fitColumn("", idWidth, 1)}${fitColumn("area", areaWidth)}${fitColumn("deps", depsWidth)}${fitColumn("task", taskWidth)}${gateHeader} ${fitColumn("channel", channelWidth)}${fitColumn("att", attemptWidth, 1)}note`;
|
|
1150
|
+
const ruleRow = ` ${"─".repeat(boardColumns - 4)}`;
|
|
1151
|
+
const rows = cells.flatMap((cell) => {
|
|
1152
|
+
const effort = effortByTask.get(cell.t.id);
|
|
1153
|
+
const columns = [
|
|
1154
|
+
wrapCells(cell.t.id, idWidth - 1),
|
|
1155
|
+
wrapCells(taskArea(cell.t), areaWidth - 2),
|
|
1156
|
+
wrapCells(cell.t.deps.length ? cell.t.deps.join(", ") : "—", depsWidth - 2),
|
|
1157
|
+
[sanitizeTaskText(cell.t.title)],
|
|
1158
|
+
[gateMatrix(cell.states)],
|
|
1159
|
+
[cell.channel],
|
|
1160
|
+
[String(effort?.dispatches ?? 0)],
|
|
1161
|
+
wrapCells(noteFor(cell), noteWidth),
|
|
1162
|
+
];
|
|
1163
|
+
const height = Math.max(...columns.map((column) => column.length));
|
|
1164
|
+
const idTone = cell.redTier
|
|
1165
|
+
? fail
|
|
1166
|
+
: cell.merged
|
|
1167
|
+
? ok
|
|
1168
|
+
: staleWorker(cell) || cell.st === "failed" || cell.st === "human"
|
|
1169
|
+
? warn
|
|
1170
|
+
: cell.livePhase || cell.st === "running"
|
|
1171
|
+
? running
|
|
1172
|
+
: dim;
|
|
1173
|
+
const titleTone = cell.redTier || cell.livePhase || (effort?.parks ?? 0) >= 3 ? title : dim;
|
|
1174
|
+
return Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
|
|
1175
|
+
+ dim(fitColumn(columns[1][index] ?? "", areaWidth))
|
|
1176
|
+
+ dim(fitColumn(columns[2][index] ?? "", depsWidth))
|
|
1177
|
+
+ titleTone(fitColumn(columns[3][index] ?? "", taskWidth))
|
|
1178
|
+
+ fitCells(columns[4][index] ?? "", gatesWidth)
|
|
1179
|
+
+ " "
|
|
1180
|
+
+ dim(fitColumn(columns[5][index] ?? "", channelWidth))
|
|
1181
|
+
+ dim(fitColumn(columns[6][index] ?? "", attemptWidth, 1))
|
|
1182
|
+
+ dim(fitCells(columns[7][index] ?? "", noteWidth)));
|
|
1183
|
+
});
|
|
1184
|
+
return [legend(headerRow), dim(ruleRow), ...rows];
|
|
1185
|
+
};
|
|
1186
|
+
const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
|
|
1068
1187
|
const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
|
|
1069
1188
|
return {
|
|
1070
|
-
content: boardRows([
|
|
1189
|
+
content: boardRows([
|
|
1190
|
+
...headerRows,
|
|
1191
|
+
...nowLine,
|
|
1192
|
+
supervisionLegend,
|
|
1193
|
+
"",
|
|
1194
|
+
taskSection,
|
|
1195
|
+
"",
|
|
1196
|
+
...tableRows(),
|
|
1197
|
+
gatesLegend,
|
|
1198
|
+
"",
|
|
1199
|
+
rule(boardColumns),
|
|
1200
|
+
"",
|
|
1201
|
+
...effort,
|
|
1202
|
+
], boardColumns).join("\n"),
|
|
1071
1203
|
...(hotPhase ? { hotPhase } : {}),
|
|
1072
1204
|
...(runId ? { runId } : {}),
|
|
1073
1205
|
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
@@ -1101,10 +1233,12 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1101
1233
|
const namedRunId = positionalRunId(argv);
|
|
1102
1234
|
if (argv.includes("--oneline"))
|
|
1103
1235
|
return oneLine(cwd, namedRunId);
|
|
1104
|
-
//
|
|
1236
|
+
// ONE manifest read per invocation, here — every frame below names this same version.
|
|
1237
|
+
const binaryVersion = await readBinaryVersion();
|
|
1238
|
+
// The task-table frame owns its own two-row brand lockup; the four-row global banner would
|
|
1239
|
+
// create the second header the approved replacement removes.
|
|
1105
1240
|
if (!argv.includes("--watch")) {
|
|
1106
|
-
|
|
1107
|
-
return visual() ? BANNER + content : content;
|
|
1241
|
+
return renderFrame(cwd, binaryVersion, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
|
|
1108
1242
|
}
|
|
1109
1243
|
const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
|
|
1110
1244
|
const webhookUrl = opts.webhookUrl
|
|
@@ -1132,14 +1266,21 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1132
1266
|
}
|
|
1133
1267
|
}
|
|
1134
1268
|
: undefined);
|
|
1135
|
-
|
|
1269
|
+
// Retiring a task that is no longer running is bookkeeping over data already in hand, so it stays
|
|
1270
|
+
// on the frame path where the board can rely on it every redraw.
|
|
1271
|
+
const retireInactiveWorkers = (active) => {
|
|
1136
1272
|
const activeIds = new Set(active.map((phase) => phase.taskId));
|
|
1137
1273
|
for (const taskId of workerLiveness.keys())
|
|
1138
1274
|
if (!activeIds.has(taskId))
|
|
1139
1275
|
workerLiveness.delete(taskId);
|
|
1276
|
+
};
|
|
1277
|
+
const scrapeWorkerOutput = async (active, runId, observedAt) => {
|
|
1140
1278
|
if (!readWorkerOutput || !runId)
|
|
1141
1279
|
return;
|
|
1142
|
-
|
|
1280
|
+
// allSettled, never all: Promise.all resolves the moment ONE pane read rejects, which would
|
|
1281
|
+
// clear the single-flight guard while a sibling read is still hung on its socket and let the
|
|
1282
|
+
// next budget start a second, overlapping scrape. Every read must settle before the guard drops.
|
|
1283
|
+
await Promise.allSettled(active.map(async (phase) => {
|
|
1143
1284
|
const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
|
|
1144
1285
|
if (output === undefined)
|
|
1145
1286
|
return;
|
|
@@ -1162,6 +1303,21 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1162
1303
|
prior.snapshot = snapshot;
|
|
1163
1304
|
}));
|
|
1164
1305
|
};
|
|
1306
|
+
// The scrape's own clock, and the reason a hung pane cannot freeze the board: it is STARTED here
|
|
1307
|
+
// and never awaited, so the frame loop walks straight past it. `scrapeInFlight` is what keeps a
|
|
1308
|
+
// scrape that outlives its budget from being joined by a second one — a wedged pane read would
|
|
1309
|
+
// otherwise accumulate one outstanding socket read per elapsed budget for as long as it hangs.
|
|
1310
|
+
let scrapeInFlight = false;
|
|
1311
|
+
let lastScrapeAt = Number.NEGATIVE_INFINITY;
|
|
1312
|
+
const scheduleWorkerScrape = (frame, observedAt) => {
|
|
1313
|
+
if (scrapeInFlight || observedAt - lastScrapeAt < SCRAPE_MS)
|
|
1314
|
+
return;
|
|
1315
|
+
scrapeInFlight = true;
|
|
1316
|
+
lastScrapeAt = observedAt;
|
|
1317
|
+
void scrapeWorkerOutput(frame.workerPhases, frame.runId, observedAt)
|
|
1318
|
+
.catch(() => undefined)
|
|
1319
|
+
.finally(() => { scrapeInFlight = false; });
|
|
1320
|
+
};
|
|
1165
1321
|
let decisionRunId;
|
|
1166
1322
|
let journalCursor = 0;
|
|
1167
1323
|
const consumeDecisionEvents = () => {
|
|
@@ -1224,10 +1380,10 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1224
1380
|
}
|
|
1225
1381
|
}
|
|
1226
1382
|
else {
|
|
1227
|
-
frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
|
|
1383
|
+
frame = renderFrame(cwd, binaryVersion, nowMs, i, workerLiveness, true, namedRunId);
|
|
1228
1384
|
if (tty) {
|
|
1229
1385
|
updateTitle(frame.hotPhase, nowMs);
|
|
1230
|
-
process.stdout.write(`\x1b[2J\x1b[H${
|
|
1386
|
+
process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${FRAME_MS / 1000}s · ^C to quit`)}`);
|
|
1231
1387
|
}
|
|
1232
1388
|
else {
|
|
1233
1389
|
process.stdout.write(frame.content + sep);
|
|
@@ -1236,9 +1392,11 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1236
1392
|
frames.push(frame.content);
|
|
1237
1393
|
}
|
|
1238
1394
|
if (i + 1 < iterations) {
|
|
1239
|
-
if (frame)
|
|
1240
|
-
|
|
1241
|
-
|
|
1395
|
+
if (frame) {
|
|
1396
|
+
retireInactiveWorkers(frame.workerPhases);
|
|
1397
|
+
scheduleWorkerScrape(frame, nowMs);
|
|
1398
|
+
}
|
|
1399
|
+
await sleep(FRAME_MS);
|
|
1242
1400
|
}
|
|
1243
1401
|
}
|
|
1244
1402
|
}
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { type TickmarkrConfig } from "../config/config.js";
|
|
2
2
|
import { type Task } from "../graph/schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* OBS-547: the FULL per-task collateral prediction, uncapped. ONE map is computed per run (daemon.ts,
|
|
5
|
+
* at run start) and handed whole to the scope gate, so a hit the plan display hides is still there
|
|
6
|
+
* when the gate asks about it. The lint lines below are a capped VIEW of this same map — never a
|
|
7
|
+
* second scan. Tasks with no hits are absent.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collateralHits(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): Map<string, string[]>;
|
|
10
|
+
/** The verbatim repair an authoring defect owes: the files[] lines the spec is missing. */
|
|
11
|
+
export declare function filesRepair(taskId: string, paths: ReadonlyArray<string>): string;
|
|
12
|
+
export interface ScopeCollateralVerdict {
|
|
13
|
+
/** every hard offender was predicted ⇒ authoring defect, repair pre-written, no attempt charged */
|
|
14
|
+
authoring: boolean;
|
|
15
|
+
/** the offenders the map had already named */
|
|
16
|
+
predicted: string[];
|
|
17
|
+
/** the offenders it had not — the lint's blind spots, recorded as evidence rather than folklore */
|
|
18
|
+
missed: string[];
|
|
19
|
+
repair: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* OBS-547: cross-reference at the RED. The gate supplies specificity (the paths actually touched),
|
|
23
|
+
* the prediction supplies classification — so there are no false positives to fear and no refusal to
|
|
24
|
+
* author. Feed this the FULL map for the task: a classifier bounded by the display cap calls a
|
|
25
|
+
* predicted 21st hit a quality failure.
|
|
26
|
+
*/
|
|
27
|
+
export declare function classifyScopeOffenders(taskId: string, hard: ReadonlyArray<string>, predicted: ReadonlyArray<string>): ScopeCollateralVerdict;
|
|
3
28
|
/**
|
|
4
29
|
* Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
|
|
5
30
|
* Each line names the task id and at least one missing collateral test path.
|