Skip to content

Commit 6040d40

Browse files
committed
feat(harness): support monorepo workspace topology
Introduce a frozen workspace-topology contract and thread package scope through evidence collection, provider sessions, inherited assets, findings, rendering, and repair routing. Implements docs/specs/2026-07-25-monorepo-workspace-support.md. The change keeps legacy findings readable, fails closed on incomplete or cross-package ownership, and was validated with npm test, npm run pack:verify, direct doc-link checks, and preview endpoint smoke tests.
1 parent 205d4e0 commit 6040d40

68 files changed

Lines changed: 6728 additions & 489 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/specs/2026-07-25-monorepo-workspace-support.md

Lines changed: 391 additions & 92 deletions
Large diffs are not rendered by default.

scripts/agent-customize/core/items.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ function allowedSourceScopes(scopeKind = "user", tab = "plugins") {
719719
return tab === "mcps" ? new Set(["team", "dashboard"]) : new Set(["team"]);
720720
}
721721
if (scopeKind === "workspace" || scopeKind === "project") {
722-
return new Set(["project"]);
722+
return new Set(["project", "inherited"]);
723723
}
724724
return new Set(["user", "plugin"]);
725725
}

scripts/agent-lint/index.mjs

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parseFrontmatter } from "../agent-customize/core/items.mjs";
66
import { enrichFindingWithRecommendation } from "../findings-recommend.mjs";
77
import { isDirectory, pathExists } from "../session-analysis/fs.mjs";
88
import { normalizeWorkspace } from "../session-analysis/paths.mjs";
9+
import { ownerRouteForPath, routeContains } from "../workspace-topology/index.mjs";
910
import { reviewHostInstructions } from "./host-instructions.mjs";
1011
import { reviewHookAssets } from "./hook-review.mjs";
1112

@@ -480,9 +481,81 @@ async function collectNestedEntrypoints(workspace, maxEntrypointDepth, provider)
480481
}));
481482
}
482483

