dw-sdlc 0.6.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 (52) hide show
  1. package/README.md +38 -5
  2. package/config/agentic.config.schema.json +67 -18
  3. package/dist/agentic-config.d.ts +28 -1
  4. package/dist/agentic-config.js +35 -5
  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/cli.js +5 -1
  19. package/dist/commands/check.d.ts +1 -1
  20. package/dist/commands/check.js +6 -4
  21. package/dist/commands/gate.d.ts +10 -4
  22. package/dist/commands/gate.js +202 -86
  23. package/dist/commands/pr-reviewed.d.ts +2 -0
  24. package/dist/commands/pr-reviewed.js +92 -0
  25. package/dist/commands/pr-ship.d.ts +2 -0
  26. package/dist/commands/pr-ship.js +183 -0
  27. package/dist/commands/references-sync.js +5 -6
  28. package/dist/drift.d.ts +17 -0
  29. package/dist/drift.js +68 -0
  30. package/dist/gate-cache.d.ts +13 -0
  31. package/dist/gate-cache.js +39 -0
  32. package/dist/git-env.d.ts +2 -0
  33. package/dist/git-env.js +10 -0
  34. package/dist/markdown.d.ts +19 -0
  35. package/dist/markdown.js +46 -2
  36. package/dist/package-version.d.ts +2 -0
  37. package/dist/package-version.js +13 -0
  38. package/dist/pr/decide.d.ts +59 -0
  39. package/dist/pr/decide.js +250 -0
  40. package/dist/pr/read.d.ts +24 -0
  41. package/dist/pr/read.js +112 -0
  42. package/dist/pr/state.d.ts +54 -0
  43. package/dist/pr/state.js +220 -0
  44. package/dist/router-block.d.ts +4 -1
  45. package/dist/router-block.js +31 -7
  46. package/dist/run-command.d.ts +24 -0
  47. package/dist/run-command.js +121 -0
  48. package/dist/setup-gate.js +3 -12
  49. package/dist/working-tree.d.ts +13 -0
  50. package/dist/working-tree.js +60 -0
  51. package/package.json +2 -2
  52. package/templates/router.md +1 -0
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # dw-sdlc
2
2
 
3
3
  The Gate and the agentic config of a repository. `.ai/agentic.config.json` declares how a repository
4
- works for an agent: its package manager, protected branches, env-file policy, commit convention and
5
- the Gate. The package ships the `dw-sdlc` bin that runs on that file, its JSON Schema, and the
4
+ works for an agent: its package manager, protected branches, env-file policy, commit convention, the
5
+ Gate and who merges a pull request. The package ships the `dw-sdlc` bin that runs on that file, its JSON Schema, and the
6
6
  resolver of its layers.
7
7
 
8
8
  ## Install
