tickmarkr 1.33.0 → 1.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,9 +62,15 @@ npm i -g tickmarkr
62
62
  ```
63
63
 
64
64
  This installs two identical bins: `tickmarkr` and its short alias `tkr` — use whichever you
65
- prefer, every example below works with either. Requirements: Node ≥ 20, git, and at least one
66
- agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, or `pi`) authenticated
67
- through its own login.
65
+ prefer, every example below works with either.
66
+
67
+ **Requirements:**
68
+
69
+ - **macOS or Linux.** Every command shells out through `bash`, so native Windows is not
70
+ supported — use WSL (untested).
71
+ - Node ≥ 20 and `git` on PATH.
72
+ - At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, or
73
+ `pi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
68
74
 
69
75
  Then verify your fleet:
70
76
 
@@ -72,12 +78,17 @@ Then verify your fleet:
72
78
  tickmarkr doctor # probes installed adapters, herdr, auth; prints the capability matrix
73
79
  ```
74
80
 
81
+ **Expect `doctor` (and `init`, which runs it) to take a while on first run:** auth detection is
82
+ one real, short LLM call per configured model per installed CLI — honest, but not instant. A
83
+ machine with three CLIs and several models each can take a minute or more; each probe is capped
84
+ at 30s.
85
+
75
86
  ## Quickstart (5 minutes)
76
87
 
77
88
  ```bash
78
- tickmarkr init # guided setup + doctor; scaffolds .drovr/config.yaml and drovr.spec.md
79
- # edit drovr.spec.md # the native spec template, marked <!-- drovr:spec -->
80
- tickmarkr compile drovr.spec.md # spec → .drovr/graph.json (fails without acceptance criteria)
89
+ tickmarkr init # guided setup + doctor; scaffolds .drovr/config.yaml and tickmarkr.spec.md
90
+ # edit tickmarkr.spec.md # the native spec template, marked <!-- drovr:spec -->
91
+ tickmarkr compile tickmarkr.spec.md # spec → .drovr/graph.json (fails without acceptance criteria)
81
92
  tickmarkr plan # dry-run routing table + cost estimate + floor lints
82
93
  tickmarkr run # execute the graph (--concurrency N --driver herdr|subprocess --route-strict)
83
94
  tickmarkr report <runId> --md # engagement record in Markdown
@@ -312,11 +323,11 @@ produced the graph. Upstream, these front-ends exist:
312
323
 
313
324
  | Spec source | Support | How |
314
325
  |---|---|---|
315
- | **drovr spec** (native format) | Native/default | `tickmarkr init` template with the `<!-- drovr:spec -->` marker |
326
+ | **tickmarkr spec** (native format) | Native/default | `tickmarkr init` template with the `<!-- drovr:spec -->` marker |
316
327
  | **Spec Kit** feature dir | Native | dir containing `tasks.md` |
317
328
  | **GSD** phase dir | Native | dir containing `*-PLAN.md` |
318
329
  | **Markdown PRD** | Native (universal adapter) | any `.md` with tasks + acceptance criteria |
319
- | OpenSpec, BMAD, Superpowers, others | Not yet | render to a drovr spec (the `<!-- drovr:spec -->` marker plus the full Task surface), or contribute a front-end: one parser module behind `src/compile/index.ts`, same pattern as `gsd.ts` |
330
+ | OpenSpec, BMAD, Superpowers, others | Not yet | render to a tickmarkr spec (the `<!-- drovr:spec -->` marker plus the full Task surface), or contribute a front-end: one parser module behind `src/compile/index.ts`, same pattern as `gsd.ts` |
320
331
 
321
332
  Compile fails loudly on anything it can't recognize (`--type native|speckit|prd|gsd` to force).
322
333
 
@@ -353,8 +364,8 @@ Untyped acceptance strings in existing specs compile as `oracle: judge` + warnin
353
364
 
354
365
  Cloning this repo gives Claude Code project skills under `.claude/skills/`:
355
366
 
356
- - **`/drovr-loop`** — the SDD-agnostic loop: compile one spec, read the plan, run from the journal, merge green work, and commit only the Markdown execution record beside that spec. For GSD bookkeeping, use `/drovr-auto`.
357
- - **`/drovr-auto`** — the **GSD binding**: run remaining GSD milestone phases autonomously with tickmarkr as the
367
+ - **`/tickmarkr-loop`** — the SDD-agnostic loop: compile one spec, read the plan, run from the journal, merge green work, and commit only the Markdown execution record beside that spec. For GSD bookkeeping, use `/tickmarkr-auto`.
368
+ - **`/tickmarkr-auto`** — the **GSD binding**: run remaining GSD milestone phases autonomously with tickmarkr as the
358
369
  executor (plans → compile → dry-run → run → merge → sync-back). Bindings are per-SDD by nature — the
359
370
  sync-back writes that methodology's artifacts; other SDDs need their own thin binding, not this one.
360
371
  - **`/overseer`** — supervise an autonomous tickmarkr engagement from a Herdr workspace: two-tier visible hierarchy,
@@ -49,7 +49,7 @@ export const cursorAgent = {
49
49
  // print-only). Doctor names the dialog; the daemon auto-answers it via trustDialog once per slot.
50
50
  trust: () => ({
51
51
  status: "action-required",
52
- command: 'accept the cursor-agent "Workspace Trust Required" dialog (Enter) — drovr auto-answers once per slot',
52
+ command: 'accept the cursor-agent "Workspace Trust Required" dialog (Enter) — tickmarkr auto-answers once per slot',
53
53
  }),
54
54
  trustDialog: CURSOR_TRUST_DIALOG,
55
55
  // v1.5 MODEL-01: fail OPEN to [] (advisory detection, unlike gates). --list-models may touch the
@@ -1,7 +1,7 @@
1
1
  import { MODEL_ID_RE } from "./types.js";
2
2
  // date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
3
3
  export const SEED_STAMPED = "2026-07-09";
4
- // knowledge past this age gets a "rerun drovr doctor" nudge (BLOCKED_POLL_MS-style named constant).
4
+ // knowledge past this age gets a "rerun tickmarkr doctor" nudge (BLOCKED_POLL_MS-style named constant).
5
5
  export const MODEL_STALE_DAYS = 30;
6
6
  const DAY_MS = 86400000;
7
7
  // cursor-agent 2026.07.08 reports 193 mostly-parameterized ids (e.g. gpt-5.3-codex-high-fast); filter the `auto`
@@ -26,7 +26,7 @@ export function modelLints(cfg, health, adapters) {
26
26
  const detected = h?.models ?? []; // MANDATORY default: pre-v1.5 files lack a populated models array
27
27
  if (detected.length === 0) {
28
28
  if (h?.installed)
29
- lints.push(`${id}: no detection data — run drovr doctor`);
29
+ lints.push(`${id}: no detection data — run tickmarkr doctor`);
30
30
  continue; // no data to diff or age
31
31
  }
32
32
  const configured = Object.keys(cfg.tiers[id].models);
@@ -45,7 +45,7 @@ export function modelLints(cfg, health, adapters) {
45
45
  if (at) {
46
46
  const days = Math.floor((Date.now() - Date.parse(at)) / DAY_MS); // completed days — never overstate age
47
47
  if (days >= MODEL_STALE_DAYS)
48
- lints.push(`${id}: model knowledge is ${days} days old — rerun drovr doctor`);
48
+ lints.push(`${id}: model knowledge is ${days} days old — rerun tickmarkr doctor`);
49
49
  }
50
50
  }
51
51
  return lints;
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { renderAcceptanceItem } from "../graph/schema.js";
4
4
  export function buildTaskPrompt(task, feedback = "", nonce = "") {
5
5
  const list = (xs) => xs.map((x) => `- ${x}`).join("\n");
6
- return `You are an autonomous coding worker dispatched by drovr into an isolated git worktree.
6
+ return `You are an autonomous coding worker dispatched by tickmarkr into an isolated git worktree.
7
7
 
8
8
  ## Task ${task.id}: ${task.title}
9
9
  Goal: ${task.goal}
@@ -40,7 +40,7 @@ export async function approve(argv, cwd = process.cwd()) {
40
40
  via: "cli",
41
41
  ...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
42
42
  });
43
- return `approved ${taskId} in ${runId} — by ${by}; run \`drovr resume ${runId}\` to dispatch it`;
43
+ return `approved ${taskId} in ${runId} — by ${by}; run \`tickmarkr resume ${runId}\` to dispatch it`;
44
44
  }
45
45
  // hand-parsed argv — no CLI framework (house style). Flags --by <name> and --reason <text>; positionals
46
46
  // are runId then taskId. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
@@ -53,12 +53,12 @@ function parseArgs(argv) {
53
53
  if (a === "--by") {
54
54
  by = argv[++i];
55
55
  if (!by)
56
- throw new Error("usage: drovr approve <run-id> <task-id> [--by <name>] [--reason <text>]");
56
+ throw new Error("usage: tickmarkr approve <run-id> <task-id> [--by <name>] [--reason <text>]");
57
57
  }
58
58
  else if (a === "--reason") {
59
59
  reason = argv[++i];
60
60
  if (!reason)
61
- throw new Error("usage: drovr approve <run-id> <task-id> [--by <name>] [--reason <text>]");
61
+ throw new Error("usage: tickmarkr approve <run-id> <task-id> [--by <name>] [--reason <text>]");
62
62
  }
63
63
  else {
64
64
  positionals.push(a);
@@ -66,7 +66,7 @@ function parseArgs(argv) {
66
66
  }
67
67
  const [runId, taskId] = positionals;
68
68
  if (!runId || !taskId) {
69
- throw new Error("usage: drovr approve <run-id> <task-id> [--by <name>] [--reason <text>]");
69
+ throw new Error("usage: tickmarkr approve <run-id> <task-id> [--by <name>] [--reason <text>]");
70
70
  }
71
71
  return { runId, taskId, by: by ?? userInfo().username, reason };
72
72
  }
@@ -11,7 +11,7 @@ export async function compile(argv, cwd = process.cwd()) {
11
11
  });
12
12
  const src = positionals[0];
13
13
  if (!src)
14
- throw new Error("usage: drovr compile <spec-dir-or-md> [--type speckit|prd|gsd|native]");
14
+ throw new Error("usage: tickmarkr compile <spec-dir-or-md> [--type speckit|prd|gsd|native]");
15
15
  // resolve against the target repo, not the process cwd (the CLI test passes a tmp repo)
16
16
  const g = compileSource(isAbsolute(src) ? src : join(cwd, src), values.type, cwd);
17
17
  // HARD-01: a compile clobbering a running daemon's graph.json is the same last-write-wins race
@@ -1,10 +1,40 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { allAdapters, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
+ import { drovrDir } from "../../graph/graph.js";
2
5
  import { modelLints, suggestOverlay } from "../../adapters/model-lints.js";
3
6
  import { loadConfig } from "../../config/config.js";
4
7
  import { HerdrDriver } from "../../drivers/herdr.js";
5
8
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
9
+ // same gate + palette as status's audit-ledger frame: styled only on a real TTY, plain otherwise
10
+ const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
11
+ const color = (text, code) => `\x1b[${code}m${text}\x1b[0m`;
12
+ // TTY-only line pass: color the verdict symbols, dim the chrome (section titles, parentheticals,
13
+ // n/a rows). Padding is untouched — every rewrite swaps equal-width text or wraps it in ANSI.
14
+ function stylize(out) {
15
+ if (!visual())
16
+ return out;
17
+ const rule = color("─".repeat(Math.min(process.stdout.columns ?? 80, 100)), 2);
18
+ return out.split("\n").map((line) => {
19
+ if (line.startsWith("tickmarkr doctor"))
20
+ return ` ${color("✓", 32)} ${color("tickmarkr", 1)} doctor ${color("· capability matrix", 2)}\n${rule}`;
21
+ if (/^(workspace trust|model status|model drift)/.test(line))
22
+ return color(line, 2);
23
+ return line
24
+ .replace(/^(\s+)✓ /, (_, s) => `${s}${color("✓", 32)} `)
25
+ .replace(/^(\s+)✗ /, (_, s) => `${s}${color("✗", 31)} `)
26
+ .replace(/^(\s+)! /, (_, s) => `${s}${color("!", 33)} `)
27
+ .replace(/^(\s+)= (.*)$/, (_, s, rest) => `${s}${color(`= ${rest}`, 2)}`)
28
+ .replace(/(\(auth assumed; verified at dispatch \(failover on auth\/quota errors\)\))/, (m) => color(m, 2))
29
+ .replace(/\bauthed\b(?!:)/, color("authed", 32))
30
+ .replace(/\bunauthed:/, color("unauthed:", 33));
31
+ }).join("\n");
32
+ }
6
33
  export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters()) {
7
34
  const cfg = loadConfig(cwd);
35
+ // stderr, live: auth probes are real LLM calls (up to 30s per configured model) and the CLI
36
+ // otherwise prints nothing until the end — silence here reads as a hang (v1.33.1)
37
+ console.error("probing installed agent CLIs — one short LLM call per configured model, may take a minute...");
8
38
  const health = await probeAll(adapters);
9
39
  // MODEL-02: detect models where the adapter exposes a list surface, BEFORE writing doctor.json (write once, below).
10
40
  // Fail OPEN — the inverse of gates' fail-closed: detection is advisory, so a broken list surface NEVER fails doctor.
@@ -67,8 +97,20 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
67
97
  if (servable.length)
68
98
  rows.push(` ! ${servabilityLine(servable)}`);
69
99
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, drovr NEVER applies it.
100
+ // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
101
+ // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
70
102
  const frag = suggestOverlay(cfg, health, adapters);
71
- const drift = frag ? `\nmodel drift — paste-ready overlay (advisory; tickmarkr never applies):\n${frag}` : "";
103
+ let drift = "";
104
+ if (frag) {
105
+ if (visual()) {
106
+ const overlayPath = join(drovrDir(cwd), "doctor-overlay.yaml");
107
+ writeFileSync(overlayPath, frag);
108
+ drift = `\nmodel drift: unclassified models detected — paste-ready overlay written to ${overlayPath} (advisory; tickmarkr never applies it)`;
109
+ }
110
+ else {
111
+ drift = `\nmodel drift — paste-ready overlay (advisory; tickmarkr never applies):\n${frag}`;
112
+ }
113
+ }
72
114
  // T4: model-status table — one row per CLASSIFIED model (tiers config) with tier, auth verdict
73
115
  // (reason + date when unauthed), operator-deny flag, and prefer rank across the routing map.
74
116
  // Unclassified listed models (detected via listModels but never tiered) compress to one count line
@@ -99,5 +141,5 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
99
141
  return rows;
100
142
  });
101
143
  const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
102
- return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote .drovr/doctor.json`;
144
+ return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote .drovr/doctor.json`);
103
145
  }
@@ -21,7 +21,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
21
21
  // output byte-identical to today.
22
22
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
23
23
  let deviations = 0;
24
- const lines = [`drovr plan — dry run (${channels.length} channels available)`, ""];
24
+ const lines = [`tickmarkr plan — dry run (${channels.length} channels available)`, ""];
25
25
  const excluded = excludedChannels(cfg, adapters, health);
26
26
  if (excluded.length)
27
27
  lines.push(exclusionLine(excluded), "");
@@ -38,7 +38,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
38
38
  if (cached) {
39
39
  const age = doctorAgeMs(cwd);
40
40
  if (age !== null && age > DOCTOR_STALE_MS) {
41
- lines.push(`doctor.json is ${Math.floor(age / 3_600_000)}h old — run 'drovr doctor' to refresh (servability/auth may have changed)`, "");
41
+ lines.push(`doctor.json is ${Math.floor(age / 3_600_000)}h old — run 'tickmarkr doctor' to refresh (servability/auth may have changed)`, "");
42
42
  }
43
43
  }
44
44
  // HYG-07(a)/T2 pin honesty: append the drop reason when a pin/miss message names a filtered channel —
