dw-sdlc 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ // `skill-sections`: each README section a skill copies, a part, matches its source. A part sits between
2
+ // `<!-- README.md#<anchor> -->` and `<!-- /README.md#<anchor> -->` and repeats only a section a Primitive's
3
+ // docs name. `dw-sdlc check skill-sections --write` writes each part from its section.
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join, posix } from "node:path";
6
+ import { resolveConfig } from "../agentic-config.js";
7
+ import { readCatalogue } from "../catalogue.js";
8
+ import { headings, openFence, prose } from "../markdown.js";
9
+ import { CannotRun } from "./check.js";
10
+ import { catalogueFile } from "./primitives.js";
11
+ import { matching, treeFiles } from "./tree.js";
12
+ /** Either marker of a part: `/` before `README.md` closes it. */
13
+ const MARKER = /<!-- (\/?)README\.md#(\S+) -->/g;
14
+ const WRITE = "dw-sdlc check skill-sections --write";
15
+ const REMEDIES = {
16
+ stale: `edit the README, not the part, then run ${WRITE}`,
17
+ readme: "write the README, or drop the part",
18
+ section: "point it at a heading the README has",
19
+ unpaired: "open a part with <!-- README.md#<anchor> --> and close it with <!-- /README.md#<anchor> -->, each on its own line",
20
+ missing: "create it, or point gate.checks.primitives.file at the catalogue",
21
+ invalid: "fix what dw-sdlc check primitives names",
22
+ fence: "close the fence"
23
+ };
24
+ /** The markers outside code in `text`, in order. */
25
+ function markers(text) {
26
+ return Array.from(prose(text).matchAll(MARKER), (match) => ({
27
+ close: match[1] === "/",
28
+ anchor: match[2] ?? "",
29
+ start: match.index,
30
+ end: match.index + match[0].length
31
+ }));
32
+ }
33
+ /** The README a skill file copies from: the one beside the `skills` directory it is in, else beside it. */
34
+ function readmeOf(path) {
35
+ const directories = path.split("/").slice(0, -1);
36
+ const at = directories.lastIndexOf("skills");
37
+ const unit = at === -1 ? directories : directories.slice(0, at);
38
+ return posix.join(...unit, "README.md");
39
+ }
40
+ /** The section under the heading with `anchor`, up to the next heading of its level or above, its end trimmed. */
41
+ function sectionOf(markdown, anchor) {
42
+ const all = headings(markdown);
43
+ const start = all.find((heading) => heading.anchor === anchor);
44
+ if (start === undefined) {
45
+ return undefined;
46
+ }
47
+ const end = all.find((heading) => heading.line > start.line && heading.level <= start.level);
48
+ return markdown
49
+ .split("\n")
50
+ .slice(start.line - 1, end === undefined ? undefined : end.line - 1)
51
+ .join("\n")
52
+ .trimEnd();
53
+ }
54
+ function lineAt(text, offset) {
55
+ return text.slice(0, offset).split("\n").length;
56
+ }
57
+ function readmeText(root, path, sources) {
58
+ if (!sources.readmes.has(path)) {
59
+ const full = join(root, path);
60
+ sources.readmes.set(path, existsSync(full) ? readFileSync(full, "utf8") : undefined);
61
+ }
62
+ return sources.readmes.get(path);
63
+ }
64
+ function render(root, path, sources) {
65
+ const text = readFileSync(join(root, path), "utf8");
66
+ const readme = readmeOf(path);
67
+ const found = markers(text);
68
+ const findings = [];
69
+ const pieces = [];
70
+ let copied = 0;
71
+ let rewritten = 0;
72
+ for (let index = 0, open = found[0]; open !== undefined; open = found[++index]) {
73
+ const close = found[index + 1];
74
+ const line = lineAt(text, open.start);
75
+ const fail = (message, remedy) => findings.push({ file: path, line, message, remedy });
76
+ if (open.close || close === undefined || !close.close || close.anchor !== open.anchor) {
77
+ const marker = `README.md#${open.anchor}`;
78
+ const message = open.close
79
+ ? `the closing marker of ${marker} has no opening marker`
80
+ : `the opening marker of ${marker} has no closing marker`;
81
+ fail(message, REMEDIES.unpaired);
82
+ continue;
83
+ }
84
+ index++;
85
+ const part = `the part from README.md#${open.anchor}`;
86
+ const source = readmeText(root, readme, sources);
87
+ const section = source === undefined ? undefined : sectionOf(source, open.anchor);
88
+ if (!sources.listed().has(`${readme}#${open.anchor}`)) {
89
+ const remedy = `name ${readme}#${open.anchor} in a Primitive's docs, or point the part at a section they name`;
90
+ fail(`${part} copies a section no Primitive's docs name`, remedy);
91
+ }
92
+ else if (source === undefined) {
93
+ fail(`${part} reads ${readme}, which does not exist`, REMEDIES.readme);
94
+ }
95
+ else if (section === undefined) {
96
+ fail(`${part} names no heading of ${readme}`, REMEDIES.section);
97
+ }
98
+ else if (text.slice(open.end, close.start) !== `\n\n${section}\n\n`) {
99
+ fail(`${part} differs from that section of ${readme}`, REMEDIES.stale);
100
+ pieces.push(text.slice(copied, open.end), `\n\n${section}\n\n`);
101
+ copied = close.start;
102
+ rewritten++;
103
+ }
104
+ }
105
+ const fence = openFence(text);
106
+ const hidden = fence === undefined ? "" : text.split("\n").slice(fence).join("\n");
107
+ if (fence !== undefined && hidden.search(MARKER) !== -1) {
108
+ const message = "a code fence opens here and never closes, so the markers after it are not read";
109
+ findings.push({ file: path, line: fence, message, remedy: REMEDIES.fence });
110
+ }
111
+ pieces.push(text.slice(copied));
112
+ return { text: pieces.join(""), findings, rewritten };
113
+ }
114
+ /** The docs entries the catalogue names, each read from the root; the catalogue is read on the first call. */
115
+ function listedDocs(root) {
116
+ let listed;
117
+ return () => {
118
+ if (listed === undefined) {
119
+ const file = catalogueFile(resolveConfig(root).config.gate.checks);
120
+ const read = readCatalogue(root, file);
121
+ if (read.state === "missing") {
122
+ throw new CannotRun({
123
+ file,
124
+ message: "the catalogue does not exist, and a skill holds a part",
125
+ remedy: REMEDIES.missing
126
+ });
127
+ }
128
+ if (read.state === "invalid") {
129
+ throw new CannotRun({
130
+ file,
131
+ message: "the catalogue is not valid, and a skill holds a part",
132
+ remedy: REMEDIES.invalid
133
+ });
134
+ }
135
+ listed = new Set(read.catalogue.primitives.flatMap(({ docs }) => docs).map((entry) => posix.join(".", entry)));
136
+ }
137
+ return listed;
138
+ };
139
+ }
140
+ function renderAll(root, { files }) {
141
+ const sources = { listed: listedDocs(root), readmes: new Map() };
142
+ return matching(root, treeFiles(root), files).map((path) => ({ path, rendered: render(root, path, sources) }));
143
+ }
144
+ function plural(count, noun) {
145
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
146
+ }
147
+ function write(root, options) {
148
+ const changed = renderAll(root, options).filter(({ rendered }) => rendered.rewritten > 0);
149
+ for (const { path, rendered } of changed) {
150
+ writeFileSync(join(root, path), rendered.text);
151
+ }
152
+ if (changed.length === 0) {
153
+ return "every part matches its section, so nothing was written";
154
+ }
155
+ const parts = changed.reduce((sum, { rendered }) => sum + rendered.rewritten, 0);
156
+ return `wrote ${plural(parts, "part")} in ${plural(changed.length, "file")}`;
157
+ }
158
+ export const skillSections = {
159
+ name: "skill-sections",
160
+ defaults: { files: ["**/skills/**/*.md"] },
161
+ run: (root, options) => renderAll(root, options).flatMap(({ rendered }) => rendered.findings),
162
+ write
163
+ };
@@ -1,11 +1,12 @@
1
1
  // `dw-sdlc check <name>`: one Check of the Gate by its name, without the Steps and the other Checks.
