@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
@@ -0,0 +1,69 @@
1
+ // A form step — a change's record is its commit message, and the Session log is retired to a pointer.
2
+
3
+ import { notYetForm, retireSessionLogs, sessionLogPointer, sessionLogSections } from "../../cli/form.mjs";
4
+
5
+ function logsIn(ws) {
6
+ if (!ws.repository) return { skip: "this workspace declares no tree, so no repository holds a Session log" };
7
+ const git = ws.repository.git;
8
+ if (git === null) return { skip: "no git work tree answers here, and a retired log points at the commit that holds its entries" };
9
+ const listed = git("ls-files", "-z", "--", "*.md");
10
+ if (listed.status !== 0) return { unknown: `git ls-files could not list the tracked Markdown — ${listed.stderr.trim() || `exit ${listed.status}`}` };
11
+ const files = [];
12
+ for (const rel of listed.stdout.split("\0").filter(Boolean)) {
13
+ let text;
14
+ try {
15
+ text = ws.repository.read(rel);
16
+ } catch {
17
+ // A link, or a path the disk cannot give: a link's target is read in its own right where git tracks it.
18
+ continue;
19
+ }
20
+ if (text === null || !text.includes("Session log")) continue;
21
+ if (sessionLogSections(text).some((s) => s.entries)) files.push({ rel, text });
22
+ }
23
+ return { git, files };
24
+ }
25
+
26
+ export const step = {
27
+ id: "0005-session-log-retired",
28
+ kind: "form",
29
+ from: null,
30
+ to: null,
31
+ title: "the Session log is retired to a pointer at the commit that holds it",
32
+ why:
33
+ "A log every change appends to conflicts on every merge and repeats what the commit messages say. " +
34
+ "Each `Session log` section with entries keeps its heading and two lines: the date it retired, and " +
35
+ "`git show <sha>:<file>`, the last commit holding its entries.",
36
+
37
+ owed(ws, ctx) {
38
+ const behind = notYetForm(ws, ctx);
39
+ if (behind) return { owed: false, because: behind };
40
+ const found = logsIn(ws);
41
+ if (found.skip) return { owed: false, because: found.skip };
42
+ if (found.unknown) return { owed: null, because: found.unknown };
43
+ if (found.files.length === 0) return { owed: false, because: "no tracked Markdown holds a Session log with entries" };
44
+ return { owed: true, because: `a Session log with entries in ${found.files.map((f) => f.rel).join(", ")}` };
45
+ },
46
+
47
+ plan(ws, ctx) {
48
+ const found = logsIn(ws);
49
+ if (found.skip) return { ok: true, edits: [] };
50
+ if (found.unknown) return { ok: false, reason: found.unknown };
51
+ const { git } = found;
52
+ // `git show <sha>:<path>` reads a path from the repository's top, and the tree may sit below it.
53
+ const prefix = git("rev-parse", "--show-prefix");
54
+ if (prefix.status !== 0) return { ok: false, reason: `git rev-parse could not name the tree's place in its repository — ${prefix.stderr.trim()}` };
55
+ const edits = [];
56
+ for (const { rel, text } of found.files) {
57
+ const status = git("status", "--porcelain", "--", rel);
58
+ if (status.status !== 0) return { ok: false, reason: `git status could not answer for ${rel} — ${status.stderr.trim() || `exit ${status.status}`}` };
59
+ if (status.stdout.trim() !== "") {
60
+ return { ok: false, reason: `${rel} has changes not committed, and its retired log would point at a commit that lacks them — commit or stash them, then upgrade` };
61
+ }
62
+ const sha = git("log", "-1", "--format=%h", "--", rel);
63
+ if (sha.status !== 0 || sha.stdout.trim() === "") return { ok: false, reason: `git names no commit holding ${rel}, so its retired log would point at nothing` };
64
+ const pointer = sessionLogPointer({ date: ctx.today, sha: sha.stdout.trim(), file: `${prefix.stdout.trim()}${rel}` });
65
+ edits.push({ root: "tree", file: rel, next: retireSessionLogs(text, pointer) });
66
+ }
67
+ return { ok: true, edits };
68
+ },
69
+ };
@@ -0,0 +1,77 @@
1
+ // A form step — the boot is a card, drafted from the workspace's own slots.
2
+
3
+ import path from "node:path";
4
+
5
+ import { claudeRulesUnignore, draftCard, notYetForm, withIgnoreLines } from "../../cli/form.mjs";
6
+
7
+ /** The Workspace Definition version `slots.context` arrived in, which a manifest declaring it needs. */
8
+ const CONTEXT_SPEC = "2.10";
9
+
10
+ const posix = (p) => p.split(path.sep).join("/");
11
+
12
+ function atLeastContextSpec(declared) {
13
+ const [major, minor] = String(declared).split(".").map(Number);
14
+ const [needMajor, needMinor] = CONTEXT_SPEC.split(".").map(Number);
15
+ return major > needMajor || (major === needMajor && minor >= needMinor) ? declared : CONTEXT_SPEC;
16
+ }
17
+
18
+ export const step = {
19
+ id: "0006-boot-card",
20
+ kind: "form",
21
+ from: null,
22
+ to: null,
23
+ title: "the boot is a card, drafted from the workspace's own slots",
24
+ why:
25
+ "A boot through the slots reads the skill's steps and every slot file in full; a card carries the " +
26
+ "same, compiled into the rules the host loads, and a session reads no slot to boot. `context/boot.md` " +
27
+ "is drafted, `slots.context` declared at 2.10, and `0007` compiles it. Delete the card to boot as before.",
28
+
29
+ owed(ws, ctx) {
30
+ const behind = notYetForm(ws, ctx);
31
+ if (behind) return { owed: false, because: behind };
32
+ const kind = ws.manifest?.kind;
33
+ if (kind !== "repository") return { owed: false, because: `a \`${kind}\` workspace has no repository of its own whose host would load a card` };
34
+ if (!ws.repository) return { owed: false, because: "this workspace declares no tree for a card to load in" };
35
+ if (typeof ws.manifest?.slots?.context === "string") {
36
+ return { owed: false, because: "`slots.context` is declared: a `boot` unit in it is the card, and a slot without one boots through the slots" };
37
+ }
38
+ return { owed: true, because: "`slots.context` is undeclared, so a session boots by reading every slot; the card is drafted from them, and `0007` then compiles it" };
39
+ },
40
+
41
+ plan(ws) {
42
+ if (ws.list().some((rel) => rel === "context" || rel.startsWith("context/"))) {
43
+ return { ok: false, reason: "`context/` is already in the workspace and is no declared slot — rename it, or declare `slots.context` yourself, then upgrade" };
44
+ }
45
+ const manifest = {
46
+ ...ws.manifest,
47
+ portulan: { ...ws.manifest.portulan, spec: atLeastContextSpec(ws.manifest.portulan?.spec) },
48
+ slots: { ...ws.manifest.slots, context: "context/" },
49
+ };
50
+ const tree = ws.repository.dir;
51
+ const workspace = posix(path.relative(tree, ws.dir)) || ".";
52
+ const inTree = (rel) => {
53
+ const at = path.relative(tree, path.resolve(ws.dir, rel));
54
+ return at !== "" && !at.startsWith("..") && !path.isAbsolute(at);
55
+ };
56
+ const read = (rel) => {
57
+ try {
58
+ return ws.read(rel);
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+ const repos = typeof manifest.slots.repos === "string" ? manifest.slots.repos.replace(/\/?$/, "/") : null;
64
+ const repoCards = repos === null
65
+ ? []
66
+ : ws.list().filter((rel) => rel.startsWith(repos) && rel.endsWith(".md") && !rel.slice(repos.length).includes("/") && path.posix.basename(rel) !== "README.md").map((rel) => path.posix.basename(rel, ".md"));
67
+
68
+ const edits = [
69
+ { file: "workspace.json", next: `${JSON.stringify(manifest, null, 2)}\n` },
70
+ { file: "context/boot.md", next: draftCard(manifest, read, { workspace, inTree, repoCards }) },
71
+ ];
72
+ // A `.gitignore` hiding `.claude/` would keep the compiled card out of review and every fresh checkout.
73
+ const { lines } = claudeRulesUnignore(tree, ws.repository.git);
74
+ if (lines.length) edits.push({ root: "tree", file: ".gitignore", next: withIgnoreLines(ws.repository.read(".gitignore"), lines) });
75
+ return { ok: true, edits };
76
+ },
77
+ };
@@ -0,0 +1,31 @@
1
+ // A form step — the guidance the workspace declares is compiled into what the host loads.
2
+
3
+ import { guidanceEdits } from "../../cli/compile.mjs";
4
+ import { notYetForm } from "../../cli/form.mjs";
5
+
6
+ export const step = {
7
+ id: "0007-guidance-compiled",
8
+ kind: "form",
9
+ from: null,
10
+ to: null,
11
+ title: "the declared guidance is compiled into the rules the host loads",
12
+ why:
13
+ "A unit in `slots.context` reaches a session only as the rule `compile` writes for it. The rules are " +
14
+ "written through compile's own planner, as `portulan compile` writes them, and never " +
15
+ "`.claude/settings.json`.",
16
+
17
+ owed(ws, ctx) {
18
+ const behind = notYetForm(ws, ctx);
19
+ if (behind) return { owed: false, because: behind };
20
+ if (!ws.repository) return { owed: false, because: "this workspace declares no tree for compiled guidance to load in" };
21
+ if (typeof ws.manifest?.slots?.context !== "string") return { owed: false, because: "`slots.context` is undeclared, so there is no guidance to compile" };
22
+ const planned = guidanceEdits(ws.dir);
23
+ const left = planned.left.length ? `; ${planned.left.join(", ")} ${planned.left.length === 1 ? "is" : "are"} not compile's, and left as \`compile\` leaves ${planned.left.length === 1 ? "it" : "them"}` : "";
24
+ if (planned.edits.length === 0) return { owed: false, because: `the compiled guidance is what \`compile\` would write${left}` };
25
+ return { owed: true, because: `${planned.edits.map((e) => e.file).join(", ")} ${planned.edits.length === 1 ? "differs" : "differ"} from what \`compile\` would write${left}` };
26
+ },
27
+
28
+ plan(ws) {
29
+ return { ok: true, edits: guidanceEdits(ws.dir).edits.map((edit) => ({ root: "tree", file: edit.file, next: edit.next })) };
30
+ },
31
+ };
@@ -0,0 +1,55 @@
1
+ // A form step — the card carries the engine's rules on reading and the cache.
2
+
3
+ import path from "node:path";
4
+
5
+ import { BOOT_CARD_UNIT } from "../../cli/compile.mjs";
6
+ import { carriesReading, HEAD_BEFORE_READING_SHOWN, notYetForm, READING_LINE, withReading } from "../../cli/form.mjs";
7
+
8
+ function cardOf(ws) {
9
+ const context = ws.manifest?.slots?.context;
10
+ if (typeof context !== "string") return { answer: { owed: false, because: "`slots.context` is undeclared, so there is no card; `0006` drafts one with the section" } };
11
+ const rel = path.posix.join(context, `${BOOT_CARD_UNIT}.md`);
12
+ try {
13
+ return { rel, card: ws.read(rel) };
14
+ } catch (error) {
15
+ if (error?.code === "ENOENT") return { answer: { owed: false, because: "`slots.context` holds no `boot` unit, so there is no card to carry the section" } };
16
+ return { answer: { owed: null, because: `${rel} could not be read: ${error.message}` } };
17
+ }
18
+ }
19
+
20
+ const unplaced = (rel) =>
21
+ `${rel} does not carry the engine's rules on reading and the cache, and its head is not one \`init\` drafted ` +
22
+ `before 2026-09-24, ${HEAD_BEFORE_READING_SHOWN}, so this step does not guess where they go: add a section ` +
23
+ `holding the line \`${READING_LINE}\` to the card, then upgrade again`;
24
+
25
+ export const step = {
26
+ id: "0008-card-reading",
27
+ kind: "form",
28
+ from: null,
29
+ to: null,
30
+ title: "the card carries the engine's rules on reading and the cache",
31
+ why:
32
+ "The card `init` drafts opens with the rules of Portulan's `core/operating/context.md` on what a " +
33
+ "session's reads, output and pauses cost on every request after them, written out by `compile`. A card " +
34
+ "drafted before them gets the section and `0007` compiles it; one under any other head is reported with " +
35
+ "the line to add by hand, and the rest of the chain runs.",
36
+
37
+ owed(ws, ctx) {
38
+ const behind = notYetForm(ws, ctx);
39
+ if (behind) return { owed: false, because: behind };
40
+ if (ws.manifest?.kind !== "repository") return { owed: false, because: `a \`${ws.manifest?.kind}\` workspace has no repository of its own whose host would load a card` };
41
+ const { rel, card, answer } = cardOf(ws);
42
+ if (answer) return answer;
43
+ if (carriesReading(card)) return { owed: false, because: `${rel} carries the engine's rules on reading and the cache` };
44
+ if (withReading(card) === null) return { owed: true, hand: true, because: unplaced(rel) };
45
+ return { owed: true, because: `${rel} was drafted before the card carried the engine's rules on reading and the cache: they go under its head, and \`0007\` then compiles it` };
46
+ },
47
+
48
+ plan(ws) {
49
+ const { rel, card, answer } = cardOf(ws);
50
+ if (answer) return { ok: false, reason: answer.because };
51
+ const next = withReading(card);
52
+ if (next === null) return { ok: false, reason: unplaced(rel) };
53
+ return { ok: true, edits: [{ file: rel, next }] };
54
+ },
55
+ };
@@ -0,0 +1,60 @@
1
+ // A form step — each section a team marks in its own instruction file moves to an on-read unit.
2
+
3
+ import { ESTIMATED_BYTES_PER_TOKEN, alwaysTier, declaredContext, tokensOf } from "../../cli/context.mjs";
4
+ import { notYetForm } from "../../cli/form.mjs";
5
+ import { INSTRUCTION_FILES, contextDir, grouped, instructionReader, linkRefusal, marksOf, planSplit, splitLines } from "../../cli/instructions.mjs";
6
+
7
+ function splitOf(ws) {
8
+ const tree = ws.repository.dir;
9
+ const context = contextDir(tree, ws.dir, ws.manifest);
10
+ const taken = context === null ? [] : ws.repository.names(context).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3));
11
+ return planSplit({ tree, context, taken, read: instructionReader(tree, ws.repository.read) });
12
+ }
13
+
14
+ export const step = {
15
+ id: "0009-instruction-sections-on-read",
16
+ kind: "form",
17
+ from: null,
18
+ to: null,
19
+ title: "each section a team marks in its instruction file moves to an on-read unit",
20
+ why:
21
+ "`CLAUDE.md` and `.claude/CLAUDE.md` load whole into every context. A section marked " +
22
+ "`<!-- portulan: on-read -->` moves, byte for byte, to an on-read unit of `slots.context`, proved to " +
23
+ "reassemble, and every context loads one index line for it. " +
24
+ "`node <plugin root>/cli/instructions.mjs --workspace <dir> --join --write` puts it back.",
25
+
26
+ owed(ws, ctx) {
27
+ const behind = notYetForm(ws, ctx);
28
+ if (behind) return { owed: false, because: behind };
29
+ if (!ws.repository) return { owed: false, because: "this workspace declares no tree, so there is no instruction file of its own" };
30
+ const read = instructionReader(ws.repository.dir, ws.repository.read);
31
+ const marked = INSTRUCTION_FILES.filter((rel) => marksOf(read(rel) ?? "").marks.length > 0);
32
+ if (marked.length === 0) return { owed: false, because: "no section of CLAUDE.md or .claude/CLAUDE.md is marked `<!-- portulan: on-read -->`" };
33
+ if (typeof ws.manifest?.slots?.context !== "string") {
34
+ return { owed: false, because: `${marked.join(" and ")} marks a section, and \`slots.context\` is undeclared: \`0006\` declares it, and this step is asked again then` };
35
+ }
36
+ const linked = marked.map((rel) => linkRefusal(ws.repository.dir, rel)).filter((refusal) => refusal !== null);
37
+ if (linked.length) return { owed: true, hand: true, because: `a marked section cannot move, so none is moved — ${linked.join("; ")}, or take the marks out, then upgrade again` };
38
+ const split = splitOf(ws);
39
+ if (split.refusals.length) return { owed: true, because: `a marked section cannot move — ${split.refusals.join("; ")}` };
40
+ const ratio = declaredContext(ws.manifest).ratio ?? ESTIMATED_BYTES_PER_TOKEN;
41
+ const before = alwaysTier(ws.repository.dir).entries.reduce((n, e) => n + e.bytes, 0);
42
+ const after = before - split.files.reduce((n, f) => n + f.removed, 0) + split.index;
43
+ return {
44
+ owed: true,
45
+ because: `${splitLines(split).join("; ")}; the always tier goes from ~${grouped(tokensOf(before, ratio))} to ~${grouped(tokensOf(after, ratio))} tokens`,
46
+ };
47
+ },
48
+
49
+ plan(ws) {
50
+ const split = splitOf(ws);
51
+ if (split.refusals.length) return { ok: false, reason: `a marked section cannot move, so none is moved — ${split.refusals.join("; ")}` };
52
+ return {
53
+ ok: true,
54
+ edits: [
55
+ ...split.files.map((f) => ({ root: "tree", file: f.rel, next: f.after })),
56
+ ...split.units.map((u) => ({ root: "tree", file: u.source, next: u.text })),
57
+ ],
58
+ };
59
+ },
60
+ };
@@ -0,0 +1,57 @@
1
+ // A form step — a `comments` recipe holds the comment lines that record a change's history.
2
+
3
+ import path from "node:path";
4
+
5
+ import { historyCount } from "../../cli/comments.mjs";
6
+ import { COMMENTS_RECIPE, commentsRecipe, commentsRecipeEntry, commentsRecipeOf, notYetForm } from "../../cli/form.mjs";
7
+
8
+ const RECIPE_FILE = "verify/comments.sh";
9
+
10
+ const posix = (p) => p.split(path.sep).join("/");
11
+
12
+ export const step = {
13
+ id: "0010-comment-rail",
14
+ kind: "form",
15
+ from: null,
16
+ to: null,
17
+ title: "a `comments` recipe holds the comment lines that record a change's history",
18
+ why:
19
+ "A comment is paid for on every read of its file, and a change's history belongs in its commit " +
20
+ "message. `verify/comments.sh` counts the comment lines that record one, and holds them at the count " +
21
+ "the tree has today, so it starts green and the count only falls. It is declared as the `comments` recipe.",
22
+
23
+ owed(ws, ctx) {
24
+ const behind = notYetForm(ws, ctx);
25
+ if (behind) return { owed: false, because: behind };
26
+ if (ws.manifest?.kind !== "repository") return { owed: false, because: `a \`${ws.manifest?.kind}\` workspace has no repository of its own to count` };
27
+ if (!ws.repository) return { owed: false, because: "this workspace declares no tree to count" };
28
+ if (ws.repository.git === null) return { owed: false, because: "no git work tree answers here, so no files are listed to count" };
29
+ const recipe = commentsRecipeOf(ws.manifest);
30
+ if (recipe?.drafted) return { owed: false, because: "a `comments` recipe is declared" };
31
+ if (recipe) return { owed: null, because: `the \`${COMMENTS_RECIPE}\` recipe runs ${recipe.run}, not a \`${RECIPE_FILE}\`: rename it, then upgrade` };
32
+ return { owed: true, because: "no `comments` recipe counts the comment lines that record a change's history: one is drafted at the tree's count" };
33
+ },
34
+
35
+ plan(ws, ctx) {
36
+ if (ws.list().includes(RECIPE_FILE)) {
37
+ return { ok: false, reason: `\`${RECIPE_FILE}\` is in the workspace and no recipe declares it: declare it as \`${COMMENTS_RECIPE}\`, or rename it, then upgrade` };
38
+ }
39
+ const tree = ws.repository.dir;
40
+ let limit;
41
+ try {
42
+ limit = historyCount(tree);
43
+ } catch (error) {
44
+ return { ok: false, reason: `the tree's comments could not be counted — ${error.message}` };
45
+ }
46
+ const entry = commentsRecipeEntry(posix(path.relative(tree, ws.dir)) || ".");
47
+ const manifest = { ...ws.manifest, verify: { ...ws.manifest.verify, recipes: [...ws.manifest.verify.recipes, entry] } };
48
+ const toTree = posix(path.relative(path.join(ws.dir, "verify"), tree));
49
+ return {
50
+ ok: true,
51
+ edits: [
52
+ { file: RECIPE_FILE, next: commentsRecipe({ bundle: ctx.bundle, limit, toTree }), mode: 0o755 },
53
+ { file: "workspace.json", next: `${JSON.stringify(manifest, null, 2)}\n` },
54
+ ],
55
+ };
56
+ },
57
+ };
@@ -5,7 +5,7 @@
5
5
  > stated condition: a migration needed code. The runner is
