dw-sdlc 0.7.0 → 0.9.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 (45) hide show
  1. package/README.md +20 -12
  2. package/config/agentic.config.schema.json +56 -18
  3. package/dist/agentic-config.d.ts +23 -7
  4. package/dist/agentic-config.js +26 -12
  5. package/dist/checks/agents-map.d.ts +9 -1
  6. package/dist/checks/agents-map.js +60 -17
  7. package/dist/checks/decision-records.d.ts +5 -0
  8. package/dist/checks/decision-records.js +121 -16
  9. package/dist/checks/file-size-ratchet.js +7 -5
  10. package/dist/checks/index.js +2 -0
  11. package/dist/checks/markdown-budget.d.ts +2 -5
  12. package/dist/checks/markdown-budget.js +6 -15
  13. package/dist/checks/markdown-refs.js +1 -11
  14. package/dist/checks/routed-docs.d.ts +6 -0
  15. package/dist/checks/routed-docs.js +49 -0
  16. package/dist/checks/tree.d.ts +2 -0
  17. package/dist/checks/tree.js +13 -3
  18. package/dist/commands/check.d.ts +1 -1
  19. package/dist/commands/check.js +6 -4
  20. package/dist/commands/gate.d.ts +10 -4
  21. package/dist/commands/gate.js +202 -86
  22. package/dist/commands/pr-reviewed.js +5 -21
  23. package/dist/commands/pr-ship.js +37 -17
  24. package/dist/commands/references-sync.js +5 -6
  25. package/dist/drift.d.ts +17 -0
  26. package/dist/drift.js +68 -0
  27. package/dist/gate-cache.d.ts +13 -0
  28. package/dist/gate-cache.js +39 -0
  29. package/dist/git-env.d.ts +2 -0
  30. package/dist/git-env.js +10 -0
  31. package/dist/markdown.d.ts +19 -0
  32. package/dist/markdown.js +46 -2
  33. package/dist/package-version.d.ts +2 -0
  34. package/dist/package-version.js +13 -0
  35. package/dist/pr/decide.d.ts +31 -8
  36. package/dist/pr/decide.js +65 -31
  37. package/dist/router-block.d.ts +4 -1
  38. package/dist/router-block.js +31 -7
  39. package/dist/run-command.d.ts +24 -0
  40. package/dist/run-command.js +121 -0
  41. package/dist/setup-gate.js +3 -12
  42. package/dist/working-tree.d.ts +13 -0
  43. package/dist/working-tree.js +60 -0
  44. package/package.json +1 -1
  45. package/templates/router.md +1 -0
package/README.md CHANGED
@@ -16,9 +16,14 @@ pnpm add -D dw-sdlc
16
16
  ## The Gate
17
17
 
18
18
  `dw-sdlc gate` runs the Steps under `gate.steps` in order from the repository root, then every Check
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. `dw-sdlc check <name>` runs one Check
21
- alone, with the same lines, exit codes and document.
19
+ under `gate.checks`. It prints one line per Step and per Check, and the last 50 lines of a command
20
+ that fails; `--verbose` streams every command's output instead. A Step that creates, changes or
21
+ deletes a file git tracks or would track fails the Gate as Drift, unless `gate.drift` is `"allow"`.
22
+ It exits 0 when everything passes, 1 when a Step or a Check fails or a Step drifts, and 2 when the
23
+ Gate cannot run. `--json` prints the result as one document. `--cache` skips the run when the
24
+ working tree and the resolved Gate match the last green run in that checkout, so a pre-push hook
25
+ costs nothing on an unchanged tree. `dw-sdlc check <name>` runs one Check alone, with the same
26
+ lines, exit codes and document.
22
27
 
23
28
  ```json
24
29
  {
@@ -43,23 +48,26 @@ its `roots` to `maxProseLines` lines of prose, 3 by default.
43
48
  `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
44
49
  answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
45
50
  whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
46
- performs its own Actions: it updates a branch behind its base, marks a draft ready once nothing else
47
- holds it back, and squash merges a pull request that is green, reviewed and opened by the `gh`
48
- account, when the merge is its own. `--dry-run` performs nothing: it answers the first Action the
51
+ performs its own Actions: it updates a branch behind its base, marks the head reviewed when the
52
+ Review depth is `off`, marks a draft ready once nothing else holds it back, and squash merges a pull
53
+ request that is green, reviewed and opened by the `gh` account, when the merge is its own. `--dry-run` performs nothing: it answers the first Action the
49
54
  script would perform as the dev's. It never deletes a branch; a merged pull request answers with
