tickmarkr 1.97.0 → 2.1.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 (47) hide show
  1. package/README.md +19 -1
  2. package/dist/brand.d.ts +28 -0
  3. package/dist/brand.js +41 -0
  4. package/dist/cli/commands/compile.js +32 -1
  5. package/dist/cli/commands/doctor.d.ts +19 -0
  6. package/dist/cli/commands/doctor.js +59 -0
  7. package/dist/cli/commands/init.js +5 -2
  8. package/dist/cli/commands/resume.js +25 -8
  9. package/dist/cli/commands/run.d.ts +61 -1
  10. package/dist/cli/commands/run.js +372 -18
  11. package/dist/cli/commands/status.js +145 -28
  12. package/dist/cli/index.d.ts +1 -1
  13. package/dist/cli/index.js +1 -1
  14. package/dist/compile/collateral.d.ts +25 -0
  15. package/dist/compile/collateral.js +46 -11
  16. package/dist/compile/native.js +10 -0
  17. package/dist/config/config.d.ts +1 -0
  18. package/dist/config/config.js +2 -2
  19. package/dist/drivers/herdr.d.ts +19 -13
  20. package/dist/drivers/herdr.js +90 -26
  21. package/dist/drivers/index.d.ts +5 -1
  22. package/dist/drivers/index.js +16 -1
  23. package/dist/drivers/orca.d.ts +189 -0
  24. package/dist/drivers/orca.js +879 -0
  25. package/dist/drivers/types.d.ts +2 -0
  26. package/dist/gates/acceptance.js +17 -7
  27. package/dist/gates/llm.d.ts +19 -0
  28. package/dist/gates/llm.js +104 -6
  29. package/dist/gates/run-gates.d.ts +18 -0
  30. package/dist/gates/run-gates.js +195 -29
  31. package/dist/gates/scope.d.ts +9 -1
  32. package/dist/gates/scope.js +22 -2
  33. package/dist/graph/graph.d.ts +1 -0
  34. package/dist/graph/graph.js +19 -2
  35. package/dist/report/compare.js +17 -2
  36. package/dist/run/daemon.d.ts +1 -8
  37. package/dist/run/daemon.js +231 -246
  38. package/dist/run/environment.d.ts +18 -1
  39. package/dist/run/environment.js +19 -2
  40. package/dist/run/journal.d.ts +50 -3
  41. package/dist/run/journal.js +181 -5
  42. package/dist/run/protocol.d.ts +4 -4
  43. package/dist/run/stall.d.ts +30 -0
  44. package/dist/run/stall.js +173 -0
  45. package/dist/tui/ink/init-app.js +14 -4
  46. package/package.json +1 -1
  47. package/skills/tickmarkr-overseer/SKILL.md +27 -15
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { brandChip, dim, fail, GLYPHS, legend, ok, rule, title, warn } from "../../brand.js";
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";
@@ -15,8 +15,42 @@ import { readSupervision, supervisionText } from "../../run/supervision.js";
15
15
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
16
16
  import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
17
17
  import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
