@azure-id/orc 1.9.0 → 1.9.2

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 (134) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README-id.md +57 -59
  3. package/README.md +53 -54
  4. package/bin/build-agents.js +109 -109
  5. package/bin/cli.js +262 -58
  6. package/bin/graph-audit.js +235 -0
  7. package/bin/graph-extract.js +308 -3
  8. package/bin/graph-gain.js +23 -3
  9. package/bin/graph-map.js +9 -1
  10. package/bin/graph-query.js +152 -10
  11. package/bin/graph-shard.js +6 -1
  12. package/bin/graph-signals.js +287 -4
  13. package/bin/graph.js +717 -658
  14. package/bin/onboarding-content.js +4 -4
  15. package/bin/pricing.json +207 -200
  16. package/bin/verify-contracts.js +117 -2
  17. package/bin/verify-package.js +11 -7
  18. package/bin/webui/css/04-motion.css +162 -152
  19. package/bin/webui/css/panels/knowledge.css +563 -116
  20. package/bin/webui/fixtures/extra.js +2036 -2036
  21. package/bin/webui/fixtures/hookui.js +5 -5
  22. package/bin/webui/fixtures/knowledge.js +21 -4
  23. package/bin/webui/fixtures/settings.js +305 -305
  24. package/bin/webui/i18n/en/knowledge.json +122 -4
  25. package/bin/webui/i18n/id/knowledge.json +122 -4
  26. package/bin/webui/js/panels/knowledge.js +573 -9
  27. package/bin/webui/js/panels/overview.js +13 -4
  28. package/mock-run/context-combiner.md +100 -100
  29. package/mock-run/orc-budget.md +534 -534
  30. package/mock-run/orc-challenge-council.md +262 -262
  31. package/mock-run/orc-ultra.md +103 -103
  32. package/package.json +1 -1
  33. package/templates/agents/MODEL-MAPPING.md +43 -43
  34. package/templates/agents/orc-advisor-opus-5-xhigh.md +56 -56
  35. package/templates/agents/orc-analyze-mini-opus-5-med.md +60 -60
  36. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +58 -58
  37. package/templates/agents/orc-challenge-advisor-opus-5-med.md +75 -75
  38. package/templates/agents/orc-challenge-contrarian-opus-5-high.md +110 -110
  39. package/templates/agents/orc-challenge-executor-opus-5-med.md +114 -114
  40. package/templates/agents/orc-challenge-expansionist-opus-5-med.md +112 -112
  41. package/templates/agents/orc-challenge-judge-opus-5-high.md +132 -132
  42. package/templates/agents/orc-challenge-outsider-opus-5-low.md +109 -109
  43. package/templates/agents/orc-challenge-principles-opus-5-high.md +109 -109
  44. package/templates/agents/orc-challenge-reader-opus-5-low.md +90 -90
  45. package/templates/agents/orc-claude-writer-opus-5-med.md +55 -55
  46. package/templates/agents/orc-context-combiner-opus-5-high.md +88 -88
  47. package/templates/agents/orc-doc-checker-opus-5-low.md +108 -108
  48. package/templates/agents/orc-doc-writer-opus-5-med.md +134 -134
  49. package/templates/agents/orc-executor-haiku-4-5.md +2 -1
  50. package/templates/agents/orc-executor-opus-4-7-high.md +2 -1
  51. package/templates/agents/orc-executor-opus-4-7-med.md +2 -1
  52. package/templates/agents/orc-executor-opus-4-8-high.md +2 -1
  53. package/templates/agents/orc-executor-opus-5-high.md +4 -3
  54. package/templates/agents/orc-executor-opus-5-low.md +4 -3
  55. package/templates/agents/orc-executor-opus-5-med.md +4 -3
  56. package/templates/agents/orc-executor-sonnet-4-6-high.md +2 -1
  57. package/templates/agents/orc-executor-sonnet-4-6-med.md +2 -1
  58. package/templates/agents/orc-executor-sonnet-5-high.md +2 -1
  59. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -1
  60. package/templates/agents/orc-judge-opus-5-xhigh.md +85 -85
  61. package/templates/agents/orc-learn-writer-opus-5-low.md +73 -73
  62. package/templates/agents/orc-pattern-codifier-opus-5-med.md +65 -65
  63. package/templates/agents/orc-planner-mini-opus-5-med.md +4 -4
  64. package/templates/agents/orc-planner-mini-sonnet-5-high.md +1 -1
  65. package/templates/agents/orc-planner-opus-5-med.md +160 -160
  66. package/templates/agents/orc-recon-opus-5-low.md +18 -8
  67. package/templates/agents/orc-recon-sonnet-4-6-med.md +15 -5
  68. package/templates/agents/orc-retro-opus-5-med.md +3 -3
  69. package/templates/agents/orc-reviewer-opus-5-med.md +60 -60
  70. package/templates/agents/orc-scout-opus-5-low.md +40 -40
  71. package/templates/agents/orc-system-analyst-opus-5-high.md +120 -120
  72. package/templates/agents/orc-test-author-opus-5-med.md +71 -71
  73. package/templates/agents/orc-test-designer-opus-5-high.md +158 -158
  74. package/templates/agents/orc-test-interpreter-opus-5-low.md +130 -130
  75. package/templates/agents/orc-verifier-opus-5-med.md +69 -69
  76. package/templates/agents/orc-wiki-scanner-opus-5-med.md +81 -81
  77. package/templates/commands/orc-ultra.md +17 -17
  78. package/templates/commands/orc-verify.md +11 -11
  79. package/templates/hooks/README.md +13 -3
  80. package/templates/hooks/orc-effort-guard.js +178 -178
  81. package/templates/hooks/orc-graph-hook.js +53 -7
  82. package/templates/hooks/orc-statusline.js +6 -6
  83. package/templates/skills/_shared/code-graph.md +57 -18
  84. package/templates/skills/_shared/config-precedence.md +198 -198
  85. package/templates/skills/_shared/extra-dispatch.md +1346 -1346
  86. package/templates/skills/_shared/opus5-only.md +8 -8
  87. package/templates/skills/_shared/phases/analyst-gates.md +136 -136
  88. package/templates/skills/_shared/phases/execution.md +4 -4
  89. package/templates/skills/_shared/phases/planning.md +3 -3
  90. package/templates/skills/_shared/phases/preflight.md +1 -1
  91. package/templates/skills/_shared/phases/review.md +4 -2
  92. package/templates/skills/_shared/phases/ship.md +1 -1
  93. package/templates/skills/_shared/phases/trace.md +3 -2
  94. package/templates/skills/_shared/read-ladder.md +14 -1
  95. package/templates/skills/_shared/return-validation.md +1 -1
  96. package/templates/skills/context-combiner/SKILL.md +1 -1
  97. package/templates/skills/context-combiner/schemas/combined-report.md +78 -78
  98. package/templates/skills/orc/README.md +2 -2
  99. package/templates/skills/orc/SKILL.md +8 -8
  100. package/templates/skills/orc/config.md +9 -9
  101. package/templates/skills/orc/examples/full-run-mock.md +73 -73
  102. package/templates/skills/orc/references/effort-and-mode.md +222 -222
  103. package/templates/skills/orc/references/preflight-report.md +2 -2
  104. package/templates/skills/orc/references/ultra-mode.md +3 -3
  105. package/templates/skills/orc/subskills/orc-planner/SKILL.md +2 -2
  106. package/templates/skills/orc/subskills/orc-planner-mini/SKILL.md +121 -121
  107. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +76 -76
  108. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +45 -45
  109. package/templates/skills/orc-analyze/SKILL.md +2 -2
  110. package/templates/skills/orc-analyze/examples/analyze-mock.md +42 -42
  111. package/templates/skills/orc-analyze/schemas/report-audit.md +83 -83
  112. package/templates/skills/orc-analyze/schemas/report-prose.md +63 -63
  113. package/templates/skills/orc-analyze/schemas/report-requirement.md +78 -78
  114. package/templates/skills/orc-challenge/README.md +142 -142
  115. package/templates/skills/orc-challenge/examples/council-full-roster.md +273 -273
  116. package/templates/skills/orc-challenge/references/council.md +315 -315
  117. package/templates/skills/orc-challenge/references/intake.md +171 -171
  118. package/templates/skills/orc-claude/SKILL.md +14 -14
  119. package/templates/skills/orc-diy/README.md +143 -143
  120. package/templates/skills/orc-diy/references/flow-schema.md +1 -1
  121. package/templates/skills/orc-doc/README.md +229 -229
  122. package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +325 -325
  123. package/templates/skills/orc-doc/references/chunking.md +527 -527
  124. package/templates/skills/orc-fast/SKILL.md +4 -4
  125. package/templates/skills/orc-learn/SKILL.md +14 -14
  126. package/templates/skills/orc-learn/examples/learn-run-mock.md +61 -61
  127. package/templates/skills/orc-mini/SKILL.md +16 -20
  128. package/templates/skills/orc-mini/references/complexity.md +25 -11
  129. package/templates/skills/orc-quick/SKILL.md +12 -13
  130. package/templates/skills/orc-quick/references/gh-mode.md +1 -1
  131. package/templates/skills/orc-quick/references/look.md +5 -5
  132. package/templates/skills/orc-verify/SKILL.md +15 -15
  133. package/templates/skills/orc-verify/examples/verify-mock.md +33 -33
  134. package/templates/skills/orc-wiki/references/extra.md +1 -1
