specpi 0.33.0 → 0.35.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,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0 - 2026-09-26
4
+
5
+ - Replace the Jev command guard with a local one. `specpi-lancet-guard` 0.1.0, new in `packages/lancet-guard`, takes the eighth pin from `specpi-jev-guard` 0.4.0. It scores shell commands with LANCET Nano v0.3.0, a 110M-parameter CodeT5-base classifier, on the CPU through ONNX Runtime: no API key, and commands never leave the machine. It keeps the Jev guard's local rules in front of the model. A risky verdict asks, Nano's unsure band asks, PowerShell and commands LANCET cannot read ask, and protected writes ask. A missing or damaged model fails closed.
6
+ - Ship it off. `/lancet-guard setup` downloads the model once as the release ZIP from the pinned LANCET-model GitHub release, which works where Hugging Face is blocked. The ZIP and each of the three files taken from it are refused unless their SHA-256 matches. `/lancet-guard on` switches the guard on for a session and `--global` saves that. SpecPi no longer writes any guard configuration, so a saved choice survives updates. An update retires the old Jev guard pin, and says so if that guard was switched on.
7
+ - Gate background jobs with the guard instead of refusing them while it is on: it checks the `background` tool's command as it checks `bash`.
8
+ - Match LANCET Nano's Python runtime exactly. A Node port of its tokenizer and scoring gave identical token ids and bands on 7,170 commands, with scores within 2.2e-16 on the same machine (ONNX Runtime's INT8 kernels move scores by up to 0.03 on some other CPUs, with the same bands on every reference command), at about 23 ms a command and 275 MB once loaded.
9
+ - Skip onnxruntime-node's CUDA download during package installation (`ONNXRUNTIME_NODE_INSTALL=skip`). The guard never uses the GPU.
10
+ - Pair this release with SpecPi Chat 0.18.0, whose footer counter reads the LANCET guard's `lancet-guard` status key in place of the retired Jev guard's.
11
+
12
+ ## 0.34.0 - 2026-09-24
13
+
14
+ - Steer long waits into background jobs instead of relying on the agent to remember. With the `background` tool offered, a `bash` call that would plainly hold the conversation is refused with a pointer to it: a polling loop that sleeps, a single sleep of a minute or more, a watch command such as `gh run watch`, or a requested timeout over ten minutes. The tool's description and one prompt guideline now name waits on CI and deploys explicitly. Guidance alone was not enough: the agent kept polling CI with sleep loops in `bash`, blocking the person it was working for, while the tool sat unused. Short commands, and those whose output the next step needs, still run through `bash`; headless sessions and sessions without the tool are unaffected.
15
+ - Rebuild the evaluations page on SpecPi as users get it. A 24 Sep Terminal-Bench 2.0 sitting ran the published 0.33.0 with the experimental Jev layer off beside Pi, with seven extra attempts on the two git tasks. SpecPi solved 30/38 against Pi's 25/39 (p = 0.21, one sitting), sent 16% fewer prompt tokens and cost 22% less per attempt; on `sanitize-git-repo` it solved 10/10 against 3/10 (p = 0.003), with `fix-git` at 10/10 for both. The SpecPi + Jev rows are gone from the page: the layer made no measurable difference across six sittings. Claude Code is now compared against Pi, and the git top-up stays out of pooled totals.
16
+ - Add a second 24 Sep sitting for SpecPi 0.33.0 beside Oh My Pi: SpecPi solved 32/39, Oh My Pi 25/38 (p = 0.12). SpecPi pools to 62/77 over its two sittings.
17
+ - Drop bare Pi's lowest (21/39) and highest (32/39) sittings, one from each end so its average does not move; the page names both. Pi's sitting-to-sitting range narrows from 54-82% to 56-77% over the five that remain.
18
+ - Put SWE-bench Verified on the evaluations page beside Terminal-Bench throughout: head to head, every chart and table, and task by task, 12 tasks at three attempts. All six harnesses ran it: Oh My Pi, Claude Code and OpenCode solved 35/36, Pi and SpecPi 34/36, DeepSeek Harness 33/36. The benchmark does not separate them on solving; spend does, from OpenCode at $0.0049 per attempt to Claude Code at $0.0140. The two benchmarks sit side by side and are never pooled.
19
+ - Run the Terminal-Bench git pair, `sanitize-git-repo` and `fix-git` at seven attempts each, for the other four harnesses too. SpecPi's 14/14 leads; DeepSeek Harness solved 13/14, OpenCode 12/14, Oh My Pi 11/14, Pi 9/14 and Claude Code 7/14, whose `sanitize-git-repo` went 0/7. `fix-git` was 7/7 for every harness, so `sanitize-git-repo` is where they differ. The pair stays out of pooled totals.
20
+ - Redesign the evaluations page around charts: four headline figures, a head-to-head diagram for SpecPi against Pi, a task heatmap, and one-line captions in place of the long notes.
21
+ - Derive the page's attempt, sitting and unscored-setup counts from the data instead of typing them, and quote the `sanitize-git-repo` result from it too.
22
+ - Publish this session's background jobs to SpecPi Chat, so its Jobs chip can open a panel that lists, times and stops them. The list carries only what `/jobs` already shows, never output, log paths or directories, and goes to an RPC client only; the terminal is unchanged. Pairs with Chat 0.17.0.
23
+ - The new background-job guideline grows the default prompt from 12,010 to 12,287 characters.
24
+ - Let the workflow-controls test harness finish when Windows still holds its temporary directory after a background job's process tree is killed; the cleanup now retries and gives up quietly instead of failing a passing run.
25
+
3
26
  ## 0.33.0 - 2026-09-23
4
27
 
5
28
  - Run long commands as background jobs, so a long eval or build no longer holds the conversation. 0.32.0 judged a background-task system not worth building from Terminal-Bench transcripts, which measure task outcomes and cannot see the actual cost: a person unable to say anything until a many-minute command returns. The `background` tool starts a command and returns at once; when the job ends, its exit code and last lines of output arrive as a follow-up message and the agent picks it up. `/jobs` lists jobs, shows recent output and stops them.
package/NPM_RELEASE.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # npm release
2
2
 
3
- This guide covers the root `specpi` package. Each package under `packages/` releases independently, with its own release process, tag prefix and publish workflow: [Browser QA](packages/browser-qa/NPM_RELEASE.md) (`browser-qa-v*`), [Delegation](packages/delegation/NPM_RELEASE.md) (`delegation-v*`) and [Experiments](packages/experiments/NPM_RELEASE.md) (`experiments-v*`). Package-only releases do not invoke the root publisher.
3
+ This guide covers the root `specpi` package. Each package under `packages/` releases independently, with its own release process, tag prefix and publish workflow: [Browser QA](packages/browser-qa/NPM_RELEASE.md) (`browser-qa-v*`), [Delegation](packages/delegation/NPM_RELEASE.md) (`delegation-v*`), [Experiments](packages/experiments/NPM_RELEASE.md) (`experiments-v*`) and [LANCET guard](packages/lancet-guard/NPM_RELEASE.md) (`lancet-guard-v*`). Package-only releases do not invoke the root publisher.
4
4
 
