@whenlabs/when 0.9.3 → 0.10.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
@@ -51,20 +51,28 @@ These tools are available to Claude in every session after install:
51
51
  | `velocity_history` | Show task history |
52
52
  | `stale_scan` | Detect documentation drift |
53
53
  | `stale_fix` | Auto-fix documentation drift (wrong paths, dead links, phantom env vars) |
54
+ | `stale_auto_fix` | Scan + auto-fix drift in one call |
54
55
  | `envalid_validate` | Validate .env files against schemas |
55
56
  | `envalid_detect` | Find undocumented env vars in codebase |
56
57
  | `envalid_generate_schema` | Generate .env.schema from code analysis |
58
+ | `envalid_auto_fix` | Detect undocumented vars + auto-generate schema entries |
57
59
  | `berth_status` | Show active ports and conflicts |
58
60
  | `berth_check` | Scan project for port conflicts |
59
61
  | `berth_resolve` | Auto-resolve port conflicts (kill or reassign) |
62
+ | `berth_auto_resolve` | Check + auto-resolve conflicts in one call |
60
63
  | `aware_init` | Auto-detect stack, generate AI context files |
61
64
  | `aware_doctor` | Diagnose project health and config issues |
65
+ | `aware_auto_sync` | Diagnose + auto-sync stale AI context files |
62
66
  | `vow_scan` | Scan and summarize dependency licenses |
63
67
  | `vow_check` | Validate licenses against policy |
64
68
  | `vow_hook_install` | Install pre-commit license check hook |
65
69
 
66
70
  > This table shows a highlights subset. Run `when <tool> --help` for all available commands per tool.
67
71
 
72
+ ### Cross-tool Intelligence
73
+
74
+ Tools automatically suggest follow-up actions when they detect issues relevant to other tools. For example, `aware_init` triggers a `stale_scan` when it generates new files, and `envalid_detect` suggests `berth_register` when it finds service URL env vars. These cascading suggestions surface as "Tip:" lines in tool output.
75
+
68
76
  ## Multi-Editor Support
69
77
 
70
78
  Install MCP servers into other editors alongside Claude Code:
@@ -83,7 +91,10 @@ Without flags, `install` targets Claude Code only.
83
91
  You can also run tools directly from the command line:
84
92
 
85
93
  ```bash
86
- when init # Onboard a project — detect stack, run all tools
94
+ when init # Onboard a project — bootstrap configs, run all tools, auto-fix
95
+ when config # Show unified .whenlabs.yml config
96
+ when config init # Generate .whenlabs.yml from existing tool configs
97
+ when config validate # Validate config structure
87
98
  when stale scan
88
99
  when stale fix # Auto-fix documentation drift
89
100
  when envalid validate
@@ -102,7 +113,15 @@ when ci # Run checks for CI (exits 1 on issues)
102
113
 
103
114
  ### `when init`
104
115
 
105
- One command to onboard any project. Auto-detects your stack, runs all 5 CLI tools in parallel, generates AI context files if missing, and shows a summary with next steps.
116
+ One command to fully onboard any project:
117
+ 1. **Bootstrap** — creates `.env.schema`, `.vow.json`, `.stale.yml`, and registers berth ports based on your project
118
+ 2. **Scan** — runs all 5 CLI tools in parallel
119
+ 3. **Auto-fix** — automatically fixes stale drift if detected
120
+ 4. **Config** — generates a unified `.whenlabs.yml` from the bootstrapped configs
121
+
122
+ ### `when config`
123
+
124
+ Manage the unified `.whenlabs.yml` project config. All six tools read their settings from this single file instead of separate config files. Subcommands: `init` (generate from existing configs), `validate` (check structure).
106
125
 
107
126
  ### `when doctor`
108
127
 
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/find-bin.ts
4
+ import { resolve, dirname } from "path";
5
+ import { existsSync } from "fs";
6
+ import { fileURLToPath } from "url";
7
+ var __dirname = dirname(fileURLToPath(import.meta.url));
8
+ function findBin(name) {
9
+ const pkgRoot = resolve(__dirname, "../..");
10
+ const localBin = resolve(pkgRoot, "node_modules", ".bin", name);
11
+ if (existsSync(localBin)) return localBin;
12
+ const directCli = resolve(pkgRoot, "node_modules", "@whenlabs", name, "dist", "cli.js");
13
+ if (existsSync(directCli)) return directCli;
14
+ return name;
15
+ }
16
+
17
+ // src/config/whenlabs-config.ts
18
+ import { existsSync as existsSync2, readFileSync } from "fs";
19
+ import { resolve as resolve2 } from "path";
20
+ import { parse } from "yaml";
21
+ var CONFIG_FILENAME = ".whenlabs.yml";
22
+ function loadConfig(projectPath) {
23
+ const dir = projectPath ?? process.cwd();
24
+ const configPath = resolve2(dir, CONFIG_FILENAME);
25
+ if (!existsSync2(configPath)) return null;
26
+ try {
27
+ const raw = readFileSync(configPath, "utf-8");
28
+ const parsed = parse(raw);
29
+ if (!parsed || typeof parsed !== "object") return null;
30
+ return parsed;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export {
37
+ findBin,
38
+ CONFIG_FILENAME,
39
+ loadConfig
40
+ };
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- findBin
4
- } from "./chunk-2A2EZZF4.js";
3
+ CONFIG_FILENAME,
4
+ findBin,
5
+ loadConfig
6
+ } from "./chunk-JOMP6AU5.js";
5
7
  import {
6
8
  getStatusPath
7
9
  } from "./chunk-4ZVSCJCJ.js";
8
10
 
9
11
  // src/index.ts
10
- import { Command as Command5 } from "commander";
12
+ import { Command as Command6 } from "commander";
11
13
 
12
14
  // src/commands/delegate.ts
13
15
  import { Command } from "commander";
@@ -297,8 +299,9 @@ function createDoctorCommand() {
297
299
  import { Command as Command3 } from "commander";
298
300
  import { spawn as spawn3 } from "child_process";
299
301
  import { resolve as resolve2, dirname as dirname2, basename } from "path";
300
- import { existsSync as existsSync2, readFileSync } from "fs";
302
+ import { existsSync as existsSync2, readFileSync, writeFileSync } from "fs";
301
303
  import { fileURLToPath as fileURLToPath2 } from "url";
304
+ import { stringify } from "yaml";
302
305
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
303
306
  var c2 = {
304
307
  reset: "\x1B[0m",
@@ -312,12 +315,6 @@ var c2 = {
312
315
  function colorize2(text, ...codes) {
313
316
  return codes.join("") + text + c2.reset;
314
317
  }
315
- function findBin3(name) {
316
- const pkgRoot = resolve2(__dirname2, "..");
317
- const localBin = resolve2(pkgRoot, "node_modules", ".bin", name);
318
- if (existsSync2(localBin)) return localBin;
319
- return name;
320
- }
321
318
  function detectProject(cwd) {
322
319
  let name = basename(cwd);
323
320
  const pkgPath = resolve2(cwd, "package.json");
@@ -348,9 +345,25 @@ function detectProject(cwd) {
348
345
  }
349
346
  return { name, stack: stacks.length > 0 ? stacks.join(", ") : "unknown" };
350
347
  }
351
- function runTool2(bin, args) {
348
+ function detectLicenseTemplate(cwd) {
349
+ const pkgPath = resolve2(cwd, "package.json");
350
+ if (existsSync2(pkgPath)) {
351
+ try {
352
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
353
+ const license = (pkg.license ?? "").toLowerCase();
354
+ if (["mit", "isc", "apache-2.0", "apache2", "bsd-2-clause", "bsd-3-clause"].some((l) => license.includes(l))) {
355
+ return "opensource";
356
+ }
357
+ if (license) return "commercial";
358
+ } catch {
359
+ }
360
+ }
361
+ return "opensource";
362
+ }
363
+ function runTool2(bin, args, cwd) {
352
364
  return new Promise((resolveP) => {
353
365
  const child = spawn3(bin, args, {
366
+ cwd,
354
367
  env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }
355
368
  });
356
369
  let stdout = "";
@@ -365,8 +378,75 @@ function runTool2(bin, args) {
365
378
  child.on("close", (code) => resolveP({ stdout, stderr, exitCode: code ?? 1 }));
366
379
  });
367
380
  }
381
+ async function bootstrapConfigs(cwd) {
382
+ const results = [];
383
+ const hasEnv = existsSync2(resolve2(cwd, ".env"));
384
+ const hasEnvSchema = existsSync2(resolve2(cwd, ".env.schema"));
385
+ if (hasEnv && !hasEnvSchema) {
386
+ const { exitCode } = await runTool2(findBin("envalid"), ["init"], cwd);
387
+ if (exitCode === 0) {
388
+ results.push({ label: ".env.schema", action: "created", detail: "Created .env.schema from .env" });
389
+ } else if (exitCode === 127) {
390
+ results.push({ label: ".env.schema", action: "error", detail: "envalid not found" });
391
+ } else {
392
+ results.push({ label: ".env.schema", action: "error", detail: "envalid init failed" });
393
+ }
394
+ } else if (hasEnvSchema) {
395
+ results.push({ label: ".env.schema", action: "skipped", detail: "Skipped (already exists)" });
396
+ } else {
397
+ results.push({ label: ".env.schema", action: "skipped", detail: "Skipped (no .env found)" });
398
+ }
399
+ const hasVowConfig = existsSync2(resolve2(cwd, ".vow.json"));
400
+ if (!hasVowConfig) {
401
+ const template = detectLicenseTemplate(cwd);
402
+ const { exitCode } = await runTool2(findBin("vow"), ["init", "--template", template], cwd);
403
+ if (exitCode === 0) {
404
+ results.push({ label: ".vow.json", action: "created", detail: `Created .vow.json (template: ${template})` });
405
+ } else if (exitCode === 127) {
406
+ results.push({ label: ".vow.json", action: "error", detail: "vow not found" });
407
+ } else {
408
+ results.push({ label: ".vow.json", action: "error", detail: "vow init failed" });
409
+ }
410
+ } else {
411
+ results.push({ label: ".vow.json", action: "skipped", detail: "Skipped (already exists)" });
412
+ }
413
+ const hasStaleConfig = existsSync2(resolve2(cwd, ".stale.yml"));
414
+ let staleScanNeeded = false;
415
+ if (!hasStaleConfig) {
416
+ const { exitCode } = await runTool2(findBin("stale"), ["init"], cwd);
417
+ if (exitCode === 0) {
418
+ results.push({ label: ".stale.yml", action: "created", detail: "Created .stale.yml" });
419
+ staleScanNeeded = true;
420
+ } else if (exitCode === 127) {
421
+ results.push({ label: ".stale.yml", action: "error", detail: "stale not found" });
422
+ } else {
423
+ results.push({ label: ".stale.yml", action: "error", detail: "stale init failed" });
424
+ }
425
+ } else {
426
+ results.push({ label: ".stale.yml", action: "skipped", detail: "Skipped (already exists)" });
427
+ }
428
+ const { exitCode: berthCode } = await runTool2(findBin("berth"), ["register", "--yes", "--dir", cwd], cwd);
429
+ if (berthCode === 0) {
430
+ results.push({ label: "berth ports", action: "created", detail: "Registered project ports" });
431
+ } else if (berthCode === 127) {
432
+ results.push({ label: "berth ports", action: "error", detail: "berth not found" });
433
+ } else {
434
+ results.push({ label: "berth ports", action: "error", detail: "berth register failed" });
435
+ }
436
+ return { results, staleScanNeeded };
437
+ }
438
+ function bootstrapIcon(action) {
439
+ switch (action) {
440
+ case "created":
441
+ return colorize2("+", c2.green);
442
+ case "skipped":
443
+ return colorize2("-", c2.dim);
444
+ case "error":
445
+ return colorize2("!", c2.yellow);
446
+ }
447
+ }
368
448
  async function scanStale(cwd) {
369
- const { stdout, exitCode } = await runTool2(findBin3("stale"), ["scan", "--format", "json", "--path", cwd]);
449
+ const { stdout, exitCode } = await runTool2(findBin("stale"), ["scan", "--format", "json", "--path", cwd]);
370
450
  if (exitCode === 127) return { label: "Doc drift (stale)", status: "error", detail: "stale not found" };
371
451
  try {
372
452
  const json = JSON.parse(stdout);
@@ -377,7 +457,7 @@ async function scanStale(cwd) {
377
457
  }
378
458
  }
379
459
  async function scanEnvalid(cwd) {
380
- const { stdout, exitCode } = await runTool2(findBin3("envalid"), ["validate", "--format", "json"]);
460
+ const { stdout, exitCode } = await runTool2(findBin("envalid"), ["validate", "--format", "json"]);
381
461
  if (exitCode === 127) return { label: "Env validation (envalid)", status: "error", detail: "envalid not found" };
382
462
  if (exitCode === 2 || stdout.includes("not found")) return { label: "Env validation (envalid)", status: "skipped", detail: "No .env.schema \u2014 run `envalid init`" };
383
463
  try {
@@ -389,7 +469,7 @@ async function scanEnvalid(cwd) {
389
469
  }
390
470
  }
391
471
  async function scanBerth(cwd) {
392
- const { stdout, exitCode } = await runTool2(findBin3("berth"), ["check", cwd, "--json"]);
472
+ const { stdout, exitCode } = await runTool2(findBin("berth"), ["check", cwd, "--json"]);
393
473
  if (exitCode === 127) return { label: "Port conflicts (berth)", status: "error", detail: "berth not found" };
394
474
  try {
395
475
  const json = JSON.parse(stdout);
@@ -400,7 +480,7 @@ async function scanBerth(cwd) {
400
480
  }
401
481
  }
402
482
  async function scanVow(cwd) {
403
- const { stdout, exitCode } = await runTool2(findBin3("vow"), ["scan", "--format", "json", "--path", cwd]);
483
+ const { stdout, exitCode } = await runTool2(findBin("vow"), ["scan", "--format", "json", "--path", cwd]);
404
484
  if (exitCode === 127) return { label: "License scan (vow)", status: "error", detail: "vow not found" };
405
485
  const jsonStart = stdout.indexOf("{");
406
486
  const jsonStr = jsonStart >= 0 ? stdout.slice(jsonStart) : stdout;
@@ -418,12 +498,12 @@ async function scanVow(cwd) {
418
498
  async function scanAware(cwd) {
419
499
  const hasConfig = existsSync2(resolve2(cwd, ".aware.json"));
420
500
  if (!hasConfig) {
421
- const { exitCode: exitCode2 } = await runTool2(findBin3("aware"), ["init"]);
501
+ const { exitCode: exitCode2 } = await runTool2(findBin("aware"), ["init", "--force"], cwd);
422
502
  if (exitCode2 === 0) return { label: "AI context (aware)", status: "ok", detail: "Generated .aware.json and context files" };
423
503
  if (exitCode2 === 127) return { label: "AI context (aware)", status: "error", detail: "aware not found" };
424
504
  return { label: "AI context (aware)", status: "skipped", detail: "Could not generate \u2014 run `aware init` manually" };
425
505
  }
426
- const { stdout, stderr, exitCode } = await runTool2(findBin3("aware"), ["doctor"]);
506
+ const { stdout, stderr, exitCode } = await runTool2(findBin("aware"), ["doctor"], cwd);
427
507
  if (exitCode === 127) return { label: "AI context (aware)", status: "error", detail: "aware not found" };
428
508
  const combined = (stdout + stderr).trim();
429
509
  const warnings = combined.split("\n").filter((l) => l.includes("\u26A0") || /warn/i.test(l)).length;
@@ -443,7 +523,7 @@ function statusIcon2(status) {
443
523
  }
444
524
  function createInitCommand() {
445
525
  const cmd = new Command3("init");
446
- cmd.description("Interactive onboarding \u2014 detect stack, run all checks, suggest next steps");
526
+ cmd.description("Interactive onboarding \u2014 detect stack, bootstrap tool configs, run all checks");
447
527
  cmd.action(async () => {
448
528
  const cwd = process.cwd();
449
529
  console.log("");
@@ -454,6 +534,53 @@ function createInitCommand() {
454
534
  console.log(` Stack: ${colorize2(project.stack, c2.cyan)}`);
455
535
  console.log(` Path: ${colorize2(cwd, c2.dim)}`);
456
536
  console.log("");
537
+ process.stdout.write(colorize2(" Bootstrapping tool configs\u2026", c2.dim) + "\n");
538
+ const { results: bootstrapResults, staleScanNeeded } = await bootstrapConfigs(cwd);
539
+ process.stdout.write("\x1B[1A\x1B[2K");
540
+ console.log(colorize2(" Bootstrap", c2.bold));
541
+ console.log(colorize2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", c2.dim));
542
+ for (const r of bootstrapResults) {
543
+ const icon = bootstrapIcon(r.action);
544
+ const label = r.label.padEnd(20);
545
+ const detail = r.action === "created" ? colorize2(r.detail, c2.green) : r.action === "error" ? colorize2(r.detail, c2.yellow) : colorize2(r.detail, c2.dim);
546
+ console.log(` ${icon} ${label} ${detail}`);
547
+ }
548
+ const whenlabsConfigPath = resolve2(cwd, CONFIG_FILENAME);
549
+ if (!existsSync2(whenlabsConfigPath)) {
550
+ try {
551
+ const mergedConfig = {};
552
+ const staleConfigPath = resolve2(cwd, ".stale.yml");
553
+ if (existsSync2(staleConfigPath)) {
554
+ mergedConfig["stale"] = {};
555
+ }
556
+ const vowConfigPath = resolve2(cwd, ".vow.json");
557
+ if (existsSync2(vowConfigPath)) {
558
+ try {
559
+ const vowData = JSON.parse(readFileSync(vowConfigPath, "utf-8"));
560
+ mergedConfig["vow"] = {
561
+ ...typeof vowData.policy === "string" ? { policy: vowData.policy } : {},
562
+ ...typeof vowData.production_only === "boolean" ? { production_only: vowData.production_only } : {}
563
+ };
564
+ } catch {
565
+ mergedConfig["vow"] = {};
566
+ }
567
+ }
568
+ const envSchemaPath = resolve2(cwd, ".env.schema");
569
+ if (existsSync2(envSchemaPath)) {
570
+ mergedConfig["envalid"] = { schema: ".env.schema" };
571
+ }
572
+ mergedConfig["berth"] = {};
573
+ mergedConfig["aware"] = {};
574
+ mergedConfig["velocity"] = {};
575
+ writeFileSync(whenlabsConfigPath, stringify(mergedConfig, { lineWidth: 0 }), "utf-8");
576
+ console.log(` ${colorize2("+", c2.green)} ${colorize2(CONFIG_FILENAME, c2.bold)} ${colorize2("created", c2.green)}`);
577
+ } catch {
578
+ console.log(` ${colorize2("!", c2.yellow)} Could not generate ${colorize2(CONFIG_FILENAME, c2.bold)}`);
579
+ }
580
+ } else {
581
+ console.log(` ${colorize2("-", c2.dim)} ${colorize2(CONFIG_FILENAME, c2.bold)} ${colorize2("already exists", c2.dim)}`);
582
+ }
583
+ console.log("");
457
584
  process.stdout.write(colorize2(" Scanning project\u2026", c2.dim) + "\n");
458
585
  const results = await Promise.all([
459
586
  scanStale(cwd),
@@ -471,13 +598,32 @@ function createInitCommand() {
471
598
  const detail = r.status === "ok" ? colorize2(r.detail, c2.green) : r.status === "skipped" ? colorize2(r.detail, c2.dim) : r.status === "error" ? colorize2(r.detail, c2.yellow) : colorize2(r.detail, c2.red);
472
599
  console.log(` ${icon} ${label} ${detail}`);
473
600
  }
601
+ const staleResult = results.find((r) => r.label === "Doc drift (stale)");
602
+ if (staleResult?.status === "issues" || staleScanNeeded) {
603
+ process.stdout.write(colorize2(" Auto-fixing doc drift\u2026", c2.dim) + "\n");
604
+ const { exitCode: fixCode } = await runTool2(findBin("stale"), ["fix", "--apply"], cwd);
605
+ process.stdout.write("\x1B[1A\x1B[2K");
606
+ if (fixCode === 0) {
607
+ console.log(` ${colorize2("\u2713", c2.green)} ${colorize2("Doc drift auto-fixed", c2.green)}`);
608
+ } else if (fixCode === 127) {
609
+ console.log(` ${colorize2("!", c2.yellow)} ${colorize2("stale not found for auto-fix", c2.yellow)}`);
610
+ } else {
611
+ console.log(` ${colorize2("-", c2.dim)} ${colorize2("No high-confidence fixes available", c2.dim)}`);
612
+ }
613
+ }
474
614
  const issueCount = results.filter((r) => r.status === "issues").length;
475
615
  const errorCount = results.filter((r) => r.status === "error").length;
616
+ const bootstrapErrors = bootstrapResults.filter((r) => r.action === "error").length;
617
+ const bootstrapCreated = bootstrapResults.filter((r) => r.action === "created").length;
476
618
  console.log(colorize2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", c2.dim));
477
- if (issueCount + errorCount === 0) {
619
+ if (issueCount + errorCount + bootstrapErrors === 0) {
478
620
  console.log(colorize2(" All clear \u2014 project looks healthy!", c2.green, c2.bold));
479
621
  } else {
480
- console.log(colorize2(` ${issueCount} tool(s) found issues, ${errorCount} could not run`, c2.yellow, c2.bold));
622
+ const parts = [];
623
+ if (bootstrapCreated > 0) parts.push(`${bootstrapCreated} config(s) created`);
624
+ if (issueCount > 0) parts.push(`${issueCount} scan(s) found issues`);
625
+ if (errorCount + bootstrapErrors > 0) parts.push(`${errorCount + bootstrapErrors} tool(s) could not run`);
626
+ console.log(colorize2(` ${parts.join(", ")}`, c2.yellow, c2.bold));
481
627
  }
482
628
  console.log("");
483
629
  console.log(colorize2(" Next steps:", c2.bold));
@@ -498,7 +644,7 @@ function createInitCommand() {
498
644
  import { Command as Command4 } from "commander";
499
645
  import { join } from "path";
500
646
  import { homedir } from "os";
501
- import { mkdirSync, writeFileSync } from "fs";
647
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
502
648
  var STATUS_DIR = join(homedir(), ".whenlabs");
503
649
  function toolResultToStatus(r) {
504
650
  const count = r.issues + r.warnings;
@@ -535,10 +681,10 @@ function writeStatus(results) {
535
681
  },
536
682
  summary: buildSummary(results)
537
683
  };
538
- writeFileSync(getStatusPath(), JSON.stringify(status, null, 2) + "\n");
684
+ writeFileSync2(getStatusPath(), JSON.stringify(status, null, 2) + "\n");
539
685
  }
540
686
  function sleep(ms) {
541
- return new Promise((resolve4) => setTimeout(resolve4, ms));
687
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
542
688
  }
543
689
  function createWatchCommand() {
544
690
  const cmd = new Command4("watch");
@@ -585,16 +731,213 @@ function createWatchCommand() {
585
731
  return cmd;
586
732
  }
587
733
 
734
+ // src/commands/config.ts
735
+ import { Command as Command5 } from "commander";
736
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
737
+ import { resolve as resolve3 } from "path";
738
+ import { parse, stringify as stringify2 } from "yaml";
739
+ var c3 = {
740
+ reset: "\x1B[0m",
741
+ bold: "\x1B[1m",
742
+ green: "\x1B[32m",
743
+ yellow: "\x1B[33m",
744
+ red: "\x1B[31m",
745
+ cyan: "\x1B[36m",
746
+ dim: "\x1B[2m"
747
+ };
748
+ function colorize3(text, ...codes) {
749
+ return codes.join("") + text + c3.reset;
750
+ }
751
+ function readExistingToolConfigs(cwd) {
752
+ const config = {};
753
+ const stalePath = resolve3(cwd, ".stale.yml");
754
+ if (existsSync3(stalePath)) {
755
+ try {
756
+ const raw = readFileSync2(stalePath, "utf-8");
757
+ const parsed = parse(raw);
758
+ config.stale = {
759
+ ignore: Array.isArray(parsed?.ignore) ? parsed.ignore : void 0,
760
+ deep: typeof parsed?.deep === "boolean" ? parsed.deep : void 0
761
+ };
762
+ } catch {
763
+ config.stale = {};
764
+ }
765
+ }
766
+ const vowPath = resolve3(cwd, ".vow.json");
767
+ if (existsSync3(vowPath)) {
768
+ try {
769
+ const vow = JSON.parse(readFileSync2(vowPath, "utf-8"));
770
+ config.vow = {
771
+ policy: typeof vow.policy === "string" ? vow.policy : void 0,
772
+ production_only: typeof vow.production_only === "boolean" ? vow.production_only : void 0
773
+ };
774
+ } catch {
775
+ }
776
+ }
777
+ const schemaPath = resolve3(cwd, ".env.schema");
778
+ if (existsSync3(schemaPath)) {
779
+ config.envalid = { schema: ".env.schema" };
780
+ }
781
+ return config;
782
+ }
783
+ function generateDefaultConfig(cwd) {
784
+ const base = readExistingToolConfigs(cwd);
785
+ return {
786
+ stale: base.stale ?? {},
787
+ envalid: base.envalid ?? {},
788
+ vow: base.vow ?? {},
789
+ berth: {},
790
+ aware: {},
791
+ velocity: {}
792
+ };
793
+ }
794
+ function validateConfig(config) {
795
+ const errors = [];
796
+ if (config.stale !== void 0 && typeof config.stale !== "object") {
797
+ errors.push("stale: must be an object");
798
+ }
799
+ if (config.stale?.ignore !== void 0 && !Array.isArray(config.stale.ignore)) {
800
+ errors.push("stale.ignore: must be an array of strings");
801
+ }
802
+ if (config.stale?.deep !== void 0 && typeof config.stale.deep !== "boolean") {
803
+ errors.push("stale.deep: must be a boolean");
804
+ }
805
+ if (config.envalid !== void 0 && typeof config.envalid !== "object") {
806
+ errors.push("envalid: must be an object");
807
+ }
808
+ if (config.envalid?.schema !== void 0 && typeof config.envalid.schema !== "string") {
809
+ errors.push("envalid.schema: must be a string");
810
+ }
811
+ if (config.envalid?.environments !== void 0 && !Array.isArray(config.envalid.environments)) {
812
+ errors.push("envalid.environments: must be an array of strings");
813
+ }
814
+ if (config.vow !== void 0 && typeof config.vow !== "object") {
815
+ errors.push("vow: must be an object");
816
+ }
817
+ if (config.vow?.policy !== void 0 && typeof config.vow.policy !== "string") {
818
+ errors.push("vow.policy: must be a string");
819
+ }
820
+ if (config.vow?.production_only !== void 0 && typeof config.vow.production_only !== "boolean") {
821
+ errors.push("vow.production_only: must be a boolean");
822
+ }
823
+ if (config.berth !== void 0 && typeof config.berth !== "object") {
824
+ errors.push("berth: must be an object");
825
+ }
826
+ if (config.berth?.ports !== void 0) {
827
+ if (typeof config.berth.ports !== "object" || Array.isArray(config.berth.ports)) {
828
+ errors.push("berth.ports: must be a key/value map of port names to numbers");
829
+ } else {
830
+ for (const [k, v] of Object.entries(config.berth.ports)) {
831
+ if (typeof v !== "number") errors.push(`berth.ports.${k}: must be a number`);
832
+ }
833
+ }
834
+ }
835
+ if (config.aware !== void 0 && typeof config.aware !== "object") {
836
+ errors.push("aware: must be an object");
837
+ }
838
+ if (config.aware?.targets !== void 0 && !Array.isArray(config.aware.targets)) {
839
+ errors.push("aware.targets: must be an array of strings");
840
+ }
841
+ if (config.velocity !== void 0 && typeof config.velocity !== "object") {
842
+ errors.push("velocity: must be an object");
843
+ }
844
+ if (config.velocity?.project !== void 0 && typeof config.velocity.project !== "string") {
845
+ errors.push("velocity.project: must be a string");
846
+ }
847
+ return errors;
848
+ }
849
+ function createConfigCommand() {
850
+ const cmd = new Command5("config");
851
+ cmd.description("Manage unified .whenlabs.yml project config");
852
+ cmd.action(() => {
853
+ const cwd = process.cwd();
854
+ const configPath = resolve3(cwd, CONFIG_FILENAME);
855
+ console.log("");
856
+ console.log(colorize3(" when config", c3.bold, c3.cyan));
857
+ console.log(colorize3(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", c3.dim));
858
+ if (!existsSync3(configPath)) {
859
+ console.log(` ${colorize3("-", c3.dim)} No ${colorize3(CONFIG_FILENAME, c3.bold)} found`);
860
+ console.log(` ${colorize3("\u2022", c3.dim)} Run ${colorize3("when config init", c3.bold)} to generate one`);
861
+ console.log("");
862
+ return;
863
+ }
864
+ const raw = readFileSync2(configPath, "utf-8");
865
+ console.log(` ${colorize3(configPath, c3.dim)}`);
866
+ console.log("");
867
+ for (const line of raw.split("\n")) {
868
+ console.log(` ${line}`);
869
+ }
870
+ });
871
+ const initCmd = new Command5("init");
872
+ initCmd.description(`Generate ${CONFIG_FILENAME} from existing tool configs`);
873
+ initCmd.option("--force", "Overwrite existing config");
874
+ initCmd.action((options) => {
875
+ const cwd = process.cwd();
876
+ const configPath = resolve3(cwd, CONFIG_FILENAME);
877
+ console.log("");
878
+ console.log(colorize3(" when config init", c3.bold, c3.cyan));
879
+ console.log(colorize3(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", c3.dim));
880
+ if (existsSync3(configPath) && !options.force) {
881
+ console.log(` ${colorize3("-", c3.dim)} ${colorize3(CONFIG_FILENAME, c3.bold)} already exists \u2014 use ${colorize3("--force", c3.bold)} to overwrite`);
882
+ console.log("");
883
+ return;
884
+ }
885
+ const config = generateDefaultConfig(cwd);
886
+ const yaml = stringify2(config, { lineWidth: 0 });
887
+ writeFileSync3(configPath, yaml, "utf-8");
888
+ console.log(` ${colorize3("+", c3.green)} Created ${colorize3(CONFIG_FILENAME, c3.bold)}`);
889
+ console.log("");
890
+ for (const line of yaml.split("\n")) {
891
+ if (line.trim()) console.log(` ${colorize3(line, c3.dim)}`);
892
+ }
893
+ console.log("");
894
+ });
895
+ const validateCmd = new Command5("validate");
896
+ validateCmd.description(`Validate ${CONFIG_FILENAME} structure`);
897
+ validateCmd.action(() => {
898
+ const cwd = process.cwd();
899
+ const configPath = resolve3(cwd, CONFIG_FILENAME);
900
+ console.log("");
901
+ console.log(colorize3(" when config validate", c3.bold, c3.cyan));
902
+ console.log(colorize3(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", c3.dim));
903
+ if (!existsSync3(configPath)) {
904
+ console.log(` ${colorize3("-", c3.dim)} No ${colorize3(CONFIG_FILENAME, c3.bold)} found \u2014 nothing to validate`);
905
+ console.log("");
906
+ return;
907
+ }
908
+ const config = loadConfig(cwd);
909
+ if (!config) {
910
+ console.log(` ${colorize3("!", c3.yellow)} Could not parse ${colorize3(CONFIG_FILENAME, c3.bold)} \u2014 invalid YAML`);
911
+ console.log("");
912
+ process.exitCode = 1;
913
+ return;
914
+ }
915
+ const errors = validateConfig(config);
916
+ if (errors.length === 0) {
917
+ console.log(` ${colorize3("\u2713", c3.green)} ${colorize3(CONFIG_FILENAME, c3.bold)} is valid`);
918
+ } else {
919
+ for (const err of errors) {
920
+ console.log(` ${colorize3("\u2717", c3.red)} ${err}`);
921
+ }
922
+ process.exitCode = 1;
923
+ }
924
+ console.log("");
925
+ });
926
+ cmd.addCommand(initCmd);
927
+ cmd.addCommand(validateCmd);
928
+ return cmd;
929
+ }
930
+
588
931
  // src/index.ts
589
- import { readFileSync as readFileSync2 } from "fs";
590
- import { resolve as resolve3, dirname as dirname3 } from "path";
932
+ import { readFileSync as readFileSync3 } from "fs";
933
+ import { resolve as resolve4, dirname as dirname3 } from "path";
591
934
  import { fileURLToPath as fileURLToPath3 } from "url";
592
935
  var __dirname3 = dirname3(fileURLToPath3(import.meta.url));
593
- var { version } = JSON.parse(readFileSync2(resolve3(__dirname3, "..", "package.json"), "utf8"));
594
- var program = new Command5();
936
+ var { version } = JSON.parse(readFileSync3(resolve4(__dirname3, "..", "package.json"), "utf8"));
937
+ var program = new Command6();
595
938
  program.name("when").version(version).description("The WhenLabs developer toolkit \u2014 6 tools, one install");
596
939
  program.command("install").description("Install all WhenLabs tools globally (MCP server + CLAUDE.md instructions)").option("--cursor", "Install MCP servers into Cursor (~/.cursor/mcp.json)").option("--vscode", "Install MCP servers into VS Code (settings.json)").option("--windsurf", "Install MCP servers into Windsurf (~/.codeium/windsurf/mcp_config.json)").option("--all", "Install MCP servers into all supported editors").action(async (options) => {
597
- const { install } = await import("./install-V24JHOA2.js");
940
+ const { install } = await import("./install-33GE3HKA.js");
598
941
  await install(options);
599
942
  });
600
943
  program.command("uninstall").description("Remove all WhenLabs tools").option("--cursor", "Remove MCP servers from Cursor").option("--vscode", "Remove MCP servers from VS Code").option("--windsurf", "Remove MCP servers from Windsurf").option("--all", "Remove MCP servers from all supported editors").action(async (options) => {
@@ -612,6 +955,7 @@ program.command("ci").description("Run stale, envalid, and vow checks \u2014 exi
612
955
  program.addCommand(createInitCommand());
613
956
  program.addCommand(createDoctorCommand());
614
957
  program.addCommand(createWatchCommand());
958
+ program.addCommand(createConfigCommand());
615
959
  program.addCommand(createDelegateCommand("stale", "Detect documentation drift in your codebase"));
616
960
  program.addCommand(createDelegateCommand("envalid", "Validate .env files against a type-safe schema"));
617
961
  program.addCommand(createDelegateCommand("berth", "Detect and resolve port conflicts"));
@@ -60,14 +60,21 @@ All six tools (including velocity) are available through the unified \`whenlabs\
60
60
  ### Tool Reference
61
61
  - \`berth_status\` \u2014 Show all active ports, Docker ports, and configured ports
62
62
  - \`berth_check\` \u2014 Scan a project directory for port conflicts
63
+ - \`berth_auto_resolve\` \u2014 Check for conflicts and auto-resolve them in one step
63
64
  - \`stale_scan\` \u2014 Detect documentation drift in the codebase
65
+ - \`stale_auto_fix\` \u2014 Scan for drift and auto-fix high-confidence issues in one step
64
66
  - \`envalid_validate\` \u2014 Validate .env files against their schema
65
67
  - \`envalid_detect\` \u2014 Find undocumented env vars in codebase
68
+ - \`envalid_auto_fix\` \u2014 Detect undocumented env vars and auto-generate schema entries
66
69
  - \`aware_init\` \u2014 Auto-detect stack and generate AI context files
67
70
  - \`aware_doctor\` \u2014 Diagnose project health and config issues
71
+ - \`aware_auto_sync\` \u2014 Diagnose and auto-sync stale AI context files in one step
68
72
  - \`vow_scan\` \u2014 Scan and summarize all dependency licenses
69
73
  - \`vow_check\` \u2014 Validate licenses against a policy file
70
74
 
75
+ ### Cross-tool Intelligence
76
+ Tools provide follow-up suggestions when they detect issues relevant to other tools. When you see a "Tip:" in tool output, act on the suggestion \u2014 it means another tool can help.
77
+
71
78
  ### Proactive Background Scans
72
79
  WhenLabs tools run automatically in the background on a schedule. The status line shows findings:
73
80
  - \`stale:N\` \u2014 N docs have drifted from code. Run \`stale_scan\` and fix the drift.
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  findBin
4
- } from "./chunk-2A2EZZF4.js";
4
+ } from "./chunk-JOMP6AU5.js";
5
5
 
6
6
  // src/mcp/index.ts
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -25,7 +25,7 @@ import { z } from "zod";
25
25
  // src/mcp/run-cli.ts
26
26
  import { spawn } from "child_process";
27
27
  import { join } from "path";
28
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
28
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
29
29
  import { homedir } from "os";
30
30
  function runCli(bin, args, cwd) {
31
31
  return new Promise((res) => {
@@ -100,6 +100,39 @@ ${staleOutput}`);
100
100
  extras.push(`
101
101
  Note: Conflicts found in project "${projectName}".`);
