@massu/core 1.6.3 → 1.8.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.
@@ -8360,14 +8360,33 @@ function domainFromWorkspace(pkg) {
8360
8360
  allowedImportsFrom: []
8361
8361
  };
8362
8362
  }
8363
- function topLevelSrcSubdirs(root) {
8364
- const srcDir = join3(root, "src");
8365
- if (!existsSync5(srcDir)) return [];
8366
- try {
8367
- return readdirSync3(srcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && !IGNORED_SUBDIRS.has(e.name)).map((e) => e.name).sort();
8368
- } catch {
8369
- return [];
8363
+ function topLevelSrcSubdirs(root, sourceDirs) {
8364
+ const effective = sourceDirs.length > 0 ? sourceDirs : ["src"];
8365
+ const seen = /* @__PURE__ */ new Set();
8366
+ for (const rel of effective) {
8367
+ const abs = join3(root, rel);
8368
+ if (!existsSync5(abs)) continue;
8369
+ try {
8370
+ for (const e of readdirSync3(abs, { withFileTypes: true })) {
8371
+ if (!e.isDirectory()) continue;
8372
+ if (IGNORED_SUBDIRS.has(e.name)) continue;
8373
+ seen.add(e.name);
8374
+ }
8375
+ } catch {
8376
+ }
8377
+ }
8378
+ return Array.from(seen).sort();
8379
+ }
8380
+ function flattenSourceDirs(sourceDirs) {
8381
+ const flat = /* @__PURE__ */ new Set();
8382
+ for (const entry of Object.values(sourceDirs)) {
8383
+ if (!entry) continue;
8384
+ for (const dir of entry.source_dirs) {
8385
+ if (dir === "." || dir === "") continue;
8386
+ flat.add(dir);
8387
+ }
8370
8388
  }
8389
+ return Array.from(flat);
8371
8390
  }
8372
8391
  function inferDomains(projectRoot, monorepo, sourceDirs) {
8373
8392
  const domains = [];
@@ -8376,7 +8395,8 @@ function inferDomains(projectRoot, monorepo, sourceDirs) {
8376
8395
  domains.push(domainFromWorkspace(pkg));
8377
8396
  }
8378
8397
  } else {
8379
- const subdirs = topLevelSrcSubdirs(projectRoot);
8398
+ const flat = flattenSourceDirs(sourceDirs);
8399
+ const subdirs = topLevelSrcSubdirs(projectRoot, flat);
8380
8400
  for (const s of subdirs) {
8381
8401
  domains.push({
8382
8402
  name: titleCase(s),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.6.3",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
package/src/cli.ts CHANGED
@@ -47,6 +47,12 @@ async function main(): Promise<void> {
47
47
  await runInstallCommands();
48
48
  break;
49
49
  }
50
+ case 'permissions': {
51
+ const { handlePermissionsSubcommand } = await import('./commands/permissions.ts');
52
+ const result = await handlePermissionsSubcommand(args.slice(1));
53
+ process.exit(result.exitCode);
54
+ return;
55
+ }
50
56
  case 'show-template': {
51
57
  const { runShowTemplate } = await import('./commands/show-template.ts');
52
58
  await runShowTemplate(args.slice(1));
@@ -162,12 +168,13 @@ Commands:
162
168
  init Set up Massu AI in your project (one command, full setup)
163
169
  doctor Check installation health
164
170
  install-hooks Install/update Claude Code hooks
165
- install-commands Install/update slash commands
171
+ install-commands Install/update slash commands (use --skip-permissions to opt out of MCP allowlist seeding)
166
172
  show-template Print the resolved variant of a bundled template (e.g. for diffs)
167
173
  watch Run the file-watcher daemon (auto-refresh on stack changes)
168
174
  refresh-log [N] Show the last N watcher auto-refresh events
169
175
  validate-config Validate massu.config.yaml (alias: config validate)
170
176
  config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
177
+ permissions <sub> MCP permission lifecycle: install | verify | check-drift
171
178
  adapters <sub> Third-party adapter registry: list | refresh | search | add-local | remove-local | install | resign
172
179
 
173
180
  Options:
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'url';
23
23
  import { parse as parseYaml } from 'yaml';
24
24
  import { getConfig, getResolvedPaths } from '../config.ts';
25
25
  import { getCurrentTier, getLicenseInfo, daysUntilExpiry } from '../license.ts';
26
+ import { readSettingsAtPath } from '../lib/settings-local.ts';
26
27
 
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const __dirname = dirname(__filename);
@@ -103,7 +104,7 @@ function checkHooksConfig(projectRoot: string): CheckResult {
103
104
  }
104
105
 
105
106
  try {
106
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'));
107
+ const content = readSettingsAtPath(settingsPath);
107
108
  if (!content.hooks) {
108
109
  return { name: 'Hooks Config', status: 'fail', detail: 'No hooks configured. Run: npx massu install-hooks' };
109
110
  }
@@ -238,8 +239,8 @@ function checkShellHooksWired(_projectRoot: string): CheckResult {
238
239
  }
239
240
 
240
241
  try {
241
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'));
242
- const hooks = content.hooks ?? {};
242
+ const content = readSettingsAtPath(settingsPath);
243
+ const hooks = (content.hooks ?? {}) as Record<string, unknown>;
243
244
  const hasSessionStart = Array.isArray(hooks.SessionStart) && hooks.SessionStart.length > 0;
244
245
  const hasPreToolUse = Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0;
245
246
  if (!hasSessionStart && !hasPreToolUse) {
@@ -34,6 +34,7 @@ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
34
34
  import { backfillMemoryFiles } from '../memory-file-ingest.ts';
35
35
  import { getConfig, resetConfig } from '../config.ts';
36
36
  import { installAll } from './install-commands.ts';
37
+ import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
37
38
  import {
38
39
  runDetection,
39
40
  type DetectionResult,
@@ -1107,20 +1108,12 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1107
1108
  claudeDirName = '.claude';
1108
1109
  }
1109
1110
  const claudeDir = resolve(projectRoot, claudeDirName);
1110
- const settingsPath = resolve(claudeDir, 'settings.local.json');
1111
1111
 
1112
1112
  if (!existsSync(claudeDir)) {
1113
1113
  mkdirSync(claudeDir, { recursive: true });
1114
1114
  }
1115
1115
 
1116
- let settings: Record<string, unknown> = {};
1117
- if (existsSync(settingsPath)) {
1118
- try {
1119
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1120
- } catch {
1121
- settings = {};
1122
- }
1123
- }
1116
+ const settings = readSettingsLocal(claudeDir);
1124
1117
 
1125
1118
  const hooksDir = resolveHooksDir();
1126
1119
  const hooksConfig = buildHooksConfig(hooksDir);
@@ -1134,7 +1127,7 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1134
1127
 
1135
1128
  settings.hooks = hooksConfig;
1136
1129
 
1137
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
1130
+ writeSettingsLocalAtomic(claudeDir, settings);
1138
1131
 
1139
1132
  return { installed: true, count: hookCount };
1140
1133
  }
@@ -20,17 +20,11 @@
20
20
  */
21
21
 
22
22
  import {
23
- closeSync,
24
23
  existsSync,
25
- fsyncSync,
26
- openSync,
27
24
  readFileSync,
28
- rmSync,
29
- writeSync,
30
25
  mkdirSync,
31
26
  readdirSync,
32
27
  statSync,
33
- renameSync,
34
28
  } from 'fs';
35
29
  import { resolve, dirname, relative, join } from 'path';
36
30
  import { fileURLToPath } from 'url';
@@ -38,6 +32,8 @@ import { createHash } from 'crypto';
38
32
  import { getConfig } from '../config.ts';
39
33
  import type { Config } from '../config.ts';
40
34
  import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
35
+ import { atomicWriteFile } from '../lib/settings-local.ts';
36
+ import { installPermissions } from '../permissions.ts';
41
37
 
42
38
  const __filename = fileURLToPath(import.meta.url);
43
39
  const __dirname = dirname(__filename);
@@ -131,47 +127,7 @@ export function loadManifest(claudeDir: string): Manifest {
131
127
  }
132
128
  }
133
129
 
134
- /**
135
- * Iter-7 fix: atomic file write — tmp + fsync + rename.
136
- *
137
- * Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
138
- * editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
139
- * AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
140
- * refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
141
- * then renameSync") both demand this. Previously installAll's per-file writes
142
- * (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
143
- * SIGINT/power-loss between truncate and complete-write left a partial file.
144
- * Now both go through this helper so the watcher's iter-6 "we don't await
145
- * fireRefresh because atomic-rename covers everything" decision is sound.
146
- *
147
- * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
148
- * data hits the platter before the rename. On any error, removes the tmp file.
149
- * Tmp filename includes process.pid to avoid clashes with concurrent installs
150
- * from sibling processes (e.g. a manual `npx massu config refresh` racing the
151
- * watcher daemon — the install-lock should prevent this, but the file-level
152
- * tmp name disambiguates if it ever happens).
153
- */
154
- function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
155
- const tmpPath = `${targetPath}.${process.pid}.tmp`;
156
- try {
157
- const fd = openSync(tmpPath, 'w', mode);
158
- try {
159
- const buf = Buffer.from(content, 'utf-8');
160
- writeSync(fd, buf, 0, buf.length, 0);
161
- fsyncSync(fd);
162
- } finally {
163
- closeSync(fd);
164
- }
165
- renameSync(tmpPath, targetPath);
166
- } catch (err) {
167
- if (existsSync(tmpPath)) {
168
- try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
169
- }
170
- throw err;
171
- }
172
- }
173
-
174
- /** Write the manifest atomically: tempfile + fsync + renameSync. */
130
+ /** Write the manifest atomically: tempfile + fsync + renameSync (uses shared lib/settings-local.ts:atomicWriteFile). */
175
131
  export function saveManifest(claudeDir: string, manifest: Manifest): void {
176
132
  const dir = resolve(claudeDir, '.massu');
177
133
  if (!existsSync(dir)) {
@@ -541,7 +497,15 @@ export function buildTemplateVars(): Record<string, unknown> {
541
497
  };
542
498
  }
543
499
 
544
- export function installCommands(projectRoot: string): InstallCommandsResult {
500
+ export interface InstallCommandsOptions {
501
+ /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
502
+ skipPermissions?: boolean;
503
+ }
504
+
505
+ export function installCommands(
506
+ projectRoot: string,
507
+ opts: InstallCommandsOptions = {},
508
+ ): InstallCommandsResult {
545
509
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
546
510
  const claudeDir = resolve(projectRoot, claudeDirName);
547
511
  const targetDir = resolve(claudeDir, 'commands');
@@ -559,9 +523,21 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
559
523
 
560
524
  const framework = getConfig().framework;
561
525
  const templateVars = buildTemplateVars();
562
- const stats = runWithManifest(claudeDir, (manifest) =>
563
- syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true, templateVars),
564
- );
526
+ const stats = runWithManifest(claudeDir, (manifest) => {
527
+ const syncStats = syncDirectory(
528
+ sourceDir,
529
+ targetDir,
530
+ framework,
531
+ manifest,
532
+ 'commands',
533
+ true,
534
+ templateVars,
535
+ );
536
+ if (!opts.skipPermissions) {
537
+ installPermissions(claudeDir, manifest, { silent: true });
538
+ }
539
+ return syncStats;
540
+ });
565
541
  return { ...stats, commandsDir: targetDir };
566
542
  }
567
543
 
@@ -576,9 +552,19 @@ export interface InstallAllResult {
576
552
  totalSkipped: number;
577
553
  totalKept: number;
578
554
  claudeDir: string;
555
+ /** Permission-seeding outcome (undefined when --skip-permissions). */
556
+ permissions?: { installed: number; kept: number; skipped: number };
579
557
  }
580
558
 
581
- export function installAll(projectRoot: string): InstallAllResult {
559
+ export interface InstallAllOptions {
560
+ /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
561
+ skipPermissions?: boolean;
562
+ }
563
+
564
+ export function installAll(
565
+ projectRoot: string,
566
+ opts: InstallAllOptions = {},
567
+ ): InstallAllResult {
582
568
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
583
569
  const claudeDir = resolve(projectRoot, claudeDirName);
584
570
 
@@ -587,6 +573,7 @@ export function installAll(projectRoot: string): InstallAllResult {
587
573
  let totalUpdated = 0;
588
574
  let totalSkipped = 0;
589
575
  let totalKept = 0;
576
+ let permissionsResult: { installed: number; kept: number; skipped: number } | undefined;
590
577
 
591
578
  const framework = getConfig().framework;
592
579
  const templateVars = buildTemplateVars();
@@ -613,6 +600,9 @@ export function installAll(projectRoot: string): InstallAllResult {
613
600
  totalSkipped += stats.skipped;
614
601
  totalKept += stats.kept;
615
602
  }
603
+ if (!opts.skipPermissions) {
604
+ permissionsResult = installPermissions(claudeDir, manifest, { silent: true });
605
+ }
616
606
  });
617
607
 
618
608
  return {
@@ -622,6 +612,7 @@ export function installAll(projectRoot: string): InstallAllResult {
622
612
  totalSkipped,
623
613
  totalKept,
624
614
  claudeDir,
615
+ permissions: permissionsResult,
625
616
  };
626
617
  }
627
618
 
@@ -631,13 +622,14 @@ export function installAll(projectRoot: string): InstallAllResult {
631
622
 
632
623
  export async function runInstallCommands(): Promise<void> {
633
624
  const projectRoot = process.cwd();
625
+ const skipPermissions = process.argv.slice(2).includes('--skip-permissions');
634
626
 
635
627
  console.log('');
636
628
  console.log('Massu AI - Install Project Assets');
637
629
  console.log('==================================');
638
630
  console.log('');
639
631
 
640
- const result = installAll(projectRoot);
632
+ const result = installAll(projectRoot, { skipPermissions });
641
633
 
642
634
  // Report per-asset-type
643
635
  for (const assetType of ASSET_TYPES) {
@@ -667,6 +659,23 @@ export async function runInstallCommands(): Promise<void> {
667
659
  ` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
668
660
  );
669
661
  }
662
+
663
+ // Permission seeding outcome line
664
+ if (skipPermissions) {
665
+ console.log(' Permission seeding skipped (--skip-permissions).');
666
+ } else if (result.permissions) {
667
+ if (result.permissions.installed > 0) {
668
+ console.log(
669
+ ` Wrote merged permissions block to .claude/settings.local.json (use --skip-permissions to opt out).`,
670
+ );
671
+ } else if (result.permissions.kept > 0) {
672
+ console.log(
673
+ ` MCP allowlist entry was edited by operator; preserved. Use \`npx massu permissions check-drift\` to inspect.`,
674
+ );
675
+ }
676
+ // skipped:1 → silent (already in sync, no operator-visible change)
677
+ }
678
+
670
679
  console.log('');
671
680
  console.log(' Restart your Claude Code session to use them.');
672
681
  console.log('');
@@ -0,0 +1,150 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu permissions <subcommand>` — MCP permission lifecycle CLI.
6
+ *
7
+ * Subcommands (each is documented at https://massu.ai/docs/reference/cli-reference):
8
+ * install Seed `mcp__massu__*` into permissions.allow + propagate global
9
+ * defaultMode (idempotent, kept-because-edited preservation).
10
+ * verify Read-only check; exit 0 if all canonical entries present, else 1.
11
+ * check-drift Extended diagnostic (4 drift kinds, severity-mapped exit codes).
12
+ *
13
+ * Exit code matrix for `check-drift` (highest severity wins when multiple kinds present):
14
+ * 0 = clean
15
+ * 1 = missing-allow
16
+ * 2 = invalid-default-mode
17
+ * 3 = unknown-key
18
+ * 4 = strips-global-defaultmode
19
+ *
20
+ * Mirrors the existing `handleConfigSubcommand` dispatch pattern at cli.ts.
21
+ */
22
+
23
+ import { resolve } from 'path';
24
+ import { getConfig } from '../config.ts';
25
+ import {
26
+ installPermissions,
27
+ verifyPermissions,
28
+ checkPermissionsDrift,
29
+ type DriftKind,
30
+ } from '../permissions.ts';
31
+ import { runWithManifest } from './install-commands.ts';
32
+
33
+ function resolveClaudeDir(): string {
34
+ let claudeDirName = '.claude';
35
+ try {
36
+ claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
37
+ } catch {
38
+ claudeDirName = '.claude';
39
+ }
40
+ return resolve(process.cwd(), claudeDirName);
41
+ }
42
+
43
+ const DRIFT_KIND_EXIT_CODE: Record<DriftKind, number> = {
44
+ 'missing-allow': 1,
45
+ 'invalid-default-mode': 2,
46
+ 'unknown-key': 3,
47
+ 'strips-global-defaultmode': 4,
48
+ };
49
+
50
+ export async function handlePermissionsSubcommand(
51
+ args: string[],
52
+ ): Promise<{ exitCode: number }> {
53
+ const sub = args[0];
54
+
55
+ switch (sub) {
56
+ case 'install': {
57
+ const claudeDir = resolveClaudeDir();
58
+ const result = runWithManifest(claudeDir, (manifest) =>
59
+ installPermissions(claudeDir, manifest, { silent: false }),
60
+ );
61
+ // Final user-facing summary line on stdout
62
+ if (result.installed > 0) {
63
+ process.stdout.write(
64
+ 'Wrote merged permissions block to .claude/settings.local.json.\n',
65
+ );
66
+ } else if (result.skipped > 0) {
67
+ process.stdout.write('Permissions already in sync — no changes.\n');
68
+ } else if (result.kept > 0) {
69
+ process.stdout.write(
70
+ 'Operator-edited permissions block preserved. Run `npx massu permissions check-drift` to inspect.\n',
71
+ );
72
+ }
73
+ return { exitCode: 0 };
74
+ }
75
+
76
+ case 'verify': {
77
+ const claudeDir = resolveClaudeDir();
78
+ const { missing } = verifyPermissions(claudeDir);
79
+ if (missing.length === 0) {
80
+ process.stdout.write('All MCP allowlist entries present.\n');
81
+ return { exitCode: 0 };
82
+ }
83
+ for (const entry of missing) {
84
+ process.stderr.write(`missing: ${entry}\n`);
85
+ }
86
+ return { exitCode: 1 };
87
+ }
88
+
89
+ case 'check-drift': {
90
+ const claudeDir = resolveClaudeDir();
91
+ const { driftItems } = checkPermissionsDrift(claudeDir);
92
+ if (driftItems.length === 0) {
93
+ process.stdout.write('No permission drift detected.\n');
94
+ return { exitCode: 0 };
95
+ }
96
+ // Highest-severity kind wins for exit code
97
+ let highest = 0;
98
+ for (const item of driftItems) {
99
+ const code = DRIFT_KIND_EXIT_CODE[item.kind];
100
+ if (code > highest) highest = code;
101
+ process.stderr.write(
102
+ `drift[${item.kind}]: ${item.detail} — remediation: ${item.remediation}\n`,
103
+ );
104
+ }
105
+ return { exitCode: highest };
106
+ }
107
+
108
+ case '--help':
109
+ case '-h':
110
+ case undefined: {
111
+ printPermissionsHelp();
112
+ return { exitCode: 0 };
113
+ }
114
+
115
+ default: {
116
+ process.stderr.write(`massu: unknown permissions subcommand: ${sub}\n`);
117
+ printPermissionsHelp();
118
+ return { exitCode: 1 };
119
+ }
120
+ }
121
+ }
122
+
123
+ export function printPermissionsHelp(): void {
124
+ process.stdout.write(`
125
+ massu permissions <subcommand>
126
+
127
+ Subcommands:
128
+ install Seed mcp__massu__* into .claude/settings.local.json's permissions.allow.
129
+ Also propagates global defaultMode (from ~/.claude/settings.json) into
130
+ the project-local file to prevent the merge-replacement trap (see
131
+ https://massu.ai/docs/reference/cli-reference#permissions-trap).
132
+ Idempotent. Preserves operator-edited values.
133
+
134
+ verify Read-only check that all canonical MCP allowlist entries are present.
135
+ Exit 0 if clean, exit 1 with one diagnostic line per missing entry.
136
+
137
+ check-drift Extended diagnostic surfacing 4 drift kinds:
138
+ - missing-allow (exit 1) — canonical entries missing
139
+ - invalid-default-mode (exit 2) — defaultMode requires launch flag
140
+ - unknown-key (exit 3) — undocumented top-level setting
141
+ - strips-global-defaultmode (exit 4) — project-local would strip global value
142
+
143
+ Examples:
144
+ npx massu permissions install
145
+ npx massu permissions verify
146
+ npx massu permissions check-drift
147
+
148
+ Documentation: https://massu.ai/docs/reference/cli-reference#massu-permissions
149
+ `);
150
+ }
@@ -67,17 +67,69 @@ function domainFromWorkspace(pkg: WorkspacePackage): DomainConfig {
67
67
  };
68
68
  }
69
69
 
70
- function topLevelSrcSubdirs(root: string): string[] {
71
- const srcDir = join(root, 'src');
72
- if (!existsSync(srcDir)) return [];
73
- try {
74
- return readdirSync(srcDir, { withFileTypes: true })
75
- .filter((e) => e.isDirectory() && !IGNORED_SUBDIRS.has(e.name))
76
- .map((e) => e.name)
77
- .sort();
78
- } catch {
79
- return [];
70
+ /**
71
+ * Enumerate domain candidates under each detected source directory.
72
+ *
73
+ * The `sourceDirs` argument is the flattened, unique list of relative
74
+ * source paths produced upstream by the source-dir detector
75
+ * (`detectSourceDirs` in `source-dir-detector.ts`). For each path that
76
+ * exists under `root`, this function lists immediate subdirectories as
77
+ * candidate domain names. Hardcoded `src/` lookup was removed (plan
78
+ * `plan-1.7.0-cohesive-cleanup` P-B-002) — the function now consumes
79
+ * the detection pipeline's output verbatim, so projects whose source
80
+ * lives at non-`src/` paths (e.g. `lib/`, `apps/<x>/src/`) are no
81
+ * longer silently dropped.
82
+ *
83
+ * Empty `sourceDirs` is treated as a legacy single-repo `src/` lookup
84
+ * to preserve behavior for callers that pre-date the source-dir
85
+ * pipeline (CLI / test harnesses that hand-wire `inferDomains`).
86
+ *
87
+ * Returns deduplicated subdir names sorted alphabetically.
88
+ */
89
+ function topLevelSrcSubdirs(root: string, sourceDirs: readonly string[]): string[] {
90
+ const effective = sourceDirs.length > 0 ? sourceDirs : ['src'];
91
+ const seen = new Set<string>();
92
+ for (const rel of effective) {
93
+ const abs = join(root, rel);
94
+ if (!existsSync(abs)) continue;
95
+ try {
96
+ for (const e of readdirSync(abs, { withFileTypes: true })) {
97
+ if (!e.isDirectory()) continue;
98
+ if (IGNORED_SUBDIRS.has(e.name)) continue;
99
+ seen.add(e.name);
100
+ }
101
+ } catch {
102
+ // skip directories that cannot be read; do not throw.
103
+ }
104
+ }
105
+ return Array.from(seen).sort();
106
+ }
107
+
108
+ /**
109
+ * Flatten a `SourceDirMap` into a unique, deduplicated list of relative
110
+ * source paths across all detected languages.
111
+ *
112
+ * Drops the root sentinels `.` and `''` — those are emitted by the
113
+ * source-dir-detector when source files live directly at the project
114
+ * root (e.g. Django's `manage.py` or Swift's `Package.swift`). Treating
115
+ * them as enumerable source dirs causes spurious top-level directory
116
+ * inclusion (Tests/, Sources/, etc.), which collides with the
117
+ * language-fallback path in `inferDomains`. Root-source repos rely on
118
+ * the language-fallback path to emit `{Python}` / `{Swift}` domains,
119
+ * NOT a fan-out of every root subdirectory.
120
+ *
121
+ * Order is not guaranteed — callers that need determinism must sort.
122
+ */
123
+ function flattenSourceDirs(sourceDirs: SourceDirMap): string[] {
124
+ const flat = new Set<string>();
125
+ for (const entry of Object.values(sourceDirs)) {
126
+ if (!entry) continue;
127
+ for (const dir of entry.source_dirs) {
128
+ if (dir === '.' || dir === '') continue;
129
+ flat.add(dir);
130
+ }
80
131
  }
132
+ return Array.from(flat);
81
133
  }
82
134
 
83
135
  /**
@@ -100,8 +152,11 @@ export function inferDomains(
100
152
  domains.push(domainFromWorkspace(pkg));
101
153
  }
102
154
  } else {
103
- // Single repo: suggest one domain per top-level src/<subdir>/ if src/ exists.
104
- const subdirs = topLevelSrcSubdirs(projectRoot);
155
+ // Single repo: suggest one domain per top-level <sourceDir>/<subdir>/ for
156
+ // every detected source dir (formerly hardcoded to `src/` only — see
157
+ // P-B-002 in plan-1.7.0-cohesive-cleanup).
158
+ const flat = flattenSourceDirs(sourceDirs);
159
+ const subdirs = topLevelSrcSubdirs(projectRoot, flat);
105
160
  for (const s of subdirs) {
106
161
  domains.push({
107
162
  name: titleCase(s),