@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 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.5.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
- # Force the line-oriented interface for pipes or basic terminals:
222
- demur training review --plain
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. Its header shows the persisted global estimated cost, and each queue
229
- row shows that evaluation's estimated input cost. It starts in an `all` view;
230
- Tab and Shift-Tab rotate between `all`, `not reviewed`, `approved` (`allow`),
231
- `ask`, and `deny` views. The queue is focused initially: arrow keys navigate it,
232
- Up from its first result focuses a
233
- fuzzy working-directory filter, and another Up focuses the tab strip. Left and
234
- Right select adjacent focused tabs, while Down returns through the filter to the
235
- queue. Right from the queue focuses the scrollable detail pane.
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
- Non-interactive invocations automatically use the line-oriented reviewer; pass
246
- `--plain` to select it explicitly. Reviews remain separate from the original
247
- evidence so they can later be curated into independently licensed eval fixtures.
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.5.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 review [--plain]
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
- runTrainingReviewTui: async (snapshot, reloadSnapshot, recordReview) => {
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(snapshot, reloadSnapshot, recordReview);
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 review --plain` for line-oriented review.",
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
- dependencies.stderr(`demur: ${errorDetail(error)}`);
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 plain = args.includes("--plain");
209
- const operands = args.filter((arg) => arg !== "--plain");
210
- if (
211
- operands.length !== 1 ||
212
- operands[0] !== "review" ||
213
- args.filter((arg) => arg === "--plain").length > 1
214
- ) {
215
- dependencies.stderr(USAGE);
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
- if (!plain && dependencies.isInteractive()) {
223
- const result = await dependencies.runTrainingReviewTui(
224
- snapshot,
225
- loadSnapshot,
226
- dependencies.recordTrainingReview,
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
- const pending = buildTrainingReviewEntries(snapshot)
233
- .filter((entry) => getTrainingReviewFilter(entry) === "unreviewed")
234
- .map((entry) => entry.record);
235
- if (pending.length === 0) {
236
- dependencies.stdout("No unreviewed demur training records.");
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
- return runPlainTrainingReview(pending, dependencies);
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 runPlainTrainingReview(
255
- pending: ReadonlyArray<TrainingRecord>,
462
+ async function loadTrainingEntries(
256
463
  dependencies: CliDependencies,
257
- ): Promise<number> {
258
- let reviewed = 0;
259
- let corrected = 0;
260
- let skipped = 0;
261
- for (const [index, record] of pending.entries()) {
262
- printTrainingRecord(record, index + 1, pending.length, dependencies);
263
- const expectedDecision = await readExpectedDecision(record, dependencies);
264
- if (expectedDecision === "quit") break;
265
- if (expectedDecision === "skip") {
266
- skipped += 1;
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
- async function readExpectedDecision(
307
- record: TrainingRecord,
308
- dependencies: CliDependencies,
309
- ): Promise<"allow" | "ask" | "deny" | "skip" | "quit"> {
310
- while (true) {
311
- const input = (
312
- await dependencies.readLine(
313
- `Expected decision [enter=${record.verdict.decision}, allow, ask, deny, skip, quit]: `,
314
- )
315
- ).trim().toLowerCase();
316
-
317
- if (input === "") return record.verdict.decision;
318
- if (
319
- input === "allow" ||
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
- dependencies.stderr(`Unknown review choice: ${input}`);
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 printTrainingRecord(
332
- record: TrainingRecord,
333
- index: number,
334
- total: number,
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
- ): void {
337
- dependencies.stdout("");
338
- dependencies.stdout(
339
- `[${index}/${total}] ${record.recordedAt} · ${record.mode} · host ${record.hostAction}`,
340
- );
341
- dependencies.stdout(`cwd: ${record.cwd}`);
342
- dependencies.stdout(`command: ${record.command}`);
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
- if (record.verdict.failure !== undefined) {
352
- dependencies.stdout(`failure: ${record.verdict.failure}`);
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 correction explanation
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: corrected ? note?.trim() || undefined : undefined,
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 FocusTarget = "tabs" | "filter" | "queue" | "detail";
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 - 13 : queueSize - 2) / 2),
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 fg={entrySelected
572
- ? statusColor(getTrainingReviewFilter(entry))
573
- : COLORS.text}>
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 fg={entrySelected ? COLORS.accent : COLORS.muted}>
577
- {` ${statusLabel(getTrainingReviewFilter(entry))} · ${formatEvaluationCost(entry)} · ${entry.reviews.length}r`}
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