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.
@@ -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.5.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) {
@@ -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