18
- // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
19
- const REFRESH_MS = 2000;
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
- // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
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)
@@ -493,6 +527,10 @@ const foldTaskEffort = (tasks, events) => {
493
527
  return [...effort.values()].sort((left, right) => right.total - left.total
494
528
  || order.get(left.taskId) - order.get(right.taskId));
495
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" };
496
534
  /**
497
535
  * Prototype panel, fitted by the cockpit's display-cell authority before board-wide wrapping.
498
536
  *
@@ -518,9 +556,9 @@ const effortPanel = (tasks, events, columns) => {
518
556
  const dispatchEnd = Math.round((task.dispatches / maxTotal) * barColumns);
519
557
  const reviewEnd = Math.round(((task.dispatches + task.reviews) / maxTotal) * barColumns);
520
558
  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)));
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)));
524
562
  return `${prefix}${fitCells(stack, barColumns)} ${counts}`;
525
563
  });
526
564
  return [
@@ -529,7 +567,7 @@ const effortPanel = (tasks, events, columns) => {
529
567
  "",
530
568
  ...rows,
531
569
  "",
532
- ` ${ok("█")} ${dim("dispatch")} ${warn("█")} ${dim("review round")} ${fail("█")} ${dim("human park")}`,
570
+ ` ${ok(EFFORT_GLYPH.dispatch)} ${dim("dispatch")} ${warn(EFFORT_GLYPH.review)} ${dim("review round")} ${fail(EFFORT_GLYPH.park)} ${dim("human park")}`,
533
571
  ];
534
572
  };
535
573
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
@@ -829,7 +867,10 @@ const recordedTasks = (graph, rows) => graph.tasks.filter((task) => {
829
867
  || row.parkKind !== undefined);
830
868
  });
831
869
  const recordedDone = (tasks, rows) => tasks.filter((task) => graphTaskStatus(rows.get(task.id)?.state, task.status) === "done").length;
832
- const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map(), journalRowsOnly = false, namedRunId) => {
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) => {
833
874
  const g = loadGraph(cwd);
834
875
  const record = readRunRecord(cwd, g, namedRunId);
835
876
  const runId = record?.runId;
@@ -975,16 +1016,18 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
975
1016
  const tipFailed = tipPhase?.state === "failed";
976
1017
  const anyFailed = cells.some((cell) => cell.redTier);
977
1018
  const progressTone = anyFailed || tipFailed ? fail : tipPhase ? warn : ok;
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;
978
1022
  const tally = tipPhase
979
- ? `${progressTone(`${done}/${total} tasks done`)}${dot}${progressTone("run not verified")}`
980
- : done === total && total > 0 ? ok(`${done}/${total} done`) : `${done}/${total} done`;
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`);
981
1025
  const live = liveness(events, now)
982
1026
  .replace(/\bdead\b/u, fail("dead"))
983
1027
  .replace(/\bfinished\b/u, dim("finished"))
984
- .replace(/\balive\b/u, ok("alive"))
1028
+ .replace(/\balive\b/u, running("alive"))
985
1029
  .replaceAll(" · ", dot);
986
- const header = [
987
- brandChip(" tickmarkr "),
1030
+ const facts = [
988
1031
  title(runId ?? "no runs yet"),
989
1032
  tally,
990
1033
  ...(events.length ? [`${events.filter((event) => event.event === "run-resume").length} restarts`] : []),
@@ -993,10 +1036,34 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
993
1036
  ...(runId && !comparable ? [warn(NOT_COMPARABLE_NOTICE)] : []),
994
1037
  ...(verify === undefined
995
1038
  ? []
996
- : [verify === "verify passed" ? ok(verify) : tipFailed ? fail(verify) : warn(verify)]),
1039
+ : [verify === "verify passed"
1040
+ ? ok(verify)
1041
+ : tipFailed ? fail(`${GLYPHS.fail} ${verify}`) : warn(`${GLYPHS.attention} ${verify}`)]),
997
1042
  ...(runId ? [live] : []),
998
1043
  ...(journalRowsOnly ? [`zone ${localZoneLabel(zoneReference)}`] : []),
999
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
+ ];
1000
1067
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
1001
1068
  const supervisionLegend = legend(` ${supervisionText(supervision)}`);
1002
1069
  const taskSectionSummary = `${done} of ${total} merged · rows in graph order · gates left→right in pipeline order`;
@@ -1018,7 +1085,15 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
1018
1085
  return [
1019
1086
  livePhase ? `${SPINNER[animationFrame % SPINNER.length]} ${phrase ?? "running"}` : "",
1020
1087
  !livePhase && isStarved ? "starved" : "",
1021
- !livePhase && !isStarved && !merged ? redTier ? "failed" : phrase ?? (st === "failed" ? "warn" : surfaceStatusWord(st)) : "",
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
+ : "",
1022
1097
  redTier && phrase ? phrase : "",
1023
1098
  priorGraph ? PRIOR_GRAPH_MARKER : "",
1024
1099
  failed.length ? `failed · ${failed.join(", ")}` : "",
@@ -1048,7 +1123,15 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
1048
1123
  return cells.flatMap((cell) => {
1049
1124
  const effort = effortByTask.get(cell.t.id);
1050
1125
  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;
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;
1052
1135
  const machinery = [
1053
1136
  `deps ${deps}`,
1054
1137
  gateMatrix(cell.states),
@@ -1078,7 +1161,15 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
1078
1161
  wrapCells(noteFor(cell), noteWidth),
1079
1162
  ];
1080
1163
  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;
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;
1082
1173
  const titleTone = cell.redTier || cell.livePhase || (effort?.parks ?? 0) >= 3 ? title : dim;
1083
1174
  return Array.from({ length: height }, (_, index) => ` ${idTone(fitColumn(columns[0][index] ?? "", idWidth, 1))}`
1084
1175
  + dim(fitColumn(columns[1][index] ?? "", areaWidth))
@@ -1096,7 +1187,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
1096
1187
  const effort = effortPanel(g.tasks, record && !comparable ? undefined : events, boardColumns);
1097
1188
  return {
1098
1189
  content: boardRows([
1099
- header,
1190
+ ...headerRows,
1100
1191
  ...nowLine,
1101
1192
  supervisionLegend,
1102
1193
  "",
@@ -1142,10 +1233,12 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1142
1233
  const namedRunId = positionalRunId(argv);
1143
1234
  if (argv.includes("--oneline"))
1144
1235
  return oneLine(cwd, namedRunId);
1145
- // The task-table frame owns its compact one-line brand chip; the four-row global banner would
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
1146
1239
  // create the second header the approved replacement removes.
1147
1240
  if (!argv.includes("--watch")) {
1148
- return renderFrame(cwd, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
1241
+ return renderFrame(cwd, binaryVersion, opts.now?.() ?? Date.now(), 0, undefined, false, namedRunId).content;
1149
1242
  }
1150
1243
  const eventStream = argv.some((arg) => arg === "--events" || arg === "--jsonl" || arg === "--decision-events");
1151
1244
  const webhookUrl = opts.webhookUrl
@@ -1173,14 +1266,21 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1173
1266
  }
1174
1267
  }
1175
1268
  : undefined);
1176
- const observeWorkerOutput = async (active, runId, observedAt) => {
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) => {
1177
1272
  const activeIds = new Set(active.map((phase) => phase.taskId));
1178
1273
  for (const taskId of workerLiveness.keys())
1179
1274
  if (!activeIds.has(taskId))
1180
1275
  workerLiveness.delete(taskId);
1276
+ };
1277
+ const scrapeWorkerOutput = async (active, runId, observedAt) => {
1181
1278
  if (!readWorkerOutput || !runId)
1182
1279
  return;
1183
- await Promise.all(active.map(async (phase) => {
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) => {
1184
1284
  const output = await readWorkerOutput(phase.taskId, phase.attempt, runId);
1185
1285
  if (output === undefined)
1186
1286
  return;
@@ -1203,6 +1303,21 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1203
1303
  prior.snapshot = snapshot;
1204
1304
  }));
1205
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
+ };
1206
1321
  let decisionRunId;
1207
1322
  let journalCursor = 0;
1208
1323
  const consumeDecisionEvents = () => {
@@ -1265,10 +1380,10 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1265
1380
  }
1266
1381
  }
1267
1382
  else {
1268
- frame = renderFrame(cwd, nowMs, i, workerLiveness, true, namedRunId);
1383
+ frame = renderFrame(cwd, binaryVersion, nowMs, i, workerLiveness, true, namedRunId);
1269
1384
  if (tty) {
1270
1385
  updateTitle(frame.hotPhase, nowMs);
1271
- process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${REFRESH_MS / 1000}s · ^C to quit`)}`);
1386
+ process.stdout.write(`\x1b[2J\x1b[H${frame.content}\n${legend(` watching · refresh ${FRAME_MS / 1000}s · ^C to quit`)}`);
1272
1387
  }
1273
1388
  else {
1274
1389
  process.stdout.write(frame.content + sep);
@@ -1277,9 +1392,11 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1277
1392
  frames.push(frame.content);
1278
1393
  }
1279
1394
  if (i + 1 < iterations) {
1280
- if (frame)
1281
- await observeWorkerOutput(frame.workerPhases, frame.runId, nowMs);
1282
- await sleep(REFRESH_MS);
1395
+ if (frame) {
1396
+ retireInactiveWorkers(frame.workerPhases);
1397
+ scheduleWorkerScrape(frame, nowMs);
1398
+ }
1399
+ await sleep(FRAME_MS);
1283
1400
  }
1284
1401
  }
1285
1402
  }
@@ -5,7 +5,7 @@ export type CommandResult = string | {
5
5
  };
6
6
  export type CommandMap = Record<string, (argv: string[]) => Promise<CommandResult>>;
7
7
  export declare const COMMANDS: CommandMap;
8
- export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
8
+ export declare const USAGE = "tickmarkr \u2014 spec-driven orchestration harness for AI coding agents\nusage: tickmarkr <command>\n init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs\n doctor re-probe adapters, herdr, auth; print capability matrix (--fix writes the test-runner ignore when a safe edit exists)\n fleet interactive fleet editor (fleet --print for CI drift checks)\n compile <src> spec \u2192 .tickmarkr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n eval run checked-in fixtures against every channel in isolated temp repos\n run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)\n status live run state\n verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD \u2014 no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n ui open the Fleet Studio TUI (full-screen tabbed cockpit)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n beat <tier> record one supervision beat for orchestrator|overseer|watch (--stand-down to hand off); a supervising seat's own watcher loop calls it, and status reads the tier STALE once the beats stop\n approve <id> <task> release a park (--uphold sides with the reviewer and funds a fixed attempt; --by <name> --reason <text>); takes effect on resume";
9
9
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
10
10
  out: string;
11
11
  code: number;
package/dist/cli/index.js CHANGED
@@ -35,7 +35,7 @@ usage: tickmarkr <command>
35
35
  scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)
36
36
  plan dry-run routing table + cost estimate + floor lints
37
37
  eval run checked-in fixtures against every channel in isolated temp repos
38
- run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)
38
+ run execute the graph (--concurrency N --driver auto|herdr|subprocess|orca --route-strict; orca runs only when named)
39
39
  status live run state
40
40
  verify run the gate battery standalone against merge-base(--base, HEAD)..HEAD — no daemon, one verdict (--base main --criteria <file> | --task <id> [--files <glob>] [--author adapter:model] [--no-review] [--json])
41
41
  resume <id> continue a run from its journal
@@ -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.
@@ -3,8 +3,12 @@ import { extname, join, relative } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
5
5
  import { renderAcceptanceItem } from "../graph/schema.js";
6
- // Advisory plan-time scan only (OBS-12/13/14/21, OBS-76). NEVER expands files[], fails compile,
7
- // or feeds the scope gate — a warning the author acts on. Plain-text (no AST), capped + sorted.
6
+ // Advisory scan (OBS-12/13/14/21, OBS-76). NEVER expands files[] or fails compile — a warning the
7
+ // author acts on. Plain-text (no AST), capped + sorted for DISPLAY only.
8
+ // OBS-547: the collateral prediction is no longer plan-time-only. The daemon computes one full
9
+ // (uncapped) map per run and hands each task's slice to its scope gate, which classifies a red
10
+ // against it — the prediction never causes a refusal, it only says whether a red the gate already
11
+ // found was foreseeable (authoring defect) or not (chargeable quality failure).
8
12
  /** Max files walked per root (sorted walk; rest ignored). */
9
13
  const MAX_WALK_FILES = 400;
10
14
  /** Max collateral test paths listed per task. */
@@ -95,15 +99,17 @@ function makeReader(repoRoot) {
95
99
  };
96
100
  }
