@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
@@ -1,50 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Portulan — every prose statement of the CURRENT version agrees with `package.json`.
3
- //
4
- // ## The defect this exists for
5
- //
6
- // `README.md` and `.portulan/products/portulan/product.md` both said the newest release entry was
7
- // `0.2.0` while `package.json`, both plugin manifests and the registry said `0.1.0`. They were fixed
8
- // together — and then, one release later, the SAME PAIR drifted again: `README.md` still read
9
- // "Current release: `0.1.0`" inside the change that cut `0.1.1`. Twice, with the pairing already
10
- // written down in `docs/plan.md` both times.
11
- //
12
- // **A sibling list in prose is not a rail.** This is the rail.
13
- //
14
- // It matters more than a tidy number because `README.md` is in `package.json`'s `files`, and npm
15
- // FREEZES a README per published version: a wrong current-version sentence that reaches a publish
16
- // cannot be corrected by any edit to `main`, only by cutting another release. That is exactly what
17
- // `0.1.1` was for.
18
- //
19
- // ## Why the record layer is excluded, which is the whole design
20
- //
21
- // This repository writes about its own defects, so the retired sentences are QUOTED in the files that
22
- // record fixing them: `CHANGELOG.md` carries *"The newest release entry is `0.2.0`"* inside the entry
23
- // explaining that it was retired, and two handoffs plus `docs/plan.md` do the same. A matcher over
24
- // everything reds on the repository's own account of the repair — the failure mode #286 measured,
25
- // where both arms it proposed died on this repository's own review corpus.
26
- //
27
- // So the scan is scoped to LIVE prose and the record layer is excluded BY PATH. The exclusion is not
28
- // a convenience: a dated record is supposed to preserve a sentence that is no longer true, and a rail
29
- // that forbids that would forbid the record.
30
- //
31
- // ## What it cannot do, stated rather than discovered
32
- //
33
- // A current-version claim spelled a NEW way, in any file, is invisible to it. There is no way to tell
34
- // "this prose asserts the current version" from "this prose mentions a version" without a matcher, and
35
- // a matcher only knows the spellings it was given. The accepted practice for that residual is #286's,
36
- // ruled by the maintainer: **a new spelling gets a pattern here and a fixture first.**
37
- //
38
- // The half that IS closed beyond drift: every file in MUST_CARRY has to produce at least one match, so
39
- // deleting or rewording a known carrier reds instead of silently shrinking the rail's reach.
40
- //
41
- // Three further residuals, named because an unstated limit is the one a reader assumes away:
42
- // · A live claim placed UNDER an excluded prefix is invisible. `.portulan/memory/` in particular
43
- // holds doctrine-shaped notes rather than only dated records, so a current-version sentence
44
- // written there would not be checked.
45
- // · An UNTRACKED carrier is invisible: enumeration is `git ls-files`, deliberately, because a rail
46
- // that reads the working tree grades files no commit will ever contain.
47
- // · A NON-MARKDOWN carrier is invisible: the pathspec is `*.md`.
2
+ // Every prose statement of the current version agrees with `package.json`.
48
3
  //
49
4
  // Exit 0 green · 1 a finding · 2 could not run.
50
5
 
@@ -53,35 +8,21 @@ import { join } from "node:path";
53
8
  import { pathToFileURL } from "node:url";
54
9
  import { execFileSync } from "node:child_process";
55
10
 
56
- /** Raised when a precondition fails. Always exit 2 — never a finding about the prose. */
57
11
  export class CouldNotRun extends Error {}
58
12
 
