tickmarkr 1.93.0 → 1.97.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/adapters/model-lints.d.ts +1 -1
- package/dist/adapters/model-lints.js +116 -52
- package/dist/brand.d.ts +3 -0
- package/dist/brand.js +3 -1
- package/dist/cli/commands/beat.d.ts +1 -0
- package/dist/cli/commands/beat.js +50 -0
- package/dist/cli/commands/status.js +325 -127
- package/dist/cli/commands/verify.js +52 -26
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/compile/gsd.js +34 -1
- package/dist/compile/native.js +26 -1
- package/dist/drivers/herdr.d.ts +26 -1
- package/dist/drivers/herdr.js +77 -29
- package/dist/gates/acceptance.js +26 -4
- package/dist/gates/baseline.d.ts +10 -1
- package/dist/gates/baseline.js +35 -6
- package/dist/run/daemon.d.ts +5 -0
- package/dist/run/daemon.js +25 -4
- package/dist/run/merge.d.ts +6 -1
- package/dist/run/merge.js +46 -11
- package/fixtures/gsd-sample/07-live-check/07-01-PLAN.md +1 -1
- package/fixtures/gsd-sample/PROJECT.md +19 -0
- package/fixtures/payload-shape/p99-shaped.spec.md +68 -0
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +42 -18
- 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 { brandChip, dim, fail, GLYPHS, legend, ok, rule, title, warn } 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";
|
|
@@ -8,11 +8,12 @@ import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../.
|
|
|
8
8
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
9
9
|
import { foldActivity } from "../../run/activity.js";
|
|
10
10
|
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
11
|
+
import { normalizeGateOutcome } from "../../run/outcome.js";
|
|
11
12
|
import { desiredPanes } from "../../run/reconcile.js";
|
|
12
13
|
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
13
14
|
import { readSupervision, supervisionText } from "../../run/supervision.js";
|
|
14
15
|
import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
|
|
15
|
-
import { COCKPIT_COLUMN_FLOOR
|
|
16
|
+
import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
|
|
16
17
|
import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
|
|
17
18
|
// ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
|
|
18
19
|
const REFRESH_MS = 2000;
|
|
@@ -216,6 +217,18 @@ const livePhases = (events) => {
|
|
|
216
217
|
const active = live.get(event.taskId);
|
|
217
218
|
if (!active)
|
|
218
219
|
continue;
|
|
220
|
+
// The daemon proves a silent worker alive by hashing its worktree and journals that proof as
|
|
221
|
+
// `worker-contact`. It is evidence ABOUT the live phase, never its outcome, so it decorates the
|
|
222
|
+
// entry and is deliberately absent from the `matched` set below.
|
|
223
|
+
if (event.event === "worker-contact" && active.phase === "worker") {
|
|
224
|
+
const at = Date.parse(event.ts);
|
|
225
|
+
if (Number.isFinite(at)) {
|
|
226
|
+
active.lastContactAt = at;
|
|
227
|
+
if (typeof event.data.evidence === "string")
|
|
228
|
+
active.contactEvidence = event.data.evidence;
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
219
232
|
const gate = active.gate ?? phaseGate(active.phase);
|
|
220
233
|
const matched = (active.phase === "worker" && event.event === "worker-result")
|
|
221
234
|
|| (active.phase === "gates" && event.event === "gate-result")
|
|
@@ -249,16 +262,44 @@ const fmtElapsed = (ms) => {
|
|
|
249
262
|
const hours = Math.floor(minutes / 60);
|
|
250
263
|
return `${hours}h${minutes % 60}m${seconds % 60}s`;
|
|
251
264
|
};
|
|
252
|
-
|
|
265
|
+
/**
|
|
266
|
+
* The freshest evidence that this worker is alive, and what produced it. ONE derivation, because the
|
|
267
|
+
* two readers of worker silence drifted apart the first time this was fixed: OBS-538 taught the detail
|
|
268
|
+
* phrase to read the daemon's journaled `worker-contact` and left the stall ALARM (`staleWorker`)
|
|
269
|
+
* clocking pane bytes alone. For the exact population OBS-538 is about — a headless channel (codex
|
|
270
|
+
* `sub`, an API worker) that prints nothing to its pane all run, so `hasOutput` never becomes true —
|
|
271
|
+
* the alarm then warn-painted every live row from 60s onward while line 2 of the same card read
|
|
272
|
+
* "last contact 10s ago (worktree)". A card cannot contradict itself if both halves ask this.
|
|
273
|
+
*/
|
|
274
|
+
const workerActivity = (phase, worker) => {
|
|
275
|
+
const outputAt = worker?.phaseStartedAt === phase.startedAt && worker.hasOutput ? worker.lastOutputAt : undefined;
|
|
276
|
+
const contactAt = phase.lastContactAt;
|
|
277
|
+
if (outputAt !== undefined && (contactAt === undefined || outputAt >= contactAt))
|
|
278
|
+
return { at: outputAt, source: "output" };
|
|
279
|
+
if (contactAt !== undefined)
|
|
280
|
+
return { at: contactAt, source: "contact" };
|
|
281
|
+
// Nothing has been observed since the phase began: silence is the whole phase, which is what both
|
|
282
|
+
// the phrase ("no output 14m51s") and the alarm's 60s threshold must measure.
|
|
283
|
+
return { at: phase.startedAt, source: "none" };
|
|
284
|
+
};
|
|
285
|
+
/** How long this worker has been silent by the freshest evidence available — the alarm's clock. */
|
|
286
|
+
const workerSilenceMs = (phase, now, worker) => now - workerActivity(phase, worker).at;
|
|
287
|
+
/**
|
|
288
|
+
* What a live worker row says about silence. Pane bytes are not more truthful than a worktree delta,
|
|
289
|
+
* only different, so the row names whichever evidence is fresher AND names its source.
|
|
290
|
+
*/
|
|
253
291
|
const phaseDetail = (phase, now, worker) => {
|
|
254
292
|
const elapsed = fmtElapsed(now - phase.startedAt);
|
|
255
293
|
if (phase.phase !== "worker")
|
|
256
294
|
return `${phaseLabel(phase)} · ${elapsed} elapsed`;
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
295
|
+
const head = `${phaseLabel(phase.phase)} · ${elapsed} elapsed`;
|
|
296
|
+
const activity = workerActivity(phase, worker);
|
|
297
|
+
const age = fmtElapsed(now - activity.at);
|
|
298
|
+
if (activity.source === "output")
|
|
299
|
+
return `${head} · last output ${age} ago`;
|
|
300
|
+
if (activity.source === "contact")
|
|
301
|
+
return `${head} · last contact ${age} ago (${phase.contactEvidence ?? "worktree"})`;
|
|
302
|
+
return `${head} · no output ${age}`;
|
|
262
303
|
};
|
|
263
304
|
const attemptStartIdx = (events, taskId) => {
|
|
264
305
|
let idx = -1;
|
|
@@ -335,11 +376,6 @@ export const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicod
|
|
|
335
376
|
? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
|
|
336
377
|
: `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
|
|
337
378
|
export const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
338
|
-
// plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
|
|
339
|
-
const failedSuffix = (states) => {
|
|
340
|
-
const f = failedGates(states);
|
|
341
|
-
return f.length ? ` · ${f.join(", ")}` : "";
|
|
342
|
-
};
|
|
343
379
|
// a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
|
|
344
380
|
// awaited approval is named from task state alone — never from gate results. Failed gates win the
|
|
345
381
|
// cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
|
|
@@ -358,19 +394,48 @@ export const shortGoal = (goal, max) => {
|
|
|
358
394
|
return fitCells(clause, cells).trimEnd();
|
|
359
395
|
return `${fitCells(clause, cells - 3).trimEnd()}...`;
|
|
360
396
|
};
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
397
|
+
/** Columns the machine row always spends on the task title, however long its machinery segment runs. */
|
|
398
|
+
const MACHINE_TITLE_FLOOR = 24;
|
|
399
|
+
// The approved task board derives an area from files[] rather than adding another graph field.
|
|
400
|
+
// Known tickmarkr domains keep their reviewed names; other repositories fall back to their first
|
|
401
|
+
// meaningful path segment so the shipped surface never turns a foreign project into UNMAPPED rows.
|
|
402
|
+
const TASK_AREA_RULES = [
|
|
403
|
+
["SPEC", [/^src\/(compile|graph|plan)\//u]],
|
|
404
|
+
["FLEET", [/^src\/(route|config|adapters)\//u]],
|
|
405
|
+
["RUN", [/^src\/(run|drivers|eval)\//u]],
|
|
406
|
+
["GATE", [/^src\/gates\//u]],
|
|
407
|
+
["UI", [/^src\/(cli|tui|report)\//u, /^src\/brand\.ts/u]],
|
|
408
|
+
["DOCS", [/^(docs|skills)\//u]],
|
|
409
|
+
["REPO", [/^(scripts|schema)\//u, /^\.github\//u, /^tests\/(lint|repo|helpers)\//u, /^(vitest\.config|package\.json|tsconfig)/u]],
|
|
410
|
+
];
|
|
411
|
+
const expandAreaBraces = (path) => {
|
|
412
|
+
const match = /^(.*?)\{([^}]*)\}(.*)$/u.exec(path);
|
|
413
|
+
return match
|
|
414
|
+
? match[2].split(",").flatMap((part) => expandAreaBraces(`${match[1]}${part.trim()}${match[3]}`))
|
|
415
|
+
: [path];
|
|
416
|
+
};
|
|
417
|
+
const areaForPath = (path) => {
|
|
418
|
+
const candidates = [path, path.replace(/^tests\//u, "src/")];
|
|
419
|
+
for (const candidate of candidates) {
|
|
420
|
+
for (const [name, patterns] of TASK_AREA_RULES) {
|
|
421
|
+
if (patterns.some((pattern) => pattern.test(candidate)))
|
|
422
|
+
return name;
|
|
423
|
+
}
|
|
372
424
|
}
|
|
373
|
-
|
|
425
|
+
const parts = path.replace(/^[!./]+/u, "").split("/").filter((part) => part && !/[*?[\]]/u.test(part));
|
|
426
|
+
const root = parts[0];
|
|
427
|
+
if (!root)
|
|
428
|
+
return "UNMAPPED";
|
|
429
|
+
const domain = ["src", "app", "packages", "tests"].includes(root) && parts[1] ? parts[1] : root;
|
|
430
|
+
return domain.replace(/\.[^.]+$/u, "").toUpperCase();
|
|
431
|
+
};
|
|
432
|
+
const taskArea = (task) => {
|
|
433
|
+
const paths = task.files.flatMap(expandAreaBraces);
|
|
434
|
+
if (paths.length === 0)
|
|
435
|
+
return "—";
|
|
436
|
+
const primary = paths.filter((path) => !path.startsWith("tests/") && !path.startsWith("src/cli/"));
|
|
437
|
+
const source = primary.length ? primary : paths;
|
|
438
|
+
return [...new Set(source.map(areaForPath))].join("+");
|
|
374
439
|
};
|
|
375
440
|
/**
|
|
376
441
|
* Every board line leaves through the cockpit's own wrap, measured in display cells: no drawn line
|
|
@@ -378,6 +443,95 @@ const detailText = (segments, budget) => {
|
|
|
378
443
|
* stay blank — `wrapCells` always answers at least one row.
|
|
379
444
|
*/
|
|
380
445
|
const boardRows = (lines, columns) => lines.flatMap((line) => wrapCells(line, columns, { continuationPrefix: " " }));
|
|
446
|
+
/**
|
|
447
|
+
* A funded review round is a review that actually returned a verdict. `passed` and `failed` are the
|
|
448
|
+
* only two arms of the gate vocabulary that carry one (outcome.ts:23) — every other arm states why
|
|
449
|
+
* nothing ran, so counting it would bill the task for a reviewer it never spent.
|
|
450
|
+
*/
|
|
451
|
+
const REVIEW_VERDICTS = new Set(["passed", "failed"]);
|
|
452
|
+
/**
|
|
453
|
+
* Fold the panel from parsed JournalEvent rows only. In particular, a review round is every
|
|
454
|
+
* gate-result whose typed gate field is `review` and whose outcome is a verdict, passed or failed.
|
|
455
|
+
* `review-raw-*` artifacts are unparseable-verdict captures, not round markers, and neither raw
|
|
456
|
+
* journal strings nor run-directory filenames participate in this count.
|
|
457
|
+
*
|
|
458
|
+
* A decline is not a round, and it is NOT readable off `data.skipped`: that field is one of four
|
|
459
|
+
* encodings the shipped record already writes, and the two it misses are both live on disk — a
|
|
460
|
+
* pre-R3 review decline states itself only as `{ pass: true, details: "skipped — …" }`, and a
|
|
461
|
+
* canonical row states it as `outcome.kind: "declined"`. Reading the flag alone bills both as funded
|
|
462
|
+
* rounds and can reorder the top four. `normalizeGateOutcome` is the ONE reader of all four
|
|
463
|
+
* encodings (outcome.ts:125), so the panel asks it rather than re-deriving a fifth answer; a row it
|
|
464
|
+
* cannot read stays `unavailable` and therefore uncounted, fail-closed. `replayMeasurement` is
|
|
465
|
+
* orthogonal to the outcome — a resume re-observing an interrupted round reports a real verdict —
|
|
466
|
+
* so it stays an explicit exclusion, the same clause journal.ts:110 uses.
|
|
467
|
+
*/
|
|
468
|
+
const foldTaskEffort = (tasks, events) => {
|
|
469
|
+
const order = new Map(tasks.map((task, index) => [task.id, index]));
|
|
470
|
+
const effort = new Map(tasks.map((task) => [task.id, {
|
|
471
|
+
taskId: task.id,
|
|
472
|
+
dispatches: 0,
|
|
473
|
+
reviews: 0,
|
|
474
|
+
parks: 0,
|
|
475
|
+
total: 0,
|
|
476
|
+
}]));
|
|
477
|
+
for (const event of events) {
|
|
478
|
+
if (!event.taskId)
|
|
479
|
+
continue;
|
|
480
|
+
const task = effort.get(event.taskId);
|
|
481
|
+
if (!task)
|
|
482
|
+
continue;
|
|
483
|
+
if (event.event === "task-dispatch")
|
|
484
|
+
task.dispatches += 1;
|
|
485
|
+
else if (event.event === "gate-result" && event.data.gate === "review"
|
|
486
|
+
&& event.data.replayMeasurement !== true
|
|
487
|
+
&& REVIEW_VERDICTS.has(normalizeGateOutcome(event.data.outcome ?? event.data).kind))
|
|
488
|
+
task.reviews += 1;
|
|
489
|
+
else if (event.event === "task-human")
|
|
490
|
+
task.parks += 1;
|
|
491
|
+
task.total = task.dispatches + task.reviews + task.parks;
|
|
492
|
+
}
|
|
493
|
+
return [...effort.values()].sort((left, right) => right.total - left.total
|
|
494
|
+
|| order.get(left.taskId) - order.get(right.taskId));
|
|
495
|
+
};
|
|
496
|
+
/**
|
|
497
|
+
* Prototype panel, fitted by the cockpit's display-cell authority before board-wide wrapping.
|
|
498
|
+
*
|
|
499
|
+
* `undefined` events mean this run's journal describes a DIFFERENT graph, so it funds no claim here.
|
|
500
|
+
* Folding it — or folding an empty array in its place — would state zero dispatches, reviews and
|
|
501
|
+
* parks for a run that recorded all three, which is unavailable evidence dressed as an answer. Same
|
|
502
|
+
* clause the verify claim uses (`verify unavailable`): no claim beats a false zero.
|
|
503
|
+
*/
|
|
504
|
+
const effortPanel = (tasks, events, columns) => {
|
|
505
|
+
if (!events) {
|
|
506
|
+
return [
|
|
507
|
+
` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
|
|
508
|
+
legend(` dispatch · review · park counts unavailable — ${NOT_COMPARABLE_CLAIM}`),
|
|
509
|
+
];
|
|
510
|
+
}
|
|
511
|
+
const top = foldTaskEffort(tasks, events).slice(0, 4);
|
|
512
|
+
const maxTotal = Math.max(1, ...top.map((task) => task.total));
|
|
513
|
+
const idColumns = Math.max(2, Math.min(16, Math.max(...top.map((task) => cellWidth(task.taskId)))));
|
|
514
|
+
const rows = top.map((task) => {
|
|
515
|
+
const counts = `${task.dispatches} dispatch · ${task.reviews} review · ${task.parks} park`;
|
|
516
|
+
const prefix = ` ${fitCells(task.taskId, idColumns)} `;
|
|
517
|
+
const barColumns = Math.min(46, Math.max(3, columns - cellWidth(prefix) - 2 - cellWidth(counts)));
|
|
518
|
+
const dispatchEnd = Math.round((task.dispatches / maxTotal) * barColumns);
|
|
519
|
+
const reviewEnd = Math.round(((task.dispatches + task.reviews) / maxTotal) * barColumns);
|
|
520
|
+
const parkEnd = Math.round((task.total / maxTotal) * barColumns);
|
|
521
|
+
const stack = ok("█".repeat(dispatchEnd))
|
|
522
|
+
+ warn("█".repeat(Math.max(0, reviewEnd - dispatchEnd)))
|
|
523
|
+
+ fail("█".repeat(Math.max(0, parkEnd - reviewEnd)));
|
|
524
|
+
return `${prefix}${fitCells(stack, barColumns)} ${counts}`;
|
|
525
|
+
});
|
|
526
|
+
return [
|
|
527
|
+
` ${ok("▌")} ${title("WHERE THE EFFORT WENT")}`,
|
|
528
|
+
legend(` dispatches · review rounds · human parks · top ${top.length} of ${tasks.length} tasks`),
|
|
529
|
+
"",
|
|
530
|
+
...rows,
|
|
531
|
+
"",
|
|
532
|
+
` ${ok("█")} ${dim("dispatch")} ${warn("█")} ${dim("review round")} ${fail("█")} ${dim("human park")}`,
|
|
533
|
+
];
|
|
534
|
+
};
|
|
381
535
|
// VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
|
|
382
536
|
// daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
|
|
383
537
|
// never fabricated (garbage pid data fails toward unknown too). kill(pid,0) is a signal probe, not a
|
|
@@ -724,6 +878,17 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
|
|
|
724
878
|
// (a recompiled graph's journal must not animate the wrong tasks); with no or stale journal the
|
|
725
879
|
// dep-waiting cells still derive from the effective graph statuses.
|
|
726
880
|
const activity = foldActivity(comparable ? events : [], effective.tasks);
|
|
881
|
+
// RULING-v189-scope §14c: a card answers BOTH supervisor questions. `foldActivity` names what a
|
|
882
|
+
// task waits FOR; this names what waits ON it. Read from `effective.tasks` — whose statuses are
|
|
883
|
+
// the journal's replay, never the compiled graph's — so a dependent the record says is done stops
|
|
884
|
+
// being named, and a task all of whose dependents are done acquires no entry at all.
|
|
885
|
+
const dependents = new Map();
|
|
886
|
+
for (const task of effective.tasks) {
|
|
887
|
+
if (task.status === "done")
|
|
888
|
+
continue;
|
|
889
|
+
for (const dep of task.deps)
|
|
890
|
+
dependents.set(dep, [...(dependents.get(dep) ?? []), task.id]);
|
|
891
|
+
}
|
|
727
892
|
const unicode = visual();
|
|
728
893
|
const divider = unicode ? " · " : " / ";
|
|
729
894
|
// SUP-01: derived by stat() alone, from THIS frame's clock. status stays a reader — nothing here
|
|
@@ -783,7 +948,12 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
|
|
|
783
948
|
const chain = gateChain(states, false);
|
|
784
949
|
const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${surfaceTaskBox(st, merged)} ${t.id} `;
|
|
785
950
|
const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : surfaceStatusWord(st)}${label} ${assignCol}${pane ? ` pane ${pane}` : ""}`;
|
|
786
|
-
|
|
951
|
+
// The machine row carries the cockpit's own law (see LAYOUT_PRIORITY and the two-line card):
|
|
952
|
+
// identity is never what a crowded row sheds. `dep-waiting on …` grows with fan-in and alone
|
|
953
|
+
// can exceed the terminal width, and the pre-floor math paid for it out of the TITLE — 20 of
|
|
954
|
+
// 41 rows in a live 41-task run named no task at all. These rows already overflow `width`
|
|
955
|
+
// (a pane name is 60 columns on its own), so the floor costs wrapping, never the graph.
|
|
956
|
+
return `${prefix}${shortGoal(t.title, Math.max(MACHINE_TITLE_FLOOR, width - prefix.length - suffix.length))}${suffix}`;
|
|
787
957
|
});
|
|
788
958
|
const zone = journalRowsOnly ? `${divider}zone ${localZoneLabel(zoneReference)}` : "";
|
|
789
959
|
const header = runId
|
|
@@ -797,120 +967,148 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
|
|
|
797
967
|
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
798
968
|
};
|
|
799
969
|
}
|
|
800
|
-
// TTY:
|
|
801
|
-
//
|
|
802
|
-
// rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
|
|
803
|
-
// padEnd math)
|
|
804
|
-
// The cockpit owns this board's column contract: `COCKPIT_COLUMN_FLOOR` is the narrowest board it
|
|
805
|
-
// plans, so below it the terminal — not a second floor stated here — decides what happens to the
|
|
806
|
-
// drawn rows. Every column figure below is derived from this one.
|
|
970
|
+
// TTY: the operator-approved task table. The daemon gives this surface a ~110-column pane first;
|
|
971
|
+
// narrower terminals keep the same facts in stacked rows. Every cut/wrap goes through width.ts.
|
|
807
972
|
const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
|
|
808
973
|
const dot = dim(" · ");
|
|
809
|
-
const
|
|
810
|
-
const gaugeCells = 10;
|
|
811
|
-
const fill = total ? Math.round((done / total) * gaugeCells) : 0;
|
|
974
|
+
const separator = ` ${dim("│")} `;
|
|
812
975
|
const tipFailed = tipPhase?.state === "failed";
|
|
976
|
+
const anyFailed = cells.some((cell) => cell.redTier);
|
|
813
977
|
const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
|
|
814
|
-
const gauge = (fill ? progressTone("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
|
|
815
|
-
const live = liveness(events, now)
|
|
816
|
-
.replace(/\bdead\b/, fail("dead"))
|
|
817
|
-
.replace(/\bfinished\b/, dim("finished"))
|
|
818
|
-
.replace(/\balive\b/, ok("alive"))
|
|
819
|
-
.replaceAll(" · ", dot);
|
|
820
978
|
const tally = tipPhase
|
|
821
979
|
? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
|
|
822
|
-
: `${done}/${total} done`;
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
980
|
+
: done === total && total > 0 ? ok(`${done}/${total} done`) : `${done}/${total} done`;
|
|
981
|
+
const live = liveness(events, now)
|
|
982
|
+
.replace(/\bdead\b/u, fail("dead"))
|
|
983
|
+
.replace(/\bfinished\b/u, dim("finished"))
|
|
984
|
+
.replace(/\balive\b/u, ok("alive"))
|
|
985
|
+
.replaceAll(" · ", dot);
|
|
986
|
+
const header = [
|
|
987
|
+
brandChip(" tickmarkr "),
|
|
988
|
+
title(runId ?? "no runs yet"),
|
|
989
|
+
tally,
|
|
990
|
+
...(events.length ? [`${events.filter((event) => event.event === "run-resume").length} restarts`] : []),
|
|
991
|
+
...(events.length ? [`${events.filter((event) => event.event === "escalation").length} escalations`] : []),
|
|
829
992
|
...(runId && supersededBy ? [warn(`superseded by ${supersededBy}`)] : []),
|
|
830
993
|
...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
|
|
831
994
|
...(verify === undefined
|
|
832
995
|
? []
|
|
833
996
|
: [verify === "verify passed" ? ok(verify) : tipFailed ? fail(verify) : warn(verify)]),
|
|
834
|
-
`${gauge} ${tipPhase ? tally : done === total && total > 0 ? ok(tally) : tally}`,
|
|
835
997
|
...(runId ? [live] : []),
|
|
836
998
|
...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
|
|
837
|
-
].join(
|
|
838
|
-
const hr = rule(Math.min(boardColumns, 100));
|
|
839
|
-
// OBS-104 run-level now line: names the most recent journal event. TTY frame only — the non-TTY
|
|
840
|
-
// machine surface is byte-pinned (status-brand golden) and must not drift. Rendered BELOW the task
|
|
841
|
-
// rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
|
|
999
|
+
].join(separator);
|
|
842
1000
|
const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
|
|
843
|
-
const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
|
|
844
|
-
// Every known tier, beaten or not: a tier missing from this line would read as a healthy one.
|
|
845
1001
|
const supervisionLegend = legend(` ${supervisionText(supervision)}`);
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const titleW = Math.max(8, ...titles.map(cellWidth));
|
|
862
|
-
// Line 2 starts under the title column — but never past what the board can pay for: an id near
|
|
863
|
-
// the schema's 64-character maximum would otherwise indent the machinery clean off a narrow row.
|
|
864
|
-
const chainCells = cellWidth(gateChain(GATE_NAMES.map(() => "open"), true));
|
|
865
|
-
const indent = " ".repeat(Math.max(0, Math.min(idW + 5, boardColumns - chainCells - 2)));
|
|
866
|
-
const rows = cells.map((c, i) => {
|
|
867
|
-
const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase, pane } = c;
|
|
868
|
-
const word = statusWord(c);
|
|
869
|
-
const staleWorker = livePhase?.phase === "worker"
|
|
870
|
-
&& workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
|
|
871
|
-
const stWord = staleWorker ? warn(word) : merged ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
|
|
872
|
-
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
873
|
-
const f = failedGates(states);
|
|
1002
|
+
const taskSectionSummary = `${done} of ${total} merged · rows in graph order · gates left→right in pipeline order`;
|
|
1003
|
+
const taskSection = ` ${ok("▌")} ${title("TASKS")} ${dim(taskSectionSummary)}`;
|
|
1004
|
+
const effortByTask = new Map(foldTaskEffort(g.tasks, record && !comparable ? [] : events)
|
|
1005
|
+
.map((task) => [task.taskId, task]));
|
|
1006
|
+
const gateMatrix = (states) => states.map((state) => {
|
|
1007
|
+
const glyph = gateBox(state, true);
|
|
1008
|
+
const tone = state === "pass" ? ok : state === "fail" ? fail : dim;
|
|
1009
|
+
return `${tone(glyph)} `;
|
|
1010
|
+
}).join("");
|
|
1011
|
+
const gateHeader = GATE_NAMES.map((gate) => fitCells(gate.slice(0, 2), 3)).join("");
|
|
1012
|
+
const noteFor = (cell) => {
|
|
1013
|
+
const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane } = cell;
|
|
1014
|
+
const effort = effortByTask.get(t.id);
|
|
1015
|
+
const blocking = graphTaskStatus(st, t.status) === "done" ? [] : dependents.get(t.id) ?? [];
|
|
1016
|
+
const failed = failedGates(states);
|
|
874
1017
|
const human = humanGateSuffix(t, graphTaskStatus(st, t.status), states);
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
?
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1018
|
+
return [
|
|
1019
|
+
livePhase ? `${SPINNER[animationFrame % SPINNER.length]} ${phrase ?? "running"}` : "",
|
|
1020
|
+
!livePhase && isStarved ? "starved" : "",
|
|
1021
|
+
!livePhase && !isStarved && !merged ? redTier ? "failed" : phrase ?? (st === "failed" ? "warn" : surfaceStatusWord(st)) : "",
|
|
1022
|
+
redTier && phrase ? phrase : "",
|
|
1023
|
+
priorGraph ? PRIOR_GRAPH_MARKER : "",
|
|
1024
|
+
failed.length ? `failed · ${failed.join(", ")}` : "",
|
|
1025
|
+
human ? "awaiting approval" : "",
|
|
1026
|
+
!merged && failureKind && !phrase?.includes(failureKind) ? failureKind : "",
|
|
1027
|
+
(effort?.parks ?? 0) >= 3 ? `parked ×${effort.parks}` : "",
|
|
1028
|
+
(effort?.reviews ?? 0) > 2 ? `${effort.reviews} review rounds` : "",
|
|
1029
|
+
blocking.length ? `blocks ${blocking.join(", ")}` : "",
|
|
1030
|
+
pane ? `pane ${pane}` : "",
|
|
1031
|
+
ctx !== undefined ? `ctx ${ctx}` : "",
|
|
1032
|
+
].filter(Boolean).join(" · ");
|
|
1033
|
+
};
|
|
1034
|
+
const staleWorker = (cell) => cell.livePhase?.phase === "worker"
|
|
1035
|
+
&& workerSilenceMs(cell.livePhase, now, workerLiveness.get(cell.t.id)) >= 60_000;
|
|
1036
|
+
const tableRows = () => {
|
|
1037
|
+
const idWidth = Math.min(8, Math.max(5, ...cells.map((cell) => cellWidth(cell.t.id))));
|
|
1038
|
+
const areaWidth = 16;
|
|
1039
|
+
const depsWidth = 14;
|
|
1040
|
+
const gatesWidth = GATE_NAMES.length * 3;
|
|
1041
|
+
const channelWidth = boardColumns >= 140 ? 22 : 12;
|
|
1042
|
+
const attemptWidth = 5;
|
|
1043
|
+
const noteWidth = boardColumns >= 140 ? 24 : boardColumns >= 120 ? 20 : 14;
|
|
1044
|
+
const fixed = 4 + idWidth + areaWidth + depsWidth + gatesWidth + 2 + channelWidth + attemptWidth + noteWidth;
|
|
1045
|
+
const taskWidth = boardColumns - fixed;
|
|
1046
|
+
const fitColumn = (text, columns, gap = 2) => fitCells(text, Math.max(1, columns - gap)) + " ".repeat(Math.min(gap, columns - 1));
|
|
1047
|
+
if (taskWidth < 12) {
|
|
1048
|
+
return cells.flatMap((cell) => {
|
|
1049
|
+
const effort = effortByTask.get(cell.t.id);
|
|
1050
|
+
const deps = cell.t.deps.length ? cell.t.deps.join(", ") : "—";
|
|
1051
|
+
const tone = cell.redTier ? fail : cell.merged ? ok : staleWorker(cell) || cell.st === "failed" || cell.st === "human" ? warn : dim;
|
|
1052
|
+
const machinery = [
|
|
1053
|
+
`deps ${deps}`,
|
|
1054
|
+
gateMatrix(cell.states),
|
|
1055
|
+
cell.channel,
|
|
1056
|
+
`att ${effort?.dispatches ?? 0}`,
|
|
1057
|
+
].join(" ");
|
|
1058
|
+
return [
|
|
1059
|
+
` ${tone(cell.t.id)} ${dim(taskArea(cell.t))} ${sanitizeTaskText(cell.t.title)}`,
|
|
1060
|
+
...wrapCells(` ${dim(machinery)}`, boardColumns, { continuationPrefix: " " }),
|
|
1061
|
+
...wrapCells(` ${dim(noteFor(cell))}`, boardColumns, { continuationPrefix: " " }).filter((line) => line.trim()),
|
|
1062
|
+
"",
|
|
1063
|
+
];
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
const headerRow = ` ${fitColumn("", idWidth, 1)}${fitColumn("area", areaWidth)}${fitColumn("deps", depsWidth)}${fitColumn("task", taskWidth)}${gateHeader} ${fitColumn("channel", channelWidth)}${fitColumn("att", attemptWidth, 1)}note`;
|
|
1067
|
+
const ruleRow = ` ${"─".repeat(boardColumns - 4)}`;
|
|
1068
|
+
const rows = cells.flatMap((cell) => {
|
|
1069
|
+
const effort = effortByTask.get(cell.t.id);
|
|
1070
|
+
const columns = [
|
|
1071
|
+
wrapCells(cell.t.id, idWidth - 1),
|
|
1072
|
+
wrapCells(taskArea(cell.t), areaWidth - 2),
|
|
1073
|
+
wrapCells(cell.t.deps.length ? cell.t.deps.join(", ") : "—", depsWidth - 2),
|
|
1074
|
+
[sanitizeTaskText(cell.t.title)],
|
|
1075
|
+
[gateMatrix(cell.states)],
|
|
1076
|
+
[cell.channel],
|
|
1077
|
+
[String(effort?.dispatches ?? 0)],
|
|
1078
|
+
wrapCells(noteFor(cell), noteWidth),
|
|
1079
|
+
];
|
|
1080
|
+
const height = Math.max(...columns.map((column) => column.length));
|
|
1081
|
+
const idTone = cell.redTier ? fail : cell.merged ? ok : staleWorker(cell) || cell.st === "failed" || cell.st === "human" ? warn : dim;
|
|
1082
|
+
const titleTone = cell.redTier || cell.livePhase || (effort?.parks ?? 0) >= 3 ? title : dim;
|
|
1083
|
+
return Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
|
|
1084
|
+
+ dim(fitColumn(columns[1][index] ?? "", areaWidth))
|
|
1085
|
+
+ dim(fitColumn(columns[2][index] ?? "", depsWidth))
|
|
1086
|
+
+ titleTone(fitColumn(columns[3][index] ?? "", taskWidth))
|
|
1087
|
+
+ fitCells(columns[4][index] ?? "", gatesWidth)
|
|
1088
|
+
+ " "
|
|
1089
|
+
+ dim(fitColumn(columns[5][index] ?? "", channelWidth))
|
|
1090
|
+
+ dim(fitColumn(columns[6][index] ?? "", attemptWidth, 1))
|
|
1091
|
+
+ dim(fitCells(columns[7][index] ?? "", noteWidth)));
|
|
1092
|
+
});
|
|
1093
|
+
return [legend(headerRow), dim(ruleRow), ...rows];
|
|
1094
|
+
};
|
|
1095
|
+
const gatesLegend = legend(` gates ${GATE_NAMES.map((gate) => `${gate.slice(0, 2)} ${gate}`).join(" ")}`);
|
|
1096
|
+
const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
|
|
912
1097
|
return {
|
|
913
|
-
content: boardRows([
|
|
1098
|
+
content: boardRows([
|
|
1099
|
+
header,
|
|
1100
|
+
...nowLine,
|
|
1101
|
+
supervisionLegend,
|
|
1102
|
+
"",
|
|
1103
|
+
taskSection,
|
|
1104
|
+
"",
|
|
1105
|
+
...tableRows(),
|
|
1106
|
+
gatesLegend,
|
|
1107
|
+
"",
|
|
1108
|
+
rule(boardColumns),
|
|
1109
|
+
"",
|
|
1110
|
+
...effort,
|
|
1111
|
+
], boardColumns).join("\n"),
|
|
914
1112
|
...(hotPhase ? { hotPhase } : {}),
|
|
915
1113
|
...(runId ? { runId } : {}),
|
|
916
1114
|
workerPhases: [...phases.values()].filter((phase) => phase.phase === "worker"),
|
|
@@ -944,10 +1142,10 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
944
1142
|
const namedRunId = positionalRunId(argv);
|
|
945
1143
|
if (argv.includes("--oneline"))
|
|
946
1144
|
return oneLine(cwd, namedRunId);
|
|
947
|
-
//
|
|
1145
|
+
// The task-table frame owns its compact one-line brand chip; the four-row global banner would
|
|
1146
|
+
// create the second header the approved replacement removes.
|
|
948
1147
|
if (!argv.includes("--watch")) {
|
|
949
|
-
|
|
950
|
-
return visual() ? BANNER + content : content;
|
|
1148
|
+
return renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
|
|
951
1149
|
}
|
|
952
1150
|
const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
|
|
953
1151
|
const webhookUrl = opts.webhookUrl
|
|
@@ -1070,7 +1268,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1070
1268
|
frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
|
|
1071
1269
|
if (tty) {
|
|
1072
1270
|
updateTitle(frame.hotPhase, nowMs);
|
|
1073
|
-
process.stdout.write(`\x1b[2J\x1b[H${
|
|
1271
|
+
process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
|
|
1074
1272
|
}
|
|
1075
1273
|
else {
|
|
1076
1274
|
process.stdout.write(frame.content + sep);
|