@happyvertical/smrt-dev-mcp 0.40.37 → 0.40.39

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.
@@ -1,8 +1,9 @@
1
- import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
- import { dirname, join, relative, resolve } from "node:path";
1
+ import { existsSync, lstatSync, opendirSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { MODULE_DOC_HASH_PREFIX, readAgentModuleDocs } from "@happyvertical/smrt-core/knowledge";
6
+ import { ManifestAdapter, OxcScanner } from "@happyvertical/smrt-scanner";
6
7
  //#region src/knowledge/index.ts
7
8
  var SDK_PACKAGE_NAMES = /* @__PURE__ */ new Set([
8
9
  "@happyvertical/ai",
@@ -40,6 +41,39 @@ var WALK_SKIP_DIRS = /* @__PURE__ */ new Set([
40
41
  "dist",
41
42
  "node_modules"
42
43
  ]);
44
+ /** Used only when neither pnpm-workspace.yaml nor package.json#workspaces exists. */
45
+ var WORKSPACE_GLOB_FALLBACK = ["packages/*"];
46
+ /**
47
+ * Bounds total directory-entry work across every workspace glob. This is a
48
+ * cardinality budget rather than a depth cap: valid deeply nested workspaces
49
+ * remain discoverable, while broad or repeated globstars fail loudly before
50
+ * they can amplify into unbounded filesystem work.
51
+ */
52
+ var MAX_WORKSPACE_GLOB_TRAVERSAL_ENTRIES = 1e4;
53
+ /** Prevents a broad but sub-budget glob from fanning out package reads. */
54
+ var MAX_DISCOVERED_WORKSPACE_PACKAGES = 512;
55
+ /** Bounds simultaneous OxcScanner instances and their filesystem handles. */
56
+ var MAX_SCANNER_CONCURRENCY = 8;
57
+ /** Caps the downstream package fallback so a large product stays in budget. */
58
+ var MAX_FALLBACK_PACKAGES = 8;
59
+ /** Kept in sync with `tools/introspect-project.ts` so both paths see one corpus. */
60
+ var SCAN_INCLUDE = [
61
+ "**/*.ts",
62
+ "**/*.tsx",
63
+ "**/*.js",
64
+ "**/*.jsx"
65
+ ];
66
+ var SCAN_EXCLUDE = [
67
+ "**/node_modules/**",
68
+ "**/dist/**",
69
+ "**/build/**",
70
+ "**/.git/**",
71
+ "**/.smrt/**",
72
+ "**/*.d.ts",
73
+ "**/*.test.ts",
74
+ "**/*.spec.ts",
75
+ "**/__tests__/**"
76
+ ];
43
77
  var STALE_PATTERNS = [
44
78
  {
45
79
  code: "stale-have-namespace",
@@ -65,45 +99,185 @@ var STALE_PATTERNS = [
65
99
  async function buildKnowledgeIndex(options = {}) {
66
100
  const rootDir = findProjectRoot(options.rootDir ?? process.cwd());
67
101
  const includeDocs = options.includeDocs ?? true;
68
- const packageDirs = discoverProjectPackageDirs(rootDir);
69
- const packages = packageDirs.map((dir) => readKnowledgePackage(rootDir, dir, includeDocs));
102
+ const { globs, globSource, dirs: discoveredPackageDirs, diagnostics: discoveryDiagnostics } = discoverProjectPackageDirs(rootDir);
103
+ const resolvedRoot = realpathSync(rootDir);
104
+ const packageDirs = [];
105
+ const packages = [];
106
+ for (const dir of discoveredPackageDirs) {
107
+ if (confinedRealPath(resolvedRoot, dir) === void 0) {
108
+ discoveryDiagnostics.push({
109
+ severity: "error",
110
+ code: "workspace-package-root-escape",
111
+ message: "Rejected a workspace package whose real path changed or escaped the workspace root before it could be read.",
112
+ remedy: "Keep workspace package paths stable and inside the workspace root throughout knowledge discovery."
113
+ });
114
+ continue;
115
+ }
116
+ packageDirs.push(dir);
117
+ packages.push(readKnowledgePackage(rootDir, dir, includeDocs));
118
+ }
119
+ await applyScannerFallbacks(packages, packageDirs.filter((dir) => resolve(dir) !== resolve(rootDir)).map((dir) => `${relative(rootDir, dir).replaceAll("\\", "/")}/**`));
70
120
  packages.push(...discoverInstalledSdkPackages(rootDir, packageDirs, includeDocs));
71
- const scopedPackages = filterKnowledgePackages(dedupePackages(packages), options);
121
+ const uniquePackages = dedupePackages(packages);
122
+ const scopedPackages = filterKnowledgePackages(uniquePackages, options);
72
123
  const smrtPackages = scopedPackages.filter((pkg) => pkg.kind === "smrt");
73
124
  const sdkPackages = scopedPackages.filter((pkg) => pkg.kind === "sdk");
125
+ const coverage = buildCoverage({
126
+ rootDir,
127
+ globs,
128
+ globSource,
129
+ packageDirs,
130
+ packages: uniquePackages
131
+ });
74
132
  return {
75
- schemaVersion: 1,
133
+ schemaVersion: 2,
76
134
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
77
135
  rootDir,
78
136
  packages: scopedPackages,
79
137
  smrtPackages,
80
138
  sdkPackages,
81
- relationshipsV2: summarizeRelationshipsV2(scopedPackages)
139
+ relationshipsV2: summarizeRelationshipsV2(scopedPackages),
140
+ coverage,
141
+ diagnostics: buildIndexDiagnostics(rootDir, uniquePackages, coverage, discoveryDiagnostics)
142
+ };
143
+ }
144
+ function buildCoverage(options) {
145
+ return {
146
+ workspaceGlobs: options.globs,
147
+ workspaceGlobSource: options.globSource,
148
+ packageDirs: options.packageDirs.map((dir) => relative(options.rootDir, dir) || "."),
149
+ packagesWithObjects: options.packages.filter((pkg) => pkg.objects.length > 0).map((pkg) => `${pkg.name} (${pkg.objects.length}, ${pkg.objectSource})`),
150
+ packagesWithoutObjects: options.packages.filter((pkg) => pkg.objects.length === 0).map((pkg) => ({
151
+ name: pkg.name,
152
+ reason: pkg.objectSourceReason ?? pkg.objectSource,
153
+ checkedPaths: pkg.checkedObjectPaths,
154
+ remedy: remedyForReason(pkg)
155
+ }))
82
156
  };
83
157
  }
158
+ function remedyForReason(pkg) {
159
+ const reason = pkg.objectSourceReason ?? "";
160
+ if (reason.startsWith("manifest-objects-owned-by-other-packages")) return `${pkg.relativeDirectory || "."} has an aggregate or stale manifest owned by other packages. Regenerate it (pnpm build in that package) so it declares this package's own objects.`;
161
+ if (reason.startsWith("scanner-failed")) return `Source scanning failed for ${pkg.relativeDirectory || "."}; fix the parse error or generate a manifest with pnpm build.`;
162
+ if (reason === "no-smrt-objects-in-sources") return "No @smrt() classes were found in this package. Expected if it is a UI, contract, or tooling package.";
163
+ return `Add @smrt() classes, or run pnpm build in ${pkg.relativeDirectory || "."} to emit .smrt/manifest.json.`;
164
+ }
165
+ /**
166
+ * Turns a discovery failure into an explicit signal.
167
+ *
168
+ * A zero-object index used to be indistinguishable from a project with no
169
+ * relationships, so callers acted on empty context as if it were an answer
170
+ * (#2143). The zero case is therefore error-grade and names what was checked.
171
+ */
172
+ function buildIndexDiagnostics(rootDir, packages, coverage, discoveryDiagnostics = []) {
173
+ const diagnostics = [...discoveryDiagnostics];
174
+ if (packages.reduce((total, pkg) => total + pkg.objects.length, 0) === 0) diagnostics.push({
175
+ severity: "error",
176
+ code: "no-smrt-objects-discovered",
177
+ message: [
178
+ `No SMRT objects were discovered under ${rootDir}.`,
179
+ `Workspace globs (${coverage.workspaceGlobSource}): ${coverage.workspaceGlobs.join(", ") || "(none)"}.`,
180
+ `Package directories checked (${coverage.packageDirs.length}): ${coverage.packageDirs.join(", ") || "(none)"}.`,
181
+ "Treat this as a discovery failure, not as evidence that the project has no SMRT model."
182
+ ].join(" "),
183
+ checkedPaths: [...new Set(packages.flatMap((pkg) => pkg.checkedObjectPaths))],
184
+ remedy: [
185
+ "Confirm rootDir is the workspace root;",
186
+ "confirm pnpm-workspace.yaml `packages:` covers the directories that hold @smrt() classes (for example apps/*);",
187
+ "run `pnpm build` in the owning package to emit .smrt/manifest.json;",
188
+ "then re-run. Cross-check with introspect-project on the same root."
189
+ ].join(" ")
190
+ });
191
+ for (const pkg of packages) {
192
+ const reason = pkg.objectSourceReason ?? "";
193
+ if (reason.startsWith("manifest-objects-owned-by-other-packages")) {
194
+ diagnostics.push({
195
+ severity: "warning",
196
+ code: "foreign-manifest-objects",
197
+ message: `${pkg.name}: every object in the discovered manifest is owned by another package (${reason}); it was rejected instead of being counted as this package's.`,
198
+ packageName: pkg.name,
199
+ checkedPaths: pkg.checkedObjectPaths,
200
+ remedy: remedyForReason(pkg)
201
+ });
202
+ continue;
203
+ }
204
+ if (reason.startsWith("rejected ")) {
205
+ diagnostics.push({
206
+ severity: "warning",
207
+ code: "partial-foreign-manifest-objects",
208
+ message: `${pkg.name}: ${reason}.`,
209
+ packageName: pkg.name,
210
+ checkedPaths: pkg.checkedObjectPaths,
211
+ remedy: remedyForReason(pkg)
212
+ });
213
+ continue;
214
+ }
215
+ if (reason.startsWith("scanner-failed")) diagnostics.push({
216
+ severity: "warning",
217
+ code: "scanner-fallback-failed",
218
+ message: `${pkg.name}: ${reason}`,
219
+ packageName: pkg.name,
220
+ checkedPaths: pkg.checkedObjectPaths,
221
+ remedy: remedyForReason(pkg)
222
+ });
223
+ }
224
+ diagnostics.push(...buildDuplicateIdentityDiagnostics(packages));
225
+ return diagnostics;
226
+ }
227
+ /**
228
+ * Reports one table claimed by more than one package. Relationships-v2 already
229
+ * counts it once, but the duplication itself usually means a stale or
230
+ * re-qualified generated artifact, which the caller should know about (#2143).
231
+ */
232
+ function buildDuplicateIdentityDiagnostics(packages) {
233
+ const duplicates = [];
234
+ for (const [identity, entries] of groupObjectsByIdentity(packages)) {
235
+ if (collapseIdentityGroup(entries).length >= entries.length) continue;
236
+ duplicates.push([identity, [...new Set(entries.map((entry) => entry.pkg.name))]]);
237
+ }
238
+ if (duplicates.length === 0) return [];
239
+ const sample = duplicates.slice(0, 5).map(([identity, names]) => `${identity} (${names.join(" + ")})`).join("; ");
240
+ return [{
241
+ severity: "warning",
242
+ code: "duplicate-object-identity",
243
+ message: `${duplicates.length} object identit${duplicates.length === 1 ? "y is" : "ies are"} reported by more than one package: ${sample}${duplicates.length > 5 ? "; …" : ""}. Relationships-v2 counts each once.`,
244
+ remedy: "Usually a stale or aggregate generated artifact in a consuming package that re-qualifies its dependencies' objects. Regenerate it (pnpm build) so each package reports only the objects it declares."
245
+ }];
246
+ }
84
247
  async function checkKnowledgeFreshness(options = {}) {
85
248
  return checkKnowledgeFreshnessFromIndex(await buildKnowledgeIndex(options), options);
86
249
  }
87
250
  async function checkKnowledgeFreshnessFromIndex(index, options = {}) {
88
251
  const issues = [];
89
252
  const changedFiles = options.changed ? getChangedFiles(index.rootDir) : void 0;
90
- for (const pkg of index.packages.filter((item) => item.kind !== "sdk")) {
253
+ const hasMemberPackages = index.packages.some((item) => item.kind !== "sdk" && !item.isWorkspaceRoot);
254
+ const memberDirectories = index.packages.filter((item) => item.kind !== "sdk" && !item.isWorkspaceRoot && item.relativeDirectory).map((item) => item.relativeDirectory);
255
+ const isNestedMember = (pkg) => Boolean(pkg.relativeDirectory) && memberDirectories.some((directory) => directory !== pkg.relativeDirectory && pkg.relativeDirectory.startsWith(`${directory}/`));
256
+ for (const pkg of index.packages.filter((item) => item.kind !== "sdk" && !(item.isWorkspaceRoot && hasMemberPackages))) {
91
257
  const packageJsonPath = join(pkg.directory, "package.json");
92
- if (!pkg.hasAgentsMd) issues.push({
258
+ const nested = isNestedMember(pkg);
259
+ if (!pkg.hasAgentsMd && !nested) issues.push({
93
260
  severity: "error",
94
261
  code: "missing-agents-md",
95
262
  message: "Workspace package is missing canonical AGENTS.md",
96
263
  file: relative(index.rootDir, join(pkg.directory, "AGENTS.md")),
97
264
  packageName: pkg.name
98
265
  });
99
- if (!pkg.hasClaudeMd) issues.push({
266
+ if (nested && pkg.hasAgentsMd) issues.push({
267
+ severity: "error",
268
+ code: "nested-agents-md",
269
+ message: "Nested workspace package must not define AGENTS.md; move it to the parent package as a linked agents/<module>.md",
270
+ file: relative(index.rootDir, join(pkg.directory, "AGENTS.md")),
271
+ packageName: pkg.name
272
+ });
273
+ if (!pkg.hasClaudeMd && !nested) issues.push({
100
274
  severity: "error",
101
275
  code: "missing-claude-shim",
102
276
  message: "Workspace package is missing CLAUDE.md compatibility shim",
103
277
  file: relative(index.rootDir, join(pkg.directory, "CLAUDE.md")),
104
278
  packageName: pkg.name
105
279
  });
106
- else if (!pkg.hasClaudeShim) issues.push({
280
+ else if (pkg.hasClaudeMd && !pkg.hasClaudeShim) issues.push({
107
281
  severity: "error",
108
282
  code: "claude-not-shim",
109
283
  message: "CLAUDE.md must contain only @AGENTS.md",
@@ -120,6 +294,7 @@ async function checkKnowledgeFreshnessFromIndex(index, options = {}) {
120
294
  packageName: pkg.name
121
295
  });
122
296
  }
297
+ if (pkg.isPrivate) continue;
123
298
  if (!pkg.files.includes("AGENTS.md")) issues.push({
124
299
  severity: "error",
125
300
  code: "package-files-missing-agents",
@@ -262,11 +437,14 @@ async function buildReviewContext(options = {}) {
262
437
  sdkPackages: selectedSdkPackages,
263
438
  sourceFiles: changedFiles,
264
439
  extraContext: options.focus,
440
+ detail: options.detail,
265
441
  moduleDocHints: {
266
442
  changedFiles,
267
443
  text: [options.focus, options.documentation].filter(Boolean).join("\n")
268
444
  }
269
- })
445
+ }),
446
+ coverage: index.coverage,
447
+ diagnostics: index.diagnostics
270
448
  };
271
449
  }
272
450
  async function smrtReview(options = {}) {
@@ -277,7 +455,9 @@ async function smrtReview(options = {}) {
277
455
  selectedPackages: context.selectedPackages,
278
456
  selectedSdkPackages: context.selectedSdkPackages,
279
457
  ...mode !== "prompt-bundle" ? { deterministicFindings: context.deterministicFindings } : {},
280
- ...mode !== "findings" ? { promptBundle: context.promptBundle } : {}
458
+ ...mode !== "findings" ? { promptBundle: context.promptBundle } : {},
459
+ coverage: context.coverage,
460
+ diagnostics: context.diagnostics
281
461
  };
282
462
  }
283
463
  async function buildArchitectureContext(options = {}) {
@@ -307,8 +487,11 @@ async function buildArchitectureContext(options = {}) {
307
487
  sdkPackages: selectedSdkPackages,
308
488
  sourceFiles: [],
309
489
  extraContext: text,
490
+ detail: options.detail,
310
491
  moduleDocHints: { text }
311
- })
492
+ }),
493
+ coverage: index.coverage,
494
+ diagnostics: index.diagnostics
312
495
  };
313
496
  }
314
497
  async function smrtArchitecture(options = {}) {
@@ -341,6 +524,7 @@ function renderKnowledgeIndexMarkdown(index) {
341
524
  `- polymorphic associations: ${index.relationshipsV2.polymorphicAssociations}`,
342
525
  `- UUID columns: ${index.relationshipsV2.uuidColumns}`,
343
526
  "",
527
+ ...renderDiagnosticsSection(index.diagnostics),
344
528
  "## Packages",
345
529
  ""
346
530
  ];
@@ -377,34 +561,261 @@ function renderFreshnessResult(result) {
377
561
  }
378
562
  return lines.join("\n");
379
563
  }
564
+ /**
565
+ * Nearest ancestor that declares a workspace (#2143).
566
+ *
567
+ * The old rule also required a literal `packages/` directory, which silently
568
+ * mis-rooted every `apps/*`-shaped product. A workspace declaration is the
569
+ * actual signal; `startDir` is the fallback so single-package repos still work.
570
+ */
380
571
  function findProjectRoot(startDir) {
381
572
  let current = resolve(startDir);
382
573
  for (;;) {
383
- if (existsSync(join(current, "pnpm-workspace.yaml")) && existsSync(join(current, "packages"))) return current;
574
+ if (existsSync(join(current, "pnpm-workspace.yaml"))) return current;
575
+ if (readPackageJsonWorkspaceGlobs(current).length > 0) return current;
384
576
  const parent = dirname(current);
385
577
  if (parent === current) return resolve(startDir);
386
578
  current = parent;
387
579
  }
388
580
  }
389
- function discoverWorkspacePackageDirs(rootDir) {
390
- const packagesDir = join(rootDir, "packages");
391
- if (!existsSync(packagesDir)) return [];
392
- return readdirSync(packagesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(packagesDir, entry.name)).filter((dir) => existsSync(join(dir, "package.json"))).sort();
581
+ function readPackageJsonWorkspaceGlobs(dir) {
582
+ const workspaces = objectRecord(readJson(join(dir, "package.json"))).workspaces;
583
+ return Array.isArray(workspaces) ? stringArray(workspaces) : stringArray(objectRecord(workspaces).packages);
584
+ }
585
+ function readWorkspaceGlobs(rootDir) {
586
+ const pnpmWorkspacePath = join(rootDir, "pnpm-workspace.yaml");
587
+ if (existsSync(pnpmWorkspacePath)) {
588
+ const globs = parseYamlStringList(readFileSync(pnpmWorkspacePath, "utf8"), "packages");
589
+ if (globs.length > 0) return {
590
+ globs,
591
+ source: "pnpm-workspace.yaml"
592
+ };
593
+ }
594
+ const packageJsonGlobs = readPackageJsonWorkspaceGlobs(rootDir);
595
+ if (packageJsonGlobs.length > 0) return {
596
+ globs: packageJsonGlobs,
597
+ source: "package.json#workspaces"
598
+ };
599
+ return {
600
+ globs: [...WORKSPACE_GLOB_FALLBACK],
601
+ source: "fallback"
602
+ };
603
+ }
604
+ /**
605
+ * Reads a top-level `key:` string list out of `pnpm-workspace.yaml`.
606
+ *
607
+ * Deliberately dependency-free: the shape consumed here is a fixed, tiny list
608
+ * of globs, and a read-only dev server should not pull in a YAML parser for it.
609
+ * Handles both block sequences and a single-line flow sequence.
610
+ */
611
+ function parseYamlStringList(content, key) {
612
+ const keyPattern = new RegExp(`^${escapeRegExp(key)}\\s*:\\s*(.*)$`);
613
+ const values = [];
614
+ let inBlock = false;
615
+ for (const line of content.split(/\r?\n/)) {
616
+ if (!inBlock) {
617
+ const match = line.match(keyPattern);
618
+ if (!match) continue;
619
+ const rest = (match[1] ?? "").trim();
620
+ if (rest.startsWith("[")) return rest.replace(/^\[/, "").replace(/\]\s*$/, "").split(",").map((entry) => unquoteYamlScalar(entry)).filter(Boolean);
621
+ if (rest !== "" && !rest.startsWith("#")) continue;
622
+ inBlock = true;
623
+ continue;
624
+ }
625
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
626
+ const item = line.match(/^\s+-\s*(.+?)\s*$/);
627
+ if (!item) break;
628
+ const value = unquoteYamlScalar(item[1] ?? "");
629
+ if (value) values.push(value);
630
+ }
631
+ return values;
632
+ }
633
+ function unquoteYamlScalar(value) {
634
+ const trimmed = value.trim();
635
+ const quoted = trimmed.match(/^(['"])([\s\S]*?)\1/);
636
+ if (quoted) return (quoted[2] ?? "").trim();
637
+ return trimmed.replace(/\s+#.*$/, "").trim();
638
+ }
639
+ var WorkspaceGlobTraversalLimitError = class extends Error {
640
+ glob;
641
+ constructor(glob) {
642
+ super(`Workspace glob traversal exceeded ${MAX_WORKSPACE_GLOB_TRAVERSAL_ENTRIES} directory entries while expanding ${glob}`);
643
+ this.glob = glob;
644
+ this.name = "WorkspaceGlobTraversalLimitError";
645
+ }
646
+ };
647
+ function expandWorkspaceGlobs(rootDir, globs) {
648
+ const diagnostics = [];
649
+ const positiveGlobs = [];
650
+ const negations = [];
651
+ for (const rawGlob of globs) {
652
+ const negated = rawGlob.trim().startsWith("!");
653
+ const glob = normalizeGlob(negated ? rawGlob.trim().slice(1) : rawGlob.trim());
654
+ const unsafeReason = unsafeWorkspaceGlobReason(glob);
655
+ if (unsafeReason) {
656
+ diagnostics.push({
657
+ severity: "error",
658
+ code: "unsafe-workspace-glob",
659
+ message: `Rejected workspace glob ${JSON.stringify(rawGlob)}: ${unsafeReason}.`,
660
+ remedy: "Keep every workspace glob relative to the declared workspace root; absolute paths and parent-directory (`..`) segments are not supported."
661
+ });
662
+ continue;
663
+ }
664
+ if (!glob) continue;
665
+ if (negated) negations.push(globToRegExp(glob));
666
+ else positiveGlobs.push(glob);
667
+ }
668
+ const matched = /* @__PURE__ */ new Set();
669
+ const budget = { entries: 0 };
670
+ try {
671
+ for (const glob of positiveGlobs) for (const dir of expandGlob(rootDir, glob, budget)) matched.add(dir);
672
+ } catch (error) {
673
+ if (!(error instanceof WorkspaceGlobTraversalLimitError)) throw error;
674
+ diagnostics.push({
675
+ severity: "error",
676
+ code: "workspace-glob-expansion-limit",
677
+ message: error.message,
678
+ remedy: "Narrow the workspace package globs or remove repeated broad globstars. Discovery stopped without reading any partially matched package set."
679
+ });
680
+ return {
681
+ dirs: [],
682
+ diagnostics,
683
+ fatal: true
684
+ };
685
+ }
686
+ const resolvedRoot = realpathSync(rootDir);
687
+ const confined = [...matched].map((dir) => confinedRealPath(resolvedRoot, dir)).filter((dir) => dir !== void 0);
688
+ if (confined.length !== matched.size) diagnostics.push({
689
+ severity: "error",
690
+ code: "workspace-glob-root-escape",
691
+ message: "Rejected a workspace package directory whose real path resolves outside the workspace root.",
692
+ remedy: "Keep workspace packages inside the workspace root and do not route workspace globs through symlinks to sibling directories."
693
+ });
694
+ return {
695
+ dirs: [...new Set(confined)].filter((dir) => existsSync(join(dir, "package.json"))).filter((dir) => {
696
+ const rel = relative(rootDir, dir).replaceAll("\\", "/");
697
+ return rel !== "" && !negations.some((pattern) => pattern.test(rel));
698
+ }).sort(),
699
+ diagnostics,
700
+ fatal: false
701
+ };
702
+ }
703
+ function normalizeGlob(glob) {
704
+ return glob.trim().replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
393
705
  }
394
- function discoverProjectPackageDirs(rootDir) {
395
- const workspaceDirs = discoverWorkspacePackageDirs(rootDir);
396
- if (existsSync(join(rootDir, "package.json")) && hasLocalDomainArtifact(rootDir)) return [rootDir, ...workspaceDirs.filter((dir) => dir !== rootDir)];
397
- return workspaceDirs;
706
+ function unsafeWorkspaceGlobReason(glob) {
707
+ if (glob.includes("\0")) return "NUL bytes are not valid path content";
708
+ if (isAbsolute(glob) || /^[A-Za-z]:\//.test(glob)) return "absolute paths are outside the workspace trust boundary";
709
+ if (glob.split("/").includes("..")) return "parent-directory segments can escape the workspace root";
398
710
  }
399
- function hasLocalDomainArtifact(rootDir) {
400
- return [
401
- join(rootDir, ".smrt", "smrt-knowledge.json"),
402
- join(rootDir, ".smrt", "manifest.json"),
403
- join(rootDir, "dist", "smrt-knowledge.json"),
404
- join(rootDir, "dist", "manifest.json"),
405
- join(rootDir, "src", "manifest", "smrt-knowledge.json"),
406
- join(rootDir, "src", "manifest", "manifest.json")
407
- ].some((path) => existsSync(path));
711
+ function confinedRealPath(resolvedRoot, candidate) {
712
+ try {
713
+ const rel = relative(resolvedRoot, realpathSync(candidate));
714
+ if (rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)) return candidate;
715
+ } catch {}
716
+ }
717
+ function expandGlob(rootDir, glob, budget) {
718
+ const segments = glob.split("/").filter((segment) => segment !== "");
719
+ if (segments.length === 0) return [];
720
+ let current = [rootDir];
721
+ for (const [segmentIndex, segment] of segments.entries()) {
722
+ const next = [];
723
+ for (const dir of current) {
724
+ if (segment === "**") {
725
+ next.push(dir, ...descendantDirs(dir, budget, glob));
726
+ continue;
727
+ }
728
+ if (segment.includes("*")) {
729
+ const pattern = globToRegExp(segment);
730
+ next.push(...childDirs(dir, budget, glob).filter((child) => pattern.test(basename(child))));
731
+ continue;
732
+ }
733
+ const literal = join(dir, segment);
734
+ const isFinalSegment = segmentIndex === segments.length - 1;
735
+ if (isDirectory(literal) || isFinalSegment && isSymbolicLink(literal)) next.push(literal);
736
+ }
737
+ current = [...new Set(next)];
738
+ if (current.length === 0) break;
739
+ }
740
+ return current;
741
+ }
742
+ function childDirs(dir, budget, glob) {
743
+ const children = [];
744
+ try {
745
+ const directory = opendirSync(dir);
746
+ try {
747
+ let entry = directory.readSync();
748
+ while (entry !== null) {
749
+ budget.entries += 1;
750
+ if (budget.entries > MAX_WORKSPACE_GLOB_TRAVERSAL_ENTRIES) throw new WorkspaceGlobTraversalLimitError(glob);
751
+ if (entry.isDirectory() && !WALK_SKIP_DIRS.has(entry.name)) children.push(join(dir, entry.name));
752
+ entry = directory.readSync();
753
+ }
754
+ } finally {
755
+ directory.closeSync();
756
+ }
757
+ return children;
758
+ } catch (error) {
759
+ if (error instanceof WorkspaceGlobTraversalLimitError) throw error;
760
+ return children;
761
+ }
762
+ }
763
+ function descendantDirs(dir, budget, glob) {
764
+ const descendants = [];
765
+ const pending = [dir];
766
+ for (let index = 0; index < pending.length; index += 1) {
767
+ const children = childDirs(pending[index], budget, glob);
768
+ descendants.push(...children);
769
+ pending.push(...children);
770
+ }
771
+ return descendants;
772
+ }
773
+ function isDirectory(path) {
774
+ try {
775
+ return lstatSync(path).isDirectory();
776
+ } catch {
777
+ return false;
778
+ }
779
+ }
780
+ function isSymbolicLink(path) {
781
+ try {
782
+ return lstatSync(path).isSymbolicLink();
783
+ } catch {
784
+ return false;
785
+ }
786
+ }
787
+ /**
788
+ * Translates a workspace glob into an anchored regular expression. `*` stays
789
+ * within one path segment; `**` spans any depth (and collapses so `a/**` also
790
+ * matches `a`).
791
+ */
792
+ function globToRegExp(glob) {
793
+ const ANY_DEPTH = "\0";
794
+ const ANY_SEGMENT = "";
795
+ const source = glob.replace(/\*\*/g, ANY_DEPTH).replace(/\*/g, ANY_SEGMENT).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll(`/${ANY_DEPTH}`, "(?:/.*)?").replaceAll(`${ANY_DEPTH}/`, "(?:.*/)?").replaceAll(ANY_DEPTH, ".*").replaceAll(ANY_SEGMENT, "[^/]*");
796
+ return new RegExp(`^${source}$`);
797
+ }
798
+ function discoverProjectPackageDirs(rootDir) {
799
+ const { globs, source } = readWorkspaceGlobs(rootDir);
800
+ const expansion = expandWorkspaceGlobs(rootDir, globs);
801
+ const dirs = expansion.fatal ? [] : existsSync(join(rootDir, "package.json")) ? [rootDir, ...expansion.dirs.filter((dir) => resolve(dir) !== resolve(rootDir))] : expansion.dirs;
802
+ if (dirs.length > MAX_DISCOVERED_WORKSPACE_PACKAGES) return {
803
+ globs,
804
+ globSource: source,
805
+ dirs: [],
806
+ diagnostics: [...expansion.diagnostics, {
807
+ severity: "error",
808
+ code: "workspace-package-limit",
809
+ message: `Workspace discovery found ${dirs.length} packages, exceeding the ${MAX_DISCOVERED_WORKSPACE_PACKAGES}-package limit.`,
810
+ remedy: "Narrow the workspace package globs. Discovery stopped without reading any partially matched package set."
811
+ }]
812
+ };
813
+ return {
814
+ globs,
815
+ globSource: source,
816
+ dirs,
817
+ diagnostics: expansion.diagnostics
818
+ };
408
819
  }
409
820
  function discoverInstalledSdkPackages(rootDir, packageDirs, includeDocs) {
410
821
  return [join(rootDir, "node_modules", "@happyvertical"), ...packageDirs.map((dir) => join(dir, "node_modules", "@happyvertical"))].filter((scopeDir, index, all) => existsSync(scopeDir) && all.indexOf(scopeDir) === index).flatMap((scopeDir) => readdirSync(scopeDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() || entry.isSymbolicLink()).map((entry) => {
@@ -454,7 +865,14 @@ function readKnowledgePackage(rootDir, directory, includeDocs) {
454
865
  const domainKnowledge = readDomainKnowledge(directory);
455
866
  const docSource = hasAgentsMd ? "AGENTS.md" : fallbackClaudeDoc ? "CLAUDE.md" : null;
456
867
  const manifest = readManifest(directory);
457
- const objects = domainKnowledge ? readDomainKnowledgeObjects(domainKnowledge.content) : manifest ? readManifestObjects(manifest.content) : [];
868
+ const resolvedObjects = resolvePackageObjects({
869
+ packageName: name,
870
+ directory,
871
+ rootDir,
872
+ domainKnowledge,
873
+ manifest
874
+ });
875
+ const objects = resolvedObjects.objects;
458
876
  const prompts = domainKnowledge ? readDomainKnowledgePrompts(domainKnowledge.content, directory, rootDir) : readPrompts(directory, rootDir);
459
877
  const smrtDependencies = Object.keys(allDeps).filter((dep) => dep.startsWith("@happyvertical/smrt-")).sort();
460
878
  const sdkDependencies = Object.keys(allDeps).filter((dep) => SDK_PACKAGE_NAMES.has(dep)).sort();
@@ -485,8 +903,131 @@ function readKnowledgePackage(rootDir, directory, includeDocs) {
485
903
  objects,
486
904
  prompts,
487
905
  mcpTools: domainKnowledge ? domainMcpTools(domainKnowledge.content) : mcpTools(objects),
488
- relationshipFeatures: relationshipFeatures(objects)
906
+ relationshipFeatures: relationshipFeatures(objects),
907
+ isWorkspaceRoot: resolve(directory) === resolve(rootDir),
908
+ isPrivate: packageJson.private === true,
909
+ objectSource: resolvedObjects.source,
910
+ objectSourceReason: resolvedObjects.reason,
911
+ checkedObjectPaths: resolvedObjects.checkedPaths
912
+ };
913
+ }
914
+ /** Artifact paths consulted for a package's objects, in precedence order. */
915
+ function objectArtifactCandidates(directory) {
916
+ return [
917
+ join(directory, ".smrt", "smrt-knowledge.json"),
918
+ join(directory, "dist", "smrt-knowledge.json"),
919
+ join(directory, "src", "manifest", "smrt-knowledge.json"),
920
+ join(directory, "src", "manifest", "manifest.json"),
921
+ join(directory, ".smrt", "manifest.json"),
922
+ join(directory, "dist", "manifest.json")
923
+ ];
924
+ }
925
+ function resolvePackageObjects(options) {
926
+ const checkedPaths = objectArtifactCandidates(options.directory).map((path) => relativeOrAbsolute(options.rootDir, path));
927
+ if (options.domainKnowledge) return {
928
+ objects: readDomainKnowledgeObjects(options.domainKnowledge.content),
929
+ source: "domain-artifact",
930
+ checkedPaths
489
931
  };
932
+ if (!options.manifest) return {
933
+ objects: [],
934
+ source: "none",
935
+ reason: "no-artifact",
936
+ checkedPaths
937
+ };
938
+ const { owned, foreignCount } = partitionOwnedObjects(options.manifest.content, options.packageName);
939
+ const objects = readManifestObjects({ objects: owned });
940
+ if (objects.length === 0) return {
941
+ objects: [],
942
+ source: "none",
943
+ reason: foreignCount > 0 ? `manifest-objects-owned-by-other-packages (${foreignCount} rejected)` : "manifest-has-no-objects",
944
+ checkedPaths
945
+ };
946
+ return {
947
+ objects,
948
+ source: "manifest",
949
+ reason: foreignCount > 0 ? `rejected ${foreignCount} manifest object(s) owned by other packages` : void 0,
950
+ checkedPaths
951
+ };
952
+ }
953
+ function relativeOrAbsolute(rootDir, path) {
954
+ const rel = relative(rootDir, path);
955
+ return rel && !rel.startsWith("..") ? rel : path;
956
+ }
957
+ /**
958
+ * Resolves objects from source when no artifact could supply them (#2143).
959
+ *
960
+ * `introspect-project` already reports `manifestSource: "scanner"` and works on
961
+ * an unbuilt checkout. Without the same fallback here, an unbuilt package
962
+ * silently contributed nothing and the two tools disagreed about one root —
963
+ * which was the defect underneath every symptom in #2143.
964
+ *
965
+ * Mutates in place because `mcpTools` and `relationshipFeatures` are derived
966
+ * from `objects` and have to be recomputed together.
967
+ */
968
+ async function applyScannerFallback(pkg, extraExcludes = []) {
969
+ if (pkg.objectSource !== "none") return;
970
+ if (!hasScannableSources(pkg.directory)) {
971
+ pkg.objectSourceReason = `${pkg.objectSourceReason ?? "no-artifact"}; no-typescript-sources`;
972
+ return;
973
+ }
974
+ try {
975
+ const objects = await scanPackageObjects(pkg.directory, pkg.name, extraExcludes);
976
+ if (objects.length === 0) {
977
+ pkg.objectSourceReason = "no-smrt-objects-in-sources";
978
+ return;
979
+ }
980
+ pkg.objects = objects;
981
+ pkg.objectSource = "scanner";
982
+ pkg.objectSourceReason = void 0;
983
+ pkg.mcpTools = mcpTools(objects);
984
+ pkg.relationshipFeatures = relationshipFeatures(objects);
985
+ } catch (error) {
986
+ pkg.objectSourceReason = `scanner-failed: ${messageFromError(error)}`;
987
+ }
988
+ }
989
+ async function applyScannerFallbacks(packages, memberExcludes) {
990
+ for (let offset = 0; offset < packages.length; offset += MAX_SCANNER_CONCURRENCY) await Promise.all(packages.slice(offset, offset + MAX_SCANNER_CONCURRENCY).map((pkg) => applyScannerFallback(pkg, pkg.isWorkspaceRoot ? memberExcludes : [])));
991
+ }
992
+ /**
993
+ * Stops at the first candidate instead of walking a whole tree.
994
+ *
995
+ * Deliberately unbounded in depth: a package may keep its models at
996
+ * `src/features/billing/models/internal/Invoice.ts`, and a shallow probe would
997
+ * report "no TypeScript sources" for a package `OxcScanner` can actually read.
998
+ */
999
+ function hasScannableSources(directory) {
1000
+ let entries;
1001
+ try {
1002
+ entries = readdirSync(directory, { withFileTypes: true });
1003
+ } catch {
1004
+ return false;
1005
+ }
1006
+ const subdirectories = [];
1007
+ for (const entry of entries) {
1008
+ if (entry.isDirectory()) {
1009
+ if (!WALK_SKIP_DIRS.has(entry.name)) subdirectories.push(join(directory, entry.name));
1010
+ continue;
1011
+ }
1012
+ if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".spec.ts")) return true;
1013
+ }
1014
+ return subdirectories.some((child) => hasScannableSources(child));
1015
+ }
1016
+ async function scanPackageObjects(directory, packageName, extraExcludes = []) {
1017
+ const { results, resolved } = await new OxcScanner({
1018
+ cwd: directory,
1019
+ include: SCAN_INCLUDE,
1020
+ exclude: [...SCAN_EXCLUDE, ...extraExcludes]
1021
+ }).scanAndResolve();
1022
+ const decorated = resolved.filter((classDef) => classDef.hasSmartDecorator);
1023
+ if (decorated.length === 0) return [];
1024
+ return readManifestObjects(new ManifestAdapter().toManifest(decorated, {
1025
+ packageName,
1026
+ typeAliases: results.typeAliases
1027
+ }));
1028
+ }
1029
+ function messageFromError(error) {
1030
+ return error instanceof Error ? error.message : String(error);
490
1031
  }
491
1032
  function readDomainKnowledge(directory) {
492
1033
  for (const path of [
@@ -521,6 +1062,40 @@ function readManifest(directory) {
521
1062
  }
522
1063
  return null;
523
1064
  }
1065
+ /**
1066
+ * Rejects objects a manifest does not own (#2143).
1067
+ *
1068
+ * A runtime `.smrt/manifest.json` is frequently an *aggregate*: it registers
1069
+ * every object reachable from the app, including its dependencies'. Counting
1070
+ * those as the package's own doubles Relationships-v2 (a stale 759KB
1071
+ * `packages/cli/.smrt/manifest.json` reported 200 foreignKey / 413 UUID against
1072
+ * a real 89 / 206). Ownership is decided per object, so an aggregate still
1073
+ * contributes the objects it genuinely owns.
1074
+ */
1075
+ function partitionOwnedObjects(manifest, packageName) {
1076
+ const objects = objectRecord(manifest.objects);
1077
+ const owned = {};
1078
+ let foreignCount = 0;
1079
+ for (const [key, raw] of Object.entries(objects)) {
1080
+ if (manifestObjectIsOwned(objectRecord(raw), packageName)) {
1081
+ owned[key] = raw;
1082
+ continue;
1083
+ }
1084
+ foreignCount += 1;
1085
+ }
1086
+ return {
1087
+ owned,
1088
+ foreignCount
1089
+ };
1090
+ }
1091
+ function manifestObjectIsOwned(object, packageName) {
1092
+ const declared = typeof object.packageName === "string" ? object.packageName : void 0;
1093
+ if (declared) return declared === packageName;
1094
+ const qualifiedName = typeof object.qualifiedName === "string" ? object.qualifiedName : void 0;
1095
+ const qualifier = qualifiedName?.includes(":") ? qualifiedName.slice(0, qualifiedName.lastIndexOf(":")) : void 0;
1096
+ if (qualifier) return qualifier === packageName;
1097
+ return true;
1098
+ }
524
1099
  function readManifestObjects(manifest) {
525
1100
  const objects = objectRecord(manifest.objects);
526
1101
  return Object.values(objects).map((raw) => {
@@ -656,8 +1231,105 @@ function domainMcpTools(manifest) {
656
1231
  operation: surface.operation
657
1232
  })).sort((a, b) => a.name.localeCompare(b.name));
658
1233
  }
1234
+ /**
1235
+ * Identity of the table an object maps to, used to avoid counting one table
1236
+ * twice (#2143).
1237
+ *
1238
+ * A consuming app's generated artifact can re-qualify a dependency's objects
1239
+ * under the app's own package name (observed: 18 of `apps/work-web`'s 21
1240
+ * artifact objects are classes declared in `packages/work`, same tables). Name
1241
+ * prefixes cannot detect that, so corpus-level facts are keyed by class plus
1242
+ * table instead.
1243
+ */
1244
+ function objectIdentity(object) {
1245
+ return `${object.className}::${object.tableName ?? object.collection ?? ""}`;
1246
+ }
1247
+ /**
1248
+ * True when either package declares the other as a dependency.
1249
+ *
1250
+ * This is what separates a re-qualified copy from a coincidence. A consuming
1251
+ * package can only restate its *dependencies'* objects, so a shared identity
1252
+ * across a dependency edge is one table reported twice. Two unrelated packages
1253
+ * that happen to share a class and table name (observed: `Account::accounts` in
1254
+ * both smrt-messages and smrt-ledgers) are genuinely distinct objects and must
1255
+ * both keep contributing their fields.
1256
+ */
1257
+ function hasDependencyEdge(a, b) {
1258
+ return dependsOn(a, b.name) || dependsOn(b, a.name);
1259
+ }
1260
+ /**
1261
+ * Picks the copy that owns the class within one connected group.
1262
+ *
1263
+ * Ownership follows the dependency direction: a consumer can restate its
1264
+ * *dependency's* objects, never the reverse, so the entry that the most other
1265
+ * members of the group depend on is the declaring package. Provenance alone is
1266
+ * not enough — preferring a scanner copy kept `smrt-support`'s compatibility
1267
+ * subtype over the canonical `smrt-projects` model and changed the facts.
1268
+ */
1269
+ function pickOwningEntry(entries) {
1270
+ let best = entries[0];
1271
+ let bestScore = -1;
1272
+ for (const entry of entries) {
1273
+ const score = entries.filter((other) => other !== entry && dependsOn(other.pkg, entry.pkg.name)).length;
1274
+ if (score > bestScore) {
1275
+ best = entry;
1276
+ bestScore = score;
1277
+ }
1278
+ }
1279
+ return best;
1280
+ }
1281
+ function dependsOn(pkg, name) {
1282
+ return name in pkg.dependencies || name in pkg.devDependencies || name in pkg.peerDependencies;
1283
+ }
1284
+ function dedupeObjectsByIdentity(packages) {
1285
+ return [...groupObjectsByIdentity(packages).values()].flatMap((entries) => collapseIdentityGroup(entries).map((entry) => entry.object));
1286
+ }
1287
+ function groupObjectsByIdentity(packages) {
1288
+ const groups = /* @__PURE__ */ new Map();
1289
+ for (const pkg of packages) for (const object of pkg.objects) {
1290
+ const identity = objectIdentity(object);
1291
+ const entries = groups.get(identity) ?? [];
1292
+ entries.push({
1293
+ pkg,
1294
+ object
1295
+ });
1296
+ groups.set(identity, entries);
1297
+ }
1298
+ return groups;
1299
+ }
1300
+ /**
1301
+ * Collapses entries linked by dependency edges, leaving unrelated same-named
1302
+ * objects intact.
1303
+ *
1304
+ * Grouping is by connected component, not a greedy first match: when two
1305
+ * independent consumers both restate one shared dependency's object, neither
1306
+ * consumer has an edge to the other, so a greedy pass would keep both and
1307
+ * double-count the very table this exists to collapse. Packages arrive
1308
+ * name-sorted, so the result is stable.
1309
+ */
1310
+ function collapseIdentityGroup(entries) {
1311
+ if (entries.length === 1) return entries;
1312
+ const parent = entries.map((_, index) => index);
1313
+ const find = (index) => {
1314
+ let root = index;
1315
+ while (parent[root] !== root) root = parent[root];
1316
+ return root;
1317
+ };
1318
+ for (let a = 0; a < entries.length; a += 1) for (let b = a + 1; b < entries.length; b += 1) {
1319
+ if (!hasDependencyEdge(entries[a].pkg, entries[b].pkg)) continue;
1320
+ const rootA = find(a);
1321
+ const rootB = find(b);
1322
+ if (rootA !== rootB) parent[rootB] = rootA;
1323
+ }
1324
+ const components = /* @__PURE__ */ new Map();
1325
+ for (const [index, entry] of entries.entries()) {
1326
+ const root = find(index);
1327
+ components.set(root, [...components.get(root) ?? [], entry]);
1328
+ }
1329
+ return [...components.values()].map((component) => component.length === 1 ? component[0] : pickOwningEntry(component));
1330
+ }
659
1331
  function summarizeRelationshipsV2(packages) {
660
- const objects = packages.flatMap((pkg) => pkg.objects);
1332
+ const objects = dedupeObjectsByIdentity(packages);
661
1333
  const fields = objects.flatMap((object) => object.fields);
662
1334
  return {
663
1335
  foreignKeyFields: fields.filter((field) => field.type === "foreignKey").length,
@@ -710,7 +1382,7 @@ function shouldScanStalePatternFile(rootDir, filePath) {
710
1382
  function buildReviewFindings(index, changedFiles, selectedPackages) {
711
1383
  const issues = [];
712
1384
  for (const pkg of selectedPackages) {
713
- const changedPackageFiles = changedFiles.filter((file) => file === pkg.relativeDirectory || file.startsWith(`${pkg.relativeDirectory}/`));
1385
+ const changedPackageFiles = changedFiles.filter((file) => packageOwnsFile(pkg, file, index.packages));
714
1386
  if (!pkg.agentDoc && pkg.kind === "smrt") issues.push({
715
1387
  severity: "warning",
716
1388
  code: "missing-package-expertise",
@@ -769,14 +1441,47 @@ function buildReviewFindings(index, changedFiles, selectedPackages) {
769
1441
  }
770
1442
  return issues;
771
1443
  }
1444
+ /**
1445
+ * Joins a package-relative path onto a package's workspace-relative directory.
1446
+ *
1447
+ * The workspace root can itself be an indexed package — including the
1448
+ * single-package layout #2143 added — and its `relativeDirectory` is the empty
1449
+ * string. Naive interpolation emits `/AGENTS.md`, an absolute filesystem path,
1450
+ * instead of the project-relative path callers are told to read.
1451
+ */
1452
+ function packageRelativePath(pkg, path) {
1453
+ return pkg.relativeDirectory ? `${pkg.relativeDirectory}/${path}` : path;
1454
+ }
1455
+ /**
1456
+ * Workspace-relative paths of a package's authored documentation.
1457
+ *
1458
+ * Shared with the MCP transport layer so the summary projection and the
1459
+ * Markdown bundle can never disagree about where a doc lives.
1460
+ */
1461
+ function packageDocPaths(pkg) {
1462
+ return [...pkg.docSource ? [packageRelativePath(pkg, pkg.docSource)] : [], ...pkg.moduleDocs.map((doc) => packageRelativePath(pkg, doc.path))];
1463
+ }
1464
+ /**
1465
+ * True when a workspace-relative changed file belongs to `pkg`.
1466
+ *
1467
+ * A workspace-root package has an empty `relativeDirectory`, so the nested-path
1468
+ * test would compare against a leading `/` and never match — which silently
1469
+ * selected no packages for exactly the single-package layout #2143 added. The
1470
+ * root instead owns any path no member package owns, mirroring the
1471
+ * member-excluded scan in `applyScannerFallbacks`.
1472
+ */
1473
+ function packageOwnsFile(pkg, file, siblings) {
1474
+ if (pkg.relativeDirectory) return file === pkg.relativeDirectory || file.startsWith(`${pkg.relativeDirectory}/`);
1475
+ return !siblings.some((member) => member !== pkg && member.relativeDirectory && (file === member.relativeDirectory || file.startsWith(`${member.relativeDirectory}/`)));
1476
+ }
772
1477
  function isPackageManifestFile(pkg, file) {
773
- return file === `${pkg.relativeDirectory}/package.json`;
1478
+ return file === packageRelativePath(pkg, "package.json");
774
1479
  }
775
1480
  function isPackageAgentDocFile(pkg, file) {
776
- return file === `${pkg.relativeDirectory}/AGENTS.md` || pkg.moduleDocs.some((doc) => file === `${pkg.relativeDirectory}/${doc.path}`);
1481
+ return file === packageRelativePath(pkg, "AGENTS.md") || pkg.moduleDocs.some((doc) => file === packageRelativePath(pkg, doc.path));
777
1482
  }
778
1483
  function isPublicEntrypointFile(pkg, file) {
779
- const sourcePrefix = `${pkg.relativeDirectory}/src/`;
1484
+ const sourcePrefix = packageRelativePath(pkg, "src/");
780
1485
  if (!file.startsWith(sourcePrefix)) return false;
781
1486
  const sourcePath = file.slice(sourcePrefix.length);
782
1487
  return sourcePath === "index.ts" || sourcePath === "index.tsx" || sourcePath === "index.js" || sourcePath.startsWith("api/") || sourcePath.startsWith("cli/") || sourcePath.startsWith("mcp/") || sourcePath.startsWith("tools/");
@@ -785,7 +1490,7 @@ function buildArchitectureRecommendations(context, ideaText) {
785
1490
  return {
786
1491
  smrtPackages: context.selectedPackages.map((pkg) => pkg.name),
787
1492
  sdkPackages: context.selectedSdkPackages.map((pkg) => pkg.name),
788
- objectModelSketch: buildObjectModelSketch(context.selectedPackages),
1493
+ objectModelSketch: buildObjectModelSketch(context.selectedPackages, context.diagnostics),
789
1494
  risks: buildArchitectureRisks(context.selectedPackages, ideaText),
790
1495
  questions: buildArchitectureQuestions(context.selectedPackages, ideaText),
791
1496
  notes: [
@@ -795,10 +1500,12 @@ function buildArchitectureRecommendations(context, ideaText) {
795
1500
  ]
796
1501
  };
797
1502
  }
798
- function buildObjectModelSketch(packages) {
1503
+ function buildObjectModelSketch(packages, diagnostics = []) {
1504
+ const blocking = diagnostics.find((diagnostic) => diagnostic.code === "no-smrt-objects-discovered");
1505
+ if (blocking) return [`No object model could be derived: ${blocking.message}`, `Remediation: ${blocking.remedy ?? "verify workspace discovery."}`];
799
1506
  const lines = packages.flatMap((pkg) => {
800
1507
  const objects = pkg.objects.filter((object) => object.extends !== "SmrtCollection").slice(0, 6).map((object) => object.className);
801
- if (objects.length === 0) return [`${pkg.name}: use package services or templates; no manifest objects indexed.`];
1508
+ if (objects.length === 0) return [`${pkg.name}: use package services or templates; no manifest objects indexed (${pkg.objectSourceReason ?? pkg.objectSource}).`];
802
1509
  return [`${pkg.name}: start from ${objects.join(", ")}.`];
803
1510
  });
804
1511
  return lines.length > 0 ? lines : ["Define the core domain as SmrtObject classes with explicit relationships and generated surfaces."];
@@ -832,7 +1539,8 @@ function buildArchitectureQuestions(packages, ideaText) {
832
1539
  }
833
1540
  function selectPackagesForFiles(index, changedFiles) {
834
1541
  const selected = /* @__PURE__ */ new Set();
835
- for (const file of changedFiles) for (const pkg of domainPackages(index)) if (file === pkg.relativeDirectory || file.startsWith(`${pkg.relativeDirectory}/`)) selected.add(pkg);
1542
+ const candidates = domainPackages(index);
1543
+ for (const file of changedFiles) for (const pkg of candidates) if (packageOwnsFile(pkg, file, index.packages)) selected.add(pkg);
836
1544
  return [...selected].sort((a, b) => a.name.localeCompare(b.name));
837
1545
  }
838
1546
  function selectPackages(index, options) {
@@ -848,15 +1556,21 @@ function selectPackages(index, options) {
848
1556
  if (includesToken(text, pkg.name.replace("@happyvertical/smrt-", "")) || text.includes(pkg.name.toLowerCase()) || pkg.objects.some((object) => includesToken(text, object.className.toLowerCase()))) selected.add(pkg);
849
1557
  }
850
1558
  if (selected.size === 0 && options.scope === "local") for (const pkg of domainPackages(index).filter((item) => scopeAllowsPackage(item, "local"))) selected.add(pkg);
851
- if (selected.size === 0 && options.scope !== "sdk") for (const name of [
852
- "@happyvertical/smrt-core",
853
- "@happyvertical/smrt-config",
854
- "@happyvertical/smrt-cli",
855
- "@happyvertical/smrt-scanner",
856
- "@happyvertical/smrt-dev-mcp"
857
- ]) {
858
- const pkg = index.smrtPackages.find((item) => item.name === name);
859
- if (pkg) selected.add(pkg);
1559
+ if (selected.size === 0 && options.scope !== "sdk") {
1560
+ for (const name of [
1561
+ "@happyvertical/smrt-core",
1562
+ "@happyvertical/smrt-config",
1563
+ "@happyvertical/smrt-cli",
1564
+ "@happyvertical/smrt-scanner",
1565
+ "@happyvertical/smrt-dev-mcp"
1566
+ ]) {
1567
+ const pkg = index.smrtPackages.find((item) => item.name === name);
1568
+ if (pkg) selected.add(pkg);
1569
+ }
1570
+ if (selected.size === 0) {
1571
+ const contributing = domainPackages(index).filter((pkg) => pkg.objects.length > 0 && scopeAllowsPackage(pkg, options.scope) && !pkg.relativeDirectory.includes("node_modules")).sort((a, b) => b.objects.length - a.objects.length).slice(0, MAX_FALLBACK_PACKAGES);
1572
+ for (const pkg of contributing) selected.add(pkg);
1573
+ }
860
1574
  }
861
1575
  return [...selected].sort((a, b) => a.name.localeCompare(b.name));
862
1576
  }
@@ -898,12 +1612,14 @@ function packageMatches(pkg, query) {
898
1612
  return pkg.name.toLowerCase() === normalized || pkg.name.toLowerCase().includes(normalized) || shortName.toLowerCase() === normalized || pkg.relativeDirectory.toLowerCase().endsWith(`/${normalized}`);
899
1613
  }
900
1614
  function buildPromptBundle(options) {
901
- const render = (pkg) => renderPackageContext(pkg, options.moduleDocHints);
1615
+ const detail = options.detail ?? "summary";
1616
+ const render = (pkg) => renderPackageContext(pkg, options.moduleDocHints, detail);
902
1617
  const contextMarkdown = [
903
1618
  `# ${options.title}`,
904
1619
  "",
905
1620
  `Baseline root: ${options.index.rootDir}`,
906
1621
  "",
1622
+ ...renderDiagnosticsSection(options.index.diagnostics),
907
1623
  "## Task",
908
1624
  "",
909
1625
  options.task,
@@ -924,13 +1640,24 @@ function buildPromptBundle(options) {
924
1640
  ];
925
1641
  return {
926
1642
  title: options.title,
927
- instructions: "Use the supplied SMRT knowledge context as source material. Return concrete findings or architecture guidance with package names and source references. Do not assume model-provider access.",
1643
+ instructions: ["Use the supplied SMRT knowledge context as source material. Return concrete findings or architecture guidance with package names and source references. Do not assume model-provider access.", detail === "summary" ? "Authored package docs are listed by path rather than embedded; read the ones you need, or re-request with detail: \"full\"." : ""].filter(Boolean).join(" "),
928
1644
  contextMarkdown: contextMarkdown.join("\n"),
929
1645
  selectedPackages: options.packages.map((pkg) => pkg.name),
930
1646
  selectedSdkPackages: options.sdkPackages.map((pkg) => pkg.name),
931
1647
  sourceFiles: options.sourceFiles
932
1648
  };
933
1649
  }
1650
+ function renderDiagnosticsSection(diagnostics) {
1651
+ if (diagnostics.length === 0) return [];
1652
+ const lines = ["## Diagnostics", ""];
1653
+ for (const diagnostic of diagnostics) {
1654
+ lines.push(`- [${diagnostic.severity.toUpperCase()}] ${diagnostic.code}: ${diagnostic.message}`);
1655
+ if (diagnostic.checkedPaths && diagnostic.checkedPaths.length > 0) lines.push(` - checked: ${diagnostic.checkedPaths.join(", ")}`);
1656
+ if (diagnostic.remedy) lines.push(` - remedy: ${diagnostic.remedy}`);
1657
+ }
1658
+ lines.push("");
1659
+ return lines;
1660
+ }
934
1661
  /**
935
1662
  * Which of a package's module docs to embed for this request (#2108).
936
1663
  *
@@ -946,7 +1673,8 @@ function selectModuleDocs(pkg, hints) {
946
1673
  embedded: [],
947
1674
  scoped: false
948
1675
  };
949
- const packageFiles = (hints?.changedFiles ?? []).filter((file) => file === pkg.relativeDirectory || file.startsWith(`${pkg.relativeDirectory}/`));
1676
+ const prefix = pkg.relativeDirectory ? `${pkg.relativeDirectory}/` : "";
1677
+ const packageFiles = (hints?.changedFiles ?? []).filter((file) => file === pkg.relativeDirectory || file.startsWith(prefix));
950
1678
  const text = hints?.text ?? "";
951
1679
  if (packageFiles.length === 0 && text.trim() === "") return {
952
1680
  embedded: all,
@@ -954,7 +1682,7 @@ function selectModuleDocs(pkg, hints) {
954
1682
  };
955
1683
  const matched = all.filter((doc) => {
956
1684
  const segment = new RegExp(`(^|[/.])${escapeRegExp(doc.module)}([/.]|$)`);
957
- return packageFiles.some((file) => segment.test(file.slice(pkg.relativeDirectory.length + 1)) || file === `${pkg.relativeDirectory}/${doc.path}`) || includesToken(text, doc.module);
1685
+ return packageFiles.some((file) => segment.test(file.slice(prefix.length)) || file === packageRelativePath(pkg, doc.path)) || includesToken(text, doc.module);
958
1686
  });
959
1687
  return matched.length > 0 ? {
960
1688
  embedded: matched,
@@ -967,7 +1695,7 @@ function selectModuleDocs(pkg, hints) {
967
1695
  function escapeRegExp(value) {
968
1696
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
969
1697
  }
970
- function renderPackageContext(pkg, hints) {
1698
+ function renderPackageContext(pkg, hints, detail = "full") {
971
1699
  const { embedded, scoped } = selectModuleDocs(pkg, hints);
972
1700
  const lines = [
973
1701
  `### ${pkg.name}`,
@@ -976,6 +1704,7 @@ function renderPackageContext(pkg, hints) {
976
1704
  `- kind: ${pkg.kind}`,
977
1705
  `- directory: ${pkg.relativeDirectory}`,
978
1706
  `- domain knowledge: ${pkg.domainKnowledgePath ?? "(manifest fallback)"}`,
1707
+ `- object source: ${pkg.objectSource}${pkg.objectSourceReason ? ` (${pkg.objectSourceReason})` : ""}`,
979
1708
  `- docs: ${[pkg.docSource ?? "(none)", ...pkg.moduleDocs.map((doc) => doc.path)].join(", ")}`,
980
1709
  `- relationship features: ${pkg.relationshipFeatures.join(", ") || "(none)"}`,
981
1710
  `- SDK deps: ${pkg.sdkDependencies.join(", ") || "(none)"}`,
@@ -984,14 +1713,20 @@ function renderPackageContext(pkg, hints) {
984
1713
  `- objects: ${pkg.objects.slice(0, 20).map((object) => object.qualifiedName ?? object.className).join(", ")}`
985
1714
  ];
986
1715
  if (pkg.objects.length > 20) lines.push(`- object count: ${pkg.objects.length}`);
1716
+ if (detail === "summary") {
1717
+ const docPaths = packageDocPaths(pkg);
1718
+ if (docPaths.length > 0) lines.push("", `> Authored docs not embedded (read on demand, or re-request with detail: "full"): ${docPaths.join(", ")}`);
1719
+ lines.push("");
1720
+ return lines.join("\n");
1721
+ }
987
1722
  if (pkg.agentDoc) lines.push("", pkg.agentDoc.trim());
988
1723
  for (const doc of embedded) {
989
- lines.push("", `#### ${pkg.relativeDirectory}/${doc.path}`, "");
1724
+ lines.push("", `#### ${packageRelativePath(pkg, doc.path)}`, "");
990
1725
  lines.push(doc.content.trim());
991
1726
  }
992
1727
  if (scoped) {
993
1728
  const omitted = pkg.moduleDocs.filter((doc) => !embedded.includes(doc));
994
- if (omitted.length > 0) lines.push("", `> Module docs not loaded for this request (read on demand): ${omitted.map((doc) => `${pkg.relativeDirectory}/${doc.path}`).join(", ")}`);
1729
+ if (omitted.length > 0) lines.push("", `> Module docs not loaded for this request (read on demand): ${omitted.map((doc) => packageRelativePath(pkg, doc.path)).join(", ")}`);
995
1730
  }
996
1731
  lines.push("");
997
1732
  return lines.join("\n");
@@ -1099,6 +1834,6 @@ function sortJson(value) {
1099
1834
  return value;
1100
1835
  }
1101
1836
  //#endregion
1102
- export { checkKnowledgeFreshnessFromIndex as a, renderKnowledgeIndexMarkdown as c, checkKnowledgeFreshness as i, smrtArchitecture as l, buildKnowledgeIndex as n, diffKnowledgeIndex as o, buildReviewContext as r, renderFreshnessResult as s, buildArchitectureContext as t, smrtReview as u };
1837
+ export { checkKnowledgeFreshnessFromIndex as a, renderFreshnessResult as c, smrtReview as d, checkKnowledgeFreshness as i, renderKnowledgeIndexMarkdown as l, buildKnowledgeIndex as n, diffKnowledgeIndex as o, buildReviewContext as r, packageDocPaths as s, buildArchitectureContext as t, smrtArchitecture as u };
1103
1838
 
1104
- //# sourceMappingURL=knowledge-BPbgCtVJ.js.map
1839
+ //# sourceMappingURL=knowledge-DUUhTM5u.js.map