dw-sdlc 0.3.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/README.md CHANGED
@@ -17,7 +17,8 @@ pnpm add -D dw-sdlc
17
17
 
18
18
  `dw-sdlc gate` runs the Steps under `gate.steps` in order from the repository root, then every Check
19
19
  under `gate.checks`. It exits 0 when everything passes, 1 when a Step or a Check fails, and 2 when the
20
- Gate cannot run. `--json` prints the result as one document.
20
+ Gate cannot run. `--json` prints the result as one document. `dw-sdlc check <name>` runs one Check
21
+ alone, with the same lines, exit codes and document.
21
22
 
22
23
  ```json
23
24
  {
@@ -38,6 +39,7 @@ Gate cannot run. `--json` prints the result as one document.
38
39
  | command | what it does |
39
40
  | ------------------ | ----------------------------------------------------------------------------------- |
40
41
  | `gate` | the Steps of `gate.steps` in order, then every Check under `gate.checks` |
42
+ | `check <name>` | one Check of the Gate by its name, without the Steps and the other Checks |
41
43
  | `primitives` | the Primitives that own each path, with their summaries and documents |
42
44
  | `setup` | this machine's local config layer, the git excludes and the permission rules |
43
45
  | `doctor` | one PASS / WARN / FAIL line per machine check the guardrails rely on |
@@ -8,5 +8,9 @@ export type EnabledCheck = {
8
8
  refresh: (root: string) => string;
9
9
  };
10
10
  };
11
+ /** The name of every Check the package ships, in the order the Gate runs them. */
12
+ export declare const CHECK_NAMES: readonly string[];
11
13
  /** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
12
14
  export declare function enabledChecks(checks: Record<string, unknown>): EnabledCheck[];
15
+ /** The shipped Check `name` on `setting`, or on its defaults where `setting` leaves it off. */
16
+ export declare function shippedCheck(name: string, setting: unknown): EnabledCheck | undefined;
@@ -36,7 +36,14 @@ const SHIPPED = [
36
36
  ship(fileSizeRatchet),
37
37
  ship(commentBudget)
38
38
  ];
39
+ /** The name of every Check the package ships, in the order the Gate runs them. */
40
+ export const CHECK_NAMES = SHIPPED.map(({ name }) => name);
39
41
  /** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
40
42
  export function enabledChecks(checks) {
41
43
  return SHIPPED.flatMap(({ name, enable }) => enable(checks[name]) ?? []);
42
44
  }
45
+ /** The shipped Check `name` on `setting`, or on its defaults where `setting` leaves it off. */
46
+ export function shippedCheck(name, setting) {
47
+ const on = setting === false || setting === undefined ? true : setting;
48
+ return SHIPPED.find((check) => check.name === name)?.enable(on);
49
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // The `dw-sdlc` bin: one row per subcommand, each a module under `commands/`. A plugin's hook or skill
3
3
  // reaches it through the plugin's `dw-sdlc` link.
4
+ import * as check from "./commands/check.js";
4
5
  import * as config from "./commands/config.js";
5
6
  import * as doctor from "./commands/doctor.js";
6
7
  import * as gate from "./commands/gate.js";
@@ -10,6 +11,7 @@ import * as referencesSync from "./commands/references-sync.js";
10
11
  import * as setup from "./commands/setup.js";
11
12
  const COMMANDS = new Map([
12
13
  ["gate", gate],
14
+ ["check", check],
13
15
  ["primitives", primitives],
14
16
  ["setup", setup],
15
17
  ["doctor", doctor],
@@ -0,0 +1,2 @@
1
+ export declare const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
2
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,59 @@
1
+ // `dw-sdlc check <name>`: one Check of the Gate by its name, without the Steps and the other Checks.
2
+ import { parseArgs } from "node:util";
3
+ import { repositoryRoot } from "../agentic-config.js";
4
+ import { CHECK_NAMES } from "../checks/index.js";
5
+ import { printReport, runGate } from "./gate.js";
6
+ export const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
7
+ const USAGE = `Usage: dw-sdlc check <name> [--json] [--root <dir>]
8
+
9
+ Runs the Check named <name> in the Gate of the repository holding --root (default: the current
10
+ directory): a script under gate.checks.run, or a Check the package ships, one of
11
+ ${CHECK_NAMES.join(", ")}. A name both carry runs both, as the Gate does. A
12
+ shipped Check runs on the setting gate.checks gives it, and on its defaults where the config sets it
13
+ false or leaves it out. No Step runs. Prints one line per failure, as the Gate does; --json prints
14
+ the Gate's document, with no Step.
15
+
16
+ A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
17
+ pass.
18
+
19
+ Exit 0 when the Check passes, 1 when it fails, 2 when it cannot run: no Check of that name, an
20
+ invalid config layer, a command the shell cannot find or execute, or an input the Check reads
21
+ missing.`;
22
+ function parseCli(argv) {
23
+ try {
24
+ const { values, positionals } = parseArgs({
25
+ args: argv,
26
+ allowPositionals: true,
27
+ options: {
28
+ help: { type: "boolean", short: "h" },
29
+ json: { type: "boolean" },
30
+ root: { type: "string" }
31
+ }
32
+ });
33
+ if (positionals.length > 1) {
34
+ return { error: `one Check at a time, not ${positionals.map((name) => JSON.stringify(name)).join(" and ")}` };
35
+ }
36
+ return { help: values.help === true, json: values.json === true, root: values.root, name: positionals[0] };
37
+ }
38
+ catch (error) {
39
+ return { error: error instanceof Error ? error.message : String(error) };
40
+ }
41
+ }
42
+ export function run(argv) {
43
+ const cli = parseCli(argv);
44
+ if ("error" in cli) {
45
+ process.stderr.write(`dw-sdlc check: ${cli.error}\n\n${USAGE}\n`);
46
+ return 2;
47
+ }
48
+ if (cli.help) {
49
+ process.stdout.write(`${USAGE}\n`);
50
+ return 0;
51
+ }
52
+ if (cli.name === undefined) {
53
+ process.stderr.write(`dw-sdlc check: name the Check to run\n\n${USAGE}\n`);
54
+ return 2;
55
+ }
56
+ const { json, name } = cli;
57
+ const report = runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
58
+ return printReport(report, json, `check ${name}`);
59
+ }
@@ -1,2 +1,38 @@
1
+ import { type Failure } from "../report.ts";
1
2
  export declare const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
3
+ type Status = "pass" | "fail" | "error";
4
+ type Outcome = {
5
+ status: Status | "skipped";
6
+ exitCode: number | null;
7
+ };
8
+ type StepReport = Outcome & {
9
+ command: string;
10
+ };
11
+ /** A Check the package ships runs in this process: it has no command. */
12
+ type CheckReport = Outcome & {
13
+ name: string;
14
+ command: string | null;
15
+ };
16
+ type GateReport = {
17
+ status: Status;
18
+ command: string | null;
19
+ steps: StepReport[];
20
+ checks: CheckReport[];
21
+ failures: Failure[];
22
+ };
23
+ /** A run of the whole Gate, or of the Checks named `only` alone: no Step, no progress line, no refresh. */
24
+ type RunOptions = {
25
+ json: boolean;
26
+ } & ({
27
+ updateBaseline: boolean;
28
+ } | {
29
+ only: string;
30
+ });
31
+ export declare function runGate(root: string, options: RunOptions): GateReport;
32
+ /**
33
+ * Prints `report`: a line per failure and the verdict under `label`, or one document under `json`; under
34
+ * GitHub Actions each failure is also an annotation. Returns the exit code.
35
+ */
36
+ export declare function printReport(report: GateReport, json: boolean, label: string): number;
2
37
  export declare function run(argv: string[]): number;
38
+ export {};
@@ -1,9 +1,10 @@
1
- // `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config.
1
+ // `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
2
+ // run and the report serve `dw-sdlc check` too, which judges one Check of it.
2
3
  import { spawnSync } from "node:child_process";
