tenbo-dashboard 0.4.1 → 0.6.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.
@@ -39,6 +39,7 @@ const commands = {
39
39
  metrics: 'scripts/compute-metrics.ts',
40
40
  'init-check': 'scripts/init-check.ts',
41
41
  sync: 'scripts/sync.ts',
42
+ archive: 'scripts/archive.ts',
42
43
  };
43
44
 
44
45
  // Documented aliases for the bare-launch behavior. Users (and agents) reading
@@ -79,6 +80,8 @@ Usage:
79
80
  tenbo-dashboard init-check Strict completeness check for fresh init (errors on missing skeletons, file_count:0, etc)
80
81
  tenbo-dashboard next-id <prefix> Allocate next roadmap item ID
81
82
  tenbo-dashboard metrics --all Compute scope metrics
83
+ tenbo-dashboard archive Move old done/dropped items to roadmap-archive.yaml
84
+ [--scope <id>] [--days 30] [--max 20]
82
85
  tenbo-dashboard --version Print package version
83
86
  tenbo-dashboard help Show this help
84
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tenbo-dashboard",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,88 @@
1
+ /**
2
+ * archive.ts — CLI to move done/dropped roadmap items to roadmap-archive.yaml.
3
+ *
4
+ * Usage:
5
+ * npx tenbo-dashboard archive [--scope <id>] [--days 30] [--max 20]
6
+ *
7
+ * --days (default 30): only archive done/dropped items whose doc_update is older than N days.
8
+ * --max (default 20): only trigger if done+dropped count exceeds this threshold per scope.
9
+ * --scope: limit to one scope (default: all scopes).
10
+ */
11
+ import { findRepoRoot } from '../src/api/lib/repoRoot';
12
+ import { readState, archiveItems } from '../src/api/lib/tenboFs';
13
+ import type { Item } from '../src/types';
14
+ import path from 'node:path';
15
+
16
+ function parseArgs(argv: string[]): { scope?: string; days: number; max: number } {
17
+ let scope: string | undefined;
18
+ let days = 30;
19
+ let max = 20;
20
+ for (let i = 0; i < argv.length; i++) {
21
+ if (argv[i] === '--scope' && argv[i + 1]) {
22
+ scope = argv[++i];
23
+ } else if (argv[i] === '--days' && argv[i + 1]) {
24
+ days = parseInt(argv[++i], 10);
25
+ } else if (argv[i] === '--max' && argv[i + 1]) {
26
+ max = parseInt(argv[++i], 10);
27
+ }
28
+ }
29
+ return { scope, days, max };
30
+ }
31
+
32
+ function isOlderThanDays(docUpdate: string | undefined, days: number): boolean {
33
+ if (!docUpdate) return false;
34
+ const match = docUpdate.match(/^\d{4}-\d{2}-\d{2}$/);
35
+ if (!match) return false;
36
+ const itemDate = new Date(docUpdate);
37
+ const cutoff = new Date();
38
+ cutoff.setDate(cutoff.getDate() - days);
39
+ return itemDate <= cutoff;
40
+ }
41
+
42
+ function getArchivableIds(items: Item[], days: number, maxThreshold: number): string[] {
43
+ const doneDropped = items.filter(i => i.status === 'done' || i.status === 'dropped');
44
+ if (doneDropped.length <= maxThreshold) return [];
45
+ return doneDropped
46
+ .filter(i => isOlderThanDays(i.doc_update, days))
47
+ .map(i => i.id);
48
+ }
49
+
50
+ function isMain(): boolean {
51
+ try {
52
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
53
+ const here = path.resolve(new URL(import.meta.url).pathname);
54
+ return invoked === here;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ if (isMain()) {
61
+ const args = parseArgs(process.argv.slice(2));
62
+ const cwd = process.cwd();
63
+ const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
64
+
65
+ const state = readState(repoRoot);
66
+ const scopesToProcess = args.scope
67
+ ? state.scopes.filter(s => s.id === args.scope)
68
+ : state.scopes;
69
+
70
+ if (args.scope && scopesToProcess.length === 0) {
71
+ process.stderr.write(`Error: scope '${args.scope}' not found.\n`);
72
+ process.exit(1);
73
+ }
74
+
75
+ let totalArchived = 0;
76
+ for (const scope of scopesToProcess) {
77
+ const ids = getArchivableIds(scope.items, args.days, args.max);
78
+ if (ids.length > 0) {
79
+ archiveItems(repoRoot, scope.id, ids);
80
+ console.log(`Archived ${ids.length} items from scope '${scope.id}' to roadmap-archive.yaml`);
81
+ totalArchived += ids.length;
82
+ }
83
+ }
84
+
85
+ if (totalArchived === 0) {
86
+ console.log('No items eligible for archiving.');
87
+ }
88
+ }
@@ -0,0 +1,76 @@
1
+ import type { Item, Scope } from '../../../types';
2
+ import type { Finding } from './types';
3
+
4
+ const THIRTY_DAYS_MS = 30 * 24 * 3600 * 1000;
5
+
6
+ /**
7
+ * Surfaces `later` items that are >30 days old and reference at least one
8
+ * done/dropped item (via spawned_from, superseded_by, or related).
9
+ * These are likely stale — either they should be dropped, completed, or
10
+ * explicitly re-justified with an updated note.
11
+ */
12
+ export function analyzeAgingSuperseded(
13
+ scope: Scope,
14
+ allItems: Map<string, Item>,
15
+ now: number = Date.now(),
16
+ ): Finding[] {
17
+ const findings: Finding[] = [];
18
+
19
+ for (const item of scope.items) {
20
+ if (item.status !== 'later') continue;
21
+
22
+ const ageDays = itemAgeDays(item, now);
23
+ if (ageDays < 30) continue;
24
+
25
+ const refs = collectReferences(item);
26
+ if (refs.length === 0) continue;
27
+
28
+ const referencedDoneOrDropped = refs
29
+ .map(id => allItems.get(id))
30
+ .filter((ref): ref is Item => ref !== undefined && (ref.status === 'done' || ref.status === 'dropped'))
31
+ .map(ref => ({ id: ref.id, status: ref.status }));
32
+
33
+ if (referencedDoneOrDropped.length === 0) continue;
34
+
35
+ findings.push({
36
+ id: `${item.layer ?? scope.id}.aging-superseded.${item.id}`,
37
+ signal: 'aging-superseded',
38
+ severity: ageDays > 90 ? 'warning' : 'info',
39
+ confidence: 'medium',
40
+ layer: item.layer ?? scope.layers[0]?.id ?? scope.id,
41
+ target: item.id,
42
+ headline: `"${item.title}" is ${ageDays}d old in later, references ${referencedDoneOrDropped.length} done/dropped item(s)`,
43
+ suggestion: {
44
+ summary: 'Mark dropped, mark done, or update with a note explaining the delay',
45
+ rationale: `This item has been in "later" for ${ageDays} days and references items that have already shipped or been dropped. It may be stale.`,
46
+ action_kind: 'triage-item',
47
+ },
48
+ details: {
49
+ kind: 'aging-superseded',
50
+ item_id: item.id,
51
+ item_title: item.title,
52
+ age_days: ageDays,
53
+ referenced_items: referencedDoneOrDropped,
54
+ },
55
+ });
56
+ }
57
+
58
+ return findings;
59
+ }
60
+
61
+ function collectReferences(item: Item): string[] {
62
+ const refs: string[] = [];
63
+ if (item.spawned_from) refs.push(item.spawned_from);
64
+ if (item.superseded_by) refs.push(item.superseded_by);
65
+ if (item.related) refs.push(...item.related);
66
+ return refs;
67
+ }
68
+
69
+ function itemAgeDays(item: Item, now: number): number {
70
+ const docUpdate = item.doc_update;
71
+ if (docUpdate && /^\d{4}-\d{2}-\d{2}$/.test(docUpdate)) {
72
+ const ts = new Date(docUpdate).getTime();
73
+ return Math.floor((now - ts) / (24 * 3600 * 1000));
74
+ }
75
+ return THIRTY_DAYS_MS / (24 * 3600 * 1000) + 1; // assume old enough if no date
76
+ }
@@ -1,4 +1,4 @@
1
- import type { Scope } from '../../../types';
1
+ import type { Item, Scope } from '../../../types';
2
2
  import type { Finding, Signal } from './types';
3
3
  import type { HealthConfig } from './config';
4
4
  import { resolveLayerFiles } from './layerFiles';
@@ -12,10 +12,12 @@ import type { ImportGraph } from './importGraph';
12
12
  import { analyzeDeadCode } from './deadCode';
13
13
  import { analyzeCoupling } from './coupling';
14
14
  import { analyzeRedundancy } from './redundancy';
15
+ import { analyzeAgingSuperseded } from './agingSuperseded';
15
16
 
16
17
  interface AnalyzerArgs {
17
18
  repoRoot: string;
18
19
  scopeId: string;
20
+ scopePath?: string;
19
21
  layerId: string;
20
22
  files: string[];
21
23
  config: HealthConfig;
@@ -26,7 +28,8 @@ type AnalyzerFn = (args: AnalyzerArgs) => Finding[];
26
28
  const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
27
29
  'hotspot-files': ({ repoRoot, layerId, files, config }) => analyzeHotspotFiles(repoRoot, layerId, files, config),
28
30
  'aging-todos': ({ repoRoot, layerId, files, config }) => analyzeAgingTodos(repoRoot, layerId, files, config),
29
- 'doc-drift': ({ repoRoot, scopeId, layerId, files }) => analyzeDocDrift(repoRoot, scopeId, layerId, files),
31
+ 'aging-superseded': null, // scope-wide item analyzer, dispatched after the layer loop
32
+ 'doc-drift': ({ repoRoot, scopeId, layerId, files, scopePath }) => analyzeDocDrift(repoRoot, scopeId, layerId, files, scopePath),
30
33
  'test-coverage': ({ repoRoot, layerId, files }) => analyzeTestCoverage(repoRoot, layerId, files),
31
34
  'architecture-compliance': ({ layerId, files }) => analyzeArchCompliance(layerId, files),
32
35
  'dead-code': ({ repoRoot, layerId, files, graph }) => graph ? analyzeDeadCode(repoRoot, layerId, files, graph) : [],
@@ -34,7 +37,7 @@ const ANALYZERS: Record<Signal, AnalyzerFn | null> = {
34
37
  'redundancy': null, // Phase 4
35
38
  };
36
39
 
37
- export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig): Promise<Finding[]> {
40
+ export async function collectAll(repoRoot: string, scope: Scope, config: HealthConfig, allItems?: Map<string, Item>): Promise<Finding[]> {
38
41
  const filesByLayer = resolveLayerFiles(repoRoot, scope);
39
42
  const allTsFiles = Object.values(filesByLayer).flat().filter(f => /\.tsx?$/.test(f));
40
43
  let graph: ImportGraph | undefined;
@@ -51,7 +54,7 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
51
54
  if (!fn) continue;
52
55
  if (opted.has(signal)) continue;
53
56
  try {
54
- out.push(...fn({ repoRoot, scopeId: scope.id, layerId: layer.id, files, config, graph }));
57
+ out.push(...fn({ repoRoot, scopeId: scope.id, scopePath: scope.path, layerId: layer.id, files, config, graph }));
55
58
  } catch (e) {
56
59
  // Analyzer failures don't break the whole report. Log and continue.
57
60
  console.error(`[health] analyzer ${signal} failed for layer ${layer.id}:`, e);
@@ -74,5 +77,15 @@ export async function collectAll(repoRoot: string, scope: Scope, config: HealthC
74
77
  return [] as Finding[];
75
78
  });
76
79
  out.push(...redundancyFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('redundancy')));
80
+
81
+ // Aging-superseded is item-based (not file-based) — run once per scope
82
+ try {
83
+ const itemMap = allItems ?? new Map(scope.items.map(i => [i.id, i]));
84
+ const agingFindings = analyzeAgingSuperseded(scope, itemMap);
85
+ out.push(...agingFindings.filter(f => !(config.ignore.layer_signals[f.layer] ?? []).includes('aging-superseded')));
86
+ } catch (e) {
87
+ console.error('[health] aging-superseded failed:', e);
88
+ }
89
+
77
90
  return out;
78
91
  }
@@ -31,6 +31,24 @@ describe('analyzeDocDrift', () => {
31
31
  rmSync(root, { recursive: true, force: true });
32
32
  });
33
33
 
34
+ it('does NOT emit missing-ref when file exists under the scope path', () => {
35
+ const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
36
+ const scopePath = 'tenbo-dashboard';
37
+ mkdirSync(path.join(root, scopePath, 'src'), { recursive: true });
38
+ writeFileSync(path.join(root, scopePath, 'src/main.tsx'), 'export default 1;\n');
39
+ mkdirSync(path.join(root, '.tenbo/scopes/dashboard/layers/lyr'), { recursive: true });
40
+ writeFileSync(
41
+ path.join(root, '.tenbo/scopes/dashboard/layers/lyr/code-map.md'),
42
+ 'Entry point: `src/main.tsx`.\n',
43
+ );
44
+ const findings = analyzeDocDrift(root, 'dashboard', 'lyr', [], scopePath);
45
+ const broken = findings.find(f =>
46
+ f.details.kind === 'doc-drift' && f.details.drift_type === 'missing-ref'
47
+ );
48
+ expect(broken).toBeUndefined();
49
+ rmSync(root, { recursive: true, force: true });
50
+ });
51
+
34
52
  it('emits unreferenced-file info finding when a layer file is not mentioned in code-map.md', () => {
35
53
  const root = mkdtempSync(path.join(tmpdir(), 'drift-'));
36
54
  mkdirSync(path.join(root, 'apps/editor/src'), { recursive: true });
@@ -22,6 +22,7 @@ export function analyzeDocDrift(
22
22
  scopeId: string,
23
23
  layerId: string,
24
24
  layerFiles: string[],
25
+ scopePath?: string,
25
26
  ): Finding[] {
26
27
  const docPath = path.join(repoRoot, '.tenbo/scopes', scopeId, 'layers', layerId, 'code-map.md');
27
28
  if (!existsSync(docPath)) return [];
@@ -34,7 +35,9 @@ export function analyzeDocDrift(
34
35
  // 1. missing-ref: file in code-map.md doesn't exist on disk
35
36
  const broken: string[] = [];
36
37
  for (const ref of refs) {
37
- if (!existsSync(path.join(repoRoot, ref))) broken.push(ref);
38
+ const existsAtRoot = existsSync(path.join(repoRoot, ref));
39
+ const existsAtScope = scopePath ? existsSync(path.join(repoRoot, scopePath, ref)) : false;
40
+ if (!existsAtRoot && !existsAtScope) broken.push(ref);
38
41
  }
39
42
  if (broken.length > 0) {
40
43
  const severity = broken.length >= 3 ? 'critical' : 'warning';
@@ -8,16 +8,16 @@ describe('resolveLayerFiles', () => {
8
8
 
9
9
  it('returns repo-relative paths bucketed by layer', () => {
10
10
  if (!repoRoot) throw new Error('Could not find repo root');
11
- // Use a real fixture: this repo's editor scope.
11
+ // Use a real fixture: this repo's health directory which has known .ts files.
12
12
  const scope: Scope = {
13
- id: 'editor',
14
- path: 'apps/editor',
13
+ id: 'dashboard',
14
+ path: 'tenbo-dashboard/src/api/lib/health',
15
15
  description: '',
16
16
  layers: [{
17
17
  id: 'visual-canvas',
18
18
  name: 'Visual Canvas',
19
19
  description: '',
20
- files: ['src/domains/canvas/**'],
20
+ files: ['*.ts'],
21
21
  dependencies: { inbound: [], outbound: [], external: [] },
22
22
  }],
23
23
  items: [],
@@ -26,7 +26,7 @@ describe('resolveLayerFiles', () => {
26
26
  expect(result['visual-canvas']).toBeDefined();
27
27
  expect(result['visual-canvas'].length).toBeGreaterThan(0);
28
28
  // Paths are repo-relative
29
- expect(result['visual-canvas'][0]).toMatch(/^apps\/editor\/src\/domains\/canvas\//);
29
+ expect(result['visual-canvas'][0]).toMatch(/^tenbo-dashboard\/src\/api\/lib\/health\//);
30
30
  });
31
31
 
32
32
  it('returns empty array for layer with no matching files', () => {
@@ -9,6 +9,7 @@ export type Signal =
9
9
  | 'doc-drift'
10
10
  | 'test-coverage'
11
11
  | 'aging-todos'
12
+ | 'aging-superseded'
12
13
  | 'architecture-compliance'
13
14
  | 'redundancy';
14
15
 
@@ -20,7 +21,8 @@ export type ActionKind =
20
21
  | 'move-file'
21
22
  | 'update-doc'
22
23
  | 'add-test'
23
- | 'resolve-todo';
24
+ | 'resolve-todo'
25
+ | 'triage-item';
24
26
 
25
27
  export interface Suggestion {
26
28
  summary: string; // imperative one-liner
@@ -36,6 +38,7 @@ export type FindingDetails =
36
38
  | { kind: 'doc-drift'; drift_type: 'missing-ref' | 'unreferenced-file' | 'stale-section'; doc_path: string; doc_mtime_iso: string | null; code_mtime_iso: string | null; affected_files: string[] }
37
39
  | { kind: 'test-coverage'; suggested_test_path: string }
38
40
  | { kind: 'aging-todos'; line: number; age_days: number; commit_hash: string; author: string; text: string; context: string }
41
+ | { kind: 'aging-superseded'; item_id: string; item_title: string; age_days: number; referenced_items: { id: string; status: string }[] }
39
42
  | { kind: 'architecture-compliance'; expected_path_pattern: string; actual_path: string; rule: string }
40
43
  | { kind: 'redundancy'; copies: { path: string; lines: [number, number] }[]; similarity_pct: number };
41
44
 
@@ -66,6 +69,7 @@ export const SIGNAL_WEIGHTS_DEFAULT: Signal[] = [
66
69
  'doc-drift',
67
70
  'test-coverage',
68
71
  'aging-todos',
72
+ 'aging-superseded',
69
73
  ];
70
74
 
71
75
  export const CONFIDENCE_RANK: Record<Confidence, number> = {
@@ -1,9 +1,9 @@
1
1
  import { readFileSync, writeFileSync, readdirSync, existsSync, renameSync, statSync, type Stats } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { parse as parseSimple } from 'yaml';
3
+ import { parse as parseSimple, stringify as yamlStringify } from 'yaml';
4
4
  import { parseYaml, stringifyYaml, patchSeqItem, reorderSeqItems } from './yamlOrdered';
5
5
  import { getCached, invalidate as invalidateCache } from './parseCache';
6
- import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent } from '../../types';
6
+ import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent, DecisionRecord } from '../../types';
7
7
 
8
8
  function tenboDir(repoRoot: string) { return path.join(repoRoot, '.tenbo'); }
9
9
 
@@ -70,6 +70,13 @@ function readRoadmap(repoRoot: string, scopeId: string): Item[] {
70
70
  return data?.items ?? [];
71
71
  }
72
72
 
73
+ export function readRoadmapArchive(repoRoot: string, scopeId: string): Item[] {
74
+ const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
75
+ if (!existsSync(file)) return [];
76
+ const data = getCached(file, 'roadmap-archive-yaml', (text) => parseSimple(text) as any);
77
+ return data?.items ?? [];
78
+ }
79
+
73
80
  function readNarratives(repoRoot: string, scopeId: string): Record<string, string> {
74
81
  const dir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers');
75
82
  if (!existsSync(dir)) return {};
@@ -186,6 +193,43 @@ export function readSpecFiles(repoRoot: string): Set<string> | undefined {
186
193
  return out;
187
194
  }
188
195
 
196
+ /**
197
+ * Read project-level decision records from `.tenbo/decisions/*.md`.
198
+ * Returns undefined if the directory does not exist (graceful no-op).
199
+ * Frontmatter is parsed leniently — malformed entries are still returned
200
+ * with whatever fields could be parsed; the validator surfaces issues.
201
+ */
202
+ export function readDecisions(repoRoot: string): Record<string, DecisionRecord> | undefined {
203
+ const dir = path.join(tenboDir(repoRoot), 'decisions');
204
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return undefined;
205
+ const out: Record<string, DecisionRecord> = {};
206
+ for (const entry of readdirSync(dir)) {
207
+ if (!entry.endsWith('.md')) continue;
208
+ const full = path.join(dir, entry);
209
+ if (!statSync(full).isFile()) continue;
210
+ const text = readFileSync(full, 'utf8');
211
+ const slug = entry.replace(/\.md$/, '');
212
+ const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
213
+ let frontmatter: Record<string, unknown> = {};
214
+ let body = text;
215
+ if (fmMatch) {
216
+ try {
217
+ frontmatter = (parseSimple(fmMatch[1]) as Record<string, unknown>) ?? {};
218
+ } catch {
219
+ frontmatter = {};
220
+ }
221
+ body = fmMatch[2] ?? '';
222
+ }
223
+ out[slug] = {
224
+ path: `.tenbo/decisions/${entry}`,
225
+ slug,
226
+ frontmatter: frontmatter as DecisionRecord['frontmatter'],
227
+ body,
228
+ };
229
+ }
230
+ return out;
231
+ }
232
+
189
233
  export function readState(repoRoot: string): TenboState {
190
234
  const ws = readWorkspace(repoRoot);
191
235
  const scopes: Scope[] = ws.scopeRefs.map(ref => ({
@@ -194,6 +238,7 @@ export function readState(repoRoot: string): TenboState {
194
238
  description: ref.description,
195
239
  layers: readArchitecture(repoRoot, ref.id),
196
240
  items: readRoadmap(repoRoot, ref.id),
241
+ archivedItems: readRoadmapArchive(repoRoot, ref.id),
197
242
  }));
198
243
  const narratives = scopes.reduce<Record<string, string>>((acc, s) => {
199
244
  return Object.assign(acc, readNarratives(repoRoot, s.id));
@@ -214,6 +259,8 @@ export function readState(repoRoot: string): TenboState {
214
259
  if (Object.keys(metrics).length > 0) state.metrics = metrics;
215
260
  const specFiles = readSpecFiles(repoRoot);
216
261
  if (specFiles) state.specFiles = specFiles;
262
+ const decisions = readDecisions(repoRoot);
263
+ if (decisions) state.decisions = decisions;
217
264
  return state;
218
265
  }
219
266
 
@@ -242,3 +289,39 @@ export function reorderItems(repoRoot: string, scopeId: string, idsInOrder: stri
242
289
  reorderSeqItems(doc, 'items', idsInOrder);
243
290
  atomicWrite(file, stringifyYaml(doc));
244
291
  }
292
+
293
+ /**
294
+ * Move items from roadmap.yaml to roadmap-archive.yaml for the given scope.
295
+ * Creates the archive file if it doesn't exist. Uses atomic writes for both files.
296
+ */
297
+ export function archiveItems(repoRoot: string, scopeId: string, itemIds: string[]): void {
298
+ if (itemIds.length === 0) return;
299
+
300
+ const roadmapFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
301
+ const archiveFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
302
+
303
+ // Read roadmap and find matching items
304
+ const roadmapText = readFileSync(roadmapFile, 'utf8');
305
+ const roadmapData = parseSimple(roadmapText) as any;
306
+ const allItems: Item[] = roadmapData?.items ?? [];
307
+
308
+ const idsToArchive = new Set(itemIds);
309
+ const itemsToArchive = allItems.filter(i => idsToArchive.has(i.id));
310
+ const remainingItems = allItems.filter(i => !idsToArchive.has(i.id));
311
+
312
+ if (itemsToArchive.length === 0) return;
313
+
314
+ // Read existing archive
315
+ let archiveItems: Item[] = [];
316
+ if (existsSync(archiveFile)) {
317
+ const archiveData = parseSimple(readFileSync(archiveFile, 'utf8')) as any;
318
+ archiveItems = archiveData?.items ?? [];
319
+ }
320
+
321
+ // Append archived items
322
+ archiveItems.push(...itemsToArchive);
323
+
324
+ // Write both files atomically
325
+ atomicWrite(roadmapFile, yamlStringify({ items: remainingItems }, { lineWidth: 0, blockQuote: 'literal' }));
326
+ atomicWrite(archiveFile, yamlStringify({ items: archiveItems }, { lineWidth: 0, blockQuote: 'literal' }));
327
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validate } from './validator';
3
+ import type { TenboState, Item } from '../../types';
4
+
5
+ function stateWithItems(items: Item[]): TenboState {
6
+ return {
7
+ scopes: [{
8
+ id: 'e',
9
+ path: '.',
10
+ description: 'editor scope description here for tests',
11
+ layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
12
+ items,
13
+ }],
14
+ crossCutting: [],
15
+ narratives: { 'e/l': '# L' },
16
+ } as any;
17
+ }
18
+
19
+ const baseItem = (over: Partial<Item> = {}): Item => ({
20
+ id: 'ed-001',
21
+ title: 't',
22
+ layer: 'l',
23
+ status: 'now',
24
+ description: 'a roadmap item used for goal_ref tests now',
25
+ done_when: ['it works'],
26
+ ...over,
27
+ });
28
+
29
+ const warningsFor = (state: TenboState, id: string) =>
30
+ validate(state).warnings.filter((w) => w.itemId === id).map((w) => w.message);
31
+
32
+ describe('validator — goal_ref shape (sk-025)', () => {
33
+ it('warns when goal_ref is missing on an active item', () => {
34
+ const state = stateWithItems([baseItem()]);
35
+ const msgs = warningsFor(state, 'ed-001');
36
+ expect(msgs.some((m) => m.includes('has no goal_ref'))).toBe(true);
37
+ });
38
+
39
+ it('does not warn when goal_ref is missing on a done or dropped item', () => {
40
+ for (const status of ['done', 'dropped'] as const) {
41
+ const state = stateWithItems([baseItem({ status })]);
42
+ const msgs = warningsFor(state, 'ed-001');
43
+ expect(msgs.some((m) => m.includes('has no goal_ref'))).toBe(false);
44
+ }
45
+ });
46
+
47
+ it('does not warn when goal_ref is a non-empty string array', () => {
48
+ const state = stateWithItems([baseItem({ goal_ref: ['g1', 'g3'] })]);
49
+ const msgs = warningsFor(state, 'ed-001');
50
+ expect(msgs.some((m) => m.includes('goal_ref'))).toBe(false);
51
+ });
52
+
53
+ it('does not warn when goal_ref is the literal "exploratory"', () => {
54
+ const state = stateWithItems([baseItem({ goal_ref: 'exploratory' })]);
55
+ const msgs = warningsFor(state, 'ed-001');
56
+ expect(msgs.some((m) => m.includes('goal_ref'))).toBe(false);
57
+ });
58
+
59
+ it('warns when goal_ref is an empty array', () => {
60
+ const state = stateWithItems([baseItem({ goal_ref: [] })]);
61
+ const msgs = warningsFor(state, 'ed-001');
62
+ expect(msgs.some((m) => m.includes('empty array'))).toBe(true);
63
+ });
64
+
65
+ it('warns when goal_ref is a malformed value (string other than "exploratory")', () => {
66
+ const state = stateWithItems([baseItem({ goal_ref: 'g1' as any })]);
67
+ const msgs = warningsFor(state, 'ed-001');
68
+ expect(msgs.some((m) => m.includes('must be a string array or the literal "exploratory"'))).toBe(true);
69
+ });
70
+
71
+ it('warns when goal_ref array contains a non-string entry', () => {
72
+ const state = stateWithItems([baseItem({ goal_ref: [1 as any, 'g2'] as any })]);
73
+ const msgs = warningsFor(state, 'ed-001');
74
+ expect(msgs.some((m) => m.includes('must be a string array'))).toBe(true);
75
+ });
76
+ });
@@ -29,7 +29,13 @@ const baseItem = (over: Partial<Item> = {}): Item => ({
29
29
  const errMsgsFor = (state: TenboState, id: string) =>
30
30
  validate(state).errors.filter((e) => e.itemId === id).map((e) => e.message);
31
31
  const warnMsgsFor = (state: TenboState, id: string) =>
32
- validate(state).warnings.filter((e) => e.itemId === id).map((e) => e.message);
32
+ validate(state).warnings
33
+ .filter((e) => e.itemId === id)
34
+ .map((e) => e.message)
35
+ // Exclude goal_ref warnings — sk-025 added a missing-goal_ref warning that
36
+ // fires on every active item without one. Tests in this file predate
37
+ // goal_ref and assert "no warnings" for unrelated relationship cases.
38
+ .filter((m) => !m.includes('goal_ref'));
33
39
 
34
40
  describe('validator — relationships', () => {
35
41
  it('accepts well-formed spawned_from + related with no errors or warnings', () => {
@@ -28,7 +28,11 @@ const baseItem = (over: Partial<Item> = {}): Item => ({
28
28
  });
29
29
 
30
30
  const warnMsgsFor = (state: TenboState, id: string) =>
31
- validate(state).warnings.filter((w) => w.itemId === id).map((w) => w.message);
31
+ validate(state).warnings
32
+ .filter((w) => w.itemId === id)
33
+ .map((w) => w.message)
34
+ // Exclude goal_ref warnings (sk-025) — orthogonal to spec-link tests.
35
+ .filter((m) => !m.includes('goal_ref'));
32
36
 
33
37
  describe('validator — spec links (Phase 6)', () => {
34
38
  it('warns when a .tenbo/specs/ link points to a missing file', () => {