dw-sdlc 0.20.0 → 0.21.1

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.
Files changed (97) hide show
  1. package/README.md +13 -13
  2. package/config/agentic.config.schema.json +1 -1
  3. package/dist/agentic-config.d.ts +16 -0
  4. package/dist/agentic-config.js +28 -23
  5. package/dist/cannot-run.d.ts +27 -0
  6. package/dist/cannot-run.js +48 -0
  7. package/dist/catalogue.js +2 -1
  8. package/dist/checks/agents-map.js +7 -8
  9. package/dist/checks/check.d.ts +10 -8
  10. package/dist/checks/check.js +9 -7
  11. package/dist/checks/decision-records.js +3 -2
  12. package/dist/checks/file-size-ratchet.js +13 -17
  13. package/dist/checks/index.d.ts +6 -5
  14. package/dist/checks/index.js +10 -9
  15. package/dist/checks/markdown-refs.js +58 -6
  16. package/dist/checks/primitives.js +5 -5
  17. package/dist/checks/routed-docs.js +1 -3
  18. package/dist/checks/skill-sections.js +12 -20
  19. package/dist/checks/tree.js +7 -7
  20. package/dist/cli.d.ts +2 -1
  21. package/dist/cli.js +26 -5
  22. package/dist/commands/check.js +27 -42
  23. package/dist/commands/config.js +22 -27
  24. package/dist/commands/doctor.d.ts +2 -0
  25. package/dist/commands/doctor.js +11 -18
  26. package/dist/commands/gate.js +35 -40
  27. package/dist/commands/lead-start.js +28 -42
  28. package/dist/commands/pr-reviewed.js +41 -59
  29. package/dist/commands/pr-ship.js +72 -87
  30. package/dist/commands/primitives.js +38 -40
  31. package/dist/commands/recap.js +23 -74
  32. package/dist/commands/references-setup.js +28 -76
  33. package/dist/commands/references-sync.js +29 -53
  34. package/dist/commands/setup.d.ts +2 -7
  35. package/dist/commands/setup.js +38 -90
  36. package/dist/commands/sweep.js +46 -157
  37. package/dist/commands/worker-ls.js +15 -31
  38. package/dist/commands/worker-report.js +36 -52
  39. package/dist/commands/worker-start.js +43 -57
  40. package/dist/drift.js +2 -1
  41. package/dist/exec.d.ts +20 -0
  42. package/dist/exec.js +37 -4
  43. package/dist/gate-cache.js +2 -1
  44. package/dist/git.d.ts +32 -0
  45. package/dist/git.js +60 -0
  46. package/dist/json-schema.js +1 -3
  47. package/dist/landed.js +11 -22
  48. package/dist/paused-operation.d.ts +2 -0
  49. package/dist/paused-operation.js +17 -0
  50. package/dist/pins.js +2 -1
  51. package/dist/pr/decide.d.ts +4 -16
  52. package/dist/pr/decide.js +48 -83
  53. package/dist/pr/delta.d.ts +3 -4
  54. package/dist/pr/delta.js +11 -20
  55. package/dist/pr/read.d.ts +27 -30
  56. package/dist/pr/read.js +96 -158
  57. package/dist/pr/reply.d.ts +34 -0
  58. package/dist/pr/reply.js +112 -0
  59. package/dist/pr/route.d.ts +11 -3
  60. package/dist/pr/route.js +5 -5
  61. package/dist/pr/standing.d.ts +138 -0
  62. package/dist/pr/standing.js +181 -0
  63. package/dist/pr/state.d.ts +14 -69
  64. package/dist/pr/state.js +75 -148
  65. package/dist/pr/upstream.d.ts +1 -6
  66. package/dist/pr/upstream.js +2 -2
  67. package/dist/primitives-diff.js +1 -1
  68. package/dist/recap-next.js +34 -47
  69. package/dist/recap-primitives.js +1 -1
  70. package/dist/references-key.d.ts +0 -1
  71. package/dist/references-key.js +3 -4
  72. package/dist/run-command.d.ts +4 -1
  73. package/dist/run-command.js +32 -11
  74. package/dist/setup-gate.d.ts +1 -7
  75. package/dist/setup-rules.js +2 -6
  76. package/dist/setup-writer.d.ts +32 -0
  77. package/dist/setup-writer.js +60 -0
  78. package/dist/shell-line.d.ts +2 -0
  79. package/dist/shell-line.js +5 -0
  80. package/dist/sweep-removal.d.ts +31 -0
  81. package/dist/sweep-removal.js +133 -0
  82. package/dist/sweep-terminals.d.ts +1 -1
  83. package/dist/sweep-terminals.js +21 -57
  84. package/dist/sweep-trees.d.ts +19 -3
  85. package/dist/sweep-trees.js +67 -31
  86. package/dist/worker/herdr.d.ts +67 -23
  87. package/dist/worker/herdr.js +126 -77
  88. package/dist/worker/launch.js +32 -45
  89. package/dist/worker/origin.d.ts +1 -1
  90. package/dist/worker/origin.js +9 -11
  91. package/dist/worker/tabs.js +11 -7
  92. package/dist/working-tree.d.ts +0 -8
  93. package/dist/working-tree.js +6 -22
  94. package/dist/worktree-lock.js +1 -1
  95. package/package.json +1 -1
  96. package/dist/git-env.d.ts +0 -2
  97. package/dist/git-env.js +0 -10
