tickmarkr 1.34.0 → 1.36.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
 
@@ -38,6 +38,7 @@ export function probeVersion(bin) {
38
38
  export const claudeCode = {
39
39
  id: "claude-code",
40
40
  vendor: "anthropic",
41
+ probeCwd: "neutral",
41
42
  probe: async () => probeVersion("claude"),
42
43
  channels: (cfg) => channelsFromConfig("claude-code", cfg),
43
44
  // --strict-mcp-config --mcp-config '{"mcpServers":{}}': pin the MCP surface to empty so fresh-worktree
@@ -49,15 +50,15 @@ export const claudeCode = {
49
50
  // Gotchas (both bit the 2026-07-10 live check): bare '{}' is REJECTED ("mcpServers: expected record"),
50
51
  // and --mcp-config is VARIADIC — a positional after it is eaten as a config-file path, so another
51
52
  // 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`,
53
+ headlessCommand: (promptFile, model) => `claude -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --permission-mode bypassPermissions --strict-mcp-config --mcp-config '{"mcpServers":{}}' --output-format text`,
53
54
  // HYG-03: the residual first-entry dialog on an interactive TUI is the workspace TRUST dialog (not MCP
54
55
  // config loading) — CLI-imposed, no flag to pre-accept, only store is claude's global last-writer-wins
55
56
  // ~/.claude.json keyed on the exact path. Closed WON'T-FIX (decision B, 2026-07-10): drovr writes nothing
56
57
  // to that file (a seed races claude's own writes, nondeterministically). Amortizes to one operator dismissal
57
58
  // per stable worktree path; blocked-pane paging surfaces it. Do NOT change this command to "fix" the dialog —
58
59
  // 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)})"`,
60
+ interactiveCommand: (promptFile, model) => `claude --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
61
+ resumeCommand: (sessionId, promptFile, model) => `claude -r ${shq(sessionId)} --model ${shq(model)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
61
62
  invoke(task, _cwd, a, ctx) {
62
63
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
63
64
  },
@@ -83,6 +83,7 @@ export const grok = {
83
83
  // FLEET-04 cross-vendor honesty: xai is diversity-distinct from anthropic/openai/cursor/zhipu.
84
84
  // MUST equal tiers.grok.vendor (a test pins the equality).
85
85
  vendor: "xai",
86
+ probeCwd: "neutral",
86
87
  probe: async () => {
87
88
  const h = probeVersion("grok");
88
89
  if (!h.installed)
@@ -1,14 +1,17 @@
1
- import type { DrovrConfig } from "../config/config.js";
1
+ 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
5
  export declare const ttyVisual: () => boolean;
6
+ export declare function seedPreferLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
6
7
  export declare function modelLints(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
7
8
  tty?: boolean;
9
+ stateDir?: string;
10
+ overlayPreferShapes?: ReadonlySet<string>;
8
11
  }): string[];
9
12
  export declare function formatModelAuthLine(excluded: {
10
13
  key: string;
11
14
  reason: string;
12
15
  probedAt: string;
13
- }[], tty?: boolean): string;
14
- export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): string;
16
+ }[], tty?: boolean, stateDir?: string): string;
17
+ export declare function suggestOverlay(cfg: DrovrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
1
2
  import { MODEL_ID_RE } from "./types.js";
2
3
  // date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
3
4
  export const SEED_STAMPED = "2026-07-09";
@@ -10,14 +11,43 @@ const DAY_MS = 86400000;
10
11
  const LINT_VARIANT_RE = /^auto$|-(fast|minimal|low|medium|high|xhigh)$/;
11
12
  const LINT_CAP = 5;
12
13
  const TTY_LINT_CAP = 3;
13
- const DOCTOR_JSON_REF = " — see .drovr/doctor.json";
14
+ const DEFAULT_STATE_DIR = ".drovr";
15
+ const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
14
16
  export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
17
+ const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
18
+ const entry = cfg.routing.map[shape];
19
+ const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
20
+ const a = adapters.find((x) => x.id === adapterId);
21
+ const h = health[adapterId];
22
+ if (!a || !h?.installed || typeof a.channels !== "function")
23
+ return false;
24
+ if (!h.modelAuth || !Object.keys(h.modelAuth).length)
25
+ return true; // no per-model probe data — compat, not dead
26
+ return a.channels(cfg).some((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
27
+ };
28
+ // OBS-30 T2: warn when a built-in seed prefer names an adapter with zero authed channels this probe pass.
29
+ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new Set()) {
30
+ const lints = [];
31
+ for (const shape of Object.keys(cfg.routing.map)) {
32
+ if (overlayPreferShapes.has(shape))
33
+ continue;
34
+ for (const p of DEFAULT_CONFIG.routing.map[shape]?.prefer ?? []) {
35
+ const adapterId = p.includes(":") ? p.slice(0, p.indexOf(":")) : p;
36
+ if (!cfg.tiers[adapterId])
37
+ continue;
38
+ if (!adapterHasAuthedChannel(adapterId, shape, cfg, health, adapters)) {
39
+ lints.push(`routing seed names dead adapter '${adapterId}' for shape '${shape}' — auto-prefer is routing around it`);
40
+ }
41
+ }
42
+ }
43
+ return lints;
44
+ }
15
45
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
16
46
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
17
47
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
18
48
  export function modelLints(cfg, health, adapters, opts) {
19
49
  const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
20
- const doctorRef = opts?.tty ? DOCTOR_JSON_REF : "";
50
+ const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
21
51
  const lints = [];
22
52
  for (const id of Object.keys(cfg.tiers)) {
23
53
  const adapter = adapters.find((a) => a.id === id);
@@ -53,18 +83,19 @@ export function modelLints(cfg, health, adapters, opts) {
53
83
  lints.push(`${id}: model knowledge is ${days} days old — rerun tickmarkr doctor`);
54
84
  }
55
85
  }
86
+ lints.push(...seedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes));
56
87
  return lints;
57
88
  }
58
89
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
59
90
  // 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) {
91
+ export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR) {
61
92
  const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
62
93
  const parts = excluded.map(({ key, reason, probedAt }) => {
63
94
  const r = tty ? trunc(reason, 60) : reason;
64
95
  return `${key} (${r} — probed ${probedAt.split("T")[0]})`;
65
96
  });
66
97
  const base = `model auth: ${excluded.length} channel(s) unauthed — ${parts.join(", ")}`;
67
- return tty ? `${base}${DOCTOR_JSON_REF}` : base;
98
+ return tty ? `${base}${doctorJsonRef(stateDir)}` : base;
68
99
  }
69
100
  // MODEL-05/06: render detected-vs-configured drift as a paste-ready config.yaml fragment. Locked v1.5
70
101
  // decision: detection is strictly advisory — doctor prints, a human pastes; NO --write/--apply exists.
@@ -72,7 +103,7 @@ export function formatModelAuthLine(excluded, tty) {
72
103
  // machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
73
104
  // `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
74
105
  // Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
75
- export function suggestOverlay(cfg, health, adapters) {
106
+ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR) {
76
107
  const blocks = [];
77
108
  for (const id of Object.keys(cfg.tiers)) {
78
109
  const adapter = adapters.find((a) => a.id === id);
@@ -116,13 +147,13 @@ export function suggestOverlay(cfg, health, adapters) {
116
147
  lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
117
148
  }
118
149
  if (omitted)
119
- lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see .drovr/doctor.json)`);
150
+ lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see ${stateDir}/doctor.json)`);
120
151
  if (lines.length)
121
152
  blocks.push(` ${id}:\n models:\n${lines.join("\n")}`);
122
153
  }
123
154
  if (blocks.length === 0)
124
155
  return "";
125
- return `# paste into .drovr/config.yaml — drovr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
156
+ return `# paste into ${stateDir}/config.yaml — tickmarkr prints this, it never applies it\ntiers:\n${blocks.join("\n")}\n`;
126
157
  }
