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.
- package/dist/agentic-config.d.ts +1 -1
- package/dist/agentic-config.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands/doctor.d.ts +0 -5
- package/dist/commands/doctor.js +11 -180
- package/dist/commands/pr-ship.js +6 -4
- package/dist/commands/recap.js +82 -20
- 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.js +2 -1
- package/dist/pr/read.d.ts +13 -2
- package/dist/pr/read.js +45 -7
- package/dist/pr/state.d.ts +11 -3
- package/dist/pr/state.js +3 -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,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,234 @@
|
|
|
1
|
+
// `dw-sdlc worker start`: puts a Brief in a herdr terminal as a Worker's first message, on the command, model
|
|
2
|
+
// and effort the config names, and reads back whether the Worker took it.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { mainCheckout, repositoryRoot, resolveConfig, WORKER_EFFORTS } from "../agentic-config.js";
|
|
8
|
+
import { isObject } from "../json-text.js";
|
|
9
|
+
import { describe, hcObject, isLive, lead, refuse, Refusal, wholeNumber, workersOf } from "../worker/herdr.js";
|
|
10
|
+
import { nameTab } from "../worker/tabs.js";
|
|
11
|
+
export const summary = "start a Worker on a Brief in a herdr terminal";
|
|
12
|
+
/** The most seconds the command looks for the Worker's tab in the window, beside the read-back. */
|
|
13
|
+
const TAB_WAIT = 10;
|
|
14
|
+
const USAGE = `Usage: dw-sdlc worker start --brief <file> --name <name> [--model <model>] [--effort <effort>]
|
|
15
|
+
[--max-workers <n>] [--wait <seconds>] [--json]
|
|
16
|
+
|
|
17
|
+
Starts a Worker in the background and gives it the Brief as its first message. It runs hc run with the name,
|
|
18
|
+
the label role=worker, --outlive-parent and the repository as the folder, then worker.command of the config
|
|
19
|
+
with --worktree <name>, --model, --effort and the Brief. worker.command is split on whitespace, so it holds
|
|
20
|
+
no quoted word. --model and --effort default to worker.model and worker.effort. When the Lead sits in a
|
|
21
|
+
herdr space the Worker joins it, and its tab, which herdr numbers, takes the Worker's name: the window lists
|
|
22
|
+
the tab a moment after hc run, so the command looks for it for up to ${TAB_WAIT} seconds, or --wait when shorter,
|
|
23
|
+
while it reads the terminal back.
|
|
24
|
+
|
|
25
|
+
It reads the terminal back for up to --wait seconds (default 30), until the Worker shows work: its status
|
|
26
|
+
turns working, blocked or done. It fails when herdr reports the first message as unsent, the terminal has
|
|
27
|
+
ended, the Worker reports an error, or the Worker is still idle or silent when the time is up. It never sends
|
|
28
|
+
the Brief again: read the terminal with hc read first.
|
|
29
|
+
|
|
30
|
+
It refuses outside herdr ($HC_TERMINAL_ID unset) and when the Lead already has --max-workers live
|
|
31
|
+
Workers (default 3). The name is 1-32 of a-z, 0-9, - and _; it names the worktree too.
|
|
32
|
+
|
|
33
|
+
One line, or one JSON document with --json.
|
|
34
|
+
|
|
35
|
+
Exit 0 started and delivered, 1 started but not delivered, 2 when it cannot run.`;
|
|
36
|
+
const DEFAULT_MAX_WORKERS = 3;
|
|
37
|
+
const DEFAULT_WAIT = 30;
|
|
38
|
+
/** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
|
|
39
|
+
const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
40
|
+
/** What a Worker reports once it has taken its first message. */
|
|
41
|
+
const WORKING = new Set(["working", "blocked", "done"]);
|
|
42
|
+
/** How long the read-back waits between two reads of the terminal. */
|
|
43
|
+
const POLL_MS = 1000;
|
|
44
|
+
function parseCli(argv) {
|
|
45
|
+
try {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args: argv,
|
|
48
|
+
options: {
|
|
49
|
+
help: { type: "boolean", short: "h" },
|
|
50
|
+
json: { type: "boolean" },
|
|
51
|
+
brief: { type: "string" },
|
|
52
|
+
name: { type: "string" },
|
|
53
|
+
model: { type: "string" },
|
|
54
|
+
effort: { type: "string" },
|
|
55
|
+
"max-workers": { type: "string" },
|
|
56
|
+
wait: { type: "string" }
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
if (values.help === true) {
|
|
60
|
+
return "help";
|
|
61
|
+
}
|
|
62
|
+
const { brief, name, model, effort } = values;
|
|
63
|
+
if (brief === undefined) {
|
|
64
|
+
return { error: "name the Brief file: --brief <file>" };
|
|
65
|
+
}
|
|
66
|
+
if (name === undefined || !NAME.test(name)) {
|
|
67
|
+
return { error: `--name takes 1-32 of a-z, 0-9, - and _, not ${JSON.stringify(name ?? "")}` };
|
|
68
|
+
}
|
|
69
|
+
if (model !== undefined && (model.trim() === "" || model.startsWith("-"))) {
|
|
70
|
+
return { error: `--model takes a model name, not ${JSON.stringify(model)}` };
|
|
71
|
+
}
|
|
72
|
+
if (effort !== undefined && !WORKER_EFFORTS.includes(effort)) {
|
|
73
|
+
return { error: `--effort takes one of ${WORKER_EFFORTS.join(", ")}, not ${JSON.stringify(effort)}` };
|
|
74
|
+
}
|
|
75
|
+
const max = wholeNumber("--max-workers", values["max-workers"], DEFAULT_MAX_WORKERS, 1);
|
|
76
|
+
const wait = wholeNumber("--wait", values.wait, DEFAULT_WAIT, 0);
|
|
77
|
+
if (typeof max === "object") {
|
|
78
|
+
return max;
|
|
79
|
+
}
|
|
80
|
+
if (typeof wait === "object") {
|
|
81
|
+
return wait;
|
|
82
|
+
}
|
|
83
|
+
return { brief, name, model, effort, max, wait, json: values.json === true };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return { error: describe(error) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The Brief's text; a file that is missing or holds only whitespace stops the command. */
|
|
90
|
+
function readBrief(file) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = readFileSync(resolve(file), "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new Refusal(`cannot read the Brief ${file}: ${describe(error)}`);
|
|
97
|
+
}
|
|
98
|
+
if (text.trim() === "") {
|
|
99
|
+
throw new Refusal(`the Brief ${file} is empty`);
|
|
100
|
+
}
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
/** The status a Worker reports, if it has reported one. */
|
|
104
|
+
function reportedStatus(terminal) {
|
|
105
|
+
const { status } = terminal;
|
|
106
|
+
return isObject(status) && typeof status.state === "string" ? status.state : undefined;
|
|
107
|
+
}
|
|
108
|
+
/** Why the Worker can no longer take the Brief, or undefined while it still may. */
|
|
109
|
+
function failure(terminal) {
|
|
110
|
+
const { first_message: first, state } = terminal;
|
|
111
|
+
if (isObject(first) && first.sent === false) {
|
|
112
|
+
return typeof first.reason === "string" ? first.reason : "herdr gave no reason";
|
|
113
|
+
}
|
|
114
|
+
if (state !== "live") {
|
|
115
|
+
return "the terminal has finished";
|
|
116
|
+
}
|
|
117
|
+
return reportedStatus(terminal) === "error" ? "the Worker reports its status as error" : undefined;
|
|
118
|
+
}
|
|
119
|
+
/** Reads the terminal until the Worker shows work, for up to `wait` seconds; why it did not, or undefined once it did. */
|
|
120
|
+
async function delivery(address, wait) {
|
|
121
|
+
const deadline = Date.now() + wait * 1000;
|
|
122
|
+
for (;;) {
|
|
123
|
+
let terminal;
|
|
124
|
+
try {
|
|
125
|
+
terminal = hcObject(["get", address, "--json"]);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (!(error instanceof Refusal)) {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
// The Worker is running by now, so a failed read is a Brief not confirmed, never a start that did not happen.
|
|
132
|
+
return `the terminal could not be read back: ${describe(error)}`;
|
|
133
|
+
}
|
|
134
|
+
const failed = failure(terminal);
|
|
135
|
+
if (failed !== undefined) {
|
|
136
|
+
return failed;
|
|
137
|
+
}
|
|
138
|
+
if (WORKING.has(reportedStatus(terminal) ?? "")) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (Date.now() >= deadline) {
|
|
142
|
+
return `the Worker showed no sign of work within ${wait} s`;
|
|
143
|
+
}
|
|
144
|
+
await sleep(POLL_MS);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The id of the herdr space the Lead sits in, if it sits in one. */
|
|
148
|
+
function spaceOf(leadId) {
|
|
149
|
+
const { labels } = hcObject(["get", leadId, "--json"]);
|
|
150
|
+
const space = isObject(labels) ? labels["herdr.space"] : undefined;
|
|
151
|
+
return typeof space === "string" ? space : undefined;
|
|
152
|
+
}
|
|
153
|
+
async function start(cli) {
|
|
154
|
+
const brief = readBrief(cli.brief);
|
|
155
|
+
const leadId = lead();
|
|
156
|
+
const checkout = repositoryRoot(process.cwd());
|
|
157
|
+
// The Worker's --worktree makes a worktree of the repository itself, never of a linked one.
|
|
158
|
+
const root = mainCheckout(checkout) ?? checkout;
|
|
159
|
+
const worker = resolveConfig(checkout).config.worker;
|
|
160
|
+
const command = worker.command.split(/\s+/).filter(Boolean);
|
|
161
|
+
if (command.length === 0) {
|
|
162
|
+
throw new Refusal("worker.command holds no command", "set it in .ai/agentic.config.local.json, or remove the key");
|
|
163
|
+
}
|
|
164
|
+
const model = cli.model ?? worker.model;
|
|
165
|
+
const effort = cli.effort ?? worker.effort;
|
|
166
|
+
const live = workersOf(leadId, false).filter(isLive);
|
|
167
|
+
if (live.length >= cli.max) {
|
|
168
|
+
throw new Refusal(`${leadId} already has ${live.length} live Workers (the limit is ${cli.max})`, `carry one through first, or raise the limit with --max-workers ${live.length + 1}`);
|
|
169
|
+
}
|
|
170
|
+
const space = spaceOf(leadId);
|
|
171
|
+
const { address } = hcObject([
|
|
172
|
+
"run",
|
|
173
|
+
"--bg",
|
|
174
|
+
"--json",
|
|
175
|
+
"--name",
|
|
176
|
+
cli.name,
|
|
177
|
+
"--label",
|
|
178
|
+
"role=worker",
|
|
179
|
+
...(space === undefined ? [] : ["--label", `herdr.space=${space}`]),
|
|
180
|
+
"--outlive-parent",
|
|
181
|
+
"--cwd",
|
|
182
|
+
root,
|
|
183
|
+
"--",
|
|
184
|
+
...command,
|
|
185
|
+
"--worktree",
|
|
186
|
+
cli.name,
|
|
187
|
+
"--model",
|
|
188
|
+
model,
|
|
189
|
+
"--effort",
|
|
190
|
+
effort,
|
|
191
|
+
brief
|
|
192
|
+
]);
|
|
193
|
+
if (typeof address !== "string") {
|
|
194
|
+
throw new Refusal("hc run answered no address for the terminal it started", "check hc ls before starting it again");
|
|
195
|
+
}
|
|
196
|
+
// Side by side, so the wait for the tab overlaps the wait for the Worker to start.
|
|
197
|
+
const naming = space === undefined ? Promise.resolve("none") : nameTab(address, cli.name, Math.min(TAB_WAIT, cli.wait));
|
|
198
|
+
const [named, reason] = await Promise.all([naming, delivery(address, cli.wait)]);
|
|
199
|
+
if (typeof named === "object") {
|
|
200
|
+
process.stderr.write(`dw-sdlc worker start: warning: ${named.kept}\n`);
|
|
201
|
+
}
|
|
202
|
+
if (cli.json) {
|
|
203
|
+
const tab = typeof named === "object" ? "kept" : named;
|
|
204
|
+
const started = { name: cli.name, address, command: worker.command, model, effort, cwd: root };
|
|
205
|
+
const document = { ...started, delivered: reason === undefined, ...(reason === undefined ? {} : { reason }), tab };
|
|
206
|
+
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
207
|
+
}
|
|
208
|
+
if (reason !== undefined) {
|
|
209
|
+
process.stderr.write(`dw-sdlc worker start: ${cli.name} (${address}) started but its first message was not delivered: ${reason} — ` +
|
|
210
|
+
`run hc read ${address} to see why; do not send the Brief again before you have read it\n`);
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
if (!cli.json) {
|
|
214
|
+
process.stdout.write(`started ${cli.name} (${address}) on ${model} at ${effort} effort\n`);
|
|
215
|
+
}
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
export async function run(argv) {
|
|
219
|
+
const cli = parseCli(argv);
|
|
220
|
+
if (cli === "help") {
|
|
221
|
+
process.stdout.write(`${USAGE}\n`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
if ("error" in cli) {
|
|
225
|
+
process.stderr.write(`dw-sdlc worker start: ${cli.error}\n\n${USAGE}\n`);
|
|
226
|
+
return 2;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return await start(cli);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return refuse("worker start", error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Check, Probe } from "./commands/doctor.ts";
|
|
2
|
+
/** The JSON `text` holds, whatever its kind: `claude plugin list --json` prints an array. */
|
|
3
|
+
export declare function parseJson(text: string | undefined): unknown;
|
|
4
|
+
/** The directory `plugin` runs from on this machine: the checkout of a directory marketplace, else the installed snapshot. */
|
|
5
|
+
export declare function runningPluginRoot(probe: Probe, claude: string, plugin?: string): string | undefined;
|
|
6
|
+
export declare function checkPlugin(probe: Probe, claude: string): Check;
|
|
7
|
+
/** Whether the plugin's hooks and skills run the dw-sdlc the repository pins, the one its Gate runs. */
|
|
8
|
+
export declare function checkPackage(probe: Probe, claude: string, root: string | undefined): Check;
|
|
9
|
+
/**
|
|
10
|
+
* `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
|
|
11
|
+
* the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkSessions(probe: Probe, claude: string): Check;
|
|
14
|
+
export declare function versionBefore(version: string, threshold: string): boolean;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The doctor's lines on the plugins: `sdlc` installed and enabled, the package it links against the one the
|
|
2
|
+
// repository pins, and the optional `sdlc-sessions` with its own copy of the package.
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isObject } from "./json-text.js";
|
|
5
|
+
import { EXACT_VERSION, parseCatalog, parsePins } from "./pins.js";
|
|
6
|
+
/** The marketplace Claude Code registers this repository under, the plugin in it, and the package the plugin links to. */
|
|
7
|
+
const MARKETPLACE = "dw-sdlc";
|
|
8
|
+
const PLUGIN = "sdlc";
|
|
9
|
+
const PLUGIN_ID = `${PLUGIN}@${MARKETPLACE}`;
|
|
10
|
+
/** The plugin of the session helpers: optional, and it links a copy of the package of its own. */
|
|
11
|
+
const SESSIONS = "sdlc-sessions";
|
|
12
|
+
const SESSIONS_ID = `${SESSIONS}@${MARKETPLACE}`;
|
|
13
|
+
const PACKAGE = "dw-sdlc";
|
|
14
|
+
/** The JSON `text` holds, whatever its kind: `claude plugin list --json` prints an array. */
|
|
15
|
+
export function parseJson(text) {
|
|
16
|
+
if (text === undefined) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
|
|
27
|
+
function liveCheckout(probe, claude) {
|
|
28
|
+
const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
|
|
29
|
+
const entry = isObject(known) && isObject(known[MARKETPLACE]) ? known[MARKETPLACE] : undefined;
|
|
30
|
+
const source = entry !== undefined && isObject(entry.source) ? entry.source : undefined;
|
|
31
|
+
return source?.source === "directory" && typeof source.path === "string" ? source.path : undefined;
|
|
32
|
+
}
|
|
33
|
+
function pluginOrigin(probe, claude, installPath) {
|
|
34
|
+
const checkout = liveCheckout(probe, claude);
|
|
35
|
+
if (checkout !== undefined) {
|
|
36
|
+
return `runs live from ${checkout}/plugins/${PLUGIN}; a change there is in the next session`;
|
|
37
|
+
}
|
|
38
|
+
return `installed at ${installPath}; a change reaches it through claude plugin update`;
|
|
39
|
+
}
|
|
40
|
+
/** What `claude plugin list --json` prints, one object a plugin, or undefined when it does not answer. */
|
|
41
|
+
function listedPlugins(probe) {
|
|
42
|
+
const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
|
|
43
|
+
const list = status === 0 ? parseJson(stdout) : undefined;
|
|
44
|
+
return Array.isArray(list) ? list.filter(isObject) : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** The directory `plugin` runs from on this machine: the checkout of a directory marketplace, else the installed snapshot. */
|
|
47
|
+
export function runningPluginRoot(probe, claude, plugin = PLUGIN) {
|
|
48
|
+
const checkout = liveCheckout(probe, claude);
|
|
49
|
+
if (checkout !== undefined) {
|
|
50
|
+
return join(checkout, "plugins", plugin);
|
|
51
|
+
}
|
|
52
|
+
const entry = listedPlugins(probe)?.find((item) => item.id === `${plugin}@${MARKETPLACE}`);
|
|
53
|
+
return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
|
|
54
|
+
}
|
|
55
|
+
export function checkPlugin(probe, claude) {
|
|
56
|
+
const list = listedPlugins(probe);
|
|
57
|
+
if (list === undefined) {
|
|
58
|
+
return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
|
|
59
|
+
}
|
|
60
|
+
const entry = list.find((item) => item.id === PLUGIN_ID);
|
|
61
|
+
if (entry === undefined) {
|
|
62
|
+
return {
|
|
63
|
+
name: "plugin",
|
|
64
|
+
level: "FAIL",
|
|
65
|
+
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`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const version = typeof entry.version === "string" ? entry.version : "?";
|
|
69
|
+
const installPath = typeof entry.installPath === "string" ? entry.installPath : "?";
|
|
70
|
+
if (entry.enabled !== true) {
|
|
71
|
+
return {
|
|
72
|
+
name: "plugin",
|
|
73
|
+
level: "FAIL",
|
|
74
|
+
detail: `${version} installed but disabled, so no guard runs. claude plugin enable ${PLUGIN_ID}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
name: "plugin",
|
|
79
|
+
level: "PASS",
|
|
80
|
+
detail: `${version} enabled; ${pluginOrigin(probe, claude, installPath)}`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The `version` of the package.json in `dir`, or undefined when there is none. */
|
|
84
|
+
function manifestVersion(probe, dir) {
|
|
85
|
+
const manifest = parseJson(probe.read(join(dir, "package.json")));
|
|
86
|
+
return isObject(manifest) && typeof manifest.version === "string" ? manifest.version : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Whether the plugin's hooks and skills run the dw-sdlc the repository pins, the one its Gate runs. */
|
|
89
|
+
export function checkPackage(probe, claude, root) {
|
|
90
|
+
const name = "package";
|
|
91
|
+
const manifest = root === undefined ? undefined : probe.read(join(root, "package.json"));
|
|
92
|
+
if (root === undefined || manifest === undefined) {
|
|
93
|
+
return { name, level: "INFO", detail: `no package.json here to pin ${PACKAGE}` };
|
|
94
|
+
}
|
|
95
|
+
let spec;
|
|
96
|
+
try {
|
|
97
|
+
spec = parsePins(manifest, parseCatalog(probe.read(join(root, "pnpm-workspace.yaml")) ?? ""))[PACKAGE];
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { name, level: "INFO", detail: `${join(root, "package.json")} is not JSON` };
|
|
101
|
+
}
|
|
102
|
+
if (spec === undefined) {
|
|
103
|
+
return { name, level: "INFO", detail: `the repository does not pin ${PACKAGE}` };
|
|
104
|
+
}
|
|
105
|
+
// `workspace:` or a range names no one version: the copy installed under node_modules is the one that runs.
|
|
106
|
+
const pinned = EXACT_VERSION.test(spec) ? spec : manifestVersion(probe, join(root, "node_modules", PACKAGE));
|
|
107
|
+
if (pinned === undefined) {
|
|
108
|
+
return { name, level: "WARN", detail: `the repository pins ${PACKAGE} ${spec}, not installed: pnpm install` };
|
|
109
|
+
}
|
|
110
|
+
const plugin = runningPluginRoot(probe, claude);
|
|
111
|
+
if (plugin === undefined) {
|
|
112
|
+
return { name, level: "INFO", detail: `no ${PLUGIN_ID} runs here to compare with the pinned ${pinned}` };
|
|
113
|
+
}
|
|
114
|
+
const linked = manifestVersion(probe, join(plugin, PACKAGE));
|
|
115
|
+
if (linked === undefined) {
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
level: "WARN",
|
|
119
|
+
detail: `the plugin at ${plugin} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (linked !== pinned) {
|
|
123
|
+
const remedy = versionBefore(linked, pinned)
|
|
124
|
+
? `claude plugin update ${PLUGIN_ID}`
|
|
125
|
+
: `pnpm add --save-dev --save-exact ${PACKAGE}@${linked}`;
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
level: "WARN",
|
|
129
|
+
detail: `drift: the plugin runs ${PACKAGE} ${linked} and the repository pins ${pinned}, so its hooks and skills run other code than the Gate. ${remedy}`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
|
|
136
|
+
* the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
|
|
137
|
+
*/
|
|
138
|
+
export function checkSessions(probe, claude) {
|
|
139
|
+
const name = "sessions";
|
|
140
|
+
const list = listedPlugins(probe);
|
|
141
|
+
const entry = list?.find((item) => item.id === SESSIONS_ID);
|
|
142
|
+
if (entry === undefined) {
|
|
143
|
+
const detail = list === undefined ? "claude plugin list did not answer" : `no ${SESSIONS_ID}, so no /sdlc-recap`;
|
|
144
|
+
return { name, level: "INFO", detail };
|
|
145
|
+
}
|
|
146
|
+
const version = typeof entry.version === "string" ? entry.version : "?";
|
|
147
|
+
if (entry.enabled !== true) {
|
|
148
|
+
return { name, level: "WARN", detail: `${version} installed but disabled. claude plugin enable ${SESSIONS_ID}` };
|
|
149
|
+
}
|
|
150
|
+
const root = runningPluginRoot(probe, claude, SESSIONS);
|
|
151
|
+
const copy = root === undefined ? undefined : manifestVersion(probe, join(root, PACKAGE));
|
|
152
|
+
if (copy === undefined) {
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
level: "WARN",
|
|
156
|
+
detail: `${version} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const guards = runningPluginRoot(probe, claude);
|
|
160
|
+
const other = guards === undefined ? undefined : manifestVersion(probe, join(guards, PACKAGE));
|
|
161
|
+
if (other !== undefined && other !== copy) {
|
|
162
|
+
const behind = versionBefore(copy, other) ? SESSIONS_ID : PLUGIN_ID;
|
|
163
|
+
return {
|
|
164
|
+
name,
|
|
165
|
+
level: "WARN",
|
|
166
|
+
detail: `drift: ${SESSIONS} runs ${PACKAGE} ${copy} and ${PLUGIN} runs ${other}. claude plugin update ${behind}`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { name, level: "PASS", detail: `${version} enabled, on ${PACKAGE} ${copy}` };
|
|
170
|
+
}
|
|
171
|
+
export function versionBefore(version, threshold) {
|
|
172
|
+
const a = version.split(".").map((part) => Number.parseInt(part, 10));
|
|
173
|
+
const b = threshold.split(".").map((part) => Number.parseInt(part, 10));
|
|
174
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
175
|
+
const x = a[i] ?? 0;
|
|
176
|
+
const y = b[i] ?? 0;
|
|
177
|
+
if (x !== y) {
|
|
178
|
+
return x < y;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
package/dist/pr/decide.js
CHANGED
|
@@ -152,7 +152,8 @@ const ROWS = [
|
|
|
152
152
|
})
|
|
153
153
|
},
|
|
154
154
|
{
|
|
155
|
-
|
|
155
|
+
// GitHub reports BEHIND only under a rule that a branch be up to date; the compare tells it anywhere.
|
|
156
|
+
when: (pr) => pr.mergeStateStatus === "BEHIND" || (pr.behindBy ?? 0) > 0,
|
|
156
157
|
answer: (pr) => ({ status: "act", action: "update-branch", actor: "script", reason: `behind ${pr.base}` })
|
|
157
158
|
},
|
|
158
159
|
{
|
package/dist/pr/read.d.ts
CHANGED
|
@@ -23,8 +23,14 @@ export declare function gh(args: string[]): {
|
|
|
23
23
|
* `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
|
|
24
24
|
*/
|
|
25
25
|
export declare function read(named: Named, command: string, sized: boolean): PrState;
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
|
|
28
|
+
* misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readBranch(sized: boolean, upstreamName: string | undefined): {
|
|
31
|
+
pr: PrState;
|
|
32
|
+
byUpstream: boolean;
|
|
33
|
+
} | null;
|
|
28
34
|
/** The default branch of the repository gh resolves from the current directory. */
|
|
29
35
|
export declare function defaultBranch(): string;
|
|
30
36
|
/** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
|
|
@@ -33,3 +39,8 @@ export declare function now(): number;
|
|
|
33
39
|
export declare function readAgain(pr: PrState, sized: boolean): PrState;
|
|
34
40
|
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
35
41
|
export declare function carriesBase(before: PrState, after: PrState): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
|
|
44
|
+
* read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
|
|
45
|
+
*/
|
|
46
|
+
export declare function withBehind(pr: PrState): PrState;
|