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,347 @@
1
+ // `dw-sdlc references sync`, the Sync: brings every Reference the `references` key of
2
+ // .ai/agentic.config.json declares to its ref under references/<dir> — a missing one cloned, an
3
+ // existing one moved to a branch's head or to a tag.
4
+ // Each clone is shallow and blobless, detached at what was fetched, and sparse when its entry names
5
+ // only some paths or skips some. Only the committed config is read. A key the parser refuses syncs
6
+ // nothing; an entry that fails to resolve fails alone. One Sync runs in a checkout at a time: a
7
+ // second one waits on the lock under references/. When the Router or its table is missing or behind
8
+ // the key, one line names the setup to run. A repository that declares no References gets no output
9
+ // at all.
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { extname, join } from "node:path";
13
+ import { setTimeout as sleep } from "node:timers/promises";
14
+ import { parseArgs } from "node:util";
15
+ import { readPins, resolveRef } from "../pins.js";
16
+ import { CONFIG, readReferences } from "../references-key.js";
17
+ import { renderBlock, withBlock } from "../router-block.js";
18
+ const CLONES = "references";
19
+ const ROUTER = "references/AGENTS.md";
20
+ /** The setup, run through the package's bin, named in a line when the Router needs it: `cli.ts` in
21
+ * the source, `cli.js` in what tsc emits. */
22
+ const SETUP = `node "${join(import.meta.dirname, "..", `cli${extname(import.meta.filename)}`)}" references setup`;
23
+ const LOCK = ".sync.lock";
24
+ /** Seconds a Sync waits for another one in the same checkout: inside the session hook's 60 s. */
25
+ const WAIT_SECONDS = 30;
26
+ const POLL_MS = 100;
27
+ export const summary = "bring every declared Reference to its ref under references/";
28
+ const USAGE = `Usage: dw-sdlc references sync [dir...]
29
+ or: pnpm references:sync [dir...], in a checkout of dw-sdlc
30
+
31
+ Brings every Reference the references key of ${CONFIG} declares to its ref under ${CLONES}/<dir>, or
32
+ only the given dirs: a missing one cloned shallow, an existing one moved to its branch's head or its
33
+ tag, holding the top-level files and the paths its only names when it has one, without the paths
34
+ its skip names. Prints one line per Reference. Exit 1 when any Reference fails, 2 on a bad key,
35
+ config or argument, in which case nothing is synced. One Sync runs in a checkout at a time; a
36
+ second one waits for it up to ${WAIT_SECONDS} s, or REFERENCES_SYNC_WAIT seconds.`;
37
+ function messageOf(error) {
38
+ return error instanceof Error ? error.message : String(error);
39
+ }
40
+ /** The code a failed system call throws with, as "ENOENT", or undefined. */
41
+ function codeOf(error) {
42
+ return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined;
43
+ }
44
+ function fail(message) {
45
+ console.error(`references:sync: ${message}`);
46
+ process.exit(2);
47
+ }
48
+ /** REFERENCES_SYNC_WAIT when it names a number of seconds, else WAIT_SECONDS. */
49
+ function waitSeconds() {
50
+ const given = process.env.REFERENCES_SYNC_WAIT?.trim();
51
+ const seconds = given === undefined || given === "" ? Number.NaN : Number(given);
52
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : WAIT_SECONDS;
53
+ }
54
+ /** Whether `pid` is a running process of this user; a Sync's lock names one, so any other pid is left over. */
55
+ function alive(pid) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** The pid of the running Sync that holds the lock at `path`; undefined for no lock, or a left-over one. */
65
+ function holderOf(path) {
66
+ let content;
67
+ try {
68
+ content = readFileSync(path, "utf8");
69
+ }
70
+ catch (error) {
71
+ if (codeOf(error) === "ENOENT") {
72
+ return undefined;
73
+ }
74
+ throw error;
75
+ }
76
+ const pid = Number(content.trim());
77
+ return Number.isInteger(pid) && pid > 0 && alive(pid) ? pid : undefined;
78
+ }
79
+ /** Removes the left-over lock at `path`; one a running Sync took since the last look goes back in place. */
80
+ function clear(path) {
81
+ const aside = `${path}.${process.pid}.left`;
82
+ try {
83
+ renameSync(path, aside);
84
+ }
85
+ catch (error) {
86
+ if (codeOf(error) === "ENOENT") {
87
+ return;
88
+ }
89
+ throw error;
90
+ }
91
+ if (holderOf(aside) === undefined) {
92
+ rmSync(aside, { force: true });
93
+ }
94
+ else {
95
+ renameSync(aside, path);
96
+ }
97
+ }
98
+ /** Removes the lock at `path` when this process holds it, and only then. */
99
+ function release(path) {
100
+ if (holderOf(path) === process.pid) {
101
+ rmSync(path, { force: true });
102
+ }
103
+ }
104
+ /**
105
+ * Takes the lock under references/ for this process, waiting up to `seconds` on a running Sync that
106
+ * holds it, and clearing one left over. Past the wait, the pid still holding it.
107
+ */
108
+ async function lock(root, seconds) {
109
+ const dir = join(root, CLONES);
110
+ mkdirSync(dir, { recursive: true });
111
+ const path = join(dir, LOCK);
112
+ // Written whole under this process's own name, then linked into place: no one reads it half-written.
113
+ const own = `${path}.${process.pid}`;
114
+ writeFileSync(own, `${process.pid}\n`);
115
+ const deadline = Date.now() + seconds * 1000;
116
+ try {
117
+ for (;;) {
118
+ try {
119
+ linkSync(own, path);
120
+ return { release: () => release(path) };
121
+ }
122
+ catch (error) {
123
+ if (codeOf(error) !== "EEXIST") {
124
+ throw error;
125
+ }
126
+ }
127
+ const holder = holderOf(path);
128
+ if (holder === undefined) {
129
+ clear(path);
130
+ continue;
131
+ }
132
+ if (Date.now() >= deadline) {
133
+ return { heldBy: holder };
134
+ }
135
+ await sleep(POLL_MS);
136
+ }
137
+ }
138
+ finally {
139
+ rmSync(own, { force: true });
140
+ }
141
+ }
142
+ /** The variables that tie git to one repository, as git itself lists them. */
143
+ const REPOSITORY_VARS = new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
144
+ function git(args, cwd) {
145
+ // Each call finds its repository from `cwd`: the GIT_DIR a git hook exports would aim it at the
146
+ // hook's repository. A missing credential fails the fetch instead of waiting on a prompt nobody sees.
147
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !REPOSITORY_VARS.has(name)));
148
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...env, GIT_TERMINAL_PROMPT: "0" } });
149
+ // git's error on one line, so each Reference stays one line of the report.
150
+ const err = (result.stderr ?? "")
151
+ .split("\n")
152
+ .map((line) => line.trim())
153
+ .filter(Boolean)
154
+ .join(" ");
155
+ return { ok: result.status === 0, out: (result.stdout ?? "").trim(), err };
156
+ }
157
+ /** Runs one git step of a sync, `gitOptions` ahead of it; throws with git's error when it fails. */
158
+ function step(args, cwd, gitOptions = []) {
159
+ const result = git([...gitOptions, ...args], cwd);
160
+ if (!result.ok) {
161
+ throw new Error(`git ${args.join(" ")}: ${result.err}`);
162
+ }
163
+ }
164
+ /** A GitHub repository fetches with the GitHub CLI's credentials, so a private one clones too. */
165
+ function credentials(repository) {
166
+ return repository.startsWith("https://github.com/")
167
+ ? ["-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential"]
168
+ : [];
169
+ }
170
+ /**
171
+ * The sparse-checkout patterns of a Reference, none for the whole tree. Without `only`, every path;
172
+ * with it, the top-level files and its paths alone. The paths `skip` names are left out of both.
173
+ */
174
+ function sparsePatterns({ only, skip }) {
175
+ if (only.length === 0 && skip.length === 0) {
176
+ return [];
177
+ }
178
+ const kept = only.length === 0 ? ["/*"] : ["/*", "!/*/", ...only.map((path) => `/${path}`)];
179
+ return [...kept, ...skip.map((path) => `!/${path}`)];
180
+ }
181
+ /** Whether the tree fetched into `cwd` holds `path`, a directory when it ends in `/`. Reads trees only. */
182
+ function fetchedTreeHas(path, cwd) {
183
+ const listed = git(["ls-tree", "FETCH_HEAD", "--", path.replace(/\/+$/, "")], cwd);
184
+ if (!listed.ok) {
185
+ throw new Error(`git ls-tree FETCH_HEAD: ${listed.err}`);
186
+ }
187
+ // `<mode> <type> <object>\t<path>`, or nothing when the tree lacks the path.
188
+ const type = listed.out.split(" ")[1];
189
+ return path.endsWith("/") ? type === "tree" : type !== undefined;
190
+ }
191
+ /** Clones or updates one Reference at `ref`, already resolved; the reason it failed, or undefined. */
192
+ function sync(root, reference, ref) {
193
+ const { dir, repository } = reference;
194
+ const patterns = sparsePatterns(reference);
195
+ const target = join(root, CLONES, dir);
196
+ // Each step that can fetch carries the credentials: a partial clone's sparse-checkout and checkout
197
+ // fetch the blobs they need.
198
+ const fetching = (args) => {
199
+ step(args, target, credentials(repository));
200
+ };
201
+ try {
202
+ if (existsSync(target)) {
203
+ const origin = git(["remote", "get-url", "origin"], target);
204
+ if (!existsSync(join(target, ".git")) || !origin.ok || origin.out !== repository) {
205
+ return `${CLONES}/${dir} exists and is not a clone of ${repository}; move it aside and run again`;
206
+ }
207
+ }
208
+ else {
209
+ // An empty repository, so the one fetch below brings only the ref, a branch or a tag alike.
210
+ step(["init", "--quiet", target]);
211
+ step(["remote", "add", "origin", repository], target);
212
+ }
213
+ fetching(["fetch", "--quiet", "--depth", "1", "--filter=blob:none", "origin", ref]);
214
+ // Before the sparse checkout moves anything, so a renamed folder leaves the clone as it was.
215
+ const lacking = reference.only.filter((path) => !fetchedTreeHas(path, target));
216
+ if (lacking.length > 0) {
217
+ return `only names ${lacking.join(", ")}, which the tree at ${ref} lacks`;
218
+ }
219
+ if (patterns.length > 0) {
220
+ fetching(["sparse-checkout", "set", "--no-cone", ...patterns]);
221
+ }
222
+ else {
223
+ fetching(["sparse-checkout", "disable"]);
224
+ }
225
+ // Detached at what was fetched: a local edit the update would overwrite fails the entry instead.
226
+ fetching(["checkout", "--quiet", "--detach", "FETCH_HEAD"]);
227
+ }
228
+ catch (error) {
229
+ return messageOf(error);
230
+ }
231
+ return undefined;
232
+ }
233
+ function parseCli(argv) {
234
+ try {
235
+ const { values, positionals } = parseArgs({
236
+ args: argv,
237
+ allowPositionals: true,
238
+ options: { help: { type: "boolean", short: "h" } }
239
+ });
240
+ return { help: values.help === true, dirs: positionals };
241
+ }
242
+ catch (error) {
243
+ fail(`${messageOf(error)}\n\n${USAGE}`);
244
+ }
245
+ }
246
+ /**
247
+ * One line when the Router or its table is missing or behind `references`, naming the setup to run,
248
+ * or when its markers are not a pair, which the setup would refuse.
249
+ */
250
+ function routerLine(root, references) {
251
+ const path = join(root, ROUTER);
252
+ if (!existsSync(path)) {
253
+ return `${ROUTER} missing run ${SETUP} from the project to write the Router`;
254
+ }
255
+ const current = readFileSync(path, "utf8");
256
+ const next = withBlock(current, renderBlock(references));
257
+ if ("problem" in next) {
258
+ return `${ROUTER} broken ${next.problem}`;
259
+ }
260
+ if (next.text !== current) {
261
+ return `${ROUTER} behind the key run ${SETUP} from the project to rewrite its table`;
262
+ }
263
+ return undefined;
264
+ }
265
+ /** `compute`'s value on the first call, from then on the same value. */
266
+ function once(compute) {
267
+ let cached;
268
+ return () => {
269
+ cached ??= { value: compute() };
270
+ return cached.value;
271
+ };
272
+ }
273
+ /** Syncs each of `selected` at its resolved ref and prints its line; whether any failed. */
274
+ function syncAll(root, selected) {
275
+ const pins = once(() => readPins(root));
276
+ let failed = false;
277
+ for (const reference of selected) {
278
+ let ref = reference.ref;
279
+ let problem;
280
+ try {
281
+ ref = resolveRef(reference.ref, pins);
282
+ problem = sync(root, reference, ref);
283
+ }
284
+ catch (error) {
285
+ problem = messageOf(error);
286
+ }
287
+ if (problem === undefined) {
288
+ const head = git(["rev-parse", "--short", "HEAD"], join(root, CLONES, reference.dir)).out;
289
+ console.log(`${CLONES}/${reference.dir} ${head} ${reference.repository} ${ref}`);
290
+ }
291
+ else {
292
+ failed = true;
293
+ console.error(`${CLONES}/${reference.dir} FAILED ${problem}`);
294
+ }
295
+ }
296
+ return failed;
297
+ }
298
+ export async function run(argv) {
299
+ const cli = parseCli(argv);
300
+ if (cli.help) {
301
+ console.log(USAGE);
302
+ return 0;
303
+ }
304
+ const toplevel = git(["rev-parse", "--show-toplevel"]);
305
+ const root = toplevel.ok ? toplevel.out : process.cwd();
306
+ const reading = readReferences(root);
307
+ if ("problem" in reading) {
308
+ fail(`${reading.problem}; nothing synced`);
309
+ }
310
+ const references = reading.references ?? [];
311
+ const unknown = cli.dirs.filter((dir) => !references.some((reference) => reference.dir === dir));
312
+ if (unknown.length > 0) {
313
+ fail(`not in the references of ${CONFIG}: ${unknown.join(", ")}`);
314
+ }
315
+ if (reading.references === undefined) {
316
+ return 0;
317
+ }
318
+ const selected = cli.dirs.length === 0 ? references : references.filter((reference) => cli.dirs.includes(reference.dir));
319
+ const seconds = waitSeconds();
320
+ const attempt = await lock(root, seconds).catch((error) => fail(`${CLONES}/${LOCK}: ${messageOf(error)}; nothing synced`));
321
+ let failed = false;
322
+ if ("heldBy" in attempt) {
323
+ console.error(`references:sync: another Sync (pid ${attempt.heldBy}) still holds ${CLONES}/${LOCK} after ${seconds} s; the clones are what it leaves`);
324
+ }
325
+ else {
326
+ try {
327
+ failed = syncAll(root, selected);
328
+ }
329
+ finally {
330
+ attempt.release();
331
+ }
332
+ }
333
+ const router = routerLine(root, references);
334
+ if (router !== undefined) {
335
+ console.log(router);
336
+ }
337
+ const listed = new Set(references.map((reference) => reference.dir));
338
+ const stray = existsSync(join(root, CLONES))
339
+ ? readdirSync(join(root, CLONES), { withFileTypes: true })
340
+ .filter((entry) => entry.isDirectory() && !listed.has(entry.name))
341
+ .map((entry) => `${CLONES}/${entry.name}`)
342
+ : [];
343
+ if (stray.length > 0) {
344
+ console.log(`Not listed in ${CONFIG}, left alone: ${stray.join(", ")}`);
345
+ }
346
+ return failed ? 1 : 0;
347
+ }
@@ -0,0 +1,31 @@
1
+ import { type CommitConvention, type CommitOnProtected, type ForcePush } from "../agentic-config.ts";
2
+ export type Outcome = "wrote" | "unchanged" | "refused";
3
+ export type Step = {
4
+ file: string;
5
+ outcome: Outcome;
6
+ detail?: string;
7
+ };
8
+ export type SetupOptions = {
9
+ root: string;
10
+ home: string;
11
+ /** Claude Code's config dir, `$CLAUDE_CONFIG_DIR`; `<home>/.claude` when absent. */
12
+ claudeDir?: string | undefined;
13
+ force: boolean;
14
+ dryRun: boolean;
15
+ /** Pins `commit.convention` in the local layer; left out, a pin the layer already holds stays. */
16
+ convention?: CommitConvention | undefined;
17
+ /** Pins `guardrails.forcePush` in the local layer; refused where it loosens the committed value. */
18
+ forcePush?: ForcePush | undefined;
19
+ /** Pins `guardrails.commitOnProtected` in the local layer; refused where it loosens the committed value. */
20
+ commitOnProtected?: CommitOnProtected | undefined;
21
+ };
22
+ export declare const summary = "write this machine's local config layer, the git excludes and the permission rules";
23
+ /**
24
+ * What the machine's layer points `$schema` at: the schema in the marketplace's clone as a `file://`
25
+ * URL, since the `$id` sits in a private repository an editor cannot fetch. The clone keeps its path
26
+ * across plugin updates, where the install cache moves with every version. No registered clone
27
+ * holding the schema falls back to the `$id`.
28
+ */
29
+ export declare function schemaReference(claudeDir: string): string;
30
+ export declare function setup(options: SetupOptions): Step[];
31
+ export declare function run(argv: string[]): number;