59
- // The spellings that assert a current version. Each must capture the version in group 1.
13
+ // Each captures the version in group 1; a claim spelled any other way goes unseen until it gets a pattern here.
60
14
  export const PATTERNS = [
61
15
  { id: "current-release", re: /\*\*Current release:\s*`([^`]+)`\*\*/g },
62
16
  { id: "newest-entry", re: /newest release entry is\s*`([^`]+)`/g },
63
17
  { id: "supported-current", re: /\|\s*`([^`]+)`\s*\|\s*Yes\s*—\s*the current release\s*\|/g },
64
18
  ];
65
19
 
66
- // Files that MUST carry at least one claim. Losing one is a finding: it means a carrier was deleted
67
- // or reworded, which is how this rail would otherwise quietly stop covering what it was built for.
68
20
  export const MUST_CARRY = ["README.md", "SECURITY.md", ".portulan/products/portulan/product.md"];
69
21
 
70
22
  // The record layer. These files preserve sentences that are no longer true, on purpose.
71
23
  export const RECORD_PREFIXES = [
72
24
  "CHANGELOG.md",
73
- // **A release record is record-layer by nature**, and it was classified here deliberately rather
74
- // than left to whether a pattern happens to match one. `evals/releases/<version>.md` is generated
75
- // per release and preserves that release's conditions forever — so the moment `0.1.4` exists,
76
- // `0.1.3`'s register is a document stating, correctly and permanently, facts about a version that
77
- // is no longer current. That is precisely what this exclusion is for. Its own drift is held by the
78
- // `release-eval` recipe, which byte-compares each register against the capture it was rendered
79
- // from — so for `<version>.json` and `<version>.md` this moves coverage to the rail that can
80
- // actually read them rather than removing it. **`evals/releases/README.md` is the exception and is
81
- // named rather than glossed:** `release-eval` reads only the two version-keyed files, so that
82
- // README now sits under an excluded prefix with nothing covering it. Harmless today — it states no
83
- // current version — and it is exactly this module's own third named residual, a live claim under an
84
- // excluded prefix, so it is recorded here instead of being discovered by the next reader.
25
+ "changes/",
85
26
  "evals/releases/",
86
27
  "docs/plan.md",
87
28
  "docs/milestones/",
@@ -93,7 +34,6 @@ export const RECORD_PREFIXES = [
93
34
 
94
35
  export const isRecord = (p) => RECORD_PREFIXES.some((r) => (r.endsWith("/") ? p.startsWith(r) : p === r));
95
36
 
96
- /** Every tracked Markdown file that is not part of the record layer. Derived, never a written list. */
97
37
  export function liveProseFiles(root) {
98
38
  let out;
99
39
  try {
@@ -109,11 +49,7 @@ export function liveProseFiles(root) {
109
49
  export function declaredVersion(root) {
110
50
  let raw;
111
51
  try {
112
- // From the INDEX, like the prose it is compared against. Reading the version from the
113
- // worktree while reading carriers from the index compares two different trees: a staged
114
- // version bump with an unchanged worktree would grade staged prose against the OLD version,
115
- // reporting drift that is not there — or, reversed, greening drift that is. Half a fix is
116
- // how this rail's own index change first shipped.
52
+ // From the index, like the prose: a version read from the worktree would grade staged prose against another tree.
117
53
  raw = execFileSync("git", ["-C", root, "show", ":package.json"], { encoding: "utf8" });
118
54
  } catch (e) {
119
55
  throw new CouldNotRun(`could not read package.json from the index: ${e.message}`);
@@ -128,7 +64,6 @@ export function declaredVersion(root) {
128
64
  return v;
129
65
  }
130
66
 
131
- /** Every current-version claim in one file's text, with the line it sits on. */
132
67
  export function claimsIn(text) {
133
68
  const found = [];
134
69
  for (const { id, re } of PATTERNS) {
@@ -152,15 +87,10 @@ export function inspect(root) {
152
87
  for (const rel of files) {
153
88
  let text;
154
89
  try {
155
- // The INDEX, not the working tree — `pack-identity` reads the same way and for the same
156
- // reason. Enumerating from the index and then reading the worktree lets a staged drift
157
- // with a reverted worktree copy report green while the commit ships the drift.
90
+ // The index, not the worktree: a reverted worktree copy would hide a staged drift the commit ships.
158
91
  text = execFileSync("git", ["-C", root, "show", `:${rel}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
159
92
  } catch (e) {
160
- // NOT `continue`. `git ls-files` just named this path, so failing to read its blob means
161
- // the rail could not examine live prose it is responsible for — and skipping it would
162
- // report GREEN over a file never looked at. That is this session's defect class exactly,
163
- // and it was sitting inside the rail built against it. Could-not-run, never a green.
93
+ // Not `continue`: skipping a file git just listed would report green over prose never read.
164
94
  throw new CouldNotRun(`could not read ${rel} from the index: ${e.message}`);
165
95
  }
