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,7 +1,8 @@
1
1
  // The candidates for the session after this one, as `dw-sdlc recap` reads them: the open issues an agent may
2
2
  // start, from one GraphQL request that also asks which issues the branch's pull request closes.
3
- import { isObject, parseObject } from "./json-text.js";
4
- import { CannotRun, gh, said } from "./pr/read.js";
3
+ import { CannotRun } from "./cannot-run.js";
4
+ import { gh, said } from "./pr/read.js";
5
+ import { child, childOrNull, integer, nodes, ReplyError, rootOf, string } from "./pr/reply.js";
5
6
  /** The label that marks an issue as ready for an agent to start. */
6
7
  export const READY_LABEL = "ready-for-agent";
7
8
  /** How many open issues the one read holds, oldest first, and how many issues the pull request closes. */
@@ -16,46 +17,33 @@ const QUERY = `query Backlog($owner: String!, $name: String!, $number: Int!, $cl
16
17
  }
17
18
  }`;
18
19
  const REMEDY = "check gh auth status and the network, then call again";
19
- function malformed(field) {
20
- return new CannotRun(`the backlog reply does not parse: ${field}`, "gh answered a shape dw-sdlc does not read; report it with the output of gh --version");
21
- }
22
- function nodesOf(value, field) {
23
- if (!isObject(value) || !Array.isArray(value.nodes)) {
24
- throw malformed(field);
25
- }
26
- return value.nodes;
27
- }
28
- function numberOf(value, field) {
29
- if (!isObject(value) || typeof value.number !== "number") {
30
- throw malformed(field);
31
- }
32
- return value.number;
33
- }
20
+ const UNREAD_SHAPE = "gh answered a shape dw-sdlc does not read; report it with the output of gh --version";
34
21
  /** An issue node as a candidate, or null when it has an assignee or an open blocker. */
