@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
@@ -15,7 +15,10 @@ import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import { ENGINE_PACKAGE, getEngineRoot } from "../../shared/paths.js";
17
17
  import { profileFrom } from "../../shared/profile.js";
18
+ import { parse as parseYaml } from "yaml";
18
19
  import { plural } from "../../shared/plural.js";
20
+ import { DEFAULT_PORT } from "../../shared/constants.js";
21
+ import { pickFreePort } from "../../shared/port-pick.js";
19
22
 
20
23
  export interface CreateOptions {
21
24
  /** Product name: lower-case `[a-z][a-z0-9-]*`. */
@@ -296,18 +299,34 @@ export function writeDistribution(dir: string, opts: CreateOptions): string[] {
296
299
 
297
300
  export interface ParsedCreateArgs extends Omit<CreateOptions, "denVersion"> {
298
301
  dir: string;
302
+ /** `--yes`: never prompt (create never does; accepted so agents can say so). */
303
+ yes: boolean;
304
+ /** `--json`: print one JSON object on stdout, nothing else. */
305
+ json: boolean;
306
+ /** `--port <n>`: the web UI's port, taken as given (no search). */
307
+ port?: number;
299
308
  }
300
309
 
301
310
  const CREATE_USAGE =
302
- "usage: den create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>]";
311
+ "usage: den create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>] [--port <n>] [--yes] [--json]";
303
312
 
304
313
  /** Parse `create` argv (after the word `create`). `cwd` resolves the dir. */
305
314
  export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
306
315
  const positional: string[] = [];
307
316
  const flags: Record<string, string> = {};
317
+ let yes = false;
318
+ let json = false;
308
319
  for (let i = 0; i < argv.length; i++) {
309
320
  const a = argv[i];
310
- const m = /^--(title|cli|env-prefix)(?:=(.*))?$/.exec(a);
321
+ if (a === "--yes" || a === "-y") {
322
+ yes = true;
323
+ continue;
324
+ }
325
+ if (a === "--json") {
326
+ json = true;
327
+ continue;
328
+ }
329
+ const m = /^--(title|cli|env-prefix|port)(?:=(.*))?$/.exec(a);
311
330
  if (m) {
312
331
  const value = m[2] ?? argv[++i];
313
332
  if (value === undefined)
@@ -326,9 +345,19 @@ export function parseCreateArgs(argv: string[], cwd: string): ParsedCreateArgs {
326
345
  title: flags.title,
327
346
  cli: flags.cli,
328
347
  envPrefix: flags["env-prefix"],
348
+ yes,
349
+ json,
350
+ ...(flags.port !== undefined ? { port: parsePortFlag(flags.port) } : {}),
329
351
  };
330
352
  }
331
353
 
354
+ function parsePortFlag(v: string): number {
355
+ const n = Number(v);
356
+ if (!/^\d+$/.test(v) || n < 1 || n > 65535)
357
+ throw new Error(`--port must be a port number\n${CREATE_USAGE}`);
358
+ return n;
359
+ }
360
+
332
361
  export const CREATE_HELP = `${CREATE_USAGE}
333
362
 
334
363
  Scaffold a new Den distribution in [dir] (default ./<name>):
@@ -338,41 +367,245 @@ config/environments/local.json, manifests/ and tests/pillars/.
338
367
 
339
368
  --title <title> brand name shown to people (default: <Name>)
340
369
  --cli <cli> the command people type (default: <name>)
341
- --env-prefix <PREFIX> env-var prefix (default: <NAME>)`;
370
+ --env-prefix <PREFIX> env-var prefix (default: <NAME>)
371
+ --port <n> the web UI's port (default: ${DEFAULT_PORT}, or the next
372
+ free one above it when that is in use)
373
+ --yes, -y never prompt (create never does; for agents)
374
+ --json print one JSON object: { ok, dir, cli, existed, port, next }
375
+
376
+ Safe to re-run: a [dir] that already holds this distribution answers ok
377
+ (existed: true) and writes nothing.`;
378
+
379
+ /**
380
+ * Whether `dir` already holds the distribution `create` would make: its
381
+ * den.yml names this product (and this cli, when one was given).
382
+ */
383
+ export function existingDistribution(
384
+ dir: string,
385
+ opts: { name: string; cli?: string },
386
+ ): boolean {
387
+ let text: string;
388
+ try {
389
+ text = fs.readFileSync(path.join(dir, "den.yml"), "utf-8");
390
+ } catch {
391
+ return false;
392
+ }
393
+ const name = /^\s+name:\s*(\S+)\s*$/m.exec(text)?.[1];
394
+ if (name !== opts.name) return false;
395
+ const cli = /^\s+cli:\s*(\S+)\s*$/m.exec(text)?.[1] ?? opts.name;
396
+ return cli === (opts.cli ?? opts.name);
397
+ }
398
+
399
+ /** What `create --json` prints. */
400
+ export interface CreateJson {
401
+ ok: boolean;
402
+ dir?: string;
403
+ cli?: string;
404
+ existed?: boolean;
405
+ files?: number;
406
+ /** The web UI's port for this distribution. */
407
+ port?: number;
408
+ /** Set when DEFAULT_PORT was in use and `port` was picked instead. */
409
+ portPicked?: { wanted: number; file: string };
410
+ next?: string[];
411
+ error?: string;
412
+ fix?: string;
413
+ ask?: string;
414
+ }
415
+
416
+ function cdPathFor(cwd: string, dir: string): string {
417
+ // Relative only when the new directory is under the caller's cwd; a
418
+ // path that climbs out (../../..) is harder to read than the absolute one.
419
+ const rel = path.relative(cwd, dir);
420
+ return rel === ""
421
+ ? "."
422
+ : rel.startsWith("..") || path.isAbsolute(rel)
423
+ ? dir
424
+ : rel;
425
+ }
426
+
427
+ /** The web port a distribution at `dir` is set to (user, then team, config). */
428
+ export function configuredWebPort(dir: string, name: string): number {
429
+ for (const rel of [
430
+ path.join("config", "local", "user.yml"),
431
+ path.join("config", `${name}.yml`),
432
+ ]) {
433
+ try {
434
+ const doc = parseYaml(fs.readFileSync(path.join(dir, rel), "utf-8")) as {
435
+ web?: { port?: unknown };
436
+ } | null;
437
+ const p = doc?.web?.port;
438
+ if (typeof p === "number" && Number.isInteger(p)) return p;
439
+ } catch {
440
+ /* absent or unreadable: next layer */
441
+ }
442
+ }
443
+ return DEFAULT_PORT;
444
+ }
445
+
446
+ export interface CreatePortOptions {
447
+ /**
448
+ * The port to start from: DEFAULT_PORT unless a test passes a port of its
449
+ * own (so no test probes the real default). The CLI reads it from
450
+ * DEN_WEB_PORT_START.
451
+ */
452
+ startPort?: number;
453
+ inUse?: (port: number) => Promise<boolean>;
454
+ }
342
455
 
343
- /** Run `create` end to end; returns the process exit code. */
344
- export function runCreate(argv: string[], cwd: string): number {
456
+ /** DEN_WEB_PORT_START, for tests and CI: never probe the real default. */
457
+ export function startPortFromEnv(
458
+ env: NodeJS.ProcessEnv = process.env,
459
+ ): number | undefined {
460
+ const v = env.DEN_WEB_PORT_START?.trim();
461
+ if (!v) return undefined;
462
+ const n = Number(v);
463
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
464
+ }
465
+
466
+ /**
467
+ * `create` as data: never prompts, never throws. A dir that already holds
468
+ * this distribution is ok (existed); any other non-empty dir is an error
469
+ * that names a fix.
470
+ *
471
+ * The web port: `--port` as given; else the default when it is free, else
472
+ * the next free port above it (never a reserved Den port), written to the
473
+ * new distribution's config/local/user.yml as `web.port`. Checking a port
474
+ * sends nothing to whoever holds it (port-pick.ts). A re-run reports the
475
+ * port the distribution is set to and picks nothing.
476
+ */
477
+ export async function createResult(
478
+ args: ParsedCreateArgs,
479
+ cwd: string,
480
+ denVersion: string = runningDenVersion(),
481
+ portOpts: CreatePortOptions = {},
482
+ ): Promise<CreateJson> {
483
+ const cli = args.cli ?? args.name;
484
+ const next = (dir: string): string[] => [
485
+ `cd ${cdPathFor(cwd, dir)}`,
486
+ "npm install",
487
+ `./bin/${cli} repo add <path|git-url> --json`,
488
+ ];
489
+ try {
490
+ validateCreateOptions({ ...args, denVersion });
491
+ if (existingDistribution(args.dir, args))
492
+ return {
493
+ ok: true,
494
+ dir: args.dir,
495
+ cli,
496
+ existed: true,
497
+ port: configuredWebPort(args.dir, args.name),
498
+ next: next(args.dir),
499
+ };
500
+ // Refuse a busy dir before looking for a port.
501
+ if (
502
+ fs.existsSync(args.dir) &&
503
+ fs.statSync(args.dir).isDirectory() &&
504
+ fs.readdirSync(args.dir).length > 0
505
+ )
506
+ writeDistribution(args.dir, { ...args, denVersion }); // throws "not empty"
507
+ const wanted = args.port ?? portOpts.startPort ?? DEFAULT_PORT;
508
+ const port =
509
+ args.port ??
510
+ (await pickFreePort({ start: wanted, inUse: portOpts.inUse })) ??
511
+ wanted;
512
+ const written = writeDistribution(args.dir, { ...args, denVersion });
513
+ let portPicked: CreateJson["portPicked"];
514
+ // Written when the port differs from what the distribution would use
515
+ // anyway (an explicit --port that isn't the default, or a picked one).
516
+ if (port !== (args.port !== undefined ? DEFAULT_PORT : wanted)) {
517
+ const rel = path.join("config", "local", "user.yml");
518
+ const file = path.join(args.dir, rel);
519
+ fs.mkdirSync(path.dirname(file), { recursive: true });
520
+ fs.writeFileSync(
521
+ file,
522
+ `# Yours, not the team's (gitignored). Set by create: ${
523
+ args.port !== undefined
524
+ ? "--port"
525
+ : `port ${wanted} was in use on this machine`
526
+ }.\nweb:\n port: ${port}\n`,
527
+ { encoding: "utf-8", flag: "wx" },
528
+ );
529
+ written.push(rel);
530
+ if (args.port === undefined) portPicked = { wanted, file: rel };
531
+ }
532
+ return {
533
+ ok: true,
534
+ dir: args.dir,
535
+ cli,
536
+ existed: false,
537
+ files: written.length,
538
+ port,
539
+ ...(portPicked ? { portPicked } : {}),
540
+ next: next(args.dir),
541
+ };
542
+ } catch (e) {
543
+ const error = (e as Error).message;
544
+ if (/is not empty/.test(error))
545
+ return {
546
+ ok: false,
547
+ error,
548
+ fix: `npx ${ENGINE_PACKAGE} create ${args.name} ${path.join(path.dirname(args.dir), `${args.name}-den`)} --yes --json`,
549
+ ask: `${args.dir} already has files that are not a ${args.name} distribution. Which new or empty folder should the distribution go in?`,
550
+ };
551
+ return {
552
+ ok: false,
553
+ error,
554
+ ask: "Which name should the distribution have? Lower-case letters, digits and dashes, starting with a letter.",
555
+ };
556
+ }
557
+ }
558
+
559
+ /** Run `create` end to end; resolves to the process exit code. */
560
+ export async function runCreate(
561
+ argv: string[],
562
+ cwd: string,
563
+ portOpts: CreatePortOptions = { startPort: startPortFromEnv() },
564
+ ): Promise<number> {
345
565
  if (argv.includes("-h") || argv.includes("--help")) {
346
566
  console.log(CREATE_HELP);
347
567
  return 0;
348
568
  }
569
+ const json = argv.includes("--json");
570
+ let args: ParsedCreateArgs;
349
571
  try {
350
- const args = parseCreateArgs(argv, cwd);
351
- const written = writeDistribution(args.dir, {
352
- ...args,
353
- denVersion: runningDenVersion(),
354
- });
355
- const cli = args.cli ?? args.name;
356
- // Relative only when the new directory is under the caller's cwd; a
357
- // path that climbs out (../../..) is harder to read than the absolute one.
358
- const rel = path.relative(cwd, args.dir);
359
- const cdPath =
360
- rel === ""
361
- ? "."
362
- : rel.startsWith("..") || path.isAbsolute(rel)
363
- ? args.dir
364
- : rel;
365
- console.log(
366
- `Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
367
- );
368
- console.log("");
369
- console.log("Next:");
370
- console.log(` cd ${cdPath}`);
371
- console.log(" npm install");
372
- console.log(` ./bin/${cli} --help`);
373
- return 0;
572
+ args = parseCreateArgs(argv, cwd);
374
573
  } catch (e) {
375
- console.error(`create: ${(e as Error).message}`);
574
+ if (json) {
575
+ console.log(
576
+ JSON.stringify({
577
+ ok: false,
578
+ error: (e as Error).message.split("\n")[0],
579
+ fix: `npx ${ENGINE_PACKAGE} create <name> [dir] --yes --json`,
580
+ } satisfies CreateJson),
581
+ );
582
+ } else console.error(`create: ${(e as Error).message}`);
583
+ return 1;
584
+ }
585
+ const r = await createResult(args, cwd, runningDenVersion(), portOpts);
586
+ if (json) {
587
+ console.log(JSON.stringify(r));
588
+ return r.ok ? 0 : 1;
589
+ }
590
+ if (!r.ok) {
591
+ console.error(`create: ${r.error}`);
376
592
  return 1;
377
593
  }
