tenbo-dashboard 0.8.0 → 0.9.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 (41) hide show
  1. package/README.md +14 -0
  2. package/bin/tenbo-dashboard.mjs +15 -0
  3. package/package.json +3 -2
  4. package/scripts/bin-output.test.ts +45 -0
  5. package/scripts/cliResult.ts +55 -0
  6. package/scripts/context.test.ts +77 -0
  7. package/scripts/context.ts +38 -0
  8. package/scripts/item.test.ts +79 -0
  9. package/scripts/item.ts +76 -0
  10. package/scripts/items.test.ts +46 -0
  11. package/scripts/items.ts +35 -0
  12. package/scripts/next.test.ts +44 -0
  13. package/scripts/next.ts +16 -0
  14. package/src/api/lib/contextResolver.test.ts +161 -0
  15. package/src/api/lib/contextResolver.ts +415 -0
  16. package/src/api/lib/health/collectAll.test.ts +61 -0
  17. package/src/api/lib/health/collectAll.ts +48 -2
  18. package/src/api/lib/health/deadCode.test.ts +5 -1
  19. package/src/api/lib/health/deadCode.ts +28 -39
  20. package/src/api/lib/health/importGraph.test.ts +44 -0
  21. package/src/api/lib/health/importGraph.ts +23 -9
  22. package/src/api/lib/health/layerFiles.ts +10 -13
  23. package/src/api/lib/health/types.test.ts +9 -2
  24. package/src/api/lib/health/types.ts +2 -1
  25. package/src/api/lib/metrics.ts +4 -1
  26. package/src/api/lib/metricsRefresh.ts +2 -1
  27. package/src/api/lib/metricsRefreshQueue.ts +183 -0
  28. package/src/api/lib/roadmapStore.test.ts +134 -0
  29. package/src/api/lib/roadmapStore.ts +251 -0
  30. package/src/api/lib/sourceIgnore.ts +23 -0
  31. package/src/api/lib/validator.ts +40 -0
  32. package/src/api/lib/validator.verification.test.ts +52 -0
  33. package/src/api/plugin.ts +2 -0
  34. package/src/api/routes/items.ts +1 -1
  35. package/src/api/routes/refreshMetrics.ts +40 -0
  36. package/src/api/routes/state.test.ts +148 -0
  37. package/src/api/routes/state.ts +3 -10
  38. package/src/types.ts +24 -0
  39. package/src/ui/HealthPage/HealthPage.module.css +18 -0
  40. package/src/ui/HealthPage/HealthPage.test.tsx +22 -0
  41. package/src/ui/HealthPage/HealthPage.tsx +24 -1
package/README.md CHANGED
@@ -38,6 +38,14 @@ Surfaces the things that would otherwise become tech debt nobody mentions: overs
38
38
  The same package ships commands the skill uses behind the scenes — also runnable directly:
39
39
 
40
40
  ```bash
41
+ npx tenbo-dashboard item show sk-030 --json
42
+ npx tenbo-dashboard item set-status sk-030 done
43
+ npx tenbo-dashboard item add-note sk-030 "Implemented; live verification pending"
44
+ npx tenbo-dashboard item verify sk-030 --status pending_live --evidence "npm test -- --run"
45
+ npx tenbo-dashboard item link-commit sk-030 7fc09a5
46
+ npx tenbo-dashboard items --status done --verification pending_live --json
47
+ npx tenbo-dashboard next --json
48
+ npx tenbo-dashboard context feature --query "help me build X" --json
41
49
  npx tenbo-dashboard validate # Schema + consistency checks
42
50
  npx tenbo-dashboard init-check # Strict completeness check after fresh init
43
51
  npx tenbo-dashboard metrics --all # Recompute scope metrics + health findings
@@ -45,6 +53,12 @@ npx tenbo-dashboard next-id <prefix> # Allocate next roadmap item ID
45
53
  npx tenbo-dashboard --version # Print the installed version
46
54
  ```
47
55
 
56
+ Installed packages also expose a shorter `tenbo` binary alias for the same commands.
57
+
58
+ `context feature` is the agent-facing read path. It returns likely scope/layers,
59
+ matching roadmap items, active work, goal refs, recommended files, and freshness warnings
60
+ as one JSON payload so agents do not have to re-read every roadmap file by hand.
61
+
48
62
  ## What is tenbo?
49
63
 
50
64
  An AI cofounder that gives your coding assistant persistent project memory — architecture docs, roadmaps, and health signals that survive across sessions. Available as a Claude Code skill and as a Cursor rule package; both editors share the same `.tenbo/` data and the same companion dashboard. The dashboard is the optional visual companion; the skill / rule is the always-on conversational brain.
@@ -41,6 +41,10 @@ const commands = {
41
41
  'init-check': 'scripts/init-check.ts',
42
42
  sync: 'scripts/sync.ts',
43
43
  archive: 'scripts/archive.ts',
44
+ item: 'scripts/item.ts',
45
+ items: 'scripts/items.ts',
46
+ next: 'scripts/next.ts',
47
+ context: 'scripts/context.ts',
44
48
  hook: 'scripts/hook.ts',
45
49
  };
46
50
 
@@ -78,6 +82,17 @@ Usage:
78
82
  tenbo-dashboard Launch the dashboard (http://localhost:5174)
79
83
  tenbo-dashboard serve Same as bare invocation (also: 'start', 'dev')
80
84
  tenbo-dashboard sync Refresh tenbo state after a change (metrics + init-check + validate, surfaces new findings)
85
+ tenbo-dashboard item show <id> [--json]
86
+ tenbo-dashboard item set-status <id> <now|next|later|done|dropped> [--json]
87
+ tenbo-dashboard item add-note <id> "<note>" [--json]
88
+ tenbo-dashboard item verify <id> --status <not_required|pending_live|verified|failed>
89
+ [--evidence "<text>"] [--note "<text>"] [--json]
90
+ tenbo-dashboard item link-commit <id> <sha> [--json]
91
+ tenbo-dashboard items Query roadmap items [--status <status>]
92
+ [--verification <status>] [--goal <goal>] [--json]
93
+ tenbo-dashboard next Show next actionable roadmap items [--json]
94
+ tenbo-dashboard context feature Fetch agent planning context for a natural-language request
95
+ --query "<request>" [--json]
81
96
  tenbo-dashboard validate Run validation rules
82
97
  tenbo-dashboard init-check Strict completeness check for fresh init (errors on missing skeletons, file_count:0, etc)
83
98
  tenbo-dashboard next-id <prefix> Allocate next roadmap item ID
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tenbo-dashboard",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,8 @@
23
23
  "node": ">=18"
24
24
  },
25
25
  "bin": {
26
- "tenbo-dashboard": "./bin/tenbo-dashboard.mjs"
26
+ "tenbo-dashboard": "./bin/tenbo-dashboard.mjs",
27
+ "tenbo": "./bin/tenbo-dashboard.mjs"
27
28
  },
