specpi 0.24.0 → 0.25.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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0 - 2026-09-17
4
+
5
+ - Hide the web access tools until needed. `web_search`, `source_check`, `fetch_content` and `get_search_content` are no longer offered to a session until `/webaccess on`; `/webaccess startup on` saves that choice. The working agreement, security model and wiki say so, and the agent asks the human to run it rather than attempting a hidden tool.
6
+ - Re-measure first-call context from the complete installed base instead of first-party extensions alone. A default session sends 15,069 characters and the enabled profile (`/browser on`, `/delegate on`, `/webaccess on`) sends 40,203. The earlier 10,536-character figure omitted installed guidance and third-party pins and is corrected on the research page. The measurement, pins and method are published as `site/research/context-measurement.json`.
7
+ - Add a capability chart that partitions the enabled profile's tool schema group by group: web access is the largest at 11,298 characters, ahead of Browser QA's fourteen tools at 8,046. Leaving the three opt-in groups hidden keeps 23,797 characters of tool schema out of every request.
8
+ - Re-measure Oh My Pi on the same terms at 65,816 characters and keep it a measured row: HarnessTax covers Claude Code, Codex CLI and Pi only, so the fork was never a study figure.
9
+ - Measure OpenCode on the same terms and add it to the first-call chart. Its default build-agent session sends 31,043 characters across 10 tools, about 5.6x stock Pi and about twice a default SpecPi, landing between a default SpecPi and the enabled profile. `scripts/measure-context.mjs --oc=<path to OpenCode's binary>` takes that row; the session title is pinned so the turn sends exactly one model call. The research page is rewritten in plainer language and the "For this base" section is restructured around subheads.
10
+ - Stop `/scope` from racing the session restore. Restore retires scope immediately but only learns the repository root once `git rev-parse` returns; a contract declared in that window was recorded against the session cwd, and the replay that followed rejected its own entry as belonging elsewhere and silently turned scope off after reporting it set. Scope commands now wait for the restore in flight.
11
+
3
12
  ## 0.24.0 - 2026-09-16
4
13
 
5
14
  - Replace `pi-subagents@0.67.0` in the default base with first-party `specpi-delegation@0.2.0`, and add `specpi-experiments@0.1.0`. The base is now seven pinned packages; the other five are unchanged.
package/README.md CHANGED
@@ -38,9 +38,36 @@ The setup optimizes for four things:
38
38
  - **Accuracy** — exact pins, checksum-tracked state, rollback on failure, and evidence over claims
39
39
  - **Improvement** — local observations become bounded, verified changes through `/harness-improvement`
40
40
  - **Efficiency** — subagent delegation, persistent goals, and browser QA handled by the right tools
41
+ - **Lean default** — web access, browser QA, and delegation ship hidden; `/webaccess on`, `/browser on`, and `/delegate on` offer them per session
41
42
 
42
43
  Everything it manages is declared, versioned, and reversible.
43
44
 
