@azure-id/orc 1.7.1 → 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 (58) hide show
  1. package/CHANGELOG.md +3649 -3381
  2. package/README-id.md +923 -844
  3. package/README.md +836 -788
  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 -4553
  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 -121
  26. package/templates/agents/orc-executor-opus-4-7-high.md +134 -122
  27. package/templates/agents/orc-executor-opus-4-7-med.md +134 -122
  28. package/templates/agents/orc-executor-opus-4-8-high.md +134 -122
  29. package/templates/agents/orc-executor-opus-5-high.md +134 -122
  30. package/templates/agents/orc-executor-opus-5-low.md +134 -122
  31. package/templates/agents/orc-executor-opus-5-med.md +134 -122
  32. package/templates/agents/orc-executor-sonnet-4-6-high.md +134 -122
  33. package/templates/agents/orc-executor-sonnet-4-6-med.md +134 -122
  34. package/templates/agents/orc-executor-sonnet-5-high.md +134 -122
  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/opus5-only.md +4 -0
  43. package/templates/skills/_shared/phases/execution.md +166 -147
  44. package/templates/skills/_shared/phases/planning.md +142 -135
  45. package/templates/skills/_shared/phases/preflight.md +132 -118
  46. package/templates/skills/_shared/phases/review.md +63 -53
  47. package/templates/skills/_shared/phases/ship.md +96 -88
  48. package/templates/skills/_shared/phases/trace.md +6 -0
  49. package/templates/skills/_shared/phases/wiki-consult.md +194 -189
  50. package/templates/skills/_shared/read-ladder.md +124 -102
  51. package/templates/skills/_shared/return-validation.md +259 -250
  52. package/templates/skills/orc/SKILL.md +255 -254
  53. package/templates/skills/orc-diy/references/flow-schema.md +101 -100
  54. package/templates/skills/orc-fast/SKILL.md +236 -229
  55. package/templates/skills/orc-mini/SKILL.md +267 -259
  56. package/templates/skills/orc-quick/SKILL.md +378 -361
  57. package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
  58. package/templates/skills/orc-wiki/references/staleness.md +294 -288
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ // ── orc graph — the RESOLUTION CACHE (v1.8.0 EW2) ───────────────────────────
3
+ //
4
+ // W3 decided "resolution is computed on read, never stored". That was right for
5
+ // correctness and wrong for cost: on django/django ONE file card spent 437 ms
6
+ // working out who calls what, and `impact` up to 520 ms — every single time.
7
+ //
8
+ // EW2 measured what is worth storing, and the answer is narrower than the plan
9
+ // assumed. Two things were built, measured, and thrown away:
10
+ //
11
+ // · the resolver's whole MEMO — 21.7 MB, ~120 ms to read and parse, to save
12
+ // less than that. A net loss. Not stored.
13
+ // · CLOSURE REPAIR (re-resolve only the changed file and its dependents) —
14
+ // measured 2,669 ms against 2,695 ms for a plain full re-resolve on the
15
+ // same one-line edit. **One percent.** On a JSON store the fixed costs
16
+ // (change detection, loading the index, and rewriting the whole file) drown
17
+ // the resolve, so an incremental resolve cannot pay. It was three
18
+ // correctness bugs deep by the time the numbers came in, so it is gone.
19
+ //
20
+ // What IS stored is the REVERSE direction — who calls this symbol — which is
21
+ // the half the 437 ms walk was computing. 13 MB on django/django, and it turns
22
+ // a file card into 458 ms from 727 ms, and `impact` into 397 ms from 631 ms.
23
+ //
24
+ // The rules that keep W3's correctness:
25
+ //
26
+ // 1. `index.json` is still the only SOURCE. `resolved.json` is DERIVED, and
27
+ // throwing it away costs speed and nothing else.
28
+ // 2. Only `update` writes it, inside the same lock. A reader never writes, so
29
+ // readers stay lock-free.
30
+ // 3. A reader uses it ONLY when `resolved.generation === meta.generation`.
31
+ // Any other answer — a crash between two writes, an older engine, a hand
32
+ // deletion — falls back to computing on read. Stale is impossible: it is
33
+ // not "probably fine", it is not used at all.
34
+ // 4. A cached answer must be BYTE-IDENTICAL to a computed one, order
35
+ // included. `test/cli/graph-resolve.test.js` drives a seeded random edit
36
+ // script and compares the two after every step.
37
+ //
38
+ // WHAT IS STORED
39
+ //
40
+ // callers target symbol id → the confident callers of it, as
41
+ // `[caller symbol id, line, state, ref]`.
42
+ // maybe target symbol id → how many AMBIGUOUS calls list this target, by
43
+ // CALLER FILE. Per file, not per call site: 5 MB on django/django
44
+ // instead of 45 MB, and the card only ever shows the total.
45
+
46
+ const fs = require("fs");
47
+ const path = require("path");
48
+ const G = require("./graph.js");
49
+
50
+ const RESOLVE_SCHEMA = 2;
51
+ const CONFIDENT = new Set(["LOCAL", "IMPORT", "UNIQUE"]);
52
+ const NAMES_PER_KEY = 5;
53
+
54
+ function resolvedPath(claudeDir) {
55
+ return path.join(G.graphPaths(claudeDir).dir, "resolved.json");
56
+ }
57
+ function namesPath(claudeDir) {
58
+ return path.join(G.graphPaths(claudeDir).dir, "names.json");
59
+ }
60
+
61
+ // Every map here is keyed by data from the repository — a bare symbol name can
62
+ // be `constructor`, `toString` or `__proto__`. A plain object would hand back
63
+ // `Object.prototype`'s member instead of "nothing here", so maps are built with
64
+ // a null prototype and read through this.
65
+ function own(o, k) {
66
+ return o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined;
67
+ }
68
+ const bare = () => Object.create(null);
69
+
70
+ function readJson(file) {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(file, "utf8"));
73
+ } catch (_) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // ── build ───────────────────────────────────────────────────────────────────
79
+ // Called by `graphUpdate` INSIDE the lock, after index.json is on disk. It
80
+ // never throws into the update: a cache that cannot be written is a slower
81
+ // graph, not a broken one.
82
+ function build(claudeDir, root) {
83
+ const t0 = Date.now();
84
+ const Q = require("./graph-query.js");
85
+ try {
86
+ // `noCache` matters: the writer must COMPUTE, never read back the cache it
87
+ // is about to replace.
88
+ const model = Q.loadModel(claudeDir, root, { noCache: true });
89
+ if (!model) return { route: "skipped", reason: "no-index", ms: Date.now() - t0 };
90
+
91
+ const callers = bare();
92
+ const maybe = bare();
93
+ for (const [rel, f] of Object.entries(model.byFile)) {
94
+ for (const s of f.symbols || []) {
95
+ s.file = rel;
96
+ for (const c of s.calls || []) {
97
+ const r = Q.resolveCall(model, rel, s, c);
98
+ if (r.target && CONFIDENT.has(r.state)) {
99
+ (own(callers, r.target) || (callers[r.target] = [])).push([s.id, c.line, r.state, c.ref ? 1 : 0]);
100
+ } else if (r.state === "AMBIGUOUS") {
101
+ for (const cand of r.candidates || []) {
102
+ const m = own(maybe, cand) || (maybe[cand] = bare());
103
+ m[rel] = (own(m, rel) || 0) + 1;
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // One stable order, so the same tree always produces the same bytes.
111
+ const out = {
112
+ schema: RESOLVE_SCHEMA,
113
+ engine: G.ENGINE,
114
+ generation: model.meta.generation,
115
+ gen_id: model.meta.gen_id,
116
+ callers: sortedRows(callers),
117
+ maybe: sortedCounts(maybe),
118
+ };
119
+ G.atomicWrite(resolvedPath(claudeDir), JSON.stringify(out));
120
+ writeNames(claudeDir, model, out.callers);
121
+ return { route: "full", ms: Date.now() - t0 };
122
+ } catch (e) {
123
+ // A cache is an optimisation. Losing it must never lose the update — and a
124
+ // HALF-written one must never be read, so it goes.
125
+ try {
126
+ fs.rmSync(resolvedPath(claudeDir), { force: true });
127
+ fs.rmSync(namesPath(claudeDir), { force: true });
128
+ } catch (_) {}
129
+ return { route: "failed", reason: String((e && e.message) || e), ms: Date.now() - t0 };
130
+ }
131
+ }
132
+
133
+ function sortedObject(o) {
134
+ const out = bare();
135
+ for (const k of Object.keys(o).sort()) out[k] = o[k];
136
+ return out;
137
+ }
138
+ function sortedRows(o) {
139
+ const out = bare();
140
+ for (const k of Object.keys(o).sort()) {
141
+ out[k] = o[k].slice().sort((a, b) => String(a[0]).localeCompare(String(b[0])) || a[1] - b[1]);
142
+ }
143
+ return out;
144
+ }
145
+ // target → [[caller file, count], …], both levels in a stable order.
146
+ function sortedCounts(o) {
147
+ const out = bare();
148
+ for (const k of Object.keys(o).sort()) out[k] = Object.keys(o[k]).sort().map((f) => [f, o[k][f]]);
149
+ return out;
150
+ }
151
+
152
+ // ── names.json ──────────────────────────────────────────────────────────────
153
+ // A bare name → the few symbols worth showing for it. This is the ONLY file a
154
+ // delivery hook is allowed to read: a name lookup, no index parse, no resolve.
155
+ function writeNames(claudeDir, model, callers) {
156
+ const out = bare();
157
+ for (const [name, syms] of model.byName) {
158
+ const rows = syms
159
+ .map((s) => ({ s, fan: (own(callers, s.id) || []).length }))
160
+ .sort((a, b) => b.fan - a.fan || a.s.file.localeCompare(b.s.file))
161
+ .slice(0, NAMES_PER_KEY)
162
+ .map(({ s, fan }) => [s.qname, s.kind, s.file, s.lines[0], s.lines[1], fan]);
163
+ if (rows.length) out[name] = rows;
164
+ }
165
+ G.atomicWrite(namesPath(claudeDir), JSON.stringify({ schema: RESOLVE_SCHEMA, generation: model.meta.generation, names: sortedObject(out) }));
166
+ }
167
+
168
+ // ── read ────────────────────────────────────────────────────────────────────
169
+ // Used ONLY when the generation matches. Anything else returns null and the
170
+ // reader computes, exactly as it did before this file existed.
171
+ function load(claudeDir, meta) {
172
+ const r = readJson(resolvedPath(claudeDir));
173
+ if (!r || r.schema !== RESOLVE_SCHEMA || r.engine !== G.ENGINE) return null;
174
+ if (!meta || !meta.generation || r.generation !== meta.generation) return null;
175
+ return r;
176
+ }
177
+
178
+ module.exports = { own, RESOLVE_SCHEMA, resolvedPath, namesPath, build, load };
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ // ── orc graph — CHEAP SIGNALS for planners and reviewers (v1.8.0 EW5) ───────
3
+ //
4
+ // Four answers that cost NO model tokens, because every one of them is already
5
+ // on disk: git's own history, and the index the parser already built.
6
+ //
7
+ // changes what this diff actually touched, who calls it, and how risky
8
+ // that is. A reviewer asking `impact` on the DECLARED files gets
9
+ // the whole file; this gets the symbols the HUNKS overlap.
10
+ // importance one number per symbol, so a card's rows are ordered by what
11
+ // matters instead of by raw fan-in. A helper called from forty
12
+ // places is not more important than the exported entry point.
13
+ // tests the test files that reach a symbol — by CALL (the graph) and by
14
+ // NAME (`x.test.js` beside `x.js`). Both, because a test that
15
+ // imports a module without naming a symbol is still its test.
16
+ // cochange the files that historically change together with this one. The
17
+ // planner's question "what else will I have to touch" is answered
18
+ // by history far better than by static edges.
19
+ //
20
+ // THE RULE THAT SHAPES ALL FOUR: a signal is a HINT with its evidence attached.
21
+ // `changes` says WHY a symbol is high risk (exported, fan-in 4, no test caller),
22
+ // never just "high". A number nobody can check is a number nobody should act on.
23
+
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+ const { spawnSync } = require("child_process");
27
+ const G = require("./graph.js");
28
+
29
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
30
+ // A merge or a sweeping rename touches hundreds of files and would make every
31
+ // pair look related. CBM's threshold, and the same reason: a commit that broad
32
+ // carries no information about which two files belong together.
33
+ const COCHANGE_MAX_FILES = 20;
34
+ const COCHANGE_MIN = 3;
35
+ const COCHANGE_SINCE = "6.months";
36
+ const COCHANGE_ROWS = 12;
37
+
38
+ function git(root, args) {
39
+ return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
40
+ }
41
+
42
+ const norm = (p) => String(p).split("\\").join("/").replace(/^\.\//, "");
43
+
44
+ // ── importance ──────────────────────────────────────────────────────────────
45
+ // sqrt of the confident in-edges, then knocked down by an order of magnitude for
46
+ // each thing that makes a symbol less likely to be what you are looking for.
47
+ // The square root matters: a symbol with 100 callers is not ten times more
48
+ // important than one with 10, it is a utility.
49
+ function importance(model, sym, fanIn) {
50
+ let v = Math.sqrt(Math.max(0, fanIn));
51
+ if (!sym.exported) v *= 0.1;
52
+ // A name defined in five or more files is a convention (`handler`, `index`,
53
+ // `run`), not a landmark.
54
+ const sameName = (model.byName.get(sym.name) || []).length;
55
+ if (sameName >= 5) v *= 0.1;
56
+ if (require("./graph-query.js").TEST_FILE.test(sym.file)) v *= 0.1;
57
+ return Math.round(v * 1000) / 1000;
58
+ }
59
+
60
+ // ── tests ───────────────────────────────────────────────────────────────────
61
+ // The naming half. `src/order.js` is tested by `order.test.js`, `test_order.py`,
62
+ // `order_test.go`, `OrderTest.java` — wherever they live.
63
+ function testsByName(model, rel) {
64
+ const base = path.posix.basename(rel).replace(/\.[^.]+$/, "");
65
+ if (!base) return [];
66
+ const want = new Set([
67
+ `${base}.test`,
68
+ `${base}.spec`,
69
+ `test_${base}`,
70
+ `${base}_test`,
71
+ `${base}Test`,
72
+ `${base}Tests`,
73
+ ]);
74
+ const out = [];
75
+ for (const f of model.fileSet) {
76
+ if (f === rel) continue;
77
+ const stem = path.posix.basename(f).replace(/\.[^.]+$/, "");
78
+ if (want.has(stem)) out.push(f);
79
+ }
80
+ return out.sort();
81
+ }
82
+
83
+ // ── changes ─────────────────────────────────────────────────────────────────
84
+ // exit 0 answered · 1 no graph or not a git repository
85
+ //
86
+ // `git diff -U0` gives the hunks. A symbol whose line range overlaps a hunk was
87
+ // really touched; a symbol elsewhere in the same file was not, and saying it was
88
+ // is what makes a whole-file `impact` noisy at review time.
89
+ function parseDiff(text) {
90
+ const byFile = new Map();
91
+ let cur = null;
92
+ for (const line of String(text).split("\n")) {
93
+ const f = /^\+\+\+ b\/(.*)$/.exec(line);
94
+ if (f) {
95
+ cur = f[1] === "/dev/null" ? null : norm(f[1]);
96
+ if (cur && !byFile.has(cur)) byFile.set(cur, []);
97
+ continue;
98
+ }
99
+ if (!cur) continue;
100
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
101
+ if (h) {
102
+ const start = Number(h[1]);
103
+ const count = h[2] === undefined ? 1 : Number(h[2]);
104
+ // A pure deletion has a zero-length new side; it still touched the line it
105
+ // was removed from, so record it as a one-line hunk there.
106
+ byFile.get(cur).push(count === 0 ? [start, start] : [start, start + count - 1]);
107
+ }
108
+ }
109
+ return byFile;
110
+ }
111
+
112
+ // The paths this diff touches, by name only — two cheap git calls, no parsing.
113
+ // `changes` is a REVIEW-time command, so it heals on these before it answers:
114
+ // a reviewer told "nothing depends on this" by a stale index is worse served
115
+ // than one told nothing at all.
116
+ function changedPaths(root, base) {
117
+ const out = [];
118
+ const d = git(root, base ? ["diff", "--name-only", "-z", base, "--"] : ["diff", "--name-only", "-z", "HEAD", "--"]);
119
+ if (d.status === 0) for (const f of d.stdout.split("\0")) if (f) out.push(norm(f));
120
+ const u = git(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
121
+ if (u.status === 0) for (const f of u.stdout.split("\0")) if (f) out.push(norm(f));
122
+ return [...new Set(out)];
123
+ }
124
+
125
+ function graphChanges(claudeDir, root, opts) {
126
+ const Q = require("./graph-query.js");
127
+ const model = Q.loadModel(claudeDir, root);
128
+ if (!model) return { ok: false, state: "none", reason: "no-index", exit: 1 };
129
+ const base = opts.base || null;
130
+ const d = git(root, base ? ["diff", "-U0", base, "--"] : ["diff", "-U0", "HEAD", "--"]);
131
+ if (d.error || d.status !== 0) return { ok: false, state: "unavailable", reason: "git-diff-failed", exit: 1 };
132
+ const hunks = parseDiff(d.stdout);
133
+
134
+ // An untracked file is a change too, and `git diff` never mentions it. Its
135
+ // whole body is the hunk.
136
+ const un = git(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
137
+ if (un.status === 0) {
138
+ for (const rel of un.stdout.split("\0")) {
139
+ if (!rel) continue;
140
+ const r = norm(rel);
141
+ if (!hunks.has(r)) hunks.set(r, [[1, Number.MAX_SAFE_INTEGER]]);
142
+ }
143
+ }
144
+
145
+ const rows = [];
146
+ const files = [...hunks.keys()].filter((f) => model.fileSet.has(f)).sort();
147
+ const indexable = new Set(Object.keys(G.LANG_BY_EXT));
148
+ const absent = [...hunks.keys()]
149
+ .filter((f) => !model.fileSet.has(f) && indexable.has(path.posix.extname(f).toLowerCase()))
150
+ .sort();
151
+ for (const rel of files) {
152
+ const ranges = hunks.get(rel);
153
+ for (const sym of (model.byFile[rel] || {}).symbols || []) {
154
+ if (sym.kind === "module") continue;
155
+ const touched = ranges.some((h) => sym.lines[0] <= h[1] && h[0] <= sym.lines[1]);
156
+ if (!touched) continue;
157
+ const c = Q.callersOf(model, sym);
158
+ const callerFiles = [...new Set(c.confident.map((x) => x.sym.file))];
159
+ const tests = [...new Set([...callerFiles.filter((f) => Q.TEST_FILE.test(f)), ...testsByName(model, rel)])];
160
+ // The reason is part of the answer. "high" with nothing behind it is a
161
+ // number nobody can check, and nobody should act on.
162
+ const why = [];
163
+ if (sym.exported) why.push("exported");
164
+ why.push(`fan-in ${c.confident.length}`);
165
+ if (!tests.length) why.push("no test reaches it");
166
+ if (c.maybe) why.push(`${c.maybe} ambiguous caller(s) not counted`);
167
+ const risk = sym.exported && c.confident.length >= 3 && !tests.length ? "high" : sym.exported || c.confident.length >= 1 ? "medium" : "low";
168
+ rows.push({
169
+ id: sym.id,
170
+ qname: sym.qname,
171
+ kind: sym.kind,
172
+ file: rel,
173
+ lines: sym.lines,
174
+ exported: !!sym.exported,
175
+ fan_in: c.confident.length,
176
+ maybe_callers: c.maybe,
177
+ caller_files: callerFiles.sort(),
178
+ tests: tests.sort(),
179
+ risk,
180
+ why,
181
+ });
182
+ }
183
+ }
184
+ const order = { high: 0, medium: 1, low: 2 };
185
+ rows.sort((a, b) => order[a.risk] - order[b.risk] || b.fan_in - a.fan_in || a.file.localeCompare(b.file) || a.lines[0] - b.lines[0]);
186
+ const counts = { high: 0, medium: 0, low: 0 };
187
+ for (const r of rows) counts[r.risk]++;
188
+ return {
189
+ ok: true,
190
+ state: "found",
191
+ base: base || "HEAD",
192
+ files,
193
+ not_in_graph: absent,
194
+ symbols: rows,
195
+ counts,
196
+ generation: model.meta.generation || 0,
197
+ exit: 0,
198
+ };
199
+ }
200
+
201
+ // ── cochange ────────────────────────────────────────────────────────────────
202
+ // exit 0 answered · 1 no graph, or git could not answer · 4 nothing reaches the
203
+ // threshold (an ANSWER: this file changes alone)
204
+ //
205
+ // Cached per HEAD. History does not move under a working-tree edit, so the cache
206
+ // is valid for exactly as long as HEAD is.
207
+ function cochangePath(claudeDir) {
208
+ return path.join(G.graphPaths(claudeDir).dir, "cochange.json");
209
+ }
210
+
211
+ function buildCochange(root) {
212
+ const r = git(root, ["log", `--since=${COCHANGE_SINCE}`, "--name-only", "--pretty=format:%x00", "--no-merges"]);
213
+ if (r.error || r.status !== 0) return null;
214
+ const pairs = Object.create(null);
215
+ const total = Object.create(null);
216
+ for (const commit of r.stdout.split("\0")) {
217
+ const files = commit.split("\n").map((x) => x.trim()).filter(Boolean).map(norm);
218
+ const uniq = [...new Set(files)];
219
+ if (uniq.length < 2 || uniq.length > COCHANGE_MAX_FILES) continue;
220
+ for (const f of uniq) total[f] = (total[f] || 0) + 1;
221
+ for (let i = 0; i < uniq.length; i++) {
222
+ for (let j = i + 1; j < uniq.length; j++) {
223
+ const k = uniq[i] < uniq[j] ? `${uniq[i]}\u0000${uniq[j]}` : `${uniq[j]}\u0000${uniq[i]}`;
224
+ pairs[k] = (pairs[k] || 0) + 1;
225
+ }
226
+ }
227
+ }
228
+ return { pairs, total };
229
+ }
230
+
231
+ function graphCochange(claudeDir, root, opts) {
232
+ const rel = norm(opts.file || "");
233
+ if (!rel) return { ok: false, reason: "missing-operand", exit: 1 };
234
+ const head = git(root, ["rev-parse", "HEAD"]);
235
+ if (head.status !== 0) return { ok: false, state: "unavailable", reason: "not-git", exit: 1 };
236
+ const at = head.stdout.trim();
237
+
238
+ let cache = null;
239
+ try {
240
+ cache = JSON.parse(fs.readFileSync(cochangePath(claudeDir), "utf8"));
241
+ } catch (_) {}
242
+ if (!cache || cache.head !== at || cache.schema !== 1) {
243
+ const built = buildCochange(root);
244
+ if (!built) return { ok: false, state: "unavailable", reason: "git-log-failed", exit: 1 };
245
+ cache = { schema: 1, head: at, since: COCHANGE_SINCE, ...built };
246
+ try {
247
+ G.atomicWrite(cochangePath(claudeDir), JSON.stringify(cache));
248
+ } catch (_) {}
249
+ }
250
+
251
+ const rows = [];
252
+ const pre = rel + "\u0000";
253
+ const suf = "\u0000" + rel;
254
+ for (const [k, n] of Object.entries(cache.pairs || {})) {
255
+ if (n < COCHANGE_MIN) continue;
256
+ let other = null;
257
+ if (k.startsWith(pre)) other = k.slice(pre.length);
258
+ else if (k.endsWith(suf)) other = k.slice(0, k.length - suf.length);
259
+ if (!other) continue;
260
+ const own = (cache.total || {})[rel] || 0;
261
+ rows.push({ file: other, together: n, of: own, share: own ? Math.round((n / own) * 100) / 100 : 0 });
262
+ }
263
+ rows.sort((a, b) => b.together - a.together || a.file.localeCompare(b.file));
264
+ return {
265
+ ok: true,
266
+ state: rows.length ? "found" : "none",
267
+ file: rel,
268
+ commits: (cache.total || {})[rel] || 0,
269
+ since: cache.since,
270
+ min: COCHANGE_MIN,
271
+ rows: rows.slice(0, COCHANGE_ROWS),
272
+ more: Math.max(0, rows.length - COCHANGE_ROWS),
273
+ exit: rows.length ? 0 : 4,
274
+ };
275
+ }
276
+
277
+ module.exports = { importance, testsByName, parseDiff, changedPaths, graphChanges, graphCochange, cochangePath, COCHANGE_MIN, COCHANGE_MAX_FILES };