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 +1 -1
- package/src/api/lib/tenboFs.ts +40 -1
- package/src/api/lib/validator.goalRef.test.ts +76 -0
- package/src/api/lib/validator.relationships.test.ts +7 -1
- package/src/api/lib/validator.specLinks.test.ts +5 -1
- package/src/api/lib/validator.ts +88 -0
- package/src/api/routes/watch.ts +2 -0
- package/src/hooks/useApiPatch.ts +3 -2
- package/src/router/routes.ts +1 -1
- package/src/types.ts +38 -0
- package/src/ui/WorkspacePage/WorkspacePage.tsx +4 -1
- package/src/ui/WorkspacePage/tabs/DecisionsTab.module.css +176 -0
- package/src/ui/WorkspacePage/tabs/DecisionsTab.tsx +194 -0
package/package.json
CHANGED
package/src/api/lib/tenboFs.ts
CHANGED
|
@@ -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
|
|
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
|
|
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', () => {
|
package/src/api/lib/validator.ts
CHANGED
|
@@ -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 ?? [])];
|
package/src/api/routes/watch.ts
CHANGED
|
@@ -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/**',
|
package/src/hooks/useApiPatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/router/routes.ts
CHANGED
|
@@ -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 — “we considered X, declined
|
|
59
|
+
because Y.” They live in <code>.tenbo/decisions/<slug>.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
|
+
}
|