@renzrollon/interlock 1.0.0

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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,249 @@
1
+ import { formatSubgraph } from '../format.mjs';
2
+
3
+ /**
4
+ * Build adjacency maps from graph.
5
+ */
6
+ export function indexGraph(graph) {
7
+ const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
8
+ const out = new Map();
9
+ const inn = new Map();
10
+ for (const n of graph.nodes) {
11
+ out.set(n.id, []);
12
+ inn.set(n.id, []);
13
+ }
14
+ for (const e of graph.links) {
15
+ if (!out.has(e.source)) out.set(e.source, []);
16
+ if (!inn.has(e.target)) inn.set(e.target, []);
17
+ out.get(e.source).push(e);
18
+ inn.get(e.target).push(e);
19
+ }
20
+ return { nodes, out, inn };
21
+ }
22
+
23
+ /**
24
+ * Expand query tokens against node labels / ids / source_file.
25
+ */
26
+ export function findSeedNodes(graph, query) {
27
+ const tokens = tokenize(query);
28
+ if (tokens.length === 0) return [];
29
+
30
+ const scored = [];
31
+ for (const n of graph.nodes) {
32
+ const hay = `${n.id} ${n.label} ${n.source_file || ''}`.toLowerCase();
33
+ let score = 0;
34
+ for (const t of tokens) {
35
+ if (n.label?.toLowerCase() === t) score += 10;
36
+ else if (n.id.toLowerCase().endsWith(`#${t}`)) score += 9;
37
+ else if (n.id.toLowerCase().includes(t)) score += 5;
38
+ else if (hay.includes(t)) score += 3;
39
+ }
40
+ if (score > 0) scored.push({ node: n, score });
41
+ }
42
+ scored.sort((a, b) => b.score - a.score);
43
+ return scored.slice(0, 8).map((s) => s.node);
44
+ }
45
+
46
+ function tokenize(query) {
47
+ return String(query)
48
+ .split(/[\s,|]+/)
49
+ .map((t) => t.trim().toLowerCase())
50
+ .filter((t) => t.length >= 2)
51
+ .map((t) => t.replace(/^file:/, '').replace(/^symbol:/, ''));
52
+ }
53
+
54
+ /**
55
+ * BFS or DFS neighborhood from seeds.
56
+ */
57
+ export function traverse(graph, seeds, { mode = 'bfs', depth = 2, budget = 1500 } = {}) {
58
+ const { nodes, out, inn } = indexGraph(graph);
59
+ const seedIds = seeds.map((s) => s.id);
60
+ const visitedNodes = new Map();
61
+ const visitedEdges = [];
62
+ const queue = seedIds.map((id) => ({ id, d: 0 }));
63
+ const seen = new Set();
64
+
65
+ while (queue.length) {
66
+ const cur = mode === 'dfs' ? queue.pop() : queue.shift();
67
+ if (!cur || seen.has(cur.id)) continue;
68
+ seen.add(cur.id);
69
+ const node = nodes.get(cur.id);
70
+ if (node) visitedNodes.set(cur.id, node);
71
+
72
+ if (cur.d >= depth) continue;
73
+
74
+ const edges = [...(out.get(cur.id) || []), ...(inn.get(cur.id) || [])];
75
+ for (const e of edges) {
76
+ visitedEdges.push(e);
77
+ const next = e.source === cur.id ? e.target : e.source;
78
+ if (!seen.has(next)) {
79
+ if (mode === 'dfs') queue.push({ id: next, d: cur.d + 1 });
80
+ else queue.push({ id: next, d: cur.d + 1 });
81
+ }
82
+ }
83
+
84
+ // Early stop by rough token budget
85
+ const rough = visitedNodes.size * 20 + visitedEdges.length * 25;
86
+ if (rough > budget) break;
87
+ }
88
+
89
+ return formatSubgraph({
90
+ mode,
91
+ seeds: seedIds,
92
+ nodes: [...visitedNodes.values()],
93
+ edges: dedupeEdges(visitedEdges),
94
+ budget,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Reverse imports + references for a symbol or file.
100
+ */
101
+ export function consumers(graph, target) {
102
+ const seeds = findSeedNodes(graph, target);
103
+ if (seeds.length === 0) {
104
+ return `No nodes matched: ${target}\n`;
105
+ }
106
+ const { nodes, inn } = indexGraph(graph);
107
+ const lines = [`Consumers of: ${seeds.map((s) => s.id).join(', ')}`, ''];
108
+
109
+ for (const seed of seeds) {
110
+ const incoming = (inn.get(seed.id) || []).filter(
111
+ (e) => e.relation === 'imports' || e.relation === 'references' || e.relation === 'implements_spec',
112
+ );
113
+ // Also: if seed is a symbol, include importers of its file that name the symbol
114
+ if (seed.type === 'symbol') {
115
+ const fileId = `file:${seed.source_file}`;
116
+ for (const e of inn.get(fileId) || []) {
117
+ if (e.relation === 'imports' && (e.imported === seed.label || e.local === seed.label)) {
118
+ incoming.push(e);
119
+ }
120
+ }
121
+ }
122
+ lines.push(`NODE ${seed.id} [${seed.source_file}:${seed.source_location}]`);
123
+ if (incoming.length === 0) {
124
+ lines.push(' (no consumers indexed)');
125
+ } else {
126
+ const seen = new Set();
127
+ for (const e of incoming) {
128
+ const key = `${e.source}|${e.relation}|${e.source_location}`;
129
+ if (seen.has(key)) continue;
130
+ seen.add(key);
131
+ const src = nodes.get(e.source);
132
+ lines.push(
133
+ ` EDGE ${short(e.source)} --${e.relation} [${e.confidence}]--> ${short(e.target)} (${e.source_file}:${e.source_location})`,
134
+ );
135
+ if (src?.type === 'file') {
136
+ lines.push(` consumer_file: ${src.source_file}`);
137
+ }
138
+ }
139
+ }
140
+ lines.push('');
141
+ }
142
+ return `${lines.join('\n')}\n`;
143
+ }
144
+
145
+ /**
146
+ * Shortest path between two queries.
147
+ */
148
+ export function shortestPath(graph, aQuery, bQuery) {
149
+ const aSeeds = findSeedNodes(graph, aQuery);
150
+ const bSeeds = findSeedNodes(graph, bQuery);
151
+ if (!aSeeds.length || !bSeeds.length) {
152
+ return `Could not resolve path endpoints.\n A matches: ${aSeeds.length}\n B matches: ${bSeeds.length}\n`;
153
+ }
154
+ const start = aSeeds[0].id;
155
+ const goals = new Set(bSeeds.map((s) => s.id));
156
+ const { nodes, out } = indexGraph(graph);
157
+
158
+ const prev = new Map();
159
+ const prevEdge = new Map();
160
+ const q = [start];
161
+ prev.set(start, null);
162
+ let found = null;
163
+
164
+ while (q.length) {
165
+ const cur = q.shift();
166
+ if (goals.has(cur)) {
167
+ found = cur;
168
+ break;
169
+ }
170
+ for (const e of out.get(cur) || []) {
171
+ if (!prev.has(e.target)) {
172
+ prev.set(e.target, cur);
173
+ prevEdge.set(e.target, e);
174
+ q.push(e.target);
175
+ }
176
+ }
177
+ }
178
+
179
+ if (!found) {
180
+ // try undirected
181
+ const { inn } = indexGraph(graph);
182
+ prev.clear();
183
+ prevEdge.clear();
184
+ q.length = 0;
185
+ q.push(start);
186
+ prev.set(start, null);
187
+ while (q.length) {
188
+ const cur = q.shift();
189
+ if (goals.has(cur)) {
190
+ found = cur;
191
+ break;
192
+ }
193
+ const edges = [...(out.get(cur) || []), ...(inn.get(cur) || [])];
194
+ for (const e of edges) {
195
+ const next = e.source === cur ? e.target : e.source;
196
+ if (!prev.has(next)) {
197
+ prev.set(next, cur);
198
+ prevEdge.set(next, e);
199
+ q.push(next);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ if (!found) return `No path found between ${start} and ${[...goals].join('|')}\n`;
206
+
207
+ const pathNodes = [];
208
+ const pathEdges = [];
209
+ let cur = found;
210
+ while (cur) {
211
+ pathNodes.unshift(nodes.get(cur));
212
+ const e = prevEdge.get(cur);
213
+ if (e) pathEdges.unshift(e);
214
+ cur = prev.get(cur);
215
+ }
216
+
217
+ return formatSubgraph({
218
+ mode: 'path',
219
+ seeds: [start, found],
220
+ nodes: pathNodes.filter(Boolean),
221
+ edges: pathEdges,
222
+ budget: 5000,
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Explain a single node and neighbors.
228
+ */
229
+ export function explain(graph, query) {
230
+ const seeds = findSeedNodes(graph, query);
231
+ if (!seeds.length) return `No node matched: ${query}\n`;
232
+ return traverse(graph, seeds.slice(0, 1), { mode: 'bfs', depth: 1, budget: 2000 });
233
+ }
234
+
235
+ function dedupeEdges(edges) {
236
+ const seen = new Set();
237
+ const out = [];
238
+ for (const e of edges) {
239
+ const key = `${e.source}|${e.target}|${e.relation}|${e.source_location}`;
240
+ if (seen.has(key)) continue;
241
+ seen.add(key);
242
+ out.push(e);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function short(id) {
248
+ return id.replace(/^(file|symbol|module|openspec|memory|external|unresolved):/, '');
249
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Generate GRAPH_REPORT.md content from a graph.
3
+ */
4
+ export function writeReport(_root, graph, stats) {
5
+ const degree = new Map();
6
+ for (const n of graph.nodes) degree.set(n.id, { in: 0, out: 0, node: n });
7
+ for (const e of graph.links) {
8
+ if (degree.has(e.source)) degree.get(e.source).out += 1;
9
+ if (degree.has(e.target)) degree.get(e.target).in += 1;
10
+ }
11
+
12
+ const hubs = [...degree.values()]
13
+ .filter((d) => d.node.type === 'file' || d.node.type === 'symbol')
14
+ .map((d) => ({ ...d, total: d.in + d.out }))
15
+ .sort((a, b) => b.total - a.total)
16
+ .slice(0, 15);
17
+
18
+ const modules = graph.nodes
19
+ .filter((n) => n.type === 'module')
20
+ .map((m) => {
21
+ const members = graph.links
22
+ .filter((e) => e.relation === 'belongs_to' && e.target === m.id)
23
+ .map((e) => e.source.replace(/^file:/, ''));
24
+ return { ...m, members };
25
+ })
26
+ .sort((a, b) => b.members.length - a.members.length);
27
+
28
+ const surprising = graph.links
29
+ .filter((e) => e.confidence === 'INFERRED' && (e.relation === 'implements_spec' || e.relation === 'couples_with'))
30
+ .slice(0, 20);
31
+
32
+ const crossModule = [];
33
+ const fileModule = new Map();
34
+ for (const e of graph.links) {
35
+ if (e.relation === 'belongs_to' && e.source.startsWith('file:')) {
36
+ fileModule.set(e.source, e.target);
37
+ }
38
+ }
39
+ for (const e of graph.links) {
40
+ if (e.relation !== 'imports') continue;
41
+ if (!e.source.startsWith('file:') || !e.target.startsWith('file:')) continue;
42
+ const a = fileModule.get(e.source);
43
+ const b = fileModule.get(e.target);
44
+ if (a && b && a !== b) {
45
+ crossModule.push(e);
46
+ }
47
+ }
48
+
49
+ const lines = [];
50
+ lines.push('# Interlock Graph Report');
51
+ lines.push('');
52
+ lines.push(`Built: ${graph.graph?.built_at || 'unknown'}`);
53
+ lines.push(`Extractor: ${stats.extractor || graph.graph?.extractor || 'unknown'}`);
54
+ lines.push('');
55
+ lines.push('## Corpus stats');
56
+ lines.push('');
57
+ lines.push(`- Files: ${stats.files}`);
58
+ lines.push(`- Nodes: ${stats.nodes}`);
59
+ lines.push(`- Edges: ${stats.edges}`);
60
+ lines.push(`- Symbols: ${stats.symbols}`);
61
+ lines.push(`- Modules: ${stats.modules}`);
62
+ lines.push('');
63
+ lines.push('## God nodes (highest degree)');
64
+ lines.push('');
65
+ if (hubs.length === 0) {
66
+ lines.push('_None_');
67
+ } else {
68
+ for (const h of hubs) {
69
+ lines.push(
70
+ `- **${h.node.label}** (\`${h.node.source_file}${h.node.source_location ? ':' + h.node.source_location : ''}\`) — in:${h.in} out:${h.out} type:${h.node.type}`,
71
+ );
72
+ }
73
+ }
74
+ lines.push('');
75
+ lines.push('## Modules');
76
+ lines.push('');
77
+ for (const m of modules.slice(0, 30)) {
78
+ lines.push(`### ${m.label} (${m.members.length} files)`);
79
+ for (const f of m.members.slice(0, 12)) {
80
+ lines.push(`- \`${f}\``);
81
+ }
82
+ if (m.members.length > 12) lines.push(`- … +${m.members.length - 12} more`);
83
+ lines.push('');
84
+ }
85
+ lines.push('## Surprising / inferred connections');
86
+ lines.push('');
87
+ if (surprising.length === 0) {
88
+ lines.push('_None_');
89
+ } else {
90
+ for (const e of surprising) {
91
+ lines.push(
92
+ `- \`${short(e.source)}\` --${e.relation} [${e.confidence}]--> \`${short(e.target)}\` (${e.source_file}:${e.source_location})`,
93
+ );
94
+ }
95
+ }
96
+ lines.push('');
97
+ lines.push('## Cross-module imports (sample)');
98
+ lines.push('');
99
+ const sample = crossModule.slice(0, 25);
100
+ if (sample.length === 0) {
101
+ lines.push('_None_');
102
+ } else {
103
+ for (const e of sample) {
104
+ lines.push(
105
+ `- \`${short(e.source)}\` → \`${short(e.target)}\` (${e.source_file}:${e.source_location})`,
106
+ );
107
+ }
108
+ }
109
+ lines.push('');
110
+ lines.push('## Suggested questions');
111
+ lines.push('');
112
+ lines.push('- Which files import a given symbol? (`interlock-graph consumers <symbol>`)');
113
+ lines.push('- What is the shortest path between two modules/files? (`interlock-graph path A B`)');
114
+ lines.push('- What are the hub files in this repo? (see God nodes above)');
115
+ lines.push('- Which OpenSpec artifacts mention a symbol? (filter implements_spec edges)');
116
+ lines.push('');
117
+ return `${lines.join('\n')}\n`;
118
+ }
119
+
120
+ function short(id) {
121
+ return id.replace(/^(file|symbol|module|openspec|memory|external|unresolved):/, '');
122
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import { walkSourceFiles } from '../extract/walk.mjs';
3
+ import { loadJson, manifestPath, rel, sha256File } from '../paths.mjs';
4
+ import { buildGraph, writeGraphArtifacts } from './build.mjs';
5
+
6
+ /**
7
+ * Incremental update: rebuild fully if many files changed; otherwise rebuild all
8
+ * (v1 keeps it simple — detect staleness, then full rebuild of affected set + dependents
9
+ * is complex; we do full rebuild when any hash differs, which is still fast for mid-size repos).
10
+ *
11
+ * Returns { updated: boolean, stats, reason }.
12
+ */
13
+ export function updateGraph(root) {
14
+ const manifest = loadJson(manifestPath(root), null);
15
+ if (!manifest?.files) {
16
+ const result = buildGraph(root);
17
+ writeGraphArtifacts(root, result);
18
+ return { updated: true, stats: result.stats, reason: 'no-manifest' };
19
+ }
20
+
21
+ const currentFiles = walkSourceFiles(root).map((f) => rel(root, f));
22
+ const prevFiles = new Set(Object.keys(manifest.files));
23
+ const changed = [];
24
+
25
+ for (const f of currentFiles) {
26
+ const abs = `${root}/${f}`;
27
+ if (!fs.existsSync(abs)) continue;
28
+ const hash = sha256File(abs);
29
+ if (manifest.files[f] !== hash) changed.push(f);
30
+ prevFiles.delete(f);
31
+ }
32
+ const removed = [...prevFiles];
33
+
34
+ if (changed.length === 0 && removed.length === 0) {
35
+ return { updated: false, stats: manifest.stats, reason: 'up-to-date' };
36
+ }
37
+
38
+ const result = buildGraph(root);
39
+ writeGraphArtifacts(root, result);
40
+ return {
41
+ updated: true,
42
+ stats: result.stats,
43
+ reason: `changed=${changed.length} removed=${removed.length}`,
44
+ changed,
45
+ removed,
46
+ };
47
+ }
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const GRAPH_DIR = '.claude/graph';
6
+ export const GRAPH_FILE = 'graph.json';
7
+ export const REPORT_FILE = 'GRAPH_REPORT.md';
8
+ export const MANIFEST_FILE = 'manifest.json';
9
+ export const DOCS_INDEX_FILE = 'docs-index.json';
10
+ export const DOCS_DIGEST_FILE = 'DOCS_DIGEST.md';
11
+
12
+ export function resolveRoot(cwd = process.cwd()) {
13
+ return path.resolve(cwd);
14
+ }
15
+
16
+ export function graphDir(root) {
17
+ return path.join(root, GRAPH_DIR);
18
+ }
19
+
20
+ export function graphPath(root) {
21
+ return path.join(graphDir(root), GRAPH_FILE);
22
+ }
23
+
24
+ export function reportPath(root) {
25
+ return path.join(graphDir(root), REPORT_FILE);
26
+ }
27
+
28
+ export function manifestPath(root) {
29
+ return path.join(graphDir(root), MANIFEST_FILE);
30
+ }
31
+
32
+ export function docsIndexPath(root) {
33
+ return path.join(graphDir(root), DOCS_INDEX_FILE);
34
+ }
35
+
36
+ export function docsDigestPath(root) {
37
+ return path.join(graphDir(root), DOCS_DIGEST_FILE);
38
+ }
39
+
40
+ export function ensureGraphDir(root) {
41
+ fs.mkdirSync(graphDir(root), { recursive: true });
42
+ }
43
+
44
+ export function rel(root, abs) {
45
+ return path.relative(root, abs).split(path.sep).join('/');
46
+ }
47
+
48
+ export function abs(root, relPath) {
49
+ return path.join(root, ...relPath.split('/'));
50
+ }
51
+
52
+ export function sha256File(filePath) {
53
+ const hash = createHash('sha256');
54
+ hash.update(fs.readFileSync(filePath));
55
+ return hash.digest('hex');
56
+ }
57
+
58
+ export function loadJson(filePath, fallback = null) {
59
+ if (!fs.existsSync(filePath)) return fallback;
60
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
61
+ }
62
+
63
+ export function writeJson(filePath, data) {
64
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
65
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
66
+ }
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs';
2
+ import { graphPath } from '../paths.mjs';
3
+ import { loadGraph } from '../graph/build.mjs';
4
+ import { findSeedNodes, traverse } from '../graph/query.mjs';
5
+ import { deriveTerms, retrieveMarkdownSections } from './markdown.mjs';
6
+ import { estimateTokens } from '../format.mjs';
7
+
8
+ /**
9
+ * Build a combined context bundle: structural graph subgraph + ranked doc sections.
10
+ */
11
+ export function retrieveContext(root, {
12
+ query = '',
13
+ changedFiles = [],
14
+ commitSubject = '',
15
+ budget = 2000,
16
+ graphBudget = null,
17
+ docsBudget = null,
18
+ docsDirs = null,
19
+ } = {}) {
20
+ const terms = deriveTerms({ query, changedFiles, commitSubject });
21
+ const searchQuery = query || terms.slice(0, 8).join(' ');
22
+ const graphShare = graphBudget ?? Math.floor(budget * 0.45);
23
+ const docsShare = docsBudget ?? budget - graphShare;
24
+
25
+ const parts = [];
26
+ parts.push(`# Context bundle`);
27
+ parts.push(`Query: ${searchQuery || '(derived from changed files)'}`);
28
+ parts.push(`Terms: [${terms.join(', ')}] | total budget=${budget}`);
29
+ parts.push('');
30
+
31
+ let graphText = '';
32
+ let graphPresent = false;
33
+
34
+ if (fs.existsSync(graphPath(root))) {
35
+ try {
36
+ const graph = loadGraph(root);
37
+ const seeds = findSeedNodes(graph, searchQuery);
38
+ if (seeds.length > 0) {
39
+ graphText = traverse(graph, seeds, { mode: 'bfs', depth: 2, budget: graphShare });
40
+ graphPresent = true;
41
+ } else {
42
+ graphText = `Structural: no graph nodes matched "${searchQuery}" — using documentation retrieval only.\n`;
43
+ }
44
+ } catch (err) {
45
+ graphText = `Structural: graph load failed (${err.message}) — documentation retrieval only.\n`;
46
+ }
47
+ } else {
48
+ graphText =
49
+ `Structural: no graph at ${graphPath(root)} — run \`interlock-graph build .\` for import/symbol navigation.\n`;
50
+ }
51
+
52
+ parts.push('## Structural (graph)');
53
+ parts.push(graphText.trimEnd());
54
+ parts.push('');
55
+
56
+ const docsResult = retrieveMarkdownSections(root, {
57
+ query: searchQuery,
58
+ terms,
59
+ changedFiles,
60
+ dirs: docsDirs ?? undefined,
61
+ budget: docsShare,
62
+ });
63
+
64
+ parts.push(docsResult.text.trimEnd());
65
+
66
+ const text = `${parts.join('\n')}\n`;
67
+ return {
68
+ text,
69
+ terms,
70
+ graphPresent,
71
+ docsEmpty: docsResult.empty,
72
+ docsSectionCount: docsResult.sectionCount,
73
+ tokens: estimateTokens(text),
74
+ empty: !graphPresent && docsResult.empty,
75
+ };
76
+ }
77
+
78
+ export function formatContextResult(result) {
79
+ if (result.empty) {
80
+ return (
81
+ `${result.text}\n` +
82
+ `(empty bundle — no graph match and no documentation sections; try broader terms or build the graph)\n`
83
+ );
84
+ }
85
+ return result.text;
86
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import {
3
+ docsDigestPath,
4
+ docsIndexPath,
5
+ ensureGraphDir,
6
+ rel,
7
+ sha256File,
8
+ writeJson,
9
+ } from '../paths.mjs';
10
+ import { walkMarkdown, resolveDocsDirs } from '../extract/walk.mjs';
11
+ import { estimateTokens } from '../format.mjs';
12
+ import { parseMarkdownSections } from './markdown.mjs';
13
+
14
+ /** Preferred markdown roots for the docs digest index (never openspec/changes/). */
15
+ export const DOCS_INDEX_DEFAULT_DIRS = ['docs', 'openspec/specs'];
16
+
17
+ const DEFAULT_BLURB_TOKENS = 40;
18
+
19
+ export { docsDigestPath, docsIndexPath, resolveDocsDirs };
20
+
21
+ /**
22
+ * Extract a short extractive blurb (~blurbTokens) from section content.
23
+ * Strips the heading line when present; prefers non-empty prose/bullet lines.
24
+ */
25
+ export function extractBlurb(content, blurbTokens = DEFAULT_BLURB_TOKENS) {
26
+ const lines = String(content || '')
27
+ .split('\n')
28
+ .map((l) => l.trim())
29
+ .filter((l) => l && !/^#{1,6}\s/.test(l) && !/^```/.test(l));
30
+ const joined = lines.join(' ').replace(/\s+/g, ' ').trim();
31
+ if (!joined) return '';
32
+ const charLimit = blurbTokens * 4;
33
+ if (joined.length <= charLimit) return joined;
34
+ return `${joined.slice(0, charLimit).trimEnd()}…`;
35
+ }
36
+
37
+ /**
38
+ * Build a deterministic TOC index over markdown under dirs
39
+ * (default: docs/ + openspec/specs/; never openspec/changes/).
40
+ */
41
+ export function buildDocsIndex(root, {
42
+ dirs: requestedDirs,
43
+ blurbTokens = DEFAULT_BLURB_TOKENS,
44
+ } = {}) {
45
+ const dirs = resolveDocsDirs(root, requestedDirs);
46
+ const files = [];
47
+ let totalSections = 0;
48
+ let totalTokens = 0;
49
+
50
+ for (const absPath of walkMarkdown(root, dirs)) {
51
+ const filePath = rel(root, absPath);
52
+ let text;
53
+ try {
54
+ text = fs.readFileSync(absPath, 'utf8');
55
+ } catch {
56
+ continue;
57
+ }
58
+ const sha256 = sha256File(absPath);
59
+ const sections = parseMarkdownSections(filePath, text).map((s) => {
60
+ const tokens_est = estimateTokens(s.content);
61
+ return {
62
+ heading: s.heading,
63
+ level: s.level,
64
+ startLine: s.startLine,
65
+ endLine: s.endLine,
66
+ tokens_est,
67
+ blurb: extractBlurb(s.content, blurbTokens),
68
+ };
69
+ });
70
+ const tokens_est = estimateTokens(text);
71
+ totalSections += sections.length;
72
+ totalTokens += tokens_est;
73
+ files.push({
74
+ path: filePath,
75
+ sha256,
76
+ tokens_est,
77
+ sections,
78
+ });
79
+ }
80
+
81
+ files.sort((a, b) => a.path.localeCompare(b.path));
82
+
83
+ return {
84
+ generated_at: new Date().toISOString(),
85
+ dirs: [...dirs],
86
+ files,
87
+ totals: {
88
+ files: files.length,
89
+ sections: totalSections,
90
+ tokens_est: totalTokens,
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Build index and optionally write to .claude/graph/docs-index.json.
97
+ * Returns { index, path, written }.
98
+ */
99
+ export function writeDocsIndex(root, options = {}) {
100
+ const { write = true, dirs, blurbTokens } = options;
101
+ const index = buildDocsIndex(root, { dirs, blurbTokens });
102
+ const outPath = docsIndexPath(root);
103
+ if (write) {
104
+ ensureGraphDir(root);
105
+ writeJson(outPath, index);
106
+ }
107
+ return { index, path: outPath, written: write };
108
+ }
109
+
110
+ export function formatDocsIndexSummary(index, outPath, written) {
111
+ const lines = [
112
+ written ? `Wrote docs index → ${outPath}` : `Docs index (not written)`,
113
+ `dirs=${index.dirs.join(',')} files=${index.totals.files} sections=${index.totals.sections} tokens_est=${index.totals.tokens_est}`,
114
+ ];
115
+ return `${lines.join('\n')}\n`;
116
+ }