dw-sdlc 0.15.2 → 0.17.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.
@@ -0,0 +1,135 @@
1
+ // `dw-sdlc lead start`: starts the next session as a Lead in a herdr terminal of its own, beside the caller and
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
+ import { mainCheckout, repositoryRoot, resolveConfig } from "../agentic-config.js";
5
+ import { describe, lead, refuse, Refusal, wholeNumber } from "../worker/herdr.js";
6
+ import { launch, NAME, spaceOf, TAB_WAIT, undelivered } from "../worker/launch.js";
7
+ export const summary = "start the next session as a Lead in a herdr terminal of its own";
8
+ const USAGE = `Usage: dw-sdlc lead start --name <name> --prompt <first message> [--wait <seconds>] [--json]
9
+
10
+ Starts a Lead in the background, the session after this one, and gives it the first message, such as
11
+ /sdlc-delegate #326. It runs hc run with the name, the label role=lead, --outlive-parent and the main
12
+ checkout as the folder, then worker.command of the config with the first message. It drops
13
+ --dangerously-skip-permissions from the command, since a session never starts another that skips them, and
14
+ any --worktree or -w with its name, since a Lead works in the main checkout. worker.command is split on
15
+ whitespace, so it holds no quoted word. When the caller sits in a herdr space the Lead joins it, and its tab,
16
+ which herdr numbers, takes the Lead's name: the window lists the tab a moment after hc run, so the command
17
+ looks for it for up to ${TAB_WAIT} seconds, or --wait when shorter, while it reads the terminal back.
18
+
19
+ It reads the terminal back for up to --wait seconds (default 30), until the Lead shows work: its status
20
+ turns working, blocked or done. It fails when herdr reports the first message as unsent, the terminal has
21
+ ended, the Lead reports an error, or the Lead is still idle or silent when the time is up. It never sends
22
+ the first message again: read the terminal with hc read first.
23
+
24
+ It refuses outside herdr ($HC_TERMINAL_ID unset) and in a linked worktree, where a Lead cannot start its
25
+ Workers. The name is 1-32 of a-z, 0-9, - and _, such as lead-<issue>-<slug>.
26
+
27
+ One line, or one JSON document with --json.
28
+
29
+ Exit 0 started and delivered, 1 started but not delivered, 2 when it cannot run.`;
30
+ const DEFAULT_WAIT = 30;
31
+ /** What a session started by a session never carries: it would run with no permission check. */
32
+ const UNSAFE = "--dangerously-skip-permissions";
33
+ /** The flags that put a Claude Code session in a worktree of its own, each taking an optional name. */
34
+ const WORKTREE = new Set(["--worktree", "-w"]);
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>" };
56
+ }
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 };
62
+ }
63
+ catch (error) {
64
+ return { error: describe(error) };
65
+ }
66
+ }
67
+ /** `words` without the flag that skips permissions and without a worktree flag, its name with it when it has one. */
68
+ function leadCommand(words) {
69
+ const kept = [];
70
+ for (let index = 0; index < words.length; index += 1) {
71
+ const word = words[index] ?? "";
72
+ if (word === UNSAFE || word.startsWith("--worktree=")) {
73
+ continue;
74
+ }
75
+ if (WORKTREE.has(word)) {
76
+ const next = words[index + 1];
77
+ if (next !== undefined && !next.startsWith("-")) {
78
+ index += 1;
79
+ }
80
+ continue;
81
+ }
82
+ kept.push(word);
83
+ }
84
+ return kept;
85
+ }
86
+ async function start(cli) {
87
+ const caller = lead();
88
+ const checkout = repositoryRoot(process.cwd());
89
+ const main = mainCheckout(checkout);
90
+ 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`);
92
+ }
93
+ const command = leadCommand(resolveConfig(checkout).config.worker.command.split(/\s+/).filter(Boolean));
94
+ 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");
96
+ }
97
+ const { address, tab, reason } = await launch("lead start", {
98
+ name: cli.name,
99
+ role: "lead",
100
+ space: spaceOf(caller),
101
+ cwd: checkout,
102
+ argv: [...command, cli.prompt],
103
+ wait: cli.wait
104
+ });
105
+ if (cli.json) {
106
+ const started = { name: cli.name, address, command: command.join(" "), cwd: checkout };
107
+ const document = { ...started, delivered: reason === undefined, ...(reason === undefined ? {} : { reason }), tab };
108
+ process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
109
+ }
110
+ if (reason !== undefined) {
111
+ process.stderr.write(undelivered("lead start", cli.name, address, reason, "first message"));
112
+ return 1;
113
+ }
114
+ if (!cli.json) {
115
+ process.stdout.write(`started ${cli.name} (${address}) in ${checkout}\n`);
116
+ }
117
+ return 0;
118
+ }
119
+ export async function run(argv) {
120
+ const cli = parseCli(argv);
121
+ if (cli === "help") {
122
+ process.stdout.write(`${USAGE}\n`);
123
+ return 0;
124
+ }
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
+ }
135
+ }
@@ -4,7 +4,7 @@
4
4
  import { parseArgs } from "node:util";
5
5
  import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
6
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, withBehind } from "../pr/read.js";
7
+ import { CannotRun, carriesBase, gh, namedProblem, nameItRemedy, now, read, readAgain, readBranch, said } from "../pr/read.js";
8
8
  import { holds, renamedUpstream } from "../pr/upstream.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]
@@ -14,18 +14,17 @@ is omitted, and answers the Action it needs next: the status, the action, whose
14
14
  A branch pushed under another name has an upstream of that name. Where gh resolves no pull request
15
15
  for it, the script asks for the upstream's, and reads it only when its commits hold HEAD.
16
16
  The script performs its own Actions, each at most once and then reads again: it updates a branch
17
- behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
17
+ GitHub reports BEHIND, marks a draft ready once nothing else holds it back, and squash merges a pull
18
18
  request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
19
- under ask, the default, it parks with the merge command. A branch is behind when the compare of
20
- the base with the head shows the head lacks a commit of the base, or GitHub reports BEHIND, which
21
- it does only under a rule that a branch be up to date. A head without a ${REVIEW} success
22
- gets the Review depth --review names for this call, else pr.review: under full the agent reviews it
23
- with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
24
- subagent reads the diff; under off the script posts the success itself, its description saying
25
- why. Under auto, the default, the script routes the depth from the changes the Review would read:
26
- lockfiles alone off; a file under .github/workflows/, more than 100 changed lines outside
27
- lockfiles, or a change it cannot size, more files than GitHub's compare lists or a file outside
28
- lockfiles with no patch, full; the rest light. After a push it names
19
+ under ask, the default, it parks with the merge command. GitHub reports BEHIND only under a rule
20
+ that a branch be up to date; a branch only behind its base merges as it is. A head without a
21
+ ${REVIEW} success gets the Review depth --review names for this call, else pr.review: under full
22
+ the agent reviews it with the command pr.reviewCommand names, /code-review by default; under light
23
+ one sdlc-reviewer subagent reads the diff; under off the script posts the success itself, its
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
29
28
  the last commit a Review read under details.since, where the Review reads from and auto routes from:
30
29
  one with a ${REVIEW} success not posted under off, or the read mark pr reviewed --findings
31
30
  posts. It carries a success over a head whose only commits since are merges of the base, unless
@@ -171,7 +170,7 @@ function readTarget(cli, root, sized) {
171
170
  function ship(cli, root, policy) {
172
171
  // Only pr.review auto routes the depth from the changes, so only it reads them.
173
172
  const sized = policy.review.depth === "auto";
174
- let pr = withBehind(readTarget(cli, root, sized));
173
+ let pr = readTarget(cli, root, sized);
175
174
  const performed = [];
176
175
  // The head each Action ran on.
177
176
  const postedOn = new Map();
@@ -205,7 +204,7 @@ function ship(cli, root, policy) {
205
204
  const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
206
205
  postedOn.set(action, before.head);
207
206
  try {
208
- pr = withBehind(readAgain(pr, sized));
207
+ pr = readAgain(pr, sized);
209
208
  performed.push({ ...record, ok: TOOK[action](before, pr) });
210
209
  }
211
210
  catch (error) {
@@ -1,2 +1,2 @@
1
- export declare const summary = "the facts a Recap rests on and whether the session can be closed";
1
+ export declare const summary = "the facts a Recap rests on, whether the session can be closed and what could come next";
2
2
  export declare function run(argv: string[]): number;
@@ -7,9 +7,10 @@ import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.j
7
7
  import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
8
8
  import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
9
9
  import { holds, upstreamName } from "../pr/upstream.js";
10
+ import { READY_LABEL, readNext } from "../recap-next.js";
10
11
  import { isLive, Refusal, workersOf, workingFolders } from "../worker/herdr.js";
11
12
  import { asked, git } from "../working-tree.js";
12
- export const summary = "the facts a Recap rests on and whether the session can be closed";
13
+ export const summary = "the facts a Recap rests on, whether the session can be closed and what could come next";
13
14
  const USAGE = `Usage: dw-sdlc recap [--json]
14
15
 
15
16
  Reads the current branch, its uncommitted changes, the commits that reach no remote, its pull request
@@ -34,6 +35,12 @@ stand. A branch force-pushed since the last fetch can still read as holding it,
34
35
  A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
35
36
  directory it runs in.
36
37
 
38
+ Beside the verdict it lists the candidates for the next session: the open issues labelled
39
+ ${READY_LABEL} with no assignee and no open blocker, of the 100 opened first. The open sub-issues of an
40
+ issue the branch's pull request closes come first, then the rest, each in issue order; an issue it closes
41
+ is no candidate. Choosing one is the Recap's, never the command's. A backlog it cannot read lists none,
42
+ with a warning, and leaves the verdict as it is.
43
+
37
44
  One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read, and in
38
45
  herdr hc on PATH. A detached HEAD or a repository with no remote asks gh nothing, and the pull request
39
46
  fact says so; outside herdr the workers fact says so.
@@ -268,7 +275,17 @@ function workersLine(workers) {
268
275
  }
269
276
  return workers.length === 0 ? "none live" : `${workers.length} live: ${workersText(workers)}`;
270
277
  }