@@ -1,66 +1,126 @@
1
- // What the `worker` commands share: `hc` and `herdr-app` as child processes, the Lead a command acts for,
2
- // and the Workers herdr lists for it.
3
- import { exec, TimedOut } from "../exec.js";
1
+ // herdr's CLI, `hc`, run from this one place, each reply parsed once into a typed value: a read that fails
2
+ // stops the command through `CannotRun` with hc's words, an action answers its refusal. Beside it:
3
+ // `herdr-app`, the Lead a command acts for, and the Workers herdr lists for it.
4
+ import { CannotRun } from "../cannot-run.js";
5
+ import { need, whyFailed } from "../exec.js";
4
6
  import { isObject, parseObject } from "../json-text.js";
5
- /** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
6
- export class Refusal extends Error {
7
- remedy;
8
- constructor(message, remedy) {
9
- super(message);
10
- this.remedy = remedy;
11
- }
7
+ const REMEDIES = {
8
+ missing: "install herdr, or run the command in a terminal herdr started",
9
+ stalled: "check herdr is responding, then call again"
10
+ };
11
+ /** The remedy for a reply in a shape this module does not read. */
12
+ const SHAPE = "check hc --version";
13
+ /** hc's exit when it has no such terminal. */
14
+ const NO_TERMINAL = 3;
15
+ /** `hc send`'s exit when the target sits on a permission or a question. */
16
+ const BLOCKED = 9;
17
+ /** `hc kill`'s exit when the terminal finished before the stop. */
18
+ const FINISHED = 11;
19
+ /** `hc args`; one not on PATH, or past its limit, stops the command. */
20
+ function hc(args) {
21
+ return need("hc", args, REMEDIES);
12
22
  }
13
- /** The first line of a tool's output that says anything, or the exit it ended with. */
14
- export function said(called) {
15
- const line = `${called.stderr}\n${called.stdout}`.split("\n").find((text) => text.trim() !== "");
16
- return line?.trim() ?? `exited ${called.status}`;
23
+ /** `herdr-app args`, the window's CLI; one not on PATH, or past its limit, stops the command. */
24
+ export function herdrApp(args) {
25
+ return need("herdr-app", args, REMEDIES);
17
26
  }
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);
27
+ /** The JSON object `hc <args>` prints, or why it printed none; `shown` names the call, as `hc get t3`. */
28
+ function answer(args, shown) {
29
+ const called = hc(args);
30
+ if (called.status !== 0) {
31
+ return { status: called.status, problem: `${shown} failed: ${whyFailed(called)}` };
22
32
  }
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;
33
+ const object = parseObject(called.stdout);
34
+ return object === undefined
35
+ ? { status: 0, problem: `${shown} answered something that is no JSON object`, remedy: SHAPE }
36
+ : { object };
37
+ }
38
+ /** The object `answered` holds; a call that answered none stops the command. */
39
+ function objectOf(answered) {
40
+ if ("object" in answered) {
41
+ return answered.object;
28
42
  }
43
+ throw new CannotRun(answered.problem, answered.remedy);
29
44
  }
