@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.
Files changed (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  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 +224 -1074
  10. package/cli/drills.mjs +68 -502
  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 +778 -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 +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  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 +27 -492
  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 +183 -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/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. package/spec/workspace.schema.json +145 -4
@@ -1,84 +1,36 @@
1
1
  // A repair — the bundle path a rewriter owes a workspace it touches.
2
- //
3
- // Since milestone 7 session 7, `cli/init.mjs` drafts `verify/index.sh` with a third CLI location baked
4
- // in: the bundle `init` ran from, as an ABSOLUTE path that git cannot carry anywhere. It is a
5
- // deliberate convenience for the person who ran `init`, and it is true on exactly one machine.
6
- //
7
- // **The failure is quiet, which is why it needs a tool rather than a reminder.** `vendor --switch`
8
- // copies these files byte for byte into another residence; a clone lands on a machine that never had
9
- // that bundle. The rail then exits **2 — could not run**, which is the fail-closed direction and is
10
- // therefore easy to never notice: nothing goes red, the index simply stops being checked.
11
- //
12
- // **The marker is the contract.** `cli/init.mjs` writes `# portulan:bundle-fallback` on both lines
13
- // carrying the path precisely so a rewriter can FIND them rather than having to notice a comment, and
14
- // `cli/init.test.mjs` asserts the marker survives edits to the recipe. This step is the rewriter that
15
- // note was written for.
16
2
 
17
3
  import path from "node:path";
18
4
  import { fileURLToPath } from "node:url";
19
5
 
20
- /** The token `cli/init.mjs` writes. One spelling, exported so a reader can grep for the pair. */
6
+ /** The token `cli/init.mjs` writes on each line that carries the bundle path. */
21
7
  export const MARKER = "portulan:bundle-fallback";
22
8
 
23
- /** The entry point a marked line points at, relative to a bundle root. */
24
9
  const ENTRY = ["cli", "index.mjs"];
25
10
 
26
- /**
27
- * Is this file one the marker could be *marking*, as opposed to one that talks about the marker?
28
- *
29
- * **Found by running this step against this repository rather than against its own fixtures.** The
30
- * marker is a token, and a token appears in prose: `.portulan/handoffs/2026-08-11-…md` documents the
31
- * marker in a sentence, and the first cut of this step read that paragraph as a marked line of a
32
- * shape it did not recognise and refused to migrate the workspace at all — exit 2, on the one
33
- * workspace this repository actually owns. Every unit test passed, because no fixture here had ever
34
- * written about itself.
35
- *
36
- * What a repair fixes is a **runnable fallback**, so the scope is scripts: a shell shebang, or a
37
- * `.sh` name. That is narrower than "any file carrying the token" and it is the honest reading of
38
- * what `cli/init.mjs` writes — it is deliberately NOT narrowed to `verify/index.sh`, because keying
39
- * on the filename would make a second and narrower carrier of the rule the marker exists to be.
40
- */
11
+ /** A script, by name or shebang: prose that mentions the marker marks nothing. */
41
12
  function couldCarryTheRail(rel, text) {
42
13
  if (rel.endsWith(".sh")) return true;
43
14
  const first = text.slice(0, text.indexOf("\n") + 1 || undefined);
44
15
  return first.startsWith("#!") && /\b(?:ba|da|k|z)?sh\b/.test(first);
45
16
  }
46
17
 
47
- /** This bundle, derived from where this module sits rather than passed in and trusted. */
18
+ function isMarkedScript(rel, text) {
19
+ return text.includes(MARKER) && couldCarryTheRail(rel, text);
20
+ }
21
+
48
22
  const OWN_BUNDLE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
49
23
 
50
- /** What a marked line should name, for a given bundle root. */
51
24
  function entryFor(bundle) {
52
25
  return `${bundle}/${ENTRY.join("/")}`;
53
26
  }
54
27
 
55
- /**
56
- * The one JSON string literal on a marked line that names a CLI entry point.
57
- *
58
- * Returns `null` when the line is not the shape `init` writes — which is a **refusal**, never a
59
- * best-effort rewrite. A marked line this step does not recognise is somebody's edit, and rewriting
60
- * around it by pattern would be exactly the guesswork `../README.md` says a repair may not do.
61
- */
62
28
  function quotedEntry(line) {
63
29
  const matches = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].filter((m) => m[1].endsWith(`/${ENTRY.join("/")}`));
64
30
  return matches.length === 1 ? matches[0] : null;
65
31
  }
66
32
 
67
- /**
68
- * Every marked line in `text`, paired with the entry path it names — or a reason it is unreadable.
69
- *
70
- * **`quoted` is the DECODED path, not the source text between the quotes.** The regex captures what
71
- * is written in the file, which is still JSON-escaped: `init` emits `JSON.stringify(...)`, so a
72
- * bundle at `C:\Users\x` appears as `"C:\\Users\\x/cli/index.mjs"`. Comparing that raw capture to an
73
- * unescaped `want` never matches, and the step reports itself **perpetually owed** — rewriting the
74
- * same file on every run, forever.
75
- *
76
- * Invisible on POSIX, where an ordinary path contains nothing JSON escapes, which is why every test
77
- * and every live run here passed over it. Copilot's promoted note, round 4 on #231.
78
- *
79
- * A literal that will not decode is treated as unreadable rather than guessed at, which is the same
80
- * refusal an unrecognised marked line already gets.
81
- */
33
+ /** Each marked line's entry path, JSON-decoded, since the raw capture keeps the escapes `init` wrote. */
82
34
  function marked(text) {
83
35
  const lines = text.split("\n");
84
36
  const hits = [];
@@ -92,7 +44,7 @@ function marked(text) {
92
44
  } catch {
93
45
  return { ok: false, line: n + 1, text: line };
94
46
  }
95
- hits.push({ n, quoted: decoded, whole: found[0] });
47
+ hits.push({ n, entry: decoded, whole: found[0] });
96
48
  }
97
49
  return { ok: true, lines, hits };
98
50
  }
@@ -116,17 +68,14 @@ export const step = {
116
68
  try {
117
69
  text = ws.read(rel);
118
70
  } catch (error) {
119
- // *An empty set is two questions.* A file that could not be read has not answered
120
- // anything, and reporting `not owed` here is "nothing looked" recorded as "nothing
121
- // wrong" — in a step whose entire subject is a failure that is already silent.
122
71
  return { owed: null, because: `${rel} could not be read — ${error.code ?? error.message}, so whether it carries a stale bundle path is unknown` };
123
72
  }
124
- if (!text.includes(MARKER) || !couldCarryTheRail(rel, text)) continue;
73
+ if (!isMarkedScript(rel, text)) continue;
125
74
  const found = marked(text);
126
75
  if (!found.ok) {
127
76
  return { owed: null, because: `${rel} line ${found.line} carries the marker in a shape this step does not recognise` };
128
77
  }
129
- if (found.hits.some((h) => h.quoted !== want)) any = true;
78
+ if (found.hits.some((h) => h.entry !== want)) any = true;
130
79
  }
131
80
  return any
132
81
  ? { owed: true, because: `a marked line names a bundle other than ${want}` }
@@ -144,10 +93,7 @@ export const step = {
144
93
  } catch (error) {
145
94
  return { ok: false, reason: `${rel} could not be read — ${error.code ?? error.message}` };
146
95
  }
147
- // The same two conditions as `owed`, and they are spelled the same way on purpose: a
148
- // scope that differed between asking and doing is `0020`'s class, and it would show up
149
- // as a step reporting itself owed and then planning no edit.
150
- if (!text.includes(MARKER) || !couldCarryTheRail(rel, text)) continue;
96
+ if (!isMarkedScript(rel, text)) continue;
151
97
  const found = marked(text);
