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.
Files changed (70) hide show
  1. package/README.md +110 -24
  2. package/dist/cli/commands/approve.d.ts +42 -0
  3. package/dist/cli/commands/approve.js +80 -13
  4. package/dist/cli/commands/doctor.d.ts +234 -0
  5. package/dist/cli/commands/doctor.js +139 -5
  6. package/dist/cli/commands/report.js +15 -44
  7. package/dist/cli/commands/scope.js +36 -6
  8. package/dist/cli/commands/stats.d.ts +2 -0
  9. package/dist/cli/commands/stats.js +42 -23
  10. package/dist/cli/commands/status.js +20 -4
  11. package/dist/cli/commands/ui.js +42 -53
  12. package/dist/cli/commands/unlock.d.ts +1 -1
  13. package/dist/cli/commands/unlock.js +59 -9
  14. package/dist/cli/help.d.ts +219 -0
  15. package/dist/cli/help.js +212 -0
  16. package/dist/cli/index.d.ts +42 -2
  17. package/dist/cli/index.js +23 -8
  18. package/dist/drivers/herdr.d.ts +7 -2
  19. package/dist/drivers/herdr.js +78 -48
  20. package/dist/drivers/orca.d.ts +3 -2
  21. package/dist/drivers/orca.js +43 -4
  22. package/dist/drivers/subprocess.d.ts +1 -0
  23. package/dist/drivers/subprocess.js +3 -0
  24. package/dist/drivers/types.d.ts +14 -0
  25. package/dist/gates/artifact-manifest.d.ts +50 -0
  26. package/dist/gates/artifact-manifest.js +23 -0
  27. package/dist/plan/scope.d.ts +25 -0
  28. package/dist/plan/scope.js +92 -12
  29. package/dist/report/operator-record.d.ts +49 -0
  30. package/dist/report/operator-record.js +137 -0
  31. package/dist/run/daemon.d.ts +3 -4
  32. package/dist/run/daemon.js +18 -10
  33. package/dist/run/lock.d.ts +59 -2
  34. package/dist/run/lock.js +184 -26
  35. package/dist/run/operator-state.d.ts +86 -0
  36. package/dist/run/operator-state.js +165 -0
  37. package/dist/run/supervision.d.ts +32 -0
  38. package/dist/run/supervision.js +138 -17
  39. package/dist/tui/cockpit/capture.d.ts +19 -0
  40. package/dist/tui/cockpit/capture.js +89 -1
  41. package/dist/tui/cockpit/components.d.ts +15 -1
  42. package/dist/tui/cockpit/components.js +79 -9
  43. package/dist/tui/cockpit/decision-actions.d.ts +147 -0
  44. package/dist/tui/cockpit/decision-actions.js +315 -0
  45. package/dist/tui/cockpit/derive.d.ts +1 -1
  46. package/dist/tui/cockpit/derive.js +2 -0
  47. package/dist/tui/cockpit/evidence-view.d.ts +119 -0
  48. package/dist/tui/cockpit/evidence-view.js +210 -0
  49. package/dist/tui/cockpit/home-view.d.ts +88 -0
  50. package/dist/tui/cockpit/home-view.js +240 -0
  51. package/dist/tui/cockpit/keys.d.ts +125 -0
  52. package/dist/tui/cockpit/keys.js +31 -0
  53. package/dist/tui/cockpit/layout.d.ts +14 -0
  54. package/dist/tui/cockpit/layout.js +15 -0
  55. package/dist/tui/cockpit/live-runtime.d.ts +46 -0
  56. package/dist/tui/cockpit/live-runtime.js +683 -0
  57. package/dist/tui/cockpit/live-store.d.ts +289 -0
  58. package/dist/tui/cockpit/live-store.js +308 -0
  59. package/dist/tui/cockpit/live.d.ts +21 -1
  60. package/dist/tui/cockpit/live.js +12 -1
  61. package/dist/tui/cockpit/run-view.d.ts +100 -0
  62. package/dist/tui/cockpit/run-view.js +202 -0
  63. package/dist/tui/cockpit/shell.d.ts +50 -0
  64. package/dist/tui/cockpit/shell.js +74 -0
  65. package/dist/tui/cockpit/theme.d.ts +27 -0
  66. package/dist/tui/cockpit/theme.js +21 -0
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +10 -1
  69. package/skills/tickmarkr-loop/SKILL.md +72 -1
  70. package/skills/tickmarkr-overseer/SKILL.md +12 -0
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { detectPackageManager, turboContinueFindings } from "../../gates/baseline.js";
6
6
  import { version } from "./version.js";
7
- import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, resolveShellBinary, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
7
+ import { allAdapters, binaryShadowWarnings, detectCandidateClis, doctorAgeMs, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, readDoctor, resolveShellBinary, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
8
8
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, claudeCode, resolveClaudeAliasIdentity } from "../../adapters/claude-code.js";
9
9
  import { shq } from "../../adapters/types.js";
10
10
  import { BANNER, compactTokens, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
@@ -347,8 +347,142 @@ export function liveBenchStalenessFinding(now) {
347
347
  return undefined;
348
348
  return `LiveBench table pinned at ${LIVEBENCH_TABLE_DATE} is ${days} days old (>${LIVEBENCH_TABLE_MAX_AGE_DAYS}d) — list ${LIVEBENCH_RELEASES_URL} for a newer table_<date>.csv and bump LIVEBENCH_TABLE_DATE (advisory — tickmarkr never fetches that listing)`;
349
349
  }
350
+ function configuredModels(cfg, adapters) {
351
+ const allowed = adapters ? new Set(adapters.map((adapter) => adapter.id)) : undefined;
352
+ return Object.entries(cfg.tiers).flatMap(([adapter, tier]) => allowed && !allowed.has(adapter) ? [] :
353
+ Object.keys(tier.models ?? {}).map((model) => ({ adapter, model })));
354
+ }
355
+ function cacheAge(ageMs) {
356
+ return ageMs === null ? "unavailable" : `${Math.floor(ageMs / 60_000)}m`;
357
+ }
358
+ /**
359
+ * A no-call disclosure for the action that normal doctor performs. This is deliberately built
360
+ * from config alone: opening the disclosure must not turn a cache reader into a model purchase.
361
+ */
362
+ export function doctorProbePreflight(cwd = process.cwd(), cfg = loadConfig(cwd), adapters = allAdapters()) {
363
+ const models = configuredModels(cfg, adapters);
364
+ const scope = models.length
365
+ ? models.map(({ adapter, model }) => `${adapter}:${model}`).join(", ")
366
+ : "none (no configured models)";
367
+ const state = stateDirName(cwd);
368
+ return [
369
+ "tickmarkr doctor — probe preflight:",
370
+ `configured model-call scope: ${models.length} model${models.length === 1 ? "" : "s"} — ${scope}`,
371
+ `cache policy: explicit probing replaces ${state}/doctor.json; each installed configured model receives one bounded call, with a serial retry only after a first failure (a prior timeout skips that retry)`,
372
+ `affected destinations: ${state}/doctor.json (adapter and model verdict cache); temporary probe prompts are removed`,
373
+ "catalog-only alternative: tickmarkr doctor --refresh-catalog refreshes the catalog cache without adapter or model probes",
374
+ ].join("\n");
375
+ }
376
+ /**
377
+ * Health surfaces use this cache-only reader. It intentionally receives adapters only to render
378
+ * their configured identities; it never invokes adapter.probe(), listModels(), or a model command.
379
+ */
380
+ export function cachedDoctorDiagnostics(cwd = process.cwd(), adapters = allAdapters(), cfg = loadConfig(cwd)) {
381
+ const health = readDoctor(cwd);
382
+ const age = doctorAgeMs(cwd);
383
+ const configured = configuredModels(cfg, adapters);
384
+ const state = stateDirName(cwd);
385
+ const knownAdapters = new Set(adapters.map((adapter) => adapter.id));
386
+ const rows = configured.map(({ adapter, model }) => {
387
+ const cachedAdapter = health?.[adapter];
388
+ const cachedModel = cachedAdapter?.modelAuth?.[model];
389
+ const status = !health
390
+ ? "unknown (cache unavailable)"
391
+ : !knownAdapters.has(adapter)
392
+ ? "unknown (adapter unavailable to this reader)"
393
+ : !cachedAdapter
394
+ ? "unknown (adapter absent from cache)"
395
+ : !cachedAdapter.installed
396
+ ? "unavailable (adapter not installed)"
397
+ : !cachedAdapter.authed
398
+ ? "unavailable (adapter not authenticated)"
399
+ : !cachedModel
400
+ ? "unknown (never probed)"
401
+ : cachedModel.authed
402
+ ? `passed (cached ${cachedModel.probedAt})`
403
+ : `failed (cached ${cachedModel.reason ?? "probe failed"})`;
404
+ return ` ${adapter}:${model} ${status}`;
405
+ });
406
+ return [
407
+ "tickmarkr doctor — cached diagnostics:",
408
+ `cache source: ${health ? `${state}/doctor.json` : "unavailable"}`,
409
+ `cache age: ${cacheAge(age)}`,
410
+ "cache policy: read-only; adapter and model probes were not called",
411
+ ...(rows.length ? ["configured model status:", ...rows] : ["configured model status: none (no configured models)"]),
412
+ ].join("\n");
413
+ }
414
+ const RUNNER_CONFIG_PATHS = [
415
+ "jest.config.js",
416
+ "jest.config.cjs",
417
+ "jest.config.mjs",
418
+ "jest.config.ts",
419
+ "jest.config.json",
420
+ "vitest.config.ts",
421
+ "vitest.config.js",
422
+ "vitest.config.mts",
423
+ "vitest.config.mjs",
424
+ "vitest.config.cts",
425
+ ];
426
+ function runnerConfigSnapshot(cwd) {
427
+ return new Map(RUNNER_CONFIG_PATHS.flatMap((path) => {
428
+ const absolute = join(cwd, path);
429
+ return existsSync(absolute) ? [[path, readFileSync(absolute, "utf8")]] : [];
430
+ }));
431
+ }
432
+ function actualTextDiff(path, before, after) {
433
+ const oldLines = before.split("\n");
434
+ const newLines = after.split("\n");
435
+ let start = 0;
436
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start])
437
+ start += 1;
438
+ let oldEnd = oldLines.length;
439
+ let newEnd = newLines.length;
440
+ while (oldEnd > start && newEnd > start && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
441
+ oldEnd -= 1;
442
+ newEnd -= 1;
443
+ }
444
+ return [
445
+ `--- ${path} (before)`,
446
+ `+++ ${path} (after)`,
447
+ ...oldLines.slice(start, oldEnd).map((line) => `-${line}`),
448
+ ...newLines.slice(start, newEnd).map((line) => `+${line}`),
449
+ ].join("\n");
450
+ }
451
+ /** Repair is an actuator, but never a reason to run the paid diagnostic sensor. */
452
+ export function doctorFixOnly(cwd = process.cwd()) {
453
+ const before = runnerConfigSnapshot(cwd);
454
+ const finding = runnerIgnoreFinding(cwd);
455
+ const repair = finding?.verdict === "warn"
456
+ ? applyRunnerIgnore(cwd)
457
+ : { action: "none", detail: finding?.detail ?? "no recognized test runner" };
458
+ const verified = runnerIgnoreFinding(cwd);
459
+ const diffs = [...before].flatMap(([path, original]) => {
460
+ const current = readFileSync(join(cwd, path), "utf8");
461
+ return current === original ? [] : [actualTextDiff(path, original, current)];
462
+ });
463
+ const result = repair.action === "wrote" && verified?.verdict !== "pass"
464
+ ? "invalid repair result (verification did not pass)"
465
+ : `${repair.action} — ${repair.detail}`;
466
+ return [
467
+ "tickmarkr doctor --fix-only — repair-only:",
468
+ "model probes: skipped (repair-only; adapters were not called)",
469
+ "catalog: untouched",
470
+ `repair result: ${result}`,
471
+ `repair verification: ${verified?.verdict ?? "unavailable"}`,
472
+ "repair diff:",
473
+ ...(diffs.length ? diffs : ["(no runner-config bytes changed)"]),
474
+ ].join("\n");
475
+ }
350
476
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(), opts = {}) {
351
- if (_argv.length === 1 && _argv[0] === "--refresh-catalog") {
477
+ if (_argv.includes("--fix-only"))
478
+ return doctorFixOnly(cwd);
479
+ if (_argv.includes("--cached") || _argv.includes("--cached-only")) {
480
+ return cachedDoctorDiagnostics(cwd, adapters);
481
+ }
482
+ if (_argv.includes("--probe-preflight") || _argv.includes("--preflight")) {
483
+ return doctorProbePreflight(cwd, loadConfig(cwd), adapters);
484
+ }
485
+ if (_argv.length === 1 && (_argv[0] === "--refresh-catalog" || _argv[0] === "--catalog-only")) {
352
486
  const refreshed = await refreshCatalogCommand({ repoRoot: cwd, fetcher: opts.catalogFetcher, now: opts.catalogNow });
353
487
  return refreshed.updated
354
488
  ? `tickmarkr doctor --refresh-catalog: model catalog refreshed — ${formatCatalogRefreshLegs(refreshed.legs)}${refreshed.warning ? `; ${refreshed.warning}` : ""}`
@@ -378,9 +512,9 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
378
512
  // banner at START — the logo greets the operator before the ~60s probe wait, never trailing it (operator report 2026-07-17)
379
513
  if (opts.banner !== false && visual())
380
514
  process.stdout.write(BANNER);
381
- // stderr, live: auth probes are real LLM calls (up to 30s per configured model) and the CLI
382
- // otherwise prints nothing until the end — silence here reads as a hang (v1.33.1)
383
- console.error("probing installed agent CLIs — one short LLM call per configured model, may take a minute...");
515
+ // stderr, live: disclose the paid boundary before the first adapter/model call. The separate
516
+ // --probe-preflight route exposes these same bytes without crossing that boundary.
517
+ console.error(`${doctorProbePreflight(cwd, cfg, adapters)}\nprobing installed agent CLIs — one short LLM call per configured model, may take a minute...`);
384
518
  const probeProgressTTY = process.stderr.isTTY === true;
385
519
  const health = await probeAll(adapters, { cwd });
386
520
  const kimiAdapter = adapters.find((a) => a.id === kimi.id);
@@ -8,34 +8,19 @@ import { modelProvider } from "../../gates/review.js";
8
8
  import { buildProofBundle, gateDeclined } from "../../report/bundle.js";
9
9
  import { compareRuns } from "../../report/compare.js";
10
10
  import { estimateCosts } from "../../report/cost.js";
11
+ import { assignmentChannel, buildOperatorRecord, formatChannelMoney, formatChannelTokens, formatTokenUsage as fields, totalTokens as total, } from "../../report/operator-record.js";
11
12
  import { cellsOf, cellSummary } from "../../route/profile.js";
12
13
  import { Journal, loadRoutingProfile } from "../../run/journal.js";
13
14
  import { deriveRunCockpitData } from "../../tui/cockpit/derive.js";
14
15
  const n = (x) => x.toLocaleString("en-US"); // explicit locale — CI/darwin flake guard
15
16
  const EM = "—";
16
- // TokenUsage fields that are actually present — filtered, never coalesced to zero (absent ⇒ unmetered).
17
- const fields = (u) => {
18
- const parts = [`in ${n(u.input)}`, `out ${n(u.output)}`];
19
- if (u.cacheRead !== undefined && u.cacheWrite !== undefined)
20
- parts.push(`cache r/w ${n(u.cacheRead)}/${n(u.cacheWrite)}`);
21
- if (u.reasoning !== undefined)
22
- parts.push(`reasoning ${n(u.reasoning)}`);
23
- return parts.join(" ");
24
- };
25
- const total = (u) => [u.input, u.output, u.cacheRead, u.cacheWrite, u.reasoning].filter((x) => x !== undefined).reduce((a, b) => a + b, 0);
26
17
  const firstLine = (s) => {
27
18
  if (typeof s !== "string" || !s)
28
19
  return EM;
29
20
  const i = s.indexOf("\n");
30
21
  return (i < 0 ? s : s.slice(0, i)) || EM;
31
22
  };
32
- const channelLabel = (data) => {
33
- const a = data.assignment;
34
- if (!a || typeof a !== "object")
35
- return EM;
36
- const { adapter, model } = a;
37
- return typeof adapter === "string" && typeof model === "string" ? `${adapter}:${model}` : EM;
38
- };
23
+ const channelLabel = (data) => assignmentChannel(data) ?? EM;
39
24
  const modelFromChannel = (channel) => channel.slice(channel.indexOf(":") + 1);
40
25
  const reviewerIdentity = (data) => {
41
26
  const details = typeof data.details === "string" ? data.details : "";
@@ -52,35 +37,10 @@ const reviewerIdentity = (data) => {
52
37
  : modelProvider(modelFromChannel(reviewer), vendor ?? "unknown");
53
38
  return { reviewer, provider, ...(vendor ? { vendor } : {}) };
54
39
  };
55
- const rateBasis = (row) => {
56
- if (!row.rate)
57
- return EM;
58
- const cache = row.rate.cacheReadPerMtok === undefined ? "" : `; cache-read $${row.rate.cacheReadPerMtok}/Mtok`;
59
- const date = row.rate.rateDate === undefined ? "" : `; rate date ${row.rate.rateDate}`;
60
- return `in/out $${row.rate.inPerMtok}/$${row.rate.outPerMtok}/Mtok${cache}${date}`;
61
- };
62
40
  const priceLine = (row) => {
63
41
  const windows = row.channel === "sub" && row.subPlan ? `windows: ${n(row.attempts)}` : `attempts/windows: ${n(row.attempts)}`;
64
- const tokenText = row.tokens
65
- ? `tokens: ${row.partialMetering ? "≥ " : ""}${fields(row.tokens)} (${n(total(row.tokens))} tokens)`
66
- : "tokens: unmetered";
67
- const prices = [];
68
- const bases = [];
69
- if (row.apiUsd !== undefined)
70
- prices.push(`price: $${row.apiUsd.toFixed(6)}`);
71
- if (row.amortizedUsd !== undefined && row.subPlan !== undefined) {
72
- const [low, high] = row.amortizedUsd;
73
- prices.push(`price: $${low.toFixed(6)}–$${high.toFixed(6)} amortized`);
74
- bases.push(`${n(row.attempts)} windows × $${row.subPlan.planMonthly}/month ÷ ${row.subPlan.windowsPerMonthHigh}–${row.subPlan.windowsPerMonthLow} windows/month`);
75
- }
76
- if (row.counterfactualUsd !== undefined)
77
- prices.push(`API-equivalent: $${row.counterfactualUsd.toFixed(6)}`);
78
- if (row.rate)
79
- bases.push(rateBasis(row));
80
- if (!prices.length)
81
- prices.push("price: not measurable");
82
- if (!bases.length)
83
- bases.push(row.reason || "not recorded");
42
+ const tokenText = `tokens: ${formatChannelTokens(row)}`;
43
+ const { prices, bases } = formatChannelMoney(row);
84
44
  return `- **${row.adapter}:${row.model}** — ${windows}; ${tokenText}; ${prices.join("; ")}; basis: ${bases.join("; ")}`;
85
45
  };
86
46
  const journalUsage = (events, known) => {
@@ -257,6 +217,17 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
257
217
  `- **escalations:** ${events.filter((e) => e.event === "escalation").length}`,
258
218
  "",
259
219
  ];
220
+ lines.push("## Channels", "");
221
+ const operatorRecords = buildOperatorRecord(events, prices);
222
+ if (operatorRecords.length) {
223
+ for (const row of operatorRecords) {
224
+ lines.push(`- **${row.channel}** — worker: ${row.worker}, review: ${row.review}, consult: ${row.consult}; tokens: ${row.tokens}; money: ${row.money}`);
225
+ }
226
+ }
227
+ else {
228
+ lines.push("- no channel activity recorded");
229
+ }
230
+ lines.push("");
260
231
  lines.push("## Audit trail", "");
261
232
  for (const taskId of taskIds(events)) {
262
233
  const dispatches = events.filter((e) => e.taskId === taskId && e.event === "task-dispatch");
@@ -1,21 +1,51 @@
1
+ import { createInterface } from "node:readline/promises";
1
2
  import { isAbsolute, join, relative } from "node:path";
2
3
  import { parseArgs } from "node:util";
3
4
  import { allAdapters } from "../../adapters/registry.js";
4
5
  import { loadConfig } from "../../config/config.js";
5
- import { scopeIntent } from "../../plan/scope.js";
6
+ import { formatScopePreview, previewScope, scopeIntent } from "../../plan/scope.js";
7
+ // R11/R16/R45 (C10): --preview is a read-only disclosure (cached candidate, destination, call
8
+ // budget) — it never probes, never calls a model, and never writes. Active scope reuses that same
9
+ // disclosure to build a confirmation question, then requires TTY "y" or --yes before it invokes the
10
+ // unchanged production authoring path (scopeIntent); a non-TTY shell without --yes refuses rather
11
+ // than silently proceeding (the unlock.ts confirm() convention).
6
12
  export async function scope(argv, cwd = process.cwd(), adapters = allAdapters(), driver) {
7
13
  const { values, positionals } = parseArgs({
8
14
  args: argv,
9
- options: { force: { type: "boolean" } },
15
+ options: { force: { type: "boolean" }, preview: { type: "boolean" }, yes: { type: "boolean" } },
10
16
  allowPositionals: true,
11
17
  });
12
18
  if (positionals.length !== 1)
13
- throw new Error("usage: tickmarkr scope <intent-file> [--force]");
19
+ throw new Error("usage: tickmarkr scope <intent-file> [--preview] [--yes] [--force]");
14
20
  const source = positionals[0];
15
21
  const intentFile = isAbsolute(source) ? source : join(cwd, source);
16
- const result = await scopeIntent(intentFile, cwd, {
17
- cfg: loadConfig(cwd), adapters, driver, force: values.force,
18
- });
22
+ const cfg = loadConfig(cwd);
23
+ const preview = previewScope(intentFile, cwd, { cfg, adapters });
24
+ if (values.preview)
25
+ return formatScopePreview(preview);
26
+ const confirm = async (question) => {
27
+ if (values.yes)
28
+ return true;
29
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true)
30
+ return false;
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ try {
33
+ return /^(?:y|yes)$/i.test((await rl.question(`${question} [y/N] `)).trim());
34
+ }
35
+ finally {
36
+ rl.close();
37
+ }
38
+ };
39
+ const candidateLabel = preview.candidate
40
+ ? `${preview.candidate.adapter}:${preview.candidate.model}`
41
+ : `an unknown candidate (${preview.cached ? "no verified channel in the doctor cache" : "no doctor cache"})`;
42
+ const question = `Draft ${relative(cwd, preview.specFile)} via ${candidateLabel} using up to ${preview.authoringBudget} authoring call${preview.authoringBudget === 1 ? "" : "s"} plus ${preview.probeCalls} probe call${preview.probeCalls === 1 ? "" : "s"}?`;
43
+ if (!(await confirm(question))) {
44
+ throw new Error("scope: not confirmed (pass --yes or confirm at a TTY)");
45
+ }
46
+ // Bind dispatch to exactly the candidate just disclosed and confirmed (R11/R45 repair) — scopeIntent
47
+ // fails loud rather than silently authoring against a different channel a fresh probe turns up.
48
+ const result = await scopeIntent(intentFile, cwd, { cfg, adapters, driver, force: values.force, candidate: preview.candidate });
19
49
  const tasks = `${result.tasks} task${result.tasks === 1 ? "" : "s"}`;
20
50
  const calls = `${result.attempts} LLM call${result.attempts === 1 ? "" : "s"}`;
21
51
  return `scoped ${source} → ${relative(cwd, result.specFile)} (${tasks}, ${calls})`;
@@ -1,3 +1,4 @@
1
+ import { type OperatorRecordRow } from "../../report/operator-record.js";
1
2
  export declare const STATS_COLUMNS: readonly ["author", "reviewer", "dispatches", "deliveries", "delivery rate", "attempts to green", "real reds", "infra reds", "within-task rescues"];
2
3
  export interface ChannelStats {
3
4
  author: string;
@@ -14,6 +15,7 @@ export interface ChannelStats {
14
15
  export interface StatsReport {
15
16
  runs: number;
16
17
  channels: ChannelStats[];
18
+ operatorRecords?: OperatorRecordRow[];
17
19
  }
18
20
  /** Reduce every readable run journal under the repository's state directory. */
19
21
  export declare function collectChannelStats(cwd?: string): StatsReport;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { stateDirName } from "../../graph/graph.js";
4
+ import { assignmentChannel, buildOperatorRecord, formatOperatorRecordRow } from "../../report/operator-record.js";
4
5
  import { Journal } from "../../run/journal.js";
5
6
  import { normalizeGateOutcome } from "../../run/outcome.js";
6
7
  // The operator-facing schema is deliberately closed. Adding a fact to the table therefore requires
@@ -17,20 +18,15 @@ export const STATS_COLUMNS = [
17
18
  "infra reds",
18
19
  "within-task rescues",
19
20
  ];
20
- const assignmentAuthor = (data) => {
21
- const assignment = data.assignment;
22
- if (!assignment || typeof assignment !== "object")
23
- return undefined;
24
- const { adapter, model } = assignment;
25
- return typeof adapter === "string" && typeof model === "string"
26
- ? `${adapter}:${model}`
27
- : undefined;
28
- };
21
+ const assignmentAuthor = assignmentChannel;
29
22
  // Current journals write the reviewer into review prose; the structured field is also accepted so
30
23
  // journals produced through gateResultJournalData retain their stronger identity representation.
31
24
  const reviewerFrom = (data) => {
32
25
  if (typeof data.reviewer === "string" && data.reviewer.trim())
33
26
  return data.reviewer.trim();
27
+ const meta = typeof data.meta === "object" && data.meta !== null ? data.meta : undefined;
28
+ if (meta && "reviewer" in meta && typeof meta.reviewer === "string" && meta.reviewer.trim())
29
+ return meta.reviewer.trim();
34
30
  if (typeof data.details !== "string")
35
31
  return undefined;
36
32
  return /\breviewer(?:\s+|:\s*)([\w@./+-]+:[\w@./+-]+)/iu.exec(data.details)?.[1];
@@ -79,8 +75,10 @@ export function collectChannelStats(cwd = process.cwd()) {
79
75
  }
80
76
  return channel;
81
77
  };
78
+ const allEvents = [];
82
79
  for (const runId of runIds) {
83
80
  const events = Journal.open(cwd, runId).read();
81
+ allEvents.push(...events);
84
82
  const tasks = new Map();
85
83
  for (const [eventIndex, event] of events.entries()) {
86
84
  if (!event.taskId)
@@ -124,6 +122,16 @@ export function collectChannelStats(cwd = process.cwd()) {
124
122
  }
125
123
  continue;
126
124
  }
125
+ if (event.event === "review-leg2") {
126
+ const author = dispatchedAuthorFor(history, event.data)
127
+ ?? (typeof event.data.author === "string" && event.data.author.includes(":") ? event.data.author : undefined);
128
+ if (author) {
129
+ const reviewer = reviewerFrom(event.data);
130
+ if (reviewer)
131
+ channelFor(author).reviewers.add(reviewer);
132
+ }
133
+ continue;
134
+ }
127
135
  if (event.event !== "gate-result")
128
136
  continue;
129
137
  const author = dispatchedAuthorFor(history, event.data);
@@ -170,26 +178,37 @@ export function collectChannelStats(cwd = process.cwd()) {
170
178
  infraReds: channel.infraReds,
171
179
  rescues: [...channel.rescues].sort((a, b) => a.localeCompare(b, "en")),
172
180
  })),
181
+ operatorRecords: buildOperatorRecord(allEvents),
173
182
  };
174
183
  }
175
184
  const percent = (rate) => `${Number((rate * 100).toFixed(1))}%`;
176
185
  export function renderStats(report) {
177
186
  const lines = [`tickmarkr stats — ${report.runs} run${report.runs === 1 ? "" : "s"}`];
178
- if (report.channels.length === 0)
187
+ if (report.channels.length === 0 && (!report.operatorRecords || report.operatorRecords.length === 0)) {
179
188
  return [...lines, "no channels"].join("\n");
180
- lines.push(STATS_COLUMNS.join(" | "));
181
- for (const channel of report.channels) {
182
- lines.push([
183
- channel.author,
184
- channel.reviewers.join(", ") || "—",
185
- channel.dispatches,
186
- channel.deliveries,
187
- percent(channel.deliveryRate),
188
- channel.attemptsToGreen === null ? "—" : Number(channel.attemptsToGreen.toFixed(2)),
189
- channel.realReds,
190
- channel.infraReds,
191
- channel.rescues.join("; ") || "—",
192
- ].join(" | "));
189
+ }
190
+ if (report.channels.length > 0) {
191
+ lines.push(STATS_COLUMNS.join(" | "));
192
+ for (const channel of report.channels) {
193
+ lines.push([
194
+ channel.author,
195
+ channel.reviewers.join(", ") || "—",
196
+ channel.dispatches,
197
+ channel.deliveries,
198
+ percent(channel.deliveryRate),
199
+ channel.attemptsToGreen === null ? "—" : Number(channel.attemptsToGreen.toFixed(2)),
200
+ channel.realReds,
201
+ channel.infraReds,
202
+ channel.rescues.join("; ") || "—",
203
+ ].join(" | "));
204
+ }
205
+ }
206
+ if (report.operatorRecords && report.operatorRecords.length > 0) {
207
+ lines.push("");
208
+ lines.push("channels:");
209
+ for (const row of report.operatorRecords) {
210
+ lines.push(` ${formatOperatorRecordRow(row)}`);
211
+ }
193
212
  }
194
213
  return lines.join("\n");
195
214
  }
@@ -13,9 +13,8 @@ import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
13
13
  import { normalizeGateOutcome } from "../../run/outcome.js";
14
14
  import { desiredPanes } from "../../run/reconcile.js";
15
15
  import { normalizeStallSnapshot } from "../../run/stall.js";
16
- import { armWatchSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
16
+ import { armWatchSupervision, observeNamedRun, WATCH_OWNER_ENV, readSupervision, supervisionText } from "../../run/supervision.js";
17
17
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
18
- import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
19
18
  import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
20
19
  import { version } from "./version.js";
21
20
  // The board is a LIVE surface, so it draws through brand.ts's operator role map and never the vivid
@@ -1093,7 +1092,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1093
1092
  }
1094
1093
  // TTY: the operator-approved task table. The daemon gives this surface a ~110-column pane first;
1095
1094
  // narrower terminals keep the same facts in stacked rows. Every cut/wrap goes through width.ts.
1096
- const boardColumns = Math.max(COCKPIT_COLUMN_FLOOR, width);
1095
+ const boardColumns = Math.max(40, width);
1097
1096
  const dot = dim(" · ");
1098
1097
  const separator = ` ${dim("│")} `;
1099
1098
  const tipFailed = tipPhase?.state === "failed";
@@ -1343,6 +1342,15 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1343
1342
  const sleep = opts.sleep ?? defaultSleep;
1344
1343
  const now = opts.now ?? Date.now;
1345
1344
  const bounded = Number.isFinite(iterations);
1345
+ // Bounded snapshots and printed twins never import Ink. Public interactive watch
1346
+ // selects its run once through the same launcher as manual UI.
1347
+ if (!bounded && !eventStream && !argv.includes("--plain") && process.stdin.isTTY === true && process.stdout.isTTY === true) {
1348
+ const { ui } = await import("./ui.js");
1349
+ const result = await ui([...(namedRunId ? [namedRunId] : []), "--view", "run"], {}, cwd);
1350
+ if (typeof result !== "string")
1351
+ throw new Error(result.out);
1352
+ return "";
1353
+ }
1346
1354
  const frames = [];
1347
1355
  const eventLines = [];
1348
1356
  const sep = "\n---\n";
@@ -1455,7 +1463,8 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1455
1463
  // it INVERTS it: armSupervision's interval outlives the board in any host that outlives one board, so
1456
1464
  // a dead board keeps writing and reads ARMED. That is the over-claiming direction, the one an operator
1457
1465
  // acts on, and the one this instrument exists to close.
1458
- const armed = bounded ? undefined : armWatchSupervision(cwd, opts.supervisionBeatMs);
1466
+ const boardObservation = !bounded && namedRunId && process.env[WATCH_OWNER_ENV] ? observeNamedRun(cwd, namedRunId) : undefined;
1467
+ const armed = bounded || boardObservation ? undefined : armWatchSupervision(cwd, opts.supervisionBeatMs);
1459
1468
  let alternateScreen = false;
1460
1469
  const leaveAlternateScreen = () => {
1461
1470
  if (!alternateScreen)
@@ -1491,6 +1500,8 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1491
1500
  if (exitingFromSignal)
1492
1501
  return;
1493
1502
  exitingFromSignal = true;
1503
+ armed?.disarm();
1504
+ boardObservation?.close();
1494
1505
  if (titleSaved) {
1495
1506
  process.removeListener("exit", restoreTitle);
1496
1507
  restoreTitle();
@@ -1507,6 +1518,8 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1507
1518
  process.stdout.write(ENTER_ALTERNATE_SCREEN);
1508
1519
  alternateScreen = true;
1509
1520
  process.once("exit", leaveAlternateScreen);
1521
+ }
1522
+ if (!bounded) {
1510
1523
  for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
1511
1524
  const handler = exitFromSignal(code);
1512
1525
  signalHandlers.push([signal, handler]);
@@ -1515,6 +1528,8 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1515
1528
  }
1516
1529
  try {
1517
1530
  for (let i = 0; i < iterations; i++) {
1531
+ if (boardObservation?.stopRequested())
1532
+ break;
1518
1533
  const nowMs = now();
1519
1534
  const decisionEvents = eventStream || webhookUrl ? consumeDecisionEvents() : [];
1520
1535
  let frame;
@@ -1558,6 +1573,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1558
1573
  // armSupervision's presence files). Otherwise the first pane closed would render the second
1559
1574
  // pane's own tier down while it is drawing frames.
1560
1575
  armed?.disarm();
1576
+ boardObservation?.close();
1561
1577
  if (titleSaved) {
1562
1578
  process.removeListener("exit", restoreTitle);
1563
1579
  restoreTitle();
@@ -1,64 +1,53 @@
1
+ import { join } from "node:path";
2
+ import { Journal, parseRunId } from "../../run/journal.js";
3
+ import { stateDirName } from "../../graph/graph.js";
4
+ import { JournalTail } from "../../tui/cockpit/live-store.js";
1
5
  const NON_TTY_MSG = "tickmarkr ui: the cockpit requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
2
- /**
3
- * The setup surface's own flag. `tickmarkr ui --setup [run-id]` opens the one
4
- * surface that writes — parked human decisions, approved or upheld through the
5
- * production command path behind an explicit confirm.
6
- */
7
- const SETUP_FLAG = "--setup";
8
6
  export async function ui(argv, io = {}, cwd = process.cwd()) {
9
7
  const input = io.input ?? process.stdin;
10
8
  const output = io.output ?? process.stdout;
11
- if (input.isTTY !== true || output.isTTY !== true) {
9
+ if (input.isTTY !== true || output.isTTY !== true)
12
10
  return { out: NON_TTY_MSG, code: 1 };
13
- }
14
- if (argv.includes("--demo")) {
15
- const { runCockpitDemo } = await import("../../tui/cockpit/demo.js");
16
- const { version } = await import("./version.js");
17
- await runCockpitDemo({
18
- input,
19
- output,
20
- binaryVersion: await version(),
21
- });
22
- return "ui: closed";
23
- }
24
- const setup = argv.includes(SETUP_FLAG);
25
- const unknownFlag = argv.find((arg) => arg.startsWith("-") && arg !== SETUP_FLAG);
26
- if (unknownFlag) {
27
- return { out: `tickmarkr ui: unknown flag ${unknownFlag}`, code: 1 };
28
- }
29
- const live = await import("../../tui/cockpit/live.js");
30
- const { version } = await import("./version.js");
31
- const binaryVersion = await version();
32
- const explicit = argv.find((arg) => !arg.startsWith("-"));
33
- let runId;
11
+ let explicit;
12
+ let view = "home";
13
+ let setup = false;
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const arg = argv[i];
16
+ if (arg === "--setup")
17
+ setup = true;
18
+ else if (arg === "--view") {
19
+ const value = argv[++i];
20
+ if (value !== "home" && value !== "run" && value !== "evidence")
21
+ return { out: "tickmarkr ui: --view must be home, run or evidence", code: 1 };
22
+ view = value;
23
+ }
24
+ else if (arg.startsWith("-"))
25
+ return { out: `tickmarkr ui: unknown flag ${arg}`, code: 1 };
26
+ else if (explicit !== undefined)
27
+ return { out: "tickmarkr ui: expected at most one run ID", code: 1 };
28
+ else
29
+ explicit = arg;
30
+ }
31
+ let runId = null;
34
32
  try {
35
- runId = live.selectEngagementRunId(cwd, explicit);
33
+ runId = explicit === undefined ? Journal.latestRunId(cwd, { withJournal: true }) : parseRunId(explicit);
34
+ // Refuse an invalid explicit target before importing or borrowing the terminal.
35
+ if (runId) {
36
+ const initial = new JournalTail(join(cwd, stateDirName(cwd), "runs", runId, "journal.jsonl")).poll();
37
+ if (initial.status === "unreadable" || initial.status === "corrupt")
38
+ throw new Error(initial.error?.error ?? initial.errors[0]?.error ?? "journal corrupt");
39
+ }
36
40
  }
37
41
  catch (error) {
38
- return { out: `tickmarkr ui: ${error.message}`, code: 1 };
39
- }
40
- if (runId === null) {
41
- return {
42
- out: "tickmarkr ui: no engagement found — start one with `tickmarkr run` first",
43
- code: 1,
44
- };
42
+ return { out: `tickmarkr ui: cannot read engagement ${runId ?? explicit ?? "latest"}: ${error.message}`, code: 1 };
45
43
  }
46
- // Refuse before drawing anything: unreadable journal bytes must never
47
- // become a plausible surface derived from nothing.
48
- try {
49
- live.deriveLiveRunCockpitData(live.loadEngagementSource(cwd, runId), binaryVersion);
50
- }
51
- catch (error) {
52
- return {
53
- out: `tickmarkr ui: cannot read engagement ${runId}: ${error.message}`,
54
- code: 1,
55
- };
56
- }
57
- if (setup) {
58
- const { runSetupDecisionsCockpit } = await import("../../tui/cockpit/setup-cockpit.js");
59
- await runSetupDecisionsCockpit({ input, output, cwd, runId });
60
- return "ui: closed";
61
- }
62
- await live.runLiveCockpit({ input, output, cwd, runId, binaryVersion });
44
+ const { runLiveCockpit } = await import("../../tui/cockpit/live.js");
45
+ const { version } = await import("./version.js");
46
+ const options = {
47
+ input, output, cwd, runId: runId ?? "run-no-run", binaryVersion: await version(),
48
+ observeRun: runId !== null, initialView: runId === null ? "home" : setup ? "run" : view,
49
+ initialParks: setup,
50
+ };
51
+ await runLiveCockpit(options);
63
52
  return "ui: closed";
64
53
  }
@@ -1 +1 @@
1
- export declare function unlock(_argv: string[], cwd?: string): Promise<string>;
1
+ export declare function unlock(argv: string[], cwd?: string): Promise<string>;