dw-sdlc 0.13.0 → 0.15.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.
@@ -1,9 +1,9 @@
1
1
  // Reads what a repository declares for an agent: the committed config, the machine's local layer over it,
2
2
  // and what the repository implies; an invalid layer is dropped whole. The sdlc plugin's
3
3
  // `hooks/config.jq` is the same merge for the guards; this file is the reference, and the tests run both.
4
- import { spawnSync } from "node:child_process";
5
4
  import { existsSync, readFileSync } from "node:fs";
6
5
  import { dirname, join } from "node:path";
6
+ import { exec } from "./exec.js";
7
7
  import { validate } from "./json-schema.js";
8
8
  export const COMMITTED_FILE = ".ai/agentic.config.json";
9
9
  export const LOCAL_FILE = ".ai/agentic.config.local.json";
@@ -143,7 +143,7 @@ function readLayer(checkouts, file) {
143
143
  }
144
144
  /** The top level of the repository holding `dir`, or undefined outside one. */
145
145
  export function findRepository(dir) {
146
- const toplevel = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
146
+ const toplevel = exec("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
147
147
  return toplevel.status === 0 ? toplevel.stdout.trim() : undefined;
148
148
  }
149
149
  /** The top level of the repository holding `dir`, or `dir` itself outside one. */
@@ -156,7 +156,7 @@ export function repositoryRoot(dir) {
156
156
  */
157
157
  export function mainCheckout(root) {
158
158
  const args = ["-C", root, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"];
159
- const result = spawnSync("git", args, { encoding: "utf8" });
159
+ const result = exec("git", args);
160
160
  if (result.status !== 0) {
161
161
  return undefined;
162
162
  }
@@ -1,8 +1,8 @@
1
1
  // `file-size-ratchet`: each file of code within the line limit of its group or, listed in the baseline,
2
2
  // within the count recorded there. A refresh only tightens the baseline: it lowers and drops, and adds no file.
3
- import { spawnSync } from "node:child_process";
4
3
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
4
  import { dirname, join, matchesGlob } from "node:path";
5
+ import { exec } from "../exec.js";
6
6
  import { CannotRun } from "./check.js";
7
7
  import { matching, treeFiles } from "./tree.js";
8
8
  const BASELINE = ".ai/file-size-ratchet.baseline.json";
@@ -58,7 +58,7 @@ function keyLines(text, path) {
58
58
  function readBaseline(root) {
59
59
  const path = join(root, BASELINE);
60
60
  if (!existsSync(path)) {
61
- const committed = spawnSync("git", ["-C", root, "cat-file", "-e", `HEAD:${BASELINE}`]).status === 0;
61
+ const committed = exec("git", ["-C", root, "cat-file", "-e", `HEAD:${BASELINE}`]).status === 0;
62
62
  return { state: "missing", committed };
63
63
  }
64
64
  const text = readFileSync(path, "utf8");
@@ -1,13 +1,13 @@
1
1
  // The files of a repository as git sees them, the directories above them, and the ones a Check's globs
2
2
  // pick out of them: what the Checks that judge many files read, so a file git ignores is never judged.
3
- import { spawnSync } from "node:child_process";
4
3
  import { existsSync, lstatSync, realpathSync } from "node:fs";
5
4
  import { join, matchesGlob, posix, relative } from "node:path";
5
+ import { exec } from "../exec.js";
6
6
  import { CannotRun } from "./check.js";
7
7
  /** Every file under `pathspecs`, the whole tree when there are none, that git tracks or would track and that is on disk; sorted. */
8
8
  export function treeFiles(root, pathspecs = []) {
9
9
  const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs];
10
- const listed = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
10
+ const listed = exec("git", args);
11
11
  if (listed.status !== 0) {
12
12
  const under = pathspecs.length === 0 ? "" : ` under ${pathspecs.join(", ")}`;
13
13
  const message = `cannot list the files${under}: not a git repository`;
package/dist/cli.js CHANGED
@@ -55,5 +55,12 @@ async function main(argv) {
55
55
  return command.run(argv.slice(words));
56
56
  }
57
57
  if (import.meta.main) {
58
- process.exitCode = await main(process.argv.slice(2));
58
+ try {
59
+ process.exitCode = await main(process.argv.slice(2));
60
+ }
61
+ catch (error) {
62
+ // What no command answered for stopped it before a verdict: exit 1 would read as one.
63
+ process.stderr.write(`dw-sdlc: ${error instanceof Error ? error.message : String(error)}\n`);
64
+ process.exitCode = 2;
65
+ }
59
66
  }
@@ -1,13 +1,13 @@
1
1
  // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the sdlc plugin relies on, for the
2
2
  // repository holding --root and the user's home; it installs nothing. FAIL is a guard that does not guard
3
3
  // (no jq, plugin absent or disabled, an invalid config), WARN a layer missing, loose or out of step, INFO one that does not apply.
4
- import { spawnSync } from "node:child_process";
5
4
  import { existsSync, readFileSync } from "node:fs";
6
5
  import { homedir } from "node:os";
7
6
  import { join } from "node:path";
8
7
  import { parseArgs } from "node:util";
9
8
  import { findRepository, resolveConfig } from "../agentic-config.js";
10
- import { checkPackage, checkPlugin, checkSessions, parseJson, runningPluginRoot, versionBefore } from "../doctor-plugins.js";
9
+ import { checkPackage, checkPlugin, checkSessions, parseJson, runningPluginRoot, sessionsEnabled, versionBefore } from "../doctor-plugins.js";
10
+ import { exec, TimedOut } from "../exec.js";
11
11
  import { isObject } from "../json-text.js";
12
12
  /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
13
13
  export function claudeConfigDir(env, home) {
@@ -16,18 +16,30 @@ export function claudeConfigDir(env, home) {
16
16
  /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
17
17
  export const RTK_FIND_EXEC_FIXED = "0.46.0";
18
18
  export const PNPM_MINIMUM_RELEASE_AGE = 10080;
19
+ /** A probe past its limit answers as a tool that could not run, so its check reports it and the rest still run. */
20
+ function probed(tool, args, cwd) {
21
+ try {
22
+ return exec(tool, args, cwd === undefined ? {} : { cwd });
23
+ }
24
+ catch (error) {
25
+ if (error instanceof TimedOut) {
26
+ return undefined;
27
+ }
28
+ throw error;
29
+ }
30
+ }
19
31
  export const realProbe = {
20
32
  run(command, args, cwd) {
21
- const result = spawnSync(command, args, { cwd, encoding: "utf8" });
22
- return { status: result.status ?? 127, stdout: result.stdout ?? "" };
33
+ const result = probed(command, args, cwd);
34
+ return { status: result?.status ?? 127, stdout: result?.stdout ?? "" };
23
35
  },
24
36
  read(path) {
25
37
  return existsSync(path) ? readFileSync(path, "utf8") : undefined;
26
38
  },
27
39
  shell(script) {
28
40
  const shell = process.env.SHELL ?? "/bin/sh";
29
- const result = spawnSync(shell, ["-l", "-c", script], { encoding: "utf8" });
30
- return (result.stdout ?? "").trim().split("\n")[0] ?? "";
41
+ const result = probed(shell, ["-l", "-c", script]);
42
+ return (result?.stdout ?? "").trim().split("\n")[0] ?? "";
31
43
  }
32
44
  };
33
45
  function checkJq(probe) {
@@ -121,11 +133,18 @@ function checkRtk(probe, home, claude) {
121
133
  detail: `rtk ${version} behind the plugin's rewrite hook; exclude_commands: ${excludes.join(", ") || "none"}`
122
134
  };
123
135
  }
124
- /** herdr's `hc` in the agent's shell, which a Lead starts its Workers through; a machine may go without it. */
136
+ /**
137
+ * herdr's `hc` in the agent's shell, which a Lead starts its Workers through. Its absence warns only where
138
+ * `sdlc-sessions` is enabled: a machine with only the guards has no reason to install herdr.
139
+ */
125
140
  function checkHc(probe) {
126
141
  const hc = probe.shell("command -v hc");
127
142
  if (hc === "") {
128
- return { name: "hc", level: "INFO", detail: "no hc on PATH, so no /sdlc-delegate: install herdr to run Workers" };
143
+ return {
144
+ name: "hc",
145
+ level: sessionsEnabled(probe) ? "WARN" : "INFO",
146
+ detail: "no hc on PATH, so no /sdlc-delegate: install herdr to run Workers"
147
+ };
129
148
  }
130
149
  return { name: "hc", level: "PASS", detail: `${hc}, so a Lead can start Workers` };
131
150
  }
@@ -1,10 +1,10 @@
1
1
  // `dw-sdlc references setup` sets a repository up to declare References: the files its USAGE names,
2
2
  // each written only when it would change. A list gains the entries it lacks at its end; the one removal
3
3
  // is a `references` key moving out of the agentic config. A file it cannot read as expected is refused.
4
- import { spawnSync } from "node:child_process";
5
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
6
5
  import { dirname, join } from "node:path";
7
6
  import { parseArgs } from "node:util";
7
+ import { exec } from "../exec.js";
8
8
  import { parseObject, removeKey, render } from "../json-text.js";
9
9
  import { CONFIG, REFERENCES_FILE, leftoverKey, leftoverProblem, parseReferences, readFile } from "../references-key.js";
10
10
  import { renderBlock, withBlock } from "../router-block.js";
@@ -159,7 +159,7 @@ function source(root) {
159
159
  }
160
160
  /** The repository `dir` sits in, or `dir` itself outside one. */
161
161
  function toplevel(dir) {
162
- const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
162
+ const result = exec("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
163
163
  return result.status === 0 ? result.stdout.trim() : dir;
164
164
  }
165
165
  function setup(root, dryRun) {
@@ -1,11 +1,11 @@
1
1
  // `dw-sdlc references sync`, the Sync: every Reference `.ai/references.json` declares brought to its ref
2
2
  // under references/<dir>, each clone shallow, blobless, detached and sparse where its entry says. An
3
3
  // entry that fails fails alone, and a repository that declares no References gets no output at all.
4
- import { spawnSync } from "node:child_process";
5
4
  import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
5
  import { extname, join } from "node:path";
7
6
  import { setTimeout as sleep } from "node:timers/promises";
8
7
  import { parseArgs } from "node:util";
8
+ import { exec, SLOW_LIMIT_MS } from "../exec.js";
9
9
  import { repositoryFreeEnv } from "../git-env.js";
10
10
  import { readPins, resolveRef } from "../pins.js";
11
11
  import { REFERENCES_FILE, readReferences } from "../references-key.js";
@@ -135,10 +135,9 @@ async function lock(root, seconds) {
135
135
  }
136
136
  }
137
137
  function git(args, cwd) {
138
- // Each call finds its repository from `cwd`. A missing credential fails the fetch instead of waiting
139
- // on a prompt nobody sees.
140
- const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
141
- const result = spawnSync("git", args, { cwd, encoding: "utf8", env });
138
+ // Each call finds its repository from `cwd`.
139
+ const env = repositoryFreeEnv();
140
+ const result = exec("git", args, { ...(cwd === undefined ? {} : { cwd }), env, limitMs: SLOW_LIMIT_MS });
142
141
  // git's error on one line, so each Reference stays one line of the report.
143
142
  const err = (result.stderr ?? "")
144
143
  .split("\n")
@@ -1,13 +1,13 @@
1
1
  // `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the files its USAGE names,
2
2
  // each written only when its content would change, so a run is safe to repeat. Existing content is
3
3
  // merged, never replaced: other keys stay, and a file that is not a JSON object is refused.
4
- import { spawnSync } from "node:child_process";
5
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
5
  import { homedir } from "node:os";
7
6
  import { dirname, join, relative } from "node:path";
8
7
  import { pathToFileURL } from "node:url";
9
8
  import { parseArgs } from "node:util";
10
9
  import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, resolveConfig, schemaId, validateDocument } from "../agentic-config.js";
10
+ import { exec } from "../exec.js";
11
11
  import { isObject, parseObject, render } from "../json-text.js";
12
12
  import { withGate, withPackage } from "../setup-gate.js";
13
13
  import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
@@ -54,7 +54,7 @@ const PACKAGE_LINK = "dw-sdlc";
54
54
  const RETIRED_PLUGINS = ["guardrails", "references"].map((name) => `${name}@${MARKETPLACE}`);
55
55
  /** A read-only git command, the one thing the setup runs in the repository. */
56
56
  function git(root, args) {
57
- const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
57
+ const result = exec("git", ["-C", root, ...args]);
58
58
  return result.status === 0 ? result.stdout.trim() : undefined;
59
59
  }
60
60
  /** The committed layer when it is valid; an absent or invalid one sets nothing, as the resolver drops it. */
@@ -1,14 +1,15 @@
1
1
  // `dw-sdlc sweep`: removes each linked worktree whose whole set passes and each Landed local branch.
2
2
  // docs/adr/0015 sets its bounds: it runs from the main checkout, never forces and never writes to a remote.
3
- import { spawnSync } from "node:child_process";
4
3
  import { existsSync } from "node:fs";
5
- import { join } from "node:path";
6
4
  import { parseArgs } from "node:util";
7
5
  import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
6
+ import { exec, SLOW_LIMIT_MS } from "../exec.js";
8
7
  import { repositoryFreeEnv } from "../git-env.js";
9
8
  import { isCommitLanded, isLanded } from "../landed.js";
10
9
  import { shellLine } from "../pr/decide.js";
11
10
  import { CannotRun } from "../pr/read.js";
11
+ import { byHand, holdersOf, keptIn, lockAfterStop, lockHolder, outsideWorktrees, readTerminals, removeTerminal, stopsOf, sweepingTerminal, whyHeld } from "../sweep-terminals.js";
12
+ import { dirtyFiles, must, pausedOperation, worktreesOf } from "../sweep-trees.js";
12
13
  import { asked, git } from "../working-tree.js";
13
14
  import { lockVerdict } from "../worktree-lock.js";
14
15
  export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
@@ -30,20 +31,24 @@ It keeps, and names with the reason and the command to run yourself:
30
31
  It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
31
32
  kept with git's words, and the sweep goes on.
32
33
 
33
- --dry-run removes nothing and answers the same items. One line an item, or one document with --json:
34
- { dryRun, base, removed: [item], kept: [item] }, an item { kind, name, reason, command? }, kind branch, worktree
35
- or lock (a lifted one), name its path, command the one the dev would run for a kept item.
34
+ In herdr ($HC_TERMINAL_ID set) it reads, with hc, the Workers of this session, the terminals whose parent is
35
+ this one, and every other live terminal. A Worker whose status is none of working, blocked or yet to report
36
+ is stopped (hc kill) then removed (hc rm) when its worktree goes in the same Sweep or its folder is already
37
+ gone; a worktree is judged whole first, so no Worker is stopped beside work it could still commit. A live
38
+ Claude Code lock on that worktree that names the Worker's own program, the pid hc get reports, does not keep
39
+ it: once the Worker is stopped the lock is read again and lifted only when it is stale then, else the
40
+ worktree stays. A finished Worker of this session is removed. This session's own terminal and another
41
+ session's are never stopped: a worktree one is in stays, named with the terminal's address and place and
42
+ hc kill as the dev's command. Outside herdr no terminal is read, and the output says so. An hc that fails
43
+ ends the Sweep with exit 2 before anything is removed.
44
+
45
+ --dry-run removes and stops nothing and answers the same items. One line an item, or one document with --json:
46
+ { dryRun, terminalsRead, base, removed: [item], kept: [item] }, an item { kind, name, reason, command? },
47
+ kind branch, worktree, lock (a lifted one) or terminal, name its path or its address, command the one the
48
+ dev would run for a kept item.
36
49
 
37
50
  Exit 0 nothing is kept but the default, the base or a protected branch, 1 something else is, 2 when it
38
- cannot run: in a linked worktree, with no base to read, or when the fetch fails.`;
39
- /** `git <args>` at `root`, or a stop naming its problem. */
40
- function must(root, args) {
41
- const result = git(root, args);
42
- if ("error" in result) {
43
- throw new CannotRun(result.error, "check the repository with git status");
44
- }
45
- return result.out;
46
- }
51
+ cannot run: in a linked worktree, with no base to read, when the fetch fails or when hc fails in herdr.`;
47
52
  function forcedDelete(branch) {
48
53
  return shellLine(["git", "branch", "-D", branch]);
49
54
  }
@@ -55,8 +60,10 @@ function fetchOrigin(root) {
55
60
  if (asked(root, ["remote", "get-url", "origin"]) === undefined) {
56
61
  return;
57
62
  }
58
- const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
59
- const result = spawnSync("git", ["-C", root, "fetch", "--quiet", "origin"], { encoding: "utf8", env, stdio: "pipe" });
63
+ const result = exec("git", ["-C", root, "fetch", "--quiet", "origin"], {
64
+ env: repositoryFreeEnv(),
65
+ limitMs: SLOW_LIMIT_MS
66
+ });
60
67
  if (result.status !== 0) {
61
68
  const said = result.stderr.split("\n").find((line) => line.trim() !== "");
62
69
  throw new CannotRun(`git fetch origin failed: ${said?.trim() ?? `exited ${result.status}`}`, "check the network, then call again");
@@ -89,68 +96,16 @@ function branchesOf(root) {
89
96
  .split("\n")
90
97
  .filter((name) => name !== "");
91
98
  }
92
- /** Every worktree of the repository, the main checkout first. */
93
- function worktreesOf(root) {
94
- const trees = [];
95
- let tree;
96
- for (const line of must(root, ["worktree", "list", "--porcelain"]).split("\n")) {
97
- if (line.startsWith("worktree ")) {
98
- tree = { path: line.slice("worktree ".length), head: "", branch: undefined, lock: undefined, prunable: false };
99
- trees.push(tree);
100
- }
101
- else if (tree !== undefined && line.startsWith("HEAD ")) {
102
- tree.head = line.slice("HEAD ".length);
103
- }
104
- else if (tree !== undefined && line.startsWith("branch refs/heads/")) {
105
- tree.branch = line.slice("branch refs/heads/".length);
106
- }
107
- else if (tree !== undefined && (line === "locked" || line.startsWith("locked "))) {
108
- tree.lock = line.slice("locked".length).trim();
109
- }
110
- else if (tree !== undefined && (line === "prunable" || line.startsWith("prunable "))) {
111
- tree.prunable = true;
112
- }
113
- }
114
- return trees;
115
- }
116
- /** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
117
- function dirtyFiles(path) {
118
- const args = ["-C", path, "status", "--porcelain=v1", "-z", "--untracked-files=all"];
119
- const result = spawnSync("git", args, { encoding: "utf8", env: repositoryFreeEnv(), maxBuffer: 64 * 1024 * 1024 });
120
- if (result.status !== 0) {
121
- const said = result.stderr.split("\n").find((line) => line.trim() !== "");
122
- return { error: `git status: ${said?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
123
- }
124
- const files = [];
125
- const entries = result.stdout.split("\0").filter((entry) => entry !== "");
126
- for (let index = 0; index < entries.length; index++) {
127
- const entry = entries[index] ?? "";
128
- files.push(entry.slice(3));
129
- if (entry.startsWith("R") || entry.startsWith("C")) {
130
- index++;
131
- }
132
- }
133
- return files;
134
- }
135
- const OPERATIONS = [
136
- ["rebase-merge", "rebase"],
137
- ["rebase-apply", "rebase or am"],
138
- ["MERGE_HEAD", "merge"],
139
- ["CHERRY_PICK_HEAD", "cherry-pick"],
140
- ["REVERT_HEAD", "revert"],
141
- ["BISECT_LOG", "bisect"]
142
- ];
143
- /** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
144
- function pausedOperation(path) {
145
- const dir = asked(path, ["rev-parse", "--absolute-git-dir"]);
146
- return dir === undefined ? undefined : OPERATIONS.find(([file]) => existsSync(join(dir, file)))?.[1];
147
- }
148
99
  /** Why the worktree stays, with the dev's command when there is one, or null when its whole set passes. */
149
- function whyTreeStays(root, baseRef, tree, lock) {
100
+ function whyTreeStays(root, baseRef, tree, lock, holders) {
150
101
  const { path } = tree;
151
102
  if (lock.state === "stays") {
152
103
  return lock;
153
104
  }
105
+ const held = whyHeld(holders);
106
+ if (held !== null) {
107
+ return held;
108
+ }
154
109
  if (tree.prunable || !existsSync(path)) {
155
110
  const prune = shellLine(["git", "worktree", "prune"]);
156
111
  return {
@@ -196,8 +151,8 @@ function stays(branch, { head, baseBranch, protectedBranches, held }) {
196
151
  const path = held.get(branch);
197
152
  return path === undefined ? null : { reason: `checked out in the worktree at ${path}`, command: null, dev: true };
198
153
  }
199
- /** Fetches origin, then sorts each linked worktree and local branch into what goes and what stays. */
200
- function judge(root) {
154
+ /** Fetches origin, then sorts each linked worktree, local branch and Worker's terminal into what goes and what stays. */
155
+ function judge(root, terminals) {
201
156
  fetchOrigin(root);
202
157
  const head = originHead(root);
203
158
  const base = baseOf(root, head);
@@ -206,9 +161,16 @@ function judge(root) {
206
161
  if (checkout === undefined) {
207
162
  throw new CannotRun("git lists no worktree", "check the repository with git worktree list");
208
163
  }
164
+ const holders = terminals === undefined
165
+ ? new Map()
166
+ : holdersOf(terminals, checkout.path, linked.map(({ path }) => path));
209
167
  const verdicts = linked.map((tree) => {
210
168
  const lock = lockVerdict(tree.path, tree.lock);
211
- return { tree, lock, why: whyTreeStays(root, base.ref, tree, lock) };
169
+ const here = holders.get(tree.path) ?? [];
170
+ const pid = lock.state === "stays" ? lock.pid : undefined;
171
+ const workerLock = pid !== undefined && lockHolder(pid, here) !== undefined ? { pid } : undefined;
172
+ const why = whyTreeStays(root, base.ref, tree, workerLock === undefined ? lock : { state: "none" }, here);
173
+ return { tree, lock, here, why, workerLock };
212
174
  });
213
175
  const staying = verdicts.filter(({ why }) => why !== null).map(({ tree }) => tree);
214
176
  const held = new Map();
@@ -243,18 +205,28 @@ function judge(root) {
243
205
  kept.push({ kind: "branch", name, reason, command: forcedDelete(name), dev: true });
244
206
  }
245
207
  };
246
- for (const { tree, lock, why } of verdicts) {
208
+ const outside = terminals === undefined
209
+ ? { remove: [], keep: [] }
210
+ : outsideWorktrees(terminals, [checkout.path, ...linked.map(({ path }) => path)]);
211
+ removed.push(...outside.remove);
212
+ for (const { name, reason } of outside.keep) {
213
+ kept.push({ kind: "terminal", name, reason, command: null, dev: true });
214
+ }
215
+ for (const { tree, lock, here, why, workerLock } of verdicts) {
247
216
  if (why !== null) {
248
217
  kept.push({ kind: "worktree", name: tree.path, ...why, dev: true });
218
+ kept.push(...keptIn(here, tree.path).map((item) => ({ kind: "terminal", ...item, dev: true })));
249
219
  continue;
250
220
  }
221
+ removed.push(...stopsOf(here, tree.path));
251
222
  const reason = `clean, its HEAD is Landed on ${baseName}`;
252
223
  removed.push({
253
224
  kind: "worktree",
254
225
  name: tree.path,
255
226
  reason,
256
227
  ...(tree.branch === undefined ? {} : { holds: tree.branch }),
257
- ...(lock.state === "stale" ? { stale: lock } : {})
228
+ ...(lock.state === "stale" ? { stale: lock } : {}),
229
+ ...(workerLock === undefined ? {} : { workerLock })
258
230
  });
259
231
  if (tree.branch !== undefined) {
260
232
  judgeBranch(tree.branch);
@@ -264,7 +236,7 @@ function judge(root) {
264
236
  return { base: baseName, removed, kept };
265
237
  }
266
238
  /** Removes `item`; the item kept when git refuses it, with git's words, or undefined once it is gone. */
267
- function removal(root, item, heldBy) {
239
+ function removal(root, item, heldBy, unstopped) {
268
240
  const refused = (reason, command) => ({
269
241
  kind: item.kind,
270
242
  name: item.name,
@@ -272,6 +244,16 @@ function removal(root, item, heldBy) {
272
244
  command,
273
245
  dev: true
274
246
  });
247
+ if (item.kind === "terminal") {
248
+ const failed = removeTerminal(item);
249
+ if (failed === undefined) {
250
+ return undefined;
251
+ }
252
+ if (item.tree !== undefined) {
253
+ unstopped.set(item.tree, `its Worker's terminal ${item.name} stays: ${failed}`);
254
+ }
255
+ return refused(failed, byHand(item));
256
+ }
275
257
  if (item.kind === "worktree") {
276
258
  const refusedWith = (reason, command) => {
277
259
  if (item.holds !== undefined) {
@@ -279,6 +261,20 @@ function removal(root, item, heldBy) {
279
261
  }
280
262
  return refused(reason, command);
281
263
  };
264
+ const unstoppable = unstopped.get(item.name);
265
+ if (unstoppable !== undefined) {
266
+ return refusedWith(unstoppable, null);
267
+ }
268
+ if (item.workerLock !== undefined) {
269
+ const now = lockAfterStop(root, item.name);
270
+ if (now.state === "stays") {
271
+ return refusedWith(`its Worker's terminal was stopped, but ${now.reason}`, now.command);
272
+ }
273
+ if (now.state === "stale") {
274
+ item.stale = now;
275
+ }
276
+ delete item.workerLock;
277
+ }
282
278
  if (item.stale !== undefined) {
283
279
  const lifted = git(root, ["worktree", "unlock", "--", item.name]);
284
280
  if ("error" in lifted) {
@@ -307,8 +303,9 @@ function apply({ base, removed, kept }, root) {
307
303
  const gone = [];
308
304
  const refused = [];
309
305
  const heldBy = new Map();
306
+ const unstopped = new Map();
310
307
  for (const item of removed) {
311
- const stayed = removal(root, item, heldBy);
308
+ const stayed = removal(root, item, heldBy, unstopped);
312
309
  if (stayed === undefined) {
313
310
  gone.push(item);
314
311
  }
@@ -320,8 +317,12 @@ function apply({ base, removed, kept }, root) {
320
317
  }
321
318
  /** The removed items as the Sweep tells them: a stale lock lifted comes just before its worktree. */
322
319
  function shownOf(removed) {
323
- return removed.flatMap(({ kind, name, reason, stale }) => {
320
+ return removed.flatMap(({ kind, name, reason, stale, workerLock }) => {
324
321
  const item = { kind, name, reason };
322
+ if (workerLock !== undefined) {
323
+ const why = `held by its Worker's program, pid ${workerLock.pid}: lifted once the stop leaves it stale`;
324
+ return [{ kind: "lock", name, reason: why }, item];
325
+ }
325
326
  return stale === undefined ? [item] : [{ kind: "lock", name, reason: stale.why }, item];
326
327
  });
327
328
  }
@@ -331,14 +332,15 @@ function verbOf(kind, dryRun) {
331
332
  }
332
333
  return dryRun ? "would remove" : "removed";
333
334
  }
334
- function linesOf({ dryRun, removed, kept }) {
335
+ function linesOf({ dryRun, terminalsRead, removed, kept }) {
335
336
  const out = [
336
337
  ...removed.map(({ kind, name, reason }) => `${verbOf(kind, dryRun)} ${kind} ${name} — ${reason}`),
337
338
  ...kept.map(({ kind, name, reason, command }) => {
338
339
  return `kept ${kind} ${name} — ${reason}${command === null || command === undefined ? "" : `; to remove it yourself: ${command}`}`;
339
340
  })
340
341
  ];
341
- return out.length === 0 ? "nothing to sweep" : out.join("\n");
342
+ const note = terminalsRead ? [] : ["terminals not read — outside herdr, $HC_TERMINAL_ID is not set"];
343
+ return [...(out.length === 0 ? ["nothing to sweep"] : out), ...note].join("\n");
342
344
  }
343
345
  function parseCli(argv) {
344
346
  try {
@@ -369,10 +371,13 @@ function sweep(json, dryRun) {
369
371
  if (main !== undefined) {
370
372
  throw new CannotRun(`${root} is a linked worktree`, `run it from the main checkout: ${shellLine(["cd", main])} && dw-sdlc sweep`);
371
373
  }
372
- const judged = judge(root);
374
+ const own = sweepingTerminal();
375
+ const terminals = own === undefined ? undefined : readTerminals(own);
376
+ const judged = judge(root, terminals);
373
377
  const { base, removed, kept } = dryRun ? judged : apply(judged, root);
374
378
  const done = {
375
379
  dryRun,
380
+ terminalsRead: terminals !== undefined,
376
381
  base,
377
382
  removed: shownOf(removed),
378
383
  kept: kept.map(({ kind, name, reason, command }) => ({ kind, name, reason, command }))
@@ -6,6 +6,8 @@ export declare function runningPluginRoot(probe: Probe, claude: string, plugin?:
6
6
  export declare function checkPlugin(probe: Probe, claude: string): Check;
7
7
  /** Whether the plugin's hooks and skills run the dw-sdlc the repository pins, the one its Gate runs. */
8
8
  export declare function checkPackage(probe: Probe, claude: string, root: string | undefined): Check;
9
+ /** Whether `sdlc-sessions` is installed and enabled here, so `/sdlc-delegate` is one the dev can start. */
10
+ export declare function sessionsEnabled(probe: Probe): boolean;
9
11
  /**
10
12
  * `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
11
13
  * the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
@@ -131,6 +131,10 @@ export function checkPackage(probe, claude, root) {
131
131
  }
132
132
  return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
133
133
  }
134
+ /** Whether `sdlc-sessions` is installed and enabled here, so `/sdlc-delegate` is one the dev can start. */
135
+ export function sessionsEnabled(probe) {
136
+ return listedPlugins(probe)?.find((item) => item.id === SESSIONS_ID)?.enabled === true;
137
+ }
134
138
  /**
135
139
  * `sdlc-sessions`, which a machine may go without, so it never fails. Its copy of the package against
136
140
  * the sdlc plugin's copy: the `package` line holds that one to the repository's pin.
@@ -140,7 +144,9 @@ export function checkSessions(probe, claude) {
140
144
  const list = listedPlugins(probe);
141
145
  const entry = list?.find((item) => item.id === SESSIONS_ID);
142
146
  if (entry === undefined) {
143
- const detail = list === undefined ? "claude plugin list did not answer" : `no ${SESSIONS_ID}, so no /sdlc-recap`;
147
+ const detail = list === undefined
148
+ ? "claude plugin list did not answer"
149
+ : `no ${SESSIONS_ID}, so no /sdlc-recap, /sdlc-brief or /sdlc-delegate. claude plugin install ${SESSIONS_ID} --scope user`;
144
150
  return { name, level: "INFO", detail };
145
151
  }
146
152
  const version = typeof entry.version === "string" ? entry.version : "?";
package/dist/exec.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type SpawnSyncReturns } from "node:child_process";
2
+ /** For what the network or a repository's size can stretch: a clone, a fetch, a scan of its history. */
3
+ export declare const SLOW_LIMIT_MS = 600000;
4
+ export type ExecOptions = {
5
+ cwd?: string;
6
+ env?: NodeJS.ProcessEnv;
7
+ input?: string;
8
+ limitMs?: number;
9
+ };
10
+ /** A tool that outlived its limit and was stopped; the message names it. */
11
+ export declare class TimedOut extends Error {
12
+ }
13
+ /** `tool args` as a child process; a tool's own exit, or its absence, is the caller's. Throws `TimedOut` at the limit. */
14
+ export declare function exec(tool: string, args: string[], options?: ExecOptions): SpawnSyncReturns<string>;
package/dist/exec.js ADDED
@@ -0,0 +1,39 @@
1
+ // Every short-lived child process the package runs: stopped at a time limit, so a tool that hangs cannot
2
+ // hang the command, and with git's prompts off, since nobody would see one. A Step of the Gate runs
3
+ // through `run-command.ts` instead, and takes as long as it takes.
4
+ import { spawnSync } from "node:child_process";
5
+ const LIMIT_MS = 120_000;
6
+ /** For what the network or a repository's size can stretch: a clone, a fetch, a scan of its history. */
7
+ export const SLOW_LIMIT_MS = 600_000;
8
+ /** A Step that writes thousands of files makes a diff-tree past the default of 1 MiB. */
9
+ const MAX_BUFFER = 64 * 1024 * 1024;
10
+ const SHOWN = 80;
11
+ /** A tool that outlived its limit and was stopped; the message names it. */
12
+ export class TimedOut extends Error {
13
+ }
14
+ /** DW_SDLC_LIMIT_MS, which a test sets to reach a limit quickly, else the limit the call asks for. */
15
+ function limitOf(asked) {
16
+ const given = Number(process.env.DW_SDLC_LIMIT_MS);
17
+ return given > 0 ? given : asked;
18
+ }
19
+ function shown(tool, args) {
20
+ return [tool, ...args].join(" ").replaceAll(/\s+/g, " ").trim().slice(0, SHOWN);
21
+ }
22
+ /** `tool args` as a child process; a tool's own exit, or its absence, is the caller's. Throws `TimedOut` at the limit. */
23
+ export function exec(tool, args, options = {}) {
24
+ const { cwd, env = process.env, input } = options;
25
+ const limitMs = limitOf(options.limitMs ?? LIMIT_MS);
26
+ const result = spawnSync(tool, args, {
27
+ ...(cwd === undefined ? {} : { cwd }),
28
+ ...(input === undefined ? {} : { input }),
29
+ encoding: "utf8",
30
+ env: { ...env, GIT_TERMINAL_PROMPT: "0" },
31
+ killSignal: "SIGKILL",
32
+ maxBuffer: MAX_BUFFER,
33
+ timeout: limitMs
34
+ });
35
+ if (result.error !== undefined && "code" in result.error && result.error.code === "ETIMEDOUT") {
36
+ throw new TimedOut(`${shown(tool, args)} did not answer within ${limitMs / 1000} s`);
37
+ }
38
+ return result;
39
+ }
package/dist/git-env.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // The environment a git command runs under to find its repository from its own directory, as a git
2
2
  // hook's GIT_DIR or GIT_INDEX_FILE would otherwise aim it at the hook's repository.
3
- import { spawnSync } from "node:child_process";
3
+ import { exec } from "./exec.js";
4
4
  let repositoryVars;
5
5
  /** This process's environment without the variables that tie git to one repository, as git lists them. */
6
6
  export function repositoryFreeEnv() {
7
- repositoryVars ??= new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
7
+ repositoryVars ??= new Set((exec("git", ["rev-parse", "--local-env-vars"]).stdout ?? "").split("\n").filter(Boolean));
8
8
  const vars = repositoryVars;
9
9
  return Object.fromEntries(Object.entries(process.env).filter(([name]) => !vars.has(name)));
10
10
  }