6
6
  > [`../../cli/upgrade.mjs`](../../cli/upgrade.mjs).
7
7
 
8
- ## A step, and the two kinds
8
+ ## A step, and the three kinds
9
9
 
10
10
  A migration is not one act. It is an ordered set of **steps**, each a zero-dependency ESM module
11
11
  exporting a single `step` object, and each of exactly one kind:
@@ -14,6 +14,7 @@ exporting a single `step` object, and each of exactly one kind:
14
14
  |---|---|---|
15
15
  | `version` | A Workspace Definition MAJOR migration. `spec/README.md`'s rule stands: **MINORs are additive and owe no migration**, so there is no such thing as a MINOR step and nothing here restamps one. | `from` → `to` |
16
16
  | `repair` | Something a rewriter owes a workspace it touches, independent of any version — a value that was true where it was written and is not true where the workspace now is. | the workspace's own state |
17
+ | `form` | A move of a consumer's records or boot to the form Portulan moved its own to, in the repository beside the workspace as well as in it (2026-09-24). Today's form stays legitimate and boots as it did, so a form step is owed only where the new form is not there yet, and `doctor` reports the form without failing it. | the workspace's and its tree's own state |
17
18
 
18
19
  The second kind exists by the maintainer's ruling of 2026-08-12. Without it this directory would hold
19
20
  one step (`1.0 → 2.0`) with **no subject in this repository and none in any tree we have seen** —
@@ -22,19 +23,37 @@ machinery demonstrable only on a fixture. What actually bites an adopter is a wo
22
23
  ```js
23
24
  export const step = {
24
25
  id: "0002-bundle-fallback-path", // stable, sortable; the id IS the chain's order
25
- kind: "repair", // "version" | "repair"
26
- from: null, to: null, // version steps only; null on a repair
26
+ kind: "repair", // "version" | "repair" | "form"
27
+ from: null, to: null, // version steps only; null on a repair or a form step
27
28
  title: "…", // one line, printed in the plan
28
29
  why: "…", // why it is owed at all, printed under -v
29
- owed(ws), // → { owed: true | false | null, because }
30
+ owed(ws, ctx), // → { owed: true | false | null, because, hand? }
30
31
  plan(ws, ctx), // → { ok: true, edits } | { ok: false, reason }
31
32
  };
32
33
  ```
33
34
 
34
- `ws` is a read view of the workspace — `{ dir, manifest, manifestText, list(), read(rel) }`. `ctx`
35
- carries `{ bundle, spec, tree }`: the CLI root this run is executing from, the Workspace Definition
36
- version **this bundle implements**, and the `--tree` a step may need and must never invent. An `edit`
37
- is `{ file, next }` — a relative path and its whole next contents.
35
+ `ws` is a read view of the workspace — `{ dir, manifest, manifestText, list(), read(rel), repository }`,
36
+ `repository` being a read view of the tree the manifest declares, `{ dir, read(rel), names(rel), git }`,
37
+ or `null` where it declares none; `git` runs git in the tree, and is `null` where no work tree answers
38
+ there. `ctx` carries `{ bundle, spec, tree, today }`: the CLI root this run is executing from, the
39
+ Workspace Definition version **this bundle implements**, the `--tree` a step may need and must never
40
+ invent, and the date a record written today carries. An `edit` is `{ file, next }` — a relative path
41
+ and its whole next contents — relative to the workspace, or to the tree where it says `root: "tree"`;
42
+ a `next` of `null` deletes the file, and a `mode` is the mode of a file the edit creates, as a recipe
43
+ must be executable. Rollback puts back what each edit replaced or deleted, in either
44
+ root, and the report names each file as its root names it.
45
+
46
+ **After each step applied, the steps after it are asked again.** A step owed only once an earlier one
47
+ has landed, `0007` compiling the card `0006` drafts, is applied in the same run rather than the next.
48
+ **And after a pass that applied a step, the whole chain is asked again** until a pass applies nothing
49
+ (2026-09-24), so a later step can make an earlier one owed, as `0008` does `0007` by editing the card;
50
+ a chain still applying after as many passes as it has steps is refused and rolled back.
51
+ **A step owed where it does not place its edits answers `hand: true`** (2026-09-24), its `because` naming
52
+ what a person adds: `upgrade --write` applies the rest of the chain, reports that step as owed and not
53
+ placed once `doctor` is green, and exits 1, as `--check` does over a step owed.
54
+ A form step reads a workspace at this bundle's MAJOR only (`notYetForm` in
55
+ [`../../cli/form.mjs`](../../cli/form.mjs)): one a MAJOR behind is moved by a version step first, and
56
+ the form steps are then asked again, so a workspace no version step reaches is still refused.
38
57
 
