kataster 0.4.0 → 0.6.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 (62) hide show
  1. package/.claude/THIRD_PARTY_NOTICES.md +4 -0
  2. package/.claude/agents/domain-architect.md +7 -3
  3. package/.claude/agents/researcher.md +77 -0
  4. package/.claude/hooks/validate-docs.ts +15 -0
  5. package/.claude/lib/architecture.ts +3 -33
  6. package/.claude/lib/backlog.ts +2 -0
  7. package/.claude/lib/config.ts +48 -0
  8. package/.claude/lib/discovery.ts +71 -1
  9. package/.claude/lib/engineering.ts +6 -0
  10. package/.claude/lib/gaps.ts +179 -0
  11. package/.claude/lib/glob.ts +39 -0
  12. package/.claude/lib/repo.ts +143 -19
  13. package/.claude/lib/research.ts +303 -0
  14. package/.claude/lib/slices.ts +529 -0
  15. package/.claude/settings.json +1 -0
  16. package/.claude/skills/adopt/SKILL.md +106 -31
  17. package/.claude/skills/adopt/scripts/gap-register.ts +61 -0
  18. package/.claude/skills/adopt/scripts/repo-map.ts +34 -2
  19. package/.claude/skills/adopt/scripts/slice-doc.ts +117 -0
  20. package/.claude/skills/adopt/scripts/slice.ts +163 -0
  21. package/.claude/skills/adopt/templates/context-map.as-is.md +22 -0
  22. package/.claude/skills/architect/SKILL.md +6 -1
  23. package/.claude/skills/domain-model/CONTEXT-FORMAT.md +1 -1
  24. package/.claude/skills/domain-model/SKILL.md +23 -3
  25. package/.claude/skills/event-storming/SKILL.md +3 -2
  26. package/.claude/skills/event-storming/schema/events.schema.json +2 -0
  27. package/.claude/skills/event-storming/templates/events.example.yaml +6 -0
  28. package/.claude/skills/feature/SKILL.md +8 -5
  29. package/.claude/skills/grilling/SKILL.md +15 -0
  30. package/.claude/skills/implement/SKILL.md +39 -3
  31. package/.claude/skills/implement/scripts/characterize.ts +252 -0
  32. package/.claude/skills/implement/scripts/story-context.ts +64 -0
  33. package/.claude/skills/implement/scripts/verify.ts +3 -0
  34. package/.claude/skills/improve-codebase-architecture/SKILL.md +4 -1
  35. package/.claude/skills/research/SKILL.md +149 -0
  36. package/.claude/skills/research/scripts/research-check.ts +105 -0
  37. package/.claude/skills/research/templates/research.md +29 -0
  38. package/.claude/skills/review-stories/SKILL.md +15 -5
  39. package/.claude/skills/shape/SKILL.md +24 -5
  40. package/.claude/skills/tdd/SKILL.md +10 -0
  41. package/.claude/skills/user-stories/SKILL.md +21 -2
  42. package/.claude/skills/user-stories/schema/story.schema.json +4 -0
  43. package/.claude/skills/user-stories/templates/story-template.md +1 -0
  44. package/.opencode/agents/domain-architect.md +7 -3
  45. package/.opencode/agents/researcher.md +91 -0
  46. package/.opencode/commands/adopt.md +1 -1
  47. package/.opencode/commands/feature.md +1 -1
  48. package/.opencode/commands/research.md +7 -0
  49. package/.opencode/commands/shape.md +1 -1
  50. package/AGENTS.md +14 -1
  51. package/CHANGELOG.md +49 -0
  52. package/README.md +78 -19
  53. package/guide/brownfield.md +41 -12
  54. package/guide/concepts.md +12 -1
  55. package/package.json +1 -1
  56. package/src/detect.mjs +9 -0
  57. package/src/install.mjs +4 -0
  58. package/templates/docs/domain/context-map.md +5 -1
  59. package/templates/docs/domain/events.yaml +3 -0
  60. package/templates/docs/research/README.md +70 -0
  61. package/templates/docs/stories/README.md +2 -0
  62. package/templates/docs/workflow.md +54 -13
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Concept slices for repositories that are not domain-driven: layer packages, feature stems and entry points.
3
+ *
4
+ * The repository map says where code lives. This module adds two readings that a layered codebase needs:
5
+ *
6
+ * - **role**: a package with a generic name (`services`, `repositories`, `utils`) that hosts many concepts is a
7
+ * layer, not a domain slice. It stays visible but is not proposed as a domain.
8
+ * - **slices**: clusters of files that share a concept stem or a table across layers
9
+ * (`api/ordersController.ts`, `services/orderService.ts`, `repositories/orderRepository.ts`).
10
+ *
11
+ * The resolver is evidence, not a verdict. It returns candidate files with reasons; `/adopt` confirms the scope
12
+ * with the user. No runtime dependency, no project knowledge.
13
+ */
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
+ import { basename, dirname, extname, join, relative } from "node:path";
16
+ import { globToRegExp } from "./glob.ts";
17
+ import type { RepoMap, RepoPackage } from "./repo.ts";
18
+
19
+ export type PackageRole = "domain" | "layer" | "shared" | "unknown";
20
+
21
+ export interface RoleAssignment {
22
+ role: PackageRole;
23
+ reasons: string[];
24
+ }
25
+
26
+ export interface SliceSuggestion {
27
+ name: string;
28
+ terms: string[];
29
+ tables: string[];
30
+ files: string[];
31
+ packages: string[];
32
+ layers: string[];
33
+ score: number;
34
+ reasons: string[];
35
+ adopted?: boolean;
36
+ adoptedAt?: string;
37
+ }
38
+
39
+ export interface ResolvedFile {
40
+ file: string;
41
+ package?: string;
42
+ layer: string;
43
+ reasons: string[];
44
+ }
45
+
46
+ export interface ResolvedSlice {
47
+ query: string;
48
+ seeds: ResolvedFile[];
49
+ files: ResolvedFile[];
50
+ byLayer: Record<string, string[]>;
51
+ warnings: string[];
52
+ }
53
+
54
+ export interface ResolveOptions {
55
+ paths?: string[];
56
+ route?: string;
57
+ table?: string;
58
+ event?: EventLookup;
59
+ cluster?: string;
60
+ term?: string;
61
+ maxFiles?: number;
62
+ depth?: number;
63
+ }
64
+
65
+ export interface EventLookup {
66
+ id: string;
67
+ terms: string[];
68
+ }
69
+
70
+ const IGNORED = new Set([
71
+ "node_modules", "dist", "build", "out", "coverage", ".next", ".nuxt", ".svelte-kit", ".turbo", ".nx",
72
+ "vendor", "target", ".git", ".claude", ".opencode", ".kataster", "docs", ".github", "__pycache__",
73
+ ".venv", "venv", "tmp", "temp",
74
+ ]);
75
+
76
+ const CODE_EXT = new Set([
77
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs", ".py", ".java", ".kt", ".cs", ".php", ".rb",
78
+ ".sql", ".prisma",
79
+ ]);
80
+ const JS_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
81
+ const MAX_FILES = 8000;
82
+
83
+ /** Words that name a layer, not a domain concept. They are stripped from file stems and package names. */
84
+ const LAYER_WORDS = new Set([
85
+ "api", "app", "application", "adapter", "adapters", "backend", "client", "clients", "common", "component",
86
+ "components", "config", "configuration", "controller", "controllers", "core", "dao", "database", "db", "dto",
87
+ "dtos", "entity", "entities", "frontend", "gateway", "handler", "handlers", "helper", "helpers", "hook", "hooks",
88
+ "infra", "infrastructure", "lib", "library", "mapper", "mappers", "middleware", "model", "models", "module",
89
+ "modules", "page", "pages", "persistence", "plugin", "plugins", "provider", "providers", "repository",
90
+ "repositories", "repository", "rest", "route", "routes", "router", "routers", "schema", "service", "services",
91
+ "shared", "store", "stores", "ui", "util", "utils", "utility", "utilities", "view", "views", "web", "worker",
92
+ "workers", "middlewares", "migration", "migrations", "seed", "seeds", "fixture", "fixtures",
93
+ ]);
94
+
95
+ /** Generic package names whose *own* name says nothing about the domain. */
96
+ const GENERIC_NAME_WORDS = new Set([
97
+ ...LAYER_WORDS, "acme", "business", "domain", "logic", "data", "access", "layer", "layers", "main", "server",
98
+ "client", "backend", "frontend", "web", "app", "apps", "lib", "libs", "package", "packages", "tooling",
99
+ ]);
100
+
101
+ const STOP_WORDS = new Set([
102
+ "index", "main", "test", "tests", "spec", "specs", "mock", "mocks", "stub", "stubs", "type", "types", "enum",
103
+ "enums", "const", "constants", "interface", "interfaces", "abstract", "base", "impl", "implementation", "default",
104
+ "new", "old", "get", "set", "create", "update", "delete", "list", "find", "read", "write", "load", "save",
105
+ "process", "handle", "run", "start", "stop", "build", "make", "check", "validate", "execute", "perform", "do",
106
+ "with", "from", "into", "for", "and", "the", "this", "that", "object", "array", "string", "number", "boolean",
107
+ "map",
108
+ ]);
109
+
110
+ const ROOT_SEGMENTS = new Set(["src", "source", "app", "apps", "lib", "libs", "packages", "package"]);
111
+
112
+ function readText(path: string): string | undefined {
113
+ try {
114
+ return readFileSync(path, "utf8");
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ function singular(word: string): string {
121
+ if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
122
+ if (word.endsWith("ses") && word.length > 4) return word.slice(0, -2);
123
+ if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
124
+ return word;
125
+ }
126
+
127
+ /** `orderService`, `order-service`, `OrderService` → ["order", "service"]. */
128
+ export function tokenize(value: string): string[] {
129
+ return value
130
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
131
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
132
+ .split(/[^A-Za-z0-9]+/)
133
+ .map((token) => token.toLowerCase())
134
+ .filter((token) => token.length >= 3 && !/^\d+$/.test(token) && !STOP_WORDS.has(token))
135
+ .map(singular);
136
+ }
137
+
138
+ export function conceptStems(path: string): string[] {
139
+ const name = basename(path, extname(path));
140
+ return [...new Set(tokenize(name).filter((token) => !LAYER_WORDS.has(token)))];
141
+ }
142
+
143
+ function walk(dir: string, out: string[], root: string): void {
144
+ if (out.length >= MAX_FILES) return;
145
+ let entries;
146
+ try {
147
+ entries = readdirSync(dir, { withFileTypes: true });
148
+ } catch {
149
+ return;
150
+ }
151
+ for (const entry of entries) {
152
+ if (out.length >= MAX_FILES) return;
153
+ if (entry.isDirectory()) {
154
+ if (IGNORED.has(entry.name) || entry.name.startsWith(".")) continue;
155
+ walk(join(dir, entry.name), out, root);
156
+ } else if (entry.isFile() && CODE_EXT.has(extname(entry.name))) {
157
+ out.push(relative(root, join(dir, entry.name)).split("\\").join("/"));
158
+ }
159
+ }
160
+ }
161
+
162
+ /** All source and schema files in the repository, repository-relative POSIX paths. */
163
+ export function sourceFiles(root: string): string[] {
164
+ const out: string[] = [];
165
+ walk(root, out, root);
166
+ return out.sort();
167
+ }
168
+
169
+ export function packageForFile(packages: RepoPackage[], file: string): RepoPackage | undefined {
170
+ return packages
171
+ .filter((pkg) => pkg.path === "." || file === pkg.path || file.startsWith(`${pkg.path}/`))
172
+ .sort((a, b) => b.path.length - a.path.length)[0];
173
+ }
174
+
175
+ /** The first meaningful directory of a file: `src/services/order.ts` → `services`. */
176
+ export function layerOf(file: string): string {
177
+ const parts = dirname(file).split("/").filter(Boolean);
178
+ const filtered = parts.filter((part) => !ROOT_SEGMENTS.has(part));
179
+ return (filtered.length ? filtered : parts)[0] ?? ".";
180
+ }
181
+
182
+ function packageNameTokens(pkg: RepoPackage): string[] {
183
+ const name = pkg.name.split("/").pop() ?? pkg.name;
184
+ return tokenize(name);
185
+ }
186
+
187
+ function filesByPackage(packages: RepoPackage[], files: string[]): Map<string, string[]> {
188
+ const out = new Map<string, string[]>();
189
+ for (const pkg of packages) out.set(pkg.path, []);
190
+ for (const file of files) {
191
+ const pkg = packageForFile(packages, file);
192
+ if (pkg) out.get(pkg.path)!.push(file);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * Marks packages that are layers or shared infrastructure rather than domain slices.
199
+ *
200
+ * The main signal is structural: a generic package name plus several concept stems, especially stems that also
201
+ * appear in other packages. The name list is only a hint, so a non-English layer name with the same shape is
202
+ * still detected.
203
+ */
204
+ export function classifyPackageRoles(
205
+ root: string,
206
+ packages: RepoPackage[],
207
+ files: string[] = sourceFiles(root),
208
+ ): Map<string, RoleAssignment> {
209
+ const byPackage = filesByPackage(packages, files);
210
+ const stemsByPackage = new Map<string, Set<string>>();
211
+ for (const pkg of packages) {
212
+ const stems = new Set<string>();
213
+ for (const file of byPackage.get(pkg.path) ?? []) for (const stem of conceptStems(file)) stems.add(stem);
214
+ stemsByPackage.set(pkg.path, stems);
215
+ }
216
+
217
+ const packagesByStem = new Map<string, Set<string>>();
218
+ for (const [path, stems] of stemsByPackage) {
219
+ for (const stem of stems) packagesByStem.set(stem, new Set([...(packagesByStem.get(stem) ?? []), path]));
220
+ }
221
+
222
+ const out = new Map<string, RoleAssignment>();
223
+ for (const pkg of packages) {
224
+ const stems = stemsByPackage.get(pkg.path) ?? new Set<string>();
225
+ const nameTokens = packageNameTokens(pkg);
226
+ const genericName = nameTokens.length > 0 && nameTokens.every((token) => GENERIC_NAME_WORDS.has(token));
227
+ const sharedWithOthers = [...stems].filter((stem) => (packagesByStem.get(stem)?.size ?? 0) > 1);
228
+
229
+ if (pkg.path !== "." && genericName && (sharedWithOthers.length >= 2 || stems.size >= 3)) {
230
+ out.set(pkg.path, {
231
+ role: "layer",
232
+ reasons: [
233
+ `generic package name (${pkg.name})`,
234
+ `${stems.size} concept stem${stems.size === 1 ? "" : "s"} in this package`,
235
+ ...(sharedWithOthers.length ? [`${sharedWithOthers.length} shared with other packages`] : []),
236
+ ],
237
+ });
238
+ continue;
239
+ }
240
+ if (pkg.kind === "library") {
241
+ out.set(pkg.path, { role: "shared", reasons: ["library/shared package"] });
242
+ continue;
243
+ }
244
+ out.set(pkg.path, {
245
+ role: "domain",
246
+ reasons: [pkg.kind === "app" || pkg.kind === "service" ? `${pkg.kind} package` : "no layer signal"],
247
+ });
248
+ }
249
+ return out;
250
+ }
251
+
252
+ function tableNames(files: string[]): Map<string, string> {
253
+ const out = new Map<string, string>();
254
+ for (const file of files) {
255
+ if (extname(file) !== ".sql") continue;
256
+ const text = readText(file);
257
+ if (!text) continue;
258
+ for (const match of text.matchAll(/\b(?:create|alter)\s+table\s+(?:if\s+not\s+exists\s+)?["'`]?([A-Za-z0-9_.]+)/gi)) {
259
+ const raw = match[1].split(".").pop() ?? match[1];
260
+ const stem = conceptStems(raw)[0];
261
+ if (stem) out.set(stem, raw);
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /** Clusters files by their primary concept stem and by shared table names. */
268
+ export function clusterSlices(
269
+ root: string,
270
+ packages: RepoPackage[],
271
+ files: string[] = sourceFiles(root),
272
+ ): SliceSuggestion[] {
273
+ const tables = tableNames(files);
274
+ const byStem = new Map<string, { files: Set<string>; terms: Set<string> }>();
275
+ const add = (stem: string, file: string, terms: string[]): void => {
276
+ if (!stem) return;
277
+ const entry = byStem.get(stem) ?? { files: new Set<string>(), terms: new Set<string>() };
278
+ entry.files.add(file);
279
+ for (const term of terms) entry.terms.add(term);
280
+ byStem.set(stem, entry);
281
+ };
282
+
283
+ for (const file of files) {
284
+ const stems = conceptStems(file);
285
+ if (!stems.length) continue;
286
+ add(stems[0], file, stems);
287
+ }
288
+
289
+ // Table evidence also pulls in files that mention the table but do not carry the stem in their file name.
290
+ for (const [stem, table] of tables) {
291
+ for (const file of files) {
292
+ if (!JS_EXT.has(extname(file)) && ![".go", ".rs", ".py", ".java", ".kt", ".cs", ".php", ".rb"].includes(extname(file))) continue;
293
+ const text = readText(file);
294
+ if (!text || !new RegExp(`\\b${table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)) continue;
295
+ add(stem, file, [stem, table]);
296
+ }
297
+ }
298
+
299
+ const out: SliceSuggestion[] = [];
300
+ for (const [stem, entry] of byStem) {
301
+ const clusterFiles = [...entry.files].sort();
302
+ const layers = [...new Set(clusterFiles.map(layerOf))].sort();
303
+ const clusterPackages = [...new Set(clusterFiles.map((file) => packageForFile(packages, file)?.path ?? "?"))].sort();
304
+ const clusterTables = tables.has(stem) ? [tables.get(stem)!] : [];
305
+ if (layers.length < 2 && !clusterTables.length) continue;
306
+ if (clusterFiles.length < 2 && !clusterTables.length) continue;
307
+
308
+ const reasons: string[] = [];
309
+ if (layers.length >= 2) reasons.push(`${layers.length} layers: ${layers.join(", ")}`);
310
+ if (clusterTables.length) reasons.push(`table ${clusterTables.join(", ")}`);
311
+ if (clusterPackages.length > 1) reasons.push(`${clusterPackages.length} packages`);
312
+ reasons.push(`${clusterFiles.length} file${clusterFiles.length === 1 ? "" : "s"}`);
313
+
314
+ out.push({
315
+ name: stem,
316
+ terms: [...entry.terms].sort(),
317
+ tables: clusterTables,
318
+ files: clusterFiles,
319
+ packages: clusterPackages,
320
+ layers,
321
+ score: layers.length * 3 + clusterTables.length * 2 + Math.min(clusterFiles.length, 6) + clusterPackages.length,
322
+ reasons,
323
+ });
324
+ }
325
+ return out.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, 20);
326
+ }
327
+
328
+ const IMPORT_RE =
329
+ /(?:import\s+(?:[^'"]*?\s+from\s+)?|export\s+[^'"]*?\s+from\s+|require\s*\(\s*|import\s*\(\s*)["']([^"']+)["']/g;
330
+
331
+ function resolveRelative(root: string, file: string, specifier: string): string | undefined {
332
+ if (!specifier.startsWith(".")) return undefined;
333
+ const base = join(root, dirname(file), specifier);
334
+ const candidates = [base];
335
+ for (const ext of JS_EXT) candidates.push(`${base}${ext}`);
336
+ for (const ext of JS_EXT) candidates.push(join(base, `index${ext}`));
337
+ for (const candidate of candidates) {
338
+ if (!existsSync(candidate)) continue;
339
+ const rel = relative(root, candidate).split("\\").join("/");
340
+ return rel;
341
+ }
342
+ return undefined;
343
+ }
344
+
345
+ function importGraph(root: string, files: string[]): { forward: Map<string, Set<string>>; reverse: Map<string, Set<string>> } {
346
+ const forward = new Map<string, Set<string>>();
347
+ const reverse = new Map<string, Set<string>>();
348
+ const present = new Set(files);
349
+ for (const file of files) {
350
+ if (!JS_EXT.has(extname(file))) continue;
351
+ const text = readText(join(root, file));
352
+ if (!text) continue;
353
+ for (const match of text.matchAll(IMPORT_RE)) {
354
+ const target = resolveRelative(root, file, match[1]);
355
+ if (!target || !present.has(target)) continue;
356
+ forward.set(file, new Set([...(forward.get(file) ?? []), target]));
357
+ reverse.set(target, new Set([...(reverse.get(target) ?? []), file]));
358
+ }
359
+ }
360
+ return { forward, reverse };
361
+ }
362
+
363
+ function addFile(
364
+ out: Map<string, ResolvedFile>,
365
+ file: string,
366
+ reason: string,
367
+ packages: RepoPackage[],
368
+ ): void {
369
+ const existing = out.get(file);
370
+ if (existing) {
371
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
372
+ return;
373
+ }
374
+ const pkg = packageForFile(packages, file);
375
+ out.set(file, { file, package: pkg?.path, layer: layerOf(file), reasons: [reason] });
376
+ }
377
+
378
+ /** Resolves a scope (glob, route, table, event, cluster or term) into candidate files across layers. */
379
+ export function resolveSlice(root: string, map: RepoMap, options: ResolveOptions): ResolvedSlice {
380
+ const files = sourceFiles(root);
381
+ const present = new Set(files);
382
+ const seeds = new Map<string, ResolvedFile>();
383
+ const warnings: string[] = [];
384
+ const maxFiles = Math.max(1, options.maxFiles ?? 200);
385
+ const depth = Math.max(0, options.depth ?? 2);
386
+
387
+ const addSeed = (file: string, reason: string): void => {
388
+ if (present.has(file)) addFile(seeds, file, reason, map.packages);
389
+ };
390
+
391
+ for (const pattern of options.paths ?? []) {
392
+ const re = globToRegExp(pattern);
393
+ const hits = files.filter((file) => re.test(file));
394
+ if (!hits.length) warnings.push(`no file matches --paths '${pattern}'`);
395
+ for (const file of hits) addSeed(file, `path ${pattern}`);
396
+ }
397
+
398
+ if (options.cluster) {
399
+ const cluster = map.slices?.find((slice) => slice.name === options.cluster);
400
+ if (!cluster) warnings.push(`cluster '${options.cluster}' is not in the repository map – run repo-map.ts or use --term`);
401
+ else for (const file of cluster.files) addSeed(file, `cluster ${cluster.name}`);
402
+ }
403
+
404
+ if (options.term) {
405
+ const term = options.term.toLowerCase();
406
+ const re = new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}s?\\b`, "i");
407
+ for (const file of files) {
408
+ const pathHit = conceptStems(file).some((stem) => stem === singular(term) || stem === term);
409
+ const text = pathHit ? "" : readText(join(root, file)) ?? "";
410
+ if (pathHit) addSeed(file, `term ${options.term}`);
411
+ else if (re.test(text)) addSeed(file, `mentions ${options.term}`);
412
+ }
413
+ }
414
+
415
+ if (options.table) {
416
+ const table = options.table;
417
+ const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
418
+ const re = new RegExp(`\\b${escaped}\\b`, "i");
419
+ let matched = 0;
420
+ for (const file of files) {
421
+ const text = readText(join(root, file));
422
+ if (text && re.test(text)) {
423
+ addSeed(file, `table ${table}`);
424
+ matched += 1;
425
+ }
426
+ }
427
+ if (!matched) warnings.push(`no file mentions table '${table}'`);
428
+ }
429
+
430
+ if (options.route) {
431
+ const routePath = options.route.replace(/^[A-Z]+\s+/, "").trim();
432
+ const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
433
+ const re = new RegExp(escaped, "i");
434
+ let matched = 0;
435
+ for (const file of files) {
436
+ const text = readText(join(root, file));
437
+ if (text && re.test(text)) {
438
+ addSeed(file, `route ${options.route}`);
439
+ matched += 1;
440
+ }
441
+ }
442
+ if (!matched) warnings.push(`no file contains route '${routePath}' – pass --paths or --term`);
443
+ }
444
+
445
+ if (options.event) {
446
+ const needles = [options.event.id, options.event.id.replace(/^(EVT|CMD|AGG)-/, ""), ...options.event.terms]
447
+ .filter(Boolean)
448
+ .map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
449
+ const re = new RegExp(`\\b(?:${needles.join("|")})\\b`, "i");
450
+ let matched = 0;
451
+ for (const file of files) {
452
+ const text = readText(join(root, file));
453
+ if (text && re.test(text)) {
454
+ addSeed(file, `event ${options.event.id}`);
455
+ matched += 1;
456
+ }
457
+ }
458
+ if (!matched) warnings.push(`no file mentions ${options.event.id} – pass --paths or --term`);
459
+ }
460
+
461
+ if (!seeds.size) {
462
+ return {
463
+ query: describeQuery(options),
464
+ seeds: [],
465
+ files: [],
466
+ byLayer: {},
467
+ warnings: [...warnings, "no seed file found – the resolver is evidence, not a verdict; widen the scope with --paths or --term"],
468
+ };
469
+ }
470
+
471
+ const graph = importGraph(root, files);
472
+ const resolved = new Map<string, ResolvedFile>(seeds);
473
+ let frontier = [...seeds.keys()];
474
+ for (let step = 0; step < depth && frontier.length; step += 1) {
475
+ const next: string[] = [];
476
+ for (const file of frontier) {
477
+ for (const target of graph.forward.get(file) ?? []) {
478
+ if (!resolved.has(target)) {
479
+ addFile(resolved, target, `imports ${file}`, map.packages);
480
+ next.push(target);
481
+ }
482
+ }
483
+ for (const source of graph.reverse.get(file) ?? []) {
484
+ if (!resolved.has(source)) {
485
+ addFile(resolved, source, `imported by ${file}`, map.packages);
486
+ next.push(source);
487
+ }
488
+ }
489
+ }
490
+ frontier = next;
491
+ }
492
+
493
+ // Same concept stem across layers, even when the layers do not import each other (the classic layered case).
494
+ const seedStems = new Set([...seeds.keys()].flatMap((file) => conceptStems(file)));
495
+ for (const file of files) {
496
+ if (resolved.has(file)) continue;
497
+ const stems = conceptStems(file);
498
+ if (stems.some((stem) => seedStems.has(stem))) addFile(resolved, file, `same concept (${stems.join(", ")})`, map.packages);
499
+ }
500
+
501
+ let result = [...resolved.values()].sort((a, b) => a.file.localeCompare(b.file));
502
+ if (result.length > maxFiles) {
503
+ warnings.push(`${result.length} candidate files – truncated to ${maxFiles}; narrow the scope`);
504
+ result = result.slice(0, maxFiles);
505
+ }
506
+
507
+ const byLayer: Record<string, string[]> = {};
508
+ for (const entry of result) (byLayer[entry.layer] ??= []).push(entry.file);
509
+ for (const key of Object.keys(byLayer)) byLayer[key].sort();
510
+
511
+ return {
512
+ query: describeQuery(options),
513
+ seeds: [...seeds.values()].sort((a, b) => a.file.localeCompare(b.file)),
514
+ files: result,
515
+ byLayer,
516
+ warnings,
517
+ };
518
+ }
519
+
520
+ function describeQuery(options: ResolveOptions): string {
521
+ const parts: string[] = [];
522
+ for (const path of options.paths ?? []) parts.push(`--paths ${path}`);
523
+ if (options.route) parts.push(`--route ${options.route}`);
524
+ if (options.table) parts.push(`--table ${options.table}`);
525
+ if (options.event) parts.push(`--event ${options.event.id}`);
526
+ if (options.cluster) parts.push(`--cluster ${options.cluster}`);
527
+ if (options.term) parts.push(`--term ${options.term}`);
528
+ return parts.join(" · ") || "(empty scope)";
529
+ }
@@ -6,6 +6,7 @@
6
6
  "Bash(node .claude/skills/event-storming/scripts/validate-events.ts)",
7
7
  "Bash(node .claude/skills/event-storming/scripts/render-event-storming.ts)",
8
8
  "Bash(node .claude/skills/user-stories/scripts/*)",
9
+ "Bash(node .claude/skills/research/scripts/*)",
9
10
  "Bash(node .claude/skills/implement/scripts/*)",
10
11
  "Bash(git status *)",
11
12
  "Bash(git diff *)",