@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,64 @@
1
+ ---
2
+ name: delegate-local
3
+ description: Offload a verify-gated one-file edit, a file digest or a draft to a local model. Use only where the workspace composes tools/local-llm and a server answers on loopback.
4
+ ---
5
+
6
+ # Skill — Delegate Local
7
+
8
+ > Every turn in a frontier session re-reads its whole context (`m_r × (F·T + g·T²/2)`, proposal
9
+ > [`0038`](../../../../../.portulan/proposals/0038-what-a-change-spends-is-measured.md)). This skill moves a
10
+ > bounded, high-turn or read-heavy sub-task off that context and onto a local model on loopback, through
11
+ > [`runner/local-llm.mjs`](../../runner/local-llm.mjs) and under a verify command.
12
+
13
+ ## Before you start
14
+
15
+ `<runner>` below is `runner/local-llm.mjs` in this pack's directory, two levels above this file:
16
+ `<plugin root>/packs/tools/local-llm/runner/local-llm.mjs` where the plugin is the repository root, and
17
+ `<plugin root>/tools/local-llm/runner/local-llm.mjs` where it is the `packs/` payload.
18
+
19
+ The runner talks to an OpenAI-compatible server on loopback: LM Studio on `127.0.0.1:1234` by default,
20
+ or `--base-url` (or `PORTULAN_LOCAL_LLM_URL`) for another loopback port such as Ollama's `11434`. Run
21
+ `node <runner> status` first. Exit `0` lists the models the server holds; exit `1` is an endpoint
22
+ refused as off loopback; exit `2` is could-not-run — no server answered, it answered with an HTTP
23
+ error, or `--base-url` is not a URL. On anything but `0`, do the work in the parent session. Never pass
24
+ `--allow-remote` on your own authority: an endpoint off loopback is the `local-llm-off-loopback`
25
+ decision, proposed before it runs.
26
+
27
+ ## When to use it
28
+
29
+ - **A verify-gated file transform:** a mechanical refactor, a history-comment cleanup, or a unit test
30
+ added, where a verify command can prove the edit green or have it rolled back.
31
+ - **An explicitly selected candidate research digest:** reading a long file or log so only a short digest enters the parent context.
32
+ - **An explicitly selected candidate librarian or handoff draft:** compressing a handoff under its byte cap, or drafting a `codify` or
33
+ `consolidate` candidate for human review.
34
+
35
+ Not for multi-file architectural planning, ambiguous requirements (`clarify`), or pre-merge verdicts
36
+ (`reviewer`).
37
+
38
+ ## The pass
39
+
40
+ 1. **Scope the input first.** Outline a file of 300 lines or more with
41
+ `node <plugin root>/cli/symbols.mjs <file>` and pass `--file <file> --lines <start>-<end>` (to
42
+ `transform`, `chat` and `summarize` alike) rather than the whole file. `cli/` ships with the
43
+ repository-root plugin and not in the `packs/` payload; there, find the span's line numbers with
44
+ `grep -n`. Prefill scales with prompt bytes: proposal 0039 measured prompts of about 400–470 tokens
45
+ prefilling in 0.85–1.88 s, and the `32 KB` default of `--max-input-bytes` (about 8k tokens) is a
46
+ ceiling, not a measured latency.
47
+ 2. **Use an earned role.** Code transforms and strict syntax use `--role code`
48
+ (`qwen/qwen3.8-27b`) at `--effort low`, supported by the
49
+ [captured A/B](../../../../../evals/ab/local-worker.md)'s L5/H5 code cell. Shared `--role prose`
50
+ (`google/gemma-4-31b`) is disabled: codify scored L0/H3, despite handoff's L5/H5. Keep digests and
51
+ librarian drafts in the parent session. `summarize` defaults to prose and refuses; explicit roles,
52
+ model overrides and environment mismatches cannot bypass the guard. An operator can select a
53
+ candidate with `--model <id>` and no `--role`; that is deliberate evaluation, not earned routing.
54
+ Screen a candidate with `node <runner> eval --model <id>`; it needs `cli/` beside `packs/` and exits
55
+ `2` without it. Passing a screen never enables a role.
56
+ 3. **Gate every edit.** `node <runner> transform --file <path> --verify "<cmd>" --prompt "<instruction>"`,
57
+ where `--verify` is required. The runner applies the edit, runs `<cmd>`, retries with the end of its
58
+ output on exit `1`, and keeps the edit only on exit `0`; any other exit, and SIGINT, SIGTERM or
59
+ SIGHUP, restores the file byte for byte. It is a rollback, not an atomic write: any other signal that
60
+ ends the runner, SIGKILL and SIGQUIT among them, or a power loss between the edit and its verdict
61
+ leaves the edit in place.
62
+ 4. **Read only the receipt back.** On exit `0`, take the one-line receipt and do not re-read the file
63
+ unless a later edit depends on its new line numbers. On exit `1` or `2`, escalate to the frontier
64
+ implementer rather than looping locally again.
@@ -0,0 +1,18 @@
1
+ # `runner-rails` — what it checks, and what it cannot
2
+
3
+ One recipe: `node ${PACK_ROOT}/runner/local-llm.mjs --verify-offline`, run from the adopting repository's root.
4
+
5
+ ## The three exits
6
+
7
+ | Exit | Means |
8
+ |---|---|
9
+ | `0` | all offline runner rails hold: non-loopback URLs are refused without `--allow-remote`, oversized inputs above `--max-input-bytes` are refused before network I/O, a verify command ending in any exit but `0` or `1` is could-not-run with the file restored, a green verify command keeps the edit with a one-line receipt, transform rollback restores original file bytes on a red verify command, and `dedup` refuses a non-loopback endpoint and a file over the ceiling before any request |
10
+ | `1` | at least one runner rail failed to fire (a forced-red check passed when it should have been refused, rollback left modified bytes behind, a green verify did not keep its edit with a one-line receipt, or `dedup` reached its request) |
11
+ | `2` | **could not run** — [`runner/local-llm.mjs`](../runner/local-llm.mjs) or the Node.js runtime could not be executed |
12
+
13
+ ## Why `--verify-offline` makes no network or loopback call
14
+
15
+ A verify recipe in Portulan may never make a network call, and a local LLM server (`localhost:1234`) is
16
+ machine-specific state that is absent in CI. `--verify-offline` exercises the runner's guards against
17
+ in-process synthetic fixtures and three temporary-file transform drills (red, no verdict, green) and a `dedup` drill over two temporary files, proving the rails hold without
18
+ touching a socket. Live model health checks and role grading use `status` or `eval` outside recipes.
package/spec/README.md CHANGED
@@ -114,7 +114,9 @@ carries, applied to the handoff series — `slots.handoffs` when `handoffs` is d
114
114
  either counted as a handoff by the `record` check's correspondence or failed by it for carrying no
