specpi 0.26.0 → 0.27.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/CHANGELOG.md CHANGED
@@ -1,7 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0 - 2026-09-18
4
+
5
+ - Put a measured number on the rule the whole layer is built around. "Any tool-set decision happens once, before the first request, or not at all" rested on a cache argument that was reasoned about here for months and never weighed. Three arms on `t3-cascade-ledger` differing only in when Browser QA's fourteen tools reach the request settle it: flipping them on at turn 6 collapsed cached tokens to 3,200 at the very next request in three attempts out of three — from 25,984, 17,792 and 21,120 — while the prompt kept climbing, one request going from 3,673 fresh tokens to 31,337. The re-warm cost 14.6%, 21.6% and 23.9% of the attempt, against a 10% threshold written down before the run. Arming the same group from turn 1 cost 16% more than never arming it, against 47% for flipping mid-session, so paying up front is about three times cheaper than paying when the need appears. Recorded to `evals/runs/cache-probe/`; `scripts/cache-probe.mjs` reproduces the analysis.
6
+ - Calibrate the Jev gate against recorded evidence and pin it there. Every threshold was a placeholder carrying a comment that said so; the layer shipped ahead of its own gate. `scripts/jev-calibrate.mjs` now measures each primitive against a label this repository already owns and, separately, checks whether a gate can fire at all — and the second check found that it could not. Retention demanded a Score confidence of 0.80 with the value within 0.15 of a level; on a deliberately obvious spent result it answers 0.10 to 0.18 at confidence 0.73 to 0.85. So retention could gate through to "keep this result" and essentially never to "this result is spent", and running the layer could never have shown it, because a system that never fires looks exactly like one whose advice was always to do nothing. The same check caught compaction's open-thread gate and, the same day, a threshold in new code written for this release.
7
+ - Report honestly that the confidence field carries little signal on hard questions. Predicting an attempt's outcome, its task category or its tier from behavioural metadata alone scores at the base rate for a Noul and a Choice, and about two-thirds exact for a Score against a 43% majority class. No system's pre-registered precision target is met anywhere on any of those curves, and `evals/runs/jev-calibration.json` records UNMET rather than a number chosen to fill the gap. On the production questions, where the state carries the material being judged, separation is wide: a planted credential scores 0.96 against 0.04 for a clean report.
8
+ - Find out, and say, that retention does not fire. Once the ledger could record outcomes rather than only calls, five live runs said the system asks three or four times per attempt and has never shortened anything, every decline being the same: Jev answered and reported a confidence below the calibrated bar. On a contrived case it is confident; on the real reads of a repair chain, where each result feeds the next step, it is genuinely unsure whether the output is spent. The threshold is not lowered to compensate, because firing on a confidence the model did not have is the one thing a system whose wrong answer costs the task must not do. The point of the instrumentation was to make that statement checkable instead of inferable, and it does.
9
+ - Instrument what retention actually drops. The ledger recorded bytes sent and never whether the advice was taken, so a system's effect could only be inferred from a cost delta it may not have caused — disqualifying for a layer whose claim is that it pays for itself. `request()` takes a `decide` callback that runs before the ledger write, so one line now records `applied` and `savedBytes`, and the eval adapter folds `elisions` and `bytesDropped` out of the disposable home into each attempt's report.
10
+ - Re-run the layer's own row and publish what it says, which is that the layer does not pay for itself. 37 attempts across five tiers with the calibrated gate and five systems on: **43 calls, none failed, none changed anything**. Retention asked 38 times and elided nothing, every decline the same confidence below the bar — and this time against a threshold that demonstrably can fire, which is what turns the earlier suspicion into a result. Progress asked 5 times and was right every time that the session was not stuck, down from spending an entire budget on one healthy session. The layer costs about 9% more per attempt and scores within noise either way, and the one figure that had looked like the mechanism working did not survive: context growth per turn went from 590 to 631 against a control of 628, so the earlier 6% reading was variance. `scripts/jev-effect.mjs` reproduces every published figure from the stored reports, using the same aggregates the evaluations page is built from rather than a second definition that could drift.
11
+ - Say that the untrusted-content system has never been called. It rides on a web or browser tool result; no task in any tier produces one, and the run that would exercise Browser QA keeps it withdrawn because capability arming needs an interactive human. `SECURITY_MODEL.md` claimed its false-positive rate was measurable on tier 5, which is not true and is corrected. A system that ships unmeasured is a gap worth naming, because an absent row reads as a zero and a zero reads as a result.
12
+ - Record a payload that outlives its session. The proxy saw 44 calls leave the machine and the ledger held 43, which is how this was found. A system that is deliberately not awaited — the normal shape of a turn-level one — can still be in flight when the session ends, and the answer was being discarded along with its audit line. The answer still is, because it belongs to a session that no longer exists and must never be acted on; the line is now written either way and says which, since the ledger's whole claim is that every transmission appears in it.
13
+ - Correct what the Jev page said the sanitiser refuses. It still carried the "refuses file contents and command output outright" wording that `SECURITY_MODEL.md` had already retracted, in prose and in a diagram. The page now states the real bound: at most twelve short redacted lines of the material being judged — six from the head, four through the middle, two from the tail — inside the same 1 KB budget. Its measured table also disagreed with the dataset it summarised, and is replaced by figures the script derives.
14
+ - Publish the session's call count where another process can read it. `/jev status` could always print what the layer had spent, and nothing outside the advisor's own process could: the ledger is an append-only audit trail with no session boundary in it, so counting *this* session out of it meant knowing something only the advisor knew. The advisor now keeps one small `usage.json` beside its settings, rewritten as it goes, holding counts and nothing else — no state, no questions, no answers, not even the ledger's digests, which is what makes it safe for a second process to read. It is written only while the master switch is on, so a layer nobody has enabled still leaves no trace, and the last session's counts survive shutdown rather than being deleted, because "this has never run" and "the session that just ended spent its whole budget" are different facts.
15
+ - Size the call budget for a session that runs for a day. The total was 120, which was sized against eval attempts — and an attempt runs for two minutes while a session runs until you close it. A turn-level system at one call every four turns reached that ceiling somewhere in the afternoon and then went quiet without having found anything wrong, which is not protection, only a later failure. The default total is now 512 with per-system ceilings that still sum past it, so the total remains a real constraint: measured, a full tier-3 task spends 4 to 7 calls and the busiest attempt ever recorded spent 12, so 512 is only reachable by a loop, and reaching it is therefore information. Cost was never the reason for a ceiling — a call is about $0.00003, so the whole total is about a cent and a half — they bound how much digest leaves the machine and how much awaited latency a runaway loop can add.
16
+ - Say so when a budget runs out. Exhaustion and "nothing to say" both produce silence, and silence is this layer's normal state, so a session could run for an hour with a system switched on and quietly dead. Each system now announces its own exhaustion once, where there is a human to read it.
17
+ - Replace one shared call budget with per-system budgets under a session total. A turn-level system firing thirty times would have reached a shared ceiling of 8 within a few turns and left every other system dead for the rest of the session, with event ordering rather than policy deciding which one won. Schema 2; a schema 1 file is migrated forward rather than read as unrecognised, because collapsing to all-off is a rule for corrupt input and applying it to our own previous version would silently disable a layer the user had switched on.
18
+ - Stop the progress system spending its budget on healthy sessions. Its first local gate asked whenever any single signal fired, and a live run spent all twelve calls on a session that scored 0.978: a 120-step repair chain re-runs its verification command constantly, so a repeated tool signature is that task's normal condition rather than a symptom, and a long read looks identical to a stuck session on the quiet-stretch signal alone. Two weak signals are now required together, a run of three consecutive errors still stands alone, and a verdict is not re-asked for four turns because the situation that produced it has not changed. The same run afterwards: one call instead of twelve, same score.
19
+ - Add three systems. **Progress** is the first aimed at turns rather than input tokens, which is where the money is on the hard tiers: it watches for a repeated tool call, a run of errors or several turns with nothing written, and only then asks whether the session is stuck. It ships set to tell the person rather than the model. **Untrusted content** prepends a fixed warning to a fetched page that confidently reads as instructions addressed to an agent, and costs no extra call while retention is on because one digest answers both questions. **Capability arming** reads the request once, before anything is sent, and offers a withdrawn tool group at turn 0 instead of turn 6 — which the cache probe prices at about a third of the cost.
20
+ - Widen retention to the results that are actually large. `fetch_content`, `get_search_content`, `web_search`, `browser_snapshot`, `browser_accessibility`, `browser_diagnostics` and `delegate` produce the biggest outputs anything in SpecPi generates and are the least likely to be load-bearing twice, and the plan said they fell out of this system for free. They did not: the names were simply not in the set.
21
+ - Serve branch summarisation, which was unserved. It is the same problem as compaction at the same discarded boundary, so it shares the compaction switch rather than adding another. `label` from a fixed enum makes `/tree`'s labelled-only filter worth having, and no model-written text reaches the session file.
22
+ - Give `request_capability` a documented per-invocation cost. Its dialog stated the standing schema weight and said nothing about the one-off, which the cache probe now prices at about 20% of a mid-length attempt. The tool description and the confirmation both say so, and the plan's claim that activating Browser QA also rebuilds the system prompt is removed rather than left standing: the pinned 0.3.0 release moved that guidance into tool descriptions, and the measurement confirms the system prompt is byte-identical between arms.
23
+ - Publish the failure-mode distribution on the evaluations page. A pass rate says how often a harness finished, not whether the failures ran out of clock, solved the wrong problem or repeated a failing call — and those need different fixes. Every verdict goes through the same gate a live session would apply, and 14 of 24 did not clear it; those are published as ungated rather than folded into the nearest category, because a report file records what a harness did and not what it was trying to do.
24
+ - Correct what the security model says leaves the machine. It claimed file contents and command output were "refused outright". That was never true: deciding whether a result is spent cannot be done from byte counts, so a bounded sample of the result's own lines has always been sent. The accurate bound is at most twelve short redacted lines inside a 1 KB budget, and it is now stated as such in both `SECURITY_MODEL.md` and `THIRD_PARTY.md`.
25
+
26
+ - Score tiers 1 to 3 on effort as well as correctness. Across 182 recorded attempts every score was exactly 0 or 1, and 9 of the 14 failures were one harness with disclosed platform problems, so thirteen tasks carried about one bit between them; a two-line deliverable is genuinely binary and no grading scheme rescues it. What did vary at identical results was the work taken — 2 tool calls against 9 on the same task — so a task may now declare an `effort` reference and the runner scores `correctness x (1 - weight + weight x min(1, floor / calls))`. Correctness multiplies, so a wrong answer still scores zero however cheap it was; the floor is the fewest calls a real harness used on a passing attempt, named per task, because reference solutions hardcode their answers and a floor derived from them would punish any agent that honestly reads its input. Beating the floor caps at 1.0, so a better harness never lowers anyone else's recorded score. The checker still returns correctness alone and never sees tool calls, so the fake/failing-fake contract is untouched, and stored attempts are rescored by `attemptScore` the same way `priceAttempt` reprices stored usage.
27
+ - Give every tier 1 and 2 task a decoy. Scope was clean on all 182 recorded attempts because most workspaces held only the file being worked on, so restraint was measuring nothing. Each task now ships a neighbour carrying the same class of defect as the in-scope file, and the prompts do not mention them: `t1-no-touch` and `t2-scoped-edit` name their forbidden file, which tests instruction-following, while these test whether a harness stays on its task when something adjacent looks broken. Across 168 attempts no harness touched an unrelated decoy; all seven edited the one that was a consequence of the change they were asked to make, so that decoy was replaced with an unrelated one rather than left measuring good engineering instinct as overreach.
28
+ - Publish efficiency rather than only spend. Cost is the sum of everything else and the least diagnostic figure of the set, so the evaluations page now reports tool calls, turns, tool-error rate, repeated calls, cache hit rate, context growth per turn and compactions beside the score. All of it was already recorded per attempt and aggregated nowhere. One harness compacted 37 times across the run and no other compacted at all, which no cost column would have shown.
29
+ - Add a "The Jev layer" page to the site, with diagrams for where the layer sits, the six gates in front of every call, and why deciding on arrival is worth five times what rewriting history is worth. It states plainly that the layer has not yet paid for itself: across 259 attempts it cost about 9% more per attempt and scored fractionally lower, with context growth and tool-error rate the only measures moving the right way, and neither attributable yet because nothing records how many bytes retention actually dropped.
30
+ - Add a Jev layer panel to SpecPi Chat. The four systems, the master switch, the call budget and the command guard are toggles that write `<agent-dir>/specpi/jev/settings.json` &mdash; the same file the extension reads. The panel holds the flattened shape and the host translates, because the systems nest under `systems` and the guard under `guard` on disk and a nested object renders as a JSON textarea. A test asserts the panel offers exactly the advisor's own systems, so the two cannot drift apart.
31
+ - Key the Jev consent grant to the host it was given for. The dialog named `api.typesafe.ai` from a fixed constant, and when the default backend became OpenRouter it went on naming a host the data no longer went to; because the grant was keyed on the same constant it was written with, nothing failed and the mismatch was invisible. The label is derived from the live base URL now, so each destination names itself and switching backends asks again rather than carrying an old answer to a new destination.
32
+ - Refuse to render an evaluations page spanning two models. Metadata was read from whichever report sorted last, so a mixed set would have published one model name over rows measured on two &mdash; the same shape already caught once for attempts per cell, which had a guard where the model did not.
33
+
3
34
  ## Unreleased
