dw-sdlc 0.9.0 → 0.11.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 +7 -3
- package/config/agentic.config.schema.json +23 -2
- package/dist/agentic-config.d.ts +22 -4
- package/dist/agentic-config.js +15 -5
- package/dist/cli.js +3 -1
- package/dist/commands/doctor.js +54 -15
- package/dist/commands/pr-reviewed.js +1 -1
- package/dist/commands/pr-ship.js +96 -46
- package/dist/commands/recap.d.ts +2 -0
- package/dist/commands/recap.js +310 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +17 -9
- package/dist/pr/decide.d.ts +20 -9
- package/dist/pr/decide.js +77 -16
- package/dist/pr/delta.d.ts +11 -0
- package/dist/pr/delta.js +60 -0
- package/dist/pr/read.d.ts +15 -4
- package/dist/pr/read.js +95 -13
- package/dist/pr/route.d.ts +14 -0
- package/dist/pr/route.js +52 -0
- package/dist/pr/state.d.ts +52 -2
- package/dist/pr/state.js +47 -9
- package/dist/setup-worker.d.ts +11 -0
- package/dist/setup-worker.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,9 +56,12 @@ the delete command for the dev.
|
|
|
56
56
|
|
|
57
57
|
`pr.merge` in the committed `.ai/agentic.config.json` says who merges: the script under `auto`, the
|
|
58
58
|
dev under `ask`, the default. `pr.review` says how closely a head no one has reviewed is read: the
|
|
59
|
-
agent reviews it under `full`,
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
agent reviews it under `full`, one `sdlc-reviewer` subagent of the plugin does under `light`, and no
|
|
60
|
+
one does under `off`. Under `auto`, the default, the script routes the depth from the changes:
|
|
61
|
+
lockfiles alone `off`; a workflow file, more than 100 changed lines outside lockfiles, or a change
|
|
62
|
+
too large to size `full`; the rest `light`. The local layer may tighten either and never loosen it;
|
|
63
|
+
`--merge` acts as `auto` and `--review full|light|off` sets the depth for one call. A pull request
|
|
64
|
+
the `gh` account did not open is always the dev's to merge.
|
|
62
65
|
|
|
63
66
|
```json
|
|
64
67
|
{ "pr": { "merge": "auto", "review": "full" } }
|
|
@@ -83,6 +86,7 @@ script posts that status itself, its description saying why, and goes on.
|
|
|
83
86
|
| `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
|
|
84
87
|
| `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
|
|
85
88
|
| `pr reviewed <n>` | a clean review pinned to the head it read, as a commit status |
|
|
89
|
+
| `recap` | the facts a Recap rests on and whether the session can be closed |
|
|
86
90
|
|
|
87
91
|
`dw-sdlc <command> --help` gives each one's options.
|
|
88
92
|
|
|
@@ -395,8 +395,8 @@
|
|
|
395
395
|
"enum": ["ask", "auto"]
|
|
396
396
|
},
|
|
397
397
|
"review": {
|
|
398
|
-
"description": "The Review depth of a head the Ship script finds unreviewed, which the dev's `--review` overrides for one call. `full`: the agent
|
|
399
|
-
"enum": ["full", "off"]
|
|
398
|
+
"description": "The Review depth of a head the Ship script finds unreviewed, which the dev's `--review` overrides for one call. `full`: the agent runs the command `pr.reviewCommand` names over the changes. `light`: one `sdlc-reviewer` subagent reads the diff at medium effort. `off`: the script posts the `dw-sdlc/review` success itself and no one reads them. `auto`: built-in rules route the depth from the changes the Review would read: lockfiles alone `off`; a file under `.github/workflows/`, more than 100 changed lines outside lockfiles, or a change the script cannot size, more files than GitHub's compare lists or a file outside lockfiles with no patch, `full`; the rest `light`. Default `auto`; an invalid layer runs `full`. Strictest first: full, auto, light, off.",
|
|
399
|
+
"enum": ["full", "auto", "light", "off"]
|
|
400
400
|
},
|
|
401
401
|
"reviewCommand": {
|
|
402
402
|
"description": "The slash command a `full` Review runs over the pull request's changes, as `/pr-review-toolkit:review-pr code errors`. Default `/code-review`. No strictness order: the local override sets it freely.",
|
|
@@ -406,6 +406,27 @@
|
|
|
406
406
|
},
|
|
407
407
|
"additionalProperties": false
|
|
408
408
|
},
|
|
409
|
+
"worker": {
|
|
410
|
+
"description": "How a Worker's Claude Code session starts: the command, the model and the effort. No strictness order, as with `pr.reviewCommand`: any layer sets any key freely.",
|
|
411
|
+
"type": "object",
|
|
412
|
+
"properties": {
|
|
413
|
+
"command": {
|
|
414
|
+
"description": "The command a Worker starts with: the words that start its Claude Code session, before the flags of its own, such as `--model` and `--effort`. Default `claude`. It belongs to one machine, so it sits in the local layer, where `dw-sdlc setup` writes `cswap run -- --dangerously-skip-permissions` when `cswap` is on PATH.",
|
|
415
|
+
"type": "string",
|
|
416
|
+
"minLength": 1
|
|
417
|
+
},
|
|
418
|
+
"model": {
|
|
419
|
+
"description": "The model a Worker runs on, as Claude Code's `--model` takes it. Default `sonnet`.",
|
|
420
|
+
"type": "string",
|
|
421
|
+
"minLength": 1
|
|
422
|
+
},
|
|
423
|
+
"effort": {
|
|
424
|
+
"description": "The effort a Worker runs at, one of Claude Code's effort levels. Default `high`.",
|
|
425
|
+
"enum": ["low", "medium", "high", "xhigh", "max"]
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
"additionalProperties": false
|
|
429
|
+
},
|
|
409
430
|
"worktree": {
|
|
410
431
|
"type": "object",
|
|
411
432
|
"properties": {
|
package/dist/agentic-config.d.ts
CHANGED
|
@@ -17,8 +17,15 @@ export type EnvFiles = {
|
|
|
17
17
|
};
|
|
18
18
|
/** Who merges a pull request the Ship script finds ready: the script under `auto`, the dev under `ask`. */
|
|
19
19
|
export type PrMerge = "ask" | "auto";
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* How closely a Review reads an unreviewed head: the command `pr.reviewCommand` names under `full`,
|
|
22
|
+
* one `sdlc-reviewer` subagent under `light`, no one under `off`.
|
|
23
|
+
*/
|
|
24
|
+
export type ReviewDepth = "full" | "light" | "off";
|
|
25
|
+
/** The Review depths, strictest first: what the dev's `--review` names. */
|
|
26
|
+
export declare const REVIEW_DEPTHS: readonly ["full", "light", "off"];
|
|
27
|
+
/** `pr.review`: a Review depth, or `auto`, which routes one from the changes by built-in rules. */
|
|
28
|
+
export type PrReview = ReviewDepth | "auto";
|
|
22
29
|
/**
|
|
23
30
|
* How the Ship script ends a pull request: who merges it, how closely it is reviewed, and the slash
|
|
24
31
|
* command a `full` Review runs.
|
|
@@ -28,6 +35,15 @@ export type PrConfig = {
|
|
|
28
35
|
review: PrReview;
|
|
29
36
|
reviewCommand: string;
|
|
30
37
|
};
|
|
38
|
+
/** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
|
|
39
|
+
declare const WORKER_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
|
|
40
|
+
export type WorkerEffort = (typeof WORKER_EFFORTS)[number];
|
|
41
|
+
/** How a Worker's Claude Code session starts: the words that start it, its model and its effort. */
|
|
42
|
+
export type WorkerConfig = {
|
|
43
|
+
command: string;
|
|
44
|
+
model: string;
|
|
45
|
+
effort: WorkerEffort;
|
|
46
|
+
};
|
|
31
47
|
export type Guardrails = {
|
|
32
48
|
forcePush: ForcePush;
|
|
33
49
|
commitOnProtected: CommitOnProtected;
|
|
@@ -84,6 +100,7 @@ export type AgenticConfig = {
|
|
|
84
100
|
};
|
|
85
101
|
gate?: GateLayer;
|
|
86
102
|
pr?: Partial<PrConfig>;
|
|
103
|
+
worker?: Partial<WorkerConfig>;
|
|
87
104
|
worktree?: {
|
|
88
105
|
setup?: string[];
|
|
89
106
|
};
|
|
@@ -104,6 +121,7 @@ export type ResolvedConfig = {
|
|
|
104
121
|
};
|
|
105
122
|
gate: Gate;
|
|
106
123
|
pr: PrConfig;
|
|
124
|
+
worker: WorkerConfig;
|
|
107
125
|
worktree: {
|
|
108
126
|
setup: string[];
|
|
109
127
|
};
|
|
@@ -141,13 +159,13 @@ export declare const STRICTNESS: {
|
|
|
141
159
|
readonly commitOnProtected: readonly ["deny", "allow"];
|
|
142
160
|
readonly envFiles: readonly ["deny", "git"];
|
|
143
161
|
readonly merge: readonly ["ask", "auto"];
|
|
144
|
-
readonly review: readonly ["full", "off"];
|
|
162
|
+
readonly review: readonly ["full", "auto", "light", "off"];
|
|
145
163
|
};
|
|
146
164
|
/** What the guards run on while any layer is invalid. */
|
|
147
165
|
export declare const STRICTEST_GUARDRAILS: Guardrails;
|
|
148
166
|
/** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
|
|
149
167
|
export declare const STRICTEST_PR_MERGE: PrMerge;
|
|
150
|
-
/** The Review depth while any layer is invalid
|
|
168
|
+
/** The Review depth while any layer is invalid: the agent reads every head in full. */
|
|
151
169
|
export declare const STRICTEST_PR_REVIEW: PrReview;
|
|
152
170
|
/** The command a `full` Review runs with nothing declared; it has no strictness order. */
|
|
153
171
|
export declare const DEFAULT_REVIEW_COMMAND = "/code-review";
|
package/dist/agentic-config.js
CHANGED
|
@@ -11,13 +11,17 @@ export const LOCAL_FILE = ".ai/agentic.config.local.json";
|
|
|
11
11
|
export const SCHEMA_FILE = "config/agentic.config.schema.json";
|
|
12
12
|
export const SCHEMA_PATH = join(import.meta.dirname, "..", SCHEMA_FILE);
|
|
13
13
|
export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
|
|
14
|
+
/** The Review depths, strictest first: what the dev's `--review` names. */
|
|
15
|
+
export const REVIEW_DEPTHS = ["full", "light", "off"];
|
|
16
|
+
/** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
|
|
17
|
+
const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
14
18
|
/** Strictest value first; the local override may only move towards the front. */
|
|
15
19
|
export const STRICTNESS = {
|
|
16
20
|
forcePush: ["deny", "ask", "lease"],
|
|
17
21
|
commitOnProtected: ["deny", "allow"],
|
|
18
22
|
envFiles: ["deny", "git"],
|
|
19
23
|
merge: ["ask", "auto"],
|
|
20
|
-
review: ["full", "off"]
|
|
24
|
+
review: ["full", "auto", "light", "off"]
|
|
21
25
|
};
|
|
22
26
|
/** What the guards run on while any layer is invalid. */
|
|
23
27
|
export const STRICTEST_GUARDRAILS = {
|
|
@@ -28,11 +32,15 @@ export const STRICTEST_GUARDRAILS = {
|
|
|
28
32
|
};
|
|
29
33
|
/** What the Ship script runs on while any layer is invalid, and with nothing declared: it merges nothing itself. */
|
|
30
34
|
export const STRICTEST_PR_MERGE = "ask";
|
|
31
|
-
/** The Review depth while any layer is invalid
|
|
35
|
+
/** The Review depth while any layer is invalid: the agent reads every head in full. */
|
|
32
36
|
export const STRICTEST_PR_REVIEW = "full";
|
|
33
37
|
const STRICTEST_PR = { merge: STRICTEST_PR_MERGE, review: STRICTEST_PR_REVIEW };
|
|
34
38
|
/** The command a `full` Review runs with nothing declared; it has no strictness order. */
|
|
35
39
|
export const DEFAULT_REVIEW_COMMAND = "/code-review";
|
|
40
|
+
/** What a repository gets with nothing declared: the merge the dev's, the depth routed from the changes. */
|
|
41
|
+
const DEFAULT_PR = { merge: STRICTEST_PR_MERGE, review: "auto", reviewCommand: DEFAULT_REVIEW_COMMAND };
|
|
42
|
+
/** What a Worker starts with when no layer says otherwise; no key has a strictness order. */
|
|
43
|
+
const DEFAULT_WORKER = { command: "claude", model: "sonnet", effort: "high" };
|
|
36
44
|
/** What a repository gets with nothing declared. */
|
|
37
45
|
export const DEFAULT_GUARDRAILS = {
|
|
38
46
|
forcePush: "lease",
|
|
@@ -202,7 +210,8 @@ export function inferConfig(root) {
|
|
|
202
210
|
guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
|
|
203
211
|
commit: { convention: "conventional" },
|
|
204
212
|
gate: inferGate(root),
|
|
205
|
-
pr: { ...
|
|
213
|
+
pr: { ...DEFAULT_PR },
|
|
214
|
+
worker: { ...DEFAULT_WORKER },
|
|
206
215
|
worktree: { setup: [] }
|
|
207
216
|
};
|
|
208
217
|
}
|
|
@@ -219,6 +228,7 @@ function applyLayer(base, layer) {
|
|
|
219
228
|
commit: { ...base.commit, ...layer.commit },
|
|
220
229
|
gate: { ...base.gate, ...layer.gate, checks: { ...base.gate.checks, ...layer.gate?.checks } },
|
|
221
230
|
pr: { ...base.pr, ...layer.pr },
|
|
231
|
+
worker: { ...base.worker, ...layer.worker },
|
|
222
232
|
worktree: { setup: layer.worktree?.setup ?? base.worktree.setup }
|
|
223
233
|
};
|
|
224
234
|
}
|
|
@@ -277,7 +287,7 @@ function loosenings(base, local) {
|
|
|
277
287
|
}
|
|
278
288
|
return problems;
|
|
279
289
|
}
|
|
280
|
-
/** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over full. */
|
|
290
|
+
/** Each local `pr` key less strict than the one it would override: `merge` auto over ask, `review` off over light over auto over full. */
|
|
281
291
|
function prLoosenings(base, local) {
|
|
282
292
|
const keys = ["merge", "review"];
|
|
283
293
|
return keys.flatMap((key) => {
|
|
@@ -323,7 +333,7 @@ export function resolveConfig(root) {
|
|
|
323
333
|
}
|
|
324
334
|
}
|
|
325
335
|
if (problems.length > 0) {
|
|
326
|
-
// `reviewCommand`
|
|
336
|
+
// `reviewCommand` and `worker` have no strictest value: they keep what the valid layers name.
|
|
327
337
|
config = { ...config, guardrails: cloneGuardrails(STRICTEST_GUARDRAILS), pr: { ...config.pr, ...STRICTEST_PR } };
|
|
328
338
|
}
|
|
329
339
|
return {
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import * as gate from "./commands/gate.js";
|
|
|
8
8
|
import * as prReviewed from "./commands/pr-reviewed.js";
|
|
9
9
|
import * as prShip from "./commands/pr-ship.js";
|
|
10
10
|
import * as primitives from "./commands/primitives.js";
|
|
11
|
+
import * as recap from "./commands/recap.js";
|
|
11
12
|
import * as referencesSetup from "./commands/references-setup.js";
|
|
12
13
|
import * as referencesSync from "./commands/references-sync.js";
|
|
13
14
|
import * as setup from "./commands/setup.js";
|
|
@@ -21,7 +22,8 @@ const COMMANDS = new Map([
|
|
|
21
22
|
["references sync", referencesSync],
|
|
22
23
|
["references setup", referencesSetup],
|
|
23
24
|
["pr ship", prShip],
|
|
24
|
-
["pr reviewed", prReviewed]
|
|
25
|
+
["pr reviewed", prReviewed],
|
|
26
|
+
["recap", recap]
|
|
25
27
|
]);
|
|
26
28
|
function usage() {
|
|
27
29
|
const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -16,6 +16,9 @@ export function claudeConfigDir(env, home) {
|
|
|
16
16
|
export const MARKETPLACE = "dw-sdlc";
|
|
17
17
|
export const PLUGIN = "sdlc";
|
|
18
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}`;
|
|
19
22
|
export const PACKAGE = "dw-sdlc";
|
|
20
23
|
/** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
|
|
21
24
|
export const RTK_FIND_EXEC_FIXED = "0.46.0";
|
|
@@ -55,8 +58,6 @@ function checkJq(probe) {
|
|
|
55
58
|
}
|
|
56
59
|
return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
|
|
57
60
|
}
|
|
58
|
-
/** Where the plugin's hooks run: live from the checkout for a marketplace added from a directory, a
|
|
59
|
-
* cached snapshot for one added from git. A marker placed in only one of the two settled it. */
|
|
60
61
|
/** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
|
|
61
62
|
function liveCheckout(probe, claude) {
|
|
62
63
|
const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
|
|
@@ -71,27 +72,27 @@ function pluginOrigin(probe, claude, installPath) {
|
|
|
71
72
|
}
|
|
72
73
|
return `installed at ${installPath}; a change reaches it through claude plugin update`;
|
|
73
74
|
}
|
|
74
|
-
/**
|
|
75
|
-
function
|
|
76
|
-
const checkout = liveCheckout(probe, claude);
|
|
77
|
-
if (checkout !== undefined) {
|
|
78
|
-
return join(checkout, "plugins", PLUGIN);
|
|
79
|
-
}
|
|
75
|
+
/** What `claude plugin list --json` prints, one object a plugin, or undefined when it does not answer. */
|
|
76
|
+
function listedPlugins(probe) {
|
|
80
77
|
const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
|
|
81
78
|
const list = status === 0 ? parseJson(stdout) : undefined;
|
|
82
|
-
|
|
83
|
-
|
|
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);
|
|
84
86
|
}
|
|
85
|
-
const entry =
|
|
87
|
+
const entry = listedPlugins(probe)?.find((item) => item.id === `${plugin}@${MARKETPLACE}`);
|
|
86
88
|
return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
|
|
87
89
|
}
|
|
88
90
|
function checkPlugin(probe, claude) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
if (!Array.isArray(list)) {
|
|
91
|
+
const list = listedPlugins(probe);
|
|
92
|
+
if (list === undefined) {
|
|
92
93
|
return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
|
|
93
94
|
}
|
|
94
|
-
const entry = list.find((item) =>
|
|
95
|
+
const entry = list.find((item) => item.id === PLUGIN_ID);
|
|
95
96
|
if (entry === undefined) {
|
|
96
97
|
return {
|
|
97
98
|
name: "plugin",
|
|
@@ -165,6 +166,43 @@ function checkPackage(probe, claude, root) {
|
|
|
165
166
|
}
|
|
166
167
|
return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
|
|
167
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
|
+
}
|
|
168
206
|
/** Every hook command string in a settings file, whatever the event and matcher. */
|
|
169
207
|
function hookCommands(settings) {
|
|
170
208
|
if (!isObject(settings) || !isObject(settings.hooks)) {
|
|
@@ -342,6 +380,7 @@ export function diagnose(inputs) {
|
|
|
342
380
|
checkJq(probe),
|
|
343
381
|
checkPlugin(probe, claude),
|
|
344
382
|
checkPackage(probe, claude, inputs.config === undefined ? undefined : inputs.root),
|
|
383
|
+
checkSessions(probe, claude),
|
|
345
384
|
checkRtk(probe, home, claude),
|
|
346
385
|
checkSafeChain(probe),
|
|
347
386
|
checkPnpmReleaseAge(probe, home),
|
|
@@ -54,7 +54,7 @@ function parseCli(argv) {
|
|
|
54
54
|
}
|
|
55
55
|
/** Posts the review status on `head` once the read shows it is still the pull request's head. */
|
|
56
56
|
function post({ number, repo, head }) {
|
|
57
|
-
const pr = read({ number, repo }, "pr reviewed");
|
|
57
|
+
const pr = read({ number, repo }, "pr reviewed", false);
|
|
58
58
|
if (pr.head !== head) {
|
|
59
59
|
throw new CannotRun(`${pr.repo}#${number}: the head is ${pr.head}, not ${head}: a push landed since the review`, "review the new head, then call again with its sha");
|
|
60
60
|
}
|
package/dist/commands/pr-ship.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// needs next, performs it when it is the script's and reads again, then answers the first Action that
|
|
3
3
|
// is not, as a line or one JSON document, with an exit code that names whose turn it is.
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { repositoryRoot, resolveConfig,
|
|
6
|
-
import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
|
|
7
|
-
import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
|
|
5
|
+
import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
|
|
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";
|
|
8
8
|
export const summary = "decide the Action one pull request needs next, as a line or JSON";
|
|
9
|
-
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|off] [--dry-run] [--json] [--verbose]
|
|
9
|
+
const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
|
|
10
10
|
|
|
11
11
|
Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
|
|
12
12
|
is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
|
|
@@ -14,16 +14,38 @@ The script performs its own Actions, each at most once and then reads again: it
|
|
|
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
16
|
under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
|
|
17
|
-
gets the Review depth --review names for this call, else pr.review: under full
|
|
18
|
-
|
|
19
|
-
script posts the success itself, its description saying
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
gets the Review depth --review names for this call, else pr.review: under full the agent reviews it
|
|
18
|
+
with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
|
|
19
|
+
subagent reads the diff; under off the script posts the success itself, its description saying
|
|
20
|
+
why. Under auto, the default, the script routes the depth from the changes the Review would read:
|
|
21
|
+
lockfiles alone off; a file under .github/workflows/, more than 100 changed lines outside
|
|
22
|
+
lockfiles, or a change it cannot size, more files than GitHub's compare lists or a file outside
|
|
23
|
+
lockfiles with no patch, full; the rest light. After a push it names
|
|
24
|
+
the last reviewed commit under details.since, where the Review reads from and auto routes from. It
|
|
25
|
+
carries that commit's success over a head whose only commits since are merges of the base, unless
|
|
26
|
+
--review names a depth for the call.
|
|
27
|
+
Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
|
|
28
|
+
Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
|
|
29
|
+
false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
|
|
30
|
+
the head carries the base it read. When the read after an Action fails, the call exits 2 and names
|
|
31
|
+
each Action that ran with gh's exit, under performed too with --json: it may have landed, so do not
|
|
32
|
+
run it blind again. --dry-run performs nothing: it parks at the first Action the script would
|
|
33
|
+
perform, under performed with ok null.
|
|
34
|
+
One line, or one document with --json; --verbose adds the state as read to the document under
|
|
35
|
+
facts. Needs an authenticated gh.
|
|
23
36
|
|
|
24
37
|
Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
|
|
38
|
+
/** The depths --review takes, strictest first, as a sentence lists them. */
|
|
39
|
+
const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
|
|
25
40
|
const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
|
|
26
|
-
|
|
41
|
+
/** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
|
|
42
|
+
class Interrupted extends CannotRun {
|
|
43
|
+
performed;
|
|
44
|
+
constructor({ message, remedy }, performed) {
|
|
45
|
+
super(message, remedy);
|
|
46
|
+
this.performed = performed;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
27
49
|
function parseCli(argv) {
|
|
28
50
|
try {
|
|
29
51
|
const { values, positionals } = parseArgs({
|
|
@@ -41,7 +63,7 @@ function parseCli(argv) {
|
|
|
41
63
|
});
|
|
42
64
|
const [number, ...more] = positionals;
|
|
43
65
|
const { repo } = values;
|
|
44
|
-
const review =
|
|
66
|
+
const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
|
|
45
67
|
if (values.help !== true) {
|
|
46
68
|
if (more.length > 0) {
|
|
47
69
|
return { error: `name one pull request, not ${positionals.length}` };
|
|
@@ -51,7 +73,7 @@ function parseCli(argv) {
|
|
|
51
73
|
return { error: problem };
|
|
52
74
|
}
|
|
53
75
|
if (values.review !== undefined && review === undefined) {
|
|
54
|
-
return { error: `--review takes ${
|
|
76
|
+
return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
|
|
55
77
|
}
|
|
56
78
|
}
|
|
57
79
|
return {
|
|
@@ -69,18 +91,6 @@ function parseCli(argv) {
|
|
|
69
91
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
|
-
/** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
|
|
73
|
-
function now() {
|
|
74
|
-
const given = process.env.DW_SDLC_NOW?.trim();
|
|
75
|
-
if (given === undefined || given === "") {
|
|
76
|
-
return Date.now();
|
|
77
|
-
}
|
|
78
|
-
const time = Date.parse(given);
|
|
79
|
-
if (Number.isNaN(time)) {
|
|
80
|
-
throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
|
|
81
|
-
}
|
|
82
|
-
return time;
|
|
83
|
-
}
|
|
84
94
|
/** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
|
|
85
95
|
const NAMED = {
|
|
86
96
|
"update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
|
|
@@ -92,6 +102,33 @@ const NAMED = {
|
|
|
92
102
|
},
|
|
93
103
|
merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
|
|
94
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* Whether the read after a script Action shows it took effect. gh's exit status cannot tell: a reply
|
|
107
|
+
* lost on the way back exits non-zero after GitHub applied the Action.
|
|
108
|
+
*/
|
|
109
|
+
const TOOK = {
|
|
110
|
+
// A moved head alone may be a push: the update took effect when the head carries the base it read.
|
|
111
|
+
"update-branch": (before, after) => after.head !== before.head && carriesBase(before, after),
|
|
112
|
+
"mark-ready": (_, after) => !after.draft,
|
|
113
|
+
// The post names the head it read: a head moved since carries none of it.
|
|
114
|
+
"mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
|
|
115
|
+
merge: (_, after) => after.state === "MERGED"
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Why an Action that ran comes round again. A head that moved after a mark-reviewed names both heads;
|
|
119
|
+
* otherwise gh refused it, or accepted it and the read shows nothing yet, or the effect is gone again.
|
|
120
|
+
*/
|
|
121
|
+
function reasonOf({ action, ok, exit, stderr = "" }, pr, ranOn, { act, still }) {
|
|
122
|
+
if (action === "mark-reviewed" && exit === 0 && ranOn !== undefined && ranOn !== pr.head) {
|
|
123
|
+
return `${act} posted on ${ranOn.slice(0, 7)}, and the head moved to ${pr.head.slice(0, 7)}`;
|
|
124
|
+
}
|
|
125
|
+
if (ok) {
|
|
126
|
+
return `${act} ran, and ${still}`;
|
|
127
|
+
}
|
|
128
|
+
return exit === 0
|
|
129
|
+
? `gh accepted ${act}; the read does not show it yet`
|
|
130
|
+
: `gh refused ${act}: ${said(stderr) || `gh exited ${exit}`}`;
|
|
131
|
+
}
|
|
95
132
|
/** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
|
|
96
133
|
function handedOver(pr, policy, answer, action, reason) {
|
|
97
134
|
if (action === "merge") {
|
|
@@ -104,9 +141,12 @@ function handedOver(pr, policy, answer, action, reason) {
|
|
|
104
141
|
* pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
|
|
105
142
|
*/
|
|
106
143
|
function ship(cli, policy) {
|
|
107
|
-
|
|
144
|
+
// Only pr.review auto routes the depth from the changes, so only it reads them.
|
|
145
|
+
const sized = policy.review.depth === "auto";
|
|
146
|
+
let pr = read(cli, "pr ship", sized);
|
|
108
147
|
const performed = [];
|
|
109
|
-
|
|
148
|
+
// The head each Action ran on.
|
|
149
|
+
const postedOn = new Map();
|
|
110
150
|
for (;;) {
|
|
111
151
|
const answer = decide(pr, now(), policy);
|
|
112
152
|
const { action } = answer;
|
|
@@ -116,7 +156,7 @@ function ship(cli, policy) {
|
|
|
116
156
|
// -R names the repository, so the checkout the script runs in plays no part.
|
|
117
157
|
const args = hostArgs(pr, action, policy, true);
|
|
118
158
|
const command = shellLine(["gh", ...args]);
|
|
119
|
-
const { would
|
|
159
|
+
const { would } = NAMED[action];
|
|
120
160
|
if (cli.dryRun) {
|
|
121
161
|
performed.push({ action, command, ok: null });
|
|
122
162
|
return {
|
|
@@ -125,25 +165,30 @@ function ship(cli, policy) {
|
|
|
125
165
|
performed
|
|
126
166
|
};
|
|
127
167
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
168
|
+
const done = performed.find((earlier) => earlier.action === action);
|
|
169
|
+
// --dry-run returns before any Action runs, so an earlier one always carries gh's exit.
|
|
170
|
+
if (done !== undefined && done.ok !== null) {
|
|
171
|
+
const reason = reasonOf(done, pr, postedOn.get(action), NAMED[action]);
|
|
131
172
|
return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
|
|
132
173
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
const { status, stderr } = gh(args);
|
|
175
|
+
const before = pr;
|
|
176
|
+
const message = stderr.trim();
|
|
177
|
+
const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
|
|
178
|
+
postedOn.set(action, before.head);
|
|
179
|
+
try {
|
|
180
|
+
pr = readAgain(pr, sized);
|
|
181
|
+
performed.push({ ...record, ok: TOOK[action](before, pr) });
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof CannotRun) {
|
|
185
|
+
// The Action may have landed: the caller must not run it blind again.
|
|
186
|
+
throw new Interrupted(error, [...performed, { ...record, ok: null }]);
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
137
189
|
}
|
|
138
|
-
pr = readAgain(pr);
|
|
139
190
|
}
|
|
140
191
|
}
|
|
141
|
-
/** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
|
|
142
|
-
function lineOf({ status, action, reason, command, recheckAfter }) {
|
|
143
|
-
const label = status === action ? status : `${status} ${action}`;
|
|
144
|
-
const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
|
|
145
|
-
return `${label}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
|
|
146
|
-
}
|
|
147
192
|
function documentOf(pr, answer, performed, verbose) {
|
|
148
193
|
const facts = verbose ? { facts: pr } : {};
|
|
149
194
|
return { pr: pr.url, head: pr.head, base: pr.base, ...answer, performed, ...facts };
|
|
@@ -161,9 +206,9 @@ export function run(argv) {
|
|
|
161
206
|
// An invalid config layer resolves to ask and full, like every other strictest setting.
|
|
162
207
|
const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
|
|
163
208
|
const policy = {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
209
|
+
...declaredPolicy(declared),
|
|
210
|
+
...(cli.merge ? { merge: "auto" } : {}),
|
|
211
|
+
...(cli.review === undefined ? {} : { review: { depth: cli.review, source: "flag" } })
|
|
167
212
|
};
|
|
168
213
|
let shipped;
|
|
169
214
|
try {
|
|
@@ -171,7 +216,12 @@ export function run(argv) {
|
|
|
171
216
|
}
|
|
172
217
|
catch (error) {
|
|
173
218
|
if (error instanceof CannotRun) {
|
|
174
|
-
|
|
219
|
+
const ran = error instanceof Interrupted ? error.performed : [];
|
|
220
|
+
const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
|
|
221
|
+
process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
|
|
222
|
+
if (cli.json && ran.length > 0) {
|
|
223
|
+
process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
|
|
224
|
+
}
|
|
175
225
|
return 2;
|
|
176
226
|
}
|
|
177
227
|
throw error;
|