specpi 0.30.0 → 0.31.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.
@@ -18,26 +18,7 @@ import path from "node:path";
18
18
  import { randomUUID } from "node:crypto";
19
19
 
20
20
  /** Systems that may run inside a session. Offline scripts are not gated here. */
21
- export const SYSTEM_NAMES = Object.freeze(["retention", "gap", "sources", "progress", "untrusted", "capability"]);
22
-
23
- /**
24
- * What a confident stuck verdict is allowed to do. `notify` tells the person and cannot be wrong in
25
- * a way that costs anything; `message` appends a fixed line the model reads before its next
26
- * request, which changes behaviour.
27
- *
28
- * It ships on `notify`. Not because the gate cannot tell the cases apart -- it demonstrably can: on
29
- * the recorded fixtures a session repeating one failing call scores 0.89 for stuck with the mode at
30
- * 0.99 confidence, and a session working steadily scores 0.30 and reports "unknown" below the gate.
31
- * The missing number is the false-positive rate on real sessions, and the two things that bear on it
32
- * point the other way: running the same taxonomy over the 24 recorded failures left 14 of them
33
- * ungated, and a wrong nudge costs a turn, which is the exact quantity this system exists to save.
34
- *
35
- * So the condition for changing this default is a measurement, not an opinion, and the eval suite
36
- * is where it comes from: `--harness=specpi-jev` sets `message` and discloses it, because a
37
- * notification in a headless run reaches nobody and would measure the cost of the system with none
38
- * of its effect.
39
- */
40
- export const NUDGE_MODES = Object.freeze(["notify", "message"]);
21
+ export const SYSTEM_NAMES = Object.freeze(["retention", "gap", "sources", "untrusted", "capability"]);
41
22
 
42
23
  const MAX_SETTINGS_BYTES = 4096;
43
24
  const MAX_CALL_BUDGET = 1024;
@@ -49,7 +30,7 @@ const MAX_TOTAL_BUDGET = 2048;
49
30
  * session, and which one won would be decided by event ordering rather than by anyone's policy.
50
31
  *
51
32
  * So the ceiling is two-level: each system gets its own, and the total is a real constraint because
52
- * it is deliberately less than their sum -- 2048 against 2274. That relationship is the invariant,
33
+ * it is deliberately less than their sum -- 1536 against 1570. That relationship is the invariant,
53
34
  * not either number: raising the total without raising the per-system ceilings would leave a total
54
35
  * no combination of systems could ever reach, which is a limit that reads as a limit and is not
55
36
  * one. `tests/jev-advisor.test.mjs` pins the inequality so a future change to one has to consider
