tickmarkr 1.33.5 → 1.34.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.
@@ -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,13 @@ 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[];
5
+ export declare const ttyVisual: () => boolean;
6
+ export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
7
+ tty?: boolean;
8
+ }): string[];
9
+ export declare function formatModelAuthLine(excluded: {
10
+ key: string;
11
+ reason: string;
12
+ probedAt: string;
13
+ }[], tty?: boolean): string;
6
14
  export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string;
@@ -9,10 +9,15 @@ 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 DOCTOR_JSON_REF = " — see .drovr/doctor.json";
14
+ export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
12
15
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
13
16
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
14
17
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
15
- export function modelLints(cfg, health, adapters) {
18
+ export function modelLints(cfg, health, adapters, opts) {
19
+ const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
20
+ const doctorRef = opts?.tty ? DOCTOR_JSON_REF : "";
16
21
  const lints = [];
17
22
  for (const id of Object.keys(cfg.tiers)) {
18
23
  const adapter = adapters.find((a) => a.id === id);
@@ -37,8 +42,8 @@ export function modelLints(cfg, health, adapters) {
37
42
  }
38
43
  const extra = detected.filter((m) => !configured.includes(m) && !LINT_VARIANT_RE.test(m));
39
44
  if (extra.length) {
40
- const shown = extra.slice(0, LINT_CAP).join(", ");
41
- const tail = extra.length > LINT_CAP ? `, +${extra.length - LINT_CAP} more` : "";
45
+ const shown = extra.slice(0, cap).join(", ");
46
+ const tail = extra.length > cap ? `, +${extra.length - cap} more${doctorRef}` : "";
42
47
  lints.push(`${id}: reports ${extra.length} model(s) not in tiers (${shown}${tail}) — classify before routing (benchmark policy)`);
43
48
  }
44
49
  const at = h?.modelsDetectedAt;
@@ -50,6 +55,17 @@ export function modelLints(cfg, health, adapters) {
50
55
  }
51
56
  return lints;
52
57
  }
58
+ // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
59
+ // 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
+ const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
62
+ const parts = excluded.map(({ key, reason, probedAt }) => {
63
+ const r = tty ? trunc(reason, 60) : reason;
64
+ return `${key} (${r} — probed ${probedAt.split("T")[0]})`;
65
+ });
66
+ const base = `model auth: ${excluded.length} channel(s) unauthed — ${parts.join(", ")}`;
67
+ return tty ? `${base}${DOCTOR_JSON_REF}` : base;
68
+ }
53
69
  // MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
54
70
  // decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
55
71
  // Additions render WHOLE-LINE-COMMENTED with a `???` tier placeholder (a tier is a benchmark claim; the
@@ -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).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);
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 .drovr/doctor.json`;
297
+ return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}${wrote}`;
298
+ }
@@ -2,7 +2,7 @@ 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
4
  import { drovrDir } from "../../graph/graph.js";
5
- import { modelLints, suggestOverlay } from "../../adapters/model-lints.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() }).map((l) => ` ! ${l}`));
92
95
  const excluded = excludedChannels(cfg, adapters, health);
93
96
  if (excluded.length)
94
97
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -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 {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.33.5",
3
+ "version": "1.34.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",