qualia-framework-v2 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework-v2",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework-v2": "./bin/cli.js"
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://github.com/qualia-solutions/qualia-framework-v2#readme",
25
25
  "scripts": {
26
- "test": "bash tests/hooks.test.sh"
26
+ "test": "bash tests/hooks.test.sh && bash tests/state.test.sh"
27
27
  },
28
28
  "files": [
29
29
  "bin/",
@@ -34,8 +34,7 @@
34
34
  "templates/",
35
35
  "tests/",
36
36
  "CLAUDE.md",
37
- "guide.md",
38
- "statusline.sh"
37
+ "guide.md"
39
38
  ],
40
39
  "engines": {
41
40
  "node": ">=18"
@@ -29,9 +29,27 @@ options:
29
29
  description: "This is a subagent role, not a slash command. Creates agents/{name}.md."
30
30
  ```
31
31
 
32
- **Framework** target: `/home/qualia/Projects/qualia/qualia-framework-v2/skills/{name}/SKILL.md`
32
+ ### 1a. Resolve framework directory
33
+
34
+ If the user chose **Framework skill** or a framework-scoped **Agent**, resolve `${FRAMEWORK_DIR}` — the checkout path of this user's qualia-framework-v2 repo — BEFORE computing any target paths. Never hardcode `/home/<user>/...`; different teammates and operating systems have different paths.
35
+
36
+ ```bash
37
+ # Priority order: env var → git detection → ask user
38
+ FRAMEWORK_DIR="${QUALIA_FRAMEWORK_DIR:-}"
39
+ if [ -z "$FRAMEWORK_DIR" ] && git -C . rev-parse --show-toplevel >/dev/null 2>&1; then
40
+ ORIGIN=$(git -C . config --get remote.origin.url 2>/dev/null)
41
+ case "$ORIGIN" in
42
+ *qualia-framework-v2*) FRAMEWORK_DIR=$(git -C . rev-parse --show-toplevel) ;;
43
+ esac
44
+ fi
45
+ echo "${FRAMEWORK_DIR:-UNRESOLVED}"
46
+ ```
47
+
48
+ If the command prints `UNRESOLVED`, ask the user: *"Where is your qualia-framework-v2 checkout? (absolute path, or type 'local' to save only to ~/.claude/)"*. If they type `local`, downgrade the scope to Local. Otherwise store the answer as `${FRAMEWORK_DIR}` for the rest of the session.
49
+
50
+ **Framework** → target: `${FRAMEWORK_DIR}/skills/{name}/SKILL.md`
33
51
  **Local** → target: `~/.claude/skills/{name}/SKILL.md`
34
- **Agent** → target: `/home/qualia/Projects/qualia/qualia-framework-v2/agents/{name}.md` (framework) or `~/.claude/agents/{name}.md` (local)
52
+ **Agent** → target: `${FRAMEWORK_DIR}/agents/{name}.md` (framework) or `~/.claude/agents/{name}.md` (local)
35
53
 
36
54
  ### 2. Gather Requirements
37
55
 
@@ -119,10 +137,11 @@ Fix any ambiguity the test agent found.
119
137
 
120
138
  ```bash
121
139
  # Framework skill — copy to local .claude for immediate testing
122
- cp /home/qualia/Projects/qualia/qualia-framework-v2/skills/{name}/SKILL.md ~/.claude/skills/{name}/SKILL.md
140
+ mkdir -p ~/.claude/skills/{name}
141
+ cp "${FRAMEWORK_DIR}/skills/{name}/SKILL.md" ~/.claude/skills/{name}/SKILL.md
123
142
 
124
143
  # Verify it parses
125
- node -e "const fs=require('fs');const c=fs.readFileSync('/home/qualia/.claude/skills/{name}/SKILL.md','utf8');if(!c.includes('---'))throw new Error('missing frontmatter');if(!c.match(/^name:\s*\S/m))throw new Error('missing name');if(!c.match(/^description:\s*\S/m))throw new Error('missing description');console.log('OK')"
144
+ node -e "const fs=require('fs');const os=require('os');const path=require('path');const c=fs.readFileSync(path.join(os.homedir(),'.claude/skills/{name}/SKILL.md'),'utf8');if(!c.includes('---'))throw new Error('missing frontmatter');if(!c.match(/^name:\s*\S/m))throw new Error('missing name');if(!c.match(/^description:\s*\S/m))throw new Error('missing description');console.log('OK')"
126
145
  ```
127
146
 
128
147
  ### 7. Commit (framework skills only)
@@ -131,7 +150,7 @@ Do NOT commit unless the user explicitly says "commit" or "ship it".
131
150
 
132
151
  When they do:
133
152
  ```bash
134
- cd /home/qualia/Projects/qualia/qualia-framework-v2
153
+ cd "${FRAMEWORK_DIR}"
135
154
  git add skills/{name}/
136
155
  git commit -m "feat: add /{name} skill"
137
156
  ```
@@ -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="$(dirname "$0")/../hooks"
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
- # --- block-env-edit.sh ---
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"}}' | bash "$HOOKS_DIR/block-env-edit.sh" > /dev/null 2>&1
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"}}' | bash "$HOOKS_DIR/block-env-edit.sh" > /dev/null 2>&1
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"}}' | bash "$HOOKS_DIR/block-env-edit.sh" > /dev/null 2>&1
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
- echo '{"tool_input":{"file_path":"src/app.tsx"}}' | bash "$HOOKS_DIR/block-env-edit.sh" > /dev/null 2>&1
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"}}' | bash "$HOOKS_DIR/block-env-edit.sh" > /dev/null 2>&1
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.sh ---
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;"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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;"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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;"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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;"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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);"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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;"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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;"}}' | bash "$HOOKS_DIR/migration-guard.sh" > /dev/null 2>&1
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.sh ---
85
+ # --- branch-guard.js (grep-based — full run needs git + real config) ---
67
86
  echo ""
68
87
  echo "branch-guard:"
69
88
 
70
- if [ -f "$HOOKS_DIR/branch-guard.sh" ]; then
71
- echo " ✓ branch-guard.sh exists"
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 " ✗ branch-guard.sh missing"
93
+ echo " ✗ not reading from .qualia-config.json"
75
94
  FAIL=$((FAIL + 1))
76
95
  fi
77
96
 
78
- if grep -q 'ROLE' "$HOOKS_DIR/branch-guard.sh"; then
79
- echo " ✓ checks ROLE variable"
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 ROLE check"
101
+ echo " ✗ missing branch check"
83
102
  FAIL=$((FAIL + 1))
84
103
  fi
85
104
 
86
- if grep -q 'z "$ROLE"' "$HOOKS_DIR/branch-guard.sh"; then
87
- echo " ✓ defaults to deny on missing ROLE"
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 empty-ROLE deny"
109
+ echo " ✗ missing OWNER check"
91
110
  FAIL=$((FAIL + 1))
92
111
  fi
93
112
 
94
- # --- pre-push.sh ---
113
+ # --- pre-push.js ---
95
114
  echo ""
96
115
  echo "pre-push:"
97
116
 
98
- if grep -q 'command -v node' "$HOOKS_DIR/pre-push.sh"; then
99
- echo " ✓ checks for node availability"
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 node check"
121
+ echo " ✗ missing tracking.json update"
103
122
  FAIL=$((FAIL + 1))
104
123
  fi
105
124
 
106
- if grep -q 'tracking sync failed' "$HOOKS_DIR/pre-push.sh"; then
107
- echo " ✓ captures tracking sync errors"
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 tracking sync error capture"
129
+ echo " ✗ missing last_commit stamp"
111
130
  FAIL=$((FAIL + 1))
112
131
  fi
113
132
 
114
- # --- branch-guard reads from .qualia-config.json ---
115
- echo ""
116
- echo "branch-guard config source:"
117
-
118
- if grep -q 'qualia-config.json' "$HOOKS_DIR/branch-guard.sh"; then
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.sh ---
139
+ # --- pre-deploy-gate.js ---
127
140
  echo ""
128
141
  echo "pre-deploy-gate:"
129
142
 
130
- if [ -f "$HOOKS_DIR/pre-deploy-gate.sh" ]; then
131
- echo " ✓ pre-deploy-gate.sh exists"
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 " ✗ pre-deploy-gate.sh missing"
147
+ echo " ✗ missing TypeScript check"
135
148
  FAIL=$((FAIL + 1))
136
149
  fi
137
150
 
138
- if grep -q 'tsc --noEmit' "$HOOKS_DIR/pre-deploy-gate.sh"; then
139
- echo " ✓ runs TypeScript check"
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 TypeScript check"
155
+ echo " ✗ missing service_role check"
143
156
  FAIL=$((FAIL + 1))
144
157
  fi
145
158
 
146
- if grep -q 'service_role' "$HOOKS_DIR/pre-deploy-gate.sh"; then
147
- echo " ✓ checks for service_role leaks"
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 service_role check"
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 ==="