102
102
  }
103
+ try {
104
+ const cacheFiles = readdirSync(CACHE_DIR).filter((f) => f.startsWith("stale_"));
105
+ for (const cacheFile of cacheFiles) {
106
+ const cached = JSON.parse(readFileSync(join(CACHE_DIR, cacheFile), "utf8"));
107
+ if (/\b\d{4,5}\b/.test(cached.output || "")) {
108
+ extras.push("\nTip: Port references found in documentation \u2014 stale_scan may need re-run after resolving conflicts.");
109
+ break;
110
+ }
111
+ }
112
+ } catch {
113
+ }
114
+ }
115
+ }
116
+ if (toolName === "envalid_detect") {
117
+ const serviceUrlMatches = output.match(/\b[A-Z_]*(?:HOST|PORT|URL|URI)[A-Z_]*\b/g);
118
+ if (serviceUrlMatches && serviceUrlMatches.length > 0) {
119
+ const examples = [...new Set(serviceUrlMatches)].slice(0, 3).join(", ");
120
+ extras.push(`
121
+ Tip: Service URLs detected (${examples}, etc.) \u2014 run berth_register to track their ports for conflict detection.`);
122
+ }
123
+ }
124
+ if (toolName === "velocity_end_task") {
125
+ const largeChange = /actual_files["\s:]+([1-9]\d)/i.test(output) || /\b([6-9]|\d{2,})\s+files?\b/i.test(output);
126
+ if (largeChange) {
127
+ extras.push("\nTip: Large change detected \u2014 consider running stale_scan to check for documentation drift.");
128
+ }
129
+ }
130
+ if (toolName === "vow_scan") {
131
+ const cacheFile = join(CACHE_DIR, `vow_scan_${deriveProject(path)}.json`);
132
+ const isFirstScan = !existsSync(cacheFile);
133
+ const hasNewPackages = /new package|added|installed/i.test(output);
134
+ if (isFirstScan || hasNewPackages) {
135
+ extras.push("\nTip: Dependency changes detected \u2014 run aware_sync to update AI context files with new library info.");
103
136
  }
104
137
  }
105
138
  return extras;
@@ -157,6 +190,32 @@ function registerStaleTools(server2) {
157
190
  return { content: [{ type: "text", text: output }] };
158
191
  }
159
192
  );