35
22
  function candidateOf(node) {
36
- if (!isObject(node) || typeof node.title !== "string" || typeof node.url !== "string") {
37
- throw malformed("issues.nodes");
38
- }
39
- const { assignees, issueDependenciesSummary: dependencies, parent } = node;
40
- if (!isObject(assignees) || typeof assignees.totalCount !== "number") {
41
- throw malformed("issues.nodes.assignees");
42
- }
43
- if (!isObject(dependencies) || typeof dependencies.blockedBy !== "number") {
44
- throw malformed("issues.nodes.issueDependenciesSummary");
45
- }
46
- if (assignees.totalCount > 0 || dependencies.blockedBy > 0) {
23
+ const title = string(node, "title");
24
+ const url = string(node, "url");
25
+ const assignees = integer(child(node, "assignees"), "totalCount");
26
+ const blockedBy = integer(child(node, "issueDependenciesSummary"), "blockedBy");
27
+ if (assignees > 0 || blockedBy > 0) {
47
28
  return null;
48
29
  }
49
- const number = numberOf(node, "issues.nodes.number");
50
- return { number, title: node.title, url: node.url, parent: parent === null ? null : numberOf(parent, "parent") };
30
+ const parent = childOrNull(node, "parent");
31
+ return { number: integer(node, "number"), title, url, parent: parent === null ? null : integer(parent, "number") };
51
32
  }
52
- function repositoryOf(stdout) {
53
- const data = parseObject(stdout)?.data;
54
- const repository = isObject(data) ? data.repository : undefined;
55
- if (!isObject(repository)) {
56
- throw malformed("data.repository");
57
- }
58
- return repository;
33
+ /**
34
+ * The candidates the backlog `reply` holds, the sub-issues of an issue the pull request closes first, each
35
+ * part in issue order. The issues it closes are read only when the query asked for them, `closing`.
36
+ */
37
+ function candidatesIn(reply, closing) {
38
+ const repository = child(child(rootOf(reply, "the backlog reply"), "data"), "repository");
39
+ const closed = closing ? nodes(child(repository, "pullRequest"), "closingIssuesReferences") : [];
40
+ const closes = new Set(closed.map((node) => integer(node, "number")));
41
+ const candidates = nodes(repository, "issues")
42
+ .map(candidateOf)
43
+ .filter((candidate) => candidate !== null && !closes.has(candidate.number))
44
+ .toSorted((one, other) => one.number - other.number);
45
+ const underClosed = (candidate) => candidate.parent !== null && closes.has(candidate.parent);
46
+ return [...candidates.filter(underClosed), ...candidates.filter((candidate) => !underClosed(candidate))];
59
47
  }
60
48
  /**
61
49
  * The open issues labelled ready-for-agent with no assignee and no open blocker, read from `repo`, or from the
@@ -81,16 +69,15 @@ function nextCandidates(pr) {
81
69
  if (reply.status !== 0) {
82
70
  throw new CannotRun(`gh could not read the backlog: ${said(reply.stderr)}`, REMEDY);
83
71
  }
84
- const repository = repositoryOf(reply.stdout);
85
- const closes = new Set(pr === null
86
- ? []
87
- : nodesOf(isObject(repository.pullRequest) ? repository.pullRequest.closingIssuesReferences : undefined, "pullRequest").map((node) => numberOf(node, "closingIssuesReferences.nodes")));
88
- const candidates = nodesOf(repository.issues, "issues")
89
- .map(candidateOf)
90
- .filter((candidate) => candidate !== null && !closes.has(candidate.number))
91
- .toSorted((one, other) => one.number - other.number);
92
- const underClosed = (candidate) => candidate.parent !== null && closes.has(candidate.parent);
93
- return [...candidates.filter(underClosed), ...candidates.filter((candidate) => !underClosed(candidate))];
72
+ try {
73
+ return candidatesIn(reply.stdout, pr !== null);
74
+ }
75
+ catch (error) {
76
+ if (error instanceof ReplyError) {
77
+ throw new CannotRun(`the backlog reply does not parse: ${error.message}`, UNREAD_SHAPE);
78
+ }
79
+ throw error;
80
+ }
94
81
  }
95
82
  export function readNext(pr) {
96
83
  try {
@@ -3,8 +3,8 @@
3
3
  // the default branch and a repository with no catalogue roll up to none.
4
4
  import { resolveConfig } from "./agentic-config.js";
5
5
  import { catalogueFile } from "./checks/primitives.js";
6
+ import { asked } from "./git.js";
6
7
  import { primitivesDiff } from "./primitives-diff.js";
7
- import { asked } from "./working-tree.js";
8
8
  /** `defaultOf` names the default branch and is asked only once the repository has a catalogue and `branch` is no base. */
9
9
  export function rollup(root, branch, defaultOf) {
10
10
  const { guardrails, gate } = resolveConfig(root).config;
@@ -15,7 +15,6 @@ export type KeyProblem = {
15
15
  path: string;
16
16
  message: string;
17
17
  };
18
- export declare function isObject(value: unknown): value is Record<string, unknown>;
19
18
  /** The References `value` declares, or the first problem in it, named by its path under the key. */
20
19
  export declare function parseReferences(value: unknown): {
21
20
  references: Reference[];
@@ -3,6 +3,8 @@
3
3
  // and `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
+ import { messageOf } from "./cannot-run.js";
7
+ import { isObject } from "./json-text.js";
6
8
  /** The committed file the References are read from; no local layer adds to it. */
7
9
  export const REFERENCES_FILE = ".ai/references.json";
8
10
  /** The committed agentic config: a `references` key in it is refused, so the file stays the one list. */
@@ -30,9 +32,6 @@ function kind(value) {
30
32
  }
31
33
  return typeof value === "object" ? "an object" : `a ${typeof value}`;
32
34
  }
33
- export function isObject(value) {
34
- return typeof value === "object" && value !== null && !Array.isArray(value);
35
- }
36
35
  function nonEmpty(value, path) {
37
36
  if (typeof value !== "string") {
38
37
  throw new KeyError(path, `must be a string, not ${kind(value)}`);
@@ -153,7 +152,7 @@ function readObject(path, name) {
153
152
  value = JSON.parse(readFileSync(path, "utf8"));
154
153
  }
155
154
  catch (error) {
156
- return { problem: `${name}: not JSON: ${error instanceof Error ? error.message : String(error)}` };
155
+ return { problem: `${name}: not JSON: ${messageOf(error)}` };
157
156
  }
158
157
  return isObject(value) ? { value } : { problem: `${name}: not a JSON object` };
159
158
  }
@@ -17,7 +17,10 @@ export type Ran = {
17
17
  tail: string[] | null;
18
18
  };
19
19
  export declare const TAIL_LINES = 50;
20
- /** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
20
+ /**
21
+ * Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate
22
+ * reports it, and SIGHUP passed on to the command.
23
+ */
21
24
  export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
22
25
  /** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
23
26
  export declare function stoppedBy(): NodeJS.Signals | undefined;
@@ -1,39 +1,58 @@
1
- // How the Gate runs one command, a Step or a `gate.checks.run` script: through `sh -c` from the
2
- // repository root, stdin closed, its output captured or passed through, and what it answered; and
3
- // how SIGINT and SIGTERM stop the command running and every command after it.
1
+ // How the Gate runs one command, a Step or a `gate.checks.run` script: `sh -c` from the repository
2
+ // root in a process group of its own, stdin closed, output captured or passed through; and how SIGINT
3
+ // and SIGTERM stop it, every process it started and every command after it.
4
4
  import { spawn } from "node:child_process";
5
5
  import { closeSync, fstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { signalGroup } from "./exec.js";
8
9
  export const TAIL_LINES = 50;
9
10
  /** What the shell answers for a command it cannot find, and for one it cannot execute. */
10
11
  const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
11
12
  const STOPPING = ["SIGINT", "SIGTERM"];
12
- /** The command running, which a signal the Gate gets is passed on to. */
13
+ /** The process group the running command leads, which a signal the Gate gets is passed on to. */
13
14
  let running;
14
15
  /** The first SIGINT or SIGTERM the Gate got; no command starts after it. */
15
16
  let received;
16
17
  /**
17
- * The SIGINT or SIGTERM that ended a command; no command starts after it either. Ctrl-C reaches the
18
- * command and the Gate together, and the command's exit can be seen before the Gate's own signal.
18
+ * The SIGINT or SIGTERM that ended a command; no command starts after it either. A signal sent to
19
+ * the command and the Gate together can show in the command's exit before the Gate's own handler runs.
19
20
  */
20
21
  let commandEndedBy;
21
22
  function release() {
22
23
  for (const signal of STOPPING) {
23
24
  process.off(signal, stop);
24
25
  }
26
+ process.off("SIGHUP", hangUp);
27
+ }
28
+ /** Sends `signal` to every process of the running command's group. */
29
+ function signalCommand(signal) {
30
+ if (running !== undefined) {
31
+ signalGroup(running, signal);
32
+ }
25
33
  }
26
34
  function stop(signal) {
27
35
  if (received !== undefined) {
28
- // A second signal ends the Gate at once, as it would with no handler: the command ignored the first.
36
+ // A second signal ends the Gate at once, as it would with no handler, and kills the command, which
37
+ // ignored the first.
38
+ signalCommand("SIGKILL");
29
39
  release();
30
40
  process.kill(process.pid, signal);
31
41
  return;
32
42
  }
33
43
  received = signal;
34
- running?.kill(signal);
44
+ signalCommand(signal);
45
+ }
46
+ /** A hangup ends the Gate as with no handler, passed on first: a terminal's hangup misses the command's session. */
47
+ function hangUp() {
48
+ signalCommand("SIGHUP");
49
+ release();
50
+ process.kill(process.pid, "SIGHUP");
35
51
  }
36
- /** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
52
+ /**
53
+ * Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate
54
+ * reports it, and SIGHUP passed on to the command.
55
+ */
37
56
  export async function holdingSignals(body) {
38
57
  running = undefined;
39
58
  received = undefined;
@@ -42,6 +61,7 @@ export async function holdingSignals(body) {
42
61
  for (const signal of STOPPING) {
43
62
  process.on(signal, stop);
44
63
  }
64
+ process.on("SIGHUP", hangUp);
45
65
  try {
46
66
  return await body();
47
67
  }
@@ -104,8 +124,9 @@ function run(command, root, stdio) {
104
124
  running = undefined;
105
125
  resolve({ ...answered, seconds: (performance.now() - started) / 1000 });
106
126
  };
107
- const child = spawn(command, { shell: true, cwd: root, stdio });
108
- running = child;
127
+ // A session of its own makes the command lead a process group, which every process it starts joins.
128
+ const child = spawn(command, { shell: true, cwd: root, stdio, detached: true });
129
+ running = child.pid;
109
130
  // A signal that cannot be delivered is an error too; only a child with no pid never started.
110
131
  child.on("error", (error) => {
111
132
  if (child.pid === undefined) {
@@ -1,11 +1,5 @@
1
1
  import type { NodePackageManager } from "./agentic-config.ts";
2
- /** A file's text after the setup and what writing it does, or why the setup leaves the file alone. */
3
- export type Change = {
4
- text: string;
5
- detail?: string;
6
- } | {
7
- refusal: string;
8
- };
2
+ import type { Change } from "./setup-writer.ts";
9
3
  /**
10
4
  * package.json with the package pinned exactly as a devDependency, in name order, where neither list
11
5
  * names it. A new dependency needs the owner's yes, so without `add` the file is refused.
@@ -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
3
  import { DEFAULT_GUARDRAILS, matchesEnvGlob, resolveConfig } from "./agentic-config.js";
4
- import { exec } from "./exec.js";
4
+ import { asked } from "./git.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. */
@@ -56,10 +56,6 @@ const GH_DENY = ["gh repo delete", "gh repo delete *"];
56
56
  */
57
57
  export const RETIRED_DENY = ["Bash(git branch -D *)", "Bash(rtk git branch -D *)"];
58
58
  const ENV_NAME = /^\.env(\..+)?$|^\.envrc$/;
59
- function git(root, args) {
60
- const result = exec("git", ["-C", root, ...args]);
61
- return result.status === 0 ? result.stdout.trim() : undefined;
62
- }
63
59
  function listedIn(globs, file) {
64
60
  return globs.some((glob) => matchesEnvGlob(glob, file));
65
61
  }
@@ -67,7 +63,7 @@ function isEnvName(file) {
67
63
  return ENV_NAME.test(file.slice(file.lastIndexOf("/") + 1));
68
64
  }
69
65
  function gitFiles(root, ...flags) {
70
- const listed = git(root, ["-c", "core.quotePath=false", "ls-files", ...flags, "--exclude-standard"]) ?? "";
66
+ const listed = asked(root, ["-c", "core.quotePath=false", "ls-files", ...flags, "--exclude-standard"]) ?? "";
71
67
  return listed.split("\n").filter((file) => file !== "");
72
68
  }
73
69
  /**
@@ -0,0 +1,32 @@
1
+ /** `by hand` is an edit the setup names and leaves to the user; `skipped` is a file it has no reason to write. */
2
+ export type Outcome = "wrote" | "unchanged" | "skipped" | "refused" | "by hand";
3
+ export type Step = {
4
+ file: string;
5
+ outcome: Outcome;
6
+ detail?: string;
7
+ };
8
+ /** A file's text after the step and what writing it does, or why the step leaves the file alone. */
9
+ export type Change = {
10
+ text: string;
11
+ detail?: string | undefined;
12
+ } | {
13
+ refusal: string;
14
+ };
15
+ export declare class Writer {
16
+ readonly steps: Step[];
17
+ private readonly dryRun;
18
+ constructor(dryRun: boolean);
19
+ private record;
20
+ /**
21
+ * The file at `path`, recorded under `label`, as `change` leaves it, or as it leaves the text the file
22
+ * holds now: written when the text differs, unchanged when it does not, else refused.
23
+ */
24
+ apply(label: string, path: string, change: Change | ((current: string | undefined) => Change)): void;
25
+ /** The file at `path`, made by `make` when nothing sits there, a dangling symlink included; never replaced. */
26
+ create(label: string, path: string, make: (path: string) => void): void;
27
+ refuse(label: string, detail: string): void;
28
+ skip(label: string, detail: string): void;
29
+ byHand(label: string, detail: string): void;
30
+ }
31
+ /** One line a step: what it did, or would do under `dryRun`, the file, and why where it says. */
32
+ export declare function stepLines(steps: Step[], dryRun: boolean): string[];
@@ -0,0 +1,60 @@
1
+ // How `dw-sdlc setup` and `dw-sdlc references setup` write a file: only when its text would change,
2
+ // nothing under --dry-run, and one line a file saying what the step did or would do.
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ export class Writer {
6
+ steps = [];
7
+ dryRun;
8
+ constructor(dryRun) {
9
+ this.dryRun = dryRun;
10
+ }
11
+ record(file, outcome, detail) {
12
+ this.steps.push(detail === undefined ? { file, outcome } : { file, outcome, detail });
13
+ }
14
+ /**
15
+ * The file at `path`, recorded under `label`, as `change` leaves it, or as it leaves the text the file
16
+ * holds now: written when the text differs, unchanged when it does not, else refused.
17
+ */
18
+ apply(label, path, change) {
19
+ const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
20
+ const next = typeof change === "function" ? change(current) : change;
21
+ if ("refusal" in next) {
22
+ this.record(label, "refused", next.refusal);
23
+ return;
24
+ }
25
+ if (next.text === current) {
26
+ this.record(label, "unchanged", undefined);
27
+ return;
28
+ }
29
+ if (!this.dryRun) {
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ writeFileSync(path, next.text);
32
+ }
33
+ this.record(label, "wrote", next.detail);
34
+ }
35
+ /** The file at `path`, made by `make` when nothing sits there, a dangling symlink included; never replaced. */
36
+ create(label, path, make) {
37
+ const absent = lstatSync(path, { throwIfNoEntry: false }) === undefined;
38
+ if (absent && !this.dryRun) {
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ make(path);
41
+ }
42
+ this.record(label, absent ? "wrote" : "unchanged", undefined);
43
+ }
44
+ refuse(label, detail) {
45
+ this.record(label, "refused", detail);
46
+ }
47
+ skip(label, detail) {
48
+ this.record(label, "skipped", detail);
49
+ }
50
+ byHand(label, detail) {
51
+ this.record(label, "by hand", detail);
52
+ }
53
+ }
54
+ /** One line a step: what it did, or would do under `dryRun`, the file, and why where it says. */
55
+ export function stepLines(steps, dryRun) {
56
+ return steps.map(({ file, outcome, detail }) => {
57
+ const verb = outcome === "wrote" && dryRun ? "would write" : outcome;
58
+ return `${verb.padEnd(11)} ${file}${detail === undefined ? "" : ` — ${detail}`}`;
59
+ });
60
+ }
@@ -0,0 +1,2 @@
1
+ /** The words as one POSIX shell command line, each word that needs it single-quoted. */
2
+ export declare function shellLine(words: string[]): string;
@@ -0,0 +1,5 @@
1
+ // A command as the line a dev pastes into a POSIX shell: what an answer hands the dev to run.
2
+ /** The words as one POSIX shell command line, each word that needs it single-quoted. */
3
+ export function shellLine(words) {
4
+ return words.map((word) => (/^[\w./:=@%+,-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`)).join(" ");
5
+ }
@@ -0,0 +1,31 @@
1
+ import type { StaleLock } from "./worktree-lock.ts";
2
+ export type Item = {
3
+ kind: "branch" | "worktree" | "lock" | "terminal";
4
+ name: string;
5
+ reason: string;
6
+ };
7
+ export type Kept = Item & {
8
+ command: string | null;
9
+ dev: boolean;
10
+ };
11
+ /**
12
+ * An item to remove: `at` the commit it was judged at, a worktree's HEAD or a branch's tip; a worktree's
13
+ * `holds` branch, `stale` lock, `workerLock` its Worker's program holds; a terminal's `stop`, `tree`.
14
+ */
15
+ export type Planned = Item & {
16
+ at?: string;
17
+ holds?: string;
18
+ stale?: StaleLock;
19
+ stop?: boolean;
20
+ tree?: string;
21
+ workerLock?: {
22
+ pid: number;
23
+ };
24
+ };
25
+ export type Judged = {
26
+ base: string;
27
+ removed: Planned[];
28
+ kept: Kept[];
29
+ };
30
+ /** Removes each item judged to go, in order; a worktree that stays keeps the branch it holds as well. */
31
+ export declare function apply({ base, removed, kept }: Judged, root: string): Judged;
@@ -0,0 +1,133 @@
1
+ // How a Sweep carries out what it judged: each item it plans to remove, removed in order, and the one git or
2
+ // hc refuses, or that outlives its time limit, kept with why. docs/adr/0015 sets the bounds: it never forces.
3
+ import { SLOW_LIMIT_MS, TimedOut } from "./exec.js";
4
+ import { git } from "./git.js";
5
+ import { byHand, lockAfterStop, takeDown } from "./sweep-terminals.js";
6
+ import { forcedDelete, forcedRemoval, tipOf, unlockCommand, whyUnclean, worktreesOf } from "./sweep-trees.js";
7
+ /** Why the worktree `item` stays after all, read again just before it goes: what changed since it was judged. */
8
+ function whyChanged(root, item) {
9
+ const now = worktreesOf(root).find(({ path }) => path === item.name);
10
+ if (now === undefined) {
11
+ return { reason: "git no longer lists it", command: null };
12
+ }
13
+ if (now.head !== item.at) {
14
+ return { reason: `its HEAD moved to ${now.head.slice(0, 9)} since the Sweep judged it`, command: null };
15
+ }
16
+ if (now.branch !== item.holds) {
17
+ const on = now.branch === undefined ? "a detached HEAD" : `${now.branch} checked out`;
18
+ return { reason: `it has ${on} since the Sweep judged it`, command: null };
19
+ }
20
+ if (now.lock !== item.stale?.held) {
21
+ return { reason: "its lock changed since the Sweep judged it", command: null };
22
+ }
23
+ return whyUnclean(item.name, item.stale !== undefined);
24
+ }
25
+ /** The command that removes `item` by hand, for one the Sweep did not remove. */
26
+ function byHandOf(item) {
27
+ if (item.kind === "terminal") {
28
+ return byHand(item);
29
+ }
30
+ return item.kind === "worktree" ? forcedRemoval(item.name, item.stale !== undefined) : forcedDelete(item.name);
31
+ }
32
+ /** Stops, if it asks, and removes the terminal of `item`; why it stays, in hc's words, or undefined once it is gone. */
33
+ function removeTerminalOf(item, unstopped) {
34
+ const failed = takeDown(item);
35
+ if (failed === undefined) {
36
+ return undefined;
37
+ }
38
+ if (item.tree !== undefined) {
39
+ unstopped.set(item.tree, `its Worker's terminal ${item.name} stays: ${failed}`);
40
+ }
41
+ return { reason: failed, command: byHandOf(item) };
42
+ }
43
+ /** Removes the worktree of `item` once it still passes, its stale lock lifted just before; why it stays, or undefined. */
44
+ function removeTree(root, item, unstopped) {
45
+ const unstoppable = unstopped.get(item.name);
46
+ if (unstoppable !== undefined) {
47
+ return { reason: unstoppable, command: null };
48
+ }
49
+ if (item.workerLock !== undefined) {
50
+ const now = lockAfterStop(root, item.name);
51
+ if (now.state === "stays") {
52
+ return { reason: `its Worker's terminal was stopped, but ${now.reason}`, command: now.command };
53
+ }
54
+ if (now.state === "stale") {
55
+ item.stale = now;
56
+ }
57
+ delete item.workerLock;
58
+ }
59
+ const changed = whyChanged(root, item);
60
+ if (changed !== null) {
61
+ return changed;
62
+ }
63
+ if (item.stale !== undefined) {
64
+ const lifted = git(root, ["worktree", "unlock", "--", item.name]);
65
+ if ("error" in lifted) {
66
+ return { reason: lifted.error, command: unlockCommand(item.name) };
67
+ }
68
+ }
69
+ let result;
70
+ try {
71
+ result = git(root, ["worktree", "remove", "--", item.name], { limitMs: SLOW_LIMIT_MS });
72
+ }
73
+ catch (error) {
74
+ if (!(error instanceof TimedOut)) {
75
+ throw error;
76
+ }
77
+ result = { error: error.message, said: error.message };
78
+ }
79
+ if (!("error" in result)) {
80
+ return undefined;
81
+ }
82
+ const relocked = item.stale === undefined ? undefined : git(root, ["worktree", "lock", "--reason", item.stale.held, "--", item.name]);
83
+ const lost = relocked !== undefined && "error" in relocked ? `; its lock could not be put back: ${relocked.error}` : "";
84
+ return { reason: `${result.error}${lost}`, command: byHandOf(item) };
85
+ }
86
+ /** Deletes the branch of `item` while it still points at the commit judged Landed; why it stays, or undefined. */
87
+ function removeBranch(root, item, heldBy) {
88
+ const path = heldBy.get(item.name);
89
+ if (path !== undefined) {
90
+ return { reason: `checked out in the worktree at ${path}`, command: null };
91
+ }
92
+ // Read just before the delete: git branch -D, the delete the guard lets an agent make, takes no expected tip.
93
+ const now = tipOf(root, item.name);
94
+ if (now !== undefined && now !== item.at) {
95
+ return { reason: `it moved to ${now.slice(0, 9)} since the Sweep judged it`, command: null };
96
+ }
97
+ const result = git(root, ["branch", "-D", "--", item.name]);
98
+ return "error" in result ? { reason: result.error, command: byHandOf(item) } : undefined;
99
+ }
100
+ /** Removes `item`; why it stays, in git's or hc's words or as the time limit it hit, or undefined once it is gone. */
101
+ function removal(root, item, heldBy, unstopped) {
102
+ try {
103
+ if (item.kind === "terminal") {
104
+ return removeTerminalOf(item, unstopped);
105
+ }
106
+ return item.kind === "worktree" ? removeTree(root, item, unstopped) : removeBranch(root, item, heldBy);
107
+ }
108
+ catch (error) {
109
+ if (error instanceof TimedOut) {
110
+ return { reason: error.message, command: byHandOf(item) };
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+ /** Removes each item judged to go, in order; a worktree that stays keeps the branch it holds as well. */
116
+ export function apply({ base, removed, kept }, root) {
117
+ const gone = [];
118
+ const refused = [];
119
+ const heldBy = new Map();
120
+ const unstopped = new Map();
121
+ for (const item of removed) {
122
+ const why = removal(root, item, heldBy, unstopped);
123
+ if (why === undefined) {
124
+ gone.push(item);
125
+ continue;
126
+ }
127
+ if (item.kind === "worktree" && item.holds !== undefined) {
128
+ heldBy.set(item.holds, item.name);
129
+ }
130
+ refused.push({ kind: item.kind, name: item.name, ...why, dev: true });
131
+ }
132
+ return { base, removed: gone, kept: [...kept, ...refused] };
133
+ }
@@ -56,7 +56,7 @@ export declare function whyHeld(holders: Holder[]): {
56
56
  /** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
57
57
  export declare function stopsOf(holders: Holder[], tree: string): TerminalPlan[];
58
58
  /** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
59
- export declare function removeTerminal(item: {
59
+ export declare function takeDown(item: {
60
60
  name: string;
61
61
  stop?: boolean;
62
62
  }): string | undefined;