@modelstatus/cli 0.1.86 → 0.1.88

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
@@ -75,6 +75,20 @@ place. Boundary-safe (`gpt-4` never rewrites inside `gpt-4o`), style-preserving
75
75
  it follows the chain to the first live model. In the TUI, press `f` for a
76
76
  red/green diff preview; nothing is written until you confirm.
77
77
 
78
+ ### Free: or hand the fixes to your AI coding agent
79
+
80
+ ```bash
81
+ mm prompt [dir] # print a fix-it prompt for Claude Code, Cursor, …
82
+ mm prompt [dir] | pbcopy # straight to your clipboard
83
+ ```
84
+
85
+ Prints one self-contained prompt: every retired/retiring reference (file:line +
86
+ the exact string in code), the chain-resolved replacement, and the rewrite
87
+ rules. Paste it into your agent and let it do the work — unlike `mm fix`, an
88
+ agent can also re-pin dated ids (`-20250514`), follow config indirection, and
89
+ adjust parameters the new model needs. In the TUI, press `p` on the Here or
90
+ Inventory tab to copy the same prompt to your clipboard.
91
+
78
92
  ### Sign in for cloud features
79
93
 
80
94
  ```bash
@@ -92,6 +106,7 @@ You get two binaries — `mm` (short) and `llmstatus` (descriptive). Same binary
92
106
  |---|---|
93
107
  | `mm status [dir]` | Free offline model-health check — no account |
94
108
  | `mm fix [dir]` | Rewrite dying model ids to their replacement (`--dry-run` to preview) |
109
+ | `mm prompt [dir]` | Print a fix-it prompt for your AI coding agent (`mm prompt \| pbcopy`) |
95
110
  | `mm [dir]` | Launch the TUI on a folder (defaults to the current one) — runs locally |
96
111
  | `mm update` | Update the binary in place (Homebrew installs: `brew upgrade`) |
97
112
  | `mm login [api_key]` | Browser sign-in with polling (or paste a key) |
@@ -125,6 +140,7 @@ Secret sources shell out to your already-authenticated CLIs, run **read-only**,
125
140
  | Resolve + health locally, on-device | ✓ | ✓ |
126
141
  | Secret-source aware (`env`, `aws-secrets`, `k8s`, `helm`, `sql`) | ✓ | ✓ |
127
142
  | `mm fix` — rewrite dying ids to replacements | ✓ | ✓ |
143
+ | `mm prompt` — fix-it prompt for your AI agent | ✓ | ✓ |
128
144
  | Cloud inventory across projects/teams | — | ✓ |
129
145
  | GitHub App: PR checks + one-click fix PRs | — | ✓ |
130
146
  | Alerts on deprecations/retirements (email/Slack/SMS) | — | ✓ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "homepage": "https://llmstatus.ai",
24
24
  "bugs": {
25
- "email": "it@llmstatus.ai"
25
+ "url": "https://github.com/randomartifact/llm-status/issues",
26
+ "email": "hi@llmstatus.ai"
26
27
  },
27
28
  "author": "LLM Status <it@llmstatus.ai>",
28
29
  "license": "MIT",
@@ -51,5 +52,9 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "ink-testing-library": "^4.0.0"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/randomartifact/llm-status.git"
54
59
  }
55
60
  }
package/src/api.js CHANGED
@@ -1,14 +1,35 @@
1
1
  /** Thin client for the LLM Status / Model Manager public API (/api/v1). */
2
+
3
+ // Per-request cap. Without one, a connection that stalls after the TCP accept
4
+ // (captive portal, blackholing proxy) holds a command for undici's ~5-minute
5
+ // default — or forever if the server dribbles bytes. Uploads get a longer
6
+ // leash (big inventories on slow links). Overridable for tests.
7
+ const timeoutMs = () => Number(process.env.MM_API_TIMEOUT_MS) || 15_000;
8
+ const uploadTimeoutMs = () => Number(process.env.MM_API_UPLOAD_TIMEOUT_MS) || Number(process.env.MM_API_TIMEOUT_MS) || 60_000;
9
+
10
+ const isTimeout = (e) => e?.name === "TimeoutError" || e?.name === "AbortError" || e?.code === "ABORT_ERR" || e?.cause?.name === "TimeoutError";
11
+
2
12
  export function createClient({ apiBase, apiKey }) {
3
- async function req(method, pathname, body) {
4
- const res = await fetch(`${apiBase}/api/v1${pathname}`, {
5
- method,
6
- headers: {
7
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
8
- ...(body ? { "content-type": "application/json" } : {}),
9
- },
10
- body: body ? JSON.stringify(body) : undefined,
11
- });
13
+ async function req(method, pathname, body, { timeout = timeoutMs() } = {}) {
14
+ let res;
15
+ try {
16
+ res = await fetch(`${apiBase}/api/v1${pathname}`, {
17
+ method,
18
+ headers: {
19
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
20
+ ...(body ? { "content-type": "application/json" } : {}),
21
+ },
22
+ body: body ? JSON.stringify(body) : undefined,
23
+ signal: AbortSignal.timeout(timeout),
24
+ });
25
+ } catch (e) {
26
+ if (isTimeout(e)) {
27
+ const err = new Error(`request timed out — check your network (${method} ${pathname} @ ${apiBase})`);
28
+ err.code = "ETIMEDOUT";
29
+ throw err;
30
+ }
31
+ throw e;
32
+ }
12
33
  const text = await res.text();
13
34
  let data;
14
35
  try {
@@ -18,7 +39,16 @@ export function createClient({ apiBase, apiKey }) {
18
39
  }
19
40
  if (!res.ok) {
20
41
  const msg = data?.error?.message || data?.message || `HTTP ${res.status}`;
21
- const err = new Error(`${method} ${pathname} ${msg}`);
42
+ // Auth failures get a re-login hint instead of a raw endpoint error —
43
+ // a revoked/rotated key is the most common cloud failure. 403 (valid key,
44
+ // insufficient scope/permissions) keeps the server's explanation.
45
+ const err = new Error(
46
+ res.status === 401
47
+ ? "API key invalid or expired — run `mm login` (or pass --key)"
48
+ : res.status === 403
49
+ ? `${msg} — run \`mm login\` to sign in with a different key`
50
+ : `${method} ${pathname} → ${msg}`,
51
+ );
22
52
  err.status = res.status;
