dw-sdlc 0.21.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 (94) hide show
  1. package/dist/agentic-config.d.ts +16 -0
  2. package/dist/agentic-config.js +28 -23
  3. package/dist/cannot-run.d.ts +27 -0
  4. package/dist/cannot-run.js +48 -0
  5. package/dist/catalogue.js +2 -1
  6. package/dist/checks/agents-map.js +7 -8
  7. package/dist/checks/check.d.ts +10 -8
  8. package/dist/checks/check.js +9 -7
  9. package/dist/checks/decision-records.js +3 -2
  10. package/dist/checks/file-size-ratchet.js +13 -17
  11. package/dist/checks/index.d.ts +6 -5
  12. package/dist/checks/index.js +10 -9
  13. package/dist/checks/markdown-refs.js +1 -1
  14. package/dist/checks/primitives.js +5 -5
  15. package/dist/checks/routed-docs.js +1 -3
  16. package/dist/checks/skill-sections.js +12 -20
  17. package/dist/checks/tree.js +7 -7
  18. package/dist/cli.d.ts +2 -1
  19. package/dist/cli.js +26 -5
  20. package/dist/commands/check.js +27 -42
  21. package/dist/commands/config.js +22 -27
  22. package/dist/commands/doctor.d.ts +2 -0
  23. package/dist/commands/doctor.js +11 -18
  24. package/dist/commands/gate.js +35 -40
  25. package/dist/commands/lead-start.js +28 -42
  26. package/dist/commands/pr-reviewed.js +41 -59
  27. package/dist/commands/pr-ship.js +71 -89
  28. package/dist/commands/primitives.js +38 -40
  29. package/dist/commands/recap.js +23 -74
  30. package/dist/commands/references-setup.js +28 -76
  31. package/dist/commands/references-sync.js +29 -53
  32. package/dist/commands/setup.d.ts +2 -7
  33. package/dist/commands/setup.js +38 -90
  34. package/dist/commands/sweep.js +46 -157
  35. package/dist/commands/worker-ls.js +15 -31
  36. package/dist/commands/worker-report.js +36 -52
  37. package/dist/commands/worker-start.js +43 -57
  38. package/dist/drift.js +2 -1
  39. package/dist/exec.d.ts +20 -0
  40. package/dist/exec.js +37 -4
  41. package/dist/gate-cache.js +2 -1
  42. package/dist/git.d.ts +32 -0
  43. package/dist/git.js +60 -0
  44. package/dist/json-schema.js +1 -3
  45. package/dist/landed.js +11 -22
  46. package/dist/paused-operation.d.ts +2 -0
  47. package/dist/paused-operation.js +17 -0
  48. package/dist/pins.js +2 -1
  49. package/dist/pr/decide.d.ts +4 -16
  50. package/dist/pr/decide.js +48 -84
  51. package/dist/pr/delta.d.ts +3 -4
  52. package/dist/pr/delta.js +10 -19
  53. package/dist/pr/read.d.ts +27 -30
  54. package/dist/pr/read.js +94 -182
  55. package/dist/pr/reply.js +3 -5
  56. package/dist/pr/route.d.ts +11 -3
  57. package/dist/pr/route.js +5 -5
  58. package/dist/pr/standing.d.ts +138 -0
  59. package/dist/pr/standing.js +181 -0
  60. package/dist/pr/state.d.ts +10 -69
  61. package/dist/pr/state.js +42 -72
  62. package/dist/pr/upstream.d.ts +1 -6
  63. package/dist/pr/upstream.js +2 -2
  64. package/dist/primitives-diff.js +1 -1
  65. package/dist/recap-next.js +34 -47
  66. package/dist/recap-primitives.js +1 -1
  67. package/dist/references-key.d.ts +0 -1
  68. package/dist/references-key.js +3 -4
  69. package/dist/run-command.d.ts +4 -1
  70. package/dist/run-command.js +32 -11
  71. package/dist/setup-gate.d.ts +1 -7
  72. package/dist/setup-rules.js +2 -6
  73. package/dist/setup-writer.d.ts +32 -0
  74. package/dist/setup-writer.js +60 -0
  75. package/dist/shell-line.d.ts +2 -0
  76. package/dist/shell-line.js +5 -0
  77. package/dist/sweep-removal.d.ts +31 -0
  78. package/dist/sweep-removal.js +133 -0
  79. package/dist/sweep-terminals.d.ts +1 -1
  80. package/dist/sweep-terminals.js +21 -57
  81. package/dist/sweep-trees.d.ts +19 -3
  82. package/dist/sweep-trees.js +67 -31
  83. package/dist/worker/herdr.d.ts +67 -23
  84. package/dist/worker/herdr.js +126 -77
  85. package/dist/worker/launch.js +32 -45
  86. package/dist/worker/origin.d.ts +1 -1
  87. package/dist/worker/origin.js +9 -11
  88. package/dist/worker/tabs.js +11 -7
  89. package/dist/working-tree.d.ts +0 -8
  90. package/dist/working-tree.js +6 -22
  91. package/dist/worktree-lock.js +1 -1
  92. package/package.json +1 -1
  93. package/dist/git-env.d.ts +0 -2
  94. package/dist/git-env.js +0 -10
