tenbo-dashboard 0.5.0 → 0.7.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.
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validate } from './validator';
3
+ import type { TenboState, Item } from '../../types';
4
+
5
+ const baseItem = (over: Partial<Item> = {}): Item => ({
6
+ id: 'ed-001',
7
+ title: 't',
8
+ layer: 'l',
9
+ status: 'later',
10
+ description: 'a roadmap item used for duplicate-id tests now',
11
+ ...over,
12
+ });
13
+
14
+ function stateWithScopeItems(items: Item[], archived: Item[] = []): TenboState {
15
+ return {
16
+ scopes: [{
17
+ id: 'e',
18
+ path: '.',
19
+ description: 'editor scope description here for tests',
20
+ layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
21
+ items,
22
+ archivedItems: archived,
23
+ }],
24
+ crossCutting: [],
25
+ narratives: { 'e/l': '# L' },
26
+ } as any;
27
+ }
28
+
29
+ const dupErrors = (state: TenboState) =>
30
+ validate(state).errors.filter((e) => /^duplicate item id/.test(e.message)).map((e) => e.message);
31
+
32
+ describe('validator — duplicate item ids', () => {
33
+ it('emits no duplicate-id error when every id appears once', () => {
34
+ const state = stateWithScopeItems([
35
+ baseItem({ id: 'ed-001' }),
36
+ baseItem({ id: 'ed-002' }),
37
+ baseItem({ id: 'ed-003' }),
38
+ ]);
39
+ expect(dupErrors(state)).toEqual([]);
40
+ });
41
+
42
+ it('errors when two active items share an id', () => {
43
+ const state = stateWithScopeItems([
44
+ baseItem({ id: 'ed-001' }),
45
+ baseItem({ id: 'ed-001', title: 'dup' }),
46
+ ]);
47
+ const msgs = dupErrors(state);
48
+ expect(msgs.length).toBe(1);
49
+ expect(msgs[0]).toMatch(/duplicate item id "ed-001"/);
50
+ expect(msgs[0]).toMatch(/active/);
51
+ });
52
+
53
+ it('errors when an id appears once in active and once in archive', () => {
54
+ const state = stateWithScopeItems(
55
+ [baseItem({ id: 'ed-001' })],
56
+ [baseItem({ id: 'ed-001', status: 'done' })],
57
+ );
58
+ const msgs = dupErrors(state);
59
+ expect(msgs.length).toBe(1);
60
+ expect(msgs[0]).toMatch(/active/);
61
+ expect(msgs[0]).toMatch(/archive/);
62
+ });
63
+
64
+ it('errors when an id appears in cross-cutting and a scope', () => {
65
+ const state: TenboState = {
66
+ ...stateWithScopeItems([baseItem({ id: 'x-001' })]),
67
+ crossCuttingRoadmap: [
68
+ { id: 'x-001', title: 'cross', status: 'now', description: 'a cross-cutting item', layers: [] } as any,
69
+ ],
70
+ };
71
+ const msgs = dupErrors(state);
72
+ expect(msgs.length).toBe(1);
73
+ expect(msgs[0]).toMatch(/cross-cutting/);
74
+ });
75
+
76
+ it('emits one error per duplicated id, not one per duplicate occurrence', () => {
77
+ const state = stateWithScopeItems([
78
+ baseItem({ id: 'ed-001' }),
79
+ baseItem({ id: 'ed-001' }),
80
+ baseItem({ id: 'ed-002' }),
81
+ baseItem({ id: 'ed-002' }),
82
+ baseItem({ id: 'ed-003' }),
83
+ ]);
84
+ const msgs = dupErrors(state);
85
+ expect(msgs.length).toBe(2);
86
+ expect(msgs.some((m) => m.includes('ed-001'))).toBe(true);
87
+ expect(msgs.some((m) => m.includes('ed-002'))).toBe(true);
88
+ });
89
+ });
@@ -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
+ });
@@ -0,0 +1,67 @@
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 preflight tests now',
25
+ done_when: ['it works'],
26
+ goal_ref: ['g1'],
27
+ ...over,
28
+ });
29
+
30
+ const warningsFor = (state: TenboState, id: string) =>
31
+ validate(state).warnings.filter((w) => w.itemId === id).map((w) => w.message);
32
+
33
+ describe('validator — preflight_violations (sk-029)', () => {
34
+ it('does not warn when item has no preflight_violations', () => {
35
+ const state = stateWithItems([baseItem()]);
36
+ const msgs = warningsFor(state, 'ed-001');
37
+ expect(msgs.some((m) => m.includes('pre-flight violation'))).toBe(false);
38
+ });
39
+
40
+ it('warns when an active item has an accepted pre-flight violation', () => {
41
+ const state = stateWithItems([baseItem({
42
+ preflight_violations: [{
43
+ check: 'ui-components anti-responsibility: no direct fs writes',
44
+ outcome: 'violation',
45
+ decision: 'accept-with-violation',
46
+ rationale: 'one-off debug helper',
47
+ }],
48
+ })]);
49
+ const msgs = warningsFor(state, 'ed-001');
50
+ expect(msgs.some((m) => m.includes('accepted 1 pre-flight violation'))).toBe(true);
51
+ });
52
+
53
+ it('does not warn for done or dropped items even with accepted violations', () => {
54
+ for (const status of ['done', 'dropped'] as const) {
55
+ const state = stateWithItems([baseItem({
56
+ status,
57
+ preflight_violations: [{
58
+ check: 'ui-components anti-responsibility: no direct fs writes',
59
+ outcome: 'violation',
60
+ decision: 'accept-with-violation',
61
+ }],
62
+ })]);
63
+ const msgs = warningsFor(state, 'ed-001');
64
+ expect(msgs.some((m) => m.includes('pre-flight violation'))).toBe(false);
65
+ }
66
+ });
67
+ });
@@ -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', () => {
@@ -30,6 +30,39 @@ export function validate(state: TenboState): ValidateResult {
30
30
  }