127
158
  // Purely relational id split for the addition gate (see suggestOverlay). Local, not exported: this is NOT a
128
159
  // global identity concept — src/gates/review.ts has its own local modelId(), deliberately not shared.
@@ -16,6 +16,7 @@ export function parseOpencodeModels(raw) {
16
16
  export const opencode = {
17
17
  id: "opencode",
18
18
  vendor: "mixed",
19
+ probeCwd: "neutral",
19
20
  probe: async () => probeVersion("opencode"),
20
21
  channels: (cfg) => channelsFromConfig("opencode", cfg),
21
22
  headlessCommand: (promptFile, model) => `opencode run -m ${shq(model)} "$(cat ${shq(promptFile)})"`,
@@ -37,6 +37,7 @@ export const pi = {
37
37
  // FLEET-04: cross-vendor review honesty — GLM's provider (pi's own label is "zai"; either is
38
38
  // diversity-distinct from anthropic/openai/cursor/mixed). Must equal tiers.pi.vendor.
39
39
  vendor: "zhipu",
40
+ probeCwd: "neutral",
40
41
  probe: async () => {
41
42
  const h = probeVersion("pi");
42
43
  if (!h.installed)
@@ -1,4 +1,5 @@
1
- import type { DrovrConfig } from "../config/config.js";
1
+ import { type DrovrConfig } from "../config/config.js";
2
+ import { type RoutingProfile } from "../route/profile.js";
2
3
  import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
3
4
  export declare function allAdapters(opts?: {
4
5
  fakeScriptPath?: string;
@@ -7,6 +8,21 @@ export declare function getAdapter(id: string, adapters: WorkerAdapter[]): Worke
7
8
  export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
8
9
  export type ProbeModelStatus = "ok" | "timeout" | "failed";
9
10
  export type ProbeModelProgress = (adapter: string, model: string, status: ProbeModelStatus, durationMs: number) => void;
11
+ export interface AutoPreferDoc {
12
+ derivedAt: string;
13
+ [shape: string]: string[] | string;
14
+ }
15
+ export declare const pendingAutoPreferKey: unique symbol;
16
+ export declare const DOCTOR_ROUTING_STALE_MS: number;
17
+ export declare function deriveAutoPrefer(cfg: DrovrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, profile?: RoutingProfile): AutoPreferDoc;
18
+ export declare function readAutoPrefer(repoRoot: string): AutoPreferDoc | null;
19
+ export declare function routingPreferContext(repoRoot: string, cfg: DrovrConfig, opts?: {
20
+ globalDir?: string;
21
+ }): {
22
+ autoPrefer?: AutoPreferDoc;
23
+ doctorFresh: boolean;
24
+ overlayPreferShapes: ReadonlySet<string>;
25
+ };
10
26
  export declare function probeModels(cfg: DrovrConfig, repoRoot: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, onProgress?: ProbeModelProgress): Promise<void>;
11
27
  export declare function writeDoctor(repoRoot: string, health: Record<string, AuthHealth>): void;
12
28
  export declare function readDoctor(repoRoot: string): Record<string, AuthHealth> | null;
@@ -1,10 +1,13 @@
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 { overlayPreferShapes, TIER_RANK } from "../config/config.js";
4
5
  import { modelLints, suggestOverlay } from "./model-lints.js";
5
6
  import { HerdrDriver } from "../drivers/herdr.js";
6
7
  import { drovrDir, stateDirName } from "../graph/graph.js";
7
8
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../route/preference.js";
9
+ import { learnedScore } from "../route/profile.js";
10
+ import { marginalCostRank } from "../route/router.js";
8
11
  import { sh } from "../run/git.js";
9
12
  import { claudeCode } from "./claude-code.js";
10
13
  import { codex } from "./codex.js";
@@ -47,6 +50,57 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
47
50
  ? output.slice(0, 240) || `probe exited ${code}`
48
51
  : undefined;
49
52
  }
53
+ export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
54
+ const median = (xs) => {
55
+ const s = [...xs].sort((a, b) => a - b);
56
+ const m = Math.floor(s.length / 2);
57
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
58
+ };
59
+ // ponytail: 24h TTL matches plan.ts staleness — promote to config when an operator asks.
60
+ export const DOCTOR_ROUTING_STALE_MS = 24 * 60 * 60 * 1000;
61
+ export function deriveAutoPrefer(cfg, adapters, health, profile) {
62
+ const derivedAt = new Date().toISOString();
63
+ const out = { derivedAt };
64
+ for (const [shape, entry] of Object.entries(cfg.routing.map)) {
65
+ const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
66
+ const ranked = [];
67
+ for (const a of adapters) {
68
+ const h = health[a.id];
69
+ if (!h?.installed || !h?.authed || typeof a.channels !== "function")
70
+ continue;
71
+ const qualifying = a.channels(cfg).filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
72
+ if (!qualifying.length)
73
+ continue;
74
+ const durations = qualifying
75
+ .map((c) => h.modelAuth?.[c.model]?.durationMs)
76
+ .filter((d) => typeof d === "number");
77
+ ranked.push({
78
+ adapter: a.id,
79
+ mc: Math.min(...qualifying.map(marginalCostRank)),
80
+ dur: durations.length ? median(durations) : Number.POSITIVE_INFINITY,
81
+ learned: profile ? Math.max(...qualifying.map((c) => learnedScore(profile, shape, channelKey(c), c.channel))) : 0,
82
+ });
83
+ }
84
+ ranked.sort((a, b) => a.mc - b.mc || a.dur - b.dur || b.learned - a.learned);
85
+ out[shape] = ranked.map((r) => r.adapter);
86
+ }
87
+ return out;
88
+ }
89
+ export function readAutoPrefer(repoRoot) {
90
+ const raw = readDoctorFile(repoRoot);
91
+ if (!raw || typeof raw.autoPrefer !== "object" || raw.autoPrefer === null)
92
+ return null;
93
+ return raw.autoPrefer;
94
+ }
95
+ export function routingPreferContext(repoRoot, cfg, opts = {}) {
96
+ const age = doctorAgeMs(repoRoot);
97
+ const doctorFresh = age !== null && age <= DOCTOR_ROUTING_STALE_MS;
98
+ return {
99
+ autoPrefer: doctorFresh ? readAutoPrefer(repoRoot) ?? undefined : undefined,
100
+ doctorFresh,
101
+ overlayPreferShapes: overlayPreferShapes(repoRoot, opts),
102
+ };
103
+ }
50
104
  const probeModelStatus = (v) => v.authed ? "ok" : v.reason?.includes("timed out") ? "timeout" : "failed";
51
105
  // v1.21: one bounded, headless call per configured model; detected-but-unclassified models never enter this loop.
52
106
  export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
@@ -62,6 +116,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
62
116
  return;
63
117
  const verdicts = {};
64
118
  const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
119
+ const probeRoot = a.probeCwd === "neutral" ? mkdtempSync(join(tmpdir(), "tickmarkr-probe-")) : repoRoot;
65
120
  // models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
66
121
  // on the slowest adapter (measured 96s); each probe is one tiny prompt, safe to overlap.
67
122
  // Capped at MODEL_PROBE_CONCURRENCY per adapter (v1.33.5 regression: 4 concurrent codex exec
@@ -78,12 +133,12 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
78
133
  else {
79
134
  const promptFile = join(mkdtempSync(join(tmpdir(), "drovr-auth-")), "probe.md");
80
135
  writeFileSync(promptFile, MODEL_PROBE_PROMPT);
81
- const r = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
136
+ const r = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
82
137
  if (r.timedOut && priorTimedOut) {
83
138
  verdict = { authed: false, reason: `probe timed out (repeat — retry skipped) (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
84
139
  }
85
140
  else if (r.timedOut) {
86
- const r2 = await sh(a.headlessCommand(promptFile, model), repoRoot, MODEL_PROBE_TIMEOUT_MS);
141
+ const r2 = await sh(a.headlessCommand(promptFile, model), probeRoot, MODEL_PROBE_TIMEOUT_MS);
87
142
  if (r2.timedOut) {
88
143
  verdict = { authed: false, reason: `probe timed out twice (${MODEL_PROBE_TIMEOUT_MS}ms)`, probedAt, durationMs: Date.now() - t0 };
89
144
  }
@@ -106,6 +161,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
106
161
  });
107
162
  h.modelAuth = verdicts;
108
163
  }));
164
+ health[pendingAutoPreferKey] = deriveAutoPrefer(cfg, adapters, health);
109
165
  }
110
166
  // T2: caps concurrent probes per adapter at 2 — v1.33.5 regression, 4 concurrent codex exec in one
111
167
  // repo made ALL 4 time out where sequential passed 2/4 (suspected CLI self-contention).
@@ -121,12 +177,24 @@ async function mapLimit(items, limit, fn) {
121
177
  await Promise.all(workers);
122
178
  }
123
179
  const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
180
+ function readDoctorFile(repoRoot) {
181
+ return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
182
+ }
124
183
  export function writeDoctor(repoRoot, health) {
125
184
  drovrDir(repoRoot);
126
- writeFileSync(doctorPath(repoRoot), JSON.stringify(health, null, 2) + "\n");
185
+ const pending = health[pendingAutoPreferKey];
186
+ const payload = { ...health };
187
+ delete payload[pendingAutoPreferKey];
188
+ if (pending)
189
+ payload.autoPrefer = pending;
190
+ writeFileSync(doctorPath(repoRoot), JSON.stringify(payload, null, 2) + "\n");
127
191
  }
128
192
  export function readDoctor(repoRoot) {
129
- return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
193
+ const raw = readDoctorFile(repoRoot);
194
+ if (!raw)
195
+ return null;
196
+ const { autoPrefer: _, ...health } = raw;
197
+ return health;
130
198
  }
131
199
  export function discoverChannels(cfg, adapters, health) {
132
200
  const base = adapters
@@ -247,7 +315,7 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
247
315
  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
316
  }
249
317
  }
250
- rows.push(...modelLints(cfg, health, adapters).map((l) => ` ! ${l}`));
318
+ rows.push(...modelLints(cfg, health, adapters, { stateDir: stateDirName(cwd) }).map((l) => ` ! ${l}`));
251
319
  const excluded = excludedChannels(cfg, adapters, health);
252
320
  if (excluded.length)
253
321
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -255,7 +323,7 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
255
323
  if (servable.length)
256
324
  rows.push(` ! ${servabilityLine(servable)}`);
257
325
  const visual = process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
258
- const frag = suggestOverlay(cfg, health, adapters);
326
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
259
327
  let drift = "";
260
328
  if (frag) {
261
329
  if (visual) {
@@ -293,6 +361,6 @@ export function formatDoctorReport(cwd, cfg, health, adapters, opts = {}) {
293
361
  return statusRows;
294
362
  });
295
363
  const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
296
- const wrote = opts.wrote === false ? "" : `\nwrote .drovr/doctor.json`;
364
+ const wrote = opts.wrote === false ? "" : `\nwrote ${stateDirName(cwd)}/doctor.json`;
297
365
  return `tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}${wrote}`;
298
366
  }
@@ -72,6 +72,7 @@ export declare function matchesTrustDialog(paneText: string, dialog: TrustDialog
72
72
  export interface WorkerAdapter {
73
73
  id: string;
74
74
  vendor: string;
75
+ probeCwd?: "repo" | "neutral";
75
76
  probe(): Promise<AuthHealth>;
76
77
  channels(cfg: DrovrConfig): BillingChannel[];
77
78
  headlessCommand(promptFile: string, model: string): string;
@@ -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,9 +1,9 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, probeAll, probeModels, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
- import { drovrDir } from "../../graph/graph.js";
3
+ import { allAdapters, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
+ import { drovrDir, stateDirName } from "../../graph/graph.js";
5
5
  import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
6
- import { loadConfig } from "../../config/config.js";
6
+ import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
7
7
  import { HerdrDriver } from "../../drivers/herdr.js";
8
8
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
9
9
  // same gate + palette as status's audit-ledger frame: styled only on a real TTY, plain otherwise
@@ -91,7 +91,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
91
91
  rows.push(` ! ${role} runs on ${sel.adapter}:${sel.model} — NOT installed; that gate will fail closed until you install it or remap cfg.${role}`);
92
92
  }
93
93
  }
94
- rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() }).map((l) => ` ! ${l}`));
94
+ rows.push(...modelLints(cfg, health, adapters, { tty: ttyVisual(), stateDir: stateDirName(cwd), overlayPreferShapes: overlayPreferShapes(cwd) }).map((l) => ` ! ${l}`));
95
95
  const excluded = excludedChannels(cfg, adapters, health);
96
96
  if (excluded.length)
97
97
  rows.push(` ! ${exclusionLine(excluded)}`);
@@ -102,7 +102,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
102
102
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, drovr NEVER applies it.
103
103
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
104
104
  // v1.33.1 onboarding); machine/CI surface keeps the inline dump — layout is pinned by tests.
105
- const frag = suggestOverlay(cfg, health, adapters);
105
+ const frag = suggestOverlay(cfg, health, adapters, stateDirName(cwd));
106
106
  let drift = "";
107
107
  if (frag) {
108
108
  if (visual()) {
@@ -143,6 +143,20 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
143
143
  rows.push(` (${unclassified.length} more listed, unclassified)`);
144
144
  return rows;
145
145
  });
146
- const modelSummary = modelStatus.length ? `\nmodel status:\n${modelStatus.join("\n")}` : "";
147
- return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote .drovr/doctor.json`);
146
+ const autoPrefer = readAutoPrefer(cwd);
147
+ const preferStatus = autoPrefer
148
+ ? Object.keys(cfg.routing.map).flatMap((shape) => {
149
+ const auto = autoPrefer[shape];
150
+ if (!Array.isArray(auto))
151
+ return [];
152
+ const seed = DEFAULT_CONFIG.routing.map[shape]?.prefer ?? [];
153
+ if (!auto.length && !seed.length)
154
+ return []; // nothing derived, nothing seeded — an empty line is noise
155
+ return [` prefer ${shape} (auto): ${auto.join(" > ")} — seed was [${seed.join(", ")}]`];
156
+ })
157
+ : [];
158
+ const modelSummary = modelStatus.length || preferStatus.length
159
+ ? `\nmodel status:\n${[...modelStatus, ...preferStatus].join("\n")}`
160
+ : "";
161
+ return stylize(`tickmarkr doctor — capability matrix:\n${rows.join("\n")}${modelSummary}${drift}\nwrote ${stateDirName(cwd)}/doctor.json`);
148
162
  }
@@ -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
  }
@@ -177,6 +177,9 @@ export declare class ConfigError extends Error {
177
177
  }
178
178
  export declare const DEFAULT_CONFIG: DrovrConfig;
179
179
  export declare function globalConfigDir(): string;
180
+ export declare function overlayPreferShapes(repoRoot: string, opts?: {
181
+ globalDir?: string;
182
+ }): ReadonlySet<string>;
180
183
  export declare function loadConfig(repoRoot: string, opts?: {
181
184
  globalDir?: string;
182
185
  }): DrovrConfig;
@@ -262,6 +262,19 @@ export function globalConfigDir() {
262
262
  const legacy = join(base, "drovr");
263
263
  return existsSync(join(next, "config.yaml")) || !existsSync(join(legacy, "config.yaml")) ? next : legacy;
264
264
  }
265
+ export function overlayPreferShapes(repoRoot, opts = {}) {
266
+ const shapes = new Set();
267
+ for (const layer of [readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")), readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"))]) {
268
+ const map = layer?.routing?.map;
269
+ if (!map)
270
+ continue;
271
+ for (const [shape, entry] of Object.entries(map)) {
272
+ if (entry && Object.prototype.hasOwnProperty.call(entry, "prefer") && entry.prefer !== null)
273
+ shapes.add(shape);
274
+ }
275
+ }
276
+ return shapes;
277
+ }
265
278
  export function loadConfig(repoRoot, opts = {}) {
266
279
  const globalCfg = readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml"));
267
280
  const repoCfg = readYaml(join(repoRoot, stateDirName(repoRoot), "config.yaml"));
@@ -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
  }
@@ -18,9 +18,17 @@ export interface Route {
18
18
  provenance: string;
19
19
  deviation?: RouteDeviation;
20
20
  }
21
+ export interface RoutingPreferContext {
22
+ autoPrefer?: {
23
+ derivedAt: string;
24
+ [shape: string]: string[] | string;
25
+ };
26
+ doctorFresh: boolean;
27
+ overlayPreferShapes: ReadonlySet<string>;
28
+ }
21
29
  export declare class RoutingError extends Error {
22
30
  constructor(msg: string);
23
31
  }
24
32
  export declare function marginalCostRank(c: BillingChannel): number;
25
- export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile): Route;
33
+ export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): Route;
26
34
  export declare function nextChannel(current: Assignment, task: Task, cfg: DrovrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile): Assignment | null;
