dw-sdlc 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +13 -13
  2. package/config/agentic.config.schema.json +1 -1
  3. package/dist/agentic-config.d.ts +16 -0
  4. package/dist/agentic-config.js +28 -23
  5. package/dist/cannot-run.d.ts +27 -0
  6. package/dist/cannot-run.js +48 -0
  7. package/dist/catalogue.js +2 -1
  8. package/dist/checks/agents-map.js +7 -8
  9. package/dist/checks/check.d.ts +10 -8
  10. package/dist/checks/check.js +9 -7
  11. package/dist/checks/decision-records.js +3 -2
  12. package/dist/checks/file-size-ratchet.js +13 -17
  13. package/dist/checks/index.d.ts +6 -5
  14. package/dist/checks/index.js +10 -9
  15. package/dist/checks/markdown-refs.js +58 -6
  16. package/dist/checks/primitives.js +5 -5
  17. package/dist/checks/routed-docs.js +1 -3
  18. package/dist/checks/skill-sections.js +12 -20
  19. package/dist/checks/tree.js +7 -7
  20. package/dist/cli.d.ts +2 -1
  21. package/dist/cli.js +26 -5
  22. package/dist/commands/check.js +27 -42
  23. package/dist/commands/config.js +22 -27
  24. package/dist/commands/doctor.d.ts +2 -0
  25. package/dist/commands/doctor.js +11 -18
  26. package/dist/commands/gate.js +35 -40
  27. package/dist/commands/lead-start.js +28 -42
  28. package/dist/commands/pr-reviewed.js +41 -59
  29. package/dist/commands/pr-ship.js +72 -87
  30. package/dist/commands/primitives.js +38 -40
  31. package/dist/commands/recap.js +23 -74
  32. package/dist/commands/references-setup.js +28 -76
  33. package/dist/commands/references-sync.js +29 -53
  34. package/dist/commands/setup.d.ts +2 -7
  35. package/dist/commands/setup.js +38 -90
  36. package/dist/commands/sweep.js +46 -157
  37. package/dist/commands/worker-ls.js +15 -31
  38. package/dist/commands/worker-report.js +36 -52
  39. package/dist/commands/worker-start.js +43 -57
  40. package/dist/drift.js +2 -1
  41. package/dist/exec.d.ts +20 -0
  42. package/dist/exec.js +37 -4
  43. package/dist/gate-cache.js +2 -1
  44. package/dist/git.d.ts +32 -0
  45. package/dist/git.js +60 -0
  46. package/dist/json-schema.js +1 -3
  47. package/dist/landed.js +11 -22
  48. package/dist/paused-operation.d.ts +2 -0
  49. package/dist/paused-operation.js +17 -0
  50. package/dist/pins.js +2 -1
  51. package/dist/pr/decide.d.ts +4 -16
  52. package/dist/pr/decide.js +48 -83
  53. package/dist/pr/delta.d.ts +3 -4
  54. package/dist/pr/delta.js +11 -20
  55. package/dist/pr/read.d.ts +27 -30
  56. package/dist/pr/read.js +96 -158
  57. package/dist/pr/reply.d.ts +34 -0
  58. package/dist/pr/reply.js +112 -0
  59. package/dist/pr/route.d.ts +11 -3
  60. package/dist/pr/route.js +5 -5
  61. package/dist/pr/standing.d.ts +138 -0
  62. package/dist/pr/standing.js +181 -0
  63. package/dist/pr/state.d.ts +14 -69
  64. package/dist/pr/state.js +75 -148
  65. package/dist/pr/upstream.d.ts +1 -6
  66. package/dist/pr/upstream.js +2 -2
  67. package/dist/primitives-diff.js +1 -1
  68. package/dist/recap-next.js +34 -47
  69. package/dist/recap-primitives.js +1 -1
  70. package/dist/references-key.d.ts +0 -1
  71. package/dist/references-key.js +3 -4
  72. package/dist/run-command.d.ts +4 -1
  73. package/dist/run-command.js +32 -11
  74. package/dist/setup-gate.d.ts +1 -7
  75. package/dist/setup-rules.js +2 -6
  76. package/dist/setup-writer.d.ts +32 -0
  77. package/dist/setup-writer.js +60 -0
  78. package/dist/shell-line.d.ts +2 -0
  79. package/dist/shell-line.js +5 -0
  80. package/dist/sweep-removal.d.ts +31 -0
  81. package/dist/sweep-removal.js +133 -0
  82. package/dist/sweep-terminals.d.ts +1 -1
  83. package/dist/sweep-terminals.js +21 -57
  84. package/dist/sweep-trees.d.ts +19 -3
  85. package/dist/sweep-trees.js +67 -31
  86. package/dist/worker/herdr.d.ts +67 -23
  87. package/dist/worker/herdr.js +126 -77
  88. package/dist/worker/launch.js +32 -45
  89. package/dist/worker/origin.d.ts +1 -1
  90. package/dist/worker/origin.js +9 -11
  91. package/dist/worker/tabs.js +11 -7
  92. package/dist/working-tree.d.ts +0 -8
  93. package/dist/working-tree.js +6 -22
  94. package/dist/worktree-lock.js +1 -1
  95. package/package.json +1 -1
  96. package/dist/git-env.d.ts +0 -2
  97. package/dist/git-env.js +0 -10