193
+ server2.tool(
194
+ "stale_auto_fix",
195
+ "Scan for documentation drift and auto-fix high-confidence issues in one step",
196
+ {
197
+ path: z.string().optional().describe("Project directory to scan (defaults to cwd)"),
198
+ deep: z.coerce.boolean().optional().describe("Enable AI-powered deep analysis")
199
+ },
200
+ async ({ path, deep }) => {
201
+ const scanArgs = ["scan"];
202
+ if (deep) scanArgs.push("--deep");
203
+ const scanResult = await runCli("stale", scanArgs, path);
204
+ const scanOutput = formatOutput(scanResult);
205
+ writeCache("stale_scan", deriveProject(path), scanOutput, scanResult.code);
206
+ writeCache("stale_auto_fix", deriveProject(path), scanOutput, scanResult.code);
207
+ if (scanResult.code !== 0) {
208
+ const fixResult = await runCli("stale", ["fix", "--apply"], path);
209
+ const fixOutput = formatOutput(fixResult);
210
+ const combined = `${scanOutput}
211
+ --- Auto-fix applied ---
212
+ ${fixOutput}`;
213
+ writeCache("stale_auto_fix", deriveProject(path), combined, fixResult.code);
214
+ return { content: [{ type: "text", text: combined }] };
215
+ }
216
+ return { content: [{ type: "text", text: scanOutput }] };
217
+ }
218
+ );
160
219
  }
