kataster 0.0.0-stage → 0.2.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 (162) hide show
  1. package/.claude/agents/acceptance-tester.md +53 -0
  2. package/.claude/agents/code-reviewer.md +56 -0
  3. package/.claude/agents/domain-architect.md +40 -0
  4. package/.claude/agents/lead-dev.md +50 -0
  5. package/.claude/agents/product-owner.md +39 -0
  6. package/.claude/agents/repo-analyst.md +78 -0
  7. package/.claude/agents/requirements-engineer.md +44 -0
  8. package/.claude/agents/ux-designer.md +64 -0
  9. package/.claude/hooks/bash-guard.ts +92 -0
  10. package/.claude/hooks/code-check.ts +37 -0
  11. package/.claude/hooks/path-guard.ts +34 -0
  12. package/.claude/hooks/path-rules.ts +66 -0
  13. package/.claude/hooks/project-check.ts +108 -0
  14. package/.claude/hooks/protect-files.ts +55 -0
  15. package/.claude/hooks/session-start.ts +213 -0
  16. package/.claude/hooks/stop-gate.ts +56 -0
  17. package/.claude/hooks/validate-docs.ts +58 -0
  18. package/.claude/lib/architecture.ts +736 -0
  19. package/.claude/lib/backlog-github.ts +454 -0
  20. package/.claude/lib/backlog-jira.ts +347 -0
  21. package/.claude/lib/backlog-sync.ts +42 -0
  22. package/.claude/lib/backlog.ts +396 -0
  23. package/.claude/lib/bazel.ts +212 -0
  24. package/.claude/lib/config.ts +122 -0
  25. package/.claude/lib/discovery.ts +821 -0
  26. package/.claude/lib/engineering.ts +406 -0
  27. package/.claude/lib/nx.ts +129 -0
  28. package/.claude/lib/repo.ts +1194 -0
  29. package/.claude/lib/toolgraph.ts +74 -0
  30. package/.claude/lib/turbo.ts +166 -0
  31. package/.claude/package-lock.json +427 -0
  32. package/.claude/package.json +21 -0
  33. package/.claude/settings.json +82 -0
  34. package/.claude/skills/adopt/SKILL.md +283 -0
  35. package/.claude/skills/adopt/scripts/boundary-check.ts +156 -0
  36. package/.claude/skills/adopt/scripts/package-doc.ts +71 -0
  37. package/.claude/skills/adopt/scripts/repo-map.ts +123 -0
  38. package/.claude/skills/adopt/scripts/rule-doc.ts +80 -0
  39. package/.claude/skills/architect/ADR-FORMAT.md +97 -0
  40. package/.claude/skills/architect/SKILL.md +64 -0
  41. package/.claude/skills/architect/TECH-STACK.md +21 -0
  42. package/.claude/skills/backlog/SKILL.md +198 -0
  43. package/.claude/skills/backlog/scripts/backlog-pull.ts +45 -0
  44. package/.claude/skills/backlog/scripts/backlog-push.ts +38 -0
  45. package/.claude/skills/backlog/scripts/backlog-status.ts +32 -0
  46. package/.claude/skills/backlog/scripts/jira-import.ts +40 -0
  47. package/.claude/skills/backlog/scripts/jira-plan.ts +26 -0
  48. package/.claude/skills/backlog/scripts/jira-record.ts +41 -0
  49. package/.claude/skills/bootstrap/SKILL.md +119 -0
  50. package/.claude/skills/codebase-design/DEEPENING.md +37 -0
  51. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  52. package/.claude/skills/codebase-design/SKILL.md +114 -0
  53. package/.claude/skills/domain-model/CONTEXT-FORMAT.md +47 -0
  54. package/.claude/skills/domain-model/SKILL.md +64 -0
  55. package/.claude/skills/domain-model/templates/data-model.md +29 -0
  56. package/.claude/skills/engineering-conventions/SKILL.md +31 -0
  57. package/.claude/skills/event-storming/SKILL.md +44 -0
  58. package/.claude/skills/event-storming/schema/events.schema.json +124 -0
  59. package/.claude/skills/event-storming/scripts/render-event-storming.ts +11 -0
  60. package/.claude/skills/event-storming/scripts/validate-events.ts +11 -0
  61. package/.claude/skills/event-storming/templates/events.example.yaml +91 -0
  62. package/.claude/skills/feature/SKILL.md +86 -0
  63. package/.claude/skills/feature/scripts/intake.ts +356 -0
  64. package/.claude/skills/grill-me/SKILL.md +7 -0
  65. package/.claude/skills/grill-with-docs/SKILL.md +7 -0
  66. package/.claude/skills/grilling/SKILL.md +30 -0
  67. package/.claude/skills/groom-backlog/SKILL.md +93 -0
  68. package/.claude/skills/groom-backlog/scripts/backlog-health.ts +238 -0
  69. package/.claude/skills/implement/SKILL.md +129 -0
  70. package/.claude/skills/implement/scripts/affected.ts +245 -0
  71. package/.claude/skills/implement/scripts/check-commands.ts +37 -0
  72. package/.claude/skills/implement/scripts/check-language.ts +15 -0
  73. package/.claude/skills/implement/scripts/check-scenarios.ts +59 -0
  74. package/.claude/skills/implement/scripts/next-story.ts +23 -0
  75. package/.claude/skills/implement/scripts/story-context.ts +135 -0
  76. package/.claude/skills/implement/scripts/story-status.ts +47 -0
  77. package/.claude/skills/implement/scripts/verify.ts +92 -0
  78. package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  79. package/.claude/skills/improve-codebase-architecture/SKILL.md +71 -0
  80. package/.claude/skills/retro/SKILL.md +53 -0
  81. package/.claude/skills/review-stories/SKILL.md +31 -0
  82. package/.claude/skills/shape/SKILL.md +193 -0
  83. package/.claude/skills/tdd/SKILL.md +38 -0
  84. package/.claude/skills/tdd/mocking.md +59 -0
  85. package/.claude/skills/tdd/tests.md +77 -0
  86. package/.claude/skills/user-stories/SKILL.md +138 -0
  87. package/.claude/skills/user-stories/schema/story.schema.json +39 -0
  88. package/.claude/skills/user-stories/scripts/render-backlog.ts +4 -0
  89. package/.claude/skills/user-stories/scripts/validate-stories.ts +13 -0
  90. package/.claude/skills/user-stories/templates/story-template.md +38 -0
  91. package/.claude/skills/ux-design/SKILL.md +107 -0
  92. package/.claude/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
  93. package/.claude/skills/writing-for-agents/SKILL.md +81 -0
  94. package/.claude/tsconfig.json +15 -0
  95. package/.opencode/agents/acceptance-tester.md +64 -0
  96. package/.opencode/agents/code-reviewer.md +69 -0
  97. package/.opencode/agents/domain-architect.md +59 -0
  98. package/.opencode/agents/lead-dev.md +67 -0
  99. package/.opencode/agents/product-owner.md +59 -0
  100. package/.opencode/agents/repo-analyst.md +86 -0
  101. package/.opencode/agents/requirements-engineer.md +58 -0
  102. package/.opencode/agents/ux-designer.md +83 -0
  103. package/.opencode/commands/adopt.md +7 -0
  104. package/.opencode/commands/backlog-sync.md +7 -0
  105. package/.opencode/commands/bootstrap.md +7 -0
  106. package/.opencode/commands/event-storming.md +7 -0
  107. package/.opencode/commands/feature.md +7 -0
  108. package/.opencode/commands/grill-me.md +7 -0
  109. package/.opencode/commands/grill-with-docs.md +7 -0
  110. package/.opencode/commands/groom-backlog.md +7 -0
  111. package/.opencode/commands/implement.md +7 -0
  112. package/.opencode/commands/improve-codebase-architecture.md +7 -0
  113. package/.opencode/commands/retro.md +7 -0
  114. package/.opencode/commands/review-stories.md +7 -0
  115. package/.opencode/commands/shape.md +7 -0
  116. package/.opencode/opencode.jsonc +15 -0
  117. package/.opencode/plugins/kataster/index.ts +283 -0
  118. package/AGENTS.md +21 -0
  119. package/CHANGELOG.md +82 -0
  120. package/LICENSE +21 -0
  121. package/README.md +324 -2
  122. package/bin/kataster.mjs +7 -0
  123. package/extras/skills/shadcn/SKILL.md +277 -0
  124. package/extras/skills/shadcn/agents/openai.yml +5 -0
  125. package/extras/skills/shadcn/assets/shadcn-small.png +0 -0
  126. package/extras/skills/shadcn/assets/shadcn.png +0 -0
  127. package/extras/skills/shadcn/cli.md +290 -0
  128. package/extras/skills/shadcn/customization.md +209 -0
  129. package/extras/skills/shadcn/evals/evals.json +77 -0
  130. package/extras/skills/shadcn/mcp.md +105 -0
  131. package/extras/skills/shadcn/registry.md +277 -0
  132. package/extras/skills/shadcn/rules/base-vs-radix.md +306 -0
  133. package/extras/skills/shadcn/rules/chat.md +250 -0
  134. package/extras/skills/shadcn/rules/composition.md +213 -0
  135. package/extras/skills/shadcn/rules/forms.md +192 -0
  136. package/extras/skills/shadcn/rules/icons.md +101 -0
  137. package/extras/skills/shadcn/rules/styling.md +185 -0
  138. package/guide/README.md +9 -0
  139. package/guide/brownfield.md +242 -0
  140. package/guide/concepts.md +109 -0
  141. package/guide/greenfield.md +84 -0
  142. package/guide/install.md +144 -0
  143. package/guide/release.md +62 -0
  144. package/package.json +56 -4
  145. package/src/cli.mjs +302 -0
  146. package/src/detect.mjs +237 -0
  147. package/src/install.mjs +427 -0
  148. package/src/repo.mjs +129 -0
  149. package/src/util.mjs +110 -0
  150. package/templates/CONTEXT.md +21 -0
  151. package/templates/conventions.md +63 -0
  152. package/templates/docs/adr/README.md +7 -0
  153. package/templates/docs/architecture/data-model.md +31 -0
  154. package/templates/docs/domain/context-map.md +21 -0
  155. package/templates/docs/domain/events.yaml +23 -0
  156. package/templates/docs/product/initiatives/README.md +41 -0
  157. package/templates/docs/product/ux-guidelines.md +27 -0
  158. package/templates/docs/product/vision.md +36 -0
  159. package/templates/docs/reviews/.gitkeep +0 -0
  160. package/templates/docs/stories/OPEN_QUESTIONS.md +8 -0
  161. package/templates/docs/stories/README.md +26 -0
  162. package/templates/docs/workflow.md +135 -0
