@monster-orange/den 0.4.5 → 0.4.7

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 (36) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +21 -7
  3. package/docs/distributions.md +221 -0
  4. package/package.json +4 -3
  5. package/skills/install-den/SKILL.md +114 -0
  6. package/src/shared/agent-setup.ts +9 -0
  7. package/src/shared/agents/den-claude.ts +19 -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 +55 -6
  15. package/src/shared/constants.ts +7 -0
  16. package/src/shared/den-config.ts +67 -6
  17. package/src/shared/dev-metrics/emit.ts +59 -5
  18. package/src/shared/dev-metrics/polling.ts +135 -0
  19. package/src/shared/engine.ts +14 -2
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/ui/cli/agent-ops.ts +7 -3
  22. package/src/ui/cli/agents-command.ts +51 -1
  23. package/src/ui/cli/create-main.ts +1 -1
  24. package/src/ui/cli/create.ts +263 -30
  25. package/src/ui/cli/doctor-command.ts +87 -4
  26. package/src/ui/cli/doctor-onboard.ts +291 -0
  27. package/src/ui/cli/flag-parser.ts +5 -0
  28. package/src/ui/cli/index.ts +352 -5
  29. package/src/ui/cli/json-out.ts +49 -0
  30. package/src/ui/cli/mcp-connect.ts +179 -15
  31. package/src/ui/cli/repo-command.ts +490 -0
  32. package/src/ui/cli/repo-detect.ts +192 -0
  33. package/src/ui/cli/web-command.ts +3 -0
  34. package/src/ui/mcp/connect.ts +134 -0
  35. package/src/ui/server/public/settings-registry.js +3 -3
  36. package/src/ui/server/request-guard.ts +1 -1
@@ -19,6 +19,12 @@
19
19
  * else). A DSN, in the env or a synced dev-metrics.config, is where to send
20
20
  * once opted in; on its own it never sends.
21
21
  *
22
+ * Each line also has `cwd` (den#5): where the command ran, relative to the
23
+ * distribution root ("." for the root itself), or "outside" for anywhere
24
+ * else, never an absolute or home path. `<cli> doctor` reads it to spot
25
+ * polling loops (polling.ts). Like every field, it is sent only when opted
26
+ * in; the envelope format is unchanged.
27
+ *
22
28
  * Guarantees: never throws, never blocks the caller (the Sentry ship is
23
29
  * fire-and-forget, bounded to ~2s). CI=true / DEV_METRICS_DISABLED=1 suppress
24
30
  * shipping but still buffer the JSONL locally.
@@ -31,7 +37,7 @@
31
37
 
32
38
  import * as fs from "fs";
33
39
  import * as path from "path";
34
- import { execFileSync } from "child_process";
40
+ import { execFileSync, spawn } from "child_process";
35
41
  import { createHash, randomUUID } from "crypto";
36
42
  import * as yaml from "yaml";
37
43
  import { getDistRoot } from "../paths.js";
@@ -115,6 +121,29 @@ function commonFields(event: string, root: string): Record<string, string> {
115
121
  };
116
122
  }
117
123
 
124
+ /** The JSONL file, under the repo root. */
125
+ export function eventsFile(root: string = repoRoot()): string {
126
+ return path.join(root, ".dev-metrics", "events.jsonl");
127
+ }
128
+
129
+ /**
130
+ * `cwd` for a local line: the path from the root to cwd ("." for the root,
131
+ * "/"-separated), or "outside" when cwd is not under the root.
132
+ */
133
+ export function cwdField(root: string, cwd: string = process.cwd()): string {
134
+ const real = (p: string): string => {
135
+ try {
136
+ return fs.realpathSync(p);
137
+ } catch {
138
+ return path.resolve(p);
139
+ }
140
+ };
141
+ const rel = path.relative(real(root), real(cwd));
142
+ if (rel === "") return ".";
143
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return "outside";
144
+ return rel.split(path.sep).join("/");
145
+ }
146
+
118
147
  function isoNoMillis(): string {
119
148
  return new Date().toISOString().replace(/\.\d+Z$/, "Z");
120
149
  }
