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