45
+ ## Measured context
46
+
47
+ The chart measures first-call context from a clean installation: all seven pinned packages, the installed working agreement, and discovered skills. “Enabled” means browser QA, delegation, and web access are switched on; no goal, scope, or improvement selection is active. The solid rows — including OpenCode and Oh My Pi, both measured as installed — are ours, taken from the request each harness actually sends through one local synthetic provider. The dimmed Codex CLI and Claude Code rows are HarnessTax's published figures, taken under the study's configuration: a reference, not a matched comparison. These are character counts, not tokens, spending, or task-quality scores. The research page [breaks the enabled profile down by capability](https://tannermidd.github.io/SpecPi/research/#specpi), so the cost of each switch is visible on its own.
48
+
49
+ <p align="center">
50
+ <a href="https://tannermidd.github.io/SpecPi/research/#specpi">
51
+ <picture>
52
+ <source media="(prefers-color-scheme: dark)" srcset="https://tannermidd.github.io/SpecPi/media/context-chart-dark.svg">
53
+ <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 15,069, OpenCode 31,043, SpecPi enabled 40,203, Codex CLI 41,616, Oh My Pi 65,816, Claude Code 90,460.">
54
+ </picture>
55
+ </a>
56
+ </p>
57
+ <p align="center"><sub>Measured tool schemas + system/developer instructions · <code>node scripts/measure-context.mjs --chart --omp=&lt;path to Oh My Pi's cli.js&gt; --oc=&lt;path to OpenCode's binary&gt;</code> · <a href="site/research/context-measurement.json">Recorded measurements and package pins</a> · <a href="https://tannermidd.github.io/SpecPi/research/#specpi">Method and caveats</a></sub></p>
58
+
59
+ The gap between the two SpecPi bars is not one decision, so the enabled profile's tool schema is also measured group by group. Browser QA's fourteen tools cost less together than web access's four:
60
+
61
+ <p align="center">
62
+ <a href="https://tannermidd.github.io/SpecPi/research/#specpi">
63
+ <picture>
64
+ <source media="(prefers-color-scheme: dark)" srcset="https://tannermidd.github.io/SpecPi/media/capability-chart-dark.svg">
65
+ <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 4,214, Goals 1,315, Browser QA 8,046, Delegation 4,453, Web access 11,298. Browser QA, Delegation, Web access are hidden until switched on.">
66
+ </picture>
67
+ </a>
68
+ </p>
69
+ <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>
70
+
44
71
  ## Install
45
72
 
46
73
  Requires Node.js 22.19+, Git, npm, and an existing Pi installation on PATH.
package/SECURITY_MODEL.md CHANGED
@@ -10,7 +10,7 @@ Scope records use Pi's current session branch. Restoring a branch does not creat
10
10
 
11
11
  ## Improvement authority and evidence
12
12
 
13
- 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.
13
+ 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 `/webaccess on`.
14
14
 
15
15
  Retirement requires source registry integration, unchanged verification policy, a matching contract, bounded source snapshots, `npm run check`, and closed registered validators. Receipts distinguish machine-observed gates from model-reported acceptance evidence. Stale selections, changed source, missing evidence, and failed checks reject retirement. A validator proves only the behavior it exercises; the human remains responsible for accepting the result. The loop never commits, publishes, or installs a resulting change automatically.
16
16
 
@@ -12,6 +12,13 @@ import {
12
12
  } from "./scope.mjs";
13
13
 
14
14
  import { readTaskContract, renderTaskContract } from "./task-contract.mjs";
15
+ import {
16
+ WEB_TOOL_NAMES,
17
+ loadStartupActivation,
18
+ saveStartupActivation,
19
+ settingsPath,
20
+ syncActiveTools as syncWebAccessTools,
21
+ } from "./web-access.mjs";
15
22
  const SCOPE_ENTRY = "specpi-scope-state";
16
23
  const SCOPE_STATUS = "specpi-scope";
17
24
  const MAX_PENDING_SCOPE = 40;
@@ -272,6 +279,11 @@ export default function workflowControls(pi: ExtensionAPI) {
272
279
  persistScope(ctx);
273
280
  };
274
281
 
282
+ // Restore empties scope first and only learns the real root once `git rev-parse` returns, so between those two
283
+ // moments `scope.root` is a guess at the session cwd: not yet canonical, and not yet the enclosing repository.
284
+ // A human command that declared scope in that gap recorded it under the guessed root, and the replay below then
285
+ // rejected its own branch entry as belonging elsewhere, silently retiring a contract the human was told was set.
286
+ // Scope commands therefore wait for `sessionRestore` rather than racing it.
275
287
  const restoreSession = async (ctx: ExtensionContext) => {
276
288
  sessionGeneration += 1;
277
289
  const origin = captureSession(ctx);
@@ -337,8 +349,24 @@ export default function workflowControls(pi: ExtensionAPI) {
337
349
  emitScopeStatus(ctx);
338
350
  };
339
351
 
340
- pi.on("session_start", (_event, ctx) => restoreSession(ctx));
341
- pi.on("session_tree", (_event, ctx) => restoreSession(ctx));
352
+ let sessionRestore: Promise<void> = Promise.resolve();
353
+ const beginRestore = (ctx: ExtensionContext) => {
354
+ // Waiters only need to know the restore is over; restoreSession reports its own failures.
355
+ sessionRestore = restoreSession(ctx).catch(() => {});
356
+ };
357
+
358
+ // Web access ships hidden. A missing or unreadable preference means off, and the
359
+ // gate only ever touches its own four tool names.
360
+ let webAccessEnabled = loadStartupActivation();
361
+ const applyWebAccess = () => syncWebAccessTools(pi, WEB_TOOL_NAMES, webAccessEnabled);
362
+
363
+ pi.on("session_start", (_event, ctx) => {
364
+ webAccessEnabled = loadStartupActivation();
365
+ applyWebAccess();
366
+ beginRestore(ctx);
367
+ });
368
+
369
+ pi.on("session_tree", (_event, ctx) => beginRestore(ctx));
342
370
 
343
371
  pi.on("session_shutdown", (_event, ctx) => {
344
372
  sessionGeneration += 1;
@@ -517,6 +545,9 @@ export default function workflowControls(pi: ExtensionAPI) {
517
545
  .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
518
546
  .map((value) => ({ value, label: value })),
519
547
  handler: async (args, ctx) => {
548
+ // Read scope only after any restore in flight has settled: before that, both the default action and the
549
+ // root every branch below records are taken from a provisional, pre-Git-lookup guess.
550
+ await sessionRestore;
520
551
  const origin = captureSession(ctx);
521
552
  const [actionRaw, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
522
553
  const action = actionRaw?.toLowerCase() || (scope.active ? "status" : "set");
@@ -749,4 +780,70 @@ export default function workflowControls(pi: ExtensionAPI) {
749
780
  }
750
781
  },
751
782
  });
783
+
784
+ pi.registerCommand("webaccess", {
785
+ description: "Offer or withdraw the web access tools, or choose whether they start offered",
786
+ getArgumentCompletions: (prefix: string) =>
787
+ ["on", "off", "status", "startup", "startup on", "startup off"]
788
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
789
+ .map((value) => ({ value, label: value })),
790
+ handler: async (args: string, ctx: ExtensionContext) => {
791
+ const [action = "status", choice, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
792
+ if (rest.length || (choice && action.toLowerCase() !== "startup")) {
793
+ throw new Error("Usage: /webaccess [on|off|status|startup [on|off]]");
794
+ }
795
+
796
+ const verb = action.toLowerCase();
797
+ if (verb === "on" || verb === "off") {
798
+ webAccessEnabled = verb === "on";
799
+ applyWebAccess();
800
+ ctx.ui.notify(
801
+ webAccessEnabled
802
+ ? `Web access offered ${WEB_TOOL_NAMES.length} tools to this session (web_search, source_check, fetch_content, get_search_content). They add about 11 KB of tool schema to each request until /webaccess off.`
803
+ : "Web access withdrew its tools from this session. Search and fetch now require /webaccess on.",
804
+ "info",
805
+ );
806
+
807
+ return;
808
+ }
809
+
810
+ if (verb === "startup") {
811
+ if (!choice) {
812
+ ctx.ui.notify(
813
+ `Web access starts ${loadStartupActivation() ? "offered" : "withdrawn"}. Preference: ${settingsPath()}`,
814
+ "info",
815
+ );
816
+
817
+ return;
818
+ }
819
+
820
+ if (!ctx.hasUI) {
821
+ throw new Error("Startup changes require a human interactive command");
822
+ }
823
+
824
+ if (!["on", "off"].includes(choice.toLowerCase())) {
825
+ throw new Error("Usage: /webaccess startup [on|off]");
826
+ }
827
+
828
+ saveStartupActivation(choice.toLowerCase() === "on");
829
+ ctx.ui.notify(
830
+ choice.toLowerCase() === "on"
831
+ ? "Web access tools will be offered in new Pi sessions, adding their schemas to every request. This session is unchanged."
832
+ : "Web access tools will start withdrawn in new Pi sessions and their schemas will not be sent. This session is unchanged.",
833
+ "info",
834
+ );
835
+
836
+ return;
837
+ }
838
+
839
+ if (verb !== "status") {
840
+ throw new Error("Usage: /webaccess [on|off|status|startup [on|off]]");
841
+ }
842
+
843
+ ctx.ui.notify(
844
+ `Web access is ${webAccessEnabled ? "offering" : "not offering"} its ${WEB_TOOL_NAMES.length} tools to this session, and starts ${loadStartupActivation() ? "offered" : "withdrawn"}.`,
845
+ "info",
846
+ );
847
+ },
848
+ });
752
849
  }
@@ -0,0 +1,107 @@
1
+ // pi-web-access registers four network tools (web_search, source_check, fetch_content,
2
+ // get_search_content). Pi sends every active tool's schema on every request, so a project
3
+ // that never searches would pay for them on every call. SpecPi therefore hides them behind
4
+ // a saved preference that ships off, and `/webaccess on` turns them on for a session.
5
+ //
6
+ // This file is SpecPi's own control: it never modifies the installed package, never reads
7
+ // Pi settings, credentials or session state, and a missing or unreadable file means off.
8
+
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+
14
+ export const WEB_TOOL_NAMES = Object.freeze(["web_search", "source_check", "fetch_content", "get_search_content"]);
15
+
16
+ const MAX_SETTINGS_BYTES = 4096;
17
+
18
+ function agentDirectory() {
19
+ const configured = process.env.PI_CODING_AGENT_DIR;
20
+
21
+ return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
22
+ }
23
+
24
+ function settingsFile() {
25
+ return path.join(agentDirectory(), "specpi", "web-access", "settings.json");
26
+ }
27
+
28
+ /** Refuses links and irregular files so the preference cannot redirect a write. */
29
+ function regularFile(file) {
30
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
31
+ if (!stat) {
32
+ return false;
33
+ }
34
+
35
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_SETTINGS_BYTES) {
36
+ throw new Error("Unsupported web access settings file");
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ export function loadStartupActivation() {
43
+ try {
44
+ const file = settingsFile();
45
+ if (!regularFile(file)) {
46
+ return false;
47
+ }
48
+
49
+ const settings = JSON.parse(fs.readFileSync(file, "utf8"));
50
+
51
+ return settings?.schema === 1 && settings.startupActivation === true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export function saveStartupActivation(enabled) {
58
+ if (typeof enabled !== "boolean") {
59
+ throw new Error("Web access startup activation must be on or off.");
60
+ }
61
+
62
+ const file = settingsFile();
63
+ const directory = path.dirname(file);
64
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
65
+ if (fs.existsSync(file)) {
66
+ regularFile(file);
67
+ }
68
+
69
+ const temporary = path.join(directory, `.settings.${randomUUID()}.tmp`);
70
+ try {
71
+ fs.writeFileSync(temporary, `${JSON.stringify({ schema: 1, startupActivation: enabled })}\n`, {
72
+ mode: 0o600,
73
+ flag: "wx",
74
+ });
75
+ fs.renameSync(temporary, file);
76
+ } finally {
77
+ fs.rmSync(temporary, { force: true });
78
+ }
79
+ }
80
+
81
+ export function settingsPath() {
82
+ return settingsFile();
83
+ }
84
+
85
+ /**
86
+ * Add or remove the web tools from Pi's active set without disturbing any other
87
+ * extension's tools, including built-ins and the other first-party gates.
88
+ */
89
+ export function syncActiveTools(pi, names, enabled) {
90
+ if (typeof pi?.getActiveTools !== "function" || typeof pi?.setActiveTools !== "function") {
91
+ return;
92
+ }
93
+
94
+ const owned = new Set(names);
95
+ const active = pi.getActiveTools();
96
+ const present = active.filter((name) => owned.has(name));
97
+ if (enabled && present.length === owned.size) {
98
+ return;
99
+ }
100
+
101
+ if (!enabled && present.length === 0) {
102
+ return;
103
+ }
104
+
105
+ const others = active.filter((name) => !owned.has(name));
106
+ pi.setActiveTools(enabled ? [...others, ...names] : others);
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -37,6 +37,7 @@ const resourcePaths = [
37
37
  "extensions/workflow-controls/scope.mjs",
38
38
  "extensions/workflow-controls/task-contract.mjs",
39
39
  "extensions/workflow-controls/smoke.mjs",
40
+ "extensions/workflow-controls/web-access.mjs",
40
41
  "extensions/tool-wishlist/index.ts",
41
42
  "extensions/tool-wishlist/core.mjs",
42
43
  "extensions/tool-wishlist/verification.mjs",
@@ -6,6 +6,7 @@
6
6
  - Write commits and pull requests the same way: a short, clear title and a brief explanation of what changed, why, and what was tested. Avoid boilerplate, inflated claims, and unnecessary implementation detail.
7
7
  - When `/scope` is active, keep outside-scope findings pending until the human allows once, acknowledges them with `/scope accept`, expands scope with `/scope add`, or clears it. Acknowledgement does not widen scope.
8
8
  - Treat wishlist observations as leads, not authorization. Start a wishlist-sourced change only from an exact `/harness-improvement` selection and follow `specpi-improve`. Record its contract before editing and retire only after its verification gate passes.
9
+ - Web access tools (`web_search`, `source_check`, `fetch_content`, `get_search_content`) ship hidden. When the task needs search or fetch, ask the human to run `/webaccess on`; do not attempt a hidden tool.
9
10
  - Never inspect Pi authentication, provider credentials, trust decisions, sessions, missions, or history to improve the harness. Use only the active extension context and intended local improvement records.
10
11
  - Use observed files, diffs, tests, and runtime behavior as evidence. Run relevant checks, inspect the final diff, and obtain fresh read-only review when risk warrants it. Report results and residual risks without claiming more than the checks prove.
11
12
  - Do not commit, push, publish, deploy, or alter remote state unless explicitly requested.