50
55
  the delete command for the dev.
51
56
 
52
57
  `pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
53
- dev under `ask`, the default. The local layer may tighten it and never loosen it, and `--merge` acts
54
- as `auto` for one call. A pull request the `gh` account did not open is always the dev's to merge.
58
+ dev under `ask`, the default. `pr.review` says how closely a head no one has reviewed is read: the
59
+ agent reviews it under `full`, the default, and no one does under `off`. The local layer may tighten
60
+ either and never loosen it; `--merge` acts as `auto` and `--review full|off` sets the depth for one
61
+ call. A pull request the `gh` account did not open is always the dev's to merge.
55
62
 
56
63
  ```json
57
- { "pr": { "merge": "auto" } }
64
+ { "pr": { "merge": "auto", "review": "full" } }
58
65
  ```
59
66
 
60
- `pr ship` answers `review` until the head carries the commit status `dw-sdlc/review`, which
61
- `dw-sdlc pr reviewed <n> --head <sha>` posts after a clean review. It posts nothing and exits 2 once
62
- a push moved the head, so a new head needs a fresh review.
67
+ `pr ship` answers `review`, its depth under `details`, until the head carries the commit status
68
+ `dw-sdlc/review`, which `dw-sdlc pr reviewed <n> --head <sha>` posts after a clean review. It posts
69
+ nothing and exits 2 once a push moved the head, so a new head needs a fresh review. Under `off` the
70
+ script posts that status itself, its description saying why, and goes on.
63
71
 
64
72
  ## Commands
65
73
 
@@ -114,13 +114,18 @@
114
114
  "minLength": 1
115
115
  },
116
116
  "steps": {
117
- "description": "The Steps: toolchain commands run in order from the repository root through `sh -c`, each one's output passed through; the first that fails ends the Gate and no Check runs. A command the shell cannot find (exit 127) or execute (126) means the Gate cannot run.",
117
+ "description": "The Steps: toolchain commands run in order from the repository root through `sh -c`; the first that does not pass ends the Gate and no Check runs. Each one's output is captured, and its last 50 lines are printed when it does not pass; `--verbose` passes the output through instead. A command the shell cannot find (exit 127) or execute (126) means the Gate cannot run.",
118
118
  "type": "array",
119
119
  "items": {
120
120
  "type": "string",
121
121
  "minLength": 1
122
122
  }
123
123
  },
124
+ "drift": {
125
+ "description": "Whether a Step may write the tree. `fail`, the default, watches every Step for Drift: before and after each Step the Gate records the content of every file git tracks or would track, and each file the Step created, changed or deleted is one failure, `\"<step>\" created it`, `changed it` or `deleted it`, without stopping the Steps after it. Ignored files and edits made before the run never drift. A tree the Gate cannot record, outside a git checkout, means the Gate cannot run. `allow` turns the watch off.",
126
+ "enum": ["fail", "allow"],
127
+ "default": "fail"
128
+ },
124
129
  "checks": {
125
130
  "description": "The Checks the Gate runs after its steps, every one even when another fails: the `run` scripts first, then the Checks the package ships, in the order listed here. A Check the package ships is `true` for its defaults, an options object over them, or `false` to stay off. A key names a Check the package ships, and a name it does not ship is refused.",
126
131
  "type": "object",
@@ -167,7 +172,7 @@
167
172
  "additionalProperties": false
168
173
  },
