tickmarkr 1.33.5 → 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
  },
@@ -129,11 +129,14 @@ export const codex = {
129
129
  probe: async () => probeVersion("codex"),
130
130
  channels: (cfg) => channelsFromConfig("codex", cfg),
131
131
  // --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
132
- headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
132
+ // OBS-24: -c 'mcp_servers={}' is codex's analog of claude's --strict-mcp-config operator-global
133
+ // MCP servers in ~/.codex/config.toml block startup indefinitely when one is down (live-measured
134
+ // 2026-07-15: probe wedged >120s with MCP, 30s clean), wedging probes and worktree workers alike.
135
+ headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
133
136
  // TUI uses expanded -a never -s workspace-write (exec-only flags do not apply)
134
137
  // (--help 2026-07-09: valid approval policies are untrusted|on-request|never; the previously
135
138
  // used `on-failure` is invalid and made codex exit 2 pre-inference)
136
- interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
139
+ interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
137
140
  invoke(task, _cwd, a, ctx) {
138
141
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
139
142
  },
@@ -2,5 +2,14 @@ import type { DrovrConfig } from "../config/config.js";
2
2
  import { type AuthHealth, type WorkerAdapter } from "./types.js";
3
3
  export declare const SEED_STAMPED = "2026-07-09";
4
4
  export declare const MODEL_STALE_DAYS = 30;
5
- export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string[];
6
- export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string;
5
+ export declare const ttyVisual: () => boolean;
6
+ export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
7
+ tty?: boolean;
8
+ stateDir?: string;
9
+ }): string[];
10
+ export declare function formatModelAuthLine(excluded: {
11
+ key: string;
12
+ reason: string;
13
+ probedAt: string;
14
+ }[], tty?: boolean, stateDir?: string): string;
15
+ export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
@@ -9,10 +9,16 @@ const DAY_MS = 86400000;
9
9
  // raw list (verified 2026-07-10). Data stays raw; lints stay signal.
10
10
  const LINT_VARIANT_RE = /^auto$|-(fast|minimal|low|medium|high|xhigh)$/;
11
11
  const LINT_CAP = 5;
