dw-sdlc 0.10.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 CHANGED
@@ -86,6 +86,7 @@ script posts that status itself, its description saying why, and goes on.
86
86
  | `references setup` | the Router, its table and the ignore rules a repository needs to declare References |
87
87
  | `pr ship [<n>]` | the Action one pull request needs next, performing the script's own |
88
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 |
89
90
 
90
91
  `dw-sdlc <command> --help` gives each one's options.
91
92
 
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;
@@ -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
- /** The directory whose hooks run on this machine: the checkout of a directory marketplace, else the installed snapshot. */
75
- function runningPluginRoot(probe, claude) {
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
- if (!Array.isArray(list)) {
83
- return 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);
84
86
  }
85
- const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
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 { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
90
- const list = status === 0 ? parseJson(stdout) : undefined;
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) => isObject(item) && item.id === PLUGIN_ID);
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),
@@ -3,8 +3,8 @@
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
5
  import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
6
- import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
7
- import { CannotRun, carriesBase, gh, namedProblem, read, readAgain, said } from "../pr/read.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
9
  const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|light|off] [--dry-run] [--json] [--verbose]
10
10
 
@@ -38,7 +38,6 @@ Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot r
38
38
  /** The depths --review takes, strictest first, as a sentence lists them. */
39
39
  const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
40
40
  const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