594
+ const dir = r.dir ?? args.dir;
595
+ console.log(
596
+ r.existed
597
+ ? `${args.name} is already in ${dir}: nothing to do.`
598
+ : `Created ${args.name} in ${dir} (${plural(r.files ?? 0, "file")}).`,
599
+ );
600
+ console.log(
601
+ r.portPicked
602
+ ? `Web UI port: ${r.port} (${r.portPicked.wanted} is in use here; set in ${r.portPicked.file}).`
603
+ : `Web UI port: ${r.port}.`,
604
+ );
605
+ console.log("");
606
+ console.log("Next:");
607
+ console.log(` cd ${cdPathFor(cwd, dir)}`);
608
+ console.log(" npm install");
609
+ console.log(` ./bin/${r.cli} --help`);
610
+ return 0;
378
611
  }
@@ -15,12 +15,19 @@
15
15
  * plain session and each available runtime's agent env (env-probe.ts), and
16
16
  * exits 1 when a runtime's scrubEnv name or a credential-shaped variable
17
17
  * reaches one. Without the PTY module the probe is unavailable, not failed.
18
+ *
19
+ * It also reads the tail of the local .dev-metrics/events.jsonl (bounded,
20
+ * polling.ts) and warns when one command, run from one place, ran more than
21
+ * 600 times in an hour: an agent polling it. A warning, never a failure;
22
+ * nothing is sent.
18
23
  */
19
24
  import chalk from "chalk";
20
25
  import { readDenConfig, type DenConfig } from "../../shared/den-config.js";
21
26
  import { getStateDir } from "../../shared/paths.js";
22
27
  import { product } from "../../shared/product.js";
23
28
  import { listRecords } from "../../shared/agents/records.js";
29
+ import { eventsFile } from "../../shared/dev-metrics/emit.js";
30
+ import { pollingWarnings } from "../../shared/dev-metrics/polling.js";
24
31
  import { readBrand } from "../../shared/brand.js";
25
32
  import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
26
33
  import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
@@ -40,6 +47,14 @@ import {
40
47
  DEFAULT_RUNTIME_ID,
41
48
  RUNTIME_KIND,
42
49
  } from "../../shared/agents/runtimes.js";
50
+ import {
51
+ defaultOnboardDeps,
52
+ onboardingChecks,
53
+ runnableCli,
54
+ type OnboardCheck,
55
+ type OnboardDeps,
56
+ } from "./doctor-onboard.js";
57
+ import { printJson } from "./json-out.js";
43
58
 
44
59
  export interface DoctorRow {
45
60
  kind: string;
@@ -211,6 +226,18 @@ export function doctorNotes(cli: string, configured: boolean): string[] {
211
226
  : [];
212
227
  }
213
228
 
229
+ /** The polling-loop warnings for this events file, as doctor prints them. */
230
+ export function pollingLines(file: string, title: string): string[] {
231
+ const warnings = pollingWarnings(file);
232
+ if (!warnings.length) return [];
233
+ return [
234
+ "",
235
+ chalk.bold(`${title} doctor: dev-metrics (local, never sent)`),
236
+ "",
237
+ ...warnings.map((w) => chalk.yellow(` ⚠ ${w}`)),
238
+ ];
239
+ }
240
+
214
241
  function readConfigOrNull(): DenConfig | null {
215
242
  try {
216
243
  return readDenConfig();
@@ -219,17 +246,73 @@ function readConfigOrNull(): DenConfig | null {
219
246
  }
220
247
  }
221
248
 
249
+ /** A strategy row as a `doctor --json` check. */
250
+ export function strategyCheck(r: DoctorRow): OnboardCheck {
251
+ const sel = r.selected
252
+ ? `${r.selected}${r.implicit ? " (implicit)" : ""}`
253
+ : "(invalid config)";
254
+ const why = r.reason ? `: ${r.reason}` : "";
255
+ return {
256
+ id: `strategy:${r.kind}`,
257
+ status: r.problem
258
+ ? "fail"
259
+ : !r.available || r.warnings.length
260
+ ? "warn"
261
+ : "ok",
262
+ detail: `${sel} ${r.available ? "available" : "unavailable"}${why}${r.warnings.length ? ` (${r.warnings.join("; ")})` : ""}`,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * `doctor --json` (ON-2): one JSON object, `{ ok, checks }`. The
268
+ * onboarding checks first (doctor-onboard.ts), then every strategy row;
269
+ * ok is false when any check fails. Exit 1 then, as for the human doctor.
270
+ */
271
+ export async function doctorJson(
272
+ deps: OnboardDeps,
273
+ rows: DoctorRow[] = doctorRows(),
274
+ ): Promise<{ ok: boolean; checks: OnboardCheck[] }> {
275
+ const checks = [
276
+ ...(await onboardingChecks(deps)),
277
+ ...rows.map(strategyCheck),
278
+ ];
279
+ return { ok: !checks.some((c) => c.status === "fail"), checks };
280
+ }
281
+
222
282
  export async function cmdDoctor(args: string[]): Promise<number> {
223
283
  const probe = args.includes("--probe");
224
- const unknown = args.filter((a) => a !== "--probe");
284
+ const json = args.includes("--json");
285
+ const unknown = args.filter((a) => a !== "--probe" && a !== "--json");
225
286
  if (unknown.length) {
226
- console.error(
227
- `✗ doctor: unknown argument ${unknown[0]} (doctor [--probe])`,
228
- );
287
+ const msg = `unknown argument ${unknown[0]} (doctor [--probe] [--json])`;
288
+ if (json)
289
+ printJson({
290
+ ok: false,
291
+ error: msg,
292
+ fix: `${runnableCli()} doctor --json`,
293
+ });
294
+ else console.error(`✗ doctor: ${msg}`);
229
295
  return 2;
230
296
  }
297
+ if (json) {
298
+ if (probe) {
299
+ printJson({
300
+ ok: false,
301
+ error: "--probe and --json do not combine: the probe starts sessions",
302
+ fix: `${runnableCli()} doctor --json`,
303
+ });
304
+ return 2;
305
+ }
306
+ const { probeWebServer } = await import("./mcp-connect.js");
307
+ const r = await doctorJson(
308
+ defaultOnboardDeps((url) => probeWebServer(url)),
309
+ );
310
+ printJson(r);
311
+ return r.ok ? 0 : 1;
312
+ }
231
313
  const rows = doctorRows();
232
314
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
315
+ for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
233
316
  const cli = product().cli;
234
317
  if (!probe) {
235
318
  for (const l of doctorNotes(