tickmarkr 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,15 @@ gates) only after independent verification. An **engagement** (`tickmarkr run`)
8
8
  into a task graph, routes work to the best installed agent CLI (claude-code, codex, cursor-agent,
9
9
  opencode, grok, pi) by cost within capability floors, dispatches **field teams** in isolated git
10
10
  worktrees — as real visible TUIs when running under [herdr](https://herdr.dev), headless
11
- subprocesses otherwise — and records every event in the **audit trail** (`.drovr/runs/<runId>/journal.jsonl`).
11
+ subprocesses otherwise — and records every event in the **audit trail** (engagement state directory).
12
12
  Green tasks consolidate onto a `tickmarkr/<runId>` **consolidation** branch; **sign-off** (merge to
13
13
  your mainline) is always the Partner's call, never the engine's. A fully green engagement is an
14
14
  **unqualified opinion** — every assertion independently ticked.
15
15
 
16
+ <!-- tickmarkr:compat-legacy BEGIN: sanctioned naming residue, unchanged pending shim spec -->
17
+ **Legacy naming (unchanged):** tickmarkr reads existing state from `.drovr/` directories and resumes runs with legacy `drovr/<runId>` branch names recorded in their journals; new engagements write to `tickmarkr/<runId>`. Environment variables `DROVR_*` remain unchanged (e.g., `DROVR_FAKE_SCRIPT`, `DROVR_E2E`). Spec markers use the historical `<!-- drovr:spec -->` comment. These names are pending formalization in a future shim spec; no action required — existing runs and configurations remain fully compatible.
18
+ <!-- tickmarkr:compat-legacy END -->
19
+
16
20
  > The package ships `tickmarkr` and `tkr` only; `tkr` is the short alias.
17
21
 
18
22
  ## Vocabulary
@@ -30,7 +34,7 @@ Audit voice in docs and reports maps to engine concepts exactly once:
30
34
  | assertion | Acceptance criterion (`acceptance[]` entry) |
31
35
  | tickmark | Passed gate |
32
36
  | audit evidence | Evidence gate (commits/diff exist) |
33
- | audit trail | Journal (`.drovr/runs/<runId>/journal.jsonl`) |
37
+ | audit trail | Journal (engagement event ledger) |
34
38
  | the consolidation | Integration branch (`tickmarkr/<runId>`) |
35
39
  | sign-off / issuance | Merge consolidation branch to main |
36
40
  | unqualified opinion | Green engagement (all tasks merged) |
@@ -46,11 +50,11 @@ These are law; the codebase fails closed around them:
46
50
 
47
51
  - `acceptance[]` required on every task; compile fails without it
48
52
  - new engagements consolidate to a `tickmarkr/<runId>` branch — never main (resume of older
49
- engagements keeps the branch names recorded in their journal, including legacy `drovr/<runId>`)
53
+ engagements keeps the branch names recorded in their journal)
50
54
  - gates never trust worker claims; tickmarkr verifies everything itself
51
- - state is files + git only (`.drovr/`); no DB, no services
55
+ - state is files + git only; no DB, no services
52
56
  - worker/judge/review/consult prompts end with machine-parseable trailers
53
- (`DROVR_RESULT` / JSON verdicts); parse defensively, fail closed
57
+ (structured JSON verdicts); parse defensively, fail closed
54
58
 
55
59
  Any agent (or human) operating this repo must respect the same five rules above — they are not
56
60
  merely internal implementation details, they are the contract the gates enforce.
@@ -86,9 +90,9 @@ at 30s.
86
90
  ## Quickstart (5 minutes)
87
91
 
88
92
  ```bash
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)
93
+ tickmarkr init # guided setup + doctor; scaffolds config and spec template
94
+ # edit tickmarkr.spec.md # the native spec template
95
+ tickmarkr compile tickmarkr.spec.md # spec → engagement graph (fails without acceptance criteria)
92
96
  tickmarkr plan # dry-run routing table + cost estimate + floor lints
93
97
  tickmarkr run # execute the graph (--concurrency N --driver herdr|subprocess --route-strict)
94
98
  tickmarkr report <runId> --md # engagement record in Markdown
@@ -170,7 +174,7 @@ cursor-agent and claude-code entirely.
170
174
 
171
175
  ## Model scoping and auth detection
172
176
 
173
- Each agent CLI exposes a list of available models. tickmarkr's routing works only with **classified** models — those you explicitly enter into `.drovr/config.yaml` under `tiers`. The `tickmarkr doctor` command probes these models to detect auth status and records the results in `.drovr/doctor.json`, which routing consumes to avoid 401/403 dispatch failures.
177
+ Each agent CLI exposes a list of available models. tickmarkr's routing works only with **classified** models — those you explicitly enter into the config under `tiers`. The `tickmarkr doctor` command probes these models to detect auth status and records the results, which routing consumes to avoid 401/403 dispatch failures.
174
178
 
175
179
  **Model terminology**:
176
180
 
@@ -180,7 +184,7 @@ Each agent CLI exposes a list of available models. tickmarkr's routing works onl
180
184
 
181
185
  When you run `tickmarkr doctor`, it:
182
186
  1. Probes each classified model exactly once (one minimal headless API call per model per adapter)
183
- 2. Records results in `.drovr/doctor.json`: `authed: true` or `authed: false` with the failure reason and probe timestamp
187
+ 2. Records results locally: `authed: true` or `authed: false` with the failure reason and probe timestamp
184
188
  3. Prints a model-status table for classified models only, showing tier, auth verdict, denial status, and prefer rank
185
189
 
186
190
  **Routing and auth**:
@@ -206,9 +210,9 @@ routing:
206
210
  ## Run output and narration
207
211
 
208
212
  When you execute `tickmarkr run`, the daemon streams live narration of each audit-trail event to stdout — task status changes,
209
- gate results (tickmarks), worker dispatch, and merge commits. This narration is derived from the append-only `.drovr/runs/<runId>/journal.jsonl`
210
- ledger, which remains the authoritative source of truth for resumability. If an engagement is interrupted, `tickmarkr resume <runId>`
211
- replays the journal and picks up where it left off. You can also tail the journal directly for raw event inspection or
213
+ gate results (tickmarks), worker dispatch, and merge commits. This narration is derived from the append-only event ledger,
214
+ which remains the authoritative source of truth for resumability. If an engagement is interrupted, `tickmarkr resume <runId>`
215
+ replays the ledger and picks up where it left off. You can also tail the ledger directly for raw event inspection or
212
216
  feed it to external tools; the narration is a convenience layer on top.
213
217
 
214
218
  ## Usage and cost
@@ -235,7 +239,7 @@ and first-attempt success rate. Cost reporting follows strict honesty rules and
235
239
  - **Ranges, never single numbers**: quota multipliers and monthly-window variation make subscription costs a range, not a point estimate
236
240
  - **"Not measurable" never becomes $0**: if a channel lacks pricing or metering data, the report explicitly states "not measurable"
237
241
  - **Basis always shown**: every cost figure prints the token count, rate used, and date so estimates can be audited
238
- - **No network calls**: pricing config is operator-maintained in `.drovr/config.yaml` (or `~/.config/tickmarkr/config.yaml`; legacy `~/.config/drovr` still read), seeded with dated
242
+ - **No network calls**: pricing config is operator-maintained locally, seeded with dated
239
243
  comments and LiteLLM's JSON file named as the copy-from source; tickmarkr never calls home to fetch rates
240
244
  - **Attribution from journal**: token counts come from tickmarkr's own telemetry spans in the audit trail, never from provider invoices or dashboards
241
245
 
@@ -323,11 +327,11 @@ produced the graph. Upstream, these front-ends exist:
323
327
 
324
328
  | Spec source | Support | How |
325
329
  |---|---|---|
326
- | **tickmarkr spec** (native format) | Native/default | `tickmarkr init` template with the `<!-- drovr:spec -->` marker |
330
+ | **tickmarkr spec** (native format) | Native/default | `tickmarkr init` template with native spec marker |
327
331
  | **Spec Kit** feature dir | Native | dir containing `tasks.md` |
328
332
  | **GSD** phase dir | Native | dir containing `*-PLAN.md` |
329
333
  | **Markdown PRD** | Native (universal adapter) | any `.md` with tasks + acceptance criteria |
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` |
334
+ | OpenSpec, BMAD, Superpowers, others | Not yet | render to a tickmarkr spec (with full Task surface), or contribute a front-end: one parser module behind `src/compile/index.ts`, same pattern as `gsd.ts` |
331
335
 
332
336
  Compile fails loudly on anything it can't recognize (`--type native|speckit|prd|gsd` to force).
333
337
 