@@ -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
  {
@@ -38,6 +43,32 @@ Under `gate.checks`, a Check the package ships is `true` for its defaults or an
38
43
  `comment-budget` holds each comment block in the JavaScript, TypeScript and shell (`.sh`) files under
39
44
  its `roots` to `maxProseLines` lines of prose, 3 by default.
40
45
 
46
+ ## The Ship script
47
+
48
+ `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
49
+ answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
50
+ whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
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
54
+ script would perform as the dev's. It never deletes a branch; a merged pull request answers with
55
+ the delete command for the dev.
56
+
57
+ `pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
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.
62
+
63
+ ```json
64
+ { "pr": { "merge": "auto", "review": "full" } }
65
+ ```
66
+
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.
71
+
41
72
  ## Commands
42
73
 
43
74
  | command | what it does |
@@ -50,6 +81,8 @@ its `roots` to `maxProseLines` lines of prose, 3 by default.
50
81
  | `config` | the resolved agentic config, or one file validated against the schema |
51
82
  | `references sync` | every declared Reference brought to its ref under `references/` |
52
83
  | `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
84
+ | `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
85
+ | `pr reviewed <n>` | a clean review pinned to the head it read, as a commit status |
53
86
 
54
87
  `dw-sdlc <command> --help` gives each one's options.
55
88
 
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/plugins/guardrails/config/agentic.config.schema.json",
4
4
  "title": "agentic.config",
5
- "description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails` but never loosen it. A key neither file sets is inferred: the Node package manager from the lockfile, the `conventional` commit preset, and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
5
+ "description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails` and `pr.merge` but never loosen them. A key neither file sets is inferred: the Node package manager from the lockfile, the `conventional` commit preset, and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
6
6
  "type": "object",
7
7
  "properties": {
8
8
  "$schema": {
@@ -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",
@@ -357,6 +386,26 @@
357
386
  },
358
387
  "additionalProperties": false
359
388
  },
389
+ "pr": {
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.",
391
+ "type": "object",
392
+ "properties": {
393
+ "merge": {
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.",
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
405
+ }
406
+ },
407
+ "additionalProperties": false
408
+ },
360
409
  "worktree": {
361
410
  "type": "object",
362
411
  "properties": {
@@ -15,6 +15,19 @@ export type EnvFiles = {
15
15
  allow: string[];
16
16
  deny: string[];
17
17
  };
18
+ /** Who merges a pull request the Ship script finds ready: the script under `auto`, the dev under `ask`. */
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
+ };
18
31
  export type Guardrails = {
19
32
  forcePush: ForcePush;
20
33
  commitOnProtected: CommitOnProtected;
@@ -38,16 +51,20 @@ export type GateChecks = {
38
51
  run: RunCheck[];
39
52
  [check: string]: unknown;
40
53
  };
41
- /** 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. */
42
57
  export type Gate = {
43
58
  command?: string;
44
59
  steps: string[];
60
+ drift: DriftSetting;
45
61
  checks: GateChecks;
46
62
  };
47
63
  /** `gate` as one layer writes it: every key optional, `checks` Check by Check. */
48
64
  export type GateLayer = {
49
65
  command?: string;
50
66
  steps?: string[];
67
+ drift?: DriftSetting;
51
68
  checks?: Partial<GateChecks>;
52
69
  };
53
70
  /** One layer as written in a file: every key optional, unknown top-level keys tolerated. */
@@ -66,6 +83,7 @@ export type AgenticConfig = {
66
83
  trailer?: string;
67
84
  };
68
85
  gate?: GateLayer;
86
+ pr?: Partial<PrConfig>;
69
87
  worktree?: {
70
88
  setup?: string[];
71
89
  };
@@ -85,6 +103,7 @@ export type ResolvedConfig = {
85
103
  trailer?: string;
86
104
  };
87
105
  gate: Gate;
106
+ pr: PrConfig;
88
107
  worktree: {
89
108
  setup: string[];
90
109
  };
@@ -121,9 +140,17 @@ export declare const STRICTNESS: {
121
140
  readonly forcePush: readonly ["deny", "ask", "lease"];
122
141
  readonly commitOnProtected: readonly ["deny", "allow"];
123
142
  readonly envFiles: readonly ["deny", "git"];
143
+ readonly merge: readonly ["ask", "auto"];
144
+ readonly review: readonly ["full", "off"];
124
145
  };
125
146
  /** What the guards run on while any layer is invalid. */
126
147
  export declare const STRICTEST_GUARDRAILS: Guardrails;
148
+ /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
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";
127
154
  /** What a repository gets with nothing declared. */
128
155
  export declare const DEFAULT_GUARDRAILS: Guardrails;
129
156
  /**
@@ -15,7 +15,9 @@ export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
15
15
  export const STRICTNESS = {
16
16
  forcePush: ["deny", "ask", "lease"],
17
17
  commitOnProtected: ["deny", "allow"],
18
- envFiles: ["deny", "git"]
18
+ envFiles: ["deny", "git"],
19
+ merge: ["ask", "auto"],
20
+ review: ["full", "off"]
19
21
  };
20
22
  /** What the guards run on while any layer is invalid. */
21
23
  export const STRICTEST_GUARDRAILS = {
@@ -24,6 +26,13 @@ export const STRICTEST_GUARDRAILS = {
24
26
  envFiles: { policy: "deny", allow: [], deny: [] },
25
27
  protectedBranches: ["master", "main", "production"]
26
28
  };
29
+ /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
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";
27
36
  /** What a repository gets with nothing declared. */
28
37
  export const DEFAULT_GUARDRAILS = {
29
38
  forcePush: "lease",
@@ -169,9 +178,12 @@ function hasCheckScript(root) {
169
178
  const { scripts } = manifest;
170
179
  return typeof scripts === "object" && scripts !== null && "check" in scripts && typeof scripts.check === "string";
171
180
  }
172
- /** 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
+ */
173
185
  function inferGate(root) {
174
- const gate = { steps: [], checks: { run: [] } };
186
+ const gate = { steps: [], drift: "fail", checks: { run: [] } };
175
187
  if (hasCheckScript(root)) {
176
188
  gate.command = "pnpm check";
177
189
  }
@@ -190,6 +202,7 @@ export function inferConfig(root) {
190
202
  guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
191
203
  commit: { convention: "conventional" },
192
204
  gate: inferGate(root),
205
+ pr: { ...STRICTEST_PR, reviewCommand: DEFAULT_REVIEW_COMMAND },
193
206
  worktree: { setup: [] }
194
207
  };
195
208
  }
@@ -205,6 +218,7 @@ function applyLayer(base, layer) {
205
218
  },
206
219
  commit: { ...base.commit, ...layer.commit },
207
220
  gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
221
+ pr: { ...base.pr, ...layer.pr },
208
222
  worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
209
223
  };
210
224
  }
@@ -263,6 +277,18 @@ function loosenings(base, local) {
263
277
  }
264
278
  return problems;
265
279
  }
280
+ /** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over full. */
281
+ function prLoosenings(base, local) {
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
+ });
291
+ }
266
292
  /** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
267
293
  function finalize(merged) {
268
294
  const pattern = merged.commit.pattern ?? COMMIT_PATTERNS[merged.commit.convention];
@@ -284,7 +310,10 @@ export function resolveConfig(root) {
284
310
  config = applyLayer(config, committed.value);
285
311
  }
286
312
  if (local.value !== undefined) {
287
- const loosened = loosenings(config.guardrails, local.value.guardrails);
313
+ const loosened = [
314
+ ...loosenings(config.guardrails, local.value.guardrails),
315
+ ...prLoosenings(config.pr, local.value.pr)
316
+ ];
288
317
  if (loosened.length > 0) {
289
318
  problems.push(...loosened);
290
319
  localState = "invalid";
@@ -294,7 +323,8 @@ export function resolveConfig(root) {
294
323
  }
295
324
  }
296
325
  if (problems.length > 0) {
297
- config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS) };
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 } };
298
328
  }
299
329
  return {
300
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 {};