39
58
  ## Three properties, and what each one replaces
40
59
 
@@ -69,7 +88,10 @@ is a question that could not be answered.
69
88
  claiming the migration caused it.
70
89
  - **Nothing here restamps a MINOR.** A manifest declares the version its content needs, not the newest
71
90
  one — `spec/README.md`'s own rule, the one that keeps `examples/` at 2.4 as live compatibility
72
- evidence and keeps `init`, `new` and `vendor` writing `2.7`.
91
+ evidence and keeps `new` and `vendor` writing `2.7`. `0006` writes 2.10 into a manifest older than it
92
+ because the step declares `slots.context`, the key 2.10 added, which is the same rule; `init`
93
+ writes 2.10 for the same reason, and 2.11 where a person takes the cache lifetime it offers, which
94
+ `sessions`, 2.11's key, declares.
73
95
 
74
96
  ## The steps that exist
75
97
 
@@ -77,11 +99,24 @@ is a question that could not be answered.
77
99
  |---|---|---|
78
100
  | [`0001-repository-declares-its-tree.mjs`](0001-repository-declares-its-tree.mjs) | `version` 1.0 → 2.0 | The one migration `spec/README.md` documents: a `repository` workspace must declare `tree`. It **will not guess** the value — `"../"` only where the parent is verifiably the repository root, otherwise it refuses and names `--tree`. |