115
115
  date; and, as of 2.6, that same pair a third time over the per-persona scope layer — `slots.personas`
116
116
  when `personas` is declared, and `personas.index.path` resolving **outside** `slots.personas`, since
117
- `index`'s orphan sweep would examine a file sited there as an undeclared persona location.
117
+ `index`'s orphan sweep would examine a file sited there as an undeclared persona location; and, as of
118
+ 2.11, `memory.store.budget.record_kilobytes` when `memory.store.budget.cutoff` is declared, since a
119
+ cutoff says which records that cap binds and alone configures nothing.
118
120
  Everything else is optional.
119
121
 
120
122
  _The 2.6 pair was enforced in `doctor` from the day the keys landed and was missing from this list until
@@ -139,7 +141,7 @@ a manifest in *another repository*, and no contract over a single document can s
139
141
  join the list, and the reason is what keeps that list meaningful: every entry there is a dependency
140
142
  between two keys **of one manifest**, which is the thing a reader of the schema alone would expect to find
141
143
  and does not. A check that needs a second repository is not in that category, and folding it in would make
142
- the count measure two different things at once. **The count stays at nine**, and it counts what it always
144
+ the count measure two different things at once. **The count stays at ten**, and it counts what it always
143
145
  counted.
144
146
 
145
147
  [`slots.md`](slots.md) argues each one.
