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,406 @@
1
+ /**
2
+ * Shared, deterministic logic for the engineering workflow (`/implement`).
3
+ *
4
+ * Used by the scripts in .claude/skills/implement/scripts and by the hooks:
5
+ * - story selection and status transitions (ready → in-progress → done)
6
+ * - traceability between stories and code: test titles `ST-NNN: <scenario title>`,
7
+ * domain IDs (CMD-, EVT-, …) in the code, glossary language in code and message catalogs
8
+ * - the last result of `verify.ts`
9
+ *
10
+ * Builds on discovery.ts; same rules: Node >= 22.18, erasable TypeScript only.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname, extname, join } from "node:path";
15
+ import * as d from "./discovery.ts";
16
+
17
+ const list = (v: unknown): string[] => (Array.isArray(v) ? v : []);
18
+
19
+ // --------------------------------------------------------------------------
20
+ // Git
21
+ // --------------------------------------------------------------------------
22
+ export function git(...args: string[]): string | null {
23
+ try {
24
+ return execFileSync("git", args, { cwd: d.ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** Content of a project-relative file at HEAD, or null if it is not committed yet. */
31
+ export function headContent(relPath: string): string | null {
32
+ return git("show", `HEAD:${relPath}`);
33
+ }
34
+
35
+ // --------------------------------------------------------------------------
36
+ // Stories
37
+ // --------------------------------------------------------------------------
38
+ export function storiesById(): Map<string, d.Story> {
39
+ return new Map(d.loadStories().parsed.map((s) => [d.storyId(s), s]));
40
+ }
41
+
42
+ export function findStory(id: string): d.Story {
43
+ const s = storiesById().get(id.toUpperCase());
44
+ if (!s) throw new Error(`story '${id}' not found in docs/stories/`);
45
+ return s;
46
+ }
47
+
48
+ /** Scenario titles from the story's acceptance criteria, in order. */
49
+ export const scenarioTitles = (story: d.Story): string[] => d.scenarioTitlesIn(story.body);
50
+
51
+ /** Checklist items (`- [ ] …`) of a spike or tech task. */
52
+ export function checklist(story: d.Story): { done: boolean; text: string }[] {
53
+ const ac = d.section(d.sections(story.body), "acceptance criteria") ?? "";
54
+ const out: { done: boolean; text: string }[] = [];
55
+ for (const line of ac.split("\n")) {
56
+ const m = /^\s*[-*]\s*\[([ xX])\]\s*(.+?)\s*$/.exec(line);
57
+ if (m) out.push({ done: m[1] !== " ", text: m[2] });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** The test title every scenario must have, exactly: `ST-NNN: <scenario title>`. */
63
+ export const testTitle = (id: string, scenario: string): string => `${id}: ${scenario}`;
64
+
65
+ // --------------------------------------------------------------------------
66
+ // Status workflow
67
+ // --------------------------------------------------------------------------
68
+ /**
69
+ * Allowed single steps. Discovery owns draft/review/ready; engineering moves ready → in-progress → done
70
+ * and sends a story back to review when it turns out to be wrong. `done` is final: changes to finished
71
+ * behaviour become a new story.
72
+ */
73
+ const TRANSITIONS: Record<string, string[]> = {
74
+ draft: ["review", "superseded"],
75
+ review: ["draft", "ready", "superseded"],
76
+ ready: ["review", "in-progress", "superseded"],
77
+ "in-progress": ["review", "done"],
78
+ done: [],
79
+ // Final like done: the record of a story done elsewhere or replaced by a new planning (user, 2026-10-25).
80
+ superseded: [],
81
+ };
82
+
83
+ function reachable(from: string, to: string): boolean {
84
+ const seen = new Set([from]);
85
+ const queue = [from];
86
+ while (queue.length) {
87
+ for (const next of TRANSITIONS[queue.shift()!] ?? []) {
88
+ if (next === to) return true;
89
+ if (!seen.has(next)) {
90
+ seen.add(next);
91
+ queue.push(next);
92
+ }
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Errors for moving `story` from status `from` to `to` (possibly over several steps, e.g. between two commits).
100
+ * Checks the path through the workflow and the preconditions of the target status.
101
+ */
102
+ export function transitionErrors(story: d.Story, from: string, to: string, stories: Map<string, d.Story>): string[] {
103
+ if (from === to) return [];
104
+ const errs: string[] = [];
105
+ if (!reachable(from, to)) {
106
+ errs.push(
107
+ from === "done" || from === "superseded"
108
+ ? `a ${from} story is never reopened – capture the change as a new story`
109
+ : `status '${from}' cannot become '${to}' (workflow: draft → review → ready → in-progress → done, back to review when a story is wrong; superseded from any status before in-progress)`,
110
+ );
111
+ return errs;
112
+ }
113
+ if (to === "in-progress" || to === "done") {
114
+ const open = list(story.meta.depends_on).filter((dep) => stories.get(dep)?.meta.status !== "done");
115
+ if (open.length) errs.push(`depends on stories that are not done yet: ${open.join(", ")}`);
116
+ }
117
+ if (to === "done") {
118
+ if (story.meta.type === "story") {
119
+ const cov = scenarioCoverage(story);
120
+ if (cov.missing.length) {
121
+ errs.push(`scenarios without a test titled '${d.storyId(story)}: <scenario title>': ${cov.missing.map((m) => `"${m}"`).join(", ")}`);
122
+ }
123
+ }
124
+ // Every checklist item must be ticked – for spikes and tech tasks, and for the foundation checklists a story
125
+ // takes over just in time (e.g. "Foundation (moved from ST-074 …)").
126
+ const open = checklist(story).filter((c) => !c.done);
127
+ const short = (t: string) => (t.length > 60 ? `${t.slice(0, 57)}…` : t);
128
+ if (open.length) errs.push(`${open.length} unticked acceptance criteria: ${open.map((c) => `"${short(c.text)}"`).join(", ")}`);
129
+ }
130
+ return errs;
131
+ }
132
+
133
+ /** Status of the story file at HEAD (null for a story that isn't committed yet). */
134
+ export function headStatus(story: d.Story): string | null {
135
+ const text = headContent(d.rel(story.path));
136
+ if (text === null) return null;
137
+ const m = /^status:\s*(\S+)\s*$/m.exec(text.split("\n---")[0] ?? "");
138
+ return m ? m[1] : null;
139
+ }
140
+
141
+ /** Rewrites the `status:` line in the story's frontmatter. */
142
+ export function writeStatus(story: d.Story, status: string): void {
143
+ const text = readFileSync(story.path, "utf8");
144
+ const end = text.indexOf("\n---", 4);
145
+ const front = text.slice(0, end).replace(/^status:.*$/m, `status: ${status}`);
146
+ writeFileSync(story.path, front + text.slice(end), "utf8");
147
+ }
148
+
149
+ /** Next story to implement: the first `ready` one in backlog order whose dependencies are all done. */
150
+ export function nextStory(): { inProgress: d.Story[]; next: d.Story | null; blocked: d.Story[] } {
151
+ const stories = storiesById();
152
+ const all = [...stories.values()];
153
+ const inProgress = all.filter((s) => s.meta.status === "in-progress");
154
+ const ready = d.backlogOrder(all.filter((s) => s.meta.status === "ready"));
155
+ const depsDone = (s: d.Story) => list(s.meta.depends_on).every((dep) => stories.get(dep)?.meta.status === "done");
156
+ return {
157
+ inProgress,
158
+ next: ready.find(depsDone) ?? null,
159
+ blocked: ready.filter((s) => !depsDone(s)),
160
+ };
161
+ }
162
+
163
+ // --------------------------------------------------------------------------
164
+ // Code scanning
165
+ // --------------------------------------------------------------------------
166
+ /** Not application code: tooling, docs, generated output, dependencies. */
167
+ const IGNORED_DIRS = new Set([
168
+ ".git", ".claude", "docs", "node_modules", ".next", ".next-e2e", ".vercel", ".turbo", "coverage", "dist", "build", "out",
169
+ "playwright-report", "test-results", "public",
170
+ ]);
171
+ const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
172
+ const TEST_RE = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)(e2e|__tests__)\//;
173
+ // Test stand-ins of a module (engineering conventions): test code, but not tests – they carry no scenario titles.
174
+ const TEST_SUPPORT_RE = /\.test-support\.[cm]?[jt]sx?$/;
175
+ const CATALOG_DIR_RE = /(^|\/)(messages|locales|i18n)\//;
176
+
177
+ export function projectFiles(): string[] {
178
+ const out: string[] = [];
179
+ const walk = (dir: string): void => {
180
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
181
+ if (e.isDirectory()) {
182
+ if (!IGNORED_DIRS.has(e.name)) walk(join(dir, e.name));
183
+ } else if (e.isFile()) {
184
+ out.push(d.rel(join(dir, e.name)));
185
+ }
186
+ }
187
+ };
188
+ walk(d.ROOT);
189
+ return out.sort();
190
+ }
191
+
192
+ export const isCode = (f: string): boolean => CODE_EXT.has(extname(f)) && !f.endsWith(".d.ts");
193
+ export const isTest = (f: string): boolean => isCode(f) && TEST_RE.test(f);
194
+ /** Tests plus test stand-ins – excluded from the domain ID and language checks of application code. */
195
+ export const isTestCode = (f: string): boolean => isTest(f) || (isCode(f) && TEST_SUPPORT_RE.test(f));
196
+ export const isCatalog = (f: string): boolean => extname(f) === ".json" && CATALOG_DIR_RE.test(f);
197
+ const read = (f: string): string => readFileSync(join(d.ROOT, f), "utf8");
198
+
199
+ /** Every `ST-NNN: …` title found in string literals of test files, with its location. */
200
+ export function testTitles(): { title: string; file: string }[] {
201
+ const out: { title: string; file: string }[] = [];
202
+ const re = /(["'`])(ST-\d{3}: (?:(?!\1)[^\n])+?)\1/g;
203
+ for (const f of projectFiles().filter(isTest)) {
204
+ for (const m of read(f).matchAll(re)) out.push({ title: m[2].replace(/\s+/g, " ").trim(), file: f });
205
+ }
206
+ return out;
207
+ }
208
+
209
+ export function scenarioCoverage(story: d.Story, titles = testTitles()): { covered: string[]; missing: string[] } {
210
+ const id = d.storyId(story);
211
+ const found = new Set(titles.map((t) => t.title));
212
+ const covered: string[] = [];
213
+ const missing: string[] = [];
214
+ for (const sc of scenarioTitles(story)) (found.has(testTitle(id, sc).replace(/\s+/g, " ")) ? covered : missing).push(sc);
215
+ return { covered, missing };
216
+ }
217
+
218
+ /**
219
+ * Scenarios of finished stories that a later story replaced (ST-094): `docs/stories/superseded-scenarios.json` maps a
220
+ * story ID to its scenario titles and the story that superseded each. A done story is never reopened, but its
221
+ * scenario can stop being true – its test is then removed together with the superseding story's change, and the
222
+ * scenario is listed here instead, with the reason in that story's pull request. `check-scenarios.ts` accepts no test
223
+ * for a listed scenario and refuses a stale entry.
224
+ */
225
+ export const SUPERSEDED_SCENARIOS_FILE = "docs/stories/superseded-scenarios.json";
226
+
227
+ export function supersededScenarios(): Map<string, Map<string, string>> {
228
+ const file = join(d.ROOT, SUPERSEDED_SCENARIOS_FILE);
229
+ if (!existsSync(file)) return new Map();
230
+ const raw = JSON.parse(readFileSync(file, "utf8")) as Record<string, Record<string, string>>;
231
+ return new Map(Object.entries(raw).map(([id, scenarios]) => [id, new Map(Object.entries(scenarios))]));
232
+ }
233
+
234
+ /** Domain IDs (`CMD-ReportProblem`, `EVT-DefectRecorded`, …) used in application code (not tests). */
235
+ export function domainIdsInCode(): Map<string, string[]> {
236
+ const out = new Map<string, string[]>();
237
+ const re = /\b(?:ACT|EXT|BC|AGG|EVT|CMD|POL|RM)-[A-Z][A-Za-z0-9]+\b/g;
238
+ for (const f of projectFiles().filter((f) => isCode(f) && !isTestCode(f))) {
239
+ for (const m of read(f).matchAll(re)) out.set(m[0], [...new Set([...(out.get(m[0]) ?? []), f])]);
240
+ }
241
+ return out;
242
+ }
243
+
244
+ // --------------------------------------------------------------------------
245
+ // Glossary language
246
+ // --------------------------------------------------------------------------
247
+ /**
248
+ * Words from `_Avoid_` lists that are ordinary programming vocabulary. They are only flagged in
249
+ * user-facing text (message catalogs), never in code identifiers.
250
+ */
251
+ const GENERIC_CODE_WORDS = new Set([
252
+ "id", "type", "class", "kind", "state", "error", "format", "tag", "upload", "log", "schedule", "unit",
253
+ "device", "place", "position", "slot", "lock", "note", "comment", "review", "check", "title", "file type",
254
+ ]);
255
+
256
+ export interface Glossary {
257
+ terms: string[];
258
+ avoid: { word: string; preferred: string }[];
259
+ }
260
+
261
+ export function loadGlossary(): Glossary {
262
+ const path = join(d.ROOT, "CONTEXT.md");
263
+ const terms: string[] = [];
264
+ const avoid: { word: string; preferred: string }[] = [];
265
+ if (!existsSync(path)) return { terms, avoid };
266
+ let current = "";
267
+ for (const line of readFileSync(path, "utf8").split("\n")) {
268
+ const t = /^\*\*(.+?)\*\*:?\s*$/.exec(line);
269
+ if (t) {
270
+ current = t[1].replace(/:$/, "").trim();
271
+ terms.push(current.toLowerCase());
272
+ continue;
273
+ }
274
+ const a = /^_Avoid_:\s*(.+)$/.exec(line);
275
+ if (a && current) {
276
+ for (const raw of a[1].replace(/\([^)]*\)/g, "").split(",")) {
277
+ const word = raw.trim().toLowerCase();
278
+ if (word) avoid.push({ word, preferred: current });
279
+ }
280
+ }
281
+ }
282
+ return { terms, avoid };
283
+ }
284
+
285
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
286
+
287
+ /** Avoided words in a piece of text; glossary terms are masked first, so "Problem report" doesn't flag "problem". */
288
+ export function avoidedWords(text: string, g: Glossary, inCode: boolean): { word: string; preferred: string }[] {
289
+ let masked = ` ${text.toLowerCase()} `;
290
+ for (const term of [...g.terms].sort((a, b) => b.length - a.length)) {
291
+ masked = masked.replace(new RegExp(`\\b${esc(term)}s?\\b`, "g"), " ¤ "); // plural too: problemReports
292
+ }
293
+ const hits = g.avoid.filter(({ word }) => {
294
+ if (g.terms.includes(word)) return false; // avoided in one place, the proper term elsewhere
295
+ if (inCode && GENERIC_CODE_WORDS.has(word)) return false;
296
+ return new RegExp(`\\b${esc(word)}s?\\b`).test(masked);
297
+ });
298
+ return [...new Map(hits.map((h) => [h.word, h])).values()];
299
+ }
300
+
301
+ /** `fooBarBaz`, `foo_bar`, `FooBar` → "foo bar baz". */
302
+ export const identifierWords = (id: string): string =>
303
+ id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/[_\-$]+/g, " ").toLowerCase();
304
+
305
+ export interface LanguageFinding {
306
+ file: string;
307
+ where: string;
308
+ word: string;
309
+ preferred: string;
310
+ }
311
+
312
+ /** Language of docs and stories, from the project profile (default English). */
313
+ function artifactLanguage(): string {
314
+ try {
315
+ const profile = JSON.parse(readFileSync(join(d.ROOT, ".claude", "project.json"), "utf8"));
316
+ return String(profile.languages?.artifacts ?? "en").toLowerCase();
317
+ } catch {
318
+ return "en";
319
+ }
320
+ }
321
+
322
+ export function languageFindings(): LanguageFinding[] {
323
+ const g = loadGlossary();
324
+ if (!g.avoid.length) return [];
325
+ // Names from the event storming ("Report problem", "Triage list") are domain language too
326
+ const model = d.loadEventsModel() ?? {};
327
+ for (const key of ["events", "commands", "policies", "read_models", "aggregates"]) {
328
+ for (const item of (model[key] ?? []) as d.Obj[]) if (typeof item.name === "string") g.terms.push(item.name.toLowerCase());
329
+ }
330
+ const out: LanguageFinding[] = [];
331
+ const files = projectFiles();
332
+ // User-facing text in the artifact language: message catalog values (other catalogs use the _UI (<lang>)_ words)
333
+ const language = artifactLanguage();
334
+ for (const f of files.filter((f) => isCatalog(f) && basename(f).toLowerCase().startsWith(language))) {
335
+ let data: unknown;
336
+ try {
337
+ data = JSON.parse(read(f));
338
+ } catch {
339
+ continue;
340
+ }
341
+ const walk = (v: unknown, key: string): void => {
342
+ if (typeof v === "string") {
343
+ for (const h of avoidedWords(v, g, false)) out.push({ file: f, where: key, ...h });
344
+ } else if (v && typeof v === "object") {
345
+ for (const [k, c] of Object.entries(v)) walk(c, key ? `${key}.${k}` : k);
346
+ }
347
+ };
348
+ walk(data, "");
349
+ }
350
+ // Identifiers declared in application code
351
+ const decl = /\b(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
352
+ for (const f of files.filter((f) => isCode(f) && !isTestCode(f))) {
353
+ const lines = read(f).split("\n");
354
+ lines.forEach((line, n) => {
355
+ if (line.includes("language-ok")) return; // explicit exception, e.g. a third-party `user` object
356
+ for (const m of line.matchAll(decl)) {
357
+ for (const h of avoidedWords(identifierWords(m[1]), g, true)) out.push({ file: f, where: `line ${n + 1}: ${m[1]}`, ...h });
358
+ }
359
+ });
360
+ }
361
+ return out;
362
+ }
363
+
364
+ // --------------------------------------------------------------------------
365
+ // Verify state (read by the SessionStart hook)
366
+ // --------------------------------------------------------------------------
367
+ const STATE_FILE = join(d.ROOT, ".claude", "state", "last-verify.json");
368
+
369
+ export interface VerifyState {
370
+ at: string;
371
+ branch: string | null;
372
+ commit: string | null;
373
+ dirty: boolean;
374
+ ok: boolean;
375
+ steps: { name: string; ok: boolean; skipped?: boolean }[];
376
+ }
377
+
378
+ export function writeVerifyState(state: VerifyState): void {
379
+ mkdirSync(dirname(STATE_FILE), { recursive: true });
380
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", "utf8");
381
+ }
382
+
383
+ export function readVerifyState(): VerifyState | null {
384
+ try {
385
+ return JSON.parse(readFileSync(STATE_FILE, "utf8"));
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ // --------------------------------------------------------------------------
392
+ // Application tooling (root package.json – created by ST-001)
393
+ // --------------------------------------------------------------------------
394
+ export function appScripts(): Record<string, string> {
395
+ try {
396
+ return JSON.parse(readFileSync(join(d.ROOT, "package.json"), "utf8")).scripts ?? {};
397
+ } catch {
398
+ return {};
399
+ }
400
+ }
401
+
402
+ /** Path of a binary in the app's node_modules/.bin, or null if not installed. */
403
+ export function appBin(name: string): string | null {
404
+ const p = join(d.ROOT, "node_modules", ".bin", name);
405
+ return existsSync(p) ? p : null;
406
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Nx adapter.
3
+ *
4
+ * Nx already knows the project graph – including inferred targets and projects without a manifest. Instead
5
+ * of re-implementing it, `repo-map.ts` asks once with `nx graph --file=<temp>` (writes JSON, starts no
6
+ * server) and stores the result as an optional `toolGraph`. If Nx is not installed, the command fails, or it
7
+ * times out, the map falls back to manifests.
8
+ *
9
+ * Nx project names need not equal package names (`billing` vs `@acme/billing`). Nodes are resolved by their
10
+ * `root` path, so the graph always speaks in repository package names; the original project names are kept
11
+ * in `projectNames` so `affected.ts` can emit `nx run-many -p …`.
12
+ *
13
+ * Node >= 22.18, erasable TypeScript only, no runtime dependency.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import type { GraphPackage, ToolGraph } from "./toolgraph.ts";
20
+
21
+ const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
22
+
23
+ /** `./apps/a/` → `apps/a`; `""` and `"."` → `"."`. */
24
+ function normalizePackagePath(path: string): string {
25
+ const trimmed = path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
26
+ return trimmed === "" || trimmed === "." ? "." : trimmed;
27
+ }
28
+
29
+ export function nxBinary(root: string): string | undefined {
30
+ const name = process.platform === "win32" ? "nx.cmd" : "nx";
31
+ for (const candidate of [join(root, "node_modules", ".bin", name), join(root, "node_modules", ".bin", "nx")]) {
32
+ if (existsSync(candidate)) return candidate;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Parses the file written by `nx graph --file`. Kept pure so it can be tested without Nx installed.
39
+ * Accepts both the wrapped (`{ graph: { nodes, dependencies } }`) and the bare (`{ nodes, dependencies }`)
40
+ * shape, and dependencies as objects (`{ source, target }`) or plain name strings.
41
+ */
42
+ export function parseNxGraph(data: unknown, packages: GraphPackage[]): ToolGraph | undefined {
43
+ if (!isObject(data)) return undefined;
44
+ const graph = isObject(data.graph) ? data.graph : data;
45
+ const nodes = isObject(graph.nodes) ? graph.nodes : undefined;
46
+ if (!nodes) return undefined;
47
+ const dependencies = isObject(graph.dependencies) ? graph.dependencies : {};
48
+
49
+ const packageByPath = new Map(packages.map((pkg) => [normalizePackagePath(pkg.path), pkg.name]));
50
+ const packageOfProject = new Map<string, string>();
51
+ const projectOfPackage = new Map<string, string>();
52
+
53
+ for (const [key, raw] of Object.entries(nodes)) {
54
+ if (!isObject(raw)) continue;
55
+ const info = isObject(raw.data) ? raw.data : raw;
56
+ const project = typeof raw.name === "string" && raw.name ? raw.name : key;
57
+ const root = typeof info.root === "string" ? info.root : undefined;
58
+ if (!root) continue;
59
+ const name = packageByPath.get(normalizePackagePath(root));
60
+ if (!name) continue;
61
+ packageOfProject.set(project, name);
62
+ packageOfProject.set(key, name);
63
+ if (!projectOfPackage.has(name)) projectOfPackage.set(name, project);
64
+ }
65
+ if (!packageOfProject.size) return undefined;
66
+
67
+ const edges = new Map<string, { from: string; to: string }>();
68
+ const add = (fromProject: unknown, toProject: unknown): void => {
69
+ if (typeof fromProject !== "string" || typeof toProject !== "string") return;
70
+ const from = packageOfProject.get(fromProject);
71
+ const to = packageOfProject.get(toProject);
72
+ if (!from || !to || from === to) return;
73
+ edges.set(`${from}>${to}`, { from, to });
74
+ };
75
+
76
+ for (const [source, list] of Object.entries(dependencies)) {
77
+ if (!Array.isArray(list)) continue;
78
+ for (const item of list) {
79
+ if (typeof item === "string") add(source, item);
80
+ else if (isObject(item)) add(item.source ?? source, item.target);
81
+ }
82
+ }
83
+
84
+ const sorted = [...edges.values()].sort((a, b) => `${a.from}>${a.to}`.localeCompare(`${b.from}>${b.to}`));
85
+ return {
86
+ source: "nx",
87
+ packages: [...new Set(projectOfPackage.keys())].sort(),
88
+ edges: sorted,
89
+ projectNames: Object.fromEntries([...projectOfPackage].sort(([a], [b]) => a.localeCompare(b))),
90
+ };
91
+ }
92
+
93
+ /** Ask Nx for its project graph. Returns undefined when Nx is absent, fails, or does not answer in time. */
94
+ export function queryNxGraph(root: string, packages: GraphPackage[]): ToolGraph | undefined {
95
+ const binary = nxBinary(root);
96
+ if (!binary) return undefined;
97
+
98
+ let dir: string;
99
+ try {
100
+ dir = mkdtempSync(join(tmpdir(), "kataster-nx-"));
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ const file = join(dir, "graph.json");
105
+ try {
106
+ // `graph` is Nx 16+; `dep-graph` is the pre-16 name. Try both, the first answer wins.
107
+ for (const command of ["graph", "dep-graph"]) {
108
+ const result = spawnSync(binary, [command, `--file=${file}`], {
109
+ cwd: root,
110
+ encoding: "utf8",
111
+ stdio: ["ignore", "pipe", "pipe"],
112
+ timeout: 30_000,
113
+ env: { ...process.env, NX_DAEMON: "false", NX_INTERACTIVE: "false", NX_TELEMETRY: "false" },
114
+ });
115
+ if (result.status !== 0 || !existsSync(file)) continue;
116
+ const text = readFileSync(file, "utf8").trim();
117
+ const start = text.indexOf("{");
118
+ const end = text.lastIndexOf("}");
119
+ if (start === -1 || end <= start) continue;
120
+ const graph = parseNxGraph(JSON.parse(text.slice(start, end + 1)), packages);
121
+ if (graph) return graph;
122
+ }
123
+ return undefined;
124
+ } catch {
125
+ return undefined;
126
+ } finally {
127
+ rmSync(dir, { recursive: true, force: true });
128
+ }
129
+ }