@eltonssouza/development-utility-kit 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.
Files changed (131) hide show
  1. package/.claude/agents/README.md +24 -0
  2. package/.claude/agents/analyst.md +198 -0
  3. package/.claude/agents/backend-developer.md +126 -0
  4. package/.claude/agents/brain-keeper.md +229 -0
  5. package/.claude/agents/code-reviewer.md +181 -0
  6. package/.claude/agents/database-engineer.md +94 -0
  7. package/.claude/agents/devops-engineer.md +141 -0
  8. package/.claude/agents/frontend-developer.md +97 -0
  9. package/.claude/agents/gate-keeper.md +118 -0
  10. package/.claude/agents/migrator.md +291 -0
  11. package/.claude/agents/mobile-developer.md +80 -0
  12. package/.claude/agents/n8n-specialist.md +94 -0
  13. package/.claude/agents/product-owner.md +115 -0
  14. package/.claude/agents/qa-engineer.md +232 -0
  15. package/.claude/agents/release-engineer.md +204 -0
  16. package/.claude/agents/scaffold.md +87 -0
  17. package/.claude/agents/security-engineer.md +199 -0
  18. package/.claude/agents/sprint-runner.md +46 -0
  19. package/.claude/agents/stack-resolver.md +104 -0
  20. package/.claude/agents/tech-lead.md +182 -0
  21. package/.claude/agents/update-template.md +54 -0
  22. package/.claude/agents/ux-designer.md +118 -0
  23. package/.claude/hooks/flow-guard.js +261 -0
  24. package/.claude/hooks/flow-state.js +197 -0
  25. package/.claude/local/CLAUDE.md +71 -0
  26. package/.claude/settings.json +55 -0
  27. package/.claude/skills/README.md +331 -0
  28. package/.claude/skills/active-project/SKILL.md +131 -0
  29. package/.claude/skills/api-integration-test/SKILL.md +84 -0
  30. package/.claude/skills/auto-test-guard/SKILL.md +239 -0
  31. package/.claude/skills/auto-test-guard/resources/backend-tests.md +20 -0
  32. package/.claude/skills/auto-test-guard/resources/e2e-tests.md +24 -0
  33. package/.claude/skills/auto-test-guard/resources/execution-report.md +49 -0
  34. package/.claude/skills/auto-test-guard/resources/frontend-tests.md +18 -0
  35. package/.claude/skills/auto-test-guard/resources/initial-setup.md +108 -0
  36. package/.claude/skills/auto-test-guard/resources/run-suite.md +48 -0
  37. package/.claude/skills/auto-test-guard/resources/senior-gate.md +19 -0
  38. package/.claude/skills/brain-keeper/SKILL.md +62 -0
  39. package/.claude/skills/brain-keeper/obsidian/app.json +9 -0
  40. package/.claude/skills/brain-keeper/obsidian/appearance.json +4 -0
  41. package/.claude/skills/brain-keeper/obsidian/core-plugins.json +20 -0
  42. package/.claude/skills/brain-keeper/obsidian/daily-notes.json +5 -0
  43. package/.claude/skills/brain-keeper/obsidian/graph.json +32 -0
  44. package/.claude/skills/brain-keeper/obsidian/snippets/folder-colors.css +90 -0
  45. package/.claude/skills/brain-keeper/obsidian/templates.json +5 -0
  46. package/.claude/skills/brain-keeper/templates/README.md +51 -0
  47. package/.claude/skills/brain-keeper/templates/adr.md +40 -0
  48. package/.claude/skills/brain-keeper/templates/bug.md +35 -0
  49. package/.claude/skills/brain-keeper/templates/daily.md +38 -0
  50. package/.claude/skills/brain-keeper/templates/feature.md +62 -0
  51. package/.claude/skills/brain-keeper/templates/meeting.md +34 -0
  52. package/.claude/skills/brain-keeper/templates/tech-debt.md +21 -0
  53. package/.claude/skills/caveman/SKILL.md +189 -0
  54. package/.claude/skills/create-stack-pack/SKILL.md +281 -0
  55. package/.claude/skills/grill-me/SKILL.md +80 -0
  56. package/.claude/skills/pair-debug/SKILL.md +288 -0
  57. package/.claude/skills/prd-ready-check/SKILL.md +86 -0
  58. package/.claude/skills/project-manager/SKILL.md +334 -0
  59. package/.claude/skills/quality-standards/SKILL.md +203 -0
  60. package/.claude/skills/quick-feature/SKILL.md +266 -0
  61. package/.claude/skills/run-sprint/SKILL.md +41 -0
  62. package/.claude/skills/scaffold/SKILL.md +60 -0
  63. package/.claude/skills/stack-discovery/SKILL.md +161 -0
  64. package/.claude/skills/test-coverage-auditor/SKILL.md +87 -0
  65. package/.claude/skills/to-issues/SKILL.md +163 -0
  66. package/.claude/skills/to-prd/SKILL.md +130 -0
  67. package/.claude/skills/update-template/SKILL.md +256 -0
  68. package/.claude/stacks/CODEOWNERS +30 -0
  69. package/.claude/stacks/README.md +97 -0
  70. package/.claude/stacks/_template.md +116 -0
  71. package/.claude/stacks/dotnet/aspire-9.md +528 -0
  72. package/.claude/stacks/go/gin-1.10.md +570 -0
  73. package/.claude/stacks/java/spring-boot-3.md +376 -0
  74. package/.claude/stacks/java/spring-boot-4.md +438 -0
  75. package/.claude/stacks/node/express-5.md +538 -0
  76. package/.claude/stacks/python/django-5.md +483 -0
  77. package/.claude/stacks/python/fastapi-0.115.md +522 -0
  78. package/.claude/stacks/typescript/angular-18.md +420 -0
  79. package/.claude/stacks/typescript/angular-19.md +397 -0
  80. package/.claude/stacks/typescript/angular-21.md +494 -0
  81. package/CLAUDE.md +472 -0
  82. package/README.md +412 -0
  83. package/bin/cli.js +848 -0
  84. package/bin/lib/adr.js +146 -0
  85. package/bin/lib/backup.js +62 -0
  86. package/bin/lib/detect-stack.js +476 -0
  87. package/bin/lib/doctor.js +527 -0
  88. package/bin/lib/help.js +328 -0
  89. package/bin/lib/identity.js +108 -0
  90. package/bin/lib/lint-allowlist.json +15 -0
  91. package/bin/lib/lint.js +798 -0
  92. package/bin/lib/local-dir.js +68 -0
  93. package/bin/lib/manifest.js +236 -0
  94. package/bin/lib/sync-all.js +394 -0
  95. package/bin/lib/version-check.js +398 -0
  96. package/dashboard/db.js +321 -0
  97. package/dashboard/package.json +22 -0
  98. package/dashboard/public/app.js +853 -0
  99. package/dashboard/public/content/docs/agents-reference.en.md +911 -0
  100. package/dashboard/public/content/docs/architecture-overview.en.md +252 -0
  101. package/dashboard/public/content/docs/autonomy-matrix.en.md +186 -0
  102. package/dashboard/public/content/docs/cli-reference.en.md +538 -0
  103. package/dashboard/public/content/docs/git-flow.en.md +525 -0
  104. package/dashboard/public/content/docs/honcho-memory.en.md +394 -0
  105. package/dashboard/public/content/docs/hooks-reference.en.md +404 -0
  106. package/dashboard/public/content/docs/pipeline.en.md +414 -0
  107. package/dashboard/public/content/docs/plugins.en.md +289 -0
  108. package/dashboard/public/content/docs/quality-gate.en.md +315 -0
  109. package/dashboard/public/content/docs/skills-reference.en.md +484 -0
  110. package/dashboard/public/content/docs/stack-rules.en.md +362 -0
  111. package/dashboard/public/content/docs/troubleshooting.en.md +565 -0
  112. package/dashboard/public/content/manifest.json +114 -0
  113. package/dashboard/public/content/manual/backend.en.md +1053 -0
  114. package/dashboard/public/content/manual/existing-project.en.md +848 -0
  115. package/dashboard/public/content/manual/frontend.en.md +1008 -0
  116. package/dashboard/public/content/manual/fullstack.en.md +1459 -0
  117. package/dashboard/public/content/manual/mobile.en.md +837 -0
  118. package/dashboard/public/content/manual/quickstart.en.md +169 -0
  119. package/dashboard/public/index.html +217 -0
  120. package/dashboard/public/style.css +857 -0
  121. package/dashboard/public/vendor/marked.min.js +69 -0
  122. package/dashboard/rtk.js +143 -0
  123. package/dashboard/server-app.js +421 -0
  124. package/dashboard/server.js +104 -0
  125. package/dashboard/test/sprint1.test.js +406 -0
  126. package/dashboard/test/sprint2.test.js +571 -0
  127. package/dashboard/test/sprint3.test.js +560 -0
  128. package/package.json +33 -0
  129. package/scripts/hooks/subagent-telemetry.sh +14 -0
  130. package/scripts/hooks/telemetry-writer.js +250 -0
  131. package/scripts/latest-versions.json +56 -0