30
- /** `tool args` as a child process; a tool that is not on PATH stops the command. */
31
- export function call(tool, args) {
32
- const result = reach(tool, args);
33
- if (result.error !== undefined) {
34
- if ("code" in result.error && result.error.code === "ENOENT") {
35
- throw new Refusal(`${tool} is not on PATH`, "install herdr, or run the command in a terminal herdr started");
36
- }
37
- throw result.error;
45
+ /** A string that says something, else undefined. */
46
+ function word(value) {
47
+ return typeof value === "string" && value !== "" ? value : undefined;
48
+ }
49
+ function listedOf(entry) {
50
+ const address = isObject(entry) ? word(entry.address) : undefined;
51
+ if (!isObject(entry) || address === undefined || typeof entry.state !== "string" || typeof entry.cwd !== "string") {
52
+ throw new CannotRun("hc ls answered a terminal without an address, state or folder", SHAPE);
38
53
  }
39
- return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
54
+ return {
55
+ address,
56
+ name: word(entry.name) ?? address,
57
+ parent: word(entry.parent),
58
+ state: entry.state,
59
+ status: word(entry.status),
60
+ folder: entry.cwd,
61
+ place: word(entry.where)
62
+ };
40
63
  }
41
- /** The JSON object `hc <args>` prints; a failing call or a reply that is no object stops the command. */
42
- export function hcObject(args) {
43
- const called = call("hc", args);
44
- if (called.status !== 0) {
45
- throw new Refusal(`hc ${args[0] ?? ""} failed: ${said(called)}`);
64
+ function readingOf({ state, status, labels, first_message: first, foreground }) {
65
+ const program = isObject(foreground) ? foreground : {};
66
+ return {
67
+ state: word(state),
68
+ status: isObject(status) ? word(status.state) : undefined,
69
+ space: isObject(labels) ? word(labels["herdr.space"]) : undefined,
70
+ firstMessage: isObject(first) && typeof first.sent === "boolean" ? { sent: first.sent, reason: word(first.reason) } : undefined,
71
+ folder: word(program.cwd),
72
+ pid: typeof program.pid === "number" ? program.pid : undefined
73
+ };
74
+ }
75
+ /** The terminals `hc ls` lists: the live ones, every one with `all`, only those labelled `label` when given. */
76
+ export function listTerminals(filter = {}) {
77
+ const all = filter.all === true ? ["--all"] : [];
78
+ const label = filter.label === undefined ? [] : ["--label", filter.label];
79
+ const { terminals } = objectOf(answer(["ls", "--json", ...all, ...label], "hc ls"));
80
+ if (!Array.isArray(terminals)) {
81
+ throw new CannotRun("hc ls answered no list of terminals", SHAPE);
46
82
  }
47
- const parsed = parseObject(called.stdout);
48
- if (parsed === undefined) {
49
- throw new Refusal(`hc ${args[0] ?? ""} answered something that is no JSON object`, "check hc --version");
83
+ return terminals.map(listedOf);
84
+ }
85
+ /** What `hc get` reports of the terminal at `address`, or `gone`, in hc's words, when herdr has no such terminal. */
86
+ export function readTerminal(address) {
87
+ const answered = answer(["get", address, "--json"], `hc get ${address}`);
88
+ if ("status" in answered && answered.status === NO_TERMINAL) {
89
+ return { gone: answered.problem };
50
90
  }
51
- return parsed;
91
+ return readingOf(objectOf(answered));
52
92
  }
53
- /** What an error says, whatever was thrown. */
54
- export function describe(error) {
55
- return error instanceof Error ? error.message : String(error);
93
+ /** Runs `argv` in a terminal of its own in the background, outliving its caller; the address hc gives it. */
94
+ export function startTerminal({ name, labels, cwd, argv }) {
95
+ const label = Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
96
+ const args = ["run", "--bg", "--json", "--name", name, ...label, "--outlive-parent", "--cwd", cwd, "--", ...argv];
97
+ const address = word(objectOf(answer(args, "hc run")).address);
98
+ if (address === undefined) {
99
+ throw new CannotRun("hc run answered no address for the terminal it started", "check hc ls before starting it again");
100
+ }
101
+ return address;
102
+ }
103
+ /** Stops the terminal at `address`: hc's words when it refuses, undefined once it is stopped or had finished. */
104
+ export function stopTerminal(address) {
105
+ const called = hc(["kill", address]);
106
+ return called.status === 0 || called.status === FINISHED
107
+ ? undefined
108
+ : `hc kill ${address} failed: ${whyFailed(called)}`;
109
+ }
110
+ /** Removes the terminal at `address`: hc's words when it refuses, undefined once it is gone. */
111
+ export function removeTerminal(address) {
112
+ const called = hc(["rm", address]);
113
+ return called.status === 0 || called.status === NO_TERMINAL
114
+ ? undefined
115
+ : `hc rm ${address} failed: ${whyFailed(called)}`;
56
116
  }
57
- /** Reports a `Refusal` of `command` on stderr and answers exit 2; any other error is a bug and goes on up. */
58
- export function refuse(command, error) {
59
- if (!(error instanceof Refusal)) {
60
- throw error;
117
+ /** Types `message` into the terminal at `address`: `sent`, `blocked` while it sits on a dialog, else hc's words. */
118
+ export function sendTo(address, message) {
119
+ const called = hc(["send", address, message]);
120
+ if (called.status === 0) {
121
+ return "sent";
61
122
  }
62
- process.stderr.write(`dw-sdlc ${command}: ${error.message}${error.remedy === undefined ? "" : ` — ${error.remedy}`}\n`);
63
- return 2;
123
+ return called.status === BLOCKED ? "blocked" : { refused: `hc send failed: ${whyFailed(called)}` };
64
124
  }
65
125
  /** The option `flag` as a whole number from `min`, `fallback` when the command line leaves it out. */
66
126
  export function wholeNumber(flag, value, fallback, min) {
@@ -73,7 +133,7 @@ export function wholeNumber(flag, value, fallback, min) {
73
133
  export function lead() {
74
134
  const id = process.env.HC_TERMINAL_ID;
75
135
  if (id === undefined || id === "") {
76
- throw new Refusal("outside herdr: $HC_TERMINAL_ID is not set", "run it in a terminal herdr started, where the Lead has an address");
136
+ throw new CannotRun("outside herdr: $HC_TERMINAL_ID is not set", "run it in a terminal herdr started, where the Lead has an address");
77
137
  }
78
138
  return id;
79
139
  }
@@ -82,41 +142,30 @@ export function isLive(worker) {
82
142
  return worker.state === "live";
83
143
  }
84
144
  /**
85
- * The Workers with the folder each live one works in, its worktree, which `hc get` reports as
86
- * `foreground.cwd` and `hc ls` leaves out. A Worker herdr cannot tell it for keeps its launch folder.
145
+ * The Workers with the folder each live one works in, its worktree, which `hc get` reports and `hc ls`
146
+ * leaves out. A Worker herdr cannot tell it for keeps its launch folder.
87
147
  */
88
148
  export function workingFolders(workers) {
89
149
  return workers.map((worker) => {
90
150
  if (!isLive(worker)) {
91
151
  return worker;
92
152
  }
93
- const got = call("hc", ["get", worker.address, "--json"]);
94
- const foreground = got.status === 0 ? parseObject(got.stdout)?.foreground : undefined;
95
- const cwd = isObject(foreground) ? foreground.cwd : undefined;
96
- return typeof cwd === "string" && cwd !== "" ? { ...worker, folder: cwd } : worker;
153
+ const answered = answer(["get", worker.address, "--json"], `hc get ${worker.address}`);
154
+ const folder = "object" in answered ? readingOf(answered.object).folder : undefined;
155
+ return folder === undefined ? worker : { ...worker, folder };
97
156
  });
98
157
  }
99
- function word(value) {
100
- return typeof value === "string" ? value : undefined;
101
- }
102
- /** The Workers `lead` started, in the order herdr lists them; `all` adds the finished ones. */
158
+ /** The Workers `leadId` started, in the order herdr lists them; `all` adds the finished ones. */
103
159
  export function workersOf(leadId, all) {
104
- const listing = hcObject(["ls", "--json", ...(all ? ["--all"] : []), "--label", "role=worker"]);
105
- const { terminals } = listing;
106
- if (!Array.isArray(terminals)) {
107
- throw new Refusal("hc ls answered no list of terminals", "check hc --version");
108
- }
109
- return terminals.flatMap((entry) => {
110
- if (!isObject(entry) || entry.parent !== leadId) {
111
- return [];
112
- }
113
- const { address, state, cwd } = entry;
114
- if (typeof address !== "string" || typeof state !== "string" || typeof cwd !== "string") {
115
- throw new Refusal("hc ls answered a terminal without an address, state or folder", "check hc --version");
116
- }
117
- const status = state === "finished" ? "finished" : (word(entry.status) ?? "unknown");
118
- return [{ name: word(entry.name) ?? address, address, state, status, folder: cwd }];
119
- });
160
+ return listTerminals({ all, label: "role=worker" })
161
+ .filter(({ parent }) => parent === leadId)
162
+ .map(({ name, address, state, status, folder }) => ({
163
+ name,
164
+ address,
165
+ state,
166
+ status: state === "finished" ? "finished" : (status ?? "unknown"),
167
+ folder
168
+ }));
120
169
  }
121
170
  /** A table of rows, every column but the last padded to its widest cell and two spaces apart. */
122
171
  export function table(rows) {
@@ -1,8 +1,8 @@
1
1
  // What `worker start` and `lead start` share: a session started in a herdr terminal of its own beside the
2
2
  // caller, its tab named, and the terminal read back until the session shows it took its first message.
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
- import { isObject } from "../json-text.js";
5
- import { describe, hcObject, Refusal } from "./herdr.js";
4
+ import { CannotRun } from "../cannot-run.js";
5
+ import { readTerminal, startTerminal } from "./herdr.js";
6
6
  import { nameTab } from "./tabs.js";
7
7
  /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
8
8
  export const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
@@ -12,42 +12,43 @@ export const TAB_WAIT = 10;
12
12
  const WORKING = new Set(["working", "blocked", "done"]);
13
13
  /** How long the read-back waits between two reads of the terminal. */
14
14
  const POLL_MS = 1000;
15
- /** The status a session reports, if it has reported one. */
16
- function reportedStatus(terminal) {
17
- const { status } = terminal;
18
- return isObject(status) && typeof status.state === "string" ? status.state : undefined;
19
- }
20
15
  /** Why the session, called `who`, can no longer take its first message, or undefined while it still may. */
21
- function failure(terminal, who) {
22
- const { first_message: first, state } = terminal;
23
- if (isObject(first) && first.sent === false) {
24
- return typeof first.reason === "string" ? first.reason : "herdr gave no reason";
16
+ function failure({ firstMessage, state, status }, who) {
17
+ if (firstMessage?.sent === false) {
18
+ return firstMessage.reason ?? "herdr gave no reason";
25
19
  }
26
20
  if (state !== "live") {
27
21
  return "the terminal has finished";
28
22
  }
29
- return reportedStatus(terminal) === "error" ? `the ${who} reports its status as error` : undefined;
23
+ return status === "error" ? `the ${who} reports its status as error` : undefined;
24
+ }
25
+ /** The terminal at `address` as `hc get` reports it, or why it could not be read. */
26
+ function readBack(address) {
27
+ try {
28
+ const reading = readTerminal(address);
29
+ return "gone" in reading ? { unread: reading.gone } : reading;
30
+ }
31
+ catch (error) {
32
+ if (error instanceof CannotRun) {
33
+ return { unread: error.message };
34
+ }
35
+ throw error;
36
+ }
30
37
  }
31
38
  /** Reads the terminal until the session shows work, for up to `wait` seconds; why it did not, or undefined once it did. */
32
39
  async function delivery(address, wait, who) {
33
40
  const deadline = Date.now() + wait * 1000;
34
41
  for (;;) {
35
- let terminal;
36
- try {
37
- terminal = hcObject(["get", address, "--json"]);
38
- }
39
- catch (error) {
40
- if (!(error instanceof Refusal)) {
41
- throw error;
42
- }
43
- // The session is running by now, so a failed read is a message not confirmed, never a start that did not happen.
44
- return `the terminal could not be read back: ${describe(error)}`;
42
+ const reading = readBack(address);
43
+ // The session is running by now, so a failed read is a message not confirmed, never a start that did not happen.
44
+ if ("unread" in reading) {
45
+ return `the terminal could not be read back: ${reading.unread}`;
45
46
  }
46
- const failed = failure(terminal, who);
47
+ const failed = failure(reading, who);
47
48
  if (failed !== undefined) {
48
49
  return failed;
49
50
  }
50
- if (WORKING.has(reportedStatus(terminal) ?? "")) {
51
+ if (WORKING.has(reading.status ?? "")) {
51
52
  return undefined;
52
53
  }
53
54
  if (Date.now() >= deadline) {
@@ -58,33 +59,19 @@ async function delivery(address, wait, who) {
58
59
  }
59
60
  /** The id of the herdr space the terminal `id` sits in, if it sits in one. */
60
61
  export function spaceOf(id) {
61
- const { labels } = hcObject(["get", id, "--json"]);
62
- const space = isObject(labels) ? labels["herdr.space"] : undefined;
63
- return typeof space === "string" ? space : undefined;
62
+ const reading = readTerminal(id);
63
+ if ("gone" in reading) {
64
+ throw new CannotRun(reading.gone);
65
+ }
66
+ return reading.space;
64
67
  }
65
68
  /**
66
69
  * Runs `hc run` once for `launch`, outliving its caller, then names the tab and reads the terminal back side
67
70
  * by side. It never sends the first message again; `reason` says why the session did not take it.
68
71
  */
69
72
  export async function launch(command, { name, role, space, cwd, argv, wait }) {
70
- const { address } = hcObject([
71
- "run",
72
- "--bg",
73
- "--json",
74
- "--name",
75
- name,
76
- "--label",
77
- `role=${role}`,
78
- ...(space === undefined ? [] : ["--label", `herdr.space=${space}`]),
79
- "--outlive-parent",
80
- "--cwd",
81
- cwd,
82
- "--",
83
- ...argv
84
- ]);
85
- if (typeof address !== "string") {
86
- throw new Refusal("hc run answered no address for the terminal it started", "check hc ls before starting it again");
87
- }
73
+ const labels = { role, ...(space === undefined ? {} : { "herdr.space": space }) };
74
+ const address = startTerminal({ name, labels, cwd, argv });
88
75
  // Side by side, so the wait for the tab overlaps the wait for the session to start.
89
76
  const naming = space === undefined ? Promise.resolve("none") : nameTab(address, name, Math.min(TAB_WAIT, wait));
90
77
  const [named, reason] = await Promise.all([naming, delivery(address, wait, role === "lead" ? "Lead" : "Worker")]);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Fetches origin's default branch, or every branch of origin when no default is known, into the remote-tracking
3
- * refs at `root`; a clone with no origin has nothing to fetch and is left alone. Throws a `Refusal` when the
3
+ * refs at `root`; a clone with no origin has nothing to fetch and is left alone. Throws `CannotRun` when the
4
4
  * fetch fails, since a Worker started on a stale base is the fault this prevents.
5
5
  */
6
6
  export declare function freshenOrigin(root: string, baseBranch: string): void;
@@ -1,10 +1,9 @@
1
1
  // Brings the clone's view of origin's default branch up to date before a Worker starts: Claude Code's
2
2
  // `--worktree` branches from the cached `origin/<default>` and fetches it only when the clone has gone a
3
3
  // day without a fetch, so a Worker could otherwise begin from a ref that is hours or days behind.
4
- import { exec, SLOW_LIMIT_MS, TimedOut } from "../exec.js";
5
- import { repositoryFreeEnv } from "../git-env.js";
6
- import { asked } from "../working-tree.js";
7
- import { Refusal } from "./herdr.js";
4
+ import { CannotRun } from "../cannot-run.js";
5
+ import { SLOW_LIMIT_MS, TimedOut } from "../exec.js";
6
+ import { asked, git } from "../git.js";
8
7
  const REMOTE_HEAD = "refs/remotes/origin/HEAD";
9
8
  const PREFIX = "refs/remotes/origin/";
10
9
  /** The branch to fetch: the one origin/HEAD names, else `baseBranch` of the config; undefined when neither names one. */
@@ -17,7 +16,7 @@ function defaultBranch(root, baseBranch) {
17
16
  }
18
17
  /**
19
18
  * Fetches origin's default branch, or every branch of origin when no default is known, into the remote-tracking
20
- * refs at `root`; a clone with no origin has nothing to fetch and is left alone. Throws a `Refusal` when the
19
+ * refs at `root`; a clone with no origin has nothing to fetch and is left alone. Throws `CannotRun` when the
21
20
  * fetch fails, since a Worker started on a stale base is the fault this prevents.
22
21
  */
23
22
  export function freshenOrigin(root, baseBranch) {
@@ -25,19 +24,18 @@ export function freshenOrigin(root, baseBranch) {
25
24
  return;
26
25
  }
27
26
  const branch = defaultBranch(root, baseBranch);
28
- const args = ["-C", root, "fetch", "--quiet", "origin", ...(branch === undefined ? [] : [branch])];
27
+ const args = ["fetch", "--quiet", "origin", ...(branch === undefined ? [] : [branch])];
29
28
  const fetched = `git fetch origin${branch === undefined ? "" : ` ${branch}`}`;
30
- const stale = (why) => new Refusal(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin; when origin/HEAD names a branch the remote no longer has, " +
29
+ const stale = (why) => new CannotRun(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin; when origin/HEAD names a branch the remote no longer has, " +
31
30
  "run `git remote set-head origin --auto`; then start the Worker again");
32
31
  let result;
33
32
  try {
34
- result = exec("git", args, { env: repositoryFreeEnv(), limitMs: SLOW_LIMIT_MS });
33
+ result = git(root, args, { limitMs: SLOW_LIMIT_MS });
35
34
  }
36
35
  catch (error) {
37
36
  throw error instanceof TimedOut ? stale(error.message) : error;
38
37
  }
39
- if (result.status !== 0) {
40
- const said = result.stderr.split("\n").find((line) => line.trim() !== "");
41
- throw stale(said?.trim() ?? `exited ${result.status ?? "on a signal"}`);
38
+ if ("error" in result) {
39
+ throw stale(result.said);
42
40
  }
43
41
  }
@@ -1,17 +1,19 @@
1
1
  // The tab of a Worker's terminal in the herdr window: `hc run` with a `herdr.space` label opens one that herdr
2
2
  // numbers, and `herdr-app` names it.
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
+ import { CannotRun } from "../cannot-run.js";
5
+ import { whyFailed } from "../exec.js";
4
6
  import { isObject, parseObject } from "../json-text.js";
5
- import { call, Refusal, said } from "./herdr.js";
7
+ import { herdrApp } from "./herdr.js";
6
8
  /** The tabs `herdr-app ls` shows, each with the addresses of its terminals. */
7
9
  function tabsOf() {
8
- const listed = call("herdr-app", ["ls"]);
10
+ const listed = herdrApp(["ls"]);
9
11
  if (listed.status !== 0) {
10
- throw new Refusal(`herdr-app ls failed: ${said(listed)}`);
12
+ throw new CannotRun(`herdr-app ls failed: ${whyFailed(listed)}`);
11
13
  }
12
14
  const parsed = parseObject(listed.stdout);
13
15
  if (parsed === undefined) {
14
- throw new Refusal("herdr-app ls answered no JSON object");
16
+ throw new CannotRun("herdr-app ls answered no JSON object");
15
17
  }
16
18
  const spaces = Array.isArray(parsed.spaces) ? parsed.spaces : [];
17
19
  return spaces
@@ -47,14 +49,16 @@ export async function nameTab(address, name, boundSeconds) {
47
49
  if (tab === undefined) {
48
50
  return { kept: `the tab of ${address} is not in herdr-app ls within ${boundSeconds} s, so it keeps herdr's name` };
49
51
  }
50
- const renamed = call("herdr-app", ["do", "tab.rename", "--id", tab.id, "--name", name]);
52
+ const renamed = herdrApp(["do", "tab.rename", "--id", tab.id, "--name", name]);
51
53
  if (renamed.status !== 0) {
52
- return { kept: `herdr-app could not rename the tab of ${address}: ${errorOf(renamed.stdout) ?? said(renamed)}` };
54
+ return {
55
+ kept: `herdr-app could not rename the tab of ${address}: ${errorOf(renamed.stdout) ?? whyFailed(renamed)}`
56
+ };
53
57
  }
54
58
  return "renamed";
55
59
  }
56
60
  catch (error) {
57
- if (error instanceof Refusal) {
61
+ if (error instanceof CannotRun) {
58
62
  return { kept: `the tab of ${address} keeps herdr's name: ${error.message}` };
59
63
  }
60
64
  throw error;
@@ -1,11 +1,3 @@
1
- /** `git <args>` in the repository at `root`, on `index` when given. */
2
- export declare function git(root: string, args: string[], index?: string): {
3
- out: string;
4
- } | {
5
- error: string;
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;
9
1
  /** The tree of the checkout at `root` and its git dir, or why it has none. */
10
2
  export declare function workingTree(root: string): {
11
3
  tree: string;
@@ -3,25 +3,8 @@
3
3
  import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { exec } from "./exec.js";
7
- import { repositoryFreeEnv } from "./git-env.js";
8
- /** `git <args>` in the repository at `root`, on `index` when given. */
9
- export function git(root, args, index) {
10
- const env = repositoryFreeEnv();
11
- const result = exec("git", ["-C", root, ...args], {
12
- env: index === undefined ? env : { ...env, GIT_INDEX_FILE: index }
13
- });
14
- if (result.status === 0) {
15
- return { out: result.stdout.trim() };
16
- }
17
- const reason = result.error?.message ?? result.stderr.split("\n").find((line) => line.trim() !== "");
18
- return { error: `git ${args[0] ?? ""}: ${reason?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
19
- }
20
- /** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
21
- export function asked(root, args) {
22
- const result = git(root, args);
23
- return "out" in result ? result.out : undefined;
24
- }
6
+ import { messageOf } from "./cannot-run.js";
7
+ import { git } from "./git.js";
25
8
  /**
26
9
  * The tree `git write-tree` hashes from a copy of the index after `git add -A` on it: the copy keeps the
27
10
  * stat cache, so only what changed is read again; identical content survives a commit, an amend or a
@@ -39,8 +22,9 @@ function treeOf(root, gitDir) {
39
22
  copyFileSync(real, index);
40
23
  utimesSync(index, atime, mtime);
41
24
  }
42
- const added = git(root, ["add", "-A"], index);
43
- return "error" in added ? added : git(root, ["write-tree"], index);
25
+ const scratchIndex = { env: { GIT_INDEX_FILE: index } };
26
+ const added = git(root, ["add", "-A"], scratchIndex);
27
+ return "error" in added ? added : git(root, ["write-tree"], scratchIndex);
44
28
  }
45
29
  finally {
46
30
  rmSync(scratch, { recursive: true, force: true });
@@ -57,6 +41,6 @@ export function workingTree(root) {
57
41
  return "error" in tree ? tree : { tree: tree.out, gitDir: gitDir.out };
58
42
  }
59
43
  catch (error) {
60
- return { error: error instanceof Error ? error.message : String(error) };
44
+ return { error: messageOf(error) };
61
45
  }
62
46
  }
@@ -2,7 +2,7 @@
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
4
  import { exec } from "./exec.js";
5
- import { shellLine } from "./pr/decide.js";
5
+ import { shellLine } from "./shell-line.js";
6
6
  const CLAUDE_LOCK = /^claude session .* \(pid (?<pid>\d{1,10}) start (?<start>.+)\)$/;
7
7
  const MAX_PID = 2 ** 31 - 1;
8
8
  const squeezed = (text) => text.trim().replace(/\s+/g, " ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "The Gate and the agentic config behind the sdlc Claude Code plugin: a bin with the Gate and its Checks, the setup, the doctor, the Sync of References, the Ship script that drives a pull request to merged, the Sweep, and Lead and Worker sessions in herdr; the resolver of a repository's .ai/agentic.config.json layers and its JSON Schema.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/dist/git-env.d.ts DELETED
@@ -1,2 +0,0 @@
1
- /** This process's environment without the variables that tie git to one repository, as git lists them. */
2
- export declare function repositoryFreeEnv(): NodeJS.ProcessEnv;
package/dist/git-env.js DELETED
@@ -1,10 +0,0 @@
1
- // The environment a git command runs under to find its repository from its own directory, as a git
2
- // hook's GIT_DIR or GIT_INDEX_FILE would otherwise aim it at the hook's repository.
3
- import { exec } from "./exec.js";
4
- let repositoryVars;
5
- /** This process's environment without the variables that tie git to one repository, as git lists them. */
6
- export function repositoryFreeEnv() {
7
- repositoryVars ??= new Set((exec("git", ["rev-parse", "--local-env-vars"]).stdout ?? "").split("\n").filter(Boolean));
8
- const vars = repositoryVars;
9
- return Object.fromEntries(Object.entries(process.env).filter(([name]) => !vars.has(name)));
10
- }