484+
function topologyScopeOwnerRoute(route) {
485+
if (route === ".claude/CLAUDE.md" || route === ".github/copilot-instructions.md") return ".";
486+
for (const marker of ["/.claude/rules/", "/.cursor/rules/", "/.qoder/rules/"]) {
487+
const normalized = `/${route}`;
488+
const index = normalized.indexOf(marker);
489+
if (index !== -1) {
490+
return normalized.slice(1, index) || ".";
491+
}
492+
}
493+
const owner = path.posix.dirname(route);
494+
return owner === "." ? "." : owner;
495+
}
496+
497+
function topologyScopeSourceKind(route) {
498+
const base = path.posix.basename(route);
499+
if (route === ".github/copilot-instructions.md") return "copilot-instructions";
500+
if (route.includes("/.claude/rules/") || route.startsWith(".claude/rules/")) return "claude-rule";
501+
if (route.includes("/.cursor/rules/") || route.startsWith(".cursor/rules/")) return "cursor-rule";
502+
if (route.includes("/.qoder/rules/") || route.startsWith(".qoder/rules/")) return "qoder-rule";
503+
if (base === "AGENTS.md") return route === "AGENTS.md" ? "agents-md" : "nested-agent-guide";
504+
if (base === "CLAUDE.md") return route === "CLAUDE.md" ? "claude-md" : "nested-agent-guide";
505+
if (base === "CLAUDE.local.md") return "claude-local";
506+
return "nested-agent-guide";
507+
}
508+
509+
function topologyScopeApplies(topology, scope) {
510+
if (topology.target.route === ".") return true;
511+
const ownerRoute = topologyScopeOwnerRoute(scope.route);
512+
return routeContains(ownerRoute, topology.target.route)
513+
|| routeContains(topology.target.route, ownerRoute);
514+
}
515+
516+
async function topologyEntrypoints(topology, provider) {
517+
const workspace = normalizeWorkspace(topology.gitRoot ?? topology.requestedWorkspace);
518+
const selected = (topology.instructionScopes?.items ?? [])
519+
.filter((scope) => !provider || scope.provider === provider)
520+
.filter((scope) => topologyScopeApplies(topology, scope));
521+
const grouped = new Map();
522+
523+
for (const scope of selected) {
524+
const current = grouped.get(scope.route);
525+
const providers = [...new Set([...(current?.providers ?? []), scope.provider])].sort();
526+
grouped.set(scope.route, {
527+
route: scope.route,
528+
providers,
529+
activation: current && (current.activation !== "effective" || scope.activation !== "effective")
530+
? "candidate"
531+
: scope.activation,
532+
});
533+
}
534+
535+
const entrypoints = [];
536+
for (const scope of [...grouped.values()].sort((left, right) => left.route.localeCompare(right.route))) {
537+
const filePath = path.join(workspace, ...scope.route.split("/"));
538+
if (!await pathExists(filePath)) continue;
539+
const sourceKind = topologyScopeSourceKind(scope.route);
540+
entrypoints.push({
541+
path: filePath,
542+
relativePath: scope.route,
543+
sourceKind,
544+
nested: sourceKind === "nested-agent-guide",
545+
activation: scope.activation,
546+
packageRoute: ownerRouteForPath(topology, scope.route),
547+
...(provider ? { provider } : { providers: scope.providers }),
548+
});
549+
}
550+
return entrypoints;
551+
}
552+
483553
export async function discoverAgentEntrypoints(options = {}) {
484-
const workspace = normalizeWorkspace(options.workspace);
554+
const topology = options.topology;
485555
const provider = options.provider ? String(options.provider).toLowerCase() : undefined;
556+
if (topology) return topologyEntrypoints(topology, provider);
557+
558+
const workspace = normalizeWorkspace(options.workspace);
486559
const maxEntrypointDepth = Number(options.maxEntrypointDepth ?? options["max-entrypoint-depth"] ?? 4);
487560
const entrypoints = [];
488561

@@ -537,7 +610,9 @@ function summarizeEntrypoints(entrypoints) {
537610
}
538611

539612
export async function collectAgentInstructionGraph(options = {}) {
540-
const workspace = normalizeWorkspace(options.workspace);
613+
const workspace = options.topology
614+
? normalizeWorkspace(options.topology.gitRoot ?? options.topology.requestedWorkspace)
615+
: normalizeWorkspace(options.workspace);
541616
const maxReferenceDepth = Number(options.maxReferenceDepth ?? options["max-reference-depth"] ?? 0);
542617
const entrypoints = await discoverAgentEntrypoints({ ...options, workspace });
543618
const queue = entrypoints.map((entrypoint) => ({ ...entrypoint, filePath: entrypoint.path, depth: 0 }));
@@ -556,6 +631,10 @@ export async function collectAgentInstructionGraph(options = {}) {
556631
const parsed = await parseFile(current.filePath, workspace, {
557632
sourceKind: current.sourceKind,
558633
entrypoint: current.depth === 0,
634+
...(current.activation ? { activation: current.activation } : {}),
635+
...(current.packageRoute ? { packageRoute: current.packageRoute } : {}),
636+
...(current.provider ? { provider: current.provider } : {}),
637+
...(current.providers ? { providers: current.providers } : {}),
559638
});
560639
const references = [];
561640
for (const link of parsed.links) {
@@ -734,6 +813,8 @@ function finding(id, severity, evidence, remediation, options = {}) {
734813
"assetName",
735814
"scope",
736815
"sourceLabel",
816+
"packageRoute",
817+
"ownerRoute",
737818
]) {
738819
if (options[key] !== undefined) {
739820
result[key] = options[key];
@@ -1576,18 +1657,29 @@ async function singleWorkspacePayload(options = {}) {
15761657
: options.profile === PROFILE_AGENT_ASSETS_REVIEW
15771658
? await applyAgentAssetsReviewProfile(graph, options)
15781659
: { findings: [], manifestEvidence: [], assetInventory: undefined };
1660+
const findings = profileResult.findings.map((item) => {
1661+
if (!options.topology || typeof item?.file !== "string") return item;
1662+
const route = normalizeSlash(item.file);
1663+
if (!route || path.isAbsolute(route) || route === ".." || route.startsWith("../")) return item;
1664+
const packageRoute = ownerRouteForPath(options.topology, route);
1665+
return {
1666+
...item,
1667+
packageRoute,
1668+
ownerRoute: packageRoute,
1669+
};
1670+
});
15791671
return {
15801672
kind: "agent-lint",
15811673
profile: options.profile,
15821674
summary: {
15831675
...summarizeGraph(graph),
1584-
...summarizeFindings(profileResult.findings),
1676+
...summarizeFindings(findings),
15851677
},
15861678
graph,
15871679
manifestEvidence: profileResult.manifestEvidence,
15881680
hostInstructionReview: profileResult.hostInstructionReview,
15891681
assetInventory: profileResult.assetInventory,
1590-
findings: profileResult.findings,
1682+
findings,
15911683
};
15921684
}
15931685

scripts/better-harness-cli/cli.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const GROUP_EXAMPLES = {
6565
"harness": [
6666
{ audience: "workflow", text: "better-harness harness analyze --workspace . --language en --format json" },
6767
{ audience: "workflow", text: "better-harness harness checkup --phase scan --provider qoder --workspace . --json" },
68+
{ audience: "advanced", text: "better-harness harness workspace-topology --workspace . --json" },
6869
{ audience: "maintainer", text: "better-harness harness source --workspace . --source <scratch>/report.source.json --language en" },
6970
{ audience: "advanced", text: "better-harness harness render --findings <input>/findings.json --mode qoder-canvas --out .qoder/better-harness --target . --validate --json" },
7071
{ audience: "advanced", text: "better-harness harness preview-canvas <run>/report.canvas.tsx --open" },

scripts/better-harness-cli/registry.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ const COMMANDS = [
169169
summary: "Collect the three specialist lanes and lead evidence in one frozen-context bundle.",
170170
description: "Return versioned Session Evidence, Project Harness, and Agent Customize envelopes with explicit lane status and unchanged diagnostic commands.",
171171
},
172+
{
173+
name: "workspace-topology",
174+
audience: "advanced",
175+
script: "workspace-topology/cli.mjs",
176+
summary: "Resolve the Git-aware workspace target and member topology.",
177+
description: "Report the canonical repository target, workspace members, instruction scopes, bounded inventory coverage, and path-scoped analysis contract without mutating the workspace.",
178+
},
172179
{
173180
name: "analyze",
174181
audience: "workflow",

scripts/coding-agent-practices/asset-baseline.mjs

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const OWNER_KIND_RANK = Object.freeze({
2828
agents: 7,
2929
workflows: 8,
3030
});
31-
const OWNER_SCOPE_RANK = Object.freeze({ workspace: 0, project: 0, user: 1, plugin: 2 });
31+
const OWNER_SCOPE_RANK = Object.freeze({ workspace: 0, project: 0, inherited: 1, user: 2, plugin: 3 });
3232

3333
function text(value, limit = 320) {
3434
return String(value ?? "").replace(/[\u0000-\u001f\u007f]/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit);
@@ -67,6 +67,7 @@ function compactFindings(findings = []) {
6767
items: ordered.slice(0, MAX_BASELINE_FINDINGS).map(compactFinding),
6868
total: ordered.length,
6969
omitted: Math.max(0, ordered.length - MAX_BASELINE_FINDINGS),
70+
truncated: ordered.length > MAX_BASELINE_FINDINGS,
7071
};
7172
}
7273

@@ -91,7 +92,9 @@ function ownerRoutes(inventory, workspace) {
9192
name,
9293
version: text(item?.version, 32),
9394
owner: text(item?.pluginName ?? item?.ownerName ?? item?.sourceLabel, 96),
94-
route: workspaceRoute(item?.path ?? item?.filePath, workspace),
95+
route: text(item?.originRoute, 180)
96+
|| workspaceRoute(item?.path ?? item?.filePath, workspace),
97+
effectiveTarget: text(item?.effectiveTarget, 180),
9598
}).filter(([, value]) => value !== undefined && value !== ""));
9699
const key = [route.kind, route.scope, route.name, route.version, route.owner, route.route].join(":");
97100
if (!routes.has(key)) routes.set(key, route);
@@ -125,6 +128,7 @@ function ownerRoutes(inventory, workspace) {
125128
items: selected,
126129
total: ordered.length,
127130
omitted: Math.max(0, ordered.length - MAX_BASELINE_OWNER_ROUTES),
131+
truncated: ordered.length > MAX_BASELINE_OWNER_ROUTES,
128132
};
129133
}
130134

@@ -197,6 +201,81 @@ function available(data) {
197201
return { status: "available", data };
198202
}
199203

204+
function inheritedWorkspaceRoots(topology, workspace) {
205+
if (!topology?.gitRoot
206+
|| !new Set(["workspace-member", "repo-subtree"]).has(topology.target?.kind)) {
207+
return [];
208+
}
209+
const relative = path.relative(topology.gitRoot, workspace);
210+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return [];
211+
const parts = relative.split(path.sep).filter(Boolean);
212+
const roots = [topology.gitRoot];
213+
for (let index = 1; index < parts.length; index += 1) {
214+
roots.push(path.join(topology.gitRoot, ...parts.slice(0, index)));
215+
}
216+
return roots;
217+
}
218+
219+
function rawItemPath(item) {
220+
return item?.evidence?.path ?? item?.filePath ?? item?.rootPath;
221+
}
222+
223+
function inheritedItem(item, topology) {
224+
const filePath = rawItemPath(item);
225+
const relative = filePath ? path.relative(topology.gitRoot, path.resolve(filePath)) : "";
226+
const originRoute = relative
227+
&& !relative.startsWith("..")
228+
&& !path.isAbsolute(relative)
229+
? relative.split(path.sep).join("/")
230+
: undefined;
231+
return {
232+
...item,
233+
scope: "inherited",
234+
originScope: "inherited",
235+
originRoute,
236+
effectiveTarget: topology.target.route,
237+
};
238+
}
239+
240+
function mergeInheritedInventories(projectInventory, inheritedInventories, topology) {
241+
const manage = Object.fromEntries(
242+
Object.entries(projectInventory.manage ?? {}).map(([collection, items]) => [collection, [...items]]),
243+
);
244+
for (const inventory of inheritedInventories) {
245+
for (const [collection, items] of Object.entries(inventory.manage ?? {})) {
246+
const target = manage[collection] ?? [];
247+
for (const item of items) {
248+
if (item?.scope !== "project") continue;
249+
const inherited = inheritedItem(item, topology);
250+
const key = [
251+
inherited.id,
252+
inherited.kind,
253+
inherited.name ?? inherited.displayName ?? inherited.label,
254+
rawItemPath(inherited),
255+
].join(":");
256+
if (!target.some((candidate) => [
257+
candidate.id,
258+
candidate.kind,
259+
candidate.name ?? candidate.displayName ?? candidate.label,
260+
rawItemPath(candidate),
261+
].join(":") === key)) {
262+
target.push(inherited);
263+
}
264+
}
265+
manage[collection] = target;
266+
}
267+
}
268+
return {
269+
...projectInventory,
270+
manage,
271+
diagnostics: {
272+
...(projectInventory.diagnostics ?? {}),
273+
inheritedWorkspaceCount: inheritedInventories.length,
274+
inheritedTargetRoute: topology.target.route,
275+
},
276+
};
277+
}
278+
200279
export async function collectAssetBaseline(options = {}, dependencies = {}) {
201280
const provider = options.provider ?? options.platform ?? "qoder";
202281
if (!PROVIDERS.has(provider)) {
@@ -224,6 +303,20 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) {
224303
let rawInventory;
225304
try {
226305
rawInventory = await collectRawInventory(common);
306+
const inheritedRoots = inheritedWorkspaceRoots(options.topology, workspace);
307+
if (inheritedRoots.length > 0) {
308+
const inheritedInventories = [];
309+
for (const inheritedWorkspace of inheritedRoots) {
310+
inheritedInventories.push(await collectRawInventory({
311+
...common,
312+
workspace: inheritedWorkspace,
313+
includeUserHome: false,
314+
includeGlobalHooks: false,
315+
includeMemories: false,
316+
}));
317+
}
318+
rawInventory = mergeInheritedInventories(rawInventory, inheritedInventories, options.topology);
319+
}
227320
} catch (error) {
228321
const failed = unavailable(error, "inventory");
229322
return {
@@ -262,17 +355,28 @@ export async function collectAssetBaseline(options = {}, dependencies = {}) {
262355
}
263356
const envelopes = { lint: lintEnvelope, inventory: inventoryEnvelope, integrity: integrityEnvelope };
264357
const availableCount = Object.values(envelopes).filter((envelope) => envelope.status === "available").length;
358+
const truncatedStages = [
359+
lintEnvelope.data?.findings?.truncated ? "lint-findings" : null,
360+
inventoryEnvelope.data?.ownerRoutes?.truncated ? "inventory-owner-routes" : null,
361+
integrityEnvelope.data?.findings?.truncated ? "integrity-findings" : null,
362+
].filter(Boolean);
265363
return {
266364
kind: ASSET_BASELINE_KIND,
267365
schemaVersion: ASSET_BASELINE_SCHEMA_VERSION,
268-
status: availableCount === 3 ? "complete" : availableCount === 0 ? "failed" : "partial",
366+
status: availableCount === 3 && truncatedStages.length === 0
367+
? "complete"
368+
: availableCount === 0
369+
? "failed"
370+
: "partial",
269371
scope: { provider, workspace, includeUserHome, includeMemories },
270372
envelopes,
271373
diagnostics: {
272374
sharedInventorySnapshot: true,
273375
compact: true,
274376
findingLimitPerEnvelope: MAX_BASELINE_FINDINGS,
275377
ownerRouteLimit: MAX_BASELINE_OWNER_ROUTES,
378+
inheritedWorkspaceCount: rawInventory?.diagnostics?.inheritedWorkspaceCount ?? 0,
379+
truncatedStages,
276380
},
277381
};
278382
}

scripts/coding-agent-practices/inventory.mjs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,9 @@ function customizeItem(item) {
485485
sourceKind: item.sourceKind,
486486
precedence: item.precedence,
487487
scope: item.scope,
488+
originScope: item.originScope,
489+
originRoute: item.originRoute,
490+
effectiveTarget: item.effectiveTarget,
488491
pluginId: item.pluginId,
489492
pluginName: item.pluginName,
490493
pluginEnabled: item.pluginEnabled,
@@ -562,6 +565,21 @@ async function buildConfiguredAssetSurfaces(inventory, scope) {
562565
}
563566
}
564567

568+
for (const [collection, type, label] of surfaceTypes) {
569+
const items = scopeItems(inventory, collection, "inherited");
570+
if (items.length > 0) {
571+
surfaces.push(customizeSurface({
572+
provider,
573+
group: "Inherited project assets",
574+
scope: "inherited",
575+
type,
576+
label: `Inherited ${provider} ${label}`,
577+
basePath: projectBase,
578+
items,
579+
}));
580+
}
581+
}
582+
565583
const projectWorkflows = await workflowItems(projectBase);
566584
if (projectWorkflows.length > 0) {
567585
surfaces.push(customizeSurface({
@@ -746,10 +764,12 @@ function practiceCoverageRows(surfaces, scope) {
746764
const scopes = [...new Set(matchedSurfaces.map((surface) => {
747765
if (surface.group === "Plugin/marketplace assets" || surface.scope === "plugin") return "Plugin";
748766
if (surface.scope === "user") return "Global";
767+
if (surface.scope === "inherited") return "Inherited";
749768
return "Project";
750769
}))];
751770
const paths = [...new Set([...uniqueItems.values()]
752-
.map((item) => boundedReportPath(item.path ?? item.filePath ?? item.rootPath, scope.workspace))
771+
.map((item) => item.originRoute
772+
?? boundedReportPath(item.path ?? item.filePath ?? item.rootPath, scope.workspace))
753773
.filter(Boolean))].slice(0, 12);
754774
rows.push({ surface: surfaceName, scopes, count: uniqueItems.size, paths });
755775
}

0 commit comments

Comments
 (0)