@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 +99 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +12 -3
- package/src/cli.ts +508 -1
- package/src/settings-model.ts +60 -0
- package/src/training-review-model.ts +175 -0
- package/src/training-review-tui.tsx +1231 -0
|
@@ -2,6 +2,21 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { getDemurConfigDirectory } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How the Pi extension applies demur to Bash calls.
|
|
9
|
+
*/
|
|
10
|
+
export type DemurMode = "enforce" | "passive" | "disabled";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Modes accepted by the Pi extension command.
|
|
14
|
+
*/
|
|
15
|
+
export const DEMUR_MODES: readonly DemurMode[] = [
|
|
16
|
+
"enforce",
|
|
17
|
+
"passive",
|
|
18
|
+
"disabled",
|
|
19
|
+
];
|
|
5
20
|
|
|
6
21
|
/**
|
|
7
22
|
* How the Pi extension handles guard failures that produce no trustworthy
|
|
@@ -23,9 +38,13 @@ export const FAILURE_POLICIES: readonly FailurePolicy[] = [
|
|
|
23
38
|
*/
|
|
24
39
|
export type DemurSettings = {
|
|
25
40
|
/**
|
|
26
|
-
* Whether
|
|
41
|
+
* Whether demur enforces, observes, or bypasses Bash calls.
|
|
42
|
+
*/
|
|
43
|
+
mode: DemurMode;
|
|
44
|
+
/**
|
|
45
|
+
* Whether full command evaluations are appended to the training log.
|
|
27
46
|
*/
|
|
28
|
-
|
|
47
|
+
training: boolean;
|
|
29
48
|
/**
|
|
30
49
|
* Host action to take when demur cannot obtain a trustworthy judgment.
|
|
31
50
|
*/
|
|
@@ -36,19 +55,20 @@ export type DemurSettings = {
|
|
|
36
55
|
* Safe settings used when no persisted Pi configuration exists.
|
|
37
56
|
*/
|
|
38
57
|
export const DEFAULT_DEMUR_SETTINGS: DemurSettings = {
|
|
39
|
-
|
|
58
|
+
mode: "enforce",
|
|
59
|
+
training: false,
|
|
40
60
|
failurePolicy: "block",
|
|
41
61
|
};
|
|
42
62
|
|
|
43
63
|
type DemurConfig = DemurSettings & {
|
|
44
|
-
version:
|
|
64
|
+
version: 2;
|
|
45
65
|
};
|
|
46
66
|
|
|
47
67
|
/**
|
|
48
|
-
* Resolve the global demur configuration file
|
|
49
|
-
*
|
|
68
|
+
* Resolve the global demur configuration file using demur-specific and XDG
|
|
69
|
+
* directory conventions.
|
|
50
70
|
*
|
|
51
|
-
* @param environment - Process environment used to resolve
|
|
71
|
+
* @param environment - Process environment used to resolve demur and XDG overrides
|
|
52
72
|
* @param homeDirectory - Home directory used when the XDG override is absent
|
|
53
73
|
* @returns Absolute path to demur's global configuration file
|
|
54
74
|
*/
|
|
@@ -56,16 +76,17 @@ export function getDemurConfigPath(
|
|
|
56
76
|
environment: NodeJS.ProcessEnv = process.env,
|
|
57
77
|
homeDirectory: string = homedir(),
|
|
58
78
|
): string {
|
|
59
|
-
|
|
60
|
-
environment
|
|
61
|
-
|
|
79
|
+
return join(
|
|
80
|
+
getDemurConfigDirectory(environment, homeDirectory),
|
|
81
|
+
"config.json",
|
|
82
|
+
);
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
/**
|
|
65
86
|
* Load the globally persisted Pi settings.
|
|
66
87
|
*
|
|
67
|
-
* A missing file uses the
|
|
68
|
-
*
|
|
88
|
+
* A missing file uses the enforcing, fail-closed defaults. Version 1 files are
|
|
89
|
+
* migrated from their former `enabled` boolean and start with training off.
|
|
69
90
|
* Invalid or unreadable files are rejected so the extension can warn the user
|
|
70
91
|
* while still falling back safely.
|
|
71
92
|
*
|
|
@@ -88,18 +109,36 @@ export async function loadDemurSettings(
|
|
|
88
109
|
throw new Error(`invalid demur config in ${configPath}: expected an object`);
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
const
|
|
112
|
+
const config = value as Record<string, unknown>;
|
|
113
|
+
if (config.version === 1) {
|
|
114
|
+
if (
|
|
115
|
+
(config.enabled !== undefined && typeof config.enabled !== "boolean") ||
|
|
116
|
+
!isFailurePolicy(config.failurePolicy)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(`invalid demur config in ${configPath}: unsupported values`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
mode: config.enabled === false ? "disabled" : "enforce",
|
|
123
|
+
training: false,
|
|
124
|
+
failurePolicy: config.failurePolicy,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
if (
|
|
93
|
-
version !==
|
|
94
|
-
(
|
|
95
|
-
|
|
129
|
+
config.version !== 2 ||
|
|
130
|
+
!isDemurMode(config.mode) ||
|
|
131
|
+
typeof config.training !== "boolean" ||
|
|
132
|
+
(config.mode === "disabled" && config.training) ||
|
|
133
|
+
!isFailurePolicy(config.failurePolicy)
|
|
96
134
|
) {
|
|
97
135
|
throw new Error(`invalid demur config in ${configPath}: unsupported values`);
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
return {
|
|
101
|
-
|
|
102
|
-
|
|
139
|
+
mode: config.mode,
|
|
140
|
+
training: config.training,
|
|
141
|
+
failurePolicy: config.failurePolicy,
|
|
103
142
|
};
|
|
104
143
|
}
|
|
105
144
|
|
|
@@ -113,7 +152,11 @@ export async function saveDemurSettings(
|
|
|
113
152
|
settings: DemurSettings,
|
|
114
153
|
configPath: string = getDemurConfigPath(),
|
|
115
154
|
): Promise<void> {
|
|
116
|
-
|
|
155
|
+
if (settings.mode === "disabled" && settings.training) {
|
|
156
|
+
throw new Error("training cannot be enabled while demur is disabled");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const config: DemurConfig = { version: 2, ...settings };
|
|
117
160
|
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
118
161
|
|
|
119
162
|
await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
@@ -133,6 +176,17 @@ export async function saveDemurSettings(
|
|
|
133
176
|
}
|
|
134
177
|
}
|
|
135
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Parse a command argument as a Pi operating mode.
|
|
181
|
+
*
|
|
182
|
+
* @param value - Raw slash-command argument
|
|
183
|
+
* @returns Normalized mode, or `undefined` when the argument is invalid
|
|
184
|
+
*/
|
|
185
|
+
export function parseDemurMode(value: string): DemurMode | undefined {
|
|
186
|
+
const normalized = value.trim().toLowerCase();
|
|
187
|
+
return isDemurMode(normalized) ? normalized : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
136
190
|
/**
|
|
137
191
|
* Parse a command argument as a Pi failure policy.
|
|
138
192
|
*
|
|
@@ -144,6 +198,10 @@ export function parseFailurePolicy(value: string): FailurePolicy | undefined {
|
|
|
144
198
|
return isFailurePolicy(normalized) ? normalized : undefined;
|
|
145
199
|
}
|
|
146
200
|
|
|
201
|
+
function isDemurMode(value: unknown): value is DemurMode {
|
|
202
|
+
return DEMUR_MODES.some((mode) => mode === value);
|
|
203
|
+
}
|
|
204
|
+
|
|
147
205
|
function isFailurePolicy(value: unknown): value is FailurePolicy {
|
|
148
206
|
return FAILURE_POLICIES.some((policy) => policy === value);
|
|
149
207
|
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import type { Decision, Verdict } from "../../src/types.ts";
|
|
6
|
+
import { getDemurStateDirectory } from "./paths.ts";
|
|
7
|
+
|
|
8
|
+
const LOCK_RETRY_MS = 10;
|
|
9
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Guard modes that can emit training records.
|
|
13
|
+
*/
|
|
14
|
+
export type TrainingMode = "enforce" | "passive";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Action the Pi host took after demur evaluated a command.
|
|
18
|
+
*/
|
|
19
|
+
export type TrainingHostAction = "allow" | "block";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Complete evidence captured for one training-mode command evaluation.
|
|
23
|
+
*/
|
|
24
|
+
export type TrainingRecord = {
|
|
25
|
+
version: 1;
|
|
26
|
+
id: string;
|
|
27
|
+
recordedAt: string;
|
|
28
|
+
command: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
mode: TrainingMode;
|
|
31
|
+
verdict: Verdict;
|
|
32
|
+
hostAction: TrainingHostAction;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Human review of one captured training evaluation.
|
|
37
|
+
*/
|
|
38
|
+
export type TrainingReview = {
|
|
39
|
+
version: 1;
|
|
40
|
+
recordId: string;
|
|
41
|
+
reviewedAt: string;
|
|
42
|
+
originalDecision: Decision;
|
|
43
|
+
expectedDecision: Decision;
|
|
44
|
+
note: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Input required to append a human review.
|
|
49
|
+
*/
|
|
50
|
+
export type TrainingReviewInput = {
|
|
51
|
+
recordId: string;
|
|
52
|
+
originalDecision: Decision;
|
|
53
|
+
expectedDecision: Decision;
|
|
54
|
+
note: string | undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the global training-record file according to demur and XDG overrides.
|
|
59
|
+
*
|
|
60
|
+
* @param environment - Process environment used to resolve demur and XDG overrides
|
|
61
|
+
* @param homeDirectory - Home directory used when the XDG override is absent
|
|
62
|
+
* @returns Absolute path to the append-only training JSONL file
|
|
63
|
+
*/
|
|
64
|
+
export function getTrainingLogPath(
|
|
65
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
homeDirectory: string = homedir(),
|
|
67
|
+
): string {
|
|
68
|
+
return join(
|
|
69
|
+
getDemurStateDirectory(environment, homeDirectory),
|
|
70
|
+
"training.jsonl",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the global training-review file according to demur and XDG overrides.
|
|
76
|
+
*
|
|
77
|
+
* @param environment - Process environment used to resolve demur and XDG overrides
|
|
78
|
+
* @param homeDirectory - Home directory used when the XDG override is absent
|
|
79
|
+
* @returns Absolute path to the append-only review JSONL file
|
|
80
|
+
*/
|
|
81
|
+
export function getTrainingReviewPath(
|
|
82
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
83
|
+
homeDirectory: string = homedir(),
|
|
84
|
+
): string {
|
|
85
|
+
return join(
|
|
86
|
+
getDemurStateDirectory(environment, homeDirectory),
|
|
87
|
+
"training-reviews.jsonl",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Append one complete command evaluation to the global training log.
|
|
93
|
+
*
|
|
94
|
+
* @param input - Evaluation evidence captured by the Pi extension
|
|
95
|
+
* @param logPath - Training file to append
|
|
96
|
+
* @returns The persisted record with generated identity and timestamp
|
|
97
|
+
*/
|
|
98
|
+
export async function recordTrainingEvaluation(
|
|
99
|
+
input: Omit<TrainingRecord, "version" | "id" | "recordedAt">,
|
|
100
|
+
logPath: string = getTrainingLogPath(),
|
|
101
|
+
): Promise<TrainingRecord> {
|
|
102
|
+
const record: TrainingRecord = {
|
|
103
|
+
version: 1,
|
|
104
|
+
id: randomUUID(),
|
|
105
|
+
recordedAt: new Date().toISOString(),
|
|
106
|
+
...input,
|
|
107
|
+
};
|
|
108
|
+
await appendJsonLine(logPath, record);
|
|
109
|
+
return record;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load and validate all captured training evaluations.
|
|
114
|
+
*
|
|
115
|
+
* @param logPath - Training file to read
|
|
116
|
+
* @returns Valid training records in capture order
|
|
117
|
+
*/
|
|
118
|
+
export async function loadTrainingRecords(
|
|
119
|
+
logPath: string = getTrainingLogPath(),
|
|
120
|
+
): Promise<ReadonlyArray<TrainingRecord>> {
|
|
121
|
+
return readJsonLines(logPath, parseTrainingRecord);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Append a human decision review to the global review log.
|
|
126
|
+
*
|
|
127
|
+
* @param input - Reviewed record identity, expected decision, and optional note
|
|
128
|
+
* @param reviewPath - Review file to append
|
|
129
|
+
* @returns The persisted review with its generated timestamp
|
|
130
|
+
*/
|
|
131
|
+
export async function recordTrainingReview(
|
|
132
|
+
input: TrainingReviewInput,
|
|
133
|
+
reviewPath: string = getTrainingReviewPath(),
|
|
134
|
+
): Promise<TrainingReview> {
|
|
135
|
+
const review: TrainingReview = {
|
|
136
|
+
version: 1,
|
|
137
|
+
reviewedAt: new Date().toISOString(),
|
|
138
|
+
...input,
|
|
139
|
+
};
|
|
140
|
+
await appendJsonLine(reviewPath, review);
|
|
141
|
+
return review;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load and validate all human training reviews.
|
|
146
|
+
*
|
|
147
|
+
* @param reviewPath - Review file to read
|
|
148
|
+
* @returns Valid reviews in capture order
|
|
149
|
+
*/
|
|
150
|
+
export async function loadTrainingReviews(
|
|
151
|
+
reviewPath: string = getTrainingReviewPath(),
|
|
152
|
+
): Promise<ReadonlyArray<TrainingReview>> {
|
|
153
|
+
return readJsonLines(reviewPath, parseTrainingReview);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function appendJsonLine(path: string, value: unknown): Promise<void> {
|
|
157
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
158
|
+
const release = await acquireLock(`${path}.lock`);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const line = JSON.stringify(
|
|
162
|
+
value,
|
|
163
|
+
(_key, nestedValue: unknown) =>
|
|
164
|
+
nestedValue === undefined ? null : nestedValue,
|
|
165
|
+
);
|
|
166
|
+
await appendFile(path, `${line}\n`, {
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
flag: "a",
|
|
169
|
+
mode: 0o600,
|
|
170
|
+
});
|
|
171
|
+
} finally {
|
|
172
|
+
await release();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readJsonLines<T>(
|
|
177
|
+
path: string,
|
|
178
|
+
parse: (value: unknown, path: string, line: number) => T,
|
|
179
|
+
): Promise<ReadonlyArray<T>> {
|
|
180
|
+
let content: string;
|
|
181
|
+
try {
|
|
182
|
+
content = await readFile(path, "utf8");
|
|
183
|
+
} catch (error: unknown) {
|
|
184
|
+
if (isErrorCode(error, "ENOENT")) return [];
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return content
|
|
189
|
+
.split("\n")
|
|
190
|
+
.map((line) => line.trim())
|
|
191
|
+
.filter((line) => line !== "")
|
|
192
|
+
.map((line, index) => {
|
|
193
|
+
let value: unknown;
|
|
194
|
+
try {
|
|
195
|
+
value = JSON.parse(line);
|
|
196
|
+
} catch (error: unknown) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`invalid JSONL in ${path} at line ${index + 1}: ${errorDetail(error)}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return parse(value, path, index + 1);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseTrainingRecord(
|
|
206
|
+
value: unknown,
|
|
207
|
+
path: string,
|
|
208
|
+
line: number,
|
|
209
|
+
): TrainingRecord {
|
|
210
|
+
if (value === null || typeof value !== "object") {
|
|
211
|
+
throw invalidRecord(path, line);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const record = value as Record<string, unknown>;
|
|
215
|
+
if (
|
|
216
|
+
record.version !== 1 ||
|
|
217
|
+
typeof record.id !== "string" ||
|
|
218
|
+
typeof record.recordedAt !== "string" ||
|
|
219
|
+
typeof record.command !== "string" ||
|
|
220
|
+
typeof record.cwd !== "string" ||
|
|
221
|
+
(record.mode !== "enforce" && record.mode !== "passive") ||
|
|
222
|
+
(record.hostAction !== "allow" && record.hostAction !== "block") ||
|
|
223
|
+
!isVerdict(record.verdict)
|
|
224
|
+
) {
|
|
225
|
+
throw invalidRecord(path, line);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const verdict = record.verdict as Record<string, unknown>;
|
|
229
|
+
return {
|
|
230
|
+
version: 1,
|
|
231
|
+
id: record.id,
|
|
232
|
+
recordedAt: record.recordedAt,
|
|
233
|
+
command: record.command,
|
|
234
|
+
cwd: record.cwd,
|
|
235
|
+
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
|
+
},
|
|
250
|
+
hostAction: record.hostAction,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseTrainingReview(
|
|
255
|
+
value: unknown,
|
|
256
|
+
path: string,
|
|
257
|
+
line: number,
|
|
258
|
+
): TrainingReview {
|
|
259
|
+
if (value === null || typeof value !== "object") {
|
|
260
|
+
throw invalidRecord(path, line);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const review = value as Record<string, unknown>;
|
|
264
|
+
if (
|
|
265
|
+
review.version !== 1 ||
|
|
266
|
+
typeof review.recordId !== "string" ||
|
|
267
|
+
typeof review.reviewedAt !== "string" ||
|
|
268
|
+
!isDecision(review.originalDecision) ||
|
|
269
|
+
!isDecision(review.expectedDecision) ||
|
|
270
|
+
(review.note !== undefined &&
|
|
271
|
+
review.note !== null &&
|
|
272
|
+
typeof review.note !== "string")
|
|
273
|
+
) {
|
|
274
|
+
throw invalidRecord(path, line);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
version: 1,
|
|
279
|
+
recordId: review.recordId,
|
|
280
|
+
reviewedAt: review.reviewedAt,
|
|
281
|
+
originalDecision: review.originalDecision,
|
|
282
|
+
expectedDecision: review.expectedDecision,
|
|
283
|
+
note: review.note === null ? undefined : review.note,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isVerdict(value: unknown): value is Verdict {
|
|
288
|
+
if (value === null || typeof value !== "object") return false;
|
|
289
|
+
const verdict = value as Record<string, unknown>;
|
|
290
|
+
return isDecision(verdict.decision) &&
|
|
291
|
+
typeof verdict.reason === "string" &&
|
|
292
|
+
typeof verdict.latencyMs === "number";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isDecision(value: unknown): value is Decision {
|
|
296
|
+
return value === "allow" || value === "ask" || value === "deny";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function invalidRecord(path: string, line: number): Error {
|
|
300
|
+
return new Error(`invalid training record in ${path} at line ${line}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
|
|
304
|
+
const startedAt = Date.now();
|
|
305
|
+
|
|
306
|
+
while (true) {
|
|
307
|
+
try {
|
|
308
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
309
|
+
return async () => {
|
|
310
|
+
await rmdir(lockPath);
|
|
311
|
+
};
|
|
312
|
+
} catch (error: unknown) {
|
|
313
|
+
if (!isErrorCode(error, "EEXIST")) throw error;
|
|
314
|
+
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
315
|
+
throw new Error(`timed out waiting for training state lock: ${lockPath}`);
|
|
316
|
+
}
|
|
317
|
+
await delay(LOCK_RETRY_MS);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isErrorCode(error: unknown, code: string): boolean {
|
|
323
|
+
return error instanceof Error &&
|
|
324
|
+
"code" in error &&
|
|
325
|
+
(error as NodeJS.ErrnoException).code === code;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function errorDetail(error: unknown): string {
|
|
329
|
+
return error instanceof Error ? error.message : String(error);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function delay(milliseconds: number): Promise<void> {
|
|
333
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
334
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyattjoh/demur",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A proof-of-concept destructive-command guard for coding agents.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
],
|
|
24
24
|
"files": [
|
|
25
25
|
"extensions/demur/cost-tracker.ts",
|
|
26
|
+
"extensions/demur/paths.ts",
|
|
26
27
|
"extensions/demur/settings.ts",
|
|
28
|
+
"extensions/demur/training-store.ts",
|
|
27
29
|
"extensions/demur/index.ts",
|
|
28
30
|
"src/adapters/claude-code.ts",
|
|
29
31
|
"src/adapters/pi-worker.ts",
|
|
@@ -36,6 +38,9 @@
|
|
|
36
38
|
"src/policy.ts",
|
|
37
39
|
"src/questions.ts",
|
|
38
40
|
"src/state.ts",
|
|
41
|
+
"src/settings-model.ts",
|
|
42
|
+
"src/training-review-model.ts",
|
|
43
|
+
"src/training-review-tui.tsx",
|
|
39
44
|
"src/types.ts"
|
|
40
45
|
],
|
|
41
46
|
"publishConfig": {
|
|
@@ -47,7 +52,7 @@
|
|
|
47
52
|
},
|
|
48
53
|
"scripts": {
|
|
49
54
|
"check": "tsc --noEmit",
|
|
50
|
-
"test": "vitest run",
|
|
55
|
+
"test": "vitest run && bun test ./src/training-review-tui.bun.tsx",
|
|
51
56
|
"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",
|
|
52
57
|
"build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
|
|
53
58
|
"build": "bun run build:pi && bun run build:claude",
|
|
@@ -63,12 +68,16 @@
|
|
|
63
68
|
},
|
|
64
69
|
"dependencies": {
|
|
65
70
|
"@effect/ai-typesafe": "4.0.0-rc.116",
|
|
66
|
-
"
|
|
71
|
+
"@opentui/core": "^0.5.11",
|
|
72
|
+
"@opentui/react": "^0.5.11",
|
|
73
|
+
"effect": "4.0.0-rc.116",
|
|
74
|
+
"react": "^19.3.0"
|
|
67
75
|
},
|
|
68
76
|
"devDependencies": {
|
|
69
77
|
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
70
78
|
"@effect/vitest": "4.0.0-rc.116",
|
|
71
79
|
"@types/bun": "1.4.2",
|
|
80
|
+
"@types/react": "^19.3.0",
|
|
72
81
|
"typescript": "7.0.2",
|
|
73
82
|
"vitest": "5.0.1"
|
|
74
83
|
},
|