dw-sdlc 0.15.2 → 0.16.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.
package/README.md CHANGED
@@ -48,7 +48,7 @@ its `roots` to `maxProseLines` lines of prose, 3 by default.
48
48
  `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
49
49
  answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
50
50
  whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
51
- performs its own Actions: it updates a branch behind its base, marks the head reviewed when the
51
+ performs its own Actions: it updates a branch GitHub reports `BEHIND`, marks the head reviewed when the
52
52
  Review depth is `off`, marks a draft ready once nothing else holds it back, and squash merges a pull
53
53
  request that is green, reviewed and opened by the `gh` account, when the merge is its own. `--dry-run` performs nothing: it answers the first Action the
54
54
  script would perform as the dev's. It never deletes a branch; a merged pull request answers with
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import * as check from "./commands/check.js";
5
5
  import * as config from "./commands/config.js";
6
6
  import * as doctor from "./commands/doctor.js";
7
7
  import * as gate from "./commands/gate.js";
8
+ import * as leadStart from "./commands/lead-start.js";
8
9
  import * as prReviewed from "./commands/pr-reviewed.js";
9
10
  import * as prShip from "./commands/pr-ship.js";
10
11
  import * as primitives from "./commands/primitives.js";
@@ -31,7 +32,8 @@ const COMMANDS = new Map([
31
32
  ["sweep", sweep],
32
33
  ["worker start", workerStart],
33
34
  ["worker ls", workerLs],
34
- ["worker report", workerReport]
35
+ ["worker report", workerReport],
36
+ ["lead start", leadStart]
35
37
  ]);
36
38
  function usage() {
37
39
  const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
@@ -0,0 +1,2 @@
1
+ export declare const summary = "start the next session as a Lead in a herdr terminal of its own";
2
+ export declare function run(argv: string[]): Promise<number>;
@@ -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;
@@ -0,0 +1,105 @@
1
+ // The candidates for the session after this one, as `dw-sdlc recap` reads them: the open issues an agent may
2
+ // start, from one GraphQL request that also asks which issues the branch's pull request closes.
3
+ import { isObject, parseObject } from "./json-text.js";
4
+ import { CannotRun, gh, said } from "./pr/read.js";
5
+ /** The label that marks an issue as ready for an agent to start. */
6
+ export const READY_LABEL = "ready-for-agent";
7
+ /** How many open issues the one read holds, oldest first, and how many issues the pull request closes. */
8
+ const ISSUES_READ = 100;
9
+ const CLOSING_READ = 25;
10
+ const QUERY = `query Backlog($owner: String!, $name: String!, $number: Int!, $closing: Boolean!) {
11
+ repository(owner: $owner, name: $name) {
12
+ pullRequest(number: $number) @include(if: $closing) { closingIssuesReferences(first: ${CLOSING_READ}) { nodes { number } } }
13
+ issues(first: ${ISSUES_READ}, states: OPEN, labels: ["${READY_LABEL}"], orderBy: { field: CREATED_AT, direction: ASC }) {
14
+ nodes { number title url assignees { totalCount } issueDependenciesSummary { blockedBy } parent { number } }
15
+ }
16
+ }
17
+ }`;
18
+ const REMEDY = "check gh auth status and the network, then call again";
19
+ function malformed(field) {
20
+ return new CannotRun(`the backlog reply does not parse: ${field}`, "gh answered a shape dw-sdlc does not read; report it with the output of gh --version");
21
+ }
22
+ function nodesOf(value, field) {
23
+ if (!isObject(value) || !Array.isArray(value.nodes)) {
24
+ throw malformed(field);
25
+ }
26
+ return value.nodes;
27
+ }
28
+ function numberOf(value, field) {
29
+ if (!isObject(value) || typeof value.number !== "number") {
30
+ throw malformed(field);
31
+ }
32
+ return value.number;
33
+ }
34
+ /** An issue node as a candidate, or null when it has an assignee or an open blocker. */
35
+ function candidateOf(node) {
36
+ if (!isObject(node) || typeof node.title !== "string" || typeof node.url !== "string") {
37
+ throw malformed("issues.nodes");
38
+ }
39
+ const { assignees, issueDependenciesSummary: dependencies, parent } = node;
40
+ if (!isObject(assignees) || typeof assignees.totalCount !== "number") {
41
+ throw malformed("issues.nodes.assignees");
42
+ }
43
+ if (!isObject(dependencies) || typeof dependencies.blockedBy !== "number") {
44
+ throw malformed("issues.nodes.issueDependenciesSummary");
45
+ }
46
+ if (assignees.totalCount > 0 || dependencies.blockedBy > 0) {
47
+ return null;
48
+ }
49
+ const number = numberOf(node, "issues.nodes.number");
50
+ return { number, title: node.title, url: node.url, parent: parent === null ? null : numberOf(parent, "parent") };
51
+ }
52
+ function repositoryOf(stdout) {
53
+ const data = parseObject(stdout)?.data;
54
+ const repository = isObject(data) ? data.repository : undefined;
55
+ if (!isObject(repository)) {
56
+ throw malformed("data.repository");
57
+ }
58
+ return repository;
59
+ }
60
+ /**
61
+ * The open issues labelled ready-for-agent with no assignee and no open blocker, read from `repo`, or from the
62
+ * repository gh resolves when it is null. The open sub-issues of an issue pull request `pr` closes come
63
+ * first, then the rest, each part in issue order; the issues it closes are never candidates.
64
+ */
65
+ function nextCandidates(pr) {
66
+ const [owner, name] = pr === null ? ["{owner}", "{repo}"] : pr.repo.split("/");
67
+ const reply = gh([
68
+ "api",
69
+ "graphql",
70
+ "-f",
71
+ `query=${QUERY}`,
72
+ "-F",
73
+ `owner=${owner ?? ""}`,
74
+ "-F",
75
+ `name=${name ?? ""}`,
76
+ "-F",
77
+ `number=${pr?.number ?? 0}`,
78
+ "-F",
79
+ `closing=${pr !== null}`
80
+ ]);
81
+ if (reply.status !== 0) {
82
+ throw new CannotRun(`gh could not read the backlog: ${said(reply.stderr)}`, REMEDY);
83
+ }
84
+ const repository = repositoryOf(reply.stdout);
85
+ const closes = new Set(pr === null
86
+ ? []
87
+ : nodesOf(isObject(repository.pullRequest) ? repository.pullRequest.closingIssuesReferences : undefined, "pullRequest").map((node) => numberOf(node, "closingIssuesReferences.nodes")));
88
+ const candidates = nodesOf(repository.issues, "issues")
89
+ .map(candidateOf)
90
+ .filter((candidate) => candidate !== null && !closes.has(candidate.number))
91
+ .toSorted((one, other) => one.number - other.number);
92
+ const underClosed = (candidate) => candidate.parent !== null && closes.has(candidate.parent);
93
+ return [...candidates.filter(underClosed), ...candidates.filter((candidate) => !underClosed(candidate))];
94
+ }
95
+ export function readNext(pr) {
96
+ try {
97
+ return { candidates: nextCandidates(pr) };
98
+ }
99
+ catch (error) {
100
+ if (error instanceof CannotRun) {
101
+ return { unread: error.message };
102
+ }
103
+ throw error;
104
+ }
105
+ }
@@ -0,0 +1,31 @@
1
+ /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
2
+ export declare const NAME: RegExp;
3
+ /** The most seconds a start looks for the new tab in the window, beside the read-back. */
4
+ export declare const TAB_WAIT = 10;
5
+ /** The id of the herdr space the terminal `id` sits in, if it sits in one. */
6
+ export declare function spaceOf(id: string): string | undefined;
7
+ export type Launch = {
8
+ name: string;
9
+ /** The `role` label of the new terminal. */
10
+ role: "worker" | "lead";
11
+ /** The herdr space of the caller, which the new terminal joins; none leaves the tab alone. */
12
+ space: string | undefined;
13
+ /** The folder it starts in. */
14
+ cwd: string;
15
+ /** The program and its arguments, its first message last. */
16
+ argv: string[];
17
+ /** The most seconds the read-back waits. */
18
+ wait: number;
19
+ };
20
+ export type Launched = {
21
+ address: string;
22
+ tab: "none" | "renamed" | "kept";
23
+ reason: string | undefined;
24
+ };
25
+ /**
26
+ * Runs `hc run` once for `launch`, outliving its caller, then names the tab and reads the terminal back side
27
+ * by side. It never sends the first message again; `reason` says why the session did not take it.
28
+ */
29
+ export declare function launch(command: string, { name, role, space, cwd, argv, wait }: Launch): Promise<Launched>;
30
+ /** The line that says a started session did not take its first message, and how to recover. */
31
+ export declare function undelivered(command: string, name: string, address: string, reason: string, message: string): string;
@@ -0,0 +1,100 @@
1
+ // What `worker start` and `lead start` share: a session started in a herdr terminal of its own beside the
2
+ // caller, its tab named, and the terminal read back until the session shows it took its first message.
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { isObject } from "../json-text.js";
5
+ import { describe, hcObject, Refusal } from "./herdr.js";
6
+ import { nameTab } from "./tabs.js";
7
+ /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
8
+ export const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
9
+ /** The most seconds a start looks for the new tab in the window, beside the read-back. */
10
+ export const TAB_WAIT = 10;
11
+ /** What a session reports once it has taken its first message. */
12
+ const WORKING = new Set(["working", "blocked", "done"]);
13
+ /** How long the read-back waits between two reads of the terminal. */
14
+ const POLL_MS = 1000;
15
+ /** The status a session reports, if it has reported one. */
16
+ function reportedStatus(terminal) {
17
+ const { status } = terminal;
18
+ return isObject(status) && typeof status.state === "string" ? status.state : undefined;
19
+ }
20
+ /** Why the session, called `who`, can no longer take its first message, or undefined while it still may. */
21
+ function failure(terminal, who) {
22
+ const { first_message: first, state } = terminal;
23
+ if (isObject(first) && first.sent === false) {
24
+ return typeof first.reason === "string" ? first.reason : "herdr gave no reason";
25
+ }
26
+ if (state !== "live") {
27
+ return "the terminal has finished";
28
+ }
29
+ return reportedStatus(terminal) === "error" ? `the ${who} reports its status as error` : undefined;
30
+ }
31
+ /** Reads the terminal until the session shows work, for up to `wait` seconds; why it did not, or undefined once it did. */
32
+ async function delivery(address, wait, who) {
33
+ const deadline = Date.now() + wait * 1000;
34
+ for (;;) {
35
+ let terminal;
36
+ try {
37
+ terminal = hcObject(["get", address, "--json"]);
38
+ }
39
+ catch (error) {
40
+ if (!(error instanceof Refusal)) {
41
+ throw error;
42
+ }
43
+ // The session is running by now, so a failed read is a message not confirmed, never a start that did not happen.
44
+ return `the terminal could not be read back: ${describe(error)}`;
45
+ }
46
+ const failed = failure(terminal, who);
47
+ if (failed !== undefined) {
48
+ return failed;
49
+ }
50
+ if (WORKING.has(reportedStatus(terminal) ?? "")) {
51
+ return undefined;
52
+ }
53
+ if (Date.now() >= deadline) {
54
+ return `the ${who} showed no sign of work within ${wait} s`;
55
+ }
56
+ await sleep(POLL_MS);
57
+ }
58
+ }
59
+ /** The id of the herdr space the terminal `id` sits in, if it sits in one. */
60
+ export function spaceOf(id) {
61
+ const { labels } = hcObject(["get", id, "--json"]);
62
+ const space = isObject(labels) ? labels["herdr.space"] : undefined;
63
+ return typeof space === "string" ? space : undefined;
64
+ }
65
+ /**
66
+ * Runs `hc run` once for `launch`, outliving its caller, then names the tab and reads the terminal back side
67
+ * by side. It never sends the first message again; `reason` says why the session did not take it.
68
+ */
69
+ export async function launch(command, { name, role, space, cwd, argv, wait }) {
70
+ const { address } = hcObject([
71
+ "run",
72
+ "--bg",
73
+ "--json",
74
+ "--name",
75
+ name,
76
+ "--label",
77
+ `role=${role}`,
78
+ ...(space === undefined ? [] : ["--label", `herdr.space=${space}`]),
79
+ "--outlive-parent",
80
+ "--cwd",
81
+ cwd,
82
+ "--",
83
+ ...argv
84
+ ]);
85
+ if (typeof address !== "string") {
86
+ throw new Refusal("hc run answered no address for the terminal it started", "check hc ls before starting it again");
87
+ }
88
+ // Side by side, so the wait for the tab overlaps the wait for the session to start.
89
+ const naming = space === undefined ? Promise.resolve("none") : nameTab(address, name, Math.min(TAB_WAIT, wait));
90
+ const [named, reason] = await Promise.all([naming, delivery(address, wait, role === "lead" ? "Lead" : "Worker")]);
91
+ if (typeof named === "object") {
92
+ process.stderr.write(`dw-sdlc ${command}: warning: ${named.kept}\n`);
93
+ }
94
+ return { address, tab: typeof named === "object" ? "kept" : named, reason };
95
+ }
96
+ /** The line that says a started session did not take its first message, and how to recover. */
97
+ export function undelivered(command, name, address, reason, message) {
98
+ return (`dw-sdlc ${command}: ${name} (${address}) started but its first message was not delivered: ${reason} — ` +
99
+ `run hc read ${address} to see why; do not send the ${message} again before you have read it\n`);
100
+ }
@@ -27,7 +27,8 @@ export function freshenOrigin(root, baseBranch) {
27
27
  const branch = defaultBranch(root, baseBranch);
28
28
  const args = ["-C", root, "fetch", "--quiet", "origin", ...(branch === undefined ? [] : [branch])];
29
29
  const fetched = `git fetch origin${branch === undefined ? "" : ` ${branch}`}`;
30
- const stale = (why) => new Refusal(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin, then start the Worker again");
30
+ const stale = (why) => new Refusal(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin; when origin/HEAD names a branch the remote no longer has, " +
31
+ "run `git remote set-head origin --auto`; then start the Worker again");
31
32
  let result;
32
33
  try {
33
34
  result = exec("git", args, { env: repositoryFreeEnv(), limitMs: SLOW_LIMIT_MS });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.15.2",
3
+ "version": "0.16.1",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {