dw-sdlc 0.10.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.
- package/README.md +1 -0
- package/dist/agentic-config.d.ts +1 -1
- package/dist/agentic-config.js +1 -1
- package/dist/cli.js +9 -1
- package/dist/commands/doctor.d.ts +0 -5
- package/dist/commands/doctor.js +12 -142
- package/dist/commands/pr-ship.js +10 -27
- package/dist/commands/recap.d.ts +2 -0
- package/dist/commands/recap.js +348 -0
- package/dist/commands/worker-ls.d.ts +2 -0
- package/dist/commands/worker-ls.js +53 -0
- package/dist/commands/worker-report.d.ts +2 -0
- package/dist/commands/worker-report.js +117 -0
- package/dist/commands/worker-start.d.ts +2 -0
- package/dist/commands/worker-start.js +234 -0
- package/dist/doctor-plugins.d.ts +14 -0
- package/dist/doctor-plugins.js +182 -0
- package/dist/pr/decide.d.ts +7 -1
- package/dist/pr/decide.js +16 -1
- package/dist/pr/read.d.ts +12 -1
- package/dist/pr/read.js +72 -13
- package/dist/pr/state.d.ts +15 -3
- package/dist/pr/state.js +8 -2
- package/dist/worker/herdr.d.ts +48 -0
- package/dist/worker/herdr.js +115 -0
- package/dist/worker/tabs.d.ts +9 -0
- package/dist/worker/tabs.js +62 -0
- package/package.json +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// `dw-sdlc recap`: the facts a Recap rests on and whether the session can be closed, read from git and
|
|
2
|
+
// gh. It writes nothing: the Action of the branch's pull request is the Ship script's decision, read.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
|
|
7
|
+
import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
|
|
8
|
+
import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
|
|
9
|
+
import { isLive, Refusal, workersOf, workingFolders } from "../worker/herdr.js";
|
|
10
|
+
import { git } from "../working-tree.js";
|
|
11
|
+
export const summary = "the facts a Recap rests on and whether the session can be closed";
|
|
12
|
+
const USAGE = `Usage: dw-sdlc recap [--json]
|
|
13
|
+
|
|
14
|
+
Reads the current branch, its uncommitted changes, the commits that reach no remote, its pull request
|
|
15
|
+
and whether the session sits in a linked worktree, and answers whether the session can be closed, with
|
|
16
|
+
every reason. It writes nothing: the pull request's Action is the one dw-sdlc pr ship would answer, read
|
|
17
|
+
and never performed.
|
|
18
|
+
|
|
19
|
+
The session cannot be closed over any of these:
|
|
20
|
+
a paused rebase, merge, cherry-pick, revert or bisect;
|
|
21
|
+
an uncommitted change;
|
|
22
|
+
a commit pushed nowhere or only part of the way;
|
|
23
|
+
an open pull request, whatever its Action;
|
|
24
|
+
commits on top of the base that no pull request holds. The default branch and a branch
|
|
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.
|
|
28
|
+
|
|
29
|
+
A pull request closed without merging merges nothing, and a detached HEAD names no pull request. There a
|
|
30
|
+
commit counts as pushed only on a remote branch, and the command asks the remote which branches still
|
|
31
|
+
stand. A branch force-pushed since the last fetch can still read as holding it, so fetch first to be exact.
|
|
32
|
+
|
|
33
|
+
A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
|
|
34
|
+
directory it runs in.
|
|
35
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
Exit 0 the session can be closed, 1 it cannot, 2 when it cannot run.`;
|
|
41
|
+
function plural(howMany, word) {
|
|
42
|
+
return `${howMany} ${word}${howMany === 1 ? "" : "s"}`;
|
|
43
|
+
}
|
|
44
|
+
/** `git <args>` at `root`; what it prints, or a stop naming its problem. */
|
|
45
|
+
function must(root, args) {
|
|
46
|
+
const result = git(root, args);
|
|
47
|
+
if ("error" in result) {
|
|
48
|
+
throw new CannotRun(result.error, "check the repository with git status");
|
|
49
|
+
}
|
|
50
|
+
return result.out;
|
|
51
|
+
}
|
|
52
|
+
/** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
|
|
53
|
+
function asked(root, args) {
|
|
54
|
+
const result = git(root, args);
|
|
55
|
+
return "out" in result ? result.out : undefined;
|
|
56
|
+
}
|
|
57
|
+
function count(root, args) {
|
|
58
|
+
return Number(must(root, ["rev-list", "--count", ...args]));
|
|
59
|
+
}
|
|
60
|
+
function remotesOf(root) {
|
|
61
|
+
return must(root, ["remote"])
|
|
62
|
+
.split("\n")
|
|
63
|
+
.filter((remote) => remote !== "");
|
|
64
|
+
}
|
|
65
|
+
/** The remote-tracking upstream of `branch`, as `--remotes` names it, unless git reports its ref gone. */
|
|
66
|
+
function upstreamOf(root, branch) {
|
|
67
|
+
const prefix = "refs/remotes/";
|
|
68
|
+
const format = "%(upstream) %(upstream:track)";
|
|
69
|
+
const [ref = "", track] = must(root, ["for-each-ref", `--format=${format}`, `refs/heads/${branch}`]).split(" ");
|
|
70
|
+
return ref.startsWith(prefix) && track !== "[gone]" ? ref.slice(prefix.length) : null;
|
|
71
|
+
}
|
|
72
|
+
/** Git's own record of each paused operation, in the git directory a linked worktree keeps apart. */
|
|
73
|
+
const PAUSES = [
|
|
74
|
+
{ record: "rebase-merge", name: "rebase" },
|
|
75
|
+
{ record: "rebase-apply", name: "rebase or am" },
|
|
76
|
+
{ record: "MERGE_HEAD", name: "merge" },
|
|
77
|
+
{ record: "CHERRY_PICK_HEAD", name: "cherry-pick" },
|
|
78
|
+
{ record: "REVERT_HEAD", name: "revert" },
|
|
79
|
+
{ record: "sequencer", name: "cherry-pick or revert" },
|
|
80
|
+
{ record: "BISECT_LOG", name: "bisect" }
|
|
81
|
+
];
|
|
82
|
+
function pausedIn(root) {
|
|
83
|
+
const gitDir = must(root, ["rev-parse", "--absolute-git-dir"]);
|
|
84
|
+
return PAUSES.find(({ record }) => existsSync(join(gitDir, record)))?.name ?? null;
|
|
85
|
+
}
|
|
86
|
+
function readLocal(root) {
|
|
87
|
+
const branch = asked(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) ?? null;
|
|
88
|
+
const head = asked(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) ?? null;
|
|
89
|
+
// Without the flag, git status refreshes the index, a write under a session working in this checkout.
|
|
90
|
+
const status = must(root, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=normal"]);
|
|
91
|
+
const upstream = branch === null ? null : upstreamOf(root, branch);
|
|
92
|
+
const pushed = upstream === null ? ["HEAD", "--not", "--remotes"] : ["@{upstream}..HEAD"];
|
|
93
|
+
return {
|
|
94
|
+
branch,
|
|
95
|
+
head,
|
|
96
|
+
uncommitted: status === "" ? 0 : status.split("\n").length,
|
|
97
|
+
upstream,
|
|
98
|
+
unpushed: head === null ? 0 : count(root, pushed),
|
|
99
|
+
paused: pausedIn(root),
|
|
100
|
+
worktree: { linked: mainCheckout(root) !== undefined, path: root }
|
|
101
|
+
};
|
|
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
|
+
}
|
|
122
|
+
/** The current branch's pull request and the Action the Ship script's decision gives it, or null. */
|
|
123
|
+
function readShipped(root) {
|
|
124
|
+
// An invalid config layer resolves to ask and full, as in the Ship script.
|
|
125
|
+
const policy = declaredPolicy(resolveConfig(root).config.pr);
|
|
126
|
+
const pr = readBranch(policy.review.depth === "auto");
|
|
127
|
+
return pr === null ? null : { pr, answer: decide(pr, now(), policy) };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The tracking refs, as `--remotes` names them, that the remote contradicts, which only a fetch would show:
|
|
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).
|
|
133
|
+
*/
|
|
134
|
+
function staleTracking(root, { branch, upstream }) {
|
|
135
|
+
return remotesOf(root).flatMap((remote) => {
|
|
136
|
+
const live = new Map((asked(root, ["ls-remote", "--heads", remote]) ?? "").split("\n").map((line) => {
|
|
137
|
+
const [oid = "", ref = ""] = line.split("\t");
|
|
138
|
+
return [ref.replace("refs/heads/", `${remote}/`), oid];
|
|
139
|
+
}));
|
|
140
|
+
return must(root, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", `refs/remotes/${remote}`])
|
|
141
|
+
.split("\n")
|
|
142
|
+
.map((line) => line.split(" "))
|
|
143
|
+
.filter(([tracking = "", oid]) => {
|
|
144
|
+
const own = tracking === upstream || tracking === `${remote}/${branch}`;
|
|
145
|
+
return tracking !== "" && (!live.has(tracking) || (own && live.get(tracking) !== oid));
|
|
146
|
+
})
|
|
147
|
+
.map(([tracking = ""]) => tracking);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Where the head stands once the pull request is read. A commit an open or merged one holds is on GitHub,
|
|
152
|
+
* whatever became of the upstream. A closed one merges nothing and a detached HEAD names none, so there only
|
|
153
|
+
* a remote branch that still stands counts for the head. A paused operation has answered already.
|
|
154
|
+
*/
|
|
155
|
+
function standing(root, read, shipped, held) {
|
|
156
|
+
const asksRemote = read.paused === null && (read.branch === null || shipped?.pr.state === "CLOSED");
|
|
157
|
+
if (!asksRemote || read.head === null) {
|
|
158
|
+
return held ? { ...read, unpushed: 0 } : read;
|
|
159
|
+
}
|
|
160
|
+
const stale = staleTracking(root, read);
|
|
161
|
+
if (read.upstream !== null && !stale.includes(read.upstream)) {
|
|
162
|
+
return read;
|
|
163
|
+
}
|
|
164
|
+
const unpushed = count(root, ["HEAD", ...notOnRemotes(stale)]);
|
|
165
|
+
return { ...read, upstream: null, unpushed };
|
|
166
|
+
}
|
|
167
|
+
/** `rev-list` arguments that leave out what a remote branch holds, by the tracking refs still true. */
|
|
168
|
+
function notOnRemotes(stale) {
|
|
169
|
+
return ["--not", ...stale.map((tracking) => `--exclude=${tracking}`), "--remotes"];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The commits of a closed pull request that left the branch, as by a reset, and that no remote branch holds.
|
|
173
|
+
* A head this checkout never had was never this session's to lose.
|
|
174
|
+
*/
|
|
175
|
+
function orphanedOf(root, read, shipped, held) {
|
|
176
|
+
if (shipped?.pr.state !== "CLOSED" || held || read.paused !== null) {
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
return Number(asked(root, ["rev-list", "--count", shipped.pr.head, ...notOnRemotes(staleTracking(root, read))]) ?? 0);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The commits on top of the base that no pull request holds. A branch the config protects takes changes
|
|
183
|
+
* through pull requests rather than making one, and so does the default branch: neither needs one.
|
|
184
|
+
*/
|
|
185
|
+
function aheadOf(root, branch, shipped) {
|
|
186
|
+
if (resolveConfig(root).config.guardrails.protectedBranches.includes(branch)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const base = shipped?.pr.base ?? defaultBranch();
|
|
190
|
+
if (branch === base) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const refs = remotesOf(root)
|
|
194
|
+
.map((remote) => `refs/remotes/${remote}/${base}`)
|
|
195
|
+
.filter((ref) => asked(root, ["rev-parse", "--verify", "--quiet", ref]) !== undefined);
|
|
196
|
+
return { base, commits: count(root, ["HEAD", "--not", ...refs]) };
|
|
197
|
+
}
|
|
198
|
+
function unpushedReason({ branch, upstream, unpushed }) {
|
|
199
|
+
const commits = plural(unpushed, "commit");
|
|
200
|
+
if (upstream !== null) {
|
|
201
|
+
return { id: "unpushed", blocks: true, text: `${commits} not pushed to ${upstream}` };
|
|
202
|
+
}
|
|
203
|
+
const where = branch === null ? "on a detached HEAD, on no remote" : "on a branch with no upstream";
|
|
204
|
+
return { id: "no-upstream", blocks: true, text: `${commits} ${where}` };
|
|
205
|
+
}
|
|
206
|
+
function pullRequestReason({ pr, answer }) {
|
|
207
|
+
const name = `pull request #${pr.number}`;
|
|
208
|
+
if (pr.state === "OPEN") {
|
|
209
|
+
return { id: "open-pull-request", blocks: true, text: `${name} is open: ${labelOf(answer)}: ${answer.reason}` };
|
|
210
|
+
}
|
|
211
|
+
return { id: pr.state === "MERGED" ? "merged" : "closed", blocks: false, text: `${name} ${answer.reason}` };
|
|
212
|
+
}
|
|
213
|
+
/** The reason when none blocks; `askedGh` false says what the verdict never read, so it claims no more. */
|
|
214
|
+
function cleanReason(askedGh) {
|
|
215
|
+
const text = askedGh
|
|
216
|
+
? "nothing is uncommitted, unpushed or open"
|
|
217
|
+
: "nothing is uncommitted or unpushed; gh was not asked";
|
|
218
|
+
return { id: "clean", blocks: false, text };
|
|
219
|
+
}
|
|
220
|
+
function reasonsOf(local, { shipped, ahead, orphaned, askedGh, workers }) {
|
|
221
|
+
const all = [];
|
|
222
|
+
if (local.paused !== null) {
|
|
223
|
+
all.push({ id: "paused", blocks: true, text: `a ${local.paused} is paused` });
|
|
224
|
+
}
|
|
225
|
+
if (local.uncommitted > 0) {
|
|
226
|
+
all.push({ id: "uncommitted", blocks: true, text: plural(local.uncommitted, "uncommitted change") });
|
|
227
|
+
}
|
|
228
|
+
if (local.unpushed > 0) {
|
|
229
|
+
all.push(unpushedReason(local));
|
|
230
|
+
}
|
|
231
|
+
if (ahead !== null && ahead.commits > 0) {
|
|
232
|
+
const text = `${plural(ahead.commits, "commit")} on top of ${ahead.base} in no pull request`;
|
|
233
|
+
all.push({ id: "no-pull-request", blocks: true, text });
|
|
234
|
+
}
|
|
235
|
+
if (shipped !== null && orphaned > 0) {
|
|
236
|
+
const text = `${plural(orphaned, "commit")} of pull request #${shipped.pr.number} on no remote branch`;
|
|
237
|
+
all.push({ id: "orphaned", blocks: true, text });
|
|
238
|
+
}
|
|
239
|
+
if (shipped !== null) {
|
|
240
|
+
all.push(pullRequestReason(shipped));
|
|
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
|
+
}
|
|
246
|
+
if (!all.some((reason) => reason.blocks)) {
|
|
247
|
+
all.unshift(cleanReason(askedGh));
|
|
248
|
+
}
|
|
249
|
+
if (local.worktree.linked) {
|
|
250
|
+
all.push({ id: "worktree", blocks: false, text: `the worktree at ${local.worktree.path} is left to remove` });
|
|
251
|
+
}
|
|
252
|
+
return all;
|
|
253
|
+
}
|
|
254
|
+
/** The verdict and the line a Recap quotes: after "no" only what blocks, after "yes" every reason. */
|
|
255
|
+
function closeOf(reasons) {
|
|
256
|
+
const blocking = reasons.filter((reason) => reason.blocks);
|
|
257
|
+
const verdict = blocking.length > 0 ? "no" : "yes";
|
|
258
|
+
const shown = blocking.length > 0 ? blocking : reasons;
|
|
259
|
+
const line = `Close the session: ${verdict}, because ${shown.map((reason) => reason.text).join("; ")}`;
|
|
260
|
+
return { verdict, line, reasons };
|
|
261
|
+
}
|
|
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) {
|
|
269
|
+
const none = askedGh ? "none" : "gh not asked";
|
|
270
|
+
return [
|
|
271
|
+
`branch: ${local.branch ?? "detached HEAD"}`,
|
|
272
|
+
`uncommitted: ${local.uncommitted}`,
|
|
273
|
+
`upstream: ${local.upstream ?? "none"}, ${local.unpushed} unpushed`,
|
|
274
|
+
`pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
|
|
275
|
+
`worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
|
|
276
|
+
`workers: ${workersLine(workers)}`,
|
|
277
|
+
line
|
|
278
|
+
].join("\n");
|
|
279
|
+
}
|
|
280
|
+
function parseCli(argv) {
|
|
281
|
+
try {
|
|
282
|
+
const { values, positionals } = parseArgs({
|
|
283
|
+
args: argv,
|
|
284
|
+
allowPositionals: true,
|
|
285
|
+
options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } }
|
|
286
|
+
});
|
|
287
|
+
if (positionals.length > 0 && values.help !== true) {
|
|
288
|
+
return { error: `it takes no argument, not ${JSON.stringify(positionals[0])}` };
|
|
289
|
+
}
|
|
290
|
+
return { help: values.help === true, json: values.json === true };
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function report(json) {
|
|
297
|
+
const root = findRepository(process.cwd());
|
|
298
|
+
if (root === undefined) {
|
|
299
|
+
throw new CannotRun(`no git repository holds ${process.cwd()}`, "run it from a checkout");
|
|
300
|
+
}
|
|
301
|
+
const read = readLocal(root);
|
|
302
|
+
const { branch, head } = read;
|
|
303
|
+
// A detached HEAD names no branch for gh to look up, and a repository with no remote has no GitHub.
|
|
304
|
+
const asksGh = branch !== null && remotesOf(root).length > 0;
|
|
305
|
+
const shipped = asksGh ? readShipped(root) : null;
|
|
306
|
+
const held = shipped !== null && head !== null && shipped.pr.commits.includes(head);
|
|
307
|
+
const local = standing(root, read, shipped, held);
|
|
308
|
+
// An open pull request can still take the head, and a commit-less repository has nothing to merge.
|
|
309
|
+
const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
|
|
310
|
+
const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
|
|
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));
|
|
319
|
+
const pullRequest = shipped === null
|
|
320
|
+
? null
|
|
321
|
+
: { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
|
|
322
|
+
const output = json
|
|
323
|
+
? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close }, null, 2)
|
|
324
|
+
: linesOf(local, facts, close.line);
|
|
325
|
+
process.stdout.write(`${output}\n`);
|
|
326
|
+
return close.verdict === "yes" ? 0 : 1;
|
|
327
|
+
}
|
|
328
|
+
export function run(argv) {
|
|
329
|
+
const cli = parseCli(argv);
|
|
330
|
+
if ("error" in cli) {
|
|
331
|
+
process.stderr.write(`dw-sdlc recap: ${cli.error}\n\n${USAGE}\n`);
|
|
332
|
+
return 2;
|
|
333
|
+
}
|
|
334
|
+
if (cli.help) {
|
|
335
|
+
process.stdout.write(`${USAGE}\n`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
return report(cli.json);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (error instanceof CannotRun) {
|
|
343
|
+
process.stderr.write(`dw-sdlc recap: ${error.message} — ${error.remedy}\n`);
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -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,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
|
+
}
|