3
4
  import { parseArgs } from "node:util";
4
5
  import { repositoryRoot, resolveConfig } from "../agentic-config.js";
5
6
  import { CannotRun } from "../checks/check.js";
6
- import { enabledChecks } from "../checks/index.js";
7
+ import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
7
8
  import { annotation, failureJson, failureLine } from "../report.js";
8
9
  export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
9
10
  const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>] [--update-baseline]
@@ -26,6 +27,8 @@ const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
26
27
  /** What the shell answers for a command it cannot find, and for one it cannot execute. */
27
28
  const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
28
29
  const SKIPPED = { status: "skipped", exitCode: null };
30
+ /** One Check run by name says nothing before its result. */
31
+ const QUIET = { write: () => true };
29
32
  const REMEDIES = {
30
33
  invalidLayer: "fix the file; the Gate runs once every layer is valid",
31
34
  failed: "fix what it printed above",
@@ -142,13 +145,36 @@ function worst(outcomes) {
142
145
  const statuses = new Set(outcomes.map(({ status }) => status));
143
146
  return statuses.has("error") ? "error" : statuses.has("fail") ? "fail" : "pass";
144
147
  }
145
- function runGate(root, json, updateBaseline) {
146
- const log = json ? process.stderr : process.stdout;
148
+ /**
149
+ * Every Step and Check of `gate`, or with `only` no Step and the Checks of that name: the run Checks
150
+ * and the shipped one, which runs on its defaults where the config leaves it off. No Check of that
151
+ * name is a failure.
152
+ */
153
+ function select(gate, only) {
154
+ if (only === undefined) {
155
+ return { steps: gate.steps, run: gate.checks.run, shipped: enabledChecks(gate.checks) };
156
+ }
157
+ const scripts = gate.checks.run.filter((check) => check.name === only);
158
+ const shipped = shippedCheck(only, gate.checks[only]);
159
+ if (scripts.length === 0 && shipped === undefined) {
160
+ const names = [...gate.checks.run.map((check) => check.name), ...CHECK_NAMES].join(", ");
161
+ return { rule: "check", message: `no Check is named ${JSON.stringify(only)}`, remedy: `name one of ${names}` };
162
+ }
163
+ return { steps: [], run: scripts, shipped: shipped === undefined ? [] : [shipped] };
164
+ }
165
+ export function runGate(root, options) {
166
+ const { json } = options;
167
+ const only = "only" in options ? options.only : undefined;
168
+ const log = only !== undefined ? QUIET : json ? process.stderr : process.stdout;
147
169
  const { config, problems } = resolveConfig(root);
148
170
  const gateCommand = config.gate.command ?? null;
149
171
  if (problems.length > 0) {
150
172
  return { status: "error", command: gateCommand, steps: [], checks: [], failures: problems.map(configFailure) };
151
173
  }
174
+ const selected = select(config.gate, only);
175
+ if ("rule" in selected) {
176
+ return { status: "error", command: gateCommand, steps: [], checks: [], failures: [selected] };
177
+ }
152
178
  const failures = [];
153
179
  /** Runs one Step or Check and records its failure under `rule`, if it has one. */
154
180
  const runOne = (command, rule, notRunnable) => {
@@ -161,7 +187,7 @@ function runGate(root, json, updateBaseline) {
161
187
  };
162
188
  const steps = [];
163
189
  let stopped = false;
164
- for (const step of config.gate.steps) {
190
+ for (const step of selected.steps) {
165
191
  if (stopped) {
166
192
  steps.push({ command: step, ...SKIPPED });
167
193
  continue;
@@ -172,7 +198,7 @@ function runGate(root, json, updateBaseline) {
172
198
  stopped = outcome.status !== "pass";
173
199
  }
174
200
  const checks = [];
175
- for (const check of config.gate.checks.run) {
201
+ for (const check of selected.run) {
176
202
  if (stopped) {
177
203
  checks.push({ ...check, ...SKIPPED });
178
204
  continue;
@@ -180,14 +206,14 @@ function runGate(root, json, updateBaseline) {
180
206
  log.write(`gate: check ${check.name}\n`);
181
207
  checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
182
208
  }
183
- const judged = enabledChecks(config.gate.checks).map((check) => {
209
+ const judged = selected.shipped.map((check) => {
184
210
  if (stopped) {
185
211
  return { check, outcome: SKIPPED, failures: [] };
186
212
  }
187
213
  log.write(`gate: check ${check.name}\n`);
188
214
  return runShipped(check, root);
189
215
  });
190
- if (updateBaseline) {
216
+ if ("updateBaseline" in options && options.updateBaseline) {
191
217
  refreshBaselines(judged, [...steps, ...checks], root, log);
192
218
  }
193
219
  for (const { check, outcome, failures: found } of judged) {
@@ -196,26 +222,21 @@ function runGate(root, json, updateBaseline) {
196
222
  }
197
223
  return { status: worst([...steps, ...checks]), command: gateCommand, steps, checks, failures };
198
224
  }
199
- function verdict(report) {
225
+ /** The last line, after `label`: green with what ran, or failed or cannot run with how many failures. */
226
+ function verdict(label, report) {
200
227
  if (report.status === "pass") {
201
- return `gate: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
228
+ return `${label}: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
202
229
  }
203
230
  const failures = plural(report.failures.length, "failure");
204
- return report.status === "fail" ? `gate: failed — ${failures}` : `gate: cannot run — ${failures}`;
231
+ return report.status === "fail" ? `${label}: failed — ${failures}` : `${label}: cannot run — ${failures}`;
205
232
  }
206
- export function run(argv) {
207
- const cli = parseCli(argv);
208
- if ("error" in cli) {
209
- process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
210
- return 2;
211
- }
212
- if (cli.help) {
213
- process.stdout.write(`${USAGE}\n`);
214
- return 0;
215
- }
216
- const report = runGate(repositoryRoot(cli.root ?? process.cwd()), cli.json, cli.updateBaseline);
233
+ /**
234
+ * Prints `report`: a line per failure and the verdict under `label`, or one document under `json`; under
235
+ * GitHub Actions each failure is also an annotation. Returns the exit code.
236
+ */
237
+ export function printReport(report, json, label) {
217
238
  const actions = process.env.GITHUB_ACTIONS === "true";
218
- if (cli.json) {
239
+ if (json) {
219
240
  const document = { ...report, failures: report.failures.map(failureJson) };
220
241
  process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
221
242
  // The runner reads workflow commands from stderr as from stdout, so stdout stays one document.
@@ -225,7 +246,20 @@ export function run(argv) {
225
246
  }
226
247
  else {
227
248
  const lines = report.failures.flatMap((failure) => actions ? [failureLine(failure), annotation(failure)] : [failureLine(failure)]);
228
- process.stdout.write(`${[...lines, verdict(report)].join("\n")}\n`);
249
+ process.stdout.write(`${[...lines, verdict(label, report)].join("\n")}\n`);
229
250
  }
230
251
  return EXIT_CODES[report.status];
231
252
  }
253
+ export function run(argv) {
254
+ const cli = parseCli(argv);
255
+ if ("error" in cli) {
256
+ process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
257
+ return 2;
258
+ }
259
+ if (cli.help) {
260
+ process.stdout.write(`${USAGE}\n`);
261
+ return 0;
262
+ }
263
+ const { json, updateBaseline } = cli;
264
+ return printReport(runGate(repositoryRoot(cli.root ?? process.cwd()), { json, updateBaseline }), json, "gate");
265
+ }
@@ -5,7 +5,8 @@ import { spawnSync } from "node:child_process";
5
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { parseArgs } from "node:util";
8
- import { CONFIG, isObject, readReferences } from "../references-key.js";
8
+ import { appendKey, parseObject, render } from "../json-text.js";
9
+ import { CONFIG, readReferences } from "../references-key.js";
9
10
  import { renderBlock, withBlock } from "../router-block.js";
10
11
  const ROUTER = "references/AGENTS.md";
11
12
  /** A symlink to the Router, so Claude Code loads it. */
@@ -45,20 +46,6 @@ function fail(message) {
45
46
  console.error(`references:setup: ${message}`);
46
47
  process.exit(2);
47
48
  }
48
- function render(value) {
49
- return `${JSON.stringify(value, null, 2)}\n`;
50
- }
51
- /** The JSON object `text` holds, or undefined when it holds anything else. */
52
- function parseObject(text) {
53
- let parsed;
54
- try {
55
- parsed = JSON.parse(text);
56
- }
57
- catch {
58
- return undefined;
59
- }
60
- return isObject(parsed) ? parsed : undefined;
61
- }
62
49
  class Writer {
63
50
  steps = [];
64
51
  root;
@@ -138,13 +125,7 @@ function withEmptyKey(current) {
138
125
  if (config === undefined) {
139
126
  return { refusal: "is not a JSON object; fix it by hand first" };
140
127
  }
141
- if (Object.hasOwn(config, "references")) {
142
- return { text: current };
143
- }
144
- const close = current.lastIndexOf("}");
145
- const body = current.slice(0, close).trimEnd();
146
- const comma = body.endsWith("{") ? "" : ",";
147
- return { text: `${body}${comma}\n "references": []\n${current.slice(close)}` };
128
+ return { text: Object.hasOwn(config, "references") ? current : appendKey(current, "references", []) };
148
129
  }
149
130
  /** The repository `dir` sits in, or `dir` itself outside one. */
150
131
  function toplevel(dir) {
@@ -1,5 +1,6 @@
1
1
  import { type CommitConvention, type CommitOnProtected, type ForcePush } from "../agentic-config.ts";
2
- export type Outcome = "wrote" | "unchanged" | "refused";
2
+ /** `by hand` is an edit the setup names and leaves to the user. */
3
+ export type Outcome = "wrote" | "unchanged" | "refused" | "by hand";
3
4
  export type Step = {
4
5
  file: string;
5
6
  outcome: Outcome;
@@ -18,8 +19,10 @@ export type SetupOptions = {
18
19
  forcePush?: ForcePush | undefined;
19
20
  /** Pins `guardrails.commitOnProtected` in the local layer; refused where it loosens the committed value. */
20
21
  commitOnProtected?: CommitOnProtected | undefined;
22
+ /** The owner's yes to pinning dw-sdlc as a devDependency where package.json lacks it. */
23
+ addPackage?: boolean;
21
24
  };
22
- export declare const summary = "write this machine's local config layer, the git excludes and the permission rules";
25
+ export declare const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
23
26
  /**
24
27
  * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
25
28
  * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
@@ -1,18 +1,20 @@
1
- // `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the four files its USAGE
2
- // names, each written only when its content would change, so a run is safe to repeat. Existing content is
3
- // merged, never replaced: other keys stay, and a settings file that is not JSON is refused.
1
+ // `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the files its USAGE names,
2
+ // each written only when its content would change, so a run is safe to repeat. Existing content is
3
+ // merged, never replaced: other keys stay, and a file that is not a JSON object is refused.
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join, relative } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { parseArgs } from "node:util";
10
- import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, schemaId, validateDocument } from "../agentic-config.js";
10
+ import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, resolveConfig, schemaId, validateDocument } from "../agentic-config.js";
11
+ import { isObject, parseObject, render } from "../json-text.js";
12
+ import { withGate, withPackage } from "../setup-gate.js";
11
13
  import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
12
- export const summary = "write this machine's local config layer, the git excludes and the permission rules";
14
+ export const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
13
15
  const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
14
16
  [--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
15
- [--force] [--dry-run]
17
+ [--add-package] [--force] [--dry-run]
16
18
  or: pnpm guardrails:setup with the same flags, in a checkout of dw-sdlc
17
19
 
18
20
  Writes .ai/agentic.config.local.json from inference, excludes it in .git/info/exclude, generates
@@ -23,7 +25,14 @@ written only when it would change; --convention ticket-key | conventional pins c
23
25
  the local config, and a run without it keeps the pin as it is; a guardrails block in the local config
24
26
  is kept, and --force-push and --commit-on-protected pin those two keys in it, refused where they would
25
27
  loosen what the committed config sets; --force overwrites a local config that differs in anything
26
- else; --dry-run reports without writing.`;
28
+ else; --dry-run reports without writing.
29
+
30
+ Where the repository has a package.json, the setup adopts the Gate. --add-package pins dw-sdlc
31
+ there as a devDependency, at the version running the setup; a new dependency needs the owner's yes,
32
+ so without the flag the file is refused. Once the package is there, .ai/agentic.config.json gains
33
+ the gate key when it has none, and the file is created when absent. Both files go to the repository
34
+ holding --root, a linked worktree included. The agent map is never written. Settings that still
35
+ enable guardrails@dw-sdlc or references@dw-sdlc get a "by hand" line naming the enabledPlugins edit.`;
27
36
  function isConvention(value) {
28
37
  return COMMIT_CONVENTIONS.some((preset) => preset === value);
29
38
  }
@@ -38,9 +47,8 @@ const MARKETPLACE = "dw-sdlc";
38
47
  const PLUGIN = "sdlc";
39
48
  /** The plugin's link to the dw-sdlc package, which carries the schema. */
40
49
  const PACKAGE_LINK = "dw-sdlc";
41
- function isObject(value) {
42
- return typeof value === "object" && value !== null && !Array.isArray(value);
43
- }
50
+ /** The two plugins the sdlc plugin replaced, which the marketplace does not list (ADR 0010). */
51
+ const RETIRED_PLUGINS = ["guardrails", "references"].map((name) => `${name}@${MARKETPLACE}`);
44
52
  /** A read-only git command, the one thing the setup runs in the repository. */
45
53
  function git(root, args) {
46
54
  const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
@@ -169,24 +177,8 @@ function union(existing, additions) {
169
177
  }
170
178
  return list;
171
179
  }
172
- function readJsonText(text) {
173
- try {
174
- const parsed = JSON.parse(text);
175
- return parsed;
176
- }
177
- catch {
178
- return undefined;
179
- }
180
- }
181
180
  function readJson(path) {
182
- if (!existsSync(path)) {
183
- return undefined;
184
- }
185
- const parsed = readJsonText(readFileSync(path, "utf8"));
186
- return isObject(parsed) ? parsed : "invalid";
187
- }
188
- function render(value) {
189
- return JSON.stringify(value, null, 2) + "\n";
181
+ return existsSync(path) ? (parseObject(readFileSync(path, "utf8")) ?? "invalid") : undefined;
190
182
  }
191
183
  class Writer {
192
184
  steps = [];
@@ -194,8 +186,8 @@ class Writer {
194
186
  constructor(dryRun) {
195
187
  this.dryRun = dryRun;
196
188
  }
197
- /** Writes when the text differs from what is on disk; records the outcome under `label`. */
198
- put(label, path, text) {
189
+ /** Writes when the text differs from what is on disk; records the outcome under `label`, with what the write does. */
190
+ put(label, path, text, detail) {
199
191
  const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
200
192
  if (current === text) {
201
193
  this.steps.push({ file: label, outcome: "unchanged" });
@@ -205,11 +197,36 @@ class Writer {
205
197
  mkdirSync(dirname(path), { recursive: true });
206
198
  writeFileSync(path, text);
207
199
  }
208
- this.steps.push({ file: label, outcome: "wrote" });
200
+ this.steps.push(detail === undefined ? { file: label, outcome: "wrote" } : { file: label, outcome: "wrote", detail });
209
201
  }
210
202
  refuse(label, detail) {
211
203
  this.steps.push({ file: label, outcome: "refused", detail });
212
204
  }
205
+ /** The step's file as `change` leaves it: written, unchanged or refused. */
206
+ apply(label, path, change) {
207
+ if ("refusal" in change) {
208
+ this.refuse(label, change.refusal);
209
+ }
210
+ else {
211
+ this.put(label, path, change.text, change.detail);
212
+ }
213
+ }
214
+ byHand(label, detail) {
215
+ this.steps.push({ file: label, outcome: "by hand", detail });
216
+ }
217
+ }
218
+ /** The enabledPlugins edit settings that still enable a retired plugin need; none where they enable neither. */
219
+ function enabledPluginsEdit(settings) {
220
+ const enabled = isObject(settings.enabledPlugins) ? settings.enabledPlugins : {};
221
+ const retired = RETIRED_PLUGINS.filter((id) => enabled[id] === true);
222
+ if (retired.length === 0) {
223
+ return undefined;
224
+ }
225
+ const id = `${PLUGIN}@${MARKETPLACE}`;
226
+ const remove = `enabledPlugins: remove ${retired.map((name) => JSON.stringify(name)).join(" and ")}, which the marketplace does not list`;
227
+ return enabled[id] === true
228
+ ? remove
229
+ : `${remove}, and add "${id}": true after claude plugin install ${id} --scope user`;
213
230
  }
214
231
  /**
215
232
  * Where the repository's files live: the main checkout for a linked worktree, since every worktree reads
@@ -231,7 +248,7 @@ export function setup(options) {
231
248
  // the guardrails block the file holds, with the keys the flags pin.
232
249
  const localPath = join(checkout, LOCAL_FILE);
233
250
  const localCurrent = existsSync(localPath) ? readFileSync(localPath, "utf8") : undefined;
234
- const existing = localCurrent === undefined ? undefined : readJsonText(localCurrent);
251
+ const existing = localCurrent === undefined ? undefined : parseObject(localCurrent);
235
252
  const convention = options.convention ?? pinnedConvention(existing);
236
253
  const pinned = pinnedGuardrails(keptGuardrails(existing), committedConfig(checkout), options);
237
254
  if ("refusal" in pinned) {
@@ -292,6 +309,23 @@ export function setup(options) {
292
309
  const permissions = isObject(settings.permissions) ? settings.permissions : {};
293
310
  settings.permissions = { ...permissions, deny: union(permissions.deny, GLOBAL_DENY) };
294
311
  writer.put(userLabel, userPath, render(settings));
312
+ const edit = enabledPluginsEdit(settings);
313
+ if (edit !== undefined) {
314
+ writer.byHand(userLabel, edit);
315
+ }
316
+ }
317
+ // 5. The Gate, where the repository has a package.json: the pinned package, then the gate key once the
318
+ // package is there. Both are committed, so they go to the repository holding --root, never the main
319
+ // checkout, and stay on the branch the setup runs on.
320
+ const manifestPath = join(root, "package.json");
321
+ if (existsSync(manifestPath)) {
322
+ const manager = resolveConfig(root).config.packageManagers.node ?? "pnpm";
323
+ const pin = withPackage(readFileSync(manifestPath, "utf8"), options.addPackage === true, manager);
324
+ writer.apply("package.json", manifestPath, pin);
325
+ if (!("refusal" in pin)) {
326
+ const configPath = join(root, COMMITTED_FILE);
327
+ writer.apply(COMMITTED_FILE, configPath, withGate(existsSync(configPath) ? readFileSync(configPath, "utf8") : undefined));
328
+ }
295
329
  }
296
330
  return writer.steps;
297
331
  }
@@ -311,6 +345,7 @@ export function run(argv) {
311
345
  convention: { type: "string" },
312
346
  "force-push": { type: "string" },
313
347
  "commit-on-protected": { type: "string" },
348
+ "add-package": { type: "boolean" },
314
349
  force: { type: "boolean" },
315
350
  "dry-run": { type: "boolean" }
316
351
  }
@@ -342,7 +377,8 @@ export function run(argv) {
342
377
  dryRun: values["dry-run"] === true,
343
378
  convention: values.convention,
344
379
  forcePush,
345
- commitOnProtected
380
+ commitOnProtected,
381
+ addPackage: values["add-package"] === true
346
382
  });
347
383
  for (const step of steps) {
348
384
  const verb = step.outcome === "wrote" && values["dry-run"] === true ? "would write" : step.outcome;
@@ -0,0 +1,7 @@
1
+ export type JsonObject = Record<string, unknown>;
2
+ export declare function isObject(value: unknown): value is JsonObject;
3
+ /** The JSON object `text` holds, or undefined when it holds anything else. */
4
+ export declare function parseObject(text: string): JsonObject | undefined;
5
+ export declare function render(value: unknown): string;
6
+ /** `text`, a JSON object, with `key` set to `value` after its last key, two spaces in. */
7
+ export declare function appendKey(text: string, key: string, value: unknown): string;
@@ -0,0 +1,26 @@
1
+ // The text of a JSON object file as the setup commands read and write it: parsed only when it holds an
2
+ // object, rendered two spaces in, and a key appended so a hand-formatted file keeps its layout.
3
+ export function isObject(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ /** The JSON object `text` holds, or undefined when it holds anything else. */
7
+ export function parseObject(text) {
8
+ try {
9
+ const parsed = JSON.parse(text);
10
+ return isObject(parsed) ? parsed : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ export function render(value) {
17
+ return `${JSON.stringify(value, null, 2)}\n`;
18
+ }
19
+ /** `text`, a JSON object, with `key` set to `value` after its last key, two spaces in. */
20
+ export function appendKey(text, key, value) {
21
+ const close = text.lastIndexOf("}");
22
+ const body = text.slice(0, close).trimEnd();
23
+ const comma = body.endsWith("{") ? "" : ",";
24
+ const rendered = JSON.stringify(value, null, 2).replaceAll("\n", "\n ");
25
+ return `${body}${comma}\n ${JSON.stringify(key)}: ${rendered}\n${text.slice(close)}`;
26
+ }
@@ -0,0 +1,15 @@
1
+ import type { NodePackageManager } from "./agentic-config.ts";
2
+ /** A file's text after the setup and what writing it does, or why the setup leaves the file alone. */
3
+ export type Change = {
4
+ text: string;
5
+ detail?: string;
6
+ } | {
7
+ refusal: string;
8
+ };
9
+ /**
10
+ * package.json with the package pinned exactly as a devDependency, in name order, where neither list
11
+ * names it. A new dependency needs the owner's yes, so without `add` the file is refused.
12
+ */
13
+ export declare function withPackage(current: string, add: boolean, manager: NodePackageManager): Change;
14
+ /** The committed config with the `gate` key when it has none, the file created when absent; a key it has stays. */
15
+ export declare function withGate(current: string | undefined): Change;
@@ -0,0 +1,82 @@
1
+ // What `dw-sdlc setup` changes to adopt the Gate, in files the repository commits: the `dw-sdlc`
2
+ // devDependency in package.json, pinned to the version that runs the setup, and the `gate` key of the
3
+ // committed config. Each answers with the file's next text, or why the file stays as it is.
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { CHECK_NAMES } from "./checks/index.js";
7
+ import { appendKey, isObject, parseObject, render } from "./json-text.js";
8
+ const PACKAGE = "dw-sdlc";
9
+ /**
10
+ * The Checks a new `gate` key turns on: each judges a tree as it is, the agent map aside, which the
11
+ * setup never writes and the first Gate names. The rest read an input a repository adds first, a
12
+ * catalogue, a records directory, docs a glob matches or a Ratchet's baseline, so they start `false`.
13
+ */
14
+ const ON_BY_DEFAULT = new Set(["agents-map", "markdown-refs", "temporal-language", "comment-budget"]);
15
+ /** Where a committed config the setup creates points `$schema`: the schema the pinned package installs. */
16
+ const SCHEMA = `../node_modules/${PACKAGE}/config/agentic.config.schema.json`;
17
+ const NOT_AN_OBJECT = "is not a JSON object; fix it by hand first";
18
+ /** This package's version, the one a pin names, so the plugin and the repository run the same code. */
19
+ function packageVersion() {
20
+ const manifest = parseObject(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
21
+ if (typeof manifest?.version !== "string") {
22
+ throw new Error(`${PACKAGE}'s package.json names no version`);
23
+ }
24
+ return manifest.version;
25
+ }
26
+ /** `a`, `a and b`, `a, b and c`. */
27
+ function list(items) {
28
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
29
+ }
30
+ /** Whether either dependency list names the package, at any spec: a pin is the doctor's to judge. */
31
+ function dependsOn(manifest) {
32
+ return ["dependencies", "devDependencies"].some((key) => {
33
+ const section = manifest[key];
34
+ return isObject(section) && Object.hasOwn(section, PACKAGE);
35
+ });
36
+ }
37
+ /**
38
+ * package.json with the package pinned exactly as a devDependency, in name order, where neither list
39
+ * names it. A new dependency needs the owner's yes, so without `add` the file is refused.
40
+ */
41
+ export function withPackage(current, add, manager) {
42
+ const manifest = parseObject(current);
43
+ if (manifest === undefined) {
44
+ return { refusal: NOT_AN_OBJECT };
45
+ }
46
+ if (dependsOn(manifest)) {
47
+ return { text: current };
48
+ }
49
+ const version = packageVersion();
50
+ if (!add) {
51
+ return {
52
+ refusal: `no ${PACKAGE} devDependency, and a new one needs your yes: --add-package pins ${PACKAGE} ${version}`
53
+ };
54
+ }
55
+ const { devDependencies = {} } = manifest;
56
+ if (!isObject(devDependencies)) {
57
+ return { refusal: "devDependencies is not an object; fix it by hand first" };
58
+ }
59
+ const entries = Object.entries(devDependencies);
60
+ const at = entries.findIndex(([name]) => name > PACKAGE);
61
+ entries.splice(at === -1 ? entries.length : at, 0, [PACKAGE, version]);
62
+ // A manifest is JSON.stringify's layout, as package managers write it, at the indent the file opens with.
63
+ const indent = /^\{\r?\n([ \t]+)"/.exec(current)?.[1] ?? " ";
64
+ const next = { ...manifest, devDependencies: Object.fromEntries(entries) };
65
+ return {
66
+ text: JSON.stringify(next, null, indent) + (current.endsWith("\n") ? "\n" : ""),
67
+ detail: `pins ${PACKAGE} ${version} as a devDependency; ${manager} install brings it in`
68
+ };
69
+ }
70
+ /** The committed config with the `gate` key when it has none, the file created when absent; a key it has stays. */
71
+ export function withGate(current) {
72
+ const gate = { checks: Object.fromEntries(CHECK_NAMES.map((name) => [name, ON_BY_DEFAULT.has(name)])) };
73
+ const detail = `the gate key, ${list(CHECK_NAMES.filter((name) => ON_BY_DEFAULT.has(name)))} on and the other Checks off`;
74
+ if (current === undefined) {
75
+ return { text: render({ $schema: SCHEMA, version: 1, gate }), detail };
76
+ }
77
+ const config = parseObject(current);
78
+ if (config === undefined) {
79
+ return { refusal: NOT_AN_OBJECT };
80
+ }
81
+ return Object.hasOwn(config, "gate") ? { text: current } : { text: appendKey(current, "gate", gate), detail };
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, and the Sync and setup of References; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {