dw-sdlc 0.11.0 → 0.12.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.
@@ -36,7 +36,7 @@ export type PrConfig = {
36
36
  reviewCommand: string;
37
37
  };
38
38
  /** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
39
- declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
39
+ export declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
40
40
  export type WorkerEffort = (typeof WORKER_EFFORTS)[number];
41
41
  /** How a Worker's Claude Code session starts: the words that start it, its model and its effort. */
42
42
  export type WorkerConfig = {
@@ -14,7 +14,7 @@ export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
14
14
  /** The Review depths, strictest first: what the dev's `--review` names. */
15
15
  export const REVIEW_DEPTHS = ["full", "light", "off"];
16
16
  /** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
17
- const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
17
+ export const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
18
18
  /** Strictest value first; the local override may only move towards the front. */
19
19
  export const STRICTNESS = {
20
20
  forcePush: ["deny", "ask", "lease"],
package/dist/cli.js CHANGED
@@ -12,6 +12,9 @@ import * as recap from "./commands/recap.js";
12
12
  import * as referencesSetup from "./commands/references-setup.js";
13
13
  import * as referencesSync from "./commands/references-sync.js";
14
14
  import * as setup from "./commands/setup.js";
15
+ import * as workerLs from "./commands/worker-ls.js";
16
+ import * as workerReport from "./commands/worker-report.js";
17
+ import * as workerStart from "./commands/worker-start.js";
15
18
  const COMMANDS = new Map([
16
19
  ["gate", gate],
17
20
  ["check", check],
@@ -23,7 +26,10 @@ const COMMANDS = new Map([
23
26
  ["references setup", referencesSetup],
24
27
  ["pr ship", prShip],
25
28
  ["pr reviewed", prReviewed],
26
- ["recap", recap]
29
+ ["recap", recap],
30
+ ["worker start", workerStart],
31
+ ["worker ls", workerLs],
32
+ ["worker report", workerReport]
27
33
  ]);
28
34
  function usage() {
29
35
  const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
@@ -40,11 +40,6 @@ export type Inputs = {
40
40
  };
41
41
  /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
42
42
  export declare function claudeConfigDir(env: Record<string, string | undefined>, home: string): string;
43
- /** The marketplace Claude Code registers this repository under, the plugin in it, and the package the plugin links to. */
44
- export declare const MARKETPLACE = "dw-sdlc";
45
- export declare const PLUGIN = "sdlc";
46
- export declare const PLUGIN_ID = "sdlc@dw-sdlc";
47
- export declare const PACKAGE = "dw-sdlc";
48
43
  /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
49
44
  export declare const RTK_FIND_EXEC_FIXED = "0.46.0";
50
45
  export declare const PNPM_MINIMUM_RELEASE_AGE = 10080;
@@ -7,19 +7,12 @@ import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { parseArgs } from "node:util";
9
9
  import { findRepository, resolveConfig } from "../agentic-config.js";
10
- import { EXACT_VERSION, parseCatalog, parsePins } from "../pins.js";
10
+ import { checkPackage, checkPlugin, checkSessions, parseJson, runningPluginRoot, versionBefore } from "../doctor-plugins.js";
11
+ import { isObject } from "../json-text.js";
11
12
  /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
12
13
  export function claudeConfigDir(env, home) {
13
14
  return env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
14
15
  }
15
- /** The marketplace Claude Code registers this repository under, the plugin in it, and the package the plugin links to. */
16
- export const MARKETPLACE = "dw-sdlc";
17
- export const PLUGIN = "sdlc";
18
- export const PLUGIN_ID = `${PLUGIN}@${MARKETPLACE}`;
19
- /** The plugin of the session helpers: optional, and it links a copy of the package of its own. */
20
- const SESSIONS = "sdlc-sessions";
21
- const SESSIONS_ID = `${SESSIONS}@${MARKETPLACE}`;
22
- export const PACKAGE = "dw-sdlc";
23
16
  /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
24
17
  export const RTK_FIND_EXEC_FIXED = "0.46.0";
25
18
  export const PNPM_MINIMUM_RELEASE_AGE = 10080;
@@ -37,20 +30,6 @@ export const realProbe = {
37
30
  return (result.stdout ?? "").trim().split("\n")[0] ?? "";
38
31
  }
39
32
  };
40
- function isObject(value) {
41
- return typeof value === "object" && value !== null && !Array.isArray(value);
42
- }
43
- function parseJson(text) {
44
- if (text === undefined) {
45
- return undefined;
46
- }
47
- try {
48
- return JSON.parse(text);
49
- }
50
- catch {
51
- return undefined;
52
- }
53
- }
54
33
  function checkJq(probe) {
55
34
  const { status, stdout } = probe.run("jq", ["--version"]);
56
35
  if (status === 0) {
@@ -58,151 +37,6 @@ function checkJq(probe) {
58
37
  }
59
38
  return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
60
39
  }
61
- /** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
62
- function liveCheckout(probe, claude) {
63
- const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
64
- const entry = isObject(known) && isObject(known[MARKETPLACE]) ? known[MARKETPLACE] : undefined;
65
- const source = entry !== undefined && isObject(entry.source) ? entry.source : undefined;
66
- return source?.source === "directory" && typeof source.path === "string" ? source.path : undefined;
67
- }
68
- function pluginOrigin(probe, claude, installPath) {
69
- const checkout = liveCheckout(probe, claude);
70
- if (checkout !== undefined) {
71
- return `runs live from ${checkout}/plugins/${PLUGIN}; a change there is in the next session`;
72
- }
73
- return `installed at ${installPath}; a change reaches it through claude plugin update`;
74
- }
75
- /** What `claude plugin list --json` prints, one object a plugin, or undefined when it does not answer. */
76
- function listedPlugins(probe) {
77
- const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
78
- const list = status === 0 ? parseJson(stdout) : undefined;
79
- return Array.isArray(list) ? list.filter(isObject) : undefined;
80
- }
81
- /** The directory `plugin` runs from on this machine: the checkout of a directory marketplace, else the installed snapshot. */
82
- function runningPluginRoot(probe, claude, plugin = PLUGIN) {
83
- const checkout = liveCheckout(probe, claude);
84
- if (checkout !== undefined) {
85
- return join(checkout, "plugins", plugin);
86
- }
87
- const entry = listedPlugins(probe)?.find((item) => item.id === `${plugin}@${MARKETPLACE}`);
88
- return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
89
- }
90
- function checkPlugin(probe, claude) {
91
- const list = listedPlugins(probe);
92
- if (list === undefined) {
93
- return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
94
- }
95
- const entry = list.find((item) => item.id === PLUGIN_ID);
96
- if (entry === undefined) {
97
- return {
98
- name: "plugin",
99
- level: "FAIL",
100
- detail: `${PLUGIN_ID} is not installed. claude plugin marketplace add dominikwozniak/dw-sdlc --sparse .claude-plugin plugins packages && claude plugin install ${PLUGIN_ID} --scope user`
101
- };
102
- }
103
- const version = typeof entry.version === "string" ? entry.version : "?";
104
- const installPath = typeof entry.installPath === "string" ? entry.installPath : "?";
105
- if (entry.enabled !== true) {
106
- return {
107
- name: "plugin",
108
- level: "FAIL",
109
- detail: `${version} installed but disabled, so no guard runs. claude plugin enable ${PLUGIN_ID}`
110
- };
111
- }
112
- return {
113
- name: "plugin",
114
- level: "PASS",
115
- detail: `${version} enabled; ${pluginOrigin(probe, claude, installPath)}`
116
- };
117
- }
118
- /** The `version` of the package.json in `dir`, or undefined when there is none. */
119
- function manifestVersion(probe, dir) {
120
- const manifest = parseJson(probe.read(join(dir, "package.json")));
121
- return isObject(manifest) && typeof manifest.version === "string" ? manifest.version : undefined;
122
- }
123
- /** Whether the plugin's hooks and skills run the dw-sdlc the repository pins, the one its Gate runs. */
124
- function checkPackage(probe, claude, root) {
125
- const name = "package";
126
- const manifest = root === undefined ? undefined : probe.read(join(root, "package.json"));
127
- if (root === undefined || manifest === undefined) {
128
- return { name, level: "INFO", detail: `no package.json here to pin ${PACKAGE}` };
129
- }
130
- let spec;
131
- try {
132
- spec = parsePins(manifest, parseCatalog(probe.read(join(root, "pnpm-workspace.yaml")) ?? ""))[PACKAGE];
133
- }
134
- catch {
135
- return { name, level: "INFO", detail: `${join(root, "package.json")} is not JSON` };
136
- }
137
- if (spec === undefined) {
138
- return { name, level: "INFO", detail: `the repository does not pin ${PACKAGE}` };
139
- }
140
- // `workspace:` or a range names no one version: the copy installed under node_modules is the one that runs.
141
- const pinned = EXACT_VERSION.test(spec) ? spec : manifestVersion(probe, join(root, "node_modules", PACKAGE));
142
- if (pinned === undefined) {
143
- return { name, level: "WARN", detail: `the repository pins ${PACKAGE} ${spec}, not installed: pnpm install` };
144
- }
145
- const plugin = runningPluginRoot(probe, claude);
146
- if (plugin === undefined) {
147
- return { name, level: "INFO", detail: `no ${PLUGIN_ID} runs here to compare with the pinned ${pinned}` };
148
- }
149
- const linked = manifestVersion(probe, join(plugin, PACKAGE));
150
- if (linked === undefined) {
151
- return {
152
- name,
153
- level: "WARN",
154
- detail: `the plugin at ${plugin} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
155
- };
156
- }
157
- if (linked !== pinned) {
158
- const remedy = versionBefore(linked, pinned)
159
- ? `claude plugin update ${PLUGIN_ID}`
160
- : `pnpm add --save-dev --save-exact ${PACKAGE}@${linked}`;
161
- return {
162
- name,
163
- level: "WARN",
164
- detail: `drift: the plugin runs ${PACKAGE} ${linked} and the repository pins ${pinned}, so its hooks and skills run other code than the Gate. ${remedy}`
165
- };
166
- }
167
- return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
168
- }
169
- /**
170
- * `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
171
- * the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
172
- */
173
- function checkSessions(probe, claude) {
174
- const name = "sessions";
175
- const list = listedPlugins(probe);
176
- const entry = list?.find((item) => item.id === SESSIONS_ID);
177
- if (entry === undefined) {
178
- const detail = list === undefined ? "claude plugin list did not answer" : `no ${SESSIONS_ID}, so no /sdlc-recap`;
179
- return { name, level: "INFO", detail };
180
- }
181
- const version = typeof entry.version === "string" ? entry.version : "?";
182
- if (entry.enabled !== true) {
183
- return { name, level: "WARN", detail: `${version} installed but disabled. claude plugin enable ${SESSIONS_ID}` };
184
- }
185
- const root = runningPluginRoot(probe, claude, SESSIONS);
186
- const copy = root === undefined ? undefined : manifestVersion(probe, join(root, PACKAGE));
187
- if (copy === undefined) {
188
- return {
189
- name,
190
- level: "WARN",
191
- detail: `${version} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
192
- };
193
- }
194
- const guards = runningPluginRoot(probe, claude);
195
- const other = guards === undefined ? undefined : manifestVersion(probe, join(guards, PACKAGE));
196
- if (other !== undefined && other !== copy) {
197
- const behind = versionBefore(copy, other) ? SESSIONS_ID : PLUGIN_ID;
198
- return {
199
- name,
200
- level: "WARN",
201
- detail: `drift: ${SESSIONS} runs ${PACKAGE} ${copy} and ${PLUGIN} runs ${other}. claude plugin update ${behind}`
202
- };
203
- }
204
- return { name, level: "PASS", detail: `${version} enabled, on ${PACKAGE} ${copy}` };
205
- }
206
40
  /** Every hook command string in a settings file, whatever the event and matcher. */
207
41
  function hookCommands(settings) {
208
42
  if (!isObject(settings) || !isObject(settings.hooks)) {
@@ -241,18 +75,6 @@ export function rtkExcludes(toml) {
241
75
  }
242
76
  return [...(match[1] ?? "").matchAll(/"([^"]*)"/g)].map((entry) => entry[1] ?? "");
243
77
  }
244
- function versionBefore(version, threshold) {
245
- const a = version.split(".").map((part) => Number.parseInt(part, 10));
246
- const b = threshold.split(".").map((part) => Number.parseInt(part, 10));
247
- for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
248
- const x = a[i] ?? 0;
249
- const y = b[i] ?? 0;
250
- if (x !== y) {
251
- return x < y;
252
- }
253
- }
254
- return false;
255
- }
256
78
  // The plugin's rtk-rewrite.sh is the one wiring of `rtk hook claude`; a second one in the user
257
79
  // settings rewrites again, and in a linked worktree that copy is what the isolation check refuses.
258
80
  function checkRtk(probe, home, claude) {
@@ -299,6 +121,14 @@ function checkRtk(probe, home, claude) {
299
121
  detail: `rtk ${version} behind the plugin's rewrite hook; exclude_commands: ${excludes.join(", ") || "none"}`
300
122
  };
301
123
  }
124
+ /** herdr's `hc` in the agent's shell, which a Lead starts its Workers through; a machine may go without it. */
125
+ function checkHc(probe) {
126
+ const hc = probe.shell("command -v hc");
127
+ if (hc === "") {
128
+ return { name: "hc", level: "INFO", detail: "no hc on PATH, so no /sdlc-delegate: install herdr to run Workers" };
129
+ }
130
+ return { name: "hc", level: "PASS", detail: `${hc}, so a Lead can start Workers` };
131
+ }
302
132
  function checkSafeChain(probe) {
303
133
  const binary = probe.shell("command -v safe-chain");
304
134
  if (binary === "") {
@@ -381,6 +211,7 @@ export function diagnose(inputs) {
381
211
  checkPlugin(probe, claude),
382
212
  checkPackage(probe, claude, inputs.config === undefined ? undefined : inputs.root),
383
213
  checkSessions(probe, claude),
214
+ checkHc(probe),
384
215
  checkRtk(probe, home, claude),
385
216
  checkSafeChain(probe),
386
217
  checkPnpmReleaseAge(probe, home),
@@ -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, now, read, readAgain, said } from "../pr/read.js";
7
+ import { CannotRun, carriesBase, gh, namedProblem, now, read, readAgain, said, withBehind } from "../pr/read.js";
8
8
  export const summary = "decide the Action one pull request needs next, as a line or JSON";
9
9
  const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
10
10
 
@@ -13,7 +13,9 @@ is omitted, and answers the Action it needs next: the status, the action, whose
13
13
  The script performs its own Actions, each at most once and then reads again: it updates a branch
14
14
  behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
15
15
  request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
16
- under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
16
+ under ask, the default, it parks with the merge command. A branch is behind when the compare of
17
+ the base with the head shows the head lacks a commit of the base, or GitHub reports BEHIND, which
18
+ it does only under a rule that a branch be up to date. A head without a ${REVIEW} success
17
19
  gets the Review depth --review names for this call, else pr.review: under full the agent reviews it
18
20
  with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
19
21
  subagent reads the diff; under off the script posts the success itself, its description saying
@@ -143,7 +145,7 @@ function handedOver(pr, policy, answer, action, reason) {
143
145
  function ship(cli, policy) {
144
146
  // Only pr.review auto routes the depth from the changes, so only it reads them.
145
147
  const sized = policy.review.depth === "auto";
146
- let pr = read(cli, "pr ship", sized);
148
+ let pr = withBehind(read(cli, "pr ship", sized));
147
149
  const performed = [];
148
150
  // The head each Action ran on.
149
151
  const postedOn = new Map();
@@ -177,7 +179,7 @@ function ship(cli, policy) {
177
179
  const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
178
180
  postedOn.set(action, before.head);
179
181
  try {
180
- pr = readAgain(pr, sized);
182
+ pr = withBehind(readAgain(pr, sized));
181
183
  performed.push({ ...record, ok: TOOK[action](before, pr) });
182
184
  }
183
185
  catch (error) {
@@ -6,6 +6,7 @@ import { parseArgs } from "node:util";
6
6
  import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
7
7
  import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
8
8
  import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
9
+ import { isLive, Refusal, workersOf, workingFolders } from "../worker/herdr.js";
9
10
  import { git } from "../working-tree.js";
10
11
  export const summary = "the facts a Recap rests on and whether the session can be closed";
11
12
  const USAGE = `Usage: dw-sdlc recap [--json]
@@ -21,7 +22,9 @@ The session cannot be closed over any of these:
21
22
  a commit pushed nowhere or only part of the way;
22
23
  an open pull request, whatever its Action;
23
24
  commits on top of the base that no pull request holds. The default branch and a branch
24
- guardrails.protectedBranches names need no pull request.
25
+ guardrails.protectedBranches names need no pull request;
26
+ a live Worker this session started as a Lead, in a terminal herdr started ($HC_TERMINAL_ID): its
27
+ report has no one to reach once the Lead is gone. A Worker whose turn ended is still live.
25
28
 
26
29
  A pull request closed without merging merges nothing, and a detached HEAD names no pull request. There a
27
30
  commit counts as pushed only on a remote branch, and the command asks the remote which branches still
@@ -30,8 +33,9 @@ stand. A branch force-pushed since the last fetch can still read as holding it,
30
33
  A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
31
34
  directory it runs in.
32
35
 
33
- One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read. A
34
- detached HEAD or a repository with no remote asks gh nothing, and the pull request fact says so.
36
+ One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read, and in
37
+ herdr hc on PATH. A detached HEAD or a repository with no remote asks gh nothing, and the pull request
38
+ fact says so; outside herdr the workers fact says so.
35
39
 
36
40
  Exit 0 the session can be closed, 1 it cannot, 2 when it cannot run.`;
37
41
  function plural(howMany, word) {
@@ -96,23 +100,64 @@ function readLocal(root) {
96
100
  worktree: { linked: mainCheckout(root) !== undefined, path: root }
97
101
  };
98
102
  }
99
- /** The current branch's pull request and the Action the Ship script's decision gives it, or null. */
100
- function readShipped(root) {
103
+ /** The live Workers this session started as a Lead, each in the folder it works in; null outside herdr. */
104
+ function liveWorkers() {
105
+ const lead = process.env.HC_TERMINAL_ID;
106
+ if (lead === undefined || lead === "") {
107
+ return null;
108
+ }
109
+ try {
110
+ return workingFolders(workersOf(lead, false).filter(isLive));
111
+ }
112
+ catch (error) {
113
+ if (error instanceof Refusal) {
114
+ throw new CannotRun(error.message, error.remedy ?? "check hc ls, then call again");
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ function workersText(workers) {
120
+ return workers.map((worker) => `${worker.name} (${worker.status})`).join(", ");
121
+ }
122
+ /**
123
+ * The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
124
+ * after the remote deleted the branch.
125
+ */
126
+ function upstreamName(root, branch) {
127
+ const remote = asked(root, ["config", "--get", `branch.${branch}.remote`]);
128
+ const merge = asked(root, ["config", "--get", `branch.${branch}.merge`]);
129
+ if (remote === undefined || remote === "." || merge === undefined || !merge.startsWith("refs/heads/")) {
130
+ return undefined;
131
+ }
132
+ const name = merge.slice("refs/heads/".length);
133
+ return name === branch ? undefined : name;
134
+ }
135
+ /** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
136
+ function holds(pr, head) {
137
+ return head !== null && pr.commits.includes(head);
138
+ }
139
+ /**
140
+ * The current branch's pull request and the Action the Ship script's decision gives it, or null. The one
141
+ * found by the upstream's name counts only when it holds the head: a branch started from another tracks
142
+ * that branch until its first push.
143
+ */
144
+ function readShipped(root, branch, head) {
101
145
  // An invalid config layer resolves to ask and full, as in the Ship script.
102
146
  const policy = declaredPolicy(resolveConfig(root).config.pr);
103
- const pr = readBranch(policy.review.depth === "auto");
104
- return pr === null ? null : { pr, answer: decide(pr, now(), policy) };
147
+ const found = readBranch(policy.review.depth === "auto", head === null ? undefined : upstreamName(root, branch));
148
+ if (found === null || (found.byUpstream && !holds(found.pr, head))) {
149
+ return null;
150
+ }
151
+ return { pr: found.pr, answer: decide(found.pr, now(), policy) };
105
152
  }
106
153
  /**
107
154
  * The tracking refs, as `--remotes` names them, that the remote contradicts, which only a fetch would show:
108
- * a branch it no longer has, its `HEAD`, and the session's own branch where it moved. Any other branch that
109
- * moved, such as the base, counts as holding what it held; plugins/sdlc-sessions/README.md says why.
155
+ * a branch it no longer has, its `HEAD`, and the session's own branch where it moved. A remote out of reach
156
+ * holds nothing; any other moved branch counts as before (plugins/sdlc-sessions/README.md says why).
110
157
  */
111
158
  function staleTracking(root, { branch, upstream }) {
112
159
  return remotesOf(root).flatMap((remote) => {
113
- const live = new Map(must(root, ["ls-remote", "--heads", remote])
114
- .split("\n")
115
- .map((line) => {
160
+ const live = new Map((asked(root, ["ls-remote", "--heads", remote]) ?? "").split("\n").map((line) => {
116
161
  const [oid = "", ref = ""] = line.split("\t");
117
162
  return [ref.replace("refs/heads/", `${remote}/`), oid];
118
163
  }));
@@ -196,7 +241,7 @@ function cleanReason(askedGh) {
196
241
  : "nothing is uncommitted or unpushed; gh was not asked";
197
242
  return { id: "clean", blocks: false, text };
198
243
  }
199
- function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
244
+ function reasonsOf(local, { shipped, ahead, orphaned, askedGh, workers }) {
200
245
  const all = [];
201
246
  if (local.paused !== null) {
202
247
  all.push({ id: "paused", blocks: true, text: `a ${local.paused} is paused` });
@@ -218,6 +263,10 @@ function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
218
263
  if (shipped !== null) {
219
264
  all.push(pullRequestReason(shipped));
220
265
  }
266
+ if (workers !== null && workers.length > 0) {
267
+ const text = `${plural(workers.length, "live Worker")}: ${workersText(workers)}`;
268
+ all.push({ id: "workers", blocks: true, text });
269
+ }
221
270
  if (!all.some((reason) => reason.blocks)) {
222
271
  all.unshift(cleanReason(askedGh));
223
272
  }
@@ -234,7 +283,13 @@ function closeOf(reasons) {
234
283
  const line = `Close the session: ${verdict}, because ${shown.map((reason) => reason.text).join("; ")}`;
235
284
  return { verdict, line, reasons };
236
285
  }
237
- function linesOf(local, shipped, askedGh, line) {
286
+ function workersLine(workers) {
287
+ if (workers === null) {
288
+ return "not in herdr";
289
+ }
290
+ return workers.length === 0 ? "none live" : `${workers.length} live: ${workersText(workers)}`;
291
+ }
292
+ function linesOf(local, { shipped, askedGh, workers }, line) {
238
293
  const none = askedGh ? "none" : "gh not asked";
239
294
  return [
240
295
  `branch: ${local.branch ?? "detached HEAD"}`,
@@ -242,6 +297,7 @@ function linesOf(local, shipped, askedGh, line) {
242
297
  `upstream: ${local.upstream ?? "none"}, ${local.unpushed} unpushed`,
243
298
  `pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
244
299
  `worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
300
+ `workers: ${workersLine(workers)}`,
245
301
  line
246
302
  ].join("\n");
247
303
  }
@@ -270,20 +326,26 @@ function report(json) {
270
326
  const { branch, head } = read;
271
327
  // A detached HEAD names no branch for gh to look up, and a repository with no remote has no GitHub.
272
328
  const asksGh = branch !== null && remotesOf(root).length > 0;
273
- const shipped = asksGh ? readShipped(root) : null;
274
- const held = shipped !== null && head !== null && shipped.pr.commits.includes(head);
329
+ const shipped = asksGh ? readShipped(root, branch, head) : null;
330
+ const held = shipped !== null && holds(shipped.pr, head);
275
331
  const local = standing(root, read, shipped, held);
276
332
  // An open pull request can still take the head, and a commit-less repository has nothing to merge.
277
333
  const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
278
334
  const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
279
- const orphaned = orphanedOf(root, read, shipped, held);
280
- const close = closeOf(reasonsOf(local, shipped, ahead, orphaned, asksGh));
335
+ const facts = {
336
+ shipped,
337
+ ahead,
338
+ orphaned: orphanedOf(root, read, shipped, held),
339
+ askedGh: asksGh,
340
+ workers: liveWorkers()
341
+ };
342
+ const close = closeOf(reasonsOf(local, facts));
281
343
  const pullRequest = shipped === null
282
344
  ? null
283
345
  : { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
284
346
  const output = json
285
- ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, close }, null, 2)
286
- : linesOf(local, shipped, asksGh, close.line);
347
+ ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close }, null, 2)
348
+ : linesOf(local, facts, close.line);
287
349
  process.stdout.write(`${output}\n`);
288
350
  return close.verdict === "yes" ? 0 : 1;
289
351
  }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "list the Workers this Lead started and their states";
2
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,53 @@
1
+ // `dw-sdlc worker ls`: the Workers the calling Lead started, with the state herdr holds them in.
2
+ import { parseArgs } from "node:util";
3
+ import { describe, lead, refuse, table, workersOf, workingFolders } from "../worker/herdr.js";
4
+ export const summary = "list the Workers this Lead started and their states";
5
+ const USAGE = `Usage: dw-sdlc worker ls [--json]
6
+
7
+ Lists the Workers the calling Lead started (terminals labelled role=worker whose parent is this
8
+ terminal): name, address, herdr's state, status and folder. A live Worker's folder is the one it works
9
+ in, its worktree, as hc get reports it; a finished one, or one herdr cannot tell it for, shows the folder
10
+ it was launched in. The status is what the Worker reports, one of idle, working, done, blocked, error, or
11
+ finished once its terminal has ended; unknown before it reports. herdr's done means a turn ended, not the
12
+ task: the Worker's Recap says whether the task is done. Finished Workers stay listed until they are
13
+ removed with hc rm.
14
+
15
+ Needs a terminal herdr started ($HC_TERMINAL_ID) and hc on PATH. One line a Worker, or one JSON array
16
+ with --json.
17
+
18
+ Exit 0 listed, 2 when it cannot run.`;
19
+ export function run(argv) {
20
+ let json;
21
+ try {
22
+ const { values } = parseArgs({
23
+ args: argv,
24
+ options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } }
25
+ });
26
+ if (values.help === true) {
27
+ process.stdout.write(`${USAGE}\n`);
28
+ return 0;
29
+ }
30
+ json = values.json === true;
31
+ }
32
+ catch (error) {
33
+ process.stderr.write(`dw-sdlc worker ls: ${describe(error)}\n\n${USAGE}\n`);
34
+ return 2;
35
+ }
36
+ try {
37
+ const workers = workingFolders(workersOf(lead(), true));
38
+ if (json) {
39
+ process.stdout.write(`${JSON.stringify(workers, null, 2)}\n`);
40
+ }
41
+ else if (workers.length === 0) {
42
+ process.stdout.write("no Workers\n");
43
+ }
44
+ else {
45
+ const rows = workers.map((worker) => [worker.name, worker.address, worker.state, worker.status, worker.folder]);
46
+ process.stdout.write(`${table([["NAME", "ADDRESS", "STATE", "STATUS", "FOLDER"], ...rows])}\n`);
47
+ }
48
+ return 0;
49
+ }
50
+ catch (error) {
51
+ return refuse("worker ls", error);
52
+ }
53
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "deliver a Recap to the Lead, retrying while it is blocked";
2
+ export declare function run(argv: string[]): Promise<number>;