@@ -393,10 +397,9 @@ npm run test:coverage # same suite, coverage floors enforced (see below)
393
397
  npm run e2e # opt-in real-CLI end-to-end — DOES spend tokens, needs ≥1 agent CLI installed
394
398
  ```
395
399
 
396
- `npm test` and `npm run test:coverage` must never call a real agent CLI or spend tokens — that's
397
- what `DROVR_FAKE_SCRIPT` and the `FakeAdapter` (`src/adapters/fake.ts`) exist for. If a change
398
- needs a real CLI to verify, that test belongs under `npm run e2e` (`tests/e2e/`), gated behind
399
- `DROVR_E2E=1`, never in the default suite.
400
+ `npm test` and `npm run test:coverage` must never call a real agent CLI or spend tokens —
401
+ test isolation uses deterministic fixtures. If a change needs a real CLI to verify, that test
402
+ belongs under `npm run e2e` (`tests/e2e/`), an opt-in test suite not run in CI.
400
403
 
401
404
  Coverage floor: `src/{graph,route,gates,run}/**` must stay at lines 80% / functions 80% /
402
405
  branches 70% — this is the CLAUDE.md invariant, enforced verbatim in `vitest.config.ts`.
@@ -432,7 +435,7 @@ behavior) are logged as a numbered entry (`OBS-NN`) in `.planning/OBSERVATIONS.m
432
435
  That file is the standing abnormality ledger for this repo — read it before assuming a rough edge
433
436
  is new.
434
437
 
435
- Design spec: `docs/superpowers/specs/2026-07-07-drover-design.md`.
438
+ See the design spec in docs/superpowers/specs/.
436
439
 
437
440
  ## License
438
441
 
@@ -49,15 +49,15 @@ export const claudeCode = {
49
49
  // Gotchas (both bit the 2026-07-10 live check): bare '{}' is REJECTED ("mcpServers: expected record"),
50
50
  // and --mcp-config is VARIADIC — a positional after it is eaten as a config-file path, so another
51
51
  // flag must always follow the value, never the prompt.
52
- headlessCommand: (promptFile, model) => `claude -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --permission-mode acceptEdits --strict-mcp-config --mcp-config '{"mcpServers":{}}' --output-format text`,
52
+ headlessCommand: (promptFile, model) => `claude -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --permission-mode bypassPermissions --strict-mcp-config --mcp-config '{"mcpServers":{}}' --output-format text`,
53
53
  // HYG-03: the residual first-entry dialog on an interactive TUI is the workspace TRUST dialog (not MCP
54
54
  // config loading) — CLI-imposed, no flag to pre-accept, only store is claude's global last-writer-wins
55
55
  // ~/.claude.json keyed on the exact path. Closed WON'T-FIX (decision B, 2026-07-10): drovr writes nothing
56
56
  // to that file (a seed races claude's own writes, nondeterministically). Amortizes to one operator dismissal
57
57
  // per stable worktree path; blocked-pane paging surfaces it. Do NOT change this command to "fix" the dialog —
58
58
  // see .planning/REQUIREMENTS.md HYG-03 and 21-02-LIVE-CHECK.md. Revisit if upstream ships a --trust flag.
59
- interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode acceptEdits "$(cat ${shq(promptFile)})"`,
60
- resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode acceptEdits "$(cat ${shq(promptFile)})"`,
59
+ interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
60
+ resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
61
61
  invoke(task, _cwd, a, ctx) {
62
62
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
63
63
  },
@@ -5,10 +5,11 @@ export declare const MODEL_STALE_DAYS = 30;
5
5
  export declare const ttyVisual: () => boolean;
6
6
  export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
7
7
  tty?: boolean;
8
+ stateDir?: string;
8
9
  }): string[];
9
10
  export declare function formatModelAuthLine(excluded: {
10
11
  key: string;
11
12
  reason: string;
12
13
  probedAt: string;
13
- }[], tty?: boolean): string;
14
- export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string;
14
+ }[], tty?: boolean, stateDir?: string): string;
15
+ export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
@@ -10,14 +10,15 @@ const DAY_MS = 86400000;
10
10
  const LINT_VARIANT_RE = /^auto$|-(fast|minimal|low|medium|high|xhigh)$/;
11
11
  const LINT_CAP = 5;
12
12
  const TTY_LINT_CAP = 3;
13
- const DOCTOR_JSON_REF = " — see .drovr/doctor.json";
13
+ const DEFAULT_STATE_DIR = ".drovr";
14
+ const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
14
15
  export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
15
16
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
16
17
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
17
18
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
18
19
  export function modelLints(cfg, health, adapters, opts) {
19
20
  const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
20
- const doctorRef = opts?.tty ? DOCTOR_JSON_REF : "";
21
+ const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
21
22
  const lints = [];
22
23
  for (const id of Object.keys(cfg.tiers)) {
23
24
  const adapter = adapters.find((a) => a.id === id);
@@ -57,14 +58,14 @@ export function modelLints(cfg, health, adapters, opts) {
57
58
  }
58
59
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
59
60
  // points at doctor.json for the full text; non-TTY is byte-identical to the pre-T6 registry helper.
60
- export function formatModelAuthLine(excluded, tty) {
61
+ export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR) {
61
62
  const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
62
63
  const parts = excluded.map(({ key, reason, probedAt }) => {
63
64
  const r = tty ? trunc(reason, 60) : reason;
64
65
  return `${key} (${r} — probed ${probedAt.split("T")[0]})`;
65
66
  });
66
67
  const base = `model auth: ${excluded.length} channel(s) unauthed — ${parts.join(", ")}`;
67
- return tty ? `${base}${DOCTOR_JSON_REF}` : base;
68
+ return tty ? `${base}${doctorJsonRef(stateDir)}` : base;
68
69
  }
69
70
  // MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
70
71
  // decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
@@ -72,7 +73,7 @@ export function formatModelAuthLine(excluded, tty) {
72
73
  // machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
73
74
  // `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
74
75
  // Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
75
- export function suggestOverlay(cfg, health, adapters) {
76
+ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR) {
76
77
  const blocks = [];
77
78
  for (const id of Object.keys(cfg.tiers)) {
78
79
  const adapter = adapters.find((a) => a.id === id);
@@ -116,13 +117,13 @@ export function suggestOverlay(cfg, health, adapters) {
116
117
  lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
117
118
  }
118
119
  if (omitted)
119
- lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see .drovr/doctor.json)`);
120
+ lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see ${stateDir}/doctor.json)`);
120
121
  if (lines.length)
121
122
  blocks.push(` ${id}:\n models:\n${lines.join("\n")}`);
122
123
  }
123
124
  if (blocks.length === 0)
124
125
  return "";
125
- return `# paste into .drovr/config.yaml — drovr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
126
+ return `# paste into ${stateDir}/config.yaml — tickmarkr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
126
127
  }
127
128
  // Purely relational id split for the addition gate (see suggestOverlay). Local, not exported: this is NOT a
128
129
  // global identity concept — src/gates/review.ts has its own local modelId(), deliberately not shared.
@@ -247,7 +247,7 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
247
247
  rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
248
248
  }
249
249
  }
250
- rows.push(...modelLints(cfg, health, adapters).map((l) => ` ! ${l}`));
250
+ rows.push(...modelLints(cfg, health, adapters, { stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
251
251
  const excluded = excludedChannels(cfg, adapters, health);
252
252
  if (excluded.length)
253
253
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -255,7 +255,7 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
255
255
  if (servable.length)
256
256
  rows.push(` ! ${servabilityLine(servable)}`);
257
257
  const visual = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
258
- const frag = suggestOverlay(cfg, health, adapters);
258
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
259
259
  let drift = "";
260
260
  if (frag) {
261
261
  if (visual) {
@@ -293,6 +293,6 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
293
293
  return statusRows;
294
294
  });
295
295
  const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
296
- const wrote = opts.wrote === false ? "" : `\nwrote .drovr/doctor.json`;
296
+ const wrote = opts.wrote === false ? "" : `\nwrote ${stateDirName(cwd)}/doctor.json`;
297
297
  return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}${wrote}`;
298
298
  }
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute, join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { compileSource } from "../../compile/index.js";
4
- import { saveGraph } from "../../graph/graph.js";
4
+ import { saveGraph, stateDirName } from "../../graph/graph.js";
5
5
  import { isRunLockLive } from "../../run/lock.js";
6
6
  export async function compile(argv, cwd = process.cwd()) {
7
7
  const { values, positionals } = parseArgs({
@@ -16,8 +16,9 @@ export async function compile(argv, cwd = process.cwd()) {
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
18
18
  // as a second daemon. Read-only check — compile never acquires/holds (it's instantaneous).
19
+ const stateDir = stateDirName(cwd);
19
20
  if (isRunLockLive(cwd))
20
- throw new Error(".drovr/graph.lock is held by another drovr run, or is a stale/garbage lock — refusing to overwrite graph.json. If no run is active, run `drovr unlock`.");
21
+ throw new Error(`${stateDir}/graph.lock is held by another drovr run, or is a stale/garbage lock — refusing to overwrite graph.json. If no run is active, run \`drovr unlock\`.`);
21
22
  saveGraph(cwd, g);
22
- return `compiled ${src} → .drovr/graph.json (${g.tasks.length} tasks, source ${g.spec.source}, hash ${g.spec.hash.slice(0, 12)})`;
23
+ return `compiled ${src} → ${stateDir}/graph.json (${g.tasks.length} tasks, source ${g.spec.source}, hash ${g.spec.hash.slice(0, 12)})`;
23
24
  }
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { allAdapters, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
- import { drovrDir } from "../../graph/graph.js";
4
+ import { drovrDir, stateDirName } from "../../graph/graph.js";
5
5
  import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
6
6
  import { loadConfig } from "../../config/config.js";
7
7
  import { HerdrDriver } from "../../drivers/herdr.js";
@@ -91,7 +91,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
91
91
  rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
92
92
  }
93
93
  }
94
- rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() }).map((l) => ` ! ${l}`));
94
+ rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
95
95
  const excluded = excludedChannels(cfg, adapters, health);
96
96
  if (excluded.length)
97
97
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -102,7 +102,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
102
102
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, drovr NEVER applies it.
103
103
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
104
104
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
105
- const frag = suggestOverlay(cfg, health, adapters);
105
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
106
106
  let drift = "";
107
107
  if (frag) {
108
108
  if (visual()) {
@@ -144,5 +144,5 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
144
144
  return rows;
145
145
  });
146
146
  const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
147
- return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote .drovr/doctor.json`);
147
+ return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`);
148
148
  }
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "../../config/config.js";
4
- import { drovrDir } from "../../graph/graph.js";
4
+ import { drovrDir, stateDirName } from "../../graph/graph.js";
5
5
  import { learnedScore, MIN_SAMPLES, cellsOf } from "../../route/profile.js";
6
6
  import { Journal, loadRoutingProfile, readProfileCursor, RUNS_WINDOW } from "../../run/journal.js";
7
7
  // drovr profile — inspect (show) and forget (reset) the derived learned-routing profile (VIS-03).
@@ -15,14 +15,15 @@ export async function profile(argv, cwd = process.cwd()) {
15
15
  // blanket-gitignores it, so the cursor is never git-addable. Opaque string: used only in a runId > compare.
16
16
  function reset(cwd) {
17
17
  const cursor = Journal.latestRunId(cwd) ?? ""; // empty repo ⇒ empty cursor ⇒ readProfileCursor === undefined
18
+ const stateDir = stateDirName(cwd);
18
19
  const path = join(drovrDir(cwd), "profile-since");
19
20
  writeFileSync(path, cursor + "\n");
20
21
  return [
21
22
  cursor
22
23
  ? `profile reset — learned routing now forgets runs at or before ${cursor}.`
23
24
  : `profile reset — no runs yet; wrote an empty cursor.`,
24
- ` wrote .drovr/profile-since (telemetry is UNTOUCHED — report/resume still see every run).`,
25
- ` to un-reset: delete .drovr/profile-since.`,
25
+ ` wrote ${stateDir}/profile-since (telemetry is UNTOUCHED — report/resume still see every run).`,
26
+ ` to un-reset: delete ${stateDir}/profile-since.`,
26
27
  ].join("\n");
27
28
  }
28
29
  // Inspection surface: preview:true bypasses the routing.learned:off short-circuit so `show` renders the
@@ -40,7 +41,7 @@ function show(cwd) {
40
41
  if (!p || p.cells.size === 0) {
41
42
  // preview bypasses the switch ⇒ undefined here means data, not policy: no telemetry, or all behind the cursor.
42
43
  header.push("", cursor
43
- ? ` empty profile — no telemetry after the reset cursor (delete .drovr/profile-since to see earlier runs).`
44
+ ? ` empty profile — no telemetry after the reset cursor (delete ${stateDirName(cwd)}/profile-since to see earlier runs).`
44
45
  : ` empty profile — no telemetry yet.`);
45
46
  return header.join("\n");
46
47
  }
@@ -30,7 +30,7 @@ export function pickReviewer(author, channels, exclude = []) {
30
30
  const DIFF_CAP = 60_000;
31
31
  export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers) {
32
32
  if (task.complexity < cfg.review.complexityThreshold) {
33
- return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}` };
33
+ return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}`, meta: { skipped: true } };
34
34
  }
35
35
  const reviewer = pickReviewer(author, channels, excludeReviewers ?? []);
36
36
  if (!reviewer) {
@@ -6,7 +6,13 @@ export function stateDirName(repoRoot) {
6
6
  const cached = stateDirs.get(repoRoot);
7
7
  if (cached)
8
8
  return cached;
9
- const name = existsSync(join(repoRoot, ".drovr")) ? ".drovr" : existsSync(join(repoRoot, ".drover")) ? ".drover" : ".drovr";
9
+ const name = existsSync(join(repoRoot, ".tickmarkr"))
10
+ ? ".tickmarkr"
11
+ : existsSync(join(repoRoot, ".drovr"))
12
+ ? ".drovr"
13
+ : existsSync(join(repoRoot, ".drover"))
14
+ ? ".drover"
15
+ : ".tickmarkr";
10
16
  stateDirs.set(repoRoot, name);
11
17
  return name;
12
18
  }
@@ -11,7 +11,7 @@ import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
11
11
  import { runGates } from "../gates/run-gates.js";
12
12
  import { addEvidence, attributeBlocked, blockedTasks, getTask, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
13
13
  import { consult } from "./consult.js";
14
- import { gitHead, sh } from "./git.js";
14
+ import { cleanupRunWorktrees, gitHead, sh } from "./git.js";
15
15
  import { Journal, loadRoutingProfile, newRunId } from "./journal.js";
16
16
  import { acquireRunLock, releaseRunLock } from "./lock.js";
17
17
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask } from "./merge.js";
@@ -706,6 +706,12 @@ export async function runDaemon(repoRoot, opts = {}) {
706
706
  };
707
707
  journal.append("run-end", undefined, { ...summary });
708
708
  await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
709
+ // OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
710
+ if (!keepForever) {
711
+ const green = summary.failed.length === 0 && summary.human.length === 0
712
+ && summary.blocked.length === 0 && summary.pending.length === 0;
713
+ await cleanupRunWorktrees(repoRoot, branch, { removeIntegration: green, removeTaskIds: summary.done });
714
+ }
709
715
  // VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
710
716
  const attribution = [...attributeBlocked(graph).entries()]
711
717
  .sort(([a], [b]) => a.localeCompare(b))
package/dist/run/git.d.ts CHANGED
@@ -7,6 +7,13 @@ export interface ShResult {
7
7
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
8
8
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
9
9
  export declare function gitHead(cwd: string): Promise<string>;
10
+ export declare const sanitizeBranch: (branch: string) => string;
11
+ export declare const worktreePath: (repo: string, branch: string) => string;
12
+ /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
13
+ export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
14
+ removeIntegration: boolean;
15
+ removeTaskIds: string[];
16
+ }): Promise<void>;
10
17
  export declare function resolveIntegrationBranch(repo: string, branch: string): Promise<string>;
11
18
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
12
19
  export declare function removeWorktree(repo: string, dir: string): Promise<void>;
package/dist/run/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, symlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
5
  import { drovrDir } from "../graph/graph.js";
@@ -47,7 +47,16 @@ export async function shOk(cmd, cwd) {
47
47
  export async function gitHead(cwd) {
48
48
  return (await shOk("git rev-parse HEAD", cwd)).trim();
49
49
  }
50
- const sanitize = (branch) => branch.replace(/[^\w.-]+/g, "-");
50
+ export const sanitizeBranch = (branch) => branch.replace(/[^\w.-]+/g, "-");
51
+ const sanitize = sanitizeBranch;
52
+ export const worktreePath = (repo, branch) => join(drovrDir(repo), "worktrees", sanitize(branch));
53
+ /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
54
+ export async function cleanupRunWorktrees(repo, branch, opts) {
55
+ if (opts.removeIntegration)
56
+ await removeWorktree(repo, worktreePath(repo, branch));
57
+ for (const id of opts.removeTaskIds)
58
+ await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
59
+ }
51
60
  const branchExists = async (repo, branch) => (await sh(`git rev-parse --verify ${shq(`refs/heads/${branch}`)}`, repo)).code === 0;
52
61
  export async function resolveIntegrationBranch(repo, branch) {
53
62
  if (!branch.startsWith("drovr/"))
@@ -68,8 +77,21 @@ export async function createWorktree(repo, branch, baseRef) {
68
77
  if (existsSync(dir))
69
78
  await removeWorktree(repo, dir);
70
79
  await shOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
80
+ linkNodeModules(repo, dir);
71
81
  return dir;
72
82
  }
83
+ // best-effort: devDep-based gates (tsx, vitest) shell out root-anchored and ENOENT in a bare
84
+ // fresh worktree (OBS-27); failure here must never fail worktree creation
85
+ function linkNodeModules(repo, dir) {
86
+ const src = join(repo, "node_modules");
87
+ const dest = join(dir, "node_modules");
88
+ if (!existsSync(src) || existsSync(dest))
89
+ return;
90
+ try {
91
+ symlinkSync(src, dest, "dir");
92
+ }
93
+ catch { /* best-effort */ }
94
+ }
73
95
  export async function removeWorktree(repo, dir) {
74
96
  await sh(`git worktree remove --force ${shq(dir)}`, repo); // best-effort; stale dirs are re-added with -B
75
97
  await sh(`rm -rf ${shq(dir)}`, repo);
package/dist/run/lock.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
- import { drovrDir } from "../graph/graph.js";
4
+ import { drovrDir, stateDirName } from "../graph/graph.js";
5
5
  // HARD-01/02: coarse per-run advisory lock over .drovr/graph.json. LOCK-02: the lock is created by
6
6
  // the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
7
7
  // which is atomic and throws EEXIST if the lock already exists (the mutual-exclusion primitive).
@@ -125,11 +125,12 @@ export function acquireRunLock(repoRoot, runId) {
125
125
  const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
126
126
  return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
127
127
  }
128
+ const stateDir = stateDirName(repoRoot);
128
129
  if (garbage)
129
- throw new Error(".drovr/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run `drovr unlock` to remove it");
130
+ throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`drovr unlock\` to remove it`);
130
131
  // LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