@@ -32,7 +32,7 @@ function show(cwd) {
32
32
  const cursor = readProfileCursor(cwd);
33
33
  const p = loadRoutingProfile(cwd, cfg, { preview: true });
34
34
  const header = [
35
- `drovr profile`,
35
+ `tickmarkr profile`,
36
36
  ` routing.learned: ${cfg.routing.learned}${cfg.routing.learned === "off" ? " (preview — routing is inert)" : ""}`,
37
37
  ` runs window: ${RUNS_WINDOW}`,
38
38
  ...(cursor ? [` reset cursor: ${cursor}`] : []),
@@ -293,9 +293,9 @@ export async function report(argv, cwd = process.cwd()) {
293
293
  options: { md: { type: "boolean" } },
294
294
  allowPositionals: true,
295
295
  });
296
- const runId = positionals[0] ?? Journal.latestRunId(cwd);
296
+ const runId = positionals[0] ?? Journal.latestRunId(cwd, { withJournal: true });
297
297
  if (!runId)
298
- throw new Error("no runs found — usage: drovr report <run-id> [--md]");
298
+ throw new Error("no runs found — usage: tickmarkr report <run-id> [--md]");
299
299
  const j = Journal.open(cwd, runId);
300
300
  const events = j.read();
301
301
  if (values.md) {
@@ -5,7 +5,7 @@ import { formatJournalNarration } from "../../run/journal.js";
5
5
  export async function resume(argv, cwd = process.cwd()) {
6
6
  const runId = argv[0];
7
7
  if (!runId)
8
- throw new Error("usage: drovr resume <run-id>");
8
+ throw new Error("usage: tickmarkr resume <run-id>");
9
9
  const s = await runDaemon(cwd, { runId, resume: true, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
10
10
  return `resumed ${s.runId} — ${formatSummary(s)}`;
11
11
  }
@@ -10,7 +10,7 @@ export async function scope(argv, cwd = process.cwd(), adapters = allAdapters(),
10
10
  allowPositionals: true,
11
11
  });
12
12
  if (positionals.length !== 1)
13
- throw new Error("usage: drovr scope <intent-file> [--force]");
13
+ throw new Error("usage: tickmarkr scope <intent-file> [--force]");
14
14
  const source = positionals[0];
15
15
  const intentFile = isAbsolute(source) ? source : join(cwd, source);
16
16
  const result = await scopeIntent(intentFile, cwd, {
@@ -39,21 +39,28 @@ const gateBox = (state, unicode) => {
39
39
  return state === "pass" ? "✓" : state === "fail" ? "✗" : state === "skip" ? "·" : "☐";
40
40
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
41
41
  };
42
- const gateChain = (task, events, unicode) => {
42
+ const gateStates = (task, events) => {
43
43
  const outcomes = new Map();
44
44
  const start = attemptStartIdx(events, task.id);
45
45
  if (start >= 0) {
46
46
  for (const e of events.slice(start)) {
47
47
  if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
48
48
  continue;
49
- if (e.data.pass === true)
49
+ if (e.data.skipped === true)
50
+ outcomes.set(e.data.gate, "skip");
51
+ else if (e.data.pass === true)
50
52
  outcomes.set(e.data.gate, "pass");
51
53
  else if (e.data.pass === false)
52
54
  outcomes.set(e.data.gate, "fail");
53
55
  }
54
56
  }
55
- return GATE_NAMES.map((gate) => `${GATE_KEYS[gate]}${gateBox(task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip", unicode)}`).join(" ");
57
+ return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
56
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);
57
64
  const shortGoal = (goal, max) => {
58
65
  const clause = goal.split(/[,;.?!]/, 1)[0].trim();
59
66
  if (clause.length <= max)
@@ -105,7 +112,7 @@ const liveness = (events) => {
105
112
  };
106
113
  const renderFrame = (cwd) => {
107
114
  const g = loadGraph(cwd);
108
- const runId = Journal.latestRunId(cwd);
115
+ const runId = Journal.latestRunId(cwd, { withJournal: true });
109
116
  const assignments = new Map();
110
117
  let replayed = null;
111
118
  let events = [];
@@ -130,24 +137,63 @@ const renderFrame = (cwd) => {
130
137
  const waiting = new Set(pendingTasks(effective).map((t) => t.id));
131
138
  const unicode = visual();
132
139
  const divider = unicode ? " · " : " / ";
133
- const rows = g.tasks.map((t) => {
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) => {
134
143
  const st = replayed?.get(t.id) ?? t.status;
135
144
  const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
136
145
  const channel = assignments.get(t.id) ?? "-";
137
146
  const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
138
- const chain = gateChain(t, events, unicode);
139
- const statusText = color(String(st), st === "done" ? 32 : st === "failed" ? 31 : st === "human" ? 33 : 36, unicode);
140
- const suffix = ` ${chain} ${String(st)}${label} ${assignCol}`;
141
- const width = process.stdout.columns ?? 120;
142
- const prefix = ` ${taskBox(st, unicode)} ${t.id} `;
143
- return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))} ${chain} ${statusText}${label} ${assignCol}`;
147
+ return { t, st, label, assignCol, states: gateStates(t, events) };
144
148
  });
145
- const done = effective.tasks.filter((t) => t.status === "done").length;
146
- const header = runId
147
- ? `tickmarkr status${divider}run ${runId}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
148
- : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
149
- const legend = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
150
- return [header, legend, ...rows].join("\n");
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)}`;
195
+ });
196
+ return [header, rule, legend, ...rows].join("\n");
151
197
  };
152
198
  export async function status(argv, cwd = process.cwd(), opts = {}) {
153
199
  if (!argv.includes("--watch"))
@@ -161,7 +207,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
161
207
  for (let i = 0; i < iterations; i++) {
162
208
  const frame = renderFrame(cwd);
163
209
  if (tty)
164
- 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`);
165
211
  else
166
212
  process.stdout.write(frame + sep);
167
213
  if (bounded)
@@ -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) => {
@@ -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
  }
@@ -144,10 +144,10 @@ export function compileNative(file) {
144
144
  // documented; the two example tasks illustrate plain vs deps+routing-hint, each with acceptance[].
145
145
  export function specTemplate() {
146
146
  return `<!-- tickmarkr:spec -->
147
- # drovr native 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
  }
@@ -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);
@@ -6,8 +6,10 @@
6
6
  // exactly. parseOwnedName (or isForeignName) is the ONLY way reconcile.ts may decide a live pane name
7
7
  // is drovr-owned — anything that doesn't parse is foreign and is never a candidate for closing.
8
8
  export const OWNED_ROLES = ["worker", "judge", "review", "consult", "watch", "other"];
9
- const OWNED_PREFIX = "drovr";
10
- const OWNED_RE = new RegExp(`^${OWNED_PREFIX}:(${OWNED_ROLES.join("|")}):([^:]+):(\\d+):([^:]+)$`);
9
+ // read-old/write-new: new panes are named tickmarkr:*, but reconcile still recognizes (and may
10
+ // close/reuse) drovr:* panes left by pre-rename runs — resumed old runs keep working.
11
+ const OWNED_PREFIX = "tickmarkr";
12
+ const OWNED_RE = new RegExp(`^(?:${OWNED_PREFIX}|drovr):(${OWNED_ROLES.join("|")}):([^:]+):(\\d+):([^:]+)$`);
11
13
  export function formatOwnedName(o) {
12
14
  return `${OWNED_PREFIX}:${o.role}:${o.taskId}:${o.attempt}:${o.runId}`;
13
15
  }
@@ -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" });
@@ -24,7 +24,7 @@ export function drovrDir(repoRoot) {
24
24
  export function loadGraph(repoRoot) {
25
25
  const p = graphPath(repoRoot);
26
26
  if (!existsSync(p))
27
- throw new Error(`no graph at ${p} — run \`drovr compile <src>\` first`);
27
+ throw new Error(`no graph at ${p} — run \`tickmarkr compile <src>\` first`);
28
28
  return validateGraph(JSON.parse(readFileSync(p, "utf8")));
29
29
  }
30
30
  export function saveGraph(repoRoot, g) {
@@ -1,6 +1,6 @@
1
1
  export function scopePrompt(intent, repair) {
2
2
  return `DROVR-JUDGE
3
- You are drafting a drovr native spec from an answered intent. Return only the Markdown spec, with no commentary or code fence.
3
+ You are drafting a tickmarkr native spec from an answered intent. Return only the Markdown spec, with no commentary or code fence.
4
4
 
5
5
  The draft must:
6
6
  - start with <!-- tickmarkr:spec -->
@@ -6,7 +6,7 @@ import { sh } from "./git.js";
6
6
  const ACTIONS = ["retry", "reroute", "decompose", "human"];
7
7
  export function buildDossierPrompt(d) {
8
8
  return `DROVR-CONSULT
9
- You are a senior engineering consult for the drovr orchestrator. A worker task hit trouble.
9
+ You are a senior engineering consult for the tickmarkr orchestrator. A worker task hit trouble.
10
10
  Read the dossier and return a verdict. Be decisive; cost is the lowest priority, quality the highest.
11
11
 
12
12
  ## Task: ${d.taskId} — trigger: ${d.trigger}
@@ -90,7 +90,7 @@ export async function runDaemon(repoRoot, opts = {}) {
90
90
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
91
91
  // a failed-to-open or later-dead watch pane never affects the run.
92
92
  try {
93
- await driver.narrator?.(repoRoot, "drovr status --watch", runId);
93
+ await driver.narrator?.(repoRoot, "tickmarkr status --watch", runId);
94
94
  }
95
95
  catch {
96
96
  /* cosmetic-only — the run proceeds without a live surface */
@@ -138,7 +138,7 @@ export async function runDaemon(repoRoot, opts = {}) {
138
138
  // merges are serialized — two concurrent `git merge`s in one worktree would corrupt each other
139
139
  let mergeChain = Promise.resolve();
140
140
  const mergeSerial = (taskBranch, t, gated) => {
141
- const next = mergeChain.then(() => mergeTask(intWt, taskBranch, `drovr: merge ${t.id} ${t.title}`, gated));
141
+ const next = mergeChain.then(() => mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated));
142
142
  mergeChain = next.catch(() => undefined);
143
143
  return next;
144
144
  };
@@ -152,7 +152,7 @@ export async function runDaemon(repoRoot, opts = {}) {
152
152
  journal.telemetry({ taskId: t.id, shape: t.shape, adapter: assignment.adapter, model: assignment.model, channel: assignment.channel, attempts, outcome: "human", durationMs: Date.now() - startMs, parkKind: kind ?? undefined, gateFails, consults, tokens, meteredAttempts: tokens ? metered : undefined, retryMode });
153
153
  }
154
154
  await reconcile({ spareLiveLlm: true }); // task-human is a terminal event — sweep, sparing sibling tasks' live LLM panes
155
- await driver.notify(`drovr ${runId}: ${t.id} needs a human — ${reason}`, { tier: "attention" });
155
+ await driver.notify(`tickmarkr ${runId}: ${t.id} needs a human — ${reason}`, { tier: "attention" });
156
156
  };
157
157
  const execTask = async (t) => {
158
158
  const startMs = Date.now();
@@ -251,7 +251,7 @@ export async function runDaemon(repoRoot, opts = {}) {
251
251
  action: v.action, notes: v.notes,
252
252
  ...(v.excludeAdapter ? { excludeAdapter: v.excludeAdapter } : {}),
253
253
  });
254
- await driver.notify(`drovr ${runId}: ${t.id} consult verdict: ${v.action}`, { tier: "attention" });
254
+ await driver.notify(`tickmarkr ${runId}: ${t.id} consult verdict: ${v.action}`, { tier: "attention" });
255
255
  if (v.action === "retry") {
256
256
  feedback = v.notes || feedback;
257
257
  return true;
@@ -385,7 +385,7 @@ export async function runDaemon(repoRoot, opts = {}) {
385
385
  threshold: cfg.contextWarnTokens,
386
386
  attempt,
387
387
  });
388
- await driver.notify(`drovr ${runId}: ${t.id} context ${usage.tokens} tokens ≥ ${cfg.contextWarnTokens}`, { tier: "attention" });
388
+ await driver.notify(`tickmarkr ${runId}: ${t.id} context ${usage.tokens} tokens ≥ ${cfg.contextWarnTokens}`, { tier: "attention" });
389
389
  };
390
390
  let finished;
391
391
  let output;
@@ -448,7 +448,7 @@ export async function runDaemon(repoRoot, opts = {}) {
448
448
  }
449
449
  paged = true; // page once — the visible pane is the operator's to unblock; task timeout is the backstop
450
450
  const why = st === "blocked" ? "is blocked on a prompt — approve in its pane" : "looks idle without finishing — check its pane";
451
- await driver.notify(`drovr ${runId}: ${slot.name} ${why}`, { tier: "attention" });
451
+ await driver.notify(`tickmarkr ${runId}: ${slot.name} ${why}`, { tier: "attention" });
452
452
  }
453
453
  // a dead pane or a false-positive marker display returns fast — sleep the unspent slice, never hot-spin
454
454
  const spent = Date.now() - sliceStart;
@@ -499,7 +499,7 @@ export async function runDaemon(repoRoot, opts = {}) {
499
499
  const next = failover("quota-failover");
500
500
  journal.append("quota-failover", t.id, { from: channelKey(assignment), to: next ? channelKey(next) : null });
501
501
  if (next) {
502
- await driver.notify(`drovr ${runId}: ${t.id} quota failover`, { tier: "attention" });
502
+ await driver.notify(`tickmarkr ${runId}: ${t.id} quota failover`, { tier: "attention" });
503
503
  // OBS-17 T2: the superseded slot's pane closes AT REROUTE TIME — it holds a throttled
504
504
  // dead-end, not failure context; the next safe-point reconcile catches a missed close.
505
505
  if (!keepForever) {
@@ -561,7 +561,7 @@ export async function runDaemon(repoRoot, opts = {}) {
561
561
  });
562
562
  }
563
563
  }
564
- journal.append("gate-result", t.id, { gate: g.gate, pass: g.pass, details: g.details });
564
+ journal.append("gate-result", t.id, { gate: g.gate, pass: g.pass, details: g.details, ...(g.meta?.skipped === true ? { skipped: true } : {}) });
565
565
  // v1.1 failover: never re-ask a reviewer channel that produced garbage for this task
566
566
  if (g.gate === "review" && !g.pass && /unparseable/.test(g.details) && typeof g.meta?.reviewer === "string") {
567
567
  badReviewers.push(g.meta.reviewer);
@@ -641,7 +641,7 @@ export async function runDaemon(repoRoot, opts = {}) {
641
641
  feedback = results.filter((g) => !g.pass).map((g) => `${g.gate}: ${g.details}`).join("\n\n");
642
642
  const step = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
643
643
  journal.append("escalation", t.id, { step, attempt: attempt + 1 });
644
- await driver.notify(`drovr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
644
+ await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
645
645
  if (step === "retry")
646
646
  continue;
647
647
  if (step === "escalate") {
@@ -711,7 +711,7 @@ export async function runDaemon(repoRoot, opts = {}) {
711
711
  .sort(([a], [b]) => a.localeCompare(b))
712
712
  .map(([root, count]) => `${count} blocked behind ${root}`)
713
713
  .join(", ");
714
- await driver.notify(`drovr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""} — integration branch ${branch} (merge to main is yours)`, { tier: "attention" });
714
+ await driver.notify(`tickmarkr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""} — integration branch ${branch} (merge to main is yours)`, { tier: "attention" });
715
715
  return summary;
716
716
  }
717
717
  finally {
package/dist/run/git.js CHANGED
@@ -7,14 +7,35 @@ import { drovrDir } from "../graph/graph.js";
7
7
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
8
8
  export function sh(cmd, cwd, timeoutMs = 600000) {
9
9
  return new Promise((resolve) => {
10
- const p = spawn("bash", ["-lc", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"] });
10
+ // detached: bash gets its own process group so a timeout can kill the whole tree —
11
+ // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
12
+ // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
13
+ const p = spawn("bash", ["-lc", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
11
14
  let stdout = "", stderr = "";
12
- let timedOut = false;
13
- const timer = setTimeout(() => { timedOut = true; p.kill("SIGKILL"); }, timeoutMs);
15
+ let timedOut = false, done = false;
16
+ const finish = (code, err) => {
17
+ if (done)
18
+ return;
19
+ done = true;
20
+ clearTimeout(timer);
21
+ resolve({ code, stdout, stderr: err ?? stderr, timedOut });
22
+ };
23
+ const timer = setTimeout(() => {
24
+ timedOut = true;
25
+ try {
26
+ process.kill(-p.pid, "SIGKILL");
27
+ }
28
+ catch {
29
+ p.kill("SIGKILL");
30
+ }
31
+ }, timeoutMs);
14
32
  p.stdout.on("data", (d) => (stdout += d));
15
33
  p.stderr.on("data", (d) => (stderr += d));
16
- p.on("error", (e) => { clearTimeout(timer); resolve({ code: 127, stdout, stderr: String(e), timedOut }); });
17
- p.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr, timedOut }); });
34
+ p.on("error", (e) => finish(127, String(e)));
35
+ p.on("close", (code) => finish(code ?? 1));
36
+ // "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
37
+ p.on("exit", (code) => { if (timedOut)
38
+ finish(code ?? 1); });
18
39
  });
19
40
  }
20
41
  export async function shOk(cmd, cwd) {
@@ -80,7 +80,9 @@ export declare class Journal {
80
80
  private constructor();
81
81
  static create(repoRoot: string, runId: string, narrate?: (event: JournalEvent) => void): Journal;
82
82
  static open(repoRoot: string, runId: string, narrate?: (event: JournalEvent) => void): Journal;
83
- static latestRunId(repoRoot: string): string | null;
83
+ static latestRunId(repoRoot: string, opts?: {
84
+ withJournal?: boolean;
85
+ }): string | null;
84
86
  private get journalPath();
85
87
  append(event: string, taskId?: string, data?: Record<string, unknown>): void;
86
88
  read(): JournalEvent[];
@@ -157,10 +157,16 @@ export class Journal {
157
157
  throw new Error(`no journal for ${runId} at ${dir}`);
158
158
  return new Journal(dir, runId, narrate);
159
159
  }
160
- static latestRunId(repoRoot) {
160
+ // withJournal: journal.jsonl appears at first append, after Journal.create mkdirs — a caller that
161
+ // will Journal.open the result (status, report) must fall back to the newest run that is actually
162
+ // readable, not throw on the mkdir-to-first-append window. Raw default stays for telemetry-scoped
163
+ // callers (profile cursor), where a journal-less run dir still counts.
164
+ static latestRunId(repoRoot, opts = {}) {
161
165
  if (!existsSync(runsDir(repoRoot)))
162
166
  return null;
163
- const ids = readdirSync(runsDir(repoRoot)).filter((d) => d.startsWith("run-")).sort();
167
+ const ids = readdirSync(runsDir(repoRoot))
168
+ .filter((d) => d.startsWith("run-") && (!opts.withJournal || existsSync(join(runsDir(repoRoot), d, "journal.jsonl"))))
169
+ .sort();
164
170
  return ids.at(-1) ?? null;
165
171
  }
166
172
  get journalPath() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.33.0",
3
+ "version": "1.33.2",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",