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.
- package/CHANGELOG.md +34 -0
- package/NPM_RELEASE.md +2 -2
- package/README.md +23 -15
- package/SECURITY_MODEL.md +14 -10
- package/THIRD_PARTY.md +1 -1
- package/extensions/jev-advisor/broker.mjs +117 -10
- package/extensions/jev-advisor/client.mjs +33 -10
- package/extensions/jev-advisor/config.mjs +36 -36
- package/extensions/jev-advisor/consent.mjs +19 -7
- package/extensions/jev-advisor/gate.mjs +26 -18
- package/extensions/jev-advisor/index.ts +241 -288
- package/extensions/jev-advisor/layer.mjs +1 -1
- package/extensions/jev-advisor/ledger.mjs +16 -2
- package/extensions/jev-advisor/questions/capabilities.mjs +3 -13
- package/extensions/jev-advisor/questions/gap.mjs +61 -88
- package/extensions/jev-advisor/questions/retention.mjs +11 -19
- package/extensions/jev-advisor/questions/sources.mjs +27 -56
- package/extensions/jev-advisor/questions/untrusted.mjs +30 -6
- package/extensions/jev-advisor/sanitize.mjs +0 -0
- package/extensions/tool-wishlist/core.mjs +86 -0
- package/extensions/tool-wishlist/index.ts +81 -8
- package/extensions/workflow-controls/index.ts +23 -0
- package/package.json +2 -2
- package/scripts/specpi.mjs +0 -1
- package/extensions/jev-advisor/questions/progress.mjs +0 -195
|
@@ -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", "
|
|
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 --
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
241
|
-
|
|
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:
|
|
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:
|
|
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 !==
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
"It
|
|
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
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|