@azure-id/orc 1.8.2 → 1.9.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README-id.md +68 -51
  3. package/README.md +60 -45
  4. package/bin/cli.js +301 -39
  5. package/bin/graph-audit.js +235 -0
  6. package/bin/graph-extract.js +308 -3
  7. package/bin/graph-gain.js +23 -3
  8. package/bin/graph-map.js +9 -1
  9. package/bin/graph-query.js +152 -10
  10. package/bin/graph-shard.js +6 -1
  11. package/bin/graph-signals.js +287 -4
  12. package/bin/graph.js +717 -658
  13. package/bin/verify-contracts.js +277 -61
  14. package/bin/verify-package.js +19 -1
  15. package/bin/webui/fixtures/knowledge.js +21 -4
  16. package/bin/webui/i18n/en/knowledge.json +5 -2
  17. package/bin/webui/i18n/id/knowledge.json +5 -2
  18. package/bin/webui/js/panels/knowledge.js +21 -2
  19. package/mock-run/orc-quick.md +141 -113
  20. package/package.json +1 -1
  21. package/templates/agents/MODEL-MAPPING.md +15 -5
  22. package/templates/agents/orc-executor-haiku-4-5.md +19 -1
  23. package/templates/agents/orc-executor-opus-4-7-high.md +19 -1
  24. package/templates/agents/orc-executor-opus-4-7-med.md +19 -1
  25. package/templates/agents/orc-executor-opus-4-8-high.md +19 -1
  26. package/templates/agents/orc-executor-opus-5-high.md +19 -1
  27. package/templates/agents/orc-executor-opus-5-low.md +19 -1
  28. package/templates/agents/orc-executor-opus-5-med.md +19 -1
  29. package/templates/agents/orc-executor-sonnet-4-6-high.md +19 -1
  30. package/templates/agents/orc-executor-sonnet-4-6-med.md +19 -1
  31. package/templates/agents/orc-executor-sonnet-5-high.md +19 -1
  32. package/templates/agents/orc-planner-mini-opus-5-med.md +75 -69
  33. package/templates/agents/orc-planner-mini-sonnet-5-high.md +73 -67
  34. package/templates/agents/orc-recon-opus-5-low.md +109 -0
  35. package/templates/agents/orc-recon-sonnet-4-6-med.md +109 -0
  36. package/templates/commands/orc-mini.md +10 -12
  37. package/templates/commands/orc-quick.md +20 -33
  38. package/templates/hooks/README.md +11 -1
  39. package/templates/hooks/orc-graph-hook.js +53 -7
  40. package/templates/hooks/orc-trace.js +476 -471
  41. package/templates/skills/_shared/code-graph.md +56 -17
  42. package/templates/skills/_shared/phases/execution.md +4 -4
  43. package/templates/skills/_shared/phases/planning.md +3 -3
  44. package/templates/skills/_shared/phases/preflight.md +1 -1
  45. package/templates/skills/_shared/phases/review.md +3 -1
  46. package/templates/skills/_shared/phases/rules.md +172 -159
  47. package/templates/skills/_shared/phases/ship.md +1 -1
  48. package/templates/skills/_shared/phases/trace.md +4 -2
  49. package/templates/skills/_shared/phases/wiki-consult.md +9 -5
  50. package/templates/skills/_shared/read-ladder.md +14 -1
  51. package/templates/skills/_shared/return-validation.md +22 -0
  52. package/templates/skills/context-combiner/SKILL.md +13 -13
  53. package/templates/skills/orc/SKILL.md +1 -1
  54. package/templates/skills/orc/subskills/orc-execution/core.md +171 -159
  55. package/templates/skills/orc-fast/SKILL.md +3 -3
  56. package/templates/skills/orc-mini/SKILL.md +145 -137
  57. package/templates/skills/orc-mini/examples/mini-run-mock.md +64 -50
  58. package/templates/skills/orc-mini/references/complexity.md +119 -0
  59. package/templates/skills/orc-quick/README.md +495 -423
  60. package/templates/skills/orc-quick/SKILL.md +157 -212
  61. package/templates/skills/orc-quick/references/context-doc.md +145 -114
  62. package/templates/skills/orc-quick/references/defect.md +101 -0
  63. package/templates/skills/orc-quick/references/dispatch-gate.md +55 -24
  64. package/templates/skills/orc-quick/references/gh-mode.md +148 -127
  65. package/templates/skills/orc-quick/references/look.md +107 -0
