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.
- package/README.md +8 -1
- package/config/agentic.config.schema.json +52 -3
- package/dist/agentic-config.d.ts +13 -3
- package/dist/agentic-config.js +8 -0
- package/dist/checks/comment-budget.js +1 -252
- package/dist/checks/comments.d.ts +29 -0
- package/dist/checks/comments.js +255 -0
- package/dist/checks/disable-directives.d.ts +6 -0
- package/dist/checks/disable-directives.js +91 -0
- package/dist/checks/hash-comments.d.ts +4 -0
- package/dist/checks/hash-comments.js +121 -0
- package/dist/checks/index.js +3 -1
- package/dist/cli.js +3 -1
- package/dist/commands/gate.d.ts +4 -1
- package/dist/commands/gate.js +22 -10
- package/dist/commands/lead-start.d.ts +2 -0
- package/dist/commands/lead-start.js +135 -0
- package/dist/commands/pr-ship.js +13 -14
- package/dist/commands/recap.d.ts +1 -1
- package/dist/commands/recap.js +27 -4
- package/dist/commands/worker-start.js +11 -97
- package/dist/pr/decide.js +9 -5
- package/dist/pr/read.d.ts +0 -5
- package/dist/pr/read.js +1 -12
- package/dist/pr/state.d.ts +4 -7
- package/dist/pr/state.js +2 -1
- package/dist/recap-next.d.ts +19 -0
- package/dist/recap-next.js +105 -0
- package/dist/report.d.ts +8 -2
- package/dist/report.js +8 -3
- package/dist/worker/launch.d.ts +31 -0
- package/dist/worker/launch.js +100 -0
- package/dist/worker/origin.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/pr-ship.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
lockfiles
|
|
27
|
-
|
|
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 =
|
|
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 =
|
|
207
|
+
pr = readAgain(pr, sized);
|
|
209
208
|
performed.push({ ...record, ok: TOOK[action](before, pr) });
|
|
210
209
|
}
|
|
211
210
|
catch (error) {
|
package/dist/commands/recap.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const summary = "the facts a Recap rests on
|
|
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;
|
package/dist/commands/recap.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
179
|
-
|
|
180
|
-
"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
"--name",
|
|
184
|
-
cli.
|
|
185
|
-
|
|
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(
|
|
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
|
|
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
|
|
193
|
-
|
|
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
|
|
253
|
-
|
|
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(
|
|
240
|
+
return withChanged(withCarried(parsed), sized);
|
|
252
241
|
}
|
package/dist/pr/state.d.ts
CHANGED
|
@@ -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"
|
|
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;
|