@@ -1,8 +1,8 @@
1
1
  // `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
2
2
  // run and the report serve `dw-sdlc check` too, which judges one Check of it.
3
- import { parseArgs } from "node:util";
4
3
  import { repositoryRoot, resolveConfig, stepCommand, stepHint } from "../agentic-config.js";
5
- import { CannotRun } from "../checks/check.js";
4
+ import { CannotRun, messageOf, readArgs, rootDir, usageError } from "../cannot-run.js";
5
+ import { findingOf } from "../checks/check.js";
6
6
  import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
7
7
  import { watchDrift } from "../drift.js";
8
8
  import { fingerprint, isGreen, stampGreen } from "../gate-cache.js";
@@ -29,10 +29,12 @@ changed or deleted is Drift, one failure per file, and the Steps after it still
29
29
  and edits made before the run never drift, and a Step a signal stopped is not compared. gate.drift
30
30
  "allow" turns the watch off.
31
31
 
32
- SIGINT or SIGTERM sent while a command runs stops it and everything after it, and the report still
33
- prints: the stopped command's last lines, its failure line and the last line. A command that SIGINT
34
- or SIGTERM ends stops everything after it the same way, as Ctrl-C reaches it and the Gate together. A
35
- second signal, or one sent while the Checks the package ships run, ends the Gate at once.
32
+ Each command runs in a process group of its own, which every process it starts joins. SIGINT or
33
+ SIGTERM sent while a command runs reaches that whole group and stops everything after it, and the
34
+ report still prints: the stopped command's last lines, its failure line and the last line. A command
35
+ that SIGINT or SIGTERM ends stops everything after it the same way. A second signal ends the Gate at
36
+ once and kills the command's group; one sent while the Checks the package ships run ends the Gate at
37
+ once. SIGHUP reaches the command's group and ends the Gate.
36
38
 
37
39
  --verbose passes each command's output through as it runs instead, under a line naming the command,
38
40
  and keeps no tail. --json prints the document alone; each Step and Check in it carries tail, the last
@@ -48,8 +50,9 @@ Untracked files count and ignored files do not. Each worktree keeps its own stam
48
50
  hash runs the whole Gate.
49
51
 
50
52
  Exit 0 when everything passes, 1 when a Step or a Check fails or a Step drifts, 2 when the Gate
51
- cannot run: an invalid config layer, a Step or Check whose command the shell cannot find or execute,
52
- a Check missing an input it reads, or a tree it cannot record for Drift.`;
53
+ cannot run: a --root that names no directory, an invalid config layer, a Step or Check whose command
54
+ the shell cannot find or execute, a Check missing an input it reads, or a tree it cannot record for
55
+ Drift.`;
53
56
  const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
54
57
  const SKIPPED = { status: "skipped", exitCode: null, tail: null };
55
58
  const SILENT = { write: () => true };
@@ -72,30 +75,25 @@ function plural(count, noun) {
72
75
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
73
76
  }
