dw-sdlc 0.11.0 → 0.12.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.
@@ -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,6 +100,25 @@ function readLocal(root) {
96
100
  worktree: { linked: mainCheckout(root) !== undefined, path: root }
97
101
  };
98
102
  }
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
+ }
99
122
  /** The current branch's pull request and the Action the Ship script's decision gives it, or null. */
100
123
  function readShipped(root) {
101
124
  // An invalid config layer resolves to ask and full, as in the Ship script.
@@ -105,14 +128,12 @@ function readShipped(root) {
105
128
  }
106
129
  /**
107
130
  * 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.
131
+ * a branch it no longer has, its `HEAD`, and the session's own branch where it moved. A remote out of reach
132
+ * holds nothing; any other moved branch counts as before (plugins/sdlc-sessions/README.md says why).
110
133
  */
111
134
  function staleTracking(root, { branch, upstream }) {
112
135
  return remotesOf(root).flatMap((remote) => {
113
- const live = new Map(must(root, ["ls-remote", "--heads", remote])
114
- .split("\n")
115
- .map((line) => {
136
+ const live = new Map((asked(root, ["ls-remote", "--heads", remote]) ?? "").split("\n").map((line) => {
116
137
  const [oid = "", ref = ""] = line.split("\t");
117
138
  return [ref.replace("refs/heads/", `${remote}/`), oid];
118
139
  }));
@@ -196,7 +217,7 @@ function cleanReason(askedGh) {
196
217
  : "nothing is uncommitted or unpushed; gh was not asked";
197
218
  return { id: "clean", blocks: false, text };
198
219
  }
199
- function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
220
+ function reasonsOf(local, { shipped, ahead, orphaned, askedGh, workers }) {
200
221
  const all = [];
201
222
  if (local.paused !== null) {
202
223
  all.push({ id: "paused", blocks: true, text: `a ${local.paused} is paused` });
@@ -218,6 +239,10 @@ function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
218
239
  if (shipped !== null) {
219
240
  all.push(pullRequestReason(shipped));
220
241
  }
242
+ if (workers !== null && workers.length > 0) {
243
+ const text = `${plural(workers.length, "live Worker")}: ${workersText(workers)}`;
244
+ all.push({ id: "workers", blocks: true, text });
245
+ }
221
246
  if (!all.some((reason) => reason.blocks)) {
222
247
  all.unshift(cleanReason(askedGh));
223
248
  }
@@ -234,7 +259,13 @@ function closeOf(reasons) {
234
259
  const line = `Close the session: ${verdict}, because ${shown.map((reason) => reason.text).join("; ")}`;
235
260
  return { verdict, line, reasons };
236
261
  }
237
- function linesOf(local, shipped, askedGh, line) {
262
+ function workersLine(workers) {
263
+ if (workers === null) {
264
+ return "not in herdr";
265
+ }
266
+ return workers.length === 0 ? "none live" : `${workers.length} live: ${workersText(workers)}`;
267
+ }
268
+ function linesOf(local, { shipped, askedGh, workers }, line) {
238
269
  const none = askedGh ? "none" : "gh not asked";
239
270
  return [
240
271
  `branch: ${local.branch ?? "detached HEAD"}`,
@@ -242,6 +273,7 @@ function linesOf(local, shipped, askedGh, line) {
242
273
  `upstream: ${local.upstream ?? "none"}, ${local.unpushed} unpushed`,
243
274
  `pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
244
275
  `worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
276
+ `workers: ${workersLine(workers)}`,
245
277
  line
246
278
  ].join("\n");
247
279
  }
@@ -276,14 +308,20 @@ function report(json) {
276
308
  // An open pull request can still take the head, and a commit-less repository has nothing to merge.
277
309
  const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
278
310
  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));
311
+ const facts = {
312
+ shipped,
313
+ ahead,
314
+ orphaned: orphanedOf(root, read, shipped, held),
315
+ askedGh: asksGh,
316
+ workers: liveWorkers()
317
+ };
318
+ const close = closeOf(reasonsOf(local, facts));
281
319
  const pullRequest = shipped === null
282
320
  ? null
283
321
  : { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
284
322
  const output = json
285
- ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, close }, null, 2)
286
- : linesOf(local, shipped, asksGh, close.line);
323
+ ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close }, null, 2)
324
+ : linesOf(local, facts, close.line);
287
325
  process.stdout.write(`${output}\n`);
288
326
  return close.verdict === "yes" ? 0 : 1;
289
327
  }
@@ -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>;
@@ -0,0 +1,117 @@
1
+ // `dw-sdlc worker report`: delivers a Worker's Recap to its Lead, and keeps trying while the Lead is blocked.
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { parseArgs } from "node:util";
6
+ import { call, describe, lead, refuse, Refusal, said, wholeNumber } from "../worker/herdr.js";
7
+ export const summary = "deliver a Recap to the Lead, retrying while it is blocked";
8
+ const USAGE = `Usage: dw-sdlc worker report --to <lead> --recap <file> [--attempts <n>] [--interval <seconds>]
9
+
10
+ Sends the Recap in <file> to the Lead's terminal with hc send. A Recap of one short line goes as itself;
11
+ anything longer goes as the absolute path of its file, which the Lead reads.
12
+
13
+ hc send exits 9 when the Lead is blocked on a dialog. The command then waits --interval seconds (default
14
+ 60) and sends again, up to --attempts sends (default 30), and exits 1 when the Lead is still blocked. It
15
+ never passes --force: typing into a blocked Lead may answer its dialog. Any other refusal of hc send ends
16
+ it at once with exit 1.
17
+
18
+ Needs a terminal herdr started ($HC_TERMINAL_ID) and hc on PATH.
19
+
20
+ Exit 0 delivered, 1 not delivered, 2 when it cannot run.`;
21
+ /** `hc send` exits with this when the target is blocked on a permission or a question. */
22
+ const BLOCKED = 9;
23
+ const DEFAULT_ATTEMPTS = 30;
24
+ const DEFAULT_INTERVAL = 60;
25
+ /** The longest Recap that goes as itself, in characters; a longer one goes as a path. */
26
+ const SHORT = 300;
27
+ function parseCli(argv) {
28
+ try {
29
+ const { values } = parseArgs({
30
+ args: argv,
31
+ options: {
32
+ help: { type: "boolean", short: "h" },
33
+ to: { type: "string" },
34
+ recap: { type: "string" },
35
+ attempts: { type: "string" },
36
+ interval: { type: "string" }
37
+ }
38
+ });
39
+ if (values.help === true) {
40
+ return "help";
41
+ }
42
+ const { to, recap } = values;
43
+ if (to === undefined || to === "") {
44
+ return { error: "name the Lead's terminal: --to <lead>" };
45
+ }
46
+ if (recap === undefined) {
47
+ return { error: "name the Recap file: --recap <file>" };
48
+ }
49
+ const attempts = wholeNumber("--attempts", values.attempts, DEFAULT_ATTEMPTS, 1);
50
+ const interval = wholeNumber("--interval", values.interval, DEFAULT_INTERVAL, 0);
51
+ if (typeof attempts === "object") {
52
+ return attempts;
53
+ }
54
+ if (typeof interval === "object") {
55
+ return interval;
56
+ }
57
+ return { to, recap, attempts, interval };
58
+ }
59
+ catch (error) {
60
+ return { error: describe(error) };
61
+ }
62
+ }
63
+ /** What to send for the Recap in `file`: the Recap itself when it is one short line, else the file's path. */
64
+ function messageFor(file) {
65
+ const path = resolve(file);
66
+ let text;
67
+ try {
68
+ text = readFileSync(path, "utf8").trim();
69
+ }
70
+ catch (error) {
71
+ throw new Refusal(`cannot read the Recap ${file}: ${describe(error)}`);
72
+ }
73
+ if (text === "") {
74
+ throw new Refusal(`the Recap ${file} is empty`);
75
+ }
76
+ return { message: text.includes("\n") || text.length > SHORT ? path : text, path };
77
+ }
78
+ async function report({ to, recap, attempts, interval }) {
79
+ lead();
80
+ const { message, path } = messageFor(recap);
81
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
82
+ const sent = call("hc", ["send", to, message]);
83
+ if (sent.status === 0) {
84
+ process.stdout.write(`reported to ${to}\n`);
85
+ return 0;
86
+ }
87
+ if (sent.status !== BLOCKED) {
88
+ process.stderr.write(`dw-sdlc worker report: hc send failed: ${said(sent)} — the Recap stays at ${path}\n`);
89
+ return 1;
90
+ }
91
+ if (attempt === attempts) {
92
+ break;
93
+ }
94
+ process.stderr.write(`dw-sdlc worker report: ${to} is blocked; waiting ${interval} s (attempt ${attempt} of ${attempts})\n`);
95
+ await sleep(interval * 1000);
96
+ }
97
+ process.stderr.write(`dw-sdlc worker report: gave up after ${attempts} attempts: ${to} is still blocked — ` +
98
+ `the Recap stays at ${path}; say so on your screen so the dev can open ${to}\n`);
99
+ return 1;
100
+ }
101
+ export async function run(argv) {
102
+ const cli = parseCli(argv);
103
+ if (cli === "help") {
104
+ process.stdout.write(`${USAGE}\n`);
105
+ return 0;
106
+ }
107
+ if ("error" in cli) {
108
+ process.stderr.write(`dw-sdlc worker report: ${cli.error}\n\n${USAGE}\n`);
109
+ return 2;
110
+ }
111
+ try {
112
+ return await report(cli);
113
+ }
114
+ catch (error) {
115
+ return refuse("worker report", error);
116
+ }
117
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "start a Worker on a Brief in a herdr terminal";
2
+ export declare function run(argv: string[]): Promise<number>;