4
35
 
36
+ - Route and price Jev traffic through the eval proxy. `TYPESAFE_BASE_URL` points the advisor at the proxy, which forwards `/v1/systemone` upstream and records what it cost, so advisor spend lands inside `modelCost` — the figure the harness comparison actually uses — instead of being invisible because it went somewhere else. It is also reported separately as `advisorCost`. Jev prices input only and reports no usage block, so tokens are estimated from the payload at chars/4 and marked as an estimate. Advisor records are filtered out of the model series, which counts turns and context growth and would be wrong if they were mixed in.
37
+ - Write the Jev guard's inert settings at install time, not only at session start. The guard's own default is `enabled: true` and it re-reads its settings on every tool call, so an absent file means an active guard — and with no key it fails closed, which is a fresh install that refuses to run commands. The advisor rewrites the file every session, but that only helps if the advisor loads; establishing it during `install` removes the dependency.
38
+ - Withdraw the two harness-improvement authoring tools when no improvement is selected. Measured across the recorded eval runs, six of SpecPi's ten offered tools were offered on 31 of 31 attempts and called zero times; `record_harness_contract` and `finish_harness_improvement` are only usable after a human selection, and whether one exists is already a fact in local state. They are now withdrawn until one is made and restored the moment it is, which needs no model and cannot be wrong. `report_capability_gap` and `request_capability` are never withdrawn: one is how friction gets reported at all, the other is the escape hatch that makes every other withdrawal safe.
39
+ - Add the Jev advisor, off by default. It asks TypeSafe's Jev classifier typed questions about session state and gets calibrated probabilities back, then gates them in one place. Four systems: shorten a spent read-only tool result before it is appended, steer compaction's summary at the one boundary where the prompt cache is discarded anyway, deduplicate and re-score capability-gap reports, and order the sources a delegation batch will freeze. It holds no authority — it never grants a capability, calls a tool or allows one — and every failure is silent, so a timeout, missing key, refused consent or unconfident answer simply produces no advice. `/jev` shows and changes it; `/jev ledger` reads back a local hash of every payload ever sent.
40
+ - Condense tool results on arrival rather than rewriting history. Simulated over the recorded token series, batching several results and rewriting them afterwards is worth about -12% of long-attempt cost, against -61% for condensing each result before it is appended: a rewrite invalidates the cached prefix, and 94% of SpecPi's prompt tokens are cache reads. Coverage matters far more than compression ratio, so the replacement is a plain deterministic head-and-tail digest that says the output can be recovered by re-running.
41
+ - Pin `specpi-jev-guard@0.1.0` beside the permission system rather than in place of it, and ship it inert. Its own default is `enabled: true`, so left alone a fresh install would start gating shell and file calls through a third-party service on day one; SpecPi writes `enabled: false`, and while it is off `@gotgenes/pi-permission-system` decides every call exactly as before. `/jev guard on` enables it for a session, `/jev guard startup on` defaults it on. Once on it is fail-closed by design — no key, an unreachable endpoint, or a middle-band verdict with no UI all block the call, and no setting hands that decision back to the permission system — so switching it on accepts that an outage stops gated work. `/jev status` and `doctor` say which posture is in force.
42
+ - Use one key for the whole Jev layer, and reach Jev through OpenRouter. Jev is published there, the guard already defaulted to that backend, and an OpenRouter key is rejected by the direct TypeSafe API with a bare 401 &mdash; so the advisor now defaults to OpenRouter too and both halves read `OPENROUTER_API_KEY`. `JEV_BACKEND=typesafe` selects the direct API for a TypeSafe key. Only `enabled`, `backend` and `uncertain` are asserted on the guard, merged into the existing file, so a user's own thresholds, safe-command globs and protected paths survive.
43
+ - Nothing in the Jev layer is on by default. The advisor's master switch, all four of its systems and the guard each ship off, and each has a `startup` preference so a user can default on whatever they want. Session toggles never write those preferences.
44
+ - Read development keys from the existing `evals/.env`, reusing the eval suite's own loader and file rather than adding a second mechanism beside it. `scripts/jev-calibrate.mjs` and `scripts/jev-triage.mjs` load it automatically; a shell variable always wins, values are never printed, and `--env-file=<path>` or `--no-env-file` override it. `--probe` sends one fixed synthetic question so a key and endpoint can be verified before a full run is spent on them. `evals/.env.example` documents the two Jev variables alongside the eval provider ones. The file is for this repository's scripts only; an installed extension reads the environment Pi was started with.
45
+ - Add `scripts/jev-calibrate.mjs` and `scripts/jev-triage.mjs`. The eval checkers are deterministic, so Jev is not a grader here and would be worse as one; calibration inverts the relationship instead and uses those objective verdicts as free labels, printing reliability bins and precision at each candidate threshold so the gate thresholds can be read off a curve rather than guessed. Triage classifies why recorded attempts failed, which is manual transcript reading today. Both are offline and touch no session.
46
+ - Add a `specpi-jev` eval harness beside `specpi-default`, price `jev-1.13.0` in the frozen list, and record `stderrTail` on every attempt. The adapter refuses to run unless `TYPESAFE_BASE_URL` points somewhere loggable, so advisor spend is priced rather than hidden. `eval-run.mjs --keep-transcripts` writes per-attempt request transcripts for triage.
47
+
5
48
  - Measure the DeepSeek Harness on the same terms and add it to the first-call chart. Its default session sends 31,743 characters across 25 tools, about 5.7x stock Pi and within 700 characters of OpenCode. `scripts/measure-context.mjs --dsh=<path to its bin>` takes that row, declared through the harness's own patch layer; its auxiliary session-title request is excluded because it carries no tool schema. The Oh My Pi row is carried forward from the same-terms run while its runtime's dependency resolution stays broken upstream.
