dw-sdlc 0.14.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,9 +1,9 @@
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
4
  import { parseArgs } from "node:util";
6
5
  import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
6
+ import { exec, SLOW_LIMIT_MS } from "../exec.js";
7
7
  import { repositoryFreeEnv } from "../git-env.js";
8
8
  import { isCommitLanded, isLanded } from "../landed.js";
9
9
  import { shellLine } from "../pr/decide.js";
@@ -60,8 +60,10 @@ function fetchOrigin(root) {
60
60
  if (asked(root, ["remote", "get-url", "origin"]) === undefined) {
61
61
  return;
62
62
  }
63
- const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
64
- 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
+ });
65
67
  if (result.status !== 0) {
66
68
  const said = result.stderr.split("\n").find((line) => line.trim() !== "");
67
69
  throw new CannotRun(`git fetch origin failed: ${said?.trim() ?? `exited ${result.status}`}`, "check the network, then call again");
@@ -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
  }
package/dist/landed.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Whether a local branch is Landed: its work is on the base, as the guard's `guard_landed`
2
2
  // (plugins/sdlc/hooks/lib-git.sh) judges it. The guard keeps its own bash version; docs/adr/0015 says why there
3
3
  // are two, and the shared table of scenarios, plugins/sdlc/test/landed-scenarios.ts, holds them to one meaning.
4
- import { spawnSync } from "node:child_process";
4
+ import { exec, SLOW_LIMIT_MS } from "./exec.js";
5
5
  import { repositoryFreeEnv } from "./git-env.js";
6
6
  /** The base commits read, newest first: a branch that forked further back is kept rather than read for seconds. */
7
7
  const SCAN = 5000;
@@ -9,18 +9,17 @@ const SCAN = 5000;
9
9
  function localEnv() {
10
10
  return { ...repositoryFreeEnv(), GIT_NO_LAZY_FETCH: "1" };
11
11
  }
