tenbo-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +32 -0
  2. package/bin/tenbo-dashboard.mjs +67 -0
  3. package/index.html +12 -0
  4. package/package.json +75 -0
  5. package/scripts/compute-metrics.test.ts +35 -0
  6. package/scripts/compute-metrics.ts +46 -0
  7. package/scripts/next-id.test.ts +128 -0
  8. package/scripts/next-id.ts +183 -0
  9. package/scripts/validate-cli.test.ts +152 -0
  10. package/scripts/validate-cli.ts +181 -0
  11. package/src/App.tsx +152 -0
  12. package/src/ErrorBoundary.tsx +25 -0
  13. package/src/api/client.ts +41 -0
  14. package/src/api/lib/frontmatterScan.ts +37 -0
  15. package/src/api/lib/health/agingTodos.test.ts +32 -0
  16. package/src/api/lib/health/agingTodos.ts +97 -0
  17. package/src/api/lib/health/archCompliance.test.ts +26 -0
  18. package/src/api/lib/health/archCompliance.ts +53 -0
  19. package/src/api/lib/health/collectAll.test.ts +49 -0
  20. package/src/api/lib/health/collectAll.ts +78 -0
  21. package/src/api/lib/health/config.test.ts +45 -0
  22. package/src/api/lib/health/config.ts +54 -0
  23. package/src/api/lib/health/coupling.test.ts +40 -0
  24. package/src/api/lib/health/coupling.ts +66 -0
  25. package/src/api/lib/health/deadCode.test.ts +23 -0
  26. package/src/api/lib/health/deadCode.ts +76 -0
  27. package/src/api/lib/health/docDrift.test.ts +54 -0
  28. package/src/api/lib/health/docDrift.ts +99 -0
  29. package/src/api/lib/health/hotspotFiles.test.ts +49 -0
  30. package/src/api/lib/health/hotspotFiles.ts +70 -0
  31. package/src/api/lib/health/importGraph.test.ts +29 -0
  32. package/src/api/lib/health/importGraph.ts +51 -0
  33. package/src/api/lib/health/layerFiles.test.ts +46 -0
  34. package/src/api/lib/health/layerFiles.ts +40 -0
  35. package/src/api/lib/health/redundancy.test.ts +24 -0
  36. package/src/api/lib/health/redundancy.ts +162 -0
  37. package/src/api/lib/health/testCoverage.test.ts +33 -0
  38. package/src/api/lib/health/testCoverage.ts +55 -0
  39. package/src/api/lib/health/types.test.ts +29 -0
  40. package/src/api/lib/health/types.ts +85 -0
  41. package/src/api/lib/http.ts +34 -0
  42. package/src/api/lib/metrics.test.ts +81 -0
  43. package/src/api/lib/metrics.ts +78 -0
  44. package/src/api/lib/metricsRefresh.test.ts +54 -0
  45. package/src/api/lib/metricsRefresh.ts +147 -0
  46. package/src/api/lib/phases.test.ts +76 -0
  47. package/src/api/lib/phases.ts +48 -0
  48. package/src/api/lib/relationships.ts +65 -0
  49. package/src/api/lib/repoRoot.ts +12 -0
  50. package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
  51. package/src/api/lib/tenboFs.ts +237 -0
  52. package/src/api/lib/tenboFs.v2.test.ts +86 -0
  53. package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
  54. package/src/api/lib/validator.docUpdate.test.ts +99 -0
  55. package/src/api/lib/validator.phases.test.ts +98 -0
  56. package/src/api/lib/validator.relationships.test.ts +126 -0
  57. package/src/api/lib/validator.specLinks.test.ts +96 -0
  58. package/src/api/lib/validator.ts +311 -0
  59. package/src/api/lib/validator.v2.test.ts +55 -0
  60. package/src/api/lib/yamlOrdered.ts +60 -0
  61. package/src/api/plugin.ts +31 -0
  62. package/src/api/routes/items.ts +25 -0
  63. package/src/api/routes/layerContent.ts +22 -0
  64. package/src/api/routes/layerDocs.ts +22 -0
  65. package/src/api/routes/open.ts +18 -0
  66. package/src/api/routes/related.ts +11 -0
  67. package/src/api/routes/reorder.ts +15 -0
  68. package/src/api/routes/state.ts +23 -0
  69. package/src/api/routes/watch.ts +31 -0
  70. package/src/css-modules.d.ts +4 -0
  71. package/src/hooks/useApiPatch.ts +9 -0
  72. package/src/hooks/useLayerContent.ts +25 -0
  73. package/src/hooks/usePrompt.ts +23 -0
  74. package/src/hooks/useTenboState.ts +35 -0
  75. package/src/main.tsx +13 -0
  76. package/src/prompts/populateLayer.ts +12 -0
  77. package/src/router/routes.ts +98 -0
  78. package/src/router/useHashRoute.test.ts +67 -0
  79. package/src/router/useHashRoute.ts +15 -0
  80. package/src/styles.css +228 -0
  81. package/src/test-setup.ts +1 -0
  82. package/src/types.ts +202 -0
  83. package/src/ui/DotGrid.module.css +7 -0
  84. package/src/ui/DotGrid.tsx +87 -0
  85. package/src/ui/EmptyState.module.css +11 -0
  86. package/src/ui/EmptyState.tsx +10 -0
  87. package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
  88. package/src/ui/FindingModal/FindingModal.module.css +31 -0
  89. package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
  90. package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
  91. package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
  92. package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
  93. package/src/ui/FindingModal/index.tsx +36 -0
  94. package/src/ui/HealthPage/Digest.tsx +29 -0
  95. package/src/ui/HealthPage/FindingRow.tsx +21 -0
  96. package/src/ui/HealthPage/HealthPage.module.css +45 -0
  97. package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
  98. package/src/ui/HealthPage/HealthPage.tsx +66 -0
  99. package/src/ui/HealthPage/LayerCard.tsx +36 -0
  100. package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
  101. package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
  102. package/src/ui/HealthPage/severity.test.ts +46 -0
  103. package/src/ui/HealthPage/severity.ts +21 -0
  104. package/src/ui/ItemCard.module.css +131 -0
  105. package/src/ui/ItemCard.tsx +117 -0
  106. package/src/ui/ItemModal/DescriptionField.tsx +22 -0
  107. package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
  108. package/src/ui/ItemModal/ItemModal.module.css +200 -0
  109. package/src/ui/ItemModal/LinksSection.tsx +26 -0
  110. package/src/ui/ItemModal/NotesSection.tsx +18 -0
  111. package/src/ui/ItemModal/PhasesSection.tsx +79 -0
  112. package/src/ui/ItemModal/RelatedSection.tsx +26 -0
  113. package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
  114. package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
  115. package/src/ui/ItemModal/TitleField.tsx +21 -0
  116. package/src/ui/ItemModal/index.tsx +117 -0
  117. package/src/ui/KanbanColumn.module.css +20 -0
  118. package/src/ui/KanbanColumn.tsx +36 -0
  119. package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
  120. package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
  121. package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
  122. package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
  123. package/src/ui/LayerDrawer.module.css +60 -0
  124. package/src/ui/LayerDrawer.tsx +69 -0
  125. package/src/ui/LayerKanban.module.css +45 -0
  126. package/src/ui/LayerKanban.tsx +65 -0
  127. package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
  128. package/src/ui/LayerPage/LayerPage.module.css +52 -0
  129. package/src/ui/LayerPage/LayerPage.tsx +76 -0
  130. package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
  131. package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
  132. package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
  133. package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
  134. package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
  135. package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
  136. package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
  137. package/src/ui/Markdown.module.css +115 -0
  138. package/src/ui/Markdown.tsx +27 -0
  139. package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
  140. package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
  141. package/src/ui/ScopePage/ScopePage.module.css +72 -0
  142. package/src/ui/ScopePage/ScopePage.tsx +131 -0
  143. package/src/ui/SummaryStrip.module.css +18 -0
  144. package/src/ui/SummaryStrip.tsx +16 -0
  145. package/src/ui/TaskList.module.css +136 -0
  146. package/src/ui/TaskList.tsx +91 -0
  147. package/src/ui/TopBar.module.css +78 -0
  148. package/src/ui/TopBar.tsx +48 -0
  149. package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
  150. package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
  151. package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
  152. package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
  153. package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
  154. package/tsconfig.json +18 -0
  155. package/vite.config.ts +9 -0
