tickmarkr 1.46.0 → 1.48.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.
@@ -0,0 +1,5 @@
1
+ import { type WorkerAdapter, type WorkerResult } from "./types.js";
2
+ export declare function kimiAuthed(credentialsText: string, nowMs: number): boolean;
3
+ export declare function parseKimiModels(raw: string): string[];
4
+ export declare function parseKimiResult(raw: string, nonce: string): WorkerResult;
5
+ export declare const kimi: WorkerAdapter;
@@ -0,0 +1,79 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { probeVersion } from "./claude-code.js";
6
+ import { parseWorkerResult } from "./prompt.js";
7
+ import { channelsFromConfig, MODEL_ID_RE, shq } from "./types.js";
8
+ // KIMI-03: collectUsage is DELIBERATELY ABSENT — documented won't-implement, consistent with the
9
+ // metering-honesty invariant (adapters/types.ts). Sessions live under ~/.kimi-code/sessions/ with
10
+ // kimi export (ZIP) + session_index.jsonl; no harness-readable per-attempt token counter was found
11
+ // in the session store (research F-6, 2026-07-17). Consequence: kimi channels report honestly
12
+ // `unmetered` — same class as grok (grok.ts:11-39). Revisit if kimi ships usage in the
13
+ // `-p --output-format stream-json` envelope or a counter in the session store.
14
+ const CREDENTIALS_PATH = join(homedir(), ".kimi-code", "credentials", "kimi-code.json");
15
+ // KIMI-01. Auth verdict from ~/.kimi-code/credentials/kimi-code.json ONLY — flat JSON, never a
16
+ // network call. expires_at is epoch SECONDS (not ISO like grok). Non-empty refresh_token dominates
17
+ // an expired expires_at (device-code flow auto-refreshes). Missing/unreadable/garbage ⇒ authed:false.
18
+ export function kimiAuthed(credentialsText, nowMs) {
19
+ try {
20
+ const j = JSON.parse(credentialsText);
21
+ if (typeof j.refresh_token === "string" && j.refresh_token.length > 0)
22
+ return true;
23
+ const exp = typeof j.expires_at === "number" ? j.expires_at * 1000 : Number(j.expires_at) * 1000;
24
+ return Number.isFinite(exp) && exp > nowMs;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ // KIMI-04 listModels parse: `kimi provider list --json` models map keys. Fail-open to [].
31
+ export function parseKimiModels(raw) {
32
+ try {
33
+ const j = JSON.parse(raw);
34
+ if (!j.models || typeof j.models !== "object")
35
+ return [];
36
+ return Object.keys(j.models).filter((id) => id.length > 0 && MODEL_ID_RE.test(id));
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ }
42
+ // KIMI-02 parse: kimi -p text output prefixes thinking/answer lines with `• `. Strip before trailer
43
+ // scan so JSON.parse succeeds (research F-4 fixture). Resume line after the trailer is ignored by
44
+ // last-valid-trailer-wins in parseWorkerResult.
45
+ export function parseKimiResult(raw, nonce) {
46
+ const stripped = raw.split("\n").map((l) => l.replace(/^[\s]*[•*-]\s+/, "")).join("\n");
47
+ return parseWorkerResult(stripped, nonce);
48
+ }
49
+ export const kimi = {
50
+ id: "kimi",
51
+ vendor: "moonshot",
52
+ probeCwd: "neutral",
53
+ probe: async () => {
54
+ const h = probeVersion("kimi");
55
+ if (!h.installed)
56
+ return h;
57
+ let authed = false;
58
+ try {
59
+ authed = kimiAuthed(readFileSync(CREDENTIALS_PATH, "utf8"), Date.now());
60
+ }
61
+ catch { /* missing/unreadable ⇒ truthfully unauthed */ }
62
+ return authed
63
+ ? { ...h, note: "auth verified via ~/.kimi-code/credentials/kimi-code.json (free, no network)" }
64
+ : { ...h, authed: false, note: "no valid ~/.kimi-code/credentials/kimi-code.json (kimi login to fix)" };
65
+ },
66
+ channels: (cfg) => channelsFromConfig("kimi", cfg),
67
+ // KIMI-02 headless. -p prompt mode + -y yolo + explicit model. kimi 0.27.0 rejects -p combined
68
+ // with -y at parse time — tickmarkr still emits both per harness contract; interactive uses -y.
69
+ headlessCommand: (promptFile, model) => `kimi -p "$(cat ${shq(promptFile)})" -y --model ${shq(model)} --output-format text`,
70
+ interactiveCommand: (promptFile, model) => `kimi -y --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
71
+ invoke(task, _cwd, a, ctx) {
72
+ return { command: this.headlessCommand(ctx.promptFile, a.model) };
73
+ },
74
+ parse: parseKimiResult,
75
+ listModels: async () => {
76
+ const r = spawnSync("kimi", ["provider", "list", "--json"], { encoding: "utf8", timeout: 15000 });
77
+ return r.error || r.status !== 0 ? [] : parseKimiModels(r.stdout || "");
78
+ },
79
+ };
@@ -1,8 +1,21 @@
1
1
  import { type TickmarkrConfig } from "../config/config.js";
2
+ import type { Task } from "../graph/schema.js";
2
3
  import { type AuthHealth, type WorkerAdapter } from "./types.js";
3
4
  export declare const SEED_STAMPED = "2026-07-09";
4
5
  export declare const MODEL_STALE_DAYS = 30;
5
6
  export declare const ttyVisual: () => boolean;
7
+ /** True when any tier entry declares at least one model window. */
8
+ export declare function hasWindowsConfig(cfg: TickmarkrConfig): boolean;
9
+ export declare function declaredModelWindow(cfg: TickmarkrConfig, adapter: string, model: string): number | undefined;
10
+ /** Best-effort plan-time payload estimate: prompt shell + context + files[] byte sizes. */
11
+ export declare function estimateTaskPayloadTokens(task: Task, repoRoot: string, feedback?: string): number;
12
+ export type RoutedAssignment = {
13
+ taskId: string;
14
+ adapter: string;
15
+ model: string;
16
+ };
17
+ /** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
18
+ export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
6
19
  export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
7
20
  export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
8
21
  tty?: boolean;
@@ -15,3 +28,9 @@ export declare function formatModelAuthLine(excluded: {
15
28
  probedAt: string;
16
29
  }[], tty?: boolean, stateDir?: string): string;
17
30
  export declare function suggestOverlay(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
31
+ /** Unclassified models surfaced for fleet screen 2 (doctor matrix math, no tier fabrication). */
32
+ export declare function fleetUnclassifiedModels(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): {
33
+ adapter: string;
34
+ model: string;
35
+ detectedAt?: string;
36
+ }[];
@@ -1,6 +1,8 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
4
+ import { buildTaskPrompt } from "./prompt.js";
2
5
  import { MODEL_ID_RE } from "./types.js";
3
- // date the tiers seeds were last live-verified (Phase 6/7 reseed) — surfaced when an adapter has no list surface.
4
6
  export const SEED_STAMPED = "2026-07-09";
5
7
  // knowledge past this age gets a "rerun tickmarkr doctor" nudge (BLOCKED_POLL_MS-style named constant).
6
8
  export const MODEL_STALE_DAYS = 30;
@@ -14,6 +16,57 @@ const TTY_LINT_CAP = 3;
14
16
  const DEFAULT_STATE_DIR = ".tickmarkr";
15
17
  const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
16
18
  export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
19
+ // ponytail: chars/4 token heuristic — good enough for advisory plan lint; no tokenizer dep.
20
+ const CHARS_PER_TOKEN = 4;
21
+ /** True when any tier entry declares at least one model window. */
22
+ export function hasWindowsConfig(cfg) {
23
+ return Object.values(cfg.tiers).some((t) => t.windows && Object.keys(t.windows).length > 0);
24
+ }
25
+ export function declaredModelWindow(cfg, adapter, model) {
26
+ return cfg.tiers[adapter]?.windows?.[model];
27
+ }
28
+ function fileBytes(repoRoot, rel) {
29
+ if (rel.includes("*") || rel.includes("?") || rel.includes("{"))
30
+ return 0; // glob — not measurable at plan time
31
+ try {
32
+ const p = join(repoRoot, rel);
33
+ if (!existsSync(p))
34
+ return 0;
35
+ return statSync(p).size;
36
+ }
37
+ catch {
38
+ return 0;
39
+ }
40
+ }
41
+ /** Best-effort plan-time payload estimate: prompt shell + context + files[] byte sizes. */
42
+ export function estimateTaskPayloadTokens(task, repoRoot, feedback = "") {
43
+ let bytes = buildTaskPrompt(task, feedback).length;
44
+ for (const p of task.context)
45
+ bytes += fileBytes(repoRoot, p);
46
+ for (const p of task.files)
47
+ bytes += fileBytes(repoRoot, p);
48
+ return Math.ceil(bytes / CHARS_PER_TOKEN);
49
+ }
50
+ /** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
51
+ export function contextWindowLints(tasks, assignments, cfg, repoRoot) {
52
+ if (!hasWindowsConfig(cfg))
53
+ return [];
54
+ const byId = new Map(assignments.map((a) => [a.taskId, a]));
55
+ const lints = [];
56
+ for (const t of tasks) {
57
+ const a = byId.get(t.id);
58
+ if (!a)
59
+ continue;
60
+ const window = declaredModelWindow(cfg, a.adapter, a.model);
61
+ if (window === undefined)
62
+ continue;
63
+ const est = estimateTaskPayloadTokens(t, repoRoot);
64
+ if (est > window) {
65
+ lints.push(`${t.id}: payload ~${est} tokens exceeds ${a.adapter}:${a.model} window ${window}`);
66
+ }
67
+ }
68
+ return lints;
69
+ }
17
70
  const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
18
71
  const entry = cfg.routing.map[shape];
19
72
  const minTier = entry?.tier ?? cfg.routing.floors[shape] ?? "cheap";
@@ -175,3 +228,23 @@ function referenceWarning(cfg, adapterId, model) {
175
228
  refs.push("consult");
176
229
  return refs.length ? ` # WARNING: still referenced by ${refs.join(", ")} — remap before removing` : "";
177
230
  }
231
+ /** Unclassified models surfaced for fleet screen 2 (doctor matrix math, no tier fabrication). */
232
+ export function fleetUnclassifiedModels(cfg, health, adapters) {
233
+ const out = [];
234
+ for (const id of Object.keys(cfg.tiers)) {
235
+ if (!adapters.some((a) => a.id === id))
236
+ continue;
237
+ const h = health[id];
238
+ const detected = h?.models ?? [];
239
+ if (!detected.length)
240
+ continue;
241
+ const configured = new Set(Object.keys(cfg.tiers[id].models));
242
+ const date = h?.modelsDetectedAt?.split("T")[0];
243
+ for (const model of detected) {
244
+ if (configured.has(model) || LINT_VARIANT_RE.test(model))
245
+ continue;
246
+ out.push({ adapter: id, model, detectedAt: date });
247
+ }
248
+ }
249
+ return out;
250
+ }
@@ -1,6 +1,17 @@
1
1
  import { type TickmarkrConfig } from "../config/config.js";
2
2
  import { type RoutingProfile } from "../route/profile.js";
3
3
  import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
4
+ export declare const CANDIDATE_CLI_CATALOG: readonly ["kimi", "gemini", "qwen", "aider", "goose", "amp", "droid", "auggie", "crush"];
5
+ export declare const REGISTERED_ADAPTER_BINARIES: readonly ["claude", "codex", "cursor-agent", "opencode", "pi", "grok"];
6
+ export type CandidateCliDetection = {
7
+ binary: string;
8
+ version: string | undefined;
9
+ };
10
+ export declare function probeCandidateVersion(bin: string): string | undefined;
11
+ export declare function detectCandidateClis(opts?: {
12
+ pathEnv?: string;
13
+ }): CandidateCliDetection[];
14
+ export declare function formatCandidateCliRow({ binary, version }: CandidateCliDetection): string;
4
15
  export declare function allAdapters(opts?: {
5
16
  fakeScriptPath?: string;
6
17
  }): WorkerAdapter[];
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
@@ -14,14 +15,49 @@ import { codex } from "./codex.js";
14
15
  import { cursorAgent } from "./cursor-agent.js";
15
16
  import { FakeAdapter } from "./fake.js";
16
17
  import { grok } from "./grok.js";
18
+ import { kimi } from "./kimi.js";
17
19
  import { opencode } from "./opencode.js";
18
20
  import { pi } from "./pi.js";
19
21
  import { channelKey, modelAuthed, QUOTA_RE } from "./types.js";
22
+ // Agent-CLI binary names with no tickmarkr adapter — doctor sweeps these advisory-only (v1.48 T1).
23
+ export const CANDIDATE_CLI_CATALOG = ["kimi", "gemini", "qwen", "aider", "goose", "amp", "droid", "auggie", "crush"];
24
+ // Binaries probed by registered adapters — kept beside the catalog so a test can forbid overlap.
25
+ export const REGISTERED_ADAPTER_BINARIES = ["claude", "codex", "cursor-agent", "opencode", "pi", "grok"];
26
+ function candidateOnPath(bin, pathEnv) {
27
+ const r = spawnSync("which", [bin], { encoding: "utf8", env: { ...process.env, PATH: pathEnv } });
28
+ return r.status === 0 && r.stdout.trim().length > 0;
29
+ }
30
+ // Fail open: a broken candidate binary never throws and never fails doctor.
31
+ export function probeCandidateVersion(bin) {
32
+ try {
33
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 10000 });
34
+ if (r.error || r.status !== 0)
35
+ return undefined;
36
+ return (r.stdout || r.stderr).trim().split("\n")[0];
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ export function detectCandidateClis(opts = {}) {
43
+ const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
44
+ const out = [];
45
+ for (const bin of CANDIDATE_CLI_CATALOG) {
46
+ if (!candidateOnPath(bin, pathEnv))
47
+ continue;
48
+ out.push({ binary: bin, version: probeCandidateVersion(bin) });
49
+ }
50
+ return out;
51
+ }
52
+ export function formatCandidateCliRow({ binary, version }) {
53
+ const ver = version ?? "version unknown";
54
+ return ` ! ${binary.padEnd(14)} detected: ${ver} (no tickmarkr adapter — not routable)`;
55
+ }
20
56
  export function allAdapters(opts = {}) {
21
- // pi + grok appended LAST: same-tier ties resolve by discovery order (Phase 6 D2), so appending
22
- // keeps the Phase 6 shape→channel matrix byte-identical; inserting anywhere else silently
23
- // reassigns shapes on same-tier ties. grok is appended AFTER pi for the same reason.
24
- const real = [claudeCode, codex, cursorAgent, opencode, pi, grok];
57
+ // pi + grok + kimi appended LAST: same-tier ties resolve by discovery order (Phase 6 D2), so
58
+ // appending keeps the Phase 6 shape→channel matrix byte-identical; inserting anywhere else
59
+ // silently reassigns shapes on same-tier ties. kimi is appended AFTER grok for the same reason.
60
+ const real = [claudeCode, codex, cursorAgent, opencode, pi, grok, kimi];
25
61
  const fakePath = opts.fakeScriptPath ?? process.env.TICKMARKR_FAKE_SCRIPT;
26
62
  return fakePath ? [new FakeAdapter(fakePath), ...real] : real;
27
63
  }
@@ -112,7 +148,7 @@ export async function probeModels(cfg, repoRoot, adapters, health, onProgress) {
112
148
  if (!h?.installed)
113
149
  return;
114
150
  // Tests inject FakeAdapter; never let an incidental default adapter spend a real token in the suite.
115
- if (process.env.VITEST && [claudeCode, codex, cursorAgent, opencode, pi, grok].includes(a))
151
+ if (process.env.VITEST && [claudeCode, codex, cursorAgent, opencode, pi, grok, kimi].includes(a))
116
152
  return;
117
153
  const verdicts = {};
118
154
  const priorModelAuth = priorHealth?.[a.id]?.modelAuth;
@@ -1,9 +1,9 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
3
+ import { allAdapters, detectCandidateClis, formatCandidateCliRow, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
4
  import { BANNER } from "../../brand.js";
5
5
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
6
- import { modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
6
+ import { declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
7
7
  import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
8
8
  import { HerdrDriver } from "../../drivers/herdr.js";
9
9
  import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
@@ -62,6 +62,8 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
62
62
  const state = !h.installed ? "not installed" : `${h.version ?? "installed"}${h.note ? ` (${h.note})` : ""}`;
63
63
  return ` ${h.installed ? "✓" : "✗"} ${a.id.padEnd(14)} ${state}`;
64
64
  });
65
+ // v1.48 T1: advisory sweep for known agent CLIs with no adapter — never written to doctor.json health.
66
+ rows.push(...detectCandidateClis().map(formatCandidateCliRow));
65
67
  rows.push(` ${HerdrDriver.available() ? "✓" : "✗"} herdr ${HerdrDriver.available() ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"}`);
66
68
  // v1.22 T5: workspace-trust pre-flight — per installed adapter: trusted | seeded | action-required | n/a.
67
69
  // action-required names the exact one-time command (or dialog) the operator must run once.
@@ -121,6 +123,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
121
123
  // per adapter — they aren't routable, so they don't earn table rows.
122
124
  const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
123
125
  const dateOf = (iso) => iso.slice(0, 10);
126
+ const showWindows = hasWindowsConfig(cfg);
124
127
  const modelStatus = adapters.flatMap((a) => {
125
128
  const h = health[a.id];
126
129
  if (!h?.installed)
@@ -138,7 +141,10 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
138
141
  const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
139
142
  const denied = d?.by === "deny" ? d.entry : "—";
140
143
  const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
141
- rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)} ${auth} denied=${denied} prefer=${pref}`);
144
+ const windowCol = showWindows
145
+ ? ` ${String(declaredModelWindow(cfg, a.id, m) ?? "—").padEnd(8)}`
146
+ : "";
147
+ rows.push(` ${m.padEnd(w)} ${classified[m].padEnd(8)}${windowCol} ${auth} denied=${denied} prefer=${pref}`);
142
148
  }
143
149
  if (unclassified.length)
144
150
  rows.push(` (${unclassified.length} more listed, unclassified)`);
@@ -0,0 +1,5 @@
1
+ import type { WorkerAdapter } from "../../adapters/types.js";
2
+ export declare function fleet(argv: string[], cwd?: string, adapters?: WorkerAdapter[]): Promise<string | {
3
+ out: string;
4
+ code: number;
5
+ }>;
@@ -0,0 +1,249 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { parseArgs } from "node:util";
5
+ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
6
+ import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
7
+ import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, loadConfig, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, unifiedYamlDiff, } from "../../config/config.js";
8
+ import { SHAPES, TIERS } from "../../graph/schema.js";
9
+ import { route } from "../../route/router.js";
10
+ import { loadRoutingProfile } from "../../run/journal.js";
11
+ const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
12
+ const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
13
+ const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
14
+ function previewTask(shape) {
15
+ return {
16
+ id: "fleet-preview",
17
+ title: "fleet preview",
18
+ goal: "preview",
19
+ shape,
20
+ complexity: 3,
21
+ acceptance: ["done"],
22
+ deps: [],
23
+ files: [],
24
+ context: [],
25
+ gates: ["build", "test", "lint", "evidence", "scope", "acceptance", "review"],
26
+ humanGate: false,
27
+ status: "pending",
28
+ evidence: { commits: [], artifacts: [], gateResults: [] },
29
+ };
30
+ }
31
+ function formatAge(ageMs) {
32
+ if (ageMs === null)
33
+ return "no probe data";
34
+ const mins = Math.floor(ageMs / 60_000);
35
+ if (mins < 60)
36
+ return `${mins}m old`;
37
+ return `${Math.floor(mins / 60)}h old`;
38
+ }
39
+ function currentRepoOverlayText(repoRoot) {
40
+ const p = repoOverlayPath(repoRoot);
41
+ return existsSync(p) ? readFileSync(p, "utf8") : "";
42
+ }
43
+ function provenanceMap(editable) {
44
+ const out = {};
45
+ for (const [adapter, models] of Object.entries(editable.tiers)) {
46
+ for (const [model, v] of Object.entries(models)) {
47
+ if (v?.provenance) {
48
+ out[adapter] ??= {};
49
+ out[adapter][model] = v.provenance;
50
+ }
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function proposedOverlayText(initial, editable, repoRoot) {
56
+ const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(repoRoot)));
57
+ return repoOverlayYaml(merged, provenanceMap(editable));
58
+ }
59
+ async function ask(rl, prompt) {
60
+ return (await rl.question(prompt)).trim();
61
+ }
62
+ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters()) {
63
+ const { values } = parseArgs({
64
+ args: argv,
65
+ options: {
66
+ print: { type: "boolean" },
67
+ "global-dir": { type: "string" },
68
+ fresh: { type: "boolean" },
69
+ },
70
+ });
71
+ const globalDir = values["global-dir"] ?? globalConfigDir();
72
+ const print = values.print ?? false;
73
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
74
+ if (print)
75
+ return formatFleetPrint(cwd, { globalDir });
76
+ if (!interactive)
77
+ return { out: NON_TTY_MSG, code: 1 };
78
+ const fresh = values.fresh ?? false;
79
+ const { reuse, health: cached } = initDoctorReuse(cwd, fresh);
80
+ if (!reuse || !cached) {
81
+ return {
82
+ out: "tickmarkr fleet: probe data missing or stale — run `tickmarkr doctor` first (fleet never re-probes; doctor is the sensor)",
83
+ code: 1,
84
+ };
85
+ }
86
+ const cfg = loadConfig(cwd, { globalDir });
87
+ const initial = fleetEditableFromConfig(cfg);
88
+ const editable = structuredClone(initial);
89
+ const health = cached;
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ try {
92
+ // screen 0 — sensor freshness
93
+ const ageLine = formatAge(doctorAgeMs(cwd));
94
+ let step = await ask(rl, `tickmarkr fleet — scope your fleet\n probe data: ${ageLine} (.tickmarkr/doctor.json)\n [Enter] use it [r] refresh via doctor [q] quit\n> `);
95
+ if (/^q$/i.test(step))
96
+ return "fleet: quit without writing";
97
+ if (/^r$/i.test(step)) {
98
+ return "fleet: run `tickmarkr doctor` to refresh probe data, then re-run `tickmarkr fleet` (doctor is the sensor; fleet never re-probes)";
99
+ }
100
+ // screen 1 — adapter membership (deny adapters)
101
+ const installed = adapters.filter((a) => health[a.id]?.installed);
102
+ const adapterLines = installed.map((a, i) => {
103
+ const h = health[a.id];
104
+ const on = !isDeniedAdapter(a.id, editable);
105
+ const mark = on ? "x" : " ";
106
+ const auth = h?.authed ? "authed" : "unauthed";
107
+ return ` ${i + 1}. [${mark}] ${a.id} ${h?.version ?? "installed"} ${auth}`;
108
+ });
109
+ step = await ask(rl, `screen 1/4 — agent CLIs [number]=toggle [Enter]=next\n${adapterLines.join("\n")}\n> `);
110
+ if (/^q$/i.test(step))
111
+ return "fleet: quit without writing";
112
+ if (step && step !== "") {
113
+ const n = Number.parseInt(step, 10);
114
+ if (Number.isInteger(n) && n >= 1 && n <= installed.length) {
115
+ const id = installed[n - 1].id;
116
+ const idx = editable.denyAdapters.indexOf(id);
117
+ if (idx === -1)
118
+ editable.denyAdapters.push(id);
119
+ else
120
+ editable.denyAdapters.splice(idx, 1);
121
+ editable.denyAdapters.sort();
122
+ }
123
+ }
124
+ // screen 2 — models per enabled adapter
125
+ const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
126
+ for (const a of enabledAdapters) {
127
+ const classified = editable.tiers[a.id] ?? {};
128
+ const models = Object.keys(classified);
129
+ const unclassified = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
130
+ const rows = [];
131
+ let row = 1;
132
+ const indexFor = new Map();
133
+ for (const m of models) {
134
+ const tier = classified[m];
135
+ const denied = isDeniedModel(a.id, m, editable) ? "deny" : "allow";
136
+ const mark = denied === "deny" ? " " : "x";
137
+ rows.push(` ${row}. [${mark}] ${m} ${tier?.tier ?? "???"} ${denied === "deny" ? "denied" : "allowed"}`);
138
+ indexFor.set(String(row), { kind: "classified", model: m });
139
+ row++;
140
+ }
141
+ for (const u of unclassified) {
142
+ rows.push(` ${row}. ( ) ${u.model} ??? unclassified${u.detectedAt ? ` (${u.detectedAt})` : ""}`);
143
+ indexFor.set(String(row), { kind: "unclassified", model: u.model });
144
+ row++;
145
+ }
146
+ step = await ask(rl, `screen 2/4 — models · ${a.id} [number]=toggle deny [t <n> <tier>]=assign tier [Enter]=next\n${rows.join("\n")}\n> `);
147
+ if (/^q$/i.test(step))
148
+ return "fleet: quit without writing";
149
+ if (step.startsWith("t ")) {
150
+ const [, numRaw, tierRaw] = step.split(/\s+/);
151
+ const tier = tierRaw;
152
+ if (!indexFor.has(numRaw) || !TIERS.includes(tier)) {
153
+ return { out: "fleet: tier assignment requires a valid model number and tier (cheap|mid|frontier)", code: 1 };
154
+ }
155
+ const target = indexFor.get(numRaw);
156
+ if (target.kind !== "unclassified") {
157
+ return { out: "fleet: tier reassignment on classified models is not supported in v1 — edit config directly", code: 1 };
158
+ }
159
+ const note = await ask(rl, "benchmark provenance note (required): ");
160
+ if (!note.trim())
161
+ return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
162
+ editable.tiers[a.id] ??= {};
163
+ editable.tiers[a.id][target.model] = { tier, provenance: note.trim() };
164
+ }
165
+ else if (step && /^\d+$/.test(step)) {
166
+ const target = indexFor.get(step);
167
+ if (target?.kind === "classified") {
168
+ const key = `${a.id}:${target.model}`;
169
+ const idx = editable.denyModels.indexOf(key);
170
+ if (idx === -1)
171
+ editable.denyModels.push(key);
172
+ else
173
+ editable.denyModels.splice(idx, 1);
174
+ editable.denyModels.sort();
175
+ }
176
+ }
177
+ }
178
+ // screen 3 — per-shape routing
179
+ const channels = discoverChannels(cfg, adapters, health);
180
+ const profile = loadRoutingProfile(cwd, cfg, { preview: true });
181
+ const autoPrefer = readAutoPrefer(cwd);
182
+ const overlayShapes = overlayPreferShapes(cwd, { globalDir });
183
+ for (let si = 0; si < SHAPES.length; si++) {
184
+ const shape = SHAPES[si];
185
+ const entry = editable.map[shape] ?? {};
186
+ let now = "";
187
+ try {
188
+ const r = route(previewTask(shape), { ...cfg, routing: { ...cfg.routing, map: editable.map, floors: editable.floors } }, channels, profile);
189
+ now = `${r.provenance}\n → ${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier})`;
190
+ }
191
+ catch (e) {
192
+ now = e.message;
193
+ }
194
+ const autoNote = autoPrefer?.[shape] && !overlayShapes.has(shape)
195
+ ? `\n auto-prefer active — explicit prefer overrides it`
196
+ : "";
197
+ step = await ask(rl, `screen 3/4 — shape routing · ${shape} (${si + 1}/${SHAPES.length})\n now: ${now}\n [1] auto within tier [2] pin channel [3] edit tier [4] edit prefer [Enter]=keep${autoNote}\n> `);
198
+ if (/^q$/i.test(step))
199
+ return "fleet: quit without writing";
200
+ if (!step)
201
+ continue;
202
+ if (step === "1") {
203
+ const next = { ...entry };
204
+ delete next.pin;
205
+ editable.map[shape] = next;
206
+ }
207
+ else if (step === "2") {
208
+ const pin = await ask(rl, "pin adapter:model> ");
209
+ const i = pin.indexOf(":");
210
+ if (i < 1)
211
+ return { out: "fleet: pin must be adapter:model", code: 1 };
212
+ editable.map[shape] = { pin: { via: pin.slice(0, i), model: pin.slice(i + 1) } };
213
+ }
214
+ else if (step === "3") {
215
+ const tier = await ask(rl, `tier (${TIERS.join("|")})> `);
216
+ if (!TIERS.includes(tier))
217
+ return { out: `fleet: invalid tier ${tier}`, code: 1 };
218
+ const next = { ...entry, tier };
219
+ delete next.pin;
220
+ editable.map[shape] = next;
221
+ }
222
+ else if (step === "4") {
223
+ const pref = await ask(rl, "prefer (comma-separated adapters or adapter:model)> ");
224
+ const prefer = pref.split(",").map((s) => s.trim()).filter(Boolean);
225
+ editable.map[shape] = { ...entry, prefer };
226
+ }
227
+ }
228
+ // review — unified diff + confirm
229
+ const before = currentRepoOverlayText(cwd);
230
+ const after = proposedOverlayText(initial, editable, cwd);
231
+ if (fleetEditableEquals(initial, editable) || before === after) {
232
+ return "fleet: no overlay changes (empty diff)";
233
+ }
234
+ const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
235
+ process.stdout.write(`\n${diff}\n`);
236
+ const confirm = await ask(rl, "write overlay? [y/N] ");
237
+ if (!/^y(?:es)?$/i.test(confirm))
238
+ return "fleet: discarded overlay changes";
239
+ const merged = fleetRepoOverlayFromDelta(initial, editable, readOverlayFile(repoOverlayPath(cwd)));
240
+ const body = repoOverlayYaml(merged, provenanceMap(editable));
241
+ const path = repoOverlayPath(cwd);
242
+ mkdirSync(dirname(path), { recursive: true });
243
+ writeFileSync(path, body);
244
+ return `fleet: wrote ${path}`;
245
+ }
246
+ finally {
247
+ rl.close();
248
+ }
249
+ }
@@ -1,5 +1,5 @@
1
1
  import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
- import { formatModelAuthLine, modelLints, ttyVisual } from "../../adapters/model-lints.js";
2
+ import { formatModelAuthLine, contextWindowLints, 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";
@@ -71,9 +71,11 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
71
71
  lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
72
72
  }
73
73
  let cost = 0;
74
+ const routed = [];
74
75
  for (const t of g.tasks) {
75
76
  try {
76
77
  const r = route(t, cfg, channels, profile);
78
+ routed.push({ taskId: t.id, adapter: r.assignment.adapter, model: r.assignment.model });
77
79
  for (const l of r.lints) {
78
80
  const suf = exclusionReason(l);
79
81
  lints.push(suf ? `${l}${suf}` : l);
@@ -104,6 +106,10 @@ export async function plan(_argv, cwd = process.cwd(), adapters = allAdapters())
104
106
  }
105
107
  if (lints.length)
106
108
  lines.push("", "routing lints:", ...lints.map((l) => ` ! ${l}`));
109
+ // v1.47 T3: advisory only — never blocks plan or --route-strict (run refuses on route().lints only)
110
+ const windowLints = contextWindowLints(g.tasks, routed, cfg, cwd);
111
+ if (windowLints.length)
112
+ lines.push("", "context window lints:", ...windowLints.map((l) => ` ! ${l}`));
107
113
  // OBS-12/13/14/21: advisory only — never blocks --route-strict (run refuses on routing lints only)
108
114
  const scopeLints = collateralLints(g.tasks, cwd);
109
115
  if (scopeLints.length)