@@ -150,8 +152,8 @@ defect [#77](https://github.com/sleepy-panda-srl/portulan/issues/77) is filed ab
150
152
  and adding to the list at 2.5 is exactly the edit that would have left it stale again — which is how
151
153
  this one was noticed._
152
154
 
153
- That the count went from one to three in a single MINOR, then to five, then to seven, and now stands at
154
- **nine**, is worth noticing rather than absorbing. Each is a genuine dependency between two keys and none
155
+ That the count went from one to three in a single MINOR, then to five, then to seven, then to nine, and
156
+ now stands at **ten**, is worth noticing rather than absorbing. Each is a genuine dependency between two keys and none
155
157
  can be written in the declared subset, so the gap between *what the schema says* and *what a conforming
156
158
  manifest must satisfy* is widening — and a constraint invisible to someone reading the schema alone is a
157
159
  real cost, stated here rather than discovered. The subset earns its narrowness by being implementable
@@ -179,6 +181,38 @@ subset can say, and `doctor` returns at *the manifest must conform first* before
179
181
  runs. It is a live hole for `cli/index.mjs` alone, which validates against no schema by design and
180
182
  therefore refuses the string itself.)_
181
183
 
184
+ **2.9 adds nothing to that list either**: `context` requires `ratio`, and `ratio` requires its two
185
+ fields, in the schema itself, because `required` is in the subset. It adds **two** keys to the hand-check,
186
+ so it covers **nine**. `context.always.budget.tokens` is the fifth budget the subset can only type as
187
+ `number`. `context.ratio.bytes_per_token` is the first key held to something other than a positive
188
+ integer: 2.99 bytes per token is a real figure, so the check is *at least 1*, since a token covers at
189
+ least one byte and a smaller figure is tokens per byte entered inverted. For that key `minimum` alone
190
+ would close the gap.
191
+
192
+ **2.10 adds nothing to either list.** `slots.context` is a directory path, which the schema types in full,
193
+ and what a unit's frontmatter may say is `compile`'s to refuse, because the subset cannot see inside a
194
+ file. `doctor` checks the slot as it checks every directory slot, and gates it to 2.10.
195
+
196
+ **2.11 adds one to each list.** `memory.store.budget.cutoff` needs `record_kilobytes` beside it, the
197
+ tenth conditional requirement; and its `pattern` admits a day that does not exist, such as `2026-02-30`,
198
+ so `doctor` and `cli/index.mjs` refuse one by hand, and the hand-check covers **ten**.
199
+ `sessions` adds nothing to either: every field of it is a boolean or one of two strings, which the
200
+ schema types in full with `type` and `enum`, so `doctor` only gates the key to 2.11.
201
+
202
+ **2.12 adds five to the hand-check and nothing to the conditional requirements.** `required` holds both
203
+ halves of `spend.multipliers` and both lifetimes of its `write` in the schema itself, and the subset types
204
+ the four figures only as `number`, the horizon included, since it cannot say `integer`, so `doctor` holds
205
+ the read in (0, 1], each write at least 1 and the horizon to a positive integer by hand, and refuses a write
206
+ the read divides past the largest number, since the restart threshold divides one by the other; the
207
+ hand-check covers **fifteen**. For the two writes `minimum` alone would close the gap, as it would for the
208
+ ratio; the read is the first figure bounded above, so it would need `exclusiveMinimum` and `maximum`, and
209
+ the horizon the budgets' `minimum` and `integer`; no keyword in the subset relates two figures, so the
210
+ quotient stays by hand.
211
+
212
+ **2.13 adds nothing to either list.** `spend.restart` is one of two strings, which the subset types in full
213
+ with `type` and `enum`, so the key needs no hand-check, only the gate to 2.13, which `doctor` and `compile`
214
+ both hold.
215
+
182
216
  _These figures are history rather than state: what 2.3 and 2.4 added cannot change, so they do not go
