@sleepy_panda_srl/portulan 0.2.0 → 0.4.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 (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +230 -40
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +1 -1
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,88 @@
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 longer than one short parent Edit:** a mechanical refactor or a
30
+ history-comment cleanup across a file, or a unit test the parent would otherwise iterate red→green,
31
+ where a verify command can prove the edit green or have it rolled back.
32
+ - **An explicitly selected candidate research digest:** reading a long file or log so only a short digest enters the parent context.
33
+ - **An explicitly selected candidate librarian or handoff draft:** compressing a handoff under its byte cap, or drafting a `codify` or
34
+ `consolidate` candidate for human review.
35
+
36
+ Not for multi-file architectural planning, ambiguous requirements (`clarify`), or pre-merge verdicts
37
+ (`reviewer`).
38
+
39
+ ## When it pays
40
+
41
+ Each call is itself one parent request: the parent writes the instruction, waits and reads the receipt,
42
+ and that request re-reads the whole context like any other. In 0038's `m_r × (F·T + g·T²/2)`, a call that
43
+ replaces one parent Edit leaves `T` as it was and lowers `g` only by the output it saves, the difference
44
+ between writing the instruction and writing the code. A miss, a call that keeps nothing or a kept edit the
45
+ parent then touches up, adds a whole request. So:
46
+
47
+ - **Keep an edit the parent can write in one short Edit in the parent.** Delegate only where one call
48
+ replaces more parent work than the request it costs.
49
+ - **Whether a call pays turns on its miss rate and the context's size.** With `s` the output a kept call
50
+ saves, priced at 0038's five times input, and `c` a miss's re-read of the context `C` at the tenth of
51
+ input that `cli/ledger.mjs` takes for a cache read, a call pays only while its miss rate stays under
52
+ `s / (s + c)`, and under less once a miss's own output is counted. One run in
53
+ [#583](https://github.com/sleepy-panda-srl/portulan/issues/583) measured a 20% miss rate at a median `C`
54
+ of 236k. The output a kept call saved, derived from the parent's output on the edits it wrote itself, was
55
+ 250–650 tokens and is overstated, since the parent kept its larger edits. That puts break-even at 5–12%,
56
+ a net loss, from one run, one local model and one parent model, and the verdict rests on the tenth: at
57
+ 0038's fortieth, break-even is 17–35%.
58
+ - **Where a call should pay is reasoning, not measurement:** a red→green loop the parent would otherwise
59
+ iterate, a batch of calls inside one tool request, and a long mechanical replacement the parent would
60
+ otherwise write out.
61
+
62
+ ## The pass
63
+
64
+ 1. **Scope the input first.** Outline a file of 300 lines or more with
65
+ `node <plugin root>/cli/symbols.mjs <file>` and pass `--file <file> --lines <start>-<end>` (to
66
+ `transform`, `chat` and `summarize` alike) rather than the whole file. `cli/` ships with the
67
+ repository-root plugin and not in the `packs/` payload; there, find the span's line numbers with
68
+ `grep -n`. Prefill scales with prompt bytes: proposal 0039 measured prompts of about 400–470 tokens
69
+ prefilling in 0.85–1.88 s, and the `32 KB` default of `--max-input-bytes` (about 8k tokens) is a
70
+ ceiling, not a measured latency.
71
+ 2. **Use an earned role.** Code transforms and strict syntax use `--role code`
72
+ (`qwen/qwen3.8-27b`) at `--effort low`, supported by the
73
+ [captured A/B](../../../../../evals/ab/local-worker.md)'s L5/H5 code cell. Shared `--role prose`
74
+ (`google/gemma-4-31b`) is disabled: codify scored L0/H3, despite handoff's L5/H5. Keep digests and
75
+ librarian drafts in the parent session. `summarize` defaults to prose and refuses; explicit roles,
76
+ model overrides and environment mismatches cannot bypass the guard. An operator can select a
77
+ candidate with `--model <id>` and no `--role`; that is deliberate evaluation, not earned routing.
78
+ Screen a candidate with `node <runner> eval --model <id>`; it needs `cli/` beside `packs/` and exits
79
+ `2` without it. Passing a screen never enables a role.
80
+ 3. **Gate every edit.** `node <runner> transform --file <path> --verify "<cmd>" --prompt "<instruction>"`,
81
+ where `--verify` is required. The runner applies the edit, runs `<cmd>`, retries with the end of its
82
+ output on exit `1`, and keeps the edit only on exit `0`; any other exit, and SIGINT, SIGTERM or
83
+ SIGHUP, restores the file byte for byte. It is a rollback, not an atomic write: any other signal that
84
+ ends the runner, SIGKILL and SIGQUIT among them, or a power loss between the edit and its verdict
85
+ leaves the edit in place.
86
+ 4. **Read only the receipt back.** On exit `0`, take the one-line receipt and do not re-read the file
87
+ unless a later edit depends on its new line numbers. On exit `1` or `2`, escalate to the frontier
88
+ 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
@@ -228,15 +228,40 @@ number governing both would make a bump in either mean a change in the other:
228
228
 
229
229
  | Schema | Manifest key | Current | What it governs |
230
230
  |---|---|---|---|
231
- | [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.13** | the Workspace Definition — the manifest at a workspace root |
232
- | [`pack.schema.json`](pack.schema.json) | `portulan.pack` | **1.0** | the Pack Definition — the manifest at a pack root. |
231
+ | [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.16** | the Workspace Definition — the manifest at a workspace root |
232
+ | [`pack.schema.json`](pack.schema.json) | `portulan.pack` | **1.1** | the Pack Definition — the manifest at a pack root. |
233
233
 
234
234
  The rules below apply to each train independently. `portulan.spec` is `MAJOR.MINOR`, and the current
235
- Workspace Definition version is **2.13**. It did **not** move when the Pack Definition arrived, because
235
+ Workspace Definition version is **2.16**. It did **not** move when the Pack Definition arrived, because
236
236
  `workspace.schema.json` was byte-identical across that change: `packs` already existed as an array of
237
237
  strings and was deliberately left that way, since tightening its items to the canonical `category/name`
238
238
  form would be a constraint an existing manifest could newly fail, which is a MAJOR.
239
239
 
240
+ **2.16 adds optional `spend.resume.tokens`**, from [#503](https://github.com/sleepy-panda-srl/portulan/issues/503).
241
+ It prices additional restart expense in uncached-input-token equivalents. Doctor
242
+ checks its finite nonnegative bound by hand and gates it to 2.16; compile refuses
243
+ older declarations before writing. Index and librarian recognize the new minor.
244
+ Existing manifests need no change: unknown resume cost or a default horizon now
245
+ permits a conditional report only, including for an existing `restart: "block"`.
246
+ No estimate is installed automatically, and init gains no question.
247
+
248
+ **2.15 adds optional handoff budgets**, accepted proposal 0037: a per-record token cap with a required
249
+ cutoff, and a generated index window with a token budget. Existing manifests need no changes. Both
250
+ budgets require an explicit context ratio; doctor checks their positive integer bounds and real cutoff,
251
+ and direct index calls independently refuse malformed declarations. Doctor gates the keys to 2.15;
252
+ index and librarian add that supported version. Init opts into 2.15 only when a budget is accepted;
253
+ upgrade installs no cap or ratio unasked. This workspace defers its proposed 3000/3000 adoption until
254
+ it has a measured ratio. The series remains unbudgeted and the Stop-gate checks no length.
255
+
256
+ **2.14 adds optional `verify.stopTimeout`.** The workspace may declare the Stop
257
+ recipe's deadline in whole seconds; absent, it remains ninety seconds. `doctor`,
258
+ `compile` and the runner share its range and version check, and compile gives
259
+ that hook sixty additional seconds. No existing declaration is tightened or
260
+ rewritten. `KNOWN_SPECS` in index and librarian gains `2.14` by addition; writers
261
+ retain the oldest version their actual fields require. The schema subset types
262
+ this field as `number`; the shared reader checks the integer/range constraint.
263
+ This adds one hand-check to 2.12's fifteen, without expanding the schema subset.
264
+
240
265
  **2.13 is a MINOR on 2.8's terms: optional keys only.** `spend.restart` is the last piece of item 5 of
241
266
  proposal `0038`'s order of work, the declared block its ruling 3 allows: `"advise"` is the restart advisory as
242
267
  it was, and `"block"` also holds a turn's end once, at the first stop at or past the threshold that no block
@@ -434,6 +459,7 @@ workspace with no constitution slot and a green report.
434
459
  | 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. |
435
460
  | A pack manifest conforms to the Pack Definition | `doctor` | **Built**. Same validator, same subset, a different schema and version train. |
436
461
  | 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. |
462
+ | A persona's declared context fields — `model`, `effort`, `omitClaudeMd`, `cacheLifetime` | `doctor` · `compile` | **Built** for composed personas and the core personas in this engine's own declared tree, with no schema version change. The optional fields belong to [`../core/personas/README.md`](../core/personas/README.md); neither reader defaults them. Compiler and doctor select existing `agents/<name>.md` bindings for plugins and `.claude/agents/<name>.md` for ordinary projects. `--check` byte-compares declared fields, including in a persona-only project. Missing adopter bindings remain unbound and are not created; malformed/nonregular/escaping marker and binding inputs are refused. A degradation line names each field an AGENTS.md host cannot express. This establishes source-derived files and selected locations, not host enablement or runtime honoring of a key. |
437
463
  | 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. |
438
464
  | 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`. |
439
465
  | Path slots resolve to real files | `doctor` | **Built.** File-versus-directory too; a slot resolving outside the workspace is reported, never failed. |
@@ -500,3 +526,11 @@ repositories are *not present* is what exposed that the claims lint had no way t
500
526
 
501
527
  Illustrative fragments in [`slots.md`](slots.md) use `{braces}` for placeholders, the same convention as
502
528
  [`../core/templates/`](../core/templates/).
529
+
530
+ Pack Definition 1.1 adds optional ritual `contributes.contexts` with
531
+ `max_concurrent_subagents`. Doctor and compiler require a positive safe integer
532
+ and the 1.1 contract; the schema subset expresses only its numeric shape.
533
+ Counts compose by minimum and compile to Claude project settings, with the
534
+ [admission scope and exceptions](../packs/rituals/checkpoints/README.md#context-admission)
535
+ stated separately from the ledger's actual lifetime count. Workspace Definition
536
+ is unchanged; rituals remain packs, as recorded in [slots](slots.md).
@@ -1,13 +1,29 @@
1
1
  // A form step — each section a team marks in its own instruction file moves to an on-read unit.
2
2
 
3
3
  import { ESTIMATED_BYTES_PER_TOKEN, alwaysTier, declaredContext, tokensOf } from "../../cli/context.mjs";
4
+ import { previewMigration } from "../../cli/init-preview.mjs";
4
5
  import { notYetForm } from "../../cli/form.mjs";
5
- import { INSTRUCTION_FILES, contextDir, grouped, instructionReader, linkRefusal, marksOf, planSplit, splitLines } from "../../cli/instructions.mjs";
6
+ import {
7
+ INSTRUCTION_FILES,
8
+ contextDir,
9
+ grouped,
10
+ instructionReader,
11
+ linkRefusal,
12
+ marksOf,
13
+ planSplit,
14
+ splitLines,
15
+ } from "../../cli/instructions.mjs";
6
16
 
7
17
  function splitOf(ws) {
8
18
  const tree = ws.repository.dir;
9
19
  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));
20
+ const taken =
21
+ context === null
22
+ ? []
23
+ : ws.repository
24
+ .names(context)
25
+ .filter((name) => name.endsWith(".md"))
26
+ .map((name) => name.slice(0, -3));
11
27
  return planSplit({ tree, context, taken, read: instructionReader(tree, ws.repository.read) });
12
28
  }
13
29
 
@@ -26,29 +42,62 @@ export const step = {
26
42
  owed(ws, ctx) {
27
43
  const behind = notYetForm(ws, ctx);
28
44
  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" };
45
+ if (!ws.repository)
46
+ return {
47
+ owed: false,
48
+ because: "this workspace declares no tree, so there is no instruction file of its own",
49
+ };
30
50
  const read = instructionReader(ws.repository.dir, ws.repository.read);
31
51
  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 -->`" };
52
+ if (marked.length === 0)
53
+ return {
54
+ owed: false,
55
+ because: "no section of CLAUDE.md or .claude/CLAUDE.md is marked `<!-- portulan: on-read -->`",
56
+ };
33
57
  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` };
58
+ return {
59
+ owed: false,
60
+ because: `${marked.join(" and ")} marks a section, and \`slots.context\` is undeclared: \`0006\` declares it, and this step is asked again then`,
61
+ };
35
62
  }
36
63
  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` };
64
+ if (linked.length)
65
+ return {
66
+ owed: true,
67
+ hand: true,
68
+ because: `a marked section cannot move, so none is moved — ${linked.join("; ")}, or take the marks out, then upgrade again`,
69
+ };
38
70
  const split = splitOf(ws);
39
- if (split.refusals.length) return { owed: true, because: `a marked section cannot move — ${split.refusals.join("; ")}` };
71
+ if (split.refusals.length)
72
+ return { owed: true, because: `a marked section cannot move — ${split.refusals.join("; ")}` };
40
73
  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;
74
+ const projected = previewMigration(ws.repository.dir, ws.dir, [
75
+ ...split.files.map((f) => ({ file: f.rel, next: f.after })),
76
+ ...split.units.map((u) => ({ file: u.source, next: u.text })),
77
+ ]);
78
+ const before = projected.before[0],
79
+ after = projected.after?.[0];
43
80
  return {
44
81
  owed: true,
45
- because: `${splitLines(split).join("; ")}; the always tier goes from ~${grouped(tokensOf(before, ratio))} to ~${grouped(tokensOf(after, ratio))} tokens`,
82
+ because: `${splitLines(split).join("; ")}; ${after === undefined ? "the after forecast is unavailable because native imports cannot be projected exactly" : `the always tier goes from ~${grouped(tokensOf(before, ratio))} to ~${grouped(tokensOf(after, ratio))} tokens`}`,
46
83
  };
47
84
  },
48
85
 
49
86
  plan(ws) {
50
87
  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("; ")}` };
88
+ if (split.refusals.length)
89
+ return {
90
+ ok: false,
91
+ reason: `a marked section cannot move, so none is moved — ${split.refusals.join("; ")}`,
92
+ };
93
+ try {
94
+ previewMigration(ws.repository.dir, ws.dir, [
95
+ ...split.files.map((f) => ({ file: f.rel, next: f.after })),
96
+ ...split.units.map((u) => ({ file: u.source, next: u.text })),
97
+ ]);
98
+ } catch (error) {
99
+ return { ok: false, reason: `prospective guidance refuses — ${error.message}` };
100
+ }
52
101
  return {
53
102
  ok: true,
54
103
  edits: [
@@ -0,0 +1,23 @@
1
+ // Refresh existing persona context fields through the compiler's ownership planner.
2
+ import { personaEdits } from "../../cli/compile.mjs";
3
+ import { notYetForm } from "../../cli/form.mjs";
4
+
5
+ export const step = {
6
+ id: "0011-persona-bindings-compiled", kind: "form", from: null, to: null,
7
+ title: "existing eligible persona bindings carry the shipped context declarations",
8
+ why: "The shipped persona route and its A/B evidence must arrive on upgrade. Refresh existing compiler-owned context fields; never create a binding, widen core source selection, or write gate settings.",
9
+ owed(ws, ctx) {
10
+ const behind = notYetForm(ws, ctx);
11
+ if (behind) return { owed: false, because: behind };
12
+ if (!ws.repository) return { owed: false, because: "no declared repository tree holds persona bindings" };
13
+ const planned = personaEdits(ws.dir);
14
+ return {
15
+ owed: planned.edits.length > 0,
16
+ because: planned.edits.length ? `${planned.edits.map(edit => edit.file).join(", ")} differ from their persona declarations` :
17
+ planned.compared ? "existing eligible persona bindings agree with their declarations" : "no existing eligible binding is selected; missing or foreign core bindings are not installed",
18
+ };
19
+ },
20
+ plan(ws) {
21
+ return { ok: true, edits: personaEdits(ws.dir).edits.map(edit => ({ root: "tree", ...edit })) };
22
+ },
23
+ };
@@ -107,6 +107,7 @@ is a question that could not be answered.
107
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
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
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. |
110
+ | [`0011-persona-bindings-compiled.mjs`](0011-persona-bindings-compiled.mjs) | `form` | Refresh existing eligible persona bindings through the compiler's ownership planner. Core sources participate only in the installed engine's own tree; composed pack sources retain their existing boundary. Missing bindings are not created and gate settings are untouched. |
110
111
 
111
112
  **`0001` has no subject in this tree** — nothing declares 1.0 — and is exercised against a fixture,
112
113
  which is said here rather than dressed up. **`0002` has no subject in this repository either**: this
@@ -118,6 +119,11 @@ either, since this repository keeps no `CLAUDE.md`, and is exercised against suc
118
119
  instruction file the suite marks; `0010` against one `init` drafted where git listed no files, then
119
120
  committed.
120
121
 
122
+ `0011` is exercised against exact v0.2.0 core bindings in a copy of the engine and against
123
+ foreign trees and absent bindings. The command-level demonstration holds unrelated bytes,
124
+ modes and settings constant, checks idempotence, and forces a staging collision after one
125
+ replacement to demonstrate rollback while preserving the unowned collision file.
126
+
121
127
  ## Adding one
122
128
 
123
129
  Drop a module in, named `NNNN-slug.mjs`. `cli/upgrade.mjs` reads the directory and orders by filename,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://portulan.dev/spec/pack/1.0/pack.schema.json",
4
- "title": "Portulan Pack Definition v1.0",
3
+ "$id": "https://portulan.dev/spec/pack/1.1/pack.schema.json",
4
+ "title": "Portulan Pack Definition v1.1",
5
5
  "description": "The manifest at the root of a pack. A pack is the cascade's middle layer — core < pack < workspace < repo card < task — and this manifest declares what it contributes to that cascade: skills, personas, verify recipes, and gate-policy fragments. It is an index, not a container, for the same reason the Workspace Definition is: the content lives in the files the paths point at. Written in the same deliberately small subset of JSON Schema 2020-12 — see spec/README.md for the subset, for what validates this, and for why a pack carries its own version train rather than the Workspace Definition's.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
@@ -49,7 +49,7 @@
49
49
  },
50
50
 
51
51
  "contributes": {
52
- "description": "What this pack adds to the cascade. Every key is optional and an absent key contributes nothing of that kind — but the object itself is required, because a pack contributing nothing is a plugin, not a pack, and the manifest should say which it is rather than leaving it to be inferred from four absences.",
52
+ "description": "What this pack adds to the cascade. Every key is optional and an absent key contributes nothing of that kind — but the object itself is required, because a pack contributing nothing is a plugin, not a pack, and the manifest should say which it is rather than leaving it to be inferred from optional absences.",
53
53
  "type": "object",
54
54
  "additionalProperties": false,
55
55
  "properties": {
@@ -74,6 +74,15 @@
74
74
  "minItems": 1,
75
75
  "items": { "$ref": "#/$defs/recipe" }
76
76
  },
77
+ "contexts": {
78
+ "description": "Since Pack Definition 1.1, ritual packs only: ordinary fresh native Agent admission within one session. Positive safe integer checked by doctor and compiler; number is the schema subset's numeric type. Multiple packs compose by minimum. Claude env compiles even without a gate policy. Parent, lifetime totals, resumes/forks and independent agent systems are outside the bound; see packs/rituals/checkpoints/README.md.",
79
+ "type": "object",
80
+ "additionalProperties": false,
81
+ "required": ["max_concurrent_subagents"],
82
+ "properties": {
83
+ "max_concurrent_subagents": { "type": "number" }
84
+ }
85
+ },
77
86
  "gates": {
78
87
  "description": "Gate-policy fragments — the cascade's missing middle, and the contribution that needs care, because an installed pack reaching the gate map means a dependency can change what an agent is allowed to do. The floor is the maintainer's ruling of 2026-07-27 recorded in .portulan/proposals/0010-prohibited-as-a-fourth-universal-tier.md: a pack may raise a tier or add a prohibition, and may NEVER demote another layer's classification. Enforcement is split between this schema and the merge step, and the split is stated rather than implied — see `$defs/fragment`. READ TODAY, like `personas` and unlike `skills` and `verify`: cli/compile.mjs is the merge step, and it resolves this key when a pack root is given.",
79
88
  "type": "array",