From 96ce56050ddad507221ef0e6027c3b71c512a32e Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 08:58:33 +0200 Subject: [PATCH 01/38] chore(lessons): capture the npm propagation and stuck runner rules --- .agentsmesh/lessons/lessons.json | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index a199deed..12467cbd 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -366,6 +366,21 @@ "t-glob-1120a27d" ] }, + "ci-and-release-security-a-ci-smoke-job-whose": { + "createdAt": "2026-09-24", + "evidence": [ + "ee2db028" + ], + "rule": "A CI smoke job whose step stays in_progress far past its normal time (the linux-arm64 binary smoke takes about 10 seconds) is a stuck runner, not slow code: compare with the same job on the same code, then 'gh run cancel ' and 'gh run rerun --failed', which reuses the passed jobs and their artifacts. Also, a PR the changesets bot opens gets no CI (GITHUB_TOKEN events start no workflows): close and reopen it with gh to run CI before merging the version PR.", + "status": "active", + "topics": [ + "ci-and-release-security" + ], + "triggers": [ + "t-kw-4ae47aa8", + "t-kw-3d6b253a" + ] + }, "ci-and-release-security-before-submitting-to-the-mcp": { "createdAt": "2026-09-22", "evidence": [ @@ -6941,6 +6956,21 @@ "t-glob-581b70ac" ] }, + "release-changesets-after-the-release-workflow-s": { + "createdAt": "2026-09-24", + "evidence": [ + "ee2db028" + ], + "rule": "After the release workflow's changeset publish reports success, npm can take about 5 minutes to serve the new version (0.41.0: published 06:52, visible 06:57; the dist-tags and version documents returned the old data and 404 meanwhile, even with a cache-busting query). Wait and re-check https://registry.npmjs.org/agentsmesh/ before treating the publish as failed; the update-tap job, which downloads the npm tarball, is the end-to-end confirmation.", + "status": "active", + "topics": [ + "release-changesets" + ], + "triggers": [ + "t-kw-b2687e53", + "t-kw-50918cd2" + ] + }, "release-changesets-before-a-release-audit-v": { "createdAt": "2026-09-23", "evidence": [ @@ -13146,6 +13176,10 @@ "kind": "keyword", "pattern": "continue permissions hooks capability" }, + "t-kw-3d6b253a": { + "kind": "keyword", + "pattern": "version packages" + }, "t-kw-3d736288": { "kind": "keyword", "pattern": "mergeGeneratedOutputContent" @@ -13198,6 +13232,10 @@ "kind": "keyword", "pattern": "web search parser failure" }, + "t-kw-4ae47aa8": { + "kind": "keyword", + "pattern": "smoke test" + }, "t-kw-4b851069": { "kind": "keyword", "pattern": "over-engineering audit duplication" @@ -13526,6 +13564,10 @@ "kind": "keyword", "pattern": "comparator coverage" }, + "t-kw-b2687e53": { + "kind": "keyword", + "pattern": "npm publish" + }, "t-kw-b2d1f670": { "kind": "keyword", "pattern": "atomic file write" From fe12d01cf114102bf506d319f71bdbe4a58d2f14 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 11:14:10 +0200 Subject: [PATCH 02/38] chore(lessons): capture the packs containment and symlinked SKILL.md rules --- .agentsmesh/lessons/lessons.json | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 12467cbd..7894177e 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -3974,6 +3974,21 @@ "t-cmd-e097d363" ] }, + "install-import-pickers-when-copying-entity-files-from": { + "createdAt": "2026-09-24", + "evidence": [ + "96ce5605" + ], + "rule": "When copying entity files from an install source, lstat every file (SKILL.md included) and skip symlinks: in 0.41.0 a skills//SKILL.md symlink was followed, copying an arbitrary local file into .agentsmesh/packs, .claude/skills and .cursor/skills, which get committed. Code that the MCP server can reach must never call process.stdout.write directly (use the logger, which MCP redirects to stderr): run-install-prompts.ts and run-uninstall.ts wrote warnings into the JSON-RPC stream.", + "status": "active", + "topics": [ + "install-import-pickers" + ], + "triggers": [ + "t-glob-25ede31e", + "t-kw-233098a2" + ] + }, "install-import-pickers-when-fixing-how-a-re": { "createdAt": "2026-09-23", "evidence": [ @@ -6651,6 +6666,22 @@ "t-glob-4c8d45b0" ] }, + "mcp-path-containment-every-folder-under-agentsmesh-that": { + "createdAt": "2026-09-24", + "evidence": [ + "96ce5605" + ], + "rule": "Every folder under .agentsmesh that agentsmesh writes, replaces or deletes (packs/, lessons/, not only rules/commands/agents/skills) must pass the realpath containment guard against the project root: git keeps symlinks, and in 0.41.0 a cloned repo's '.agentsmesh/packs -> ../../outside' made install replace an outside folder (deleting its files) and uninstall delete outside the project, via both the CLI and MCP.", + "status": "active", + "topics": [ + "mcp-path-containment" + ], + "triggers": [ + "t-glob-25ede31e", + "t-glob-35153049", + "t-kw-d3093e8e" + ] + }, "mcp-path-containment-mcp-path-containment-canonicalize-recons": { "createdAt": "2026-07-08", "evidence": [ @@ -11632,6 +11663,10 @@ "kind": "file_glob", "pattern": "AGENTS.md" }, + "t-glob-25ede31e": { + "kind": "file_glob", + "pattern": "src/install/**/*.ts" + }, "t-glob-2841b685": { "kind": "file_glob", "pattern": "src/core/generate/*-owned-keys.ts" @@ -11692,6 +11727,10 @@ "kind": "file_glob", "pattern": "src/targets/*/permissions-import.ts" }, + "t-glob-35153049": { + "kind": "file_glob", + "pattern": "src/lessons/graph-store.ts" + }, "t-glob-35a0a200": { "kind": "file_glob", "pattern": "tests/e2e/generate-capabilities.e2e.test.ts" @@ -13068,6 +13107,10 @@ "kind": "keyword", "pattern": "regression" }, + "t-kw-233098a2": { + "kind": "keyword", + "pattern": "SKILL.md symlink" + }, "t-kw-233ef987": { "kind": "keyword", "pattern": "NFA state" @@ -13668,6 +13711,10 @@ "kind": "keyword", "pattern": "UserPromptSubmit" }, + "t-kw-d3093e8e": { + "kind": "keyword", + "pattern": "symlink" + }, "t-kw-d3364734": { "kind": "keyword", "pattern": "ReDoS" From f2cfb0f140c93991a2868558e569fba8c6c6824f Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 11:48:40 +0200 Subject: [PATCH 03/38] chore(lessons): capture the private advisory rule for security findings --- .agentsmesh/lessons/lessons.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 7894177e..fe3dcc7a 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -713,6 +713,22 @@ "t-cmd-6ea72461" ] }, + "ci-and-release-security-this-repo-is-public-and": { + "createdAt": "2026-09-24", + "evidence": [ + "fe12d01c" + ], + "rule": "This repo is public and SECURITY.md routes vulnerabilities to private advisories: file exploitable findings (writes outside the project, file leaks, untrusted content taking over instructions) as DRAFT security advisories with 'gh api -X POST repos/sampleXbro/agentsmesh/security-advisories --input ' (summary, description, severity, cwe_ids, vulnerabilities: npm agentsmesh with vulnerable_version_range), never as public issues with repro steps. Plain bugs go to public issues with the bug label.", + "status": "active", + "topics": [ + "ci-and-release-security" + ], + "triggers": [ + "t-cmd-e0e8fc9f", + "t-kw-6d405b45", + "t-kw-e0471170" + ] + }, "cli-presentation-a-clack-clack-prompts-spinner": { "createdAt": "2026-06-23", "evidence": [], @@ -11343,6 +11359,10 @@ "kind": "command_pattern", "pattern": "cli\\.js install\\b" }, + "t-cmd-e0e8fc9f": { + "kind": "command_pattern", + "pattern": "gh issue create" + }, "t-cmd-e243ffa3": { "kind": "command_pattern", "pattern": "astro (dev|preview)" @@ -13387,6 +13407,10 @@ "kind": "keyword", "pattern": "readFileSafe directory EISDIR" }, + "t-kw-6d405b45": { + "kind": "keyword", + "pattern": "security advisory" + }, "t-kw-6d98db32": { "kind": "keyword", "pattern": "branch coverage" @@ -13751,6 +13775,10 @@ "kind": "keyword", "pattern": "antigravity permissions capability" }, + "t-kw-e0471170": { + "kind": "keyword", + "pattern": "file issues" + }, "t-kw-e0aa2fb2": { "kind": "keyword", "pattern": "precommit hook" From 00e633736f8b45cb43b2336e5ec29fc93047a7f6 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 12:12:09 +0200 Subject: [PATCH 04/38] fix(install): refuse pack folders that resolve outside the project git keeps symlinks, so a repository could point .agentsmesh/packs, or one pack folder, outside the project; install, install --sync, refresh and uninstall then followed the link and wrote, replaced or deleted there, through the CLI and the MCP tools. - New assertPackPathInsideProject: the real path of a packs or pack folder must stay inside the project root (the home folder in global mode), checked with the existing assertPathInsideRoot. - installAsPack checks the packs folder before any read and the target pack folder before any write, also under --dry-run. - runUninstall checks the packs folder and every pack it will remove before any prompt or delete, also under --dry-run (a pre-flight, like generate's boundary check, so it is not swallowed by per-pack failure isolation). - parseNames and defaultAdapter move to uninstall-io.ts to keep run-uninstall.ts under 200 lines. --- .changeset/pack-folder-containment.md | 5 + src/install/pack/pack-containment.ts | 27 ++++ src/install/run/run-install-pack.ts | 4 + src/install/uninstall/run-uninstall.ts | 35 ++-- src/install/uninstall/uninstall-io.ts | 26 +++ .../install/pack-folder-containment.test.ts | 153 ++++++++++++++++++ 6 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 .changeset/pack-folder-containment.md create mode 100644 src/install/pack/pack-containment.ts create mode 100644 src/install/uninstall/uninstall-io.ts create mode 100644 tests/unit/install/pack-folder-containment.test.ts diff --git a/.changeset/pack-folder-containment.md b/.changeset/pack-folder-containment.md new file mode 100644 index 00000000..d1b86558 --- /dev/null +++ b/.changeset/pack-folder-containment.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +`install` (including `install --sync` and `refresh`) and `uninstall` now refuse a `.agentsmesh/packs` folder, or a single pack folder, that resolves outside the project, for example through a symlink committed to the repository. Before, they followed the link and could write, replace or delete a folder outside the project. The check runs before anything is read or written, also under `--dry-run`, and the error names where the path really points. The MCP `install` and `uninstall` tools get the same protection. diff --git a/src/install/pack/pack-containment.ts b/src/install/pack/pack-containment.ts new file mode 100644 index 00000000..0e9c23be --- /dev/null +++ b/src/install/pack/pack-containment.ts @@ -0,0 +1,27 @@ +import { dirname, join } from 'node:path'; +import { assertPathInsideRoot } from '../../utils/filesystem/path-containment.js'; + +/** + * A pack path must resolve inside the project (the home folder in global + * mode). git keeps symlinks, so a cloned repo can point `.agentsmesh/packs`, or + * one pack folder, anywhere; writing or deleting through it reaches outside. + * `canonicalDir` is always `/.agentsmesh` (config/core/scope.ts). The + * boundary is that root, not `.agentsmesh`, so a linked `.agentsmesh` fails too. + */ +export function assertPackPathInsideProject(canonicalDir: string, packPath: string): Promise { + return assertPathInsideRoot(dirname(canonicalDir), packPath); +} + +/** + * Uninstall pre-flight, before any prompt or delete and under --dry-run too + * (like generate's boundary check): the packs folder and each pack it removes. + */ +export async function assertUninstallPacksInsideProject( + canonicalDir: string, + packDirs: readonly (string | null)[], +): Promise { + await assertPackPathInsideProject(canonicalDir, join(canonicalDir, 'packs')); + for (const dir of packDirs) { + if (dir !== null) await assertPackPathInsideProject(canonicalDir, dir); + } +} diff --git a/src/install/run/run-install-pack.ts b/src/install/run/run-install-pack.ts index 4d0166b8..72b1e162 100644 --- a/src/install/run/run-install-pack.ts +++ b/src/install/run/run-install-pack.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { materializePack } from '../pack/pack-writer.js'; import { readPackMetadata } from '../pack/pack-reader.js'; +import { assertPackPathInsideProject } from '../pack/pack-containment.js'; import { mergeIntoPack } from '../pack/pack-merge.js'; import { cleanInstallCache } from '../pack/cache-cleanup.js'; import { collectPreservedRootFiles } from '../source/collect-preserved-root.js'; @@ -45,6 +46,7 @@ export async function installAsPack(args: InstallAsPackArgs): Promise { } = args; const packsDir = join(canonicalDir, 'packs'); + await assertPackPathInsideProject(canonicalDir, packsDir); const selectedCanonical = applySelection(narrowed, selected); const preservedRootFiles = contentRoot ? await collectPreservedRootFiles(contentRoot) : []; const now = new Date().toISOString(); @@ -69,6 +71,8 @@ export async function installAsPack(args: InstallAsPackArgs): Promise { let persistedPath = pathInRepo; let persistedPaths: string[] | undefined; const packMeta = packTarget?.found.meta; + // The pack folder itself can be a link out of the project; check it before any write. + await assertPackPathInsideProject(canonicalDir, packTarget?.found.packDir ?? join(packsDir, packName)); if (!packMeta && !forceFreshMaterialize && (await readPackMetadata(join(packsDir, packName)))) { throw packNameCollision(packName, explicitName === true); } diff --git a/src/install/uninstall/run-uninstall.ts b/src/install/uninstall/run-uninstall.ts index b29bbc99..c0d1db39 100644 --- a/src/install/uninstall/run-uninstall.ts +++ b/src/install/uninstall/run-uninstall.ts @@ -24,11 +24,12 @@ import { acquireInstallLock } from '../lock/install-lock.js'; import { readInstallManifest } from '../core/install-manifest.js'; import { runPostOperationGenerate } from '../run/post-install-generate.js'; import { logger } from '../../utils/output/logger.js'; -import { readLine } from '../prompts/prompt-io.js'; import { planUninstall, type UninstallRemovalPlan } from './plan-uninstall.js'; import { gatherUninstallDecisions } from './uninstall-decisions.js'; +import { assertUninstallPacksInsideProject } from '../pack/pack-containment.js'; import { applyUninstall } from './apply-uninstall.js'; import { appliedEntry, buildSkipped, previewEntries } from './uninstall-result.js'; +import { defaultUninstallAdapter, parseUninstallNames } from './uninstall-io.js'; import type { PromptAdapter } from '../prompts/prompt-types.js'; import type { UninstallData, UninstallRemovedEntry } from '../../cli/command-result.js'; @@ -37,29 +38,6 @@ export interface UninstallCommandResult { data: UninstallData; } -function parseNames(args: readonly string[]): string[] { - // Preserves duplicates so `planUninstall`'s `detectDuplicates` guard can - // raise the documented "probably a typo or scripted-loop bug" error - // instead of being silenced here. - const out: string[] = []; - for (const arg of args) { - for (const part of arg - .split(',') - .map((s) => s.trim()) - .filter(Boolean)) { - out.push(part); - } - } - return out; -} - -function defaultAdapter(): PromptAdapter { - return { - ask: (prompt: string) => readLine(prompt), - write: (chunk: string) => process.stdout.write(chunk), - }; -} - export interface RunUninstallOptions { /** Test seam: override the default stdin/stdout-backed modification prompt. */ readonly promptAdapter?: PromptAdapter; @@ -81,7 +59,7 @@ export async function runUninstall( const keepGenerated = flags['keep-generated'] === true; const tty = options.assumeTty === true || process.stdin.isTTY; - const names = parseNames(args); + const names = parseUninstallNames(args); const isJson = flags.json === true; @@ -127,8 +105,13 @@ export async function runUninstall( packsDir, }); + await assertUninstallPacksInsideProject( + context.canonicalDir, + plan.removals.map((r) => r.packDir), + ); + const { decisions, aborted } = await gatherUninstallDecisions(plan.removals, packsDir, { - adapter: options.promptAdapter ?? defaultAdapter(), + adapter: options.promptAdapter ?? defaultUninstallAdapter(), warn: (m) => logger.warn(m), bypassPrompts: force || dryRun || !tty, keepPack, diff --git a/src/install/uninstall/uninstall-io.ts b/src/install/uninstall/uninstall-io.ts new file mode 100644 index 00000000..ca6f3653 --- /dev/null +++ b/src/install/uninstall/uninstall-io.ts @@ -0,0 +1,26 @@ +import { readLine } from '../prompts/prompt-io.js'; +import type { PromptAdapter } from '../prompts/prompt-types.js'; + +/** Uninstall names from `a,b c` style args; split from run-uninstall.ts for the 200-line limit. */ +export function parseUninstallNames(args: readonly string[]): string[] { + // Preserves duplicates so `planUninstall`'s `detectDuplicates` guard can + // raise the documented "probably a typo or scripted-loop bug" error + // instead of being silenced here. + const out: string[] = []; + for (const arg of args) { + for (const part of arg + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + out.push(part); + } + } + return out; +} + +export function defaultUninstallAdapter(): PromptAdapter { + return { + ask: (prompt: string) => readLine(prompt), + write: (chunk: string) => process.stdout.write(chunk), + }; +} diff --git a/tests/unit/install/pack-folder-containment.test.ts b/tests/unit/install/pack-folder-containment.test.ts new file mode 100644 index 00000000..e34d0922 --- /dev/null +++ b/tests/unit/install/pack-folder-containment.test.ts @@ -0,0 +1,153 @@ +/** + * git keeps symlinks, so a cloned repo can point `.agentsmesh/packs`, or one + * pack folder, outside the project. install (fresh, replace, merge, --sync, + * --dry-run) and uninstall refuse such a pack path instead of writing, + * replacing or deleting outside the project (GHSA-87c3-4xm5-xvpm). + */ + +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runInstall } from '../../../src/install/run/run-install.js'; +import { runUninstall } from '../../../src/install/uninstall/run-uninstall.js'; + +const UNSAFE = /Unsafe filesystem path/; + +let base: string; +let proj: string; +let src: string; +let outside: string; + +const write = (path: string, text: string): void => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +}; +// 'junction' makes directory links work on Windows without admin rights. +const link = (target: string, path: string): void => symlinkSync(target, path, 'junction'); +const packs = (): string => join(proj, '.agentsmesh', 'packs'); +const install = (flags: Record = {}): ReturnType => + runInstall({ force: true, ...flags }, [src], proj); +const uninstall = ( + name: string, + flags: Record = {}, +): ReturnType => + runUninstall({ force: true, ...flags }, [name], proj, { assumeTty: false }); + +/** Every file under `dir` with its content, so a test can prove nothing changed. */ +function snapshot(dir: string): Record { + const files: Record = {}; + for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const abs = join(entry.parentPath, entry.name); + files[relative(dir, abs).replaceAll('\\', '/')] = readFileSync(abs, 'utf8'); + } + return files; +} + +/** Install `src` normally, then move its pack folder outside and link it back. */ +async function packLinkedOutside(): Promise { + await install(); + const [name] = readdirSync(packs()).filter((n) => !n.startsWith('.')); + renameSync(join(packs(), name!), join(outside, name!)); + link(join(outside, name!), join(packs(), name!)); + return name!; +} + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-pack-contain-'))); + proj = join(base, 'proj'); + src = join(base, 'src'); + outside = join(base, 'outside'); + write(join(proj, 'agentsmesh.yaml'), 'version: 1\ntargets: [claude-code]\nfeatures: [rules]\n'); + write(join(proj, '.agentsmesh', 'rules', '_root.md'), '---\nroot: true\n---\n# Root\n'); + write(join(src, '.agentsmesh', 'rules', 'alpha.md'), '---\ndescription: a\n---\n# Alpha\n'); + write(join(src, '.agentsmesh', 'rules', 'beta.md'), '---\ndescription: b\n---\n# Beta\n'); + write(join(outside, 'victim', '.claude', 'settings.json'), '{"user":"keep me"}\n'); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +describe('install with .agentsmesh/packs linked outside the project', () => { + it('refuses and leaves the linked folder alone', async () => { + link(join(outside, 'victim'), packs()); + const before = snapshot(outside); + + await expect(install({ name: '.claude' })).rejects.toThrow(UNSAFE); + + expect(snapshot(outside)).toEqual(before); + }); + + it('refuses under --dry-run too, so a preview never approves it', async () => { + link(join(outside, 'victim'), packs()); + + await expect(install({ name: '.claude', 'dry-run': true })).rejects.toThrow(UNSAFE); + }); + + it('refuses install --sync of a pack recorded in installs.yaml', async () => { + await install(); + rmSync(packs(), { recursive: true }); + link(join(outside, 'victim'), packs()); + const before = snapshot(outside); + + await expect(runInstall({ sync: true, force: true }, [], proj)).rejects.toThrow(UNSAFE); + + expect(snapshot(outside)).toEqual(before); + }); +}); + +describe('install into a pack folder that is a link outside the project', () => { + it('refuses to replace it on a whole-source re-install', async () => { + await packLinkedOutside(); + const before = snapshot(outside); + + await expect(install()).rejects.toThrow(UNSAFE); + + expect(snapshot(outside)).toEqual(before); + }); + + it('refuses to merge a picked rule into it', async () => { + await install({ path: '.agentsmesh/rules/alpha.md' }); + const [name] = readdirSync(packs()).filter((n) => !n.startsWith('.')); + renameSync(join(packs(), name!), join(outside, name!)); + link(join(outside, name!), join(packs(), name!)); + const before = snapshot(outside); + + await expect(install({ path: '.agentsmesh/rules/beta.md' })).rejects.toThrow(UNSAFE); + + expect(snapshot(outside)).toEqual(before); + }); +}); + +describe('uninstall of a pack that resolves outside the project', () => { + it('refuses when .agentsmesh/packs is linked outside, and deletes nothing there', async () => { + await install(); + const [name] = readdirSync(packs()).filter((n) => !n.startsWith('.')); + renameSync(packs(), join(outside, 'packs')); + link(join(outside, 'packs'), packs()); + const before = snapshot(outside); + + await expect(uninstall(name!)).rejects.toThrow(UNSAFE); + await expect(uninstall(name!, { 'dry-run': true })).rejects.toThrow(UNSAFE); + + expect(snapshot(outside)).toEqual(before); + }); + + it('refuses when the pack folder itself is a link outside', async () => { + const name = await packLinkedOutside(); + const before = snapshot(outside); + + await expect(uninstall(name)).rejects.toThrow(UNSAFE); + + expect(snapshot(outside)).toEqual(before); + }); +}); From ddb6e53c6f2295eb35b0d5d9af9443b63372caa0 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 12:12:31 +0200 Subject: [PATCH 05/38] chore(lessons): capture the pre-flight boundary check rule --- .agentsmesh/lessons/lessons.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index fe3dcc7a..2c13b1f6 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -3304,6 +3304,22 @@ "t-glob-eb1e5fce" ] }, + "install-import-pickers-put-a-path-boundary-check": { + "createdAt": "2026-09-24", + "evidence": [ + "00e63373" + ], + "rule": "Put a path-boundary check in a command with per-item failure isolation (uninstall) as a pre-flight, before prompts and also under --dry-run: inside the per-item apply step it is caught as one 'failed' entry while other items continue, and the dry-run preview approves what the real run refuses. Place it once at the command's choke point (installAsPack for every install path incl. --sync and refresh; runUninstall), not in helpers that 40 tests call.", + "status": "active", + "topics": [ + "install-import-pickers" + ], + "triggers": [ + "t-glob-7d01f666", + "t-glob-6c742b74", + "t-glob-d8ba983b" + ] + }, "install-import-pickers-rule-1": { "createdAt": "2026-06-05", "evidence": [ @@ -12203,6 +12219,10 @@ "kind": "file_glob", "pattern": "src/install/prompts/prompt-io.ts" }, + "t-glob-7d01f666": { + "kind": "file_glob", + "pattern": "src/install/uninstall/*.ts" + }, "t-glob-7d39ded4": { "kind": "file_glob", "pattern": "src/config/core/conversions.ts" @@ -12775,6 +12795,10 @@ "kind": "file_glob", "pattern": "src/install/source/git-pin.ts" }, + "t-glob-d8ba983b": { + "kind": "file_glob", + "pattern": "src/install/pack/pack-containment.ts" + }, "t-glob-d8e040bc": { "kind": "file_glob", "pattern": "tests/unit/utils/filesystem/rename-retry.test.ts" From 2b483ccf33fa0564bbea1730c411f0d0cba8b9e4 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 12:34:13 +0200 Subject: [PATCH 06/38] fix(lessons): refuse a lessons folder that resolves outside the project The second half of the pack-folder fix: a repository could link .agentsmesh/lessons (or .agentsmesh) outside the project, and lessons add, the MCP lessons tools, init --lessons and the recall hook's logs then wrote lessons.json, its lock and its logs there. - resolvesInsideRootSync (path-containment.ts): a sync containment check that treats a dangling link as outside, since a missing target must not count as inside. - lessons-dir-guard.ts: lessonsDirInsideProject and assertLessonsDirInsideProject, anchored at the project root. - saveLessonsGraph, acquireLessonsLock and scaffoldLessons refuse with LessonsDirOutsideProjectError. Every graph writer (add, deprecate, prune, resolve, legacy migration, MCP) goes through the lock or the save, so all of them are covered, and migration never reaches its delete step. - The capture, recall and outcome logs skip such a folder, so the hook still exits 0. Reading lessons through the link keeps working. --- .changeset/pack-folder-containment.md | 2 + src/lessons/capture-telemetry.ts | 3 +- src/lessons/graph-store.ts | 2 + src/lessons/init.ts | 2 + src/lessons/lessons-dir-guard.ts | 37 +++++ src/lessons/lessons-lock.ts | 2 + src/lessons/outcome-log.ts | 3 +- src/lessons/telemetry.ts | 3 +- src/utils/filesystem/path-containment.ts | 29 ++++ .../lessons/lessons-dir-containment.test.ts | 149 ++++++++++++++++++ 10 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 src/lessons/lessons-dir-guard.ts create mode 100644 tests/unit/lessons/lessons-dir-containment.test.ts diff --git a/.changeset/pack-folder-containment.md b/.changeset/pack-folder-containment.md index d1b86558..d07d2c00 100644 --- a/.changeset/pack-folder-containment.md +++ b/.changeset/pack-folder-containment.md @@ -3,3 +3,5 @@ --- `install` (including `install --sync` and `refresh`) and `uninstall` now refuse a `.agentsmesh/packs` folder, or a single pack folder, that resolves outside the project, for example through a symlink committed to the repository. Before, they followed the link and could write, replace or delete a folder outside the project. The check runs before anything is read or written, also under `--dry-run`, and the error names where the path really points. The MCP `install` and `uninstall` tools get the same protection. + +Lessons get the same protection: when `.agentsmesh/lessons` (or `.agentsmesh`) resolves outside the project, `lessons add` and every other lessons write, `init --lessons` and the MCP lessons tools refuse with a clear error instead of writing `lessons.json`, its lock or its logs there. The recall hook keeps working and simply skips its logs. A link to a folder inside the project still works. diff --git a/src/lessons/capture-telemetry.ts b/src/lessons/capture-telemetry.ts index 38214f1b..776a6fe7 100644 --- a/src/lessons/capture-telemetry.ts +++ b/src/lessons/capture-telemetry.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import type { AddLessonResult } from './add.js'; import { appendJsonl, logExists, readJsonl } from './jsonl-log.js'; import { isCaptureRecord } from './log-record-guards.js'; +import { lessonsDirInsideProject } from './lessons-dir-guard.js'; import { lessonsPaths } from './paths.js'; import { isTelemetryEnabled, sessionId } from './telemetry.js'; @@ -66,7 +67,7 @@ export function appendCaptureRecord( record: CaptureTelemetryRecord, env: NodeJS.ProcessEnv = process.env, ): void { - if (!isTelemetryEnabled(env, projectRoot)) return; + if (!isTelemetryEnabled(env, projectRoot) || !lessonsDirInsideProject(projectRoot)) return; appendJsonl(captureLogPath(projectRoot), record, { maxRecords: MAX_CAPTURE_LOG_RECORDS, trimTriggerBytes: CAPTURE_LOG_TRIM_TRIGGER_BYTES, diff --git a/src/lessons/graph-store.ts b/src/lessons/graph-store.ts index cb109a63..6897abcc 100644 --- a/src/lessons/graph-store.ts +++ b/src/lessons/graph-store.ts @@ -12,6 +12,7 @@ import { import { stripBom } from '../utils/filesystem/fs-text-encoding.js'; import { dirname, resolve } from 'node:path'; import { CURRENT_GRAPH_VERSION, parseGraph, type LessonsGraph } from './graph-schema.js'; +import { assertLessonsDirInsideProject } from './lessons-dir-guard.js'; /** Project-relative path of the lessons graph, forward slashes. */ export const LESSONS_GRAPH_PATH = '.agentsmesh/lessons/lessons.json'; @@ -99,6 +100,7 @@ function isWritable(path: string): boolean { } export function saveLessonsGraph(projectRoot: string, graph: LessonsGraph): void { + assertLessonsDirInsideProject(projectRoot); const path = graphFilePath(projectRoot); mkdirSync(dirname(path), { recursive: true }); // The rename below would replace a read-only file and reset its mode. diff --git a/src/lessons/init.ts b/src/lessons/init.ts index 39e64e12..42d5b4e8 100644 --- a/src/lessons/init.ts +++ b/src/lessons/init.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'; import { maybeAutoMigrateLessons } from './auto-migrate.js'; import { captureLogPath } from './capture-telemetry.js'; import { lessonsLockPath } from './lessons-lock.js'; +import { assertLessonsDirInsideProject } from './lessons-dir-guard.js'; import { outcomeLogPath } from './outcome-log.js'; import { mutateLessonsGraphLocked } from './mutate.js'; import { lessonsPaths, toRelPath } from './paths.js'; @@ -66,6 +67,7 @@ export async function scaffoldLessons(projectRoot: string): Promise { + assertLessonsDirInsideProject(projectRoot); return acquireProcessLock(lessonsLockPath(projectRoot), { retries: opts.retries ?? LESSONS_LOCK_OPTIONS.retries, retryDelayMs: opts.retryDelayMs ?? LESSONS_LOCK_OPTIONS.retryDelayMs, diff --git a/src/lessons/outcome-log.ts b/src/lessons/outcome-log.ts index fd4dbfec..3b615894 100644 --- a/src/lessons/outcome-log.ts +++ b/src/lessons/outcome-log.ts @@ -3,6 +3,7 @@ import { effectivenessScores } from './effectiveness.js'; import type { LessonsGraph } from './graph-schema.js'; import { appendJsonl, logExists, readJsonl } from './jsonl-log.js'; import { isOutcomeEvent } from './log-record-guards.js'; +import { lessonsDirInsideProject } from './lessons-dir-guard.js'; import { lessonsPaths } from './paths.js'; import { isOutcomeLogEnabled, sessionId } from './telemetry.js'; @@ -56,7 +57,7 @@ export function appendOutcomeEvent( event: OutcomeEvent, env: NodeJS.ProcessEnv = process.env, ): void { - if (!isOutcomeLogEnabled(env, projectRoot)) return; + if (!isOutcomeLogEnabled(env, projectRoot) || !lessonsDirInsideProject(projectRoot)) return; appendJsonl(outcomeLogPath(projectRoot), event, { maxRecords: MAX_OUTCOME_LOG_RECORDS, trimTriggerBytes: OUTCOME_LOG_TRIM_TRIGGER_BYTES, diff --git a/src/lessons/telemetry.ts b/src/lessons/telemetry.ts index 074ae781..c5fa28fd 100644 --- a/src/lessons/telemetry.ts +++ b/src/lessons/telemetry.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { stripBom } from '../utils/filesystem/fs-text-encoding.js'; import { appendJsonl, logExists, readJsonl } from './jsonl-log.js'; import { isRecallRecord } from './log-record-guards.js'; +import { lessonsDirInsideProject } from './lessons-dir-guard.js'; import { lessonsPaths } from './paths.js'; /** Keep at most this many recall records; older ones are dropped on truncation. */ @@ -171,7 +172,7 @@ export function appendRecallRecord( record: RecallTelemetryRecord, env: NodeJS.ProcessEnv = process.env, ): void { - if (!isTelemetryEnabled(env, projectRoot)) return; + if (!isTelemetryEnabled(env, projectRoot) || !lessonsDirInsideProject(projectRoot)) return; appendJsonl(recallLogPath(projectRoot), record, { maxRecords: MAX_RECALL_LOG_RECORDS, trimTriggerBytes: RECALL_LOG_TRIM_TRIGGER_BYTES, diff --git a/src/utils/filesystem/path-containment.ts b/src/utils/filesystem/path-containment.ts index 77930127..85b02a80 100644 --- a/src/utils/filesystem/path-containment.ts +++ b/src/utils/filesystem/path-containment.ts @@ -1,3 +1,4 @@ +import { lstatSync, realpathSync } from 'node:fs'; import { realpath } from 'node:fs/promises'; import { basename, dirname, join, resolve, sep } from 'node:path'; @@ -13,6 +14,34 @@ export async function canonicalizePath(path: string): Promise { } } +/** + * Sync `canonicalizePath` for code that must stay sync. Unlike the async one it + * returns null for a dangling link: its missing target must not count as inside. + */ +function canonicalizePathSync(path: string): string | null { + try { + return realpathSync(path); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return null; + } + try { + lstatSync(path); + return null; // The entry exists but does not resolve: a dangling link. + } catch { + const parent = dirname(path); + if (parent === path) return resolve(path); + const realParent = canonicalizePathSync(parent); + return realParent === null ? null : join(realParent, basename(path)); + } +} + +/** Sync containment test: false when `target` resolves outside `root` or cannot be resolved. */ +export function resolvesInsideRootSync(root: string, target: string): boolean { + const realRoot = canonicalizePathSync(resolve(root)); + const realTarget = canonicalizePathSync(resolve(target)); + return realRoot !== null && realTarget !== null && isPathInside(realTarget, realRoot); +} + export function isPathInside(target: string, root: string): boolean { return target === root || target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`); } diff --git a/tests/unit/lessons/lessons-dir-containment.test.ts b/tests/unit/lessons/lessons-dir-containment.test.ts new file mode 100644 index 00000000..72d5c9c9 --- /dev/null +++ b/tests/unit/lessons/lessons-dir-containment.test.ts @@ -0,0 +1,149 @@ +/** + * git keeps symlinks, so a cloned repo can link `.agentsmesh/lessons` (or + * `.agentsmesh`) outside the project. Lesson writes (add, graph save, lock, + * init --lessons) refuse such a folder with a clear error, and the logs skip + * it silently so the recall hook never breaks (GHSA-87c3-4xm5-xvpm). + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { addLesson } from '../../../src/lessons/add.js'; +import { + appendCaptureRecord, + type CaptureTelemetryRecord, +} from '../../../src/lessons/capture-telemetry.js'; +import { emptyGraph } from '../../../src/lessons/graph-schema.js'; +import { saveLessonsGraph } from '../../../src/lessons/graph-store.js'; +import { scaffoldLessons } from '../../../src/lessons/init.js'; +import { appendOutcomeEvent } from '../../../src/lessons/outcome-log.js'; +import { appendRecallRecord, type RecallTelemetryRecord } from '../../../src/lessons/telemetry.js'; + +const OUTSIDE = { code: 'LESSONS_DIR_OUTSIDE_PROJECT' }; +const ON: NodeJS.ProcessEnv = { AGENTSMESH_LESSONS_TELEMETRY: '1' }; +const INPUT = { + rule: 'Keep lessons inside the project.', + topic: 'safety', + triggers: { keywords: ['containment'] }, +}; +const add = (): ReturnType => + addLesson(proj, INPUT, { allowNewTopic: true, topicSummary: 'Safety.' }); + +let base: string; +let proj: string; +let outside: string; + +// 'junction' makes directory links work on Windows without admin rights. +const link = (target: string, path: string): void => symlinkSync(target, path, 'junction'); + +/** Every file under `dir` with its content, so a test can prove nothing changed. */ +function snapshot(dir: string): Record { + const files: Record = {}; + for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const abs = join(entry.parentPath, entry.name); + files[relative(dir, abs).replaceAll('\\', '/')] = readFileSync(abs, 'utf8'); + } + return files; +} + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-lessons-contain-'))); + proj = join(base, 'proj'); + outside = join(base, 'outside'); + mkdirSync(join(proj, '.agentsmesh'), { recursive: true }); + writeFileSync(join(proj, 'agentsmesh.yaml'), 'version: 1\ntargets: [claude-code]\n'); + mkdirSync(join(outside, 'lessons'), { recursive: true }); + writeFileSync(join(outside, 'lessons', 'keep.txt'), 'not yours\n'); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +describe('.agentsmesh/lessons linked outside the project', () => { + beforeEach(() => link(join(outside, 'lessons'), join(proj, '.agentsmesh', 'lessons'))); + + it('addLesson refuses and writes nothing there (no graph, lock or log)', async () => { + await expect(add()).rejects.toMatchObject(OUTSIDE); + expect(snapshot(outside)).toEqual({ 'lessons/keep.txt': 'not yours\n' }); + }); + + it('a direct graph save refuses too', () => { + expect(() => saveLessonsGraph(proj, emptyGraph())).toThrow( + expect.objectContaining({ + ...OUTSIDE, + message: expect.stringMatching(/outside the project/), + }), + ); + }); + + it('init --lessons refuses', async () => { + await expect(scaffoldLessons(proj)).rejects.toMatchObject(OUTSIDE); + expect(snapshot(outside)).toEqual({ 'lessons/keep.txt': 'not yours\n' }); + }); + + it('the capture, recall and outcome logs skip it without throwing', () => { + const capture: CaptureTelemetryRecord = { + ts: '2026-09-24T00:00:00.000Z', + isNewLesson: true, + isNewTopic: false, + newTriggerCount: 1, + triggerKinds: { file: 1, command: 0, keyword: 0 }, + blocked: false, + warningCodes: [], + }; + const recall: RecallTelemetryRecord = { + ts: '2026-09-24T00:00:00.000Z', + hasFile: true, + hasCommand: false, + hasKeyword: false, + totalMatches: 1, + returnedCount: 1, + returnedTokens: 10, + truncated: false, + matchedByKind: { file: 1, command: 0, keyword: 0 }, + }; + appendCaptureRecord(proj, capture, ON); + appendRecallRecord(proj, recall, ON); + appendOutcomeEvent( + proj, + { ts: '2026-09-24T00:00:00Z', kind: 'delivered', lessonId: 'l', contextKey: 'k' }, + ON, + ); + expect(snapshot(outside)).toEqual({ 'lessons/keep.txt': 'not yours\n' }); + }); +}); + +describe('other links', () => { + it('refuses when .agentsmesh itself is linked outside', async () => { + rmSync(join(proj, '.agentsmesh'), { recursive: true }); + link(outside, join(proj, '.agentsmesh')); + + await expect(add()).rejects.toMatchObject(OUTSIDE); + expect(snapshot(outside)).toEqual({ 'lessons/keep.txt': 'not yours\n' }); + }); + + it('refuses a dangling link and does not create its target', async () => { + link(join(outside, 'missing'), join(proj, '.agentsmesh', 'lessons')); + + await expect(add()).rejects.toMatchObject(OUTSIDE); + expect(existsSync(join(outside, 'missing'))).toBe(false); + }); + + it('allows a link to a folder inside the project', async () => { + mkdirSync(join(proj, 'shared-lessons')); + link(join(proj, 'shared-lessons'), join(proj, '.agentsmesh', 'lessons')); + + await expect(add()).resolves.toMatchObject({ isNewLesson: true }); + expect(existsSync(join(proj, 'shared-lessons', 'lessons.json'))).toBe(true); + }); +}); From bc52f89e26b114ccfb83901e424720cb59d5e4ea Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 12:34:26 +0200 Subject: [PATCH 07/38] chore(lessons): capture the one-file-per-recall rule --- .agentsmesh/lessons/lessons.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 2c13b1f6..46b76084 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -5395,6 +5395,21 @@ ], "triggers": [] }, + "lessons-system-run-agentsmesh-lessons-query-once": { + "createdAt": "2026-09-24", + "evidence": [ + "2b483ccf" + ], + "rule": "Run 'agentsmesh lessons query' once per file: --file is a single-value flag, and passing it twice exits 2 ('--file was given 2 times') so no recall runs at all. Loop over the files (for f in ...; do lessons query --file \"$f\" --session auto; done) and treat a non-zero exit from recall as recall not done.", + "status": "active", + "topics": [ + "lessons-system" + ], + "triggers": [ + "t-cmd-b7a21d14", + "t-kw-17e3a485" + ] + }, "lessons-system-run-agentsmesh-lessons-subcommand-help": { "createdAt": "2026-07-17", "evidence": [ @@ -11303,6 +11318,10 @@ "kind": "command_pattern", "pattern": "install github:" }, + "t-cmd-b7a21d14": { + "kind": "command_pattern", + "pattern": "lessons query.*--file.*--file" + }, "t-cmd-b7e554e6": { "kind": "command_pattern", "pattern": "node -e" @@ -13107,6 +13126,10 @@ "kind": "keyword", "pattern": "astro dev-only page sitemap" }, + "t-kw-17e3a485": { + "kind": "keyword", + "pattern": "recall before edit" + }, "t-kw-18a1de9b": { "kind": "keyword", "pattern": "refactor" From 1b6e186a2a9725eb8a0bd31871e7ee2e2f2e0046 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 12:50:34 +0200 Subject: [PATCH 08/38] fix(init): stop instead of setting up an overwrite of unimported config In a script or CI shell without --yes, init found existing tool config, printed only a hint to import it, and still enabled that tool. The next generate then replaced the config; in --global mode that meant ~/.claude/CLAUDE.md and the MCP servers in ~/.claude.json, which git cannot restore. Now non-interactive init stops with exit 1 and writes nothing when a detected tool would be enabled without --yes. The message says to run init --yes to import it first, or pass --targets to leave the tool out. Explicit targets that leave the detected tool out, --yes, and the interactive wizard work as before. Tests that asserted the old scaffold-anyway behaviour now use --targets or --yes for the same purpose. Docs (init.mdx, README) and the init help text describe the stop. Fixes #130 --- .changeset/init-refuses-unimported-config.md | 5 ++ README.md | 2 +- src/cli/commands/init.ts | 12 +++ src/cli/help-data.ts | 4 +- tests/e2e/init.e2e.test.ts | 14 ++-- .../cli/commands/init-existing-config.test.ts | 77 +++++++++++++++++++ tests/unit/cli/commands/init.test.ts | 22 +++--- website/src/content/docs/cli/init.mdx | 4 +- 8 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 .changeset/init-refuses-unimported-config.md create mode 100644 tests/unit/cli/commands/init-existing-config.test.ts diff --git a/.changeset/init-refuses-unimported-config.md b/.changeset/init-refuses-unimported-config.md new file mode 100644 index 00000000..63a20f78 --- /dev/null +++ b/.changeset/init-refuses-unimported-config.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +`agentsmesh init` no longer sets up a silent overwrite of your existing tool config. In a script or CI shell (no wizard) and without `--yes`, `init` used to find existing config, print only a hint to import it, and still enable that tool, so the next `agentsmesh generate` replaced it: in `--global` mode your `~/.claude/CLAUDE.md` and the MCP servers in `~/.claude.json`, which git cannot restore. Now `init` stops with exit code 1 and writes nothing when a tool it would enable already has config. Run `agentsmesh init --yes` to import that config first, or pass `--targets` to leave the tool out. The interactive wizard is unchanged. diff --git a/README.md b/README.md index 8bb03eb6..580ba9ec 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ agentsmesh generate # write each tool's native config from the one source agentsmesh check # CI drift gate against .agentsmesh/.lock ``` -On a terminal, `init` runs a short wizard: pick targets, import detected configs (`.cursor/`, `.claude/`, `.github/copilot-instructions.md`, and more), enable lessons, and optionally generate right away. Nothing is written until you finish. Use `--yes` for the non-interactive default, `--global` for your user-level config under `~/.agentsmesh/`, and `npx agentsmesh` if you installed it as a dev dependency. +On a terminal, `init` runs a short wizard: pick targets, import detected configs (`.cursor/`, `.claude/`, `.github/copilot-instructions.md`, and more), enable lessons, and optionally generate right away. Nothing is written until you finish. Use `--yes` for the non-interactive default (in a script, `init` without `--yes` stops when it finds existing tool config, so `generate` never replaces config you have not imported), `--global` for your user-level config under `~/.agentsmesh/`, and `npx agentsmesh` if you installed it as a dev dependency. `init` enables the tools you actually use, not all of them: whatever this project already has config for, else whatever is installed on your machine, else a minimal set of Claude Code, Cursor and Copilot. It prints what it picked. Pass `--targets a,b` to choose, or `--all-targets` for everything. diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 05b5706a..9733dd6f 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -132,6 +132,18 @@ export async function runInit( allowed: scope === 'global' ? GLOBAL_INIT_TARGETS : undefined, }); + // Without --yes nothing is imported, so enabling a tool that already has + // config would let the next generate replace it (in global mode, home files + // git cannot restore). Stop before writing anything. + const notImported = existing.filter((target) => resolved.targets.includes(target)); + if (!doImport && notImported.length > 0) { + throw new Error( + `Found existing configurations: ${notImported.join(', ')}. Without --yes, init does not ` + + 'import them, and the next `agentsmesh generate` would replace them. Run ' + + '`agentsmesh init --yes` to import them first, or pass --targets to leave those tools out.', + ); + } + const plan: InitPlan = { scope, targets: resolved.targets, diff --git a/src/cli/help-data.ts b/src/cli/help-data.ts index fbd9a11b..a2008213 100644 --- a/src/cli/help-data.ts +++ b/src/cli/help-data.ts @@ -27,7 +27,9 @@ export const COMMANDS: HelpCommand[] = [ 'Create agentsmesh.yaml, agentsmesh.local.yaml, and canonical .agentsmesh scaffold. ' + 'On a TTY runs an interactive wizard (targets, import, generate; plus lessons in project scope, ' + 'never in --global); --yes, --json, and non-TTY/CI run non-interactively. Targets default to the ' + - 'tools found in the project, else those installed on this machine, else a minimal set.', + 'tools found in the project, else those installed on this machine, else a minimal set. Without ' + + '--yes, a non-interactive init stops if a tool it would enable already has config (import it ' + + 'with --yes, or leave it out with --targets).', flags: [ { name: '--global', diff --git a/tests/e2e/init.e2e.test.ts b/tests/e2e/init.e2e.test.ts index 18c0505a..9159968b 100644 --- a/tests/e2e/init.e2e.test.ts +++ b/tests/e2e/init.e2e.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { runCli } from './helpers/run-cli.js'; import { createTestProject, cleanup } from './helpers/setup.js'; @@ -26,11 +26,13 @@ describe('init', () => { fileContains(join(dir, '.agentsmesh', 'rules', '_root.md'), 'root'); }); - it('init detects Claude config — copy claude fixture, run init → stdout mentions found configs', async () => { + it('init detects Claude config — without --yes it refuses instead of overwriting it', async () => { dir = createTestProject('claude-code-project'); const r = await runCli('init', dir); - expect(r.exitCode).toBe(0); - expect(r.stdout).toContain('claude-code'); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('Found existing configurations: claude-code.'); + expect(r.stderr).toContain('agentsmesh init --yes'); + expect(existsSync(join(dir, 'agentsmesh.yaml'))).toBe(false); }); it('init detects multiple tools — copy claude + cursor fixtures, run init in fresh empty dir with both', async () => { @@ -40,8 +42,8 @@ describe('init', () => { mkdirSync(cursorRulesDir, { recursive: true }); writeFileSync(join(cursorRulesDir, 'root.mdc'), '---\nalwaysApply: true\n---\n# Cursor'); const r = await runCli('init', dir); - expect(r.exitCode).toBe(0); - expect(r.stdout + r.stderr).toMatch(/claude-code|cursor|Found existing/); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('Found existing configurations: claude-code, cursor.'); }); it('init refuses if config exists — create yaml first → run init → exit 1', async () => { diff --git a/tests/unit/cli/commands/init-existing-config.test.ts b/tests/unit/cli/commands/init-existing-config.test.ts new file mode 100644 index 00000000..960b9dca --- /dev/null +++ b/tests/unit/cli/commands/init-existing-config.test.ts @@ -0,0 +1,77 @@ +/** + * Non-interactive `init` without `--yes` used to find existing tool config, + * print only a hint, and still enable that tool, so the next `generate` + * replaced the user's files (in global mode `~/.claude/CLAUDE.md` and the MCP + * servers in `~/.claude.json`, with no git history to restore them). It now + * refuses and writes nothing when a detected tool would be enabled (#130). + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runInit } from '../../../../src/cli/commands/init.js'; + +const REFUSED = /Found existing configurations: claude-code\..*agentsmesh init --yes/s; + +let base: string; +let proj: string; +let home: string; + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-init-existing-'))); + proj = join(base, 'proj'); + home = join(base, 'home'); + mkdirSync(proj); + mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); +}); +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(base, { recursive: true, force: true }); +}); + +describe('non-interactive init with existing tool config and no --yes', () => { + it('refuses in a project and writes nothing', async () => { + writeFileSync(join(proj, 'CLAUDE.md'), '# My rules\n'); + + await expect(runInit(proj, {})).rejects.toThrow(REFUSED); + + expect(existsSync(join(proj, 'agentsmesh.yaml'))).toBe(false); + expect(existsSync(join(proj, '.agentsmesh'))).toBe(false); + expect(readFileSync(join(proj, 'CLAUDE.md'), 'utf8')).toBe('# My rules\n'); + }); + + it('refuses in global mode and leaves the home config alone', async () => { + mkdirSync(join(home, '.claude')); + writeFileSync(join(home, '.claude', 'CLAUDE.md'), '- I prefer tabs\n'); + + await expect(runInit(proj, { global: true })).rejects.toThrow(REFUSED); + + expect(existsSync(join(home, '.agentsmesh'))).toBe(false); + expect(readFileSync(join(home, '.claude', 'CLAUDE.md'), 'utf8')).toBe('- I prefer tabs\n'); + }); +}); + +describe('init paths that stay allowed', () => { + it('--yes imports the existing config', async () => { + writeFileSync(join(proj, 'CLAUDE.md'), '# My rules\n'); + + const result = await runInit(proj, { yes: true }); + + expect([result.exitCode, result.data.imported.length > 0]).toEqual([0, true]); + expect(readFileSync(join(proj, '.agentsmesh', 'rules', '_root.md'), 'utf8')).toContain( + '# My rules', + ); + }); + + it('explicit --targets that leave the detected tool out change nothing it owns', async () => { + writeFileSync(join(proj, 'CLAUDE.md'), '# My rules\n'); + + const result = await runInit(proj, { targets: ['cursor'] }); + + expect([result.exitCode, result.data.targets]).toEqual([0, ['cursor']]); + expect(readFileSync(join(proj, 'CLAUDE.md'), 'utf8')).toBe('# My rules\n'); + }); +}); diff --git a/tests/unit/cli/commands/init.test.ts b/tests/unit/cli/commands/init.test.ts index cb3189c2..57784a6b 100644 --- a/tests/unit/cli/commands/init.test.ts +++ b/tests/unit/cli/commands/init.test.ts @@ -146,9 +146,9 @@ describe('runInit — structured result', () => { expect(result.data.imported.length).toBeGreaterThan(0); }); - it('returns scaffoldType full when existing configs but no --yes', async () => { + it('returns scaffoldType full when an existing config is left out of the targets', async () => { writeFileSync(join(TEST_DIR, 'CLAUDE.md'), '# Rules\n'); - const result = await runInit(TEST_DIR); + const result = await runInit(TEST_DIR, { targets: ['cursor'] }); expect(result.data.scaffoldType).toBe('full'); expect(result.data.imported).toEqual([]); }); @@ -319,26 +319,26 @@ describe('runInit — scaffold (no existing configs)', () => { }); }); -describe('runInit — existing configs detected, no --yes', () => { - it('creates scaffold (not imported content) when existing configs but no --yes', async () => { +describe('runInit — existing configs detected', () => { + it('creates scaffold (not imported content) when the existing config is not a target', async () => { writeFileSync(join(TEST_DIR, 'CLAUDE.md'), '# Rules\n'); - await runInit(TEST_DIR); + await runInit(TEST_DIR, { targets: ['cursor'] }); const content = readFileSync(join(TEST_DIR, '.agentsmesh', 'rules', '_root.md'), 'utf-8'); expect(content).toContain('root: true'); expect(content).not.toContain('# Rules'); }); - it('creates all scaffold files when existing configs but no --yes', async () => { + it('creates all scaffold files when the existing config is not a target', async () => { writeFileSync(join(TEST_DIR, 'CLAUDE.md'), '# Rules\n'); - await runInit(TEST_DIR); + await runInit(TEST_DIR, { targets: ['cursor'] }); expect(existsSync(join(TEST_DIR, '.agentsmesh', 'commands', '_example.md'))).toBe(true); expect(existsSync(join(TEST_DIR, '.agentsmesh', 'agents', '_example.md'))).toBe(true); expect(existsSync(join(TEST_DIR, '.agentsmesh', 'mcp.json'))).toBe(true); }); - it('creates agentsmesh.yaml scoped to the detected tool when no --yes', async () => { + it('creates agentsmesh.yaml scoped to the detected tool', async () => { writeFileSync(join(TEST_DIR, 'CLAUDE.md'), '# Rules\n'); - await runInit(TEST_DIR); + await runInit(TEST_DIR, { yes: true }); const content = readFileSync(join(TEST_DIR, 'agentsmesh.yaml'), 'utf-8'); expect(content).toContain('version: 1'); expect(content).toContain('claude-code'); @@ -349,7 +349,7 @@ describe('runInit — existing configs detected, no --yes', () => { it('enables the whole starter set only when asked with --all-targets', async () => { writeFileSync(join(TEST_DIR, 'CLAUDE.md'), '# Rules\n'); - await runInit(TEST_DIR, { allTargets: true }); + await runInit(TEST_DIR, { allTargets: true, yes: true }); const content = readFileSync(join(TEST_DIR, 'agentsmesh.yaml'), 'utf-8'); expect(content).toContain('claude-code'); expect(content).toContain('kiro'); @@ -544,7 +544,7 @@ describe('runInit — global mode', () => { const detected = await detectExistingConfigs(homeDir, 'global'); expect(detected).toContain('claude-code'); - const result = await runInit(join(TEST_DIR, 'workspace'), { global: true }); + const result = await runInit(join(TEST_DIR, 'workspace'), { global: true, yes: true }); expect(result.exitCode).toBe(0); expect(result.data.scope).toBe('global'); diff --git a/website/src/content/docs/cli/init.mdx b/website/src/content/docs/cli/init.mdx index 6be5bcfb..692a5544 100644 --- a/website/src/content/docs/cli/init.mdx +++ b/website/src/content/docs/cli/init.mdx @@ -40,7 +40,7 @@ Every step after the first offers a **↩ Back** choice to return to the previou | project | All four steps above. | | `--global` | Target list limited to global-capable tools; the **Lessons step is omitted** (lessons is project-only). | -The wizard is **skipped** — and the original non-interactive behavior runs — with `--yes`, `--json`, or in a non-TTY/CI shell. Scripted and CI usage is unchanged. +The wizard is **skipped** — and the non-interactive behavior runs — with `--yes`, `--json`, or in a non-TTY/CI shell. Without `--yes`, a non-interactive `init` stops with exit code 1 when it finds existing config for a tool it would enable: it would not import that config, so the next `agentsmesh generate` would replace it (in `--global` mode, files in your home folder that git cannot restore). Run `agentsmesh init --yes` to import it first, or pass `--targets` to leave that tool out. ## Already initialized @@ -115,7 +115,7 @@ Only two init steps are project-only: the `.gitignore` update and the lessons sc ## Auto-detection -During init, AgentsMesh scans the project for existing AI tool configs from every [supported target](../../reference/supported-tools/) — `.claude/`, `.cursor/`, `.github/copilot-instructions.md`, and the rest. The wizard offers to import everything it finds (a single yes/no); `--yes` imports all of them non-interactively. +During init, AgentsMesh scans the project for existing AI tool configs from every [supported target](../../reference/supported-tools/) — `.claude/`, `.cursor/`, `.github/copilot-instructions.md`, and the rest. The wizard offers to import everything it finds (a single yes/no); `--yes` imports all of them non-interactively. In a script or CI shell without `--yes`, init refuses instead of enabling a tool whose config it did not import, so nothing is overwritten by accident. ## Examples From 6fba555b72ab76d47283fd8292579f26c0cda0fe Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 12:50:46 +0200 Subject: [PATCH 09/38] chore(lessons): capture the init import-or-stop rule --- .agentsmesh/lessons/lessons.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 46b76084..175ff072 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -4037,6 +4037,20 @@ "t-glob-a1c12fe6" ] }, + "install-import-pickers-when-init-enables-a-tool": { + "createdAt": "2026-09-24", + "evidence": [ + "1b6e186a" + ], + "rule": "When init enables a tool because it detected that tool's existing config, it must also import that config, or stop: enabling without importing lets the first generate replace the unimported config (in --global mode ~/.claude/CLAUDE.md and ~/.claude.json MCP servers, which git cannot restore; issue #130). Non-interactive init without --yes now throws before writing when a detected tool is in the resolved targets; keep any new target-selection path behind the same check.", + "status": "active", + "topics": [ + "install-import-pickers" + ], + "triggers": [ + "t-glob-2c569101" + ] + }, "kilo-code-config-surface-for-kilo-code-current-kilo": { "createdAt": "2026-06-24", "evidence": [ From 7a8aea3354dc790e5097c808b38bb28842deb241 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 13:12:26 +0200 Subject: [PATCH 10/38] fix(skills): skip a SKILL.md that is a symlink parseSkills and parseSkillDirectory read SKILL.md with readFileSafe, which follows symlinks, while every other canonical reader skips them (readDirRecursiveNoSymlinks, copyDir). A skills source with skills//SKILL.md (or a root SKILL.md) linked to a local file made install copy that file into .agentsmesh/packs and the generated .claude/.cursor skills; generate did the same for a project's own canonical skills. Both parsers now read SKILL.md through readSkillFile, which lstats it and skips a symlink. Every path goes through them: install sources, installed packs, the canonical loader for generate, and skill packs. The native import pipeline already enumerates with readDirRecursiveNoSymlinks, and a symlinked --path file already fails. --- .changeset/skill-md-symlink.md | 5 + src/canonical/features/skills.ts | 20 +++- tests/unit/canonical/skill-md-symlink.test.ts | 54 +++++++++++ .../install/skill-md-symlink-install.test.ts | 91 +++++++++++++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 .changeset/skill-md-symlink.md create mode 100644 tests/unit/canonical/skill-md-symlink.test.ts create mode 100644 tests/unit/install/skill-md-symlink-install.test.ts diff --git a/.changeset/skill-md-symlink.md b/.changeset/skill-md-symlink.md new file mode 100644 index 00000000..c5bb4955 --- /dev/null +++ b/.changeset/skill-md-symlink.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +A skill whose `SKILL.md` is a symlink is now skipped, like every other symlinked canonical file. Before, `install`, `generate` and pack loading followed the link and copied the file it pointed to into `.agentsmesh/packs/` and the generated `.claude/skills/` and `.cursor/skills/` folders, so a skills repository could pull a local file into folders that usually get committed. The other skills in the same source are still installed. diff --git a/src/canonical/features/skills.ts b/src/canonical/features/skills.ts index 3ec89c3f..e863368e 100644 --- a/src/canonical/features/skills.ts +++ b/src/canonical/features/skills.ts @@ -3,7 +3,7 @@ */ import { basename, join } from 'node:path'; -import { readdir } from 'node:fs/promises'; +import { lstat, readdir } from 'node:fs/promises'; import type { CanonicalSkill, SkillSupportingFile } from '../../core/types.js'; import { readFileSafe } from '../../utils/filesystem/fs.js'; import { readDirRecursiveNoSymlinks } from '../../utils/filesystem/fs-traverse.js'; @@ -20,6 +20,20 @@ async function readContent(path: string): Promise { const SKILL_FILE = 'SKILL.md'; +/** + * SKILL.md content, or null when missing or a symlink: like every other + * canonical entry (readDirRecursiveNoSymlinks, copyDir), a link is not followed, + * or it would copy any local file into packs and generated skills. + */ +async function readSkillFile(skillPath: string): Promise { + try { + if ((await lstat(skillPath)).isSymbolicLink()) return null; + } catch { + return null; + } + return readFileSafe(skillPath); +} + /** Markdown / plain-text doc filenames the markdown-boilerplate filter applies to. */ const DOC_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.txt']); @@ -89,7 +103,7 @@ export async function parseSkillDirectory( opts: ParseFrontmatterOptions = {}, ): Promise { const skillPath = join(skillDir, SKILL_FILE); - const content = await readFileSafe(skillPath); + const content = await readSkillFile(skillPath); if (!content) return null; const parsed = parseOrSkipFrontmatter(content, skillPath, opts.onParseError); // SKILL.md frontmatter parse error skips the entire skill directory: a skill @@ -127,7 +141,7 @@ export async function parseSkills( assertCanonicalName('skill', ent.name); const skillDir = join(skillsDir, ent.name); const skillPath = join(skillDir, SKILL_FILE); - const content = await readFileSafe(skillPath); + const content = await readSkillFile(skillPath); if (!content) continue; const parsed = parseOrSkipFrontmatter(content, skillPath, opts.onParseError); if (!parsed) continue; diff --git a/tests/unit/canonical/skill-md-symlink.test.ts b/tests/unit/canonical/skill-md-symlink.test.ts new file mode 100644 index 00000000..b7090731 --- /dev/null +++ b/tests/unit/canonical/skill-md-symlink.test.ts @@ -0,0 +1,54 @@ +/** + * A skill whose SKILL.md is a symlink is skipped, like every other canonical + * entry (readDirRecursiveNoSymlinks, copyDir). Following it copied any local + * file into packs and generated .claude/.cursor skills (GHSA-pxf6-493h-83j5). + */ + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseSkillDirectory, parseSkills } from '../../../src/canonical/features/skills.js'; + +let base: string; +let skillsDir: string; + +const skill = (name: string): string => { + const dir = join(skillsDir, name); + mkdirSync(dir, { recursive: true }); + return dir; +}; + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-skill-link-'))); + skillsDir = join(base, 'skills'); + writeFileSync(join(base, 'creds.txt'), '---\nname: pwn\n---\nCANARY_SECRET\n'); + writeFileSync(join(skill('ok'), 'SKILL.md'), '---\ndescription: fine\n---\n# OK\n'); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +// File symlinks need extra rights on Windows, so these run on POSIX only. +describe.skipIf(process.platform === 'win32')('a symlinked SKILL.md', () => { + it('is skipped by parseSkills, which keeps the other skills', async () => { + symlinkSync(join(base, 'creds.txt'), join(skill('pwn'), 'SKILL.md')); + + const skills = await parseSkills(skillsDir); + + expect(skills.map((s) => s.name)).toEqual(['ok']); + }); + + it('is skipped by parseSkillDirectory', async () => { + symlinkSync(join(base, 'creds.txt'), join(skill('pwn'), 'SKILL.md')); + + expect(await parseSkillDirectory(join(skillsDir, 'pwn'))).toBeNull(); + }); + + it('is skipped even when it points inside the same tree', async () => { + writeFileSync(join(skillsDir, 'shared.md'), '---\ndescription: shared\n---\n# Shared\n'); + symlinkSync(join(skillsDir, 'shared.md'), join(skill('alias'), 'SKILL.md')); + + const skills = await parseSkills(skillsDir); + + expect(skills.map((s) => s.name)).toEqual(['ok']); + }); +}); diff --git a/tests/unit/install/skill-md-symlink-install.test.ts b/tests/unit/install/skill-md-symlink-install.test.ts new file mode 100644 index 00000000..536372d9 --- /dev/null +++ b/tests/unit/install/skill-md-symlink-install.test.ts @@ -0,0 +1,91 @@ +/** + * A symlinked SKILL.md must never bring an outside file into the project: + * not from an install source (a skills collection or a root SKILL.md) and not + * from the project's own canonical skills during generate (GHSA-pxf6-493h-83j5). + */ + +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runGenerate } from '../../../src/cli/commands/generate.js'; +import { runInstall } from '../../../src/install/run/run-install.js'; + +const CANARY = 'CANARY_SECRET_4821'; + +let base: string; +let proj: string; + +const write = (path: string, text: string): void => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +}; + +/** Project files that contain the canary, forward-slashed and sorted. */ +function leaks(): string[] { + const hits: string[] = []; + for (const entry of readdirSync(proj, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const abs = join(entry.parentPath, entry.name); + if (readFileSync(abs, 'utf8').includes(CANARY)) + hits.push(relative(proj, abs).replaceAll('\\', '/')); + } + return hits.sort(); +} + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-skill-leak-'))); + proj = join(base, 'proj'); + write(join(base, 'creds.txt'), `---\nname: pwn\ndescription: d\n---\n${CANARY}\n`); + write( + join(proj, 'agentsmesh.yaml'), + 'version: 1\ntargets: [claude-code, cursor]\nfeatures: [rules, skills]\n', + ); + write(join(proj, '.agentsmesh', 'rules', '_root.md'), '---\nroot: true\n---\n# Root\n'); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +// File symlinks need extra rights on Windows, so these run on POSIX only. +describe.skipIf(process.platform === 'win32')('a symlinked SKILL.md', () => { + it('in a skills collection source is not installed; the other skills are', async () => { + const src = join(base, 'evilrepo'); + write(join(src, 'skills', 'good', 'SKILL.md'), '---\ndescription: fine\n---\n# Good\n'); + mkdirSync(join(src, 'skills', 'pwn'), { recursive: true }); + symlinkSync(join(base, 'creds.txt'), join(src, 'skills', 'pwn', 'SKILL.md')); + + await runInstall({ force: true }, [src], proj); + + expect(leaks()).toEqual([]); + expect(readFileSync(join(proj, '.claude', 'skills', 'good', 'SKILL.md'), 'utf8')).toContain( + '# Good', + ); + }); + + it('at the root of a source is not installed', async () => { + const src = join(base, 'rootskill'); + mkdirSync(src); + symlinkSync(join(base, 'creds.txt'), join(src, 'SKILL.md')); + + await runInstall({ force: true }, [src], proj).catch(() => undefined); + + expect(leaks()).toEqual([]); + }); + + it("in the project's own canonical skills is not generated", async () => { + mkdirSync(join(proj, '.agentsmesh', 'skills', 'pwn'), { recursive: true }); + symlinkSync(join(base, 'creds.txt'), join(proj, '.agentsmesh', 'skills', 'pwn', 'SKILL.md')); + + await runGenerate({}, proj, { printMatrix: false }); + + expect(leaks()).toEqual([]); + }); +}); From 96a723ae9c475940ac6020a5cbd7ffdcd0cd171f Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 13:12:40 +0200 Subject: [PATCH 11/38] chore(lessons): capture the canonical symlink reader rule --- .agentsmesh/lessons/lessons.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 175ff072..80c7a909 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -1831,6 +1831,21 @@ "t-glob-49133422" ] }, + "frontmatter-and-canonical-shapes-every-canonical-file-reader-must": { + "createdAt": "2026-09-24", + "evidence": [ + "7a8aea33" + ], + "rule": "Every canonical file reader must skip symlinks, the way readDirRecursiveNoSymlinks and copyDir do: a direct readFileSafe(join(dir, 'SKILL.md')) followed links, so install, installed packs AND generate (a project's own .agentsmesh/skills) copied any local file into packs and generated .claude/.cursor skills (GHSA-pxf6-493h-83j5). Read SKILL.md through readSkillFile in canonical/features/skills.ts, and lstat any new fixed-name file read before using its content.", + "status": "active", + "topics": [ + "frontmatter-and-canonical-shapes" + ], + "triggers": [ + "t-glob-e508af7d", + "t-glob-89c6bc75" + ] + }, "frontmatter-and-canonical-shapes-rule-1": { "createdAt": "2026-06-05", "evidence": [ @@ -12372,6 +12387,10 @@ "kind": "file_glob", "pattern": "src/cli/commands/generate-handlers.ts" }, + "t-glob-89c6bc75": { + "kind": "file_glob", + "pattern": "src/canonical/load/*.ts" + }, "t-glob-8ac603bd": { "kind": "file_glob", "pattern": "src/lessons/action-match.ts" @@ -12912,6 +12931,10 @@ "kind": "file_glob", "pattern": "src/lessons/validate-health.ts" }, + "t-glob-e508af7d": { + "kind": "file_glob", + "pattern": "src/canonical/features/*.ts" + }, "t-glob-e5b597c8": { "kind": "file_glob", "pattern": "website/src/styles/*.css" From 8b51999ce2ffdd19d03da5f1517838a146f5ac4d Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 13:37:13 +0200 Subject: [PATCH 12/38] fix(rules): keep one root rule so packs and extends cannot replace yours Twelve targets pick the root with rules.find((r) => r.root). The merge (extends -> packs -> local) appends new rule names, so a pack or extend rule with root: true under any name but _root came before the user's _root.md and became the root: CLAUDE.md, AGENTS.md and Cursor's root rule showed only its text, check passed, and it worked even for remote packs whose elevated artifacts are stripped. settleRootRule (canonical/load/root-precedence.ts) now keeps exactly one root after the merge: the project's own, else an installed pack's, else an extend's. Every other root: true rule becomes a normal rule, and loadCanonicalWithExtends warns with both paths. Docs (extends.mdx, install.mdx) describe the rule. --- .changeset/root-rule-precedence.md | 5 + src/canonical/extends/extends.ts | 10 ++ src/canonical/load/root-precedence.ts | 49 ++++++++ .../canonical/root-rule-precedence.test.ts | 108 ++++++++++++++++++ website/src/content/docs/cli/install.mdx | 2 +- .../content/docs/configuration/extends.mdx | 2 +- 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 .changeset/root-rule-precedence.md create mode 100644 src/canonical/load/root-precedence.ts create mode 100644 tests/unit/canonical/root-rule-precedence.test.ts diff --git a/.changeset/root-rule-precedence.md b/.changeset/root-rule-precedence.md new file mode 100644 index 00000000..5d0bd8a5 --- /dev/null +++ b/.changeset/root-rule-precedence.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +An installed pack or an extended source can no longer replace your root instructions. Before, a pack rule with `root: true` (under any name other than `_root.md`) silently became the root rule, so `CLAUDE.md`, `AGENTS.md` and Cursor's root rule showed only the pack's text, and `check` still passed. This worked even for remote packs, whose hooks, MCP servers and permissions are stripped as untrusted. Now there is exactly one root rule: your own, else an installed pack's, else an extended source's. Any other `root: true` rule is used as a normal rule, and `generate` prints a warning naming it. diff --git a/src/canonical/extends/extends.ts b/src/canonical/extends/extends.ts index 3b29bb69..13355bd1 100644 --- a/src/canonical/extends/extends.ts +++ b/src/canonical/extends/extends.ts @@ -10,6 +10,7 @@ import type { ResolvedExtend } from '../../config/resolve/resolver.js'; import { resolveExtendPaths } from '../../config/resolve/resolver.js'; import { loadCanonicalFiles } from '../load/loader.js'; import { combineHooks, mergeCanonicalFiles } from '../load/merge.js'; +import { demotedRootMessage, settleRootRule } from '../load/root-precedence.js'; import { loadCanonicalForExtend } from './extend-load.js'; import { applyExtendPick } from './extend-pick.js'; import { gateExtendElevatedArtifacts } from './extend-elevated.js'; @@ -94,6 +95,15 @@ export async function loadCanonicalWithExtends( const localCanonical = await loadCanonicalFiles(canonicalDir); merged = mergeCanonicalFiles(merged, localCanonical); + const { rules, root, demoted } = settleRootRule( + merged.rules, + localCanonical.rules, + packsCanonical.rules, + ); + if (root !== undefined) { + for (const rule of demoted) logger.warn(demotedRootMessage(rule, root, configDir)); + } + merged = { ...merged, rules }; // A local event overrides extends hooks, but an installed pack's hooks stay // (e.g. `init --lessons` adding the recall hook must not drop them). merged = { ...merged, hooks: combineHooks(merged.hooks, packsCanonical.hooks) }; diff --git a/src/canonical/load/root-precedence.ts b/src/canonical/load/root-precedence.ts new file mode 100644 index 00000000..91736403 --- /dev/null +++ b/src/canonical/load/root-precedence.ts @@ -0,0 +1,49 @@ +import { relative } from 'node:path'; +import type { CanonicalRule } from '../../core/types.js'; + +export interface SettledRootRule { + readonly rules: CanonicalRule[]; + /** The root rule that stays, if any. */ + readonly root: CanonicalRule | undefined; + /** Rules that said `root: true` but are now used as normal rules. */ + readonly demoted: CanonicalRule[]; +} + +/** + * Keep one root rule after the extends → packs → local merge: the project's + * own, else an installed pack's, else an extend's (the first in merge order). + * Every other `root: true` rule becomes a normal rule, so a pack or an extend + * can never replace the project's root instructions. `local` and `packs` are + * the layer lists the merge took its rule objects from. + */ +export function settleRootRule( + merged: readonly CanonicalRule[], + local: readonly CanonicalRule[], + packs: readonly CanonicalRule[], +): SettledRootRule { + const roots = merged.filter((rule) => rule.root); + const root = + roots.find((rule) => local.includes(rule)) ?? + roots.find((rule) => packs.includes(rule)) ?? + roots[0]; + const demoted = roots.filter((rule) => rule !== root); + return { + rules: merged.map((rule) => (demoted.includes(rule) ? { ...rule, root: false } : rule)), + root, + demoted, + }; +} + +/** The warning for a rule `settleRootRule` demoted; paths relative to `baseDir`. */ +export function demotedRootMessage( + rule: CanonicalRule, + root: CanonicalRule, + baseDir: string, +): string { + const shown = (r: CanonicalRule): string => relative(baseDir, r.source).replaceAll('\\', '/'); + return ( + `[agentsmesh] Rule "${shown(rule)}" also says root: true, but "${shown(root)}" is the root rule ` + + "(the project's own root wins over installed packs, and packs over extends), so it is used " + + 'as a normal rule.' + ); +} diff --git a/tests/unit/canonical/root-rule-precedence.test.ts b/tests/unit/canonical/root-rule-precedence.test.ts new file mode 100644 index 00000000..cffbd532 --- /dev/null +++ b/tests/unit/canonical/root-rule-precedence.test.ts @@ -0,0 +1,108 @@ +/** + * Exactly one root rule survives the extends → packs → local merge: the + * project's own, else a pack's, else an extend's. Any other `root: true` rule is + * used as a normal rule, so an installed pack (even an untrusted remote one) or + * an extend can never replace the project's root instructions (GHSA-4m36-whwv-q74c). + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadCanonicalWithExtends } from '../../../src/canonical/extends/extends.js'; +import { runGenerate } from '../../../src/cli/commands/generate.js'; +import { loadConfigFromDir } from '../../../src/config/core/loader.js'; +import { logger } from '../../../src/utils/output/logger.js'; + +let base: string; +let project: string; + +const write = (path: string, text: string): void => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +}; +const rootRule = (title: string): string => `---\nroot: true\ndescription: r\n---\n# ${title}\n`; + +function setup({ extend }: { extend: boolean }): void { + const ext = extend + ? '\nextends:\n - name: base\n source: ../shared\n features: [rules]\n' + : '\n'; + write( + join(project, 'agentsmesh.yaml'), + `version: 1\ntargets: [claude-code, cursor]\nfeatures: [rules]${ext}`, + ); + const pack = join(project, '.agentsmesh', 'packs', 'sneak'); + write(join(pack, 'rules', 'sneaky.md'), rootRule('SNEAKY PACK ROOT')); + write( + join(pack, 'pack.yaml'), + 'name: sneak\nsource: github:org/repo@abc123\nsource_kind: github\n' + + 'installed_at: "2026-09-24T00:00:00Z"\nupdated_at: "2026-09-24T00:00:00Z"\n' + + 'content_hash: sha256:aabbcc\nfeatures:\n - rules\n', + ); + write(join(base, 'shared', '.agentsmesh', 'rules', 'base-root.md'), rootRule('EXTEND ROOT')); +} + +async function roots(): Promise { + const { config } = await loadConfigFromDir(project); + const { canonical } = await loadCanonicalWithExtends(config, project); + return canonical.rules.filter((r) => r.root).map((r) => r.body.trim()); +} + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-root-prec-'))); + project = join(base, 'project'); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(base, { recursive: true, force: true }); +}); + +describe('root rule precedence', () => { + it("keeps the project's own root over a pack's and an extend's", async () => { + setup({ extend: true }); + write(join(project, '.agentsmesh', 'rules', '_root.md'), rootRule('USER ROOT')); + + expect(await roots()).toEqual(['# USER ROOT']); + }); + + it("uses a pack's root over an extend's when the project has none", async () => { + setup({ extend: true }); + + expect(await roots()).toEqual(['# SNEAKY PACK ROOT']); + }); + + it('keeps the other root rules as normal rules and says so', async () => { + setup({ extend: false }); + write(join(project, '.agentsmesh', 'rules', '_root.md'), rootRule('USER ROOT')); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + + const { config } = await loadConfigFromDir(project); + const { canonical } = await loadCanonicalWithExtends(config, project); + + const sneaky = canonical.rules.find((r) => r.body.includes('SNEAKY PACK ROOT')); + expect(sneaky?.root).toBe(false); + expect(warn.mock.calls.map(([m]) => String(m))).toEqual([ + '[agentsmesh] Rule ".agentsmesh/packs/sneak/rules/sneaky.md" also says root: true, but ' + + '".agentsmesh/rules/_root.md" is the root rule (the project\'s own root wins over installed ' + + 'packs, and packs over extends), so it is used as a normal rule.', + ]); + }); + + it('generate writes the user root, not the pack root, as the root output', async () => { + setup({ extend: false }); + write(join(project, '.agentsmesh', 'rules', '_root.md'), rootRule('USER ROOT')); + + await runGenerate({}, project, { printMatrix: false }); + + const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8'); + const cursor = readFileSync(join(project, '.cursor', 'rules', 'general.mdc'), 'utf8'); + expect([claude.includes('USER ROOT'), claude.includes('SNEAKY PACK ROOT')]).toEqual([ + true, + false, + ]); + expect([cursor.includes('USER ROOT'), cursor.includes('SNEAKY PACK ROOT')]).toEqual([ + true, + false, + ]); + }); +}); diff --git a/website/src/content/docs/cli/install.mdx b/website/src/content/docs/cli/install.mdx index 0275d312..fef05944 100644 --- a/website/src/content/docs/cli/install.mdx +++ b/website/src/content/docs/cli/install.mdx @@ -424,6 +424,6 @@ Every install writes `.agentsmesh-install-manifest.json` next to the pack with t ## Merge behavior -Installed packs merge with your canonical config during `agentsmesh generate`. If a pack defines a rule with the same name as your canonical rule, the canonical rule takes precedence. +Installed packs merge with your canonical config during `agentsmesh generate`. If a pack defines a rule with the same name as your canonical rule, the canonical rule takes precedence. The same goes for the root rule, whatever its name: if a pack rule says `root: true`, your own root rule stays the root, and the pack's rule is used as a normal rule with a warning. A pack can never replace your root instructions. Hooks are the exception: a pack's hooks are never dropped. Packs with hooks on the same event are combined, a pack adds to an `extends` source's hooks for that event, and when your `hooks.yaml` defines the same event, the pack's hooks follow yours. A pack hook with the same type, matcher and command (or prompt text) as one of yours is the same hook, and your copy wins. To drop a pack hook, uninstall the pack or leave `hooks` out of its features. diff --git a/website/src/content/docs/configuration/extends.mdx b/website/src/content/docs/configuration/extends.mdx index 05cf36f8..6d0b7dec 100644 --- a/website/src/content/docs/configuration/extends.mdx +++ b/website/src/content/docs/configuration/extends.mdx @@ -141,7 +141,7 @@ When extends sources define resources that conflict with your local canonical co 2. Installed packs (`.agentsmesh/packs/`) 3. Extended sources (lowest priority) -This means your local overrides always take precedence over shared org config. The one exception is hooks from installed packs, which are kept next to yours; see [community packs](../../guides/community-packs/#merge-behavior). +This means your local overrides always take precedence over shared org config. The root rule follows the same order, even when the rules have different names: there is only one, taken from your local `.agentsmesh/` if it has one, else from an installed pack, else from an extended source. Any other rule marked `root: true` is used as a normal rule, and `generate` prints a warning naming it. The one exception is hooks from installed packs, which are kept next to yours; see [community packs](../../guides/community-packs/#merge-behavior). ## Installing extends via CLI From fa0796dbe5795c839e729837509c81ea8f226c1f Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 13:37:26 +0200 Subject: [PATCH 13/38] chore(lessons): capture the merge-order root rule --- .agentsmesh/lessons/lessons.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 80c7a909..4cb1f1ac 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -1831,6 +1831,22 @@ "t-glob-49133422" ] }, + "frontmatter-and-canonical-shapes-a-single-winner-field-chosen": { + "createdAt": "2026-09-24", + "evidence": [ + "8b51999c" + ], + "rule": "A single-winner field chosen with find() over merged layers depends on merge ORDER, not precedence: mergeByKey keeps base positions and APPENDS new names, so the highest-precedence layer's entries land last, and rules.find((r) => r.root) picked a pack's or extend's root: true rule over the project's _root.md (GHSA-4m36-whwv-q74c). After any merge change, keep the root settled by settleRootRule (canonical/load/root-precedence.ts): one root, the project's own, else a pack's, else an extend's.", + "status": "active", + "topics": [ + "frontmatter-and-canonical-shapes" + ], + "triggers": [ + "t-glob-3c315a53", + "t-glob-c21ba0b0", + "t-glob-40c513ae" + ] + }, "frontmatter-and-canonical-shapes-every-canonical-file-reader-must": { "createdAt": "2026-09-24", "evidence": [ @@ -11907,6 +11923,10 @@ "kind": "file_glob", "pattern": "tests/unit/lessons/import-legacy.test.ts" }, + "t-glob-40c513ae": { + "kind": "file_glob", + "pattern": "src/canonical/load/root-precedence.ts" + }, "t-glob-414c9b17": { "kind": "file_glob", "pattern": "website/package.json" From e61d9575a2d32b18229b7a8eb68fc15db48365ee Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:02:53 +0200 Subject: [PATCH 14/38] fix(import): keep permissions, ignore and MCP servers across imports Importing a second tool replaced .agentsmesh/permissions.yaml and .agentsmesh/ignore, and four importers (gemini-cli, cline, windsurf, crush) replaced .agentsmesh/mcp.json. Claude Code deny rules that protect secrets were dropped without a word, and the next generate removed them from .claude/settings.json. runTargetImport now wraps every importFrom call (import, init --yes, the public importFrom API and so the MCP import tool). It reads the three files before the import and adds back any entries the import dropped: permission lists and ignore lines are unioned, MCP servers merge by name with the new import winning. Being one step around the importer, it covers every builtin importer and plugin targets alike. Fixes #131 --- .changeset/import-keeps-settings.md | 5 + README.md | 2 +- src/canonical/features/permissions.ts | 9 ++ src/cli/commands/import.ts | 5 +- src/cli/commands/init-apply.ts | 3 +- src/public/engine.ts | 3 +- src/targets/import/mcp-merge.ts | 6 +- src/targets/import/run-target-import.ts | 88 +++++++++++ ...ort-multi-target-merge.integration.test.ts | 41 ++--- .../commands/import-keeps-settings.test.ts | 133 +++++++++++++++++ .../targets/import/run-target-import.test.ts | 140 ++++++++++++++++++ website/src/content/docs/cli/import.mdx | 10 +- .../content/docs/guides/existing-project.mdx | 4 +- .../docs/reference/programmatic-api.mdx | 2 + 14 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 .changeset/import-keeps-settings.md create mode 100644 src/targets/import/run-target-import.ts create mode 100644 tests/unit/cli/commands/import-keeps-settings.test.ts create mode 100644 tests/unit/targets/import/run-target-import.test.ts diff --git a/.changeset/import-keeps-settings.md b/.changeset/import-keeps-settings.md new file mode 100644 index 00000000..58b20185 --- /dev/null +++ b/.changeset/import-keeps-settings.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +Importing a second tool no longer drops the settings the first one brought in. Before, `agentsmesh import --from cursor` after `agentsmesh import --from claude-code` replaced `.agentsmesh/permissions.yaml` and `.agentsmesh/ignore` with Cursor's values, so Claude Code `deny` rules that protect secrets were lost without a word, and the next `generate` removed them from `.claude/settings.json`. Some importers (Gemini CLI, Cline, Windsurf, Crush) also replaced `.agentsmesh/mcp.json`. Now each import adds its permissions (`allow`, `deny`, `ask`), ignore patterns and MCP servers to the canonical files and keeps the ones already there; for an MCP server with the same name, the later import wins. This works the same for `import`, `init --yes`, the `importFrom()` API, the MCP `import` tool, and plugin targets. Import never removes an entry from these files; to drop one, edit the canonical file. diff --git a/README.md b/README.md index 580ba9ec..a6fed651 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Built to be depended on: 12,000+ tests on Linux, macOS, and Windows CI, JSON-Sch - **Lossless two-way sync.** When a tool has no native slot for a feature, it is embedded with round-trip metadata instead of dropped. [Managed embedding](https://samplexbro.github.io/agentsmesh/reference/managed-embedding/) - **Automatic link rebasing.** `.agentsmesh/skills/api-gen/template.hbs` becomes `.claude/skills/...` or `.cursor/skills/...` in each generated file. [Generation pipeline](https://samplexbro.github.io/agentsmesh/reference/generation-pipeline/) -- **Safe adoption.** `import`, then `diff`, then `generate`, then `check`; nothing is overwritten blind. When several tools have a root rule, they are merged into one `_root.md` rather than the last one winning. [Existing-project guide](https://samplexbro.github.io/agentsmesh/guides/existing-project/) +- **Safe adoption.** `import`, then `diff`, then `generate`, then `check`; nothing is overwritten blind. When several tools have a root rule, they are merged into one `_root.md` rather than the last one winning, and their permissions, ignore patterns and MCP servers add up instead of replacing each other. [Existing-project guide](https://samplexbro.github.io/agentsmesh/guides/existing-project/) - **Output directory safety.** `generate` and `convert` reject output directories that resolve outside the project (or home directory with `--global`), checked before any write and under `--dry-run` too. Stale-file cleanup enforces the same boundary. - **Safe updates.** Explicitly empty Claude permission, hook, and MCP configurations clear their generated entries. MCP edits preserve untouched server fields and reject malformed files; skill updates validate supporting files before writing. - **Migrate between tools.** `convert --from --to ` rewrites one tool's config directly into another's. [convert](https://samplexbro.github.io/agentsmesh/cli/convert/) diff --git a/src/canonical/features/permissions.ts b/src/canonical/features/permissions.ts index 5c45c9c1..1570cd3d 100644 --- a/src/canonical/features/permissions.ts +++ b/src/canonical/features/permissions.ts @@ -23,6 +23,15 @@ export async function parsePermissions( ): Promise { const content = await readFileSafe(permissionsPath); if (content === null) return null; + return parsePermissionsContent(content, permissionsPath, onParseError); +} + +/** Parse permissions.yaml text; `permissionsPath` only names the file in errors. */ +export function parsePermissionsContent( + content: string, + permissionsPath: string, + onParseError?: ParseErrorCallback, +): Required | null { if (!content.trim()) return { allow: [], deny: [], ask: [] }; let parsed: unknown; try { diff --git a/src/cli/commands/import.ts b/src/cli/commands/import.ts index 8c2750bc..fd6ce377 100644 --- a/src/cli/commands/import.ts +++ b/src/cli/commands/import.ts @@ -17,6 +17,7 @@ import { rootRuleBodyGrew, } from '../../targets/import/root-rule-body-merge.js'; import { AB_ROOT_RULE } from '../../core/canonical-paths.js'; +import { runTargetImport } from '../../targets/import/run-target-import.js'; export interface ImportCommandResult { exitCode: number; @@ -76,7 +77,7 @@ export async function runImport( const context = resolveScopeContext(root, scope); const target = getDescriptor(normalized)!; const rootBefore = readRootRuleBody(context.rootBase); - const results = await target.generators.importFrom(context.rootBase, { scope }); + const results = await runTargetImport(target, context.rootBase, scope); if (results.length > 0) { await seedAgentsmeshMcpEntry(context.rootBase); await ensureImportedLessonsSubsystem(context.rootBase, scope); @@ -112,7 +113,7 @@ export async function runImport( } const rootBefore = readRootRuleBody(context.rootBase); - const results = await descriptor.generators.importFrom(context.rootBase, { scope }); + const results = await runTargetImport(descriptor, context.rootBase, scope); if (results.length > 0) { await seedAgentsmeshMcpEntry(context.rootBase); await ensureImportedLessonsSubsystem(context.rootBase, scope); diff --git a/src/cli/commands/init-apply.ts b/src/cli/commands/init-apply.ts index 2c9e010e..6cabc500 100644 --- a/src/cli/commands/init-apply.ts +++ b/src/cli/commands/init-apply.ts @@ -10,6 +10,7 @@ import { join, relative } from 'node:path'; import { writeFileAtomic } from '../../utils/filesystem/fs.js'; import { ensureGitignoreEntries } from '../../utils/filesystem/gitignore.js'; import { BUILTIN_TARGETS } from '../../targets/catalog/builtin-targets.js'; +import { runTargetImport } from '../../targets/import/run-target-import.js'; import type { ImportResult } from '../../core/types.js'; import { buildConfig, LOCAL_TEMPLATE } from './init-templates.js'; import { writeScaffoldFull, writeScaffoldGapFill } from './init-scaffold.js'; @@ -44,7 +45,7 @@ const IMPORTERS: Record Promise [ d.id, - (root: string, scope: ConfigScope) => d.generators.importFrom(root, { scope }), + (root: string, scope: ConfigScope) => runTargetImport(d, root, scope), ]), ); diff --git a/src/public/engine.ts b/src/public/engine.ts index 257d78e6..b23d03cd 100644 --- a/src/public/engine.ts +++ b/src/public/engine.ts @@ -39,6 +39,7 @@ import type { TargetLayoutScope } from '../targets/catalog/target-descriptor.js' import type { ComputeDiffResult } from '../core/differ.js'; import { getDescriptor, getAllDescriptors } from '../targets/catalog/registry.js'; import { TARGET_IDS } from '../targets/catalog/target-ids.js'; +import { runTargetImport } from '../targets/import/run-target-import.js'; export type { ValidatedConfig } from '../config/core/schema.js'; export type { TargetLayoutScope } from '../targets/catalog/target-descriptor.js'; @@ -57,7 +58,7 @@ export async function importFrom( supported: [...TARGET_IDS, ...getAllDescriptors().map((d) => d.id)], }); } - return descriptor.generators.importFrom(opts.root, { scope: opts.scope ?? 'project' }); + return runTargetImport(descriptor, opts.root, opts.scope ?? 'project'); } /** diff --git a/src/targets/import/mcp-merge.ts b/src/targets/import/mcp-merge.ts index bc5a91fa..04b38a7e 100644 --- a/src/targets/import/mcp-merge.ts +++ b/src/targets/import/mcp-merge.ts @@ -22,7 +22,11 @@ export async function writeMcpWithMerge( } async function readExistingServers(path: string): Promise> { - const content = await readFileSafe(path); + return parseMcpServers(await readFileSafe(path)); +} + +/** Servers in canonical mcp.json text; `{}` when it is missing or not valid. */ +export function parseMcpServers(content: string | null): Record { if (content === null) return {}; let parsed: unknown; try { diff --git a/src/targets/import/run-target-import.ts b/src/targets/import/run-target-import.ts new file mode 100644 index 00000000..c8693b66 --- /dev/null +++ b/src/targets/import/run-target-import.ts @@ -0,0 +1,88 @@ +/** + * Import one target without dropping canonical settings an earlier import wrote. + * + * Permissions, ignore patterns and MCP servers are lists of separate entries, + * not one entity, so a second tool's import adds to them instead of replacing + * them (#131): what the importer wrote stays, and entries only the earlier file + * had are added back. Import never removes an entry here; edit the canonical + * file for that. Every other canonical path keeps last-import-wins. + * + * Done once around `importFrom`, so every importer and every plugin gets it. + */ + +import { join } from 'node:path'; +import { parseDocument } from 'yaml'; +import { parsePermissionsContent } from '../../canonical/features/permissions.js'; +import { AB_IGNORE, AB_MCP, AB_PERMISSIONS } from '../../core/canonical-paths.js'; +import type { ImportResult } from '../../core/types.js'; +import { readFileSafe, writeFileAtomic } from '../../utils/filesystem/fs.js'; +import type { TargetDescriptor, TargetLayoutScope } from '../catalog/target-descriptor.js'; +import { parseMcpServers } from './mcp-merge.js'; + +const PERMISSION_LISTS = ['allow', 'deny', 'ask'] as const; + +type Keeper = (path: string, before: string) => Promise; + +async function keepPermissions(path: string, before: string): Promise { + const after = (await readFileSafe(path)) ?? ''; + const skipBroken = (): void => undefined; + const earlier = parsePermissionsContent(before, path, skipBroken); + const now = parsePermissionsContent(after, path, skipBroken); + if (earlier === null || now === null) return; + const doc = parseDocument(after); + let changed = false; + for (const list of PERMISSION_LISTS) { + const old = earlier[list]; + const cur = now[list]; + if (old.every((entry) => cur.includes(entry))) continue; + doc.set(list, [...old, ...cur.filter((entry) => !old.includes(entry))]); + changed = true; + } + if (changed) await writeFileAtomic(path, doc.toString()); +} + +async function keepIgnorePatterns(path: string, before: string): Promise { + const lines = (text: string): string[] => + text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const earlier = lines(before); + const now = lines((await readFileSafe(path)) ?? ''); + if (earlier.every((line) => now.includes(line))) return; + const added = now.filter((line) => !earlier.includes(line)); + await writeFileAtomic(path, `${[before.trimEnd(), ...added].join('\n')}\n`); +} + +/** Same rule as `writeMcpWithMerge`: the new import wins on a name clash. */ +async function keepMcpServers(path: string, before: string): Promise { + const earlier = parseMcpServers(before); + const now = parseMcpServers(await readFileSafe(path)); + if (Object.keys(earlier).every((name) => Object.hasOwn(now, name))) return; + await writeFileAtomic(path, JSON.stringify({ mcpServers: { ...earlier, ...now } }, null, 2)); +} + +const KEEPERS: ReadonlyArray = [ + [AB_PERMISSIONS, keepPermissions], + [AB_IGNORE, keepIgnorePatterns], + [AB_MCP, keepMcpServers], +]; + +/** Run the target's import, then add back settings entries it replaced. */ +export async function runTargetImport( + descriptor: Pick, + rootBase: string, + scope: TargetLayoutScope, +): Promise { + const snapshots = await Promise.all( + KEEPERS.map(async ([rel, keep]) => { + const path = join(rootBase, rel); + return { path, keep, before: await readFileSafe(path) }; + }), + ); + const results = await descriptor.generators.importFrom(rootBase, { scope }); + for (const { path, keep, before } of snapshots) { + if (before !== null) await keep(path, before); + } + return results; +} diff --git a/tests/integration/import-multi-target-merge.integration.test.ts b/tests/integration/import-multi-target-merge.integration.test.ts index 9fe9bf0c..c7ffd80d 100644 --- a/tests/integration/import-multi-target-merge.integration.test.ts +++ b/tests/integration/import-multi-target-merge.integration.test.ts @@ -2,9 +2,9 @@ * Sequential `agentsmesh import --from ` behavior across targets. * * Overlapping canonical paths are last-import-wins; disjoint paths accumulate. - * Two exceptions, both because the canonical slot holds more than one source: - * MCP servers merge by name across sequential imports (imported wins on - * conflict), and `rules/_root.md` accumulates every target's root rule — + * The exceptions are slots that hold more than one source: MCP servers merge by + * name (imported wins on conflict), permissions and ignore patterns add up + * entry by entry, and `rules/_root.md` accumulates every target's root rule — * two tools' root instructions are distinct content, not one entity described * twice, so replacing there would destroy the user's rules. * @@ -17,6 +17,7 @@ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execSync } from 'node:child_process'; +import { parse as parseYaml } from 'yaml'; const TEST_DIR = join(tmpdir(), 'am-integration-import-multi'); const CLI_PATH = join(process.cwd(), 'dist', 'cli.js'); @@ -206,24 +207,24 @@ describe('import: multi-target sequential merge (integration)', () => { ]); }); - it('last import wins for .agentsmesh/ignore when both targets contribute ignore patterns', () => { + it('ignore patterns from both targets accumulate, in either order', () => { writeFileSync(join(TEST_DIR, '.claudeignore'), 'from-claude-ignore\n'); mkdirSync(join(TEST_DIR, '.cursor'), { recursive: true }); writeFileSync(join(TEST_DIR, '.cursorignore'), 'from-cursor-ignore\n'); runImport('claude-code'); runImport('cursor'); - const ign = readFileSync(join(TEST_DIR, '.agentsmesh', 'ignore'), 'utf-8'); - expect(ign).toContain('from-cursor-ignore'); - expect(ign).not.toContain('from-claude-ignore'); + expect(readFileSync(join(TEST_DIR, '.agentsmesh', 'ignore'), 'utf-8')).toBe( + 'from-claude-ignore\nfrom-cursor-ignore\n', + ); rmSync(join(TEST_DIR, '.agentsmesh'), { recursive: true, force: true }); runImport('cursor'); runImport('claude-code'); - const ign2 = readFileSync(join(TEST_DIR, '.agentsmesh', 'ignore'), 'utf-8'); - expect(ign2).toContain('from-claude-ignore'); - expect(ign2).not.toContain('from-cursor-ignore'); + expect(readFileSync(join(TEST_DIR, '.agentsmesh', 'ignore'), 'utf-8')).toBe( + 'from-cursor-ignore\nfrom-claude-ignore\n', + ); }); it('same skill name from two targets: last import overwrites canonical skills//SKILL.md', () => { @@ -253,7 +254,7 @@ describe('import: multi-target sequential merge (integration)', () => { ).toContain('SKILL_BODY_CLAUDE'); }); - it('last import wins for .agentsmesh/permissions.yaml from native settings.json', () => { + it('permissions from both targets accumulate, in either order', () => { mkdirSync(join(TEST_DIR, '.claude'), { recursive: true }); mkdirSync(join(TEST_DIR, '.cursor'), { recursive: true }); writeFileSync( @@ -269,19 +270,25 @@ describe('import: multi-target sequential merge (integration)', () => { }), ); + const perms = (): unknown => + parseYaml(readFileSync(join(TEST_DIR, '.agentsmesh', 'permissions.yaml'), 'utf-8')); + runImport('claude-code'); runImport('cursor'); - const perms = readFileSync(join(TEST_DIR, '.agentsmesh', 'permissions.yaml'), 'utf-8'); - expect(perms).toContain('PERM_FROM_CURSOR'); - expect(perms).not.toContain('PERM_FROM_CLAUDE'); + expect(perms()).toEqual({ + allow: ['Read', 'Write'], + deny: ['PERM_FROM_CLAUDE', 'PERM_FROM_CURSOR'], + }); rmSync(join(TEST_DIR, '.agentsmesh'), { recursive: true, force: true }); runImport('cursor'); runImport('claude-code'); - const perms2 = readFileSync(join(TEST_DIR, '.agentsmesh', 'permissions.yaml'), 'utf-8'); - expect(perms2).toContain('PERM_FROM_CLAUDE'); - expect(perms2).not.toContain('PERM_FROM_CURSOR'); + expect(perms()).toEqual({ + allow: ['Write', 'Read'], + deny: ['PERM_FROM_CURSOR', 'PERM_FROM_CLAUDE'], + ask: [], + }); }); it('last import wins for .agentsmesh/hooks.yaml (standalone hooks.json per target)', () => { diff --git a/tests/unit/cli/commands/import-keeps-settings.test.ts b/tests/unit/cli/commands/import-keeps-settings.test.ts new file mode 100644 index 00000000..27aa04d1 --- /dev/null +++ b/tests/unit/cli/commands/import-keeps-settings.test.ts @@ -0,0 +1,133 @@ +/** + * Importing a second tool, with `import` or `init --yes`, adds to canonical + * permissions, ignore patterns and MCP servers instead of replacing them, so a + * deny rule or a server the first import brought in is never dropped (#131). + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runImport } from '../../../../src/cli/commands/import.js'; +import { runInit } from '../../../../src/cli/commands/init.js'; + +const PERMS = '.agentsmesh/permissions.yaml'; +const IGNORE = '.agentsmesh/ignore'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const perms = (): unknown => parseYaml(read(PERMS)); + +function claudeAndCursor(): void { + write('CLAUDE.md', '# Project\n'); + write( + '.claude/settings.json', + JSON.stringify({ + permissions: { deny: ['Read(./secrets/**)', 'Bash(rm -rf:*)'], allow: ['Bash(npm test)'] }, + }), + ); + write('.claudeignore', 'secrets/\n'); + write('.cursor/cli.json', JSON.stringify({ permissions: { allow: ['Shell(ls)'] } })); + write('.cursorignore', 'build/\n'); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-import-keep-'))); +}); +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('importing a second tool', () => { + it('keeps the first tool deny rules and ignore patterns', async () => { + claudeAndCursor(); + + await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'cursor' }, root); + + expect(perms()).toEqual({ + allow: ['Bash(npm test)', 'Shell(ls)'], + deny: ['Read(./secrets/**)', 'Bash(rm -rf:*)'], + }); + expect(read(IGNORE)).toBe('secrets/\nbuild/\n'); + }); + + it('keeps them in the other order too', async () => { + claudeAndCursor(); + + await runImport({ from: 'cursor' }, root); + await runImport({ from: 'claude-code' }, root); + + expect(perms()).toEqual({ + allow: ['Shell(ls)', 'Bash(npm test)'], + deny: ['Read(./secrets/**)', 'Bash(rm -rf:*)'], + ask: [], + }); + expect(read(IGNORE)).toBe('build/\nsecrets/\n'); + }); + + it('keeps MCP servers from a tool whose importer writes mcp.json itself', async () => { + write('CLAUDE.md', '# Root\n'); + write( + '.mcp.json', + JSON.stringify({ mcpServers: { claudeonly: { command: 'a' }, shared: { command: 'c' } } }), + ); + write( + '.gemini/settings.json', + JSON.stringify({ mcpServers: { geminionly: { command: 'b' }, shared: { command: 'g' } } }), + ); + + await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'gemini-cli' }, root); + + const servers = ( + JSON.parse(read('.agentsmesh/mcp.json')) as { + mcpServers: Record; + } + ).mcpServers; + expect(Object.keys(servers).sort()).toEqual([ + 'agentsmesh', + 'claudeonly', + 'geminionly', + 'shared', + ]); + expect(servers.shared?.command).toBe('g'); + }); + + it('leaves the files byte-identical when the same tool is imported again', async () => { + claudeAndCursor(); + await runImport({ from: 'claude-code' }, root); + const first = [read(PERMS), read(IGNORE)]; + + await runImport({ from: 'claude-code' }, root); + + expect([read(PERMS), read(IGNORE)]).toEqual(first); + }); + + it('init --yes keeps the settings of every detected tool', async () => { + claudeAndCursor(); + // init detects Cursor by its rules, not by cli.json. + write('.cursor/rules/style.mdc', '---\ndescription: Style\n---\n# Style\n'); + // init reads HOME to pick targets; keep this machine's tools out of it. + const home = join(root, 'home'); + mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + + await runInit(root, { yes: true }); + + const merged = perms() as { allow: string[]; deny: string[] }; + expect([[...merged.allow].sort(), [...merged.deny].sort()]).toEqual([ + ['Bash(npm test)', 'Shell(ls)'], + ['Bash(rm -rf:*)', 'Read(./secrets/**)'], + ]); + expect(read(IGNORE).split('\n').filter(Boolean).sort()).toEqual(['build/', 'secrets/']); + }); +}); diff --git a/tests/unit/targets/import/run-target-import.test.ts b/tests/unit/targets/import/run-target-import.test.ts new file mode 100644 index 00000000..885633e6 --- /dev/null +++ b/tests/unit/targets/import/run-target-import.test.ts @@ -0,0 +1,140 @@ +/** + * runTargetImport adds back canonical permissions, ignore patterns and MCP + * servers an import replaced, for every importer and every plugin (#131). + * The CLI flows are covered in tests/unit/cli/commands/import-keeps-settings.test.ts. + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { importFrom } from '../../../../src/public/engine.js'; +import { runTargetImport } from '../../../../src/targets/import/run-target-import.js'; +import { + getDescriptor, + registerTargetDescriptor, + resetRegistry, +} from '../../../../src/targets/catalog/registry.js'; +import type { TargetDescriptor } from '../../../../src/targets/catalog/target-descriptor.js'; +import type { ImportResult } from '../../../../src/core/types.js'; + +const PERMS = '.agentsmesh/permissions.yaml'; +const IGNORE = '.agentsmesh/ignore'; +const MCP = '.agentsmesh/mcp.json'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const perms = (): unknown => parseYaml(read(PERMS)); + +/** A cursor-shaped descriptor whose import runs `body` instead. */ +function fakeDescriptor(body: () => void): Pick { + const importFrom = async (): Promise => { + body(); + return []; + }; + return { generators: { ...getDescriptor('cursor')!.generators, importFrom } }; +} + +const importWith = (body: () => void): Promise => + runTargetImport(fakeDescriptor(body), root, 'project'); + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-run-import-'))); +}); +afterEach(() => { + resetRegistry(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('runTargetImport', () => { + it('a registered plugin descriptor gets the same merge through importFrom', async () => { + write(PERMS, 'allow:\n - Read\ndeny:\n - Secret\n'); + write(IGNORE, 'keep/\n'); + write(MCP, JSON.stringify({ mcpServers: { kept: { command: 'k' } } })); + const plugin = fakeDescriptor(() => { + write(PERMS, 'allow:\n - PluginAllow\ndeny: []\n'); + write(IGNORE, 'plugin/\n'); + write(MCP, JSON.stringify({ mcpServers: { pluginsrv: { command: 'p' } } })); + }); + registerTargetDescriptor({ ...getDescriptor('cursor')!, ...plugin, id: 'merge-plugin' }); + + await importFrom('merge-plugin', { root }); + + expect(perms()).toEqual({ allow: ['Read', 'PluginAllow'], deny: ['Secret'] }); + expect(read(IGNORE)).toBe('keep/\nplugin/\n'); + expect(JSON.parse(read(MCP))).toEqual({ + mcpServers: { kept: { command: 'k' }, pluginsrv: { command: 'p' } }, + }); + }); + + it('does not touch files the import did not write', async () => { + const perm = '# mine\nallow:\n - Read\n'; + write(PERMS, perm); + write(IGNORE, '# note\n\nkeep/\n'); + + await importWith(() => undefined); + + expect([read(PERMS), read(IGNORE)]).toEqual([perm, '# note\n\nkeep/\n']); + }); + + it('adds only ignore lines the earlier file lacks, after its own lines', async () => { + write(IGNORE, 'a\n# note\nb\n'); + + await importWith(() => write(IGNORE, 'b\n\nc\n')); + + expect(read(IGNORE)).toBe('a\n# note\nb\nc\n'); + }); + + it('keeps the new permissions file comments and adds back only missing entries', async () => { + write(PERMS, 'allow:\n - Read\ndeny:\n - Secret\n'); + + await importWith(() => write(PERMS, '# from tool\nallow:\n - Read\n - Write\n')); + + expect(read(PERMS).startsWith('# from tool\n')).toBe(true); + expect(perms()).toEqual({ allow: ['Read', 'Write'], deny: ['Secret'] }); + }); + + it('restores the entries when the import deletes the files', async () => { + write(PERMS, 'deny:\n - Secret\n'); + write(IGNORE, 'keep/\n'); + + await importWith(() => { + rmSync(join(root, PERMS)); + rmSync(join(root, IGNORE)); + }); + + expect([perms(), read(IGNORE)]).toEqual([{ deny: ['Secret'] }, 'keep/\n']); + }); + + it('keeps what the import wrote when the earlier files are broken', async () => { + write(PERMS, 'allow: [unclosed\n'); + write(MCP, '{ not json'); + + await importWith(() => { + write(PERMS, 'allow:\n - New\n'); + write(MCP, '{"mcpServers":{"n":{"command":"n"}}}'); + }); + + expect([read(PERMS), read(MCP)]).toEqual([ + 'allow:\n - New\n', + '{"mcpServers":{"n":{"command":"n"}}}', + ]); + }); + + it('adds back an MCP server named like an Object method', async () => { + write(MCP, JSON.stringify({ mcpServers: { constructor: { command: 'c' } } })); + + await importWith(() => write(MCP, JSON.stringify({ mcpServers: { n: { command: 'n' } } }))); + + expect(Object.keys((JSON.parse(read(MCP)) as { mcpServers: object }).mcpServers)).toEqual([ + 'constructor', + 'n', + ]); + }); +}); diff --git a/website/src/content/docs/cli/import.mdx b/website/src/content/docs/cli/import.mdx index ebda37a5..d3681173 100644 --- a/website/src/content/docs/cli/import.mdx +++ b/website/src/content/docs/cli/import.mdx @@ -134,7 +134,15 @@ This keeps cross-references valid after import. Most canonical paths are last-import-wins: if two tools each define a command named `ship`, the second import replaces the first, because both describe the same entity. -`.agentsmesh/rules/_root.md` is the exception. It is the single slot every tool's root +Permissions, ignore patterns and MCP servers are different: each is a list of separate +entries, not one entity. A second import **adds** its entries to what is already in +`.agentsmesh/permissions.yaml`, `.agentsmesh/ignore` and `.agentsmesh/mcp.json`, and keeps +the entries the first tool brought in. So a Claude Code `deny` rule survives a later Cursor +import. When two tools define an MCP server with the same name, the later import wins for +that server. Import never removes an entry from these files; to drop one, edit the +canonical file and run `agentsmesh generate`. + +`.agentsmesh/rules/_root.md` is also an exception. It is the single slot every tool's root instruction collapses into, so `CLAUDE.md` and an always-apply Cursor rule are different rules that merely share a destination. Importing the second one **appends** it to the first instead of replacing it, and the CLI says the root rule was merged: diff --git a/website/src/content/docs/guides/existing-project.mdx b/website/src/content/docs/guides/existing-project.mdx index 777c3a35..1d0e92eb 100644 --- a/website/src/content/docs/guides/existing-project.mdx +++ b/website/src/content/docs/guides/existing-project.mdx @@ -52,8 +52,8 @@ If your project already has AI tool configs scattered across `.claude/`, `.curso `.cursor/rules/*.mdc` — all of them are combined into `.agentsmesh/rules/_root.md` and the CLI tells you the root rule was merged. Nothing is dropped, but nothing is reconciled either: read the merged file and delete anything the tools said twice. - Every other canonical path (commands, skills, hooks, permissions, ignore) is - last-import-wins. + Permissions, ignore patterns and MCP servers add up the same way, entry by entry. + Every other canonical path (commands, skills, hooks) is last-import-wins. 3. **Configure agentsmesh.yaml** diff --git a/website/src/content/docs/reference/programmatic-api.mdx b/website/src/content/docs/reference/programmatic-api.mdx index 2dc1754f..f36c7bd3 100644 --- a/website/src/content/docs/reference/programmatic-api.mdx +++ b/website/src/content/docs/reference/programmatic-api.mdx @@ -130,6 +130,8 @@ When two targets emit the same output path with the same content (e.g. Codex CLI Imports a tool's native config back into canonical form on disk under `.agentsmesh/`. Returns one `ImportResult` per file imported. +Like `agentsmesh import`, it adds to the canonical permissions, ignore patterns and MCP servers already on disk instead of replacing them. See [Importing more than one tool](../../cli/import/#importing-more-than-one-tool). + ```ts import { importFrom, type ImportResult } from 'agentsmesh'; From 44a947c69137c717e3446ddb0c80caf06896b603 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:03:27 +0200 Subject: [PATCH 15/38] chore(lessons): capture the import settings merge rule Supersede the "last-import-wins except _root.md and mcp.json" lesson with the runTargetImport rule, and record that init detects Cursor by its rules, not by cli.json. --- .agentsmesh/lessons/lessons.json | 58 ++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 4cb1f1ac..14445e00 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -1281,6 +1281,21 @@ "t-glob-aa8ef603" ] }, + "fixture-and-assertion-discipline-in-init-yes-tests-init": { + "createdAt": "2026-09-24", + "evidence": [ + "e61d9575" + ], + "rule": "In init --yes tests, init detects Cursor only by its descriptor detectionPaths (.cursor/rules, .cursor/mcp.json, etc.), not by .cursor/cli.json or .cursorignore alone; without a rule file Cursor is never imported and a merge test fails as if the product were broken. Add .cursor/rules/.mdc to the fixture, and check the init --json detectedConfigs before blaming the code.", + "status": "active", + "topics": [ + "fixture-and-assertion-discipline" + ], + "triggers": [ + "t-glob-1377e63f", + "t-glob-75a81240" + ] + }, "fixture-and-assertion-discipline-in-path-equality-endswith-test": { "createdAt": "2026-07-07", "evidence": [ @@ -3269,13 +3284,28 @@ "4d5c0a51" ], "rule": "Canonical paths are last-import-wins EXCEPT .agentsmesh/rules/_root.md and mcp.json, which accumulate. Root rule merging lives in the single shared serializer serializeImportedRuleWithFallback (src/targets/import/import-metadata-core.ts) because all ~45 root write sites across 33 targets funnel through it — never patch per-target importers for root-rule behavior. When changing it, remember the init placeholder body (src/canonical/root-rule-placeholder.ts) must count as unwritten or every import stacks under scaffold boilerplate.", + "status": "superseded", + "supersededBy": "install-import-pickers-canonical-permissions-yaml-ignore-and", + "topics": [ + "install-import-pickers" + ], + "triggers": [] + }, + "install-import-pickers-canonical-permissions-yaml-ignore-and": { + "createdAt": "2026-09-24", + "evidence": [ + "e61d9575" + ], + "rule": "Canonical permissions.yaml, ignore and mcp.json accumulate across imports through runTargetImport (src/targets/import/run-target-import.ts), which snapshots them before importFrom and adds back dropped entries; _root.md accumulates through serializeImportedRuleWithFallback; every other canonical path is last-import-wins. Route any new import entry point through runTargetImport instead of calling descriptor.generators.importFrom directly, and never patch per-target importers for merge behaviour: four importers already bypassed writeMcpWithMerge (#131).", "status": "active", "topics": [ "install-import-pickers" ], "triggers": [ - "t-glob-f98a792a", - "t-glob-cfe44da2" + "t-glob-36b511b8", + "t-glob-90ecaacd", + "t-glob-6b98951d", + "t-glob-84b6c902" ] }, "install-import-pickers-every-per-target-mcp-importer": { @@ -11639,6 +11669,10 @@ "kind": "file_glob", "pattern": "src/targets/catalog/registry.ts" }, + "t-glob-1377e63f": { + "kind": "file_glob", + "pattern": "tests/**/*init*.test.ts" + }, "t-glob-13a07989": { "kind": "file_glob", "pattern": "src/mcp/handlers/*.ts" @@ -11839,6 +11873,10 @@ "kind": "file_glob", "pattern": "scripts/seed-capability-ledger.ts" }, + "t-glob-36b511b8": { + "kind": "file_glob", + "pattern": "src/targets/import/**" + }, "t-glob-36d72b33": { "kind": "file_glob", "pattern": "tests/consumer-smoke/src/smoke.ts" @@ -12247,6 +12285,10 @@ "kind": "file_glob", "pattern": "tests/unit/targets/per-target-lint-branches.test.ts" }, + "t-glob-75a81240": { + "kind": "file_glob", + "pattern": "tests/unit/cli/commands/import-keeps-settings.test.ts" + }, "t-glob-75de7d28": { "kind": "file_glob", "pattern": "src/core/path-helpers.ts" @@ -12451,6 +12493,10 @@ "kind": "file_glob", "pattern": "**/SKILL.md" }, + "t-glob-90ecaacd": { + "kind": "file_glob", + "pattern": "src/cli/commands/import.ts" + }, "t-glob-91e8e620": { "kind": "file_glob", "pattern": "tsconfig.tests.json" @@ -12807,10 +12853,6 @@ "kind": "file_glob", "pattern": "src/core/generate/*-merge.ts" }, - "t-glob-cfe44da2": { - "kind": "file_glob", - "pattern": "src/canonical/root-rule-placeholder.ts" - }, "t-glob-d044343b": { "kind": "file_glob", "pattern": "README.md" @@ -13047,10 +13089,6 @@ "kind": "file_glob", "pattern": "src/utils/filesystem/rename-retry.ts" }, - "t-glob-f98a792a": { - "kind": "file_glob", - "pattern": "src/targets/import/*.ts" - }, "t-glob-f9f526aa": { "kind": "file_glob", "pattern": "src/cli/commands/generate-lock.ts" From 982f61dfa8c763270e42f7900a6302bdc2025d71 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:37:29 +0200 Subject: [PATCH 16/38] fix(init): keep both same-name rules, commands and agents on --yes init --yes imports every detected tool in one run, but a canonical name comes from the file name, so two tools' typescript.md rules (or deploy.md commands) landed on one file: the later tool replaced the earlier text and mixed in its frontmatter, and the next generate wrote that over the first tool's own file. importKeepingSameName now wraps each tool's import in init. The rule, command and agent files earlier tools wrote in this run are set aside while the next tool imports, so it writes a clean file. The earlier file is then put back; a different text from the later tool is saved as -.md and listed in the new InitData.sameNameCopies, which the renderer prints as a warning. The same text keeps one file. Separate import --from runs stay last-import-wins. Fixes #132 --- .changeset/init-same-name-kept.md | 5 + README.md | 2 +- src/cli/command-result.ts | 5 + src/cli/commands/init-apply.ts | 19 ++- src/cli/commands/init-same-name.ts | 93 +++++++++++ src/cli/commands/init-wizard.ts | 1 + src/cli/commands/init.ts | 1 + src/cli/renderers/init.ts | 7 + .../commands/init-same-name-helper.test.ts | 144 ++++++++++++++++++ .../unit/cli/commands/init-same-name.test.ts | 120 +++++++++++++++ .../unit/cli/renderers/init-same-name.test.ts | 48 ++++++ tests/unit/cli/renderers/init.test.ts | 2 + .../cli/renderers/renderer-test-helpers.ts | 1 + website/src/content/docs/cli/import.mdx | 3 + website/src/content/docs/cli/init.mdx | 2 + .../content/docs/guides/existing-project.mdx | 5 +- 16 files changed, 450 insertions(+), 8 deletions(-) create mode 100644 .changeset/init-same-name-kept.md create mode 100644 src/cli/commands/init-same-name.ts create mode 100644 tests/unit/cli/commands/init-same-name-helper.test.ts create mode 100644 tests/unit/cli/commands/init-same-name.test.ts create mode 100644 tests/unit/cli/renderers/init-same-name.test.ts diff --git a/.changeset/init-same-name-kept.md b/.changeset/init-same-name-kept.md new file mode 100644 index 00000000..363e5263 --- /dev/null +++ b/.changeset/init-same-name-kept.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +`agentsmesh init --yes` no longer loses a rule, command or agent when two tools have one with the same name. Before, the later tool's file replaced the earlier one in `.agentsmesh/`, with the earlier file's frontmatter mixed in, and the next `generate` wrote that text over the first tool's own file. Now, if both texts are the same, one canonical file is kept. If they differ, the first tool's file keeps its name, and the later tool's version is saved as `-.md` (for example `.agentsmesh/rules/typescript-cursor.md`), each with only its own frontmatter. Init prints a warning for each copy, and `init --json` lists them in `sameNameCopies`. The interactive wizard works the same way. Separate `agentsmesh import --from` runs stay last-import-wins. diff --git a/README.md b/README.md index a6fed651..7b3ddf24 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Built to be depended on: 12,000+ tests on Linux, macOS, and Windows CI, JSON-Sch - **Lossless two-way sync.** When a tool has no native slot for a feature, it is embedded with round-trip metadata instead of dropped. [Managed embedding](https://samplexbro.github.io/agentsmesh/reference/managed-embedding/) - **Automatic link rebasing.** `.agentsmesh/skills/api-gen/template.hbs` becomes `.claude/skills/...` or `.cursor/skills/...` in each generated file. [Generation pipeline](https://samplexbro.github.io/agentsmesh/reference/generation-pipeline/) -- **Safe adoption.** `import`, then `diff`, then `generate`, then `check`; nothing is overwritten blind. When several tools have a root rule, they are merged into one `_root.md` rather than the last one winning, and their permissions, ignore patterns and MCP servers add up instead of replacing each other. [Existing-project guide](https://samplexbro.github.io/agentsmesh/guides/existing-project/) +- **Safe adoption.** `import`, then `diff`, then `generate`, then `check`; nothing is overwritten blind. When several tools have a root rule, they are merged into one `_root.md` rather than the last one winning, their permissions, ignore patterns and MCP servers add up instead of replacing each other, and `init --yes` keeps both versions of a same-name rule, command or agent. [Existing-project guide](https://samplexbro.github.io/agentsmesh/guides/existing-project/) - **Output directory safety.** `generate` and `convert` reject output directories that resolve outside the project (or home directory with `--global`), checked before any write and under `--dry-run` too. Stale-file cleanup enforces the same boundary. - **Safe updates.** Explicitly empty Claude permission, hook, and MCP configurations clear their generated entries. MCP edits preserve untouched server fields and reject malformed files; skill updates validate supporting files before writing. - **Migrate between tools.** `convert --from --to ` rewrites one tool's config directly into another's. [convert](https://samplexbro.github.io/agentsmesh/cli/convert/) diff --git a/src/cli/command-result.ts b/src/cli/command-result.ts index cc6217a9..2131f61d 100644 --- a/src/cli/command-result.ts +++ b/src/cli/command-result.ts @@ -32,6 +32,11 @@ export interface InitData { importedToolCount: number; /** Two or more detected tools' root rules were combined into one canonical root. */ rootRuleMerged: boolean; + /** + * Rules, commands or agents two tools both had, with different text. The + * earlier tool's file kept its name; the later tool's text was saved as `copy`. + */ + sameNameCopies: Array<{ path: string; copy: string; tool: string }>; scaffoldType: 'full' | 'gap-fill' | 'none'; gitignoreUpdated: boolean; /** diff --git a/src/cli/commands/init-apply.ts b/src/cli/commands/init-apply.ts index 6cabc500..4efab078 100644 --- a/src/cli/commands/init-apply.ts +++ b/src/cli/commands/init-apply.ts @@ -18,10 +18,8 @@ import type { ConfigScope, ScopeContext } from '../../config/core/scope.js'; import { scaffoldLessons } from '../../lessons/init.js'; import type { InitData } from '../command-result.js'; import type { InitTargetSource } from './init-target-resolution.js'; -import { - readRootRuleBody, - rootRuleBodyGrew, -} from '../../targets/import/root-rule-body-merge.js'; +import { importKeepingSameName } from './init-same-name.js'; +import { readRootRuleBody, rootRuleBodyGrew } from '../../targets/import/root-rule-body-merge.js'; export interface InitCommandResult { exitCode: number; @@ -72,14 +70,20 @@ async function importDetectedTools( imported: Array<{ from: string; to: string }>; importedToolCount: number; rootRuleMerged: boolean; + sameNameCopies: InitData['sameNameCopies']; }> { const imported: Array<{ from: string; to: string }> = []; + const sameNameCopies: InitData['sameNameCopies'] = []; + const owned = new Set(); let rootRuleMerged = false; for (const toolId of toolIds) { const importerFn = IMPORTERS[toolId]; if (!importerFn) continue; const rootBefore = readRootRuleBody(rootBase); - const results = await importerFn(rootBase, scope); + const { results, copies } = await importKeepingSameName(rootBase, toolId, owned, () => + importerFn(rootBase, scope), + ); + sameNameCopies.push(...copies); if (rootRuleBodyGrew(rootBefore, readRootRuleBody(rootBase))) rootRuleMerged = true; for (const r of results) { imported.push({ @@ -88,7 +92,7 @@ async function importDetectedTools( }); } } - return { imported, importedToolCount: toolIds.length, rootRuleMerged }; + return { imported, importedToolCount: toolIds.length, rootRuleMerged, sameNameCopies }; } /** Apply an InitPlan and return the structured InitData. */ @@ -102,6 +106,7 @@ export async function applyInitPlan( let imported: Array<{ from: string; to: string }> = []; let importedToolCount = 0; let rootRuleMerged = false; + let sameNameCopies: InitData['sameNameCopies'] = []; let scaffoldType: 'full' | 'gap-fill'; if (plan.doImport) { @@ -109,6 +114,7 @@ export async function applyInitPlan( imported = res.imported; importedToolCount = res.importedToolCount; rootRuleMerged = res.rootRuleMerged; + sameNameCopies = res.sameNameCopies; await writeScaffoldGapFill(context.canonicalDir); scaffoldType = 'gap-fill'; } else { @@ -138,6 +144,7 @@ export async function applyInitPlan( targets: [...plan.targets], targetSource: plan.targetSource, rootRuleMerged, + sameNameCopies, imported, importedToolCount, scaffoldType, diff --git a/src/cli/commands/init-same-name.ts b/src/cli/commands/init-same-name.ts new file mode 100644 index 00000000..4ece53e7 --- /dev/null +++ b/src/cli/commands/init-same-name.ts @@ -0,0 +1,93 @@ +/** + * Same-name rules, commands and agents across the tools `init --yes` imports. + * + * A canonical name comes from the file name, so two tools' `typescript.md` + * rules land on one file, and the later importer also mixes the earlier file's + * frontmatter into its own (#132). Inside one init run we know which tool wrote + * which file, so each later tool imports with the earlier tools' files set + * aside. Afterwards the earlier file is put back; if the later tool wrote a + * different text there, that text is kept too, as `-.md`. + * `agentsmesh import --from` stays last-import-wins. + */ + +import { rm } from 'node:fs/promises'; +import { join, posix } from 'node:path'; +import type { ImportResult } from '../../core/types.js'; +import { exists, readFileSafe, writeFileAtomic } from '../../utils/filesystem/fs.js'; +import { splitFrontmatter } from '../../utils/text/markdown.js'; + +export interface SameNameCopy { + /** The earlier tool's file, kept as it was. */ + readonly path: string; + /** Where the later tool's different text was saved. */ + readonly copy: string; + /** The later tool. */ + readonly tool: string; +} + +/** Rule, command and agent files; `_root.md` and other `_` files are not entities. */ +const ENTITY_FILE = /^\.agentsmesh\/(?:rules|commands|agents)\/(?:.+\/)?[^/_][^/]*\.md$/; + +const toPosix = (path: string): string => path.replaceAll('\\', '/'); + +function bodyOf(content: string): string { + const body = splitFrontmatter(content)?.body ?? content; + return body.replace(/\r\n?/g, '\n').trim(); +} + +async function freeName(rootBase: string, rel: string, tool: string): Promise { + const stem = posix.join(posix.dirname(rel), `${posix.basename(rel, '.md')}-${tool}`); + for (let n = 1; ; n++) { + const candidate = n === 1 ? `${stem}.md` : `${stem}-${n}.md`; + if (!(await exists(join(rootBase, candidate)))) return candidate; + } +} + +/** + * Run one tool's import so it cannot replace or mix with the entity files in + * `owned` (written by earlier tools in this run). Adds the files this import + * wrote to `owned`, and returns its results with renamed copies applied. + */ +export async function importKeepingSameName( + rootBase: string, + tool: string, + owned: Set, + run: () => Promise, +): Promise<{ results: ImportResult[]; copies: SameNameCopy[] }> { + const earlier = new Map(); + for (const rel of owned) { + const content = await readFileSafe(join(rootBase, rel)); + if (content === null) continue; + earlier.set(rel, content); + await rm(join(rootBase, rel), { force: true }); + } + + let results: ImportResult[]; + try { + results = await run(); + } catch (err) { + for (const [rel, content] of earlier) await writeFileAtomic(join(rootBase, rel), content); + throw err; + } + + const copies: SameNameCopy[] = []; + for (const [rel, content] of earlier) { + const now = await readFileSafe(join(rootBase, rel)); + if (now !== null && bodyOf(now) !== bodyOf(content)) { + const copy = await freeName(rootBase, rel, tool); + await writeFileAtomic(join(rootBase, copy), now); + copies.push({ path: rel, copy, tool }); + } + await writeFileAtomic(join(rootBase, rel), content); + } + + const renamed = new Map(copies.map((c) => [c.path, c.copy])); + const moved = results.map((r) => { + const copy = renamed.get(toPosix(r.toPath)); + return copy === undefined ? r : { ...r, toPath: copy }; + }); + for (const r of moved) { + if (ENTITY_FILE.test(toPosix(r.toPath))) owned.add(toPosix(r.toPath)); + } + return { results: moved, copies }; +} diff --git a/src/cli/commands/init-wizard.ts b/src/cli/commands/init-wizard.ts index b2ea0d23..6b1dc9a5 100644 --- a/src/cli/commands/init-wizard.ts +++ b/src/cli/commands/init-wizard.ts @@ -97,6 +97,7 @@ function cancelledResult(scope: ScopeContext['scope']): InitCommandResult { imported: [], importedToolCount: 0, rootRuleMerged: false, + sameNameCopies: [], targets: [], targetSource: 'explicit', scaffoldType: 'none', diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 9733dd6f..57bdde8c 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -78,6 +78,7 @@ export async function runInit( imported: [], importedToolCount: 0, rootRuleMerged: false, + sameNameCopies: [], targets: [], targetSource: 'explicit', scaffoldType: 'none', diff --git a/src/cli/renderers/init.ts b/src/cli/renderers/init.ts index e0e44d36..9ce8d23c 100644 --- a/src/cli/renderers/init.ts +++ b/src/cli/renderers/init.ts @@ -38,6 +38,13 @@ export function renderInit(result: InitCommandResult): void { ` More than one tool had a root rule — all of them were merged into .agentsmesh/rules/_root.md. Review it before running 'agentsmesh generate'.`, ); } + for (const { path, copy, tool } of data.sameNameCopies) { + logger.warn( + `${path}: another tool had a different text with the same name, so ${tool}'s version ` + + `was saved as ${copy}. Review both, then merge or delete one before running ` + + `'agentsmesh generate'.`, + ); + } } const targetsSuffix = diff --git a/tests/unit/cli/commands/init-same-name-helper.test.ts b/tests/unit/cli/commands/init-same-name-helper.test.ts new file mode 100644 index 00000000..ebe07ff1 --- /dev/null +++ b/tests/unit/cli/commands/init-same-name-helper.test.ts @@ -0,0 +1,144 @@ +/** + * importKeepingSameName: the per-tool step `init --yes` uses so a later tool's + * same-name rule, command or agent never replaces or mixes with an earlier + * tool's file (#132). The init flow itself is in init-same-name.test.ts. + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { importKeepingSameName } from '../../../../src/cli/commands/init-same-name.js'; +import type { ImportResult } from '../../../../src/core/types.js'; + +const RULE = '.agentsmesh/rules/x.md'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const md = (text: string): string => `---\ndescription: d\n---\n\n${text}`; +const result = (toPath: string): ImportResult => ({ + fromTool: 'b', + fromPath: join(root, 'src.md'), + toPath, + feature: 'rules', +}); + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-same-name-'))); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('importKeepingSameName', () => { + it('tracks the rules, commands and agents an import wrote, not the root rule or settings', async () => { + const owned = new Set(); + + await importKeepingSameName(root, 'a', owned, async () => [ + result(RULE), + result('.agentsmesh\\commands\\c.md'), + result('.agentsmesh/agents/g.md'), + result('.agentsmesh/rules/_root.md'), + result('.agentsmesh/permissions.yaml'), + result('.agentsmesh/skills/s/SKILL.md'), + ]); + + expect([...owned].sort()).toEqual([ + '.agentsmesh/agents/g.md', + '.agentsmesh/commands/c.md', + '.agentsmesh/rules/x.md', + ]); + }); + + it('hides earlier files from the import and restores the ones it did not write', async () => { + write(RULE, md('A')); + let seen = true; + + await importKeepingSameName(root, 'b', new Set([RULE]), async () => { + seen = existsSync(join(root, RULE)); + return []; + }); + + expect([seen, read(RULE)]).toEqual([false, md('A')]); + }); + + it('saves a different text under the next free name and keeps the earlier file', async () => { + write(RULE, md('A')); + + const { results, copies } = await importKeepingSameName( + root, + 'b', + new Set([RULE]), + async () => { + write(RULE, md('B')); + write('.agentsmesh/rules/x-b.md', md('OWN')); + return [result(RULE), result('.agentsmesh/rules/x-b.md')]; + }, + ); + + expect([ + read(RULE), + read('.agentsmesh/rules/x-b-2.md'), + read('.agentsmesh/rules/x-b.md'), + ]).toEqual([md('A'), md('B'), md('OWN')]); + expect(results.map((r) => r.toPath)).toEqual([ + '.agentsmesh/rules/x-b-2.md', + '.agentsmesh/rules/x-b.md', + ]); + expect(copies).toEqual([{ path: RULE, copy: '.agentsmesh/rules/x-b-2.md', tool: 'b' }]); + }); + + it('keeps nested command folders in the copy name', async () => { + const cmd = '.agentsmesh/commands/sub/deploy.md'; + write(cmd, md('A')); + + const { copies } = await importKeepingSameName(root, 'b', new Set([cmd]), async () => { + write(cmd, md('B')); + return [result(cmd)]; + }); + + expect(copies).toEqual([ + { path: cmd, copy: '.agentsmesh/commands/sub/deploy-b.md', tool: 'b' }, + ]); + }); + + it('keeps the earlier file when the text is the same, with or without frontmatter', async () => { + write(RULE, 'SAME\n'); + + const { copies } = await importKeepingSameName(root, 'b', new Set([RULE]), async () => { + write(RULE, '---\ndescription: other\n---\n\nSAME'); + return [result(RULE)]; + }); + + expect([read(RULE), copies]).toEqual(['SAME\n', []]); + }); + + it('restores the earlier files when the import fails', async () => { + write(RULE, md('A')); + + const failing = importKeepingSameName(root, 'b', new Set([RULE]), async () => { + write(RULE, md('HALF')); + throw new Error('boom'); + }); + + await expect(failing).rejects.toThrow('boom'); + expect(read(RULE)).toBe(md('A')); + }); + + it('skips an earlier file that is already gone', async () => { + await importKeepingSameName(root, 'b', new Set([RULE]), async () => []); + + expect(existsSync(join(root, RULE))).toBe(false); + }); +}); diff --git a/tests/unit/cli/commands/init-same-name.test.ts b/tests/unit/cli/commands/init-same-name.test.ts new file mode 100644 index 00000000..6379a5fc --- /dev/null +++ b/tests/unit/cli/commands/init-same-name.test.ts @@ -0,0 +1,120 @@ +/** + * `init --yes` imports every detected tool in one run. When two tools have a + * rule, command or agent with the same name and different text, both are kept: + * the later tool's copy gets a `-` name, and nothing is mixed (#132). + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; +import { runInit } from '../../../../src/cli/commands/init.js'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); + +const CLAUDE_TS = '---\nroot: false\ndescription: ts\nglobs: []\n---\n\n# TS\nCLAUDE_TS_TEXT'; +const CURSOR_TS = + '---\nroot: false\ndescription: ts\nglobs:\n - src/**/*.ts\ntrigger: model_decision\n---\n\n' + + '# TS\nCURSOR_TS_TEXT'; + +function sameNameRules(): void { + write('CLAUDE.md', '# Root\n'); + write('.claude/rules/typescript.md', '---\ndescription: ts\n---\n# TS\nCLAUDE_TS_TEXT\n'); + write( + '.cursor/rules/typescript.mdc', + '---\ndescription: ts\nglobs: src/**/*.ts\nalwaysApply: false\n---\n# TS\nCURSOR_TS_TEXT\n', + ); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-init-same-'))); + // init reads HOME to pick targets; keep this machine's tools out of it. + const home = join(root, 'home'); + mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); +}); +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('init --yes with same-name entries in two tools', () => { + it('keeps both rules, each with only its own frontmatter', async () => { + sameNameRules(); + write('.claude/rules/claude-only.md', '---\ndescription: c\n---\nCLAUDE_ONLY\n'); + + const { data } = await runInit(root, { yes: true }); + + expect(data.detectedConfigs).toEqual(['claude-code', 'cursor']); + expect(read('.agentsmesh/rules/typescript.md')).toBe(CLAUDE_TS); + expect(read('.agentsmesh/rules/typescript-cursor.md')).toBe(CURSOR_TS); + expect(read('.agentsmesh/rules/claude-only.md')).toContain('CLAUDE_ONLY'); + expect(data.sameNameCopies).toEqual([ + { + path: '.agentsmesh/rules/typescript.md', + copy: '.agentsmesh/rules/typescript-cursor.md', + tool: 'cursor', + }, + ]); + expect(data.imported).toContainEqual({ + from: '.cursor/rules/typescript.mdc', + to: '.agentsmesh/rules/typescript-cursor.md', + }); + }); + + it('generate then writes both texts to both tools', async () => { + sameNameRules(); + await runInit(root, { yes: true }); + + await runGenerate({}, root, { printMatrix: false }); + + expect(read('.claude/rules/typescript.md')).toContain('CLAUDE_TS_TEXT'); + expect(read('.claude/rules/typescript-cursor.md')).toContain('CURSOR_TS_TEXT'); + expect(read('.cursor/rules/typescript.mdc')).toContain('CLAUDE_TS_TEXT'); + expect(read('.cursor/rules/typescript-cursor.mdc')).toContain('CURSOR_TS_TEXT'); + }); + + it('keeps both commands without mixing their descriptions', async () => { + write('CLAUDE.md', '# Root\n'); + write('.claude/commands/deploy.md', '---\ndescription: Deploy\n---\nCLAUDE_DEPLOY\n'); + write('.cursor/commands/deploy.md', 'CURSOR_DEPLOY\n'); + write('.cursor/rules/style.mdc', '---\ndescription: Style\n---\n# Style\n'); + + const { data } = await runInit(root, { yes: true }); + + expect(read('.agentsmesh/commands/deploy.md')).toBe( + '---\ndescription: Deploy\nallowed-tools: []\n---\n\nCLAUDE_DEPLOY', + ); + expect(read('.agentsmesh/commands/deploy-cursor.md')).toBe( + '---\ndescription: ""\nallowed-tools: []\n---\n\nCURSOR_DEPLOY', + ); + expect(data.sameNameCopies).toEqual([ + { + path: '.agentsmesh/commands/deploy.md', + copy: '.agentsmesh/commands/deploy-cursor.md', + tool: 'cursor', + }, + ]); + }); + + it('keeps one file when both tools have the same text', async () => { + write('CLAUDE.md', '# Root\n'); + write('.claude/rules/typescript.md', '---\ndescription: ts\n---\n# TS\nSAME_TEXT\n'); + write('.cursor/rules/typescript.mdc', '---\ndescription: ts\n---\n# TS\nSAME_TEXT\n'); + + const { data } = await runInit(root, { yes: true }); + + expect(read('.agentsmesh/rules/typescript.md')).toBe( + '---\nroot: false\ndescription: ts\nglobs: []\n---\n\n# TS\nSAME_TEXT', + ); + expect(data.sameNameCopies).toEqual([]); + }); +}); diff --git a/tests/unit/cli/renderers/init-same-name.test.ts b/tests/unit/cli/renderers/init-same-name.test.ts new file mode 100644 index 00000000..7feec969 --- /dev/null +++ b/tests/unit/cli/renderers/init-same-name.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { renderInit } from '../../../../src/cli/renderers/init.js'; +import type { InitCommandResult } from '../../../../src/cli/commands/init.js'; +import { useCapturedOutput } from './renderer-test-helpers.js'; + +function initWith(sameNameCopies: InitCommandResult['data']['sameNameCopies']): InitCommandResult { + return { + exitCode: 0, + data: { + scope: 'project', + configFile: 'agentsmesh.yaml', + localConfigFile: 'agentsmesh.local.yaml', + detectedConfigs: ['claude-code', 'cursor'], + imported: [{ from: '.cursor/rules/ts.mdc', to: '.agentsmesh/rules/ts-cursor.md' }], + importedToolCount: 2, + rootRuleMerged: false, + sameNameCopies, + targets: ['claude-code', 'cursor'], + targetSource: 'project', + scaffoldType: 'gap-fill', + gitignoreUpdated: false, + }, + }; +} + +describe('renderInit same-name copies', () => { + const output = useCapturedOutput(); + + it('names each kept copy and asks for a review', () => { + renderInit( + initWith([ + { path: '.agentsmesh/rules/ts.md', copy: '.agentsmesh/rules/ts-cursor.md', tool: 'cursor' }, + ]), + ); + + expect(output.stderr()).toBe( + '⚠ .agentsmesh/rules/ts.md: another tool had a different text with the same name, so ' + + "cursor's version was saved as .agentsmesh/rules/ts-cursor.md. Review both, then merge " + + "or delete one before running 'agentsmesh generate'.\n", + ); + }); + + it('prints nothing extra when there are no copies', () => { + renderInit(initWith([])); + + expect(output.stderr()).toBe(''); + }); +}); diff --git a/tests/unit/cli/renderers/init.test.ts b/tests/unit/cli/renderers/init.test.ts index dfa8cc6d..3ca6ecf0 100644 --- a/tests/unit/cli/renderers/init.test.ts +++ b/tests/unit/cli/renderers/init.test.ts @@ -38,6 +38,7 @@ describe('renderInit', () => { detectedConfigs: ['claude-code', 'cursor'], imported: [{ from: '.claude/CLAUDE.md', to: '.agentsmesh/rules/_root.md' }], importedToolCount: 2, + sameNameCopies: [], targets: [], targetSource: 'explicit', scaffoldType: 'gap-fill', @@ -65,6 +66,7 @@ describe('renderInit', () => { detectedConfigs: [], imported: [{ from: '.cursor/rules/a.mdc', to: '.agentsmesh/rules/a.md' }], importedToolCount: 1, + sameNameCopies: [], targets: [], targetSource: 'explicit', scaffoldType: 'none', diff --git a/tests/unit/cli/renderers/renderer-test-helpers.ts b/tests/unit/cli/renderers/renderer-test-helpers.ts index 511a4196..6eb8096a 100644 --- a/tests/unit/cli/renderers/renderer-test-helpers.ts +++ b/tests/unit/cli/renderers/renderer-test-helpers.ts @@ -51,6 +51,7 @@ export function lessonsInit(lessons: Partial = {}): InitC imported: [], importedToolCount: 0, rootRuleMerged: false, + sameNameCopies: [], targets: [], targetSource: 'explicit', scaffoldType: 'none', diff --git a/website/src/content/docs/cli/import.mdx b/website/src/content/docs/cli/import.mdx index d3681173..981f129e 100644 --- a/website/src/content/docs/cli/import.mdx +++ b/website/src/content/docs/cli/import.mdx @@ -133,6 +133,9 @@ This keeps cross-references valid after import. Most canonical paths are last-import-wins: if two tools each define a command named `ship`, the second import replaces the first, because both describe the same entity. +`agentsmesh init --yes` is different: it imports all tools in one run, so it keeps both +texts and saves the later tool's version as `ship-.md`. See +[Auto-detection](../init/#auto-detection). Permissions, ignore patterns and MCP servers are different: each is a list of separate entries, not one entity. A second import **adds** its entries to what is already in diff --git a/website/src/content/docs/cli/init.mdx b/website/src/content/docs/cli/init.mdx index 692a5544..573caebd 100644 --- a/website/src/content/docs/cli/init.mdx +++ b/website/src/content/docs/cli/init.mdx @@ -117,6 +117,8 @@ Only two init steps are project-only: the `.gitignore` update and the lessons sc During init, AgentsMesh scans the project for existing AI tool configs from every [supported target](../../reference/supported-tools/) — `.claude/`, `.cursor/`, `.github/copilot-instructions.md`, and the rest. The wizard offers to import everything it finds (a single yes/no); `--yes` imports all of them non-interactively. In a script or CI shell without `--yes`, init refuses instead of enabling a tool whose config it did not import, so nothing is overwritten by accident. +When two tools have a rule, command or agent with the same name, the import keeps both texts. If the texts are the same, one canonical file is kept. If they differ, the first tool's file keeps its name, and the later tool's version is saved as `-.md` (for example `.agentsmesh/rules/typescript-cursor.md`). Each file keeps only its own frontmatter, and init prints a warning for each copy. Review both, then merge or delete one before you run `agentsmesh generate`. + ## Examples ### Interactive mode diff --git a/website/src/content/docs/guides/existing-project.mdx b/website/src/content/docs/guides/existing-project.mdx index 1d0e92eb..70fd0464 100644 --- a/website/src/content/docs/guides/existing-project.mdx +++ b/website/src/content/docs/guides/existing-project.mdx @@ -53,7 +53,10 @@ If your project already has AI tool configs scattered across `.claude/`, `.curso and the CLI tells you the root rule was merged. Nothing is dropped, but nothing is reconciled either: read the merged file and delete anything the tools said twice. Permissions, ignore patterns and MCP servers add up the same way, entry by entry. - Every other canonical path (commands, skills, hooks) is last-import-wins. + When two tools have a rule, command or agent with the same name and different text, + `init --yes` keeps both and saves the later one as `-.md`; review them. + With separate `agentsmesh import` runs, commands, skills, hooks and same-name rules + are last-import-wins. 3. **Configure agentsmesh.yaml** From c7f63cfdd0056bb8762515b819533f53ce2daf4b Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:38:20 +0200 Subject: [PATCH 17/38] chore(lessons): capture the init same-name rule Supersede the import merge lessons with one that also names importKeepingSameName, and record two bits of friction: a str.replace whose shorter-indent pattern matched twice, and renderer test fixtures that omit required InitData fields. --- .agentsmesh/lessons/lessons.json | 83 +++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 14445e00..1db0b5ea 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -1708,6 +1708,21 @@ "t-kw-09d05099" ] }, + "fixture-and-assertion-discipline-tests-unit-cli-renderers-init": { + "createdAt": "2026-09-24", + "evidence": [ + "982f61df" + ], + "rule": "tests/unit/cli/renderers/init.test.ts (and other renderer tests listed in tsconfig.tests.json exclude) build InitData fixtures that omit required fields. When you add a required array field that a renderer iterates, add it to every fixture that reaches that branch and to renderer-test-helpers.ts, or keep the loop inside a branch those fixtures never reach; tsc will not catch the gap and the render throws 'is not iterable'.", + "status": "active", + "topics": [ + "fixture-and-assertion-discipline" + ], + "triggers": [ + "t-glob-a9c88b6d", + "t-glob-dbe11674" + ] + }, "fixture-and-assertion-discipline-to-cover-the-1-arm": { "createdAt": "2026-06-07", "evidence": [ @@ -3285,7 +3300,7 @@ ], "rule": "Canonical paths are last-import-wins EXCEPT .agentsmesh/rules/_root.md and mcp.json, which accumulate. Root rule merging lives in the single shared serializer serializeImportedRuleWithFallback (src/targets/import/import-metadata-core.ts) because all ~45 root write sites across 33 targets funnel through it — never patch per-target importers for root-rule behavior. When changing it, remember the init placeholder body (src/canonical/root-rule-placeholder.ts) must count as unwritten or every import stacks under scaffold boilerplate.", "status": "superseded", - "supersededBy": "install-import-pickers-canonical-permissions-yaml-ignore-and", + "supersededBy": "install-import-pickers-import-merge-rules-permissions-yaml", "topics": [ "install-import-pickers" ], @@ -3297,7 +3312,8 @@ "e61d9575" ], "rule": "Canonical permissions.yaml, ignore and mcp.json accumulate across imports through runTargetImport (src/targets/import/run-target-import.ts), which snapshots them before importFrom and adds back dropped entries; _root.md accumulates through serializeImportedRuleWithFallback; every other canonical path is last-import-wins. Route any new import entry point through runTargetImport instead of calling descriptor.generators.importFrom directly, and never patch per-target importers for merge behaviour: four importers already bypassed writeMcpWithMerge (#131).", - "status": "active", + "status": "superseded", + "supersededBy": "install-import-pickers-import-merge-rules-permissions-yaml", "topics": [ "install-import-pickers" ], @@ -3321,6 +3337,24 @@ "t-glob-72016213" ] }, + "install-import-pickers-import-merge-rules-permissions-yaml": { + "createdAt": "2026-09-24", + "evidence": [ + "982f61df" + ], + "rule": "Import merge rules: permissions.yaml, ignore and mcp.json accumulate across imports through runTargetImport (src/targets/import/run-target-import.ts); _root.md accumulates through serializeImportedRuleWithFallback; in one init --yes run, same-name rules, commands and agents from two tools are both kept by importKeepingSameName (src/cli/commands/init-same-name.ts), the later one as -.md; separate import --from runs stay last-import-wins for them. Route new import entry points through runTargetImport (and multi-tool runs through importKeepingSameName), never call descriptor.generators.importFrom directly, and never patch per-target importers for merge behaviour.", + "status": "active", + "topics": [ + "install-import-pickers" + ], + "triggers": [ + "t-glob-36b511b8", + "t-glob-90ecaacd", + "t-glob-6b98951d", + "t-glob-24b8cd6b", + "t-glob-84b6c902" + ] + }, "install-import-pickers-install-sync-refresh-re-run": { "createdAt": "2026-06-13", "evidence": [ @@ -7690,6 +7724,35 @@ "t-cmd-69dc7d4e" ] }, + "shell-quoting-when-a-scripted-str-replace": { + "createdAt": "2026-09-24", + "evidence": [ + "982f61df" + ], + "rule": "When a scripted str.replace inserts a line after a key in several files, never use a shorter-indent pattern that is a suffix of a deeper-indent line: ' rootRuleMerged: false,' also matches inside ' rootRuleMerged: false,', so the insert lands twice (TS1117 duplicate property). Anchor the pattern on the preceding newline ('\\\\n key') or assert the exact match count per file before writing.", + "status": "superseded", + "supersededBy": "shell-quoting-when-a-scripted-str-replace-2", + "topics": [ + "shell-quoting" + ], + "triggers": [ + "t-cmd-d441ad48" + ] + }, + "shell-quoting-when-a-scripted-str-replace-2": { + "createdAt": "2026-09-24", + "evidence": [ + "982f61df" + ], + "rule": "When a scripted str.replace inserts a line after a key in several files, never use a shorter-indent pattern that is a suffix of a deeper-indent line: ' rootRuleMerged: false,' also matches inside ' rootRuleMerged: false,', so the insert lands twice (TS1117 duplicate property). Start the pattern with the newline before the line (a real newline, written \\n in the Python string) or assert the exact match count per file before writing.", + "status": "active", + "topics": [ + "shell-quoting" + ], + "triggers": [ + "t-cmd-d441ad48" + ] + }, "skill-path-resolution-resolve-skill-paths-from-the": { "createdAt": "2026-06-08", "evidence": [ @@ -11437,6 +11500,10 @@ "kind": "command_pattern", "pattern": "sed|cat" }, + "t-cmd-d441ad48": { + "kind": "command_pattern", + "pattern": "python3 -" + }, "t-cmd-d75c3b91": { "kind": "command_pattern", "pattern": "pnpm (test|lint|typecheck)[^|]*\\|\\s*(tail|head|grep)" @@ -11785,6 +11852,10 @@ "kind": "file_glob", "pattern": "src/lessons/auto-prune.ts" }, + "t-glob-24b8cd6b": { + "kind": "file_glob", + "pattern": "src/cli/commands/init-same-name.ts" + }, "t-glob-2511e880": { "kind": "file_glob", "pattern": ".claude-plugin/**" @@ -12653,6 +12724,10 @@ "kind": "file_glob", "pattern": "src/targets/*/layout.ts" }, + "t-glob-a9c88b6d": { + "kind": "file_glob", + "pattern": "src/cli/renderers/**" + }, "t-glob-a9eb0502": { "kind": "file_glob", "pattern": "src/utils/filesystem/*lock*.ts" @@ -12937,6 +13012,10 @@ "kind": "file_glob", "pattern": "src/lessons/paths.ts" }, + "t-glob-dbe11674": { + "kind": "file_glob", + "pattern": "src/cli/command-result.ts" + }, "t-glob-dc08e1d8": { "kind": "file_glob", "pattern": "tests/fixtures/lessons/recurrence/**" From b4ba0fa9b6e720cfb6cb613c16eb1cb6add174d2 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:50:56 +0200 Subject: [PATCH 18/38] fix(mcp): keep install and uninstall notices off stdout The install and uninstall prompt adapters wrote the broken-link and locally-modified notices with process.stdout.write, so under agentsmesh mcp they landed in the JSON-RPC stream, and under --json in the JSON output. They now use the new logger writeOutput, which follows the MCP stderr redirect and the --json mute. Fixes #133 --- .changeset/mcp-install-stdout.md | 5 + src/install/run/run-install-prompts.ts | 4 +- src/install/uninstall/uninstall-io.ts | 3 +- src/utils/output/logger.ts | 8 ++ .../mcp-install-stdout.integration.test.ts | 125 ++++++++++++++++++ tests/unit/utils/logger.test.ts | 10 +- 6 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 .changeset/mcp-install-stdout.md create mode 100644 tests/integration/mcp-install-stdout.integration.test.ts diff --git a/.changeset/mcp-install-stdout.md b/.changeset/mcp-install-stdout.md new file mode 100644 index 00000000..65f29b3f --- /dev/null +++ b/.changeset/mcp-install-stdout.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +The MCP `install` and `uninstall` tools no longer write plain-text notices into the JSON-RPC stream. When an installed skill had a broken link, or an uninstalled pack had locally changed files, `agentsmesh mcp` printed those notices on stdout, which stricter MCP clients cannot parse. They now go to stderr like every other message from the server. With `--json`, the CLI no longer prints them into the JSON output either. diff --git a/src/install/run/run-install-prompts.ts b/src/install/run/run-install-prompts.ts index 54974748..1b4eeb34 100644 --- a/src/install/run/run-install-prompts.ts +++ b/src/install/run/run-install-prompts.ts @@ -22,7 +22,7 @@ import { readLine } from '../prompts/prompt-io.js'; import { runBrokenLinkPrompt, type BrokenLinkDecision } from '../prompts/broken-link-prompt.js'; import { runBulkPrompt, type BulkCandidates, type BulkSelection } from '../prompts/bulk-prompt.js'; import type { PromptAdapter } from '../prompts/prompt-types.js'; -import { logger } from '../../utils/output/logger.js'; +import { logger, writeOutput } from '../../utils/output/logger.js'; import { applyBrokenLinkDecisions } from '../../sources/anthropic-skill-pack/apply-decisions.js'; import type { AggregateResult } from '../../sources/anthropic-skill-pack/aggregate.js'; import { ruleSlug } from '../core/validate-resources.js'; @@ -47,7 +47,7 @@ export interface SkillPackPromptFlowResult { function defaultAdapter(): PromptAdapter { return { ask: (prompt: string) => readLine(prompt), - write: (chunk: string) => process.stdout.write(chunk), + write: writeOutput, }; } diff --git a/src/install/uninstall/uninstall-io.ts b/src/install/uninstall/uninstall-io.ts index ca6f3653..01fb2a55 100644 --- a/src/install/uninstall/uninstall-io.ts +++ b/src/install/uninstall/uninstall-io.ts @@ -1,5 +1,6 @@ import { readLine } from '../prompts/prompt-io.js'; import type { PromptAdapter } from '../prompts/prompt-types.js'; +import { writeOutput } from '../../utils/output/logger.js'; /** Uninstall names from `a,b c` style args; split from run-uninstall.ts for the 200-line limit. */ export function parseUninstallNames(args: readonly string[]): string[] { @@ -21,6 +22,6 @@ export function parseUninstallNames(args: readonly string[]): string[] { export function defaultUninstallAdapter(): PromptAdapter { return { ask: (prompt: string) => readLine(prompt), - write: (chunk: string) => process.stdout.write(chunk), + write: writeOutput, }; } diff --git a/src/utils/output/logger.ts b/src/utils/output/logger.ts index 06f510bb..1825ef24 100644 --- a/src/utils/output/logger.ts +++ b/src/utils/output/logger.ts @@ -33,6 +33,14 @@ function out(text: string): void { outStream().write(text); } +/** + * Raw text on the logger's stream: stderr under `agentsmesh mcp` (stdout is the + * JSON-RPC channel), nothing under `--json`. Use it instead of process.stdout. + */ +export function writeOutput(text: string): void { + if (!muted) out(text); +} + function c(code: string, text: string, stream: NodeJS.WriteStream): string { return colorEnabled(stream) ? `${code}${text}${C.reset}` : text; } diff --git a/tests/integration/mcp-install-stdout.integration.test.ts b/tests/integration/mcp-install-stdout.integration.test.ts new file mode 100644 index 00000000..284bc378 --- /dev/null +++ b/tests/integration/mcp-install-stdout.integration.test.ts @@ -0,0 +1,125 @@ +/** + * The MCP `install` and `uninstall` tools must keep stdout for JSON-RPC only: + * the broken-link and locally-modified notices go to stderr (#133). + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, + appendFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const CLI_PATH = join(process.cwd(), 'dist', 'cli.js'); + +let base: string; + +const write = (path: string, text: string): void => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +}; + +interface Session { + call: (id: number, name: string, args: Record) => Promise; + close: () => Promise<{ stdout: string; stderr: string }>; +} + +function startServer(cwd: string): Session { + const child: ChildProcessWithoutNullStreams = spawn('node', [CLI_PATH, 'mcp'], { cwd }); + let stdout = ''; + let stderr = ''; + const waiting = new Map void>(); + child.stdout.on('data', (d: Buffer) => { + stdout += d.toString(); + for (const line of d.toString().split('\n').filter(Boolean)) { + try { + const msg = JSON.parse(line) as { id?: number }; + if (msg.id !== undefined) waiting.get(msg.id)?.(msg); + } catch { + // A non-JSON line is what this test catches; it is asserted below. + } + } + }); + child.stderr.on('data', (d: Buffer) => (stderr += d.toString())); + const send = (msg: object): void => void child.stdin.write(`${JSON.stringify(msg)}\n`); + const request = (id: number, method: string, params: object): Promise => + new Promise((resolve) => { + waiting.set(id, resolve); + send({ jsonrpc: '2.0', id, method, params }); + }); + const ready = request(0, 'initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }).then(() => send({ jsonrpc: '2.0', method: 'notifications/initialized' })); + return { + call: async (id, name, args) => { + await ready; + return request(id, 'tools/call', { name, arguments: args }); + }, + close: () => + new Promise((resolve) => { + child.on('close', () => resolve({ stdout, stderr })); + child.stdin.end(); + child.kill('SIGTERM'); + }), + }; +} + +beforeEach(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), 'am-mcp-install-out-'))); + write( + join(base, 'proj', 'agentsmesh.yaml'), + 'version: 1\ntargets: [claude-code]\nfeatures: [rules, skills]\n', + ); + write(join(base, 'proj', '.agentsmesh', 'rules', '_root.md'), '---\nroot: true\n---\n# Root\n'); + write( + join(base, 'src', 'skills', 'ap', 'SKILL.md'), + '---\ndescription: ap\n---\n# AP\nSee [m](../../nope.md).\n', + ); +}); +afterEach(() => rmSync(base, { recursive: true, force: true })); + +describe('MCP install and uninstall stdout', () => { + it('writes only JSON-RPC to stdout and the notices to stderr', async () => { + const server = startServer(join(base, 'proj')); + const source = join(base, 'src'); + + const replies = [ + await server.call(1, 'install', { source, name: 'appack', dry_run: true }), + await server.call(2, 'install', { source, name: 'appack' }), + ]; + appendFileSync( + join(base, 'proj', '.agentsmesh', 'packs', 'appack', 'skills', 'ap', 'SKILL.md'), + 'EDIT\n', + ); + replies.push(await server.call(3, 'uninstall', { names: ['appack'] })); + const { stdout, stderr } = await server.close(); + + const notJson = stdout + .split('\n') + .filter(Boolean) + .filter((line) => { + try { + JSON.parse(line); + return false; + } catch { + return true; + } + }); + expect(notJson).toEqual([]); + const failed = (r: unknown): boolean => { + const reply = r as { error?: unknown; result?: { isError?: boolean } }; + return reply.error !== undefined || reply.result?.isError === true; + }; + expect(replies.map(failed)).toEqual([false, false, false]); + expect(stderr).toContain('Entity "ap" (skill) has 1 broken link:'); + expect(stderr).toContain('Pack "appack" has 1 locally modified file:'); + }, 60_000); +}); diff --git a/tests/unit/utils/logger.test.ts b/tests/unit/utils/logger.test.ts index 22c59bfb..d7ca4580 100644 --- a/tests/unit/utils/logger.test.ts +++ b/tests/unit/utils/logger.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { logger, muteLogger, unmuteLogger } from '../../../src/utils/output/logger.js'; +import { logger, muteLogger, unmuteLogger, writeOutput } from '../../../src/utils/output/logger.js'; describe('logger', () => { beforeEach(() => { @@ -139,6 +139,14 @@ describe('logger', () => { unmuteLogger(); }); + it('writeOutput writes raw text to the output stream, and nothing when muted', () => { + writeOutput('raw\n'); + muteLogger(); + writeOutput('hidden\n'); + unmuteLogger(); + expect(vi.mocked(process.stdout.write).mock.calls.map(([chunk]) => chunk)).toEqual(['raw\n']); + }); + it('unmuteLogger restores output', () => { muteLogger(); unmuteLogger(); From f7a5e11773e6c2f4f53512bc6008563f02eee8bf Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:53:28 +0200 Subject: [PATCH 19/38] fix(mcp): keep a body that starts with --- as body text serializeMd returned the body as-is when frontmatter was empty, so a body starting with a --- block became the file's frontmatter: get_* returned different metadata and body text could set root: true, allowed-tools or name for generate. It now writes an empty --- block before such a body, and parseMd uses the shared splitFrontmatter so MCP reads and the canonical loaders agree on where frontmatter ends. Fixes #135 --- .changeset/mcp-create-body-frontmatter.md | 5 ++ src/mcp/writers/md-frontmatter.ts | 24 ++++--- .../handlers/create-body-frontmatter.test.ts | 66 +++++++++++++++++++ tests/unit/mcp/writers/md-frontmatter.test.ts | 14 ++++ 4 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 .changeset/mcp-create-body-frontmatter.md create mode 100644 tests/unit/mcp/handlers/create-body-frontmatter.test.ts diff --git a/.changeset/mcp-create-body-frontmatter.md b/.changeset/mcp-create-body-frontmatter.md new file mode 100644 index 00000000..76cbf5fb --- /dev/null +++ b/.changeset/mcp-create-body-frontmatter.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +The MCP `create_*` and `update_*` tools for rules, commands and skills now store the body exactly as sent. Before, with empty `frontmatter`, a body that started with a `---` block was saved so that the block became the file's frontmatter. `get_*` then returned different metadata, and body text could set fields such as `root: true`, `allowed-tools` or `name` for `generate`. Now an empty frontmatter block is written before such a body, and the MCP server reads files with the same frontmatter rules as the canonical loader. diff --git a/src/mcp/writers/md-frontmatter.ts b/src/mcp/writers/md-frontmatter.ts index 5c05414a..c597fbb0 100644 --- a/src/mcp/writers/md-frontmatter.ts +++ b/src/mcp/writers/md-frontmatter.ts @@ -1,21 +1,25 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { splitFrontmatter } from '../../utils/text/markdown.js'; -const FRONT_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n\r?\n?([\s\S]*)$/; - +/** + * Split a canonical markdown file with the same delimiter rules the canonical + * loaders use, so MCP reads and `generate` agree on where frontmatter ends. + */ export function parseMd(src: string): { frontmatter: Record; body: string } { - const m = FRONT_RE.exec(src); - if (!m) return { frontmatter: {}, body: src }; - // Regex groups 1 and 2 are non-optional captures, so on match they're - // always strings. `parseYaml('')` returns undefined → fall back to {}. - const [, fmRaw, body] = m as RegExpExecArray & [string, string, string]; + const split = splitFrontmatter(src); + if (split === null) return { frontmatter: {}, body: src }; return { - frontmatter: (parseYaml(fmRaw) ?? {}) as Record, - body, + frontmatter: (parseYaml(split.yaml) ?? {}) as Record, + // Drop the closer's newline and the one blank line serializeMd adds. + body: src.slice(split.prefix.length).replace(/^\r?\n(?:\r?\n)?/, ''), }; } export function serializeMd(frontmatter: Record, body: string): string { - if (Object.keys(frontmatter).length === 0) return body; + if (Object.keys(frontmatter).length === 0) { + // An empty block keeps a body that starts with `---` as body text (#135). + return splitFrontmatter(body) === null ? body : `---\n---\n\n${body}`; + } const yaml = stringifyYaml(frontmatter).trimEnd(); return `---\n${yaml}\n---\n\n${body}`; } diff --git a/tests/unit/mcp/handlers/create-body-frontmatter.test.ts b/tests/unit/mcp/handlers/create-body-frontmatter.test.ts new file mode 100644 index 00000000..a33a2a10 --- /dev/null +++ b/tests/unit/mcp/handlers/create-body-frontmatter.test.ts @@ -0,0 +1,66 @@ +/** + * MCP `create_*` with empty frontmatter must store the body as body text: a + * body that starts with a `---` block must not become the frontmatter, for + * `get_*` or for the canonical loader that generate uses (#135). + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseCommands } from '../../../../src/canonical/features/commands.js'; +import { parseRules } from '../../../../src/canonical/features/rules.js'; +import { parseSkills } from '../../../../src/canonical/features/skills.js'; +import { commandsHandlers } from '../../../../src/mcp/handlers/commands.js'; +import { rulesHandlers } from '../../../../src/mcp/handlers/rules.js'; +import { skillsHandlers } from '../../../../src/mcp/handlers/skills.js'; +import { resolveContext, type McpContext } from '../../../../src/mcp/context.js'; + +const INJECTED = + '---\nroot: true\nallowed-tools: [Bash]\nname: other\ndescription: hijack\n---\nBODY'; + +let projectRoot: string; +let ctx: McpContext; +const canonical = (dir: string): string => join(projectRoot, '.agentsmesh', dir); + +beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), 'am-mcp-inject-')); + await mkdir(canonical('rules'), { recursive: true }); + await writeFile(join(projectRoot, 'agentsmesh.yaml'), 'version: 1\ntargets: []\nfeatures: []\n'); + ctx = await resolveContext({ cwd: projectRoot }); +}); +afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); +}); + +describe('create_* with empty frontmatter and a body that starts with ---', () => { + it('create_rule keeps the block in the body, for get_rule and for generate', async () => { + await rulesHandlers.create(ctx, { name: 'inj', frontmatter: {}, body: INJECTED }); + + const got = await rulesHandlers.get(ctx, { name: 'inj' }); + const [rule] = await parseRules(canonical('rules')); + + expect([got.frontmatter, got.body]).toEqual([{}, INJECTED]); + expect([rule?.root, rule?.description, rule?.body]).toEqual([false, '', INJECTED]); + }); + + it('create_command keeps the block in the body', async () => { + await commandsHandlers.create(ctx, { name: 'inj', frontmatter: {}, body: INJECTED }); + + const got = await commandsHandlers.get(ctx, { name: 'inj' }); + const [command] = await parseCommands(canonical('commands')); + + expect([got.frontmatter, got.body]).toEqual([{}, INJECTED]); + expect([command?.allowedTools, command?.body]).toEqual([[], INJECTED]); + }); + + it('create_skill keeps the block in the body', async () => { + await skillsHandlers.create(ctx, { name: 'inj', frontmatter: {}, body: INJECTED }); + + const got = await skillsHandlers.get(ctx, { name: 'inj' }); + const [skill] = await parseSkills(canonical('skills')); + + expect([got.frontmatter, got.body]).toEqual([{}, INJECTED]); + expect([skill?.name, skill?.body]).toEqual(['inj', INJECTED]); + }); +}); diff --git a/tests/unit/mcp/writers/md-frontmatter.test.ts b/tests/unit/mcp/writers/md-frontmatter.test.ts index 623d6cd3..0b3c999d 100644 --- a/tests/unit/mcp/writers/md-frontmatter.test.ts +++ b/tests/unit/mcp/writers/md-frontmatter.test.ts @@ -12,4 +12,18 @@ describe('md-frontmatter', () => { it('handles missing frontmatter', () => { expect(parseMd('plain body\n')).toEqual({ frontmatter: {}, body: 'plain body\n' }); }); + it('writes an empty block before a body that starts with ---, and reads it back', () => { + const body = '---\nroot: true\n---\nBODY\n'; + const src = serializeMd({}, body); + + expect(src).toBe(`---\n---\n\n${body}`); + expect(parseMd(src)).toEqual({ frontmatter: {}, body }); + }); + it('reads an empty block and a closer at the end of the file', () => { + expect(parseMd('---\n---\n\nbody\n')).toEqual({ frontmatter: {}, body: 'body\n' }); + expect(parseMd('---\ndescription: x\n---')).toEqual({ + frontmatter: { description: 'x' }, + body: '', + }); + }); }); From e66d8df9d2cbef3fb0d8fcffd80871b4f090e145 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 15:55:39 +0200 Subject: [PATCH 20/38] fix(mcp): run tool calls one at a time so parallel writes keep every change JSON-RPC clients may send several tools/call requests without waiting. Each write tool reads a file, changes it and writes it back, so parallel add_mcp_server, update_permissions/update_ignore (append) and update_config calls kept only the last writer's change while every call reported success. The server now runs tool calls through one queue, in arrival order; a failed call does not block the next one. The stdio test client moved to tests/helpers/mcp-stdio.ts so the #133 and #134 integration tests share it. Fixes #134 --- .changeset/mcp-calls-in-order.md | 5 ++ src/mcp/call-queue.ts | 14 ++++ src/mcp/server.ts | 10 ++- tests/helpers/mcp-stdio.ts | 69 +++++++++++++++++++ .../mcp-install-stdout.integration.test.ts | 55 +-------------- .../mcp-parallel-writes.integration.test.ts | 54 +++++++++++++++ tests/unit/mcp/call-queue.test.ts | 34 +++++++++ .../src/content/docs/reference/mcp-server.mdx | 2 + 8 files changed, 189 insertions(+), 54 deletions(-) create mode 100644 .changeset/mcp-calls-in-order.md create mode 100644 src/mcp/call-queue.ts create mode 100644 tests/helpers/mcp-stdio.ts create mode 100644 tests/integration/mcp-parallel-writes.integration.test.ts create mode 100644 tests/unit/mcp/call-queue.test.ts diff --git a/.changeset/mcp-calls-in-order.md b/.changeset/mcp-calls-in-order.md new file mode 100644 index 00000000..3fb18041 --- /dev/null +++ b/.changeset/mcp-calls-in-order.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +The MCP server no longer loses changes when a client sends several write calls at once. Each write tool reads a file, changes it and writes it back, so parallel calls such as three `add_mcp_server`, `update_permissions` (append), `update_ignore` (append) or `update_config` calls kept only one change while all of them reported success. `agentsmesh mcp` now runs tool calls one at a time, in the order they arrive. diff --git a/src/mcp/call-queue.ts b/src/mcp/call-queue.ts new file mode 100644 index 00000000..5b9f57a5 --- /dev/null +++ b/src/mcp/call-queue.ts @@ -0,0 +1,14 @@ +/** + * Runs MCP tool calls one at a time, in the order they arrive. A client may + * send several calls without waiting, and each write tool reads a file, changes + * it and writes it back, so parallel calls lost each other's changes while all + * of them reported success (#134). One queue covers every tool at once. + */ +export function createCallQueue(): (task: () => Promise) => Promise { + let last: Promise = Promise.resolve(); + return (task: () => Promise): Promise => { + const run = last.then(task); + last = run.catch(() => undefined); + return run; + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b60a9249..96cf2cae 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,6 +2,8 @@ import { getVersion } from '../cli/version.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { + type CallToolRequest, + type CallToolResult, CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, @@ -13,6 +15,7 @@ import { readResource, toMcpError } from './resources.js'; import { McpError } from './errors.js'; import { enrichValidationIssues } from './validation-errors.js'; import { mcpServerInstructions } from './instructions.js'; +import { createCallQueue } from './call-queue.js'; export async function startServer(): Promise { const server = new Server( @@ -34,7 +37,10 @@ export async function startServer(): Promise { })), })); - server.setRequestHandler(CallToolRequestSchema, async (req) => { + const inOrder = createCallQueue(); + server.setRequestHandler(CallToolRequestSchema, (req) => inOrder(() => callTool(req))); + + async function callTool(req: CallToolRequest): Promise { const desc = TOOL_DESCRIPTORS.find((d) => d.name === req.params.name); if (!desc) { return { @@ -69,7 +75,7 @@ export async function startServer(): Promise { content: [{ type: 'text' as const, text: JSON.stringify(env) }], }; } - }); + } server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DESCRIPTORS.map((r) => ({ diff --git a/tests/helpers/mcp-stdio.ts b/tests/helpers/mcp-stdio.ts new file mode 100644 index 00000000..f7b401f5 --- /dev/null +++ b/tests/helpers/mcp-stdio.ts @@ -0,0 +1,69 @@ +/** + * A tiny JSON-RPC client for `node dist/cli.js mcp` over stdio. `call` sends a + * tools/call and resolves with the reply that has the same id; calls made + * together are sent without waiting for each other. + */ + +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; + +const CLI_PATH = join(process.cwd(), 'dist', 'cli.js'); + +export interface McpStdioSession { + call: (id: number, name: string, args: Record) => Promise; + close: () => Promise<{ stdout: string; stderr: string }>; +} + +export function startMcpServer(cwd: string): McpStdioSession { + const child = spawn('node', [CLI_PATH, 'mcp'], { cwd }); + let stdout = ''; + let stderr = ''; + const waiting = new Map void>(); + child.stdout.on('data', (d: Buffer) => { + stdout += d.toString(); + for (const line of d.toString().split('\n').filter(Boolean)) { + try { + const msg = JSON.parse(line) as { id?: number }; + if (msg.id !== undefined) waiting.get(msg.id)?.(msg); + } catch { + // Tests assert on stdout lines that are not JSON. + } + } + }); + child.stderr.on('data', (d: Buffer) => (stderr += d.toString())); + const send = (msg: object): void => void child.stdin.write(`${JSON.stringify(msg)}\n`); + const request = (id: number, method: string, params: object): Promise => + new Promise((resolve) => { + waiting.set(id, resolve); + send({ jsonrpc: '2.0', id, method, params }); + }); + const ready = request(0, 'initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }).then(() => send({ jsonrpc: '2.0', method: 'notifications/initialized' })); + return { + call: async (id, name, args) => { + await ready; + return request(id, 'tools/call', { name, arguments: args }); + }, + close: () => + new Promise((resolve) => { + child.on('close', () => resolve({ stdout, stderr })); + child.stdin.end(); + child.kill('SIGTERM'); + }), + }; +} + +/** The JSON a successful tools/call reply carries, or the error text. */ +export function toolResult(reply: unknown): unknown { + const r = reply as { + error?: unknown; + result?: { isError?: boolean; content?: [{ text: string }] }; + }; + if (r.error !== undefined || r.result?.isError === true) { + throw new Error(`tool call failed: ${JSON.stringify(reply)}`); + } + return JSON.parse(r.result?.content?.[0]?.text ?? 'null') as unknown; +} diff --git a/tests/integration/mcp-install-stdout.integration.test.ts b/tests/integration/mcp-install-stdout.integration.test.ts index 284bc378..95d5ab51 100644 --- a/tests/integration/mcp-install-stdout.integration.test.ts +++ b/tests/integration/mcp-install-stdout.integration.test.ts @@ -3,20 +3,18 @@ * the broken-link and locally-modified notices go to stderr (#133). */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { + appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, - appendFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -const CLI_PATH = join(process.cwd(), 'dist', 'cli.js'); +import { startMcpServer } from '../helpers/mcp-stdio.js'; let base: string; @@ -25,53 +23,6 @@ const write = (path: string, text: string): void => { writeFileSync(path, text); }; -interface Session { - call: (id: number, name: string, args: Record) => Promise; - close: () => Promise<{ stdout: string; stderr: string }>; -} - -function startServer(cwd: string): Session { - const child: ChildProcessWithoutNullStreams = spawn('node', [CLI_PATH, 'mcp'], { cwd }); - let stdout = ''; - let stderr = ''; - const waiting = new Map void>(); - child.stdout.on('data', (d: Buffer) => { - stdout += d.toString(); - for (const line of d.toString().split('\n').filter(Boolean)) { - try { - const msg = JSON.parse(line) as { id?: number }; - if (msg.id !== undefined) waiting.get(msg.id)?.(msg); - } catch { - // A non-JSON line is what this test catches; it is asserted below. - } - } - }); - child.stderr.on('data', (d: Buffer) => (stderr += d.toString())); - const send = (msg: object): void => void child.stdin.write(`${JSON.stringify(msg)}\n`); - const request = (id: number, method: string, params: object): Promise => - new Promise((resolve) => { - waiting.set(id, resolve); - send({ jsonrpc: '2.0', id, method, params }); - }); - const ready = request(0, 'initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.0' }, - }).then(() => send({ jsonrpc: '2.0', method: 'notifications/initialized' })); - return { - call: async (id, name, args) => { - await ready; - return request(id, 'tools/call', { name, arguments: args }); - }, - close: () => - new Promise((resolve) => { - child.on('close', () => resolve({ stdout, stderr })); - child.stdin.end(); - child.kill('SIGTERM'); - }), - }; -} - beforeEach(() => { base = realpathSync(mkdtempSync(join(tmpdir(), 'am-mcp-install-out-'))); write( @@ -88,7 +39,7 @@ afterEach(() => rmSync(base, { recursive: true, force: true })); describe('MCP install and uninstall stdout', () => { it('writes only JSON-RPC to stdout and the notices to stderr', async () => { - const server = startServer(join(base, 'proj')); + const server = startMcpServer(join(base, 'proj')); const source = join(base, 'src'); const replies = [ diff --git a/tests/integration/mcp-parallel-writes.integration.test.ts b/tests/integration/mcp-parallel-writes.integration.test.ts new file mode 100644 index 00000000..2f4feabc --- /dev/null +++ b/tests/integration/mcp-parallel-writes.integration.test.ts @@ -0,0 +1,54 @@ +/** + * JSON-RPC clients may send several tool calls without waiting. Each write + * tool reads, changes and writes a file, so parallel calls must not lose each + * other's changes while every call reports success (#134). + */ + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { startMcpServer, toolResult } from '../helpers/mcp-stdio.js'; + +let root: string; + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-mcp-parallel-'))); + mkdirSync(join(root, '.agentsmesh'), { recursive: true }); + writeFileSync(join(root, 'agentsmesh.yaml'), 'version: 1\ntargets: [claude-code]\n'); + writeFileSync(join(root, '.agentsmesh', 'mcp.json'), '{ "mcpServers": {} }\n'); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('MCP parallel write calls', () => { + it('keep every change', async () => { + const server = startMcpServer(root); + const names = ['s1', 's2', 's3']; + + const writes = await Promise.all([ + ...names.map((name, i) => + server.call(1 + i, 'add_mcp_server', { + name, + server: { type: 'stdio', command: 'echo', args: [name] }, + }), + ), + ...names.map((name, i) => + server.call(4 + i, 'update_permissions', { allow: [`Bash(${name})`], mode: 'append' }), + ), + ...names.map((name, i) => + server.call(7 + i, 'update_ignore', { patterns: [`${name}/`], mode: 'append' }), + ), + ]); + const servers = toolResult(await server.call(10, 'list_mcp_servers', {})) as { + servers: Record; + }; + const perms = toolResult(await server.call(11, 'get_permissions', {})) as { allow: string[] }; + const ignore = toolResult(await server.call(12, 'get_ignore', {})) as { patterns: string[] }; + await server.close(); + + writes.forEach((reply) => toolResult(reply)); + expect(Object.keys(servers.servers).sort()).toEqual(names); + expect([...perms.allow].sort()).toEqual(['Bash(s1)', 'Bash(s2)', 'Bash(s3)']); + expect([...ignore.patterns].sort()).toEqual(['s1/', 's2/', 's3/']); + }, 60_000); +}); diff --git a/tests/unit/mcp/call-queue.test.ts b/tests/unit/mcp/call-queue.test.ts new file mode 100644 index 00000000..6825765e --- /dev/null +++ b/tests/unit/mcp/call-queue.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { createCallQueue } from '../../../src/mcp/call-queue.js'; + +const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 5)); + +describe('createCallQueue', () => { + it('runs tasks one at a time, in the order they came', async () => { + const run = createCallQueue(); + const log: string[] = []; + const task = (name: string) => async (): Promise => { + log.push(`start ${name}`); + await tick(); + log.push(`end ${name}`); + return name; + }; + + const results = await Promise.all([run(task('a')), run(task('b')), run(task('c'))]); + + expect(results).toEqual(['a', 'b', 'c']); + expect(log).toEqual(['start a', 'end a', 'start b', 'end b', 'start c', 'end c']); + }); + + it('keeps going after a task fails, and passes the failure to its caller', async () => { + const run = createCallQueue(); + + const failed = run(async () => { + throw new Error('boom'); + }); + const next = run(async () => 'next'); + + await expect(failed).rejects.toThrow('boom'); + await expect(next).resolves.toBe('next'); + }); +}); diff --git a/website/src/content/docs/reference/mcp-server.mdx b/website/src/content/docs/reference/mcp-server.mdx index 55149a29..b9e2aa5a 100644 --- a/website/src/content/docs/reference/mcp-server.mdx +++ b/website/src/content/docs/reference/mcp-server.mdx @@ -56,6 +56,8 @@ The server reads `projectRoot` from the process working directory at startup, wa The server exposes **50 tools** grouped by category. All tool errors return a structured envelope `{ code, message, details? }` (see [Error codes](#error-codes)). +The server runs tool calls one at a time, in the order they arrive. A client can send several calls without waiting for the replies: parallel write calls to the same file (for example three `add_mcp_server` calls) all keep their changes. + ### Canonical reads (8) | Tool | Description | From 0878dcd5dc04aa553b0e3d142be503ddeda00ba3 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 16:01:53 +0200 Subject: [PATCH 21/38] fix(generate): keep lock sources when --targets skips an enabled target A filtered generate wrote the new canonical checksums to the lock even though the targets it skipped were not regenerated, so check reported "in sync" while their outputs were stale. writeLockFile now takes the skipped targets: when there are any, it keeps the previous lock's checksums, extends and packs and only merges the new outputs, and with no lock yet it writes none. check then fails until a full generate. A --targets list that names every enabled target is still a full run. Fixes #136 --- .changeset/check-after-filtered-generate.md | 5 ++ src/cli/commands/generate-empty-run.ts | 5 +- src/cli/commands/generate-handlers.ts | 1 + src/cli/commands/generate-lock.ts | 32 ++++++-- src/cli/commands/generate.ts | 1 + .../commands/generate-empty-results.test.ts | 1 + .../commands/generate-filtered-check.test.ts | 77 +++++++++++++++++++ website/src/content/docs/cli/check.mdx | 1 + website/src/content/docs/cli/generate.mdx | 4 +- 9 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 .changeset/check-after-filtered-generate.md create mode 100644 tests/unit/cli/commands/generate-filtered-check.test.ts diff --git a/.changeset/check-after-filtered-generate.md b/.changeset/check-after-filtered-generate.md new file mode 100644 index 00000000..c117c7a7 --- /dev/null +++ b/.changeset/check-after-filtered-generate.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +`agentsmesh check` no longer passes after `agentsmesh generate --targets …` left other targets out of date. Before, a filtered run recorded the new canonical checksums in `.agentsmesh/.lock` even though the targets it skipped were not regenerated, so the CI gate said "Lock file is in sync." while, for example, `CLAUDE.md` was stale. Now a `--targets` run that leaves out an enabled target keeps the lock's previous canonical checksums (and creates no lock if there is none yet), so `check` fails until a full `agentsmesh generate`. A `--targets` list that names every enabled target works like a full run. diff --git a/src/cli/commands/generate-empty-run.ts b/src/cli/commands/generate-empty-run.ts index f882099d..2ebc895a 100644 --- a/src/cli/commands/generate-empty-run.ts +++ b/src/cli/commands/generate-empty-run.ts @@ -33,6 +33,8 @@ export interface EmptyResultsArgs { root: string; options: RunGenerateOptions; activeTargets: string[]; + /** Every target in the config, so the lock knows which ones this run skipped. */ + configuredTargets: string[]; } /** @@ -61,6 +63,7 @@ function resolveEmptyReason( export async function handleEmptyResults(args: EmptyResultsArgs): Promise { const { mode, scope, dryRun, context, resolvedExtends, flags, root, options, activeTargets } = args; + const skippedTargets = args.configuredTargets.filter((t) => !activeTargets.includes(t)); const data: GenerateData = { scope, mode, @@ -100,7 +103,7 @@ export async function handleEmptyResults(args: EmptyResultsArgs): Promise> { + return { + checksums: await buildChecksums(canonicalDir), + extends: resolvedExtends.length > 0 ? await buildExtendChecksums(resolvedExtends) : {}, + packs: await buildPackChecksums(join(canonicalDir, 'packs')), + }; +} + +/** + * Write `.agentsmesh/.lock` when its content changed; returns whether it did. + * `skippedTargets` are enabled targets a `--targets` run did not generate. + */ export async function writeLockFile( context: { canonicalDir: string; configDir: string }, resolvedExtends: ResolvedExtend[], runOutputs: Record, filtered: boolean, + skippedTargets: readonly string[] = [], ): Promise { - const checksums = await buildChecksums(context.canonicalDir); - const extendChecksums = - resolvedExtends.length > 0 ? await buildExtendChecksums(resolvedExtends) : {}; - const packChecksums = await buildPackChecksums(join(context.canonicalDir, 'packs')); const previous = await readLock(context.canonicalDir); + // A skipped target was not regenerated, so the lock keeps the sources its + // outputs came from and `check` fails until a full run (#136). With no lock + // yet, write none: `check` then asks for a full generate as well. + const skipped = skippedTargets.length > 0; + if (skipped && previous === null) return false; + const sources = + skipped && previous !== null + ? { checksums: previous.checksums, extends: previous.extends, packs: previous.packs } + : await currentSources(context.canonicalDir, resolvedExtends); // Full generate replaces the outputs map (dropping disabled targets' entries). // Filtered generate merges per-path so untouched targets' entries survive; it // never prunes stale entries — an accepted limitation until the next full run. const outputs = filtered ? { ...(previous?.outputs ?? {}), ...runOutputs } : runOutputs; - const content = { checksums, extends: extendChecksums, packs: packChecksums, outputs }; + const content = { ...sources, outputs }; // Time, user and version describe the last run that changed the content. // Rewriting them on a no-op run dirtied the git tree after every generate. const changed = previous === null || !sameContent(previous, content); diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index 90e4a344..271c3748 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -119,6 +119,7 @@ export async function runGenerate( root, options, activeTargets, + configuredTargets: allTargets, }) : checkOnly ? buildCheckResult(results, scope) diff --git a/tests/unit/cli/commands/generate-empty-results.test.ts b/tests/unit/cli/commands/generate-empty-results.test.ts index 70760fa2..bddbe738 100644 --- a/tests/unit/cli/commands/generate-empty-results.test.ts +++ b/tests/unit/cli/commands/generate-empty-results.test.ts @@ -112,6 +112,7 @@ describe('handleEmptyResults: empty reason in global scope', () => { root: testDir, options: { printMatrix: false }, activeTargets, + configuredTargets: activeTargets, }); it('names no-global-support when every active target is cloud-only', async () => { diff --git a/tests/unit/cli/commands/generate-filtered-check.test.ts b/tests/unit/cli/commands/generate-filtered-check.test.ts new file mode 100644 index 00000000..a98467de --- /dev/null +++ b/tests/unit/cli/commands/generate-filtered-check.test.ts @@ -0,0 +1,77 @@ +/** + * A `generate --targets` run that leaves out an enabled target must not move + * the lock's canonical checksums forward: the skipped target's outputs were + * not regenerated, so `check` must fail until a full generate (#136). + */ + +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runCheck } from '../../../../src/cli/commands/check.js'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; + +let root: string; +const ROOT_RULE = (): string => join(root, '.agentsmesh', 'rules', '_root.md'); +const generate = (flags: Record = {}): Promise => + runGenerate(flags, root, { printMatrix: false }); +const checkExit = async (): Promise => (await runCheck({}, root)).exitCode; + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-filtered-check-'))); + mkdirSync(join(root, '.agentsmesh', 'rules'), { recursive: true }); + writeFileSync( + join(root, 'agentsmesh.yaml'), + 'version: 1\ntargets: [claude-code, cursor]\nfeatures: [rules]\n', + ); + writeFileSync(ROOT_RULE(), '---\nroot: true\n---\n# Root\n'); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('check after generate --targets', () => { + it('fails while a skipped target is stale, and passes after a full generate', async () => { + await generate(); + appendFileSync(ROOT_RULE(), '\n- NEW LINE XYZ\n'); + + await generate({ targets: 'cursor' }); + const afterFiltered = await checkExit(); + await generate(); + + expect(readFileSync(join(root, '.cursor', 'rules', 'general.mdc'), 'utf8')).toContain( + 'NEW LINE XYZ', + ); + expect([afterFiltered, await checkExit()]).toEqual([1, 0]); + }); + + it('treats a --targets list that names every enabled target as a full run', async () => { + await generate(); + appendFileSync(ROOT_RULE(), '\n- NEW LINE XYZ\n'); + + await generate({ targets: 'claude-code,cursor' }); + + expect(await checkExit()).toBe(0); + }); + + it('stays in sync after a filtered run when nothing canonical changed', async () => { + await generate(); + + await generate({ targets: 'cursor' }); + + expect(await checkExit()).toBe(0); + }); + + it('does not create a lock on a first run that skips a target', async () => { + await generate({ targets: 'cursor' }); + + expect([existsSync(join(root, '.agentsmesh', '.lock')), await checkExit()]).toEqual([false, 1]); + }); +}); diff --git a/website/src/content/docs/cli/check.mdx b/website/src/content/docs/cli/check.mdx index b5ef0d08..85f6fd4d 100644 --- a/website/src/content/docs/cli/check.mdx +++ b/website/src/content/docs/cli/check.mdx @@ -33,6 +33,7 @@ The `.agentsmesh/` sources no longer match the checksums recorded in `.agentsmes - Someone edited `.agentsmesh/` but forgot to run `agentsmesh generate` - A pull request merged changes to canonical config without regenerating - An `extends` source changed and the project hasn't refreshed +- `agentsmesh generate --targets …` ran after a canonical change but left out an enabled target. That target's files were not regenerated, so the lock keeps the older checksums and `check` fails until a full `agentsmesh generate` ### Generated-output drift diff --git a/website/src/content/docs/cli/generate.mdx b/website/src/content/docs/cli/generate.mdx index aac4185c..85c36011 100644 --- a/website/src/content/docs/cli/generate.mdx +++ b/website/src/content/docs/cli/generate.mdx @@ -16,7 +16,7 @@ agentsmesh generate [flags] | Flag | Description | |------|-------------| -| `--targets ` | Comma-separated list of target IDs to generate for. Defaults to all configured targets. | +| `--targets ` | Comma-separated list of target IDs to generate for. Defaults to all configured targets. A run that leaves out an enabled target does not move the lock's canonical checksums forward (and creates no lock if there is none yet), so `agentsmesh check` keeps failing until a full `generate` updates every target. | | `--global` | Generate user-level config from `~/.agentsmesh/` instead of project-local config (e.g. `claude-code`, `antigravity`, `codex-cli`; Cursor writes full `~/.cursor/` global surfaces including `rules/`, `AGENTS.md`, hooks, ignore, MCP, skills, agents, commands). | | `--dry-run` | Preview changes without writing any files. | | `--check` | Verify sync status only. Exit code 1 if out of sync, or if the project's lessons graph cannot be read. | @@ -93,7 +93,7 @@ Forces a re-fetch of all remote `extends` sources. Without this flag, AgentsMesh 3. **Generate per target** — for each enabled target, run target-specific generators for rules, commands, agents, skills, MCP, hooks, ignore, and permissions. 4. **Rewrite references** — internal `.agentsmesh/` file paths are rewritten to target-relative paths. 5. **Resolve collisions** — detect overlapping output paths, prefer native over embedded. -6. **Write output** — create target directories, write all files, update the lock file (canonical-source checksums plus an `outputs` map of every generated file's checksum, so `agentsmesh check` can later detect direct edits to generated files). Filtered runs (`--targets`) merge outputs per-path into the existing map; a full run replaces it. The lock is rewritten only when this content changes: a run that changes nothing leaves `.agentsmesh/.lock` untouched, so the git tree stays clean. +6. **Write output** — create target directories, write all files, update the lock file (canonical-source checksums plus an `outputs` map of every generated file's checksum, so `agentsmesh check` can later detect direct edits to generated files). Filtered runs (`--targets`) merge outputs per-path into the existing map; a full run replaces it. A filtered run that leaves out an enabled target keeps the previous canonical checksums, because that target was not regenerated from the new sources. The lock is rewritten only when this content changes: a run that changes nothing leaves `.agentsmesh/.lock` untouched, so the git tree stays clean. 7. **Clean stale files** — remove previously generated files no longer in the output set. This also runs when a full run produces no files at all, so uninstalling the last pack removes its generated outputs and resets the lock's `outputs` map. ## Lessons upkeep From 75daebd9f25e01d7831b1c2cc912bfd7a7f9e530 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 16:09:39 +0200 Subject: [PATCH 22/38] fix(claude-code): map rule paths to globs and write them back as paths Claude Code scopes a .claude/rules/*.md rule with `paths:`, the only field it reads from a rule. Import left `paths` as an extra field and gave the canonical rule `globs: []`, and generate wrote canonical globs as `globs:`, which Claude Code ignores, so scoped rules applied to every file. The rule mapper now reads `paths` (list or comma-separated string) into globs, still reads an older generated `globs:`, and the generator writes `paths:`. Fixes #137 --- .changeset/claude-rule-paths.md | 5 + src/targets/claude-code/generator.ts | 3 +- src/targets/claude-code/import-mappers.ts | 10 +- .../claude-code-format-roundtrip.e2e.test.ts | 5 +- .../targets/claude-code/rule-paths.test.ts | 97 +++++++++++++++++++ .../content/docs/canonical-config/rules.mdx | 2 +- 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 .changeset/claude-rule-paths.md create mode 100644 tests/unit/targets/claude-code/rule-paths.test.ts diff --git a/.changeset/claude-rule-paths.md b/.changeset/claude-rule-paths.md new file mode 100644 index 00000000..c45a6c04 --- /dev/null +++ b/.changeset/claude-rule-paths.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +Claude Code rules keep their file scope. Claude Code scopes a rule in `.claude/rules/*.md` with `paths:`, the only field it reads from a rule. `agentsmesh import --from claude-code` ignored `paths` (the canonical rule got `globs: []`), and `agentsmesh generate` wrote a canonical rule's `globs` as `globs:`, which Claude Code ignores, so scoped rules applied to every file. Now import reads `paths` (a YAML list or a comma-separated string) into `globs`, older generated files with `globs:` still import, and generate writes `paths:`. diff --git a/src/targets/claude-code/generator.ts b/src/targets/claude-code/generator.ts index 016f1535..00c2c745 100644 --- a/src/targets/claude-code/generator.ts +++ b/src/targets/claude-code/generator.ts @@ -46,7 +46,8 @@ export function generateRules(canonical: CanonicalFiles): RulesOutput[] { const slug = basename(rule.source, '.md'); const frontmatter: Record = {}; if (rule.description) frontmatter.description = rule.description; - if (rule.globs.length > 0) frontmatter.globs = rule.globs; + // `paths` is the only field Claude Code reads from a rule. + if (rule.globs.length > 0) frontmatter.paths = rule.globs; const content = serializeFrontmatter(frontmatter, rule.body.trim() || ''); outputs.push({ path: `${CLAUDE_RULES_DIR}/${slug}.md`, content }); } diff --git a/src/targets/claude-code/import-mappers.ts b/src/targets/claude-code/import-mappers.ts index 61615000..b9e0f6ea 100644 --- a/src/targets/claude-code/import-mappers.ts +++ b/src/targets/claude-code/import-mappers.ts @@ -14,7 +14,11 @@ import { import { toToolsArray } from '../import/shared-import-helpers.js'; import type { ImportEntryMapper } from '../catalog/import-descriptor.js'; -/** Non-root Claude rules pass frontmatter through and force `root: false`. */ +/** + * Non-root Claude rules pass frontmatter through and force `root: false`. + * Claude Code scopes a rule with `paths` (list or comma-separated string), so + * that becomes canonical `globs`; an older generated `globs` is still read. + */ export const claudeRuleMapper: ImportEntryMapper = async ({ relativePath, normalizeTo, @@ -22,12 +26,14 @@ export const claudeRuleMapper: ImportEntryMapper = async ({ }) => { const destPath = join(destDir, relativePath); const { frontmatter, body } = parseFrontmatter(normalizeTo(destPath)); + const { paths, ...rest } = frontmatter; + const scoped = paths === undefined ? {} : { globs: toToolsArray(paths) }; return { destPath, toPath: `${AB_RULES}/${relativePath}`, content: await serializeImportedRuleWithFallback( destPath, - { ...frontmatter, root: false }, + { ...rest, ...scoped, root: false }, body, ), }; diff --git a/tests/e2e/claude-code-format-roundtrip.e2e.test.ts b/tests/e2e/claude-code-format-roundtrip.e2e.test.ts index adff4d3b..0a93ac3e 100644 --- a/tests/e2e/claude-code-format-roundtrip.e2e.test.ts +++ b/tests/e2e/claude-code-format-roundtrip.e2e.test.ts @@ -27,8 +27,9 @@ describe('claude-code doc format roundtrip', () => { const rule = readFileSync(join(dir, '.claude', 'rules', 'typescript.md'), 'utf-8'); expect(rule).toContain('description: TypeScript specific rules'); - expect(rule).toContain('globs:'); - expect(rule).toContain('src/**/*.ts'); + // `paths` is the only field Claude Code reads from a rule (#137). + expect(rule).toContain('paths:\n - src/**/*.ts'); + expect(rule).not.toContain('globs:'); expect(rule).toContain('# TypeScript'); const command = readFileSync(join(dir, '.claude', 'commands', 'review.md'), 'utf-8'); diff --git a/tests/unit/targets/claude-code/rule-paths.test.ts b/tests/unit/targets/claude-code/rule-paths.test.ts new file mode 100644 index 00000000..d8582f13 --- /dev/null +++ b/tests/unit/targets/claude-code/rule-paths.test.ts @@ -0,0 +1,97 @@ +/** + * Claude Code scopes a rule with `paths:` (a YAML list or a comma-separated + * string); it is the only field Claude Code reads from a rule. Import maps it + * to canonical `globs` and generate writes it back as `paths:`, so a scoped + * rule never silently becomes global (#137). + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; +import { runImport } from '../../../../src/cli/commands/import.js'; +import { generateRules } from '../../../../src/targets/claude-code/generator.js'; +import type { CanonicalFiles } from '../../../../src/core/types.js'; +import { splitFrontmatter } from '../../../../src/utils/text/markdown.js'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const frontmatterOf = (rel: string): unknown => + parseYaml(splitFrontmatter(readFileSync(join(root, rel), 'utf8'))?.yaml ?? ''); + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-claude-paths-'))); + write('CLAUDE.md', '# Root\n'); + write('agentsmesh.yaml', 'version: 1\ntargets: [claude-code]\nfeatures: [rules]\n'); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('Claude Code rule paths', () => { + it('imports a paths list as canonical globs, without an extra paths field', async () => { + write('.claude/rules/backend.md', '---\npaths:\n - "src/api/**/*.ts"\n---\n\n# Backend\n'); + + await runImport({ from: 'claude-code' }, root); + + expect(frontmatterOf('.agentsmesh/rules/backend.md')).toEqual({ + root: false, + description: '', + globs: ['src/api/**/*.ts'], + }); + }); + + it('imports a comma-separated paths string, and an older globs field', async () => { + write('.claude/rules/a.md', '---\npaths: "src/**/*.ts, test/**/*.ts"\n---\n# A\n'); + write('.claude/rules/b.md', '---\nglobs:\n - lib/**\n---\n# B\n'); + + await runImport({ from: 'claude-code' }, root); + + expect([ + (frontmatterOf('.agentsmesh/rules/a.md') as { globs: string[] }).globs, + (frontmatterOf('.agentsmesh/rules/b.md') as { globs: string[] }).globs, + ]).toEqual([['src/**/*.ts', 'test/**/*.ts'], ['lib/**']]); + }); + + it('generates the rule scope as paths', () => { + const canonical: CanonicalFiles = { + rules: [ + { + source: '/p/.agentsmesh/rules/ts.md', + root: false, + targets: [], + description: 'TS', + globs: ['src/**/*.ts'], + body: 'Use strict mode.', + }, + ], + commands: [], + agents: [], + skills: [], + mcp: null, + permissions: null, + hooks: null, + ignore: [], + }; + + expect(generateRules(canonical)).toEqual([ + { + path: '.claude/rules/ts.md', + content: '---\ndescription: TS\npaths:\n - src/**/*.ts\n---\n\nUse strict mode.', + }, + ]); + }); + + it('keeps the scope through import and generate', async () => { + write('.claude/rules/backend.md', '---\npaths:\n - "src/api/**/*.ts"\n---\n\n# Backend\n'); + + await runImport({ from: 'claude-code' }, root); + await runGenerate({}, root, { printMatrix: false }); + + expect(frontmatterOf('.claude/rules/backend.md')).toEqual({ paths: ['src/api/**/*.ts'] }); + }); +}); diff --git a/website/src/content/docs/canonical-config/rules.mdx b/website/src/content/docs/canonical-config/rules.mdx index 65683238..a6f35ab8 100644 --- a/website/src/content/docs/canonical-config/rules.mdx +++ b/website/src/content/docs/canonical-config/rules.mdx @@ -80,7 +80,7 @@ Always run `pnpm typecheck` before committing. | `root` | `boolean` | Always-applied rule. Required for `_root.md`. | | `description` | `string` | Human-readable rule name shown in tool menus. | | `targets` | `string[]` | Limit rule to specific tools. Empty = all targets. See the [supported tools matrix](../../reference/supported-tools/) for valid IDs. | -| `globs` | `string[]` | File patterns this rule applies to (tool-dependent). Uses gitignore-style glob syntax. | +| `globs` | `string[]` | File patterns this rule applies to (tool-dependent). Uses gitignore-style glob syntax. Claude Code reads a rule's scope from `paths:`, so `generate` writes `globs` there as `paths`, and `import --from claude-code` reads `paths` (a list or a comma-separated string) back into `globs`. | | `trigger` | `string` | Windsurf activation mode: `always_on`, `model_decision`, `glob`, `manual`. | | `codex_emit` | `string` | Codex CLI instruction type: `advisory` or `execution`. | | `codex_instruction` | `string` | Codex nested instruction format: set to `override` to opt in; omit for the default. | From f23984f2a87f61120ff4dd46224bfcd0dd50c5c8 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 16:16:48 +0200 Subject: [PATCH 23/38] fix(generate): record targets a filtered run left stale instead of holding the lock back The first #136 fix kept the lock's old checksums after a --targets run that skipped an enabled target, and wrote no lock at all on a first filtered run. That lost the outputs provenance the stale-file sweep needs (the target contract matrix caught it), made a full run after a filtered --force run need --force again under strategy: lock, and kept check red after two filtered runs that together covered every target. The lock now always records the current sources and gains an optional stale_targets list: a filtered run adds the enabled targets it skipped when the sources changed, keeps only already-stale skipped targets when they did not, and a full run clears it. check reports them as staleTargets (CLI, --json, MCP check, check() API) and fails while any enabled target is listed. Locks of projects that never use --targets do not change. Refs #136 --- .changeset/check-after-filtered-generate.md | 2 +- src/cli/command-result.ts | 2 ++ src/cli/commands/check.ts | 1 + src/cli/commands/generate-lock.ts | 36 +++++++++++-------- src/cli/renderers/check.ts | 3 ++ src/config/core/lock-stale-targets.ts | 28 +++++++++++++++ src/config/core/lock.ts | 16 ++++----- src/core/check/lock-sync-types.ts | 7 +++- src/core/check/lock-sync.ts | 10 +++++- src/core/config-types.ts | 2 ++ src/mcp/handlers/orchestrate-types.ts | 2 ++ src/mcp/handlers/orchestrate.ts | 1 + tests/consumer-smoke/src/smoke.ts | 2 ++ .../mcp-generate-lock.integration.test.ts | 1 + .../commands/generate-filtered-check.test.ts | 33 +++++++++++++---- tests/unit/cli/renderers/check-hints.test.ts | 1 + tests/unit/cli/renderers/check.test.ts | 35 ++++++++++++++++++ tests/unit/config/lock-stale-targets.test.ts | 30 ++++++++++++++++ .../mcp/handlers/orchestrate-check.test.ts | 3 ++ website/src/content/docs/cli/check.mdx | 5 ++- website/src/content/docs/cli/generate.mdx | 4 +-- .../docs/reference/generation-pipeline.mdx | 3 +- .../docs/reference/programmatic-api.mdx | 2 ++ 23 files changed, 189 insertions(+), 40 deletions(-) create mode 100644 src/config/core/lock-stale-targets.ts create mode 100644 tests/unit/config/lock-stale-targets.test.ts diff --git a/.changeset/check-after-filtered-generate.md b/.changeset/check-after-filtered-generate.md index c117c7a7..bf637de8 100644 --- a/.changeset/check-after-filtered-generate.md +++ b/.changeset/check-after-filtered-generate.md @@ -2,4 +2,4 @@ 'agentsmesh': patch --- -`agentsmesh check` no longer passes after `agentsmesh generate --targets …` left other targets out of date. Before, a filtered run recorded the new canonical checksums in `.agentsmesh/.lock` even though the targets it skipped were not regenerated, so the CI gate said "Lock file is in sync." while, for example, `CLAUDE.md` was stale. Now a `--targets` run that leaves out an enabled target keeps the lock's previous canonical checksums (and creates no lock if there is none yet), so `check` fails until a full `agentsmesh generate`. A `--targets` list that names every enabled target works like a full run. +`agentsmesh check` no longer passes after `agentsmesh generate --targets …` left other targets out of date. Before, a filtered run recorded the new canonical checksums in `.agentsmesh/.lock` even though the targets it skipped were not regenerated, so the CI gate said "Lock file is in sync." while, for example, `CLAUDE.md` was stale. Now, when the canonical sources changed, the lock lists the enabled targets the run left out under a new `stale_targets` key, and `check` fails and names them (`staleTargets` in `--json`, the MCP `check` tool and the `check()` API) until each is generated again. A full `agentsmesh generate` clears the list, and the lock does not change for projects that never use `--targets`. diff --git a/src/cli/command-result.ts b/src/cli/command-result.ts index 2131f61d..d0a8fb1d 100644 --- a/src/cli/command-result.ts +++ b/src/cli/command-result.ts @@ -96,6 +96,8 @@ export interface CheckData { outputsRemoved: string[]; /** Managed generated outputs present on disk but absent from the lock. */ outputsStale: string[]; + /** Targets a `generate --targets` run left out after canonical sources changed. */ + staleTargets: string[]; /** Managed-dir files agentsmesh did not write; informational, never drift. */ outputsUntracked: string[]; /** diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 13b3d10a..85d03036 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -68,6 +68,7 @@ function lockResult(report: Awaited>): CheckCom outputsModified: [...report.outputsModified], outputsRemoved: [...report.outputsRemoved], outputsStale: [...report.outputsStale], + staleTargets: [...report.staleTargets], outputsUntracked: [...report.outputsUntracked], outputsChecked: report.outputsChecked, }, diff --git a/src/cli/commands/generate-lock.ts b/src/cli/commands/generate-lock.ts index aff2bf45..8cea7cbf 100644 --- a/src/cli/commands/generate-lock.ts +++ b/src/cli/commands/generate-lock.ts @@ -10,6 +10,7 @@ import { readLock, writeLock, } from '../../config/core/lock.js'; +import { nextStaleTargets } from '../../config/core/lock-stale-targets.js'; import { getCacheDir } from '../../config/remote/remote-fetcher.js'; import { ensureCacheSymlink } from '../../utils/filesystem/fs.js'; import { logger } from '../../utils/output/logger.js'; @@ -17,7 +18,8 @@ import { getVersion } from '../version.js'; import type { ResolvedExtend } from '../../config/resolve/resolver.js'; import type { LockFile } from '../../core/types.js'; -type LockContent = Pick; +type LockSources = Pick; +type LockContent = LockSources & Pick; function sameMap( a: Record | undefined, @@ -28,19 +30,26 @@ function sameMap( return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); } -function sameContent(previous: LockFile, next: LockContent): boolean { +function sameSources(previous: LockFile, next: LockSources): boolean { return ( sameMap(previous.checksums, next.checksums) && sameMap(previous.extends, next.extends) && - sameMap(previous.packs, next.packs) && - sameMap(previous.outputs, next.outputs) + sameMap(previous.packs, next.packs) + ); +} + +function sameContent(previous: LockFile, next: LockContent): boolean { + return ( + sameSources(previous, next) && + sameMap(previous.outputs, next.outputs) && + (previous.staleTargets ?? []).join('\n') === (next.staleTargets ?? []).join('\n') ); } async function currentSources( canonicalDir: string, resolvedExtends: ResolvedExtend[], -): Promise> { +): Promise { return { checksums: await buildChecksums(canonicalDir), extends: resolvedExtends.length > 0 ? await buildExtendChecksums(resolvedExtends) : {}, @@ -60,20 +69,17 @@ export async function writeLockFile( skippedTargets: readonly string[] = [], ): Promise { const previous = await readLock(context.canonicalDir); - // A skipped target was not regenerated, so the lock keeps the sources its - // outputs came from and `check` fails until a full run (#136). With no lock - // yet, write none: `check` then asks for a full generate as well. - const skipped = skippedTargets.length > 0; - if (skipped && previous === null) return false; - const sources = - skipped && previous !== null - ? { checksums: previous.checksums, extends: previous.extends, packs: previous.packs } - : await currentSources(context.canonicalDir, resolvedExtends); + const sources = await currentSources(context.canonicalDir, resolvedExtends); + const staleTargets = nextStaleTargets( + previous?.staleTargets, + skippedTargets, + previous === null || !sameSources(previous, sources), + ); // Full generate replaces the outputs map (dropping disabled targets' entries). // Filtered generate merges per-path so untouched targets' entries survive; it // never prunes stale entries — an accepted limitation until the next full run. const outputs = filtered ? { ...(previous?.outputs ?? {}), ...runOutputs } : runOutputs; - const content = { ...sources, outputs }; + const content = { ...sources, outputs, ...(staleTargets ? { staleTargets } : {}) }; // Time, user and version describe the last run that changed the content. // Rewriting them on a no-op run dirtied the git tree after every generate. const changed = previous === null || !sameContent(previous, content); diff --git a/src/cli/renderers/check.ts b/src/cli/renderers/check.ts index e6418d11..c1397b42 100644 --- a/src/cli/renderers/check.ts +++ b/src/cli/renderers/check.ts @@ -55,6 +55,9 @@ export function renderCheck(result: CheckCommandResult): void { for (const p of data.outputsStale) { ui.error(` generated output "${fwd(p)}" is stale`); } + for (const target of data.staleTargets) { + ui.error(` target "${target}" was not generated after the last canonical change`); + } ui.note('Generated files are out of sync.', 'Check'); ui.info(driftHint(data)); renderUntracked(data); diff --git a/src/config/core/lock-stale-targets.ts b/src/config/core/lock-stale-targets.ts new file mode 100644 index 00000000..3aba5927 --- /dev/null +++ b/src/config/core/lock-stale-targets.ts @@ -0,0 +1,28 @@ +/** + * `stale_targets` in `.agentsmesh/.lock`: enabled targets a `generate --targets` + * run left out after the canonical sources changed. Their outputs are older + * than the lock's checksums, so `check` fails until they are generated again + * (#136). A full run clears the list; it is absent when it would be empty. + */ + +export function parseStaleTargets(raw: unknown): string[] | undefined { + if (!Array.isArray(raw)) return undefined; + const targets = raw.filter((target): target is string => typeof target === 'string'); + return targets.length > 0 ? targets : undefined; +} + +/** + * Stale targets after a run that skipped `skipped`: all of them when the + * sources changed, else only those that were already stale. Every target the + * run generated is current again. + */ +export function nextStaleTargets( + previous: readonly string[] | undefined, + skipped: readonly string[], + sourcesChanged: boolean, +): string[] | undefined { + const stale = sourcesChanged + ? [...skipped] + : skipped.filter((target) => previous?.includes(target) === true); + return stale.length > 0 ? stale.sort() : undefined; +} diff --git a/src/config/core/lock.ts b/src/config/core/lock.ts index ea210af5..df574d5c 100644 --- a/src/config/core/lock.ts +++ b/src/config/core/lock.ts @@ -11,6 +11,7 @@ import { import { hashFile, hashContent } from '../../utils/crypto/hash.js'; import type { LockFile } from '../../core/types.js'; import { listPacks } from '../../install/pack/pack-reader.js'; +import { parseStaleTargets } from './lock-stale-targets.js'; const LOCK_FILENAME = '.lock'; @@ -42,11 +43,7 @@ const FEATURE_PATTERNS: Record boolean> = { ignore: (path) => path === 'ignore', }; -/** - * Read lock file from .agentsmesh directory. - * @param abDir - Absolute path to .agentsmesh - * @returns Parsed LockFile or null if missing - */ +/** Read `.agentsmesh/.lock`; null when it is missing or cannot be parsed. */ export async function readLock(abDir: string): Promise { const lockPath = join(abDir, LOCK_FILENAME); const content = await readFileSafe(lockPath); @@ -61,6 +58,7 @@ export async function readLock(abDir: string): Promise { extends?: Record; packs?: Record; outputs?: Record; + stale_targets?: unknown; }; if (!raw || typeof raw !== 'object') return null; return { @@ -72,17 +70,14 @@ export async function readLock(abDir: string): Promise { packs: raw.packs && typeof raw.packs === 'object' ? raw.packs : {}, // undefined (not {}) when absent → old-format lock; skips output check. outputs: raw.outputs && typeof raw.outputs === 'object' ? raw.outputs : undefined, + staleTargets: parseStaleTargets(raw.stale_targets), }; } catch { return null; } } -/** - * Write lock file to .agentsmesh directory. - * @param abDir - Absolute path to .agentsmesh - * @param lock - Lock file data - */ +/** Write `.agentsmesh/.lock` from `lock`. */ export async function writeLock(abDir: string, lock: LockFile): Promise { const lockPath = join(abDir, LOCK_FILENAME); const raw = { @@ -94,6 +89,7 @@ export async function writeLock(abDir: string, lock: LockFile): Promise { packs: lock.packs, // Omit the key entirely when undefined (old-format lock). ...(lock.outputs !== undefined ? { outputs: lock.outputs } : {}), + ...(lock.staleTargets !== undefined ? { stale_targets: lock.staleTargets } : {}), }; const content = '# Auto-generated. DO NOT EDIT MANUALLY.\n# Tracks the state of all config files for team conflict resolution.\n\n' + diff --git a/src/core/check/lock-sync-types.ts b/src/core/check/lock-sync-types.ts index b3f9357d..9b47a4ab 100644 --- a/src/core/check/lock-sync-types.ts +++ b/src/core/check/lock-sync-types.ts @@ -13,7 +13,7 @@ export interface LockSyncReport { readonly lockConflict: boolean; /** True when canonical files or extends differ from the lock. */ readonly canonicalDrift: boolean; - /** True when a generated output is modified, removed, or stale. */ + /** True when a generated output is modified, removed, or stale, or a target is stale. */ readonly outputDrift: boolean; /** Canonical files whose checksum differs from the lock. */ readonly modified: readonly string[]; @@ -34,6 +34,11 @@ export interface LockSyncReport { readonly outputsRemoved: readonly string[]; /** Managed generated outputs present on disk but absent from the lock. */ readonly outputsStale: readonly string[]; + /** + * Enabled targets a `generate --targets` run left out after canonical + * sources changed, so their outputs were not regenerated from them. + */ + readonly staleTargets: readonly string[]; /** * Files inside a managed directory the lock does not claim — the tool's own * output or something hand-authored. Informational: deliberately excluded diff --git a/src/core/check/lock-sync.ts b/src/core/check/lock-sync.ts index 3a6e36e2..83dda64d 100644 --- a/src/core/check/lock-sync.ts +++ b/src/core/check/lock-sync.ts @@ -52,6 +52,7 @@ export async function checkLockSync(opts: CheckLockSyncOptions): Promise configured.includes(target)); + const canonicalDrift = modified.length > 0 || added.length > 0 || removed.length > 0 || extendsModified.length > 0; const outputDrift = - outputsModified.length > 0 || outputsRemoved.length > 0 || outputsStale.length > 0; + outputsModified.length > 0 || + outputsRemoved.length > 0 || + outputsStale.length > 0 || + staleTargets.length > 0; const inSync = !canonicalDrift && !outputDrift; return { @@ -153,6 +160,7 @@ export async function checkLockSync(opts: CheckLockSyncOptions): Promise; + /** Targets a `generate --targets` run left out after sources changed; see lock-stale-targets.ts. */ + staleTargets?: string[]; } diff --git a/src/mcp/handlers/orchestrate-types.ts b/src/mcp/handlers/orchestrate-types.ts index dcff34f7..8d9d5560 100644 --- a/src/mcp/handlers/orchestrate-types.ts +++ b/src/mcp/handlers/orchestrate-types.ts @@ -35,6 +35,8 @@ export interface CheckHandlerResult { outputsRemoved: string[]; /** Managed generated outputs present on disk but absent from the lock. */ outputsStale: string[]; + /** Targets a `generate --targets` run left out after canonical sources changed. */ + staleTargets: string[]; /** * True when generated-output drift was verified; false for old-format locks * without an `outputs` map. diff --git a/src/mcp/handlers/orchestrate.ts b/src/mcp/handlers/orchestrate.ts index b1e573a7..75a4bd6d 100644 --- a/src/mcp/handlers/orchestrate.ts +++ b/src/mcp/handlers/orchestrate.ts @@ -106,6 +106,7 @@ async function check(ctx: McpContext): Promise { outputsModified: data.outputsModified, outputsRemoved: data.outputsRemoved, outputsStale: data.outputsStale, + staleTargets: data.staleTargets, outputsChecked: data.outputsChecked, }; } catch (e) { diff --git a/tests/consumer-smoke/src/smoke.ts b/tests/consumer-smoke/src/smoke.ts index 2afa046c..67167cd5 100644 --- a/tests/consumer-smoke/src/smoke.ts +++ b/tests/consumer-smoke/src/smoke.ts @@ -194,6 +194,7 @@ async function exerciseRuntime(): Promise { const checkReport: LockSyncReport = await check(checkOpts); const _checkFromSub: LockSyncReport = await checkFromSub(checkOpts); const _lockConflict: boolean = checkReport.lockConflict; + const _staleTargets: readonly string[] = checkReport.staleTargets; const lessonsGraph: LessonsGraph = loadLessonsGraph('/tmp/noop'); const lessonsQuery: LessonsQuery = { file: 'src/x.ts', command: 'pnpm test' }; @@ -242,6 +243,7 @@ async function exerciseRuntime(): Promise { void _diffSummary; void _checkFromSub; void _lockConflict; + void _staleTargets; void checkReport; void lessonsGraph; void matchedLessons; diff --git a/tests/integration/mcp-generate-lock.integration.test.ts b/tests/integration/mcp-generate-lock.integration.test.ts index 98320250..e26fa476 100644 --- a/tests/integration/mcp-generate-lock.integration.test.ts +++ b/tests/integration/mcp-generate-lock.integration.test.ts @@ -43,6 +43,7 @@ describe('mcp generate — lockfile persistence', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsChecked: true, }); }); diff --git a/tests/unit/cli/commands/generate-filtered-check.test.ts b/tests/unit/cli/commands/generate-filtered-check.test.ts index a98467de..7e2fedb7 100644 --- a/tests/unit/cli/commands/generate-filtered-check.test.ts +++ b/tests/unit/cli/commands/generate-filtered-check.test.ts @@ -1,12 +1,11 @@ /** - * A `generate --targets` run that leaves out an enabled target must not move - * the lock's canonical checksums forward: the skipped target's outputs were - * not regenerated, so `check` must fail until a full generate (#136). + * A `generate --targets` run that leaves out an enabled target after canonical + * sources changed did not regenerate it. The lock lists it in `stale_targets`, + * so `check` fails until that target is generated again (#136). */ import { appendFileSync, - existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -19,6 +18,8 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { runCheck } from '../../../../src/cli/commands/check.js'; import { runGenerate } from '../../../../src/cli/commands/generate.js'; +import { loadConfigFromDir } from '../../../../src/config/core/loader.js'; +import { checkLockSync } from '../../../../src/core/check/lock-sync.js'; let root: string; const ROOT_RULE = (): string => join(root, '.agentsmesh', 'rules', '_root.md'); @@ -69,9 +70,29 @@ describe('check after generate --targets', () => { expect(await checkExit()).toBe(0); }); - it('does not create a lock on a first run that skips a target', async () => { + it('passes after two filtered runs that together cover every target', async () => { + await generate(); + appendFileSync(ROOT_RULE(), '\n- NEW LINE XYZ\n'); + await generate({ targets: 'cursor' }); + await generate({ targets: 'claude-code' }); + + expect(await checkExit()).toBe(0); + }); + + it('records the skipped targets on a first filtered run, and check names them', async () => { + await generate({ targets: 'cursor' }); + + const lock = readFileSync(join(root, '.agentsmesh', '.lock'), 'utf8'); + const report = await checkLockSync({ + config: (await loadConfigFromDir(root)).config, + configDir: root, + canonicalDir: join(root, '.agentsmesh'), + rootBase: root, + }); - expect([existsSync(join(root, '.agentsmesh', '.lock')), await checkExit()]).toEqual([false, 1]); + expect(lock).toContain('stale_targets:\n - claude-code\n'); + expect([report.inSync, report.staleTargets]).toEqual([false, ['claude-code']]); + expect(await checkExit()).toBe(1); }); }); diff --git a/tests/unit/cli/renderers/check-hints.test.ts b/tests/unit/cli/renderers/check-hints.test.ts index c3ac047c..3832cd84 100644 --- a/tests/unit/cli/renderers/check-hints.test.ts +++ b/tests/unit/cli/renderers/check-hints.test.ts @@ -25,6 +25,7 @@ const IN_SYNC: CheckData = { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: true, }; diff --git a/tests/unit/cli/renderers/check.test.ts b/tests/unit/cli/renderers/check.test.ts index e7c2671e..8a8411e9 100644 --- a/tests/unit/cli/renderers/check.test.ts +++ b/tests/unit/cli/renderers/check.test.ts @@ -22,6 +22,7 @@ describe('renderCheck', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: false, }, @@ -47,6 +48,7 @@ describe('renderCheck', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: true, }, @@ -72,6 +74,7 @@ describe('renderCheck', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: true, }, @@ -89,6 +92,34 @@ describe('renderCheck', () => { expect(output.stdout()).toContain("run 'agentsmesh generate --force' to accept the change"); }); + it('names each target a filtered generate left stale', () => { + renderCheck({ + exitCode: 1, + data: { + hasLock: true, + lockConflict: false, + canonicalDrift: false, + outputDrift: true, + inSync: false, + modified: [], + added: [], + removed: [], + extendsModified: [], + lockedViolations: [], + outputsModified: [], + outputsRemoved: [], + outputsStale: [], + staleTargets: ['claude-code'], + outputsUntracked: [], + outputsChecked: true, + }, + }); + + expect(output.stderr()).toContain( + ' target "claude-code" was not generated after the last canonical change', + ); + }); + it('renders generated-output drift with forward-slash paths', () => { renderCheck({ exitCode: 1, @@ -106,6 +137,7 @@ describe('renderCheck', () => { outputsModified: ['.cursor/rules/_root.mdc', 'AGENTS.md'], outputsRemoved: ['.claude/CLAUDE.md'], outputsStale: ['.cursor/rules/orphaned.mdc'], + staleTargets: [], outputsUntracked: [], outputsChecked: true, }, @@ -138,6 +170,7 @@ describe('renderCheck', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: false, }, @@ -164,6 +197,7 @@ describe('renderCheck', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: true, }, @@ -190,6 +224,7 @@ describe('renderCheck', () => { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: false, }, diff --git a/tests/unit/config/lock-stale-targets.test.ts b/tests/unit/config/lock-stale-targets.test.ts new file mode 100644 index 00000000..c6154c74 --- /dev/null +++ b/tests/unit/config/lock-stale-targets.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + nextStaleTargets, + parseStaleTargets, +} from '../../../src/config/core/lock-stale-targets.js'; + +describe('parseStaleTargets', () => { + it('keeps the string entries of a list and drops an empty or wrong value', () => { + expect([ + parseStaleTargets(['cursor', 3, 'claude-code']), + parseStaleTargets([]), + parseStaleTargets('cursor'), + parseStaleTargets(undefined), + ]).toEqual([['cursor', 'claude-code'], undefined, undefined, undefined]); + }); +}); + +describe('nextStaleTargets', () => { + it('marks every skipped target stale when the sources changed', () => { + expect(nextStaleTargets(undefined, ['zed', 'cursor'], true)).toEqual(['cursor', 'zed']); + }); + + it('keeps only already-stale targets that were skipped again when nothing changed', () => { + expect(nextStaleTargets(['cursor', 'zed'], ['zed', 'amp'], false)).toEqual(['zed']); + }); + + it('is empty (undefined) after a full run', () => { + expect(nextStaleTargets(['cursor'], [], true)).toBeUndefined(); + }); +}); diff --git a/tests/unit/mcp/handlers/orchestrate-check.test.ts b/tests/unit/mcp/handlers/orchestrate-check.test.ts index 28fcef3a..31c963f3 100644 --- a/tests/unit/mcp/handlers/orchestrate-check.test.ts +++ b/tests/unit/mcp/handlers/orchestrate-check.test.ts @@ -30,6 +30,7 @@ const IN_SYNC: CheckData = { outputsModified: [], outputsRemoved: [], outputsStale: [], + staleTargets: [], outputsUntracked: [], outputsChecked: true, }; @@ -59,6 +60,7 @@ describe('orchestrateHandlers.check', () => { outputsModified: ['AGENTS.md'], outputsRemoved: ['.claude/CLAUDE.md'], outputsStale: ['.cursor/rules/orphaned.mdc'], + staleTargets: [], }, }); @@ -74,6 +76,7 @@ describe('orchestrateHandlers.check', () => { outputsModified: ['AGENTS.md'], outputsRemoved: ['.claude/CLAUDE.md'], outputsStale: ['.cursor/rules/orphaned.mdc'], + staleTargets: [], outputsChecked: true, }); }); diff --git a/website/src/content/docs/cli/check.mdx b/website/src/content/docs/cli/check.mdx index 85f6fd4d..392f2b61 100644 --- a/website/src/content/docs/cli/check.mdx +++ b/website/src/content/docs/cli/check.mdx @@ -33,11 +33,10 @@ The `.agentsmesh/` sources no longer match the checksums recorded in `.agentsmes - Someone edited `.agentsmesh/` but forgot to run `agentsmesh generate` - A pull request merged changes to canonical config without regenerating - An `extends` source changed and the project hasn't refreshed -- `agentsmesh generate --targets …` ran after a canonical change but left out an enabled target. That target's files were not regenerated, so the lock keeps the older checksums and `check` fails until a full `agentsmesh generate` ### Generated-output drift -A generated file (`.claude/`, `.cursor/`, `AGENTS.md`, etc.) was hand-edited, deleted, or added unexpectedly under a configured target's managed output locations. When `agentsmesh generate` runs, it records an `outputs` map in the lock — a checksum of every generated file it wrote or verified. `agentsmesh check` re-hashes those files on disk and reports any that were changed (`outputsModified`) or deleted (`outputsRemoved`), then scans managed locations for files absent from the lock (`outputsStale`). This catches generated-output drift without running the generators. If a managed location resolves outside the project through a symlink, `check` stops with an error naming where it resolves to instead of reporting drift. +A generated file (`.claude/`, `.cursor/`, `AGENTS.md`, etc.) was hand-edited, deleted, or added unexpectedly under a configured target's managed output locations. When `agentsmesh generate` runs, it records an `outputs` map in the lock — a checksum of every generated file it wrote or verified. `agentsmesh check` re-hashes those files on disk and reports any that were changed (`outputsModified`) or deleted (`outputsRemoved`), then scans managed locations for files absent from the lock (`outputsStale`). It also reports every enabled target that `agentsmesh generate --targets …` left out after a canonical change (`staleTargets`): its files were not regenerated from the new sources, so `check` fails until that target is generated again. This catches generated-output drift without running the generators. If a managed location resolves outside the project through a symlink, `check` stops with an error naming where it resolves to instead of reporting drift. JSON output exposes the two drift classes directly as `canonicalDrift` and `outputDrift`; the per-path arrays remain available for actionable diagnostics. @@ -109,7 +108,7 @@ jobs: | `0` | Canonical sources and recorded generated outputs both match the lock. | | `1` | Drift detected — canonical sources or generated outputs are out of sync — or the project's lessons graph cannot be read. | -The MCP `check` tool mirrors this in its result payload, exposing `canonicalDrift`, `outputDrift`, `outputsModified`, `outputsRemoved`, `outputsStale`, `outputsChecked`, `lockConflict` (true when the lock has git conflict markers; run `agentsmesh merge`), and `lessonsGraphError`. That field holds the same text as the CLI JSON `error` when `.agentsmesh/lessons/lessons.json` cannot be read, so the MCP tool and `agentsmesh check` fail on the same things; it is `null` otherwise. +The MCP `check` tool mirrors this in its result payload, exposing `canonicalDrift`, `outputDrift`, `outputsModified`, `outputsRemoved`, `outputsStale`, `staleTargets`, `outputsChecked`, `lockConflict` (true when the lock has git conflict markers; run `agentsmesh merge`), and `lessonsGraphError`. That field holds the same text as the CLI JSON `error` when `.agentsmesh/lessons/lessons.json` cannot be read, so the MCP tool and `agentsmesh check` fail on the same things; it is `null` otherwise. ## Difference between `check` and `generate --check` diff --git a/website/src/content/docs/cli/generate.mdx b/website/src/content/docs/cli/generate.mdx index 85c36011..064ecbec 100644 --- a/website/src/content/docs/cli/generate.mdx +++ b/website/src/content/docs/cli/generate.mdx @@ -16,7 +16,7 @@ agentsmesh generate [flags] | Flag | Description | |------|-------------| -| `--targets ` | Comma-separated list of target IDs to generate for. Defaults to all configured targets. A run that leaves out an enabled target does not move the lock's canonical checksums forward (and creates no lock if there is none yet), so `agentsmesh check` keeps failing until a full `generate` updates every target. | +| `--targets ` | Comma-separated list of target IDs to generate for. Defaults to all configured targets. If the canonical sources changed, the enabled targets the run left out are recorded in the lock as `stale_targets`, and `agentsmesh check` fails until each of them is generated again. | | `--global` | Generate user-level config from `~/.agentsmesh/` instead of project-local config (e.g. `claude-code`, `antigravity`, `codex-cli`; Cursor writes full `~/.cursor/` global surfaces including `rules/`, `AGENTS.md`, hooks, ignore, MCP, skills, agents, commands). | | `--dry-run` | Preview changes without writing any files. | | `--check` | Verify sync status only. Exit code 1 if out of sync, or if the project's lessons graph cannot be read. | @@ -93,7 +93,7 @@ Forces a re-fetch of all remote `extends` sources. Without this flag, AgentsMesh 3. **Generate per target** — for each enabled target, run target-specific generators for rules, commands, agents, skills, MCP, hooks, ignore, and permissions. 4. **Rewrite references** — internal `.agentsmesh/` file paths are rewritten to target-relative paths. 5. **Resolve collisions** — detect overlapping output paths, prefer native over embedded. -6. **Write output** — create target directories, write all files, update the lock file (canonical-source checksums plus an `outputs` map of every generated file's checksum, so `agentsmesh check` can later detect direct edits to generated files). Filtered runs (`--targets`) merge outputs per-path into the existing map; a full run replaces it. A filtered run that leaves out an enabled target keeps the previous canonical checksums, because that target was not regenerated from the new sources. The lock is rewritten only when this content changes: a run that changes nothing leaves `.agentsmesh/.lock` untouched, so the git tree stays clean. +6. **Write output** — create target directories, write all files, update the lock file (canonical-source checksums plus an `outputs` map of every generated file's checksum, so `agentsmesh check` can later detect direct edits to generated files). Filtered runs (`--targets`) merge outputs per-path into the existing map; a full run replaces it. A filtered run after a canonical change records the enabled targets it left out as `stale_targets`, because they were not regenerated from the new sources. The lock is rewritten only when this content changes: a run that changes nothing leaves `.agentsmesh/.lock` untouched, so the git tree stays clean. 7. **Clean stale files** — remove previously generated files no longer in the output set. This also runs when a full run produces no files at all, so uninstalling the last pack removes its generated outputs and resets the lock's `outputs` map. ## Lessons upkeep diff --git a/website/src/content/docs/reference/generation-pipeline.mdx b/website/src/content/docs/reference/generation-pipeline.mdx index 2696a06e..04685854 100644 --- a/website/src/content/docs/reference/generation-pipeline.mdx +++ b/website/src/content/docs/reference/generation-pipeline.mdx @@ -150,10 +150,11 @@ outputs: - `checksums` — hashes of the canonical `.agentsmesh/` sources (paths relative to `.agentsmesh/`). - `outputs` — hashes of every generated file written or verified at generate time (paths relative to the project root, forward-slashed). +- `stale_targets` — only present after `agentsmesh generate --targets …` left out an enabled target when the canonical sources had changed. Those targets' files were not regenerated from the `checksums` above, so `check` fails until they are generated again. A full `agentsmesh generate` removes the key. `agentsmesh check` reads this file and re-hashes both maps against what's currently on disk. A canonical mismatch or a changed/deleted generated output is reported as drift. -`agentsmesh generate` rewrites the lock only when `checksums`, `extends`, `packs` or `outputs` change. A run that changes nothing leaves the file byte-for-byte as it was, so your git tree stays clean after `generate`, and a teammate's run doesn't create a lock diff. `generated_at`, `generated_by` and `lib_version` therefore describe the last run that changed the lock, not the latest run. +`agentsmesh generate` rewrites the lock only when `checksums`, `extends`, `packs`, `outputs` or `stale_targets` change. A run that changes nothing leaves the file byte-for-byte as it was, so your git tree stays clean after `generate`, and a teammate's run doesn't create a lock diff. `generated_at`, `generated_by` and `lib_version` therefore describe the last run that changed the lock, not the latest run. Filtered generates (`--targets`/`--features`) merge their outputs per-path into the existing `outputs` map without pruning stale entries; a full `agentsmesh generate` replaces the map, which is when entries for disabled targets drop off. `agentsmesh merge` keeps the `outputs` map: it combines the entries of both branches, and where both branches recorded the same file, your branch's hash wins. That hash can be out of date for a file the other branch changed, so until you run `agentsmesh generate`, `check` can report that file as modified. Only when neither branch's lock had an `outputs` map does the merged lock have none, and then `check` skips output verification until you regenerate. diff --git a/website/src/content/docs/reference/programmatic-api.mdx b/website/src/content/docs/reference/programmatic-api.mdx index f36c7bd3..c90bd3b4 100644 --- a/website/src/content/docs/reference/programmatic-api.mdx +++ b/website/src/content/docs/reference/programmatic-api.mdx @@ -241,6 +241,8 @@ if (!report.inSync) { `LockSyncReport.lockedViolations` is the subset of changes that violate `collaboration.lock_features` — useful for distinguishing "drift the team allows" from "drift that should fail CI". +`LockSyncReport.staleTargets` lists enabled targets that a `generate --targets` run left out after the canonical sources changed. Their files were not regenerated, so `inSync` is `false` (and `outputDrift` is `true`) until they are generated again. + `LockSyncReport.lockConflict` is `true` when `.lock` still has git conflict markers. Such a lock cannot be read, so `hasLock` is `false` too. Check `lockConflict` first: the fix is `agentsmesh merge`, not `generate`. ## Plugin registration From 1dc629ffdfd705a72cb2f89334d9d113b7385db1 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 16:26:33 +0200 Subject: [PATCH 24/38] fix(generate): rebase relative links in a root file two targets share A root instruction file claimed by two targets (AGENTS.md for codex-cli and cursor, codex-cli and gemini-cli, cursor and windsurf) skipped reference rewriting entirely, so its copies stayed identical. A relative link in the root rule, like ./typescript.md, then pointed nowhere from the project root and the broken-link check failed the whole run. Shared root files are now rewritten with an identity path translation: Markdown links are rebased onto the canonical file they name, which is the same for every target, so the copies still converge; bare path tokens stay untouched. Fixes #139 --- .changeset/shared-root-links.md | 5 ++ src/core/reference/rewriter.ts | 36 ++++++------ .../core/generate/shared-root-links.test.ts | 55 +++++++++++++++++++ .../docs/reference/generation-pipeline.mdx | 2 + 4 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 .changeset/shared-root-links.md create mode 100644 tests/unit/core/generate/shared-root-links.test.ts diff --git a/.changeset/shared-root-links.md b/.changeset/shared-root-links.md new file mode 100644 index 00000000..c0190645 --- /dev/null +++ b/.changeset/shared-root-links.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +`agentsmesh generate` no longer fails when the root rule links to another rule and two enabled targets write the same `AGENTS.md` (for example Codex CLI and Cursor, Codex CLI and Gemini CLI, or Cursor and Windsurf). The shared `AGENTS.md` keeps canonical references so both copies stay the same, but a relative link such as `[TS rule](./typescript.md)` was left unchanged and pointed to nothing, so the broken-link check stopped the run. Now such links point to the canonical file (`.agentsmesh/rules/typescript.md`). diff --git a/src/core/reference/rewriter.ts b/src/core/reference/rewriter.ts index 6479f82f..db1322db 100644 --- a/src/core/reference/rewriter.ts +++ b/src/core/reference/rewriter.ts @@ -126,7 +126,6 @@ export function rewriteGeneratedReferences( const sourceCache = new Map>(); return results.map((result) => { - if (skipPaths?.has(result.path)) return result; const smKey = sourceMapCacheKey(result.target, activeTargets); const sourceMap = sourceCache.get(smKey) ?? @@ -138,22 +137,27 @@ export function rewriteGeneratedReferences( const sourceFile = sourceMap.get(result.path); if (!sourceFile) return result; + // A root file two targets share keeps canonical references, so its copies + // stay identical and merge. Its relative links are still rebased onto the + // canonical file they name; left as-is they point nowhere (#139). + const shared = skipPaths?.has(result.path) === true; const artifactMapTarget = artifactMapTargetForResult(result, scope, activeTargets); const cacheKey = artifactCacheKey(result, scope, activeTargets); - const artifactMap = - artifactCache.get(cacheKey) ?? - (() => { - const built = buildArtifactPathMap( - artifactMapTarget, - canonical, - config, - projectRoot, - result.path, - { scope }, - ); - artifactCache.set(cacheKey, built); - return built; - })(); + const artifactMap = shared + ? new Map() + : (artifactCache.get(cacheKey) ?? + (() => { + const built = buildArtifactPathMap( + artifactMapTarget, + canonical, + config, + projectRoot, + result.path, + { scope }, + ); + artifactCache.set(cacheKey, built); + return built; + })()); const rewritten = rewriteFileLinks({ content: result.content, projectRoot, @@ -162,7 +166,7 @@ export function rewriteGeneratedReferences( translatePath: (absolutePath) => artifactMap.get(absolutePath) ?? absolutePath, pathExists: (absolutePath) => plannedPaths.has(absolutePath) || existsSync(absolutePath), explicitCurrentDirLinks: true, - rewriteBarePathTokens: true, + rewriteBarePathTokens: !shared, scope, pathIsDirectory: (absolutePath) => { try { diff --git a/tests/unit/core/generate/shared-root-links.test.ts b/tests/unit/core/generate/shared-root-links.test.ts new file mode 100644 index 00000000..4f0f227d --- /dev/null +++ b/tests/unit/core/generate/shared-root-links.test.ts @@ -0,0 +1,55 @@ +/** + * When two targets write the same root file (AGENTS.md), its copies keep + * canonical references so they merge. A relative link in the root rule must + * still be rebased to the canonical file it names, or the shared file links to + * nothing and generate fails (#139). + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; + +let root: string; + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-shared-root-links-'))); + mkdirSync(join(root, '.agentsmesh', 'rules'), { recursive: true }); + writeFileSync( + join(root, '.agentsmesh', 'rules', '_root.md'), + '---\nroot: true\n---\n# Root\nSee [TS rule](./typescript.md).\n', + ); + writeFileSync( + join(root, '.agentsmesh', 'rules', 'typescript.md'), + '---\ndescription: ts\nglobs: ["src/**/*.ts"]\n---\n# TS\n', + ); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('a relative link in a root rule shared by two AGENTS.md targets', () => { + it.each([ + ['codex-cli', 'cursor'], + ['codex-cli', 'gemini-cli'], + ['cursor', 'windsurf'], + ])('%s + %s: generate succeeds and the link points to the canonical rule', async (a, b) => { + writeFileSync( + join(root, 'agentsmesh.yaml'), + `version: 1\ntargets: [${a}, ${b}]\nfeatures: [rules]\n`, + ); + + const { exitCode } = await runGenerate({}, root, { printMatrix: false }); + + const link = /\[TS rule\]\(([^)]+)\)/.exec(readFileSync(join(root, 'AGENTS.md'), 'utf8'))?.[1]; + expect([exitCode, link]).toEqual([0, '.agentsmesh/rules/typescript.md']); + expect(existsSync(join(root, link ?? ''))).toBe(true); + }); +}); diff --git a/website/src/content/docs/reference/generation-pipeline.mdx b/website/src/content/docs/reference/generation-pipeline.mdx index 04685854..5bc35aad 100644 --- a/website/src/content/docs/reference/generation-pipeline.mdx +++ b/website/src/content/docs/reference/generation-pipeline.mdx @@ -57,6 +57,8 @@ Internal file references are rewritten from canonical paths to target-relative p This keeps cross-file links valid in each tool's native directory. +A root instruction file that two enabled targets both write (for example `AGENTS.md` for Codex CLI and Cursor) keeps canonical references instead, so both copies are identical and merge into one file. Its Markdown links are still rebased: `[TS rule](./typescript.md)` in `.agentsmesh/rules/_root.md` becomes `[TS rule](.agentsmesh/rules/typescript.md)` in `AGENTS.md`, a file that exists for every target. + Generated-only managed blocks and canonical embedded payloads are protected from rewriting when their contents must stay literal. So are URLs — including a URL whose path contains parentheses, such as a Next.js route group (`apps/(marketing)/package.json`). Generated Markdown is then checked for broken local links. Three shapes are deliberately not treated as links, so ordinary prose never fails a run: link syntax quoted inside inline code (`` `[label](path.md)` ``, which is how a rule documents the syntax), GFM footnote definitions (`[^1]: prose`), and anything inside a fenced code block. Note that a path written inside backticks is still *rebased* — only the broken-link check skips it. From aaeeabe1d71f906e8acd495616971d11fc149e35 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 16:51:54 +0200 Subject: [PATCH 25/38] fix(codex-cli): restore nested AGENTS.md rules to their canonical files on import codex-cli (and codebuff, which writes the same paths) wrote a scoped rule into a nested /AGENTS.md as plain text, so import read it as a new .md rule and every import/generate cycle repeated the text in src/AGENTS.md and copied it to other targets. Each rule in a nested file is now a bare agentsmesh:embedded-rule entry naming its canonical source, description, globs and targets. Bare entries are not a protected block, so their links are still rewritten for the nested file, and codex's entries stay a prefix of codebuff's so the shared path still resolves by containment. The codex-cli, codebuff and windsurf nested importers split the entries back to their canonical files via splitNestedAgentsFile (keeping the Codex override variant) and import only the remaining hand-written text as the directory rule. The per-rule entry code moved from managed-blocks.ts to embedded-rule-entries.ts, and the windsurf nested import to its own file, to stay under 200 lines. Fixes #140 --- .changeset/codex-nested-rules-roundtrip.md | 5 + src/targets/codebuff/generator.ts | 8 +- src/targets/codebuff/import-rules.ts | 36 ++++-- src/targets/codex-cli/generator/rules.ts | 8 +- src/targets/codex-cli/importer-rules.ts | 29 +++-- src/targets/import/embedded-rules.ts | 37 +++++- .../projection/embedded-rule-entries.ts | 109 ++++++++++++++++ src/targets/projection/managed-blocks.ts | 112 ++--------------- src/targets/windsurf/import-nested-agents.ts | 66 ++++++++++ src/targets/windsurf/importer.ts | 37 +----- tests/contract/contracts/codebuff.ts | 2 +- tests/contract/contracts/codex-cli.ts | 2 +- tests/e2e/codex-format-roundtrip.e2e.test.ts | 22 ++-- .../codebuff/generate-contract.test.ts | 4 +- tests/unit/targets/codebuff/generator.test.ts | 38 +++--- .../targets/codebuff/nested-rules.test.ts | 22 ++-- .../unit/targets/codex-cli/generator.test.ts | 13 +- .../codex-cli/nested-rules-roundtrip.test.ts | 119 ++++++++++++++++++ .../docs/reference/managed-embedding.mdx | 6 +- 19 files changed, 452 insertions(+), 223 deletions(-) create mode 100644 .changeset/codex-nested-rules-roundtrip.md create mode 100644 src/targets/projection/embedded-rule-entries.ts create mode 100644 src/targets/windsurf/import-nested-agents.ts create mode 100644 tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts diff --git a/.changeset/codex-nested-rules-roundtrip.md b/.changeset/codex-nested-rules-roundtrip.md new file mode 100644 index 00000000..acdb2263 --- /dev/null +++ b/.changeset/codex-nested-rules-roundtrip.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +Importing generated Codex CLI or Codebuff output back no longer adds duplicate rules. A scoped rule, for example `globs: ["src/**/*.ts"]`, is written to a nested `src/AGENTS.md`. `agentsmesh import --from codex-cli` (or `codebuff` or `windsurf`) read that file as a new rule `.agentsmesh/rules/src.md`, and each import and generate cycle then repeated the rule text in `src/AGENTS.md` and copied it to every other target. Now each rule in a nested `AGENTS.md` is written as an `agentsmesh:embedded-rule` entry that names its canonical source, so import restores the original rule (with its globs and Codex instruction variant) and only hand-written text becomes a directory rule. If an earlier cycle already created such a rule, such as `.agentsmesh/rules/src.md`, delete it once. diff --git a/src/targets/codebuff/generator.ts b/src/targets/codebuff/generator.ts index b73f711c..15cda6ce 100644 --- a/src/targets/codebuff/generator.ts +++ b/src/targets/codebuff/generator.ts @@ -22,6 +22,7 @@ import type { FeatureGeneratorOutput } from '../catalog/target.interface.js'; import type { CanonicalFiles } from '../../core/types.js'; import { generateEmbeddedSkills } from '../import/embedded-skill.js'; import { appendEmbeddedRulesBlock } from '../projection/managed-blocks.js'; +import { renderEmbeddedRuleEntries } from '../projection/embedded-rule-entries.js'; import { commandSkillDirName, serializeCommandSkill } from '../codex-cli/command-skill.js'; import { eligibleRules, groupByNestedPath } from './nested-rules.js'; import { serializeCodebuffMcp } from './mcp-format.js'; @@ -40,11 +41,10 @@ export function generateRules(canonical: CanonicalFiles): CodebuffOutput[] { const rootBody = canonical.rules.find((rule) => rule.root)?.body.trim() ?? ''; if (rootBody) outputs.push({ path: CODEBUFF_ROOT_FILE, content: rootBody }); + // Embedded-rule entries in Codex's order, so Codex's file stays a prefix of + // this one and import restores each rule to its own canonical file. for (const [path, rules] of groupByNestedPath(eligibleRules(canonical))) { - const content = rules - .map((rule) => rule.body.trim()) - .filter((body) => body.length > 0) - .join('\n\n'); + const content = renderEmbeddedRuleEntries(rules.filter((rule) => rule.body.trim().length > 0)); if (content) outputs.push({ path, content }); } diff --git a/src/targets/codebuff/import-rules.ts b/src/targets/codebuff/import-rules.ts index ac5edce6..927e8f83 100644 --- a/src/targets/codebuff/import-rules.ts +++ b/src/targets/codebuff/import-rules.ts @@ -24,7 +24,7 @@ import type { TargetLayoutScope } from '../catalog/target-descriptor.js'; import { mkdirp, readFileSafe, writeFileAtomic } from '../../utils/filesystem/fs.js'; import { parseFrontmatter } from '../../utils/text/markdown.js'; import { serializeImportedRuleWithFallback } from '../import/import-metadata.js'; -import { splitEmbeddedRulesToCanonical } from '../import/embedded-rules.js'; +import { splitEmbeddedRulesToCanonical, splitNestedAgentsFile } from '../import/embedded-rules.js'; import { importFileDirectory } from '../import/import-orchestrator.js'; import { shouldImportScopedAgentsRule } from '../import/scoped-agents-import.js'; import { CODEBUFF_TARGET, CODEBUFF_ROOT_FILE, CODEBUFF_GLOBAL_ROOT_FILE } from './constants.js'; @@ -89,34 +89,48 @@ async function importNestedRules( normalize: Normalizer, ): Promise { const destDir = join(projectRoot, AB_RULES); - return importFileDirectory({ + const embedded: ImportResult[] = []; + const results = await importFileDirectory({ srcDir: projectRoot, destDir, extensions: [CODEBUFF_ROOT_FILE], fromTool: CODEBUFF_TARGET, normalize, - mapEntry: ({ srcPath, normalizeTo }) => { + mapEntry: async ({ srcPath, content, normalizeTo }) => { if (basename(srcPath) !== CODEBUFF_ROOT_FILE) return null; const relDir = relative(projectRoot, dirname(srcPath)).replace(/\\/g, '/'); if (!relDir || relDir === '.') return null; if (!shouldImportScopedAgentsRule(relDir)) return null; if (isVendored(relDir)) return null; + const ownText = await splitNestedAgentsFile( + { + content, + projectRoot, + rulesDir: AB_RULES, + sourcePath: srcPath, + fromTool: CODEBUFF_TARGET, + normalize, + }, + embedded, + ); + if (ownText === null) return null; const ruleName = relDir.replace(/\//g, '-'); const destPath = join(destDir, `${ruleName}.md`); - const { frontmatter, body } = parseFrontmatter(normalizeTo(destPath)); - return serializeImportedRuleWithFallback( - destPath, - { ...frontmatter, root: false, globs: [`${relDir}/**`] }, - body, - ).then((content) => ({ + const { frontmatter, body } = parseFrontmatter(normalizeTo(destPath, ownText)); + return { destPath, toPath: `${AB_RULES}/${ruleName}.md`, feature: 'rules', - content, - })); + content: await serializeImportedRuleWithFallback( + destPath, + { ...frontmatter, root: false, globs: [`${relDir}/**`] }, + body, + ), + }; }, }); + return [...results, ...embedded]; } export async function importCodebuffRules( diff --git a/src/targets/codex-cli/generator/rules.ts b/src/targets/codex-cli/generator/rules.ts index 23476627..84942455 100644 --- a/src/targets/codex-cli/generator/rules.ts +++ b/src/targets/codex-cli/generator/rules.ts @@ -1,5 +1,6 @@ import type { CanonicalFiles, CanonicalRule } from '../../../core/types.js'; import { appendEmbeddedRulesBlock } from '../../projection/managed-blocks.js'; +import { renderEmbeddedRuleEntries } from '../../projection/embedded-rule-entries.js'; import { AGENTS_MD, CODEX_RULES_DIR } from '../constants.js'; import { codexNestedAgentsPath } from '../codex-rule-paths.js'; import type { RulesOutput } from './types.js'; @@ -87,12 +88,11 @@ export function generateRules(canonical: CanonicalFiles): RulesOutput[] { }); } + // Each rule is an embedded-rule entry, so import can restore it to its own + // canonical file instead of adding a `.md` rule (#140). const nested = advisory.filter((rule) => !isRootEmbedded(rule)); for (const [path, rules] of groupByNestedPath(nested)) { - const content = rules - .map((rule) => rule.body.trim()) - .filter((body) => body.length > 0) - .join('\n\n'); + const content = renderEmbeddedRuleEntries(rules.filter((rule) => rule.body.trim().length > 0)); outputs.push({ path, content }); } diff --git a/src/targets/codex-cli/importer-rules.ts b/src/targets/codex-cli/importer-rules.ts index f7c34836..bae96b89 100644 --- a/src/targets/codex-cli/importer-rules.ts +++ b/src/targets/codex-cli/importer-rules.ts @@ -10,7 +10,7 @@ import { } from '../../utils/filesystem/fs.js'; import { parseFrontmatter } from '../../utils/text/markdown.js'; import { serializeImportedRuleWithFallback } from '../import/import-metadata.js'; -import { splitEmbeddedRulesToCanonical } from '../import/embedded-rules.js'; +import { splitEmbeddedRulesToCanonical, splitNestedAgentsFile } from '../import/embedded-rules.js'; import { importFileDirectory } from '../import/import-orchestrator.js'; import { shouldImportScopedAgentsRule, @@ -101,6 +101,7 @@ export async function importCodexRules( results.push(...(await importCodexNonRootRuleFiles(projectRoot, destDir, normalize))); if (layoutScope !== 'global') { + const embedded: ImportResult[] = []; results.push( ...(await importFileDirectory({ srcDir: projectRoot, @@ -108,7 +109,7 @@ export async function importCodexRules( extensions: ['AGENTS.md', 'AGENTS.override.md'], fromTool: 'codex-cli', normalize, - mapEntry: async ({ srcPath, normalizeTo }) => { + mapEntry: async ({ srcPath, content, normalizeTo }) => { const relDir = relative(projectRoot, dirname(srcPath)).replace(/\\/g, '/'); const fileName = basename(srcPath); const isOverride = fileName === 'AGENTS.override.md'; @@ -119,26 +120,36 @@ export async function importCodexRules( await removePathIfExists(join(destDir, `${ruleName}.md`)); return null; } + const variant = isOverride ? { codex_instruction: 'override' } : {}; + const ownText = await splitNestedAgentsFile( + { + content, + projectRoot, + rulesDir: AB_RULES, + sourcePath: srcPath, + fromTool: 'codex-cli', + normalize, + frontmatter: variant, + }, + embedded, + ); + if (ownText === null) return null; const destPath = join(destDir, `${ruleName}.md`); - const { frontmatter, body } = parseFrontmatter(normalizeTo(destPath)); + const { frontmatter, body } = parseFrontmatter(normalizeTo(destPath, ownText)); return { destPath, toPath: `${AB_RULES}/${ruleName}.md`, feature: 'rules', content: await serializeImportedRuleWithFallback( destPath, - { - ...frontmatter, - root: false, - globs: [`${relDir}/**`], - ...(isOverride ? { codex_instruction: 'override' } : {}), - }, + { ...frontmatter, root: false, globs: [`${relDir}/**`], ...variant }, body, ), }; }, })), ); + results.push(...embedded); } } diff --git a/src/targets/import/embedded-rules.ts b/src/targets/import/embedded-rules.ts index ada3905f..40e9ded7 100644 --- a/src/targets/import/embedded-rules.ts +++ b/src/targets/import/embedded-rules.ts @@ -3,6 +3,10 @@ import type { ImportResult } from '../../core/types.js'; import { mkdirp, writeFileAtomic } from '../../utils/filesystem/fs.js'; import { parseFrontmatter } from '../../utils/text/markdown.js'; import { extractEmbeddedRules } from '../projection/managed-blocks.js'; +import { + takeEmbeddedRuleEntries, + type ExtractedEmbeddedRule, +} from '../projection/embedded-rule-entries.js'; import { serializeImportedRuleWithFallback } from './import-metadata.js'; export interface SplitEmbeddedRulesInput { @@ -12,6 +16,8 @@ export interface SplitEmbeddedRulesInput { sourcePath: string; fromTool: string; normalize: (content: string, sourceFile: string, destinationFile: string) => string; + /** Extra frontmatter for every restored rule (e.g. the file's Codex instruction variant). */ + frontmatter?: Record; } export interface SplitEmbeddedRulesResult { @@ -30,13 +36,19 @@ export async function splitEmbeddedRulesToCanonical( input: SplitEmbeddedRulesInput, ): Promise { const extracted = extractEmbeddedRules(input.content); + const results = await writeEmbeddedRules(extracted.rules, input); + return { rootContent: extracted.rootContent, results }; +} + +async function writeEmbeddedRules( + rules: readonly ExtractedEmbeddedRule[], + input: SplitEmbeddedRulesInput, +): Promise { const results: ImportResult[] = []; - if (extracted.rules.length === 0) { - return { rootContent: extracted.rootContent, results }; - } + if (rules.length === 0) return results; await mkdirp(join(input.projectRoot, input.rulesDir)); - for (const rule of extracted.rules) { + for (const rule of rules) { const canonicalSource = canonicalRulePath(rule.source); if (canonicalSource === null || canonicalSource === 'rules/_root.md') continue; const destPath = join(input.projectRoot, '.agentsmesh', canonicalSource); @@ -46,6 +58,7 @@ export async function splitEmbeddedRulesToCanonical( destPath, { ...frontmatter, + ...input.frontmatter, root: false, description: rule.description || undefined, globs: rule.globs.length > 0 ? rule.globs : undefined, @@ -61,5 +74,19 @@ export async function splitEmbeddedRulesToCanonical( feature: 'rules', }); } - return { rootContent: extracted.rootContent, results }; + return results; +} + +/** + * Split a nested `/AGENTS.md`: its embedded rules go back to their own + * canonical files (their results are added to `into`), and the text left over + * is returned as the directory's own rule, or null when there is none (#140). + */ +export async function splitNestedAgentsFile( + input: SplitEmbeddedRulesInput, + into: ImportResult[], +): Promise { + const { rest, rules } = takeEmbeddedRuleEntries(input.content); + into.push(...(await writeEmbeddedRules(rules, input))); + return rest.length > 0 ? rest : null; } diff --git a/src/targets/projection/embedded-rule-entries.ts b/src/targets/projection/embedded-rule-entries.ts new file mode 100644 index 00000000..3507a39c --- /dev/null +++ b/src/targets/projection/embedded-rule-entries.ts @@ -0,0 +1,109 @@ +/** + * One embedded rule entry: a start marker carrying the rule's canonical source + * and metadata, its body, and an end marker. Entries sit inside the protected + * embedded-rules block of a root file, or bare in a nested `/AGENTS.md`, + * where their links are still rewritten and import restores each rule to its + * own canonical file (#140). + */ + +import { basename, join } from 'node:path'; +import type { CanonicalRule } from '../../core/types.js'; + +export const EMBEDDED_RULE_END = ''; +const START_PREFIX = ''; + +interface EmbeddedRuleMarker { + source: string; + description: string; + globs: string[]; + targets: string[]; +} + +export interface ExtractedEmbeddedRule extends EmbeddedRuleMarker { + body: string; +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function ruleSource(source: string): string { + const normalized = source.replace(/\\/g, '/'); + const meshIndex = normalized.lastIndexOf('.agentsmesh/'); + if (meshIndex >= 0) return normalized.slice(meshIndex + '.agentsmesh/'.length); + if (normalized.startsWith('rules/')) return normalized; + return join('rules', basename(normalized)).replace(/\\/g, '/'); +} + +export function renderEmbeddedRule(rule: CanonicalRule): string { + const marker: EmbeddedRuleMarker = { + source: ruleSource(rule.source), + description: rule.description, + globs: rule.globs, + targets: rule.targets, + }; + const parts = [`${START_PREFIX}${JSON.stringify(marker)}${START_SUFFIX}`]; + if (rule.description.trim()) { + parts.push(`## ${rule.description.trim()}`, ''); + } + parts.push(rule.body.trim(), EMBEDDED_RULE_END); + return parts.filter((part) => part.length > 0).join('\n'); +} + +/** Bare entries for a nested `/AGENTS.md`, one after another. */ +export function renderEmbeddedRuleEntries(rules: readonly CanonicalRule[]): string { + return rules.map(renderEmbeddedRule).join('\n\n'); +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : []; +} + +function parseMarker(value: string): EmbeddedRuleMarker | null { + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (typeof record.source !== 'string' || !record.source.startsWith('rules/')) return null; + return { + source: record.source, + description: typeof record.description === 'string' ? record.description : '', + globs: toStringArray(record.globs), + targets: toStringArray(record.targets), + }; + } catch { + return null; + } +} + +function stripGeneratedHeading(body: string, description: string): string { + const trimmed = body.trim(); + if (!description.trim()) return trimmed; + const heading = `## ${description.trim()}`; + return trimmed.startsWith(heading) ? trimmed.slice(heading.length).trim() : trimmed; +} + +/** + * Take every entry out of `text`: returns the rules and the trimmed text left. + * An entry whose marker cannot be read is left in place. + */ +export function takeEmbeddedRuleEntries(text: string): { + rest: string; + rules: ExtractedEmbeddedRule[]; +} { + const rules: ExtractedEmbeddedRule[] = []; + const entry = new RegExp( + `${escapeRegExp(START_PREFIX)}([\\s\\S]*?)${escapeRegExp(START_SUFFIX)}([\\s\\S]*?)${escapeRegExp(EMBEDDED_RULE_END)}`, + 'g', + ); + const rest = text.replace(entry, (whole: string, markerText: string, body: string) => { + const marker = parseMarker(markerText); + if (!marker) return whole; + rules.push({ ...marker, body: stripGeneratedHeading(body, marker.description) }); + return ''; + }); + return { rest: rest.trim(), rules }; +} diff --git a/src/targets/projection/managed-blocks.ts b/src/targets/projection/managed-blocks.ts index 64a702d2..88a63e29 100644 --- a/src/targets/projection/managed-blocks.ts +++ b/src/targets/projection/managed-blocks.ts @@ -1,6 +1,13 @@ import { splitFrontmatter } from '../../utils/text/markdown.js'; -import { basename, join } from 'node:path'; import type { CanonicalRule, CanonicalFiles } from '../../core/types.js'; +import { + escapeRegExp, + renderEmbeddedRule, + takeEmbeddedRuleEntries, + type ExtractedEmbeddedRule, +} from './embedded-rule-entries.js'; + +export { EMBEDDED_RULE_END, type ExtractedEmbeddedRule } from './embedded-rule-entries.js'; export const ROOT_CONTRACT_START = ''; export const ROOT_CONTRACT_END = ''; @@ -8,35 +15,12 @@ export const LESSONS_CONTRACT_START = ''; export const EMBEDDED_RULES_START = ''; export const EMBEDDED_RULES_END = ''; -export const EMBEDDED_RULE_END = ''; - -const EMBEDDED_RULE_START_PREFIX = ''; - -interface EmbeddedRuleMarker { - source: string; - description: string; - globs: string[]; - targets: string[]; -} - -export interface ExtractedEmbeddedRule { - source: string; - description: string; - globs: string[]; - targets: string[]; - body: string; -} export interface ExtractedEmbeddedRules { rootContent: string; rules: ExtractedEmbeddedRule[]; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - function managedBlockPattern(start: string, end: string): RegExp { return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`, 'g'); } @@ -82,39 +66,9 @@ export function insertAtBodyTop(content: string, block: string): string { return prefix ? `${prefix}\n\n${placed}` : placed; } -function ruleSource(source: string): string { - const normalized = source.replace(/\\/g, '/'); - const meshIndex = normalized.lastIndexOf('.agentsmesh/'); - if (meshIndex >= 0) return normalized.slice(meshIndex + '.agentsmesh/'.length); - if (normalized.startsWith('rules/')) return normalized; - return join('rules', basename(normalized)).replace(/\\/g, '/'); -} - -function markerForRule(rule: CanonicalRule): EmbeddedRuleMarker { - return { - source: ruleSource(rule.source), - description: rule.description, - globs: rule.globs, - targets: rule.targets, - }; -} - -function embeddedRuleStart(rule: CanonicalRule): string { - return `${EMBEDDED_RULE_START_PREFIX}${JSON.stringify(markerForRule(rule))}${EMBEDDED_RULE_START_SUFFIX}`; -} - -function renderRule(rule: CanonicalRule): string { - const parts = [embeddedRuleStart(rule)]; - if (rule.description.trim()) { - parts.push(`## ${rule.description.trim()}`, ''); - } - parts.push(rule.body.trim(), EMBEDDED_RULE_END); - return parts.filter((part) => part.length > 0).join('\n'); -} - export function renderEmbeddedRulesBlock(rules: readonly CanonicalRule[]): string { if (rules.length === 0) return ''; - return [EMBEDDED_RULES_START, ...rules.map(renderRule), EMBEDDED_RULES_END].join('\n'); + return [EMBEDDED_RULES_START, ...rules.map(renderEmbeddedRule), EMBEDDED_RULES_END].join('\n'); } export function appendEmbeddedRulesBlock(content: string, rules: readonly CanonicalRule[]): string { @@ -124,56 +78,12 @@ export function appendEmbeddedRulesBlock(content: string, rules: readonly Canoni return withoutExisting ? `${withoutExisting}\n\n${block}` : block; } -function toStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : []; -} - -function parseMarker(value: string): EmbeddedRuleMarker | null { - try { - const parsed: unknown = JSON.parse(value); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - const record = parsed as Record; - if (typeof record.source !== 'string' || !record.source.startsWith('rules/')) return null; - return { - source: record.source, - description: typeof record.description === 'string' ? record.description : '', - globs: toStringArray(record.globs), - targets: toStringArray(record.targets), - }; - } catch { - return null; - } -} - -function stripGeneratedHeading(body: string, description: string): string { - const trimmed = body.trim(); - if (!description.trim()) return trimmed; - const heading = `## ${description.trim()}`; - return trimmed.startsWith(heading) ? trimmed.slice(heading.length).trim() : trimmed; -} - export function extractEmbeddedRules(content: string): ExtractedEmbeddedRules { const rules: ExtractedEmbeddedRule[] = []; const outerPattern = managedBlockPattern(EMBEDDED_RULES_START, EMBEDDED_RULES_END); const rootContent = content.replace(outerPattern, (block) => { - const inner = block.replace(EMBEDDED_RULES_START, '').replace(EMBEDDED_RULES_END, '').trim(); - const entryPattern = new RegExp( - `${escapeRegExp(EMBEDDED_RULE_START_PREFIX)}([\\s\\S]*?)${escapeRegExp(EMBEDDED_RULE_START_SUFFIX)}([\\s\\S]*?)${escapeRegExp(EMBEDDED_RULE_END)}`, - 'g', - ); - for (const match of inner.matchAll(entryPattern)) { - const markerText = match[1]; - const body = match[2]; - if (markerText === undefined || body === undefined) continue; - const marker = parseMarker(markerText); - if (!marker) continue; - rules.push({ - ...marker, - body: stripGeneratedHeading(body, marker.description), - }); - } + const inner = block.replace(EMBEDDED_RULES_START, '').replace(EMBEDDED_RULES_END, ''); + rules.push(...takeEmbeddedRuleEntries(inner).rules); return ''; }); return { rootContent: rootContent.trim(), rules }; diff --git a/src/targets/windsurf/import-nested-agents.ts b/src/targets/windsurf/import-nested-agents.ts new file mode 100644 index 00000000..b5ec6931 --- /dev/null +++ b/src/targets/windsurf/import-nested-agents.ts @@ -0,0 +1,66 @@ +/** + * Windsurf reads a nested `/AGENTS.md` as a rule for that directory. + * Embedded rule blocks in it (written by Codex CLI or Codebuff) go back to + * their own canonical files; only the text outside them is the directory rule. + */ + +import { basename, dirname, join, relative } from 'node:path'; +import { AB_RULES } from '../../core/canonical-paths.js'; +import type { ImportResult } from '../../core/types.js'; +import { splitNestedAgentsFile } from '../import/embedded-rules.js'; +import { serializeImportedRuleWithFallback } from '../import/import-metadata.js'; +import { importFileDirectory } from '../import/import-orchestrator.js'; +import { + removePathIfExists, + shouldImportScopedAgentsRule, +} from '../import/scoped-agents-import.js'; + +type Normalizer = (content: string, sourceFile: string, destinationFile: string) => string; + +export async function importWindsurfNestedAgents( + projectRoot: string, + normalize: Normalizer, +): Promise { + const destRulesDir = join(projectRoot, AB_RULES); + const embedded: ImportResult[] = []; + const results = await importFileDirectory({ + srcDir: projectRoot, + destDir: destRulesDir, + extensions: ['AGENTS.md'], + fromTool: 'windsurf', + normalize, + mapEntry: async ({ srcPath, content, normalizeTo }) => { + const relDir = relative(projectRoot, dirname(srcPath)).replace(/\\/g, '/'); + if (!relDir || relDir === '.' || basename(srcPath) !== 'AGENTS.md') return null; + const ruleName = relDir.replace(/\//g, '-'); + if (!shouldImportScopedAgentsRule(relDir)) { + await removePathIfExists(join(destRulesDir, `${ruleName}.md`)); + return null; + } + const ownText = await splitNestedAgentsFile( + { + content, + projectRoot, + rulesDir: AB_RULES, + sourcePath: srcPath, + fromTool: 'windsurf', + normalize, + }, + embedded, + ); + if (ownText === null) return null; + const destPath = join(destRulesDir, `${ruleName}.md`); + return { + destPath, + toPath: `${AB_RULES}/${ruleName}.md`, + feature: 'rules', + content: await serializeImportedRuleWithFallback( + destPath, + { root: false, globs: [`${relDir}/**`] }, + normalizeTo(destPath, ownText), + ), + }; + }, + }); + return [...results, ...embedded]; +} diff --git a/src/targets/windsurf/importer.ts b/src/targets/windsurf/importer.ts index 02686ef2..ea558123 100644 --- a/src/targets/windsurf/importer.ts +++ b/src/targets/windsurf/importer.ts @@ -10,7 +10,7 @@ */ import { AB_IGNORE, AB_RULES } from '../../core/canonical-paths.js'; -import { basename, join, dirname, relative } from 'node:path'; +import { join } from 'node:path'; import type { ImportResult } from '../../core/types.js'; import type { TargetLayoutScope } from '../catalog/target-descriptor.js'; import { createImportReferenceNormalizer } from '../../core/reference/import-rewriter.js'; @@ -18,10 +18,7 @@ import { readFileSafe, writeFileAtomic, mkdirp } from '../../utils/filesystem/fs import { parseFrontmatter } from '../../utils/text/markdown.js'; import { serializeImportedRuleWithFallback } from '../import/import-metadata.js'; import { importFileDirectory } from '../import/import-orchestrator.js'; -import { - removePathIfExists, - shouldImportScopedAgentsRule, -} from '../import/scoped-agents-import.js'; +import { importWindsurfNestedAgents } from './import-nested-agents.js'; import { WINDSURF_TARGET, WINDSURF_RULES_ROOT, @@ -93,35 +90,7 @@ export async function importFromWindsurf( } if (layoutScope !== 'global') { - results.push( - ...(await importFileDirectory({ - srcDir: projectRoot, - destDir: destRulesDir, - extensions: ['AGENTS.md'], - fromTool: 'windsurf', - normalize, - mapEntry: async ({ srcPath, normalizeTo }) => { - const relDir = relative(projectRoot, dirname(srcPath)).replace(/\\/g, '/'); - if (!relDir || relDir === '.' || basename(srcPath) !== 'AGENTS.md') return null; - const ruleName = relDir.replace(/\//g, '-'); - if (!shouldImportScopedAgentsRule(relDir)) { - await removePathIfExists(join(destRulesDir, `${ruleName}.md`)); - return null; - } - const destPath = join(destRulesDir, `${ruleName}.md`); - return { - destPath, - toPath: `${AB_RULES}/${ruleName}.md`, - feature: 'rules', - content: await serializeImportedRuleWithFallback( - destPath, - { root: false, globs: [`${relDir}/**`] }, - normalizeTo(destPath), - ), - }; - }, - })), - ); + results.push(...(await importWindsurfNestedAgents(projectRoot, normalize))); } const rulesDir = join(projectRoot, WINDSURF_RULES_DIR); diff --git a/tests/contract/contracts/codebuff.ts b/tests/contract/contracts/codebuff.ts index 3f3edc59..995bb90d 100644 --- a/tests/contract/contracts/codebuff.ts +++ b/tests/contract/contracts/codebuff.ts @@ -16,7 +16,7 @@ export const codebuffContract: TargetPathContract = { '.agentsmesh/ignore', '.agentsmesh/mcp.json', '.agentsmesh/rules/_root.md', - '.agentsmesh/rules/src.md', + '.agentsmesh/rules/typescript.md', '.agentsmesh/skills/api-generator/SKILL.md', '.agentsmesh/skills/api-generator/references/route-checklist.md', '.agentsmesh/skills/api-generator/template.ts', diff --git a/tests/contract/contracts/codex-cli.ts b/tests/contract/contracts/codex-cli.ts index c735166b..e47c0157 100644 --- a/tests/contract/contracts/codex-cli.ts +++ b/tests/contract/contracts/codex-cli.ts @@ -22,7 +22,7 @@ export const codexCliContract: TargetPathContract = { '.agentsmesh/mcp.json', '.agentsmesh/permissions.yaml', '.agentsmesh/rules/_root.md', - '.agentsmesh/rules/src.md', + '.agentsmesh/rules/typescript.md', '.agentsmesh/skills/api-generator/SKILL.md', '.agentsmesh/skills/api-generator/references/route-checklist.md', '.agentsmesh/skills/api-generator/template.ts', diff --git a/tests/e2e/codex-format-roundtrip.e2e.test.ts b/tests/e2e/codex-format-roundtrip.e2e.test.ts index 38cf7d25..a0b4473d 100644 --- a/tests/e2e/codex-format-roundtrip.e2e.test.ts +++ b/tests/e2e/codex-format-roundtrip.e2e.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { cleanup, createTestProject } from './helpers/setup.js'; import { runCli } from './helpers/run-cli.js'; @@ -211,19 +211,13 @@ describe('codex-cli format contract roundtrip', () => { expect(importResult.exitCode, importResult.stderr).toBe(0); fileContains(join(dir, '.agentsmesh', 'rules', '_root.md'), 'root: true'); - // Nested AGENTS.md files round-trip renamed to their directory (Codex has no - // concept of the original canonical slug — see codex-rule-paths.ts / importer-rules.ts). - fileContains(join(dir, '.agentsmesh', 'rules', 'src.md'), 'globs:'); - fileContains(join(dir, '.agentsmesh', 'rules', 'src.md'), ' - src/**'); - fileContains( - join(dir, '.agentsmesh', 'rules', 'services-payments.md'), - 'codex_instruction: override', - ); - fileContains(join(dir, '.agentsmesh', 'rules', 'services-payments.md'), 'globs:'); - fileContains( - join(dir, '.agentsmesh', 'rules', 'services-payments.md'), - ' - services/payments/**', - ); + // Nested AGENTS.md files carry an embedded-rule entry per rule, so each + // rule returns to its own canonical file with its own globs (#140). + fileContains(join(dir, '.agentsmesh', 'rules', 'typescript.md'), ' - src/**/*.ts'); + fileContains(join(dir, '.agentsmesh', 'rules', 'payments.md'), 'codex_instruction: override'); + fileContains(join(dir, '.agentsmesh', 'rules', 'payments.md'), ' - services/payments/**'); + expect(existsSync(join(dir, '.agentsmesh', 'rules', 'src.md'))).toBe(false); + expect(existsSync(join(dir, '.agentsmesh', 'rules', 'services-payments.md'))).toBe(false); fileContains(join(dir, '.agentsmesh', 'rules', 'default.md'), 'codex_emit: execution'); fileContains(join(dir, '.agentsmesh', 'rules', 'default.md'), 'prefix_rule('); diff --git a/tests/unit/targets/codebuff/generate-contract.test.ts b/tests/unit/targets/codebuff/generate-contract.test.ts index 408efaa3..1a19926f 100644 --- a/tests/unit/targets/codebuff/generate-contract.test.ts +++ b/tests/unit/targets/codebuff/generate-contract.test.ts @@ -40,7 +40,9 @@ const EXPECTED_IMPORTED = [ '.agentsmesh/ignore', '.agentsmesh/mcp.json', '.agentsmesh/rules/_root.md', - '.agentsmesh/rules/src.md', + // The nested src/AGENTS.md carries an embedded-rule block, so the rule + // returns to its own canonical file instead of a new src.md (#140). + '.agentsmesh/rules/typescript.md', '.agentsmesh/skills/api-generator/SKILL.md', '.agentsmesh/skills/api-generator/references/route-checklist.md', '.agentsmesh/skills/api-generator/template.ts', diff --git a/tests/unit/targets/codebuff/generator.test.ts b/tests/unit/targets/codebuff/generator.test.ts index 2236fb4a..c5b0d2e9 100644 --- a/tests/unit/targets/codebuff/generator.test.ts +++ b/tests/unit/targets/codebuff/generator.test.ts @@ -12,6 +12,7 @@ import { renderCodebuffGlobalInstructions, } from '../../../../src/targets/codebuff/generator.js'; import { generateRules as generateCodexRules } from '../../../../src/targets/codex-cli/generator/rules.js'; +import { renderEmbeddedRuleEntries } from '../../../../src/targets/projection/embedded-rule-entries.js'; import { CODEBUFF_ROOT_FILE, CODEBUFF_SKILLS_DIR, @@ -65,21 +66,15 @@ describe('generateRules (codebuff)', () => { }); it('nests a scoped rule as /AGENTS.md derived from its first glob', () => { - const results = generateRules( - makeCanonical({ - rules: [ - rootRule, - makeRule({ - source: '/proj/.agentsmesh/rules/typescript.md', - globs: ['src/**/*.ts'], - body: '# TypeScript\n\n- No any', - }), - ], - }), - ); + const typescript = makeRule({ + source: '/proj/.agentsmesh/rules/typescript.md', + globs: ['src/**/*.ts'], + body: '# TypeScript\n\n- No any', + }); + const results = generateRules(makeCanonical({ rules: [rootRule, typescript] })); expect(results.map((r) => r.path)).toEqual([CODEBUFF_ROOT_FILE, 'src/AGENTS.md']); - expect(results[1]?.content).toBe('# TypeScript\n\n- No any'); + expect(results[1]?.content).toBe(renderEmbeddedRuleEntries([typescript])); }); it('falls back to the rule slug when no glob yields a directory', () => { @@ -90,17 +85,14 @@ describe('generateRules (codebuff)', () => { expect(results.map((r) => r.path)).toEqual(['style/AGENTS.md']); }); - it('joins rules that collide on one nested path', () => { - const results = generateRules( - makeCanonical({ - rules: [ - makeRule({ source: '/a/one.md', globs: ['src/**'], body: 'One' }), - makeRule({ source: '/a/two.md', globs: ['src/**'], body: 'Two' }), - ], - }), - ); + it('writes rules that collide on one nested path as rule entries', () => { + const rules = [ + makeRule({ source: '/a/one.md', globs: ['src/**'], body: 'One' }), + makeRule({ source: '/a/two.md', globs: ['src/**'], body: 'Two' }), + ]; + const results = generateRules(makeCanonical({ rules })); - expect(results).toEqual([{ path: 'src/AGENTS.md', content: 'One\n\nTwo' }]); + expect(results).toEqual([{ path: 'src/AGENTS.md', content: renderEmbeddedRuleEntries(rules) }]); }); it('skips rules scoped to other targets', () => { diff --git a/tests/unit/targets/codebuff/nested-rules.test.ts b/tests/unit/targets/codebuff/nested-rules.test.ts index dfcf6720..a3546394 100644 --- a/tests/unit/targets/codebuff/nested-rules.test.ts +++ b/tests/unit/targets/codebuff/nested-rules.test.ts @@ -18,6 +18,10 @@ import { generateRules } from '../../../../src/targets/codebuff/generator.js'; import { generateRules as generateCodexRules } from '../../../../src/targets/codex-cli/generator/rules.js'; import { lintRules } from '../../../../src/targets/codebuff/linter.js'; import { makeCanonical, makeRule } from './factories.js'; +import { + renderEmbeddedRuleEntries, + takeEmbeddedRuleEntries, +} from '../../../../src/targets/projection/embedded-rule-entries.js'; function nested(outputs: { path: string; content: string }[], path: string): string { return outputs.find((output) => output.path === path)?.content ?? ''; @@ -43,7 +47,7 @@ describe('nested AGENTS.md stays byte-compatible with codex-cli', () => { const codex = nested(generateCodexRules(canonical), 'src/AGENTS.md'); const codebuff = nested(generateRules(canonical), 'src/AGENTS.md'); - expect(codex).toBe('AAA\n\nBBB'); + expect(codex).toBe(renderEmbeddedRuleEntries([canonical.rules[0]!, canonical.rules[2]!])); expect(codebuff).toContain('OOO'); expect(codebuff).toContain(codex); }); @@ -60,7 +64,7 @@ describe('nested AGENTS.md stays byte-compatible with codex-cli', () => { const codex = nested(generateCodexRules(canonical), 'src/AGENTS.md'); const codebuff = nested(generateRules(canonical), 'src/AGENTS.md'); - expect(codex).toBe('AAA\n\nBBB'); + expect(codex).toBe(renderEmbeddedRuleEntries([canonical.rules[0]!, canonical.rules[2]!])); expect(codebuff).toContain(codex); }); @@ -76,7 +80,7 @@ describe('nested AGENTS.md stays byte-compatible with codex-cli', () => { const codex = nested(generateCodexRules(canonical), 'src/AGENTS.md'); const codebuff = nested(generateRules(canonical), 'src/AGENTS.md'); - expect(codex).toBe('AAA\n\nBBB'); + expect(codex).toBe(renderEmbeddedRuleEntries([canonical.rules[0]!, canonical.rules[2]!])); expect(codebuff).toContain(codex); }); @@ -95,12 +99,12 @@ describe('nested AGENTS.md stays byte-compatible with codex-cli', () => { ], }); - expect(nested(generateRules(canonical), 'src/AGENTS.md').split('\n\n').sort()).toEqual([ - 'AAA', - 'BBB', - 'OOO', - 'XXX', - ]); + const content = nested(generateRules(canonical), 'src/AGENTS.md'); + expect( + takeEmbeddedRuleEntries(content) + .rules.map((rule) => rule.body) + .sort(), + ).toEqual(['AAA', 'BBB', 'OOO', 'XXX']); }); it('excludes a rule targeted away from codebuff', () => { diff --git a/tests/unit/targets/codex-cli/generator.test.ts b/tests/unit/targets/codex-cli/generator.test.ts index 47c16226..502395a6 100644 --- a/tests/unit/targets/codex-cli/generator.test.ts +++ b/tests/unit/targets/codex-cli/generator.test.ts @@ -12,6 +12,7 @@ import { } from '../../../../src/targets/codex-cli/generator.js'; import type { CanonicalFiles } from '../../../../src/core/types.js'; import { appendEmbeddedRulesBlock } from '../../../../src/targets/projection/managed-blocks.js'; +import { renderEmbeddedRuleEntries } from '../../../../src/targets/projection/embedded-rule-entries.js'; import { AGENTS_MD, CODEX_AGENTS_DIR, @@ -122,7 +123,7 @@ describe('generateRules (codex-cli)', () => { expect(rootFile!.content).toBe('# Root'); const tsRule = results.find((r) => r.path === 'src/AGENTS.md'); expect(tsRule).toBeDefined(); - expect(tsRule!.content).toBe('Use strict mode.'); + expect(tsRule!.content).toBe(renderEmbeddedRuleEntries([canonical.rules[1]!])); }); it('embeds **/*.ts rules (no directory prefix) into the root AGENTS.md instead of a slug dir', () => { @@ -152,7 +153,7 @@ describe('generateRules (codex-cli)', () => { expect(results[0]!.content).toContain('Strict types.'); }); - it('writes unscoped override rules to the root AGENTS.override.md, joined', () => { + it('writes unscoped override rules to the root AGENTS.override.md, as rule entries', () => { const canonical = makeCanonical({ rules: [ { @@ -175,10 +176,12 @@ describe('generateRules (codex-cli)', () => { }, ], }); - expect(generateRules(canonical)).toEqual([{ path: 'AGENTS.override.md', content: 'A\n\nB' }]); + expect(generateRules(canonical)).toEqual([ + { path: 'AGENTS.override.md', content: renderEmbeddedRuleEntries(canonical.rules) }, + ]); }); - it('joins multiple rules that resolve to the same nested AGENTS.md', () => { + it('writes multiple rules that resolve to the same nested AGENTS.md as rule entries', () => { const canonical = makeCanonical({ rules: [ { @@ -202,7 +205,7 @@ describe('generateRules (codex-cli)', () => { const results = generateRules(canonical); expect(results).toHaveLength(1); expect(results[0]!.path).toBe('src/AGENTS.md'); - expect(results[0]!.content).toBe('First.\n\nSecond.'); + expect(results[0]!.content).toBe(renderEmbeddedRuleEntries(canonical.rules)); }); it('writes AGENTS.override.md for override-variant rules', () => { diff --git a/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts b/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts new file mode 100644 index 00000000..cc4e16a2 --- /dev/null +++ b/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts @@ -0,0 +1,119 @@ +/** + * Codex scopes a rule with a nested `/AGENTS.md`. Importing that + * generated file back must restore the canonical rule it came from, not add a + * `.md` rule whose text the next generate duplicates (#140). Other tools + * that read or write the same nested files must understand it too. + */ + +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; +import { runImport } from '../../../../src/cli/commands/import.js'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const rules = (): string[] => readdirSync(join(root, '.agentsmesh', 'rules')).sort(); +const count = (text: string, part: string): number => text.split(part).length - 1; +const generate = (): Promise => runGenerate({}, root, { printMatrix: false }); + +function project(targets: string, ruleFrontmatter = ''): void { + write('agentsmesh.yaml', `version: 1\ntargets: [${targets}]\nfeatures: [rules]\n`); + write('.agentsmesh/rules/_root.md', '---\nroot: true\n---\n# Root\n'); + write( + '.agentsmesh/rules/typescript.md', + `---\ndescription: ts\nglobs: ["src/**/*.ts"]\n${ruleFrontmatter}---\n# TS\nSCOPED_TEXT\n`, + ); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-codex-nested-'))); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('codex-cli nested AGENTS.md round trip', () => { + it('is a no-op for rules that came from canonical files', async () => { + project('codex-cli'); + await generate(); + const canonical = read('.agentsmesh/rules/typescript.md'); + + for (let cycle = 0; cycle < 2; cycle++) { + await runImport({ from: 'codex-cli' }, root); + await generate(); + } + + expect(rules()).toEqual(['_root.md', 'typescript.md']); + expect(count(read('src/AGENTS.md'), 'SCOPED_TEXT')).toBe(1); + expect(read('.agentsmesh/rules/typescript.md')).toContain('SCOPED_TEXT'); + expect(read('.agentsmesh/rules/typescript.md').includes('src/**/*.ts')).toBe( + canonical.includes('src/**/*.ts'), + ); + }); + + it('restores an override rule, with its variant, into a fresh canonical tree', async () => { + project('codex-cli', 'codex_instruction: override\n'); + await generate(); + rmSync(join(root, '.agentsmesh', 'rules', 'typescript.md')); + + await runImport({ from: 'codex-cli' }, root); + + expect(rules()).toEqual(['_root.md', 'typescript.md']); + expect(read('.agentsmesh/rules/typescript.md')).toContain('codex_instruction: override'); + }); + + it('still imports hand-written text in a nested file as the directory rule', async () => { + project('codex-cli'); + await generate(); + write('src/AGENTS.md', `${read('src/AGENTS.md')}\n\nHAND_WRITTEN\n`); + + await runImport({ from: 'codex-cli' }, root); + + expect(rules()).toEqual(['_root.md', 'src.md', 'typescript.md']); + expect([ + read('.agentsmesh/rules/src.md').includes('HAND_WRITTEN'), + read('.agentsmesh/rules/src.md').includes('SCOPED_TEXT'), + ]).toEqual([true, false]); + }); + + it('works when codebuff writes the same nested file', async () => { + project('codex-cli, codebuff'); + write( + '.agentsmesh/rules/buffonly.md', + '---\ndescription: b\nglobs: ["src/**"]\ntargets: [codebuff]\n---\nBUFF_TEXT\n', + ); + await generate(); + + await runImport({ from: 'codebuff' }, root); + await generate(); + + expect(rules()).toEqual(['_root.md', 'buffonly.md', 'typescript.md']); + expect([ + count(read('src/AGENTS.md'), 'SCOPED_TEXT'), + count(read('src/AGENTS.md'), 'BUFF_TEXT'), + ]).toEqual([1, 1]); + }); + + it('windsurf import restores the rule from a nested file codex wrote', async () => { + project('codex-cli'); + await generate(); + write('agentsmesh.yaml', 'version: 1\ntargets: [windsurf]\nfeatures: [rules]\n'); + + await runImport({ from: 'windsurf' }, root); + + expect(rules()).toEqual(['_root.md', 'typescript.md']); + }); +}); diff --git a/website/src/content/docs/reference/managed-embedding.mdx b/website/src/content/docs/reference/managed-embedding.mdx index 908aa5c9..c9baeb61 100644 --- a/website/src/content/docs/reference/managed-embedding.mdx +++ b/website/src/content/docs/reference/managed-embedding.mdx @@ -66,13 +66,17 @@ On import, AgentsMesh removes that block from the root file and restores the rul The marker metadata restores the rule description, globs, and target scoping. The generated target filename is not used as the canonical source. +### Nested `AGENTS.md` files + +Codex CLI and Codebuff scope a rule by writing it into a nested `/AGENTS.md` (for example `src/AGENTS.md` for `globs: ["src/**/*.ts"]`). There, each rule is written as a bare `agentsmesh:embedded-rule` entry, without the `embedded-rules` container. The entry still names its canonical source, so `import` restores `.agentsmesh/rules/typescript.md` instead of creating a `src.md` rule, and repeated import and generate cycles do not duplicate the text. Links inside a bare entry are rewritten for the nested file like any other generated text. Text in a nested `AGENTS.md` outside the entries (for example lines you wrote by hand) is imported as the directory's own rule, `.agentsmesh/rules/.md`. + ## Current block types | Block | Used for | Round-trip behavior | |-------|----------|---------------------| | `agentsmesh:root-generation-contract` | The generated root instruction paragraph that tells agents to edit `.agentsmesh/` instead of generated files. | Stripped on import. | | `agentsmesh:embedded-rules` | Outer container for additional rules embedded in an aggregate/root instruction file. | Removed from root content after extraction. | -| `agentsmesh:embedded-rule` | One additional rule inside `embedded-rules`. | Restored to `.agentsmesh/`. | +| `agentsmesh:embedded-rule` | One additional rule inside `embedded-rules`, or on its own in a nested `/AGENTS.md`. | Restored to `.agentsmesh/`. | | `agentsmesh:codex-rule-index` | Generated Codex index of additional instruction files. | Stripped on Codex import. | | `agentsmesh:lessons-contract` | The always-on lessons recall/capture ritual, written into `.agentsmesh/rules/_root.md` by `init --lessons` and carried to every target through ordinary rule generation. | Canonical content, so it round-trips byte-stable; the sentinels let `scaffoldLessons` refresh the wording from one constant. | From ad0a72b38ea68b094cff3b8ffeee59eb4c363d2d Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 16:59:20 +0200 Subject: [PATCH 26/38] fix(lessons): migrate every legacy rule, keep journal.md, never migrate from the hook The legacy lessons migration kept only single-line numbered items under a "## Rules" heading, so wrapped rules lost their tail, bullet rules and rules under "## Lessons" were dropped, and rules with the same number in two sections overwrote each other. It never read journal.md, then deleted it with the rest, and the recall hook ran it silently. The rules parser (now import-legacy-rules.ts) reads numbered and bullet items under "## Rules" or "## Lessons", joins the lines that wrap onto an item, and numbers rules by position so ids stay unique. A list item under any other heading stops the migration with LegacyStrayRuleError, naming the file and line, before anything is written or deleted. journal.md and journal.legacy.md are kept, since their notes are not rules. The lessons hook no longer migrates, and recall takes an autoMigrate option the hook paths turn off. Fixes #138 --- .changeset/legacy-lessons-lossless.md | 5 + src/cli/commands/lessons.ts | 16 +-- src/cli/renderers/lessons-render-query.ts | 5 +- src/lessons/hook-emit.ts | 6 +- src/lessons/hook-prompt.ts | 4 +- src/lessons/import-legacy-parse.ts | 52 +------- src/lessons/import-legacy-read.ts | 26 ++-- src/lessons/import-legacy-rules.ts | 64 +++++++++ src/lessons/recall-always.ts | 4 +- src/lessons/recall.ts | 7 +- tests/unit/cli/commands/lessons.test.ts | 6 +- .../lessons/import-legacy-lossless.test.ts | 122 ++++++++++++++++++ tests/unit/lessons/import-legacy.test.ts | 4 +- website/src/content/docs/cli/lessons.mdx | 11 +- .../src/content/docs/reference/lessons.mdx | 9 +- 15 files changed, 258 insertions(+), 83 deletions(-) create mode 100644 .changeset/legacy-lessons-lossless.md create mode 100644 src/lessons/import-legacy-rules.ts create mode 100644 tests/unit/lessons/import-legacy-lossless.test.ts diff --git a/.changeset/legacy-lessons-lossless.md b/.changeset/legacy-lessons-lossless.md new file mode 100644 index 00000000..5da7079f --- /dev/null +++ b/.changeset/legacy-lessons-lossless.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +The one-time migration of the old lessons store (`index.yaml` + `topics/*.md` + `journal.md`, used up to 0.22) no longer loses lessons. Before, it kept only single-line numbered rules under a `## Rules` heading, dropped bullet rules, rules under other headings and the rest of wrapped rules, never read `journal.md`, and then deleted all the old files, even when started silently by the recall hook. Now every list item under a `## Rules` or `## Lessons` heading becomes a lesson, numbered or bullet, with the lines that wrap onto it, and rules with the same number in two sections are both kept. If a topic file has a list item under any other heading, the migration stops, names the file and line, and changes nothing. `journal.md` is kept for you to review, and the recall hook never migrates: the first `lessons` command you run does, and says so. diff --git a/src/cli/commands/lessons.ts b/src/cli/commands/lessons.ts index f8972fb5..e38829d4 100644 --- a/src/cli/commands/lessons.ts +++ b/src/cli/commands/lessons.ts @@ -33,9 +33,9 @@ export type { LessonsCommandResult } from './lessons-types.js'; /** * Pre-dispatch legacy migration. `import-md` migrates explicitly (never here). - * The RECALL subcommands (`query`, `hook`) must never crash — a corrupt legacy - * store degrades to an unmigrated (usually absent) graph, leaving the legacy - * files intact for an explicit `import-md` to surface the error loudly. Every + * `query` must never crash — a corrupt legacy store degrades to an unmigrated + * (usually absent) graph, leaving the legacy files intact for an explicit + * `import-md` to surface the error loudly. Every * other subcommand keeps the throw: failing a write loudly prevents a fresh * empty graph from permanently stranding an unmigrated legacy store. */ @@ -45,14 +45,14 @@ async function migrateForSubcommand( ): Promise<{ migrated: boolean; error?: string }> { // `resolve` and the git merge driver work on a conflicted graph mid-merge; // migrating first could write over it or fail the merge. - if (subcommand === 'import-md' || subcommand === 'resolve' || subcommand === 'merge-driver') { - return { migrated: false }; - } - if (subcommand === 'query' || subcommand === 'hook') { + // The hook runs before every tool call and prints nothing a user reads, so + // it never migrates: a migration deletes files and must be seen (#138). + const skip = ['import-md', 'resolve', 'merge-driver', 'hook']; + if (skip.includes(subcommand)) return { migrated: false }; + if (subcommand === 'query') { try { return { migrated: await maybeAutoMigrateLessons(projectRoot) }; } catch (err) { - // `query` reports it; the hook stays silent. return { migrated: false, error: err instanceof Error ? err.message : String(err) }; } } diff --git a/src/cli/renderers/lessons-render-query.ts b/src/cli/renderers/lessons-render-query.ts index 91901174..b7262da5 100644 --- a/src/cli/renderers/lessons-render-query.ts +++ b/src/cli/renderers/lessons-render-query.ts @@ -9,7 +9,10 @@ import type { LessonsQueryData, LessonsQueryFormat } from '../commands/lessons-t export function renderQuery(data: LessonsQueryData, format: LessonsQueryFormat): void { if (data.autoMigrated) { - logger.warn('lessons.json was auto-migrated from index.yaml on first invocation.'); + logger.warn( + 'lessons.json was auto-migrated from index.yaml on first invocation. journal.md, if ' + + 'present, was kept: add the notes you still need with `agentsmesh lessons add`.', + ); } if (data.warning !== undefined && data.warning.length > 0) { logger.warn(data.warning); diff --git a/src/lessons/hook-emit.ts b/src/lessons/hook-emit.ts index 65870f7f..e3e7d31f 100644 --- a/src/lessons/hook-emit.ts +++ b/src/lessons/hook-emit.ts @@ -64,7 +64,11 @@ export async function collectRecall( for (const query of queries) { const room = HOOK_INJECT_LIMIT - rules.length; if (room <= 0) break; - const r = await recallLessons(projectRoot, query, { sessionId, limit: room }); + const r = await recallLessons(projectRoot, query, { + sessionId, + limit: room, + autoMigrate: false, + }); if (r.corrupt === true) return { rules, hidden, corrupt: true }; if (r.newerVersion !== undefined) return { rules, hidden, newerVersion: r.newerVersion }; hidden += hiddenByCap(r.totalMatches, r.suppressed, r.lessons.length); diff --git a/src/lessons/hook-prompt.ts b/src/lessons/hook-prompt.ts index b49d6815..7b5e41e3 100644 --- a/src/lessons/hook-prompt.ts +++ b/src/lessons/hook-prompt.ts @@ -22,14 +22,14 @@ export async function taskRecall( sessionId: string | undefined, taskText: string | undefined, ): Promise { - const always = await recallAlwaysLessons(projectRoot, { sessionId }); + const always = await recallAlwaysLessons(projectRoot, { sessionId, autoMigrate: false }); const keyword = taskText === undefined ? undefined : await recallLessons( projectRoot, { keyword: taskText }, - { sessionId, limit: HOOK_INJECT_LIMIT }, + { sessionId, limit: HOOK_INJECT_LIMIT, autoMigrate: false }, ); // No task text, no keyword recall: read the graph health directly so an // unreadable graph is still reported on a prompt-less session start. diff --git a/src/lessons/import-legacy-parse.ts b/src/lessons/import-legacy-parse.ts index 9d0da481..fd2537c8 100644 --- a/src/lessons/import-legacy-parse.ts +++ b/src/lessons/import-legacy-parse.ts @@ -71,58 +71,10 @@ function makeTriggerId(spec: TriggerSpec): string { return `t-${TRIGGER_PREFIX[spec.kind]}-${hash}`; } -export interface ParsedRule { - readonly index: number; - readonly body: string; - readonly evidence: string[]; -} - -const RULE_HEADING_RE = /^##\s+Rules\b.*$/i; -const NEXT_HEADING_RE = /^##\s+/; -const RULE_LINE_RE = /^(\d+)\.\s+(.+?)\s*$/; -const EVIDENCE_TAIL_RE = /\s*\(Evidence:?\s+([^)]+)\)\s*$/; -const EVIDENCE_REF_RE = /L\d+/g; - -export function parseRulesSection(markdown: string): ParsedRule[] { - const lines = markdown.split(/\r?\n/); - let inRules = false; - const rules: ParsedRule[] = []; - - for (const line of lines) { - if (!inRules) { - if (RULE_HEADING_RE.test(line)) inRules = true; - continue; - } - if (NEXT_HEADING_RE.test(line)) break; - - const m = RULE_LINE_RE.exec(line); - if (m === null) continue; - - const ruleIndex = Number(m[1]); - // Group 2 `(.+?)` is mandatory in RULE_HEADING's line regex, so it is always - // present when the line matched; same for the EVIDENCE tail's `([^)]+)`. - let body = m[2]!; - const evidence: string[] = []; - - let tail = EVIDENCE_TAIL_RE.exec(body); - while (tail !== null) { - const refs = tail[1]!; - const matches = refs.match(EVIDENCE_REF_RE); - if (matches !== null) evidence.unshift(...matches); - body = body.slice(0, tail.index).trimEnd(); - tail = EVIDENCE_TAIL_RE.exec(body); - } - - rules.push({ index: ruleIndex, body, evidence }); - } - - return rules; -} - +// journal.md and journal.legacy.md are kept: their notes are not rules, so +// the migration cannot turn them into lessons, and deleting them lost them (#138). const LEGACY_ARTIFACT_REL = [ 'index.yaml', - 'journal.md', - 'journal.legacy.md', 'topics', 'distill-ledger.yaml', 'distill-proposal.md', diff --git a/src/lessons/import-legacy-read.ts b/src/lessons/import-legacy-read.ts index a0ec6733..4671a98b 100644 --- a/src/lessons/import-legacy-read.ts +++ b/src/lessons/import-legacy-read.ts @@ -10,11 +10,8 @@ import { parse as parseYaml } from 'yaml'; import { assertPathInsideRoot } from '../utils/filesystem/path-containment.js'; import type { AddLessonInput } from './add.js'; import type { Lesson, Topic, Trigger } from './graph-schema.js'; -import { - collectClusterTriggerIds, - LegacyIndexSchema, - parseRulesSection, -} from './import-legacy-parse.js'; +import { collectClusterTriggerIds, LegacyIndexSchema } from './import-legacy-parse.js'; +import { parseRulesSection } from './import-legacy-rules.js'; import { lessonsPaths } from './paths.js'; const LESSONS_DIR = '.agentsmesh/lessons'; @@ -30,6 +27,19 @@ export class LegacyTopicPathError extends Error { } } +/** Thrown when a legacy topic file has a list item the migration cannot place. */ +export class LegacyStrayRuleError extends Error { + readonly code = 'LEGACY_STRAY_RULE'; + constructor(file: string, line: number) { + super( + `Legacy lessons were not migrated: ${file} line ${line} is a list item outside a ` + + '"## Rules" or "## Lessons" section. Move it under one of them or delete it, then run ' + + '`agentsmesh lessons import-md`. Nothing was changed.', + ); + this.name = 'LegacyStrayRuleError'; + } +} + /** * Resolve a project-relative legacy topic path, refusing absolute paths, drive * or UNC paths (on any host OS), traversal after normalization, and symlinks @@ -92,9 +102,9 @@ export async function readLegacySource( ); } - for (const { index: ruleIndex, body, evidence } of parseRulesSection( - readFileSync(topicFile, 'utf8'), - )) { + const parsed = parseRulesSection(readFileSync(topicFile, 'utf8')); + if (parsed.strayLine !== null) throw new LegacyStrayRuleError(cluster.file, parsed.strayLine); + for (const { index: ruleIndex, body, evidence } of parsed.rules) { const lessonEvidence = [ `legacy:${cluster.file}#rule-${ruleIndex}`, ...evidence.map((e) => `legacy:${e}`), diff --git a/src/lessons/import-legacy-rules.ts b/src/lessons/import-legacy-rules.ts new file mode 100644 index 00000000..fb2f5b26 --- /dev/null +++ b/src/lessons/import-legacy-rules.ts @@ -0,0 +1,64 @@ +/** + * Rules in a legacy topic file (up to 0.22): list items under a `## Rules` or + * `## Lessons` heading. A numbered or bullet item keeps the lines that wrap + * onto it, and an `(Evidence L1, L2)` tail becomes evidence. A list item under + * any other heading is not guessed at: its line is reported so the migration + * stops with nothing changed (#138). + */ + +export interface ParsedRule { + /** 1-based position among the file's rules; unique even when numbers repeat. */ + readonly index: number; + readonly body: string; + readonly evidence: string[]; +} + +export interface ParsedTopic { + readonly rules: ParsedRule[]; + /** 1-based line of the first list item outside a rules section, or null. */ + readonly strayLine: number | null; +} + +const HEADING = /^#{1,6}\s/; +const SECTION_HEADING = /^#{1,2}\s/; +const RULES_HEADING = /^##\s+(?:Rules|Lessons)\b/i; +const ITEM = /^\s{0,3}(?:\d+[.)]|[-*+])\s+(.+?)\s*$/; +const EVIDENCE_TAIL = /\s*\(Evidence:?\s+([^)]+)\)\s*$/; +const EVIDENCE_REF = /L\d+/g; + +function withEvidence(index: number, text: string): ParsedRule { + let body = text; + const evidence: string[] = []; + let tail = EVIDENCE_TAIL.exec(body); + while (tail !== null) { + evidence.unshift(...(tail[1]!.match(EVIDENCE_REF) ?? [])); + body = body.slice(0, tail.index).trimEnd(); + tail = EVIDENCE_TAIL.exec(body); + } + return { index, body, evidence }; +} + +export function parseRulesSection(markdown: string): ParsedTopic { + const items: string[] = []; + let inRules = false; + // The previous line was part of an item, so a plain next line wraps onto it. + let open = false; + let strayLine: number | null = null; + markdown.split(/\r?\n/).forEach((line, i) => { + if (HEADING.test(line)) { + if (SECTION_HEADING.test(line)) inRules = RULES_HEADING.test(line); + open = false; + return; + } + const item = ITEM.exec(line); + if (item !== null) { + if (inRules) items.push(item[1]!); + else strayLine ??= i + 1; + open = inRules; + return; + } + if (line.trim() === '') open = false; + else if (open) items[items.length - 1] += ` ${line.trim()}`; + }); + return { rules: items.map((text, i) => withEvidence(i + 1, text)), strayLine }; +} diff --git a/src/lessons/recall-always.ts b/src/lessons/recall-always.ts index 07f65925..56e6c1d1 100644 --- a/src/lessons/recall-always.ts +++ b/src/lessons/recall-always.ts @@ -39,13 +39,15 @@ export async function recallAlwaysLessons( readonly sessionId?: string; /** Skip session dedup: deliver already-seen lessons and mark nothing seen. */ readonly noDedup?: boolean; + /** Migrate a legacy store first (default); see RecallOptions.autoMigrate. */ + readonly autoMigrate?: boolean; /** Bound suppression for callers with no context-reset signal — see RecallOptions.ttlMs. */ readonly ttlMs?: number; } = {}, ): Promise { // Mirror recallLessons: migrate if needed, and never throw on a blocking path. try { - await maybeAutoMigrateLessons(projectRoot); + if (options.autoMigrate !== false) await maybeAutoMigrateLessons(projectRoot); } catch { // Degrade to whatever graph state exists. } diff --git a/src/lessons/recall.ts b/src/lessons/recall.ts index 31cc624e..ca74df1d 100644 --- a/src/lessons/recall.ts +++ b/src/lessons/recall.ts @@ -51,6 +51,11 @@ export interface RecallOptions { * compact/clear, which is the exact signal a wall-clock TTL only approximates. */ readonly ttlMs?: number; + /** + * Migrate a legacy store first (default true). The hook passes false: the + * migration deletes files, so it only runs from a command the user runs. + */ + readonly autoMigrate?: boolean; } export interface RecallResult { @@ -92,7 +97,7 @@ export async function recallLessons( // files intact for an explicit `lessons import-md` to surface the error. // WRITE paths keep failing loudly so a fresh graph never strands the legacy. try { - await maybeAutoMigrateLessons(projectRoot); + if (options.autoMigrate !== false) await maybeAutoMigrateLessons(projectRoot); } catch { // Degrade; see above. } diff --git a/tests/unit/cli/commands/lessons.test.ts b/tests/unit/cli/commands/lessons.test.ts index ce4d7b13..8df8225b 100644 --- a/tests/unit/cli/commands/lessons.test.ts +++ b/tests/unit/cli/commands/lessons.test.ts @@ -262,15 +262,15 @@ describe('runLessons query', () => { expect(r.data.warning).toMatch(/config\.json/); }); - it('auto-migrates from legacy index.yaml on first query and DELETES legacy artifacts', async () => { + it('auto-migrates from legacy index.yaml on first query, deletes it and topics, keeps journal.md', async () => { cpSync(LEGACY_FIXTURE, join(root, '.agentsmesh/lessons'), { recursive: true }); expect(existsSync(join(root, '.agentsmesh/lessons/lessons.json'))).toBe(false); const r = await runLessons({ keyword: 'alpha' }, ['query'], root); expect(existsSync(join(root, '.agentsmesh/lessons/lessons.json'))).toBe(true); - // Clean-break: legacy files are gone after auto-migration. + // The migrated index and topics are gone; the journal's notes are kept (#138). expect(existsSync(join(root, '.agentsmesh/lessons/index.yaml'))).toBe(false); - expect(existsSync(join(root, '.agentsmesh/lessons/journal.md'))).toBe(false); + expect(existsSync(join(root, '.agentsmesh/lessons/journal.md'))).toBe(true); expect(existsSync(join(root, '.agentsmesh/lessons/topics'))).toBe(false); if (r.subcommand !== 'query') return; diff --git a/tests/unit/lessons/import-legacy-lossless.test.ts b/tests/unit/lessons/import-legacy-lossless.test.ts new file mode 100644 index 00000000..5406a545 --- /dev/null +++ b/tests/unit/lessons/import-legacy-lossless.test.ts @@ -0,0 +1,122 @@ +/** + * The legacy lessons store (index.yaml + topics/*.md + journal.md, up to 0.22) + * migrates without losing rules: wrapped and bullet rules are kept, a topic can + * use `## Lessons`, list items it cannot place stop the migration with nothing + * changed, journal.md is kept for review, and the recall hook never migrates + * (#138). + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { Readable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { maybeAutoMigrateLessons } from '../../../src/lessons/auto-migrate.js'; +import { runLessons } from '../../../src/cli/commands/lessons.js'; + +const LESSONS = '.agentsmesh/lessons'; +const INDEX = + 'version: 1\nclusters:\n - topic: testing\n file: .agentsmesh/lessons/topics/testing.md\n' + + ' summary: Testing.\n triggers:\n file_globs: []\n command_patterns: []\n' + + ' keywords: [testing]\n'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const has = (rel: string): boolean => existsSync(join(root, rel)); +const rules = (): string[] => { + const graph = JSON.parse(readFileSync(join(root, LESSONS, 'lessons.json'), 'utf8')) as { + lessons: Record; + }; + return Object.values(graph.lessons).map((lesson) => lesson.rule); +}; + +function legacy(topic: string): void { + write('agentsmesh.yaml', 'version: 1\ntargets: [claude-code]\n'); + write(`${LESSONS}/index.yaml`, INDEX); + write(`${LESSONS}/topics/testing.md`, topic); + write( + `${LESSONS}/journal.md`, + '# Journal\n- L2 Never run migrations against prod from a laptop\n', + ); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-legacy-lossless-'))); +}); +const realStdin = Object.getOwnPropertyDescriptor(process, 'stdin'); +afterEach(() => { + if (realStdin !== undefined) Object.defineProperty(process, 'stdin', realStdin); + rmSync(root, { recursive: true, force: true }); +}); + +describe('legacy lessons migration', () => { + it('keeps wrapped and bullet rules, and keeps journal.md', async () => { + legacy( + '# T\n\n## Rules\n\n1. Rule one (Evidence L1)\n2. Wrapped rule\n with key detail.\n' + + '- Bullet rule\n', + ); + + expect(await maybeAutoMigrateLessons(root)).toBe(true); + + expect(rules()).toEqual(['Rule one', 'Wrapped rule with key detail.', 'Bullet rule']); + expect([ + has(`${LESSONS}/index.yaml`), + has(`${LESSONS}/topics`), + has(`${LESSONS}/journal.md`), + ]).toEqual([false, false, true]); + }); + + it('reads rules under a ## Lessons heading and keeps same-number rules from two sections', async () => { + legacy('# T\n\n## Rules\n1. Alpha\n2. Beta\n\n## Lessons\n1. Gamma\n'); + + await maybeAutoMigrateLessons(root); + + expect(rules()).toEqual(['Alpha', 'Beta', 'Gamma']); + }); + + it('refuses, changing nothing, when a list item sits outside a rules section', async () => { + legacy('# T\n\n## Rules\n1. Alpha\n\n## Notes\n- Stray note\n'); + + await expect(maybeAutoMigrateLessons(root)).rejects.toThrow( + 'Legacy lessons were not migrated: .agentsmesh/lessons/topics/testing.md line 7 is a list ' + + 'item outside a "## Rules" or "## Lessons" section. Move it under one of them or delete ' + + 'it, then run `agentsmesh lessons import-md`. Nothing was changed.', + ); + expect([ + has(`${LESSONS}/lessons.json`), + has(`${LESSONS}/index.yaml`), + has(`${LESSONS}/topics/testing.md`), + has(`${LESSONS}/journal.md`), + ]).toEqual([false, true, true, true]); + }); + + it('is never run by the lessons hook', async () => { + legacy('# T\n\n## Rules\n1. Alpha\n'); + const payload = { + session_id: 'm', + hook_event_name: 'SessionStart', + cwd: root, + source: 'startup', + }; + Object.defineProperty(process, 'stdin', { + configurable: true, + value: Readable.from([Buffer.from(JSON.stringify(payload))]), + }); + + await runLessons({}, ['hook'], root); + + expect([has(`${LESSONS}/lessons.json`), has(`${LESSONS}/index.yaml`)]).toEqual([false, true]); + }); +}); diff --git a/tests/unit/lessons/import-legacy.test.ts b/tests/unit/lessons/import-legacy.test.ts index 50c6eee5..30b230d6 100644 --- a/tests/unit/lessons/import-legacy.test.ts +++ b/tests/unit/lessons/import-legacy.test.ts @@ -39,10 +39,10 @@ describe('importLegacyLessons', async () => { expect(got).toBe(expected); }); - it('DELETES every legacy artifact by default (clean-break upgrade)', async () => { + it('deletes the migrated legacy artifacts by default and keeps journal.md', async () => { const report = await importLegacyLessons(root, { migratedAt: MIGRATED_AT }); expect(existsSync(join(root, '.agentsmesh/lessons/index.yaml'))).toBe(false); - expect(existsSync(join(root, '.agentsmesh/lessons/journal.md'))).toBe(false); + expect(existsSync(join(root, '.agentsmesh/lessons/journal.md'))).toBe(true); expect(existsSync(join(root, '.agentsmesh/lessons/topics'))).toBe(false); expect(report.deletedPaths.length).toBeGreaterThan(0); }); diff --git a/website/src/content/docs/cli/lessons.mdx b/website/src/content/docs/cli/lessons.mdx index a1b5adb0..1bcd60f1 100644 --- a/website/src/content/docs/cli/lessons.mdx +++ b/website/src/content/docs/cli/lessons.mdx @@ -199,12 +199,17 @@ If you're coming from a previous release that used `index.yaml` + ```bash $ agentsmesh lessons query --file src/x.ts -⚠ lessons.json was auto-migrated from index.yaml on first invocation. +⚠ lessons.json was auto-migrated from index.yaml on first invocation. journal.md, if present, was kept: add the notes you still need with `agentsmesh lessons add`. … ``` -Auto-migration **deletes the legacy files** after a successful import so the -project lands in a clean state. Run `agentsmesh lessons import-md` explicitly +Auto-migration **deletes `index.yaml` and `topics/`** after a successful import so the +project lands in a clean state. Every list item under a `## Rules` or `## Lessons` +heading in a topic file becomes a lesson, numbered or bullet, including lines that +wrap onto it. If a topic file has a list item under any other heading, the migration +stops, names the file and line, and changes nothing. `journal.md` is kept: its notes +are not rules, so add the ones you still need with `agentsmesh lessons add`. The recall +hook never migrates; the first `lessons` command you run does, and says so. Run `agentsmesh lessons import-md` explicitly if you prefer to migrate at a specific point in time. New projects skip this entirely — `agentsmesh init --lessons` creates the graph directly, alongside `.agentsmesh/lessons/config.json` with every tunable at its default diff --git a/website/src/content/docs/reference/lessons.mdx b/website/src/content/docs/reference/lessons.mdx index 65bb112c..bad04a93 100644 --- a/website/src/content/docs/reference/lessons.mdx +++ b/website/src/content/docs/reference/lessons.mdx @@ -237,15 +237,18 @@ The practical consequence is for **cloned third-party repositories**: a lesson g Earlier releases stored lessons as `.agentsmesh/lessons/index.yaml` plus per-topic Markdown files plus an append-only `journal.md`. On upgrade, the -migrator collapses all three into `lessons.json` and **deletes the legacy -files**: +migrator turns every list item under a `## Rules` or `## Lessons` heading of a +topic file into a lesson in `lessons.json` (numbered or bullet, with the lines +that wrap onto it) and **deletes `index.yaml` and `topics/`**. A list item under +any other heading stops the migration with nothing changed. `journal.md` is kept, +because its notes are not rules: ```bash agentsmesh lessons import-md ``` It also runs lazily on the first `lessons` subcommand when `lessons.json` -is absent and `index.yaml` exists. New projects skip this entirely. +is absent and `index.yaml` exists. The recall hook never runs it. New projects skip this entirely. Every topic file path in the legacy index must stay inside `.agentsmesh/lessons/`. If one points anywhere else, the migration is refused From ea1f7c49da8c7a689bbf736924e5448514cac796 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 17:16:34 +0200 Subject: [PATCH 27/38] test(install): cover the default uninstall adapter The #133 fix swapped the adapter's write arrow for writeOutput, which left the untested ask arrow as a quarter of the file's functions and dropped uninstall-io.ts below the per-file coverage floor. The new test drives ask through stdin and checks both writes go to the logger stream. --- .../install/uninstall/uninstall-io.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/unit/install/uninstall/uninstall-io.test.ts diff --git a/tests/unit/install/uninstall/uninstall-io.test.ts b/tests/unit/install/uninstall/uninstall-io.test.ts new file mode 100644 index 00000000..3c8c824c --- /dev/null +++ b/tests/unit/install/uninstall/uninstall-io.test.ts @@ -0,0 +1,33 @@ +import { Readable } from 'node:stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + defaultUninstallAdapter, + parseUninstallNames, +} from '../../../../src/install/uninstall/uninstall-io.js'; + +const realStdin = Object.getOwnPropertyDescriptor(process, 'stdin'); +afterEach(() => { + if (realStdin !== undefined) Object.defineProperty(process, 'stdin', realStdin); + vi.restoreAllMocks(); +}); + +describe('uninstall io', () => { + it('splits comma and space separated names and keeps duplicates', () => { + expect(parseUninstallNames(['a,b', ' c ', 'a'])).toEqual(['a', 'b', 'c', 'a']); + }); + + it('the default adapter reads an answer from stdin and writes through the logger', async () => { + Object.defineProperty(process, 'stdin', { + configurable: true, + value: Readable.from([Buffer.from('yes\n')]), + }); + const out = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const adapter = defaultUninstallAdapter(); + + const answer = await adapter.ask('Remove? '); + adapter.write('notice\n'); + + expect(answer).toBe('yes'); + expect(out.mock.calls.map(([chunk]) => String(chunk))).toEqual(['Remove? ', 'notice\n']); + }); +}); From 9a60bf3ef627b707a0a210234fcfb0a5ed4a44e9 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 17:16:35 +0200 Subject: [PATCH 28/38] chore(lessons): capture the filtered-lock, nested-entry and claude paths rules --- .agentsmesh/lessons/lessons.json | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 1db0b5ea..e5e3fdca 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -2581,6 +2581,23 @@ "t-glob-861a9bdb" ] }, + "generation-collision-source-maps-write-rules-into-a-shared": { + "createdAt": "2026-09-24", + "evidence": [ + "aaeeabe1" + ], + "rule": "Write rules into a shared nested /AGENTS.md as bare embedded-rule entries (renderEmbeddedRuleEntries), never as an embedded-rules block: the outer block is a protected range, so its relative links are not rewritten and break once the rule sits in the nested file, and bare entries keep codex-cli's text a prefix of codebuff's so the shared path still resolves by containment. Every importer of nested AGENTS.md must split entries back with splitNestedAgentsFile.", + "status": "active", + "topics": [ + "generation-collision-source-maps" + ], + "triggers": [ + "t-glob-b3216045", + "t-glob-36b69836", + "t-glob-7f7c09ee", + "t-glob-8c8ae089" + ] + }, "generation-safety-a-key-scoped-merger-must": { "createdAt": "2026-09-03", "evidence": [ @@ -6744,6 +6761,22 @@ "t-glob-40333e0c" ] }, + "lock-file-format-never-hold-back-agentsmesh-lock": { + "createdAt": "2026-09-24", + "evidence": [ + "f23984f2" + ], + "rule": "Never hold back .agentsmesh/.lock checksums or skip writing the lock to mark a filtered generate as incomplete: the lock's outputs map is the provenance the stale-output sweep gates on, and its checksums gate collaboration strategy: lock violations, so both broke (the target contract matrix caught it). Record per-target staleness separately, as stale_targets, and run tests/contract/target-contract.matrix.test.ts before committing any change to what the lock records.", + "status": "active", + "topics": [ + "lock-file-format" + ], + "triggers": [ + "t-glob-f9f526aa", + "t-glob-e6cc94f4", + "t-glob-b31c4e39" + ] + }, "lock-file-format-readlock-returns-null-for-a": { "createdAt": "2026-09-23", "evidence": [ @@ -8854,6 +8887,20 @@ "t-glob-8ac72756" ] }, + "targets-check-a-target-s-rule": { + "createdAt": "2026-09-24", + "evidence": [ + "75daebd9" + ], + "rule": "Check a target's rule frontmatter field names against the tool's own docs before generating or importing them: Claude Code reads only paths from a .claude/rules/*.md rule (a YAML list or a comma-separated string), so writing canonical globs as globs: silently made every scoped rule global and import dropped paths.", + "status": "active", + "topics": [ + "targets" + ], + "triggers": [ + "t-glob-fadbbc47" + ] + }, "targets-codex-marketplace-catalogs-reject-policy": { "createdAt": "2026-09-22", "evidence": [ @@ -11948,6 +11995,10 @@ "kind": "file_glob", "pattern": "src/targets/import/**" }, + "t-glob-36b69836": { + "kind": "file_glob", + "pattern": "src/targets/codebuff/generator.ts" + }, "t-glob-36d72b33": { "kind": "file_glob", "pattern": "tests/consumer-smoke/src/smoke.ts" @@ -12428,6 +12479,10 @@ "kind": "file_glob", "pattern": "src/targets/import/shared/skill-import-pipeline.ts" }, + "t-glob-7f7c09ee": { + "kind": "file_glob", + "pattern": "src/targets/projection/**" + }, "t-glob-7fc51540": { "kind": "file_glob", "pattern": "src/public/lessons.ts" @@ -12548,6 +12603,10 @@ "kind": "file_glob", "pattern": "tests/integration/lessons-*.integration.test.ts" }, + "t-glob-8c8ae089": { + "kind": "file_glob", + "pattern": "src/targets/*/import-nested-agents.ts" + }, "t-glob-8ce6202e": { "kind": "file_glob", "pattern": "src/core/reference/output-source-map.ts" @@ -12780,6 +12839,14 @@ "kind": "file_glob", "pattern": "src/targets/**" }, + "t-glob-b31c4e39": { + "kind": "file_glob", + "pattern": "src/core/check/lock-sync*.ts" + }, + "t-glob-b3216045": { + "kind": "file_glob", + "pattern": "src/targets/codex-cli/generator/**" + }, "t-glob-b39eff4f": { "kind": "file_glob", "pattern": "website/src/content/docs/reference/lessons.mdx" @@ -13176,6 +13243,10 @@ "kind": "file_glob", "pattern": "src/lessons/hook-payload.ts" }, + "t-glob-fadbbc47": { + "kind": "file_glob", + "pattern": "src/targets/claude-code/**" + }, "t-glob-fb5c7dc9": { "kind": "file_glob", "pattern": "src/targets/*/global-permissions.ts" From 543b348b0998bdc2c8970d6f9d7a48b4662c2d56 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 17:47:25 +0200 Subject: [PATCH 29/38] fix(windsurf): write a scoped rule once, as its own glob rule For a rule with globs the windsurf generator wrote three outputs: the glob rule .windsurf/rules/.md, an identical .windsurf/rules/.md and a plain /AGENTS.md. Windsurf reads a trigger: glob rule for the files its globs match and also reads a subdirectory AGENTS.md as a rule for that folder (docs.devin.ai/desktop/cascade/memories), so it loaded the rule several times, the nested file widened src/**/*.ts to src/**, and import --from windsurf added a duplicate .md canonical rule. Only .windsurf/rules/.md is written now, and the layout no longer declares /AGENTS.md as a rule output. The stale .windsurf/rules/.md is evicted by the next generate (managed dir + lock provenance). A nested /AGENTS.md is not a managed output, so an old copy stays on disk; the windsurf nested importer skips one whose text equals a .windsurf/rules/*.md body, so the round trip is a no-op, while a hand-written nested AGENTS.md still imports as the folder rule. --- .changeset/windsurf-scoped-rule-once.md | 5 + src/targets/windsurf/generator/rules.ts | 20 +--- src/targets/windsurf/import-nested-agents.ts | 20 +++- src/targets/windsurf/index.ts | 13 +-- tests/contract/contracts/windsurf.ts | 3 - tests/e2e/generate-anatomy.e2e.test.ts | 10 +- .../e2e/generate-descriptor-fixes.e2e.test.ts | 9 +- tests/e2e/helpers/reference-targets.ts | 4 +- tests/unit/core/output-source-map.test.ts | 11 +- tests/unit/core/reference-rewriter.test.ts | 12 +- .../targets/windsurf/rules-branches.test.ts | 70 +++--------- .../windsurf/scoped-rule-roundtrip.test.ts | 106 ++++++++++++++++++ .../content/docs/canonical-config/rules.mdx | 2 + 13 files changed, 177 insertions(+), 108 deletions(-) create mode 100644 .changeset/windsurf-scoped-rule-once.md create mode 100644 tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts diff --git a/.changeset/windsurf-scoped-rule-once.md b/.changeset/windsurf-scoped-rule-once.md new file mode 100644 index 00000000..2c1255d9 --- /dev/null +++ b/.changeset/windsurf-scoped-rule-once.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +Windsurf now gets each scoped rule once. For a rule with `globs: ["src/**/*.ts"]`, `agentsmesh generate` wrote `.windsurf/rules/typescript.md`, an identical `.windsurf/rules/src.md`, and `src/AGENTS.md`, so Windsurf loaded the rule several times, `src/AGENTS.md` applied it to every file in `src/`, and `agentsmesh import --from windsurf` added a duplicate `.agentsmesh/rules/src.md`. Now only `.windsurf/rules/typescript.md` (`trigger: glob`) is written; the next `generate` removes the old `.windsurf/rules/src.md`, and import ignores an old `src/AGENTS.md` that holds the same text as a Windsurf rule, so a generate and import round trip no longer adds rules. You can delete such an old `src/AGENTS.md` if no other tool uses it. A nested `AGENTS.md` you wrote yourself is still imported as a folder rule. diff --git a/src/targets/windsurf/generator/rules.ts b/src/targets/windsurf/generator/rules.ts index bfd7e4a5..40d442df 100644 --- a/src/targets/windsurf/generator/rules.ts +++ b/src/targets/windsurf/generator/rules.ts @@ -10,15 +10,6 @@ function ruleSlug(source: string): string { return name === '_root' ? 'root' : name; } -function directoryScopedRuleDir(globs: string[]): string | null { - if (globs.length === 0) return null; - const dirs = globs - .map((glob) => glob.split('/')[0] ?? '') - .filter((segment) => /^[A-Za-z0-9._-]+$/.test(segment)); - if (dirs.length !== globs.length) return null; - return dirs.every((dir) => dir === dirs[0]) ? dirs[0]! : null; -} - export function generateRules(canonical: CanonicalFiles): RulesOutput[] { const outputs: RulesOutput[] = []; const root = canonical.rules.find((r) => r.root); @@ -47,15 +38,10 @@ export function generateRules(canonical: CanonicalFiles): RulesOutput[] { Object.keys(frontmatter).length > 0 ? serializeFrontmatter(frontmatter, rule.body.trim() || '') : rule.body.trim() || ''; + // Written once: Windsurf applies a `trigger: glob` rule to the files its + // globs match, and also reads a `/AGENTS.md` as a rule for that folder, + // so a nested copy loaded the text twice and widened its scope. outputs.push({ path: `${WINDSURF_RULES_DIR}/${slug}.md`, content }); - - const dir = directoryScopedRuleDir(rule.globs); - if (dir) { - if (dir !== slug) { - outputs.push({ path: `${WINDSURF_RULES_DIR}/${dir}.md`, content }); - } - outputs.push({ path: `${dir}/AGENTS.md`, content: rule.body.trim() || '' }); - } } return outputs; diff --git a/src/targets/windsurf/import-nested-agents.ts b/src/targets/windsurf/import-nested-agents.ts index b5ec6931..1eb41107 100644 --- a/src/targets/windsurf/import-nested-agents.ts +++ b/src/targets/windsurf/import-nested-agents.ts @@ -2,6 +2,8 @@ * Windsurf reads a nested `/AGENTS.md` as a rule for that directory. * Embedded rule blocks in it (written by Codex CLI or Codebuff) go back to * their own canonical files; only the text outside them is the directory rule. + * A nested file whose text is a `.windsurf/rules/*.md` rule's body is the copy + * older versions also wrote, so it is skipped instead of becoming a new rule. */ import { basename, dirname, join, relative } from 'node:path'; @@ -14,15 +16,31 @@ import { removePathIfExists, shouldImportScopedAgentsRule, } from '../import/scoped-agents-import.js'; +import { readDirRecursiveNoSymlinks, readFileSafe } from '../../utils/filesystem/fs.js'; +import { splitFrontmatter } from '../../utils/text/markdown.js'; +import { WINDSURF_RULES_DIR } from './constants.js'; type Normalizer = (content: string, sourceFile: string, destinationFile: string) => string; +const bodyKey = (text: string): string => text.replace(/\r\n?/g, '\n').trim(); + +async function windsurfRuleBodies(projectRoot: string): Promise> { + const files = await readDirRecursiveNoSymlinks(join(projectRoot, WINDSURF_RULES_DIR)); + const bodies = new Set(); + for (const file of files.filter((path) => path.endsWith('.md'))) { + const content = await readFileSafe(file); + if (content !== null) bodies.add(bodyKey(splitFrontmatter(content)?.body ?? content)); + } + return bodies; +} + export async function importWindsurfNestedAgents( projectRoot: string, normalize: Normalizer, ): Promise { const destRulesDir = join(projectRoot, AB_RULES); const embedded: ImportResult[] = []; + const ruleBodies = await windsurfRuleBodies(projectRoot); const results = await importFileDirectory({ srcDir: projectRoot, destDir: destRulesDir, @@ -48,7 +66,7 @@ export async function importWindsurfNestedAgents( }, embedded, ); - if (ownText === null) return null; + if (ownText === null || ruleBodies.has(bodyKey(ownText))) return null; const destPath = join(destRulesDir, `${ruleName}.md`); return { destPath, diff --git a/src/targets/windsurf/index.ts b/src/targets/windsurf/index.ts index 6a77326b..66a9d4c1 100644 --- a/src/targets/windsurf/index.ts +++ b/src/targets/windsurf/index.ts @@ -39,15 +39,6 @@ import { buildWindsurfImportPaths } from '../../core/reference/import-map-builde import { shouldConvertAgentsToSkills } from '../../config/core/conversions.js'; import { projectedAgentSkillDirName } from '../projection/projected-agent-skill.js'; -function directoryScopedRuleDir(globs: readonly string[]): string | null { - if (globs.length === 0) return null; - const dirs = globs - .map((glob) => glob.split('/')[0] ?? '') - .filter((segment) => /^[A-Za-z0-9._-]+$/.test(segment)); - if (dirs.length !== globs.length) return null; - return dirs.every((dir) => dir === dirs[0]) ? dirs[0]! : null; -} - export const target: TargetGenerators = { name: 'windsurf', primaryRootInstructionPath: WINDSURF_AGENTS_MD, @@ -65,9 +56,7 @@ export const target: TargetGenerators = { const project: TargetLayout = { rootInstructionPath: WINDSURF_AGENTS_MD, extraRuleOutputPaths(rule) { - if (rule.root) return [WINDSURF_AGENTS_MD]; - const dir = directoryScopedRuleDir(rule.globs); - return dir !== null ? [`${dir}/AGENTS.md`] : []; + return rule.root ? [WINDSURF_AGENTS_MD] : []; }, skillDir: WINDSURF_SKILLS_DIR, managedOutputs: { diff --git a/tests/contract/contracts/windsurf.ts b/tests/contract/contracts/windsurf.ts index 5dd24365..6e238fa8 100644 --- a/tests/contract/contracts/windsurf.ts +++ b/tests/contract/contracts/windsurf.ts @@ -5,7 +5,6 @@ export const windsurfContract: TargetPathContract = { '.codeiumignore', '.windsurf/hooks.json', '.windsurf/mcp_config.example.json', - '.windsurf/rules/src.md', '.windsurf/rules/typescript.md', '.windsurf/skills/am-agent-code-reviewer/SKILL.md', '.windsurf/skills/am-agent-researcher/SKILL.md', @@ -14,7 +13,6 @@ export const windsurfContract: TargetPathContract = { '.windsurf/skills/api-generator/template.ts', '.windsurf/workflows/review.md', 'AGENTS.md', - 'src/AGENTS.md', ], imported: [ '.agentsmesh/agents/code-reviewer.md', @@ -24,7 +22,6 @@ export const windsurfContract: TargetPathContract = { '.agentsmesh/ignore', '.agentsmesh/mcp.json', '.agentsmesh/rules/_root.md', - '.agentsmesh/rules/src.md', '.agentsmesh/rules/typescript.md', '.agentsmesh/skills/api-generator/SKILL.md', '.agentsmesh/skills/api-generator/references/route-checklist.md', diff --git a/tests/e2e/generate-anatomy.e2e.test.ts b/tests/e2e/generate-anatomy.e2e.test.ts index 06cabe8b..9fc3cf10 100644 --- a/tests/e2e/generate-anatomy.e2e.test.ts +++ b/tests/e2e/generate-anatomy.e2e.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { cleanup, createTestProject } from './helpers/setup.js'; import { runCli } from './helpers/run-cli.js'; @@ -58,7 +58,7 @@ describe('generate anatomy variants', () => { fileContains(join(dir, 'src', 'AGENTS.md'), 'Use strict mode.'); }); - it('generates Windsurf subdirectory AGENTS.md from scoped canonical rules', async () => { + it('generates a Windsurf glob rule, not a subdirectory AGENTS.md, from a scoped rule', async () => { dir = createTestProject(); mkdirSync(join(dir, '.agentsmesh', 'rules'), { recursive: true }); writeFileSync( @@ -77,6 +77,10 @@ describe('generate anatomy variants', () => { const result = await runCli('generate', dir); expect(result.exitCode).toBe(0); - fileContains(join(dir, 'src', 'AGENTS.md'), 'Src windsurf rules'); + // Windsurf also reads src/AGENTS.md as a src/** rule, so one glob rule is enough. + expect(readFileSync(join(dir, '.windsurf', 'rules', 'src.md'), 'utf-8')).toBe( + '---\ndescription: Src rules\ntrigger: glob\nglob: src/**/*.ts\n---\n\n# Src windsurf rules\nUse strict mode.', + ); + expect(existsSync(join(dir, 'src', 'AGENTS.md'))).toBe(false); }); }); diff --git a/tests/e2e/generate-descriptor-fixes.e2e.test.ts b/tests/e2e/generate-descriptor-fixes.e2e.test.ts index f6904022..12412f0d 100644 --- a/tests/e2e/generate-descriptor-fixes.e2e.test.ts +++ b/tests/e2e/generate-descriptor-fixes.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createCanonicalProject } from './helpers/canonical.js'; import { createTestProject, cleanup } from './helpers/setup.js'; @@ -38,7 +38,7 @@ features: [rules, agents] expect(String(reviewer!.roleDefinition ?? '')).toContain('code reviewer'); }); - it('windsurf: directory-scoped glob rule generates src/AGENTS.md', async () => { + it('windsurf: a directory-scoped glob rule is written once, with no src/AGENTS.md', async () => { dir = createTestProject(); mkdirSync(join(dir, '.agentsmesh', 'rules'), { recursive: true }); writeFileSync( @@ -56,7 +56,8 @@ features: [rules, agents] const gen = await runCli('generate --targets windsurf', dir); expect(gen.exitCode, gen.stderr).toBe(0); - fileExists(join(dir, 'src', 'AGENTS.md')); - fileContains(join(dir, 'src', 'AGENTS.md'), 'Src-specific guidance'); + fileContains(join(dir, '.windsurf', 'rules', 'src-guide.md'), 'Src-specific guidance'); + expect(existsSync(join(dir, '.windsurf', 'rules', 'src.md'))).toBe(false); + expect(existsSync(join(dir, 'src', 'AGENTS.md'))).toBe(false); }); }); diff --git a/tests/e2e/helpers/reference-targets.ts b/tests/e2e/helpers/reference-targets.ts index 63f8dc5d..1da53adb 100644 --- a/tests/e2e/helpers/reference-targets.ts +++ b/tests/e2e/helpers/reference-targets.ts @@ -142,7 +142,7 @@ export function outputPaths(target: TargetName): OutputPathGroups { : target === 'junie' ? ['.junie/rules/typescript.md'] : target === 'windsurf' - ? ['.windsurf/rules/typescript.md', 'src/AGENTS.md'] + ? ['.windsurf/rules/typescript.md'] : [ target === 'aider' ? 'CONVENTIONS.md' @@ -290,7 +290,7 @@ export function expectedRefs(target: TargetName, path?: string): Record { - it('uses descriptor-declared rule extra outputs for source ownership', () => { + it('maps windsurf root and rule outputs, and no nested AGENTS.md it no longer writes', () => { const sourceMap = buildOutputSourceMap('windsurf', canonical(), config('windsurf')); expect(sourceMap.get('AGENTS.md')).toBe(join('/repo/.agentsmesh/rules/_root.md')); + expect(sourceMap.get('.windsurf/rules/typescript.md')).toBe( + join('/repo/.agentsmesh/rules/typescript.md'), + ); + expect(sourceMap.has('src/AGENTS.md')).toBe(false); + }); + + it('uses descriptor-declared rule extra outputs for source ownership', () => { + const sourceMap = buildOutputSourceMap('codex-cli', canonical(), config('codex-cli')); + expect(sourceMap.get('src/AGENTS.md')).toBe(join('/repo/.agentsmesh/rules/typescript.md')); }); diff --git a/tests/unit/core/reference-rewriter.test.ts b/tests/unit/core/reference-rewriter.test.ts index 0043c3d2..7943e221 100644 --- a/tests/unit/core/reference-rewriter.test.ts +++ b/tests/unit/core/reference-rewriter.test.ts @@ -567,7 +567,7 @@ describe('rewriteGeneratedReferences', () => { ); }); - it('rewrites windsurf shared and scoped AGENTS outputs from their canonical rule sources', () => { + it('rewrites windsurf root AGENTS and scoped rule outputs from their canonical rule sources', () => { const projectRoot = '/proj'; const canonical = makeCanonical(projectRoot); const results: GenerateResult[] = [ @@ -577,16 +577,10 @@ describe('rewriteGeneratedReferences', () => { content: 'Use .agentsmesh/skills/api-gen/ and .agentsmesh/skills/api-gen/references/.', status: 'created', }, - { - target: 'windsurf', - path: 'src/AGENTS.md', - content: 'Use .agentsmesh/skills/api-gen/SKILL.md.', - status: 'created', - }, { target: 'windsurf', path: '.windsurf/rules/typescript.md', - content: '', + content: 'Use .agentsmesh/skills/api-gen/SKILL.md.', status: 'created', }, { @@ -613,7 +607,7 @@ describe('rewriteGeneratedReferences', () => { expect(rewritten[0]!.content).toContain('skills/api-gen/'); expect(rewritten[0]!.content).toContain('skills/api-gen/references/'); // Canonical anchor projects to the colocated windsurf skill counterpart. - expect(rewritten[1]!.content).toContain('.windsurf/skills/api-gen/SKILL.md'); + expect(rewritten[1]!.content).toBe('Use ../skills/api-gen/SKILL.md.'); }); it('reuses one artifact map for multiple outputs of the same target', () => { diff --git a/tests/unit/targets/windsurf/rules-branches.test.ts b/tests/unit/targets/windsurf/rules-branches.test.ts index f87a7fcb..294859c4 100644 --- a/tests/unit/targets/windsurf/rules-branches.test.ts +++ b/tests/unit/targets/windsurf/rules-branches.test.ts @@ -173,8 +173,11 @@ describe('windsurf generateRules — frontmatter glob/globs branches', () => { }); }); -describe('windsurf generateRules — directoryScopedRuleDir branches', () => { - it('mirrors a directory-scoped rule into both .windsurf/rules and {dir}/AGENTS.md', () => { +describe('windsurf generateRules — scoped rules', () => { + // Windsurf applies a `trigger: glob` rule to the files its globs match and + // also reads a `/AGENTS.md` as a rule for that folder, so a scoped rule + // is written once: a `.md` copy or a nested AGENTS.md loaded it twice. + it('writes a directory-scoped rule once, as its own glob rule', () => { const result = generateRules( makeCanonical({ rules: [ @@ -190,38 +193,16 @@ describe('windsurf generateRules — directoryScopedRuleDir branches', () => { ], }), ); - const paths = result.map((r) => r.path); - expect(paths).toContain(`${WINDSURF_RULES_DIR}/src-scope.md`); - // dir != slug ('src' vs 'src-scope') → second mirror added - expect(paths).toContain(`${WINDSURF_RULES_DIR}/src.md`); - expect(paths).toContain(`src/AGENTS.md`); - const agentsMirror = result.find((r) => r.path === 'src/AGENTS.md'); - expect(agentsMirror!.content).toBe('src-body'); - }); - - it('does not double-emit rules dir copy when slug equals dir', () => { - const result = generateRules( - makeCanonical({ - rules: [ - rootRule(), - { - source: '/p/.agentsmesh/rules/src.md', - root: false, - targets: [], - description: '', - globs: ['src/**/*.ts'], - body: 'b', - }, - ], - }), - ); - // path "src/AGENTS.md" should appear (dir mirror), but only one src.md under rules/. - const rulesCopies = result.filter((r) => r.path === `${WINDSURF_RULES_DIR}/src.md`); - expect(rulesCopies).toHaveLength(1); - expect(result.some((r) => r.path === 'src/AGENTS.md')).toBe(true); + expect(result).toEqual([ + { path: 'AGENTS.md', content: '# Root\n\nRoot body.' }, + { + path: `${WINDSURF_RULES_DIR}/src-scope.md`, + content: '---\ndescription: Src\ntrigger: glob\nglob: src/**/*.ts\n---\n\nsrc-body', + }, + ]); }); - it('does NOT mirror when globs span multiple top-level directories', () => { + it('writes a rule scoped to several folders once, with all its globs', () => { const result = generateRules( makeCanonical({ rules: [ @@ -237,30 +218,7 @@ describe('windsurf generateRules — directoryScopedRuleDir branches', () => { ], }), ); - const paths = result.map((r) => r.path); - expect(paths).not.toContain('src/AGENTS.md'); - expect(paths).not.toContain('tests/AGENTS.md'); - }); - - it('does NOT mirror when a glob has no leading directory segment', () => { - const result = generateRules( - makeCanonical({ - rules: [ - rootRule(), - { - source: '/p/.agentsmesh/rules/glob-only.md', - root: false, - targets: [], - description: '', - globs: ['*.md'], - body: 'b', - }, - ], - }), - ); - // The first segment '*.md' has '*' which fails the [A-Za-z0-9._-] regex - // → directoryScopedRuleDir returns null → no AGENTS.md mirror. - expect(result.some((r) => r.path === '*.md/AGENTS.md')).toBe(false); + expect(result.map((r) => r.path)).toEqual(['AGENTS.md', `${WINDSURF_RULES_DIR}/mix.md`]); }); it('uses "root" slug when source is _root.md (still skipped because rule.root=true)', () => { diff --git a/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts b/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts new file mode 100644 index 00000000..fd726bce --- /dev/null +++ b/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts @@ -0,0 +1,106 @@ +/** + * Windsurf applies a `.windsurf/rules/*.md` rule with `trigger: glob` to the + * files its globs match, and treats a subdirectory `AGENTS.md` as a rule for + * that directory (docs.devin.ai/desktop/cascade/memories). A scoped rule is + * therefore written once, as its own glob rule; the extra `.md` copy and + * `/AGENTS.md` loaded it twice and made import add a `.md` rule. + */ + +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; +import { runImport } from '../../../../src/cli/commands/import.js'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const rules = (): string[] => readdirSync(join(root, '.agentsmesh', 'rules')).sort(); +const generate = (): Promise => runGenerate({}, root, { printMatrix: false }); + +/** + * Every generated file outside `.agentsmesh/` and the config, forward-slashed. + * `.agentsmeshcache` is generate's link to the shared remote cache, not output. + */ +function outputs(): string[] { + return readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => relative(root, join(entry.parentPath, entry.name)).replaceAll('\\', '/')) + .filter((path) => !/^(?:\.agentsmesh\/|\.agentsmeshcache\/|agentsmesh)/.test(path)) + .sort(); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-windsurf-scoped-'))); + write('agentsmesh.yaml', 'version: 1\ntargets: [windsurf]\nfeatures: [rules]\n'); + write('.agentsmesh/rules/_root.md', '---\nroot: true\n---\n# Root\n'); + write( + '.agentsmesh/rules/typescript.md', + '---\ndescription: ts\nglobs: ["src/**/*.ts"]\n---\n# TS\nSCOPED_TEXT\n', + ); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('windsurf scoped rule', () => { + it('is written once, as its own glob rule', async () => { + await generate(); + + expect(outputs()).toEqual(['.windsurf/rules/typescript.md', 'AGENTS.md']); + }); + + it('round-trips through import without new rules or repeated text', async () => { + await generate(); + const first = read('.windsurf/rules/typescript.md'); + + for (let cycle = 0; cycle < 2; cycle++) { + await runImport({ from: 'windsurf' }, root); + await generate(); + } + + expect(rules()).toEqual(['_root.md', 'typescript.md']); + expect(outputs()).toEqual(['.windsurf/rules/typescript.md', 'AGENTS.md']); + expect(read('.windsurf/rules/typescript.md')).toBe(first); + }); + + it('does not import a src/AGENTS.md an older version wrote as a new rule', async () => { + await generate(); + write('src/AGENTS.md', '# TS\nSCOPED_TEXT'); + + await runImport({ from: 'windsurf' }, root); + + expect(rules()).toEqual(['_root.md', 'typescript.md']); + }); + + it('matches the old copy against a Windsurf rule file without frontmatter too', async () => { + await generate(); + write('.windsurf/rules/notes.md', 'PLAIN_NOTE\n'); + write('docs/AGENTS.md', 'PLAIN_NOTE'); + + await runImport({ from: 'windsurf' }, root); + + expect(rules()).toEqual(['_root.md', 'notes.md', 'typescript.md']); + }); + + it('still imports a hand-written nested AGENTS.md as the directory rule', async () => { + await generate(); + write('src/AGENTS.md', '# Src\nHAND_WRITTEN\n'); + + await runImport({ from: 'windsurf' }, root); + + expect(rules()).toEqual(['_root.md', 'src.md', 'typescript.md']); + expect(read('.agentsmesh/rules/src.md')).toContain('HAND_WRITTEN'); + }); +}); diff --git a/website/src/content/docs/canonical-config/rules.mdx b/website/src/content/docs/canonical-config/rules.mdx index a6f35ab8..bbf77a4a 100644 --- a/website/src/content/docs/canonical-config/rules.mdx +++ b/website/src/content/docs/canonical-config/rules.mdx @@ -89,6 +89,8 @@ Always run `pnpm typecheck` before committing. Rules are mapped to each tool's native format during generation. See the **Rules** row in the [supported tools matrix](../../reference/supported-tools/) for per-target support levels and the [generate output locations](../../cli/generate/#output-locations) for target directory mappings. +A rule with `globs` goes to Windsurf once, as `.windsurf/rules/.md` with `trigger: glob`. Windsurf also reads a `/AGENTS.md` as a rule for that whole folder, so AgentsMesh does not write one for Windsurf: it would load the rule twice and apply it to more files than its globs name. `agentsmesh import --from windsurf` still reads a `/AGENTS.md` you wrote by hand as a rule for that folder. + Some targets fold additional rules into a root or aggregate instruction file. In those cases AgentsMesh wraps each embedded rule in a [managed embedding block](../../reference/managed-embedding/) so repeated generation does not append duplicates and import can restore the rule to `.agentsmesh/rules/*.md`. ## File naming From 24961aa4e2d8b47cee3571139d74c3e05174fc1b Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 17:47:42 +0200 Subject: [PATCH 30/38] chore(lessons): capture the output-eviction, cache-walk and generated-docs rules --- .agentsmesh/lessons/lessons.json | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index e5e3fdca..ac506088 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -1281,6 +1281,21 @@ "t-glob-aa8ef603" ] }, + "fixture-and-assertion-discipline-in-a-test-that-lists": { + "createdAt": "2026-09-24", + "evidence": [ + "543b348b" + ], + "rule": "In a test that lists generated files with readdirSync(root, { recursive: true }), leave out .agentsmeshcache: generate creates it as a symlink to the shared remote cache, so the walk passes alone but lists other tests' cached repos in the full parallel run.", + "status": "active", + "topics": [ + "fixture-and-assertion-discipline" + ], + "triggers": [ + "t-glob-808c4019", + "t-glob-c731ac40" + ] + }, "fixture-and-assertion-discipline-in-init-yes-tests-init": { "createdAt": "2026-09-24", "evidence": [ @@ -7106,6 +7121,20 @@ "t-glob-4f36b2c0" ] }, + "readme-authoring-never-hand-edit-the-per": { + "createdAt": "2026-09-24", + "evidence": [ + "543b348b" + ], + "rule": "Never hand-edit the per-target sections of website/src/content/docs/reference/supported-tools.mdx: everything between agentsmesh:tool-details:start and :end (and the support-matrix blocks) is rendered from descriptors by scripts/support-matrix-blocks.ts, and tests/integration/matrix-drift-guard.integration.test.ts fails on any drift. Put target-specific prose in the feature pages (for example canonical-config/rules.mdx) and run node_modules/.bin/tsx scripts/render-support-matrix.ts --verify.", + "status": "active", + "topics": [ + "readme-authoring" + ], + "triggers": [ + "t-glob-3af8d08b" + ] + }, "readme-authoring-when-trimming-or-rewriting-readme": { "createdAt": "2026-06-23", "evidence": [], @@ -8531,6 +8560,22 @@ "t-kw-603ecbc4" ] }, + "target-capability-audits-when-a-generator-stops-writing": { + "createdAt": "2026-09-24", + "evidence": [ + "543b348b" + ], + "rule": "When a generator stops writing an output, check whether upgraded projects lose the old copy: the stale sweep deletes only paths inside the target's managedOutputs dirs or files that the previous lock records, so an old nested /AGENTS.md stays on disk forever. Make the importer ignore such a leftover (for example text equal to a native rule body) and say in the changeset that users can delete it.", + "status": "active", + "topics": [ + "target-capability-audits" + ], + "triggers": [ + "t-glob-0605e241", + "t-glob-17bd0955", + "t-glob-98419953" + ] + }, "target-capability-audits-when-a-target-declares-a": { "createdAt": "2026-06-24", "evidence": [ @@ -11695,6 +11740,10 @@ "kind": "file_glob", "pattern": "src/lessons/telemetry.ts" }, + "t-glob-0605e241": { + "kind": "file_glob", + "pattern": "src/targets/*/generator/**" + }, "t-glob-07cf19c0": { "kind": "file_glob", "pattern": "src/lessons/**/*.ts" @@ -12031,6 +12080,10 @@ "kind": "file_glob", "pattern": "tests/unit/targets/*/recall-hooks.test.ts" }, + "t-glob-3af8d08b": { + "kind": "file_glob", + "pattern": "website/src/content/docs/reference/supported-tools.mdx" + }, "t-glob-3bfccedc": { "kind": "file_glob", "pattern": "tests/helpers/temp-git-repo.ts" @@ -12499,6 +12552,10 @@ "kind": "file_glob", "pattern": "src/mcp/instructions.ts" }, + "t-glob-808c4019": { + "kind": "file_glob", + "pattern": "tests/unit/targets/**" + }, "t-glob-8148e382": { "kind": "file_glob", "pattern": "src/lessons/init.ts" @@ -12951,6 +13008,10 @@ "kind": "file_glob", "pattern": "src/targets/*/generator/hooks.ts" }, + "t-glob-c731ac40": { + "kind": "file_glob", + "pattern": "tests/unit/cli/commands/**" + }, "t-glob-c7eb35ad": { "kind": "file_glob", "pattern": "src/targets/catalog/init-starter-targets.ts" From bacd9016bb3c4a5f5e4fccb64239a9712ada7aea Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 18:44:17 +0200 Subject: [PATCH 31/38] fix(windsurf): write rule scope as globs, the field Windsurf reads Windsurf scopes a trigger: glob rule with `globs:`, one string with patterns joined by commas: its docs (docs.devin.ai/desktop/cascade/memories, where docs.windsurf.com redirects) name only `globs`, real .windsurf/rules files use `globs: a,b`, and its language server parses rules in parseRuleFrontmatter and turns the string into a list with globStringToGlobList. The generator wrote one pattern as `glob:`, which Windsurf ignores, so single-glob rules never activated, and several as a YAML list. The generator now writes `globs:` as a comma-joined string. The rules importer reads `globs` as a string or a list and the older `glob:`, splits only on commas outside braces so *.{ts,tsx} stays whole, quotes an unquoted globs value first (the documented `globs: **/*.test.ts` is a YAML alias and failed the whole import), and skips a rule it still cannot parse with a warning instead of aborting. --- .changeset/windsurf-globs-field.md | 5 + src/targets/windsurf/generator/rules.ts | 5 +- src/targets/windsurf/importer.ts | 23 ++-- src/targets/windsurf/rule-globs.ts | 57 +++++++++ tests/e2e/generate-anatomy.e2e.test.ts | 2 +- tests/e2e/generate-capabilities.e2e.test.ts | 4 +- tests/unit/targets/windsurf/generator.test.ts | 5 +- .../unit/targets/windsurf/rule-globs.test.ts | 118 ++++++++++++++++++ .../targets/windsurf/rules-branches.test.ts | 18 +-- .../content/docs/canonical-config/rules.mdx | 2 +- 10 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 .changeset/windsurf-globs-field.md create mode 100644 src/targets/windsurf/rule-globs.ts create mode 100644 tests/unit/targets/windsurf/rule-globs.test.ts diff --git a/.changeset/windsurf-globs-field.md b/.changeset/windsurf-globs-field.md new file mode 100644 index 00000000..8c69afd1 --- /dev/null +++ b/.changeset/windsurf-globs-field.md @@ -0,0 +1,5 @@ +--- +'agentsmesh': patch +--- + +Windsurf rules with a single glob now activate. Windsurf reads a rule's scope from `globs:` (one string, several patterns joined by commas), but `agentsmesh generate` wrote a single pattern as `glob:`, which Windsurf ignores, and several patterns as a YAML list. Now every scoped rule in `.windsurf/rules/*.md` gets `globs: ` or `globs: ,`. `agentsmesh import --from windsurf` also got sturdier: it reads `globs` as a string or a list and still reads the older `glob:`, keeps a brace pattern like `src/**/*.{ts,tsx}` whole, accepts the unquoted form from Windsurf's docs (`globs: **/*.test.ts`), which used to stop the whole import with a YAML error, and skips a rule it still cannot read with a warning instead of failing. diff --git a/src/targets/windsurf/generator/rules.ts b/src/targets/windsurf/generator/rules.ts index 40d442df..9a7136f6 100644 --- a/src/targets/windsurf/generator/rules.ts +++ b/src/targets/windsurf/generator/rules.ts @@ -4,6 +4,7 @@ import { serializeFrontmatter } from '../../../utils/text/markdown.js'; import { appendEmbeddedRulesBlock } from '../../projection/managed-blocks.js'; import { WINDSURF_RULES_DIR, WINDSURF_AGENTS_MD } from '../constants.js'; import type { RulesOutput } from './types.js'; +import { formatWindsurfGlobs } from '../rule-globs.js'; function ruleSlug(source: string): string { const name = basename(source, '.md'); @@ -28,8 +29,8 @@ export function generateRules(canonical: CanonicalFiles): RulesOutput[] { const frontmatter: Record = { description: rule.description || undefined, trigger: normalizedTrigger, - glob: rule.globs.length === 1 ? rule.globs[0] : undefined, - globs: rule.globs.length > 1 ? rule.globs : undefined, + // Windsurf reads `globs` as one comma-joined string; it ignores `glob`. + globs: rule.globs.length > 0 ? formatWindsurfGlobs(rule.globs) : undefined, }; Object.keys(frontmatter).forEach((k) => { if (frontmatter[k] === undefined) delete frontmatter[k]; diff --git a/src/targets/windsurf/importer.ts b/src/targets/windsurf/importer.ts index ea558123..9b9ac028 100644 --- a/src/targets/windsurf/importer.ts +++ b/src/targets/windsurf/importer.ts @@ -15,7 +15,9 @@ import type { ImportResult } from '../../core/types.js'; import type { TargetLayoutScope } from '../catalog/target-descriptor.js'; import { createImportReferenceNormalizer } from '../../core/reference/import-rewriter.js'; import { readFileSafe, writeFileAtomic, mkdirp } from '../../utils/filesystem/fs.js'; -import { parseFrontmatter } from '../../utils/text/markdown.js'; +import { tryParseFrontmatter } from '../../utils/text/markdown.js'; +import { logger } from '../../utils/output/logger.js'; +import { parseWindsurfGlobs, quoteWindsurfGlobValues } from './rule-globs.js'; import { serializeImportedRuleWithFallback } from '../import/import-metadata.js'; import { importFileDirectory } from '../import/import-orchestrator.js'; import { importWindsurfNestedAgents } from './import-nested-agents.js'; @@ -101,15 +103,22 @@ export async function importFromWindsurf( extensions: ['.md'], fromTool: 'windsurf', normalize, - mapEntry: async ({ relativePath, normalizeTo }) => { + mapEntry: async ({ relativePath, content, normalizeTo }) => { if (relativePath === '_root.md' && rootContent !== null) return null; const destPath = join(destRulesDir, relativePath); - const { frontmatter, body } = parseFrontmatter(normalizeTo(destPath)); - const normalizedFrontmatter: Record = { ...frontmatter }; - if (typeof normalizedFrontmatter.glob === 'string' && normalizedFrontmatter.glob.trim()) { - normalizedFrontmatter.globs = [normalizedFrontmatter.glob]; - delete normalizedFrontmatter.glob; + const sourceLabel = `${WINDSURF_RULES_DIR}/${relativePath}`; + const parsed = tryParseFrontmatter( + normalizeTo(destPath, quoteWindsurfGlobValues(content)), + sourceLabel, + ); + if (!parsed.ok) { + logger.warn(`Skipping ${sourceLabel}: ${parsed.error.message}`); + return null; } + const { frontmatter, body } = parsed.value; + const { glob, ...normalizedFrontmatter } = frontmatter; + const globs = parseWindsurfGlobs(frontmatter.globs ?? glob); + if (globs.length > 0) normalizedFrontmatter.globs = globs; return { destPath, toPath: `${AB_RULES}/${relativePath}`, diff --git a/src/targets/windsurf/rule-globs.ts b/src/targets/windsurf/rule-globs.ts new file mode 100644 index 00000000..6451c802 --- /dev/null +++ b/src/targets/windsurf/rule-globs.ts @@ -0,0 +1,57 @@ +/** + * Windsurf scopes a `trigger: glob` rule with `globs:`: a string holding one + * pattern or several joined by commas (docs.devin.ai/desktop/cascade/memories). + * A singular `glob:` is not read. Import still accepts `glob:` (older + * agentsmesh output) and a YAML list, which some real rules use. + */ + +export function formatWindsurfGlobs(globs: readonly string[]): string { + return globs.join(','); +} + +/** Split on commas outside `{…}`, so a brace pattern like `*.{ts,tsx}` stays whole. */ +function splitTopLevelCommas(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of value) { + if (char === '{') depth++; + else if (char === '}') depth = Math.max(0, depth - 1); + if (char === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + parts.push(current); + return parts.map((part) => part.trim()).filter(Boolean); +} + +export function parseWindsurfGlobs(value: unknown): string[] { + if (typeof value === 'string') return splitTopLevelCommas(value); + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +const UNQUOTED_GLOBS_LINE = /^(\s*globs?\s*:[ \t]*)([^\s"'[{|>#][^\r\n]*?)[ \t]*(\r?)$/; + +/** + * Quote an unquoted `globs:`/`glob:` value inside the leading frontmatter. + * Windsurf's own docs example writes the pattern bare, starting with `**`, + * which is not valid YAML (a value starting with `*` is an alias) and aborted + * the whole import. + */ +export function quoteWindsurfGlobValues(content: string): string { + const lines = content.split('\n'); + if (lines[0]?.trim() !== '---') return content; + for (let i = 1; i < lines.length; i++) { + if (lines[i]!.trim() === '---') break; + const match = UNQUOTED_GLOBS_LINE.exec(lines[i]!); + if (match !== null) lines[i] = `${match[1]}${JSON.stringify(match[2])}${match[3]}`; + } + return lines.join('\n'); +} diff --git a/tests/e2e/generate-anatomy.e2e.test.ts b/tests/e2e/generate-anatomy.e2e.test.ts index 9fc3cf10..2d6df56a 100644 --- a/tests/e2e/generate-anatomy.e2e.test.ts +++ b/tests/e2e/generate-anatomy.e2e.test.ts @@ -79,7 +79,7 @@ describe('generate anatomy variants', () => { expect(result.exitCode).toBe(0); // Windsurf also reads src/AGENTS.md as a src/** rule, so one glob rule is enough. expect(readFileSync(join(dir, '.windsurf', 'rules', 'src.md'), 'utf-8')).toBe( - '---\ndescription: Src rules\ntrigger: glob\nglob: src/**/*.ts\n---\n\n# Src windsurf rules\nUse strict mode.', + '---\ndescription: Src rules\ntrigger: glob\nglobs: src/**/*.ts\n---\n\n# Src windsurf rules\nUse strict mode.', ); expect(existsSync(join(dir, 'src', 'AGENTS.md'))).toBe(false); }); diff --git a/tests/e2e/generate-capabilities.e2e.test.ts b/tests/e2e/generate-capabilities.e2e.test.ts index 6d4f8ecb..e8bb75a1 100644 --- a/tests/e2e/generate-capabilities.e2e.test.ts +++ b/tests/e2e/generate-capabilities.e2e.test.ts @@ -483,9 +483,9 @@ features: [rules, commands, skills, hooks, mcp] fileNotContains(join(dir, 'AGENTS.md'), 'root: true'); fileNotContains(join(dir, 'AGENTS.md'), '---'); - // Rules use trigger-based Windsurf frontmatter with glob for single-pattern scope. + // Rules use trigger-based Windsurf frontmatter; Windsurf reads the scope from `globs`. fileContains(join(dir, '.windsurf', 'rules', 'typescript.md'), 'trigger: glob'); - fileContains(join(dir, '.windsurf', 'rules', 'typescript.md'), 'glob: src/**/*.ts'); + fileContains(join(dir, '.windsurf', 'rules', 'typescript.md'), '\nglobs: src/**/*.ts\n'); fileContains(join(dir, '.windsurf', 'rules', 'typescript.md'), 'description: TypeScript'); // Workflows include command description as intro + body content. diff --git a/tests/unit/targets/windsurf/generator.test.ts b/tests/unit/targets/windsurf/generator.test.ts index 41ace56b..f2dfb681 100644 --- a/tests/unit/targets/windsurf/generator.test.ts +++ b/tests/unit/targets/windsurf/generator.test.ts @@ -84,7 +84,8 @@ describe('generateRules (windsurf)', () => { expect(perRule).toBeDefined(); expect(perRule?.content).toContain('description: TS rules'); expect(perRule?.content).toContain('trigger: glob'); - expect(perRule?.content).toContain('glob: src/**/*.ts'); + expect(perRule?.content).toContain('\nglobs: src/**/*.ts\n'); + expect(perRule?.content).not.toContain('\nglob:'); expect(perRule?.content).toContain('Use strict TS.'); }); @@ -302,7 +303,7 @@ describe('generateRules (windsurf) — AGENTS.md + trigger', () => { const results = generateRules(canonical); const rule = results.find((r) => r.path === '.windsurf/rules/no-trigger.md'); expect(rule?.content).toContain('trigger: glob'); - expect(rule?.content).toContain('glob: "**/*.ts"'); + expect(rule?.content).toContain('\nglobs: "**/*.ts"\n'); }); }); diff --git a/tests/unit/targets/windsurf/rule-globs.test.ts b/tests/unit/targets/windsurf/rule-globs.test.ts new file mode 100644 index 00000000..bd508eca --- /dev/null +++ b/tests/unit/targets/windsurf/rule-globs.test.ts @@ -0,0 +1,118 @@ +/** + * Windsurf scopes a `trigger: glob` rule with `globs:`, a string with one + * pattern or several joined by commas (docs.devin.ai/desktop/cascade/memories; + * its language server turns that string into a list). A singular `glob:` is not + * read, so rules written with it never activated. Import reads every form real + * files use, and the documented unquoted value no longer aborts it. + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runGenerate } from '../../../../src/cli/commands/generate.js'; +import { runImport } from '../../../../src/cli/commands/import.js'; +import { + formatWindsurfGlobs, + parseWindsurfGlobs, +} from '../../../../src/targets/windsurf/rule-globs.js'; +import { splitFrontmatter } from '../../../../src/utils/text/markdown.js'; +import { logger } from '../../../../src/utils/output/logger.js'; + +let root: string; + +const write = (rel: string, text: string): void => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); +}; +const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const globsOf = (rel: string): unknown => + (parseYaml(splitFrontmatter(read(rel))?.yaml ?? '') as { globs?: unknown }).globs; +const canonicalRule = (name: string, globs: string): void => + write(`.agentsmesh/rules/${name}.md`, `---\ndescription: d\nglobs: ${globs}\n---\n# R\n`); + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'am-windsurf-globs-'))); + write('agentsmesh.yaml', 'version: 1\ntargets: [windsurf]\nfeatures: [rules]\n'); + write('.agentsmesh/rules/_root.md', '---\nroot: true\n---\n# Root\n'); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('Windsurf rule globs', () => { + it('generates globs, never glob, as one comma-joined string', async () => { + canonicalRule('one', '["src/**/*.ts"]'); + canonicalRule('two', '["src/**/*.ts", "tests/**/*.ts"]'); + canonicalRule('star', '["**/*.css"]'); + + await runGenerate({}, root, { printMatrix: false }); + + expect([ + read('.windsurf/rules/one.md'), + read('.windsurf/rules/two.md'), + read('.windsurf/rules/star.md'), + ]).toEqual([ + '---\ndescription: d\ntrigger: glob\nglobs: src/**/*.ts\n---\n\n# R', + '---\ndescription: d\ntrigger: glob\nglobs: src/**/*.ts,tests/**/*.ts\n---\n\n# R', + '---\ndescription: d\ntrigger: glob\nglobs: "**/*.css"\n---\n\n# R', + ]); + }); + + it('imports every globs form real Windsurf rules use, and the older glob', async () => { + write('AGENTS.md', '# Root\n'); + write('.windsurf/rules/docs.md', '---\ntrigger: glob\nglobs: **/*.css,**/*.tsx\n---\nA\n'); + write('.windsurf/rules/spaced.md', '---\ntrigger: glob\nglobs: **/*.rb, Rakefile\n---\nB\n'); + write('.windsurf/rules/brace.md', '---\ntrigger: glob\nglobs: src/**/*.{ts,tsx}\n---\nC\n'); + write('.windsurf/rules/list.md', '---\ntrigger: glob\nglobs: ["src/main/**/*.java"]\n---\nD\n'); + write('.windsurf/rules/old.md', '---\ntrigger: glob\nglob: "lib/**"\n---\nE\n'); + + await runImport({ from: 'windsurf' }, root); + + expect( + ['docs', 'spaced', 'brace', 'list', 'old'].map((n) => globsOf(`.agentsmesh/rules/${n}.md`)), + ).toEqual([ + ['**/*.css', '**/*.tsx'], + ['**/*.rb', 'Rakefile'], + ['src/**/*.{ts,tsx}'], + ['src/main/**/*.java'], + ['lib/**'], + ]); + }); + + it('round-trips several globs, braces included, through generate and import', async () => { + canonicalRule('mix', '["src/**/*.{ts,tsx}", "tests/**"]'); + + await runGenerate({}, root, { printMatrix: false }); + await runImport({ from: 'windsurf' }, root); + + expect(globsOf('.agentsmesh/rules/mix.md')).toEqual(['src/**/*.{ts,tsx}', 'tests/**']); + }); + + it('skips a rule whose frontmatter still cannot be read, and imports the rest', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + write('AGENTS.md', '# Root\n'); + write('.windsurf/rules/bad.md', '---\ntrigger: [unclosed\n---\nBAD\n'); + write('.windsurf/rules/good.md', '---\ntrigger: glob\nglobs: src/**\n---\nGOOD\n'); + + await runImport({ from: 'windsurf' }, root); + + expect(globsOf('.agentsmesh/rules/good.md')).toEqual(['src/**']); + expect(warn.mock.calls.map(([m]) => String(m).split(':')[0])).toEqual([ + 'Skipping .windsurf/rules/bad.md', + ]); + }); +}); + +describe('formatWindsurfGlobs and parseWindsurfGlobs', () => { + it('join with commas and split only outside braces', () => { + expect(formatWindsurfGlobs(['a/**', 'b/*.{x,y}'])).toBe('a/**,b/*.{x,y}'); + expect([ + parseWindsurfGlobs(' a/** , b/*.{x,y} ,'), + parseWindsurfGlobs(['a/**', 3, ' b ']), + parseWindsurfGlobs(undefined), + ]).toEqual([['a/**', 'b/*.{x,y}'], ['a/**', 'b'], []]); + }); +}); diff --git a/tests/unit/targets/windsurf/rules-branches.test.ts b/tests/unit/targets/windsurf/rules-branches.test.ts index 294859c4..939412d0 100644 --- a/tests/unit/targets/windsurf/rules-branches.test.ts +++ b/tests/unit/targets/windsurf/rules-branches.test.ts @@ -104,8 +104,9 @@ describe('windsurf generateRules — root + targets filter', () => { }); }); -describe('windsurf generateRules — frontmatter glob/globs branches', () => { - it('uses singular `glob` field with one glob', () => { +describe('windsurf generateRules — frontmatter globs', () => { + // Windsurf reads only `globs`, a comma-joined string; a singular `glob` is ignored. + it('writes one glob as the `globs` string', () => { const result = generateRules( makeCanonical({ rules: [ @@ -123,12 +124,12 @@ describe('windsurf generateRules — frontmatter glob/globs branches', () => { ); const single = result.find((r) => r.path === `${WINDSURF_RULES_DIR}/single.md`); expect(single).toBeDefined(); - expect(single!.content).toContain('description: TS rules'); - expect(single!.content).toContain('glob: src/**/*.ts'); - expect(single!.content).not.toMatch(/^globs:/m); + expect(single!.content).toBe( + '---\ndescription: TS rules\ntrigger: glob\nglobs: src/**/*.ts\n---\n\nB', + ); }); - it('uses plural `globs` field with multiple globs', () => { + it('writes several globs as one comma-joined `globs` string', () => { const result = generateRules( makeCanonical({ rules: [ @@ -146,8 +147,7 @@ describe('windsurf generateRules — frontmatter glob/globs branches', () => { ); const multi = result.find((r) => r.path === `${WINDSURF_RULES_DIR}/multi.md`); expect(multi).toBeDefined(); - expect(multi!.content).toContain('globs:'); - expect(multi!.content).not.toMatch(/^glob:/m); + expect(multi!.content).toBe('---\ntrigger: glob\nglobs: src/**/*.ts,tests/**/*.ts\n---\n\nB'); }); it('emits plain body (no frontmatter) when description+trigger+globs are all empty', () => { @@ -197,7 +197,7 @@ describe('windsurf generateRules — scoped rules', () => { { path: 'AGENTS.md', content: '# Root\n\nRoot body.' }, { path: `${WINDSURF_RULES_DIR}/src-scope.md`, - content: '---\ndescription: Src\ntrigger: glob\nglob: src/**/*.ts\n---\n\nsrc-body', + content: '---\ndescription: Src\ntrigger: glob\nglobs: src/**/*.ts\n---\n\nsrc-body', }, ]); }); diff --git a/website/src/content/docs/canonical-config/rules.mdx b/website/src/content/docs/canonical-config/rules.mdx index bbf77a4a..d64354c7 100644 --- a/website/src/content/docs/canonical-config/rules.mdx +++ b/website/src/content/docs/canonical-config/rules.mdx @@ -89,7 +89,7 @@ Always run `pnpm typecheck` before committing. Rules are mapped to each tool's native format during generation. See the **Rules** row in the [supported tools matrix](../../reference/supported-tools/) for per-target support levels and the [generate output locations](../../cli/generate/#output-locations) for target directory mappings. -A rule with `globs` goes to Windsurf once, as `.windsurf/rules/.md` with `trigger: glob`. Windsurf also reads a `/AGENTS.md` as a rule for that whole folder, so AgentsMesh does not write one for Windsurf: it would load the rule twice and apply it to more files than its globs name. `agentsmesh import --from windsurf` still reads a `/AGENTS.md` you wrote by hand as a rule for that folder. +A rule with `globs` goes to Windsurf once, as `.windsurf/rules/.md` with `trigger: glob` and a `globs:` line: Windsurf reads the scope from `globs` (one string, several patterns joined by commas) and ignores a singular `glob:`. `agentsmesh import --from windsurf` reads `globs` as a string or a list, and the older `glob:`, and keeps a brace pattern such as `*.{ts,tsx}` whole. Windsurf also reads a `/AGENTS.md` as a rule for that whole folder, so AgentsMesh does not write one for Windsurf: it would load the rule twice and apply it to more files than its globs name. `agentsmesh import --from windsurf` still reads a `/AGENTS.md` you wrote by hand as a rule for that folder. Some targets fold additional rules into a root or aggregate instruction file. In those cases AgentsMesh wraps each embedded rule in a [managed embedding block](../../reference/managed-embedding/) so repeated generation does not append duplicates and import can restore the rule to `.agentsmesh/rules/*.md`. From 734db3f293cc829c73d0a37da350224ab25880ed Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 18:44:35 +0200 Subject: [PATCH 32/38] chore(lessons): capture the zsh path, windsurf parser and globs quoting rules --- .agentsmesh/lessons/lessons.json | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index ac506088..1f191f5f 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -7444,6 +7444,20 @@ "t-kw-6399aaee" ] }, + "shell-quoting-in-zsh-bash-calls-never-3": { + "createdAt": "2026-09-24", + "evidence": [ + "bacd9016" + ], + "rule": "In zsh Bash calls, never name a loop or read variable path (or fpath, cdpath): zsh ties path to PATH, so 'while read -r repo path' wipes the command search path and every later command (awk, head, tr) fails with 'command not found' while the loop still runs. Use another name such as rel.", + "status": "active", + "topics": [ + "shell-quoting" + ], + "triggers": [ + "t-cmd-c69a2607" + ] + }, "shell-quoting-never-delete-a-ts-function": { "createdAt": "2026-09-22", "evidence": [ @@ -8932,6 +8946,21 @@ "t-glob-8ac72756" ] }, + "targets-any-reader-of-windsurf-rule": { + "createdAt": "2026-09-24", + "evidence": [ + "bacd9016" + ], + "rule": "Any reader of Windsurf rule files must quote an unquoted globs/glob value before YAML parsing (quoteWindsurfGlobValues in src/targets/windsurf/rule-globs.ts): Windsurf's documented form 'globs: **/*.test.ts' is a YAML alias and made the strict parser abort the whole import. Split globs on commas outside braces (parseWindsurfGlobs) so *.{ts,tsx} stays one pattern.", + "status": "active", + "topics": [ + "targets" + ], + "triggers": [ + "t-glob-6af95f98", + "t-glob-593e3d22" + ] + }, "targets-check-a-target-s-rule": { "createdAt": "2026-09-24", "evidence": [ @@ -9174,6 +9203,20 @@ "t-kw-12b92504" ] }, + "targets-when-windsurf-s-docs-leave": { + "createdAt": "2026-09-24", + "evidence": [ + "bacd9016" + ], + "rule": "When Windsurf's docs leave a rule or config field unclear, check its real parser: the Windsurf JetBrains plugin ships the Cascade language server at ~/Library/Application Support/JetBrains//plugins/codeium//language_server_macos_arm; its Go function names (exa/cortex/memories.parseRuleFrontmatter, globStringToGlobList, triggerStringToCortexMemoryTrigger) and protobuf tags show which keys and value shapes it reads. Search it with python over the raw bytes or strings output; grep here is ugrep and rejects long context patterns.", + "status": "active", + "topics": [ + "targets" + ], + "triggers": [ + "t-glob-a566b381" + ] + }, "targets-zed-s-file-scan-exclusions": { "createdAt": "2026-09-01", "evidence": [], @@ -11572,6 +11615,10 @@ "kind": "command_pattern", "pattern": "git push origin" }, + "t-cmd-c69a2607": { + "kind": "command_pattern", + "pattern": "read -r" + }, "t-cmd-c6b5fd8b": { "kind": "command_pattern", "pattern": "git reset --hard" @@ -12256,6 +12303,10 @@ "kind": "file_glob", "pattern": "tests/helpers/**" }, + "t-glob-593e3d22": { + "kind": "file_glob", + "pattern": "src/targets/windsurf/rule-globs.ts" + }, "t-glob-5995acad": { "kind": "file_glob", "pattern": "src/targets/**/generator*.ts" @@ -12368,6 +12419,10 @@ "kind": "file_glob", "pattern": "tests/integration/init-wizard.integration.test.ts" }, + "t-glob-6af95f98": { + "kind": "file_glob", + "pattern": "src/targets/windsurf/import*.ts" + }, "t-glob-6b98951d": { "kind": "file_glob", "pattern": "src/cli/commands/init-apply.ts" @@ -12800,6 +12855,10 @@ "kind": "file_glob", "pattern": "src/cli/commands/init-templates.ts" }, + "t-glob-a566b381": { + "kind": "file_glob", + "pattern": "src/targets/windsurf/**" + }, "t-glob-a5fc7764": { "kind": "file_glob", "pattern": "tests/**/*posix*" From f65a95dafc05cfcbb26d73df8bde70f656b272b2 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 20:16:31 +0200 Subject: [PATCH 33/38] refactor: cut helpers and test boilerplate the unpushed fixes left behind - generate-lock: inline currentSources, a one-caller helper left from the first #136 design. - mcp-merge: inline readExistingServers, now a one-line wrapper. - uninstall-io: parseUninstallNames uses flatMap (same order, keeps duplicates). - import-legacy-read: plain Error instead of LegacyStrayRuleError, whose class and code nothing read. - windsurf rule-globs: drop formatWindsurfGlobs (globs.join(',') inline in the generator) and read a globs list with the shared toToolsArray. - tests: nine new test files shared one copied temp-project block (mkdtemp, write, read, cleanup); they now use tests/helpers/temp-project.ts. No behaviour change. --- src/cli/commands/generate-lock.ts | 17 ++----- src/install/uninstall/uninstall-io.ts | 12 ++--- src/lessons/import-legacy-read.ts | 21 +++----- src/targets/import/mcp-merge.ts | 6 +-- src/targets/windsurf/generator/rules.ts | 3 +- src/targets/windsurf/rule-globs.ts | 10 +--- tests/helpers/temp-project.ts | 32 +++++++++++++ .../commands/import-keeps-settings.test.ts | 39 ++++++--------- .../commands/init-same-name-helper.test.ts | 48 ++++++------------- .../unit/cli/commands/init-same-name.test.ts | 28 ++++------- .../lessons/import-legacy-lossless.test.ts | 40 +++++----------- .../targets/claude-code/rule-paths.test.ts | 26 ++++------ .../codex-cli/nested-rules-roundtrip.test.ts | 44 +++++------------ .../targets/import/run-target-import.test.ts | 27 ++++------- .../unit/targets/windsurf/rule-globs.test.ts | 33 ++++--------- .../windsurf/scoped-rule-roundtrip.test.ts | 41 +++++----------- 16 files changed, 159 insertions(+), 268 deletions(-) create mode 100644 tests/helpers/temp-project.ts diff --git a/src/cli/commands/generate-lock.ts b/src/cli/commands/generate-lock.ts index 8cea7cbf..05cb042d 100644 --- a/src/cli/commands/generate-lock.ts +++ b/src/cli/commands/generate-lock.ts @@ -46,17 +46,6 @@ function sameContent(previous: LockFile, next: LockContent): boolean { ); } -async function currentSources( - canonicalDir: string, - resolvedExtends: ResolvedExtend[], -): Promise { - return { - checksums: await buildChecksums(canonicalDir), - extends: resolvedExtends.length > 0 ? await buildExtendChecksums(resolvedExtends) : {}, - packs: await buildPackChecksums(join(canonicalDir, 'packs')), - }; -} - /** * Write `.agentsmesh/.lock` when its content changed; returns whether it did. * `skippedTargets` are enabled targets a `--targets` run did not generate. @@ -69,7 +58,11 @@ export async function writeLockFile( skippedTargets: readonly string[] = [], ): Promise { const previous = await readLock(context.canonicalDir); - const sources = await currentSources(context.canonicalDir, resolvedExtends); + const sources: LockSources = { + checksums: await buildChecksums(context.canonicalDir), + extends: resolvedExtends.length > 0 ? await buildExtendChecksums(resolvedExtends) : {}, + packs: await buildPackChecksums(join(context.canonicalDir, 'packs')), + }; const staleTargets = nextStaleTargets( previous?.staleTargets, skippedTargets, diff --git a/src/install/uninstall/uninstall-io.ts b/src/install/uninstall/uninstall-io.ts index 01fb2a55..927052a5 100644 --- a/src/install/uninstall/uninstall-io.ts +++ b/src/install/uninstall/uninstall-io.ts @@ -7,16 +7,12 @@ export function parseUninstallNames(args: readonly string[]): string[] { // Preserves duplicates so `planUninstall`'s `detectDuplicates` guard can // raise the documented "probably a typo or scripted-loop bug" error // instead of being silenced here. - const out: string[] = []; - for (const arg of args) { - for (const part of arg + return args.flatMap((arg) => + arg .split(',') .map((s) => s.trim()) - .filter(Boolean)) { - out.push(part); - } - } - return out; + .filter(Boolean), + ); } export function defaultUninstallAdapter(): PromptAdapter { diff --git a/src/lessons/import-legacy-read.ts b/src/lessons/import-legacy-read.ts index 4671a98b..bb61b696 100644 --- a/src/lessons/import-legacy-read.ts +++ b/src/lessons/import-legacy-read.ts @@ -27,19 +27,6 @@ export class LegacyTopicPathError extends Error { } } -/** Thrown when a legacy topic file has a list item the migration cannot place. */ -export class LegacyStrayRuleError extends Error { - readonly code = 'LEGACY_STRAY_RULE'; - constructor(file: string, line: number) { - super( - `Legacy lessons were not migrated: ${file} line ${line} is a list item outside a ` + - '"## Rules" or "## Lessons" section. Move it under one of them or delete it, then run ' + - '`agentsmesh lessons import-md`. Nothing was changed.', - ); - this.name = 'LegacyStrayRuleError'; - } -} - /** * Resolve a project-relative legacy topic path, refusing absolute paths, drive * or UNC paths (on any host OS), traversal after normalization, and symlinks @@ -103,7 +90,13 @@ export async function readLegacySource( } const parsed = parseRulesSection(readFileSync(topicFile, 'utf8')); - if (parsed.strayLine !== null) throw new LegacyStrayRuleError(cluster.file, parsed.strayLine); + if (parsed.strayLine !== null) { + throw new Error( + `Legacy lessons were not migrated: ${cluster.file} line ${parsed.strayLine} is a list ` + + 'item outside a "## Rules" or "## Lessons" section. Move it under one of them or ' + + 'delete it, then run `agentsmesh lessons import-md`. Nothing was changed.', + ); + } for (const { index: ruleIndex, body, evidence } of parsed.rules) { const lessonEvidence = [ `legacy:${cluster.file}#rule-${ruleIndex}`, diff --git a/src/targets/import/mcp-merge.ts b/src/targets/import/mcp-merge.ts index 04b38a7e..5bc41506 100644 --- a/src/targets/import/mcp-merge.ts +++ b/src/targets/import/mcp-merge.ts @@ -15,16 +15,12 @@ export async function writeMcpWithMerge( imported: Record, ): Promise { const destPath = join(projectRoot, canonicalPath); - const existing = await readExistingServers(destPath); + const existing = parseMcpServers(await readFileSafe(destPath)); const merged: Record = { ...existing, ...imported }; await mkdirp(dirname(destPath)); await writeFileAtomic(destPath, JSON.stringify({ mcpServers: merged }, null, 2)); } -async function readExistingServers(path: string): Promise> { - return parseMcpServers(await readFileSafe(path)); -} - /** Servers in canonical mcp.json text; `{}` when it is missing or not valid. */ export function parseMcpServers(content: string | null): Record { if (content === null) return {}; diff --git a/src/targets/windsurf/generator/rules.ts b/src/targets/windsurf/generator/rules.ts index 9a7136f6..8a1032ea 100644 --- a/src/targets/windsurf/generator/rules.ts +++ b/src/targets/windsurf/generator/rules.ts @@ -4,7 +4,6 @@ import { serializeFrontmatter } from '../../../utils/text/markdown.js'; import { appendEmbeddedRulesBlock } from '../../projection/managed-blocks.js'; import { WINDSURF_RULES_DIR, WINDSURF_AGENTS_MD } from '../constants.js'; import type { RulesOutput } from './types.js'; -import { formatWindsurfGlobs } from '../rule-globs.js'; function ruleSlug(source: string): string { const name = basename(source, '.md'); @@ -30,7 +29,7 @@ export function generateRules(canonical: CanonicalFiles): RulesOutput[] { description: rule.description || undefined, trigger: normalizedTrigger, // Windsurf reads `globs` as one comma-joined string; it ignores `glob`. - globs: rule.globs.length > 0 ? formatWindsurfGlobs(rule.globs) : undefined, + globs: rule.globs.length > 0 ? rule.globs.join(',') : undefined, }; Object.keys(frontmatter).forEach((k) => { if (frontmatter[k] === undefined) delete frontmatter[k]; diff --git a/src/targets/windsurf/rule-globs.ts b/src/targets/windsurf/rule-globs.ts index 6451c802..35c9684e 100644 --- a/src/targets/windsurf/rule-globs.ts +++ b/src/targets/windsurf/rule-globs.ts @@ -5,9 +5,7 @@ * agentsmesh output) and a YAML list, which some real rules use. */ -export function formatWindsurfGlobs(globs: readonly string[]): string { - return globs.join(','); -} +import { toToolsArray } from '../import/shared-import-helpers.js'; /** Split on commas outside `{…}`, so a brace pattern like `*.{ts,tsx}` stays whole. */ function splitTopLevelCommas(value: string): string[] { @@ -30,11 +28,7 @@ function splitTopLevelCommas(value: string): string[] { export function parseWindsurfGlobs(value: unknown): string[] { if (typeof value === 'string') return splitTopLevelCommas(value); - if (!Array.isArray(value)) return []; - return value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter(Boolean); + return Array.isArray(value) ? toToolsArray(value) : []; } const UNQUOTED_GLOBS_LINE = /^(\s*globs?\s*:[ \t]*)([^\s"'[{|>#][^\r\n]*?)[ \t]*(\r?)$/; diff --git a/tests/helpers/temp-project.ts b/tests/helpers/temp-project.ts new file mode 100644 index 00000000..ca92bb73 --- /dev/null +++ b/tests/helpers/temp-project.ts @@ -0,0 +1,32 @@ +/** + * A fresh project folder per test, removed afterwards. `root()` is its real + * path (macOS tmp is a /private symlink), and `write`/`read` take paths + * relative to it. Call at module level, before any hook that uses it. + */ + +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach } from 'vitest'; + +export interface TempProject { + readonly root: () => string; + readonly write: (rel: string, text: string) => void; + readonly read: (rel: string) => string; +} + +export function useTempProject(prefix: string): TempProject { + let root = ''; + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + return { + root: () => root, + write: (rel, text) => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + }, + read: (rel) => readFileSync(join(root, rel), 'utf8'), + }; +} diff --git a/tests/unit/cli/commands/import-keeps-settings.test.ts b/tests/unit/cli/commands/import-keeps-settings.test.ts index 27aa04d1..cf2c9b03 100644 --- a/tests/unit/cli/commands/import-keeps-settings.test.ts +++ b/tests/unit/cli/commands/import-keeps-settings.test.ts @@ -4,24 +4,19 @@ * deny rule or a server the first import brought in is never dropped (#131). */ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { runImport } from '../../../../src/cli/commands/import.js'; import { runInit } from '../../../../src/cli/commands/init.js'; const PERMS = '.agentsmesh/permissions.yaml'; const IGNORE = '.agentsmesh/ignore'; -let root: string; +const { root, write, read } = useTempProject('am-import-keep-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); const perms = (): unknown => parseYaml(read(PERMS)); function claudeAndCursor(): void { @@ -37,20 +32,16 @@ function claudeAndCursor(): void { write('.cursorignore', 'build/\n'); } -beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-import-keep-'))); -}); afterEach(() => { vi.unstubAllEnvs(); - rmSync(root, { recursive: true, force: true }); }); describe('importing a second tool', () => { it('keeps the first tool deny rules and ignore patterns', async () => { claudeAndCursor(); - await runImport({ from: 'claude-code' }, root); - await runImport({ from: 'cursor' }, root); + await runImport({ from: 'claude-code' }, root()); + await runImport({ from: 'cursor' }, root()); expect(perms()).toEqual({ allow: ['Bash(npm test)', 'Shell(ls)'], @@ -62,8 +53,8 @@ describe('importing a second tool', () => { it('keeps them in the other order too', async () => { claudeAndCursor(); - await runImport({ from: 'cursor' }, root); - await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'cursor' }, root()); + await runImport({ from: 'claude-code' }, root()); expect(perms()).toEqual({ allow: ['Shell(ls)', 'Bash(npm test)'], @@ -84,8 +75,8 @@ describe('importing a second tool', () => { JSON.stringify({ mcpServers: { geminionly: { command: 'b' }, shared: { command: 'g' } } }), ); - await runImport({ from: 'claude-code' }, root); - await runImport({ from: 'gemini-cli' }, root); + await runImport({ from: 'claude-code' }, root()); + await runImport({ from: 'gemini-cli' }, root()); const servers = ( JSON.parse(read('.agentsmesh/mcp.json')) as { @@ -103,10 +94,10 @@ describe('importing a second tool', () => { it('leaves the files byte-identical when the same tool is imported again', async () => { claudeAndCursor(); - await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'claude-code' }, root()); const first = [read(PERMS), read(IGNORE)]; - await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'claude-code' }, root()); expect([read(PERMS), read(IGNORE)]).toEqual(first); }); @@ -116,12 +107,12 @@ describe('importing a second tool', () => { // init detects Cursor by its rules, not by cli.json. write('.cursor/rules/style.mdc', '---\ndescription: Style\n---\n# Style\n'); // init reads HOME to pick targets; keep this machine's tools out of it. - const home = join(root, 'home'); + const home = join(root(), 'home'); mkdirSync(home); vi.stubEnv('HOME', home); vi.stubEnv('USERPROFILE', home); - await runInit(root, { yes: true }); + await runInit(root(), { yes: true }); const merged = perms() as { allow: string[]; deny: string[] }; expect([[...merged.allow].sort(), [...merged.deny].sort()]).toEqual([ diff --git a/tests/unit/cli/commands/init-same-name-helper.test.ts b/tests/unit/cli/commands/init-same-name-helper.test.ts index ebe07ff1..f7cf6918 100644 --- a/tests/unit/cli/commands/init-same-name-helper.test.ts +++ b/tests/unit/cli/commands/init-same-name-helper.test.ts @@ -4,48 +4,30 @@ * tool's file (#132). The init flow itself is in init-same-name.test.ts. */ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { importKeepingSameName } from '../../../../src/cli/commands/init-same-name.js'; import type { ImportResult } from '../../../../src/core/types.js'; const RULE = '.agentsmesh/rules/x.md'; -let root: string; +const { root, write, read } = useTempProject('am-same-name-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); const md = (text: string): string => `---\ndescription: d\n---\n\n${text}`; const result = (toPath: string): ImportResult => ({ fromTool: 'b', - fromPath: join(root, 'src.md'), + fromPath: join(root(), 'src.md'), toPath, feature: 'rules', }); -beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-same-name-'))); -}); -afterEach(() => rmSync(root, { recursive: true, force: true })); - describe('importKeepingSameName', () => { it('tracks the rules, commands and agents an import wrote, not the root rule or settings', async () => { const owned = new Set(); - await importKeepingSameName(root, 'a', owned, async () => [ + await importKeepingSameName(root(), 'a', owned, async () => [ result(RULE), result('.agentsmesh\\commands\\c.md'), result('.agentsmesh/agents/g.md'), @@ -65,8 +47,8 @@ describe('importKeepingSameName', () => { write(RULE, md('A')); let seen = true; - await importKeepingSameName(root, 'b', new Set([RULE]), async () => { - seen = existsSync(join(root, RULE)); + await importKeepingSameName(root(), 'b', new Set([RULE]), async () => { + seen = existsSync(join(root(), RULE)); return []; }); @@ -77,7 +59,7 @@ describe('importKeepingSameName', () => { write(RULE, md('A')); const { results, copies } = await importKeepingSameName( - root, + root(), 'b', new Set([RULE]), async () => { @@ -103,7 +85,7 @@ describe('importKeepingSameName', () => { const cmd = '.agentsmesh/commands/sub/deploy.md'; write(cmd, md('A')); - const { copies } = await importKeepingSameName(root, 'b', new Set([cmd]), async () => { + const { copies } = await importKeepingSameName(root(), 'b', new Set([cmd]), async () => { write(cmd, md('B')); return [result(cmd)]; }); @@ -116,7 +98,7 @@ describe('importKeepingSameName', () => { it('keeps the earlier file when the text is the same, with or without frontmatter', async () => { write(RULE, 'SAME\n'); - const { copies } = await importKeepingSameName(root, 'b', new Set([RULE]), async () => { + const { copies } = await importKeepingSameName(root(), 'b', new Set([RULE]), async () => { write(RULE, '---\ndescription: other\n---\n\nSAME'); return [result(RULE)]; }); @@ -127,7 +109,7 @@ describe('importKeepingSameName', () => { it('restores the earlier files when the import fails', async () => { write(RULE, md('A')); - const failing = importKeepingSameName(root, 'b', new Set([RULE]), async () => { + const failing = importKeepingSameName(root(), 'b', new Set([RULE]), async () => { write(RULE, md('HALF')); throw new Error('boom'); }); @@ -137,8 +119,8 @@ describe('importKeepingSameName', () => { }); it('skips an earlier file that is already gone', async () => { - await importKeepingSameName(root, 'b', new Set([RULE]), async () => []); + await importKeepingSameName(root(), 'b', new Set([RULE]), async () => []); - expect(existsSync(join(root, RULE))).toBe(false); + expect(existsSync(join(root(), RULE))).toBe(false); }); }); diff --git a/tests/unit/cli/commands/init-same-name.test.ts b/tests/unit/cli/commands/init-same-name.test.ts index 6379a5fc..28255b64 100644 --- a/tests/unit/cli/commands/init-same-name.test.ts +++ b/tests/unit/cli/commands/init-same-name.test.ts @@ -4,20 +4,14 @@ * the later tool's copy gets a `-` name, and nothing is mixed (#132). */ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { runGenerate } from '../../../../src/cli/commands/generate.js'; import { runInit } from '../../../../src/cli/commands/init.js'; -let root: string; - -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); +const { root, write, read } = useTempProject('am-init-same-'); const CLAUDE_TS = '---\nroot: false\ndescription: ts\nglobs: []\n---\n\n# TS\nCLAUDE_TS_TEXT'; const CURSOR_TS = @@ -34,16 +28,14 @@ function sameNameRules(): void { } beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-init-same-'))); // init reads HOME to pick targets; keep this machine's tools out of it. - const home = join(root, 'home'); + const home = join(root(), 'home'); mkdirSync(home); vi.stubEnv('HOME', home); vi.stubEnv('USERPROFILE', home); }); afterEach(() => { vi.unstubAllEnvs(); - rmSync(root, { recursive: true, force: true }); }); describe('init --yes with same-name entries in two tools', () => { @@ -51,7 +43,7 @@ describe('init --yes with same-name entries in two tools', () => { sameNameRules(); write('.claude/rules/claude-only.md', '---\ndescription: c\n---\nCLAUDE_ONLY\n'); - const { data } = await runInit(root, { yes: true }); + const { data } = await runInit(root(), { yes: true }); expect(data.detectedConfigs).toEqual(['claude-code', 'cursor']); expect(read('.agentsmesh/rules/typescript.md')).toBe(CLAUDE_TS); @@ -72,9 +64,9 @@ describe('init --yes with same-name entries in two tools', () => { it('generate then writes both texts to both tools', async () => { sameNameRules(); - await runInit(root, { yes: true }); + await runInit(root(), { yes: true }); - await runGenerate({}, root, { printMatrix: false }); + await runGenerate({}, root(), { printMatrix: false }); expect(read('.claude/rules/typescript.md')).toContain('CLAUDE_TS_TEXT'); expect(read('.claude/rules/typescript-cursor.md')).toContain('CURSOR_TS_TEXT'); @@ -88,7 +80,7 @@ describe('init --yes with same-name entries in two tools', () => { write('.cursor/commands/deploy.md', 'CURSOR_DEPLOY\n'); write('.cursor/rules/style.mdc', '---\ndescription: Style\n---\n# Style\n'); - const { data } = await runInit(root, { yes: true }); + const { data } = await runInit(root(), { yes: true }); expect(read('.agentsmesh/commands/deploy.md')).toBe( '---\ndescription: Deploy\nallowed-tools: []\n---\n\nCLAUDE_DEPLOY', @@ -110,7 +102,7 @@ describe('init --yes with same-name entries in two tools', () => { write('.claude/rules/typescript.md', '---\ndescription: ts\n---\n# TS\nSAME_TEXT\n'); write('.cursor/rules/typescript.mdc', '---\ndescription: ts\n---\n# TS\nSAME_TEXT\n'); - const { data } = await runInit(root, { yes: true }); + const { data } = await runInit(root(), { yes: true }); expect(read('.agentsmesh/rules/typescript.md')).toBe( '---\nroot: false\ndescription: ts\nglobs: []\n---\n\n# TS\nSAME_TEXT', diff --git a/tests/unit/lessons/import-legacy-lossless.test.ts b/tests/unit/lessons/import-legacy-lossless.test.ts index 5406a545..1ac0a21c 100644 --- a/tests/unit/lessons/import-legacy-lossless.test.ts +++ b/tests/unit/lessons/import-legacy-lossless.test.ts @@ -6,19 +6,11 @@ * (#138). */ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { Readable } from 'node:stream'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useTempProject } from '../../helpers/temp-project.js'; import { maybeAutoMigrateLessons } from '../../../src/lessons/auto-migrate.js'; import { runLessons } from '../../../src/cli/commands/lessons.js'; @@ -28,15 +20,11 @@ const INDEX = ' summary: Testing.\n triggers:\n file_globs: []\n command_patterns: []\n' + ' keywords: [testing]\n'; -let root: string; +const { root, write } = useTempProject('am-legacy-lossless-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const has = (rel: string): boolean => existsSync(join(root, rel)); +const has = (rel: string): boolean => existsSync(join(root(), rel)); const rules = (): string[] => { - const graph = JSON.parse(readFileSync(join(root, LESSONS, 'lessons.json'), 'utf8')) as { + const graph = JSON.parse(readFileSync(join(root(), LESSONS, 'lessons.json'), 'utf8')) as { lessons: Record; }; return Object.values(graph.lessons).map((lesson) => lesson.rule); @@ -52,13 +40,9 @@ function legacy(topic: string): void { ); } -beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-legacy-lossless-'))); -}); const realStdin = Object.getOwnPropertyDescriptor(process, 'stdin'); afterEach(() => { if (realStdin !== undefined) Object.defineProperty(process, 'stdin', realStdin); - rmSync(root, { recursive: true, force: true }); }); describe('legacy lessons migration', () => { @@ -68,7 +52,7 @@ describe('legacy lessons migration', () => { '- Bullet rule\n', ); - expect(await maybeAutoMigrateLessons(root)).toBe(true); + expect(await maybeAutoMigrateLessons(root())).toBe(true); expect(rules()).toEqual(['Rule one', 'Wrapped rule with key detail.', 'Bullet rule']); expect([ @@ -81,7 +65,7 @@ describe('legacy lessons migration', () => { it('reads rules under a ## Lessons heading and keeps same-number rules from two sections', async () => { legacy('# T\n\n## Rules\n1. Alpha\n2. Beta\n\n## Lessons\n1. Gamma\n'); - await maybeAutoMigrateLessons(root); + await maybeAutoMigrateLessons(root()); expect(rules()).toEqual(['Alpha', 'Beta', 'Gamma']); }); @@ -89,7 +73,7 @@ describe('legacy lessons migration', () => { it('refuses, changing nothing, when a list item sits outside a rules section', async () => { legacy('# T\n\n## Rules\n1. Alpha\n\n## Notes\n- Stray note\n'); - await expect(maybeAutoMigrateLessons(root)).rejects.toThrow( + await expect(maybeAutoMigrateLessons(root())).rejects.toThrow( 'Legacy lessons were not migrated: .agentsmesh/lessons/topics/testing.md line 7 is a list ' + 'item outside a "## Rules" or "## Lessons" section. Move it under one of them or delete ' + 'it, then run `agentsmesh lessons import-md`. Nothing was changed.', @@ -107,7 +91,7 @@ describe('legacy lessons migration', () => { const payload = { session_id: 'm', hook_event_name: 'SessionStart', - cwd: root, + cwd: root(), source: 'startup', }; Object.defineProperty(process, 'stdin', { @@ -115,7 +99,7 @@ describe('legacy lessons migration', () => { value: Readable.from([Buffer.from(JSON.stringify(payload))]), }); - await runLessons({}, ['hook'], root); + await runLessons({}, ['hook'], root()); expect([has(`${LESSONS}/lessons.json`), has(`${LESSONS}/index.yaml`)]).toEqual([false, true]); }); diff --git a/tests/unit/targets/claude-code/rule-paths.test.ts b/tests/unit/targets/claude-code/rule-paths.test.ts index d8582f13..d997d268 100644 --- a/tests/unit/targets/claude-code/rule-paths.test.ts +++ b/tests/unit/targets/claude-code/rule-paths.test.ts @@ -5,38 +5,32 @@ * rule never silently becomes global (#137). */ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { runGenerate } from '../../../../src/cli/commands/generate.js'; import { runImport } from '../../../../src/cli/commands/import.js'; import { generateRules } from '../../../../src/targets/claude-code/generator.js'; import type { CanonicalFiles } from '../../../../src/core/types.js'; import { splitFrontmatter } from '../../../../src/utils/text/markdown.js'; -let root: string; +const { root, write } = useTempProject('am-claude-paths-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; const frontmatterOf = (rel: string): unknown => - parseYaml(splitFrontmatter(readFileSync(join(root, rel), 'utf8'))?.yaml ?? ''); + parseYaml(splitFrontmatter(readFileSync(join(root(), rel), 'utf8'))?.yaml ?? ''); beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-claude-paths-'))); write('CLAUDE.md', '# Root\n'); write('agentsmesh.yaml', 'version: 1\ntargets: [claude-code]\nfeatures: [rules]\n'); }); -afterEach(() => rmSync(root, { recursive: true, force: true })); describe('Claude Code rule paths', () => { it('imports a paths list as canonical globs, without an extra paths field', async () => { write('.claude/rules/backend.md', '---\npaths:\n - "src/api/**/*.ts"\n---\n\n# Backend\n'); - await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'claude-code' }, root()); expect(frontmatterOf('.agentsmesh/rules/backend.md')).toEqual({ root: false, @@ -49,7 +43,7 @@ describe('Claude Code rule paths', () => { write('.claude/rules/a.md', '---\npaths: "src/**/*.ts, test/**/*.ts"\n---\n# A\n'); write('.claude/rules/b.md', '---\nglobs:\n - lib/**\n---\n# B\n'); - await runImport({ from: 'claude-code' }, root); + await runImport({ from: 'claude-code' }, root()); expect([ (frontmatterOf('.agentsmesh/rules/a.md') as { globs: string[] }).globs, @@ -89,8 +83,8 @@ describe('Claude Code rule paths', () => { it('keeps the scope through import and generate', async () => { write('.claude/rules/backend.md', '---\npaths:\n - "src/api/**/*.ts"\n---\n\n# Backend\n'); - await runImport({ from: 'claude-code' }, root); - await runGenerate({}, root, { printMatrix: false }); + await runImport({ from: 'claude-code' }, root()); + await runGenerate({}, root(), { printMatrix: false }); expect(frontmatterOf('.claude/rules/backend.md')).toEqual({ paths: ['src/api/**/*.ts'] }); }); diff --git a/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts b/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts index cc4e16a2..f37199fd 100644 --- a/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts +++ b/tests/unit/targets/codex-cli/nested-rules-roundtrip.test.ts @@ -5,31 +5,18 @@ * that read or write the same nested files must understand it too. */ -import { - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { runGenerate } from '../../../../src/cli/commands/generate.js'; import { runImport } from '../../../../src/cli/commands/import.js'; -let root: string; +const { root, write, read } = useTempProject('am-codex-nested-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); -const rules = (): string[] => readdirSync(join(root, '.agentsmesh', 'rules')).sort(); +const rules = (): string[] => readdirSync(join(root(), '.agentsmesh', 'rules')).sort(); const count = (text: string, part: string): number => text.split(part).length - 1; -const generate = (): Promise => runGenerate({}, root, { printMatrix: false }); +const generate = (): Promise => runGenerate({}, root(), { printMatrix: false }); function project(targets: string, ruleFrontmatter = ''): void { write('agentsmesh.yaml', `version: 1\ntargets: [${targets}]\nfeatures: [rules]\n`); @@ -40,11 +27,6 @@ function project(targets: string, ruleFrontmatter = ''): void { ); } -beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-codex-nested-'))); -}); -afterEach(() => rmSync(root, { recursive: true, force: true })); - describe('codex-cli nested AGENTS.md round trip', () => { it('is a no-op for rules that came from canonical files', async () => { project('codex-cli'); @@ -52,7 +34,7 @@ describe('codex-cli nested AGENTS.md round trip', () => { const canonical = read('.agentsmesh/rules/typescript.md'); for (let cycle = 0; cycle < 2; cycle++) { - await runImport({ from: 'codex-cli' }, root); + await runImport({ from: 'codex-cli' }, root()); await generate(); } @@ -67,9 +49,9 @@ describe('codex-cli nested AGENTS.md round trip', () => { it('restores an override rule, with its variant, into a fresh canonical tree', async () => { project('codex-cli', 'codex_instruction: override\n'); await generate(); - rmSync(join(root, '.agentsmesh', 'rules', 'typescript.md')); + rmSync(join(root(), '.agentsmesh', 'rules', 'typescript.md')); - await runImport({ from: 'codex-cli' }, root); + await runImport({ from: 'codex-cli' }, root()); expect(rules()).toEqual(['_root.md', 'typescript.md']); expect(read('.agentsmesh/rules/typescript.md')).toContain('codex_instruction: override'); @@ -80,7 +62,7 @@ describe('codex-cli nested AGENTS.md round trip', () => { await generate(); write('src/AGENTS.md', `${read('src/AGENTS.md')}\n\nHAND_WRITTEN\n`); - await runImport({ from: 'codex-cli' }, root); + await runImport({ from: 'codex-cli' }, root()); expect(rules()).toEqual(['_root.md', 'src.md', 'typescript.md']); expect([ @@ -97,7 +79,7 @@ describe('codex-cli nested AGENTS.md round trip', () => { ); await generate(); - await runImport({ from: 'codebuff' }, root); + await runImport({ from: 'codebuff' }, root()); await generate(); expect(rules()).toEqual(['_root.md', 'buffonly.md', 'typescript.md']); @@ -112,7 +94,7 @@ describe('codex-cli nested AGENTS.md round trip', () => { await generate(); write('agentsmesh.yaml', 'version: 1\ntargets: [windsurf]\nfeatures: [rules]\n'); - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); expect(rules()).toEqual(['_root.md', 'typescript.md']); }); diff --git a/tests/unit/targets/import/run-target-import.test.ts b/tests/unit/targets/import/run-target-import.test.ts index 885633e6..682ce179 100644 --- a/tests/unit/targets/import/run-target-import.test.ts +++ b/tests/unit/targets/import/run-target-import.test.ts @@ -4,11 +4,11 @@ * The CLI flows are covered in tests/unit/cli/commands/import-keeps-settings.test.ts. */ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { importFrom } from '../../../../src/public/engine.js'; import { runTargetImport } from '../../../../src/targets/import/run-target-import.js'; import { @@ -23,13 +23,8 @@ const PERMS = '.agentsmesh/permissions.yaml'; const IGNORE = '.agentsmesh/ignore'; const MCP = '.agentsmesh/mcp.json'; -let root: string; +const { root, write, read } = useTempProject('am-run-import-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); const perms = (): unknown => parseYaml(read(PERMS)); /** A cursor-shaped descriptor whose import runs `body` instead. */ @@ -42,14 +37,10 @@ function fakeDescriptor(body: () => void): Pick } const importWith = (body: () => void): Promise => - runTargetImport(fakeDescriptor(body), root, 'project'); + runTargetImport(fakeDescriptor(body), root(), 'project'); -beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-run-import-'))); -}); afterEach(() => { resetRegistry(); - rmSync(root, { recursive: true, force: true }); }); describe('runTargetImport', () => { @@ -64,7 +55,7 @@ describe('runTargetImport', () => { }); registerTargetDescriptor({ ...getDescriptor('cursor')!, ...plugin, id: 'merge-plugin' }); - await importFrom('merge-plugin', { root }); + await importFrom('merge-plugin', { root: root() }); expect(perms()).toEqual({ allow: ['Read', 'PluginAllow'], deny: ['Secret'] }); expect(read(IGNORE)).toBe('keep/\nplugin/\n'); @@ -105,8 +96,8 @@ describe('runTargetImport', () => { write(IGNORE, 'keep/\n'); await importWith(() => { - rmSync(join(root, PERMS)); - rmSync(join(root, IGNORE)); + rmSync(join(root(), PERMS)); + rmSync(join(root(), IGNORE)); }); expect([perms(), read(IGNORE)]).toEqual([{ deny: ['Secret'] }, 'keep/\n']); diff --git a/tests/unit/targets/windsurf/rule-globs.test.ts b/tests/unit/targets/windsurf/rule-globs.test.ts index bd508eca..9a787ab6 100644 --- a/tests/unit/targets/windsurf/rule-globs.test.ts +++ b/tests/unit/targets/windsurf/rule-globs.test.ts @@ -6,40 +6,28 @@ * files use, and the documented unquoted value no longer aborts it. */ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { runGenerate } from '../../../../src/cli/commands/generate.js'; import { runImport } from '../../../../src/cli/commands/import.js'; -import { - formatWindsurfGlobs, - parseWindsurfGlobs, -} from '../../../../src/targets/windsurf/rule-globs.js'; +import { parseWindsurfGlobs } from '../../../../src/targets/windsurf/rule-globs.js'; import { splitFrontmatter } from '../../../../src/utils/text/markdown.js'; import { logger } from '../../../../src/utils/output/logger.js'; -let root: string; +const { root, write, read } = useTempProject('am-windsurf-globs-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); const globsOf = (rel: string): unknown => (parseYaml(splitFrontmatter(read(rel))?.yaml ?? '') as { globs?: unknown }).globs; const canonicalRule = (name: string, globs: string): void => write(`.agentsmesh/rules/${name}.md`, `---\ndescription: d\nglobs: ${globs}\n---\n# R\n`); beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-windsurf-globs-'))); write('agentsmesh.yaml', 'version: 1\ntargets: [windsurf]\nfeatures: [rules]\n'); write('.agentsmesh/rules/_root.md', '---\nroot: true\n---\n# Root\n'); }); afterEach(() => { vi.restoreAllMocks(); - rmSync(root, { recursive: true, force: true }); }); describe('Windsurf rule globs', () => { @@ -48,7 +36,7 @@ describe('Windsurf rule globs', () => { canonicalRule('two', '["src/**/*.ts", "tests/**/*.ts"]'); canonicalRule('star', '["**/*.css"]'); - await runGenerate({}, root, { printMatrix: false }); + await runGenerate({}, root(), { printMatrix: false }); expect([ read('.windsurf/rules/one.md'), @@ -69,7 +57,7 @@ describe('Windsurf rule globs', () => { write('.windsurf/rules/list.md', '---\ntrigger: glob\nglobs: ["src/main/**/*.java"]\n---\nD\n'); write('.windsurf/rules/old.md', '---\ntrigger: glob\nglob: "lib/**"\n---\nE\n'); - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); expect( ['docs', 'spaced', 'brace', 'list', 'old'].map((n) => globsOf(`.agentsmesh/rules/${n}.md`)), @@ -85,8 +73,8 @@ describe('Windsurf rule globs', () => { it('round-trips several globs, braces included, through generate and import', async () => { canonicalRule('mix', '["src/**/*.{ts,tsx}", "tests/**"]'); - await runGenerate({}, root, { printMatrix: false }); - await runImport({ from: 'windsurf' }, root); + await runGenerate({}, root(), { printMatrix: false }); + await runImport({ from: 'windsurf' }, root()); expect(globsOf('.agentsmesh/rules/mix.md')).toEqual(['src/**/*.{ts,tsx}', 'tests/**']); }); @@ -97,7 +85,7 @@ describe('Windsurf rule globs', () => { write('.windsurf/rules/bad.md', '---\ntrigger: [unclosed\n---\nBAD\n'); write('.windsurf/rules/good.md', '---\ntrigger: glob\nglobs: src/**\n---\nGOOD\n'); - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); expect(globsOf('.agentsmesh/rules/good.md')).toEqual(['src/**']); expect(warn.mock.calls.map(([m]) => String(m).split(':')[0])).toEqual([ @@ -106,9 +94,8 @@ describe('Windsurf rule globs', () => { }); }); -describe('formatWindsurfGlobs and parseWindsurfGlobs', () => { - it('join with commas and split only outside braces', () => { - expect(formatWindsurfGlobs(['a/**', 'b/*.{x,y}'])).toBe('a/**,b/*.{x,y}'); +describe('parseWindsurfGlobs', () => { + it('splits only outside braces, and reads a list', () => { expect([ parseWindsurfGlobs(' a/** , b/*.{x,y} ,'), parseWindsurfGlobs(['a/**', 3, ' b ']), diff --git a/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts b/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts index fd726bce..721283ff 100644 --- a/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts +++ b/tests/unit/targets/windsurf/scoped-rule-roundtrip.test.ts @@ -6,45 +6,31 @@ * `/AGENTS.md` loaded it twice and made import add a `.md` rule. */ -import { - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, relative } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useTempProject } from '../../../helpers/temp-project.js'; import { runGenerate } from '../../../../src/cli/commands/generate.js'; import { runImport } from '../../../../src/cli/commands/import.js'; -let root: string; +const { root, write, read } = useTempProject('am-windsurf-scoped-'); -const write = (rel: string, text: string): void => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); -}; -const read = (rel: string): string => readFileSync(join(root, rel), 'utf8'); -const rules = (): string[] => readdirSync(join(root, '.agentsmesh', 'rules')).sort(); -const generate = (): Promise => runGenerate({}, root, { printMatrix: false }); +const rules = (): string[] => readdirSync(join(root(), '.agentsmesh', 'rules')).sort(); +const generate = (): Promise => runGenerate({}, root(), { printMatrix: false }); /** * Every generated file outside `.agentsmesh/` and the config, forward-slashed. * `.agentsmeshcache` is generate's link to the shared remote cache, not output. */ function outputs(): string[] { - return readdirSync(root, { recursive: true, withFileTypes: true }) + return readdirSync(root(), { recursive: true, withFileTypes: true }) .filter((entry) => entry.isFile()) - .map((entry) => relative(root, join(entry.parentPath, entry.name)).replaceAll('\\', '/')) + .map((entry) => relative(root(), join(entry.parentPath, entry.name)).replaceAll('\\', '/')) .filter((path) => !/^(?:\.agentsmesh\/|\.agentsmeshcache\/|agentsmesh)/.test(path)) .sort(); } beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'am-windsurf-scoped-'))); write('agentsmesh.yaml', 'version: 1\ntargets: [windsurf]\nfeatures: [rules]\n'); write('.agentsmesh/rules/_root.md', '---\nroot: true\n---\n# Root\n'); write( @@ -52,7 +38,6 @@ beforeEach(() => { '---\ndescription: ts\nglobs: ["src/**/*.ts"]\n---\n# TS\nSCOPED_TEXT\n', ); }); -afterEach(() => rmSync(root, { recursive: true, force: true })); describe('windsurf scoped rule', () => { it('is written once, as its own glob rule', async () => { @@ -66,7 +51,7 @@ describe('windsurf scoped rule', () => { const first = read('.windsurf/rules/typescript.md'); for (let cycle = 0; cycle < 2; cycle++) { - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); await generate(); } @@ -79,7 +64,7 @@ describe('windsurf scoped rule', () => { await generate(); write('src/AGENTS.md', '# TS\nSCOPED_TEXT'); - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); expect(rules()).toEqual(['_root.md', 'typescript.md']); }); @@ -89,7 +74,7 @@ describe('windsurf scoped rule', () => { write('.windsurf/rules/notes.md', 'PLAIN_NOTE\n'); write('docs/AGENTS.md', 'PLAIN_NOTE'); - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); expect(rules()).toEqual(['_root.md', 'notes.md', 'typescript.md']); }); @@ -98,7 +83,7 @@ describe('windsurf scoped rule', () => { await generate(); write('src/AGENTS.md', '# Src\nHAND_WRITTEN\n'); - await runImport({ from: 'windsurf' }, root); + await runImport({ from: 'windsurf' }, root()); expect(rules()).toEqual(['_root.md', 'src.md', 'typescript.md']); expect(read('.agentsmesh/rules/src.md')).toContain('HAND_WRITTEN'); From d7b42b45f5755bb7b0dc389357f87a8c01fd305f Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 20:16:43 +0200 Subject: [PATCH 34/38] chore(lessons): capture the scripted-rename rule --- .agentsmesh/lessons/lessons.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 1f191f5f..561b838e 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -7800,6 +7800,20 @@ "t-cmd-69dc7d4e" ] }, + "shell-quoting-when-a-script-renames-a": { + "createdAt": "2026-09-24", + "evidence": [ + "f65a95da" + ], + "rule": "When a script renames a variable to a call across test files (root -> root()), handle object shorthand ({ root } must become { root: root() }), skip string literals and test names as well as comments, and prune now-unused imports by real code use, not a word search (a comment saying 'joined' kept an unused join import). Run tsc -p tsconfig.tests.json, eslint and a grep for the new call inside quotes before trusting the rewrite.", + "status": "active", + "topics": [ + "shell-quoting" + ], + "triggers": [ + "t-cmd-d441ad48" + ] + }, "shell-quoting-when-a-scripted-str-replace": { "createdAt": "2026-09-24", "evidence": [ From 6abd853ed2888abcc203969fb2a312aaef7f13b7 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 20:38:20 +0200 Subject: [PATCH 35/38] fix(generate): rebase only Markdown links in a root file two targets share The #139 fix ran shared root files (AGENTS.md written by two targets) through the link rebaser with an identity translation. That rebased every path token, and a backticked canonical folder such as .agentsmesh/skills/qa/ came out in the mesh-relative form skills/qa/, which does not exist from the project root. The broken-link check skips inline code, so tests passed; the repo's own generate --check caught it. rewriteFileLinks gains markdownLinksOnly, and shared root files use it: Markdown link destinations (inline and reference-style) are rebased onto the canonical file, and every other path token stays as written. Refs #139 --- .changeset/shared-root-links.md | 2 +- src/core/reference/link-rebaser.ts | 4 +++ src/core/reference/rewriter.ts | 8 +++--- .../core/generate/shared-root-links.test.ts | 26 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.changeset/shared-root-links.md b/.changeset/shared-root-links.md index c0190645..dfb812ca 100644 --- a/.changeset/shared-root-links.md +++ b/.changeset/shared-root-links.md @@ -2,4 +2,4 @@ 'agentsmesh': patch --- -`agentsmesh generate` no longer fails when the root rule links to another rule and two enabled targets write the same `AGENTS.md` (for example Codex CLI and Cursor, Codex CLI and Gemini CLI, or Cursor and Windsurf). The shared `AGENTS.md` keeps canonical references so both copies stay the same, but a relative link such as `[TS rule](./typescript.md)` was left unchanged and pointed to nothing, so the broken-link check stopped the run. Now such links point to the canonical file (`.agentsmesh/rules/typescript.md`). +`agentsmesh generate` no longer fails when the root rule links to another rule and two enabled targets write the same `AGENTS.md` (for example Codex CLI and Cursor, Codex CLI and Gemini CLI, or Cursor and Windsurf). The shared `AGENTS.md` keeps canonical references so both copies stay the same, but a relative link such as `[TS rule](./typescript.md)` was left unchanged and pointed to nothing, so the broken-link check stopped the run. Now such Markdown links point to the canonical file (`.agentsmesh/rules/typescript.md`); every other path in the shared file stays exactly as written. diff --git a/src/core/reference/link-rebaser.ts b/src/core/reference/link-rebaser.ts index 4acc3a21..3d9b927b 100644 --- a/src/core/reference/link-rebaser.ts +++ b/src/core/reference/link-rebaser.ts @@ -32,6 +32,8 @@ export interface RewriteFileLinksInput { pathExists: (absolutePath: string) => boolean; explicitCurrentDirLinks?: boolean; rewriteBarePathTokens?: boolean; + /** Rewrite Markdown link destinations only; leave every other path token as written. */ + markdownLinksOnly?: boolean; /** `global`: leave links unchanged when they resolve outside `.agentsmesh/`. */ scope?: TargetLayoutScope; /** For project scope: distinguish directory targets without a trailing slash in the link. */ @@ -77,6 +79,8 @@ export function rewriteFileLinks(input: RewriteFileLinksInput): RewriteFileLinks const lineNumSuffix = lineNumMatch ? lineNumMatch[0] : ''; if (!rawCandidate) return match; const tokenContext = getTokenContext(fullContent, offset, offset + rawCandidate.length); + if (input.markdownLinksOnly === true && tokenContext.role !== 'markdown-link-dest') + return match; const candidate = decodeLinkPath(rawCandidate, tokenContext.role); if (tokenContext.role !== 'markdown-link-dest' && WINDOWS_ABSOLUTE_PATH.test(candidate)) { return match; diff --git a/src/core/reference/rewriter.ts b/src/core/reference/rewriter.ts index db1322db..65d2dbef 100644 --- a/src/core/reference/rewriter.ts +++ b/src/core/reference/rewriter.ts @@ -138,8 +138,9 @@ export function rewriteGeneratedReferences( if (!sourceFile) return result; // A root file two targets share keeps canonical references, so its copies - // stay identical and merge. Its relative links are still rebased onto the - // canonical file they name; left as-is they point nowhere (#139). + // stay identical and merge. Its Markdown link destinations are still rebased + // onto the canonical file they name (left as-is they point nowhere, #139); + // every other path token stays exactly as written. const shared = skipPaths?.has(result.path) === true; const artifactMapTarget = artifactMapTargetForResult(result, scope, activeTargets); const cacheKey = artifactCacheKey(result, scope, activeTargets); @@ -166,7 +167,8 @@ export function rewriteGeneratedReferences( translatePath: (absolutePath) => artifactMap.get(absolutePath) ?? absolutePath, pathExists: (absolutePath) => plannedPaths.has(absolutePath) || existsSync(absolutePath), explicitCurrentDirLinks: true, - rewriteBarePathTokens: !shared, + rewriteBarePathTokens: true, + markdownLinksOnly: shared, scope, pathIsDirectory: (absolutePath) => { try { diff --git a/tests/unit/core/generate/shared-root-links.test.ts b/tests/unit/core/generate/shared-root-links.test.ts index 4f0f227d..31957a53 100644 --- a/tests/unit/core/generate/shared-root-links.test.ts +++ b/tests/unit/core/generate/shared-root-links.test.ts @@ -52,4 +52,30 @@ describe('a relative link in a root rule shared by two AGENTS.md targets', () => expect([exitCode, link]).toEqual([0, '.agentsmesh/rules/typescript.md']); expect(existsSync(join(root, link ?? ''))).toBe(true); }); + + it('rebases only link destinations: backticked and prose paths stay as written', async () => { + writeFileSync( + join(root, 'agentsmesh.yaml'), + 'version: 1\ntargets: [codex-cli, cursor]\nfeatures: [rules]\n', + ); + mkdirSync(join(root, '.agentsmesh', 'skills', 'qa'), { recursive: true }); + writeFileSync( + join(root, '.agentsmesh', 'skills', 'qa', 'SKILL.md'), + '---\ndescription: qa\n---\n# QA\n', + ); + writeFileSync( + join(root, '.agentsmesh', 'rules', '_root.md'), + '---\nroot: true\n---\n# Root\nSee [TS rule](./typescript.md) and [ts].\n' + + 'Use the skill (`.agentsmesh/skills/qa/`) and read `.agentsmesh/rules/typescript.md`.\n\n' + + '[ts]: ./typescript.md\n', + ); + + await runGenerate({}, root, { printMatrix: false }); + + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + '# Root\nSee [TS rule](.agentsmesh/rules/typescript.md) and [ts].\n' + + 'Use the skill (`.agentsmesh/skills/qa/`) and read `.agentsmesh/rules/typescript.md`.\n\n' + + '[ts]: .agentsmesh/rules/typescript.md', + ); + }); }); From 62e74846e6017001bd6359d93ee74579f8169a2b Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 20:38:21 +0200 Subject: [PATCH 36/38] chore(generate): refresh target artifacts --- .agentsmesh/.lock | 10 +++++----- .claude/rules/typescript.md | 2 +- src/AGENTS.md | 5 ++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.agentsmesh/.lock b/.agentsmesh/.lock index b010fd9c..7b019703 100644 --- a/.agentsmesh/.lock +++ b/.agentsmesh/.lock @@ -1,9 +1,9 @@ # Auto-generated. DO NOT EDIT MANUALLY. # Tracks the state of all config files for team conflict resolution. -generated_at: 2026-09-23T06:16:28.822Z +generated_at: 2026-09-24T18:28:00.436Z generated_by: serhii -lib_version: 0.40.0 +lib_version: 0.41.0 checksums: agents/code-debugger.md: sha256:33452bf839256a119a602cd10d085bff3cdb8f537ded76e60053bb9d8e5d33bc agents/code-documenter.md: sha256:89b4ebd27718bcd50a7283c95ea1ff9cad153b1112cf695f9f1fc22d90ec85ec @@ -138,7 +138,7 @@ packs: njengah-claude-code-cheat-sheet-agents: sha256:6b0a573bc4a725d0a5d38d323d19382f7a24bd948885d3555b94cbca47a05cbf outputs: CLAUDE.md: sha256:6e15d36c45aa9caf6ec5e321f7d65d215dca31c6d8632858155d452be320e1a7 - .claude/rules/typescript.md: sha256:8ffe7a805c52aa22c070c4efcb9fd5de1c946197712582629f156c195a90709c + .claude/rules/typescript.md: sha256:157a99004dac3a3d42928971c2f090b4f5b076a48f0e7c23089b4fa6b4787c46 AGENTS.md: sha256:e31197bb6b816ca06bc248d16b224afbdb349860eaf29c2b268f171f63f1c3ad .cursor/rules/general.mdc: sha256:db24c27174b45c1ad17f1faffe8406fe57a4ae6450a2a03e1b8937a95fb568a3 .cursor/rules/typescript.mdc: sha256:f7e4f54d82fd35d53871bc44d5330c5d1062253cb10b838d4f896a594f1a048c @@ -147,8 +147,8 @@ outputs: .github/instructions/typescript.instructions.md: sha256:e137275142f4613a56292cc04926d1c96a290d1627c4ac0844b059bb7e18a018 GEMINI.md: sha256:a092e6e133dfed9b3331be87dbe60dd0659eb970eb3ae51cb70819d66d6af7c2 .cline/rules/typescript.md: sha256:157a99004dac3a3d42928971c2f090b4f5b076a48f0e7c23089b4fa6b4787c46 - src/AGENTS.md: sha256:f417aa9c45ec6da39a74f1911e482347033f7373f86229676a12073620abce84 - .windsurf/rules/typescript.md: sha256:49186d6f55329f891c1f3da626b34ffc510dda0692ca321214ccd0f091f463cc + src/AGENTS.md: sha256:1a7613a2089d294597c72c0b09790bae4208db2d02178c4ebf4cef64496b11eb + .windsurf/rules/typescript.md: sha256:9afa2106d00372bf6c203a1f4dce665d859ee07ef54cddb999adbee6dafaecd5 .continue/rules/general.md: sha256:4a90a39588a3662e5ad7e36fe47d4f5861967182117bbb9a567329a96faaa59b .continue/rules/typescript.md: sha256:8ffe7a805c52aa22c070c4efcb9fd5de1c946197712582629f156c195a90709c .junie/AGENTS.md: sha256:7e692a46b05f3a963b28463a1b96389524ab42db71bdc3dbdfb301cfcd317afc diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md index 2bd36c3d..0b2e2bf4 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -1,6 +1,6 @@ --- description: TypeScript-specific coding standards -globs: +paths: - src/**/*.ts - tests/**/*.ts --- diff --git a/src/AGENTS.md b/src/AGENTS.md index 3adc60c7..d0620051 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,6 +1,9 @@ + +## TypeScript-specific coding standards # TypeScript Standards - Use strict mode - Prefer `unknown` over `any` - Use explicit return types for public functions -- Prefer `interface` over `type` for object shapes \ No newline at end of file +- Prefer `interface` over `type` for object shapes + \ No newline at end of file From c9213857796bc4fe0533f7191be575a43c2b55c9 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 20:38:21 +0200 Subject: [PATCH 37/38] chore(release): add 0.42.0 upgrade notes --- .changeset/upgrade-notes-0-42.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/upgrade-notes-0-42.md diff --git a/.changeset/upgrade-notes-0-42.md b/.changeset/upgrade-notes-0-42.md new file mode 100644 index 00000000..8011da01 --- /dev/null +++ b/.changeset/upgrade-notes-0-42.md @@ -0,0 +1,14 @@ +--- +'agentsmesh': minor +--- + +**Upgrade notes.** This release fixes data loss on import and adoption, three security issues, and several generated formats. Most projects need to do nothing, but check these after you upgrade: + +- Run `agentsmesh generate` once. Some generated files change format, so `agentsmesh generate --check` reports drift until you regenerate: Claude Code rules scope files with `paths:` (the field Claude Code reads) instead of `globs:`; Windsurf rules use `globs:` instead of `glob:` and a scoped rule is written once (`generate` removes the old `.windsurf/rules/.md` copy; you can delete an old `/AGENTS.md` it wrote for Windsurf only); Codex CLI and Codebuff write each rule in a nested `/AGENTS.md` with an `agentsmesh:embedded-rule` marker. +- If an earlier `import --from codex-cli`, `codebuff` or `windsurf` cycle created a folder rule such as `.agentsmesh/rules/src.md` that repeats another rule's text, delete it once. +- `agentsmesh init` without `--yes` now stops with exit code 1 when it finds existing config for a tool it would enable, because it would not import that config. Use `agentsmesh init --yes`, or `--targets` to leave that tool out. +- `agentsmesh check` now fails after `agentsmesh generate --targets …` left an enabled target out of date (the lock lists it under `stale_targets`). Run a full `agentsmesh generate`. `check()` and `check --json` report these targets as `staleTargets`. +- Importing a second tool now adds to `.agentsmesh/permissions.yaml`, `.agentsmesh/ignore` and `.agentsmesh/mcp.json` instead of replacing them, and never removes an entry; edit those files to remove one. In one `agentsmesh init --yes` run, two tools' rules, commands or agents with the same name and different text are both kept, the later one as `-.md` (listed as `sameNameCopies` in `init --json`). +- A `root: true` rule from an installed pack or an `extends` source no longer replaces your own `.agentsmesh/rules/_root.md`; it is used as a normal rule, with a warning. +- The MCP server runs tool calls one at a time, in the order they arrive. +- Old lessons stores (`index.yaml` + `topics/` + `journal.md`, up to 0.22): the migration keeps `journal.md`, never runs from the recall hook, and stops with nothing changed if a topic file has a list item outside a `## Rules` or `## Lessons` section. From 1b5a8289c2694ab20e522d76ea16b96872928311 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Thu, 24 Sep 2026 20:38:31 +0200 Subject: [PATCH 38/38] chore(lessons): capture the pre-release generate-check rule --- .agentsmesh/lessons/lessons.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index 561b838e..f488531c 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -7237,6 +7237,21 @@ "t-kw-0182f2d9" ] }, + "release-changesets-before-a-release-run-node": { + "createdAt": "2026-09-24", + "evidence": [ + "6abd853e" + ], + "rule": "Before a release, run node dist/cli.js generate --check on this repo and read the diff of every generated file it would change: a generate-side change can pass the whole test suite and still corrupt real output. The #139 identity translation for shared root files turned the backticked .agentsmesh/skills/post-feature-qa/ in AGENTS.md into skills/post-feature-qa/, and no test saw it because the broken-link check skips inline code. When a rewriter change widens which tokens are rewritten, add a test that asserts backticked and prose paths stay byte-for-byte.", + "status": "active", + "topics": [ + "release-changesets" + ], + "triggers": [ + "t-glob-f935b2a8", + "t-cmd-44e0c062" + ] + }, "release-changesets-before-cutting-a-release-from": { "createdAt": "2026-09-23", "evidence": [ @@ -11401,6 +11416,10 @@ "kind": "command_pattern", "pattern": "<