@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/README.md CHANGED
@@ -48,11 +48,13 @@ or sensitive names. Review TypeSafe's service terms and data-handling policy
48
48
  before enabling demur in a sensitive repository. Do not run secrets directly in
49
49
  shell arguments when the guard is active.
50
50
 
51
- Pi training capture stores the complete command, working directory, operating
52
- mode, full verdict and judgments, and resulting host action locally. These
53
- records can therefore contain secrets or sensitive names from shell arguments
54
- and paths. Training capture is off by default and is unavailable while the Pi
55
- integration is disabled.
51
+ Pi training capture stores the complete command, exact TypeSafe state, local
52
+ static-analysis result, model/question/policy versions, exact policy thresholds,
53
+ operating mode, full verdict and judgments, and resulting host action locally.
54
+ These records can therefore contain secrets or sensitive names from shell
55
+ arguments, paths, Git
56
+ context, and deterministic command analysis. Training capture is off by default
57
+ and is unavailable while the Pi integration is disabled.
56
58
 
57
59
  ## Requirements
58
60
 
@@ -98,7 +100,7 @@ Pin a specific release when reproducibility matters:
98
100
 
99
101
  <!-- x-release-please-start-version -->
100
102
  ```sh
101
- pi install npm:@wyattjoh/demur@0.5.0
103
+ pi install npm:@wyattjoh/demur@0.7.0
102
104
  ```
103
105
  <!-- x-release-please-end -->
104
106
 
@@ -111,7 +113,9 @@ pi
111
113
  The extension intercepts `bash` tool calls. Because Pi runs extensions under
112
114
  Node.js while demur uses `Bun.secrets`, the extension launches a package-local
113
115
  Bun worker for each judgment. The API key remains inside that worker; only the
114
- command request and resulting verdict cross its local stdio pipes. `ask` opens
116
+ command request and resulting verdict cross its local stdio pipes. When training
117
+ capture is enabled, the exact model state and local policy evidence return with
118
+ the verdict for private local persistence. `ask` opens
115
119
  an interactive confirmation dialog; without an interactive UI, demur blocks the
116
120
  command.
117
121
 
@@ -166,8 +170,11 @@ Set `DEMUR_STATE_HOME` to place `usage.json`, `training.jsonl`, and
166
170
  demur-specific override takes precedence over the XDG and home-directory
167
171
  locations. Training records are retained until the user removes them. Human
168
172
  reviews are appended separately; accepted and corrected records are linked by a
169
- stable record ID, leaving the original evidence unchanged. A training-write
170
- failure is reported but never changes whether the command runs.
173
+ stable record ID, leaving the original evidence unchanged. Corrected reviews
174
+ also carry a machine-readable reason so recurring question, context, policy, and
175
+ service failures can be measured without mining free-form notes. Version-one
176
+ records and reviews remain readable. A training-write failure is reported but
177
+ never changes whether the command runs.
171
178
 
172
179
  Pi packages execute with the user's full system permissions. Review this
173
180
  repository before installing it.
@@ -218,33 +225,58 @@ demur auth login
218
225
  demur auth status
219
226
  demur auth logout
220
227
  demur training review
221
- # Force the line-oriented interface for pipes or basic terminals:
222
- demur training review --plain
228
+ demur training list --status=unreviewed --cwd=/workspace
229
+ demur training list --status=deny --json
230
+ demur training evaluate --json
231
+ demur training review <record-id> --decision=deny --reason=recoverability --note="would destroy work" --json
223
232
  demur judge "git reset --hard HEAD~3"