31
31
  for (const item of state.crossCuttingRoadmap ?? []) allItemIds.add(item.id);
32
32
 
33
+ // Duplicate-ID detection (sk-031). Walks active + archived + cross-cutting
34
+ // items, recording every (id → location) occurrence. An ID appearing more
35
+ // than once anywhere in the workspace is a hard data-integrity error: it
36
+ // breaks relationship validation, history (active vs archive), and item
37
+ // identity. Emits one error per duplicated ID listing all locations.
38
+ const idOccurrences = new Map<string, string[]>();
39
+ const recordOccurrence = (id: string, where: string) => {
40
+ if (!id || typeof id !== 'string') return;
41
+ const arr = idOccurrences.get(id) ?? [];
42
+ arr.push(where);
43
+ idOccurrences.set(id, arr);
44
+ };
45
+ for (const scope of state.scopes) {
46
+ for (const item of scope.items) {
47
+ recordOccurrence(item.id, `scopes/${scope.id}/roadmap.yaml (active)`);
48
+ }
49
+ for (const item of scope.archivedItems ?? []) {
50
+ recordOccurrence(item.id, `scopes/${scope.id}/roadmap-archive.yaml`);
51
+ }
52
+ }
53
+ for (const item of state.crossCuttingRoadmap ?? []) {
54
+ recordOccurrence(item.id, `.tenbo/roadmap.yaml (cross-cutting)`);
55
+ }
56
+ for (const [id, locs] of idOccurrences) {
57
+ if (locs.length > 1) {
58
+ errors.push({
59
+ level: 'error',
60
+ message: `duplicate item id "${id}" — appears in ${locs.join(' and ')}`,
61
+ itemId: id,
62
+ });
63
+ }
64
+ }
65
+
33
66
  const validateSupersededBy = (item: Item, scopeId?: string) => {
34
67
  if (item.superseded_by === undefined) return;
35
68
  const sb = item.superseded_by;
@@ -191,16 +224,12 @@ export function validate(state: TenboState): ValidateResult {
191
224
  }
192
225
  }
193
226
 
194
- // Item rules
195
- const seenIds = new Set<string>();
227
+ // Item rules. Note: cross-scope/active+archive duplicate-id detection runs
228
+ // once at the top of validate() (sk-031) — no per-scope seenIds needed.
196
229
  for (const item of scope.items) {
197
230
  if (!/^[a-z]{1,5}-\d{3,}$/.test(item.id)) {
198
231
  errors.push({ level: 'error', message: `item "${item.id}" has invalid id format (expected <prefix>-NNN, e.g. ed-001 or x-001)`, scope: scope.id, itemId: item.id });
199
232
  }
200
- if (seenIds.has(item.id)) {
201
- errors.push({ level: 'error', message: `duplicate item id "${item.id}"`, scope: scope.id, itemId: item.id });
202
- }
203
- seenIds.add(item.id);
204
233
  // Status is optional when phases are present (it's derived via roll-up).
205
234
  // Only validate when explicitly set.
206
235
  const hasPhases = Array.isArray(item.phases) && item.phases.length > 0;
@@ -277,6 +306,91 @@ export function validate(state: TenboState): ValidateResult {
277
306
  }
278
307
  }
279
308
 