161
220
 
162
221
  // src/mcp/envalid.ts
@@ -303,6 +362,28 @@ function registerEnvalidTools(server2) {
303
362
  return { content: [{ type: "text", text: output }] };
304
363
  }
305
364
  );
365
+ server2.tool(
366
+ "envalid_auto_fix",
367
+ "Detect undocumented env vars and auto-generate schema entries",
368
+ {
369
+ path: z2.string().optional().describe("Project directory (defaults to cwd)")
370
+ },
371
+ async ({ path }) => {
372
+ const detectResult = await runCli("envalid", ["detect"], path);
373
+ const detectOutput = formatOutput(detectResult);
374
+ writeCache("envalid_detect", deriveProject(path), detectOutput, detectResult.code);
375
+ const hasUndocumented = /undocumented|missing from schema/i.test(detectOutput);
376
+ if (hasUndocumented) {
377
+ const generateResult = await runCli("envalid", ["detect", "--generate"], path);
378
+ const generateOutput = formatOutput(generateResult);
379
+ const combined = `${detectOutput}
380
+ --- Auto-generated schema entries ---
381
+ ${generateOutput}`;
382
+ return { content: [{ type: "text", text: combined }] };
383
+ }
384
+ return { content: [{ type: "text", text: detectOutput }] };
385
+ }
386
+ );
306
387
  }
