@monster-orange/den 0.4.6 → 0.4.8

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +244 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/skills/install-den/SKILL.md +2 -2
  7. package/src/shared/agents/den-claude.ts +38 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +66 -7
  15. package/src/shared/agents/records.ts +114 -0
  16. package/src/shared/agents/runtime.ts +12 -0
  17. package/src/shared/den-config.ts +97 -6
  18. package/src/shared/dev-metrics/emit.ts +31 -4
  19. package/src/shared/dev-metrics/polling.ts +135 -0
  20. package/src/shared/engine.ts +14 -2
  21. package/src/shared/plugins/manifest.ts +3 -0
  22. package/src/shared/usage/paths.ts +16 -0
  23. package/src/shared/usage/report.ts +283 -0
  24. package/src/shared/usage/sources.ts +127 -0
  25. package/src/shared/usage/transcript.ts +262 -0
  26. package/src/ui/cli/agent-ops.ts +495 -53
  27. package/src/ui/cli/agents-command.ts +170 -14
  28. package/src/ui/cli/doctor-command.ts +20 -0
  29. package/src/ui/cli/flag-parser.ts +1 -0
  30. package/src/ui/cli/index.ts +119 -2
  31. package/src/ui/cli/usage-command.ts +256 -0
  32. package/src/ui/cli/web-command.ts +3 -1
  33. package/src/ui/mcp/api.ts +10 -1
  34. package/src/ui/mcp/server.ts +34 -0
  35. package/src/ui/mcp/tools.ts +50 -0
  36. package/src/ui/server/agent-resume-grant.ts +53 -0
  37. package/src/ui/server/agent-routes.ts +230 -9
  38. package/src/ui/server/audit.ts +68 -1
  39. package/src/ui/server/index.ts +16 -5
  40. package/src/ui/server/public/agents-page.js +333 -11
  41. package/src/ui/server/public/agents-section.js +17 -3
  42. package/src/ui/server/public/agents.html +44 -0
  43. package/src/ui/server/public/i18n/es.js +28 -0
  44. package/src/ui/server/public/i18n/ja.js +25 -0
  45. package/src/ui/server/public/shell.js +66 -0
  46. package/src/ui/server/request-guard.ts +21 -1
  47. package/src/ui/server/route-classes.ts +23 -0
  48. package/src/ui/server/session-routes.ts +52 -4
  49. package/src/ui/server/sudo-store.ts +36 -7
