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,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.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PrMerge, PrReview, ReviewDepth } from "../agentic-config.ts";
|
|
1
|
+
import type { PrMerge, PrReview, ResolvedConfig, ReviewDepth } from "../agentic-config.ts";
|
|
2
2
|
import { type PrState, REVIEW } from "./state.ts";
|
|
3
3
|
export { REVIEW };
|
|
4
4
|
export type Status = "act" | "wait" | "park" | "done";
|
|
@@ -26,6 +26,8 @@ export type Policy = {
|
|
|
26
26
|
};
|
|
27
27
|
reviewCommand: string;
|
|
28
28
|
};
|
|
29
|
+
/** The policy a repository's `pr` key declares; a flag for one call goes over it. */
|
|
30
|
+
export declare function declaredPolicy({ merge, review, reviewCommand }: ResolvedConfig["pr"]): Policy;
|
|
29
31
|
/** An Action the pull request needs beside the one its answer names, so one push can fix several. */
|
|
30
32
|
export type Blocker = {
|
|
31
33
|
action: Action;
|
|
@@ -60,5 +62,9 @@ export declare function hostArgs(pr: PrState, action: ScriptAction, policy: Poli
|
|
|
60
62
|
export declare function devCommand(pr: PrState, action: ScriptAction, policy: Policy): string;
|
|
61
63
|
/** The merge left to the dev, `reason` saying why it is theirs. */
|
|
62
64
|
export declare function readyToMerge(pr: PrState, policy: Policy, reason: string): Omit<Answer, "blockers">;
|
|
65
|
+
/** `<status> <action>`, the action dropped when it repeats the status. */
|
|
66
|
+
export declare function labelOf({ status, action }: Pick<Answer, "status" | "action">): string;
|
|
67
|
+
/** The label and the reason, then the recheck and the command. */
|
|
68
|
+
export declare function lineOf(answer: Answer): string;
|
|
63
69
|
/** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
|
|
64
70
|
export declare function decide(pr: PrState, now: number, policy: Policy): Answer;
|
package/dist/pr/decide.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
import { route } from "./route.js";
|
|
5
5
|
import { awaitsReview, OFF_PREFIX, REVIEW } from "./state.js";
|
|
6
6
|
export { REVIEW };
|
|
7
|
+
/** The policy a repository's `pr` key declares; a flag for one call goes over it. */
|
|
8
|
+
export function declaredPolicy({ merge, review, reviewCommand }) {
|
|
9
|
+
return { merge, review: { depth: review, source: "config" }, reviewCommand };
|
|
10
|
+
}
|
|
7
11
|
/** How long a head may carry no check before it parks as unverified, from its commit. */
|
|
8
12
|
const GRACE_SECONDS = 120;
|
|
9
13
|
/** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
|
|
@@ -148,7 +152,8 @@ const ROWS = [
|
|
|
148
152
|
})
|
|
149
153
|
},
|
|
150
154
|
{
|
|
151
|
-
|
|
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,
|
|
152
157
|
answer: (pr) => ({ status: "act", action: "update-branch", actor: "script", reason: `behind ${pr.base}` })
|
|
153
158
|
},
|
|
154
159
|
{
|
|
@@ -279,6 +284,16 @@ const ROWS = [
|
|
|
279
284
|
merging: true
|
|
280
285
|
}
|
|
281
286
|
];
|
|
287
|
+
/** `<status> <action>`, the action dropped when it repeats the status. */
|
|
288
|
+
export function labelOf({ status, action }) {
|
|
289
|
+
return status === action ? status : `${status} ${action}`;
|
|
290
|
+
}
|
|
291
|
+
/** The label and the reason, then the recheck and the command. */
|
|
292
|
+
export function lineOf(answer) {
|
|
293
|
+
const { reason, command, recheckAfter } = answer;
|
|
294
|
+
const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
|
|
295
|
+
return `${labelOf(answer)}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
|
|
296
|
+
}
|
|
282
297
|
function blockerOf({ action, actor, reason, details }) {
|
|
283
298
|
return { action, actor, reason, ...(details === undefined ? {} : { details }) };
|
|
284
299
|
}
|
package/dist/pr/read.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type PrState } from "./state.ts";
|
|
2
|
-
/** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
|
|
2
|
+
/** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
|
|
3
3
|
export declare class CannotRun extends Error {
|
|
4
4
|
remedy: string;
|
|
5
5
|
constructor(message: string, remedy: string);
|
|
@@ -23,7 +23,18 @@ 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
|
+
/** The state of the current branch's pull request, open or not, or null when gh finds none. */
|
|
27
|
+
export declare function readBranch(sized: boolean): PrState | null;
|
|
28
|
+
/** The default branch of the repository gh resolves from the current directory. */
|
|
29
|
+
export declare function defaultBranch(): string;
|
|
30
|
+
/** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
|
|
31
|
+
export declare function now(): number;
|
|
26
32
|
/** The state of `pr` once more, after an action the script performed on it: the same pull request. */
|
|
27
33
|
export declare function readAgain(pr: PrState, sized: boolean): PrState;
|
|
28
34
|
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
29
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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// The read
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The read the pr commands and recap make: gh as a child process, its auth, the pull request a command names,
|
|
2
|
+
// its state from one GraphQL request with the compares that tell whether its head carries an earlier Review
|
|
3
|
+
// and that size its changes, and beside them the default branch and the clock the decision reads.
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
|
|
6
|
-
import { awaitsReview, parsePr, parseView, QUERY, ReplyError } from "./state.js";
|
|
7
|
-
/** What stops a pr command before it answers: exit 2, the problem and its remedy on one line. */
|
|
6
|
+
import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY, ReplyError } from "./state.js";
|
|
7
|
+
/** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
|
|
8
8
|
export class CannotRun extends Error {
|
|
9
9
|
remedy;
|
|
10
10
|
constructor(message, remedy) {
|
|
@@ -17,7 +17,8 @@ const REMEDIES = {
|
|
|
17
17
|
auth: "run gh auth login, or set GH_TOKEN",
|
|
18
18
|
target: "check the number, or name the repository with -R owner/name",
|
|
19
19
|
read: "check gh auth status and the network, then call again",
|
|
20
|
-
reply: "gh answered a shape dw-sdlc does not read; report it with the output of gh --version"
|
|
20
|
+
reply: "gh answered a shape dw-sdlc does not read; report it with the output of gh --version",
|
|
21
|
+
clock: "unset it: it stands in for the clock in tests"
|
|
21
22
|
};
|
|
22
23
|
/** The problem with `<n>` or `-R` as given on a command line, if there is one. */
|
|
23
24
|
export function namedProblem(number, repo) {
|
|
@@ -76,29 +77,75 @@ function targetOf({ number, repo }, command) {
|
|
|
76
77
|
}
|
|
77
78
|
return parseOrCannotRun(() => parseView(view.stdout));
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
-
* The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
|
|
81
|
-
* `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
|
|
82
|
-
*/
|
|
83
|
-
export function read(named, command, sized) {
|
|
80
|
+
function requireAuth() {
|
|
84
81
|
// The host gh api reads from: an account on any other host has no say in this call.
|
|
85
82
|
const host = process.env.GH_HOST?.trim() || "github.com";
|
|
86
83
|
const auth = gh(["auth", "status", "--active", "--hostname", host]);
|
|
87
84
|
if (auth.status !== 0) {
|
|
88
85
|
throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
|
|
89
86
|
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
|
|
90
|
+
* `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
|
|
91
|
+
*/
|
|
92
|
+
export function read(named, command, sized) {
|
|
93
|
+
requireAuth();
|
|
90
94
|
return query(targetOf(named, command), sized);
|
|
91
95
|
}
|
|
96
|
+
/** The state of the current branch's pull request, open or not, or null when gh finds none. */
|
|
97
|
+
export function readBranch(sized) {
|
|
98
|
+
requireAuth();
|
|
99
|
+
const view = gh(["pr", "view", "--json", "url"]);
|
|
100
|
+
if (view.status === 0) {
|
|
101
|
+
return query(parseOrCannotRun(() => parseView(view.stdout)), sized);
|
|
102
|
+
}
|
|
103
|
+
if (said(view.stderr).startsWith("no pull requests found")) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
|
|
107
|
+
}
|
|
108
|
+
/** The default branch of the repository gh resolves from the current directory. */
|
|
109
|
+
export function defaultBranch() {
|
|
110
|
+
const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
|
|
111
|
+
if (view.status !== 0) {
|
|
112
|
+
throw new CannotRun(`gh repo view failed: ${said(view.stderr)}`, REMEDIES.read);
|
|
113
|
+
}
|
|
114
|
+
return parseOrCannotRun(() => parseDefaultBranch(view.stdout));
|
|
115
|
+
}
|
|
116
|
+
/** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
|
|
117
|
+
export function now() {
|
|
118
|
+
const given = process.env.DW_SDLC_NOW?.trim();
|
|
119
|
+
if (given === undefined || given === "") {
|
|
120
|
+
return Date.now();
|
|
121
|
+
}
|
|
122
|
+
const time = Date.parse(given);
|
|
123
|
+
if (Number.isNaN(time)) {
|
|
124
|
+
throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
|
|
125
|
+
}
|
|
126
|
+
return time;
|
|
127
|
+
}
|
|
92
128
|
/** The state of `pr` once more, after an action the script performed on it: the same pull request. */
|
|
93
129
|
export function readAgain(pr, sized) {
|
|
94
130
|
return query(pr, sized);
|
|
95
131
|
}
|
|
96
|
-
/**
|
|
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. */
|
|
97
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
|
+
}
|
|
98
144
|
const reply = gh(compareArgs(pr.repo, from, commit));
|
|
99
145
|
if (reply.status !== 0) {
|
|
100
146
|
throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
|
|
101
147
|
}
|
|
148
|
+
COMPARES.set(key, reply.stdout);
|
|
102
149
|
return reply.stdout;
|
|
103
150
|
}
|
|
104
151
|
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
@@ -106,6 +153,17 @@ export function carriesBase(before, after) {
|
|
|
106
153
|
const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
|
|
107
154
|
return parseOrCannotRun(() => behindOf(reply)) === 0;
|
|
108
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
|
+
}
|
|
109
167
|
/** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
|
|
110
168
|
function ownChanges(pr, commit) {
|
|
111
169
|
const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
|
|
@@ -154,5 +212,6 @@ function query({ repo, number }, sized) {
|
|
|
154
212
|
const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
|
|
155
213
|
throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
|
|
156
214
|
}
|
|
157
|
-
|
|
215
|
+
const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
|
|
216
|
+
return withChanged(withCarried({ ...parsed, behindBy: null }), sized);
|
|
158
217
|
}
|