@wyattjoh/demur 0.4.2 → 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
@@ -48,6 +48,12 @@ 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.
56
+
51
57
  ## Requirements
52
58
 
53
59
  - [Bun](https://bun.sh/) 1.4 or newer
@@ -92,7 +98,7 @@ Pin a specific release when reproducibility matters:
92
98
 
93
99
  <!-- x-release-please-start-version -->
94
100
  ```sh
95
- pi install npm:@wyattjoh/demur@0.4.2
101
+ pi install npm:@wyattjoh/demur@0.6.0
96
102
  ```
97
103
  <!-- x-release-please-end -->
98
104
 
@@ -109,23 +115,34 @@ command request and resulting verdict cross its local stdio pipes. `ask` opens
109
115
  an interactive confirmation dialog; without an interactive UI, demur blocks the
110
116
  command.
111
117
 
112
- Use `/demur` to open the extension menu. It can enable or disable demur and
113
- change what Pi does when demur cannot obtain a trustworthy judgment because of
114
- a missing credential, timeout, API error, malformed worker response, or
115
- unexpected guard failure:
118
+ Use `/demur` to open the extension menu. Its global operating mode is:
119
+
120
+ - `enforce` (default) applies `allow`, `ask`, and `deny` decisions normally.
121
+ - `passive` still judges every Bash call and prints the diagnostic, but never
122
+ prompts or blocks because of the verdict.
123
+ - `disabled` bypasses the worker and allows Bash calls without judgment.
124
+
125
+ Training capture can be enabled independently in `enforce` or `passive` mode.
126
+ It is automatically turned off when the integration is disabled. Pi's bottom
127
+ status bar always shows the current mode and whether training is active so every
128
+ bypass or local recording state remains visible.
129
+
130
+ The menu also controls what enforce mode does when demur cannot obtain a
131
+ trustworthy judgment because of a missing credential, timeout, API error,
132
+ malformed worker response, or unexpected guard failure:
116
133
 
117
134
  - `block` (default) fails closed.
118
135
  - `ask` requests interactive confirmation and blocks when no UI is available.
119
136
  - `allow` fails open without confirmation.
120
137
 
121
- Disabling demur bypasses the worker and allows Bash calls without judgment. Pi's
122
- bottom status bar always shows `demur: enabled` or `demur: disabled` so this
123
- bypass remains visible.
124
-
125
- Both settings are stored globally at `$XDG_CONFIG_HOME/demur/config.json`, or
138
+ Settings are stored globally at `$XDG_CONFIG_HOME/demur/config.json`, or
126
139
  `~/.config/demur/config.json` when `XDG_CONFIG_HOME` is unset, and apply to
127
- future Pi sessions. While demur is enabled, the failure policy never changes a
128
- completed `deny` policy judgment; those commands remain blocked.
140
+ future Pi sessions. Set `DEMUR_CONFIG_HOME` to use an isolated demur directory;
141
+ `config.json` is read and written directly beneath it. This demur-specific
142
+ override takes precedence over the XDG and home-directory locations. While
143
+ demur is enforcing, the failure policy never changes a completed `deny` policy
144
+ judgment; those commands remain blocked. Passive mode reports failures but does
145
+ not apply the failure policy because it never blocks.
129
146
 
130
147
  After each run, Pi's interactive UI prints the decision, submitted input-token
131
148
  count, the run's estimated input cost, the accumulated global estimate, and the
@@ -142,6 +159,16 @@ file before an atomic rename so the total cannot be partially written or lose a
142
159
  concurrent increment. Cost-accounting failures do not change demur's guard
143
160
  decision; the status reports `accumulated unavailable` instead.
144
161
 
162
+ Training evaluations are appended as private, versioned JSONL records at
163
+ `$XDG_STATE_HOME/demur/training.jsonl`, with the same home-directory fallback.
164
+ Set `DEMUR_STATE_HOME` to place `usage.json`, `training.jsonl`, and
165
+ `training-reviews.jsonl` directly beneath an isolated directory instead. This
166
+ demur-specific override takes precedence over the XDG and home-directory
167
+ locations. Training records are retained until the user removes them. Human
168
+ 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.
171
+
145
172
  Pi packages execute with the user's full system permissions. Review this
146
173
  repository before installing it.
147
174
 
@@ -182,16 +209,63 @@ Claude Code's `hookSpecificOutput.permissionDecision` response.
182
209
 
183
210
  ### CLI
184
211
 
185
- Manage the stored credential or judge a single command without a host
186
- integration:
212
+ Launch the central interface, manage the stored credential, or judge a single
213
+ command without a host integration:
187
214
 
188
215
  ```sh
216
+ demur
189
217
  demur auth login
190
218
  demur auth status
191
219
  demur auth logout
220
+ demur training review
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
192
224
  demur judge "git reset --hard HEAD~3"
193
225
  ```
194
226
 
227
+ Bare `demur` opens the central OpenTUI interface when stdin and stdout are
228
+ interactive. `demur training review` remains an explicit alias for the same
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.
247
+ The detail pane supports arrows or `j`/`k`; Left returns to the queue. Page Up
248
+ and Page Down page within the focused pane, and queue navigation stops at its
249
+ first and last entries. Mouse clicks select tabs, records, the filter, or either
250
+ pane; the wheel scrolls the queue and detail pane. Enter selects the original
251
+ decision, `1`/`2`/`3` choose `allow`/`ask`/`deny`, `s`
252
+ leaves a record for a later pass, and `q` or Escape stops. Previously reviewed
253
+ records remain available, and changing an answer appends a review revision while
254
+ preserving its visible history. The TUI remains open when a view is empty and
255
+ polls training state for newly captured or externally reviewed evaluations.
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.
268
+
195
269
  From a development checkout, `bun run judge "<command>"` remains available.
196
270
 
197
271
  ## Configuration
@@ -200,6 +274,8 @@ From a development checkout, `bun run judge "<command>"` remains available.
200
274
  | --- | --- | --- |
201
275
  | `TYPESAFE_API_KEY` | stored credential | Optional TypeSafe API credential override. Missing keys fail closed. |
202
276
  | `DEMUR_TIMEOUT_MS` | `4000` | Per-attempt model timeout in milliseconds. |
277
+ | `DEMUR_CONFIG_HOME` | XDG/home config | Demur-specific directory containing `config.json`. |
278
+ | `DEMUR_STATE_HOME` | XDG/home state | Demur-specific directory containing usage and training state. |
203
279
  | `DEMUR_DISABLE` | unset | Emergency bypass. `1` or `true` allows every command. |
204
280
 
205
281
  ## Failure posture
@@ -209,12 +285,12 @@ timeout, API failure, malformed response, or unexpected guard error returns
209
285
  `deny` with a reason that identifies the guard failure rather than presenting it
210
286
  as a policy judgment. The Claude Code adapter and CLI preserve that verdict.
211
287
 
212
- The Pi extension defaults to enabled with the same fail-closed behavior, but
213
- its explicit `/demur` menu can globally change how Pi handles guard failures or
214
- disable the extension entirely. The failure-policy override applies only when
215
- no trustworthy judgment was produced; it cannot loosen a completed policy
216
- denial while demur is enabled. The bottom status bar makes the enabled state
217
- visible.
288
+ The Pi extension defaults to enforce mode with the same fail-closed behavior,
289
+ but its explicit `/demur` menu can globally select enforce, passive, or disabled
290
+ mode. The failure-policy override applies only in enforce mode when no
291
+ trustworthy judgment was produced; it cannot loosen a completed policy denial.
292
+ Passive mode always continues after reporting the underlying verdict, while the
293
+ bottom status bar keeps the active mode and training state visible.
218
294
 
219
295
  `DEMUR_DISABLE=1` remains the cross-host emergency bypass. It disables judgment
220
296
  and protection entirely and should remain unset during normal use.
@@ -248,7 +324,9 @@ and deterministic policy controls alongside demur.
248
324
  - `src/key.ts` — environment precedence and operating-system credential storage
249
325
  - `src/guard.internal.ts` — Effect-native orchestration and fail-closed recovery
250
326
  - `src/guard.ts` — managed runtime and Promise boundary
251
- - `extensions/demur/`Pi `tool_call` integration
327
+ - `src/training-review-model.ts`historical review status and cwd filtering
328
+ - `src/training-review-tui.tsx` — interactive OpenTUI training-review queue
329
+ - `extensions/demur/` — Pi `tool_call` integration and training-state storage
252
330
  - `src/adapters/claude-code.ts` — Claude Code `PreToolUse` integration
253
331
  - `eval/` — safe synthetic contrast cases and the live evaluation runner
254
332
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "node:fs/promises";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
+ import { getDemurStateDirectory } from "./paths.ts";
12
13
 
13
14
  const LOCK_RETRY_MS = 10;
14
15
  const LOCK_TIMEOUT_MS = 5_000;
@@ -31,9 +32,9 @@ export type CostTotals = {
31
32
  };
32
33
 
33
34
  /**
34
- * Resolve the global demur usage file according to the XDG state convention.
35
+ * Resolve the global demur usage file using demur-specific and XDG conventions.
35
36
  *
36
- * @param environment - Process environment used to resolve `XDG_STATE_HOME`
37
+ * @param environment - Process environment used to resolve demur and XDG overrides
37
38
  * @param homeDirectory - Home directory used when the XDG override is absent
38
39
  * @returns Absolute path to demur's usage state file
39
40
  */
@@ -41,8 +42,10 @@ export function getCostStatePath(
41
42
  environment: NodeJS.ProcessEnv = process.env,
42
43
  homeDirectory: string = homedir(),
43
44
  ): string {
44
- const stateDirectory = environment.XDG_STATE_HOME || join(homeDirectory, ".local", "state");
45
- return join(stateDirectory, "demur", "usage.json");
45
+ return join(
46
+ getDemurStateDirectory(environment, homeDirectory),
47
+ "usage.json",
48
+ );
46
49
  }
47
50
 
48
51
  /**
@@ -57,6 +60,17 @@ export function estimateInputCostUsd(inputTokens: number): number {
57
60
  return (inputTokens * JEV_INPUT_COST_USD_PER_MILLION) / 1_000_000;
58
61
  }
59
62
 
63
+ /**
64
+ * Format an estimated US-dollar cost without hiding sub-cent evaluations.
65
+ *
66
+ * @param value - Estimated cost in US dollars
67
+ * @returns Dollar-prefixed cost with up to nine fractional digits
68
+ */
69
+ export function formatUsd(value: number): string {
70
+ const decimal = value.toFixed(9).replace(/0+$/, "").replace(/\.$/, "");
71
+ return `$${decimal}`;
72
+ }
73
+
60
74
  /**
61
75
  * Atomically add one judgment's usage to the global accumulated estimate.
62
76
  *
@@ -80,7 +94,7 @@ export async function recordInputCost(
80
94
  const release = await acquireLock(`${statePath}.lock`);
81
95
 
82
96
  try {
83
- const current = await readTotals(statePath);
97
+ const current = await loadCostTotals(statePath);
84
98
  const next: CostTotals = {
85
99
  version: 1,
86
100
  totalInputTokens: current.totalInputTokens + inputTokens,
@@ -114,7 +128,18 @@ async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
114
128
  }
115
129
  }
116
130
 
117
- async function readTotals(statePath: string): Promise<CostTotals> {
131
+ /**
132
+ * Load the persisted global usage and estimated-cost totals.
133
+ *
134
+ * A missing state file represents zero recorded usage. Invalid state fails
135
+ * rather than returning a misleading total.
136
+ *
137
+ * @param statePath - Usage file to read
138
+ * @returns Persisted totals, or zero totals when the file does not exist
139
+ */
140
+ export async function loadCostTotals(
141
+ statePath: string = getCostStatePath(),
142
+ ): Promise<CostTotals> {
118
143
  let content: string;
119
144
  try {
120
145
  content = await readFile(statePath, "utf8");
@@ -10,17 +10,21 @@ import {
10
10
  import type { Verdict } from "../../src/types.ts";
11
11
  import {
12
12
  estimateInputCostUsd,
13
+ formatUsd,
13
14
  recordInputCost,
14
15
  } from "./cost-tracker.ts";
15
16
  import {
16
17
  DEFAULT_DEMUR_SETTINGS,
18
+ DEMUR_MODES,
17
19
  FAILURE_POLICIES,
18
20
  loadDemurSettings,
21
+ parseDemurMode,
19
22
  parseFailurePolicy,
20
23
  saveDemurSettings,
21
24
  type DemurSettings,
22
25
  type FailurePolicy,
23
26
  } from "./settings.ts";
27
+ import { recordTrainingEvaluation } from "./training-store.ts";
24
28
 
25
29
  const WORKER_PATH = fileURLToPath(
26
30
  new URL("../../src/adapters/pi-worker.ts", import.meta.url),
@@ -44,7 +48,7 @@ export async function handleToolCall(
44
48
  settings: DemurSettings = DEFAULT_DEMUR_SETTINGS,
45
49
  ): Promise<ToolCallEventResult | undefined> {
46
50
  if (!isToolCallEventType("bash", event)) return undefined;
47
- if (!settings.enabled) return undefined;
51
+ if (settings.mode === "disabled") return undefined;
48
52
 
49
53
  const command = event.input.command ?? "";
50
54
  if (command.trim() === "") return undefined;
@@ -70,28 +74,45 @@ export async function handleToolCall(
70
74
  };
71
75
  }
72
76
 
73
- return handleGuardFailure(
74
- `demur: guard worker crashed — ${errorDetail(error)}`,
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,
84
+ };
85
+ }
86
+
87
+ const evaluationMs = performance.now() - evaluationStartedAt;
88
+ const inputTokens = verdict.usage?.inputTokens;
89
+ const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
90
+ const result = settings.mode === "passive"
91
+ ? resolvePassiveVerdict(
92
+ verdict,
93
+ ctx,
94
+ accumulatedCostUsd,
95
+ evaluationMs,
96
+ )
97
+ : await resolveVerdict(
98
+ verdict,
75
99
  command,
76
100
  ctx,
77
101
  settings.failurePolicy,
78
- undefined,
79
- undefined,
102
+ accumulatedCostUsd,
80
103
  evaluationMs,
81
104
  );
82
- }
83
105
 
84
- const evaluationMs = performance.now() - evaluationStartedAt;
85
- const inputTokens = verdict.usage?.inputTokens;
86
- const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
87
- return resolveVerdict(
88
- verdict,
89
- command,
90
- ctx,
91
- settings.failurePolicy,
92
- accumulatedCostUsd,
93
- evaluationMs,
94
- );
106
+ if (settings.training) {
107
+ await recordTrainingResult(
108
+ command,
109
+ ctx,
110
+ settings.mode,
111
+ verdict,
112
+ result,
113
+ );
114
+ }
115
+ return result;
95
116
  }
96
117
 
97
118
  /**
@@ -185,6 +206,40 @@ export async function resolveVerdict(
185
206
  return { block: true, reason: `${verdict.reason} Declined by the user.` };
186
207
  }
187
208
 
209
+ /**
210
+ * Report a completed verdict without allowing it to affect execution.
211
+ *
212
+ * Passive mode never prompts and never returns a block result. Failures remain
213
+ * visible as failures rather than being mapped through the enforcement-only
214
+ * failure policy.
215
+ *
216
+ * @param verdict - Completed demur guard result
217
+ * @param ctx - Pi extension context used for notifications
218
+ * @param accumulatedCostUsd - Persisted global estimate after this run
219
+ * @param evaluationMs - Wall-clock time spent obtaining the guard verdict
220
+ * @returns Nothing so Pi continues with the command
221
+ */
222
+ export function resolvePassiveVerdict(
223
+ verdict: Verdict,
224
+ ctx: ExtensionContext,
225
+ accumulatedCostUsd: number | undefined,
226
+ evaluationMs: number,
227
+ ): undefined {
228
+ const source = verdict.failure === undefined
229
+ ? verdict.decision.toUpperCase()
230
+ : "FAILURE";
231
+ const result = `PASSIVE: ${source} · NOT ENFORCED`;
232
+ notifyRun(
233
+ ctx,
234
+ result,
235
+ verdict.usage?.inputTokens,
236
+ accumulatedCostUsd,
237
+ evaluationMs,
238
+ source === "ALLOW" ? "info" : "warning",
239
+ );
240
+ return undefined;
241
+ }
242
+
188
243
  /**
189
244
  * Format the compact status Pi prints after each demur run.
190
245
  *
@@ -326,14 +381,40 @@ export default function demur(pi: ExtensionAPI): void {
326
381
  }
327
382
 
328
383
  await refreshSettings(ctx);
329
- const toggleLabel = settings.enabled ? "Disable demur" : "Enable demur";
384
+ const modeLabel = `Change mode (current: ${settings.mode})`;
385
+ const trainingLabel = settings.training
386
+ ? "Disable training capture"
387
+ : "Enable training capture";
330
388
  const policyLabel = `Change failure policy (current: ${settings.failurePolicy})`;
331
- const action = await ctx.ui.select("demur", [toggleLabel, policyLabel]);
389
+ const actions = settings.mode === "disabled"
390
+ ? [modeLabel, policyLabel]
391
+ : [modeLabel, trainingLabel, policyLabel];
392
+ const action = await ctx.ui.select("demur", actions);
332
393
  if (action === undefined) return;
333
394
 
334
- if (action === toggleLabel) {
395
+ if (action === modeLabel) {
396
+ const selection = await ctx.ui.select(
397
+ `demur mode (current: ${settings.mode})`,
398
+ [...DEMUR_MODES],
399
+ );
400
+ if (selection === undefined) return;
401
+
402
+ const mode = parseDemurMode(selection);
403
+ if (mode === undefined) return;
404
+ await persistSettings(
405
+ {
406
+ ...settings,
407
+ mode,
408
+ training: mode === "disabled" ? false : settings.training,
409
+ },
410
+ ctx,
411
+ );
412
+ return;
413
+ }
414
+
415
+ if (action === trainingLabel) {
335
416
  await persistSettings(
336
- { ...settings, enabled: !settings.enabled },
417
+ { ...settings, training: !settings.training },
337
418
  ctx,
338
419
  );
339
420
  return;
@@ -366,7 +447,7 @@ export default function demur(pi: ExtensionAPI): void {
366
447
  } catch (error: unknown) {
367
448
  settings = { ...DEFAULT_DEMUR_SETTINGS };
368
449
  ctx.ui.notify(
369
- `Could not load demur settings; using enabled/block: ${errorDetail(error)}`,
450
+ `Could not load demur settings; using enforce/block with training off: ${errorDetail(error)}`,
370
451
  "warning",
371
452
  );
372
453
  }
@@ -395,14 +476,42 @@ function updateStatus(
395
476
  ctx: ExtensionContext,
396
477
  settings: DemurSettings,
397
478
  ): void {
398
- const status = settings.enabled ? "enabled" : "disabled";
399
- const color = settings.enabled ? "success" : "warning";
400
- ctx.ui.setStatus("demur", ctx.ui.theme.fg(color, `demur: ${status}`));
479
+ const training = settings.training ? " + training" : "";
480
+ const color = settings.mode === "enforce" && !settings.training
481
+ ? "success"
482
+ : "warning";
483
+ ctx.ui.setStatus(
484
+ "demur",
485
+ ctx.ui.theme.fg(color, `demur: ${settings.mode}${training}`),
486
+ );
401
487
  }
402
488
 
403
489
  function settingsNotification(settings: DemurSettings): string {
404
- const status = settings.enabled ? "enabled" : "disabled";
405
- return `demur ${status} globally; failure policy: ${settings.failurePolicy}.`;
490
+ const training = settings.training ? "on" : "off";
491
+ return `demur mode: ${settings.mode}; training: ${training}; failure policy: ${settings.failurePolicy}.`;
492
+ }
493
+
494
+ async function recordTrainingResult(
495
+ command: string,
496
+ ctx: ExtensionContext,
497
+ mode: "enforce" | "passive",
498
+ verdict: Verdict,
499
+ result: ToolCallEventResult | undefined,
500
+ ): Promise<void> {
501
+ try {
502
+ await recordTrainingEvaluation({
503
+ command,
504
+ cwd: ctx.cwd,
505
+ mode,
506
+ verdict,
507
+ hostAction: result?.block === true ? "block" : "allow",
508
+ });
509
+ } catch (error: unknown) {
510
+ ctx.ui.notify(
511
+ `demur: could not record training evaluation — ${errorDetail(error)}`,
512
+ "warning",
513
+ );
514
+ }
406
515
  }
407
516
 
408
517
  async function handleGuardFailure(
@@ -521,26 +630,31 @@ function formatDecimal(value: number, fractionDigits: number): string {
521
630
  .replace(/\.0+$/, "");
522
631
  }
523
632
 
524
- function formatUsd(value: number): string {
525
- const decimal = value.toFixed(9).replace(/0+$/, "").replace(/\.$/, "");
526
- return `$${decimal}`;
527
- }
528
-
529
633
  function parseVerdict(output: string): Verdict {
530
634
  const value: unknown = JSON.parse(output);
531
635
  if (value === null || typeof value !== "object") {
532
636
  throw new Error("verdict must be an object");
533
637
  }
534
638
 
535
- const { decision, reason } = value as Record<string, unknown>;
639
+ const { decision, reason, judgments, failure, latencyMs, usage } =
640
+ value as Record<string, unknown>;
536
641
  if (
537
642
  (decision !== "allow" && decision !== "ask" && decision !== "deny") ||
538
- typeof reason !== "string"
643
+ typeof reason !== "string" ||
644
+ typeof latencyMs !== "number" ||
645
+ !Number.isFinite(latencyMs)
539
646
  ) {
540
- throw new Error("verdict must contain a valid decision and reason");
647
+ throw new Error("verdict must contain a valid decision, reason, and latency");
541
648
  }
542
649
 
543
- return value as Verdict;
650
+ return {
651
+ decision,
652
+ reason,
653
+ judgments: judgments as Verdict["judgments"],
654
+ failure: failure as Verdict["failure"],
655
+ latencyMs,
656
+ usage: usage as Verdict["usage"],
657
+ };
544
658
  }
545
659
 
546
660
  function errorDetail(error: unknown): string {
@@ -0,0 +1,46 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Resolve demur's configuration directory.
6
+ *
7
+ * `DEMUR_CONFIG_HOME` takes precedence over the XDG config directory and the
8
+ * standard home-directory fallback. Unlike `XDG_CONFIG_HOME`, the demur-specific
9
+ * override names demur's directory directly.
10
+ *
11
+ * @param environment - Process environment used to resolve directory overrides
12
+ * @param homeDirectory - Home directory used when overrides are absent
13
+ * @returns Absolute or caller-provided path to demur's configuration directory
14
+ */
15
+ export function getDemurConfigDirectory(
16
+ environment: NodeJS.ProcessEnv = process.env,
17
+ homeDirectory: string = homedir(),
18
+ ): string {
19
+ if (environment.DEMUR_CONFIG_HOME) return environment.DEMUR_CONFIG_HOME;
20
+
21
+ const configHome = environment.XDG_CONFIG_HOME ||
22
+ join(homeDirectory, ".config");
23
+ return join(configHome, "demur");
24
+ }
25
+
26
+ /**
27
+ * Resolve demur's persistent state directory.
28
+ *
29
+ * `DEMUR_STATE_HOME` takes precedence over the XDG state directory and the
30
+ * standard home-directory fallback. Unlike `XDG_STATE_HOME`, the demur-specific
31
+ * override names demur's directory directly.
32
+ *
33
+ * @param environment - Process environment used to resolve directory overrides
34
+ * @param homeDirectory - Home directory used when overrides are absent
35
+ * @returns Absolute or caller-provided path to demur's persistent state directory
36
+ */
37
+ export function getDemurStateDirectory(
38
+ environment: NodeJS.ProcessEnv = process.env,
39
+ homeDirectory: string = homedir(),
40
+ ): string {
41
+ if (environment.DEMUR_STATE_HOME) return environment.DEMUR_STATE_HOME;
42
+
43
+ const stateHome = environment.XDG_STATE_HOME ||
44
+ join(homeDirectory, ".local", "state");
45
+ return join(stateHome, "demur");
46
+ }