79
101
  | [`0002-bundle-fallback-path.mjs`](0002-bundle-fallback-path.mjs) | `repair` | `init` bakes the bundle it ran from into `verify/index.sh` as an absolute path, marked `# portulan:bundle-fallback`. `vendor --switch` copies it byte for byte. The rail then exits **2 — could not run**, which is fail-closed and therefore easy never to notice. This re-derives it. |
102
+ | [`0003-handoff-index-not-kept.mjs`](0003-handoff-index-not-kept.mjs) | `form` | A kept handoff index is deleted and its path git-ignored, so none is committed again; `portulan index --handoffs` prints it. Owed by git's answer: a copy git ignores and does not track is one machine's own, and is left alone. |
103
+ | [`0004-changelog-fragments.mjs`](0004-changelog-fragments.mjs) | `form` | Each entry under `CHANGELOG.md`'s Unreleased becomes a fragment under `changes/`, and the heading keeps a two-line pointer. **Proved before it is offered**: the fragments, rendered as the cut renders them, must print each section back as the changelog held it, or the step refuses; a heading naming none of the six sections is refused too. The one change is an entry that does not open `- `, a `*` or `+` marker or a tab after one, whose fragment does since the cut accepts no other, and the step names each such entry by its line. |
104
+ | [`0005-session-log-retired.mjs`](0005-session-log-retired.mjs) | `form` | Each tracked Markdown file with a `Session log` holding entries keeps its heading and two lines naming the last commit that holds them. **Refused on a file with changes not committed**, since the pointer would name a commit that lacks them. |
105
+ | [`0006-boot-card.mjs`](0006-boot-card.mjs) | `form` | Where `slots.context` is undeclared, a boot card is drafted from the workspace's own files, as `init` drafts one, and the slot declared at 2.10. A declared slot with no `boot` unit is a workspace that boots through its slots, and is owed nothing. |
106
+ | [`0007-guidance-compiled.mjs`](0007-guidance-compiled.mjs) | `form` | The guidance half of `portulan compile` written where it drifted: the card and the rules the host loads, through `compile`'s own planner and refusals, and never `.claude/settings.json`, which enforces gates and is a person's to compile. |
107
+ | [`0008-card-reading.mjs`](0008-card-reading.mjs) | `form` | A card drafted before it carried the engine's rules on reading and the cache gets that section, which `compile` writes out from the installed Portulan's `core/operating/context.md`, and `0007` compiles it. A card lacking the section under any other head is owed it by hand: the run names the head the step looks for and the line to add, applies the rest of the chain and exits 1, and `doctor` names the card until the line is there. |
108
+ | [`0009-instruction-sections-on-read.mjs`](0009-instruction-sections-on-read.mjs) | `form` | Each section a team marks with a line `<!-- portulan: on-read -->` under its heading, in `CLAUDE.md` or `.claude/CLAUDE.md`, moves byte for byte into an on-read unit of `slots.context`, and a comment is left where it was. **Proved before it is offered**: the file must reassemble from its units byte for byte and each clause land once, or the step refuses; a section importing a file, a mark inside a marked section and a mark under no heading are refused too, and a mark in an instruction file that is a link is owed by hand while the rest of the chain runs. Owed only where a team marked a section, and put back by [`../../cli/instructions.mjs`](../../cli/instructions.mjs) `--join`. |
109
+ | [`0010-comment-rail.mjs`](0010-comment-rail.mjs) | `form` | Where a `repository` workspace's tree is one git lists and no `comments` recipe is declared, `verify/comments.sh` is drafted as `init` drafts it, holding the comment lines that record a change's history at the count the tree has, and declared. **Refused where `verify/comments.sh` is already there undeclared**, rather than written over. |
80
110
 