package/bin/graph-gain.js CHANGED
@@ -254,6 +254,13 @@ function stampMs(at) {
254
254
  return null;
255
255
  }
256
256
 
257
+ // M2 (v1.9.1): the reads a project COULD have made. `never_called` is
258
+ // `READ_SET` minus what the ledger holds, for the scope asked about. It is a
259
+ // fact and it carries no advice: a graph is not wrong for holding an answer
260
+ // nobody asked for, and this file does not tell anyone to start asking.
261
+ // `hint`, `read-note` and `hook-update` are the hook's rows, not reads.
262
+ const READ_SET = ["ctx", "for-slice", "impact", "map", "changes", "cochange", "coverage", "path"];
263
+
257
264
  function gain(claudeDir, opts) {
258
265
  const o = opts || {};
259
266
  const all = readLedger(claudeDir);
@@ -267,11 +274,19 @@ function gain(claudeDir, opts) {
267
274
  });
268
275
  if (!rows.length) return { ok: false, state: "none", reason: o.run ? "no-rows-for-run" : "no-rows", exit: 1 };
269
276
 
270
- const paid = { card: 0, source: 0, hints: 0 };
277
+ // B3 (v1.9.1): `envelope` is everything a `--json` answer carried BEYOND its
278
+ // card — the row arrays, the scalars, the punctuation. Measured on this tree
279
+ // it was 4 to 8 times the card, and until now the meter called the card the
280
+ // whole cost. A row written before 1.9.1, and every row the hook writes, has
281
+ // no envelope and sums as 0.
282
+ const paid = { card: 0, source: 0, hints: 0, envelope: 0 };
271
283
  const avoided = { low: 0, high: 0 };
272
284
  const calls = { low: 0, high: 0 };
273
285
  const byCommand = {};
274
- const hints = { injected: 0, read_notes: 0, updates: 0 };
286
+ // R2 (v1.9.1): `wide_unhinted` is a COUNT of whole-file reads of wide files
287
+ // that the `on,read` hint would have named ranges for. It put nothing into
288
+ // any context, so it prices at zero; it is the number DE-8 waits for.
289
+ const hints = { injected: 0, read_notes: 0, updates: 0, wide_unhinted: 0 };
275
290
  // W8: rows the sharded read path could not size. Their estimate omits the
276
291
  // search term, so a FALLING estimate can be told apart from a falling saving.
277
292
  let unsized = 0;
