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.
- 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 +52 -14
- 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 +5 -0
- package/dist/pr/read.js +25 -2
- 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,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
|
@@ -33,3 +33,8 @@ export declare function now(): number;
|
|
|
33
33
|
export declare function readAgain(pr: PrState, sized: boolean): PrState;
|
|
34
34
|
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
35
35
|
export declare function carriesBase(before: PrState, after: PrState): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
|
|
38
|
+
* read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
|
|
39
|
+
*/
|
|
40
|
+
export declare function withBehind(pr: PrState): PrState;
|
package/dist/pr/read.js
CHANGED
|
@@ -129,12 +129,23 @@ export function now() {
|
|
|
129
129
|
export function readAgain(pr, sized) {
|
|
130
130
|
return query(pr, sized);
|
|
131
131
|
}
|
|
132
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* The compares this process has read, by repository and the two commits, so reads that share one ask gh
|
|
134
|
+
* once: a compare of two commits never changes.
|
|
135
|
+
*/
|
|
136
|
+
const COMPARES = new Map();
|
|
137
|
+
/** The reply of the compare from `from` to `commit`, read once per process; `what` names it when gh refuses it. */
|
|
133
138
|
function compared(pr, from, commit, what) {
|
|
139
|
+
const key = `${pr.repo} ${from}...${commit}`;
|
|
140
|
+
const known = COMPARES.get(key);
|
|
141
|
+
if (known !== undefined) {
|
|
142
|
+
return known;
|
|
143
|
+
}
|
|
134
144
|
const reply = gh(compareArgs(pr.repo, from, commit));
|
|
135
145
|
if (reply.status !== 0) {
|
|
136
146
|
throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
|
|
137
147
|
}
|
|
148
|
+
COMPARES.set(key, reply.stdout);
|
|
138
149
|
return reply.stdout;
|
|
139
150
|
}
|
|
140
151
|
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
@@ -142,6 +153,17 @@ export function carriesBase(before, after) {
|
|
|
142
153
|
const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
|
|
143
154
|
return parseOrCannotRun(() => behindOf(reply)) === 0;
|
|
144
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
|
|
158
|
+
* read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
|
|
159
|
+
*/
|
|
160
|
+
export function withBehind(pr) {
|
|
161
|
+
if (pr.state !== "OPEN" || pr.mergeable !== "MERGEABLE" || pr.mergeStateStatus === "BEHIND") {
|
|
162
|
+
return pr;
|
|
163
|
+
}
|
|
164
|
+
const reply = compared(pr, pr.baseOid, pr.head, `the compare with ${pr.base}`);
|
|
165
|
+
return { ...pr, behindBy: parseOrCannotRun(() => behindOf(reply)) };
|
|
166
|
+
}
|
|
145
167
|
/** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
|
|
146
168
|
function ownChanges(pr, commit) {
|
|
147
169
|
const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
|
|
@@ -190,5 +212,6 @@ function query({ repo, number }, sized) {
|
|
|
190
212
|
const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
|
|
191
213
|
throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
|
|
192
214
|
}
|
|
193
|
-
|
|
215
|
+
const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
|
|
216
|
+
return withChanged(withCarried({ ...parsed, behindBy: null }), sized);
|
|
194
217
|
}
|
package/dist/pr/state.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export declare const REVIEW = "dw-sdlc/review";
|
|
3
3
|
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
4
4
|
export declare const OFF_PREFIX = "off:";
|
|
5
|
-
export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
|
|
5
|
+
export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
|
|
6
6
|
export type Bucket = "pass" | "fail" | "pending" | "skipping";
|
|
7
7
|
export type CiCheck = {
|
|
8
8
|
name: string;
|
|
@@ -40,7 +40,10 @@ export type PrState = {
|
|
|
40
40
|
headCommittedAt: string;
|
|
41
41
|
branch: string;
|
|
42
42
|
base: string;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The commit the base branch points at, read from the branch itself: GitHub's `baseRefOid` lags a
|
|
45
|
+
* base that just moved. A deleted base branch leaves `baseRefOid`.
|
|
46
|
+
*/
|
|
44
47
|
baseOid: string;
|
|
45
48
|
mergeCommit: string | null;
|
|
46
49
|
checks: CiCheck[];
|
|
@@ -57,6 +60,11 @@ export type PrState = {
|
|
|
57
60
|
* auto` to route on: `unsized` when the compare cannot list them all, null when not read.
|
|
58
61
|
*/
|
|
59
62
|
changed: ChangedFile[] | "unsized" | null;
|
|
63
|
+
/**
|
|
64
|
+
* How many commits of the base the head lacks, from the compare of the base with the head; null when
|
|
65
|
+
* not read.
|
|
66
|
+
*/
|
|
67
|
+
behindBy: number | null;
|
|
60
68
|
};
|
|
61
69
|
/**
|
|
62
70
|
* A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
|
|
@@ -100,5 +108,5 @@ export declare function parseView(reply: string): {
|
|
|
100
108
|
/** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
|
|
101
109
|
export declare function parseDefaultBranch(reply: string): string;
|
|
102
110
|
/** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
|
|
103
|
-
export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
|
|
111
|
+
export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "behindBy">;
|
|
104
112
|
export {};
|
package/dist/pr/state.js
CHANGED
|
@@ -8,7 +8,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
|
8
8
|
repository(owner: $owner, name: $name) {
|
|
9
9
|
pullRequest(number: $number) {
|
|
10
10
|
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
|
|
11
|
-
viewerDidAuthor mergeCommit { oid }
|
|
11
|
+
baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
|
|
12
12
|
reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
|
|
13
13
|
comments(first: 1) { nodes { author { login } body } }
|
|
14
14
|
latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
|
|
@@ -232,6 +232,7 @@ export function parsePr(reply, repo) {
|
|
|
232
232
|
const rollup = childOrNull(commit, "statusCheckRollup");
|
|
233
233
|
const mergeCommit = childOrNull(pr, "mergeCommit");
|
|
234
234
|
const head = string(pr, "headRefOid");
|
|
235
|
+
const baseRef = childOrNull(pr, "baseRef");
|
|
235
236
|
return {
|
|
236
237
|
url: string(pr, "url"),
|
|
237
238
|
repo,
|
|
@@ -246,7 +247,7 @@ export function parsePr(reply, repo) {
|
|
|
246
247
|
headCommittedAt: time(commit, "committedDate"),
|
|
247
248
|
branch: string(pr, "headRefName"),
|
|
248
249
|
base: string(pr, "baseRefName"),
|
|
249
|
-
baseOid: string(pr, "baseRefOid"),
|
|
250
|
+
baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
|
|
250
251
|
mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
|
|
251
252
|
checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
|
|
252
253
|
unresolvedThreads: nodes(pr, "reviewThreads")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type JsonObject } from "../json-text.ts";
|
|
2
|
+
/** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
|
|
3
|
+
export declare class Refusal extends Error {
|
|
4
|
+
remedy: string | undefined;
|
|
5
|
+
constructor(message: string, remedy?: string);
|
|
6
|
+
}
|
|
7
|
+
export type Called = {
|
|
8
|
+
status: number;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
};
|
|
12
|
+
/** The first line of a tool's output that says anything, or the exit it ended with. */
|
|
13
|
+
export declare function said(called: Called): string;
|
|
14
|
+
/** `tool args` as a child process; a tool that is not on PATH stops the command. */
|
|
15
|
+
export declare function call(tool: "hc" | "herdr-app", args: string[]): Called;
|
|
16
|
+
/** The JSON object `hc <args>` prints; a failing call or a reply that is no object stops the command. */
|
|
17
|
+
export declare function hcObject(args: string[]): JsonObject;
|
|
18
|
+
/** What an error says, whatever was thrown. */
|
|
19
|
+
export declare function describe(error: unknown): string;
|
|
20
|
+
/** Reports a `Refusal` of `command` on stderr and answers exit 2; any other error is a bug and goes on up. */
|
|
21
|
+
export declare function refuse(command: string, error: unknown): number;
|
|
22
|
+
/** The option `flag` as a whole number from `min`, `fallback` when the command line leaves it out. */
|
|
23
|
+
export declare function wholeNumber(flag: string, value: string | undefined, fallback: number, min: number): number | {
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
/** The terminal the command runs in, which is the Lead of the Workers it starts or reads. */
|
|
27
|
+
export declare function lead(): string;
|
|
28
|
+
export type Worker = {
|
|
29
|
+
name: string;
|
|
30
|
+
address: string;
|
|
31
|
+
/** herdr's state: live or finished. */
|
|
32
|
+
state: string;
|
|
33
|
+
/** What the Worker reports (idle, working, done, blocked, error), `finished` once it has ended, `unknown` before it reports. */
|
|
34
|
+
status: string;
|
|
35
|
+
/** The folder it was launched in, or the one it works in once `workingFolders` has asked herdr. */
|
|
36
|
+
folder: string;
|
|
37
|
+
};
|
|
38
|
+
/** Whether herdr still runs the Worker's terminal; the one test of it every command shares. */
|
|
39
|
+
export declare function isLive(worker: Worker): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The Workers with the folder each live one works in, its worktree, which `hc get` reports as
|
|
42
|
+
* `foreground.cwd` and `hc ls` leaves out. A Worker herdr cannot tell it for keeps its launch folder.
|
|
43
|
+
*/
|
|
44
|
+
export declare function workingFolders(workers: Worker[]): Worker[];
|
|
45
|
+
/** The Workers `lead` started, in the order herdr lists them; `all` adds the finished ones. */
|
|
46
|
+
export declare function workersOf(leadId: string, all: boolean): Worker[];
|
|
47
|
+
/** A table of rows, every column but the last padded to its widest cell and two spaces apart. */
|
|
48
|
+
export declare function table(rows: string[][]): string;
|