dw-sdlc 0.9.0 → 0.11.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" } }
@@ -83,6 +86,7 @@ script posts that status itself, its description saying why, and goes on.
83
86
  | `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
84
87
  | `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
85
88
  | `pr reviewed <n>` | a clean review pinned to the head it read, as a commit status |
89
+ | `recap` | the facts a Recap rests on and whether the session can be closed |
86
90
 
87
91
  `dw-sdlc <command> --help` gives each one's options.
88
92
 
@@ -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 {
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import * as gate from "./commands/gate.js";
8
8
  import * as prReviewed from "./commands/pr-reviewed.js";
9
9
  import * as prShip from "./commands/pr-ship.js";
10
10
  import * as primitives from "./commands/primitives.js";
11
+ import * as recap from "./commands/recap.js";
11
12
  import * as referencesSetup from "./commands/references-setup.js";
12
13
  import * as referencesSync from "./commands/references-sync.js";
13
14
  import * as setup from "./commands/setup.js";
@@ -21,7 +22,8 @@ const COMMANDS = new Map([
21
22
  ["references sync", referencesSync],
22
23
  ["references setup", referencesSetup],
23
24
  ["pr ship", prShip],
24
- ["pr reviewed", prReviewed]
25
+ ["pr reviewed", prReviewed],
26
+ ["recap", recap]
25
27
  ]);
26
28
  function usage() {
27
29
  const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
@@ -16,6 +16,9 @@ export function claudeConfigDir(env, home) {
16
16
  export const MARKETPLACE = "dw-sdlc";
17
17
  export const PLUGIN = "sdlc";
18
18
  export const PLUGIN_ID = `${PLUGIN}@${MARKETPLACE}`;
19
+ /** The plugin of the session helpers: optional, and it links a copy of the package of its own. */
20
+ const SESSIONS = "sdlc-sessions";
21
+ const SESSIONS_ID = `${SESSIONS}@${MARKETPLACE}`;
19
22
  export const PACKAGE = "dw-sdlc";
20
23
  /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
21
24
  export const RTK_FIND_EXEC_FIXED = "0.46.0";
@@ -55,8 +58,6 @@ function checkJq(probe) {
55
58
  }
56
59
  return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
57
60
  }
58
- /** Where the plugin's hooks run: live from the checkout for a marketplace added from a directory, a
59
- * cached snapshot for one added from git. A marker placed in only one of the two settled it. */
60
61
  /** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
61
62
  function liveCheckout(probe, claude) {
62
63
  const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
@@ -71,27 +72,27 @@ function pluginOrigin(probe, claude, installPath) {
71
72
  }
72
73
  return `installed at ${installPath}; a change reaches it through claude plugin update`;
73
74
  }
74
- /** The directory whose hooks run on this machine: the checkout of a directory marketplace, else the installed snapshot. */
75
- function runningPluginRoot(probe, claude) {
76
- const checkout = liveCheckout(probe, claude);
77
- if (checkout !== undefined) {
78
- return join(checkout, "plugins", PLUGIN);
79
- }
75
+ /** What `claude plugin list --json` prints, one object a plugin, or undefined when it does not answer. */
76
+ function listedPlugins(probe) {
80
77
  const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
81
78
  const list = status === 0 ? parseJson(stdout) : undefined;
82
- if (!Array.isArray(list)) {
83
- return undefined;
79
+ return Array.isArray(list) ? list.filter(isObject) : undefined;
80
+ }
81
+ /** The directory `plugin` runs from on this machine: the checkout of a directory marketplace, else the installed snapshot. */
82
+ function runningPluginRoot(probe, claude, plugin = PLUGIN) {
83
+ const checkout = liveCheckout(probe, claude);
84
+ if (checkout !== undefined) {
85
+ return join(checkout, "plugins", plugin);
84
86
  }
85
- const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
87
+ const entry = listedPlugins(probe)?.find((item) => item.id === `${plugin}@${MARKETPLACE}`);
86
88
  return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
87
89
  }
88
90
  function checkPlugin(probe, claude) {
89
- const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
90
- const list = status === 0 ? parseJson(stdout) : undefined;
91
- if (!Array.isArray(list)) {
91
+ const list = listedPlugins(probe);
92
+ if (list === undefined) {
92
93
  return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
93
94
  }
94
- const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
95
+ const entry = list.find((item) => item.id === PLUGIN_ID);
95
96
  if (entry === undefined) {
96
97
  return {
97
98
  name: "plugin",
@@ -165,6 +166,43 @@ function checkPackage(probe, claude, root) {
165
166
  }
166
167
  return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
167
168
  }
169
+ /**
170
+ * `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
171
+ * the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
172
+ */
173
+ function checkSessions(probe, claude) {
174
+ const name = "sessions";
175
+ const list = listedPlugins(probe);
176
+ const entry = list?.find((item) => item.id === SESSIONS_ID);
177
+ if (entry === undefined) {
178
+ const detail = list === undefined ? "claude plugin list did not answer" : `no ${SESSIONS_ID}, so no /sdlc-recap`;
179
+ return { name, level: "INFO", detail };
180
+ }
181
+ const version = typeof entry.version === "string" ? entry.version : "?";
182
+ if (entry.enabled !== true) {
183
+ return { name, level: "WARN", detail: `${version} installed but disabled. claude plugin enable ${SESSIONS_ID}` };
184
+ }
185
+ const root = runningPluginRoot(probe, claude, SESSIONS);
186
+ const copy = root === undefined ? undefined : manifestVersion(probe, join(root, PACKAGE));
187
+ if (copy === undefined) {
188
+ return {
189
+ name,
190
+ level: "WARN",
191
+ detail: `${version} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
192
+ };
193
+ }
194
+ const guards = runningPluginRoot(probe, claude);
195
+ const other = guards === undefined ? undefined : manifestVersion(probe, join(guards, PACKAGE));
196
+ if (other !== undefined && other !== copy) {
197
+ const behind = versionBefore(copy, other) ? SESSIONS_ID : PLUGIN_ID;
198
+ return {
199
+ name,
200
+ level: "WARN",
201
+ detail: `drift: ${SESSIONS} runs ${PACKAGE} ${copy} and ${PLUGIN} runs ${other}. claude plugin update ${behind}`
202
+ };
203
+ }
204
+ return { name, level: "PASS", detail: `${version} enabled, on ${PACKAGE} ${copy}` };
205
+ }
168
206
  /** Every hook command string in a settings file, whatever the event and matcher. */
169
207
  function hookCommands(settings) {
170
208
  if (!isObject(settings) || !isObject(settings.hooks)) {
@@ -342,6 +380,7 @@ export function diagnose(inputs) {
342
380
  checkJq(probe),
343
381
  checkPlugin(probe, claude),
344
382
  checkPackage(probe, claude, inputs.config === undefined ? undefined : inputs.root),
383
+ checkSessions(probe, claude),
345
384
  checkRtk(probe, home, claude),
346
385
  checkSafeChain(probe),
347
386
  checkPnpmReleaseAge(probe, home),
@@ -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";
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";
5
+ import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
6
+ import { decide, declaredPolicy, devCommand, hostArgs, isScriptAction, lineOf, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
7
+ import { CannotRun, carriesBase, gh, namedProblem, now, 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,38 @@ 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
- const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
41
+ /** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
42
+ class Interrupted extends CannotRun {
43
+ performed;
44
+ constructor({ message, remedy }, performed) {
45
+ super(message, remedy);
46
+ this.performed = performed;
47
+ }
48
+ }
27
49
  function parseCli(argv) {
28
50
  try {
29
51
  const { values, positionals } = parseArgs({
@@ -41,7 +63,7 @@ function parseCli(argv) {
41
63
  });
42
64
  const [number, ...more] = positionals;
43
65
  const { repo } = values;
44
- const review = STRICTNESS.review.find((depth) => depth === values.review);
66
+ const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
45
67
  if (values.help !== true) {
46
68
  if (more.length > 0) {
47
69
  return { error: `name one pull request, not ${positionals.length}` };
@@ -51,7 +73,7 @@ function parseCli(argv) {
51
73
  return { error: problem };
52
74
  }
53
75
  if (values.review !== undefined && review === undefined) {
54
- return { error: `--review takes ${STRICTNESS.review.join(" or ")}, not ${JSON.stringify(values.review)}` };
76
+ return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
55
77
  }
56
78
  }
57
79
  return {
@@ -69,18 +91,6 @@ function parseCli(argv) {
69
91
  return { error: error instanceof Error ? error.message : String(error) };
70
92
  }
71
93
  }
72
- /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
73
- function now() {
74
- const given = process.env.DW_SDLC_NOW?.trim();
75
- if (given === undefined || given === "") {
76
- return Date.now();
77
- }
78
- const time = Date.parse(given);
79
- if (Number.isNaN(time)) {
80
- throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
81
- }
82
- return time;
83
- }
84
94
  /** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
85
95
  const NAMED = {
86
96
  "update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
@@ -92,6 +102,33 @@ const NAMED = {
92
102
  },
93
103
  merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
94
104
  };
105
+ /**
106
+ * Whether the read after a script Action shows it took effect. gh's exit status cannot tell: a reply
107
+ * lost on the way back exits non-zero after GitHub applied the Action.
108
+ */
109
+ const TOOK = {
110
+ // A moved head alone may be a push: the update took effect when the head carries the base it read.
111
+ "update-branch": (before, after) => after.head !== before.head && carriesBase(before, after),
112
+ "mark-ready": (_, after) => !after.draft,
113
+ // The post names the head it read: a head moved since carries none of it.
114
+ "mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
115
+ merge: (_, after) => after.state === "MERGED"
116
+ };
117
+ /**
118
+ * Why an Action that ran comes round again. A head that moved after a mark-reviewed names both heads;
119
+ * otherwise gh refused it, or accepted it and the read shows nothing yet, or the effect is gone again.
120
+ */
121
+ function reasonOf({ action, ok, exit, stderr = "" }, pr, ranOn, { act, still }) {
122
+ if (action === "mark-reviewed" && exit === 0 && ranOn !== undefined && ranOn !== pr.head) {
123
+ return `${act} posted on ${ranOn.slice(0, 7)}, and the head moved to ${pr.head.slice(0, 7)}`;
124
+ }
125
+ if (ok) {
126
+ return `${act} ran, and ${still}`;
127
+ }
128
+ return exit === 0
129
+ ? `gh accepted ${act}; the read does not show it yet`
130
+ : `gh refused ${act}: ${said(stderr) || `gh exited ${exit}`}`;
131
+ }
95
132
  /** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
96
133
  function handedOver(pr, policy, answer, action, reason) {
97
134
  if (action === "merge") {
@@ -104,9 +141,12 @@ function handedOver(pr, policy, answer, action, reason) {
104
141
  * pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
105
142
  */
106
143
  function ship(cli, policy) {
107
- let pr = read(cli, "pr ship");
144
+ // Only pr.review auto routes the depth from the changes, so only it reads them.
145
+ const sized = policy.review.depth === "auto";
146
+ let pr = read(cli, "pr ship", sized);
108
147
  const performed = [];
109
- const refusals = {};
148
+ // The head each Action ran on.
149
+ const postedOn = new Map();
110
150
  for (;;) {
111
151
  const answer = decide(pr, now(), policy);
112
152
  const { action } = answer;
@@ -116,7 +156,7 @@ function ship(cli, policy) {
116
156
  // -R names the repository, so the checkout the script runs in plays no part.
117
157
  const args = hostArgs(pr, action, policy, true);
118
158
  const command = shellLine(["gh", ...args]);
119
- const { would, act, still } = NAMED[action];
159
+ const { would } = NAMED[action];
120
160
  if (cli.dryRun) {
121
161
  performed.push({ action, command, ok: null });
122
162
  return {
@@ -125,25 +165,30 @@ function ship(cli, policy) {
125
165
  performed
126
166
  };
127
167
  }
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}`;
168
+ const done = performed.find((earlier) => earlier.action === action);
169
+ // --dry-run returns before any Action runs, so an earlier one always carries gh's exit.
170
+ if (done !== undefined && done.ok !== null) {
171
+ const reason = reasonOf(done, pr, postedOn.get(action), NAMED[action]);
131
172
  return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
132
173
  }
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}`;
174
+ const { status, stderr } = gh(args);
175
+ const before = pr;
176
+ const message = stderr.trim();
177
+ const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
178
+ postedOn.set(action, before.head);
179
+ try {
180
+ pr = readAgain(pr, sized);
181
+ performed.push({ ...record, ok: TOOK[action](before, pr) });
182
+ }
183
+ catch (error) {
184
+ if (error instanceof CannotRun) {
185
+ // The Action may have landed: the caller must not run it blind again.
186
+ throw new Interrupted(error, [...performed, { ...record, ok: null }]);
187
+ }
188
+ throw error;
137
189
  }
138
- pr = readAgain(pr);
139
190
  }
140
191
  }
141
- /** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
142
- function lineOf({ status, action, reason, command, recheckAfter }) {
143
- const label = status === action ? status : `${status} ${action}`;
144
- const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
145
- return `${label}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
146
- }
147
192
  function documentOf(pr, answer, performed, verbose) {
148
193
  const facts = verbose ? { facts: pr } : {};
149
194
  return { pr: pr.url, head: pr.head, base: pr.base, ...answer, performed, ...facts };
@@ -161,9 +206,9 @@ export function run(argv) {
161
206
  // An invalid config layer resolves to ask and full, like every other strictest setting.
162
207
  const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
163
208
  const policy = {
164
- merge: cli.merge ? "auto" : declared.merge,
165
- review: cli.review === undefined ? { depth: declared.review, source: "config" } : { depth: cli.review, source: "flag" },
166
- reviewCommand: declared.reviewCommand
209
+ ...declaredPolicy(declared),
210
+ ...(cli.merge ? { merge: "auto" } : {}),
211
+ ...(cli.review === undefined ? {} : { review: { depth: cli.review, source: "flag" } })
167
212
  };
168
213
  let shipped;
169
214
  try {
@@ -171,7 +216,12 @@ export function run(argv) {
171
216
  }
172
217
  catch (error) {
173
218
  if (error instanceof CannotRun) {
174
- process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n`);
219
+ const ran = error instanceof Interrupted ? error.performed : [];
220
+ const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
221
+ process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
222
+ if (cli.json && ran.length > 0) {
223
+ process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
224
+ }
175
225
  return 2;
176
226
  }
177
227
  throw error;
@@ -0,0 +1,2 @@
1
+ export declare const summary = "the facts a Recap rests on and whether the session can be closed";
2
+ export declare function run(argv: string[]): number;