2
2
  import { parseArgs } from "node:util";
3
- import { repositoryRoot } from "../agentic-config.js";
4
- import { CHECK_NAMES } from "../checks/index.js";
3
+ import { repositoryRoot, resolveConfig } from "../agentic-config.js";
4
+ import { CannotRun } from "../checks/check.js";
5
+ import { CHECK_NAMES, shippedCheck } from "../checks/index.js";
5
6
  import { TAIL_LINES } from "../run-command.js";
6
7
  import { printReport, runGate } from "./gate.js";
7
8
  export const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
8
- const USAGE = `Usage: dw-sdlc check <name> [--json] [--root <dir>]
9
+ const USAGE = `Usage: dw-sdlc check <name> [--json] [--write] [--root <dir>]
9
10
 
10
11
  Runs the Check named <name> in the Gate of the repository holding --root (default: the current
11
12
  directory): a script under gate.checks.run, or a Check the package ships, one of
@@ -15,6 +16,10 @@ false or leaves it out. No Step runs. A run script prints nothing of its own unl
15
16
  pass: then its last ${TAIL_LINES} lines, both streams in the order written. Prints one line per failure,
16
17
  as the Gate does; --json prints the Gate's document alone, with no Step.
17
18
 
19
+ --write first writes what the Check derives from its sources, then judges the tree it wrote, and
20
+ prints a line on what it wrote, to stderr under --json. Only a Check that derives files takes it:
21
+ skill-sections writes each part of a skill from its README section.
22
+
18
23
  A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