@@ -0,0 +1,27 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash|Read",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "{pretooluse}",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "Stop": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "{stop}",
21
+ "timeout": 30
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,39 @@
1
+ ## How you work and write (every Den persona)
2
+
3
+ ### Writing to people
4
+
5
+ - Write plainly. Use short sentences.
6
+ - Never name an issue or an ID without saying what it is. Write "#5 (the
7
+ hooks issue)" or "the plain-writing rule (R12)", never a bare "#5" or "R12".
8
+ - Ask each decision as a numbered yes/no question. One question per number.
9
+ - No caveat sections. Leave out anything the person doesn't need in order to
10
+ act.
11
+
12
+ ### Reading and waiting
13
+
14
+ - Don't read a whole log or a large file into the conversation. Read a slice
15
+ (`tail -n 100`, `grep -n`), or hand the file to a subagent and ask for the
16
+ conclusion.
17
+ - Don't poll with `sleep` or `until …; do sleep …; done`. Run long work in the
18
+ background and wait to be told it ended, or use a wait primitive.
19
+
20
+ ### Handing work back
21
+
22
+ When you hand work back to a person, end with this, filled in. Den checks it:
23
+ a hand-back with a missing part, an unexplained ID, or a decision that is not
24
+ a numbered yes/no question is sent back to you to fix.
25
+
26
+ ```
27
+ ## Hand-back
28
+ - URL: <the full link the person opens, with https://>
29
+ - Machine: <the machine it runs on>
30
+ - Branch: <the branch>
31
+ - What changed: <what is different now, in a sentence or two>
32
+ - What to click: <the steps to see it working>
33
+ - Decisions:
34
+ 1. <a question the person answers yes or no>?
35
+ ```
36
+
37
+ Write "Decisions: none" when there are none, and "URL: none" (with a short
38
+ reason) when the work has no page to open. Every message you end a turn
39
+ with is read by a person, hand-back or not: explain each ID in it.
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Den's PreToolUse hook for Claude Code sessions it launches (den-claude).
4
+ * Den passes it in the agent's own settings file (--settings); it never
5
+ * touches the user's ~/.claude. Plain Node with no imports beyond node:*,
6
+ * so it starts in a few tens of milliseconds on every Bash and Read call.
7
+ *
8
+ * Hook contract (https://code.claude.com/docs/en/hooks, "PreToolUse" and
9
+ * "Exit code output"): the tool call arrives as JSON on stdin
10
+ * ({ tool_name, tool_input, cwd, ... }). Exit 2 blocks the call, and Claude
11
+ * gets stderr as the reason. Exit 0 with no output lets the normal
12
+ * permission flow decide. Any error here exits 0: a broken hook never
13
+ * blocks anything.
14
+ *
15
+ * It blocks exactly two things, and nothing else:
16
+ *
17
+ * 1. Whole-file reads of a log or a large file into the conversation:
18
+ * - Read with no `limit`;
19
+ * - Bash `cat` or `less` of it, when its output reaches the
20
+ * conversation (the last stage of a pipeline, stdout not redirected);
21
+ * - Bash `tail` from a line or byte (`tail +1`, `-n +1`, `--lines=+1`),
22
+ * which prints the whole rest of the file, likewise. A plain `tail`
23
+ * prints 10 lines, a slice, and passes.
24
+ * A log is `*.log` (or `*.log.<N>`), a `.jsonl` under `.dev-metrics/`,
25
+ * or a `.jsonl` under a `.claude/projects/` directory (a transcript).
26
+ * Large is over LARGE_BYTES.
27
+ * 2. Foreground `sleep` in Bash: `sleep N` on its own or inside an
28
+ * `until ...; do sleep N; done` (or while/for) polling loop. A Bash call
29
+ * with run_in_background, or a `sleep ... &`, is not blocked.
30
+ */
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ /** Over this many bytes, a file is large (Claude Code's own Read cap). */
36
+ export const LARGE_BYTES = 256 * 1024;
37
+
38
+ const READ_HINT =
39
+ "Read a slice instead: `tail -n 100 <file>`, `grep -n <pattern> <file>`, " +
40
+ "or a Read with offset and limit. To go through all of it, hand it to a " +
41
+ "subagent and ask it for the conclusion.";
42
+
43
+ const SLEEP_HINT =
44
+ "Waiting with sleep is polling. Run the long command with " +
45
+ "run_in_background (you are told when it ends), or use a wait primitive " +
46
+ "(the Monitor tool with an until-condition, or the command's own --wait). " +
47
+ "Do not loop on sleep.";
48
+
49
+ /** Is this path a log: *.log, .dev-metrics/*.jsonl, or a transcript? */
50
+ export function isLogPath(p) {
51
+ const s = String(p).replace(/\\/g, "/");
52
+ const base = s.slice(s.lastIndexOf("/") + 1);
53
+ if (/\.log(\.\d+)?$/i.test(base)) return true;
54
+ if (!/\.jsonl$/i.test(base)) return false;
55
+ if (/(^|\/)\.dev-metrics\//.test(s)) return true;
56
+ return /(^|\/)\.claude\/projects\//.test(s);
57
+ }
58
+
59
+ /** The file's size, or -1 when it can't be read (a glob, a variable). */
60
+ function sizeOf(p, cwd, home) {
61
+ try {
62
+ let f = String(p);
63
+ if (home && (f === "~" || f.startsWith("~/")))
64
+ f = path.join(home, f.slice(1));
65
+ const st = fs.statSync(path.resolve(cwd || ".", f));
66
+ return st.isFile() ? st.size : -1;
67
+ } catch {
68
+ return -1;
69
+ }
70
+ }
71
+
72
+ /** Why reading all of this file is blocked, or null. */
73
+ export function wholeReadReason(p, cwd, home) {
74
+ if (isLogPath(p)) return "a log";
75
+ const n = sizeOf(p, cwd, home);
76
+ if (n > LARGE_BYTES) return `${(n / 1024 / 1024).toFixed(1)} MB`;
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Split a shell command into pipelines of stages. Each stage is
82
+ * { words, stdoutRedirected, background }. Quotes and backslashes are
83
+ * honoured, `$( … )` stays inside its word, comments and heredoc bodies are
84
+ * skipped. Not a full shell parser: anything it can't read is left alone.
85
+ */
86
+ export function parseShell(src) {
87
+ const pipelines = [];
88
+ let stages = [];
89
+ let words = [];
90
+ let word = "";
91
+ let inWord = false;
92
+ const heredocs = [];
93
+
94
+ const endWord = () => {
95
+ if (inWord) words.push(word);
96
+ word = "";
97
+ inWord = false;
98
+ };
99
+ const endStage = () => {
100
+ endWord();
101
+ if (words.length) stages.push(stage(words));
102
+ words = [];
103
+ };
104
+ const endPipeline = (background = false) => {
105
+ endStage();
106
+ if (stages.length) {
107
+ if (background) stages[stages.length - 1].background = true;
108
+ pipelines.push(stages);
109
+ }
110
+ stages = [];
111
+ };
112
+
113
+ let i = 0;
114
+ while (i < src.length) {
115
+ const c = src[i];
116
+ if (c === "\\" && i + 1 < src.length) {
117
+ if (src[i + 1] !== "\n") word += src[i + 1];
118
+ inWord = inWord || src[i + 1] !== "\n";
119
+ i += 2;
120
+ continue;
121
+ }
122
+ if (c === "'") {
123
+ const j = src.indexOf("'", i + 1);
124
+ const end = j < 0 ? src.length : j;
125
+ word += src.slice(i + 1, end);
126
+ inWord = true;
127
+ i = end + 1;
128
+ continue;
129
+ }
130
+ if (c === '"') {
131
+ let j = i + 1;
132
+ while (j < src.length && src[j] !== '"') {
133
+ if (src[j] === "\\" && j + 1 < src.length) {
134
+ word += src[j + 1];
135
+ j += 2;
136
+ } else word += src[j++];
137
+ }
138
+ inWord = true;
139
+ i = j + 1;
140
+ continue;
141
+ }
142
+ if (c === "$" && src[i + 1] === "(") {
143
+ let depth = 0;
144
+ let j = i + 1;
145
+ for (; j < src.length; j++) {
146
+ if (src[j] === "(") depth++;
147
+ else if (src[j] === ")" && --depth === 0) break;
148
+ }
149
+ word += src.slice(i, j + 1);
150
+ inWord = true;
151
+ i = j + 1;
152
+ continue;
153
+ }
154
+ if (c === "`") {
155
+ const j = src.indexOf("`", i + 1);
156
+ const end = j < 0 ? src.length : j;
157
+ word += src.slice(i, end + 1);
158
+ inWord = true;
159
+ i = end + 1;
160
+ continue;
161
+ }
162
+ if (c === "#" && !inWord) {
163
+ while (i < src.length && src[i] !== "\n") i++;
164
+ continue;
165
+ }
166
+ if (c === "\n") {
167
+ endPipeline();
168
+ i++;
169
+ // Skip heredoc bodies that start on this line.
170
+ while (heredocs.length) {
171
+ const delim = heredocs.shift();
172
+ for (;;) {
173
+ if (i >= src.length) break;
174
+ const nl = src.indexOf("\n", i);
175
+ const line = src.slice(i, nl < 0 ? src.length : nl);
176
+ i = nl < 0 ? src.length : nl + 1;
177
+ if (line.replace(/^\t+/, "") === delim) break;
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+ if (c === " " || c === "\t") {
183
+ endWord();
184
+ i++;
185
+ continue;
186
+ }
187
+ if (c === "<" && src[i + 1] === "<" && src[i + 2] !== "<") {
188
+ endWord();
189
+ let j = i + 2;
190
+ if (src[j] === "-") j++;
191
+ while (src[j] === " " || src[j] === "\t") j++;
192
+ let d = "";
193
+ while (j < src.length && !/[\s;&|<>()]/.test(src[j])) d += src[j++];
194
+ heredocs.push(d.replace(/['"\\]/g, ""));
195
+ words.push("<<");
196
+ i = j;
197
+ continue;
198
+ }
199
+ if (c === "&" && src[i + 1] === "&") {
200
+ endPipeline();
201
+ i += 2;
202
+ continue;
203
+ }
204
+ if (c === "|" && src[i + 1] === "|") {
205
+ endPipeline();
206
+ i += 2;
207
+ continue;
208
+ }
209
+ if (c === "&" && src[i + 1] === ">") {
210
+ endWord();
211
+ words.push(src[i + 2] === ">" ? "&>>" : "&>");
212
+ i += src[i + 2] === ">" ? 3 : 2;
213
+ continue;
214
+ }
215
+ if (c === "|") {
216
+ endStage();
217
+ i += src[i + 1] === "&" ? 2 : 1;
218
+ continue;
219
+ }
220
+ if (c === "&") {
221
+ endPipeline(true);
222
+ i++;
223
+ continue;
224
+ }
225
+ if (c === ";" || c === "(" || c === ")") {
226
+ endPipeline();
227
+ i++;
228
+ continue;
229
+ }
230
+ if (c === ">" || c === "<") {
231
+ // A redirection operator: its own word, with any fd before it.
232
+ const fd = inWord && /^\d+$/.test(word) ? word : "";
233
+ if (fd) {
234
+ word = "";
235
+ inWord = false;
236
+ } else endWord();
237
+ let op = fd + c;
238
+ let j = i + 1;
239
+ if (src[j] === ">" || src[j] === "&") op += src[j++];
240
+ words.push(op);
241
+ i = j;
242
+ continue;
243
+ }
244
+ word += c;
245
+ inWord = true;
246
+ i++;
247
+ }
248
+ endPipeline();
249
+ return pipelines;
250
+ }
251
+
252
+ const PREFIX = new Set([
253
+ "do",
254
+ "then",
255
+ "else",
256
+ "elif",
257
+ "while",
258
+ "until",
259
+ "if",
260
+ "!",
261
+ "{",
262
+ "}",
263
+ "time",
264
+ "exec",
265
+ "command",
266
+ "nohup",
267
+ ]);
268
+
269
+ /** One stage: its command and arguments, with redirections taken out. */
270
+ function stage(raw) {
271
+ let k = 0;
272
+ while (
273
+ k < raw.length &&
274
+ (PREFIX.has(raw[k]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(raw[k]))
275
+ )
276
+ k++;
277
+ const words = [];
278
+ const inputs = [];
279
+ let stdoutRedirected = false;
280
+ for (let j = k; j < raw.length; j++) {
281
+ const w = raw[j];
282
+ const r = /^(\d*|&)>>?(&?)$/.exec(w);
283
+ if (r) {
284
+ // Claude Code shows stdout and stderr both, so only stdout sent to a
285
+ // file leaves the chat: `>f`, `1>f`, `&>f`. `2>f`, `2>&1`, `>&2` don't.
286
+ const toFd = r[2] === "&" && /^\d+$/.test(raw[j + 1] ?? "");
287
+ if ((r[1] === "" || r[1] === "1" || r[1] === "&") && !toFd)
288
+ stdoutRedirected = true;
289
+ j++;
290
+ continue;
291
+ }
292
+ if (w === "<") {
293
+ if (raw[j + 1] !== undefined) inputs.push(raw[j + 1]);
294
+ j++;
295
+ continue;
296
+ }
297
+ if (w === "<<") {
298
+ j++;
299
+ continue;
300
+ }
301
+ words.push(w);
302
+ }
303
+ return { words, inputs, stdoutRedirected, background: false };
304
+ }
305
+
306
+ const base = (w) => String(w).slice(String(w).lastIndexOf("/") + 1);
307
+
308
+ /**
309
+ * tail reads the whole rest of the file only when it starts from a line or
310
+ * byte (`+N`, `-n +N`, `-n+N`, `--lines=+N`, the same for -c/--bytes); a
311
+ * plain tail prints 10 lines, and a count or a follow is a slice.
312
+ */
313
+ function tailReadsAll(args) {
314
+ for (let j = 0; j < args.length; j++) {
315
+ const a = args[j];
316
+ if (/^\+\d+$/.test(a)) return true;
317
+ if (/^-[nc]\+\d+$/.test(a) || /^--(lines|bytes)=\+\d+$/.test(a))
318
+ return true;
319
+ if (
320
+ (a === "-n" || a === "-c" || a === "--lines" || a === "--bytes") &&
321
+ /^\+\d+$/.test(args[j + 1] ?? "")
322
+ )
323
+ return true;
324
+ }
325
+ return false;
326
+ }
327
+
328
+ /** The file arguments of cat, less or tail (options and their values out). */
329
+ function fileArgs(cmd, args) {
330
+ const out = [];
331
+ for (let j = 0; j < args.length; j++) {
332
+ const a = args[j];
333
+ if (a === "--") {
334
+ out.push(...args.slice(j + 1));
335
+ break;
336
+ }
337
+ if (a === "-") continue;
338
+ if (a.startsWith("-") || (cmd === "tail" && /^\+\d+$/.test(a))) {
339
+ if (cmd === "tail" && (a === "-n" || a === "-c")) j++;
340
+ continue;
341
+ }
342
+ out.push(a);
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** The reason to block this Bash command, or null. */
348
+ export function bashDecision(command, opts = {}) {
349
+ const { cwd, home, background } = opts;
350
+ let pipelines;
351
+ try {
352
+ pipelines = parseShell(String(command ?? ""));
353
+ } catch {
354
+ return null;
355
+ }
356
+ for (const stages of pipelines)
357
+ for (let s = 0; s < stages.length; s++) {
358
+ const st = stages[s];
359
+ const cmd = base(st.words[0] ?? "");
360
+ const args = st.words.slice(1);
361
+ if (cmd === "sleep" && args.length && !background && !st.background)
362
+ return `Den blocked \`sleep ${args.join(" ")}\`. ${SLEEP_HINT}`;
363
+ if (cmd !== "cat" && cmd !== "less" && cmd !== "tail") continue;
364
+ // Output that goes on to another command or a file never reaches the chat.
365
+ if (s < stages.length - 1 || st.stdoutRedirected) continue;
366
+ if (cmd === "tail" && !tailReadsAll(args)) continue;
367
+ for (const f of [...fileArgs(cmd, args), ...st.inputs]) {
368
+ const why = wholeReadReason(f, cwd, home);
369
+ if (why)
370
+ return `Den blocked \`${[cmd, ...args].join(" ")}\`: it reads all of ${f} (${why}) into the conversation. ${READ_HINT}`;
371
+ }
372
+ }
373
+ return null;
374
+ }
375
+
376
+ /** The reason to block this tool call, or null. */
377
+ export function decide(input, env = process.env) {
378
+ if (!input || typeof input !== "object") return null;
379
+ const ti =
380
+ input.tool_input && typeof input.tool_input === "object"
381
+ ? input.tool_input
382
+ : {};
383
+ const cwd = typeof input.cwd === "string" ? input.cwd : process.cwd();
384
+ const home = env.HOME;
385
+ if (input.tool_name === "Read") {
386
+ if (ti.limit !== undefined && ti.limit !== null) return null;
387
+ if (typeof ti.file_path !== "string") return null;
388
+ const why = wholeReadReason(ti.file_path, cwd, home);
389
+ return why
390
+ ? `Den blocked a Read of all of ${ti.file_path} (${why}). ${READ_HINT}`
391
+ : null;
392
+ }
393
+ if (input.tool_name === "Bash")
394
+ return bashDecision(ti.command, {
395
+ cwd,
396
+ home,
397
+ background: ti.run_in_background === true,
398
+ });
399
+ return null;
400
+ }
401
+
402
+ async function main() {
403
+ let text = "";
404
+ for await (const chunk of process.stdin) text += chunk;
405
+ let reason = null;
406
+ try {
407
+ reason = decide(JSON.parse(text));
408
+ } catch {
409
+ reason = null;
410
+ }
411
+ if (reason) {
412
+ process.stderr.write(`${reason}\n`);
413
+ process.exit(2);
414
+ }
415
+ process.exit(0);
416
+ }
417
+
418
+ /** Run as a script (the hook), not imported. */
419
+ function isMain() {
420
+ try {
421
+ return (
422
+ !!process.argv[1] &&
423
+ fs.realpathSync(process.argv[1]) ===
424
+ fs.realpathSync(fileURLToPath(import.meta.url))
425
+ );
426
+ } catch {
427
+ return false;
428
+ }
429
+ }
430
+ if (isMain()) main().catch(() => process.exit(0));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Den's Stop hook for Claude Code sessions it launches (den-claude): the
3
+ * hand-back check (handback.ts) on the turn's last message.
4
+ *
5
+ * Hook contract (https://code.claude.com/docs/en/hooks, "Stop input" and
6
+ * "Stop decision control"): JSON on stdin with `last_assistant_message`
7
+ * and `stop_hook_active`. Printing `{"decision":"block","reason":…}` keeps
8
+ * Claude going with the reason; printing nothing lets it stop.
9
+ *
10
+ * A message with the "Hand-back" heading (the template's) gets the whole
11
+ * hand-back check. Any other last message is still what the person reads,
12
+ * so it gets the plain-writing part only: every ID explained (R12).
13
+ * When Claude is already continuing because of a stop hook
14
+ * (stop_hook_active), it lets the turn end, so it never loops. Any error
15
+ * lets the turn end too.
16
+ */
17
+ import { bareIds, checkHandback, looksLikeHandback } from "../handback.js";
18
+
19
+ /** The hook's stdout for one Stop input ("" lets the turn end). */
20
+ export function stopDecision(input: unknown): string {
21
+ if (!input || typeof input !== "object") return "";
22
+ const i = input as {
23
+ stop_hook_active?: unknown;
24
+ last_assistant_message?: unknown;
25
+ };
26
+ if (i.stop_hook_active === true) return "";
27
+ const msg = i.last_assistant_message;
28
+ if (typeof msg !== "string") return "";
29
+ // A hand-back is checked whole. Any other final message is what the
30
+ // person reads too, so it gets the plain-writing check: no bare IDs.
31
+ const handback = looksLikeHandback(msg);
32
+ const problems = handback
33
+ ? checkHandback(msg).problems
34
+ : bareIds(msg).map(
35
+ (id) =>
36
+ `"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
37
+ );
38
+ if (problems.length === 0) return "";
39
+ return JSON.stringify({
40
+ decision: "block",
41
+ reason: [
42
+ handback
43
+ ? "Den checked your hand-back and it is not ready. Fix these, then send it again:"
44
+ : "Den checked your message to the person and it is not plain yet. Fix these, then send it again:",
45
+ ...problems.map((p) => `- ${p}`),
46
+ ].join("\n"),
47
+ });
48
+ }
49
+
50
+ async function main(): Promise<void> {
51
+ let text = "";
52
+ for await (const chunk of process.stdin) text += String(chunk);
53
+ let out = "";
54
+ try {
55
+ out = stopDecision(JSON.parse(text));
56
+ } catch {
57
+ out = "";
58
+ }
59
+ if (out) process.stdout.write(`${out}\n`);
60
+ }
61
+
62
+ if (process.argv[1] && /stop-hook\.[jt]s$/.test(process.argv[1]))
63
+ main().then(
64
+ () => process.exit(0),
65
+ () => process.exit(0),
66
+ );
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Guardrails (den#5): what Den adds to a launch of a default-pack persona,
3
+ * from Den-owned files in guardrails/:
4
+ *
5
+ * instructions.md the shared persona instructions (plain writing, reading
6
+ * and waiting, the hand-back template). Appended to the
7
+ * role prompt (personas.ts rolePrompt), so every runtime
8
+ * gets them.
9
+ * hooks.json a Claude Code settings fragment: a PreToolUse hook on
10
+ * Bash and Read (pretooluse.mjs) and a Stop hook
11
+ * (stop-hook.ts, the hand-back check). den-claude merges
12
+ * it into the agent's own --settings file; Den never
13
+ * writes the user's ~/.claude.
14
+ *
15
+ * On by default for Den's default pack, off for a distribution's packs.
16
+ * A distribution turns them off or on per pack (agents.default_pack, a
17
+ * persona_packs entry's `hooks` / `instructions`) and per persona (front
18
+ * matter `den_hooks` / `den_instructions`), the persona winning.
19
+ */
20
+ import * as fs from "fs";
21
+ import * as path from "path";
22
+ import { enginePath, scriptEntrypoint, type EngineCommand } from "../engine.js";
23
+
24
+ export interface Guardrails {
25
+ /** Den's Claude Code hooks (PreToolUse reads and sleeps, Stop hand-back). */
26
+ hooks: boolean;
27
+ /** The shared persona instructions in the role prompt. */
28
+ instructions: boolean;
29
+ }
30
+
31
+ /** Den's default pack: both on. */
32
+ export const DEFAULT_PACK_GUARDRAILS: Guardrails = {
33
+ hooks: true,
34
+ instructions: true,
35
+ };
36
+
37
+ /** A distribution's pack: both off unless it opts in. */
38
+ export const PACK_GUARDRAILS: Guardrails = {
39
+ hooks: false,
40
+ instructions: false,
41
+ };
42
+
43
+ /** Where the guardrail files ship (src/ ships whole). */
44
+ export const GUARDRAILS_DIR = enginePath(
45
+ "src",
46
+ "shared",
47
+ "agents",
48
+ "guardrails",
49
+ );
50
+
51
+ /** The shared persona instructions, as Den ships them. */
52
+ export function sharedInstructions(dir: string = GUARDRAILS_DIR): string {
53
+ return fs.readFileSync(path.join(dir, "instructions.md"), "utf-8").trim();
54
+ }
55
+
56
+ /** One argument for a POSIX shell, single-quoted when it needs it. */
57
+ export function shellQuote(s: string): string {
58
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(s)
59
+ ? s
60
+ : `'${s.replace(/'/g, `'\\''`)}'`;
61
+ }
62
+
63
+ const commandLine = (c: EngineCommand): string =>
64
+ [c.command, ...c.args].map(shellQuote).join(" ");
65
+
66
+ export interface HookCommands {
67
+ /** The PreToolUse hook's command line. */
68
+ pretooluse: string;
69
+ /** The Stop hook's command line. */
70
+ stop: string;
71
+ }
72
+
73
+ /** The command lines Den's hooks run: this node, Den's own scripts. */
74
+ export function hookCommands(
75
+ dir: string = GUARDRAILS_DIR,
76
+ node: string = process.execPath,
77
+ ): HookCommands {
78
+ return {
79
+ pretooluse: commandLine({
80
+ command: node,
81
+ args: [path.join(dir, "pretooluse.mjs")],
82
+ }),
83
+ stop: commandLine(
84
+ scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts"),
85
+ ),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The `hooks` block of the agent's settings file: hooks.json with each
91
+ * `{pretooluse}` / `{stop}` placeholder replaced by its command line.
92
+ */
93
+ export function guardrailHooks(
94
+ dir: string = GUARDRAILS_DIR,
95
+ cmds: HookCommands = hookCommands(dir),
96
+ ): Record<string, unknown> {
97
+ const fragment = JSON.parse(
98
+ fs.readFileSync(path.join(dir, "hooks.json"), "utf-8"),
99
+ ) as { hooks: Record<string, unknown> };
100
+ const fill = (v: unknown): unknown => {
101
+ if (typeof v === "string")
102
+ return v.replace(
103
+ /\{(pretooluse|stop)\}/g,
104
+ (_, k: keyof HookCommands) => cmds[k],
105
+ );
106
+ if (Array.isArray(v)) return v.map(fill);
107
+ if (v && typeof v === "object")
108
+ return Object.fromEntries(
109
+ Object.entries(v).map(([k, x]) => [k, fill(x)]),
110
+ );
111
+ return v;
112
+ };
113
+ return fill(fragment.hooks) as Record<string, unknown>;
114
+ }