qualia-framework-v2 2.6.0 → 2.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/README.md +7 -3
- package/bin/install.js +23 -10
- package/hooks/auto-update.js +92 -0
- package/hooks/block-env-edit.js +30 -0
- package/hooks/branch-guard.js +47 -0
- package/hooks/migration-guard.js +60 -0
- package/hooks/pre-compact.js +32 -0
- package/hooks/pre-deploy-gate.js +110 -0
- package/hooks/pre-push.js +33 -0
- package/hooks/session-start.js +84 -0
- package/package.json +1 -1
- package/tests/hooks.test.sh +107 -55
- package/hooks/auto-update.sh +0 -56
- package/hooks/block-env-edit.sh +0 -11
- package/hooks/branch-guard.sh +0 -27
- package/hooks/migration-guard.sh +0 -43
- package/hooks/pre-compact.sh +0 -11
- package/hooks/pre-deploy-gate.sh +0 -50
- package/hooks/pre-push.sh +0 -28
- package/hooks/session-start.sh +0 -35
package/README.md
CHANGED
|
@@ -49,10 +49,14 @@ See `guide.md` for the full developer guide.
|
|
|
49
49
|
|
|
50
50
|
- **19 skills** — slash commands from setup to handoff, plus debugging, design, review, knowledge, session management, and skill authoring
|
|
51
51
|
- **4 agents** — planner, builder, verifier, qa-browser (each in fresh context)
|
|
52
|
-
- **8 hooks** — session start, branch guard, pre-push tracking sync, env protection, migration guard, deploy gate, pre-compact state save, auto-update
|
|
52
|
+
- **8 hooks** — session start, branch guard, pre-push tracking sync, env protection, migration guard, deploy gate, pre-compact state save, auto-update (all Node.js — cross-platform)
|
|
53
53
|
- **3 rules** — security, frontend, deployment
|
|
54
54
|
- **5 templates** — tracking.json, state.md, project.md, plan.md, DESIGN.md
|
|
55
55
|
|
|
56
|
+
## Supported Platforms
|
|
57
|
+
|
|
58
|
+
Works on **Windows 10/11, macOS, and Linux** (all distros). The only requirement is Node.js 18+. No Git Bash, no WSL, no bash dependency — every hook is pure Node.js.
|
|
59
|
+
|
|
56
60
|
## Why It Works
|
|
57
61
|
|
|
58
62
|
### Goal-Backward Verification
|
|
@@ -94,8 +98,8 @@ npx qualia-framework-v2 install
|
|
|
94
98
|
~/.claude/
|
|
95
99
|
├── skills/ 19 slash commands
|
|
96
100
|
├── agents/ planner.md, builder.md, verifier.md, qa-browser.md
|
|
97
|
-
├── hooks/ 8
|
|
98
|
-
├── bin/ state.js (state machine
|
|
101
|
+
├── hooks/ 8 Node.js hooks — cross-platform (no bash dependency)
|
|
102
|
+
├── bin/ state.js (state machine) + qualia-ui.js (cosmetics library)
|
|
99
103
|
├── knowledge/ learned-patterns.md, common-fixes.md, client-prefs.md (auto-loaded by skills)
|
|
100
104
|
├── rules/ security.md, frontend.md, deployment.md
|
|
101
105
|
├── qualia-templates/ tracking.json, state.md, project.md, plan.md, DESIGN.md
|
package/bin/install.js
CHANGED
|
@@ -146,10 +146,20 @@ async function main() {
|
|
|
146
146
|
const hooksSource = path.join(FRAMEWORK_DIR, "hooks");
|
|
147
147
|
const hooksDest = path.join(CLAUDE_DIR, "hooks");
|
|
148
148
|
if (!fs.existsSync(hooksDest)) fs.mkdirSync(hooksDest, { recursive: true });
|
|
149
|
+
// Clean up legacy .sh hooks from previous v2.5/v2.6 installs so no orphans
|
|
150
|
+
// remain on disk after upgrading to the pure-Node v2.7+ hooks.
|
|
151
|
+
try {
|
|
152
|
+
for (const f of fs.readdirSync(hooksDest)) {
|
|
153
|
+
if (f.endsWith(".sh")) {
|
|
154
|
+
try { fs.unlinkSync(path.join(hooksDest, f)); } catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
149
158
|
for (const file of fs.readdirSync(hooksSource)) {
|
|
150
159
|
try {
|
|
151
160
|
const dest = path.join(hooksDest, file);
|
|
152
161
|
copy(path.join(hooksSource, file), dest);
|
|
162
|
+
// chmod is a no-op on Windows but harmless
|
|
153
163
|
fs.chmodSync(dest, 0o755);
|
|
154
164
|
ok(file);
|
|
155
165
|
} catch (e) {
|
|
@@ -331,8 +341,11 @@ async function main() {
|
|
|
331
341
|
],
|
|
332
342
|
};
|
|
333
343
|
|
|
334
|
-
// Hooks —
|
|
344
|
+
// Hooks — pure Node.js, cross-platform (Windows/macOS/Linux).
|
|
345
|
+
// Every hook command is `node <absolute-path-to-hook.js>` which avoids the
|
|
346
|
+
// bash/Git Bash requirement on Windows.
|
|
335
347
|
const hd = path.join(CLAUDE_DIR, "hooks");
|
|
348
|
+
const nodeCmd = (hookFile) => `node "${path.join(hd, hookFile)}"`;
|
|
336
349
|
settings.hooks = {
|
|
337
350
|
SessionStart: [
|
|
338
351
|
{
|
|
@@ -340,7 +353,7 @@ async function main() {
|
|
|
340
353
|
hooks: [
|
|
341
354
|
{
|
|
342
355
|
type: "command",
|
|
343
|
-
command:
|
|
356
|
+
command: nodeCmd("session-start.js"),
|
|
344
357
|
timeout: 5,
|
|
345
358
|
},
|
|
346
359
|
],
|
|
@@ -352,28 +365,28 @@ async function main() {
|
|
|
352
365
|
hooks: [
|
|
353
366
|
{
|
|
354
367
|
type: "command",
|
|
355
|
-
command:
|
|
368
|
+
command: nodeCmd("auto-update.js"),
|
|
356
369
|
timeout: 5,
|
|
357
370
|
},
|
|
358
371
|
{
|
|
359
372
|
type: "command",
|
|
360
373
|
if: "Bash(git push*)",
|
|
361
|
-
command:
|
|
374
|
+
command: nodeCmd("branch-guard.js"),
|
|
362
375
|
timeout: 10,
|
|
363
376
|
statusMessage: "◆ Checking branch permissions...",
|
|
364
377
|
},
|
|
365
378
|
{
|
|
366
379
|
type: "command",
|
|
367
380
|
if: "Bash(git push*)",
|
|
368
|
-
command:
|
|
381
|
+
command: nodeCmd("pre-push.js"),
|
|
369
382
|
timeout: 15,
|
|
370
383
|
statusMessage: "◆ Syncing tracking...",
|
|
371
384
|
},
|
|
372
385
|
{
|
|
373
386
|
type: "command",
|
|
374
387
|
if: "Bash(vercel --prod*)",
|
|
375
|
-
command:
|
|
376
|
-
timeout:
|
|
388
|
+
command: nodeCmd("pre-deploy-gate.js"),
|
|
389
|
+
timeout: 180,
|
|
377
390
|
statusMessage: "◆ Running quality gates...",
|
|
378
391
|
},
|
|
379
392
|
],
|
|
@@ -384,14 +397,14 @@ async function main() {
|
|
|
384
397
|
{
|
|
385
398
|
type: "command",
|
|
386
399
|
if: "Edit(*.env*)|Write(*.env*)",
|
|
387
|
-
command:
|
|
400
|
+
command: nodeCmd("block-env-edit.js"),
|
|
388
401
|
timeout: 5,
|
|
389
402
|
statusMessage: "◆ Checking file permissions...",
|
|
390
403
|
},
|
|
391
404
|
{
|
|
392
405
|
type: "command",
|
|
393
406
|
if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)",
|
|
394
|
-
command:
|
|
407
|
+
command: nodeCmd("migration-guard.js"),
|
|
395
408
|
timeout: 10,
|
|
396
409
|
statusMessage: "◆ Checking migration safety...",
|
|
397
410
|
},
|
|
@@ -404,7 +417,7 @@ async function main() {
|
|
|
404
417
|
hooks: [
|
|
405
418
|
{
|
|
406
419
|
type: "command",
|
|
407
|
-
command:
|
|
420
|
+
command: nodeCmd("pre-compact.js"),
|
|
408
421
|
timeout: 15,
|
|
409
422
|
statusMessage: "◆ Saving state...",
|
|
410
423
|
},
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/auto-update.js — daily silent update check in the background.
|
|
3
|
+
// PreToolUse hook on every Bash tool call. Fast path: single stat() call that
|
|
4
|
+
// returns immediately if last check was <24h ago. Cross-platform.
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const { spawn, spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
12
|
+
const CACHE_FILE = path.join(CLAUDE_DIR, ".qualia-last-update-check");
|
|
13
|
+
const CONFIG_FILE = path.join(CLAUDE_DIR, ".qualia-config.json");
|
|
14
|
+
const LOCK_FILE = path.join(CLAUDE_DIR, ".qualia-updating");
|
|
15
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
// Fast path: recently checked
|
|
19
|
+
if (fs.existsSync(CACHE_FILE)) {
|
|
20
|
+
const last = Number(fs.readFileSync(CACHE_FILE, "utf8")) || 0;
|
|
21
|
+
if (Date.now() - last * 1000 < MAX_AGE_MS) {
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Already updating
|
|
27
|
+
if (fs.existsSync(LOCK_FILE)) {
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Update cache timestamp immediately to debounce concurrent checks
|
|
32
|
+
fs.writeFileSync(CACHE_FILE, String(Math.floor(Date.now() / 1000)));
|
|
33
|
+
|
|
34
|
+
// Read current config
|
|
35
|
+
let cfg = {};
|
|
36
|
+
try {
|
|
37
|
+
cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
38
|
+
} catch {
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (!cfg.code || !cfg.version) process.exit(0);
|
|
42
|
+
|
|
43
|
+
// Fork the check-and-update into a detached background process so the hook
|
|
44
|
+
// returns immediately and Claude Code is never blocked.
|
|
45
|
+
const script = `
|
|
46
|
+
const fs = require("fs");
|
|
47
|
+
const path = require("path");
|
|
48
|
+
const { spawnSync } = require("child_process");
|
|
49
|
+
const CLAUDE_DIR = ${JSON.stringify(CLAUDE_DIR)};
|
|
50
|
+
const LOCK_FILE = ${JSON.stringify(LOCK_FILE)};
|
|
51
|
+
const CONFIG_FILE = ${JSON.stringify(CONFIG_FILE)};
|
|
52
|
+
const cfg = ${JSON.stringify(cfg)};
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid));
|
|
55
|
+
const r = spawnSync("npm", ["view", "qualia-framework-v2", "version"], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
timeout: 15000,
|
|
58
|
+
shell: process.platform === "win32",
|
|
59
|
+
});
|
|
60
|
+
const latest = ((r.stdout || "").trim());
|
|
61
|
+
if (!latest) { fs.unlinkSync(LOCK_FILE); return; }
|
|
62
|
+
const cmp = (a, b) => {
|
|
63
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
if ((pa[i]||0) > (pb[i]||0)) return 1;
|
|
66
|
+
if ((pa[i]||0) < (pb[i]||0)) return -1;
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
};
|
|
70
|
+
if (cmp(latest, cfg.version) > 0) {
|
|
71
|
+
// Silent update — pipe the install code via stdin
|
|
72
|
+
const child = spawnSync("npx", ["qualia-framework-v2@latest", "install"], {
|
|
73
|
+
input: cfg.code + "\\n",
|
|
74
|
+
timeout: 120000,
|
|
75
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
76
|
+
shell: process.platform === "win32",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const child = spawn(process.execPath, ["-e", script], {
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: "ignore",
|
|
86
|
+
});
|
|
87
|
+
child.unref();
|
|
88
|
+
} catch {
|
|
89
|
+
// Silent — never block the tool call
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
process.exit(0);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/block-env-edit.js — prevent editing .env files.
|
|
3
|
+
// PreToolUse hook on Edit/Write tool calls. Reads tool input as JSON on stdin.
|
|
4
|
+
// Exits 2 to BLOCK the tool call. Exits 0 to allow it.
|
|
5
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
|
|
9
|
+
function readInput() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const input = readInput();
|
|
19
|
+
const file = (input.tool_input && (input.tool_input.file_path || input.tool_input.command)) || "";
|
|
20
|
+
|
|
21
|
+
// Match .env, .env.local, .env.production, .env.*, etc.
|
|
22
|
+
// Normalize separators so Windows paths (C:\project\.env.local) also match.
|
|
23
|
+
const normalized = String(file).replace(/\\/g, "/");
|
|
24
|
+
|
|
25
|
+
if (/\.env(\.|$)/.test(normalized)) {
|
|
26
|
+
console.log("BLOCKED: Cannot edit environment files. Ask Fawzi to update secrets.");
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
process.exit(0);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/branch-guard.js — block non-OWNER push to main/master.
|
|
3
|
+
// PreToolUse hook on `git push*` commands. Reads role from
|
|
4
|
+
// ~/.claude/.qualia-config.json (single source of truth).
|
|
5
|
+
// Exits 1 to BLOCK. Exits 0 to allow.
|
|
6
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const { spawnSync } = require("child_process");
|
|
12
|
+
|
|
13
|
+
const CONFIG = path.join(os.homedir(), ".claude", ".qualia-config.json");
|
|
14
|
+
|
|
15
|
+
function fail(msg) {
|
|
16
|
+
console.log(msg);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let role = "";
|
|
21
|
+
try {
|
|
22
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG, "utf8"));
|
|
23
|
+
role = cfg.role || "";
|
|
24
|
+
} catch {
|
|
25
|
+
fail(`BLOCKED: ${CONFIG} missing or unreadable. Run: npx qualia-framework-v2 install`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!role) {
|
|
29
|
+
fail(`BLOCKED: Cannot determine role from ${CONFIG}. Defaulting to deny.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Ask git for the current branch --show-current. Works identically on Windows/macOS/Linux.
|
|
33
|
+
const r = spawnSync("git", ["branch", "--show-current"], {
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
timeout: 3000,
|
|
36
|
+
});
|
|
37
|
+
const branch = ((r.stdout || "").trim());
|
|
38
|
+
|
|
39
|
+
if (branch === "main" || branch === "master") {
|
|
40
|
+
if (role !== "OWNER") {
|
|
41
|
+
console.log(`BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`);
|
|
42
|
+
console.log("Run: git checkout -b feature/your-feature-name");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.exit(0);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/migration-guard.js — catch dangerous SQL patterns in migrations.
|
|
3
|
+
// PreToolUse hook on Edit/Write tool calls. Reads tool input as JSON on stdin.
|
|
4
|
+
// Exits 2 to BLOCK. Exits 0 to allow.
|
|
5
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
|
|
9
|
+
function readInput() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const input = readInput();
|
|
19
|
+
const ti = input.tool_input || {};
|
|
20
|
+
const file = String(ti.file_path || "").replace(/\\/g, "/");
|
|
21
|
+
const content = String(ti.content || ti.new_string || "");
|
|
22
|
+
|
|
23
|
+
// Only inspect migration/SQL files
|
|
24
|
+
if (!/migration|migrate|\.sql$/i.test(file)) {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const errors = [];
|
|
29
|
+
|
|
30
|
+
// DROP TABLE without IF EXISTS
|
|
31
|
+
if (/DROP\s+TABLE/i.test(content) && !/IF\s+EXISTS/i.test(content)) {
|
|
32
|
+
errors.push("DROP TABLE without IF EXISTS");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// DELETE without WHERE
|
|
36
|
+
if (/DELETE\s+FROM/i.test(content) && !/WHERE/i.test(content)) {
|
|
37
|
+
errors.push("DELETE FROM without WHERE clause");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// TRUNCATE (almost always wrong in migrations)
|
|
41
|
+
if (/TRUNCATE/i.test(content)) {
|
|
42
|
+
errors.push("TRUNCATE detected — are you sure?");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// CREATE TABLE without RLS
|
|
46
|
+
if (/CREATE\s+TABLE/i.test(content) && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(content)) {
|
|
47
|
+
errors.push("CREATE TABLE without ENABLE ROW LEVEL SECURITY");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (errors.length > 0) {
|
|
51
|
+
console.log("◆ Migration guard — dangerous patterns found:");
|
|
52
|
+
for (const e of errors) {
|
|
53
|
+
console.log(` ✗ ${e}`);
|
|
54
|
+
}
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log("Fix these before proceeding. If intentional, ask Fawzi to approve.");
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
process.exit(0);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/pre-compact.js — commit STATE.md before context compaction.
|
|
3
|
+
// PreCompact hook. Silent on failure — context compaction must never be blocked.
|
|
4
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawnSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
const STATE_FILE = path.join(".planning", "STATE.md");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
14
|
+
console.log("QUALIA: Saving state before compaction...");
|
|
15
|
+
// Check if STATE.md has uncommitted changes
|
|
16
|
+
const diff = spawnSync("git", ["diff", "--name-only", STATE_FILE], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
timeout: 3000,
|
|
19
|
+
});
|
|
20
|
+
if ((diff.stdout || "").includes("STATE.md")) {
|
|
21
|
+
spawnSync("git", ["add", STATE_FILE], { timeout: 3000 });
|
|
22
|
+
spawnSync("git", ["commit", "-m", "state: pre-compaction save"], {
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Silent — never block compaction
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
process.exit(0);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/pre-deploy-gate.js — quality gates before production deploy.
|
|
3
|
+
// PreToolUse hook on `vercel --prod*` commands. Runs tsc, lint, tests, build,
|
|
4
|
+
// then scans for service_role leaks in client code.
|
|
5
|
+
// Exits 1 to BLOCK deploy. Exits 0 to allow.
|
|
6
|
+
// Cross-platform (Windows/macOS/Linux). No `grep` or `find` — pure Node.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { spawnSync } = require("child_process");
|
|
11
|
+
|
|
12
|
+
function runGate(label, cmd, args, { required = true } = {}) {
|
|
13
|
+
const r = spawnSync(cmd, args, {
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
timeout: 180000,
|
|
16
|
+
shell: process.platform === "win32",
|
|
17
|
+
});
|
|
18
|
+
if (r.status === 0) {
|
|
19
|
+
console.log(` ✓ ${label}`);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
if (required) {
|
|
23
|
+
console.log(`BLOCKED: ${label} errors. Fix before deploying.`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasScript(name) {
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
|
32
|
+
return pkg.scripts && typeof pkg.scripts[name] === "string";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function walk(dir, out = []) {
|
|
39
|
+
if (!fs.existsSync(dir)) return out;
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
48
|
+
const full = path.join(dir, e.name);
|
|
49
|
+
if (e.isDirectory()) {
|
|
50
|
+
walk(full, out);
|
|
51
|
+
} else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name)) {
|
|
52
|
+
out.push(full);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scanServiceRoleLeaks() {
|
|
59
|
+
const roots = ["app", "components", "src", "pages", "lib"];
|
|
60
|
+
const leaks = [];
|
|
61
|
+
for (const root of roots) {
|
|
62
|
+
for (const file of walk(root)) {
|
|
63
|
+
// Skip server-only files (convention: *.server.ts, server/ dirs)
|
|
64
|
+
if (/\.server\.|[\\/]server[\\/]/.test(file)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(file, "utf8");
|
|
67
|
+
if (/service_role/.test(content)) {
|
|
68
|
+
leaks.push(file);
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return leaks;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log("◆ Pre-deploy gate...");
|
|
77
|
+
|
|
78
|
+
// TypeScript
|
|
79
|
+
if (fs.existsSync("tsconfig.json")) {
|
|
80
|
+
runGate("TypeScript", "npx", ["tsc", "--noEmit"]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Lint
|
|
84
|
+
if (hasScript("lint")) {
|
|
85
|
+
runGate("Lint", "npm", ["run", "lint"]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Tests
|
|
89
|
+
if (hasScript("test")) {
|
|
90
|
+
runGate("Tests", "npm", ["test"]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build
|
|
94
|
+
if (hasScript("build")) {
|
|
95
|
+
runGate("Build", "npm", ["run", "build"]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Security: no service_role in client code
|
|
99
|
+
const leaks = scanServiceRoleLeaks();
|
|
100
|
+
if (leaks.length > 0) {
|
|
101
|
+
console.log("BLOCKED: service_role found in client code. Remove before deploying.");
|
|
102
|
+
for (const f of leaks.slice(0, 10)) {
|
|
103
|
+
console.log(` ✗ ${f}`);
|
|
104
|
+
}
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
console.log(" ✓ Security");
|
|
108
|
+
console.log("◆ All gates passed.");
|
|
109
|
+
|
|
110
|
+
process.exit(0);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/pre-push.js — update tracking.json with last commit + timestamp.
|
|
3
|
+
// PreToolUse hook on `git push*` commands. state.js handles phase/status sync;
|
|
4
|
+
// this just stamps the file so the ERP sees fresh commit info on every push.
|
|
5
|
+
// Cross-platform (Windows/macOS/Linux).
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const TRACKING = path.join(".planning", "tracking.json");
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (fs.existsSync(TRACKING)) {
|
|
15
|
+
const r = spawnSync("git", ["log", "--oneline", "-1", "--format=%h"], {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
timeout: 3000,
|
|
18
|
+
});
|
|
19
|
+
const lastCommit = ((r.stdout || "").trim());
|
|
20
|
+
const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
21
|
+
|
|
22
|
+
const t = JSON.parse(fs.readFileSync(TRACKING, "utf8"));
|
|
23
|
+
if (lastCommit) t.last_commit = lastCommit;
|
|
24
|
+
t.last_updated = now;
|
|
25
|
+
fs.writeFileSync(TRACKING, JSON.stringify(t, null, 2) + "\n");
|
|
26
|
+
|
|
27
|
+
spawnSync("git", ["add", TRACKING], { timeout: 3000 });
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
process.stderr.write(`WARNING: tracking sync failed: ${err.message}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
process.exit(0);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/session-start.js — branded context panel on session start.
|
|
3
|
+
// Cross-platform (Windows/macOS/Linux). Zero shell dependencies.
|
|
4
|
+
//
|
|
5
|
+
// CRITICAL: this hook must NEVER exit non-zero. Claude Code treats any non-zero
|
|
6
|
+
// exit from a SessionStart hook as a "hook error" and shows a red banner. The
|
|
7
|
+
// banner is purely informational — we'd rather render nothing than block a
|
|
8
|
+
// session. Every call is wrapped in try/catch and we always exit 0 at the end.
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
const { spawnSync } = require("child_process");
|
|
14
|
+
|
|
15
|
+
const HOME = os.homedir();
|
|
16
|
+
const UI = path.join(HOME, ".claude", "bin", "qualia-ui.js");
|
|
17
|
+
const STATE_FILE = path.join(".planning", "STATE.md");
|
|
18
|
+
const CONTINUE_HERE = ".continue-here.md";
|
|
19
|
+
|
|
20
|
+
function runUi(...args) {
|
|
21
|
+
if (!fs.existsSync(UI)) return;
|
|
22
|
+
try {
|
|
23
|
+
spawnSync(process.execPath, [UI, ...args], {
|
|
24
|
+
stdio: "inherit",
|
|
25
|
+
timeout: 3000,
|
|
26
|
+
});
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getNextCommand() {
|
|
31
|
+
const stateJs = path.join(HOME, ".claude", "bin", "state.js");
|
|
32
|
+
if (!fs.existsSync(stateJs)) return "";
|
|
33
|
+
try {
|
|
34
|
+
const r = spawnSync(process.execPath, [stateJs, "check"], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
timeout: 3000,
|
|
37
|
+
});
|
|
38
|
+
if (!r.stdout) return "";
|
|
39
|
+
const j = JSON.parse(r.stdout);
|
|
40
|
+
return j.next_command || "";
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fallbackText() {
|
|
47
|
+
// If qualia-ui.js is missing, emit plain text. Keeps the session informative
|
|
48
|
+
// even on a broken install.
|
|
49
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
50
|
+
try {
|
|
51
|
+
const content = fs.readFileSync(STATE_FILE, "utf8");
|
|
52
|
+
const phase = (content.match(/^Phase:\s*(.+)$/m) || [])[1] || "—";
|
|
53
|
+
const status = (content.match(/^Status:\s*(.+)$/m) || [])[1] || "—";
|
|
54
|
+
console.log(`QUALIA: Project loaded. Phase: ${phase.trim()} | Status: ${status.trim()}`);
|
|
55
|
+
console.log("QUALIA: Run /qualia for next step.");
|
|
56
|
+
} catch {
|
|
57
|
+
console.log("QUALIA: Project detected but STATE.md could not be read.");
|
|
58
|
+
}
|
|
59
|
+
} else if (fs.existsSync(CONTINUE_HERE)) {
|
|
60
|
+
console.log("QUALIA: Handoff file found. Read .continue-here.md to resume.");
|
|
61
|
+
} else {
|
|
62
|
+
console.log("QUALIA: No project detected. Run /qualia-new to start.");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (!fs.existsSync(UI)) {
|
|
68
|
+
fallbackText();
|
|
69
|
+
} else if (fs.existsSync(STATE_FILE)) {
|
|
70
|
+
runUi("banner", "router");
|
|
71
|
+
const next = getNextCommand();
|
|
72
|
+
if (next) runUi("info", `Run ${next} to continue`);
|
|
73
|
+
} else if (fs.existsSync(CONTINUE_HERE)) {
|
|
74
|
+
runUi("banner", "router");
|
|
75
|
+
runUi("warn", "Handoff found — read .continue-here.md to resume");
|
|
76
|
+
} else {
|
|
77
|
+
runUi("banner", "router");
|
|
78
|
+
runUi("info", "No project detected. Run /qualia-new to start.");
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Deliberately silent — hook must never fail
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.exit(0);
|
package/package.json
CHANGED
package/tests/hooks.test.sh
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# Qualia Framework v2 — Hook Tests
|
|
2
|
+
# Qualia Framework v2 — Hook Tests (cross-platform Node.js hooks)
|
|
3
3
|
# Run: bash tests/hooks.test.sh
|
|
4
4
|
|
|
5
5
|
PASS=0
|
|
6
6
|
FAIL=0
|
|
7
|
-
HOOKS_DIR
|
|
7
|
+
# Resolve HOOKS_DIR to an ABSOLUTE path so `cd` inside subshells doesn't break it.
|
|
8
|
+
HOOKS_DIR="$(cd "$(dirname "$0")/../hooks" && pwd)"
|
|
9
|
+
NODE="${NODE:-node}"
|
|
8
10
|
|
|
9
11
|
assert_exit() {
|
|
10
12
|
local name="$1" expected="$2" actual="$3"
|
|
@@ -17,139 +19,189 @@ assert_exit() {
|
|
|
17
19
|
fi
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
echo "=== Hook Tests ==="
|
|
22
|
+
echo "=== Hook Tests (Node.js) ==="
|
|
21
23
|
echo ""
|
|
22
24
|
|
|
23
|
-
# ---
|
|
25
|
+
# --- All hooks are syntactically valid Node.js ---
|
|
26
|
+
echo "syntax:"
|
|
27
|
+
for f in "$HOOKS_DIR"/*.js; do
|
|
28
|
+
if $NODE -c "$f" 2>/dev/null; then
|
|
29
|
+
echo " ✓ $(basename "$f")"
|
|
30
|
+
PASS=$((PASS + 1))
|
|
31
|
+
else
|
|
32
|
+
echo " ✗ $(basename "$f")"
|
|
33
|
+
FAIL=$((FAIL + 1))
|
|
34
|
+
fi
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
# --- block-env-edit.js ---
|
|
38
|
+
echo ""
|
|
24
39
|
echo "block-env-edit:"
|
|
25
40
|
|
|
26
|
-
echo '{"tool_input":{"file_path":".env.local"}}' |
|
|
41
|
+
echo '{"tool_input":{"file_path":".env.local"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
27
42
|
assert_exit "blocks .env.local" 2 $?
|
|
28
43
|
|
|
29
|
-
echo '{"tool_input":{"file_path":".env.production"}}' |
|
|
44
|
+
echo '{"tool_input":{"file_path":".env.production"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
30
45
|
assert_exit "blocks .env.production" 2 $?
|
|
31
46
|
|
|
32
|
-
echo '{"tool_input":{"file_path":".env"}}' |
|
|
47
|
+
echo '{"tool_input":{"file_path":".env"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
33
48
|
assert_exit "blocks .env" 2 $?
|
|
34
49
|
|
|
35
|
-
|
|
50
|
+
# Windows-style path with backslashes (normalized by the hook)
|
|
51
|
+
echo '{"tool_input":{"file_path":"C:\\project\\.env.local"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
52
|
+
assert_exit "blocks windows .env.local" 2 $?
|
|
53
|
+
|
|
54
|
+
echo '{"tool_input":{"file_path":"src/app.tsx"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
36
55
|
assert_exit "allows src/app.tsx" 0 $?
|
|
37
56
|
|
|
38
|
-
echo '{"tool_input":{"file_path":"components/Footer.tsx"}}' |
|
|
57
|
+
echo '{"tool_input":{"file_path":"components/Footer.tsx"}}' | $NODE "$HOOKS_DIR/block-env-edit.js" > /dev/null 2>&1
|
|
39
58
|
assert_exit "allows components/Footer.tsx" 0 $?
|
|
40
59
|
|
|
41
|
-
# --- migration-guard.
|
|
60
|
+
# --- migration-guard.js ---
|
|
42
61
|
echo ""
|
|
43
62
|
echo "migration-guard:"
|
|
44
63
|
|
|
45
|
-
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE users;"}}' |
|
|
64
|
+
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
46
65
|
assert_exit "blocks DROP TABLE without IF EXISTS" 2 $?
|
|
47
66
|
|
|
48
|
-
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE IF EXISTS old_users;"}}' |
|
|
67
|
+
echo '{"tool_input":{"file_path":"migrations/001.sql","content":"DROP TABLE IF EXISTS old_users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
49
68
|
assert_exit "allows DROP TABLE IF EXISTS" 0 $?
|
|
50
69
|
|
|
51
|
-
echo '{"tool_input":{"file_path":"migrations/002.sql","content":"DELETE FROM users;"}}' |
|
|
70
|
+
echo '{"tool_input":{"file_path":"migrations/002.sql","content":"DELETE FROM users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
52
71
|
assert_exit "blocks DELETE without WHERE" 2 $?
|
|
53
72
|
|
|
54
|
-
echo '{"tool_input":{"file_path":"migrations/003.sql","content":"TRUNCATE TABLE sessions;"}}' |
|
|
73
|
+
echo '{"tool_input":{"file_path":"migrations/003.sql","content":"TRUNCATE TABLE sessions;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
55
74
|
assert_exit "blocks TRUNCATE" 2 $?
|
|
56
75
|
|
|
57
|
-
echo '{"tool_input":{"file_path":"migrations/004.sql","content":"CREATE TABLE users (id uuid);"}}' |
|
|
76
|
+
echo '{"tool_input":{"file_path":"migrations/004.sql","content":"CREATE TABLE users (id uuid);"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
58
77
|
assert_exit "blocks CREATE TABLE without RLS" 2 $?
|
|
59
78
|
|
|
60
|
-
echo '{"tool_input":{"file_path":"migrations/005.sql","content":"ALTER TABLE users ADD COLUMN email text;"}}' |
|
|
79
|
+
echo '{"tool_input":{"file_path":"migrations/005.sql","content":"ALTER TABLE users ADD COLUMN email text;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
61
80
|
assert_exit "allows safe ALTER TABLE" 0 $?
|
|
62
81
|
|
|
63
|
-
echo '{"tool_input":{"file_path":"src/app.tsx","content":"DROP TABLE users;"}}' |
|
|
82
|
+
echo '{"tool_input":{"file_path":"src/app.tsx","content":"DROP TABLE users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
|
|
64
83
|
assert_exit "skips non-migration files" 0 $?
|
|
65
84
|
|
|
66
|
-
# --- branch-guard.
|
|
85
|
+
# --- branch-guard.js (grep-based — full run needs git + real config) ---
|
|
67
86
|
echo ""
|
|
68
87
|
echo "branch-guard:"
|
|
69
88
|
|
|
70
|
-
if
|
|
71
|
-
echo " ✓
|
|
89
|
+
if grep -q '.qualia-config.json' "$HOOKS_DIR/branch-guard.js"; then
|
|
90
|
+
echo " ✓ reads role from .qualia-config.json"
|
|
72
91
|
PASS=$((PASS + 1))
|
|
73
92
|
else
|
|
74
|
-
echo " ✗
|
|
93
|
+
echo " ✗ not reading from .qualia-config.json"
|
|
75
94
|
FAIL=$((FAIL + 1))
|
|
76
95
|
fi
|
|
77
96
|
|
|
78
|
-
if grep -q '
|
|
79
|
-
echo " ✓ checks
|
|
97
|
+
if grep -q 'branch --show-current' "$HOOKS_DIR/branch-guard.js"; then
|
|
98
|
+
echo " ✓ checks current git branch"
|
|
80
99
|
PASS=$((PASS + 1))
|
|
81
100
|
else
|
|
82
|
-
echo " ✗ missing
|
|
101
|
+
echo " ✗ missing branch check"
|
|
83
102
|
FAIL=$((FAIL + 1))
|
|
84
103
|
fi
|
|
85
104
|
|
|
86
|
-
if grep -q '
|
|
87
|
-
echo " ✓
|
|
105
|
+
if grep -q 'OWNER' "$HOOKS_DIR/branch-guard.js"; then
|
|
106
|
+
echo " ✓ enforces OWNER role"
|
|
88
107
|
PASS=$((PASS + 1))
|
|
89
108
|
else
|
|
90
|
-
echo " ✗ missing
|
|
109
|
+
echo " ✗ missing OWNER check"
|
|
91
110
|
FAIL=$((FAIL + 1))
|
|
92
111
|
fi
|
|
93
112
|
|
|
94
|
-
# --- pre-push.
|
|
113
|
+
# --- pre-push.js ---
|
|
95
114
|
echo ""
|
|
96
115
|
echo "pre-push:"
|
|
97
116
|
|
|
98
|
-
if grep -q '
|
|
99
|
-
echo " ✓
|
|
117
|
+
if grep -q 'tracking.json' "$HOOKS_DIR/pre-push.js"; then
|
|
118
|
+
echo " ✓ updates tracking.json"
|
|
100
119
|
PASS=$((PASS + 1))
|
|
101
120
|
else
|
|
102
|
-
echo " ✗ missing
|
|
121
|
+
echo " ✗ missing tracking.json update"
|
|
103
122
|
FAIL=$((FAIL + 1))
|
|
104
123
|
fi
|
|
105
124
|
|
|
106
|
-
if grep -q '
|
|
107
|
-
echo " ✓
|
|
125
|
+
if grep -q 'last_commit' "$HOOKS_DIR/pre-push.js"; then
|
|
126
|
+
echo " ✓ stamps last_commit"
|
|
108
127
|
PASS=$((PASS + 1))
|
|
109
128
|
else
|
|
110
|
-
echo " ✗ missing
|
|
129
|
+
echo " ✗ missing last_commit stamp"
|
|
111
130
|
FAIL=$((FAIL + 1))
|
|
112
131
|
fi
|
|
113
132
|
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
echo " ✓ reads role from .qualia-config.json"
|
|
120
|
-
PASS=$((PASS + 1))
|
|
121
|
-
else
|
|
122
|
-
echo " ✗ not reading from .qualia-config.json"
|
|
123
|
-
FAIL=$((FAIL + 1))
|
|
124
|
-
fi
|
|
133
|
+
# Run pre-push.js in a dir with no tracking.json — must exit 0 cleanly
|
|
134
|
+
TMP=$(mktemp -d)
|
|
135
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/pre-push.js" >/dev/null 2>&1)
|
|
136
|
+
assert_exit "exits 0 with no tracking.json" 0 $?
|
|
137
|
+
rm -rf "$TMP"
|
|
125
138
|
|
|
126
|
-
# --- pre-deploy-gate.
|
|
139
|
+
# --- pre-deploy-gate.js ---
|
|
127
140
|
echo ""
|
|
128
141
|
echo "pre-deploy-gate:"
|
|
129
142
|
|
|
130
|
-
if
|
|
131
|
-
echo " ✓
|
|
143
|
+
if grep -q 'tsc' "$HOOKS_DIR/pre-deploy-gate.js"; then
|
|
144
|
+
echo " ✓ runs TypeScript check"
|
|
132
145
|
PASS=$((PASS + 1))
|
|
133
146
|
else
|
|
134
|
-
echo " ✗
|
|
147
|
+
echo " ✗ missing TypeScript check"
|
|
135
148
|
FAIL=$((FAIL + 1))
|
|
136
149
|
fi
|
|
137
150
|
|
|
138
|
-
if grep -q '
|
|
139
|
-
echo " ✓
|
|
151
|
+
if grep -q 'service_role' "$HOOKS_DIR/pre-deploy-gate.js"; then
|
|
152
|
+
echo " ✓ checks for service_role leaks"
|
|
140
153
|
PASS=$((PASS + 1))
|
|
141
154
|
else
|
|
142
|
-
echo " ✗ missing
|
|
155
|
+
echo " ✗ missing service_role check"
|
|
143
156
|
FAIL=$((FAIL + 1))
|
|
144
157
|
fi
|
|
145
158
|
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
# --- session-start.js — must exit 0 always ---
|
|
160
|
+
echo ""
|
|
161
|
+
echo "session-start:"
|
|
162
|
+
|
|
163
|
+
TMP=$(mktemp -d)
|
|
164
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/session-start.js" >/dev/null 2>&1)
|
|
165
|
+
assert_exit "exits 0 with no project" 0 $?
|
|
166
|
+
|
|
167
|
+
# Simulate a project with STATE.md
|
|
168
|
+
mkdir -p "$TMP/.planning"
|
|
169
|
+
cat > "$TMP/.planning/STATE.md" <<'EOF'
|
|
170
|
+
# Project State
|
|
171
|
+
Phase: 1 of 3 — Foundation
|
|
172
|
+
Status: setup
|
|
173
|
+
EOF
|
|
174
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/session-start.js" >/dev/null 2>&1)
|
|
175
|
+
assert_exit "exits 0 with STATE.md" 0 $?
|
|
176
|
+
rm -rf "$TMP"
|
|
177
|
+
|
|
178
|
+
# --- pre-compact.js ---
|
|
179
|
+
echo ""
|
|
180
|
+
echo "pre-compact:"
|
|
181
|
+
|
|
182
|
+
TMP=$(mktemp -d)
|
|
183
|
+
(cd "$TMP" && $NODE "$HOOKS_DIR/pre-compact.js" >/dev/null 2>&1)
|
|
184
|
+
assert_exit "exits 0 with no STATE.md" 0 $?
|
|
185
|
+
rm -rf "$TMP"
|
|
186
|
+
|
|
187
|
+
# --- auto-update.js ---
|
|
188
|
+
echo ""
|
|
189
|
+
echo "auto-update:"
|
|
190
|
+
|
|
191
|
+
TMP=$(mktemp -d)
|
|
192
|
+
mkdir -p "$TMP/.claude"
|
|
193
|
+
echo '{"code":"QS-FAWZI-01","version":"99.99.99"}' > "$TMP/.claude/.qualia-config.json"
|
|
194
|
+
HOME="$TMP" $NODE "$HOOKS_DIR/auto-update.js" >/dev/null 2>&1
|
|
195
|
+
assert_exit "exits 0 (fast path)" 0 $?
|
|
196
|
+
# Should now have cache file
|
|
197
|
+
if [ -f "$TMP/.claude/.qualia-last-update-check" ]; then
|
|
198
|
+
echo " ✓ writes cache timestamp"
|
|
148
199
|
PASS=$((PASS + 1))
|
|
149
200
|
else
|
|
150
|
-
echo " ✗ missing
|
|
201
|
+
echo " ✗ missing cache timestamp"
|
|
151
202
|
FAIL=$((FAIL + 1))
|
|
152
203
|
fi
|
|
204
|
+
rm -rf "$TMP"
|
|
153
205
|
|
|
154
206
|
echo ""
|
|
155
207
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
package/hooks/auto-update.sh
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Qualia auto-update — checks once per day, updates silently in background
|
|
3
|
-
# Runs as PreToolUse hook. Cached so it's a no-op most of the time.
|
|
4
|
-
|
|
5
|
-
CLAUDE_DIR="$HOME/.claude"
|
|
6
|
-
CACHE_FILE="$CLAUDE_DIR/.qualia-last-update-check"
|
|
7
|
-
CONFIG_FILE="$CLAUDE_DIR/.qualia-config.json"
|
|
8
|
-
LOCK_FILE="$CLAUDE_DIR/.qualia-updating"
|
|
9
|
-
MAX_AGE=86400 # 24 hours in seconds
|
|
10
|
-
|
|
11
|
-
# Exit fast if recently checked (most common path — single stat call)
|
|
12
|
-
if [ -f "$CACHE_FILE" ]; then
|
|
13
|
-
LAST_CHECK=$(cat "$CACHE_FILE" 2>/dev/null || echo 0)
|
|
14
|
-
NOW=$(date +%s)
|
|
15
|
-
AGE=$((NOW - LAST_CHECK))
|
|
16
|
-
if [ "$AGE" -lt "$MAX_AGE" ]; then
|
|
17
|
-
exit 0
|
|
18
|
-
fi
|
|
19
|
-
fi
|
|
20
|
-
|
|
21
|
-
# Exit if already updating
|
|
22
|
-
[ -f "$LOCK_FILE" ] && exit 0
|
|
23
|
-
|
|
24
|
-
# Update cache timestamp immediately (prevents concurrent checks)
|
|
25
|
-
date +%s > "$CACHE_FILE"
|
|
26
|
-
|
|
27
|
-
# Run the actual check + update in background so we don't block the user
|
|
28
|
-
(
|
|
29
|
-
trap 'rm -f "$LOCK_FILE"' EXIT
|
|
30
|
-
touch "$LOCK_FILE"
|
|
31
|
-
|
|
32
|
-
# Get installed version
|
|
33
|
-
INSTALLED=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf8')).version)}catch{console.log('0.0.0')}" 2>/dev/null)
|
|
34
|
-
[ -z "$INSTALLED" ] && INSTALLED="0.0.0"
|
|
35
|
-
|
|
36
|
-
# Get latest from npm (5s timeout)
|
|
37
|
-
LATEST=$(npm view qualia-framework-v2 version 2>/dev/null)
|
|
38
|
-
[ -z "$LATEST" ] && exit 0
|
|
39
|
-
|
|
40
|
-
# Compare versions
|
|
41
|
-
NEEDS_UPDATE=$(node -e "
|
|
42
|
-
const a='$LATEST'.split('.').map(Number), b='$INSTALLED'.split('.').map(Number);
|
|
43
|
-
for(let i=0;i<3;i++){if(a[i]>b[i]){console.log('yes');process.exit()}if(a[i]<b[i]){process.exit()}}
|
|
44
|
-
" 2>/dev/null)
|
|
45
|
-
|
|
46
|
-
if [ "$NEEDS_UPDATE" = "yes" ]; then
|
|
47
|
-
# Get saved install code
|
|
48
|
-
CODE=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf8')).code)}catch{}" 2>/dev/null)
|
|
49
|
-
[ -z "$CODE" ] && exit 0
|
|
50
|
-
|
|
51
|
-
# Run silent update
|
|
52
|
-
npx qualia-framework-v2@latest install <<< "$CODE" > /dev/null 2>&1
|
|
53
|
-
fi
|
|
54
|
-
) &
|
|
55
|
-
|
|
56
|
-
exit 0
|
package/hooks/block-env-edit.sh
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Prevent Claude from editing .env files
|
|
3
|
-
# Claude Code hooks receive JSON on stdin with tool_input.file_path
|
|
4
|
-
|
|
5
|
-
INPUT=$(cat)
|
|
6
|
-
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // ""' 2>/dev/null)
|
|
7
|
-
|
|
8
|
-
if [[ "$FILE" == *.env* ]] || [[ "$FILE" == *".env.local"* ]] || [[ "$FILE" == *".env.production"* ]]; then
|
|
9
|
-
echo "BLOCKED: Cannot edit environment files. Ask Fawzi to update secrets."
|
|
10
|
-
exit 2
|
|
11
|
-
fi
|
package/hooks/branch-guard.sh
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Block non-OWNER push to main/master
|
|
3
|
-
# Reads role from ~/.claude/.qualia-config.json (machine-readable source of truth)
|
|
4
|
-
|
|
5
|
-
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
6
|
-
CONFIG="$HOME/.claude/.qualia-config.json"
|
|
7
|
-
|
|
8
|
-
if [ ! -f "$CONFIG" ]; then
|
|
9
|
-
echo "BLOCKED: ~/.claude/.qualia-config.json missing. Run: npx qualia-framework-v2 install"
|
|
10
|
-
exit 1
|
|
11
|
-
fi
|
|
12
|
-
|
|
13
|
-
# Extract role without jq dependency (installers may not have jq)
|
|
14
|
-
ROLE=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('$CONFIG','utf8')).role||'')}catch{}" 2>/dev/null)
|
|
15
|
-
|
|
16
|
-
if [ -z "$ROLE" ]; then
|
|
17
|
-
echo "BLOCKED: Cannot determine role from $CONFIG. Defaulting to deny."
|
|
18
|
-
exit 1
|
|
19
|
-
fi
|
|
20
|
-
|
|
21
|
-
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
|
|
22
|
-
if [[ "$ROLE" != "OWNER" ]]; then
|
|
23
|
-
echo "BLOCKED: Employees cannot push to $BRANCH. Create a feature branch first."
|
|
24
|
-
echo "Run: git checkout -b feature/your-feature-name"
|
|
25
|
-
exit 1
|
|
26
|
-
fi
|
|
27
|
-
fi
|
package/hooks/migration-guard.sh
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Catch dangerous SQL patterns in migration files
|
|
3
|
-
# Runs as PreToolUse hook on Write/Edit of migration files
|
|
4
|
-
|
|
5
|
-
INPUT=$(cat)
|
|
6
|
-
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
|
|
7
|
-
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // ""' 2>/dev/null)
|
|
8
|
-
|
|
9
|
-
# Only check migration/SQL files
|
|
10
|
-
case "$FILE" in
|
|
11
|
-
*migration*|*migrate*|*.sql) ;;
|
|
12
|
-
*) exit 0 ;;
|
|
13
|
-
esac
|
|
14
|
-
|
|
15
|
-
ERRORS=""
|
|
16
|
-
|
|
17
|
-
# DROP TABLE without safeguards
|
|
18
|
-
if echo "$CONTENT" | grep -qi "DROP TABLE" && ! echo "$CONTENT" | grep -qi "IF EXISTS"; then
|
|
19
|
-
ERRORS="${ERRORS}\n ✗ DROP TABLE without IF EXISTS"
|
|
20
|
-
fi
|
|
21
|
-
|
|
22
|
-
# DELETE without WHERE
|
|
23
|
-
if echo "$CONTENT" | grep -qi "DELETE FROM" && ! echo "$CONTENT" | grep -qi "WHERE"; then
|
|
24
|
-
ERRORS="${ERRORS}\n ✗ DELETE FROM without WHERE clause"
|
|
25
|
-
fi
|
|
26
|
-
|
|
27
|
-
# TRUNCATE (almost always wrong in migrations)
|
|
28
|
-
if echo "$CONTENT" | grep -qi "TRUNCATE"; then
|
|
29
|
-
ERRORS="${ERRORS}\n ✗ TRUNCATE detected — are you sure?"
|
|
30
|
-
fi
|
|
31
|
-
|
|
32
|
-
# CREATE TABLE without RLS
|
|
33
|
-
if echo "$CONTENT" | grep -qi "CREATE TABLE" && ! echo "$CONTENT" | grep -qi "ENABLE ROW LEVEL SECURITY"; then
|
|
34
|
-
ERRORS="${ERRORS}\n ✗ CREATE TABLE without ENABLE ROW LEVEL SECURITY"
|
|
35
|
-
fi
|
|
36
|
-
|
|
37
|
-
if [ -n "$ERRORS" ]; then
|
|
38
|
-
echo "◆ Migration guard — dangerous patterns found:"
|
|
39
|
-
echo -e "$ERRORS"
|
|
40
|
-
echo ""
|
|
41
|
-
echo "Fix these before proceeding. If intentional, ask Fawzi to approve."
|
|
42
|
-
exit 2
|
|
43
|
-
fi
|
package/hooks/pre-compact.sh
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Save state before context compression
|
|
3
|
-
|
|
4
|
-
if [ -f ".planning/STATE.md" ]; then
|
|
5
|
-
echo "QUALIA: Saving state before compaction..."
|
|
6
|
-
# State is in git — just ensure it's committed
|
|
7
|
-
if git diff --name-only .planning/STATE.md 2>/dev/null | grep -q STATE; then
|
|
8
|
-
git add .planning/STATE.md
|
|
9
|
-
git commit -m "state: pre-compaction save" 2>/dev/null
|
|
10
|
-
fi
|
|
11
|
-
fi
|
package/hooks/pre-deploy-gate.sh
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Quality gates before production deploy
|
|
3
|
-
|
|
4
|
-
echo "◆ Pre-deploy gate..."
|
|
5
|
-
|
|
6
|
-
# TypeScript check
|
|
7
|
-
if [ -f "tsconfig.json" ]; then
|
|
8
|
-
if ! npx tsc --noEmit 2>/dev/null; then
|
|
9
|
-
echo "BLOCKED: TypeScript errors. Fix before deploying."
|
|
10
|
-
exit 1
|
|
11
|
-
fi
|
|
12
|
-
echo " ✓ TypeScript"
|
|
13
|
-
fi
|
|
14
|
-
|
|
15
|
-
# Lint check
|
|
16
|
-
if [ -f "package.json" ] && grep -q '"lint"' package.json; then
|
|
17
|
-
if ! npm run lint 2>/dev/null; then
|
|
18
|
-
echo "BLOCKED: Lint errors. Fix before deploying."
|
|
19
|
-
exit 1
|
|
20
|
-
fi
|
|
21
|
-
echo " ✓ Lint"
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
# Test check
|
|
25
|
-
if [ -f "package.json" ] && grep -q '"test"' package.json; then
|
|
26
|
-
if ! npm test 2>/dev/null; then
|
|
27
|
-
echo "BLOCKED: Tests failed. Fix before deploying."
|
|
28
|
-
exit 1
|
|
29
|
-
fi
|
|
30
|
-
echo " ✓ Tests"
|
|
31
|
-
fi
|
|
32
|
-
|
|
33
|
-
# Build check
|
|
34
|
-
if [ -f "package.json" ] && grep -q '"build"' package.json; then
|
|
35
|
-
if ! npm run build 2>/dev/null; then
|
|
36
|
-
echo "BLOCKED: Build failed. Fix before deploying."
|
|
37
|
-
exit 1
|
|
38
|
-
fi
|
|
39
|
-
echo " ✓ Build"
|
|
40
|
-
fi
|
|
41
|
-
|
|
42
|
-
# Security: no service_role in client code
|
|
43
|
-
LEAKS=$(grep -r "service_role" app/ components/ src/ 2>/dev/null | grep -v node_modules | grep -v ".server." | wc -l)
|
|
44
|
-
if [ "$LEAKS" -gt 0 ]; then
|
|
45
|
-
echo "BLOCKED: service_role found in client code. Remove before deploying."
|
|
46
|
-
exit 1
|
|
47
|
-
fi
|
|
48
|
-
echo " ✓ Security"
|
|
49
|
-
|
|
50
|
-
echo "◆ All gates passed."
|
package/hooks/pre-push.sh
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Update tracking.json timestamps before push
|
|
3
|
-
# State.js handles phase/status sync — this just updates commit hash and timestamp
|
|
4
|
-
|
|
5
|
-
TRACKING=".planning/tracking.json"
|
|
6
|
-
|
|
7
|
-
if [ -f "$TRACKING" ]; then
|
|
8
|
-
if ! command -v node &>/dev/null; then
|
|
9
|
-
echo "WARNING: node not found, skipping tracking sync" >&2
|
|
10
|
-
exit 0
|
|
11
|
-
fi
|
|
12
|
-
|
|
13
|
-
LAST_COMMIT=$(git log --oneline -1 --format="%h" 2>/dev/null)
|
|
14
|
-
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
15
|
-
|
|
16
|
-
node -e "
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
try {
|
|
19
|
-
const t = JSON.parse(fs.readFileSync('$TRACKING', 'utf8'));
|
|
20
|
-
t.last_commit = '${LAST_COMMIT}';
|
|
21
|
-
t.last_updated = '${NOW}';
|
|
22
|
-
fs.writeFileSync('$TRACKING', JSON.stringify(t, null, 2) + '\n');
|
|
23
|
-
} catch (e) {
|
|
24
|
-
process.stderr.write('WARNING: tracking sync failed: ' + e.message + '\n');
|
|
25
|
-
}
|
|
26
|
-
"
|
|
27
|
-
git add "$TRACKING" 2>/dev/null
|
|
28
|
-
fi
|
package/hooks/session-start.sh
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Qualia session start — show branded context panel on every new session.
|
|
3
|
-
# Delegates to qualia-ui.js so formatting matches the rest of the framework.
|
|
4
|
-
|
|
5
|
-
UI="$HOME/.claude/bin/qualia-ui.js"
|
|
6
|
-
STATE=".planning/STATE.md"
|
|
7
|
-
|
|
8
|
-
# Fallback if qualia-ui.js is missing (first install before mirror)
|
|
9
|
-
if [ ! -f "$UI" ]; then
|
|
10
|
-
if [ -f "$STATE" ]; then
|
|
11
|
-
PHASE=$(grep "^Phase:" "$STATE" 2>/dev/null | head -1)
|
|
12
|
-
STATUS=$(grep "^Status:" "$STATE" 2>/dev/null | head -1)
|
|
13
|
-
echo "QUALIA: Project loaded. $PHASE | $STATUS"
|
|
14
|
-
echo "QUALIA: Run /qualia for next step."
|
|
15
|
-
elif [ -f ".continue-here.md" ]; then
|
|
16
|
-
echo "QUALIA: Handoff file found. Read .continue-here.md to resume."
|
|
17
|
-
else
|
|
18
|
-
echo "QUALIA: No project detected. Run /qualia-new to start."
|
|
19
|
-
fi
|
|
20
|
-
exit 0
|
|
21
|
-
fi
|
|
22
|
-
|
|
23
|
-
# Branded banner for every session start
|
|
24
|
-
if [ -f "$STATE" ]; then
|
|
25
|
-
node "$UI" banner router
|
|
26
|
-
# Read next command from state.js and suggest it
|
|
27
|
-
NEXT=$(node "$HOME/.claude/bin/state.js" check 2>/dev/null | node -e "try{let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const j=JSON.parse(d);process.stdout.write(j.next_command||'')})}" 2>/dev/null)
|
|
28
|
-
[ -n "$NEXT" ] && node "$UI" info "Run $NEXT to continue"
|
|
29
|
-
elif [ -f ".continue-here.md" ]; then
|
|
30
|
-
node "$UI" banner router
|
|
31
|
-
node "$UI" warn "Handoff found — read .continue-here.md to resume"
|
|
32
|
-
else
|
|
33
|
-
node "$UI" banner router
|
|
34
|
-
node "$UI" info "No project detected. Run /qualia-new to start."
|
|
35
|
-
fi
|