74
77
  function parseCli(argv) {
75
- try {
76
- const { values } = parseArgs({
77
- args: argv,
78
- options: {
79
- help: { type: "boolean", short: "h" },
80
- json: { type: "boolean" },
81
- verbose: { type: "boolean" },
82
- root: { type: "string" },
83
- "update-baseline": { type: "boolean" },
84
- cache: { type: "boolean" }
85
- }
86
- });
87
- const refresh = values["update-baseline"] === true;
88
- const cache = values.cache === true;
89
- if (refresh && cache) {
90
- return { error: "--cache and --update-baseline do not combine: a refresh needs the Gate to run" };
78
+ const { values } = readArgs({
79
+ args: argv,
80
+ options: {
81
+ help: { type: "boolean", short: "h" },
82
+ json: { type: "boolean" },
83
+ verbose: { type: "boolean" },
84
+ root: { type: "string" },
85
+ "update-baseline": { type: "boolean" },
86
+ cache: { type: "boolean" }
91
87
  }
92
- const mode = refresh ? "update-baseline" : cache ? "cache" : "plain";
93
- const flags = { help: values.help === true, json: values.json === true, verbose: values.verbose === true };
94
- return { ...flags, root: values.root, mode };
95
- }
96
- catch (error) {
97
- return { error: error instanceof Error ? error.message : String(error) };
88
+ }, USAGE);
89
+ const refresh = values["update-baseline"] === true;
90
+ const cache = values.cache === true;
91
+ if (refresh && cache) {
92
+ throw usageError("--cache and --update-baseline do not combine: a refresh needs the Gate to run", USAGE);
98
93
  }
94
+ const mode = refresh ? "update-baseline" : cache ? "cache" : "plain";
95
+ const flags = { help: values.help === true, json: values.json === true, verbose: values.verbose === true };
96
+ return { ...flags, root: values.root, mode };
99
97
  }
100
98
  function configFailure({ file, path, message }) {
101
99
  const where = path === "" ? message : `${path}: ${message}`;
@@ -164,8 +162,8 @@ function progressOf(options, log) {
164
162
  /** What a Check that threw reports: the input it lacks, or what stopped it. */
165
163
  function erred(check, error) {
166
164
  const finding = error instanceof CannotRun
167
- ? error.finding
168
- : { message: `stopped: ${error instanceof Error ? error.message : String(error)}`, remedy: REMEDIES.checkStopped };
165
+ ? findingOf(error, REMEDIES.checkStopped)
166
+ : { message: `stopped: ${messageOf(error)}`, remedy: REMEDIES.checkStopped };
169
167
  return {
170
168
  check,
171
169
  outcome: { status: "error", exitCode: EXIT_CODES.error, tail: null },
@@ -209,7 +207,8 @@ function refreshBaselines(judged, others, root) {
209
207
  }
210
208
  catch (error) {
211
209
  if (error instanceof CannotRun) {
212
- notes.push(`gate: baseline ${file} not refreshed — ${error.finding.message}; ${error.finding.remedy}`);
210
+ const { message, remedy } = findingOf(error, REMEDIES.checkStopped);
211
+ notes.push(`gate: baseline ${file} not refreshed — ${message}; ${remedy}`);
213
212
  }
214
213
  else {
215
214
  judged[index] = erred(check, error);
@@ -229,10 +228,10 @@ function worst(outcomes) {
229
228
  */
230
229
  function select(gate, only) {
231
230
  if (only === undefined) {
232
- return { steps: gate.steps, run: gate.checks.run, shipped: enabledChecks(gate.checks) };
231
+ return { steps: gate.steps, run: gate.checks.run, shipped: enabledChecks(gate) };
233
232
  }
234
233
  const scripts = gate.checks.run.filter((check) => check.name === only);
235
- const shipped = shippedCheck(only, gate.checks[only]);
234
+ const shipped = shippedCheck(only, gate);
236
235
  if (scripts.length === 0 && shipped === undefined) {
237
236
  const names = [...gate.checks.run.map((check) => check.name), ...CHECK_NAMES].join(", ");
238
237
  return { rule: "check", message: `no Check is named ${JSON.stringify(only)}`, remedy: `name one of ${names}` };
@@ -380,15 +379,11 @@ export function printReport(report, json, label) {
380
379
  }
381
380
  export async function run(argv) {
382
381
  const cli = parseCli(argv);
383
- if ("error" in cli) {
384
- process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
385
- return 2;
386
- }
387
382
  if (cli.help) {
388
383
  process.stdout.write(`${USAGE}\n`);
389
384
  return 0;
390
385
  }
391
386
  const { json, verbose, mode } = cli;
392
- const report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, verbose, mode });
387
+ const report = await runGate(repositoryRoot(rootDir(cli.root)), { json, verbose, mode });
393
388
  return printReport(report, json, "gate");
394
389
  }
@@ -1,8 +1,8 @@
1
1
  // `dw-sdlc lead start`: starts the next session as a Lead in a herdr terminal of its own, beside the caller and
2
2
  // outliving it, on the command the config names for a Worker, without what makes it unsafe or isolated.
3
- import { parseArgs } from "node:util";
4
3
  import { mainCheckout, repositoryRoot, resolveConfig } from "../agentic-config.js";
5
- import { describe, lead, refuse, Refusal, wholeNumber } from "../worker/herdr.js";
4
+ import { CannotRun, readArgs, usageError } from "../cannot-run.js";
5
+ import { lead, wholeNumber } from "../worker/herdr.js";
6
6
  import { launch, NAME, spaceOf, TAB_WAIT, undelivered } from "../worker/launch.js";
7
7
  export const summary = "start the next session as a Lead in a herdr terminal of its own";
8
8
  const USAGE = `Usage: dw-sdlc lead start --name <name> --prompt <first message> [--wait <seconds>] [--json]
@@ -33,36 +33,31 @@ const UNSAFE = "--dangerously-skip-permissions";
33
33
  /** The flags that put a Claude Code session in a worktree of its own, each taking an optional name. */
34
34
  const WORKTREE = new Set(["--worktree", "-w"]);
35
35
  function parseCli(argv) {
36
- try {
37
- const { values } = parseArgs({
38
- args: argv,
39
- options: {
40
- help: { type: "boolean", short: "h" },
41
- json: { type: "boolean" },
42
- name: { type: "string" },
43
- prompt: { type: "string" },
44
- wait: { type: "string" }
45
- }
46
- });
47
- if (values.help === true) {
48
- return "help";
49
- }
50
- const { name, prompt } = values;
51
- if (name === undefined || !NAME.test(name)) {
52
- return { error: `--name takes 1-32 of a-z, 0-9, - and _, not ${JSON.stringify(name ?? "")}` };
53
- }
54
- if (prompt === undefined || prompt.trim() === "") {
55
- return { error: "give the Lead its first message: --prompt <first message>" };
36
+ const { values } = readArgs({
37
+ args: argv,
38
+ options: {
39
+ help: { type: "boolean", short: "h" },
40
+ json: { type: "boolean" },
41
+ name: { type: "string" },
42
+ prompt: { type: "string" },
43
+ wait: { type: "string" }
56
44
  }
57
- const wait = wholeNumber("--wait", values.wait, DEFAULT_WAIT, 0);
58
- if (typeof wait === "object") {
59
- return wait;
60
- }
61
- return { name, prompt, wait, json: values.json === true };
45
+ }, USAGE);
46
+ if (values.help === true) {
47
+ return { help: true };
48
+ }
49
+ const { name, prompt } = values;
50
+ if (name === undefined || !NAME.test(name)) {
51
+ throw usageError(`--name takes 1-32 of a-z, 0-9, - and _, not ${JSON.stringify(name ?? "")}`, USAGE);
62
52
  }
63
- catch (error) {
64
- return { error: describe(error) };
53
+ if (prompt === undefined || prompt.trim() === "") {
54
+ throw usageError("give the Lead its first message: --prompt <first message>", USAGE);
65
55
  }
56
+ const wait = wholeNumber("--wait", values.wait, DEFAULT_WAIT, 0);
57
+ if (typeof wait === "object") {
58
+ throw usageError(wait.error, USAGE);
59
+ }
60
+ return { help: false, name, prompt, wait, json: values.json === true };
66
61
  }
67
62
  /** `words` without the flag that skips permissions and without a worktree flag, its name with it when it has one. */
68
63
  function leadCommand(words) {
@@ -88,11 +83,11 @@ async function start(cli) {
88
83
  const checkout = repositoryRoot(process.cwd());
89
84
  const main = mainCheckout(checkout);
90
85
  if (main !== undefined) {
91
- throw new Refusal(`in a linked worktree, ${checkout}: a Lead works in the main checkout, and this session cannot start one there`, `run it from the main checkout, ${main}, or start the Lead there by hand`);
86
+ throw new CannotRun(`in a linked worktree, ${checkout}: a Lead works in the main checkout, and this session cannot start one there`, `run it from the main checkout, ${main}, or start the Lead there by hand`);
92
87
  }
93
88
  const command = leadCommand(resolveConfig(checkout).config.worker.command.split(/\s+/).filter(Boolean));
94
89
  if (command.length === 0) {
95
- throw new Refusal("worker.command holds no command", "set it in .ai/agentic.config.local.json, or remove the key");
90
+ throw new CannotRun("worker.command holds no command", "set it in .ai/agentic.config.local.json, or remove the key");
96
91
  }
97
92
  const { address, tab, reason } = await launch("lead start", {
98
93
  name: cli.name,
@@ -118,18 +113,9 @@ async function start(cli) {
118
113
  }
119
114
  export async function run(argv) {
120
115
  const cli = parseCli(argv);
121
- if (cli === "help") {
116
+ if (cli.help) {
122
117
  process.stdout.write(`${USAGE}\n`);
123
118
  return 0;
124
119
  }
125
- if ("error" in cli) {
126
- process.stderr.write(`dw-sdlc lead start: ${cli.error}\n\n${USAGE}\n`);
127
- return 2;
128
- }
129
- try {
130
- return await start(cli);
131
- }
132
- catch (error) {
133
- return refuse("lead start", error);
134
- }
120
+ return start(cli);
135
121
  }
@@ -1,9 +1,9 @@
1
1
  // `dw-sdlc pr reviewed <n> --head <sha>`: pins a Review to the head it read, as the commit status
2
2
  // `dw-sdlc/review`: success for a clean one, --findings the read mark of one that left a Finding open,
3
3
  // --accept the dev's acceptance of a head as it stands, which the review-limit park names.
4
- import { parseArgs } from "node:util";
4
+ import { CannotRun, readArgs, usageError } from "../cannot-run.js";
5
5
  import { REVIEW, REVIEW_STATE, reviewStatusArgs } from "../pr/decide.js";
6
- import { CannotRun, gh, namedProblem, read, said } from "../pr/read.js";
6
+ import { gh, namedProblem, pullRequestNamed, said } from "../pr/read.js";
7
7
  import { ACCEPTED, MARK_STATE, READ_MARK } from "../pr/state.js";
8
8
  export const summary = "pin a Review to the head it read, as a commit status";
9
9
  const USAGE = `Usage: dw-sdlc pr reviewed <n> [-R owner/name] --head <sha> [--findings | --accept] [--json]
@@ -25,54 +25,49 @@ const STATUS = {
25
25
  accept: { state: REVIEW_STATE, description: ACCEPTED }
26
26
  };
27
27
  function parseCli(argv) {
28
- try {
29
- const { values, positionals } = parseArgs({
30
- args: argv,
31
- allowPositionals: true,
32
- options: {
33
- help: { type: "boolean", short: "h" },
34
- json: { type: "boolean" },
35
- repo: { type: "string", short: "R" },
36
- head: { type: "string" },
37
- findings: { type: "boolean" },
38
- accept: { type: "boolean" }
39
- }
40
- });
41
- if (values.help === true) {
42
- return "help";
28
+ const { values, positionals } = readArgs({
29
+ args: argv,
30
+ allowPositionals: true,
31
+ options: {
32
+ help: { type: "boolean", short: "h" },
33
+ json: { type: "boolean" },
34
+ repo: { type: "string", short: "R" },
35
+ head: { type: "string" },
36
+ findings: { type: "boolean" },
37
+ accept: { type: "boolean" }
43
38
  }
44
- const [number, ...more] = positionals;
45
- const { repo, head } = values;
46
- if (number === undefined) {
47
- return { error: "name the pull request: dw-sdlc pr reviewed <n> --head <sha>" };
48
- }
49
- if (more.length > 0) {
50
- return { error: `name one pull request, not ${positionals.length}` };
51
- }
52
- const problem = namedProblem(number, repo);
53
- if (problem !== undefined) {
54
- return { error: problem };
55
- }
56
- if (head === undefined) {
57
- return { error: "name the head the review read: --head <sha>" };
58
- }
59
- if (!/^[0-9a-f]{40}$/.test(head)) {
60
- return { error: `--head takes the full 40-character sha, not ${JSON.stringify(head)}` };
61
- }
62
- if (values.findings === true && values.accept === true) {
63
- return { error: "--findings and --accept contradict: name one" };
64
- }
65
- const kind = values.findings === true ? "findings" : "clean";
66
- const status = STATUS[values.accept === true ? "accept" : kind];
67
- return { json: values.json === true, number: Number(number), repo, head, status };
39
+ }, USAGE);
40
+ if (values.help === true) {
41
+ return { help: true };
42
+ }
43
+ const [number, ...more] = positionals;
44
+ const { repo, head } = values;
45
+ if (number === undefined) {
46
+ throw usageError("name the pull request: dw-sdlc pr reviewed <n> --head <sha>", USAGE);
47
+ }
48
+ if (more.length > 0) {
49
+ throw usageError(`name one pull request, not ${positionals.length}`, USAGE);
68
50
  }
69
- catch (error) {
70
- return { error: error instanceof Error ? error.message : String(error) };
51
+ const problem = namedProblem(number, repo);
52
+ if (problem !== undefined) {
53
+ throw usageError(problem, USAGE);
71
54
  }
55
+ if (head === undefined) {
56
+ throw usageError("name the head the review read: --head <sha>", USAGE);
57
+ }
58
+ if (!/^[0-9a-f]{40}$/.test(head)) {
59
+ throw usageError(`--head takes the full 40-character sha, not ${JSON.stringify(head)}`, USAGE);
60
+ }
61
+ if (values.findings === true && values.accept === true) {
62
+ throw usageError("--findings and --accept contradict: name one", USAGE);
63
+ }
64
+ const kind = values.findings === true ? "findings" : "clean";
65
+ const status = STATUS[values.accept === true ? "accept" : kind];
66
+ return { help: false, json: values.json === true, number: Number(number), repo, head, status };
72
67
  }
73
68
  /** Posts the review status on `head` once the read shows it is still the pull request's head. */
74
69
  function post({ number, repo, head, status }) {
75
- const pr = read({ number, repo }, "pr reviewed", false);
70
+ const pr = pullRequestNamed({ number, repo }, "pr reviewed");
76
71
  if (pr.head !== head) {
77
72
  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");
78
73
  }
@@ -83,24 +78,11 @@ function post({ number, repo, head, status }) {
83
78
  }
84
79
  export function run(argv) {
85
80
  const cli = parseCli(argv);
86
- if (cli === "help") {
81
+ if (cli.help) {
87
82
  process.stdout.write(`${USAGE}\n`);
88
83
  return 0;
89
84
  }
90
- if ("error" in cli) {
91
- process.stderr.write(`dw-sdlc pr reviewed: ${cli.error}\n\n${USAGE}\n`);
92
- return 2;
93
- }
94
- try {
95
- post(cli);
96
- }
97
- catch (error) {
98
- if (error instanceof CannotRun) {
99
- process.stderr.write(`dw-sdlc pr reviewed: ${error.message} — ${error.remedy}\n`);
100
- return 2;
101
- }
102
- throw error;
103
- }
85
+ post(cli);
104
86
  const { head } = cli;
105
87
  const { state } = cli.status;
106
88
  const answer = cli.json
@@ -1,11 +1,11 @@
1
1
  // `dw-sdlc pr ship [<n>]`, the Ship script: reads one pull request through gh, decides the Action it
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
- import { parseArgs } from "node:util";
5
4
  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, nameItRemedy, now, read, readAgain, readBranch, said } from "../pr/read.js";
8
- import { holds, renamedUpstream } from "../pr/upstream.js";
5
+ import { CannotRun, readArgs, usageError } from "../cannot-run.js";
6
+ import { decide, declaredPolicy, devCommand, hostArgs, isScriptAction, lineOf, readyToMerge, REVIEW } from "../pr/decide.js";
7
+ import { gh, ghReads, namedProblem, nameItRemedy, now, said } from "../pr/read.js";
8
+ import { shellLine } from "../shell-line.js";
9
9
  export const summary = "decide the Action one pull request needs next, as a line or JSON";
10
10
  const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
11
11
 
@@ -22,13 +22,18 @@ ${REVIEW} success gets the Review depth --review names for this call, else pr.re
22
22
  the agent reviews it with the command pr.reviewCommand names, /code-review by default; under light
23
23
  one sdlc-reviewer subagent reads the diff; under off the script posts the success itself, its
24
24
  description saying why. Under auto, the default, the script routes the depth from the changes the
25
- Review would read: lockfiles alone off; a file under .github/workflows/, more than 100 changed
26
- lines outside lockfiles, or a change it cannot size, more files than GitHub's compare lists or a
27
- file outside lockfiles with no patch, full; the rest light. After a push it names
25
+ Review would read: lockfiles alone off, unless a Finding is open; a file under .github/workflows/,
26
+ more than 100 changed lines outside lockfiles, or a change it cannot size, more files than GitHub's
27
+ compare lists or a file outside lockfiles with no patch, full; the rest light. After a push it names
28
28
  the last commit a Review read under details.since, where the Review reads from and auto routes from:
29
29
  one with a ${REVIEW} success not posted under off, or the read mark pr reviewed --findings
30
30
  posts. It carries a success over a head whose only commits since are merges of the base, unless
31
- --review names a depth for the call; a read mark never carries.
31
+ --review names a depth for the call; a read mark never carries. A read mark no later commit answers,
32
+ on the head or before commits that leave the own changes as it read them, is named under
33
+ details.open: the Review then reads the whole pull request, and nothing carries over it. After a merge or a rebase, details.paths names
34
+ the files whose own change moved, the only ones the Review reads. Three Reviews that left a Finding
35
+ open with no success between them park as review-limit at every depth, pr reviewed --accept the
36
+ dev's command: the dev's --review <depth> buys one more Review, and their --review off posts past it.
32
37
  Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
33
38
  Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
34
39
  false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
@@ -43,58 +48,57 @@ Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot r
43
48
  /** The depths --review takes, strictest first, as a sentence lists them. */
44
49
  const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
45
50
  const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
46
- /** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
51
+ /**
52
+ * A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read
53
+ * followed, and each Action is named with gh's exit under the problem.
54
+ */
47
55
  class Interrupted extends CannotRun {
48
56
  performed;
49
57
  constructor({ message, remedy }, performed) {
50
- super(message, remedy);
58
+ const ran = performed.map(({ action, exit }) => `${action} ran (gh exit ${exit ?? "unknown"})`);
59
+ super(message, remedy, { after: ran.join("\n") });
51
60
  this.performed = performed;
52
61
  }
53
62
  }
54
63
  function parseCli(argv) {
55
- try {
56
- const { values, positionals } = parseArgs({
57
- args: argv,
58
- allowPositionals: true,
59
- options: {
60
- help: { type: "boolean", short: "h" },
61
- json: { type: "boolean" },
62
- verbose: { type: "boolean" },
63
- merge: { type: "boolean" },
64
- review: { type: "string" },
65
- "dry-run": { type: "boolean" },
66
- repo: { type: "string", short: "R" }
67
- }
68
- });
69
- const [number, ...more] = positionals;
70
- const { repo } = values;
71
- const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
72
- if (values.help !== true) {
73
- if (more.length > 0) {
74
- return { error: `name one pull request, not ${positionals.length}` };
75
- }
76
- const problem = namedProblem(number, repo);
77
- if (problem !== undefined) {
78
- return { error: problem };
79
- }
80
- if (values.review !== undefined && review === undefined) {
81
- return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
82
- }
64
+ const { values, positionals } = readArgs({
65
+ args: argv,
66
+ allowPositionals: true,
67
+ options: {
68
+ help: { type: "boolean", short: "h" },
69
+ json: { type: "boolean" },
70
+ verbose: { type: "boolean" },
71
+ merge: { type: "boolean" },
72
+ review: { type: "string" },
73
+ "dry-run": { type: "boolean" },
74
+ repo: { type: "string", short: "R" }
75
+ }
76
+ }, USAGE);
77
+ const [number, ...more] = positionals;
78
+ const { repo } = values;
79
+ const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
80
+ if (values.help !== true) {
81
+ if (more.length > 0) {
82
+ throw usageError(`name one pull request, not ${positionals.length}`, USAGE);
83
+ }
84
+ const problem = namedProblem(number, repo);
85
+ if (problem !== undefined) {
86
+ throw usageError(problem, USAGE);
87
+ }
88
+ if (values.review !== undefined && review === undefined) {
89
+ throw usageError(`--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}`, USAGE);
83
90
  }
84
- return {
85
- help: values.help === true,
86
- json: values.json === true,
87
- verbose: values.verbose === true,
88
- merge: values.merge === true,
89
- review,
90
- dryRun: values["dry-run"] === true,
91
- number: number === undefined ? undefined : Number(number),
92
- repo
93
- };
94
- }
95
- catch (error) {
96
- return { error: error instanceof Error ? error.message : String(error) };
97
91
  }
92
+ return {
93
+ help: values.help === true,
94
+ json: values.json === true,
95
+ verbose: values.verbose === true,
96
+ merge: values.merge === true,
97
+ review,
98
+ dryRun: values["dry-run"] === true,
99
+ number: number === undefined ? undefined : Number(number),
100
+ repo
101
+ };
98
102
  }
99
103
  /** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
100
104
  const NAMED = {
@@ -113,7 +117,7 @@ const NAMED = {
113
117
  */
114
118
  const TOOK = {
115
119
  // A moved head alone may be a push: the update took effect when the head carries the base it read.
116
- "update-branch": (before, after) => after.head !== before.head && carriesBase(before, after),
120
+ "update-branch": (before, after, reads) => after.head !== before.head && reads.carriesBase(before, after),
117
121
  "mark-ready": (_, after) => !after.draft,
118
122
  // The post names the head it read: a head moved since carries none of it.
119
123
  "mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
@@ -141,27 +145,17 @@ function handedOver(pr, policy, answer, action, reason) {
141
145
  }
142
146
  return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action, policy) };
143
147
  }
144
- /**
145
- * The pull request the call names, else the current branch's as `gh pr view` resolves it. Where gh finds
146
- * none for a branch whose upstream has another name, the one of that name, taken only when its commits
147
- * hold the local head: a branch started from another tracks that branch until its first push.
148
- */
149
- function readTarget(cli, root, sized) {
150
- const renamed = cli.number === undefined && cli.repo === undefined ? renamedUpstream(root) : undefined;
151
- if (renamed === undefined) {
152
- return read(cli, "pr ship", sized);
153
- }
154
- const { branch, head, upstream } = renamed;
155
- const found = readBranch(sized, upstream);
156
- const upstreamNamed = `${upstream}, the name its upstream gives`;
157
- if (found === null) {
158
- throw new CannotRun(`gh pr view found no pull request for ${branch}, nor for ${upstreamNamed}`, nameItRemedy("pr ship"));
148
+ /** The pull request the call names, else this checkout's; none found stops the call with what to name. */
149
+ function readTarget(cli, root, reads) {
150
+ if (cli.number !== undefined || cli.repo !== undefined) {
151
+ return reads.named(cli, "pr ship");
159
152
  }
160
- if (found.byUpstream && !holds(found.pr, head)) {
161
- const lacks = `${found.pr.url}, found by ${upstreamNamed}, lacks HEAD ${head.slice(0, 7)}`;
162
- throw new CannotRun(lacks, `push HEAD if it is this branch's pull request, else ${nameItRemedy("pr ship")}`);
153
+ const found = reads.checkout(root);
154
+ if ("problem" in found) {
155
+ const nameIt = nameItRemedy("pr ship");
156
+ throw new CannotRun(found.problem, found.lacksHead ? `push HEAD if it is this branch's pull request, else ${nameIt}` : nameIt);
163
157
  }
164
- return found.pr;
158
+ return found;
165
159
  }
166
160
  /**
167
161
  * Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
@@ -169,8 +163,8 @@ function readTarget(cli, root, sized) {
169
163
  */
170
164
  function ship(cli, root, policy) {
171
165
  // Only pr.review auto routes the depth from the changes, so only it reads them.
172
- const sized = policy.review.depth === "auto";
173
- let pr = readTarget(cli, root, sized);
166
+ const reads = ghReads(policy.review.depth === "auto");
167
+ let pr = readTarget(cli, root, reads);
174
168
  const performed = [];
175
169
  // The head each Action ran on.
176
170
  const postedOn = new Map();
@@ -204,8 +198,8 @@ function ship(cli, root, policy) {
204
198
  const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
205
199
  postedOn.set(action, before.head);
206
200
  try {
207
- pr = readAgain(pr, sized);
208
- performed.push({ ...record, ok: TOOK[action](before, pr) });
201
+ pr = reads.again(pr);
202
+ performed.push({ ...record, ok: TOOK[action](before, pr, reads) });
209
203
  }
210
204
  catch (error) {
211
205
  if (error instanceof CannotRun) {
@@ -222,10 +216,6 @@ function documentOf(pr, answer, performed, verbose) {
222
216
  }
223
217
  export function run(argv) {
224
218
  const cli = parseCli(argv);
225
- if ("error" in cli) {
226
- process.stderr.write(`dw-sdlc pr ship: ${cli.error}\n\n${USAGE}\n`);
227
- return 2;
228
- }
229
219
  if (cli.help) {
230
220
  process.stdout.write(`${USAGE}\n`);
231
221
  return 0;
@@ -243,14 +233,9 @@ export function run(argv) {
243
233
  shipped = ship(cli, root, policy);
244
234
  }
245
235
  catch (error) {
246
- if (error instanceof CannotRun) {
247
- const ran = error instanceof Interrupted ? error.performed : [];
248
- const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
249
- process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
250
- if (cli.json && ran.length > 0) {
251
- process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
252
- }
253
- return 2;
236
+ if (cli.json && error instanceof Interrupted) {
237
+ const { message, remedy, performed } = error;
238
+ process.stdout.write(`${JSON.stringify({ error: message, remedy, performed }, null, 2)}\n`);
254
239
  }
255
240
  throw error;
256
241
  }