5
5
  Publishing, tags, deprecation, ownership changes and GitHub Releases require explicit human approval. Publication is a post-merge operation: never publish from an unmerged commit. npm versions are immutable.
6
6
 
7
7
  ## Prepare
8
8
 
9
- 1. This candidate is SpecPi **0.33.0**, paired with the Chat **0.16.0** VSIX. Browser QA **0.3.0** is already independently published; do not republish or edit its source as part of this release. Choose an unused version; update `package.json`, the dated `CHANGELOG.md` entry, README, and the website. Check Chat's version and VSIX download links. Preserve historical changelog entries.
9
+ 1. This candidate is SpecPi **0.35.0**, paired with the Chat **0.18.0** VSIX. Browser QA **0.3.0** and LANCET guard **0.1.0** are already independently published; do not republish or edit their source as part of this release. Choose an unused version; update `package.json`, the dated `CHANGELOG.md` entry, README, and the website. Check Chat's version and VSIX download links. Preserve historical changelog entries.
10
10
  2. Validate:
11
11
 
12
12
  ```sh
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  ---
30
30
 
31
- SpecPi 0.33.0 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.
31
+ SpecPi 0.35.0 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
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
 
@@ -53,7 +53,7 @@ The solid rows are measured by us, from the request each setup actually sends th
53
53
  <a href="https://tannermidd.github.io/SpecPi/research/#specpi">
54
54
  <picture>
55
55
  <source media="(prefers-color-scheme: dark)" srcset="https://tannermidd.github.io/SpecPi/media/context-chart-dark.svg">
56
- <img src="https://tannermidd.github.io/SpecPi/media/context-chart-light.svg" width="880" alt="Bar chart of characters sent on the first model call: Pi stock 5,521, SpecPi default 12,010, OpenCode 31,043, DeepSeek Harness 31,743, SpecPi enabled 36,658, Codex CLI 41,616, Oh My Pi 66,708, Claude Code 90,460.">
56
+ <img src="https://tannermidd.github.io/SpecPi/media/context-chart-light.svg" width="880" alt="Bar chart of characters sent on the first model call: Pi stock 5,521, SpecPi default 12,287, OpenCode 31,043, DeepSeek Harness 31,743, SpecPi enabled 36,935, Codex CLI 41,616, Oh My Pi 66,708, Claude Code 90,460.">
57
57
  </picture>
58
58
  </a>
59
59
  </p>
@@ -65,7 +65,7 @@ The gap between the two SpecPi bars comes from a few separate switches, so the e
65
65
  <a href="https://tannermidd.github.io/SpecPi/research/#specpi">
66
66
  <picture>
67
67
  <source media="(prefers-color-scheme: dark)" srcset="https://tannermidd.github.io/SpecPi/media/capability-chart-dark.svg">
68
- <img src="https://tannermidd.github.io/SpecPi/media/capability-chart-light.svg" width="880" alt="Bar chart of tool-schema characters each capability adds: Pi built-ins 2,896, Improvement loop 1,601, Capability request 995, Background jobs 657, Goals 1,315, Browser QA 8,234, Delegation 4,453, Web access 11,298. Browser QA, Delegation, Web access are hidden until switched on.">
68
+ <img src="https://tannermidd.github.io/SpecPi/media/capability-chart-light.svg" width="880" alt="Bar chart of tool-schema characters each capability adds: Pi built-ins 2,896, Improvement loop 1,601, Capability request 995, Background jobs 728, Goals 1,315, Browser QA 8,234, Delegation 4,453, Web access 11,298. Browser QA, Delegation, Web access are hidden until switched on.">
69
69
  </picture>
70
70
  </a>
71
71
  </p>
@@ -80,30 +80,32 @@ price list, with only the harness changing.
80
80
 
81
81
  <!-- eval-summary -->
82
82
 
83
- **840 scored attempts across 20 tasks and 6 harnesses**,
84
- all on `deepseek-v4.1-flash`.
83
+ **947 scored attempts on two benchmarks and 6 harnesses**,
84
+ all on `deepseek-v4.1-flash`. SpecPi is the published 0.33.0 release, with the experimental Jev layer off.
85
+
86
+ SWE-bench Verified, 12 tasks × 3: SpecPi solved 34/36 against Pi's 34/36
87
+ (p = 1.00), with 2% more prompt tokens and 1% less cost per attempt.
88
+
89
+ Terminal-Bench 2.0, 731 attempts across 20 tasks:
85
90
 
86
91
  | Harness | Solved | Rate | Cost/attempt | Prompt tokens | Cache hit |
87
92
  | --- | --- | --- | --- | --- | --- |
88
93
  | OpenCode | 29/39 | 0.744 | $0.0124 | 494,173 | 96.5% |
89
- | SpecPi + Jev | 197/269 | 0.732 | $0.0142 | 405,712 | 93.6% |
90
- | Pi (base) | 192/269 | 0.714 | $0.0158 | 451,229 | 92.9% |
91
- | Oh My Pi | 90/113 | 0.796 | $0.0216 | 1,103,136 | 97.4% |
94
+ | SpecPi | 62/77 | 0.805 | $0.0144 | 407,416 | 92.4% |
95
+ | Pi (base) | 164/230 | 0.713 | $0.0155 | 436,214 | 92.9% |
92
96
  | DeepSeek Harness | 21/38 | 0.553 | $0.0226 | 1,126,586 | 95.4% |
97
+ | Oh My Pi | 115/151 | 0.762 | $0.0237 | 1,108,076 | 96.5% |
93
98
  | Claude Code | 81/112 | 0.723 | $0.0273 | 670,828 | 95.4% |
94
99
 
95
- Solve rate does not separate them in the sittings where both ran: Pi against SpecPi + Jev is Fisher p = 0.70
96
- pooled and 0.66 paired, and the closest paired comparison of any two harnesses is
97
- OpenCode against DeepSeek Harness at p = 0.10. Nor can it at this
98
- sample size -- bare Pi, on unchanged software and the same thirteen tasks, spans
99
- 54-82% across 6 separate sittings, a wider gap than any measured here between two
100
- harnesses. Pooled across sittings, Oh My Pi leads DeepSeek Harness (p = 0.005) and SpecPi + Jev leads DeepSeek Harness (p = 0.034), but pooling sets one harness's sittings against another's.
101
-
102
- The rows pool different sittings, so compare them paired. In the sittings where both ran,
103
- SpecPi + Jev sent fewer prompt tokens than Pi in 4 of 5 (7–30% fewer, 27% more in the other),
104
- and cost less in 4 of 5, by about 5%: output tokens are most of the bill.
105
- Cost is recomputed from recorded tokens against a dated price file, never taken from a
106
- harness's self-report, and SpecPi + Jev's excludes the Jev advisor's own calls.
100
+ SpecPi and Pi ran side by side in the 24 Sep · a sitting. SpecPi solved 30/38
101
+ against Pi's 25/39 (Fisher p = 0.21), sending 16% fewer prompt tokens and costing
102
+ 22% less per attempt. On `sanitize-git-repo`, with that sitting's extra attempts, SpecPi solved
103
+ 10/10 against 3/10 (p = 0.003); `fix-git` was 10/10 for both.
104
+
105
+ Overall solve rate is a different matter: one sitting cannot rank harnesses here. Bare Pi, on
106
+ unchanged software and the same thirteen tasks, spans 56-77% across 5 sittings, a wider gap than any
107
+ measured between two harnesses. Pooled across sittings, SpecPi leads DeepSeek Harness (p = 0.007) and Oh My Pi leads DeepSeek Harness (p = 0.015), but pooling sets one harness's sittings against another's. Cost is recomputed from recorded tokens against a dated
108
+ price file, never taken from a harness's self-report.
107
109
 
108
110
  <!-- /eval-summary -->
109
111
 
package/SECURITY_MODEL.md CHANGED
@@ -19,7 +19,7 @@ Optional tool groups ship withdrawn. The `request_capability` tool lets the mode
19
19
  The `background` tool starts a shell command and returns at once; the job reports its exit code and last lines of output as a follow-up message when it ends. It runs through Pi's own local shell runner with the session's `shellPath` and `shellCommandPrefix`, so the shell, working directory and process-tree cleanup on Pi's exit match the `bash` tool. It adds no new authority, and every start fails closed:
20
20
 
21
21
  - **Permission parity.** The installer records `shellTools.background = { commandArgument: "command" }` in the permission system's global config, so its full `bash` stack — command decomposition, wrapper flooring, path and outside-directory checks, and every `bash:` rule — applies to the command. The entry is merged into the existing file, backed up and rolled back with the rest of the transaction, left alone when the file carries comments, and removed at uninstall only if it is still SpecPi's. Before every start the extension reads the effective mapping (global, then a trusted project's, which could remap it) and refuses unless it gates `command`. An unreadable config refuses too.
22
- - **Command guard.** `specpi-jev-guard` checks `bash`, `powershell`, `write` and `edit` only, so background jobs are off while it is on. The extension resolves the guard's saved state as the guard does — its default on, the global file, then a trusted project's — and starts a job only when that resolves off. The guard's session-only switch lives in its own memory and no other extension can read it, so a guard switched on with `/jev-guard on` without `--global`, over a saved off, does not stop background jobs. Save the choice with `--global` to cover them.
22
+ - **Command guard.** `specpi-lancet-guard` gates the `background` tool's command exactly as it gates `bash`, so background jobs run under it with no extra condition. The retired `specpi-jev-guard` checked `bash`, `powershell`, `write` and `edit` only, so while a `jev-guard` command is present -- someone who still installs that package themselves -- background jobs stay off unless its saved state resolves off, read the way it reads it: its default on, the global file, then a trusted project's. Its session-only switch lives in its own memory and cannot be read, so save that choice with `--global`.
23
23
  - **Interactive only.** Headless sessions are not offered the tool and are refused if they call it; there is nobody to report back to. A Pi that cannot list its commands is refused, because the guard and permission system cannot be detected.
24
24
  - **Bounded.** At most four jobs run at once. Each log is capped at 8 MiB under `<agent-dir>/specpi/background/`, owner-only, and deleted when the session ends; directories left by a crashed session are pruned after a day. A completion message carries at most 40 lines and 4,000 characters of output with control characters removed. Output is untrusted text, like any tool result.
25
25
  - **Session-owned.** `/jobs stop` and session end kill each job's process tree; Pi's own exit handling also kills tracked children. Process cleanup is best effort: a program that detaches itself from its parent, or a daemon a job starts, can outlive it, exactly as with `bash`.
@@ -50,7 +50,7 @@ One of the five systems can add fixed advice to model context, and it is off by
50
50
 
51
51
  **Progress detection, formerly system 5, has been withdrawn.** It was the only system that could steer the model mid-session, and replayed over recorded runs its stuck verdict did not predict failure. Nothing in the layer now appends a message to the conversation. Schema 6 settings drop its switch, budget and `progressNudge` mode.
52
52
 
53
- The command guard is **not** part of this layer. It is a separate pinned package with its own gate, its own configuration file, its own commands and a fail-closed posture, and it has its own section below. It is named here because a reader who took "the advisor holds no authority" to cover everything SpecPi installs would be wrong about exactly one component. No switch in SpecPi turns it on: `/jev` does not mention it, the advisor imports nothing from it, and the only thing SpecPi ever writes to its configuration is the off side of it, at install time.
53
+ The command guard is **not** part of this layer. It is a separate pinned package, `specpi-lancet-guard`, with its own gate, its own configuration file, its own commands and a fail-closed posture, and it has its own section below. It is named here because a reader who took "the advisor holds no authority" to cover everything SpecPi installs would be wrong about exactly one component. It sends nothing anywhere: it scores commands locally. `/jev` does not mention it and the advisor imports nothing from it.
54
54
 
55
55
  **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 to web and browser tool results and to shell commands whose output is a fetched document -- `curl`, `wget`, `gh api`, HTTPie and PowerShell's web cmdlets -- and never to other shell output or to file reads. That shell exception is decided by the command, locally, before anything is sent; the result is then sampled and redacted like any other. 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.
56
56
 
@@ -58,19 +58,17 @@ An earlier version of this document said its false-positive rate was measurable
58
58
 
59
59
  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 schemas: an older 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. Keys for withdrawn systems -- compaction guidance in schema 5, progress detection in schema 6 -- are dropped rather than migrated, and two more keys are dropped for a different reason. A `schema: 2` file kept a `guard` pair and a `schema: 3` file kept `systems.guard`; schema 4 keeps neither, because whether the command guard runs is not this file's answer to hold. The package keeps its own switch in its own configuration, and a stale copy here could only ever disagree with it. 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. Turning the layer on or off writes that preference, because a switch that forgets is not a switch; `--session` is how a one-off change is kept out of the file. The key is resolved the way Pi resolves every provider credential, in Pi's own documented order: the `openrouter` entry that `/login openrouter` writes to `<agent-dir>/auth.json`, then the environment variable. That is a deliberate, narrow exception to the rule that Pi state is never read, and it is bounded to exactly one question &mdash; the one `api_key` entry for the one provider this layer calls. Be precise about when: the key's *value* is read only on a call path the master switch already gates, but whether an entry *exists* is checked whenever status is displayed, including with the layer off, because that is the answer someone needs in order to configure it in the first place. A presence check reports a source name and nothing else. An `oauth` entry is ignored rather than unwrapped, because Pi refreshes those under its own lock and a second reader would race a rotation. The value is returned by a single function straight into a request header; nothing else in the layer receives it, and status output, the ledger and the Chat panel carry source names only. Pi trust decisions, sessions, missions and history are never read. `JEV_KEY_SOURCE=environment` restricts resolution to the environment, which is what this repository's own eval scripts set so a measured run cannot silently bill a developer's personal account.
60
60
 
61
- ## Command policy and the Jev guard
61
+ ## Command policy and the LANCET guard
62
62
 
63
- `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 the package's own `/jev-guard setup` is how a human opts in — no SpecPi command does. `@gotgenes/pi-permission-system` stays pinned and, while the guard is off, decides every tool call exactly as it did before the guard existed.
63
+ `specpi-lancet-guard` is pinned in the base set and ships **off**. SpecPi writes nothing into its configuration: the package's own default is off, and only its own `/lancet-guard on` (this session) or `/lancet-guard on --global` (saved in `<home>/.pi/lancet-guard.json`) turns it on. An installer run never arms or disarms it, so a saved choice survives every update. `@gotgenes/pi-permission-system` stays pinned and decides every tool call whether the guard is on or off; the guard is a second, independent check: either one can block a call, so the guard never allows what the permission system would refuse.
64
64
 
