tickmarkr 1.33.4 → 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.
- package/dist/adapters/codex.js +5 -2
- package/dist/adapters/model-lints.d.ts +9 -1
- package/dist/adapters/model-lints.js +19 -3
- package/dist/adapters/registry.d.ts +13 -1
- package/dist/adapters/registry.js +151 -18
- package/dist/cli/commands/doctor.js +6 -3
- package/dist/cli/commands/init.js +74 -11
- package/dist/cli/commands/plan.js +4 -4
- package/dist/cli/index.js +5 -2
- package/dist/config/config.d.ts +8 -1
- package/dist/config/config.js +17 -2
- package/package.json +1 -1
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
41
|
-
const tail = extra.length >
|
|
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
|
|
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
|
-
|
|
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,33 +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 = {};
|
|
57
|
-
|
|
64
|
+
const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
|
|
65
|
+
// models probe concurrently too (v1.33.5) — sequential chains made init wall time Σ(models×60s)
|
|
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();
|
|
58
71
|
const probedAt = new Date().toISOString();
|
|
72
|
+
const priorTimedOut = priorModelAuth?.[model]?.reason?.includes("timed out") === true;
|
|
73
|
+
let verdict;
|
|
59
74
|
try {
|
|
60
75
|
if (typeof a.headlessCommand !== "function") {
|
|
61
|
-
|
|
62
|
-
continue;
|
|
76
|
+
verdict = { authed: false, reason: "headless probe unavailable", probedAt, durationMs: Date.now() - t0 };
|
|
63
77
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 };
|
|
72
98
|
}
|
|
73
99
|
}
|
|
74
|
-
const reason = probeFailure(r.code, r.stdout, r.stderr, r.timedOut, MODEL_PROBE_TIMEOUT_MS);
|
|
75
|
-
verdicts[model] = reason ? { authed: false, reason, probedAt } : { authed: true, probedAt };
|
|
76
100
|
}
|
|
77
101
|
catch (e) {
|
|
78
|
-
|
|
102
|
+
verdict = { authed: false, reason: String(e), probedAt, durationMs: Date.now() - t0 };
|
|
79
103
|
}
|
|
80
|
-
|
|
104
|
+
verdicts[model] = verdict;
|
|
105
|
+
onProgress?.(a.id, model, probeModelStatus(verdict), verdict.durationMs);
|
|
106
|
+
});
|
|
81
107
|
h.modelAuth = verdicts;
|
|
82
108
|
}));
|
|
83
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
|
+
}
|
|
84
123
|
const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
|
|
85
124
|
export function writeDoctor(repoRoot, health) {
|
|
86
125
|
drovrDir(repoRoot);
|
|
@@ -163,3 +202,97 @@ export function doctorAgeMs(repoRoot) {
|
|
|
163
202
|
// renders a nonsensical negative age ("doctor.json is -0h old").
|
|
164
203
|
return Math.max(0, Date.now() - statSync(p).mtimeMs);
|
|
165
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 {
|
|
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 =
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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:
|
|
42
|
+
return { out: USAGE, code: 1 };
|
|
40
43
|
try {
|
|
41
44
|
return { out: await fn(argv), code: 0 };
|
|
42
45
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|