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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -2
  3. package/config/agentic.config.schema.json +212 -0
  4. package/config/references.schema.json +56 -0
  5. package/dist/agentic-config.d.ts +167 -0
  6. package/dist/agentic-config.js +307 -0
  7. package/dist/catalogue.d.ts +36 -0
  8. package/dist/catalogue.js +74 -0
  9. package/dist/checks/agents-map.d.ts +7 -0
  10. package/dist/checks/agents-map.js +166 -0
  11. package/dist/checks/check.d.ts +17 -0
  12. package/dist/checks/check.js +21 -0
  13. package/dist/checks/index.d.ts +8 -0
  14. package/dist/checks/index.js +19 -0
  15. package/dist/checks/primitives.d.ts +8 -0
  16. package/dist/checks/primitives.js +128 -0
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +43 -0
  19. package/dist/commands/config.d.ts +2 -0
  20. package/dist/commands/config.js +76 -0
  21. package/dist/commands/doctor.d.ts +57 -0
  22. package/dist/commands/doctor.js +341 -0
  23. package/dist/commands/gate.d.ts +2 -0
  24. package/dist/commands/gate.js +175 -0
  25. package/dist/commands/primitives.d.ts +2 -0
  26. package/dist/commands/primitives.js +106 -0
  27. package/dist/commands/references-setup.d.ts +2 -0
  28. package/dist/commands/references-setup.js +213 -0
  29. package/dist/commands/references-sync.d.ts +2 -0
  30. package/dist/commands/references-sync.js +347 -0
  31. package/dist/commands/setup.d.ts +31 -0
  32. package/dist/commands/setup.js +369 -0
  33. package/dist/json-schema.d.ts +8 -0
  34. package/dist/json-schema.js +227 -0
  35. package/dist/markdown.d.ts +19 -0
  36. package/dist/markdown.js +77 -0
  37. package/dist/pins.d.ts +16 -0
  38. package/dist/pins.js +83 -0
  39. package/dist/references-key.d.ts +31 -0
  40. package/dist/references-key.js +150 -0
  41. package/dist/report.d.ts +13 -0
  42. package/dist/report.js +29 -0
  43. package/dist/router-block.d.ts +13 -0
  44. package/dist/router-block.js +62 -0
  45. package/dist/setup-rules.d.ts +22 -0
  46. package/dist/setup-rules.js +98 -0
  47. package/package.json +30 -3
  48. package/templates/router.md +38 -0