41
- const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
42
41
  /** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
43
42
  class Interrupted extends CannotRun {
44
43
  performed;
@@ -92,18 +91,6 @@ function parseCli(argv) {
92
91
  return { error: error instanceof Error ? error.message : String(error) };
93
92
  }
94
93
  }
95
- /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
96
- function now() {
97
- const given = process.env.DW_SDLC_NOW?.trim();
98
- if (given === undefined || given === "") {
99
- return Date.now();
100
- }
101
- const time = Date.parse(given);
102
- if (Number.isNaN(time)) {
103
- throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
104
- }
105
- return time;
106
- }
107
94
  /** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
108
95
  const NAMED = {
109
96
  "update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
@@ -202,12 +189,6 @@ function ship(cli, policy) {
202
189
  }
203
190
  }
204
191
  }
205
- /** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
206
- function lineOf({ status, action, reason, command, recheckAfter }) {
207
- const label = status === action ? status : `${status} ${action}`;
208
- const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
209
- return `${label}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
210
- }
211
192
  function documentOf(pr, answer, performed, verbose) {
212
193
  const facts = verbose ? { facts: pr } : {};
213
194
  return { pr: pr.url, head: pr.head, base: pr.base, ...answer, performed, ...facts };
@@ -225,9 +206,9 @@ export function run(argv) {
225
206
  // An invalid config layer resolves to ask and full, like every other strictest setting.
226
207
  const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
227
208
  const policy = {
228
- merge: cli.merge ? "auto" : declared.merge,
229
- review: cli.review === undefined ? { depth: declared.review, source: "config" } : { depth: cli.review, source: "flag" },
230
- reviewCommand: declared.reviewCommand
209
+ ...declaredPolicy(declared),
210
+ ...(cli.merge ? { merge: "auto" } : {}),
211
+ ...(cli.review === undefined ? {} : { review: { depth: cli.review, source: "flag" } })
231
212
  };
232
213
  let shipped;
233
214
  try {
@@ -0,0 +1,2 @@
1
+ export declare const summary = "the facts a Recap rests on and whether the session can be closed";
2
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,310 @@
1
+ // `dw-sdlc recap`: the facts a Recap rests on and whether the session can be closed, read from git and
2
+ // gh. It writes nothing: the Action of the branch's pull request is the Ship script's decision, read.
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
7
+ import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
8
+ import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
9
+ import { git } from "../working-tree.js";
10
+ export const summary = "the facts a Recap rests on and whether the session can be closed";
11
+ const USAGE = `Usage: dw-sdlc recap [--json]
12
+
13
+ Reads the current branch, its uncommitted changes, the commits that reach no remote, its pull request
14
+ and whether the session sits in a linked worktree, and answers whether the session can be closed, with
15
+ every reason. It writes nothing: the pull request's Action is the one dw-sdlc pr ship would answer, read
16
+ and never performed.
17
+
18
+ The session cannot be closed over any of these:
19
+ a paused rebase, merge, cherry-pick, revert or bisect;
20
+ an uncommitted change;
21
+ a commit pushed nowhere or only part of the way;
22
+ an open pull request, whatever its Action;
23
+ 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
+
26
+ A pull request closed without merging merges nothing, and a detached HEAD names no pull request. There a
27
+ commit counts as pushed only on a remote branch, and the command asks the remote which branches still
28
+ stand. A branch force-pushed since the last fetch can still read as holding it, so fetch first to be exact.
29
+
30
+ A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
31
+ directory it runs in.
32
+
33
+ One line a fact, or one document with --json. Needs an authenticated gh and a remote it can read. A
34
+ detached HEAD or a repository with no remote asks gh nothing, and the pull request fact says so.
35
+
36
+ Exit 0 the session can be closed, 1 it cannot, 2 when it cannot run.`;
37
+ function plural(howMany, word) {
38
+ return `${howMany} ${word}${howMany === 1 ? "" : "s"}`;
39
+ }
40
+ /** `git <args>` at `root`; what it prints, or a stop naming its problem. */
41
+ function must(root, args) {
42
+ const result = git(root, args);
43
+ if ("error" in result) {
44
+ throw new CannotRun(result.error, "check the repository with git status");
45
+ }
46
+ return result.out;
47
+ }
48
+ /** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
49
+ function asked(root, args) {
50
+ const result = git(root, args);
51
+ return "out" in result ? result.out : undefined;
52
+ }
53
+ function count(root, args) {
54
+ return Number(must(root, ["rev-list", "--count", ...args]));
55
+ }
56
+ function remotesOf(root) {
57
+ return must(root, ["remote"])
58
+ .split("\n")
59
+ .filter((remote) => remote !== "");
60
+ }
61
+ /** The remote-tracking upstream of `branch`, as `--remotes` names it, unless git reports its ref gone. */
62
+ function upstreamOf(root, branch) {
63
+ const prefix = "refs/remotes/";
64
+ const format = "%(upstream) %(upstream:track)";
65
+ const [ref = "", track] = must(root, ["for-each-ref", `--format=${format}`, `refs/heads/${branch}`]).split(" ");
66
+ return ref.startsWith(prefix) && track !== "[gone]" ? ref.slice(prefix.length) : null;
67
+ }
68
+ /** Git's own record of each paused operation, in the git directory a linked worktree keeps apart. */
69
+ const PAUSES = [
70
+ { record: "rebase-merge", name: "rebase" },
71
+ { record: "rebase-apply", name: "rebase or am" },
72
+ { record: "MERGE_HEAD", name: "merge" },
73
+ { record: "CHERRY_PICK_HEAD", name: "cherry-pick" },
74
+ { record: "REVERT_HEAD", name: "revert" },
75
+ { record: "sequencer", name: "cherry-pick or revert" },
76
+ { record: "BISECT_LOG", name: "bisect" }
77
+ ];
78
+ function pausedIn(root) {
79
+ const gitDir = must(root, ["rev-parse", "--absolute-git-dir"]);
80
+ return PAUSES.find(({ record }) => existsSync(join(gitDir, record)))?.name ?? null;
81
+ }
82
+ function readLocal(root) {
83
+ const branch = asked(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) ?? null;
84
+ const head = asked(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) ?? null;
85
+ // Without the flag, git status refreshes the index, a write under a session working in this checkout.
86
+ const status = must(root, ["--no-optional-locks", "status", "--porcelain", "--untracked-files=normal"]);
87
+ const upstream = branch === null ? null : upstreamOf(root, branch);
88
+ const pushed = upstream === null ? ["HEAD", "--not", "--remotes"] : ["@{upstream}..HEAD"];
89
+ return {
90
+ branch,
91
+ head,
92
+ uncommitted: status === "" ? 0 : status.split("\n").length,
93
+ upstream,
94
+ unpushed: head === null ? 0 : count(root, pushed),
95
+ paused: pausedIn(root),
96
+ worktree: { linked: mainCheckout(root) !== undefined, path: root }
97
+ };
98
+ }
99
+ /** The current branch's pull request and the Action the Ship script's decision gives it, or null. */
100
+ function readShipped(root) {
101
+ // An invalid config layer resolves to ask and full, as in the Ship script.
102
+ const policy = declaredPolicy(resolveConfig(root).config.pr);
103
+ const pr = readBranch(policy.review.depth === "auto");
104
+ return pr === null ? null : { pr, answer: decide(pr, now(), policy) };
105
+ }
106
+ /**
107
+ * 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. Any other branch that
109
+ * moved, such as the base, counts as holding what it held; plugins/sdlc-sessions/README.md says why.
110
+ */
111
+ function staleTracking(root, { branch, upstream }) {
112
+ return remotesOf(root).flatMap((remote) => {
113
+ const live = new Map(must(root, ["ls-remote", "--heads", remote])
114
+ .split("\n")
115
+ .map((line) => {
116
+ const [oid = "", ref = ""] = line.split("\t");
117
+ return [ref.replace("refs/heads/", `${remote}/`), oid];
118
+ }));
119
+ return must(root, ["for-each-ref", "--format=%(refname:lstrip=2) %(objectname)", `refs/remotes/${remote}`])
120
+ .split("\n")
121
+ .map((line) => line.split(" "))
122
+ .filter(([tracking = "", oid]) => {
123
+ const own = tracking === upstream || tracking === `${remote}/${branch}`;
124
+ return tracking !== "" && (!live.has(tracking) || (own && live.get(tracking) !== oid));
125
+ })
126
+ .map(([tracking = ""]) => tracking);
127
+ });
128
+ }
129
+ /**
130
+ * Where the head stands once the pull request is read. A commit an open or merged one holds is on GitHub,
131
+ * whatever became of the upstream. A closed one merges nothing and a detached HEAD names none, so there only
132
+ * a remote branch that still stands counts for the head. A paused operation has answered already.
133
+ */
134
+ function standing(root, read, shipped, held) {
135
+ const asksRemote = read.paused === null && (read.branch === null || shipped?.pr.state === "CLOSED");
136
+ if (!asksRemote || read.head === null) {
137
+ return held ? { ...read, unpushed: 0 } : read;
138
+ }
139
+ const stale = staleTracking(root, read);
140
+ if (read.upstream !== null && !stale.includes(read.upstream)) {
141
+ return read;
142
+ }
143
+ const unpushed = count(root, ["HEAD", ...notOnRemotes(stale)]);
144
+ return { ...read, upstream: null, unpushed };
145
+ }
146
+ /** `rev-list` arguments that leave out what a remote branch holds, by the tracking refs still true. */
147
+ function notOnRemotes(stale) {
148
+ return ["--not", ...stale.map((tracking) => `--exclude=${tracking}`), "--remotes"];
149
+ }
150
+ /**
151
+ * The commits of a closed pull request that left the branch, as by a reset, and that no remote branch holds.
152
+ * A head this checkout never had was never this session's to lose.
153
+ */
154
+ function orphanedOf(root, read, shipped, held) {
155
+ if (shipped?.pr.state !== "CLOSED" || held || read.paused !== null) {
156
+ return 0;
157
+ }
158
+ return Number(asked(root, ["rev-list", "--count", shipped.pr.head, ...notOnRemotes(staleTracking(root, read))]) ?? 0);
159
+ }
160
+ /**
161
+ * The commits on top of the base that no pull request holds. A branch the config protects takes changes
162
+ * through pull requests rather than making one, and so does the default branch: neither needs one.
163
+ */
164
+ function aheadOf(root, branch, shipped) {
165
+ if (resolveConfig(root).config.guardrails.protectedBranches.includes(branch)) {
166
+ return null;
167
+ }
168
+ const base = shipped?.pr.base ?? defaultBranch();
169
+ if (branch === base) {
170
+ return null;
171
+ }
172
+ const refs = remotesOf(root)
173
+ .map((remote) => `refs/remotes/${remote}/${base}`)
174
+ .filter((ref) => asked(root, ["rev-parse", "--verify", "--quiet", ref]) !== undefined);
175
+ return { base, commits: count(root, ["HEAD", "--not", ...refs]) };
176
+ }
177
+ function unpushedReason({ branch, upstream, unpushed }) {
178
+ const commits = plural(unpushed, "commit");
179
+ if (upstream !== null) {
180
+ return { id: "unpushed", blocks: true, text: `${commits} not pushed to ${upstream}` };
181
+ }
182
+ const where = branch === null ? "on a detached HEAD, on no remote" : "on a branch with no upstream";
183
+ return { id: "no-upstream", blocks: true, text: `${commits} ${where}` };
184
+ }
185
+ function pullRequestReason({ pr, answer }) {
186
+ const name = `pull request #${pr.number}`;
187
+ if (pr.state === "OPEN") {
188
+ return { id: "open-pull-request", blocks: true, text: `${name} is open: ${labelOf(answer)}: ${answer.reason}` };
189
+ }
190
+ return { id: pr.state === "MERGED" ? "merged" : "closed", blocks: false, text: `${name} ${answer.reason}` };
191
+ }
192
+ /** The reason when none blocks; `askedGh` false says what the verdict never read, so it claims no more. */
193
+ function cleanReason(askedGh) {
194
+ const text = askedGh
195
+ ? "nothing is uncommitted, unpushed or open"
196
+ : "nothing is uncommitted or unpushed; gh was not asked";
197
+ return { id: "clean", blocks: false, text };
198
+ }
199
+ function reasonsOf(local, shipped, ahead, orphaned, askedGh) {
200
+ const all = [];
201
+ if (local.paused !== null) {
202
+ all.push({ id: "paused", blocks: true, text: `a ${local.paused} is paused` });
203
+ }
204
+ if (local.uncommitted > 0) {
205
+ all.push({ id: "uncommitted", blocks: true, text: plural(local.uncommitted, "uncommitted change") });
206
+ }
207
+ if (local.unpushed > 0) {
208
+ all.push(unpushedReason(local));
209
+ }
210
+ if (ahead !== null && ahead.commits > 0) {
211
+ const text = `${plural(ahead.commits, "commit")} on top of ${ahead.base} in no pull request`;
212
+ all.push({ id: "no-pull-request", blocks: true, text });
213
+ }
214
+ if (shipped !== null && orphaned > 0) {
215
+ const text = `${plural(orphaned, "commit")} of pull request #${shipped.pr.number} on no remote branch`;
216
+ all.push({ id: "orphaned", blocks: true, text });
217
+ }
218
+ if (shipped !== null) {
219
+ all.push(pullRequestReason(shipped));
220
+ }
221
+ if (!all.some((reason) => reason.blocks)) {
222
+ all.unshift(cleanReason(askedGh));
223
+ }
224
+ if (local.worktree.linked) {
225
+ all.push({ id: "worktree", blocks: false, text: `the worktree at ${local.worktree.path} is left to remove` });
226
+ }
227
+ return all;
228
+ }
229
+ /** The verdict and the line a Recap quotes: after "no" only what blocks, after "yes" every reason. */
230
+ function closeOf(reasons) {
231
+ const blocking = reasons.filter((reason) => reason.blocks);
232
+ const verdict = blocking.length > 0 ? "no" : "yes";
233
+ const shown = blocking.length > 0 ? blocking : reasons;
234
+ const line = `Close the session: ${verdict}, because ${shown.map((reason) => reason.text).join("; ")}`;
235
+ return { verdict, line, reasons };
236
+ }
237
+ function linesOf(local, shipped, askedGh, line) {
238
+ const none = askedGh ? "none" : "gh not asked";
239
+ return [
240
+ `branch: ${local.branch ?? "detached HEAD"}`,
241
+ `uncommitted: ${local.uncommitted}`,
242
+ `upstream: ${local.upstream ?? "none"}, ${local.unpushed} unpushed`,
243
+ `pull request: ${shipped === null ? none : `${shipped.pr.url} ${lineOf(shipped.answer)}`}`,
244
+ `worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
245
+ line
246
+ ].join("\n");
247
+ }
248
+ function parseCli(argv) {
249
+ try {
250
+ const { values, positionals } = parseArgs({
251
+ args: argv,
252
+ allowPositionals: true,
253
+ options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } }
254
+ });
255
+ if (positionals.length > 0 && values.help !== true) {
256
+ return { error: `it takes no argument, not ${JSON.stringify(positionals[0])}` };
257
+ }
258
+ return { help: values.help === true, json: values.json === true };
259
+ }
260
+ catch (error) {
261
+ return { error: error instanceof Error ? error.message : String(error) };
262
+ }
263
+ }
264
+ function report(json) {
265
+ const root = findRepository(process.cwd());
266
+ if (root === undefined) {
267
+ throw new CannotRun(`no git repository holds ${process.cwd()}`, "run it from a checkout");
268
+ }
269
+ const read = readLocal(root);
270
+ const { branch, head } = read;
271
+ // A detached HEAD names no branch for gh to look up, and a repository with no remote has no GitHub.
272
+ const asksGh = branch !== null && remotesOf(root).length > 0;
273
+ const shipped = asksGh ? readShipped(root) : null;
274
+ const held = shipped !== null && head !== null && shipped.pr.commits.includes(head);
275
+ const local = standing(root, read, shipped, held);
276
+ // An open pull request can still take the head, and a commit-less repository has nothing to merge.
277
+ const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
278
+ const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
279
+ const orphaned = orphanedOf(root, read, shipped, held);
280
+ const close = closeOf(reasonsOf(local, shipped, ahead, orphaned, asksGh));
281
+ const pullRequest = shipped === null
282
+ ? null
283
+ : { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
284
+ const output = json
285
+ ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, close }, null, 2)
286
+ : linesOf(local, shipped, asksGh, close.line);
287
+ process.stdout.write(`${output}\n`);
288
+ return close.verdict === "yes" ? 0 : 1;
289
+ }
290
+ export function run(argv) {
291
+ const cli = parseCli(argv);
292
+ if ("error" in cli) {
293
+ process.stderr.write(`dw-sdlc recap: ${cli.error}\n\n${USAGE}\n`);
294
+ return 2;
295
+ }
296
+ if (cli.help) {
297
+ process.stdout.write(`${USAGE}\n`);
298
+ return 0;
299
+ }
300
+ try {
301
+ return report(cli.json);
302
+ }
303
+ catch (error) {
304
+ if (error instanceof CannotRun) {
305
+ process.stderr.write(`dw-sdlc recap: ${error.message} — ${error.remedy}\n`);
306
+ return 2;
307
+ }
308
+ throw error;
309
+ }
310
+ }
@@ -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. */
@@ -279,6 +283,16 @@ const ROWS = [
279
283
  merging: true
280
284
  }
281
285
  ];
286
+ /** `<status> <action>`, the action dropped when it repeats the status. */
287
+ export function labelOf({ status, action }) {
288
+ return status === action ? status : `${status} ${action}`;
289
+ }
290
+ /** The label and the reason, then the recheck and the command. */
291
+ export function lineOf(answer) {
292
+ const { reason, command, recheckAfter } = answer;
293
+ const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
294
+ return `${labelOf(answer)}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
295
+ }
282
296
  function blockerOf({ action, actor, reason, details }) {
283
297
  return { action, actor, reason, ...(details === undefined ? {} : { details }) };
284
298
  }
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,6 +23,12 @@ 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. */
package/dist/pr/read.js CHANGED
@@ -1,10 +1,10 @@
1
- // The read both pr commands make: gh as a child process, its auth on the host gh api reads from, the
2
- // pull request a command names, and that pull request's state from one GraphQL request, with the two
3
- // compares that tell whether its head carries an earlier Review and the one that sizes its changes.
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,19 +77,54 @@ 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);
@@ -46,6 +46,8 @@ export type PrState = {
46
46
  checks: CiCheck[];
47
47
  /** The threads neither resolved nor outdated. */
48
48
  unresolvedThreads: Thread[];
49
+ /** The oids of the pull request's last 100 commits, oldest first, the head last. */
50
+ commits: string[];
49
51
  /** The last commit before the head with a review success not posted under off, of the last 100. */
50
52
  lastReviewed: LastReviewed | null;
51
53
  /** Whether the head carries the success of `lastReviewed`: the read decides it from two compares. */
@@ -95,6 +97,8 @@ export declare function parseView(reply: string): {
95
97
  repo: string;
96
98
  number: number;
97
99
  };
100
+ /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
101
+ export declare function parseDefaultBranch(reply: string): string;
98
102
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
99
103
  export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
100
104
  export {};
package/dist/pr/state.js CHANGED
@@ -221,6 +221,10 @@ export function parseView(reply) {
221
221
  }
222
222
  return { repo, number: Number(number) };
223
223
  }
224
+ /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
225
+ export function parseDefaultBranch(reply) {
226
+ return string(child(rootOf(reply, "the reply of gh repo view"), "defaultBranchRef"), "name");
227
+ }
224
228
  /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
225
229
  export function parsePr(reply, repo) {
226
230
  const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
@@ -248,6 +252,7 @@ export function parsePr(reply, repo) {
248
252
  unresolvedThreads: nodes(pr, "reviewThreads")
249
253
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
250
254
  .map(threadOf),
255
+ commits: nodes(pr, "history").map((node) => string(child(node, "commit"), "oid")),
251
256
  lastReviewed: lastReviewedOf(pr, head)
252
257
  };
253
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {