tenbo-dashboard 0.1.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 +32 -0
- package/bin/tenbo-dashboard.mjs +67 -0
- package/index.html +12 -0
- package/package.json +75 -0
- package/scripts/compute-metrics.test.ts +35 -0
- package/scripts/compute-metrics.ts +46 -0
- package/scripts/next-id.test.ts +128 -0
- package/scripts/next-id.ts +183 -0
- package/scripts/validate-cli.test.ts +152 -0
- package/scripts/validate-cli.ts +181 -0
- package/src/App.tsx +152 -0
- package/src/ErrorBoundary.tsx +25 -0
- package/src/api/client.ts +41 -0
- package/src/api/lib/frontmatterScan.ts +37 -0
- package/src/api/lib/health/agingTodos.test.ts +32 -0
- package/src/api/lib/health/agingTodos.ts +97 -0
- package/src/api/lib/health/archCompliance.test.ts +26 -0
- package/src/api/lib/health/archCompliance.ts +53 -0
- package/src/api/lib/health/collectAll.test.ts +49 -0
- package/src/api/lib/health/collectAll.ts +78 -0
- package/src/api/lib/health/config.test.ts +45 -0
- package/src/api/lib/health/config.ts +54 -0
- package/src/api/lib/health/coupling.test.ts +40 -0
- package/src/api/lib/health/coupling.ts +66 -0
- package/src/api/lib/health/deadCode.test.ts +23 -0
- package/src/api/lib/health/deadCode.ts +76 -0
- package/src/api/lib/health/docDrift.test.ts +54 -0
- package/src/api/lib/health/docDrift.ts +99 -0
- package/src/api/lib/health/hotspotFiles.test.ts +49 -0
- package/src/api/lib/health/hotspotFiles.ts +70 -0
- package/src/api/lib/health/importGraph.test.ts +29 -0
- package/src/api/lib/health/importGraph.ts +51 -0
- package/src/api/lib/health/layerFiles.test.ts +46 -0
- package/src/api/lib/health/layerFiles.ts +40 -0
- package/src/api/lib/health/redundancy.test.ts +24 -0
- package/src/api/lib/health/redundancy.ts +162 -0
- package/src/api/lib/health/testCoverage.test.ts +33 -0
- package/src/api/lib/health/testCoverage.ts +55 -0
- package/src/api/lib/health/types.test.ts +29 -0
- package/src/api/lib/health/types.ts +85 -0
- package/src/api/lib/http.ts +34 -0
- package/src/api/lib/metrics.test.ts +81 -0
- package/src/api/lib/metrics.ts +78 -0
- package/src/api/lib/metricsRefresh.test.ts +54 -0
- package/src/api/lib/metricsRefresh.ts +147 -0
- package/src/api/lib/phases.test.ts +76 -0
- package/src/api/lib/phases.ts +48 -0
- package/src/api/lib/relationships.ts +65 -0
- package/src/api/lib/repoRoot.ts +12 -0
- package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
- package/src/api/lib/tenboFs.ts +237 -0
- package/src/api/lib/tenboFs.v2.test.ts +86 -0
- package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
- package/src/api/lib/validator.docUpdate.test.ts +99 -0
- package/src/api/lib/validator.phases.test.ts +98 -0
- package/src/api/lib/validator.relationships.test.ts +126 -0
- package/src/api/lib/validator.specLinks.test.ts +96 -0
- package/src/api/lib/validator.ts +311 -0
- package/src/api/lib/validator.v2.test.ts +55 -0
- package/src/api/lib/yamlOrdered.ts +60 -0
- package/src/api/plugin.ts +31 -0
- package/src/api/routes/items.ts +25 -0
- package/src/api/routes/layerContent.ts +22 -0
- package/src/api/routes/layerDocs.ts +22 -0
- package/src/api/routes/open.ts +18 -0
- package/src/api/routes/related.ts +11 -0
- package/src/api/routes/reorder.ts +15 -0
- package/src/api/routes/state.ts +23 -0
- package/src/api/routes/watch.ts +31 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/useApiPatch.ts +9 -0
- package/src/hooks/useLayerContent.ts +25 -0
- package/src/hooks/usePrompt.ts +23 -0
- package/src/hooks/useTenboState.ts +35 -0
- package/src/main.tsx +13 -0
- package/src/prompts/populateLayer.ts +12 -0
- package/src/router/routes.ts +98 -0
- package/src/router/useHashRoute.test.ts +67 -0
- package/src/router/useHashRoute.ts +15 -0
- package/src/styles.css +228 -0
- package/src/test-setup.ts +1 -0
- package/src/types.ts +202 -0
- package/src/ui/DotGrid.module.css +7 -0
- package/src/ui/DotGrid.tsx +87 -0
- package/src/ui/EmptyState.module.css +11 -0
- package/src/ui/EmptyState.tsx +10 -0
- package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
- package/src/ui/FindingModal/FindingModal.module.css +31 -0
- package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
- package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
- package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
- package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
- package/src/ui/FindingModal/index.tsx +36 -0
- package/src/ui/HealthPage/Digest.tsx +29 -0
- package/src/ui/HealthPage/FindingRow.tsx +21 -0
- package/src/ui/HealthPage/HealthPage.module.css +45 -0
- package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
- package/src/ui/HealthPage/HealthPage.tsx +66 -0
- package/src/ui/HealthPage/LayerCard.tsx +36 -0
- package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
- package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
- package/src/ui/HealthPage/severity.test.ts +46 -0
- package/src/ui/HealthPage/severity.ts +21 -0
- package/src/ui/ItemCard.module.css +131 -0
- package/src/ui/ItemCard.tsx +117 -0
- package/src/ui/ItemModal/DescriptionField.tsx +22 -0
- package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
- package/src/ui/ItemModal/ItemModal.module.css +200 -0
- package/src/ui/ItemModal/LinksSection.tsx +26 -0
- package/src/ui/ItemModal/NotesSection.tsx +18 -0
- package/src/ui/ItemModal/PhasesSection.tsx +79 -0
- package/src/ui/ItemModal/RelatedSection.tsx +26 -0
- package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
- package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
- package/src/ui/ItemModal/TitleField.tsx +21 -0
- package/src/ui/ItemModal/index.tsx +117 -0
- package/src/ui/KanbanColumn.module.css +20 -0
- package/src/ui/KanbanColumn.tsx +36 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
- package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
- package/src/ui/LayerDrawer.module.css +60 -0
- package/src/ui/LayerDrawer.tsx +69 -0
- package/src/ui/LayerKanban.module.css +45 -0
- package/src/ui/LayerKanban.tsx +65 -0
- package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
- package/src/ui/LayerPage/LayerPage.module.css +52 -0
- package/src/ui/LayerPage/LayerPage.tsx +76 -0
- package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
- package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
- package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
- package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
- package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
- package/src/ui/Markdown.module.css +115 -0
- package/src/ui/Markdown.tsx +27 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
- package/src/ui/ScopePage/ScopePage.module.css +72 -0
- package/src/ui/ScopePage/ScopePage.tsx +131 -0
- package/src/ui/SummaryStrip.module.css +18 -0
- package/src/ui/SummaryStrip.tsx +16 -0
- package/src/ui/TaskList.module.css +136 -0
- package/src/ui/TaskList.tsx +91 -0
- package/src/ui/TopBar.module.css +78 -0
- package/src/ui/TopBar.tsx +48 -0
- package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
- package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
- package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
- package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
- package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# tenbo-dashboard
|
|
2
|
+
|
|
3
|
+
Local architecture dashboard and CLI tools for [tenbo](https://github.com/poyi/tenbo) — an agentic project manager for AI-assisted coding.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Run in any project with a .tenbo/ directory
|
|
11
|
+
npx tenbo-dashboard
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Opens a local dashboard for browsing roadmaps, triaging items, and drag-reorder.
|
|
15
|
+
|
|
16
|
+
## CLI tools
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx tenbo-dashboard validate # Run validation rules
|
|
20
|
+
npx tenbo-dashboard next-id <prefix> # Allocate next roadmap item ID
|
|
21
|
+
npx tenbo-dashboard metrics --all # Compute scope metrics
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What is tenbo?
|
|
25
|
+
|
|
26
|
+
tenbo is a Claude Code skill that gives your AI coding assistant persistent project memory — architecture docs, roadmaps, and health signals that survive across sessions. The dashboard is an optional visual companion.
|
|
27
|
+
|
|
28
|
+
Install the skill: [github.com/poyi/tenbo](https://github.com/poyi/tenbo)
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const root = path.resolve(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
const [command, ...args] = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
const commands = {
|
|
13
|
+
validate: 'scripts/validate-cli.ts',
|
|
14
|
+
'next-id': 'scripts/next-id.ts',
|
|
15
|
+
metrics: 'scripts/compute-metrics.ts',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function run(script, scriptArgs) {
|
|
19
|
+
const tsx = path.join(root, 'node_modules', '.bin', 'tsx');
|
|
20
|
+
const child = spawn(tsx, [path.join(root, script), ...scriptArgs], {
|
|
21
|
+
stdio: 'inherit',
|
|
22
|
+
cwd: process.cwd(),
|
|
23
|
+
});
|
|
24
|
+
child.on('close', (code) => process.exit(code ?? 1));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function startDashboard() {
|
|
28
|
+
// Use Vite's JS API to start the dev server
|
|
29
|
+
const viteScript = `
|
|
30
|
+
import { createServer } from 'vite';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts') });
|
|
35
|
+
await server.listen();
|
|
36
|
+
server.printUrls();
|
|
37
|
+
`;
|
|
38
|
+
const tsx = path.join(root, 'node_modules', '.bin', 'tsx');
|
|
39
|
+
const child = spawn(tsx, ['--eval', viteScript], {
|
|
40
|
+
stdio: 'inherit',
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
env: { ...process.env, TENBO_CWD: process.cwd() },
|
|
43
|
+
});
|
|
44
|
+
child.on('close', (code) => process.exit(code ?? 0));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!command || command === 'help' || command === '--help') {
|
|
48
|
+
console.log(`
|
|
49
|
+
tenbo-dashboard — local architecture dashboard for .tenbo/ repos
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
tenbo-dashboard Launch the dashboard (http://localhost:5174)
|
|
53
|
+
tenbo-dashboard validate Run validation rules
|
|
54
|
+
tenbo-dashboard next-id <prefix> Allocate next roadmap item ID
|
|
55
|
+
tenbo-dashboard metrics --all Compute scope metrics
|
|
56
|
+
tenbo-dashboard help Show this help
|
|
57
|
+
`);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (commands[command]) {
|
|
62
|
+
run(commands[command], args);
|
|
63
|
+
} else {
|
|
64
|
+
// No recognized subcommand — default to launching the dashboard
|
|
65
|
+
// Pass the original command back as an arg in case it was a flag
|
|
66
|
+
startDashboard();
|
|
67
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>tenbo</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tenbo-dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Po-Yi Chen",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/poyi/tenbo.git",
|
|
11
|
+
"directory": "tenbo-dashboard"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/poyi/tenbo#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"tenbo",
|
|
16
|
+
"architecture",
|
|
17
|
+
"roadmap",
|
|
18
|
+
"developer-tools",
|
|
19
|
+
"project-management",
|
|
20
|
+
"ai-skills"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"tenbo-dashboard": "./bin/tenbo-dashboard.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin/",
|
|
30
|
+
"src/",
|
|
31
|
+
"scripts/",
|
|
32
|
+
"index.html",
|
|
33
|
+
"vite.config.ts",
|
|
34
|
+
"tsconfig.json"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "vite",
|
|
38
|
+
"build": "tsc -b && vite build",
|
|
39
|
+
"test": "vitest",
|
|
40
|
+
"validate": "tsx scripts/validate-cli.ts",
|
|
41
|
+
"next-id": "tsx scripts/next-id.ts",
|
|
42
|
+
"compute-metrics": "tsx scripts/compute-metrics.ts"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@dnd-kit/core": "^6.3.1",
|
|
46
|
+
"@dnd-kit/sortable": "^8.0.0",
|
|
47
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
48
|
+
"@xyflow/react": "^12.10.2",
|
|
49
|
+
"chokidar": "^3.6.0",
|
|
50
|
+
"dagre": "^0.8.5",
|
|
51
|
+
"gray-matter": "^4.0.3",
|
|
52
|
+
"lucide-react": "^0.545.0",
|
|
53
|
+
"react": "^18.3.1",
|
|
54
|
+
"react-dom": "^18.3.1",
|
|
55
|
+
"react-markdown": "^9.0.1",
|
|
56
|
+
"remark-gfm": "^4.0.0",
|
|
57
|
+
"yaml": "^2.5.1",
|
|
58
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
59
|
+
"tsx": "^4.19.0",
|
|
60
|
+
"typescript": "^5.5.4",
|
|
61
|
+
"vite": "^5.4.3"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
65
|
+
"@testing-library/react": "^16.0.1",
|
|
66
|
+
"@types/dagre": "^0.7.54",
|
|
67
|
+
"@types/node": "^22.5.4",
|
|
68
|
+
"@types/react": "^18.3.5",
|
|
69
|
+
"@types/react-dom": "^18.3.0",
|
|
70
|
+
"jscpd": "^4",
|
|
71
|
+
"jsdom": "^25.0.0",
|
|
72
|
+
"ts-morph": "^25",
|
|
73
|
+
"vitest": "^2.0.5"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { runComputeMetrics } from './compute-metrics.js';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(path.join(tmpdir(), 'tenbo-cm-'));
|
|
11
|
+
const scope = path.join(dir, '.tenbo', 'scopes', 'editor');
|
|
12
|
+
const layer = path.join(scope, 'layers', 'foo');
|
|
13
|
+
mkdirSync(layer, { recursive: true });
|
|
14
|
+
writeFileSync(path.join(dir, '.tenbo', 'workspace.yaml'), 'scopes:\n - id: editor\n path: scopes/editor\n');
|
|
15
|
+
writeFileSync(path.join(scope, 'architecture.yaml'), 'scope: editor\nlayers:\n - id: foo\n name: Foo\n description: A small layer.\n files: ["src/**"]\n dependencies: { inbound: [], outbound: [], external: [] }\n');
|
|
16
|
+
writeFileSync(path.join(scope, 'roadmap.yaml'), 'items: []\n');
|
|
17
|
+
writeFileSync(path.join(layer, 'intent.md'), '# Foo\n');
|
|
18
|
+
writeFileSync(path.join(layer, 'code-map.md'), '# Foo\n');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
describe('compute-metrics CLI', () => {
|
|
24
|
+
it('writes metrics.json for the requested scope', async () => {
|
|
25
|
+
const code = await runComputeMetrics({ repoRoot: dir, scope: 'editor' });
|
|
26
|
+
expect(code).toBe(0);
|
|
27
|
+
expect(existsSync(path.join(dir, '.tenbo', 'scopes', 'editor', 'metrics.json'))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('writes metrics for all scopes with --all', async () => {
|
|
31
|
+
const code = await runComputeMetrics({ repoRoot: dir, all: true });
|
|
32
|
+
expect(code).toBe(0);
|
|
33
|
+
expect(existsSync(path.join(dir, '.tenbo', 'scopes', 'editor', 'metrics.json'))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ensureFresh } from '../src/api/lib/metricsRefresh.js';
|
|
2
|
+
import { readState } from '../src/api/lib/tenboFs.js';
|
|
3
|
+
import { findRepoRoot } from '../src/api/lib/repoRoot.js';
|
|
4
|
+
|
|
5
|
+
export interface RunArgs {
|
|
6
|
+
repoRoot: string;
|
|
7
|
+
scope?: string;
|
|
8
|
+
all?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function runComputeMetrics(args: RunArgs): Promise<number> {
|
|
12
|
+
const { repoRoot } = args;
|
|
13
|
+
const state = readState(repoRoot);
|
|
14
|
+
const scopes = args.all
|
|
15
|
+
? state.scopes.map((s) => s.id)
|
|
16
|
+
: args.scope
|
|
17
|
+
? [args.scope]
|
|
18
|
+
: [];
|
|
19
|
+
if (scopes.length === 0) {
|
|
20
|
+
console.error('compute-metrics: pass --all or --scope <id>');
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
for (const id of scopes) {
|
|
24
|
+
try {
|
|
25
|
+
await ensureFresh(repoRoot, id, { force: true });
|
|
26
|
+
console.log(`compute-metrics: wrote metrics for ${id}`);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
console.error(`compute-metrics: failed for ${id}:`, e);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const all = args.includes('--all');
|
|
38
|
+
const scopeIdx = args.indexOf('--scope');
|
|
39
|
+
const scope = scopeIdx >= 0 ? args[scopeIdx + 1] : undefined;
|
|
40
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
41
|
+
if (!repoRoot) {
|
|
42
|
+
console.error('compute-metrics: unable to find repo root');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
runComputeMetrics({ repoRoot, scope, all }).then((code) => process.exit(code));
|
|
46
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readdirSync, utimesSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { allocateNextId, STALE_LOCK_MS } from './next-id';
|
|
7
|
+
|
|
8
|
+
// Resolve repo paths from cwd — Vitest's `import.meta.url` is not a `file:` URL
|
|
9
|
+
// under jsdom. Vitest runs from the package root, so cwd === tenbo-dashboard.
|
|
10
|
+
const APP_ROOT = process.cwd();
|
|
11
|
+
const SCRIPT_PATH = path.join(APP_ROOT, 'scripts', 'next-id.ts');
|
|
12
|
+
const TSX_BIN = path.join(APP_ROOT, 'node_modules', '.bin', 'tsx');
|
|
13
|
+
|
|
14
|
+
const created: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
while (created.length) {
|
|
18
|
+
const root = created.pop();
|
|
19
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeRoot(): string {
|
|
24
|
+
const root = mkdtempSync(path.join(tmpdir(), 'tenbo-nextid-'));
|
|
25
|
+
created.push(root);
|
|
26
|
+
// .git marker so findRepoRoot treats this as a repo (not strictly needed since
|
|
27
|
+
// tests pass repoRoot directly, but harmless).
|
|
28
|
+
mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
29
|
+
mkdirSync(path.join(root, '.tenbo', 'scopes', 'editor'), { recursive: true });
|
|
30
|
+
writeFileSync(
|
|
31
|
+
path.join(root, '.tenbo', 'workspace.yaml'),
|
|
32
|
+
'scopes:\n - id: editor\n prefix: ed\n path: apps/editor\n description: editor scope\ncross_cutting: []\n',
|
|
33
|
+
);
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeRoadmap(root: string, scopeRel: string, ids: string[]): void {
|
|
38
|
+
const items = ids.length ? ids.map((id) => ` - id: ${id}\n title: t\n`).join('') : '';
|
|
39
|
+
const body = `items:\n${items}`;
|
|
40
|
+
writeFileSync(path.join(root, '.tenbo', scopeRel, 'roadmap.yaml'), body);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('next-id allocator', () => {
|
|
44
|
+
it('returns prefix-001 for an empty roadmap', () => {
|
|
45
|
+
const root = makeRoot();
|
|
46
|
+
writeRoadmap(root, 'scopes/editor', []);
|
|
47
|
+
expect(allocateNextId({ repoRoot: root, prefix: 'ed' })).toBe('ed-001');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns ed-059 when ed-001..ed-058 exist', () => {
|
|
51
|
+
const root = makeRoot();
|
|
52
|
+
const ids = Array.from({ length: 58 }, (_, i) => `ed-${String(i + 1).padStart(3, '0')}`);
|
|
53
|
+
writeRoadmap(root, 'scopes/editor', ids);
|
|
54
|
+
expect(allocateNextId({ repoRoot: root, prefix: 'ed' })).toBe('ed-059');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns x-002 when only x-001 exists in workspace roadmap', () => {
|
|
58
|
+
const root = makeRoot();
|
|
59
|
+
writeFileSync(path.join(root, '.tenbo', 'roadmap.yaml'), 'items:\n - id: x-001\n title: t\n');
|
|
60
|
+
expect(allocateNextId({ repoRoot: root, prefix: 'x' })).toBe('x-002');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reclaims stale lock files older than 60s', () => {
|
|
64
|
+
const root = makeRoot();
|
|
65
|
+
writeRoadmap(root, 'scopes/editor', []);
|
|
66
|
+
// Pre-create a stale lock for ed-001.
|
|
67
|
+
const lockDir = path.join(root, '.tenbo', '.id-locks');
|
|
68
|
+
mkdirSync(lockDir, { recursive: true });
|
|
69
|
+
const lockPath = path.join(lockDir, 'ed-001.lock');
|
|
70
|
+
writeFileSync(lockPath, JSON.stringify({ pid: 99999, ts: '2020-01-01T00:00:00Z' }));
|
|
71
|
+
const ancient = (Date.now() - STALE_LOCK_MS - 5_000) / 1000;
|
|
72
|
+
utimesSync(lockPath, ancient, ancient);
|
|
73
|
+
|
|
74
|
+
const id = allocateNextId({ repoRoot: root, prefix: 'ed' });
|
|
75
|
+
// Stale lock reclaimed → ed-001 returned.
|
|
76
|
+
expect(id).toBe('ed-001');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('skips past fresh locks (simulated concurrent allocation)', () => {
|
|
80
|
+
const root = makeRoot();
|
|
81
|
+
writeRoadmap(root, 'scopes/editor', []);
|
|
82
|
+
// Simulate another caller having just claimed ed-001 by creating a fresh lock.
|
|
83
|
+
const id1 = allocateNextId({ repoRoot: root, prefix: 'ed' });
|
|
84
|
+
expect(id1).toBe('ed-001');
|
|
85
|
+
const id2 = allocateNextId({ repoRoot: root, prefix: 'ed' });
|
|
86
|
+
expect(id2).toBe('ed-002');
|
|
87
|
+
const id3 = allocateNextId({ repoRoot: root, prefix: 'ed' });
|
|
88
|
+
expect(id3).toBe('ed-003');
|
|
89
|
+
// Lock files for each should now exist.
|
|
90
|
+
const locks = readdirSync(path.join(root, '.tenbo', '.id-locks')).sort();
|
|
91
|
+
expect(locks).toEqual(['ed-001.lock', 'ed-002.lock', 'ed-003.lock']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('concurrent child processes never return the same id', () => {
|
|
95
|
+
const root = makeRoot();
|
|
96
|
+
writeRoadmap(root, 'scopes/editor', []);
|
|
97
|
+
|
|
98
|
+
// Fan out N parallel child processes that all try to allocate `ed`.
|
|
99
|
+
const N = 5;
|
|
100
|
+
const procs = Array.from({ length: N }, () =>
|
|
101
|
+
spawnSync(TSX_BIN, [SCRIPT_PATH, 'ed'], {
|
|
102
|
+
cwd: root,
|
|
103
|
+
encoding: 'utf8',
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const ids = procs.map((p) => {
|
|
107
|
+
expect(p.status, `stderr: ${p.stderr}`).toBe(0);
|
|
108
|
+
return p.stdout.trim();
|
|
109
|
+
});
|
|
110
|
+
const unique = new Set(ids);
|
|
111
|
+
expect(unique.size).toBe(N);
|
|
112
|
+
// All should match the ed-NNN pattern.
|
|
113
|
+
for (const id of ids) expect(id).toMatch(/^ed-\d{3}$/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns no-op gracefully when prefix has no roadmap', () => {
|
|
117
|
+
const root = makeRoot();
|
|
118
|
+
// No roadmap file at all → empty existing → starts at 001.
|
|
119
|
+
expect(allocateNextId({ repoRoot: root, prefix: 'ed' })).toBe('ed-001');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Sanity: confirm the module exports the constant tests rely on.
|
|
124
|
+
describe('exports', () => {
|
|
125
|
+
it('exports STALE_LOCK_MS = 60000', () => {
|
|
126
|
+
expect(STALE_LOCK_MS).toBe(60_000);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* next-id.ts — Atomic id allocator for tenbo roadmap items.
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Read the relevant `roadmap.yaml` files for a given scope prefix, find the
|
|
6
|
+
* maximum three-digit suffix already in use, and return the next id, while
|
|
7
|
+
* atomically reserving it via a short-lived lock file. Two parallel callers
|
|
8
|
+
* must never get the same id.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* npx tenbo-dashboard next-id <prefix>
|
|
12
|
+
* # e.g. npx tenbo-dashboard next-id ed → ed-059
|
|
13
|
+
* # e.g. npx tenbo-dashboard next-id x → x-002
|
|
14
|
+
*
|
|
15
|
+
* When to use:
|
|
16
|
+
* Any tenbo skill behavior that needs to allocate a new roadmap-item id
|
|
17
|
+
* (Behaviors B2, B12, B13). Replaces ad-hoc grep+sort logic and removes the
|
|
18
|
+
* id-collision class of error.
|
|
19
|
+
*
|
|
20
|
+
* Lock-file convention:
|
|
21
|
+
* Locks live at `.tenbo/.id-locks/<prefix>-<NNN>.lock`. Each lock contains
|
|
22
|
+
* the allocating process's PID and a UTC timestamp. Locks older than 60s
|
|
23
|
+
* are considered stale and reclaimable. The directory is git-ignored.
|
|
24
|
+
*
|
|
25
|
+
* Output:
|
|
26
|
+
* On success, prints the allocated id (e.g. `ed-059`) to stdout with no
|
|
27
|
+
* decoration so callers can capture it via `$(...)`. Errors go to stderr
|
|
28
|
+
* and the process exits non-zero.
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, writeFileSync, statSync, unlinkSync, openSync, closeSync, writeSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { parse as parseYaml } from 'yaml';
|
|
33
|
+
import { findRepoRoot } from '../src/api/lib/repoRoot';
|
|
34
|
+
|
|
35
|
+
export const STALE_LOCK_MS = 60_000;
|
|
36
|
+
const MAX_ATTEMPTS = 1000;
|
|
37
|
+
|
|
38
|
+
interface WorkspaceScope {
|
|
39
|
+
id: string;
|
|
40
|
+
prefix?: string;
|
|
41
|
+
path?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RoadmapFile {
|
|
45
|
+
items?: Array<{ id?: string }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Find which roadmap.yaml files contain ids for this prefix. */
|
|
49
|
+
export function resolveRoadmapPaths(repoRoot: string, prefix: string): string[] {
|
|
50
|
+
const tenbo = path.join(repoRoot, '.tenbo');
|
|
51
|
+
if (prefix === 'x') {
|
|
52
|
+
const p = path.join(tenbo, 'roadmap.yaml');
|
|
53
|
+
return existsSync(p) ? [p] : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const wsPath = path.join(tenbo, 'workspace.yaml');
|
|
57
|
+
if (!existsSync(wsPath)) return [];
|
|
58
|
+
const ws = parseYaml(readFileSync(wsPath, 'utf8')) as { scopes?: WorkspaceScope[] } | null;
|
|
59
|
+
const scopes = ws?.scopes ?? [];
|
|
60
|
+
const match = scopes.find((s) => s.prefix === prefix);
|
|
61
|
+
if (!match) return [];
|
|
62
|
+
const p = path.join(tenbo, 'scopes', match.id, 'roadmap.yaml');
|
|
63
|
+
return existsSync(p) ? [p] : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Collect numeric suffixes for `<prefix>-NNN` ids across the given roadmap files. */
|
|
67
|
+
export function collectExistingNumbers(roadmapPaths: string[], prefix: string): number[] {
|
|
68
|
+
const re = new RegExp(`^${prefix}-(\\d{3})$`);
|
|
69
|
+
const nums: number[] = [];
|
|
70
|
+
for (const p of roadmapPaths) {
|
|
71
|
+
const text = readFileSync(p, 'utf8');
|
|
72
|
+
// Use a forgiving regex scan rather than full YAML parse — some roadmaps
|
|
73
|
+
// contain unrelated YAML errors that block parsing but leave the `id:`
|
|
74
|
+
// lines intact.
|
|
75
|
+
const idLines = text.match(/^\s*-?\s*id:\s*[^\s#]+/gm) ?? [];
|
|
76
|
+
for (const line of idLines) {
|
|
77
|
+
const m = line.match(/id:\s*([^\s#]+)/);
|
|
78
|
+
if (!m) continue;
|
|
79
|
+
const id = m[1].replace(/['"]/g, '');
|
|
80
|
+
const mm = id.match(re);
|
|
81
|
+
if (mm) nums.push(Number(mm[1]));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return nums;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lockDir(repoRoot: string): string {
|
|
88
|
+
return path.join(repoRoot, '.tenbo', '.id-locks');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function lockPath(repoRoot: string, id: string): string {
|
|
92
|
+
return path.join(lockDir(repoRoot), `${id}.lock`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Returns true if a usable lock for `id` exists; cleans stale locks as a side effect. */
|
|
96
|
+
function lockIsActive(repoRoot: string, id: string, now: number): boolean {
|
|
97
|
+
const p = lockPath(repoRoot, id);
|
|
98
|
+
if (!existsSync(p)) return false;
|
|
99
|
+
try {
|
|
100
|
+
const st = statSync(p);
|
|
101
|
+
if (now - st.mtimeMs > STALE_LOCK_MS) {
|
|
102
|
+
try { unlinkSync(p); } catch { /* race; treat as gone */ }
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Attempt to atomically create a lock file. Returns true on success. */
|
|
112
|
+
function tryAcquireLock(repoRoot: string, id: string): boolean {
|
|
113
|
+
const dir = lockDir(repoRoot);
|
|
114
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
115
|
+
const p = lockPath(repoRoot, id);
|
|
116
|
+
try {
|
|
117
|
+
// 'wx' = exclusive create. Two parallel processes cannot both succeed.
|
|
118
|
+
const fd = openSync(p, 'wx');
|
|
119
|
+
const payload = JSON.stringify({ pid: process.pid, ts: new Date().toISOString() });
|
|
120
|
+
writeSync(fd, payload);
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e: unknown) {
|
|
124
|
+
const code = (e as NodeJS.ErrnoException)?.code;
|
|
125
|
+
if (code === 'EEXIST') return false;
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AllocateOptions {
|
|
131
|
+
repoRoot: string;
|
|
132
|
+
prefix: string;
|
|
133
|
+
/** For tests: clock injection. */
|
|
134
|
+
now?: () => number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function allocateNextId(opts: AllocateOptions): string {
|
|
138
|
+
const { repoRoot, prefix } = opts;
|
|
139
|
+
const now = opts.now ?? Date.now;
|
|
140
|
+
|
|
141
|
+
const paths = resolveRoadmapPaths(repoRoot, prefix);
|
|
142
|
+
const existing = collectExistingNumbers(paths, prefix);
|
|
143
|
+
const startFrom = existing.length ? Math.max(...existing) + 1 : 1;
|
|
144
|
+
|
|
145
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
146
|
+
const n = startFrom + attempt;
|
|
147
|
+
const id = `${prefix}-${String(n).padStart(3, '0')}`;
|
|
148
|
+
// If a fresh lock already exists for this id, skip past it.
|
|
149
|
+
if (lockIsActive(repoRoot, id, now())) continue;
|
|
150
|
+
if (tryAcquireLock(repoRoot, id)) return id;
|
|
151
|
+
// Lost the race — another caller just grabbed this id. Try the next.
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`next-id: could not allocate after ${MAX_ATTEMPTS} attempts for prefix "${prefix}"`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isMain(): boolean {
|
|
157
|
+
// Compare resolved path to argv[1]. Works under tsx.
|
|
158
|
+
try {
|
|
159
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
160
|
+
const here = path.resolve(new URL(import.meta.url).pathname);
|
|
161
|
+
return invoked === here;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isMain()) {
|
|
168
|
+
const prefix = process.argv[2];
|
|
169
|
+
if (!prefix) {
|
|
170
|
+
console.error('Usage: next-id <prefix> (e.g. next-id ed)');
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const cwd = process.cwd();
|
|
175
|
+
const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
|
|
176
|
+
const id = allocateNextId({ repoRoot, prefix });
|
|
177
|
+
process.stdout.write(id + '\n');
|
|
178
|
+
process.exit(0);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.error(`next-id: ${(e as Error).message}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
}
|