97
101
  /**
98
- * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
99
- * Each line names the task id and at least one missing collateral test path.
102
+ * OBS-547: the FULL per-task collateral prediction, uncapped. ONE map is computed per run (daemon.ts,
103
+ * at run start) and handed whole to the scope gate, so a hit the plan display hides is still there
104
+ * when the gate asks about it. The lint lines below are a capped VIEW of this same map — never a
105
+ * second scan. Tasks with no hits are absent.
100
106
  */
101
- export function collateralLints(tasks, repoRoot) {
107
+ export function collateralHits(tasks, repoRoot) {
108
+ const map = new Map();
102
109
  const testFiles = walkCode(repoRoot, "tests");
103
110
  if (!testFiles.length)
104
- return [];
111
+ return map;
105
112
  const read = makeReader(repoRoot);
106
- const lines = [];
107
113
  for (const t of tasks) {
108
114
  // OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
109
115
  const scoped = filesGlob(t.files.map((f) => f.replace(/^\.\//, "")));
@@ -122,14 +128,43 @@ export function collateralLints(tasks, repoRoot) {
122
128
  if (mentions(text, needles))
123
129
  hits.push(tf);
124
130
  }
125
- if (!hits.length)
126
- continue;
127
131
  // deterministic: walk already sorted; stable list
132
+ if (hits.length)
133
+ map.set(t.id, hits);
134
+ }
135
+ return map;
136
+ }
137
+ /** The verbatim repair an authoring defect owes: the files[] lines the spec is missing. */
138
+ export function filesRepair(taskId, paths) {
139
+ return paths.map((p) => `add ${p} to ${taskId}.files[]`).join("\n");
140
+ }
141
+ /**
142
+ * OBS-547: cross-reference at the RED. The gate supplies specificity (the paths actually touched),
143
+ * the prediction supplies classification — so there are no false positives to fear and no refusal to
144
+ * author. Feed this the FULL map for the task: a classifier bounded by the display cap calls a
145
+ * predicted 21st hit a quality failure.
146
+ */
147
+ export function classifyScopeOffenders(taskId, hard, predicted) {
148
+ const named = new Set(predicted);
149
+ const hit = hard.filter((f) => named.has(f));
150
+ const missed = hard.filter((f) => !named.has(f));
151
+ return { authoring: hard.length > 0 && missed.length === 0, predicted: hit, missed, repair: filesRepair(taskId, hit) };
152
+ }
153
+ /**
154
+ * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
155
+ * Each line names the task id and at least one missing collateral test path.
156
+ */
157
+ export function collateralLints(tasks, repoRoot) {
158
+ const lines = [];
159
+ for (const [id, hits] of collateralHits(tasks, repoRoot)) {
128
160
  const listed = hits.slice(0, MAX_HITS_PER_TASK).join(", ");
161
+ // OBS-547: the cap hides names, never predictions. Say so, and say where the hidden ones surface —
162
+ // a count with no route is exactly what left one run's victim unreadable.
129
163
  const tail = hits.length > MAX_HITS_PER_TASK
130
- ? ` (${hits.length} total; capped at ${MAX_HITS_PER_TASK} shown)`
164
+ ? ` (${hits.length} total; ${MAX_HITS_PER_TASK} shown, ${hits.length - MAX_HITS_PER_TASK} capped out of view`
165
+ + ` but RETAINED for the scope gate — a matching scope red prints the hidden path with its files[] repair)`
131
166
  : "";
132
- lines.push(`${t.id}: likely collateral tests not in files[]: ${listed}${tail}`);
167
+ lines.push(`${id}: likely collateral tests not in files[]: ${listed}${tail}`);
133
168
  }
134
169
  return lines;
135
170
  }
@@ -728,6 +728,16 @@ acceptance is required on every task (a nested list of observable outcomes).
728
728
  - Enumerating one axis exhaustively is what hides the others. A spec that guards PARTIAL coverage
729
729
  site-by-site, member-by-member, can be defeated wholesale by CONDITIONAL coverage, which leaves
730
730
  every enumeration satisfied. After you enumerate, ask what a single flag would do to the whole set.
731
+ - EVERY CRITERION NAMES THE PAIR IT DISCRIMINATES: the correct case that MUST PASS, and the
732
+ neighbouring plausible-wrong or false-clean case that MUST FAIL. Two easy examples that both pass are
733
+ not discrimination — they are two ways of being green, and the wrong half is the whole point: it is
734
+ what a reader would mistake for the mechanism, its VOCABULARY without its behaviour. Measured three
735
+ times in three days, each a criterion that pinned vocabulary instead of the discriminating case: a
736
+ suite that pinned an alarm's text while the alarm itself went unpinned; an export asserted SOMEWHERE,
737
+ which says nothing about the environment a process actually receives; and a gate green on "infra is
738
+ what execution says" while a signalled oracle still charged an attempt. Each was satisfied exactly as
739
+ written and each shipped the defect. If you cannot name the case that must FAIL, you have named a
740
+ topic, not a criterion — and the "so X fails" clause that ends a good criterion is where it goes.
731
741
  - A criterion that names a behaviour must name the VALUE AT WHICH IT WOULD BREAK. Every criterion is a
732
742
  claim about a variable — a status, a width, a count, an arrival time — and if it does not say which
733
743
  value of that variable is the hard one, THE TEST WILL CHOOSE THE EASY ONE AND BE GREEN. Measured: a
@@ -138,6 +138,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
138
138
  auto: "auto";
139
139
  herdr: "herdr";
140
140
  subprocess: "subprocess";
141
+ orca: "orca";
141
142
  }>;
142
143
  integrationBranchPrefix: z.ZodString;
143
144
  taskTimeoutMinutes: z.ZodNumber;
@@ -266,7 +266,7 @@ const ShapeGateParticipationSchema = z
266
266
  });
267
267
  export const TickmarkrConfigSchema = z.object({
268
268
  concurrency: z.number().int().positive(),
269
- driver: z.enum(["auto", "herdr", "subprocess"]),
269
+ driver: z.enum(["auto", "herdr", "subprocess", "orca"]),
270
270
  integrationBranchPrefix: z
271
271
  .string()
272
272
  .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "must be branch-safe (letters/digits/._/-, no spaces or shell metacharacters)")
@@ -713,7 +713,7 @@ export function overlayBytesLoadError(repoRoot, bytes, opts = {}) {
713
713
  export function configTemplate(overlay) {
714
714
  const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
715
715
  # concurrency: 3
716
- # driver: auto # auto | herdr | subprocess
716
+ # driver: auto # auto | herdr | subprocess | orca
717
717
  # taskTimeoutMinutes: 30
718
718
  # contextWarnTokens: 170000 # v1.23: journal+notify once per attempt when live worker context crosses this (status shows the sample)
719
719
  # setup: npm ci --prefer-offline # run in each fresh task worktree before dispatch
@@ -1,3 +1,4 @@
1
+ import { type JournalEvent } from "../run/journal.js";
1
2
  import { type ExecutorDriver, type NotifyOpts, type Slot, type SlotOpts } from "./types.js";
2
3
  export declare const TRAILER_SAFE_FLOOR_COLS = 108;
3
4
  export declare const TRAILER_WIDTH_MARGIN = 2;
@@ -22,20 +23,20 @@ export declare class DeliveryCorruptedError extends Error {
22
23
  export type DriverJournal = (event: string, slotName: string, data: Record<string, unknown>) => void;
23
24
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
24
25
  export declare function workerSplitDirection(paneCols: number | null, safeFloor?: number, margin?: number): "right" | "down";
25
- export declare const BOARD_TARGET_COLS = 110;
26
- export declare const BOARD_SEAT_FLOOR_COLS = 40;
27
- export interface BoardSplitPlan {
28
- direction: "right" | "down";
29
- /** herdr's split ratio is the FIRST child's share and a right split's first child is the caller,
30
- * so this is what the SEAT keeps. Absent on `down` — the board then takes the full width. */
31
- ratio?: number;
32
- /** Columns the board gets under this plan; null when it takes the caller's whole width. */
33
- boardCols: number | null;
26
+ export declare const BOARD_HEIGHT_SHARE = 0.72;
27
+ export interface BoardPlacement {
28
+ /** Always down: the split is vertical, so the board can own the caller's FULL width. */
29
+ direction: "down";
30
+ /** herdr's split ratio is the FIRST child's share, and a down split's first child is the TOP
31
+ * region — the region the board occupies once it is swapped above the caller. */
32
+ ratio: number;
33
+ /** The new pane is swapped ABOVE the caller; the split alone would leave the board underneath. */
34
+ swap: "above";
34
35
  }
35
- /** Board-first placement beside the supervising seat: right only while the caller can fund the board
36
- * its target AND leave the seat its floor; otherwise down at full width, never a squeezed board.
37
- * An unmeasurable caller falls back to down like every other placement here (fail closed). */
38
- export declare function boardSplitPlan(callerCols: number | null, boardCols?: number, seatFloor?: number): BoardSplitPlan;
36
+ /** The single approved vertical-stack record. The caller's columns are accepted and deliberately
37
+ * ignored: this signature is where width used to decide the arrangement, and the parameter stays
38
+ * so that "the plan does not depend on it" is a property a caller (and a test) can exercise. */
39
+ export declare function boardSplitPlan(_callerCols?: number | null): BoardPlacement;
39
40
  export declare function taskGroupOf(name: string): string | undefined;
40
41
  /** The operator-facing TITLE for a tab this driver creates when no stage label is supplied: a task
41
42
  * token for a worker (`T5`, `T5↻2` on a retry) and ROLE + task for a gate pane (`REVIEW T5`) — the
@@ -63,11 +64,14 @@ export declare class HerdrDriver implements ExecutorDriver {
63
64
  private inputBoxes;
64
65
  private bootstraps;
65
66
  private journalRoots;
67
+ private narrate?;
66
68
  private ws;
67
69
  private callerPane;
68
70
  private watches;
69
71
  constructor(bin?: string, workersPerTab?: number, time?: HerdrTimeSource, journal?: DriverJournal | undefined);
70
72
  private appendDispatchRetry;
73
+ /** v1.99 T2: bind this driver's own journal writes to the run's live narration sink. */
74
+ narrateWith(narrate: (event: JournalEvent) => void): void;
71
75
  private serial;
72
76
  private deliveryQueue;
73
77
  private reserveDispatch;
@@ -77,6 +81,7 @@ export declare class HerdrDriver implements ExecutorDriver {
77
81
  static available(): boolean;
78
82
  private herdr;
79
83
  private namedPaneId;
84
+ private paneStillOpen;
80
85
  private paneId;
81
86
  private paneWidth;
82
87
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
@@ -116,6 +121,7 @@ export declare class HerdrDriver implements ExecutorDriver {
116
121
  private closeGrouped;
117
122
  private ownedWatchPanes;
118
123
  private watchSlot;
124
+ private discardSplit;
119
125
  narrator(cwd: string, command: string, runId?: string): Promise<Slot>;
120
126
  reconcile(desired: Set<string>, runId: string, opts?: {
121
127
  spareLiveLlm?: boolean;