@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 +82 -25
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +4 -2
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +554 -135
- package/src/guard.internal.ts +83 -33
- package/src/guard.ts +42 -2
- package/src/judge.ts +53 -7
- package/src/policy.ts +10 -1
- package/src/questions.ts +9 -1
- package/src/settings-model.ts +60 -0
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +18 -2
- package/src/training-review-tui.tsx +408 -44
- package/src/types.ts +56 -0
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,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
170
|
-
|
|
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
|
-
|
|
222
|
-
demur training
|
|
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.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 {
|
|
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
|
|
61
|
+
let evaluation: GuardEvaluation;
|
|
58
62
|
try {
|
|
59
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
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
|
|
634
|
-
|
|
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
|
}
|