12
+ const TTY_LINT_CAP = 3;
13
+ const DEFAULT_STATE_DIR = ".drovr";
14
+ const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
15
+ export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
12
16
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
13
17
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
14
18
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
15
- export function modelLints(cfg, health, adapters) {
19
+ export function modelLints(cfg, health, adapters, opts) {
20
+ const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
21
+ const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
16
22
  const lints = [];
17
23
  for (const id of Object.keys(cfg.tiers)) {
18
24
  const adapter = adapters.find((a) => a.id === id);
@@ -37,8 +43,8 @@ export function modelLints(cfg, health, adapters) {
37
43
  }
38
44
  const extra = detected.filter((m) => !configured.includes(m) && !LINT_VARIANT_RE.test(m));
39
45
  if (extra.length) {
40
- const shown = extra.slice(0, LINT_CAP).join(", ");
41
- const tail = extra.length > LINT_CAP ? `, +${extra.length - LINT_CAP} more` : "";
46
+ const shown = extra.slice(0, cap).join(", ");
47
+ const tail = extra.length > cap ? `, +${extra.length - cap} more${doctorRef}` : "";
42
48
  lints.push(`${id}: reports ${extra.length} model(s) not in tiers (${shown}${tail}) — classify before routing (benchmark policy)`);
43
49
  }
44
50
  const at = h?.modelsDetectedAt;
@@ -50,13 +56,24 @@ export function modelLints(cfg, health, adapters) {
50
56
  }
51
57
  return lints;
52
58
  }
59
+ // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
60
+ // points at doctor.json for the full text; non-TTY is byte-identical to the pre-T6 registry helper.
61
+ export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR) {
62
+ const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
63
+ const parts = excluded.map(({ key, reason, probedAt }) => {
64
+ const r = tty ? trunc(reason, 60) : reason;
65
+ return `${key} (${r} — probed ${probedAt.split("T")[0]})`;
66
+ });
67
+ const base = `model auth: ${excluded.length} channel(s) unauthed — ${parts.join(", ")}`;
68
+ return tty ? `${base}${doctorJsonRef(stateDir)}` : base;
69
+ }
53
70
  // MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
54
71
  // decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
55
72
  // Additions render WHOLE-LINE-COMMENTED with a `???` tier placeholder (a tier is a benchmark claim; the
56
73
  // machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
57
74
  // `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
58
75
  // Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
59
- export function suggestOverlay(cfg, health, adapters) {
76
+ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR) {
60
77
  const blocks = [];
61
78
  for (const id of Object.keys(cfg.tiers)) {
62
79
  const adapter = adapters.find((a) => a.id === id);
@@ -100,13 +117,13 @@ export function suggestOverlay(cfg, health, adapters) {
100
117
  lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
101
118
  }
102
119
  if (omitted)
103
- 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)`);
104
121
  if (lines.length)
105
122
  blocks.push(` ${id}:\n models:\n${lines.join("\n")}`);
106
123
  }
107
124
  if (blocks.length === 0)
108
125
  return "";
109
- 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`;
110
127
  }
111
128
  // Purely relational id split for the addition gate (see suggestOverlay). Local, not exported: this is NOT a
112
129
  // global identity concept — src/gates/review.ts has its own local modelId(), deliberately not shared.
@@ -5,7 +5,9 @@ export declare function allAdapters(opts?: {
5
5
  }): WorkerAdapter[];
6
6
  export declare function getAdapter(id: string, adapters: WorkerAdapter[]): WorkerAdapter;
7
7
  export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
8
- export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): Promise<void>;
8
+ export type ProbeModelStatus = "ok" | "timeout" | "failed";
9
+ export type ProbeModelProgress = (adapter: string, model: string, status: ProbeModelStatus, durationMs: number) => void;
10
+ export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, onProgress?: ProbeModelProgress): Promise<void>;
9
11
  export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
10
12
  export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
11
13
  export declare function discoverChannels(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): BillingChannel[];
@@ -29,3 +31,13 @@ export declare function modelAuthLine(excluded: {
29
31
  probedAt: string;
30
32
  }[]): string;
31
33
  export declare function doctorAgeMs(repoRoot: string): number | null;
34
+ export declare const INIT_DOCTOR_REUSE_MS: number;
35
+ export declare function formatDoctorAgeForInit(ageMs: number): string;
36
+ export declare function initDoctorReuse(repoRoot: string, fresh: boolean): {
37
+ reuse: boolean;
38
+ ageMs: number | null;
39
+ health: Record<string, AuthHealth> | null;
40
+ };
41
+ export declare function formatDoctorReport(cwd: string, cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
42
+ wrote?: boolean;
43
+ }): string;
@@ -1,8 +1,10 @@
1
1
  import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { modelLints, suggestOverlay } from "./model-lints.js";
5
+ import { HerdrDriver } from "../drivers/herdr.js";
4
6
  import { drovrDir, stateDirName } from "../graph/graph.js";
5
- import { disallowedBy } from "../route/preference.js";
7
+ import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../route/preference.js";
6
8
  import { sh } from "../run/git.js";
7
9
  import { claudeCode } from "./claude-code.js";
8
10
  import { codex } from "./codex.js";
@@ -32,7 +34,8 @@ export async function probeAll(adapters) {
32
34
  return out;
33
35
  }
34
36
  const MODEL_PROBE_PROMPT = "Reply with exactly OK and nothing else.";
35
- const MODEL_PROBE_TIMEOUT_MS = 30000;
37
+ // 60s: a healthy MCP-free codex probe measured 29.9s round-trip (2026-07-15) — 30s had zero headroom.
38
+ const MODEL_PROBE_TIMEOUT_MS = 60000;
36
39
  // Auth-words only match when tied to a failure word; bare "auth"/"OAuth"/"authored" never fail (v1.27 T2).
37
40
  const AUTH_FAILURE_RE = /\b4\d\d\b|\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
