@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.
@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import type { Decision, Verdict } from "../../src/types.ts";
5
+ import type {
6
+ Decision,
7
+ GuardEvidence,
8
+ Verdict,
9
+ } from "../../src/types.ts";
6
10
  import { getDemurStateDirectory } from "./paths.ts";
7
11
 
8
12
  const LOCK_RETRY_MS = 10;
@@ -19,9 +23,9 @@ export type TrainingMode = "enforce" | "passive";
19
23
  export type TrainingHostAction = "allow" | "block";
20
24
 
21
25
  /**
22
- * Complete evidence captured for one training-mode command evaluation.
26
+ * Legacy training record captured before replayable invocation evidence existed.
23
27
  */
24
- export type TrainingRecord = {
28
+ export type TrainingRecordV1 = {
25
29
  version: 1;
26
30
  id: string;
27
31
  recordedAt: string;
@@ -33,9 +37,60 @@ export type TrainingRecord = {
33
37
  };
34
38
 
35
39
  /**
36
- * Human review of one captured training evaluation.
40
+ * Training record with exact model state and policy provenance.
37
41
  */
38
- export type TrainingReview = {
42
+ export type TrainingRecordV2 = {
43
+ version: 2;
44
+ id: string;
45
+ recordedAt: string;
46
+ command: string;
47
+ cwd: string;
48
+ mode: TrainingMode;
49
+ verdict: Verdict;
50
+ evidence: GuardEvidence | undefined;
51
+ hostAction: TrainingHostAction;
52
+ };
53
+
54
+ /**
55
+ * Complete evidence captured for one training-mode command evaluation.
56
+ */
57
+ export type TrainingRecord = TrainingRecordV1 | TrainingRecordV2;
58
+
59
+ /**
60
+ * Machine-readable explanation for why a human corrected a verdict.
61
+ */
62
+ export type TrainingCorrectionReason =
63
+ | "inert-or-read-only"
64
+ | "sensitive-data"
65
+ | "security-boundary"
66
+ | "recoverability"
67
+ | "shared-infrastructure"
68
+ | "blast-radius"
69
+ | "static-uncertainty"
70
+ | "missing-context"
71
+ | "service-failure";
72
+
73
+ /**
74
+ * Stable correction-reason values accepted by storage and CLI boundaries.
75
+ */
76
+ export const TRAINING_CORRECTION_REASONS: ReadonlyArray<
77
+ TrainingCorrectionReason
78
+ > = [
79
+ "inert-or-read-only",
80
+ "sensitive-data",
81
+ "security-boundary",
82
+ "recoverability",
83
+ "shared-infrastructure",
84
+ "blast-radius",
85
+ "static-uncertainty",
86
+ "missing-context",
87
+ "service-failure",
88
+ ];
89
+
90
+ /**
91
+ * Legacy human review recorded before structured correction reasons existed.
92
+ */
93
+ export type TrainingReviewV1 = {
39
94
  version: 1;
40
95
  recordId: string;
41
96
  reviewedAt: string;
@@ -44,6 +99,24 @@ export type TrainingReview = {
44
99
  note: string | undefined;
45
100
  };
46
101
 
102
+ /**
103
+ * Human review with a structured reason for corrected model decisions.
104
+ */
105
+ export type TrainingReviewV2 = {
106
+ version: 2;
107
+ recordId: string;
108
+ reviewedAt: string;
109
+ originalDecision: Decision;
110
+ expectedDecision: Decision;
111
+ correctionReason: TrainingCorrectionReason | undefined;
112
+ note: string | undefined;
113
+ };
114
+
115
+ /**
116
+ * Human review of one captured training evaluation.
117
+ */
118
+ export type TrainingReview = TrainingReviewV1 | TrainingReviewV2;
119
+
47
120
  /**
48
121
  * Input required to append a human review.
49
122
  */
@@ -51,9 +124,18 @@ export type TrainingReviewInput = {
51
124
  recordId: string;
52
125
  originalDecision: Decision;
53
126
  expectedDecision: Decision;
127
+ correctionReason: TrainingCorrectionReason | undefined;
54
128
  note: string | undefined;
55
129
  };
56
130
 
131
+ /**
132
+ * Input required to append a replayable training evaluation.
133
+ */
134
+ export type TrainingRecordInput = Omit<
135
+ TrainingRecordV2,
136
+ "version" | "id" | "recordedAt"
137
+ >;
138
+
57
139
  /**
58
140
  * Resolve the global training-record file according to demur and XDG overrides.
59
141
  *
@@ -96,11 +178,11 @@ export function getTrainingReviewPath(
96
178
  * @returns The persisted record with generated identity and timestamp
97
179
  */
98
180
  export async function recordTrainingEvaluation(
99
- input: Omit<TrainingRecord, "version" | "id" | "recordedAt">,
181
+ input: TrainingRecordInput,
100
182
  logPath: string = getTrainingLogPath(),
101
- ): Promise<TrainingRecord> {
102
- const record: TrainingRecord = {
103
- version: 1,
183
+ ): Promise<TrainingRecordV2> {
184
+ const record: TrainingRecordV2 = {
185
+ version: 2,
104
186
  id: randomUUID(),
105
187
  recordedAt: new Date().toISOString(),
106
188
  ...input,
@@ -131,9 +213,22 @@ export async function loadTrainingRecords(
131
213
  export async function recordTrainingReview(
132
214
  input: TrainingReviewInput,
133
215
  reviewPath: string = getTrainingReviewPath(),
134
- ): Promise<TrainingReview> {
135
- const review: TrainingReview = {
136
- version: 1,
216
+ ): Promise<TrainingReviewV2> {
217
+ if (
218
+ input.originalDecision !== input.expectedDecision &&
219
+ input.correctionReason === undefined
220
+ ) {
221
+ throw new Error("corrected training reviews require a correction reason");
222
+ }
223
+ if (
224
+ input.originalDecision === input.expectedDecision &&
225
+ input.correctionReason !== undefined
226
+ ) {
227
+ throw new Error("accepted training reviews cannot have a correction reason");
228
+ }
229
+
230
+ const review: TrainingReviewV2 = {
231
+ version: 2,
137
232
  reviewedAt: new Date().toISOString(),
138
233
  ...input,
139
234
  };
@@ -213,41 +308,37 @@ function parseTrainingRecord(
213
308
 
214
309
  const record = value as Record<string, unknown>;
215
310
  if (
216
- record.version !== 1 ||
311
+ (record.version !== 1 && record.version !== 2) ||
217
312
  typeof record.id !== "string" ||
218
313
  typeof record.recordedAt !== "string" ||
219
314
  typeof record.command !== "string" ||
220
315
  typeof record.cwd !== "string" ||
221
316
  (record.mode !== "enforce" && record.mode !== "passive") ||
222
317
  (record.hostAction !== "allow" && record.hostAction !== "block") ||
223
- !isVerdict(record.verdict)
318
+ !isVerdict(record.verdict) ||
319
+ (record.version === 2 && !isGuardEvidence(record.evidence))
224
320
  ) {
225
321
  throw invalidRecord(path, line);
226
322
  }
227
323
 
228
- const verdict = record.verdict as Record<string, unknown>;
229
- return {
230
- version: 1,
324
+ const verdict = normalizeVerdict(record.verdict);
325
+ const common = {
231
326
  id: record.id,
232
327
  recordedAt: record.recordedAt,
233
328
  command: record.command,
234
329
  cwd: record.cwd,
235
330
  mode: record.mode,
236
- verdict: {
237
- decision: verdict.decision as Decision,
238
- reason: verdict.reason as string,
239
- judgments: verdict.judgments === null
240
- ? undefined
241
- : verdict.judgments as Verdict["judgments"],
242
- failure: verdict.failure === null
243
- ? undefined
244
- : verdict.failure as Verdict["failure"],
245
- latencyMs: verdict.latencyMs as number,
246
- usage: verdict.usage === null
247
- ? undefined
248
- : verdict.usage as Verdict["usage"],
249
- },
331
+ verdict,
250
332
  hostAction: record.hostAction,
333
+ } as const;
334
+
335
+ if (record.version === 1) return { version: 1, ...common };
336
+ return {
337
+ version: 2,
338
+ ...common,
339
+ evidence: record.evidence === null
340
+ ? undefined
341
+ : normalizeGuardEvidence(record.evidence),
251
342
  };
252
343
  }
253
344
 
@@ -262,25 +353,55 @@ function parseTrainingReview(
262
353
 
263
354
  const review = value as Record<string, unknown>;
264
355
  if (
265
- review.version !== 1 ||
356
+ (review.version !== 1 && review.version !== 2) ||
266
357
  typeof review.recordId !== "string" ||
267
358
  typeof review.reviewedAt !== "string" ||
268
359
  !isDecision(review.originalDecision) ||
269
360
  !isDecision(review.expectedDecision) ||
270
361
  (review.note !== undefined &&
271
362
  review.note !== null &&
272
- typeof review.note !== "string")
363
+ typeof review.note !== "string") ||
364
+ (review.version === 2 &&
365
+ (!isReviewCorrectionReasonValid(review) ||
366
+ (review.correctionReason !== undefined &&
367
+ review.correctionReason !== null &&
368
+ !isTrainingCorrectionReason(review.correctionReason))))
273
369
  ) {
274
370
  throw invalidRecord(path, line);
275
371
  }
276
372
 
277
- return {
278
- version: 1,
373
+ const common = {
279
374
  recordId: review.recordId,
280
375
  reviewedAt: review.reviewedAt,
281
376
  originalDecision: review.originalDecision,
282
377
  expectedDecision: review.expectedDecision,
283
378
  note: review.note === null ? undefined : review.note,
379
+ } as const;
380
+ if (review.version === 1) return { version: 1, ...common };
381
+ return {
382
+ version: 2,
383
+ ...common,
384
+ correctionReason: review.correctionReason === null
385
+ ? undefined
386
+ : review.correctionReason as TrainingCorrectionReason | undefined,
387
+ };
388
+ }
389
+
390
+ function normalizeVerdict(value: unknown): Verdict {
391
+ const verdict = value as Record<string, unknown>;
392
+ return {
393
+ decision: verdict.decision as Decision,
394
+ reason: verdict.reason as string,
395
+ judgments: verdict.judgments === null
396
+ ? undefined
397
+ : verdict.judgments as Verdict["judgments"],
398
+ failure: verdict.failure === null
399
+ ? undefined
400
+ : verdict.failure as Verdict["failure"],
401
+ latencyMs: verdict.latencyMs as number,
402
+ usage: verdict.usage === null
403
+ ? undefined
404
+ : verdict.usage as Verdict["usage"],
284
405
  };
285
406
  }
286
407
 
@@ -296,6 +417,63 @@ function isDecision(value: unknown): value is Decision {
296
417
  return value === "allow" || value === "ask" || value === "deny";
297
418
  }
298
419
 
420
+ function isReviewCorrectionReasonValid(
421
+ review: Record<string, unknown>,
422
+ ): boolean {
423
+ const corrected = review.originalDecision !== review.expectedDecision;
424
+ const hasReason = review.correctionReason !== undefined &&
425
+ review.correctionReason !== null;
426
+ return corrected === hasReason;
427
+ }
428
+
429
+ /**
430
+ * Check whether a value is a supported structured correction reason.
431
+ *
432
+ * @param value - Candidate correction-reason value
433
+ * @returns Whether the value belongs to the stable correction taxonomy
434
+ */
435
+ export function isTrainingCorrectionReason(
436
+ value: unknown,
437
+ ): value is TrainingCorrectionReason {
438
+ return typeof value === "string" &&
439
+ TRAINING_CORRECTION_REASONS.includes(value as TrainingCorrectionReason);
440
+ }
441
+
442
+ function isGuardEvidence(value: unknown): boolean {
443
+ if (value === null || value === undefined) return true;
444
+ if (typeof value !== "object") return false;
445
+ const evidence = value as Record<string, unknown>;
446
+ return evidence.modelState !== null &&
447
+ typeof evidence.modelState === "object" &&
448
+ (evidence.analysis === null || evidence.analysis === undefined ||
449
+ typeof evidence.analysis === "object") &&
450
+ typeof evidence.model === "string" &&
451
+ Number.isInteger(evidence.questionSetVersion) &&
452
+ Number.isInteger(evidence.policyVersion) &&
453
+ isNumberRecord(evidence.policyThresholds);
454
+ }
455
+
456
+ function normalizeGuardEvidence(value: unknown): GuardEvidence {
457
+ const evidence = value as Record<string, unknown>;
458
+ return {
459
+ modelState: evidence.modelState as GuardEvidence["modelState"],
460
+ analysis: evidence.analysis === null
461
+ ? undefined
462
+ : evidence.analysis as GuardEvidence["analysis"],
463
+ model: evidence.model as string,
464
+ questionSetVersion: evidence.questionSetVersion as number,
465
+ policyVersion: evidence.policyVersion as number,
466
+ policyThresholds: evidence.policyThresholds as GuardEvidence["policyThresholds"],
467
+ };
468
+ }
469
+
470
+ function isNumberRecord(value: unknown): boolean {
471
+ if (value === null || typeof value !== "object") return false;
472
+ return Object.values(value).every((entry) =>
473
+ typeof entry === "number" && Number.isFinite(entry)
474
+ );
475
+ }
476
+
299
477
  function invalidRecord(path: string, line: number): Error {
300
478
  return new Error(`invalid training record in ${path} at line ${line}`);
301
479
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyattjoh/demur",
3
- "version": "0.5.0",
3
+ "version": "0.7.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,8 @@
38
38
  "src/policy.ts",
39
39
  "src/questions.ts",
40
40
  "src/state.ts",
41
+ "src/settings-model.ts",
42
+ "src/training-evaluation.ts",
41
43
  "src/training-review-model.ts",
42
44
  "src/training-review-tui.tsx",
43
45
  "src/types.ts"
@@ -51,7 +53,7 @@
51
53
  },
52
54
  "scripts": {
53
55
  "check": "tsc --noEmit",
54
- "test": "vitest run",
56
+ "test": "vitest run && bun test ./src/training-review-tui.bun.tsx",
55
57
  "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
58
  "build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
57
59
  "build": "bun run build:pi && bun run build:claude",
@@ -1,14 +1,22 @@
1
1
  #!/usr/bin/env bun
2
- import { guard } from "../guard.ts";
2
+ import { guard, guardWithEvidence } from "../guard.ts";
3
3
 
4
4
  type GuardRequest = {
5
5
  command: string;
6
6
  cwd: string;
7
+ includeEvidence: boolean;
7
8
  };
8
9
 
9
10
  const request = parseRequest(await Bun.stdin.text());
10
- const verdict = await guard(request.command, request.cwd, "pi");
11
- process.stdout.write(JSON.stringify(verdict));
11
+ if (request.includeEvidence) {
12
+ const evaluation = await guardWithEvidence(request.command, request.cwd, "pi");
13
+ process.stdout.write(`${JSON.stringify(evaluation.verdict)}\n`);
14
+ process.stdout.write(JSON.stringify(evaluation.evidence ?? null));
15
+ } else {
16
+ process.stdout.write(
17
+ JSON.stringify(await guard(request.command, request.cwd, "pi")),
18
+ );
19
+ }
12
20
 
13
21
  function parseRequest(input: string): GuardRequest {
14
22
  const value: unknown = JSON.parse(input);
@@ -16,10 +24,16 @@ function parseRequest(input: string): GuardRequest {
16
24
  throw new Error("guard request must be an object");
17
25
  }
18
26
 
19
- const { command, cwd } = value as Record<string, unknown>;
20
- if (typeof command !== "string" || typeof cwd !== "string") {
21
- throw new Error("guard request must contain string command and cwd fields");
27
+ const { command, cwd, includeEvidence } = value as Record<string, unknown>;
28
+ if (
29
+ typeof command !== "string" ||
30
+ typeof cwd !== "string" ||
31
+ (includeEvidence !== undefined && typeof includeEvidence !== "boolean")
32
+ ) {
33
+ throw new Error(
34
+ "guard request must contain string command and cwd fields and an optional boolean includeEvidence field",
35
+ );
22
36
  }
23
37
 
24
- return { command, cwd };
38
+ return { command, cwd, includeEvidence: includeEvidence === true };
25
39
  }