307
388
 
308
389
  // src/mcp/berth.ts
@@ -443,6 +524,30 @@ function registerBerthTools(server2) {
443
524
  return { content: [{ type: "text", text: output }] };
444
525
  }
445
526
  );
527
+ server2.tool(
528
+ "berth_auto_resolve",
529
+ "Check for port conflicts and auto-resolve them",
530
+ {
531
+ path: z3.string().optional().describe("Project directory (defaults to cwd)"),
532
+ strategy: z3.enum(["kill", "reassign", "auto"]).optional().describe("Resolution strategy (default: auto)")
533
+ },
534
+ async ({ path, strategy }) => {
535
+ const checkResult = await runCli("berth", ["check", path || "."]);
536
+ const checkOutput = formatOutput(checkResult);
537
+ writeCache("berth_check", deriveProject(path), checkOutput, checkResult.code);
538
+ const hasConflicts = /conflict/i.test(checkOutput);
539
+ if (hasConflicts) {
540
+ const resolveArgs = ["resolve", "--strategy", strategy || "auto", "--kill"];
541
+ const resolveResult = await runCli("berth", resolveArgs, path);
542
+ const resolveOutput = formatOutput(resolveResult);
543
+ const combined = `${checkOutput}
544
+ --- Auto-resolve applied ---
545
+ ${resolveOutput}`;
546
+ return { content: [{ type: "text", text: combined }] };
547
+ }
548
+ return { content: [{ type: "text", text: checkOutput }] };
549
+ }
550
+ );
446
551
  }