81
111
  **`0001` has no subject in this tree** — nothing declares 1.0 — and is exercised against a fixture,
82
112
  which is said here rather than dressed up. **`0002` has no subject in this repository either**: this
83
113
  workspace's `verify/index.sh` was written by hand, not drafted by `init`, and carries no marker. It is
84
- exercised against workspaces the real `init` drafts.
114
+ exercised against workspaces the real `init` drafts. **Neither have the form steps**: this repository
115
+ moved its own records and boot by hand on 2026-09-23 and 24, and `0003`–`0008` are exercised against a
116
+ consumer the real `init` drafts, put back in the form it drafted before, and committed. `0009` has none
117
+ either, since this repository keeps no `CLAUDE.md`, and is exercised against such a consumer whose
118
+ instruction file the suite marks; `0010` against one `init` drafted where git listed no files, then
119
+ committed.
85
120
 
86
121
  ## Adding one
87
122
 
@@ -132,7 +132,7 @@
132
132
  },
133
133
 
134
134
  "fragment": {
135
- "description": "One gate-policy fragment: an id-addressed rule this pack contributes, in the same shape .portulan/gates.json already uses, because that policy was deliberately modelled as a list of id-addressed rules with no dependence on being the only source so that a merge step would be an addition rather than a redesign (.portulan/compile/README.md). WHERE THE TIGHTEN-ONLY RULE IS ENFORCED, stated plainly because a schema implying a guarantee it does not carry is what .portulan/dod.md condition 4 refuses: tightening is relative to what the same id already holds in the layer beneath, and a manifest cannot see that layer. So the shape forecloses what shape can — `auto` is not in the tier enum below, which makes demotion-to-unattended unexpressible in a pack at all — and the remaining comparisons are the merge step's. TIGHTENING HAS TWO AXES AND THE MERGE CHECKS BOTH. The tier may only rise. The `action` may not change at all: a fragment naming an existing id must carry that rule's action unaltered, because raising the tier while replacing the matcher passes every tier comparison, is reported as a tightening, and removes the gate — measured, on a real policy, as a fragment that took `git push --force` from gated to prohibited and deleted the emitted gate in the same move. A pack that wants to gate a different action contributes a NEW id; changing what an existing rule matches belongs to the workspace, which owns its policy. Neither layer is sufficient alone, and a release that consumes fragments without the merge's refusals would be trusting what it claims to check.",
135
+ "description": "One gate-policy fragment: an id-addressed rule this pack contributes, in the same shape .portulan/gates.json already uses, because that policy was deliberately modelled as a list of id-addressed rules with no dependence on being the only source so that a merge step would be an addition rather than a redesign (.portulan/gate-map/compiler.md). WHERE THE TIGHTEN-ONLY RULE IS ENFORCED, stated plainly because a schema implying a guarantee it does not carry is what .portulan/dod.md condition 4 refuses: tightening is relative to what the same id already holds in the layer beneath, and a manifest cannot see that layer. So the shape forecloses what shape can — `auto` is not in the tier enum below, which makes demotion-to-unattended unexpressible in a pack at all — and the remaining comparisons are the merge step's. TIGHTENING HAS TWO AXES AND THE MERGE CHECKS BOTH. The tier may only rise. The `action` may not change at all: a fragment naming an existing id must carry that rule's action unaltered, because raising the tier while replacing the matcher passes every tier comparison, is reported as a tightening, and removes the gate — measured, on a real policy, as a fragment that took `git push --force` from gated to prohibited and deleted the emitted gate in the same move. A pack that wants to gate a different action contributes a NEW id; changing what an existing rule matches belongs to the workspace, which owns its policy. Neither layer is sufficient alone, and a release that consumes fragments without the merge's refusals would be trusting what it claims to check.",
136
136
  "type": "object",
137
137
  "additionalProperties": false,
138
138
  "required": ["id", "tier", "action", "reason"],