@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
package/README.md CHANGED
@@ -20,7 +20,7 @@ workspaces and premium packs delivered as private-marketplace plugins.
20
20
 
21
21
  ## Status
22
22
 
23
- **Current release: `0.2.0`** — recorded in [`CHANGELOG.md`](CHANGELOG.md), which is also the honest
23
+ **Current release: `0.4.0`** — recorded in [`CHANGELOG.md`](CHANGELOG.md), which is also the honest
24
24
  account of what the release does *not* contain.
25
25
 
26
26
  This is pre-release software. The leading `0.` is meant literally: nothing here is stable yet, and
@@ -100,7 +100,13 @@ node cli/doctor.mjs examples
100
100
 
101
101
  To adopt Portulan in your own repository, `portulan init` interviews you, scans the codebase and
102
102
  drafts a workspace; `portulan doctor` validates it; `portulan compile` turns your gate policy into
103
- host enforcement.
103
+ host enforcement. At a terminal, an in-repo draft offers an optional always-tier budget before writing.
104
+ The offer covers current and projected guidance in both supported formats at your explicit ratio.
105
+ Declining or an initially unavailable projection leaves it undeclared. After final confirmation,
106
+ init checks the accepted cap against fresh project inputs. An insufficient cap, a newly unavailable
107
+ measurement or a changed effective handoff cutoff stops before writing and asks you to run init again.
108
+ The cutoff uses eligible dated Markdown handoffs; unrelated filenames cannot advance it. Invalid
109
+ selected dates or file types, or an uninspectable series, stop record-cap adoption before writing.
104
110
 
105
111
  ## Command-line interface
106
112
 
@@ -119,7 +125,7 @@ commit, refusing any packed file that differs from its source.
119
125
  |---|---|
120
126
  | [`init`](cli/init.mjs) | Interview plus codebase scan, producing a drafted workspace |
121
127
  | [`doctor`](cli/doctor.mjs) | Validate a workspace against the Workspace Definition |
