dw-sdlc 0.9.0 → 0.10.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
@@ -56,9 +56,12 @@ the delete command for the dev.
56
56
 
57
57
  `pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
58
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.
59
+ agent reviews it under `full`, one `sdlc-reviewer` subagent of the plugin does under `light`, and no
60
+ one does under `off`. Under `auto`, the default, the script routes the depth from the changes:
61
+ lockfiles alone `off`; a workflow file, more than 100 changed lines outside lockfiles, or a change
62
+ too large to size `full`; the rest `light`. The local layer may tighten either and never loosen it;
63
+ `--merge` acts as `auto` and `--review full|light|off` sets the depth for one call. A pull request
64
+ the `gh` account did not open is always the dev's to merge.
62
65
 
63
66
  ```json
64
67
  { "pr": { "merge": "auto", "review": "full" } }
@@ -395,8 +395,8 @@
395
395
  "enum": ["ask", "auto"]
396
396
  },
397
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"]
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 runs the command `pr.reviewCommand` names over the changes. `light`: one `sdlc-reviewer` subagent reads the diff at medium effort. `off`: the script posts the `dw-sdlc/review` success itself and no one reads them. `auto`: built-in rules route the depth from the changes the Review would read: lockfiles alone `off`; a file under `.github/workflows/`, more than 100 changed lines outside lockfiles, or a change the script cannot size, more files than GitHub's compare lists or a file outside lockfiles with no patch, `full`; the rest `light`. Default `auto`; an invalid layer runs `full`. Strictest first: full, auto, light, off.",
399
+ "enum": ["full", "auto", "light", "off"]
400
400
  },
401
401
  "reviewCommand": {
402
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.",
@@ -406,6 +406,27 @@
406
406
  },
407
407
  "additionalProperties": false
408
408
  },