@@ -0,0 +1,1194 @@
1
+ /**
2
+ * Deterministic repository map for monorepos and single-package repositories.
3
+ *
4
+ * Used by the `/adopt` scripts and (later) by the engineering hooks:
5
+ * - detect the build tooling (Nx, Turborepo, Bazel, pnpm/npm/yarn workspaces, Go, Cargo, Gradle, Maven)
6
+ * - list packages with their manifests, entry points, internal dependencies and consumers
7
+ * - rank adoption slices by churn, fan-in and kind
8
+ * - render `docs/architecture/repo-map.{json,md}`, the generated root block in `AGENTS.md`,
9
+ * and the per-package `AGENTS.md` / `CLAUDE.md` pair
10
+ *
11
+ * Rules: Node >= 22.18, erasable TypeScript only, no runtime dependency (JSON, not YAML), and no
12
+ * project knowledge – everything comes from the repository.
13
+ *
14
+ * Keep the tool marker list in sync with `src/repo.mjs` (the installer uses the same detection before
15
+ * `.claude/node_modules` exists).
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
19
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
+ import { graphLabel, graphOrigin, graphToolLabel, queryToolGraph, type ToolGraph } from "./toolgraph.ts";
21
+
22
+ export const ROOT = resolve(import.meta.dirname, "..", "..");
23
+ export const REPO_MAP_JSON = join("docs", "architecture", "repo-map.json");
24
+ export const REPO_MAP_MD = join("docs", "architecture", "repo-map.md");
25
+ export const REPO_BEGIN = "<!-- kataster:repo:begin -->";
26
+ export const REPO_END = "<!-- kataster:repo:end -->";
27
+ export const PACKAGE_BEGIN = "<!-- kataster:package:begin -->";
28
+ export const PACKAGE_END = "<!-- kataster:package:end -->";
29
+
30
+ export type PackageKind = "app" | "service" | "library" | "package";
31
+
32
+ export interface Tooling {
33
+ kind: string;
34
+ config: string;
35
+ workspaces: string[];
36
+ manager: string;
37
+ monorepo: boolean;
38
+ }
39
+
40
+ export interface RepoPackage {
41
+ name: string;
42
+ path: string;
43
+ kind: PackageKind;
44
+ language: string;
45
+ manifest: string;
46
+ scripts: Record<string, string>;
47
+ entryPoints: string[];
48
+ internalDeps: string[];
49
+ externalDeps: number;
50
+ consumers: string[];
51
+ hasTests: boolean;
52
+ adopted: boolean;
53
+ adoptedAt?: string;
54
+ /** Nx project name when it differs from the package name. */
55
+ projectName?: string;
56
+ }
57
+
58
+ export interface RankedPackage {
59
+ path: string;
60
+ score: number;
61
+ reasons: string[];
62
+ }
63
+
64
+ export interface RepoMap {
65
+ version: 1;
66
+ tooling: Tooling;
67
+ packages: RepoPackage[];
68
+ ranking: RankedPackage[];
69
+ /** Optional enrichment from the build tool; the manifest graph stays the source of truth. */
70
+ toolGraph?: ToolGraph;
71
+ }
72
+
73
+ export interface SliceProposal {
74
+ domain?: RepoPackage;
75
+ library?: RepoPackage;
76
+ }
77
+
78
+ type Json = Record<string, unknown>;
79
+
80
+ const IGNORED = new Set([
81
+ "node_modules", "dist", "build", "out", "coverage", ".next", ".nuxt", ".svelte-kit", ".turbo", ".nx",
82
+ "vendor", "target", "bin", "obj", ".git", ".claude", ".opencode", ".kataster", "__pycache__", ".venv",
83
+ "venv", ".tox", "tmp", "temp",
84
+ ]);
85
+
86
+ const MANIFESTS = [
87
+ "package.json", "go.mod", "Cargo.toml", "pyproject.toml", "setup.py", "pom.xml",
88
+ "build.gradle", "build.gradle.kts", "composer.json", "Gemfile",
89
+ ] as const;
90
+
91
+ const MANIFEST_RANK = new Map<string, number>([
92
+ ["package.json", 0], ["go.mod", 1], ["Cargo.toml", 2], ["pyproject.toml", 3], ["setup.py", 4],
93
+ ["pom.xml", 5], ["build.gradle", 6], ["build.gradle.kts", 6], ["composer.json", 7], ["Gemfile", 8],
94
+ ]);
95
+
96
+ const TOOL_MARKERS: { kind: string; config: string[]; require?: (text: string) => boolean }[] = [
97
+ { kind: "bazel", config: ["MODULE.bazel", "WORKSPACE.bazel", "WORKSPACE"] },
98
+ { kind: "nx", config: ["nx.json"] },
99
+ { kind: "turbo", config: ["turbo.json"] },
100
+ { kind: "rush", config: ["rush.json"] },
101
+ { kind: "lerna", config: ["lerna.json"] },
102
+ { kind: "pnpm", config: ["pnpm-workspace.yaml"] },
103
+ { kind: "go-work", config: ["go.work"] },
104
+ { kind: "cargo-workspace", config: ["Cargo.toml"], require: (text) => /^\s*\[workspace\]/m.test(text) },
105
+ { kind: "gradle", config: ["settings.gradle.kts", "settings.gradle"], require: (text) => /\binclude\s*[(:]/.test(text) },
106
+ { kind: "maven", config: ["pom.xml"], require: (text) => /<modules>/.test(text) },
107
+ ];
108
+
109
+ const APP_DIRS = new Set(["apps", "app", "frontend", "web", "ui", "clients", "site"]);
110
+ const SERVICE_DIRS = new Set(["services", "service", "backend", "api", "server", "workers", "jobs"]);
111
+ const LIBRARY_DIRS = new Set(["libs", "lib", "packages", "shared", "common", "core", "components", "contracts"]);
112
+ const FRAMEWORK_HINTS = ["express", "fastify", "koa", "hono", "@nestjs/core", "next", "nuxt", "remix", "svelte"];
113
+
114
+ const list = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
115
+ const isObject = (v: unknown): v is Json => v !== null && typeof v === "object" && !Array.isArray(v);
116
+
117
+ function readText(path: string): string | undefined {
118
+ try {
119
+ return readFileSync(path, "utf8");
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function readJson(path: string): Json | undefined {
126
+ try {
127
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
128
+ return isObject(value) ? value : undefined;
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ export function toPosix(path: string): string {
135
+ return path.split(sep).join("/");
136
+ }
137
+
138
+ export function projectPath(root: string, path: string): string {
139
+ return toPosix(relative(resolve(root), resolve(path))) || ".";
140
+ }
141
+
142
+ function fromRoot(root: string, path: string): string {
143
+ return path === "." ? root : join(root, path);
144
+ }
145
+
146
+ // --------------------------------------------------------------------------
147
+ // Detection
148
+ // --------------------------------------------------------------------------
149
+ export function detectTooling(root: string): Tooling {
150
+ let tool = { kind: "none", config: "" };
151
+ for (const candidate of TOOL_MARKERS) {
152
+ let found = false;
153
+ for (const name of candidate.config) {
154
+ const path = join(root, name);
155
+ if (!existsSync(path)) continue;
156
+ if (candidate.require) {
157
+ const text = readText(path);
158
+ if (text === undefined || !candidate.require(text)) continue;
159
+ }
160
+ tool = { kind: candidate.kind, config: name };
161
+ found = true;
162
+ break;
163
+ }
164
+ if (found) break;
165
+ }
166
+
167
+ const workspaces = workspaceGlobs(root);
168
+ const pkg = readJson(join(root, "package.json"));
169
+ const declared = Array.isArray(pkg?.workspaces)
170
+ || (isObject(pkg?.workspaces) && Array.isArray(pkg.workspaces.packages))
171
+ || workspaces.length > 0;
172
+ const kind = tool.kind !== "none" ? tool.kind : declared ? "workspaces" : "none";
173
+ const declaredManager = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined;
174
+ const manager = detectManager(root)
175
+ ?? (declaredManager && ["npm", "pnpm", "yarn", "bun"].includes(declaredManager) ? declaredManager : undefined)
176
+ ?? (tool.kind === "pnpm" || existsSync(join(root, "pnpm-workspace.yaml")) ? "pnpm" : undefined)
177
+ ?? (pkg ? "npm" : "");
178
+
179
+ return {
180
+ kind,
181
+ config: tool.config,
182
+ workspaces,
183
+ manager,
184
+ monorepo: kind !== "none",
185
+ };
186
+ }
187
+
188
+ function detectManager(root: string): string | undefined {
189
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
190
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
191
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun";
192
+ if (existsSync(join(root, "package-lock.json"))) return "npm";
193
+ return undefined;
194
+ }
195
+
196
+ export function workspaceGlobs(root: string): string[] {
197
+ const out: string[] = [];
198
+ const pkg = readJson(join(root, "package.json"));
199
+ const declared = Array.isArray(pkg?.workspaces) ? pkg.workspaces : isObject(pkg?.workspaces) ? pkg.workspaces.packages : undefined;
200
+ out.push(...list(declared));
201
+
202
+ const pnpm = readText(join(root, "pnpm-workspace.yaml"));
203
+ if (pnpm !== undefined) {
204
+ let inPackages = false;
205
+ for (const raw of pnpm.split("\n")) {
206
+ const line = raw.replace(/#.*$/, "");
207
+ if (/^packages\s*:/.test(line)) {
208
+ inPackages = true;
209
+ continue;
210
+ }
211
+ if (inPackages && /^\S/.test(line) && line.trim()) break;
212
+ const match = /^\s*-\s*['"]?([^'"]+?)['"]?\s*$/.exec(line);
213
+ if (inPackages && match) out.push(match[1]);
214
+ }
215
+ }
216
+
217
+ const lerna = readJson(join(root, "lerna.json"));
218
+ out.push(...list(lerna?.packages));
219
+
220
+ const rush = readJson(join(root, "rush.json"));
221
+ if (Array.isArray(rush?.projects)) {
222
+ for (const project of rush.projects) {
223
+ if (isObject(project) && typeof project.projectFolder === "string") out.push(project.projectFolder);
224
+ }
225
+ }
226
+ return [...new Set(out)];
227
+ }
228
+
229
+ // --------------------------------------------------------------------------
230
+ // Scan
231
+ // --------------------------------------------------------------------------
232
+ interface ManifestFile {
233
+ dir: string;
234
+ name: string;
235
+ path: string;
236
+ }
237
+
238
+ function findManifests(root: string): ManifestFile[] {
239
+ const found: ManifestFile[] = [];
240
+ const walk = (dir: string): void => {
241
+ let entries;
242
+ try {
243
+ entries = readdirSync(dir, { withFileTypes: true });
244
+ } catch {
245
+ return;
246
+ }
247
+ for (const entry of entries) {
248
+ if (entry.isDirectory()) {
249
+ if (IGNORED.has(entry.name) || entry.name.startsWith(".")) continue;
250
+ walk(join(dir, entry.name));
251
+ } else if (entry.isFile() && (MANIFESTS as readonly string[]).includes(entry.name)) {
252
+ found.push({ dir, name: entry.name, path: join(dir, entry.name) });
253
+ }
254
+ }
255
+ };
256
+ walk(root);
257
+
258
+ // One package per directory: the strongest manifest wins (package.json before go.mod before …).
259
+ const byDir = new Map<string, ManifestFile>();
260
+ for (const file of found) {
261
+ const current = byDir.get(file.dir);
262
+ if (!current || (MANIFEST_RANK.get(file.name) ?? 99) < (MANIFEST_RANK.get(current.name) ?? 99)) {
263
+ byDir.set(file.dir, file);
264
+ }
265
+ }
266
+ return [...byDir.values()];
267
+ }
268
+
269
+ function packageKind(path: string, scripts: Record<string, string>, deps: Record<string, string>, monorepo: boolean, entryPoints: string[] = []): PackageKind {
270
+ const top = path.split("/")[0];
271
+ if (APP_DIRS.has(top)) return "app";
272
+ if (SERVICE_DIRS.has(top)) return "service";
273
+ if (LIBRARY_DIRS.has(top)) return "library";
274
+ if (path === ".") return monorepo ? "package" : "app";
275
+ if (["start", "serve", "dev"].some((s) => s in scripts)) return "app";
276
+ if (Object.keys(deps).some((d) => FRAMEWORK_HINTS.some((f) => d === f || d.startsWith(`${f}/`)))) return "service";
277
+ // Outside the conventional apps/ services/ libs/ folders, the entry point decides: a binary is an app,
278
+ // a library root is a library.
279
+ if (entryPoints.some((p) => /(^|\/)(main\.(rs|go|py)|cmd)$/.test(p))) return "app";
280
+ if (entryPoints.some((p) => /(^|\/)(lib\.rs|__init__\.py)$/.test(p))) return "library";
281
+ return "package";
282
+ }
283
+
284
+ function languageOf(dir: string, manifest: string): string {
285
+ if (manifest === "package.json") {
286
+ if (existsSync(join(dir, "tsconfig.json")) || hasExtension(dir, [".ts", ".tsx"])) return "TypeScript";
287
+ return "JavaScript";
288
+ }
289
+ if (manifest === "go.mod") return "Go";
290
+ if (manifest === "Cargo.toml") return "Rust";
291
+ if (manifest === "pyproject.toml" || manifest === "setup.py") return "Python";
292
+ if (manifest === "pom.xml") return "Java";
293
+ if (manifest === "build.gradle" || manifest === "build.gradle.kts") {
294
+ return hasExtension(dir, [".kt", ".kts"]) ? "Kotlin" : "Java";
295
+ }
296
+ if (manifest === "composer.json") return "PHP";
297
+ if (manifest === "Gemfile") return "Ruby";
298
+ return "unknown";
299
+ }
300
+
301
+ /** Cheap check for a file extension in the package's own tree (bounded, ignores build dirs). */
302
+ function hasExtension(dir: string, extensions: string[], limit = 200): boolean {
303
+ let seen = 0;
304
+ const walk = (current: string): boolean => {
305
+ let entries;
306
+ try {
307
+ entries = readdirSync(current, { withFileTypes: true });
308
+ } catch {
309
+ return false;
310
+ }
311
+ for (const entry of entries) {
312
+ if (seen >= limit) return false;
313
+ if (entry.isDirectory()) {
314
+ if (IGNORED.has(entry.name) || entry.name.startsWith(".")) continue;
315
+ if (walk(join(current, entry.name))) return true;
316
+ } else if (entry.isFile()) {
317
+ seen += 1;
318
+ if (extensions.some((ext) => entry.name.endsWith(ext))) return true;
319
+ }
320
+ }
321
+ return false;
322
+ };
323
+ return walk(dir);
324
+ }
325
+
326
+ function entryPointsOf(dir: string, manifest: string, pkg: Json | undefined): string[] {
327
+ const out: string[] = [];
328
+ const add = (value: unknown): void => {
329
+ if (typeof value === "string" && value && !value.startsWith("/") && !value.includes("://") && !value.startsWith("node:")) {
330
+ out.push(value.replace(/^\.\//, ""));
331
+ }
332
+ };
333
+ if (manifest === "package.json" && pkg) {
334
+ for (const key of ["main", "module", "types", "bin"]) {
335
+ const value = pkg[key];
336
+ if (isObject(value)) Object.values(value).forEach(add);
337
+ else add(value);
338
+ }
339
+ if (isObject(pkg.exports)) {
340
+ for (const value of Object.values(pkg.exports)) {
341
+ if (isObject(value)) Object.values(value).forEach(add);
342
+ else add(value);
343
+ }
344
+ }
345
+ for (const candidate of ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/server.ts", "src/app.ts", "index.ts", "index.js", "src/index.py", "src/main.py"]) {
346
+ if (existsSync(join(dir, candidate))) out.push(candidate);
347
+ }
348
+ } else if (manifest === "go.mod") {
349
+ for (const candidate of ["main.go", "cmd"]) if (existsSync(join(dir, candidate))) out.push(candidate);
350
+ } else if (manifest === "Cargo.toml") {
351
+ for (const candidate of ["src/main.rs", "src/lib.rs"]) if (existsSync(join(dir, candidate))) out.push(candidate);
352
+ } else if (manifest === "pyproject.toml" || manifest === "setup.py") {
353
+ for (const candidate of ["src", "main.py", "__init__.py"]) if (existsSync(join(dir, candidate))) out.push(candidate);
354
+ }
355
+ return [...new Set(out)].slice(0, 6);
356
+ }
357
+
358
+ interface ParsedManifest {
359
+ name: string;
360
+ scripts: Record<string, string>;
361
+ deps: Record<string, string>;
362
+ externalDeps: number;
363
+ }
364
+
365
+ function parseManifest(file: ManifestFile): ParsedManifest {
366
+ const text = readText(file.path) ?? "";
367
+ if (file.name === "package.json") {
368
+ const pkg = readJson(file.path);
369
+ const scripts: Record<string, string> = {};
370
+ for (const [key, value] of Object.entries(isObject(pkg?.scripts) ? pkg.scripts : {})) {
371
+ if (typeof value === "string") scripts[key] = value;
372
+ }
373
+ const deps: Record<string, string> = {};
374
+ for (const key of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
375
+ const block = pkg?.[key];
376
+ if (!isObject(block)) continue;
377
+ for (const [name, version] of Object.entries(block)) {
378
+ if (typeof version === "string") deps[name] = version;
379
+ }
380
+ }
381
+ return {
382
+ name: typeof pkg?.name === "string" ? pkg.name : basename(file.dir),
383
+ scripts,
384
+ deps,
385
+ externalDeps: Object.keys(deps).length,
386
+ };
387
+ }
388
+
389
+ if (file.name === "go.mod") {
390
+ const module = /^module\s+(\S+)/m.exec(text)?.[1] ?? basename(file.dir);
391
+ const deps: Record<string, string> = {};
392
+ for (const match of text.matchAll(/^\s*(?:require\s+)?([\w./-]+)\s+v[\w.-]+/gm)) deps[match[1]] = "*";
393
+ return { name: module, scripts: {}, deps, externalDeps: Object.keys(deps).length };
394
+ }
395
+
396
+ if (file.name === "Cargo.toml") {
397
+ const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? basename(file.dir);
398
+ const deps: Record<string, string> = {};
399
+ // Only dependency sections; `name = "…"` in [package] is not a dependency.
400
+ let section = "";
401
+ for (const line of text.split("\n")) {
402
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
403
+ if (header) {
404
+ section = header[1];
405
+ continue;
406
+ }
407
+ if (!/^(?:workspace\.)?(?:dev-|build-)?dependencies$/.test(section)) continue;
408
+ const match = /^\s*([A-Za-z0-9_-]+)\s*=\s*(?:\{[^}]*\}|"[^"]+")/.exec(line);
409
+ if (match) deps[match[1]] = "*";
410
+ }
411
+ return { name, scripts: {}, deps, externalDeps: Object.keys(deps).length };
412
+ }
413
+
414
+ if (file.name === "pyproject.toml") {
415
+ const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? basename(file.dir);
416
+ const deps: Record<string, string> = {};
417
+ const block = /^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m.exec(text)?.[1] ?? "";
418
+ for (const match of block.matchAll(/"([^"]+)"/g)) {
419
+ const dep = match[1].split(/[<>=!~@;\[ ]/)[0].trim();
420
+ if (dep) deps[dep] = "*";
421
+ }
422
+ return { name, scripts: {}, deps, externalDeps: Object.keys(deps).length };
423
+ }
424
+
425
+ if (file.name === "setup.py") {
426
+ return { name: basename(file.dir), scripts: {}, deps: {}, externalDeps: 0 };
427
+ }
428
+
429
+ if (file.name === "pom.xml") {
430
+ const name = /<artifactId>([^<]+)<\/artifactId>/.exec(text)?.[1] ?? basename(file.dir);
431
+ return { name, scripts: {}, deps: {}, externalDeps: 0 };
432
+ }
433
+
434
+ if (file.name === "build.gradle" || file.name === "build.gradle.kts") {
435
+ const name = /rootProject\.name\s*=\s*["']([^"']+)["']/.exec(text)?.[1] ?? basename(file.dir);
436
+ return { name, scripts: {}, deps: {}, externalDeps: 0 };
437
+ }
438
+
439
+ if (file.name === "composer.json") {
440
+ const pkg = readJson(file.path);
441
+ const deps: Record<string, string> = {};
442
+ for (const key of ["require", "require-dev"]) {
443
+ const block = pkg?.[key];
444
+ if (!isObject(block)) continue;
445
+ for (const [dep, version] of Object.entries(block)) if (typeof version === "string") deps[dep] = version;
446
+ }
447
+ return { name: typeof pkg?.name === "string" ? pkg.name : basename(file.dir), scripts: {}, deps, externalDeps: Object.keys(deps).length };
448
+ }
449
+
450
+ return { name: basename(file.dir), scripts: {}, deps: {}, externalDeps: 0 };
451
+ }
452
+
453
+ function hasTests(dir: string, scripts: Record<string, string>): boolean {
454
+ if (typeof scripts.test === "string" && scripts.test.trim()) return true;
455
+ return ["test", "tests", "__tests__", "e2e", "spec"].some((name) => existsSync(join(dir, name)));
456
+ }
457
+
458
+ function languageForDir(dir: string): string {
459
+ if (existsSync(join(dir, "tsconfig.json")) || hasExtension(dir, [".ts", ".tsx"])) return "TypeScript";
460
+ if (hasExtension(dir, [".js", ".jsx", ".mjs", ".cjs"])) return "JavaScript";
461
+ if (hasExtension(dir, [".go"])) return "Go";
462
+ if (hasExtension(dir, [".rs"])) return "Rust";
463
+ if (hasExtension(dir, [".py"])) return "Python";
464
+ if (hasExtension(dir, [".kt", ".kts"])) return "Kotlin";
465
+ if (hasExtension(dir, [".java"])) return "Java";
466
+ return "unknown";
467
+ }
468
+
469
+ /** Bazel packages are directories with a BUILD file, not directories with a manifest. */
470
+ function findBuildDirs(root: string, limit = 500): { dir: string; file: string }[] {
471
+ const out: { dir: string; file: string }[] = [];
472
+ const walk = (dir: string): void => {
473
+ if (out.length >= limit) return;
474
+ let entries;
475
+ try {
476
+ entries = readdirSync(dir, { withFileTypes: true });
477
+ } catch {
478
+ return;
479
+ }
480
+ for (const entry of entries) {
481
+ if (entry.isDirectory()) {
482
+ if (IGNORED.has(entry.name) || entry.name.startsWith(".")) continue;
483
+ walk(join(dir, entry.name));
484
+ } else if (entry.isFile() && (entry.name === "BUILD" || entry.name === "BUILD.bazel")) {
485
+ out.push({ dir, file: entry.name });
486
+ }
487
+ }
488
+ };
489
+ walk(root);
490
+ return out;
491
+ }
492
+
493
+ function readNxProjectName(dir: string): string | undefined {
494
+ for (const file of ["project.json", "project.jsonc"]) {
495
+ const data = readJson(join(dir, file));
496
+ if (typeof data?.name === "string") return data.name;
497
+ }
498
+ return undefined;
499
+ }
500
+
501
+ function resolveWorkspaceDep(name: string, version: string, packages: RepoPackage[]): string | undefined {
502
+ const byName = packages.find((p) => p.name === name);
503
+ if (byName) return byName.name;
504
+ if (version.startsWith("file:") || version.startsWith("link:") || version.startsWith("workspace:")) {
505
+ const target = version.replace(/^(file|link|workspace):/, "").replace(/^\.\//, "");
506
+ if (!target || target === "*") return undefined;
507
+ const wanted = toPosix(target).replace(/\/$/, "");
508
+ const byPath = packages.find((p) => p.path === wanted || p.path.endsWith(`/${wanted}`));
509
+ return byPath?.name;
510
+ }
511
+ return undefined;
512
+ }
513
+
514
+ export function scanRepo(root: string, options: { toolGraph?: boolean } = {}): RepoMap {
515
+ const tooling = detectTooling(root);
516
+ const manifests = findManifests(root);
517
+
518
+ const packages: RepoPackage[] = [];
519
+ for (const file of manifests) {
520
+ const path = projectPath(root, file.dir);
521
+ if (tooling.monorepo && path === ".") continue; // the workspace root is not a package
522
+ const parsed = parseManifest(file);
523
+ const entryPoints = entryPointsOf(file.dir, file.name, readJson(file.path));
524
+ const kind = packageKind(path, parsed.scripts, parsed.deps, tooling.monorepo, entryPoints);
525
+ packages.push({
526
+ name: parsed.name,
527
+ path,
528
+ kind,
529
+ language: languageOf(file.dir, file.name),
530
+ manifest: projectPath(root, file.path),
531
+ scripts: parsed.scripts,
532
+ entryPoints,
533
+ internalDeps: [],
534
+ externalDeps: parsed.externalDeps,
535
+ consumers: [],
536
+ hasTests: hasTests(file.dir, parsed.scripts),
537
+ adopted: false,
538
+ projectName: readNxProjectName(file.dir),
539
+ });
540
+ }
541
+
542
+ // Bazel's unit is the directory with a BUILD file. Add those that do not already have a manifest package,
543
+ // so a Bazel repository can be sliced even when it has no package.json.
544
+ if (tooling.kind === "bazel") {
545
+ const seen = new Set(packages.map((p) => p.path));
546
+ for (const build of findBuildDirs(root)) {
547
+ const path = projectPath(root, build.dir);
548
+ if (path === "." || seen.has(path)) continue;
549
+ seen.add(path);
550
+ packages.push({
551
+ name: path.split("/").pop() ?? path,
552
+ path,
553
+ kind: packageKind(path, {}, {}, true),
554
+ language: languageForDir(build.dir),
555
+ manifest: projectPath(root, join(build.dir, build.file)),
556
+ scripts: {},
557
+ entryPoints: [],
558
+ internalDeps: [],
559
+ externalDeps: 0,
560
+ consumers: [],
561
+ hasTests: false,
562
+ adopted: false,
563
+ });
564
+ }
565
+ }
566
+
567
+ // A repository without manifests is still a repository: fall back to the code roots from the profile.
568
+ if (!packages.length) {
569
+ const profile = readProfile(root);
570
+ const roots = list(profile?.repo && isObject(profile.repo) ? profile.repo.codeRoots : []);
571
+ const language = list(profile?.stack && isObject(profile.stack) ? profile.stack.languages : [])[0] ?? "unknown";
572
+ for (const path of roots.length ? roots : ["."]) {
573
+ packages.push({
574
+ name: typeof profile?.name === "string" ? profile.name : basename(root),
575
+ path: path === "." ? "." : toPosix(path),
576
+ kind: "app",
577
+ language,
578
+ manifest: "",
579
+ scripts: {},
580
+ entryPoints: [],
581
+ internalDeps: [],
582
+ externalDeps: 0,
583
+ consumers: [],
584
+ hasTests: false,
585
+ adopted: false,
586
+ });
587
+ }
588
+ }
589
+
590
+ // Resolve internal edges by name or workspace path.
591
+ for (const file of manifests) {
592
+ const path = projectPath(root, file.dir);
593
+ const pkg = packages.find((p) => p.path === path);
594
+ if (!pkg) continue;
595
+ const parsed = parseManifest(file);
596
+ const internal: string[] = [];
597
+ for (const [name, version] of Object.entries(parsed.deps)) {
598
+ const target = resolveWorkspaceDep(name, version, packages);
599
+ if (target && target !== pkg.name) internal.push(target);
600
+ }
601
+ pkg.internalDeps = [...new Set(internal)];
602
+ pkg.externalDeps = Math.max(0, parsed.externalDeps - pkg.internalDeps.length);
603
+ }
604
+
605
+ const byName = new Map(packages.map((p) => [p.name, p]));
606
+ for (const pkg of packages) {
607
+ for (const dep of pkg.internalDeps) {
608
+ const target = byName.get(dep);
609
+ if (target && !target.consumers.includes(pkg.name)) target.consumers.push(pkg.name);
610
+ }
611
+ }
612
+ for (const pkg of packages) pkg.consumers.sort();
613
+ packages.sort((a, b) => a.path.localeCompare(b.path));
614
+
615
+ const ranking = rankPackages(root, packages);
616
+ const map: RepoMap = { version: 1, tooling, packages, ranking };
617
+ // Enrichment only. The manifest graph remains the source of truth, so the map stays usable without the
618
+ // build tool: Turbo exposes a task graph, Nx a project graph, Bazel its BUILD deps.
619
+ if (options.toolGraph !== false && tooling.kind !== "none") {
620
+ const graph = queryToolGraph(root, tooling.kind, packages);
621
+ if (graph) map.toolGraph = graph;
622
+ }
623
+ return map;
624
+ }
625
+
626
+ export function readProfile(root: string): Json | undefined {
627
+ return readJson(join(root, ".claude", "project.json"));
628
+ }
629
+
630
+ function recentFiles(root: string, limit = 500): string[] {
631
+ try {
632
+ const out = execFileSync("git", ["log", "--format=", "--name-only", "-n", String(limit)], {
633
+ cwd: root,
634
+ encoding: "utf8",
635
+ stdio: ["ignore", "pipe", "ignore"],
636
+ timeout: 15_000,
637
+ });
638
+ return out.split("\n").map((line) => line.trim()).filter(Boolean);
639
+ } catch {
640
+ return [];
641
+ }
642
+ }
643
+
644
+ export function rankPackages(root: string, packages: RepoPackage[]): RankedPackage[] {
645
+ const files = recentFiles(root);
646
+ const churn = new Map<string, number>();
647
+ for (const pkg of packages) {
648
+ const prefix = pkg.path === "." ? "" : `${pkg.path}/`;
649
+ churn.set(pkg.name, files.filter((file) => (pkg.path === "." ? true : file.startsWith(prefix))).length);
650
+ }
651
+ const maxChurn = Math.max(1, ...churn.values());
652
+ const maxConsumers = Math.max(1, ...packages.map((p) => p.consumers.length));
653
+
654
+ return packages
655
+ .map((pkg) => {
656
+ const changes = churn.get(pkg.name) ?? 0;
657
+ const score = 0.55 * (changes / maxChurn) + 0.3 * (pkg.consumers.length / maxConsumers) + 0.15 * (pkg.kind === "library" ? 0.4 : 1);
658
+ const reasons: string[] = [];
659
+ if (changes) reasons.push(`${changes} changed file${changes === 1 ? "" : "s"} in recent history`);
660
+ if (pkg.consumers.length) {
661
+ const n = pkg.consumers.length;
662
+ reasons.push(`${n} package${n === 1 ? "" : "s"} ${n === 1 ? "depends" : "depend"} on it`);
663
+ }
664
+ if (pkg.kind !== "package") reasons.push(pkg.kind === "library" ? "shared library" : pkg.kind);
665
+ if (!reasons.length) reasons.push("no churn or consumers recorded");
666
+ return { path: pkg.path, score: Math.round(score * 100) / 100, reasons };
667
+ })
668
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
669
+ }
670
+
671
+ export function proposeSlices(map: RepoMap): SliceProposal {
672
+ const byPath = new Map(map.packages.map((p) => [p.path, p]));
673
+ const ranked = map.ranking.map((r) => byPath.get(r.path)).filter((p): p is RepoPackage => p !== undefined);
674
+ const domain = ranked.find((p) => p.kind === "app" || p.kind === "service") ?? ranked.find((p) => p.kind !== "library") ?? ranked[0];
675
+ if (!domain) return {};
676
+
677
+ const libs = domain.internalDeps
678
+ .map((name) => map.packages.find((p) => p.name === name))
679
+ .filter((p): p is RepoPackage => p !== undefined && p.kind === "library")
680
+ .sort((a, b) => b.consumers.length - a.consumers.length);
681
+ const library = libs[0] ?? ranked.find((p) => p.kind === "library" && p.name !== domain.name);
682
+ return { domain, library };
683
+ }
684
+
685
+ // --------------------------------------------------------------------------
686
+ // Commands
687
+ // --------------------------------------------------------------------------
688
+ export function taskCommand(tooling: Tooling, pkg: RepoPackage, task: string): string {
689
+ // Nx project names are usually the package name without the scope; project.json wins when it exists.
690
+ const project = pkg.projectName ?? pkg.name.replace(/^@[^/]+\//, "");
691
+ const path = pkg.path;
692
+ switch (tooling.kind) {
693
+ case "nx":
694
+ return `nx run ${project}:${task}`;
695
+ case "turbo":
696
+ return `turbo run ${task} --filter=${pkg.name}`;
697
+ case "bazel":
698
+ return task === "test" ? `bazel test //${path}/...` : task === "build" ? `bazel build //${path}/...` : "";
699
+ case "pnpm":
700
+ return `pnpm --filter ${pkg.name} ${task}`;
701
+ case "workspaces":
702
+ if (tooling.manager === "yarn") return `yarn workspace ${pkg.name} ${task}`;
703
+ if (tooling.manager === "bun") return `bun run --filter ${pkg.name} ${task}`;
704
+ return `npm run ${task} --workspace ${pkg.name}`;
705
+ case "go-work":
706
+ return task === "test" ? `go test ./${path}/...` : task === "build" ? `go build ./${path}/...` : task === "lint" ? `go vet ./${path}/...` : "";
707
+ case "cargo-workspace":
708
+ return task === "lint" ? `cargo clippy -p ${pkg.name} -- -D warnings` : `cargo ${task} -p ${pkg.name}`;
709
+ case "gradle":
710
+ return `./gradlew :${path.split("/").join(":")}:${task}`;
711
+ case "maven":
712
+ return `mvn -pl ${path} ${task}`;
713
+ default:
714
+ if (pkg.scripts[task]) return path === "." ? `npm run ${task}` : `npm --prefix ${path} run ${task}`;
715
+ return "";
716
+ }
717
+ }
718
+
719
+ export function templateCommand(tooling: Tooling, task: string): string {
720
+ switch (tooling.kind) {
721
+ case "nx": return `nx run <project>:${task}`;
722
+ case "turbo": return `turbo run ${task} --filter=<package>`;
723
+ case "bazel": return task === "test" ? "bazel test //<package>/..." : task === "build" ? "bazel build //<package>/..." : "";
724
+ case "pnpm": return `pnpm --filter <package> ${task}`;
725
+ case "workspaces":
726
+ if (tooling.manager === "yarn") return `yarn workspace <package> ${task}`;
727
+ if (tooling.manager === "bun") return `bun run --filter <package> ${task}`;
728
+ return `npm run ${task} --workspace <package>`;
729
+ case "go-work": return task === "test" ? "go test ./<package>/..." : task === "build" ? "go build ./<package>/..." : task === "lint" ? "go vet ./<package>/..." : "";
730
+ case "cargo-workspace": return task === "lint" ? `cargo clippy -p <package> -- -D warnings` : `cargo ${task} -p <package>`;
731
+ case "gradle": return `./gradlew :<package>:${task}`;
732
+ case "maven": return `mvn -pl <package> ${task}`;
733
+ default: return "";
734
+ }
735
+ }
736
+
737
+ export function affectedCommand(tooling: Tooling): string {
738
+ switch (tooling.kind) {
739
+ case "nx": return "nx affected -t test";
740
+ case "turbo": return "turbo run test --filter='...[HEAD^1]'";
741
+ case "pnpm": return "pnpm -r --filter '...[HEAD^1]' test";
742
+ default: return "";
743
+ }
744
+ }
745
+
746
+ // --------------------------------------------------------------------------
747
+ // Rendering
748
+ // --------------------------------------------------------------------------
749
+ function installCommand(tooling: Tooling, configured = ""): string {
750
+ if (configured) return configured;
751
+ switch (tooling.kind) {
752
+ case "go-work": return "go mod download";
753
+ case "cargo-workspace": return "cargo build";
754
+ case "gradle": return "./gradlew build";
755
+ case "maven": return "mvn install";
756
+ case "bazel": return "bazel build //...";
757
+ default: return tooling.manager ? `${tooling.manager} install` : "";
758
+ }
759
+ }
760
+
761
+ function toolingLabel(tooling: Tooling): string {
762
+ if (tooling.kind === "none") return "none (single package)";
763
+ const names: Record<string, string> = {
764
+ bazel: "Bazel", nx: "Nx", turbo: "Turborepo", rush: "Rush", lerna: "Lerna", pnpm: "pnpm workspaces",
765
+ "go-work": "Go workspace", "cargo-workspace": "Cargo workspace", gradle: "Gradle", maven: "Maven",
766
+ workspaces: `${tooling.manager || "npm"} workspaces`,
767
+ };
768
+ return `${names[tooling.kind] ?? tooling.kind}${tooling.config ? ` (\`${tooling.config}\`)` : ""}`;
769
+ }
770
+
771
+ export function packageCounts(map: RepoMap): string {
772
+ const apps = map.packages.filter((p) => p.kind === "app" || p.kind === "service").length;
773
+ const libs = map.packages.filter((p) => p.kind === "library").length;
774
+ return `${apps} ${apps === 1 ? "app/service" : "apps/services"}, ${libs} ${libs === 1 ? "library" : "libraries"}`;
775
+ }
776
+
777
+ function groups(packages: RepoPackage[]): { dir: string; total: number; kinds: PackageKind[] }[] {
778
+ const byDir = new Map<string, RepoPackage[]>();
779
+ for (const pkg of packages) {
780
+ const dir = pkg.path === "." ? "." : pkg.path.split("/")[0];
781
+ byDir.set(dir, [...(byDir.get(dir) ?? []), pkg]);
782
+ }
783
+ return [...byDir.entries()]
784
+ .map(([dir, list]) => ({ dir, total: list.length, kinds: [...new Set(list.map((p) => p.kind))] }))
785
+ .sort((a, b) => b.total - a.total || a.dir.localeCompare(b.dir));
786
+ }
787
+
788
+ function tableCell(value: string): string {
789
+ return value.replaceAll("|", "\\|") || "–";
790
+ }
791
+
792
+ export function renderRepoMapMarkdown(map: RepoMap): string {
793
+ const out: string[] = [
794
+ "# Repository map",
795
+ "",
796
+ "> Generated from the repository's manifests by `repo-map.ts` – **do not edit manually**. Refresh with",
797
+ "> `node .claude/skills/adopt/scripts/repo-map.ts`.",
798
+ "",
799
+ `- **Tooling:** ${toolingLabel(map.tooling)}`,
800
+ `- **Workspaces:** ${map.tooling.workspaces.length ? map.tooling.workspaces.map((w) => `\`${w}\``).join(", ") : "–"}`,
801
+ `- **Packages:** ${map.packages.length} (${packageCounts(map)})`,
802
+ `- **Adopted:** ${map.packages.filter((p) => p.adopted).length} of ${map.packages.length}`,
803
+ ...(map.toolGraph
804
+ ? [`- **Tool graph:** ${graphLabel(map.toolGraph)}${map.toolGraph.task ? ` (\`${map.toolGraph.task}\`)` : ""} – ${map.toolGraph.packages.length} package(s), ${map.toolGraph.edges.length} edge(s); used for affected runs`]
805
+ : [`- **Tool graph:** manifests only${graphToolLabel(map.tooling.kind) ? ` (no ${graphToolLabel(map.tooling.kind)} graph found)` : ""}`]),
806
+ "",
807
+ "## Packages",
808
+ "",
809
+ "| Package | Path | Kind | Language | Depends on | Used by | Adopted |",
810
+ "|---|---|---|---|---|---|---|",
811
+ ];
812
+
813
+ for (const pkg of [...map.packages].sort((a, b) => a.path.localeCompare(b.path))) {
814
+ out.push(
815
+ `| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${pkg.kind} | ${pkg.language} | ` +
816
+ `${pkg.internalDeps.length ? pkg.internalDeps.map((d) => `\`${d}\``).join(", ") : "–"} | ` +
817
+ `${pkg.consumers.length ? pkg.consumers.map((d) => `\`${d}\``).join(", ") : "–"} | ` +
818
+ `${pkg.adopted ? "yes" : "–"} |`,
819
+ );
820
+ }
821
+ out.push("");
822
+
823
+ const edges = map.packages.flatMap((pkg) => pkg.internalDeps.map((dep) => [pkg, dep] as const));
824
+ if (edges.length) {
825
+ const nodes = new Map<string, RepoPackage>();
826
+ for (const [pkg, dep] of edges.slice(0, 80)) {
827
+ nodes.set(pkg.name, pkg);
828
+ const target = map.packages.find((p) => p.name === dep);
829
+ if (target) nodes.set(target.name, target);
830
+ }
831
+ const id = (name: string): string => name.replace(/[^A-Za-z0-9_]/g, "_");
832
+ out.push("## Dependency edges", "", "```mermaid", "flowchart LR");
833
+ for (const pkg of [...nodes.values()].slice(0, 60)) {
834
+ out.push(` ${id(pkg.name)}["${pkg.name.replaceAll('"', "'")}"]`);
835
+ }
836
+ for (const [pkg, dep] of edges.slice(0, 80)) {
837
+ if (nodes.has(pkg.name) && nodes.has(dep)) out.push(` ${id(pkg.name)} --> ${id(dep)}`);
838
+ }
839
+ out.push("```", "");
840
+ if (edges.length > 80) out.push(`_Graph truncated at 80 edges (${edges.length} total). See the table above._`, "");
841
+ }
842
+
843
+ if (map.toolGraph) {
844
+ out.push(
845
+ `## Tool graph (${graphLabel(map.toolGraph)})`,
846
+ "",
847
+ `Source: \`${graphOrigin(map.toolGraph)}\`. The manifest graph above stays the source of truth;`,
848
+ "this graph is cross-checked and used for affected test runs.",
849
+ "",
850
+ "| From | To |",
851
+ "|---|---|",
852
+ );
853
+ for (const edge of map.toolGraph.edges.slice(0, 80)) out.push(`| \`${edge.from}\` | \`${edge.to}\` |`);
854
+ if (map.toolGraph.edges.length > 80) out.push(`| … | ${map.toolGraph.edges.length - 80} more |`);
855
+ out.push("");
856
+ }
857
+
858
+ out.push("## Commands", "", "| Scope | Command |", "|---|---|");
859
+ const install = installCommand(map.tooling);
860
+ if (install) out.push(`| Install | \`${install}\` |`);
861
+ const one = templateCommand(map.tooling, "test");
862
+ if (one) out.push(`| One package | \`${one}\` |`);
863
+ const affected = affectedCommand(map.tooling);
864
+ if (affected) out.push(`| Affected only | \`${affected}\` |`);
865
+ out.push("");
866
+
867
+ out.push("## Adoption ranking", "", "| # | Package | Score | Why |", "|---|---|---|---|");
868
+ const byPath = new Map(map.packages.map((p) => [p.path, p]));
869
+ map.ranking.forEach((rank, index) => {
870
+ out.push(`| ${index + 1} | \`${rank.path}\` (${tableCell(byPath.get(rank.path)?.name ?? "")}) | ${rank.score.toFixed(2)} | ${rank.reasons.join("; ")} |`);
871
+ });
872
+ out.push("");
873
+
874
+ const { domain, library } = proposeSlices(map);
875
+ if (domain) {
876
+ const label = domain.path === "." ? "root package" : `\`${domain.path}\``;
877
+ out.push("## Suggested first slice", "", `- **Domain:** ${label} (${domain.name})`);
878
+ if (library) out.push(`- **Shared library:** \`${library.path}\` (${library.name})`);
879
+ out.push("", "Adopt one domain and at most one shared library per session. Then mark the slice adopted:", "", "```bash", `node .claude/skills/adopt/scripts/repo-map.ts --adopt ${domain.path}`, "```", "");
880
+ }
881
+ return out.join("\n");
882
+ }
883
+
884
+ export function rootBlock(map: RepoMap, root = resolve(import.meta.dirname, "..", "..")): string {
885
+ const profile = readProfile(root);
886
+ const commands = isObject(profile?.commands) ? profile.commands : {};
887
+ const command = (key: string): string => (typeof commands[key] === "string" ? commands[key] : "");
888
+ const groupsList = groups(map.packages);
889
+ const adopted = map.packages.filter((p) => p.adopted);
890
+
891
+ const lines = [
892
+ REPO_BEGIN,
893
+ "<!-- Generated from docs/architecture/repo-map.json by repo-map.ts. Do not edit inside this block. -->",
894
+ "## Repository map",
895
+ "",
896
+ `- **Tooling:** ${toolingLabel(map.tooling)} · **Packages:** ${map.packages.length} · **Adopted:** ${adopted.length} of ${map.packages.length}`,
897
+ ];
898
+ if (map.toolGraph) {
899
+ lines.push(
900
+ `- **Tool graph:** ${graphLabel(map.toolGraph)}${map.toolGraph.task ? ` (\`${map.toolGraph.task}\`)` : ""} – used for affected test runs.`,
901
+ );
902
+ }
903
+ if (groupsList.length) {
904
+ lines.push(`- **Layout:** ${groupsList.slice(0, 8).map((g) => `\`${g.dir}/\` (${g.total})`).join(", ")}`);
905
+ }
906
+ lines.push("- **Detail:** `docs/architecture/repo-map.md` – package list, dependency edges, consumers, adoption ranking.");
907
+ if (adopted.length) {
908
+ const rest = map.packages.length - adopted.length;
909
+ const labels = adopted.map((p) => (p.path === "." ? "root package" : `\`${p.path}\``));
910
+ lines.push(
911
+ `- **Adopted slices:** ${labels.join(", ")}` +
912
+ (rest ? `. Everything else is **not adopted**: run \`/adopt <path>\` before inferring domain there.` : "."),
913
+ );
914
+ } else {
915
+ lines.push("- **No package is adopted yet.** Run `/adopt` before inferring domain from code.");
916
+ }
917
+ lines.push("", "## Commands", "", "| Scope | Command |", "|---|---|");
918
+
919
+ const rows: [string, string][] = [
920
+ ["Install", installCommand(map.tooling, command("install"))],
921
+ ["Lint", command("lint")],
922
+ ["Typecheck", command("typecheck")],
923
+ ["Test", command("test")],
924
+ ["End-to-end", command("e2e")],
925
+ ["One package", templateCommand(map.tooling, "test")],
926
+ ["Affected only", affectedCommand(map.tooling)],
927
+ ["Gate", "node .claude/skills/implement/scripts/verify.ts"],
928
+ ];
929
+ for (const [scope, value] of rows) {
930
+ if (value) lines.push(`| ${scope} | \`${value}\` |`);
931
+ }
932
+ lines.push(
933
+ "",
934
+ "## Read next",
935
+ "",
936
+ "- `docs/architecture/repo-map.md` – where domains and shared libraries live",
937
+ "- `docs/engineering/conventions.md` – how code is built in this repository",
938
+ "- `CONTEXT.md` – domain language; this file stays free of domain rules on purpose",
939
+ "- Package docs: `<package>/AGENTS.md` (loaded when you work there) with `<package>/CLAUDE.md` as the Claude Code entry",
940
+ REPO_END,
941
+ );
942
+ return lines.join("\n");
943
+ }
944
+
945
+ export function upsertBlock(existing: string, block: string, begin: string, end: string): string {
946
+ const start = existing.indexOf(begin);
947
+ const stop = existing.indexOf(end);
948
+ if (start !== -1 && stop !== -1 && stop > start) {
949
+ return `${existing.slice(0, start)}${block}${existing.slice(stop + end.length)}`.replace(/\n{3,}/g, "\n\n");
950
+ }
951
+ const trimmed = existing.trimEnd();
952
+ return `${trimmed}${trimmed ? "\n\n" : ""}${block}\n`;
953
+ }
954
+
955
+ // --------------------------------------------------------------------------
956
+ // Package documents
957
+ // --------------------------------------------------------------------------
958
+ function taskAvailable(map: RepoMap, pkg: RepoPackage, task: string): boolean {
959
+ if (pkg.scripts[task]) return true;
960
+ if (task === "test" && pkg.hasTests) return true;
961
+ if (map.tooling.kind === "go-work") return task === "test" || task === "build" || task === "lint";
962
+ if (map.tooling.kind === "cargo-workspace") return task === "test" || task === "build" || task === "lint";
963
+ if (map.tooling.kind === "gradle" || map.tooling.kind === "maven") return task === "test" || task === "build";
964
+ if (map.tooling.kind === "bazel") return task === "test" || task === "build";
965
+ return false;
966
+ }
967
+
968
+ function commandLines(map: RepoMap, pkg: RepoPackage): string[] {
969
+ const entries: [string, string][] = [];
970
+ for (const task of ["build", "lint", "typecheck", "test", "e2e"]) {
971
+ if (!taskAvailable(map, pkg, task)) continue;
972
+ const value = taskCommand(map.tooling, pkg, task);
973
+ if (value) entries.push([task, value]);
974
+ }
975
+ return entries.map(([task, value]) => `- **${task.charAt(0).toUpperCase()}${task.slice(1)}:** \`${value}\``);
976
+ }
977
+
978
+ export function packageBlock(map: RepoMap, pkg: RepoPackage): string {
979
+ const lines = [
980
+ PACKAGE_BEGIN,
981
+ "<!-- Generated from docs/architecture/repo-map.json by package-doc.ts. Do not edit inside this block. -->",
982
+ "## Facts",
983
+ "",
984
+ `- **Path:** \`${pkg.path}\``,
985
+ `- **Kind:** ${pkg.kind} · ${pkg.language}`,
986
+ ];
987
+ if (pkg.manifest) lines.push(`- **Manifest:** \`${pkg.manifest}\``);
988
+ lines.push(...commandLines(map, pkg));
989
+ lines.push(`- **Depends on:** ${pkg.internalDeps.length ? pkg.internalDeps.map((d) => `\`${d}\``).join(", ") : "– no internal packages"}`);
990
+ lines.push(`- **Used by:** ${pkg.consumers.length ? pkg.consumers.map((d) => `\`${d}\``).join(", ") : "– no internal consumers"}`);
991
+ if (pkg.entryPoints.length) lines.push(`- **Entry points:** ${pkg.entryPoints.map((p) => `\`${p}\``).join(", ")}`);
992
+ lines.push(PACKAGE_END);
993
+ return lines.join("\n");
994
+ }
995
+
996
+ function proseSkeleton(pkg: RepoPackage): string {
997
+ if (pkg.kind === "library") {
998
+ return [
999
+ "## Purpose",
1000
+ "",
1001
+ "[OPEN] Why does this package exist? One or two sentences, in the domain's words.",
1002
+ "",
1003
+ "## Public API",
1004
+ "",
1005
+ "[OPEN] What is exported and stable, and what is deliberately internal?",
1006
+ "",
1007
+ "## Consumers",
1008
+ "",
1009
+ "[OPEN] Who depends on it, and what may they rely on? The generated list above is only the current state.",
1010
+ "",
1011
+ "## Compatibility rules",
1012
+ "",
1013
+ "[OPEN] What must not break without a migration? Versioning, deprecation, data formats.",
1014
+ "",
1015
+ "## Typical pitfalls",
1016
+ "",
1017
+ "[OPEN] What breaks here? From tests, incidents and git history – not guesses.",
1018
+ "",
1019
+ "## Boundaries",
1020
+ "",
1021
+ "- May import: [OPEN]",
1022
+ "- Must not import: [OPEN] (shared libraries never import apps or services)",
1023
+ "- Cross-context communication: only through published contracts or events.",
1024
+ "",
1025
+ "## Read next",
1026
+ "",
1027
+ "- `docs/architecture/repo-map.md`",
1028
+ "- `CONTEXT.md`",
1029
+ "- `docs/domain/events.yaml`",
1030
+ "",
1031
+ ].join("\n");
1032
+ }
1033
+
1034
+ return [
1035
+ "## Purpose",
1036
+ "",
1037
+ "[OPEN] Why does this package exist? One or two sentences, in the domain's words.",
1038
+ "",
1039
+ "## Key entities",
1040
+ "",
1041
+ "[OPEN] The terms from `CONTEXT.md` this package owns or changes; aggregate IDs from `docs/domain/events.yaml`.",
1042
+ "",
1043
+ "## External interfaces",
1044
+ "",
1045
+ "[OPEN] HTTP/CLI/events/exports: what can the outside call, and what is deliberately private?",
1046
+ "",
1047
+ "## Typical pitfalls",
1048
+ "",
1049
+ "[OPEN] What breaks here? From tests, incidents and git history – not guesses.",
1050
+ "",
1051
+ "## Boundaries",
1052
+ "",
1053
+ "- May import: [OPEN]",
1054
+ "- Must not import: [OPEN]",
1055
+ "- Cross-context communication: only through published contracts or events.",
1056
+ "",
1057
+ "## Read next",
1058
+ "",
1059
+ "- `docs/architecture/repo-map.md`",
1060
+ "- `CONTEXT.md`",
1061
+ "- `docs/domain/events.yaml`",
1062
+ "",
1063
+ ].join("\n");
1064
+ }
1065
+
1066
+ export function renderPackageDoc(map: RepoMap, pkg: RepoPackage): string {
1067
+ return [
1068
+ `# ${pkg.name}`,
1069
+ "",
1070
+ `> Read this when you work in \`${pkg.path}\`. Repository-wide rules: root \`AGENTS.md\`.`,
1071
+ "",
1072
+ packageBlock(map, pkg),
1073
+ "",
1074
+ proseSkeleton(pkg),
1075
+ ].join("\n");
1076
+ }
1077
+
1078
+ /** Replaces only the generated block; existing prose stays untouched. Returns null when the file has no markers. */
1079
+ export function updatePackageDoc(existing: string, map: RepoMap, pkg: RepoPackage): string | null {
1080
+ if (!existing.includes(PACKAGE_BEGIN) || !existing.includes(PACKAGE_END)) return null;
1081
+ return upsertBlock(existing, packageBlock(map, pkg), PACKAGE_BEGIN, PACKAGE_END);
1082
+ }
1083
+
1084
+ export function claudeTwin(pkg: RepoPackage): string {
1085
+ return `# ${pkg.name}\n\n@AGENTS.md\n`;
1086
+ }
1087
+
1088
+ // --------------------------------------------------------------------------
1089
+ // Persistence
1090
+ // --------------------------------------------------------------------------
1091
+ export function writeRepoMap(root: string, map: RepoMap): { json: string; md: string } {
1092
+ const jsonPath = join(root, REPO_MAP_JSON);
1093
+ const mdPath = join(root, REPO_MAP_MD);
1094
+ mkdirSync(dirname(jsonPath), { recursive: true });
1095
+ writeFileSync(jsonPath, `${JSON.stringify(map, null, 2)}\n`, "utf8");
1096
+ writeFileSync(mdPath, renderRepoMapMarkdown(map), "utf8");
1097
+ return { json: jsonPath, md: mdPath };
1098
+ }
1099
+
1100
+ export function readRepoMap(root: string): RepoMap | undefined {
1101
+ const value = readJson(join(root, REPO_MAP_JSON));
1102
+ if (!value || value.version !== 1 || !Array.isArray(value.packages)) return undefined;
1103
+ return value as unknown as RepoMap;
1104
+ }
1105
+
1106
+ export function updateProjectTooling(root: string, tooling: Tooling): boolean {
1107
+ const path = join(root, ".claude", "project.json");
1108
+ const profile = readJson(path);
1109
+ if (!profile) return false;
1110
+ const repo = isObject(profile.repo) ? profile.repo : {};
1111
+ const before = JSON.stringify(repo.tooling);
1112
+ repo.tooling = tooling;
1113
+ profile.repo = repo;
1114
+ const after = JSON.stringify(repo.tooling);
1115
+ if (before === after) return false;
1116
+ writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
1117
+ return true;
1118
+ }
1119
+
1120
+ export function writeRootBlock(root: string, map: RepoMap): boolean {
1121
+ const path = join(root, "AGENTS.md");
1122
+ const existing = readText(path) ?? "";
1123
+ const next = upsertBlock(existing, rootBlock(map, root), REPO_BEGIN, REPO_END);
1124
+ if (next === existing) return false;
1125
+ writeFileSync(path, next, "utf8");
1126
+ return true;
1127
+ }
1128
+
1129
+ export interface PackageDocResult {
1130
+ agents: string;
1131
+ claude: string;
1132
+ status: "created" | "refreshed" | "kept";
1133
+ }
1134
+
1135
+ export function writePackageDoc(root: string, map: RepoMap, pkg: RepoPackage): PackageDocResult {
1136
+ if (pkg.path === ".") throw new Error("the root package is documented in AGENTS.md, not in a package file");
1137
+ const dir = fromRoot(root, pkg.path);
1138
+ mkdirSync(dir, { recursive: true });
1139
+ const agentsPath = join(dir, "AGENTS.md");
1140
+ const claudePath = join(dir, "CLAUDE.md");
1141
+ const existing = readText(agentsPath);
1142
+ let content: string;
1143
+ let status: PackageDocResult["status"];
1144
+
1145
+ if (existing === undefined) {
1146
+ content = renderPackageDoc(map, pkg);
1147
+ status = "created";
1148
+ } else {
1149
+ const updated = updatePackageDoc(existing, map, pkg);
1150
+ if (updated === null) {
1151
+ // Hand-written docs stay untouched, but Claude Code still needs the one-line entry file.
1152
+ if (!existsSync(claudePath)) writeFileSync(claudePath, claudeTwin(pkg), "utf8");
1153
+ return { agents: agentsPath, claude: claudePath, status: "kept" };
1154
+ }
1155
+ content = updated;
1156
+ status = "refreshed";
1157
+ }
1158
+ if (content !== existing) writeFileSync(agentsPath, content, "utf8");
1159
+ if (!existsSync(claudePath)) writeFileSync(claudePath, claudeTwin(pkg), "utf8");
1160
+ return { agents: agentsPath, claude: claudePath, status };
1161
+ }
1162
+
1163
+ export function markAdopted(root: string, path: string, date = new Date().toISOString().slice(0, 10)): RepoMap {
1164
+ const map = readRepoMap(root);
1165
+ if (!map) throw new Error(`no ${REPO_MAP_JSON} – run repo-map.ts first`);
1166
+ const wanted = path.replace(/\/$/, "");
1167
+ const pkg = map.packages.find((p) => p.path === wanted || p.name === wanted);
1168
+ if (!pkg) throw new Error(`package '${path}' is not in the repository map`);
1169
+ pkg.adopted = true;
1170
+ pkg.adoptedAt = date;
1171
+ writeRepoMap(root, map);
1172
+ writeRootBlock(root, map);
1173
+ return map;
1174
+ }
1175
+
1176
+ /** Stable view for staleness checks: generated docs and ranking may change with history. */
1177
+ export function stableMap(map: RepoMap): string {
1178
+ const packages = [...map.packages]
1179
+ .sort((a, b) => a.path.localeCompare(b.path))
1180
+ .map(({ adopted: _adopted, adoptedAt: _adoptedAt, consumers: _consumers, ...rest }) => rest);
1181
+ return JSON.stringify({ version: map.version, tooling: map.tooling, packages }, null, 2);
1182
+ }
1183
+
1184
+ export function findPackage(map: RepoMap, path: string): RepoPackage | undefined {
1185
+ const wanted = path.replace(/\/$/, "");
1186
+ return map.packages.find((p) => p.path === wanted || p.name === wanted);
1187
+ }
1188
+
1189
+ export function ensureInside(root: string, path: string): string {
1190
+ const absolute = resolve(root, path);
1191
+ const rel = relative(resolve(root), absolute);
1192
+ if (rel.startsWith("..") || resolve(rel) === rel) throw new Error(`'${path}' is outside the repository`);
1193
+ return absolute;
1194
+ }