271
- function linesOf(local, { shipped, askedGh, workers }, line) {
278
+ function nextLine(next) {
279
+ if (next === null) {
280
+ return "gh not asked";
281
+ }
282
+ if ("unread" in next) {
283
+ return `not read, ${next.unread}`;
284
+ }
285
+ const named = next.candidates.map(({ number, parent }) => `#${number}${parent === null ? "" : ` (sub-issue of #${parent})`}`);
286
+ return named.length === 0 ? "none" : named.join(", ");
287
+ }
288
+ function linesOf(local, { shipped, askedGh, workers, next }, line) {
272
289
  const none = askedGh ? "none" : "gh not asked";
273
290
  return [
274
291
  `branch: ${local.branch ?? "detached HEAD"}`,
@@ -277,6 +294,7 @@ function linesOf(local, { shipped, askedGh, workers }, line) {
277
294
  `pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
278
295
  `worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
279
296
  `workers: ${workersLine(workers)}`,
297
+ `next: ${nextLine(next)}`,
280
298
  line
281
299
  ].join("\n");
282
300
  }
@@ -316,14 +334,19 @@ function report(json) {
316
334
  ahead,
317
335
  orphaned: orphanedOf(root, read, shipped, held),
318
336
  askedGh: asksGh,
319
- workers: liveWorkers()
337
+ workers: liveWorkers(),
338
+ next: asksGh ? readNext(shipped === null ? null : shipped.pr) : null
320
339
  };
340
+ if (facts.next !== null && "unread" in facts.next) {
341
+ process.stderr.write(`dw-sdlc recap: warning: the backlog was not read: ${facts.next.unread}\n`);
342
+ }
343
+ const next = facts.next !== null && "candidates" in facts.next ? facts.next.candidates : null;
321
344
  const close = closeOf(reasonsOf(local, facts));
322
345
  const pullRequest = shipped === null
323
346
  ? null
324
347
  : { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
325
348
  const output = json
326
- ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close }, null, 2)
349
+ ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close, next }, null, 2)
327
350
  : linesOf(local, facts, close.line);
328
351
  process.stdout.write(`${output}\n`);
329
352
  return close.verdict === "yes" ? 0 : 1;
@@ -2,16 +2,12 @@
2
2
  // and effort the config names, and reads back whether the Worker took it.
3
3
  import { readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
- import { setTimeout as sleep } from "node:timers/promises";
6
5
  import { parseArgs } from "node:util";
7
6
  import { mainCheckout, repositoryRoot, resolveConfig, WORKER_EFFORTS } from "../agentic-config.js";
8
- import { isObject } from "../json-text.js";
9
- import { describe, hcObject, isLive, lead, refuse, Refusal, wholeNumber, workersOf } from "../worker/herdr.js";
7
+ import { describe, isLive, lead, refuse, Refusal, wholeNumber, workersOf } from "../worker/herdr.js";
8
+ import { launch, NAME, spaceOf, TAB_WAIT, undelivered } from "../worker/launch.js";
10
9
  import { freshenOrigin } from "../worker/origin.js";
11
- import { nameTab } from "../worker/tabs.js";
12
10
  export const summary = "start a Worker on a Brief in a herdr terminal";
13
- /** The most seconds the command looks for the Worker's tab in the window, beside the read-back. */
14
- const TAB_WAIT = 10;
15
11
  const USAGE = `Usage: dw-sdlc worker start --brief <file> --name <name> [--model <model>] [--effort <effort>]
16
12
  [--max-workers <n>] [--wait <seconds>] [--json]
17
13
 
@@ -41,12 +37,6 @@ One line, or one JSON document with --json.
41
37
  Exit 0 started and delivered, 1 started but not delivered, 2 when it cannot run, a failed fetch included.`;
42
38
  const DEFAULT_MAX_WORKERS = 3;
43
39
  const DEFAULT_WAIT = 30;
44
- /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
45
- const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
46
- /** What a Worker reports once it has taken its first message. */
47
- const WORKING = new Set(["working", "blocked", "done"]);
48
- /** How long the read-back waits between two reads of the terminal. */
49
- const POLL_MS = 1000;
50
40
  function parseCli(argv) {
51
41
  try {
52
42
  const { values } = parseArgs({
@@ -106,56 +96,6 @@ function readBrief(file) {
106
96
  }
107
97
  return text;
108
98
  }
109
- /** The status a Worker reports, if it has reported one. */
110
- function reportedStatus(terminal) {
111
- const { status } = terminal;
112
- return isObject(status) && typeof status.state === "string" ? status.state : undefined;
113
- }
114
- /** Why the Worker can no longer take the Brief, or undefined while it still may. */
115
- function failure(terminal) {
116
- const { first_message: first, state } = terminal;
117
- if (isObject(first) && first.sent === false) {
118
- return typeof first.reason === "string" ? first.reason : "herdr gave no reason";
119
- }
120
- if (state !== "live") {
121
- return "the terminal has finished";
122
- }
123
- return reportedStatus(terminal) === "error" ? "the Worker reports its status as error" : undefined;
124
- }
125
- /** Reads the terminal until the Worker shows work, for up to `wait` seconds; why it did not, or undefined once it did. */
126
- async function delivery(address, wait) {
127
- const deadline = Date.now() + wait * 1000;
128
- for (;;) {
129
- let terminal;
130
- try {
131
- terminal = hcObject(["get", address, "--json"]);
132
- }
133
- catch (error) {
134
- if (!(error instanceof Refusal)) {
135
- throw error;
136
- }
137
- // The Worker is running by now, so a failed read is a Brief not confirmed, never a start that did not happen.
138
- return `the terminal could not be read back: ${describe(error)}`;
139
- }
140
- const failed = failure(terminal);
141
- if (failed !== undefined) {
142
- return failed;
143
- }
144
- if (WORKING.has(reportedStatus(terminal) ?? "")) {
145
- return undefined;
146
- }
147
- if (Date.now() >= deadline) {
148
- return `the Worker showed no sign of work within ${wait} s`;
149
- }
150
- await sleep(POLL_MS);
151
- }
152
- }
153
- /** The id of the herdr space the Lead sits in, if it sits in one. */
154
- function spaceOf(leadId) {
155
- const { labels } = hcObject(["get", leadId, "--json"]);
156
- const space = isObject(labels) ? labels["herdr.space"] : undefined;
157
- return typeof space === "string" ? space : undefined;
158
- }
159
99
  async function start(cli) {
160
100
  const brief = readBrief(cli.brief);
161
101
  const leadId = lead();
@@ -175,47 +115,21 @@ async function start(cli) {
175
115
  }
176
116
  // Before hc run, so a failed fetch starts no terminal.
177
117
  freshenOrigin(root, baseBranch);
178
- const space = spaceOf(leadId);
179
- const { address } = hcObject([
180
- "run",
181
- "--bg",
182
- "--json",
183
- "--name",
184
- cli.name,
185
- "--label",
186
- "role=worker",
187
- ...(space === undefined ? [] : ["--label", `herdr.space=${space}`]),
188
- "--outlive-parent",
189
- "--cwd",
190
- root,
191
- "--",
192
- ...command,
193
- "--worktree",
194
- cli.name,
195
- "--model",
196
- model,
197
- "--effort",
198
- effort,
199
- brief
200
- ]);
201
- if (typeof address !== "string") {
202
- throw new Refusal("hc run answered no address for the terminal it started", "check hc ls before starting it again");
203
- }
204
- // Side by side, so the wait for the tab overlaps the wait for the Worker to start.
205
- const naming = space === undefined ? Promise.resolve("none") : nameTab(address, cli.name, Math.min(TAB_WAIT, cli.wait));
206
- const [named, reason] = await Promise.all([naming, delivery(address, cli.wait)]);
207
- if (typeof named === "object") {
208
- process.stderr.write(`dw-sdlc worker start: warning: ${named.kept}\n`);
209
- }
118
+ const { address, tab, reason } = await launch("worker start", {
119
+ name: cli.name,
120
+ role: "worker",
121
+ space: spaceOf(leadId),
122
+ cwd: root,
123
+ argv: [...command, "--worktree", cli.name, "--model", model, "--effort", effort, brief],
124
+ wait: cli.wait
125
+ });
210
126
  if (cli.json) {
211
- const tab = typeof named === "object" ? "kept" : named;
212
127
  const started = { name: cli.name, address, command: worker.command, model, effort, cwd: root };
213
128
  const document = { ...started, delivered: reason === undefined, ...(reason === undefined ? {} : { reason }), tab };
214
129
  process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
215
130
  }
216
131
  if (reason !== undefined) {
217
- process.stderr.write(`dw-sdlc worker start: ${cli.name} (${address}) started but its first message was not delivered: ${reason} — ` +
218
- `run hc read ${address} to see why; do not send the Brief again before you have read it\n`);
132
+ process.stderr.write(undelivered("worker start", cli.name, address, reason, "Brief"));
219
133
  return 1;
220
134
  }
221
135
  if (!cli.json) {
package/dist/pr/decide.js CHANGED
@@ -157,7 +157,9 @@ const ROWS = [
157
157
  action: "done",
158
158
  actor: "dev",
159
159
  reason: pr.mergeCommit === null ? "merged" : `merged as ${pr.mergeCommit.slice(0, 7)}`,
160
- ...(pr.branchStands ? { command: shellLine(["git", "push", "origin", "--delete", pr.branch]) } : {}),
160
+ ...(pr.branchStands && !pr.fromFork
161
+ ? { command: shellLine(["git", "push", "origin", "--delete", pr.branch]) }
162
+ : {}),
161
163
  ...(pr.mergeCommit === null ? {} : { details: { mergeCommit: pr.mergeCommit } })
162
164
  }),
163
165
  alone: true
@@ -189,8 +191,9 @@ const ROWS = [
189
191
  })
190
192
  },
191
193
  {
192
- // GitHub reports BEHIND only under a rule that a branch be up to date; the compare tells it anywhere.
193
- when: (pr) => pr.mergeStateStatus === "BEHIND" || (pr.behindBy ?? 0) > 0,
194
+ // GitHub reports BEHIND only under a rule that a branch be up to date. A branch only behind its base
195
+ // merges as it is: CI ran on its merge with the base as the base stood at that run.
196
+ when: (pr) => pr.mergeStateStatus === "BEHIND",
194
197
  answer: (pr) => ({ status: "act", action: "update-branch", actor: "script", reason: `behind ${pr.base}` })
195
198
  },
196
199
  {
@@ -249,8 +252,9 @@ const ROWS = [
249
252
  }
250
253
  },
251
254
  {
252
- // A head whose every check was skipped has run nothing: a draft's are, until `ready` starts the run.
253
- when: (pr) => ciOf(pr).length === 0 || (!pr.draft && ciOf(pr).every((check) => check.bucket === "skipping")),
255
+ // A head whose every check was skipped, or that has none, has run nothing. A draft's runs skip or
256
+ // never start until `ready` starts the run, so a draft is no case of it: `mark-ready` is its row.
257
+ when: (pr) => !pr.draft && (ciOf(pr).length === 0 || ciOf(pr).every((check) => check.bucket === "skipping")),
254
258
  answer: (pr, now, policy) => now - Math.max(Date.parse(pr.headCommittedAt), Date.parse(pr.readyAt ?? pr.headCommittedAt)) <=
255
259
  GRACE_SECONDS * 1000
256
260
  ? { status: "wait", action: "wait", actor: "script", reason: "unverified", recheckAfter: 15 }
package/dist/pr/read.d.ts CHANGED
@@ -43,8 +43,3 @@ export declare function readAgain(pr: PrState, sized: boolean): PrState;
43
43
  export declare function forgetCompares(): void;
44
44
  /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
45
45
  export declare function carriesBase(before: PrState, after: PrState): boolean;
46
- /**
47
- * `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
48
- * read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
49
- */
50
- export declare function withBehind(pr: PrState): PrState;
package/dist/pr/read.js CHANGED
@@ -187,17 +187,6 @@ export function carriesBase(before, after) {
187
187
  const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
188
188
  return parseOrCannotRun(() => behindOf(reply)) === 0;
189
189
  }
190
- /**
191
- * `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
192
- * read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
193
- */
194
- export function withBehind(pr) {
195
- if (pr.state !== "OPEN" || pr.mergeable !== "MERGEABLE" || pr.mergeStateStatus === "BEHIND") {
196
- return pr;
197
- }
198
- const reply = compared(pr, pr.baseOid, pr.head, `the compare with ${pr.base}`);
199
- return { ...pr, behindBy: parseOrCannotRun(() => behindOf(reply)) };
200
- }
201
190
  /** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
202
191
  function ownChanges(pr, commit) {
203
192
  const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
@@ -248,5 +237,5 @@ function query({ repo, number }, sized) {
248
237
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
249
238
  }
250
239
  const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
251
- return withChanged(withCarried({ ...parsed, behindBy: null }), sized);
240
+ return withChanged(withCarried(parsed), sized);
252
241
  }
@@ -10,7 +10,7 @@ export declare const MARK_STATE = "failure";
10
10
  export declare const READ_MARK = "read, findings open";
11
11
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
12
  export declare const ACCEPTED = "accepted by the dev, findings open";
13
- export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
13
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } isCrossRepository baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
14
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
15
15
  export type CiCheck = {
16
16
  name: string;
@@ -56,6 +56,8 @@ export type PrState = {
56
56
  branch: string;
57
57
  /** Whether the head branch still stands on the remote: GitHub reports no `headRef` once it is gone. */
58
58
  branchStands: boolean;
59
+ /** Whether the pull request comes from a fork: its head branch lives on the fork, not on `origin`. */
60
+ fromFork: boolean;
59
61
  base: string;
60
62
  /**
61
63
  * The commit the base branch points at, read from the branch itself: GitHub's `baseRefOid` lags a
@@ -86,11 +88,6 @@ export type PrState = {
86
88
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
87
89
  */
88
90
  changed: ChangedFile[] | "unsized" | null;
89
- /**
90
- * How many commits of the base the head lacks, from the compare of the base with the head; null when
91
- * not read.
92
- */
93
- behindBy: number | null;
94
91
  };
95
92
  /**
96
93
  * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
@@ -147,5 +144,5 @@ export declare function parseView(reply: string): {
147
144
  /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
148
145
  export declare function parseDefaultBranch(reply: string): string;
149
146
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
150
- export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "behindBy">;
147
+ export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
151
148
  export {};
package/dist/pr/state.js CHANGED
@@ -18,7 +18,7 @@ const THREADS_READ = 100;
18
18
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
19
19
  repository(owner: $owner, name: $name) {
20
20
  pullRequest(number: $number) {
21
- url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } baseRefName
21
+ url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } isCrossRepository baseRefName
22
22
  baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
23
23
  ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }
24
24
  latestReviews(first: 100) { nodes { state author { login } commit { oid } } }
@@ -319,6 +319,7 @@ export function parsePr(reply, repo) {
319
319
  readyAt: ready === undefined ? null : time(ready, "createdAt"),
320
320
  branch: string(pr, "headRefName"),
321
321
  branchStands: childOrNull(pr, "headRef") !== null,
322
+ fromFork: boolean(pr, "isCrossRepository"),
322
323
  base: string(pr, "baseRefName"),
323
324
  baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
324
325
  mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
@@ -0,0 +1,19 @@
1
+ /** The label that marks an issue as ready for an agent to start. */
2
+ export declare const READY_LABEL = "ready-for-agent";
3
+ /** An issue the next session may start; `parent` the issue it is a sub-issue of, null for none. */
4
+ export type Candidate = {
5
+ number: number;
6
+ title: string;
7
+ url: string;
8
+ parent: number | null;
9
+ };
10
+ /** The candidates, or why the backlog could not be read: a Recap closes without them, so no failure stops it. */
11
+ export type Next = {
12
+ candidates: Candidate[];
13
+ } | {
14
+ unread: string;
15
+ };
16
+ export declare function readNext(pr: {
17
+ repo: string;
18
+ number: number;
19
+ } | null): Next;