From aabcc832628e62588504dd26d89440706da34fab Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Tue, 3 Nov 2026 16:15:12 -0500 Subject: [PATCH 1/2] feat(essentials-sync): accept a single file as --source --no-extract can sync one instructions/ess item without requiring the source to be a directory. --- skills/ess/essentials-sync/SKILL.md | 2 +- tools/typescript/essentials-sync/README.md | 2 +- tools/typescript/essentials-sync/src/cli.ts | 2 +- .../essentials-sync/src/jargon-overrides.ts | 25 ++++- .../typescript/essentials-sync/src/prompts.ts | 2 +- tools/typescript/essentials-sync/src/sync.ts | 32 ++++++- tools/typescript/essentials-sync/src/types.ts | 5 +- .../essentials-sync/tests/sync-plan.test.ts | 92 +++++++++++++++++++ 8 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 tools/typescript/essentials-sync/tests/sync-plan.test.ts diff --git a/skills/ess/essentials-sync/SKILL.md b/skills/ess/essentials-sync/SKILL.md index 5949d15e..316dd641 100644 --- a/skills/ess/essentials-sync/SKILL.md +++ b/skills/ess/essentials-sync/SKILL.md @@ -33,7 +33,7 @@ Stop and report if it fails. It checks Node >= 22, that the CLI resolves, and th | Argument | Rule | | --- | --- | -| `--source` | Absolute path. In default (extract) mode it must be a `tools/python//` directory inside the source repo. Ask if the user did not give one. | +| `--source` | Absolute path. In default (extract) mode it must be a `tools/python//` directory inside the source repo. With `--no-extract` it may be a directory or a single file, such as `instructions/ess/ess-.instructions.md`. Ask if the user did not give one. | | `--target-repo` | Absolute path to the essentials clone. Must be a git working tree. Ask if missing. | | `--target-path` | Relative to `--target-repo`. Optional in extract mode (defaults to `packages/python/`), **required** with `--no-extract`. | diff --git a/tools/typescript/essentials-sync/README.md b/tools/typescript/essentials-sync/README.md index b4a01ed5..5a195a68 100644 --- a/tools/typescript/essentials-sync/README.md +++ b/tools/typescript/essentials-sync/README.md @@ -159,7 +159,7 @@ essentials-sync [options] | Option | Required | Description | | --- | --- | --- | -| `--source ` | yes | Absolute path to the source tool directory. In default (extract) mode this must be a `tools/python//` directory inside the source repo. | +| `--source ` | yes | Absolute path to the source. In default (extract) mode this must be a `tools/python//` directory inside the source repo. With `--no-extract` it may be a directory or a single file, such as `instructions/ess/ess-.instructions.md`. | | `--target-repo ` | yes | Absolute path to the target repo root. Must be a git working tree. | | `--target-path ` | no | Path relative to `--target-repo` where the synced package lands. Default: `packages/python/`. Required when `--no-extract` is set. | | `--source-repo ` | no | Source repo root. Default: the git root discovered by walking up from `--source`. | diff --git a/tools/typescript/essentials-sync/src/cli.ts b/tools/typescript/essentials-sync/src/cli.ts index 5eed83d9..73ed1fc9 100644 --- a/tools/typescript/essentials-sync/src/cli.ts +++ b/tools/typescript/essentials-sync/src/cli.ts @@ -47,7 +47,7 @@ function buildSyncProgram(): Command { + " in the source repo, then sync that shared package to the open-source essentials repo." + " Default behavior runs both phases; pass --no-extract for sources that are already generic.", ) - .option("-s, --source ", "Absolute path to the source tool directory (tools/python//)") + .option("-s, --source ", "Absolute path to the source. Extract mode: a tools/python// directory. --no-extract: a directory or a single file") .option("-r, --target-repo ", "Absolute path to the target repo root (must be a git working tree)") .option("-t, --target-path ", "Path relative to --target-repo where the package should land (default: packages/python/)") .option("--source-repo ", "Source repo root (default: git root of --source)") diff --git a/tools/typescript/essentials-sync/src/jargon-overrides.ts b/tools/typescript/essentials-sync/src/jargon-overrides.ts index 85f61a8a..d065cdac 100644 --- a/tools/typescript/essentials-sync/src/jargon-overrides.ts +++ b/tools/typescript/essentials-sync/src/jargon-overrides.ts @@ -5,11 +5,12 @@ import { parseJargonOverrides } from "./jargon-list.js"; export const JARGON_OVERRIDES_FILE = ".essentials-sync-jargon.json"; -// Reads extra jargon terms from /.essentials-sync-jargon.json. A -// missing file means no extra terms; an unreadable one is warned about and +// Reads extra jargon terms from /.essentials-sync-jargon.json. A +// single-file source (an instructions/ess item) loads the file beside it. +// A missing file means no extra terms; an unreadable one is warned about and // treated the same way so a typo never blocks a scan. -export async function loadJargonOverrides(directory: string): Promise { - const configPath = path.join(directory, JARGON_OVERRIDES_FILE); +export async function loadJargonOverrides(anchor: string): Promise { + const configPath = path.join(await jargonDirectory(anchor), JARGON_OVERRIDES_FILE); try { const raw = await fs.readFile(configPath, "utf8"); return parseJargonOverrides(JSON.parse(raw) as unknown).terms; @@ -23,3 +24,19 @@ export async function loadJargonOverrides(directory: string): Promise return []; } } + +async function jargonDirectory(anchor: string): Promise { + try { + const stat = await fs.stat(anchor); + if (stat.isFile()) return path.dirname(anchor); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + return anchor; +} + +function isNotFoundError(error: unknown): boolean { + return Boolean( + error && typeof error === "object" && (error as { code?: string }).code === "ENOENT", + ); +} diff --git a/tools/typescript/essentials-sync/src/prompts.ts b/tools/typescript/essentials-sync/src/prompts.ts index 0cff0560..831b1a7e 100644 --- a/tools/typescript/essentials-sync/src/prompts.ts +++ b/tools/typescript/essentials-sync/src/prompts.ts @@ -87,7 +87,7 @@ TARGET PATH (relative to target repo): ${plan.targetPathRel} TARGET ABSOLUTE PATH: ${plan.targetAbs} TASK: -1. Read every file in the SOURCE directory. You have full read access to it. +1. Read every file under SOURCE. When SOURCE is a single file, that file is the whole item. You have full read access to it. 2. Write the generalized equivalent into TARGET ABSOLUTE PATH. - In COPY mode, the target path does not exist yet. Create it. - In SYNC mode, the target path already exists; reconcile by updating files that have meaningfully diverged. Do not blindly overwrite the target. diff --git a/tools/typescript/essentials-sync/src/sync.ts b/tools/typescript/essentials-sync/src/sync.ts index cfc26db1..b222b04d 100644 --- a/tools/typescript/essentials-sync/src/sync.ts +++ b/tools/typescript/essentials-sync/src/sync.ts @@ -24,12 +24,14 @@ export async function planSync({ const targetAbs = path.join(targetRepoAbs, targetPathRel); if (assertSourceExists) { - await assertDirectoryExists(sourceAbs, "--source"); + await assertFileOrDirectoryExists(sourceAbs, "--source"); } await assertDirectoryExists(targetRepoAbs, "--target-repo"); await assertGitWorkingTree(targetRepoAbs); - const mode: SyncMode = (await directoryExists(targetAbs)) ? "SYNC" : "COPY"; + // A missing target is a first copy. An existing directory or file (a single + // instructions/ess/ess-*.instructions.md item) is a re-sync. + const mode: SyncMode = (await pathExists(targetAbs)) ? "SYNC" : "COPY"; return { mode, @@ -84,6 +86,22 @@ async function assertDirectoryExists(absPath: string, label: string): Promise { + try { + const stat = await fs.stat(absPath); + if (!stat.isDirectory() && !stat.isFile()) { + throw new Error(`${label} is not a file or directory: ${absPath}`); + } + } catch (error) { + if (isNotFoundError(error)) { + throw new Error(`${label} does not exist: ${absPath}`); + } + throw error; + } +} + async function assertGitWorkingTree(repoPath: string): Promise { const gitDir = path.join(repoPath, ".git"); if (!(await directoryExists(gitDir)) && !(await fileExists(gitDir))) { @@ -111,6 +129,16 @@ async function fileExists(p: string): Promise { } } +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch (error) { + if (isNotFoundError(error)) return false; + throw error; + } +} + function isNotFoundError(error: unknown): boolean { if (error && typeof error === "object") { const code = (error as { code?: string }).code; diff --git a/tools/typescript/essentials-sync/src/types.ts b/tools/typescript/essentials-sync/src/types.ts index c950a408..d3c37afc 100644 --- a/tools/typescript/essentials-sync/src/types.ts +++ b/tools/typescript/essentials-sync/src/types.ts @@ -30,8 +30,9 @@ export type SyncMode = "COPY" | "SYNC"; export interface SyncPlan { mode: SyncMode; - // sourceAbs is the directory that Phase B copies INTO essentials. When extract - // is enabled, it points at the path where Phase A will materialize the + // sourceAbs is what Phase B copies INTO essentials: a directory, or a single + // file when --no-extract syncs one instructions/ess item. When extract is + // enabled, it points at the path where Phase A will materialize the // extracted package -- that path will not exist on disk at plan time. sourceAbs: string; targetRepoAbs: string; diff --git a/tools/typescript/essentials-sync/tests/sync-plan.test.ts b/tools/typescript/essentials-sync/tests/sync-plan.test.ts new file mode 100644 index 00000000..66253365 --- /dev/null +++ b/tools/typescript/essentials-sync/tests/sync-plan.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { planSync } from "../src/sync.js"; + +const tempRoots: string[] = []; + +async function makeRepo(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "sync-plan-")); + tempRoots.push(root); + await fs.mkdir(path.join(root, ".git")); + return root; +} + +afterEach(async () => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) await fs.rm(root, { recursive: true, force: true }); + } +}); + +describe("planSync", () => { + it("plans a copy of a single instruction file when the target is missing", async () => { + const sourceRepo = await makeRepo(); + const targetRepo = await makeRepo(); + const source = path.join(sourceRepo, "instructions", "ess", "ess-apm-sources.instructions.md"); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "# APM Sources\n"); + + const plan = await planSync({ + source, + targetRepo, + targetPath: "instructions/ess/ess-apm-sources.instructions.md", + }); + + expect(plan.mode).toBe("COPY"); + expect(plan.sourceAbs).toBe(source); + expect(plan.targetAbs).toBe( + path.join(targetRepo, "instructions", "ess", "ess-apm-sources.instructions.md"), + ); + expect(plan.phaseAEnabled).toBe(false); + }); + + it("plans a sync when the target file already exists", async () => { + const sourceRepo = await makeRepo(); + const targetRepo = await makeRepo(); + const source = path.join(sourceRepo, "instructions", "ess", "ess-rule.instructions.md"); + const target = path.join(targetRepo, "instructions", "ess", "ess-rule.instructions.md"); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(source, "# Rule\n"); + await fs.writeFile(target, "# Rule\n"); + + const plan = await planSync({ + source, + targetRepo, + targetPath: "instructions/ess/ess-rule.instructions.md", + }); + + expect(plan.mode).toBe("SYNC"); + expect(plan.targetAbs).toBe(target); + }); + + it("still accepts a directory source", async () => { + const sourceRepo = await makeRepo(); + const targetRepo = await makeRepo(); + const source = path.join(sourceRepo, "packages", "python", "ess-demo"); + await fs.mkdir(source, { recursive: true }); + + const plan = await planSync({ + source, + targetRepo, + targetPath: "packages/python/ess-demo", + }); + + expect(plan.mode).toBe("COPY"); + expect(plan.sourceAbs).toBe(source); + }); + + it("rejects a missing source", async () => { + const targetRepo = await makeRepo(); + + await expect( + planSync({ + source: path.join(targetRepo, "missing.md"), + targetRepo, + targetPath: "instructions/ess/missing.md", + }), + ).rejects.toThrow(/does not exist/); + }); +}); From 7a347489358f4efd3d6a0eb2161b397410e49703 Mon Sep 17 00:00:00 2001 From: Matt Norris Date: Tue, 3 Nov 2026 16:17:12 -0500 Subject: [PATCH 2/2] feat(instructions): add the APM sources rule Sync the generic instruction so skills and rules are authored in source directories and deployed by APM. --- .../ess/ess-apm-sources.instructions.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 instructions/ess/ess-apm-sources.instructions.md diff --git a/instructions/ess/ess-apm-sources.instructions.md b/instructions/ess/ess-apm-sources.instructions.md new file mode 100644 index 00000000..9c2e3be2 --- /dev/null +++ b/instructions/ess/ess-apm-sources.instructions.md @@ -0,0 +1,47 @@ +--- +description: Author skills in skills/ and rules in instructions/, then deploy them with APM. Never add or edit files in .agents/skills/, .cursor/rules/, or any other agent rules/ directory by hand. +applyTo: "**/.agents/skills/**,**/.cursor/skills/**,**/.claude/skills/**,**/.cursor/rules/**,**/.claude/rules/**,**/.windsurf/rules/**" +--- + +# APM Sources + +[APM](https://microsoft.github.io/apm/) deploys skills and rules into the directories each +agent reads. Those directories hold build output. Author in the source directory and let +APM write the copy. + +| To add or change | Author it in | APM deploys it to | +| --- | --- | --- | +| A skill | `skills//SKILL.md` | `.agents/skills//` | +| A rule scoped to files | `instructions/.instructions.md` | `.cursor/rules/.mdc`, or the host's `rules/` directory | + +Never create, edit, or delete files in `.agents/skills/`, `.cursor/skills/`, +`.cursor/rules/`, or any other agent's `rules/` directory by hand. `apm install` overwrites +hand edits, and a file APM does not track never reaches the other agents the repo targets. + +## Add a skill or rule + +1. Write the source under `skills/` or `instructions/`. +2. Declare it in `apm.yml`: + + ```yaml + dependencies: + apm: + - path: ./skills/my-skill + - path: ./instructions/my-rule.instructions.md + ``` + +3. Run `apm install`, or the repo's wrapper for it. +4. Commit the source, `apm.yml`, `apm.lock.yaml`, and the deployed files together. + +To change an existing skill or rule, edit the source and rerun step 3. If a deployed file +has no source, because `apm.lock.yaml` does not list it, move it into `skills/` or +`instructions/` before changing it. + +A third-party skill is an APM dependency too. Declare its git source in `apm.yml` instead +of copying it into `.agents/skills/`. + +## What does not fit an instruction + +An instruction's frontmatter holds only `description` and `applyTo`, so APM cannot deploy +a rule that applies in every conversation. Put always-on guidance in `AGENTS.md`. Guidance +tied to a task rather than to files becomes a skill.