12
- function git(root, args, input) {
13
- const result = spawnSync("git", ["-C", root, ...args], {
14
- encoding: "utf8",
12
+ function git(root, args, input, limitMs) {
13
+ const result = exec("git", ["-C", root, ...args], {
15
14
  env: localEnv(),
16
- input,
17
- maxBuffer: 64 * 1024 * 1024
15
+ ...(input === undefined ? {} : { input }),
16
+ ...(limitMs === undefined ? {} : { limitMs })
18
17
  });
19
18
  return { status: result.status ?? 1, stdout: result.stdout ?? "" };
20
19
  }
21
20
  /** What `git <args>` prints, or undefined when it exits non-zero. */
22
- function answer(root, args, input) {
23
- const ran = git(root, args, input);
21
+ function answer(root, args, input, limitMs) {
22
+ const ran = git(root, args, input, limitMs);
24
23
  return ran.status === 0 ? ran.stdout.trim() : undefined;
25
24
  }
26
25
  /** The patch-id of the whole diff from `from` to `to`; undefined for an empty diff or one git could not finish. */
@@ -44,10 +43,9 @@ function baseHoldsPatch(root, range, patch) {
44
43
  "git patch-id --verbatim"
45
44
  ].join(" | ")
46
45
  ].join("; ");
47
- const result = spawnSync("bash", ["-c", pipeline, "bash", root, String(SCAN), range], {
48
- encoding: "utf8",
46
+ const result = exec("bash", ["-c", pipeline, "bash", root, String(SCAN), range], {
49
47
  env: localEnv(),
50
- maxBuffer: 64 * 1024 * 1024
48
+ limitMs: SLOW_LIMIT_MS
51
49
  });
52
50
  if (result.status !== 0) {
53
51
  return false;
@@ -77,7 +75,7 @@ export function isCommitLanded(root, baseRef, tip) {
77
75
  return false;
78
76
  }
79
77
  const range = `${fork}..${baseRef}`;
80
- const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range]) ?? "";
78
+ const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range], undefined, SLOW_LIMIT_MS) ?? "";
81
79
  if (trees.split("\n").includes(tree)) {
82
80
  return true;
83
81
  }
@@ -3,7 +3,7 @@ import { type PrState, REVIEW } from "./state.ts";
3
3
  export { REVIEW };
4
4
  export type Status = "act" | "wait" | "park" | "done";
5
5
  export type Actor = "script" | "agent" | "dev";
6
- export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "review-limit" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
6
+ export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "review-limit" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "awaiting-review" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "partial-read" | "ready-to-merge" | "merge";
7
7
  /** The Actions the script performs itself, each one gh command. */
8
8
  export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
9
9
  /**
package/dist/pr/decide.js CHANGED
@@ -46,6 +46,20 @@ function failure({ name, link, log }) {
46
46
  function threads(pr, lastByViewer) {
47
47
  return pr.unresolvedThreads.filter((thread) => thread.lastByViewer === lastByViewer);
48
48
  }
49
+ /**
50
+ * Whether the decision is changes requested and every reviewer asking for them has been answered by a
51
+ * push. One review still unanswered, or none in the reply to judge, keeps the agent's turn.
52
+ */
53
+ function reviewAnswered({ reviewDecision, changeRequests }) {
54
+ return (reviewDecision === "CHANGES_REQUESTED" &&
55
+ changeRequests.length > 0 &&
56
+ changeRequests.every(({ answered }) => answered));
57
+ }
58
+ /** The names, `a`, `a and b` or `a, b and c`. */
59
+ function namesOf(names) {
60
+ const last = names.at(-1);
61
+ return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${last}`;
62
+ }
49
63
  function listed({ id, path, line, author, body }) {
50
64
  return { id, path, line, author, body };
51
65
  }
@@ -200,7 +214,7 @@ const ROWS = [
200
214
  })
201
215
  },
202
216
  {
203
- when: (pr) => pr.reviewDecision === "CHANGES_REQUESTED",
217
+ when: (pr) => pr.reviewDecision === "CHANGES_REQUESTED" && !reviewAnswered(pr),
204
218
  answer: () => ({ status: "act", action: "address-review", actor: "agent", reason: "changes requested" })
205
219
  },
206
220
  {
@@ -273,6 +287,17 @@ const ROWS = [
273
287
  details: { threads: threads(pr, true).map(listed) }
274
288
  })
275
289
  },
290
+ {
291
+ // After awaiting-reply, so the pull request's own Review and CI run first and the reviewer gets a
292
+ // verified fix. Only the reviewer can act: their review stands until they review again or dismiss it.
293
+ when: reviewAnswered,
294
+ answer: (pr) => ({
295
+ status: "park",
296
+ action: "awaiting-review",
297
+ actor: "dev",
298
+ reason: `changes requested by ${namesOf(pr.changeRequests.map(({ reviewer }) => reviewer))}, answered by a push: the next review is the reviewer's`
299
+ })
300
+ },
276
301
  {
277
302
  when: (pr) => pr.draft && pr.viewerDidAuthor,
278
303
  answer: () => ({ status: "act", action: "mark-ready", actor: "script", reason: "still a draft" })
@@ -295,6 +320,17 @@ const ROWS = [
295
320
  reason: "an approving review is required"
296
321
  })
297
322
  },
323
+ {
324
+ // A failure or an open thread past what the read holds shows in no row above.
325
+ when: (pr) => pr.cutOff.length > 0,
326
+ answer: (pr, _, policy) => ({
327
+ status: "park",
328
+ action: "partial-read",
329
+ actor: "dev",
330
+ reason: `the read holds only ${pr.cutOff.join(" and ")}: the merge is the dev's`,
331
+ command: devCommand(pr, "merge", policy)
332
+ })
333
+ },
298
334
  {
299
335
  when: (pr) => !pr.viewerDidAuthor,
300
336
  answer: (pr, _, policy) => readyToMerge(pr, policy, "another author's pull request: the merge is the dev's"),
package/dist/pr/read.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The read the pr commands and recap make: gh as a child process, its auth, the pull request a command names,
2
2
  // its state from one GraphQL request with the compares that tell whether its head carries an earlier Review
3
3
  // and that size its changes, and beside them the default branch and the clock the decision reads.
4
- import { spawnSync } from "node:child_process";
4
+ import { exec, TimedOut } from "../exec.js";
5
5
  import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
6
6
  import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY, ReplyError } from "./state.js";