@@ -283,6 +298,7 @@ function gain(claudeDir, opts) {
283
298
  paid.card += Number(r.paid.card) || 0;
284
299
  paid.source += Number(r.paid.source) || 0;
285
300
  paid.hints += Number(r.paid.hints) || 0;
301
+ paid.envelope += Number(r.paid.envelope) || 0;
286
302
  if (used) {
287
303
  avoided.low += Number(r.avoided.low) || 0;
288
304
  avoided.high += Number(r.avoided.high) || 0;
@@ -294,10 +310,11 @@ function gain(claudeDir, opts) {
294
310
  if (r.cmd === "hint") hints.injected++;
295
311
  if (r.cmd === "read-note") hints.read_notes++;
296
312
  if (r.cmd === "hook-update") hints.updates++;
313
+ if (r.cmd === "wide-unhinted") hints.wide_unhinted++;
297
314
  if (r.gen) gens.push(Number(r.gen));
298
315
  if (r.run) runs.add(r.run);
299
316
  }
300
- const paidTotal = paid.card + paid.source + paid.hints;
317
+ const paidTotal = paid.card + paid.source + paid.hints + paid.envelope;
301
318
  gens.sort((a, b) => a - b);
302
319
  return {
303
320
  ok: true,
@@ -313,6 +330,8 @@ function gain(claudeDir, opts) {
313
330
  calls,
314
331
  net: { low: avoided.low - paidTotal, high: avoided.high - paidTotal },
315
332
  by_command: byCommand,
333
+ read_set: READ_SET,
334
+ never_called: READ_SET.filter((c) => !Object.prototype.hasOwnProperty.call(byCommand, c)),
316
335
  hints,
317
336
  unsized,
318
337
  estimate: true,
@@ -373,6 +392,7 @@ function deltaPct(on, off) {
373
392
  }
374
393
 
375
394
  module.exports = {
395
+ READ_SET,
376
396
  MEASURED_MIN_RUNS,
377
397
  median,
378
398
  summarise,
package/bin/graph-map.js CHANGED
@@ -149,7 +149,15 @@ function multiplier(model, rel) {
149
149
  let m = 1;
150
150
  if (Q.TEST_FILE.test(rel)) m *= 0.1;
151
151
  const syms = (model.byFile[rel].symbols || []).filter((s) => s.kind !== "module");
152
- if (syms.length && !syms.some((s) => s.exported)) m *= 0.5;
152
+ // A4 (v1.9.1): a file with NO symbol is as far from "a place behaviour lives"
153
+ // as one whose symbols are all private, and it is knocked down the same way.
154
+ // Measured on a real front-end project, 16 of the 26 rows a `--budget 600`
155
+ // map printed named a file with nothing in it — a ranking that sends a
156
+ // planner to a file the graph cannot answer a question about.
157
+ //
158
+ // It is a READ-TIME rank, not a stored one: `map.json` holds the edges and
159
+ // the base ranking and is untouched, so nothing is re-extracted for this.
160
+ if (!syms.length || !syms.some((s) => s.exported)) m *= 0.5;
153
161
  return m;
154
162
  }
155
163
 
@@ -340,6 +340,13 @@ function classSymbol(model, rel, name) {
340
340
  const s = ((model.byFile[t.file] || {}).symbols || []).find((x) => x.kind === "class" && x.name === t.name);
341
341
  if (s) return { sym: s, state: "IMPORT" };
342
342
  }
343
+ // W5b: a DEFAULT import names the file's default export, whatever the
344
+ // importer calls it (`import FM from "./formMixin"`). When that file holds
345
+ // exactly one exported class, it is the one.
346
+ if (bd.orig === "default") {
347
+ const only = ((model.byFile[bd.files[0]] || {}).symbols || []).filter((x) => x.kind === "class" && x.exported);
348
+ if (only.length === 1) return { sym: only[0], state: "IMPORT" };
349
+ }
343
350
  }
344
351
  if (f.lang === "go") {
345
352
  const s = filesInDir(model, posix.dirname(rel), ".go").flatMap((x) => ((model.byFile[x] || {}).symbols || []).filter((y) => y.kind === "class" && y.name === name));
@@ -840,6 +847,12 @@ function warmBlobs(model, queries) {
840
847
  if (t.kind === "symbol") rels.add(t.sym.file);
841
848
  else if (t.kind === "file") rels.add(t.rel);
842
849
  }
850
+ warmFiles(model, rels);
851
+ }
852
+
853
+ // The same batch, for files the caller already knows by path (R1: the caller
854
+ // files a `--callers-source` card reads).
855
+ function warmFiles(model, rels) {
843
856
  const onDisk = [...rels].filter((rel) => !model.blobStates.has(rel) && fs.existsSync(path.join(model.root, ...rel.split("/"))));
844
857
  for (const rel of rels) if (!fs.existsSync(path.join(model.root, ...rel.split("/")))) model.blobStates.set(rel, "deleted");
845
858
  if (!onDisk.length) return;
@@ -879,14 +892,15 @@ const BLOB_LABEL = {
879
892
  // · `tail` is the `--source` block (D1). It is charged against the SAME
880
893
  // budget and it goes LAST, so the rows always win: source takes what the
881
894
  // card left, line by line, and the footer says how many lines it cut.
882
- function fit(items, budget, footer, tail) {
895
+ function fit(items, budget, footer, tail, blocks) {
883
896
  // The reserve is the LONGEST footer this card could print — one hidden
884
897
  // count per section present — so the final `used` can never pass the
885
898
  // budget, whatever ends up hidden. (W2: a card with five sections overran a
886
899
  // 100-token budget by five tokens on the footer alone.)
887
900
  const worst = {};
888
901
  for (const it of items) if (it.pri > 0) worst[it.section] = 99;
889
- const reserve = tok(footer(0, worst, tail ? { kept: 0, want: tail.want, cut: tail.want } : null)) + 2;
902
+ const worstBlocks = blocks && blocks.length ? { shown: 0, want: blocks.length } : null;
903
+ const reserve = tok(footer(0, worst, tail ? { kept: 0, want: tail.want, cut: tail.want } : null, worstBlocks)) + 2;
890
904
  let used = 0;
891
905
  const keep = new Set();
892
906
  const hidden = {};
@@ -918,9 +932,32 @@ function fit(items, budget, footer, tail) {
918
932
  }
919
933
  source = { kept: body.length, want: tail.want, cut: tail.want - body.length };
920
934
  }
921
- const foot = footer(used, hidden, source);
935
+ // R1 (v1.9.1): the callers' own lines. They go AFTER the target's source, and
936
+ // each block is kept WHOLE or not at all — six lines cut to four is a call
937
+ // site with its arguments missing, which is worse than no call site.
938
+ let callersShown = null;
939
+ if (blocks && blocks.length) {
940
+ const headOf = (n) => ` callers source ${n < blocks.length ? `${n} of ${blocks.length}` : n} shown (${CALLERS_SOURCE_LINES} lines each${n < blocks.length ? "; raise --budget" : ""})`;
941
+ let left = budget - used - reserve - tok(headOf(0)) - 1;
942
+ const out = [];
943
+ for (const b of blocks) {
944
+ const open = " ```" + (b.fence || "");
945
+ const block = [b.head, open, ...b.lines, " ```"];
946
+ const t = block.reduce((a, l) => a + tok(l) + 1, 0);
947
+ if (t > left) break;
948
+ left -= t;
949
+ out.push({ b, block });
950
+ }
951
+ if (out.length) {
952
+ const all = [headOf(out.length), ...out.flatMap((x) => x.block)];
953
+ used += all.reduce((a, l) => a + tok(l) + 1, 0);
954
+ lines.push(...all);
955
+ }
956
+ callersShown = { shown: out.length, want: blocks.length, kept: out.map((x) => x.b) };
957
+ }
958
+ const foot = footer(used, hidden, source, callersShown);
922
959
  if (foot) lines.push(foot);
923
- return { card: lines.join("\n"), kept: keep, hidden, source, used: used + tok(foot) };
960
+ return { card: lines.join("\n"), kept: keep, hidden, source, callers: callersShown, used: used + tok(foot) };
924
961
  }
925
962
 
926
963
  // ── E6: the tree format ─────────────────────────────────────────────────────
@@ -1021,6 +1058,97 @@ function sourceText(tail, kept) {
1021
1058
  return tail.lines.slice(0, kept).join("\n");
1022
1059
  }
1023
1060
 
1061
+ // ── R1 (v1.9.1): the call sites WITH the card ───────────────────────────────
1062
+ // A recon agent asked "who calls this" and then opened every caller file to
1063
+ // see HOW it was called. Six lines around each call site answer that in the
1064
+ // same call. The limits, and the reason for each:
1065
+ // · CONFIDENT callers only. Source printed for an AMBIGUOUS guess reads as a
1066
+ // fact, and that is the false sentence the R3-C eval found.
1067
+ // · never the target's OWN file. The agent that asks is about to edit that
1068
+ // file, and a file you edit is read in full with `Read` first (the read
1069
+ // ladder's exception 1) — a CLI print of it would be read twice.
1070
+ // · ≤ 5 callers, 6 lines each, charged to the SAME budget, after everything
1071
+ // else. The rows always win.
1072
+ const CALLERS_SOURCE_MAX = 5;
1073
+ const CALLERS_SOURCE_LINES = 6;
1074
+
1075
+ function fileLines(model, rel) {
1076
+ model.textCache = model.textCache || new Map();
1077
+ if (model.textCache.has(rel)) return model.textCache.get(rel);
1078
+ let all = null;
1079
+ try {
1080
+ const text = fs.readFileSync(path.join(model.root, ...rel.split("/")), "utf8");
1081
+ all = text.split(/\r?\n/);
1082
+ if (all.length > 1 && all[all.length - 1] === "") all.pop();
1083
+ } catch (_) {
1084
+ all = null;
1085
+ }
1086
+ model.textCache.set(rel, all);
1087
+ return all;
1088
+ }
1089
+
1090
+ function callerBlocks(model, sym, confident) {
1091
+ const others = confident.filter((c) => c.sym.file !== sym.file);
1092
+ // One git call for the files the blocks will most likely come from. A caller
1093
+ // past this slice (many call sites in one window) is still checked, one by one.
1094
+ warmFiles(model, new Set(others.slice(0, CALLERS_SOURCE_MAX * 4).map((c) => c.sym.file)));
1095
+ const out = [];
1096
+ for (const c of others) {
1097
+ if (out.length >= CALLERS_SOURCE_MAX) break;
1098
+ const rel = c.sym.file;
1099
+ // Two call sites a line apart are ONE window. Printing it twice is the
1100
+ // same six lines paid twice.
1101
+ if (out.some((b) => b.file === rel && b.from <= c.line && c.line <= b.to)) continue;
1102
+ const fresh = blobState(model, rel);
1103
+ if (fresh === "deleted") continue;
1104
+ const all = fileLines(model, rel);
1105
+ if (!all || !all.length) continue;
1106
+ const at = Math.max(1, Math.min(all.length, Number(c.line) || 1));
1107
+ // Two lines above, three below — shifted, not shortened, at either end of
1108
+ // the file, so every block is six lines when the file has six.
1109
+ const from = Math.max(1, Math.min(at - 2, all.length - CALLERS_SOURCE_LINES + 1));
1110
+ const to = Math.min(all.length, from + CALLERS_SOURCE_LINES - 1);
1111
+ const width = String(to).length;
1112
+ const lines = [];
1113
+ for (let i = from; i <= to; i++) lines.push(` ${String(i).padStart(width)} ${all[i - 1]}`);
1114
+ const caller = c.url || c.sym.qname;
1115
+ const changed = fresh !== "current";
1116
+ out.push({
1117
+ head: ` ${rel}:${from}-${to} ← ${caller}${changed ? " — CHANGED since index, the range may have moved" : ""}`,
1118
+ fence: FENCE_BY_EXT[posix.extname(rel)] || "",
1119
+ lines,
1120
+ file: rel,
1121
+ from,
1122
+ to,
1123
+ caller,
1124
+ changed,
1125
+ });
1126
+ }
1127
+ return out;
1128
+ }
1129
+
1130
+ function callersSourceNote(cs) {
1131
+ if (!cs || cs.shown >= cs.want) return "";
1132
+ return cs.shown ? ` · callers source ${cs.want - cs.shown} cut (raise --budget)` : ` · callers source not shown (${cs.want} callers, raise --budget)`;
1133
+ }
1134
+
1135
+ // ── R4 (v1.9.1): where a language server should be asked ───────────────────
1136
+ // The LSP tool takes a file, a line and a 1-based character. The card knows the
1137
+ // line; the character is where the NAME sits on it, read from the bytes on disk.
1138
+ // `null` whenever that cannot be stated exactly — a guessed column sends the
1139
+ // language server to the wrong token, and its answer is then confidently wrong.
1140
+ function lspAt(model, sym, fresh) {
1141
+ if (fresh !== "current" || !sym || !sym.name) return null;
1142
+ const all = fileLines(model, sym.file);
1143
+ const line = sym.lines && sym.lines[0];
1144
+ if (!all || !line || line > all.length) return null;
1145
+ const text = all[line - 1];
1146
+ const esc = String(sym.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1147
+ const m = new RegExp(`(^|[^A-Za-z0-9_$])${esc}(?![A-Za-z0-9_$])`).exec(text);
1148
+ if (!m) return null;
1149
+ return { file: sym.file, line, character: m.index + m[1].length + 1 };
1150
+ }
1151
+
1024
1152
  function sourceNote(source) {
1025
1153
  if (!source) return "";
1026
1154
  if (!source.kept) return ` · source not shown (${source.want} lines, raise --budget)`;
@@ -1159,19 +1287,27 @@ function ctx(model, query, opts) {
1159
1287
  const short = pickShort(items);
1160
1288
  // The footer is printed ONLY when the card hid something, dropped an
1161
1289
  // unresolved call, or cut the source block (§6).
1162
- const footer = (used, hidden, source) => {
1163
- const why = `${unresolved ? ` · ${unresolved} unresolved call(s) not shown` : ""}${hiddenText(hidden)}${sourceNote(source)}`;
1290
+ const footer = (used, hidden, source, cs) => {
1291
+ const why = `${unresolved ? ` · ${unresolved} unresolved call(s) not shown` : ""}${hiddenText(hidden)}${sourceNote(source)}${callersSourceNote(cs)}`;
1164
1292
  return why ? ` budget ${used}/${budget} tokens${why}` : "";
1165
1293
  };
1166
1294
  const tail = opts.source === undefined ? null : sourceTail(model, sym.file, sym.lines[0], sym.lines[1], opts.source, fresh);
1167
- const f = fit(items, budget, footer, tail);
1295
+ const blocks = opts.callersSource ? callerBlocks(model, sym, callers.confident) : null;
1296
+ const f = fit(items, budget, footer, tail, blocks);
1168
1297
  const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
1298
+ const callersSource = blocks
1299
+ ? {
1300
+ callers_source: (f.callers ? f.callers.kept : []).map((b) => ({ file: b.file, from: b.from, to: b.to, caller: b.caller, text: b.lines.join("\n"), changed: b.changed })),
1301
+ counts: { callers_source_cut: f.callers ? f.callers.want - f.callers.shown : 0 },
1302
+ }
1303
+ : {};
1169
1304
  return {
1170
1305
  ok: true,
1171
1306
  state: "found",
1172
1307
  target: brief(sym),
1173
1308
  blob: fresh,
1174
1309
  exported: sym.exported,
1310
+ lsp_at: lspAt(model, sym, fresh),
1175
1311
  states: short ? "short" : "long",
1176
1312
  note: note || null,
1177
1313
  callers: kept.filter((d) => d.item ==="caller"),
@@ -1182,6 +1318,7 @@ function ctx(model, query, opts) {
1182
1318
  tests,
1183
1319
  unresolved,
1184
1320
  source: tail && f.source && f.source.kept ? { file: tail.file, from: tail.from, to: tail.from + f.source.kept - 1, text: sourceText(tail, f.source.kept), cut: f.source.cut } : null,
1321
+ ...callersSource,
1185
1322
  hidden: f.hidden,
1186
1323
  budget: { used: f.used, max: budget },
1187
1324
  card: f.card,
@@ -1266,8 +1403,11 @@ function fileCard(model, rel, budget, opts) {
1266
1403
  if (fileTests.length) push(4, "tests", ` tests ${fileTests.join(useTree ? " " : ", ")}`, { item: "tests", files: fileTests });
1267
1404
  const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([rel]) : [];
1268
1405
  if (wiki.length) push(5, "wiki", ` wiki ${wiki.join(useTree ? " " : ", ")}`, { item: "wiki", docs: wiki });
1406
+ // R1: `--callers-source` is a SYMBOL card's option. A file card names who
1407
+ // imports the file, not a call line, so there is no six-line window to print.
1408
+ const ignored = opts.callersSource ? " · callers source: symbol cards only" : "";
1269
1409
  const footer = (used, hidden, source) => {
1270
- const why = `${hiddenText(hidden)}${sourceNote(source)}`;
1410
+ const why = `${hiddenText(hidden)}${sourceNote(source)}${ignored}`;
1271
1411
  return why ? ` budget ${used}/${budget} tokens${why}` : "";
1272
1412
  };
1273
1413
  // D1 on a FILE card is the file's head — the imports, the exports and the
@@ -1420,7 +1560,9 @@ function forSlice(model, files, opts) {
1420
1560
  const each = Math.max(100, Math.floor(budget / known.length));
1421
1561
  const blocks = known.map((rel) => sliceBlock(model, rel, each, opts));
1422
1562
  const body = blocks.map((b) => b.card).join("\n\n");
1423
- const note = missing.length ? `\n not in the graph: ${missing.join(", ")}` : "";
1563
+ // R1 / DE-7b: the executor reads its declared files whole, so the outside
1564
+ // view stays rows only — the flag is named as ignored, never silently dropped.
1565
+ const note = (missing.length ? `\n not in the graph: ${missing.join(", ")}` : "") + (opts.callersSource ? "\n callers source: symbol cards only" : "");
1424
1566
  // The column header is named ONCE for the whole answer, not once per block —
1425
1567
  // the tree format's own rule, applied one level up.
1426
1568
  return {
@@ -1767,4 +1909,4 @@ function nameHits(model, name) {
1767
1909
  return calls + defs;
1768
1910
  }
1769
1911
 
1770
- module.exports = { loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP };
1912
+ module.exports = { loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP, CALLERS_SOURCE_MAX, CALLERS_SOURCE_LINES };
@@ -175,7 +175,12 @@ function forwardRow(i, res) {
175
175
  const NAMES_PER_KEY = 5;
176
176
  const norm = (p) => String(p).split("\\").join("/").replace(/^\.\//, "");
177
177
 
178
- function fastModel(claudeDir, root, query) {
178
+ function fastModel(claudeDir, root, query, opts) {
179
+ // R1 (v1.9.1): `--callers-source` reads every caller FILE and asks git for
180
+ // each one's freshness. The fast model holds blobs, not bytes, so the full
181
+ // model answers — the same decline, for the same reason, as any other read
182
+ // the fast path cannot prove.
183
+ if (opts && opts.callersSource) return { model: null, reason: "callers-source" };
179
184
  const p = G.graphPaths(claudeDir);
180
185
  const meta = readJson(p.meta);
181
186
  if (!meta || !meta.generation) return { model: null, reason: "no-meta" };