tenbo-dashboard 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tenbo-dashboard.mjs +11 -1
- package/package.json +1 -1
- package/scripts/bin-output.test.ts +14 -0
- package/scripts/cliArgs.ts +30 -0
- package/scripts/cliResult.ts +7 -3
- package/scripts/commit-ready.test.ts +47 -0
- package/scripts/commit-ready.ts +69 -0
- package/scripts/context.test.ts +1 -1
- package/scripts/context.ts +4 -13
- package/scripts/item.test.ts +156 -1
- package/scripts/item.ts +98 -26
- package/scripts/items.test.ts +66 -0
- package/scripts/items.ts +33 -13
- package/scripts/next.test.ts +23 -0
- package/scripts/next.ts +19 -1
- package/scripts/work-queue.test.ts +65 -0
- package/scripts/work-queue.ts +58 -0
- package/src/api/lib/itemEvidence.ts +49 -0
- package/src/api/lib/itemProjection.ts +79 -0
- package/src/api/lib/roadmapStore.test.ts +29 -0
- package/src/api/lib/roadmapStore.ts +67 -4
package/bin/tenbo-dashboard.mjs
CHANGED
|
@@ -41,9 +41,12 @@ const commands = {
|
|
|
41
41
|
'init-check': 'scripts/init-check.ts',
|
|
42
42
|
sync: 'scripts/sync.ts',
|
|
43
43
|
archive: 'scripts/archive.ts',
|
|
44
|
+
'commit-ready': 'scripts/commit-ready.ts',
|
|
44
45
|
item: 'scripts/item.ts',
|
|
45
46
|
items: 'scripts/items.ts',
|
|
47
|
+
list: 'scripts/items.ts',
|
|
46
48
|
next: 'scripts/next.ts',
|
|
49
|
+
'work-queue': 'scripts/work-queue.ts',
|
|
47
50
|
context: 'scripts/context.ts',
|
|
48
51
|
hook: 'scripts/hook.ts',
|
|
49
52
|
};
|
|
@@ -89,8 +92,13 @@ Usage:
|
|
|
89
92
|
[--evidence "<text>"] [--note "<text>"] [--json]
|
|
90
93
|
tenbo-dashboard item link-commit <id> <sha> [--json]
|
|
91
94
|
tenbo-dashboard items Query roadmap items [--status <status>]
|
|
92
|
-
[--verification <status>] [--goal <goal>]
|
|
95
|
+
[--verification <status>] [--goal <goal>]
|
|
96
|
+
[--type <type>] [--priority <p0|p1|p2|p3>]
|
|
97
|
+
[--layer <id>] [--fields <a,b>] [--summary] [--json]
|
|
98
|
+
tenbo-dashboard list Alias for items
|
|
93
99
|
tenbo-dashboard next Show next actionable roadmap items [--json]
|
|
100
|
+
tenbo-dashboard work-queue Compact agent task flow [--status <a,b>]
|
|
101
|
+
[--type <type>] [--json]
|
|
94
102
|
tenbo-dashboard context feature Fetch agent planning context for a natural-language request
|
|
95
103
|
--query "<request>" [--json]
|
|
96
104
|
tenbo-dashboard validate Run validation rules
|
|
@@ -102,6 +110,8 @@ Usage:
|
|
|
102
110
|
tenbo-dashboard hook install Install opt-in pre-commit validation hook
|
|
103
111
|
[--dry-run] [--force]
|
|
104
112
|
tenbo-dashboard hook uninstall Remove the tenbo pre-commit hook (idempotent)
|
|
113
|
+
tenbo-dashboard commit-ready Report branch, diff, validation, and staging gates
|
|
114
|
+
without committing [--json]
|
|
105
115
|
tenbo-dashboard --version Print package version
|
|
106
116
|
tenbo-dashboard help Show this help
|
|
107
117
|
|
package/package.json
CHANGED
|
@@ -42,4 +42,18 @@ describe('bin output', () => {
|
|
|
42
42
|
const payload = JSON.parse(result.stdout);
|
|
43
43
|
expect(payload.items[0].item.risks[0]).toHaveLength(12_000);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it('supports list as an alias for items', () => {
|
|
47
|
+
const bin = path.resolve('bin/tenbo-dashboard.mjs');
|
|
48
|
+
const result = spawnSync(process.execPath, [bin, 'list', '--status', 'done', '--fields', 'id,title,status', '--json'], {
|
|
49
|
+
cwd: dir,
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
maxBuffer: 1024 * 1024,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.status).toBe(0);
|
|
55
|
+
expect(JSON.parse(result.stdout).items).toEqual([
|
|
56
|
+
{ id: 'ed-001', title: 'Large Output', status: 'done' },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
45
59
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function hasFlag(args: string[], flag: string): boolean {
|
|
2
|
+
return args.includes(flag);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function readOption(args: string[], flag: string): string | undefined {
|
|
6
|
+
const i = args.indexOf(flag);
|
|
7
|
+
if (i === -1) return undefined;
|
|
8
|
+
return args[i + 1];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readRepeated(args: string[], flag: string): string[] {
|
|
12
|
+
const out: string[] = [];
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
if (args[i] === flag && args[i + 1]) out.push(args[i + 1]);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function positionalArgs(args: string[]): string[] {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (arg.startsWith('--')) {
|
|
24
|
+
if (args[i + 1] && !args[i + 1].startsWith('--')) i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
out.push(arg);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
package/scripts/cliResult.ts
CHANGED
|
@@ -11,15 +11,19 @@ export function ok(stdout: string): CliResult {
|
|
|
11
11
|
return { stdout, stderr: '', exitCode: 0 };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function fail(error: string, message: string, json: boolean, retryable = false): CliResult {
|
|
14
|
+
export function fail(error: string, message: string, json: boolean, retryable = false, exitCode = 1): CliResult {
|
|
15
15
|
if (json) {
|
|
16
16
|
return {
|
|
17
17
|
stdout: '',
|
|
18
18
|
stderr: `${JSON.stringify({ ok: false, error, message, retryable })}\n`,
|
|
19
|
-
exitCode
|
|
19
|
+
exitCode,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
-
return { stdout: '', stderr: `${message}\n`, exitCode
|
|
22
|
+
return { stdout: '', stderr: `${message}\n`, exitCode };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function misuse(message: string, json: boolean): CliResult {
|
|
26
|
+
return fail('invalid_args', message, json, false, 2);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function serialize(payload: unknown, json: boolean, text: string): CliResult {
|
|
@@ -0,0 +1,47 @@
|
|
|
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 path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { runCommitReadyCli } from './commit-ready';
|
|
7
|
+
|
|
8
|
+
let dir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(path.join(tmpdir(), 'tenbo-commit-ready-cli-'));
|
|
12
|
+
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
13
|
+
spawnSync('git', ['checkout', '-q', '-b', 'main'], { cwd: dir });
|
|
14
|
+
mkdirSync(path.join(dir, '.tenbo/scopes/editor/layers/app'), { recursive: true });
|
|
15
|
+
writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), 'scopes:\n - id: editor\n path: apps/editor\n description: editor scope\ncross_cutting: []\n');
|
|
16
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/editor/architecture.yaml'), 'layers:\n - id: app\n name: App\n description: application layer\n files: ["src/**"]\n');
|
|
17
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/editor/layers/app/README.md'), '# App\n');
|
|
18
|
+
writeFileSync(path.join(dir, '.tenbo/overview.md'), '# Overview\n');
|
|
19
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
|
|
20
|
+
'items:',
|
|
21
|
+
' - id: ed-001',
|
|
22
|
+
' title: First',
|
|
23
|
+
' layer: app',
|
|
24
|
+
' status: next',
|
|
25
|
+
' description: first item',
|
|
26
|
+
'',
|
|
27
|
+
].join('\n'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
31
|
+
|
|
32
|
+
describe('commit-ready CLI', () => {
|
|
33
|
+
it('reports branch, dirty files, diff check, and validation without committing', () => {
|
|
34
|
+
const result = runCommitReadyCli(dir, ['--json']);
|
|
35
|
+
|
|
36
|
+
expect(result.exitCode).toBe(0);
|
|
37
|
+
const payload = JSON.parse(result.stdout);
|
|
38
|
+
expect(payload).toMatchObject({
|
|
39
|
+
ok: true,
|
|
40
|
+
branch: expect.objectContaining({ current: 'main' }),
|
|
41
|
+
diff_check: expect.objectContaining({ exitCode: 0 }),
|
|
42
|
+
validation: expect.objectContaining({ errors: 0 }),
|
|
43
|
+
});
|
|
44
|
+
expect(payload.dirty_files.length).toBeGreaterThan(0);
|
|
45
|
+
expect(payload.recommended_gates).toContain('git diff --check');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readState } from '../src/api/lib/tenboFs';
|
|
3
|
+
import { validate } from '../src/api/lib/validator';
|
|
4
|
+
import { hasFlag } from './cliArgs';
|
|
5
|
+
import { handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
6
|
+
|
|
7
|
+
function runGit(repoRoot: string, args: string[]) {
|
|
8
|
+
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
9
|
+
return {
|
|
10
|
+
exitCode: result.status ?? 1,
|
|
11
|
+
stdout: result.stdout ?? '',
|
|
12
|
+
stderr: result.stderr ?? '',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseStatus(stdout: string): { current: string | null; upstream?: string; dirtyFiles: string[] } {
|
|
17
|
+
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
18
|
+
const header = lines[0] ?? '';
|
|
19
|
+
const unbornMatch = header.match(/^## No commits yet on (.+)$/);
|
|
20
|
+
if (unbornMatch) {
|
|
21
|
+
return {
|
|
22
|
+
current: unbornMatch[1],
|
|
23
|
+
dirtyFiles: lines.slice(1),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const branchMatch = header.match(/^## ([^.\s]+)(?:\.\.\.([^\s]+))?/);
|
|
27
|
+
return {
|
|
28
|
+
current: branchMatch?.[1] ?? null,
|
|
29
|
+
...(branchMatch?.[2] ? { upstream: branchMatch[2] } : {}),
|
|
30
|
+
dirtyFiles: lines.slice(1),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runCommitReadyCli(repoRoot: string, args: string[]): CliResult {
|
|
35
|
+
const json = hasFlag(args, '--json');
|
|
36
|
+
try {
|
|
37
|
+
const status = runGit(repoRoot, ['status', '--short', '--branch']);
|
|
38
|
+
const parsed = parseStatus(status.stdout);
|
|
39
|
+
const diffCheck = runGit(repoRoot, ['diff', '--check']);
|
|
40
|
+
const staged = runGit(repoRoot, ['diff', '--cached', '--name-only']);
|
|
41
|
+
const validation = validate(readState(repoRoot));
|
|
42
|
+
const payload = {
|
|
43
|
+
ok: true,
|
|
44
|
+
branch: {
|
|
45
|
+
current: parsed.current,
|
|
46
|
+
upstream: parsed.upstream ?? null,
|
|
47
|
+
},
|
|
48
|
+
dirty_files: parsed.dirtyFiles,
|
|
49
|
+
staged_files: staged.stdout.split(/\r?\n/).filter(Boolean),
|
|
50
|
+
diff_check: diffCheck,
|
|
51
|
+
validation: {
|
|
52
|
+
errors: validation.errors.length,
|
|
53
|
+
warnings: validation.warnings.length,
|
|
54
|
+
},
|
|
55
|
+
recommended_gates: [
|
|
56
|
+
'git diff --check',
|
|
57
|
+
'tenbo-dashboard validate',
|
|
58
|
+
'git diff --cached --check',
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
return serialize(payload, json, `branch: ${payload.branch.current ?? 'unknown'}\ndirty files: ${payload.dirty_files.length}\nvalidation: ${payload.validation.errors} errors, ${payload.validation.warnings} warnings\n`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return handleCliError(err, json);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isMain(import.meta.url)) {
|
|
68
|
+
runMain(runCommitReadyCli(repoRootFromCwd(), process.argv.slice(2)));
|
|
69
|
+
}
|
package/scripts/context.test.ts
CHANGED
|
@@ -68,7 +68,7 @@ describe('context CLI', () => {
|
|
|
68
68
|
it('rejects feature context requests without a query', () => {
|
|
69
69
|
const result = runContextCli(dir, ['feature', '--json']);
|
|
70
70
|
|
|
71
|
-
expect(result.exitCode).toBe(
|
|
71
|
+
expect(result.exitCode).toBe(2);
|
|
72
72
|
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
73
73
|
ok: false,
|
|
74
74
|
error: 'invalid_args',
|
package/scripts/context.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { resolveFeatureContext } from '../src/api/lib/contextResolver';
|
|
2
|
-
import {
|
|
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
|
-
}
|
|
2
|
+
import { hasFlag, readOption } from './cliArgs';
|
|
3
|
+
import { handleCliError, isMain, misuse, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
13
4
|
|
|
14
5
|
export function runContextCli(repoRoot: string, args: string[]): CliResult {
|
|
15
6
|
const json = hasFlag(args, '--json');
|
|
@@ -18,7 +9,7 @@ export function runContextCli(repoRoot: string, args: string[]): CliResult {
|
|
|
18
9
|
try {
|
|
19
10
|
if (command === 'feature') {
|
|
20
11
|
const query = readOption(args, '--query');
|
|
21
|
-
if (!query) return
|
|
12
|
+
if (!query) return misuse('Usage: tenbo context feature --query "<request>" [--json]', json);
|
|
22
13
|
const bundle = resolveFeatureContext(repoRoot, query);
|
|
23
14
|
return serialize(
|
|
24
15
|
bundle,
|
|
@@ -27,7 +18,7 @@ export function runContextCli(repoRoot: string, args: string[]): CliResult {
|
|
|
27
18
|
);
|
|
28
19
|
}
|
|
29
20
|
|
|
30
|
-
return
|
|
21
|
+
return misuse('Usage: tenbo context feature --query "<request>" [--json]', json);
|
|
31
22
|
} catch (err) {
|
|
32
23
|
return handleCliError(err, json);
|
|
33
24
|
}
|
package/scripts/item.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { runItemCli } from './item';
|
|
@@ -21,6 +21,13 @@ beforeEach(() => {
|
|
|
21
21
|
' layer: app',
|
|
22
22
|
' status: next',
|
|
23
23
|
' description: first item',
|
|
24
|
+
' type: refactor',
|
|
25
|
+
' done_when:',
|
|
26
|
+
' - First item lands.',
|
|
27
|
+
' files_to_read:',
|
|
28
|
+
' - src/app.ts',
|
|
29
|
+
' risks:',
|
|
30
|
+
' - May overlap active work.',
|
|
24
31
|
'',
|
|
25
32
|
].join('\n'));
|
|
26
33
|
});
|
|
@@ -49,6 +56,67 @@ describe('item CLI', () => {
|
|
|
49
56
|
});
|
|
50
57
|
});
|
|
51
58
|
|
|
59
|
+
it('prints an active task brief as compact JSON', () => {
|
|
60
|
+
const result = runItemCli(dir, ['brief', 'ed-001', '--json']);
|
|
61
|
+
|
|
62
|
+
expect(result.exitCode).toBe(0);
|
|
63
|
+
const payload = JSON.parse(result.stdout);
|
|
64
|
+
expect(payload).toMatchObject({
|
|
65
|
+
ok: true,
|
|
66
|
+
item: {
|
|
67
|
+
id: 'ed-001',
|
|
68
|
+
title: 'First',
|
|
69
|
+
status: 'next',
|
|
70
|
+
type: 'refactor',
|
|
71
|
+
layer: 'app',
|
|
72
|
+
},
|
|
73
|
+
files_to_read: ['src/app.ts'],
|
|
74
|
+
acceptance_criteria: ['First item lands.'],
|
|
75
|
+
risks: ['May overlap active work.'],
|
|
76
|
+
});
|
|
77
|
+
expect(payload.item).not.toHaveProperty('description');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('prints a handoff prompt for another agent', () => {
|
|
81
|
+
const result = runItemCli(dir, ['handoff', 'ed-001', '--json']);
|
|
82
|
+
|
|
83
|
+
expect(result.exitCode).toBe(0);
|
|
84
|
+
const payload = JSON.parse(result.stdout);
|
|
85
|
+
expect(payload).toMatchObject({
|
|
86
|
+
ok: true,
|
|
87
|
+
item_id: 'ed-001',
|
|
88
|
+
});
|
|
89
|
+
expect(payload.prompt).toContain('Files to read');
|
|
90
|
+
expect(payload.prompt).toContain('src/app.ts');
|
|
91
|
+
expect(payload.prompt).toContain('Acceptance criteria');
|
|
92
|
+
expect(payload.prompt).toContain('First item lands.');
|
|
93
|
+
expect(payload.prompt).toContain('Non-goals');
|
|
94
|
+
expect(payload.prompt).toContain('Completion rules');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('adds a note when existing notes are a YAML sequence', () => {
|
|
98
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
|
|
99
|
+
'items:',
|
|
100
|
+
' - id: ed-001',
|
|
101
|
+
' title: First',
|
|
102
|
+
' layer: app',
|
|
103
|
+
' status: next',
|
|
104
|
+
' description: first item',
|
|
105
|
+
' notes:',
|
|
106
|
+
' - Existing list note',
|
|
107
|
+
'',
|
|
108
|
+
].join('\n'));
|
|
109
|
+
|
|
110
|
+
const result = runItemCli(dir, ['add-note', 'ed-001', 'Added from CLI', '--json']);
|
|
111
|
+
const roadmap = readFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), 'utf8');
|
|
112
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
113
|
+
|
|
114
|
+
expect(result.exitCode).toBe(0);
|
|
115
|
+
expect(JSON.parse(result.stdout).item.notes).toContain('- Existing list note');
|
|
116
|
+
expect(JSON.parse(result.stdout).item.notes).toContain(`- ${today}: Added from CLI`);
|
|
117
|
+
expect(roadmap).toContain(`- ${today}: Added from CLI`);
|
|
118
|
+
});
|
|
119
|
+
|
|
52
120
|
it('sets verification status and evidence', () => {
|
|
53
121
|
const result = runItemCli(dir, [
|
|
54
122
|
'verify',
|
|
@@ -67,6 +135,79 @@ describe('item CLI', () => {
|
|
|
67
135
|
});
|
|
68
136
|
});
|
|
69
137
|
|
|
138
|
+
it('checks item evidence without mutating verification metadata', () => {
|
|
139
|
+
const result = runItemCli(dir, ['verify', 'ed-001', '--check', '--json']);
|
|
140
|
+
|
|
141
|
+
expect(result.exitCode).toBe(0);
|
|
142
|
+
const payload = JSON.parse(result.stdout);
|
|
143
|
+
expect(payload).toMatchObject({
|
|
144
|
+
ok: true,
|
|
145
|
+
item_id: 'ed-001',
|
|
146
|
+
verdict: 'likely_open',
|
|
147
|
+
});
|
|
148
|
+
expect(findRoadmap()).not.toContain('verification:');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports inconsistent evidence for done items missing hygiene fields', () => {
|
|
152
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), [
|
|
153
|
+
'items:',
|
|
154
|
+
' - id: ed-001',
|
|
155
|
+
' title: First',
|
|
156
|
+
' layer: app',
|
|
157
|
+
' status: done',
|
|
158
|
+
' description: first item',
|
|
159
|
+
'',
|
|
160
|
+
].join('\n'));
|
|
161
|
+
|
|
162
|
+
const result = runItemCli(dir, ['verify', 'ed-001', '--check', '--json']);
|
|
163
|
+
|
|
164
|
+
expect(result.exitCode).toBe(0);
|
|
165
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
166
|
+
verdict: 'inconsistent',
|
|
167
|
+
missing: expect.arrayContaining(['verification', 'doc_update']),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('sets doc_update from the CLI', () => {
|
|
172
|
+
const dated = runItemCli(dir, ['doc-update', 'ed-001', '--date', 'today', '--json']);
|
|
173
|
+
const skipped = runItemCli(dir, ['doc-update', 'ed-001', '--skipped', '--reason', 'No docs changed.', '--json']);
|
|
174
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
175
|
+
|
|
176
|
+
expect(dated.exitCode).toBe(0);
|
|
177
|
+
expect(JSON.parse(dated.stdout).item.doc_update).toBe(today);
|
|
178
|
+
expect(skipped.exitCode).toBe(0);
|
|
179
|
+
expect(JSON.parse(skipped.stdout).item.doc_update).toBe('skipped — No docs changed.');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('completes an item from the CLI in one roadmap transaction', () => {
|
|
183
|
+
const result = runItemCli(dir, [
|
|
184
|
+
'complete',
|
|
185
|
+
'ed-001',
|
|
186
|
+
'--evidence',
|
|
187
|
+
'npm test -- --run',
|
|
188
|
+
'--doc-update',
|
|
189
|
+
'today',
|
|
190
|
+
'--commit',
|
|
191
|
+
'abc123',
|
|
192
|
+
'--json',
|
|
193
|
+
]);
|
|
194
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
195
|
+
|
|
196
|
+
expect(result.exitCode).toBe(0);
|
|
197
|
+
const payload = JSON.parse(result.stdout);
|
|
198
|
+
expect(payload.item).toMatchObject({
|
|
199
|
+
status: 'done',
|
|
200
|
+
doc_update: today,
|
|
201
|
+
links: ['commit:abc123'],
|
|
202
|
+
verification: {
|
|
203
|
+
status: 'verified',
|
|
204
|
+
evidence: ['npm test -- --run'],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
expect(payload.item.notes).toContain(`- ${today}: Completed with evidence: npm test -- --run`);
|
|
208
|
+
expect(payload.validation).toHaveProperty('errors');
|
|
209
|
+
});
|
|
210
|
+
|
|
70
211
|
it('returns a structured error for missing items', () => {
|
|
71
212
|
const result = runItemCli(dir, ['show', 'ed-999', '--json']);
|
|
72
213
|
|
|
@@ -76,4 +217,18 @@ describe('item CLI', () => {
|
|
|
76
217
|
error: 'not_found',
|
|
77
218
|
});
|
|
78
219
|
});
|
|
220
|
+
|
|
221
|
+
it('returns misuse exit code for unknown item commands', () => {
|
|
222
|
+
const result = runItemCli(dir, ['unknown', 'ed-001', '--json']);
|
|
223
|
+
|
|
224
|
+
expect(result.exitCode).toBe(2);
|
|
225
|
+
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
226
|
+
ok: false,
|
|
227
|
+
error: 'invalid_args',
|
|
228
|
+
});
|
|
229
|
+
});
|
|
79
230
|
});
|
|
231
|
+
|
|
232
|
+
function findRoadmap(): string {
|
|
233
|
+
return readFileSync(path.join(dir, '.tenbo/scopes/editor/roadmap.yaml'), 'utf8');
|
|
234
|
+
}
|
package/scripts/item.ts
CHANGED
|
@@ -1,34 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
2
|
addItemNote,
|
|
3
|
+
completeItem,
|
|
3
4
|
findItem,
|
|
4
5
|
linkItemCommit,
|
|
6
|
+
setItemDocUpdate,
|
|
5
7
|
setItemStatus,
|
|
6
8
|
setItemVerification,
|
|
7
9
|
} from '../src/api/lib/roadmapStore';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
}
|
|
10
|
+
import { summarizeItem } from '../src/api/lib/itemProjection';
|
|
11
|
+
import { checkItemEvidence } from '../src/api/lib/itemEvidence';
|
|
12
|
+
import { hasFlag, positionalArgs, readOption, readRepeated } from './cliArgs';
|
|
13
|
+
import { handleCliError, isMain, misuse, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
27
14
|
|
|
28
15
|
export function runItemCli(repoRoot: string, args: string[]): CliResult {
|
|
29
16
|
const json = hasFlag(args, '--json');
|
|
30
|
-
const [command, itemId, value] = args
|
|
31
|
-
if (!command || !itemId) return
|
|
17
|
+
const [command, itemId, value] = positionalArgs(args);
|
|
18
|
+
if (!command || !itemId) return misuse('Usage: tenbo item <show|set-status|add-note|verify|link-commit> <item-id>', json);
|
|
32
19
|
|
|
33
20
|
try {
|
|
34
21
|
if (command === 'show') {
|
|
@@ -36,21 +23,77 @@ export function runItemCli(repoRoot: string, args: string[]): CliResult {
|
|
|
36
23
|
return serialize({ ok: true, scope: located.scopeId, item: located.item }, json, `${itemId}: ${located.item.title} (${located.item.status})\n`);
|
|
37
24
|
}
|
|
38
25
|
|
|
26
|
+
if (command === 'brief') {
|
|
27
|
+
const located = findItem(repoRoot, itemId);
|
|
28
|
+
const item = located.item;
|
|
29
|
+
return serialize({
|
|
30
|
+
ok: true,
|
|
31
|
+
scope: located.scopeId,
|
|
32
|
+
item: summarizeItem(located),
|
|
33
|
+
files_to_read: item.files_to_read ?? [],
|
|
34
|
+
acceptance_criteria: item.done_when ?? [],
|
|
35
|
+
risks: item.risks ?? [],
|
|
36
|
+
verification_commands: [
|
|
37
|
+
`tenbo-dashboard item verify ${item.id} --check --json`,
|
|
38
|
+
],
|
|
39
|
+
completion_commands: [
|
|
40
|
+
`tenbo-dashboard item complete ${item.id} --evidence "<evidence>" --doc-update today --json`,
|
|
41
|
+
],
|
|
42
|
+
}, json, `${item.id}: ${item.title} (${item.status})\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (command === 'handoff') {
|
|
46
|
+
const located = findItem(repoRoot, itemId);
|
|
47
|
+
const item = located.item;
|
|
48
|
+
const files = item.files_to_read?.length ? item.files_to_read.map((file) => `- ${file}`).join('\n') : '- Read the linked item/spec first.';
|
|
49
|
+
const criteria = item.done_when?.length ? item.done_when.map((entry) => `- ${entry}`).join('\n') : '- Satisfy the roadmap item description.';
|
|
50
|
+
const risks = item.risks?.length ? item.risks.map((entry) => `- ${entry}`).join('\n') : '- No known overlap risks recorded.';
|
|
51
|
+
const prompt = [
|
|
52
|
+
`Implement ${item.id}: ${item.title}`,
|
|
53
|
+
'',
|
|
54
|
+
'Files to read',
|
|
55
|
+
files,
|
|
56
|
+
'',
|
|
57
|
+
'Non-goals',
|
|
58
|
+
'- Do not broaden scope beyond this roadmap item.',
|
|
59
|
+
'- Do not edit unrelated roadmap items or user changes.',
|
|
60
|
+
'',
|
|
61
|
+
'Overlap risks',
|
|
62
|
+
risks,
|
|
63
|
+
'',
|
|
64
|
+
'Acceptance criteria',
|
|
65
|
+
criteria,
|
|
66
|
+
'',
|
|
67
|
+
'Verification commands',
|
|
68
|
+
`- tenbo-dashboard item verify ${item.id} --check --json`,
|
|
69
|
+
'- Run focused tests for touched code.',
|
|
70
|
+
'',
|
|
71
|
+
'Completion rules',
|
|
72
|
+
`- Use tenbo-dashboard item complete ${item.id} --evidence "<evidence>" --doc-update today --json when the item is actually complete.`,
|
|
73
|
+
'- Report tests run and any residual risks.',
|
|
74
|
+
].join('\n');
|
|
75
|
+
return serialize({ ok: true, item_id: item.id, scope: located.scopeId, prompt }, json, `${prompt}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
39
78
|
if (command === 'set-status') {
|
|
40
|
-
if (!value) return
|
|
79
|
+
if (!value) return misuse('Usage: tenbo item set-status <item-id> <status>', json);
|
|
41
80
|
const result = setItemStatus(repoRoot, itemId, value);
|
|
42
81
|
return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} status: ${result.item.status}\n`);
|
|
43
82
|
}
|
|
44
83
|
|
|
45
84
|
if (command === 'add-note') {
|
|
46
|
-
if (!value) return
|
|
85
|
+
if (!value) return misuse('Usage: tenbo item add-note <item-id> <note>', json);
|
|
47
86
|
const result = addItemNote(repoRoot, itemId, value);
|
|
48
87
|
return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} note added\n`);
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
if (command === 'verify') {
|
|
91
|
+
if (hasFlag(args, '--check')) {
|
|
92
|
+
const report = checkItemEvidence(repoRoot, itemId);
|
|
93
|
+
return serialize(report, json, `${itemId} evidence: ${report.verdict}\n`);
|
|
94
|
+
}
|
|
52
95
|
const status = readOption(args, '--status');
|
|
53
|
-
if (!status) return
|
|
96
|
+
if (!status) return misuse('Usage: tenbo item verify <item-id> --status <status> [--evidence <text>] [--note <text>]', json);
|
|
54
97
|
const result = setItemVerification(repoRoot, itemId, {
|
|
55
98
|
status,
|
|
56
99
|
evidence: readRepeated(args, '--evidence'),
|
|
@@ -59,13 +102,42 @@ export function runItemCli(repoRoot: string, args: string[]): CliResult {
|
|
|
59
102
|
return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} verification: ${result.item.verification?.status}\n`);
|
|
60
103
|
}
|
|
61
104
|
|
|
105
|
+
if (command === 'doc-update') {
|
|
106
|
+
const date = readOption(args, '--date');
|
|
107
|
+
const skipped = hasFlag(args, '--skipped');
|
|
108
|
+
const reason = readOption(args, '--reason');
|
|
109
|
+
if (!date && !skipped) return misuse('Usage: tenbo item doc-update <item-id> --date <today|YYYY-MM-DD> OR --skipped --reason "<text>"', json);
|
|
110
|
+
if (skipped && !reason) return misuse('Usage: tenbo item doc-update <item-id> --skipped --reason "<text>"', json);
|
|
111
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
112
|
+
const docUpdate = skipped ? `skipped — ${reason}` : date === 'today' ? today : date;
|
|
113
|
+
const result = setItemDocUpdate(repoRoot, itemId, docUpdate ?? today);
|
|
114
|
+
return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} doc_update: ${result.item.doc_update}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (command === 'complete') {
|
|
118
|
+
const evidence = readRepeated(args, '--evidence');
|
|
119
|
+
const docUpdateArg = readOption(args, '--doc-update');
|
|
120
|
+
const commit = readOption(args, '--commit');
|
|
121
|
+
const verificationStatus = readOption(args, '--verification');
|
|
122
|
+
if (evidence.length === 0) return misuse('Usage: tenbo item complete <item-id> --evidence "<text>" [--doc-update today|YYYY-MM-DD] [--commit <sha>] [--verification <status>]', json);
|
|
123
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
124
|
+
const docUpdate = docUpdateArg === 'today' ? today : docUpdateArg;
|
|
125
|
+
const result = completeItem(repoRoot, itemId, {
|
|
126
|
+
evidence,
|
|
127
|
+
...(docUpdate ? { docUpdate } : {}),
|
|
128
|
+
...(commit ? { commit } : {}),
|
|
129
|
+
...(verificationStatus ? { verificationStatus } : {}),
|
|
130
|
+
});
|
|
131
|
+
return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} completed\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
62
134
|
if (command === 'link-commit') {
|
|
63
|
-
if (!value) return
|
|
135
|
+
if (!value) return misuse('Usage: tenbo item link-commit <item-id> <sha>', json);
|
|
64
136
|
const result = linkItemCommit(repoRoot, itemId, value);
|
|
65
137
|
return serialize({ ok: true, scope: result.scopeId, item: result.item, validation: result.validation }, json, `${itemId} linked commit: ${value}\n`);
|
|
66
138
|
}
|
|
67
139
|
|
|
68
|
-
return
|
|
140
|
+
return misuse(`unknown item command: ${command}`, json);
|
|
69
141
|
} catch (err) {
|
|
70
142
|
return handleCliError(err, json);
|
|
71
143
|
}
|
package/scripts/items.test.ts
CHANGED
|
@@ -28,7 +28,20 @@ beforeEach(() => {
|
|
|
28
28
|
' title: Second',
|
|
29
29
|
' layer: app',
|
|
30
30
|
' status: next',
|
|
31
|
+
' type: feature',
|
|
31
32
|
' description: second item',
|
|
33
|
+
' notes: |',
|
|
34
|
+
' - 2026-06-14: Long implementation note that summary should not dump wholesale.',
|
|
35
|
+
' - id: ed-003',
|
|
36
|
+
' title: Third',
|
|
37
|
+
' layer: app',
|
|
38
|
+
' status: later',
|
|
39
|
+
' type: refactor',
|
|
40
|
+
' priority: p1',
|
|
41
|
+
' description: third item',
|
|
42
|
+
' verification:',
|
|
43
|
+
' status: verified',
|
|
44
|
+
' updated_at: 2026-06-15T10:00:00.000Z',
|
|
32
45
|
'',
|
|
33
46
|
].join('\n'));
|
|
34
47
|
});
|
|
@@ -43,4 +56,57 @@ describe('items CLI', () => {
|
|
|
43
56
|
const payload = JSON.parse(result.stdout);
|
|
44
57
|
expect(payload.items.map((entry: { item: { id: string } }) => entry.item.id)).toEqual(['ed-001']);
|
|
45
58
|
});
|
|
59
|
+
|
|
60
|
+
it('filters by type and comma-separated statuses with selected fields', () => {
|
|
61
|
+
const result = runItemsCli(dir, [
|
|
62
|
+
'--type',
|
|
63
|
+
'refactor',
|
|
64
|
+
'--status',
|
|
65
|
+
'next,later',
|
|
66
|
+
'--fields',
|
|
67
|
+
'id,title,status,priority,layer,verification',
|
|
68
|
+
'--json',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
expect(result.exitCode).toBe(0);
|
|
72
|
+
const payload = JSON.parse(result.stdout);
|
|
73
|
+
expect(payload.items).toEqual([
|
|
74
|
+
{
|
|
75
|
+
id: 'ed-003',
|
|
76
|
+
title: 'Third',
|
|
77
|
+
status: 'later',
|
|
78
|
+
priority: 'p1',
|
|
79
|
+
layer: 'app',
|
|
80
|
+
verification: 'verified',
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns compact summaries without changing full JSON by default', () => {
|
|
86
|
+
const summary = runItemsCli(dir, ['--status', 'next', '--summary', '--json']);
|
|
87
|
+
const full = runItemsCli(dir, ['--status', 'next', '--json']);
|
|
88
|
+
|
|
89
|
+
expect(summary.exitCode).toBe(0);
|
|
90
|
+
expect(JSON.parse(summary.stdout).items).toEqual([
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
id: 'ed-002',
|
|
93
|
+
title: 'Second',
|
|
94
|
+
status: 'next',
|
|
95
|
+
type: 'feature',
|
|
96
|
+
layer: 'app',
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
expect(JSON.parse(summary.stdout).items[0]).not.toHaveProperty('description');
|
|
100
|
+
expect(JSON.parse(full.stdout).items[0].item.description).toBe('second item');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns misuse errors for invalid status lists and fields', () => {
|
|
104
|
+
const badStatus = runItemsCli(dir, ['--status', 'next,unknown', '--json']);
|
|
105
|
+
const badField = runItemsCli(dir, ['--fields', 'id,nope', '--json']);
|
|
106
|
+
|
|
107
|
+
expect(badStatus.exitCode).toBe(2);
|
|
108
|
+
expect(JSON.parse(badStatus.stderr)).toMatchObject({ ok: false, error: 'invalid_args' });
|
|
109
|
+
expect(badField.exitCode).toBe(2);
|
|
110
|
+
expect(JSON.parse(badField.stderr)).toMatchObject({ ok: false, error: 'invalid_args' });
|
|
111
|
+
});
|
|
46
112
|
});
|
package/scripts/items.ts
CHANGED
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
import { listItems, type ListItemFilters } from '../src/api/lib/roadmapStore';
|
|
2
|
-
import {
|
|
2
|
+
import { parseItemFields, projectItem, summarizeItem } from '../src/api/lib/itemProjection';
|
|
3
|
+
import { hasFlag, readOption } from './cliArgs';
|
|
4
|
+
import { handleCliError, isMain, misuse, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
return args.includes(flag);
|
|
6
|
-
}
|
|
6
|
+
const STATUSES = new Set(['now', 'next', 'later', 'done', 'dropped']);
|
|
7
7
|
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
function readStatusFilter(value: string | undefined): ListItemFilters['status'] {
|
|
9
|
+
if (!value) return undefined;
|
|
10
|
+
const statuses = value.split(',').map((status) => status.trim()).filter(Boolean);
|
|
11
|
+
for (const status of statuses) {
|
|
12
|
+
if (!STATUSES.has(status)) throw new Error(`invalid status: ${status}`);
|
|
13
|
+
}
|
|
14
|
+
return statuses as ListItemFilters['status'];
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export function runItemsCli(repoRoot: string, args: string[]): CliResult {
|
|
15
18
|
const json = hasFlag(args, '--json');
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
verification: readOption(args, '--verification') as ListItemFilters['verification'],
|
|
19
|
-
goal: readOption(args, '--goal'),
|
|
20
|
-
};
|
|
19
|
+
const summary = hasFlag(args, '--summary');
|
|
20
|
+
const fieldsArg = readOption(args, '--fields');
|
|
21
21
|
try {
|
|
22
|
+
const filters: ListItemFilters = {
|
|
23
|
+
status: readStatusFilter(readOption(args, '--status')),
|
|
24
|
+
verification: readOption(args, '--verification') as ListItemFilters['verification'],
|
|
25
|
+
goal: readOption(args, '--goal'),
|
|
26
|
+
type: readOption(args, '--type') as ListItemFilters['type'],
|
|
27
|
+
priority: readOption(args, '--priority') as ListItemFilters['priority'],
|
|
28
|
+
layer: readOption(args, '--layer'),
|
|
29
|
+
};
|
|
22
30
|
const items = listItems(repoRoot, filters);
|
|
31
|
+
if (fieldsArg) {
|
|
32
|
+
const fields = parseItemFields(fieldsArg);
|
|
33
|
+
const projected = items.map((entry) => projectItem(entry, fields));
|
|
34
|
+
return serialize({ ok: true, items: projected }, json, `${projected.map((entry) => `${entry.id} ${entry.status} ${entry.title}`).join('\n')}${projected.length ? '\n' : ''}`);
|
|
35
|
+
}
|
|
36
|
+
if (summary) {
|
|
37
|
+
const projected = items.map((entry) => summarizeItem(entry));
|
|
38
|
+
return serialize({ ok: true, items: projected }, json, `${projected.map((entry) => `${entry.id} ${entry.status} ${entry.title}`).join('\n')}${projected.length ? '\n' : ''}`);
|
|
39
|
+
}
|
|
23
40
|
return serialize({ ok: true, items }, json, `${items.map((entry) => `${entry.item.id} ${entry.item.status} ${entry.item.title}`).join('\n')}${items.length ? '\n' : ''}`);
|
|
24
41
|
} catch (err) {
|
|
42
|
+
if (err instanceof Error && (err.message.startsWith('invalid field:') || err.message.startsWith('invalid status:'))) {
|
|
43
|
+
return misuse(err.message, json);
|
|
44
|
+
}
|
|
25
45
|
return handleCliError(err, json);
|
|
26
46
|
}
|
|
27
47
|
}
|
package/scripts/next.test.ts
CHANGED
|
@@ -27,6 +27,11 @@ beforeEach(() => {
|
|
|
27
27
|
' status: next',
|
|
28
28
|
' priority: p0',
|
|
29
29
|
' description: second item',
|
|
30
|
+
' type: refactor',
|
|
31
|
+
' done_when:',
|
|
32
|
+
' - Next work lands.',
|
|
33
|
+
' files_to_read:',
|
|
34
|
+
' - src/next.ts',
|
|
30
35
|
'',
|
|
31
36
|
].join('\n'));
|
|
32
37
|
});
|
|
@@ -41,4 +46,22 @@ describe('next CLI', () => {
|
|
|
41
46
|
const payload = JSON.parse(result.stdout);
|
|
42
47
|
expect(payload.items.map((entry: { item: { id: string } }) => entry.item.id)).toEqual(['ed-002']);
|
|
43
48
|
});
|
|
49
|
+
|
|
50
|
+
it('returns compact agent summary for next work', () => {
|
|
51
|
+
const result = runNextCli(dir, ['--agent-summary', '--json']);
|
|
52
|
+
|
|
53
|
+
expect(result.exitCode).toBe(0);
|
|
54
|
+
const payload = JSON.parse(result.stdout);
|
|
55
|
+
expect(payload.recommended_sequence).toEqual(['ed-002']);
|
|
56
|
+
expect(payload.items).toEqual([
|
|
57
|
+
expect.objectContaining({
|
|
58
|
+
id: 'ed-002',
|
|
59
|
+
title: 'Second',
|
|
60
|
+
status: 'next',
|
|
61
|
+
type: 'refactor',
|
|
62
|
+
priority: 'p0',
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
expect(payload.items[0]).not.toHaveProperty('description');
|
|
66
|
+
});
|
|
44
67
|
});
|
package/scripts/next.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { listNextItems } from '../src/api/lib/roadmapStore';
|
|
2
|
+
import { summarizeItem } from '../src/api/lib/itemProjection';
|
|
3
|
+
import { hasFlag } from './cliArgs';
|
|
2
4
|
import { handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
3
5
|
|
|
4
6
|
export function runNextCli(repoRoot: string, args: string[]): CliResult {
|
|
5
|
-
const json = args
|
|
7
|
+
const json = hasFlag(args, '--json');
|
|
8
|
+
const agentSummary = hasFlag(args, '--agent-summary');
|
|
6
9
|
try {
|
|
7
10
|
const items = listNextItems(repoRoot);
|
|
11
|
+
if (agentSummary) {
|
|
12
|
+
const projected = items.map((entry) => summarizeItem(entry));
|
|
13
|
+
return serialize({
|
|
14
|
+
ok: true,
|
|
15
|
+
recommended_sequence: projected.map((item) => item.id),
|
|
16
|
+
items: projected,
|
|
17
|
+
verification: projected.map((item) => ({
|
|
18
|
+
id: item.id,
|
|
19
|
+
commands: [
|
|
20
|
+
`tenbo-dashboard item brief ${item.id} --json`,
|
|
21
|
+
`tenbo-dashboard item verify ${item.id} --check --json`,
|
|
22
|
+
],
|
|
23
|
+
})),
|
|
24
|
+
}, json, `${projected.map((entry) => `${entry.id} ${entry.status} ${entry.title}`).join('\n')}${projected.length ? '\n' : ''}`);
|
|
25
|
+
}
|
|
8
26
|
return serialize({ ok: true, items }, json, `${items.map((entry) => `${entry.item.id} ${entry.item.status} ${entry.item.title}`).join('\n')}${items.length ? '\n' : ''}`);
|
|
9
27
|
} catch (err) {
|
|
10
28
|
return handleCliError(err, json);
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { runWorkQueueCli } from './work-queue';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(path.join(tmpdir(), 'tenbo-work-queue-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: Active Refactor',
|
|
21
|
+
' layer: app',
|
|
22
|
+
' status: now',
|
|
23
|
+
' type: refactor',
|
|
24
|
+
' priority: p2',
|
|
25
|
+
' description: active refactor',
|
|
26
|
+
' done_when:',
|
|
27
|
+
' - Refactor lands.',
|
|
28
|
+
' files_to_read:',
|
|
29
|
+
' - src/app.ts',
|
|
30
|
+
' - id: ed-002',
|
|
31
|
+
' title: Later Refactor',
|
|
32
|
+
' layer: app',
|
|
33
|
+
' status: later',
|
|
34
|
+
' type: refactor',
|
|
35
|
+
' priority: p1',
|
|
36
|
+
' description: later refactor',
|
|
37
|
+
' - id: ed-003',
|
|
38
|
+
' title: Later Feature',
|
|
39
|
+
' layer: app',
|
|
40
|
+
' status: later',
|
|
41
|
+
' type: feature',
|
|
42
|
+
' description: later feature',
|
|
43
|
+
'',
|
|
44
|
+
].join('\n'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
48
|
+
|
|
49
|
+
describe('work-queue CLI', () => {
|
|
50
|
+
it('returns compact actionable candidates filtered by type and status', () => {
|
|
51
|
+
const result = runWorkQueueCli(dir, ['--type', 'refactor', '--status', 'now,later', '--json']);
|
|
52
|
+
|
|
53
|
+
expect(result.exitCode).toBe(0);
|
|
54
|
+
const payload = JSON.parse(result.stdout);
|
|
55
|
+
expect(payload).toMatchObject({
|
|
56
|
+
ok: true,
|
|
57
|
+
recommended_sequence: ['ed-001', 'ed-002'],
|
|
58
|
+
});
|
|
59
|
+
expect(payload.items).toEqual([
|
|
60
|
+
expect.objectContaining({ id: 'ed-001', title: 'Active Refactor', status: 'now', type: 'refactor' }),
|
|
61
|
+
expect.objectContaining({ id: 'ed-002', title: 'Later Refactor', status: 'later', type: 'refactor' }),
|
|
62
|
+
]);
|
|
63
|
+
expect(payload.items[0]).not.toHaveProperty('description');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { listItems, type ListItemFilters } from '../src/api/lib/roadmapStore';
|
|
2
|
+
import { summarizeItem } from '../src/api/lib/itemProjection';
|
|
3
|
+
import { comparePriority } from '../src/api/lib/priority';
|
|
4
|
+
import { hasFlag, readOption } from './cliArgs';
|
|
5
|
+
import { handleCliError, isMain, misuse, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
|
|
6
|
+
|
|
7
|
+
const STATUSES = new Set(['now', 'next', 'later', 'done', 'dropped']);
|
|
8
|
+
|
|
9
|
+
function readStatusFilter(value: string | undefined): ListItemFilters['status'] {
|
|
10
|
+
if (!value) return ['now', 'next'];
|
|
11
|
+
const statuses = value.split(',').map((status) => status.trim()).filter(Boolean);
|
|
12
|
+
for (const status of statuses) {
|
|
13
|
+
if (!STATUSES.has(status)) throw new Error(`invalid status: ${status}`);
|
|
14
|
+
}
|
|
15
|
+
return statuses as ListItemFilters['status'];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function statusRank(status: string): number {
|
|
19
|
+
return status === 'now' ? 0 : status === 'next' ? 1 : status === 'later' ? 2 : 3;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runWorkQueueCli(repoRoot: string, args: string[]): CliResult {
|
|
23
|
+
const json = hasFlag(args, '--json');
|
|
24
|
+
try {
|
|
25
|
+
const filters: ListItemFilters = {
|
|
26
|
+
status: readStatusFilter(readOption(args, '--status')),
|
|
27
|
+
type: readOption(args, '--type') as ListItemFilters['type'],
|
|
28
|
+
priority: readOption(args, '--priority') as ListItemFilters['priority'],
|
|
29
|
+
layer: readOption(args, '--layer'),
|
|
30
|
+
verification: readOption(args, '--verification') as ListItemFilters['verification'],
|
|
31
|
+
goal: readOption(args, '--goal'),
|
|
32
|
+
};
|
|
33
|
+
const located = listItems(repoRoot, filters)
|
|
34
|
+
.sort((a, b) => statusRank(a.item.status) - statusRank(b.item.status) || comparePriority(a.item, b.item) || a.item.id.localeCompare(b.item.id));
|
|
35
|
+
const items = located.map((entry) => summarizeItem(entry));
|
|
36
|
+
const payload = {
|
|
37
|
+
ok: true,
|
|
38
|
+
recommended_sequence: items.map((item) => item.id),
|
|
39
|
+
blockers: [],
|
|
40
|
+
items,
|
|
41
|
+
verification: items.map((item) => ({
|
|
42
|
+
id: item.id,
|
|
43
|
+
commands: [
|
|
44
|
+
`tenbo-dashboard item brief ${item.id} --json`,
|
|
45
|
+
`tenbo-dashboard item verify ${item.id} --check --json`,
|
|
46
|
+
],
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
return serialize(payload, json, `${items.map((item) => `${item.id} ${item.status} ${item.title}`).join('\n')}${items.length ? '\n' : ''}`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof Error && err.message.startsWith('invalid status:')) return misuse(err.message, json);
|
|
52
|
+
return handleCliError(err, json);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isMain(import.meta.url)) {
|
|
57
|
+
runMain(runWorkQueueCli(repoRootFromCwd(), process.argv.slice(2)));
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { findItem } from './roadmapStore';
|
|
2
|
+
|
|
3
|
+
export type ItemEvidenceVerdict = 'likely_done' | 'likely_open' | 'needs_review' | 'inconsistent';
|
|
4
|
+
|
|
5
|
+
export interface ItemEvidenceReport {
|
|
6
|
+
ok: true;
|
|
7
|
+
item_id: string;
|
|
8
|
+
verdict: ItemEvidenceVerdict;
|
|
9
|
+
status: string;
|
|
10
|
+
missing: string[];
|
|
11
|
+
signals: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function checkItemEvidence(repoRoot: string, itemId: string): ItemEvidenceReport {
|
|
15
|
+
const located = findItem(repoRoot, itemId);
|
|
16
|
+
const item = located.item;
|
|
17
|
+
const missing: string[] = [];
|
|
18
|
+
const signals: string[] = [];
|
|
19
|
+
|
|
20
|
+
if (item.verification?.status) signals.push(`verification:${item.verification.status}`);
|
|
21
|
+
if (item.doc_update) signals.push(`doc_update:${item.doc_update}`);
|
|
22
|
+
if (item.links?.some((link) => link.startsWith('commit:'))) signals.push('commit_link');
|
|
23
|
+
if (item.notes?.trim()) signals.push('notes');
|
|
24
|
+
if (item.done_when?.length) signals.push(`done_when:${item.done_when.length}`);
|
|
25
|
+
if (item.files_to_read?.length) signals.push(`files_to_read:${item.files_to_read.length}`);
|
|
26
|
+
|
|
27
|
+
if (item.status === 'done') {
|
|
28
|
+
if (!item.verification?.status) missing.push('verification');
|
|
29
|
+
if (!item.doc_update) missing.push('doc_update');
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
item_id: item.id,
|
|
33
|
+
verdict: missing.length > 0 ? 'inconsistent' : 'likely_done',
|
|
34
|
+
status: item.status,
|
|
35
|
+
missing,
|
|
36
|
+
signals,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hasDoneEvidence = item.verification?.status === 'verified' || Boolean(item.doc_update);
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
item_id: item.id,
|
|
44
|
+
verdict: hasDoneEvidence ? 'needs_review' : 'likely_open',
|
|
45
|
+
status: item.status,
|
|
46
|
+
missing,
|
|
47
|
+
signals,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Item, Priority, Status, VerificationStatus } from '../../types';
|
|
2
|
+
import type { LocatedItem } from './roadmapStore';
|
|
3
|
+
|
|
4
|
+
export interface ItemSummaryRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
status: Status;
|
|
8
|
+
scope: string;
|
|
9
|
+
layer?: string;
|
|
10
|
+
layers?: string[];
|
|
11
|
+
type?: Item['type'];
|
|
12
|
+
priority?: Priority;
|
|
13
|
+
verification?: VerificationStatus;
|
|
14
|
+
risk_count?: number;
|
|
15
|
+
done_when_count?: number;
|
|
16
|
+
latest_note?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ITEM_FIELD_NAMES = [
|
|
20
|
+
'id',
|
|
21
|
+
'title',
|
|
22
|
+
'status',
|
|
23
|
+
'scope',
|
|
24
|
+
'layer',
|
|
25
|
+
'layers',
|
|
26
|
+
'type',
|
|
27
|
+
'priority',
|
|
28
|
+
'verification',
|
|
29
|
+
'risk_count',
|
|
30
|
+
'done_when_count',
|
|
31
|
+
'latest_note',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type ItemFieldName = typeof ITEM_FIELD_NAMES[number];
|
|
35
|
+
|
|
36
|
+
const ITEM_FIELD_SET = new Set<string>(ITEM_FIELD_NAMES);
|
|
37
|
+
|
|
38
|
+
export function parseItemFields(value: string): ItemFieldName[] {
|
|
39
|
+
const fields = value.split(',').map((field) => field.trim()).filter(Boolean);
|
|
40
|
+
for (const field of fields) {
|
|
41
|
+
if (!ITEM_FIELD_SET.has(field)) throw new Error(`invalid field: ${field}`);
|
|
42
|
+
}
|
|
43
|
+
return fields as ItemFieldName[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function latestNote(notes: string | undefined): string | undefined {
|
|
47
|
+
if (!notes) return undefined;
|
|
48
|
+
const lines = notes.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
49
|
+
return lines.at(-1)?.replace(/^- /, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function summarizeItem(entry: LocatedItem): ItemSummaryRecord {
|
|
53
|
+
const item = entry.item;
|
|
54
|
+
return {
|
|
55
|
+
id: item.id,
|
|
56
|
+
title: item.title,
|
|
57
|
+
status: item.status,
|
|
58
|
+
scope: entry.scopeId,
|
|
59
|
+
...(item.layer ? { layer: item.layer } : {}),
|
|
60
|
+
...(item.layers ? { layers: item.layers } : {}),
|
|
61
|
+
...(item.type ? { type: item.type } : {}),
|
|
62
|
+
...(item.priority ? { priority: item.priority } : {}),
|
|
63
|
+
...(item.verification?.status ? { verification: item.verification.status } : {}),
|
|
64
|
+
...(item.risks ? { risk_count: item.risks.length } : {}),
|
|
65
|
+
...(item.done_when ? { done_when_count: item.done_when.length } : {}),
|
|
66
|
+
...(latestNote(item.notes) ? { latest_note: latestNote(item.notes) } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function projectItem(entry: LocatedItem, fields?: ItemFieldName[]): Partial<ItemSummaryRecord> {
|
|
71
|
+
const summary = summarizeItem(entry);
|
|
72
|
+
if (!fields || fields.length === 0) return summary;
|
|
73
|
+
const projected: Partial<ItemSummaryRecord> = {};
|
|
74
|
+
for (const field of fields) {
|
|
75
|
+
const value = summary[field];
|
|
76
|
+
if (value !== undefined) projected[field] = value as never;
|
|
77
|
+
}
|
|
78
|
+
return projected;
|
|
79
|
+
}
|
|
@@ -4,10 +4,12 @@ import path from 'node:path';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import {
|
|
6
6
|
addItemNote,
|
|
7
|
+
completeItem,
|
|
7
8
|
findItem,
|
|
8
9
|
linkItemCommit,
|
|
9
10
|
listItems,
|
|
10
11
|
listNextItems,
|
|
12
|
+
setItemDocUpdate,
|
|
11
13
|
setItemStatus,
|
|
12
14
|
setItemVerification,
|
|
13
15
|
} from './roadmapStore';
|
|
@@ -119,6 +121,33 @@ describe('roadmapStore', () => {
|
|
|
119
121
|
expect(result.item.links).toEqual(['commit:7fc09a5']);
|
|
120
122
|
});
|
|
121
123
|
|
|
124
|
+
it('sets doc_update to a date or skipped reason', () => {
|
|
125
|
+
const dated = setItemDocUpdate(dir, 'ed-001', '2026-06-28');
|
|
126
|
+
const skipped = setItemDocUpdate(dir, 'ed-001', 'skipped — No docs changed.');
|
|
127
|
+
|
|
128
|
+
expect(dated.item.doc_update).toBe('2026-06-28');
|
|
129
|
+
expect(skipped.item.doc_update).toBe('skipped — No docs changed.');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('completes an item with evidence, doc update, and commit link in one mutation', () => {
|
|
133
|
+
const result = completeItem(dir, 'ed-001', {
|
|
134
|
+
evidence: ['npm test -- --run'],
|
|
135
|
+
docUpdate: '2026-06-28',
|
|
136
|
+
commit: 'abc123',
|
|
137
|
+
now: () => new Date('2026-06-28T12:00:00.000Z'),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result.item.status).toBe('done');
|
|
141
|
+
expect(result.item.doc_update).toBe('2026-06-28');
|
|
142
|
+
expect(result.item.links).toEqual(['commit:abc123']);
|
|
143
|
+
expect(result.item.notes).toContain('- 2026-06-28: Completed with evidence: npm test -- --run');
|
|
144
|
+
expect(result.item.verification).toMatchObject({
|
|
145
|
+
status: 'verified',
|
|
146
|
+
evidence: ['npm test -- --run'],
|
|
147
|
+
updated_at: '2026-06-28T12:00:00.000Z',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
122
151
|
it('lists next work in status and priority order', () => {
|
|
123
152
|
const items = listNextItems(dir);
|
|
124
153
|
|
|
@@ -6,7 +6,7 @@ import { readState } from './tenboFs';
|
|
|
6
6
|
import { invalidate as invalidateCache } from './parseCache';
|
|
7
7
|
import { validate } from './validator';
|
|
8
8
|
import { comparePriority } from './priority';
|
|
9
|
-
import type { Item, Status, VerificationStatus, ValidateResult } from '../../types';
|
|
9
|
+
import type { Item, Priority, Status, VerificationStatus, ValidateResult } from '../../types';
|
|
10
10
|
|
|
11
11
|
export type RoadmapErrorCode = 'not_found' | 'conflict' | 'invalid_status' | 'invalid_args';
|
|
12
12
|
|
|
@@ -36,9 +36,12 @@ export interface WriteResult {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export interface ListItemFilters {
|
|
39
|
-
status?: Status;
|
|
39
|
+
status?: Status | Status[];
|
|
40
40
|
verification?: VerificationStatus;
|
|
41
41
|
goal?: string;
|
|
42
|
+
type?: Item['type'];
|
|
43
|
+
priority?: Priority;
|
|
44
|
+
layer?: string;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
interface RoadmapFileRef {
|
|
@@ -120,6 +123,19 @@ function validateVerificationStatus(status: string): VerificationStatus {
|
|
|
120
123
|
throw new RoadmapStoreError('invalid_status', `invalid verification status: ${status}`);
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
function normalizeNotes(notes: unknown): string {
|
|
127
|
+
if (!notes) return '';
|
|
128
|
+
if (typeof notes === 'string') return notes.trimEnd();
|
|
129
|
+
if (Array.isArray(notes)) {
|
|
130
|
+
return notes
|
|
131
|
+
.map((entry) => String(entry).trim())
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.map((entry) => entry.startsWith('- ') ? entry : `- ${entry}`)
|
|
134
|
+
.join('\n');
|
|
135
|
+
}
|
|
136
|
+
return String(notes).trimEnd();
|
|
137
|
+
}
|
|
138
|
+
|
|
123
139
|
function mutateItem(
|
|
124
140
|
repoRoot: string,
|
|
125
141
|
itemId: string,
|
|
@@ -178,7 +194,8 @@ export function addItemNote(
|
|
|
178
194
|
const now = opts.now ?? (() => new Date());
|
|
179
195
|
const date = now().toISOString().slice(0, 10);
|
|
180
196
|
return mutateItem(repoRoot, itemId, (item) => {
|
|
181
|
-
const
|
|
197
|
+
const existing = normalizeNotes(item.notes);
|
|
198
|
+
const prefix = existing.length > 0 ? `${existing}\n` : '';
|
|
182
199
|
return { notes: `${prefix}- ${date}: ${note}` };
|
|
183
200
|
}, opts);
|
|
184
201
|
}
|
|
@@ -213,12 +230,58 @@ export function linkItemCommit(repoRoot: string, itemId: string, commit: string,
|
|
|
213
230
|
}, opts);
|
|
214
231
|
}
|
|
215
232
|
|
|
233
|
+
export function setItemDocUpdate(repoRoot: string, itemId: string, docUpdate: string, opts?: MutationOptions): WriteResult {
|
|
234
|
+
return mutateItem(repoRoot, itemId, () => ({ doc_update: docUpdate }), opts);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function completeItem(
|
|
238
|
+
repoRoot: string,
|
|
239
|
+
itemId: string,
|
|
240
|
+
opts: MutationOptions & {
|
|
241
|
+
evidence: string[];
|
|
242
|
+
docUpdate?: string;
|
|
243
|
+
commit?: string;
|
|
244
|
+
verificationStatus?: VerificationStatus | string;
|
|
245
|
+
now?: () => Date;
|
|
246
|
+
},
|
|
247
|
+
): WriteResult {
|
|
248
|
+
const now = opts.now ?? (() => new Date());
|
|
249
|
+
const date = now().toISOString().slice(0, 10);
|
|
250
|
+
const updatedAt = now().toISOString();
|
|
251
|
+
const status = validateVerificationStatus(opts.verificationStatus ?? 'verified');
|
|
252
|
+
return mutateItem(repoRoot, itemId, (item) => {
|
|
253
|
+
const existingNotes = normalizeNotes(item.notes);
|
|
254
|
+
const evidenceText = opts.evidence.length ? opts.evidence.join('; ') : 'No evidence supplied';
|
|
255
|
+
const note = `- ${date}: Completed with evidence: ${evidenceText}`;
|
|
256
|
+
const links = item.links ?? [];
|
|
257
|
+
const commitLink = opts.commit ? `commit:${opts.commit}` : undefined;
|
|
258
|
+
return {
|
|
259
|
+
status: 'done',
|
|
260
|
+
notes: existingNotes ? `${existingNotes}\n${note}` : note,
|
|
261
|
+
verification: {
|
|
262
|
+
status,
|
|
263
|
+
updated_at: updatedAt,
|
|
264
|
+
...(opts.evidence.length ? { evidence: opts.evidence } : {}),
|
|
265
|
+
},
|
|
266
|
+
...(opts.docUpdate ? { doc_update: opts.docUpdate } : {}),
|
|
267
|
+
...(commitLink ? { links: links.includes(commitLink) ? links : [...links, commitLink] } : {}),
|
|
268
|
+
};
|
|
269
|
+
}, opts);
|
|
270
|
+
}
|
|
271
|
+
|
|
216
272
|
export function listItems(repoRoot: string, filters: ListItemFilters = {}): LocatedItem[] {
|
|
217
273
|
const out: LocatedItem[] = [];
|
|
274
|
+
const statuses = Array.isArray(filters.status) ? filters.status : filters.status ? [filters.status] : [];
|
|
218
275
|
for (const ref of readRoadmapRefs(repoRoot)) {
|
|
219
276
|
for (const item of ref.items) {
|
|
220
|
-
if (
|
|
277
|
+
if (statuses.length > 0 && !statuses.includes(item.status)) continue;
|
|
221
278
|
if (filters.verification && item.verification?.status !== filters.verification) continue;
|
|
279
|
+
if (filters.type && item.type !== filters.type) continue;
|
|
280
|
+
if (filters.priority && item.priority !== filters.priority) continue;
|
|
281
|
+
if (filters.layer) {
|
|
282
|
+
const layerRefs = [item.layer, ...(item.layers ?? []), ...(item.affects ?? [])].filter(Boolean);
|
|
283
|
+
if (!layerRefs.includes(filters.layer)) continue;
|
|
284
|
+
}
|
|
222
285
|
if (filters.goal) {
|
|
223
286
|
const goalRef = item.goal_ref;
|
|
224
287
|
const matchesGoal = Array.isArray(goalRef) ? goalRef.includes(filters.goal) : goalRef === filters.goal;
|