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
package/dist/agentic-config.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export type PrConfig = {
|
|
|
36
36
|
reviewCommand: string;
|
|
37
37
|
};
|
|
38
38
|
/** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
|
|
39
|
-
declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
|
|
39
|
+
export declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
|
|
40
40
|
export type WorkerEffort = (typeof WORKER_EFFORTS)[number];
|
|
41
41
|
/** How a Worker's Claude Code session starts: the words that start it, its model and its effort. */
|
|
42
42
|
export type WorkerConfig = {
|
package/dist/agentic-config.js
CHANGED
|
@@ -14,7 +14,7 @@ export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
|
|
|
14
14
|
/** The Review depths, strictest first: what the dev's `--review` names. */
|
|
15
15
|
export const REVIEW_DEPTHS = ["full", "light", "off"];
|
|
16
16
|
/** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
|
|
17
|
-
const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
17
|
+
export const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
18
18
|
/** Strictest value first; the local override may only move towards the front. */
|
|
19
19
|
export const STRICTNESS = {
|
|
20
20
|
forcePush: ["deny", "ask", "lease"],
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,9 @@ import * as recap from "./commands/recap.js";
|
|
|
12
12
|
import * as referencesSetup from "./commands/references-setup.js";
|
|
13
13
|
import * as referencesSync from "./commands/references-sync.js";
|
|
14
14
|
import * as setup from "./commands/setup.js";
|
|
15
|
+
import * as workerLs from "./commands/worker-ls.js";
|
|
16
|
+
import * as workerReport from "./commands/worker-report.js";
|
|
17
|
+
import * as workerStart from "./commands/worker-start.js";
|
|
15
18
|
const COMMANDS = new Map([
|
|
16
19
|
["gate", gate],
|
|
17
20
|
["check", check],
|
|
@@ -23,7 +26,10 @@ const COMMANDS = new Map([
|
|
|
23
26
|
["references setup", referencesSetup],
|
|
24
27
|
["pr ship", prShip],
|
|
25
28
|
["pr reviewed", prReviewed],
|
|
26
|
-
["recap", recap]
|
|
29
|
+
["recap", recap],
|
|
30
|
+
["worker start", workerStart],
|
|
31
|
+
["worker ls", workerLs],
|
|
32
|
+
["worker report", workerReport]
|
|
27
33
|
]);
|
|
28
34
|
function usage() {
|
|
29
35
|
const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
|
|
@@ -40,11 +40,6 @@ export type Inputs = {
|
|
|
40
40
|
};
|
|
41
41
|
/** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
|
|
42
42
|
export declare function claudeConfigDir(env: Record<string, string | undefined>, home: string): string;
|
|
43
|
-
/** The marketplace Claude Code registers this repository under, the plugin in it, and the package the plugin links to. */
|
|
44
|
-
export declare const MARKETPLACE = "dw-sdlc";
|
|
45
|
-
export declare const PLUGIN = "sdlc";
|
|
46
|
-
export declare const PLUGIN_ID = "sdlc@dw-sdlc";
|
|
47
|
-
export declare const PACKAGE = "dw-sdlc";
|
|
48
43
|
/** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
|
|
49
44
|
export declare const RTK_FIND_EXEC_FIXED = "0.46.0";
|
|
50
45
|
export declare const PNPM_MINIMUM_RELEASE_AGE = 10080;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,19 +7,12 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { findRepository, resolveConfig } from "../agentic-config.js";
|
|
10
|
-
import {
|
|
10
|
+
import { checkPackage, checkPlugin, checkSessions, parseJson, runningPluginRoot, versionBefore } from "../doctor-plugins.js";
|
|
11
|
+
import { isObject } from "../json-text.js";
|
|
11
12
|
/** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
|
|
12
13
|
export function claudeConfigDir(env, home) {
|
|
13
14
|
return env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
|
|
14
15
|
}
|
|
15
|
-
/** The marketplace Claude Code registers this repository under, the plugin in it, and the package the plugin links to. */
|
|
16
|
-
export const MARKETPLACE = "dw-sdlc";
|
|
17
|
-
export const PLUGIN = "sdlc";
|
|
18
|
-
export const PLUGIN_ID = `${PLUGIN}@${MARKETPLACE}`;
|
|
19
|
-
/** The plugin of the session helpers: optional, and it links a copy of the package of its own. */
|
|
20
|
-
const SESSIONS = "sdlc-sessions";
|
|
21
|
-
const SESSIONS_ID = `${SESSIONS}@${MARKETPLACE}`;
|
|
22
|
-
export const PACKAGE = "dw-sdlc";
|
|
23
16
|
/** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
|
|
24
17
|
export const RTK_FIND_EXEC_FIXED = "0.46.0";
|
|
25
18
|
export const PNPM_MINIMUM_RELEASE_AGE = 10080;
|
|
@@ -37,20 +30,6 @@ export const realProbe = {
|
|
|
37
30
|
return (result.stdout ?? "").trim().split("\n")[0] ?? "";
|
|
38
31
|
}
|
|
39
32
|
};
|
|
40
|
-
function isObject(value) {
|
|
41
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
-
}
|
|
43
|
-
function parseJson(text) {
|
|
44
|
-
if (text === undefined) {
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
return JSON.parse(text);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
33
|
function checkJq(probe) {
|
|
55
34
|
const { status, stdout } = probe.run("jq", ["--version"]);
|
|
56
35
|
if (status === 0) {
|
|
@@ -58,151 +37,6 @@ function checkJq(probe) {
|
|
|
58
37
|
}
|
|
59
38
|
return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
|
|
60
39
|
}
|
|
61
|
-
/** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
|
|
62
|
-
function liveCheckout(probe, claude) {
|
|
63
|
-
const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
|
|
64
|
-
const entry = isObject(known) && isObject(known[MARKETPLACE]) ? known[MARKETPLACE] : undefined;
|
|
65
|
-
const source = entry !== undefined && isObject(entry.source) ? entry.source : undefined;
|
|
66
|
-
return source?.source === "directory" && typeof source.path === "string" ? source.path : undefined;
|
|
67
|
-
}
|
|
68
|
-
function pluginOrigin(probe, claude, installPath) {
|
|
69
|
-
const checkout = liveCheckout(probe, claude);
|
|
70
|
-
if (checkout !== undefined) {
|
|
71
|
-
return `runs live from ${checkout}/plugins/${PLUGIN}; a change there is in the next session`;
|
|
72
|
-
}
|
|
73
|
-
return `installed at ${installPath}; a change reaches it through claude plugin update`;
|
|
74
|
-
}
|
|
75
|
-
/** What `claude plugin list --json` prints, one object a plugin, or undefined when it does not answer. */
|
|
76
|
-
function listedPlugins(probe) {
|
|
77
|
-
const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
|
|
78
|
-
const list = status === 0 ? parseJson(stdout) : undefined;
|
|
79
|
-
return Array.isArray(list) ? list.filter(isObject) : undefined;
|
|
80
|
-
}
|
|
81
|
-
/** The directory `plugin` runs from on this machine: the checkout of a directory marketplace, else the installed snapshot. */
|
|
82
|
-
function runningPluginRoot(probe, claude, plugin = PLUGIN) {
|
|
83
|
-
const checkout = liveCheckout(probe, claude);
|
|
84
|
-
if (checkout !== undefined) {
|
|
85
|
-
return join(checkout, "plugins", plugin);
|
|
86
|
-
}
|
|
87
|
-
const entry = listedPlugins(probe)?.find((item) => item.id === `${plugin}@${MARKETPLACE}`);
|
|
88
|
-
return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
|
|
89
|
-
}
|
|
90
|
-
function checkPlugin(probe, claude) {
|
|
91
|
-
const list = listedPlugins(probe);
|
|
92
|
-
if (list === undefined) {
|
|
93
|
-
return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
|
|
94
|
-
}
|
|
95
|
-
const entry = list.find((item) => item.id === PLUGIN_ID);
|
|
96
|
-
if (entry === undefined) {
|
|
97
|
-
return {
|
|
98
|
-
name: "plugin",
|
|
99
|
-
level: "FAIL",
|
|
100
|
-
detail: `${PLUGIN_ID} is not installed. claude plugin marketplace add dominikwozniak/dw-sdlc --sparse .claude-plugin plugins packages && claude plugin install ${PLUGIN_ID} --scope user`
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
const version = typeof entry.version === "string" ? entry.version : "?";
|
|
104
|
-
const installPath = typeof entry.installPath === "string" ? entry.installPath : "?";
|
|
105
|
-
if (entry.enabled !== true) {
|
|
106
|
-
return {
|
|
107
|
-
name: "plugin",
|
|
108
|
-
level: "FAIL",
|
|
109
|
-
detail: `${version} installed but disabled, so no guard runs. claude plugin enable ${PLUGIN_ID}`
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
return {
|
|
113
|
-
name: "plugin",
|
|
114
|
-
level: "PASS",
|
|
115
|
-
detail: `${version} enabled; ${pluginOrigin(probe, claude, installPath)}`
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
/** The `version` of the package.json in `dir`, or undefined when there is none. */
|
|
119
|
-
function manifestVersion(probe, dir) {
|
|
120
|
-
const manifest = parseJson(probe.read(join(dir, "package.json")));
|
|
121
|
-
return isObject(manifest) && typeof manifest.version === "string" ? manifest.version : undefined;
|
|
122
|
-
}
|
|
123
|
-
/** Whether the plugin's hooks and skills run the dw-sdlc the repository pins, the one its Gate runs. */
|
|
124
|
-
function checkPackage(probe, claude, root) {
|
|
125
|
-
const name = "package";
|
|
126
|
-
const manifest = root === undefined ? undefined : probe.read(join(root, "package.json"));
|
|
127
|
-
if (root === undefined || manifest === undefined) {
|
|
128
|
-
return { name, level: "INFO", detail: `no package.json here to pin ${PACKAGE}` };
|
|
129
|
-
}
|
|
130
|
-
let spec;
|
|
131
|
-
try {
|
|
132
|
-
spec = parsePins(manifest, parseCatalog(probe.read(join(root, "pnpm-workspace.yaml")) ?? ""))[PACKAGE];
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
return { name, level: "INFO", detail: `${join(root, "package.json")} is not JSON` };
|
|
136
|
-
}
|
|
137
|
-
if (spec === undefined) {
|
|
138
|
-
return { name, level: "INFO", detail: `the repository does not pin ${PACKAGE}` };
|
|
139
|
-
}
|
|
140
|
-
// `workspace:` or a range names no one version: the copy installed under node_modules is the one that runs.
|
|
141
|
-
const pinned = EXACT_VERSION.test(spec) ? spec : manifestVersion(probe, join(root, "node_modules", PACKAGE));
|
|
142
|
-
if (pinned === undefined) {
|
|
143
|
-
return { name, level: "WARN", detail: `the repository pins ${PACKAGE} ${spec}, not installed: pnpm install` };
|
|
144
|
-
}
|
|
145
|
-
const plugin = runningPluginRoot(probe, claude);
|
|
146
|
-
if (plugin === undefined) {
|
|
147
|
-
return { name, level: "INFO", detail: `no ${PLUGIN_ID} runs here to compare with the pinned ${pinned}` };
|
|
148
|
-
}
|
|
149
|
-
const linked = manifestVersion(probe, join(plugin, PACKAGE));
|
|
150
|
-
if (linked === undefined) {
|
|
151
|
-
return {
|
|
152
|
-
name,
|
|
153
|
-
level: "WARN",
|
|
154
|
-
detail: `the plugin at ${plugin} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
if (linked !== pinned) {
|
|
158
|
-
const remedy = versionBefore(linked, pinned)
|
|
159
|
-
? `claude plugin update ${PLUGIN_ID}`
|
|
160
|
-
: `pnpm add --save-dev --save-exact ${PACKAGE}@${linked}`;
|
|
161
|
-
return {
|
|
162
|
-
name,
|
|
163
|
-
level: "WARN",
|
|
164
|
-
detail: `drift: the plugin runs ${PACKAGE} ${linked} and the repository pins ${pinned}, so its hooks and skills run other code than the Gate. ${remedy}`
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
|
|
171
|
-
* the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
|
|
172
|
-
*/
|
|
173
|
-
function checkSessions(probe, claude) {
|
|
174
|
-
const name = "sessions";
|
|
175
|
-
const list = listedPlugins(probe);
|
|
176
|
-
const entry = list?.find((item) => item.id === SESSIONS_ID);
|
|
177
|
-
if (entry === undefined) {
|
|
178
|
-
const detail = list === undefined ? "claude plugin list did not answer" : `no ${SESSIONS_ID}, so no /sdlc-recap`;
|
|
179
|
-
return { name, level: "INFO", detail };
|
|
180
|
-
}
|
|
181
|
-
const version = typeof entry.version === "string" ? entry.version : "?";
|
|
182
|
-
if (entry.enabled !== true) {
|
|
183
|
-
return { name, level: "WARN", detail: `${version} installed but disabled. claude plugin enable ${SESSIONS_ID}` };
|
|
184
|
-
}
|
|
185
|
-
const root = runningPluginRoot(probe, claude, SESSIONS);
|
|
186
|
-
const copy = root === undefined ? undefined : manifestVersion(probe, join(root, PACKAGE));
|
|
187
|
-
if (copy === undefined) {
|
|
188
|
-
return {
|
|
189
|
-
name,
|
|
190
|
-
level: "WARN",
|
|
191
|
-
detail: `${version} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
const guards = runningPluginRoot(probe, claude);
|
|
195
|
-
const other = guards === undefined ? undefined : manifestVersion(probe, join(guards, PACKAGE));
|
|
196
|
-
if (other !== undefined && other !== copy) {
|
|
197
|
-
const behind = versionBefore(copy, other) ? SESSIONS_ID : PLUGIN_ID;
|
|
198
|
-
return {
|
|
199
|
-
name,
|
|
200
|
-
level: "WARN",
|
|
201
|
-
detail: `drift: ${SESSIONS} runs ${PACKAGE} ${copy} and ${PLUGIN} runs ${other}. claude plugin update ${behind}`
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
return { name, level: "PASS", detail: `${version} enabled, on ${PACKAGE} ${copy}` };
|
|
205
|
-
}
|
|
206
40
|
/** Every hook command string in a settings file, whatever the event and matcher. */
|
|
207
41
|
function hookCommands(settings) {
|
|
208
42
|
if (!isObject(settings) || !isObject(settings.hooks)) {
|
|
@@ -241,18 +75,6 @@ export function rtkExcludes(toml) {
|
|
|
241
75
|
}
|
|
242
76
|
return [...(match[1] ?? "").matchAll(/"([^"]*)"/g)].map((entry) => entry[1] ?? "");
|
|
243
77
|
}
|
|
244
|
-
function versionBefore(version, threshold) {
|
|
245
|
-
const a = version.split(".").map((part) => Number.parseInt(part, 10));
|
|
246
|
-
const b = threshold.split(".").map((part) => Number.parseInt(part, 10));
|
|
247
|
-
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
248
|
-
const x = a[i] ?? 0;
|
|
249
|
-
const y = b[i] ?? 0;
|
|
250
|
-
if (x !== y) {
|
|
251
|
-
return x < y;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
256
78
|
// The plugin's rtk-rewrite.sh is the one wiring of `rtk hook claude`; a second one in the user
|
|
257
79
|
// settings rewrites again, and in a linked worktree that copy is what the isolation check refuses.
|
|
258
80
|
function checkRtk(probe, home, claude) {
|
|
@@ -299,6 +121,14 @@ function checkRtk(probe, home, claude) {
|
|
|
299
121
|
detail: `rtk ${version} behind the plugin's rewrite hook; exclude_commands: ${excludes.join(", ") || "none"}`
|
|
300
122
|
};
|
|
301
123
|
}
|
|
124
|
+
/** herdr's `hc` in the agent's shell, which a Lead starts its Workers through; a machine may go without it. */
|
|
125
|
+
function checkHc(probe) {
|
|
126
|
+
const hc = probe.shell("command -v hc");
|
|
127
|
+
if (hc === "") {
|
|
128
|
+
return { name: "hc", level: "INFO", detail: "no hc on PATH, so no /sdlc-delegate: install herdr to run Workers" };
|
|
129
|
+
}
|
|
130
|
+
return { name: "hc", level: "PASS", detail: `${hc}, so a Lead can start Workers` };
|
|
131
|
+
}
|
|
302
132
|
function checkSafeChain(probe) {
|
|
303
133
|
const binary = probe.shell("command -v safe-chain");
|
|
304
134
|
if (binary === "") {
|
|
@@ -381,6 +211,7 @@ export function diagnose(inputs) {
|
|
|
381
211
|
checkPlugin(probe, claude),
|
|
382
212
|
checkPackage(probe, claude, inputs.config === undefined ? undefined : inputs.root),
|
|
383
213
|
checkSessions(probe, claude),
|
|
214
|
+
checkHc(probe),
|
|
384
215
|
checkRtk(probe, home, claude),
|
|
385
216
|
checkSafeChain(probe),
|
|
386
217
|
checkPnpmReleaseAge(probe, home),
|
package/dist/commands/pr-ship.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
|
|
6
6
|
import { decide, declaredPolicy, devCommand, hostArgs, isScriptAction, lineOf, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
|
|
7
|
-
import { CannotRun, carriesBase, gh, namedProblem, now, read, readAgain, said } from "../pr/read.js";
|
|
7
|
+
import { CannotRun, carriesBase, gh, namedProblem, now, read, readAgain, said, withBehind } from "../pr/read.js";
|
|
8
8
|
export const summary = "decide the Action one pull request needs next, as a line or JSON";
|
|
9
9
|
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
|
|
10
10
|
|
|
@@ -13,7 +13,9 @@ is omitted, and answers the Action it needs next: the status, the action, whose
|
|
|
13
13
|
The script performs its own Actions, each at most once and then reads again: it updates a branch
|
|
14
14
|
behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
|
|
15
15
|
request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
|
|
16
|
-
under ask, the default, it parks with the merge command. A
|
|
16
|
+
under ask, the default, it parks with the merge command. A branch is behind when the compare of
|
|
17
|
+
the base with the head shows the head lacks a commit of the base, or GitHub reports BEHIND, which
|
|
18
|
+
it does only under a rule that a branch be up to date. A head without a ${REVIEW} success
|
|
17
19
|
gets the Review depth --review names for this call, else pr.review: under full the agent reviews it
|
|
18
20
|
with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
|
|
19
21
|
subagent reads the diff; under off the script posts the success itself, its description saying
|
|
@@ -143,7 +145,7 @@ function handedOver(pr, policy, answer, action, reason) {
|
|
|
143
145
|
function ship(cli, policy) {
|
|
144
146
|
// Only pr.review auto routes the depth from the changes, so only it reads them.
|
|
145
147
|
const sized = policy.review.depth === "auto";
|
|
146
|
-
let pr = read(cli, "pr ship", sized);
|
|
148
|
+
let pr = withBehind(read(cli, "pr ship", sized));
|
|
147
149
|
const performed = [];
|
|
148
150
|
// The head each Action ran on.
|
|
149
151
|
const postedOn = new Map();
|
|
@@ -177,7 +179,7 @@ function ship(cli, policy) {
|
|
|
177
179
|
const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
|
|
178
180
|
postedOn.set(action, before.head);
|
|
179
181
|
try {
|
|
180
|
-
pr = readAgain(pr, sized);
|
|
182
|
+
pr = withBehind(readAgain(pr, sized));
|
|
181
183
|
performed.push({ ...record, ok: TOOK[action](before, pr) });
|
|
182
184
|
}
|
|
183
185
|
catch (error) {
|
package/dist/commands/recap.js
CHANGED
|
@@ -6,6 +6,7 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
|
|
7
7
|
import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
|
|
8
8
|
import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
|
|
9
|
+
import { isLive, Refusal, workersOf, workingFolders } from "../worker/herdr.js";
|
|
9
10
|
import { git } from "../working-tree.js";
|
|
10
11
|
export const summary = "the facts a Recap rests on and whether the session can be closed";
|
|
11
12
|
const USAGE = `Usage: dw-sdlc recap [--json]
|
|
@@ -21,7 +22,9 @@ The session cannot be closed over any of these:
|
|
|
21
22
|
a commit pushed nowhere or only part of the way;
|
|
22
23
|
an open pull request, whatever its Action;
|
|
23
24
|
commits on top of the base that no pull request holds. The default branch and a branch
|
|
24
|
-
guardrails.protectedBranches names need no pull request
|
|
25
|
+
guardrails.protectedBranches names need no pull request;
|
|
26
|
+
a live Worker this session started as a Lead, in a terminal herdr started ($HC_TERMINAL_ID): its
|
|
27
|
+
report has no one to reach once the Lead is gone. A Worker whose turn ended is still live.
|
|
25
28
|
|
|
26
29
|
A pull request closed without merging merges nothing, and a detached HEAD names no pull request. There a
|
|
27
30
|
commit counts as pushed only on a remote branch, and the command asks the remote which branches still
|
|
@@ -30,8 +33,9 @@ stand. A branch force-pushed since the last fetch can still read as holding it,
|
|
|
30
33
|
A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
|
|
31
34
|
directory it runs in.
|
|
32
35
|
|
|
33
|
-
One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read
|
|
34
|
-
detached HEAD or a repository with no remote asks gh nothing, and the pull request
|
|
36
|
+
One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read, and in
|
|
37
|
+
herdr hc on PATH. A detached HEAD or a repository with no remote asks gh nothing, and the pull request
|
|
38
|
+
fact says so; outside herdr the workers fact says so.
|
|
35
39
|
|
|
36
40
|
Exit 0 the session can be closed, 1 it cannot, 2 when it cannot run.`;
|
|
37
41
|
function plural(howMany, word) {
|
|
@@ -96,6 +100,25 @@ function readLocal(root) {
|
|
|
96
100
|
worktree: { linked: mainCheckout(root) !== undefined, path: root }
|
|
97
101
|
};
|
|
98
102
|
}
|
|
103
|
+
/** The live Workers this session started as a Lead, each in the folder it works in; null outside herdr. */
|
|
104
|
+
function liveWorkers() {
|
|
105
|
+
const lead = process.env.HC_TERMINAL_ID;
|
|
106
|
+
if (lead === undefined || lead === "") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return workingFolders(workersOf(lead, false).filter(isLive));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof Refusal) {
|
|
114
|
+
throw new CannotRun(error.message, error.remedy ?? "check hc ls, then call again");
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function workersText(workers) {
|
|
120
|
+
return workers.map((worker) => `${worker.name} (${worker.status})`).join(", ");
|
|
121
|
+
}
|
|
99
122
|
/** The current branch's pull request and the Action the Ship script's decision gives it, or null. */
|
|
100
123
|
function readShipped(root) {
|
|
101
124
|
// An invalid config layer resolves to ask and full, as in the Ship script.
|
|
@@ -105,14 +128,12 @@ function readShipped(root) {
|
|
|
105
128
|
}
|
|
106
129
|
/**
|
|
107
130
|
* The tracking refs, as `--remotes` names them, that the remote contradicts, which only a fetch would show:
|
|
108
|
-
* a branch it no longer has, its `HEAD`, and the session's own branch where it moved.
|
|
109
|
-
*
|
|
131
|
+
* a branch it no longer has, its `HEAD`, and the session's own branch where it moved. A remote out of reach
|
|
132
|
+
* holds nothing; any other moved branch counts as before (plugins/sdlc-sessions/README.md says why).
|
|
110
133
|
*/
|
|
111
134
|
function staleTracking(root, { branch, upstream }) {
|
|
112
135
|
return remotesOf(root).flatMap((remote) => {
|
|
113
|
-
const live = new Map(
|
|
114
|
-
.split("\n")
|
|
115
|
-
.map((line) => {
|
|
136
|
+
const live = new Map((asked(root, ["ls-remote", "--heads", remote]) ?? "").split("\n").map((line) => {
|
|
116
137
|
const [oid = "", ref = ""] = line.split("\t");
|
|
117
138
|
return [ref.replace("refs/heads/", `${remote}/`), oid];
|
|
118
139
|
}));
|
|
@@ -196,7 +217,7 @@ function cleanReason(askedGh) {
|
|
|
196
217
|
: "nothing is uncommitted or unpushed; gh was not asked";
|
|
197
218
|
return { id: "clean", blocks: false, text };
|
|
198
219
|
}
|
|
199
|
-
function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
|
|
220
|
+
function reasonsOf(local, { shipped, ahead, orphaned, askedGh, workers }) {
|
|
200
221
|
const all = [];
|
|
201
222
|
if (local.paused !== null) {
|
|
202
223
|
all.push({ id: "paused", blocks: true, text: `a ${local.paused} is paused` });
|
|
@@ -218,6 +239,10 @@ function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
|
|
|
218
239
|
if (shipped !== null) {
|
|
219
240
|
all.push(pullRequestReason(shipped));
|
|
220
241
|
}
|
|
242
|
+
if (workers !== null && workers.length > 0) {
|
|
243
|
+
const text = `${plural(workers.length, "live Worker")}: ${workersText(workers)}`;
|
|
244
|
+
all.push({ id: "workers", blocks: true, text });
|
|
245
|
+
}
|
|
221
246
|
if (!all.some((reason) => reason.blocks)) {
|
|
222
247
|
all.unshift(cleanReason(askedGh));
|
|
223
248
|
}
|
|
@@ -234,7 +259,13 @@ function closeOf(reasons) {
|
|
|
234
259
|
const line = `Close the session: ${verdict}, because ${shown.map((reason) => reason.text).join("; ")}`;
|
|
235
260
|
return { verdict, line, reasons };
|
|
236
261
|
}
|
|
237
|
-
function
|
|
262
|
+
function workersLine(workers) {
|
|
263
|
+
if (workers === null) {
|
|
264
|
+
return "not in herdr";
|
|
265
|
+
}
|
|
266
|
+
return workers.length === 0 ? "none live" : `${workers.length} live: ${workersText(workers)}`;
|
|
267
|
+
}
|
|
268
|
+
function linesOf(local, { shipped, askedGh, workers }, line) {
|
|
238
269
|
const none = askedGh ? "none" : "gh not asked";
|
|
239
270
|
return [
|
|
240
271
|
`branch: ${local.branch ?? "detached HEAD"}`,
|
|
@@ -242,6 +273,7 @@ function linesOf(local, shipped, askedGh, line) {
|
|
|
242
273
|
`upstream: ${local.upstream ?? "none"}, ${local.unpushed} unpushed`,
|
|
243
274
|
`pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
|
|
244
275
|
`worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
|
|
276
|
+
`workers: ${workersLine(workers)}`,
|
|
245
277
|
line
|
|
246
278
|
].join("\n");
|
|
247
279
|
}
|
|
@@ -276,14 +308,20 @@ function report(json) {
|
|
|
276
308
|
// An open pull request can still take the head, and a commit-less repository has nothing to merge.
|
|
277
309
|
const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
|
|
278
310
|
const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
|
|
279
|
-
const
|
|
280
|
-
|
|
311
|
+
const facts = {
|
|
312
|
+
shipped,
|
|
313
|
+
ahead,
|
|
314
|
+
orphaned: orphanedOf(root, read, shipped, held),
|
|
315
|
+
askedGh: asksGh,
|
|
316
|
+
workers: liveWorkers()
|
|
317
|
+
};
|
|
318
|
+
const close = closeOf(reasonsOf(local, facts));
|
|
281
319
|
const pullRequest = shipped === null
|
|
282
320
|
? null
|
|
283
321
|
: { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
|
|
284
322
|
const output = json
|
|
285
|
-
? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, close }, null, 2)
|
|
286
|
-
: linesOf(local,
|
|
323
|
+
? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close }, null, 2)
|
|
324
|
+
: linesOf(local, facts, close.line);
|
|
287
325
|
process.stdout.write(`${output}\n`);
|
|
288
326
|
return close.verdict === "yes" ? 0 : 1;
|
|
289
327
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `dw-sdlc worker ls`: the Workers the calling Lead started, with the state herdr holds them in.
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { describe, lead, refuse, table, workersOf, workingFolders } from "../worker/herdr.js";
|
|
4
|
+
export const summary = "list the Workers this Lead started and their states";
|
|
5
|
+
const USAGE = `Usage: dw-sdlc worker ls [--json]
|
|
6
|
+
|
|
7
|
+
Lists the Workers the calling Lead started (terminals labelled role=worker whose parent is this
|
|
8
|
+
terminal): name, address, herdr's state, status and folder. A live Worker's folder is the one it works
|
|
9
|
+
in, its worktree, as hc get reports it; a finished one, or one herdr cannot tell it for, shows the folder
|
|
10
|
+
it was launched in. The status is what the Worker reports, one of idle, working, done, blocked, error, or
|
|
11
|
+
finished once its terminal has ended; unknown before it reports. herdr's done means a turn ended, not the
|
|
12
|
+
task: the Worker's Recap says whether the task is done. Finished Workers stay listed until they are
|
|
13
|
+
removed with hc rm.
|
|
14
|
+
|
|
15
|
+
Needs a terminal herdr started ($HC_TERMINAL_ID) and hc on PATH. One line a Worker, or one JSON array
|
|
16
|
+
with --json.
|
|
17
|
+
|
|
18
|
+
Exit 0 listed, 2 when it cannot run.`;
|
|
19
|
+
export function run(argv) {
|
|
20
|
+
let json;
|
|
21
|
+
try {
|
|
22
|
+
const { values } = parseArgs({
|
|
23
|
+
args: argv,
|
|
24
|
+
options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } }
|
|
25
|
+
});
|
|
26
|
+
if (values.help === true) {
|
|
27
|
+
process.stdout.write(`${USAGE}\n`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
json = values.json === true;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
process.stderr.write(`dw-sdlc worker ls: ${describe(error)}\n\n${USAGE}\n`);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const workers = workingFolders(workersOf(lead(), true));
|
|
38
|
+
if (json) {
|
|
39
|
+
process.stdout.write(`${JSON.stringify(workers, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
else if (workers.length === 0) {
|
|
42
|
+
process.stdout.write("no Workers\n");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const rows = workers.map((worker) => [worker.name, worker.address, worker.state, worker.status, worker.folder]);
|
|
46
|
+
process.stdout.write(`${table([["NAME", "ADDRESS", "STATE", "STATUS", "FOLDER"], ...rows])}\n`);
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return refuse("worker ls", error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// `dw-sdlc worker report`: delivers a Worker's Recap to its Lead, and keeps trying while the Lead is blocked.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { call, describe, lead, refuse, Refusal, said, wholeNumber } from "../worker/herdr.js";
|
|
7
|
+
export const summary = "deliver a Recap to the Lead, retrying while it is blocked";
|
|
8
|
+
const USAGE = `Usage: dw-sdlc worker report --to <lead> --recap <file> [--attempts <n>] [--interval <seconds>]
|
|
9
|
+
|
|
10
|
+
Sends the Recap in <file> to the Lead's terminal with hc send. A Recap of one short line goes as itself;
|
|
11
|
+
anything longer goes as the absolute path of its file, which the Lead reads.
|
|
12
|
+
|
|
13
|
+
hc send exits 9 when the Lead is blocked on a dialog. The command then waits --interval seconds (default
|
|
14
|
+
60) and sends again, up to --attempts sends (default 30), and exits 1 when the Lead is still blocked. It
|
|
15
|
+
never passes --force: typing into a blocked Lead may answer its dialog. Any other refusal of hc send ends
|
|
16
|
+
it at once with exit 1.
|
|
17
|
+
|
|
18
|
+
Needs a terminal herdr started ($HC_TERMINAL_ID) and hc on PATH.
|
|
19
|
+
|
|
20
|
+
Exit 0 delivered, 1 not delivered, 2 when it cannot run.`;
|
|
21
|
+
/** `hc send` exits with this when the target is blocked on a permission or a question. */
|
|
22
|
+
const BLOCKED = 9;
|
|
23
|
+
const DEFAULT_ATTEMPTS = 30;
|
|
24
|
+
const DEFAULT_INTERVAL = 60;
|
|
25
|
+
/** The longest Recap that goes as itself, in characters; a longer one goes as a path. */
|
|
26
|
+
const SHORT = 300;
|
|
27
|
+
function parseCli(argv) {
|
|
28
|
+
try {
|
|
29
|
+
const { values } = parseArgs({
|
|
30
|
+
args: argv,
|
|
31
|
+
options: {
|
|
32
|
+
help: { type: "boolean", short: "h" },
|
|
33
|
+
to: { type: "string" },
|
|
34
|
+
recap: { type: "string" },
|
|
35
|
+
attempts: { type: "string" },
|
|
36
|
+
interval: { type: "string" }
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
if (values.help === true) {
|
|
40
|
+
return "help";
|
|
41
|
+
}
|
|
42
|
+
const { to, recap } = values;
|
|
43
|
+
if (to === undefined || to === "") {
|
|
44
|
+
return { error: "name the Lead's terminal: --to <lead>" };
|
|
45
|
+
}
|
|
46
|
+
if (recap === undefined) {
|
|
47
|
+
return { error: "name the Recap file: --recap <file>" };
|
|
48
|
+
}
|
|
49
|
+
const attempts = wholeNumber("--attempts", values.attempts, DEFAULT_ATTEMPTS, 1);
|
|
50
|
+
const interval = wholeNumber("--interval", values.interval, DEFAULT_INTERVAL, 0);
|
|
51
|
+
if (typeof attempts === "object") {
|
|
52
|
+
return attempts;
|
|
53
|
+
}
|
|
54
|
+
if (typeof interval === "object") {
|
|
55
|
+
return interval;
|
|
56
|
+
}
|
|
57
|
+
return { to, recap, attempts, interval };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return { error: describe(error) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** What to send for the Recap in `file`: the Recap itself when it is one short line, else the file's path. */
|
|
64
|
+
function messageFor(file) {
|
|
65
|
+
const path = resolve(file);
|
|
66
|
+
let text;
|
|
67
|
+
try {
|
|
68
|
+
text = readFileSync(path, "utf8").trim();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new Refusal(`cannot read the Recap ${file}: ${describe(error)}`);
|
|
72
|
+
}
|
|
73
|
+
if (text === "") {
|
|
74
|
+
throw new Refusal(`the Recap ${file} is empty`);
|
|
75
|
+
}
|
|
76
|
+
return { message: text.includes("\n") || text.length > SHORT ? path : text, path };
|
|
77
|
+
}
|
|
78
|
+
async function report({ to, recap, attempts, interval }) {
|
|
79
|
+
lead();
|
|
80
|
+
const { message, path } = messageFor(recap);
|
|
81
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
82
|
+
const sent = call("hc", ["send", to, message]);
|
|
83
|
+
if (sent.status === 0) {
|
|
84
|
+
process.stdout.write(`reported to ${to}\n`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (sent.status !== BLOCKED) {
|
|
88
|
+
process.stderr.write(`dw-sdlc worker report: hc send failed: ${said(sent)} — the Recap stays at ${path}\n`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (attempt === attempts) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
process.stderr.write(`dw-sdlc worker report: ${to} is blocked; waiting ${interval} s (attempt ${attempt} of ${attempts})\n`);
|
|
95
|
+
await sleep(interval * 1000);
|
|
96
|
+
}
|
|
97
|
+
process.stderr.write(`dw-sdlc worker report: gave up after ${attempts} attempts: ${to} is still blocked — ` +
|
|
98
|
+
`the Recap stays at ${path}; say so on your screen so the dev can open ${to}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
export async function run(argv) {
|
|
102
|
+
const cli = parseCli(argv);
|
|
103
|
+
if (cli === "help") {
|
|
104
|
+
process.stdout.write(`${USAGE}\n`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if ("error" in cli) {
|
|
108
|
+
process.stderr.write(`dw-sdlc worker report: ${cli.error}\n\n${USAGE}\n`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return await report(cli);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
return refuse("worker report", error);
|
|
116
|
+
}
|
|
117
|
+
}
|