qualia-framework-v2 2.5.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 +14 -10
- package/agents/planner.md +8 -2
- package/agents/qa-browser.md +186 -0
- package/bin/install.js +52 -27
- package/bin/qualia-ui.js +278 -0
- 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/skills/qualia/SKILL.md +15 -11
- package/skills/qualia-build/SKILL.md +17 -16
- package/skills/qualia-debug/SKILL.md +14 -0
- package/skills/qualia-design/SKILL.md +4 -0
- package/skills/qualia-handoff/SKILL.md +5 -9
- package/skills/qualia-learn/SKILL.md +4 -0
- package/skills/qualia-new/SKILL.md +13 -14
- package/skills/qualia-pause/SKILL.md +4 -0
- package/skills/qualia-plan/SKILL.md +21 -20
- package/skills/qualia-polish/SKILL.md +15 -19
- package/skills/qualia-quick/SKILL.md +9 -0
- package/skills/qualia-report/SKILL.md +4 -0
- package/skills/qualia-resume/SKILL.md +11 -6
- package/skills/qualia-review/SKILL.md +4 -0
- package/skills/qualia-ship/SKILL.md +10 -13
- package/skills/qualia-skill-new/SKILL.md +148 -0
- package/skills/qualia-task/SKILL.md +11 -15
- package/skills/qualia-verify/SKILL.md +49 -20
- package/tests/hooks.test.sh +108 -44
- package/hooks/auto-update.sh +0 -56
- package/hooks/block-env-edit.sh +0 -11
- package/hooks/branch-guard.sh +0 -18
- 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 -17
package/bin/qualia-ui.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Qualia UI — consistent banners, context panels, status for every skill.
|
|
3
|
+
// Zero dependencies. Reads state.js + .qualia-config.json for context.
|
|
4
|
+
//
|
|
5
|
+
// Commands:
|
|
6
|
+
// banner <action> [phase] [subtitle] — full header with context panel
|
|
7
|
+
// context — just the context panel
|
|
8
|
+
// divider — horizontal rule
|
|
9
|
+
// ok <message> — green check line
|
|
10
|
+
// fail <message> — red cross line
|
|
11
|
+
// warn <message> — yellow bang line
|
|
12
|
+
// info <message> — blue dot line
|
|
13
|
+
// spawn <agent> <description> — spawning a subagent
|
|
14
|
+
// wave <N> <total> <task-count> — wave header for /qualia-build
|
|
15
|
+
// task <N> <title> — task line (pending)
|
|
16
|
+
// done <N> <title> [commit] — task line (completed)
|
|
17
|
+
// next <command> — "Run: /qualia-X" footer
|
|
18
|
+
// end <status> [next-command] — closing banner with optional next
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const os = require("os");
|
|
23
|
+
const { execSync } = require("child_process");
|
|
24
|
+
|
|
25
|
+
// ─── Colors ──────────────────────────────────────────────
|
|
26
|
+
const TEAL = "\x1b[38;2;0;206;209m";
|
|
27
|
+
const TEAL_DIM = "\x1b[38;2;0;140;145m";
|
|
28
|
+
const DIM = "\x1b[38;2;100;110;120m";
|
|
29
|
+
const DIM2 = "\x1b[38;2;70;80;90m";
|
|
30
|
+
const GREEN = "\x1b[38;2;52;211;153m";
|
|
31
|
+
const WHITE = "\x1b[38;2;220;225;230m";
|
|
32
|
+
const YELLOW = "\x1b[38;2;234;179;8m";
|
|
33
|
+
const RED = "\x1b[38;2;239;68;68m";
|
|
34
|
+
const BLUE = "\x1b[38;2;96;165;250m";
|
|
35
|
+
const RESET = "\x1b[0m";
|
|
36
|
+
const BOLD = "\x1b[1m";
|
|
37
|
+
|
|
38
|
+
const RULE = "━".repeat(42);
|
|
39
|
+
const RULE_DIM = `${DIM2}${RULE}${RESET}`;
|
|
40
|
+
|
|
41
|
+
// ─── Action Labels ───────────────────────────────────────
|
|
42
|
+
const ACTIONS = {
|
|
43
|
+
router: { label: "SMART ROUTER", glyph: "◆" },
|
|
44
|
+
new: { label: "NEW PROJECT", glyph: "◆" },
|
|
45
|
+
plan: { label: "PLANNING", glyph: "◇" },
|
|
46
|
+
build: { label: "BUILDING", glyph: "◈" },
|
|
47
|
+
verify: { label: "VERIFYING", glyph: "◉" },
|
|
48
|
+
polish: { label: "POLISHING", glyph: "◆" },
|
|
49
|
+
ship: { label: "SHIPPING", glyph: "▲" },
|
|
50
|
+
handoff: { label: "HANDING OFF", glyph: "▶" },
|
|
51
|
+
report: { label: "SESSION REPORT", glyph: "◆" },
|
|
52
|
+
debug: { label: "DEBUGGING", glyph: "◊" },
|
|
53
|
+
learn: { label: "LEARNING", glyph: "◆" },
|
|
54
|
+
pause: { label: "PAUSING", glyph: "◆" },
|
|
55
|
+
resume: { label: "RESUMING", glyph: "◆" },
|
|
56
|
+
review: { label: "REVIEW", glyph: "◆" },
|
|
57
|
+
design: { label: "DESIGN PASS", glyph: "◆" },
|
|
58
|
+
quick: { label: "QUICK FIX", glyph: "◆" },
|
|
59
|
+
task: { label: "TASK", glyph: "◆" },
|
|
60
|
+
"skill-new": { label: "NEW SKILL", glyph: "◆" },
|
|
61
|
+
gaps: { label: "GAP CLOSURE", glyph: "◇" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ─── State Reading ───────────────────────────────────────
|
|
65
|
+
function readState() {
|
|
66
|
+
try {
|
|
67
|
+
const out = execSync(`node ${path.join(os.homedir(), ".claude", "bin", "state.js")} check 2>/dev/null`, {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
timeout: 3000,
|
|
70
|
+
});
|
|
71
|
+
return JSON.parse(out);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readConfig() {
|
|
78
|
+
try {
|
|
79
|
+
const f = path.join(os.homedir(), ".claude", ".qualia-config.json");
|
|
80
|
+
return JSON.parse(fs.readFileSync(f, "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function projectName() {
|
|
87
|
+
try {
|
|
88
|
+
return path.basename(process.cwd());
|
|
89
|
+
} catch {
|
|
90
|
+
return "—";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Rendering Helpers ───────────────────────────────────
|
|
95
|
+
function progressBar(phase, total) {
|
|
96
|
+
if (!total || total < 1) return "";
|
|
97
|
+
const pct = Math.min(100, Math.round(((phase - 1) / total) * 100));
|
|
98
|
+
const filled = Math.round(pct / 10);
|
|
99
|
+
const bar = `${TEAL}${"█".repeat(filled)}${DIM2}${"░".repeat(10 - filled)}${RESET}`;
|
|
100
|
+
return `${bar} ${DIM}${pct}%${RESET}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function colorForStatus(s) {
|
|
104
|
+
const colors = {
|
|
105
|
+
setup: DIM,
|
|
106
|
+
planned: BLUE,
|
|
107
|
+
built: YELLOW,
|
|
108
|
+
verified: GREEN,
|
|
109
|
+
polished: GREEN,
|
|
110
|
+
shipped: TEAL,
|
|
111
|
+
handed_off: TEAL,
|
|
112
|
+
done: GREEN,
|
|
113
|
+
};
|
|
114
|
+
return colors[s] || WHITE;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pad(str, width) {
|
|
118
|
+
// Width-aware padding ignoring ANSI codes
|
|
119
|
+
const visible = str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
120
|
+
const need = Math.max(0, width - visible.length);
|
|
121
|
+
return str + " ".repeat(need);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Commands ────────────────────────────────────────────
|
|
125
|
+
function cmdBanner(action, phase, subtitle) {
|
|
126
|
+
const spec = ACTIONS[action] || { label: (action || "qualia").toUpperCase(), glyph: "◆" };
|
|
127
|
+
const state = readState();
|
|
128
|
+
const config = readConfig();
|
|
129
|
+
const project = projectName();
|
|
130
|
+
|
|
131
|
+
const title = phase
|
|
132
|
+
? `${spec.label} ${DIM}·${WHITE} Phase ${phase}${subtitle ? ` ${DIM}— ${WHITE}${subtitle}` : ""}`
|
|
133
|
+
: spec.label;
|
|
134
|
+
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log(` ${TEAL}${BOLD}${spec.glyph}${RESET} ${WHITE}${BOLD}QUALIA${RESET} ${DIM}►${RESET} ${WHITE}${title}${RESET}`);
|
|
137
|
+
console.log(` ${RULE_DIM}`);
|
|
138
|
+
|
|
139
|
+
// Context panel
|
|
140
|
+
const roleColor = config.role === "OWNER" ? TEAL : BLUE;
|
|
141
|
+
const roleLine = config.role
|
|
142
|
+
? `${roleColor}${config.role}${RESET} ${DIM}·${RESET} ${WHITE}${config.installed_by || ""}${RESET}`
|
|
143
|
+
: `${DIM}(not configured)${RESET}`;
|
|
144
|
+
|
|
145
|
+
console.log(` ${pad(DIM + "Project" + RESET, 20)}${WHITE}${project}${RESET}`);
|
|
146
|
+
|
|
147
|
+
if (state && state.ok) {
|
|
148
|
+
const phaseStr = state.phase_name
|
|
149
|
+
? `${state.phase} of ${state.total_phases} ${DIM}— ${WHITE}${state.phase_name}`
|
|
150
|
+
: `${state.phase} of ${state.total_phases}`;
|
|
151
|
+
console.log(` ${pad(DIM + "Phase" + RESET, 20)}${WHITE}${phaseStr}${RESET}`);
|
|
152
|
+
console.log(` ${pad(DIM + "Status" + RESET, 20)}${colorForStatus(state.status)}${state.status}${RESET}`);
|
|
153
|
+
if (state.tasks_total) {
|
|
154
|
+
console.log(` ${pad(DIM + "Tasks" + RESET, 20)}${WHITE}${state.tasks_done}/${state.tasks_total}${RESET}`);
|
|
155
|
+
}
|
|
156
|
+
const bar = progressBar(state.phase, state.total_phases);
|
|
157
|
+
if (bar) console.log(` ${pad(DIM + "Progress" + RESET, 20)}${bar}`);
|
|
158
|
+
if (state.gap_cycles > 0) {
|
|
159
|
+
console.log(` ${pad(DIM + "Gap cycles" + RESET, 20)}${YELLOW}${state.gap_cycles}/2${RESET}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(` ${pad(DIM + "Role" + RESET, 20)}${roleLine}`);
|
|
164
|
+
console.log(` ${RULE_DIM}`);
|
|
165
|
+
console.log("");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cmdContext() {
|
|
169
|
+
const state = readState();
|
|
170
|
+
const config = readConfig();
|
|
171
|
+
const project = projectName();
|
|
172
|
+
|
|
173
|
+
console.log("");
|
|
174
|
+
console.log(` ${pad(DIM + "Project" + RESET, 20)}${WHITE}${project}${RESET}`);
|
|
175
|
+
|
|
176
|
+
if (state && state.ok) {
|
|
177
|
+
const phaseStr = state.phase_name
|
|
178
|
+
? `${state.phase} of ${state.total_phases} ${DIM}— ${WHITE}${state.phase_name}`
|
|
179
|
+
: `${state.phase} of ${state.total_phases}`;
|
|
180
|
+
console.log(` ${pad(DIM + "Phase" + RESET, 20)}${WHITE}${phaseStr}${RESET}`);
|
|
181
|
+
console.log(` ${pad(DIM + "Status" + RESET, 20)}${colorForStatus(state.status)}${state.status}${RESET}`);
|
|
182
|
+
const bar = progressBar(state.phase, state.total_phases);
|
|
183
|
+
if (bar) console.log(` ${pad(DIM + "Progress" + RESET, 20)}${bar}`);
|
|
184
|
+
} else {
|
|
185
|
+
console.log(` ${DIM}No project detected. Run${RESET} ${TEAL}/qualia-new${RESET}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (config.role) {
|
|
189
|
+
const roleColor = config.role === "OWNER" ? TEAL : BLUE;
|
|
190
|
+
console.log(` ${pad(DIM + "Role" + RESET, 20)}${roleColor}${config.role}${RESET} ${DIM}·${RESET} ${WHITE}${config.installed_by || ""}${RESET}`);
|
|
191
|
+
}
|
|
192
|
+
console.log("");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function cmdDivider() {
|
|
196
|
+
console.log(` ${RULE_DIM}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function cmdOk(msg) {
|
|
200
|
+
console.log(` ${GREEN}✓${RESET} ${WHITE}${msg}${RESET}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function cmdFail(msg) {
|
|
204
|
+
console.log(` ${RED}✗${RESET} ${WHITE}${msg}${RESET}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cmdWarn(msg) {
|
|
208
|
+
console.log(` ${YELLOW}!${RESET} ${WHITE}${msg}${RESET}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cmdInfo(msg) {
|
|
212
|
+
console.log(` ${BLUE}◦${RESET} ${DIM}${msg}${RESET}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cmdSpawn(agent, desc) {
|
|
216
|
+
const name = agent || "agent";
|
|
217
|
+
const d = desc ? ` ${DIM}— ${desc}${RESET}` : "";
|
|
218
|
+
console.log(` ${TEAL}⟐${RESET} ${WHITE}Spawning${RESET} ${TEAL}${name}${RESET}${d}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function cmdWave(num, total, taskCount) {
|
|
222
|
+
console.log("");
|
|
223
|
+
const n = parseInt(num) || 0;
|
|
224
|
+
const t = parseInt(total) || 0;
|
|
225
|
+
const c = parseInt(taskCount) || 0;
|
|
226
|
+
console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}Wave ${n}/${t}${RESET} ${DIM}(${c} ${c === 1 ? "task" : "tasks"}, parallel)${RESET}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function cmdTask(num, title) {
|
|
230
|
+
console.log(` ${DIM}${num}.${RESET} ${WHITE}${title}${RESET} ${DIM}…${RESET}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function cmdDone(num, title, commit) {
|
|
234
|
+
const c = commit ? ` ${DIM}(${commit})${RESET}` : "";
|
|
235
|
+
console.log(` ${GREEN}✓${RESET} ${DIM}${num}.${RESET} ${WHITE}${title}${RESET}${c}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cmdNext(cmd) {
|
|
239
|
+
if (!cmd) return;
|
|
240
|
+
console.log("");
|
|
241
|
+
console.log(` ${TEAL}→${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${cmd}${RESET}`);
|
|
242
|
+
console.log("");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function cmdEnd(status, nextCmd) {
|
|
246
|
+
console.log("");
|
|
247
|
+
console.log(` ${TEAL}${BOLD}◆${RESET} ${WHITE}${BOLD}${status || "DONE"}${RESET}`);
|
|
248
|
+
console.log(` ${RULE_DIM}`);
|
|
249
|
+
if (nextCmd) {
|
|
250
|
+
console.log(` ${TEAL}→${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${nextCmd}${RESET}`);
|
|
251
|
+
}
|
|
252
|
+
console.log("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Main ────────────────────────────────────────────────
|
|
256
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
257
|
+
switch (cmd) {
|
|
258
|
+
case "banner":
|
|
259
|
+
cmdBanner(rest[0] || "router", rest[1] || "", rest.slice(2).join(" "));
|
|
260
|
+
break;
|
|
261
|
+
case "context": cmdContext(); break;
|
|
262
|
+
case "divider": cmdDivider(); break;
|
|
263
|
+
case "ok": cmdOk(rest.join(" ")); break;
|
|
264
|
+
case "fail": cmdFail(rest.join(" ")); break;
|
|
265
|
+
case "warn": cmdWarn(rest.join(" ")); break;
|
|
266
|
+
case "info": cmdInfo(rest.join(" ")); break;
|
|
267
|
+
case "spawn": cmdSpawn(rest[0], rest.slice(1).join(" ")); break;
|
|
268
|
+
case "wave": cmdWave(rest[0], rest[1], rest[2]); break;
|
|
269
|
+
case "task": cmdTask(rest[0], rest.slice(1).join(" ")); break;
|
|
270
|
+
case "done": cmdDone(rest[0], rest[1], rest[2]); break;
|
|
271
|
+
case "next": cmdNext(rest.join(" ")); break;
|
|
272
|
+
case "end": cmdEnd(rest[0], rest.slice(1).join(" ")); break;
|
|
273
|
+
default:
|
|
274
|
+
console.error(
|
|
275
|
+
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end> [args]`
|
|
276
|
+
);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
@@ -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);
|