131
132
  // (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
132
- throw new Error(`.drovr/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
133
+ throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
133
134
  }
134
135
  }
135
136
  export function releaseRunLock(repoRoot) {
@@ -169,7 +170,7 @@ export function unlockRun(repoRoot) {
169
170
  return { held: false };
170
171
  } // statSync ENOENT ⇒ no lock
171
172
  if (!insp.dead && !insp.garbage) {
172
- throw new Error(`.drovr/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
173
+ throw new Error(`${stateDirName(repoRoot)}/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
173
174
  }
174
175
  try {
175
176
  unlinkSync(p);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,9 +12,9 @@ Use this to execute a requested sequence of repository specs. It is SDD-agnostic
12
12
 
13
13
  When working in a multi-agent terminal environment, decide your role before starting:
14
14
 
15
- - **Orchestrator:** your session was started to execute the mission. Run the loop below.
16
- - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it.
17
- - **Primary session without an orchestrator:** create one child orchestration session, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself.
15
+ - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane ORCHESTRATOR and run the loop below.
16
+ - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it as OVERSEER.
17
+ - **Primary session without an orchestrator:** rename your own tab OVERSEER, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab ORCHESTRATOR, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself.
18
18
 
19
19
  Outside a multi-agent terminal environment, run the loop directly.
20
20
 
@@ -11,9 +11,9 @@ Use this for any spec that `tickmarkr compile` accepts. It is SDD-agnostic: use
11
11
 
12
12
  When working in a multi-agent terminal environment, decide your role before starting:
13
13
 
14
- - **Orchestrator:** your session was started to execute the mission. Run the loop below.
15
- - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it.
16
- - **Primary session without an orchestrator:** create one child orchestration session, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself.
14
+ - **Orchestrator:** your session was started to execute the mission. Rename your own tab/pane ORCHESTRATOR and run the loop below.
15
+ - **Supervisor with a live orchestrator:** do not start a second run. Relay the mission to the existing orchestrator with a verified send, then supervise it as OVERSEER.
16
+ - **Primary session without an orchestrator:** rename your own tab OVERSEER, spawn one child orchestration session with `--permission-mode bypassPermissions`, label its tab ORCHESTRATOR, give it the mission and these rules verbatim, then supervise it. Do not drive a duplicate single-tier run yourself.
17
17
 
18
18
  Outside a multi-agent terminal environment, run the loop directly.
19
19