@azure-id/orc 1.7.0 → 1.8.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 (63) hide show
  1. package/CHANGELOG.md +3649 -3339
  2. package/README-id.md +923 -844
  3. package/README.md +836 -860
  4. package/bin/build-agents.js +43 -27
  5. package/bin/cli.js +701 -3
  6. package/bin/graph-extract.js +927 -0
  7. package/bin/graph-notes.js +188 -0
  8. package/bin/graph-query.js +808 -0
  9. package/bin/graph-resolve.js +178 -0
  10. package/bin/graph-signals.js +277 -0
  11. package/bin/graph.js +605 -0
  12. package/bin/verify-contracts.js +4669 -4527
  13. package/bin/verify-package.js +626 -616
  14. package/bin/webui/api.js +1419 -1414
  15. package/bin/webui/fixtures/index.js +579 -576
  16. package/bin/webui/fixtures/knowledge.js +316 -291
  17. package/bin/webui/i18n/en/knowledge.json +167 -151
  18. package/bin/webui/i18n/en/overview.json +101 -100
  19. package/bin/webui/i18n/id/knowledge.json +167 -151
  20. package/bin/webui/i18n/id/overview.json +101 -100
  21. package/bin/webui/js/panels/knowledge.js +1065 -1006
  22. package/bin/webui/js/panels/overview.js +492 -488
  23. package/package.json +39 -39
  24. package/templates/agents/MODEL-MAPPING.md +163 -158
  25. package/templates/agents/orc-executor-haiku-4-5.md +133 -113
  26. package/templates/agents/orc-executor-opus-4-7-high.md +134 -114
  27. package/templates/agents/orc-executor-opus-4-7-med.md +134 -114
  28. package/templates/agents/orc-executor-opus-4-8-high.md +134 -114
  29. package/templates/agents/orc-executor-opus-5-high.md +134 -114
  30. package/templates/agents/orc-executor-opus-5-low.md +134 -114
  31. package/templates/agents/orc-executor-opus-5-med.md +134 -114
  32. package/templates/agents/orc-executor-sonnet-4-6-high.md +134 -114
  33. package/templates/agents/orc-executor-sonnet-4-6-med.md +134 -114
  34. package/templates/agents/orc-executor-sonnet-5-high.md +134 -114
  35. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +86 -0
  36. package/templates/hooks/README.md +444 -396
  37. package/templates/hooks/orc-graph-hook.js +336 -0
  38. package/templates/hooks/orc-statusline-render.js +922 -921
  39. package/templates/hooks/orc-statusline.js +1596 -1545
  40. package/templates/skills/_shared/README.md +4 -0
  41. package/templates/skills/_shared/code-graph.md +220 -0
  42. package/templates/skills/_shared/extra-dispatch.md +1 -1
  43. package/templates/skills/_shared/opus5-only.md +4 -0
  44. package/templates/skills/_shared/phases/execution.md +166 -145
  45. package/templates/skills/_shared/phases/planning.md +142 -135
  46. package/templates/skills/_shared/phases/preflight.md +132 -118
  47. package/templates/skills/_shared/phases/review.md +63 -53
  48. package/templates/skills/_shared/phases/rules.md +5 -0
  49. package/templates/skills/_shared/phases/ship.md +96 -88
  50. package/templates/skills/_shared/phases/trace.md +6 -0
  51. package/templates/skills/_shared/phases/wiki-consult.md +194 -189
  52. package/templates/skills/_shared/read-ladder.md +124 -102
  53. package/templates/skills/_shared/return-validation.md +259 -250
  54. package/templates/skills/orc/SKILL.md +255 -254
  55. package/templates/skills/orc/references/preflight-report.md +6 -0
  56. package/templates/skills/orc/subskills/orc-execution/SKILL.md +2 -1
  57. package/templates/skills/orc/subskills/orc-execution/core.md +9 -1
  58. package/templates/skills/orc-diy/references/flow-schema.md +101 -100
  59. package/templates/skills/orc-fast/SKILL.md +236 -229
  60. package/templates/skills/orc-mini/SKILL.md +267 -259
  61. package/templates/skills/orc-quick/SKILL.md +378 -360
  62. package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
  63. package/templates/skills/orc-wiki/references/staleness.md +294 -288
package/bin/cli.js CHANGED
@@ -234,7 +234,10 @@ function installGuards(claudeDir) {
234
234
  try {
235
235
  fs.writeFileSync(
236
236
  path.join(hooksDest, "orc-version.json"),
237
- JSON.stringify({ version: currentVersion() }) + "\n"
237
+ // EW3: the graph hook shells `orc graph update`, and a hook must never
238
+ // guess at PATH — the installer is the one place that knows for certain
239
+ // which cli.js this payload came from.
240
+ JSON.stringify({ version: currentVersion(), cli: path.resolve(__dirname, "cli.js") }) + "\n"
238
241
  );
239
242
  } catch (_) {}
240
243
  // The statusline's phase rail (v1.2.1). Regenerated every install from the
@@ -389,6 +392,33 @@ function installGuards(claudeDir) {
389
392
  console.log(" upd settings.json → PreToolUse read gate path");
390
393
  }
391
394
 
395
+ // 6) The graph hook (v1.8.0 EW3/EW4) — FOUR events, one file. It updates the
396
+ // graph when an ORC executor finishes, and hands a subagent the anchors it
397
+ // would otherwise grep for. Wired even though `code_graph` may be off: the
398
+ // hook's first act is to read that key and return, so an unarmed hook is
399
+ // byte-identical to not having it (asserted by a test).
400
+ const graphHookCmd = nodeCmd(path.join(hooksDest, "orc-graph-hook.js"));
401
+ const wireGraph = (arrName, matcher) => {
402
+ settings.hooks[arrName] = settings.hooks[arrName] || [];
403
+ for (const entry of settings.hooks[arrName]) {
404
+ for (const h of entry.hooks || []) {
405
+ if (typeof h.command === "string" && h.command.includes("orc-graph-hook")) {
406
+ h.command = graphHookCmd; // keep the path current on update
407
+ if (matcher) entry.matcher = matcher;
408
+ return;
409
+ }
410
+ }
411
+ }
412
+ const entry = { hooks: [{ type: "command", command: graphHookCmd }] };
413
+ if (matcher) entry.matcher = matcher;
414
+ settings.hooks[arrName].push(entry);
415
+ console.log(` add settings.json → ${arrName} graph hook`);
416
+ };
417
+ wireGraph("SubagentStart", null);
418
+ wireGraph("SubagentStop", null);
419
+ wireGraph("PreToolUse", "Grep|Glob");
420
+ wireGraph("PostToolUse", "Read");
421
+
392
422
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
393
423
  }
394
424
 
@@ -1263,6 +1293,10 @@ const CONFIG_FAMILIES = {
1263
1293
  // the gate acts on the MAIN session's reads and `opus5_only` / `extra_*`
1264
1294
  // decide which MODEL runs a dispatched role. Nothing shadows these two.
1265
1295
  read: { contested: false, question: "whether an oversized full read by the main session is refused" },
1296
+ // v1.8.0 — the code graph. UNCONTESTED: no forcing mode reaches it. The
1297
+ // structure costs no model tokens, so nothing about a dispatched model can
1298
+ // shadow whether it is built.
1299
+ graph: { contested: false, question: "whether code lanes build and read a local map of how the repo is connected" },
1266
1300
  };
1267
1301
 
1268
1302
  // Ordered, tiered metadata. Common first, then advanced.