7
7
  /** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
@@ -42,8 +42,20 @@ function authProblem(stderr) {
42
42
  const marked = stderr.split("\n").find((line) => line.trimStart().startsWith("X "));
43
43
  return marked === undefined ? firstLine(stderr) : marked.trim().slice(2);
44
44
  }
45
+ /** `gh args` as a child process; past its limit it stops the command as any other failed read does. */
46
+ function reachGh(args) {
47
+ try {
48
+ return exec("gh", args);
49
+ }
50
+ catch (error) {
51
+ if (error instanceof TimedOut) {
52
+ throw new CannotRun(error.message, REMEDIES.read);
53
+ }
54
+ throw error;
55
+ }
56
+ }
45
57
  export function gh(args) {
46
- const result = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
58
+ const result = reachGh(args);
47
59
  if (result.error !== undefined) {
48
60
  if ("code" in result.error && result.error.code === "ENOENT") {
49
61
  throw new CannotRun("gh is not on PATH", REMEDIES.missing);
@@ -10,7 +10,7 @@ export declare const MARK_STATE = "failure";
10
10
  export declare const READ_MARK = "read, findings open";
11
11
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
12
  export declare const ACCEPTED = "accepted by the dev, findings open";
13
- export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
13
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
14
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
15
15
  export type CiCheck = {
16
16
  name: string;
@@ -33,6 +33,11 @@ export type Thread = {
33
33
  declare const STATES: readonly ["OPEN", "CLOSED", "MERGED"];
34
34
  declare const MERGEABLE: readonly ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
35
35
  declare const REVIEW_DECISIONS: readonly ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
36
+ /** A reviewer whose latest review requests changes, and whether a push has answered it. */
37
+ export type ChangeRequest = {
38
+ reviewer: string;
39
+ answered: boolean;
40
+ };
36
41
  export type PrState = {
37
42
  url: string;
38
43
  repo: string;
@@ -57,6 +62,10 @@ export type PrState = {
57
62
  checks: CiCheck[];
58
63
  /** The threads neither resolved nor outdated. */
59
64
  unresolvedThreads: Thread[];
65
+ /** Each reviewer's latest review that requests changes, in the order GitHub lists them. */
66
+ changeRequests: ChangeRequest[];
67
+ /** What the read holds of each list with more after it, as `the first 50 checks`; empty when it holds all. */
68
+ cutOff: string[];
60
69
  /** The oids of the pull request's last 100 commits, oldest first, the head last. */
61
70
  commits: string[];
62
71
  /**
package/dist/pr/state.js CHANGED
@@ -12,16 +12,20 @@ export const MARK_STATE = "failure";
12
12
  export const READ_MARK = "read, findings open";
13
13
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
14
14
  export const ACCEPTED = "accepted by the dev, findings open";
15
+ /** How many checks and review threads the one read holds; each list's `pageInfo` says when more follow. */
16
+ const CHECKS_READ = 50;
17
+ const THREADS_READ = 100;
15
18
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
16
19
  repository(owner: $owner, name: $name) {
17
20
  pullRequest(number: $number) {
18
21
  url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
19
22
  baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
20
- reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
23
+ latestReviews(first: 100) { nodes { state author { login } commit { oid } } }
24
+ reviewThreads(first: ${THREADS_READ}) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line
21
25
  comments(first: 1) { nodes { author { login } body } }
22
26
  latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
23
27
  commits(last: 1) { nodes { commit { committedDate statusCheckRollup {
24
- contexts(first: 50) { nodes {
28
+ contexts(first: ${CHECKS_READ}) { pageInfo { hasNextPage } nodes {
25
29
  ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
26
30
  ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
27
31
  history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
@@ -131,6 +135,10 @@ function nodes(node, key) {
131
135
  }
132
136
  return value.map((item, index) => object(item, `${at}[${index}]`));
133
137
  }
138
+ /** Whether a connection has more nodes after the ones the reply holds. */
139
+ function hasMore(node, key) {
140
+ return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
141
+ }
134
142
  /** The first object of a connection's `nodes`; an empty connection does not parse. */
135
143
  function firstNode(node, key) {
136
144
  const [first] = nodes(node, key);
@@ -246,6 +254,27 @@ function lastReadOf(history, head) {
246
254
  }
247
255
  return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
248
256
  }
257
+ /**
258
+ * Whether a non-merge commit follows `commit` in the history, oldest first, as `lastReadOf` judges a read
259
+ * mark. A commit outside the history, after a force-push or past the last 100, is not answered.
260
+ */
261
+ function answeredAfter(history, commit) {
262
+ const index = history.findIndex(({ oid }) => oid === commit);
263
+ return index >= 0 && history.slice(index + 1).some(({ merge }) => !merge);
264
+ }
265
+ /** The reviewers whose latest review requests changes, each judged against the history. */
266
+ function changeRequestsOf(pr, history) {
267
+ return nodes(pr, "latestReviews")
268
+ .filter((review) => string(review, "state") === "CHANGES_REQUESTED")
269
+ .map((review) => {
270
+ const author = childOrNull(review, "author");
271
+ const commit = childOrNull(review, "commit");
272
+ return {
273
+ reviewer: author === null ? "ghost" : string(author, "login"),
274
+ answered: answeredAfter(history, commit === null ? null : string(commit, "oid"))
275
+ };
276
+ });
277
+ }
249
278
  function readMarksOf(history) {
250
279
  const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
251
280
  const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
@@ -293,6 +322,11 @@ export function parsePr(reply, repo) {
293
322
  unresolvedThreads: nodes(pr, "reviewThreads")
294
323
  .filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
295
324
  .map(threadOf),
325
+ changeRequests: changeRequestsOf(pr, history),
326
+ cutOff: [
327
+ ...(rollup !== null && hasMore(rollup, "contexts") ? [`the first ${CHECKS_READ} checks`] : []),
328
+ ...(hasMore(pr, "reviewThreads") ? [`the first ${THREADS_READ} threads`] : [])
329
+ ],
296
330
  commits: history.map(({ oid }) => oid),
297
331
  lastRead: lastReadOf(history, head),
298
332
  marks: readMarksOf(history)
@@ -1,7 +1,7 @@
1
1
  // The permission rules the setup writes: the lease exception and the irreversible set for the user
2
2
  // file, and a repository's deny backstop for a machine without jq.
3
- import { spawnSync } from "node:child_process";
4
3
  import { DEFAULT_GUARDRAILS, matchesEnvGlob, resolveConfig } from "./agentic-config.js";
4
+ import { exec } from "./exec.js";
5
5
  /** The auto-mode exception that lets a lease push through the classifier's default force-push block. */
6
6
  export const LEASE_RULE = "Force push with --force-with-lease or --force-if-includes to a branch that is not master, main or production: allowed. The guardrails plugin's hook refuses a bare --force, -f or +refspec and any force to a protected branch, so the lease form is the safe one and needs no prompt.";
7
7
  /** The irreversible set for the user file; `git stash clear` also in the rtk form a permission rule sees once rtk rewrites it. */
@@ -57,7 +57,7 @@ const GH_DENY = ["gh repo delete", "gh repo delete *"];
57
57
  export const RETIRED_DENY = ["Bash(git branch -D *)", "Bash(rtk git branch -D *)"];
58
58
  const ENV_NAME = /^\.env(\..+)?$|^\.envrc$/;
59
59
  function git(root, args) {
60
- const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
60
+ const result = exec("git", ["-C", root, ...args]);
61
61
  return result.status === 0 ? result.stdout.trim() : undefined;
62
62
  }
63
63
  function listedIn(globs, file) {
@@ -1,8 +1,8 @@
1
1
  // What a Sweep reads of a repository's linked worktrees: where each lies, what it has checked out, its lock,
2
2
  // the files that hold it and the operation paused in it.
3
- import { spawnSync } from "node:child_process";
4
3
  import { existsSync } from "node:fs";
5
4
  import { join } from "node:path";
5
+ import { exec } from "./exec.js";
6
6
  import { repositoryFreeEnv } from "./git-env.js";
7
7
  import { CannotRun } from "./pr/read.js";
8
8
  import { asked, git } from "./working-tree.js";
@@ -41,7 +41,7 @@ export function worktreesOf(root) {
41
41
  /** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
42
42
  export function dirtyFiles(path) {
43
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 });
44
+ const result = exec("git", args, { env: repositoryFreeEnv() });
45
45
  if (result.status !== 0) {
46
46
  const said = result.stderr.split("\n").find((line) => line.trim() !== "");
47
47
  return { error: `git status: ${said?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
@@ -1,6 +1,6 @@
1
1
  // What the `worker` commands share: `hc` and `herdr-app` as child processes, the Lead a command acts for,
2
2
  // and the Workers herdr lists for it.
3
- import { spawnSync } from "node:child_process";
3
+ import { exec, TimedOut } from "../exec.js";
4
4
  import { isObject, parseObject } from "../json-text.js";
5
5
  /** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
6
6
  export class Refusal extends Error {
@@ -15,9 +15,21 @@ export function said(called) {
15
15
  const line = `${called.stderr}\n${called.stdout}`.split("\n").find((text) => text.trim() !== "");
16
16
  return line?.trim() ?? `exited ${called.status}`;
17
17
  }
18
+ /** `tool args` as a child process; past its limit it stops the command as any failed call does. */
19
+ function reach(tool, args) {
20
+ try {
21
+ return exec(tool, args);
22
+ }
23
+ catch (error) {
24
+ if (error instanceof TimedOut) {
25
+ throw new Refusal(error.message, "check herdr is responding, then call again");
26
+ }
27
+ throw error;
28
+ }
29
+ }
18
30
  /** `tool args` as a child process; a tool that is not on PATH stops the command. */
19
31
  export function call(tool, args) {
20
- const result = spawnSync(tool, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
32
+ const result = reach(tool, args);
21
33
  if (result.error !== undefined) {
22
34
  if ("code" in result.error && result.error.code === "ENOENT") {
23
35
  throw new Refusal(`${tool} is not on PATH`, "install herdr, or run the command in a terminal herdr started");
@@ -1,18 +1,15 @@
1
1
  // A checkout's working tree as git would commit it: the tree of every file git tracks or would track,
2
2
  // hashed on a copy of the index. The Gate's cache fingerprints it and the watch for Drift records it.
3
- import { spawnSync } from "node:child_process";
4
3
  import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
5
4
  import { tmpdir } from "node:os";
6
5
  import { join } from "node:path";
6
+ import { exec } from "./exec.js";
7
7
  import { repositoryFreeEnv } from "./git-env.js";
8
8
  /** `git <args>` in the repository at `root`, on `index` when given. */
9
9
  export function git(root, args, index) {
10
10
  const env = repositoryFreeEnv();
11
- const result = spawnSync("git", ["-C", root, ...args], {
12
- encoding: "utf8",
13
- env: index === undefined ? env : { ...env, GIT_INDEX_FILE: index },
14
- // A Step that writes thousands of files makes a diff-tree past the default 1 MiB.
15
- maxBuffer: 64 * 1024 * 1024
11
+ const result = exec("git", ["-C", root, ...args], {
12
+ env: index === undefined ? env : { ...env, GIT_INDEX_FILE: index }
16
13
  });
17
14
  if (result.status === 0) {
18
15
  return { out: result.stdout.trim() };
@@ -1,7 +1,7 @@
1
1
  // What a worktree's lock means to a Sweep. Claude Code locks the worktree of a session with the reason
2
2
  // `claude session <name> (pid <N> start <time>)`, <time> as `ps -o lstart` writes it in UTC. Such a lock is
3
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";
4
+ import { exec } from "./exec.js";
5
5
  import { shellLine } from "./pr/decide.js";
6
6
  const CLAUDE_LOCK = /^claude session .* \(pid (?<pid>\d{1,10}) start (?<start>.+)\)$/;
7
7
  const MAX_PID = 2 ** 31 - 1;
@@ -22,7 +22,7 @@ function runningOf(pid) {
22
22
  const starts = [{ TZ: "UTC" }, {}]
23
23
  .map((zone) => {
24
24
  const env = { ...process.env, LC_ALL: "C", ...zone };
25
- const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env });
25
+ const result = exec("ps", ["-o", "lstart=", "-p", String(pid)], { env });
26
26
  return result.status === 0 ? squeezed(result.stdout) : "";
27
27
  })
28
28
  .filter((start) => start !== "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.14.0",
3
+ "version": "0.15.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": {