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.
- package/dist/agentic-config.js +3 -3
- package/dist/checks/file-size-ratchet.js +2 -2
- package/dist/checks/tree.js +2 -2
- package/dist/cli.js +8 -1
- package/dist/commands/doctor.js +27 -8
- package/dist/commands/references-setup.js +2 -2
- package/dist/commands/references-sync.js +4 -5
- package/dist/commands/setup.js +2 -2
- package/dist/commands/sweep.js +89 -84
- package/dist/doctor-plugins.d.ts +2 -0
- package/dist/doctor-plugins.js +7 -1
- package/dist/exec.d.ts +14 -0
- package/dist/exec.js +39 -0
- package/dist/git-env.js +2 -2
- package/dist/landed.js +10 -12
- package/dist/pr/decide.d.ts +1 -1
- package/dist/pr/decide.js +37 -1
- package/dist/pr/read.js +14 -2
- package/dist/pr/state.d.ts +10 -1
- package/dist/pr/state.js +36 -2
- package/dist/setup-rules.js +2 -2
- package/dist/sweep-terminals.d.ts +89 -0
- package/dist/sweep-terminals.js +262 -0
- package/dist/sweep-trees.d.ts +17 -0
- package/dist/sweep-trees.js +72 -0
- package/dist/worker/herdr.js +14 -2
- package/dist/working-tree.js +3 -6
- package/dist/worktree-lock.d.ts +6 -3
- package/dist/worktree-lock.js +4 -3
- package/package.json +1 -1
package/dist/agentic-config.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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");
|
package/dist/checks/tree.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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 =
|
|
22
|
-
return { status: result
|
|
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 =
|
|
30
|
-
return (result
|
|
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
|
-
/**
|
|
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 {
|
|
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 =
|
|
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`.
|
|
139
|
-
|
|
140
|
-
const
|
|
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")
|
package/dist/commands/setup.js
CHANGED
|
@@ -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 =
|
|
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. */
|
package/dist/commands/sweep.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
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
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 }))
|
package/dist/doctor-plugins.d.ts
CHANGED
|
@@ -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.
|
package/dist/doctor-plugins.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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((
|
|
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
|
}
|