169
174
  "agents-map": {
170
- "description": "The agent map: within its line and byte budget, naming the resolved `gate.command`, carrying a Task Router row to the Primitives catalogue where there is one, every path the Task Router points at existing, and the skills directory, `skills-lock.json` and the `.claude/skills` links in step where the repository has them.",
175
+ "description": "The agent map: within its byte budget, naming the resolved `gate.command`, carrying a Task Router row to the Primitives catalogue where there is one, every path the Task Router points at existing, `CLAUDE.md` a symbolic link to the map unless the map is `CLAUDE.md`, and the skills directory, `skills-lock.json` and the `.claude/skills` links in step where the repository has them.",
171
176
  "type": ["boolean", "object"],
172
177
  "properties": {
173
178
  "file": {
@@ -175,25 +180,45 @@
175
180
  "type": "string",
176
181
  "minLength": 1
177
182
  },
178
- "maxLines": {
179
- "description": "Default 150.",
180
- "type": "integer",
181
- "minimum": 1
182
- },
183
183
  "maxBytes": {
184
- "description": "Default 10240.",
184
+ "description": "The map's budget in bytes. Default 10240.",
185
185
  "type": "integer",
186
186
  "minimum": 1
187
187
  }
188
188
  },
189
189
  "additionalProperties": false
190
190
  },
191
+ "routed-docs": {
192
+ "description": "Every document a task leads to: each file `files` matches and `exempt` does not is reached by a Task Router row of the agent map, through a code span in a cell after the first naming it, a directory above it or a glob that matches it, or by the `docs` of a Primitive in the catalogue naming it, its heading anchor dropped; a path reads from the root, `./` or `/` before it or not. The map is the file `agents-map` reads and the catalogue the one `primitives` reads, each Check on or off; a catalogue that is missing or invalid reaches nothing. A map with no Task Router table fails once on the map, and a missing map leaves the Gate unable to run.",
193
+ "type": ["boolean", "object"],
194
+ "properties": {
195
+ "files": {
196
+ "description": "Globs of the documents, against the files git tracks or would track; `*` and `**` skip a name that starts with a dot. Default `docs/**/*.md`.",
197
+ "type": "array",
198
+ "items": {
199
+ "type": "string",
200
+ "minLength": 1
201
+ },
202
+ "uniqueItems": true
203
+ },
204
+ "exempt": {
205
+ "description": "Globs of the documents left out. Default `docs/research/**`: a research note is read through the spec it backs. A list replaces the default.",
206
+ "type": "array",
207
+ "items": {
208
+ "type": "string",
209
+ "minLength": 1
210
+ },
211
+ "uniqueItems": true
212
+ }
213
+ },
214
+ "additionalProperties": false
215
+ },
191
216
  "markdown-budget": {
192
- "description": "Each file a glob of `budgets` matches within that glob's `maxLines` and `maxBytes`, and every glob matching a file. A glob matches the files git tracks or would track, a symbolic link left out when the file it points at is matched too; `*` and `**` skip a name that starts with a dot.",
217
+ "description": "Each file a glob of `budgets` matches within that glob's `maxBytes`, and every glob matching a file. A glob matches the files git tracks or would track, a symbolic link left out when the file it points at is matched too; `*` and `**` skip a name that starts with a dot.",
193
218
  "type": ["boolean", "object"],
194
219
  "properties": {
195
220
  "budgets": {
196
- "description": "Default `docs/agents/*.md` within 100 lines and 6144 bytes; a list replaces the default.",
221
+ "description": "Default `docs/agents/*.md` within 6144 bytes; a list replaces the default, such as one that adds `GLOSSARY.md` at a budget of its own.",
197
222
  "type": "array",
198
223
  "items": {
199
224
  "type": "object",
@@ -202,16 +227,12 @@
202
227
  "type": "string",
203
228
  "minLength": 1
204
229
  },
205
- "maxLines": {
206
- "type": "integer",
207
- "minimum": 1
208
- },
209
230
  "maxBytes": {
210
231
  "type": "integer",
211
232
  "minimum": 1
212
233
  }
213
234
  },
214
- "required": ["glob", "maxLines", "maxBytes"],
235
+ "required": ["glob", "maxBytes"],
215
236
  "additionalProperties": false
216
237
  }
217
238
  }
@@ -289,16 +310,24 @@
289
310
  "minLength": 1
290
311
  },
291
312
  "uniqueItems": true