@@ -0,0 +1,398 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * version-check.js — Fetch latest versions for ecosystems and compare semver.
5
+ *
6
+ * Uses Node's native https module (no fetch — Node 18 compat). Caches at
7
+ * ~/.duk/cache/versions.json (TTL 24h). Falls back to scripts/latest-versions.json
8
+ * when network fails. All network operations are best-effort and silent on error.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const https = require('https');
15
+
16
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
17
+ const NETWORK_TIMEOUT_MS = 5000;
18
+
19
+ /**
20
+ * Resolve the cache file path.
21
+ * @returns {string}
22
+ */
23
+ function getCachePath() {
24
+ return path.join(os.homedir(), '.duk', 'cache', 'versions.json');
25
+ }
26
+
27
+ /**
28
+ * Resolve the offline fallback JSON path (shipped with the harness).
29
+ * @returns {string}
30
+ */
31
+ function getFallbackPath() {
32
+ return path.resolve(__dirname, '..', '..', 'scripts', 'latest-versions.json');
33
+ }
34
+
35
+ /**
36
+ * Read JSON file safely; returns null on any error.
37
+ * @param {string} filePath
38
+ * @returns {object|null}
39
+ */
40
+ function readJsonSafe(filePath) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Write JSON file, creating parent dirs as needed. Best-effort; swallows errors.
50
+ * @param {string} filePath
51
+ * @param {object} data
52
+ */
53
+ function writeJsonSafe(filePath, data) {
54
+ try {
55
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
56
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
57
+ } catch {
58
+ // best-effort
59
+ }
60
+ }
61
+
62
+ /**
63
+ * HTTPS GET with timeout. Resolves to response body string or rejects.
64
+ * @param {string} url
65
+ * @returns {Promise<string>}
66
+ */
67
+ function httpsGet(url) {
68
+ return new Promise((resolve, reject) => {
69
+ const req = https.get(
70
+ url,
71
+ {
72
+ headers: {
73
+ 'User-Agent': 'duk-cli/0.1 (https://github.com/eltonssouza/development-utility-kit)',
74
+ Accept: 'application/json',
75
+ },
76
+ },
77
+ (res) => {
78
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
79
+ // Single redirect hop
80
+ httpsGet(res.headers.location).then(resolve, reject);
81
+ return;
82
+ }
83
+ if (!res.statusCode || res.statusCode >= 400) {
84
+ res.resume();
85
+ reject(new Error(`HTTP ${res.statusCode}`));
86
+ return;
87
+ }
88
+ let data = '';
89
+ res.setEncoding('utf8');
90
+ res.on('data', (chunk) => (data += chunk));
91
+ res.on('end', () => resolve(data));
92
+ res.on('error', reject);
93
+ }
94
+ );
95
+
96
+ req.setTimeout(NETWORK_TIMEOUT_MS, () => {
97
+ req.destroy(new Error('timeout'));
98
+ });
99
+ req.on('error', reject);
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Query Maven Central for the latest version of a g:a coordinate.
105
+ * @param {string} groupId
106
+ * @param {string} artifactId
107
+ * @returns {Promise<string|null>}
108
+ */
109
+ async function mavenCentralLatest(groupId, artifactId) {
110
+ const url =
111
+ `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}` +
112
+ `&rows=1&wt=json`;
113
+ try {
114
+ const body = await httpsGet(url);
115
+ const json = JSON.parse(body);
116
+ const doc = json && json.response && Array.isArray(json.response.docs) && json.response.docs[0];
117
+ return doc && doc.latestVersion ? String(doc.latestVersion) : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Query npm registry for dist-tags.latest of a package.
125
+ * @param {string} pkgName
126
+ * @returns {Promise<string|null>}
127
+ */
128
+ async function npmRegistryLatest(pkgName) {
129
+ const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName).replace('%40', '@')}`;
130
+ try {
131
+ const body = await httpsGet(url);
132
+ const json = JSON.parse(body);
133
+ return json && json['dist-tags'] && json['dist-tags'].latest
134
+ ? String(json['dist-tags'].latest)
135
+ : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Query GitHub releases for tag_name of latest release.
143
+ * @param {string} owner
144
+ * @param {string} repo
145
+ * @returns {Promise<string|null>}
146
+ */
147
+ async function githubReleasesLatest(owner, repo) {
148
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
149
+ try {
150
+ const body = await httpsGet(url);
151
+ const json = JSON.parse(body);
152
+ const tag = json && json.tag_name ? String(json.tag_name) : null;
153
+ if (!tag) return null;
154
+ // strip leading 'v'
155
+ return tag.replace(/^v/i, '');
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Determine ecosystem + identifier per framework, then call the right API.
163
+ * @param {string} framework
164
+ * @returns {Promise<string|null>}
165
+ */
166
+ async function fetchFrameworkLatest(framework) {
167
+ switch (framework) {
168
+ case 'spring-boot':
169
+ return mavenCentralLatest('org.springframework.boot', 'spring-boot');
170
+ case 'angular':
171
+ return npmRegistryLatest('@angular/core');
172
+ case 'react':
173
+ return npmRegistryLatest('react');
174
+ case 'react-native':
175
+ return npmRegistryLatest('react-native');
176
+ case 'next':
177
+ return npmRegistryLatest('next');
178
+ case 'nuxt':
179
+ return npmRegistryLatest('nuxt');
180
+ case 'vue':
181
+ return npmRegistryLatest('vue');
182
+ case 'nest':
183
+ return npmRegistryLatest('@nestjs/core');
184
+ case 'express':
185
+ return npmRegistryLatest('express');
186
+ case 'fastify':
187
+ return npmRegistryLatest('fastify');
188
+ case 'django':
189
+ return githubReleasesLatest('django', 'django');
190
+ case 'fastapi':
191
+ return githubReleasesLatest('fastapi', 'fastapi');
192
+ case 'flask':
193
+ return githubReleasesLatest('pallets', 'flask');
194
+ case 'gin':
195
+ return githubReleasesLatest('gin-gonic', 'gin');
196
+ case 'echo':
197
+ return githubReleasesLatest('labstack', 'echo');
198
+ default:
199
+ return null;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Determine the latest version of a language runtime (uses fallback JSON in
205
+ * practice — language runtime cadence does not move fast enough to warrant
206
+ * an API call per install).
207
+ * @param {string} language
208
+ * @param {object} fallback
209
+ * @returns {string|null}
210
+ */
211
+ function languageLatestFromFallback(language, fallback) {
212
+ if (!fallback) return null;
213
+ switch (language) {
214
+ case 'java':
215
+ return (fallback.java && (fallback.java.latest_lts || fallback.java.latest)) || null;
216
+ case 'node':
217
+ case 'typescript':
218
+ case 'javascript':
219
+ return (fallback.node && (fallback.node.latest_lts || fallback.node.latest)) || null;
220
+ case 'python':
221
+ return (fallback.python && (fallback.python.latest_stable || fallback.python.latest)) || null;
222
+ case 'go':
223
+ return (fallback.go && fallback.go.latest) || null;
224
+ default:
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Cache the result of fetchFrameworkLatest using the local versions.json cache.
231
+ * @param {string} framework
232
+ * @returns {Promise<string|null>}
233
+ */
234
+ async function getFrameworkLatestCached(framework) {
235
+ const cachePath = getCachePath();
236
+ const cache = readJsonSafe(cachePath) || { _ts: 0, frameworks: {} };
237
+
238
+ const now = Date.now();
239
+ const fresh = cache._ts && now - cache._ts < CACHE_TTL_MS;
240
+ if (fresh && cache.frameworks && cache.frameworks[framework]) {
241
+ return cache.frameworks[framework];
242
+ }
243
+
244
+ const latest = await fetchFrameworkLatest(framework);
245
+ if (latest) {
246
+ cache.frameworks = cache.frameworks || {};
247
+ cache.frameworks[framework] = latest;
248
+ cache._ts = now;
249
+ writeJsonSafe(cachePath, cache);
250
+ return latest;
251
+ }
252
+
253
+ // No network — fall back to static JSON shipped with the harness
254
+ const fallback = readJsonSafe(getFallbackPath());
255
+ if (fallback && fallback[framework] && fallback[framework].latest) {
256
+ return fallback[framework].latest;
257
+ }
258
+
259
+ // Stale cache > nothing
260
+ if (cache.frameworks && cache.frameworks[framework]) {
261
+ return cache.frameworks[framework];
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Get latest versions for a stack descriptor.
268
+ * @param {{language: string, framework: string}} stack
269
+ * @returns {Promise<{language_latest: string|null, framework_latest: string|null}>}
270
+ */
271
+ async function getLatestVersions(stack) {
272
+ if (!stack || typeof stack !== 'object') {
273
+ return { language_latest: null, framework_latest: null };
274
+ }
275
+
276
+ const fallback = readJsonSafe(getFallbackPath());
277
+ const language_latest = languageLatestFromFallback(stack.language, fallback);
278
+ const framework_latest = await getFrameworkLatestCached(stack.framework);
279
+ return { language_latest, framework_latest };
280
+ }
281
+
282
+ /**
283
+ * Force-refresh the entire cache for all known frameworks.
284
+ * @returns {Promise<object>} the new cache contents
285
+ */
286
+ async function refreshLatestVersions() {
287
+ const frameworks = [
288
+ 'spring-boot',
289
+ 'angular',
290
+ 'react',
291
+ 'react-native',
292
+ 'next',
293
+ 'nuxt',
294
+ 'vue',
295
+ 'nest',
296
+ 'express',
297
+ 'fastify',
298
+ 'django',
299
+ 'fastapi',
300
+ 'flask',
301
+ 'gin',
302
+ 'echo',
303
+ ];
304
+ const cache = { _ts: Date.now(), frameworks: {} };
305
+ for (const f of frameworks) {
306
+ const v = await fetchFrameworkLatest(f);
307
+ if (v) cache.frameworks[f] = v;
308
+ }
309
+ writeJsonSafe(getCachePath(), cache);
310
+ return cache;
311
+ }
312
+
313
+ // ─── semver compare ──────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Parse a semver-ish string into [major, minor, patch] integers.
317
+ * Pre-release and build metadata are dropped.
318
+ * @param {string} v
319
+ * @returns {[number, number, number]}
320
+ */
321
+ function parseSemver(v) {
322
+ if (!v || typeof v !== 'string') return [0, 0, 0];
323
+ const m = v.match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
324
+ if (!m) return [0, 0, 0];
325
+ return [parseInt(m[1], 10) || 0, parseInt(m[2] || '0', 10), parseInt(m[3] || '0', 10)];
326
+ }
327
+
328
+ /**
329
+ * Compare two versions. Returns -1, 0, or 1.
330
+ * Accepts plain semver only.
331
+ * @param {string} a
332
+ * @param {string} b
333
+ * @returns {number}
334
+ */
335
+ function compareSemverPair(a, b) {
336
+ const [a1, a2, a3] = parseSemver(a);
337
+ const [b1, b2, b3] = parseSemver(b);
338
+ if (a1 !== b1) return a1 < b1 ? -1 : 1;
339
+ if (a2 !== b2) return a2 < b2 ? -1 : 1;
340
+ if (a3 !== b3) return a3 < b3 ? -1 : 1;
341
+ return 0;
342
+ }
343
+
344
+ /**
345
+ * Compare two versions (or a version against a range expression).
346
+ *
347
+ * Plain compare (both args plain versions): returns -1, 0, 1.
348
+ *
349
+ * Range compare (b is "<X", ">=X", "<=X", ">X", "=X"): returns -1/0/1 where
350
+ * -1 = a satisfies "less than b"
351
+ * 0 = a satisfies the expression exactly
352
+ * 1 = a does not satisfy (a is greater/outside the constraint)
353
+ *
354
+ * Practical contract used by sync-all filters: `compareVersions(localVersion, "<0.2") === -1`
355
+ * means the local version is strictly less than 0.2 (i.e. matches the filter).
356
+ *
357
+ * @param {string} a
358
+ * @param {string} b
359
+ * @returns {number}
360
+ */
361
+ function compareVersions(a, b) {
362
+ if (typeof b === 'string' && /^(>=|<=|>|<|=)/.test(b)) {
363
+ const m = b.match(/^(>=|<=|>|<|=)\s*(.+)$/);
364
+ if (!m) return compareSemverPair(a, b);
365
+ const op = m[1];
366
+ const ref = m[2].trim();
367
+ const cmp = compareSemverPair(a, ref);
368
+ switch (op) {
369
+ case '<':
370
+ return cmp < 0 ? -1 : 1;
371
+ case '<=':
372
+ return cmp <= 0 ? -1 : 1;
373
+ case '>':
374
+ return cmp > 0 ? -1 : 1;
375
+ case '>=':
376
+ return cmp >= 0 ? -1 : 1;
377
+ case '=':
378
+ return cmp === 0 ? 0 : 1;
379
+ default:
380
+ return cmp;
381
+ }
382
+ }
383
+ return compareSemverPair(a, b);
384
+ }
385
+
386
+ module.exports = {
387
+ getLatestVersions,
388
+ compareVersions,
389
+ refreshLatestVersions,
390
+ // exported for tests
391
+ parseSemver,
392
+ compareSemverPair,
393
+ mavenCentralLatest,
394
+ npmRegistryLatest,
395
+ githubReleasesLatest,
396
+ getCachePath,
397
+ getFallbackPath,
398
+ };
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const DB_PATH = path.join(os.homedir(), '.claude', 'telemetry.db');
8
+
9
+ /**
10
+ * Open the telemetry SQLite database.
11
+ * Tries better-sqlite3 first; falls back to sql.js.
12
+ * Throws a descriptive error if both are unavailable.
13
+ *
14
+ * @param {string} [dbPath] - path to the SQLite file (defaults to ~/.claude/telemetry.db)
15
+ * @returns {object} database handle (better-sqlite3 or sql.js Database)
16
+ */
17
+ function openDb(dbPath) {
18
+ const target = dbPath || DB_PATH;
19
+
20
+ // Ensure parent directory exists
21
+ const dir = path.dirname(target);
22
+ if (!fs.existsSync(dir)) {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ }
25
+
26
+ // Attempt better-sqlite3 (synchronous, native)
27
+ try {
28
+ const BetterSqlite3 = require('better-sqlite3');
29
+ const db = new BetterSqlite3(target);
30
+ db._isBetterSqlite3 = true;
31
+ return db;
32
+ } catch {
33
+ // fall through to sql.js
34
+ }
35
+
36
+ // Attempt sql.js (WASM, no native bindings needed)
37
+ try {
38
+ const initSqlJs = require('sql.js');
39
+ // sql.js is async but we simulate sync by using a wrapper approach.
40
+ // For the dashboard use case we return a promise-aware wrapper.
41
+ const wasmDb = openSqlJs(target, initSqlJs);
42
+ return wasmDb;
43
+ } catch {
44
+ // fall through
45
+ }
46
+
47
+ throw new Error(
48
+ 'Neither better-sqlite3 nor sql.js is available. ' +
49
+ 'Run: npm install --prefix <dashboard-dir> to install dependencies.'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Open a sql.js database from file (returns a synchronous-looking wrapper).
55
+ * sql.js loads the WASM synchronously when initSqlJs is called with no fetch.
56
+ */
57
+ function openSqlJs(dbPath, initSqlJs) {
58
+ // initSqlJs can be the module itself (sql.js <= 1.6) or a factory fn (sql.js >= 1.8)
59
+ const SqlJs = typeof initSqlJs === 'function' ? initSqlJs({ wasmBinary: null }) : initSqlJs;
60
+
61
+ let fileBuffer = null;
62
+ if (fs.existsSync(dbPath)) {
63
+ fileBuffer = fs.readFileSync(dbPath);
64
+ }
65
+
66
+ const db = fileBuffer ? new SqlJs.Database(fileBuffer) : new SqlJs.Database();
67
+ db._isSqlJs = true;
68
+ db._path = dbPath;
69
+
70
+ // Patch db.close to persist
71
+ const origClose = db.close.bind(db);
72
+ db.close = () => {
73
+ const data = db.export();
74
+ fs.writeFileSync(dbPath, Buffer.from(data));
75
+ origClose();
76
+ };
77
+
78
+ return db;
79
+ }
80
+
81
+ /**
82
+ * Create the events table and indexes if they don't exist.
83
+ * Also adds missing columns to existing tables (migration-safe).
84
+ * Works with both better-sqlite3 and sql.js handles.
85
+ *
86
+ * @param {object} db
87
+ */
88
+ function initSchema(db) {
89
+ const ddl = `
90
+ CREATE TABLE IF NOT EXISTS events (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ project_path TEXT NOT NULL DEFAULT 'unknown',
93
+ project_name TEXT NOT NULL DEFAULT 'unknown',
94
+ model TEXT NOT NULL DEFAULT 'other',
95
+ input_tokens INTEGER NOT NULL DEFAULT 0,
96
+ output_tokens INTEGER NOT NULL DEFAULT 0,
97
+ subagent_type TEXT,
98
+ session_id TEXT,
99
+ agent_id TEXT,
100
+ agent_type TEXT,
101
+ ts INTEGER NOT NULL
102
+ );
103
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
104
+ CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_path);
105
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
106
+ CREATE TABLE IF NOT EXISTS orchestration_runs (
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
108
+ run_group_id TEXT NOT NULL,
109
+ parent TEXT,
110
+ agent_type TEXT,
111
+ model TEXT,
112
+ project_path TEXT,
113
+ session_id TEXT,
114
+ ended_at INTEGER,
115
+ status TEXT,
116
+ ts INTEGER NOT NULL
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_orch_run_group ON orchestration_runs(run_group_id);
119
+ CREATE INDEX IF NOT EXISTS idx_orch_project ON orchestration_runs(project_path);
120
+ CREATE INDEX IF NOT EXISTS idx_orch_session ON orchestration_runs(session_id);
121
+ `;
122
+
123
+ // Idempotent migrations for DBs created by older writer versions.
124
+ const alters = [
125
+ 'ALTER TABLE events ADD COLUMN session_id TEXT',
126
+ 'ALTER TABLE events ADD COLUMN agent_id TEXT',
127
+ 'ALTER TABLE events ADD COLUMN agent_type TEXT',
128
+ ];
129
+
130
+ if (db._isBetterSqlite3) {
131
+ db.exec(ddl);
132
+ for (const stmt of alters) {
133
+ try { db.exec(stmt); } catch { /* column exists */ }
134
+ }
135
+ } else if (db._isSqlJs) {
136
+ db.run(ddl);
137
+ for (const stmt of alters) {
138
+ try { db.run(stmt); } catch { /* exists */ }
139
+ }
140
+ const data = db.export();
141
+ fs.writeFileSync(db._path, Buffer.from(data));
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Insert one orchestration run record (Phase A telemetry).
147
+ * Called once per agent in a parallel dispatch, sharing the same run_group_id.
148
+ * The root dispatch row has parent=null; each child row has parent=run_group_id.
149
+ *
150
+ * @param {object} db
151
+ * @param {object} run
152
+ * @param {string} run.run_group_id - shared group ID for all agents in this dispatch
153
+ * @param {string|null} run.parent - null for the dispatch root; run_group_id for children
154
+ * @param {string|null} run.agent_type
155
+ * @param {string|null} run.model
156
+ * @param {string|null} run.project_path
157
+ * @param {string|null} run.session_id
158
+ * @param {number|null} run.ended_at - Unix seconds; null while in-flight
159
+ * @param {string} run.status - 'pending' | 'running' | 'completed' | 'failed'
160
+ */
161
+ function insertOrchestrationRun(db, run) {
162
+ const sql = `
163
+ INSERT INTO orchestration_runs
164
+ (run_group_id, parent, agent_type, model, project_path, session_id, ended_at, status, ts)
165
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
166
+ `;
167
+ const ts = (Date.now() / 1000) | 0;
168
+ const params = [
169
+ run.run_group_id,
170
+ run.parent || null,
171
+ run.agent_type || null,
172
+ run.model || null,
173
+ run.project_path || null,
174
+ run.session_id || null,
175
+ run.ended_at || null,
176
+ run.status || 'pending',
177
+ ts,
178
+ ];
179
+
180
+ if (db._isBetterSqlite3) {
181
+ db.prepare(sql).run(...params);
182
+ } else if (db._isSqlJs) {
183
+ db.run(sql, params);
184
+ const data = db.export();
185
+ fs.writeFileSync(db._path, Buffer.from(data));
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Query orchestration run-groups for a project, newest first.
191
+ * Returns groups with their child agent runs nested under each.
192
+ *
193
+ * @param {object} db
194
+ * @param {string} projectPath
195
+ * @param {number} [limit=20] - max number of distinct run_group_ids to return
196
+ * @returns {Array<{run_group_id: string, status: string, ts: number, children: Array}>}
197
+ */
198
+ function queryRunGroups(db, projectPath, limit) {
199
+ const cap = limit || 20;
200
+
201
+ const groupSql = `
202
+ SELECT run_group_id, MAX(status) AS status, MAX(ended_at) AS ended_at, MIN(ts) AS ts
203
+ FROM orchestration_runs
204
+ WHERE project_path = ? AND parent IS NULL
205
+ GROUP BY run_group_id
206
+ ORDER BY ts DESC
207
+ LIMIT ?
208
+ `;
209
+
210
+ const childSql = `
211
+ SELECT run_group_id, agent_type, model, session_id, ended_at, status, ts
212
+ FROM orchestration_runs
213
+ WHERE project_path = ? AND parent IS NOT NULL
214
+ ORDER BY ts ASC
215
+ `;
216
+
217
+ let groups = [];
218
+ let children = [];
219
+
220
+ if (db._isBetterSqlite3) {
221
+ groups = db.prepare(groupSql).all(projectPath, cap);
222
+ children = db.prepare(childSql).all(projectPath);
223
+ } else if (db._isSqlJs) {
224
+ const gStmt = db.prepare(groupSql);
225
+ gStmt.bind([projectPath, cap]);
226
+ while (gStmt.step()) groups.push(gStmt.getAsObject());
227
+ gStmt.free();
228
+
229
+ const cStmt = db.prepare(childSql);
230
+ cStmt.bind([projectPath]);
231
+ while (cStmt.step()) children.push(cStmt.getAsObject());
232
+ cStmt.free();
233
+ }
234
+
235
+ // Nest children under their parent run-group
236
+ const childMap = {};
237
+ for (const c of children) {
238
+ const key = c.run_group_id;
239
+ if (!childMap[key]) childMap[key] = [];
240
+ childMap[key].push(c);
241
+ }
242
+
243
+ return groups.map((g) => ({
244
+ run_group_id: g.run_group_id,
245
+ status: g.status,
246
+ ended_at: g.ended_at,
247
+ ts: g.ts,
248
+ children: childMap[g.run_group_id] || [],
249
+ }));
250
+ }
251
+
252
+ /**
253
+ * Query projects active since `sinceTs` (Unix seconds).
254
+ * Groups by project_path, returns last seen + session count.
255
+ *
256
+ * @param {object} db
257
+ * @param {number} sinceTs - Unix timestamp in seconds
258
+ * @returns {Array<{name: string, path: string, last_seen: number, session_count: number}>}
259
+ */
260
+ function queryProjects(db, sinceTs) {
261
+ // session_count uses COUNT(DISTINCT session_id) so multiple SubagentStop
262
+ // events from the same session aggregate to 1. Events with NULL session_id
263
+ // (older rows or non-session contexts) fall back to being counted as one
264
+ // session per event via COALESCE on the row id.
265
+ const sql = `
266
+ SELECT
267
+ project_path AS path,
268
+ project_name AS name,
269
+ MAX(ts) AS last_seen,
270
+ COUNT(DISTINCT COALESCE(session_id, CAST(id AS TEXT))) AS session_count
271
+ FROM events
272
+ WHERE ts > ?
273
+ GROUP BY project_path
274
+ ORDER BY last_seen DESC
275
+ `;
276
+
277
+ if (db._isBetterSqlite3) {
278
+ return db.prepare(sql).all(sinceTs);
279
+ } else if (db._isSqlJs) {
280
+ const stmt = db.prepare(sql);
281
+ const rows = [];
282
+ stmt.bind([sinceTs]);
283
+ while (stmt.step()) {
284
+ const r = stmt.getAsObject();
285
+ rows.push(r);
286
+ }
287
+ stmt.free();
288
+ return rows;
289
+ }
290
+ return [];
291
+ }
292
+
293
+ /**
294
+ * Query model usage counts.
295
+ *
296
+ * @param {object} db
297
+ * @returns {Array<{model: string, count: number}>}
298
+ */
299
+ function queryModels(db) {
300
+ const sql = `
301
+ SELECT model, COUNT(*) AS count
302
+ FROM events
303
+ GROUP BY model
304
+ ORDER BY count DESC
305
+ `;
306
+
307
+ if (db._isBetterSqlite3) {
308
+ return db.prepare(sql).all();
309
+ } else if (db._isSqlJs) {
310
+ const stmt = db.prepare(sql);
311
+ const rows = [];
312
+ while (stmt.step()) {
313
+ rows.push(stmt.getAsObject());
314
+ }
315
+ stmt.free();
316
+ return rows;
317
+ }
318
+ return [];
319
+ }
320
+
321
+ module.exports = { openDb, initSchema, queryProjects, queryModels, insertOrchestrationRun, queryRunGroups };