pi-warden 0.71.0 → 0.74.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 +54 -0
- package/dist/adaptive.d.ts +3 -2
- package/dist/adaptive.js +4 -3
- package/dist/backend.d.ts +33 -14
- package/dist/backend.js +27 -14
- package/dist/config.d.ts +13 -8
- package/dist/config.js +20 -20
- package/dist/conscience.d.ts +1 -1
- package/dist/extension.d.ts +14 -3
- package/dist/extension.js +410 -64
- package/dist/guard.d.ts +2 -1
- package/dist/guard.js +4 -4
- package/dist/holds.d.ts +1 -1
- package/dist/holds.js +3 -2
- package/dist/host-compat.d.ts +2 -0
- package/dist/host-compat.js +132 -0
- package/dist/host-dirs.d.ts +10 -0
- package/dist/host-dirs.js +21 -0
- package/dist/index-cmd.d.ts +6 -5
- package/dist/index-cmd.js +16 -24
- package/dist/index.d.ts +12 -4
- package/dist/index.js +6 -2
- package/dist/init.js +31 -3
- package/dist/learning.d.ts +12 -11
- package/dist/learning.js +60 -38
- package/dist/loops.d.ts +2 -1
- package/dist/loops.js +3 -2
- package/dist/panel.d.ts +4 -1
- package/dist/panel.js +11 -6
- package/dist/prefs.d.ts +1 -1
- package/dist/prefs.js +3 -2
- package/dist/rules-audit.d.ts +208 -0
- package/dist/rules-audit.js +417 -0
- package/dist/rules-calibrate.d.ts +196 -0
- package/dist/rules-calibrate.js +474 -0
- package/dist/rules-lint.d.ts +2 -0
- package/dist/rules-lint.js +7 -2
- package/dist/rules-log.d.ts +6 -1
- package/dist/rules-log.js +9 -3
- package/dist/rules-report.d.ts +3 -0
- package/dist/rules-report.js +5 -0
- package/dist/rules.d.ts +72 -5
- package/dist/rules.js +116 -54
- package/dist/shape.js +8 -2
- package/dist/turn-rules.d.ts +86 -0
- package/dist/turn-rules.js +232 -0
- package/dist/widget-render.d.ts +23 -0
- package/dist/widget-render.js +82 -0
- package/dist/widget.d.ts +5 -22
- package/dist/widget.js +3 -80
- package/package.json +5 -3
package/dist/learning.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// Records full context with each hold and predicts outcomes using history.
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import { mkdirSync } from "fs";
|
|
5
|
-
import { homedir } from "os";
|
|
6
5
|
import { dirname, join } from "path";
|
|
6
|
+
import { defaultHostDirs } from "./host-dirs.js";
|
|
7
|
+
import { userConfigPath } from "./config.js";
|
|
7
8
|
import { redact } from "./redact.js";
|
|
8
|
-
|
|
9
|
+
const dbs = new Map();
|
|
9
10
|
let sqliteAvailable;
|
|
10
11
|
// --- Schema (shared constant) ---
|
|
11
12
|
export const HOLDS_SCHEMA = `
|
|
@@ -38,31 +39,52 @@ const NOOP_DB = {
|
|
|
38
39
|
prepare() { return { run() { return { changes: 0, lastInsertRowid: 0 }; }, get() { return undefined; }, all() { return []; } }; },
|
|
39
40
|
pragma() { },
|
|
40
41
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (sqliteAvailable === false)
|
|
45
|
-
return NOOP_DB;
|
|
42
|
+
/** Open one connection; a failure is remembered per path so later calls neither warn again nor retry forever. */
|
|
43
|
+
async function openDb(dbPath) {
|
|
44
|
+
let opened;
|
|
46
45
|
try {
|
|
47
|
-
|
|
46
|
+
// Static import cannot work: node:sqlite is flagged experimental and loads lazily so a missing
|
|
47
|
+
// or broken build of it disables learning features instead of failing the whole process.
|
|
48
|
+
const sqlite = await import("node:sqlite").catch(err => ({ importFailed: err }));
|
|
49
|
+
if ("importFailed" in sqlite) {
|
|
50
|
+
sqliteAvailable = false;
|
|
51
|
+
console.warn("pi-warden: node:sqlite unavailable, learning features disabled:", sqlite.importFailed);
|
|
52
|
+
return NOOP_DB;
|
|
53
|
+
}
|
|
54
|
+
const { DatabaseSync } = sqlite;
|
|
48
55
|
sqliteAvailable = true;
|
|
49
|
-
const dbPath = process.env.PI_WARDEN_DB ?? join(homedir(), ".pi", "agent", "pi-warden", "holds.db");
|
|
50
56
|
// DatabaseSync does not create parent directories; on a fresh machine the folder may not exist yet.
|
|
51
57
|
mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 });
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
58
|
+
opened = new DatabaseSync(dbPath);
|
|
59
|
+
opened.exec("PRAGMA journal_mode = WAL");
|
|
60
|
+
opened.exec("PRAGMA busy_timeout = 10000");
|
|
61
|
+
return opened;
|
|
56
62
|
}
|
|
57
63
|
catch (err) {
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
// Import failures are handled above; this is a path open or PRAGMA failure for this path only.
|
|
65
|
+
// Close the half-opened handle and leave the resolved NOOP promise cached: one warning,
|
|
66
|
+
// and later calls neither reopen nor retry an unusable file.
|
|
67
|
+
try {
|
|
68
|
+
opened?.close();
|
|
69
|
+
}
|
|
70
|
+
catch { /* already closed */ }
|
|
71
|
+
console.warn(`pi-warden: could not open ${dbPath}:`, err);
|
|
60
72
|
return NOOP_DB;
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
|
-
|
|
75
|
+
/** One connection per resolved path; concurrent first calls share one open. */
|
|
76
|
+
async function getDb(dirs = defaultHostDirs()) {
|
|
77
|
+
const dbPath = process.env.PI_WARDEN_DB ?? join(dirname(userConfigPath(dirs)), "holds.db");
|
|
78
|
+
if (sqliteAvailable === false)
|
|
79
|
+
return NOOP_DB;
|
|
80
|
+
let opening = dbs.get(dbPath);
|
|
81
|
+
if (!opening)
|
|
82
|
+
dbs.set(dbPath, opening = openDb(dbPath));
|
|
83
|
+
return opening;
|
|
84
|
+
}
|
|
85
|
+
export async function initSchema(retentionDays = 365, dirs = defaultHostDirs()) {
|
|
64
86
|
try {
|
|
65
|
-
const d = await getDb();
|
|
87
|
+
const d = await getDb(dirs);
|
|
66
88
|
d.exec(HOLDS_SCHEMA);
|
|
67
89
|
// Migrate: add columns that may be missing from older databases.
|
|
68
90
|
try {
|
|
@@ -135,8 +157,8 @@ export function toHoldRecord(item, projectRoot, ctx) {
|
|
|
135
157
|
return result;
|
|
136
158
|
}
|
|
137
159
|
// --- Recording ---
|
|
138
|
-
export async function recordHold(hold) {
|
|
139
|
-
const d = await getDb();
|
|
160
|
+
export async function recordHold(hold, dirs = defaultHostDirs()) {
|
|
161
|
+
const d = await getDb(dirs);
|
|
140
162
|
const hash = signatureHash(hold.tool, hold.scores);
|
|
141
163
|
const stmt = d.prepare(`
|
|
142
164
|
INSERT INTO holds
|
|
@@ -148,9 +170,9 @@ export async function recordHold(hold) {
|
|
|
148
170
|
const result = stmt.run(hold.timestamp, hold.projectRoot, hold.tool, hash, hold.commandPreview, hold.task ?? null, hold.plan ?? null, hold.contextSummary ?? null, hold.precedingActions ?? null, JSON.stringify(hold.scores), hold.level, hold.held ? 1 : 0, JSON.stringify(hold.reasons), hold.agentReason ?? null, hold.confidence ?? null);
|
|
149
171
|
return Number(result.lastInsertRowid);
|
|
150
172
|
}
|
|
151
|
-
export async function recordOutcome(id, outcome) {
|
|
173
|
+
export async function recordOutcome(id, outcome, dirs = defaultHostDirs()) {
|
|
152
174
|
try {
|
|
153
|
-
(await getDb()).prepare("UPDATE holds SET outcome = ?, outcome_at = ? WHERE id = ?").run(outcome, Date.now(), id);
|
|
175
|
+
(await getDb(dirs)).prepare("UPDATE holds SET outcome = ?, outcome_at = ? WHERE id = ?").run(outcome, Date.now(), id);
|
|
154
176
|
}
|
|
155
177
|
catch (err) {
|
|
156
178
|
console.warn("pi-warden: could not record hold outcome:", err);
|
|
@@ -158,15 +180,15 @@ export async function recordOutcome(id, outcome) {
|
|
|
158
180
|
}
|
|
159
181
|
// --- Querying ---
|
|
160
182
|
/** Query holds by project and held value. Used by tests and future analytics. */
|
|
161
|
-
export async function queryHoldsForProject(projectRoot, options) {
|
|
162
|
-
const d = await getDb();
|
|
183
|
+
export async function queryHoldsForProject(projectRoot, options, dirs = defaultHostDirs()) {
|
|
184
|
+
const d = await getDb(dirs);
|
|
163
185
|
if (options?.held !== undefined) {
|
|
164
186
|
return d.prepare("SELECT id, tool, held, outcome, command_preview FROM holds WHERE project_root = ? AND held = ? ORDER BY timestamp").all(projectRoot, options.held ? 1 : 0);
|
|
165
187
|
}
|
|
166
188
|
return d.prepare("SELECT id, tool, held, outcome, command_preview FROM holds WHERE project_root = ? ORDER BY timestamp").all(projectRoot);
|
|
167
189
|
}
|
|
168
|
-
export async function holdStats(projectRoot) {
|
|
169
|
-
const d = await getDb();
|
|
190
|
+
export async function holdStats(projectRoot, dirs = defaultHostDirs()) {
|
|
191
|
+
const d = await getDb(dirs);
|
|
170
192
|
const held = d.prepare(`SELECT
|
|
171
193
|
COUNT(*) AS total,
|
|
172
194
|
COUNT(CASE WHEN outcome IN ('approved','declined','replanned') THEN 1 END) AS labeled,
|
|
@@ -194,8 +216,8 @@ export async function holdStats(projectRoot) {
|
|
|
194
216
|
newest: held.newest ?? 0,
|
|
195
217
|
};
|
|
196
218
|
}
|
|
197
|
-
export async function querySmartHistory(tool, scores, projectRoot) {
|
|
198
|
-
const d = await getDb();
|
|
219
|
+
export async function querySmartHistory(tool, scores, projectRoot, dirs = defaultHostDirs()) {
|
|
220
|
+
const d = await getDb(dirs);
|
|
199
221
|
const hash = signatureHash(tool, scores);
|
|
200
222
|
const exact = d.prepare(`
|
|
201
223
|
SELECT task, plan, outcome, scores, agent_reason, timestamp
|
|
@@ -236,8 +258,8 @@ export function calculateSmartConfidence(history) {
|
|
|
236
258
|
};
|
|
237
259
|
}
|
|
238
260
|
// --- Integration ---
|
|
239
|
-
export async function shouldSkipHold(tool, scores, projectRoot) {
|
|
240
|
-
const history = await querySmartHistory(tool, scores, projectRoot);
|
|
261
|
+
export async function shouldSkipHold(tool, scores, projectRoot, dirs = defaultHostDirs()) {
|
|
262
|
+
const history = await querySmartHistory(tool, scores, projectRoot, dirs);
|
|
241
263
|
const { confidence, reason } = calculateSmartConfidence(history);
|
|
242
264
|
const isDestructive = scores.reasons.some(r => r.startsWith("destructive:"));
|
|
243
265
|
if (isDestructive)
|
|
@@ -248,8 +270,8 @@ export async function shouldSkipHold(tool, scores, projectRoot) {
|
|
|
248
270
|
return { skip: false, confidence, reason };
|
|
249
271
|
}
|
|
250
272
|
/** Analyze hold outcomes to suggest threshold adjustments. */
|
|
251
|
-
export async function analyzeThresholds(projectRoot) {
|
|
252
|
-
const d = await getDb();
|
|
273
|
+
export async function analyzeThresholds(projectRoot, dirs = defaultHostDirs()) {
|
|
274
|
+
const d = await getDb(dirs);
|
|
253
275
|
const adjustments = [];
|
|
254
276
|
// Analyze action guard: look at holds vs approvals
|
|
255
277
|
const actionHolds = d.prepare(`
|
|
@@ -295,8 +317,8 @@ export async function analyzeThresholds(projectRoot) {
|
|
|
295
317
|
}
|
|
296
318
|
return adjustments;
|
|
297
319
|
}
|
|
298
|
-
export async function analyzePatterns(projectRoot) {
|
|
299
|
-
const d = await getDb();
|
|
320
|
+
export async function analyzePatterns(projectRoot, dirs = defaultHostDirs()) {
|
|
321
|
+
const d = await getDb(dirs);
|
|
300
322
|
const insights = [];
|
|
301
323
|
// Get pattern outcomes
|
|
302
324
|
const patterns = d.prepare(`
|
|
@@ -340,9 +362,9 @@ export async function analyzePatterns(projectRoot) {
|
|
|
340
362
|
return insights.sort((a, b) => b.falsePositiveRate - a.falsePositiveRate);
|
|
341
363
|
}
|
|
342
364
|
/** Generate recommendations based on learning data. */
|
|
343
|
-
export async function generateRecommendations(projectRoot) {
|
|
365
|
+
export async function generateRecommendations(projectRoot, dirs = defaultHostDirs()) {
|
|
344
366
|
const recommendations = [];
|
|
345
|
-
const thresholdAdjustments = await analyzeThresholds(projectRoot);
|
|
367
|
+
const thresholdAdjustments = await analyzeThresholds(projectRoot, dirs);
|
|
346
368
|
for (const adj of thresholdAdjustments) {
|
|
347
369
|
if (adj.confidence > 0.5) {
|
|
348
370
|
recommendations.push({
|
|
@@ -352,7 +374,7 @@ export async function generateRecommendations(projectRoot) {
|
|
|
352
374
|
});
|
|
353
375
|
}
|
|
354
376
|
}
|
|
355
|
-
const patternInsights = await analyzePatterns(projectRoot);
|
|
377
|
+
const patternInsights = await analyzePatterns(projectRoot, dirs);
|
|
356
378
|
for (const insight of patternInsights.slice(0, 3)) {
|
|
357
379
|
if (insight.falsePositiveRate > 0.6) {
|
|
358
380
|
recommendations.push({
|
|
@@ -368,8 +390,8 @@ export async function generateRecommendations(projectRoot) {
|
|
|
368
390
|
});
|
|
369
391
|
}
|
|
370
392
|
/** Analyze steer effectiveness from hold outcomes. */
|
|
371
|
-
export async function analyzeSteerEffectivenessReport(projectRoot) {
|
|
372
|
-
const d = await getDb();
|
|
393
|
+
export async function analyzeSteerEffectivenessReport(projectRoot, dirs = defaultHostDirs()) {
|
|
394
|
+
const d = await getDb(dirs);
|
|
373
395
|
const suggestions = [];
|
|
374
396
|
// Get steer outcomes (inferred from hold outcomes)
|
|
375
397
|
const steerOutcomes = d.prepare(`
|
package/dist/loops.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HostDirs } from "./host-dirs.js";
|
|
1
2
|
export declare const LOOP_CHARS = 160;
|
|
2
3
|
/** Open loops shown in the appendix, the reminder, and the resume message. */
|
|
3
4
|
export declare const LOOPS_SHOWN = 8;
|
|
@@ -25,7 +26,7 @@ export declare function emptyLoopStore(): LoopStore;
|
|
|
25
26
|
* One file per session, in a folder per project: the working directory's hash keeps another project's session with the
|
|
26
27
|
* same id apart, and the id keeps another session of this project apart.
|
|
27
28
|
*/
|
|
28
|
-
export declare function loopsPath(cwd: string, session: string): string;
|
|
29
|
+
export declare function loopsPath(cwd: string, session: string, dirs?: HostDirs): string;
|
|
29
30
|
/** A missing file is an empty store; an unreadable or malformed one throws, so a write never replaces loops it could not read. */
|
|
30
31
|
export declare function readLoops(path: string): Promise<LoopStore>;
|
|
31
32
|
/** Written whole to a temporary file and renamed, so a crash never leaves half a file. */
|
package/dist/loops.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFile } from "node:fs/promises";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { serialize, writeFileAtomic } from "./atomic.js";
|
|
12
12
|
import { userConfigPath } from "./config.js";
|
|
13
|
+
import { defaultHostDirs } from "./host-dirs.js";
|
|
13
14
|
import { redact } from "./redact.js";
|
|
14
15
|
export const LOOP_CHARS = 160;
|
|
15
16
|
/** Open loops shown in the appendix, the reminder, and the resume message. */
|
|
@@ -25,9 +26,9 @@ export function emptyLoopStore() {
|
|
|
25
26
|
* One file per session, in a folder per project: the working directory's hash keeps another project's session with the
|
|
26
27
|
* same id apart, and the id keeps another session of this project apart.
|
|
27
28
|
*/
|
|
28
|
-
export function loopsPath(cwd, session) {
|
|
29
|
+
export function loopsPath(cwd, session, dirs = defaultHostDirs()) {
|
|
29
30
|
const project = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
30
|
-
return join(dirname(userConfigPath()), "loops", project, `${session.replace(/[^\w.-]/g, "_")}.json`);
|
|
31
|
+
return join(dirname(userConfigPath(dirs)), "loops", project, `${session.replace(/[^\w.-]/g, "_")}.json`);
|
|
31
32
|
}
|
|
32
33
|
const isLoop = (value) => {
|
|
33
34
|
const loop = value;
|
package/dist/panel.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { WardenConfig } from "./config.js";
|
|
3
|
+
import type { HostDirs } from "./host-dirs.js";
|
|
3
4
|
import type { Trace } from "./trace.js";
|
|
4
5
|
import type { ThemeLike } from "./widget.js";
|
|
5
6
|
export interface PanelActions {
|
|
@@ -69,6 +70,7 @@ export declare class ConfigPanel implements Component {
|
|
|
69
70
|
private readonly theme;
|
|
70
71
|
private readonly actions;
|
|
71
72
|
private readonly requestRender;
|
|
73
|
+
private readonly dirs;
|
|
72
74
|
focused: boolean;
|
|
73
75
|
private cursor;
|
|
74
76
|
private scroll;
|
|
@@ -77,7 +79,7 @@ export declare class ConfigPanel implements Component {
|
|
|
77
79
|
private editBuffer;
|
|
78
80
|
private readonly edits;
|
|
79
81
|
private readonly entries;
|
|
80
|
-
constructor(config: WardenConfig, theme: ThemeLike, actions: PanelActions, requestRender: () => void);
|
|
82
|
+
constructor(config: WardenConfig, theme: ThemeLike, actions: PanelActions, requestRender: () => void, dirs?: HostDirs);
|
|
81
83
|
invalidate(): void;
|
|
82
84
|
private lines;
|
|
83
85
|
render(width: number): string[];
|
|
@@ -89,4 +91,5 @@ export declare class ConfigPanel implements Component {
|
|
|
89
91
|
}
|
|
90
92
|
export declare function openConfigPanel(ui: PanelUi, config: WardenConfig, options?: {
|
|
91
93
|
width?: string | number;
|
|
94
|
+
dirs?: HostDirs;
|
|
92
95
|
}): PanelController;
|
package/dist/panel.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { applyUserOverrides, defaultConfig, readUserConfig, writeUserConfig } from "./config.js";
|
|
3
|
+
import { defaultHostDirs } from "./host-dirs.js";
|
|
3
4
|
import { LEVEL_COLOR, parseVerdictLine, renderSegment } from "./widget.js";
|
|
4
5
|
/**
|
|
5
6
|
* Sidebar listing the trace newest-first, live-updating while open. It opens without taking keyboard input: the editor
|
|
@@ -169,7 +170,8 @@ export function openTracePanel(ui, trace, options = {}) {
|
|
|
169
170
|
}, {
|
|
170
171
|
overlay: true,
|
|
171
172
|
overlayOptions: { anchor: "right-center", width: options.width ?? "40%", minWidth: 44, maxHeight: "100%", nonCapturing: true },
|
|
172
|
-
|
|
173
|
+
// A handle without `unfocus` belongs to a host whose overlays always capture input; close instead.
|
|
174
|
+
onHandle: (handle) => { unfocus = typeof handle.unfocus === "function" ? () => handle.unfocus() : () => close(); },
|
|
173
175
|
});
|
|
174
176
|
return { closed, close: () => close(), built: () => built };
|
|
175
177
|
}
|
|
@@ -195,6 +197,7 @@ export class ConfigPanel {
|
|
|
195
197
|
theme;
|
|
196
198
|
actions;
|
|
197
199
|
requestRender;
|
|
200
|
+
dirs;
|
|
198
201
|
focused = false;
|
|
199
202
|
cursor = 0;
|
|
200
203
|
scroll = 0;
|
|
@@ -203,11 +206,12 @@ export class ConfigPanel {
|
|
|
203
206
|
editBuffer = "";
|
|
204
207
|
edits = new Map();
|
|
205
208
|
entries;
|
|
206
|
-
constructor(config, theme, actions, requestRender) {
|
|
209
|
+
constructor(config, theme, actions, requestRender, dirs = defaultHostDirs()) {
|
|
207
210
|
this.config = config;
|
|
208
211
|
this.theme = theme;
|
|
209
212
|
this.actions = actions;
|
|
210
213
|
this.requestRender = requestRender;
|
|
214
|
+
this.dirs = dirs;
|
|
211
215
|
this.entries = flattenConfig(config);
|
|
212
216
|
}
|
|
213
217
|
invalidate() { }
|
|
@@ -369,7 +373,7 @@ export class ConfigPanel {
|
|
|
369
373
|
save() {
|
|
370
374
|
if (this.edits.size === 0)
|
|
371
375
|
return;
|
|
372
|
-
const raw = readUserConfig();
|
|
376
|
+
const raw = readUserConfig(this.dirs);
|
|
373
377
|
const obj = (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ? raw : {};
|
|
374
378
|
for (const [path, value] of this.edits) {
|
|
375
379
|
const keys = path.split(".");
|
|
@@ -382,7 +386,7 @@ export class ConfigPanel {
|
|
|
382
386
|
}
|
|
383
387
|
current[keys.at(-1)] = value;
|
|
384
388
|
}
|
|
385
|
-
writeUserConfig(obj);
|
|
389
|
+
writeUserConfig(obj, this.dirs);
|
|
386
390
|
this.edits.clear();
|
|
387
391
|
}
|
|
388
392
|
}
|
|
@@ -391,7 +395,7 @@ export function openConfigPanel(ui, config, options = {}) {
|
|
|
391
395
|
let unfocus = () => { };
|
|
392
396
|
const closed = ui.custom((tui, theme, _keybindings, done) => {
|
|
393
397
|
close = () => done();
|
|
394
|
-
const panel = new ConfigPanel(config, theme, { close, unfocus: () => unfocus() }, () => tui.requestRender());
|
|
398
|
+
const panel = new ConfigPanel(config, theme, { close, unfocus: () => unfocus() }, () => tui.requestRender(), options.dirs);
|
|
395
399
|
const rows = tui.terminal?.rows;
|
|
396
400
|
if (typeof rows === "number")
|
|
397
401
|
panel.setViewport(rows - 2);
|
|
@@ -399,7 +403,8 @@ export function openConfigPanel(ui, config, options = {}) {
|
|
|
399
403
|
}, {
|
|
400
404
|
overlay: true,
|
|
401
405
|
overlayOptions: { anchor: "right-center", width: options.width ?? "40%", minWidth: 44, maxHeight: "100%", nonCapturing: true },
|
|
402
|
-
|
|
406
|
+
// A handle without `unfocus` belongs to a host whose overlays always capture input; close instead.
|
|
407
|
+
onHandle: (handle) => { unfocus = typeof handle.unfocus === "function" ? () => handle.unfocus() : () => close(); },
|
|
403
408
|
});
|
|
404
409
|
return { closed, close: () => close() };
|
|
405
410
|
}
|
package/dist/prefs.d.ts
CHANGED
|
@@ -184,7 +184,7 @@ export interface PrefsStore {
|
|
|
184
184
|
forgotten: Forgotten[];
|
|
185
185
|
}
|
|
186
186
|
export declare function emptyPrefsStore(): PrefsStore;
|
|
187
|
-
export declare function prefsStorePath(project: string): string;
|
|
187
|
+
export declare function prefsStorePath(project: string, dirs?: import("./host-dirs.js").HostDirs): string;
|
|
188
188
|
/**
|
|
189
189
|
* A missing file is an empty store. An unreadable or malformed one throws: without the forgotten list, a group the
|
|
190
190
|
* user dropped could reach the agent again, so the caller injects nothing and says why.
|
package/dist/prefs.js
CHANGED
|
@@ -14,6 +14,7 @@ import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
|
14
14
|
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
15
15
|
import { serialize, writeFileAtomic } from "./atomic.js";
|
|
16
16
|
import { userConfigPath } from "./config.js";
|
|
17
|
+
import { defaultHostDirs } from "./host-dirs.js";
|
|
17
18
|
import { redact } from "./redact.js";
|
|
18
19
|
/**
|
|
19
20
|
* Short automated sessions fill a project's directory quickly: on real data the newest 50 sessions covered three to six
|
|
@@ -502,9 +503,9 @@ export async function scanPreferences(options) {
|
|
|
502
503
|
export function emptyPrefsStore() {
|
|
503
504
|
return { lessons: [], confirmations: [], forgotten: [] };
|
|
504
505
|
}
|
|
505
|
-
export function prefsStorePath(project) {
|
|
506
|
+
export function prefsStorePath(project, dirs = defaultHostDirs()) {
|
|
506
507
|
const hash = createHash("sha256").update(project).digest("hex").slice(0, 12);
|
|
507
|
-
return join(dirname(userConfigPath()), "prefs", `${hash}.json`);
|
|
508
|
+
return join(dirname(userConfigPath(dirs)), "prefs", `${hash}.json`);
|
|
508
509
|
}
|
|
509
510
|
const isRecord = (value) => typeof value === "object" && value !== null && typeof value.session === "string" && typeof value.at === "number";
|
|
510
511
|
const isLesson = (value) => typeof value === "object" && value !== null && typeof value.text === "string" && Array.isArray(value.records) && value.records.every(isRecord);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Judge } from "pi-typesafe";
|
|
2
|
+
import type { RulesConfig } from "./config.js";
|
|
3
|
+
import type { RuleSet, RulesVerdict } from "./rules.js";
|
|
4
|
+
/**
|
|
5
|
+
* `/warden rules audit` and `/warden bench`: two plain functions over the rules guard.
|
|
6
|
+
*
|
|
7
|
+
* The audit judges existing files against the project rules as if each had just been written, one `write` request per
|
|
8
|
+
* file. Nothing leaves the machine without the confirm dialog (or `--yes` in a headless run), the results never reach
|
|
9
|
+
* the rules log, and the only file written is the Markdown copy under `.pi-warden/`. The bench measures what one of
|
|
10
|
+
* those requests costs on this machine with a fixed built-in sample, so no project content is sent for it at all.
|
|
11
|
+
*/
|
|
12
|
+
/** Files the audit judges unless `--max` says otherwise. */
|
|
13
|
+
export declare const AUDIT_DEFAULT_MAX = 50;
|
|
14
|
+
/** Requests in flight at once; small so a big audit cannot burst the backend. */
|
|
15
|
+
export declare const AUDIT_CONCURRENCY = 4;
|
|
16
|
+
/** Source files the discovery walk collects before it stops; the cap counts what it found. */
|
|
17
|
+
export declare const AUDIT_DISCOVERY_MAX = 5000;
|
|
18
|
+
export declare const AUDIT_REPORT_FILE = ".pi-warden/rules-audit.md";
|
|
19
|
+
export declare const RULES_AUDIT_USAGE = "Usage: /warden rules audit [paths...] [--max N] [--yes], where N is a positive integer (default 50) and paths default to the project.";
|
|
20
|
+
export declare const BENCH_USAGE = "Usage: /warden bench [--runs N], where N is a positive integer (default 10).";
|
|
21
|
+
export interface RulesAuditArgs {
|
|
22
|
+
/** Project-relative paths to audit; `["."]` when none were given. */
|
|
23
|
+
paths: string[];
|
|
24
|
+
max: number;
|
|
25
|
+
yes: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function parseRulesAuditArgs(tokens: readonly string[]): RulesAuditArgs;
|
|
29
|
+
export interface BenchArgs {
|
|
30
|
+
runs: number;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function parseBenchArgs(tokens: readonly string[]): BenchArgs;
|
|
34
|
+
export interface AuditFilePlan {
|
|
35
|
+
/** Project-relative paths to judge, in sorted order, capped at `max`. */
|
|
36
|
+
files: string[];
|
|
37
|
+
/** Files that passed every filter (git, `rules.exclude`, `rules.skip`, and at least one applicable rule). */
|
|
38
|
+
matched: number;
|
|
39
|
+
/** Matched files left out at the cap. */
|
|
40
|
+
leftOut: number;
|
|
41
|
+
/** Given paths that are not a file or directory inside the project. */
|
|
42
|
+
missing: string[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The source files under `paths` the audit will judge: found by `discoverSourceFiles`, inside the project, not ignored
|
|
46
|
+
* by git, not kept out by `rules.exclude` or `rules.skip`, and with at least one rule that applies to them. Capped in
|
|
47
|
+
* sorted order so the same command judges the same files twice.
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectAuditFiles(cwd: string, paths: readonly string[], set: RuleSet | undefined, config: RulesConfig, max: number): AuditFilePlan;
|
|
50
|
+
export interface RulesAuditFileResult {
|
|
51
|
+
/** Project-relative path. */
|
|
52
|
+
path: string;
|
|
53
|
+
verdict: RulesVerdict;
|
|
54
|
+
}
|
|
55
|
+
export interface RulesAuditRow {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
/** Files whose request asked about this rule. */
|
|
59
|
+
judged: number;
|
|
60
|
+
/** Files where the rule scored at or above its cutoff. */
|
|
61
|
+
flagged: number;
|
|
62
|
+
/** Mean P(violation) over the files judged. */
|
|
63
|
+
mean: number;
|
|
64
|
+
}
|
|
65
|
+
export interface RulesAuditOutcome {
|
|
66
|
+
paths: string[];
|
|
67
|
+
max: number;
|
|
68
|
+
/** Files selected for judgment. */
|
|
69
|
+
selected: number;
|
|
70
|
+
judged: number;
|
|
71
|
+
leftOut: number;
|
|
72
|
+
missing: string[];
|
|
73
|
+
/** Selected files that could not be read or were empty. */
|
|
74
|
+
unreadable: string[];
|
|
75
|
+
/** Files whose judgment came back as an error. */
|
|
76
|
+
errors: string[];
|
|
77
|
+
results: RulesAuditFileResult[];
|
|
78
|
+
rows: RulesAuditRow[];
|
|
79
|
+
/** Files with at least one finding. */
|
|
80
|
+
flagged: number;
|
|
81
|
+
sources: string[];
|
|
82
|
+
/** Distinct rules asked across the audit. */
|
|
83
|
+
rules: number;
|
|
84
|
+
aggregate: boolean;
|
|
85
|
+
reportPath: string;
|
|
86
|
+
elapsedMs: number;
|
|
87
|
+
}
|
|
88
|
+
export type RulesAuditResult = {
|
|
89
|
+
status: "no-judge";
|
|
90
|
+
} | {
|
|
91
|
+
status: "no-files";
|
|
92
|
+
reason: string;
|
|
93
|
+
} | {
|
|
94
|
+
status: "needs-yes";
|
|
95
|
+
files: number;
|
|
96
|
+
leftOut: number;
|
|
97
|
+
} | {
|
|
98
|
+
status: "cancelled";
|
|
99
|
+
} | {
|
|
100
|
+
status: "done";
|
|
101
|
+
outcome: RulesAuditOutcome;
|
|
102
|
+
};
|
|
103
|
+
export interface RulesAuditOptions {
|
|
104
|
+
cwd: string;
|
|
105
|
+
paths: readonly string[];
|
|
106
|
+
max: number;
|
|
107
|
+
/** Headless runs pass `--yes`; without it and without a dialog nothing is sent. */
|
|
108
|
+
yes: boolean;
|
|
109
|
+
config: RulesConfig;
|
|
110
|
+
set: RuleSet | undefined;
|
|
111
|
+
judge?: Judge | undefined;
|
|
112
|
+
timeoutMs: number;
|
|
113
|
+
signal?: AbortSignal | undefined;
|
|
114
|
+
/** Where the samples go, named in the confirm dialog. */
|
|
115
|
+
destination: string;
|
|
116
|
+
confirm?: ((title: string, body: string) => boolean | Promise<boolean>) | undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* One `write` request per selected file, at most AUDIT_CONCURRENCY at a time, after the confirm dialog (or `--yes`).
|
|
120
|
+
* With no judge nothing is sent and no dialog is shown. The Markdown copy is written to `AUDIT_REPORT_FILE`; the rules
|
|
121
|
+
* log is never touched.
|
|
122
|
+
*/
|
|
123
|
+
export declare function runRulesAudit(options: RulesAuditOptions): Promise<RulesAuditResult>;
|
|
124
|
+
/** The per-rule table body, flagged rules first. */
|
|
125
|
+
export declare function formatRulesAuditRows(rows: readonly RulesAuditRow[]): string;
|
|
126
|
+
export interface RulesAuditFlag {
|
|
127
|
+
name: string;
|
|
128
|
+
score: number;
|
|
129
|
+
}
|
|
130
|
+
export interface RulesAuditFileFlags {
|
|
131
|
+
path: string;
|
|
132
|
+
findings: RulesAuditFlag[];
|
|
133
|
+
soft: RulesAuditFlag[];
|
|
134
|
+
}
|
|
135
|
+
/** Files with findings, worst first (most findings, then the strongest score). */
|
|
136
|
+
export declare function flaggedAuditFiles(results: readonly RulesAuditFileResult[]): RulesAuditFileFlags[];
|
|
137
|
+
/** One line per file, rule names with scores indented under the path. */
|
|
138
|
+
export declare function formatRulesAuditFiles(results: readonly RulesAuditFileResult[]): string;
|
|
139
|
+
/** The terminal output: summary, the table by rule, the file list, and where the Markdown copy went. */
|
|
140
|
+
export declare function formatRulesAudit(outcome: RulesAuditOutcome): string;
|
|
141
|
+
/** The Markdown copy of one audit run. */
|
|
142
|
+
export declare function rulesAuditMarkdown(outcome: RulesAuditOutcome, date?: Date): string;
|
|
143
|
+
/** Write the Markdown copy and return the path it went to. */
|
|
144
|
+
export declare function writeRulesAuditReport(cwd: string, markdown: string): string;
|
|
145
|
+
/** One row per rule asked anywhere in the audit: files judged, files flagged, mean score; flagged first. */
|
|
146
|
+
export declare function ruleAuditRows(results: readonly RulesAuditFileResult[]): RulesAuditRow[];
|
|
147
|
+
/**
|
|
148
|
+
* The one fixed sample the bench judges. It is built in, so no project content is sent for a bench run; only the
|
|
149
|
+
* active rule text travels with it.
|
|
150
|
+
*/
|
|
151
|
+
export declare const BENCH_SAMPLE: {
|
|
152
|
+
path: string;
|
|
153
|
+
content: string;
|
|
154
|
+
};
|
|
155
|
+
/** The session counters a bench run diffs; what pi-typesafe's `getUsage()` returns. */
|
|
156
|
+
export interface BenchUsage {
|
|
157
|
+
requestsStarted: number;
|
|
158
|
+
inputTokens: number;
|
|
159
|
+
outputTokens: number;
|
|
160
|
+
estimatedUsd: number;
|
|
161
|
+
}
|
|
162
|
+
export interface BenchOptions {
|
|
163
|
+
runs: number;
|
|
164
|
+
cwd: string;
|
|
165
|
+
config: RulesConfig;
|
|
166
|
+
set: RuleSet | undefined;
|
|
167
|
+
judge?: Judge | undefined;
|
|
168
|
+
getUsage?: (() => BenchUsage) | undefined;
|
|
169
|
+
timeoutMs: number;
|
|
170
|
+
signal?: AbortSignal | undefined;
|
|
171
|
+
/** Clock for the latency measurements; injected by tests. */
|
|
172
|
+
now?: () => number;
|
|
173
|
+
}
|
|
174
|
+
export type BenchResult = {
|
|
175
|
+
status: "no-judge";
|
|
176
|
+
} | {
|
|
177
|
+
status: "skipped";
|
|
178
|
+
reason: string;
|
|
179
|
+
} | {
|
|
180
|
+
status: "done";
|
|
181
|
+
runs: number;
|
|
182
|
+
/** Rules asked per check. */
|
|
183
|
+
asked: number;
|
|
184
|
+
aggregate: boolean;
|
|
185
|
+
/** Checks with at least one finding. */
|
|
186
|
+
flagged: number;
|
|
187
|
+
/** Checks that came back as an error. */
|
|
188
|
+
errors: number;
|
|
189
|
+
/** Requests sent, from `getUsage()`; the checks themselves without it. */
|
|
190
|
+
requests: number;
|
|
191
|
+
p50: number;
|
|
192
|
+
p95: number;
|
|
193
|
+
meanInputTokens?: number;
|
|
194
|
+
costPerCheck?: number;
|
|
195
|
+
costPer100?: number;
|
|
196
|
+
};
|
|
197
|
+
/** Nearest-rank percentile of a sorted, non-empty list of numbers. */
|
|
198
|
+
export declare function percentile(sorted: readonly number[], p: number): number;
|
|
199
|
+
/**
|
|
200
|
+
* Judge the built-in sample `runs` times against the active rules, one request at a time so the latency numbers mean
|
|
201
|
+
* something, and report what the checks cost on this machine. With no judge nothing is sent; a sample the rules keep
|
|
202
|
+
* out (`rules.exclude`, `rules.skip`) is reported and never sent either.
|
|
203
|
+
*/
|
|
204
|
+
export declare function runBench(options: BenchOptions): Promise<BenchResult>;
|
|
205
|
+
/** The bench output: the numbers, and the line that says no project content was sent. */
|
|
206
|
+
export declare function formatBench(result: Extract<BenchResult, {
|
|
207
|
+
status: "done";
|
|
208
|
+
}>): string;
|