package/bin/graph.js CHANGED
@@ -1,658 +1,717 @@
1
- "use strict";
2
- // ── orc graph (v1.8.0) — the code graph STORE ───────────────────────────────
3
- //
4
- // A small, local, git-ignored map of how this repository is connected. This
5
- // file owns the store and change detection. Extraction is `graph-extract.js`;
6
- // resolution and the read commands are `graph-query.js`. `bin/cli.js` routes
7
- // `orc graph …` and resolves the config — no module here reads
8
- // `.claude/orc.config.yaml`.
9
- //
10
- // THE RULES THIS FILE HOLDS
11
- //
12
- // 1. A change is found from git's OWN blob SHAs. `git ls-files -s` hashes
13
- // every tracked file in one call; `git status` names the dirty and
14
- // untracked ones, and `git hash-object` hashes only those. Nothing is
15
- // parsed to find out whether it changed. (W0: 42 ms for 7,091 files.)
16
- // 2. A record is CONTENT-ADDRESSED — `blobs/<ab>/<sha>.json`. A branch
17
- // switch back, a revert, or a teammate's identical file reuses it.
18
- // 3. Writes are atomic (temp file + rename) and serialized by `.lock`.
19
- // Readers never take the lock: they see the old index or the new one.
20
- // 4. The write ORDER is blobs → index.json → files.json → meta.json. A crash
21
- // between any two leaves files.json OLD, so the next status reads DRIFTED
22
- // and the next update redoes the work. Idempotent, never half-applied.
23
- // 5. No background process and no timer, ever — a continuous rebuild is how
24
- // the graph tools in the research froze machines. EW3 adds two ONE-SHOT
25
- // triggers (a read that finds its own target stale, and an executor
26
- // finishing). Both take the lock below, and the loser SKIPS rather than
27
- // queues, so nothing can ever pile up.
28
-
29
- const fs = require("fs");
30
- const path = require("path");
31
- const { spawnSync } = require("child_process");
32
- const crypto = require("crypto");
33
- const X = require("./graph-extract.js");
34
-
35
- const SCHEMA = 1;
36
- // Bumped whenever the record shape or extraction changes. A record made by an
37
- // older engine is re-extracted, and status reads DRIFTED until it is.
38
- // @3 (W9): route symbols + `ref` edges — an older index re-extracts once.
39
- // @4 (EW1): per-file COVERAGE and a GENERATION on the index.
40
- // @5 (v1.8.2): `urls`, `mounts`, decorator routes with `handler`, `bases`,
41
- // aliases, re-exports. A 1.8.1 store reads DRIFTED with `engine_stale` and
42
- // the next preflight (`status --heal`) re-extracts every file once.
43
- const ENGINE = "graph@5";
44
- const GIT_MAX_BUFFER = 256 * 1024 * 1024;
45
- const MAX_BYTES = 512 * 1024;
46
- const LOCK_STALE_MS = 10 * 60 * 1000;
47
- // Measured on this machine class: nestjs/nest 1.3 ms per file (heuristic),
48
- // django/django 3.5 ms per file on 1.8.1 and 5.6 ms on 1.8.2 (Python ast +
49
- // masking + the W1/W2 alias, decorator and re-export passes). The estimate
50
- // uses the slower one — a first build that finishes early is fine, one that
51
- // overruns its own estimate teaches people to ignore it.
52
- const EST_MS_PER_FILE = 5.6;
53
- const ESTIMATE_ABOVE = 2000;
54
-
55
- const LANG_BY_EXT = {
56
- ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js",
57
- ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts",
58
- ".py": "py",
59
- ".go": "go",
60
- ".java": "java",
61
- ".cs": "cs",
62
- ".php": "php",
63
- // v1.8.2 W5 (G6) — the heuristic rung gains Ruby, Rust, Kotlin, the two
64
- // single-file component formats, and C/C++.
65
- ".rb": "rb", ".rake": "rb",
66
- ".rs": "rs",
67
- ".kt": "kt", ".kts": "kt",
68
- // A single-file component is its `<script>` block, parsed as js or ts. The
69
- // rest of the file is blanked, so every line number is the file's own.
70
- ".vue": "vue",
71
- ".svelte": "svelte",
72
- ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".cxx": "c", ".hpp": "c", ".hh": "c",
73
- };
74
-
75
- // Never part of the map, whatever git says: ORC's own tree, installed or
76
- // vendored dependencies (sometimes committed), build output, caches, minified
77
- // or bundled JS, generated files, and `.d.ts` declarations (v1.8.2 W2 — a
78
- // declaration file duplicates every symbol of its module and made each one
79
- // AMBIGUOUS). `dist/` and `build/` are skipped at the ROOT only: `pkg/build/`
80
- // is a package name in more than one real repository. A skipped path has no
81
- // record, so `coverage` reports it `excluded`, never silently.
82
- const ALWAYS_SKIP = /^(\.claude\/|dist\/|build\/|out\/|target\/(debug|release)\/)|(^|\/)(node_modules|vendor|__pycache__|\.next|\.nuxt|\.venv|venv|target\/classes)\/|\.(min|bundle|chunk)\.js$|\.d\.ts$|\.generated\.[A-Za-z]+$|\.pb\.go$|_pb2\.py$/;
83
-
84
- function graphPaths(claudeDir) {
85
- const dir = path.join(claudeDir, "orc", "graph");
86
- return {
87
- dir,
88
- meta: path.join(dir, "meta.json"),
89
- files: path.join(dir, "files.json"),
90
- index: path.join(dir, "index.json"),
91
- blobs: path.join(dir, "blobs"),
92
- notes: path.join(dir, "notes.jsonl"),
93
- lock: path.join(dir, ".lock"),
94
- };
95
- }
96
-
97
- function globRe(g) {
98
- const raw = String(g).replace(/^\.\//, "");
99
- const esc = raw
100
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
101
- .replace(/\*\*/g, "__GLOBSTAR__")
102
- .replace(/\*/g, "[^/]*")
103
- .replace(/\?/g, "[^/]")
104
- .replace(/__GLOBSTAR__/g, ".*");
105
- // gitignore's rule (W2): a pattern with no slash matches at ANY depth
106
- // (`*.gen.ts`, `__snapshots__`); one with a slash is anchored at the root.
107
- const anchored = raw.replace(/\/$/, "").includes("/");
108
- return new RegExp("^" + (anchored ? "" : "(?:.*/)?") + esc + "(/|$)");
109
- }
110
-
111
- function makeFilter(ignore) {
112
- const res = (Array.isArray(ignore) ? ignore : []).filter(Boolean).map(globRe);
113
- return (rel) => {
114
- if (ALWAYS_SKIP.test(rel)) return null;
115
- if (res.some((re) => re.test(rel))) return null;
116
- return LANG_BY_EXT[path.extname(rel).toLowerCase()] || null;
117
- };
118
- }
119
-
120
- function git(root, args, input) {
121
- return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER, input });
122
- }
123
-
124
- function stamp(d) {
125
- const p = (n) => String(n).padStart(2, "0");
126
- return `${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
127
- }
128
-
129
- function readJson(file) {
130
- try {
131
- return JSON.parse(fs.readFileSync(file, "utf8"));
132
- } catch (_) {
133
- return null;
134
- }
135
- }
136
-
137
- // Windows can refuse a rename for a moment while another process has the
138
- // target open (a reader mid-parse). A short retry is the whole remedy.
139
- function atomicWrite(file, text) {
140
- const tmp = `${file}.${process.pid}.tmp`;
141
- fs.writeFileSync(tmp, text);
142
- for (let i = 0; ; i++) {
143
- try {
144
- fs.renameSync(tmp, file);
145
- return;
146
- } catch (e) {
147
- if (i >= 5 || (e.code !== "EPERM" && e.code !== "EBUSY" && e.code !== "EACCES")) {
148
- try { fs.rmSync(tmp, { force: true }); } catch (_) {}
149
- throw e;
150
- }
151
- const until = Date.now() + 20 * (i + 1);
152
- while (Date.now() < until) {} // eslint-disable-line no-empty
153
- }
154
- }
155
- }
156
-
157
- // ── the lock ────────────────────────────────────────────────────────────────
158
- function acquireLock(p) {
159
- fs.mkdirSync(p.dir, { recursive: true });
160
- for (let attempt = 0; attempt < 2; attempt++) {
161
- try {
162
- const fd = fs.openSync(p.lock, "wx");
163
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: stamp(new Date()) }));
164
- fs.closeSync(fd);
165
- return { ok: true };
166
- } catch (e) {
167
- if (e.code !== "EEXIST") throw e;
168
- let age = 0;
169
- try {
170
- age = Date.now() - fs.statSync(p.lock).mtimeMs;
171
- } catch (_) {
172
- continue; // released between the open and the stat
173
- }
174
- // A writer that died leaves its lock behind. Ten minutes is far past any
175
- // real update (W0: 1.7 s for Django), so an older lock is a dead one.
176
- if (age > LOCK_STALE_MS) {
177
- try { fs.rmSync(p.lock, { force: true }); } catch (_) {}
178
- continue;
179
- }
180
- return { ok: false, holder: readJson(p.lock), age_ms: Math.round(age) };
181
- }
182
- }
183
- return { ok: false, holder: readJson(p.lock), age_ms: null };
184
- }
185
-
186
- function releaseLock(p) {
187
- try { fs.rmSync(p.lock, { force: true }); } catch (_) {}
188
- }
189
-
190
- // ── detection ───────────────────────────────────────────────────────────────
191
- // Returns { ok, files: Map<rel, blob>, langs, head } or { ok:false, reason }.
192
- function detect(root, opts) {
193
- const langOf = makeFilter(opts && opts.ignore);
194
- const ls = git(root, ["ls-files", "-s", "-z"]);
195
- if (ls.error || ls.status !== 0) return { ok: false, reason: "not-git" };
196
- const files = new Map();
197
- const langs = new Map();
198
- for (const rec of ls.stdout.split("\0")) {
199
- if (!rec) continue;
200
- const tab = rec.indexOf("\t");
201
- if (tab < 0) continue;
202
- const [mode, blob] = rec.slice(0, tab).split(" ");
203
- const rel = rec.slice(tab + 1);
204
- if (mode === "160000") continue; // a submodule is another repository
205
- const lang = langOf(rel);
206
- if (!lang) continue;
207
- // A conflicted file lists up to three stages; the working tree decides
208
- // below, so the first stage seen is only a placeholder.
209
- if (!files.has(rel)) files.set(rel, blob);
210
- langs.set(rel, lang);
211
- }
212
-
213
- const st = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"]);
214
- if (st.error || st.status !== 0) return { ok: false, reason: "git-status-failed" };
215
- const hashMe = [];
216
- for (const rec of st.stdout.split("\0")) {
217
- if (!rec || rec.length < 4) continue;
218
- const x = rec[0];
219
- const y = rec[1];
220
- const rel = rec.slice(3);
221
- const lang = langOf(rel);
222
- if (!lang) continue;
223
- if (y === "D") {
224
- files.delete(rel);
225
- langs.delete(rel);
226
- continue;
227
- }
228
- // `ls-files -s` already carries the STAGED content. Only a working-tree
229
- // difference (M, T, U, A with intent-to-add) or an untracked file needs its
230
- // own hash.
231
- if (x === "?" || x === "U" || y === "M" || y === "T" || y === "U" || y === "A") {
232
- hashMe.push(rel);
233
- langs.set(rel, lang);
234
- }
235
- }
236
- if (hashMe.length) {
237
- // No --no-filters on purpose: the clean filter (autocrlf on Windows) must
238
- // run, or every CRLF file would hash differently from its index blob and
239
- // read as changed forever.
240
- const h = git(root, ["hash-object", "--stdin-paths"], hashMe.join("\n") + "\n");
241
- if (h.error || h.status !== 0) return { ok: false, reason: "hash-object-failed" };
242
- const shas = h.stdout.split(/\r?\n/).filter(Boolean);
243
- hashMe.forEach((rel, i) => {
244
- if (shas[i]) files.set(rel, shas[i]);
245
- });
246
- }
247
- const head = git(root, ["rev-parse", "HEAD"]);
248
- return {
249
- ok: true,
250
- files,
251
- langs,
252
- head: head.status === 0 ? head.stdout.trim() : null,
253
- };
254
- }
255
-
256
- function diffFiles(prev, cur) {
257
- const added = [];
258
- const changed = [];
259
- const deleted = [];
260
- for (const [rel, blob] of cur) {
261
- const was = prev[rel];
262
- if (!was) added.push(rel);
263
- else if (was.blob !== blob) changed.push(rel);
264
- }
265
- for (const rel of Object.keys(prev)) if (!cur.has(rel)) deleted.push(rel);
266
- return { added, changed, deleted };
267
- }
268
-
269
- function blobPath(p, blob) {
270
- return path.join(p.blobs, blob.slice(0, 2), `${blob}.json`);
271
- }
272
-
273
- // ── generation (EW1) ────────────────────────────────────────────────────────
274
- // `generation` counts index-changing writes; `gen_id` names the CONTENT those
275
- // writes produced. A reader compares the number (cheap) and a second store —
276
- // the resolution cache — pins itself to it. Two machines that index the same
277
- // tree get the same `gen_id` and different `generation` numbers, so the id is
278
- // what a card may quote and the number is what a cache may compare.
279
- function genId(filesOut) {
280
- const h = crypto.createHash("sha1");
281
- for (const rel of Object.keys(filesOut).sort()) h.update(`${rel}${(filesOut[rel] || {}).blob || ""}`);
282
- return h.digest("hex").slice(0, 8);
283
- }
284
-
285
- // What the extractor saw of ONE file, from the record it already wrote. A path
286
- // with no record at all is `excluded` — git does not track it, an ignore glob
287
- // dropped it, or the language has no extractor.
288
- function coverageOf(entry) {
289
- if (!entry) return { coverage: "excluded" };
290
- if (entry.skipped) return { coverage: `skipped:${entry.skipped}` };
291
- if (entry.coverage === "partial") return { coverage: "partial", ranges: entry.partial || [] };
292
- return { coverage: "full" };
293
- }
294
-
295
- // ── status ──────────────────────────────────────────────────────────────────
296
- // exit 0 FRESH · 1 NONE (or unavailable) · 2 DRIFTED · 3 OFF
297
- function graphStatus(claudeDir, root, opts) {
298
- const p = graphPaths(claudeDir);
299
- const meta = readJson(p.meta);
300
- const prev = readJson(p.files);
301
- const base = {
302
- ok: true,
303
- enabled: !!opts.enabled,
304
- exists: !!(meta && prev),
305
- files: meta ? meta.files : 0,
306
- symbols: meta ? meta.symbols : 0,
307
- updated_at: meta ? meta.updated_at : null,
308
- head_commit: meta ? meta.head_commit : null,
309
- engine: meta ? meta.engine : null,
310
- generation: meta ? meta.generation || 0 : 0,
311
- gen_id: meta ? meta.gen_id || null : null,
312
- };
313
- if (!opts.enabled) return { ...base, state: "off", exit: 3 };
314
- if (!meta || !prev) return { ...base, state: "none", exit: 1 };
315
- const d = detect(root, opts);
316
- if (!d.ok) return { ...base, ok: false, state: "unavailable", reason: d.reason, exit: 1 };
317
- const diff = diffFiles(prev, d.files);
318
- const behind = { added: diff.added.length, changed: diff.changed.length, deleted: diff.deleted.length };
319
- const engineStale = meta.engine !== ENGINE || meta.schema !== SCHEMA;
320
- const n = behind.added + behind.changed + behind.deleted;
321
- if (n || engineStale) return { ...base, state: "drifted", behind, engine_stale: engineStale, exit: 2 };
322
- return { ...base, state: "fresh", behind, engine_stale: false, exit: 0 };
323
- }
324
-
325
- // ── update ──────────────────────────────────────────────────────────────────
326
- // exit 0 built/updated/unchanged · 1 unavailable (not git, locked, io) · 3 off
327
- function graphUpdate(claudeDir, root, opts) {
328
- const t0 = Date.now();
329
- const say = opts.say || (() => {});
330
- if (!opts.enabled && opts.ifEnabled) return { ok: true, enabled: false, state: "off", exit: 3 };
331
- const p = graphPaths(claudeDir);
332
- const d = detect(root, opts);
333
- if (!d.ok) return { ok: false, enabled: !!opts.enabled, state: "unavailable", reason: d.reason, exit: 1 };
334
-
335
- const lock = acquireLock(p);
336
- if (!lock.ok) {
337
- return { ok: false, enabled: !!opts.enabled, state: "unavailable", reason: "locked", holder: lock.holder, age_ms: lock.age_ms, exit: 1 };
338
- }
339
- try {
340
- const prevMeta = readJson(p.meta);
341
- const prevFiles = readJson(p.files);
342
- const first = !prevMeta || !prevFiles;
343
- const upgrade = !first && (prevMeta.engine !== ENGINE || prevMeta.schema !== SCHEMA);
344
- const prev = first ? {} : prevFiles;
345
- const fresh = { schema: SCHEMA, engine: ENGINE, by_file: {} };
346
- const index = first || upgrade ? fresh : readJson(p.index) || fresh;
347
-
348
- const diff = diffFiles(prev, d.files);
349
- // An engine upgrade re-extracts everything. A missing index entry is
350
- // repaired too — that is how a crash between two writes heals.
351
- const work = new Set([...diff.added, ...diff.changed]);
352
- if (upgrade) for (const rel of d.files.keys()) work.add(rel);
353
- for (const rel of d.files.keys()) if (!index.by_file[rel]) work.add(rel);
354
-
355
- if (first && work.size > ESTIMATE_ABOVE) {
356
- say(`graph: building first index (~${work.size} files, est. ${Math.max(1, Math.round((work.size * EST_MS_PER_FILE) / 1000))} s)`);
357
- }
358
-
359
- // Phase 1 — reuse what is already on disk; read the rest.
360
- let reused = 0;
361
- let skipped = 0;
362
- const records = new Map();
363
- const toExtract = [];
364
- for (const rel of work) {
365
- const blob = d.files.get(rel);
366
- const lang = d.langs.get(rel);
367
- const have = readJson(blobPath(p, blob));
368
- if (have && have.schema === SCHEMA && have.engine === ENGINE) {
369
- records.set(rel, have);
370
- reused++;
371
- continue;
372
- }
373
- const abs = path.join(root, ...rel.split("/"));
374
- try {
375
- const size = fs.statSync(abs).size;
376
- if (size > MAX_BYTES) records.set(rel, { schema: SCHEMA, engine: ENGINE, blob, lang, bytes: size, skipped: "too-large", coverage: "skipped", imports: [], symbols: [] });
377
- else toExtract.push({ rel, abs, lang, blob, bytes: size, src: fs.readFileSync(abs, "utf8") });
378
- } catch (_) {
379
- records.set(rel, { schema: SCHEMA, engine: ENGINE, blob, lang, bytes: 0, skipped: "unreadable", coverage: "skipped", imports: [], symbols: [] });
380
- }
381
- }
382
-
383
- // Phase 2 — extract in ONE batch (one Python process for every .py file).
384
- const out = X.extractBatch(toExtract, { root });
385
- for (const it of toExtract) {
386
- const r = out.get(it.rel) || { extractor: X.HEURISTIC, imports: [], symbols: [] };
387
- records.set(it.rel, {
388
- schema: SCHEMA,
389
- engine: ENGINE,
390
- blob: it.blob,
391
- lang: it.lang,
392
- bytes: it.bytes,
393
- lines: r.lines || 0,
394
- extractor: r.extractor,
395
- ...(r.error ? { skipped: r.error } : {}),
396
- coverage: r.error ? "skipped" : r.coverage || "full",
397
- ...(r.partial ? { partial: r.partial } : {}),
398
- imports: r.imports,
399
- symbols: r.symbols,
400
- ...(r.reexports ? { reexports: r.reexports } : {}),
401
- });
402
- }
403
- for (const [rel, record] of records) {
404
- if (!toExtract.some((t) => t.rel === rel) && reused && readJson(blobPath(p, record.blob))) continue;
405
- const file = blobPath(p, record.blob);
406
- fs.mkdirSync(path.dirname(file), { recursive: true });
407
- atomicWrite(file, JSON.stringify(record));
408
- }
409
-
410
- const filesOut = {};
411
- for (const [rel, blob] of d.files) {
412
- const record = records.get(rel);
413
- if (record) {
414
- if (record.skipped) skipped++;
415
- index.by_file[rel] = {
416
- blob,
417
- lang: record.lang,
418
- bytes: record.bytes || 0,
419
- lines: record.lines || 0,
420
- ...(record.skipped ? { skipped: record.skipped } : {}),
421
- ...(record.coverage && record.coverage !== "full" ? { coverage: record.coverage } : {}),
422
- ...(record.partial ? { partial: record.partial } : {}),
423
- imports: record.imports,
424
- symbols: record.symbols,
425
- ...(record.reexports ? { reexports: record.reexports } : {}),
426
- };
427
- filesOut[rel] = {
428
- blob,
429
- lang: record.lang,
430
- bytes: record.bytes,
431
- ...(record.lines ? { lines: record.lines } : {}),
432
- ...(record.extractor ? { extractor: record.extractor } : {}),
433
- ...(record.skipped ? { skipped: record.skipped } : {}),
434
- ...(record.coverage && record.coverage !== "full" ? { coverage: record.coverage } : {}),
435
- ...(record.partial ? { partial: record.partial } : {}),
436
- };
437
- } else {
438
- filesOut[rel] = prev[rel];
439
- }
440
- }
441
- for (const rel of Object.keys(index.by_file)) if (!d.files.has(rel)) delete index.by_file[rel];
442
-
443
- let symbols = 0;
444
- for (const v of Object.values(index.by_file)) symbols += (v.symbols || []).filter((s) => s.kind !== "module").length;
445
- const unchanged = !first && !upgrade && work.size === 0 && diff.deleted.length === 0;
446
-
447
- const gen = genId(filesOut);
448
- const meta = {
449
- schema: SCHEMA,
450
- engine: ENGINE,
451
- head_commit: d.head,
452
- updated_at: unchanged && prevMeta ? prevMeta.updated_at : stamp(new Date()),
453
- files: d.files.size,
454
- symbols,
455
- // The number goes up only when the index on disk changes, so a reader
456
- // that saw generation N is looking at exactly the index that wrote N.
457
- generation: unchanged && prevMeta && prevMeta.generation ? prevMeta.generation : ((prevMeta && prevMeta.generation) || 0) + 1,
458
- gen_id: gen,
459
- // EW3: how long the last real update took, end to end. A read that may
460
- // heal uses it as the estimate for the next one — the only honest
461
- // estimate available, because an update cannot be stopped half way.
462
- update_ms: unchanged && prevMeta ? prevMeta.update_ms || 0 : 0,
463
- };
464
- if (!unchanged) {
465
- index.schema = SCHEMA;
466
- index.engine = ENGINE;
467
- atomicWrite(p.index, JSON.stringify(index));
468
- atomicWrite(p.files, JSON.stringify(filesOut));
469
- atomicWrite(p.meta, JSON.stringify(meta, null, 2) + "\n");
470
- }
471
- // EW2: the DERIVED resolution cache, written inside this same lock, AFTER
472
- // meta.json — it reads the generation it must pin itself to. It is an
473
- // optimisation: a route of `failed` leaves the graph correct and only
474
- // slower, so it never changes this function's answer.
475
- // S2 (v1.8.2 W2): the index is already in memory — `build` must not read
476
- // the 21 MB file it just wrote a second time. One parse per update.
477
- const resolveRoute = unchanged && require("./graph-resolve.js").load(claudeDir, meta)
478
- ? { route: "unchanged", ms: 0 }
479
- : require("./graph-resolve.js").build(claudeDir, root, index);
480
- // EW3: stamp the duration. A second 200-byte write of the same file, not a
481
- // second commit point — every other field is already the one just written,
482
- // so a crash between the two leaves a valid meta that only lacks an
483
- // estimate, and a missing estimate simply means "heal, and find out".
484
- if (!unchanged) {
485
- meta.update_ms = Date.now() - t0;
486
- atomicWrite(p.meta, JSON.stringify(meta, null, 2) + "\n");
487
- }
488
- return {
489
- ok: true,
490
- enabled: !!opts.enabled,
491
- state: first ? "built" : unchanged ? "unchanged" : "updated",
492
- files: d.files.size,
493
- symbols,
494
- generation: meta.generation,
495
- gen_id: meta.gen_id,
496
- route: resolveRoute.route,
497
- ...(resolveRoute.reason ? { route_reason: resolveRoute.reason } : {}),
498
- added: diff.added.length,
499
- changed: diff.changed.length,
500
- deleted: diff.deleted.length,
501
- parsed: toExtract.length,
502
- reused,
503
- skipped,
504
- engine_upgrade: upgrade,
505
- head_commit: d.head,
506
- ms: Date.now() - t0,
507
- exit: 0,
508
- };
509
- } finally {
510
- releaseLock(p);
511
- }
512
- }
513
-
514
- // ── coverage ────────────────────────────────────────────────────────────────
515
- // exit 0 always when a graph exists — a gap IS the answer, never an error.
516
- // exit 1 no index · 3 off (with --if-enabled)
517
- //
518
- // It reads `files.json` (444 KB on django/django) and NEVER `index.json`
519
- // (22 MB), because the only question is how much of each file was seen.
520
- function graphCoverage(claudeDir, root, opts) {
521
- const p = graphPaths(claudeDir);
522
- const meta = readJson(p.meta);
523
- const files = readJson(p.files);
524
- if (!meta || !files) return { ok: false, state: "none", reason: "no-index", exit: 1 };
525
- const paths = (opts.paths || []).map((x) => String(x).split("\\").join("/").replace(/^\.\//, ""));
526
- // One `hash-object` for every path that is still on disk — the batch command
527
- // must not pay one git process per file.
528
- const onDisk = paths.filter((rel) => files[rel] && fs.existsSync(path.join(root, ...rel.split("/"))));
529
- const shas = new Map();
530
- if (onDisk.length) {
531
- const h = git(root, ["hash-object", "--stdin-paths"], onDisk.join("\n") + "\n");
532
- if (h.status === 0) {
533
- const out = h.stdout.split(/\r?\n/).filter(Boolean);
534
- onDisk.forEach((rel, i) => shas.set(rel, out[i]));
535
- }
536
- }
537
- const rows = [];
538
- for (const rel of paths) {
539
- const entry = files[rel];
540
- const c = coverageOf(entry);
541
- let changed = null;
542
- if (entry) {
543
- if (!fs.existsSync(path.join(root, ...rel.split("/")))) changed = "deleted";
544
- else if (!shas.has(rel)) changed = "unknown";
545
- else changed = shas.get(rel) === entry.blob ? "current" : "changed";
546
- }
547
- rows.push({ path: rel, ...c, changed_since_index: changed });
548
- }
549
- const gaps = rows.filter((r) => r.coverage !== "full").length;
550
- return {
551
- ok: true,
552
- state: "found",
553
- generation: meta.generation || 0,
554
- gen_id: meta.gen_id || null,
555
- rows,
556
- gaps,
557
- exit: 0,
558
- };
559
- }
560
-
561
- // ── gc ──────────────────────────────────────────────────────────────────────
562
- // exit 0 done · 1 none or locked
563
- function graphGc(claudeDir) {
564
- const p = graphPaths(claudeDir);
565
- const files = readJson(p.files);
566
- if (!files) return { ok: false, state: "none", reason: "no-index", exit: 1 };
567
- const lock = acquireLock(p);
568
- if (!lock.ok) return { ok: false, state: "unavailable", reason: "locked", holder: lock.holder, exit: 1 };
569
- try {
570
- const keep = new Set(Object.values(files).map((f) => f.blob));
571
- let removed = 0;
572
- let kept = 0;
573
- let bytes = 0;
574
- let shards = [];
575
- try {
576
- shards = fs.readdirSync(p.blobs);
577
- } catch (_) {}
578
- for (const shard of shards) {
579
- const dir = path.join(p.blobs, shard);
580
- let names = [];
581
- try {
582
- names = fs.readdirSync(dir);
583
- } catch (_) {
584
- continue;
585
- }
586
- for (const name of names) {
587
- const blob = name.replace(/\.json$/, "");
588
- const full = path.join(dir, name);
589
- if (name.endsWith(".json") && keep.has(blob)) {
590
- kept++;
591
- continue;
592
- }
593
- try {
594
- bytes += fs.statSync(full).size;
595
- fs.rmSync(full, { force: true });
596
- removed++;
597
- } catch (_) {}
598
- }
599
- try {
600
- if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
601
- } catch (_) {}
602
- }
603
- // S1 (v1.8.2 W8): a shard set from an older generation is never READ — a
604
- // reader refuses it on the generation check — but it is still tens of
605
- // megabytes nobody will ever open. An update replaces the set wholesale, so
606
- // the only way to get here is a crash between two writes.
607
- let deadShards = { removed: 0, bytes: 0 };
608
- try {
609
- const S = require("./graph-shard.js");
610
- const dir = S.shardDir(claudeDir);
611
- const meta = readJson(p.meta);
612
- for (const name of fs.readdirSync(dir)) {
613
- const full = path.join(dir, name);
614
- const j = readJson(full);
615
- if (j && meta && j.generation === meta.generation && j.engine === ENGINE) continue;
616
- deadShards.bytes += fs.statSync(full).size;
617
- fs.rmSync(full, { force: true });
618
- deadShards.removed++;
619
- }
620
- } catch (_) {}
621
-
622
- // A temp file left by a writer that died mid-write.
623
- for (const name of fs.readdirSync(p.dir)) {
624
- if (name.endsWith(".tmp")) {
625
- try { fs.rmSync(path.join(p.dir, name), { force: true }); } catch (_) {}
626
- }
627
- }
628
- // Compact the notes ledger in the same locked pass: the latest note per
629
- // symbol whose body still hashes the same, and nothing else.
630
- const notes = require("./graph-notes.js").compactNotes(p, readJson(p.index), atomicWrite);
631
- // K1 (v1.8.2 W4b): the gain ledger is capped in the same locked pass. It is
632
- // append-only between compactions, exactly like the notes ledger.
633
- const gain = require("./graph-gain.js").compact(p, atomicWrite);
634
- return { ok: true, state: "done", removed, kept, bytes_freed: bytes + deadShards.bytes, shards: deadShards, notes, gain, exit: 0 };
635
- } finally {
636
- releaseLock(p);
637
- }
638
- }
639
-
640
- module.exports = {
641
- SCHEMA,
642
- ENGINE,
643
- LANG_BY_EXT,
644
- graphPaths,
645
- detect,
646
- diffFiles,
647
- graphStatus,
648
- graphUpdate,
649
- graphCoverage,
650
- coverageOf,
651
- genId,
652
- graphGc,
653
- // shared with graph-notes.js — one lock, one atomic writer, one date format
654
- acquireLock,
655
- releaseLock,
656
- atomicWrite,
657
- stamp,
658
- };
1
+ "use strict";
2
+ // ── orc graph (v1.8.0) — the code graph STORE ───────────────────────────────
3
+ //
4
+ // A small, local, git-ignored map of how this repository is connected. This
5
+ // file owns the store and change detection. Extraction is `graph-extract.js`;
6
+ // resolution and the read commands are `graph-query.js`. `bin/cli.js` routes
7
+ // `orc graph …` and resolves the config — no module here reads
8
+ // `.claude/orc.config.yaml`.
9
+ //
10
+ // THE RULES THIS FILE HOLDS
11
+ //
12
+ // 1. A change is found from git's OWN blob SHAs. `git ls-files -s` hashes
13
+ // every tracked file in one call; `git status` names the dirty and
14
+ // untracked ones, and `git hash-object` hashes only those. Nothing is
15
+ // parsed to find out whether it changed. (W0: 42 ms for 7,091 files.)
16
+ // 2. A record is CONTENT-ADDRESSED — `blobs/<ab>/<sha>.json`. A branch
17
+ // switch back, a revert, or a teammate's identical file reuses it.
18
+ // 3. Writes are atomic (temp file + rename) and serialized by `.lock`.
19
+ // Readers never take the lock: they see the old index or the new one.
20
+ // 4. The write ORDER is blobs → index.json → files.json → meta.json. A crash
21
+ // between any two leaves files.json OLD, so the next status reads DRIFTED
22
+ // and the next update redoes the work. Idempotent, never half-applied.
23
+ // 5. No background process and no timer, ever — a continuous rebuild is how
24
+ // the graph tools in the research froze machines. EW3 adds two ONE-SHOT
25
+ // triggers (a read that finds its own target stale, and an executor
26
+ // finishing). Both take the lock below, and the loser SKIPS rather than
27
+ // queues, so nothing can ever pile up.
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+ const { spawnSync } = require("child_process");
32
+ const crypto = require("crypto");
33
+ const X = require("./graph-extract.js");
34
+
35
+ const SCHEMA = 1;
36
+ // Bumped whenever the record shape or extraction changes. A record made by an
37
+ // older engine is re-extracted, and status reads DRIFTED until it is.
38
+ // @3 (W9): route symbols + `ref` edges — an older index re-extracts once.
39
+ // @4 (EW1): per-file COVERAGE and a GENERATION on the index.
40
+ // @5 (v1.8.2): `urls`, `mounts`, decorator routes with `handler`, `bases`,
41
+ // aliases, re-exports. A 1.8.1 store reads DRIFTED with `engine_stale` and
42
+ // the next preflight (`status --heal`) re-extracts every file once.
43
+ // @6 (v1.9.1 W5b, DE-15): an Options API object is a `class` with `method`
44
+ // members (`mixins`/`extends` → `bases`), exported constants are `const`
45
+ // symbols, and a `<script setup>` component is one `class`. No record FIELD
46
+ // changed: a 1.9.0 store reads DRIFTED and the next `status --heal`
47
+ // re-extracts once, the same path 1.8.1 → 1.8.2 took.
48
+ const ENGINE = "graph@6";
49
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
50
+ const MAX_BYTES = 512 * 1024;
51
+ const LOCK_STALE_MS = 10 * 60 * 1000;
52
+ // Measured on this machine class: nestjs/nest 1.3 ms per file (heuristic),
53
+ // django/django 3.5 ms per file on 1.8.1 and 5.6 ms on 1.8.2 (Python ast +
54
+ // masking + the W1/W2 alias, decorator and re-export passes). The estimate
55
+ // uses the slower one — a first build that finishes early is fine, one that
56
+ // overruns its own estimate teaches people to ignore it.
57
+ const EST_MS_PER_FILE = 5.6;
58
+ const ESTIMATE_ABOVE = 2000;
59
+
60
+ const LANG_BY_EXT = {
61
+ ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js",
62
+ ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts",
63
+ ".py": "py",
64
+ ".go": "go",
65
+ ".java": "java",
66
+ ".cs": "cs",
67
+ ".php": "php",
68
+ // v1.8.2 W5 (G6) — the heuristic rung gains Ruby, Rust, Kotlin, the two
69
+ // single-file component formats, and C/C++.
70
+ ".rb": "rb", ".rake": "rb",
71
+ ".rs": "rs",
72
+ ".kt": "kt", ".kts": "kt",
73
+ // A single-file component is its `<script>` block, parsed as js or ts. The
74
+ // rest of the file is blanked, so every line number is the file's own.
75
+ ".vue": "vue",
76
+ ".svelte": "svelte",
77
+ ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".cxx": "c", ".hpp": "c", ".hh": "c",
78
+ };
79
+
80
+ // Never part of the map, whatever git says: ORC's own tree, installed or
81
+ // vendored dependencies (sometimes committed), build output, caches, minified
82
+ // or bundled JS, generated files, and `.d.ts` declarations (v1.8.2 W2 — a
83
+ // declaration file duplicates every symbol of its module and made each one
84
+ // AMBIGUOUS). `dist/` and `build/` are skipped at the ROOT only: `pkg/build/`
85
+ // is a package name in more than one real repository. A skipped path has no
86
+ // record, so `coverage` reports it `excluded`, never silently.
87
+ const ALWAYS_SKIP = /^(\.claude\/|dist\/|build\/|out\/|target\/(debug|release)\/)|(^|\/)(node_modules|vendor|__pycache__|\.next|\.nuxt|\.venv|venv|target\/classes)\/|\.(min|bundle|chunk)\.js$|\.d\.ts$|\.generated\.[A-Za-z]+$|\.pb\.go$|_pb2\.py$/;
88
+
89
+ function graphPaths(claudeDir) {
90
+ const dir = path.join(claudeDir, "orc", "graph");
91
+ return {
92
+ dir,
93
+ meta: path.join(dir, "meta.json"),
94
+ files: path.join(dir, "files.json"),
95
+ index: path.join(dir, "index.json"),
96
+ blobs: path.join(dir, "blobs"),
97
+ notes: path.join(dir, "notes.jsonl"),
98
+ lock: path.join(dir, ".lock"),
99
+ };
100
+ }
101
+
102
+ function globRe(g) {
103
+ const raw = String(g).replace(/^\.\//, "");
104
+ const esc = raw
105
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
106
+ .replace(/\*\*/g, "__GLOBSTAR__")
107
+ .replace(/\*/g, "[^/]*")
108
+ .replace(/\?/g, "[^/]")
109
+ .replace(/__GLOBSTAR__/g, ".*");
110
+ // gitignore's rule (W2): a pattern with no slash matches at ANY depth
111
+ // (`*.gen.ts`, `__snapshots__`); one with a slash is anchored at the root.
112
+ const anchored = raw.replace(/\/$/, "").includes("/");
113
+ return new RegExp("^" + (anchored ? "" : "(?:.*/)?") + esc + "(/|$)");
114
+ }
115
+
116
+ function makeFilter(ignore) {
117
+ const res = (Array.isArray(ignore) ? ignore : []).filter(Boolean).map(globRe);
118
+ return (rel) => {
119
+ if (ALWAYS_SKIP.test(rel)) return null;
120
+ if (res.some((re) => re.test(rel))) return null;
121
+ return LANG_BY_EXT[path.extname(rel).toLowerCase()] || null;
122
+ };
123
+ }
124
+
125
+ function git(root, args, input) {
126
+ return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER, input });
127
+ }
128
+
129
+ function stamp(d) {
130
+ const p = (n) => String(n).padStart(2, "0");
131
+ return `${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
132
+ }
133
+
134
+ function readJson(file) {
135
+ try {
136
+ return JSON.parse(fs.readFileSync(file, "utf8"));
137
+ } catch (_) {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ // Windows can refuse a rename for a moment while another process has the
143
+ // target open (a reader mid-parse). A short retry is the whole remedy.
144
+ function atomicWrite(file, text) {
145
+ const tmp = `${file}.${process.pid}.tmp`;
146
+ fs.writeFileSync(tmp, text);
147
+ for (let i = 0; ; i++) {
148
+ try {
149
+ fs.renameSync(tmp, file);
150
+ return;
151
+ } catch (e) {
152
+ if (i >= 5 || (e.code !== "EPERM" && e.code !== "EBUSY" && e.code !== "EACCES")) {
153
+ try { fs.rmSync(tmp, { force: true }); } catch (_) {}
154
+ throw e;
155
+ }
156
+ const until = Date.now() + 20 * (i + 1);
157
+ while (Date.now() < until) {} // eslint-disable-line no-empty
158
+ }
159
+ }
160
+ }
161
+
162
+ // ── the lock ────────────────────────────────────────────────────────────────
163
+ function acquireLock(p) {
164
+ fs.mkdirSync(p.dir, { recursive: true });
165
+ for (let attempt = 0; attempt < 2; attempt++) {
166
+ try {
167
+ const fd = fs.openSync(p.lock, "wx");
168
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: stamp(new Date()) }));
169
+ fs.closeSync(fd);
170
+ return { ok: true };
171
+ } catch (e) {
172
+ if (e.code !== "EEXIST") throw e;
173
+ let age = 0;
174
+ try {
175
+ age = Date.now() - fs.statSync(p.lock).mtimeMs;
176
+ } catch (_) {
177
+ continue; // released between the open and the stat
178
+ }
179
+ // A writer that died leaves its lock behind. Ten minutes is far past any
180
+ // real update (W0: 1.7 s for Django), so an older lock is a dead one.
181
+ if (age > LOCK_STALE_MS) {
182
+ try { fs.rmSync(p.lock, { force: true }); } catch (_) {}
183
+ continue;
184
+ }
185
+ return { ok: false, holder: readJson(p.lock), age_ms: Math.round(age) };
186
+ }
187
+ }
188
+ return { ok: false, holder: readJson(p.lock), age_ms: null };
189
+ }
190
+
191
+ function releaseLock(p) {
192
+ try { fs.rmSync(p.lock, { force: true }); } catch (_) {}
193
+ }
194
+
195
+ // ── detection ───────────────────────────────────────────────────────────────
196
+ // Returns { ok, files: Map<rel, blob>, langs, head } or { ok:false, reason }.
197
+ function detect(root, opts) {
198
+ const langOf = makeFilter(opts && opts.ignore);
199
+ const ls = git(root, ["ls-files", "-s", "-z"]);
200
+ if (ls.error || ls.status !== 0) return { ok: false, reason: "not-git" };
201
+ const files = new Map();
202
+ const langs = new Map();
203
+ for (const rec of ls.stdout.split("\0")) {
204
+ if (!rec) continue;
205
+ const tab = rec.indexOf("\t");
206
+ if (tab < 0) continue;
207
+ const [mode, blob] = rec.slice(0, tab).split(" ");
208
+ const rel = rec.slice(tab + 1);
209
+ if (mode === "160000") continue; // a submodule is another repository
210
+ const lang = langOf(rel);
211
+ if (!lang) continue;
212
+ // A conflicted file lists up to three stages; the working tree decides
213
+ // below, so the first stage seen is only a placeholder.
214
+ if (!files.has(rel)) files.set(rel, blob);
215
+ langs.set(rel, lang);
216
+ }
217
+
218
+ const st = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"]);
219
+ if (st.error || st.status !== 0) return { ok: false, reason: "git-status-failed" };
220
+ const hashMe = [];
221
+ for (const rec of st.stdout.split("\0")) {
222
+ if (!rec || rec.length < 4) continue;
223
+ const x = rec[0];
224
+ const y = rec[1];
225
+ const rel = rec.slice(3);
226
+ const lang = langOf(rel);
227
+ if (!lang) continue;
228
+ if (y === "D") {
229
+ files.delete(rel);
230
+ langs.delete(rel);
231
+ continue;
232
+ }
233
+ // `ls-files -s` already carries the STAGED content. Only a working-tree
234
+ // difference (M, T, U, A with intent-to-add) or an untracked file needs its
235
+ // own hash.
236
+ if (x === "?" || x === "U" || y === "M" || y === "T" || y === "U" || y === "A") {
237
+ hashMe.push(rel);
238
+ langs.set(rel, lang);
239
+ }
240
+ }
241
+ if (hashMe.length) {
242
+ // No --no-filters on purpose: the clean filter (autocrlf on Windows) must
243
+ // run, or every CRLF file would hash differently from its index blob and
244
+ // read as changed forever.
245
+ const h = git(root, ["hash-object", "--stdin-paths"], hashMe.join("\n") + "\n");
246
+ if (h.error || h.status !== 0) return { ok: false, reason: "hash-object-failed" };
247
+ const shas = h.stdout.split(/\r?\n/).filter(Boolean);
248
+ hashMe.forEach((rel, i) => {
249
+ if (shas[i]) files.set(rel, shas[i]);
250
+ });
251
+ }
252
+ const head = git(root, ["rev-parse", "HEAD"]);
253
+ return {
254
+ ok: true,
255
+ files,
256
+ langs,
257
+ head: head.status === 0 ? head.stdout.trim() : null,
258
+ };
259
+ }
260
+
261
+ function diffFiles(prev, cur) {
262
+ const added = [];
263
+ const changed = [];
264
+ const deleted = [];
265
+ for (const [rel, blob] of cur) {
266
+ const was = prev[rel];
267
+ if (!was) added.push(rel);
268
+ else if (was.blob !== blob) changed.push(rel);
269
+ }
270
+ for (const rel of Object.keys(prev)) if (!cur.has(rel)) deleted.push(rel);
271
+ return { added, changed, deleted };
272
+ }
273
+
274
+ function blobPath(p, blob) {
275
+ return path.join(p.blobs, blob.slice(0, 2), `${blob}.json`);
276
+ }
277
+
278
+ // ── generation (EW1) ────────────────────────────────────────────────────────
279
+ // `generation` counts index-changing writes; `gen_id` names the CONTENT those
280
+ // writes produced. A reader compares the number (cheap) and a second store —
281
+ // the resolution cache — pins itself to it. Two machines that index the same
282
+ // tree get the same `gen_id` and different `generation` numbers, so the id is
283
+ // what a card may quote and the number is what a cache may compare.
284
+ function genId(filesOut) {
285
+ const h = crypto.createHash("sha1");
286
+ for (const rel of Object.keys(filesOut).sort()) h.update(`${rel}${(filesOut[rel] || {}).blob || ""}`);
287
+ return h.digest("hex").slice(0, 8);
288
+ }
289
+
290
+ // What the extractor saw of ONE file, from the record it already wrote. A path
291
+ // with no record at all is `excluded` — git does not track it, an ignore glob
292
+ // dropped it, or the language has no extractor.
293
+ function coverageOf(entry) {
294
+ if (!entry) return { coverage: "excluded" };
295
+ if (entry.skipped) return { coverage: `skipped:${entry.skipped}` };
296
+ if (entry.coverage === "partial") return { coverage: "partial", ranges: entry.partial || [] };
297
+ return { coverage: "full" };
298
+ }
299
+
300
+ // ── density (v1.9.1 A1) ─────────────────────────────────────────────────────
301
+ // How many named symbols the parser found per file, and how many files it read
302
+ // as EMPTY. On the user's own project the graph held 1.9 symbols per file and
303
+ // most `ctx` calls answered exit 4 — and nothing said whether the code has no
304
+ // named functions or the parser did not read them. This field is what the
305
+ // status line and `orc graph audit` both answer from.
306
+ //
307
+ // `module` symbols are excluded, exactly as `meta.symbols` excludes them: a
308
+ // file's module record is not a thing anyone looks for.
309
+ function densityOf(index, files) {
310
+ const byLang = Object.create(null);
311
+ let symbols = 0;
312
+ let zero = 0;
313
+ let partial = 0;
314
+ let skipped = 0;
315
+ const entries = Object.entries((index && index.by_file) || {});
316
+ for (const [rel, v] of entries) {
317
+ const n = (v.symbols || []).filter((x) => x.kind !== "module").length;
318
+ const lang = v.lang || LANG_BY_EXT[path.extname(rel).toLowerCase()] || "other";
319
+ const l = byLang[lang] || (byLang[lang] = { files: 0, symbols: 0, zero: 0, partial: 0, skipped: 0 });
320
+ l.files++;
321
+ l.symbols += n;
322
+ symbols += n;
323
+ if (n === 0) {
324
+ l.zero++;
325
+ zero++;
326
+ }
327
+ if (v.coverage === "partial") {
328
+ l.partial++;
329
+ partial++;
330
+ }
331
+ }
332
+ for (const [rel, v] of Object.entries(files || {})) {
333
+ if (!v || !v.skipped) continue;
334
+ skipped++;
335
+ const lang = LANG_BY_EXT[path.extname(rel).toLowerCase()] || "other";
336
+ const l = byLang[lang] || (byLang[lang] = { files: 0, symbols: 0, zero: 0, partial: 0, skipped: 0 });
337
+ l.skipped++;
338
+ }
339
+ const n = entries.length;
340
+ return {
341
+ symbols_per_file: n ? Math.round((10 * symbols) / n) / 10 : 0,
342
+ zero_files: zero,
343
+ zero_share: n ? Math.round((100 * zero) / n) / 100 : 0,
344
+ skipped,
345
+ partial,
346
+ by_lang: byLang,
347
+ };
348
+ }
349
+
350
+ // ── status ──────────────────────────────────────────────────────────────────
351
+ // exit 0 FRESH · 1 NONE (or unavailable) · 2 DRIFTED · 3 OFF
352
+ function graphStatus(claudeDir, root, opts) {
353
+ const p = graphPaths(claudeDir);
354
+ const meta = readJson(p.meta);
355
+ const prev = readJson(p.files);
356
+ const base = {
357
+ ok: true,
358
+ enabled: !!opts.enabled,
359
+ exists: !!(meta && prev),
360
+ files: meta ? meta.files : 0,
361
+ symbols: meta ? meta.symbols : 0,
362
+ updated_at: meta ? meta.updated_at : null,
363
+ head_commit: meta ? meta.head_commit : null,
364
+ engine: meta ? meta.engine : null,
365
+ generation: meta ? meta.generation || 0 : 0,
366
+ gen_id: meta ? meta.gen_id || null : null,
367
+ };
368
+ if (!opts.enabled) return { ...base, state: "off", exit: 3 };
369
+ if (!meta || !prev) return { ...base, state: "none", exit: 1 };
370
+ const d = detect(root, opts);
371
+ if (!d.ok) return { ...base, ok: false, state: "unavailable", reason: d.reason, exit: 1 };
372
+ const diff = diffFiles(prev, d.files);
373
+ const behind = { added: diff.added.length, changed: diff.changed.length, deleted: diff.deleted.length };
374
+ const engineStale = meta.engine !== ENGINE || meta.schema !== SCHEMA;
375
+ const n = behind.added + behind.changed + behind.deleted;
376
+ if (n || engineStale) return { ...base, state: "drifted", behind, engine_stale: engineStale, exit: 2 };
377
+ return { ...base, state: "fresh", behind, engine_stale: false, exit: 0 };
378
+ }
379
+
380
+ // ── update ──────────────────────────────────────────────────────────────────
381
+ // exit 0 built/updated/unchanged · 1 unavailable (not git, locked, io) · 3 off
382
+ function graphUpdate(claudeDir, root, opts) {
383
+ const t0 = Date.now();
384
+ const say = opts.say || (() => {});
385
+ if (!opts.enabled && opts.ifEnabled) return { ok: true, enabled: false, state: "off", exit: 3 };
386
+ const p = graphPaths(claudeDir);
387
+ const d = detect(root, opts);
388
+ if (!d.ok) return { ok: false, enabled: !!opts.enabled, state: "unavailable", reason: d.reason, exit: 1 };
389
+
390
+ const lock = acquireLock(p);
391
+ if (!lock.ok) {
392
+ return { ok: false, enabled: !!opts.enabled, state: "unavailable", reason: "locked", holder: lock.holder, age_ms: lock.age_ms, exit: 1 };
393
+ }
394
+ try {
395
+ const prevMeta = readJson(p.meta);
396
+ const prevFiles = readJson(p.files);
397
+ const first = !prevMeta || !prevFiles;
398
+ const upgrade = !first && (prevMeta.engine !== ENGINE || prevMeta.schema !== SCHEMA);
399
+ const prev = first ? {} : prevFiles;
400
+ const fresh = { schema: SCHEMA, engine: ENGINE, by_file: {} };
401
+ const index = first || upgrade ? fresh : readJson(p.index) || fresh;
402
+
403
+ const diff = diffFiles(prev, d.files);
404
+ // An engine upgrade re-extracts everything. A missing index entry is
405
+ // repaired too — that is how a crash between two writes heals.
406
+ const work = new Set([...diff.added, ...diff.changed]);
407
+ if (upgrade) for (const rel of d.files.keys()) work.add(rel);
408
+ for (const rel of d.files.keys()) if (!index.by_file[rel]) work.add(rel);
409
+
410
+ if (first && work.size > ESTIMATE_ABOVE) {
411
+ say(`graph: building first index (~${work.size} files, est. ${Math.max(1, Math.round((work.size * EST_MS_PER_FILE) / 1000))} s)`);
412
+ }
413
+
414
+ // Phase 1 — reuse what is already on disk; read the rest.
415
+ let reused = 0;
416
+ let skipped = 0;
417
+ const records = new Map();
418
+ const toExtract = [];
419
+ for (const rel of work) {
420
+ const blob = d.files.get(rel);
421
+ const lang = d.langs.get(rel);
422
+ const have = readJson(blobPath(p, blob));
423
+ if (have && have.schema === SCHEMA && have.engine === ENGINE) {
424
+ records.set(rel, have);
425
+ reused++;
426
+ continue;
427
+ }
428
+ const abs = path.join(root, ...rel.split("/"));
429
+ try {
430
+ const size = fs.statSync(abs).size;
431
+ if (size > MAX_BYTES) records.set(rel, { schema: SCHEMA, engine: ENGINE, blob, lang, bytes: size, skipped: "too-large", coverage: "skipped", imports: [], symbols: [] });
432
+ else toExtract.push({ rel, abs, lang, blob, bytes: size, src: fs.readFileSync(abs, "utf8") });
433
+ } catch (_) {
434
+ records.set(rel, { schema: SCHEMA, engine: ENGINE, blob, lang, bytes: 0, skipped: "unreadable", coverage: "skipped", imports: [], symbols: [] });
435
+ }
436
+ }
437
+
438
+ // Phase 2 — extract in ONE batch (one Python process for every .py file).
439
+ const out = X.extractBatch(toExtract, { root });
440
+ for (const it of toExtract) {
441
+ const r = out.get(it.rel) || { extractor: X.HEURISTIC, imports: [], symbols: [] };
442
+ records.set(it.rel, {
443
+ schema: SCHEMA,
444
+ engine: ENGINE,
445
+ blob: it.blob,
446
+ lang: it.lang,
447
+ bytes: it.bytes,
448
+ lines: r.lines || 0,
449
+ extractor: r.extractor,
450
+ ...(r.error ? { skipped: r.error } : {}),
451
+ coverage: r.error ? "skipped" : r.coverage || "full",
452
+ ...(r.partial ? { partial: r.partial } : {}),
453
+ imports: r.imports,
454
+ symbols: r.symbols,
455
+ ...(r.reexports ? { reexports: r.reexports } : {}),
456
+ });
457
+ }
458
+ for (const [rel, record] of records) {
459
+ if (!toExtract.some((t) => t.rel === rel) && reused && readJson(blobPath(p, record.blob))) continue;
460
+ const file = blobPath(p, record.blob);
461
+ fs.mkdirSync(path.dirname(file), { recursive: true });
462
+ atomicWrite(file, JSON.stringify(record));
463
+ }
464
+
465
+ const filesOut = {};
466
+ for (const [rel, blob] of d.files) {
467
+ const record = records.get(rel);
468
+ if (record) {
469
+ if (record.skipped) skipped++;
470
+ index.by_file[rel] = {
471
+ blob,
472
+ lang: record.lang,
473
+ bytes: record.bytes || 0,
474
+ lines: record.lines || 0,
475
+ ...(record.skipped ? { skipped: record.skipped } : {}),
476
+ ...(record.coverage && record.coverage !== "full" ? { coverage: record.coverage } : {}),
477
+ ...(record.partial ? { partial: record.partial } : {}),
478
+ imports: record.imports,
479
+ symbols: record.symbols,
480
+ ...(record.reexports ? { reexports: record.reexports } : {}),
481
+ };
482
+ filesOut[rel] = {
483
+ blob,
484
+ lang: record.lang,
485
+ bytes: record.bytes,
486
+ ...(record.lines ? { lines: record.lines } : {}),
487
+ ...(record.extractor ? { extractor: record.extractor } : {}),
488
+ ...(record.skipped ? { skipped: record.skipped } : {}),
489
+ ...(record.coverage && record.coverage !== "full" ? { coverage: record.coverage } : {}),
490
+ ...(record.partial ? { partial: record.partial } : {}),
491
+ };
492
+ } else {
493
+ filesOut[rel] = prev[rel];
494
+ }
495
+ }
496
+ for (const rel of Object.keys(index.by_file)) if (!d.files.has(rel)) delete index.by_file[rel];
497
+
498
+ let symbols = 0;
499
+ for (const v of Object.values(index.by_file)) symbols += (v.symbols || []).filter((s) => s.kind !== "module").length;
500
+ const unchanged = !first && !upgrade && work.size === 0 && diff.deleted.length === 0;
501
+
502
+ const gen = genId(filesOut);
503
+ const meta = {
504
+ schema: SCHEMA,
505
+ engine: ENGINE,
506
+ head_commit: d.head,
507
+ updated_at: unchanged && prevMeta ? prevMeta.updated_at : stamp(new Date()),
508
+ files: d.files.size,
509
+ symbols,
510
+ // The number goes up only when the index on disk changes, so a reader
511
+ // that saw generation N is looking at exactly the index that wrote N.
512
+ generation: unchanged && prevMeta && prevMeta.generation ? prevMeta.generation : ((prevMeta && prevMeta.generation) || 0) + 1,
513
+ gen_id: gen,
514
+ // EW3: how long the last real update took, end to end. A read that may
515
+ // heal uses it as the estimate for the next one — the only honest
516
+ // estimate available, because an update cannot be stopped half way.
517
+ update_ms: unchanged && prevMeta ? prevMeta.update_ms || 0 : 0,
518
+ // A1: computed from the index this update just built, so it costs one
519
+ // pass over what is already in memory and no file is opened for it.
520
+ density: unchanged && prevMeta && prevMeta.density ? prevMeta.density : densityOf(index, filesOut),
521
+ };
522
+ if (!unchanged) {
523
+ index.schema = SCHEMA;
524
+ index.engine = ENGINE;
525
+ atomicWrite(p.index, JSON.stringify(index));
526
+ atomicWrite(p.files, JSON.stringify(filesOut));
527
+ atomicWrite(p.meta, JSON.stringify(meta, null, 2) + "\n");
528
+ }
529
+ // EW2: the DERIVED resolution cache, written inside this same lock, AFTER
530
+ // meta.json — it reads the generation it must pin itself to. It is an
531
+ // optimisation: a route of `failed` leaves the graph correct and only
532
+ // slower, so it never changes this function's answer.
533
+ // S2 (v1.8.2 W2): the index is already in memory — `build` must not read
534
+ // the 21 MB file it just wrote a second time. One parse per update.
535
+ const resolveRoute = unchanged && require("./graph-resolve.js").load(claudeDir, meta)
536
+ ? { route: "unchanged", ms: 0 }
537
+ : require("./graph-resolve.js").build(claudeDir, root, index);
538
+ // EW3: stamp the duration. A second 200-byte write of the same file, not a
539
+ // second commit point — every other field is already the one just written,
540
+ // so a crash between the two leaves a valid meta that only lacks an
541
+ // estimate, and a missing estimate simply means "heal, and find out".
542
+ if (!unchanged) {
543
+ meta.update_ms = Date.now() - t0;
544
+ atomicWrite(p.meta, JSON.stringify(meta, null, 2) + "\n");
545
+ }
546
+ return {
547
+ ok: true,
548
+ enabled: !!opts.enabled,
549
+ state: first ? "built" : unchanged ? "unchanged" : "updated",
550
+ files: d.files.size,
551
+ symbols,
552
+ generation: meta.generation,
553
+ gen_id: meta.gen_id,
554
+ route: resolveRoute.route,
555
+ ...(resolveRoute.reason ? { route_reason: resolveRoute.reason } : {}),
556
+ added: diff.added.length,
557
+ changed: diff.changed.length,
558
+ deleted: diff.deleted.length,
559
+ parsed: toExtract.length,
560
+ reused,
561
+ skipped,
562
+ engine_upgrade: upgrade,
563
+ head_commit: d.head,
564
+ ms: Date.now() - t0,
565
+ exit: 0,
566
+ };
567
+ } finally {
568
+ releaseLock(p);
569
+ }
570
+ }
571
+
572
+ // ── coverage ────────────────────────────────────────────────────────────────
573
+ // exit 0 always when a graph exists — a gap IS the answer, never an error.
574
+ // exit 1 no index · 3 off (with --if-enabled)
575
+ //
576
+ // It reads `files.json` (444 KB on django/django) and NEVER `index.json`
577
+ // (22 MB), because the only question is how much of each file was seen.
578
+ function graphCoverage(claudeDir, root, opts) {
579
+ const p = graphPaths(claudeDir);
580
+ const meta = readJson(p.meta);
581
+ const files = readJson(p.files);
582
+ if (!meta || !files) return { ok: false, state: "none", reason: "no-index", exit: 1 };
583
+ const paths = (opts.paths || []).map((x) => String(x).split("\\").join("/").replace(/^\.\//, ""));
584
+ // One `hash-object` for every path that is still on disk — the batch command
585
+ // must not pay one git process per file.
586
+ const onDisk = paths.filter((rel) => files[rel] && fs.existsSync(path.join(root, ...rel.split("/"))));
587
+ const shas = new Map();
588
+ if (onDisk.length) {
589
+ const h = git(root, ["hash-object", "--stdin-paths"], onDisk.join("\n") + "\n");
590
+ if (h.status === 0) {
591
+ const out = h.stdout.split(/\r?\n/).filter(Boolean);
592
+ onDisk.forEach((rel, i) => shas.set(rel, out[i]));
593
+ }
594
+ }
595
+ const rows = [];
596
+ for (const rel of paths) {
597
+ const entry = files[rel];
598
+ const c = coverageOf(entry);
599
+ let changed = null;
600
+ if (entry) {
601
+ if (!fs.existsSync(path.join(root, ...rel.split("/")))) changed = "deleted";
602
+ else if (!shas.has(rel)) changed = "unknown";
603
+ else changed = shas.get(rel) === entry.blob ? "current" : "changed";
604
+ }
605
+ rows.push({ path: rel, ...c, changed_since_index: changed });
606
+ }
607
+ const gaps = rows.filter((r) => r.coverage !== "full").length;
608
+ return {
609
+ ok: true,
610
+ state: "found",
611
+ generation: meta.generation || 0,
612
+ gen_id: meta.gen_id || null,
613
+ rows,
614
+ gaps,
615
+ exit: 0,
616
+ };
617
+ }
618
+
619
+ // ── gc ──────────────────────────────────────────────────────────────────────
620
+ // exit 0 done · 1 none or locked
621
+ function graphGc(claudeDir) {
622
+ const p = graphPaths(claudeDir);
623
+ const files = readJson(p.files);
624
+ if (!files) return { ok: false, state: "none", reason: "no-index", exit: 1 };
625
+ const lock = acquireLock(p);
626
+ if (!lock.ok) return { ok: false, state: "unavailable", reason: "locked", holder: lock.holder, exit: 1 };
627
+ try {
628
+ const keep = new Set(Object.values(files).map((f) => f.blob));
629
+ let removed = 0;
630
+ let kept = 0;
631
+ let bytes = 0;
632
+ let shards = [];
633
+ try {
634
+ shards = fs.readdirSync(p.blobs);
635
+ } catch (_) {}
636
+ for (const shard of shards) {
637
+ const dir = path.join(p.blobs, shard);
638
+ let names = [];
639
+ try {
640
+ names = fs.readdirSync(dir);
641
+ } catch (_) {
642
+ continue;
643
+ }
644
+ for (const name of names) {
645
+ const blob = name.replace(/\.json$/, "");
646
+ const full = path.join(dir, name);
647
+ if (name.endsWith(".json") && keep.has(blob)) {
648
+ kept++;
649
+ continue;
650
+ }
651
+ try {
652
+ bytes += fs.statSync(full).size;
653
+ fs.rmSync(full, { force: true });
654
+ removed++;
655
+ } catch (_) {}
656
+ }
657
+ try {
658
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
659
+ } catch (_) {}
660
+ }
661
+ // S1 (v1.8.2 W8): a shard set from an older generation is never READ — a
662
+ // reader refuses it on the generation check — but it is still tens of
663
+ // megabytes nobody will ever open. An update replaces the set wholesale, so
664
+ // the only way to get here is a crash between two writes.
665
+ let deadShards = { removed: 0, bytes: 0 };
666
+ try {
667
+ const S = require("./graph-shard.js");
668
+ const dir = S.shardDir(claudeDir);
669
+ const meta = readJson(p.meta);
670
+ for (const name of fs.readdirSync(dir)) {
671
+ const full = path.join(dir, name);
672
+ const j = readJson(full);
673
+ if (j && meta && j.generation === meta.generation && j.engine === ENGINE) continue;
674
+ deadShards.bytes += fs.statSync(full).size;
675
+ fs.rmSync(full, { force: true });
676
+ deadShards.removed++;
677
+ }
678
+ } catch (_) {}
679
+
680
+ // A temp file left by a writer that died mid-write.
681
+ for (const name of fs.readdirSync(p.dir)) {
682
+ if (name.endsWith(".tmp")) {
683
+ try { fs.rmSync(path.join(p.dir, name), { force: true }); } catch (_) {}
684
+ }
685
+ }
686
+ // Compact the notes ledger in the same locked pass: the latest note per
687
+ // symbol whose body still hashes the same, and nothing else.
688
+ const notes = require("./graph-notes.js").compactNotes(p, readJson(p.index), atomicWrite);
689
+ // K1 (v1.8.2 W4b): the gain ledger is capped in the same locked pass. It is
690
+ // append-only between compactions, exactly like the notes ledger.
691
+ const gain = require("./graph-gain.js").compact(p, atomicWrite);
692
+ return { ok: true, state: "done", removed, kept, bytes_freed: bytes + deadShards.bytes, shards: deadShards, notes, gain, exit: 0 };
693
+ } finally {
694
+ releaseLock(p);
695
+ }
696
+ }
697
+
698
+ module.exports = {
699
+ SCHEMA,
700
+ ENGINE,
701
+ LANG_BY_EXT,
702
+ graphPaths,
703
+ detect,
704
+ diffFiles,
705
+ graphStatus,
706
+ graphUpdate,
707
+ graphCoverage,
708
+ coverageOf,
709
+ densityOf,
710
+ genId,
711
+ graphGc,
712
+ // shared with graph-notes.js — one lock, one atomic writer, one date format
713
+ acquireLock,
714
+ releaseLock,
715
+ atomicWrite,
716
+ stamp,
717
+ };