tenbo-dashboard 0.5.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tenbo-dashboard",
3
- "version": "0.5.0",
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",
@@ -3,7 +3,7 @@ import path from 'node:path';
3
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
 
@@ -193,6 +193,43 @@ export function readSpecFiles(repoRoot: string): Set<string> | undefined {
193
193
  return out;
194
194
  }
195
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
+
196
233
  export function readState(repoRoot: string): TenboState {
197
234
  const ws = readWorkspace(repoRoot);
198
235
  const scopes: Scope[] = ws.scopeRefs.map(ref => ({
@@ -222,6 +259,8 @@ export function readState(repoRoot: string): TenboState {
222
259
  if (Object.keys(metrics).length > 0) state.metrics = metrics;
223
260
  const specFiles = readSpecFiles(repoRoot);
224
261
  if (specFiles) state.specFiles = specFiles;
262
+ const decisions = readDecisions(repoRoot);
263
+ if (decisions) state.decisions = decisions;
225
264
  return state;
226
265
  }
227
266
 
@@ -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', () => {
@@ -277,6 +277,52 @@ export function validate(state: TenboState): ValidateResult {
277
277
  }
278
278
  }
279
279
 
280
+ // Goal-ref shape validation (sk-025). Optional field; warn (do not error)
281
+ // when missing or malformed. Shape-check only — does NOT cross-reference
282
+ // against `overview.md` Product goals (deferred per sk-022 risks).
283
+ // Skip the missing-warning for done/dropped items: no value in nagging
284
+ // about completed work.
285
+ if (item.goal_ref === undefined) {
286
+ if (item.status !== 'done' && item.status !== 'dropped') {
287
+ warnings.push({
288
+ level: 'warning',
289
+ message: `item ${item.id} has no goal_ref — backfill with cited goals or "exploratory"`,
290
+ scope: scope.id,
291
+ itemId: item.id,
292
+ });
293
+ }
294
+ } else if (item.goal_ref === 'exploratory') {
295
+ // Valid literal — no warning.
296
+ } else if (Array.isArray(item.goal_ref)) {
297
+ if (item.goal_ref.length === 0) {
298
+ warnings.push({
299
+ level: 'warning',
300
+ message: `item ${item.id}.goal_ref is an empty array — use "exploratory" if no goals identified, or remove the field`,
301
+ scope: scope.id,
302
+ itemId: item.id,
303
+ });
304
+ } else {
305
+ for (const ref of item.goal_ref) {
306
+ if (typeof ref !== 'string') {
307
+ warnings.push({
308
+ level: 'warning',
309
+ message: `item ${item.id}.goal_ref must be a string array or the literal "exploratory" — got: ${typeof ref} entry`,
310
+ scope: scope.id,
311
+ itemId: item.id,
312
+ });
313
+ break;
314
+ }
315
+ }
316
+ }
317
+ } else {
318
+ warnings.push({
319
+ level: 'warning',
320
+ message: `item ${item.id}.goal_ref must be a string array or the literal "exploratory" — got: ${typeof item.goal_ref}`,
321
+ scope: scope.id,
322
+ itemId: item.id,
323
+ });
324
+ }
325
+
280
326
  // Phase schema validation (Phase 3 of x-001).
281
327
  if (item.phases !== undefined) {
282
328
  if (!Array.isArray(item.phases)) {
@@ -324,6 +370,48 @@ export function validate(state: TenboState): ValidateResult {
324
370
  }
325
371
  }
326
372
 
373
+ // Decision records (sk-020). Validate frontmatter shape on every file under
374
+ // `.tenbo/decisions/`. Additive: state.decisions is undefined when the
375
+ // directory does not exist, so existing repos without decisions/ stay quiet.
376
+ if (state.decisions) {
377
+ const VALID_DECISION_STATUSES = new Set(['accepted', 'superseded']);
378
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
379
+ const decisions = state.decisions;
380
+ for (const slug of Object.keys(decisions)) {
381
+ const rec = decisions[slug];
382
+ const fm = rec.frontmatter ?? {};
383
+ const where = rec.path;
384
+ // Required fields → errors.
385
+ for (const field of ['id', 'date', 'status', 'title'] as const) {
386
+ const v = fm[field];
387
+ if (v === undefined || v === null || (typeof v === 'string' && v.trim() === '')) {
388
+ errors.push({ level: 'error', message: `decision ${where} is missing required frontmatter field "${field}"` });
389
+ }
390
+ }
391
+ if (typeof fm.status === 'string' && !VALID_DECISION_STATUSES.has(fm.status)) {
392
+ errors.push({ level: 'error', message: `decision ${where} has invalid status "${fm.status}" (expected accepted|superseded)` });
393
+ }
394
+ if (fm.date !== undefined && typeof fm.date === 'string' && fm.date.trim() !== '' && !ISO_DATE_RE.test(fm.date.trim())) {
395
+ warnings.push({ level: 'warning', message: `decision ${where} date "${fm.date}" is not YYYY-MM-DD` });
396
+ }
397
+ if (fm.status === 'superseded') {
398
+ const sb = fm.superseded_by;
399
+ if (typeof sb !== 'string' || sb.trim() === '') {
400
+ errors.push({ level: 'error', message: `decision ${where} has status:superseded but no superseded_by` });
401
+ } else if (!state.decisions[sb]) {
402
+ errors.push({ level: 'error', message: `decision ${where} superseded_by "${sb}" does not match a sibling decision file` });
403
+ } else if (sb === slug) {
404
+ errors.push({ level: 'error', message: `decision ${where} superseded_by cannot reference itself` });
405
+ }
406
+ } else if (fm.superseded_by !== undefined && fm.status !== 'superseded') {
407
+ warnings.push({ level: 'warning', message: `decision ${where} has superseded_by but status is "${fm.status ?? 'unset'}"` });
408
+ }
409
+ if (fm.related_items !== undefined && !Array.isArray(fm.related_items)) {
410
+ warnings.push({ level: 'warning', message: `decision ${where} related_items should be a list` });
411
+ }
412
+ }
413
+ }
414
+
327
415
  const allRefs = new Set(state.scopes.flatMap(s => s.layers.map(l => `${s.id}:${l.id}`)));
328
416
  for (const item of state.crossCuttingRoadmap ?? []) {
329
417
  const refs = [...(item.layers ?? []), ...(item.affects ?? [])];
@@ -40,6 +40,8 @@ const IGNORE_GLOBS = [
40
40
  '**/*.swx', // vim
41
41
  '**/*~', // many editors' backup
42
42
  '**/4913', // vim's pre-write probe
43
+ '**/*.tmp', // simple atomic-rename staging
44
+ '**/*.tmp.*', // tenboFs.ts atomic writes use `<file>.tmp.<PID>.<ts>` suffix
43
45
  '**/.DS_Store', // macOS
44
46
  '**/.git/**',
45
47
  '**/node_modules/**',
@@ -3,7 +3,8 @@ import type { Item } from '../types';
3
3
  import { tenboApi } from '../api/client';
4
4
 
5
5
  export function useApiPatch() {
6
- return useCallback((scopeId: string, itemId: string, patch: Partial<Item>) => {
7
- return tenboApi.patchItem(scopeId, itemId, patch);
6
+ return useCallback(async (scopeId: string, itemId: string, patch: Partial<Item>): Promise<Item> => {
7
+ const { item } = await tenboApi.patchItem(scopeId, itemId, patch);
8
+ return item;
8
9
  }, []);
9
10
  }
@@ -2,7 +2,7 @@ export type WorkspaceTab = typeof WORKSPACE_TABS[number];
2
2
  export type LayerTab = typeof LAYER_TABS[number];
3
3
  export type Mode = 'docs' | 'roadmap' | 'health';
4
4
 
5
- const WORKSPACE_TABS = ['overview', 'principles', 'glossary'] as const;
5
+ const WORKSPACE_TABS = ['overview', 'principles', 'glossary', 'decisions'] as const;
6
6
  const LAYER_TABS = ['overview', 'purpose', 'files', 'docs'] as const;
7
7
 
8
8
  export type Route =
package/src/types.ts CHANGED
@@ -55,6 +55,15 @@ export interface Item {
55
55
  risks?: string[];
56
56
  type?: 'feature' | 'bug' | 'refactor' | 'spike';
57
57
  priority?: Priority;
58
+ /**
59
+ * Goals this item advances. Either a list of goal IDs (e.g. ["g1", "g3"])
60
+ * matching IDs declared in `overview.md` Product goals section, or the
61
+ * literal string "exploratory" for items captured without a clear goal
62
+ * connection. Items lacking goal_ref entirely produce a validator warning
63
+ * (not an error) — they're surfaced for backfill but don't block anything.
64
+ * See sk-022 for context.
65
+ */
66
+ goal_ref?: string[] | 'exploratory';
58
67
  /** Optional multi-phase progress. When present, item-level `status` is derived. */
59
68
  phases?: Phase[];
60
69
  /**
@@ -161,6 +170,11 @@ export interface TenboState {
161
170
  * resolve to a real file. Absent when `.tenbo/specs/` does not exist.
162
171
  */
163
172
  specFiles?: Set<string>;
173
+ /**
174
+ * Decision records loaded from `.tenbo/decisions/`. Absent if the directory
175
+ * does not exist. Keyed by slug for `superseded_by` resolution.
176
+ */
177
+ decisions?: Record<string, DecisionRecord>;
164
178
  }
165
179
 
166
180
  export type ValidateLevel = 'error' | 'warning';
@@ -200,6 +214,30 @@ export interface WorkspaceContent {
200
214
  observationsMtime: number | null;
201
215
  }
202
216
 
217
+ /**
218
+ * Project-level decision record loaded from `.tenbo/decisions/<slug>.md`.
219
+ * Only created when a future audit would re-suggest the same thing without
220
+ * the rationale. See `skill/templates/decision.md.tmpl` for the shape.
221
+ */
222
+ export interface DecisionRecord {
223
+ /** repo-relative path, e.g. `.tenbo/decisions/foo.md` */
224
+ path: string;
225
+ /** filename slug without extension */
226
+ slug: string;
227
+ /** parsed frontmatter — values may be missing on malformed files */
228
+ frontmatter: {
229
+ id?: string;
230
+ date?: string;
231
+ status?: 'accepted' | 'superseded' | string;
232
+ title?: string;
233
+ related_items?: string[];
234
+ superseded_by?: string;
235
+ [k: string]: unknown;
236
+ };
237
+ /** body content after frontmatter (markdown) */
238
+ body: string;
239
+ }
240
+
203
241
  export interface LayerContent {
204
242
  scope: string;
205
243
  layer: string;
@@ -3,14 +3,16 @@ import type { TenboState } from '../../types';
3
3
  import { OverviewTab } from './tabs/OverviewTab';
4
4
  import { PrinciplesTab } from './tabs/PrinciplesTab';
5
5
  import { GlossaryTab } from './tabs/GlossaryTab';
6
+ import { DecisionsTab } from './tabs/DecisionsTab';
6
7
  import styles from './WorkspacePage.module.css';
7
8
 
8
- const TABS: WorkspaceTab[] = ['overview', 'principles', 'glossary'];
9
+ const TABS: WorkspaceTab[] = ['overview', 'principles', 'glossary', 'decisions'];
9
10
 
10
11
  const TAB_LABELS: Record<WorkspaceTab, string> = {
11
12
  'overview': 'Project overview',
12
13
  'principles': 'Principles',
13
14
  'glossary': 'Glossary',
15
+ 'decisions': 'Decisions',
14
16
  };
15
17
 
16
18
  export function WorkspacePage(props: { tab: WorkspaceTab; navigate: (r: Route) => void; state: TenboState }) {
@@ -30,6 +32,7 @@ export function WorkspacePage(props: { tab: WorkspaceTab; navigate: (r: Route) =
30
32
  {props.tab === 'overview' && <OverviewTab state={props.state} navigate={props.navigate} />}
31
33
  {props.tab === 'principles' && <PrinciplesTab state={props.state} />}
32
34
  {props.tab === 'glossary' && <GlossaryTab state={props.state} />}
35
+ {props.tab === 'decisions' && <DecisionsTab state={props.state} />}
33
36
  </div>
34
37
  </div>
35
38
  );
@@ -0,0 +1,176 @@
1
+ .layout {
2
+ display: grid;
3
+ grid-template-columns: 280px 1fr;
4
+ gap: 24px;
5
+ align-items: start;
6
+ }
7
+
8
+ .list {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 4px;
12
+ border-right: 1px solid var(--console-mist);
13
+ padding-right: 12px;
14
+ max-height: calc(100vh - 200px);
15
+ overflow-y: auto;
16
+ }
17
+
18
+ .listItem {
19
+ display: block;
20
+ width: 100%;
21
+ text-align: left;
22
+ background: transparent;
23
+ border: 1px solid transparent;
24
+ border-radius: 4px;
25
+ padding: 8px 10px;
26
+ cursor: pointer;
27
+ font: inherit;
28
+ color: inherit;
29
+ }
30
+ .listItem:hover {
31
+ background: var(--console-mist, #f0f0f0);
32
+ }
33
+ .listItemActive {
34
+ background: var(--console-mist, #f0f0f0);
35
+ border-color: var(--console-fog, #ccc);
36
+ }
37
+ .listItemSuperseded {
38
+ opacity: 0.6;
39
+ }
40
+
41
+ .itemTitleRow {
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ gap: 8px;
46
+ margin-bottom: 4px;
47
+ }
48
+ .itemTitle {
49
+ font-size: 13px;
50
+ font-weight: 600;
51
+ line-height: 1.3;
52
+ flex: 1;
53
+ min-width: 0;
54
+ }
55
+
56
+ .itemMeta {
57
+ display: flex;
58
+ justify-content: space-between;
59
+ gap: 8px;
60
+ font-size: 11px;
61
+ color: var(--console-fog, #888);
62
+ }
63
+ .itemSlug {
64
+ font-family: ui-monospace, SFMono-Regular, monospace;
65
+ overflow: hidden;
66
+ text-overflow: ellipsis;
67
+ white-space: nowrap;
68
+ }
69
+ .itemDate {
70
+ flex-shrink: 0;
71
+ }
72
+ .itemDateMissing {
73
+ color: #b58900;
74
+ flex-shrink: 0;
75
+ }
76
+
77
+ .badge {
78
+ font-size: 10px;
79
+ font-weight: 600;
80
+ text-transform: uppercase;
81
+ letter-spacing: 0.04em;
82
+ padding: 2px 6px;
83
+ border-radius: 3px;
84
+ flex-shrink: 0;
85
+ }
86
+ .badgeAccepted {
87
+ background: #d4edda;
88
+ color: #155724;
89
+ }
90
+ .badgeSuperseded {
91
+ background: #e2e3e5;
92
+ color: #495057;
93
+ text-decoration: line-through;
94
+ }
95
+ .badgeUnknown {
96
+ background: #fff3cd;
97
+ color: #856404;
98
+ }
99
+
100
+ .detail {
101
+ min-width: 0;
102
+ }
103
+ .detailHeader {
104
+ border-bottom: 1px solid var(--console-mist);
105
+ padding-bottom: 12px;
106
+ margin-bottom: 16px;
107
+ }
108
+ .detailTitleRow {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 12px;
112
+ margin-bottom: 4px;
113
+ }
114
+ .detailTitle {
115
+ font-size: 20px;
116
+ font-weight: 700;
117
+ margin: 0;
118
+ line-height: 1.3;
119
+ }
120
+ .detailMeta {
121
+ display: flex;
122
+ gap: 12px;
123
+ font-size: 12px;
124
+ color: var(--console-fog, #888);
125
+ font-family: ui-monospace, SFMono-Regular, monospace;
126
+ }
127
+
128
+ .supersededBanner {
129
+ margin-top: 10px;
130
+ padding: 8px 10px;
131
+ background: #fff3cd;
132
+ border-left: 3px solid #ffc107;
133
+ color: #5c4a00;
134
+ font-size: 13px;
135
+ border-radius: 2px;
136
+ text-align: left;
137
+ }
138
+ .successorLink {
139
+ background: none;
140
+ border: none;
141
+ padding: 0;
142
+ font: inherit;
143
+ color: var(--link, #0366d6);
144
+ text-decoration: underline;
145
+ cursor: pointer;
146
+ }
147
+ .successorLink:hover {
148
+ text-decoration: none;
149
+ }
150
+
151
+ .warn {
152
+ color: #b58900;
153
+ }
154
+
155
+ .emptyOuter {
156
+ max-width: 600px;
157
+ padding: 16px 0;
158
+ }
159
+ .emptyHeading {
160
+ font-size: 16px;
161
+ font-weight: 600;
162
+ margin: 0 0 12px;
163
+ }
164
+ .emptyBody {
165
+ font-size: 14px;
166
+ line-height: 1.5;
167
+ color: var(--console-fog, #555);
168
+ margin: 0 0 12px;
169
+ }
170
+ .emptyBody code {
171
+ font-family: ui-monospace, SFMono-Regular, monospace;
172
+ font-size: 12px;
173
+ background: var(--console-mist, #f0f0f0);
174
+ padding: 1px 4px;
175
+ border-radius: 3px;
176
+ }
@@ -0,0 +1,194 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { Markdown } from '../../Markdown';
3
+ import type { TenboState, DecisionRecord } from '../../../types';
4
+ import styles from './DecisionsTab.module.css';
5
+
6
+ function statusOf(d: DecisionRecord): 'accepted' | 'superseded' | 'unknown' {
7
+ const s = d.frontmatter.status;
8
+ if (s === 'accepted' || s === 'superseded') return s;
9
+ return 'unknown';
10
+ }
11
+
12
+ function titleOf(d: DecisionRecord): string {
13
+ const t = d.frontmatter.title;
14
+ if (typeof t === 'string' && t.trim()) return t;
15
+ return d.slug;
16
+ }
17
+
18
+ function dateOf(d: DecisionRecord): string | null {
19
+ const v = d.frontmatter.date;
20
+ if (typeof v === 'string' && v.trim()) return v;
21
+ if (v && typeof v === 'object' && Object.prototype.toString.call(v) === '[object Date]') {
22
+ return (v as Date).toISOString().slice(0, 10);
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function DecisionsTab({ state }: { state: TenboState }) {
28
+ const decisions = state.decisions;
29
+
30
+ const sorted = useMemo(() => {
31
+ if (!decisions) return [];
32
+ const list = Object.values(decisions);
33
+ // Sort: accepted before superseded, then by date desc, then by slug.
34
+ list.sort((a, b) => {
35
+ const sa = statusOf(a);
36
+ const sb = statusOf(b);
37
+ const supA = sa === 'superseded' ? 1 : 0;
38
+ const supB = sb === 'superseded' ? 1 : 0;
39
+ if (supA !== supB) return supA - supB;
40
+ const da = dateOf(a) ?? '';
41
+ const db = dateOf(b) ?? '';
42
+ if (da !== db) return db.localeCompare(da);
43
+ return a.slug.localeCompare(b.slug);
44
+ });
45
+ return list;
46
+ }, [decisions]);
47
+
48
+ const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
49
+ const selected = selectedSlug ? decisions?.[selectedSlug] ?? null : sorted[0] ?? null;
50
+ const activeSlug = selected?.slug ?? null;
51
+
52
+ if (!decisions) {
53
+ return (
54
+ <div className={styles.emptyOuter}>
55
+ <h2 className={styles.emptyHeading}>No decision records yet</h2>
56
+ <p className={styles.emptyBody}>
57
+ Decision records capture project-level choices that a future audit
58
+ would otherwise re-suggest without context — &ldquo;we considered X, declined
59
+ because Y.&rdquo; They live in <code>.tenbo/decisions/&lt;slug&gt;.md</code>.
60
+ </p>
61
+ <p className={styles.emptyBody}>
62
+ See <code>skill/templates/decision.md.tmpl</code> for the file shape
63
+ (frontmatter + Context / Decision / Consequences / When to revisit).
64
+ </p>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ if (sorted.length === 0) {
70
+ return (
71
+ <div className={styles.emptyOuter}>
72
+ <p className={styles.emptyBody}>
73
+ The <code>.tenbo/decisions/</code> directory exists but contains no
74
+ records yet.
75
+ </p>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <div className={styles.layout}>
82
+ <aside className={styles.list} aria-label="Decision records">
83
+ {sorted.map((d) => {
84
+ const status = statusOf(d);
85
+ const date = dateOf(d);
86
+ const isActive = d.slug === activeSlug;
87
+ return (
88
+ <button
89
+ key={d.slug}
90
+ type="button"
91
+ className={`${styles.listItem} ${isActive ? styles.listItemActive : ''} ${status === 'superseded' ? styles.listItemSuperseded : ''}`}
92
+ onClick={() => setSelectedSlug(d.slug)}
93
+ aria-pressed={isActive}
94
+ >
95
+ <div className={styles.itemTitleRow}>
96
+ <span className={styles.itemTitle}>{titleOf(d)}</span>
97
+ <span
98
+ className={`${styles.badge} ${
99
+ status === 'accepted' ? styles.badgeAccepted :
100
+ status === 'superseded' ? styles.badgeSuperseded :
101
+ styles.badgeUnknown
102
+ }`}
103
+ >
104
+ {status === 'unknown' ? '?' : status}
105
+ </span>
106
+ </div>
107
+ <div className={styles.itemMeta}>
108
+ <span className={styles.itemSlug}>{d.slug}</span>
109
+ {date ? (
110
+ <span className={styles.itemDate}>{date}</span>
111
+ ) : (
112
+ <span className={styles.itemDateMissing} title="Missing date in frontmatter">
113
+ no date
114
+ </span>
115
+ )}
116
+ </div>
117
+ </button>
118
+ );
119
+ })}
120
+ </aside>
121
+
122
+ <section className={styles.detail}>
123
+ {selected ? (
124
+ <DecisionDetail decision={selected} all={decisions} onSelect={setSelectedSlug} />
125
+ ) : (
126
+ <p>Select a decision to view its body.</p>
127
+ )}
128
+ </section>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function DecisionDetail({
134
+ decision,
135
+ all,
136
+ onSelect,
137
+ }: {
138
+ decision: DecisionRecord;
139
+ all: Record<string, DecisionRecord>;
140
+ onSelect: (slug: string) => void;
141
+ }) {
142
+ const status = statusOf(decision);
143
+ const date = dateOf(decision);
144
+ const supersededBy = decision.frontmatter.superseded_by;
145
+ const successor = typeof supersededBy === 'string' ? all[supersededBy] : undefined;
146
+
147
+ return (
148
+ <article>
149
+ <header className={styles.detailHeader}>
150
+ <div className={styles.detailTitleRow}>
151
+ <h1 className={styles.detailTitle}>{titleOf(decision)}</h1>
152
+ <span
153
+ className={`${styles.badge} ${
154
+ status === 'accepted' ? styles.badgeAccepted :
155
+ status === 'superseded' ? styles.badgeSuperseded :
156
+ styles.badgeUnknown
157
+ }`}
158
+ >
159
+ {status === 'unknown' ? '?' : status}
160
+ </span>
161
+ </div>
162
+ <div className={styles.detailMeta}>
163
+ <span>{decision.path}</span>
164
+ {date ? <span>{date}</span> : <span className={styles.warn}>no date in frontmatter</span>}
165
+ </div>
166
+ {status === 'superseded' && (
167
+ <div className={styles.supersededBanner}>
168
+ Superseded
169
+ {typeof supersededBy === 'string' ? (
170
+ <>
171
+ {' by '}
172
+ {successor ? (
173
+ <button
174
+ type="button"
175
+ className={styles.successorLink}
176
+ onClick={() => onSelect(successor.slug)}
177
+ >
178
+ {titleOf(successor)} ({successor.slug})
179
+ </button>
180
+ ) : (
181
+ <code>{supersededBy}</code>
182
+ )}
183
+ {!successor && <span className={styles.warn}> — successor not found</span>}
184
+ </>
185
+ ) : (
186
+ <span className={styles.warn}> — no superseded_by reference</span>
187
+ )}
188
+ </div>
189
+ )}
190
+ </header>
191
+ <Markdown source={decision.body} />
192
+ </article>
193
+ );
194
+ }