6
49
 
7
50
  ## 0.26.0 - 2026-09-17
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
- <a href="https://tannermidd.github.io/SpecPi/">Website</a> · <a href="https://tannermidd.github.io/SpecPi/wiki/">Documentation</a> · <a href="https://github.com/TannerMidd/SpecPi/releases">Releases</a>
16
+ <a href="https://tannermidd.github.io/SpecPi/">Website</a> · <a href="https://tannermidd.github.io/SpecPi/wiki/">Documentation</a> · <a href="https://tannermidd.github.io/SpecPi/evaluations/">Evaluations</a> · <a href="https://github.com/TannerMidd/SpecPi/releases">Releases</a>
17
17
  </p>
18
18
 
19
19
  <p align="center">
@@ -30,7 +30,7 @@
30
30
 
31
31
  SpecPi is a small starting point for the [Pi coding agent](https://pi.dev/). It is one opinionated setup for how the agent should work, not a marketplace of plugins.
32
32
 
33
- At the center are two built-in extensions. **Scope control** keeps each task to the files it said it would touch. The **improvement loop** turns repeated friction into small, tested changes to the setup, instead of letting prompts and workarounds pile up. Around those are seven hand-picked packages, each locked to an exact version and checked before anything installs, plus **SpecPi Chat**, a VS Code panel for working alongside the agent.
33
+ At the center are two built-in extensions. **Scope control** keeps each task to the files it said it would touch. The **improvement loop** turns repeated friction into small, tested changes to the setup, instead of letting prompts and workarounds pile up. Around those are eight hand-picked packages, each locked to an exact version and checked before anything installs, plus **SpecPi Chat**, a VS Code panel for working alongside the agent.
34
34
 
35
35
  It focuses on five things:
36
36
 
@@ -44,7 +44,7 @@ Everything it touches is written down, versioned, and easy to undo.
44
44
 
45
45
  ## Measured context
46
46
 
47
- This chart shows first-call context from a clean install: all seven pinned packages, the working agreement, and the skills Pi finds. "Enabled" means browser QA, delegation, and web access are switched on, with no goal, scope, or improvement selection active.
47
+ This chart shows first-call context from a clean install: all seven pinned packages, the working agreement, and the skills Pi finds. It predates `specpi-jev-guard`, the eighth pin, and has not been re-measured since. "Enabled" means browser QA, delegation, and web access are switched on, with no goal, scope, or improvement selection active.
48
48
 
49
49
  The solid rows are measured by us, from the request each setup actually sends through one local test provider. That includes OpenCode, the DeepSeek Harness, and Oh My Pi, all measured as installed. The faded Codex CLI and Claude Code rows come from HarnessTax's published numbers, measured under their own setup. Treat those as a rough reference, not a head-to-head test. These are character counts. They say nothing about tokens, cost, or how well each tool does the job. The research page [breaks down the enabled setup by feature](https://tannermidd.github.io/SpecPi/research/#specpi), so you can see what each switch costs on its own.
50
50
 
@@ -70,6 +70,40 @@ The gap between the two SpecPi bars comes from a few separate switches, so the e
70
70
  </p>
71
71
  <p align="center"><sub>Every tool in the measured request belongs to exactly one group · Leaving all three opt-in groups hidden keeps 23,797 characters of tool schema out of every request</sub></p>
72
72
 
73
+ ## Harness evaluations
74
+
75
+ The chart above counts characters. It says nothing about what a harness costs to
76
+ actually use, or whether it finishes the job. That is what the eval suite is for:
77
+ the same tasks, the same model and the same frozen price list, with only the
78
+ harness changing.
79
+
80
+ <!-- eval-summary -->
81
+
82
+ **259 attempts across 7 harnesses and 24 tasks**, all on `deepseek-v4.1-flash`.
83
+
84
+ | Harness | Solved | Cost/attempt | Prompt tokens | Sent before any work |
85
+ | --- | --- | --- | --- | --- |
86
+ | Pi | 34/37 | $0.0038 | 112,681 | 5,522 |
87
+ | SpecPi | 35/37 | $0.0042 | 134,859 | 13,799 |
88
+ | OpenCode | 31/37 | $0.0043 | 143,830 | 38,097 |
89
+ | SpecPi + Jev | 35/37 | $0.0046 | 153,077 | 13,799 |
90
+ | Codex CLI | 30/37 | $0.0067 | 242,031 | 38,148 |
91
+ | Oh My Pi | 35/37 | $0.0070 | 257,783 | 62,612 |
92
+ | DeepSeek Harness | 35/37 | $0.0078 | 281,676 | 31,750 |
93
+
94
+ Cost is the harness's own model spend, priced from recorded usage against a
95
+ dated price file. The last column is the tool schema plus system instructions
96
+ riding every single request, which is the fixed toll a harness charges before
97
+ the model does anything.
98
+
99
+ <!-- /eval-summary -->
100
+
101
+ Read the method, the charts and the caveats on the
102
+ [evaluations page](https://tannermidd.github.io/SpecPi/evaluations/). The runner,
103
+ the tasks and the recorded results are in [evals/](evals/), and this table is
104
+ regenerated from those reports by `node scripts/eval-site.mjs`, so it cannot
105
+ drift from the published figures.
106
+
73
107
  ## Install
74
108
 
75
109
  Requires Node.js 22.19+, Git, npm, and an existing Pi installation on PATH.
@@ -89,7 +123,7 @@ Full setup options, package details, and requirements: [website](https://tannerm
89
123
 
90
124
  | | |
91
125
  | --- | --- |
92
- | [Packages](https://tannermidd.github.io/SpecPi/#packages) | The seven pinned packages and what each provides |
126
+ | [Packages](https://tannermidd.github.io/SpecPi/#packages) | The eight pinned packages and what each provides |
93
127
  | [Scope control](https://tannermidd.github.io/SpecPi/wiki/#scope) | `/scope` commands and drift monitoring |
94
128
  | [Improvement loop](https://tannermidd.github.io/SpecPi/#loop) | Local wishlist, `/harness-improvement`, and retirement with evidence |
95
129
  | [SpecPi Chat](https://tannermidd.github.io/SpecPi/#vscode-chat) | VS Code frontend and VSIX install · [Chat guide](https://github.com/TannerMidd/SpecPi/blob/main/vscode/README.md) |
package/SECURITY_MODEL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Security model
2
2
 
3
- SpecPi provides scope monitoring and an explicit harness improvement loop, and installs six pinned packages as its default base. Extensions run as trusted code with Pi's permissions. Scope is not an OS sandbox or a general command guard. Use OS isolation for hostile code.
3
+ SpecPi provides scope monitoring, an explicit harness improvement loop and an optional Jev advisor, and installs eight pinned packages as its default base. Extensions run as trusted code with Pi's permissions. Scope is not an OS sandbox or a general command guard. Use OS isolation for hostile code.
4
4
 
5
5
  ## Scope monitoring
6
6
 
@@ -14,6 +14,40 @@ Optional tool groups ship withdrawn. The `request_capability` tool lets the mode
14
14
 
15
15
  `/capability allow <name>` records a standing grant so that group is offered without a prompt, and `/capability ask <name>` returns it to prompting. Recording either requires an interactive human command, and a grant that fires still announces itself in the session. Grants live in `<agent-dir>/specpi/capabilities/settings.json`, written atomically with owner-only permissions; links and irregular files are refused, and an unreadable file, a foreign schema or an unknown capability name reads as no grant. A standing grant removes the prompt, not the requirement for a human: headless sessions are still refused, so a granted capability cannot be activated by an unattended run. It also does not offer the group at startup — the model must still ask, and the group stays withdrawn until it does.
16
16
 
17
+ ## Jev advisor
18
+
19
+ The advisor is the only part of SpecPi that sends anything off this machine, and it ships off. With the master switch off there is no network call, no key read, no consent read and no prompt injection: the harness behaves exactly as it did before the extension existed.
20
+
21
+ What is sent: a summary object of at most 1 KB per call to `openrouter.ai`, which is where Jev is published and the default route, or to `api.typesafe.ai` when `JEV_BACKEND=typesafe` selects the direct API. Either way, over HTTPS. It carries tool names, byte counts, relative paths, entry counts, short descriptions and a bounded sample of lines from the material being judged. `sanitize.mjs` is the only module permitted to build one, so the rule is enforced in one place rather than promised in several: credentials, tokens, JWTs, emails and URLs are redacted, absolute paths are relativized, and anything still over budget is truncated rather than sent. Redaction reduces exposure; it does not guarantee that a model-authored string carries nothing sensitive.
22
+
23
+ Earlier versions of this document said file contents and command output were "refused outright". That was never accurate and is corrected here. Deciding whether a tool result is spent, or whether a fetched page is addressing the agent, cannot be done from byte counts alone, so `outline()` has always sent a sample of the result's own lines: up to six from the head and two from the tail, each collapsed to at most 80 characters and passed through the same redaction as everything else. As of 0.27.0 it also samples up to four lines evenly spaced through the middle, because an instruction planted in a fetched page is rarely in its first six lines and a digest that could never contain one would ask system 7 a question its own state made unanswerable. So the accurate statement is: **a bounded, redacted sample of at most twelve short lines per result, inside a 1 KB total budget** &mdash; not the whole content, and not none of it. What the sample cannot include is anything beyond that budget, which is enforced by truncation rather than by intention.
24
+
25
+ Consent is separate from the switch. The first time any system would transmit, an interactive dialog names the endpoint, the shape of the data and the byte budget. Without an interactive human nothing is sent, so an unattended run cannot be the thing that starts transmitting. The grant lives in `<agent-dir>/specpi/jev/consent.json` and is bound to the host it was given for, so switching backends asks again rather than carrying an old answer to a new destination. `/jev forget` revokes it.
26
+
27
+ Every call appends one line to `<agent-dir>/specpi/jev/transmissions.jsonl`: timestamp, system, question keys, state byte count, a SHA-256 of the exact payload, latency and the gated outcome. The payload itself is never written. `/jev ledger` reads it back, so "only digests are sent" is checkable rather than asserted. The ledger is user data and survives uninstall.
28
+
29
+ The advisor also keeps `<agent-dir>/specpi/jev/usage.json`, the running call count for the current session, written atomically with owner-only permissions. It exists because the ledger has no session boundary in it, so nothing outside the advisor's own process could say what *this* session had spent, and the count is what SpecPi Chat shows beside each budget. It is the one file in this layer meant to be read by another process, and it is deliberately the least interesting one: counts per system, the budgets they are counted against, a session identifier and two timestamps. No state, no questions, no answers, not even the ledger's digests. It is written only while the master switch is on, so a layer nobody has enabled leaves no trace of having been installed, and the last session's counts are kept rather than deleted at shutdown, because "this has never run" and "the session that just ended spent its whole budget" are different facts and a reader should be able to tell them apart. One file serves the directory, as the settings file does, so where several Pi sessions share an agent directory it describes whichever wrote to it last; it carries a session identifier, two timestamps and an active flag so a reader can say which rather than having to assume.
30
+
31
+ The advisor holds no authority. It never grants a capability, never calls a tool and never allows one. Its only blocking action is to refuse a capability-gap report that looks like it carries a credential, which asks the model to rewrite its own text and discards nothing. Failure is silent, not closed: a timeout, HTTP error, missing key, missing consent, an exhausted per-system or session budget, or ungated confidence produces no advice, and the existing code path runs unchanged. Tool-result retention can shorten a large read-only result before it is appended; it never alters a write, edit or error result, and the replacement says the output is recoverable by re-running.
32
+
33
+ Two of the seven systems do something the other five do not, and both are off by default like everything else here.
34
+
35
+ **System 5, progress detection**, is the only one that can change what the model does. On a confident verdict that a session has stopped making progress it adds one fixed, code-written line at a turn boundary. It ships set to `notify`, which tells the person and cannot change the session; `message`, which appends the line for the model to read, is a deliberate opt-in. It is written once per session and never retracted, it never aborts or blocks, and it is suppressed entirely when the same call reports that a person would have to answer something first. Nothing the model produced ever enters the transcript: the line is chosen from a fixed table by failure mode.
36
+
37
+ **System 7, untrusted-content classification**, prepends a fixed warning line to externally fetched content that confidently reads as instructions addressed to an agent. It applies only to web and browser tool results, never to the agent's own shell output or file reads. It is defence in depth and explicitly not a control: it never blocks, it has no authority over what the model then does, and it should not be relied on to stop prompt injection. Its value is that untrusted content in a fetched page is currently owned by nothing at all.
38
+
39
+ An earlier version of this document said its false-positive rate was measurable on tier 5 of the eval suite. It is not, and the re-run that checked found out why: **this system has never been called once**. It rides on a web or browser tool result, no task in any tier produces one, and the run that would have exercised Browser QA keeps it withdrawn because capability arming needs an interactive human. So the honest statement is that system 7 ships unmeasured, and the eval suite as it stands cannot measure it. That is a gap in the suite, not a property of the system, and it is stated here rather than left as an absent number that would read as a zero.
40
+
41
+ Settings live in `<agent-dir>/specpi/jev/settings.json`, written atomically with owner-only permissions. Links, irregular files, oversize files, an unrecognised schema and unparseable contents all read as off. The one exception is the layer's own previous schema: a `schema: 1` file is migrated forward rather than read as unrecognised, because "collapse to all-off" is a rule for corrupt input and applying it to our own earlier version would silently disable a layer the user had switched on. Call budgets are per system under a session total, so one busy system cannot exhaust the allowance of the others and leave them dead for the rest of the session with event ordering deciding which one won. Session toggles write no startup preference. The key is read from the environment only; Pi authentication, credentials, trust decisions, sessions and history are never read.
42
+
43
+ ## Command policy and the Jev guard
44
+
45
+ `specpi-jev-guard` is pinned in the base set but ships **inert**. Its own default is `enabled: true`, so left alone a fresh install would begin gating shell and file calls through a third-party service immediately; SpecPi writes `enabled: false` and `/jev guard on` is how a human opts in. `@gotgenes/pi-permission-system` stays pinned and, while the guard is off, decides every tool call exactly as it did before the guard existed.
46
+
47
+ Be precise about what the guard does once it is on, because it cannot be configured away. The guard is **fail-closed by design**: with no key, an unreachable endpoint, or a middle-band verdict in a session with no UI, it blocks the call and reports why. There is no setting that hands the decision back to the permission system instead. So switching it on accepts that an outage stops gated work until it is switched off again. That trade is the user's to make, which is why it ships off and why `/jev status` and `specpi doctor` state plainly whether it is on, which backend it uses and whether that backend's key is present.
48
+
49
+ SpecPi asserts only three fields in `<home>/.pi/jev-guard.json` and merges them into whatever is already there, so a user's own thresholds, safe-command globs and protected paths survive: `enabled`, `uncertain: "ask"` so a middle-band verdict asks a human wherever there is one to ask, and `backend: "typesafe"` so the guard and the advisor read the same `TYPESAFE_API_KEY` rather than the guard needing a separate `OPENROUTER_API_KEY`. Those fields are rewritten at every session start, outside the advisor's master switch, because whether the guard is inert is a property of the install rather than a feature of the advisor — and off is a written configuration, not an absence of one. SpecPi writes only the global file; a project-local override under a workspace's `.pi/` remains the user's to make. Neither the guard nor the advisor is an OS sandbox.
50
+
17
51
  ## Improvement authority and evidence
18
52
 
19
53
  Collection is off by default. Enabling it permits sanitized gap observations, not implementation. Only an exact human `/harness-improvement` selection authorizes a wishlist-sourced change. The selected contract is bound to the gap, source checkout, session, and selection generation. Web access tools (`web_search`, `source_check`, `fetch_content`, `get_search_content`) are hidden until a human offers them, through `/webaccess on` or by accepting a `request_capability` prompt.
@@ -26,7 +60,7 @@ Wishlist records remain local under `<agent-dir>/specpi/`. Sanitization and salt
26
60
 
27
61
  Package acquisition requests exact npm dependency saves through the child process environment and verifies the installed top-level versions before completing the transaction. A mismatch fails the operation and triggers managed-state rollback. Upstream transitive dependency ranges remain outside this pinning guarantee.
28
62
 
29
- `plan` is read-only. Installation, updates, and removal require confirmation or `--yes`. SpecPi manages its two first-party extension families, improvement skill, manifest, AGENTS marker block, and the six package entries listed in `templates/settings.json`. Normal install/update runs `pi install npm:<name>@<pin>` for every default package. Only package entries are merged; existing resource filters and unrelated settings are retained. `--skip-package-install` allows a core-only install or preserves an already configured base during update, skipping Chromium setup too. Confirmed install/update otherwise invokes the installed Browser QA Node bin with bounded subprocesses to download Chromium and check readiness. `--skip-browser-install` skips that setup only; doctor still checks readiness. SpecPi never passes `--with-deps`, auto-installs OS libraries, or installs/removes Bun. No new shell profile integration is installed.
63
+ `plan` is read-only. Installation, updates, and removal require confirmation or `--yes`. SpecPi manages its two first-party extension families, improvement skill, manifest, AGENTS marker block, and the eight package entries listed in `templates/settings.json`. Normal install/update runs `pi install npm:<name>@<pin>` for every default package. Only package entries are merged; existing resource filters and unrelated settings are retained. `--skip-package-install` allows a core-only install or preserves an already configured base during update, skipping Chromium setup too. Confirmed install/update otherwise invokes the installed Browser QA Node bin with bounded subprocesses to download Chromium and check readiness. `--skip-browser-install` skips that setup only; doctor still checks readiness. SpecPi never passes `--with-deps`, auto-installs OS libraries, or installs/removes Bun. No new shell profile integration is installed.
30
64
 
31
65
  Managed configuration and files are locked and backed up before mutation; first-party writes are atomic and checksum-tracked. Failure restores the saved configuration and first-party files. Package acquisition runs upstream package-manager scripts and may leave downloads, dependency changes, or external script effects even after configuration rollback. Those effects, including downloaded browser-cache bytes, are outside SpecPi's transaction and survive uninstall. Updates require `--force` before replacing modified retained resources. Retired resources are backed up before deactivation; pre-install files are restored where ownership records identify them. Old runtime directories are moved into backups without inspecting their contents. Backups and private evidence remain after uninstall and can contain sensitive local material.
32
66
 
package/THIRD_PARTY.md CHANGED
@@ -11,7 +11,7 @@ Development formatting uses Prettier **3.9.6** (MIT), ESLint **10.9.1** (MIT), `
11
11
 
12
12
  ## Default packages
13
13
 
14
- Reviewed on 2026-09-16 against published npm metadata and integrity-verified source archives. All seven top-level packages declare the MIT license. Pins are authoritative in `templates/settings.json`.
14
+ Reviewed on 2026-09-16 against published npm metadata and integrity-verified source archives. All eight top-level packages declare the MIT license. Pins are authoritative in `templates/settings.json`.
15
15
 
16
16
  SpecPi requests exact npm dependency saves for these pins and checks installed top-level versions before completing installation. The override applies to package acquisition without changing the user's global npm configuration.
17
17
 
@@ -24,6 +24,7 @@ SpecPi requests exact npm dependency saves for these pins and checks installed t
24
24
  | pi-goal-x | 0.31.2 | [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-x) |
25
25
  | @sreetej510/pi-usage | 0.10.0 | [Sreetej510/pi-extensions](https://github.com/Sreetej510/pi-extensions) |
26
26
  | @gotgenes/pi-permission-system | 32.0.2 | [gotgenes/pi-packages](https://github.com/gotgenes/pi-packages) |
27
+ | specpi-jev-guard | 0.1.0 | [TannerMidd/specpi-jev-guard](https://github.com/TannerMidd/specpi-jev-guard) |
27
28
 
28
29
  Transitive dependencies and their notices remain in Pi's npm installation tree. Top-level pins do not freeze upstream dependency ranges or constitute a full transitive security audit. Pi invokes npm with its upstream package-management semantics, including dependency lifecycle scripts. Browser QA has no install hook: confirmed SpecPi install/update explicitly invokes its installed Node setup bin, unless acquisition or browser setup is skipped. It downloads package-pinned Chromium without installing OS libraries. BetterWright is optional/manual and retains its own Bun-based setup; SpecPi neither removes Bun nor deletes user-owned tools. Usage reporting and web packages make their own provider/service connections. Consult upstream licenses and security policies before redistributing their components.
29
30
 
@@ -31,6 +32,14 @@ Pi Lens is no longer a default package. Normal updates retire only unchanged ent
31
32
 
32
33
  The combined base is tested with Pi 0.84.4. Pi Goal X declares Pi `>=0.83.0 <0.85.0`; compatibility with newer hosts is not assumed. SpecPi's former custom browser, structural-search, background-task, and command-guard implementations, DonSeTch, and Pi themes have been removed. Its delegation and experiment implementations were not discarded: they now ship as the independent `specpi-delegation` and `specpi-experiments` packages described below. Removal restores owned package settings but does not delete downloaded upstream packages or tools. Retired private runtimes remain in local backups with their notices.
33
34
 
35
+ ## Network services
36
+
37
+ The optional Jev advisor and `specpi-jev-guard` both reach TypeSafe's System One endpoint. Neither is a bundled dependency: the advisor is first-party source that uses Node's built-in `fetch` and declares no package, and the guard is a pinned MIT package with no declared dependencies. Both require a key supplied by the user through the environment -- `OPENROUTER_API_KEY` on the default OpenRouter route, `TYPESAFE_API_KEY` when `JEV_BACKEND=typesafe` selects the direct API. The two are not interchangeable: the other service rejects the wrong one with a bare 401. SpecPi never provisions, stores or reads provider credentials for them.
38
+
39
+ Requests go to `openrouter.ai` by default, or to `api.typesafe.ai` on the direct backend, unless `TYPESAFE_BASE_URL` redirects them, which exists so tests never reach the network and the eval proxy can price advisor traffic. Retention and processing of what is sent are governed by TypeSafe and, on the default route, by OpenRouter as well -- not by SpecPi; the advisor ships off, asks before its first transmission and records a hash of every payload locally. The frozen eval price list carries `jev-1.13.0` at $0.042/MTok input and free output so a measured SpecPi + Jev row never reports a cost that excludes its own advisor.
40
+
41
+ Worth stating plainly, because it is the part a reader is most likely to assume otherwise: what leaves the machine is a bounded sample of the material being judged, not only counts about it. A tool result contributes up to twelve short lines of its own text, redacted and inside a 1 KB budget, because whether a result is spent or whether a fetched page is addressing the agent cannot be decided from byte counts. When the untrusted-content system is enabled, that sample is drawn from externally fetched pages and browser snapshots as well as from local output. [`SECURITY_MODEL.md`](SECURITY_MODEL.md#jev-advisor) states the exact bound and where it is enforced.
42
+
34
43
  ## Standalone delegation and experiments
35
44
 
36
45
  `packages/delegation` and `packages/experiments` are first-party packages extracted from SpecPi's own retired harness code under MIT. Neither bundles third-party runtime code or declares a production dependency; every runtime import is a Node builtin or a Pi-supplied optional peer. Experiments invokes the user's own `git` as an external program through Pi's `exec` seam; Git is not bundled, vendored or version-pinned. Delegation's extraction drops the Command Guard admission path, which SpecPi no longer ships, and reports its guard posture as `absent`. See their dependency notices and security boundaries: [delegation](packages/delegation/THIRD_PARTY.md) / [boundary](packages/delegation/SECURITY.md), [experiments](packages/experiments/THIRD_PARTY.md) / [boundary](packages/experiments/SECURITY.md).
@@ -0,0 +1,276 @@
1
+ // The only thing in this extension that touches the network. Systems are questions plus a gate;
2
+ // they never hold a client, so two systems firing at one hook cost one call rather than two.
3
+ //
4
+ // Every gate is checked here, in one order, before anything leaves the process: master switch,
5
+ // per-system switch, human consent, per-system budget, session total budget, then the byte budget
6
+ // inside sanitize.
7
+ //
8
+ // AWAIT ONLY THE SYSTEMS THAT MUTATE WHAT THEY INSPECT. Retention must be awaited, because its
9
+ // answer replaces the tool result it was asked about; so must compaction, the branch hook and the
10
+ // two tool_call systems, which return a patch or edit `event.input` in place. A system that acts on
11
+ // a later turn must not be awaited: at roughly 300ms a call, a turn-level system firing thirty
12
+ // times would add nine seconds to an attempt that takes a hundred and thirty, to deliver advice
13
+ // that was never going to change the turn it was asked during.
14
+
15
+ import { randomUUID } from "node:crypto";
16
+ import { SYSTEM_NAMES, loadSettings } from "./config.mjs";
17
+ import { ensureConsent } from "./consent.mjs";
18
+ import { buildState } from "./sanitize.mjs";
19
+ import { ask } from "./client.mjs";
20
+ import { payloadDigest, record } from "./ledger.mjs";
21
+ import { writeUsage } from "./usage.mjs";
22
+
23
+ export const SYSTEM_LABELS = Object.freeze({
24
+ retention: "Tool-result retention",
25
+ compaction: "Compaction guidance",
26
+ gap: "Capability-gap triage",
27
+ sources: "Delegation source ranking",
28
+ progress: "Progress and thrash detection",
29
+ untrusted: "Untrusted-content classification",
30
+ capability: "Turn-zero capability arming",
31
+ });
32
+
33
+ export function createBroker(options = {}) {
34
+ // Injected in tests so master-off can be proven as "the transport was never reached" rather
35
+ // than "no socket was observed".
36
+ const transport = options.ask ?? ask;
37
+ const readSettings = options.loadSettings ?? loadSettings;
38
+ const resolveConsent = options.ensureConsent ?? ensureConsent;
39
+ const write = options.record ?? record;
40
+ // Separate from `record` because it answers a different question and is read by a different
41
+ // reader. The ledger is an audit trail for a person; this is a live counter for SpecPi Chat,
42
+ // which runs in another process and cannot see `callsUsed`.
43
+ const publish = options.recordUsage ?? writeUsage;
44
+
45
+ let callsUsed = 0;
46
+ let generation = 0;
47
+ let session = "";
48
+ let startedAt = "";
49
+ const usedBySystem = new Map();
50
+ const effects = new Map();
51
+ const warned = new Set();
52
+
53
+ /** Zero counts for every system, so a reader never has to distinguish absent from unused. */
54
+ const snapshot = (active) => {
55
+ const settings = readSettings();
56
+
57
+ return {
58
+ schema: 1,
59
+ session,
60
+ startedAt,
61
+ updatedAt: new Date().toISOString(),
62
+ active,
63
+ calls: callsUsed,
64
+ budgets: settings.budgets,
65
+ systems: Object.fromEntries(
66
+ SYSTEM_NAMES.map((name) => [
67
+ name,
68
+ {
69
+ calls: usedBySystem.get(name) ?? 0,
70
+ applied: effects.get(name)?.applied ?? 0,
71
+ failed: effects.get(name)?.failed ?? 0,
72
+ savedBytes: effects.get(name)?.savedBytes ?? 0,
73
+ },
74
+ ]),
75
+ ),
76
+ };
77
+ };
78
+
79
+ const clear = () => {
80
+ callsUsed = 0;
81
+ usedBySystem.clear();
82
+ effects.clear();
83
+ warned.clear();
84
+ // Bumped before anything else so an answer still in flight from the previous session is
85
+ // discarded rather than counted against the new one.
86
+ generation += 1;
87
+ };
88
+
89
+ /**
90
+ * Publishing is itself gated on the master switch. With the layer off this extension is meant
91
+ * to leave no trace at all, and a counts file appearing in every Pi session on every machine
92
+ * that merely has SpecPi installed is a trace. Once a call has been made there is something
93
+ * worth saying, so the count keeps being published for the rest of the session even if the
94
+ * master switch is turned back off.
95
+ */
96
+ const publishIf = (active) => {
97
+ if (callsUsed > 0 || readSettings().master === true) {
98
+ publish(snapshot(active));
99
+ }
100
+ };
101
+
102
+ const reset = () => {
103
+ clear();
104
+ session = randomUUID();
105
+ startedAt = new Date().toISOString();
106
+ publishIf(true);
107
+ };
108
+
109
+ /**
110
+ * End of session. The counts are published one last time with `active` false rather than
111
+ * cleared, because a reader that found no file could not tell "this layer has never run" from
112
+ * "the session that just ended spent its whole budget", and the second is the more useful
113
+ * thing to be able to see after the fact.
114
+ */
115
+ const finish = () => {
116
+ publishIf(false);
117
+ clear();
118
+ };
119
+
120
+ /**
121
+ * Running out of budget used to be indistinguishable from a system that had nothing to say.
122
+ * Both produce silence, and silence is this layer's normal state, so a session could spend an
123
+ * hour with retention switched on and quietly dead without anything ever saying so. The notice
124
+ * fires once per system per session -- repeating it every turn would be its own nuisance -- and
125
+ * only where there is a human to read it.
126
+ */
127
+ const warnExhausted = (system, ctx, scope) => {
128
+ if (warned.has(system) || !ctx?.hasUI || typeof ctx?.ui?.notify !== "function") {
129
+ return;
130
+ }
131
+
132
+ warned.add(system);
133
+ try {
134
+ ctx.ui.notify(
135
+ scope === "system"
136
+ ? `Jev: the ${SYSTEM_LABELS[system] ?? system} budget for this session is spent, so that system is now off until the session ends. Raise it in SpecPi Chat under package settings, or in the Jev layer's own settings file.`
137
+ : `Jev: this session's total call budget is spent, so the whole advisor is now quiet until the session ends. Raise it in SpecPi Chat under package settings, or in the Jev layer's own settings file.`,
138
+ "info",
139
+ );
140
+ } catch {
141
+ // A notice that cannot be delivered must not fail the call it was reporting on.
142
+ }
143
+ };
144
+
145
+ const status = () => {
146
+ const settings = readSettings();
147
+
148
+ return {
149
+ master: settings.master,
150
+ systems: settings.systems,
151
+ session,
152
+ callsUsed,
153
+ budgets: settings.budgets,
154
+ usedBySystem: Object.fromEntries(usedBySystem),
155
+ };
156
+ };
157
+
158
+ /**
159
+ * Ask one batch for one system. Returns `{ ok: false, reason }` for every refusal so a caller
160
+ * can log why it got no advice without having to distinguish "switched off" from "timed out".
161
+ *
162
+ * `decide` is how the ledger learns what the advice did. The ledger recorded bytes sent and
163
+ * never whether the answer was taken, so a system's effect could only be inferred from a cost
164
+ * delta it may not have caused. The callback runs here, before the ledger write, because that
165
+ * is the only point where the answers and the audit line exist together; its `decision` is
166
+ * handed back so the caller does not gate the same answers twice.
167
+ */
168
+ const request = async ({ system, state, questions, ctx, root, maxBytes, timeoutMs, signal, decide }) => {
169
+ const settings = readSettings();
170
+ if (!settings.master) {
171
+ return { ok: false, reason: "master-off", answers: {} };
172
+ }
173
+
174
+ if (settings.systems[system] !== true) {
175
+ return { ok: false, reason: "system-off", answers: {} };
176
+ }
177
+
178
+ // Per-system first, so an exhausted turn-level system reports its own exhaustion rather
179
+ // than looking like the session as a whole ran out.
180
+ if ((usedBySystem.get(system) ?? 0) >= (settings.budgets?.[system] ?? 0)) {
181
+ warnExhausted(system, ctx, "system");
182
+
183
+ return { ok: false, reason: "system-budget-exhausted", answers: {} };
184
+ }
185
+
186
+ if (callsUsed >= (settings.budgets?.total ?? 0)) {
187
+ warnExhausted("total", ctx, "total");
188
+
189
+ return { ok: false, reason: "budget-exhausted", answers: {} };
190
+ }
191
+
192
+ const consented = await resolveConsent(ctx, SYSTEM_LABELS[system] ?? system);
193
+ if (!consented) {
194
+ return { ok: false, reason: "no-consent", answers: {} };
195
+ }
196
+
197
+ // Settings can change while the dialog is open, and a session can end under it.
198
+ const current = readSettings();
199
+ if (!current.master || current.systems[system] !== true) {
200
+ return { ok: false, reason: "master-off", answers: {} };
201
+ }
202
+
203
+ const built = buildState(state, { root, maxBytes });
204
+ const questionKeys = Object.keys(questions);
205
+ callsUsed += 1;
206
+ usedBySystem.set(system, (usedBySystem.get(system) ?? 0) + 1);
207
+ const startedGeneration = generation;
208
+ const result = await transport(built.state, questions, { timeoutMs, signal });
209
+ // The session can end under a call that was never awaited, which is the normal shape of a
210
+ // turn-level system: the payload has already left the machine, and the answer now belongs
211
+ // to a session that no longer exists. It must not be acted on. It must still be recorded --
212
+ // the ledger's whole claim is that every transmission appears in it, and a run that sent 44
213
+ // and logged 43 is how this was found. So the line is written either way and says which.
214
+ const stale = startedGeneration !== generation;
215
+
216
+ // A gate that throws must not turn into a failed call: the caller's own catch would have
217
+ // swallowed it anyway, and recording it as unapplied is the truthful line.
218
+ let outcome = { applied: false };
219
+ if (!stale && result.ok && typeof decide === "function") {
220
+ try {
221
+ outcome = decide(result.answers) ?? { applied: false };
222
+ } catch {
223
+ outcome = { applied: false, gateThrew: true };
224
+ }
225
+ }
226
+
227
+ write({
228
+ system,
229
+ questionKeys,
230
+ stateBytes: built.bytes,
231
+ stateTruncated: built.truncated,
232
+ payloadSha256: payloadDigest({ state: built.state, questions }),
233
+ ok: result.ok,
234
+ reason: result.ok ? undefined : result.reason,
235
+ // A sent payload whose answer arrived too late to use. Distinguished from a refusal,
236
+ // because nothing was refused: it was asked, answered, and discarded.
237
+ discarded: stale ? true : undefined,
238
+ // Whether the advice changed anything, and what it saved when the change was a
239
+ // shortening. Zero is a real answer here and means "asked, and kept the result whole".
240
+ applied: outcome.applied === true,
241
+ // And why not, when nothing changed. Without this a system that asks and never acts is
242
+ // indistinguishable from one whose gate can never be satisfied, which is the exact
243
+ // failure the calibration pass had to go looking for by hand.
244
+ outcome: stale ? "session-changed" : typeof outcome.reason === "string" ? outcome.reason : undefined,
245
+ savedBytes: Number.isFinite(outcome.savedBytes) ? Math.max(0, Math.round(outcome.savedBytes)) : 0,
246
+ gateThrew: outcome.gateThrew === true ? true : undefined,
247
+ latencyMs: result.latencyMs,
248
+ model: result.model,
249
+ // Values only, never the state that produced them: enough to plot a calibration curve.
250
+ answers: Object.fromEntries(
251
+ Object.entries(result.answers ?? {}).map(([name, answer]) => [
252
+ name,
253
+ { kind: answer.kind, value: answer.value, confidence: answer.confidence },
254
+ ]),
255
+ ),
256
+ });
257
+
258
+ if (stale) {
259
+ // Counted against the session it was made in, which has already been published and
260
+ // cleared. Adding it to the new session's running total would attribute one session's
261
+ // spend to the next one.
262
+ return { ok: false, reason: "session-changed", answers: {} };
263
+ }
264
+
265
+ const effect = effects.get(system) ?? { applied: 0, failed: 0, savedBytes: 0 };
266
+ effect.applied += outcome.applied === true ? 1 : 0;
267
+ effect.failed += result.ok ? 0 : 1;
268
+ effect.savedBytes += Number.isFinite(outcome.savedBytes) ? Math.max(0, Math.round(outcome.savedBytes)) : 0;
269
+ effects.set(system, effect);
270
+ publishIf(true);
271
+
272
+ return { ...result, decision: outcome.decision };
273
+ };
274
+
275
+ return { request, reset, finish, status };
276
+ }