dw-sdlc 0.12.1 → 0.14.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.
@@ -0,0 +1,262 @@
1
+ // What a Sweep does with herdr terminals: it stops and removes only a Worker of its own whose turn has ended,
2
+ // in a worktree that goes in the same Sweep or is already gone. docs/adr/0015 sets the bounds.
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { isObject, parseObject } from "./json-text.js";
5
+ import { shellLine } from "./pr/decide.js";
6
+ import { CannotRun } from "./pr/read.js";
7
+ import { worktreesOf } from "./sweep-trees.js";
8
+ import { call, hcObject, isLive, Refusal, said, workersOf } from "./worker/herdr.js";
9
+ import { lockVerdict } from "./worktree-lock.js";
10
+ /** Statuses of a Worker that is not at the end of its turn, with how the Sweep says it. */
11
+ const AT_WORK = {
12
+ working: "working",
13
+ blocked: "blocked, waiting for the dev",
14
+ unknown: "yet to report"
15
+ };
16
+ /** The address of the terminal the Sweep runs in, or undefined outside herdr. */
17
+ export function sweepingTerminal() {
18
+ const id = process.env.HC_TERMINAL_ID;
19
+ return id === undefined || id === "" ? undefined : id;
20
+ }
21
+ function word(value) {
22
+ return typeof value === "string" && value !== "" ? value : undefined;
23
+ }
24
+ /**
25
+ * Each live terminal with the folder it works in, `hc get`'s foreground. An `hc get` that fails stops the
26
+ * Sweep, since a terminal in a folder it cannot see must not count as absent; one gone since the listing is dropped.
27
+ */
28
+ function foldersOf(list) {
29
+ return list.flatMap((worker) => {
30
+ if (!isLive(worker)) {
31
+ return [{ ...worker, pid: undefined }];
32
+ }
33
+ const got = call("hc", ["get", worker.address, "--json"]);
34
+ if (got.status === 3) {
35
+ return [];
36
+ }
37
+ const parsed = got.status === 0 ? parseObject(got.stdout) : undefined;
38
+ if (parsed === undefined) {
39
+ throw new Refusal(`hc get ${worker.address} failed: ${got.status === 0 ? "its reply is no JSON object" : said(got)}`);
40
+ }
41
+ const { cwd, pid } = isObject(parsed.foreground) ? parsed.foreground : { cwd: undefined, pid: undefined };
42
+ const there = typeof cwd === "string" && cwd !== "" ? { ...worker, folder: cwd } : worker;
43
+ return [{ ...there, pid: typeof pid === "number" ? pid : undefined }];
44
+ });
45
+ }
46
+ /** The live terminals `hc ls` lists, each with the folder it works in. */
47
+ function liveTerminals() {
48
+ const { terminals } = hcObject(["ls", "--json"]);
49
+ if (!Array.isArray(terminals)) {
50
+ throw new Refusal("hc ls answered no list of terminals", "check hc --version");
51
+ }
52
+ const places = new Map();
53
+ const listed = terminals.flatMap((entry) => {
54
+ const address = isObject(entry) ? word(entry.address) : undefined;
55
+ if (!isObject(entry) || address === undefined || typeof entry.cwd !== "string") {
56
+ throw new Refusal("hc ls answered a terminal without an address or a folder", "check hc --version");
57
+ }
58
+ places.set(address, word(entry.where));
59
+ const name = word(entry.name) ?? address;
60
+ return [{ name, address, state: "live", status: word(entry.status) ?? "unknown", folder: entry.cwd }];
61
+ });
62
+ return foldersOf(listed).map(({ name, address, folder }) => ({
63
+ name,
64
+ address,
65
+ folder,
66
+ place: places.get(address) ?? folder
67
+ }));
68
+ }
69
+ /**
70
+ * Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
71
+ * terminals. An `hc` that fails stops the Sweep, since a terminal it cannot see must not count as absent.
72
+ */
73
+ export function readTerminals(own) {
74
+ try {
75
+ const workers = foldersOf(workersOf(own, true));
76
+ const live = liveTerminals();
77
+ const addresses = new Set(workers.map(({ address }) => address));
78
+ return {
79
+ own: live.find(({ address }) => address === own),
80
+ workers,
81
+ others: live.filter(({ address }) => address !== own && !addresses.has(address))
82
+ };
83
+ }
84
+ catch (error) {
85
+ if (error instanceof Refusal) {
86
+ throw new CannotRun(error.message, error.remedy ?? "check hc, then call again");
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+ function canonical(path) {
92
+ try {
93
+ return realpathSync(path);
94
+ }
95
+ catch {
96
+ return path;
97
+ }
98
+ }
99
+ function isInside(folder, root) {
100
+ return folder === root || folder.startsWith(root.endsWith("/") ? root : `${root}/`);
101
+ }
102
+ /** The deepest of `roots` whose folder holds `folder`, or undefined. */
103
+ function treeOf(folder, roots) {
104
+ const real = canonical(folder);
105
+ const holding = roots.filter((root) => isInside(folder, root) || isInside(real, canonical(root)));
106
+ return holding.toSorted((a, b) => b.length - a.length)[0];
107
+ }
108
+ /** A Worker whose terminal is live but whose folder is gone, with no worktree of `roots` around it, could commit nothing more. */
109
+ export function workerWithoutFolder({ workers }, roots) {
110
+ const standing = roots.filter((root) => existsSync(root));
111
+ return workers.filter((worker) => isLive(worker) && !existsSync(worker.folder) && treeOf(worker.folder, standing) === undefined);
112
+ }
113
+ /** The terminals in the folder of each of `trees`; the main checkout `main` takes those not in a linked one. */
114
+ export function holdersOf({ own, workers, others }, main, trees) {
115
+ const held = new Map();
116
+ const standing = [main, ...trees].filter((root) => existsSync(root));
117
+ const place = (folder, holder) => {
118
+ const tree = treeOf(folder, standing);
119
+ if (tree !== undefined && tree !== main) {
120
+ held.set(tree, [...(held.get(tree) ?? []), holder]);
121
+ }
122
+ };
123
+ if (own !== undefined) {
124
+ place(own.folder, { role: "own", terminal: own });
125
+ }
126
+ for (const terminal of others) {
127
+ place(terminal.folder, { role: "other", terminal });
128
+ }
129
+ for (const worker of workers) {
130
+ if (isLive(worker)) {
131
+ place(worker.folder, { role: "worker", worker });
132
+ }
133
+ }
134
+ return held;
135
+ }
136
+ /** How the Sweep says a Worker that is working, blocked or yet to report is, or undefined for one that is done. */
137
+ export function atWork({ status }) {
138
+ return AT_WORK[status];
139
+ }
140
+ /** What the terminals in a worktree keep it for, or null when only Workers at the end of their turn are in it. */
141
+ export function whyHeld(holders) {
142
+ const reasons = [];
143
+ const kills = [];
144
+ for (const holder of holders) {
145
+ if (holder.role === "own") {
146
+ reasons.push(`the terminal this Sweep runs in, ${holder.terminal.address}, is in it`);
147
+ }
148
+ else if (holder.role === "other") {
149
+ const { address, place } = holder.terminal;
150
+ reasons.push(`another session's terminal ${address} is in it, at ${place}`);
151
+ kills.push(shellLine(["hc", "kill", address]));
152
+ }
153
+ else {
154
+ const at = atWork(holder.worker);
155
+ if (at !== undefined) {
156
+ reasons.push(`its Worker ${holder.worker.name} (${holder.worker.address}) is ${at}`);
157
+ }
158
+ }
159
+ }
160
+ return reasons.length === 0
161
+ ? null
162
+ : { reason: reasons.join("; "), command: kills.length === 0 ? null : kills.join(" && ") };
163
+ }
164
+ /** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
165
+ export function stopsOf(holders, tree) {
166
+ return holders.flatMap((holder) => holder.role === "worker"
167
+ ? [
168
+ {
169
+ kind: "terminal",
170
+ name: holder.worker.address,
171
+ reason: `Worker ${holder.worker.name} is ${holder.worker.status}: stopped, then removed with its worktree`,
172
+ stop: true,
173
+ tree
174
+ }
175
+ ]
176
+ : []);
177
+ }
178
+ /** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
179
+ export function removeTerminal(item) {
180
+ try {
181
+ if (item.stop === true) {
182
+ const killed = call("hc", ["kill", item.name]);
183
+ // 11: the terminal finished between the read and the stop.
184
+ if (killed.status !== 0 && killed.status !== 11) {
185
+ return `hc kill ${item.name} failed: ${said(killed)}`;
186
+ }
187
+ }
188
+ const removed = call("hc", ["rm", item.name]);
189
+ // 3: the terminal is gone already.
190
+ return removed.status === 0 || removed.status === 3 ? undefined : `hc rm ${item.name} failed: ${said(removed)}`;
191
+ }
192
+ catch (error) {
193
+ if (error instanceof Refusal) {
194
+ return error.message;
195
+ }
196
+ throw error;
197
+ }
198
+ }
199
+ /** The Workers no worktree of this Sweep decides: a finished one is removed, one whose folder is gone goes under the status rule. */
200
+ export function outsideWorktrees(terminals, roots) {
201
+ const remove = [];
202
+ const keep = [];
203
+ for (const worker of terminals.workers.filter((one) => !isLive(one))) {
204
+ remove.push({ kind: "terminal", name: worker.address, reason: `Worker ${worker.name} is finished`, stop: false });
205
+ }
206
+ for (const worker of workerWithoutFolder(terminals, roots)) {
207
+ const at = atWork(worker);
208
+ if (at === undefined) {
209
+ const reason = `Worker ${worker.name} is ${worker.status} and its folder ${worker.folder} is gone: stopped, then removed`;
210
+ remove.push({ kind: "terminal", name: worker.address, reason, stop: true });
211
+ }
212
+ else {
213
+ keep.push({ name: worker.address, reason: `Worker ${worker.name} is ${at}, its folder ${worker.folder} is gone` });
214
+ }
215
+ }
216
+ return { remove, keep };
217
+ }
218
+ /** The command that stops or removes the terminal of a planned terminal by hand. */
219
+ export function byHand(item) {
220
+ return item.stop === true
221
+ ? `${shellLine(["hc", "kill", item.name])}; ${shellLine(["hc", "rm", item.name])}`
222
+ : shellLine(["hc", "rm", item.name]);
223
+ }
224
+ /** The Worker in `holders` whose program is the process a live lock names, when it may be stopped. */
225
+ export function lockHolder(pid, holders) {
226
+ for (const holder of holders) {
227
+ if (holder.role === "worker" && holder.worker.pid === pid && atWork(holder.worker) === undefined) {
228
+ return holder.worker;
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+ /** The lock of the worktree at `path` read again once its Worker is stopped, given the program a moment to end. */
234
+ export function lockAfterStop(root, path) {
235
+ let verdict = { state: "none" };
236
+ for (let attempt = 0; attempt < 15; attempt++) {
237
+ verdict = lockVerdict(path, worktreesOf(root).find((tree) => tree.path === path)?.lock);
238
+ if (verdict.state !== "stays") {
239
+ return verdict;
240
+ }
241
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
242
+ }
243
+ return verdict;
244
+ }
245
+ /** The terminals in a worktree that stays which the Sweep lists as kept: Workers, and another session's terminals. */
246
+ export function keptIn(holders, tree) {
247
+ return holders.flatMap((holder) => {
248
+ if (holder.role === "other") {
249
+ const { address, place } = holder.terminal;
250
+ const reason = `another session's terminal, in the worktree at ${tree}, at ${place}`;
251
+ return [{ name: address, reason, command: shellLine(["hc", "kill", address]) }];
252
+ }
253
+ if (holder.role === "worker") {
254
+ const { name, address, status } = holder.worker;
255
+ const at = atWork(holder.worker);
256
+ return [
257
+ { name: address, reason: `Worker ${name} is ${at ?? `${status}, but its worktree stays`}`, command: null }
258
+ ];
259
+ }
260
+ return [];
261
+ });
262
+ }
@@ -0,0 +1,17 @@
1
+ /** `git <args>` at `root`, or a stop naming its problem. */
2
+ export declare function must(root: string, args: string[]): string;
3
+ export type Tree = {
4
+ path: string;
5
+ head: string;
6
+ branch: string | undefined;
7
+ lock: string | undefined;
8
+ prunable: boolean;
9
+ };
10
+ /** Every worktree of the repository, the main checkout first. */
11
+ export declare function worktreesOf(root: string): Tree[];
12
+ /** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
13
+ export declare function dirtyFiles(path: string): string[] | {
14
+ error: string;
15
+ };
16
+ /** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
17
+ export declare function pausedOperation(path: string): string | undefined;
@@ -0,0 +1,72 @@
1
+ // What a Sweep reads of a repository's linked worktrees: where each lies, what it has checked out, its lock,
2
+ // the files that hold it and the operation paused in it.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { repositoryFreeEnv } from "./git-env.js";
7
+ import { CannotRun } from "./pr/read.js";
8
+ import { asked, git } from "./working-tree.js";
9
+ /** `git <args>` at `root`, or a stop naming its problem. */
10
+ export function must(root, args) {
11
+ const result = git(root, args);
12
+ if ("error" in result) {
13
+ throw new CannotRun(result.error, "check the repository with git status");
14
+ }
15
+ return result.out;
16
+ }
17
+ /** Every worktree of the repository, the main checkout first. */
18
+ export function worktreesOf(root) {
19
+ const trees = [];
20
+ let tree;
21
+ for (const line of must(root, ["worktree", "list", "--porcelain"]).split("\n")) {
22
+ if (line.startsWith("worktree ")) {
23
+ tree = { path: line.slice("worktree ".length), head: "", branch: undefined, lock: undefined, prunable: false };
24
+ trees.push(tree);
25
+ }
26
+ else if (tree !== undefined && line.startsWith("HEAD ")) {
27
+ tree.head = line.slice("HEAD ".length);
28
+ }
29
+ else if (tree !== undefined && line.startsWith("branch refs/heads/")) {
30
+ tree.branch = line.slice("branch refs/heads/".length);
31
+ }
32
+ else if (tree !== undefined && (line === "locked" || line.startsWith("locked "))) {
33
+ tree.lock = line.slice("locked".length).trim();
34
+ }
35
+ else if (tree !== undefined && (line === "prunable" || line.startsWith("prunable "))) {
36
+ tree.prunable = true;
37
+ }
38
+ }
39
+ return trees;
40
+ }
41
+ /** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
42
+ export function dirtyFiles(path) {
43
+ const args = ["-C", path, "status", "--porcelain=v1", "-z", "--untracked-files=all"];
44
+ const result = spawnSync("git", args, { encoding: "utf8", env: repositoryFreeEnv(), maxBuffer: 64 * 1024 * 1024 });
45
+ if (result.status !== 0) {
46
+ const said = result.stderr.split("\n").find((line) => line.trim() !== "");
47
+ return { error: `git status: ${said?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
48
+ }
49
+ const files = [];
50
+ const entries = result.stdout.split("\0").filter((entry) => entry !== "");
51
+ for (let index = 0; index < entries.length; index++) {
52
+ const entry = entries[index] ?? "";
53
+ files.push(entry.slice(3));
54
+ if (entry.startsWith("R") || entry.startsWith("C")) {
55
+ index++;
56
+ }
57
+ }
58
+ return files;
59
+ }
60
+ const OPERATIONS = [
61
+ ["rebase-merge", "rebase"],
62
+ ["rebase-apply", "rebase or am"],
63
+ ["MERGE_HEAD", "merge"],
64
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
65
+ ["REVERT_HEAD", "revert"],
66
+ ["BISECT_LOG", "bisect"]
67
+ ];
68
+ /** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
69
+ export function pausedOperation(path) {
70
+ const dir = asked(path, ["rev-parse", "--absolute-git-dir"]);
71
+ return dir === undefined ? undefined : OPERATIONS.find(([file]) => existsSync(join(dir, file)))?.[1];
72
+ }
@@ -4,6 +4,8 @@ export declare function git(root: string, args: string[], index?: string): {
4
4
  } | {
5
5
  error: string;
6
6
  };
7
+ /** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
8
+ export declare function asked(root: string, args: string[]): string | undefined;
7
9
  /** The tree of the checkout at `root` and its git dir, or why it has none. */
8
10
  export declare function workingTree(root: string): {
9
11
  tree: string;
@@ -20,6 +20,11 @@ export function git(root, args, index) {
20
20
  const reason = result.error?.message ?? result.stderr.split("\n").find((line) => line.trim() !== "");
21
21
  return { error: `git ${args[0] ?? ""}: ${reason?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
22
22
  }
23
+ /** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
24
+ export function asked(root, args) {
25
+ const result = git(root, args);
26
+ return "out" in result ? result.out : undefined;
27
+ }
23
28
  /**
24
29
  * The tree `git write-tree` hashes from a copy of the index after `git add -A` on it: the copy keeps the
25
30
  * stat cache, so only what changed is read again; identical content survives a commit, an amend or a
@@ -0,0 +1,18 @@
1
+ /** The process is gone: `held` is the reason to put back should the removal fail, `why` what the Sweep tells. */
2
+ export type StaleLock = {
3
+ state: "stale";
4
+ held: string;
5
+ why: string;
6
+ };
7
+ /** A lock that keeps the worktree; `pid` is the live process a Claude Code lock names. */
8
+ export type LockStays = {
9
+ state: "stays";
10
+ reason: string;
11
+ command: string | null;
12
+ pid?: number;
13
+ };
14
+ export type LockVerdict = {
15
+ state: "none";
16
+ } | StaleLock | LockStays;
17
+ /** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
18
+ export declare function lockVerdict(path: string, reason: string | undefined): LockVerdict;
@@ -0,0 +1,64 @@
1
+ // What a worktree's lock means to a Sweep. Claude Code locks the worktree of a session with the reason
2
+ // `claude session <name> (pid <N> start <time>)`, <time> as `ps -o lstart` writes it in UTC. Such a lock is
3
+ // stale once no process N runs or N started at another time. Any other reason is the dev's to lift.
4
+ import { spawnSync } from "node:child_process";
5
+ import { shellLine } from "./pr/decide.js";
6
+ const CLAUDE_LOCK = /^claude session .* \(pid (?<pid>\d{1,10}) start (?<start>.+)\)$/;
7
+ const MAX_PID = 2 ** 31 - 1;
8
+ const squeezed = (text) => text.trim().replace(/\s+/g, " ");
9
+ /**
10
+ * Whether process `pid` runs and, if so, its start time as `ps` writes it in UTC and in local time, so a
11
+ * live lock is never taken for stale over a time zone.
12
+ */
13
+ function runningOf(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
19
+ return { at: "gone" };
20
+ }
21
+ }
22
+ const starts = [{ TZ: "UTC" }, {}]
23
+ .map((zone) => {
24
+ const env = { ...process.env, LC_ALL: "C", ...zone };
25
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env });
26
+ return result.status === 0 ? squeezed(result.stdout) : "";
27
+ })
28
+ .filter((start) => start !== "");
29
+ return starts.length === 0 ? { at: "unknown" } : { at: "running", starts };
30
+ }
31
+ /** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
32
+ export function lockVerdict(path, reason) {
33
+ if (reason === undefined) {
34
+ return { state: "none" };
35
+ }
36
+ const unlock = shellLine(["git", "worktree", "unlock", path]);
37
+ const { pid: pidText = "", start = "" } = CLAUDE_LOCK.exec(reason)?.groups ?? {};
38
+ const pid = Number(pidText);
39
+ if (pidText === "" || pid < 1 || pid > MAX_PID) {
40
+ const said = reason === "" ? "locked, with no reason given" : `locked: ${reason}`;
41
+ return { state: "stays", reason: said, command: unlock };
42
+ }
43
+ const now = runningOf(pid);
44
+ if (now.at === "gone") {
45
+ return { state: "stale", held: reason, why: `pid ${pid} is gone, no process runs under it` };
46
+ }
47
+ if (now.at === "unknown") {
48
+ return { state: "stays", reason: `locked: ${reason}; ps could not say whether pid ${pid} runs`, command: unlock };
49
+ }
50
+ if (!now.starts.includes(squeezed(start))) {
51
+ const started = now.starts[0] ?? "";
52
+ return {
53
+ state: "stale",
54
+ held: reason,
55
+ why: `pid ${pid} is gone, it now belongs to another process (started ${started} UTC)`
56
+ };
57
+ }
58
+ return {
59
+ state: "stays",
60
+ reason: `locked by a live Claude Code session: pid ${pid} is running (${reason})`,
61
+ command: null,
62
+ pid
63
+ };
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.12.1",
3
+ "version": "0.14.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": {