specpi 0.30.1 → 0.32.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.
@@ -5,8 +5,10 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
5
5
  import { Type } from "typebox";
6
6
  import {
7
7
  CAPABILITY_NAMES,
8
+ CAPABILITY_REQUEST_TOOL,
8
9
  capabilityActive,
9
10
  capabilityInstalled,
11
+ capabilityRequestOffered,
10
12
  describeCapabilities,
11
13
  findCapability,
12
14
  missingTools,
@@ -389,6 +391,17 @@ export default function workflowControls(pi: ExtensionAPI) {
389
391
  );
390
392
  });
391
393
 
394
+ // Decided once per session, before the first request, so the cached prompt prefix is never
395
+ // disturbed. See capabilityRequestOffered for why a session may not need the tool at all.
396
+ const applyCapabilityRequest = (ctx: ExtensionContext) => {
397
+ const allToolNames = typeof pi.getAllTools === "function" ? pi.getAllTools().map((tool) => tool.name) : [];
398
+ syncToolGroup(
399
+ pi,
400
+ [CAPABILITY_REQUEST_TOOL],
401
+ capabilityRequestOffered({ interactive: ctx.hasUI, allToolNames }),
402
+ );
403
+ };
404
+
392
405
  // Web access ships hidden. A missing or unreadable preference means off, and the
393
406
  // gate only ever touches its own four tool names.
394
407
  let webAccessEnabled = loadStartupActivation();
