@wyattjoh/demur 0.5.0 → 0.6.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/README.md +34 -14
- package/package.json +3 -2
- package/src/cli.ts +404 -134
- package/src/settings-model.ts +60 -0
- package/src/training-review-model.ts +2 -3
- package/src/training-review-tui.tsx +299 -15
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ Pin a specific release when reproducibility matters:
|
|
|
98
98
|
|
|
99
99
|
<!-- x-release-please-start-version -->
|
|
100
100
|
```sh
|
|
101
|
-
pi install npm:@wyattjoh/demur@0.
|
|
101
|
+
pi install npm:@wyattjoh/demur@0.6.0
|
|
102
102
|
```
|
|
103
103
|
<!-- x-release-please-end -->
|
|
104
104
|
|
|
@@ -218,21 +218,32 @@ demur auth login
|
|
|
218
218
|
demur auth status
|
|
219
219
|
demur auth logout
|
|
220
220
|
demur training review
|
|
221
|
-
|
|
222
|
-
demur training
|
|
221
|
+
demur training list --status=unreviewed --cwd=/workspace
|
|
222
|
+
demur training list --status=deny --json
|
|
223
|
+
demur training review <record-id> --decision=deny --note="would destroy work" --json
|
|
223
224
|
demur judge "git reset --hard HEAD~3"
|
|
224
225
|
```
|
|
225
226
|
|
|
226
227
|
Bare `demur` opens the central OpenTUI interface when stdin and stdout are
|
|
227
228
|
interactive. `demur training review` remains an explicit alias for the same
|
|
228
|
-
interface.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
229
|
+
interface. `[` opens Reviews and `]` opens Settings. You can also navigate Up
|
|
230
|
+
to the top-level section strip, use Left and Right to switch sections, and press
|
|
231
|
+
Down or Enter to open one. Settings exposes every option from Pi's `/demur`
|
|
232
|
+
menu—operating mode, training capture,
|
|
233
|
+
and failure policy—and atomically saves each change to the same global
|
|
234
|
+
configuration file. Use Up and Down to select a setting, Left and Right to
|
|
235
|
+
change it in either direction, or Enter/Space to choose its next value.
|
|
236
|
+
Disabling demur also turns training capture off, and training remains unavailable
|
|
237
|
+
until an active mode is selected.
|
|
238
|
+
|
|
239
|
+
The Reviews header shows the persisted global estimated cost, and each queue row
|
|
240
|
+
shows the model decision in a muted semantic color. It starts in an `all` view;
|
|
241
|
+
Tab and Shift-Tab rotate between `all`, `not reviewed`, `approved` (`allow`), `ask`,
|
|
242
|
+
and `deny` views. The queue is focused initially: arrow keys navigate it, Up
|
|
243
|
+
from its first result focuses a fuzzy working-directory filter, and another Up
|
|
244
|
+
focuses the tab strip. Left and Right select adjacent focused tabs, while Down
|
|
245
|
+
returns through the filter to the queue. Right from the queue focuses the
|
|
246
|
+
scrollable detail pane.
|
|
236
247
|
The detail pane supports arrows or `j`/`k`; Left returns to the queue. Page Up
|
|
237
248
|
and Page Down page within the focused pane, and queue navigation stops at its
|
|
238
249
|
first and last entries. Mouse clicks select tabs, records, the filter, or either
|
|
@@ -242,9 +253,18 @@ leaves a record for a later pass, and `q` or Escape stops. Previously reviewed
|
|
|
242
253
|
records remain available, and changing an answer appends a review revision while
|
|
243
254
|
preserving its visible history. The TUI remains open when a view is empty and
|
|
244
255
|
polls training state for newly captured or externally reviewed evaluations.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
256
|
+
|
|
257
|
+
Flag-based training commands provide the same review operations without the
|
|
258
|
+
TUI. `demur training list` accepts `--status` and fuzzy `--cwd` filters. Status
|
|
259
|
+
is derived from the latest human review, so `allow`, `ask`, and `deny` select
|
|
260
|
+
reviewed records while `unreviewed` selects records without a review. Record a
|
|
261
|
+
new append-only review revision by passing a record ID, a required
|
|
262
|
+
`--decision=<allow|ask|deny>`, and an optional `--note`. Add `--json` to either
|
|
263
|
+
operation to emit one versioned JSON document; JSON failures are written to
|
|
264
|
+
stdout with a nonzero exit code. Interactive `demur training review` still opens
|
|
265
|
+
the TUI, but without a terminal it requires an explicit list or record-ID review
|
|
266
|
+
operation. Reviews remain separate from the original evidence so they can later
|
|
267
|
+
be curated into independently licensed eval fixtures.
|
|
248
268
|
|
|
249
269
|
From a development checkout, `bun run judge "<command>"` remains available.
|
|
250
270
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyattjoh/demur",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A proof-of-concept destructive-command guard for coding agents.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"src/policy.ts",
|
|
39
39
|
"src/questions.ts",
|
|
40
40
|
"src/state.ts",
|
|
41
|
+
"src/settings-model.ts",
|
|
41
42
|
"src/training-review-model.ts",
|
|
42
43
|
"src/training-review-tui.tsx",
|
|
43
44
|
"src/types.ts"
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
|
53
54
|
"check": "tsc --noEmit",
|
|
54
|
-
"test": "vitest run",
|
|
55
|
+
"test": "vitest run && bun test ./src/training-review-tui.bun.tsx",
|
|
55
56
|
"build:pi": "bun build extensions/demur/index.ts --target=node --outfile=dist/demur-guard.js --format=esm --external @earendil-works/pi-coding-agent && bun build src/adapters/pi-worker.ts --target=bun --outfile=dist/demur-pi-worker.js --format=esm",
|
|
56
57
|
"build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
|
|
57
58
|
"build": "bun run build:pi && bun run build:claude",
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { createInterface } from "node:readline/promises";
|
|
3
2
|
import { Predicate } from "effect";
|
|
4
3
|
import { loadCostTotals } from "../extensions/demur/cost-tracker.ts";
|
|
4
|
+
import {
|
|
5
|
+
loadDemurSettings,
|
|
6
|
+
saveDemurSettings,
|
|
7
|
+
type DemurSettings,
|
|
8
|
+
} from "../extensions/demur/settings.ts";
|
|
5
9
|
import {
|
|
6
10
|
loadTrainingRecords,
|
|
7
11
|
loadTrainingReviews,
|
|
@@ -19,7 +23,12 @@ import {
|
|
|
19
23
|
} from "./key.ts";
|
|
20
24
|
import {
|
|
21
25
|
buildTrainingReviewEntries,
|
|
26
|
+
createTrainingReviewInput,
|
|
27
|
+
filterTrainingReviewEntries,
|
|
28
|
+
getLatestTrainingReview,
|
|
22
29
|
getTrainingReviewFilter,
|
|
30
|
+
type TrainingReviewEntry,
|
|
31
|
+
type TrainingReviewFilter,
|
|
23
32
|
type TrainingReviewSnapshot,
|
|
24
33
|
} from "./training-review-model.ts";
|
|
25
34
|
import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
|
|
@@ -30,7 +39,9 @@ const USAGE = `Usage:
|
|
|
30
39
|
demur auth login
|
|
31
40
|
demur auth status
|
|
32
41
|
demur auth logout
|
|
33
|
-
demur training
|
|
42
|
+
demur training list [--status=<all|unreviewed|allow|ask|deny>] [--cwd=<query>] [--json]
|
|
43
|
+
demur training review
|
|
44
|
+
demur training review <record-id> --decision=<allow|ask|deny> [--note=<text>] [--json]
|
|
34
45
|
demur judge "<command>" [--cwd=<path>]`;
|
|
35
46
|
|
|
36
47
|
/**
|
|
@@ -45,26 +56,22 @@ export type CliDependencies = {
|
|
|
45
56
|
loadTrainingReviews(): Promise<ReadonlyArray<TrainingReview>>;
|
|
46
57
|
loadGlobalEstimatedCostUsd(): Promise<number>;
|
|
47
58
|
recordTrainingReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
59
|
+
loadDemurSettings(): Promise<DemurSettings>;
|
|
60
|
+
saveDemurSettings(settings: DemurSettings): Promise<void>;
|
|
48
61
|
runTrainingReviewTui(
|
|
49
62
|
snapshot: TrainingReviewSnapshot,
|
|
63
|
+
settings: DemurSettings,
|
|
50
64
|
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
51
65
|
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
66
|
+
saveSettings: (settings: DemurSettings) => Promise<void>,
|
|
52
67
|
): Promise<TrainingReviewTuiResult>;
|
|
53
68
|
isInteractive(): boolean;
|
|
54
69
|
readSecret(prompt: string): Promise<string>;
|
|
55
|
-
readLine(prompt: string): Promise<string>;
|
|
56
70
|
cwd(): string;
|
|
57
71
|
stdout(message: string): void;
|
|
58
72
|
stderr(message: string): void;
|
|
59
73
|
};
|
|
60
74
|
|
|
61
|
-
let lineInput:
|
|
62
|
-
| {
|
|
63
|
-
terminal: ReturnType<typeof createInterface>;
|
|
64
|
-
lines: AsyncIterator<string>;
|
|
65
|
-
}
|
|
66
|
-
| undefined;
|
|
67
|
-
|
|
68
75
|
const defaultDependencies: CliDependencies = {
|
|
69
76
|
judge: (command, cwd) => guard(command, cwd, "cli"),
|
|
70
77
|
resolveApiKey,
|
|
@@ -75,15 +82,28 @@ const defaultDependencies: CliDependencies = {
|
|
|
75
82
|
loadGlobalEstimatedCostUsd: async () =>
|
|
76
83
|
(await loadCostTotals()).estimatedCostUsd,
|
|
77
84
|
recordTrainingReview,
|
|
78
|
-
|
|
85
|
+
loadDemurSettings,
|
|
86
|
+
saveDemurSettings,
|
|
87
|
+
runTrainingReviewTui: async (
|
|
88
|
+
snapshot,
|
|
89
|
+
settings,
|
|
90
|
+
reloadSnapshot,
|
|
91
|
+
recordReview,
|
|
92
|
+
saveSettings,
|
|
93
|
+
) => {
|
|
79
94
|
const { runTrainingReviewTui } = await import(
|
|
80
95
|
"./training-review-tui.tsx"
|
|
81
96
|
);
|
|
82
|
-
return runTrainingReviewTui(
|
|
97
|
+
return runTrainingReviewTui(
|
|
98
|
+
snapshot,
|
|
99
|
+
settings,
|
|
100
|
+
reloadSnapshot,
|
|
101
|
+
recordReview,
|
|
102
|
+
saveSettings,
|
|
103
|
+
);
|
|
83
104
|
},
|
|
84
105
|
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
85
106
|
readSecret,
|
|
86
|
-
readLine,
|
|
87
107
|
cwd: () => process.cwd(),
|
|
88
108
|
stdout: (message) => console.log(message),
|
|
89
109
|
stderr: (message) => console.error(message),
|
|
@@ -104,7 +124,7 @@ export async function runCli(
|
|
|
104
124
|
if (args.length === 0) {
|
|
105
125
|
if (!dependencies.isInteractive()) {
|
|
106
126
|
dependencies.stderr(
|
|
107
|
-
"demur: the interactive app requires a terminal; use `demur training
|
|
127
|
+
"demur: the interactive app requires a terminal; use `demur training list --json` or review a record by ID.",
|
|
108
128
|
);
|
|
109
129
|
return 2;
|
|
110
130
|
}
|
|
@@ -127,10 +147,17 @@ export async function runCli(
|
|
|
127
147
|
const judgeArgs = args[0] === "judge" ? args.slice(1) : args;
|
|
128
148
|
return await runJudge(judgeArgs, dependencies);
|
|
129
149
|
} catch (error: unknown) {
|
|
130
|
-
|
|
150
|
+
if (isTrainingJsonRequest(args)) {
|
|
151
|
+
writeTrainingJsonError(
|
|
152
|
+
trainingOperation(args),
|
|
153
|
+
"unexpected_error",
|
|
154
|
+
errorDetail(error),
|
|
155
|
+
dependencies,
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
dependencies.stderr(`demur: ${errorDetail(error)}`);
|
|
159
|
+
}
|
|
131
160
|
return 1;
|
|
132
|
-
} finally {
|
|
133
|
-
closeLineInput();
|
|
134
161
|
}
|
|
135
162
|
}
|
|
136
163
|
|
|
@@ -201,43 +228,224 @@ async function runAuth(
|
|
|
201
228
|
return 2;
|
|
202
229
|
}
|
|
203
230
|
|
|
231
|
+
type ParsedArguments = {
|
|
232
|
+
positionals: ReadonlyArray<string>;
|
|
233
|
+
options: ReadonlyMap<string, string | true>;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
type ParseArgumentsResult =
|
|
237
|
+
| { parsed: ParsedArguments; error: undefined }
|
|
238
|
+
| { parsed: undefined; error: string };
|
|
239
|
+
|
|
204
240
|
async function runTraining(
|
|
205
241
|
args: ReadonlyArray<string>,
|
|
206
242
|
dependencies: CliDependencies,
|
|
207
243
|
): Promise<number> {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
244
|
+
const command = args[0];
|
|
245
|
+
if (command === "list") {
|
|
246
|
+
return runTrainingList(args.slice(1), dependencies);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (command === "review") {
|
|
250
|
+
if (args.length === 1) {
|
|
251
|
+
return runInteractiveTrainingReview(dependencies);
|
|
252
|
+
}
|
|
253
|
+
return runTrainingReview(args.slice(1), dependencies);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return writeTrainingUsageError(
|
|
257
|
+
trainingOperation(["training", ...args]),
|
|
258
|
+
args.includes("--json"),
|
|
259
|
+
"expected `training list` or `training review`",
|
|
260
|
+
dependencies,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runInteractiveTrainingReview(
|
|
265
|
+
dependencies: CliDependencies,
|
|
266
|
+
): Promise<number> {
|
|
267
|
+
if (!dependencies.isInteractive()) {
|
|
268
|
+
dependencies.stderr(
|
|
269
|
+
"demur: `training review` requires a terminal; use `training list --json` and review a record by ID.",
|
|
270
|
+
);
|
|
216
271
|
return 2;
|
|
217
272
|
}
|
|
218
273
|
|
|
219
274
|
const loadSnapshot = () => loadTrainingReviewSnapshot(dependencies);
|
|
220
275
|
const snapshot = await loadSnapshot();
|
|
276
|
+
const settings = await dependencies.loadDemurSettings();
|
|
277
|
+
const result = await dependencies.runTrainingReviewTui(
|
|
278
|
+
snapshot,
|
|
279
|
+
settings,
|
|
280
|
+
loadSnapshot,
|
|
281
|
+
dependencies.recordTrainingReview,
|
|
282
|
+
dependencies.saveDemurSettings,
|
|
283
|
+
);
|
|
284
|
+
printReviewSummary(result, dependencies);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
221
287
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
288
|
+
async function runTrainingList(
|
|
289
|
+
args: ReadonlyArray<string>,
|
|
290
|
+
dependencies: CliDependencies,
|
|
291
|
+
): Promise<number> {
|
|
292
|
+
const operation = "training.list";
|
|
293
|
+
const parsedResult = parseArguments(
|
|
294
|
+
args,
|
|
295
|
+
new Set(["json"]),
|
|
296
|
+
new Set(["status", "cwd"]),
|
|
297
|
+
);
|
|
298
|
+
const json = args.includes("--json");
|
|
299
|
+
if (parsedResult.error !== undefined) {
|
|
300
|
+
return writeTrainingUsageError(
|
|
301
|
+
operation,
|
|
302
|
+
json,
|
|
303
|
+
parsedResult.error,
|
|
304
|
+
dependencies,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const parsed = parsedResult.parsed;
|
|
309
|
+
if (parsed.positionals.length !== 0) {
|
|
310
|
+
return writeTrainingUsageError(
|
|
311
|
+
operation,
|
|
312
|
+
hasOption(parsed, "json"),
|
|
313
|
+
"`training list` does not accept positional arguments",
|
|
314
|
+
dependencies,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const statusValue = getStringOption(parsed, "status") ?? "all";
|
|
319
|
+
if (!isTrainingReviewFilter(statusValue)) {
|
|
320
|
+
return writeTrainingUsageError(
|
|
321
|
+
operation,
|
|
322
|
+
hasOption(parsed, "json"),
|
|
323
|
+
`unsupported status: ${statusValue}`,
|
|
324
|
+
dependencies,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const cwdQuery = getStringOption(parsed, "cwd") ?? "";
|
|
329
|
+
const entries = filterTrainingReviewEntries(
|
|
330
|
+
await loadTrainingEntries(dependencies),
|
|
331
|
+
statusValue,
|
|
332
|
+
cwdQuery,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
if (hasOption(parsed, "json")) {
|
|
336
|
+
writeTrainingJsonSuccess(
|
|
337
|
+
operation,
|
|
338
|
+
{
|
|
339
|
+
filter: {
|
|
340
|
+
status: statusValue,
|
|
341
|
+
cwd: cwdQuery === "" ? null : cwdQuery,
|
|
342
|
+
},
|
|
343
|
+
records: entries.map(trainingListRecord),
|
|
344
|
+
},
|
|
345
|
+
dependencies,
|
|
227
346
|
);
|
|
228
|
-
printReviewSummary(result, dependencies);
|
|
229
347
|
return 0;
|
|
230
348
|
}
|
|
231
349
|
|
|
232
|
-
|
|
233
|
-
.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
350
|
+
if (entries.length === 0) {
|
|
351
|
+
dependencies.stdout("No matching demur training records.");
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
dependencies.stdout(
|
|
356
|
+
`${entries.length} matching training record${entries.length === 1 ? "" : "s"}:`,
|
|
357
|
+
);
|
|
358
|
+
for (const entry of entries) {
|
|
359
|
+
const record = entry.record;
|
|
360
|
+
dependencies.stdout(
|
|
361
|
+
[
|
|
362
|
+
record.id,
|
|
363
|
+
getTrainingReviewFilter(entry),
|
|
364
|
+
`model=${record.verdict.decision}`,
|
|
365
|
+
record.cwd,
|
|
366
|
+
summarizeTrainingCommand(record.command),
|
|
367
|
+
].join("\t"),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function runTrainingReview(
|
|
374
|
+
args: ReadonlyArray<string>,
|
|
375
|
+
dependencies: CliDependencies,
|
|
376
|
+
): Promise<number> {
|
|
377
|
+
const operation = "training.review";
|
|
378
|
+
const parsedResult = parseArguments(
|
|
379
|
+
args,
|
|
380
|
+
new Set(["json"]),
|
|
381
|
+
new Set(["decision", "note"]),
|
|
382
|
+
);
|
|
383
|
+
const json = args.includes("--json");
|
|
384
|
+
if (parsedResult.error !== undefined) {
|
|
385
|
+
return writeTrainingUsageError(
|
|
386
|
+
operation,
|
|
387
|
+
json,
|
|
388
|
+
parsedResult.error,
|
|
389
|
+
dependencies,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const parsed = parsedResult.parsed;
|
|
394
|
+
if (parsed.positionals.length !== 1) {
|
|
395
|
+
return writeTrainingUsageError(
|
|
396
|
+
operation,
|
|
397
|
+
hasOption(parsed, "json"),
|
|
398
|
+
"`training review` requires exactly one record ID",
|
|
399
|
+
dependencies,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const decisionValue = getStringOption(parsed, "decision");
|
|
404
|
+
if (decisionValue === undefined || !isDecision(decisionValue)) {
|
|
405
|
+
return writeTrainingUsageError(
|
|
406
|
+
operation,
|
|
407
|
+
hasOption(parsed, "json"),
|
|
408
|
+
"`--decision` must be allow, ask, or deny",
|
|
409
|
+
dependencies,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const recordId = parsed.positionals[0];
|
|
414
|
+
const entries = await loadTrainingEntries(dependencies);
|
|
415
|
+
const entry = entries.find((candidate) => candidate.record.id === recordId);
|
|
416
|
+
if (entry === undefined) {
|
|
417
|
+
return writeTrainingDomainError(
|
|
418
|
+
operation,
|
|
419
|
+
hasOption(parsed, "json"),
|
|
420
|
+
"record_not_found",
|
|
421
|
+
`training record not found: ${recordId}`,
|
|
422
|
+
dependencies,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const previousReview = getLatestTrainingReview(entry) ?? null;
|
|
427
|
+
const review = await dependencies.recordTrainingReview(
|
|
428
|
+
createTrainingReviewInput(
|
|
429
|
+
entry.record,
|
|
430
|
+
decisionValue,
|
|
431
|
+
getStringOption(parsed, "note"),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
const corrected = decisionValue !== entry.record.verdict.decision;
|
|
435
|
+
|
|
436
|
+
if (hasOption(parsed, "json")) {
|
|
437
|
+
writeTrainingJsonSuccess(
|
|
438
|
+
operation,
|
|
439
|
+
{ review, corrected, previousReview },
|
|
440
|
+
dependencies,
|
|
441
|
+
);
|
|
237
442
|
return 0;
|
|
238
443
|
}
|
|
239
444
|
|
|
240
|
-
|
|
445
|
+
dependencies.stdout(
|
|
446
|
+
`Recorded ${decisionValue.toUpperCase()} review for ${recordId}${corrected ? ` (model: ${entry.record.verdict.decision.toUpperCase()})` : " (accepted model decision)"}.`,
|
|
447
|
+
);
|
|
448
|
+
return 0;
|
|
241
449
|
}
|
|
242
450
|
|
|
243
451
|
async function loadTrainingReviewSnapshot(
|
|
@@ -251,44 +459,18 @@ async function loadTrainingReviewSnapshot(
|
|
|
251
459
|
return { records, reviews, globalEstimatedCostUsd };
|
|
252
460
|
}
|
|
253
461
|
|
|
254
|
-
async function
|
|
255
|
-
pending: ReadonlyArray<TrainingRecord>,
|
|
462
|
+
async function loadTrainingEntries(
|
|
256
463
|
dependencies: CliDependencies,
|
|
257
|
-
): Promise<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const isCorrection = expectedDecision !== record.verdict.decision;
|
|
271
|
-
const note = isCorrection
|
|
272
|
-
? (await dependencies.readLine("Correction note (optional): ")).trim() ||
|
|
273
|
-
undefined
|
|
274
|
-
: undefined;
|
|
275
|
-
await dependencies.recordTrainingReview({
|
|
276
|
-
recordId: record.id,
|
|
277
|
-
originalDecision: record.verdict.decision,
|
|
278
|
-
expectedDecision,
|
|
279
|
-
note,
|
|
280
|
-
});
|
|
281
|
-
reviewed += 1;
|
|
282
|
-
if (isCorrection) corrected += 1;
|
|
283
|
-
dependencies.stdout(
|
|
284
|
-
isCorrection
|
|
285
|
-
? `Recorded correction: ${record.verdict.decision} → ${expectedDecision}.`
|
|
286
|
-
: `Accepted ${record.verdict.decision} decision.`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
printReviewSummary({ reviewed, corrected, skipped }, dependencies);
|
|
291
|
-
return 0;
|
|
464
|
+
): Promise<ReadonlyArray<TrainingReviewEntry>> {
|
|
465
|
+
const [records, reviews] = await Promise.all([
|
|
466
|
+
dependencies.loadTrainingRecords(),
|
|
467
|
+
dependencies.loadTrainingReviews(),
|
|
468
|
+
]);
|
|
469
|
+
return buildTrainingReviewEntries({
|
|
470
|
+
records,
|
|
471
|
+
reviews,
|
|
472
|
+
globalEstimatedCostUsd: 0,
|
|
473
|
+
});
|
|
292
474
|
}
|
|
293
475
|
|
|
294
476
|
function printReviewSummary(
|
|
@@ -303,54 +485,162 @@ function printReviewSummary(
|
|
|
303
485
|
);
|
|
304
486
|
}
|
|
305
487
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
input === "ask" ||
|
|
321
|
-
input === "deny" ||
|
|
322
|
-
input === "skip" ||
|
|
323
|
-
input === "quit"
|
|
324
|
-
) {
|
|
325
|
-
return input;
|
|
488
|
+
function parseArguments(
|
|
489
|
+
args: ReadonlyArray<string>,
|
|
490
|
+
booleanOptions: ReadonlySet<string>,
|
|
491
|
+
valueOptions: ReadonlySet<string>,
|
|
492
|
+
): ParseArgumentsResult {
|
|
493
|
+
const positionals: Array<string> = [];
|
|
494
|
+
const options = new Map<string, string | true>();
|
|
495
|
+
|
|
496
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
497
|
+
const argument = args[index];
|
|
498
|
+
if (argument === undefined) continue;
|
|
499
|
+
if (!argument.startsWith("--")) {
|
|
500
|
+
positionals.push(argument);
|
|
501
|
+
continue;
|
|
326
502
|
}
|
|
327
|
-
|
|
503
|
+
|
|
504
|
+
const separator = argument.indexOf("=");
|
|
505
|
+
const name = argument.slice(2, separator < 0 ? undefined : separator);
|
|
506
|
+
const inlineValue = separator < 0 ? undefined : argument.slice(separator + 1);
|
|
507
|
+
if (options.has(name)) {
|
|
508
|
+
return { parsed: undefined, error: `duplicate option: --${name}` };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (booleanOptions.has(name)) {
|
|
512
|
+
if (inlineValue !== undefined) {
|
|
513
|
+
return {
|
|
514
|
+
parsed: undefined,
|
|
515
|
+
error: `option --${name} does not accept a value`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
options.set(name, true);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!valueOptions.has(name)) {
|
|
523
|
+
return { parsed: undefined, error: `unknown option: --${name}` };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const followingValue = args[index + 1];
|
|
527
|
+
const value = inlineValue ??
|
|
528
|
+
(followingValue !== undefined && !followingValue.startsWith("--")
|
|
529
|
+
? followingValue
|
|
530
|
+
: undefined);
|
|
531
|
+
if (value === undefined) {
|
|
532
|
+
return {
|
|
533
|
+
parsed: undefined,
|
|
534
|
+
error: `option --${name} requires a value`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (inlineValue === undefined) index += 1;
|
|
538
|
+
options.set(name, value);
|
|
328
539
|
}
|
|
540
|
+
|
|
541
|
+
return { parsed: { positionals, options }, error: undefined };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function getStringOption(
|
|
545
|
+
parsed: ParsedArguments,
|
|
546
|
+
name: string,
|
|
547
|
+
): string | undefined {
|
|
548
|
+
const value = parsed.options.get(name);
|
|
549
|
+
return typeof value === "string" ? value : undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function hasOption(parsed: ParsedArguments, name: string): boolean {
|
|
553
|
+
return parsed.options.has(name);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isTrainingReviewFilter(value: string): value is TrainingReviewFilter {
|
|
557
|
+
return value === "all" ||
|
|
558
|
+
value === "unreviewed" ||
|
|
559
|
+
value === "allow" ||
|
|
560
|
+
value === "ask" ||
|
|
561
|
+
value === "deny";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isDecision(value: string): value is "allow" | "ask" | "deny" {
|
|
565
|
+
return value === "allow" || value === "ask" || value === "deny";
|
|
329
566
|
}
|
|
330
567
|
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
568
|
+
function trainingListRecord(entry: TrainingReviewEntry): object {
|
|
569
|
+
return {
|
|
570
|
+
status: getTrainingReviewFilter(entry),
|
|
571
|
+
record: entry.record,
|
|
572
|
+
reviews: entry.reviews,
|
|
573
|
+
latestReview: getLatestTrainingReview(entry) ?? null,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function summarizeTrainingCommand(command: string): string {
|
|
578
|
+
return command.replaceAll(/\s+/g, " ").trim();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function writeTrainingUsageError(
|
|
582
|
+
operation: string,
|
|
583
|
+
json: boolean,
|
|
584
|
+
message: string,
|
|
335
585
|
dependencies: CliDependencies,
|
|
336
|
-
):
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
dependencies.stdout(
|
|
344
|
-
`verdict: ${record.verdict.decision.toUpperCase()} — ${record.verdict.reason}`,
|
|
345
|
-
);
|
|
346
|
-
if (record.verdict.judgments !== undefined) {
|
|
347
|
-
dependencies.stdout(
|
|
348
|
-
`judgments: ${JSON.stringify(record.verdict.judgments)}`,
|
|
586
|
+
): number {
|
|
587
|
+
if (json) {
|
|
588
|
+
writeTrainingJsonError(
|
|
589
|
+
operation,
|
|
590
|
+
"invalid_arguments",
|
|
591
|
+
message,
|
|
592
|
+
dependencies,
|
|
349
593
|
);
|
|
594
|
+
} else {
|
|
595
|
+
dependencies.stderr(`demur: ${message}`);
|
|
596
|
+
dependencies.stderr(USAGE);
|
|
350
597
|
}
|
|
351
|
-
|
|
352
|
-
|
|
598
|
+
return 2;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function writeTrainingDomainError(
|
|
602
|
+
operation: string,
|
|
603
|
+
json: boolean,
|
|
604
|
+
code: string,
|
|
605
|
+
message: string,
|
|
606
|
+
dependencies: CliDependencies,
|
|
607
|
+
): number {
|
|
608
|
+
if (json) {
|
|
609
|
+
writeTrainingJsonError(operation, code, message, dependencies);
|
|
610
|
+
} else {
|
|
611
|
+
dependencies.stderr(`demur: ${message}`);
|
|
353
612
|
}
|
|
613
|
+
return 1;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function writeTrainingJsonSuccess(
|
|
617
|
+
operation: string,
|
|
618
|
+
result: unknown,
|
|
619
|
+
dependencies: CliDependencies,
|
|
620
|
+
): void {
|
|
621
|
+
dependencies.stdout(JSON.stringify({ version: 1, ok: true, operation, result }));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function writeTrainingJsonError(
|
|
625
|
+
operation: string,
|
|
626
|
+
code: string,
|
|
627
|
+
message: string,
|
|
628
|
+
dependencies: CliDependencies,
|
|
629
|
+
): void {
|
|
630
|
+
dependencies.stdout(JSON.stringify({
|
|
631
|
+
version: 1,
|
|
632
|
+
ok: false,
|
|
633
|
+
operation,
|
|
634
|
+
error: { code, message },
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isTrainingJsonRequest(args: ReadonlyArray<string>): boolean {
|
|
639
|
+
return args[0] === "training" && args.includes("--json");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function trainingOperation(args: ReadonlyArray<string>): string {
|
|
643
|
+
return `training.${args[1] ?? "unknown"}`;
|
|
354
644
|
}
|
|
355
645
|
|
|
356
646
|
async function runJudge(
|
|
@@ -403,26 +693,6 @@ async function runJudge(
|
|
|
403
693
|
return 0;
|
|
404
694
|
}
|
|
405
695
|
|
|
406
|
-
async function readLine(prompt: string): Promise<string> {
|
|
407
|
-
if (lineInput === undefined) {
|
|
408
|
-
const terminal = createInterface({ input: process.stdin });
|
|
409
|
-
lineInput = {
|
|
410
|
-
terminal,
|
|
411
|
-
lines: terminal[Symbol.asyncIterator](),
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
process.stderr.write(prompt);
|
|
416
|
-
const next = await lineInput.lines.next();
|
|
417
|
-
if (next.done) throw new Error("training review input ended");
|
|
418
|
-
return next.value;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function closeLineInput(): void {
|
|
422
|
-
lineInput?.terminal.close();
|
|
423
|
-
lineInput = undefined;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
696
|
async function readSecret(prompt: string): Promise<string> {
|
|
427
697
|
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
|
|
428
698
|
return (await Bun.stdin.text()).trim();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEMUR_MODES,
|
|
3
|
+
FAILURE_POLICIES,
|
|
4
|
+
type DemurSettings,
|
|
5
|
+
} from "../extensions/demur/settings.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Settings exposed by both the Pi extension menu and the central TUI.
|
|
9
|
+
*/
|
|
10
|
+
export type DemurSettingKey = "mode" | "training" | "failurePolicy";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Move one persisted demur setting to its next or previous supported value.
|
|
14
|
+
*
|
|
15
|
+
* Selecting disabled mode always turns training capture off. Training cannot be
|
|
16
|
+
* changed while disabled, matching the persisted configuration invariant.
|
|
17
|
+
*
|
|
18
|
+
* @param settings - Current persisted settings
|
|
19
|
+
* @param key - Setting to change
|
|
20
|
+
* @param direction - Positive for the next value, negative for the previous
|
|
21
|
+
* @returns The updated settings, or the original object when no change is valid
|
|
22
|
+
*/
|
|
23
|
+
export function changeDemurSetting(
|
|
24
|
+
settings: DemurSettings,
|
|
25
|
+
key: DemurSettingKey,
|
|
26
|
+
direction: number,
|
|
27
|
+
): DemurSettings {
|
|
28
|
+
if (key === "training") {
|
|
29
|
+
if (settings.mode === "disabled") return settings;
|
|
30
|
+
return { ...settings, training: !settings.training };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (key === "mode") {
|
|
34
|
+
const mode = cycleValue(DEMUR_MODES, settings.mode, direction);
|
|
35
|
+
return {
|
|
36
|
+
...settings,
|
|
37
|
+
mode,
|
|
38
|
+
training: mode === "disabled" ? false : settings.training,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...settings,
|
|
44
|
+
failurePolicy: cycleValue(
|
|
45
|
+
FAILURE_POLICIES,
|
|
46
|
+
settings.failurePolicy,
|
|
47
|
+
direction,
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cycleValue<Value>(
|
|
53
|
+
values: ReadonlyArray<Value>,
|
|
54
|
+
current: Value,
|
|
55
|
+
direction: number,
|
|
56
|
+
): Value {
|
|
57
|
+
const currentIndex = values.indexOf(current);
|
|
58
|
+
const offset = direction < 0 ? values.length - 1 : 1;
|
|
59
|
+
return values[(currentIndex + offset) % values.length] ?? current;
|
|
60
|
+
}
|
|
@@ -119,7 +119,7 @@ export function filterTrainingReviewEntries(
|
|
|
119
119
|
*
|
|
120
120
|
* @param record - Training evidence being reviewed
|
|
121
121
|
* @param expectedDecision - Human-selected expected outcome
|
|
122
|
-
* @param note - Optional
|
|
122
|
+
* @param note - Optional explanation for the human decision
|
|
123
123
|
* @returns Normalized review input for persistence
|
|
124
124
|
*/
|
|
125
125
|
export function createTrainingReviewInput(
|
|
@@ -127,12 +127,11 @@ export function createTrainingReviewInput(
|
|
|
127
127
|
expectedDecision: Decision,
|
|
128
128
|
note: string | undefined,
|
|
129
129
|
): TrainingReviewInput {
|
|
130
|
-
const corrected = expectedDecision !== record.verdict.decision;
|
|
131
130
|
return {
|
|
132
131
|
recordId: record.id,
|
|
133
132
|
originalDecision: record.verdict.decision,
|
|
134
133
|
expectedDecision,
|
|
135
|
-
note:
|
|
134
|
+
note: note?.trim() || undefined,
|
|
136
135
|
};
|
|
137
136
|
}
|
|
138
137
|
|
|
@@ -12,10 +12,15 @@ import {
|
|
|
12
12
|
estimateInputCostUsd,
|
|
13
13
|
formatUsd,
|
|
14
14
|
} from "../extensions/demur/cost-tracker.ts";
|
|
15
|
+
import type { DemurSettings } from "../extensions/demur/settings.ts";
|
|
15
16
|
import type {
|
|
16
17
|
TrainingReview,
|
|
17
18
|
TrainingReviewInput,
|
|
18
19
|
} from "../extensions/demur/training-store.ts";
|
|
20
|
+
import {
|
|
21
|
+
changeDemurSetting,
|
|
22
|
+
type DemurSettingKey,
|
|
23
|
+
} from "./settings-model.ts";
|
|
19
24
|
import {
|
|
20
25
|
buildTrainingReviewEntries,
|
|
21
26
|
createTrainingReviewInput,
|
|
@@ -38,8 +43,11 @@ const COLORS = {
|
|
|
38
43
|
text: "#eceff4",
|
|
39
44
|
muted: "#8f98a8",
|
|
40
45
|
allow: "#a3be8c",
|
|
46
|
+
allowMuted: "#78906a",
|
|
41
47
|
ask: "#ebcb8b",
|
|
48
|
+
askMuted: "#a28f68",
|
|
42
49
|
deny: "#bf616a",
|
|
50
|
+
denyMuted: "#87515a",
|
|
43
51
|
selection: "#2e3440",
|
|
44
52
|
error: "#ff6b7a",
|
|
45
53
|
} as const;
|
|
@@ -55,6 +63,33 @@ const FILTERS: ReadonlyArray<{
|
|
|
55
63
|
{ value: "deny", label: "deny" },
|
|
56
64
|
];
|
|
57
65
|
|
|
66
|
+
const SECTIONS: ReadonlyArray<{ value: AppSection; label: string }> = [
|
|
67
|
+
{ value: "reviews", label: "Reviews" },
|
|
68
|
+
{ value: "settings", label: "Settings" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const SETTING_ROWS: ReadonlyArray<{
|
|
72
|
+
key: DemurSettingKey;
|
|
73
|
+
label: string;
|
|
74
|
+
description: string;
|
|
75
|
+
}> = [
|
|
76
|
+
{
|
|
77
|
+
key: "mode",
|
|
78
|
+
label: "Operating mode",
|
|
79
|
+
description: "Enforce decisions, observe passively, or bypass the guard.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "training",
|
|
83
|
+
label: "Training capture",
|
|
84
|
+
description: "Append full evaluations for later human review.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: "failurePolicy",
|
|
88
|
+
label: "Failure policy",
|
|
89
|
+
description: "Action when no trustworthy judgment is available.",
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
58
93
|
/**
|
|
59
94
|
* Summary returned after an interactive training-review session.
|
|
60
95
|
*/
|
|
@@ -66,8 +101,10 @@ export type TrainingReviewTuiResult = {
|
|
|
66
101
|
|
|
67
102
|
type TrainingReviewAppProps = {
|
|
68
103
|
snapshot: TrainingReviewSnapshot;
|
|
104
|
+
settings: DemurSettings;
|
|
69
105
|
reloadSnapshot(): Promise<TrainingReviewSnapshot>;
|
|
70
106
|
recordReview(input: TrainingReviewInput): Promise<TrainingReview>;
|
|
107
|
+
saveSettings(settings: DemurSettings): Promise<void>;
|
|
71
108
|
pollIntervalMs: number | undefined;
|
|
72
109
|
onExit(result: TrainingReviewTuiResult): void;
|
|
73
110
|
};
|
|
@@ -77,7 +114,15 @@ type NoteEditorState = {
|
|
|
77
114
|
expectedDecision: Decision;
|
|
78
115
|
};
|
|
79
116
|
|
|
80
|
-
type
|
|
117
|
+
type AppSection = "reviews" | "settings";
|
|
118
|
+
|
|
119
|
+
type FocusTarget =
|
|
120
|
+
| "sections"
|
|
121
|
+
| "tabs"
|
|
122
|
+
| "filter"
|
|
123
|
+
| "queue"
|
|
124
|
+
| "detail"
|
|
125
|
+
| "settings";
|
|
81
126
|
|
|
82
127
|
/**
|
|
83
128
|
* Render the interactive historical training-review queue.
|
|
@@ -93,6 +138,11 @@ export function TrainingReviewApp(
|
|
|
93
138
|
const [snapshot, setSnapshot] = useState<TrainingReviewSnapshot>(
|
|
94
139
|
props.snapshot,
|
|
95
140
|
);
|
|
141
|
+
const [section, setSection] = useState<AppSection>("reviews");
|
|
142
|
+
const [settings, setSettings] = useState<DemurSettings>(props.settings);
|
|
143
|
+
const [selectedSettingIndex, setSelectedSettingIndex] = useState(0);
|
|
144
|
+
const [savingSettings, setSavingSettings] = useState(false);
|
|
145
|
+
const [settingsError, setSettingsError] = useState<string | undefined>();
|
|
96
146
|
const [activeFilter, setActiveFilter] = useState<TrainingReviewFilter>(
|
|
97
147
|
"all",
|
|
98
148
|
);
|
|
@@ -147,7 +197,7 @@ export function TrainingReviewApp(
|
|
|
147
197
|
: Math.max(7, Math.min(11, Math.floor(height * 0.32)));
|
|
148
198
|
const queuePageSize = Math.max(
|
|
149
199
|
1,
|
|
150
|
-
Math.floor((horizontal ? height -
|
|
200
|
+
Math.floor((horizontal ? height - 16 : queueSize - 2) / 2),
|
|
151
201
|
);
|
|
152
202
|
useEffect(() => {
|
|
153
203
|
const timer = setTimeout(() => setReady(true), 250);
|
|
@@ -204,6 +254,26 @@ export function TrainingReviewApp(
|
|
|
204
254
|
props.onExit(nextResult);
|
|
205
255
|
};
|
|
206
256
|
|
|
257
|
+
const persistSetting = async (
|
|
258
|
+
key: DemurSettingKey,
|
|
259
|
+
direction: number,
|
|
260
|
+
) => {
|
|
261
|
+
if (savingSettings) return;
|
|
262
|
+
const nextSettings = changeDemurSetting(settings, key, direction);
|
|
263
|
+
if (nextSettings === settings) return;
|
|
264
|
+
|
|
265
|
+
setSavingSettings(true);
|
|
266
|
+
setSettingsError(undefined);
|
|
267
|
+
try {
|
|
268
|
+
await props.saveSettings(nextSettings);
|
|
269
|
+
setSettings(nextSettings);
|
|
270
|
+
} catch (cause: unknown) {
|
|
271
|
+
setSettingsError(errorDetail(cause));
|
|
272
|
+
} finally {
|
|
273
|
+
setSavingSettings(false);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
207
277
|
const persistDecision = async (
|
|
208
278
|
entry: TrainingReviewEntry,
|
|
209
279
|
expectedDecision: Decision,
|
|
@@ -323,6 +393,73 @@ export function TrainingReviewApp(
|
|
|
323
393
|
return;
|
|
324
394
|
}
|
|
325
395
|
|
|
396
|
+
const sectionKey = key.sequence || key.name;
|
|
397
|
+
if (focus !== "filter" && (sectionKey === "[" || sectionKey === "]")) {
|
|
398
|
+
key.preventDefault();
|
|
399
|
+
const nextSection = sectionKey === "[" ? "reviews" : "settings";
|
|
400
|
+
setSection(nextSection);
|
|
401
|
+
setFocus(nextSection === "reviews" ? "tabs" : "settings");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (focus === "sections") {
|
|
406
|
+
if (key.name === "left" || key.name === "right") {
|
|
407
|
+
key.preventDefault();
|
|
408
|
+
setSection((current) =>
|
|
409
|
+
current === "reviews" ? "settings" : "reviews"
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (key.name === "down" || key.name === "return") {
|
|
414
|
+
key.preventDefault();
|
|
415
|
+
setFocus(section === "reviews" ? "tabs" : "settings");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (key.name === "q" || key.name === "escape") {
|
|
419
|
+
key.preventDefault();
|
|
420
|
+
finish(result);
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (section === "settings") {
|
|
426
|
+
if (key.name === "q" || key.name === "escape") {
|
|
427
|
+
key.preventDefault();
|
|
428
|
+
finish(result);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (key.name === "up") {
|
|
432
|
+
key.preventDefault();
|
|
433
|
+
if (selectedSettingIndex === 0) {
|
|
434
|
+
setFocus("sections");
|
|
435
|
+
} else {
|
|
436
|
+
setSelectedSettingIndex((current) => current - 1);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (key.name === "down") {
|
|
441
|
+
key.preventDefault();
|
|
442
|
+
setSelectedSettingIndex((current) =>
|
|
443
|
+
Math.min(current + 1, SETTING_ROWS.length - 1)
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (
|
|
448
|
+
key.name === "left" ||
|
|
449
|
+
key.name === "right" ||
|
|
450
|
+
key.name === "return" ||
|
|
451
|
+
key.name === "space" ||
|
|
452
|
+
key.sequence === " "
|
|
453
|
+
) {
|
|
454
|
+
key.preventDefault();
|
|
455
|
+
const row = SETTING_ROWS[selectedSettingIndex];
|
|
456
|
+
if (row !== undefined) {
|
|
457
|
+
void persistSetting(row.key, key.name === "left" ? -1 : 1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
326
463
|
if (key.name === "tab") {
|
|
327
464
|
key.preventDefault();
|
|
328
465
|
rotateFilter(key.shift);
|
|
@@ -330,6 +467,11 @@ export function TrainingReviewApp(
|
|
|
330
467
|
}
|
|
331
468
|
|
|
332
469
|
if (focus === "tabs") {
|
|
470
|
+
if (key.name === "up") {
|
|
471
|
+
key.preventDefault();
|
|
472
|
+
setFocus("sections");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
333
475
|
if (key.name === "left" || key.name === "right") {
|
|
334
476
|
key.preventDefault();
|
|
335
477
|
moveFilterSelection(key.name === "left" ? -1 : 1);
|
|
@@ -461,6 +603,42 @@ export function TrainingReviewApp(
|
|
|
461
603
|
</box>
|
|
462
604
|
|
|
463
605
|
<box
|
|
606
|
+
title=" View "
|
|
607
|
+
titleColor={focus === "sections" ? COLORS.accent : COLORS.muted}
|
|
608
|
+
flexDirection="row"
|
|
609
|
+
gap={1}
|
|
610
|
+
border
|
|
611
|
+
borderColor={focus === "sections" ? COLORS.accent : COLORS.border}
|
|
612
|
+
backgroundColor={COLORS.panel}
|
|
613
|
+
height={3}
|
|
614
|
+
paddingLeft={1}
|
|
615
|
+
paddingRight={1}
|
|
616
|
+
>
|
|
617
|
+
{SECTIONS.map((candidate) => {
|
|
618
|
+
const active = candidate.value === section;
|
|
619
|
+
return (
|
|
620
|
+
<text
|
|
621
|
+
key={candidate.value}
|
|
622
|
+
fg={active ? COLORS.accent : COLORS.muted}
|
|
623
|
+
bg={active ? COLORS.selection : COLORS.background}
|
|
624
|
+
onMouseDown={() => {
|
|
625
|
+
setSection(candidate.value);
|
|
626
|
+
setFocus("sections");
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
{` ${candidate.label} `}
|
|
630
|
+
</text>
|
|
631
|
+
);
|
|
632
|
+
})}
|
|
633
|
+
<text fg={COLORS.muted}>[ / ] switch · Up to focus · ←/→ switch</text>
|
|
634
|
+
</box>
|
|
635
|
+
|
|
636
|
+
{section === "reviews"
|
|
637
|
+
? (
|
|
638
|
+
<>
|
|
639
|
+
<box
|
|
640
|
+
title=" Review status "
|
|
641
|
+
titleColor={focus === "tabs" ? COLORS.accent : COLORS.muted}
|
|
464
642
|
flexDirection="row"
|
|
465
643
|
gap={1}
|
|
466
644
|
border
|
|
@@ -568,13 +746,28 @@ export function TrainingReviewApp(
|
|
|
568
746
|
setFocus("queue");
|
|
569
747
|
}}
|
|
570
748
|
>
|
|
571
|
-
<text
|
|
572
|
-
|
|
573
|
-
|
|
749
|
+
<text
|
|
750
|
+
fg={entrySelected
|
|
751
|
+
? statusColor(getTrainingReviewFilter(entry))
|
|
752
|
+
: COLORS.text}
|
|
753
|
+
wrapMode="none"
|
|
754
|
+
truncate
|
|
755
|
+
>
|
|
574
756
|
{`${entrySelected ? "▶" : " "} ${summarizeCommand(entry.record.command, horizontal ? queueSize - 6 : width - 8)}`}
|
|
575
757
|
</text>
|
|
576
|
-
<text
|
|
577
|
-
{
|
|
758
|
+
<text
|
|
759
|
+
fg={entrySelected ? COLORS.accent : COLORS.muted}
|
|
760
|
+
wrapMode="none"
|
|
761
|
+
truncate
|
|
762
|
+
>
|
|
763
|
+
{` ${statusLabel(getTrainingReviewFilter(entry))} · `}
|
|
764
|
+
<span
|
|
765
|
+
fg={mutedDecisionColor(
|
|
766
|
+
entry.record.verdict.decision,
|
|
767
|
+
)}
|
|
768
|
+
>
|
|
769
|
+
{entry.record.verdict.decision.toUpperCase()}
|
|
770
|
+
</span>
|
|
578
771
|
</text>
|
|
579
772
|
</box>
|
|
580
773
|
);
|
|
@@ -704,7 +897,7 @@ export function TrainingReviewApp(
|
|
|
704
897
|
<box flexDirection="row" justifyContent="space-between">
|
|
705
898
|
<text fg={COLORS.muted}>
|
|
706
899
|
{focus === "tabs"
|
|
707
|
-
? "←/→ select status · Down/Enter filter · Tab rotates"
|
|
900
|
+
? "←/→ select status · Up sections · Down/Enter filter · Tab rotates"
|
|
708
901
|
: focus === "filter"
|
|
709
902
|
? "Type to fuzzy-search cwd · Up tabs · Down/Enter queue · Tab rotates"
|
|
710
903
|
: focus === "detail"
|
|
@@ -715,6 +908,81 @@ export function TrainingReviewApp(
|
|
|
715
908
|
{saving ? "Saving…" : "q/Esc quit"}
|
|
716
909
|
</text>
|
|
717
910
|
</box>
|
|
911
|
+
</>
|
|
912
|
+
)
|
|
913
|
+
: (
|
|
914
|
+
<>
|
|
915
|
+
<box
|
|
916
|
+
title=" Pi extension settings "
|
|
917
|
+
titleColor={COLORS.accent}
|
|
918
|
+
border
|
|
919
|
+
borderColor={COLORS.accent}
|
|
920
|
+
backgroundColor={COLORS.panel}
|
|
921
|
+
flexGrow={1}
|
|
922
|
+
flexDirection="column"
|
|
923
|
+
padding={1}
|
|
924
|
+
gap={1}
|
|
925
|
+
>
|
|
926
|
+
<text fg={COLORS.muted} wrapMode="word">
|
|
927
|
+
Changes are saved globally and picked up by Pi before its next Bash call.
|
|
928
|
+
</text>
|
|
929
|
+
{SETTING_ROWS.map((row, index) => {
|
|
930
|
+
const selectedRow = index === selectedSettingIndex;
|
|
931
|
+
const unavailable = row.key === "training" &&
|
|
932
|
+
settings.mode === "disabled";
|
|
933
|
+
return (
|
|
934
|
+
<box
|
|
935
|
+
key={row.key}
|
|
936
|
+
flexDirection="column"
|
|
937
|
+
backgroundColor={selectedRow
|
|
938
|
+
? COLORS.selection
|
|
939
|
+
: COLORS.panel}
|
|
940
|
+
paddingLeft={1}
|
|
941
|
+
paddingRight={1}
|
|
942
|
+
onMouseDown={() => {
|
|
943
|
+
setSelectedSettingIndex(index);
|
|
944
|
+
setFocus("settings");
|
|
945
|
+
}}
|
|
946
|
+
>
|
|
947
|
+
<box flexDirection="row">
|
|
948
|
+
<text
|
|
949
|
+
width={24}
|
|
950
|
+
fg={selectedRow ? COLORS.accent : COLORS.text}
|
|
951
|
+
>
|
|
952
|
+
{`${selectedRow ? "▶" : " "} ${row.label}`}
|
|
953
|
+
</text>
|
|
954
|
+
<text fg={unavailable ? COLORS.muted : COLORS.allow}>
|
|
955
|
+
{settingDisplayValue(settings, row.key)}
|
|
956
|
+
</text>
|
|
957
|
+
</box>
|
|
958
|
+
<text fg={COLORS.muted} wrapMode="word">
|
|
959
|
+
{unavailable
|
|
960
|
+
? "Unavailable while the operating mode is disabled."
|
|
961
|
+
: row.description}
|
|
962
|
+
</text>
|
|
963
|
+
</box>
|
|
964
|
+
);
|
|
965
|
+
})}
|
|
966
|
+
</box>
|
|
967
|
+
|
|
968
|
+
{settingsError === undefined
|
|
969
|
+
? null
|
|
970
|
+
: (
|
|
971
|
+
<text fg={COLORS.error}>
|
|
972
|
+
Could not save settings: {settingsError}
|
|
973
|
+
</text>
|
|
974
|
+
)}
|
|
975
|
+
|
|
976
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
977
|
+
<text fg={COLORS.muted}>
|
|
978
|
+
↑/↓ select · ←/→ change · Enter/Space next · [ reviews
|
|
979
|
+
</text>
|
|
980
|
+
<text fg={savingSettings ? COLORS.ask : COLORS.muted}>
|
|
981
|
+
{savingSettings ? "Saving…" : "q/Esc quit"}
|
|
982
|
+
</text>
|
|
983
|
+
</box>
|
|
984
|
+
</>
|
|
985
|
+
)}
|
|
718
986
|
</box>
|
|
719
987
|
);
|
|
720
988
|
}
|
|
@@ -766,14 +1034,18 @@ function JudgmentsTable(props: { entry: TrainingReviewEntry }): React.ReactNode
|
|
|
766
1034
|
* Launch OpenTUI for complete training history and restore the terminal on exit.
|
|
767
1035
|
*
|
|
768
1036
|
* @param snapshot - Complete training state to present initially
|
|
1037
|
+
* @param settings - Current globally persisted Pi extension settings
|
|
769
1038
|
* @param reloadSnapshot - Polling callback that returns current training state
|
|
770
1039
|
* @param recordReview - Persistence callback for review revisions
|
|
1040
|
+
* @param saveSettings - Atomic persistence callback for Pi extension settings
|
|
771
1041
|
* @returns Counts for the completed interactive session
|
|
772
1042
|
*/
|
|
773
1043
|
export async function runTrainingReviewTui(
|
|
774
1044
|
snapshot: TrainingReviewSnapshot,
|
|
1045
|
+
settings: DemurSettings,
|
|
775
1046
|
reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
|
|
776
1047
|
recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
|
|
1048
|
+
saveSettings: (settings: DemurSettings) => Promise<void>,
|
|
777
1049
|
): Promise<TrainingReviewTuiResult> {
|
|
778
1050
|
const renderer = await createCliRenderer({
|
|
779
1051
|
exitOnCtrlC: false,
|
|
@@ -796,8 +1068,10 @@ export async function runTrainingReviewTui(
|
|
|
796
1068
|
root.render(
|
|
797
1069
|
<TrainingReviewApp
|
|
798
1070
|
snapshot={snapshot}
|
|
1071
|
+
settings={settings}
|
|
799
1072
|
reloadSnapshot={reloadSnapshot}
|
|
800
1073
|
recordReview={recordReview}
|
|
1074
|
+
saveSettings={saveSettings}
|
|
801
1075
|
pollIntervalMs={undefined}
|
|
802
1076
|
onExit={finish}
|
|
803
1077
|
/>,
|
|
@@ -805,6 +1079,15 @@ export async function runTrainingReviewTui(
|
|
|
805
1079
|
});
|
|
806
1080
|
}
|
|
807
1081
|
|
|
1082
|
+
function settingDisplayValue(
|
|
1083
|
+
settings: DemurSettings,
|
|
1084
|
+
key: DemurSettingKey,
|
|
1085
|
+
): string {
|
|
1086
|
+
if (key === "mode") return settings.mode;
|
|
1087
|
+
if (key === "training") return settings.training ? "on" : "off";
|
|
1088
|
+
return settings.failurePolicy;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
808
1091
|
function countByFilter(
|
|
809
1092
|
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
810
1093
|
): Record<TrainingReviewFilter, number> {
|
|
@@ -871,6 +1154,14 @@ function decisionColor(decision: Decision): string {
|
|
|
871
1154
|
}[decision];
|
|
872
1155
|
}
|
|
873
1156
|
|
|
1157
|
+
function mutedDecisionColor(decision: Decision): string {
|
|
1158
|
+
return {
|
|
1159
|
+
allow: COLORS.allowMuted,
|
|
1160
|
+
ask: COLORS.askMuted,
|
|
1161
|
+
deny: COLORS.denyMuted,
|
|
1162
|
+
}[decision];
|
|
1163
|
+
}
|
|
1164
|
+
|
|
874
1165
|
function emptyTitle(
|
|
875
1166
|
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
876
1167
|
cwdQuery: string,
|
|
@@ -928,13 +1219,6 @@ function judgmentRows(
|
|
|
928
1219
|
];
|
|
929
1220
|
}
|
|
930
1221
|
|
|
931
|
-
function formatEvaluationCost(entry: TrainingReviewEntry): string {
|
|
932
|
-
const inputTokens = entry.record.verdict.usage?.inputTokens;
|
|
933
|
-
return inputTokens === undefined
|
|
934
|
-
? "cost n/a"
|
|
935
|
-
: formatUsd(estimateInputCostUsd(inputTokens));
|
|
936
|
-
}
|
|
937
|
-
|
|
938
1222
|
function formatUsage(entry: TrainingReviewEntry): string {
|
|
939
1223
|
const usage = entry.record.verdict.usage;
|
|
940
1224
|
return usage === undefined
|