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.
package/README.md CHANGED
@@ -49,10 +49,18 @@ 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
53
- - **3 rules** — security, frontend, deployment
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
+ - **4 rules** — security, frontend, design-reference, 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**. Requires Node.js 18+ and Claude Code.
59
+
60
+ - Every hook and the status line are pure Node.js — no external bash, jq, or GNU coreutils required.
61
+ - Skills are executed by Claude Code's own Bash tool (which Claude Code provides on all platforms, including Windows).
62
+ - Tested on Fedora, EndeavourOS, macOS, and Windows 10/11.
63
+
56
64
  ## Why It Works
57
65
 
58
66
  ### Goal-Backward Verification
@@ -65,7 +73,7 @@ Splitting planner, builder, and verifier into separate agents with separate cont
65
73
 
66
74
  ### Production-Grade Hooks
67
75
 
68
- The `settings.json` hooks are real ops engineering, not theoretical:
76
+ All 8 hooks are real ops engineering, not theoretical. Highlights:
69
77
 
70
78
  - **Pre-deploy gate** — TypeScript, lint, tests, build, and `service_role` leak scan before `vercel --prod`
71
79
  - **Branch guard** — Role-aware: owner can push to main, employees can't
@@ -94,9 +102,9 @@ npx qualia-framework-v2 install
94
102
  ~/.claude/
95
103
  ├── skills/ 19 slash commands
96
104
  ├── agents/ planner.md, builder.md, verifier.md, qa-browser.md
97
- ├── hooks/ 8 shell scripts (session-start, branch, env, migration, deploy, push, compact, auto-update)
98
- ├── bin/ state.js (state machine with precondition enforcement)
99
- ├── knowledge/ learned-patterns.md, common-fixes.md, client-prefs.md (auto-loaded by skills)
105
+ ├── hooks/ 8 Node.js hooks — cross-platform (no bash dependency)
106
+ ├── bin/ state.js (state machine) + qualia-ui.js (cosmetics library)
107
+ ├── knowledge/ learned-patterns.md, common-fixes.md, client-prefs.md (loaded by plan/debug/new)
100
108
  ├── rules/ security.md, frontend.md, deployment.md
101
109
  ├── qualia-templates/ tracking.json, state.md, project.md, plan.md, DESIGN.md
102
110
  ├── CLAUDE.md global instructions (role-configured per team member)