152
98
  if (!found.ok) {
153
99
  return {
@@ -158,18 +104,11 @@ export const step = {
158
104
  `cannot read rather than guessing: ${found.text.trim()}`,
159
105
  };
160
106
  }
161
- if (!found.hits.some((h) => h.quoted !== want)) continue;
107
+ if (!found.hits.some((h) => h.entry !== want)) continue;
162
108
 
163
- // Rewritten by SPLICING the one recognised literal, never by a replace over the line: a
164
- // line may legitimately carry other quoted text, and a blanket substitution would edit it.
165
109
  const lines = [...found.lines];
166
110
  for (const hit of found.hits) {
167
- // A **function** replacer, not a string one. `String.prototype.replace` reads `$&`,
168
- // `` $` ``, `$'` and `$$` in the REPLACEMENT as patterns, so a checkout under a
169
- // directory whose name contains one of them spliced the old path back in with broken
170
- // quoting — producing a corrupted rail that `doctor` then grades GREEN, because
171
- // `doctor` never runs shell. A function replacer is inserted verbatim.
172
- // Found at the pre-commit checkpoint, by building the path rather than reading the line.
111
+ // A function replacer: a string one reads `$&`, `` $` ``, `$'` and `$$` in the path as patterns.
173
112
  lines[hit.n] = lines[hit.n].replace(hit.whole, () => JSON.stringify(want));
174
113
  }
175
114
  edits.push({ file: rel, next: lines.join("\n") });
@@ -0,0 +1,66 @@
1
+ // A form step — the handoff index is printed on demand, and not kept.
2
+
3
+ import path from "node:path";
4
+
5
+ import { handoffIndexIgnore, notYetForm, withIgnoreLines } from "../../cli/form.mjs";
6
+
7
+ const posix = (p) => p.split(path.sep).join("/");
8
+
9
+ function located(ws) {
10
+ const declared = ws.manifest?.handoffs?.index?.path;
11
+ if (typeof declared !== "string") return { skip: "this workspace declares no handoff index" };
12
+ if (!ws.repository) return { skip: "this workspace declares no tree, so no repository's git keeps a copy" };
13
+ const rel = path.relative(ws.repository.dir, path.resolve(ws.dir, declared));
14
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return { skip: `the index at \`${declared}\` lies outside the tree` };
15
+ const git = ws.repository.git;
16
+ if (git === null) return { skip: "no git work tree answers here, so no copy is committed to stop keeping" };
17
+ return { rel: posix(rel), workspace: posix(path.relative(ws.repository.dir, ws.dir)) || ".", git };
18
+ }
19
+
20
+ /** `--no-index` asks the ignore rules alone, so a tracked path answers too. */
21
+ function ignoredBy(at) {
22
+ const out = at.git("check-ignore", "-q", "--no-index", "--", at.rel);
23
+ if (out.status === 0) return { ignored: true };
24
+ if (out.status === 1) return { ignored: false };
25
+ return { ignored: null, why: `git check-ignore could not answer for ${at.rel} — ${out.stderr.trim() || `exit ${out.status}`}` };
26
+ }
27
+
28
+ export const step = {
29
+ id: "0003-handoff-index-not-kept",
30
+ kind: "form",
31
+ from: null,
32
+ to: null,
33
+ title: "the handoff index is printed on demand, and not kept",
34
+ why:
35
+ "A committed handoff index conflicts on every merge that adds a handoff, and carries nothing the " +
36
+ "series does not: `portulan index --handoffs` prints it. The copy is deleted, so commit the deletion, " +
37
+ "and its path is git-ignored so none is committed again.",
38
+
39
+ owed(ws, ctx) {
40
+ const behind = notYetForm(ws, ctx);
41
+ if (behind) return { owed: false, because: behind };
42
+ const at = located(ws);
43
+ if (at.skip) return { owed: false, because: at.skip };
44
+ const rule = ignoredBy(at);
45
+ if (rule.ignored === null) return { owed: null, because: rule.why };
46
+ const kept = ws.repository.read(at.rel) !== null;
47
+ if (!rule.ignored) return { owed: true, because: `\`${at.rel}\` is not git-ignored${kept ? ", and a copy is kept" : ""}` };
48
+ const tracked = at.git("ls-files", "--error-unmatch", "--", at.rel).status === 0;
49
+ if (kept && tracked) return { owed: true, because: `\`${at.rel}\` is git-ignored and still tracked, so the committed copy conflicts on every merge that adds a handoff` };
50
+ return { owed: false, because: `\`${at.rel}\` is git-ignored${kept ? ", and the copy on disk is this machine's own" : ", and no copy is kept"}` };
51
+ },
52
+
53
+ plan(ws) {
54
+ const at = located(ws);
55
+ if (at.skip) return { ok: true, edits: [] };
56
+ const rule = ignoredBy(at);
57
+ if (rule.ignored === null) return { ok: false, reason: rule.why };
58
+ const edits = [];
59
+ if (ws.repository.read(at.rel) !== null) edits.push({ root: "tree", file: at.rel, next: null });
60
+ if (!rule.ignored) {
61
+ const before = ws.repository.read(".gitignore");
62
+ edits.push({ root: "tree", file: ".gitignore", next: withIgnoreLines(before, handoffIndexIgnore(at.rel, at.workspace)) });
63
+ }
64
+ return { ok: true, edits };
65
+ },
66
+ };
@@ -0,0 +1,53 @@
1
+ // A form step — a changelog entry is a fragment under `changes/`.
2
+
3
+ import { CHANGELOG, CHANGES_DIR, CHANGES_README, changesReadme, notYetForm, unreleasedCount, unreleasedFragments, unreleasedRewrites } from "../../cli/form.mjs";
4
+
5
+ function rewritten(changelog) {
6
+ const lines = unreleasedRewrites(changelog);
7
+ if (lines.length === 0) return "";
8
+ if (lines.length === 1) return ` (the one at line ${lines[0]} does not open \`- \`, which its fragment does, as the release cut accepts no other opening)`;
9
+ return ` (those at lines ${lines.slice(0, -1).join(", ")} and ${lines.at(-1)} do not open \`- \`, which their fragments do, as the release cut accepts no other opening)`;
10
+ }
11
+
12
+ export const step = {
13
+ id: "0004-changelog-fragments",
14
+ kind: "form",
15
+ from: null,
16
+ to: null,
17
+ title: "a changelog entry is a fragment under `changes/`",
18
+ why:
19
+ "Two open changes that each add a bullet under Unreleased conflict on merge; a file per entry never " +
20
+ "does. Each entry moves to `changes/<nn>-<slug>.<section>.md`, proved to print back as the changelog " +
21
+ "held it but for an entry that does not open `- `, whose fragment does and which the step names by its " +
22
+ "line, and `portulan index --changes changes` prints them as the cut pastes them.",
23
+
24
+ owed(ws, ctx) {
25
+ const behind = notYetForm(ws, ctx);
26
+ if (behind) return { owed: false, because: behind };
27
+ if (!ws.repository) return { owed: false, because: "this workspace declares no tree, so no changelog sits beside it" };
28
+ const changelog = ws.repository.read(CHANGELOG);
29
+ const readme = ws.repository.read(CHANGES_README) !== null;
30
+ if (changelog === null) {
31
+ return { owed: false, because: readme ? `${CHANGES_README} is here, and no ${CHANGELOG} yet` : `no ${CHANGELOG} here for fragments to feed` };
32
+ }
33
+ const entries = unreleasedCount(changelog) ?? 0;
34
+ const owed = [];
35
+ if (!readme) owed.push(`no ${CHANGES_README}`);
36
+ if (entries) owed.push(`${entries} entr${entries === 1 ? "y" : "ies"} under ${CHANGELOG}'s Unreleased${rewritten(changelog)}`);
37
+ if (owed.length === 0) return { owed: false, because: `the changelog's entries are fragments in ${CHANGES_DIR}/` };
38
+ return { owed: true, because: owed.join(", and ") };
39
+ },
40
+
41
+ plan(ws) {
42
+ const edits = [];
43
+ if (ws.repository.read(CHANGES_README) === null) edits.push({ root: "tree", file: CHANGES_README, next: changesReadme() });
44
+ const changelog = ws.repository.read(CHANGELOG);
45
+ const moved = changelog === null ? null : unreleasedFragments(changelog, new Set(ws.repository.names(CHANGES_DIR)));
46
+ if (moved?.refused) return { ok: false, reason: moved.refused };
47
+ if (moved && moved.fragments.length) {
48
+ for (const fragment of moved.fragments) edits.push({ root: "tree", file: `${CHANGES_DIR}/${fragment.name}`, next: fragment.text });
49
+ edits.push({ root: "tree", file: CHANGELOG, next: moved.next });
50
+ }
51
+ return { ok: true, edits };
52
+ },
53
+ };
@@ -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
+ };