@@ -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,16 +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
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 only merges or a rebase, is named under details.open: the Review then reads
33
- 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.
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.
35
37
  Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
36
38
  Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
37
39
  false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
@@ -46,58 +48,57 @@ Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot r
46
48
  /** The depths --review takes, strictest first, as a sentence lists them. */
47
49
  const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
48
50
  const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
49
- /** 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
+ */
50
55
  class Interrupted extends CannotRun {
51
56
  performed;
52
57
  constructor({ message, remedy }, performed) {
53
- super(message, remedy);
58
+ const ran = performed.map(({ action, exit }) => `${action} ran (gh exit ${exit ?? "unknown"})`);
59
+ super(message, remedy, { after: ran.join("\n") });
54
60
  this.performed = performed;
55
61
  }
56
62
  }
57
63
  function parseCli(argv) {
58
- try {
59
- const { values, positionals } = parseArgs({
60
- args: argv,
61
- allowPositionals: true,
62
- options: {
63
- help: { type: "boolean", short: "h" },
64
- json: { type: "boolean" },
65
- verbose: { type: "boolean" },
66
- merge: { type: "boolean" },
67
- review: { type: "string" },
68
- "dry-run": { type: "boolean" },
69
- repo: { type: "string", short: "R" }
70
- }
71
- });
72
- const [number, ...more] = positionals;
73
- const { repo } = values;
74
- const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
75
- if (values.help !== true) {
76
- if (more.length > 0) {
77
- return { error: `name one pull request, not ${positionals.length}` };
78
- }
79
- const problem = namedProblem(number, repo);
80
- if (problem !== undefined) {
81
- return { error: problem };
82
- }
83
- if (values.review !== undefined && review === undefined) {
84
- return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
85
- }
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);
86
90
  }
87
- return {
88
- help: values.help === true,
89
- json: values.json === true,
90
- verbose: values.verbose === true,
91
- merge: values.merge === true,
92
- review,
93
- dryRun: values["dry-run"] === true,
94
- number: number === undefined ? undefined : Number(number),
95
- repo
96
- };
97
- }
98
- catch (error) {
99
- return { error: error instanceof Error ? error.message : String(error) };
100
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
+ };
101
102
  }
102
103
  /** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
103
104
  const NAMED = {
@@ -116,7 +117,7 @@ const NAMED = {
116
117
  */
117
118
  const TOOK = {
118
119
  // A moved head alone may be a push: the update took effect when the head carries the base it read.
119
- "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),
120
121
  "mark-ready": (_, after) => !after.draft,
121
122
  // The post names the head it read: a head moved since carries none of it.
122
123
  "mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
@@ -144,27 +145,17 @@ function handedOver(pr, policy, answer, action, reason) {
144
145
  }
145
146
  return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action, policy) };
146
147
  }
147
- /**
148
- * The pull request the call names, else the current branch's as `gh pr view` resolves it. Where gh finds
149
- * none for a branch whose upstream has another name, the one of that name, taken only when its commits
150
- * hold the local head: a branch started from another tracks that branch until its first push.
151
- */
152
- function readTarget(cli, root, sized) {
153
- const renamed = cli.number === undefined && cli.repo === undefined ? renamedUpstream(root) : undefined;
154
- if (renamed === undefined) {
155
- return read(cli, "pr ship", sized);
156
- }
157
- const { branch, head, upstream } = renamed;
158
- const found = readBranch(sized, upstream);
159
- const upstreamNamed = `${upstream}, the name its upstream gives`;
160
- if (found === null) {
161
- 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");
162
152
  }
163
- if (found.byUpstream && !holds(found.pr, head)) {
164
- const lacks = `${found.pr.url}, found by ${upstreamNamed}, lacks HEAD ${head.slice(0, 7)}`;
165
- 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);
166
157
  }
