@sleepy_panda_srl/portulan 0.1.3 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
package/cli/finish.mjs ADDED
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ // The finishing command — one call closes a change: its recipes, its changelog fragment, its commit, its push.
3
+ //
4
+ // node cli/finish.mjs -m <subject> [-m <paragraph>]... [options]
5
+ // node cli/finish.mjs -F <file | -> [options]
6
+ //
7
+ // Exit 0 closed, or nothing to close · 1 red: a recipe, the fragment, an untracked file or a refusing hook · 2 could not run or push.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
15
+ import { packRoots, resolvePack } from "./compile.mjs";
16
+ import { CHANGES_DIR, CHANGES_README } from "./form.mjs";
17
+ import { CHANGE_NAME } from "./index.mjs";
18
+ import { recipeSet, resolverFor } from "./recipe-set.mjs";
19
+
20
+ export const RECIPE_TIMEOUT_MS = 10 * 60 * 1000;
21
+
22
+ export const TAIL_LINES = 25;
23
+
24
+ const TAIL_BYTES = 64 * 1024;
25
+
26
+ const DRAIN_MS = 1000;
27
+
28
+ const GIT_TIMEOUT_MS = 2 * 60 * 1000;
29
+
30
+ const CANNOT_RUN = new Set([2, 126, 127]);
31
+
32
+ const USAGE = [
33
+ "usage: node cli/finish.mjs -m <subject> [-m <paragraph>]... [options]",
34
+ " node cli/finish.mjs -F <file | -> [options]",
35
+ "",
36
+ " -m <text> a paragraph of the commit message, the first its subject, as git takes -m",
37
+ " -F <file | -> the whole commit message, from a file or from standard input",
38
+ " --no-fragment <why> the change owes no changelog fragment, and why",
39
+ " --base <ref> the branch the change merges into; else PORTULAN_BASE_REF, else the remote's HEAD",
40
+ " --workspace <dir> the workspace whose recipes run; else PORTULAN_WORKSPACE, else .portulan at the root",
41
+ " --pack-root <dir> where composed packs resolve, repeatable, or auto, as recipe-set takes it",
42
+ "",
43
+ "Commits the changes to tracked files and whatever is staged; an untracked file stops it, so stage a new one",
44
+ "by name in the same call: git add <paths> && node cli/finish.mjs ...",
45
+ "Runs every recipe on the commit it makes, then pushes; on a red it undoes that commit and pushes nothing.",
46
+ "exit 0 closed · 1 red: a recipe, the fragment, an untracked file or a commit hook · 2 could not run or push",
47
+ ].join("\n");
48
+
49
+ const TAKES_VALUE = new Set(["-m", "-F", "--no-fragment", "--base", "--workspace", "--pack-root"]);
50
+
51
+ /** The options, or `{ error }`. */
52
+ export function parseArgs(argv, cwd = process.cwd()) {
53
+ const options = { paragraphs: [], file: null, noFragment: null, base: null, workspace: null, named: [], forced: false, help: false };
54
+ for (let i = 0; i < argv.length; i += 1) {
55
+ const flag = argv[i];
56
+ if (flag === "--help" || flag === "-h") {
57
+ options.help = true;
58
+ continue;
59
+ }
60
+ if (!TAKES_VALUE.has(flag)) return { error: `unknown argument ${JSON.stringify(flag)}` };
61
+ const value = argv[i + 1];
62
+ i += 1;
63
+ if (value === undefined) return { error: `${flag} needs a value` };
64
+ if (value.trim() === "") return { error: `${flag} was given an empty value` };
65
+ const prose = flag === "-m" || flag === "--no-fragment";
66
+ if (!prose && value !== "-" && value.startsWith("-")) return { error: `${flag} was given ${JSON.stringify(value)}, which is a flag rather than a value` };
67
+ if (flag === "-m") options.paragraphs.push(value);
68
+ else if (flag === "-F") {
69
+ if (options.file !== null) return { error: "-F was given twice" };
70
+ options.file = value;
71
+ } else if (flag === "--no-fragment") options.noFragment = value.trim();
72
+ else if (flag === "--base") options.base = value;
73
+ else if (flag === "--workspace") options.workspace = path.resolve(cwd, value);
74
+ else if (value === AUTO) options.forced = true;
75
+ else {
76
+ let stat = null;
77
+ try {
78
+ stat = fs.statSync(path.resolve(cwd, value));
79
+ } catch {
80
+ // Reported below as not a directory.
81
+ }
82
+ if (!stat?.isDirectory()) return { error: `--pack-root ${JSON.stringify(value)} is not a directory` };
83
+ options.named.push(path.resolve(cwd, value));
84
+ }
85
+ }
86
+ if (options.file !== null && options.paragraphs.length > 0) return { error: "-m and -F both give the message; pass one of them" };
87
+ const both = namedWithAuto(options.named, options.forced);
88
+ if (both) return { error: both };
89
+ return options;
90
+ }
91
+
92
+ /** The commit message the options give, or null where they give none; `{ error }` where the file cannot be read. */
93
+ export function messageOf(options, readStdin = () => fs.readFileSync(0, "utf8")) {
94
+ if (options.file === null) return options.paragraphs.length ? options.paragraphs.join("\n\n") : null;
95
+ let text;
96
+ try {
97
+ text = options.file === "-" ? readStdin() : fs.readFileSync(options.file, "utf8");
98
+ } catch (error) {
99
+ return { error: `the message file ${options.file} could not be read — ${error.code ?? error.message}` };
100
+ }
101
+ return text.trim() === "" ? { error: `the message in ${options.file === "-" ? "standard input" : options.file} is empty` } : text;
102
+ }
103
+
104
+ function gitAt(cwd, env) {
105
+ return (args, { input, timeout = GIT_TIMEOUT_MS } = {}) => {
106
+ const r = spawnSync("git", args, { cwd, env, input, encoding: "utf8", timeout, maxBuffer: 64 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] });
107
+ return { status: r.error ? null : r.status, out: (r.stdout ?? "").trim(), raw: r.stdout ?? "", err: `${r.stderr ?? ""}${r.error ? r.error.message : ""}`.trim() };
108
+ };
109
+ }
110
+
111
+ const tail = (text, lines = TAIL_LINES) => text.trim().split("\n").slice(-lines).join("\n");
112
+
113
+ const lastLine = (text) => tail(text, 1) || "no output";
114
+
115
+ /** Why git refused a push: its `! [rejected]` line, which names the ref and the reason, not a `hint:` after it. */
116
+ const refusalOf = (text) => text.split("\n").map((line) => line.trim().replace(/\s+/g, " ")).find((line) => line.startsWith("! ")) ?? lastLine(text);
117
+
118
+ const textOf = (thrown) => {
119
+ try {
120
+ return String(thrown?.message ?? thrown);
121
+ } catch {
122
+ return "the runner threw a value with no text";
123
+ }
124
+ };
125
+
126
+ const resultOf = (id, r) => {
127
+ if (r?.outcome === "green") return { id, outcome: "green" };
128
+ if (r?.outcome === "red" || r?.outcome === "could not run") {
129
+ return { id, outcome: r.outcome, code: Number.isInteger(r.code) ? r.code : null, output: typeof r.output === "string" ? r.output : "" };
130
+ }
131
+ return { id, outcome: "could not run", code: null, output: "the runner returned no result" };
132
+ };
133
+
134
+ /** The base as `{ ref, sha, name }`, `name` null where it is no branch, or `{ why }`; never a branch picked by name. */
135
+ export function findBase(git, { remote, remotes = [remote], given, env }) {
136
+ const asked = given ?? (env.PORTULAN_BASE_REF || null);
137
+ let full;
138
+ if (asked !== null) {
139
+ const from = given === null ? ", from PORTULAN_BASE_REF," : "";
140
+ // Refused, not passed after `--end-of-options`: git 2.43's `rev-parse --symbolic-full-name` echoes that back as the name.
141
+ if (asked.startsWith("-")) return { why: `the base ${JSON.stringify(asked)}${from} is not a ref: a ref never begins with a dash` };
142
+ const sha = git(["rev-parse", "--verify", "-q", `${asked}^{commit}`]);
143
+ if (sha.status !== 0 || sha.out === "") return { why: `the base ${JSON.stringify(asked)}${from} is not a commit here` };
144
+ full = git(["rev-parse", "--symbolic-full-name", asked]).out;
145
+ return { ref: asked, sha: sha.out, name: branchOn(full, remotes) };
146
+ }
147
+ full = git(["symbolic-ref", "-q", `refs/remotes/${remote}/HEAD`]).out;
148
+ if (full === "") {
149
+ const listed = git(["ls-remote", "--symref", remote, "HEAD"], { timeout: 30_000 });
150
+ const head = listed.status === 0 ? /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(listed.raw) : null;
151
+ if (head === null) return { why: `${remote} has no default head recorded here and did not name one when asked — pass --base <ref>` };
152
+ full = `refs/remotes/${remote}/${head[1]}`;
153
+ }
154
+ const ref = full.replace(/^refs\/remotes\//, "");
155
+ const sha = git(["rev-parse", "--verify", "-q", `${full}^{commit}`]);
156
+ if (sha.status !== 0) return { why: `the base ${ref} is not fetched here — fetch it, or pass --base <ref>` };
157
+ return { ref, sha: sha.out, name: branchOn(full, remotes) };
158
+ }
159
+
160
+ /** The branch a full ref names, locally or on one of `remotes`, or null for anything else: a commit, a tag. */
161
+ function branchOn(full, remotes) {
162
+ if (full.startsWith("refs/heads/")) return full.slice("refs/heads/".length);
163
+ const on = [...remotes].sort((a, b) => b.length - a.length).find((r) => full.startsWith(`refs/remotes/${r}/`));
164
+ return on === undefined ? null : full.slice(`refs/remotes/${on}/`.length);
165
+ }
166
+
167
+ /** The paths this change touched since `mergeBase`, committed or not, with their status: `A`, `M`, `D` or `?` for untracked. */
168
+ export function changedPaths(git, mergeBase) {
169
+ const out = [];
170
+ const fields = git(["diff", "--name-status", "--no-renames", "-z", mergeBase, "--"]).raw.split("\0");
171
+ for (let i = 0; i + 1 < fields.length; i += 2) if (fields[i] !== "") out.push({ status: fields[i][0], path: fields[i + 1] });
172
+ for (const p of git(["ls-files", "--others", "--exclude-standard", "-z"]).raw.split("\0")) if (p !== "") out.push({ status: "?", path: p });
173
+ return out;
174
+ }
175
+
176
+ /** The change's changelog fragment, preferring one it added, or null. */
177
+ export function fragmentIn(paths) {
178
+ const fragments = paths.filter(({ status, path: p }) => status !== "D" && p.startsWith(`${CHANGES_DIR}/`) && CHANGE_NAME.test(p.slice(CHANGES_DIR.length + 1)));
179
+ return (fragments.find(({ status }) => status === "A" || status === "?") ?? fragments[0])?.path ?? null;
180
+ }
181
+
182
+ /** The tree's own pack roots when they hold every composed pack, so an installed plugin's copies are not refused as shadowed; else none. */
183
+ export function treeRoots({ workspaceDir, manifest }) {
184
+ const declared = Array.isArray(manifest?.packs) ? manifest.packs : [];
185
+ if (declared.length === 0) return [];
186
+ const roots = packRoots(workspaceDir, manifest).filter((dir) => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
187
+ return roots.length && declared.every((ref) => resolvePack(String(ref), roots)?.dir) ? roots : [];
188
+ }
189
+
190
+ /** The recipes the workspace yields with its packs composed, as CI reads them, or `{ why }`. */
191
+ export function recipesOf({ root, workspaceDir, named, forced }) {
192
+ let manifest;
193
+ try {
194
+ manifest = JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8"));
195
+ } catch (error) {
196
+ return { why: `the workspace manifest in ${path.relative(root, workspaceDir) || "."} could not be read — ${error.code ?? error.message} — so nothing names the recipes to run` };
197
+ }
198
+ let resolve;
199
+ try {
200
+ const roots = named.length === 0 && !forced ? treeRoots({ workspaceDir, manifest }) : named;
201
+ resolve = resolverFor({ workspaceDir, manifest, repoRoot: root, named: roots, discovery: () => discoverPackRoots(), forced });
202
+ } catch (error) {
203
+ return { why: error.message };
204
+ }
205
+ const set = recipeSet(manifest, { resolve });
206
+ return set.ok ? { recipes: set.recipes } : { why: set.reason };
207
+ }
208
+
209
+ /** A stream's last `limit` bytes, kept as it arrives: what is held never passes `limit` and one chunk. */
210
+ export function tailKeeper(limit = TAIL_BYTES) {
211
+ const chunks = [];
212
+ let held = 0;
213
+ return {
214
+ add(chunk) {
215
+ chunks.push(chunk);
216
+ held += chunk.length;
217
+ while (held - chunks[0].length >= limit) held -= chunks.shift().length;
218
+ },
219
+ get held() {
220
+ return held;
221
+ },
222
+ text: () => Buffer.concat(chunks).subarray(-limit).toString("utf8"),
223
+ };
224
+ }
225
+
226
+ export function runRecipe(recipe, { root, env, timeout = RECIPE_TIMEOUT_MS }) {
227
+ return new Promise((resolve) => {
228
+ const kept = tailKeeper();
229
+ let failure = null;
230
+ let late = false;
231
+ let limit;
232
+ let drain;
233
+ const settle = (status) => {
234
+ clearTimeout(limit);
235
+ clearTimeout(drain);
236
+ const code = failure || late ? null : status;
237
+ if (code === 0) return resolve({ id: recipe.id, outcome: "green" });
238
+ const why = failure ? `\n${failure.message}` : late ? `\nkilled at its time limit of ${timeout / 1000} s` : "";
239
+ const cannot = code === null || CANNOT_RUN.has(code);
240
+ resolve({ id: recipe.id, outcome: cannot ? "could not run" : "red", code, output: `${kept.text()}${why}` });
241
+ };
242
+ // As CI and the Stop-gate run a recipe, its stderr joined to its stdout so its lines keep their order.
243
+ const child = spawn("bash", ["-c", `exec 2>&1\n${recipe.run}`], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
244
+ const stop = () => {
245
+ child.stdout.destroy();
246
+ child.stderr.destroy();
247
+ };
248
+ child.stdout.on("data", kept.add);
249
+ child.stderr.on("data", kept.add);
250
+ child.on("exit", () => {
251
+ clearTimeout(limit);
252
+ // A process the shell left running is not waited for: once the pipe closes, the child's next write kills it with SIGPIPE, or fails with EPIPE where it ignores that signal, and a child that never writes keeps running.
253
+ drain = setTimeout(stop, DRAIN_MS);
254
+ });
255
+ child.on("error", (error) => {
256
+ failure = error;
257
+ settle(null);
258
+ });
259
+ child.on("close", settle);
260
+ limit = setTimeout(() => {
261
+ late = true;
262
+ child.kill();
263
+ stop();
264
+ }, timeout);
265
+ });
266
+ }
267
+
268
+ /** Resolves to `{ code, lines }`: the exit code and what to print, the first line saying what happened. */
269
+ export async function finish(options, { cwd = process.cwd(), env = process.env, readStdin, runOne = runRecipe } = {}) {
270
+ const stop = (code, ...lines) => ({ code, lines: [`finish: ${lines[0]}`, ...lines.slice(1)] });
271
+ const message = messageOf(options, readStdin);
272
+ if (message?.error) return stop(2, `could not run — ${message.error}`);
273
+
274
+ const quiet = { ...env, GIT_TERMINAL_PROMPT: "0" };
275
+ const top = gitAt(cwd, quiet)(["rev-parse", "--show-toplevel"]);
276
+ if (top.status !== 0) return stop(2, `could not run — ${cwd} is not inside a git work tree`);
277
+ const root = top.out;
278
+ const git = gitAt(root, quiet);
279
+
280
+ const branch = git(["symbolic-ref", "-q", "--short", "HEAD"]).out;
281
+ if (branch === "") return stop(2, "could not run — HEAD is detached: a change closes on a branch");
282
+ const parent = git(["rev-parse", "--verify", "-q", "HEAD^{commit}"]).out;
283
+ if (parent === "") return stop(2, `could not run — ${branch} has no commit yet, so there is no base to close a change against`);
284
+
285
+ const configured = git(["config", "--get", `branch.${branch}.remote`]).out;
286
+ const remotes = git(["remote"]).out.split("\n").filter(Boolean);
287
+ const remote = configured !== "" && configured !== "." ? configured : remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
288
+ if (remote === null) return stop(2, `could not run — no remote to push to: ${remotes.length ? `name one with \`git branch --set-upstream-to\`, among ${remotes.join(", ")}` : "the repository has none"}`);
289
+ const merge = remote === configured ? git(["config", "--get", `branch.${branch}.merge`]).out : "";
290
+ const target = merge.startsWith("refs/heads/") ? merge.slice("refs/heads/".length) : branch;
291
+
292
+ const base = findBase(git, { remote, remotes, given: options.base, env });
293
+ if (base.why) return stop(2, `could not run — ${base.why}`);
294
+ if (base.name === null) {
295
+ return stop(2, `could not run — the base ${base.ref} names no branch, so nothing shows ${branch} is not the branch changes merge into: pass --base <remote>/<branch>`);
296
+ }
297
+ const recorded = branchOn(git(["symbolic-ref", "-q", `refs/remotes/${remote}/HEAD`]).out, [remote]);
298
+ const guarded = [base.name, recorded].filter((name) => name !== null);
299
+ if (guarded.includes(target) || guarded.includes(branch)) {
300
+ return stop(2, `could not run — ${branch} would push to ${remote}/${target}, the branch changes merge into: close a change from a working branch`);
301
+ }
302
+ const forkPoint = git(["merge-base", base.sha, "HEAD"]).out;
303
+ if (forkPoint === "") return stop(2, `could not run — HEAD shares no history with ${base.ref} here; a shallow clone may need \`git fetch --unshallow\``);
304
+
305
+ const dirty = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).raw !== "";
306
+ const tracking = git(["rev-parse", "--verify", "-q", `refs/remotes/${remote}/${target}^{commit}`]).out;
307
+ const upstream = merge !== "" && tracking !== "";
308
+ const unpushed = upstream ? Number(git(["rev-list", "--count", `${tracking}..HEAD`]).out) : Number(git(["rev-list", "--count", `${forkPoint}..HEAD`]).out);
309
+ if (!dirty && unpushed === 0) {
310
+ return stop(0, upstream ? `nothing to close — the tree is clean and ${branch} matches ${remote}/${target}` : `nothing to close — the tree is clean and ${branch} is at ${base.ref}`);
311
+ }
312
+
313
+ const untracked = git(["ls-files", "--others", "--exclude-standard", "-z", "--", "."]).raw.split("\0").filter(Boolean);
314
+ if (untracked.length) {
315
+ const shown = untracked.slice(0, TAIL_LINES).map((p) => ` ${p}`);
316
+ if (untracked.length > TAIL_LINES) shown.push(` and ${untracked.length - TAIL_LINES} more`);
317
+ return stop(
318
+ 1,
319
+ `stopped — ${untracked.length} untracked path(s) that nobody staged. Stage by name the ones this change created, with ` +
320
+ "`git add <paths> &&` before this command in the same call; leave any other where it is and list it in " +
321
+ ".git/info/exclude, which is never committed, or ask whoever put it there. Never delete a file this change did " +
322
+ "not create. Nothing was committed or pushed.",
323
+ ...shown,
324
+ );
325
+ }
326
+
327
+ let fragment = "no fragment owed: this tree keeps no changes/ directory";
328
+ if (fs.existsSync(path.join(root, CHANGES_README))) {
329
+ const found = fragmentIn(changedPaths(git, forkPoint));
330
+ if (found !== null) fragment = `fragment ${found}`;
331
+ else if (options.noFragment !== null) fragment = `no fragment: ${options.noFragment}`;
332
+ else {
333
+ return stop(
334
+ 1,
335
+ `stopped — this change carries no changelog fragment since ${base.ref}: write ${CHANGES_DIR}/<slug>.<section>.md, one bullet, ` +
336
+ `or pass --no-fragment "<why>" where the change owes none. Nothing was committed or pushed.`,
337
+ );
338
+ }
339
+ }
340
+
341
+ const workspaceDir = options.workspace ?? path.resolve(root, env.PORTULAN_WORKSPACE || ".portulan");
342
+ const set = recipesOf({ root, workspaceDir, named: options.named, forced: options.forced });
343
+ if (set.why) return stop(2, `could not run — the recipe set: ${set.why}. Nothing was committed or pushed.`);
344
+
345
+ let made = null;
346
+ if (dirty) {
347
+ if (message === null) return stop(2, "could not run — the tree holds work to commit and no message was given: pass -m <subject> or -F <file>");
348
+ const staged = git(["add", "-u", "--", "."]);
349
+ if (staged.status !== 0) return stop(2, `could not run — \`git add\` failed: ${lastLine(staged.err)}`);
350
+ const committed = git(["commit", "-q", "-F", "-"], { input: message });
351
+ if (committed.status !== 0) {
352
+ // Git exits 1 where a hook refused the commit, a verdict on the change; 128 where it could not commit at all.
353
+ return stop(committed.status === 1 ? 1 : 2, `stopped — the commit was refused (git exit ${committed.status ?? "none"}), so nothing was committed or pushed; the changes are staged`, tail(`${committed.raw}\n${committed.err}`));
354
+ }
355
+ made = git(["rev-parse", "HEAD"]).out;
356
+ }
357
+
358
+ const undo = (why) => {
359
+ if (made === null) return "";
360
+ // With the old value given, `update-ref` moves HEAD back only while it still holds `made`.
361
+ const moved = git(["update-ref", "-m", `finish: ${why}`, "HEAD", parent, made]);
362
+ return moved.status === 0
363
+ ? " The commit is undone and its changes are staged."
364
+ : ` The commit ${made.slice(0, 7)} could not be undone (${lastLine(moved.err)}) and stays, unpushed.`;
365
+ };
366
+
367
+ // The recipes judge the commit, not the tree before it: `docs` reads the newest commit's message for its `Seam-scan:` line.
368
+ const judged = git(["rev-parse", "--verify", "-q", "HEAD^{commit}"]).out;
369
+ const recipeEnv = { ...env, PORTULAN_BASE_REF: base.ref };
370
+ const results = [];
371
+ for (const recipe of set.recipes) {
372
+ try {
373
+ results.push(resultOf(recipe.id, await runOne(recipe, { root, env: recipeEnv })));
374
+ } catch (error) {
375
+ results.push({ id: recipe.id, outcome: "could not run", code: null, output: textOf(error) });
376
+ }
377
+ }
378
+ const failed = results.filter((r) => r.outcome !== "green");
379
+ if (failed.length) {
380
+ const code = failed.some((r) => r.outcome === "red") ? 1 : 2;
381
+ const named = failed.map((r) => r.id).join(", ");
382
+ return stop(
383
+ code,
384
+ `stopped — ${failed.length} of ${results.length} recipe(s) not green: ${named}.${undo(`${named} not green`)} Nothing was pushed.`,
385
+ ...failed.flatMap((r) => [`${r.id} — ${r.outcome === "red" ? "RED" : "could not run"} (exit ${r.code ?? "none"}):`, tail(r.output).replace(/^/gm, " ")]),
386
+ );
387
+ }
388
+
389
+ // The judged commit is pushed by its SHA, and only while the branch still holds it.
390
+ const now = git(["rev-parse", "--verify", "-q", `refs/heads/${branch}^{commit}`]).out;
391
+ if (now !== judged) {
392
+ return stop(
393
+ 2,
394
+ `not pushed — ${branch} moved while the recipes ran, from ${judged.slice(0, 7)} to ${now.slice(0, 7) || "nowhere"}, so they did not judge ` +
395
+ "what it holds; both commits stay, unpushed: run this again.",
396
+ );
397
+ }
398
+ const pushed = git(["push", remote, `${judged}:refs/heads/${target}`]);
399
+ if (pushed.status !== 0) {
400
+ return stop(
401
+ 2,
402
+ `not pushed — ${remote} refused ${branch}: ${refusalOf(pushed.err)}. ${made ? `The commit ${made.slice(0, 7)} is green and stays` : "The branch is green"}; ` +
403
+ "if the remote moved, merge it in, never force, and run this again.",
404
+ );
405
+ }
406
+ if (!upstream) {
407
+ git(["config", `branch.${branch}.remote`, remote]);
408
+ git(["config", `branch.${branch}.merge`, `refs/heads/${target}`]);
409
+ }
410
+ const head = git(["rev-parse", "--short", judged]).out;
411
+ const files = made === null ? 0 : git(["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", made]).raw.split("\0").filter(Boolean).length;
412
+ const left = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).raw.split("\0").filter(Boolean).length;
413
+ return stop(
414
+ 0,
415
+ `closed ${branch} at ${head} — ${made ? `committed ${files} file(s)` : "nothing new to commit"}; ${fragment}; ${results.length} recipe(s) green; pushed to ${remote}/${target}` +
416
+ (left ? `; ${left} path(s) changed while the recipes ran and are not committed` : ""),
417
+ );
418
+ }
419
+
420
+ export async function run(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, readStdin } = {}) {
421
+ const options = parseArgs(argv, cwd);
422
+ if (options.error) {
423
+ stderr.write(`finish: ${options.error}\n${USAGE}\n`);
424
+ return 2;
425
+ }
426
+ if (options.help) {
427
+ stdout.write(`${USAGE}\n`);
428
+ return 0;
429
+ }
430
+ const { code, lines } = await finish(options, { cwd, env, readStdin });
431
+ stdout.write(`${lines.join("\n")}\n`);
432
+ return code;
433
+ }
434
+
435
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
436
+ process.exitCode = await run();
437
+ }