@schneiderjoseph/devia 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/src/commands/check.mjs +56 -18
- package/src/commands/init.mjs +24 -5
- package/src/commands/skills.mjs +48 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-09
|
|
4
|
+
|
|
5
|
+
The standard is unchanged: `VERSION` stays at 0.1.0, no adopter needs `devia sync`.
|
|
6
|
+
|
|
7
|
+
### A manifest is not always at the root
|
|
8
|
+
|
|
9
|
+
Running `devia init` on a real project for the first time — a Next.js app whose manifest lives
|
|
10
|
+
in `apps/web/` — exposed five gates reporting `SKIP no package.json` to a repository that has
|
|
11
|
+
one, with a lockfile, a lint script and thirteen dependencies. The letter of the rule was kept,
|
|
12
|
+
since nothing was rounded up to `PASS`; the reason given was false, which is worse. A reader
|
|
13
|
+
believes the tool looked.
|
|
14
|
+
|
|
15
|
+
- `check` reads every `package.json` in the repository, nearest the root first, and takes the
|
|
16
|
+
union of their dependencies: "does this project use X" is not a question about one directory
|
|
17
|
+
- A lockfile is looked for next to each manifest, not only at the root
|
|
18
|
+
- A migrations directory is found at any depth
|
|
19
|
+
- `SKIP` now says `no package.json anywhere in the repository`, and findings name the file they
|
|
20
|
+
came from — `no npm test script in apps/web/package.json`
|
|
21
|
+
- `init` detects the profile from the nearest manifest instead of falling back to a default, and
|
|
22
|
+
records the directories holding manifests in `code.paths`, which is what `doctor` watches for
|
|
23
|
+
staleness
|
|
24
|
+
|
|
25
|
+
On that project: six SKIPs became two, four gates turned into real findings, and the dependency
|
|
26
|
+
lockfile went from invisible to `PASS apps/web/package-lock.json`.
|
|
27
|
+
|
|
28
|
+
- G1 closed and reframed: the blind spot was never the ecosystem, it was the root assumption.
|
|
29
|
+
D9 records what is still root-only: `pyproject.toml`, `go.mod`, `Cargo.toml`
|
|
30
|
+
- G7 opened: what devia should do when a repository already carries an ad-hoc memory of its own
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Nested directories were not excluded from the scan on Windows when git was unavailable: the
|
|
35
|
+
separator class only matched `/`, so `apps/web/node_modules` was walked. Both separators now.
|
|
36
|
+
|
|
37
|
+
## 0.3.0 — 2026-09-09
|
|
38
|
+
|
|
39
|
+
The standard is unchanged: `VERSION` stays at 0.1.0, no adopter needs `devia sync`.
|
|
40
|
+
|
|
41
|
+
### devia is for every agent
|
|
42
|
+
|
|
43
|
+
0.2.0 shipped `--global` serving Claude Code alone and reported the other agents as SKIP. Two of
|
|
44
|
+
those reasons were wrong: they came from an absence never verified. `~/.cursor/rules/` holds
|
|
45
|
+
user-level `.mdc` rules, and `~/.codex/skills/` uses the same `SKILL.md` convention as Claude
|
|
46
|
+
Code. The decision is recorded as G6: no agent is privileged.
|
|
47
|
+
|
|
48
|
+
`devia skills install --global` now writes, each in the format the agent actually reads:
|
|
49
|
+
|
|
50
|
+
| Agent | Path | File |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Claude Code | `~/.claude/skills/devia/SKILL.md` | skill pack |
|
|
53
|
+
| Codex | `~/.codex/skills/devia/SKILL.md` | skill pack |
|
|
54
|
+
| Cursor | `~/.cursor/rules/devia.mdc` | rules adapter |
|
|
55
|
+
| Gemini | `~/.gemini/GEMINI.md` | universal contract, only when absent or empty |
|
|
56
|
+
| Copilot, Windsurf | — | `SKIP`: user-level configuration is editor settings, not a file devia can place (`12_DEBT.md` D8) |
|
|
57
|
+
|
|
58
|
+
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured when set. A directory the agent owns is
|
|
59
|
+
written to freely; a file the **user** owns is written only when absent or empty, and otherwise
|
|
60
|
+
skipped with the reason rather than replaced. `--force` overrides both and names every path.
|
|
61
|
+
|
|
3
62
|
## 0.2.0 — 2026-09-09
|
|
4
63
|
|
|
5
64
|
The standard is unchanged: `VERSION` stays at 0.1.0 and no adopter needs `devia sync`. This
|
package/package.json
CHANGED
package/src/commands/check.mjs
CHANGED
|
@@ -59,7 +59,9 @@ const TEXT_EXT = new Set([
|
|
|
59
59
|
* secret scan that silently scanned nothing is worse than one that over-reports.
|
|
60
60
|
*/
|
|
61
61
|
function sourceFiles(root) {
|
|
62
|
-
|
|
62
|
+
// Both separators: git reports "/", walk() reports path.sep, and a nested node_modules must be
|
|
63
|
+
// skipped in either shape.
|
|
64
|
+
const skipped = (rel) => rel.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg));
|
|
63
65
|
const wanted = (rel) => TEXT_EXT.has(path.extname(rel).toLowerCase());
|
|
64
66
|
|
|
65
67
|
const tracked = trackedFiles(root);
|
|
@@ -81,13 +83,31 @@ function anyExists(root, candidates) {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function makeChecks(root, ctx) {
|
|
84
|
-
const pkg = readJSON(path.join(root, "package.json"));
|
|
85
|
-
const scripts = pkg?.scripts || {};
|
|
86
|
-
const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
|
|
87
86
|
const config = readJSON(path.join(root, ".devia", "devia.json"));
|
|
88
87
|
const files = sourceFiles(root);
|
|
89
88
|
const rel = (p) => p.split(path.sep).join("/");
|
|
90
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A manifest is not always at the repository root: `apps/web/package.json` is as real as
|
|
92
|
+
* `./package.json`. Reading only the root turned "I did not look there" into "you have no
|
|
93
|
+
* package.json", which is a wrong answer dressed as a SKIP — worse than no answer, because
|
|
94
|
+
* the reader believes the tool looked.
|
|
95
|
+
*/
|
|
96
|
+
const manifests = files
|
|
97
|
+
.filter((f) => /(^|[\\/])package\.json$/.test(f))
|
|
98
|
+
.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
|
|
99
|
+
const primary = manifests[0] || null;
|
|
100
|
+
const pkg = primary ? readJSON(path.join(root, primary)) : null;
|
|
101
|
+
const pkgWhere = primary ? rel(primary) : null;
|
|
102
|
+
const scripts = pkg?.scripts || {};
|
|
103
|
+
// Dependencies are asked as "does this project use X anywhere", so every manifest counts.
|
|
104
|
+
const deps = {};
|
|
105
|
+
for (const m of manifests) {
|
|
106
|
+
const json = readJSON(path.join(root, m)) || {};
|
|
107
|
+
Object.assign(deps, json.dependencies, json.devDependencies);
|
|
108
|
+
}
|
|
109
|
+
const noManifest = `no package.json anywhere in the repository`;
|
|
110
|
+
|
|
91
111
|
const hasDep = (...names) => names.some((n) => n in deps);
|
|
92
112
|
const grepFiles = (re, limit = 40) => {
|
|
93
113
|
const hits = [];
|
|
@@ -305,12 +325,13 @@ function makeChecks(root, ctx) {
|
|
|
305
325
|
rule: "TST-001",
|
|
306
326
|
title: "A test command exists",
|
|
307
327
|
run: () => {
|
|
308
|
-
if (!pkg) return { kind: "SKIP", detail:
|
|
328
|
+
if (!pkg) return { kind: "SKIP", detail: noManifest };
|
|
309
329
|
const t = scripts.test;
|
|
310
|
-
|
|
330
|
+
const where = pkgWhere === "package.json" ? "" : ` in ${pkgWhere}`;
|
|
331
|
+
if (!t) return { kind: "WARN", detail: `no npm test script${where}` };
|
|
311
332
|
return /no test specified/.test(t)
|
|
312
|
-
? { kind: "FAIL", detail:
|
|
313
|
-
: { kind: "PASS" };
|
|
333
|
+
? { kind: "FAIL", detail: `test script is the npm placeholder${where}` }
|
|
334
|
+
: { kind: "PASS", detail: where.trim() };
|
|
314
335
|
},
|
|
315
336
|
},
|
|
316
337
|
{
|
|
@@ -319,9 +340,7 @@ function makeChecks(root, ctx) {
|
|
|
319
340
|
rule: "OPS-004",
|
|
320
341
|
title: "Dependency lockfile committed",
|
|
321
342
|
run: () => {
|
|
322
|
-
const
|
|
323
|
-
if (!anyExists(root, manifests)) return { kind: "SKIP", detail: "no manifest" };
|
|
324
|
-
const lock = anyExists(root, [
|
|
343
|
+
const LOCKS = [
|
|
325
344
|
"package-lock.json",
|
|
326
345
|
"pnpm-lock.yaml",
|
|
327
346
|
"yarn.lock",
|
|
@@ -331,10 +350,23 @@ function makeChecks(root, ctx) {
|
|
|
331
350
|
"go.sum",
|
|
332
351
|
"Cargo.lock",
|
|
333
352
|
"Gemfile.lock",
|
|
353
|
+
];
|
|
354
|
+
// A lockfile sits next to the manifest it locks, which is not always the root.
|
|
355
|
+
const dirs = new Set(["."]);
|
|
356
|
+
for (const m of manifests) dirs.add(path.dirname(m));
|
|
357
|
+
const rootManifest = anyExists(root, [
|
|
358
|
+
"package.json",
|
|
359
|
+
"pyproject.toml",
|
|
360
|
+
"go.mod",
|
|
361
|
+
"Cargo.toml",
|
|
362
|
+
"Gemfile",
|
|
334
363
|
]);
|
|
335
|
-
return
|
|
336
|
-
|
|
337
|
-
|
|
364
|
+
if (!rootManifest && !manifests.length) return { kind: "SKIP", detail: "no manifest" };
|
|
365
|
+
for (const dir of dirs) {
|
|
366
|
+
const found = LOCKS.find((l) => exists(path.join(root, dir, l)));
|
|
367
|
+
if (found) return { kind: "PASS", detail: rel(path.join(dir, found)) };
|
|
368
|
+
}
|
|
369
|
+
return { kind: "FAIL", detail: "no lockfile" };
|
|
338
370
|
},
|
|
339
371
|
},
|
|
340
372
|
{
|
|
@@ -355,8 +387,14 @@ function makeChecks(root, ctx) {
|
|
|
355
387
|
"alembic",
|
|
356
388
|
path.join("src", "migrations"),
|
|
357
389
|
]);
|
|
358
|
-
return dir
|
|
359
|
-
|
|
390
|
+
if (dir) return { kind: "PASS", detail: dir };
|
|
391
|
+
// Not only at the root: a migrations directory can live under any package.
|
|
392
|
+
const SEGMENTS = new Set(["migrations", "migrate", "alembic"]);
|
|
393
|
+
const nested = files
|
|
394
|
+
.map(rel)
|
|
395
|
+
.find((f) => f.split("/").slice(0, -1).some((seg) => SEGMENTS.has(seg)));
|
|
396
|
+
return nested
|
|
397
|
+
? { kind: "PASS", detail: nested.split("/").slice(0, -1).join("/") }
|
|
360
398
|
: { kind: "FAIL", detail: "database in use, no migrations directory" };
|
|
361
399
|
},
|
|
362
400
|
},
|
|
@@ -388,7 +426,7 @@ function makeChecks(root, ctx) {
|
|
|
388
426
|
run: () => {
|
|
389
427
|
if (config && config.modules && config.modules.design === false)
|
|
390
428
|
return { kind: "SKIP", detail: "design module disabled" };
|
|
391
|
-
if (!pkg) return { kind: "SKIP", detail:
|
|
429
|
+
if (!pkg) return { kind: "SKIP", detail: noManifest };
|
|
392
430
|
const found = hasDep(
|
|
393
431
|
"axe-core", "@axe-core/react", "@axe-core/playwright", "jest-axe",
|
|
394
432
|
"eslint-plugin-jsx-a11y", "pa11y", "@storybook/addon-a11y", "lighthouse"
|
|
@@ -407,7 +445,7 @@ function makeChecks(root, ctx) {
|
|
|
407
445
|
const profile = config?.project?.profile;
|
|
408
446
|
if (["cli", "library", "docs"].includes(profile))
|
|
409
447
|
return { kind: "SKIP", detail: `${profile} does not run as a watched service` };
|
|
410
|
-
if (!pkg) return { kind: "SKIP", detail:
|
|
448
|
+
if (!pkg) return { kind: "SKIP", detail: noManifest };
|
|
411
449
|
const found = hasDep("@sentry/node", "@sentry/browser", "@sentry/nextjs", "bugsnag",
|
|
412
450
|
"rollbar", "datadog-lambda-js", "dd-trace", "@opentelemetry/api");
|
|
413
451
|
return found
|
package/src/commands/init.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { packageRoot, exists, read, writeFile, writeJSON, walk } from "../lib/fs.mjs";
|
|
4
|
+
import { trackedFiles } from "../lib/git.mjs";
|
|
4
5
|
import { cliVersion, standardVersion } from "../lib/version.mjs";
|
|
5
6
|
import { vendorStandard } from "../lib/vendor.mjs";
|
|
6
7
|
import { color, heading, status, line } from "../lib/ui.mjs";
|
|
@@ -15,10 +16,22 @@ const PROFILES = {
|
|
|
15
16
|
docs: "Documentation or content repository",
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Every package.json in the repository, nearest to the root first. A monorepo keeps its real
|
|
21
|
+
* manifest in `apps/web/` or `packages/*`, and a profile detected from the root alone falls back
|
|
22
|
+
* to a default while the evidence sits one directory down.
|
|
23
|
+
*/
|
|
24
|
+
function findManifests(root) {
|
|
25
|
+
const tracked = trackedFiles(root);
|
|
26
|
+
if (!tracked) return exists(path.join(root, "package.json")) ? ["package.json"] : [];
|
|
27
|
+
return tracked
|
|
28
|
+
.filter((f) => /(^|\/)package\.json$/.test(f) && !f.includes("node_modules/"))
|
|
29
|
+
.sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
function detectProfile(root) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const json = JSON.parse(read(pkg) || "{}");
|
|
33
|
+
for (const manifest of findManifests(root)) {
|
|
34
|
+
const json = JSON.parse(read(path.join(root, manifest)) || "{}");
|
|
22
35
|
const deps = { ...json.dependencies, ...json.devDependencies };
|
|
23
36
|
if (json.bin) return "cli";
|
|
24
37
|
if (deps.next || deps.react || deps.vue || deps.svelte || deps["@angular/core"])
|
|
@@ -46,8 +59,14 @@ function detectName(root) {
|
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
function detectCodePaths(root) {
|
|
49
|
-
const candidates = ["src", "app", "lib", "packages", "server", "api", "web", "components"];
|
|
50
|
-
|
|
62
|
+
const candidates = ["src", "app", "apps", "lib", "packages", "server", "api", "web", "components"];
|
|
63
|
+
const found = candidates.filter((c) => exists(path.join(root, c)));
|
|
64
|
+
// The directory holding a manifest is code by definition, wherever it sits.
|
|
65
|
+
for (const manifest of findManifests(root)) {
|
|
66
|
+
const dir = path.posix.dirname(manifest);
|
|
67
|
+
if (dir !== "." && !found.some((f) => dir === f || dir.startsWith(`${f}/`))) found.push(dir);
|
|
68
|
+
}
|
|
69
|
+
return found;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
function fill(text, vars) {
|
package/src/commands/skills.mjs
CHANGED
|
@@ -38,57 +38,84 @@ export function installAdapters(root, { force = false, agents = Object.keys(ADAP
|
|
|
38
38
|
return { written, kept };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const SKILL_PACK = path.join("skills", "devia", "SKILL.md");
|
|
42
|
+
const adapter = (file) => path.join("templates", "agents", file);
|
|
43
|
+
|
|
41
44
|
/**
|
|
42
|
-
* Where
|
|
45
|
+
* Where each agent keeps a contract that applies to every project, not one.
|
|
43
46
|
*
|
|
44
|
-
*
|
|
45
|
-
* reported as SKIP with the reason
|
|
46
|
-
*
|
|
47
|
+
* devia is for every agent, so an agent is listed here as soon as its user-level location is
|
|
48
|
+
* known — and reported as SKIP with the reason when it is not. Each entry carries the file the
|
|
49
|
+
* agent actually reads: a skill pack where the agent loads skills, its own rules format
|
|
50
|
+
* otherwise. Guessing a path inside someone's home directory is the confident wrong answer this
|
|
51
|
+
* tool exists to prevent, so absence of evidence is reported, never rounded up.
|
|
47
52
|
*/
|
|
48
|
-
function
|
|
53
|
+
function globalTargets() {
|
|
49
54
|
const home = os.homedir();
|
|
50
|
-
const
|
|
51
|
-
? path.resolve(process.env.
|
|
52
|
-
|
|
55
|
+
const configDir = (envVar, fallback) =>
|
|
56
|
+
process.env[envVar] ? path.resolve(process.env[envVar]) : path.join(home, fallback);
|
|
57
|
+
|
|
53
58
|
return {
|
|
54
59
|
claude: {
|
|
55
|
-
target: path.join(
|
|
60
|
+
target: path.join(configDir("CLAUDE_CONFIG_DIR", ".claude"), "skills", "devia", "SKILL.md"),
|
|
61
|
+
source: SKILL_PACK,
|
|
62
|
+
},
|
|
63
|
+
codex: {
|
|
64
|
+
target: path.join(configDir("CODEX_HOME", ".codex"), "skills", "devia", "SKILL.md"),
|
|
65
|
+
source: SKILL_PACK,
|
|
56
66
|
},
|
|
57
67
|
cursor: {
|
|
58
|
-
|
|
68
|
+
target: path.join(home, ".cursor", "rules", "devia.mdc"),
|
|
69
|
+
source: adapter("cursor.mdc"),
|
|
70
|
+
},
|
|
71
|
+
gemini: {
|
|
72
|
+
// One file the user owns, not a directory devia can add to: written only when it is
|
|
73
|
+
// absent or empty, so a global instruction file is never silently replaced.
|
|
74
|
+
target: path.join(home, ".gemini", "GEMINI.md"),
|
|
75
|
+
source: adapter("AGENTS.md"),
|
|
76
|
+
onlyWhenEmpty: true,
|
|
59
77
|
},
|
|
60
78
|
copilot: {
|
|
61
|
-
reason: "instructions
|
|
79
|
+
reason: "user-level instructions live in the editor's settings, not a file devia can place",
|
|
62
80
|
},
|
|
63
81
|
windsurf: {
|
|
64
|
-
reason: "rules
|
|
82
|
+
reason: "no user-level rules file — .windsurfrules is per repository",
|
|
65
83
|
},
|
|
66
84
|
};
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
/**
|
|
70
|
-
* Install the
|
|
71
|
-
* it happens
|
|
88
|
+
* Install the contract once for every project. This is the only path that writes outside
|
|
89
|
+
* `--root`: it happens behind `--global`, and it prints every path it touches
|
|
90
|
+
* (`04_PERMISSIONS.md`).
|
|
72
91
|
*/
|
|
73
92
|
export function installGlobalSkill({ force = false } = {}) {
|
|
74
|
-
const skill = read(path.join(packageRoot, "skills", "devia", "SKILL.md"));
|
|
75
93
|
const written = [];
|
|
76
94
|
const kept = [];
|
|
77
95
|
const skipped = [];
|
|
78
|
-
|
|
96
|
+
|
|
97
|
+
for (const [key, entry] of Object.entries(globalTargets())) {
|
|
79
98
|
if (!entry.target) {
|
|
80
99
|
skipped.push([key, entry.reason]);
|
|
81
100
|
continue;
|
|
82
101
|
}
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
const content = read(path.join(packageRoot, entry.source));
|
|
103
|
+
if (content === null) {
|
|
104
|
+
skipped.push([key, `${entry.source} missing from the installed package`]);
|
|
85
105
|
continue;
|
|
86
106
|
}
|
|
87
107
|
if (exists(entry.target) && !force) {
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
const current = read(entry.target) || "";
|
|
109
|
+
if (entry.onlyWhenEmpty && current.trim()) {
|
|
110
|
+
skipped.push([key, `${path.basename(entry.target)} already has content — add the contract yourself`]);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (current.trim()) {
|
|
114
|
+
kept.push([key, entry.target]);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
90
117
|
}
|
|
91
|
-
writeFile(entry.target,
|
|
118
|
+
writeFile(entry.target, content);
|
|
92
119
|
written.push([key, entry.target]);
|
|
93
120
|
}
|
|
94
121
|
return { written, kept, skipped };
|