309
+ // Goal-ref shape validation (sk-025). Optional field; warn (do not error)
310
+ // when missing or malformed. Shape-check only — does NOT cross-reference
311
+ // against `overview.md` Product goals (deferred per sk-022 risks).
312
+ // Skip the missing-warning for done/dropped items: no value in nagging
313
+ // about completed work.
314
+ if (item.goal_ref === undefined) {
315
+ if (item.status !== 'done' && item.status !== 'dropped') {
316
+ warnings.push({
317
+ level: 'warning',
318
+ message: `item ${item.id} has no goal_ref — backfill with cited goals or "exploratory"`,
319
+ scope: scope.id,
320
+ itemId: item.id,
321
+ });
322
+ }
323
+ } else if (item.goal_ref === 'exploratory') {
324
+ // Valid literal — no warning.
325
+ } else if (Array.isArray(item.goal_ref)) {
326
+ if (item.goal_ref.length === 0) {
327
+ warnings.push({
328
+ level: 'warning',
329
+ message: `item ${item.id}.goal_ref is an empty array — use "exploratory" if no goals identified, or remove the field`,
330
+ scope: scope.id,
331
+ itemId: item.id,
332
+ });
333
+ } else {
334
+ for (const ref of item.goal_ref) {
335
+ if (typeof ref !== 'string') {
336
+ warnings.push({
337
+ level: 'warning',
338
+ message: `item ${item.id}.goal_ref must be a string array or the literal "exploratory" — got: ${typeof ref} entry`,
339
+ scope: scope.id,
340
+ itemId: item.id,
341
+ });
342
+ break;
343
+ }
344
+ }
345
+ }
346
+ } else {
347
+ warnings.push({
348
+ level: 'warning',
349
+ message: `item ${item.id}.goal_ref must be a string array or the literal "exploratory" — got: ${typeof item.goal_ref}`,
350
+ scope: scope.id,
351
+ itemId: item.id,
352
+ });
353
+ }
354
+
355
+ // Pre-flight violations (sk-029). Optional field; surface as a health
356
+ // observation (warning) when an item carries any accept-with-violation
357
+ // entry. Skip done/dropped items — no value nagging completed work.
358
+ // Shape-check only: validate array shape and enum values; don't
359
+ // cross-validate `check` strings against any taxonomy.
360
+ if (item.preflight_violations !== undefined) {
361
+ if (!Array.isArray(item.preflight_violations)) {
362
+ errors.push({ level: 'error', message: `item "${item.id}" preflight_violations must be a list`, scope: scope.id, itemId: item.id });
363
+ } else {
364
+ const VALID_OUTCOMES = new Set(['violation', 'threshold-cross', 'dependency-direction']);
365
+ const VALID_DECISIONS = new Set(['accept-with-violation', 'scope-adjusted', 'deferred']);
366
+ let acceptedCount = 0;
367
+ for (const entry of item.preflight_violations) {
368
+ if (!entry || typeof entry !== 'object') {
369
+ errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry is not an object`, scope: scope.id, itemId: item.id });
370
+ continue;
371
+ }
372
+ if (typeof (entry as any).check !== 'string' || (entry as any).check.trim() === '') {
373
+ errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry missing string "check"`, scope: scope.id, itemId: item.id });
374
+ }
375
+ if (!VALID_OUTCOMES.has((entry as any).outcome)) {
376
+ errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry has invalid outcome "${(entry as any).outcome}"`, scope: scope.id, itemId: item.id });
377
+ }
378
+ if (!VALID_DECISIONS.has((entry as any).decision)) {
379
+ errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry has invalid decision "${(entry as any).decision}"`, scope: scope.id, itemId: item.id });
380
+ }
381
+ if ((entry as any).decision === 'accept-with-violation') acceptedCount++;
382
+ }
383
+ if (acceptedCount > 0 && item.status !== 'done' && item.status !== 'dropped') {
384
+ warnings.push({
385
+ level: 'warning',
386
+ message: `item ${item.id} has accepted ${acceptedCount} pre-flight violation(s) — review at next audit`,
387
+ scope: scope.id,
388
+ itemId: item.id,
389
+ });
390
+ }
391
+ }
392
+ }
393
+
280
394
  // Phase schema validation (Phase 3 of x-001).