313
+ },
314
+ "fields": {
315
+ "description": "The frontmatter fields each record is held to beyond `status`; none by default. `date`: every record carries the day it was decided, `YYYY-MM-DD`. `touches`: a record in force, its status `accepted` or `active`, names the paths it governs. Each is a file or directory git tracks or would track, or a glob that matches such a file. A `superseded` record names none. `superseded-by`: a `superseded` record names the number of each record that replaces it. `amended-by`: each number it names is the number of a record. A number names a record in the same directory. A list is comma-separated after its key, or one `- ` item a line under it.",
316
+ "type": "array",
317
+ "items": {
318
+ "enum": ["date", "touches", "superseded-by", "amended-by"]
319
+ },
320
+ "uniqueItems": true
292
321
  }
293
322
  },
294
323
  "additionalProperties": false
295
324
  },
296
325
  "file-size-ratchet": {
297
- "description": "The file-size Ratchet: each file git tracks or would track within the line limit of its group, or, where `.ai/file-size-ratchet.baseline.json` lists it, within the count recorded there. A file belongs to the first group with a glob that matches it; a file no group matches has no limit, and a symbolic link is left out when the file it points at is matched too. The baseline is committed and lists the files over their limit; one that lists a file twice, at a count within its group's limit or in no group fails the Check. `dw-sdlc gate --update-baseline` records it where there is none and otherwise only tightens it, and only when every Step and every other Check passes: a file within its limit leaves it, a count that shrank is lowered, and no file joins.",
326
+ "description": "The file-size Ratchet, which holds code: each file git tracks or would track within the line limit of its group, or, where `.ai/file-size-ratchet.baseline.json` lists it, within the count recorded there. A file belongs to the first group with a glob that matches it; a file no group matches has no limit, and a symbolic link is left out when the file it points at is matched too. The baseline is committed and lists the files over their limit; one that lists a file twice, at a count within its group's limit or in no group fails the Check. `dw-sdlc gate --update-baseline` records it where there is none and otherwise only tightens it, and only when every Step and every other Check passes: a file within its limit leaves it, a count that shrank is lowered, and no file joins.",
298
327
  "type": ["boolean", "object"],
299
328
  "properties": {
300
329
  "groups": {
301
- "description": "Default, in this order: `tests`, 2000 lines, a file under a `test`, `tests` or `__tests__` directory or named `*.test.*` or `*.spec.*`; `agent-docs`, 120 lines, `AGENTS.md`, `CLAUDE.md` and `GLOSSARY.md` anywhere and the markdown under `docs/agents`; `source`, 500 lines, JavaScript, TypeScript and shell.",
330
+ "description": "Default, in this order: `tests`, 2000 lines, a file under a `test`, `tests` or `__tests__` directory or named `*.test.*` or `*.spec.*`; `source`, 500 lines, JavaScript, TypeScript and shell. No default group holds markdown: `agents-map` and `markdown-budget` hold it to byte budgets.",
302
331
  "type": "array",
303
332
  "items": {
304
333
  "type": "object",
@@ -358,12 +387,21 @@
358
387
  "additionalProperties": false
359
388
  },
360
389
  "pr": {
361
- "description": "How the Ship script, `dw-sdlc pr ship`, ends a pull request. The local override may tighten it and never loosen it.",
390
+ "description": "How the Ship script, `dw-sdlc pr ship`, ends a pull request. The local override may tighten `merge` and `review` and never loosen them.",
362
391
  "type": "object",
363
392
  "properties": {
364
393
  "merge": {
365
394
  "description": "Who merges a pull request that is green, reviewed and authored by the account `gh` runs as. `auto`: the script squash merges it, naming the head it read. `ask`: it parks with the merge command for the dev. Default `ask`, which an invalid layer runs too. Strictest first: ask, auto.",
366
395
  "enum": ["ask", "auto"]
396
+ },
397
+ "review": {
398
+ "description": "The Review depth of a head the Ship script finds unreviewed, which the dev's `--review` overrides for one call. `full`: the agent reviews the changes. `off`: the script posts the `dw-sdlc/review` success itself and no one reads them. Default `full`, which an invalid layer runs too. Strictest first: full, off.",
399
+ "enum": ["full", "off"]
400
+ },
401
+ "reviewCommand": {
402
+ "description": "The slash command a `full` Review runs over the pull request's changes, as `/pr-review-toolkit:review-pr code errors`. Default `/code-review`. No strictness order: the local override sets it freely.",
403
+ "type": "string",
404
+ "minLength": 1
367
405
  }
368
406
  },
369
407
  "additionalProperties": false
@@ -17,6 +17,17 @@ export type EnvFiles = {
17
17
  };
18
18
  /** Who merges a pull request the Ship script finds ready: the script under `auto`, the dev under `ask`. */
19
19
  export type PrMerge = "ask" | "auto";
20
+ /** How closely a Review reads an unreviewed head: the agent reads it under `full`, no one under `off`. */
21
+ export type PrReview = "full" | "off";
22
+ /**
23
+ * How the Ship script ends a pull request: who merges it, how closely it is reviewed, and the slash
24
+ * command a `full` Review runs.
25
+ */
26
+ export type PrConfig = {
27
+ merge: PrMerge;
28
+ review: PrReview;
29
+ reviewCommand: string;
30
+ };
20
31
  export type Guardrails = {
21
32
  forcePush: ForcePush;
22
33
  commitOnProtected: CommitOnProtected;
@@ -40,16 +51,20 @@ export type GateChecks = {
40
51
  run: RunCheck[];
41
52
  [check: string]: unknown;
42
53
  };
43
- /** The Gate: its command, the steps run in order, the Checks run after them. */
54
+ /** `gate.drift`: whether a Step may write the tree; `src/drift.ts` reads it. */
55
+ export type DriftSetting = "fail" | "allow";
56
+ /** The Gate: its command, the steps run in order, whether they may write the tree, the Checks run after them. */
44
57
  export type Gate = {
45
58
  command?: string;
46
59
  steps: string[];
60
+ drift: DriftSetting;
47
61
  checks: GateChecks;
48
62
  };
49
63
  /** `gate` as one layer writes it: every key optional, `checks` Check by Check. */
50
64
  export type GateLayer = {
51
65
  command?: string;
52
66
  steps?: string[];
67
+ drift?: DriftSetting;
53
68
  checks?: Partial<GateChecks>;
54
69
  };
55
70
  /** One layer as written in a file: every key optional, unknown top-level keys tolerated. */
@@ -68,9 +83,7 @@ export type AgenticConfig = {
68
83
  trailer?: string;
69
84
  };
70
85
  gate?: GateLayer;
71
- pr?: {
72
- merge?: PrMerge;
73
- };
86
+ pr?: Partial<PrConfig>;
74
87
  worktree?: {
75
88
  setup?: string[];
76
89
  };
@@ -90,9 +103,7 @@ export type ResolvedConfig = {
90
103
  trailer?: string;
91
104
  };
92
105
  gate: Gate;
93
- pr: {
94
- merge: PrMerge;
95
- };
106
+ pr: PrConfig;
96
107
  worktree: {
97
108
  setup: string[];
98
109
  };
@@ -130,11 +141,16 @@ export declare const STRICTNESS: {
130
141
  readonly commitOnProtected: readonly ["deny", "allow"];
131
142
  readonly envFiles: readonly ["deny", "git"];
132
143
  readonly merge: readonly ["ask", "auto"];
144
+ readonly review: readonly ["full", "off"];
133
145
  };
134
146
  /** What the guards run on while any layer is invalid. */
135
147
  export declare const STRICTEST_GUARDRAILS: Guardrails;
136
148
  /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
137
149
  export declare const STRICTEST_PR_MERGE: PrMerge;
150
+ /** The Review depth while any layer is invalid, and with nothing declared: the agent reads every head. */
151
+ export declare const STRICTEST_PR_REVIEW: PrReview;
152
+ /** The command a `full` Review runs with nothing declared; it has no strictness order. */
153
+ export declare const DEFAULT_REVIEW_COMMAND = "/code-review";
138
154
  /** What a repository gets with nothing declared. */
139
155
  export declare const DEFAULT_GUARDRAILS: Guardrails;
140
156
  /**
@@ -16,7 +16,8 @@ export const STRICTNESS = {
16
16
  forcePush: ["deny", "ask", "lease"],
17
17
  commitOnProtected: ["deny", "allow"],
18
18
  envFiles: ["deny", "git"],
19
- merge: ["ask", "auto"]
19
+ merge: ["ask", "auto"],
20
+ review: ["full", "off"]
20
21
  };
21
22
  /** What the guards run on while any layer is invalid. */
22
23
  export const STRICTEST_GUARDRAILS = {
@@ -27,6 +28,11 @@ export const STRICTEST_GUARDRAILS = {
27
28
  };
28
29
  /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
29
30
  export const STRICTEST_PR_MERGE = "ask";
31
+ /** The Review depth while any layer is invalid, and with nothing declared: the agent reads every head. */
32
+ export const STRICTEST_PR_REVIEW = "full";
33
+ const STRICTEST_PR = { merge: STRICTEST_PR_MERGE, review: STRICTEST_PR_REVIEW };
34
+ /** The command a `full` Review runs with nothing declared; it has no strictness order. */
35
+ export const DEFAULT_REVIEW_COMMAND = "/code-review";
30
36
  /** What a repository gets with nothing declared. */
31
37
  export const DEFAULT_GUARDRAILS = {
32
38
  forcePush: "lease",
@@ -172,9 +178,12 @@ function hasCheckScript(root) {
172
178
  const { scripts } = manifest;
173
179
  return typeof scripts === "object" && scripts !== null && "check" in scripts && typeof scripts.check === "string";
174
180
  }
175
- /** The Gate with nothing declared: `pnpm check` where the manifest has a `check` script, else no command. */
181
+ /**
182
+ * The Gate with nothing declared: `pnpm check` where the manifest has a `check` script, else no command;
183
+ * no Step, no Check, and the watch for Drift on.
184
+ */
176
185
  function inferGate(root) {
177
- const gate = { steps: [], checks: { run: [] } };
186
+ const gate = { steps: [], drift: "fail", checks: { run: [] } };
178
187
  if (hasCheckScript(root)) {
179
188
  gate.command = "pnpm check";
180
189
  }
@@ -193,7 +202,7 @@ export function inferConfig(root) {
193
202
  guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
194
203
  commit: { convention: "conventional" },
195
204
  gate: inferGate(root),
196
- pr: { merge: STRICTEST_PR_MERGE },
205
+ pr: { ...STRICTEST_PR, reviewCommand: DEFAULT_REVIEW_COMMAND },
197
206
  worktree: { setup: [] }
198
207
  };
199
208
  }
@@ -268,13 +277,17 @@ function loosenings(base, local) {
268
277
  }
269
278
  return problems;
270
279
  }
271
- /** A local `pr.merge` less strict than the one it would override: `auto` over `ask`. */
280
+ /** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over full. */
272
281
  function prLoosenings(base, local) {
273
- if (local === undefined || rank("merge", local) <= rank("merge", base)) {
274
- return [];
275
- }
276
- const message = `${show(local)} loosens ${show(base)}; the local file may tighten pr.merge, never loosen it`;
277
- return [{ file: LOCAL_FILE, path: "pr.merge", message }];
282
+ const keys = ["merge", "review"];
283
+ return keys.flatMap((key) => {
284
+ const value = local?.[key];
285
+ if (value === undefined || rank(key, value) <= rank(key, base[key])) {
286
+ return [];
287
+ }
288
+ const message = `${show(value)} loosens ${show(base[key])}; the local file may tighten pr.${key}, never loosen it`;
289
+ return [{ file: LOCAL_FILE, path: `pr.${key}`, message }];
290
+ });
278
291
  }
279
292
  /** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
280
293
  function finalize(merged) {
@@ -299,7 +312,7 @@ export function resolveConfig(root) {
299
312
  if (local.value !== undefined) {
300
313
  const loosened = [
301
314
  ...loosenings(config.guardrails, local.value.guardrails),
302
- ...prLoosenings(config.pr.merge, local.value.pr?.merge)
315
+ ...prLoosenings(config.pr, local.value.pr)
303
316
  ];
304
317
  if (loosened.length > 0) {
305
318
  problems.push(...loosened);
@@ -310,7 +323,8 @@ export function resolveConfig(root) {
310
323
  }
311
324
  }
312
325
  if (problems.length > 0) {
313
- config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { merge: STRICTEST_PR_MERGE } };
326
+ // `reviewCommand` has no strictest value: it keeps what the valid layers name.
327
+ config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { ...config.pr, ...STRICTEST_PR } };
314
328
  }
315
329
  return {
316
330
  config: finalize(config),
@@ -1,7 +1,15 @@
1
1
  import { type Check } from "./check.ts";
2
2
  export type AgentsMapOptions = {
3
3
  file: string;
4
- maxLines: number;
5
4
  maxBytes: number;
6
5
  };
6
+ /** The rows of the Task Router in `text`, each its line and the paths it points at; none when it has no such table. */
7
+ export declare function routes(text: string): {
8
+ line: number;
9
+ paths: string[];
10
+ }[];
11
+ /** The text of the map at `file`; throws `CannotRun` when it does not exist. */
12
+ export declare function readMap(root: string, file: string): string;
7
13
  export declare const agentsMap: Check<AgentsMapOptions>;
14
+ /** The agent map a resolved `gate.checks` points at: the `agents-map` Check's file, on or off. */
15
+ export declare function mapFile(checks: Record<string, unknown>): string;
@@ -1,17 +1,19 @@
1
1
  // `agents-map`: the agent map stays a map. Within its budget, naming the Gate command, routing to the
2
- // Primitives catalogue, every path its Task Router points at existing, and the skills directory, its
3
- // lock and its links in step.
4
- import { existsSync, globSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
- import { join } from "node:path";
2
+ // Primitives catalogue, every path its Task Router points at existing, `CLAUDE.md` a link to it so
3
+ // Claude Code reads it, and the skills directory, its lock and its links in step.
4
+ import { existsSync, globSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
5
+ import { join, normalize } from "node:path";
6
6
  import { resolveConfig } from "../agentic-config.js";
7
7
  import { codeSpans, tableRows } from "../markdown.js";
8
- import { CannotRun } from "./check.js";
8
+ import { CannotRun, optionsOf } from "./check.js";
9
9
  import { overBudget } from "./markdown-budget.js";
10
10
  import { catalogueFile } from "./primitives.js";
11
11
  const ROUTER = "Task Router";
12
12
  const AGENTS_SKILLS = ".agents/skills";
13
13
  const CLAUDE_SKILLS = ".claude/skills";
14
14
  const LOCK = "skills-lock.json";
15
+ /** The file Claude Code reads at the repository root, in place of `AGENTS.md`. */
16
+ const CLAUDE_MD = "CLAUDE.md";
15
17
  const REMEDIES = {
16
18
  missing: "write it, or point gate.checks.agents-map.file at the map",
17
19
  budget: `cut it, or move a topic into a doc a ${ROUTER} row points at`,
@@ -34,6 +36,14 @@ function exists(root, path) {
34
36
  function escape(text) {
35
37
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
38
  }
39
+ /** The rows of the Task Router in `text`, each its line and the paths it points at; none when it has no such table. */
40
+ export function routes(text) {
41
+ // The first column says when a row applies; the others say what to read.
42
+ return tableRows(text, ROUTER).map(({ cells, line }) => ({
43
+ line,
44
+ paths: codeSpans(cells.slice(1).join(" ")).filter(isPath)
45
+ }));
46
+ }
37
47
  /** The budget, the Gate command, the row to the catalogue and every path the Task Router points at. */
38
48
  function judgeMap(root, { file, ...budget }, text) {
39
49
  const findings = overBudget(text, budget).map((message) => ({ file, message, remedy: REMEDIES.budget }));
@@ -45,17 +55,13 @@ function judgeMap(root, { file, ...budget }, text) {
45
55
  const message = `does not name the Gate command ${JSON.stringify(gate.command)}`;
46
56
  findings.push({ file, message, remedy: REMEDIES.command });
47
57
  }
48
- // The first column says when a row applies; the others say what to read.
49
- const routes = tableRows(text, ROUTER).map(({ cells, line }) => ({
50
- line,
51
- paths: codeSpans(cells.slice(1).join(" ")).filter(isPath)
52
- }));
58
+ const rows = routes(text);
53
59
  const catalogue = catalogueFile(gate.checks);
54
- if (existsSync(join(root, catalogue)) && !routes.some(({ paths }) => paths.includes(catalogue))) {
60
+ if (existsSync(join(root, catalogue)) && !rows.some(({ paths }) => paths.includes(catalogue))) {
55
61
  const message = `no ${ROUTER} row points at ${catalogue}`;
56
62
  findings.push({ file, message, remedy: REMEDIES.catalogue });
57
63
  }
58
- for (const { line, paths } of routes) {
64
+ for (const { line, paths } of rows) {
59
65
  for (const missing of paths.filter((path) => !exists(root, path))) {
60
66
  const message = `the ${ROUTER} points at ${JSON.stringify(missing)}, which does not exist`;
61
67
  findings.push({ file, line, message, remedy: REMEDIES.route });
@@ -63,6 +69,31 @@ function judgeMap(root, { file, ...budget }, text) {
63
69
  }
64
70
  return findings;
65
71
  }
72
+ /**
73
+ * `CLAUDE.md` a link to the map by its path from the root, so the link holds in every clone; nothing
74
+ * when the map is `CLAUDE.md` itself.
75
+ */
76
+ function judgeLink(root, map) {
77
+ if (normalize(map) === CLAUDE_MD) {
78
+ return [];
79
+ }
80
+ const path = join(root, CLAUDE_MD);
81
+ const command = `ln -s ${map} ${CLAUDE_MD}`;
82
+ const stat = lstatSync(path, { throwIfNoEntry: false });
83
+ if (stat === undefined) {
84
+ return [{ file: CLAUDE_MD, message: "does not exist", remedy: `link it to the map: ${command}` }];
85
+ }
86
+ if (!stat.isSymbolicLink()) {
87
+ const message = `is a file of its own, not a link to ${map}`;
88
+ return [{ file: CLAUDE_MD, message, remedy: `move what it holds into ${map}, remove it, then ${command}` }];
89
+ }
90
+ const target = readlinkSync(path);
91
+ if (normalize(target) !== normalize(map)) {
92
+ const message = `links to ${JSON.stringify(target)}, not to ${map}`;
93
+ return [{ file: CLAUDE_MD, message, remedy: `remove it, then ${command}` }];
94
+ }
95
+ return [];
96
+ }
66
97
  /** The names of the directories in `dir`, sorted; empty when there is no `dir`. */
67
98
  function directories(dir) {
68
99
  if (!existsSync(dir)) {
@@ -145,15 +176,27 @@ function linksTo(link, target) {
145
176
  return false;
146
177
  }
147
178
  }
148
- function run(root, options) {
149
- const path = join(root, options.file);
179
+ /** The text of the map at `file`; throws `CannotRun` when it does not exist. */
180
+ export function readMap(root, file) {
181
+ const path = join(root, file);
150
182
  if (!existsSync(path)) {
151
- throw new CannotRun({ file: options.file, message: "the map does not exist", remedy: REMEDIES.missing });
183
+ throw new CannotRun({ file, message: "the map does not exist", remedy: REMEDIES.missing });
152
184
  }
153
- return [...judgeMap(root, options, readFileSync(path, "utf8")), ...judgeSkills(root)];
185
+ return readFileSync(path, "utf8");
186
+ }
187
+ function run(root, options) {
188
+ return [
189
+ ...judgeMap(root, options, readMap(root, options.file)),
190
+ ...judgeLink(root, options.file),
191
+ ...judgeSkills(root)
192
+ ];
154
193
  }
155
194
  export const agentsMap = {
156
195
  name: "agents-map",
157
- defaults: { file: "AGENTS.md", maxLines: 150, maxBytes: 10240 },
196
+ defaults: { file: "AGENTS.md", maxBytes: 10240 },
158
197
  run
159
198
  };
199
+ /** The agent map a resolved `gate.checks` points at: the `agents-map` Check's file, on or off. */
200
+ export function mapFile(checks) {
201
+ return (optionsOf(agentsMap, checks["agents-map"]) ?? agentsMap.defaults).file;
202
+ }
@@ -1,6 +1,11 @@
1
1
  import { type Check } from "./check.ts";
2
+ /** The frontmatter fields beyond `status` a record can be held to, in the order they are judged. */
3
+ declare const FIELDS: readonly ["date", "touches", "superseded-by", "amended-by"];
4
+ export type DecisionField = (typeof FIELDS)[number];
2
5
  export type DecisionRecordsOptions = {
3
6
  dirs: string[];
4
7
  statuses: string[];
8
+ fields: DecisionField[];
5
9
  };
6
10
  export declare const decisionRecords: Check<DecisionRecordsOptions>;
11
+ export {};