28
29
  "files": [
29
30
  "bin/",
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-bin-output-'));
11
+ mkdirSync(path.join(dir, '.git'));
12
+ mkdirSync(path.join(dir, '.tenbo/scopes/editor/layers/app'), { recursive: true });
13
+ writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), 'scopes:\n - id: editor\n path: apps/editor\n description: editor scope\ncross_cutting: []\n');
14
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/architecture.yaml'), 'layers:\n - id: app\n name: App\n description: application layer\n files: ["src/**"]\n');
15
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/layers/app/README.md'), '# App\n');
16
+ writeFileSync(path.join(dir, '.tenbo/overview.md'), '# Overview\n');
17
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
18
+ 'items:',
19
+ ' - id: ed-001',
20
+ ' title: Large Output',
21
+ ' layer: app',
22
+ ' status: done',
23
+ ' description: item with a large field',
24
+ ' risks:',
25
+ ` - ${'x'.repeat(12_000)}`,
26
+ '',
27
+ ].join('\n'));
28
+ });
29
+
30
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
31
+
32
+ describe('bin output', () => {
33
+ it('flushes large JSON output before exiting', () => {
34
+ const bin = path.resolve('bin/tenbo-dashboard.mjs');
35
+ const result = spawnSync(process.execPath, [bin, 'items', '--status', 'done', '--json'], {
36
+ cwd: dir,
37
+ encoding: 'utf8',
38
+ maxBuffer: 1024 * 1024,
39
+ });
40
+
41
+ expect(result.status).toBe(0);
42
+ const payload = JSON.parse(result.stdout);
43
+ expect(payload.items[0].item.risks[0]).toHaveLength(12_000);
44
+ });
45
+ });
@@ -0,0 +1,55 @@
1
+ import { findRepoRoot } from '../src/api/lib/repoRoot';
2
+ import { isRoadmapStoreError } from '../src/api/lib/roadmapStore';
3
+
4
+ export interface CliResult {
5
+ stdout: string;
6
+ stderr: string;
7
+ exitCode: number;
8
+ }
9
+
10
+ export function ok(stdout: string): CliResult {
11
+ return { stdout, stderr: '', exitCode: 0 };
12
+ }
13
+
14
+ export function fail(error: string, message: string, json: boolean, retryable = false): CliResult {
15
+ if (json) {
16
+ return {
17
+ stdout: '',
18
+ stderr: `${JSON.stringify({ ok: false, error, message, retryable })}\n`,
19
+ exitCode: 1,
20
+ };
21
+ }
22
+ return { stdout: '', stderr: `${message}\n`, exitCode: 1 };
23
+ }
24
+
25
+ export function serialize(payload: unknown, json: boolean, text: string): CliResult {
26
+ return ok(json ? `${JSON.stringify(payload, null, 2)}\n` : text);
27
+ }
28
+
29
+ export function handleCliError(err: unknown, json: boolean): CliResult {
30
+ if (isRoadmapStoreError(err)) {
31
+ return fail(err.code, err.message, json, err.retryable);
32
+ }
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ return fail('error', message, json);
35
+ }
36
+
37
+ export function repoRootFromCwd(cwd = process.cwd()): string {
38
+ return findRepoRoot(cwd) ?? cwd;
39
+ }
40
+
41
+ export function runMain(result: CliResult): void {
42
+ if (result.stdout) process.stdout.write(result.stdout);
43
+ if (result.stderr) process.stderr.write(result.stderr);
44
+ process.exitCode = result.exitCode;
45
+ }
46
+
47
+ export function isMain(importMetaUrl: string): boolean {
48
+ try {
49
+ const invoked = process.argv[1] ? new URL(`file://${process.argv[1]}`).pathname : '';
50
+ const here = new URL(importMetaUrl).pathname;
51
+ return invoked === here;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { runContextCli } from './context';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-context-cli-'));
11
+ mkdirSync(path.join(dir, '.git'));
12
+ mkdirSync(path.join(dir, '.tenbo/scopes/dashboard/layers/cli-tools'), { recursive: true });
13
+ writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), [
14
+ 'scopes:',
15
+ ' - id: dashboard',
16
+ ' path: tenbo-dashboard',
17
+ ' description: dashboard and CLI package',
18
+ 'cross_cutting: []',
19
+ '',
20
+ ].join('\n'));
21
+ writeFileSync(path.join(dir, '.tenbo/overview.md'), [
22
+ '# Overview',
23
+ '',
24
+ '## Product goals',
25
+ '- **g1**: Give agents holistic context.',
26
+ '',
27
+ ].join('\n'));
28
+ writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/architecture.yaml'), [
29
+ 'layers:',
30
+ ' - id: cli-tools',
31
+ ' name: CLI Tools',
32
+ ' description: Commands for agents to fetch context.',
33
+ ' files: ["bin/**", "scripts/**"]',
34
+ '',
35
+ ].join('\n'));
36
+ writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/layers/cli-tools/intent.md'), '# CLI Tools\n');
37
+ writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/roadmap.yaml'), [
38
+ 'items:',
39
+ ' - id: td-010',
40
+ ' title: Add context command',
41
+ ' layer: cli-tools',
42
+ ' status: next',
43
+ ' goal_ref: [g1]',
44
+ ' description: Add a context command for feature planning.',
45
+ '',
46
+ ].join('\n'));
47
+ });
48
+
49
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
50
+
51
+ describe('context CLI', () => {
52
+ it('prints feature context as JSON', () => {
53
+ const result = runContextCli(dir, ['feature', '--query', 'build a context command for agents', '--json']);
54
+
55
+ expect(result.exitCode).toBe(0);
56
+ const payload = JSON.parse(result.stdout);
57
+ expect(payload).toMatchObject({
58
+ ok: true,
59
+ intent: 'feature',
60
+ recommendation: {
61
+ scope: 'dashboard',
62
+ layers: ['cli-tools'],
63
+ },
64
+ });
65
+ expect(payload.roadmap.matching_items.map((entry: { item: { id: string } }) => entry.item.id)).toEqual(['td-010']);
66
+ });
67
+
68
+ it('rejects feature context requests without a query', () => {
69
+ const result = runContextCli(dir, ['feature', '--json']);
70
+
71
+ expect(result.exitCode).toBe(1);
72
+ expect(JSON.parse(result.stderr)).toMatchObject({
73
+ ok: false,
74
+ error: 'invalid_args',
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,38 @@
1
+ import { resolveFeatureContext } from '../src/api/lib/contextResolver';
2
+ import { fail, handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
3
+
4
+ function hasFlag(args: string[], flag: string): boolean {
5
+ return args.includes(flag);
6
+ }
7
+
8
+ function readOption(args: string[], flag: string): string | undefined {
9
+ const i = args.indexOf(flag);
10
+ if (i === -1) return undefined;
11
+ return args[i + 1];
12
+ }
13
+
14
+ export function runContextCli(repoRoot: string, args: string[]): CliResult {
15
+ const json = hasFlag(args, '--json');
16
+ const command = args.find((arg) => !arg.startsWith('--'));
17
+
18
+ try {
19
+ if (command === 'feature') {
20
+ const query = readOption(args, '--query');
21
+ if (!query) return fail('invalid_args', 'Usage: tenbo context feature --query "<request>" [--json]', json);
22
+ const bundle = resolveFeatureContext(repoRoot, query);
23
+ return serialize(
24
+ bundle,
25
+ json,
26
+ `${bundle.recommendation.confidence} confidence: ${bundle.recommendation.scope ?? 'unclassified'}${bundle.recommendation.layers.length ? `/${bundle.recommendation.layers.join(',')}` : ''}\n`,
27
+ );
28
+ }
29
+
30
+ return fail('invalid_args', 'Usage: tenbo context feature --query "<request>" [--json]', json);
31
+ } catch (err) {
32
+ return handleCliError(err, json);
33
+ }
34
+ }
35
+
36
+ if (isMain(import.meta.url)) {
37
+ runMain(runContextCli(repoRootFromCwd(), process.argv.slice(2)));
38
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { runItemCli } from './item';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-item-cli-'));
11
+ mkdirSync(path.join(dir, '.git'));
12
+ mkdirSync(path.join(dir, '.tenbo/scopes/editor/layers/app'), { recursive: true });
13
+ writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), 'scopes:\n - id: editor\n path: apps/editor\n description: editor scope\ncross_cutting: []\n');
14
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/architecture.yaml'), 'layers:\n - id: app\n name: App\n description: application layer\n files: ["src/**"]\n');
15
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/layers/app/README.md'), '# App\n');
16
+ writeFileSync(path.join(dir, '.tenbo/overview.md'), '# Overview\n');
17
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
18
+ 'items:',
19
+ ' - id: ed-001',
20
+ ' title: First',
21
+ ' layer: app',
22
+ ' status: next',
23
+ ' description: first item',
24
+ '',
25
+ ].join('\n'));
26
+ });
27
+
28
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
29
+
30
+ describe('item CLI', () => {
31
+ it('prints canonical JSON for item show --json', () => {
32
+ const result = runItemCli(dir, ['show', 'ed-001', '--json']);
33
+
34
+ expect(result.exitCode).toBe(0);
35
+ expect(JSON.parse(result.stdout)).toMatchObject({
36
+ ok: true,
37
+ item: { id: 'ed-001', status: 'next' },
38
+ scope: 'editor',
39
+ });
40
+ });
41
+
42
+ it('sets status and prints concise text by default', () => {
43
+ const result = runItemCli(dir, ['set-status', 'ed-001', 'done']);
44
+
45
+ expect(result).toMatchObject({
46
+ exitCode: 0,
47
+ stdout: 'ed-001 status: done\n',
48
+ stderr: '',
49
+ });
50
+ });
51
+
52
+ it('sets verification status and evidence', () => {
53
+ const result = runItemCli(dir, [
54
+ 'verify',
55
+ 'ed-001',
56
+ '--status',
57
+ 'pending_live',
58
+ '--evidence',
59
+ 'npm test -- --run',
60
+ '--json',
61
+ ]);
62
+
63
+ expect(result.exitCode).toBe(0);
64
+ expect(JSON.parse(result.stdout).item.verification).toMatchObject({
65
+ status: 'pending_live',
66
+ evidence: ['npm test -- --run'],
67
+ });
68
+ });
69
+
70
+ it('returns a structured error for missing items', () => {
71
+ const result = runItemCli(dir, ['show', 'ed-999', '--json']);
72
+
73
+ expect(result.exitCode).toBe(1);
74
+ expect(JSON.parse(result.stderr)).toMatchObject({
75
+ ok: false,
76
+ error: 'not_found',
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,76 @@
1
+ import {
2
+ addItemNote,
3
+ findItem,
4
+ linkItemCommit,
5
+ setItemStatus,
6
+ setItemVerification,
7
+ } from '../src/api/lib/roadmapStore';
8
+ import { fail, handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
9
+
10
+ function hasFlag(args: string[], flag: string): boolean {
11
+ return args.includes(flag);
12
+ }
13
+
14
+ function readOption(args: string[], flag: string): string | undefined {
15
+ const i = args.indexOf(flag);
16
+ if (i === -1) return undefined;
17
+ return args[i + 1];
18
+ }
19
+
20
+ function readRepeated(args: string[], flag: string): string[] {
21
+ const out: string[] = [];
22
+ for (let i = 0; i < args.length; i++) {
23
+ if (args[i] === flag && args[i + 1]) out.push(args[i + 1]);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function runItemCli(repoRoot: string, args: string[]): CliResult {
29
+ const json = hasFlag(args, '--json');
30
+ const [command, itemId, value] = args.filter((arg) => arg !== '--json');
31
+ if (!command || !itemId) return fail('invalid_args', 'Usage: tenbo item <show|set-status|add-note|verify|link-commit> <item-id>', json);
32
+
33
+ try {
34
+ if (command === 'show') {
35
+ const located = findItem(repoRoot, itemId);
36
+ return serialize({ ok: true, scope: located.scopeId, item: located.item }, json, `${itemId}: ${located.item.title} (${located.item.status})\n`);
37
+ }
38
+
39
+ if (command === 'set-status') {
40
+ if (!value) return fail('invalid_args', 'Usage: tenbo item set-status <item-id> <status>', json);
41
+ const result = setItemStatus(repoRoot, itemId, value);
42
+ return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} status: ${result.item.status}\n`);
43
+ }
44
+
45
+ if (command === 'add-note') {
46
+ if (!value) return fail('invalid_args', 'Usage: tenbo item add-note <item-id> <note>', json);
47
+ const result = addItemNote(repoRoot, itemId, value);
48
+ return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} note added\n`);
49
+ }
50
+
51
+ if (command === 'verify') {
52
+ const status = readOption(args, '--status');
53
+ if (!status) return fail('invalid_args', 'Usage: tenbo item verify <item-id> --status <status> [--evidence <text>] [--note <text>]', json);
54
+ const result = setItemVerification(repoRoot, itemId, {
55
+ status,
56
+ evidence: readRepeated(args, '--evidence'),
57
+ note: readOption(args, '--note'),
58
+ });
59
+ return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} verification: ${result.item.verification?.status}\n`);
60
+ }
61
+
62
+ if (command === 'link-commit') {
63
+ if (!value) return fail('invalid_args', 'Usage: tenbo item link-commit <item-id> <sha>', json);
64
+ const result = linkItemCommit(repoRoot, itemId, value);
65
+ return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} linked commit: ${value}\n`);
66
+ }
67
+
68
+ return fail('invalid_args', `unknown item command: ${command}`, json);
69
+ } catch (err) {
70
+ return handleCliError(err, json);
71
+ }
72
+ }
73
+
74
+ if (isMain(import.meta.url)) {
75
+ runMain(runItemCli(repoRootFromCwd(), process.argv.slice(2)));
76
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { runItemsCli } from './items';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-items-cli-'));
11
+ mkdirSync(path.join(dir, '.git'));
12
+ mkdirSync(path.join(dir, '.tenbo/scopes/editor/layers/app'), { recursive: true });
13
+ writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), 'scopes:\n - id: editor\n path: apps/editor\n description: editor scope\ncross_cutting: []\n');
14
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/architecture.yaml'), 'layers:\n - id: app\n name: App\n description: application layer\n files: ["src/**"]\n');
15
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/layers/app/README.md'), '# App\n');
16
+ writeFileSync(path.join(dir, '.tenbo/overview.md'), '# Overview\n');
17
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
18
+ 'items:',
19
+ ' - id: ed-001',
20
+ ' title: First',
21
+ ' layer: app',
22
+ ' status: done',
23
+ ' description: first item',
24
+ ' verification:',
25
+ ' status: pending_live',
26
+ ' updated_at: 2026-06-14T10:00:00.000Z',
27
+ ' - id: ed-002',
28
+ ' title: Second',
29
+ ' layer: app',
30
+ ' status: next',
31
+ ' description: second item',
32
+ '',
33
+ ].join('\n'));
34
+ });
35
+
36
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
37
+
38
+ describe('items CLI', () => {
39
+ it('filters by status and verification status as JSON', () => {
40
+ const result = runItemsCli(dir, ['--status', 'done', '--verification', 'pending_live', '--json']);
41
+
42
+ expect(result.exitCode).toBe(0);
43
+ const payload = JSON.parse(result.stdout);
44
+ expect(payload.items.map((entry: { item: { id: string } }) => entry.item.id)).toEqual(['ed-001']);
45
+ });
46
+ });
@@ -0,0 +1,35 @@
1
+ import { listItems, type ListItemFilters } from '../src/api/lib/roadmapStore';
2
+ import { fail, handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
3
+
4
+ function hasFlag(args: string[], flag: string): boolean {
5
+ return args.includes(flag);
6
+ }
7
+
8
+ function readOption(args: string[], flag: string): string | undefined {
9
+ const i = args.indexOf(flag);
10
+ if (i === -1) return undefined;
11
+ return args[i + 1];
12
+ }
13
+
14
+ export function runItemsCli(repoRoot: string, args: string[]): CliResult {
15
+ const json = hasFlag(args, '--json');
16
+ const filters: ListItemFilters = {
17
+ status: readOption(args, '--status') as ListItemFilters['status'],
18
+ verification: readOption(args, '--verification') as ListItemFilters['verification'],
19
+ goal: readOption(args, '--goal'),
20
+ };
21
+ try {
22
+ const items = listItems(repoRoot, filters);
23
+ return serialize({ ok: true, items }, json, `${items.map((entry) => `${entry.item.id} ${entry.item.status} ${entry.item.title}`).join('\n')}${items.length ? '\n' : ''}`);
24
+ } catch (err) {
25
+ return handleCliError(err, json);
26
+ }
27
+ }
28
+
29
+ if (isMain(import.meta.url)) {
30
+ const args = process.argv.slice(2);
31
+ if (args.includes('--help')) {
32
+ runMain(fail('invalid_args', 'Usage: tenbo items [--status <status>] [--verification <status>] [--goal <goal>] [--json]', false));
33
+ }
34
+ runMain(runItemsCli(repoRootFromCwd(), args));
35
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { runNextCli } from './next';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-next-cli-'));
11
+ mkdirSync(path.join(dir, '.git'));
12
+ mkdirSync(path.join(dir, '.tenbo/scopes/editor/layers/app'), { recursive: true });
13
+ writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), 'scopes:\n - id: editor\n path: apps/editor\n description: editor scope\ncross_cutting: []\n');
14
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/architecture.yaml'), 'layers:\n - id: app\n name: App\n description: application layer\n files: ["src/**"]\n');
15
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/layers/app/README.md'), '# App\n');
16
+ writeFileSync(path.join(dir, '.tenbo/overview.md'), '# Overview\n');
17
+ writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
18
+ 'items:',
19
+ ' - id: ed-001',
20
+ ' title: First',
21
+ ' layer: app',
22
+ ' status: later',
23
+ ' description: first item',
24
+ ' - id: ed-002',
25
+ ' title: Second',
26
+ ' layer: app',
27
+ ' status: next',
28
+ ' priority: p0',
29
+ ' description: second item',
30
+ '',
31
+ ].join('\n'));
32
+ });
33
+
34
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
35
+
36
+ describe('next CLI', () => {
37
+ it('returns next work as JSON', () => {
38
+ const result = runNextCli(dir, ['--json']);
39
+
40
+ expect(result.exitCode).toBe(0);
41
+ const payload = JSON.parse(result.stdout);
42
+ expect(payload.items.map((entry: { item: { id: string } }) => entry.item.id)).toEqual(['ed-002']);
43
+ });
44
+ });
@@ -0,0 +1,16 @@
1
+ import { listNextItems } from '../src/api/lib/roadmapStore';
2
+ import { handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
3
+
4
+ export function runNextCli(repoRoot: string, args: string[]): CliResult {
5
+ const json = args.includes('--json');
6
+ try {
7
+ const items = listNextItems(repoRoot);
8
+ return serialize({ ok: true, items }, json, `${items.map((entry) => `${entry.item.id} ${entry.item.status} ${entry.item.title}`).join('\n')}${items.length ? '\n' : ''}`);
9
+ } catch (err) {
10
+ return handleCliError(err, json);
11
+ }
12
+ }
13
+
14
+ if (isMain(import.meta.url)) {
15
+ runMain(runNextCli(repoRootFromCwd(), process.argv.slice(2)));
16
+ }