@@ -241,6 +270,32 @@ export function buildEnvelope(
241
270
  return `{}\n${itemHeader}\n${payload}\n`;
242
271
  }
243
272
 
273
+ /**
274
+ * Send from a short-lived detached process instead of this one. The CLI
275
+ * records its run in the `exit` handler, where an async fetch never runs;
276
+ * a detached sender outlives the CLI and gives up after SHIP_TIMEOUT_MS.
277
+ * The long-lived server keeps the in-process fetch.
278
+ */
279
+ let shipDetached = false;
280
+ export function shipFromDetachedSender(): void {
281
+ shipDetached = true;
282
+ }
283
+
284
+ const SENDER = `fetch(process.env.DM_URL,{method:"POST",headers:{"Content-Type":"application/x-sentry-envelope","X-Sentry-Auth":process.env.DM_AUTH},body:process.env.DM_BODY,signal:AbortSignal.timeout(${SHIP_TIMEOUT_MS})}).catch(()=>{}).finally(()=>process.exit(0))`;
285
+
286
+ function sendDetached(url: string, auth: string, body: string): void {
287
+ try {
288
+ spawn(process.execPath, ["-e", SENDER], {
289
+ detached: true,
290
+ stdio: "ignore",
291
+ // Only what the sender needs: none of the caller's environment.
292
+ env: { DM_URL: url, DM_AUTH: auth, DM_BODY: body },
293
+ }).unref();
294
+ } catch {
295
+ /* fail-open: the JSONL line is already buffered */
296
+ }
297
+ }
298
+
244
299
  function ship(
245
300
  record: Record<string, string>,
246
301
  epoch: number,
@@ -266,6 +321,7 @@ function ship(
266
321
 
267
322
  const url = `${parsed.scheme}://${parsed.host}/api/${parsed.projectId}/envelope/`;
268
323
  const auth = `Sentry sentry_key=${parsed.key}, sentry_version=7, sentry_client=dev-metrics-${product().cli}/1.0`;
324
+ if (shipDetached) return sendDetached(url, auth, envelope);
269
325
  // Fire-and-forget: never awaited, so the caller never blocks. Bounded by a 2s
270
326
  // abort so a hung ingest can't leak a pending request in the long-lived server.
271
327
  void fetch(url, {
@@ -307,14 +363,12 @@ export function record(
307
363
  for (const [k, v] of Object.entries(fields)) {
308
364
  obj[k] = v == null ? "" : String(v);
309
365
  }
366
+ obj.cwd = cwdField(root);
310
367
 
311
368
  try {
312
369
  const dir = path.join(root, ".dev-metrics");
313
370
  fs.mkdirSync(dir, { recursive: true });
314
- fs.appendFileSync(
315
- path.join(dir, "events.jsonl"),
316
- JSON.stringify(obj) + "\n",
317
- );
371
+ fs.appendFileSync(eventsFile(root), JSON.stringify(obj) + "\n");
318
372
  } catch {
319
373
  /* non-writable dir → fail-open */
320
374
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Polling-loop warning (den#5) for `<cli> doctor`: one command, run from
3
+ * one place, more than POLL_LIMIT times in any POLL_WINDOW_MS window looks
4
+ * like an agent polling it. Local only: it reads the tail of the local
5
+ * .dev-metrics/events.jsonl and sends nothing anywhere.
6
+ *
7
+ * A line's place is its `cwd` (emit.ts). An older line has none, so it is
8
+ * keyed by its `repo` instead.
9
+ */
10
+ import * as fs from "fs";
11
+
12
+ /** More calls than this in one window is a polling loop. */
13
+ export const POLL_LIMIT = 600;
14
+ /** The window: an hour. */
15
+ export const POLL_WINDOW_MS = 60 * 60 * 1000;
16
+ /** At most this much of the file's end is read, however big it is. */
17
+ export const TAIL_BYTES = 8 * 1024 * 1024;
18
+
19
+ /**
20
+ * The file's last `max` bytes as whole lines: a line cut by the start of
21
+ * the slice is dropped. A missing or unreadable file is no lines.
22
+ */
23
+ export function readTailLines(
24
+ file: string,
25
+ max: number = TAIL_BYTES,
26
+ ): string[] {
27
+ let fd: number | undefined;
28
+ try {
29
+ fd = fs.openSync(file, "r");
30
+ const size = fs.fstatSync(fd).size;
31
+ const len = Math.min(size, max);
32
+ const buf = Buffer.alloc(len);
33
+ let got = 0;
34
+ while (got < len) {
35
+ const n = fs.readSync(fd, buf, got, len - got, size - len + got);
36
+ if (n <= 0) break;
37
+ got += n;
38
+ }
39
+ const lines = buf.subarray(0, got).toString("utf-8").split("\n");
40
+ if (len < size) lines.shift();
41
+ return lines.filter((l) => l.trim());
42
+ } catch {
43
+ return [];
44
+ } finally {
45
+ if (fd !== undefined) fs.closeSync(fd);
46
+ }
47
+ }
48
+
49
+ export interface PollingLoop {
50
+ command: string;
51
+ /** Where: the line's cwd, or `repo <name>` for an older line. */
52
+ where: string;
53
+ /** Calls in the busiest window. */
54
+ calls: number;
55
+ /** The busiest window's first and last call (ISO, no millis). */
56
+ from: string;
57
+ to: string;
58
+ }
59
+
60
+ const iso = (ms: number): string =>
61
+ new Date(ms).toISOString().replace(/\.\d+Z$/, "Z");
62
+
63
+ /**
64
+ * Every command+place with more than `limit` calls in some `windowMs`
65
+ * window, with its busiest window. Lines that aren't JSON, or have no
66
+ * command or time, are skipped.
67
+ */
68
+ export function findPollingLoops(
69
+ lines: string[],
70
+ limit: number = POLL_LIMIT,
71
+ windowMs: number = POLL_WINDOW_MS,
72
+ ): PollingLoop[] {
73
+ const byKey = new Map<
74
+ string,
75
+ { command: string; where: string; times: number[] }
76
+ >();
77
+ for (const line of lines) {
78
+ let o: Record<string, unknown>;
79
+ try {
80
+ o = JSON.parse(line) as Record<string, unknown>;
81
+ } catch {
82
+ continue;
83
+ }
84
+ if (!o || typeof o !== "object") continue;
85
+ const command = typeof o.command === "string" ? o.command : "";
86
+ const t = typeof o.ts === "string" ? Date.parse(o.ts) : NaN;
87
+ if (!command || Number.isNaN(t)) continue;
88
+ const where =
89
+ typeof o.cwd === "string" && o.cwd
90
+ ? o.cwd
91
+ : `repo ${typeof o.repo === "string" && o.repo ? o.repo : "?"}`;
92
+ const key = `${command}\u0000${where}`;
93
+ let e = byKey.get(key);
94
+ if (!e) byKey.set(key, (e = { command, where, times: [] }));
95
+ e.times.push(t);
96
+ }
97
+ const out: PollingLoop[] = [];
98
+ for (const { command, where, times } of byKey.values()) {
99
+ if (times.length <= limit) continue;
100
+ times.sort((a, b) => a - b);
101
+ let best = 0;
102
+ let bi = 0;
103
+ let bj = 0;
104
+ for (let i = 0, j = 0; j < times.length; j++) {
105
+ while (times[j] - times[i] >= windowMs) i++;
106
+ if (j - i + 1 > best) {
107
+ best = j - i + 1;
108
+ bi = i;
109
+ bj = j;
110
+ }
111
+ }
112
+ if (best > limit)
113
+ out.push({
114
+ command,
115
+ where,
116
+ calls: best,
117
+ from: iso(times[bi]),
118
+ to: iso(times[bj]),
119
+ });
120
+ }
121
+ return out.sort((a, b) => b.calls - a.calls);
122
+ }
123
+
124
+ /** One warning line, as doctor prints it. */
125
+ export function pollingWarning(p: PollingLoop): string {
126
+ return `looks like a polling loop: ${p.command} in ${p.where}, ${p.calls} calls in an hour (${p.from}–${p.to})`;
127
+ }
128
+
129
+ /** doctor's warnings for this events file (none when it is fine or absent). */
130
+ export function pollingWarnings(
131
+ file: string,
132
+ max: number = TAIL_BYTES,
133
+ ): string[] {
134
+ return findPollingLoops(readTailLines(file, max)).map(pollingWarning);
135
+ }
@@ -93,11 +93,23 @@ export function playwrightCommand(...args: string[]): EngineCommand {
93
93
  * or with DEN_USE_DIST=1. Mirrors the web server's resolveServerEntrypoint.
94
94
  */
95
95
  export function cliEntrypoint(engine: string = getEngineRoot()): EngineCommand {
96
- const built = path.join(engine, "dist/src/ui/cli/index.js");
96
+ return scriptEntrypoint("src/ui/cli/index.ts", engine);
97
+ }
98
+
99
+ /**
100
+ * How to run one of Den's TypeScript entry points (`src/….ts`, relative to
101
+ * the engine root) as a node process: cliEntrypoint's rules, for any
102
+ * script (an agent's hook, guardrails/stop-hook.ts).
103
+ */
104
+ export function scriptEntrypoint(
105
+ rel: string,
106
+ engine: string = getEngineRoot(),
107
+ ): EngineCommand {
108
+ const built = path.join(engine, "dist", rel.replace(/\.ts$/, ".js"));
97
109
  if (process.env.DEN_USE_DIST === "1" && fs.existsSync(built))
98
110
  return { command: process.execPath, args: [built] };
99
111
  const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
100
- const src = path.join(engine, "src/ui/cli/index.ts");
112
+ const src = path.join(engine, rel);
101
113
  if (loader && fs.existsSync(src))
102
114
  return {
103
115
  command: process.execPath,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Picking a free localhost port for a new distribution's web server
3
+ * (`create`, ON-2), so a second Den on one machine doesn't collide with the
4
+ * first on DEFAULT_PORT.
5
+ *
6
+ * "In use" is checked without talking to whoever holds the port: a TCP
7
+ * connect that is closed as soon as it opens (no byte is sent), then a
8
+ * bind. Either one finding something there means in use.
9
+ */
10
+ import * as net from "net";
11
+ import { RESERVED_DEN_PORTS } from "./constants.js";
12
+
13
+ /** Whether something holds `port` on localhost. Never sends a byte. */
14
+ export function portInUse(port: number, host = "localhost"): Promise<boolean> {
15
+ return new Promise((resolve) => {
16
+ const sock = net.connect({ host, port });
17
+ const done = (inUse: boolean | null) => {
18
+ sock.destroy();
19
+ if (inUse !== null) return resolve(inUse);
20
+ // Nothing accepted a connection: can we bind it ourselves?
21
+ const srv = net.createServer();
22
+ srv.once("error", () => resolve(true));
23
+ srv.listen({ port, host, exclusive: true }, () =>
24
+ srv.close(() => resolve(false)),
25
+ );
26
+ };
27
+ sock.setTimeout(1_000);
28
+ sock.once("connect", () => done(true));
29
+ // Something there that doesn't complete a handshake: in use.
30
+ sock.once("timeout", () => done(true));
31
+ sock.once("error", (e: NodeJS.ErrnoException) =>
32
+ done(e.code === "ECONNREFUSED" ? null : true),
33
+ );
34
+ });
35
+ }
36
+
37
+ export interface PickPortOptions {
38
+ /** The port wanted (the default); the search goes up from here. */
39
+ start: number;
40
+ /** Never chosen. */
41
+ reserved?: readonly number[];
42
+ /** How many ports above `start` to try. */
43
+ span?: number;
44
+ inUse?: (port: number) => Promise<boolean>;
45
+ }
46
+
47
+ /**
48
+ * `start` when it is free, else the first free port above it that is not
49
+ * reserved; null when none in the span is free.
50
+ */
51
+ export async function pickFreePort(o: PickPortOptions): Promise<number | null> {
52
+ const reserved = o.reserved ?? RESERVED_DEN_PORTS;
53
+ const inUse = o.inUse ?? ((p: number) => portInUse(p));
54
+ const span = o.span ?? 100;
55
+ for (let p = o.start; p <= Math.min(o.start + span, 65535); p++) {
56
+ if (reserved.includes(p)) continue;
57
+ if (!(await inUse(p))) return p;
58
+ }
59
+ return null;
60
+ }
@@ -437,20 +437,24 @@ function personas(
437
437
  cfg: ResolvedAgentsConfig,
438
438
  ): Map<string, Persona> {
439
439
  try {
440
- return loadPersonas(resolvePackDirs(cfg.personaPacks, d.distRoot()));
440
+ return loadPersonas(
441
+ resolvePackDirs(cfg.personaPacks, d.distRoot()),
442
+ undefined,
443
+ { defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
444
+ );
441
445
  } catch (e) {
442
446
  throw refused((e as Error).message, [], 422);
443
447
  }
444
448
  }
445
449
 
446
450
  /** A persona as listed: everything but its role prompt. */
447
- export type ListedPersona = Omit<Persona, "prompt">;
451
+ export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet">;
448
452
 
449
453
  /** Den's default pack, then agents.persona_packs, by id. */
450
454
  export function listPersonas(d: AgentsResolved): ListedPersona[] {
451
455
  return [...personas(d, agentsConfig(d)).values()]
452
456
  .sort((a, b) => a.id.localeCompare(b.id))
453
- .map(({ prompt: _p, ...rest }) => rest);
457
+ .map(({ prompt: _p, guardrailsSet: _g, ...rest }) => rest);
454
458
  }
455
459
 
456
460
  export interface RuntimeChoice {
@@ -33,13 +33,18 @@
33
33
  * fix when it is missing. Exit 1 when the default runtime is
34
34
  * unavailable. It resolves the login shell's PATH first, as the
35
35
  * web server does at start.
36
+ * handback-check <file|-> (den#5) the hand-back check (handback.ts) on a
37
+ * file or stdin: each problem on its own line, exit 1 when there
38
+ * is one, 0 when the hand-back is complete and plain.
36
39
  *
37
40
  * Nothing here takes a grant: grants come from the org config only. The
38
41
  * host enforces agents.max_concurrent on every start, counting agents only.
39
42
  */
43
+ import * as fs from "fs";
40
44
  import { DEFAULT_RUNTIME_ID } from "../../shared/agents/runtimes.js";
41
45
  import { initLoginPath } from "../../shared/agents/runtime-search.js";
42
46
  import { HostError } from "../../shared/sessions/client.js";
47
+ import { checkHandback } from "../../shared/agents/handback.js";
43
48
  import {
44
49
  AgentError,
45
50
  listAgents,
@@ -75,7 +80,7 @@ export {
75
80
  } from "./agent-ops.js";
76
81
 
77
82
  export const AGENTS_HELP = {
78
- usage: "agents <list|start|stop|resume|personas|doctor> ...",
83
+ usage: "agents <list|start|stop|resume|personas|doctor|handback-check> ...",
79
84
  lines: [
80
85
  "Agents: a persona and a workspace, run by a runtime in a session that",
81
86
  "outlives the web server (see `{cli} sessions`).",
@@ -94,6 +99,10 @@ export const AGENTS_HELP = {
94
99
  " each runtime, installed first: status, the CLI it runs and where it came",
95
100
  " from, its version, the directories searched, and the fix; exit 1 when the",
96
101
  " default is missing. -w: as that workspace's agents find them",
102
+ "{cli} agents handback-check <file|-> [--json]",
103
+ " checks a hand-back to a person: URL, machine, branch, what changed, what",
104
+ " to click and decisions are all there, every ID is explained, and each",
105
+ " decision is a numbered yes/no question; exit 1 with what to fix",
97
106
  "",
98
107
  "Runtimes: den-claude (default; Claude Code's claude), codex (OpenAI's Codex",
99
108
  "CLI), gemini (Google's Gemini CLI), grok (xAI's Grok Build), and generic (the",
@@ -276,6 +285,45 @@ async function listPersonas(
276
285
  export interface AgentsDoctorDeps {
277
286
  /** Resolve the login shell's PATH first (default: initLoginPath). */
278
287
  loginPath?: () => Promise<void>;
288
+ /** Read a hand-back: a file path, or "-" for stdin (handback-check). */
289
+ readHandback?: (src: string) => Promise<string>;
290
+ }
291
+
292
+ async function readFileOrStdin(src: string): Promise<string> {
293
+ if (src !== "-") return fs.promises.readFile(src, "utf-8");
294
+ let text = "";
295
+ for await (const chunk of process.stdin) text += String(chunk);
296
+ return text;
297
+ }
298
+
299
+ /** `agents handback-check <file|-> [--json]`. */
300
+ async function handbackCheck(
301
+ args: string[],
302
+ d: AgentsResolved,
303
+ deps: AgentsDoctorDeps,
304
+ ): Promise<number> {
305
+ const where = "agents handback-check";
306
+ const p = parseArgs(where, args, { "--json": false });
307
+ if (p.positional.length !== 1 || p.rest)
308
+ throw new UsageError(`${where} takes one file, or - for stdin`);
309
+ const src = p.positional[0];
310
+ let text: string;
311
+ try {
312
+ text = await (deps.readHandback ?? readFileOrStdin)(src);
313
+ } catch (e) {
314
+ d.err(
315
+ `✗ ${where}: can't read ${src} (${(e as NodeJS.ErrnoException).code ?? "error"})`,
316
+ );
317
+ return 1;
318
+ }
319
+ const r = checkHandback(text);
320
+ if (p.flags["--json"]) d.out(JSON.stringify(r, null, 2));
321
+ else if (r.ok) d.out("✓ the hand-back is complete and plain");
322
+ else {
323
+ d.out("✗ the hand-back is not ready:");
324
+ for (const l of r.problems) d.out(` - ${l}`);
325
+ }
326
+ return r.ok ? 0 : 1;
279
327
  }
280
328
 
281
329
  const SOURCE_WORD: Record<string, string> = {
@@ -399,6 +447,8 @@ export async function cmdAgents(
399
447
  // -w checks the workspace exists, and reads its runtime paths.
400
448
  await loadAgentWorkspaces(deps);
401
449
  return await doctor(rest, d, deps);
450
+ case "handback-check":
451
+ return await handbackCheck(rest, d, deps);
402
452
  default:
403
453
  throw new UsageError(`unknown agents command "${sub}"`);
404
454
  }
@@ -6,4 +6,4 @@
6
6
  import { runCreate } from "./create.js";
7
7
 
8
8
  const cwd = process.env.DEN_INVOKE_CWD?.trim() || process.cwd();
9
- process.exit(runCreate(process.argv.slice(2), cwd));
9
+ process.exit(await runCreate(process.argv.slice(2), cwd));