122
- | [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement |
128
+ | [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement, guidance into host tiers, and declared persona fields into existing plugin or project agent bindings |
123
129
  | [`vendor`](cli/vendor.mjs) | Materialise a workspace where it is needed, in either direction |
124
130
  | [`index`](cli/index.mjs) | Rebuild the declared indexes over memory, handoffs and personas; print changelog fragments for a release |
125
131
  | [`upgrade`](cli/upgrade.mjs) | Migrate a workspace across Workspace Definition versions |
@@ -138,7 +144,7 @@ are not subcommands at all. [`cli/README.md`](cli/README.md), rendered from the
138
144
  | `cli/` | [The Node CLI](cli/) described above, plus its tests and the compiled-hook runners |
139
145
  | `packs/` | [Composable packs](packs/) — `stacks/`, `tools/`, `rituals/` |
140
146
  | `plugin/` | [The Claude Code adapter](plugin/): the `/portulan` boot skill |
141
- | `agents/` | [The three personas bound to this host's tools](agents/) — `implementer`, `reviewer`, `librarian`. A platform-fixed location; the doctrine they bind stays in [`core/personas/`](core/personas/) |
147
+ | `agents/` | [Host bindings](agents/) for the core implementer, reviewer and librarian plus the composed supervisor and local-worker. Their charters stay in core or the contributing pack; ordinary projects bind roles under `.claude/agents/` |
142
148
  | `examples/` | [A fictional, public demo workspace](examples/) — two products, read end to end |
143
149
  | `docs/` | [`vision.md`](docs/vision.md) (the constitution), [`plan.md`](docs/plan.md) (the milestone map) and [`milestones/`](docs/milestones/) — one file per milestone from 12 on, holding amendment arguments, session notes and close evidence (milestones 0 to 11 read with `git show 8a33f9b:docs/milestones/mNN.md`) |
144
150
  | `evals/` | [The eval corpora](evals/) — golden tasks for the compiled gates and for the core skills — plus the review-loop register, the A/B corpus with its arms, graders and recorded baseline, and [`evals/releases/`](evals/releases/), the eval result each release from `0.1.3` onward carries. **No count is written here**; `git ls-files evals` derives it. _(This cell has now recorded going stale twice, and this is the second repair: it said the **graders** and the **baseline run** were *still to arrive* after both landed on 2026-08-31. The sentence that expires is always the one naming what is next, so this cell no longer names one — what is unbuilt lives in [`evals/README.md`](evals/README.md)'s own *What is NOT built yet*, which is the single carrier for it and is checked by nothing, exactly as this cell was.)_ |
@@ -173,3 +179,8 @@ channels in the order to try them, what is in scope, and what response you can a
173
179
  ## License
174
180
 
175
181
  [Apache-2.0](LICENSE) © 2026 Sleepy Panda SRL. See [`NOTICE`](NOTICE).
182
+
183
+ `portulan doctor --count-tokens MODEL_ID .portulan` optionally sends the measured project always corpus
184
+ to Anthropic for a provider token estimate, using `ANTHROPIC_API_KEY` from the environment. The
185
+ [counting boundary](core/operating/context.md#the-always-tier-is-budgeted-in-tokens) explains exclusions,
186
+ manual ratio assessment and why the estimate is not actual complete host usage. Verification remains offline.
package/cli/README.md CHANGED
@@ -22,7 +22,7 @@ in [`../.portulan/verify/`](../.portulan/verify/), and its operator tooling in
22
22
  |---|---|
23
23
  | [`portulan.mjs`](portulan.mjs) | Portulan — the one entry point the `npx` package exposes. |
24
24
  | [`init.mjs`](init.mjs) | `portulan init`: draft a workspace for a repository that has none. |
25
- | [`doctor.mjs`](doctor.mjs) | `portulan doctor`: validate a workspace against the Workspace Definition. |
25
+ | [`doctor.mjs`](doctor.mjs) | `portulan doctor`: validate a workspace and report branch-owned host tokens beside local receipts; --static skips usage records. |
26
26
  | [`compile.mjs`](compile.mjs) | `portulan compile`: compile a workspace's gate policy into host enforcement. |
27
27
  | [`vendor.mjs`](vendor.mjs) | `portulan vendor`: materialise a workspace where it is needed: into a host, or between residences. |
28
28
  | [`index.mjs`](index.mjs) | `portulan index`: regenerate the memory, handoff and scope indexes. |
@@ -37,8 +37,8 @@ Run by the hooks `compile` writes into a host's settings, never by a person.
37
37
  | File | What it is |
38
38
  |---|---|
39
39
  | [`gate.mjs`](gate.mjs) | The PreToolUse gate runner — the *explanation* half of the enforcement compiler. |
40
- | [`stop-gate.mjs`](stop-gate.mjs) | The Stop-gate runner — "done" is not a thing an agent may simply declare. |
41
- | [`advisory.mjs`](advisory.mjs) | The restart advisory — one line, once, where the agent is, and the same figure for the human. |
40
+ | [`stop-gate.mjs`](stop-gate.mjs) | Stop-gate: bounded recipe and work checks. Hook failures need a verdict, since a crashed hook fails open. |
41
+ | [`advisory.mjs`](advisory.mjs) | Restart comparison with explicit horizon and resume expense; unknown recovery cost stays conditional. |
42
42
 
43
43
  ### Tools and modules
44
44
 
@@ -49,34 +49,54 @@ Run as `node cli/<file>.mjs`, or imported by the files above.
49
49
  | [`ab-grade.mjs`](ab-grade.mjs) | The A/B graders — what an arm's tree says it did, and the discrimination that proves a grader read it. Not shipped. |
50
50
  | [`ab-run.mjs`](ab-run.mjs) | The A/B runner: spawns the agent turns of both arms and records the baseline they measure. Not shipped. |
51
51
  | [`ab.mjs`](ab.mjs) | The A/B arm builder — what "Portulan on" is made of, built rather than described. Not shipped. |
52
+ | [`acquire-personas.mjs`](acquire-personas.mjs) | Acquire the frozen persona comparisons through native children; never run by a recipe. Not shipped. |
53
+ | [`cache-risk.mjs`](cache-risk.mjs) | Potential cache risks from project files; host activation is not inferred. |
54
+ | [`cache-stability.mjs`](cache-stability.mjs) | Prefix baseline. Not shipped. |
52
55
  | [`comments.mjs`](comments.mjs) | `comments` — the comments in a tree that record a change's history instead of what the code cannot show. |
53
- | [`context.mjs`](context.mjs) | What a context loads, measured: what a boot reads in full, and what the host loads into every context. |
56
+ | [`context.mjs`](context.mjs) | Measure boot reads and the project's always tier. |
54
57
  | [`control-chars.mjs`](control-chars.mjs) | `control-chars` — the rail on bytes a reader cannot see. |
55
58
  | [`discover.mjs`](discover.mjs) | Host plugin-cache discovery: reads a host's installed-plugin record to resolve a pointer and find pack roots. |
56
59
  | [`drills.mjs`](drills.mjs) | The forced-red drill harness — every rail broken on purpose, and required to fire. |
57
60
  | [`eval-bundle.mjs`](eval-bundle.mjs) | Cut a named-recipient evaluation bundle of Portulan from a commit. Not shipped. |
58
61
  | [`finish.mjs`](finish.mjs) | The finishing command — one call closes a change: its recipes, its changelog fragment, its commit, its push. |
59
62
  | [`form.mjs`](form.mjs) | `form` — which form a consumer's records and boot are in, and the files that move them to the new one. |
63
+ | [`frontmatter.mjs`](frontmatter.mjs) | Flat frontmatter for plugin components and host bindings. |
60
64
  | [`fuzz-shell.mjs`](fuzz-shell.mjs) | Grammar-aware fuzzing over this repository's two shell segmenters. |
61
65
  | [`goldens.mjs`](goldens.mjs) | Grade this workspace's compiled gates against a corpus of adversarial fixtures. |
66
+ | [`guidance-files.mjs`](guidance-files.mjs) | Nested instruction ownership and atomic guidance publication. |
67
+ | [`handoff-policy.mjs`](handoff-policy.mjs) | Handoff caps and windows. |
68
+ | [`html-entities.mjs`](html-entities.mjs) | HTML5 named references (semicolon forms): https://html.spec.whatwg.org/entities.json Generated from Python 3 html.entities.html5. |
69
+ | [`import-files.mjs`](import-files.mjs) | Optional project import probes. |
70
+ | [`init-preview.mjs`](init-preview.mjs) | Project guidance preview, shared by init and instruction migration. |
62
71
  | [`inside.mjs`](inside.mjs) | Is one path inside another? One predicate, one file, no dependencies. |
72
+ | [`instructions-journal.mjs`](instructions-journal.mjs) | One CLI split/join, bounded to its original sources. The planner proves the saved operation again. Immutable lease children serialize recovery without deleting a possibly live writer's lock. Terminal proof outlives rollback authority; unproved initialization remains for human review. |
63
73
  | [`instructions.mjs`](instructions.mjs) | `instructions` — the sections a team marks in its own instruction file, moved to on-read units and proved. |
64
74
  | [`ledger.mjs`](ledger.mjs) | What a change spends, measured from the host's own usage records. |
65
75
  | [`librarian.mjs`](librarian.mjs) | `librarian` — the scheduled pass over the curated layer: reindex, staleness, nags, demotion drafts. |
76
+ | [`local-routing.mjs`](local-routing.mjs) | Verify the shipped operational local roles against their immutable acquired A/B record. No model. Not shipped. |
66
77
  | [`manifest.mjs`](manifest.mjs) | The two facts every tool here needs out of `package.json`, read once and in a module that imports nothing of ours. |
67
78
  | [`mutants.mjs`](mutants.mjs) | Mutation-test this repository's two gate matchers against the corpus that claims to cover them. |
79
+ | [`normalize-personas.mjs`](normalize-personas.mjs) | Publish whitelisted numeric metadata and generic answers from the completed private persona acquisition. Not shipped. |
68
80
  | [`pack-identity.mjs`](pack-identity.mjs) | Every file `npm pack` emits is byte-identical to the tracked blob it came from. |
69
81
  | [`pack-version.mjs`](pack-version.mjs) | A change to a pack's `contributes` must move that pack's `portulan.version`. |
70
82
  | [`payload.mjs`](payload.mjs) | Every `cli/*.mjs` the npm payload carries is classified, and nothing arrives in it unclassified. Not shipped. |
83
+ | [`persona-routing.mjs`](persona-routing.mjs) | Regrade the acquired core-persona records and check their shipped routing without a model. |
71
84
  | [`plugin-lint.mjs`](plugin-lint.mjs) | `plugin-lint` — the packaging validator. |
85
+ | [`recipe-output.mjs`](recipe-output.mjs) | Agent output for any recipe: one green count, complete failure streams. |
86
+ | [`recipe-runner.mjs`](recipe-runner.mjs) | Shared asynchronous recipe execution, bounded output and process-tree cleanup. |
72
87
  | [`recipe-set.mjs`](recipe-set.mjs) | The recipe set: the verify recipes a workspace yields, its own plus each composed pack's, namespaced by pack. |
73
88
  | [`release-eval.mjs`](release-eval.mjs) | Portulan — the eval result a release carries. |
74
- | [`review-meter.mjs`](review-meter.mjs) | The review-loop meter: the figures that bound this repository's review loop, derived from its reviews. |
89
+ | [`review-meter.mjs`](review-meter.mjs) | The review-loop meter: the historical figures of this repository's Copilot review loop, derived from its reviews. |
90
+ | [`ritual-contexts.mjs`](ritual-contexts.mjs) | Own native settings without inventing a gate policy. |
75
91
  | [`roster.mjs`](roster.mjs) | The cli roster: `cli/README.md`, rendered from the files it lists and never edited by hand. Not shipped. |
76
92
  | [`rule-carriers.mjs`](rule-carriers.mjs) | `rule-carriers` — the rail that keeps a reduced rule reduced. |
77
93
  | [`sessions.mjs`](sessions.mjs) | The session switches' texts: the cache-lifetime offer `init` and `upgrade` print, and the one line `doctor` reports on them. |
78
94
  | [`skill-goldens.mjs`](skill-goldens.mjs) | The core-skill golden corpus — every mandate a core skill states, bound to the live artifacts it governs, and graded. |
79
95
  | [`skills-set.mjs`](skills-set.mjs) | The registrable set: the `skills` paths a plugin manifest must declare so a composed pack's skills register. |
96
+ | [`spend-analysis.mjs`](spend-analysis.mjs) | Class indices compare stated assumptions, never bills or route quality. |
97
+ | [`spend-offer.mjs`](spend-offer.mjs) | Local evidence for init's optional spend declaration. |
98
+ | [`stop-diagnostics.mjs`](stop-diagnostics.mjs) | Bounded streaming failure excerpts for the Stop recipe relay. |
99
+ | [`stop-policy.mjs`](stop-policy.mjs) | Stop recipe deadline policy. |
80
100
  | [`symbols.mjs`](symbols.mjs) | A code file's outline, one line per symbol, and a Markdown file's, one line per heading with its size. |
81
101
  | [`telemetry.mjs`](telemetry.mjs) | The OTel emitter: the review-loop figures as OTLP/HTTP JSON, sent only when a committed config opts in. |
82
102
  | [`version-carriers.mjs`](version-carriers.mjs) | Every prose statement of the current version agrees with `package.json`. |
@@ -91,16 +111,27 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
91
111
  | [`ab-grade.test.mjs`](ab-grade.test.mjs) | Tests for `ab-grade` — the A/B graders: staging, attribution, the four scenarios, the two levels and the register. |
92
112
  | [`ab-run.test.mjs`](ab-run.test.mjs) | Tests for `ab-run` — the A/B runner: isolation, the matrix, the journal, the register and the checks behind it. |
93
113
  | [`ab.test.mjs`](ab.test.mjs) | Tests for `ab` — the A/B arm builder: its disposition table, rule-2 matcher, nonces, isolation and stop probe. |
114
+ | [`advisory-crossing.test.mjs`](advisory-crossing.test.mjs) | First recorded crossings survive delayed batches without stale Stop holds. |
115
+ | [`advisory-dedup.test.mjs`](advisory-dedup.test.mjs) | Delayed repeats cannot replace the latest distinct request. |
116
+ | [`advisory-host-trace.test.mjs`](advisory-host-trace.test.mjs) | The shipped host trace is evidence data; CI only replays its public counters. |
94
117
  | [`advisory.test.mjs`](advisory.test.mjs) | Tests for `advisory` — the restart advisory's one line with a tool result or at the prompt, and its figure in the status line. |
118
+ | [`branch-freshness.test.mjs`](branch-freshness.test.mjs) | Tests for this repository's branch freshness check and trusted workflow. |
119
+ | [`cache-inputs.test.mjs`](cache-inputs.test.mjs) | Missing cache inputs stay unpriced in reports and hooks. |
120
+ | [`cache-risk.test.mjs`](cache-risk.test.mjs) | Project cache warnings are reports; no host state or server is consulted. |
121
+ | [`cache-stability.test.mjs`](cache-stability.test.mjs) | Cache-prefix tests. |
95
122
  | [`checkout-refs.live.test.mjs`](checkout-refs.live.test.mjs) | A workflow that checks out MORE THAN ONCE names the ref of every checkout after the first. |
96
123
  | [`cli-roster.live.test.mjs`](cli-roster.live.test.mjs) | `cli/README.md` is exactly what ./roster.mjs renders from this directory, and the render keeps its shape. |
97
124
  | [`collisions.test.mjs`](collisions.test.mjs) | The collision contract: `init`, `new` and `vendor` answer each contracted state alike, and no fourth `collisions` exists. |
98
125
  | [`comments.test.mjs`](comments.test.mjs) | Tests for `comments`: each language's comments found exactly, and the four kinds of history told from the rest. |
99
126
  | [`compile.test.mjs`](compile.test.mjs) | Tests for `compile` — the enforcement compiler. |
127
+ | [`context-count.test.mjs`](context-count.test.mjs) | On-demand provider counts preserve the measured corpus and remain outside offline verification. |
128
+ | [`context-hosts.test.mjs`](context-hosts.test.mjs) | Independent project context measurements for the two guidance formats. |
100
129
  | [`context.test.mjs`](context.test.mjs) | Tests for `context` — what a boot reads and what the host loads into every context, measured. |
101
130
  | [`control-chars.test.mjs`](control-chars.test.mjs) | Tests for `control-chars` — the rail on bytes a reader cannot see. |
131
+ | [`demo-upgrade.test.mjs`](demo-upgrade.test.mjs) | The demo's pinned bundle replacement reproduces its boot reduction without changing its workspace. |
102
132
  | [`discover.test.mjs`](discover.test.mjs) | Tests for host plugin-cache discovery. |
103
- | [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` — the Workspace Definition validator. |
133
+ | [`doctor-lever.test.mjs`](doctor-lever.test.mjs) | Tests for doctor's savings lever. |
134
+ | [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` — workspace validation, branch-owned hybrid usage and static-mode isolation. |
104
135
  | [`drills.test.mjs`](drills.test.mjs) | Tests for `drills` — the forced-red drill harness: its table, perturbation guards, pair oracle, hook rails and tree choice. |
105
136
  | [`eval-bundle.test.mjs`](eval-bundle.test.mjs) | The evaluation-bundle cutter, driven on this repository AND on real fixture repositories. |
106
137
  | [`feedback.live.test.mjs`](feedback.live.test.mjs) | The sender's field map, against the real issue forms rather than against a fixture. |
@@ -112,24 +143,42 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
112
143
  | [`gate.test.mjs`](gate.test.mjs) | The PreToolUse gate runner, driven as the host drives it. |
113
144
  | [`gh-bot.test.mjs`](gh-bot.test.mjs) | The agent identity's wrapper refuses the endpoints it is not for. |
114
145
  | [`goldens.test.mjs`](goldens.test.mjs) | Tests for `goldens` — the gate-corpus rail: the exemption, both rails, every corpus refusal and the CLI. |
146
+ | [`handoff-budget.test.mjs`](handoff-budget.test.mjs) | Handoff budget tests. |
115
147
  | [`index.test.mjs`](index.test.mjs) | Tests for `index` — the memory, handoff and scope indexes, the memory budgets, and changelog fragments. |
148
+ | [`init-budget.test.mjs`](init-budget.test.mjs) | The init interview's declared always-tier choice. |
116
149
  | [`init.test.mjs`](init.test.mjs) | Tests for `init` — the onboarding subcommand that drafts a workspace for a repository that has none. |
150
+ | [`instructions-journal.test.mjs`](instructions-journal.test.mjs) | Process interruption and ownership controls for CLI instruction migration. |
117
151
  | [`instructions.test.mjs`](instructions.test.mjs) | Tests for `instructions`: the sections a team marks, moved to on-read units and proved, and the join back. |
118
152
  | [`ledger.test.mjs`](ledger.test.mjs) | Tests for `ledger` — what a change spends, from the host's own usage records. |
119
153
  | [`librarian.test.mjs`](librarian.test.mjs) | Tests for `librarian` — the scheduled pass over the curated layer. |
120
154
  | [`list-quoting.live.test.mjs`](list-quoting.live.test.mjs) | Every recipe that enumerates the tree reads the pathname git actually carries. |
155
+ | [`local-completion.test.mjs`](local-completion.test.mjs) | Local completion and fenced-code regression tests. |
156
+ | [`local-transport.test.mjs`](local-transport.test.mjs) | Local HTTP deadlines, response bounds and compatibility tests. |
157
+ | [`local-usage.test.mjs`](local-usage.test.mjs) | Usage, exhaustion and rollback tests. |
121
158
  | [`mutants.test.mjs`](mutants.test.mjs) | Tests for `mutants`, the mutation-census rail: its operator table, its imports, its corpus and its refusals. |
159
+ | [`nested-instructions.test.mjs`](nested-instructions.test.mjs) | Nested guidance generation, ownership recovery and consumer parity. |
122
160
  | [`new.test.mjs`](new.test.mjs) | Tests for `new`: scaffolds that validate, written into the user's own layer and never into `core/`. |
123
161
  | [`pack-identity.test.mjs`](pack-identity.test.mjs) | Tests for the byte-identity rail: green, both findings and could-not-run, each exercised. |
162
+ | [`pack-local-llm.test.mjs`](pack-local-llm.test.mjs) | Tests for the `tools/local-llm` pack's runner — its loopback, input-budget and rollback rails, its eval screen, dedup ranking and A/B record. |
124
163
  | [`pack-version.test.mjs`](pack-version.test.mjs) | The pack-version rail, driven on REAL git repositories rather than an injected history. |
125
164
  | [`payload.test.mjs`](payload.test.mjs) | Tests for the payload-classification rail. |
165
+ | [`persona-acquisition.test.mjs`](persona-acquisition.test.mjs) | Check native invocation and no-resampling guards. |
166
+ | [`persona-capture.test.mjs`](persona-capture.test.mjs) | Force acquired persona, routing and packaging rails red. |
167
+ | [`persona-routing.test.mjs`](persona-routing.test.mjs) | Semantic controls for the acquired core-persona routing comparisons. |
168
+ | [`persona-upgrade.test.mjs`](persona-upgrade.test.mjs) | Exercise historical persona upgrade, ownership and rollback. |
126
169
  | [`pinned-roots.live.test.mjs`](pinned-roots.live.test.mjs) | Required checks name their resolution root, and every suite whose imports reach the host's plugin record empties it. |
127
170
  | [`plugin-lint.test.mjs`](plugin-lint.test.mjs) | Tests for `plugin-lint` — the packaging validator. |
171
+ | [`policyfree-advisory.test.mjs`](policyfree-advisory.test.mjs) | Full compilation wires advice independently of gate policy. |
128
172
  | [`portulan.test.mjs`](portulan.test.mjs) | Tests for the `npx` entry point: dispatch through an injected loader, its refusals, and its version. |
173
+ | [`prompt-footprint.test.mjs`](prompt-footprint.test.mjs) | Owned prompt footprint regression checks. |
174
+ | [`recipe-output.test.mjs`](recipe-output.test.mjs) | Agent recipe output controls. |
175
+ | [`recipe-runner.test.mjs`](recipe-runner.test.mjs) | Recipe cleanup controls use isolated, test-owned process groups and late filesystem writes. |
129
176
  | [`recipe-set.live.test.mjs`](recipe-set.live.test.mjs) | The composed recipe set, against THIS repository rather than against fixtures. |
130
177
  | [`recipe-set.test.mjs`](recipe-set.test.mjs) | The recipe set — one carrier, and every reader reaches it. |
131
178
  | [`release-eval.test.mjs`](release-eval.test.mjs) | The release-eval suite, on the record layer only: `--capture` spawns every other rail, the tests that run this suite included. |
179
+ | [`restart-resume.test.mjs`](restart-resume.test.mjs) | Bounded-run restart comparisons, recovery expense, profitability and Stop refusal controls. |
132
180
  | [`review-meter.test.mjs`](review-meter.test.mjs) | Tests for `review-meter` — the review-loop meter: arithmetic, snapshot contract, register rail, window and shaping. |
181
+ | [`ritual-contexts.test.mjs`](ritual-contexts.test.mjs) | Ritual admission caps and total observed contexts have different units. |
133
182
  | [`rule-carriers.test.mjs`](rule-carriers.test.mjs) | Tests for `rule-carriers` — the rail that keeps a reduced rule reduced. |
134
183
  | [`sessions.test.mjs`](sessions.test.mjs) | `sessions` — the cache lifetime's offer, and `doctor`'s one line on the session switches. |
135
184
  | [`shadowed-pack.test.mjs`](shadowed-pack.test.mjs) | A shadowed pack is refused rather than picked. |
@@ -137,6 +186,9 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
137
186
  | [`skill-goldens.test.mjs`](skill-goldens.test.mjs) | Tests for `skill-goldens`, the core-skill golden corpus: every refusal exercised on a fixture that trips it. |
138
187
  | [`skills-set.live.test.mjs`](skills-set.live.test.mjs) | The registrable set, against THIS repository rather than against fixtures. |
139
188
  | [`skills-set.test.mjs`](skills-set.test.mjs) | The registrable set — what a plugin manifest must declare so a composed pack's skills register. |
189
+ | [`spend-analysis.test.mjs`](spend-analysis.test.mjs) | Numeric class analysis keeps ownership, recency and unknown metadata explicit. |
190
+ | [`spend-offer.test.mjs`](spend-offer.test.mjs) | Init spend offers. |
191
+ | [`stop-diagnostics.test.mjs`](stop-diagnostics.test.mjs) | Stop recipe diagnostics retain failures across noise and stream boundaries. |
140
192
  | [`stop-gate.test.mjs`](stop-gate.test.mjs) | Tests for the Stop-gate runner: its per-reason caps, the handoff date, its verdicts, the did-work signals and the tree it answers about. |
141
193
  | [`symbols.live.test.mjs`](symbols.live.test.mjs) | `symbols` against this repository: tracked code and Markdown files outline, each declaration compiles alone, and heading links land. |
142
194
  | [`symbols.test.mjs`](symbols.test.mjs) | The outline's contract, on fixtures: what each construct prints, and every refusal. |
@@ -156,4 +208,7 @@ What the tools and suites read.
156
208
  | File | What it is |
157
209
  |---|---|
158
210
  | [`eval-license.template.md`](eval-license.template.md) | Portulan Evaluation Issue (`EVAL-LICENSE.md`). Not shipped. |
211
+ | [`finish.md`](finish.md) | Finish verification boundaries |
212
+ | [`recipe-runner.md`](recipe-runner.md) | Recipe execution and cleanup |
213
+ | [`spend-offer.md`](spend-offer.md) | Init's spend offer |
159
214
  | [`fixtures/`](fixtures/) | The fixtures the suites build on; each suite names the ones it reads. Not shipped. |
package/cli/advisory.mjs CHANGED
@@ -1,31 +1,37 @@
1
1
  #!/usr/bin/env node
2
- // The restart advisory — one line, once, where the agent is, and the same figure for the human.
2
+ // Restart comparison with explicit horizon and resume expense; unknown recovery cost stays conditional.
3
3
  //
4
- // node cli/advisory.mjs tool [<figures>] a PostToolUse hook: the line, as additionalContext, once, mid-stretch
5
- // node cli/advisory.mjs prompt [<figures>] a UserPromptSubmit hook: the same line, at the next prompt, if not said yet
6
- // node cli/advisory.mjs status [<figures>] a status-line command: the figure and the request count, on every refresh
7
- // node cli/advisory.mjs stop [<figures>] a Stop hook, where a workspace declares it: the same line as a block, once
8
- //
9
- // <figures> --read <m> --write-5m <m> --write-1h <m>, all three or none, and --horizon <n>: a workspace's
10
- // `spend`, as `./compile.mjs` writes it; undeclared, the general multipliers and horizon.
11
- //
12
- // Exit 0 on every path: a 2 would erase the person's prompt at `UserPromptSubmit` and hold every turn's end at
13
- // `Stop`. What it cannot read or use, it passes over and names on stderr.
14
- //
15
- // The line rides a tool result or a prompt: a non-blocking `Stop` hook's output never reaches the model, and a
16
- // headless run's one prompt comes before any request. The transcript is written asynchronously, so the line may
17
- // come one request late, never early.
4
+ // node cli/advisory.mjs tool|prompt|status|stop [--read m --write-5m m --write-1h m] [--horizon n] [--resume-tokens cost]
5
+ // Undeclared prices/horizon use general figures; unknown resume cost permits conditional reports only.
6
+ // Tool/prompt output reaches the agent; a declared Stop holds only with explicit profitable estimates.
7
+ // Retain the first crossing in a delayed batch; Stop uses the latest estimate. Hook failures exit 0.
8
+ // Unusable figures are named on stderr.
18
9
 
19
10
  import fs from "node:fs";
20
11
  import os from "node:os";
21
12
  import path from "node:path";
22
13
  import { pathToFileURL } from "node:url";
14
+ import { createHash } from "node:crypto";
23
15
 
24
- import { HORIZON, LedgerError, SPEND_FIGURES, figureOf, foldFigures, overflowingWrite, readLine, sessionFigures } from "./ledger.mjs";
16
+ import { HORIZON, INPUT_USAGE_MASK, inputUnusable, usageOf, contextOf, LedgerError, SPEND_FIGURES, figureOf, foldFigures, foldCanonical, overflowingWrite, readLine, readTranscript, restartSaving, sessionFigures } from "./ledger.mjs";
25
17
 
26
18
  const grouped = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
27
-
28
- const STATE_VERSION = 2;
19
+ const savingWords = (n) => grouped(Number(n.toPrecision(6)));
20
+
21
+ const STATE_VERSION = 8;
22
+ const SEEN_BYTES = 1024;
23
+ const emptySeen = () => Buffer.alloc(SEEN_BYTES).toString("hex");
24
+ // Membership selects exact replay; collisions never establish identity.
25
+ function membership(bits, id, remember = false) {
26
+ const hash = createHash("sha256").update(id).digest();
27
+ let hit = true;
28
+ for (const at of [0, 4, 8]) {
29
+ const bit = hash.readUInt32LE(at) % (SEEN_BYTES * 8), mask = 1 << (bit & 7);
30
+ hit = Boolean(bits[bit >> 3] & mask) && hit;
31
+ if (remember) bits[bit >> 3] |= mask;
32
+ }
33
+ return hit;
34
+ }
29
35
 
30
36
  const CHUNK = 1 << 16;
31
37
 
@@ -97,18 +103,64 @@ function restore(file, transcriptPath, stat) {
97
103
  }
98
104
  if (kept === null || typeof kept !== "object" || kept.v !== STATE_VERSION || kept.transcript !== transcriptPath) return null;
99
105
  if (kept.ino !== stat.ino || !isCount(kept.offset) || kept.offset > stat.size || typeof kept.tail !== "string" || !wellFormed(kept.figures)) return null;
106
+ if (typeof kept.comparison !== "string" || typeof kept.seen !== "string" || kept.seen.length !== SEEN_BYTES * 2 || !/^[0-9a-f]+$/.test(kept.seen)) return null;
107
+ if (typeof kept.freshUnsafe !== "boolean" || typeof kept.latestUnsafe !== "boolean" || ![kept.freshId, kept.latestId, kept.crossingId].every(id => id === null || typeof id === "string")) return null;
108
+ if (![kept.freshInput, kept.latestInput, kept.crossingInput].every(v => v === null || (Array.isArray(v) && v.length === 5 && v.every(isCount)))) return null;
109
+ if (!Array.isArray(kept.recentInputs) || kept.recentInputs.length > 16 || !kept.recentInputs.every(v => Array.isArray(v) && v.length === 3 && typeof v[0] === "string" && Array.isArray(v[1]) && v[1].length === 5 && v[1].every(isCount) && (isCount(v[2]) && (v[2] & INPUT_USAGE_MASK) === v[2]))) return null;
110
+ if (kept.crossing !== null && (!wellFormed(kept.crossing) || kept.crossing.pending || kept.crossing.compactions !== kept.figures.compactions || kept.crossing.fresh !== kept.figures.fresh || kept.crossing.requests > kept.figures.requests || kept.crossing.recent.length !== 0)) return null;
111
+ const seen = Buffer.from(kept.seen, "hex");
112
+ if ([...kept.figures.recent, kept.freshId, kept.latestId, kept.crossingId].some(id => id !== null && !membership(seen, id))) return null;
100
113
  return kept;
101
114
  }
102
115
 
103
- function advance(kept, transcriptPath, size) {
116
+ const comparisonKey = ({ declared, horizon, resume }) => JSON.stringify([declared?.read ?? null, declared?.write?.["5m"] ?? null, declared?.write?.["1h"] ?? null, horizon, resume]);
117
+ const inputOf = r => [r.uncached, r.written1h, r.written5m, r.writtenUnknown, r.read];
118
+ const validity = () => ({ freshUnsafe: false, latestUnsafe: false, freshId: null, latestId: null, crossingId: null, freshInput: null, latestInput: null, crossingInput: null });
119
+ const reset = (kept) => Object.assign(kept, { offset: 0, tail: "", seen: emptySeen(), figures: sessionFigures(), crossing: null, recentInputs: [], ...validity() });
120
+ function rememberInput(state, r) {
121
+ if (r.id === null) return;
122
+ state.recentInputs = [...state.recentInputs.filter(v => v[0] !== r.id), [r.id, inputOf(r), r.usageMissing & INPUT_USAGE_MASK]].filter(v => state.figures.recent.includes(v[0])).slice(-16);
123
+ }
124
+
125
+ function replayValidity(kept, transcriptPath, spend) {
126
+ const transcript = readTranscript(transcriptPath, { end: kept.offset });
127
+ const state = { figures: sessionFigures(), crossing: null, recentInputs: [], ...validity() }, seen = Buffer.alloc(SEEN_BYTES);
128
+ for (const r of transcript.requests) {
129
+ if (r.id !== null) membership(seen, r.id, true);
130
+ if (r.sidechain) continue;
131
+ if (r.compacted) Object.assign(state, { crossing: null, ...validity() });
132
+ const fresh = state.figures.fresh === null || state.figures.pending || r.compacted;
133
+ foldCanonical(state.figures, r);
134
+ rememberInput(state, r);
135
+ const unsafe = inputUnusable(r);
136
+ if (fresh) { state.freshUnsafe = unsafe; state.freshId = r.id; state.freshInput = inputOf(r); }
137
+ state.latestUnsafe = unsafe; state.latestId = r.id; state.latestInput = inputOf(r);
138
+ if (state.freshUnsafe && !isCount(state.figures.fresh)) state.figures.fresh = 0;
139
+ if (state.latestUnsafe && !isCount(state.figures.last)) state.figures.last = 0;
140
+ if (state.freshUnsafe || state.latestUnsafe || state.crossing !== null) continue;
141
+ try {
142
+ const figure = figureOf(state.figures, spend);
143
+ if (figure !== null && figure.saving >= 0) { state.crossing = { ...state.figures, recent: [] }; state.crossingId = r.id; state.crossingInput = inputOf(r); }
144
+ } catch (error) { if (!(error instanceof LedgerError)) throw error; }
145
+ }
146
+ state.figures.compactions = transcript.figures.compactions;
147
+ state.figures.pending = transcript.figures.pending;
148
+ if (state.figures.pending) Object.assign(state, { crossing: null, ...validity() });
149
+ else if (state.crossing) state.crossing.compactions = state.figures.compactions;
150
+ Object.assign(kept, state, { seen: seen.toString("hex") });
151
+ }
152
+
153
+ function advance(kept, transcriptPath, size, spend) {
104
154
  if (size === kept.offset) return false;
105
155
  const fd = fs.openSync(transcriptPath, "r");
106
156
  try {
107
157
  if (kept.offset > 0 && tailBefore(fd, kept.offset) !== kept.tail) {
108
- Object.assign(kept, { offset: 0, tail: "", figures: sessionFigures() });
158
+ reset(kept);
109
159
  }
160
+ const seen = Buffer.from(kept.seen, "hex");
110
161
  const start = kept.offset;
111
162
  let position = kept.offset;
163
+ let replay = false;
112
164
  // Joined once the newline lands: joining at every chunk would copy a long line once per chunk it spans.
113
165
  let waiting = [];
114
166
  while (position < size) {
@@ -125,12 +177,48 @@ function advance(kept, transcriptPath, size) {
125
177
  const complete = waiting.length === 0 ? bytes.subarray(0, end) : Buffer.concat([...waiting, bytes.subarray(0, end)]);
126
178
  for (const line of complete.toString("utf8").split("\n")) {
127
179
  const read = readLine(line);
128
- if (read !== null && !read.malformed) foldFigures(kept.figures, read);
180
+ if (read === null || read.malformed) continue;
181
+ const { requests, compactions } = kept.figures;
182
+ const fresh = kept.figures.fresh === null || kept.figures.pending;
183
+ const r = read.request;
184
+ if (r && !r.synthetic) {
185
+ const repeated = r.id !== null && membership(seen, r.id, true);
186
+ if (repeated && !kept.figures.recent.includes(r.id)) replay = true;
187
+ if (!r.sidechain || repeated) {
188
+ const input = inputOf(r);
189
+ const tracked = [[kept.freshId, kept.freshInput], [kept.latestId, kept.latestInput], [kept.crossingId, kept.crossingInput]];
190
+ const recent = kept.recentInputs.find(v => v[0] === r.id);
191
+ if (inputUnusable(r) || (recent && (JSON.stringify(recent[1]) !== JSON.stringify(input) || recent[2] !== (r.usageMissing & INPUT_USAGE_MASK))) || (r.id !== null && tracked.some(([id, before]) => id === r.id && (JSON.stringify(before) !== JSON.stringify(input) || kept.freshUnsafe || kept.latestUnsafe)))) replay = true;
192
+ }
193
+ }
194
+ foldFigures(kept.figures, read);
195
+ if (compactions !== kept.figures.compactions) Object.assign(kept, { crossing: null, ...validity() });
196
+ if (r && !r.synthetic && !r.sidechain) {
197
+ rememberInput(kept, r);
198
+ const unsafe = inputUnusable(r);
199
+ if (requests !== kept.figures.requests) {
200
+ if (fresh) { kept.freshUnsafe = unsafe; kept.freshId = r.id; kept.freshInput = inputOf(r); }
201
+ kept.latestUnsafe = unsafe; kept.latestId = r.id; kept.latestInput = inputOf(r);
202
+ } else if (r.id !== null && (r.usageUnsafe & 1)) {
203
+ if (r.id === kept.freshId) kept.freshUnsafe = true;
204
+ if (r.id === kept.latestId) kept.latestUnsafe = true;
205
+ }
206
+ }
207
+ if (kept.freshUnsafe || kept.latestUnsafe) continue;
208
+ if (requests === kept.figures.requests || kept.crossing !== null) continue;
209
+ try {
210
+ const figure = figureOf(kept.figures, spend);
211
+ if (figure !== null && figure.saving >= 0) { kept.crossing = { ...kept.figures, recent: [] }; kept.crossingId = r.id; kept.crossingInput = inputOf(r); }
212
+ } catch (error) {
213
+ if (!(error instanceof LedgerError)) throw error;
214
+ }
129
215
  }
130
216
  waiting = end + 1 < got ? [bytes.subarray(end + 1)] : [];
131
217
  kept.offset = position - (got - end - 1);
132
218
  }
133
219
  if (kept.offset !== start) kept.tail = tailBefore(fd, kept.offset);
220
+ kept.seen = seen.toString("hex");
221
+ if (replay) replayValidity(kept, transcriptPath, spend);
134
222
  return kept.offset !== start;
135
223
  } finally {
136
224
  fs.closeSync(fd);
@@ -165,19 +253,25 @@ function locate(payload, dir) {
165
253
  if (!stat.isFile()) return { why: "the transcript could not be read — it is not a file" };
166
254
  const sessionId = sessionOf(payload);
167
255
  const file = sessionId === null ? null : stateFile(sessionId, dir);
168
- const kept = (file === null ? null : restore(file, transcriptPath, stat)) ?? { v: STATE_VERSION, transcript: transcriptPath, ino: stat.ino, offset: 0, tail: "", figures: sessionFigures() };
256
+ const kept = (file === null ? null : restore(file, transcriptPath, stat)) ?? { v: STATE_VERSION, transcript: transcriptPath, ino: stat.ino, offset: 0, tail: "", seen: emptySeen(), figures: sessionFigures(), comparison: "", crossing: null, recentInputs: [], ...validity() };
169
257
  return { transcriptPath, sessionId, stat, file, kept };
170
258
  }
171
259
 
172
260
  function refresh(found, warn, spend) {
173
261
  try {
174
- if (advance(found.kept, found.transcriptPath, found.stat.size) && found.file !== null) keep(found.file, found.kept, warn);
262
+ const key = comparisonKey(spend);
263
+ const changed = found.kept.comparison !== key;
264
+ if (changed) { reset(found.kept); found.kept.comparison = key; }
265
+ if ((advance(found.kept, found.transcriptPath, found.stat.size, spend) || changed) && found.file !== null) keep(found.file, found.kept, warn);
175
266
  } catch (error) {
176
267
  return { why: `the transcript could not be read — ${error.code ?? error.message}` };
177
268
  }
178
269
  let figure;
270
+ if (found.kept.freshUnsafe) return { why: "fresh context usage has invalid or missing counters; comparison unavailable (compaction or completed missing fields required)" };
271
+ if (found.kept.latestUnsafe) return { why: "latest context usage has invalid or missing counters; comparison unavailable (valid next request or completed missing fields required)" };
179
272
  try {
180
273
  figure = figureOf(found.kept.figures, spend);
274
+ if (figure !== null) figure.crossing = found.kept.crossing === null ? null : figureOf(found.kept.crossing, spend);
181
275
  } catch (error) {
182
276
  if (!(error instanceof LedgerError)) throw error;
183
277
  return { why: error.message };
@@ -188,35 +282,40 @@ function refresh(found, warn, spend) {
188
282
  : { why: "no request is recorded yet", after: "the first recorded request" };
189
283
  }
190
284
 
191
- const FINISH = { PostToolUse: "finish the current step, then ", UserPromptSubmit: "finish what this prompt asks, then ", Stop: "" };
192
-
193
285
  export function adviceLine(figure, event = "UserPromptSubmit") {
194
286
  const m = figure.multipliers;
195
- const assumed =
196
- m.source === "declared"
197
- ? "multipliers declared"
198
- : `multipliers undeclared, so the general read multiplier and the ${m.recorded ? `${m.lifetime === "1h" ? "one-hour" : "five-minute"} writes the host recorded` : "five-minute write the records did not state"}`;
199
- return (
200
- `Portulan restart advisory: after ${grouped(figure.requests)} requests, each re-reading it, this session's context, ${grouped(figure.context)} tokens, has reached its restart threshold of ` +
201
- `${grouped(figure.threshold)} = fresh context ${grouped(figure.fresh)} × (1 + write ${m.write}× / (${figure.horizon} more requests × read ${m.read}×)); ${assumed}. ` +
202
- `Continuing costs more in re-reads than restarting: ${FINISH[event]}write the handoff and end the session. Said once.`
203
- );
287
+ const horizon = `${figure.horizon} remaining (${figure.horizonDeclared ? "declared" : "default"})`;
288
+ const resume = figure.resume == null ? "resume cost unknown; threshold is a lower bound" : `resume ${grouped(figure.resume)} input equivalents`;
289
+ const comparison = figure.saving < 0 ? "Latest estimate does not favor restarting."
290
+ : figure.resume != null && figure.horizonDeclared
291
+ ? `Estimated savings ${savingWords(figure.saving)} input equivalents; consider a handoff and restart if these estimates still fit the task.`
292
+ : "Restart only if remaining work and recovery cost make it cheaper; finish bounded work when that costs less.";
293
+ const first = figure.crossing;
294
+ const history = first && first.requests !== figure.requests
295
+ ? ` First crossing: request ${grouped(first.requests)}, context ${grouped(first.context)} / threshold ${grouped(first.threshold)} (fresh ${grouped(first.fresh)}, read ${first.multipliers.read}×, write ${first.multipliers.write}×, ${first.multipliers.recorded ? "recorded" : "assumed"} ${first.multipliers.lifetime}).`
296
+ : "";
297
+ return `Portulan restart advisory: after ${grouped(figure.requests)} requests, context ${grouped(figure.context)} tokens, threshold ${grouped(figure.threshold)}; ${horizon}, read ${m.read}×, write ${m.write}× (${m.source}); ${resume}.${history} ${comparison} Said once.`;
204
298
  }
205
299
 
206
300
  export function statusLine(figure) {
207
301
  const m = figure.multipliers;
208
- const multiplied = `${m.source === "declared" ? "multipliers declared" : "multipliers undeclared"}: read ${m.read}×, write ${m.write}×`;
209
- const requests = `${grouped(figure.requests)} request${figure.requests === 1 ? "" : "s"}`;
210
- return figure.context >= figure.threshold
211
- ? `${requests} · context ${compact(figure.context)} has reached its ${compact(figure.threshold)} restart threshold: write the handoff and restart · ${multiplied}`
212
- : `${requests} · context ${compact(figure.context)} of a ${compact(figure.threshold)} restart threshold · ${multiplied}`;
302
+ return `${grouped(figure.requests)} requests · context ${compact(figure.context)} / restart ${compact(figure.threshold)} · ${figure.horizon} remaining (${figure.horizonDeclared ? "declared" : "default"}) · ` +
303
+ `${figure.resume == null ? "resume cost unknown: lower bound" : `resume ${grouped(figure.resume)} input-equivalent tokens; estimated saving ${savingWords(figure.saving)}`} · ` +
304
+ `multipliers ${m.source}: read ${m.read}×, write ${m.write}×`;
213
305
  }
214
306
 
215
- function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = HORIZON } = {}) {
307
+ export function advisoryOutput(figure, event) {
308
+ const line = adviceLine(figure, event);
309
+ return event === "Stop"
310
+ ? JSON.stringify({ decision: "block", reason: line })
311
+ : JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: line } });
312
+ }
313
+
314
+ function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = null, resume = null } = {}) {
216
315
  // Claude Code 2.1.281: a subagent's tool call carries its own `agent_id` and the main session's transcript,
217
316
  // and a subagent told to end its session would end nothing.
218
317
  if (typeof payload?.agent_id === "string" && payload.agent_id !== "") return null;
219
- // A host can give a block's retry a new session id, where the held-once record would hold it again.
318
+ // Without the host's retry marker, rotated session ids can repeat the hold.
220
319
  if (event === "Stop" && payload?.stop_hook_active === true) return null;
221
320
  const sessionId = sessionOf(payload);
222
321
  if (sessionId === null) {
@@ -231,12 +330,13 @@ function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = n
231
330
  const record = event === "Stop" ? heldFile : toldFile;
232
331
  // Nothing written since the figures were kept, so nothing compacted: the record at the kept count answers without a read.
233
332
  if (found.stat.size === found.kept.offset && fs.existsSync(record(sessionId, found.kept.figures.compactions, dir))) return null;
234
- const figure = refresh(found, warn, { declared, horizon });
333
+ const figure = refresh(found, warn, { declared, horizon, resume });
235
334
  if (figure.why !== undefined) {
236
335
  warn(figure.why);
237
336
  return null;
238
337
  }
239
- if (figure.context < figure.threshold) return null;
338
+ if (figure.crossing === null) return null;
339
+ if (event === "Stop" && (!figure.horizonDeclared || figure.resume === null || !(figure.saving > 0))) return null;
240
340
  const mark = (file) => fs.writeFileSync(file, `${figure.context} ${figure.threshold}\n`, { flag: "wx", mode: 0o600 });
241
341
  try {
242
342
  mark(record(sessionId, figure.compactions, dir));
@@ -250,13 +350,13 @@ function once(event, payload, { dir = os.tmpdir(), warn = () => {}, declared = n
250
350
  }
251
351
  return null;
252
352
  }
253
- if (event !== "Stop") return JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: adviceLine(figure, event) } });
353
+ if (event !== "Stop") return advisoryOutput(figure, event);
254
354
  try {
255
355
  mark(toldFile(sessionId, figure.compactions, dir));
256
356
  } catch (error) {
257
357
  if (error.code !== "EEXIST") warn(`the told-once record could not be written — ${error.code ?? error.message}; the line may come once more after the block`);
258
358
  }
259
- return JSON.stringify({ decision: "block", reason: adviceLine(figure, event) });
359
+ return advisoryOutput(figure, event);
260
360
  }
261
361
 
262
362
  export const onTool = (payload, options) => once("PostToolUse", payload, options);
@@ -266,13 +366,24 @@ export const onPrompt = (payload, options) => once("UserPromptSubmit", payload,
266
366
  export const onStop = (payload, options) => once("Stop", payload, options);
267
367
 
268
368
  /** The status line, its context the host's own last-call counts where sent: current where the transcript may lag a request. */
269
- export function onStatus(payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = HORIZON } = {}) {
369
+ export function onStatus(payload, { dir = os.tmpdir(), warn = () => {}, declared = null, horizon = null, resume = null } = {}) {
270
370
  const found = locate(payload, dir);
271
- const figure = found.why === undefined ? refresh(found, warn, { declared, horizon }) : found;
371
+ const figure = found.why === undefined ? refresh(found, warn, { declared, horizon, resume }) : found;
272
372
  if (figure.why !== undefined) return figure.after !== undefined ? `restart threshold: after ${figure.after}` : `restart threshold: not known, because ${figure.why}`;
273
373
  const usage = payload?.context_window?.current_usage;
274
- const counts = usage !== null && typeof usage === "object" ? ["input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"].map((k) => usage[k]) : [];
275
- if (counts.length === 3 && counts.every((n) => Number.isSafeInteger(n) && n >= 0)) figure.context = counts.reduce((a, b) => a + b, 0);
374
+ if (usage !== undefined && usage !== null) {
375
+ if (typeof usage !== "object" || Array.isArray(usage)) return "restart threshold: not known, because the current input has missing or invalid counters";
376
+ const current = usageOf(usage), context = contextOf(current);
377
+ if (!Number.isSafeInteger(context)) return "restart threshold: not known, because the current input sum is unsafe";
378
+ if (inputUnusable(current)) return "restart threshold: not known, because the current input has missing or invalid counters";
379
+ try {
380
+ figure.saving = restartSaving({ fresh: figure.fresh, context, horizon: figure.horizon, read: figure.multipliers.read, write: figure.multipliers.write, resume: figure.resume ?? 0 });
381
+ } catch (error) {
382
+ if (error instanceof LedgerError) return "restart threshold: not known, because the estimated saving overflows";
383
+ throw error;
384
+ }
385
+ figure.context = context;
386
+ }
276
387
  return statusLine(figure);
277
388
  }
278
389
 
@@ -284,7 +395,7 @@ function readPayload() {
284
395
  }
285
396
  }
286
397
 
287
- const SPEND_FLAGS = { "--read": "read", "--write-5m": "write", "--write-1h": "write", "--horizon": "requests" };
398
+ const SPEND_FLAGS = { "--read": "read", "--write-5m": "write", "--write-1h": "write", "--horizon": "requests", "--resume-tokens": "resume" };
288
399
 
289
400
  const MULTIPLIER_FLAGS = ["--read", "--write-5m", "--write-1h"];
290
401
 
@@ -294,7 +405,7 @@ const DECIMAL = /^[0-9]+(\.[0-9]+)?(e[+-]?[0-9]+)?$/;
294
405
  /** The figures after the mode, `{ declared, horizon, fault }`, each null where absent; a partial multiplier set is undeclared whole. */
295
406
  export function spendFlags(args) {
296
407
  const values = new Map();
297
- const faults = { multipliers: [], horizon: [], other: [] };
408
+ const faults = { multipliers: [], horizon: [], resume: [], other: [] };
298
409
  const shown = (s) => (typeof s === "string" && /^[-+.a-zA-Z0-9]+$/.test(s) ? s : JSON.stringify(s));
299
410
  for (let i = 0; i < args.length; i += 2) {
300
411
  const [flag, raw] = [args[i], args[i + 1]];
@@ -302,7 +413,7 @@ export function spendFlags(args) {
302
413
  faults.other.push(`${shown(flag)} is no flag it takes`);
303
414
  continue;
304
415
  }
305
- const part = flag === "--horizon" ? faults.horizon : faults.multipliers;
416
+ const part = flag === "--horizon" ? faults.horizon : flag === "--resume-tokens" ? faults.resume : faults.multipliers;
306
417
  const range = SPEND_FIGURES[SPEND_FLAGS[flag]];
307
418
  const value = typeof raw === "string" && DECIMAL.test(raw) ? Number(raw) : Number.NaN;
308
419
  if (values.has(flag)) part.push(`${flag} is given twice`);
@@ -319,10 +430,12 @@ export function spendFlags(args) {
319
430
  const said = [];
320
431
  if (faults.multipliers.length) said.push(`${faults.multipliers.join(", ")}, so the multipliers are undeclared`);
321
432
  if (faults.horizon.length) said.push(`${faults.horizon.join(", ")}, so the horizon is undeclared, and ${HORIZON} requests`);
433
+ if (faults.resume.length) said.push(`${faults.resume.join(", ")}, so resume cost is unknown`);
322
434
  if (faults.other.length) said.push(`${faults.other.join(", ")}, so ${faults.other.length === 1 ? "it is" : "each is"} passed over with the value after it`);
323
435
  return {
324
436
  declared: missing.length === 0 && !faults.multipliers.length ? { read: values.get("--read"), write: { "5m": values.get("--write-5m"), "1h": values.get("--write-1h") } } : null,
325
437
  horizon: values.has("--horizon") && !faults.horizon.length ? values.get("--horizon") : null,
438
+ resume: values.has("--resume-tokens") && !faults.resume.length ? values.get("--resume-tokens") : null,
326
439
  fault: said.length ? said.join("; ") : null,
327
440
  };
328
441
  }
@@ -334,7 +447,8 @@ export function main(argv = process.argv.slice(2), { stdout = process.stdout, st
334
447
  try {
335
448
  const figures = spendFlags(argv.slice(1));
336
449
  if (figures.fault !== null) warn(`the figures after ${mode} are not all usable — ${figures.fault}`);
337
- const priced = { dir, warn, declared: figures.declared, horizon: figures.horizon ?? HORIZON };
450
+ if (mode === "stop" && figures.fault !== null) return 0;
451
+ const priced = { dir, warn, declared: figures.declared, horizon: figures.horizon, resume: figures.resume };
338
452
  if (mode === "prompt" || mode === "tool" || mode === "stop") {
339
453
  const out = { tool: onTool, prompt: onPrompt, stop: onStop }[mode](input, priced);
340
454
  if (out !== null) stdout.write(`${out}\n`);