224
233
  ```
225
234
 
226
235
  Bare `demur` opens the central OpenTUI interface when stdin and stdout are
227
236
  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.
237
+ interface. `[` opens Reviews and `]` opens Settings. You can also navigate Up
238
+ to the top-level section strip, use Left and Right to switch sections, and press
239
+ Down or Enter to open one. Settings exposes every option from Pi's `/demur`
240
+ menu—operating mode, training capture,
241
+ and failure policy—and atomically saves each change to the same global
242
+ configuration file. Use Up and Down to select a setting, Left and Right to
243
+ change it in either direction, or Enter/Space to choose its next value.
244
+ Disabling demur also turns training capture off, and training remains unavailable
245
+ until an active mode is selected.
246
+
247
+ The Reviews header shows the persisted global estimated cost, and each queue row
248
+ shows the model decision in a muted semantic color. It starts in an `all` view;
249
+ Tab and Shift-Tab rotate between `all`, `not reviewed`, `approved` (`allow`), `ask`,
250
+ and `deny` views. The queue is focused initially: arrow keys navigate it, Up
251
+ from its first result focuses a fuzzy working-directory filter, and another Up
252
+ focuses the tab strip. Left and Right select adjacent focused tabs, while Down
253
+ returns through the filter to the queue. Right from the queue focuses the
254
+ scrollable detail pane.
236
255
  The detail pane supports arrows or `j`/`k`; Left returns to the queue. Page Up
237
256
  and Page Down page within the focused pane, and queue navigation stops at its
238
257
  first and last entries. Mouse clicks select tabs, records, the filter, or either
239
258
  pane; the wheel scrolls the queue and detail pane. Enter selects the original
240
259
  decision, `1`/`2`/`3` choose `allow`/`ask`/`deny`, `s`
241
- leaves a record for a later pass, and `q` or Escape stops. Previously reviewed
242
- records remain available, and changing an answer appends a review revision while
243
- preserving its visible history. The TUI remains open when a view is empty and
260
+ leaves a record for a later pass, and `q` or Escape stops. A correction first
261
+ asks for one structured reason, then accepts an optional note. Previously
262
+ reviewed records remain available, and changing an answer appends a review
263
+ revision while preserving its visible history. The TUI remains open when a view is empty and
244
264
  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.
265
+
266
+ Flag-based training commands provide the same review operations without the
267
+ TUI. `demur training list` accepts `--status` and fuzzy `--cwd` filters. Status
268
+ is derived from the latest human review, so `allow`, `ask`, and `deny` select
269
+ reviewed records while `unreviewed` selects records without a review. Record a
270
+ new append-only review revision by passing a record ID and a required
271
+ `--decision=<allow|ask|deny>`. Corrections also require `--reason` with one of
272
+ `inert-or-read-only`, `sensitive-data`, `security-boundary`, `recoverability`,
273
+ `shared-infrastructure`, `blast-radius`, `static-uncertainty`, `missing-context`,
274
+ or `service-failure`; `--note` remains optional. Add `--json` to list, evaluate,
275
+ or review to emit one versioned JSON document; JSON failures are written to
276
+ stdout with a nonzero exit code. Interactive `demur training review` still opens
277
+ the TUI, but without a terminal it requires an explicit list or record-ID review
278
+ operation. Reviews remain separate from the original evidence so they can later
279
+ be curated into independently licensed eval fixtures.
248
280
 
249
281
  From a development checkout, `bun run judge "<command>"` remains available.
250
282
 
@@ -305,11 +337,36 @@ and deterministic policy controls alongside demur.
305
337
  - `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
306
338
  - `src/guard.ts` — managed runtime and Promise boundary
307
339
  - `src/training-review-model.ts` — historical review status and cwd filtering
340
+ - `src/training-evaluation.ts` — offline correction metrics and threshold comparisons
308
341
  - `src/training-review-tui.tsx` — interactive OpenTUI training-review queue
309
342
  - `extensions/demur/` — Pi `tool_call` integration and training-state storage
310
343
  - `src/adapters/claude-code.ts` — Claude Code `PreToolUse` integration
311
344
  - `eval/` — safe synthetic contrast cases and the live evaluation runner
312
345
 
346
+ ## Training feedback evaluation
347
+
348
+ `demur training evaluate` joins each record with its latest review and replays
349
+ stored raw judgments through the shared `decide()` policy. By default it makes
350
+ no TypeSafe requests. The report includes a decision matrix, an asymmetric weighted loss
351
+ that penalizes unsafe false allows most heavily, correction counts grouped by
352
+ structured reason, replay fidelity, and up to five single-threshold candidates
353
+ that improve the observed records.
354
+
355
+ Candidates are exploratory and are never applied automatically. The command
356
+ scores them on the same private records used to discover them, so validate a
357
+ candidate on an independent holdout and the synthetic corpus before changing
358
+ `THRESHOLDS`. Newly captured version-two records include the exact model state
359
+ and static-gate analysis required for complete replay; legacy records support
360
+ policy-only replay.
361
+
362
+ Pass `--replay` explicitly to send reviewed version-two records' previously
363
+ captured model state to TypeSafe again using the current question set. This is a
364
+ cost-bearing operation and re-discloses the stored command and context described
365
+ above. It is capped at 20 requests by default; use `--limit=<1-100>` to choose a
366
+ different bound. Commands remain data and are never executed. The replay report shows
367
+ improvements, regressions, failures, token usage, and per-record raw judgments;
368
+ use it when changing one question at a time.
369
+
313
370
  ## Synthetic evaluation
