@wyattjoh/demur 0.5.0 → 0.7.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/src/cli.ts CHANGED
@@ -1,8 +1,13 @@
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";
5
4
  import {
5
+ loadDemurSettings,
6
+ saveDemurSettings,
7
+ type DemurSettings,
8
+ } from "../extensions/demur/settings.ts";
9
+ import {
10
+ isTrainingCorrectionReason,
6
11
  loadTrainingRecords,
7
12
  loadTrainingReviews,
8
13
  recordTrainingReview,
@@ -11,26 +16,39 @@ import {
11
16
  type TrainingReviewInput,
12
17
  } from "../extensions/demur/training-store.ts";
13
18
  import { guard } from "./guard.ts";
19
+ import { judgeRenderedState, type JudgeResult } from "./judge.ts";
14
20
  import {
15
21
  deleteApiKey,
16
22
  resolveApiKey,
17
23
  storeApiKey,
18
24
  type ResolvedApiKey,
19
25
  } from "./key.ts";
26
+ import {
27
+ analyzeTrainingFeedback,
28
+ replayTrainingQuestions,
29
+ } from "./training-evaluation.ts";
20
30
  import {
21
31
  buildTrainingReviewEntries,
32
+ createTrainingReviewInput,
33
+ filterTrainingReviewEntries,
34
+ getLatestTrainingReview,
22
35
  getTrainingReviewFilter,
36
+ type TrainingReviewEntry,
37
+ type TrainingReviewFilter,
23
38
  type TrainingReviewSnapshot,
24
39
  } from "./training-review-model.ts";
25
40
  import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
26
- import type { Verdict } from "./types.ts";
41
+ import type { RenderedCommandState, Verdict } from "./types.ts";
27
42
 
28
43
  const USAGE = `Usage:
29
44
  demur
30
45
  demur auth login
31
46
  demur auth status
32
47
  demur auth logout
33
- demur training review [--plain]
48
+ demur training list [--status=<all|unreviewed|allow|ask|deny>] [--cwd=<query>] [--json]
49
+ demur training evaluate [--replay] [--limit=<1-100>] [--json]
50
+ demur training review
51
+ demur training review <record-id> --decision=<allow|ask|deny> [--reason=<correction-reason>] [--note=<text>] [--json]
34
52
  demur judge "<command>" [--cwd=<path>]`;
35
53
 
36
54
  /**
@@ -38,6 +56,7 @@ const USAGE = `Usage:
38
56
  */
39
57
  export type CliDependencies = {
40
58
  judge(command: string, cwd: string): Promise<Verdict>;
59
+ judgeTrainingState(state: RenderedCommandState): Promise<JudgeResult>;
41
60
  resolveApiKey(): Promise<ResolvedApiKey | undefined>;
42
61
  storeApiKey(value: string): Promise<void>;
43
62
  deleteApiKey(): Promise<boolean>;
@@ -45,28 +64,25 @@ export type CliDependencies = {
45
64
  loadTrainingReviews(): Promise<ReadonlyArray<TrainingReview>>;
46
65
  loadGlobalEstimatedCostUsd(): Promise<number>;
47
66
  recordTrainingReview(input: TrainingReviewInput): Promise<TrainingReview>;
67
+ loadDemurSettings(): Promise<DemurSettings>;
68
+ saveDemurSettings(settings: DemurSettings): Promise<void>;
48
69
  runTrainingReviewTui(
49
70
  snapshot: TrainingReviewSnapshot,
71
+ settings: DemurSettings,
50
72
  reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
51
73
  recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
74
+ saveSettings: (settings: DemurSettings) => Promise<void>,
52
75
  ): Promise<TrainingReviewTuiResult>;
53
76
  isInteractive(): boolean;
54
77
  readSecret(prompt: string): Promise<string>;
55
- readLine(prompt: string): Promise<string>;
56
78
  cwd(): string;
57
79
  stdout(message: string): void;
58
80
  stderr(message: string): void;
59
81
  };
60
82
 
61
- let lineInput:
62
- | {
63
- terminal: ReturnType<typeof createInterface>;
64
- lines: AsyncIterator<string>;
65
- }
66
- | undefined;
67
-
68
83
  const defaultDependencies: CliDependencies = {
69
84
  judge: (command, cwd) => guard(command, cwd, "cli"),
85
+ judgeTrainingState: judgeRenderedState,
70
86
  resolveApiKey,
71
87
  storeApiKey,
72
88
  deleteApiKey,
@@ -75,15 +91,28 @@ const defaultDependencies: CliDependencies = {
75
91
  loadGlobalEstimatedCostUsd: async () =>
76
92
  (await loadCostTotals()).estimatedCostUsd,
77
93
  recordTrainingReview,
78
- runTrainingReviewTui: async (snapshot, reloadSnapshot, recordReview) => {
94
+ loadDemurSettings,
95
+ saveDemurSettings,
96
+ runTrainingReviewTui: async (
97
+ snapshot,
98
+ settings,
99
+ reloadSnapshot,
100
+ recordReview,
101
+ saveSettings,
102
+ ) => {
79
103
  const { runTrainingReviewTui } = await import(
80
104
  "./training-review-tui.tsx"
81
105
  );
82
- return runTrainingReviewTui(snapshot, reloadSnapshot, recordReview);
106
+ return runTrainingReviewTui(
107
+ snapshot,
108
+ settings,
109
+ reloadSnapshot,
110
+ recordReview,
111
+ saveSettings,
112
+ );
83
113
  },
84
114
  isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
85
115
  readSecret,
86
- readLine,
87
116
  cwd: () => process.cwd(),
88
117
  stdout: (message) => console.log(message),
89
118
  stderr: (message) => console.error(message),
@@ -104,7 +133,7 @@ export async function runCli(
104
133
  if (args.length === 0) {
105
134
  if (!dependencies.isInteractive()) {
106
135
  dependencies.stderr(
107
- "demur: the interactive app requires a terminal; use `demur training review --plain` for line-oriented review.",
136
+ "demur: the interactive app requires a terminal; use `demur training list --json` or review a record by ID.",
108
137
  );
109
138
  return 2;
110
139
  }
@@ -127,10 +156,17 @@ export async function runCli(
127
156
  const judgeArgs = args[0] === "judge" ? args.slice(1) : args;
128
157
  return await runJudge(judgeArgs, dependencies);
129
158
  } catch (error: unknown) {
130
- dependencies.stderr(`demur: ${errorDetail(error)}`);
159
+ if (isTrainingJsonRequest(args)) {
160
+ writeTrainingJsonError(
161
+ trainingOperation(args),
162
+ "unexpected_error",
163
+ errorDetail(error),
164
+ dependencies,
165
+ );
166
+ } else {
167
+ dependencies.stderr(`demur: ${errorDetail(error)}`);
168
+ }
131
169
  return 1;
132
- } finally {
133
- closeLineInput();
134
170
  }
135
171
  }
136
172
 
@@ -201,43 +237,364 @@ async function runAuth(
201
237
  return 2;
202
238
  }
203
239
 
240
+ type ParsedArguments = {
241
+ positionals: ReadonlyArray<string>;
242
+ options: ReadonlyMap<string, string | true>;
243
+ };
244
+
245
+ type ParseArgumentsResult =
246
+ | { parsed: ParsedArguments; error: undefined }
247
+ | { parsed: undefined; error: string };
248
+
204
249
  async function runTraining(
205
250
  args: ReadonlyArray<string>,
206
251
  dependencies: CliDependencies,
207
252
  ): 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);
253
+ const command = args[0];
254
+ if (command === "list") {
255
+ return runTrainingList(args.slice(1), dependencies);
256
+ }
257
+
258
+ if (command === "evaluate") {
259
+ return runTrainingEvaluate(args.slice(1), dependencies);
260
+ }
261
+
262
+ if (command === "review") {
263
+ if (args.length === 1) {
264
+ return runInteractiveTrainingReview(dependencies);
265
+ }
266
+ return runTrainingReview(args.slice(1), dependencies);
267
+ }
268
+
269
+ return writeTrainingUsageError(
270
+ trainingOperation(["training", ...args]),
271
+ args.includes("--json"),
272
+ "expected `training list`, `training evaluate`, or `training review`",
273
+ dependencies,
274
+ );
275
+ }
276
+
277
+ async function runInteractiveTrainingReview(
278
+ dependencies: CliDependencies,
279
+ ): Promise<number> {
280
+ if (!dependencies.isInteractive()) {
281
+ dependencies.stderr(
282
+ "demur: `training review` requires a terminal; use `training list --json` and review a record by ID.",
283
+ );
216
284
  return 2;
217
285
  }
218
286
 
219
287
  const loadSnapshot = () => loadTrainingReviewSnapshot(dependencies);
220
288
  const snapshot = await loadSnapshot();
289
+ const settings = await dependencies.loadDemurSettings();
290
+ const result = await dependencies.runTrainingReviewTui(
291
+ snapshot,
292
+ settings,
293
+ loadSnapshot,
294
+ dependencies.recordTrainingReview,
295
+ dependencies.saveDemurSettings,
296
+ );
297
+ printReviewSummary(result, dependencies);
298
+ return 0;
299
+ }
221
300
 
222
- if (!plain && dependencies.isInteractive()) {
223
- const result = await dependencies.runTrainingReviewTui(
224
- snapshot,
225
- loadSnapshot,
226
- dependencies.recordTrainingReview,
301
+ async function runTrainingList(
302
+ args: ReadonlyArray<string>,
303
+ dependencies: CliDependencies,
304
+ ): Promise<number> {
305
+ const operation = "training.list";
306
+ const parsedResult = parseArguments(
307
+ args,
308
+ new Set(["json"]),
309
+ new Set(["status", "cwd"]),
310
+ );
311
+ const json = args.includes("--json");
312
+ if (parsedResult.error !== undefined) {
313
+ return writeTrainingUsageError(
314
+ operation,
315
+ json,
316
+ parsedResult.error,
317
+ dependencies,
318
+ );
319
+ }
320
+
321
+ const parsed = parsedResult.parsed;
322
+ if (parsed.positionals.length !== 0) {
323
+ return writeTrainingUsageError(
324
+ operation,
325
+ hasOption(parsed, "json"),
326
+ "`training list` does not accept positional arguments",
327
+ dependencies,
227
328
  );
228
- printReviewSummary(result, dependencies);
329
+ }
330
+
331
+ const statusValue = getStringOption(parsed, "status") ?? "all";
332
+ if (!isTrainingReviewFilter(statusValue)) {
333
+ return writeTrainingUsageError(
334
+ operation,
335
+ hasOption(parsed, "json"),
336
+ `unsupported status: ${statusValue}`,
337
+ dependencies,
338
+ );
339
+ }
340
+
341
+ const cwdQuery = getStringOption(parsed, "cwd") ?? "";
342
+ const entries = filterTrainingReviewEntries(
343
+ await loadTrainingEntries(dependencies),
344
+ statusValue,
345
+ cwdQuery,
346
+ );
347
+
348
+ if (hasOption(parsed, "json")) {
349
+ writeTrainingJsonSuccess(
350
+ operation,
351
+ {
352
+ filter: {
353
+ status: statusValue,
354
+ cwd: cwdQuery === "" ? null : cwdQuery,
355
+ },
356
+ records: entries.map(trainingListRecord),
357
+ },
358
+ dependencies,
359
+ );
360
+ return 0;
361
+ }
362
+
363
+ if (entries.length === 0) {
364
+ dependencies.stdout("No matching demur training records.");
229
365
  return 0;
230
366
  }
231
367
 
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.");
368
+ dependencies.stdout(
369
+ `${entries.length} matching training record${entries.length === 1 ? "" : "s"}:`,
370
+ );
371
+ for (const entry of entries) {
372
+ const record = entry.record;
373
+ dependencies.stdout(
374
+ [
375
+ record.id,
376
+ getTrainingReviewFilter(entry),
377
+ `model=${record.verdict.decision}`,
378
+ record.cwd,
379
+ summarizeTrainingCommand(record.command),
380
+ ].join("\t"),
381
+ );
382
+ }
383
+ return 0;
384
+ }
385
+
386
+ async function runTrainingEvaluate(
387
+ args: ReadonlyArray<string>,
388
+ dependencies: CliDependencies,
389
+ ): Promise<number> {
390
+ const operation = "training.evaluate";
391
+ const parsedResult = parseArguments(
392
+ args,
393
+ new Set(["json", "replay"]),
394
+ new Set(["limit"]),
395
+ );
396
+ const json = args.includes("--json");
397
+ if (parsedResult.error !== undefined) {
398
+ return writeTrainingUsageError(
399
+ operation,
400
+ json,
401
+ parsedResult.error,
402
+ dependencies,
403
+ );
404
+ }
405
+
406
+ const parsed = parsedResult.parsed;
407
+ if (parsed.positionals.length !== 0) {
408
+ return writeTrainingUsageError(
409
+ operation,
410
+ hasOption(parsed, "json"),
411
+ "`training evaluate` does not accept positional arguments",
412
+ dependencies,
413
+ );
414
+ }
415
+
416
+ const limitValue = getStringOption(parsed, "limit");
417
+ if (limitValue !== undefined && !hasOption(parsed, "replay")) {
418
+ return writeTrainingUsageError(
419
+ operation,
420
+ hasOption(parsed, "json"),
421
+ "`--limit` requires `--replay`",
422
+ dependencies,
423
+ );
424
+ }
425
+ const replayLimit = limitValue === undefined ? 20 : Number(limitValue);
426
+ if (
427
+ !Number.isInteger(replayLimit) ||
428
+ replayLimit < 1 ||
429
+ replayLimit > 100
430
+ ) {
431
+ return writeTrainingUsageError(
432
+ operation,
433
+ hasOption(parsed, "json"),
434
+ "`--limit` must be an integer from 1 to 100",
435
+ dependencies,
436
+ );
437
+ }
438
+
439
+ const entries = await loadTrainingEntries(dependencies);
440
+ const report = analyzeTrainingFeedback(entries);
441
+ const replay = hasOption(parsed, "replay")
442
+ ? await replayTrainingQuestions(
443
+ entries,
444
+ dependencies.judgeTrainingState,
445
+ replayLimit,
446
+ )
447
+ : undefined;
448
+ if (hasOption(parsed, "json")) {
449
+ writeTrainingJsonSuccess(
450
+ operation,
451
+ replay === undefined ? report : { offline: report, replay },
452
+ dependencies,
453
+ );
237
454
  return 0;
238
455
  }
239
456
 
240
- return runPlainTrainingReview(pending, dependencies);
457
+ dependencies.stdout(
458
+ `Reviewed ${report.reviewed}; evaluable ${report.evaluable}; unavailable ${report.unavailable}.`,
459
+ );
460
+ dependencies.stdout(
461
+ `Replay fidelity: ${report.completeReplayRecords} complete, ${report.policyOnlyRecords} policy-only legacy.`,
462
+ );
463
+ dependencies.stdout(
464
+ `Current policy: ${report.current.matches}/${report.current.evaluated} match; weighted loss ${report.current.weightedLoss}.`,
465
+ );
466
+
467
+ const reasons = Object.entries(report.correctionsByReason);
468
+ if (reasons.length > 0) {
469
+ dependencies.stdout(
470
+ `Correction reasons: ${reasons.map(([reason, count]) => `${reason}=${count}`).join(", ")}.`,
471
+ );
472
+ }
473
+
474
+ if (report.candidates.length === 0) {
475
+ dependencies.stdout("No single-threshold candidate improved observed weighted loss.");
476
+ } else {
477
+ dependencies.stdout("Exploratory single-threshold candidates:");
478
+ for (const candidate of report.candidates) {
479
+ dependencies.stdout(
480
+ ` ${candidate.field}=${candidate.value}: ${candidate.metrics.matches}/${candidate.metrics.evaluated} match; weighted loss ${candidate.metrics.weightedLoss}`,
481
+ );
482
+ }
483
+ }
484
+
485
+ if (report.warning !== undefined) dependencies.stdout(`Warning: ${report.warning}`);
486
+ dependencies.stdout(
487
+ "Candidates are not applied automatically; validate them on an independent holdout and the synthetic corpus.",
488
+ );
489
+ if (replay !== undefined) {
490
+ dependencies.stdout(
491
+ `Current questions replay: ${replay.metrics.matches}/${replay.metrics.evaluated} match; ${replay.improved} improved; ${replay.regressed} regressed; ${replay.unavailable} unavailable; ${replay.skipped} skipped by limit.`,
492
+ );
493
+ dependencies.stdout(
494
+ `Replay usage: ${replay.inputTokens} input / ${replay.outputTokens} output tokens. Stored commands remained data and were never executed.`,
495
+ );
496
+ }
497
+ return 0;
498
+ }
499
+
500
+ async function runTrainingReview(
501
+ args: ReadonlyArray<string>,
502
+ dependencies: CliDependencies,
503
+ ): Promise<number> {
504
+ const operation = "training.review";
505
+ const parsedResult = parseArguments(
506
+ args,
507
+ new Set(["json"]),
508
+ new Set(["decision", "reason", "note"]),
509
+ );
510
+ const json = args.includes("--json");
511
+ if (parsedResult.error !== undefined) {
512
+ return writeTrainingUsageError(
513
+ operation,
514
+ json,
515
+ parsedResult.error,
516
+ dependencies,
517
+ );
518
+ }
519
+
520
+ const parsed = parsedResult.parsed;
521
+ if (parsed.positionals.length !== 1) {
522
+ return writeTrainingUsageError(
523
+ operation,
524
+ hasOption(parsed, "json"),
525
+ "`training review` requires exactly one record ID",
526
+ dependencies,
527
+ );
528
+ }
529
+
530
+ const decisionValue = getStringOption(parsed, "decision");
531
+ if (decisionValue === undefined || !isDecision(decisionValue)) {
532
+ return writeTrainingUsageError(
533
+ operation,
534
+ hasOption(parsed, "json"),
535
+ "`--decision` must be allow, ask, or deny",
536
+ dependencies,
537
+ );
538
+ }
539
+
540
+ const recordId = parsed.positionals[0];
541
+ const entries = await loadTrainingEntries(dependencies);
542
+ const entry = entries.find((candidate) => candidate.record.id === recordId);
543
+ if (entry === undefined) {
544
+ return writeTrainingDomainError(
545
+ operation,
546
+ hasOption(parsed, "json"),
547
+ "record_not_found",
548
+ `training record not found: ${recordId}`,
549
+ dependencies,
550
+ );
551
+ }
552
+
553
+ const corrected = decisionValue !== entry.record.verdict.decision;
554
+ const reasonValue = getStringOption(parsed, "reason");
555
+ if (corrected && !isTrainingCorrectionReason(reasonValue)) {
556
+ return writeTrainingUsageError(
557
+ operation,
558
+ hasOption(parsed, "json"),
559
+ "corrected reviews require `--reason` with a supported correction reason",
560
+ dependencies,
561
+ );
562
+ }
563
+ if (!corrected && reasonValue !== undefined) {
564
+ return writeTrainingUsageError(
565
+ operation,
566
+ hasOption(parsed, "json"),
567
+ "`--reason` is only valid when correcting the model decision",
568
+ dependencies,
569
+ );
570
+ }
571
+
572
+ const correctionReason = corrected && isTrainingCorrectionReason(reasonValue)
573
+ ? reasonValue
574
+ : undefined;
575
+ const previousReview = getLatestTrainingReview(entry) ?? null;
576
+ const review = await dependencies.recordTrainingReview(
577
+ createTrainingReviewInput(
578
+ entry.record,
579
+ decisionValue,
580
+ correctionReason,
581
+ getStringOption(parsed, "note"),
582
+ ),
583
+ );
584
+
585
+ if (hasOption(parsed, "json")) {
586
+ writeTrainingJsonSuccess(
587
+ operation,
588
+ { review, corrected, previousReview },
589
+ dependencies,
590
+ );
591
+ return 0;
592
+ }
593
+
594
+ dependencies.stdout(
595
+ `Recorded ${decisionValue.toUpperCase()} review for ${recordId}${corrected ? ` (model: ${entry.record.verdict.decision.toUpperCase()})` : " (accepted model decision)"}.`,
596
+ );
597
+ return 0;
241
598
  }
242
599
 
243
600
  async function loadTrainingReviewSnapshot(
@@ -251,44 +608,18 @@ async function loadTrainingReviewSnapshot(
251
608
  return { records, reviews, globalEstimatedCostUsd };
252
609
  }
253
610
 
254
- async function runPlainTrainingReview(
255
- pending: ReadonlyArray<TrainingRecord>,
611
+ async function loadTrainingEntries(
256
612
  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;
613
+ ): Promise<ReadonlyArray<TrainingReviewEntry>> {
614
+ const [records, reviews] = await Promise.all([
615
+ dependencies.loadTrainingRecords(),
616
+ dependencies.loadTrainingReviews(),
617
+ ]);
618
+ return buildTrainingReviewEntries({
619
+ records,
620
+ reviews,
621
+ globalEstimatedCostUsd: 0,
622
+ });
292
623
  }
293
624
 
294
625
  function printReviewSummary(
@@ -303,54 +634,162 @@ function printReviewSummary(
303
634
  );
304
635
  }
305
636
 
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;
637
+ function parseArguments(
638
+ args: ReadonlyArray<string>,
639
+ booleanOptions: ReadonlySet<string>,
640
+ valueOptions: ReadonlySet<string>,
641
+ ): ParseArgumentsResult {
642
+ const positionals: Array<string> = [];
643
+ const options = new Map<string, string | true>();
644
+
645
+ for (let index = 0; index < args.length; index += 1) {
646
+ const argument = args[index];
647
+ if (argument === undefined) continue;
648
+ if (!argument.startsWith("--")) {
649
+ positionals.push(argument);
650
+ continue;
651
+ }
652
+
653
+ const separator = argument.indexOf("=");
654
+ const name = argument.slice(2, separator < 0 ? undefined : separator);
655
+ const inlineValue = separator < 0 ? undefined : argument.slice(separator + 1);
656
+ if (options.has(name)) {
657
+ return { parsed: undefined, error: `duplicate option: --${name}` };
326
658
  }
327
- dependencies.stderr(`Unknown review choice: ${input}`);
659
+
660
+ if (booleanOptions.has(name)) {
661
+ if (inlineValue !== undefined) {
662
+ return {
663
+ parsed: undefined,
664
+ error: `option --${name} does not accept a value`,
665
+ };
666
+ }
667
+ options.set(name, true);
668
+ continue;
669
+ }
670
+
671
+ if (!valueOptions.has(name)) {
672
+ return { parsed: undefined, error: `unknown option: --${name}` };
673
+ }
674
+
675
+ const followingValue = args[index + 1];
676
+ const value = inlineValue ??
677
+ (followingValue !== undefined && !followingValue.startsWith("--")
678
+ ? followingValue
679
+ : undefined);
680
+ if (value === undefined) {
681
+ return {
682
+ parsed: undefined,
683
+ error: `option --${name} requires a value`,
684
+ };
685
+ }
686
+ if (inlineValue === undefined) index += 1;
687
+ options.set(name, value);
328
688
  }
689
+
690
+ return { parsed: { positionals, options }, error: undefined };
329
691
  }
330
692
 
331
- function printTrainingRecord(
332
- record: TrainingRecord,
333
- index: number,
334
- total: number,
693
+ function getStringOption(
694
+ parsed: ParsedArguments,
695
+ name: string,
696
+ ): string | undefined {
697
+ const value = parsed.options.get(name);
698
+ return typeof value === "string" ? value : undefined;
699
+ }
700
+
701
+ function hasOption(parsed: ParsedArguments, name: string): boolean {
702
+ return parsed.options.has(name);
703
+ }
704
+
705
+ function isTrainingReviewFilter(value: string): value is TrainingReviewFilter {
706
+ return value === "all" ||
707
+ value === "unreviewed" ||
708
+ value === "allow" ||
709
+ value === "ask" ||
710
+ value === "deny";
711
+ }
712
+
713
+ function isDecision(value: string): value is "allow" | "ask" | "deny" {
714
+ return value === "allow" || value === "ask" || value === "deny";
715
+ }
716
+
717
+ function trainingListRecord(entry: TrainingReviewEntry): object {
718
+ return {
719
+ status: getTrainingReviewFilter(entry),
720
+ record: entry.record,
721
+ reviews: entry.reviews,
722
+ latestReview: getLatestTrainingReview(entry) ?? null,
723
+ };
724
+ }
725
+
726
+ function summarizeTrainingCommand(command: string): string {
727
+ return command.replaceAll(/\s+/g, " ").trim();
728
+ }
729
+
730
+ function writeTrainingUsageError(
731
+ operation: string,
732
+ json: boolean,
733
+ message: string,
335
734
  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)}`,
735
+ ): number {
736
+ if (json) {
737
+ writeTrainingJsonError(
738
+ operation,
739
+ "invalid_arguments",
740
+ message,
741
+ dependencies,
349
742
  );
743
+ } else {
744
+ dependencies.stderr(`demur: ${message}`);
745
+ dependencies.stderr(USAGE);
350
746
  }
351
- if (record.verdict.failure !== undefined) {
352
- dependencies.stdout(`failure: ${record.verdict.failure}`);
747
+ return 2;
748
+ }
749
+
750
+ function writeTrainingDomainError(
751
+ operation: string,
752
+ json: boolean,
753
+ code: string,
754
+ message: string,
755
+ dependencies: CliDependencies,
756
+ ): number {
757
+ if (json) {
758
+ writeTrainingJsonError(operation, code, message, dependencies);
759
+ } else {
760
+ dependencies.stderr(`demur: ${message}`);
353
761
  }
762
+ return 1;
763
+ }
764
+
765
+ function writeTrainingJsonSuccess(
766
+ operation: string,
767
+ result: unknown,
768
+ dependencies: CliDependencies,
769
+ ): void {
770
+ dependencies.stdout(JSON.stringify({ version: 1, ok: true, operation, result }));
771
+ }
772
+
773
+ function writeTrainingJsonError(
774
+ operation: string,
775
+ code: string,
776
+ message: string,
777
+ dependencies: CliDependencies,
778
+ ): void {
779
+ dependencies.stdout(JSON.stringify({
780
+ version: 1,
781
+ ok: false,
782
+ operation,
783
+ error: { code, message },
784
+ }));
785
+ }
786
+
787
+ function isTrainingJsonRequest(args: ReadonlyArray<string>): boolean {
788
+ return args[0] === "training" && args.includes("--json");
789
+ }
790
+
791
+ function trainingOperation(args: ReadonlyArray<string>): string {
792
+ return `training.${args[1] ?? "unknown"}`;
354
793
  }
355
794
 
356
795
  async function runJudge(
@@ -403,26 +842,6 @@ async function runJudge(
403
842
  return 0;
404
843
  }
405
844
 
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
845
  async function readSecret(prompt: string): Promise<string> {
427
846
  if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
428
847
  return (await Bun.stdin.text()).trim();