447
552
 
448
553
  // src/mcp/aware.ts
@@ -534,6 +639,28 @@ function registerAwareTools(server2) {
534
639
  return { content: [{ type: "text", text: output }] };
535
640
  }
536
641
  );
642
+ server2.tool(
643
+ "aware_auto_sync",
644
+ "Diagnose project health and auto-sync stale AI context files",
645
+ {
646
+ path: z4.string().optional().describe("Project directory (defaults to cwd)")
647
+ },
648
+ async ({ path }) => {
649
+ const doctorResult = await runCli("aware", ["doctor"], path);
650
+ const doctorOutput = formatOutput(doctorResult);
651
+ writeCache("aware_doctor", deriveProject(path), doctorOutput, doctorResult.code);
652
+ const needsSync = /stale|outdated|drift/i.test(doctorOutput);
653
+ if (needsSync) {
654
+ const syncResult = await runCli("aware", ["sync"], path);
655
+ const syncOutput = formatOutput(syncResult);
656
+ const combined = `${doctorOutput}
657
+ --- Auto-sync applied ---
658
+ ${syncOutput}`;
659
+ return { content: [{ type: "text", text: combined }] };
660
+ }
661
+ return { content: [{ type: "text", text: doctorOutput }] };
662
+ }
663
+ );
537
664
  }
538
665
 
539
666
  // src/mcp/vow.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whenlabs/when",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "The WhenLabs developer toolkit — 6 tools, one install",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,6 +36,7 @@
36
36
  "@whenlabs/velocity-mcp": "^0.1.3",
37
37
  "@whenlabs/vow": "^0.1.4",
38
38
  "commander": "^12.0.0",
39
+ "yaml": "^2.8.3",
39
40
  "zod": "^4.3.6"
40
41
  },
41
42
  "devDependencies": {
@@ -1,19 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/utils/find-bin.ts
4
- import { resolve, dirname } from "path";
5
- import { existsSync } from "fs";
6
- import { fileURLToPath } from "url";
7
- var __dirname = dirname(fileURLToPath(import.meta.url));
8
- function findBin(name) {
9
- const pkgRoot = resolve(__dirname, "../..");
10
- const localBin = resolve(pkgRoot, "node_modules", ".bin", name);
11
- if (existsSync(localBin)) return localBin;
12
- const directCli = resolve(pkgRoot, "node_modules", "@whenlabs", name, "dist", "cli.js");
13
- if (existsSync(directCli)) return directCli;
14
- return name;
15
- }
16
-
17
- export {
18
- findBin
19
- };