tickmarkr 1.30.0 → 1.33.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.
@@ -6,6 +6,7 @@ const REFRESH_MS = 2000;
6
6
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
7
7
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
8
8
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ const GATE_KEYS = { build: "B", test: "T", lint: "L", evidence: "E", scope: "S", acceptance: "A", review: "R" };
9
10
  const attemptStartIdx = (events, taskId) => {
10
11
  let idx = -1;
11
12
  for (let i = 0; i < events.length; i++) {
@@ -15,39 +16,58 @@ const attemptStartIdx = (events, taskId) => {
15
16
  }
16
17
  return idx;
17
18
  };
18
- /** Derive the live phase column for one task. Counts only that task's events since its last dispatch/escalation. */
19
- export const derivePhase = (task, events, channel) => {
20
- const startIdx = attemptStartIdx(events, task.id);
21
- if (startIdx < 0)
22
- return "";
23
- // Scope to this task — concurrent tasks' events must not leak into the attempt window (D-03).
24
- const attempt = events.slice(startIdx).filter((e) => e.taskId === task.id);
25
- if (attempt.some((e) => e.event === "task-done" || e.event === "task-failed" || e.event === "task-human"))
26
- return "";
27
- if (!attempt.some((e) => e.event === "task-dispatch"))
28
- return "retrying"; // escalation before re-dispatch
29
- if (!attempt.some((e) => e.event === "worker-result"))
30
- return channel ? `worker · ${channel}` : "worker";
31
- const enabled = GATE_NAMES.filter((g) => task.gates.includes(g));
32
- const n = enabled.length;
33
- const gateResults = attempt.filter((e) => e.event === "gate-result");
34
- if (gateResults.length > 0) {
35
- const last = gateResults[gateResults.length - 1];
36
- if (!last.data.pass)
37
- return `gate ✗ ${last.data.gate}`;
19
+ const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
20
+ const color = (text, code, enabled) => enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
21
+ const taskBox = (status, unicode) => {
22
+ if (unicode) {
23
+ if (status === "done")
24
+ return "✓";
25
+ if (status === "failed")
26
+ return "";
27
+ if (status === "human")
28
+ return "";
29
+ return "";
38
30
  }
39
- const passed = new Set();
40
- for (const e of gateResults)
41
- if (e.data.pass)
42
- passed.add(String(e.data.gate));
43
- const k = passed.size;
44
- // n===0 (no enabled gates) → merge window immediately after worker-result
45
- if (n === 0 || k >= n) {
46
- if (!attempt.some((e) => e.event === "merge" || e.event === "task-done"))
47
- return "merging";
48
- return "";
31
+ if (status === "done")
32
+ return "[x]";
33
+ if (status === "failed" || status === "human")
34
+ return "[!]";
35
+ return "[ ]";
36
+ };
37
+ const gateBox = (state, unicode) => {
38
+ if (unicode)
39
+ return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
40
+ return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
41
+ };
42
+ const gateStates = (task, events) => {
43
+ const outcomes = new Map();
44
+ const start = attemptStartIdx(events, task.id);
45
+ if (start >= 0) {
46
+ for (const e of events.slice(start)) {
47
+ if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
48
+ continue;
49
+ if (e.data.skipped === true)
50
+ outcomes.set(e.data.gate, "skip");
51
+ else if (e.data.pass === true)
52
+ outcomes.set(e.data.gate, "pass");
53
+ else if (e.data.pass === false)
54
+ outcomes.set(e.data.gate, "fail");
55
+ }
49
56
  }
50
- return `gate ${k + 1}/${n} ${enabled[k]}`;
57
+ return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
58
+ };
59
+ // verdict semantics only: pass green, fail red, skip/open dim chrome — everything else stays quiet
60
+ const GATE_STATE_COLOR = { pass: 32, fail: 31, skip: 2, open: 2 };
61
+ const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => color(`${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`, GATE_STATE_COLOR[states[i]], unicode)).join(" ");
62
+ // plain (uncolored) chip width for column math — ANSI codes have zero display width
63
+ const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + GATE_KEYS[gate].length + (unicode ? 1 : 3) + 1, -1);
64
+ const shortGoal = (goal, max) => {
65
+ const clause = goal.split(/[,;.?!]/, 1)[0].trim();
66
+ if (clause.length <= max)
67
+ return clause;
68
+ if (max <= 3)
69
+ return clause.slice(0, Math.max(0, max));
70
+ return `${clause.slice(0, max - 3).trimEnd()}...`;
51
71
  };
52
72
  // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
53
73
  // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
@@ -72,14 +92,14 @@ const daemonPid = (events) => {
72
92
  }
73
93
  return undefined;
74
94
  };
75
- const livenessLine = (events) => {
95
+ const liveness = (events) => {
76
96
  const last = events.at(-1);
77
97
  if (!last)
78
- return null; // nothing to be honest about
98
+ return "last event unknown · daemon pid unknown";
79
99
  const age = fmtAge(Date.now() - Date.parse(last.ts));
80
100
  const pid = daemonPid(events);
81
101
  if (pid === undefined)
82
- return ` last event ${age} ago · daemon pid unknown`;
102
+ return `last event ${age} ago · daemon pid unknown`;
83
103
  let state;
84
104
  try {
85
105
  process.kill(pid, 0);
@@ -88,17 +108,14 @@ const livenessLine = (events) => {
88
108
  catch (k) {
89
109
  state = k.code === "ESRCH" ? "dead" : "alive";
90
110
  } // EPERM ⇒ alive
91
- return ` last event ${age} ago · daemon pid ${pid} ${state}`;
111
+ return `last event ${age} ago · daemon pid ${pid} ${state}`;
92
112
  };
93
113
  const renderFrame = (cwd) => {
94
114
  const g = loadGraph(cwd);
95
- const runId = Journal.latestRunId(cwd);
115
+ const runId = Journal.latestRunId(cwd, { withJournal: true });
96
116
  const assignments = new Map();
97
- const gates = new Map();
98
117
  let replayed = null;
99
118
  let events = [];
100
- // v1.23 T2: latest known context tokens per task (from context-sample journal events). Informational
101
- // only — never feeds status/phase/gate classification (derivePhase and replayStatuses ignore them).
102
119
  const contexts = new Map();
103
120
  if (runId) {
104
121
  const j = Journal.open(cwd, runId);
@@ -107,11 +124,8 @@ const renderFrame = (cwd) => {
107
124
  for (const e of events) {
108
125
  if (e.event === "task-dispatch" && e.taskId) {
109
126
  const a = e.data.assignment;
110
- assignments.set(e.taskId, `${a.adapter}:${a.model}`);
111
- }
112
- if (e.event === "gate-result" && e.taskId) {
113
- const prev = gates.get(e.taskId) ?? "";
114
- gates.set(e.taskId, `${prev}${e.data.pass ? "✓" : "✗"}${e.data.gate} `);
127
+ if (typeof a.adapter === "string" && typeof a.model === "string")
128
+ assignments.set(e.taskId, `${a.adapter}:${a.model}`);
115
129
  }
116
130
  if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
117
131
  contexts.set(e.taskId, e.data.tokens); // last write wins
@@ -121,22 +135,65 @@ const renderFrame = (cwd) => {
121
135
  const effective = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: replayed?.get(t.id) ?? t.status })) };
122
136
  const starved = new Set(blockedTasks(effective).map((t) => t.id));
123
137
  const waiting = new Set(pendingTasks(effective).map((t) => t.id));
124
- const rows = g.tasks.map((t) => {
138
+ const unicode = visual();
139
+ const divider = unicode ? " · " : " / ";
140
+ const width = process.stdout.columns ?? 120;
141
+ const done = effective.tasks.filter((t) => t.status === "done").length;
142
+ const cells = g.tasks.map((t) => {
125
143
  const st = replayed?.get(t.id) ?? t.status;
126
144
  const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
127
- const phase = derivePhase(t, events, assignments.get(t.id)) || "-";
128
- const base = assignments.get(t.id) ?? "-";
129
- const ctx = contexts.get(t.id);
130
- // Informational suffix only when known — never invents 0 from absence.
131
- const assignCol = ctx !== undefined ? `${base} · ctx ${ctx}` : base;
132
- return ` ${t.id.padEnd(6)} ${String(st).padEnd(8)} ${phase.padEnd(28)} ${assignCol.padEnd(28)} ${gates.get(t.id) ?? ""}${label}`;
145
+ const channel = assignments.get(t.id) ?? "-";
146
+ const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
147
+ return { t, st, label, assignCol, states: gateStates(t, events) };
148
+ });
149
+ const statusColor = (st) => st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36;
150
+ if (!unicode) {
151
+ // machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
152
+ const rows = cells.map(({ t, st, label, assignCol, states }) => {
153
+ const chain = gateChain(states, false);
154
+ const prefix = ` ${taskBox(st, false)} ${t.id} `;
155
+ const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
156
+ return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
157
+ });
158
+ const header = runId
159
+ ? `tickmarkr status${divider}run ${runId}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
160
+ : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
161
+ const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
162
+ return [header, legend, ...rows].join("\n");
163
+ }
164
+ // TTY: audit-ledger frame — bold brand mark, dim chrome, semantic color only on verdicts,
165
+ // completion gauge, and column-aligned rows (pad plain text FIRST, colorize after: ANSI has
166
+ // zero display width and would corrupt padEnd math)
167
+ const dim = (s) => color(s, 2, true);
168
+ const dot = dim(" · ");
169
+ const anyFailed = cells.some((c) => c.st === "failed");
170
+ const gaugeCells = 10;
171
+ const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
172
+ const gauge = (fill ? color("█".repeat(fill), anyFailed ? 31 : 32, true) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
173
+ const live = liveness(events)
174
+ .replace(/\bdead\b/, color("dead", 31, true))
175
+ .replace(/\balive\b/, color("alive", 32, true))
176
+ .replaceAll(" · ", dot);
177
+ const tally = `${done}/${g.tasks.length} done`;
178
+ const header = ` ${color("✓", 32, true)} ${color("tickmarkr", 1, true)}${dot}` +
179
+ (runId ? `run ${runId}${dot}${live}${dot}` : `no runs yet${dot}`) +
180
+ `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? color(tally, 32, true) : tally}`;
181
+ const rule = dim("─".repeat(Math.min(width, 100)));
182
+ const legend = dim(` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
183
+ const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
184
+ const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
185
+ const chainW = gateChainWidth(true);
186
+ const assignW = Math.max(...cells.map((c) => c.assignCol.length));
187
+ const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
188
+ const rows = cells.map(({ t, st, label, assignCol, states }) => {
189
+ const box = color(taskBox(st, true), st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 2, true);
190
+ const goal = shortGoal(t.goal, goalW).padEnd(goalW);
191
+ const statusCell = color(String(st), statusColor(st), true) +
192
+ (label ? (label === " starved" ? color(label, 31, true) : dim(label)) : "") +
193
+ " ".repeat(stW - (String(st) + label).length);
194
+ return ` ${box} ${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`;
133
195
  });
134
- return [
135
- `drovr status${runId ? ` — run ${runId}` : " — no runs yet"}`,
136
- ...(runId ? [livenessLine(events)].filter((l) => l !== null) : []),
137
- " task status phase assignment gates",
138
- ...rows,
139
- ].join("\n");
196
+ return [header, rule, legend, ...rows].join("\n");
140
197
  };
141
198
  export async function status(argv, cwd = process.cwd(), opts = {}) {
142
199
  if (!argv.includes("--watch"))
@@ -146,11 +203,11 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
146
203
  const bounded = Number.isFinite(iterations);
147
204
  const frames = [];
148
205
  const sep = "\n---\n";
149
- const tty = process.stdout.isTTY;
206
+ const tty = visual();
150
207
  for (let i = 0; i < iterations; i++) {
151
208
  const frame = renderFrame(cwd);
152
209
  if (tty)
153
- process.stdout.write(`\x1b[2J\x1b[H${frame}`);
210
+ process.stdout.write(`\x1b[2J\x1b[H${frame}\n\x1b[2m watching · refresh ${REFRESH_MS / 1000}s · ^C to quit\x1b[0m`);
154
211
  else
155
212
  process.stdout.write(frame + sep);
156
213
  if (bounded)
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  export type CommandMap = Record<string, (argv: string[]) => Promise<string>>;
3
3
  export declare const COMMANDS: CommandMap;
4
- export declare const USAGE = "drovr \u2014 spec-driven orchestration harness for AI coding agents\nusage: drovr <command>\n init guided setup + doctor\n doctor re-probe adapters, herdr, auth; print capability matrix\n compile <src> spec \u2192 .drovr/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 run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\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 unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
4
+ 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\n compile <src> spec \u2192 .drovr/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 run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\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 unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
5
5
  export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
6
6
  out: string;
7
7
  code: number;
package/dist/cli/index.js CHANGED
@@ -16,9 +16,9 @@ import { unlock } from "./commands/unlock.js";
16
16
  export const COMMANDS = {
17
17
  init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
18
18
  };
19
- export const USAGE = `drovr — spec-driven orchestration harness for AI coding agents
20
- usage: drovr <command>
21
- init guided setup + doctor
19
+ export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
20
+ usage: tickmarkr <command>
21
+ init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
22
22
  doctor re-probe adapters, herdr, auth; print capability matrix
23
23
  compile <src> spec → .drovr/graph.json (fails without acceptance criteria)
24
24
  scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)
@@ -41,7 +41,7 @@ export async function dispatch(cmd, argv, commands = COMMANDS) {
41
41
  return { out: await fn(argv), code: 0 };
42
42
  }
43
43
  catch (err) {
44
- return { out: `drovr ${cmd}: ${err.message}`, code: 1 };
44
+ return { out: `tickmarkr ${cmd}: ${err.message}`, code: 1 };
45
45
  }
46
46
  }
47
47
  /* v8 ignore start -- binary entry: printing + process.exit side effects, not unit-testable (ROADMAP crit 2) */
@@ -92,7 +92,7 @@ function compileOne(file, storedPath) {
92
92
  const truths = strings(fm.must_haves?.truths);
93
93
  const acceptance = [...dones, ...truths].filter(Boolean);
94
94
  if (!acceptance.length) {
95
- throw new CompileError(`${file} has no acceptance criteria — every drovr task needs them.\n` +
95
+ throw new CompileError(`${file} has no acceptance criteria — every tickmarkr task needs them.\n` +
96
96
  `GSD source: add <done> lines to the plan's tasks, or must_haves.truths in its frontmatter.`);
97
97
  }
98
98
  // @-refs from the <context> block only; keep repo-relative paths (no $VAR, ~, absolute, or ..-escape)
@@ -151,7 +151,7 @@ function compileOne(file, storedPath) {
151
151
  }
152
152
  export function compileGsd(src, root) {
153
153
  if (!existsSync(src))
154
- throw new CompileError(`${src} does not exist — point drovr compile at a GSD phase directory or a *-PLAN.md`);
154
+ throw new CompileError(`${src} does not exist — point tickmarkr compile at a GSD phase directory or a *-PLAN.md`);
155
155
  const isDir = statSync(src).isDirectory();
156
156
  const files = isDir
157
157
  ? readdirSync(src)
@@ -160,7 +160,7 @@ export function compileGsd(src, root) {
160
160
  .map((f) => join(src, f))
161
161
  : [src];
162
162
  if (!files.length) {
163
- throw new CompileError(`no *-PLAN.md files in ${src} — point drovr compile at a GSD phase directory`);
163
+ throw new CompileError(`no *-PLAN.md files in ${src} — point tickmarkr compile at a GSD phase directory`);
164
164
  }
165
165
  // context[0] must be readable from an isolated worktree: store repo-relative, never absolute
166
166
  const rel = (f) => {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { GATE_NAMES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
3
3
  import { CompileError, inferShape, sha256 } from "./common.js";
4
- export const NATIVE_MARKER = /^<!--\s*drovr:spec(?:\s+v1)?\s*-->\s*$/m;
4
+ export const NATIVE_MARKER = /^<!--\s*(?:tickmarkr|drovr):spec(?:\s+v1)?\s*-->\s*$/m;
5
5
  const HEAD_RE = /^## (T\d+):\s*(.+)$/;
6
6
  const FIELD_RE = /^- (\w+):\s*(.*)$/;
7
7
  const NESTED_RE = /^\s+- (.+)$/;
@@ -135,7 +135,7 @@ export function compileNative(file) {
135
135
  // v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
136
136
  // one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
137
137
  if (plainCount > 0) {
138
- console.warn(`drovr: ${plainCount} acceptance item${plainCount === 1 ? "" : "s"} in ${file} ${plainCount === 1 ? "is a plain string" : "are plain strings"} — compiled as judge oracle${plainCount === 1 ? "" : "s"}. Prefix with command:/test:/judge: to make the oracle explicit.`);
138
+ console.warn(`tickmarkr: ${plainCount} acceptance item${plainCount === 1 ? "" : "s"} in ${file} ${plainCount === 1 ? "is a plain string" : "are plain strings"} — compiled as judge oracle${plainCount === 1 ? "" : "s"}. Prefix with command:/test:/judge: to make the oracle explicit.`);
139
139
  }
140
140
  return result;
141
141
  }
@@ -143,11 +143,11 @@ export function compileNative(file) {
143
143
  // parser ignores) so the template itself round-trips through compileSource() unchanged. Every field is
144
144
  // documented; the two example tasks illustrate plain vs deps+routing-hint, each with acceptance[].
145
145
  export function specTemplate() {
146
- return `<!-- drovr:spec -->
147
- # drovr native spec
146
+ return `<!-- tickmarkr:spec -->
147
+ # tickmarkr native spec
148
148
 
149
- Your starting point for a drovr native spec. Edit this file, then run:
150
- drovr compile drovr.spec.md && drovr plan && drovr run
149
+ Your starting point for a tickmarkr native spec. Edit this file, then run:
150
+ tickmarkr compile tickmarkr.spec.md && tickmarkr plan && tickmarkr run
151
151
 
152
152
  Each task is a "## Tn: Title" heading with "- field: value" bullets.
153
153
  acceptance is required on every task (a nested list of observable outcomes).
@@ -7,7 +7,7 @@ const SUB_RE = /^\s+- (\w+):\s*(.+)$/;
7
7
  export function compileSpecKit(dir) {
8
8
  const path = join(dir, "tasks.md");
9
9
  if (!existsSync(path)) {
10
- throw new CompileError(`no tasks.md in ${dir} — point drovr compile at a Spec Kit feature directory`);
10
+ throw new CompileError(`no tasks.md in ${dir} — point tickmarkr compile at a Spec Kit feature directory`);
11
11
  }
12
12
  const content = readFileSync(path, "utf8");
13
13
  const drafts = [];
@@ -269,7 +269,7 @@ export function loadConfig(repoRoot, opts = {}) {
269
269
  return r.data;
270
270
  }
271
271
  export function configTemplate() {
272
- return `# drovr config overlay — merges over built-in defaults (repo beats global beats defaults)
272
+ return `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
273
273
  # concurrency: 3
274
274
  # driver: auto # auto | herdr | subprocess
275
275
  # taskTimeoutMinutes: 30
@@ -279,9 +279,9 @@ export function configTemplate() {
279
279
  # map: # per-shape routing; pin an exact CLI+model, or tier+prefer
280
280
  # implement: { tier: mid, prefer: [cursor-agent, codex] }
281
281
  # migration: { pin: { via: claude-code, model: fable } }
282
- # floors: # advisory minimum tiers; 'drovr plan' lints violations
282
+ # floors: # advisory minimum tiers; 'tickmarkr plan' lints violations
283
283
  # migration: frontier
284
- # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'drovr plan'
284
+ # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
285
285
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
286
286
  # allow: { adapters: [claude-code, codex] } # optional fleet allowlist; presence activates even if empty (fail-closed)
287
287
  # deny: { models: [codex:gpt-5.5] } # optional fleet denylist; deny beats allow on conflict
@@ -321,9 +321,9 @@ export function configTemplate() {
321
321
  # dialog stalling unattended HEADLESS runs (live-verified on claude 2.1.205). Interactive TUIs still
322
322
  # show a first-entry dialog — but it is the workspace TRUST dialog, not MCP config loading (live-drilled
323
323
  # claude 2.1.206, 2026-07-10). There is NO CLI flag to pre-accept trust; the only store is claude's global
324
- # ~/.claude.json keyed on the EXACT worktree path. drovr does NOT write it (HYG-03 won't-fix, decision B:
324
+ # ~/.claude.json keyed on the EXACT worktree path. tickmarkr does NOT write it (HYG-03 won't-fix, decision B:
325
325
  # claude's own last-writer-wins persistence races any seed). Cost is ~one dismissal per worktree path,
326
- # ever — drovr reuses stable worktree paths so the accept persists across runs; blocked-pane paging
326
+ # ever — tickmarkr reuses stable worktree paths so the accept persists across runs; blocked-pane paging
327
327
  # surfaces each first-time dialog.
328
328
  `;
329
329
  }
@@ -11,10 +11,13 @@ export declare class HerdrDriver implements ExecutorDriver {
11
11
  private groups;
12
12
  private groupSerial;
13
13
  private ws;
14
+ private callerPane;
15
+ private watches;
14
16
  constructor(bin?: string, workersPerTab?: number);
15
17
  private serial;
16
18
  static available(): boolean;
17
19
  private herdr;
20
+ private namedPaneId;
18
21
  private paneId;
19
22
  private paneWidth;
20
23
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
@@ -36,6 +39,8 @@ export declare class HerdrDriver implements ExecutorDriver {
36
39
  notify(msg: string, opts?: NotifyOpts): Promise<void>;
37
40
  close(slot: Slot): Promise<void>;
38
41
  private closeGrouped;
42
+ private priorWatch;
43
+ private watchSlot;
39
44
  narrator(cwd: string, command: string, runId?: string): Promise<Slot>;
40
45
  reconcile(desired: Set<string>, runId: string, opts?: {
41
46
  spareLiveLlm?: boolean;
@@ -1,7 +1,7 @@
1
1
  import { shq } from "../adapters/types.js";
2
2
  import { createWorktree, sh } from "../run/git.js";
3
3
  import { herdrSealShellPrefix } from "./subprocess.js";
4
- import { canonicalizeLegacyName, formatOwnedName, panesToClose } from "./types.js";
4
+ import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
5
5
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
6
6
  export const TRAILER_SAFE_FLOOR_COLS = 108;
7
7
  export const TRAILER_WIDTH_MARGIN = 2; // cols below (floor + margin) refuse a rightward first split
@@ -24,6 +24,8 @@ export class HerdrDriver {
24
24
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
25
25
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
26
26
  ws = process.env.HERDR_WORKSPACE_ID;
27
+ callerPane = process.env.HERDR_PANE_ID;
28
+ watches = new Map();
27
29
  constructor(bin = "herdr", workersPerTab = 3) {
28
30
  this.bin = bin;
29
31
  this.workersPerTab = workersPerTab;
@@ -39,18 +41,21 @@ export class HerdrDriver {
39
41
  herdr(args, cwd = process.cwd(), timeoutMs) {
40
42
  return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
41
43
  }
42
- // pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
43
- async paneId(slot) {
44
- const r = await this.herdr(`agent get ${shq(slot.name)}`);
44
+ async namedPaneId(name) {
45
+ const r = await this.herdr(`agent get ${shq(name)}`);
46
+ if (r.code !== 0)
47
+ return null;
45
48
  try {
46
49
  const id = JSON.parse(r.stdout).result?.agent?.pane_id;
47
- if (typeof id === "string" && id)
48
- return id;
50
+ return typeof id === "string" && id ? id : null;
49
51
  }
50
52
  catch {
51
- /* fall back to cached id */
53
+ return null;
52
54
  }
53
- return slot.id;
55
+ }
56
+ // pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
57
+ async paneId(slot) {
58
+ return await this.namedPaneId(slot.name) ?? slot.id;
54
59
  }
55
60
  // VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
56
61
  async paneWidth(paneId) {
@@ -310,6 +315,13 @@ export class HerdrDriver {
310
315
  await this.herdr(`notification show ${shq(msg)} --sound ${opts?.tier === "attention" ? "request" : opts?.sound ?? "request"}`);
311
316
  }
312
317
  async close(slot) {
318
+ if (this.watches.get(slot.name)?.id === slot.id) {
319
+ this.watches.delete(slot.name);
320
+ const pane = await this.namedPaneId(slot.name);
321
+ if (pane)
322
+ await this.herdr(`pane close ${shq(pane)}`);
323
+ return; // run-end reconcile may already have reaped it; never close a compacted stale id
324
+ }
313
325
  if (slot.group && this.groups.has(slot.group)) {
314
326
  return this.serial(() => this.closeGrouped(slot));
315
327
  }
@@ -342,16 +354,95 @@ export class HerdrDriver {
342
354
  this.groups.delete(slot.group); // group dies when all generations gone
343
355
  }
344
356
  }
345
- // T6 narrator: the run's single live surface — a drovr-created "watch" tab running the daemon's
346
- // status command. Reuses the dedicated-tab slot path (label → tabSlot); close() reaps the whole tab
347
- // at run end. One per run (the daemon calls this once). Failures propagate the daemon swallows.
348
- // T2: with a runId the pane is named canonically (drovr:watch:run:0:<runId>) so reconcile owns it;
349
- // the legacy `narrator-watch-<pid>` shape survives for runId-less callers (canonicalizeLegacyName).
357
+ async priorWatch(runId) {
358
+ if (!this.ws)
359
+ throw new Error("herdr watch placement requires HERDR_WORKSPACE_IDrefusing unseeded pane");
360
+ const list = await this.herdr("agent list");
361
+ if (list.code !== 0)
362
+ throw new Error(`herdr agent list failed: ${list.stderr || list.stdout}`);
363
+ let agents;
364
+ try {
365
+ agents = JSON.parse(list.stdout).result?.agents;
366
+ }
367
+ catch {
368
+ throw new Error(`herdr agent list returned unparseable JSON: ${list.stdout}`);
369
+ }
370
+ if (!Array.isArray(agents))
371
+ throw new Error(`herdr agent list returned no agents: ${list.stdout}`);
372
+ const prior = agents.find((a) => {
373
+ const owned = typeof a.name === "string" ? parseOwnedName(a.name) : null;
374
+ return a.workspace_id === this.ws && typeof a.pane_id === "string" && owned?.role === "watch" && owned.taskId === "run" && owned.runId !== runId;
375
+ });
376
+ return prior?.pane_id ?? null;
377
+ }
378
+ // T2: the watch is a rightward sibling of the daemon's own pane, never a separate tab. Its durable
379
+ // owned name lets a resumed daemon find an already-running watch instead of stacking another one.
380
+ async watchSlot(cwd, name) {
381
+ if (!this.ws)
382
+ throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
383
+ if (!this.callerPane)
384
+ throw new Error("herdr watch placement requires HERDR_PANE_ID — refusing untargeted split");
385
+ const sp = await this.herdr(`pane split ${shq(this.callerPane)} --direction right --no-focus`);
386
+ if (sp.code !== 0)
387
+ throw new Error(`herdr watch split failed: ${sp.stderr || sp.stdout}`);
388
+ let pane;
389
+ try {
390
+ pane = JSON.parse(sp.stdout).result?.pane?.pane_id;
391
+ }
392
+ catch {
393
+ /* fail closed below */
394
+ }
395
+ if (typeof pane !== "string" || !pane)
396
+ throw new Error(`herdr watch split returned no pane id: ${sp.stdout}`);
397
+ const renamed = await this.herdr(`agent rename ${shq(pane)} ${shq(name)}`);
398
+ if (renamed.code !== 0 || await this.namedPaneId(name) !== pane) {
399
+ await this.herdr(`pane close ${shq(pane)}`);
400
+ throw new Error(`herdr watch rename failed: ${renamed.stderr || renamed.stdout}`);
401
+ }
402
+ const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`cd ${shq(cwd)}; export HERDR_WORKSPACE_ID=${shq(this.ws)}; ${herdrSealShellPrefix()}`)}`, cwd);
403
+ if (seed.code !== 0) {
404
+ await this.herdr(`pane close ${shq(pane)}`);
405
+ throw new Error(`herdr watch seed failed: ${seed.stderr || seed.stdout}`);
406
+ }
407
+ return { id: pane, name, cwd };
408
+ }
409
+ // T6 narrator: the run's single live status surface. Reuse a local or already-running owned watch;
410
+ // a new run reowns its prior watch, and only a newly split pane receives the watch command. The
411
+ // status command reads the latest run every frame, so the renamed pane follows the new run without
412
+ // interrupting the operator's watch loop. Failures propagate — the daemon swallows.
350
413
  async narrator(cwd, command, runId) {
351
414
  const name = runId ? formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId }) : `narrator-watch-${process.pid}`;
352
- const s = await this.slot(cwd, name, { label: "watch" });
353
- await this.run(s, command);
354
- return s;
415
+ return this.serial(async () => {
416
+ const cached = this.watches.get(name);
417
+ if (cached)
418
+ return cached;
419
+ const existing = await this.namedPaneId(name);
420
+ if (existing) {
421
+ const s = { id: existing, name, cwd };
422
+ this.watches.set(name, s);
423
+ return s;
424
+ }
425
+ const prior = runId ? await this.priorWatch(runId) : null;
426
+ if (prior) {
427
+ const renamed = await this.herdr(`agent rename ${shq(prior)} ${shq(name)}`);
428
+ if (renamed.code !== 0 || await this.namedPaneId(name) !== prior) {
429
+ throw new Error(`herdr watch reclaim failed: ${renamed.stderr || renamed.stdout}`);
430
+ }
431
+ const s = { id: prior, name, cwd };
432
+ this.watches.set(name, s);
433
+ return s;
434
+ }
435
+ const s = await this.watchSlot(cwd, name);
436
+ this.watches.set(name, s);
437
+ try {
438
+ await this.run(s, command);
439
+ }
440
+ catch (err) {
441
+ this.watches.delete(name);
442
+ throw err;
443
+ }
444
+ return s;
445
+ });
355
446
  }
356
447
  // OBS-17 T2 / v1.22b T1: close every drovr-owned pane that should not exist (superseded attempts,
357
448
  // killed-daemon orphans, leftovers from OLDER runs) — in this run's workspace OR misplaced in any
@@ -57,7 +57,7 @@ export class SubprocessDriver {
57
57
  p.stdout.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
58
58
  p.stderr.on("data", (d) => (s.buf = (s.buf + d).slice(-MAX_BUF)));
59
59
  p.on("close", () => (s.exited = true));
60
- p.on("error", (e) => { s.buf = (s.buf + `\n[drovr subprocess error] ${e}\n`).slice(-MAX_BUF); s.exited = true; });
60
+ p.on("error", (e) => { s.buf = (s.buf + `\n[tickmarkr subprocess error] ${e}\n`).slice(-MAX_BUF); s.exited = true; });
61
61
  }
62
62
  async waitOutput(slot, pattern, timeoutMs, opts) {
63
63
  const s = this.state(slot);
@@ -92,7 +92,7 @@ export class SubprocessDriver {
92
92
  async notify(msg, _opts) {
93
93
  if (_opts?.tier === "routine")
94
94
  return;
95
- console.log(`[drovr] ${msg}`);
95
+ console.log(`[tickmarkr] ${msg}`);
96
96
  }
97
97
  async close(slot) {
98
98
  const s = this.slots.get(slot.id);
@@ -20,19 +20,20 @@ export function parseOwnedName(name) {
20
20
  export function isForeignName(name) {
21
21
  return parseOwnedName(name) === null;
22
22
  }
23
- // v1.22b T1: workspace-aware fold over a fleet snapshot — decides which owned panes are garbage
23
+ // v1.22b T1: workspace-aware fold over a fleet snapshot — decides which owned task panes are garbage
24
24
  // right now. In-workspace: the existing desired-set/spareLiveLlm sweep (OBS-17 T2). Out-of-workspace:
25
25
  // an owned pane from a DIFFERENT run is a misplaced leftover (bug, foreign actor, pre-VIS-10 relic)
26
26
  // and closes regardless of `desired`; an owned pane from THIS run elsewhere is left alone — a live
27
27
  // run can legitimately hold panes across workspaces, so only run age marks a misplaced pane garbage.
28
- // Foreign names (parseOwnedName fails) are never candidates, in any workspace.
28
+ // Watch panes are operator-owned after run end and are reclaimed by the next run; foreign names
29
+ // (parseOwnedName fails) are never candidates, in any workspace.
29
30
  export function panesToClose(agents, desired, ws, runId, opts) {
30
31
  const out = [];
31
32
  for (const a of agents) {
32
33
  if (typeof a.name !== "string" || typeof a.paneId !== "string")
33
34
  continue;
34
35
  const owned = parseOwnedName(a.name);
35
- if (!owned)
36
+ if (!owned || owned.role === "watch")
36
37
  continue;
37
38
  if (a.workspaceId === ws) {
38
39
  if (desired.has(a.name))
@@ -71,8 +71,12 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
71
71
  const results = [];
72
72
  for (const name of enabled) {
73
73
  const cmd = commands[name];
74
- if (!cmd)
75
- continue; // nothing detected for this gate in the target repo
74
+ if (!cmd) {
75
+ // nothing detected for this gate in the target repo — journal an explicit skip instead of
76
+ // vanishing silently (a lint gate with no lint script rendered as forever-open in status)
77
+ results.push({ gate: name, pass: true, details: `no ${name} command detected — skipped`, meta: { skipped: true } });
78
+ continue;
79
+ }
76
80
  const r = await sh(cmd, cwd);
77
81
  if (r.code === 0) {
78
82
  results.push({ gate: name, pass: true, details: "exit 0" });