dw-sdlc 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +63 -2
- package/config/agentic.config.schema.json +212 -0
- package/config/references.schema.json +56 -0
- package/dist/agentic-config.d.ts +167 -0
- package/dist/agentic-config.js +307 -0
- package/dist/catalogue.d.ts +36 -0
- package/dist/catalogue.js +74 -0
- package/dist/checks/agents-map.d.ts +7 -0
- package/dist/checks/agents-map.js +166 -0
- package/dist/checks/check.d.ts +17 -0
- package/dist/checks/check.js +21 -0
- package/dist/checks/index.d.ts +8 -0
- package/dist/checks/index.js +19 -0
- package/dist/checks/primitives.d.ts +8 -0
- package/dist/checks/primitives.js +128 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +43 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +76 -0
- package/dist/commands/doctor.d.ts +57 -0
- package/dist/commands/doctor.js +341 -0
- package/dist/commands/gate.d.ts +2 -0
- package/dist/commands/gate.js +175 -0
- package/dist/commands/primitives.d.ts +2 -0
- package/dist/commands/primitives.js +106 -0
- package/dist/commands/references-setup.d.ts +2 -0
- package/dist/commands/references-setup.js +213 -0
- package/dist/commands/references-sync.d.ts +2 -0
- package/dist/commands/references-sync.js +347 -0
- package/dist/commands/setup.d.ts +31 -0
- package/dist/commands/setup.js +369 -0
- package/dist/json-schema.d.ts +8 -0
- package/dist/json-schema.js +227 -0
- package/dist/markdown.d.ts +19 -0
- package/dist/markdown.js +77 -0
- package/dist/pins.d.ts +16 -0
- package/dist/pins.js +83 -0
- package/dist/references-key.d.ts +31 -0
- package/dist/references-key.js +150 -0
- package/dist/report.d.ts +13 -0
- package/dist/report.js +29 -0
- package/dist/router-block.d.ts +13 -0
- package/dist/router-block.js +62 -0
- package/dist/setup-rules.d.ts +22 -0
- package/dist/setup-rules.js +98 -0
- package/package.json +30 -3
- package/templates/router.md +38 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Reads what a repository declares for an agent: `.ai/agentic.config.json` (committed), then
|
|
2
|
+
// `.ai/agentic.config.local.json` (one machine) over it, then what the repository itself implies. The
|
|
3
|
+
// local file may tighten `guardrails` and never loosen it; any invalid layer is dropped whole and the
|
|
4
|
+
// guards fall back to the strictest defaults until it is fixed. The guardrails plugin's `hooks/config.jq`
|
|
5
|
+
// is the same merge for the guards; this file is the reference, and the plugin's tests run both.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { validate } from "./json-schema.js";
|
|
10
|
+
export const COMMITTED_FILE = ".ai/agentic.config.json";
|
|
11
|
+
export const LOCAL_FILE = ".ai/agentic.config.local.json";
|
|
12
|
+
/** The schema's path inside this package. */
|
|
13
|
+
export const SCHEMA_FILE = "config/agentic.config.schema.json";
|
|
14
|
+
export const SCHEMA_PATH = join(import.meta.dirname, "..", SCHEMA_FILE);
|
|
15
|
+
export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
|
|
16
|
+
/** Strictest value first; the local override may only move towards the front. */
|
|
17
|
+
export const STRICTNESS = {
|
|
18
|
+
forcePush: ["deny", "ask", "lease"],
|
|
19
|
+
commitOnProtected: ["deny", "allow"],
|
|
20
|
+
envFiles: ["deny", "git"]
|
|
21
|
+
};
|
|
22
|
+
/** What the guards run on while any layer is invalid. */
|
|
23
|
+
export const STRICTEST_GUARDRAILS = {
|
|
24
|
+
forcePush: "deny",
|
|
25
|
+
commitOnProtected: "deny",
|
|
26
|
+
envFiles: { policy: "deny", allow: [], deny: [] },
|
|
27
|
+
protectedBranches: ["master", "main", "production"]
|
|
28
|
+
};
|
|
29
|
+
/** What a repository gets with nothing declared. */
|
|
30
|
+
export const DEFAULT_GUARDRAILS = {
|
|
31
|
+
forcePush: "lease",
|
|
32
|
+
commitOnProtected: "allow",
|
|
33
|
+
envFiles: { policy: "git", allow: ["**/.env.example", "**/.env.sample", "**/.env.template"], deny: [] },
|
|
34
|
+
protectedBranches: ["master", "main", "production"]
|
|
35
|
+
};
|
|
36
|
+
function cloneGuardrails(source) {
|
|
37
|
+
return {
|
|
38
|
+
forcePush: source.forcePush,
|
|
39
|
+
commitOnProtected: source.commitOnProtected,
|
|
40
|
+
envFiles: { policy: source.envFiles.policy, allow: [...source.envFiles.allow], deny: [...source.envFiles.deny] },
|
|
41
|
+
protectedBranches: [...source.protectedBranches]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether an `envFiles` glob selects the file at `relativePath` (to the repository root, or the
|
|
46
|
+
* basename outside one). `*` and `?` as in a shell pattern, `*` crossing `/`; a leading `**` and a
|
|
47
|
+
* slash mean at any depth. The same rule `hooks/block-env-access.sh` applies with `[[ == ]]`.
|
|
48
|
+
*/
|
|
49
|
+
export function matchesEnvGlob(glob, relativePath) {
|
|
50
|
+
const anyDepth = glob.startsWith("**/");
|
|
51
|
+
const pattern = anyDepth ? glob.slice(3) : glob;
|
|
52
|
+
const source = pattern
|
|
53
|
+
.split("")
|
|
54
|
+
.map((char) => {
|
|
55
|
+
if (char === "*") {
|
|
56
|
+
return ".*";
|
|
57
|
+
}
|
|
58
|
+
if (char === "?") {
|
|
59
|
+
return ".";
|
|
60
|
+
}
|
|
61
|
+
return char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
62
|
+
})
|
|
63
|
+
.join("");
|
|
64
|
+
return new RegExp(`^${anyDepth ? "(.*/)?" : ""}${source}$`).test(relativePath);
|
|
65
|
+
}
|
|
66
|
+
// The preset each convention names. `ticket-key` is a ticket key ahead of a short type list;
|
|
67
|
+
// `conventional` is Conventional Commits. Both are POSIX ERE, because `grep -E` runs them.
|
|
68
|
+
export const COMMIT_PATTERNS = {
|
|
69
|
+
"ticket-key": "^\\[[A-Z]+-[0-9]+\\] (feat|fix|refactor|docs|chore|test|perf|ci|revert): .+",
|
|
70
|
+
conventional: "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([^)]+\\))?!?: .+"
|
|
71
|
+
};
|
|
72
|
+
const NODE_LOCKFILES = [
|
|
73
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
74
|
+
["package-lock.json", "npm"],
|
|
75
|
+
["yarn.lock", "yarn"],
|
|
76
|
+
["bun.lock", "bun"],
|
|
77
|
+
["bun.lockb", "bun"]
|
|
78
|
+
];
|
|
79
|
+
function messageOf(error) {
|
|
80
|
+
return error instanceof Error ? error.message : String(error);
|
|
81
|
+
}
|
|
82
|
+
function show(value) {
|
|
83
|
+
return JSON.stringify(value);
|
|
84
|
+
}
|
|
85
|
+
let schema;
|
|
86
|
+
function loadSchema() {
|
|
87
|
+
schema ??= JSON.parse(readFileSync(SCHEMA_PATH, "utf8"));
|
|
88
|
+
return schema;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The schema's public `$id`: what a written config points its `$schema` at. Stable across plugin
|
|
92
|
+
* installs, where `SCHEMA_PATH` moves with every version.
|
|
93
|
+
*/
|
|
94
|
+
export function schemaId() {
|
|
95
|
+
const loaded = loadSchema();
|
|
96
|
+
if (typeof loaded === "object" && loaded !== null && "$id" in loaded && typeof loaded.$id === "string") {
|
|
97
|
+
return loaded.$id;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`${SCHEMA_PATH} declares no string $id`);
|
|
100
|
+
}
|
|
101
|
+
/** Parses and validates one document; `file` names it in every problem. */
|
|
102
|
+
export function validateDocument(file, text) {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(text);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return { problems: [{ file, path: "", message: `not JSON: ${messageOf(error)}` }] };
|
|
109
|
+
}
|
|
110
|
+
const problems = validate(loadSchema(), parsed).map(({ path, message }) => ({ file, path, message }));
|
|
111
|
+
if (problems.length > 0) {
|
|
112
|
+
return { problems };
|
|
113
|
+
}
|
|
114
|
+
// The schema is the runtime twin of `AgenticConfig`: a document it accepts has this shape.
|
|
115
|
+
return { value: parsed };
|
|
116
|
+
}
|
|
117
|
+
/** `file` from the first of `checkouts` that has it. */
|
|
118
|
+
function readLayer(checkouts, file) {
|
|
119
|
+
const source = checkouts.find((checkout) => existsSync(join(checkout, file)));
|
|
120
|
+
if (source === undefined) {
|
|
121
|
+
return { state: "absent", problems: [] };
|
|
122
|
+
}
|
|
123
|
+
const result = validateDocument(file, readFileSync(join(source, file), "utf8"));
|
|
124
|
+
if ("problems" in result) {
|
|
125
|
+
return { state: "invalid", problems: result.problems, source };
|
|
126
|
+
}
|
|
127
|
+
return { state: "valid", value: result.value, problems: [], source };
|
|
128
|
+
}
|
|
129
|
+
/** The top level of the repository holding `dir`, or undefined outside one. */
|
|
130
|
+
export function findRepository(dir) {
|
|
131
|
+
const toplevel = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
132
|
+
return toplevel.status === 0 ? toplevel.stdout.trim() : undefined;
|
|
133
|
+
}
|
|
134
|
+
/** The top level of the repository holding `dir`, or `dir` itself outside one. */
|
|
135
|
+
export function repositoryRoot(dir) {
|
|
136
|
+
return findRepository(dir) ?? dir;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The main checkout of the linked worktree at `root`, or undefined for a main checkout or outside a
|
|
140
|
+
* repository: a linked worktree's git dir differs from the common one, which sits in the main checkout.
|
|
141
|
+
*/
|
|
142
|
+
export function mainCheckout(root) {
|
|
143
|
+
const args = ["-C", root, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"];
|
|
144
|
+
const result = spawnSync("git", args, { encoding: "utf8" });
|
|
145
|
+
if (result.status !== 0) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const [gitDir, commonDir] = result.stdout.trim().split("\n");
|
|
149
|
+
return gitDir === undefined || commonDir === undefined || gitDir === commonDir ? undefined : dirname(commonDir);
|
|
150
|
+
}
|
|
151
|
+
function inferPackageManagers(root) {
|
|
152
|
+
const managers = {};
|
|
153
|
+
const lockfile = NODE_LOCKFILES.find(([file]) => existsSync(join(root, file)));
|
|
154
|
+
if (lockfile !== undefined) {
|
|
155
|
+
managers.node = lockfile[1];
|
|
156
|
+
}
|
|
157
|
+
return managers;
|
|
158
|
+
}
|
|
159
|
+
/** Whether `package.json` at `root` declares a `check` script; a missing or unreadable manifest has none. */
|
|
160
|
+
function hasCheckScript(root) {
|
|
161
|
+
let manifest;
|
|
162
|
+
try {
|
|
163
|
+
manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (typeof manifest !== "object" || manifest === null || !("scripts" in manifest)) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const { scripts } = manifest;
|
|
172
|
+
return typeof scripts === "object" && scripts !== null && "check" in scripts && typeof scripts.check === "string";
|
|
173
|
+
}
|
|
174
|
+
/** The Gate with nothing declared: `pnpm check` where the manifest has a `check` script, else no command. */
|
|
175
|
+
function inferGate(root) {
|
|
176
|
+
const gate = { steps: [], checks: { run: [] } };
|
|
177
|
+
if (hasCheckScript(root)) {
|
|
178
|
+
gate.command = "pnpm check";
|
|
179
|
+
}
|
|
180
|
+
return gate;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The bottom layer: the package manager the lockfile implies, the Gate command the manifest's `check`
|
|
184
|
+
* script implies, plus the defaults every key falls back to. `commit.pattern` is left to
|
|
185
|
+
* `resolveConfig`, which fills it from the convention when no layer sets one.
|
|
186
|
+
*/
|
|
187
|
+
export function inferConfig(root) {
|
|
188
|
+
return {
|
|
189
|
+
version: 1,
|
|
190
|
+
baseBranch: "auto",
|
|
191
|
+
packageManagers: inferPackageManagers(root),
|
|
192
|
+
guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
|
|
193
|
+
commit: { convention: "conventional" },
|
|
194
|
+
gate: inferGate(root),
|
|
195
|
+
worktree: { setup: [] }
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function applyLayer(base, layer) {
|
|
199
|
+
return {
|
|
200
|
+
version: 1,
|
|
201
|
+
baseBranch: layer.baseBranch ?? base.baseBranch,
|
|
202
|
+
packageManagers: { ...base.packageManagers, ...layer.packageManagers },
|
|
203
|
+
guardrails: {
|
|
204
|
+
...base.guardrails,
|
|
205
|
+
...layer.guardrails,
|
|
206
|
+
envFiles: { ...base.guardrails.envFiles, ...layer.guardrails?.envFiles }
|
|
207
|
+
},
|
|
208
|
+
commit: { ...base.commit, ...layer.commit },
|
|
209
|
+
gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
|
|
210
|
+
worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function rank(key, value) {
|
|
214
|
+
const order = STRICTNESS[key];
|
|
215
|
+
return order.indexOf(value);
|
|
216
|
+
}
|
|
217
|
+
const LOOSENS = "the local file may tighten guardrails, never loosen them";
|
|
218
|
+
/**
|
|
219
|
+
* Every guardrail the local layer sets less strictly than what it would override: a lower-ranked
|
|
220
|
+
* `forcePush`, `commitOnProtected` or `envFiles.policy`, an added `envFiles.allow` entry, a dropped
|
|
221
|
+
* `envFiles.deny` entry, a dropped protected branch.
|
|
222
|
+
*/
|
|
223
|
+
function loosenings(base, local) {
|
|
224
|
+
if (local === undefined) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const problems = [];
|
|
228
|
+
if (local.forcePush !== undefined && rank("forcePush", local.forcePush) > rank("forcePush", base.forcePush)) {
|
|
229
|
+
const message = `${show(local.forcePush)} loosens ${show(base.forcePush)}; ${LOOSENS}`;
|
|
230
|
+
problems.push({ file: LOCAL_FILE, path: "guardrails.forcePush", message });
|
|
231
|
+
}
|
|
232
|
+
if (local.commitOnProtected !== undefined &&
|
|
233
|
+
rank("commitOnProtected", local.commitOnProtected) > rank("commitOnProtected", base.commitOnProtected)) {
|
|
234
|
+
const message = `${show(local.commitOnProtected)} loosens ${show(base.commitOnProtected)}; ${LOOSENS}`;
|
|
235
|
+
problems.push({ file: LOCAL_FILE, path: "guardrails.commitOnProtected", message });
|
|
236
|
+
}
|
|
237
|
+
const policy = local.envFiles?.policy;
|
|
238
|
+
if (policy !== undefined && rank("envFiles", policy) > rank("envFiles", base.envFiles.policy)) {
|
|
239
|
+
const message = `${show(policy)} loosens ${show(base.envFiles.policy)}; ${LOOSENS}`;
|
|
240
|
+
problems.push({ file: LOCAL_FILE, path: "guardrails.envFiles.policy", message });
|
|
241
|
+
}
|
|
242
|
+
if (local.envFiles?.allow !== undefined) {
|
|
243
|
+
const known = new Set(base.envFiles.allow);
|
|
244
|
+
const added = local.envFiles.allow.filter((glob) => !known.has(glob));
|
|
245
|
+
if (added.length > 0) {
|
|
246
|
+
const message = `adds ${added.map(show).join(", ")}; the local file may drop allowed env files, never add them`;
|
|
247
|
+
problems.push({ file: LOCAL_FILE, path: "guardrails.envFiles.allow", message });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (local.envFiles?.deny !== undefined) {
|
|
251
|
+
const kept = new Set(local.envFiles.deny);
|
|
252
|
+
const dropped = base.envFiles.deny.filter((glob) => !kept.has(glob));
|
|
253
|
+
if (dropped.length > 0) {
|
|
254
|
+
const message = `drops ${dropped.map(show).join(", ")}; the local file may add denied env files, never drop them`;
|
|
255
|
+
problems.push({ file: LOCAL_FILE, path: "guardrails.envFiles.deny", message });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (local.protectedBranches !== undefined) {
|
|
259
|
+
const kept = new Set(local.protectedBranches);
|
|
260
|
+
const dropped = base.protectedBranches.filter((branch) => !kept.has(branch));
|
|
261
|
+
if (dropped.length > 0) {
|
|
262
|
+
const message = `drops ${dropped.map(show).join(", ")}; the local file may add protected branches, never remove them`;
|
|
263
|
+
problems.push({ file: LOCAL_FILE, path: "guardrails.protectedBranches", message });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return problems;
|
|
267
|
+
}
|
|
268
|
+
/** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
|
|
269
|
+
function finalize(merged) {
|
|
270
|
+
const pattern = merged.commit.pattern ?? COMMIT_PATTERNS[merged.commit.convention];
|
|
271
|
+
return { ...merged, commit: { ...merged.commit, pattern } };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Local over committed over inferred, for the repository at `root`. A linked worktree reads a layer it
|
|
275
|
+
* lacks from its main checkout, where a repository that keeps `.ai/` out of git has the only copy.
|
|
276
|
+
*/
|
|
277
|
+
export function resolveConfig(root) {
|
|
278
|
+
const shared = mainCheckout(root);
|
|
279
|
+
const checkouts = shared === undefined ? [root] : [root, shared];
|
|
280
|
+
const committed = readLayer(checkouts, COMMITTED_FILE);
|
|
281
|
+
const local = readLayer(checkouts, LOCAL_FILE);
|
|
282
|
+
const problems = [...committed.problems, ...local.problems];
|
|
283
|
+
let localState = local.state;
|
|
284
|
+
let config = inferConfig(root);
|
|
285
|
+
if (committed.value !== undefined) {
|
|
286
|
+
config = applyLayer(config, committed.value);
|
|
287
|
+
}
|
|
288
|
+
if (local.value !== undefined) {
|
|
289
|
+
const loosened = loosenings(config.guardrails, local.value.guardrails);
|
|
290
|
+
if (loosened.length > 0) {
|
|
291
|
+
problems.push(...loosened);
|
|
292
|
+
localState = "invalid";
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
config = applyLayer(config, local.value);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (problems.length > 0) {
|
|
299
|
+
config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS) };
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
config: finalize(config),
|
|
303
|
+
problems,
|
|
304
|
+
layers: { committed: committed.state, local: localState },
|
|
305
|
+
sources: { committed: committed.source, local: local.source }
|
|
306
|
+
};
|
|
307
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type Group = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
export type Primitive = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
group: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
code: string[];
|
|
11
|
+
docs: string[];
|
|
12
|
+
};
|
|
13
|
+
export type Catalogue = {
|
|
14
|
+
version: 1;
|
|
15
|
+
groups: Group[];
|
|
16
|
+
primitives: Primitive[];
|
|
17
|
+
};
|
|
18
|
+
/** Where a repository keeps its catalogue unless its config says otherwise. */
|
|
19
|
+
export declare const CATALOGUE_FILE = ".ai/primitives.json";
|
|
20
|
+
export type CatalogueRead = {
|
|
21
|
+
state: "missing";
|
|
22
|
+
} | {
|
|
23
|
+
state: "invalid";
|
|
24
|
+
problems: string[];
|
|
25
|
+
} | {
|
|
26
|
+
state: "valid";
|
|
27
|
+
catalogue: Catalogue;
|
|
28
|
+
text: string;
|
|
29
|
+
};
|
|
30
|
+
/** The catalogue at `file` under `root`, or why there is none to read. */
|
|
31
|
+
export declare function readCatalogue(root: string, file: string): CatalogueRead;
|
|
32
|
+
/**
|
|
33
|
+
* The Primitives whose `code` names `path`, relative to the repository root: of those, the ones whose
|
|
34
|
+
* entry is nearest the path, so a file wins over the directory holding it. Empty when none names it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function primitivesNaming(catalogue: Catalogue, path: string): Primitive[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The Primitives catalogue: what a repository exposes to an agent, where the code of each lives and
|
|
2
|
+
// which documents explain it. Read and held to its shape here; the `primitives` Check judges what it
|
|
3
|
+
// says and `dw-sdlc primitives --paths` answers from it.
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { validate } from "./json-schema.js";
|
|
7
|
+
/** Where a repository keeps its catalogue unless its config says otherwise. */
|
|
8
|
+
export const CATALOGUE_FILE = ".ai/primitives.json";
|
|
9
|
+
const STRINGS = { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 };
|
|
10
|
+
const NAMED = { type: "string", minLength: 1 };
|
|
11
|
+
const SCHEMA = {
|
|
12
|
+
type: "object",
|
|
13
|
+
required: ["version", "groups", "primitives"],
|
|
14
|
+
properties: {
|
|
15
|
+
version: { const: 1 },
|
|
16
|
+
groups: {
|
|
17
|
+
type: "array",
|
|
18
|
+
items: {
|
|
19
|
+
type: "object",
|
|
20
|
+
required: ["id", "name"],
|
|
21
|
+
properties: { id: NAMED, name: NAMED },
|
|
22
|
+
additionalProperties: false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
primitives: {
|
|
26
|
+
type: "array",
|
|
27
|
+
items: {
|
|
28
|
+
type: "object",
|
|
29
|
+
required: ["id", "name", "group", "summary", "code", "docs"],
|
|
30
|
+
properties: { id: NAMED, name: NAMED, group: NAMED, summary: NAMED, code: STRINGS, docs: STRINGS },
|
|
31
|
+
additionalProperties: false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
additionalProperties: false
|
|
36
|
+
};
|
|
37
|
+
/** The catalogue at `file` under `root`, or why there is none to read. */
|
|
38
|
+
export function readCatalogue(root, file) {
|
|
39
|
+
const path = join(root, file);
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
return { state: "missing" };
|
|
42
|
+
}
|
|
43
|
+
const text = readFileSync(path, "utf8");
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return { state: "invalid", problems: [`not JSON: ${error instanceof Error ? error.message : String(error)}`] };
|
|
50
|
+
}
|
|
51
|
+
const problems = validate(SCHEMA, parsed).map(({ path: at, message }) => (at === "" ? message : `${at}: ${message}`));
|
|
52
|
+
if (problems.length > 0) {
|
|
53
|
+
return { state: "invalid", problems };
|
|
54
|
+
}
|
|
55
|
+
// The schema above is the runtime twin of `Catalogue`: a document it accepts has this shape.
|
|
56
|
+
return { state: "valid", catalogue: parsed, text };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* How near the `code` entry `entry`, a file or a directory, names `path`: the length of the entry when
|
|
60
|
+
* it is the path or a directory above it, -1 when it is neither.
|
|
61
|
+
*/
|
|
62
|
+
function covers(entry, path) {
|
|
63
|
+
const prefix = entry.replace(/\/+$/, "");
|
|
64
|
+
return path === prefix || path.startsWith(`${prefix}/`) ? prefix.length : -1;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The Primitives whose `code` names `path`, relative to the repository root: of those, the ones whose
|
|
68
|
+
* entry is nearest the path, so a file wins over the directory holding it. Empty when none names it.
|
|
69
|
+
*/
|
|
70
|
+
export function primitivesNaming(catalogue, path) {
|
|
71
|
+
const nearest = catalogue.primitives.map((primitive) => Math.max(-1, ...primitive.code.map((entry) => covers(entry, path))));
|
|
72
|
+
const best = Math.max(-1, ...nearest);
|
|
73
|
+
return best === -1 ? [] : catalogue.primitives.filter((_, index) => nearest[index] === best);
|
|
74
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `agents-map`: the agent map stays a map. Within its budget, naming the Gate command, routing to the
|
|
2
|
+
// Primitives catalogue, every path its Task Router points at existing, and the skills directory, its
|
|
3
|
+
// lock and its links in step.
|
|
4
|
+
import { existsSync, globSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolveConfig } from "../agentic-config.js";
|
|
7
|
+
import { codeSpans, tableRows } from "../markdown.js";
|
|
8
|
+
import { CannotRun } from "./check.js";
|
|
9
|
+
import { catalogueFile } from "./primitives.js";
|
|
10
|
+
const ROUTER = "Task Router";
|
|
11
|
+
const AGENTS_SKILLS = ".agents/skills";
|
|
12
|
+
const CLAUDE_SKILLS = ".claude/skills";
|
|
13
|
+
const LOCK = "skills-lock.json";
|
|
14
|
+
const REMEDIES = {
|
|
15
|
+
missing: "write it, or point gate.checks.agents-map.file at the map",
|
|
16
|
+
budget: `cut it, or move a topic into a doc a ${ROUTER} row points at`,
|
|
17
|
+
command: "name it where the map lists the commands",
|
|
18
|
+
noCommand: "set gate.command in the agentic config, or give package.json a check script",
|
|
19
|
+
catalogue: "add a row that sends an agent to the catalogue",
|
|
20
|
+
route: "fix the path or drop it from the row",
|
|
21
|
+
unlocked: "add it through the skills CLI, or remove the directory",
|
|
22
|
+
unheld: "restore the skill, or drop it from the lock",
|
|
23
|
+
lock: "fix it, or write it again through the skills CLI",
|
|
24
|
+
stray: `remove it, or move the skill into ${AGENTS_SKILLS}`
|
|
25
|
+
};
|
|
26
|
+
/** A code span that names a path: no spaces, and a slash or a file extension; `*` makes it a glob. */
|
|
27
|
+
function isPath(span) {
|
|
28
|
+
return /^[\w.*/-]+$/.test(span) && (span.includes("/") || /\.\w+$/.test(span));
|
|
29
|
+
}
|
|
30
|
+
function exists(root, path) {
|
|
31
|
+
return path.includes("*") ? globSync(path, { cwd: root }).length > 0 : existsSync(join(root, path));
|
|
32
|
+
}
|
|
33
|
+
function escape(text) {
|
|
34
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
}
|
|
36
|
+
/** The budget, the Gate command, the row to the catalogue and every path the Task Router points at. */
|
|
37
|
+
function judgeMap(root, { file, maxLines, maxBytes }, text) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
const lines = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
|
40
|
+
if (lines > maxLines) {
|
|
41
|
+
findings.push({ file, message: `${lines} lines, over the ${maxLines}-line budget`, remedy: REMEDIES.budget });
|
|
42
|
+
}
|
|
43
|
+
const bytes = Buffer.byteLength(text);
|
|
44
|
+
if (bytes > maxBytes) {
|
|
45
|
+
findings.push({ file, message: `${bytes} bytes, over the ${maxBytes}-byte budget`, remedy: REMEDIES.budget });
|
|
46
|
+
}
|
|
47
|
+
const { gate } = resolveConfig(root).config;
|
|
48
|
+
if (gate.command === undefined) {
|
|
49
|
+
findings.push({ message: "the Gate has no command for the map to name", remedy: REMEDIES.noCommand });
|
|
50
|
+
}
|
|
51
|
+
else if (!new RegExp(`(^|[^\\w-])${escape(gate.command)}($|[^\\w-])`, "m").test(text)) {
|
|
52
|
+
const message = `does not name the Gate command ${JSON.stringify(gate.command)}`;
|
|
53
|
+
findings.push({ file, message, remedy: REMEDIES.command });
|
|
54
|
+
}
|
|
55
|
+
// The first column says when a row applies; the others say what to read.
|
|
56
|
+
const routes = tableRows(text, ROUTER).map(({ cells, line }) => ({
|
|
57
|
+
line,
|
|
58
|
+
paths: codeSpans(cells.slice(1).join(" ")).filter(isPath)
|
|
59
|
+
}));
|
|
60
|
+
const catalogue = catalogueFile(gate.checks);
|
|
61
|
+
if (existsSync(join(root, catalogue)) && !routes.some(({ paths }) => paths.includes(catalogue))) {
|
|
62
|
+
const message = `no ${ROUTER} row points at ${catalogue}`;
|
|
63
|
+
findings.push({ file, message, remedy: REMEDIES.catalogue });
|
|
64
|
+
}
|
|
65
|
+
for (const { line, paths } of routes) {
|
|
66
|
+
for (const missing of paths.filter((path) => !exists(root, path))) {
|
|
67
|
+
const message = `the ${ROUTER} points at ${JSON.stringify(missing)}, which does not exist`;
|
|
68
|
+
findings.push({ file, line, message, remedy: REMEDIES.route });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
/** The names of the directories in `dir`, sorted; empty when there is no `dir`. */
|
|
74
|
+
function directories(dir) {
|
|
75
|
+
if (!existsSync(dir)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
return readdirSync(dir)
|
|
79
|
+
.filter((name) => statSync(join(dir, name), { throwIfNoEntry: false })?.isDirectory() === true)
|
|
80
|
+
.toSorted();
|
|
81
|
+
}
|
|
82
|
+
/** The skills `skills-lock.json` pins, or why it cannot be read. */
|
|
83
|
+
function lockedSkills(root) {
|
|
84
|
+
let lock;
|
|
85
|
+
try {
|
|
86
|
+
lock = JSON.parse(readFileSync(join(root, LOCK), "utf8"));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return { problem: `not JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
90
|
+
}
|
|
91
|
+
if (typeof lock !== "object" || lock === null || !("skills" in lock)) {
|
|
92
|
+
return { problem: "it has no skills object" };
|
|
93
|
+
}
|
|
94
|
+
const { skills } = lock;
|
|
95
|
+
if (typeof skills !== "object" || skills === null || Array.isArray(skills)) {
|
|
96
|
+
return { problem: "it has no skills object" };
|
|
97
|
+
}
|
|
98
|
+
return Object.keys(skills).toSorted();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The skills directory, `.agents/skills` or else `.claude/skills`, against the lock where there is one,
|
|
102
|
+
* and `.agents/skills` against the links `.claude/skills` holds to it where there are both.
|
|
103
|
+
*/
|
|
104
|
+
function judgeSkills(root) {
|
|
105
|
+
const dir = existsSync(join(root, AGENTS_SKILLS)) ? AGENTS_SKILLS : CLAUDE_SKILLS;
|
|
106
|
+
const skills = directories(join(root, dir));
|
|
107
|
+
const findings = [];
|
|
108
|
+
if (existsSync(join(root, LOCK))) {
|
|
109
|
+
const locked = lockedSkills(root);
|
|
110
|
+
if ("problem" in locked) {
|
|
111
|
+
findings.push({ file: LOCK, message: `not a skills lock: ${locked.problem}`, remedy: REMEDIES.lock });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
for (const skill of skills.filter((name) => !locked.includes(name))) {
|
|
115
|
+
const message = `skill ${JSON.stringify(skill)} is not in ${LOCK}`;
|
|
116
|
+
findings.push({ file: `${dir}/${skill}`, message, remedy: REMEDIES.unlocked });
|
|
117
|
+
}
|
|
118
|
+
for (const skill of locked.filter((name) => !skills.includes(name))) {
|
|
119
|
+
const message = `locks skill ${JSON.stringify(skill)}, which ${dir} does not hold`;
|
|
120
|
+
findings.push({ file: LOCK, message, remedy: REMEDIES.unheld });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (dir === AGENTS_SKILLS && existsSync(join(root, CLAUDE_SKILLS))) {
|
|
125
|
+
const entries = readdirSync(join(root, CLAUDE_SKILLS)).toSorted();
|
|
126
|
+
for (const skill of skills) {
|
|
127
|
+
const file = `${CLAUDE_SKILLS}/${skill}`;
|
|
128
|
+
const remedy = `link it to ../../${AGENTS_SKILLS}/${skill}`;
|
|
129
|
+
if (!entries.includes(skill)) {
|
|
130
|
+
findings.push({ file, message: `skill ${JSON.stringify(skill)} has no link here`, remedy });
|
|
131
|
+
}
|
|
132
|
+
else if (!linksTo(join(root, file), join(root, AGENTS_SKILLS, skill))) {
|
|
133
|
+
findings.push({ file, message: `is not a link to ${AGENTS_SKILLS}/${skill}`, remedy });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries.filter((name) => !skills.includes(name))) {
|
|
137
|
+
const message = `${AGENTS_SKILLS} holds no skill ${JSON.stringify(entry)}`;
|
|
138
|
+
findings.push({ file: `${CLAUDE_SKILLS}/${entry}`, message, remedy: REMEDIES.stray });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
}
|
|
143
|
+
/** Whether `link` is a symbolic link that resolves to `target`. */
|
|
144
|
+
function linksTo(link, target) {
|
|
145
|
+
if (!lstatSync(link).isSymbolicLink()) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
return realpathSync(link) === realpathSync(target);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function run(root, options) {
|
|
156
|
+
const path = join(root, options.file);
|
|
157
|
+
if (!existsSync(path)) {
|
|
158
|
+
throw new CannotRun({ file: options.file, message: "the map does not exist", remedy: REMEDIES.missing });
|
|
159
|
+
}
|
|
160
|
+
return [...judgeMap(root, options, readFileSync(path, "utf8")), ...judgeSkills(root)];
|
|
161
|
+
}
|
|
162
|
+
export const agentsMap = {
|
|
163
|
+
name: "agents-map",
|
|
164
|
+
defaults: { file: "AGENTS.md", maxLines: 150, maxBytes: 10240 },
|
|
165
|
+
run
|
|
166
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Failure } from "../report.ts";
|
|
2
|
+
/** A failure as a Check finds it: the file and line where it has one, what is wrong, the remedy. */
|
|
3
|
+
export type Finding = Omit<Failure, "rule">;
|
|
4
|
+
/** An input the Check needs is missing or unreadable, so the Gate cannot run: exit 2, not 1. */
|
|
5
|
+
export declare class CannotRun extends Error {
|
|
6
|
+
finding: Finding;
|
|
7
|
+
constructor(finding: Finding);
|
|
8
|
+
}
|
|
9
|
+
export type Check<Options extends object> = {
|
|
10
|
+
/** The Check's key under `gate.checks`. */
|
|
11
|
+
name: string;
|
|
12
|
+
defaults: Options;
|
|
13
|
+
/** Every failure of the tree at `root`, none when it passes; throws `CannotRun` when it cannot judge. */
|
|
14
|
+
run: (root: string, options: Options) => Finding[];
|
|
15
|
+
};
|
|
16
|
+
/** The options `setting` turns the Check on with: the defaults under `true`, an object over them, else off. */
|
|
17
|
+
export declare function optionsOf<Options extends object>(check: Check<Options>, setting: unknown): Options | undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What every Check the package ships is: a name, the options `true` stands for, and a run over the
|
|
2
|
+
// tree that answers with its failures. The Gate names the Check as each failure's rule.
|
|
3
|
+
/** An input the Check needs is missing or unreadable, so the Gate cannot run: exit 2, not 1. */
|
|
4
|
+
export class CannotRun extends Error {
|
|
5
|
+
finding;
|
|
6
|
+
constructor(finding) {
|
|
7
|
+
super(finding.message);
|
|
8
|
+
this.finding = finding;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** The options `setting` turns the Check on with: the defaults under `true`, an object over them, else off. */
|
|
12
|
+
export function optionsOf(check, setting) {
|
|
13
|
+
if (setting === true) {
|
|
14
|
+
return check.defaults;
|
|
15
|
+
}
|
|
16
|
+
if (typeof setting === "object" && setting !== null && !Array.isArray(setting)) {
|
|
17
|
+
// The schema has refused every key the Check does not take and every value of the wrong type.
|
|
18
|
+
return { ...check.defaults, ...setting };
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Finding } from "./check.ts";
|
|
2
|
+
/** A Check turned on by its setting, ready to judge a tree. */
|
|
3
|
+
export type EnabledCheck = {
|
|
4
|
+
name: string;
|
|
5
|
+
run: (root: string) => Finding[];
|
|
6
|
+
};
|
|
7
|
+
/** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
|
|
8
|
+
export declare function enabledChecks(checks: Record<string, unknown>): EnabledCheck[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The Checks the package ships, in the order the Gate runs them. A new Check is a module beside this
|
|
2
|
+
// file, one row below and one key under `gate.checks` in the schema, listed in the same order.
|
|
3
|
+
import { agentsMap } from "./agents-map.js";
|
|
4
|
+
import { optionsOf } from "./check.js";
|
|
5
|
+
import { primitives } from "./primitives.js";
|
|
6
|
+
function ship(check) {
|
|
7
|
+
return {
|
|
8
|
+
name: check.name,
|
|
9
|
+
enable: (setting) => {
|
|
10
|
+
const options = optionsOf(check, setting);
|
|
11
|
+
return options === undefined ? undefined : { name: check.name, run: (root) => check.run(root, options) };
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const SHIPPED = [ship(primitives), ship(agentsMap)];
|
|
16
|
+
/** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
|
|
17
|
+
export function enabledChecks(checks) {
|
|
18
|
+
return SHIPPED.flatMap(({ name, enable }) => enable(checks[name]) ?? []);
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Check } from "./check.ts";
|
|
2
|
+
export type PrimitivesOptions = {
|
|
3
|
+
file: string;
|
|
4
|
+
roots: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare const primitives: Check<PrimitivesOptions>;
|
|
7
|
+
/** The catalogue a resolved `gate.checks` points at: the `primitives` Check's file, on or off. */
|
|
8
|
+
export declare function catalogueFile(checks: Record<string, unknown>): string;
|