23
53
  err.code = data?.error?.code;
24
54
  err.body = data;
@@ -62,7 +92,7 @@ export function createClient({ apiBase, apiKey }) {
62
92
  patchUsage: (id, body) => req("PATCH", `/usages/${id}`, body),
63
93
  deleteUsage: (id) => req("DELETE", `/usages/${id}`),
64
94
  linkUsage: (id, modelId) => req("POST", `/usages/${id}/link`, { model_id: modelId }),
65
- bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }),
95
+ bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }, { timeout: uploadTimeoutMs() }),
66
96
  // Clear inventory: all usages, or `{ all: true }` to also wipe projects + rules + feed.
67
97
  clearUsages: ({ all = false, project } = {}) => req("DELETE", `/usages${qs({ all: all ? "true" : undefined, project_id: project })}`),
68
98
 
@@ -1,6 +1,31 @@
1
1
  /* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
2
2
  * Release notes baked into the binary (in-TUI + the on-load what's-new card). */
3
3
  export const CHANGELOG = [
4
+ {
5
+ "version": "0.1.88",
6
+ "date": "2026-08-22",
7
+ "title": "Hand the fixes to your AI coding agent",
8
+ "items": [
9
+ "New `mm prompt [dir]`: prints one self-contained prompt describing every retired/retiring model reference in your repo (file:line, the exact string in code, the chain-resolved replacement) plus rewrite rules. Paste it into Claude Code, Cursor, or any coding agent — or pipe it straight to your clipboard: `mm prompt | pbcopy`.",
10
+ "Unlike `mm fix` (a mechanical in-place rewrite), an agent can also re-pin dated ids like `-20250514`, follow config indirection, and adjust parameters the new model needs — the prompt spells out those rules for it.",
11
+ "TUI: press `p` on the Here tab or the Inventory tab to copy the same prompt to your clipboard (with a file fallback when no clipboard tool exists). On the Inventory tab the `/` filter scopes it — search `prod`, press `p`, get a prod-only prompt.",
12
+ "`mm status` and `mm fix` now point at `mm prompt` when there's something an auto-rewrite can't safely touch (like version-pinned ids)."
13
+ ]
14
+ },
15
+ {
16
+ "version": "0.1.87",
17
+ "date": "2026-07-07",
18
+ "title": "Hardening pass: 40 fixes from an adversarial audit",
19
+ "items": [
20
+ "`mm fix` now writes the provider’s real model id (`gpt-5.5`), not the registry’s dash-form slug (`gpt-5-5`) — 59 of 85 replacement targets were affected. It also fixes everything the scanner detects (Bedrock ARNs, dated variants, mixed case), preserves your file’s line endings, and refuses to touch files that aren’t valid UTF-8.",
21
+ "The CI gate can’t pass by accident anymore: `--fail-on=retiring` (equals-style flags) now parses, a typo’d `--sources` or a path that doesn’t exist is a hard error instead of a silent green build, and withdrawn models fail the gate like retired ones. Unknown flags error out instead of being ignored.",
22
+ "`mm scan --dry-run` is fully local — no account needed, nothing sent anywhere.",
23
+ "Every network call has a timeout: a stalled CDN or API can’t hang a command for minutes. Failed self-updates back off instead of re-downloading each run, and self-update works on Windows.",
24
+ "Vendor CLI failures (aws, vercel, supabase…) warn instead of silently reporting a clean scan. `.github/workflows` files are scanned now, and `--vercel-project` verifies the linked project instead of relabeling output.",
25
+ "Telemetry no longer includes your working directory path — anonymous event names only, as documented.",
26
+ "TUI: long alerts lists scroll properly (keys can’t act on an off-screen rule, delete asks first), the arcade game can’t strand your terminal or undo a sign-in, and checkout polling stops when you quit."
27
+ ]
28
+ },
4
29
  {
5
30
  "version": "0.1.86",
6
31
  "date": "2026-06-16",
package/src/ci.js CHANGED
@@ -9,10 +9,14 @@ import { getRegistry } from "./registry/fetch.js";
9
9
  import { resolveLocal, computeHealth } from "./registry/local.js";
10
10
  import { collectFrom } from "./sources/index.js";
11
11
 
12
- export const HEALTH_RANK = { ok: 0, deprecating: 1, retiring: 2, retired: 3 };
12
+ // "withdrawn" (the provider already pulled the model) is as dead as retired
13
+ // same rank, mirroring tui/scan-stream.js — so it fails every threshold except
14
+ // "none". Omitting it here once meant `undefined >= threshold` and withdrawn
15
+ // models could NEVER fail the build.
16
+ export const HEALTH_RANK = { ok: 0, deprecating: 1, retiring: 2, retired: 3, withdrawn: 3 };
13
17
  // `--fail-on` threshold → the minimum health rank that fails the build.
14
18
  export const FAIL_THRESHOLD = { none: 99, deprecating: 1, retiring: 2, retired: 3 };
15
- const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴" };
19
+ const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔" };
16
20
 
17
21
  /** Evaluate `dir` and return { findings, failing, threshold, failOn, counts, snapshot }.
18
22
  * `findings` are per-(model, location) entries with health worse than ok. */
@@ -54,7 +58,7 @@ export async function evaluateCi({ dir, sources = ["filesystem"], explicit = new
54
58
  }
55
59
  findings.sort((a, b) => HEALTH_RANK[b.health] - HEALTH_RANK[a.health] || String(a.retires || "9999").localeCompare(String(b.retires || "9999")));
56
60
 
57
- const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0 };
61
+ const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, withdrawn: 0 };
58
62
  for (const f of findings) counts[f.health]++;
59
63
  const failing = findings.filter((f) => HEALTH_RANK[f.health] >= threshold);
60
64
  return { snapshot, candidates, findings, failing, threshold, failOn, counts };
@@ -126,7 +130,7 @@ export function annotationLines(findings, threshold) {
126
130
  export function summaryMarkdown(findings, { failing, failOn } = {}) {
127
131
  const lines = ["## LLM Status — model lifecycle check", ""];
128
132
  if (!findings.length) {
129
- lines.push("✅ No deprecated, retiring, or retired AI models found.");
133
+ lines.push("✅ No deprecated, retiring, retired, or withdrawn AI models found.");
130
134
  return lines.join("\n") + "\n";
131
135
  }
132
136
  const fc = failing ? failing.length : 0;
@@ -0,0 +1,42 @@
1
+ /* Deliver generated text from inside the TUI, where stdout belongs to ink:
2
+ * copy to the system clipboard via the platform's native tool, or — when no
3
+ * clipboard tool exists (headless, bare containers) — write a file and hand
4
+ * back its path. Best-effort and synchronous (the payloads are a few KB).
5
+ * MM_NO_CLIPBOARD=1 skips the copy (tests + users who hate surprise copies);
6
+ * MM_PROMPT_DIR redirects the fallback file (tests). */
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ const CANDIDATES = process.platform === "darwin"
13
+ ? [["pbcopy", []]]
14
+ : process.platform === "win32"
15
+ ? [["clip", []]]
16
+ : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
17
+
18
+ /** True when the text landed on the system clipboard. */
19
+ export function copyToClipboard(text) {
20
+ if (process.env.MM_NO_CLIPBOARD === "1") return false;
21
+ for (const [cmd, args] of CANDIDATES) {
22
+ try {
23
+ const r = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"], timeout: 3000 });
24
+ if (r.status === 0) return true;
25
+ } catch { /* tool missing — try the next */ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /** Clipboard first, file fallback. Returns { method: "clipboard" } or
31
+ * { method: "file", path } — never throws (a failed file write returns
32
+ * { method: "none" } so the caller can say so instead of crashing the TUI). */
33
+ export function deliverText(text, { filename = "mm-fix-prompt.md" } = {}) {
34
+ if (copyToClipboard(text)) return { method: "clipboard" };
35
+ try {
36
+ const p = path.join(process.env.MM_PROMPT_DIR || os.tmpdir(), filename);
37
+ fs.writeFileSync(p, text);
38
+ return { method: "file", path: p };
39
+ } catch {
40
+ return { method: "none" };
41
+ }
42
+ }
@@ -16,21 +16,22 @@ function cleanGeneric(s) {
16
16
 
17
17
  /** A char is part of a model token if it's alnum or one of - _ . / : (slug-ish).
18
18
  * Mirrors the server PR scanner (apps/web/lib/github/scan-pr.ts) so the CLI and
19
- * the GitHub check agree on what counts as a boundary. */
20
- function isTokenChar(ch) {
19
+ * the GitHub check agree on what counts as a boundary. Exported so fix.js keeps
20
+ * its rewrite boundaries in lockstep with detection. */
21
+ export function isTokenChar(ch) {
21
22
  return /[A-Za-z0-9._/:-]/.test(ch);
22
23
  }
23
24
 
24
25
  // Provider prefixes legitimately precede an id ("anthropic.claude-…", "ft:gpt-…",
25
26
  // "us.anthropic.…", "openrouter/…"), so '.' ':' '/' on the LEFT is still a boundary.
26
- function isPrefixSep(ch) {
27
+ export function isPrefixSep(ch) {
27
28
  return ch === "." || ch === ":" || ch === "/";
28
29
  }
29
30
  // Known model-id SUFFIXES seen in real configs: Bedrock ':0'/'-v1', dated
30
31
  // '-20250514' snapshots, '@version'. The remainder starting with one is still a
31
32
  // boundary — so "claude-opus-4-20250514" resolves inside a Bedrock ARN, while
32
33
  // "gpt-4" still does NOT match inside "gpt-4o". Kept identical to scan-pr.ts.
33
- const MODEL_SUFFIX = /^(:|-v[0-9]|-[0-9]{6,}|@)/;
34
+ export const MODEL_SUFFIX = /^(:|-v[0-9]|-[0-9]{6,}|@)/;
34
35
 
35
36
  /** True when `term` occurs in `haystack` at a model-id boundary — tolerating
36
37
  * provider prefixes + known version/region/snapshot suffixes, but NOT a plain
@@ -135,8 +136,18 @@ export function detectInLine(line, compiled) {
135
136
  re.lastIndex = 0;
136
137
  let m;
137
138
  while ((m = re.exec(line))) {
138
- const cand = cleanGeneric(m[0].toLowerCase());
139
- if (looksLikeModel(cand)) found.add(cand);
139
+ // Left boundary — same rule as matchesAtBoundary. A token char right
140
+ // before the match means we're inside a longer identifier ("ChatGPT-4",
141
+ // "ngrok-2.3.40", "my-gpt-4-deployment"), not a model id; provider
142
+ // prefixes ('.' ':' '/') stay legal ("openai/gpt-4", "ft:gpt-4",
143
+ // "anthropic.claude-…"). Hyphen/underscore-joined prefixes are treated
144
+ // as compound identifiers (resource/deployment names), matching the
145
+ // exact-string matcher. (scan-pr.ts should mirror this — see web repo.)
146
+ const before = m.index > 0 ? line[m.index - 1] : "";
147
+ if (!before || !isTokenChar(before) || isPrefixSep(before)) {
148
+ const cand = cleanGeneric(m[0].toLowerCase());
149
+ if (looksLikeModel(cand)) found.add(cand);
150
+ }
140
151
  if (re.lastIndex === m.index) re.lastIndex++;
141
152
  }
142
153
  }
@@ -0,0 +1,106 @@
1
+ /* Build a copy-paste prompt for an AI coding agent (Claude Code, Cursor, …) to
2
+ * fix dying model references. `mm fix` is the mechanical path — boundary-safe
3
+ * string swaps at known file:lines — but it deliberately skips what a dumb
4
+ * rewrite can't do safely: version-pinned ids, config indirection, parameter
5
+ * changes the new model needs. An agent CAN do those, so the prompt hands it
6
+ * the full picture: every dying reference (file:line + the exact string), the
7
+ * chain-resolved replacement, and the rewrite rules fix.js enforces.
8
+ *
9
+ * Used by `mm prompt` (stdout), the Here tab (`p`) and the Inv tab (`p`).
10
+ * Pure text-building — no I/O, no registry access — so every surface renders
11
+ * the identical prompt and tests stay hermetic.
12
+ */
13
+
14
+ // Same severity order the TUI/status sort by: already-dead first, then aging.
15
+ const TIER = { retired: 0, withdrawn: 0, retiring: 1, deprecating: 2 };
16
+
17
+ const REFS_CAP = 100; // per model — an agent doesn't need ref #101 to get it
18
+
19
+ /** One reference line: "- path:line — currently `gpt-4o`" (repo-prefixed when
20
+ * the ref came from another checkout, so a cross-repo inventory stays honest). */
21
+ function refLine(r) {
22
+ const loc = `${r.repo ? r.repo + " · " : ""}${r.file}${r.line ? ":" + r.line : ""}`;
23
+ return `- ${loc}${r.matched ? ` — currently \`${r.matched}\`` : ""}`;
24
+ }
25
+
26
+ /** Dedupe a model's refs (same file:line:string appears once; at one file:line
27
+ * a matched string that's a substring of a sibling's is dropped — the detector
28
+ * can emit both "gpt-4o" and "openai/gpt-4o" for the same spot). */
29
+ function dedupeRefs(refs) {
30
+ const seen = new Map();
31
+ for (const r of refs || []) {
32
+ if (!r?.file) continue;
33
+ const key = `${r.repo || ""}|${r.file}:${r.line ?? ""}:${(r.matched || "").toLowerCase()}`;
34
+ if (!seen.has(key)) seen.set(key, r);
35
+ }
36
+ const list = [...seen.values()];
37
+ return list.filter(
38
+ (r) => !list.some(
39
+ (q) => q !== r && q.file === r.file && q.line === r.line && (q.repo || "") === (r.repo || "")
40
+ && q.matched && r.matched && q.matched.toLowerCase() !== r.matched.toLowerCase()
41
+ && q.matched.toLowerCase().includes(r.matched.toLowerCase()),
42
+ ),
43
+ );
44
+ }
45
+
46
+ /** Human health phrase for a section header. */
47
+ function healthPhrase(f) {
48
+ if (f.health === "retired") return f.retires_date ? `retired ${f.retires_date}` : "retired";
49
+ if (f.health === "withdrawn") return "withdrawn by the provider";
50
+ return `${f.health}${f.retires_date ? `, retires ${f.retires_date}` : ""}`;
51
+ }
52
+
53
+ /**
54
+ * Render the prompt. `findings` = [{ slug, display, health, retires_date,
55
+ * replacement, refs: [{ file, line, matched, repo? }] }] where `replacement`
56
+ * is the write-form target ("provider/canonical_id", terminalReplacement's
57
+ * output) or null when the registry has no successor yet. Findings with zero
58
+ * usable refs are dropped; returns null when nothing remains.
59
+ */
60
+ export function buildFixPrompt(findings, { date = new Date() } = {}) {
61
+ const rows = (findings || [])
62
+ .map((f) => ({ ...f, refs: dedupeRefs(f.refs) }))
63
+ .filter((f) => f.refs.length)
64
+ .sort((a, b) => (TIER[a.health] ?? 3) - (TIER[b.health] ?? 3)
65
+ || String(a.retires_date || "9999").localeCompare(String(b.retires_date || "9999"))
66
+ || String(a.slug || a.display).localeCompare(String(b.slug || b.display)));
67
+ if (!rows.length) return null;
68
+
69
+ const day = date.toISOString().slice(0, 10);
70
+ const nRefs = rows.reduce((n, f) => n + f.refs.length, 0);
71
+ const out = [];
72
+ out.push("# Task: update retired / retiring AI model references");
73
+ out.push("");
74
+ out.push(`This codebase references ${rows.length === 1 ? "an AI model" : `${rows.length} AI models`} that ${rows.length === 1 ? "is" : "are"} retired or scheduled for retirement (checked against the LLM Status registry on ${day}). Update every reference listed below — ${nRefs} in total.`);
75
+ out.push("");
76
+ out.push("## Models to replace");
77
+
78
+ for (const f of rows) {
79
+ const name = f.display && f.display !== f.slug ? `${f.display} (\`${f.slug}\`)` : `\`${f.slug || f.display}\``;
80
+ out.push("");
81
+ out.push(`### ${name} — ${healthPhrase(f)}`);
82
+ if (f.replacement) {
83
+ const i = f.replacement.indexOf("/");
84
+ const bare = i >= 0 ? f.replacement.slice(i + 1) : f.replacement;
85
+ out.push(bare !== f.replacement
86
+ ? `Replace with: \`${bare}\` (provider-prefixed form: \`${f.replacement}\`).`
87
+ : `Replace with: \`${f.replacement}\`.`);
88
+ } else {
89
+ out.push("No registry replacement is listed yet — pick the provider's closest current successor, apply it consistently, and note the choice in your summary.");
90
+ }
91
+ out.push("References:");
92
+ for (const r of f.refs.slice(0, REFS_CAP)) out.push(refLine(r));
93
+ if (f.refs.length > REFS_CAP) out.push(`- …and ${f.refs.length - REFS_CAP} more (rewrite every occurrence of the old id, not just the lines listed)`);
94
+ }
95
+
96
+ out.push("");
97
+ out.push("## Rules");
98
+ out.push("");
99
+ out.push("1. Preserve each call site's existing style: keep a bare id bare (`claude-sonnet-4-5`) and a provider-prefixed id prefixed (`anthropic/claude-sonnet-4-5`); don't change quoting or surrounding formatting.");
100
+ out.push("2. Version-pinned ids (dated like `-20250514`, `@`-pins, Bedrock `-v1:0`, fine-tune `:suffix`): switch to the replacement model's own current id — pinned only if the platform requires a pin. Never graft the old model's version pin onto the new id.");
101
+ out.push("3. If a model id comes from a config file, env var, constant, or database row, update it at the source — not just the call sites listed above.");
102
+ out.push("4. Review adjacent parameters that may need adjusting for the new model (max output tokens, temperature constraints, flags the old model needed).");
103
+ out.push("5. When you're done, run the project's tests, then run `mm status` (the LLM Status CLI, https://llmstatus.ai) to confirm no retired or retiring references remain.");
104
+ out.push("");
105
+ return out.join("\n");
106
+ }