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.
- package/bin/tenbo-dashboard.mjs +4 -0
- package/package.json +1 -1
- package/scripts/hook.test.ts +144 -0
- package/scripts/hook.ts +402 -0
- package/scripts/validate-cli.ts +9 -1
- package/src/api/lib/tenboFs.ts +40 -1
- package/src/api/lib/validator.duplicateIds.test.ts +89 -0
- package/src/api/lib/validator.goalRef.test.ts +76 -0
- package/src/api/lib/validator.preflightViolations.test.ts +67 -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 +162 -6
- 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 +50 -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/bin/tenbo-dashboard.mjs
CHANGED
|
@@ -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
|
@@ -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
|
+
});
|
package/scripts/hook.ts
ADDED
|
@@ -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
|
+
}
|
package/scripts/validate-cli.ts
CHANGED
|
@@ -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) {
|
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
|
|