specpi 0.26.0 → 0.27.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 +43 -0
- package/README.md +38 -4
- package/SECURITY_MODEL.md +36 -2
- package/THIRD_PARTY.md +10 -1
- package/extensions/jev-advisor/broker.mjs +276 -0
- package/extensions/jev-advisor/client.mjs +182 -0
- package/extensions/jev-advisor/config.mjs +254 -0
- package/extensions/jev-advisor/consent.mjs +133 -0
- package/extensions/jev-advisor/gate.mjs +249 -0
- package/extensions/jev-advisor/guard.mjs +140 -0
- package/extensions/jev-advisor/index.ts +849 -0
- package/extensions/jev-advisor/ledger.mjs +138 -0
- package/extensions/jev-advisor/questions/capabilities.mjs +124 -0
- package/extensions/jev-advisor/questions/compaction.mjs +153 -0
- package/extensions/jev-advisor/questions/gap.mjs +140 -0
- package/extensions/jev-advisor/questions/progress.mjs +195 -0
- package/extensions/jev-advisor/questions/retention.mjs +188 -0
- package/extensions/jev-advisor/questions/sources.mjs +91 -0
- package/extensions/jev-advisor/questions/untrusted.mjs +69 -0
- package/extensions/jev-advisor/sanitize.mjs +0 -0
- package/extensions/jev-advisor/usage.mjs +92 -0
- package/extensions/tool-wishlist/authoring-tools.mjs +42 -0
- package/extensions/tool-wishlist/index.ts +11 -0
- package/extensions/workflow-controls/capabilities.mjs +26 -0
- package/extensions/workflow-controls/index.ts +2 -2
- package/package.json +1 -1
- package/scripts/specpi.mjs +33 -1
- package/templates/settings.json +2 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// A probability is not a decision. This file is the only place that turns one into the other, so
|
|
2
|
+
// every system gates the same way and a threshold change is a one-line diff with one test to move.
|
|
3
|
+
//
|
|
4
|
+
// The three primitives do not gate alike. A Noul returns a bare probability with no confidence
|
|
5
|
+
// field, so a confidence test cannot be applied to it. A Choice can be confident and still be a
|
|
6
|
+
// coin flip between its top two options, so the margin matters as much as the confidence. A Score
|
|
7
|
+
// is only actionable when it sits clear of a level boundary rather than straddling one.
|
|
8
|
+
//
|
|
9
|
+
// MEASURED, not guessed. Every number here is now read off `evals/runs/jev-calibration.json`, which
|
|
10
|
+
// `node scripts/jev-calibrate.mjs` writes from 259 recorded eval attempts plus a fixed reachability
|
|
11
|
+
// pass of six synthetic cases run five times each. `tests/jev-calibration.test.mjs` pins each number
|
|
12
|
+
// to that file, so moving one takes new evidence rather than a new opinion.
|
|
13
|
+
//
|
|
14
|
+
// Two questions were asked of the evidence, because a threshold can fail in two different ways.
|
|
15
|
+
//
|
|
16
|
+
// 1. DOES THE CONFIDENCE FIELD SEPARATE RIGHT FROM WRONG? Measured against labels this repository
|
|
17
|
+
// already owns: predicting an attempt's pass (Noul), its task category (Choice) and its tier
|
|
18
|
+
// (Score) from behavioural metadata alone, with the labels withheld from the state.
|
|
19
|
+
//
|
|
20
|
+
// Score: yes, weakly. At scoreConfidence 0.60 and boundary 0.30 the answer is exactly right
|
|
21
|
+
// about two thirds of the time and within one level about 96%, on roughly a fifth of
|
|
22
|
+
// answers, against a 43.2% majority class. The exact figures are in CALIBRATION below,
|
|
23
|
+
// which a test compares against the artifact; they moved from 68.4% to 64.7% between two
|
|
24
|
+
// runs over the same 259 attempts, so any single decimal here is a sample, not a
|
|
25
|
+
// constant, and the honest summary is "a lift of about 1.5".
|
|
26
|
+
// Noul: no. Precision tracks the 90.7% base rate at every threshold (lift 1.01-1.02), and no
|
|
27
|
+
// answer to that question ever exceeded 0.80.
|
|
28
|
+
// Choice: no. About 30% accuracy against a 29.7% majority class, and accuracy falls as
|
|
29
|
+
// confidence rises. The margin changes nothing, because the top-two gap is almost always
|
|
30
|
+
// wide.
|
|
31
|
+
//
|
|
32
|
+
// None of the systems' pre-registered precision targets (0.75 to 0.95) is met anywhere on any of
|
|
33
|
+
// those curves, and the artifact records UNMET rather than a number chosen to fill the gap. The
|
|
34
|
+
// Score point below is the best available operating point, not a met target. That is a real
|
|
35
|
+
// limit on what this layer can claim, and it is published rather than smoothed over.
|
|
36
|
+
//
|
|
37
|
+
// It is also a fair reading that the proxy questions are much harder than the production ones:
|
|
38
|
+
// the eval state is a row of counters, while a production state carries the material being
|
|
39
|
+
// judged. Question 2 is what tests that, and the answer is yes.
|
|
40
|
+
//
|
|
41
|
+
// 2. CAN THE GATE EVER FIRE? This is the one that found a shipped defect. Against the fixture cases
|
|
42
|
+
// the real question sets separate cleanly -- a planted secret scores 0.96 and a clean report
|
|
43
|
+
// 0.04; a page carrying an injected instruction scores 0.97 and an ordinary one 0.04; the one
|
|
44
|
+
// relevant file among noise scores 1.99 while the other two score 0.01 -- but the Score gate
|
|
45
|
+
// shipped at confidence 0.80 with boundary 0.35, and on a maximally obvious "spent" result (a
|
|
46
|
+
// listing of vendor icons during a changelog edit) Jev answered 0.10-0.18 with confidence
|
|
47
|
+
// 0.73-0.85 across ten runs, most of them under 0.80. Boundary 0.35 demands the value sit within
|
|
48
|
+
// 0.15 of a level, which 0.16 and 0.17 miss. So retention could gate through to "keep this
|
|
49
|
+
// result" and essentially never to "this result is spent": the only branch that does anything
|
|
50
|
+
// was unreachable, and running the layer could never have revealed it, because a system that
|
|
51
|
+
// never fires looks exactly like a system whose advice was always to do nothing.
|
|
52
|
+
//
|
|
53
|
+
// compaction's `unresolved_thread` had the same problem at high 0.85: the clearest open
|
|
54
|
+
// investigation the fixture can express scores 0.63-0.65. It is lowered to 0.60, which is
|
|
55
|
+
// defensible only because of what that branch does -- add one sentence to a summariser prompt
|
|
56
|
+
// that is being rebuilt from scratch anyway. It is the cheapest action in the layer, so it can
|
|
57
|
+
// afford the loosest gate. gap keeps 0.85 because its Noul reaches 0.96 on the case that matters
|
|
58
|
+
// and because a firing there blocks a write.
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The figures the comment above cites, in a form a test can check against the artifact. A citation
|
|
62
|
+
* that drifts from its source is worse than no citation, because it reads like evidence. If these
|
|
63
|
+
* stop matching `evals/runs/jev-calibration.json`, `tests/jev-calibration.test.mjs` fails and
|
|
64
|
+
* whoever re-ran the calibration has to update the prose too.
|
|
65
|
+
*/
|
|
66
|
+
export const CALIBRATION = Object.freeze({
|
|
67
|
+
artifact: "evals/runs/jev-calibration.json",
|
|
68
|
+
attempts: 259,
|
|
69
|
+
passBaseRate: 0.907,
|
|
70
|
+
tierBaseRate: 0.432,
|
|
71
|
+
categoryBaseRate: 0.297,
|
|
72
|
+
// At the shipped scoreConfidence 0.60 / boundary 0.30. Tolerance is deliberate: see above.
|
|
73
|
+
scoreExact: 0.647,
|
|
74
|
+
scoreWithinOne: 0.961,
|
|
75
|
+
scoreCoverage: 0.197,
|
|
76
|
+
tolerance: 0.05,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const THRESHOLDS = Object.freeze({
|
|
80
|
+
// One Score operating point, applied to every system, because one proxy question produced one
|
|
81
|
+
// curve. Four different per-system numbers would be four claims from a single measurement. The
|
|
82
|
+
// per-system asymmetry lives where it belongs instead: retention needs two independent answers
|
|
83
|
+
// to agree before it shortens anything, and sources only ever reorders.
|
|
84
|
+
retention: Object.freeze({
|
|
85
|
+
scoreConfidence: 0.6,
|
|
86
|
+
boundary: 0.3,
|
|
87
|
+
choiceConfidence: 0.75,
|
|
88
|
+
margin: 0.3,
|
|
89
|
+
// Unused by retention, which reads only the low side; kept so every system has a full set.
|
|
90
|
+
high: 0.9,
|
|
91
|
+
// Measured 0.06-0.07 on the spent case, so this clears with room.
|
|
92
|
+
low: 0.1,
|
|
93
|
+
}),
|
|
94
|
+
compaction: Object.freeze({
|
|
95
|
+
scoreConfidence: 0.6,
|
|
96
|
+
boundary: 0.3,
|
|
97
|
+
choiceConfidence: 0.75,
|
|
98
|
+
margin: 0.25,
|
|
99
|
+
// Lowered from 0.85: the clearest open investigation scores 0.63-0.65, and the action is
|
|
100
|
+
// one sentence added to a prompt that is being rebuilt regardless.
|
|
101
|
+
high: 0.6,
|
|
102
|
+
low: 0.15,
|
|
103
|
+
}),
|
|
104
|
+
gap: Object.freeze({
|
|
105
|
+
scoreConfidence: 0.6,
|
|
106
|
+
boundary: 0.3,
|
|
107
|
+
choiceConfidence: 0.75,
|
|
108
|
+
margin: 0.2,
|
|
109
|
+
// Measured 0.96 on a report carrying a machine-specific path and 0.04 on a clean one.
|
|
110
|
+
high: 0.85,
|
|
111
|
+
low: 0.15,
|
|
112
|
+
}),
|
|
113
|
+
progress: Object.freeze({
|
|
114
|
+
scoreConfidence: 0.6,
|
|
115
|
+
boundary: 0.3,
|
|
116
|
+
// The strictest Choice gate in the layer, because it is the only system whose action can
|
|
117
|
+
// change what the model does next. Running the same taxonomy over the 24 recorded failures
|
|
118
|
+
// left 14 of them below this bar, which is the intended behaviour: silence is the correct
|
|
119
|
+
// answer to a session whose trouble is not yet legible.
|
|
120
|
+
choiceConfidence: 0.8,
|
|
121
|
+
margin: 0.25,
|
|
122
|
+
high: 0.85,
|
|
123
|
+
low: 0.15,
|
|
124
|
+
}),
|
|
125
|
+
capability: Object.freeze({
|
|
126
|
+
scoreConfidence: 0.6,
|
|
127
|
+
boundary: 0.3,
|
|
128
|
+
choiceConfidence: 0.75,
|
|
129
|
+
margin: 0.2,
|
|
130
|
+
// Asymmetric on purpose. A false positive costs the group's schema on every request for
|
|
131
|
+
// the rest of the session -- a turn-1 arming measured 16% more than never arming -- plus a
|
|
132
|
+
// confirmation the human did not need. A false negative costs nothing: it leaves today's
|
|
133
|
+
// behaviour exactly as it is, and `request_capability` is still there for the moment the
|
|
134
|
+
// need becomes real.
|
|
135
|
+
//
|
|
136
|
+
// This shipped at 0.90 for exactly as long as it took to measure it, which is the same
|
|
137
|
+
// defect described above, in code written the same day, caught by the same check. On a
|
|
138
|
+
// request that unambiguously needs a browser -- open the pricing page at 375px and fix what
|
|
139
|
+
// overflows -- `needs_browser` answers 0.86-0.87, five times out of five, while the same
|
|
140
|
+
// question on a rename answers 0.09-0.10. 0.90 sits inside the yes cluster and rejects all
|
|
141
|
+
// of it; 0.85 sits below it with a margin of 0.76 to the nearest no. No Noul anywhere in
|
|
142
|
+
// the fixture set has ever exceeded 0.97, so "higher is safer" stops being true well before
|
|
143
|
+
// it stops being tempting.
|
|
144
|
+
high: 0.85,
|
|
145
|
+
low: 0.1,
|
|
146
|
+
}),
|
|
147
|
+
untrusted: Object.freeze({
|
|
148
|
+
scoreConfidence: 0.6,
|
|
149
|
+
boundary: 0.3,
|
|
150
|
+
choiceConfidence: 0.75,
|
|
151
|
+
margin: 0.2,
|
|
152
|
+
// A banner is cheap and a missed injection is not, so this is the one place where the
|
|
153
|
+
// asymmetry runs the other way from gap's. It is still 0.85 rather than lower, because a
|
|
154
|
+
// banner on ordinary prose is exactly the false positive that teaches a model to stop
|
|
155
|
+
// reading the channel -- the objection this system had to answer before it could exist.
|
|
156
|
+
high: 0.85,
|
|
157
|
+
low: 0.15,
|
|
158
|
+
}),
|
|
159
|
+
sources: Object.freeze({
|
|
160
|
+
scoreConfidence: 0.6,
|
|
161
|
+
boundary: 0.3,
|
|
162
|
+
choiceConfidence: 0.7,
|
|
163
|
+
margin: 0.15,
|
|
164
|
+
// Not reached by any fixture case: `worth_delegating` scored 0.20-0.21 on a question that
|
|
165
|
+
// reads as self-contained to a human. Left at 0.85 rather than tuned down, because the only
|
|
166
|
+
// thing that reads it warns and never blocks, and a threshold moved to make a fixture pass
|
|
167
|
+
// is a threshold set by the fixture.
|
|
168
|
+
high: 0.85,
|
|
169
|
+
low: 0.15,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export function thresholdsFor(system) {
|
|
174
|
+
return THRESHOLDS[system] ?? THRESHOLDS.gap;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** True when the Noul is confidently yes. */
|
|
178
|
+
export function nounTrue(answer, system) {
|
|
179
|
+
const limits = thresholdsFor(system);
|
|
180
|
+
|
|
181
|
+
return answer?.kind === "noul" && answer.value >= limits.high;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** True when the Noul is confidently no. Not the negation of nounTrue: the middle band is silence. */
|
|
185
|
+
export function nounFalse(answer, system) {
|
|
186
|
+
const limits = thresholdsFor(system);
|
|
187
|
+
|
|
188
|
+
return answer?.kind === "noul" && answer.value <= limits.low;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function topTwo(probabilities) {
|
|
192
|
+
const values = Object.values(probabilities ?? {})
|
|
193
|
+
.filter((value) => typeof value === "number")
|
|
194
|
+
.sort((a, b) => b - a);
|
|
195
|
+
|
|
196
|
+
return { first: values[0] ?? 0, second: values[1] ?? 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A Choice is actionable when it is both confident and clearly separated from its runner-up.
|
|
201
|
+
* Without a distribution the margin cannot be checked, so the answer is treated as ungated.
|
|
202
|
+
*
|
|
203
|
+
* The reachability pass confirms all 40 Choice answers from this backend carried a distribution, so
|
|
204
|
+
* the margin is a live test rather than a branch that silently never runs.
|
|
205
|
+
*/
|
|
206
|
+
export function choiceValue(answer, system) {
|
|
207
|
+
if (answer?.kind !== "choice" || typeof answer.confidence !== "number") {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const limits = thresholdsFor(system);
|
|
212
|
+
if (answer.confidence < limits.choiceConfidence) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { first, second } = topTwo(answer.probabilities);
|
|
217
|
+
if (answer.probabilities && first - second < limits.margin) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return answer.value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A Score is actionable when it is confident and sits clear of the nearest level boundary. The
|
|
226
|
+
* returned level is the rounded band; callers compare against their own rubric.
|
|
227
|
+
*/
|
|
228
|
+
export function scoreLevel(answer, system) {
|
|
229
|
+
if (answer?.kind !== "score" || typeof answer.confidence !== "number") {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const limits = thresholdsFor(system);
|
|
234
|
+
if (answer.confidence < limits.scoreConfidence) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const level = Math.round(answer.value);
|
|
239
|
+
if (Math.abs(answer.value - level) > 0.5 - limits.boundary) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return level;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Raw probability for logging and calibration, with no gate applied. */
|
|
247
|
+
export function rawValue(answer) {
|
|
248
|
+
return answer?.value;
|
|
249
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Integration seam for specpi-jev-guard, the Jev-scored command gate.
|
|
2
|
+
//
|
|
3
|
+
// The guard is pinned in the base set but ships **inert**, the way web access ships installed but
|
|
4
|
+
// withdrawn. Its own `DEFAULT_SETTINGS.enabled` is `true`, so leaving it alone would mean a fresh
|
|
5
|
+
// SpecPi install started gating shell and file calls through a third-party service on day one.
|
|
6
|
+
// SpecPi writes `enabled: false` instead, and `/jev guard on` is how a human opts in.
|
|
7
|
+
//
|
|
8
|
+
// One key for the whole layer: `OPENROUTER_API_KEY`. Jev is published on OpenRouter, the guard
|
|
9
|
+
// reaches it that way by default, and the advisor now does too, so the guard's backend is left
|
|
10
|
+
// alone rather than re-pinned.
|
|
11
|
+
//
|
|
12
|
+
// Be precise about what this cannot do. The guard is fail-closed by design: with no key, an
|
|
13
|
+
// unreachable endpoint, or a middle-band verdict in a session with no UI, it blocks the call and
|
|
14
|
+
// says so. There is no setting that hands the decision back to @gotgenes/pi-permission-system
|
|
15
|
+
// instead. So the honest posture is:
|
|
16
|
+
//
|
|
17
|
+
// - off (the default) -> the guard is not in the tool path at all, and the permission system
|
|
18
|
+
// decides every call exactly as it did before this package existed;
|
|
19
|
+
// - on -> the guard decides first, asks a human in the middle band when there is a UI, and
|
|
20
|
+
// blocks when it cannot reach Jev. An outage stops gated work until it is switched off.
|
|
21
|
+
//
|
|
22
|
+
// That trade is the user's to make, which is why it ships off and why /jev status says plainly
|
|
23
|
+
// what is in force.
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { agentDirectory, regularFile, writeFileAtomic } from "./config.mjs";
|
|
29
|
+
|
|
30
|
+
export const GUARD_PACKAGE = "specpi-jev-guard";
|
|
31
|
+
export const FALLBACK_PACKAGE = "@gotgenes/pi-permission-system";
|
|
32
|
+
|
|
33
|
+
/** Must match the pin in templates/settings.json. */
|
|
34
|
+
export const GUARD_PIN = "npm:specpi-jev-guard@0.1.0";
|
|
35
|
+
|
|
36
|
+
// The guard reads `<homedir>/.pi/jev-guard.json` globally, and a project copy under `<cwd>/.pi/`
|
|
37
|
+
// when the project is trusted. SpecPi writes only the global file: a project-local override is the
|
|
38
|
+
// user's to make, and writing one would put a security setting inside whatever repository happened
|
|
39
|
+
// to be open at the time.
|
|
40
|
+
const CONFIG_DIR_NAME = ".pi";
|
|
41
|
+
const SETTINGS_FILE = "jev-guard.json";
|
|
42
|
+
|
|
43
|
+
function guardRoot() {
|
|
44
|
+
return path.join(agentDirectory(), "npm", "node_modules", GUARD_PACKAGE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function guardConfigFile() {
|
|
48
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, SETTINGS_FILE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function installed() {
|
|
52
|
+
try {
|
|
53
|
+
const manifest = path.join(guardRoot(), "package.json");
|
|
54
|
+
if (!fs.existsSync(manifest)) {
|
|
55
|
+
return { installed: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
59
|
+
if (parsed?.name !== GUARD_PACKAGE) {
|
|
60
|
+
return { installed: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { installed: true, version: typeof parsed.version === "string" ? parsed.version : "unknown" };
|
|
64
|
+
} catch {
|
|
65
|
+
return { installed: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The fields SpecPi owns, in the guard's own schema. Anything absent here keeps the package's
|
|
71
|
+
* default, so its risk rules, protected paths and thresholds stay the package's business.
|
|
72
|
+
*/
|
|
73
|
+
export function desiredConfig(enabled = false) {
|
|
74
|
+
return {
|
|
75
|
+
// Off means the guard never enters the tool path, so no key is needed and nothing is sent.
|
|
76
|
+
enabled: enabled === true,
|
|
77
|
+
// Left at the guard's own default. Both halves use OpenRouter, so one OPENROUTER_API_KEY
|
|
78
|
+
// serves the guard and the advisor together.
|
|
79
|
+
backend: "openrouter",
|
|
80
|
+
// With a UI, a middle-band verdict asks rather than deciding on its own. Without one the
|
|
81
|
+
// guard fails closed; that is the package's design, disclosed rather than configured away.
|
|
82
|
+
uncertain: "ask",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readConfig() {
|
|
87
|
+
try {
|
|
88
|
+
const file = guardConfigFile();
|
|
89
|
+
if (!regularFile(file, "Jev guard settings")) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Merge SpecPi's fields into whatever is already there rather than replacing the file. A user who
|
|
101
|
+
* set their own thresholds, safe-command globs or protected paths keeps them; only the three fields
|
|
102
|
+
* above are asserted. Idempotent, and reports what changed so a caller can say so.
|
|
103
|
+
*/
|
|
104
|
+
export function applyConfig(enabled = false) {
|
|
105
|
+
if (!installed().installed) {
|
|
106
|
+
return { applied: false, reason: "not-installed" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const desired = desiredConfig(enabled);
|
|
110
|
+
const current = readConfig();
|
|
111
|
+
if (current && Object.entries(desired).every(([key, value]) => current[key] === value)) {
|
|
112
|
+
return { applied: false, reason: "already-current" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
writeFileAtomic(guardConfigFile(), `${JSON.stringify({ ...(current ?? {}), ...desired }, null, 4)}\n`);
|
|
116
|
+
|
|
117
|
+
return { applied: true, reason: current ? "updated" : "created" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One line for `/jev status` and `specpi doctor`. Never prints a key. */
|
|
121
|
+
export function statusLine() {
|
|
122
|
+
const state = installed();
|
|
123
|
+
if (!state.installed) {
|
|
124
|
+
return `guard: not installed (command policy stays with ${FALLBACK_PACKAGE})`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const config = readConfig();
|
|
128
|
+
if (config?.enabled !== true) {
|
|
129
|
+
return `guard: ${GUARD_PACKAGE}@${state.version} installed but off (every call goes to ${FALLBACK_PACKAGE})`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const backend = config.backend === "typesafe" ? "typesafe" : (config.backend ?? "openrouter");
|
|
133
|
+
const variable = backend === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
|
|
134
|
+
|
|
135
|
+
return `guard: ${GUARD_PACKAGE}@${state.version} ON via ${backend} (${variable} ${process.env[variable] ? "present" : "MISSING"}); it decides before ${FALLBACK_PACKAGE} and fails closed when Jev is unreachable`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function configPath() {
|
|
139
|
+
return guardConfigFile();
|
|
140
|
+
}
|