167
- return found.pr;
158
+ return found;
168
159
  }
169
160
  /**
170
161
  * Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
@@ -172,8 +163,8 @@ function readTarget(cli, root, sized) {
172
163
  */
173
164
  function ship(cli, root, policy) {
174
165
  // Only pr.review auto routes the depth from the changes, so only it reads them.
175
- const sized = policy.review.depth === "auto";
176
- let pr = readTarget(cli, root, sized);
166
+ const reads = ghReads(policy.review.depth === "auto");
167
+ let pr = readTarget(cli, root, reads);
177
168
  const performed = [];
178
169
  // The head each Action ran on.
179
170
  const postedOn = new Map();
@@ -207,8 +198,8 @@ function ship(cli, root, policy) {
207
198
  const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
208
199
  postedOn.set(action, before.head);
209
200
  try {
210
- pr = readAgain(pr, sized);
211
- performed.push({ ...record, ok: TOOK[action](before, pr) });
201
+ pr = reads.again(pr);
202
+ performed.push({ ...record, ok: TOOK[action](before, pr, reads) });
212
203
  }
213
204
  catch (error) {
214
205
  if (error instanceof CannotRun) {
@@ -225,10 +216,6 @@ function documentOf(pr, answer, performed, verbose) {
225
216
  }
226
217
  export function run(argv) {
227
218
  const cli = parseCli(argv);
228
- if ("error" in cli) {
229
- process.stderr.write(`dw-sdlc pr ship: ${cli.error}\n\n${USAGE}\n`);
230
- return 2;
231
- }
232
219
  if (cli.help) {
233
220
  process.stdout.write(`${USAGE}\n`);
234
221
  return 0;
@@ -246,14 +233,9 @@ export function run(argv) {
246
233
  shipped = ship(cli, root, policy);
247
234
  }
248
235
  catch (error) {
249
- if (error instanceof CannotRun) {
250
- const ran = error instanceof Interrupted ? error.performed : [];
251
- const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
252
- process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
253
- if (cli.json && ran.length > 0) {
254
- process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
255
- }
256
- 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`);
257
239
  }
258
240
  throw error;
259
241
  }
@@ -3,8 +3,8 @@
3
3
  // Primitives a change removes, adds or extends.
4
4
  import { existsSync, realpathSync } from "node:fs";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
- import { parseArgs } from "node:util";
7
6
  import { repositoryRoot, resolveConfig } from "../agentic-config.js";
7
+ import { CannotRun, readArgs, rootDir, usageError } from "../cannot-run.js";
8
8
  import { primitivesNaming, readCatalogue } from "../catalogue.js";
9
9
  import { catalogueFile } from "../checks/primitives.js";
10
10
  import { blockOf, primitivesDiff } from "../primitives-diff.js";
@@ -25,44 +25,42 @@ for a pull request body, which a later run replaces. --json prints instead, per
25
25
  class, file count, summary and docs, each doc with whether the change touches its file, and the count of
26
26
  changed files outside every Primitive. It takes no --paths.
27
27
 
28
- Exit 0 with an answer, 2 when there is no catalogue to read or it is not one, and for --diff when <base>
29
- does not resolve.`;
28
+ Exit 0 with an answer, 2 when --root names no directory, when there is no catalogue to read or it is
29
+ not one, and for --diff when <base> does not resolve.`;
30
30
  const REMEDIES = {
31
31
  missing: "create it, or point gate.checks.primitives.file at the catalogue",
32
32
  invalid: "fix the catalogue; dw-sdlc gate names each problem under the primitives Check"
33
33
  };
34
34
  function parseCli(argv) {
35
- try {
36
- const { values, positionals } = parseArgs({
37
- args: argv,
38
- allowPositionals: true,
39
- options: {
40
- help: { type: "boolean", short: "h" },
41
- json: { type: "boolean" },
42
- paths: { type: "boolean" },
43
- diff: { type: "string" },
44
- root: { type: "string" }
45
- }
46
- });
47
- const cli = {
48
- help: values.help === true,
49
- json: values.json === true,
50
- paths: values.paths === true,
51
- diff: values.diff,
52
- files: positionals,
53
- root: values.root
54
- };
55
- if (cli.help) {
56
- return cli;
35
+ const { values, positionals } = readArgs({
36
+ args: argv,
37
+ allowPositionals: true,
38
+ options: {
39
+ help: { type: "boolean", short: "h" },
40
+ json: { type: "boolean" },
41
+ paths: { type: "boolean" },
42
+ diff: { type: "string" },
43
+ root: { type: "string" }
57
44
  }
58
- if (cli.diff !== undefined && (cli.paths || cli.files.length > 0)) {
59
- return { error: "--diff and --paths exclude each other — ask one question at a time" };
60
- }
61
- return cli.paths || cli.diff !== undefined ? cli : { error: "name the paths with --paths, or a base with --diff" };
45
+ }, USAGE);
46
+ const cli = {
47
+ help: values.help === true,
48
+ json: values.json === true,
49
+ paths: values.paths === true,
50
+ diff: values.diff,
51
+ files: positionals,
52
+ root: values.root
53
+ };
54
+ if (cli.help) {
55
+ return cli;
56
+ }
57
+ if (cli.diff !== undefined && (cli.paths || cli.files.length > 0)) {
58
+ throw usageError("--diff and --paths exclude each other — ask one question at a time", USAGE);
62
59
  }
63
- catch (error) {
64
- return { error: error instanceof Error ? error.message : String(error) };
60
+ if (!cli.paths && cli.diff === undefined) {
61
+ throw usageError("name the paths with --paths, or a base with --diff", USAGE);
65
62
  }
63
+ return cli;
66
64
  }
67
65
  /**
68
66
  * `path` with every symbolic link in it resolved, as git reports the root: the nearest part on disk
@@ -87,26 +85,27 @@ function plain(path, primitives) {
87
85
  ` read: ${docs.join(", ")}`
88
86
  ]);
89
87
  }
88
+ /** Stops the command on every one of `problems`, each on a line of its own, as the bin prints the first. */
89
+ function stopOn(problems) {
90
+ const [first = "the catalogue cannot be read", ...rest] = problems;
91
+ const after = rest.map((problem) => `dw-sdlc primitives: ${problem}`).join("\n");
92
+ throw new CannotRun(first, undefined, rest.length === 0 ? {} : { after });
93
+ }
90
94
  function runDiff(root, base, json) {
91
95
  const answer = primitivesDiff(root, base);
92
96
  if ("problems" in answer) {
93
- process.stderr.write(answer.problems.map((problem) => `dw-sdlc primitives: ${problem}\n`).join(""));
94
- return 2;
97
+ stopOn(answer.problems);
95
98
  }
96
99
  process.stdout.write(json ? `${JSON.stringify(answer, null, 2)}\n` : `${blockOf(answer)}\n`);
97
100
  return 0;
98
101
  }
99
102
  export function run(argv) {
100
103
  const cli = parseCli(argv);
101
- if ("error" in cli) {
102
- process.stderr.write(`dw-sdlc primitives: ${cli.error}\n\n${USAGE}\n`);
103
- return 2;
104
- }
105
104
  if (cli.help) {
106
105
  process.stdout.write(`${USAGE}\n`);
107
106
  return 0;
108
107
  }
109
- const root = repositoryRoot(cli.root ?? process.cwd());
108
+ const root = repositoryRoot(rootDir(cli.root));
110
109
  if (cli.diff !== undefined) {
111
110
  return runDiff(root, cli.diff, cli.json);
112
111
  }
@@ -116,8 +115,7 @@ export function run(argv) {
116
115
  const problems = read.state === "missing"
117
116
  ? [`the catalogue does not exist — ${REMEDIES.missing}`]
118
117
  : read.problems.map((problem) => `${problem} — ${REMEDIES.invalid}`);
119
- process.stderr.write(problems.map((problem) => `dw-sdlc primitives: ${file}: ${problem}\n`).join(""));
120
- return 2;
118
+ stopOn(problems.map((problem) => `${file}: ${problem}`));
121
119
  }
122
120
  const answers = cli.files.map((path) => {
123
121
  const fromRoot = relative(root, realPath(resolve(path)));