@@ -397,6 +410,7 @@ export default function workflowControls(pi: ExtensionAPI) {
397
410
  pi.on("session_start", (_event, ctx) => {
398
411
  webAccessEnabled = loadStartupActivation();
399
412
  applyWebAccess();
413
+ applyCapabilityRequest(ctx);
400
414
  beginRestore(ctx);
401
415
  });
402
416
 
@@ -561,7 +575,7 @@ export default function workflowControls(pi: ExtensionAPI) {
561
575
  if (scope.active) {
562
576
  const taskStale = scope.taskDigest !== undefined && currentTask?.digest !== scope.taskDigest;
563
577
  guidance.push(
564
- `[SPECPI SCOPE]\nDeclared paths: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}\nPending outside-scope paths: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}. Do not describe pending paths as accepted scope.${taskStale ? "\nTask-bound scope is stale; do not widen it automatically." : ""}`,
578
+ `[SPECPI SCOPE]\nDeclared paths: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}\nPending outside-scope paths: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}. 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, and pending paths are not accepted scope.${taskStale ? "\nTask-bound scope is stale; do not widen it automatically." : ""}`,
565
579
  );
566
580
  }
567
581
 
@@ -905,9 +919,9 @@ export default function workflowControls(pi: ExtensionAPI) {
905
919
  };
906
920
 
907
921
  pi.registerTool({
908
- name: "request_capability",
922
+ name: CAPABILITY_REQUEST_TOOL,
909
923
  label: "Request Capability",
910
- 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.`,
924
+ description: `Ask the user to restore a withdrawn tool group for this session — ${describeCapabilities()}. Hidden tools cannot be called until restored. Ask only when the task needs one: restoring mid-session discards the cached prompt prefix, about 20% of a mid-length task's cost. Restored tools work from your next message; if the user declines, continue without them. Delegation is not requestable; ask the user to run /delegate on.`,
911
925
  parameters: Type.Object(
912
926
  {
913
927
  capability: StringEnum(CAPABILITY_NAMES, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "description": "Scope control and a human-selected harness improvement loop for Pi",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -62,7 +62,6 @@ const resourcePaths = [
62
62
  "extensions/jev-advisor/questions/retention.mjs",
63
63
  "extensions/jev-advisor/questions/gap.mjs",
64
64
  "extensions/jev-advisor/questions/sources.mjs",
65
- "extensions/jev-advisor/questions/progress.mjs",
66
65
  "extensions/jev-advisor/questions/untrusted.mjs",
67
66
  "extensions/jev-advisor/questions/capabilities.mjs",
68
67
  "skills/specpi-improve/SKILL.md",
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: specpi-improve
3
3
  description: Turns a selected SpecPi capability-gap wishlist item into a minimal, evidence-backed, reversible improvement. Use when the user asks to improve SpecPi from its wishlist, act on a selected gap, or complete the notice-to-retire loop.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # SpecPi Improve
@@ -2,11 +2,7 @@
2
2
 
3
3
  - Keep changes small, testable, reversible, and tied to the human's request. Inspect existing code and guidance before editing; avoid adjacent feature accumulation.
4
4
  - Prefer direct work and existing project tools. Ask concise questions only when material requirements are unclear.
5
- - Keep replies brief and human-readable. Lead with the answer, use plain language, and avoid jargon, technical babble, and repetitive progress updates. Include technical detail only when it helps the human decide or act.
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
- - 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
- - 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 and Browser QA tools ship hidden to keep each request small. When the task genuinely needs one, call `request_capability`; it asks the human, who may decline. Continue without the capability if they do, and do not attempt a hidden tool. The human can also run `/webaccess on` or `/browser on` directly. Delegation is not requestable this way; ask the human to run `/delegate on`.
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.
5
+ - Keep replies brief and human-readable: lead with the answer, use plain language, avoid jargon and repetitive progress updates, and include technical detail only when it helps the human decide or act.
6
+ - Write commits and pull requests the same way: a short, clear title and a brief note on what changed, why, and what was tested.
11
7
  - 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.
12
8
  - Do not commit, push, publish, deploy, or alter remote state unless explicitly requested.
@@ -1,195 +0,0 @@
1
- // System 5: notice that a session has stopped making progress, while it can still be helped.
2
- //
3
- // This is the only system aimed at turns rather than at input tokens, and turns are where the money
4
- // is on the hard tiers. Splitting recorded cost three ways: at tier 3 output is 59% of spend, at
5
- // tier 4 66%, at tier 5 56%. Output is bought by turns, and every other system here targets
6
- // input. At tier 4 `specpi-jev` runs cheaper per turn than `specpi-default` -- $0.000538 against
7
- // $0.000613 -- and costs more per attempt anyway, because it takes 29.4 turns against 24.8.
8
- //
9
- // The evidence that turns are wasted rather than merely numerous is in the same runs.
10
- // `specpi-default` invoked `webqa` 199 times across 30 turns on `t4-browser-triage` and still
11
- // failed. Pi stock abandoned after two tries and burned the full 600-second timeout on two tier-4
12
- // tasks, scoring zero both times. A turn costs 4-7 seconds and $0.0005-$0.0011; a Jev call costs
13
- // about 300ms and $0.0000105. Preventing one timeout pays for roughly two thousand calls.
14
- //
15
- // LOCAL STATE FIRST, which is the standing rule and the reason this is affordable. Local state
16
- // cannot answer "is this session stuck" -- that needs a judgement about whether the work is going
17
- // anywhere -- but it answers "is that question worth asking", and it answers it cheaply. Nothing is
18
- // sent unless a repeated tool signature, a run of errors or a stretch of turns without a file
19
- // change has already made the session look suspicious.
20
- //
21
- // It is an append, so it passes the standing cache rule: the line goes on the end of the transcript
22
- // at a turn boundary and invalidates no prefix. It is written once and never retracted, because
23
- // retracting it would mean rewriting the thing it was appended to.
24
- //
25
- // The plan named `deliverAs: "nextTurn"` for that append. Pi documents that mode as "queued for
26
- // next user prompt, does not interrupt or trigger anything", and an unattended session has exactly
27
- // one user prompt -- so a nextTurn message would never arrive, in precisely the case the argument
28
- // for this system rests on. The caller uses "steer" instead: delivered after the current tool calls
29
- // finish and before the next model request, which is the same append at the same boundary and is
30
- // actually read. `triggerTurn` stays off, so this can never add a turn of its own.
31
-
32
- import { choice, noul } from "../client.mjs";
33
- import { choiceValue, nounTrue } from "../gate.mjs";
34
- import { compact } from "../sanitize.mjs";
35
-
36
- /**
37
- * The same taxonomy `scripts/jev-triage.mjs` uses offline. It lives here, in the shipped extension,
38
- * and the script imports it: the corpus that calibrates this system and the enum this system asks
39
- * against have to be the same object, or the published failure-mode distribution is measuring
40
- * something the session never asks.
41
- */
42
- export const FAILURE_MODES = Object.freeze({
43
- "gave-up-on-fault": "Met an injected command failure and stopped instead of retrying",
44
- "turn-cap": "Ran out of turns or requests while still working",
45
- timeout: "Exceeded the wall-clock limit",
46
- "wrong-approach": "Worked steadily but solved the wrong problem",
47
- "misread-requirement": "Produced output that misses a stated requirement",
48
- "scope-violation": "Changed files it was told to leave alone",
49
- "tool-error-loop": "Repeated the same failing tool call without progress",
50
- "harness-error": "The harness itself crashed or could not start",
51
- unknown: "Not determinable from what was recorded",
52
- });
53
-
54
- /** Modes a session can still act on. A timeout or a crashed harness is not advice, it is an epitaph. */
55
- export const ACTIONABLE_MODES = Object.freeze(
56
- new Set(["wrong-approach", "misread-requirement", "tool-error-loop", "gave-up-on-fault", "scope-violation"]),
57
- );
58
-
59
- /** Tools whose success means the worktree changed. Used only to decide whether anything happened. */
60
- export const MUTATING_TOOLS = Object.freeze(
61
- new Set(["write", "edit", "multi_edit", "apply_patch", "create_file", "str_replace"]),
62
- );
63
-
64
- /** Turns without a single successful mutation before that counts as a reason to look. */
65
- export const STALE_TURNS = 4;
66
- const REPEAT_SIGNATURES = 2;
67
- const CONSECUTIVE_ERRORS = 3;
68
-
69
- /**
70
- * Turns to stay quiet after asking. Conditions persist for many turns at a time, so without this
71
- * the same unchanged situation is re-asked every turn until the budget runs out.
72
- */
73
- export const ASK_COOLDOWN_TURNS = 4;
74
-
75
- /**
76
- * A tool call reduced to something comparable. The arguments matter -- reading two different files
77
- * twice is work, reading one file twice is a loop -- but their contents do not, so this keeps a
78
- * short normalized form rather than the payload.
79
- */
80
- export function signature(toolName, input) {
81
- return compact(`${toolName}:${JSON.stringify(input ?? {})}`, 120);
82
- }
83
-
84
- /**
85
- * Whether local state alone already justifies the question. Returns the reasons rather than a bare
86
- * boolean, because they go into the state as the grounds for asking and a caller logging "asked"
87
- * without "why" cannot audit the budget afterwards.
88
- *
89
- * One reason is not enough, and that is a correction rather than a precaution. The first version
90
- * asked whenever any single signal fired, and a live run of `t3-cascade-ledger` spent all twelve
91
- * calls of its budget on a session that finished with a score of 0.978. A 120-step repair chain
92
- * re-runs the same verification command constantly, so "a tool signature repeated" is its normal
93
- * condition, not a symptom. The same is true of "no file written for four turns" during a long
94
- * read: a research task looks identical to a stuck one from that signal alone.
95
- *
96
- * So a repeated call or a quiet stretch must coincide with something else before it is worth
97
- * asking about. A run of consecutive errors stands alone, because nothing healthy produces three
98
- * failures in a row.
99
- */
100
- export function suspicious(history) {
101
- const reasons = [];
102
- const signatures = history?.signatures ?? [];
103
- const counts = new Map();
104
- for (const item of signatures) {
105
- counts.set(item, (counts.get(item) ?? 0) + 1);
106
- }
107
-
108
- const repeated = [...counts.entries()].filter(([, count]) => count >= REPEAT_SIGNATURES);
109
- if (repeated.length > 0) {
110
- reasons.push("repeated-tool-call");
111
- }
112
-
113
- const errors = (history?.consecutiveErrors ?? 0) >= CONSECUTIVE_ERRORS;
114
- if (errors) {
115
- reasons.push("consecutive-errors");
116
- }
117
-
118
- if ((history?.turnsSinceChange ?? 0) >= STALE_TURNS) {
119
- reasons.push("no-file-change");
120
- }
121
-
122
- // Conditions persist across turns, so a verdict of "not stuck" is still the right answer three
123
- // turns later and asking again buys nothing. The cooldown is what stops one situation being
124
- // charged for repeatedly.
125
- const cooling = Number.isFinite(history?.askedAtTurn)
126
- ? (history.turn ?? 0) - history.askedAtTurn < ASK_COOLDOWN_TURNS
127
- : false;
128
-
129
- return {
130
- ask: !cooling && (errors || reasons.length >= 2),
131
- reasons,
132
- repeatedSignatures: repeated.length,
133
- cooling,
134
- };
135
- }
136
-
137
- /** Shape and counts. No tool output, no file contents, no arguments beyond a normalized signature. */
138
- export function buildInput({ history, objective, reasons }) {
139
- return {
140
- objective: compact(objective ?? "", 180),
141
- turn: history?.turn ?? 0,
142
- reasons,
143
- turnsSinceFileChange: history?.turnsSinceChange ?? 0,
144
- consecutiveErrors: history?.consecutiveErrors ?? 0,
145
- distinctTools: [...new Set((history?.tools ?? []).slice(-12))].slice(0, 12),
146
- repeatedCalls: suspicious(history).repeatedSignatures,
147
- recentErrors: (history?.errors ?? []).slice(-4).map((item) => compact(item, 80)),
148
- filesChanged: history?.filesChanged ?? 0,
149
- };
150
- }
151
-
152
- export function questions() {
153
- return {
154
- // Deliberately two questions rather than one. "Is it stuck" is the decision; "which mode"
155
- // is what makes a fixed remedy line possible without any model-written prose.
156
- is_stuck: noul("This session has stopped making progress and will not finish without changing approach"),
157
- failure_mode: choice("If this session is going to fail, why?", FAILURE_MODES),
158
- needs_human: noul("A person would have to answer something before this session could continue"),
159
- };
160
- }
161
-
162
- /** One fixed line per mode. Code-written, so nothing the model produced reaches the transcript. */
163
- const REMEDIES = Object.freeze({
164
- "wrong-approach":
165
- "Progress check: the last few turns have not moved the task forward. Re-read the objective and state, in one line, what the current approach is meant to achieve before continuing.",
166
- "misread-requirement":
167
- "Progress check: the work so far may not match what was asked. Re-read the requirements and list which ones are satisfied and which are not, before making further changes.",
168
- "tool-error-loop":
169
- "Progress check: the same tool call has failed repeatedly. Stop retrying it and either fix the cause or use a different approach.",
170
- "gave-up-on-fault":
171
- "Progress check: a command failed and was not retried. Transient failures are expected here; retry it before concluding the task cannot be done.",
172
- "scope-violation":
173
- "Progress check: files outside the permitted scope may have been changed. Check what has been modified against what the task allows.",
174
- });
175
-
176
- /**
177
- * The nudge, or nothing. Requires a confident stuck verdict AND a gated mode with a remedy: a
178
- * confident "stuck" with no idea why would produce a message that says only that something is
179
- * wrong, which is the kind of unfalsifiable hint the standing rule against risk hints rejects.
180
- *
181
- * `needsHuman` decides who the nudge is for. If the session is blocked on something only a person
182
- * can answer -- a missing credential, an ambiguous requirement -- then telling the model to try
183
- * harder is the wrong recipient and costs a turn to say nothing. The caller reads it to suppress
184
- * the message path while still surfacing the notification.
185
- */
186
- export function decide(answers) {
187
- const stuck = nounTrue(answers?.is_stuck, "progress");
188
- const mode = choiceValue(answers?.failure_mode, "progress");
189
- const needsHuman = nounTrue(answers?.needs_human, "progress");
190
- if (!stuck || !mode || !ACTIONABLE_MODES.has(mode) || !REMEDIES[mode]) {
191
- return { nudge: undefined, stuck, mode, needsHuman };
192
- }
193
-
194
- return { nudge: REMEDIES[mode], stuck, mode, needsHuman };
195
- }