@whenlabs/when 0.9.3 → 0.11.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
 
package/action.yml CHANGED
@@ -1,17 +1,79 @@
1
1
  name: 'WhenLabs CI Check'
2
2
  description: 'Run documentation drift, env validation, and license checks'
3
+ branding:
4
+ icon: 'check-circle'
5
+ color: 'orange'
3
6
  inputs:
4
7
  tools:
5
- description: 'Comma-separated tools to run'
8
+ description: 'Comma-separated tools to run (stale, envalid, vow)'
6
9
  default: 'stale,envalid,vow'
10
+ checks:
11
+ description: 'Alias for tools — select which checks to run (stale, envalid, vow)'
12
+ default: ''
7
13
  fail_on:
8
14
  description: 'Fail on: error, warning, or none'
9
15
  default: 'error'
16
+ comment:
17
+ description: 'Post results as a PR comment when running on a pull request'
18
+ default: 'false'
10
19
  runs:
11
20
  using: 'composite'
12
21
  steps:
13
22
  - uses: actions/setup-node@v4
14
23
  with:
15
24
  node-version: '20'
16
- - run: npx @whenlabs/when ci --ci
25
+
26
+ - name: Cache npm
27
+ uses: actions/cache@v4
28
+ with:
29
+ path: ~/.npm
30
+ key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
31
+ restore-keys: |
32
+ ${{ runner.os }}-node-
33
+
34
+ - name: Run WhenLabs checks
35
+ id: whenlabs_check
36
+ run: |
37
+ SELECTED="${{ inputs.checks != '' && inputs.checks || inputs.tools }}"
38
+ WHENLABS_TOOLS="$SELECTED" npx @whenlabs/when ci --ci 2>&1 | tee /tmp/whenlabs-output.txt
39
+ echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
40
+ shell: bash
41
+
42
+ - name: Post PR comment
43
+ if: ${{ inputs.comment == 'true' && github.event_name == 'pull_request' }}
44
+ uses: actions/github-script@v7
45
+ with:
46
+ script: |
47
+ const fs = require('fs');
48
+ const output = fs.readFileSync('/tmp/whenlabs-output.txt', 'utf8');
49
+ const exitCode = '${{ steps.whenlabs_check.outputs.exit_code }}';
50
+ const status = exitCode === '0' ? '✅ All checks passed' : '❌ Issues found';
51
+ const body = [
52
+ '## WhenLabs CI Check',
53
+ '',
54
+ status,
55
+ '',
56
+ '<details><summary>Full output</summary>',
57
+ '',
58
+ '```',
59
+ output.trim(),
60
+ '```',
61
+ '',
62
+ '</details>',
63
+ ].join('\n');
64
+ await github.rest.issues.createComment({
65
+ owner: context.repo.owner,
66
+ repo: context.repo.repo,
67
+ issue_number: context.issue.number,
68
+ body,
69
+ });
70
+
71
+ - name: Fail if issues found
72
+ if: ${{ inputs.fail_on != 'none' }}
73
+ run: |
74
+ EXIT="${{ steps.whenlabs_check.outputs.exit_code }}"
75
+ if [ "$EXIT" != "0" ]; then
76
+ echo "WhenLabs checks found issues. Set fail_on: none to suppress this failure."
77
+ exit 1
78
+ fi
17
79
  shell: bash
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp/velocity-dashboard.ts
4
+ import { writeFileSync, mkdirSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ import { execSync } from "child_process";
8
+ import { TaskQueries, initDb } from "@whenlabs/velocity-mcp/lib";
9
+ var DASH_DIR = join(homedir(), ".whenlabs");
10
+ var DASH_PATH = join(DASH_DIR, "dashboard.html");
11
+ function parseTags(raw) {
12
+ try {
13
+ return JSON.parse(raw);
14
+ } catch {
15
+ return raw ? raw.split(",").map((t) => t.trim()).filter(Boolean) : [];
16
+ }
17
+ }
18
+ function fmtDuration(seconds) {
19
+ if (seconds < 60) return `${Math.round(seconds)}s`;
20
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
21
+ const h = Math.floor(seconds / 3600);
22
+ const m = Math.round(seconds % 3600 / 60);
23
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
24
+ }
25
+ function buildCategoryStats(tasks) {
26
+ const map = /* @__PURE__ */ new Map();
27
+ for (const t of tasks) {
28
+ if (!t.duration_seconds) continue;
29
+ const existing = map.get(t.category) ?? { count: 0, total: 0 };
30
+ existing.count++;
31
+ existing.total += t.duration_seconds;
32
+ map.set(t.category, existing);
33
+ }
34
+ return Array.from(map.entries()).map(([category, { count, total }]) => ({
35
+ category,
36
+ count,
37
+ avgDuration: total / count,
38
+ totalDuration: total
39
+ })).sort((a, b) => b.count - a.count);
40
+ }
41
+ function buildTagCounts(tasks) {
42
+ const map = /* @__PURE__ */ new Map();
43
+ for (const t of tasks) {
44
+ for (const tag of parseTags(t.tags)) {
45
+ map.set(tag, (map.get(tag) ?? 0) + 1);
46
+ }
47
+ }
48
+ return Array.from(map.entries()).map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count).slice(0, 15);
49
+ }
50
+ function svgBarChart(data, opts) {
51
+ const { width = 520, barHeight = 32, color = "#6366f1", valueFormatter = String } = opts;
52
+ if (data.length === 0) return '<p style="color:#888;font-size:13px">No data yet.</p>';
53
+ const maxVal = Math.max(...data.map((d) => d.value), 1);
54
+ const labelWidth = 120;
55
+ const valueWidth = 70;
56
+ const chartWidth = width - labelWidth - valueWidth - 16;
57
+ const height = data.length * (barHeight + 8) + 16;
58
+ const bars = data.map((d, i) => {
59
+ const barW = Math.max(2, d.value / maxVal * chartWidth);
60
+ const y = i * (barHeight + 8) + 8;
61
+ const sub = d.subtitle ? `<text x="${labelWidth + barW + 6}" y="${y + barHeight / 2 + 4}" font-size="10" fill="#999">${d.subtitle}</text>` : "";
62
+ return `
63
+ <text x="${labelWidth - 6}" y="${y + barHeight / 2 + 4}" text-anchor="end" font-size="12" fill="#ddd" font-family="system-ui,sans-serif">${d.label}</text>
64
+ <rect x="${labelWidth}" y="${y}" width="${barW}" height="${barHeight}" rx="4" fill="${color}" opacity="0.85"/>
65
+ <text x="${labelWidth + barW + 6}" y="${y + barHeight / 2 + 4}" font-size="12" fill="#e2e8f0" font-family="system-ui,sans-serif">${valueFormatter(d.value)}${sub ? "" : ""}</text>
66
+ ${sub}`;
67
+ }).join("");
68
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${bars}</svg>`;
69
+ }
70
+ function generateHtml(tasks) {
71
+ const now = /* @__PURE__ */ new Date();
72
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
73
+ const recentTasks = tasks.filter((t) => t.ended_at && new Date(t.ended_at) >= sevenDaysAgo).sort((a, b) => new Date(b.ended_at).getTime() - new Date(a.ended_at).getTime()).slice(0, 20);
74
+ const catStats = buildCategoryStats(tasks);
75
+ const tagCounts = buildTagCounts(tasks);
76
+ const totalTasks = tasks.length;
77
+ const totalSeconds = tasks.reduce((sum, t) => sum + (t.duration_seconds ?? 0), 0);
78
+ const avgSeconds = totalTasks > 0 ? totalSeconds / totalTasks : 0;
79
+ const completedCount = tasks.filter((t) => t.status === "completed").length;
80
+ const catChartData = catStats.map((c) => ({
81
+ label: c.category,
82
+ value: c.count,
83
+ subtitle: fmtDuration(c.avgDuration) + " avg"
84
+ }));
85
+ const durationChartData = catStats.filter((c) => c.avgDuration > 0).map((c) => ({
86
+ label: c.category,
87
+ value: Math.round(c.avgDuration),
88
+ subtitle: ""
89
+ }));
90
+ const tagChartData = tagCounts.map((t) => ({
91
+ label: t.tag,
92
+ value: t.count
93
+ }));
94
+ const catChart = svgBarChart(catChartData, {
95
+ color: "#6366f1",
96
+ valueFormatter: (v) => `${v} tasks`
97
+ });
98
+ const durationChart = svgBarChart(durationChartData, {
99
+ color: "#10b981",
100
+ valueFormatter: fmtDuration
101
+ });
102
+ const tagChart = svgBarChart(tagChartData, {
103
+ color: "#f59e0b",
104
+ valueFormatter: (v) => `${v}`
105
+ });
106
+ const recentRows = recentTasks.map((t) => {
107
+ const tags = parseTags(t.tags);
108
+ const dur = t.duration_seconds ? fmtDuration(t.duration_seconds) : "\u2014";
109
+ const statusColor = t.status === "completed" ? "#10b981" : t.status === "failed" ? "#ef4444" : "#94a3b8";
110
+ const tagBadges = tags.slice(0, 4).map((tag) => `<span class="tag">${tag}</span>`).join("");
111
+ return `
112
+ <tr>
113
+ <td><span class="cat-badge cat-${t.category}">${t.category}</span></td>
114
+ <td class="desc">${t.description}</td>
115
+ <td>${tagBadges}</td>
116
+ <td style="color:${statusColor};text-align:center">${t.status ?? "\u2014"}</td>
117
+ <td style="text-align:right;color:#94a3b8">${dur}</td>
118
+ </tr>`;
119
+ }).join("");
120
+ return `<!DOCTYPE html>
121
+ <html lang="en">
122
+ <head>
123
+ <meta charset="UTF-8">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
125
+ <title>Velocity Dashboard</title>
126
+ <style>
127
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
128
+ body {
129
+ font-family: system-ui, -apple-system, sans-serif;
130
+ background: #0f172a;
131
+ color: #e2e8f0;
132
+ min-height: 100vh;
133
+ padding: 24px;
134
+ }
135
+ .header {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ margin-bottom: 28px;
140
+ border-bottom: 1px solid #1e293b;
141
+ padding-bottom: 16px;
142
+ }
143
+ .header h1 { font-size: 22px; font-weight: 700; color: #f1f5f9; }
144
+ .header .subtitle { font-size: 12px; color: #64748b; margin-top: 2px; }
145
+ .badge-when {
146
+ background: #6366f1;
147
+ color: #fff;
148
+ font-size: 11px;
149
+ font-weight: 600;
150
+ padding: 3px 10px;
151
+ border-radius: 999px;
152
+ }
153
+ .stats-grid {
154
+ display: grid;
155
+ grid-template-columns: repeat(4, 1fr);
156
+ gap: 14px;
157
+ margin-bottom: 28px;
158
+ }
159
+ @media (max-width: 700px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } }
160
+ .stat-card {
161
+ background: #1e293b;
162
+ border: 1px solid #334155;
163
+ border-radius: 10px;
164
+ padding: 16px 20px;
165
+ }
166
+ .stat-card .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: .05em; }
167
+ .stat-card .value { font-size: 28px; font-weight: 700; color: #f1f5f9; margin: 4px 0 2px; }
168
+ .stat-card .sub { font-size: 11px; color: #64748b; }
169
+ .charts-grid {
170
+ display: grid;
171
+ grid-template-columns: 1fr 1fr;
172
+ gap: 18px;
173
+ margin-bottom: 28px;
174
+ }
175
+ @media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
176
+ .chart-card {
177
+ background: #1e293b;
178
+ border: 1px solid #334155;
179
+ border-radius: 10px;
180
+ padding: 18px 20px;
181
+ }
182
+ .chart-card.full { grid-column: 1 / -1; }
183
+ .chart-card h2 { font-size: 13px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 14px; }
184
+ .table-card {
185
+ background: #1e293b;
186
+ border: 1px solid #334155;
187
+ border-radius: 10px;
188
+ padding: 18px 20px;
189
+ overflow-x: auto;
190
+ }
191
+ .table-card h2 { font-size: 13px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 14px; }
192
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
193
+ th { text-align: left; color: #64748b; font-weight: 500; font-size: 11px; text-transform: uppercase; padding: 6px 10px; border-bottom: 1px solid #334155; }
194
+ td { padding: 8px 10px; border-bottom: 1px solid #1e293b; vertical-align: middle; }
195
+ tr:last-child td { border-bottom: none; }
196
+ .desc { max-width: 340px; color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
197
+ .tag {
198
+ display: inline-block;
199
+ background: #334155;
200
+ color: #94a3b8;
201
+ font-size: 10px;
202
+ padding: 1px 7px;
203
+ border-radius: 999px;
204
+ margin-right: 3px;
205
+ }
206
+ .cat-badge {
207
+ display: inline-block;
208
+ font-size: 10px;
209
+ font-weight: 600;
210
+ padding: 2px 8px;
211
+ border-radius: 999px;
212
+ text-transform: uppercase;
213
+ letter-spacing: .03em;
214
+ }
215
+ .cat-scaffold { background:#1e3a5f; color:#60a5fa; }
216
+ .cat-implement { background:#1a2e3b; color:#38bdf8; }
217
+ .cat-refactor { background:#1e3b2e; color:#4ade80; }
218
+ .cat-debug { background:#3b2020; color:#f87171; }
219
+ .cat-test { background:#2d2b3b; color:#a78bfa; }
220
+ .cat-config { background:#2d2a1a; color:#fbbf24; }
221
+ .cat-docs { background:#1e2e3b; color:#7dd3fc; }
222
+ .cat-deploy { background:#1e2e1e; color:#86efac; }
223
+ .generated-at { font-size: 11px; color: #475569; margin-top: 20px; text-align: right; }
224
+ </style>
225
+ </head>
226
+ <body>
227
+ <div class="header">
228
+ <div>
229
+ <h1>Velocity Dashboard</h1>
230
+ <div class="subtitle">Generated ${now.toLocaleString()}</div>
231
+ </div>
232
+ <span class="badge-when">@whenlabs/when</span>
233
+ </div>
234
+
235
+ <div class="stats-grid">
236
+ <div class="stat-card">
237
+ <div class="label">Total Tasks</div>
238
+ <div class="value">${totalTasks}</div>
239
+ <div class="sub">${completedCount} completed</div>
240
+ </div>
241
+ <div class="stat-card">
242
+ <div class="label">Total Time</div>
243
+ <div class="value">${fmtDuration(totalSeconds)}</div>
244
+ <div class="sub">across all tasks</div>
245
+ </div>
246
+ <div class="stat-card">
247
+ <div class="label">Avg Duration</div>
248
+ <div class="value">${fmtDuration(avgSeconds)}</div>
249
+ <div class="sub">per task</div>
250
+ </div>
251
+ <div class="stat-card">
252
+ <div class="label">Categories</div>
253
+ <div class="value">${catStats.length}</div>
254
+ <div class="sub">active categories</div>
255
+ </div>
256
+ </div>
257
+
258
+ <div class="charts-grid">
259
+ <div class="chart-card">
260
+ <h2>Tasks by Category</h2>
261
+ ${catChart}
262
+ </div>
263
+ <div class="chart-card">
264
+ <h2>Avg Duration by Category</h2>
265
+ ${durationChart}
266
+ </div>
267
+ <div class="chart-card full">
268
+ <h2>Top Tags</h2>
269
+ ${tagChart}
270
+ </div>
271
+ </div>
272
+
273
+ <div class="table-card">
274
+ <h2>Recent Tasks (last 7 days)</h2>
275
+ ${recentRows ? `<table>
276
+ <thead><tr>
277
+ <th>Category</th><th>Description</th><th>Tags</th><th style="text-align:center">Status</th><th style="text-align:right">Duration</th>
278
+ </tr></thead>
279
+ <tbody>${recentRows}</tbody>
280
+ </table>` : '<p style="color:#888;font-size:13px">No tasks in the last 7 days.</p>'}
281
+ </div>
282
+
283
+ <div class="generated-at">Generated by <code>when velocity dashboard</code></div>
284
+ </body>
285
+ </html>`;
286
+ }
287
+ async function generateDashboard() {
288
+ const db = initDb();
289
+ const queries = new TaskQueries(db);
290
+ const now = (/* @__PURE__ */ new Date()).toISOString();
291
+ const yearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1e3).toISOString();
292
+ const tasks = queries.getCompletedInRange(yearAgo, now);
293
+ db.close();
294
+ const html = generateHtml(tasks);
295
+ mkdirSync(DASH_DIR, { recursive: true });
296
+ writeFileSync(DASH_PATH, html, "utf8");
297
+ const catStats = buildCategoryStats(tasks);
298
+ const totalSeconds = tasks.reduce((sum, t) => sum + (t.duration_seconds ?? 0), 0);
299
+ const topCats = catStats.slice(0, 3).map((c) => `${c.category}(${c.count})`).join(", ");
300
+ const summary = [
301
+ `Total tasks: ${tasks.length}`,
302
+ `Total time: ${fmtDuration(totalSeconds)}`,
303
+ `Top categories: ${topCats || "none yet"}`,
304
+ `Dashboard written to: ${DASH_PATH}`
305
+ ].join("\n");
306
+ return { path: DASH_PATH, summary };
307
+ }
308
+ function openFile(filePath) {
309
+ const platform = process.platform;
310
+ try {
311
+ if (platform === "darwin") {
312
+ execSync(`open "${filePath}"`, { stdio: "ignore" });
313
+ } else if (platform === "linux") {
314
+ execSync(`xdg-open "${filePath}"`, { stdio: "ignore" });
315
+ }
316
+ } catch {
317
+ }
318
+ }
319
+ function registerVelocityDashboard(server) {
320
+ server.tool(
321
+ "velocity_dashboard",
322
+ "Generate an HTML dashboard with charts showing task timing stats \u2014 opens in browser",
323
+ {},
324
+ async () => {
325
+ const { path, summary } = await generateDashboard();
326
+ openFile(path);
327
+ const text = `Dashboard generated and opened in browser.
328
+
329
+ ${summary}`;
330
+ return { content: [{ type: "text", text }] };
331
+ }
332
+ );
333
+ }
334
+
335
+ // src/utils/find-bin.ts
336
+ import { resolve, dirname } from "path";
337
+ import { existsSync } from "fs";
338
+ import { fileURLToPath } from "url";
339
+ var __dirname = dirname(fileURLToPath(import.meta.url));
340
+ function findBin(name) {
341
+ const pkgRoot = resolve(__dirname, "../..");
342
+ const localBin = resolve(pkgRoot, "node_modules", ".bin", name);
343
+ if (existsSync(localBin)) return localBin;
344
+ const directCli = resolve(pkgRoot, "node_modules", "@whenlabs", name, "dist", "cli.js");
345
+ if (existsSync(directCli)) return directCli;
346
+ return name;
347
+ }
348
+
349
+ // src/config/whenlabs-config.ts
350
+ import { existsSync as existsSync2, readFileSync } from "fs";
351
+ import { resolve as resolve2 } from "path";
352
+ import { parse } from "yaml";
353
+ var CONFIG_FILENAME = ".whenlabs.yml";
354
+ function loadConfig(projectPath) {
355
+ const dir = projectPath ?? process.cwd();
356
+ const configPath = resolve2(dir, CONFIG_FILENAME);
357
+ if (!existsSync2(configPath)) return null;
358
+ try {
359
+ const raw = readFileSync(configPath, "utf-8");
360
+ const parsed = parse(raw);
361
+ if (!parsed || typeof parsed !== "object") return null;
362
+ return parsed;
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ // src/mcp/run-cli.ts
369
+ import { spawn } from "child_process";
370
+ import { join as join2 } from "path";
371
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, readdirSync } from "fs";
372
+ import { homedir as homedir2 } from "os";
373
+ function runCli(bin, args, cwd) {
374
+ return new Promise((res) => {
375
+ const child = spawn(findBin(bin), args, {
376
+ cwd: cwd || process.cwd(),
377
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }
378
+ });
379
+ let stdout = "";
380
+ let stderr = "";
381
+ child.stdout.on("data", (d) => stdout += d);
382
+ child.stderr.on("data", (d) => stderr += d);
383
+ child.on("error", (err) => res({ stdout, stderr: err.message, code: 1 }));
384
+ child.on("close", (code) => res({ stdout, stderr, code: code ?? 0 }));
385
+ });
386
+ }
387
+ var CACHE_DIR = join2(homedir2(), ".whenlabs", "cache");
388
+ function writeCache(tool, project, output, code) {
389
+ try {
390
+ mkdirSync2(CACHE_DIR, { recursive: true });
391
+ const file = join2(CACHE_DIR, `${tool}_${project}.json`);
392
+ writeFileSync2(file, JSON.stringify({ timestamp: Date.now(), output, code }));
393
+ } catch {
394
+ }
395
+ }
396
+ function deriveProject(path) {
397
+ const dir = path || process.cwd();
398
+ return dir.replace(/\\/g, "/").split("/").filter(Boolean).pop() || "unknown";
399
+ }
400
+ function readAwareProjectName(path) {
401
+ try {
402
+ const awareFile = join2(path || process.cwd(), ".aware.json");
403
+ if (!existsSync3(awareFile)) return null;
404
+ const data = JSON.parse(readFileSync2(awareFile, "utf8"));
405
+ return data.name || data.project || null;
406
+ } catch {
407
+ return null;
408
+ }
409
+ }
410
+ function formatOutput(result) {
411
+ const parts = [];
412
+ if (result.stdout.trim()) parts.push(result.stdout.trim());
413
+ if (result.stderr.trim()) parts.push(result.stderr.trim());
414
+ return parts.join("\n") || "No output";
415
+ }
416
+ async function checkTriggers(toolName, result, path) {
417
+ const output = result.stdout || result.stderr || "";
418
+ const extras = [];
419
+ if (toolName === "aware_init") {
420
+ const madeChanges = /wrote|created|updated|generated/i.test(output);
421
+ if (madeChanges) {
422
+ const staleResult = await runCli("stale", ["scan"], path);
423
+ const staleOutput = staleResult.stdout || staleResult.stderr || "";
424
+ writeCache("stale", deriveProject(path), staleOutput, staleResult.code);
425
+ if (staleOutput.trim()) {
426
+ extras.push(`
427
+ --- Auto-triggered stale_scan (stack change detected) ---
428
+ ${staleOutput}`);
429
+ }
430
+ }
431
+ }
432
+ if (toolName === "vow_scan") {
433
+ const hasUnknown = /unknown|UNKNOWN|unlicensed/i.test(output);
434
+ if (hasUnknown) {
435
+ extras.push("\nNote: Unknown licenses detected \u2014 check README for license accuracy claims.");
436
+ }
437
+ }
438
+ if (toolName === "berth_check") {
439
+ const hasConflicts = /conflict|in use|occupied|taken/i.test(output);
440
+ if (hasConflicts) {
441
+ const projectName = readAwareProjectName(path);
442
+ if (projectName) {
443
+ extras.push(`
444
+ Note: Conflicts found in project "${projectName}".`);
445
+ }
446
+ try {
447
+ const cacheFiles = readdirSync(CACHE_DIR).filter((f) => f.startsWith("stale_"));
448
+ for (const cacheFile of cacheFiles) {
449
+ const cached = JSON.parse(readFileSync2(join2(CACHE_DIR, cacheFile), "utf8"));
450
+ if (/\b\d{4,5}\b/.test(cached.output || "")) {
451
+ extras.push("\nTip: Port references found in documentation \u2014 stale_scan may need re-run after resolving conflicts.");
452
+ break;
453
+ }
454
+ }
455
+ } catch {
456
+ }
457
+ }
458
+ }
459
+ if (toolName === "envalid_detect") {
460
+ const serviceUrlMatches = output.match(/\b[A-Z_]*(?:HOST|PORT|URL|URI)[A-Z_]*\b/g);
461
+ if (serviceUrlMatches && serviceUrlMatches.length > 0) {
462
+ const examples = [...new Set(serviceUrlMatches)].slice(0, 3).join(", ");
463
+ extras.push(`
464
+ Tip: Service URLs detected (${examples}, etc.) \u2014 run berth_register to track their ports for conflict detection.`);
465
+ }
466
+ }
467
+ if (toolName === "velocity_end_task") {
468
+ const largeChange = /actual_files["\s:]+([1-9]\d)/i.test(output) || /\b([6-9]|\d{2,})\s+files?\b/i.test(output);
469
+ if (largeChange) {
470
+ extras.push("\nTip: Large change detected \u2014 consider running stale_scan to check for documentation drift.");
471
+ }
472
+ }
473
+ if (toolName === "vow_scan") {
474
+ const cacheFile = join2(CACHE_DIR, `vow_scan_${deriveProject(path)}.json`);
475
+ const isFirstScan = !existsSync3(cacheFile);
476
+ const hasNewPackages = /new package|added|installed/i.test(output);
477
+ if (isFirstScan || hasNewPackages) {
478
+ extras.push("\nTip: Dependency changes detected \u2014 run aware_sync to update AI context files with new library info.");
479
+ }
480
+ }
481
+ return extras;
482
+ }
483
+
484
+ export {
485
+ findBin,
486
+ CONFIG_FILENAME,
487
+ loadConfig,
488
+ runCli,
489
+ CACHE_DIR,
490
+ writeCache,
491
+ deriveProject,
492
+ formatOutput,
493
+ checkTriggers,
494
+ generateDashboard,
495
+ registerVelocityDashboard
496
+ };