65
- 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, an answer it cannot parse, 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 &mdash; `uncertain` chooses between asking and denying the middle band, and only its `allow` value lets that band through unattended. 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 SpecPi only ever writes the off side of it and why turning it on takes the package's own `/jev-guard` command.
65
+ When on, local rules copied from `specpi-jev-guard` decide first -- hard-deny patterns block, provably read-only commands and the user's allow globs pass -- and LANCET Nano v0.3.0, a 110M-parameter CodeT5-base classifier, scores the rest of `bash`, `powershell` and `background` commands in-process on the CPU through ONNX Runtime. `risky` asks (or blocks, if configured), `review` -- the band Nano is unsure about -- asks, `not_flagged` runs, and what LANCET cannot read (PowerShell, over-long input) asks. Writes and edits to protected or out-of-workspace paths ask without a model call. With no UI, an ask becomes a block unless `uncertain` is `allow`. A missing, damaged or unloadable model blocks what the rules leave open: switching the guard on accepts that, and `/lancet-guard on` is refused until the model is installed. `not_flagged` is not a safety claim; on LANCET's own 793-command release benchmark the classifier alone caught 85.8% of risky commands and 66% of risky secrets commands, against 96.8% and 98% for Jev.
66
66
 
67
- 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: false`, `uncertain: "ask"` so a middle-band verdict asks a human wherever there is one to ask, and `backend: "openrouter"` so the guard and the advisor resolve the same credential. 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.
67
+ Commands never leave the machine and are never executed by the guard. The only network access is `/lancet-guard setup`, run by a human: it downloads one ZIP from the pinned `TannerMidd/LANCET-model` GitHub release into a private staging directory, capped at its pinned size, and refuses it unless its SHA-256 matches the digest compiled into the package. Only then does the package's own small ZIP reader extract the three model files, each inflated to its pinned size and refused unless its own SHA-256 matches. GitHub is the only host involved, so it works where Hugging Face is blocked. The digests are checked again on every load. The package's one production dependency, `onnxruntime-node` 1.30.0, is pinned exactly; its install script would fetch CUDA libraries on Linux x64, so the installer sets `ONNXRUNTIME_NODE_INSTALL=skip`.
68
68
 
69
- That write happens at install and update, and nowhere else. It deliberately does not repeat at session start, so the package's own `/jev-guard on [--global]` is the only switch and a saved choice survives every restart. It does repeat on the next installer run, unconditionally: an install left holding a stale `enabled: true` from before the package was last unpinned would otherwise arm a fail-closed gate the moment the package came back. That is the one case where establishing the default takes something away from someone, so the run reports it by name rather than doing it quietly.
69
+ The guard publishes a per-session counter as a Pi status item under the key `lancet-guard`: the number of commands LANCET scored, the number blocked once there are any, and the last verdict. SpecPi Chat renders that line in its footer and does nothing else with it. The key is absent whenever the guard is off or its `auditDisplay` is. Every decision is also written to Pi's own session file by the package.
70
70
 
71
- As of 0.3.0 the guard resolves its key the way the advisor does and the way Pi resolves every provider credential: Pi's saved login first, then the environment. One `/login openrouter` therefore serves both without either component knowing about the other. `/jev-guard` reports its own state, including which backend and whether a key is present; `/jev status` does not, because the guard is not part of the layer it describes. Neither the guard nor the advisor is an OS sandbox.
72
-
73
- 0.4.0 publishes a per-session counter as a Pi status item under the key `jev-guard` — the number of classifier calls, the number of blocked calls once there are any, and the last verdict. SpecPi Chat renders that line in its session footer and does nothing else with it: it does not recount from the session records, reformat the text, or offer a control. The key is absent whenever the guard is off or its `auditDisplay` is, so the readout appears exactly when the guard is gating. Every judged call is also written to Pi's own session file by the package, which is the durable audit trail and is not SpecPi's to manage.
71
+ `specpi-jev-guard`, which this replaced, is no longer pinned. An update retires its package entry like any other SpecPi-added pin and leaves its downloaded bytes and `jev-guard.json` in place. When that file had it switched on, the update says so, because retiring a gate someone had armed is not a change to make quietly. The LANCET guard does not inherit that setting; it starts off.
74
72
 
75
73
  ## Improvement authority and evidence
76
74
 
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, and `specpi-jev-guard` 0.4.0 on 2026-09-20. All eight 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, and `specpi-lancet-guard` 0.1.0, which replaces `specpi-jev-guard` 0.4.0, from source in this repository. 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,22 +24,26 @@ 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.4.0 | [TannerMidd/specpi-jev-guard](https://github.com/TannerMidd/specpi-jev-guard) |
27
+ | specpi-lancet-guard | 0.1.0 | [TannerMidd/SpecPi](https://github.com/TannerMidd/SpecPi/tree/main/packages/lancet-guard) |
28
28
 
29
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.
30
30
 
31
31
  Pi Lens is no longer a default package. Normal updates retire only unchanged entries added by SpecPi; independent or modified entries and downloaded bytes remain, with their upstream notices. Restart Pi to unload Lens.
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 native command-guard implementations, DonSeTch, and Pi themes have been removed; command gating is the pinned `specpi-jev-guard` package's again. 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
+ 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 native command-guard implementations, DonSeTch, and Pi themes have been removed; command gating is the pinned `specpi-lancet-guard` package's. 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.
34
34
 
35
35
  ## Network services
36
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 runtime dependencies. Both resolve their key the way every other Pi provider credential is resolved, in Pi's own documented order: the `openrouter` entry that `/login openrouter` writes to Pi's `auth.json`, then `OPENROUTER_API_KEY` from the environment (`TYPESAFE_API_KEY` when the direct API is selected &mdash; `JEV_BACKEND=typesafe` for the advisor, `/jev-guard backend typesafe` for the guard). Guard 0.3.0 is the release that added the credential-store half; before it the package read the environment alone. The two keys are not interchangeable: the other service rejects the wrong one with a bare 401. SpecPi never provisions or stores a provider credential, and reads one only to place it in the request header of the call the user has switched on.
37
+ The optional Jev advisor reaches TypeSafe's System One endpoint. It is not a bundled dependency: it is first-party source that uses Node's built-in `fetch` and declares no package. It resolves its key the way every other Pi provider credential is resolved, in Pi's own documented order: the `openrouter` entry that `/login openrouter` writes to Pi's `auth.json`, then `OPENROUTER_API_KEY` from the environment (`TYPESAFE_API_KEY` when `JEV_BACKEND=typesafe` selects the direct API). The two keys are not interchangeable: the other service rejects the wrong one with a bare 401. SpecPi never provisions or stores a provider credential, and reads one only to place it in the request header of the call the user has switched on.
38
38
 
39
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
40
 
41
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
42
 
43
+ ## LANCET command guard
44
+
45
+ `packages/lancet-guard` is first-party source, released independently as `specpi-lancet-guard`. Its one production dependency is `onnxruntime-node` 1.30.0 (MIT, Microsoft), pinned exactly, which bundles prebuilt ONNX Runtime CPU libraries; SpecPi's installer sets `ONNXRUNTIME_NODE_INSTALL=skip` so its install script does not fetch the CUDA libraries it would otherwise download on Linux x64. Its local rules are copied under MIT from `specpi-jev-guard` 0.4.0. The model is not bundled: `/lancet-guard setup` downloads LANCET Nano v0.3.0 (Apache-2.0) from the `TannerMidd/LANCET-model` GitHub release, pinned by SHA-256. It was fine-tuned from Salesforce CodeT5-base (Apache-2.0); its model card and notices in the release record the training-data sources. The guard makes no other network connection. See its [dependency notices](packages/lancet-guard/THIRD_PARTY.md) and [security boundary](packages/lancet-guard/SECURITY.md).
46
+
43
47
  ## Standalone delegation and experiments
44
48
 
45
49
  `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 it does not integrate with, 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).