166
96
  for (const c of claimsIn(text)) {
@@ -200,12 +130,7 @@ export function main(argv, stdout = process.stdout, stderr = process.stderr) {
200
130
  return 0;
201
131
  }
202
132
 
203
- // The entry guard, in the ONE form `cli/rule-carriers.mjs` designates after this repository got it
204
- // wrong twice. `file://${argv[1]}` is NOT that form: `import.meta.url` percent-encodes, this working
205
- // copy lives under a path with spaces, and the comparison failed — so the tool exited 0 having run
206
- // nothing. **A green that is the tool never starting**, shipped inside the rail built against exactly
207
- // that. The realpath fallback covers the symlink case an npm `bin` produces, in a `try` because a
208
- // missing path must answer no rather than throw.
133
+ // URLs on both sides, since `import.meta.url` percent-encodes; the realpath covers a symlinked npm `bin`.
209
134
  function isMain() {
210
135
  const invoked = process.argv[1];
211
136
  if (!invoked) return false;
@@ -217,8 +142,5 @@ function isMain() {
217
142
  }
218
143
  }
219
144
 
220
- // `process.exitCode` rather than `process.exit`, which `cli/control-chars.mjs:540` settled for this
221
- // repository: exiting outright can truncate a pipe that has not drained. It matters more here than
222
- // stylistically — a truncated `ok` line IS exit 0 with no output, which is the precise shape of the
223
- // false green this file's entry guard was fixed for. The same defect, one door along.
145
+ // `exitCode`, not `exit()`, so a pipe that has not drained is not cut short.
224
146
  if (isMain()) process.exitCode = main(process.argv.slice(2));
package/core/README.md CHANGED
@@ -13,7 +13,7 @@ loads on demand.
13
13
  | Path | What lives here |
14
14
  |---|---|
15
15
  | [`engine.md`](engine.md) | The kernel: cascade, loop, non-negotiable gates, and the map. Always loaded. |
16
- | [`operating/`](operating/) | The doctrine, one concern per file: loop · autonomy · verification · memory · evolution · safety. |
16
+ | [`operating/`](operating/) | The doctrine, one concern per file: loop · autonomy · verification · memory · evolution · safety · context · sessions. |
17
17
  | [`templates/`](templates/) | The artifacts the doctrine references: repo-card · task · handoff · proposal · memory-entry. |
18
18
  | [`personas/`](personas/) | Roles an agent takes, as context firewalls — each with a `tools:` allow-list. |
19
19
  | [`skills/`](skills/) | Progressive-disclosure procedures in `SKILL.md` form. |
package/core/engine.md CHANGED
@@ -1,12 +1,7 @@
1
1
  # Portulan engine
2
2
 
3
3
  > The always-loaded kernel: the invariants every agent carries, and the map to the doctrine behind
4
- > them. Kept small on purpose — everything here is load-bearing; everything else loads on demand. This
5
- > file is the core's source contribution to the always-loaded layer; `portulan vendor --host` inlines it
6
- > verbatim into a vendored `AGENTS.md` beside the workspace's own layer, for a host that cannot install
7
- > the plugin. The **pack** layer is named in that file rather than composed into it — a pack resolves
8
- > from a feed at a pinned version and **vendoring resolves nothing**, which is unchanged. Pack-cache
9
- > discovery landed at milestone 7: the CLI reads the host's record, a vendored file cannot.
4
+ > them. Kept small on purpose — everything here is load-bearing; everything else loads on demand.
10
5
 
11
6
  ## Resolution cascade
12
7
 
@@ -38,10 +33,9 @@ scale down. → `operating/loop.md`
38
33
 
39
34
  ## The map
40
35
 
41
- - `operating/` — the doctrine: loop · autonomy · verification · memory · evolution · safety.
36
+ - `operating/` — the doctrine: loop · autonomy · verification · memory · evolution · safety · context · sessions.
42
37
  - `templates/` — the artifacts: repo-card · task · handoff · proposal · memory-entry.
43
38
  - `personas/` — roles as context firewalls, each with a `tools:` allow-list.
44
39
  - `skills/` — progressive-disclosure procedures; a skill must enforce, measure, or earn its tokens.
45
40
 
46
- Every rule here carries its rationale and provenance in the doc it links to. Nothing in this kernel is
47
- prose for its own sake: when a line stops being load-bearing, it moves out of the kernel.
41
+ Every rule here carries its rationale and provenance in the doc it links to.
@@ -54,7 +54,7 @@ else safe.)_
54
54
  by changing the rule itself through the evolution gate — never by runtime approval.
55
55
 
56
56
  That distinction is the whole reason the fourth tier exists rather than being folded into Gated, and it
57
- is not academic: a compiler with three tiers must file *"no agent edits the constitution"* under Gated,
57
+ is not academic: a compiler with three tiers must file *"no session certifies its own checkpoint"* under Gated,
58
58
  and Gated compiles to a prompt. *Never* would become *unless somebody clicks yes* — a difference no