@@ -61,7 +42,7 @@ const MAX_TOTAL_BUDGET = 2048;
61
42
  export const DEFAULT_BUDGETS = Object.freeze({
62
43
  // A backstop, not a working limit, and the number says which. Measured, a full tier-3 task -- a
63
44
  // 120-step repair chain over about 25 model requests -- spends 4 to 7 calls, and the busiest
64
- // attempt ever recorded spent 12. A session would have to run for days before 2048 bound
45
+ // attempt ever recorded spent 12. A session would have to run for days before 1536 bound
65
46
  // anything a person was actually doing, which is the point: the ceiling should only ever be hit
66
47
  // by a loop, and hitting it should therefore be information rather than an inconvenience.
67
48
  //
@@ -74,22 +55,23 @@ export const DEFAULT_BUDGETS = Object.freeze({
74
55
  // to compact several times spends in the hundreds, so the backstop sat close enough to real
75
56
  // use to be reachable by a session that was working correctly.
76
57
  //
77
- // Cost is not what these are for. A call is about $0.00003, so the whole total is about six
58
+ // Cost is not what these are for. A call is about $0.00003, so the whole total is about five
78
59
  // cents. They bound two things that do not get cheaper with scale: how much digest leaves the
79
60
  // machine for a third party, at up to 1 KB a call, and how much awaited latency a runaway loop
80
- // can add before something stops it. Two megabytes of digest and an announced stop is the shape
61
+ // can add before something stops it. About a megabyte and a half of digest and an announced stop is the shape
81
62
  // of the trade.
82
- total: 2048,
63
+ //
64
+ // Lowered from 2048 to 1536 when progress detection was withdrawn. That system was the only one
65
+ // that could ask every turn, and its 704 left the per-system ceilings summing to 1570 -- below
66
+ // 2048, so the total could never have bound. 1536 is still far above anything a working session
67
+ // has spent.
68
+ total: 1536,
83
69
  // The per-system numbers move with the total, because a total the per-system ceilings can never
84
70
  // add up to is not a constraint at all -- see below. They are scaled rather than re-derived:
85
71
  // each one's rationale is a firing frequency, and none of those frequencies changed.
86
72
  retention: 832,
87
73
  gap: 192,
88
74
  sources: 128,
89
- // Turn-level, but gated behind local signals and a four-turn cooldown, so it only spends on
90
- // sessions that already look wrong. The ceiling is what stops a genuinely thrashing session
91
- // from spending the total on being told it is thrashing.
92
- progress: 704,
93
75
  // Usually free: when retention is on, system 7's question rides the call retention was already
94
76
  // making against the same state. This ceiling only binds when retention is off, or when the
95
77
  // fetched result is too small for retention to be interested in it.
@@ -135,12 +117,11 @@ export function regularFile(file, label) {
135
117
  /** Every unknown shape collapses to the same all-off default rather than a partial enable. */
136
118
  export function defaultSettings() {
137
119
  return {
138
- schema: 5,
120
+ schema: 6,
139
121
  master: false,
140
122
  startup: false,
141
123
  systems: Object.fromEntries(SYSTEM_NAMES.map((name) => [name, false])),
142
124
  budgets: { ...DEFAULT_BUDGETS },
143
- progressNudge: "notify",
144
125
  };
145
126
  }
146
127
 
@@ -220,6 +201,25 @@ function migrateToFive(raw) {
220
201
  return { ...raw, schema: 5, systems, budgets };
221
202
  }
222
203
 
204
+ /**
205
+ * Schema 6 removes progress detection, withdrawn on a replay rather than a solve-rate result.
206
+ *
207
+ * Over 174 recorded Terminal-Bench 2 runs where its local gate fired, the stuck verdict did not
208
+ * predict failure -- within-task AUC 0.46, with the shipped state and with one that added the recent
209
+ * commands -- and its failure-mode answer echoed its own input 624 times out of 624. A system that
210
+ * can steer a model has to show it can tell when to; this one could not.
211
+ *
212
+ * `systems.progress`, `budgets.progress` and `progressNudge` go, for the reason the compaction
213
+ * keys went: the hooks they gated no longer exist.
214
+ */
215
+ function migrateToSix(raw) {
216
+ const { progress: _system, ...systems } = raw?.systems ?? {};
217
+ const { progress: _budget, ...budgets } = raw?.budgets ?? {};
218
+ const { progressNudge: _nudge, ...rest } = raw ?? {};
219
+
220
+ return { ...rest, schema: 6, systems, budgets };
221
+ }
222
+
223
223
  /**
224
224
  * What the advisor will read, given a settings object, without writing it anywhere.
225
225
  *
@@ -237,20 +237,20 @@ export function normalizeSettings(raw) {
237
237
  function normalize(raw) {
238
238
  const one = raw?.schema === 1 ? migrate(raw) : raw;
239
239
  const four = one?.schema === 2 || one?.schema === 3 ? migrateToFour(one) : one;
240
- const source = four?.schema === 4 ? migrateToFive(four) : four;
241
- if (source?.schema !== 5) {
240
+ const five = four?.schema === 4 ? migrateToFive(four) : four;
241
+ const source = five?.schema === 5 ? migrateToSix(five) : five;
242
+ if (source?.schema !== 6) {
242
243
  return defaultSettings();
243
244
  }
244
245
 
245
246
  const systems = Object.fromEntries(SYSTEM_NAMES.map((name) => [name, source.systems?.[name] === true]));
246
247
 
247
248
  return {
248
- schema: 5,
249
+ schema: 6,
249
250
  master: source.master === true,
250
251
  startup: source.startup === true,
251
252
  systems,
252
253
  budgets: normalizeBudgets(source.budgets),
253
- progressNudge: NUDGE_MODES.includes(source.progressNudge) ? source.progressNudge : "notify",
254
254
  };
255
255
  }
256
256
 
@@ -283,7 +283,7 @@ export function writeFileAtomic(file, contents) {
283
283
  export function saveSettings(settings) {
284
284
  // A caller handing back an older shape is migrated rather than reset, so a round trip through
285
285
  // an old reader cannot quietly disable the layer.
286
- const next = normalize([1, 2, 3, 4].includes(settings?.schema) ? settings : { ...settings, schema: 5 });
286
+ const next = normalize([1, 2, 3, 4, 5].includes(settings?.schema) ? settings : { ...settings, schema: 6 });
287
287
  const file = settingsFile();
288
288
  if (fs.existsSync(file)) {
289
289
  regularFile(file, "Jev settings");
@@ -20,6 +20,12 @@ import { MAX_STATE_BYTES } from "./sanitize.mjs";
20
20
  * and a TYPESAFE_BASE_URL override) names itself, and so the rule below is true rather than
21
21
  * aspirational: a grant is keyed on this string, so repointing the client really does ask again.
22
22
  */
23
+ export const CONSENT_SCHEMA = 2;
24
+
25
+ export function endpointOrigin() {
26
+ return new URL(baseUrl()).origin;
27
+ }
28
+
23
29
  export function endpointLabel() {
24
30
  const base = baseUrl();
25
31
  try {
@@ -43,12 +49,12 @@ export function loadConsent() {
43
49
  }
44
50
 
45
51
  const stored = JSON.parse(fs.readFileSync(file, "utf8"));
46
- if (stored?.schema !== 1 || stored.granted !== true || typeof stored.endpoint !== "string") {
52
+ if (stored?.schema !== CONSENT_SCHEMA || stored.granted !== true || typeof stored.endpoint !== "string") {
47
53
  return undefined;
48
54
  }
49
55
 
50
56
  // A grant is for the endpoint it was given for. Repointing the client asks again.
51
- return stored.endpoint === endpointLabel() ? stored : undefined;
57
+ return stored.endpoint === endpointLabel() && stored.origin === endpointOrigin() ? stored : undefined;
52
58
  } catch {
53
59
  return undefined;
54
60
  }
@@ -65,9 +71,11 @@ export function saveConsent() {
65
71
  }
66
72
 
67
73
  const stored = {
68
- schema: 1,
74
+ // Earlier consent incorrectly excluded file/command samples. It cannot authorize them.
75
+ schema: CONSENT_SCHEMA,
69
76
  granted: true,
70
77
  endpoint: endpointLabel(),
78
+ origin: endpointOrigin(),
71
79
  maxStateBytes: MAX_STATE_BYTES,
72
80
  grantedAt: new Date().toISOString(),
73
81
  };
@@ -89,15 +97,19 @@ export function consentPath() {
89
97
  return consentFile();
90
98
  }
91
99
 
92
- export const CONSENT_TITLE = "Allow SpecPi to send task summaries to Jev?";
100
+ export const CONSENT_TITLE = "Allow SpecPi to send task summaries and text samples to Jev?";
93
101
 
94
102
  export function consentBody(systemLabel) {
95
103
  return [
96
104
  `${systemLabel} wants to ask TypeSafe's Jev classifier a question about this session.`,
97
105
  "",
98
- `What is sent: a summary object of at most ${MAX_STATE_BYTES} bytes to ${endpointLabel()}, over HTTPS.`,
99
- "It carries tool names, byte counts, relative paths and short descriptions.",
100
- "It never carries file contents, command output, credentials, URLs or session history.",
106
+ `What is sent: a state object of at most ${MAX_STATE_BYTES} bytes to ${endpointLabel()}, with classifier questions.`,
107
+ `Transport: ${new URL(baseUrl()).protocol === "https:" ? "HTTPS" : "not HTTPS (configured endpoint override)"}.`,
108
+ "It may include the current request or objective, relative paths, capability-gap summaries,",
109
+ "and short sampled lines from file contents, command output and fetched content.",
110
+ "Known credential, email, URL and outside-workspace path patterns are redacted before sending.",
111
+ "Redaction is best effort, not a guarantee that every sensitive detail is removed. No session history is read.",
112
+ "Collection stays local unless you separately allow this advisor transmission.",
101
113
  "",
102
114
  "Every call is recorded locally in transmissions.jsonl with a hash of exactly what was sent,",
103
115
  "which you can read with /jev ledger. Turn this off at any time with /jev off.",
@@ -95,19 +95,12 @@ export const THRESHOLDS = Object.freeze({
95
95
  boundary: 0.3,
96
96
  choiceConfidence: 0.75,
97
97
  margin: 0.2,
98
- // Measured 0.96 on a report carrying a machine-specific path and 0.04 on a clean one.
99
- high: 0.85,
100
- low: 0.15,
101
- }),
102
- progress: Object.freeze({
103
- scoreConfidence: 0.6,
104
- boundary: 0.3,
105
- // The strictest Choice gate in the layer, because it is the only system whose action can
106
- // change what the model does next. Running the same taxonomy over the 24 recorded failures
107
- // left 14 of them below this bar, which is the intended behaviour: silence is the correct
108
- // answer to a session whose trouble is not yet legible.
109
- choiceConfidence: 0.8,
110
- margin: 0.25,
98
+ // The first measurement (0.96 on a machine-specific path) sent the path unredacted, which
99
+ // production never does. Built as a session builds it, the old single question scored a
100
+ // password written as prose 0.60-0.65 -- under this bar, on the case it existed for. The bar
101
+ // stayed; the question was split in two (`questions/gap.mjs`), and each written secret or
102
+ // identity now scores 0.87-0.98 against 0.07 or less for a report that only mentions one
103
+ // (`evals/runs/jev-reach-hard.json`).
111
104
  high: 0.85,
112
105
  low: 0.15,
113
106
  }),
@@ -167,14 +160,14 @@ export function thresholdsFor(system) {
167
160
  export function nounTrue(answer, system) {
168
161
  const limits = thresholdsFor(system);
169
162
 
170
- return answer?.kind === "noul" && answer.value >= limits.high;
163
+ return answer?.kind === "noul" && Number.isFinite(answer.value) && answer.value >= limits.high && answer.value <= 1;
171
164
  }
172
165
 
173
166
  /** True when the Noul is confidently no. Not the negation of nounTrue: the middle band is silence. */
174
167
  export function nounFalse(answer, system) {
175
168
  const limits = thresholdsFor(system);
176
169
 
177
- return answer?.kind === "noul" && answer.value <= limits.low;
170
+ return answer?.kind === "noul" && Number.isFinite(answer.value) && answer.value >= 0 && answer.value <= limits.low;
178
171
  }
179
172
 
180
173
  function topTwo(probabilities) {
@@ -193,7 +186,15 @@ function topTwo(probabilities) {
193
186
  * the margin is a live test rather than a branch that silently never runs.
194
187
  */
195
188
  export function choiceValue(answer, system) {
196
- if (answer?.kind !== "choice" || typeof answer.confidence !== "number") {
189
+ if (
190
+ answer?.kind !== "choice" ||
191
+ !Number.isFinite(answer.confidence) ||
192
+ answer.confidence > 1 ||
193
+ !answer.probabilities ||
194
+ Array.isArray(answer.probabilities) ||
195
+ !Object.hasOwn(answer.probabilities, answer.value) ||
196
+ !Object.values(answer.probabilities).every((value) => Number.isFinite(value) && value >= 0 && value <= 1)
197
+ ) {
197
198
  return undefined;
198
199
  }
199
200
 
@@ -203,7 +204,7 @@ export function choiceValue(answer, system) {
203
204
  }
204
205
 
205
206
  const { first, second } = topTwo(answer.probabilities);
206
- if (answer.probabilities && first - second < limits.margin) {
207
+ if (answer.probabilities[answer.value] !== first || first - second < limits.margin) {
207
208
  return undefined;
208
209
  }
209
210
 
@@ -215,7 +216,14 @@ export function choiceValue(answer, system) {
215
216
  * returned level is the rounded band; callers compare against their own rubric.
216
217
  */
217
218
  export function scoreLevel(answer, system) {
218
- if (answer?.kind !== "score" || typeof answer.confidence !== "number") {
219
+ if (
220
+ answer?.kind !== "score" ||
221
+ !Number.isFinite(answer.confidence) ||
222
+ answer.confidence > 1 ||
223
+ !Number.isFinite(answer.value) ||
224
+ answer.value < 0 ||
225
+ answer.value > 9
226
+ ) {
219
227
  return undefined;
220
228
  }
221
229