@@ -2,6 +2,19 @@ import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
2
  import { TIER_RANK } from "../config/config.js";
3
3
  import { disallowedBy } from "./preference.js";
4
4
  import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore } from "./profile.js";
5
+ const autoPreferList = (doc, shape) => {
6
+ const v = doc?.[shape];
7
+ return Array.isArray(v) ? v : undefined;
8
+ };
9
+ const preferFromAuto = (shape, preferCtx) => !!preferCtx?.doctorFresh && !!preferCtx.autoPrefer && !preferCtx.overlayPreferShapes.has(shape) &&
10
+ autoPreferList(preferCtx.autoPrefer, shape) !== undefined;
11
+ const effectivePrefer = (shape, entry, preferCtx) => {
12
+ if (preferCtx?.overlayPreferShapes.has(shape))
13
+ return entry?.prefer;
14
+ if (preferCtx?.doctorFresh && preferCtx.autoPrefer)
15
+ return autoPreferList(preferCtx.autoPrefer, shape) ?? entry?.prefer;
16
+ return entry?.prefer;
17
+ };
5
18
  export class RoutingError extends Error {
6
19
  constructor(msg) {
7
20
  super(msg);
@@ -31,10 +44,11 @@ function ladderFor(task, entry) {
31
44
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
32
45
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
33
46
  }
34
- export function route(task, cfg, channels, profile) {
47
+ export function route(task, cfg, channels, profile, preferCtx) {
35
48
  const lints = [];
36
49
  const floor = cfg.routing.floors[task.shape];
37
50
  const entry = cfg.routing.map[task.shape];
51
+ const prefer = effectivePrefer(task.shape, entry, preferCtx);
38
52
  const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
39
53
  const disallowedPin = (via, model, kind) => {
40
54
  const d = disallowedBy({ adapter: via, model }, cfg.routing);
@@ -95,12 +109,12 @@ export function route(task, cfg, channels, profile) {
95
109
  if (entry?.tier)
96
110
  lintFloor(entry.tier, "map tier");
97
111
  if (prefActive)
98
- for (const p of entry?.prefer ?? [])
112
+ for (const p of prefer ?? [])
99
113
  preflightPrefer(p);
100
114
  // key order is a contract: prefer > marginal cost > tier (cheapest sufficient) > learned score (v1.6 ROUTE-06)
101
115
  // > discovery order (same-tier fairness, D2). The learned score is the LAST key — it decides only the
102
116
  // discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08).
103
- const staticCmp = (a, b) => preferIndex(a, entry?.prefer) - preferIndex(b, entry?.prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
117
+ const staticCmp = (a, b) => preferIndex(a, prefer) - preferIndex(b, prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
104
118
  const eligibleRaw = channels.filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier]);
105
119
  if (!eligibleRaw.length) {
106
120
  let msg = `${task.id}: no channel at tier>=${minTier}; available: ${channels.map(channelKey).join(", ") || "(none)"}`;
@@ -137,19 +151,19 @@ export function route(task, cfg, channels, profile) {
137
151
  // entries, while intra-entry order (cost > tier > bonus > score) and every cold path stay byte-identical.
138
152
  // Cold reduces to preferIndex || cost || tier ≡ staticCmp (proof P1); rep keys use the group HEAD so
139
153
  // the probed group's budget self-extinguishes at EXPLORE_CAP (P4); flat lexicographic K is total (P5).
140
- const prefer = entry?.prefer ?? [];
154
+ const preferBands = prefer ?? [];
141
155
  const groupCmp = (a, b) => marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier] ||
142
156
  bonusOf(b) - bonusOf(a) || scoreOf(b) - scoreOf(a);
143
157
  const heads = new Map();
144
158
  for (const c of [...eligibleRaw].sort(groupCmp)) {
145
- const g = preferIndex(c, prefer);
159
+ const g = preferIndex(c, preferBands);
146
160
  if (!heads.has(g))
147
161
  heads.set(g, c);
148
162
  }
149
163
  const keyOf = new Map(eligibleRaw.map((c) => {
150
- const g = preferIndex(c, prefer);
164
+ const g = preferIndex(c, preferBands);
151
165
  const rep = heads.get(g);
152
- return [channelKey(c), [g < prefer.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), g,
166
+ return [channelKey(c), [g < preferBands.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), g,
153
167
  marginalCostRank(c), TIER_RANK[c.tier], -bonusOf(c), -scoreOf(c)]];
154
168
  }));
155
169
  const kOf = (c) => keyOf.get(channelKey(c));
@@ -180,8 +194,11 @@ export function route(task, cfg, channels, profile) {
180
194
  "tier cheap (default)";
181
195
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
182
196
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
183
- const chosenBy = learnedChosen || (entry?.prefer && preferIndex(eligible[0], entry.prefer) < entry.prefer.length
184
- ? "via prefer" : "cheapest sufficient tier");
197
+ const preferVia = preferFromAuto(task.shape, preferCtx)
198
+ ? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
199
+ : "via prefer";
200
+ const chosenBy = learnedChosen || (prefer && preferIndex(eligible[0], prefer) < prefer.length
201
+ ? preferVia : "cheapest sufficient tier");
185
202
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
186
203
  }
187
204
  export function nextChannel(current, task, cfg, channels, tried, profile) {
@@ -11,7 +11,7 @@ import { captureBaseline, detectGateCommands } from "../gates/baseline.js";
11
11
  import { runGates } from "../gates/run-gates.js";
12
12
  import { addEvidence, attributeBlocked, blockedTasks, getTask, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
13
13
  import { consult } from "./consult.js";
14
- import { gitHead, sh } from "./git.js";
14
+ import { cleanupRunWorktrees, gitHead, sh } from "./git.js";
15
15
  import { Journal, loadRoutingProfile, newRunId } from "./journal.js";
16
16
  import { acquireRunLock, releaseRunLock } from "./lock.js";
17
17
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask } from "./merge.js";
@@ -706,6 +706,12 @@ export async function runDaemon(repoRoot, opts = {}) {
706
706
  };
707
707
  journal.append("run-end", undefined, { ...summary });
708
708
  await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
709
+ // OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
710
+ if (!keepForever) {
711
+ const green = summary.failed.length === 0 && summary.human.length === 0
712
+ && summary.blocked.length === 0 && summary.pending.length === 0;
713
+ await cleanupRunWorktrees(repoRoot, branch, { removeIntegration: green, removeTaskIds: summary.done });
714
+ }
709
715
  // VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
710
716
  const attribution = [...attributeBlocked(graph).entries()]
711
717
  .sort(([a], [b]) => a.localeCompare(b))
package/dist/run/git.d.ts CHANGED
@@ -7,6 +7,13 @@ export interface ShResult {
7
7
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
8
8
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
9
9
  export declare function gitHead(cwd: string): Promise<string>;
10
+ export declare const sanitizeBranch: (branch: string) => string;
11
+ export declare const worktreePath: (repo: string, branch: string) => string;
12
+ /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
13
+ export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
14
+ removeIntegration: boolean;
15
+ removeTaskIds: string[];
16
+ }): Promise<void>;
10
17
  export declare function resolveIntegrationBranch(repo: string, branch: string): Promise<string>;
11
18
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
12
19
  export declare function removeWorktree(repo: string, dir: string): Promise<void>;
package/dist/run/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, symlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
5
  import { drovrDir } from "../graph/graph.js";
@@ -47,7 +47,16 @@ export async function shOk(cmd, cwd) {
47
47
  export async function gitHead(cwd) {
48
48
  return (await shOk("git rev-parse HEAD", cwd)).trim();
49
49
  }
50
- const sanitize = (branch) => branch.replace(/[^\w.-]+/g, "-");
50
+ export const sanitizeBranch = (branch) => branch.replace(/[^\w.-]+/g, "-");
51
+ const sanitize = sanitizeBranch;
52
+ export const worktreePath = (repo, branch) => join(drovrDir(repo), "worktrees", sanitize(branch));
53
+ /** OBS-28: remove this run's recorded worktrees; tolerates already-gone paths. */
54
+ export async function cleanupRunWorktrees(repo, branch, opts) {
55
+ if (opts.removeIntegration)
56
+ await removeWorktree(repo, worktreePath(repo, branch));
57
+ for (const id of opts.removeTaskIds)
58
+ await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
59
+ }
51
60
  const branchExists = async (repo, branch) => (await sh(`git rev-parse --verify ${shq(`refs/heads/${branch}`)}`, repo)).code === 0;
52
61
  export async function resolveIntegrationBranch(repo, branch) {
53
62
  if (!branch.startsWith("drovr/"))
@@ -68,8 +77,21 @@ export async function createWorktree(repo, branch, baseRef) {
68
77
  if (existsSync(dir))
69
78
  await removeWorktree(repo, dir);
70
79
  await shOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
80
+ linkNodeModules(repo, dir);
71
81
  return dir;
72
82
  }
83
+ // best-effort: devDep-based gates (tsx, vitest) shell out root-anchored and ENOENT in a bare
84
+ // fresh worktree (OBS-27); failure here must never fail worktree creation
85
+ function linkNodeModules(repo, dir) {
86
+ const src = join(repo, "node_modules");
87
+ const dest = join(dir, "node_modules");
88
+ if (!existsSync(src) || existsSync(dest))
89
+ return;
90
+ try {
91
+ symlinkSync(src, dest, "dir");
92
+ }
93
+ catch { /* best-effort */ }
94
+ }
73
95
  export async function removeWorktree(repo, dir) {
74
96
  await sh(`git worktree remove --force ${shq(dir)}`, repo); // best-effort; stale dirs are re-added with -B
75
97
  await sh(`rm -rf ${shq(dir)}`, repo);
package/dist/run/lock.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
- import { drovrDir } from "../graph/graph.js";
4
+ import { drovrDir, stateDirName } from "../graph/graph.js";
5
5
  // HARD-01/02: coarse per-run advisory lock over .drovr/graph.json. LOCK-02: the lock is created by
6
6
  // the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
7
7
  // which is atomic and throws EEXIST if the lock already exists (the mutual-exclusion primitive).
@@ -125,11 +125,12 @@ export function acquireRunLock(repoRoot, runId) {
125
125
  const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
126
126
  return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
127
127
  }
128
+ const stateDir = stateDirName(repoRoot);
128
129
  if (garbage)
129
- throw new Error(".drovr/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run `drovr unlock` to remove it");
130
+ throw new Error(`${stateDir}/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run \`drovr unlock\` to remove it`);
130
131
  // LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
131
132
  // (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
132
- throw new Error(`.drovr/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
133
+ throw new Error(`${stateDir}/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
133
134
  }
134
135
  }
135
136
  export function releaseRunLock(repoRoot) {
@@ -169,7 +170,7 @@ export function unlockRun(repoRoot) {
169
170
  return { held: false };
170
171
  } // statSync ENOENT ⇒ no lock
171
172
  if (!insp.dead && !insp.garbage) {
172
- throw new Error(`.drovr/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
173
+ throw new Error(`${stateDirName(repoRoot)}/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
173
174
  }
174
175
  try {
175
176
  unlinkSync(p);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.34.0",
3
+ "version": "1.36.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