@sleepy_panda_srl/portulan 0.3.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.
- package/README.md +15 -4
- package/cli/README.md +55 -3
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- 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.
|
|
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/` | [
|
|
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
|
@@ -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) |
|
|
41
|
-
| [`advisory.mjs`](advisory.mjs) |
|
|
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,35 +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) |
|
|
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. |
|
|
66
76
|
| [`local-routing.mjs`](local-routing.mjs) | Verify the shipped operational local roles against their immutable acquired A/B record. No model. Not shipped. |
|
|
67
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. |
|
|
68
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. |
|
|
69
80
|
| [`pack-identity.mjs`](pack-identity.mjs) | Every file `npm pack` emits is byte-identical to the tracked blob it came from. |
|
|
70
81
|
| [`pack-version.mjs`](pack-version.mjs) | A change to a pack's `contributes` must move that pack's `portulan.version`. |
|
|
71
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. |
|
|
72
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. |
|
|
73
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. |
|
|
74
88
|
| [`release-eval.mjs`](release-eval.mjs) | Portulan — the eval result a release carries. |
|
|
75
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. |
|
|
76
91
|
| [`roster.mjs`](roster.mjs) | The cli roster: `cli/README.md`, rendered from the files it lists and never edited by hand. Not shipped. |
|
|
77
92
|
| [`rule-carriers.mjs`](rule-carriers.mjs) | `rule-carriers` — the rail that keeps a reduced rule reduced. |
|
|
78
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. |
|
|
79
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. |
|
|
80
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. |
|
|
81
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. |
|
|
82
101
|
| [`telemetry.mjs`](telemetry.mjs) | The OTel emitter: the review-loop figures as OTLP/HTTP JSON, sent only when a committed config opts in. |
|
|
83
102
|
| [`version-carriers.mjs`](version-carriers.mjs) | Every prose statement of the current version agrees with `package.json`. |
|
|
@@ -92,16 +111,26 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
|
|
|
92
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. |
|
|
93
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. |
|
|
94
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. |
|
|
95
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. |
|
|
96
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. |
|
|
97
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. |
|
|
98
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. |
|
|
99
124
|
| [`collisions.test.mjs`](collisions.test.mjs) | The collision contract: `init`, `new` and `vendor` answer each contracted state alike, and no fourth `collisions` exists. |
|
|
100
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. |
|
|
101
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. |
|
|
102
129
|
| [`context.test.mjs`](context.test.mjs) | Tests for `context` — what a boot reads and what the host loads into every context, measured. |
|
|
103
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. |
|
|
104
132
|
| [`discover.test.mjs`](discover.test.mjs) | Tests for host plugin-cache discovery. |
|
|
133
|
+
| [`doctor-lever.test.mjs`](doctor-lever.test.mjs) | Tests for doctor's savings lever. |
|
|
105
134
|
| [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` — workspace validation, branch-owned hybrid usage and static-mode isolation. |
|
|
106
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. |
|
|
107
136
|
| [`eval-bundle.test.mjs`](eval-bundle.test.mjs) | The evaluation-bundle cutter, driven on this repository AND on real fixture repositories. |
|
|
@@ -114,25 +143,42 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
|
|
|
114
143
|
| [`gate.test.mjs`](gate.test.mjs) | The PreToolUse gate runner, driven as the host drives it. |
|
|
115
144
|
| [`gh-bot.test.mjs`](gh-bot.test.mjs) | The agent identity's wrapper refuses the endpoints it is not for. |
|
|
116
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. |
|
|
117
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. |
|
|
118
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. |
|
|
119
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. |
|
|
120
152
|
| [`ledger.test.mjs`](ledger.test.mjs) | Tests for `ledger` — what a change spends, from the host's own usage records. |
|
|
121
153
|
| [`librarian.test.mjs`](librarian.test.mjs) | Tests for `librarian` — the scheduled pass over the curated layer. |
|
|
122
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. |
|
|
123
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. |
|
|
124
160
|
| [`new.test.mjs`](new.test.mjs) | Tests for `new`: scaffolds that validate, written into the user's own layer and never into `core/`. |
|
|
125
161
|
| [`pack-identity.test.mjs`](pack-identity.test.mjs) | Tests for the byte-identity rail: green, both findings and could-not-run, each exercised. |
|
|
126
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. |
|
|
127
163
|
| [`pack-version.test.mjs`](pack-version.test.mjs) | The pack-version rail, driven on REAL git repositories rather than an injected history. |
|
|
128
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. |
|
|
129
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. |
|
|
130
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. |
|
|
131
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. |
|
|
132
176
|
| [`recipe-set.live.test.mjs`](recipe-set.live.test.mjs) | The composed recipe set, against THIS repository rather than against fixtures. |
|
|
133
177
|
| [`recipe-set.test.mjs`](recipe-set.test.mjs) | The recipe set — one carrier, and every reader reaches it. |
|
|
134
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. |
|
|
135
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. |
|
|
136
182
|
| [`rule-carriers.test.mjs`](rule-carriers.test.mjs) | Tests for `rule-carriers` — the rail that keeps a reduced rule reduced. |
|
|
137
183
|
| [`sessions.test.mjs`](sessions.test.mjs) | `sessions` — the cache lifetime's offer, and `doctor`'s one line on the session switches. |
|
|
138
184
|
| [`shadowed-pack.test.mjs`](shadowed-pack.test.mjs) | A shadowed pack is refused rather than picked. |
|
|
@@ -140,6 +186,9 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
|
|
|
140
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. |
|
|
141
187
|
| [`skills-set.live.test.mjs`](skills-set.live.test.mjs) | The registrable set, against THIS repository rather than against fixtures. |
|
|
142
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. |
|
|
143
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. |
|
|
144
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. |
|
|
145
194
|
| [`symbols.test.mjs`](symbols.test.mjs) | The outline's contract, on fixtures: what each construct prints, and every refusal. |
|
|
@@ -159,4 +208,7 @@ What the tools and suites read.
|
|
|
159
208
|
| File | What it is |
|
|
160
209
|
|---|---|
|
|
161
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 |
|
|
162
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
|
-
//
|
|
2
|
+
// Restart comparison with explicit horizon and resume expense; unknown recovery cost stays conditional.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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`);
|