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.
@@ -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>] [--json]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tenbo-dashboard",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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: 1,
19
+ exitCode,
20
20
  };
21
21
  }
22
- return { stdout: '', stderr: `${message}\n`, exitCode: 1 };
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
+ }
@@ -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(1);
71
+ expect(result.exitCode).toBe(2);
72
72
  expect(JSON.parse(result.stderr)).toMatchObject({
73
73
  ok: false,
74
74
  error: 'invalid_args',
@@ -1,15 +1,6 @@
1
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
- }
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 fail('invalid_args', 'Usage: tenbo context feature --query "<request>" [--json]', json);
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 fail('invalid_args', 'Usage: tenbo context feature --query "<request>" [--json]', json);
21
+ return misuse('Usage: tenbo context feature --query "<request>" [--json]', json);
31
22
  } catch (err) {
32
23
  return handleCliError(err, json);
33
24
  }
@@ -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 { 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
- }
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.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);
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 fail('invalid_args', 'Usage: tenbo item set-status <item-id> <status>', json);
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 fail('invalid_args', 'Usage: tenbo item add-note <item-id> <note>', json);
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 fail('invalid_args', 'Usage: tenbo item verify <item-id> --status <status> [--evidence <text>] [--note <text>]', json);
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 fail('invalid_args', 'Usage: tenbo item link-commit <item-id> <sha>', json);
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 fail('invalid_args', `unknown item command: ${command}`, json);
140
+ return misuse(`unknown item command: ${command}`, json);
69
141
  } catch (err) {
70
142
  return handleCliError(err, json);
71
143
  }
@@ -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 { fail, handleCliError, isMain, repoRootFromCwd, runMain, serialize, type CliResult } from './cliResult';
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
- function hasFlag(args: string[], flag: string): boolean {
5
- return args.includes(flag);
6
- }
6
+ const STATUSES = new Set(['now', 'next', 'later', 'done', 'dropped']);
7
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];
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 filters: ListItemFilters = {
17
- status: readOption(args, '--status') as ListItemFilters['status'],
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
  }
@@ -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.includes('--json');
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 prefix = item.notes && item.notes.trim().length > 0 ? `${item.notes.trimEnd()}\n` : '';
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 (filters.status && item.status !== filters.status) continue;
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;