@@ -52,4 +56,4 @@ The separately released `packages/browser-qa` source reuses the retired QA imple
52
56
 
53
57
  The website uses the bundled IBM Plex Sans and Plex Mono fonts under the SIL Open Font License 1.1; see [`site/fonts/LICENSE.txt`](https://github.com/TannerMidd/SpecPi/blob/main/site/fonts/LICENSE.txt). The website's scripts and diagrams are first-party code and assets. The README loads public package/license badges from Shields.io and build status from GitHub. Website fonts and media are not included in the npm package.
54
58
 
55
- SpecPi Chat 0.16.0 is a separately packaged VS Code extension with no bundled runtime dependencies. It uses VS Code's host APIs and Pi's RPC protocol. Generic tool output, visible custom messages, widgets, and dialogs stay owned by their upstream packages. Playwright **1.62.1** (Apache-2.0, [Microsoft Playwright](https://github.com/microsoft/playwright)) is a pinned development dependency for Chat's rendering tests; its browser is used for those checks and is not shipped in either artifact. The default Browser QA package independently uses the same pinned Playwright version for its runtime. Chat's global Destructive guard preset is a full replacement configuration reviewed against Permission System 32.0.2's schema and native pattern semantics. No upstream patch, new dependency, policy-layer merger, or independent command evaluator is included. See [the security model](SECURITY_MODEL.md) for its limits.
59
+ SpecPi Chat 0.17.0 is a separately packaged VS Code extension with no bundled runtime dependencies. It uses VS Code's host APIs and Pi's RPC protocol. Generic tool output, visible custom messages, widgets, and dialogs stay owned by their upstream packages. Playwright **1.62.1** (Apache-2.0, [Microsoft Playwright](https://github.com/microsoft/playwright)) is a pinned development dependency for Chat's rendering tests; its browser is used for those checks and is not shipped in either artifact. The default Browser QA package independently uses the same pinned Playwright version for its runtime. Chat's global Destructive guard preset is a full replacement configuration reviewed against Permission System 32.0.2's schema and native pattern semantics. No upstream patch, new dependency, policy-layer merger, or independent command evaluator is included. See [the security model](SECURITY_MODEL.md) for its limits.
@@ -6,8 +6,8 @@
6
6
  // anything -- see key-source.mjs.
7
7
  //
8
8
  // The command guard is not here at all. It is a separate pinned package that keeps its own
9
- // configuration and its own switch, and nothing in this file tracks it -- see scripts/jev-guard.mjs
10
- // for the one place SpecPi touches it, which is install time.
9
+ // configuration and its own switch, and nothing in this file tracks it. It is specpi-lancet-guard,
10
+ // which scores commands locally and does not use Jev.
11
11
  //
12
12
  // The file is SpecPi's own, hardened the same way as web-access and capability-policy: atomic
13
13
  // write, mode 0600, symlinks refused, and a missing or unreadable file read as off.
@@ -23,6 +23,9 @@ import { randomUUID } from "node:crypto";
23
23
  export const BACKGROUND_TOOL = "background";
24
24
  export const BACKGROUND_MESSAGE = "specpi-background";
25
25
  export const BACKGROUND_STATUS = "specpi-background";
26
+ /** The structured job list SpecPi Chat draws its jobs panel from. Published to RPC clients only. */
27
+ export const BACKGROUND_WIDGET = "specpi-background-v1";
28
+ export const WIDGET_JOBS = 8;
26
29
 
27
30
  /** The `shellTools` entry that makes the permission system gate `command` exactly like `bash`. */
28
31
  export const SHELL_TOOL_MAPPING = Object.freeze({ commandArgument: "command" });
@@ -269,6 +272,56 @@ export function admission({
269
272
  return { ok: true };
270
273
  }
271
274
 
275
+ // ---------------------------------------------------------------------------------------------
276
+ // Steering long bash calls
277
+ // ---------------------------------------------------------------------------------------------
278
+
279
+ /** A bash call asking for a timeout above this is a long run; it belongs in a background job. */
280
+ export const LONG_BASH_TIMEOUT_SECONDS = 600;
281
+
282
+ const LOOP = /\b(?:for|while|until)\b[\s\S]*\bdo\b/u;
283
+ const SLEEPS = /\bsleep\s+(\d+(?:\.\d+)?)([smh]?)\b/gu;
284
+ const WATCHERS = /\bgh\s+run\s+watch\b|\bgh\s+pr\s+checks\b[^\n]*--watch\b|\btail\s+-[a-zA-Z]*f\b|\bwatch\s+-n\b/u;
285
+
286
+ function seconds(value, unit) {
287
+ const number = Number(value);
288
+
289
+ return unit === "h" ? number * 3600 : unit === "m" ? number * 60 : number;
290
+ }
291
+
292
+ /**
293
+ * Whether a bash call would plainly hold the conversation while it waits, and if so why. Only the
294
+ * unambiguous shapes: a polling loop that sleeps, a single long sleep, a watch command, or a timeout
295
+ * the caller itself expects to be long. A long build that happens to take minutes is not guessable
296
+ * from its text and is left to the guidance. Returns undefined when the call is fine.
297
+ */
298
+ export function blockingShellCall(input) {
299
+ const command = typeof input?.command === "string" ? input.command : "";
300
+ const timeout = Number(input?.timeout);
301
+ if (Number.isFinite(timeout) && timeout > LONG_BASH_TIMEOUT_SECONDS) {
302
+ return `it asks for a ${Math.round(timeout)}-second timeout`;
303
+ }
304
+
305
+ const sleeps = [...command.matchAll(SLEEPS)].map((match) => seconds(match[1], match[2]));
306
+ if (LOOP.test(command) && sleeps.some((value) => value >= 10)) {
307
+ return "it polls in a loop that sleeps between checks";
308
+ }
309
+
310
+ if (sleeps.some((value) => value >= 60)) {
311
+ return "it sleeps for a minute or more";
312
+ }
313
+
314
+ if (WATCHERS.test(command)) {
315
+ return "it watches something until it finishes";
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
321
+ export function blockingShellReason(why) {
322
+ return `Not run: this bash call would block the conversation because ${why}, and the user cannot talk to you until it returns. Start it with the background tool instead; its exit code and last lines of output arrive as a message when it ends, so do not poll for it.`;
323
+ }
324
+
272
325
  // ---------------------------------------------------------------------------------------------
273
326
  // Jobs
274
327
  // ---------------------------------------------------------------------------------------------
@@ -474,6 +527,31 @@ export function statusText(jobs) {
474
527
  return count > 0 ? `${count} background job${count === 1 ? "" : "s"} running` : undefined;
475
528
  }
476
529
 
530
+ /**
531
+ * One JSON line for Chat: every running job, then the most recent finished ones, newest first.
532
+ * Only what /jobs already shows: no output, no log path, no working directory.
533
+ */
534
+ export function widgetPayload(jobs) {
535
+ const newestFirst = [...jobs].sort((a, b) => Number(b.id) - Number(a.id));
536
+ const shown = [
537
+ ...newestFirst.filter((job) => job.state === "running"),
538
+ ...newestFirst.filter((job) => job.state !== "running"),
539
+ ].slice(0, WIDGET_JOBS);
540
+
541
+ return JSON.stringify({
542
+ version: 1,
543
+ jobs: shown.map((job) => ({
544
+ id: job.id,
545
+ label: job.label ?? null,
546
+ command: job.command,
547
+ state: job.state,
548
+ exitCode: Number.isInteger(job.exitCode) ? job.exitCode : null,
549
+ startedAt: job.startedAt,
550
+ endedAt: job.endedAt ?? null,
551
+ })),
552
+ });
553
+ }
554
+
477
555
  export function listText(jobs, now = Date.now()) {
478
556
  if (jobs.length === 0) {
479
557
  return "No background jobs in this session.";
@@ -23,6 +23,7 @@ import {
23
23
  BACKGROUND_MESSAGE,
24
24
  BACKGROUND_STATUS,
25
25
  BACKGROUND_TOOL,
26
+ BACKGROUND_WIDGET,
26
27
  admission as backgroundAdmissionFor,
27
28
  agentDirectory as backgroundAgentDirectory,
28
29
  completionText,
@@ -35,6 +36,9 @@ import {
35
36
  startedText,
36
37
  statusText,
37
38
  tailOf,
39
+ widgetPayload,
40
+ blockingShellCall,
41
+ blockingShellReason,
38
42
  } from "./background.mjs";
39
43
  import {
40
44
  canonicalRoot,
@@ -471,7 +475,12 @@ export default function workflowControls(pi: ExtensionAPI) {
471
475
 
472
476
  const publishJobStatus = () => {
473
477
  try {
474
- jobsContext?.ui.setStatus(BACKGROUND_STATUS, statusText(jobs?.list() ?? []));
478
+ const all = jobs?.list() ?? [];
479
+ jobsContext?.ui.setStatus(BACKGROUND_STATUS, statusText(all));
480
+ // Chat's jobs panel. A terminal would draw a widget as text above the editor, so only RPC gets it.
481
+ if (jobsContext?.mode === "rpc") {
482
+ jobsContext.ui.setWidget(BACKGROUND_WIDGET, all.length > 0 ? [widgetPayload(all)] : undefined);
483
+ }
475
484
  } catch {
476
485
  // A status line is a courtesy; a closed UI must not fail the job.
477
486
  }
@@ -548,6 +557,24 @@ export default function workflowControls(pi: ExtensionAPI) {
548
557
  }
549
558
  });
550
559
 
560
+ // A bash call that would hold the conversation while it waits is refused whenever a background job
561
+ // could run it instead, so the user can keep talking. Guidance alone was not enough: the agent
562
+ // kept polling CI with sleep loops in bash while the background tool sat unused.
563
+ pi.on("tool_call", async (event: any, ctx) => {
564
+ if (event.toolName !== "bash" || ctx.hasUI !== true) {
565
+ return;
566
+ }
567
+
568
+ const active = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
569
+ if (!active.includes(BACKGROUND_TOOL)) {
570
+ return;
571
+ }
572
+
573
+ const why = blockingShellCall(event.input);
574
+
575
+ return why ? { block: true, reason: blockingShellReason(why) } : undefined;
576
+ });
577
+
551
578
  pi.on("tool_call", async (event: any, ctx) => {
552
579
  if (!scope.active || (event.toolName !== "write" && event.toolName !== "edit")) {
553
580
  return;
@@ -1146,7 +1173,10 @@ export default function workflowControls(pi: ExtensionAPI) {
1146
1173
  name: BACKGROUND_TOOL,
1147
1174
  label: "Background",
1148
1175
  description:
1149
- "Start a long-running shell command (an eval, build or server) without blocking the conversation. It returns at once; the exit code and last lines of output arrive later as a message. Use bash for anything quick, or when the next step needs the output. Bash permission rules apply.",
1176
+ "Start a shell command that may take more than a minute or two (an eval, build, server, test suite, or waiting on CI or a deploy) without blocking the conversation. It returns at once; the exit code and last lines of output arrive later as a message, so never poll or sleep waiting for it. Use bash only for quick commands. Bash permission rules apply.",
1177
+ promptGuidelines: [
1178
+ "Use background, not bash, for anything that may take more than a minute or two or that waits on something (CI, deploys, sleep loops); a long bash call stops the user from talking to you until it returns.",
1179
+ ],
1150
1180
  parameters: Type.Object(
1151
1181
  {
1152
1182
  command: Type.String({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "scripts/lib.mjs",
29
29
  "scripts/lock.mjs",
30
30
  "scripts/packages.mjs",
31
- "scripts/jev-guard.mjs",
31
+ "scripts/lancet-guard.mjs",
32
32
  "scripts/permission-shell-tools.mjs",
33
33
  "templates",
34
34
  "extensions",
@@ -88,6 +88,7 @@
88
88
  "check:browser-qa": "npm --prefix packages/browser-qa run check",
89
89
  "check:delegation": "npm --prefix packages/delegation run check",
90
90
  "check:experiments": "npm --prefix packages/experiments run check",
91
+ "check:lancet-guard": "npm --prefix packages/lancet-guard run check",
91
92
  "check:vscode": "npm --prefix vscode run check",
92
93
  "prepublishOnly": "npm run check",
93
94
  "check": "npm run format:check && npm run check:syntax && npm test && npm run check:package && npm run check:vscode"
@@ -0,0 +1,35 @@
1
+ // Install-time notes for specpi-lancet-guard, the local command guard in the base set.
2
+ //
3
+ // SpecPi writes nothing into the guard's configuration. The package ships off, downloads its model
4
+ // only when someone runs `/lancet-guard setup`, and owns its own switch: `/lancet-guard on` for a
5
+ // session, `--global` to save it. An installer run therefore never arms or disarms it, and a
6
+ // saved preference survives every update.
7
+ //
8
+ // It replaced specpi-jev-guard, which SpecPi used to pin and write inert. An update retires that
9
+ // pin like any other SpecPi-added package entry. The one thing worth saying out loud is when that
10
+ // takes away a gate someone had switched on, so this reads the old package's saved switch -- only
11
+ // that field -- to decide whether to say it.
12
+
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+
17
+ /** Must match the pin in templates/settings.json. */
18
+ export const GUARD_PIN = "npm:specpi-lancet-guard@0.1.0";
19
+
20
+ export const RETIRED_GUARD_PIN = "npm:specpi-jev-guard@0.4.0";
21
+
22
+ /** Whether the retired Jev guard's saved global setting had it switched on. */
23
+ export function retiredGuardWasOn(home = os.homedir()) {
24
+ try {
25
+ const file = path.join(home, ".pi", "jev-guard.json");
26
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/u, ""));
27
+
28
+ return parsed?.enabled === true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export const RETIRED_GUARD_NOTICE =
35
+ "The Jev command guard was on and is no longer in SpecPi's default packages. Its replacement, the local LANCET guard, starts off: run /lancet-guard setup in Pi to download its model and turn it on.";
@@ -94,6 +94,10 @@ export function installBasePackages(agentDir) {
94
94
  PI_CODING_AGENT_DIR: agentDir,
95
95
  npm_config_save_exact: "true",
96
96
  NPM_CONFIG_SAVE_EXACT: "true",
97
+ // onnxruntime-node (the LANCET guard's runtime) bundles its CPU binaries, but on
98
+ // Linux x64 its install script would also fetch CUDA libraries from NuGet. The guard
99
+ // is CPU-only, so skip that download.
100
+ ONNXRUNTIME_NODE_INSTALL: "skip",
97
101
  },
98
102
  stdio: "inherit",
99
103
  windowsHide: true,
@@ -23,7 +23,7 @@ import { validateCapabilityRegistry } from "../extensions/tool-wishlist/registry
23
23
  import { runValidator } from "../extensions/tool-wishlist/validators.mjs";
24
24
  import { acquireSpecPiLock } from "./lock.mjs";
25
25
  import { basePackages, checkBasePackages, installBasePackages, packageChanges, runBrowserQA } from "./packages.mjs";
26
- import { applyInertConfig as applyGuardConfig, configPath as guardConfigPath } from "./jev-guard.mjs";
26
+ import { RETIRED_GUARD_NOTICE, RETIRED_GUARD_PIN, retiredGuardWasOn } from "./lancet-guard.mjs";
27
27
  import {
28
28
  applyShellToolMapping,
29
29
  manualInstruction as shellToolInstruction,
@@ -366,24 +366,15 @@ async function mutate(options, operation) {
366
366
  runBrowserQA(agentDir, "setup");
367
367
  }
368
368
 
369
- // specpi-jev-guard's own default is enabled:true, so a freshly installed base would
370
- // start gating shell and file calls through a third-party service before anyone asked
371
- // for it — and with no key it fails closed, which means a first install that refuses to
372
- // run commands. This is the only place SpecPi touches that package's configuration:
373
- // between installer runs the guard's own /jev-guard commands own it, so the write does
374
- // not repeat at session start and a saved `--global` choice survives every restart.
375
- try {
376
- const guard = applyGuardConfig();
377
- if (guard.disarmed) {
378
- // The one case where establishing the default takes something away. An
379
- // installer run that silently switched off a gate the user had switched on is
380
- // exactly the kind of quiet security change this file refuses to make.
381
- warnings.push(
382
- `Switched the Jev command guard off in ${guardConfigPath()}. SpecPi installs it inert and asserts that on every install and update; run /jev-guard on --global in Pi to turn it back on.`,
383
- );
384
- }
385
- } catch (error) {
386
- console.log(`SpecPi: could not write the Jev guard's inert settings: ${error.message}`);
369
+ // The LANCET guard ships off and owns its own switch, so SpecPi writes nothing into its
370
+ // configuration. Retiring the Jev guard it replaced is the one change here that can take
371
+ // a gate away from someone, so that case is said out loud rather than done quietly.
372
+ if (
373
+ previous?.basePackages?.includes(RETIRED_GUARD_PIN) &&
374
+ !basePackages.includes(RETIRED_GUARD_PIN) &&
375
+ retiredGuardWasOn()
376
+ ) {
377
+ warnings.push(RETIRED_GUARD_NOTICE);
387
378
  }
388
379
 
389
380
  packageState = {
@@ -7,6 +7,6 @@
7
7
  "npm:pi-goal-x@0.31.2",
8
8
  "npm:@sreetej510/pi-usage@0.10.0",
9
9
  "npm:@gotgenes/pi-permission-system@32.0.2",
10
- "npm:specpi-jev-guard@0.4.0"
10
+ "npm:specpi-lancet-guard@0.1.0"
11
11
  ]
12
12
  }
@@ -1,154 +0,0 @@
1
- // Install-time seam for specpi-jev-guard, the Jev-scored command gate.
2
- //
3
- // The guard is pinned in the base set but ships **inert**. Its own `DEFAULT_SETTINGS.enabled` is
4
- // `true`, so leaving it alone would mean a fresh SpecPi install started gating shell and file calls
5
- // through a third-party service on day one. SpecPi writes `enabled: false` instead.
6
- //
7
- // That is the whole of SpecPi's involvement, and the file lives under `scripts/` to say so. The
8
- // guard is its own thing: it ships its own `/jev-guard setup | on | off [--global] | check | model
9
- // | backend`, keeps its own configuration, resolves its own key, and is not part of the Jev layer.
10
- // The advisor imports nothing from here, `/jev` does not mention it, and no switch in SpecPi turns
11
- // it on. One command does -- the package's own.
12
- //
13
- // Be precise about what this cannot do. The guard is fail-closed by design: with no key, an
14
- // unreachable endpoint, or an answer it cannot parse, the call does not go through, and in a
15
- // session with no UI its `uncertain` default blocks the middle band too. There is no setting that
16
- // hands the decision back to @gotgenes/pi-permission-system instead. So the honest posture is:
17
- //
18
- // - off (what SpecPi installs) -> the guard is not in the tool path at all, and the permission
19
- // system decides every call exactly as it did before this package existed;
20
- // - on -> the guard decides first, asks a human in the middle band when there is a UI, and
21
- // blocks when it cannot reach Jev. An outage stops gated work until it is switched off.
22
- //
23
- // That trade is the user's to make, which is why SpecPi only ever writes the off side of it.
24
- //
25
- // ONE KEY, and not one SpecPi supplies. Since 0.3.0 the package reads Pi's saved login first and
26
- // the environment second -- the same order the Jev advisor uses for the same provider entry -- so
27
- // `/login openrouter` serves both without either knowing about the other.
28
- //
29
- // `auditDisplay` is deliberately not asserted below, although SpecPi Chat renders the counter that
30
- // setting controls. It defaults to `status`, which is what publishes the line, and it is the user's
31
- // to change: pinning it here would take a display preference away from them to guarantee a readout
32
- // in one frontend. A session with it set to `off` simply shows no counter, which is what they
33
- // asked for.
34
-
35
- import fs from "node:fs";
36
- import os from "node:os";
37
- import path from "node:path";
38
- import { agentDirectory, regularFile, writeFileAtomic } from "../extensions/jev-advisor/config.mjs";
39
-
40
- export const GUARD_PACKAGE = "specpi-jev-guard";
41
-
42
- /** Must match the pin in templates/settings.json. */
43
- export const GUARD_PIN = "npm:specpi-jev-guard@0.4.0";
44
-
45
- // The guard reads `<homedir>/.pi/jev-guard.json` globally, and a project copy under `<cwd>/.pi/`
46
- // when the project is trusted. SpecPi writes only the global file: a project-local override is the
47
- // user's to make, and writing one would put a security setting inside whatever repository happened
48
- // to be open at the time.
49
- const CONFIG_DIR_NAME = ".pi";
50
- const SETTINGS_FILE = "jev-guard.json";
51
-
52
- function guardRoot() {
53
- return path.join(agentDirectory(), "npm", "node_modules", GUARD_PACKAGE);
54
- }
55
-
56
- function guardConfigFile() {
57
- return path.join(os.homedir(), CONFIG_DIR_NAME, SETTINGS_FILE);
58
- }
59
-
60
- export function installed() {
61
- try {
62
- const manifest = path.join(guardRoot(), "package.json");
63
- if (!fs.existsSync(manifest)) {
64
- return { installed: false };
65
- }
66
-
67
- const parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
68
- if (parsed?.name !== GUARD_PACKAGE) {
69
- return { installed: false };
70
- }
71
-
72
- return { installed: true, version: typeof parsed.version === "string" ? parsed.version : "unknown" };
73
- } catch {
74
- return { installed: false };
75
- }
76
- }
77
-
78
- /**
79
- * The fields SpecPi owns, in the guard's own schema. Anything absent here keeps the package's
80
- * default, so its thresholds, command lists, protected paths and model settings stay its business.
81
- *
82
- * There is no `enabled: true` form of this, deliberately. SpecPi has no command that arms the
83
- * guard, so a function that could produce an armed configuration would have no caller and one
84
- * obvious wrong use.
85
- */
86
- export function desiredConfig() {
87
- return {
88
- // Off means the guard never enters the tool path, so no key is needed and nothing is sent.
89
- enabled: false,
90
- // Left at the guard's own default, and asserted so the two halves of the Jev story resolve
91
- // the same credential: one `/login openrouter` serves the advisor and the guard.
92
- backend: "openrouter",
93
- // With a UI, a middle-band verdict asks rather than deciding on its own. Without one the
94
- // guard fails closed; that is the package's design, disclosed rather than configured away.
95
- uncertain: "ask",
96
- };
97
- }
98
-
99
- export function readConfig() {
100
- try {
101
- const file = guardConfigFile();
102
- if (!regularFile(file, "Jev guard settings")) {
103
- return undefined;
104
- }
105
-
106
- return JSON.parse(fs.readFileSync(file, "utf8"));
107
- } catch {
108
- return undefined;
109
- }
110
- }
111
-
112
- /**
113
- * Write the inert posture, merged into whatever is already there.
114
- *
115
- * Merged, not replaced: a user's own thresholds, safe-command globs and protected paths survive,
116
- * and only the three fields above are asserted. Idempotent, and it reports what it changed so the
117
- * installer can say so.
118
- *
119
- * It runs on install and on update, and it always asserts `enabled: false`. Not only when the file
120
- * is absent, which is the version of this that looks safer and is not: an install carrying a stale
121
- * `enabled: true` from before this package was last unpinned would arm a fail-closed gate the
122
- * moment the package came back, with nobody present to be told. So the rule is the blunt one --
123
- * SpecPi never leaves an armed gate behind an installer run -- and `disarmed` is returned so the
124
- * run can report the one case where that took something away from someone.
125
- *
126
- * This is an occasional, human-initiated act, which is what makes the blunt rule affordable. It
127
- * deliberately does not run at session start: the package owns its switch between installs, and a
128
- * per-session rewrite would mean `/jev-guard on --global` never survived a restart.
129
- */
130
- export function applyInertConfig() {
131
- if (!installed().installed) {
132
- return { applied: false, reason: "not-installed" };
133
- }
134
-
135
- const desired = desiredConfig();
136
- const current = readConfig();
137
- if (current && Object.entries(desired).every(([key, value]) => current[key] === value)) {
138
- return { applied: false, reason: "already-current" };
139
- }
140
-
141
- writeFileAtomic(guardConfigFile(), `${JSON.stringify({ ...(current ?? {}), ...desired }, null, 4)}\n`);
142
-
143
- return {
144
- applied: true,
145
- reason: current ? "updated" : "created",
146
- // The one change worth announcing: everything else here is establishing a default, and this
147
- // is switching off a gate a human had switched on.
148
- disarmed: current?.enabled === true,
149
- };
150
- }
151
-
152
- export function configPath() {
153
- return guardConfigFile();
154
- }