183
217
  stale the way the removed count did. The one forward-looking sentence is the growth rate, and it is
184
218
  dated by the version it names._
@@ -194,15 +228,87 @@ number governing both would make a bump in either mean a change in the other:
194
228
 
195
229
  | Schema | Manifest key | Current | What it governs |
196
230
  |---|---|---|---|
197
- | [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.8** | the Workspace Definition — the manifest at a workspace root |
231
+ | [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.13** | the Workspace Definition — the manifest at a workspace root |
198
232
  | [`pack.schema.json`](pack.schema.json) | `portulan.pack` | **1.0** | the Pack Definition — the manifest at a pack root. |
199
233
 
200
234
  The rules below apply to each train independently. `portulan.spec` is `MAJOR.MINOR`, and the current
201
- Workspace Definition version is **2.8**. It did **not** move when the Pack Definition arrived, because
235
+ Workspace Definition version is **2.13**. It did **not** move when the Pack Definition arrived, because
202
236
  `workspace.schema.json` was byte-identical across that change: `packs` already existed as an array of
203
237
  strings and was deliberately left that way, since tightening its items to the canonical `category/name`
204
238
  form would be a constraint an existing manifest could newly fail, which is a MAJOR.
205
239
 
240
+ **2.13 is a MINOR on 2.8's terms: optional keys only.** `spend.restart` is the last piece of item 5 of
241
+ proposal `0038`'s order of work, the declared block its ruling 3 allows: `"advise"` is the restart advisory as
242
+ it was, and `"block"` also holds a turn's end once, at the first stop at or past the threshold that no block
243
+ provoked, with the advisory's line as the reason; [`slots.md`](slots.md) argues it. Nothing is removed,
244
+ renamed, tightened or defaulted: without the key, or with `"advise"`, the settings compile byte for byte as
245
+ before, so every 2.12 manifest is a valid 2.13 manifest unchanged. This repository's own workspace stays on
246
+ 2.11 and declares no block: it declares no multipliers either, and at the general ones the line comes early
247
+ for a model whose cache reads cost less than a tenth, so a block would hold its sessions short of their real
248
+ line. Nothing writes 2.13 unasked: `init` asks nothing about the key and `upgrade` offers nothing, since
249
+ ruling 3 makes a block a workspace's own choice. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
250
+ [`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.13"` by addition, and `doctor` and `compile` both
251
+ refuse the key in a manifest that declares an earlier MINOR.
252
+
253
+ **2.12 is a MINOR on 2.8's terms: optional keys only.** `spend` is item 4 of proposal `0038`'s order of
254
+ work: the multipliers the restart threshold is computed at, which its ruling 2 has the manifest declare, and
255
+ the horizon the threshold is judged over, 20 requests under ruling 3 until the manifest declares another;
256
+ [`slots.md`](slots.md) argues it. Nothing is removed, renamed, tightened or defaulted: without `spend`,
257
+ [`../cli/ledger.mjs`](../cli/ledger.mjs) and the restart advisory price at the general multipliers as before
258
+ and say `undeclared`, and the settings compile byte for byte as before, so every 2.11 manifest is a valid
259
+ 2.12 manifest unchanged. This repository's own workspace stays on 2.11 and declares no `spend`; it now
260
+ declares `sessions.headless.cache_lifetime` `"5m"`, a 2.11 key. `examples/` stays on 2.4. `init` writes 2.11
261
+ where a person takes the five-minute cache lifetime it now offers, since `sessions` is 2.11's key, and 2.10
262
+ otherwise. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
263
+ [`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.12"` by addition, and `doctor` gates the key to
264
+ 2.12. `0038`'s third key, the declared Stop-gate block, takes the next free MINOR, 2.13, in its own change,
265
+ and `0034`'s keys take the next free MINOR when they are drafted.
266
+
267
+ **2.11 is a MINOR on 2.8's terms: optional keys only.** `memory.store.budget.cutoff`
268
+ makes the per-record cap forward-only, in proposal `0037`'s shape for a handoff, and [`slots.md`](slots.md)
269
+ argues it. `sessions` is the machine half of [`../core/operating/sessions.md`](../core/operating/sessions.md),
270
+ from `0038`'s rules 3 and 4: the host switches that change what a fresh session writes into the prompt cache,
271
+ two of which `compile` writes into the settings it already generates and one of which only a runner can apply;
272
+ [`slots.md`](slots.md) argues it too. Nothing is removed, renamed, tightened or defaulted: without the cutoff,
273
+ `record_kilobytes` binds every record as it did, and without `sessions` the settings compile byte for byte as
274
+ before, so every 2.10 manifest is a valid 2.11 manifest unchanged. This repository's own workspace moves from
275
+ 2.10 to 2.11 to declare the cutoff, with the cap lowered from 8 KB to 2 and the cutoff 2026-09-24, and declares
276
+ no `sessions`; `examples/` stays on 2.4. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
277
+ [`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.11"` by addition, `doctor` gates both keys to 2.11,
278
+ and the four writers stay at `2.7`, since nothing they scaffold declares either. `0034`'s key and the rest of
279
+ `0038`'s still take the next free MINOR when they are drafted.
280
+
281
+ **2.10 is a MINOR on 2.9's terms: one optional slot and nothing else.** `slots.context` is the content
282
+ half of [`../core/operating/context.md`](../core/operating/context.md), from proposal `0036`'s compile
283
+ targets: a directory of guidance, each file declaring in its frontmatter the load tier it belongs in, which
284
+ `compile` emits in each host's own form. Nothing is removed, renamed, tightened or defaulted, so every 2.9
285
+ manifest is a valid 2.10 manifest unchanged, and [`slots.md`](slots.md) argues the slot. It is the first
286
+ MINOR with two digits, and every reader compares the MINOR as a number or matches the string whole, so
287
+ `2.10` sorts after `2.9`. `0034`'s and `0038`'s keys still take the next free MINOR when they are drafted.
288
+
289
+ **Measured across the bump:** `node cli/doctor.mjs .portulan examples` is GREEN, with `.portulan` on 2.8
290
+ and `examples/` on 2.4, both untouched: the demo stays six MINORs behind for the reason given under 2.8,
291
+ and no workspace here declares the slot, because moving guidance between tiers in this repository's own
292
+ curated layer is its maintainer's change to make. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs)
293
+ and [`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.10"` by addition, and the four writers stay at
294
+ `2.7`, since nothing they scaffold declares the slot.
295
+
296
+ **2.9 is a MINOR on 2.8's terms: one optional key and nothing else.** `context` is the machine half of
297
+ [`../core/operating/context.md`](../core/operating/context.md), from proposal `0036`: an always-tier
298
+ budget in tokens and the bytes-per-token ratio it is counted at, with the host whose exact count
299
+ calibrated it. Nothing is removed, renamed, tightened or defaulted, so every 2.8 manifest is a valid 2.9
300
+ manifest unchanged. [`slots.md`](slots.md) argues the key. **It carries `context` alone.** Proposals
301
+ `0034` (`evals` and `telemetry`) and `0038` (its spend keys) each say their keys ride the MINOR this key
302
+ needs and that neither waits for the other; none of them is drafted, and `0034`'s accepted text names no
303
+ version number, so they take the next free MINOR when they are drafted.
304
+
305
+ **Measured across the bump, as for 2.7 and 2.8:** `node cli/doctor.mjs .portulan examples` is GREEN, with
306
+ `.portulan` still on 2.8 and `examples/` on 2.4, both untouched. Neither declares the key, because
307
+ nothing measures the always tier yet and a budget nothing checks reads as a rail. What it costs:
308
+ `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and [`../cli/librarian.mjs`](../cli/librarian.mjs)
309
+ gains `"2.9"` by addition, and the four writers stay at `2.7`, since nothing they scaffold declares the
310
+ key.
311
+
206
312
  **2.8 is a MINOR on the plainest terms in this train's history: one optional key and nothing else.**
207
313
  `memory.store.budget.record_kilobytes` — the most bytes any single record may hold — joins the sibling
208
314
  `kilobytes` it does not replace. Nothing is removed, renamed, tightened or defaulted, so every 2.7
@@ -328,6 +434,7 @@ workspace with no constitution slot and a green report.
328
434
  | A declared pack **resolves** | `doctor` | **Built**. Roots are derived from `tree`, and named roots may be given with `--pack-root`, which **replace** the derived one rather than preceding it, so a from-a-feed demonstration cannot be satisfied by a copy in the local tree at all. A workspace with no `tree` and no named root has its packs reported *unverifiable* rather than failed. Resolution **from a feed is demonstrated**: the checkpoint ritual pack installed from the private `portulan-internal` marketplace and resolved from the install root, which is the same code path the derived one takes. **Discovery is built** ([#123](https://github.com/sleepy-panda-srl/portulan/issues/123)): `--pack-root auto` reads the host's installed-plugin record — in **both** shapes a plugin lands in, packs under `packs/` for a repository-shaped one and categories at the install root for a flat one, which is what this project's own feed ships. Asked-for discovery is **unioned** with the tree-derived root, discovered first, so a workspace composing a cache-installed pack *and* one of its own resolves both. The **named**-root property above is untouched: a named root still replaces the derived one, so a from-a-feed demonstration given one cannot be satisfied by a local copy. What the union trades is the weaker guarantee that `auto` *alone* implied provenance; it no longer does, and each pack's resolution states which root answered instead. Asking for a named root **and** `auto` together is refused (exit 2) rather than silently resolved to one of them. Discovery runs on the **unasked** path too, because `--pack-root` is *optional where discovery finds a root*. `auto` now selects the strict degrade (asked-and-could-not-look is exit 2) rather than unlocking discovery. Two things bound it, and neither makes a bare run host-independent — `doctor` is a per-host capability report and a bare verdict does move with the machine. First, every **required** check **names** its root, which replaces every other source, so it cannot consult the host at all. Second, `doctor`'s note-vs-fail keying is on a root's **origin**, so discovery can turn a note into a **resolution** and never a **miss** into a failure — a miss under a root the workspace or the caller claimed stays a FAIL whatever is installed. It softens nothing: a discovered copy that resolves and is *invalid* still fails, with its origin named. |
329
435
  | A pack manifest conforms to the Pack Definition | `doctor` | **Built**. Same validator, same subset, a different schema and version train. |
330
436
  | A pack's skills and personas are opened and validated | `doctor` | **Built**, on the maintainer's ruling that row 7's validation reads broad. A skill's frontmatter (`name` kebab-case, `description` non-empty — the two a host reads before loading a body); a persona against the five-part contract, **including the memory scope** `core/operating/memory.md` promised would be validated at this milestone; and the tier no role may claim. Containment is checked **after resolution**, because opening these paths is what made an escaping value stop being inert. Three honest limits, each measured rather than assumed. The skills walk stops **three levels down**, matching `cli/plugin-lint.mjs`'s bound so two walkers over the same key cannot disagree, and it **reports the directories it did not descend into** rather than counting a root it never opened as empty. The Prohibited check is a prose heuristic defeated by any negation **in the same block** — a bullet list counts as one block — and the structural fix is a declared reach *field*, a contract change that is not built. And the five-part check is **presence**, not content: a persona whose sections are all empty passes, so a declared memory scope is read and not honoured. |
437
+ | A persona's declared context fields — `model`, `effort`, `omitClaudeMd`, `cacheLifetime` | `doctor` · `compile` | **Built** for a persona a composed pack ships, with no schema version moved, since no persona file is governed by either schema: the fields belong to the persona contract [`../core/personas/README.md`](../core/personas/README.md) fixes, and are optional. `doctor` fails a malformed value and `compile` refuses one; neither defaults any. `compile` writes the declared fields into the role's hand-written binding, `agents/<name>.md` in the tree, byte-compared under `--check`, and prints a degradation line for each field a host cannot express. Claude Code loads `agents/` only from a plugin root, so in a tree that is not one the compiled block is loaded by nothing, and that degradation line says so; in one that is, it loads only while the plugin is installed and enabled, which `compile` cannot see. **Not built**: writing `.claude/agents/`, where a repository that is not a plugin loads its agents; a binding where none exists, which is reported unbound; and the compiling of a core persona's binding. |
331
438
  | Pack gate fragments merge **tighten-only** | [`../cli/compile.mjs`](../cli/compile.mjs) | **Built**. Two layers and two axes: `auto` is absent from the Pack Definition's tier enum, so a demotion to unattended is unexpressible; the comparison against the composed base is the compiler's, and both a weakened **tier** and a changed **action** throw rather than being dropped. The action half is the one a tier-only check misses — raising the tier while swapping the matcher removes the gate and still reads as a tightening. |
332
439
  | A pack declaring a version ahead of `doctor` | `doctor` | **Built**. Refused rather than graded, symmetric with the workspace train, and on the Pack Definition's own `$id`. |
333
440
  | Path slots resolve to real files | `doctor` | **Built.** File-versus-directory too; a slot resolving outside the workspace is reported, never failed. |
@@ -1,30 +1,11 @@
1
1
  // Workspace Definition 1.0 → 2.0 — a `repository` workspace declares its `tree`.
2
- //
3
- // The only MAJOR migration this train has, and `../README.md` records why it was taken while it cost
4
- // nothing: `tree` began optional, and optional was a hole — deleting one manifest line degraded the
5
- // whole claims-lint class from *checked* to *reported*, GREEN, exit 0.
6
- //
7
- // **This step has no subject in this repository and none in any tree we have seen.** Nothing here
8
- // declares 1.0. It is exercised against a fixture, which `../README.md` says out loud rather than
9
- // leaving a passing suite to imply otherwise.
10
2
 
11
3
  import fs from "node:fs";
12
4
  import path from "node:path";
13
5
 
14
- /** The version this step migrates FROM, as a MAJOR. One place, so the two readers below agree. */
15
6
  const FROM_MAJOR = 1;
16
7
 
17
- /**
18
- * Is `dir` a repository root? `.git` is the evidence, and it is **a file as often as a directory**.
19
- *
20
- * A worktree's `.git` is a FILE containing `gitdir: …`, and so is a submodule's. A directory-only
21
- * test passes every suite written on an ordinary clone and is wrong for every worktree user — the
22
- * shape this project keeps paying for, so it is guarded here rather than discovered. The repository
23
- * this step ships from is itself checked out as a worktree today.
24
- *
25
- * Three answers, not two: `yes`, `no`, and **`unknown`** — because only `ENOENT` means absent, and an
26
- * `EACCES` is a question nobody answered.
27
- */
8
+ /** `.git` is a file in a worktree or a submodule, and only ENOENT means it is absent. */
28
9
  function repositoryRoot(dir) {
29
10
  try {
30
11
  fs.lstatSync(path.join(dir, ".git"));
@@ -63,16 +44,10 @@ export const step = {
63
44
  plan(ws, ctx) {
64
45
  const next = { ...ws.manifest, portulan: { ...ws.manifest.portulan, spec: this.to } };
65
46
 
66
- // Only a `repository` needs `tree`. A `demo` or `portfolio` describes repositories that are
67
- // not beside it, and giving one a tree would be inventing a claim the workspace never made.
68
47
  if (next.kind === "repository" && typeof next.tree !== "string") {
69
48
  if (typeof ctx.tree === "string" && ctx.tree !== "") {
70
49
  next.tree = ctx.tree;
71
50
  } else {
72
- // Derived only where it is derivable, and refused otherwise. A hopeful `../` is a
73
- // claim about somebody's layout made by a tool that did not look — and `tree` is
74
- // precisely the slot whose wrongness turns a check class into a lie rather than an
75
- // error.
76
51
  const parent = path.resolve(ws.dir, "..");
77
52
  const verdict = repositoryRoot(parent);
78
53
  if (verdict === "yes") {
@@ -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
+ };