38
41
  function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TIMEOUT_MS) {
@@ -44,8 +47,12 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
44
47
  ? output.slice(0, 240) || `probe exited ${code}`
45
48
  : undefined;
46
49
  }
50
+ const probeModelStatus = (v) => v.authed ? "ok" : v.reason?.includes("timed out") ? "timeout" : "failed";
47
51
  // v1.21: one bounded, headless call per configured model; detected-but-unclassified models never enter this loop.
48
- export async function probeModels(cfg, repoRoot, adapters, health) {
52
+ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
53
+ // T2: a prior doctor.json timeout verdict for this model skips the retry — a persistently dead
54
+ // model (e.g. opencode glm-5.2) costs one 30s attempt instead of two every run.
55
+ const priorHealth = readDoctor(repoRoot);
49
56
  await Promise.all(adapters.map(async (a) => {
50
57
  const h = health[a.id];
51
58
  if (!h?.installed)
@@ -54,35 +61,65 @@ export async function probeModels(cfg, repoRoot, adapters, health) {
54
61
  if (process.env.VITEST && [claudeCode, codex, cursorAgent, opencode, pi, grok].includes(a))
55
62
  return;
56
63
  const verdicts = {};
64
+ const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
57
65
  // models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
58
- // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap
59
- await Promise.all(Object.keys(cfg.tiers[a.id]?.models ?? {}).map(async (model) => {
66
+ // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
67
+ // Capped at MODEL_PROBE_CONCURRENCY per adapter (v1.33.5 regression: 4 concurrent codex exec
68
+ // in one repo made ALL 4 codex probes time out where sequential passed 2/4).
69
+ await mapLimit(Object.keys(cfg.tiers[a.id]?.models ?? {}), MODEL_PROBE_CONCURRENCY, async (model) => {
70
+ const t0 = Date.now();
60
71
  const probedAt = new Date().toISOString();
72
+ const priorTimedOut = priorModelAuth?.[model]?.reason?.includes("timed out") === true;
73
+ let verdict;
61
74
  try {
62
75
  if (typeof a.headlessCommand !== "function") {
63
- verdicts[model] = { authed: false, reason: "headless probe unavailable", probedAt };
64
- return;
76
+ verdict = { authed: false, reason: "headless probe unavailable", probedAt, durationMs: Date.now() - t0 };
65
77
  }
66
- const promptFile = join(mkdtempSync(join(tmpdir(), "drovr-auth-")), "probe.md");
67
- writeFileSync(promptFile, MODEL_PROBE_PROMPT);
68
- let r = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
69
- if (r.timedOut) {
70
- r = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
71
- if (r.timedOut) {
72
- verdicts[model] = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt };
73
- return;
78
+ else {
79
+ const promptFile = join(mkdtempSync(join(tmpdir(), "drovr-auth-")), "probe.md");
80
+ writeFileSync(promptFile, MODEL_PROBE_PROMPT);
81
+ const r = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
82
+ if (r.timedOut && priorTimedOut) {
83
+ verdict = { authed: false, reason: `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
84
+ }
85
+ else if (r.timedOut) {
86
+ const r2 = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
87
+ if (r2.timedOut) {
88
+ verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
89
+ }
90
+ else {
91
+ const reason = probeFailure(r2.code, r2.stdout, r2.stderr, r2.timedOut, MODEL_PROBE_TIMEOUT_MS);
92
+ verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
93
+ }
94
+ }
95
+ else {
96
+ const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
97
+ verdict = reason ? { authed: false, reason, probedAt, durationMs: Date.now() - t0 } : { authed: true, probedAt, durationMs: Date.now() - t0 };
74
98
  }
75
99
  }
76
- const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
77
- verdicts[model] = reason ? { authed: false, reason, probedAt } : { authed: true, probedAt };
78
100
  }
79
101
  catch (e) {
80
- verdicts[model] = { authed: false, reason: String(e), probedAt };
102
+ verdict = { authed: false, reason: String(e), probedAt, durationMs: Date.now() - t0 };
81
103
  }
82
- }));
104
+ verdicts[model] = verdict;
105
+ onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
106
+ });
83
107
  h.modelAuth = verdicts;
84
108
  }));
85
109
  }
110
+ // T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
111
+ // repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
112
+ const MODEL_PROBE_CONCURRENCY = 2;
113
+ async function mapLimit(items, limit, fn) {
114
+ let i = 0;
115
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
116
+ while (i < items.length) {
117
+ const item = items[i++];
118
+ await fn(item);
119
+ }
120
+ });
121
+ await Promise.all(workers);
122
+ }
86
123
  const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
87
124
  export function writeDoctor(repoRoot, health) {
88
125
  drovrDir(repoRoot);
@@ -165,3 +202,97 @@ export function doctorAgeMs(repoRoot) {
165
202
  // renders a nonsensical negative age ("doctor.json is -0h old").
166
203
  return Math.max(0, Date.now() - statSync(p).mtimeMs);
167
204
  }
205
+ // ponytail: hardcoded 60m TTL for init reuse only — promote to config when an operator asks.
206
+ export const INIT_DOCTOR_REUSE_MS = 60 * 60 * 1000;
207
+ export function formatDoctorAgeForInit(ageMs) {
208
+ return `${Math.floor(ageMs / 60_000)}m`;
209
+ }
210
+ export function initDoctorReuse(repoRoot, fresh) {
211
+ const health = readDoctor(repoRoot);
212
+ const ageMs = doctorAgeMs(repoRoot);
213
+ const reuse = !fresh && health !== null && ageMs !== null && ageMs < INIT_DOCTOR_REUSE_MS;
214
+ return { reuse, ageMs, health: reuse ? health : null };
215
+ }
216
+ // Report body shared by init's cached-doctor path — mirrors doctor.ts formatting without probing.
217
+ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
218
+ const rows = adapters.map((a) => {
219
+ const h = health[a.id];
220
+ const state = !h?.installed ? "not installed" : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
221
+ return ` ${h?.installed ? "✓" : "✗"} ${a.id.padEnd(14)} ${state}`;
222
+ });
223
+ rows.push(` ${HerdrDriver.available() ? "✓" : "✗"} herdr ${HerdrDriver.available() ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"}`);
224
+ rows.push("workspace trust:");
225
+ for (const a of adapters) {
226
+ if (!health[a.id]?.installed)
227
+ continue;
228
+ if (!a.trust) {
229
+ rows.push(` = ${a.id.padEnd(14)} trust: n/a`);
230
+ continue;
231
+ }
232
+ try {
233
+ const v = a.trust(cwd);
234
+ if (v.status === "trusted")
235
+ rows.push(` ✓ ${a.id.padEnd(14)} trust: trusted`);
236
+ else if (v.status === "seeded")
237
+ rows.push(` ✓ ${a.id.padEnd(14)} trust: seeded`);
238
+ else
239
+ rows.push(` ! ${a.id.padEnd(14)} trust: action-required — run ONCE: ${v.command}`);
240
+ }
241
+ catch (e) {
242
+ rows.push(` ! ${a.id.padEnd(14)} trust: action-required — run ONCE: (trust check failed: ${e instanceof Error ? e.message : String(e)})`);
243
+ }
244
+ }
245
+ for (const [role, sel] of [["judge", cfg.judge], ["consult", cfg.consult]]) {
246
+ if (!health[sel.adapter]?.installed) {
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
+ }
249
+ }
250
+ rows.push(...modelLints(cfg, health, adapters, { stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
251
+ const excluded = excludedChannels(cfg, adapters, health);
252
+ if (excluded.length)
253
+ rows.push(` ! ${exclusionLine(excluded)}`);
254
+ const servable = servableExclusions(cfg, adapters, health);
255
+ if (servable.length)
256
+ rows.push(` ! ${servabilityLine(servable)}`);
257
+ const visual = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
258
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
259
+ let drift = "";
260
+ if (frag) {
261
+ if (visual) {
262
+ const overlayPath = join(drovrDir(cwd), "doctor-overlay.yaml");
263
+ writeFileSync(overlayPath, frag);
264
+ drift = `\nmodel drift: unclassified models detected — paste-ready overlay written to ${overlayPath} (advisory; tickmarkr never applies it)`;
265
+ }
266
+ else {
267
+ drift = `\nmodel drift — paste-ready overlay (advisory; tickmarkr never applies):\n${frag}`;
268
+ }
269
+ }
270
+ const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
271
+ const dateOf = (iso) => iso.slice(0, 10);
272
+ const modelStatus = adapters.flatMap((a) => {
273
+ const h = health[a.id];
274
+ if (!h?.installed)
275
+ return [];
276
+ const classified = cfg.tiers[a.id]?.models ?? {};
277
+ const models = Object.keys(classified);
278
+ const unclassified = (h.models ?? []).filter((m) => !(m in classified));
279
+ if (!models.length && !unclassified.length)
280
+ return [];
281
+ const w = Math.max(8, ...models.map((m) => m.length));
282
+ const statusRows = [` ${a.id}`];
283
+ for (const m of models) {
284
+ const v = h.modelAuth?.[m];
285
+ const auth = !v ? "unknown" : v.authed ? "authed" : `unauthed: ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
286
+ const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
287
+ const denied = d?.by === "deny" ? d.entry : "—";
288
+ const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
289
+ statusRows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)} ${auth} denied=${denied} prefer=${pref}`);
290
+ }
291
+ if (unclassified.length)
292
+ statusRows.push(` (${unclassified.length} more listed, unclassified)`);
293
+ return statusRows;
294
+ });
295
+ const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
296
+ const wrote = opts.wrote === false ? "" : `\nwrote ${stateDirName(cwd)}/doctor.json`;
297
+ return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}${wrote}`;
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,8 +1,8 @@
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";
5
- import { modelLints, suggestOverlay } from "../../adapters/model-lints.js";
4
+ import { drovrDir, stateDirName } from "../../graph/graph.js";
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";
8
8
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
@@ -35,6 +35,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
35
35
  // stderr, live: auth probes are real LLM calls (up to 30s per configured model) and the CLI
36
36
  // otherwise prints nothing until the end — silence here reads as a hang (v1.33.1)
37
37
  console.error("probing installed agent CLIs — one short LLM call per configured model, may take a minute...");
38
+ const probeProgressTTY = process.stderr.isTTY === true;
38
39
  const health = await probeAll(adapters);
39
40
  // MODEL-02: detect models where the adapter exposes a list surface, BEFORE writing doctor.json (write once, below).
40
41
  // Fail OPEN — the inverse of gates' fail-closed: detection is advisory, so a broken list surface NEVER fails doctor.
@@ -51,7 +52,9 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
51
52
  }
52
53
  catch { /* fail open: leave models as-is, doctor stays healthy */ }
53
54
  }
54
- await probeModels(cfg, cwd, adapters, health);
55
+ await probeModels(cfg, cwd, adapters, health, probeProgressTTY
56
+ ? (adapter, model, status, durationMs) => console.error(` ${adapter}:${model} ${status} (${(durationMs / 1000).toFixed(1)}s)`)
57
+ : undefined);
55
58
  writeDoctor(cwd, health);
56
59
  const rows = adapters.map((a) => {
57
60
  const h = health[a.id];
@@ -88,7 +91,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
88
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}`);
89
92
  }
90
93
  }
91
- rows.push(...modelLints(cfg, health, adapters).map((l) => ` ! ${l}`));
94
+ rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
92
95
  const excluded = excludedChannels(cfg, adapters, health);
93
96
  if (excluded.length)
94
97
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -99,7 +102,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
99
102
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, drovr NEVER applies it.
100
103
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
101
104
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
102
- const frag = suggestOverlay(cfg, health, adapters);
105
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
103
106
  let drift = "";
104
107
  if (frag) {
105
108
  if (visual()) {
@@ -141,5 +144,5 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
141
144
  return rows;
142
145
  });
143
146
  const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
144
- 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`);
145
148
  }
@@ -2,7 +2,8 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
2
2
  import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { parseArgs } from "node:util";
5
- import { configTemplate, globalConfigDir } from "../../config/config.js";
5
+ import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReuse } from "../../adapters/registry.js";
6
+ import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
6
7
  import { specTemplate } from "../../compile/native.js";
7
8
  import { drovrDir } from "../../graph/graph.js";
8
9
  import { doctor } from "./doctor.js";
@@ -23,6 +24,9 @@ Loop: \`tickmarkr compile <spec>\` → \`tickmarkr plan\` → \`tickmarkr run\`
23
24
  - Treat missing or unparseable results as failures.
24
25
  ${DOCS_END}
25
26
  `;
27
+ const skillPath = (cwd, skill) => join(cwd, ".claude", "skills", skill, "SKILL.md");
28
+ const skillsInstalled = (cwd) => AGENT_SKILLS.every((s) => existsSync(skillPath(cwd, s)));
29
+ const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
26
30
  async function installAgentFiles(cwd, force, docs, notes) {
27
31
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
28
32
  let prompt;
@@ -34,7 +38,7 @@ async function installAgentFiles(cwd, force, docs, notes) {
34
38
  };
35
39
  try {
36
40
  for (const skill of AGENT_SKILLS) {
37
- const dest = join(cwd, ".claude", "skills", skill, "SKILL.md");
41
+ const dest = skillPath(cwd, skill);
38
42
  const exists = existsSync(dest);
39
43
  if (exists && !force && !(await confirm(`Overwrite ${dest}?`))) {
40
44
  notes.push(`skipped existing ${dest}; pass --force to overwrite it`);
@@ -63,6 +67,42 @@ async function installAgentFiles(cwd, force, docs, notes) {
63
67
  prompt?.close();
64
68
  }
65
69
  }
70
+ const askDefault = async (rl, label, def) => {
71
+ const answer = (await rl.question(`${label} [${def}] `)).trim();
72
+ return answer || def;
73
+ };
74
+ const askYesNo = async (rl, label, def) => {
75
+ const hint = def ? "Y/n" : "y/N";
76
+ const answer = (await rl.question(`${label} [${hint}] `)).trim();
77
+ if (!answer)
78
+ return def;
79
+ return /^(?:y|yes)$/i.test(answer);
80
+ };
81
+ async function runInitWizard(cwd) {
82
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
83
+ try {
84
+ const driverDef = wizardDriverDefault();
85
+ const driverRaw = await askDefault(rl, "Driver (auto|herdr|subprocess)", driverDef);
86
+ const driver = ["auto", "herdr", "subprocess"].includes(driverRaw)
87
+ ? driverRaw
88
+ : driverDef;
89
+ const concRaw = await askDefault(rl, "Concurrency", String(DEFAULT_CONFIG.concurrency));
90
+ const parsed = Number.parseInt(concRaw, 10);
91
+ const concurrency = Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CONFIG.concurrency;
92
+ const llmDef = DEFAULT_CONFIG.visibility.llm;
93
+ const llmRaw = await askDefault(rl, "visibility.llm (pane|headless)", llmDef);
94
+ const llm = llmRaw === "pane" || llmRaw === "headless" ? llmRaw : llmDef;
95
+ let installSkills = false;
96
+ if (!skillsInstalled(cwd)) {
97
+ const skillsDef = existsSync(join(cwd, ".claude")) && !skillsInstalled(cwd);
98
+ installSkills = await askYesNo(rl, "Install agent skills (tickmarkr-loop/tickmarkr-auto) into .claude/skills?", skillsDef);
99
+ }
100
+ return { overlay: { driver, concurrency, visibility: { llm } }, installSkills };
101
+ }
102
+ finally {
103
+ rl.close();
104
+ }
105
+ }
66
106
  export async function init(argv, cwd = process.cwd()) {
67
107
  const { values } = parseArgs({
68
108
  args: argv,
@@ -71,20 +111,25 @@ export async function init(argv, cwd = process.cwd()) {
71
111
  agent: { type: "boolean" },
72
112
  force: { type: "boolean" },
73
113
  docs: { type: "boolean" },
114
+ fresh: { type: "boolean" },
115
+ yes: { type: "boolean" },
74
116
  },
75
117
  });
76
118
  const gdir = values["global-dir"] ?? globalConfigDir();
77
119
  mkdirSync(gdir, { recursive: true });
78
120
  const notes = [];
79
- for (const p of [join(gdir, "config.yaml"), join(drovrDir(cwd), "config.yaml")]) {
80
- if (!existsSync(p)) {
81
- writeFileSync(p, configTemplate());
82
- notes.push(`wrote ${p}`);
83
- }
84
- else {
85
- notes.push(`kept existing ${p}`);
86
- }
121
+ const globalPath = join(gdir, "config.yaml");
122
+ if (!existsSync(globalPath)) {
123
+ writeFileSync(globalPath, configTemplate());
124
+ notes.push(`wrote ${globalPath}`);
125
+ }
126
+ else {
127
+ notes.push(`kept existing ${globalPath}`);
87
128
  }
129
+ const repoConfigPath = join(drovrDir(cwd), "config.yaml");
130
+ const repoConfigExists = existsSync(repoConfigPath);
131
+ if (repoConfigExists)
132
+ notes.push(`kept existing ${repoConfigPath}`);
88
133
  const specPath = join(cwd, "tickmarkr.spec.md");
89
134
  if (existsSync(specPath)) {
90
135
  notes.push(`kept existing ${specPath}`);
@@ -96,8 +141,26 @@ export async function init(argv, cwd = process.cwd()) {
96
141
  writeFileSync(specPath, specTemplate());
97
142
  notes.push(`wrote ${specPath}`);
98
143
  }
144
+ const fresh = values.fresh ?? false;
145
+ const { reuse, ageMs, health } = initDoctorReuse(cwd, fresh);
146
+ const doc = reuse && health && ageMs !== null
147
+ ? `using probe results from ${formatDoctorAgeForInit(ageMs)} ago — run tickmarkr doctor to refresh (or init --fresh)\n${formatDoctorReport(cwd, loadConfig(cwd), health, allAdapters(), { wrote: false })}`
148
+ : await doctor([], cwd);
149
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true && !(values.yes ?? false);
150
+ if (!repoConfigExists) {
151
+ if (interactive) {
152
+ const wizard = await runInitWizard(cwd);
153
+ writeFileSync(repoConfigPath, configTemplate(wizard.overlay));
154
+ notes.push(`wrote ${repoConfigPath}`);
155
+ if (wizard.installSkills)
156
+ await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
157
+ }
158
+ else {
159
+ writeFileSync(repoConfigPath, configTemplate());
160
+ notes.push(`wrote ${repoConfigPath}`);
161
+ }
162
+ }
99
163
  if (values.agent)
100
164
  await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
101
- const doc = await doctor([], cwd);
102
165
  return `${notes.join("\n")}\n${doc}\nnext: edit tickmarkr.spec.md, then tickmarkr compile tickmarkr.spec.md && tickmarkr plan && tickmarkr run`;
103
166
  }
@@ -1,5 +1,5 @@
1
- import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, modelAuthLine, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
- import { modelLints } from "../../adapters/model-lints.js";
1
+ import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
+ import { formatModelAuthLine, modelLints, ttyVisual } from "../../adapters/model-lints.js";
3
3
  import { collateralLints } from "../../compile/collateral.js";
4
4
  import { loadConfig } from "../../config/config.js";
5
5
  import { loadGraph } from "../../graph/graph.js";
@@ -31,7 +31,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
31
31
  // T2 (2026-07-13): per-model unauthed verdicts from doctor — one lint per exclusion (reason + date).
32
32
  const modelUnauthed = modelAuthExclusions(cfg, adapters, health);
33
33
  if (modelUnauthed.length)
34
- lines.push(modelAuthLine(modelUnauthed), "");
34
+ lines.push(formatModelAuthLine(modelUnauthed, ttyVisual()), "");
35
35
  const unauthed = adapters.filter((a) => health[a.id]?.installed && !health[a.id]?.authed).map((a) => a.id);
36
36
  if (unauthed.length)
37
37
  lines.push(`installed but unauthed: ${unauthed.join(", ")} — channels excluded from routing`, "");
@@ -58,7 +58,7 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
58
58
  if (!health[sel.adapter]?.installed)
59
59
  lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
60
60
  }
61
- lints.push(...modelLints(cfg, health, adapters)); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
61
+ lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
62
62
  let cost = 0;
63
63
  for (const t of g.tasks) {
64
64
  try {
@@ -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
  }
package/dist/cli/index.js CHANGED
@@ -16,6 +16,7 @@ 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
+ const HELP_CMDS = new Set(["help", "-h", "--help"]);
19
20
  export const USAGE = `tickmarkr — spec-driven orchestration harness for AI coding agents
20
21
  usage: tickmarkr <command>
21
22
  init guided setup + doctor; init --agent [--force] [--docs] adds agent skills/docs
@@ -34,9 +35,11 @@ usage: tickmarkr <command>
34
35
  // unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `drovr`); a handler throw becomes
35
36
  // a one-line `drovr <cmd>: <message>` (never a raw stack) at exit 1.
36
37
  export async function dispatch(cmd, argv, commands = COMMANDS) {
37
- const fn = cmd ? commands[cmd] : undefined;
38
+ if (!cmd || HELP_CMDS.has(cmd))
39
+ return { out: USAGE, code: 0 };
40
+ const fn = commands[cmd];
38
41
  if (!fn)
39
- return { out: USAGE, code: cmd ? 1 : 0 };
42
+ return { out: USAGE, code: 1 };
40
43
  try {
41
44
  return { out: await fn(argv), code: 0 };
42
45
  }
@@ -180,5 +180,12 @@ export declare function globalConfigDir(): string;
180
180
  export declare function loadConfig(repoRoot: string, opts?: {
181
181
  globalDir?: string;
182
182
  }): DrovrConfig;
183
- export declare function configTemplate(): string;
183
+ export type InitConfigOverlay = {
184
+ concurrency?: number;
185
+ driver?: DrovrConfig["driver"];
186
+ visibility?: {
187
+ llm?: DrovrConfig["visibility"]["llm"];
188
+ };
189
+ };
190
+ export declare function configTemplate(overlay?: InitConfigOverlay): string;
184
191
  export {};
@@ -271,8 +271,8 @@ export function loadConfig(repoRoot, opts = {}) {
271
271
  throw new ConfigError(z.prettifyError(r.error));
272
272
  return r.data;
273
273
  }
274
- export function configTemplate() {
275
- return `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
274
+ export function configTemplate(overlay) {
275
+ const base = `# tickmarkr config overlay — merges over built-in defaults (repo beats global beats defaults)
276
276
  # concurrency: 3
277
277
  # driver: auto # auto | herdr | subprocess
278
278
  # taskTimeoutMinutes: 30
@@ -329,4 +329,19 @@ export function configTemplate() {
329
329
  # ever — tickmarkr reuses stable worktree paths so the accept persists across runs; blocked-pane paging
330
330
  # surfaces each first-time dialog.
331
331
  `;
332
+ if (!overlay)
333
+ return base;
334
+ const lines = [];
335
+ if (overlay.concurrency !== undefined)
336
+ lines.push(`concurrency: ${overlay.concurrency}`);
337
+ if (overlay.driver !== undefined)
338
+ lines.push(`driver: ${overlay.driver}`);
339
+ if (overlay.visibility?.llm !== undefined) {
340
+ lines.push("visibility:");
341
+ lines.push(` llm: ${overlay.visibility.llm}`);
342
+ }
343
+ if (!lines.length)
344
+ return base;
345
+ const nl = base.indexOf("\n");
346
+ return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
332
347
  }
@@ -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.33.5",
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