59
59
  reader of the gate map would see, produced entirely by the vocabulary being one word short. _(Found by
60
60
  building the compiler: milestone 4 needed a fourth class before it had a schema, and the workspace layer
@@ -0,0 +1,258 @@
1
+ # Context
2
+
3
+ > Core doctrine — loaded on demand, which on this page's own terms is the on-read tier. What a host
4
+ > loads into every context is paid for again by every fresh context: each session, subagent, checkpoint
5
+ > and persona. So guidance sits in load tiers, the always tier is budgeted like memory, and a demotion
6
+ > is measured before and after. `loop.md` manages the window while a task runs; this page governs what
7
+ > the host loads into it, and when. _(Proposal
8
+ > [`0036`](../../.portulan/proposals/0036-what-a-host-loads-into-every-context-is-budgeted.md).)_
9
+
10
+ ## Four load tiers
11
+
12
+ **Every piece of guidance sits in a load tier, and belongs in the latest tier that still reaches the
13
+ agent in time.** There are four:
14
+
15
+ - **Always**: loaded into every context, which covers project instruction files and their imports,
16
+ unscoped rules, and skill and agent descriptions.
17
+ - **On-path**: loaded when the agent first touches a matching path, which covers nested instruction files
18
+ and path-scoped rules.
19
+ - **On-invoke**: loaded when a skill, persona or ritual runs.
20
+ - **On-read**: loaded when the agent opens the file.
21
+
22
+ The always tier is paid for by every context, whether or not its task needs what the tier holds; a later
23
+ tier is paid for only by the contexts that reach it. *In time* is the limit: guidance that arrives after
24
+ the moment it applies has not reached the agent.
25
+
26
+ A workspace sets its own guidance in these tiers in `slots.context`, since Workspace Definition 2.10: one
27
+ unit per file, each declaring its tier, and `compile` emits each unit in its host's form of that tier.
28
+ _(See [`../../spec/slots.md`](../../spec/slots.md).)_
29
+
30
+ ## One level of index
31
+
32
+ **One level of index only**: an always-tier line may point at content, and a pointer may not point at
33
+ another pointer. A **pointer** is a line the agent reads to decide whether to open something; a file a
34
+ procedure always reads is content loaded in that procedure's tier, not a pointer. So the kernel's map,
35
+ whose lines point at directories of content, is one level, and so is the boot's path to a memory record:
36
+ the boot always reads the memory index, and each index line is the one pointer to its record.
37
+
38
+ _(Motivated, not established, by arXiv 2607.17598, *Is Progressive Disclosure All You Need for
39
+ Long-Context Agents?* (He, Zhao, Wang and Chen, 2026), whose abstract finds that a second, deeper routing
40
+ level never helps and sometimes breaks accuracy. It measured long-document question answering, not
41
+ instruction files.)_
42
+
43
+ ## The always tier is budgeted in tokens
44
+
45
+ **The budget is declared in the manifest and never defaulted**, as `context.always.budget.tokens`. A
46
+ breach is repaired by **demotion** to a later tier, by **merge** or by **retirement**, and never by
47
+ raising the budget in the change that breached it. The shape is memory's on purpose, its limit included:
48
+ a budget is a rail, not an aim, and the no-raise half is a rule no checker establishes, for the reason
49
+ [`memory.md`](memory.md) gives.
50
+
51
+ **Bytes per token are declared too**, as `context.ratio`: measured exactly on demand, by asking the host
52
+ for the true count, and recorded with the host whose exact count calibrated it. The exact measurement
53
+ never runs inside a recipe, because no verify recipe may make a network call, and a ratio that moved
54
+ between runs would make one tree red on one run and green on the next. The keys are argued in
55
+ [`../../spec/slots.md`](../../spec/slots.md).
56
+
57
+ **A report by default, a rail by declaration.** A workspace is failed only against a budget it declared.
58
+
59
+ ## Portulan budgets its own contribution
60
+
61
+ **Portulan budgets its own contribution by default, because it owns it.** That is the kernel, the boot
62
+ skill and the engine half of the boot read-set, skill and persona prompts, hook-injected text, and the
63
+ vendored `AGENTS.md` skeleton. The default is a rail in this repository, not a budget in any adopter's
64
+ manifest, so *never defaulted* holds. This is what lets every install benefit on upgrade without
65
+ configuring anything.
66
+
67
+ **The kernel, [`../engine.md`](../engine.md), is the core's source contribution to the always-loaded
68
+ layer**; `portulan vendor --host` inlines it verbatim into a vendored `AGENTS.md` beside the workspace's
69
+ own layer, for a host that cannot install the plugin. The **pack** layer is named in that file rather
70
+ than composed into it — a pack resolves from a feed at a pinned version and **vendoring resolves
71
+ nothing**, which is unchanged. Pack-cache discovery landed at milestone 7: the CLI reads the host's
72
+ record, a vendored file cannot. Nothing in this kernel is prose for its own sake: when a line stops being
73
+ load-bearing, it moves out of the kernel.
74
+
75
+ ## A fresh context is priced where it is created
76
+
77
+ Whatever spawns one (a checkpoint, a persona, a ritual) states what it loads, and the compiled agent
78
+ carries that. **Reviewers keep the conventions they grade against**: the remedy for an expensive always
79
+ tier is a smaller always tier, never a blind reviewer.
80
+
81
+ ## No optimisation may lower context or code quality
82
+
83
+ Prefer moving content to a later tier over deleting it, keep one source per fact, and measure before and
84
+ after. **A demotion states both figures.**
85
+
86
+ **What this does not ask for**: a defaulted workspace rail, lossy compression, a codebase vector index,
87
+ switching model or effort mid-session, output-shortening tricks, or any change to a host. Portulan
88
+ compiles to what hosts load and does not change how they load it.
89
+
90
+ ## Every request pays for what the session has read
91
+
92
+ Each tool call ends a request, and the next request sends the whole context again: the host's own prefix,
93
+ the always tier, and everything read and printed so far. The prompt cache makes the repeat cheaper, not
94
+ free. In Claude Code 2.1.281 the main conversation's cache lives an hour on a subscription within its
95
+ usage limits and five minutes on an API key or a cloud provider, a subagent's five minutes unless set
96
+ (its settings `promptCacheTtl` and `subagentPromptCacheTtl`, read in its program text on 2026-09-24).
97
+ So what a session adds, it pays for on every request after, and a task done in fewer requests re-sends
98
+ less. `compile` writes the lead of each rule below onto every boot card, Portulan's and each consumer's:
99
+
100
+ - **Send independent tool calls in one request.** Reads, searches and commands whose inputs do not wait
101
+ on each other's output cost one request together and one each apart, and each request re-sends the
102
+ context.
103
+ - **Read a file once; reuse what your context holds.** A second read of an unchanged file adds a second
104
+ copy, paid for on every later request. After an Edit, do not read the file back to check it: the host
105
+ refuses an edit it cannot apply, and says so.
106
+ - **Open a file only when the task needs it, and one of 300 lines or more only where it does:
107
+ `node <plugin root>/cli/symbols.mjs <file>` outlines it, and `<file>#<heading>` prints one section.**
108
+ The outline gives each part's lines, and a Markdown heading's anchor and size; it costs a line a part
109
+ where a doctrine page or an instruction file runs to thousands of tokens and a task needs one section.
110
+ A link to a heading names its section, so `<file>#<anchor>` reads what the link points at without an
111
+ outline. A shorter file the task changes is read whole, as below. Read a section you will edit with the
112
+ Read tool's `offset` and `limit`, which an Edit may follow. `<plugin root>` is where Portulan is
113
+ installed, the directory whose `core/engine.md` the boot reads; in Portulan's own repository it is the
114
+ repository, and `compile` writes the command as run from there.
115
+ - **Ask for only the output you need: a range, a count, the failures.** Output enters the context as a
116
+ read does. A passing run needs its summary and a failing one the failing check and the fact to act on,
117
+ as [`loop.md`](loop.md)'s compacted error does; `git diff --stat`, `grep -c` and a line range ask for
118
+ less than a whole log, file or tree.
119
+ - **The prompt cache lasts five minutes or an hour: a longer pause, or a fresh subagent where one could
120
+ be resumed, writes the whole context again.** A subagent's context is its own and starts with the
121
+ host's prefix, so start one only where the reads it keeps out of this context cost more than that
122
+ prefix, and resume one rather than start another; [`sessions.md`](sessions.md) holds both, and the
123
+ lifetime a workspace declares. The cost table in the same program text prices a cache write above
124
+ sending the tokens fresh and a cache read below it.
125
+ - **Code should speak for itself: a comment is paid for on every read of its file, so add one only when
126
+ truly needed, concise, meaningful and valuable (less is more), and keep a change's history in its
127
+ commit message.** Claude Code 2.1.281's system prompt tells a model to match the surrounding code's
128
+ comment density, so what a file carries, the next change copies. Truly needed is what no name or
129
+ structure can show, in a line: a host's behaviour at its version, an ordering or a safety constraint the
130
+ code does not make obvious, a limit of the algorithm. A comment never carries a date, a proposal or
131
+ milestone number, a review round or a reviewer's credit, a pull request or issue number, or a narration
132
+ of what the code does; where the code needs one to be understood, rename or extract instead.
133
+ `node <plugin root>/cli/comments.mjs` counts the comment lines that record a change's history, in code
134
+ and in a Markdown file's HTML comments other than the lines a tool reads, and a workspace's `comments`
135
+ recipe holds that count at a limit that only falls.
136
+
137
+ ## Code is read by symbol
138
+
139
+ A session outlines a code file of 300 lines or more before it opens it, then reads the spans it needs: in
140
+ Claude Code, the Read tool with `offset` at a span's first line and `limit` its length. **A file under 300
141
+ lines that the task changes is read whole**, since most of it is context for the change and the saving is
142
+ smallest there, and so is any file whose outline cannot say where a change goes. A span read the host does
143
+ not cut is a full read of those lines, so an Edit may follow it: in Claude Code 2.1.281 a Read is marked
144
+ partial, and an Edit or a Write after it refused, only when the Read's token cap, 25,000 by default, cut it
145
+ (read in its program text on 2026-09-24). [`cli/symbols.mjs`](../../cli/symbols.mjs) prints the outline,
146
+ one line per symbol, and `--find <name>` locates a definition; it outlines Markdown by its headings, with
147
+ each section's anchor and size, the same way. It prints from the file as it is, so no map is committed to
148
+ go stale. Nothing checks that a session outlined first; what the rule saves is measured by rerunning a
149
+ task, never by a recipe, which may not read the host's usage records.
150
+
151
+ ## A local model runs without the host floor
152
+
153
+ A model served on the machine itself costs nothing per token, so `0038`'s dollar arithmetic gives it no
154
+ restart threshold. What it does cost is time to prefill, memory for each context it holds, and recall that
155
+ still sags in a long context. And every turn it takes is a turn the parent session does not send again on
156
+ each request after. So a local model is called as a context firewall, and four rules hold it:
157
+
158
+ 1. **A local worker runs without the host floor (`omitClaudeMd`) and under a hard input-byte ceiling.** It
159
+ is given one bounded input: a symbol span from [`cli/symbols.mjs`](../../cli/symbols.mjs), a file under
160
+ 300 lines, a diff, or a log to compress, and never the parent session's instruction files. A prompt over
161
+ the ceiling is refused before a socket opens. _Why: a host floor of 44k to 85k tokens is prefilled into
162
+ a local model's cache on every cold context, where `0039` measured prompts of about 400 to 470 tokens
163
+ prefilling in 0.85 to 1.88 seconds._
164
+ 2. **Thinking effort is set explicitly at invocation**: low or off for a mechanical transform, bounded for
165
+ prose or review, never left to the chat template's default. _Why: one template `0039` read sets
166
+ reasoning effort to `xhigh` unless thinking is switched off, and under a 1,500-token cap that model
167
+ returned no answer on any of three tasks, every token spent thinking; at low effort it passed both
168
+ tasks it was rerun on._
169
+ 3. **A local edit is kept only on its verify command's exit `0`.** The runner applies the edit and runs the
170
+ command. On exit `0` one receipt line is all that enters the parent context (`0038` rule 5). On exit `1`
171
+ after its retries, or at once on any other exit, which is no verdict, it restores the file's original
172
+ bytes, and it does the same on SIGINT, SIGTERM and SIGHUP. **This is a rollback, not an atomic write**:
173
+ the verify needs the edited file in place, so any other signal that ends the runner, SIGKILL and SIGQUIT
174
+ among them, or a power loss between the edit and its verdict leaves the edit, and a transform with no
175
+ receipt is unverified. _Why not atomic: a scratch worktree would judge the last commit plus the edit
176
+ rather than the working copy plus the edit, would lack the untracked files a verify reads, and moving
177
+ the edit back after exit `0` would not be atomic either (`0039` ruling 4)._
178
+ 4. **Loopback is a rail, and routing is earned.** A local runner refuses an endpoint outside loopback
179
+ (`127.0.0.1`, `localhost`, `[::1]`) unless leaving it is proposed and approved. A screen grades an
180
+ installed model once per task against Portulan's own predicates, from
181
+ [`cli/comments.mjs`](../../cli/comments.mjs), [`cli/doctor.mjs`](../../cli/doctor.mjs) and
182
+ [`cli/skill-goldens.mjs`](../../cli/skill-goldens.mjs), and a model that fails it is not routed. Passing
183
+ it earns nothing. A role is routed to a local model by an A/B record under `0038` rule 4: the local model
184
+ as routed against the host's model on the same tasks, `k` runs per cell, captured once and railed to its
185
+ register in `evals/ab/`. _Why: an endpoint off the machine sends workspace context away under a local
186
+ tool's name, and the screen takes one sample per task with no baseline arm, while a cheaper model for a
187
+ role is earned by an A/B run, never assumed (`0039` ruling 5)._
188
+
189
+ **What the runner holds today.** The [`tools/local-llm`](../../packs/tools/local-llm/README.md) pack's
190
+ runner holds rules 1 to 3 and rule 4's loopback rail for the calls it makes. It sends the model its own
191
+ short instructions and the input it was given, with the end of a red verify's output on a retry, and
192
+ nothing else. It refuses a prompt over its ceiling before a socket opens, names the effort on every chat
193
+ request, low unless set, requires a verify command for an edit and rolls back as rule 3 says, and refuses
194
+ a host off loopback and a redirect away from it. Which inputs it is given is the caller's: only their
195
+ bytes are checked. `--allow-remote` lifts the loopback rail on the caller's word, and the pack's
196
+ `local-llm-off-loopback` gate has no matcher, so approval to leave loopback is the human's. Its `eval`
197
+ subcommand is the screen. Its `runner-rails` recipe holds the loopback and ceiling refusals and the
198
+ rollback on a red or no-verdict verify offline, with no network call; the rollback on a signal is held by
199
+ the runner's tests. This repository composes the pack and registers its skill; the ledger reports local
200
+ receipts beside the host's tokens. `doctor` includes that branch-owned usage report by default,
201
+ with unavailable data reported as unmeasured; `--static` omits usage-record discovery for verification.
202
+ The [captured A/B](../../evals/ab/local-worker.md) supports code
203
+ at low effort (L5/H5), while shared prose is disabled because codify scored L0/H3, despite handoff's
204
+ L5/H5. Operational defaults and explicit roles require the measured model at low effort; explicit
205
+ model-only calls remain candidates. The record and register are regraded and byte-compared in CI,
206
+ with shipped routing rederived from their turns. The `local-worker` persona declares
207
+ `omitClaudeMd: true` and `effort: low`, and `compile` writes both into a workspace's binding against a
208
+ committed golden.
209
+
210
+ _(Provenance: proposal
211
+ [`0039`](../../.portulan/proposals/0039-a-local-model-runs-without-the-host-floor-and-under-a-thinking-cap.md)'s
212
+ measurement of two local models against three of this repository's tasks on 2026-10-27, and its rulings 4
213
+ and 5 of 2026-10-30.)_
214
+
215
+ ## What is machinery today, and what is not
216
+
217
+ As of Workspace Definition 2.9 a workspace can declare the budget and the ratio, and both are checked
218
+ for shape, by the schema and by `doctor`; the manifest key refuses a budget with no ratio to count it by.
219
+ **The always tier is measured and reported.** [`cli/context.mjs`](../../cli/context.mjs) measures what
220
+ Claude Code loads into every context from a workspace's `tree`; `doctor` reports that for every workspace
221
+ and fails it only over a declared budget or where a declared budget cannot be judged; the boot closes
222
+ with the same line; and this repository's `context` recipe rails Portulan's own footprint. **A boot can
223
+ read a card instead of the slots**: the `boot` unit of `slots.context` (Workspace Definition 2.10) is
224
+ compiled into every context, imports whole the files it needs, writes out the lead sentences of others,
225
+ and names the file behind each line, so the rest loads when a rule's path or the card sends a session to
226
+ it. **A team's own instruction file can be split the same way, section by section**: a line
227
+ `<!-- portulan: on-read -->` under a heading of `CLAUDE.md` or `.claude/CLAUDE.md` is the team's word that
228
+ the section may leave every context, and `portulan upgrade` moves it, byte for byte and proved to
229
+ reassemble, into an on-read unit whose index line names the heading and its size in whole KB. `doctor`, the
230
+ boot and `init` offer it where the file is over `0036`'s 8,000-token offer floor, and `doctor` and the boot
231
+ wherever a declared budget is breached, naming there the command that splits while `upgrade` will not run;
232
+ a section is never moved for its size alone. Claude Code 2.1.281 drops a comment standing on its own lines
233
+ before it loads an instruction file or rule (read in its program text on 2026-09-24), so the mark and the
234
+ marker left behind cost nothing. This repository's own card holds its boot under the 7,500-token line of
235
+ 2026-09-23, railed at today's figure plus 2% by the same recipe. A pointer's repository is not measured,
236
+ because a pointer declares no tree. **A persona can declare what its context costs**: `model`, `effort`,
237
+ `omitClaudeMd` and `cacheLifetime`, refused when malformed and never defaulted, which `compile` writes into
238
+ the hand-written binding of a persona a composed pack ships and byte-compares under `--check`, printing
239
+ each field a host cannot express, with the persona and the host, as its degradation report for personas.
240
+ Claude Code 2.1.317 takes all four on an agent, and loads `agents/` only from a plugin root (both read
241
+ in its program text on 2026-11-03), so in a tree that is not a plugin the compiled block is loaded by
242
+ nothing, and the degradation report says so; in one that is, only while the plugin is installed and
243
+ enabled, which `compile` cannot see. That stops short of the row: `compile` writes no
244
+ `.claude/agents/`, where a repository that is not a plugin loads its agents, and no binding where none
245
+ exists; a core persona's binding in this plugin's `agents/` is not compiled; and no host has been shown
246
+ honouring a compiled field. **Still to land**: the
247
+ exact mode that calibrates a ratio, the degradation report's half that says which tiers a host can
248
+ express, the Stop-gate's named demotion and the librarian pass, one change at a time, in `0036`'s order
249
+ of work and under row 12 of
250
+ [`../../docs/plan.md`](../../docs/plan.md).
251
+ Until each lands, the rule it would enforce is held by the human gate, and saying so is the point: a rule
252
+ nothing checks, presented as one that something does, is the failure this page exists to prevent.
253
+
254
+ _(Provenance: proposal `0036`'s sealed incident, in which an adopting repository's always-loaded
255
+ instruction files measured 130k tokens per fresh context, cache writes were most of the spend, and every
256
+ subagent and checkpoint paid it again, because nothing measured, budgeted or demoted what the host
257
+ loads. Host facts from the Claude Code documentation on costs, prompt caching and memory, checked
258
+ 2026-09-21.)_
@@ -67,9 +67,9 @@ rather than a story about this repository. **Work is agent-drafted and fresh-con
67
67
  moments**: the plan before building starts, the diff before it goes outward, and the exit criteria at
68
68
  the close — where done is *demonstrated, not asserted* (`verification.md`). Two conditions hold the
69
69
  cycle up and neither is optional. The unit of work carries **hard exit criteria** fixed before it
70
- begins, so the close has a standard to grade rather than an impression. And **every session ends by
71
- writing the record** (`loop.md`), so the context arriving at the next moment inherits the reasoning
72
- instead of re-deriving it — a graded moment whose grader must first reconstruct what was decided is
70
+ begins, so the close has a standard to grade rather than an impression. And **every change writes its
71
+ record** (`loop.md`), its why in the commit and any open work in a handoff, so the context arriving at
72
+ the next moment inherits the reasoning instead of re-deriving it — a graded moment whose grader must first reconstruct what was decided is
73
73
  grading its own reconstruction.
74
74
 
75
75
  The altitudes stay separate, and the separation is the part that travels. Core states that the three
@@ -26,26 +26,19 @@ The loop runs inside a finite attention window, so context is managed, not hoard
26
26
  next action — and drop the transcript. The loop is a stateless reducer over that state, so it resumes
27
27
  from the compaction without losing the thread. _(Provenance: HumanLayer ACE-FCA — the loop and
28
28
  compaction; 12-Factor Agents — stateless-reducer resumability.)_
29
- - **Handoffs record decisions and their why**, not just what changed, so the next agent — or the next
30
- window — inherits the reasoning. **Every session ends with a dated handoff** — a *session* being one
31
- bounded working stretch, however the host names it, from the moment work opens to the moment it closes.
32
- Short is fine, absent is not, and an exception is a last resort. The rule is binary on purpose: a
33
- discretionary one cannot be enforced, because no gate can judge whether skipping was warranted, and the
34
- costs are asymmetric — an unnecessary handoff costs five lines, a skipped one loses the reasoning
35
- permanently. Ceremony scales down *inside* the artifact, never by omitting it, so a gate on this checks
36
- that one exists and is dated — never its structure or its length. **Dated** means the filename leads
37
- with an ISO date: `YYYY-MM-DD-{slug}.md`. Naming the form matters, because a rule justified by being
38
- checkable has to say what the checker looks at; a date buried in prose would need parsing and would be
39
- read differently by every writer. It also makes the series sort chronologically for free. An unbroken
40
- series is what makes the record machine-readable: the session-end gate arrived in milestone 4 — it
41
- checks that a handoff dated today exists, never its length or its shape — and milestone 5 built the
42
- librarian that **reads** the series, in three ways that only an unbroken one allows. It generates an
43
- index over it, every field derived. It ages it from git. And it mines it: an incident nothing in the
44
- curated layer points back at is a candidate for the `codify` skill, because a rule whose incident
45
- cannot be traced can never be retired on evidence. The pass also **writes** to the series — a
46
- scheduled pass is a session, so it ends with a dated handoff like any other — which is why nothing
47
- regenerates that index until the pass's own record is on disk. _(Provenance: Cognition. See
48
- `../templates/handoff.md`.)_
29
+ - **A change's why goes in its commit; a handoff carries only the work still open.** A commit's subject
30
+ says what changed and its body says why, in a few lines: the reasoning the next agent cannot
31
+ reconstruct from the diff, kept beside the diff. A session (one bounded working stretch, however the
32
+ host names it) that ends with work not committed and pushed ends with a dated handoff: where things
33
+ stand, the open questions and the single next action, and nothing the diff or a commit already says.
34
+ **Dated** means the filename leads with an ISO date, `YYYY-MM-DD-{slug}.md`, because a rule justified by
35
+ being checkable has to say what the checker looks at, and the series then sorts chronologically for
36
+ free. The session-end gate (milestone 4) checks exactly that: while the tree holds uncommitted or
37
+ unpushed work, a handoff dated today exists, never its length or its shape. The librarian (milestone 5)
38
+ reads the series: it indexes it, ages it from git, and mines it, since an incident nothing in the
39
+ curated layer points back at is a candidate for the `codify` skill.
40
+ _(Provenance: Cognition — handoffs record decisions and their rationale; Git's own SubmittingPatches —
41
+ a commit's body explains the problem and justifies the change. See `../templates/handoff.md`.)_
49
42
  - **Subagents are context firewalls.** Fan work out to a persona with its own window and return only
50
43
  the conclusion; the parent's budget stays clean. Read in parallel, write from one place. _(See
51
44
  `../personas/`. Provenance: HumanLayer; Cognition — read-parallel / write-isolated.)_