409
+ "worker": {
410
+ "description": "How a Worker's Claude Code session starts: the command, the model and the effort. No strictness order, as with `pr.reviewCommand`: any layer sets any key freely.",
411
+ "type": "object",
412
+ "properties": {
413
+ "command": {
414
+ "description": "The command a Worker starts with: the words that start its Claude Code session, before the flags of its own, such as `--model` and `--effort`. Default `claude`. It belongs to one machine, so it sits in the local layer, where `dw-sdlc setup` writes `cswap run -- --dangerously-skip-permissions` when `cswap` is on PATH.",
415
+ "type": "string",
416
+ "minLength": 1
417
+ },
418
+ "model": {
419
+ "description": "The model a Worker runs on, as Claude Code's `--model` takes it. Default `sonnet`.",
420
+ "type": "string",
421
+ "minLength": 1
422
+ },
423
+ "effort": {
424
+ "description": "The effort a Worker runs at, one of Claude Code's effort levels. Default `high`.",
425
+ "enum": ["low", "medium", "high", "xhigh", "max"]
426
+ }
427
+ },
428
+ "additionalProperties": false
429
+ },
409
430
  "worktree": {
410
431
  "type": "object",
411
432
  "properties": {
@@ -17,8 +17,15 @@ 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";
20
+ /**
21
+ * How closely a Review reads an unreviewed head: the command `pr.reviewCommand` names under `full`,
22
+ * one `sdlc-reviewer` subagent under `light`, no one under `off`.
23
+ */
24
+ export type ReviewDepth = "full" | "light" | "off";
25
+ /** The Review depths, strictest first: what the dev's `--review` names. */
26
+ export declare const REVIEW_DEPTHS: readonly ["full", "light", "off"];
27
+ /** `pr.review`: a Review depth, or `auto`, which routes one from the changes by built-in rules. */
28
+ export type PrReview = ReviewDepth | "auto";
22
29
  /**
23
30
  * How the Ship script ends a pull request: who merges it, how closely it is reviewed, and the slash
24
31
  * command a `full` Review runs.
@@ -28,6 +35,15 @@ export type PrConfig = {
28
35
  review: PrReview;
29
36
  reviewCommand: string;
30
37
  };
38
+ /** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
39
+ declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
40
+ export type WorkerEffort = (typeof WORKER_EFFORTS)[number];
41
+ /** How a Worker's Claude Code session starts: the words that start it, its model and its effort. */
42
+ export type WorkerConfig = {
43
+ command: string;
44
+ model: string;
45
+ effort: WorkerEffort;
46
+ };
31
47
  export type Guardrails = {
32
48
  forcePush: ForcePush;
33
49
  commitOnProtected: CommitOnProtected;
@@ -84,6 +100,7 @@ export type AgenticConfig = {
84
100
  };
85
101
  gate?: GateLayer;
86
102
  pr?: Partial<PrConfig>;
103
+ worker?: Partial<WorkerConfig>;
87
104
  worktree?: {
88
105
  setup?: string[];
89
106
  };
@@ -104,6 +121,7 @@ export type ResolvedConfig = {
104
121
  };
105
122
  gate: Gate;
106
123
  pr: PrConfig;
124
+ worker: WorkerConfig;
107
125
  worktree: {
108
126
  setup: string[];
109
127
  };
@@ -141,13 +159,13 @@ export declare const STRICTNESS: {
141
159
  readonly commitOnProtected: readonly ["deny", "allow"];
142
160
  readonly envFiles: readonly ["deny", "git"];
143
161
  readonly merge: readonly ["ask", "auto"];
144
- readonly review: readonly ["full", "off"];
162
+ readonly review: readonly ["full", "auto", "light", "off"];
145
163
  };
146
164
  /** What the guards run on while any layer is invalid. */
147
165
  export declare const STRICTEST_GUARDRAILS: Guardrails;
148
166
  /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
149
167
  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. */
168
+ /** The Review depth while any layer is invalid: the agent reads every head in full. */
151
169
  export declare const STRICTEST_PR_REVIEW: PrReview;
152
170
  /** The command a `full` Review runs with nothing declared; it has no strictness order. */
153
171
  export declare const DEFAULT_REVIEW_COMMAND = "/code-review";
@@ -11,13 +11,17 @@ export const LOCAL_FILE = ".ai/agentic.config.local.json";
11
11
  export const SCHEMA_FILE = "config/agentic.config.schema.json";
12
12
  export const SCHEMA_PATH = join(import.meta.dirname, "..", SCHEMA_FILE);
13
13
  export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
14
+ /** The Review depths, strictest first: what the dev's `--review` names. */
15
+ export const REVIEW_DEPTHS = ["full", "light", "off"];
16
+ /** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
17
+ const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
14
18
  /** Strictest value first; the local override may only move towards the front. */
15
19
  export const STRICTNESS = {
16
20
  forcePush: ["deny", "ask", "lease"],
17
21
  commitOnProtected: ["deny", "allow"],
18
22
  envFiles: ["deny", "git"],
19
23
  merge: ["ask", "auto"],
20
- review: ["full", "off"]
24
+ review: ["full", "auto", "light", "off"]
21
25
  };
22
26
  /** What the guards run on while any layer is invalid. */
23
27
  export const STRICTEST_GUARDRAILS = {
@@ -28,11 +32,15 @@ export const STRICTEST_GUARDRAILS = {
28
32
  };
29
33
  /** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
30
34
  export const STRICTEST_PR_MERGE = "ask";
31
- /** The Review depth while any layer is invalid, and with nothing declared: the agent reads every head. */
35
+ /** The Review depth while any layer is invalid: the agent reads every head in full. */
32
36
  export const STRICTEST_PR_REVIEW = "full";
33
37
  const STRICTEST_PR = { merge: STRICTEST_PR_MERGE, review: STRICTEST_PR_REVIEW };
34
38
  /** The command a `full` Review runs with nothing declared; it has no strictness order. */
35
39
  export const DEFAULT_REVIEW_COMMAND = "/code-review";
40
+ /** What a repository gets with nothing declared: the merge the dev's, the depth routed from the changes. */
41
+ const DEFAULT_PR = { merge: STRICTEST_PR_MERGE, review: "auto", reviewCommand: DEFAULT_REVIEW_COMMAND };
42
+ /** What a Worker starts with when no layer says otherwise; no key has a strictness order. */
43
+ const DEFAULT_WORKER = { command: "claude", model: "sonnet", effort: "high" };
36
44
  /** What a repository gets with nothing declared. */
37
45
  export const DEFAULT_GUARDRAILS = {
38
46
  forcePush: "lease",
@@ -202,7 +210,8 @@ export function inferConfig(root) {
202
210
  guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
203
211
  commit: { convention: "conventional" },
204
212
  gate: inferGate(root),
205
- pr: { ...STRICTEST_PR, reviewCommand: DEFAULT_REVIEW_COMMAND },
213
+ pr: { ...DEFAULT_PR },
214
+ worker: { ...DEFAULT_WORKER },
206
215
  worktree: { setup: [] }
207
216
  };
208
217
  }
@@ -219,6 +228,7 @@ function applyLayer(base, layer) {
219
228
  commit: { ...base.commit, ...layer.commit },
220
229
  gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
221
230
  pr: { ...base.pr, ...layer.pr },
231
+ worker: { ...base.worker, ...layer.worker },
222
232
  worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
223
233
  };
224
234
  }
@@ -277,7 +287,7 @@ function loosenings(base, local) {
277
287
  }
278
288
  return problems;
279
289
  }
280
- /** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over full. */
290
+ /** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over light over auto over full. */
281
291
  function prLoosenings(base, local) {
282
292
  const keys = ["merge", "review"];
283
293
  return keys.flatMap((key) => {
@@ -323,7 +333,7 @@ export function resolveConfig(root) {
323
333
  }
324
334
  }
325
335
  if (problems.length > 0) {
326
- // `reviewCommand` has no strictest value: it keeps what the valid layers name.
336
+ // `reviewCommand` and `worker` have no strictest value: they keep what the valid layers name.
327
337
  config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { ...config.pr, ...STRICTEST_PR } };
328
338
  }
329
339
  return {
@@ -54,7 +54,7 @@ function parseCli(argv) {
54
54
  }
55
55
  /** Posts the review status on `head` once the read shows it is still the pull request's head. */
56
56
  function post({ number, repo, head }) {
57
- const pr = read({ number, repo }, "pr reviewed");
57
+ const pr = read({ number, repo }, "pr reviewed", false);
58
58
  if (pr.head !== head) {
59
59
  throw new CannotRun(`${pr.repo}#${number}: the head is ${pr.head}, not ${head}: a push landed since the review`, "review the new head, then call again with its sha");
60
60
  }
@@ -2,11 +2,11 @@
2
2
  // needs next, performs it when it is the script's and reads again, then answers the first Action that
3
3
  // is not, as a line or one JSON document, with an exit code that names whose turn it is.
4
4
  import { parseArgs } from "node:util";
5
- import { repositoryRoot, resolveConfig, STRICTNESS } from "../agentic-config.js";
5
+ import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
6
6
  import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
7
- import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
7
+ import { CannotRun, carriesBase, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
8
8
  export const summary = "decide the Action one pull request needs next, as a line or JSON";
9
- const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|off] [--dry-run] [--json] [--verbose]
9
+ const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
10
10
 
11
11
  Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
12
12
  is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
@@ -14,16 +14,39 @@ The script performs its own Actions, each at most once and then reads again: it
14
14
  behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
15
15
  request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
16
16
  under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
17
- gets the Review depth --review names for this call, else pr.review: under full, the default, the
18
- agent reviews it with the command pr.reviewCommand names, /code-review by default; under off the
19
- script posts the success itself, its description saying why.
20
- --dry-run performs nothing: it parks at the first Action the script would perform, listed under
21
- performed. One line, or one document with --json; --verbose adds the state as read to the document
22
- under facts. Needs an authenticated gh.
17
+ gets the Review depth --review names for this call, else pr.review: under full the agent reviews it
18
+ with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
19
+ subagent reads the diff; under off the script posts the success itself, its description saying
20
+ why. Under auto, the default, the script routes the depth from the changes the Review would read:
21
+ lockfiles alone off; a file under .github/workflows/, more than 100 changed lines outside
22
+ lockfiles, or a change it cannot size, more files than GitHub's compare lists or a file outside
23
+ lockfiles with no patch, full; the rest light. After a push it names
24
+ the last reviewed commit under details.since, where the Review reads from and auto routes from. It
25
+ carries that commit's success over a head whose only commits since are merges of the base, unless
26
+ --review names a depth for the call.
27
+ Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
28
+ Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
29
+ false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
30
+ the head carries the base it read. When the read after an Action fails, the call exits 2 and names
31
+ each Action that ran with gh's exit, under performed too with --json: it may have landed, so do not
32
+ run it blind again. --dry-run performs nothing: it parks at the first Action the script would
33
+ perform, under performed with ok null.
34
+ One line, or one document with --json; --verbose adds the state as read to the document under
35
+ facts. Needs an authenticated gh.
23
36
 
24
37
  Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
38
+ /** The depths --review takes, strictest first, as a sentence lists them. */
39
+ const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
25
40
  const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
26
41
  const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
42
+ /** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
43
+ class Interrupted extends CannotRun {
44
+ performed;
45
+ constructor({ message, remedy }, performed) {
46
+ super(message, remedy);
47
+ this.performed = performed;
48
+ }
49
+ }
27
50
  function parseCli(argv) {
28
51
  try {
29
52
  const { values, positionals } = parseArgs({
@@ -41,7 +64,7 @@ function parseCli(argv) {
41
64
  });
42
65
  const [number, ...more] = positionals;
43
66
  const { repo } = values;
44
- const review = STRICTNESS.review.find((depth) => depth === values.review);
67
+ const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
45
68
  if (values.help !== true) {
46
69
  if (more.length > 0) {
47
70
  return { error: `name one pull request, not ${positionals.length}` };
@@ -51,7 +74,7 @@ function parseCli(argv) {
51
74
  return { error: problem };
52
75
  }
53
76
  if (values.review !== undefined && review === undefined) {
54
- return { error: `--review takes ${STRICTNESS.review.join(" or ")}, not ${JSON.stringify(values.review)}` };
77
+ return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
55
78
  }
56
79
  }
57
80
  return {
@@ -92,6 +115,33 @@ const NAMED = {
92
115
  },
93
116
  merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
94
117
  };
118
+ /**
119
+ * Whether the read after a script Action shows it took effect. gh's exit status cannot tell: a reply
120
+ * lost on the way back exits non-zero after GitHub applied the Action.
121
+ */
122
+ const TOOK = {
123
+ // A moved head alone may be a push: the update took effect when the head carries the base it read.
124
+ "update-branch": (before, after) => after.head !== before.head && carriesBase(before, after),
125
+ "mark-ready": (_, after) => !after.draft,
126
+ // The post names the head it read: a head moved since carries none of it.
127
+ "mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
128
+ merge: (_, after) => after.state === "MERGED"
129
+ };
130
+ /**
131
+ * Why an Action that ran comes round again. A head that moved after a mark-reviewed names both heads;
132
+ * otherwise gh refused it, or accepted it and the read shows nothing yet, or the effect is gone again.
133
+ */
134
+ function reasonOf({ action, ok, exit, stderr = "" }, pr, ranOn, { act, still }) {
135
+ if (action === "mark-reviewed" && exit === 0 && ranOn !== undefined && ranOn !== pr.head) {
136
+ return `${act} posted on ${ranOn.slice(0, 7)}, and the head moved to ${pr.head.slice(0, 7)}`;
137
+ }
138
+ if (ok) {
139
+ return `${act} ran, and ${still}`;
140
+ }
141
+ return exit === 0
142
+ ? `gh accepted ${act}; the read does not show it yet`
143
+ : `gh refused ${act}: ${said(stderr) || `gh exited ${exit}`}`;
144
+ }
95
145
  /** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
96
146
  function handedOver(pr, policy, answer, action, reason) {
97
147
  if (action === "merge") {
@@ -104,9 +154,12 @@ function handedOver(pr, policy, answer, action, reason) {
104
154
  * pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
105
155
  */
106
156
  function ship(cli, policy) {
107
- let pr = read(cli, "pr ship");
157
+ // Only pr.review auto routes the depth from the changes, so only it reads them.
158
+ const sized = policy.review.depth === "auto";
159
+ let pr = read(cli, "pr ship", sized);
108
160
  const performed = [];
109
- const refusals = {};
161
+ // The head each Action ran on.
162
+ const postedOn = new Map();
110
163
  for (;;) {
111
164
  const answer = decide(pr, now(), policy);
112
165
  const { action } = answer;
@@ -116,7 +169,7 @@ function ship(cli, policy) {
116
169
  // -R names the repository, so the checkout the script runs in plays no part.
117
170
  const args = hostArgs(pr, action, policy, true);
118
171
  const command = shellLine(["gh", ...args]);
119
- const { would, act, still } = NAMED[action];
172
+ const { would } = NAMED[action];
120
173
  if (cli.dryRun) {
121
174
  performed.push({ action, command, ok: null });
122
175
  return {
@@ -125,17 +178,28 @@ function ship(cli, policy) {
125
178
  performed
126
179
  };
127
180
  }
128
- if (performed.some((done) => done.action === action)) {
129
- const refusal = refusals[action];
130
- const reason = refusal === undefined ? `${act} ran, and ${still}` : `gh refused ${act}: ${refusal}`;
181
+ const done = performed.find((earlier) => earlier.action === action);
182
+ // --dry-run returns before any Action runs, so an earlier one always carries gh's exit.
183
+ if (done !== undefined && done.ok !== null) {
184
+ const reason = reasonOf(done, pr, postedOn.get(action), NAMED[action]);
131
185
  return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
132
186
  }
133
- const result = gh(args);
134
- performed.push({ action, command, ok: result.status === 0 });
135
- if (result.status !== 0) {
136
- refusals[action] = said(result.stderr) || `gh exited ${result.status}`;
187
+ const { status, stderr } = gh(args);
188
+ const before = pr;
189
+ const message = stderr.trim();
190
+ const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
191
+ postedOn.set(action, before.head);
192
+ try {
193
+ pr = readAgain(pr, sized);
194
+ performed.push({ ...record, ok: TOOK[action](before, pr) });
195
+ }
196
+ catch (error) {
197
+ if (error instanceof CannotRun) {
198
+ // The Action may have landed: the caller must not run it blind again.
199
+ throw new Interrupted(error, [...performed, { ...record, ok: null }]);
200
+ }
201
+ throw error;
137
202
  }
138
- pr = readAgain(pr);
139
203
  }
140
204
  }
141
205
  /** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
@@ -171,7 +235,12 @@ export function run(argv) {
171
235
  }
172
236
  catch (error) {
173
237
  if (error instanceof CannotRun) {
174
- process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n`);
238
+ const ran = error instanceof Interrupted ? error.performed : [];
239
+ const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
240
+ process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
241
+ if (cli.json && ran.length > 0) {
242
+ process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
243
+ }
175
244
  return 2;
176
245
  }
177
246
  throw error;
@@ -21,6 +21,8 @@ export type SetupOptions = {
21
21
  commitOnProtected?: CommitOnProtected | undefined;
22
22
  /** The owner's yes to pinning dw-sdlc as a devDependency where package.json lacks it. */
23
23
  addPackage?: boolean;
24
+ /** The PATH the setup looks for `cswap` on; `$PATH` when absent. */
25
+ path?: string | undefined;
24
26
  };
25
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";
26
28
  /**
@@ -11,6 +11,7 @@ import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCH
11
11
  import { isObject, parseObject, render } from "../json-text.js";
12
12
  import { withGate, withPackage } from "../setup-gate.js";
13
13
  import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
14
+ import { keptWorker, pinnedWorker } from "../setup-worker.js";
14
15
  export const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
15
16
  const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
16
17
  [--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
@@ -25,7 +26,9 @@ written only when it would change; --convention ticket-key | conventional pins c
25
26
  the local config, and a run without it keeps the pin as it is; a guardrails block in the local config
26
27
  is kept, and --force-push and --commit-on-protected pin those two keys in it, refused where they would
27
28
  loosen what the committed config sets; --force overwrites a local config that differs in anything
28
- else; --dry-run reports without writing.
29
+ else; --dry-run reports without writing. Where cswap is on PATH, the local config also gets
30
+ worker.command, the command a Worker starts with: cswap run -- --dangerously-skip-permissions. A
31
+ worker block the file holds is kept as it is, and without cswap nothing is written.
29
32
 
30
33
  Where the repository has a package.json, the setup adopts the Gate. --add-package pins dw-sdlc
31
34
  there as a devDependency, at the version running the setup; a new dependency needs the owner's yes,
@@ -99,12 +102,13 @@ function keptGuardrails(existing) {
99
102
  }
100
103
  /**
101
104
  * A layer without the keys the setup owns: `$schema`, which moves with the clone, a pinned
102
- * convention, which `--convention` may replace, and a `guardrails` object, which the setup carries
103
- * and the flags pin keys in. Everything left is someone's content.
105
+ * convention, which `--convention` may replace, and a `guardrails` and a `worker` object, which the
106
+ * setup carries and adds keys to. Everything left is someone's content.
104
107
  */
105
108
  function unowned(layer) {
106
- const { $schema: _schema, commit, guardrails, ...other } = layer;
107
- const rest = guardrails === undefined || isObject(guardrails) ? other : { ...other, guardrails };
109
+ const { $schema: _schema, commit, ...other } = layer;
110
+ const blocks = ["guardrails", "worker"];
111
+ const rest = Object.fromEntries(Object.entries(other).filter(([key, value]) => !blocks.includes(key) || !isObject(value)));
108
112
  if (!isObject(commit) || pinnedConvention(layer) === undefined) {
109
113
  return commit === undefined ? rest : { ...rest, commit };
110
114
  }
@@ -144,9 +148,9 @@ function sameContent(existing, config) {
144
148
  }
145
149
  /**
146
150
  * The machine's layer: what inference finds, minus every key the committed config already sets, which an
147
- * inferred key here would override, plus the `guardrails` block the file holds and the convention asked for.
151
+ * inferred key here would override, plus the `guardrails` and `worker` blocks and the convention asked for.
148
152
  */
149
- function localConfig(root, claudeDir, convention, guardrails) {
153
+ function localConfig(root, claudeDir, convention, guardrails, worker) {
150
154
  const inferred = inferConfig(root);
151
155
  const committed = committedConfig(root);
152
156
  const config = { $schema: schemaReference(claudeDir) };
@@ -160,6 +164,9 @@ function localConfig(root, claudeDir, convention, guardrails) {
160
164
  if (guardrails !== undefined) {
161
165
  config.guardrails = guardrails;
162
166
  }
167
+ if (worker !== undefined) {
168
+ config.worker = worker;
169
+ }
163
170
  // Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
164
171
  // pinning it here would override a convention the repository commits later.
165
172
  if (convention !== undefined) {
@@ -255,12 +262,13 @@ export function setup(options) {
255
262
  writer.refuse(LOCAL_FILE, pinned.refusal);
256
263
  }
257
264
  else {
258
- const local = localConfig(checkout, claudeDir, convention, pinned.guardrails);
265
+ const { worker, detail } = pinnedWorker(keptWorker(existing), options.path ?? process.env.PATH);
266
+ const local = localConfig(checkout, claudeDir, convention, pinned.guardrails, worker);
259
267
  if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
260
268
  writer.refuse(LOCAL_FILE, "exists with other content; pass --force to overwrite it");
261
269
  }
262
270
  else {
263
- writer.put(LOCAL_FILE, localPath, render(local));
271
+ writer.put(LOCAL_FILE, localPath, render(local), detail);
264
272
  }
265
273
  }
266
274
  // 2. The exclude entries, appended to whatever the file holds: they keep both local files out of git
@@ -1,21 +1,28 @@
1
- import type { PrMerge, PrReview } from "../agentic-config.ts";
2
- import type { PrState } from "./state.ts";
1
+ import type { PrMerge, PrReview, ReviewDepth } from "../agentic-config.ts";
2
+ import { type PrState, REVIEW } from "./state.ts";
3
+ export { REVIEW };
3
4
  export type Status = "act" | "wait" | "park" | "done";
4
5
  export type Actor = "script" | "agent" | "dev";
5
6
  export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
6
7
  /** The Actions the script performs itself, each one gh command. */
7
8
  export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
8
- /** Where a Review depth comes from: the dev's `--review` for this call, or `pr.review`. */
9
- export type DepthSource = "flag" | "config";
10
9
  /**
11
- * What the decision reads beside the pull request: who merges it, how closely a Review reads it, and
12
- * the slash command a `full` Review runs.
10
+ * Where a Review depth comes from: the dev's `--review` for this call, a depth `pr.review` sets, or the
11
+ * rules `pr.review: auto` routes it by.
12
+ */
13
+ export type DepthSource = "flag" | "config" | "auto";
14
+ /**
15
+ * What the decision reads beside the pull request: who merges it, how closely a Review reads it, the
16
+ * dev's word or `pr.review`, and the slash command a `full` Review runs.
13
17
  */
14
18
  export type Policy = {
15
19
  merge: PrMerge;
16
20
  review: {
21
+ depth: ReviewDepth;
22
+ source: "flag";
23
+ } | {
17
24
  depth: PrReview;
18
- source: DepthSource;
25
+ source: "config";
19
26
  };
20
27
  reviewCommand: string;
21
28
  };
@@ -34,8 +41,6 @@ export type Answer = Blocker & {
34
41
  recheckAfter?: number;
35
42
  blockers: Blocker[];
36
43
  };
37
- /** The commit status a clean review of the head leaves on it. */
38
- export declare const REVIEW = "dw-sdlc/review";
39
44
  /** Its one state: a failure or a pending status would turn the pull request unstable. */
40
45
  export declare const REVIEW_STATE = "success";
41
46
  /** The words as one POSIX shell command line, each word that needs it single-quoted. */
package/dist/pr/decide.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // The decision of the Ship script: one ordered list of rows, and the first row whose `when` holds for
2
2
  // the pull request answers its Action at the time given; every later row that holds rides in its
3
3
  // blockers. The last rows say who merges what no row before them holds back.
4
+ import { route } from "./route.js";
5
+ import { awaitsReview, OFF_PREFIX, REVIEW } from "./state.js";
6
+ export { REVIEW };
4
7
  /** How long a head may carry no check before it parks as unverified, from its commit. */
5
8
  const GRACE_SECONDS = 120;
6
9
  /** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
7
10
  const BUDGET_MINUTES = 40;
8
11
  /** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
9
12
  const OWN_PREFIX = "dw-sdlc/";
10
- /** The commit status a clean review of the head leaves on it. */
11
- export const REVIEW = "dw-sdlc/review";
12
13
  /** Its one state: a failure or a pending status would turn the pull request unstable. */
13
14
  export const REVIEW_STATE = "success";
14
15
  /** The words as one POSIX shell command line, each word that needs it single-quoted. */
@@ -44,15 +45,49 @@ export function reviewStatusArgs(pr, description) {
44
45
  const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
45
46
  return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
46
47
  }
47
- /** Why a head no one reads carries the success: the dev's word or the config. */
48
- function offDescription(policy) {
49
- return `off: ${policy.review.source === "flag" ? "--review off" : "pr.review"}`;
48
+ /** Whether the dev named a depth for a head that would carry the status: their word beats the carry. */
49
+ function flagOverCarry(pr, { review }) {
50
+ return pr.carried && review.source === "flag";
51
+ }
52
+ /** The reviewed commit whose success the head carries, if it does and the dev named no depth. */
53
+ function carriedFrom(pr, policy) {
54
+ return pr.carried && !flagOverCarry(pr, policy) ? (pr.lastReviewed?.commit ?? null) : null;
55
+ }
56
+ /**
57
+ * The commit the Review reads from: the last reviewed one. Over a carry the dev's depth reads the whole
58
+ * diff, since the changes after that commit are only the base's.
59
+ */
60
+ function sinceOf(pr, policy) {
61
+ return flagOverCarry(pr, policy) ? null : (pr.lastReviewed?.commit ?? null);
62
+ }
63
+ /** The depth a Review reads the head at: the dev's or the one `pr.review` sets, else the one the rules route. */
64
+ function depthOf(pr, { review }) {
65
+ const { depth, source } = review;
66
+ if (depth === "off") {
67
+ return { depth, source, why: source === "flag" ? "--review off" : "pr.review" };
68
+ }
69
+ if (depth !== "auto") {
70
+ return { depth, source };
71
+ }
72
+ return { ...route(pr.changed), source: "auto" };
73
+ }
74
+ /** Why the head carries the success the script posts: the Review it carries, else why its depth is off. */
75
+ function markedDescription(pr, policy) {
76
+ const carried = carriedFrom(pr, policy);
77
+ if (carried !== null) {
78
+ return `carried from ${carried.slice(0, 7)}`;
79
+ }
80
+ const depth = depthOf(pr, policy);
81
+ if (depth.depth !== "off") {
82
+ throw new Error(`the script marks ${pr.url} reviewed at ${depth.depth}, neither off nor a carry`);
83
+ }
84
+ return `${OFF_PREFIX} ${depth.why}`;
50
85
  }
51
86
  /** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
52
87
  const HOST = {
53
88
  "update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
54
89
  "mark-ready": (pr, repo) => ["pr", "ready", String(pr.number), ...repo],
55
- "mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, offDescription(policy)),
90
+ "mark-reviewed": (pr, _, policy) => reviewStatusArgs(pr, markedDescription(pr, policy)),
56
91
  merge: (pr, repo) => ["pr", "merge", String(pr.number), ...repo, "--squash", "--match-head-commit", pr.head]
57
92
  };
58
93
  export function isScriptAction(action) {
@@ -141,17 +176,29 @@ const ROWS = [
141
176
  answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
142
177
  },
143
178
  {
144
- // The dev merges another author's pull request, so it needs no Review. Under off no one reads
145
- // the head: the script posts the success itself.
146
- when: (pr) => pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
147
- answer: (pr, _, { review, reviewCommand }) => {
148
- const off = review.depth === "off";
179
+ // Another author's pull request needs no Review: the dev merges it. The script posts the success
180
+ // under off, and over a carry, which reads at no depth.
181
+ when: awaitsReview,
182
+ answer: (pr, _, policy) => {
183
+ const since = sinceOf(pr, policy);
184
+ const head = { status: "act", reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}` };
185
+ const after = since === null ? {} : { since };
186
+ if (carriedFrom(pr, policy) !== null) {
187
+ return { ...head, action: "mark-reviewed", actor: "script", details: after };
188
+ }
189
+ const { depth, source } = depthOf(pr, policy);
190
+ const byScript = depth === "off";
149
191
  return {
150
- status: "act",
151
- action: off ? "mark-reviewed" : "review",
152
- actor: off ? "script" : "agent",
153
- reason: `no ${REVIEW} success on ${pr.head.slice(0, 7)}`,
154
- details: { ...review, ...(review.depth === "full" ? { reviewCommand } : {}) }
192
+ ...head,
193
+ action: byScript ? "mark-reviewed" : "review",
194
+ actor: byScript ? "script" : "agent",
195
+ details: {
196
+ depth,
197
+ source,
198
+ // The agent may raise a routed depth to full, so auto names the command at any depth.
199
+ ...(depth === "full" || source === "auto" ? { reviewCommand: policy.reviewCommand } : {}),
200
+ ...after
201
+ }
155
202
  };
156
203
  }
157
204
  },
@@ -0,0 +1,11 @@
1
+ import { type ChangedFile } from "./state.ts";
2
+ /** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
3
+ export declare function compareArgs(repo: string, from: string, commit: string): string[];
4
+ /** How many commits of the compare's base its head lacks: 0 when the head carries all of it. */
5
+ export declare function behindOf(reply: string): number;
6
+ /** The own changes a compare reply lists, file by file; null when it cannot show them whole. */
7
+ export declare function changesOf(reply: string): string[] | null;
8
+ /** The files a compare reply lists with their changed lines; `unsized` when it may hold some back. */
9
+ export declare function changedOf(reply: string): ChangedFile[] | "unsized";
10
+ /** Whether the own changes at the head are those at the reviewed commit; unknown changes never are. */
11
+ export declare function sameChanges(then: string[] | null, now: string[] | null): boolean;
@@ -0,0 +1,60 @@
1
+ // GitHub's compare of two commits, read two ways: whether a head carries the Review of an earlier commit,
2
+ // the pull request's own changes from the base the same at both, since a merge of the base in adds none
3
+ // of its own; and the files a Review would read, with their changed lines, for `pr.review: auto`.
4
+ import { fieldOf, integer, object, ReplyError, rootOf, shown, string } from "./state.js";
5
+ /** GitHub's compare lists at most this many files; one that lists as many may hold back the rest. */
6
+ const FILE_LIMIT = 300;
7
+ /** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
8
+ export function compareArgs(repo, from, commit) {
9
+ return ["api", `repos/${repo}/compare/${from}...${commit}?per_page=1`];
10
+ }
11
+ /** The files a compare reply lists. */
12
+ function filesOf(reply) {
13
+ const { value, at } = fieldOf(rootOf(reply, "the compare"), "files");
14
+ if (!Array.isArray(value)) {
15
+ throw new ReplyError(`${at} is ${shown(value)}, not a list`);
16
+ }
17
+ return value.map((item, index) => object(item, `${at}[${index}]`));
18
+ }
19
+ /** How many commits of the compare's base its head lacks: 0 when the head carries all of it. */
20
+ export function behindOf(reply) {
21
+ return integer(rootOf(reply, "the compare"), "behind_by");
22
+ }
23
+ /** A field the compare may leave out, as a file's patch or its previous name. */
24
+ function optional(file, key) {
25
+ return file.value[key] === undefined || file.value[key] === null ? null : string(file, key);
26
+ }
27
+ /**
28
+ * A file's own change: its status, its names and the lines its patch adds and removes, in order,
29
+ * without the hunk headers and context a moved base shifts; null when the compare shows no patch.
30
+ */
31
+ function changeOf(file) {
32
+ const names = [string(file, "filename"), string(file, "status"), optional(file, "previous_filename")];
33
+ const patch = optional(file, "patch");
34
+ if (patch === null) {
35
+ return null;
36
+ }
37
+ return JSON.stringify([...names, patch.split("\n").filter((line) => /^[-+\\]/.test(line))]);
38
+ }
39
+ /** The own changes a compare reply lists, file by file; null when it cannot show them whole. */
40
+ export function changesOf(reply) {
41
+ const changes = filesOf(reply).map(changeOf);
42
+ const whole = changes.filter((change) => change !== null);
43
+ return changes.length >= FILE_LIMIT || whole.length < changes.length ? null : whole;
44
+ }
45
+ /** The files a compare reply lists with their changed lines; `unsized` when it may hold some back. */
46
+ export function changedOf(reply) {
47
+ const files = filesOf(reply).map((file) => ({
48
+ path: string(file, "filename"),
49
+ previous: optional(file, "previous_filename"),
50
+ lines: optional(file, "patch") === null ? null : integer(file, "additions") + integer(file, "deletions")
51
+ }));
52
+ return files.length >= FILE_LIMIT ? "unsized" : files;
53
+ }
54
+ /** Whether the own changes at the head are those at the reviewed commit; unknown changes never are. */
55
+ export function sameChanges(then, now) {
56
+ if (then === null || now === null) {
57
+ return false;
58
+ }
59
+ return JSON.stringify(then.toSorted()) === JSON.stringify(now.toSorted());
60
+ }
package/dist/pr/read.d.ts CHANGED
@@ -18,7 +18,12 @@ export declare function gh(args: string[]): {
18
18
  stdout: string;
19
19
  stderr: string;
20
20
  };
21
- /** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
22
- export declare function read(named: Named, command: string): PrState;
21
+ /**
22
+ * The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
23
+ * `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
24
+ */
25
+ export declare function read(named: Named, command: string, sized: boolean): PrState;
23
26
  /** The state of `pr` once more, after an action the script performed on it: the same pull request. */
24
- export declare function readAgain(pr: PrState): PrState;
27
+ export declare function readAgain(pr: PrState, sized: boolean): PrState;
28
+ /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
29
+ export declare function carriesBase(before: PrState, after: PrState): boolean;
package/dist/pr/read.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // The read both pr commands make: gh as a child process, its auth on the host gh api reads from, the
2
- // pull request a command names, and that pull request's state from one GraphQL request.
2
+ // pull request a command names, and that pull request's state from one GraphQL request, with the two
3
+ // compares that tell whether its head carries an earlier Review and the one that sizes its changes.
3
4
  import { spawnSync } from "node:child_process";
4
- import { parsePr, parseView, QUERY, ReplyError } from "./state.js";
5
+ import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
6
+ import { awaitsReview, parsePr, parseView, QUERY, ReplyError } from "./state.js";
5
7
  /** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
6
8
  export class CannotRun extends Error {
7
9
  remedy;
@@ -74,22 +76,66 @@ function targetOf({ number, repo }, command) {
74
76
  }
75
77
  return parseOrCannotRun(() => parseView(view.stdout));
76
78
  }
77
- /** The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. */
78
- export function read(named, command) {
79
+ /**
80
+ * The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
81
+ * `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
82
+ */
83
+ export function read(named, command, sized) {
79
84
  // The host gh api reads from: an account on any other host has no say in this call.
80
85
  const host = process.env.GH_HOST?.trim() || "github.com";
81
86
  const auth = gh(["auth", "status", "--active", "--hostname", host]);
82
87
  if (auth.status !== 0) {
83
88
  throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
84
89
  }
85
- return query(targetOf(named, command));
90
+ return query(targetOf(named, command), sized);
86
91
  }
87
92
  /** The state of `pr` once more, after an action the script performed on it: the same pull request. */
88
- export function readAgain(pr) {
89
- return query(pr);
93
+ export function readAgain(pr, sized) {
94
+ return query(pr, sized);
90
95
  }
91
- /** The state of pull request `number` of `repo`, from one GraphQL request. */
92
- function query({ repo, number }) {
96
+ /** The reply of the compare from `from` to `commit`; `what` names it when gh refuses it. */
97
+ function compared(pr, from, commit, what) {
98
+ const reply = gh(compareArgs(pr.repo, from, commit));
99
+ if (reply.status !== 0) {
100
+ throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
101
+ }
102
+ return reply.stdout;
103
+ }
104
+ /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
105
+ export function carriesBase(before, after) {
106
+ const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
107
+ return parseOrCannotRun(() => behindOf(reply)) === 0;
108
+ }
109
+ /** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
110
+ function ownChanges(pr, commit) {
111
+ const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
112
+ return parseOrCannotRun(() => changesOf(reply));
113
+ }
114
+ /**
115
+ * `pr` and whether its head carries the last reviewed commit's status: only merges since it, and the
116
+ * same own changes at both. The compares run only when a Review awaits the head.
117
+ */
118
+ function withCarried(pr) {
119
+ const last = pr.lastReviewed;
120
+ if (last === null || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
121
+ return { ...pr, carried: false };
122
+ }
123
+ return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
124
+ }
125
+ /**
126
+ * `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
127
+ * the compare from the last reviewed commit, else from the base, to the head.
128
+ */
129
+ function withChanged(pr, sized) {
130
+ if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
131
+ return { ...pr, changed: null };
132
+ }
133
+ const from = pr.lastReviewed?.commit ?? pr.baseOid;
134
+ const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
135
+ return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
136
+ }
137
+ /** The state of pull request `number` of `repo`, from one GraphQL request and the compares a Review needs. */
138
+ function query({ repo, number }, sized) {
93
139
  const [owner = "", name = ""] = repo.split("/");
94
140
  const reply = gh([
95
141
  "api",
@@ -108,5 +154,5 @@ function query({ repo, number }) {
108
154
  const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
109
155
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
110
156
  }
111
- return parseOrCannotRun(() => parsePr(reply.stdout, repo));
157
+ return withChanged(withCarried(parseOrCannotRun(() => parsePr(reply.stdout, repo))), sized);
112
158
  }
@@ -0,0 +1,14 @@
1
+ import type { ReviewDepth } from "../agentic-config.ts";
2
+ import type { ChangedFile } from "./state.ts";
3
+ /** A routed depth, and under off why no one reads the changes, as the status the script posts says. */
4
+ export type Routed = {
5
+ depth: Exclude<ReviewDepth, "off">;
6
+ } | {
7
+ depth: "off";
8
+ why: string;
9
+ };
10
+ /**
11
+ * The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
12
+ * outside lockfiles the compare shows no patch for.
13
+ */
14
+ export declare function route(changed: ChangedFile[] | "unsized" | null): Routed;
@@ -0,0 +1,52 @@
1
+ // The built-in rules `pr.review: auto` routes a Review depth by, from the files the Review would read:
2
+ // lockfiles alone are off; a workflow file, more than 100 changed lines outside lockfiles, or a change
3
+ // the compare cannot size is full; the rest is light. No config key moves them.
4
+ /** The lockfiles a package manager writes, by file name in any directory. */
5
+ const LOCKFILES = new Set([
6
+ "pnpm-lock.yaml",
7
+ "package-lock.json",
8
+ "npm-shrinkwrap.json",
9
+ "yarn.lock",
10
+ "bun.lock",
11
+ "bun.lockb",
12
+ "Gemfile.lock",
13
+ "Cargo.lock",
14
+ "poetry.lock",
15
+ "uv.lock",
16
+ "Pipfile.lock",
17
+ "composer.lock",
18
+ "go.sum",
19
+ "Podfile.lock",
20
+ "pubspec.lock",
21
+ "mix.lock",
22
+ "flake.lock"
23
+ ]);
24
+ /** Where GitHub Actions reads its workflows: a change there runs with the repository's secrets. */
25
+ const WORKFLOWS = ".github/workflows/";
26
+ /** The most changed lines outside lockfiles a light Review reads. */
27
+ const LINE_LIMIT = 100;
28
+ function isLockfile(path) {
29
+ return LOCKFILES.has(path.slice(path.lastIndexOf("/") + 1));
30
+ }
31
+ function isWorkflow({ path, previous }) {
32
+ return [path, previous].some((name) => name?.startsWith(WORKFLOWS) === true);
33
+ }
34
+ /**
35
+ * The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
36
+ * outside lockfiles the compare shows no patch for.
37
+ */
38
+ export function route(changed) {
39
+ if (changed === null || changed === "unsized") {
40
+ return { depth: "full" };
41
+ }
42
+ const code = changed.filter((file) => !isLockfile(file.path));
43
+ if (changed.length > 0 && code.length === 0) {
44
+ return { depth: "off", why: "lockfiles only" };
45
+ }
46
+ const lines = code.map((file) => file.lines);
47
+ if (changed.some(isWorkflow) || lines.includes(null)) {
48
+ return { depth: "full" };
49
+ }
50
+ const total = lines.reduce((sum, count) => sum + (count ?? 0), 0);
51
+ return { depth: total > LINE_LIMIT ? "full" : "light" };
52
+ }
@@ -1,4 +1,8 @@
1
- export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n }\n }\n}";
1
+ /** The commit status a clean review of the head leaves on it. */
2
+ export declare const REVIEW = "dw-sdlc/review";
3
+ /** How the description of a success the script posts under off begins: no one read that head. */
4
+ export declare const OFF_PREFIX = "off:";
5
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
2
6
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
3
7
  export type CiCheck = {
4
8
  name: string;
@@ -36,19 +40,61 @@ export type PrState = {
36
40
  headCommittedAt: string;
37
41
  branch: string;
38
42
  base: string;
43
+ /** The commit the base branch points at. */
44
+ baseOid: string;
39
45
  mergeCommit: string | null;
40
46
  checks: CiCheck[];
41
47
  /** The threads neither resolved nor outdated. */
42
48
  unresolvedThreads: Thread[];
49
+ /** The last commit before the head with a review success not posted under off, of the last 100. */
50
+ lastReviewed: LastReviewed | null;
51
+ /** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
52
+ carried: boolean;
53
+ /**
54
+ * The files a Review would read, from `lastReviewed` or the base to the head, read only for `pr.review:
55
+ * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
56
+ */
57
+ changed: ChangedFile[] | "unsized" | null;
58
+ };
59
+ /**
60
+ * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
61
+ * null when the compare shows no patch for it, as for a binary file.
62
+ */
63
+ export type ChangedFile = {
64
+ path: string;
65
+ previous: string | null;
66
+ lines: number | null;
67
+ };
68
+ /** A reviewed commit, and whether every commit after it, the head included, is a merge. */
69
+ export type LastReviewed = {
70
+ commit: string;
71
+ mergesOnly: boolean;
43
72
  };
44
73
  /** A reply that is not the shape the query asks for; the message names the field. */
45
74
  export declare class ReplyError extends Error {
46
75
  }
76
+ /** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
77
+ export type Node = {
78
+ value: Record<string, unknown>;
79
+ at: string;
80
+ };
81
+ export declare function shown(value: unknown): string;
82
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
83
+ export declare function rootOf(reply: string, what: string): Node;
84
+ export declare function fieldOf(node: Node, key: string): {
85
+ value: unknown;
86
+ at: string;
87
+ };
88
+ export declare function object(value: unknown, at: string): Node;
89
+ export declare function string(node: Node, key: string): string;
90
+ export declare function integer(node: Node, key: string): number;
91
+ /** The viewer's own pull request without a review success on its head: the one a Review reads. */
92
+ export declare function awaitsReview(pr: Pick<PrState, "viewerDidAuthor" | "checks">): boolean;
47
93
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
48
94
  export declare function parseView(reply: string): {
49
95
  repo: string;
50
96
  number: number;
51
97
  };
52
98
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
53
- export declare function parsePr(reply: string, repo: string): PrState;
99
+ export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
54
100
  export {};
package/dist/pr/state.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
2
2
  // state the decision reads. Every field the state needs is checked; a reply that fails names the field.
3
+ /** The commit status a clean review of the head leaves on it. */
4
+ export const REVIEW = "dw-sdlc/review";
5
+ /** How the description of a success the script posts under off begins: no one read that head. */
6
+ export const OFF_PREFIX = "off:";
3
7
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
4
8
  repository(owner: $owner, name: $name) {
5
9
  pullRequest(number: $number) {
6
- url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
10
+ url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
7
11
  viewerDidAuthor mergeCommit { oid }
8
12
  reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
9
13
  comments(first: 1) { nodes { author { login } body } }
@@ -12,6 +16,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
12
16
  contexts(first: 50) { nodes {
13
17
  ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
14
18
  ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
19
+ history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
15
20
  }
16
21
  }
17
22
  }`;
@@ -24,11 +29,11 @@ export class ReplyError extends Error {
24
29
  function isRecord(value) {
25
30
  return typeof value === "object" && value !== null && !Array.isArray(value);
26
31
  }
27
- function shown(value) {
32
+ export function shown(value) {
28
33
  return value === undefined ? "missing" : JSON.stringify(value);
29
34
  }
30
35
  /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
31
- function rootOf(reply, what) {
36
+ export function rootOf(reply, what) {
32
37
  let json;
33
38
  try {
34
39
  json = JSON.parse(reply);
@@ -41,10 +46,10 @@ function rootOf(reply, what) {
41
46
  }
42
47
  return { value: json, at: "" };
43
48
  }
44
- function fieldOf(node, key) {
49
+ export function fieldOf(node, key) {
45
50
  return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
46
51
  }
47
- function object(value, at) {
52
+ export function object(value, at) {
48
53
  if (!isRecord(value)) {
49
54
  throw new ReplyError(`${at} is ${shown(value)}, not an object`);
50
55
  }
@@ -58,7 +63,7 @@ function childOrNull(node, key) {
58
63
  const { value, at } = fieldOf(node, key);
59
64
  return value === null ? null : object(value, at);
60
65
  }
61
- function string(node, key) {
66
+ export function string(node, key) {
62
67
  const { value, at } = fieldOf(node, key);
63
68
  if (typeof value !== "string") {
64
69
  throw new ReplyError(`${at} is ${shown(value)}, not a string`);
@@ -75,7 +80,7 @@ function boolean(node, key) {
75
80
  }
76
81
  return value;
77
82
  }
78
- function integer(node, key) {
83
+ export function integer(node, key) {
79
84
  const { value, at } = fieldOf(node, key);
80
85
  if (typeof value !== "number" || !Number.isInteger(value)) {
81
86
  throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
@@ -182,6 +187,31 @@ function threadOf(thread) {
182
187
  lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
183
188
  };
184
189
  }
190
+ /** The viewer's own pull request without a review success on its head: the one a Review reads. */
191
+ export function awaitsReview(pr) {
192
+ return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
193
+ }
194
+ /** A commit whose review success says someone read it: a status posted under off never does. */
195
+ function isReviewed(commit) {
196
+ const status = childOrNull(commit, "status");
197
+ const context = status === null ? null : childOrNull(status, "context");
198
+ if (context === null || string(context, "state") !== "SUCCESS") {
199
+ return false;
200
+ }
201
+ return !(stringOrNull(context, "description") ?? "").startsWith(OFF_PREFIX);
202
+ }
203
+ /** The last commit before `head` that `isReviewed` takes, the history oldest first. */
204
+ function lastReviewedOf(pr, head) {
205
+ const commits = nodes(pr, "history").map((node) => child(node, "commit"));
206
+ const index = commits.findLastIndex((commit) => string(commit, "oid") !== head && isReviewed(commit));
207
+ const reviewed = commits[index];
208
+ if (reviewed === undefined) {
209
+ return null;
210
+ }
211
+ const after = commits.slice(index + 1);
212
+ const mergesOnly = after.every((commit) => integer(child(commit, "parents"), "totalCount") > 1);
213
+ return { commit: string(reviewed, "oid"), mergesOnly };
214
+ }
185
215
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
186
216
  export function parseView(reply) {
187
217
  const url = string(rootOf(reply, "the reply of gh pr view"), "url");
@@ -197,6 +227,7 @@ export function parsePr(reply, repo) {
197
227
  const commit = child(firstNode(pr, "commits"), "commit");
198
228
  const rollup = childOrNull(commit, "statusCheckRollup");
199
229
  const mergeCommit = childOrNull(pr, "mergeCommit");
230
+ const head = string(pr, "headRefOid");
200
231
  return {
201
232
  url: string(pr, "url"),
202
233
  repo,
@@ -207,14 +238,16 @@ export function parsePr(reply, repo) {
207
238
  mergeStateStatus: string(pr, "mergeStateStatus"),
208
239
  reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
209
240
  viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
210
- head: string(pr, "headRefOid"),
241
+ head,
211
242
  headCommittedAt: time(commit, "committedDate"),
212
243
  branch: string(pr, "headRefName"),
213
244
  base: string(pr, "baseRefName"),
245
+ baseOid: string(pr, "baseRefOid"),
214
246
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
215
247
  checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
216
248
  unresolvedThreads: nodes(pr, "reviewThreads")
217
249
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
218
- .map(threadOf)
250
+ .map(threadOf),
251
+ lastReviewed: lastReviewedOf(pr, head)
219
252
  };
220
253
  }
@@ -0,0 +1,11 @@
1
+ import { type JsonObject } from "./json-text.ts";
2
+ /** The `worker` block a local file holds, carried through on every run, `--force` included. */
3
+ export declare function keptWorker(existing: unknown): JsonObject | undefined;
4
+ /**
5
+ * The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
6
+ * is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
7
+ */
8
+ export declare function pinnedWorker(kept: JsonObject | undefined, pathVariable: string | undefined): {
9
+ worker: JsonObject | undefined;
10
+ detail?: string;
11
+ };
@@ -0,0 +1,37 @@
1
+ // The `worker` block of the machine's local layer: the command a Worker starts with, which the setup
2
+ // writes once when `cswap` is on PATH so no prompt ever carries it.
3
+ import { accessSync, constants, statSync } from "node:fs";
4
+ import { delimiter, join } from "node:path";
5
+ import { isObject } from "./json-text.js";
6
+ /** The command that starts a Worker on the account `cswap` maps to the repository, prompts bypassed. */
7
+ const CSWAP_WORKER_COMMAND = "cswap run -- --dangerously-skip-permissions";
8
+ /** Whether an executable file `name` sits in a directory of `pathVariable`, a PATH as the shell reads it. */
9
+ function onPath(name, pathVariable) {
10
+ return (pathVariable ?? "").split(delimiter).some((dir) => {
11
+ if (dir === "") {
12
+ return false;
13
+ }
14
+ try {
15
+ const file = join(dir, name);
16
+ accessSync(file, constants.X_OK);
17
+ return statSync(file).isFile();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ });
23
+ }
24
+ /** The `worker` block a local file holds, carried through on every run, `--force` included. */
25
+ export function keptWorker(existing) {
26
+ return isObject(existing) && isObject(existing.worker) ? existing.worker : undefined;
27
+ }
28
+ /**
29
+ * The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
30
+ * is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
31
+ */
32
+ export function pinnedWorker(kept, pathVariable) {
33
+ if ((kept !== undefined && "command" in kept) || !onPath("cswap", pathVariable)) {
34
+ return { worker: kept };
35
+ }
36
+ return { worker: { ...kept, command: CSWAP_WORKER_COMMAND }, detail: `worker.command: ${CSWAP_WORKER_COMMAND}` };
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
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.",
5
5
  "license": "MIT",
6
6
  "repository": {