package/bin/cli.js CHANGED
@@ -94,13 +94,21 @@ function cmdUpdate() {
94
94
  console.log("");
95
95
 
96
96
  try {
97
- // Pull latest and reinstall with saved code
98
- execSync(
99
- `npx qualia-framework-v2@latest install <<< "${cfg.code}"`,
100
- { stdio: "inherit", shell: true, timeout: 60000 }
101
- );
97
+ const { spawnSync } = require("child_process");
98
+ const r = spawnSync("npx", ["qualia-framework-v2@latest", "install"], {
99
+ input: cfg.code + "\n",
100
+ stdio: ["pipe", "inherit", "inherit"],
101
+ shell: process.platform === "win32", // npx is a .cmd shim on Windows — must go through shell
102
+ timeout: 120000,
103
+ encoding: "utf8",
104
+ });
105
+ if (r.status !== 0) {
106
+ console.log(` ${RED}✗${RESET} Update failed. Run manually: npx qualia-framework-v2@latest install`);
107
+ process.exit(1);
108
+ }
102
109
  } catch (e) {
103
- console.log(` ${RED}✗${RESET} Update failed. Run manually: npx qualia-framework-v2@latest install`);
110
+ console.log(` ${RED}✗${RESET} Update failed: ${e.message}`);
111
+ console.log(` ${DIM}Run manually:${RESET} npx qualia-framework-v2@latest install`);
104
112
  process.exit(1);
105
113
  }
106
114
  }
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) {
@@ -160,12 +170,11 @@ async function main() {
160
170
  // ─── Status line ───────────────────────────────────────
161
171
  log(`${WHITE}Status line${RESET}`);
162
172
  try {
163
- const slDest = path.join(CLAUDE_DIR, "statusline.sh");
164
- copy(path.join(FRAMEWORK_DIR, "statusline.sh"), slDest);
165
- fs.chmodSync(slDest, 0o755);
166
- ok("statusline.sh");
173
+ const slDest = path.join(CLAUDE_DIR, "bin", "statusline.js");
174
+ copy(path.join(FRAMEWORK_DIR, "bin", "statusline.js"), slDest);
175
+ ok("statusline.js");
167
176
  } catch (e) {
168
- warn(`statusline.sh — ${e.message}`);
177
+ warn(`statusline.js — ${e.message}`);
169
178
  }
170
179
 
171
180
  // ─── Templates ─────────────────────────────────────────
@@ -214,6 +223,11 @@ async function main() {
214
223
  );
215
224
  fs.chmodSync(path.join(binDest, "qualia-ui.js"), 0o755);
216
225
  ok("qualia-ui.js (cosmetics library)");
226
+ copy(
227
+ path.join(FRAMEWORK_DIR, "bin", "statusline.js"),
228
+ path.join(binDest, "statusline.js")
229
+ );
230
+ ok("statusline.js (status bar renderer)");
217
231
  } catch (e) {
218
232
  warn(`scripts — ${e.message}`);
219
233
  }
@@ -293,7 +307,7 @@ async function main() {
293
307
  // Status line
294
308
  settings.statusLine = {
295
309
  type: "command",
296
- command: "~/.claude/statusline.sh",
310
+ command: `node "${path.join(CLAUDE_DIR, "bin", "statusline.js")}"`,
297
311
  };
298
312
 
299
313
  // Spinner
@@ -331,8 +345,11 @@ async function main() {
331
345
  ],
332
346
  };
333
347
 
334
- // Hooks — full system
348
+ // Hooks — pure Node.js, cross-platform (Windows/macOS/Linux).
349
+ // Every hook command is `node <absolute-path-to-hook.js>` which avoids the
350
+ // bash/Git Bash requirement on Windows.
335
351
  const hd = path.join(CLAUDE_DIR, "hooks");
352
+ const nodeCmd = (hookFile) => `node "${path.join(hd, hookFile)}"`;
336
353
  settings.hooks = {
337
354
  SessionStart: [
338
355
  {
@@ -340,7 +357,7 @@ async function main() {
340
357
  hooks: [
341
358
  {
342
359
  type: "command",
343
- command: `${hd}/session-start.sh`,
360
+ command: nodeCmd("session-start.js"),
344
361
  timeout: 5,
345
362
  },
346
363
  ],
@@ -352,28 +369,28 @@ async function main() {
352
369
  hooks: [
353
370
  {
354
371
  type: "command",
355
- command: `${hd}/auto-update.sh`,
372
+ command: nodeCmd("auto-update.js"),
356
373
  timeout: 5,
357
374
  },
358
375
  {
359
376
  type: "command",
360
377
  if: "Bash(git push*)",
361
- command: `${hd}/branch-guard.sh`,
378
+ command: nodeCmd("branch-guard.js"),
362
379
  timeout: 10,
363
380
  statusMessage: "◆ Checking branch permissions...",
364
381
  },
365
382
  {
366
383
  type: "command",
367
384
  if: "Bash(git push*)",
368
- command: `${hd}/pre-push.sh`,
385
+ command: nodeCmd("pre-push.js"),
369
386
  timeout: 15,
370
387
  statusMessage: "◆ Syncing tracking...",
371
388
  },
372
389
  {
373
390
  type: "command",
374
391
  if: "Bash(vercel --prod*)",
375
- command: `${hd}/pre-deploy-gate.sh`,
376
- timeout: 120,
392
+ command: nodeCmd("pre-deploy-gate.js"),
393
+ timeout: 180,
377
394
  statusMessage: "◆ Running quality gates...",
378
395
  },
379
396
  ],
@@ -384,14 +401,14 @@ async function main() {
384
401
  {
385
402
  type: "command",
386
403
  if: "Edit(*.env*)|Write(*.env*)",
387
- command: `${hd}/block-env-edit.sh`,
404
+ command: nodeCmd("block-env-edit.js"),
388
405
  timeout: 5,
389
406
  statusMessage: "◆ Checking file permissions...",
390
407
  },
391
408
  {
392
409
  type: "command",
393
410
  if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)",
394
- command: `${hd}/migration-guard.sh`,
411
+ command: nodeCmd("migration-guard.js"),
395
412
  timeout: 10,
396
413
  statusMessage: "◆ Checking migration safety...",
397
414
  },
@@ -404,25 +421,13 @@ async function main() {
404
421
  hooks: [
405
422
  {
406
423
  type: "command",
407
- command: `${hd}/pre-compact.sh`,
424
+ command: nodeCmd("pre-compact.js"),
408
425
  timeout: 15,
409
426
  statusMessage: "◆ Saving state...",
410
427
  },
411
428
  ],
412
429
  },
413
430
  ],
414
- SubagentStart: [
415
- {
416
- matcher: ".*",
417
- hooks: [
418
- {
419
- type: "command",
420
- command:
421
- 'echo \'{"additionalContext": "◆ Qualia agent spawned"}\'',
422
- },
423
- ],
424
- },
425
- ],
426
431
  };
427
432
 
428
433
  // Permissions
@@ -436,8 +441,6 @@ async function main() {
436
441
  ];
437
442
  }
438
443
 
439
- settings.effortLevel = "high";
440
-
441
444
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
442
445
 
443
446
  ok("Hooks: session-start, auto-update, branch-guard, pre-push, env-block, migration-guard, deploy-gate, pre-compact");
@@ -454,7 +457,7 @@ async function main() {
454
457
  console.log(` Agents: ${WHITE}${agentCount}${RESET} ${DIM}(planner, builder, verifier, qa-browser)${RESET}`);
455
458
  console.log(` Hooks: ${WHITE}8${RESET} ${DIM}(session-start, auto-update, branch-guard, pre-push, env-block, migration-guard, deploy-gate, pre-compact)${RESET}`);
456
459
  console.log(` Rules: ${WHITE}${fs.readdirSync(rulesDir).length}${RESET} ${DIM}(security, frontend, design-reference, deployment)${RESET}`);
457
- console.log(` Scripts: ${WHITE}2${RESET} ${DIM}(state.js, qualia-ui.js)${RESET}`);
460
+ console.log(` Scripts: ${WHITE}3${RESET} ${DIM}(state.js, qualia-ui.js, statusline.js)${RESET}`);
458
461
  console.log(` Knowledge: ${WHITE}3${RESET} ${DIM}(patterns, fixes, client prefs)${RESET}`);
459
462
  console.log(` Templates: ${WHITE}${fs.readdirSync(tmplDir).length}${RESET}`);
460
463
  console.log(` Status line: ${GREEN}✓${RESET}`);
package/bin/qualia-ui.js CHANGED
@@ -20,7 +20,7 @@
20
20
  const fs = require("fs");
21
21
  const path = require("path");
22
22
  const os = require("os");
23
- const { execSync } = require("child_process");
23
+ const { spawnSync } = require("child_process");
24
24
 
25
25
  // ─── Colors ──────────────────────────────────────────────
26
26
  const TEAL = "\x1b[38;2;0;206;209m";
@@ -64,11 +64,14 @@ const ACTIONS = {
64
64
  // ─── State Reading ───────────────────────────────────────
65
65
  function readState() {
66
66
  try {
67
- const out = execSync(`node ${path.join(os.homedir(), ".claude", "bin", "state.js")} check 2>/dev/null`, {
67
+ const statePath = path.join(os.homedir(), ".claude", "bin", "state.js");
68
+ const r = spawnSync(process.execPath, [statePath, "check"], {
68
69
  encoding: "utf8",
69
70
  timeout: 3000,
71
+ stdio: ["ignore", "pipe", "ignore"],
70
72
  });
71
- return JSON.parse(out);
73
+ if (r.status !== 0 || !r.stdout) return null;
74
+ return JSON.parse(r.stdout);
72
75
  } catch {
73
76
  return null;
74
77
  }
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ // Qualia status line — teal branded, shows phase + context + git.
3
+ // Pure Node.js port of the original statusline.sh. Cross-platform
4
+ // (Windows/macOS/Linux). No jq, no GNU stat, no /tmp hardcoding.
5
+ //
6
+ // Reads JSON from stdin (Claude Code status line schema), prints
7
+ // two ANSI-formatted lines to stdout. Never throws — every section
8
+ // is wrapped so missing data degrades gracefully.
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const { spawnSync } = require("child_process");
14
+
15
+ // ─── Colors (matches bin/qualia-ui.js palette) ───────────
16
+ const TEAL = "\x1b[38;2;0;206;209m";
17
+ const TEAL_GLOW = "\x1b[38;2;0;170;175m";
18
+ const TEAL_DIM = "\x1b[38;2;0;130;135m";
19
+ const WHITE = "\x1b[38;2;220;225;230m";
20
+ const DIM = "\x1b[38;2;80;90;100m";
21
+ const GREEN = "\x1b[38;2;52;211;153m";
22
+ const YELLOW = "\x1b[38;2;234;179;8m";
23
+ const RED = "\x1b[38;2;239;68;68m";
24
+ const RESET = "\x1b[0m";
25
+
26
+ // ─── Read input ──────────────────────────────────────────
27
+ function readInput() {
28
+ try {
29
+ const raw = fs.readFileSync(0, "utf8");
30
+ return JSON.parse(raw);
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ const input = readInput();
37
+
38
+ function pick(obj, keypath, fallback) {
39
+ try {
40
+ let cur = obj;
41
+ for (const k of keypath.split(".")) {
42
+ if (cur == null) return fallback;
43
+ cur = cur[k];
44
+ }
45
+ return cur == null ? fallback : cur;
46
+ } catch {
47
+ return fallback;
48
+ }
49
+ }
50
+
51
+ const MODEL = String(pick(input, "model.display_name", ""));
52
+ const DIR = String(pick(input, "workspace.current_dir", process.cwd()));
53
+ const PCT_RAW = Number(pick(input, "context_window.used_percentage", 0)) || 0;
54
+ const PCT = Math.floor(PCT_RAW);
55
+ const COST = Number(pick(input, "cost.total_cost_usd", 0)) || 0;
56
+ const DURATION_MS = Number(pick(input, "cost.total_duration_ms", 0)) || 0;
57
+ const AGENT = String(pick(input, "agent.name", "") || "");
58
+ const WORKTREE = String(pick(input, "worktree.name", "") || "");
59
+
60
+ // ─── Context bar ─────────────────────────────────────────
61
+ let BAR = "";
62
+ let BAR_COLOR = TEAL;
63
+ try {
64
+ if (PCT >= 80) BAR_COLOR = RED;
65
+ else if (PCT >= 50) BAR_COLOR = YELLOW;
66
+ else BAR_COLOR = TEAL;
67
+
68
+ const BAR_WIDTH = 10;
69
+ const filled = Math.max(0, Math.min(BAR_WIDTH, Math.floor((PCT * BAR_WIDTH) / 100)));
70
+ const empty = BAR_WIDTH - filled;
71
+ BAR = "━".repeat(filled) + "╌".repeat(empty);
72
+ } catch {
73
+ BAR = "╌".repeat(10);
74
+ }
75
+
76
+ // ─── Git branch (cached, cross-platform) ─────────────────
77
+ let BRANCH = "";
78
+ let CHANGES = 0;
79
+ try {
80
+ const username = (os.userInfo().username || "anon").replace(/[^a-zA-Z0-9_-]/g, "_");
81
+ const cacheFile = path.join(os.tmpdir(), `qualia-git-cache-${username}`);
82
+
83
+ let fresh = false;
84
+ try {
85
+ const st = fs.statSync(cacheFile);
86
+ if (Date.now() - st.mtimeMs <= 3000) fresh = true;
87
+ } catch {
88
+ fresh = false;
89
+ }
90
+
91
+ if (!fresh) {
92
+ let branch = "";
93
+ let changes = 0;
94
+ try {
95
+ const dirCheck = spawnSync("git", ["rev-parse", "--git-dir"], {
96
+ cwd: DIR,
97
+ encoding: "utf8",
98
+ timeout: 1000,
99
+ stdio: ["ignore", "pipe", "ignore"],
100
+ });
101
+ if (dirCheck.status === 0) {
102
+ const br = spawnSync("git", ["branch", "--show-current"], {
103
+ cwd: DIR,
104
+ encoding: "utf8",
105
+ timeout: 1000,
106
+ stdio: ["ignore", "pipe", "ignore"],
107
+ });
108
+ if (br.status === 0) branch = (br.stdout || "").trim();
109
+
110
+ const st = spawnSync("git", ["status", "--porcelain"], {
111
+ cwd: DIR,
112
+ encoding: "utf8",
113
+ timeout: 1000,
114
+ stdio: ["ignore", "pipe", "ignore"],
115
+ });
116
+ if (st.status === 0) {
117
+ const out = (st.stdout || "").trim();
118
+ changes = out ? out.split("\n").length : 0;
119
+ }
120
+ }
121
+ } catch {}
122
+ try {
123
+ fs.writeFileSync(cacheFile, `${branch}|${changes}`);
124
+ } catch {}
125
+ }
126
+
127
+ try {
128
+ const cached = fs.readFileSync(cacheFile, "utf8");
129
+ const [b, c] = cached.split("|");
130
+ BRANCH = b || "";
131
+ CHANGES = parseInt(c, 10) || 0;
132
+ } catch {}
133
+ } catch {}
134
+
135
+ // ─── Phase info from .planning/tracking.json ─────────────
136
+ let PHASE_INFO = "";
137
+ try {
138
+ const trackingPath = path.join(DIR, ".planning", "tracking.json");
139
+ if (fs.existsSync(trackingPath)) {
140
+ const tracking = JSON.parse(fs.readFileSync(trackingPath, "utf8"));
141
+ const phase = Number(tracking.phase || 0) || 0;
142
+ const total = Number(tracking.total_phases || 0) || 0;
143
+ const status = String(tracking.status || "");
144
+ if (total > 0) {
145
+ const pdone = Math.floor((phase * 100) / total);
146
+ const pfill = Math.max(0, Math.min(4, Math.floor(pdone / 25)));
147
+ const pempt = 4 - pfill;
148
+ const pbar = "●".repeat(pfill) + "○".repeat(pempt);
149
+ PHASE_INFO = `${TEAL}${pbar}${RESET} ${WHITE}P${phase}/${total}${RESET} ${TEAL_GLOW}${status}${RESET}`;
150
+ }
151
+ }
152
+ } catch {}
153
+
154
+ // ─── Duration ────────────────────────────────────────────
155
+ let DUR = "0s";
156
+ try {
157
+ if (DURATION_MS >= 60000) {
158
+ DUR = `${Math.floor(DURATION_MS / 60000)}m`;
159
+ } else {
160
+ DUR = `${Math.floor(DURATION_MS / 1000)}s`;
161
+ }
162
+ } catch {}
163
+
164
+ // ─── Cost ────────────────────────────────────────────────
165
+ let COST_FMT = "$0.00";
166
+ try {
167
+ COST_FMT = `$${COST.toFixed(2)}`;
168
+ } catch {}
169
+
170
+ // ─── Line 1: Project + Git + Agent + Worktree + Phase ────
171
+ let LINE1 = "";
172
+ try {
173
+ const dirBase = path.basename(DIR) || DIR;
174
+ LINE1 = `${TEAL}◆${RESET} ${WHITE}${dirBase}${RESET}`;
175
+ if (BRANCH) {
176
+ if (CHANGES > 0) {
177
+ LINE1 += ` ${DIM}on${RESET} ${TEAL_GLOW}${BRANCH}${RESET} ${YELLOW}~${CHANGES}${RESET}`;
178
+ } else {
179
+ LINE1 += ` ${DIM}on${RESET} ${TEAL_GLOW}${BRANCH}${RESET}`;
180
+ }
181
+ }
182
+ if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
183
+ if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
184
+ if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
185
+ } catch {
186
+ LINE1 = `${TEAL}◆${RESET} ${WHITE}qualia${RESET}`;
187
+ }
188
+
189
+ // ─── Line 2: Context bar + Cost + Duration + Model ───────
190
+ let LINE2 = "";
191
+ try {
192
+ LINE2 =
193
+ `${BAR_COLOR}${BAR}${RESET} ${DIM}${PCT}%${RESET} ` +
194
+ `${DIM}│${RESET} ${DIM}${COST_FMT}${RESET} ` +
195
+ `${DIM}│${RESET} ${DIM}${DUR}${RESET} ` +
196
+ `${DIM}│${RESET} ${TEAL_DIM}${MODEL}${RESET}`;
197
+ } catch {
198
+ LINE2 = `${DIM}${PCT}%${RESET}`;
199
+ }
200
+
201
+ process.stdout.write(LINE1 + "\n");
202
+ process.stdout.write(LINE2 + "\n");
@@ -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);