pi-jev-auto-mode 0.1.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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Settings, policy notes, and the stored API key.
3
+ *
4
+ * Global settings live next to the rest of the Pi agent state
5
+ * (`$PI_CODING_AGENT_DIR` or `~/.pi/agent`). A project can override them from
6
+ * `<cwd>/<CONFIG_DIR_NAME>/jev-auto-mode.json`, but only for a trusted project:
7
+ * an untrusted checkout must not be able to loosen the gate that is judging it.
8
+ *
9
+ * The API key is not a setting. It goes to `<agentDir>/secrets/` as a `0600` file,
10
+ * which is where Pi keeps its own credentials, so that it is neither committed with
11
+ * a project nor readable by other users on the machine.
12
+ */
13
+
14
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
15
+ import { dirname, join } from "node:path";
16
+
17
+ export interface JevAutoModeSettings {
18
+ readonly enabled: boolean;
19
+ /** Per-attempt JEV timeout. Kept short: this is a gate, not a batch job. */
20
+ readonly timeoutMs: number;
21
+ /** Retries after the first attempt. */
22
+ readonly maxRetries: number;
23
+ /** Commands the user considers safe to run without a decision record. */
24
+ readonly safeCommands: readonly string[];
25
+ /** Commands that override a dangerous-pattern match; the override is recorded. */
26
+ readonly allowedCommands: readonly string[];
27
+ readonly disallowedCommands: readonly string[];
28
+ readonly extraProtectedPaths: readonly string[];
29
+ /** Shared state + questions budget guard, in characters. */
30
+ readonly maxStateCharacters: number;
31
+ /**
32
+ * Per-rule probability thresholds, overriding the calibrated defaults.
33
+ *
34
+ * Keys are rule ids. An unknown key is kept but has no effect, so a typo is
35
+ * visible in `/jev-auto-mode threshold` instead of silently resetting the rule.
36
+ */
37
+ readonly thresholds: Readonly<Record<string, number>>;
38
+ }
39
+
40
+ export type SettingsScope = "global" | "project";
41
+
42
+ export const DEFAULT_SETTINGS: JevAutoModeSettings = {
43
+ enabled: true,
44
+ timeoutMs: 4000,
45
+ maxRetries: 1,
46
+ safeCommands: [],
47
+ allowedCommands: [],
48
+ disallowedCommands: [],
49
+ extraProtectedPaths: [],
50
+ maxStateCharacters: 120_000,
51
+ thresholds: {},
52
+ };
53
+
54
+ const MAX_PATTERN_ENTRIES = 200;
55
+ const MAX_PATTERN_LENGTH = 300;
56
+ const MAX_THRESHOLD_ENTRIES = 32;
57
+ const MAX_RULE_ID_LENGTH = 64;
58
+ const MAX_POLICY_NOTES_LENGTH = 8000;
59
+ const CREDENTIAL_FILE_NAME = "jev-auto-mode-typesafe-api-key";
60
+ /** Mirrors Pi's own secret directory/file modes. */
61
+ const SECRET_DIRECTORY_MODE = 0o700;
62
+ const SECRET_FILE_MODE = 0o600;
63
+ const MIN_TIMEOUT_MS = 250;
64
+ const MAX_TIMEOUT_MS = 60_000;
65
+ const MAX_RETRIES = 5;
66
+
67
+ /** A probability threshold must leave a middle band on both sides. */
68
+ export const MIN_THRESHOLD = 0.5;
69
+ export const MAX_THRESHOLD = 1;
70
+
71
+ export interface StoreOptions {
72
+ /** Usually `~/.pi/agent`, honoring `PI_CODING_AGENT_DIR`. */
73
+ readonly agentDir: string;
74
+ /** Usually `.pi` (`CONFIG_DIR_NAME`). */
75
+ readonly configDirName: string;
76
+ }
77
+
78
+ export type SettingsPatch = { -readonly [K in keyof JevAutoModeSettings]?: JevAutoModeSettings[K] };
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ /** Validate a single threshold value. Returns `undefined` when it is not usable. */
85
+ export function parseThreshold(value: unknown): number | undefined {
86
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
87
+ if (value <= MIN_THRESHOLD || value > MAX_THRESHOLD) return undefined;
88
+ return value;
89
+ }
90
+
91
+ function readThresholds(value: unknown): Readonly<Record<string, number>> | undefined {
92
+ if (!isRecord(value)) return undefined;
93
+ const thresholds: Record<string, number> = {};
94
+ for (const [ruleId, raw] of Object.entries(value)) {
95
+ if (ruleId.length === 0 || ruleId.length > MAX_RULE_ID_LENGTH) continue;
96
+ const threshold = parseThreshold(raw);
97
+ if (threshold === undefined) continue;
98
+ thresholds[ruleId] = threshold;
99
+ if (Object.keys(thresholds).length >= MAX_THRESHOLD_ENTRIES) break;
100
+ }
101
+ return thresholds;
102
+ }
103
+
104
+ function readBoundedInteger(value: unknown, min: number, max: number): number | undefined {
105
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
106
+ const rounded = Math.round(value);
107
+ if (rounded < min || rounded > max) return undefined;
108
+ return rounded;
109
+ }
110
+
111
+ function readStringArray(value: unknown): readonly string[] | undefined {
112
+ if (!Array.isArray(value)) return undefined;
113
+ return value
114
+ .filter((entry): entry is string => typeof entry === "string")
115
+ .map((entry) => entry.trim())
116
+ .filter((entry) => entry.length > 0 && entry.length <= MAX_PATTERN_LENGTH && !entry.includes("\n"))
117
+ .slice(0, MAX_PATTERN_ENTRIES);
118
+ }
119
+
120
+ /**
121
+ * Validate an untrusted settings file.
122
+ *
123
+ * Malformed values are dropped rather than replaced by a default: a broken
124
+ * project file must not be able to pin a value that overrides the global layer.
125
+ * Unknown fields are ignored too.
126
+ */
127
+ export function parseSettingsPatch(value: unknown): SettingsPatch {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
129
+ const record = value as Record<string, unknown>;
130
+ const patch: SettingsPatch = {};
131
+
132
+ if (typeof record.enabled === "boolean") patch.enabled = record.enabled;
133
+
134
+ const timeoutMs = readBoundedInteger(record.timeoutMs, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
135
+ if (timeoutMs !== undefined) patch.timeoutMs = timeoutMs;
136
+
137
+ const maxRetries = readBoundedInteger(record.maxRetries, 0, MAX_RETRIES);
138
+ if (maxRetries !== undefined) patch.maxRetries = maxRetries;
139
+
140
+ const maxStateCharacters = readBoundedInteger(record.maxStateCharacters, 1000, 1_000_000);
141
+ if (maxStateCharacters !== undefined) patch.maxStateCharacters = maxStateCharacters;
142
+
143
+ const safeCommands = record.safeCommands === undefined ? undefined : readStringArray(record.safeCommands);
144
+ if (safeCommands !== undefined) patch.safeCommands = safeCommands;
145
+
146
+ const allowedCommands = record.allowedCommands === undefined ? undefined : readStringArray(record.allowedCommands);
147
+ if (allowedCommands !== undefined) patch.allowedCommands = allowedCommands;
148
+
149
+ const disallowedCommands =
150
+ record.disallowedCommands === undefined ? undefined : readStringArray(record.disallowedCommands);
151
+ if (disallowedCommands !== undefined) patch.disallowedCommands = disallowedCommands;
152
+
153
+ const extraProtectedPaths =
154
+ record.extraProtectedPaths === undefined ? undefined : readStringArray(record.extraProtectedPaths);
155
+ if (extraProtectedPaths !== undefined) patch.extraProtectedPaths = extraProtectedPaths;
156
+
157
+ const thresholds = record.thresholds === undefined ? undefined : readThresholds(record.thresholds);
158
+ if (thresholds !== undefined) patch.thresholds = thresholds;
159
+
160
+ return patch;
161
+ }
162
+
163
+ export function mergeSettings(base: JevAutoModeSettings, patch: SettingsPatch): JevAutoModeSettings {
164
+ const merged = { ...base, ...patch };
165
+ // Thresholds merge per rule: a project file that retunes one condition must not
166
+ // wipe the global overrides for the others.
167
+ if (patch.thresholds !== undefined) {
168
+ merged.thresholds = { ...base.thresholds, ...patch.thresholds };
169
+ }
170
+ return merged;
171
+ }
172
+
173
+ async function readJsonFile(path: string): Promise<unknown> {
174
+ try {
175
+ return JSON.parse(await readFile(path, "utf8"));
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ async function writeFileAtomic(path: string, contents: string): Promise<void> {
182
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
183
+ await mkdir(dirname(path), { recursive: true });
184
+ await writeFile(temporary, contents, "utf8");
185
+ await rename(temporary, path);
186
+ }
187
+
188
+ export class JevAutoModeStore {
189
+ private readonly agentDir: string;
190
+ private readonly configDirName: string;
191
+
192
+ constructor(options: StoreOptions) {
193
+ this.agentDir = options.agentDir;
194
+ this.configDirName = options.configDirName;
195
+ }
196
+
197
+ globalSettingsPath(): string {
198
+ return join(this.agentDir, "jev-auto-mode.json");
199
+ }
200
+
201
+ projectSettingsPath(cwd: string): string {
202
+ return join(cwd, this.configDirName, "jev-auto-mode.json");
203
+ }
204
+
205
+ policyNotesPath(): string {
206
+ return join(this.agentDir, "jev-auto-mode-policy.md");
207
+ }
208
+
209
+ /** Global settings with the project override layered on top, when trusted. */
210
+ async loadSettings(cwd: string, projectTrusted: boolean): Promise<{ settings: JevAutoModeSettings; scope: SettingsScope }> {
211
+ const globalPatch = parseSettingsPatch(await readJsonFile(this.globalSettingsPath()));
212
+ if (!projectTrusted) {
213
+ return { settings: mergeSettings(DEFAULT_SETTINGS, globalPatch), scope: "global" };
214
+ }
215
+ const projectValue = await readJsonFile(this.projectSettingsPath(cwd));
216
+ const hasProjectSettings = projectValue !== undefined;
217
+ const projectPatch = parseSettingsPatch(projectValue);
218
+ return {
219
+ settings: mergeSettings(mergeSettings(DEFAULT_SETTINGS, globalPatch), projectPatch),
220
+ scope: hasProjectSettings ? "project" : "global",
221
+ };
222
+ }
223
+
224
+ async saveSettings(settings: JevAutoModeSettings, scope: SettingsScope, cwd: string): Promise<void> {
225
+ const path = scope === "project" ? this.projectSettingsPath(cwd) : this.globalSettingsPath();
226
+ await writeFileAtomic(path, `${JSON.stringify(settings, null, 2)}\n`);
227
+ }
228
+
229
+ /** User-authored policy notes. Advisory input to JEV, never a hard rule. */
230
+ async loadPolicyNotes(): Promise<string> {
231
+ try {
232
+ return (await readFile(this.policyNotesPath(), "utf8")).slice(0, MAX_POLICY_NOTES_LENGTH);
233
+ } catch {
234
+ return "";
235
+ }
236
+ }
237
+
238
+ async savePolicyNotes(notes: string): Promise<void> {
239
+ await writeFileAtomic(this.policyNotesPath(), notes.slice(0, MAX_POLICY_NOTES_LENGTH));
240
+ }
241
+
242
+ credentialPath(): string {
243
+ return join(this.agentDir, "secrets", CREDENTIAL_FILE_NAME);
244
+ }
245
+
246
+ async readStoredApiKey(): Promise<string | undefined> {
247
+ try {
248
+ const value = (await readFile(this.credentialPath(), "utf8")).trim();
249
+ return value.length > 0 ? value : undefined;
250
+ } catch {
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Store the API key with owner-only permissions.
257
+ *
258
+ * `mode` on `writeFile` only applies when the file is created, so the mode is set
259
+ * again afterwards: an existing file with looser permissions is tightened rather
260
+ * than trusted.
261
+ */
262
+ async writeStoredApiKey(apiKey: string): Promise<void> {
263
+ const path = this.credentialPath();
264
+ const directory = dirname(path);
265
+ await mkdir(directory, { recursive: true, mode: SECRET_DIRECTORY_MODE });
266
+ await chmod(directory, SECRET_DIRECTORY_MODE).catch(() => undefined);
267
+ await writeFile(path, `${apiKey.trim()}\n`, { encoding: "utf8", mode: SECRET_FILE_MODE });
268
+ await chmod(path, SECRET_FILE_MODE).catch(() => undefined);
269
+ }
270
+
271
+ async deleteStoredApiKey(): Promise<void> {
272
+ await rm(this.credentialPath(), { force: true });
273
+ }
274
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Footer status and user-facing text.
3
+ *
4
+ * The status line is the only always-visible signal that a probabilistic gate is
5
+ * standing between the model and the shell, so it stays short but never silent:
6
+ * "off" and "no decision engine" are different states and are shown differently.
7
+ */
8
+
9
+ import type { JevAutoModeSettings, SettingsScope } from "./settings.ts";
10
+ import { DEFAULT_RULES, type JevRule } from "./jev/questions.ts";
11
+ import { formatThreshold, observe } from "./jev/decide.ts";
12
+
13
+ export const STATUS_ID = "jev-auto-mode";
14
+
15
+ export interface StatusInput {
16
+ readonly enabled: boolean;
17
+ readonly engineId: string;
18
+ readonly scope: SettingsScope;
19
+ }
20
+
21
+ export function statusText(input: StatusInput): string {
22
+ if (!input.enabled) return "🛡 jev off";
23
+ const scope = input.scope === "project" ? "project" : "global";
24
+ const engine = input.engineId === "manual" ? " ask-only" : "";
25
+ return `🛡 jev${engine} (${scope})`;
26
+ }
27
+
28
+ export interface StatusContext {
29
+ readonly ui: { setStatus(key: string, value: string | undefined): void };
30
+ }
31
+
32
+ export function updateStatus(ctx: StatusContext, input: StatusInput): void {
33
+ ctx.ui.setStatus(STATUS_ID, statusText(input));
34
+ }
35
+
36
+ export function describeSettings(settings: JevAutoModeSettings, scope: SettingsScope): string {
37
+ return [
38
+ `enabled: ${settings.enabled}`,
39
+ `scope: ${scope}`,
40
+ `timeout: ${settings.timeoutMs}ms (retries ${settings.maxRetries})`,
41
+ `safe commands: ${settings.safeCommands.length}`,
42
+ `allowed commands: ${settings.allowedCommands.length}`,
43
+ `disallowed commands: ${settings.disallowedCommands.length}`,
44
+ `extra protected paths: ${settings.extraProtectedPaths.length}`,
45
+ `max state characters: ${settings.maxStateCharacters}`,
46
+ ].join("\n");
47
+ }
48
+
49
+ export const USAGE_TEXT = [
50
+ "Usage:",
51
+ " /jev-auto-mode show status",
52
+ " /jev-auto-mode on|off toggle auto mode",
53
+ " /jev-auto-mode login|logout store or remove the TypeSafe API key",
54
+ " /jev-auto-mode policy list the user policy notes",
55
+ " /jev-auto-mode policy edit",
56
+ " /jev-auto-mode policy clear",
57
+ " /jev-auto-mode threshold show thresholds and last observed probabilities",
58
+ " /jev-auto-mode threshold <rule> <0.5-1.0>",
59
+ " /jev-auto-mode threshold reset [rule]",
60
+ ].join("\n");
61
+
62
+ export const POLICY_HEADER = [
63
+ "# JEV auto mode policy",
64
+ "",
65
+ "Free-form notes describing what this machine and these repositories allow.",
66
+ "They are reference material for the semantic judgment: they can justify an",
67
+ "approval, but they cannot override hard-deny rules.",
68
+ ].join("\n");
69
+
70
+ /**
71
+ * The most recent judgment of one condition, kept for threshold tuning.
72
+ *
73
+ * Only the probability is stored. The band is recomputed against the *current*
74
+ * threshold, so changing a threshold immediately shows what the last judgment would
75
+ * have become — storing the band would leave a stale label next to a threshold that
76
+ * no longer produced it.
77
+ */
78
+ export interface ObservedCondition {
79
+ readonly probability: number;
80
+ readonly at: number;
81
+ }
82
+
83
+ function pad(value: string, width: number): string {
84
+ return value.length >= width ? value : value + " ".repeat(width - value.length);
85
+ }
86
+
87
+ /**
88
+ * The tuning table.
89
+ *
90
+ * Showing the last observed probability next to each threshold is the whole point:
91
+ * a threshold cannot be chosen from a rule description, only from what the model
92
+ * actually answered for calls you care about. The band is recomputed against the
93
+ * current threshold, so the table doubles as a what-if view while tuning.
94
+ */
95
+ export function formatRuleTable(
96
+ rules: readonly JevRule[] = DEFAULT_RULES,
97
+ overrides: Readonly<Record<string, number>> = {},
98
+ observed: ReadonlyMap<string, ObservedCondition> = new Map(),
99
+ ): string {
100
+ const header = `${pad("rule", 24)}${pad("mode", 10)}${pad("severity", 10)}${pad("threshold", 30)}last observed`;
101
+ const rows = rules.map((rule) => {
102
+ const override = overrides[rule.id];
103
+ const threshold = override ?? rule.threshold;
104
+ const origin = override === undefined ? "default" : `override (default ${formatThreshold(rule.threshold)})`;
105
+ // Recompute against the effective rule, not the default one: the point of the
106
+ // last-observed column is to answer "what would this answer mean now".
107
+ const effective = override === undefined ? rule : { ...rule, threshold: override };
108
+ return `${pad(rule.id, 24)}${pad(rule.mode, 10)}${pad(rule.severity, 10)}${pad(`${formatThreshold(threshold)} ${origin}`, 30)}${describeLast(effective, observed.get(rule.id))}`;
109
+ });
110
+
111
+ const unknown = Object.keys(overrides).filter((ruleId) => !rules.some((rule) => rule.id === ruleId));
112
+ if (unknown.length > 0) {
113
+ rows.push(`\noverride(s) that match no known rule: ${unknown.join(", ")}`);
114
+ }
115
+
116
+ return [header, ...rows].join("\n");
117
+ }
118
+
119
+ function describeLast(rule: JevRule, last: ObservedCondition | undefined): string {
120
+ if (!last) return "-";
121
+ const [observation] = observe([rule], { [rule.id]: last.probability });
122
+ const band = observation?.verdict ?? "uncertain";
123
+ const label = band === "uncertain" && observation?.effective === "satisfied" ? "ignored" : band;
124
+ return `p=${last.probability.toFixed(2)} (${label})`;
125
+ }