314
371
 
315
372
  The synthetic evaluation measures whether the two policy-qualification questions
@@ -7,7 +7,11 @@ import {
7
7
  type ToolCallEvent,
8
8
  type ToolCallEventResult,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import type { Verdict } from "../../src/types.ts";
10
+ import type {
11
+ GuardEvaluation,
12
+ GuardEvidence,
13
+ Verdict,
14
+ } from "../../src/types.ts";
11
15
  import {
12
16
  estimateInputCostUsd,
13
17
  formatUsd,
@@ -54,9 +58,15 @@ export async function handleToolCall(
54
58
  if (command.trim() === "") return undefined;
55
59
 
56
60
  const evaluationStartedAt = performance.now();
57
- let verdict: Verdict;
61
+ let evaluation: GuardEvaluation;
58
62
  try {
59
- verdict = await runGuardWorker(command, ctx.cwd, ctx.signal);
63
+ evaluation = await runGuardWorkerRequest(
64
+ command,
65
+ ctx.cwd,
66
+ ctx.signal,
67
+ process.env,
68
+ settings.training,
69
+ );
60
70
  } catch (error: unknown) {
61
71
  const evaluationMs = performance.now() - evaluationStartedAt;
62
72
  if (ctx.signal?.aborted) {
@@ -74,16 +84,20 @@ export async function handleToolCall(
74
84
  };
75
85
  }
76
86
 
77
- verdict = {
78
- decision: "deny",
79
- reason: `demur: guard worker crashed — ${errorDetail(error)}`,
80
- judgments: undefined,
81
- failure: "unexpected",
82
- latencyMs: evaluationMs,
83
- usage: undefined,
87
+ evaluation = {
88
+ verdict: {
89
+ decision: "deny",
90
+ reason: `demur: guard worker crashed — ${errorDetail(error)}`,
91
+ judgments: undefined,
92
+ failure: "unexpected",
93
+ latencyMs: evaluationMs,
94
+ usage: undefined,
95
+ },
96
+ evidence: undefined,
84
97
  };
85
98
  }
86
99
 
100
+ const verdict = evaluation.verdict;
87
101
  const evaluationMs = performance.now() - evaluationStartedAt;
88
102
  const inputTokens = verdict.usage?.inputTokens;
89
103
  const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
@@ -109,6 +123,7 @@ export async function handleToolCall(
109
123
  ctx,
110
124
  settings.mode,
111
125
  verdict,
126
+ evaluation.evidence,
112
127
  result,
113
128
  );
114
129
  }
@@ -302,6 +317,18 @@ export function runGuardWorker(
302
317
  signal: AbortSignal | undefined,
303
318
  environment: NodeJS.ProcessEnv = process.env,
304
319
  ): Promise<Verdict> {
320
+ return runGuardWorkerRequest(command, cwd, signal, environment, false).then(
321
+ (evaluation) => evaluation.verdict,
322
+ );
323
+ }
324
+
325
+ function runGuardWorkerRequest(
326
+ command: string,
327
+ cwd: string,
328
+ signal: AbortSignal | undefined,
329
+ environment: NodeJS.ProcessEnv,
330
+ includeEvidence: boolean,
331
+ ): Promise<GuardEvaluation> {
305
332
  return new Promise((resolve, reject) => {
306
333
  if (signal?.aborted) {
307
334
  reject(new Error("guard request cancelled"));
@@ -333,7 +360,6 @@ export function runGuardWorker(
333
360
  signal?.addEventListener("abort", abort, { once: true });
334
361
  child.stdout.on("data", (chunk: Buffer) => {
335
362
  stdout = appendBounded(stdout, chunk);
336
- if (stdout.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
337
363
  });
338
364
  child.stderr.on("data", (chunk: Buffer) => {
339
365
  stderr = appendBounded(stderr, chunk);
@@ -349,7 +375,7 @@ export function runGuardWorker(
349
375
  }
350
376
 
351
377
  try {
352
- resolve(parseVerdict(stdout));
378
+ resolve(parseGuardWorkerResponse(stdout, includeEvidence));
353
379
  } catch (error: unknown) {
354
380
  reject(new Error(`invalid guard worker response: ${errorDetail(error)}`));
355
381
  }
@@ -357,7 +383,7 @@ export function runGuardWorker(
357
383
  });
358
384
 
359
385
  child.stdin.on("error", (error) => finish(() => reject(error)));
360
- child.stdin.end(JSON.stringify({ command, cwd }));
386
+ child.stdin.end(JSON.stringify({ command, cwd, includeEvidence }));
361
387
  });
362
388
  }
363
389
 
@@ -496,14 +522,22 @@ async function recordTrainingResult(
496
522
  ctx: ExtensionContext,
497
523
  mode: "enforce" | "passive",
498
524
  verdict: Verdict,
525
+ evidence: GuardEvidence | undefined,
499
526
  result: ToolCallEventResult | undefined,
500
527
  ): Promise<void> {
501
528
  try {
529
+ if (verdict.judgments !== undefined && evidence === undefined) {
530
+ ctx.ui.notify(
531
+ "demur: recording training evaluation without replay evidence",
532
+ "warning",
533
+ );
534
+ }
502
535
  await recordTrainingEvaluation({
503
536
  command,
504
537
  cwd: ctx.cwd,
505
538
  mode,
506
539
  verdict,
540
+ evidence,
507
541
  hostAction: result?.block === true ? "block" : "allow",
508
542
  });
509
543
  } catch (error: unknown) {
@@ -630,8 +664,36 @@ function formatDecimal(value: number, fractionDigits: number): string {
630
664
  .replace(/\.0+$/, "");
631
665
  }
632
666
 
633
- function parseVerdict(output: string): Verdict {
634
- const value: unknown = JSON.parse(output);
667
+ function parseGuardWorkerResponse(
668
+ output: string,
669
+ includeEvidence: boolean,
670
+ ): GuardEvaluation {
671
+ if (!includeEvidence) {
672
+ return {
673
+ verdict: parseVerdictValue(JSON.parse(output)),
674
+ evidence: undefined,
675
+ };
676
+ }
677
+
678
+ const separator = output.indexOf("\n");
679
+ if (separator < 0) {
680
+ throw new Error("training guard response did not contain a verdict line");
681
+ }
682
+
683
+ const verdict = parseVerdictValue(JSON.parse(output.slice(0, separator)));
684
+ const encodedEvidence = output.slice(separator + 1);
685
+ try {
686
+ return {
687
+ verdict,
688
+ evidence: parseGuardEvidence(JSON.parse(encodedEvidence)),
689
+ };
690
+ } catch {
691
+ // Training evidence is best-effort and must never change the guard verdict.
692
+ return { verdict, evidence: undefined };
693
+ }
694
+ }
695
+
696
+ function parseVerdictValue(value: unknown): Verdict {
635
697
  if (value === null || typeof value !== "object") {
636
698
  throw new Error("verdict must be an object");
637
699
  }
@@ -657,6 +719,28 @@ function parseVerdict(output: string): Verdict {
657
719
  };
658
720
  }
659
721
 
722
+ function parseGuardEvidence(value: unknown): GuardEvidence | undefined {
723
+ if (value === undefined || value === null) return undefined;
724
+ if (typeof value !== "object") {
725
+ throw new Error("guard evidence must be an object");
726
+ }
727
+
728
+ const evidence = value as Record<string, unknown>;
729
+ if (
730
+ evidence.modelState === null ||
731
+ typeof evidence.modelState !== "object" ||
732
+ typeof evidence.model !== "string" ||
733
+ !Number.isInteger(evidence.questionSetVersion) ||
734
+ !Number.isInteger(evidence.policyVersion) ||
735
+ evidence.policyThresholds === null ||
736
+ typeof evidence.policyThresholds !== "object"
737
+ ) {
738
+ throw new Error("guard evidence is incomplete");
739
+ }
740
+
741
+ return evidence as GuardEvidence;
742
+ }
743
+
660
744
  function errorDetail(error: unknown): string {
661
745
  return error instanceof Error ? error.message : String(error);
662
746
  }