@behaghel/pi-domain-tree 0.4.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.
@@ -0,0 +1,1111 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ /**
4
+ * Domain Tree — pi extension
5
+ *
6
+ * Makes pi fluent in domain-driven codebase structure: DDD-informed domain
7
+ * trees with 1:1 spec/code mirroring, subdomain classification, context
8
+ * mapping, and shared kernel management.
9
+ *
10
+ * What this extension provides:
11
+ * • Auto-detects domain-tree projects (domains.yaml)
12
+ * • Injects domain-navigator expertise into the system prompt
13
+ * • Registers ownership, term-resolution, health-check, and coverage tools
14
+ * • Registers commands: /domain-tree:init, /domain-tree:check, /domain-tree:map
15
+ * • Monitors tool calls for spec-on-touch enforcement and cross-domain violations
16
+ *
17
+ * Place in ~/.pi/agent/extensions/ (global) or .pi/extensions/ (per project).
18
+ *
19
+ * For the companion skill (knowledge base), see ../skills/domain-navigator/SKILL.md
20
+ */
21
+
22
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
23
+ import { existsSync } from "fs";
24
+ import { readFile, access, readdir, stat } from "fs/promises";
25
+ import { join, resolve, relative, basename, dirname } from "path";
26
+ import { Type } from "typebox";
27
+ import {
28
+ buildTermIndex,
29
+ entryForResolution,
30
+ findAmbiguousCodeMappings,
31
+ findContextMapIssues,
32
+ flattenDomains,
33
+ parseDomainManifest,
34
+ resolveDomainForFilePath,
35
+ resolveTerm,
36
+ specDirForEntry,
37
+ specLabelForEntry,
38
+ validateNormativeSpecContent,
39
+ validateSpecificationWiki,
40
+ validateSystemSpecContent,
41
+ } from "./domain-core.ts";
42
+ import type { ManifestDiagnostic, WikiDocument } from "./domain-core.ts";
43
+
44
+ // ─── Helpers ────────────────────────────────────────────────
45
+
46
+ /** Check if a path exists (sync, for startup checks). */
47
+ function pathExists(p: string): boolean {
48
+ try {
49
+ return existsSync(p);
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ const DOMAIN_MANIFEST = "domains.yaml";
56
+ const LEGACY_DOMAIN_MANIFEST = join("spec", "domains.yaml");
57
+
58
+ /** Find the project root by looking for domains.yaml from cwd upward. */
59
+ function findDomainRoot(cwd: string): string | null {
60
+ let dir = resolve(cwd);
61
+ for (let i = 0; i < 20; i++) {
62
+ if (pathExists(join(dir, DOMAIN_MANIFEST)) || pathExists(join(dir, LEGACY_DOMAIN_MANIFEST))) {
63
+ return dir;
64
+ }
65
+ const parent = dirname(dir);
66
+ if (parent === dir) break;
67
+ dir = parent;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** Quick check if a directory is a domain-tree project. */
73
+ function isDomainTreeProject(dir: string): boolean {
74
+ return pathExists(join(dir, DOMAIN_MANIFEST)) || pathExists(join(dir, LEGACY_DOMAIN_MANIFEST));
75
+ }
76
+
77
+ function manifestRelPath(root: string): string {
78
+ return pathExists(join(root, DOMAIN_MANIFEST)) ? DOMAIN_MANIFEST : LEGACY_DOMAIN_MANIFEST;
79
+ }
80
+
81
+ function isLegacyManifest(root: string): boolean {
82
+ return !pathExists(join(root, DOMAIN_MANIFEST)) && pathExists(join(root, LEGACY_DOMAIN_MANIFEST));
83
+ }
84
+
85
+ /** Strip YAML frontmatter (--- ... ---) from markdown content. */
86
+ function stripFrontmatter(content: string): string {
87
+ const lines = content.split("\n");
88
+ let firstDash = -1;
89
+ let secondDash = -1;
90
+ for (let i = 0; i < lines.length; i++) {
91
+ if (lines[i].trim() === "---") {
92
+ if (firstDash === -1) {
93
+ firstDash = i;
94
+ } else {
95
+ secondDash = i;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ if (firstDash !== -1 && secondDash !== -1) {
101
+ return lines.slice(secondDash + 1).join("\n").trim();
102
+ }
103
+ return content;
104
+ }
105
+
106
+ /** Parse domains.yaml without allowing partial ownership results. */
107
+ async function tryLoadDomains(root: string): Promise<{
108
+ domains: Record<string, any> | null;
109
+ diagnostics: ManifestDiagnostic[];
110
+ }> {
111
+ try {
112
+ const content = await readFile(join(root, manifestRelPath(root)), "utf-8");
113
+ return parseDomainManifest(content);
114
+ } catch (error) {
115
+ return {
116
+ domains: null,
117
+ diagnostics: [{
118
+ path: manifestRelPath(root),
119
+ message: error instanceof Error ? error.message : String(error),
120
+ }],
121
+ };
122
+ }
123
+ }
124
+
125
+ function formatManifestDiagnostics(diagnostics: ManifestDiagnostic[]): string {
126
+ return diagnostics.map((diagnostic) => {
127
+ const location = diagnostic.line && diagnostic.column
128
+ ? ` (line ${diagnostic.line}, column ${diagnostic.column})`
129
+ : "";
130
+ return `- \`${diagnostic.path}\`: ${diagnostic.message}${location}`;
131
+ }).join("\n");
132
+ }
133
+
134
+ async function collectMarkdownFiles(absolutePath: string): Promise<string[]> {
135
+ const pathStat = await stat(absolutePath);
136
+ if (pathStat.isFile()) {
137
+ if (!absolutePath.endsWith(".md")) {
138
+ throw new Error("System-spec files must use the `.md` extension.");
139
+ }
140
+ return [absolutePath];
141
+ }
142
+ if (!pathStat.isDirectory()) {
143
+ throw new Error("System-spec paths must identify a Markdown file or directory.");
144
+ }
145
+ const markdownPaths: string[] = [];
146
+ for (const entry of await readdir(absolutePath, { withFileTypes: true })) {
147
+ const childPath = join(absolutePath, entry.name);
148
+ if (entry.isDirectory()) {
149
+ markdownPaths.push(...await collectMarkdownFiles(childPath));
150
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
151
+ markdownPaths.push(childPath);
152
+ }
153
+ }
154
+ return markdownPaths;
155
+ }
156
+
157
+ async function collectDomainWikiDocuments(
158
+ root: string,
159
+ domains: Record<string, any>,
160
+ ): Promise<WikiDocument[]> {
161
+ const documents: WikiDocument[] = [];
162
+ for (const node of flattenDomains(domains)) {
163
+ const specDir = specDirForEntry(root, node.entry);
164
+ if (!specDir) continue;
165
+ let files: string[];
166
+ try {
167
+ files = await readdir(specDir);
168
+ } catch {
169
+ continue;
170
+ }
171
+ for (const file of files.filter((name) => name.endsWith(".md"))) {
172
+ const content = await readFile(join(specDir, file), "utf-8");
173
+ if (file !== "README.md" && !content.startsWith("---\n")) continue;
174
+ documents.push({
175
+ path: relative(root, join(specDir, file)),
176
+ content,
177
+ domain: node.path.join("/"),
178
+ });
179
+ }
180
+ }
181
+ return documents;
182
+ }
183
+
184
+ /** Determine which domain a file path belongs to from the domain manifest. */
185
+ async function resolveDomainForFile(filePath: string, root: string, domains: Record<string, any>): Promise<{ domain: string; subdomain: string | null; type: string } | null> {
186
+ return resolveDomainForFilePath(filePath, root, domains);
187
+ }
188
+
189
+ /** Check if a spec exists for a given domain. */
190
+ async function specExistsForDomain(root: string, domains: Record<string, any>, domainName: string, subdomain: string | null): Promise<boolean> {
191
+ const entry = entryForResolution(domains, { domain: domainName, subdomain, type: "supporting" });
192
+ const specDir = entry ? specDirForEntry(root, entry) : null;
193
+ if (!specDir) return false;
194
+ try {
195
+ await access(specDir);
196
+ const readme = await readFile(join(specDir, "README.md"), "utf-8");
197
+ const expectedDomain = [
198
+ domainName,
199
+ ...(subdomain ? subdomain.split(" > ") : []),
200
+ ].join("/");
201
+ return validateNormativeSpecContent(readme, expectedDomain, true).length === 0;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /** Get domain type for a given domain name from the parsed manifest. */
208
+ function getDomainType(domains: Record<string, any>, domainName: string): string {
209
+ return domains[domainName]?.type || "supporting";
210
+ }
211
+
212
+ // List of command patterns that look like project commands
213
+ const PROJECT_COMMAND_PATTERNS = [
214
+ /^npm\s+(run|test|build|dev|start|lint|check)\b/,
215
+ /^npx\b/,
216
+ /^tsx\b/,
217
+ /^vitest\b/,
218
+ /^vite\b/,
219
+ /^python\s+-m\b/,
220
+ /^alembic\b/,
221
+ /^django-admin\b/,
222
+ /^cargo\s+(build|test|run|check)\b/,
223
+ /^go\s+(build|test|run)\b/,
224
+ /^direnv\s+allow\b/,
225
+ ];
226
+
227
+ // ─── Extension ──────────────────────────────────────────────
228
+
229
+ export default function domainTreeExtension(pi: ExtensionAPI) {
230
+ let domainRoot: string | null = null;
231
+ let isActive = false;
232
+ let domainsCache: Record<string, any> | null = null;
233
+ let manifestDiagnostics: ManifestDiagnostic[] = [];
234
+
235
+ // ─── Status helpers ────────────────────────────────────────
236
+
237
+ function updateStatus(ctx?: { ui: { setStatus: (key: string, val?: string) => void } }) {
238
+ if (!ctx) return;
239
+ if (isActive && domainRoot) {
240
+ ctx.ui.setStatus("domain-tree", `🌳 ${basename(domainRoot)}`);
241
+ } else {
242
+ ctx.ui.setStatus("domain-tree", undefined);
243
+ }
244
+ }
245
+
246
+ /** Refresh the domain cache. */
247
+ async function refreshDomainCache() {
248
+ if (domainRoot) {
249
+ const result = await tryLoadDomains(domainRoot);
250
+ domainsCache = result.domains;
251
+ manifestDiagnostics = result.diagnostics;
252
+ } else {
253
+ domainsCache = null;
254
+ manifestDiagnostics = [];
255
+ }
256
+ }
257
+
258
+ function invalidManifestResult() {
259
+ return {
260
+ content: [{
261
+ type: "text" as const,
262
+ text: "`domains.yaml` is invalid. Ownership results are unavailable until these issues are fixed:\n\n" +
263
+ formatManifestDiagnostics(manifestDiagnostics),
264
+ }],
265
+ details: { valid: false, diagnostics: manifestDiagnostics },
266
+ };
267
+ }
268
+
269
+ /** Lazily rediscover the domain root for tools/commands after init in the same session. */
270
+ async function ensureDomainRoot(ctx?: any) {
271
+ if (!domainRoot && ctx?.cwd) {
272
+ domainRoot = findDomainRoot(ctx.cwd);
273
+ isActive = domainRoot !== null && isDomainTreeProject(domainRoot);
274
+ }
275
+ if (domainRoot) {
276
+ await refreshDomainCache();
277
+ }
278
+ if (ctx?.ui) updateStatus(ctx);
279
+ return domainRoot !== null && domainsCache !== null;
280
+ }
281
+
282
+ // ─── Session start: detect domain-tree project ─────────────
283
+
284
+ pi.on("session_start", async (_event, ctx) => {
285
+ domainRoot = findDomainRoot(ctx.cwd);
286
+ isActive = domainRoot !== null && isDomainTreeProject(domainRoot);
287
+ if (isActive) {
288
+ await refreshDomainCache();
289
+ }
290
+ updateStatus(ctx);
291
+ });
292
+
293
+ pi.on("session_tree", async (_event, ctx) => {
294
+ domainRoot = findDomainRoot(ctx.cwd);
295
+ isActive = domainRoot !== null && isDomainTreeProject(domainRoot);
296
+ if (isActive) {
297
+ await refreshDomainCache();
298
+ }
299
+ updateStatus(ctx);
300
+ });
301
+
302
+ pi.on("session_shutdown", async (_event, ctx) => {
303
+ ctx.ui.setStatus("domain-tree", undefined);
304
+ });
305
+
306
+ // ─── System prompt: inject domain-navigator expertise ──────
307
+
308
+ pi.on("before_agent_start", async (event) => {
309
+ if (!isActive) return;
310
+ if (manifestDiagnostics.length > 0) {
311
+ return {
312
+ systemPrompt: `${event.systemPrompt}\n\n## Invalid Domain Manifest\n\nDomain enforcement is unavailable until \`domains.yaml\` is fixed:\n${formatManifestDiagnostics(manifestDiagnostics)}`,
313
+ };
314
+ }
315
+
316
+ const domainExpertise = `
317
+ ## Domain Tree Environment
318
+
319
+ This project uses a domain-driven codebase structure defined in \`domains.yaml\`.
320
+ The domain tree encodes three things:
321
+
322
+ 1. **Where things live** — domain specs are colocated with the code they govern
323
+ 2. **How much rigor each domain deserves** — core vs supporting vs generic classification
324
+ 3. **How domains communicate** — the context map declaring integration patterns
325
+
326
+ ### Core rules
327
+ - **Structural groups** — entries with \`kind: group\` organize nested \`domains\` but own no code, specs, classification, or context contracts.
328
+ - **Colocated specs** — domain specs live next to code. The first \`code\` path is the default spec directory; \`README.md\` is the required main domain spec.
329
+ - **Normative corpora** — Domain specs are the default. Declared \`system-specs\` hold only durable RFC 2119 requirements that cannot belong to one domain. Iteration specs are temporary and non-normative.
330
+ - **Specification DRY** — Define each concept, invariant, or contract once at its narrowest owner; other specs link to it and state only local consequences.
331
+ - **Specification wiki** — Use relative Markdown links. Canonical terms and aliases have one repository-wide owner; use \`domain_tree_resolve_term\` and link meaningful first occurrences to that page.
332
+ - **Timeless specifications** — Normative specs contain present-tense durable behavior, never roadmaps, rollout or migration plans, progress, delivery metadata, verification plans, legacy comparisons, or temporary workarounds.
333
+ - **Subsidiarity** — the most-specific matching child path owns a file. Parent/child overlap is valid; unrelated domains may not claim the same path.
334
+ - **Spec-on-touch** — The first time you modify a domain, write its spec. Rigor scales with classification:
335
+ - **core**: spec required before any code change (hard block)
336
+ - **shared-kernel**: spec required, all consumers notified (hard block)
337
+ - **supporting**: warning when missing (soft)
338
+ - **generic**: only when integration boundary changes
339
+ - **Cross-domain awareness** — Always consult the context map when work spans domains.
340
+ Use the declared integration pattern (shared-kernel, customer-supplier, ACL, conformist, etc.) to guide implementation.
341
+ - **Classify before coding** — Domain type (core/supporting/generic/shared-kernel) determines spec and test rigor.
342
+
343
+ ### Key tools
344
+ | Action | Tool |
345
+ |--------|------|
346
+ | Resolve which domain owns a file | \`domain_tree_resolve\` |
347
+ | Resolve canonical term or alias ownership | \`domain_tree_resolve_term\` |
348
+ | Validate domain tree structure | \`domain_tree_check\` |
349
+ | Show domain coverage dashboard | \`domain_tree_map\` |
350
+
351
+ ### Key commands
352
+ | Action | Command |
353
+ |--------|---------|
354
+ | Bootstrap domain tree | \`/domain-tree:init\` |
355
+ | Health check | \`/domain-tree:check\` |
356
+ | Coverage dashboard | \`/domain-tree:map\` |
357
+
358
+ ### Configuration files
359
+ | File | Role |
360
+ |------|------|
361
+ | \`domains.yaml\` | Domain tree manifest (source of truth) |
362
+ | \`<domain code path>/README.md\` | Main domain specification |
363
+ | \`<domain code path>/*.md\` with normative frontmatter | Cohesive behavior and term specifications |
364
+
365
+ For detailed reference, load the \`domain-navigator\` skill.
366
+ `;
367
+
368
+ return {
369
+ systemPrompt: `${event.systemPrompt}\n${domainExpertise}`,
370
+ };
371
+ });
372
+
373
+ // ─── Tool monitoring: spec-on-touch & cross-domain enforcement ──
374
+
375
+ pi.on("tool_call", async (event, ctx) => {
376
+ const hasDomainTree = await ensureDomainRoot(ctx);
377
+ if (!hasDomainTree || !domainRoot || !domainsCache) return;
378
+
379
+ if (event.toolName === "write" || event.toolName === "edit") {
380
+ const input = event.input as { path?: string; command?: string };
381
+ const targetPath = input?.path || "";
382
+
383
+ // Try to resolve which domain this touches
384
+ const resolved = await resolveDomainForFile(targetPath, domainRoot, domainsCache);
385
+ if (!resolved) return; // not in any domain
386
+
387
+ // Check spec-on-touch for core domains
388
+ const domainType = resolved.type;
389
+ if (domainType === "core" || domainType === "shared-kernel") {
390
+ const hasSpec = await specExistsForDomain(domainRoot, domainsCache, resolved.domain, resolved.subdomain);
391
+ if (!hasSpec) {
392
+ const domainLabel = resolved.subdomain
393
+ ? `${resolved.domain} > ${resolved.subdomain}`
394
+ : resolved.domain;
395
+
396
+ if (domainType === "core") {
397
+ return {
398
+ block: true,
399
+ reason:
400
+ `⚠️ **Domain boundary violation: ${domainLabel}** (type: **core**)\n\n` +
401
+ `This is a **core** domain — it is the competitive advantage of the project. ` +
402
+ `No spec exists for this domain yet. **Spec required before any code change.**\n\n` +
403
+ `Use \`/domain-tree:init\` to scaffold the domain tree, or run \`/domain-tree:check\` to see the current state.`,
404
+ };
405
+ }
406
+
407
+ if (domainType === "shared-kernel") {
408
+ return {
409
+ block: true,
410
+ reason:
411
+ `⚠️ **Domain boundary violation: ${domainLabel}** (type: **shared-kernel**)\n\n` +
412
+ `This is **shared kernel** — changes affect ALL consuming domains. ` +
413
+ `No spec exists yet. **Spec required and all consumers must be notified.**\n\n` +
414
+ `Check the context map in \`domains.yaml\` for which domains depend on this.`,
415
+ };
416
+ }
417
+ }
418
+ }
419
+ }
420
+ });
421
+
422
+ // ─── Custom tools ──────────────────────────────────────────
423
+
424
+ // Tool: domain_tree_resolve — resolve which domain owns a given file path
425
+ pi.registerTool({
426
+ name: "domain_tree_resolve",
427
+ label: "Domain Tree Resolve",
428
+ description:
429
+ "Resolve which domain and subdomain own a given file path, based on domains.yaml. " +
430
+ "Returns the domain name, subdomain (if any), and domain type (core/supporting/generic/shared-kernel). " +
431
+ "Use this before creating new files to ensure they land in the correct domain namespace.",
432
+ promptSnippet: "Resolve domain ownership for a file path",
433
+ promptGuidelines: [
434
+ "Before creating new files, use domain_tree_resolve to check which domain owns the target path.",
435
+ "Use domain_tree_resolve when the user asks 'where does X live?' or 'what domain owns Y?'.",
436
+ ],
437
+ parameters: Type.Object({
438
+ path: Type.String({
439
+ description: "File path to resolve (relative to project root)",
440
+ }),
441
+ }),
442
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
443
+ await ensureDomainRoot(ctx);
444
+ if (domainRoot && manifestDiagnostics.length > 0) {
445
+ return invalidManifestResult();
446
+ }
447
+ if (!domainRoot || !domainsCache) {
448
+ return {
449
+ content: [
450
+ {
451
+ type: "text",
452
+ text: "No domain tree found. Run `/domain-tree:init` to create one, or check that `domains.yaml` exists.",
453
+ },
454
+ ],
455
+ };
456
+ }
457
+
458
+ const resolved = await resolveDomainForFile(params.path, domainRoot, domainsCache);
459
+ if (!resolved) {
460
+ return {
461
+ content: [
462
+ {
463
+ type: "text",
464
+ text: `**${params.path}** is not covered by any domain in \`domains.yaml\`. ` +
465
+ "Should we add it to an existing domain or create a new one?",
466
+ },
467
+ ],
468
+ details: { covered: false, path: params.path },
469
+ };
470
+ }
471
+
472
+ const domainLabel = resolved.subdomain
473
+ ? `${resolved.domain} > ${resolved.subdomain}`
474
+ : resolved.domain;
475
+
476
+ const typeEmoji: Record<string, string> = {
477
+ core: "🔴",
478
+ supporting: "🟡",
479
+ generic: "🟢",
480
+ "shared-kernel": "🔵",
481
+ };
482
+
483
+ const specEntry = entryForResolution(domainsCache, resolved);
484
+ const specDir = specEntry ? specLabelForEntry(specEntry) || "(no spec path; add a code path or spec override)" : "(unknown)";
485
+
486
+ return {
487
+ content: [
488
+ {
489
+ type: "text",
490
+ text: `📍 **${params.path}** is in the **${domainLabel}** namespace\n` +
491
+ ` Type: ${typeEmoji[resolved.type] || "🟡"} **${resolved.type}**\n` +
492
+ ` Spec at: \`${specDir}\``,
493
+ },
494
+ ],
495
+ details: {
496
+ covered: true,
497
+ domain: resolved.domain,
498
+ subdomain: resolved.subdomain,
499
+ type: resolved.type,
500
+ },
501
+ };
502
+ },
503
+ });
504
+
505
+ // Tool: domain_tree_resolve_term — resolve canonical ubiquitous-language ownership
506
+ pi.registerTool({
507
+ name: "domain_tree_resolve_term",
508
+ label: "Domain Tree Resolve Term",
509
+ description:
510
+ "Resolve an exact canonical ubiquitous-language term or alias to its owning domain and normative page.",
511
+ promptSnippet: "Resolve a canonical domain term or alias",
512
+ promptGuidelines: [
513
+ "Use domain_tree_resolve_term before linking a ubiquitous-language term across domain specifications.",
514
+ ],
515
+ parameters: Type.Object({
516
+ term: Type.String({ description: "Canonical term or alias to resolve" }),
517
+ }),
518
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
519
+ await ensureDomainRoot(ctx);
520
+ if (domainRoot && manifestDiagnostics.length > 0) {
521
+ return invalidManifestResult();
522
+ }
523
+ if (!domainRoot || !domainsCache) {
524
+ return {
525
+ content: [{ type: "text", text: "No domain tree found. Run `/domain-tree:init` to create one." }],
526
+ };
527
+ }
528
+
529
+ const documents = await collectDomainWikiDocuments(domainRoot, domainsCache);
530
+ const index = buildTermIndex(documents);
531
+ if (index.diagnostics.length > 0) {
532
+ return {
533
+ content: [{
534
+ type: "text",
535
+ text: "The ubiquitous-language index is ambiguous:\n\n" +
536
+ index.diagnostics.map((issue) => `- \`${issue.path}\`: ${issue.message}`).join("\n"),
537
+ }],
538
+ details: { resolved: false, diagnostics: index.diagnostics },
539
+ };
540
+ }
541
+
542
+ const resolved = resolveTerm(index, params.term);
543
+ if (!resolved) {
544
+ return {
545
+ content: [{
546
+ type: "text",
547
+ text: `No canonical ubiquitous-language term or alias matches **${params.term}**.`,
548
+ }],
549
+ details: { resolved: false, term: params.term },
550
+ };
551
+ }
552
+
553
+ return {
554
+ content: [{
555
+ type: "text",
556
+ text: `**${params.term}** resolves to **${resolved.canonicalTerm}**\n` +
557
+ ` Domain: \`${resolved.domain}\`\n` +
558
+ ` Canonical page: \`${resolved.path}\`` +
559
+ (resolved.matchedAlias ? `\n Matched alias: \`${resolved.matchedAlias}\`` : ""),
560
+ }],
561
+ details: { resolved: true, ...resolved },
562
+ };
563
+ },
564
+ });
565
+
566
+ // Tool: domain_tree_check — validate domain tree structure vs codebase
567
+ pi.registerTool({
568
+ name: "domain_tree_check",
569
+ label: "Domain Tree Check",
570
+ description:
571
+ "Validate that the domain tree in domains.yaml matches the actual codebase. " +
572
+ "Reports broken mappings, orphaned code, missing spec directories, code-paths quality issues, " +
573
+ "context map health, and classification consistency. Use this for periodic structural health checks.",
574
+ promptSnippet: "Validate domain tree structure against codebase",
575
+ promptGuidelines: [
576
+ "Use domain_tree_check periodically to verify the domain tree matches the actual codebase.",
577
+ "Run domain_tree_check before major refactors to understand current domain boundaries.",
578
+ ],
579
+ parameters: Type.Object({
580
+ detailed: Type.Optional(
581
+ Type.Boolean({
582
+ description: "If true, also check context map health and code-paths quality (slower). Default: false.",
583
+ }),
584
+ ),
585
+ }),
586
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
587
+ await ensureDomainRoot(ctx);
588
+ if (domainRoot && manifestDiagnostics.length > 0) {
589
+ return invalidManifestResult();
590
+ }
591
+ if (!domainRoot || !domainsCache) {
592
+ return {
593
+ content: [{ type: "text", text: "No domain tree found. Run `/domain-tree:init` to create one." }],
594
+ };
595
+ }
596
+
597
+ const results: string[] = [];
598
+ let issues = 0;
599
+ let passed = 0;
600
+
601
+ results.push("## Domain Tree Health Check");
602
+ results.push(`*Manifest: \`${manifestRelPath(domainRoot)}\`*`);
603
+ results.push("");
604
+
605
+ if (isLegacyManifest(domainRoot)) {
606
+ results.push("### 🔁 Migration needed");
607
+ results.push("This project still uses the legacy `spec/domains.yaml` layout.");
608
+ results.push("Move `spec/domains.yaml` to `domains.yaml`, then move each domain's markdown files next to the code it governs.");
609
+ results.push("Use `README.md` as the main domain spec instead of `index.md`.");
610
+ results.push("");
611
+ results.push("Suggested migration:");
612
+ results.push("1. `mv spec/domains.yaml domains.yaml`");
613
+ results.push("2. For each domain, choose the first `code:` directory (for example `src/payments/`) as the spec directory.");
614
+ results.push("3. Move `spec/<domain>/index.md` to `<code path>/README.md`; move other `*.md` files into the same colocated directory.");
615
+ results.push("4. Update any explicit `spec:` fields in `domains.yaml` to the new colocated path, or remove them to infer from `code:`.");
616
+ results.push("5. Delete the old `spec/` tree once empty.");
617
+ results.push("");
618
+ }
619
+
620
+ // Step 1: Check declared paths exist
621
+ results.push("### 📁 Mappings: manifest → codebase");
622
+ const nodes = flattenDomains(domainsCache);
623
+ for (const node of nodes) {
624
+ const label = node.path.join(" > ");
625
+ for (const cp of node.entry.code || []) {
626
+ const cpNorm = cp.replace(/\/+$/, "");
627
+ const fullPath = join(domainRoot!, cpNorm);
628
+ try {
629
+ await stat(fullPath);
630
+ passed++;
631
+ } catch {
632
+ results.push(` ⚠ Domain **${label}** declares \`${cp}\` but it doesn't exist`);
633
+ issues++;
634
+ }
635
+ }
636
+
637
+ const specDir = specDirForEntry(domainRoot, node.entry);
638
+ const specLabel = specLabelForEntry(node.entry);
639
+ if (specDir && specLabel) {
640
+ try {
641
+ await stat(specDir);
642
+ passed++;
643
+ } catch {
644
+ results.push(` ⚠ Domain **${label}** has no spec directory at \`${specLabel}\``);
645
+ issues++;
646
+ }
647
+ }
648
+ }
649
+
650
+ if (issues === 0 && passed > 0) {
651
+ results.push(" ✅ All declared paths exist");
652
+ }
653
+
654
+ results.push("");
655
+ results.push("### 📚 Normative documentation");
656
+ let documentationIssues = 0;
657
+ const wikiDocuments: WikiDocument[] = [];
658
+ for (const node of nodes) {
659
+ const specDir = specDirForEntry(domainRoot, node.entry);
660
+ if (!specDir) continue;
661
+ let files: string[];
662
+ try {
663
+ files = await readdir(specDir);
664
+ } catch {
665
+ continue;
666
+ }
667
+ const markdownFiles = files.filter((file: string) => file.endsWith(".md"));
668
+ if (!markdownFiles.includes("README.md")) markdownFiles.unshift("README.md");
669
+ for (const file of markdownFiles) {
670
+ let content = "";
671
+ try {
672
+ content = await readFile(join(specDir, file), "utf-8");
673
+ } catch {
674
+ // Missing README.md is reported by the validator below.
675
+ }
676
+ if (file === "README.md" || content.startsWith("---\n")) {
677
+ wikiDocuments.push({
678
+ path: relative(domainRoot, join(specDir, file)),
679
+ content,
680
+ domain: node.path.join("/"),
681
+ });
682
+ }
683
+ for (const issue of validateNormativeSpecContent(
684
+ content,
685
+ node.path.join("/"),
686
+ file === "README.md",
687
+ )) {
688
+ results.push(` ⚠ **${node.path.join(" > ")}** \`${file}\`: ${issue}`);
689
+ issues++;
690
+ documentationIssues++;
691
+ }
692
+ }
693
+ }
694
+ if (documentationIssues === 0) {
695
+ results.push(" ✅ README.md and normative frontmatter are consistent");
696
+ }
697
+
698
+ const systemSpecPaths = domainsCache._systemSpecs || [];
699
+ if (systemSpecPaths.length > 0) {
700
+ results.push("");
701
+ results.push("### 📜 System specifications");
702
+ const systemName = domainsCache._project?.name || "";
703
+ const markdownPaths = new Set<string>();
704
+ let systemIssues = 0;
705
+
706
+ for (const declaredPath of systemSpecPaths) {
707
+ const absolutePath = resolve(domainRoot, declaredPath);
708
+ const relativePath = relative(domainRoot, absolutePath);
709
+ if (relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
710
+ results.push(` ⚠ System-spec path \`${declaredPath}\` escapes the project root.`);
711
+ issues++;
712
+ systemIssues++;
713
+ continue;
714
+ }
715
+ try {
716
+ for (const markdownPath of await collectMarkdownFiles(absolutePath)) {
717
+ markdownPaths.add(markdownPath);
718
+ }
719
+ } catch (error) {
720
+ results.push(
721
+ ` ⚠ System-spec path \`${declaredPath}\`: ${error instanceof Error ? error.message : String(error)}`,
722
+ );
723
+ issues++;
724
+ systemIssues++;
725
+ }
726
+ }
727
+
728
+ if (markdownPaths.size === 0) {
729
+ results.push(" ⚠ Declared system-spec paths contain no Markdown files.");
730
+ issues++;
731
+ systemIssues++;
732
+ }
733
+ for (const absolutePath of [...markdownPaths].sort()) {
734
+ const label = relative(domainRoot, absolutePath);
735
+ const content = await readFile(absolutePath, "utf-8");
736
+ wikiDocuments.push({ path: label, content, system: systemName });
737
+ for (const issue of validateSystemSpecContent(content, systemName)) {
738
+ results.push(` ⚠ \`${label}\`: ${issue}`);
739
+ issues++;
740
+ systemIssues++;
741
+ }
742
+ }
743
+ if (markdownPaths.size > 0 && systemIssues === 0) {
744
+ results.push(` ✅ ${markdownPaths.size} system specification(s) are valid`);
745
+ }
746
+ }
747
+
748
+ results.push("");
749
+ results.push("### 🔤 Specification wiki");
750
+ const wikiIssues = await validateSpecificationWiki(
751
+ domainRoot,
752
+ wikiDocuments,
753
+ );
754
+ for (const issue of wikiIssues) {
755
+ results.push(` ⚠ \`${issue.path}\`: ${issue.message}`);
756
+ issues++;
757
+ }
758
+ if (wikiIssues.length === 0) {
759
+ const termCount = buildTermIndex(wikiDocuments).owners.length;
760
+ results.push(
761
+ ` ✅ Links are valid and ${termCount} canonical term(s) have unique owners`,
762
+ );
763
+ }
764
+
765
+ // Step 2: Classification consistency
766
+ results.push("");
767
+ results.push("### 🏷️ Classification check");
768
+ for (const node of nodes) {
769
+ const label = node.path.join(" > ");
770
+ if (!node.entry.type && node.path.length === 1) {
771
+ results.push(` ⚠ Domain **${label}** has no type field`);
772
+ issues++;
773
+ } else {
774
+ passed++;
775
+ }
776
+ }
777
+ if (issues === 0) {
778
+ results.push(" ✅ All domains have a type");
779
+ }
780
+
781
+ // Step 3: Detailed checks
782
+ if (params.detailed) {
783
+ const ambiguousMappings = findAmbiguousCodeMappings(domainsCache);
784
+ if (ambiguousMappings.length > 0) {
785
+ results.push("");
786
+ results.push("### 🧭 Mapping ambiguity");
787
+ for (const issue of ambiguousMappings) {
788
+ results.push(` ⚠ ${issue}`);
789
+ issues++;
790
+ }
791
+ }
792
+
793
+ if (domainsCache._contextMap) {
794
+ results.push("");
795
+ results.push("### 🔗 Context map");
796
+ const contextIssues = findContextMapIssues(domainsCache);
797
+ let contextSectionIssues = contextIssues.length;
798
+ for (const issue of contextIssues) {
799
+ results.push(` ⚠ ${issue}`);
800
+ issues++;
801
+ }
802
+ for (const relationship of domainsCache._contextEntries || []) {
803
+ if (!relationship.contract) continue;
804
+ try {
805
+ await stat(join(domainRoot!, relationship.contract));
806
+ passed++;
807
+ } catch {
808
+ results.push(
809
+ ` ⚠ Context contract \`${relationship.contract}\` for **${relationship.provider}** does not exist.`,
810
+ );
811
+ issues++;
812
+ contextSectionIssues++;
813
+ }
814
+ }
815
+ if (contextSectionIssues === 0 && (domainsCache._contextEntries || []).length > 0) {
816
+ results.push(" ✅ Providers, consumers, patterns, and contracts are valid");
817
+ } else if ((domainsCache._contextEntries || []).length === 0) {
818
+ results.push(
819
+ " ⚠ Context map uses a legacy shape; declare `provider`, `consumers`, `pattern`, and `contract`.",
820
+ );
821
+ issues++;
822
+ }
823
+ }
824
+ }
825
+
826
+ // Summary
827
+ results.push("");
828
+ if (issues === 0) {
829
+ results.push("**✅ All checks passed.** The domain tree looks healthy.");
830
+ } else {
831
+ results.push(`**⚠ ${issues} issue(s) found.** Review the items above and fix as needed.`);
832
+ }
833
+
834
+ return {
835
+ content: [{ type: "text", text: results.join("\n") }],
836
+ details: { issues, passed, root: domainRoot },
837
+ };
838
+ },
839
+ });
840
+
841
+ // Tool: domain_tree_map — show domain coverage dashboard
842
+ pi.registerTool({
843
+ name: "domain_tree_map",
844
+ label: "Domain Tree Map",
845
+ description:
846
+ "Show the current state of the domain tree — what's specced, what's tested, and what's stale. " +
847
+ "Displays a coverage dashboard with domain types and context map visualization.",
848
+ promptSnippet: "Show domain tree coverage dashboard",
849
+ promptGuidelines: [
850
+ "Use domain_tree_map to get a quick overview of domain coverage before starting work.",
851
+ "Check the map to understand which domains are core (most rigorous) vs generic (least).",
852
+ ],
853
+ parameters: Type.Object({}),
854
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
855
+ await ensureDomainRoot(ctx);
856
+ if (domainRoot && manifestDiagnostics.length > 0) {
857
+ return invalidManifestResult();
858
+ }
859
+ if (!domainRoot || !domainsCache) {
860
+ return {
861
+ content: [{ type: "text", text: "No domain tree found. Run `/domain-tree:init` to create one." }],
862
+ };
863
+ }
864
+
865
+ const typeEmoji: Record<string, string> = {
866
+ core: "🔴",
867
+ supporting: "🟡",
868
+ generic: "🟢",
869
+ "shared-kernel": "🔵",
870
+ };
871
+
872
+ const rows: string[] = [];
873
+ rows.push("## Domain Tree");
874
+ rows.push(`*Generated from \`${manifestRelPath(domainRoot)}\` at ${basename(domainRoot)}*`);
875
+ rows.push("");
876
+ rows.push(`| Domain | Type | Specs | Status |`);
877
+ rows.push(`|--------|------|-------|--------|`);
878
+
879
+ for (const node of flattenDomains(domainsCache)) {
880
+ const type = node.type || "supporting";
881
+ const emoji = typeEmoji[type] || "🟡";
882
+ const typeLabel = `${emoji} ${type}`;
883
+ const label = node.path.length === 1 ? `**${node.path[0]}**` : node.path.join(" > ");
884
+
885
+ let specStatus = "❌ none";
886
+ const specDir = specDirForEntry(domainRoot, node.entry);
887
+ try {
888
+ const files = specDir ? await readdir(specDir) : [];
889
+ const mdFiles = files.filter((file: string) => file.endsWith(".md"));
890
+ let normativeCount = 0;
891
+ for (const file of mdFiles) {
892
+ const content = await readFile(join(specDir!, file), "utf-8");
893
+ const isReadme = file === "README.md";
894
+ if (!isReadme && !content.startsWith("---\n")) continue;
895
+ if (
896
+ validateNormativeSpecContent(
897
+ content,
898
+ node.path.join("/"),
899
+ isReadme,
900
+ ).length === 0
901
+ ) {
902
+ normativeCount++;
903
+ }
904
+ }
905
+ specStatus = normativeCount > 0 ? `✅ ${normativeCount}` : "❌ none";
906
+ } catch {
907
+ specStatus = "❌ none";
908
+ }
909
+
910
+ rows.push(`| ${label} | ${typeLabel} | ${specStatus} | |`);
911
+ }
912
+
913
+ const systemSpecPaths = domainsCache._systemSpecs || [];
914
+ if (systemSpecPaths.length > 0) {
915
+ const systemFiles = new Set<string>();
916
+ for (const declaredPath of systemSpecPaths) {
917
+ try {
918
+ for (const markdownPath of await collectMarkdownFiles(
919
+ resolve(domainRoot, declaredPath),
920
+ )) {
921
+ systemFiles.add(markdownPath);
922
+ }
923
+ } catch {
924
+ // The health check reports path diagnostics; the map shows zero coverage.
925
+ }
926
+ }
927
+ let validSystemSpecs = 0;
928
+ for (const file of systemFiles) {
929
+ const content = await readFile(file, "utf-8");
930
+ if (
931
+ validateSystemSpecContent(
932
+ content,
933
+ domainsCache._project?.name || "",
934
+ ).length === 0
935
+ ) {
936
+ validSystemSpecs++;
937
+ }
938
+ }
939
+ rows.push("");
940
+ rows.push(
941
+ `**System specifications:** ${validSystemSpecs}/${systemFiles.size} valid`,
942
+ );
943
+ }
944
+
945
+ const termIndex = buildTermIndex(
946
+ await collectDomainWikiDocuments(domainRoot, domainsCache),
947
+ );
948
+ rows.push("");
949
+ rows.push(
950
+ `**Specification wiki:** ${termIndex.owners.length} canonical term(s), ${termIndex.diagnostics.length} ownership conflict(s)`,
951
+ );
952
+
953
+ // Context map section
954
+ if (domainsCache._contextMap) {
955
+ rows.push("");
956
+ rows.push("### 🔗 Context Map");
957
+ rows.push("```");
958
+ rows.push(domainsCache._contextMap.trim());
959
+ rows.push("```");
960
+ }
961
+
962
+ // Legend
963
+ rows.push("");
964
+ rows.push("**Legend:** 🔴 core 🟡 supporting 🟢 generic 🔵 shared-kernel");
965
+
966
+ return {
967
+ content: [{ type: "text", text: rows.join("\n") }],
968
+ details: { root: domainRoot },
969
+ };
970
+ },
971
+ });
972
+
973
+ // ─── Commands ──────────────────────────────────────────────
974
+
975
+ /**
976
+ * /domain-tree:init — Bootstrap the domain tree.
977
+ */
978
+ pi.registerCommand("domain-tree:init", {
979
+ description: "Bootstrap domain tree from existing codebase analysis",
980
+ handler: async (_args, ctx) => {
981
+ await ensureDomainRoot(ctx);
982
+ if (isActive) {
983
+ const overwrite = await ctx.ui.confirm(
984
+ "Domain tree exists",
985
+ "A domain manifest already exists. Overwrite?",
986
+ );
987
+ if (!overwrite) return;
988
+ }
989
+
990
+ ctx.ui.notify(
991
+ "Let's analyze the codebase and propose a domain tree. " +
992
+ "I'll scan the project structure and check existing documentation.",
993
+ "info",
994
+ );
995
+
996
+ // Scan project and propose domain tree
997
+ const msg = `I need to bootstrap a domain tree for this project.
998
+
999
+ Please:
1000
+ 1. Read the project structure — top-level directories, build files, module definitions
1001
+ 2. Read existing architecture docs and README files
1002
+ 3. Propose a domain tree with DDD subdomain classification (core/supporting/generic/shared-kernel)
1003
+ 4. Create domains.yaml with the approved tree
1004
+ 5. Create README.md domain specs next to each domain's code
1005
+ 6. Report the coverage with a summary table
1006
+
1007
+ Remember:
1008
+ - Every domain must have at least one code path
1009
+ - Prefer fewer domains (5-10) — split later when pain emerges
1010
+ - Use the domain-navigator skill conventions for README.md structure
1011
+ - Do NOT duplicate domains.yaml info in README.md files`;
1012
+ pi.sendUserMessage(msg);
1013
+ },
1014
+ });
1015
+
1016
+ /**
1017
+ * /domain-tree:check — Structural health check.
1018
+ */
1019
+ pi.registerCommand("domain-tree:check", {
1020
+ description: "Validate domain tree structure against codebase",
1021
+ handler: async (args, ctx) => {
1022
+ await ensureDomainRoot(ctx);
1023
+ if (!isActive) {
1024
+ ctx.ui.notify("No domain tree found. Use /domain-tree:init first.", "warning");
1025
+ return;
1026
+ }
1027
+ if (manifestDiagnostics.length > 0) {
1028
+ ctx.ui.notify(
1029
+ "domains.yaml is invalid:\n" + formatManifestDiagnostics(manifestDiagnostics),
1030
+ "error",
1031
+ );
1032
+ return;
1033
+ }
1034
+
1035
+ const detailed = args.includes("--detailed") || args.includes("-d");
1036
+
1037
+ const msg = `I need to validate the domain tree against the codebase.
1038
+
1039
+ Please:
1040
+ 1. Read domains.yaml
1041
+ 2. For each domain, verify declared code paths exist
1042
+ 3. Check for orphaned production code (not covered by any domain)
1043
+ 4. Check classification consistency (every classified domain has a type)
1044
+ 5. Check normative documentation integrity (README.md plus frontmatter-marked sibling specs)
1045
+ ${detailed ? "6. Check semantic context-map health (providers, consumers, patterns, and canonical contracts)" : ""}
1046
+
1047
+ Use domain_tree_check to help with the validation.
1048
+
1049
+ Report a summary with pass/fail and actionable recommendations.`;
1050
+ pi.sendUserMessage(msg);
1051
+ },
1052
+ });
1053
+
1054
+ /**
1055
+ * /domain-tree:map — Coverage dashboard.
1056
+ */
1057
+ pi.registerCommand("domain-tree:map", {
1058
+ description: "Show domain tree with spec and test coverage",
1059
+ handler: async (_args, ctx) => {
1060
+ await ensureDomainRoot(ctx);
1061
+ if (!isActive) {
1062
+ ctx.ui.notify("No domain tree found. Use /domain-tree:init first.", "warning");
1063
+ return;
1064
+ }
1065
+ if (manifestDiagnostics.length > 0) {
1066
+ ctx.ui.notify(
1067
+ "domains.yaml is invalid; map output is unavailable:\n" +
1068
+ formatManifestDiagnostics(manifestDiagnostics),
1069
+ "error",
1070
+ );
1071
+ return;
1072
+ }
1073
+
1074
+ const msg = `I need to visualize the domain tree coverage.
1075
+
1076
+ Please:
1077
+ 1. Read domains.yaml
1078
+ 2. For each domain, check the colocated spec directory and count markdown spec files
1079
+ 3. Present a markdown table with:
1080
+ - Domain name (bold for parent, indented for subdomains)
1081
+ - Type with emoji (🔴 core, 🟡 supporting, 🟢 generic, 🔵 shared-kernel)
1082
+ - Spec count or ❌ none
1083
+ 4. Show the context map
1084
+ 5. Add recommendations weighted by domain importance
1085
+
1086
+ Use domain_tree_map to help generate the report.`;
1087
+ pi.sendUserMessage(msg);
1088
+ },
1089
+ });
1090
+
1091
+ // ─── Init-time notification ────────────────────────────────
1092
+
1093
+ pi.on("session_start", async (_event, ctx) => {
1094
+ if (isActive && domainRoot) {
1095
+ if (manifestDiagnostics.length > 0) {
1096
+ ctx.ui.notify(
1097
+ "🌳 Domain-driven project detected, but domains.yaml is invalid. Run domain_tree_check for diagnostics.",
1098
+ "error",
1099
+ );
1100
+ return;
1101
+ }
1102
+ ctx.ui.notify(
1103
+ `🌳 Domain-driven project detected at ${domainRoot}. ` +
1104
+ `Tools: domain_tree_resolve, domain_tree_resolve_term, domain_tree_check, domain_tree_map. ` +
1105
+ `Commands: /domain-tree:init, /domain-tree:check, /domain-tree:map. ` +
1106
+ `Skill: domain-navigator.`,
1107
+ "info",
1108
+ );
1109
+ }
1110
+ });
1111
+ }