@@ -1321,6 +1355,21 @@ const CONFIG_META = [
1321
1355
  { key: "subagent_line_custom", def: "off", tier: "common", answers: [{ family: "statusline", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether the agent panel renders YOUR composed row for each subagent instead of Claude Code's own. Off is Claude Code's row, unchanged. Compose it in `orc ui` > CLI Hook Interface, on the subagent board. INDEPENDENT of the per-agent token record: ORC writes what the agent panel reports whether this is on or off, because that measurement is handed over either way and throwing it out because a display setting is off would be the wrong trade." },
1322
1356
  { key: "read_gate", def: "off", tier: "common", answers: [{ family: "read", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "warn", "block"), options: ["off", "warn", "block"], desc: "Whether the PreToolUse read gate acts on an oversized full read by the MAIN session. Off is byte-identical to not having the hook. `warn` allows and says so; `block` refuses and names the cheaper path. It is SILENT on every read a subagent makes (so an executor's read-before-edit is never touched), outside an open ORC run, on a targeted offset/limit read, on output a gate parses, and under `read_gate_max_lines`." },
1323
1357
  { key: "read_gate_max_lines", def: 1000, tier: "advanced", answers: [{ family: "read", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [500, 1000, 2000], desc: "Line count at or above which `read_gate` acts. The default 1000 is MEASURED, not borrowed: a read-only dispatch costs ~13k tokens and p50 76s, which at 55.2 chars/line across 316 sampled reads puts break-even near 1000 lines. A lower number delegates work whose overhead exceeds its saving." },
1358
+ // v1.8.0 — `lanes: []` ON PURPOSE, the read_gate answer for a different
1359
+ // reason: no lane reads this key. Every lane calls `orc graph … --if-enabled`
1360
+ // and the CLI resolves it, which is how /orc-quick takes part while its Q0
1361
+ // still reads `log_dir` and nothing else.
1362
+ { key: "code_graph", def: "off", tier: "common", answers: [{ family: "graph", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether code-changing lanes (orc, ultra, diy, mini, fast, quick) build and read a local code graph under .claude/orc/graph/. The structure is extracted by the CLI from git blob SHAs and costs no model tokens; a teammate's change is found and healed at the next preflight. Off means no lane reads or writes it. See `orc graph status`." },
1363
+ // v1.8.0 W7 — the graph's operating keys. All `lanes: []` for the same reason
1364
+ // as `code_graph`: the CLI resolves them behind `--if-enabled`. All gated by
1365
+ // `code_graph`, so turning the graph off makes every one of them INERT, out loud.
1366
+ { key: "code_graph_notes", def: "off", tier: "common", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vEnum("off", "wave", "end"), options: ["off", "wave", "end"], desc: "Whether a code lane pays for one-sentence notes on the functions it changed (orc-graph-noter-sonnet-4-6-med). `wave` = one batch per wave (per code-writing request in /orc-quick); `end` = one batch at the end of the run. The structure layer is free either way — notes are the only part of the graph that costs tokens. Inert while code_graph is off." },
1367
+ { key: "code_graph_notes_min", def: 5, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vInt(1), options: [3, 5, 10], desc: "Fewest changed functions worth a notes dispatch. Below it nothing is dispatched and the functions wait for a later batch: a subagent's fixed start-up cost (~5.7K tokens) makes a one-function batch the most expensive note there is." },
1368
+ { key: "code_graph_notes_cap", def: 40, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vInt(1), options: [20, 40, 80], desc: "Most functions one notes dispatch covers. The rest wait for the next batch." },
1369
+ { key: "code_graph_card_budget", def: 1200, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vRange(300, 4000), options: [600, 1200, 2400], desc: "Token budget for one `orc graph ctx` card. A card never exceeds it and always says what it hid — and every card is sent again on each later turn of the agent that received it." },
1370
+ { key: "code_graph_auto_update", def: true, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vEnum("true", "false"), desc: "Whether a code lane's preflight heals a DRIFTED graph itself (free — parser only, no model). false = the lane prints DRIFTED and treats the graph as hints until you run `orc graph update`." },
1371
+ { key: "code_graph_heal_ms", def: 1500, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vRange(0, 60000), options: [0, 1500, 5000], desc: "How long a READ (`ctx`, `impact`, `coverage`) may spend healing a graph that has moved. The read never starts a heal it expects to overrun: the last update's own duration is the estimate. Over the cap, or another update holds the lock, the read answers from the old generation and marks every touched card CHANGED. 0 = never heal on a read (the lane steps and the run-end hook still do). Inert while code_graph or code_graph_auto_update is off." },
1372
+ { key: "code_graph_hooks", def: "on", tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vEnum("on", "off"), options: ["on", "off"], desc: "Whether the installed `orc-graph-hook.js` acts. It updates the graph when an ORC executor finishes, so a lane that forgot its own update step cannot leave the map behind. It is silent in the main session, outside an ORC run, when the graph does not exist, and on any error — it never blocks a tool call and never fails one. off = the hook exits immediately and the lane steps are the only update path." },
1324
1373
  { key: "statusline_custom", def: "off", tier: "common", answers: [{ family: "statusline", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether the status line renders YOUR composed layout instead of the shipped two lines. Off is byte-identical to what ships. Compose the layout in `orc ui` > CLI Hook Interface (the CLI half exists so the panel has something to shell). Turning this on with an invalid or missing layout is refused, naming the reason; a layout that later becomes unreadable falls back to the shipped lines silently and is reported by `orc doctor`." },
1325
1374
  { key: "wait_hop_minutes", def: 30, tier: "advanced", answers: [{ family: "wait", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [5, 10, 15, 30], desc: "How long ONE detached hop waits before ORC re-reads the window. Short on purpose: each wake-up is session activity, and session activity is the only thing that makes the statusline write a fresh reading. One long sleep wakes into a reading as stale as the sleep was long." },
1326
1375
  { key: "wait_max_hops", def: 5, tier: "advanced", answers: [{ family: "wait", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [1, 2, 3, 5, 8, 12], desc: "How many hops before ORC gives up and stops with the hand-back. A wrong reset time must cost you a bounded wait, never a session that never comes back." },
@@ -3104,6 +3153,18 @@ function laneAnnounce(lane, map, claudeDir, families) {
3104
3153
  out.push(`extra: ${bits.join("; ")} — this sends work to a third party`);
3105
3154
  }
3106
3155
  }
3156
+ // v1.8.0 W9 — the code-graph cache. Round 1 of W9 measured /orc-mini and
3157
+ // /orc-quick with `code_graph: on` that never called `orc graph`: the steps
3158
+ // sat in the spine, and the spine was skipped. This line comes from the
3159
+ // resolver every lane already obeys and prints VERBATIM at preflight, so the
3160
+ // three steps are in front of the lane whatever the spine said.
3161
+ const graphOn = String(Object.prototype.hasOwnProperty.call(map, "code_graph") ? map.code_graph : metaFor("code_graph").def) === "on";
3162
+ if (graphOn && LANE_CALLS["graph-status"].lanes.includes(lane))
3163
+ out.push(
3164
+ "graph: on — use the code-graph cache: (1) now: `orc graph status --if-enabled --heal --json` (builds or updates it); " +
3165
+ "(2) every code-writing slice: `orc graph ctx <declared files> --if-enabled --json` cards; " +
3166
+ "(3) after every code change: `orc graph update --if-enabled --json` (keeps it for the next run). Copy each `line` and `trace` verbatim"
3167
+ );
3107
3168
  // A shadowed setting must never be silent, and neither must an inert one.
3108
3169
  const shadowed = [];
3109
3170
  for (const f of Object.values(families))
@@ -3295,6 +3356,106 @@ const LANE_CALLS = {
3295
3356
  never: "never inject the ledger unfiltered — only the entries whose `scope` glob matches the slice",
3296
3357
  lanes: ["orc", "orc-boundary", "orc-brainstorm", "orc-fast", "orc-grill", "orc-mini", "orc-retro", "orc-route"],
3297
3358
  },
3359
+ // v1.8.0 — the code graph. Every row carries `--if-enabled`: the CLI resolves
3360
+ // `code_graph`, so no lane reads the key (and /orc-quick can take part).
3361
+ "graph-status": {
3362
+ cmd: "orc graph status --if-enabled --heal [--json]",
3363
+ what: "is the local code graph on, built, and fresh — `--heal` builds a missing index or updates a drifted one in the same call",
3364
+ exits: { 0: "FRESH (after a heal, too)", 1: "NONE — no index and it could not be built, or unavailable (not a git repository)", 2: "DRIFTED — and it could not be healed (locked, or `code_graph_auto_update: false`)", 3: "OFF — `code_graph` is off" },
3365
+ states: ["fresh", "none", "drifted", "off", "unavailable"],
3366
+ cost: "free",
3367
+ when: "once, at preflight, in a code-changing lane — BEFORE the first dispatch",
3368
+ on_absent: "exit 3 is an ANSWER — print `graph: off` once and make no other graph call; any other exit never blocks: print its `line`, copy its `trace`, continue",
3369
+ canonical: "_shared/code-graph.md",
3370
+ never: "never read `code_graph` from the config yourself — `--if-enabled` is how a lane takes part without reading a key",
3371
+ lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick"],
3372
+ },
3373
+ "graph-update": {
3374
+ cmd: "orc graph update --if-enabled [--json]",
3375
+ what: "bring the code graph in line with the working tree — parse only the files whose git blob changed",
3376
+ exits: { 0: "built, updated or unchanged", 1: "unavailable — not a git repository, or another update holds the lock", 3: "off — `code_graph` is off" },
3377
+ states: ["built", "updated", "unchanged", "unavailable", "off"],
3378
+ cost: "free",
3379
+ when: "at preflight when status was 1 or 2; after every wave's worktree audit; after a green smoke gate; after a code-writing /orc-quick request; at ship. v1.8.0 EW3 adds two paths that need no lane step: a READ heals what it is about to answer for, and the installed graph hook updates once when an ORC executor finishes",
3380
+ on_absent: "exit 1 never blocks a phase — say it once and continue; the next preflight heals it",
3381
+ canonical: "_shared/code-graph.md",
3382
+ never: "never run it from a WATCHER or a background process — a continuous rebuild is how graph tools freeze a machine. A one-shot update at a discrete event (an executor finishing, a read that found its own target stale) is not that: it takes the same lock, it is bounded by `code_graph_heal_ms`, and a second one that finds the lock held SKIPS rather than queues",
3383
+ lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick"],
3384
+ },
3385
+ "graph-ctx": {
3386
+ cmd: "orc graph ctx <symbol|file[:line]>… --if-enabled [--budget N] [--json]",
3387
+ what: "a budgeted context card: where a symbol is, who calls or uses it, what it calls, its effects, its note — up to 5 targets share one budget",
3388
+ exits: { 0: "found (at least one target)", 1: "no graph index", 3: "off — `code_graph` is off", 4: "not found or ambiguous — nearest names or every candidate listed" },
3389
+ states: null,
3390
+ cost: "free",
3391
+ when: "at slice build (the declared files, one call), in /orc-quick's Q1 look, and by the executor itself — the read ladder's step 0",
3392
+ on_absent: "exit 4 → back to read ladder step 1; an unknown symbol is never a reason to guess",
3393
+ canonical: "_shared/code-graph.md",
3394
+ never: "never act on behaviour from a card alone — read the RANGE it names; a card whose header says CHANGED is hints only",
3395
+ lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick"],
3396
+ },
3397
+ "graph-impact": {
3398
+ cmd: "orc graph impact <file…> --if-enabled [--depth N] [--json]",
3399
+ what: "transitive confident callers of everything in these files, with the tests and wiki docs that reach them",
3400
+ exits: { 0: "answered", 1: "no graph index", 3: "off — `code_graph` is off", 4: "none of the files is in the graph" },
3401
+ states: null,
3402
+ cost: "free",
3403
+ when: "at planning (declared files, fan and risk facets) and at review (unchanged callers of a changed signature)",
3404
+ on_absent: "exit 3 or 4 → plan or review exactly as before; AMBIGUOUS callers are counted, never followed",
3405
+ canonical: "_shared/code-graph.md",
3406
+ never: "never treat an empty impact as proof nothing depends on the change — dynamic dispatch is invisible to the graph",
3407
+ lanes: ["orc", "orc-diy"],
3408
+ },
3409
+ // EW5 — the cheap signals. All three read what is already on disk (the index,
3410
+ // and git's own history), so all three cost zero model tokens.
3411
+ "graph-coverage": {
3412
+ cmd: "orc graph coverage <file…> --if-enabled [--json]",
3413
+ what: "how much of each file the extractor actually saw — `full` · `partial` with the line ranges it did not finish · `skipped:<reason>` · `excluded`",
3414
+ exits: { 0: "answered — ALWAYS, when a graph exists; a gap is an answer, never an error", 1: "no graph index", 3: "off — `code_graph` is off" },
3415
+ states: null,
3416
+ cost: "free",
3417
+ when: "before you trust a card's SILENCE about a file — one batch call for every file in the slice",
3418
+ on_absent: "exit 1 or 3 → read the source, exactly as before the graph existed",
3419
+ canonical: "_shared/code-graph.md",
3420
+ never: "never read `no recorded gap` as proof of completeness — a file marked `full` was fully PARSED, not fully understood",
3421
+ lanes: ["orc", "orc-diy"],
3422
+ },
3423
+ "graph-changes": {
3424
+ cmd: "orc graph changes [--base <ref>] --if-enabled [--json]",
3425
+ what: "the symbols THIS diff's hunks overlap, each with its callers, its tests, and a risk word that carries its own reason",
3426
+ exits: { 0: "answered (an empty answer is an answer)", 1: "no graph index, or not a git repository", 3: "off — `code_graph` is off" },
3427
+ states: null,
3428
+ cost: "free",
3429
+ when: "at review, instead of `impact` on the declared files — and in a verify or ship summary",
3430
+ on_absent: "exit 1 or 3 → review from the diff alone, exactly as before",
3431
+ canonical: "_shared/code-graph.md",
3432
+ never: "never report `risk` without the `why` beside it — a rating nobody can check is a rating nobody should act on",
3433
+ lanes: ["orc", "orc-diy"],
3434
+ },
3435
+ "graph-cochange": {
3436
+ cmd: "orc graph cochange <file> --if-enabled [--json]",
3437
+ what: "the files that historically changed WITH this one — from `git log`, at least 3 times, ignoring commits that touched more than 20 files",
3438
+ exits: { 0: "rows", 1: "not a git repository", 3: "off — `code_graph` is off", 4: "nothing reaches the threshold — this file changes alone (an ANSWER)" },
3439
+ states: null,
3440
+ cost: "free",
3441
+ when: "at planning, to sharpen `declared_files`: it answers \"what else will I have to touch\" from history, which a static edge cannot see",
3442
+ on_absent: "exit 4 is an ANSWER, not a miss — plan exactly as before",
3443
+ canonical: "_shared/code-graph.md",
3444
+ never: "never read a co-change as a DEPENDENCY — it says what people changed together, never what needs what",
3445
+ lanes: ["orc", "orc-diy"],
3446
+ },
3447
+ "graph-notes-pending": {
3448
+ cmd: "orc graph notes pending --files <a,b> [--at wave|end] --if-enabled [--json]",
3449
+ what: "which changed functions have no note for their current body — the input to one notes batch",
3450
+ exits: { 0: "rows to note — dispatch the noter", 1: "no graph index, or no --files", 3: "off — `code_graph` or `code_graph_notes` is off", 5: "none pending, fewer than `code_graph_notes_min`, or deferred to the other `--at` site — dispatch nothing" },
3451
+ states: ["pending", "below-min", "none", "deferred", "off"],
3452
+ cost: "free",
3453
+ when: "after the update that follows a wave, a green smoke gate or a code-writing request — only while notes are on",
3454
+ on_absent: "exit 5 is an ANSWER — the symbols wait for a later batch and nothing is lost",
3455
+ canonical: "_shared/code-graph.md",
3456
+ never: "never paste notes into your own context — the noter pipes them to `orc graph notes apply -` and returns one line",
3457
+ lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick"],
3458
+ },
3298
3459
  "gotcha-list": {
3299
3460
  cmd: "orc gotcha list [--archived] [--json]",
3300
3461
  what: "the live repair-memory entries, or the archived tail",
@@ -4821,6 +4982,9 @@ const DIY_META = [
4821
4982
  { key: "mock_example", def: "ask", options: ["ask", "on", "off"], validate: vEnum("ask", "on", "off"), desc: "Post-verify mocked example + drift recovery (mock-examples/<slug>/, never committed)." },
4822
4983
  { key: "tdd", def: "on", options: ["on", "off"], validate: vEnum("on", "off"), desc: "TDD-anchored planning: plan-time tdd_spec, Wave-0 red tests, TDD gate in the verify slot." },
4823
4984
  { key: "gotchas", def: "on", options: ["on", "off"], validate: vEnum("on", "off"), desc: "Repair memory: inject scope-matching gotchas into executor slices and record one when a repair loop goes red → green." },
4985
+ // W9: default `on`. The compiled steps all carry `--if-enabled`, so the global
4986
+ // `code_graph` key still decides at run time; `off` is the explicit opt-out.
4987
+ { key: "code_graph", def: "on", options: ["on", "off"], validate: vEnum("on", "off"), desc: "Code-graph steps in this flow: status --heal at preflight, ctx cards in slices, update after every wave and at ship, one notes batch per wave when notes are on. on = compiled in, and the global `code_graph` key decides at run time; off = no graph step, even when `code_graph` is on." },
4824
4988
  { key: "wiki_gate", def: "notice", options: ["notice", "off", "hard"], validate: vEnum("notice", "off", "hard"), desc: "Wiki freshness at preflight: notice | off | hard (stale blocks with an ask)." },
4825
4989
  { key: "post_ship_wiki_ask", def: "on", options: ["on", "off"], validate: vEnum("on", "off"), desc: "Offer a wiki refresh after big shipped runs." },
4826
4990
  { key: "summary", def: "full", options: ["full", "off", "short"], validate: vEnum("full", "off", "short"), desc: "Summary depth." },
@@ -8337,6 +8501,478 @@ function gotchaArchived(claudeDir) {
8337
8501
  process.exit(0);
8338
8502
  }
8339
8503
 
8504
+ // ── orc graph (v1.8.0) — the code graph ─────────────────────────────────────
8505
+ // A thin router. The engine is `bin/graph.js`; this function resolves the
8506
+ // config (the engine never reads it) and owns the human lines. The exit code is
8507
+ // the same on the human path and the `--json` path (S7).
8508
+ function graphCmd() {
8509
+ const usage =
8510
+ "Usage: orc graph status [--heal] | update | gc | ctx <symbol|file[:line]>… | impact <file…> | path <from> <to>\n" +
8511
+ " orc graph coverage <file…> | changes [--base <ref>] | cochange <file>\n" +
8512
+ " orc graph notes pending --files <a,b> [--cap 40] [--min 5] | notes apply <file|->\n" +
8513
+ " [--if-enabled] [--depth N] [--budget TOKENS] [--format prose|tree] [--offset N] [--json] [--dir <path>]\n" +
8514
+ " status exit 0 FRESH · 1 NONE · 2 DRIFTED · 3 OFF (--heal builds or updates a NONE/DRIFTED graph first)\n" +
8515
+ " update exit 0 done · 1 unavailable or locked · 3 off (with --if-enabled)\n" +
8516
+ " gc exit 0 done · 1 no index or locked\n" +
8517
+ " ctx exit 0 found · 1 no graph · 3 off (with --if-enabled) · 4 not found or ambiguous\n" +
8518
+ " impact exit 0 found · 1 no graph · 3 off (with --if-enabled) · 4 none of the files is in the graph\n" +
8519
+ " path exit 0 found · 1 no graph · 3 off (with --if-enabled) · 4 no confident path, or an unknown end\n" +
8520
+ " coverage exit 0 always when a graph exists (a gap is an answer) · 1 no graph · 3 off (with --if-enabled)\n" +
8521
+ " changes exit 0 answered · 1 no graph or no git · 3 off (with --if-enabled) the symbols THIS diff touched, with risk\n" +
8522
+ " cochange exit 0 rows · 1 no git · 3 off (with --if-enabled) · 4 nothing reaches the threshold (an ANSWER)\n" +
8523
+ " notes pending exit 0 rows · 1 no index or no --files · 5 none, or fewer than --min\n" +
8524
+ " notes apply exit 0 all applied · 1 no index, unreadable or locked · 6 one or more rows rejected";
8525
+ if (flag("--global")) {
8526
+ console.error("❌ orc graph is project-scoped — the map is this repo's. Run it from the project (or with --dir <path>).");
8527
+ process.exit(1);
8528
+ }
8529
+ const G = require("./graph.js");
8530
+ const claudeDir = resolveClaudeDir();
8531
+ const root = repoRootOf(claudeDir);
8532
+ const sub = positionals()[1];
8533
+ const asJson = wantsJson();
8534
+ const ovr = readOverride(claudeDir).map;
8535
+ const cfg = (k) => (Object.prototype.hasOwnProperty.call(ovr, k) ? ovr[k] : metaFor(k).def);
8536
+ const enabled = String(cfg("code_graph")) === "on";
8537
+ // W9: every JSON answer carries the chat `line` and the trace `trace` a lane
8538
+ // copies VERBATIM — round 1 measured lanes that paraphrased the graph into a
8539
+ // GATE line, or dropped it. A read card already IS its line, so it is not repeated.
8540
+ // EW1: EVERY graph answer carries the generation it was computed from, so a
8541
+ // card quoted back later can be checked against the index that is on disk now.
8542
+ const metaNow = () => {
8543
+ try {
8544
+ return JSON.parse(fs.readFileSync(G.graphPaths(claudeDir).meta, "utf8"));
8545
+ } catch (_) {
8546
+ return null;
8547
+ }
8548
+ };
8549
+ const finish = (r, line, trace, jsonLine = true) => {
8550
+ const { exit, ...obj } = r;
8551
+ if (obj.generation === undefined) {
8552
+ const m = metaNow();
8553
+ if (m && m.generation) {
8554
+ obj.generation = m.generation;
8555
+ obj.gen_id = m.gen_id || null;
8556
+ }
8557
+ }
8558
+ if (asJson) emitJson({ ...obj, ...(line && jsonLine ? { line } : {}), ...(trace ? { trace } : {}) }, exit);
8559
+ if (line) console.log(line);
8560
+ process.exit(exit);
8561
+ };
8562
+
8563
+ if (sub === "status") {
8564
+ let r = G.graphStatus(claudeDir, root, { enabled });
8565
+ const autoUpdate = String(cfg("code_graph_auto_update")) !== "false";
8566
+ // `--heal` (W9): consult AND build in ONE call. A lane that had to run
8567
+ // status, read the exit code and then run update skipped the second step.
8568
+ let healed = null;
8569
+ if (flag("--heal") === true && enabled && autoUpdate && (r.state === "none" || r.state === "drifted")) {
8570
+ const u = G.graphUpdate(claudeDir, root, { enabled, ifEnabled: true });
8571
+ healed =
8572
+ u.exit === 0
8573
+ ? { state: u.state, added: u.added, changed: u.changed, deleted: u.deleted, parsed: u.parsed, reused: u.reused, engine_upgrade: u.engine_upgrade, ms: u.ms }
8574
+ : { state: u.state, reason: u.reason };
8575
+ if (u.exit === 0) r = G.graphStatus(claudeDir, root, { enabled });
8576
+ }
8577
+ // What the lane does NEXT depends on these two, and the lane reads no key.
8578
+ r.auto_update = autoUpdate;
8579
+ r.notes = String(cfg("code_graph_notes"));
8580
+ if (healed) r.healed = healed;
8581
+ const b = r.behind || {};
8582
+ const lines = {
8583
+ off: "graph: off",
8584
+ none: "graph: none — no index yet (run: orc graph update)",
8585
+ unavailable: `graph: unavailable — ${r.reason}`,
8586
+ fresh: `graph: FRESH — ${plural(r.files, "file")} · ${plural(r.symbols, "symbol")} · gen ${r.generation} (updated ${r.updated_at})`,
8587
+ drifted:
8588
+ `graph: DRIFTED — ${plural((b.added || 0) + (b.changed || 0) + (b.deleted || 0), "file")} behind ` +
8589
+ `(${b.added || 0} added · ${b.changed || 0} changed · ${b.deleted || 0} deleted` +
8590
+ `${r.extractor_stale ? " · extractor upgraded" : ""}); hints only, code wins (run: orc graph update)`,
8591
+ };
8592
+ let line = lines[r.state];
8593
+ let consult = r.state;
8594
+ if (healed && healed.parsed !== undefined) {
8595
+ const moved = healed.added + healed.changed + healed.deleted;
8596
+ const size = `${plural(r.files, "file")} · ${plural(r.symbols, "symbol")}`;
8597
+ consult = healed.state === "built" ? "built" : "updated";
8598
+ line =
8599
+ healed.state === "built"
8600
+ ? `graph: built first index — ${size} (${healed.ms} ms)`
8601
+ : healed.engine_upgrade && !moved
8602
+ ? `graph: index upgraded to the new engine → updated (${healed.ms} ms) — ${size}`
8603
+ : `graph: ${plural(moved, "file")} changed outside ORC → updated (${healed.ms} ms) — ${size}`;
8604
+ } else if (healed) line += ` · could not heal: ${healed.reason}`;
8605
+ return finish(r, line, consult === "off" ? "GRAPH-CONSULT off" : `GRAPH-CONSULT ${consult} :: files=${r.files} symbols=${r.symbols} gen=${r.generation}`);
8606
+ }
8607
+
8608
+ if (sub === "update") {
8609
+ const r = G.graphUpdate(claudeDir, root, {
8610
+ enabled,
8611
+ ifEnabled: flag("--if-enabled") === true,
8612
+ say: asJson ? null : (s) => console.log(s),
8613
+ });
8614
+ let line;
8615
+ if (r.state === "off") line = "graph: off";
8616
+ else if (r.state === "unavailable")
8617
+ line =
8618
+ r.reason === "locked"
8619
+ ? `graph: another update holds the lock (pid ${(r.holder && r.holder.pid) || "?"}) — retry in a moment`
8620
+ : `graph: unavailable — ${r.reason}`;
8621
+ else if (r.state === "unchanged") line = `graph: FRESH — nothing changed · gen ${r.generation} · ${r.ms} ms`;
8622
+ else {
8623
+ const verb = r.state === "built" ? "built first index" : "updated";
8624
+ line =
8625
+ `graph: ${verb} — ${plural(r.files, "file")} (${r.parsed} parsed, ${r.reused} reused, ${r.deleted} deleted` +
8626
+ `${r.skipped ? `, ${r.skipped} skipped` : ""}) · ${r.ms} ms`;
8627
+ }
8628
+ if (r.exit === 0 && !enabled) {
8629
+ line += "\n note: code_graph is off — lanes will not read this graph (orc config set code_graph on)";
8630
+ }
8631
+ const trace =
8632
+ r.exit === 0
8633
+ ? `GRAPH-UPDATE ${r.state} :: parsed=${r.parsed} reused=${r.reused} deleted=${r.deleted} gen=${r.generation} route=${r.route} ms=${r.ms}`
8634
+ : r.state === "off"
8635
+ ? "GRAPH-UPDATE off"
8636
+ : `GRAPH-UPDATE unavailable :: ${r.reason}`;
8637
+ return finish(r, line, trace);
8638
+ }
8639
+
8640
+ // ── EW3: heal on read ──────────────────────────────────────────────────────
8641
+ // W9 round 2 measured a lane that changed a file and never re-indexed it (R3).
8642
+ // The next READ now repairs that, without a background process and without
8643
+ // making every read pay for a full change scan.
8644
+ //
8645
+ // The plan wanted a cheap whole-repo signature first. EW0 measured that idea
8646
+ // and dropped it: the `git status` it needs IS most of the full scan, and a
8647
+ // full scan on every read (206 ms on django/django) costs more than the EW2
8648
+ // cache saves. So the trigger is the two cheap things a read can afford:
8649
+ //
8650
+ // · HEAD moved `git rev-parse HEAD` — 28 ms. A commit, a checkout, a
8651
+ // rebase, a pull: everything that moves the tree at once.
8652
+ // · a TARGET moved one batched `git hash-object` over the paths this read
8653
+ // is actually about. This is the R3 case exactly — an
8654
+ // executor edited a file and nobody re-indexed it.
8655
+ //
8656
+ // What it does NOT catch is an edit to a file this read never mentions. That
8657
+ // one is caught by the lane's own update step and by the run-end hook, and
8658
+ // until then the card about THIS file is still right about this file.
8659
+ //
8660
+ // It never starts a heal it expects to overrun: `meta.update_ms` is the last
8661
+ // update's own duration, and the only honest estimate there is, because an
8662
+ // update cannot be stopped half way. Over the cap, or another writer holds
8663
+ // the lock, the read answers from the old generation — and every touched card
8664
+ // already says `CHANGED since index` for itself.
8665
+ const healOnRead = (paths) => {
8666
+ if (!enabled) return null;
8667
+ const autoUpdate = String(cfg("code_graph_auto_update")) !== "false";
8668
+ const capRaw = Number(cfg("code_graph_heal_ms"));
8669
+ const cap = Number.isFinite(capRaw) ? capRaw : 1500;
8670
+ if (!autoUpdate || cap <= 0) return null;
8671
+ const meta = metaNow();
8672
+ if (!meta) return null; // no graph at all — `update` is the answer, not a heal
8673
+ const last = Number(meta.update_ms) || 0;
8674
+ if (last > cap) return { state: "skipped", reason: "over-cap", last_ms: last, cap_ms: cap };
8675
+
8676
+ let moved = null;
8677
+ try {
8678
+ const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" });
8679
+ if (head.status === 0 && meta.head_commit && head.stdout.trim() !== meta.head_commit) moved = "head";
8680
+ } catch (_) {}
8681
+ if (!moved && paths && paths.length) {
8682
+ try {
8683
+ const files = JSON.parse(fs.readFileSync(G.graphPaths(claudeDir).files, "utf8"));
8684
+ const known = paths.filter((x) => files[x]);
8685
+ if (known.length) {
8686
+ const h = spawnSync("git", ["hash-object", "--stdin-paths"], { cwd: root, encoding: "utf8", input: known.join("\n") + "\n" });
8687
+ if (h.status === 0) {
8688
+ const out = h.stdout.split(/\r?\n/).filter(Boolean);
8689
+ known.forEach((rel, i) => {
8690
+ if (!moved && out[i] && out[i] !== files[rel].blob) moved = "target";
8691
+ });
8692
+ }
8693
+ }
8694
+ // A path the index has never heard of is also a reason to look.
8695
+ if (!moved && paths.some((x) => !files[x] && fs.existsSync(path.join(root, ...x.split("/"))))) moved = "new-path";
8696
+ } catch (_) {}
8697
+ }
8698
+ if (!moved) return null;
8699
+
8700
+ const u = G.graphUpdate(claudeDir, root, { enabled, ifEnabled: true });
8701
+ if (u.exit !== 0) return { state: "skipped", reason: u.reason || u.state, trigger: moved };
8702
+ return { state: u.state, trigger: moved, added: u.added, changed: u.changed, deleted: u.deleted, generation: u.generation, ms: u.ms };
8703
+ };
8704
+ // Only the paths a read names can be checked cheaply, so normalise them the
8705
+ // way the store does.
8706
+ const asPaths = (list) => list.map((x) => String(x).split("\\").join("/").replace(/^\.\//, "").replace(/:\d+$/, ""));
8707
+
8708
+ if (sub === "coverage") {
8709
+ if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
8710
+ const VALUE_FLAGS = new Set(["--dir"]);
8711
+ const plain = [];
8712
+ for (let i = 0; i < args.length; i++) {
8713
+ if (VALUE_FLAGS.has(args[i])) {
8714
+ i++;
8715
+ continue;
8716
+ }
8717
+ if (!String(args[i]).startsWith("--")) plain.push(args[i]);
8718
+ }
8719
+ const paths = plain.slice(2);
8720
+ if (!paths.length) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
8721
+ const healed = healOnRead(asPaths(paths));
8722
+ const r = G.graphCoverage(claudeDir, root, { paths });
8723
+ if (healed) r.healed = healed;
8724
+ if (r.exit === 1) return finish(r, "graph: none — no index yet (run: orc graph update)");
8725
+ const LABEL = (x) =>
8726
+ x.coverage === "full"
8727
+ ? "full"
8728
+ : x.coverage === "partial"
8729
+ ? `partial ${(x.ranges || []).map((g) => `${g[0]}-${g[1]}`).join(",")}`
8730
+ : x.coverage;
8731
+ const line =
8732
+ `graph coverage (gen ${r.generation}) — ${plural(r.gaps, "gap")} in ${plural(r.rows.length, "path")}\n` +
8733
+ r.rows.map((x) => ` ${x.path} ${LABEL(x)}${x.changed_since_index && x.changed_since_index !== "current" ? ` [${x.changed_since_index} since index]` : ""}`).join("\n") +
8734
+ "\n no recorded gap is not proof of completeness — the source is ground truth";
8735
+ return finish(r, line, `GRAPH-COVERAGE ${r.gaps ? "gaps" : "clean"} :: paths=${r.rows.length} gaps=${r.gaps} gen=${r.generation}`);
8736
+ }
8737
+
8738
+ // ── E7: the cheap signals ──────────────────────────────────────────────────
8739
+ if (sub === "changes" || sub === "cochange") {
8740
+ if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
8741
+ const VALUE_FLAGS = new Set(["--dir", "--base"]);
8742
+ const plain = [];
8743
+ for (let i = 0; i < args.length; i++) {
8744
+ if (VALUE_FLAGS.has(args[i])) {
8745
+ i++;
8746
+ continue;
8747
+ }
8748
+ if (!String(args[i]).startsWith("--")) plain.push(args[i]);
8749
+ }
8750
+ const SIG = require("./graph-signals.js");
8751
+ if (sub === "changes") {
8752
+ const base = typeof flag("--base") === "string" ? flag("--base") : null;
8753
+ const healed = healOnRead(SIG.changedPaths(root, base));
8754
+ const r = SIG.graphChanges(claudeDir, root, { base });
8755
+ if (healed) r.healed = healed;
8756
+ if (r.exit !== 0) return finish(r, r.reason === "no-index" ? "graph: none — no index yet (run: orc graph update)" : `graph: unavailable — ${r.reason}`);
8757
+ const c = r.counts;
8758
+ const line = r.symbols.length
8759
+ ? `graph changes vs ${r.base} — ${plural(r.symbols.length, "symbol")} in ${plural(r.files.length, "file")} · ${c.high} high · ${c.medium} medium · ${c.low} low\n` +
8760
+ r.symbols.map((x) => ` ${x.risk.padEnd(6)} ${x.qname} ${x.file}:${x.lines[0]}-${x.lines[1]} (${x.why.join(" · ")})`).join("\n") +
8761
+ (r.not_in_graph.length ? `\n not in the graph: ${r.not_in_graph.join(", ")}` : "") +
8762
+ "\n risk is a HINT with its reason attached — an empty impact is never proof nothing depends on the change"
8763
+ : `graph changes vs ${r.base} — no indexed symbol was touched${r.not_in_graph.length ? ` (changed, but not in the graph: ${r.not_in_graph.join(", ")})` : ""}`;
8764
+ return finish(r, line, `GRAPH-CHANGES ${r.symbols.length ? "found" : "none"} :: symbols=${r.symbols.length} high=${c.high} medium=${c.medium} low=${c.low} gen=${r.generation}`);
8765
+ }
8766
+ const file = plain[2];
8767
+ if (!file) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
8768
+ const r = SIG.graphCochange(claudeDir, root, { file });
8769
+ if (r.exit === 1) return finish(r, r.reason === "missing-operand" ? usage : `graph: unavailable — ${r.reason}`);
8770
+ const line = r.rows.length
8771
+ ? `graph cochange ${r.file} — changed in ${plural(r.commits, "commit")} since ${r.since}; these changed WITH it at least ${r.min} times\n` +
8772
+ r.rows.map((x) => ` ${String(x.together).padStart(3)}× ${x.file} (${Math.round(x.share * 100)}% of its commits)`).join("\n") +
8773
+ (r.more ? `\n ${r.more} more below the shown rows` : "") +
8774
+ "\n history, not structure — it says what people changed together, never what depends on what"
8775
+ : `graph cochange ${r.file} — nothing changed with it at least ${r.min} times since ${r.since} (it changes alone)`;
8776
+ return finish(r, line, `GRAPH-COCHANGE ${r.rows.length ? "found" : "none"} :: rows=${r.rows.length} commits=${r.commits}`);
8777
+ }
8778
+
8779
+ if (sub === "gc") {
8780
+ const r = G.graphGc(claudeDir);
8781
+ const line =
8782
+ r.exit === 0
8783
+ ? `graph: gc — ${plural(r.removed, "record")} removed, ${r.kept} kept (${Math.round(r.bytes_freed / 1024)} KB freed)` +
8784
+ `${r.notes && r.notes.removed ? ` · ${plural(r.notes.removed, "old note")} compacted` : ""}`
8785
+ : r.reason === "locked"
8786
+ ? "graph: another update holds the lock — retry in a moment"
8787
+ : "graph: none — nothing to collect";
8788
+ return finish(r, line);
8789
+ }
8790
+
8791
+ if (sub === "notes") {
8792
+ const NOTE_VALUE_FLAGS = new Set(["--files", "--cap", "--min", "--at", "--dir"]);
8793
+ const words = [];
8794
+ for (let i = 0; i < args.length; i++) {
8795
+ if (NOTE_VALUE_FLAGS.has(args[i])) {
8796
+ i++;
8797
+ continue;
8798
+ }
8799
+ if (!String(args[i]).startsWith("--")) words.push(args[i]);
8800
+ }
8801
+ const action = words[2];
8802
+ const N = require("./graph-notes.js");
8803
+ if (action === "pending") {
8804
+ const notesMode = String(cfg("code_graph_notes"));
8805
+ const gated = flag("--if-enabled") === true;
8806
+ if (gated && (!enabled || notesMode === "off"))
8807
+ return finish({ ok: true, state: "off", enabled, notes: notesMode, exit: 3 }, enabled ? "graph notes: off" : "graph: off", "GRAPH-NOTES off");
8808
+ // `--at wave|end` names the CALL SITE. A batch the configured mode places at
8809
+ // the other site is DEFERRED — an answer (exit 5), never an error.
8810
+ const at = flag("--at");
8811
+ if (gated && typeof at === "string" && at !== notesMode)
8812
+ return finish(
8813
+ { ok: true, state: "deferred", notes: notesMode, at, exit: 5 },
8814
+ `graph notes: deferred — code_graph_notes is '${notesMode}', and this is the '${at}' batch`,
8815
+ `GRAPH-NOTES deferred :: mode=${notesMode} at=${at}`
8816
+ );
8817
+ const raw = flag("--files");
8818
+ const files = typeof raw === "string" ? raw.split(",").map((s) => s.trim()).filter(Boolean) : [];
8819
+ const r = N.notesPending(claudeDir, {
8820
+ files,
8821
+ cap: flag("--cap") === undefined ? cfg("code_graph_notes_cap") : flag("--cap"),
8822
+ min: flag("--min") === undefined ? cfg("code_graph_notes_min") : flag("--min"),
8823
+ });
8824
+ r.notes = notesMode;
8825
+ let line;
8826
+ if (r.state === "usage") line = usage;
8827
+ else if (r.state === "none" && r.reason === "no-index") line = "graph: none — no index yet (run: orc graph update)";
8828
+ else if (r.state === "none") line = "graph notes: nothing pending for these files";
8829
+ else if (r.state === "below-min")
8830
+ line = `graph notes: ${r.total} pending — below the minimum of ${r.min}; they wait for a later batch (nothing is lost)`;
8831
+ else
8832
+ line =
8833
+ `graph notes: ${r.total} pending — ${r.rows.length} in this batch${r.more ? `, ${r.more} more after it` : ""}\n` +
8834
+ r.rows.map((x) => ` ${x.sym} :${x.lines[0]}-${x.lines[1]} ${x.body_hash}`).join("\n");
8835
+ if (r.missing && r.missing.length) line += `\n not in the graph: ${r.missing.join(", ")}`;
8836
+ // `pending` has no trace here: the noter's ONE-line return is the GRAPH-NOTES line.
8837
+ const trace =
8838
+ r.state === "none" && r.reason !== "no-index"
8839
+ ? "GRAPH-NOTES none :: nothing pending"
8840
+ : r.state === "below-min"
8841
+ ? `GRAPH-NOTES below-min :: ${r.total} pending, min ${r.min}`
8842
+ : null;
8843
+ return finish(r, line, trace);
8844
+ }
8845
+ if (action === "apply") {
8846
+ const src = words[3];
8847
+ if (!src) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
8848
+ let text;
8849
+ try {
8850
+ text = src === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(src), "utf8");
8851
+ } catch (e) {
8852
+ return finish({ ok: false, state: "unavailable", reason: "unreadable", source: src, exit: 1 }, `graph notes: cannot read ${src}`);
8853
+ }
8854
+ const r = N.notesApply(claudeDir, text);
8855
+ const reasons = {};
8856
+ for (const x of r.rejected || []) reasons[x.reason] = (reasons[x.reason] || 0) + 1;
8857
+ const why = Object.entries(reasons).map(([k, v]) => `${v} ${k}`).join(", ");
8858
+ const line =
8859
+ r.state === "none"
8860
+ ? "graph: none — no index yet (run: orc graph update)"
8861
+ : r.reason === "locked"
8862
+ ? "graph notes: another update holds the lock — retry in a moment"
8863
+ : r.state === "invalid"
8864
+ ? `graph notes: invalid input (${r.reason})`
8865
+ : `notes: ${r.applied} applied · ${(r.rejected || []).length} rejected${why ? ` · ${why}` : ""}`;
8866
+ return finish(r, line);
8867
+ }
8868
+ return finish({ ok: false, reason: "unknown-subcommand", usage, exit: 1 }, usage);
8869
+ }
8870
+
8871
+ if (sub === "ctx" || sub === "impact" || sub === "path") {
8872
+ if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
8873
+ // Positionals after the subcommand, with every value-flag's value removed.
8874
+ const VALUE_FLAGS = new Set(["--depth", "--budget", "--dir", "--format", "--offset"]);
8875
+ const plain = [];
8876
+ for (let i = 0; i < args.length; i++) {
8877
+ if (VALUE_FLAGS.has(args[i])) {
8878
+ i++;
8879
+ continue;
8880
+ }
8881
+ if (!String(args[i]).startsWith("--")) plain.push(args[i]);
8882
+ }
8883
+ const operands = plain.slice(2);
8884
+ // `path` names two symbols, not files, so it has nothing cheap to check —
8885
+ // it reads whatever generation is on disk, like every other command did
8886
+ // before EW3.
8887
+ const healed = sub === "path" ? null : healOnRead(asPaths(operands));
8888
+ const Q = require("./graph-query.js");
8889
+ const model = Q.loadModel(claudeDir, root);
8890
+ if (!model) return finish({ ok: false, state: "none", reason: "no-index", exit: 1 }, "graph: none — no index yet (run: orc graph update)");
8891
+ const wikiDocsFor = (files) => {
8892
+ try {
8893
+ const ws = wikiState(claudeDir);
8894
+ if (ws.state !== "registered" || !ws.meta) return [];
8895
+ return (ws.meta.docs || []).filter((d) => files.some((f) => docCovers(d, f))).map((d) => path.basename(d.file));
8896
+ } catch (_) {
8897
+ return [];
8898
+ }
8899
+ };
8900
+ const opts = {
8901
+ depth: flag("--depth"),
8902
+ budget: flag("--budget") === undefined ? cfg("code_graph_card_budget") : flag("--budget"),
8903
+ // E6: a FORMAT, never a filter — the same rows, the same order, the same
8904
+ // JSON. `prose` is the default because a shorter card that is read wrong
8905
+ // costs more than it saves.
8906
+ format: String(flag("--format") || "prose") === "tree" ? "tree" : "prose",
8907
+ offset: flag("--offset"),
8908
+ wikiDocsFor,
8909
+ };
8910
+ let r;
8911
+ if (sub === "ctx") {
8912
+ if (!operands[0]) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
8913
+ if (operands.length === 1) r = Q.ctx(model, operands[0], opts);
8914
+ else {
8915
+ // Several targets, ONE call, ONE budget (W9): a slice builder that had to
8916
+ // loop over its declared files ran the first and skipped the rest.
8917
+ const list = operands.slice(0, 5);
8918
+ const max = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
8919
+ const each = Math.max(100, Math.floor(max / list.length));
8920
+ const parts = list.map((q) => ({ query: q, res: Q.ctx(model, q, { ...opts, budget: each }) }));
8921
+ const found = parts.filter((p) => p.res.ok);
8922
+ r = {
8923
+ ok: found.length > 0,
8924
+ state: found.length ? "found" : "not-found",
8925
+ query: list.join(" "),
8926
+ cards: parts.map((p) => ({ query: p.query, state: p.res.state, used: p.res.budget ? p.res.budget.used : 0 })),
8927
+ card: found.map((p) => p.res.card).join("\n\n") || null,
8928
+ budget: { used: found.reduce((a, p) => a + p.res.budget.used, 0), max },
8929
+ exit: found.length ? 0 : 4,
8930
+ };
8931
+ }
8932
+ } else if (sub === "impact") {
8933
+ if (!operands.length) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
8934
+ r = Q.impact(model, operands, opts);
8935
+ } else {
8936
+ if (operands.length < 2) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
8937
+ r = Q.pathBetween(model, operands[0], operands[1]);
8938
+ }
8939
+ let line = r.card;
8940
+ if (!line && r.state === "not-found") {
8941
+ const near = (r.nearest || []).map((s) => `${s.qname} (${s.file}:${s.lines[0]})`);
8942
+ line = r.missing
8943
+ ? `graph: not in the graph — ${r.missing.join(", ")}`
8944
+ : `graph: nothing named "${r.query || operands.join(" ")}"${near.length ? ` — nearest: ${near.join(", ")}` : ""}`;
8945
+ if (sub === "path") line = "graph: path needs two known symbols — " + ((r.candidates || []).length ? `ambiguous: ${r.candidates.map((s) => `${s.qname} (${s.file})`).join(", ")}` : "one end was not found");
8946
+ }
8947
+ if (!line && r.state === "ambiguous-target")
8948
+ line = `graph: "${r.query}" names ${r.candidates.length} symbols — pick one:\n` + r.candidates.map((s) => ` ${s.id}`).join("\n");
8949
+ if (healed) r.healed = healed;
8950
+ // A heal is a real event and it gets ONE line above the card — a card that
8951
+ // silently answers from a different generation than the reader expected is
8952
+ // the confusion this whole wave exists to remove.
8953
+ if (healed && healed.state && healed.state !== "skipped" && healed.state !== "unchanged") {
8954
+ const n = (healed.added || 0) + (healed.changed || 0) + (healed.deleted || 0);
8955
+ line = `graph: ${plural(n, "file")} changed outside ORC → healed on read (${healed.ms} ms) · gen ${healed.generation}
8956
+ ${line || ""}`;
8957
+ } else if (healed && healed.state === "skipped") {
8958
+ line =
8959
+ `graph: not healed on read (${healed.reason === "over-cap" ? `the last update took ${healed.last_ms} ms, over the ${healed.cap_ms} ms cap` : healed.reason}) — cards are hints where they say CHANGED
8960
+ ` +
8961
+ (line || "");
8962
+ }
8963
+ const trace =
8964
+ sub === "ctx" && r.exit === 0
8965
+ ? `GRAPH-CONSULT card :: targets=${(r.cards ? r.cards.filter((c) => c.state === "found").map((c) => c.query) : [operands[0]]).join(",")}` +
8966
+ (healed && healed.state && healed.state !== "skipped" ? ` healed=${healed.trigger}` : "")
8967
+ : null;
8968
+ return finish(r, line, trace, !r.card);
8969
+ }
8970
+
8971
+ if (asJson) emitJson({ ok: false, reason: "unknown-subcommand", usage }, 1);
8972
+ console.log(usage);
8973
+ process.exit(1);
8974
+ }
8975
+
8340
8976
  function gotcha() {
8341
8977
  if (flag("--global")) {
8342
8978
  console.error("❌ orc gotcha is project-scoped — the memory is this repo's. Run it from the project (or with --dir <path>).");
@@ -33024,6 +33660,45 @@ function doctor() {
33024
33660
  }
33025
33661
  } catch (_) {}
33026
33662
 
33663
+ // 5a-ter) the code graph (v1.8.0). ONE finding, and only while `code_graph` is
33664
+ // on — the read-gate rule. DRIFTED is the only state worth a line: NONE is
33665
+ // built by the next code lane's preflight, and a doctor that warns about a
33666
+ // graph nobody has asked for yet is a doctor people learn to scroll past.
33667
+ try {
33668
+ const cfg = resolvedConfig(claudeDir);
33669
+ if (String(cfg.code_graph || "off") !== "on") ok("code graph off (fine — the default)");
33670
+ else {
33671
+ const gs = require("./graph.js").graphStatus(claudeDir, repoRootOf(claudeDir), { enabled: true });
33672
+ if (gs.state === "drifted") {
33673
+ const b = gs.behind || {};
33674
+ const n = (b.added || 0) + (b.changed || 0) + (b.deleted || 0);
33675
+ warn(
33676
+ "graph-drifted",
33677
+ `code graph is DRIFTED — ${n} file(s) changed since the index${gs.engine_stale ? " (and the extractor was upgraded)" : ""}; lanes treat it as hints until it is updated. Fix: \`orc graph update\` (FREE — parser only, no model)`,
33678
+ { fix: "orc graph update", fix_command: "orc graph update" }
33679
+ );
33680
+ } else ok(`code graph ${gs.state}`);
33681
+ // EW3/EW4 — the delivery and self-update hook. Only ever raised while the
33682
+ // feature is ARMED: a warning about a hook for a feature you switched off
33683
+ // is exactly the noise that teaches people to ignore doctor.
33684
+ if (String(cfg.code_graph_hooks === undefined ? "on" : cfg.code_graph_hooks) === "on") {
33685
+ let hk = {};
33686
+ try {
33687
+ hk = (JSON.parse(fs.readFileSync(path.join(claudeDir, "settings.json"), "utf8")) || {}).hooks || {};
33688
+ } catch (_) {}
33689
+ const wiredOn = (arr) => Array.isArray(arr) && arr.some((e) => (e.hooks || []).some((h) => String(h.command || "").includes("orc-graph-hook")));
33690
+ const wired = ["SubagentStart", "SubagentStop", "PreToolUse", "PostToolUse"].filter((e) => wiredOn(hk[e]));
33691
+ if (wired.length === 4) ok("graph hook wired on all four events");
33692
+ else
33693
+ warn(
33694
+ "graph-hook-unwired",
33695
+ `graph hook wired on ${wired.length} of 4 events (${wired.join(", ") || "none"}) — the graph will not update itself when an executor finishes, and subagents get no anchors; run \`orc update\``,
33696
+ { fixable: true, fix: "orc update", fix_command: "orc update" }
33697
+ );
33698
+ }
33699
+ }
33700
+ } catch (_) {}
33701
+
33027
33702
  // 5b) the wiki (v0.49.1). Exactly TWO findings, and the restraint is the
33028
33703
  // design: a doctor that warns about normal states teaches people to ignore
33029
33704
  // doctor. Both route to the Knowledge panel via FINDING_ROUTE — a caution
@@ -39364,7 +40039,23 @@ Usage:
39364
40039
  (exit 0 = entries, 1 = none); list also prints them
39365
40040
  orc gotcha prune archive the low-value tail (fewest hits, then oldest)
39366
40041
  down to gotchas_max → gotchas-archive.md; never deletes
39367
- orc mock [--dir <path>] mocked runnable examples left by a green verify
40042
+ orc graph [--dir <path>] the code graph — a local map of how this repo is connected
40043
+ (project-scoped; no --global; key code_graph, default off)
40044
+ orc graph status [--json] is the map fresh against the working tree
40045
+ (exit 0 FRESH · 1 NONE · 2 DRIFTED · 3 OFF)
40046
+ orc graph update [--if-enabled] find changes from git blob SHAs, parse only those
40047
+ (exit 0 · 1 unavailable/locked · 3 off with --if-enabled)
40048
+ orc graph gc [--json] remove records no file points at
40049
+ orc graph ctx <symbol|file[:line]> a budgeted context card: callers, calls, effects, wiki, tests
40050
+ [--depth 2] [--budget 1200] (exit 4 = not found/ambiguous)
40051
+ orc graph impact <file…> transitive callers of everything in these files
40052
+ [--depth 3] [--budget 1500]
40053
+ orc graph path <from> <to> the shortest confident call chain (exit 4 = none)
40054
+ orc graph notes pending --files <a,b> symbols whose current body has no note (exit 5 = none
40055
+ or below --min 5); never repo-wide
40056
+ orc graph notes apply <file|-> validate + store notes from the noter agent (exit 6 =
40057
+ some rows rejected, each named)
40058
+ orc mock [--dir <path>] mocked runnable examples left by a green verify
39368
40059
  (project-scoped; no --global) — read-only, never runs one
39369
40060
  orc mock list [--json] every mock-examples/<slug>/, newest first
39370
40061
  orc mock show <slug> [--json] EXAMPLE.md + the file tree for one example
@@ -40842,6 +41533,7 @@ const STATUSLINE_COMPONENTS = [
40842
41533
  slRow({ id: "wiki-distance", group: "D", label: "c", summary: "Commits since the wiki was scanned.", renderers: ["bare", "plain", "label-value", "paren"], defaults: { render: "paren", suffix: "c" }, states: ["fresh", "aging", "stale"], cost: "new-read", binding: "wiki.distance", state_binding: "wiki.tier" }),
40843
41534
  slRow({ id: "wiki-worst", group: "D", label: "worst", summary: "The wiki document deciding its tier.", renderers: ["bare", "plain", "label-value"], defaults: { render: "bare", truncate: "middle", max_len: 24 }, unknown: "hide", cost: "refused", refused_reason: "freshness is COVERAGE-RELATIVE, so finding the worst document means one `git rev-list` PER DOCUMENT against that document's own covered files. `orc wiki status` computes it; a status line cannot afford N subprocesses on a surface that redraws on every keystroke." }),
40844
41535
  slRow({ id: "wiki-coverage", group: "D", label: "cov", summary: "How much of the repo the wiki covers.", renderers: ["plain", "bare", "label-value"], defaults: { render: "plain", format: "percent" }, cost: "refused", refused_reason: "coverage is COMPUTED, not stored: it needs the tracked-file list, which is a `git ls-files` subprocess, and then a match per document. `orc wiki coverage` is where that answer lives. A status line reading a field wiki-meta.json does not have would render a confident number nobody measured." }),
41536
+ slRow({ id: "graph", group: "D", label: "graph", summary: "The local code graph, from its own index. This is the FLOOR: it compares the commit the index was built at with HEAD, read from .git with no subprocess, and never walks the tree — so `behind` means HEAD moved, never that files changed. `orc graph status` is the real check, and it also sees uncommitted edits.", renderers: ["word", "shape", "badge"], defaults: { render: "word" }, states: ["fresh", "behind", "none", "off"], shapes: { fresh: "●", behind: "◐", none: "·", off: "○" }, cost: "new-read", binding: "graph.state", state_binding: "graph.state" }),
40845
41537
  slRow({ id: "wiki-docs", group: "D", label: "docs", summary: "How many wiki documents are registered.", renderers: ["plain", "label-value", "bare"], defaults: { render: "plain" }, cost: "new-read", binding: "wiki.docs" }),
40846
41538
  slRow({ id: "wiki-blindspot", group: "D", label: "blind", summary: "Whether the wiki has a STRUCTURAL blind spot. That is a coverage gap, not doc rot.", renderers: ["plain", "bare"], defaults: { render: "plain" }, unknown: "hide", cost: "refused", refused_reason: "a blind spot is files changed since the floor that NO document covers — the same per-document git walk `wiki-worst` needs. `orc wiki status` reports it, and `orc wiki impact` escalates it, which is the right place for that signal." }),
40847
41539
  slRow({ id: "pattern", group: "D", label: "pattern", summary: "Whether a code pattern is cached for this project.", renderers: ["word", "plain", "label-value", "bare", "shape"], defaults: { render: "plain" }, states: ["cached", "none"], shapes: { cached: "●", none: "○" }, cost: "new-read", binding: "pattern.state", state_binding: "pattern.state" }),
@@ -41374,7 +42066,10 @@ function slCompile(layout) {
41374
42066
  // W3's groups all read files the shipped status line never opens, so they
41375
42067
  // share one gate — the extended scan — and each sub-read inside it carries
41376
42068
  // its own TTL. One gate, many clocks.
41377
- const EXTENDED = /^(wiki\.(coverage|docs|worst|blindspot)|pattern\.|crosslink\.|gotchas\.|extra\.(profile|provider|spend|tasks|inflight|demoted|passphrase|orphans|reliability)|wait\.|preset\.|run\.(wave|wave_total|resume|open)|pact\.|boundary\.|challenge\.|doc\.|usage\.|config\.)/;
42069
+ // v1.8.0 — `graph.` joins it: the code graph row reads meta.json and .git inside
42070
+ // the extended scan, on the knowledge clock. Without it the lock never names
42071
+ // `scan.extended` and the row renders an em dash forever.
42072
+ const EXTENDED = /^(wiki\.(coverage|docs|worst|blindspot)|pattern\.|crosslink\.|gotchas\.|graph\.|extra\.(profile|provider|spend|tasks|inflight|demoted|passphrase|orphans|reliability)|wait\.|preset\.|run\.(wave|wave_total|resume|open)|pact\.|boundary\.|challenge\.|doc\.|usage\.|config\.)/;
41378
42073
  const note = (comp, item) => {
41379
42074
  // A GROUP'S CHILDREN ARE READS TOO. Without this the read planner would
41380
42075
  // fetch nothing for a group and every child would render an em dash — the
@@ -44035,6 +44730,9 @@ function jsonCrash(err) {
44035
44730
  case "gotcha":
44036
44731
  gotcha();
44037
44732
  break;
44733
+ case "graph":
44734
+ graphCmd();
44735
+ break;
44038
44736
  case "mock":
44039
44737
  mock();
44040
44738
  break;