@@ -46,6 +46,15 @@ which is worse than no check at all. So the breach is a rail and the *remedy* is
46
46
  gate holds. Written down rather than implied, because a rule nothing checks that is presented as one
47
47
  that does is the failure this page would otherwise be demonstrating.
48
48
 
49
+ **The cap can bind forward only** (Workspace Definition 2.11): with `memory.store.budget.cutoff`
50
+ declared beside it, it binds the records dated after the cutoff, and one dated on or before it is
51
+ reported, never railed, in proposal `0037`'s shape for a handoff. A record's date is its `**dated:**`
52
+ line, the day its text last changed, because a verify rail may not read git; an edit re-dates it, so
53
+ an old record meets the cap in the change that next rewrites it, while compression is that change's
54
+ to do. A record whose date cannot be read is refused. The cutoff never moves earlier, and moves later
55
+ only with a cap that tightens: like the raise above, a rule the human gate holds, because a moved
56
+ cutoff unbinds what the cap bound and only history shows the move.
57
+
49
58
  ## One fact per memory, with provenance
50
59
 
51
60
  A memory holds one fact and states where it came from. A rule links to the **incident** that created
@@ -62,8 +71,8 @@ curated context improves it, and the difference is measurable. So capture is age
62
71
  consolidation into the curated layer is a reviewed step. _(Provenance: ETH Zurich, arXiv:2602.11988 —
63
72
  generated context hurts, curated helps. The scheduled librarian arrived in milestone 5: a pass that
64
73
  regenerates each generated index, ages every record from git, nags a sealed stamp's owner, chases
65
- undecided proposals, drafts demotions, and **runs consolidation** — filed as a pull request nobody has
66
- to remember to open. What consolidation means on a schedule is bounded rather than claimed: the pass
74
+ undecided proposals, drafts demotions, and **runs consolidation** — its report filed with a pull request
75
+ nobody has to remember to open whenever the pass changed the tree. What consolidation means on a schedule is bounded rather than claimed: the pass
67
76
  reports how close each budget is to its rail, and surfaces records citing one incident as a
68
77
  **question**, because `../skills/consolidate/SKILL.md` merges records that are one *mechanism* and one
69
78
  incident routinely teaches several. Its steps 3 and 4 — surfacing contradictions and compressing what
@@ -145,7 +154,7 @@ where a reader already knows to look is stated at the narrowest carrier.
145
154
  was put to him twice and answered two different ways. The record of both, and of the third option that
146
155
  reconciles them, is
147
156
  [#98](https://github.com/sleepy-panda-srl/portulan/issues/98) and
148
- [`docs/milestones/m06.md`](../../docs/milestones/m06.md). Neither row needed a criterion edit to carry
157
+ [`docs/milestones/m06.md`](https://github.com/sleepy-panda-srl/portulan/blob/8a33f9b/docs/milestones/m06.md). Neither row needed a criterion edit to carry
149
158
  this: both already demanded their half. Said plainly because the
150
159
  alternative is a page describing an enforcement that does not exist — the failure a workspace's
151
160
  definition of done exists to catch (`.portulan/dod.md` condition 4, in this repository): a document may