Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions instructions/ess/ess-apm-sources.instructions.md
Original file line number Diff line number Diff line change
@@ -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/<name>/SKILL.md` | `.agents/skills/<name>/` |
| A rule scoped to files | `instructions/<name>.instructions.md` | `.cursor/rules/<name>.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.
2 changes: 1 addition & 1 deletion skills/ess/essentials-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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/<name>/` directory inside the source repo. With `--no-extract` it may be a directory or a single file, such as `instructions/ess/ess-<name>.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/<package-name>`), **required** with `--no-extract`. |

Expand Down
2 changes: 1 addition & 1 deletion tools/typescript/essentials-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ essentials-sync [options]

| Option | Required | Description |
| --- | --- | --- |
| `--source <path>` | yes | Absolute path to the source tool directory. In default (extract) mode this must be a `tools/python/<name>/` directory inside the source repo. |
| `--source <path>` | yes | Absolute path to the source. In default (extract) mode this must be a `tools/python/<name>/` directory inside the source repo. With `--no-extract` it may be a directory or a single file, such as `instructions/ess/ess-<name>.instructions.md`. |
| `--target-repo <path>` | yes | Absolute path to the target repo root. Must be a git working tree. |
| `--target-path <path>` | no | Path relative to `--target-repo` where the synced package lands. Default: `packages/python/<package-name>`. Required when `--no-extract` is set. |
| `--source-repo <path>` | no | Source repo root. Default: the git root discovered by walking up from `--source`. |
Expand Down
2 changes: 1 addition & 1 deletion tools/typescript/essentials-sync/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>", "Absolute path to the source tool directory (tools/python/<name>/)")
.option("-s, --source <path>", "Absolute path to the source. Extract mode: a tools/python/<name>/ directory. --no-extract: a directory or a single file")
.option("-r, --target-repo <path>", "Absolute path to the target repo root (must be a git working tree)")
.option("-t, --target-path <path>", "Path relative to --target-repo where the package should land (default: packages/python/<package-name>)")
.option("--source-repo <path>", "Source repo root (default: git root of --source)")
Expand Down
25 changes: 21 additions & 4 deletions tools/typescript/essentials-sync/src/jargon-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { parseJargonOverrides } from "./jargon-list.js";

export const JARGON_OVERRIDES_FILE = ".essentials-sync-jargon.json";

// Reads extra jargon terms from <directory>/.essentials-sync-jargon.json. A
// missing file means no extra terms; an unreadable one is warned about and
// Reads extra jargon terms from <anchor>/.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<string[]> {
const configPath = path.join(directory, JARGON_OVERRIDES_FILE);
export async function loadJargonOverrides(anchor: string): Promise<string[]> {
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;
Expand All @@ -23,3 +24,19 @@ export async function loadJargonOverrides(directory: string): Promise<string[]>
return [];
}
}

async function jargonDirectory(anchor: string): Promise<string> {
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",
);
}
2 changes: 1 addition & 1 deletion tools/typescript/essentials-sync/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 30 additions & 2 deletions tools/typescript/essentials-sync/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -84,6 +86,22 @@ async function assertDirectoryExists(absPath: string, label: string): Promise<vo
}
}

// --no-extract sources are either a package directory or one instruction file.
// Extract mode never reaches this check: the extracted package does not exist yet.
async function assertFileOrDirectoryExists(absPath: string, label: string): Promise<void> {
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<void> {
const gitDir = path.join(repoPath, ".git");
if (!(await directoryExists(gitDir)) && !(await fileExists(gitDir))) {
Expand Down Expand Up @@ -111,6 +129,16 @@ async function fileExists(p: string): Promise<boolean> {
}
}

async function pathExists(p: string): Promise<boolean> {
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;
Expand Down
5 changes: 3 additions & 2 deletions tools/typescript/essentials-sync/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions tools/typescript/essentials-sync/tests/sync-plan.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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/);
});
});
Loading