dw-sdlc 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # dw-sdlc
2
2
 
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, 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
- resolver of its layers.
3
+ The bin and the config behind the [dw-sdlc](https://github.com/dominikwozniak/dw-sdlc) Claude Code plugins.
4
+ `.ai/agentic.config.json` declares how a repository works for an agent: its package manager, protected
5
+ branches, env-file policy, commit convention, the Gate and who merges a pull request. The package ships
6
+ the `dw-sdlc` bin that runs on that file, the file's JSON Schema and the resolver of its layers. The Gate
7
+ and the Ship script work in any repository, with or without the plugins.
7
8
 
8
9
  ## Install
9
10
 
@@ -13,17 +14,17 @@ Node 24 or later.
13
14
  pnpm add -D dw-sdlc
14
15
  ```
15
16
 
17
+ Every release goes to npm from GitHub Actions by trusted publishing, and every version after 0.17.0
18
+ carries provenance too. Trust evidence never drops from one version to the next, so a repository on
19
+ pnpm's `trustPolicy: no-downgrade` takes each release. Dependabot offers one after the seven days of
20
+ `minimumReleaseAge`; a repository that cannot wait names `dw-sdlc` in `minimumReleaseAgeExclude` under
21
+ its own rule for exclusions.
22
+
16
23
  ## The Gate
17
24
 
18
- `dw-sdlc gate` runs the Steps under `gate.steps` in order from the repository root, then every Check
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.
25
+ `dw-sdlc gate` proves a change. It runs the Steps under `gate.steps`, the repository's own commands, in
26
+ order from the repository root, stopping at the first that fails. Then it runs every Check under
27
+ `gate.checks`, a named rule the package ships, none stopping the next.
27
28
 
28
29
  ```json
29
30
  {
@@ -35,90 +36,113 @@ lines, exit codes and document.
35
36
  }
36
37
  ```
37
38
 
38
- A Step is a command string, or `{ "command": "…", "hint": "…" }`; a `gate.checks.run` entry takes a
39
- `hint` beside `name` and `command`. When the Step or script fails, the Gate prints `hint: <text>` under
40
- its failure line, and `--json` carries `hint` on every Step and Check, `null` where there is none.
41
-
42
39
  ```json
43
40
  { "scripts": { "check": "dw-sdlc gate" } }
44
41
  ```
45
42
 
46
- Under `gate.checks`, a Check the package ships is `true` for its defaults or an object of its options.
47
- `comment-budget` holds each comment block in the JavaScript, TypeScript and shell (`.sh`) files under
48
- its `roots` to `maxProseLines` lines of prose, 3 by default.
49
- `disable-directives` fails every Disable directive in a comment that gives no reason: `oxlint-disable*`,
50
- `eslint-disable*`, `@ts-expect-error`, `@ts-ignore`, `rubocop:disable` and `noqa`. A reason follows `--`
51
- on the directive's line, follows an `@ts-` directive, or is a comment on the line above.
52
-
53
- `skill-sections` holds each README section a skill copies to its source. A part sits between two
54
- markers, each on its own line, and `dw-sdlc check skill-sections --write` fills an empty pair:
55
-
56
- ```md
57
- <!-- README.md#errors -->
58
- <!-- /README.md#errors -->
59
- ```
60
-
61
- The anchor is GitHub's, and the section runs from its heading to the next heading of its level or
62
- above, in the `README.md` beside the `skills` directory that holds the file, or beside the file
63
- outside one. The Check fails a part that differs from its section, a part whose anchor names no
64
- heading of the README, a part from a section no Primitive in `.ai/primitives.json` names among its
65
- docs, a marker without its pair, and a code fence that never closes above a marker, which hides it.
43
+ It prints one line per Step and per Check, and the last 50 lines of a command that fails. A Step that
44
+ creates, changes or deletes a file git tracks or would track fails as Drift, unless `gate.drift` is
45
+ `"allow"`. A Step written as `{ "command": "…", "hint": "…" }` prints `hint: <text>` under its failure
46
+ line; a `gate.checks.run` entry takes a `hint` beside `name` and `command`.
47
+
48
+ | flag | does |
49
+ | ------------------- | --------------------------------------------------------------------------------- |
50
+ | `--json` | prints the result as one document; each Step and Check carries `tail` and `hint` |
51
+ | `--verbose` | streams every command's output instead of the tail |
52
+ | `--cache` | skips the run when the tree and the Gate match the last green run in the checkout |
53
+ | `--update-baseline` | after a green run, tightens the baseline of each Ratchet |
54
+
55
+ | exit | means |
56
+ | ---- | ---------------------------------------------------------------------------------- |
57
+ | 0 | everything passes |
58
+ | 1 | a Step or a Check fails, or a Step drifts |
59
+ | 2 | the Gate cannot run: an invalid config layer, a command not found, a missing input |
60
+
61
+ `dw-sdlc check <name>` runs one Check alone, with the same lines, exit codes and document.
62
+
63
+ ### Checks
64
+
65
+ Under `gate.checks`, a Check is `true` for its defaults, an object of its options, or `false`. The
66
+ schema's description of each one names its options and defaults.
67
+
68
+ | Check | fails | reads first |
69
+ | -------------------- | --------------------------------------------------------------------------- | ------------------------- |
70
+ | `markdown-refs` | a relative link or image whose path or heading anchor does not resolve | nothing |
71
+ | `temporal-language` | a changelog phrase in a durable document, outside code | nothing |
72
+ | `comment-budget` | a comment block over 3 lines of prose in JS, TS or shell | nothing |
73
+ | `disable-directives` | a linter or type-checker disable comment with no reason | nothing |
74
+ | `agents-map` | an `AGENTS.md` over budget, missing the Gate command or pointing at nothing | `AGENTS.md` |
75
+ | `primitives` | a catalogue entry out of step with the tree, or a file no entry owns | `.ai/primitives.json` |
76
+ | `routed-docs` | a document under `docs/` no Task Router row or Primitive leads to | the map, the catalogue |
77
+ | `markdown-budget` | a markdown file over the byte budget of its glob | the files its globs match |
78
+ | `decision-records` | a decision record misnamed, misnumbered or without a valid status | a records directory |
79
+ | `file-size-ratchet` | a code file over its line limit that the baseline does not allow | its baseline |
80
+ | `skill-sections` | a README section a skill copies that differs from its source | the catalogue |
81
+
82
+ Each Check's options and defaults:
83
+ [its description in the schema](https://github.com/dominikwozniak/dw-sdlc/blob/main/packages/dw-sdlc/config/agentic.config.schema.json).
66
84
 
67
85
  ## The Ship script
68
86
 
69
- `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
70
- answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
71
- whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
72
- performs its own Actions: it updates a branch GitHub reports `BEHIND`, marks the head reviewed when the
73
- Review depth is `off`, marks a draft ready once nothing else holds it back, and squash merges a pull
74
- 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
75
- script would perform as the dev's. It never deletes a branch; a merged pull request answers with
76
- the delete command for the dev.
77
-
78
- `pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
79
- dev under `ask`, the default. `pr.review` says how closely a head no one has reviewed is read: the
80
- agent reviews it under `full`, one `sdlc-reviewer` subagent of the plugin does under `light`, and no
81
- one does under `off`. Under `auto`, the default, the script routes the depth from the changes:
82
- lockfiles alone `off`; a workflow file, more than 100 changed lines outside lockfiles, or a change
83
- too large to size `full`; the rest `light`. The local layer may tighten either and never loosen it;
84
- `--merge` acts as `auto` and `--review full|light|off` sets the depth for one call. A pull request
85
- the `gh` account did not open is always the dev's to merge.
87
+ `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and answers
88
+ the one Action it needs next, as a line or, with `--json`, one document. It performs its own Actions:
89
+ it updates a branch GitHub reports `BEHIND`, marks the head reviewed at Review depth `off`, marks a draft
90
+ ready once nothing else holds it back, and squash merges when the merge is its own. It never deletes a
91
+ branch. `--dry-run` performs nothing.
92
+
93
+ | exit | whose turn |
94
+ | ---- | ------------------ |
95
+ | 0 | done |
96
+ | 10 | the agent's: act |
97
+ | 20 | nobody's yet: wait |
98
+ | 30 | the dev's: park |
99
+ | 2 | it cannot run |
100
+
101
+ | key | sets |
102
+ | ------------------ | ---------------------------------------------------------------------------------------------- |
103
+ | `pr.merge` | who merges: the script under `auto`, the dev under `ask`, the default |
104
+ | `pr.review` | the Review depth of an unreviewed head: `full`, `light`, `off`, or `auto` from the change size |
105
+ | `pr.reviewCommand` | the slash command a `full` Review runs, `/code-review` by default |
86
106
 
87
107
  ```json
88
108
  { "pr": { "merge": "auto", "review": "full" } }
89
109
  ```
90
110
 
91
- `pr ship` answers `review`, its depth under `details`, until the head carries the commit status
92
- `dw-sdlc/review`, which `dw-sdlc pr reviewed <n> --head <sha>` posts after a clean review. A Review
93
- that leaves a Finding open posts it with `--findings`, in state failure, "read, findings open": the
94
- read mark. After the next push, `details.since` names the last head a Review read, clean or marked,
95
- so the Review after a fix reads the fix alone. `pr reviewed` posts nothing and exits 2 once a push
96
- moved the head, so a new head needs a fresh review. Under `off` the script posts that status
97
- itself, its description saying why, and goes on.
111
+ The local layer may tighten `pr.merge` and `pr.review`, never loosen them. `--merge` acts as `auto` and
112
+ `--review full|light|off` sets the depth for one call. A pull request the `gh` account did not open is
113
+ always the dev's to merge. `light` spawns the `sdlc-reviewer` subagent of the
114
+ [`sdlc` plugin](https://github.com/dominikwozniak/dw-sdlc/blob/main/plugins/sdlc/README.md#the-light-review).
98
115
 
99
- Three Reviews with no success between them end the loop: `pr ship` answers `park review-limit`
100
- instead of `review`, whose command, `dw-sdlc pr reviewed <n> -R <owner/name> --head <sha> --accept`,
101
- is the dev's. `--accept` posts the success described "accepted by the dev, findings open"; with
102
- `--findings` it exits 2. The limit is a constant, not a config key, and `--review <depth>` on a call
103
- buys one more Review past it.
116
+ `dw-sdlc pr reviewed <n> --head <sha>` pins a Review to the head it read, as the commit status
117
+ `dw-sdlc/review`: clean, Findings open with `--findings`, or accepted by the dev with `--accept`. The
118
+ routing rules, the read mark and the limit of three unclean Reviews are in
119
+ [How the Ship script merges a pull request](https://github.com/dominikwozniak/dw-sdlc/blob/main/docs/overview.md#how-the-ship-script-merges-a-pull-request).
104
120
 
105
121
  ## Commands
106
122
 
107
- | command | what it does |
108
- | ------------------ | ------------------------------------------------------------------------------------------ |
109
- | `gate` | the Steps of `gate.steps` in order, then every Check under `gate.checks` |
110
- | `check <name>` | one Check of the Gate by its name, without the Steps and the other Checks |
111
- | `primitives` | the Primitives that own each path, or that a change removes, adds or extends |
112
- | `setup` | this machine's local config layer, the git excludes and the permission rules |
113
- | `doctor` | one PASS / WARN / FAIL line per machine check the guardrails rely on |
114
- | `config` | the resolved agentic config, or one file validated against the schema |
115
- | `references sync` | every declared Reference brought to its ref under `references/` |
116
- | `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
117
- | `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
118
- | `pr reviewed <n>` | a Review pinned to the head it read, as a commit status: clean, Findings open, or accepted |
119
- | `recap` | the facts a Recap rests on and whether the session can be closed |
120
-
121
- `dw-sdlc <command> --help` gives each one's options.
123
+ | command | what it does |
124
+ | ------------------ | ------------------------------------------------------------------------------------------------- |
125
+ | `gate` | the Gate: the Steps of `gate.steps` in order, then every Check under `gate.checks` |
126
+ | `check <name>` | one Check of the Gate by its name, without the Steps and the other Checks |
127
+ | `primitives` | the Primitives that own each path, or that a change removes, adds or extends |
128
+ | `setup` | the Gate adopted where asked, this machine's local config layer, git excludes and permissions |
129
+ | `doctor` | one PASS / WARN / FAIL line per machine check the `sdlc` plugin relies on |
130
+ | `config` | the resolved agentic config, or one file validated against the schema |
131
+ | `references sync` | every declared Reference, a repository cloned for reading, brought to its ref under `references/` |
132
+ | `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
133
+ | `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
134
+ | `pr reviewed <n>` | a Review pinned to the head it read, as a commit status |
135
+ | `recap` | the facts a Recap rests on, whether the session can be closed and what could come next |
136
+ | `sweep` | Landed branches and clean worktrees removed, and what it keeps named |
137
+ | `worker start` | a Worker started on a Brief in a herdr terminal |
138
+ | `worker ls` | the Workers this Lead started and their states |
139
+ | `worker report` | a Recap delivered to the Lead, retried while it is blocked |
140
+ | `lead start` | the next session started as a Lead in a herdr terminal of its own |
141
+
142
+ `dw-sdlc --help` lists them, and `dw-sdlc <command> --help` gives each one's options and exit codes. A
143
+ Primitive is one unit a repository exposes to an agent, a module, a hook, a script, a config key or a
144
+ skill, listed in `.ai/primitives.json`. A Recap, a Brief, a Worker and a Lead belong to the
145
+ [`sdlc-sessions` plugin](https://github.com/dominikwozniak/dw-sdlc/blob/main/plugins/sdlc-sessions/README.md).
122
146
 
123
147
  ## The resolver
124
148
 
@@ -129,8 +153,8 @@ const { config, problems } = resolveConfig(process.cwd())
129
153
  ```
130
154
 
131
155
  `resolveConfig` merges the committed `.ai/agentic.config.json`, the machine's
132
- `.ai/agentic.config.local.json` and what it infers from the repository. `problems` names each layer
133
- that fails the schema; an invalid layer is dropped whole.
156
+ `.ai/agentic.config.local.json` and what it infers from the repository. `problems` names each layer that
157
+ fails the schema; an invalid layer is dropped whole, and the guards then run the strictest set.
134
158
 
135
159
  ## License
136
160
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/plugins/guardrails/config/agentic.config.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/packages/dw-sdlc/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` 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.",
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`, `pr.merge` and `pr.review` but never loosen them. A key neither file sets is inferred: the Node package manager from the lockfile 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": {
@@ -92,7 +92,7 @@
92
92
  "type": "object",
93
93
  "properties": {
94
94
  "convention": {
95
- "description": "The subject preset `pattern` falls back to. `ticket-key`: `[KEY-123] type: subject`, the tracker's key in brackets before a type from a fixed list. `conventional`: Conventional Commits; inferred when no layer sets either.",
95
+ "description": "The subject preset `pattern` falls back to. `ticket-key`: `[KEY-123] type: subject`, the tracker's key in brackets before a type from a fixed list. `conventional`: Conventional Commits. Never inferred: with no layer setting this or `pattern`, the subject goes unchecked.",
96
96
  "enum": ["ticket-key", "conventional"]
97
97
  },
98
98
  "pattern": {
@@ -125,8 +125,8 @@ export type ResolvedConfig = {
125
125
  };
126
126
  guardrails: Guardrails;
127
127
  commit: {
128
- convention: CommitConvention;
129
- pattern: string;
128
+ convention?: CommitConvention;
129
+ pattern?: string;
130
130
  trailer?: string;
131
131
  };
132
132
  gate: Gate;
@@ -136,14 +136,6 @@ export type ResolvedConfig = {
136
136
  setup: string[];
137
137
  };
138
138
  };
139
- /** The layers merged, before `commit.pattern` falls back to the preset its convention names. */
140
- type Merged = Omit<ResolvedConfig, "commit"> & {
141
- commit: {
142
- convention: CommitConvention;
143
- pattern?: string;
144
- trailer?: string;
145
- };
146
- };
147
139
  export type Problem = {
148
140
  file: string;
149
141
  path: string;
@@ -210,13 +202,12 @@ export declare function repositoryRoot(dir: string): string;
210
202
  export declare function mainCheckout(root: string): string | undefined;
211
203
  /**
212
204
  * The bottom layer: the package manager the lockfile implies, the Gate command the manifest's `check`
213
- * script implies, plus the defaults every key falls back to. `commit.pattern` is left to
214
- * `resolveConfig`, which fills it from the convention when no layer sets one.
205
+ * script implies, plus the defaults every key falls back to. No commit convention: only a file sets
206
+ * one, and `resolveConfig` fills `commit.pattern` from it when no layer sets a pattern.
215
207
  */
216
- export declare function inferConfig(root: string): Merged;
208
+ export declare function inferConfig(root: string): ResolvedConfig;
217
209
  /**
218
210
  * Local over committed over inferred, for the repository at `root`. A linked worktree reads a layer it
219
211
  * lacks from its main checkout, where a repository that keeps `.ai/` out of git has the only copy.
220
212
  */
221
213
  export declare function resolveConfig(root: string): Resolution;
222
- export {};
@@ -207,8 +207,8 @@ function inferGate(root) {
207
207
  }
208
208
  /**
209
209
  * The bottom layer: the package manager the lockfile implies, the Gate command the manifest's `check`
210
- * script implies, plus the defaults every key falls back to. `commit.pattern` is left to
211
- * `resolveConfig`, which fills it from the convention when no layer sets one.
210
+ * script implies, plus the defaults every key falls back to. No commit convention: only a file sets
211
+ * one, and `resolveConfig` fills `commit.pattern` from it when no layer sets a pattern.
212
212
  */
213
213
  export function inferConfig(root) {
214
214
  return {
@@ -216,7 +216,7 @@ export function inferConfig(root) {
216
216
  baseBranch: "auto",
217
217
  packageManagers: inferPackageManagers(root),
218
218
  guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
219
- commit: { convention: "conventional" },
219
+ commit: {},
220
220
  gate: inferGate(root),
221
221
  pr: { ...DEFAULT_PR },
222
222
  worker: { ...DEFAULT_WORKER },
@@ -307,10 +307,16 @@ function prLoosenings(base, local) {
307
307
  return [{ file: LOCAL_FILE, path: `pr.${key}`, message }];
308
308
  });
309
309
  }
310
- /** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
310
+ /**
311
+ * `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. Neither
312
+ * set, no pattern: the subject goes unchecked.
313
+ */
311
314
  function finalize(merged) {
312
- const pattern = merged.commit.pattern ?? COMMIT_PATTERNS[merged.commit.convention];
313
- return { ...merged, commit: { ...merged.commit, pattern } };
315
+ const { convention, pattern } = merged.commit;
316
+ if (pattern !== undefined || convention === undefined) {
317
+ return merged;
318
+ }
319
+ return { ...merged, commit: { ...merged.commit, pattern: COMMIT_PATTERNS[convention] } };
314
320
  }
315
321
  /**
316
322
  * Local over committed over inferred, for the repository at `root`. A linked worktree reads a layer it
@@ -19,7 +19,7 @@ export type Probe = {
19
19
  export type RepositoryConfig = {
20
20
  config: {
21
21
  commit: {
22
- convention: string;
22
+ convention?: string;
23
23
  };
24
24
  };
25
25
  problems: string[];
@@ -47,7 +47,11 @@ function checkJq(probe) {
47
47
  if (status === 0) {
48
48
  return { name: "jq", level: "PASS", detail: stdout.trim() };
49
49
  }
50
- return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
50
+ return {
51
+ name: "jq",
52
+ level: "FAIL",
53
+ detail: "missing: every guard is silent without it. https://jqlang.org/download/"
54
+ };
51
55
  }
52
56
  /** Every hook command string in a settings file, whatever the event and matcher. */
53
57
  function hookCommands(settings) {
@@ -219,7 +223,7 @@ function checkConfig(config, root) {
219
223
  return {
220
224
  name: "config",
221
225
  level: "PASS",
222
- detail: `commit ${config.config.commit.convention}${borrowed.join("")}`
226
+ detail: `commit ${config.config.commit.convention ?? "unchecked"}${borrowed.join("")}`
223
227
  };
224
228
  }
225
229
  export function diagnose(inputs) {
@@ -27,8 +27,8 @@ export type SetupOptions = {
27
27
  export declare const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
28
28
  /**
29
29
  * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
30
- * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
31
- * its path across plugin updates. Without a registered clone holding the schema, the `$id`.
30
+ * editor reads it offline at the installed version and the clone, unlike the install cache, keeps its
31
+ * path across plugin updates. Without a registered clone holding the schema, the `$id`.
32
32
  */
33
33
  export declare function schemaReference(claudeDir: string): string;
34
34
  export declare function setup(options: SetupOptions): Step[];
@@ -69,8 +69,8 @@ function committedConfig(root) {
69
69
  }
70
70
  /**
71
71
  * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
72
- * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
73
- * its path across plugin updates. Without a registered clone holding the schema, the `$id`.
72
+ * editor reads it offline at the installed version and the clone, unlike the install cache, keeps its
73
+ * path across plugin updates. Without a registered clone holding the schema, the `$id`.
74
74
  */
75
75
  export function schemaReference(claudeDir) {
76
76
  const registry = readJson(join(claudeDir, "plugins", "known_marketplaces.json"));
@@ -168,8 +168,8 @@ function localConfig(root, claudeDir, convention, guardrails, worker) {
168
168
  if (worker !== undefined) {
169
169
  config.worker = worker;
170
170
  }
171
- // Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
172
- // pinning it here would override a convention the repository commits later.
171
+ // Only one asked for, by `--convention` or the file's pin: pinning one here would override a
172
+ // convention the repository commits later.
173
173
  if (convention !== undefined) {
174
174
  config.commit = { convention };
175
175
  }
@@ -9,7 +9,7 @@ import { isCommitLanded, isLanded } from "../landed.js";
9
9
  import { shellLine } from "../pr/decide.js";
10
10
  import { CannotRun } from "../pr/read.js";
11
11
  import { byHand, holdersOf, keptIn, lockAfterStop, lockHolder, outsideWorktrees, readTerminals, removeTerminal, stopsOf, sweepingTerminal, whyHeld } from "../sweep-terminals.js";
12
- import { dirtyFiles, must, pausedOperation, worktreesOf } from "../sweep-trees.js";
12
+ import { branchesOf, dirtyFiles, pausedOperation, switchOff, worktreesOf } from "../sweep-trees.js";
13
13
  import { asked, git } from "../working-tree.js";
14
14
  import { lockVerdict } from "../worktree-lock.js";
15
15
  export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
@@ -26,7 +26,8 @@ config, else the branch origin/HEAD names) and removes, never forcing:
26
26
  It keeps, and names with the reason and the command to run yourself:
27
27
  a worktree that fails the set: the files that hold it with the forced removal, the paused operation, the
28
28
  lock's reason with git worktree unlock, a live Claude Code lock named by its pid, a HEAD that is not Landed;
29
- the default branch, the base branch, a protected branch and a branch a kept worktree has checked out;
29
+ the default branch, the base branch, a protected branch and a branch a kept worktree has checked out; a
30
+ Landed branch the main checkout has checked out, clean and with nothing paused, with git switch to the base;
30
31
  a branch whose work is on no commit of the base, with the forced delete.
31
32
  It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
32
33
  kept with git's words, and the sweep goes on.
@@ -91,11 +92,6 @@ function baseOf(root, head) {
91
92
  }
92
93
  return { name, ref };
93
94
  }
94
- function branchesOf(root) {
95
- return must(root, ["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
96
- .split("\n")
97
- .filter((name) => name !== "");
98
- }
99
95
  /** Why the worktree stays, with the dev's command when there is one, or null when its whole set passes. */
100
96
  function whyTreeStays(root, baseRef, tree, lock, holders) {
101
97
  const { path } = tree;
@@ -194,8 +190,9 @@ function judge(root, terminals) {
194
190
  }
195
191
  judged.add(name);
196
192
  const why = stays(name, footing);
193
+ const off = why?.dev === true && held.get(name) === checkout.path ? switchOff(root, base, name) : null;
197
194
  if (why !== null) {
198
- kept.push({ kind: "branch", name, ...why });
195
+ kept.push({ kind: "branch", name, ...why, ...off });
199
196
  }
200
197
  else if (isLanded(root, base.ref, name)) {
201
198
  removed.push({ kind: "branch", name, reason: `Landed on ${baseName}` });
@@ -146,7 +146,7 @@ export function checkSessions(probe, claude) {
146
146
  if (entry === undefined) {
147
147
  const detail = list === undefined
148
148
  ? "claude plugin list did not answer"
149
- : `no ${SESSIONS_ID}, so no /sdlc-recap, /sdlc-brief or /sdlc-delegate. claude plugin install ${SESSIONS_ID} --scope user`;
149
+ : `no ${SESSIONS_ID}, so no /sdlc-recap, /sdlc-kickoff or /sdlc-delegate. claude plugin install ${SESSIONS_ID} --scope user`;
150
150
  return { name, level: "INFO", detail };
151
151
  }
152
152
  const version = typeof entry.version === "string" ? entry.version : "?";
package/dist/pr/decide.js CHANGED
@@ -232,8 +232,10 @@ const ROWS = [
232
232
  return { ...head, action: "mark-reviewed", actor: "script", details: after };
233
233
  }
234
234
  const { depth, source } = depthOf(pr, policy);
235
- // The dev's own depth buys one Review past the limit; a Review under off needs none.
236
- if (depth !== "off" && source !== "flag" && pr.marks.count >= REVIEW_LIMIT) {
235
+ // The dev's own depth buys one Review past the limit, and only one: the flag rides on every
236
+ // later call. A Review under off needs none.
237
+ const limit = source === "flag" ? REVIEW_LIMIT + 1 : REVIEW_LIMIT;
238
+ if (depth !== "off" && pr.marks.count >= limit) {
237
239
  return reviewLimit(pr);
238
240
  }
239
241
  const byScript = depth === "off";
@@ -246,7 +248,8 @@ const ROWS = [
246
248
  source,
247
249
  // The agent may raise a routed depth to full, so auto names the command at any depth.
248
250
  ...(depth === "full" || source === "auto" ? { reviewCommand: policy.reviewCommand } : {}),
249
- ...after
251
+ ...after,
252
+ ...(pr.scope === null ? {} : { paths: pr.scope })
250
253
  }
251
254
  };
252
255
  }
package/dist/pr/read.js CHANGED
@@ -205,16 +205,47 @@ function withCarried(pr) {
205
205
  return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
206
206
  }
207
207
  /**
208
- * `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
209
- * the compare from the last commit a Review read, else from the base, to the head.
208
+ * The files whose own change differs between `from` and the head of `pr`, a rename under both names:
209
+ * what a fix touched, without the files a merge of the base brought in. Null when a compare cannot show
210
+ * the own changes whole.
211
+ */
212
+ function ownScope(pr, from) {
213
+ const before = ownChanges(pr, from);
214
+ const after = ownChanges(pr, pr.head);
215
+ if (before === null || after === null) {
216
+ return null;
217
+ }
218
+ const moved = [
219
+ ...after.filter((change) => !before.includes(change)),
220
+ ...before.filter((change) => !after.includes(change))
221
+ ];
222
+ const names = moved.flatMap((change) => {
223
+ const [path, , previous] = JSON.parse(change);
224
+ return previous === null ? [path] : [path, previous];
225
+ });
226
+ return [...new Set(names)].toSorted();
227
+ }
228
+ /**
229
+ * `pr` and, when a Review awaits a head that carries none, its `scope` after a merge of the base, and
230
+ * when `sized`, the files the Review would read: the compare from the last commit a Review read, else
231
+ * from the base, to the head, kept to the scope when there is one.
210
232
  */
211
233
  function withChanged(pr, sized) {
212
- if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
213
- return { ...pr, changed: null };
234
+ if (pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
235
+ return { ...pr, changed: null, scope: null };
236
+ }
237
+ const scope = pr.lastRead?.merges === true ? ownScope(pr, pr.lastRead.commit) : null;
238
+ if (!sized) {
239
+ return { ...pr, changed: null, scope };
214
240
  }
215
241
  const from = pr.lastRead?.commit ?? pr.baseOid;
216
242
  const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
217
- return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
243
+ const changed = parseOrCannotRun(() => changedOf(reply));
244
+ if (scope === null || changed === "unsized") {
245
+ return { ...pr, changed, scope };
246
+ }
247
+ const within = changed.filter(({ path, previous }) => scope.includes(path) || (previous !== null && scope.includes(previous)));
248
+ return { ...pr, changed: within, scope };
218
249
  }
219
250
  /** The state of pull request `number` of `repo`, from one GraphQL request and the compares a Review needs. */
220
251
  function query({ repo, number }, sized) {
@@ -88,6 +88,11 @@ export type PrState = {
88
88
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
89
89
  */
90
90
  changed: ChangedFile[] | "unsized" | null;
91
+ /**
92
+ * The files whose own change moved since `lastRead`, when a merge of the base came after it: the files
93
+ * a fix touched, the base's own left out. Null with no such merge, or when a compare cannot show them.
94
+ */
95
+ scope: string[] | null;
91
96
  };
92
97
  /**
93
98
  * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
@@ -106,6 +111,7 @@ export type LastRead = {
106
111
  commit: string;
107
112
  clean: boolean;
108
113
  mergesOnly: boolean;
114
+ merges: boolean;
109
115
  };
110
116
  /**
111
117
  * The Reviews that left a Finding open since the last review success, a success under off and the dev's
@@ -144,5 +150,5 @@ export declare function parseView(reply: string): {
144
150
  /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
145
151
  export declare function parseDefaultBranch(reply: string): string;
146
152
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
147
- export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
153
+ export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "scope">;
148
154
  export {};
package/dist/pr/state.js CHANGED
@@ -253,7 +253,12 @@ function lastReadOf(history, head) {
253
253
  if (last === undefined) {
254
254
  return null;
255
255
  }
256
- return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
256
+ return {
257
+ commit: last.oid,
258
+ clean: last.read === "clean",
259
+ mergesOnly: mergesOnlyAfter(index),
260
+ merges: commits.slice(index + 1).some(({ merge }) => merge)
261
+ };
257
262
  }
258
263
  /**
259
264
  * Whether a non-merge commit follows `commit` in the history, oldest first, as `lastReadOf` judges a read
@@ -1,5 +1,7 @@
1
1
  /** `git <args>` at `root`, or a stop naming its problem. */
2
2
  export declare function must(root: string, args: string[]): string;
3
+ /** The local branches of the repository at `root`. */
4
+ export declare function branchesOf(root: string): string[];
3
5
  export type Tree = {
4
6
  path: string;
5
7
  head: string;
@@ -15,3 +17,11 @@ export declare function dirtyFiles(path: string): string[] | {
15
17
  };
16
18
  /** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
17
19
  export declare function pausedOperation(path: string): string | undefined;
20
+ /** The dev's way off `branch`, checked out in the main checkout at `root`: null until it is Landed and nothing there could be lost. */
21
+ export declare function switchOff(root: string, base: {
22
+ name: string;
23
+ ref: string;
24
+ }, branch: string): {
25
+ reason: string;
26
+ command: string;
27
+ } | null;
@@ -1,9 +1,11 @@
1
- // What a Sweep reads of a repository's linked worktrees: where each lies, what it has checked out, its lock,
2
- // the files that hold it and the operation paused in it.
1
+ // What a Sweep reads of a repository's branches and worktrees: where each worktree lies, what it has checked
2
+ // out, its lock, the files that hold it and the operation paused in it, and the way off the main checkout's.
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { exec } from "./exec.js";
6
6
  import { repositoryFreeEnv } from "./git-env.js";
7
+ import { isLanded } from "./landed.js";
8
+ import { shellLine } from "./pr/decide.js";
7
9
  import { CannotRun } from "./pr/read.js";
8
10
  import { asked, git } from "./working-tree.js";
9
11
  /** `git <args>` at `root`, or a stop naming its problem. */
@@ -14,6 +16,12 @@ export function must(root, args) {
14
16
  }
15
17
  return result.out;
16
18
  }
19
+ /** The local branches of the repository at `root`. */
20
+ export function branchesOf(root) {
21
+ return must(root, ["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
22
+ .split("\n")
23
+ .filter((name) => name !== "");
24
+ }
17
25
  /** Every worktree of the repository, the main checkout first. */
18
26
  export function worktreesOf(root) {
19
27
  const trees = [];
@@ -70,3 +78,15 @@ export function pausedOperation(path) {
70
78
  const dir = asked(path, ["rev-parse", "--absolute-git-dir"]);
71
79
  return dir === undefined ? undefined : OPERATIONS.find(([file]) => existsSync(join(dir, file)))?.[1];
72
80
  }
81
+ /** The dev's way off `branch`, checked out in the main checkout at `root`: null until it is Landed and nothing there could be lost. */
82
+ export function switchOff(root, base, branch) {
83
+ const files = dirtyFiles(root);
84
+ if ("error" in files ||
85
+ files.length > 0 ||
86
+ pausedOperation(root) !== undefined ||
87
+ !isLanded(root, base.ref, branch)) {
88
+ return null;
89
+ }
90
+ const command = `${shellLine(["git", "switch", base.name])} && ${shellLine(["git", "pull", "--rebase"])}`;
91
+ return { reason: `checked out in the main checkout at ${root}, Landed on origin/${base.name}`, command };
92
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.18.0",
4
- "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
3
+ "version": "0.20.0",
4
+ "description": "The Gate and the agentic config behind the sdlc Claude Code plugin: a bin with the Gate and its Checks, the setup, the doctor, the Sync of References, the Ship script that drives a pull request to merged, the Sweep, and Lead and Worker sessions in herdr; the resolver of a repository's .ai/agentic.config.json layers and its JSON Schema.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",