tenbo-dashboard 0.6.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.
@@ -40,6 +40,7 @@ const commands = {
40
40
  'init-check': 'scripts/init-check.ts',
41
41
  sync: 'scripts/sync.ts',
42
42
  archive: 'scripts/archive.ts',
43
+ hook: 'scripts/hook.ts',
43
44
  };
44
45
 
45
46
  // Documented aliases for the bare-launch behavior. Users (and agents) reading
@@ -82,6 +83,9 @@ Usage:
82
83
  tenbo-dashboard metrics --all Compute scope metrics
83
84
  tenbo-dashboard archive Move old done/dropped items to roadmap-archive.yaml
84
85
  [--scope <id>] [--days 30] [--max 20]
86
+ tenbo-dashboard hook install Install opt-in pre-commit validation hook
87
+ [--dry-run] [--force]
88
+ tenbo-dashboard hook uninstall Remove the tenbo pre-commit hook (idempotent)
85
89
  tenbo-dashboard --version Print package version
86
90
  tenbo-dashboard help Show this help
87
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tenbo-dashboard",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {
6
+ planInstall,
7
+ applyInstall,
8
+ planUninstall,
9
+ applyUninstall,
10
+ formatInstallPlan,
11
+ stripHuskyBlock,
12
+ TENBO_HEADER_MARKER,
13
+ TENBO_HUSKY_BEGIN,
14
+ TENBO_HUSKY_END,
15
+ HOOK_BODY,
16
+ } from './hook';
17
+
18
+ let tmp: string;
19
+
20
+ beforeEach(() => {
21
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tenbo-hook-'));
22
+ fs.mkdirSync(path.join(tmp, '.git', 'hooks'), { recursive: true });
23
+ });
24
+
25
+ afterEach(() => {
26
+ fs.rmSync(tmp, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('planInstall', () => {
30
+ it('plans a standalone install when no pre-commit hook exists', () => {
31
+ const plan = planInstall({ repoRoot: tmp });
32
+ expect(plan.mode).toBe('standalone');
33
+ expect(plan.writes).toHaveLength(1);
34
+ expect(plan.moves).toHaveLength(0);
35
+ });
36
+
37
+ it('detects an already-installed tenbo hook (header present)', () => {
38
+ const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
39
+ fs.writeFileSync(hookPath, `#!/usr/bin/env sh\n${TENBO_HEADER_MARKER}\necho hi\n`);
40
+ const plan = planInstall({ repoRoot: tmp });
41
+ expect(plan.mode).toBe('already-installed');
42
+ expect(plan.writes).toHaveLength(0);
43
+ });
44
+
45
+ it('chains a foreign hook by moving it aside to .local', () => {
46
+ const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
47
+ fs.writeFileSync(hookPath, '#!/bin/sh\necho foreign\n');
48
+ const plan = planInstall({ repoRoot: tmp });
49
+ expect(plan.mode).toBe('chained');
50
+ expect(plan.moves[0].to.endsWith('.local')).toBe(true);
51
+ });
52
+
53
+ it('--force overwrites a foreign hook in place', () => {
54
+ const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
55
+ fs.writeFileSync(hookPath, '#!/bin/sh\necho foreign\n');
56
+ const plan = planInstall({ repoRoot: tmp, force: true });
57
+ expect(plan.mode).toBe('standalone');
58
+ expect(plan.moves).toHaveLength(0);
59
+ });
60
+
61
+ it('prefers husky integration when .husky/pre-commit exists', () => {
62
+ const huskyPath = path.join(tmp, '.husky', 'pre-commit');
63
+ fs.mkdirSync(path.dirname(huskyPath), { recursive: true });
64
+ fs.writeFileSync(huskyPath, '#!/bin/sh\nnpx lint-staged\n');
65
+ const plan = planInstall({ repoRoot: tmp });
66
+ expect(plan.mode).toBe('husky');
67
+ expect(plan.writes[0].path).toBe(huskyPath);
68
+ });
69
+ });
70
+
71
+ describe('applyInstall + applyUninstall round-trip', () => {
72
+ it('standalone install creates an executable hook with the tenbo header', () => {
73
+ const plan = planInstall({ repoRoot: tmp });
74
+ applyInstall(plan);
75
+ const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
76
+ const body = fs.readFileSync(hookPath, 'utf8');
77
+ expect(body).toBe(HOOK_BODY);
78
+ expect(body).toContain(TENBO_HEADER_MARKER);
79
+ expect((fs.statSync(hookPath).mode & 0o111) !== 0).toBe(true);
80
+ });
81
+
82
+ it('chained install preserves the foreign hook at .local and restores on uninstall', () => {
83
+ const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
84
+ const original = '#!/bin/sh\necho foreign-hook\n';
85
+ fs.writeFileSync(hookPath, original);
86
+ fs.chmodSync(hookPath, 0o755);
87
+
88
+ applyInstall(planInstall({ repoRoot: tmp }));
89
+ expect(fs.readFileSync(hookPath + '.local', 'utf8')).toBe(original);
90
+ expect(fs.readFileSync(hookPath, 'utf8')).toContain(TENBO_HEADER_MARKER);
91
+
92
+ applyUninstall(planUninstall(tmp));
93
+ expect(fs.existsSync(hookPath + '.local')).toBe(false);
94
+ expect(fs.readFileSync(hookPath, 'utf8')).toBe(original);
95
+ });
96
+
97
+ it('husky install appends a delimited block; uninstall removes only that block', () => {
98
+ const huskyPath = path.join(tmp, '.husky', 'pre-commit');
99
+ fs.mkdirSync(path.dirname(huskyPath), { recursive: true });
100
+ const original = '#!/bin/sh\nnpx lint-staged\n';
101
+ fs.writeFileSync(huskyPath, original);
102
+
103
+ applyInstall(planInstall({ repoRoot: tmp }));
104
+ const after = fs.readFileSync(huskyPath, 'utf8');
105
+ expect(after).toContain(TENBO_HUSKY_BEGIN);
106
+ expect(after).toContain(TENBO_HUSKY_END);
107
+ expect(after).toContain('npx lint-staged');
108
+
109
+ applyUninstall(planUninstall(tmp));
110
+ const restored = fs.readFileSync(huskyPath, 'utf8');
111
+ expect(restored).not.toContain(TENBO_HUSKY_BEGIN);
112
+ expect(restored).toContain('npx lint-staged');
113
+ });
114
+ });
115
+
116
+ describe('planUninstall', () => {
117
+ it('reports not-installed when no hook exists', () => {
118
+ expect(planUninstall(tmp).mode).toBe('not-installed');
119
+ });
120
+
121
+ it('leaves foreign hooks alone (not-installed)', () => {
122
+ fs.writeFileSync(path.join(tmp, '.git', 'hooks', 'pre-commit'), '#!/bin/sh\necho foreign\n');
123
+ expect(planUninstall(tmp).mode).toBe('not-installed');
124
+ });
125
+ });
126
+
127
+ describe('stripHuskyBlock', () => {
128
+ it('removes only the tenbo block, preserving surrounding lines', () => {
129
+ const input = `line one\n\n${TENBO_HUSKY_BEGIN}\necho tenbo\n${TENBO_HUSKY_END}\nline two\n`;
130
+ const out = stripHuskyBlock(input);
131
+ expect(out).toContain('line one');
132
+ expect(out).toContain('line two');
133
+ expect(out).not.toContain('echo tenbo');
134
+ expect(out).not.toContain(TENBO_HUSKY_BEGIN);
135
+ });
136
+ });
137
+
138
+ describe('formatInstallPlan', () => {
139
+ it('renders a standalone plan with mode + writes', () => {
140
+ const out = formatInstallPlan(planInstall({ repoRoot: tmp }));
141
+ expect(out).toContain('Install mode: standalone');
142
+ expect(out).toContain('write:');
143
+ });
144
+ });
@@ -0,0 +1,402 @@
1
+ /**
2
+ * hook.ts — install / uninstall the opt-in tenbo pre-commit hook.
3
+ *
4
+ * Runs the deterministic validator (`tenbo-dashboard validate --strict`) at
5
+ * commit time so duplicate-IDs, broken refs, and schema violations surface
6
+ * BEFORE bad shapes enter history. Tier 1 only — no LLM, sub-500ms.
7
+ *
8
+ * Subcommands (from `tenbo-dashboard hook ...`):
9
+ * install [--dry-run] [--force]
10
+ * uninstall
11
+ * status (introspect — useful for tests + users)
12
+ *
13
+ * Install strategy (in order of preference):
14
+ * 1. If `.husky/pre-commit` exists → append a clearly-delimited tenbo block
15
+ * to the husky file. Less surprising for husky users than touching
16
+ * `.git/hooks/`.
17
+ * 2. Else if `.git/hooks/pre-commit` is absent → create it standalone with
18
+ * the tenbo header.
19
+ * 3. Else if existing `.git/hooks/pre-commit` already carries the tenbo
20
+ * header → no-op (already installed).
21
+ * 4. Else (foreign hook present) → chained-hook pattern: move existing to
22
+ * `pre-commit.local`, write a new `pre-commit` that runs the local one
23
+ * first (preserving exit code) then the tenbo check.
24
+ *
25
+ * Uninstall is surgical and idempotent — only undoes what install added.
26
+ *
27
+ * Written in vanilla TS (no external deps) and exported as pure functions
28
+ * for unit testing. The `if (isMain())` block runs the CLI side-effects.
29
+ */
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { findRepoRoot } from '../src/api/lib/repoRoot';
33
+
34
+ export const TENBO_HEADER_MARKER = '# tenbo-managed pre-commit hook (sk-032)';
35
+ export const TENBO_HUSKY_BEGIN = '### tenbo-managed (sk-032) BEGIN ###';
36
+ export const TENBO_HUSKY_END = '### tenbo-managed (sk-032) END ###';
37
+
38
+ /** The body of the pre-commit script the hook runs (POSIX sh, no bash-isms). */
39
+ export const HOOK_BODY = `#!/usr/bin/env sh
40
+ ${TENBO_HEADER_MARKER}
41
+ # Runs the deterministic validator on .tenbo/ before allowing commit.
42
+ # Errors block commit; warnings print but don't block.
43
+ # Bypass with \`git commit --no-verify\`.
44
+
45
+ # Skip if no .tenbo/ in repo (graceful no-op for non-tenbo repos)
46
+ if [ ! -d .tenbo ]; then exit 0; fi
47
+
48
+ # Run validate with strict mode
49
+ npx --no-install tenbo-dashboard validate --strict
50
+ `;
51
+
52
+ /** Husky-style block, appended to an existing .husky/pre-commit file. */
53
+ export const HUSKY_BLOCK = `\n${TENBO_HUSKY_BEGIN}
54
+ if [ -d .tenbo ]; then
55
+ npx --no-install tenbo-dashboard validate --strict || exit $?
56
+ fi
57
+ ${TENBO_HUSKY_END}\n`;
58
+
59
+ /** Chained-hook pre-commit body when a foreign hook was present. */
60
+ export function chainedHookBody(localHookRelPath: string): string {
61
+ return `#!/usr/bin/env sh
62
+ ${TENBO_HEADER_MARKER}
63
+ # Chained install — runs the previously-installed hook first, then tenbo's
64
+ # validator. Uninstalling restores the local hook to its original location.
65
+
66
+ LOCAL_HOOK="$(dirname "$0")/${path.basename(localHookRelPath)}"
67
+ if [ -x "$LOCAL_HOOK" ]; then
68
+ "$LOCAL_HOOK" "$@" || exit $?
69
+ fi
70
+
71
+ if [ -d .tenbo ]; then
72
+ npx --no-install tenbo-dashboard validate --strict
73
+ fi
74
+ `;
75
+ }
76
+
77
+ export type InstallMode = 'standalone' | 'chained' | 'husky' | 'already-installed';
78
+
79
+ export interface InstallPlan {
80
+ mode: InstallMode;
81
+ /** Absolute paths the install would write/move. */
82
+ writes: { path: string; reason: string; chmod?: boolean }[];
83
+ moves: { from: string; to: string }[];
84
+ notes: string[];
85
+ }
86
+
87
+ export interface InstallContext {
88
+ repoRoot: string;
89
+ /** Whether to overwrite a foreign hook in place (skips chaining). */
90
+ force?: boolean;
91
+ }
92
+
93
+ function isTenboManaged(content: string): boolean {
94
+ return content.includes(TENBO_HEADER_MARKER) || content.includes(TENBO_HUSKY_BEGIN);
95
+ }
96
+
97
+ /** Compute the install plan WITHOUT touching the filesystem. */
98
+ export function planInstall(ctx: InstallContext): InstallPlan {
99
+ const huskyPath = path.join(ctx.repoRoot, '.husky', 'pre-commit');
100
+ const hookPath = path.join(ctx.repoRoot, '.git', 'hooks', 'pre-commit');
101
+
102
+ // Husky integration: prefer if .husky/pre-commit exists.
103
+ if (fs.existsSync(huskyPath)) {
104
+ const content = fs.readFileSync(huskyPath, 'utf8');
105
+ if (isTenboManaged(content)) {
106
+ return {
107
+ mode: 'already-installed',
108
+ writes: [],
109
+ moves: [],
110
+ notes: [`tenbo block already present in ${huskyPath}`],
111
+ };
112
+ }
113
+ return {
114
+ mode: 'husky',
115
+ writes: [{ path: huskyPath, reason: 'append tenbo block to existing husky pre-commit', chmod: true }],
116
+ moves: [],
117
+ notes: [
118
+ `Detected husky at ${huskyPath} — appending tenbo block (delimited by ${TENBO_HUSKY_BEGIN} / ${TENBO_HUSKY_END}).`,
119
+ ],
120
+ };
121
+ }
122
+
123
+ // No husky. Look at .git/hooks/pre-commit.
124
+ if (!fs.existsSync(hookPath)) {
125
+ return {
126
+ mode: 'standalone',
127
+ writes: [{ path: hookPath, reason: 'create new pre-commit hook (no existing hook found)', chmod: true }],
128
+ moves: [],
129
+ notes: ['No existing pre-commit hook found — installing standalone.'],
130
+ };
131
+ }
132
+
133
+ const existing = fs.readFileSync(hookPath, 'utf8');
134
+ if (isTenboManaged(existing)) {
135
+ return {
136
+ mode: 'already-installed',
137
+ writes: [],
138
+ moves: [],
139
+ notes: [`Tenbo header already present in ${hookPath}`],
140
+ };
141
+ }
142
+
143
+ if (ctx.force) {
144
+ return {
145
+ mode: 'standalone',
146
+ writes: [{ path: hookPath, reason: 'overwrite foreign hook (--force)', chmod: true }],
147
+ moves: [],
148
+ notes: [`--force: overwriting existing non-tenbo hook at ${hookPath}.`],
149
+ };
150
+ }
151
+
152
+ // Chained install — preserve existing hook by moving it aside.
153
+ const localPath = hookPath + '.local';
154
+ return {
155
+ mode: 'chained',
156
+ writes: [{ path: hookPath, reason: 'write chained hook that runs local then tenbo', chmod: true }],
157
+ moves: [{ from: hookPath, to: localPath }],
158
+ notes: [
159
+ `Existing non-tenbo hook detected at ${hookPath}.`,
160
+ `It will be preserved at ${localPath} and chained: local hook runs first, then tenbo validator.`,
161
+ ],
162
+ };
163
+ }
164
+
165
+ /** Apply an install plan. Returns the same plan for caller reporting. */
166
+ export function applyInstall(plan: InstallPlan): InstallPlan {
167
+ // Moves first (so chained mode can free the original path before write).
168
+ for (const m of plan.moves) {
169
+ fs.renameSync(m.from, m.to);
170
+ }
171
+ for (const w of plan.writes) {
172
+ fs.mkdirSync(path.dirname(w.path), { recursive: true });
173
+ let body: string;
174
+ if (plan.mode === 'husky') {
175
+ const existing = fs.existsSync(w.path) ? fs.readFileSync(w.path, 'utf8') : '';
176
+ body = existing.endsWith('\n') ? existing + HUSKY_BLOCK : existing + '\n' + HUSKY_BLOCK;
177
+ } else if (plan.mode === 'chained') {
178
+ const moved = plan.moves.find((m) => m.to.endsWith('.local'));
179
+ body = chainedHookBody(moved ? moved.to : 'pre-commit.local');
180
+ } else {
181
+ body = HOOK_BODY;
182
+ }
183
+ fs.writeFileSync(w.path, body);
184
+ if (w.chmod) fs.chmodSync(w.path, 0o755);
185
+ }
186
+ return plan;
187
+ }
188
+
189
+ export type UninstallMode = 'standalone' | 'chained' | 'husky' | 'not-installed';
190
+
191
+ export interface UninstallPlan {
192
+ mode: UninstallMode;
193
+ removes: string[];
194
+ /** Move (restore) operations: e.g. `pre-commit.local` → `pre-commit`. */
195
+ restores: { from: string; to: string }[];
196
+ /** In-place edits (husky block removal). */
197
+ edits: { path: string; before: string; after: string }[];
198
+ notes: string[];
199
+ }
200
+
201
+ export function planUninstall(repoRoot: string): UninstallPlan {
202
+ const huskyPath = path.join(repoRoot, '.husky', 'pre-commit');
203
+ const hookPath = path.join(repoRoot, '.git', 'hooks', 'pre-commit');
204
+ const localPath = hookPath + '.local';
205
+
206
+ // Husky path takes priority if the husky file carries the tenbo block.
207
+ if (fs.existsSync(huskyPath)) {
208
+ const content = fs.readFileSync(huskyPath, 'utf8');
209
+ if (content.includes(TENBO_HUSKY_BEGIN)) {
210
+ const after = stripHuskyBlock(content);
211
+ return {
212
+ mode: 'husky',
213
+ removes: [],
214
+ restores: [],
215
+ edits: [{ path: huskyPath, before: content, after }],
216
+ notes: [`Removing tenbo block from ${huskyPath}.`],
217
+ };
218
+ }
219
+ }
220
+
221
+ if (!fs.existsSync(hookPath)) {
222
+ return { mode: 'not-installed', removes: [], restores: [], edits: [], notes: ['No tenbo-managed hook found.'] };
223
+ }
224
+
225
+ const content = fs.readFileSync(hookPath, 'utf8');
226
+ if (!content.includes(TENBO_HEADER_MARKER)) {
227
+ return {
228
+ mode: 'not-installed',
229
+ removes: [],
230
+ restores: [],
231
+ edits: [],
232
+ notes: [`Hook at ${hookPath} is not tenbo-managed — leaving it alone.`],
233
+ };
234
+ }
235
+
236
+ // Tenbo-managed. Chained if a sibling .local exists.
237
+ if (fs.existsSync(localPath)) {
238
+ return {
239
+ mode: 'chained',
240
+ removes: [],
241
+ restores: [{ from: localPath, to: hookPath }],
242
+ edits: [],
243
+ notes: [`Restoring previous hook from ${localPath} → ${hookPath}.`],
244
+ };
245
+ }
246
+
247
+ return {
248
+ mode: 'standalone',
249
+ removes: [hookPath],
250
+ restores: [],
251
+ edits: [],
252
+ notes: [`Removing tenbo-managed standalone hook at ${hookPath}.`],
253
+ };
254
+ }
255
+
256
+ export function applyUninstall(plan: UninstallPlan): UninstallPlan {
257
+ for (const r of plan.removes) {
258
+ if (fs.existsSync(r)) fs.unlinkSync(r);
259
+ }
260
+ for (const r of plan.restores) {
261
+ if (fs.existsSync(r.to)) fs.unlinkSync(r.to);
262
+ fs.renameSync(r.from, r.to);
263
+ fs.chmodSync(r.to, 0o755);
264
+ }
265
+ for (const e of plan.edits) {
266
+ fs.writeFileSync(e.path, e.after);
267
+ }
268
+ return plan;
269
+ }
270
+
271
+ /** Strip the husky tenbo block (between BEGIN/END markers, inclusive). Pure. */
272
+ export function stripHuskyBlock(content: string): string {
273
+ const lines = content.split('\n');
274
+ const out: string[] = [];
275
+ let inside = false;
276
+ for (const line of lines) {
277
+ if (line.includes(TENBO_HUSKY_BEGIN)) {
278
+ inside = true;
279
+ // Drop a single leading blank line we may have added for spacing.
280
+ if (out.length && out[out.length - 1] === '') out.pop();
281
+ continue;
282
+ }
283
+ if (line.includes(TENBO_HUSKY_END)) {
284
+ inside = false;
285
+ continue;
286
+ }
287
+ if (!inside) out.push(line);
288
+ }
289
+ return out.join('\n');
290
+ }
291
+
292
+ /** Pretty-print a plan for --dry-run. */
293
+ export function formatInstallPlan(plan: InstallPlan): string {
294
+ const lines: string[] = [];
295
+ lines.push(`Install mode: ${plan.mode}`);
296
+ for (const n of plan.notes) lines.push(` - ${n}`);
297
+ for (const m of plan.moves) lines.push(` move: ${m.from} → ${m.to}`);
298
+ for (const w of plan.writes) lines.push(` write: ${w.path} (${w.reason})`);
299
+ if (plan.mode === 'already-installed') lines.push(' (no changes — already installed)');
300
+ return lines.join('\n');
301
+ }
302
+
303
+ export function formatUninstallPlan(plan: UninstallPlan): string {
304
+ const lines: string[] = [];
305
+ lines.push(`Uninstall mode: ${plan.mode}`);
306
+ for (const n of plan.notes) lines.push(` - ${n}`);
307
+ for (const r of plan.removes) lines.push(` remove: ${r}`);
308
+ for (const r of plan.restores) lines.push(` restore: ${r.from} → ${r.to}`);
309
+ for (const e of plan.edits) lines.push(` edit: ${e.path}`);
310
+ return lines.join('\n');
311
+ }
312
+
313
+ function isMain(): boolean {
314
+ try {
315
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
316
+ const here = path.resolve(new URL(import.meta.url).pathname);
317
+ return invoked === here;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ function printHelp(): void {
324
+ process.stdout.write(`tenbo-dashboard hook — opt-in pre-commit hook (sk-032)
325
+
326
+ Usage:
327
+ tenbo-dashboard hook install [--dry-run] [--force]
328
+ tenbo-dashboard hook uninstall
329
+ tenbo-dashboard hook status
330
+
331
+ Install runs the deterministic validator on .tenbo/ at commit time.
332
+ Errors block commit; warnings print but don't block.
333
+ Bypass per-commit with \`git commit --no-verify\`.
334
+
335
+ Flags:
336
+ --dry-run Print exactly what would change without writing.
337
+ --force Overwrite an existing non-tenbo hook (use with care).
338
+
339
+ Notes:
340
+ - Existing .git/hooks/pre-commit (foreign) is preserved via chained-hook pattern.
341
+ - .husky/pre-commit users get a delimited block appended (no .git/hooks/ touch).
342
+ - Re-running install after install is a no-op.
343
+ `);
344
+ }
345
+
346
+ if (isMain()) {
347
+ const args = process.argv.slice(2);
348
+ const action = args[0];
349
+ const cwd = process.cwd();
350
+ const repoRoot = findRepoRoot(cwd);
351
+
352
+ if (!action || action === 'help' || action === '--help') {
353
+ printHelp();
354
+ process.exit(0);
355
+ }
356
+
357
+ if (!repoRoot) {
358
+ process.stderr.write('hook: not inside a git repository (no .git/ found in any parent).\n');
359
+ process.exit(1);
360
+ }
361
+
362
+ if (action === 'install') {
363
+ const dryRun = args.includes('--dry-run');
364
+ const force = args.includes('--force');
365
+ const plan = planInstall({ repoRoot, force });
366
+ if (dryRun) {
367
+ process.stdout.write(formatInstallPlan(plan) + '\n');
368
+ process.stdout.write('(dry-run — no changes written)\n');
369
+ process.exit(0);
370
+ }
371
+ if (plan.mode === 'already-installed') {
372
+ process.stdout.write('tenbo pre-commit hook already installed. No changes.\n');
373
+ process.exit(0);
374
+ }
375
+ applyInstall(plan);
376
+ process.stdout.write(formatInstallPlan(plan) + '\n');
377
+ process.stdout.write('Installed. Bypass with `git commit --no-verify`. Uninstall with `npx tenbo-dashboard hook uninstall`.\n');
378
+ process.exit(0);
379
+ }
380
+
381
+ if (action === 'uninstall') {
382
+ const plan = planUninstall(repoRoot);
383
+ if (plan.mode === 'not-installed') {
384
+ process.stdout.write('No tenbo-managed pre-commit hook found. Nothing to do.\n');
385
+ process.exit(0);
386
+ }
387
+ applyUninstall(plan);
388
+ process.stdout.write(formatUninstallPlan(plan) + '\n');
389
+ process.stdout.write('Uninstalled.\n');
390
+ process.exit(0);
391
+ }
392
+
393
+ if (action === 'status') {
394
+ const plan = planInstall({ repoRoot });
395
+ process.stdout.write(`hook status: ${plan.mode === 'already-installed' ? 'installed' : 'not installed'}\n`);
396
+ for (const n of plan.notes) process.stdout.write(` - ${n}\n`);
397
+ process.exit(0);
398
+ }
399
+
400
+ process.stderr.write(`hook: unknown action '${action}'. Run \`tenbo-dashboard hook help\`.\n`);
401
+ process.exit(2);
402
+ }
@@ -50,6 +50,13 @@ export function computeDiff(prev: Snapshot | null, current: Snapshot): DiffResul
50
50
  export interface RenderOptions {
51
51
  verbose?: boolean;
52
52
  json?: boolean;
53
+ /**
54
+ * Strict mode (sk-032): reserved for future stricter behavior. Today the
55
+ * default already exits non-zero on errors and 0 on warnings, so --strict
56
+ * is a no-op semantically but a stable contract for the pre-commit hook.
57
+ * If we ever introduce a warning-blocks-commit mode it lands here.
58
+ */
59
+ strict?: boolean;
53
60
  }
54
61
 
55
62
  export interface RenderResult {
@@ -152,6 +159,7 @@ if (isMain()) {
152
159
  const args = process.argv.slice(2);
153
160
  const verbose = args.includes('--verbose');
154
161
  const json = args.includes('--json');
162
+ const strict = args.includes('--strict');
155
163
 
156
164
  const cwd = process.cwd();
157
165
  const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
@@ -169,7 +177,7 @@ if (isMain()) {
169
177
  fs.writeFileSync(statusPath, JSON.stringify(snapshot, null, 2) + '\n');
170
178
 
171
179
  const diff = computeDiff(prev, snapshot);
172
- const rendered = renderOutput(snapshot, diff, { verbose, json });
180
+ const rendered = renderOutput(snapshot, diff, { verbose, json, strict });
173
181
  if (rendered.stdout) process.stdout.write(rendered.stdout);
174
182
  if (rendered.stderr) process.stderr.write(rendered.stderr);
175
183
  if (!json && !verbose) {
@@ -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,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
+ });
@@ -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;
@@ -323,6 +352,45 @@ export function validate(state: TenboState): ValidateResult {
323
352
  });
324
353
  }
325
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
+
326
394
  // Phase schema validation (Phase 3 of x-001).
327
395
  if (item.phases !== undefined) {
328
396
  if (!Array.isArray(item.phases)) {
package/src/types.ts CHANGED
@@ -93,6 +93,18 @@ export interface Item {
93
93
  * refactor, bug} lacks this field.
94
94
  */
95
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
+ }>;
96
108
  }
97
109
 
98
110
  export interface Layer {