19
24
  pass.
20
25
 
@@ -29,18 +34,50 @@ function parseCli(argv) {
29
34
  options: {
30
35
  help: { type: "boolean", short: "h" },
31
36
  json: { type: "boolean" },
37
+ write: { type: "boolean" },
32
38
  root: { type: "string" }
33
39
  }
34
40
  });
35
41
  if (positionals.length > 1) {
36
42
  return { error: `one Check at a time, not ${positionals.map((name) => JSON.stringify(name)).join(" and ")}` };
37
43
  }
38
- return { help: values.help === true, json: values.json === true, root: values.root, name: positionals[0] };
44
+ const flags = { help: values.help === true, json: values.json === true, write: values.write === true };
45
+ return { ...flags, root: values.root, name: positionals[0] };
39
46
  }
40
47
  catch (error) {
41
48
  return { error: error instanceof Error ? error.message : String(error) };
42
49
  }
43
50
  }
51
+ /**
52
+ * Writes what the shipped Check `name` derives, on the setting the config gives it, and prints what it
53
+ * wrote; why it cannot when it takes no --write. An invalid config or a missing input writes nothing,
54
+ * and the run after it reports why.
55
+ */
56
+ function writeDerived(root, name, json) {
57
+ const { config, problems } = resolveConfig(root);
58
+ if (problems.length > 0) {
59
+ return undefined;
60
+ }
61
+ const shipped = shippedCheck(name, config.gate.checks[name]);
62
+ if (shipped === undefined && !config.gate.checks.run.some((check) => check.name === name)) {
63
+ // No Check of that name: the run after it says so, with the names there are.
64
+ return undefined;
65
+ }
66
+ const write = shipped?.write;
67
+ if (write === undefined) {
68
+ return `${JSON.stringify(name)} takes no --write: it derives no file`;
69
+ }
70
+ try {
71
+ const out = json ? process.stderr : process.stdout;
72
+ out.write(`check ${name}: ${write(root)}\n`);
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof CannotRun)) {
76
+ throw error;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
44
81
  export async function run(argv) {
45
82
  const cli = parseCli(argv);
46
83
  if ("error" in cli) {
@@ -56,6 +93,14 @@ export async function run(argv) {
56
93
  return 2;
57
94
  }
58
95
  const { json, name } = cli;
59
- const report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
96
+ const root = repositoryRoot(cli.root ?? process.cwd());
97
+ if (cli.write) {
98
+ const refused = writeDerived(root, name, json);
99
+ if (refused !== undefined) {
100
+ process.stderr.write(`dw-sdlc check: ${refused}\n`);
101
+ return 2;
102
+ }
103
+ }
104
+ const report = await runGate(root, { json, only: name });
60
105
  return printReport(report, json, `check ${name}`);
61
106
  }
@@ -47,7 +47,11 @@ function checkJq(probe) {
47
47
  if (status === 0) {
48
48
  return { name: "jq", level: "PASS", detail: stdout.trim() };
49
49
  }
50
- return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
50
+ return {
51
+ name: "jq",
52
+ level: "FAIL",
53
+ detail: "missing: every guard is silent without it. https://jqlang.org/download/"
54
+ };
51
55
  }
52
56
  /** Every hook command string in a settings file, whatever the event and matcher. */
53
57
  function hookCommands(settings) {
@@ -1,2 +1,2 @@
1
- export declare const summary = "name the Primitives that own each path, with their summaries and documents";
1
+ export declare const summary = "name the Primitives that own each path, or that a change removes, adds or extends";
2
2
  export declare function run(argv: string[]): number;
@@ -1,21 +1,32 @@
1
- // `dw-sdlc primitives --paths <files>`: for each path, the Primitives of the catalogue that name it,
2
- // with their summaries and the documents to read, or that none does.
1
+ // `dw-sdlc primitives`: with `--paths <files>`, for each path the Primitives of the catalogue that name
2
+ // it, with their summaries and the documents to read, or that none does; with `--diff <base>`, the
3
+ // Primitives a change removes, adds or extends.
3
4
  import { existsSync, realpathSync } from "node:fs";
4
5
  import { basename, dirname, join, relative, resolve } from "node:path";
5
6
  import { parseArgs } from "node:util";
6
7
  import { repositoryRoot, resolveConfig } from "../agentic-config.js";
7
8
  import { primitivesNaming, readCatalogue } from "../catalogue.js";
8
9
  import { catalogueFile } from "../checks/primitives.js";
9
- export const summary = "name the Primitives that own each path, with their summaries and documents";
10
+ import { blockOf, primitivesDiff } from "../primitives-diff.js";
11
+ export const summary = "name the Primitives that own each path, or that a change removes, adds or extends";
10
12
  const USAGE = `Usage: dw-sdlc primitives --paths <file>... [--json] [--root <dir>]
13
+ dw-sdlc primitives --diff <base> [--json] [--root <dir>]
11
14
 
12
- Prints, for each path, the Primitives of the catalogue whose code names it, the file itself or the
13
- nearest directory above it: the id, the name, the summary and the documents to read; or that no
15
+ --paths prints, for each path, the Primitives of the catalogue whose code names it, the file itself or
16
+ the nearest directory above it: the id, the name, the summary and the documents to read; or that no
14
17
  Primitive names it. A path is read from the current directory. The catalogue is the one the
15
18
  primitives Check reads, in the repository holding --root (default: the current directory). --json
16
19
  prints one document instead.
17
20
 
18
- Exit 0 with an answer for every path, 2 when there is no catalogue to read or it is not one.`;
21
+ --diff prints the Primitives the committed change from the merge-base of <base> and HEAD to HEAD
22
+ removes (an id gone from the catalogue), adds (a new id) and extends (a changed file in the code of a
23
+ Primitive that was there): a markdown block between <!-- primitives:start --> and <!-- primitives:end -->
24
+ for a pull request body, which a later run replaces. --json prints instead, per Primitive, its id, name,
25
+ class, file count, summary and docs, each doc with whether the change touches its file, and the count of
26
+ changed files outside every Primitive. It takes no --paths.
27
+
28
+ Exit 0 with an answer, 2 when there is no catalogue to read or it is not one, and for --diff when <base>
29
+ does not resolve.`;
19
30
  const REMEDIES = {
20
31
  missing: "create it, or point gate.checks.primitives.file at the catalogue",
21
32
  invalid: "fix the catalogue; dw-sdlc gate names each problem under the primitives Check"
@@ -29,6 +40,7 @@ function parseCli(argv) {
29
40
  help: { type: "boolean", short: "h" },
30
41
  json: { type: "boolean" },
31
42
  paths: { type: "boolean" },
43
+ diff: { type: "string" },
32
44
  root: { type: "string" }
33
45
  }
34
46
  });
@@ -36,10 +48,17 @@ function parseCli(argv) {
36
48
  help: values.help === true,
37
49
  json: values.json === true,
38
50
  paths: values.paths === true,
51
+ diff: values.diff,
39
52
  files: positionals,
40
53
  root: values.root
41
54
  };
42
- return cli.help || cli.paths ? cli : { error: "name the paths with --paths" };
55
+ if (cli.help) {
56
+ return cli;
57
+ }
58
+ if (cli.diff !== undefined && (cli.paths || cli.files.length > 0)) {
59
+ return { error: "--diff and --paths exclude each other — ask one question at a time" };
60
+ }
61
+ return cli.paths || cli.diff !== undefined ? cli : { error: "name the paths with --paths, or a base with --diff" };
43
62
  }
44
63
  catch (error) {
45
64
  return { error: error instanceof Error ? error.message : String(error) };
@@ -68,6 +87,15 @@ function plain(path, primitives) {
68
87
  ` read: ${docs.join(", ")}`
69
88
  ]);
70
89
  }
90
+ function runDiff(root, base, json) {
91
+ const answer = primitivesDiff(root, base);
92
+ if ("problems" in answer) {
93
+ process.stderr.write(answer.problems.map((problem) => `dw-sdlc primitives: ${problem}\n`).join(""));
94
+ return 2;
95
+ }
96
+ process.stdout.write(json ? `${JSON.stringify(answer, null, 2)}\n` : `${blockOf(answer)}\n`);
97
+ return 0;
98
+ }
71
99
  export function run(argv) {
72
100
  const cli = parseCli(argv);
73
101
  if ("error" in cli) {
@@ -79,6 +107,9 @@ export function run(argv) {
79
107
  return 0;
80
108
  }
81
109
  const root = repositoryRoot(cli.root ?? process.cwd());
110
+ if (cli.diff !== undefined) {
111
+ return runDiff(root, cli.diff, cli.json);
112
+ }
82
113
  const file = catalogueFile(resolveConfig(root).config.gate.checks);
83
114
  const read = readCatalogue(root, file);
84
115
  if (read.state !== "valid") {
@@ -8,6 +8,7 @@ import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
8
8
  import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
9
9
  import { holds, upstreamName } from "../pr/upstream.js";
10
10
  import { READY_LABEL, readNext } from "../recap-next.js";
11
+ import { rollup } from "../recap-primitives.js";
11
12
  import { isLive, Refusal, workersOf, workingFolders } from "../worker/herdr.js";
12
13
  import { asked, git } from "../working-tree.js";
13
14
  export const summary = "the facts a Recap rests on, whether the session can be closed and what could come next";
@@ -32,6 +33,10 @@ A pull request closed without merging merges nothing, and a detached HEAD names
32
33
  commit counts as pushed only on a remote branch, and the command asks the remote which branches still
33
34
  stand. A branch force-pushed since the last fetch can still read as holding it, so fetch first to be exact.
34
35
 
36
+ Beside the facts it lists the Primitives the session's branch changed, as primitives --diff classes them
37
+ from its merge-base with origin/<default>: ids and classes, none on the default branch or in a repository
38
+ with no catalogue. A catalogue it cannot read lists none, with a warning.
39
+
35
40
  A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
36
41
  directory it runs in.
37
42
 
@@ -192,11 +197,11 @@ function orphanedOf(root, read, shipped, held) {
192
197
  * The commits on top of the base that no pull request holds. A branch the config protects takes changes
193
198
  * through pull requests rather than making one, and so does the default branch: neither needs one.
194
199
  */
195
- function aheadOf(root, branch, shipped) {
200
+ function aheadOf(root, branch, baseOf) {
196
201
  if (resolveConfig(root).config.guardrails.protectedBranches.includes(branch)) {
197
202
  return null;
198
203
  }
199
- const base = shipped?.pr.base ?? defaultBranch();
204
+ const base = baseOf();
200
205
  if (branch === base) {
201
206
  return null;
202
207
  }
@@ -285,7 +290,11 @@ function nextLine(next) {
285
290
  const named = next.candidates.map(({ number, parent }) => `#${number}${parent === null ? "" : ` (sub-issue of #${parent})`}`);
286
291
  return named.length === 0 ? "none" : named.join(", ");
287
292
  }
288
- function linesOf(local, { shipped, askedGh, workers, next }, line) {
293
+ function primitivesLine(primitives) {
294
+ const named = primitives.map(({ id, class: kind }) => `${kind} ${id}`);
295
+ return named.length === 0 ? [] : [`primitives: ${named.join(", ")}`];
296
+ }
297
+ function linesOf(local, { shipped, askedGh, workers, next }, primitives, line) {
289
298
  const none = askedGh ? "none" : "gh not asked";
290
299
  return [
291
300
  `branch: ${local.branch ?? "detached HEAD"}`,
@@ -295,6 +304,7 @@ function linesOf(local, { shipped, askedGh, workers, next }, line) {
295
304
  `worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
296
305
  `workers: ${workersLine(workers)}`,
297
306
  `next: ${nextLine(next)}`,
307
+ ...primitivesLine(primitives),
298
308
  line
299
309
  ].join("\n");
300
310
  }
@@ -328,7 +338,9 @@ function report(json) {
328
338
  const local = standing(root, read, shipped, held);
329
339
  // An open pull request can still take the head, and a commit-less repository has nothing to merge.
330
340
  const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
331
- const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
341
+ let named;
342
+ const baseOf = () => (named ??= shipped?.pr.base ?? defaultBranch());
343
+ const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, baseOf) : null;
332
344
  const facts = {
333
345
  shipped,
334
346
  ahead,
@@ -340,14 +352,18 @@ function report(json) {
340
352
  if (facts.next !== null && "unread" in facts.next) {
341
353
  process.stderr.write(`dw-sdlc recap: warning: the backlog was not read: ${facts.next.unread}\n`);
342
354
  }
355
+ const rolled = asksGh ? rollup(root, branch, defaultBranch) : { primitives: [] };
356
+ if ("unread" in rolled) {
357
+ process.stderr.write(`dw-sdlc recap: warning: the Primitives were not read: ${rolled.unread}\n`);
358
+ }
343
359
  const next = facts.next !== null && "candidates" in facts.next ? facts.next.candidates : null;
344
360
  const close = closeOf(reasonsOf(local, facts));
345
361
  const pullRequest = shipped === null
346
362
  ? null
347
363
  : { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
348
364
  const output = json
349
- ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close, next }, null, 2)
350
- : linesOf(local, facts, close.line);
365
+ ? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, primitives: rolled.primitives, close, next }, null, 2)
366
+ : linesOf(local, facts, rolled.primitives, close.line);
351
367
  process.stdout.write(`${output}\n`);
352
368
  return close.verdict === "yes" ? 0 : 1;
353
369
  }
@@ -27,8 +27,8 @@ export type SetupOptions = {
27
27
  export declare const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
28
28
  /**
29
29
  * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
30
- * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
31
- * its path across plugin updates. Without a registered clone holding the schema, the `$id`.
30
+ * editor reads it offline at the installed version and the clone, unlike the install cache, keeps its
31
+ * path across plugin updates. Without a registered clone holding the schema, the `$id`.
32
32
  */
33
33
  export declare function schemaReference(claudeDir: string): string;
34
34
  export declare function setup(options: SetupOptions): Step[];
@@ -69,8 +69,8 @@ function committedConfig(root) {
69
69
  }
70
70
  /**
71
71
  * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
72
- * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
73
- * its path across plugin updates. Without a registered clone holding the schema, the `$id`.
72
+ * editor reads it offline at the installed version and the clone, unlike the install cache, keeps its
73
+ * path across plugin updates. Without a registered clone holding the schema, the `$id`.
74
74
  */
75
75
  export function schemaReference(claudeDir) {
76
76
  const registry = readJson(join(claudeDir, "plugins", "known_marketplaces.json"));
@@ -9,7 +9,7 @@ import { isCommitLanded, isLanded } from "../landed.js";
9
9
  import { shellLine } from "../pr/decide.js";
10
10
  import { CannotRun } from "../pr/read.js";
11
11
  import { byHand, holdersOf, keptIn, lockAfterStop, lockHolder, outsideWorktrees, readTerminals, removeTerminal, stopsOf, sweepingTerminal, whyHeld } from "../sweep-terminals.js";
12
- import { dirtyFiles, must, pausedOperation, worktreesOf } from "../sweep-trees.js";
12
+ import { branchesOf, dirtyFiles, pausedOperation, switchOff, worktreesOf } from "../sweep-trees.js";
13
13
  import { asked, git } from "../working-tree.js";
14
14
  import { lockVerdict } from "../worktree-lock.js";
15
15
  export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
@@ -26,7 +26,8 @@ config, else the branch origin/HEAD names) and removes, never forcing:
26
26
  It keeps, and names with the reason and the command to run yourself:
27
27
  a worktree that fails the set: the files that hold it with the forced removal, the paused operation, the
28
28
  lock's reason with git worktree unlock, a live Claude Code lock named by its pid, a HEAD that is not Landed;
29
- the default branch, the base branch, a protected branch and a branch a kept worktree has checked out;
29
+ the default branch, the base branch, a protected branch and a branch a kept worktree has checked out; a
30
+ Landed branch the main checkout has checked out, clean and with nothing paused, with git switch to the base;
30
31
  a branch whose work is on no commit of the base, with the forced delete.
31
32
  It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
32
33
  kept with git's words, and the sweep goes on.
@@ -91,11 +92,6 @@ function baseOf(root, head) {
91
92
  }
92
93
  return { name, ref };
93
94
  }
94
- function branchesOf(root) {
95
- return must(root, ["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
96
- .split("\n")
97
- .filter((name) => name !== "");
98
- }
99
95
  /** Why the worktree stays, with the dev's command when there is one, or null when its whole set passes. */
100
96
  function whyTreeStays(root, baseRef, tree, lock, holders) {
101
97
  const { path } = tree;
@@ -194,8 +190,9 @@ function judge(root, terminals) {
194
190
  }
195
191
  judged.add(name);
196
192
  const why = stays(name, footing);
193
+ const off = why?.dev === true && held.get(name) === checkout.path ? switchOff(root, base, name) : null;
197
194
  if (why !== null) {
198
- kept.push({ kind: "branch", name, ...why });
195
+ kept.push({ kind: "branch", name, ...why, ...off });
199
196
  }
200
197
  else if (isLanded(root, base.ref, name)) {
201
198
  removed.push({ kind: "branch", name, reason: `Landed on ${baseName}` });
@@ -12,6 +12,8 @@ export type Link = {
12
12
  target: string;
13
13
  line: number;
14
14
  };
15
+ /** The 1-based line of the code fence `text` leaves open at its end; none when every fence closes. */
16
+ export declare function openFence(text: string): number | undefined;
15
17
  /** Every ATX heading in `text`, a repeated anchor numbered `-1`, `-2` as GitHub numbers it. */
16
18
  export declare function headings(text: string): Heading[];
17
19
  /**
package/dist/markdown.js CHANGED
@@ -13,15 +13,24 @@ function scan(text) {
13
13
  const marker = FENCE.exec(line)?.[1];
14
14
  if (fence === undefined && marker !== undefined) {
15
15
  fence = marker;
16
- return { text: line, line: index + 1, fenced: true };
16
+ return { text: line, line: index + 1, fenced: true, edge: "open" };
17
17
  }
18
18
  if (fence !== undefined && marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) {
19
19
  fence = undefined;
20
- return { text: line, line: index + 1, fenced: true };
20
+ return { text: line, line: index + 1, fenced: true, edge: "close" };
21
21
  }
22
22
  return { text: line, line: index + 1, fenced: fence !== undefined };
23
23
  });
24
24
  }
25
+ /** The 1-based line of the code fence `text` leaves open at its end; none when every fence closes. */
26
+ export function openFence(text) {
27
+ const lines = scan(text);
28
+ const last = lines.at(-1);
29
+ if (last === undefined || !last.fenced || last.edge === "close") {
30
+ return undefined;
31
+ }
32
+ return lines.findLast(({ edge }) => edge === "open")?.line;
33
+ }
25
34
  /**
26
35
  * GitHub's anchor for a heading: its text as rendered, lowercased, every character but a letter, a
27
36
  * digit, a space, `-` and `_` dropped, each space a hyphen.
@@ -0,0 +1,31 @@
1
+ export type PrimitiveClass = "removes" | "adds" | "extends";
2
+ export type Change = {
3
+ id: string;
4
+ name: string;
5
+ class: PrimitiveClass;
6
+ /** The changed files the Primitive owns. */
7
+ files: number;
8
+ summary: string;
9
+ /** Each `docs` entry, and whether the diff changes its file, whatever its anchor. */
10
+ docs: {
11
+ entry: string;
12
+ touched: boolean;
13
+ }[];
14
+ };
15
+ export type PrimitivesDiff = {
16
+ base: string;
17
+ primitives: Change[];
18
+ outside: number;
19
+ };
20
+ /** Why a diff cannot be answered: each problem with its remedy. */
21
+ export type Refusal = {
22
+ problems: string[];
23
+ };
24
+ /**
25
+ * The Primitives `base...HEAD` removes, adds and extends. The catalogue's path comes from the config of
26
+ * the checkout; the catalogue is read at `HEAD` and at the merge-base, from the commits and not the tree.
27
+ * A file's owners are the nearest entries of the catalogue it is read against, as `--paths` answers.
28
+ */
29
+ export declare function primitivesDiff(root: string, base: string): PrimitivesDiff | Refusal;
30
+ /** The markdown block between its markers, which a later run replaces whole. */
31
+ export declare function blockOf({ primitives, outside }: PrimitivesDiff): string;