@sleepy_panda_srl/portulan 0.1.3 → 0.2.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.
- package/README.md +6 -5
- package/cli/README.md +142 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1322 -1607
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +224 -1074
- package/cli/drills.mjs +68 -502
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +17 -258
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +27 -492
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +255 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +3 -3
- package/package.json +4 -2
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +333 -21
- package/spec/workspace.schema.json +145 -4
package/cli/form.mjs
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// `form` — which form a consumer's records and boot are in, and the files that move them to the new one.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here writes: callers write the texts it returns through their own guards.
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { BOOT_CARD_LINE, BOOT_CARD_UNIT, GUIDANCE_RULES_DIR, leadsOfText } from "./compile.mjs";
|
|
10
|
+
import { CHANGE_SECTIONS, readChanges, renderChanges } from "./index.mjs";
|
|
11
|
+
import { instructionsState, shellWord, splitCommand } from "./instructions.mjs";
|
|
12
|
+
import { recipeSet } from "./recipe-set.mjs";
|
|
13
|
+
|
|
14
|
+
/** Anything that means the form could not be read. Carries no verdict. */
|
|
15
|
+
export class FormError extends Error {}
|
|
16
|
+
|
|
17
|
+
export const CHANGES_DIR = "changes";
|
|
18
|
+
export const CHANGES_README = `${CHANGES_DIR}/README.md`;
|
|
19
|
+
|
|
20
|
+
export const CHANGELOG = "CHANGELOG.md";
|
|
21
|
+
|
|
22
|
+
export const COMPILED_CARD = `${GUIDANCE_RULES_DIR}/${BOOT_CARD_UNIT}.md`;
|
|
23
|
+
|
|
24
|
+
const SESSION_LOG = /^(#{1,6})[ \t]+Session log[ \t]*#*[ \t]*$/;
|
|
25
|
+
|
|
26
|
+
const UNRELEASED = /^##[ \t]+\[?Unreleased\]?[ \t]*$/i;
|
|
27
|
+
|
|
28
|
+
const BULLET = /^[-*+][ \t]/;
|
|
29
|
+
|
|
30
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
31
|
+
|
|
32
|
+
/** Whether anything is at `file`, a link included: `lstat`, never `existsSync`, which follows one. */
|
|
33
|
+
function present(file) {
|
|
34
|
+
try {
|
|
35
|
+
fs.lstatSync(file);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
if (cause.code === "ENOENT" || cause.code === "ENOTDIR") return false;
|
|
39
|
+
throw new FormError(`${file} could not be examined — ${cause.code ?? cause.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readOrNull(file) {
|
|
44
|
+
try {
|
|
45
|
+
return fs.readFileSync(file, "utf8");
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
if (cause.code === "ENOENT" || cause.code === "ENOTDIR") return null;
|
|
48
|
+
throw new FormError(`${file} could not be read — ${cause.code ?? cause.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ------------------------------------------------------------------------- the new form's files, as texts
|
|
53
|
+
|
|
54
|
+
export function changesReadme() {
|
|
55
|
+
return `# Changelog fragments
|
|
56
|
+
|
|
57
|
+
One file per entry for the next release, so two open changes never edit the same lines. Name each
|
|
58
|
+
\`<slug>.<section>.md\`, the section one of ${CHANGE_SECTIONS.slice(0, -1).join(", ")} or ${CHANGE_SECTIONS.at(-1)}, and write one top-level
|
|
59
|
+
bullet, with any link relative to this directory. \`portulan index --changes changes\` prints them grouped,
|
|
60
|
+
with their links moved up one directory, as the release cut pastes them under the new version in
|
|
61
|
+
\`../${CHANGELOG}\`. The cut then deletes them and keeps this file, so the directory stays tracked.
|
|
62
|
+
|
|
63
|
+
The entries \`portulan upgrade\` moved here from the changelog's Unreleased are named
|
|
64
|
+
\`<n>-<slug>.<section>.md\`, numbered in the changelog's order and padded to one width, since the cut reads
|
|
65
|
+
fragments by name: they print as the changelog held them, but for an entry that did not open \`- \`, whose
|
|
66
|
+
fragment does, and ahead of any fragment named by its slug.
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function changelogPointer() {
|
|
71
|
+
return [
|
|
72
|
+
`Each entry for the next release is a file in [\`${CHANGES_DIR}/\`](${CHANGES_DIR}/), so two open changes never edit the`,
|
|
73
|
+
`same lines; \`portulan index --changes ${CHANGES_DIR}\` prints them as the cut pastes them.`,
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sessionLogPointer({ date, sha, file }) {
|
|
78
|
+
return [
|
|
79
|
+
`Retired ${date}: a change's record is its commit message, and \`git log --first-parent\` lists what`,
|
|
80
|
+
`landed. The entries written until then are at \`git show ${sha}:${file}\`.`,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function handoffsReadme() {
|
|
85
|
+
return `# Handoffs
|
|
86
|
+
|
|
87
|
+
A handoff carries work still open, so the next session starts from where things stand. Write one only
|
|
88
|
+
when a session ends with work not committed and pushed: committed work carries its why in its commit
|
|
89
|
+
message, and needs none. Name it \`YYYY-MM-DD-<slug>.md\`, keep only the parts that have something to say,
|
|
90
|
+
and five lines is a valid handoff. Drafted from the engine's \`core/templates/handoff.md\`.
|
|
91
|
+
|
|
92
|
+
**State.** Where things stand: what is committed, and what is in progress and uncommitted.
|
|
93
|
+
|
|
94
|
+
**Open questions.** What is undecided, and who decides it.
|
|
95
|
+
|
|
96
|
+
**Next action.** The single next step, concrete enough to start from cold.
|
|
97
|
+
|
|
98
|
+
**Recoverability.** Anything left in a partial state, and how to make it safe.
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function handoffIndexIgnore(rel, workspaceRel) {
|
|
103
|
+
return [
|
|
104
|
+
`# The handoff index is printed on demand (\`portulan index --handoffs ${workspaceRel}\`), never kept: a`,
|
|
105
|
+
"# committed copy conflicts on every merge that adds a handoff, and carries nothing the series does not.",
|
|
106
|
+
`/${rel}`,
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function withIgnoreLines(text, lines) {
|
|
111
|
+
const have = new Set((text ?? "").split(/\r?\n/).map((l) => l.trim()));
|
|
112
|
+
const patterns = lines.filter((l) => !l.startsWith("#"));
|
|
113
|
+
if (patterns.every((l) => have.has(l))) return text ?? "";
|
|
114
|
+
const base = text ?? "";
|
|
115
|
+
const sep = base === "" ? "" : base.endsWith("\n\n") ? "" : base.endsWith("\n") ? "\n" : "\n\n";
|
|
116
|
+
return `${base}${sep}${lines.join("\n")}\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const COMMENTS_RECIPE = "comments";
|
|
120
|
+
|
|
121
|
+
export function commentsRecipeOf(manifest) {
|
|
122
|
+
const own = recipeSet(manifest, { packs: [] });
|
|
123
|
+
const recipe = own.ok ? own.recipes.find((entry) => entry.id === COMMENTS_RECIPE) : undefined;
|
|
124
|
+
if (!recipe) return null;
|
|
125
|
+
return /(?:^|\/)verify\/comments\.sh'?$/.test(recipe.run) ? { drafted: true } : { drafted: false, run: recipe.run };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function commentsRecipeEntry(workspaceRel) {
|
|
129
|
+
const run = shellWord(workspaceRel === "." ? "./verify/comments.sh" : `./${workspaceRel}/verify/comments.sh`);
|
|
130
|
+
return { id: COMMENTS_RECIPE, run, requires: ["bash", "git", "node"] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function commentsRecipe({ bundle, limit, toTree }) {
|
|
134
|
+
const entry = JSON.stringify(`${bundle}/cli/index.mjs`);
|
|
135
|
+
return `#!/usr/bin/env bash
|
|
136
|
+
# Comments rail: no more comment lines record a change's history than LIMIT. A comment is paid for on
|
|
137
|
+
# every read of its file, and a change's history goes in its commit message, so LIMIT is only lowered.
|
|
138
|
+
# Code not written here, vendored, is left out with \`--exclude <dir>/\` on the \`node\` line.
|
|
139
|
+
#
|
|
140
|
+
# exit 0 green: the count is within LIMIT
|
|
141
|
+
# exit 1 red: it is over, and the lines are listed
|
|
142
|
+
# exit 2 could not run: git, node or the Portulan CLI is not reachable from here. NEVER a pass.
|
|
143
|
+
|
|
144
|
+
set -uo pipefail
|
|
145
|
+
cd -- "\$(dirname -- "\$0")"/${shellWord(toTree)} || exit 2
|
|
146
|
+
|
|
147
|
+
LIMIT=${limit}
|
|
148
|
+
|
|
149
|
+
for need in git node; do
|
|
150
|
+
command -v "\$need" >/dev/null 2>&1 || {
|
|
151
|
+
printf 'verify: %s is needed and is not on PATH, so the comments were NOT counted.\\n' "\$need" >&2
|
|
152
|
+
exit 2
|
|
153
|
+
}
|
|
154
|
+
done
|
|
155
|
+
|
|
156
|
+
if [ -n "\${PORTULAN_CLI:-}" ]; then
|
|
157
|
+
cli=\$PORTULAN_CLI
|
|
158
|
+
elif command -v portulan >/dev/null 2>&1 &&
|
|
159
|
+
entry=\$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "\$(command -v portulan)") &&
|
|
160
|
+
[ -f "\$(dirname -- "\$entry")/comments.mjs" ]; then
|
|
161
|
+
cli=\$(dirname -- "\$entry")
|
|
162
|
+
elif [ -f ${entry} ]; then # portulan:bundle-fallback
|
|
163
|
+
cli=\$(dirname -- ${entry}) # portulan:bundle-fallback
|
|
164
|
+
else
|
|
165
|
+
printf 'verify: the Portulan CLI is not reachable, so the comments were NOT counted. Looked at\\n' >&2
|
|
166
|
+
printf 'verify: $PORTULAN_CLI, a portulan on PATH holding comments.mjs, and the bundle this workspace was drafted from.\\n' >&2
|
|
167
|
+
exit 2
|
|
168
|
+
fi
|
|
169
|
+
if [ ! -f "\$cli/comments.mjs" ]; then
|
|
170
|
+
printf 'verify: %s holds no comments.mjs, so the comments were NOT counted.\\n' "\$cli" >&2
|
|
171
|
+
exit 2
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
node "\$cli/comments.mjs" --limit "\$LIMIT"
|
|
175
|
+
status=\$?
|
|
176
|
+
[ "\$status" -le 2 ] && exit "\$status"
|
|
177
|
+
printf 'verify: comments.mjs exited %s, which is no verdict, so the comments were NOT counted.\\n' "\$status" >&2
|
|
178
|
+
exit 2
|
|
179
|
+
`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------------------- git
|
|
183
|
+
|
|
184
|
+
export function gitIn(root) {
|
|
185
|
+
const run = (...args) => {
|
|
186
|
+
const out = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
187
|
+
if (out.error) return { status: null, stdout: "", stderr: String(out.error.code ?? out.error.message) };
|
|
188
|
+
return { status: out.status, stdout: out.stdout, stderr: out.stderr };
|
|
189
|
+
};
|
|
190
|
+
const probe = run("rev-parse", "--is-inside-work-tree");
|
|
191
|
+
if (probe.status !== 0 || probe.stdout.trim() !== "true") return null;
|
|
192
|
+
return run;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The `.gitignore` lines that let git see the compiled guidance, none where it already can; `git` false where no git answered. */
|
|
196
|
+
export function claudeRulesUnignore(root, git = gitIn(root)) {
|
|
197
|
+
if (git === null) return { lines: [], git: false };
|
|
198
|
+
const ignored = (rel) => {
|
|
199
|
+
const out = git("check-ignore", "-q", "--no-index", rel);
|
|
200
|
+
if (out.status !== 0 && out.status !== 1) throw new FormError(`git check-ignore could not answer for ${rel} — ${out.stderr.trim() || `exit ${out.status}`}`);
|
|
201
|
+
return out.status === 0;
|
|
202
|
+
};
|
|
203
|
+
if (!ignored(COMPILED_CARD)) return { lines: [], git: true };
|
|
204
|
+
const lines = ["# `portulan compile` writes this repository's boot card and guidance to .claude/rules/portulan/, reviewed", "# like code; everything else the host keeps under .claude/ stays ignored."];
|
|
205
|
+
// Git re-includes nothing below an excluded directory, and answers reliably for a file, not a directory: each level is probed with one.
|
|
206
|
+
if (ignored(".claude/portulan-probe")) lines.push("!/.claude/", "/.claude/*");
|
|
207
|
+
if (ignored(".claude/rules/portulan-probe") || lines.length > 2) lines.push("!/.claude/rules/", "/.claude/rules/*");
|
|
208
|
+
lines.push("!/.claude/rules/portulan/", "!/.claude/rules/portulan/*");
|
|
209
|
+
return { lines, git: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function cardIgnored(root, git = gitIn(root)) {
|
|
213
|
+
if (git === null) return false;
|
|
214
|
+
return git("check-ignore", "-q", "--no-index", COMPILED_CARD).status === 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ------------------------------------------------------------------------- which workspaces a step moving the form reads
|
|
218
|
+
|
|
219
|
+
export function notYetForm(ws, ctx) {
|
|
220
|
+
const declared = ws.manifest?.portulan?.spec;
|
|
221
|
+
const major = Number(String(declared).split(".")[0]);
|
|
222
|
+
if (major === ctx?.spec?.major) return null;
|
|
223
|
+
return `this workspace declares ${declared}, and a step moving the form reads ${ctx?.spec?.major}.x: a version step moves it first`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------------- the Session log
|
|
227
|
+
|
|
228
|
+
/** Whether each of `lines` is fenced code, fence lines included; a fence closes on its character whatever length it opened with. */
|
|
229
|
+
export function fenced(lines) {
|
|
230
|
+
let fence = null;
|
|
231
|
+
return lines.map((line) => {
|
|
232
|
+
const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
233
|
+
if (!marker) return fence !== null;
|
|
234
|
+
fence = fence === null ? marker[1][0] : marker[1][0] === fence ? null : fence;
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function sessionLogSections(text) {
|
|
240
|
+
const lines = text.split("\n");
|
|
241
|
+
const found = [];
|
|
242
|
+
const code = fenced(lines);
|
|
243
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
244
|
+
if (code[i]) continue;
|
|
245
|
+
const heading = SESSION_LOG.exec(lines[i]);
|
|
246
|
+
if (!heading) continue;
|
|
247
|
+
const level = heading[1].length;
|
|
248
|
+
let end = i + 1;
|
|
249
|
+
for (; end < lines.length; end += 1) {
|
|
250
|
+
if (!code[end] && /^(#{1,6})[ \t]/.exec(lines[end]) && /^(#{1,6})/.exec(lines[end])[1].length <= level) break;
|
|
251
|
+
}
|
|
252
|
+
const body = lines.slice(i + 1, end);
|
|
253
|
+
const first = body.find((l) => l.trim() !== "");
|
|
254
|
+
found.push({ line: i + 1, start: i, end, entries: first !== undefined && !first.startsWith("Retired ") });
|
|
255
|
+
}
|
|
256
|
+
return found;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function retireSessionLogs(text, pointer) {
|
|
260
|
+
const sections = sessionLogSections(text).filter((s) => s.entries);
|
|
261
|
+
if (sections.length === 0) return text;
|
|
262
|
+
const lines = text.split("\n");
|
|
263
|
+
for (const s of [...sections].reverse()) {
|
|
264
|
+
const tail = s.end < lines.length ? [""] : lines.at(-1) === "" ? [""] : [];
|
|
265
|
+
lines.splice(s.start + 1, s.end - s.start - 1, "", ...pointer, ...tail);
|
|
266
|
+
}
|
|
267
|
+
return lines.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Every `.md` file under `root` on disk, never through a link; of the dot-directories, only the way down to `keep` is walked. */
|
|
271
|
+
export function markdownOnDisk(root, keep = null) {
|
|
272
|
+
const out = [];
|
|
273
|
+
const walk = (dir, rel, narrow) => {
|
|
274
|
+
let entries;
|
|
275
|
+
try {
|
|
276
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
277
|
+
} catch (cause) {
|
|
278
|
+
if (cause.code === "ENOENT" || cause.code === "ENOTDIR") return;
|
|
279
|
+
throw new FormError(`${dir} could not be listed — ${cause.code ?? cause.message}`);
|
|
280
|
+
}
|
|
281
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
282
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
283
|
+
const toward = keep !== null && (r === keep || keep.startsWith(`${r}/`));
|
|
284
|
+
if (narrow && !toward) continue;
|
|
285
|
+
if (e.isDirectory()) {
|
|
286
|
+
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
287
|
+
if (e.name.startsWith(".") && !toward) continue;
|
|
288
|
+
walk(path.join(dir, e.name), r, r !== keep && (narrow || e.name.startsWith(".")));
|
|
289
|
+
} else if (e.isFile() && e.name.endsWith(".md")) out.push(r);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
walk(root, "", false);
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function sessionLogsIn(root, files) {
|
|
297
|
+
const found = [];
|
|
298
|
+
for (const rel of files) {
|
|
299
|
+
const text = readOrNull(path.join(root, ...rel.split("/")));
|
|
300
|
+
if (text === null || !text.includes("Session log")) continue;
|
|
301
|
+
const logs = sessionLogSections(text).filter((s) => s.entries);
|
|
302
|
+
if (logs.length) found.push({ file: rel, line: logs[0].line });
|
|
303
|
+
}
|
|
304
|
+
return found;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ------------------------------------------------------------------------- the changelog's Unreleased section, as fragments
|
|
308
|
+
|
|
309
|
+
function slugOf(bullet) {
|
|
310
|
+
const words = bullet
|
|
311
|
+
.split("\n")[0]
|
|
312
|
+
.replace(/^[-*+][ \t]+/, "")
|
|
313
|
+
.replace(/\]\([^)]*\)/g, "]")
|
|
314
|
+
.replace(/[`*_[\]]/g, "")
|
|
315
|
+
.toLowerCase()
|
|
316
|
+
.split(/[^a-z0-9]+/)
|
|
317
|
+
.filter(Boolean);
|
|
318
|
+
let slug = "";
|
|
319
|
+
for (const w of words) {
|
|
320
|
+
if (slug.length + w.length + 1 > 48) break;
|
|
321
|
+
slug = slug ? `${slug}-${w}` : w;
|
|
322
|
+
}
|
|
323
|
+
return slug || "entry";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Each relative link one directory deeper, as a fragment under `changes/` needs: `renderChanges` takes one `../` off again. */
|
|
327
|
+
function rerooted(text) {
|
|
328
|
+
return text.replace(/\]\(([^)\s]*)/g, (whole, target) => (target === "" || /^(?:[a-z][a-z0-9+.-]*:|#|\/|<)/i.test(target) ? whole : `](../${target}`));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The Unreleased entries as fragments, and the changelog without them; null where it has no Unreleased heading. */
|
|
332
|
+
export function unreleasedFragments(text, taken = new Set()) {
|
|
333
|
+
const lines = text.split("\n");
|
|
334
|
+
const { start, end, code } = unreleasedSpan(lines);
|
|
335
|
+
if (start === -1) return null;
|
|
336
|
+
|
|
337
|
+
const entries = [];
|
|
338
|
+
const kept = [];
|
|
339
|
+
let section = "changed";
|
|
340
|
+
for (let i = start + 1; i < end; i += 1) {
|
|
341
|
+
const line = lines[i];
|
|
342
|
+
if (code[i]) {
|
|
343
|
+
kept.push({ line, heading: false });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const sub = /^###[ \t]+(.+?)[ \t]*$/.exec(line);
|
|
347
|
+
if (sub) {
|
|
348
|
+
const named = sub[1].toLowerCase();
|
|
349
|
+
if (!CHANGE_SECTIONS.includes(named)) {
|
|
350
|
+
return { refused: `${CHANGELOG} line ${i + 1}: \`${line}\` under Unreleased is none of ${CHANGE_SECTIONS.join(", ")}, so its entries have no fragment name — rename the heading or move them, then upgrade` };
|
|
351
|
+
}
|
|
352
|
+
section = named;
|
|
353
|
+
kept.push({ line, heading: true });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (BULLET.test(line)) {
|
|
357
|
+
const body = [line.replace(/^[-*+][ \t]/, "- ")];
|
|
358
|
+
let j = i + 1;
|
|
359
|
+
while (j < end && (/^[ \t]+\S/.test(lines[j]) || (lines[j].trim() === "" && j + 1 < end && /^[ \t]+\S/.test(lines[j + 1])))) {
|
|
360
|
+
body.push(lines[j]);
|
|
361
|
+
j += 1;
|
|
362
|
+
}
|
|
363
|
+
entries.push({ section, text: body.join("\n").replace(/\s+$/, ""), original: [line, ...lines.slice(i + 1, j)].join("\n").replace(/\s+$/, "") });
|
|
364
|
+
i = j - 1;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
kept.push({ line, heading: false });
|
|
368
|
+
}
|
|
369
|
+
if (entries.length === 0) return { fragments: [], next: text };
|
|
370
|
+
|
|
371
|
+
const width = String(entries.length).length;
|
|
372
|
+
const fragments = entries.map((e, n) => {
|
|
373
|
+
let name = `${String(n + 1).padStart(width, "0")}-${slugOf(e.text)}.${e.section}.md`;
|
|
374
|
+
for (let k = 2; taken.has(name); k += 1) name = `${String(n + 1).padStart(width, "0")}-${slugOf(e.text)}-${k}.${e.section}.md`;
|
|
375
|
+
return { name, section: e.section, text: `${rerooted(e.text)}\n`, original: e };
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const byName = [...fragments].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
379
|
+
const printed = renderChanges(byName.map((f) => ({ name: f.name, section: f.section, text: f.text.replace(/\s+$/, "") })));
|
|
380
|
+
for (const section of CHANGE_SECTIONS) {
|
|
381
|
+
const want = entries.filter((e) => e.section === section).map((e) => e.text);
|
|
382
|
+
if (want.length === 0) continue;
|
|
383
|
+
const block = printed.split(/^### /m).find((b) => b.toLowerCase().startsWith(`${section}\n`));
|
|
384
|
+
const got = (block ?? "").split("\n").slice(1).join("\n").trim();
|
|
385
|
+
if (got !== want.join("\n\n")) {
|
|
386
|
+
return { refused: `the Unreleased entries under ${section} would not print back as ${CHANGELOG} holds them once moved to fragments — move them by hand, then upgrade` };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const rest = [];
|
|
391
|
+
for (let k = 0; k < kept.length; k += 1) {
|
|
392
|
+
if (kept[k].heading) {
|
|
393
|
+
const until = kept.findIndex((x, m) => m > k && x.heading);
|
|
394
|
+
const under = kept.slice(k + 1, until === -1 ? kept.length : until);
|
|
395
|
+
if (!under.some((x) => x.line.trim() !== "")) continue;
|
|
396
|
+
}
|
|
397
|
+
rest.push(kept[k].line);
|
|
398
|
+
}
|
|
399
|
+
while (rest.length && rest[0].trim() === "") rest.shift();
|
|
400
|
+
while (rest.length && rest.at(-1).trim() === "") rest.pop();
|
|
401
|
+
const body = ["", ...changelogPointer(), "", ...(rest.length ? [...rest, ""] : [])];
|
|
402
|
+
const next = [...lines.slice(0, start + 1), ...body, ...lines.slice(end)].join("\n");
|
|
403
|
+
return { fragments: fragments.map((f) => ({ name: f.name, text: f.text })), next };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 1-based lines of the Unreleased entries not opening `- `, which the move rewrites, since the cut refuses any other opening. */
|
|
407
|
+
export function unreleasedRewrites(text) {
|
|
408
|
+
const lines = text.split("\n");
|
|
409
|
+
const { start, end, code } = unreleasedSpan(lines);
|
|
410
|
+
const found = [];
|
|
411
|
+
for (let i = start + 1; start !== -1 && i < end; i += 1) if (!code[i] && BULLET.test(lines[i]) && !lines[i].startsWith("- ")) found.push(i + 1);
|
|
412
|
+
return found;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function unreleasedCount(text) {
|
|
416
|
+
const lines = text.split("\n");
|
|
417
|
+
const { start, end, code } = unreleasedSpan(lines);
|
|
418
|
+
if (start === -1) return null;
|
|
419
|
+
let n = 0;
|
|
420
|
+
for (let i = start + 1; i < end; i += 1) if (!code[i] && BULLET.test(lines[i])) n += 1;
|
|
421
|
+
return n;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function unreleasedSpan(lines) {
|
|
425
|
+
const code = fenced(lines);
|
|
426
|
+
const start = lines.findIndex((l, i) => !code[i] && UNRELEASED.test(l));
|
|
427
|
+
let end = start + 1;
|
|
428
|
+
while (start !== -1 && end < lines.length && (code[end] || !/^##[ \t]/.test(lines[end]))) end += 1;
|
|
429
|
+
return { start, end, code };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ------------------------------------------------------------------------- the boot card
|
|
433
|
+
|
|
434
|
+
/** The card's section on reading and the cache: `compile` expands `READING_LINE` into the installed engine's rules. */
|
|
435
|
+
export const READING_TITLE = "Reading and the cache: Portulan's `core/operating/context.md`";
|
|
436
|
+
export const READING_LINE = "<!-- engine: operating/context.md#every-request-pays-for-what-the-session-has-read -->";
|
|
437
|
+
|
|
438
|
+
const HEAD_BEFORE_READING = /^(> Compiled by `portulan compile` from .+\. Each section names its file): an(\r?\n)> import is here in full; open any other file when its subject is your task\.$/m;
|
|
439
|
+
|
|
440
|
+
export const HEAD_BEFORE_READING_SHOWN =
|
|
441
|
+
'"> Compiled by `portulan compile` from <the card>. Each section names its file: an" over ' +
|
|
442
|
+
'"> import is here in full; open any other file when its subject is your task."';
|
|
443
|
+
|
|
444
|
+
export function carriesReading(card) {
|
|
445
|
+
return card.split(/\r?\n/).includes(READING_LINE);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** A card drafted before the reading section, rewritten as `draftCard` writes it now; null where it has the section or another head. */
|
|
449
|
+
export function withReading(card) {
|
|
450
|
+
if (carriesReading(card)) return null;
|
|
451
|
+
const head = HEAD_BEFORE_READING.exec(card);
|
|
452
|
+
if (!head) return null;
|
|
453
|
+
const [old, opening, eol] = head;
|
|
454
|
+
const moved = [`${opening}, and an`, "> import is here in full.", "", `## ${READING_TITLE}`, "", READING_LINE].join(eol);
|
|
455
|
+
return card.slice(0, head.index) + moved + card.slice(head.index + old.length);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function leadsFit(text, name) {
|
|
459
|
+
if (text === null) return false;
|
|
460
|
+
try {
|
|
461
|
+
leadsOfText(text, name, "card");
|
|
462
|
+
return true;
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The boot card, `context/boot.md`: each fact imported, or written out by `compile`, from the file that holds it, never copied. */
|
|
469
|
+
export function draftCard(manifest, read, { workspace, inTree, repoCards = [] }) {
|
|
470
|
+
const slots = manifest.slots ?? {};
|
|
471
|
+
const shown = (rel) => `\`${posix(path.posix.normalize(`${workspace}/${rel}`))}\``;
|
|
472
|
+
const imported = (rel) => `@../${rel}`;
|
|
473
|
+
const out = [
|
|
474
|
+
"---",
|
|
475
|
+
"tier: always",
|
|
476
|
+
"---",
|
|
477
|
+
"",
|
|
478
|
+
BOOT_CARD_LINE,
|
|
479
|
+
"",
|
|
480
|
+
`> Compiled by \`portulan compile\` from ${shown("context/boot.md")}. Each section names its file, and an`,
|
|
481
|
+
"> import is here in full.",
|
|
482
|
+
];
|
|
483
|
+
const section = (title, ...body) => out.push("", `## ${title}`, "", ...body);
|
|
484
|
+
const whole = (rel, why) => (inTree(rel) ? [imported(rel)] : [`Read ${shown(rel)} in full at boot: ${why}.`]);
|
|
485
|
+
|
|
486
|
+
section(READING_TITLE, READING_LINE);
|
|
487
|
+
|
|
488
|
+
if (slots.identity) section(`Identity: ${shown(slots.identity)}`, ...whole(slots.identity, "it lies outside this repository, where no import reaches"));
|
|
489
|
+
if (slots.principles) {
|
|
490
|
+
const fits = leadsFit(read(slots.principles), slots.principles);
|
|
491
|
+
section(
|
|
492
|
+
`Principles: ${shown(slots.principles)}`,
|
|
493
|
+
...(fits ? [`<!-- leads: ../${slots.principles} -->`, "", "Each has its reason there: read it before you set one aside."] : whole(slots.principles, "it lies outside this repository")),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (slots.constitution) {
|
|
497
|
+
section(`Constitution: ${shown(slots.constitution)}`, ...whole(slots.constitution, "it lies outside this repository, where no import reaches"));
|
|
498
|
+
}
|
|
499
|
+
if (typeof manifest.gates === "string" || slots.gates) {
|
|
500
|
+
const policy = typeof manifest.gates === "string" && read(manifest.gates) !== null ? manifest.gates : null;
|
|
501
|
+
const body = [];
|
|
502
|
+
if (policy) body.push(`<!-- gates: ../${policy} -->`, "");
|
|
503
|
+
if (slots.gates) {
|
|
504
|
+
body.push(
|
|
505
|
+
policy
|
|
506
|
+
? `**Open ${shown(slots.gates)} before an act a gate names**: it holds each gate's conditions, and where no layer enforces one, so you hold it yourself.`
|
|
507
|
+
: `Read ${shown(slots.gates)} in full at boot: this workspace declares no gate policy for the card to list.`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (body.length === 0) body.push(`${shown(manifest.gates)} could not be read when this card was drafted: \`portulan doctor\` names why.`);
|
|
511
|
+
section(`Gates: ${shown(policy ?? slots.gates ?? manifest.gates)}`, ...body);
|
|
512
|
+
}
|
|
513
|
+
if (slots.dod) {
|
|
514
|
+
const fits = leadsFit(read(slots.dod), slots.dod);
|
|
515
|
+
section(
|
|
516
|
+
`Done: ${shown(slots.dod)}`,
|
|
517
|
+
"On core's floor: a green verify, and nothing reported done that you could not explain.",
|
|
518
|
+
...(fits ? ["Read the file before you call work done.", "", `<!-- leads: ../${slots.dod} -->`] : ["", ...whole(slots.dod, "it lies outside this repository")]),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (slots.repos) {
|
|
522
|
+
const only = repoCards.length === 1 ? `${slots.repos.replace(/\/?$/, "/")}${repoCards[0]}.md` : null;
|
|
523
|
+
section(
|
|
524
|
+
only ? `This repository: ${shown(only)}` : `This repository: its card in ${shown(slots.repos)}`,
|
|
525
|
+
...(only && inTree(only) ? [imported(only)] : [`Read the card in ${shown(slots.repos)} that names this repository before you build, test or run anything here.`]),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
section(
|
|
529
|
+
"Records",
|
|
530
|
+
'Close a change with `git add <new files> && node <plugin root>/cli/finish.mjs -m "<subject>" -m "<why>"` after writing its changelog entry, a one-bullet file `changes/<slug>.<section>.md`: it commits and pushes only when every verify recipe passes.',
|
|
531
|
+
...(slots.handoffs ? [`A session ending with work not committed and pushed leaves a dated handoff in ${shown(slots.handoffs)}.`] : []),
|
|
532
|
+
);
|
|
533
|
+
section(
|
|
534
|
+
"What is enforced here, and what is not",
|
|
535
|
+
`- **The verify recipes are real**: ${shown("workspace.json")} declares them, and a session's end runs`,
|
|
536
|
+
" the default; each exits 0 green, 1 red or 2 could not run, never a pass.",
|
|
537
|
+
"- **Gates are enforced where `portulan compile` has written `.claude/settings.json`**; without it, you",
|
|
538
|
+
" hold every rule yourself.",
|
|
539
|
+
...(Array.isArray(manifest.packs) && manifest.packs.length
|
|
540
|
+
? ["- **A pack is resolved by a step of its own**: read the plugin's `skills/portulan/packs.md` before", " you say what one delivers."]
|
|
541
|
+
: []),
|
|
542
|
+
);
|
|
543
|
+
const index = manifest.memory?.index?.path;
|
|
544
|
+
if (typeof index === "string" && read(index) !== null && inTree(index)) {
|
|
545
|
+
section("Memory: the rules this team minted from its incidents", "Each record carries its provenance; open one when its title touches your task.", "", imported(index));
|
|
546
|
+
}
|
|
547
|
+
return `${out.join("\n")}\n`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ------------------------------------------------------------------------- which form a consumer is in
|
|
551
|
+
|
|
552
|
+
/** Why the handoff index at `rel` is kept, as `spec/migrations/0003` judges it: `{ kept: null }` where it is not, null where git cannot say. */
|
|
553
|
+
function indexKept(tree, rel, onDisk) {
|
|
554
|
+
const git = gitIn(tree);
|
|
555
|
+
const rule = git === null ? null : git("check-ignore", "-q", "--no-index", "--", rel);
|
|
556
|
+
if (rule === null || (rule.status !== 0 && rule.status !== 1)) return null;
|
|
557
|
+
if (rule.status === 1) return { kept: `is not git-ignored${onDisk ? ", and a copy is kept" : ""}` };
|
|
558
|
+
const tracked = onDisk && git("ls-files", "--error-unmatch", "--", rel).status === 0;
|
|
559
|
+
return { kept: tracked ? "is git-ignored and still tracked" : null };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Each piece of the form, read from disk as `doctor` reads: `new` or `today`, with `hand` where `upgrade` does not place it. */
|
|
563
|
+
export function formOf(workspaceDir, manifest) {
|
|
564
|
+
const pieces = [];
|
|
565
|
+
const add = (id, state, text) => pieces.push({ id, state, text });
|
|
566
|
+
const wsDir = path.resolve(workspaceDir);
|
|
567
|
+
const tree = typeof manifest?.tree === "string" ? path.resolve(wsDir, manifest.tree) : null;
|
|
568
|
+
if (tree === null) return { tree, pieces };
|
|
569
|
+
|
|
570
|
+
const changelog = readOrNull(path.join(tree, CHANGELOG));
|
|
571
|
+
const fragments = present(path.join(tree, CHANGES_README));
|
|
572
|
+
if (changelog !== null || fragments) add("changes", fragments ? "new" : "today", fragments ? `changelog fragments in ${CHANGES_DIR}/` : `no ${CHANGES_README}`);
|
|
573
|
+
const entries = changelog === null ? null : unreleasedCount(changelog);
|
|
574
|
+
if (entries) add("changelog", "today", `${CHANGELOG} holds ${entries} entr${entries === 1 ? "y" : "ies"} under Unreleased`);
|
|
575
|
+
else if (entries === 0) add("changelog", "new", `no entry under ${CHANGELOG}'s Unreleased`);
|
|
576
|
+
|
|
577
|
+
const logs = sessionLogsIn(tree, markdownOnDisk(tree, posix(path.relative(tree, wsDir)) || null));
|
|
578
|
+
add("session-log", logs.length ? "today" : "new", logs.length ? `a Session log with entries in ${logs.map((l) => l.file).join(", ")}` : "no Session log with entries");
|
|
579
|
+
|
|
580
|
+
const index = manifest?.handoffs?.index?.path;
|
|
581
|
+
if (typeof index === "string") {
|
|
582
|
+
const at = path.resolve(wsDir, index);
|
|
583
|
+
const rel = posix(path.relative(tree, at));
|
|
584
|
+
if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
585
|
+
const judged = indexKept(tree, rel, present(at));
|
|
586
|
+
if (judged?.kept) add("handoff-index", "today", `the handoff index at ${index} ${judged.kept}`);
|
|
587
|
+
else if (judged) add("handoff-index", "new", "the handoff index is printed on demand, not kept");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (manifest?.kind === "repository") {
|
|
592
|
+
const context = manifest?.slots?.context;
|
|
593
|
+
if (typeof context !== "string") {
|
|
594
|
+
add("card", "today", "no boot card: `slots.context` is undeclared");
|
|
595
|
+
} else {
|
|
596
|
+
const source = readOrNull(path.resolve(wsDir, context, `${BOOT_CARD_UNIT}.md`));
|
|
597
|
+
if (source === null) add("card", "new", "no boot card, by choice: `slots.context` holds no `boot` unit");
|
|
598
|
+
else if (withReading(source) !== null) add("card", "today", "a boot card drafted before it carried the engine's rules on reading and the cache");
|
|
599
|
+
else if (!carriesReading(source)) {
|
|
600
|
+
pieces.push({ id: "card", state: "today", hand: true, text: `a boot card without the engine's rules on reading and the cache, whose head \`upgrade\` does not recognise: add a section holding the line \`${READING_LINE}\`` });
|
|
601
|
+
} else if (readOrNull(path.join(tree, COMPILED_CARD)) !== null) add("card", "new", "a compiled boot card");
|
|
602
|
+
else if ((readOrNull(path.join(tree, "AGENTS.md")) ?? "").split(/\r?\n/).includes(BOOT_CARD_LINE)) add("card", "new", "a boot card at the head of AGENTS.md, which this host reads");
|
|
603
|
+
else add("card", "today", `a boot card not yet compiled to ${COMPILED_CARD}: run \`portulan compile\``);
|
|
604
|
+
}
|
|
605
|
+
const marked = instructionsState(tree, (rel) => readOrNull(path.join(tree, ...rel.split("/"))));
|
|
606
|
+
if (marked.files.length) {
|
|
607
|
+
const files = marked.files.join(" and ");
|
|
608
|
+
const count = (n) => `${n} section${n === 1 ? "" : "s"}`;
|
|
609
|
+
const gone = marked.gone.length ? `, and ${marked.gone.join(", ")}, named by a marker, ${marked.gone.length === 1 ? "is" : "are"} not there` : "";
|
|
610
|
+
const pending = `${count(marked.pending)} of ${files} marked to move to on-read units${gone}`;
|
|
611
|
+
if (marked.linked.length) {
|
|
612
|
+
const one = marked.linked.length === 1;
|
|
613
|
+
const links = `${marked.linked.join(" and ")} ${one ? "is a link" : "are links"}`;
|
|
614
|
+
pieces.push({ id: "instructions", state: "today", hand: true, text: `${pending}, and ${links}, whose sections the split does not move: make ${one ? "it" : "each"} a file of its own, or take the marks out` });
|
|
615
|
+
} else if (marked.pending) add("instructions", "today", pending);
|
|
616
|
+
else add("instructions", "new", `${count(marked.moved)} of ${files} moved to on-read units${gone}`);
|
|
617
|
+
}
|
|
618
|
+
if (gitIn(tree) !== null) {
|
|
619
|
+
const recipe = commentsRecipeOf(manifest);
|
|
620
|
+
if (recipe?.drafted) add("comments", "new", "a `comments` recipe holding the comments that record a change's history");
|
|
621
|
+
else if (recipe) pieces.push({ id: "comments", state: "today", hand: true, text: `the \`comments\` recipe runs ${recipe.run}, not a \`verify/comments.sh\`: rename it, so \`upgrade\` can draft the one that counts the comments recording a change's history` });
|
|
622
|
+
else add("comments", "today", "no `comments` recipe: nothing counts the comments that record a change's history");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return { tree, pieces };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** `doctor`'s one line for the form: a report, never a verdict, since today's form boots as it did. */
|
|
629
|
+
export function formLine(workspaceDir, manifest, { over = false } = {}) {
|
|
630
|
+
const { tree, pieces } = formOf(workspaceDir, manifest);
|
|
631
|
+
if (tree === null) return "not reported: this workspace declares no tree, so no repository's records or boot are its own";
|
|
632
|
+
const today = pieces.filter((p) => p.state === "today");
|
|
633
|
+
if (today.length === 0) return `the new form: ${pieces.map((p) => p.text).join("; ")}`;
|
|
634
|
+
const rel = path.relative(process.cwd(), path.resolve(workspaceDir));
|
|
635
|
+
const shown = rel === "" ? "." : rel.startsWith("..") ? path.resolve(workspaceDir) : rel;
|
|
636
|
+
const moves = today.some((p) => p.hand) ? "moves all but what is named to add by hand" : "moves it";
|
|
637
|
+
const said = `today's form in ${today.length} of ${pieces.length}: ${today.map((p) => p.text).join("; ")} — `;
|
|
638
|
+
if (over && today.some((p) => p.id === "instructions" && !p.hand)) {
|
|
639
|
+
const rest = today.filter((p) => p.id !== "instructions");
|
|
640
|
+
const after = rest.length === 0 ? "" : `, and \`portulan upgrade --write ${shellWord(shown)}\` ${rest.some((p) => p.hand) ? "moves the rest but what is named to add by hand" : "moves the rest"} once it runs`;
|
|
641
|
+
return `${said}\`${splitCommand(shown)}\` moves the marked sections, since \`portulan upgrade\` does not run over a breached budget${after}, and until then it boots as it did`;
|
|
642
|
+
}
|
|
643
|
+
return `${said}\`portulan upgrade --write ${shellWord(shown)}\` ${moves}, and until then it boots as it did`;
|
|
644
|
+
}
|