281
395
  if (item.phases !== undefined) {
282
396
  if (!Array.isArray(item.phases)) {
@@ -324,6 +438,48 @@ export function validate(state: TenboState): ValidateResult {
324
438
  }
325
439
  }
326
440
 
441
+ // Decision records (sk-020). Validate frontmatter shape on every file under
442
+ // `.tenbo/decisions/`. Additive: state.decisions is undefined when the
443
+ // directory does not exist, so existing repos without decisions/ stay quiet.
444
+ if (state.decisions) {
445
+ const VALID_DECISION_STATUSES = new Set(['accepted', 'superseded']);
446
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
447
+ const decisions = state.decisions;
448
+ for (const slug of Object.keys(decisions)) {
449
+ const rec = decisions[slug];
450
+ const fm = rec.frontmatter ?? {};
451
+ const where = rec.path;
452
+ // Required fields → errors.
453
+ for (const field of ['id', 'date', 'status', 'title'] as const) {
454
+ const v = fm[field];
455
+ if (v === undefined || v === null || (typeof v === 'string' && v.trim() === '')) {
456
+ errors.push({ level: 'error', message: `decision ${where} is missing required frontmatter field "${field}"` });
457
+ }
458
+ }
459
+ if (typeof fm.status === 'string' && !VALID_DECISION_STATUSES.has(fm.status)) {
460
+ errors.push({ level: 'error', message: `decision ${where} has invalid status "${fm.status}" (expected accepted|superseded)` });
461
+ }
462
+ if (fm.date !== undefined && typeof fm.date === 'string' && fm.date.trim() !== '' && !ISO_DATE_RE.test(fm.date.trim())) {
463
+ warnings.push({ level: 'warning', message: `decision ${where} date "${fm.date}" is not YYYY-MM-DD` });
464
+ }
465
+ if (fm.status === 'superseded') {
466
+ const sb = fm.superseded_by;
467
+ if (typeof sb !== 'string' || sb.trim() === '') {
468
+ errors.push({ level: 'error', message: `decision ${where} has status:superseded but no superseded_by` });
469
+ } else if (!state.decisions[sb]) {
470
+ errors.push({ level: 'error', message: `decision ${where} superseded_by "${sb}" does not match a sibling decision file` });
471
+ } else if (sb === slug) {
472
+ errors.push({ level: 'error', message: `decision ${where} superseded_by cannot reference itself` });
473
+ }
474
+ } else if (fm.superseded_by !== undefined && fm.status !== 'superseded') {
475
+ warnings.push({ level: 'warning', message: `decision ${where} has superseded_by but status is "${fm.status ?? 'unset'}"` });
476
+ }
477
+ if (fm.related_items !== undefined && !Array.isArray(fm.related_items)) {
478
+ warnings.push({ level: 'warning', message: `decision ${where} related_items should be a list` });
479
+ }
480
+ }
481
+ }
482
+
327
483
  const allRefs = new Set(state.scopes.flatMap(s => s.layers.map(l => `${s.id}:${l.id}`)));
328
484
  for (const item of state.crossCuttingRoadmap ?? []) {
329
485
  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
  /**
@@ -84,6 +93,18 @@ export interface Item {
84
93
  * refactor, bug} lacks this field.
85
94
  */
86
95
  doc_update?: string;
96
+ /**
97
+ * Pre-flight violations recorded at capture time when the user accepted an
98
+ * item despite a flagged violation. Each entry: which check fired, what the
99
+ * outcome was, what the user decided, and their rationale. Empty / undefined
100
+ * when item passed pre-flight cleanly. See sk-029.
101
+ */
102
+ preflight_violations?: Array<{
103
+ check: string; // e.g. "ui-components anti-responsibility: no direct fs writes"
104
+ outcome: 'violation' | 'threshold-cross' | 'dependency-direction';
105
+ decision: 'accept-with-violation' | 'scope-adjusted' | 'deferred';
106
+ rationale?: string;
107
+ }>;
87
108
  }
88
109
 
89
110
  export interface Layer {
@@ -161,6 +182,11 @@ export interface TenboState {
161
182
  * resolve to a real file. Absent when `.tenbo/specs/` does not exist.
162
183
  */
163
184
  specFiles?: Set<string>;
185
+ /**
186
+ * Decision records loaded from `.tenbo/decisions/`. Absent if the directory
187
+ * does not exist. Keyed by slug for `superseded_by` resolution.
188
+ */
189
+ decisions?: Record<string, DecisionRecord>;
164
190
  }
165
191
 
166
192
  export type ValidateLevel = 'error' | 'warning';
@@ -200,6 +226,30 @@ export interface WorkspaceContent {
200
226
  observationsMtime: number | null;
201
227
  }
202
228
 
229
+ /**
230
+ * Project-level decision record loaded from `.tenbo/decisions/<slug>.md`.
231
+ * Only created when a future audit would re-suggest the same thing without
232
+ * the rationale. See `skill/templates/decision.md.tmpl` for the shape.
233
+ */
234
+ export interface DecisionRecord {
235
+ /** repo-relative path, e.g. `.tenbo/decisions/foo.md` */
236
+ path: string;
237
+ /** filename slug without extension */
238
+ slug: string;
239
+ /** parsed frontmatter — values may be missing on malformed files */
240
+ frontmatter: {
241
+ id?: string;
242
+ date?: string;
243
+ status?: 'accepted' | 'superseded' | string;
244
+ title?: string;
245
+ related_items?: string[];
246
+ superseded_by?: string;
247
+ [k: string]: unknown;
248
+ };
249
+ /** body content after frontmatter (markdown) */
250
+ body: string;
251
+ }
252
+
203
253
  export interface LayerContent {
204
254
  scope: string;
205
255
  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
  );