From 3dc625bff0f56b213028999d62c6282f5a235ecc Mon Sep 17 00:00:00 2001 From: Schneider <224583183+schneiderjoseph@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:59:29 -0400 Subject: [PATCH] Install the skill once for every project, and fix the cold start Installing the skill system-wide exposed a bootstrap that could not work. The skill and every agent adapter told an agent to run `npx devia init`. The package is scoped, so in a repository that has not installed devia that resolves to nothing: 404 devia@*. Per-repository that was survivable, since init had usually just been run with the scoped name. As a system-wide skill it is the normal path: the agent meets an unfamiliar repository, and the first command it is told to run fails. They now say `npm i -D @schneiderjoseph/devia && npx devia init`. skills install --global - Writes the skill pack to the agent's own configuration directory, so the contract applies to every project instead of one - Claude Code: ~/.claude/skills/devia/SKILL.md, or CLAUDE_CONFIG_DIR when set - Cursor, Copilot and Windsurf: SKIP with the reason. Their user-level locations are not something devia can determine, and writing to a guessed path in someone's home directory is the confident wrong answer this tool exists to prevent - The only command that writes outside --root: off by default, every path printed, an edited file kept without --force The boundary is written down rather than quietly bent: 04_PERMISSIONS.md and 10_NEVER_ALWAYS.md both carry the exception and its guards, and 02_SURFACES.md says what an adopter receives. Version: package 0.2.0, standard unchanged at 0.1.0. No adopter needs sync. Verified: 33 tests with and without FORCE_COLOR, including one that asserts a plain `skills install` writes nothing outside the repository. validate clean, check P0 clear, memory validate clean. --- .devia/02_SURFACES.md | 3 +- .devia/04_PERMISSIONS.md | 8 +++- .devia/10_NEVER_ALWAYS.md | 5 ++- CHANGELOG.md | 23 ++++++++++ package.json | 2 +- skills/devia/SKILL.md | 3 ++ src/commands/skills.mjs | 75 +++++++++++++++++++++++++++++++ templates/agents/AGENTS.md | 3 +- templates/agents/CLAUDE.md | 3 +- templates/agents/cursor.mdc | 2 +- templates/agents/windsurfrules.md | 2 +- tests/cli.test.mjs | 34 +++++++++++++- 12 files changed, 152 insertions(+), 11 deletions(-) diff --git a/.devia/02_SURFACES.md b/.devia/02_SURFACES.md index 393ea43..cf369b3 100644 --- a/.devia/02_SURFACES.md +++ b/.devia/02_SURFACES.md @@ -13,7 +13,7 @@ | `devia doctor` | Adoption, drift, staleness | `src/commands/doctor.mjs` | 1 when there is no `.devia/` | | `devia rules` | Query the registry | `src/commands/rules.mjs` | 1 when `--id` is unknown | | `devia sync` | Refresh the vendored standard | `src/commands/sync.mjs` | 1 without `.devia/` | -| `devia skills` | Install adapters and the skill pack | `src/commands/skills.mjs` | 2 on a bad action | +| `devia skills` | Install adapters and the skill pack, per repository or `--global` | `src/commands/skills.mjs` | 2 on a bad action | | `devia gap` / `devia debt` | Registry lines | `src/commands/registry.mjs` | 1 when the id is unknown | Global flags: `--root`, `--json`, `--help`, `--version` (prints the CLI **and** standard @@ -37,6 +37,7 @@ say where, or `--yes` to accept it. Nothing is written before that question is s | `init` | `.devia/` (memory, `devia.json`, `impact-map.yaml`, `standard/`) | | `init`, `skills install` | `AGENTS.md`, `CLAUDE.md`, `.cursor/rules/devia.mdc`, `.github/copilot-instructions.md`, `.windsurfrules` | | `skills install --skill` | `.cursor/skills/devia/SKILL.md`, `.claude/skills/devia/SKILL.md` | +| `skills install --global` | Outside the repository: the agent's own skills directory, so the contract applies to every project | `files` in `package.json` decides what npm ships. Adding a directory the CLI reads at runtime without adding it there ships a broken package — see `12_DEBT.md` before assuming it is covered. diff --git a/.devia/04_PERMISSIONS.md b/.devia/04_PERMISSIONS.md index a9036af..1c85768 100644 --- a/.devia/04_PERMISSIONS.md +++ b/.devia/04_PERMISSIONS.md @@ -7,7 +7,7 @@ | Allowed | Never | |---|---| -| Create and update files under `/.devia/` | Write outside `--root` | +| Create and update files under `/.devia/` | Write outside `--root`, except `skills install --global` | | Write the agent adapters at the repository root | Overwrite a file the user has edited, without `--force` | | Read files in the target repository to produce evidence | Send anything over the network | | Replace `.devia/standard/` wholesale on `sync` | Touch the project's own memory content on `sync` | @@ -15,6 +15,12 @@ `init` keeps every existing memory file unless `--force` is passed, because those files hold decisions the tool did not make. +`skills install --global` is the single exception to the boundary: it installs the skill in the +agent's own configuration directory so it applies to every project. It is off by default, it +prints every path it writes, it keeps an edited file without `--force`, and for agents whose +user-level location cannot be determined it reports `SKIP` with the reason rather than guessing +a path inside someone's home directory. + ## Destructive operations | Operation | Where | Guard | diff --git a/.devia/10_NEVER_ALWAYS.md b/.devia/10_NEVER_ALWAYS.md index a879596..0a8b2e3 100644 --- a/.devia/10_NEVER_ALWAYS.md +++ b/.devia/10_NEVER_ALWAYS.md @@ -14,8 +14,9 @@ liability (`ARC-004`) cannot ship a tree of its own. Node built-ins, or argue it in the PR. - **Never let a check return `PASS` when it could not determine the answer.** `SKIP` with the reason. A false pass is worse than a missing check, exactly like a false registry line. -- **Never write outside `--root`.** Every path a command touches is derived from the context, - never from `process.cwd()` inside a command. +- **Never write outside `--root`** — the one exception is `skills install --global`, which is + off by default and prints every path it writes. Every other path a command touches is derived + from the context, never from `process.cwd()` inside a command. - **Never overwrite an adopter's memory file without `--force`.** Those files hold decisions the tool did not make. - **Never add a directory the CLI reads at runtime without adding it to `files` in diff --git a/CHANGELOG.md b/CHANGELOG.md index 1848d9e..e3152da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.2.0 — 2026-09-09 + +The standard is unchanged: `VERSION` stays at 0.1.0 and no adopter needs `devia sync`. This +release is the CLI only. + +### The skill, once for every project + +- `devia skills install --global` installs the skill pack in the agent's own configuration + directory instead of one repository, so the contract applies everywhere. Claude Code is + supported (`~/.claude/skills/devia/SKILL.md`, or `CLAUDE_CONFIG_DIR` when set); Cursor, + Copilot and Windsurf report `SKIP` with the reason, because devia will not guess a path + inside someone's home directory +- It is the only command that writes outside `--root`: off by default, every path printed, an + edited file kept without `--force` (`04_PERMISSIONS.md`, `10_NEVER_ALWAYS.md`) + +### Bootstrap, fixed + +- The skill and every agent adapter told an agent to run `npx devia init`. Since the package is + scoped, that resolves to nothing in a repository that has not installed devia: `404 devia@*`. + They now say `npm i -D @schneiderjoseph/devia && npx devia init`, which is what a cold start + actually needs. Found while installing the skill system-wide, where the cold start is the + normal case rather than the exception + ## 0.1.0 — 2026-09-03 First release. Devia consolidates three bodies of work into one maintained standard plus a diff --git a/package.json b/package.json index da48547..5f326c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schneiderjoseph/devia", - "version": "0.1.0", + "version": "0.2.0", "description": "One standard, one memory: engineering and design rules plus living project memory for AI coding agents", "type": "module", "license": "MIT", diff --git a/skills/devia/SKILL.md b/skills/devia/SKILL.md index 6bf37e0..859831e 100644 --- a/skills/devia/SKILL.md +++ b/skills/devia/SKILL.md @@ -21,9 +21,12 @@ ls .devia **No `.devia/`** → initialise before writing any code: ```bash +npm i -D @schneiderjoseph/devia # the package is scoped, the command is not npx devia init ``` +Install first: `npx devia` only resolves once the package is a dependency of the project. + Then fill `.devia/00_OVERVIEW.md` from what the repository actually contains — stack, modules, where the truth lives. Read the code to fill it; do not invent it. This is not paperwork: it is the difference between a task and a guess (`AGT-002`). diff --git a/src/commands/skills.mjs b/src/commands/skills.mjs index 2f39178..e3a4085 100644 --- a/src/commands/skills.mjs +++ b/src/commands/skills.mjs @@ -1,4 +1,6 @@ +import os from "node:os"; import path from "node:path"; +import process from "node:process"; import { packageRoot, exists, read, writeFile } from "../lib/fs.mjs"; import { color, heading, status, line } from "../lib/ui.mjs"; @@ -36,6 +38,62 @@ export function installAdapters(root, { force = false, agents = Object.keys(ADAP return { written, kept }; } +/** + * Where an agent keeps skills for every project, not one. + * + * Only agents whose user-level location devia can actually determine are listed. The rest are + * reported as SKIP with the reason: guessing a path in someone's home directory and writing to + * it is exactly the kind of confident wrong answer this tool exists to prevent. + */ +function globalSkillTargets() { + const home = os.homedir(); + const claudeDir = process.env.CLAUDE_CONFIG_DIR + ? path.resolve(process.env.CLAUDE_CONFIG_DIR) + : path.join(home, ".claude"); + return { + claude: { + target: path.join(claudeDir, "skills", "devia", "SKILL.md"), + }, + cursor: { + reason: "no user-level skill directory — use the per-project .cursor/rules/devia.mdc", + }, + copilot: { + reason: "instructions are per-repository — .github/copilot-instructions.md", + }, + windsurf: { + reason: "rules are per-repository — .windsurfrules", + }, + }; +} + +/** + * Install the skill once for every project. This is the only path that writes outside `--root`, + * it happens only behind `--global`, and it prints every path it touches (04_PERMISSIONS.md). + */ +export function installGlobalSkill({ force = false } = {}) { + const skill = read(path.join(packageRoot, "skills", "devia", "SKILL.md")); + const written = []; + const kept = []; + const skipped = []; + for (const [key, entry] of Object.entries(globalSkillTargets())) { + if (!entry.target) { + skipped.push([key, entry.reason]); + continue; + } + if (skill === null) { + skipped.push([key, "skill pack missing from the installed package"]); + continue; + } + if (exists(entry.target) && !force) { + kept.push([key, entry.target]); + continue; + } + writeFile(entry.target, skill); + written.push([key, entry.target]); + } + return { written, kept, skipped }; +} + export function installSkill(root, { force = false, agents = Object.keys(SKILL_TARGETS) } = {}) { const skill = read(path.join(packageRoot, "skills", "devia", "SKILL.md")); const written = []; @@ -64,12 +122,29 @@ ${color.bold("devia skills")} — the same contract for every coding agent devia skills list devia skills install [--agent all|${Object.keys(ADAPTERS).join("|")}] [--force] [--skill] + devia skills install --global [--force] --skill also install skills/devia/SKILL.md for Cursor and Claude Code + --global install the skill for every project, in the agent's own configuration + directory. The only command that writes outside --root; it prints + every path it touches `.trim()); return flags.help ? 0 : 2; } + if (flags.global) { + const res = installGlobalSkill({ force: Boolean(flags.force) }); + heading("devia skills install --global"); + for (const [key, target] of res.written) status("PASS", key, target); + for (const [key, target] of res.kept) status("SKIP", `${key} — already there`, target); + for (const [key, reason] of res.skipped) status("SKIP", key, reason); + line(""); + line(color.dim(" The skill is now available in every project. It still expects each")); + line(color.dim(" repository to carry its own .devia/ — the skill says how to create one.")); + line(""); + return 0; + } + if (action === "list") { heading("Adapters"); for (const [key, [, target]] of Object.entries(ADAPTERS)) { diff --git a/templates/agents/AGENTS.md b/templates/agents/AGENTS.md index 409be59..029871b 100644 --- a/templates/agents/AGENTS.md +++ b/templates/agents/AGENTS.md @@ -10,7 +10,8 @@ This repository uses **devia**: a standard plus a living project memory in `.dev 4. Read the memory file for the surface you are touching — see [`.devia/14_INDEX.md`](.devia/14_INDEX.md) -If `.devia/` is missing, run `npx devia init` and fill `00_OVERVIEW.md` before writing code. +If `.devia/` is missing, run `npm i -D @schneiderjoseph/devia && npx devia init`, then fill +`00_OVERVIEW.md` before writing code. ## While working diff --git a/templates/agents/CLAUDE.md b/templates/agents/CLAUDE.md index efbc679..5a08fdc 100644 --- a/templates/agents/CLAUDE.md +++ b/templates/agents/CLAUDE.md @@ -10,7 +10,8 @@ work, updated with every change. 3. `.devia/00_OVERVIEW.md` — what this project is 4. The memory file for the surface you are touching (`.devia/14_INDEX.md`) -No `.devia/`? Run `npx devia init` and fill `00_OVERVIEW.md` before writing code. +No `.devia/`? Run `npm i -D @schneiderjoseph/devia && npx devia init`, then fill +`00_OVERVIEW.md` before writing code. ## Rules that override default behaviour diff --git a/templates/agents/cursor.mdc b/templates/agents/cursor.mdc index d50c0b7..6f08a25 100644 --- a/templates/agents/cursor.mdc +++ b/templates/agents/cursor.mdc @@ -7,7 +7,7 @@ This repository runs on devia. `.devia/` is the project memory and it is not opt 1. Before the first edit, read `.devia/AGENTS.md`, `.devia/10_NEVER_ALWAYS.md`, `.devia/00_OVERVIEW.md`, and the memory file for the surface you are touching - (`.devia/14_INDEX.md`). If `.devia/` does not exist, run `npx devia init` first (AGT-001, + (`.devia/14_INDEX.md`). If `.devia/` does not exist, run `npm i -D @schneiderjoseph/devia && npx devia init` first (AGT-001, AGT-002). 2. Never invent an endpoint, field, config key or business rule. Unknown goes to `.devia/11_GAPS.md` or to the human — never into the code as a quiet default (AGT-004, diff --git a/templates/agents/windsurfrules.md b/templates/agents/windsurfrules.md index 17abe9c..22ea914 100644 --- a/templates/agents/windsurfrules.md +++ b/templates/agents/windsurfrules.md @@ -4,7 +4,7 @@ This repository runs on devia. `.devia/` is the project memory. 1. Read `.devia/AGENTS.md`, `.devia/10_NEVER_ALWAYS.md`, `.devia/00_OVERVIEW.md` and the memory file for the surface you are touching before editing anything. No `.devia/`? Run - `npx devia init` first. + `npm i -D @schneiderjoseph/devia && npx devia init` first. 2. Never invent an endpoint, field, config key or business rule — record the unknown in `.devia/11_GAPS.md` or ask. 3. Decided but not built goes to `.devia/12_DEBT.md`; never delete a line you did not discharge. diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 6fc1fdc..aa969c9 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -23,8 +23,8 @@ const { FORCE_COLOR, ...cleanEnv } = process.env; // stdout is a contract: `--json` is parsed from it. Merging stderr into it on failure turned a // harmless runtime warning into unparseable JSON, and only for whoever had FORCE_COLOR set. -function devia(args, cwd, { allowFailure = false } = {}) { - const options = { cwd, encoding: "utf8", env: { ...cleanEnv, NO_COLOR: "1" } }; +function devia(args, cwd, { allowFailure = false, env = {} } = {}) { + const options = { cwd, encoding: "utf8", env: { ...cleanEnv, NO_COLOR: "1", ...env } }; try { return { code: 0, out: execFileSync(process.execPath, [bin, ...args], options), err: "" }; } catch (e) { @@ -204,6 +204,36 @@ test("closing a line keeps the open table contiguous", () => { } }); +test("skills install writes outside the project only behind --global", () => { + const dir = scratch(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "devia-home-")); + const claudeDir = path.join(home, ".claude"); + const skill = path.join(claudeDir, "skills", "devia", "SKILL.md"); + try { + // Without the flag, nothing outside --root may be touched (04_PERMISSIONS.md). + devia(["skills", "install", "--root", dir], dir, { env: { CLAUDE_CONFIG_DIR: claudeDir } }); + assert.ok(!fs.existsSync(claudeDir), "a plain install must stay inside the repository"); + + const res = devia(["skills", "install", "--global", "--root", dir], dir, { + env: { CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.ok(fs.existsSync(skill), "the user-level skill must be written"); + assert.match(fs.readFileSync(skill, "utf8"), /^---\nname: devia/); + // Every path it touches is printed, and agents it cannot place are SKIP with a reason. + assert.match(res.out, /SKILL\.md/); + assert.match(res.out, /cursor/); + + fs.writeFileSync(skill, "edited by hand\n"); + devia(["skills", "install", "--global", "--root", dir], dir, { + env: { CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(fs.readFileSync(skill, "utf8"), /edited by hand/, "no overwrite without --force"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("rules can be queried by id and by filter", () => { const dir = scratch(); try {