@@ -0,0 +1,128 @@
1
+ // `primitives`: the catalogue says what is true of the tree. Unique ids, a known group, a one-line
2
+ // summary, every code and docs path on disk with its anchor resolving, and every file under the roots
3
+ // named by a Primitive.
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { CATALOGUE_FILE, primitivesNaming, readCatalogue } from "../catalogue.js";
8
+ import { headings } from "../markdown.js";
9
+ import { CannotRun, optionsOf } from "./check.js";
10
+ const MAX_SUMMARY = 120;
11
+ /** A file a test runs, never shipped: under a test directory, or named as a test. */
12
+ const TEST_FILE = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[^/]+$/;
13
+ const REMEDIES = {
14
+ missing: "create it, or point gate.checks.primitives.file at the catalogue",
15
+ shape: "give it the catalogue's shape: version 1, groups of id and name, primitives of id, name, group, summary, code and docs",
16
+ duplicate: "give each Primitive an id of its own",
17
+ group: "add the group to groups, or move the Primitive into one listed there",
18
+ summary: `keep the summary to one line of ${MAX_SUMMARY} characters or fewer`,
19
+ path: "fix the path or drop it",
20
+ anchor: "point the entry at a heading the file has",
21
+ notGit: "run the Gate inside a git repository"
22
+ };
23
+ function show(value) {
24
+ return JSON.stringify(value);
25
+ }
26
+ /** The line of the `nth` (0-based) `"id": "<id>"` under the `primitives` key, where a Primitive is declared. */
27
+ function lineOf(text, id, nth) {
28
+ const start = Math.max(0, text.indexOf('"primitives"'));
29
+ const pattern = new RegExp(`"id"\\s*:\\s*${JSON.stringify(id).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g");
30
+ const match = Array.from(text.slice(start).matchAll(pattern))[nth];
31
+ return match?.index === undefined ? undefined : text.slice(0, start + match.index).split("\n").length;
32
+ }
33
+ /** The anchors of the headings of `path`; none when it is not a file. */
34
+ function anchorsOf(root, path, cache) {
35
+ let anchors = cache.get(path);
36
+ if (anchors === undefined) {
37
+ const full = join(root, path);
38
+ anchors = new Set(statSync(full).isFile() ? headings(readFileSync(full, "utf8")).map(({ anchor }) => anchor) : []);
39
+ cache.set(path, anchors);
40
+ }
41
+ return anchors;
42
+ }
43
+ /** What is wrong with one Primitive, declared at `at`: its group, its summary, each code and docs entry. */
44
+ function judge(root, catalogue, primitive, at, cache) {
45
+ const { id, group, summary, code, docs } = primitive;
46
+ const findings = [];
47
+ const fail = (message, remedy) => findings.push({ ...at, message, remedy });
48
+ if (!catalogue.groups.some((known) => known.id === group)) {
49
+ fail(`${show(id)} is in group ${show(group)}, which groups does not list`, REMEDIES.group);
50
+ }
51
+ const summaryLines = summary.split("\n").length;
52
+ if (summaryLines > 1) {
53
+ fail(`the summary of ${show(id)} runs over ${summaryLines} lines`, REMEDIES.summary);
54
+ }
55
+ else if ([...summary].length > MAX_SUMMARY) {
56
+ fail(`the summary of ${show(id)} is ${[...summary].length} characters, over ${MAX_SUMMARY}`, REMEDIES.summary);
57
+ }
58
+ for (const entry of code.filter((path) => !existsSync(join(root, path)))) {
59
+ fail(`${show(id)} names code at ${show(entry)}, which does not exist`, REMEDIES.path);
60
+ }
61
+ for (const entry of docs) {
62
+ const [path = "", anchor] = entry.split("#", 2);
63
+ if (!existsSync(join(root, path))) {
64
+ fail(`${show(id)} names the doc ${show(entry)}, which does not exist`, REMEDIES.path);
65
+ }
66
+ else if (anchor !== undefined && !anchorsOf(root, path, cache).has(anchor)) {
67
+ fail(`${show(id)} names the doc ${show(entry)}, which has no heading with that anchor`, REMEDIES.anchor);
68
+ }
69
+ }
70
+ return findings;
71
+ }
72
+ /** Every file under `roots` that git tracks or would track, and that is on disk. */
73
+ function filesUnder(root, roots) {
74
+ const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...roots];
75
+ const listed = spawnSync("git", args, { encoding: "utf8" });
76
+ if (listed.status !== 0) {
77
+ throw new CannotRun({
78
+ message: `cannot list the files under ${roots.join(", ")}: not a git repository`,
79
+ remedy: REMEDIES.notGit
80
+ });
81
+ }
82
+ return [...new Set(listed.stdout.split("\0").filter((path) => path !== ""))]
83
+ .filter((path) => existsSync(join(root, path)))
84
+ .toSorted();
85
+ }
86
+ function run(root, { file, roots }) {
87
+ const read = readCatalogue(root, file);
88
+ if (read.state === "missing") {
89
+ throw new CannotRun({ file, message: "the catalogue does not exist", remedy: REMEDIES.missing });
90
+ }
91
+ if (read.state === "invalid") {
92
+ return read.problems.map((message) => ({ file, message, remedy: REMEDIES.shape }));
93
+ }
94
+ const { catalogue, text } = read;
95
+ const findings = [];
96
+ const seen = new Map();
97
+ const cache = new Map();
98
+ for (const primitive of catalogue.primitives) {
99
+ const nth = seen.get(primitive.id) ?? 0;
100
+ seen.set(primitive.id, nth + 1);
101
+ const line = lineOf(text, primitive.id, nth);
102
+ const at = line === undefined ? { file } : { file, line };
103
+ if (nth > 0) {
104
+ findings.push({
105
+ ...at,
106
+ message: `id ${show(primitive.id)} is taken by an earlier Primitive`,
107
+ remedy: REMEDIES.duplicate
108
+ });
109
+ }
110
+ findings.push(...judge(root, catalogue, primitive, at, cache));
111
+ }
112
+ for (const path of filesUnder(root, roots)) {
113
+ if (!TEST_FILE.test(path) && primitivesNaming(catalogue, path).length === 0) {
114
+ const remedy = `name it, or a directory above it, in the code of a Primitive in ${file}`;
115
+ findings.push({ file: path, message: "no Primitive names this file", remedy });
116
+ }
117
+ }
118
+ return findings;
119
+ }
120
+ export const primitives = {
121
+ name: "primitives",
122
+ defaults: { file: CATALOGUE_FILE, roots: ["packages", "plugins", "scripts", "src"] },
123
+ run
124
+ };
125
+ /** The catalogue a resolved `gate.checks` points at: the `primitives` Check's file, on or off. */
126
+ export function catalogueFile(checks) {
127
+ return (optionsOf(primitives, checks.primitives) ?? primitives.defaults).file;
128
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // The `dw-sdlc` bin: one row per subcommand, each a module under `commands/`. A plugin's hook or skill
3
+ // reaches it through the plugin's `dw-sdlc` link.
4
+ import * as config from "./commands/config.js";
5
+ import * as doctor from "./commands/doctor.js";
6
+ import * as gate from "./commands/gate.js";
7
+ import * as primitives from "./commands/primitives.js";
8
+ import * as referencesSetup from "./commands/references-setup.js";
9
+ import * as referencesSync from "./commands/references-sync.js";
10
+ import * as setup from "./commands/setup.js";
11
+ const COMMANDS = new Map([
12
+ ["gate", gate],
13
+ ["primitives", primitives],
14
+ ["setup", setup],
15
+ ["doctor", doctor],
16
+ ["config", config],
17
+ ["references sync", referencesSync],
18
+ ["references setup", referencesSetup]
19
+ ]);
20
+ function usage() {
21
+ const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
22
+ const rows = [...COMMANDS].map(([name, { summary }]) => ` ${name.padEnd(width)}${summary}`);
23
+ return `Usage: dw-sdlc <command> [options]\n\nCommands:\n${rows.join("\n")}\n\nRun dw-sdlc <command> --help for its options.`;
24
+ }
25
+ async function main(argv) {
26
+ const [first, second] = argv;
27
+ if (first === "--help" || first === "-h") {
28
+ process.stdout.write(`${usage()}\n`);
29
+ return 0;
30
+ }
31
+ // A two-word name, as `references sync`, before its first word alone.
32
+ const words = second !== undefined && COMMANDS.has(`${first} ${second}`) ? 2 : 1;
33
+ const command = COMMANDS.get(argv.slice(0, words).join(" "));
34
+ if (command === undefined) {
35
+ const problem = first === undefined ? "no command given" : `unknown command ${JSON.stringify(first)}`;
36
+ process.stderr.write(`dw-sdlc: ${problem}\n\n${usage()}\n`);
37
+ return 2;
38
+ }
39
+ return command.run(argv.slice(words));
40
+ }
41
+ if (import.meta.main) {
42
+ process.exitCode = await main(process.argv.slice(2));
43
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "resolve the repository's agentic config, or validate one file against the schema";
2
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,76 @@
1
+ // `dw-sdlc config`, the config check: resolves a repository's agentic config, or validates one file
2
+ // against the schema, and prints what the guards see.
3
+ import { readFileSync } from "node:fs";
4
+ import { parseArgs } from "node:util";
5
+ import { COMMITTED_FILE, findRepository, LOCAL_FILE, repositoryRoot, resolveConfig, validateDocument } from "../agentic-config.js";
6
+ export const summary = "resolve the repository's agentic config, or validate one file against the schema";
7
+ const USAGE = `Usage: dw-sdlc config [--root <dir>] [file]
8
+ or: pnpm agentic:check [--root <dir>] [file], in a checkout of dw-sdlc
9
+
10
+ Without a file: resolves the agentic config of the repository holding --root (default: the current
11
+ directory) — local over committed over inferred — and prints what the guards see. With a file:
12
+ validates that one document against the schema. Exit 1 on any problem.`;
13
+ function messageOf(error) {
14
+ return error instanceof Error ? error.message : String(error);
15
+ }
16
+ function fail(message, code) {
17
+ console.error(`agentic:check: ${message}`);
18
+ process.exit(code);
19
+ }
20
+ function parseCli(argv) {
21
+ try {
22
+ const { values, positionals } = parseArgs({
23
+ args: argv,
24
+ allowPositionals: true,
25
+ options: { help: { type: "boolean", short: "h" }, root: { type: "string" } }
26
+ });
27
+ if (positionals.length > 1) {
28
+ throw new Error("at most one file");
29
+ }
30
+ return { help: values.help === true, root: values.root, file: positionals[0] };
31
+ }
32
+ catch (error) {
33
+ fail(`${messageOf(error)}\n\n${USAGE}`, 2);
34
+ }
35
+ }
36
+ function report(problems) {
37
+ for (const { file, path, message } of problems) {
38
+ console.error(path === "" ? `${file}: ${message}` : `${file}: ${path}: ${message}`);
39
+ }
40
+ }
41
+ export function run(argv) {
42
+ const cli = parseCli(argv);
43
+ if (cli.help) {
44
+ console.log(USAGE);
45
+ return 0;
46
+ }
47
+ if (cli.file !== undefined) {
48
+ let text;
49
+ try {
50
+ text = readFileSync(cli.file, "utf8");
51
+ }
52
+ catch (error) {
53
+ fail(messageOf(error), 1);
54
+ }
55
+ const result = validateDocument(cli.file, text);
56
+ if ("problems" in result) {
57
+ report(result.problems);
58
+ return 1;
59
+ }
60
+ console.log(`${cli.file}: valid`);
61
+ return 0;
62
+ }
63
+ const root = cli.root === undefined
64
+ ? (findRepository(process.cwd()) ?? fail("not inside a git repository; pass --root <dir>", 1))
65
+ : repositoryRoot(cli.root);
66
+ const { config, problems, layers, sources } = resolveConfig(root);
67
+ const from = (source) => source === undefined || source === root ? "" : `, from ${source}`;
68
+ console.log(`committed ${COMMITTED_FILE} ${layers.committed}${from(sources.committed)}`);
69
+ console.log(`local ${LOCAL_FILE} ${layers.local}${from(sources.local)}`);
70
+ console.log(JSON.stringify(config, null, 2));
71
+ if (problems.length > 0) {
72
+ report(problems);
73
+ fail(`${problems.length} problem(s) above; the guards run on the strictest defaults until the file is fixed`, 1);
74
+ }
75
+ return 0;
76
+ }
@@ -0,0 +1,57 @@
1
+ export type Level = "PASS" | "WARN" | "FAIL" | "INFO";
2
+ export type Check = {
3
+ name: string;
4
+ level: Level;
5
+ detail: string;
6
+ };
7
+ /** What the doctor asks the machine; the tests answer from canned data. */
8
+ export type Probe = {
9
+ /** A command run directly, from `cwd` when given, with its exit status and stdout. */
10
+ run(command: string, args: string[], cwd?: string): {
11
+ status: number;
12
+ stdout: string;
13
+ };
14
+ /** A file's text, or undefined when it is absent. */
15
+ read(path: string): string | undefined;
16
+ /** One line of `command -v <tool>` from a non-interactive login shell, the shell the agent gets. */
17
+ shell(script: string): string;
18
+ };
19
+ export type RepositoryConfig = {
20
+ config: {
21
+ commit: {
22
+ convention: string;
23
+ };
24
+ };
25
+ problems: string[];
26
+ /** The checkout each layer was read from, as `resolveConfig` reports it. */
27
+ sources?: {
28
+ committed: string | undefined;
29
+ local: string | undefined;
30
+ };
31
+ };
32
+ export type Inputs = {
33
+ root: string;
34
+ home: string;
35
+ /** Claude Code's config dir; `<home>/.claude` when absent. */
36
+ claudeDir?: string;
37
+ probe: Probe;
38
+ /** Undefined when no repository holds `root`. */
39
+ config: RepositoryConfig | undefined;
40
+ };
41
+ /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
42
+ export declare function claudeConfigDir(env: Record<string, string | undefined>, home: string): string;
43
+ /** The marketplace Claude Code registers this repository under, and the plugin id inside it. */
44
+ export declare const MARKETPLACE = "dw-sdlc";
45
+ export declare const PLUGIN_ID = "guardrails@dw-sdlc";
46
+ /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
47
+ export declare const RTK_FIND_EXEC_FIXED = "0.46.0";
48
+ export declare const PNPM_MINIMUM_RELEASE_AGE = 10080;
49
+ export declare const realProbe: Probe;
50
+ /** rtk's config.toml: Application Support on macOS, XDG or ~/.config elsewhere. */
51
+ export declare function rtkConfigPath(home: string, platform?: string, env?: Record<string, string | undefined>): string;
52
+ /** The `exclude_commands` list of an rtk config.toml, read with a regex: the file is small and ours. */
53
+ export declare function rtkExcludes(toml: string | undefined): string[];
54
+ export declare function diagnose(inputs: Inputs): Check[];
55
+ export declare function render(checks: Check[]): string;
56
+ export declare const summary = "one PASS / WARN / FAIL line per machine check the guardrails rely on";
57
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,341 @@
1
+ // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the guardrails rely on, read-only.
2
+ // It diagnoses the repository holding --root and the user's home; it installs nothing. FAIL is a
3
+ // guard that does not guard (no jq, plugin absent or disabled, an invalid config), WARN a layer that
4
+ // is missing or loose (Safe Chain, the pnpm cooldown, signing, rtk excludes), INFO a check that does
5
+ // not apply here. Every probe goes through one interface, so the tests fake each answer. The
6
+ // machine-wide values are asked from the home directory: from a checkout, its own
7
+ // pnpm-workspace.yaml would answer for the machine.
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { parseArgs } from "node:util";
13
+ import { findRepository, resolveConfig } from "../agentic-config.js";
14
+ /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
15
+ export function claudeConfigDir(env, home) {
16
+ return env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
17
+ }
18
+ /** The marketplace Claude Code registers this repository under, and the plugin id inside it. */
19
+ export const MARKETPLACE = "dw-sdlc";
20
+ export const PLUGIN_ID = `guardrails@${MARKETPLACE}`;
21
+ /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
22
+ export const RTK_FIND_EXEC_FIXED = "0.46.0";
23
+ export const PNPM_MINIMUM_RELEASE_AGE = 10080;
24
+ export const realProbe = {
25
+ run(command, args, cwd) {
26
+ const result = spawnSync(command, args, { cwd, encoding: "utf8" });
27
+ return { status: result.status ?? 127, stdout: result.stdout ?? "" };
28
+ },
29
+ read(path) {
30
+ return existsSync(path) ? readFileSync(path, "utf8") : undefined;
31
+ },
32
+ shell(script) {
33
+ const shell = process.env.SHELL ?? "/bin/sh";
34
+ const result = spawnSync(shell, ["-l", "-c", script], { encoding: "utf8" });
35
+ return (result.stdout ?? "").trim().split("\n")[0] ?? "";
36
+ }
37
+ };
38
+ function isObject(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ function parseJson(text) {
42
+ if (text === undefined) {
43
+ return undefined;
44
+ }
45
+ try {
46
+ return JSON.parse(text);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ function checkJq(probe) {
53
+ const { status, stdout } = probe.run("jq", ["--version"]);
54
+ if (status === 0) {
55
+ return { name: "jq", level: "PASS", detail: stdout.trim() };
56
+ }
57
+ return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
58
+ }
59
+ /** Where the plugin's hooks run: live from the checkout for a marketplace added from a directory, a
60
+ * cached snapshot for one added from git. A marker placed in only one of the two settled it. */
61
+ /** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
62
+ function liveCheckout(probe, claude) {
63
+ const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
64
+ const entry = isObject(known) && isObject(known[MARKETPLACE]) ? known[MARKETPLACE] : undefined;
65
+ const source = entry !== undefined && isObject(entry.source) ? entry.source : undefined;
66
+ return source?.source === "directory" && typeof source.path === "string" ? source.path : undefined;
67
+ }
68
+ function pluginOrigin(probe, claude, installPath) {
69
+ const checkout = liveCheckout(probe, claude);
70
+ if (checkout !== undefined) {
71
+ return `runs live from ${checkout}/plugins/guardrails; a change there is in the next session`;
72
+ }
73
+ return `installed at ${installPath}; a change reaches it through claude plugin update`;
74
+ }
75
+ /** The directory whose hooks run on this machine: the checkout of a directory marketplace, else the installed snapshot. */
76
+ function runningPluginRoot(probe, claude) {
77
+ const checkout = liveCheckout(probe, claude);
78
+ if (checkout !== undefined) {
79
+ return join(checkout, "plugins", "guardrails");
80
+ }
81
+ const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
82
+ const list = status === 0 ? parseJson(stdout) : undefined;
83
+ if (!Array.isArray(list)) {
84
+ return undefined;
85
+ }
86
+ const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
87
+ return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
88
+ }
89
+ function checkPlugin(probe, claude) {
90
+ const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
91
+ const list = status === 0 ? parseJson(stdout) : undefined;
92
+ if (!Array.isArray(list)) {
93
+ return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
94
+ }
95
+ const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
96
+ if (entry === undefined) {
97
+ return {
98
+ name: "plugin",
99
+ level: "FAIL",
100
+ detail: `${PLUGIN_ID} is not installed. claude plugin marketplace add dominikwozniak/dw-sdlc --sparse .claude-plugin plugins packages && claude plugin install ${PLUGIN_ID} --scope user`
101
+ };
102
+ }
103
+ const version = typeof entry.version === "string" ? entry.version : "?";
104
+ if (entry.enabled !== true) {
105
+ return {
106
+ name: "plugin",
107
+ level: "FAIL",
108
+ detail: `${version} installed but disabled, so no guard runs. claude plugin enable ${PLUGIN_ID}`
109
+ };
110
+ }
111
+ return {
112
+ name: "plugin",
113
+ level: "PASS",
114
+ detail: `${version} enabled; ${pluginOrigin(probe, claude, String(entry.installPath ?? ""))}`
115
+ };
116
+ }
117
+ /** Every hook command string in a settings file, whatever the event and matcher. */
118
+ function hookCommands(settings) {
119
+ if (!isObject(settings) || !isObject(settings.hooks)) {
120
+ return [];
121
+ }
122
+ const commands = [];
123
+ for (const groups of Object.values(settings.hooks)) {
124
+ if (!Array.isArray(groups)) {
125
+ continue;
126
+ }
127
+ for (const group of groups) {
128
+ if (!isObject(group) || !Array.isArray(group.hooks)) {
129
+ continue;
130
+ }
131
+ for (const hook of group.hooks) {
132
+ if (isObject(hook) && typeof hook.command === "string") {
133
+ commands.push(hook.command);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return commands;
139
+ }
140
+ /** rtk's config.toml: Application Support on macOS, XDG or ~/.config elsewhere. */
141
+ export function rtkConfigPath(home, platform = process.platform, env = process.env) {
142
+ if (platform === "darwin") {
143
+ return join(home, "Library", "Application Support", "rtk", "config.toml");
144
+ }
145
+ return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "rtk", "config.toml");
146
+ }
147
+ /** The `exclude_commands` list of an rtk config.toml, read with a regex: the file is small and ours. */
148
+ export function rtkExcludes(toml) {
149
+ const match = /^\s*exclude_commands\s*=\s*\[([^\]]*)\]/m.exec(toml ?? "");
150
+ if (match === null) {
151
+ return [];
152
+ }
153
+ return [...(match[1] ?? "").matchAll(/"([^"]*)"/g)].map((entry) => entry[1] ?? "");
154
+ }
155
+ function versionBefore(version, threshold) {
156
+ const a = version.split(".").map((part) => Number.parseInt(part, 10));
157
+ const b = threshold.split(".").map((part) => Number.parseInt(part, 10));
158
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
159
+ const x = a[i] ?? 0;
160
+ const y = b[i] ?? 0;
161
+ if (x !== y) {
162
+ return x < y;
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+ // The plugin's rtk-rewrite.sh is the one wiring of `rtk hook claude`; a second one in the user
168
+ // settings rewrites again, and in a linked worktree that copy is what the isolation check refuses.
169
+ function checkRtk(probe, home, claude) {
170
+ const rtk = probe.shell("command -v rtk");
171
+ if (rtk === "") {
172
+ return { name: "rtk", level: "PASS", detail: "no rtk on this machine, the plugin's rewrite hook stays silent" };
173
+ }
174
+ const settingsPath = join(claude, "settings.json");
175
+ const settings = parseJson(probe.read(settingsPath));
176
+ const byHand = hookCommands(settings).find((command) => /\brtk\b/.test(command));
177
+ const root = runningPluginRoot(probe, claude);
178
+ if (root === undefined || probe.read(join(root, "hooks", "rtk-rewrite.sh")) === undefined) {
179
+ const meanwhile = byHand === undefined ? "nothing rewrites" : `the hand-wired ${byHand} carries it until then`;
180
+ return {
181
+ name: "rtk",
182
+ level: "WARN",
183
+ detail: `rtk at ${rtk} but the plugin running here has no hooks/rtk-rewrite.sh: claude plugin update; ${meanwhile}`
184
+ };
185
+ }
186
+ if (byHand !== undefined) {
187
+ return {
188
+ name: "rtk",
189
+ level: "FAIL",
190
+ detail: `${settingsPath} wires rtk by hand (${byHand}), and the plugin's rtk-rewrite.sh already runs it: remove that hook, or every git command in a linked worktree is refused by the isolation check`
191
+ };
192
+ }
193
+ // The filters that misbehave on the installed release have to sit in exclude_commands.
194
+ const version = probe.run("rtk", ["--version"]).stdout.trim().split(/\s+/)[1] ?? "0";
195
+ const excludes = rtkExcludes(probe.read(rtkConfigPath(home)));
196
+ const missing = [];
197
+ if (versionBefore(version, RTK_FIND_EXEC_FIXED) && !excludes.includes("find")) {
198
+ missing.push(`find (rtk ${version} refuses find -exec; fixed in ${RTK_FIND_EXEC_FIXED})`);
199
+ }
200
+ if (missing.length > 0) {
201
+ return {
202
+ name: "rtk",
203
+ level: "WARN",
204
+ detail: `rtk ${version} behind the plugin's rewrite hook, but [hooks] exclude_commands lacks ${missing.join("; ")}`
205
+ };
206
+ }
207
+ return {
208
+ name: "rtk",
209
+ level: "PASS",
210
+ detail: `rtk ${version} behind the plugin's rewrite hook; exclude_commands: ${excludes.join(", ") || "none"}`
211
+ };
212
+ }
213
+ function checkSafeChain(probe) {
214
+ const binary = probe.shell("command -v safe-chain");
215
+ if (binary === "") {
216
+ return {
217
+ name: "safe chain",
218
+ level: "WARN",
219
+ detail: "absent: the agent shell reaches the registry without Aikido's blocklist. github.com/AikidoSec/safe-chain"
220
+ };
221
+ }
222
+ const npm = probe.shell("command -v npm");
223
+ // The --ci shims put a wrapper on PATH; the laptop install defines a shell function, which
224
+ // `command -v` reports as the bare name.
225
+ const through = npm === "npm" || npm.includes(".safe-chain");
226
+ if (!through) {
227
+ return {
228
+ name: "safe chain",
229
+ level: "WARN",
230
+ detail: `installed (${binary}) but npm resolves to ${npm}, not through it; the shell function or the --ci shims are not on this shell's PATH`
231
+ };
232
+ }
233
+ return { name: "safe chain", level: "PASS", detail: `${binary}, npm goes through it` };
234
+ }
235
+ function checkPnpmReleaseAge(probe, home) {
236
+ if (probe.shell("command -v pnpm") === "") {
237
+ return { name: "pnpm release age", level: "INFO", detail: "no pnpm in the shell" };
238
+ }
239
+ const { stdout } = probe.run("pnpm", ["config", "get", "minimumReleaseAge"], home);
240
+ const minutes = Number.parseInt(stdout.trim(), 10);
241
+ if (Number.isNaN(minutes) || minutes < PNPM_MINIMUM_RELEASE_AGE) {
242
+ const state = Number.isNaN(minutes) ? "unset" : `${minutes} minutes`;
243
+ return {
244
+ name: "pnpm release age",
245
+ level: "WARN",
246
+ detail: `${state} globally: below the ${PNPM_MINIMUM_RELEASE_AGE}-minute baseline. pnpm config set --global minimumReleaseAge ${PNPM_MINIMUM_RELEASE_AGE}`
247
+ };
248
+ }
249
+ return { name: "pnpm release age", level: "PASS", detail: `${minutes} minutes globally` };
250
+ }
251
+ function checkCommitSigning(probe) {
252
+ const { status, stdout } = probe.run("git", ["config", "--global", "commit.gpgsign"]);
253
+ if (status === 0 && stdout.trim() === "true") {
254
+ return { name: "commit signing", level: "PASS", detail: "commit.gpgsign true" };
255
+ }
256
+ return {
257
+ name: "commit signing",
258
+ level: "WARN",
259
+ detail: "commit.gpgsign is off, so commits go out unsigned. git config --global commit.gpgsign true"
260
+ };
261
+ }
262
+ function checkConfig(config, root) {
263
+ if (config === undefined) {
264
+ return { name: "config", level: "INFO", detail: `no repository found at ${root}; run from one or pass --root` };
265
+ }
266
+ if (config.problems.length > 0) {
267
+ return {
268
+ name: "config",
269
+ level: "FAIL",
270
+ detail: `the guards run on the strictest defaults: ${config.problems.join("; ")}`
271
+ };
272
+ }
273
+ // A worktree that lacks a layer reads the main checkout's; say so, since the file is not here.
274
+ const borrowed = Object.entries(config.sources ?? {})
275
+ .filter((entry) => entry[1] !== undefined && entry[1] !== root)
276
+ .map(([layer, source]) => `; ${layer} layer from ${source}`);
277
+ return {
278
+ name: "config",
279
+ level: "PASS",
280
+ detail: `commit ${config.config.commit.convention}${borrowed.join("")}`
281
+ };
282
+ }
283
+ export function diagnose(inputs) {
284
+ const { probe, home } = inputs;
285
+ const claude = inputs.claudeDir ?? join(home, ".claude");
286
+ return [
287
+ checkJq(probe),
288
+ checkPlugin(probe, claude),
289
+ checkRtk(probe, home, claude),
290
+ checkSafeChain(probe),
291
+ checkPnpmReleaseAge(probe, home),
292
+ checkCommitSigning(probe),
293
+ checkConfig(inputs.config, inputs.root)
294
+ ];
295
+ }
296
+ export function render(checks) {
297
+ return checks.map((check) => `${check.level.padEnd(5)} ${check.name.padEnd(17)} ${check.detail}`).join("\n");
298
+ }
299
+ export const summary = "one PASS / WARN / FAIL line per machine check the guardrails rely on";
300
+ const USAGE = `Usage: dw-sdlc doctor [--root <dir>] [--home <dir>]
301
+ or: pnpm guardrails:doctor [--root <dir>] [--home <dir>], in a checkout of dw-sdlc
302
+
303
+ One PASS / WARN / FAIL line per machine check the guardrails rely on; read-only. Exit 1 when any
304
+ check fails. Claude Code's settings are read from $CLAUDE_CONFIG_DIR, or ~/.claude without it; a
305
+ --home reads <home>/.claude.`;
306
+ function repositoryConfig(root) {
307
+ const resolution = resolveConfig(root);
308
+ return {
309
+ config: resolution.config,
310
+ problems: resolution.problems.map(({ file, path, message }) => path === "" ? `${file}: ${message}` : `${file}: ${path}: ${message}`),
311
+ sources: resolution.sources
312
+ };
313
+ }
314
+ export function run(argv) {
315
+ let values;
316
+ try {
317
+ values = parseArgs({
318
+ args: argv,
319
+ options: { help: { type: "boolean", short: "h" }, root: { type: "string" }, home: { type: "string" } }
320
+ }).values;
321
+ }
322
+ catch (error) {
323
+ console.error(`guardrails:doctor: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
324
+ return 2;
325
+ }
326
+ if (values.help === true) {
327
+ console.log(USAGE);
328
+ return 0;
329
+ }
330
+ const dir = values.root ?? process.cwd();
331
+ const root = findRepository(dir);
332
+ const checks = diagnose({
333
+ root: root ?? dir,
334
+ home: values.home ?? homedir(),
335
+ claudeDir: values.home === undefined ? claudeConfigDir(process.env, homedir()) : join(values.home, ".claude"),
336
+ probe: realProbe,
337
+ config: root === undefined ? undefined : repositoryConfig(root)
338
+ });
339
+ console.log(render(checks));
340
+ return checks.some((check) => check.level === "FAIL") ? 1 : 0;
341
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
2
+ export declare function run(argv: string[]): number;