specpi 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +37 -3
  3. package/SECURITY_MODEL.md +44 -4
  4. package/THIRD_PARTY.md +9 -1
  5. package/extensions/jev-advisor/broker.mjs +277 -0
  6. package/extensions/jev-advisor/client.mjs +172 -0
  7. package/extensions/jev-advisor/config.mjs +270 -0
  8. package/extensions/jev-advisor/consent.mjs +133 -0
  9. package/extensions/jev-advisor/gate.mjs +263 -0
  10. package/extensions/jev-advisor/index.ts +999 -0
  11. package/extensions/jev-advisor/key-source.mjs +252 -0
  12. package/extensions/jev-advisor/layer.mjs +169 -0
  13. package/extensions/jev-advisor/ledger.mjs +138 -0
  14. package/extensions/jev-advisor/questions/capabilities.mjs +124 -0
  15. package/extensions/jev-advisor/questions/compaction.mjs +153 -0
  16. package/extensions/jev-advisor/questions/gap.mjs +140 -0
  17. package/extensions/jev-advisor/questions/guard.mjs +168 -0
  18. package/extensions/jev-advisor/questions/progress.mjs +195 -0
  19. package/extensions/jev-advisor/questions/retention.mjs +188 -0
  20. package/extensions/jev-advisor/questions/sources.mjs +91 -0
  21. package/extensions/jev-advisor/questions/untrusted.mjs +69 -0
  22. package/extensions/jev-advisor/risk.mjs +442 -0
  23. package/extensions/jev-advisor/sanitize.mjs +0 -0
  24. package/extensions/jev-advisor/usage.mjs +92 -0
  25. package/extensions/tool-wishlist/authoring-tools.mjs +42 -0
  26. package/extensions/tool-wishlist/index.ts +11 -0
  27. package/extensions/workflow-controls/capabilities.mjs +26 -0
  28. package/extensions/workflow-controls/index.ts +2 -2
  29. package/package.json +1 -1
  30. package/scripts/packages.mjs +56 -0
  31. package/scripts/specpi.mjs +73 -4
@@ -884,7 +884,7 @@ export default function workflowControls(pi: ExtensionAPI) {
884
884
  pi.registerTool({
885
885
  name: "request_capability",
886
886
  label: "Request Capability",
887
- description: `Ask the user to restore a withdrawn SpecPi tool group for this session. Available groups — ${describeCapabilities()}. Their tools are hidden to keep each request small, so request a group only when the current task actually needs it, and continue without it if the user declines. The restored tools are usable from your next message and stay available until the session ends. Delegation is not requestable here; ask the user to run /delegate on.`,
887
+ description: `Ask the user to restore a withdrawn SpecPi tool group for this session. Available groups — ${describeCapabilities()}. Their tools are hidden to keep each request small, and restoring one mid-session also discards the provider's cached prompt prefix, which measured about 20% of a mid-length attempt's cost in this project's own testing — so request a group only when the current task actually needs it, and continue without it if the user declines. The restored tools are usable from your next message and stay available until the session ends. Delegation is not requestable here; ask the user to run /delegate on.`,
888
888
  parameters: Type.Object(
889
889
  {
890
890
  capability: StringEnum(CAPABILITY_NAMES, {
@@ -959,7 +959,7 @@ export default function workflowControls(pi: ExtensionAPI) {
959
959
  standing ||
960
960
  (await ctx.ui.confirm(
961
961
  `Allow ${capability.label} for this session?`,
962
- `The agent asked to ${capability.summary}. Reason given: ${safeMessage(params.reason)}\n\nThis offers ${pending.length} tool${pending.length === 1 ? "" : "s"} for the rest of this session and adds ${capability.schemaCost}. Withdraw them again with ${capability.command} off.`,
962
+ `The agent asked to ${capability.summary}. Reason given: ${safeMessage(params.reason)}\n\nThis offers ${pending.length} tool${pending.length === 1 ? "" : "s"} for the rest of this session, adds ${capability.schemaCost}, ${capability.activationCost}. Withdraw them again with ${capability.command} off.`,
963
963
  ));
964
964
  if (!accepted) {
965
965
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -48,6 +48,54 @@ export function runBrowserQA(agentDir, command) {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * Packages a past SpecPi version pinned and this one no longer does.
53
+ *
54
+ * Dropping an entry from `templates/settings.json` stops new installs getting it and does nothing at
55
+ * all to a machine that already has it: the entry stays in `settings.json` and Pi keeps loading it.
56
+ * That is tolerable for a package that merely stopped being useful, and not tolerable for
57
+ * `specpi-jev-guard`, which fails closed -- an install left holding it after SpecPi deleted both the
58
+ * code that kept it inert and the `/jev guard off` command that could disarm it would block every
59
+ * shell call the moment its key or its endpoint went away, with nothing left to turn it off.
60
+ *
61
+ * So retirement is explicit, and it removes the entry rather than waiting for the restore path to.
62
+ */
63
+ export const retiredPackages = Object.freeze(["npm:specpi-jev-guard"]);
64
+
65
+ /**
66
+ * Drop retired entries from a settings object, in place, returning what was removed.
67
+ *
68
+ * Every entry goes, whatever shape it has. That is a deliberate exception to the ownership rule the
69
+ * restore path applies elsewhere -- "a user-modified entry is theirs" -- and the exception is the
70
+ * reason the list is not open to additions. Preserving a modified `specpi-jev-guard` entry preserved
71
+ * a fail-closed gate that this release removed the controls for: the code that rewrote its global
72
+ * configuration to `enabled: false` at every session start is gone, and so is `/jev guard off`, so a
73
+ * preserved entry is a gate that refuses every shell call the moment its key or endpoint goes away,
74
+ * with nothing left to turn it off. Filters are worth less than that.
75
+ *
76
+ * The removed entry is returned verbatim so the caller can print it back, and the write it belongs
77
+ * to is inside the installer's transaction and backed up with everything else. Version is not
78
+ * consulted: the reason for retirement is the package.
79
+ */
80
+ export function removeRetiredPackages(settings) {
81
+ if (!Array.isArray(settings?.packages)) {
82
+ return [];
83
+ }
84
+
85
+ const removed = [];
86
+ settings.packages = settings.packages.filter((entry) => {
87
+ if (!retiredPackages.includes(packageIdentity(entry))) {
88
+ return true;
89
+ }
90
+
91
+ removed.push(packageSource(entry));
92
+
93
+ return false;
94
+ });
95
+
96
+ return removed;
97
+ }
98
+
51
99
  export function packageChanges(before, after) {
52
100
  return basePackages.map((source) => {
53
101
  const identity = packageIdentity(source);
@@ -134,6 +182,14 @@ export function installBasePackages(agentDir) {
134
182
 
135
183
  export function checkBasePackages(agentDir, settings) {
136
184
  const errors = [];
185
+ for (const entry of Array.isArray(settings.packages) ? settings.packages : []) {
186
+ if (retiredPackages.includes(packageIdentity(entry))) {
187
+ errors.push(
188
+ `Retired base package still configured: ${packageSource(entry)}. Run specpi update to unpin it.`,
189
+ );
190
+ }
191
+ }
192
+
137
193
  for (const source of basePackages) {
138
194
  const identity = packageIdentity(source);
139
195
  const entry =
@@ -22,7 +22,14 @@ import {
22
22
  import { validateCapabilityRegistry } from "../extensions/tool-wishlist/registry.mjs";
23
23
  import { runValidator } from "../extensions/tool-wishlist/validators.mjs";
24
24
  import { acquireSpecPiLock } from "./lock.mjs";
25
- import { basePackages, checkBasePackages, installBasePackages, packageChanges, runBrowserQA } from "./packages.mjs";
25
+ import {
26
+ basePackages,
27
+ checkBasePackages,
28
+ installBasePackages,
29
+ packageChanges,
30
+ removeRetiredPackages,
31
+ runBrowserQA,
32
+ } from "./packages.mjs";
26
33
 
27
34
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
35
  const VERSION = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
@@ -46,6 +53,27 @@ const resourcePaths = [
46
53
  "extensions/tool-wishlist/registry.mjs",
47
54
  "extensions/tool-wishlist/validators.mjs",
48
55
  "extensions/tool-wishlist/capabilities.json",
56
+ "extensions/tool-wishlist/authoring-tools.mjs",
57
+ "extensions/jev-advisor/index.ts",
58
+ "extensions/jev-advisor/config.mjs",
59
+ "extensions/jev-advisor/consent.mjs",
60
+ "extensions/jev-advisor/ledger.mjs",
61
+ "extensions/jev-advisor/sanitize.mjs",
62
+ "extensions/jev-advisor/usage.mjs",
63
+ "extensions/jev-advisor/client.mjs",
64
+ "extensions/jev-advisor/key-source.mjs",
65
+ "extensions/jev-advisor/layer.mjs",
66
+ "extensions/jev-advisor/risk.mjs",
67
+ "extensions/jev-advisor/questions/guard.mjs",
68
+ "extensions/jev-advisor/broker.mjs",
69
+ "extensions/jev-advisor/gate.mjs",
70
+ "extensions/jev-advisor/questions/retention.mjs",
71
+ "extensions/jev-advisor/questions/compaction.mjs",
72
+ "extensions/jev-advisor/questions/gap.mjs",
73
+ "extensions/jev-advisor/questions/sources.mjs",
74
+ "extensions/jev-advisor/questions/progress.mjs",
75
+ "extensions/jev-advisor/questions/untrusted.mjs",
76
+ "extensions/jev-advisor/questions/capabilities.mjs",
49
77
  "skills/specpi-improve/SKILL.md",
50
78
  ];
51
79
 
@@ -59,7 +87,7 @@ Usage:
59
87
  specpi doctor
60
88
  specpi uninstall [--yes]
61
89
 
62
- Installs /scope, the harness improvement loop, and six pinned packages.
90
+ Installs /scope, the harness improvement loop, and seven pinned packages.
63
91
  The base is tested with Pi 0.84.4. Run specpi plan to see package versions.
64
92
  --skip-package-install installs only the core, or preserves an existing base on update.
65
93
  --skip-browser-install skips Chromium setup, not package acquisition or doctor checks.
@@ -142,7 +170,9 @@ function validateManifestPaths(manifest) {
142
170
  assertLocalPath(target, agentDir);
143
171
  const relative = path.relative(agentDir, target).replaceAll("\\", "/");
144
172
  if (
145
- !/^(extensions\/(?:workflow-controls|tool-wishlist|browser|command-guard|delegation|background-tasks|structural-search|files|spec|specpi-ui-refresh)\/[^/]+|extensions\/spec\.ts|skills\/(?:specpi-improve|donsetch)\/SKILL\.md|themes\/(?:tea-house|specpi-spec)\.json|specpi\/pi-profiles\.sh)$/.test(
173
+ // jev-advisor is the one managed extension with a subdirectory, so it is listed
174
+ // separately rather than loosening the single-segment rule for every other family.
175
+ !/^(extensions\/(?:workflow-controls|tool-wishlist|browser|command-guard|delegation|background-tasks|structural-search|files|spec|specpi-ui-refresh)\/[^/]+|extensions\/jev-advisor\/(?:questions\/)?[^/]+|extensions\/spec\.ts|skills\/(?:specpi-improve|donsetch)\/SKILL\.md|themes\/(?:tea-house|specpi-spec)\.json|specpi\/pi-profiles\.sh)$/.test(
146
176
  relative,
147
177
  )
148
178
  ) {
@@ -230,6 +260,32 @@ function restoreLegacySettings(manifest, warnings) {
230
260
  writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
231
261
  }
232
262
 
263
+ /**
264
+ * Unpin a package a past version installed and this one has retired.
265
+ *
266
+ * Deliberately outside the `--skip-packages` guard. Skipping package acquisition means not
267
+ * downloading or re-pinning anything, and it has never meant leaving an entry SpecPi itself wrote
268
+ * pointing at code SpecPi has since removed the controls for -- `specpi-jev-guard` is fail-closed,
269
+ * so the machine that skipped packages is exactly the machine that would keep it armed forever.
270
+ *
271
+ * `settingsPath` is in the transaction's watched set for every non-uninstall operation, so this
272
+ * write is snapshotted, backed up and rolled back with everything else.
273
+ */
274
+ function retireBasePackages(warnings) {
275
+ const settings = readJson(settingsPath, {});
276
+ const removed = removeRetiredPackages(settings);
277
+ if (removed.length === 0) {
278
+ return;
279
+ }
280
+
281
+ writeJson(settingsPath, settings, existingMode(settingsPath, 0o600));
282
+ for (const source of removed) {
283
+ warnings.push(
284
+ `Unpinned retired package: ${source}. Its downloaded files stay in the agent npm directory, Pi no longer loads it, and any settings of your own on that entry are in this run's backup.`,
285
+ );
286
+ }
287
+ }
288
+
233
289
  function removeLegacyShell(manifest) {
234
290
  if (manifest?.shellRc && fs.existsSync(manifest.shellRc)) {
235
291
  const result = removeManagedBlock(fs.readFileSync(manifest.shellRc, "utf8"), SHELL_START, SHELL_END);
@@ -284,7 +340,16 @@ async function mutate(options, operation) {
284
340
  ...files.map(([, target]) => target),
285
341
  ...Object.keys(previous?.files || {}),
286
342
  ];
287
- if (!options.skipPackages || previous?.settingsChanges?.length || previous?.packageChanges?.length) {
343
+ // Watched whenever anything in this run can write it. `retireBasePackages` runs on every
344
+ // non-uninstall operation, including under `--skip-package-install`, so the narrower
345
+ // condition that used to guard this left that write outside the snapshot -- unbacked up, and
346
+ // not rolled back by a later failure in the same transaction.
347
+ if (
348
+ operation !== "uninstall" ||
349
+ !options.skipPackages ||
350
+ previous?.settingsChanges?.length ||
351
+ previous?.packageChanges?.length
352
+ ) {
288
353
  watched.push(settingsPath);
289
354
  }
290
355
 
@@ -305,6 +370,10 @@ async function mutate(options, operation) {
305
370
  const warnings = [];
306
371
  const preserveBase = operation !== "uninstall" && options.skipPackages && previous?.basePackages?.length;
307
372
  restoreLegacySettings(preserveBase ? { ...previous, packageChanges: [] } : previous, warnings);
373
+ if (operation !== "uninstall") {
374
+ retireBasePackages(warnings);
375
+ }
376
+
308
377
  removeLegacyShell(previous);
309
378
  let packageState = preserveBase
310
379
  ? {