tickmarkr 2.5.0 → 2.5.1

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.
@@ -25,6 +25,7 @@ export declare class FakeAdapter implements WorkerAdapter {
25
25
  private scriptPath;
26
26
  id: string;
27
27
  vendor: string;
28
+ busyFrameMarkers: readonly import("./types.js").BusyFrameMarker[];
28
29
  trustDialog: TrustDialog;
29
30
  private script;
30
31
  private attempts;
@@ -1,12 +1,13 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { parseWorkerResult } from "./prompt.js";
4
- import { shq, TokenUsageSchema } from "./types.js";
4
+ import { OPENCODE_BUSY_FRAME_MARKERS, shq, TokenUsageSchema } from "./types.js";
5
5
  // ponytail: deterministic scripted adapter — the whole integration suite runs on it, zero tokens.
6
6
  export class FakeAdapter {
7
7
  scriptPath;
8
8
  id = "fake";
9
9
  vendor = "fake-a";
10
+ busyFrameMarkers = OPENCODE_BUSY_FRAME_MARKERS;
10
11
  // v1.89 T1 / OBS-414: the scripted adapter runs `bash -c` — it has no TUI, so no prompt of any
11
12
  // kind can render. Daemon tests that exercise the auto-answer seam assign a captured declaration
12
13
  // to this field explicitly; that assignment is the falsifier for the claim made here.
@@ -1,3 +1,4 @@
1
1
  import { type WorkerAdapter } from "./types.js";
2
2
  export declare function parseOpencodeModels(raw: string): string[];
3
+ export { OPENCODE_BUSY_FRAME_MARKERS } from "./types.js";
3
4
  export declare const opencode: WorkerAdapter;
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { probeVersion } from "./claude-code.js";
6
6
  import { parseWorkerResult } from "./prompt.js";
7
- import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
7
+ import { channelsFromConfig, MODEL_ID_RE, OPENCODE_BUSY_FRAME_MARKERS, shq, TokenUsageSchema } from "./types.js";
8
8
  // v1.5 MODEL-01: pure parser for `opencode models` (one provider/model id per line, no header).
9
9
  // Live-verified 2026-07-10, opencode 1.17.15. Lines are already the seed id format; drop blanks.
10
10
  export function parseOpencodeModels(raw) {
@@ -13,9 +13,12 @@ export function parseOpencodeModels(raw) {
13
13
  .filter((l) => l.length > 0)
14
14
  .filter((id) => MODEL_ID_RE.test(id));
15
15
  }
16
+ // Keep the adapter export available without making the fake adapter import probe code.
17
+ export { OPENCODE_BUSY_FRAME_MARKERS } from "./types.js";
16
18
  export const opencode = {
17
19
  id: "opencode",
18
20
  vendor: "mixed",
21
+ busyFrameMarkers: OPENCODE_BUSY_FRAME_MARKERS,
19
22
  probeCwd: "neutral",
20
23
  probe: async () => probeVersion("opencode"),
21
24
  channels: (cfg) => channelsFromConfig("opencode", cfg),
@@ -150,6 +150,19 @@ export declare function matchesInputBox(paneText: string, inputBox: InputBox): b
150
150
  export declare function matchesEmptyInputBox(paneText: string, inputBox: InputBox): boolean;
151
151
  export declare function matchesOccupiedInputBox(paneText: string, inputBox: InputBox): boolean;
152
152
  export declare function missingInputStateDeclarations(inputBox: InputBox | undefined): string[];
153
+ export interface BusyFrameMarker {
154
+ name: string;
155
+ match(paneText: string): boolean;
156
+ }
157
+ export declare const OPENCODE_BUSY_FRAME_MARKERS: readonly BusyFrameMarker[];
158
+ export declare class SettledTrailerTracker {
159
+ private readonly markers;
160
+ private idleSamples;
161
+ readonly busyMarkersSeen: Set<string>;
162
+ settled: boolean;
163
+ constructor(markers: readonly BusyFrameMarker[]);
164
+ sample(paneText: string, trailer: boolean): boolean;
165
+ }
153
166
  export interface WorkerAdapter {
154
167
  id: string;
155
168
  vendor: string;
@@ -174,6 +187,7 @@ export interface WorkerAdapter {
174
187
  trust?(repoRoot: string): TrustVerdict;
175
188
  trustDialog: TrustDialog;
176
189
  inputBox?: InputBox;
190
+ busyFrameMarkers?: readonly BusyFrameMarker[];
177
191
  hardcodedFlags?: {
178
192
  binary: string;
179
193
  flags: string[];
@@ -207,6 +207,31 @@ export function missingInputStateDeclarations(inputBox) {
207
207
  missing.push("occupiedMatch");
208
208
  return missing;
209
209
  }
210
+ // OBS-957 capture retained under fixtures/opencode (the captured UI identifies as omp).
211
+ // Anchor activity glyphs to chrome/tool rows so a quoted spinner in a summary is harmless.
212
+ export const OPENCODE_BUSY_FRAME_MARKERS = [
213
+ { name: "unchecked TODO", match: (text) => /(?:^|\n)[^\n]*[├└]─[^\n]*☐/.test(text) || /(?:^|\n)\s*[-*] \[ \]/.test(text) },
214
+ { name: "in-flight tool", match: (text) => /(?:^|\n)\s*⎋ [^\n]+/.test(text) },
215
+ { name: "spinner", match: (text) => /(?:^|\n)(?:[╭│][─ ]*|\s*)[⠁-⣿](?:\s|$)/.test(text) },
216
+ ];
217
+ export class SettledTrailerTracker {
218
+ markers;
219
+ idleSamples = 0;
220
+ busyMarkersSeen = new Set();
221
+ settled = false;
222
+ constructor(markers) {
223
+ this.markers = markers;
224
+ }
225
+ sample(paneText, trailer) {
226
+ const busy = this.markers.filter((marker) => marker.match(paneText));
227
+ if (trailer)
228
+ for (const marker of busy)
229
+ this.busyMarkersSeen.add(marker.name);
230
+ this.idleSamples = trailer && busy.length === 0 ? this.idleSamples + 1 : 0;
231
+ this.settled = this.idleSamples >= 2;
232
+ return this.settled;
233
+ }
234
+ }
210
235
  export function channelsFromConfig(adapterId, cfg) {
211
236
  const e = cfg.tiers[adapterId];
212
237
  if (!e)
@@ -1,3 +1,5 @@
1
+ export type DetectedHost = "herdr" | "orca" | "subprocess";
2
+ export declare function detectedHost(env?: NodeJS.ProcessEnv): DetectedHost;
1
3
  /** Test seam mirroring FleetIO: production callers omit it and get the process streams. */
2
4
  export type InitIO = {
3
5
  input?: NodeJS.ReadStream;
@@ -4,6 +4,7 @@ import { join, relative } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { parseArgs } from "node:util";
7
+ import { parse as parseYaml } from "yaml";
7
8
  import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
8
9
  import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
9
10
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
@@ -218,7 +219,14 @@ const describeDrift = (d) => [
218
219
  // should not be asked again just because a copy drifted. Staleness is a different question with a
219
220
  // different answer (`--agent --force`), and it is reported by name on every init path below.
220
221
  const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
221
- const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : orcaHostDetected() ? "orca" : "auto";
222
+ export function detectedHost(env = process.env) {
223
+ if (env.HERDR_ENV === "1")
224
+ return "herdr";
225
+ if (orcaHostDetected(env))
226
+ return "orca";
227
+ return "subprocess";
228
+ }
229
+ const wizardDriverDefault = () => "auto";
222
230
  function packageName(cwd) {
223
231
  try {
224
232
  const parsed = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
@@ -323,6 +331,7 @@ async function runInitWizard(cwd, input, output) {
323
331
  visibilityLlm: DEFAULT_CONFIG.visibility.llm,
324
332
  offerSkills: !skillsInstalled(cwd),
325
333
  skillsDefault: existsSync(join(cwd, ".claude", "skills")) && !skillsInstalled(cwd),
334
+ detectedHost: detectedHost(),
326
335
  },
327
336
  input,
328
337
  output,
@@ -368,8 +377,37 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
368
377
  }
369
378
  const repoConfigPath = join(tickmarkrDir(cwd), "config.yaml");
370
379
  const repoConfigExists = existsSync(repoConfigPath);
371
- if (repoConfigExists)
372
- notes.push(`kept existing ${repoConfigPath}`);
380
+ if (repoConfigExists) {
381
+ if (values.force) {
382
+ const currentRaw = readFileSync(repoConfigPath, "utf8");
383
+ let parsedDriver;
384
+ try {
385
+ const parsed = parseYaml(currentRaw);
386
+ if (typeof parsed?.driver === "string")
387
+ parsedDriver = parsed.driver;
388
+ }
389
+ catch {
390
+ // malformed: fall back to regex
391
+ }
392
+ if (!parsedDriver) {
393
+ const m = currentRaw.match(/^\s*driver:\s*([^\s#]+)/m);
394
+ if (m)
395
+ parsedDriver = m[1];
396
+ }
397
+ const host = detectedHost();
398
+ if (parsedDriver && parsedDriver === host) {
399
+ const rewritten = currentRaw.replace(new RegExp(`^(\\s*driver:\\s*)${parsedDriver}\\b.*$`, "m"), `$1auto # auto — resolves to ${host} here`);
400
+ writeFileSync(repoConfigPath, rewritten);
401
+ notes.push(`rewrote driver: ${parsedDriver} to auto in ${repoConfigPath}`);
402
+ }
403
+ else {
404
+ notes.push(`kept existing ${repoConfigPath}`);
405
+ }
406
+ }
407
+ else {
408
+ notes.push(`kept existing ${repoConfigPath}`);
409
+ }
410
+ }
373
411
  const specPath = join(cwd, SCAFFOLD_SPEC);
374
412
  if (existsSync(specPath)) {
375
413
  notes.push(`kept existing ${specPath}`);
@@ -417,7 +455,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
417
455
  // so costs one clause; everything else stops here. Esc and ctrl+c both land here.
418
456
  return `init: wizard quit — nothing further run (scaffolding kept: ${notes.filter((n) => n.startsWith("wrote")).length} file(s) written above). Re-run tickmarkr init to continue.`;
419
457
  }
420
- writeFileSync(repoConfigPath, configTemplate(wizard.overlay));
458
+ const host = detectedHost();
459
+ const renderedConfig = configTemplate(wizard.overlay).replace(/^driver: auto$/m, `driver: auto # auto — resolves to ${host} here`);
460
+ writeFileSync(repoConfigPath, renderedConfig);
421
461
  notes.push(`wrote ${repoConfigPath}`);
422
462
  if (wizard.installSkills) {
423
463
  await installAgentFiles(cwd, values.force ?? false, wizard.installDocs || (values.docs ?? false), notes);
@@ -462,7 +502,7 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
462
502
  const next = nextSteps(cwd, SCAFFOLD_SPEC);
463
503
  if (!visual())
464
504
  return `${notes.join("\n")}\n${doc}\n${next}\n${ENVIRONMENTS_FOOTER}`;
465
- const noteRows = notes.map((note) => ` ${statusRow(/^(?:wrote|overwrote|appended)/.test(note) ? "pass" : note.startsWith("skipped") ? "warn" : "neutral", note)}`);
505
+ const noteRows = notes.map((note) => ` ${statusRow(/^(?:wrote|overwrote|appended|rewrote)/.test(note) ? "pass" : note.startsWith("skipped") ? "warn" : "neutral", note)}`);
466
506
  const footerRows = ENVIRONMENTS_FOOTER.split("\n").slice(1).map((line) => {
467
507
  const separator = line.indexOf(" — ");
468
508
  return kvRow(line.slice(2, separator), line.slice(separator + 1), 12);
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { allAdapters, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, rolePools, servableExclusions, servabilityLine } from "../../adapters/registry.js";
@@ -7,7 +7,7 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
9
  import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
- import { loadGraph } from "../../graph/graph.js";
10
+ import { graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
11
11
  import { renderAcceptanceItem } from "../../graph/schema.js";
12
12
  import { resolveRunMode } from "../../run/daemon.js";
13
13
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
@@ -15,7 +15,7 @@ import { staffLedEvidence } from "../../route/profile.js";
15
15
  import { route, RoutingError } from "../../route/router.js";
16
16
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
17
17
  import { modelId, modelProvider, pickReviewer } from "../../gates/review.js";
18
- import { Journal, loadRoutingProfile } from "../../run/journal.js";
18
+ import { Journal, loadRoutingProfile, recordedGraphDefinitionHash } from "../../run/journal.js";
19
19
  import { harnessLine, resolveHarness } from "../harness.js";
20
20
  import { channelKey, shq } from "../../adapters/types.js";
21
21
  import { shGit } from "../../run/git.js";
@@ -40,7 +40,10 @@ const stylizePlan = (out) => {
40
40
  const fleetCanCrossVendorReview = (channels) => {
41
41
  for (let i = 0; i < channels.length; i++)
42
42
  for (let j = 0; j < channels.length; j++)
43
- if (i !== j && channels[i].vendor !== channels[j].vendor && modelId(channels[i].model) !== modelId(channels[j].model))
43
+ if (i !== j
44
+ && channels[i].vendor !== channels[j].vendor
45
+ && modelProvider(channels[i].model, channels[i].vendor) !== modelProvider(channels[j].model, channels[j].vendor)
46
+ && modelId(channels[i].model) !== modelId(channels[j].model))
44
47
  return true;
45
48
  return false;
46
49
  };
@@ -121,6 +124,43 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
121
124
  throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
122
125
  }
123
126
  const g = loadGraph(cwd);
127
+ const gHash = graphDefinitionHash(g);
128
+ const runsRoot = join(cwd, stateDirName(cwd), "runs");
129
+ let matchingEvents;
130
+ let matchingRunId;
131
+ if (existsSync(runsRoot)) {
132
+ const locked = runLockRunId(cwd);
133
+ const candidates = readdirSync(runsRoot)
134
+ .filter((d) => d.startsWith("run-") && existsSync(join(runsRoot, d, "journal.jsonl")))
135
+ .sort();
136
+ const ordered = locked && candidates.includes(locked)
137
+ ? [locked, ...candidates.filter((id) => id !== locked).reverse()]
138
+ : candidates.reverse();
139
+ for (const runId of ordered) {
140
+ try {
141
+ const events = Journal.open(cwd, runId).read();
142
+ if (recordedGraphDefinitionHash(events) === gHash) {
143
+ matchingEvents = events;
144
+ matchingRunId = runId;
145
+ break;
146
+ }
147
+ }
148
+ catch {
149
+ // ignore unreadable or torn journals
150
+ }
151
+ }
152
+ }
153
+ const hasRun = matchingEvents !== undefined;
154
+ const reviewHistory = matchingEvents
155
+ ? matchingEvents
156
+ .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
157
+ .map((event) => event.data.reviewer)
158
+ : [];
159
+ // OBS-947 repair: a resumed run re-dispatches only tasks whose replayed status is pending (or that the
160
+ // journal never mentions). done, failed and human are all skipped alike, so rendering their preview
161
+ // picks must not advance the shared reviewHistory — a phantom draw wraps the LRU and makes a later
162
+ // pending task print the ranked-first seat while the journal's actual next draw is a later channel.
163
+ const taskStatuses = matchingRunId ? Journal.open(cwd, matchingRunId).replayStatuses() : new Map();
124
164
  const oracleRefusals = new Map();
125
165
  const oracleAdvisories = new Map();
126
166
  if (g.tasks.some((task) => task.acceptance.some((item) => typeof item === "object" && item.oracle === "test"))) {
@@ -306,10 +346,22 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
306
346
  const policy = effectiveReviewPolicy(task.files, cfg.review);
307
347
  if (policy === "judge-only")
308
348
  return { line: "none — reviewPolicy judge-only: declared leaf paths", cost: 0 };
309
- const reviewer = pickReviewer(author, pools.review, [], cfg.review.prefer ?? [], task.routingHints?.floor);
349
+ let rotationSeat = 1;
350
+ let rotationCount = 0;
351
+ const reviewer = pickReviewer(author, pools.review, [], cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, (seat, count) => { rotationSeat = seat; rotationCount = count; });
310
352
  if (!reviewer)
311
353
  return { line: "none — no cross-vendor reviewer available", cost: 0 };
312
- return { line: `${channelKey(reviewer)} — reviewPolicy full`, cost: seatCost(reviewer) };
354
+ // Advance the simulated rotation only for tasks the run would actually dispatch next — the same
355
+ // rule as readyTasks (src/graph/graph.ts): pending, with every dependency already done. A pending
356
+ // task behind a parked/failed/pending dependency cannot run, so it consumes no draw (RULING-229-14).
357
+ const isPending = (id) => { const s = taskStatuses.get(id); return s === undefined || s === "pending"; };
358
+ const ready = isPending(task.id) && task.deps.every((d) => taskStatuses.get(d) === "done");
359
+ if (hasRun && ready)
360
+ reviewHistory.push(channelKey(reviewer));
361
+ const rotationNote = rotationCount > 1
362
+ ? (hasRun ? ` (seat ${rotationSeat} of ${rotationCount})` : ` (seat 1 of ${rotationCount} — rotation applies at run time)`)
363
+ : "";
364
+ return { line: `${channelKey(reviewer)}${rotationNote} — reviewPolicy full`, cost: seatCost(reviewer) };
313
365
  };
314
366
  const routed = [];
315
367
  for (const t of g.tasks) {
@@ -268,7 +268,9 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
268
268
  pass = "pass (same-provider — independence not established)";
269
269
  }
270
270
  }
271
- lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}`);
271
+ const resolved = gate === "review" && g.data.pass === true && Array.isArray(g.data.resolved)
272
+ ? g.data.resolved.filter((id) => typeof id === "string") : [];
273
+ lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}${resolved.length ? `; resolved: ${resolved.join(", ")}` : ""}`);
272
274
  }
273
275
  for (const row of leg2) {
274
276
  const pass = row.data.pass === true ? "pass" : row.data.pass === false ? "fail" : EM;
@@ -118,6 +118,9 @@ export const RAIL_ROWS = {
118
118
  "gate-result": { label: "gate", tone: "pass" },
119
119
  "gate-reused": { label: "gate reused", tone: "neutral" },
120
120
  "judge-retry": { label: "judge retry", tone: "attention" },
121
+ "review-no-verdict": { label: "review unavailable", tone: "attention" },
122
+ "review-pool-demotion": { label: "review seat demoted", tone: "attention" },
123
+ "review-infra-retry": { label: "review infra retry", tone: "attention" },
121
124
  "review-retry": { label: "review retry", tone: "attention" },
122
125
  // repairs and escalations
123
126
  "repair-dispatch": { label: "repair", tone: "attention" },
@@ -61,7 +61,12 @@ export interface LlmRunResult {
61
61
  output: string;
62
62
  exitCode?: number;
63
63
  timedOut: boolean;
64
+ launchNeverStarted?: boolean;
65
+ seatAuthoredBytes?: number;
64
66
  }
67
+ export declare const PROMPT_GLYPHS: readonly ["➜", "❯", "$", "%", ">>", ">"];
68
+ export declare function reviewSeatOutput(raw: string, nonce: string): string;
69
+ export declare const REVIEW_FIRST_LIVENESS_MS = 30000;
65
70
  export declare function runHeadless(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, timeoutMs?: number): Promise<string>;
66
71
  export declare function runViaDriver(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via: LlmVia, timeoutMs?: number): Promise<string>;
67
72
  export declare function dewrapPaneVerdict(out: string, nonce: string): string;
package/dist/gates/llm.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { stripVTControlCharacters } from "node:util";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -5,7 +6,7 @@ import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { matchesTrustDialog } from "../adapters/types.js";
7
8
  import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
8
- import { bannerShell, paneDispatchCommand } from "../brand.js";
9
+ import { bannerShell, paneDispatchCommand, PLAIN_BANNER } from "../brand.js";
9
10
  import { sh } from "../run/git.js";
10
11
  import { harvestCpuFlatWindowMs, normalizeStallSnapshot, WorkerTreeCpuAccountant, } from "../run/stall.js";
11
12
  export const GATE_PANE_SEP = " · ";
@@ -129,13 +130,164 @@ export async function captureLlmOutput(run) {
129
130
  const value = await llmOutputCapture.run(outputs, run);
130
131
  return { value, outputs };
131
132
  }
133
+ // The harness preamble has a fixed row shape, in order: the dispatch echo rows (the identity export
134
+ // and the START printf, each possibly re-echoed behind the shell prompt), the START acknowledgement
135
+ // row, the banner rows, blank rows, and the identity row. RULING-229-15 add.1 makes the boundary
136
+ // STRUCTURAL: a pane read is line-terminated, so only the LAST row of a capture can be a partial
137
+ // paint. Every row before it is complete, and a complete row is harness only when it EQUALS a full
138
+ // harness row in the position the preamble allows. A complete row that merely starts with "T",
139
+ // "export", "review" or a banner glyph is seat text, and seat prose that MENTIONS a marker mid-row
140
+ // counts in full — "contains TICKMARKR_START_ somewhere" is never by itself a reason to measure zero.
141
+ const BANNER_ROWS = PLAIN_BANNER.replace(/\n$/, "").split("\n");
142
+ const ECHO_OPENER = "export HERDR_WORKSPACE_ID=";
143
+ const ECHO_START_OPENER = "printf '%s%s\\n' 'TICKMARKR_START_' '";
144
+ const ECHO_OPENERS = [ECHO_OPENER, ECHO_START_OPENER];
145
+ // RULING-229-15 add.5: the prompt glyph set is this CLOSED list — the corpus sweeps it, the grammar
146
+ // consumes the LONGEST glyph that matches (so ">>" is one glyph and ">" is still one).
147
+ export const PROMPT_GLYPHS = ["➜", "❯", "$", "%", ">>", ">"];
148
+ const GIT_SEGMENT_OPEN = "git:(";
149
+ function echoRowMatch(row) {
150
+ const opener = (at) => {
151
+ const body = row.slice(at);
152
+ if (ECHO_OPENERS.some((o) => body.startsWith(o)))
153
+ return "complete";
154
+ return ECHO_OPENERS.some((o) => o.startsWith(body)) ? "partial" : "none";
155
+ };
156
+ const ws = (at) => /^\s+/.exec(row.slice(at))?.[0].length;
157
+ const bare = opener(0);
158
+ if (bare !== "none")
159
+ return bare;
160
+ const glyph = PROMPT_GLYPHS.find((g) => row.startsWith(g));
161
+ if (glyph === undefined)
162
+ return "none";
163
+ let i = glyph.length;
164
+ const ws1 = ws(i);
165
+ if (ws1 === undefined)
166
+ return row.length === i ? "partial" : "none";
167
+ i += ws1;
168
+ const dir = /^\S+/.exec(row.slice(i));
169
+ if (!dir)
170
+ return "partial";
171
+ i += dir[0].length;
172
+ const ws2 = ws(i);
173
+ if (ws2 === undefined)
174
+ return "partial";
175
+ i += ws2;
176
+ const rest = row.slice(i);
177
+ if (rest.startsWith(GIT_SEGMENT_OPEN)) {
178
+ const close = row.indexOf(")", i + GIT_SEGMENT_OPEN.length);
179
+ if (close < 0)
180
+ return "partial";
181
+ i = close + 1;
182
+ const ws3 = ws(i);
183
+ if (ws3 === undefined)
184
+ return row.length === i ? "partial" : "none";
185
+ return opener(i + ws3);
186
+ }
187
+ if (GIT_SEGMENT_OPEN.startsWith(rest))
188
+ return "partial"; // "", "g", "gi", "git", "git:"
189
+ return opener(i);
190
+ }
191
+ // A complete dispatch echo row: the grammar reaches the opener. Never "contains the opener" — a row
192
+ // that mentions it mid-prose is the seat's.
193
+ function completeEchoRow(row) {
194
+ return echoRowMatch(row) === "complete";
195
+ }
196
+ // A LAST row still being painted: any prefix of a row in the grammar.
197
+ function partialEchoRow(row) {
198
+ return echoRowMatch(row) !== "none";
199
+ }
200
+ const START_MARKER = "TICKMARKR_START_";
201
+ const START_ROW = /^TICKMARKR_START_[\w-]+$/;
202
+ const IDENTITY_LINE = /^(?:review\s*·|tickmarkr(?::|$))/;
203
+ const IDENTITY_OPENERS = ["review ·", "tickmarkr"];
204
+ // Stages of the preamble walk. Each complete harness row is accepted only at or after its stage.
205
+ const ECHO = 0, START = 1, BANNER = 2, IDENTITY = 3, SEAT = 4;
206
+ // The char offset where the seat's own text begins, or -1 when the capture ends inside the preamble.
207
+ function seatStart(output) {
208
+ const rows = output.split("\n");
209
+ if (rows.length > 1 && rows[rows.length - 1] === "")
210
+ rows.pop(); // the read's own line terminator
211
+ let stage = ECHO;
212
+ let bannerAt; // next banner row expected once the banner has begun
213
+ let offset = 0;
214
+ for (let i = 0; i < rows.length; i++) {
215
+ const row = rows[i].replace(/[ \t]+$/, "");
216
+ const t = row.trim();
217
+ const last = i === rows.length - 1;
218
+ // Complete harness rows: equality against the shape the preamble allows at this stage.
219
+ let accepted = false;
220
+ if (stage < SEAT && t.length === 0)
221
+ accepted = true; // blank rows between preamble rows
222
+ else if (stage <= ECHO && completeEchoRow(row))
223
+ accepted = true;
224
+ else if (stage <= START && START_ROW.test(row)) {
225
+ stage = BANNER;
226
+ accepted = true;
227
+ }
228
+ else if (stage <= BANNER && bannerAt === undefined && BANNER_ROWS.includes(row)) {
229
+ stage = BANNER;
230
+ bannerAt = BANNER_ROWS.indexOf(row) + 1;
231
+ accepted = true;
232
+ }
233
+ else if (stage <= BANNER && bannerAt !== undefined && row === BANNER_ROWS[bannerAt]) {
234
+ bannerAt++;
235
+ accepted = true;
236
+ }
237
+ else if (stage <= IDENTITY && IDENTITY_LINE.test(t)) {
238
+ stage = SEAT;
239
+ accepted = true;
240
+ }
241
+ if (accepted) {
242
+ offset += rows[i].length + 1;
243
+ continue;
244
+ }
245
+ if (!last)
246
+ return offset;
247
+ // The last row may be a partial paint: a prefix of the next harness row the preamble allows.
248
+ if (stage <= ECHO && partialEchoRow(row))
249
+ return -1;
250
+ if (stage <= START && (START_MARKER.startsWith(row) || /^TICKMARKR_START_[\w-]*$/.test(row)))
251
+ return -1;
252
+ if (stage <= BANNER && (bannerAt === undefined
253
+ ? BANNER_ROWS.some((b) => b.startsWith(row))
254
+ : BANNER_ROWS[bannerAt]?.startsWith(row) === true))
255
+ return -1;
256
+ // The identity row is painted right after the banner, so its prefix is a partial paint only there;
257
+ // with no banner in the capture, "review" or "tick" alone is the seat's own first row.
258
+ if (stage <= IDENTITY && bannerAt === BANNER_ROWS.length && IDENTITY_OPENERS.some((o) => o.startsWith(t)))
259
+ return -1;
260
+ return offset;
261
+ }
262
+ return -1; // every row was harness — the seat has not taken its turn
263
+ }
264
+ // A pane's dispatch echo, start acknowledgement, banner and identity are harness bytes.
265
+ // Remove only that leading preamble, never matching text later in the seat's response.
266
+ // Every capture taken before the preamble finishes therefore measures ZERO seat-authored bytes,
267
+ // which is what makes the caller's running Math.max safe: a partial banner counted once would be
268
+ // retained for the whole call and buy a silent seat its full ceiling.
269
+ export function reviewSeatOutput(raw, nonce) {
270
+ const output = stripVTControlCharacters(raw).replace(/\r\n?/g, "\n");
271
+ const start = seatStart(output);
272
+ if (start < 0)
273
+ return "";
274
+ const seat = output.slice(start);
275
+ // Anything after the nonce-bound trailer belongs to the terminal (typically the next shell
276
+ // prompt), not the reviewer. Deleting only the marker would count that postlude as seat output and
277
+ // let a zero-byte seat escape first-silence demotion.
278
+ const trailer = new RegExp(`(?:^|\\n)TICKMARKR_EXIT_${nonce}:\\d+[^\\n]*(?:\\n|$)`).exec(seat);
279
+ // A trailer row still being typed is not stripped: after the preamble a last row "T" is far more
280
+ // often the seat's first byte than the harness's exit marker, and the next read completes either.
281
+ return trailer ? seat.slice(0, trailer.index) : seat;
282
+ }
283
+ export const REVIEW_FIRST_LIVENESS_MS = 30_000;
132
284
  async function runHeadlessDetailed(adapter, model, prompt, cwd, timeoutMs = 300000) {
133
285
  const dir = mkdtempSync(join(tmpdir(), "tickmarkr-llm-"));
134
286
  try {
135
287
  const pf = join(dir, "prompt.md");
136
288
  writeFileSync(pf, prompt);
137
289
  const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
138
- return { output: r.stdout + "\n" + r.stderr, exitCode: r.code, timedOut: r.timedOut === true };
290
+ return { output: r.stdout + "\n" + r.stderr, exitCode: r.code, timedOut: r.timedOut === true, seatAuthoredBytes: Buffer.byteLength(r.stdout + r.stderr) };
139
291
  }
140
292
  finally {
141
293
  rmSync(dir, { recursive: true, force: true });
@@ -150,6 +302,7 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
150
302
  const dir = mkdtempSync(join(tmpdir(), "tickmarkr-llm-"));
151
303
  let slot;
152
304
  let accountant;
305
+ let forceClose = false;
153
306
  try {
154
307
  const pf = join(dir, "prompt.md");
155
308
  writeFileSync(pf, prompt);
@@ -180,6 +333,8 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
180
333
  const exitPattern = `TICKMARKR_EXIT_${nonce}:\\d`;
181
334
  let out;
182
335
  let timedOut = false;
336
+ let launchNeverStarted = false;
337
+ let seatAuthoredBytes = 0;
183
338
  const gatePrompt = prompt.startsWith("TICKMARKR-JUDGE") || prompt.startsWith("TICKMARKR-REVIEW");
184
339
  if (!gatePrompt) {
185
340
  await via.driver.waitOutput(slot, exitPattern, timeoutMs, { regex: true });
@@ -193,6 +348,9 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
193
348
  await accountant.start();
194
349
  const startedAt = Date.now();
195
350
  out = await via.driver.read(slot, 400);
351
+ const reviewing = prompt.startsWith("TICKMARKR-REVIEW");
352
+ seatAuthoredBytes = Buffer.byteLength(reviewSeatOutput(out, nonce));
353
+ let firstLivenessObserved = false;
196
354
  let priorSnapshot = normalizeStallSnapshot(out);
197
355
  const anchoredAt = Date.now();
198
356
  let quietSince = anchoredAt;
@@ -207,12 +365,35 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
207
365
  const matched = await via.driver.waitOutput(slot, exitPattern, sliceMs, { regex: true });
208
366
  const raw = await via.driver.read(slot, 400);
209
367
  out = raw;
368
+ seatAuthoredBytes = Math.max(seatAuthoredBytes, Buffer.byteLength(reviewSeatOutput(raw, nonce)));
210
369
  // waitOutput is the driver's authoritative marker match. The raw check covers drivers whose
211
370
  // wait timed out at the same boundary the marker landed; either way a trailer completes
212
371
  // normally and is never mistaken for inactivity.
213
372
  if (matched || new RegExp(exitPattern).test(raw))
214
373
  break;
215
374
  const now = Date.now();
375
+ // The ceiling wins if it coincides with the first beat (or the read crosses it).
376
+ // That seat was killed by its configured timeout, not an early launch reroute.
377
+ if (now - startedAt >= timeoutMs)
378
+ break;
379
+ if (reviewing && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
380
+ firstLivenessObserved = true;
381
+ // RS-2: the beat reads seat-authored bytes ALONE. CPU evidence never holds a preamble-only
382
+ // capture open to the ceiling; a seat that has not written one byte of its own is re-routed.
383
+ // OBS-944 is why a buffering runner earns no exemption here: the claude-code seat's 901 s
384
+ // capture was byte-identical across two legs and ended at the pane-identity line — that
385
+ // seat never started, it was not quietly working. RULING-229-06 puts the beat on the PANE
386
+ // path only; a headless `-p` runner (runHeadlessDetailed, no pane, no beat) buffers every
387
+ // byte until completion and keeps its full ceiling.
388
+ if (seatAuthoredBytes === 0) {
389
+ launchNeverStarted = true;
390
+ forceClose = true;
391
+ break;
392
+ }
393
+ }
394
+ // Producing reviews own their full ceiling; inactivity is not a review verdict.
395
+ if (reviewing)
396
+ continue;
216
397
  const snapshot = normalizeStallSnapshot(raw);
217
398
  if (snapshot !== priorSnapshot) {
218
399
  priorSnapshot = snapshot;
@@ -246,18 +427,22 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
246
427
  }
247
428
  }
248
429
  timedOut = Date.now() - startedAt >= timeoutMs && !new RegExp(exitPattern).test(out);
430
+ if (timedOut)
431
+ forceClose = true;
249
432
  }
250
433
  const exitCode = Number(new RegExp(`TICKMARKR_EXIT_${nonce}:(\\d+)`).exec(out)?.[1]);
251
434
  return {
252
435
  output: dewrapPaneVerdict(out, nonce),
253
436
  ...(Number.isFinite(exitCode) ? { exitCode } : {}),
254
437
  timedOut,
438
+ launchNeverStarted,
439
+ seatAuthoredBytes,
255
440
  };
256
441
  }
257
442
  finally {
258
443
  try {
259
444
  await accountant?.stop();
260
- if (slot && !via.keep)
445
+ if (slot && (forceClose || !via.keep))
261
446
  await via.driver.close(slot);
262
447
  }
263
448
  finally {