@@ -0,0 +1,65 @@
1
+ import type { Item, TenboState } from '../../types';
2
+
3
+ export interface ItemRef {
4
+ item: Item;
5
+ scopeId?: string;
6
+ }
7
+
8
+ /**
9
+ * Flatten every roadmap item across all scopes plus the cross-cutting roadmap
10
+ * into a single array of `{ item, scopeId }` references. `scopeId` is undefined
11
+ * for cross-cutting items. Order: scope-by-scope (in `state.scopes` order),
12
+ * then cross-cutting at the end.
13
+ */
14
+ export function allItems(state: TenboState): ItemRef[] {
15
+ const out: ItemRef[] = [];
16
+ for (const scope of state.scopes) {
17
+ for (const item of scope.items) out.push({ item, scopeId: scope.id });
18
+ }
19
+ for (const item of state.crossCuttingRoadmap ?? []) out.push({ item });
20
+ return out;
21
+ }
22
+
23
+ /** Find an item by id anywhere in the workspace. Returns null if missing. */
24
+ export function findItemById(state: TenboState, id: string): ItemRef | null {
25
+ for (const ref of allItems(state)) {
26
+ if (ref.item.id === id) return ref;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Given a flat list of items, return all items whose `spawned_from` equals `parentId`.
33
+ * Pure function — extracted so it can be tested without constructing a full TenboState.
34
+ */
35
+ export function childrenOf(items: Item[], parentId: string): Item[] {
36
+ return items.filter((it) => it.spawned_from === parentId);
37
+ }
38
+
39
+ /**
40
+ * Compute the full set of items related to `target` (deduplicated by id):
41
+ * - Forward edges: every id in `target.related` that resolves to a known item.
42
+ * - Reverse edges: every other item whose `related` array contains `target.id`.
43
+ * The target itself is excluded. Order: forward refs first (preserving target.related
44
+ * order), then reverse refs in workspace-iteration order.
45
+ */
46
+ export function relatedItems(state: TenboState, target: Item): ItemRef[] {
47
+ const seen = new Set<string>([target.id]);
48
+ const out: ItemRef[] = [];
49
+ for (const ref of target.related ?? []) {
50
+ if (seen.has(ref)) continue;
51
+ const found = findItemById(state, ref);
52
+ if (found) {
53
+ seen.add(ref);
54
+ out.push(found);
55
+ }
56
+ }
57
+ for (const candidate of allItems(state)) {
58
+ if (seen.has(candidate.item.id)) continue;
59
+ if ((candidate.item.related ?? []).includes(target.id)) {
60
+ seen.add(candidate.item.id);
61
+ out.push(candidate);
62
+ }
63
+ }
64
+ return out;
65
+ }
@@ -0,0 +1,12 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function findRepoRoot(start: string): string | null {
5
+ let cur = path.resolve(start);
6
+ while (true) {
7
+ if (existsSync(path.join(cur, '.git'))) return cur;
8
+ const parent = path.dirname(cur);
9
+ if (parent === cur) return null;
10
+ cur = parent;
11
+ }
12
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { readLayerContent } from './tenboFs.js';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-lc-'));
11
+ const layer = path.join(dir, '.tenbo', 'scopes', 'editor', 'layers', 'foo');
12
+ mkdirSync(layer, { recursive: true });
13
+ writeFileSync(path.join(layer, 'README.md'), '# Foo\n');
14
+ writeFileSync(path.join(layer, 'intent.md'), '# Intent\n');
15
+ writeFileSync(path.join(layer, 'code-map.md'), '# Code Map\n');
16
+ });
17
+
18
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
19
+
20
+ describe('readLayerContent', () => {
21
+ it('returns the three layer files', () => {
22
+ const r = readLayerContent(dir, 'editor', 'foo');
23
+ expect(r.scope).toBe('editor');
24
+ expect(r.layer).toBe('foo');
25
+ expect(r.readme).toContain('Foo');
26
+ expect(r.intentMd).toContain('Intent');
27
+ expect(r.codeMapMd).toContain('Code Map');
28
+ });
29
+
30
+ it('returns empty strings when files are missing', () => {
31
+ const r = readLayerContent(dir, 'editor', 'missing');
32
+ expect(r.readme).toBe('');
33
+ expect(r.intentMd).toBe('');
34
+ expect(r.codeMapMd).toBe('');
35
+ });
36
+ });
@@ -0,0 +1,237 @@
1
+ import { readFileSync, writeFileSync, readdirSync, existsSync, renameSync, statSync, type Stats } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseSimple } from 'yaml';
4
+ import { parseYaml, stringifyYaml, patchSeqItem, reorderSeqItems } from './yamlOrdered';
5
+ import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent } from '../../types';
6
+
7
+ function tenboDir(repoRoot: string) { return path.join(repoRoot, '.tenbo'); }
8
+
9
+ function readMaybeFile(p: string): { content: string; mtime: number | null } {
10
+ if (!existsSync(p)) return { content: '', mtime: null };
11
+ const content = readFileSync(p, 'utf8');
12
+ const mtime = statSync(p).mtimeMs;
13
+ return { content, mtime };
14
+ }
15
+
16
+ export function readWorkspaceContent(repoRoot: string): WorkspaceContent {
17
+ const tenbo = tenboDir(repoRoot);
18
+ const ov = readMaybeFile(path.join(tenbo, 'overview.md'));
19
+ const p = readMaybeFile(path.join(tenbo, 'principles.md'));
20
+ const g = readMaybeFile(path.join(tenbo, 'glossary.md'));
21
+ const o = readMaybeFile(path.join(tenbo, 'observations.md'));
22
+ return {
23
+ overviewMd: ov.content,
24
+ principlesMd: p.content,
25
+ glossaryMd: g.content,
26
+ observationsMd: o.content,
27
+ overviewMtime: ov.mtime,
28
+ principlesMtime: p.mtime,
29
+ glossaryMtime: g.mtime,
30
+ observationsMtime: o.mtime,
31
+ };
32
+ }
33
+
34
+ export function readLayerContent(repoRoot: string, scopeId: string, layerId: string): LayerContent {
35
+ const layerDir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers', layerId);
36
+ return {
37
+ scope: scopeId,
38
+ layer: layerId,
39
+ readme: readMaybeFile(path.join(layerDir, 'README.md')).content,
40
+ intentMd: readMaybeFile(path.join(layerDir, 'intent.md')).content,
41
+ codeMapMd: readMaybeFile(path.join(layerDir, 'code-map.md')).content,
42
+ };
43
+ }
44
+
45
+ export function tenboExists(repoRoot: string): boolean {
46
+ return existsSync(path.join(tenboDir(repoRoot), 'workspace.yaml'));
47
+ }
48
+
49
+ export function readWorkspace(repoRoot: string): { scopeRefs: { id: string; path: string; description: string }[]; crossCutting: CrossCutting[] } {
50
+ const text = readFileSync(path.join(tenboDir(repoRoot), 'workspace.yaml'), 'utf8');
51
+ const data = parseSimple(text) as any;
52
+ return {
53
+ scopeRefs: data?.scopes ?? [],
54
+ crossCutting: data?.cross_cutting ?? [],
55
+ };
56
+ }
57
+
58
+ function readArchitecture(repoRoot: string, scopeId: string): Layer[] {
59
+ const text = readFileSync(path.join(tenboDir(repoRoot), 'scopes', scopeId, 'architecture.yaml'), 'utf8');
60
+ const data = parseSimple(text) as any;
61
+ return data?.layers ?? [];
62
+ }
63
+
64
+ function readRoadmap(repoRoot: string, scopeId: string): Item[] {
65
+ const text = readFileSync(path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml'), 'utf8');
66
+ const data = parseSimple(text) as any;
67
+ return data?.items ?? [];
68
+ }
69
+
70
+ function readNarratives(repoRoot: string, scopeId: string): Record<string, string> {
71
+ const dir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers');
72
+ if (!existsSync(dir)) return {};
73
+ const out: Record<string, string> = {};
74
+ for (const entry of readdirSync(dir)) {
75
+ const full = path.join(dir, entry);
76
+ if (!statSync(full).isDirectory()) continue;
77
+ const readme = path.join(full, 'README.md');
78
+ if (existsSync(readme)) {
79
+ out[`${scopeId}/${entry}`] = readFileSync(readme, 'utf8');
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export interface LayerDocEntry {
86
+ filename: string;
87
+ title: string | null;
88
+ }
89
+
90
+ export function listLayerDocs(repoRoot: string, scopeId: string, layerId: string): LayerDocEntry[] {
91
+ const layerDir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers', layerId);
92
+ if (!existsSync(layerDir) || !statSync(layerDir).isDirectory()) return [];
93
+ const out: LayerDocEntry[] = [];
94
+ for (const entry of readdirSync(layerDir)) {
95
+ if (!entry.endsWith('.md')) continue;
96
+ if (entry === 'README.md') continue;
97
+ const full = path.join(layerDir, entry);
98
+ if (!statSync(full).isFile()) continue;
99
+ const text = readFileSync(full, 'utf8');
100
+ const m = text.match(/^#\s+(.+)$/m);
101
+ out.push({ filename: entry, title: m ? m[1].trim() : null });
102
+ }
103
+ out.sort((a, b) => a.filename.localeCompare(b.filename));
104
+ return out;
105
+ }
106
+
107
+ function tryStat(p: string): Stats | null {
108
+ try { return statSync(p); } catch { return null; }
109
+ }
110
+
111
+ function readLayerDocs(repoRoot: string, scopeId: string): Record<string, LayerDocs> {
112
+ const dir = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'layers');
113
+ if (!existsSync(dir)) return {};
114
+ const out: Record<string, LayerDocs> = {};
115
+ for (const entry of readdirSync(dir)) {
116
+ const full = path.join(dir, entry);
117
+ const entryStat = tryStat(full);
118
+ if (!entryStat?.isDirectory()) continue;
119
+ const intentPath = path.join(full, 'intent.md');
120
+ const intentStat = tryStat(intentPath);
121
+ const codeMapStat = tryStat(path.join(full, 'code-map.md'));
122
+ let intentEmpty = false;
123
+ if (intentStat) {
124
+ try {
125
+ const stripped = readFileSync(intentPath, 'utf8')
126
+ .replace(/<!--[\s\S]*?-->/g, '')
127
+ .replace(/^#.*$/gm, '')
128
+ .trim();
129
+ intentEmpty = stripped.length === 0;
130
+ } catch {
131
+ // Read failure (race, permission, broken symlink): treat as non-empty;
132
+ // higher layers will surface a different error if the file is unusable.
133
+ intentEmpty = false;
134
+ }
135
+ }
136
+ out[`${scopeId}/${entry}`] = {
137
+ hasIntent: !!intentStat,
138
+ hasCodeMap: !!codeMapStat,
139
+ intentMtime: intentStat?.mtimeMs ?? null,
140
+ codeMapMtime: codeMapStat?.mtimeMs ?? null,
141
+ intentEmpty,
142
+ };
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function readCrossCuttingRoadmap(repoRoot: string): Item[] {
148
+ const file = path.join(tenboDir(repoRoot), 'roadmap.yaml');
149
+ if (!existsSync(file)) return [];
150
+ const data = parseSimple(readFileSync(file, 'utf8')) as any;
151
+ return data?.items ?? [];
152
+ }
153
+
154
+ function readMetrics(repoRoot: string, scopeId: string): ScopeMetrics | undefined {
155
+ const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'metrics.json');
156
+ if (!existsSync(file)) return undefined;
157
+ try {
158
+ return JSON.parse(readFileSync(file, 'utf8')) as ScopeMetrics;
159
+ } catch (err) {
160
+ console.warn(`tenbo: failed to parse ${file}: ${(err as Error).message}`);
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * List spec markdown files under `.tenbo/specs/` and `.tenbo/specs/archive/`.
167
+ * Returns a Set of repo-relative paths (e.g. `.tenbo/specs/x-001-foo.md`).
168
+ * Returns undefined if `.tenbo/specs/` does not exist.
169
+ */
170
+ export function readSpecFiles(repoRoot: string): Set<string> | undefined {
171
+ const specsDir = path.join(tenboDir(repoRoot), 'specs');
172
+ if (!existsSync(specsDir)) return undefined;
173
+ const out = new Set<string>();
174
+ for (const entry of readdirSync(specsDir)) {
175
+ if (entry.endsWith('.md')) out.add(`.tenbo/specs/${entry}`);
176
+ }
177
+ const archiveDir = path.join(specsDir, 'archive');
178
+ if (existsSync(archiveDir) && statSync(archiveDir).isDirectory()) {
179
+ for (const entry of readdirSync(archiveDir)) {
180
+ if (entry.endsWith('.md')) out.add(`.tenbo/specs/archive/${entry}`);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
186
+ export function readState(repoRoot: string): TenboState {
187
+ const ws = readWorkspace(repoRoot);
188
+ const scopes: Scope[] = ws.scopeRefs.map(ref => ({
189
+ id: ref.id,
190
+ path: ref.path,
191
+ description: ref.description,
192
+ layers: readArchitecture(repoRoot, ref.id),
193
+ items: readRoadmap(repoRoot, ref.id),
194
+ }));
195
+ const narratives = scopes.reduce<Record<string, string>>((acc, s) => {
196
+ return Object.assign(acc, readNarratives(repoRoot, s.id));
197
+ }, {});
198
+ const layerDocs = scopes.reduce<Record<string, LayerDocs>>((acc, s) => {
199
+ return Object.assign(acc, readLayerDocs(repoRoot, s.id));
200
+ }, {});
201
+ const crossCuttingRoadmap = readCrossCuttingRoadmap(repoRoot);
202
+ const metrics: Record<string, ScopeMetrics> = {};
203
+ for (const s of scopes) {
204
+ const m = readMetrics(repoRoot, s.id);
205
+ if (m) metrics[s.id] = m;
206
+ }
207
+ const workspaceContent = readWorkspaceContent(repoRoot);
208
+ const state: TenboState = { scopes, crossCutting: ws.crossCutting, narratives, workspaceContent };
209
+ state.layerDocs = layerDocs;
210
+ state.crossCuttingRoadmap = crossCuttingRoadmap;
211
+ if (Object.keys(metrics).length > 0) state.metrics = metrics;
212
+ const specFiles = readSpecFiles(repoRoot);
213
+ if (specFiles) state.specFiles = specFiles;
214
+ return state;
215
+ }
216
+
217
+ function atomicWrite(filePath: string, content: string): void {
218
+ const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`;
219
+ writeFileSync(tmp, content, 'utf8');
220
+ renameSync(tmp, filePath);
221
+ }
222
+
223
+ export function patchItem(repoRoot: string, scopeId: string, itemId: string, patch: Partial<Item>): void {
224
+ const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
225
+ const text = readFileSync(file, 'utf8');
226
+ const doc = parseYaml(text);
227
+ patchSeqItem(doc, 'items', itemId, patch as Record<string, unknown>);
228
+ atomicWrite(file, stringifyYaml(doc));
229
+ }
230
+
231
+ export function reorderItems(repoRoot: string, scopeId: string, idsInOrder: string[]): void {
232
+ const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
233
+ const text = readFileSync(file, 'utf8');
234
+ const doc = parseYaml(text);
235
+ reorderSeqItems(doc, 'items', idsInOrder);
236
+ atomicWrite(file, stringifyYaml(doc));
237
+ }
@@ -0,0 +1,86 @@
1
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import { readState } from './tenboFs';
6
+
7
+ function makeTenbo(structure: Record<string, string>) {
8
+ const root = mkdtempSync(path.join(tmpdir(), 'tenbo-v2-'));
9
+ for (const [rel, content] of Object.entries(structure)) {
10
+ const full = path.join(root, rel);
11
+ mkdirSync(path.dirname(full), { recursive: true });
12
+ writeFileSync(full, content);
13
+ }
14
+ return root;
15
+ }
16
+
17
+ describe('readState v2 extensions', () => {
18
+ it('reports layerDocs.hasIntent and hasCodeMap when present', () => {
19
+ const root = makeTenbo({
20
+ '.tenbo/workspace.yaml': 'scopes:\n - id: e\n path: .\n description: ok\ncross_cutting: []\n',
21
+ '.tenbo/scopes/e/architecture.yaml': 'layers:\n - id: l\n name: L\n description: a layer here\n files: ["**/*"]\n',
22
+ '.tenbo/scopes/e/roadmap.yaml': 'items: []\n',
23
+ '.tenbo/scopes/e/layers/l/README.md': '# L\n',
24
+ '.tenbo/scopes/e/layers/l/intent.md': '# L\n',
25
+ '.tenbo/scopes/e/layers/l/code-map.md': '# L\n',
26
+ });
27
+ const state = readState(root);
28
+ expect(state.layerDocs?.['e/l'].hasIntent).toBe(true);
29
+ expect(state.layerDocs?.['e/l'].hasCodeMap).toBe(true);
30
+ expect(typeof state.layerDocs?.['e/l'].intentMtime).toBe('number');
31
+ });
32
+
33
+ it('reads cross-cutting roadmap from .tenbo/roadmap.yaml', () => {
34
+ const root = makeTenbo({
35
+ '.tenbo/workspace.yaml': 'scopes: []\ncross_cutting: []\n',
36
+ '.tenbo/roadmap.yaml': 'items:\n - id: x-001\n title: cross thing\n layers: []\n status: later\n description: spans many things across the system\n',
37
+ });
38
+ const state = readState(root);
39
+ expect(state.crossCuttingRoadmap?.length).toBe(1);
40
+ expect(state.crossCuttingRoadmap?.[0].id).toBe('x-001');
41
+ });
42
+
43
+ it('reports absent v2 files cleanly', () => {
44
+ const root = makeTenbo({
45
+ '.tenbo/workspace.yaml': 'scopes:\n - id: e\n path: .\n description: ok\ncross_cutting: []\n',
46
+ '.tenbo/scopes/e/architecture.yaml': 'layers:\n - id: l\n name: L\n description: a small layer for testing\n files: ["**/*"]\n',
47
+ '.tenbo/scopes/e/roadmap.yaml': 'items: []\n',
48
+ '.tenbo/scopes/e/layers/l/README.md': '# L\n',
49
+ });
50
+ const state = readState(root);
51
+ expect(state.layerDocs?.['e/l'].hasIntent).toBe(false);
52
+ expect(state.crossCuttingRoadmap).toEqual([]);
53
+ expect(state.metrics).toBeUndefined();
54
+ });
55
+
56
+ it('reads metrics.json when present', () => {
57
+ const root = makeTenbo({
58
+ '.tenbo/workspace.yaml': 'scopes:\n - id: e\n path: .\n description: ok\ncross_cutting: []\n',
59
+ '.tenbo/scopes/e/architecture.yaml': 'layers:\n - id: l\n name: L\n description: a layer here\n files: ["**/*"]\n',
60
+ '.tenbo/scopes/e/roadmap.yaml': 'items: []\n',
61
+ '.tenbo/scopes/e/layers/l/README.md': '# L\n',
62
+ '.tenbo/scopes/e/metrics.json': JSON.stringify({
63
+ generated_at: '2026-04-26T00:00:00Z',
64
+ layers: { l: { file_count: 3, total_lines: 42, outbound_deps: 0, deep_dive_count: 0, intent_age_days: null, pct_roadmap_in_now: 0 } },
65
+ }),
66
+ });
67
+ const state = readState(root);
68
+ expect(state.metrics?.['e'].layers.l.file_count).toBe(3);
69
+ expect(state.metrics?.['e'].generated_at).toBe('2026-04-26T00:00:00Z');
70
+ });
71
+
72
+ it('tolerates malformed metrics.json without crashing readState', () => {
73
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
74
+ const root = makeTenbo({
75
+ '.tenbo/workspace.yaml': 'scopes:\n - id: e\n path: .\n description: ok\ncross_cutting: []\n',
76
+ '.tenbo/scopes/e/architecture.yaml': 'layers:\n - id: l\n name: L\n description: a layer here\n files: ["**/*"]\n',
77
+ '.tenbo/scopes/e/roadmap.yaml': 'items: []\n',
78
+ '.tenbo/scopes/e/layers/l/README.md': '# L\n',
79
+ '.tenbo/scopes/e/metrics.json': '{ not valid json',
80
+ });
81
+ const state = readState(root);
82
+ expect(state.metrics).toBeUndefined();
83
+ expect(warn).toHaveBeenCalled();
84
+ warn.mockRestore();
85
+ });
86
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { readWorkspaceContent } from './tenboFs.js';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-ws-'));
11
+ mkdirSync(path.join(dir, '.tenbo'), { recursive: true });
12
+ });
13
+
14
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
15
+
16
+ describe('readWorkspaceContent', () => {
17
+ it('returns empty strings and null mtimes when files are missing', () => {
18
+ const r = readWorkspaceContent(dir);
19
+ expect(r.principlesMd).toBe('');
20
+ expect(r.glossaryMd).toBe('');
21
+ expect(r.observationsMd).toBe('');
22
+ expect(r.principlesMtime).toBeNull();
23
+ expect(r.glossaryMtime).toBeNull();
24
+ expect(r.observationsMtime).toBeNull();
25
+ });
26
+
27
+ it('reads contents and mtimes when files exist', () => {
28
+ const fixedTime = new Date('2026-01-01T00:00:00Z');
29
+ const tenbo = path.join(dir, '.tenbo');
30
+ writeFileSync(path.join(tenbo, 'principles.md'), '# Principles\n');
31
+ writeFileSync(path.join(tenbo, 'glossary.md'), '# Glossary\n');
32
+ writeFileSync(path.join(tenbo, 'observations.md'), '# Observations\n');
33
+ for (const f of ['principles.md', 'glossary.md', 'observations.md']) {
34
+ utimesSync(path.join(tenbo, f), fixedTime, fixedTime);
35
+ }
36
+ const r = readWorkspaceContent(dir);
37
+ expect(r.principlesMd).toContain('Principles');
38
+ expect(r.glossaryMd).toContain('Glossary');
39
+ expect(r.observationsMd).toContain('Observations');
40
+ expect(r.principlesMtime).toBe(fixedTime.getTime());
41
+ });
42
+ });
@@ -0,0 +1,99 @@
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: 'done',
24
+ description: 'a roadmap item used for doc_update tests now',
25
+ ...over,
26
+ });
27
+
28
+ const issuesFor = (state: TenboState, id: string) => {
29
+ const r = validate(state);
30
+ return {
31
+ warnings: r.warnings.filter((w) => w.itemId === id).map((w) => w.message),
32
+ errors: r.errors.filter((e) => e.itemId === id).map((e) => e.message),
33
+ };
34
+ };
35
+
36
+ describe('validator — doc_update gate (x-003 phase 3)', () => {
37
+ it('warns when a done feature/refactor/bug item is missing doc_update', () => {
38
+ for (const type of ['feature', 'refactor', 'bug'] as const) {
39
+ const state = stateWithItems([baseItem({ id: `ed-00${1}`, type })]);
40
+ const { warnings } = issuesFor(state, 'ed-001');
41
+ expect(warnings.some((m) => m.includes('doc_update is not stamped'))).toBe(true);
42
+ }
43
+ });
44
+
45
+ it('does not warn when doc_update is a valid ISO date', () => {
46
+ const state = stateWithItems([baseItem({ type: 'feature', doc_update: '2026-04-28' })]);
47
+ expect(issuesFor(state, 'ed-001').warnings).toEqual([]);
48
+ });
49
+
50
+ it('does not warn when doc_update is a skipped reason with em-dash', () => {
51
+ const state = stateWithItems([baseItem({ type: 'refactor', doc_update: 'skipped — internal helper rename only' })]);
52
+ expect(issuesFor(state, 'ed-001').warnings).toEqual([]);
53
+ });
54
+
55
+ it('does not warn when doc_update is a skipped reason with hyphen or colon separator', () => {
56
+ for (const sep of [' - ', ': ']) {
57
+ const state = stateWithItems([baseItem({ type: 'bug', doc_update: `skipped${sep}no architecture impact` })]);
58
+ expect(issuesFor(state, 'ed-001').warnings).toEqual([]);
59
+ }
60
+ });
61
+
62
+ it('warns when doc_update is malformed (neither date nor skipped reason)', () => {
63
+ const state = stateWithItems([baseItem({ type: 'feature', doc_update: 'yes I did' })]);
64
+ const { warnings } = issuesFor(state, 'ed-001');
65
+ expect(warnings.some((m) => m.includes('is malformed'))).toBe(true);
66
+ });
67
+
68
+ it('errors when doc_update is not a string', () => {
69
+ const state = stateWithItems([baseItem({ type: 'feature', doc_update: 123 as any })]);
70
+ const { errors } = issuesFor(state, 'ed-001');
71
+ expect(errors.some((m) => m.includes('must be a string'))).toBe(true);
72
+ });
73
+
74
+ it('does not warn when item has no type set (exemption for un-typed items)', () => {
75
+ const state = stateWithItems([baseItem({ /* no type */ })]);
76
+ const { warnings } = issuesFor(state, 'ed-001');
77
+ expect(warnings.some((m) => m.includes('doc_update'))).toBe(false);
78
+ });
79
+
80
+ it('does not warn for spike items even when doc_update is missing', () => {
81
+ const state = stateWithItems([baseItem({ type: 'spike' })]);
82
+ const { warnings } = issuesFor(state, 'ed-001');
83
+ expect(warnings.some((m) => m.includes('doc_update'))).toBe(false);
84
+ });
85
+
86
+ it('does not warn for items with status other than done', () => {
87
+ for (const status of ['now', 'next', 'later'] as const) {
88
+ const state = stateWithItems([baseItem({ status, type: 'feature', done_when: ['ok'] })]);
89
+ const { warnings } = issuesFor(state, 'ed-001');
90
+ expect(warnings.some((m) => m.includes('doc_update'))).toBe(false);
91
+ }
92
+ });
93
+
94
+ it('warns when doc_update is empty string', () => {
95
+ const state = stateWithItems([baseItem({ type: 'feature', doc_update: '' })]);
96
+ const { warnings } = issuesFor(state, 'ed-001');
97
+ expect(warnings.some((m) => m.includes('doc_update is not stamped'))).toBe(true);
98
+ });
99
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validate } from './validator';
3
+ import type { TenboState, Item, Phase } from '../../types';
4
+
5
+ function baseState(items: Item[] = []): TenboState {
6
+ return {
7
+ scopes: [{
8
+ id: 'e', path: '.', description: 'editor scope description here for tests',
9
+ layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
10
+ items,
11
+ }],
12
+ crossCutting: [],
13
+ narratives: { 'e/l': '# L' },
14
+ } as any;
15
+ }
16
+
17
+ const baseItem = (over: Partial<Item> = {}): Item => ({
18
+ id: 'ed-001',
19
+ title: 't',
20
+ layer: 'l',
21
+ status: 'later',
22
+ description: 'a roadmap item with phases for testing now',
23
+ ...over,
24
+ });
25
+
26
+ const ph = (id: number, status: Phase['status'], extra: Partial<Phase> = {}): Phase => ({
27
+ id,
28
+ title: `phase ${id}`,
29
+ status,
30
+ ...extra,
31
+ });
32
+
33
+ describe('validator — phase schema', () => {
34
+ it('accepts a well-formed phases list with no errors', () => {
35
+ const item = baseItem({
36
+ phases: [ph(1, 'done', { completed_at: '2026-04-27' }), ph(2, 'now')],
37
+ });
38
+ const r = validate(baseState([item]));
39
+ const phaseErrors = r.errors.filter((e) => e.itemId === 'ed-001');
40
+ expect(phaseErrors).toEqual([]);
41
+ });
42
+
43
+ it('errors when a phase id does not match its 1-based position', () => {
44
+ const item = baseItem({ phases: [ph(1, 'done'), ph(3, 'now')] });
45
+ const r = validate(baseState([item]));
46
+ expect(r.errors.some((e) => /id 3.*1\.\.N/.test(e.message))).toBe(true);
47
+ });
48
+
49
+ it('errors when a phase has a non-integer id', () => {
50
+ const item = baseItem({ phases: [{ title: 'p', status: 'later' } as any] });
51
+ const r = validate(baseState([item]));
52
+ expect(r.errors.some((e) => /missing an integer id/.test(e.message))).toBe(true);
53
+ });
54
+
55
+ it('errors when a phase is missing title', () => {
56
+ const item = baseItem({ phases: [{ id: 1, status: 'later' } as any] });
57
+ const r = validate(baseState([item]));
58
+ expect(r.errors.some((e) => /missing a title/.test(e.message))).toBe(true);
59
+ });
60
+
61
+ it('errors on invalid phase status', () => {
62
+ const item = baseItem({ phases: [{ id: 1, title: 'x', status: 'wip' } as any] });
63
+ const r = validate(baseState([item]));
64
+ expect(r.errors.some((e) => /invalid status "wip"/.test(e.message))).toBe(true);
65
+ });
66
+
67
+ it('errors when completed_at is not YYYY-MM-DD', () => {
68
+ const item = baseItem({ phases: [ph(1, 'done', { completed_at: '04/27/2026' })] });
69
+ const r = validate(baseState([item]));
70
+ expect(r.errors.some((e) => /completed_at.*YYYY-MM-DD/.test(e.message))).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe('validator — phase warnings', () => {
75
+ it('warns when phase is done but completed_at missing', () => {
76
+ const item = baseItem({ phases: [ph(1, 'done')] });
77
+ const r = validate(baseState([item]));
78
+ expect(r.warnings.some((w) => /done but has no completed_at/.test(w.message))).toBe(true);
79
+ });
80
+
81
+ it('warns when completed_at set but status not done', () => {
82
+ const item = baseItem({ phases: [ph(1, 'now', { completed_at: '2026-04-27' })] });
83
+ const r = validate(baseState([item]));
84
+ expect(r.warnings.some((w) => /has completed_at but status is "now"/.test(w.message))).toBe(true);
85
+ });
86
+
87
+ it('warns when both status and phases are present', () => {
88
+ const item = baseItem({ status: 'now', phases: [ph(1, 'now')] });
89
+ const r = validate(baseState([item]));
90
+ expect(r.warnings.some((w) => /both status and phases/.test(w.message))).toBe(true);
91
+ });
92
+
93
+ it('does not warn for items without phases', () => {
94
+ const item = baseItem({ status: 'now', done_when: ['x'] });
95
+ const r = validate(baseState([item]));
96
+ expect(r.warnings.some((w) => /both status and phases/.test(w.message))).toBe(false);
97
+ });
98
+ });