tenbo-dashboard 0.4.1 → 0.5.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.5.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,6 +1,6 @@
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
6
  import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent } from '../../types';
@@ -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 {};
@@ -194,6 +201,7 @@ export function readState(repoRoot: string): TenboState {
194
201
  description: ref.description,
195
202
  layers: readArchitecture(repoRoot, ref.id),
196
203
  items: readRoadmap(repoRoot, ref.id),
204
+ archivedItems: readRoadmapArchive(repoRoot, ref.id),
197
205
  }));
198
206
  const narratives = scopes.reduce<Record<string, string>>((acc, s) => {
199
207
  return Object.assign(acc, readNarratives(repoRoot, s.id));
@@ -242,3 +250,39 @@ export function reorderItems(repoRoot: string, scopeId: string, idsInOrder: stri
242
250
  reorderSeqItems(doc, 'items', idsInOrder);
243
251
  atomicWrite(file, stringifyYaml(doc));
244
252
  }
253
+
254
+ /**
255
+ * Move items from roadmap.yaml to roadmap-archive.yaml for the given scope.
256
+ * Creates the archive file if it doesn't exist. Uses atomic writes for both files.
257
+ */
258
+ export function archiveItems(repoRoot: string, scopeId: string, itemIds: string[]): void {
259
+ if (itemIds.length === 0) return;
260
+
261
+ const roadmapFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
262
+ const archiveFile = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap-archive.yaml');
263
+
264
+ // Read roadmap and find matching items
265
+ const roadmapText = readFileSync(roadmapFile, 'utf8');
266
+ const roadmapData = parseSimple(roadmapText) as any;
267
+ const allItems: Item[] = roadmapData?.items ?? [];
268
+
269
+ const idsToArchive = new Set(itemIds);
270
+ const itemsToArchive = allItems.filter(i => idsToArchive.has(i.id));
271
+ const remainingItems = allItems.filter(i => !idsToArchive.has(i.id));
272
+
273
+ if (itemsToArchive.length === 0) return;
274
+
275
+ // Read existing archive
276
+ let archiveItems: Item[] = [];
277
+ if (existsSync(archiveFile)) {
278
+ const archiveData = parseSimple(readFileSync(archiveFile, 'utf8')) as any;
279
+ archiveItems = archiveData?.items ?? [];
280
+ }
281
+
282
+ // Append archived items
283
+ archiveItems.push(...itemsToArchive);
284
+
285
+ // Write both files atomically
286
+ atomicWrite(roadmapFile, yamlStringify({ items: remainingItems }, { lineWidth: 0, blockQuote: 'literal' }));
287
+ atomicWrite(archiveFile, yamlStringify({ items: archiveItems }, { lineWidth: 0, blockQuote: 'literal' }));
288
+ }
@@ -22,12 +22,38 @@ export function validate(state: TenboState): ValidateResult {
22
22
  // Collect every known roadmap-item id across scopes + cross-cutting roadmap.
23
23
  // Used by the relationship validator (`spawned_from`, `related`) to flag
24
24
  // references that don't resolve to anything in the workspace.
25
+ // Also includes archived items so references to them don't produce warnings.
25
26
  const allItemIds = new Set<string>();
26
27
  for (const scope of state.scopes) {
27
28
  for (const item of scope.items) allItemIds.add(item.id);
29
+ for (const item of scope.archivedItems ?? []) allItemIds.add(item.id);
28
30
  }
29
31
  for (const item of state.crossCuttingRoadmap ?? []) allItemIds.add(item.id);
30
32
 
33
+ const validateSupersededBy = (item: Item, scopeId?: string) => {
34
+ if (item.superseded_by === undefined) return;
35
+ const sb = item.superseded_by;
36
+ if (typeof sb !== 'string' || !ITEM_ID_RE.test(sb)) {
37
+ errors.push({ level: 'error', message: `item "${item.id}" superseded_by "${sb}" has invalid id format`, scope: scopeId, itemId: item.id });
38
+ return;
39
+ }
40
+ if (sb === item.id) {
41
+ errors.push({ level: 'error', message: `item "${item.id}" superseded_by cannot reference itself`, scope: scopeId, itemId: item.id });
42
+ return;
43
+ }
44
+ if (!allItemIds.has(sb)) {
45
+ warnings.push({ level: 'warning', message: `${item.id} superseded_by references unknown id '${sb}'`, scope: scopeId, itemId: item.id });
46
+ }
47
+ if (item.status !== 'dropped') {
48
+ warnings.push({ level: 'warning', message: `${item.id} has superseded_by but status is "${item.status}" (expected "dropped")`, scope: scopeId, itemId: item.id });
49
+ }
50
+ // Cycle check: if the superseder also has superseded_by pointing back
51
+ const superseder = [...state.scopes.flatMap(s => s.items), ...(state.crossCuttingRoadmap ?? [])].find(i => i.id === sb);
52
+ if (superseder?.superseded_by === item.id) {
53
+ errors.push({ level: 'error', message: `cycle: "${item.id}" superseded_by "${sb}" which is superseded_by "${item.id}"`, scope: scopeId, itemId: item.id });
54
+ }
55
+ };
56
+
31
57
  const validateRelationships = (item: Item, scopeId?: string) => {
32
58
  if (item.spawned_from !== undefined) {
33
59
  const sf = item.spawned_from;
@@ -213,6 +239,9 @@ export function validate(state: TenboState): ValidateResult {
213
239
  // Relationship validation (Phase 5 of x-001).
214
240
  validateRelationships(item, scope.id);
215
241
 
242
+ // Superseded-by validation (sk-015).
243
+ validateSupersededBy(item, scope.id);
244
+
216
245
  // Spec-link lifecycle validation (Phase 6 of x-001).
217
246
  validateSpecLinks(item, scope.id);
218
247
 
@@ -304,6 +333,7 @@ export function validate(state: TenboState): ValidateResult {
304
333
  }
305
334
  }
306
335
  validateRelationships(item);
336
+ validateSupersededBy(item);
307
337
  validateSpecLinks(item);
308
338
  }
309
339
 
package/src/api/plugin.ts CHANGED
@@ -8,6 +8,7 @@ import { openRoute } from './routes/open';
8
8
  import { watchRoute } from './routes/watch';
9
9
  import { layerDocsRoute } from './routes/layerDocs';
10
10
  import { layerContentRoute } from './routes/layerContent';
11
+ import { archiveRoute } from './routes/archive';
11
12
 
12
13
  export function tenboApiPlugin(): Plugin {
13
14
  return {
@@ -28,6 +29,7 @@ export function tenboApiPlugin(): Plugin {
28
29
  server.httpServer?.on('close', () => { void watch.close(); });
29
30
  server.middlewares.use(layerDocsRoute(repoRoot));
30
31
  server.middlewares.use(layerContentRoute(repoRoot));
32
+ server.middlewares.use(archiveRoute(repoRoot));
31
33
  },
32
34
  };
33
35
  }
@@ -0,0 +1,60 @@
1
+ import type { Connect } from 'vite';
2
+ import { readState, archiveItems } from '../lib/tenboFs';
3
+ import { readBody, json, withErrorHandling } from '../lib/http';
4
+ import type { Item } from '../../types';
5
+
6
+ interface ArchiveRequest {
7
+ scopeId?: string;
8
+ days?: number;
9
+ maxThreshold?: number;
10
+ }
11
+
12
+ interface ArchiveResultEntry {
13
+ archived: string[];
14
+ scopeId: string;
15
+ }
16
+
17
+ function isOlderThanDays(docUpdate: string | undefined, days: number): boolean {
18
+ if (!docUpdate) return false;
19
+ const match = docUpdate.match(/^\d{4}-\d{2}-\d{2}$/);
20
+ if (!match) return false;
21
+ const itemDate = new Date(docUpdate);
22
+ const cutoff = new Date();
23
+ cutoff.setDate(cutoff.getDate() - days);
24
+ return itemDate <= cutoff;
25
+ }
26
+
27
+ function getArchivableIds(items: Item[], days: number, maxThreshold: number): string[] {
28
+ const doneDropped = items.filter(i => i.status === 'done' || i.status === 'dropped');
29
+ if (doneDropped.length <= maxThreshold) return [];
30
+ return doneDropped
31
+ .filter(i => isOlderThanDays(i.doc_update, days))
32
+ .map(i => i.id);
33
+ }
34
+
35
+ export function archiveRoute(repoRoot: string): Connect.NextHandleFunction {
36
+ return withErrorHandling(async (req, res, next) => {
37
+ if (req.url !== '/api/archive' || req.method !== 'POST') return next();
38
+
39
+ const body = await readBody<ArchiveRequest>(req);
40
+ const days = body.days ?? 30;
41
+ const maxThreshold = body.maxThreshold ?? 20;
42
+
43
+ const state = readState(repoRoot);
44
+ const results: ArchiveResultEntry[] = [];
45
+
46
+ const scopesToProcess = body.scopeId
47
+ ? state.scopes.filter(s => s.id === body.scopeId)
48
+ : state.scopes;
49
+
50
+ for (const scope of scopesToProcess) {
51
+ const ids = getArchivableIds(scope.items, days, maxThreshold);
52
+ if (ids.length > 0) {
53
+ archiveItems(repoRoot, scope.id, ids);
54
+ results.push({ archived: ids, scopeId: scope.id });
55
+ }
56
+ }
57
+
58
+ json(res, results);
59
+ });
60
+ }
@@ -2,9 +2,11 @@ import { describe, it, expect } from 'vitest';
2
2
  import { parseHash, formatRoute } from './routes.js';
3
3
 
4
4
  describe('parseHash', () => {
5
- it('defaults to docs project overview on empty hash', () => {
6
- expect(parseHash('')).toEqual({ kind: 'docs-project', tab: 'overview' });
7
- expect(parseHash('#/')).toEqual({ kind: 'docs-project', tab: 'overview' });
5
+ it('defaults to roadmap on empty hash', () => {
6
+ expect(parseHash('')).toEqual({ kind: 'roadmap' });
7
+ expect(parseHash('#/')).toEqual({ kind: 'roadmap' });
8
+ });
9
+ it('defaults to docs project overview on #/docs', () => {
8
10
  expect(parseHash('#/docs')).toEqual({ kind: 'docs-project', tab: 'overview' });
9
11
  });
10
12
  it('parses docs project tab', () => {
package/src/types.ts CHANGED
@@ -64,6 +64,12 @@ export interface Item {
64
64
  * the new item should set `spawned_from` to the dispatching item's id.
65
65
  */
66
66
  spawned_from?: string;
67
+ /**
68
+ * The id of the item that replaced/superseded this one. When set, the item's
69
+ * status should be `dropped`. The validator enforces this constraint and checks
70
+ * for cycles and dangling references.
71
+ */
72
+ superseded_by?: string;
67
73
  /**
68
74
  * Peer relationships. Any number of ids in the same format. Order is not significant.
69
75
  * Not parent–child — for that use `spawned_from`.
@@ -102,6 +108,7 @@ export interface Scope {
102
108
  description: string;
103
109
  layers: Layer[];
104
110
  items: Item[];
111
+ archivedItems?: Item[];
105
112
  }
106
113
 
107
114
  export interface CrossCutting {
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /**
4
+ * Set of item IDs that came from roadmap-archive.yaml.
5
+ * Provided by RoadmapPage so any descendant (ItemCard, TaskList row)
6
+ * can style archived items without prop drilling.
7
+ */
8
+ export const ArchivedIdsContext = createContext<ReadonlySet<string>>(new Set());
9
+
10
+ export function useArchivedIds(): ReadonlySet<string> {
11
+ return useContext(ArchivedIdsContext);
12
+ }
@@ -20,6 +20,15 @@
20
20
  .cardDone:hover {
21
21
  opacity: 1;
22
22
  }
23
+
24
+ /* Archived items are even more subtle — dashed border signals "read-only historical". */
25
+ .cardArchived {
26
+ opacity: 0.45;
27
+ border: 1px dashed var(--console-mist);
28
+ }
29
+ .cardArchived:hover {
30
+ opacity: 0.8;
31
+ }
23
32
  .card:focus-visible {
24
33
  outline: 2px solid var(--signal-cyan);
25
34
  outline-offset: 2px;
@@ -68,6 +77,15 @@
68
77
  white-space: nowrap;
69
78
  }
70
79
 
80
+ .archivedChip {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ color: var(--console-smoke);
84
+ padding: 1px 4px;
85
+ border-radius: var(--r-sm);
86
+ background: var(--console-soot);
87
+ }
88
+
71
89
  .titleInput {
72
90
  width: 100%;
73
91
  font: inherit;
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react';
2
2
  import { useSortable } from '@dnd-kit/sortable';
3
3
  import { CSS } from '@dnd-kit/utilities';
4
- import { CornerLeftUp, ArrowLeftRight } from 'lucide-react';
4
+ import { Archive, CornerLeftUp, ArrowLeftRight } from 'lucide-react';
5
5
  import type { Item } from '../types';
6
6
  import { effectiveStatus, phaseProgress } from '../api/lib/phases';
7
+ import { useArchivedIds } from './ArchivedContext';
7
8
  import styles from './ItemCard.module.css';
8
9
 
9
10
  interface Props {
@@ -18,21 +19,21 @@ export function ItemCard({ item, onClick, onTitleEdit, onDescEdit }: Props) {
18
19
  const dragStyle = {
19
20
  transform: CSS.Translate.toString(transform),
20
21
  transition,
21
- opacity: isDragging ? 0.4 : 1,
22
+ ...(isDragging ? { opacity: 0.4 } : {}),
22
23
  };
23
24
  const [editing, setEditing] = useState<null | 'title' | 'desc'>(null);
24
25
  const phases = item.phases ?? [];
25
26
  const progress = phases.length > 0 ? phaseProgress(phases) : null;
26
27
 
27
- // Done cards are dimmed in the kanban so the live columns (now/next/later)
28
- // own the user's visual attention. Hover restores opacity. The item modal
29
- // (rendered separately) is not affected — only the kanban card. (td-008)
30
- const isDone = effectiveStatus(item) === 'done';
28
+ const archivedIds = useArchivedIds();
29
+ const isArchived = archivedIds.has(item.id);
30
+ const status = effectiveStatus(item);
31
+ const isDimmed = status === 'done' || status === 'dropped';
31
32
 
32
33
  return (
33
34
  <div
34
35
  ref={setNodeRef}
35
- className={`${styles.card}${isDone ? ` ${styles.cardDone}` : ''}`}
36
+ className={`${styles.card}${isDimmed ? ` ${styles.cardDone}` : ''}${isArchived ? ` ${styles.cardArchived}` : ''}`}
36
37
  style={dragStyle}
37
38
  {...attributes}
38
39
  {...listeners}
@@ -46,6 +47,11 @@ export function ItemCard({ item, onClick, onTitleEdit, onDescEdit }: Props) {
46
47
  <div className={styles.id}>
47
48
  {item.priority && <span className={`${styles.priority} ${styles[item.priority]}`}>{item.priority.toUpperCase()}</span>}
48
49
  {item.id}
50
+ {isArchived && (
51
+ <span className={styles.archivedChip} title="Archived item" aria-label="Archived">
52
+ <Archive size={10} strokeWidth={1.75} />
53
+ </span>
54
+ )}
49
55
  {item.spawned_from && (
50
56
  <span
51
57
  className={styles.relChip}
@@ -19,6 +19,7 @@ const SIGNAL_LABELS: Record<Signal, string> = {
19
19
  'doc-drift': 'Doc drift',
20
20
  'test-coverage': 'Test coverage',
21
21
  'aging-todos': 'Aging TODOs',
22
+ 'aging-superseded': 'Stale later items',
22
23
  'architecture-compliance': 'Architecture compliance',
23
24
  'redundancy': 'Redundancy',
24
25
  };
@@ -91,3 +91,25 @@
91
91
  .viewBtn:hover:not(.viewBtnActive) {
92
92
  color: var(--console-snow);
93
93
  }
94
+
95
+ /* Archive toggle — pill style, matches viewBtn feel */
96
+ .archiveBtn {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ gap: 5px;
100
+ padding: 4px 10px;
101
+ border-radius: var(--r-md);
102
+ font-size: 12px;
103
+ font-weight: 500;
104
+ color: var(--console-fog);
105
+ background: var(--console-iron);
106
+ transition: background var(--dur-state) var(--ease), color var(--dur-state) var(--ease);
107
+ white-space: nowrap;
108
+ }
109
+ .archiveBtn:hover {
110
+ color: var(--console-snow);
111
+ }
112
+ .archiveBtnActive {
113
+ background: var(--console-mist);
114
+ color: var(--signal-cyan);
115
+ }
@@ -1,5 +1,5 @@
1
1
  import { useMemo, useState } from 'react';
2
- import { Columns2, List } from 'lucide-react';
2
+ import { Archive, Columns2, List } from 'lucide-react';
3
3
  import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
4
4
  import { LayerKanban } from '../LayerKanban';
5
5
  import { TaskList, type FlatItem } from '../TaskList';
@@ -7,6 +7,7 @@ import { effectiveStatus } from '../../api/lib/phases';
7
7
  import { tenboApi } from '../../api/client';
8
8
  import type { Route } from '../../router/routes';
9
9
  import type { Item, Layer, Status, TenboState } from '../../types';
10
+ import { ArchivedIdsContext } from '../ArchivedContext';
10
11
  import styles from './RoadmapPage.module.css';
11
12
 
12
13
  const GENERAL_SCOPE = 'general';
@@ -42,12 +43,25 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
42
43
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
43
44
  const [view, setView] = useState<'kanban' | 'list'>('kanban');
44
45
  const [search, setSearch] = useState('');
46
+ const [showArchived, setShowArchived] = useState(false);
47
+
48
+ /** IDs of archived items so we can tag them visually. */
49
+ const archivedIds = useMemo(() => {
50
+ const s = new Set<string>();
51
+ for (const scope of state.scopes) for (const i of scope.archivedItems ?? []) s.add(i.id);
52
+ return s;
53
+ }, [state]);
54
+
55
+ const totalArchived = archivedIds.size;
45
56
 
46
57
  const itemBySource = useMemo(() => {
47
58
  const m = new Map<string, { scopeId: string; item: Item }>();
48
- for (const s of state.scopes) for (const i of s.items) m.set(i.id, { scopeId: s.id, item: i });
59
+ for (const s of state.scopes) {
60
+ for (const i of s.items) m.set(i.id, { scopeId: s.id, item: i });
61
+ if (showArchived) for (const i of s.archivedItems ?? []) m.set(i.id, { scopeId: s.id, item: i });
62
+ }
49
63
  return m;
50
- }, [state]);
64
+ }, [state, showArchived]);
51
65
 
52
66
  const crossCuttingItems = state.crossCuttingRoadmap ?? [];
53
67
  const showGeneral = !scopeFilter || scopeFilter === GENERAL_SCOPE;
@@ -63,8 +77,12 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
63
77
  item.title.toLowerCase().includes(q) ||
64
78
  (item.description ?? '').toLowerCase().includes(q);
65
79
 
80
+ /** Merge active + archived items per scope when toggle is on. */
81
+ const scopeItems = (s: typeof state.scopes[number]) =>
82
+ showArchived ? [...s.items, ...(s.archivedItems ?? [])] : s.items;
83
+
66
84
  const allItems = [
67
- ...visibleScopes.flatMap((s) => s.items).filter((i) => (!layerFilter || i.layer === layerFilter) && matchesSearch(i)),
85
+ ...visibleScopes.flatMap((s) => scopeItems(s)).filter((i) => (!layerFilter || i.layer === layerFilter) && matchesSearch(i)),
68
86
  ...visibleGeneral.filter(matchesSearch),
69
87
  ];
70
88
 
@@ -72,7 +90,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
72
90
  const result: FlatItem[] = [];
73
91
  for (const scope of visibleScopes) {
74
92
  const layerMap = new Map(scope.layers.map(l => [l.id, l.name]));
75
- for (const item of scope.items) {
93
+ for (const item of scopeItems(scope)) {
76
94
  if (layerFilter && item.layer !== layerFilter) continue;
77
95
  if (!matchesSearch(item)) continue;
78
96
  result.push({ item, scopeId: scope.id, layerName: item.layer ? layerMap.get(item.layer) : undefined });
@@ -83,7 +101,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
83
101
  }
84
102
  return result;
85
103
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
- }, [state, scopeFilter, layerFilter, search]);
104
+ }, [state, scopeFilter, layerFilter, search, showArchived]);
87
105
 
88
106
  const handleDragEnd = async (e: DragEndEvent) => {
89
107
  const activeId = String(e.active.id);
@@ -109,6 +127,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
109
127
  };
110
128
 
111
129
  return (
130
+ <ArchivedIdsContext.Provider value={archivedIds}>
112
131
  <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
113
132
  <div className={styles.stickyBar}>
114
133
  <RoadmapFilterBar
@@ -121,6 +140,9 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
121
140
  allItems={allItems}
122
141
  search={search}
123
142
  onSearchChange={setSearch}
143
+ showArchived={showArchived}
144
+ onToggleArchived={() => setShowArchived((v) => !v)}
145
+ archivedCount={totalArchived}
124
146
  />
125
147
  </div>
126
148
  <main style={{ padding: view === 'list' ? '16px 0 0' : 16 }}>
@@ -142,7 +164,7 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
142
164
  layer={layer}
143
165
  depth={depth}
144
166
  alwaysOpen={!!layerFilter}
145
- items={scope.items.filter((i) => i.layer === layer.id && matchesSearch(i))}
167
+ items={scopeItems(scope).filter((i) => i.layer === layer.id && matchesSearch(i))}
146
168
  onCardClick={(item) => onCardClick(scope.id, item)}
147
169
  onTitleEdit={(id, title) => onPatch(scope.id, id, { title })}
148
170
  onDescEdit={(id, desc) => onPatch(scope.id, id, { description: desc })}
@@ -166,10 +188,11 @@ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardC
166
188
  )}
167
189
  </main>
168
190
  </DndContext>
191
+ </ArchivedIdsContext.Provider>
169
192
  );
170
193
  }
171
194
 
172
- function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, onViewChange, allItems, search, onSearchChange }: {
195
+ function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, onViewChange, allItems, search, onSearchChange, showArchived, onToggleArchived, archivedCount }: {
173
196
  scopes: TenboState['scopes'];
174
197
  scopeFilter: string | undefined;
175
198
  layerFilter: string | undefined;
@@ -179,9 +202,12 @@ function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, on
179
202
  allItems: Item[];
180
203
  search: string;
181
204
  onSearchChange: (v: string) => void;
205
+ showArchived: boolean;
206
+ onToggleArchived: () => void;
207
+ archivedCount: number;
182
208
  }) {
183
209
  const activeScope = scopeFilter && scopeFilter !== GENERAL_SCOPE ? scopes.find((s) => s.id === scopeFilter) : undefined;
184
- const counts = { now: 0, next: 0, later: 0, done: 0 };
210
+ const counts = { now: 0, next: 0, later: 0, done: 0, dropped: 0 };
185
211
  for (const i of allItems) counts[effectiveStatus(i)]++;
186
212
 
187
213
  return (
@@ -242,6 +268,18 @@ function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, on
242
268
  aria-label="Search tasks by title"
243
269
  />
244
270
 
271
+ {archivedCount > 0 && (
272
+ <button
273
+ className={`${styles.archiveBtn} ${showArchived ? styles.archiveBtnActive : ''}`}
274
+ onClick={onToggleArchived}
275
+ aria-pressed={showArchived}
276
+ title={showArchived ? 'Hide archived items' : `Show ${archivedCount} archived item${archivedCount === 1 ? '' : 's'}`}
277
+ >
278
+ <Archive size={13} strokeWidth={1.75} />
279
+ {archivedCount}
280
+ </button>
281
+ )}
282
+
245
283
  {/* Counts — far right */}
246
284
  <div className={styles.filterSpacer} />
247
285
  <div className={styles.counts}>
@@ -3,7 +3,7 @@ import { effectiveStatus } from '../api/lib/phases';
3
3
  import styles from './SummaryStrip.module.css';
4
4
 
5
5
  export function SummaryStrip({ items }: { items: Item[] }) {
6
- const counts = { now: 0, next: 0, later: 0, done: 0 };
6
+ const counts = { now: 0, next: 0, later: 0, done: 0, dropped: 0 };
7
7
  for (const i of items) counts[effectiveStatus(i)]++;
8
8
  return (
9
9
  <div className={styles.strip}>