qualia-framework 3.3.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/statusline.js CHANGED
@@ -93,35 +93,55 @@ try {
93
93
  let branch = "";
94
94
  let changes = 0;
95
95
  try {
96
- const dirCheck = spawnSync("git", ["rev-parse", "--git-dir"], {
96
+ // Single git spawn: `status -b --porcelain=v1` returns branch on the
97
+ // first line (`## branch.name...`) and one change per subsequent line.
98
+ // Three separate git spawns cost ~450ms on Windows; this collapses to one.
99
+ const st = spawnSync("git", ["status", "-b", "--porcelain=v1"], {
97
100
  cwd: DIR,
98
101
  encoding: "utf8",
99
102
  timeout: 1000,
100
103
  stdio: ["ignore", "pipe", "ignore"],
104
+ shell: process.platform === "win32",
101
105
  });
102
- if (dirCheck.status === 0) {
103
- const br = spawnSync("git", ["branch", "--show-current"], {
104
- cwd: DIR,
105
- encoding: "utf8",
106
- timeout: 1000,
107
- stdio: ["ignore", "pipe", "ignore"],
108
- });
109
- if (br.status === 0) branch = (br.stdout || "").trim();
110
-
111
- const st = spawnSync("git", ["status", "--porcelain"], {
112
- cwd: DIR,
113
- encoding: "utf8",
114
- timeout: 1000,
115
- stdio: ["ignore", "pipe", "ignore"],
116
- });
117
- if (st.status === 0) {
118
- const out = (st.stdout || "").trim();
119
- changes = out ? out.split("\n").length : 0;
106
+ if (st.status === 0) {
107
+ const lines = (st.stdout || "").split("\n");
108
+ const header = lines[0] || "";
109
+ if (header.startsWith("## ")) {
110
+ // Possible forms:
111
+ // "## main"
112
+ // "## main...origin/main"
113
+ // "## main...origin/main [ahead 1, behind 2]"
114
+ // "## HEAD (no branch)" ← detached
115
+ // "## No commits yet on main"
116
+ let raw = header.slice(3);
117
+ const ellipsisIdx = raw.indexOf("...");
118
+ if (ellipsisIdx !== -1) raw = raw.slice(0, ellipsisIdx);
119
+ // Strip any trailing "[ahead/behind]" annotation that survived
120
+ raw = raw.replace(/\s*\[.*\]\s*$/, "").trim();
121
+ if (raw === "HEAD (no branch)") {
122
+ branch = "HEAD";
123
+ } else if (raw.startsWith("No commits yet on ")) {
124
+ branch = raw.slice("No commits yet on ".length).trim();
125
+ } else {
126
+ branch = raw;
127
+ }
128
+ }
129
+ // Count change lines: every non-empty line after the header
130
+ for (let i = 1; i < lines.length; i++) {
131
+ if (lines[i].length > 0) changes++;
120
132
  }
121
133
  }
122
134
  } catch {}
123
135
  try {
124
- fs.writeFileSync(cacheFile, `${branch}|${changes}`);
136
+ // Atomic write: tmp + rename so concurrent prompts can't observe
137
+ // a half-written cache file. Same pattern as state.js atomicWrite.
138
+ const tmp = `${cacheFile}.tmp.${process.pid}`;
139
+ fs.writeFileSync(tmp, `${branch}|${changes}`);
140
+ try {
141
+ fs.renameSync(tmp, cacheFile);
142
+ } catch {
143
+ try { fs.unlinkSync(tmp); } catch {}
144
+ }
125
145
  } catch {}
126
146
  }
127
147
 
@@ -34,7 +34,11 @@ Content-Type: application/json
34
34
  ```json
35
35
  {
36
36
  "project": "client-project-name",
37
+ "project_id": "qs-acme-portal",
38
+ "team_id": "qualia-solutions",
39
+ "git_remote": "github.com/QualiasolutionsCY/acme-portal",
37
40
  "client": "Client Name",
41
+ "milestone": 2,
38
42
  "phase": 2,
39
43
  "phase_name": "Authentication & Dashboard",
40
44
  "total_phases": 4,
@@ -43,8 +47,19 @@ Content-Type: application/json
43
47
  "tasks_total": 5,
44
48
  "verification": "pass",
45
49
  "gap_cycles": 0,
50
+ "build_count": 12,
51
+ "deploy_count": 3,
46
52
  "deployed_url": "https://client.vercel.app",
53
+ "lifetime": {
54
+ "tasks_completed": 23,
55
+ "phases_completed": 8,
56
+ "milestones_completed": 1,
57
+ "total_phases": 8,
58
+ "last_closed_milestone": 1
59
+ },
60
+ "session_started_at": "2026-04-12T13:45:00Z",
47
61
  "session_duration_minutes": 45,
62
+ "last_pushed_at": "2026-04-12T14:25:00Z",
48
63
  "commits": ["abc1234", "def5678"],
49
64
  "notes": "Completed auth flow, dashboard layout, and API routes.",
50
65
  "submitted_by": "Fawzi Goussous",
@@ -52,6 +67,12 @@ Content-Type: application/json
52
67
  }
53
68
  ```
54
69
 
70
+ **`gap_cycles` polymorphism (v3.5+):** in `tracking.json` (the file the ERP
71
+ reads from git for passive monitoring) `gap_cycles` is an OBJECT keyed by
72
+ phase number — `{"1": 0, "2": 1}`. In the POST `/api/v1/reports` body,
73
+ `/qualia-report` flattens to a NUMBER for the current phase. Receivers must
74
+ accept both shapes: if object, use `gap_cycles[String(phase)] || 0`.
75
+
55
76
  **Response (200 OK):**
56
77
  ```json
57
78
  {
@@ -119,10 +140,17 @@ Authorization: Bearer <api-key>
119
140
  "ok": true,
120
141
  "tracking": {
121
142
  "project": "client-project-name",
143
+ "milestone": 2,
122
144
  "phase": 2,
123
145
  "total_phases": 4,
124
146
  "status": "built",
125
- "last_updated": "2026-04-12T14:30:00Z"
147
+ "last_updated": "2026-04-12T14:30:00Z",
148
+ "lifetime": {
149
+ "tasks_completed": 23,
150
+ "phases_completed": 8,
151
+ "milestones_completed": 1,
152
+ "total_phases": 8
153
+ }
126
154
  }
127
155
  }
128
156
  ```
@@ -134,6 +162,8 @@ Authorization: Bearer <api-key>
134
162
  - Network failures are non-blocking — the report is saved locally regardless.
135
163
  - The ERP reads `tracking.json` directly from git for real-time status (no API call needed for passive monitoring).
136
164
  - Reports are append-only — no update or delete endpoints exist.
165
+ - `tracking.json` includes `milestone` and `lifetime` fields (added in v3.4). These survive across milestone resets and `state.js init` calls. For aggregate reporting, use `lifetime.total_phases` + current `total_phases` for the grand total across all milestones.
166
+ - Backward compatibility: if `lifetime` is absent in tracking.json, treat all counters as 0 and `milestone` as 1.
137
167
 
138
168
  ## Required Fields
139
169
 
@@ -144,6 +174,15 @@ Authorization: Bearer <api-key>
144
174
  | status | string | yes | Current status (setup, planned, built, verified, etc.) |
145
175
  | submitted_by | string | yes | Team member name |
146
176
  | submitted_at | string | yes | ISO 8601 timestamp |
177
+ | milestone | number | recommended | Current milestone number (1-indexed) |
178
+ | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases, last_closed_milestone |
179
+ | project_id | string | recommended (v3.6+) | Stable per-project identifier — preferred dedupe key over `project` slug. Survives directory renames. |
180
+ | team_id | string | recommended (v3.6+) | Installation's team identifier. Composite `(team_id, project_id)` is the canonical project key. |
181
+ | git_remote | string | optional (v3.6+) | e.g. `github.com/QualiasolutionsCY/foo`. Lets the ERP correlate tracking with the source repo. |
182
+ | session_started_at | string | optional (v3.6+) | ISO 8601 — when the current Claude Code session began. |
183
+ | last_pushed_at | string | optional (v3.6+) | ISO 8601 — distinct from `last_updated` (which fires on local writes too). |
184
+ | build_count | number | optional (v3.6+) | Lifetime build counter. |
185
+ | deploy_count | number | optional (v3.6+) | Lifetime deploy counter. |
147
186
 
148
187
  All other fields are optional but recommended for complete reporting.
149
188
 
@@ -6,7 +6,7 @@
6
6
  const fs = require("fs");
7
7
  const path = require("path");
8
8
  const os = require("os");
9
- const { spawn, spawnSync } = require("child_process");
9
+ const { spawnSync } = require("child_process");
10
10
 
11
11
  const _traceStart = Date.now();
12
12
 
@@ -14,6 +14,7 @@ const CLAUDE_DIR = path.join(os.homedir(), ".claude");
14
14
  const CACHE_FILE = path.join(CLAUDE_DIR, ".qualia-last-update-check");
15
15
  const CONFIG_FILE = path.join(CLAUDE_DIR, ".qualia-config.json");
16
16
  const LOCK_FILE = path.join(CLAUDE_DIR, ".qualia-updating");
17
+ const NOTIF_FILE = path.join(CLAUDE_DIR, ".qualia-update-available.json");
17
18
  const MAX_AGE_MS = 24 * 60 * 60 * 1000;
18
19
 
19
20
  function _trace(hookName, result, extra) {
@@ -48,9 +49,6 @@ try {
48
49
  process.exit(0);
49
50
  }
50
51
 
51
- // Update cache timestamp immediately to debounce concurrent checks
52
- fs.writeFileSync(CACHE_FILE, String(Math.floor(Date.now() / 1000)));
53
-
54
52
  // Read current config
55
53
  let cfg = {};
56
54
  try {
@@ -64,76 +62,62 @@ try {
64
62
  process.exit(0);
65
63
  }
66
64
 
67
- // Fork the check-and-update into a detached background process so the hook
68
- // returns immediately and Claude Code is never blocked.
69
- //
70
- // OWNER: silent auto-install (unchanged behavior).
71
- // EMPLOYEE: write a sticky notification file — session-start.js renders a
72
- // banner every session until they run the update manually. Fawzi (OWNER)
73
- // never sees the banner because his framework auto-updates ahead of it.
74
- const script = `
75
- const fs = require("fs");
76
- const path = require("path");
77
- const { spawnSync } = require("child_process");
78
- const CLAUDE_DIR = ${JSON.stringify(CLAUDE_DIR)};
79
- const LOCK_FILE = ${JSON.stringify(LOCK_FILE)};
80
- const CONFIG_FILE = ${JSON.stringify(CONFIG_FILE)};
81
- const NOTIF_FILE = path.join(CLAUDE_DIR, ".qualia-update-available.json");
82
- const cfg = ${JSON.stringify(cfg)};
65
+ // Synchronously fetch the latest version from npm. Tight timeout so the hook
66
+ // never blocks Claude Code for long. The cache timestamp is written ONLY if
67
+ // this fetch succeeds — otherwise the next session retries (no 24h blackout
68
+ // when the network is unreachable).
69
+ let latest = "";
70
+ try {
71
+ fs.writeFileSync(LOCK_FILE, String(process.pid));
72
+ const r = spawnSync("npm", ["view", "qualia-framework", "version"], {
73
+ encoding: "utf8",
74
+ timeout: 3000,
75
+ shell: process.platform === "win32",
76
+ stdio: ["ignore", "pipe", "ignore"],
77
+ });
78
+ latest = ((r.stdout || "").trim());
79
+ } catch {}
80
+ try { fs.unlinkSync(LOCK_FILE); } catch {}
81
+
82
+ if (!latest) {
83
+ // Fetch failed — leave cache untouched so the next call retries.
84
+ _trace("auto-update", "allow", { reason: "npm-fetch-failed" });
85
+ process.exit(0);
86
+ }
87
+
88
+ // Successful fetch — debounce future checks for 24h.
89
+ fs.writeFileSync(CACHE_FILE, String(Math.floor(Date.now() / 1000)));
90
+
91
+ const cmp = (a, b) => {
92
+ const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
93
+ for (let i = 0; i < 3; i++) {
94
+ if ((pa[i]||0) > (pb[i]||0)) return 1;
95
+ if ((pa[i]||0) < (pb[i]||0)) return -1;
96
+ }
97
+ return 0;
98
+ };
99
+
100
+ if (cmp(latest, cfg.version) > 0) {
101
+ // Update available — write a sticky notification file for ALL roles.
102
+ // session-start.js renders a banner every session until the user runs
103
+ // `npx qualia-framework update` manually. We do NOT auto-install during
104
+ // a live Claude Code session because install rewrites ~/.claude/settings.json
105
+ // and can corrupt the running session.
83
106
  try {
84
- fs.writeFileSync(LOCK_FILE, String(process.pid));
85
- const r = spawnSync("npm", ["view", "qualia-framework", "version"], {
86
- encoding: "utf8",
87
- timeout: 15000,
88
- shell: process.platform === "win32",
89
- });
90
- const latest = ((r.stdout || "").trim());
91
- if (!latest) { try { fs.unlinkSync(LOCK_FILE); } catch {} process.exit(0); }
92
- const cmp = (a, b) => {
93
- const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
94
- for (let i = 0; i < 3; i++) {
95
- if ((pa[i]||0) > (pb[i]||0)) return 1;
96
- if ((pa[i]||0) < (pb[i]||0)) return -1;
97
- }
98
- return 0;
99
- };
100
- if (cmp(latest, cfg.version) > 0) {
101
- if (cfg.role === "OWNER") {
102
- // Silent auto-install for OWNER — no notification banner ever shown.
103
- spawnSync("npx", ["qualia-framework@latest", "install"], {
104
- input: cfg.code + "\\n",
105
- timeout: 120000,
106
- stdio: ["pipe", "ignore", "ignore"],
107
- shell: process.platform === "win32",
108
- });
109
- try { fs.unlinkSync(NOTIF_FILE); } catch {}
110
- } else {
111
- // EMPLOYEE: write sticky notification. session-start.js will render
112
- // a visible banner every session until the employee runs the update.
113
- try {
114
- fs.writeFileSync(NOTIF_FILE, JSON.stringify({
115
- current: cfg.version,
116
- latest: latest,
117
- detected_at: new Date().toISOString(),
118
- }, null, 2));
119
- } catch {}
120
- }
121
- } else {
122
- // Already up to date — clear any stale notification file.
123
- try { fs.unlinkSync(NOTIF_FILE); } catch {}
124
- }
107
+ fs.writeFileSync(NOTIF_FILE, JSON.stringify({
108
+ current: cfg.version,
109
+ latest: latest,
110
+ detected_at: new Date().toISOString(),
111
+ }, null, 2));
125
112
  } catch {}
126
- try { fs.unlinkSync(LOCK_FILE); } catch {}
127
- `;
128
-
129
- const child = spawn(process.execPath, ["-e", script], {
130
- detached: true,
131
- stdio: "ignore",
132
- });
133
- child.unref();
113
+ _trace("auto-update", "allow", { reason: "notification-written", current: cfg.version, latest });
114
+ } else {
115
+ // Already up to date — clear any stale notification file.
116
+ try { fs.unlinkSync(NOTIF_FILE); } catch {}
117
+ _trace("auto-update", "allow", { reason: "up-to-date", version: cfg.version });
118
+ }
134
119
  } catch {
135
120
  // Silent — never block the tool call
136
121
  }
137
122
 
138
- _trace("auto-update", "allow", { reason: "check-spawned" });
139
123
  process.exit(0);
@@ -2,7 +2,7 @@
2
2
  // ~/.claude/hooks/branch-guard.js — block non-OWNER push to main/master.
3
3
  // PreToolUse hook on `git push*` commands. Reads role from
4
4
  // ~/.claude/.qualia-config.json (single source of truth).
5
- // Exits 1 to BLOCK. Exits 0 to allow.
5
+ // Exits 2 to BLOCK (Claude Code hook protocol). Exits 0 to allow.
6
6
  // Cross-platform (Windows/macOS/Linux).
7
7
 
8
8
  const fs = require("fs");
@@ -30,8 +30,17 @@ function _trace(hookName, result, extra) {
30
30
  } catch {}
31
31
  }
32
32
 
33
- function fail(msg) {
33
+ function fail(msg, extraLines) {
34
+ // Claude Code surfaces stderr in hook block reasons — write there primarily.
35
+ // Also mirror to stdout so downstream tooling that scrapes stdout still sees it.
36
+ console.error(msg);
34
37
  console.log(msg);
38
+ if (Array.isArray(extraLines)) {
39
+ for (const line of extraLines) {
40
+ console.error(line);
41
+ console.log(line);
42
+ }
43
+ }
35
44
  _trace("branch-guard", "block", { reason: msg });
36
45
  process.exit(2);
37
46
  }
@@ -48,19 +57,68 @@ if (!role) {
48
57
  fail(`BLOCKED: Cannot determine role from ${CONFIG}. Defaulting to deny.`);
49
58
  }
50
59
 
60
+ // Read Claude Code hook payload from stdin (if any). Contains tool_input.command
61
+ // with the actual `git push ...` invocation. Parsing this lets us catch refspec
62
+ // bypasses like `git push origin feature/x:main` that --show-current would miss.
63
+ let pushCommand = "";
64
+ try {
65
+ const raw = fs.readFileSync(0, "utf8");
66
+ if (raw && raw.trim()) {
67
+ const payload = JSON.parse(raw);
68
+ pushCommand = (payload && payload.tool_input && payload.tool_input.command) || "";
69
+ }
70
+ } catch {
71
+ // No stdin or non-JSON stdin — fall through to branch check.
72
+ }
73
+
74
+ // Tokenize the push command and detect refspecs targeting main/master.
75
+ // Refspec forms: <src>:<dst>, :<dst> (delete), +<src>:<dst> (force).
76
+ // We only flag explicit <src>:<dst> refspecs here; bare branch pushes
77
+ // (e.g. `git push origin main` from a non-main branch) are uncommon and
78
+ // handled by the --show-current fallback below when applicable.
79
+ function refspecTargetsProtected(cmd) {
80
+ if (!cmd || typeof cmd !== "string") return null;
81
+ const tokens = cmd.split(/\s+/).filter(Boolean);
82
+ const pushIdx = tokens.indexOf("push");
83
+ if (pushIdx === -1) return null;
84
+
85
+ for (let i = pushIdx + 1; i < tokens.length; i++) {
86
+ let tok = tokens[i];
87
+ if (tok.startsWith("-")) continue;
88
+ if (tok.startsWith("+")) tok = tok.slice(1);
89
+ tok = tok.replace(/^['"]|['"]$/g, "");
90
+
91
+ if (tok.includes(":")) {
92
+ const parts = tok.split(":");
93
+ const dst = parts[parts.length - 1].replace(/^refs\/heads\//, "");
94
+ if (dst === "main" || dst === "master") return dst;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ const refspecTarget = refspecTargetsProtected(pushCommand);
101
+ if (refspecTarget && role !== "OWNER") {
102
+ fail(
103
+ `BLOCKED: Employees cannot push to ${refspecTarget}. Create a feature branch first.`,
104
+ ["Run: git checkout -b feature/your-feature-name"]
105
+ );
106
+ }
107
+
51
108
  // Ask git for the current branch --show-current. Works identically on Windows/macOS/Linux.
52
109
  const r = spawnSync("git", ["branch", "--show-current"], {
53
110
  encoding: "utf8",
54
111
  timeout: 3000,
112
+ shell: process.platform === "win32",
55
113
  });
56
114
  const branch = ((r.stdout || "").trim());
57
115
 
58
116
  if (branch === "main" || branch === "master") {
59
117
  if (role !== "OWNER") {
60
- console.log(`BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`);
61
- console.log("Run: git checkout -b feature/your-feature-name");
62
- _trace("branch-guard", "block", { reason: `non-owner push to ${branch}` });
63
- process.exit(2);
118
+ fail(
119
+ `BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`,
120
+ ["Run: git checkout -b feature/your-feature-name"]
121
+ );
64
122
  }
65
123
  }
66
124
 
@@ -8,10 +8,29 @@ const fs = require("fs");
8
8
 
9
9
  const _traceStart = Date.now();
10
10
 
11
+ // Read JSON tool input from stdin with a safety timeout.
12
+ // On Windows, fs.readFileSync(0) can hang if stdin isn't closed by the host.
13
+ // We loop fs.readSync with a 1s deadline; if no data arrives, treat as empty.
11
14
  function readInput() {
15
+ const deadline = Date.now() + 1000;
16
+ const buf = Buffer.alloc(65536);
17
+ let data = "";
12
18
  try {
13
- const raw = fs.readFileSync(0, "utf8");
14
- return JSON.parse(raw);
19
+ while (Date.now() < deadline) {
20
+ let n = 0;
21
+ try {
22
+ n = fs.readSync(0, buf, 0, buf.length, null);
23
+ } catch (e) {
24
+ // EAGAIN/EWOULDBLOCK: no data yet, retry until deadline
25
+ if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) continue;
26
+ // Any other read error: bail
27
+ break;
28
+ }
29
+ if (n === 0) break; // EOF
30
+ data += buf.slice(0, n).toString("utf8");
31
+ }
32
+ if (!data) return {};
33
+ return JSON.parse(data);
15
34
  } catch {
16
35
  return {};
17
36
  }
@@ -20,7 +39,14 @@ function readInput() {
20
39
  const input = readInput();
21
40
  const ti = input.tool_input || {};
22
41
  const file = String(ti.file_path || "").replace(/\\/g, "/");
23
- const content = String(ti.content || ti.new_string || "");
42
+
43
+ // For Edit tool calls, dangerous SQL might live in old_string OR new_string.
44
+ // Concatenate both sides of the delta plus any full content payload so we
45
+ // scan everything that could reach disk.
46
+ const content = [ti.old_string, ti.new_string, ti.content]
47
+ .filter((v) => v != null)
48
+ .map((v) => String(v))
49
+ .join("\n");
24
50
 
25
51
  function _trace(hookName, result, extra) {
26
52
  try {
@@ -40,31 +66,80 @@ function _trace(hookName, result, extra) {
40
66
  } catch {}
41
67
  }
42
68
 
43
- // Only inspect migration/SQL files
44
- if (!/migration|migrate|\.sql$/i.test(file)) {
69
+ // Only inspect SQL files or files that live inside a migrations/ directory.
70
+ // Prior regex was over-broad (matched MigrationModal.tsx, migrations.md, etc.).
71
+ if (!/(^|\/)migrations?\//i.test(file) && !/\.sql$/i.test(file)) {
45
72
  _trace("migration-guard", "allow", { reason: "non-migration file" });
46
73
  process.exit(0);
47
74
  }
48
75
 
76
+ // Strip SQL comments before pattern matching so rolled-back/explanatory
77
+ // statements inside `-- ...` line comments or `/* ... */` block comments
78
+ // don't trigger false positives.
79
+ function stripSqlComments(src) {
80
+ // Remove /* ... */ block comments (non-greedy, multi-line).
81
+ let out = src.replace(/\/\*[\s\S]*?\*\//g, "");
82
+ // Remove -- line comments (to end of line).
83
+ out = out.replace(/--[^\n\r]*/g, "");
84
+ return out;
85
+ }
86
+
87
+ const scan = stripSqlComments(content);
88
+
49
89
  const errors = [];
50
90
 
51
91
  // DROP TABLE without IF EXISTS
52
- if (/DROP\s+TABLE/i.test(content) && !/IF\s+EXISTS/i.test(content)) {
92
+ if (/DROP\s+TABLE/i.test(scan) && !/IF\s+EXISTS/i.test(scan)) {
53
93
  errors.push("DROP TABLE without IF EXISTS");
54
94
  }
55
95
 
96
+ // DROP DATABASE — almost never appropriate in app migrations
97
+ if (/DROP\s+DATABASE/i.test(scan)) {
98
+ errors.push("DROP DATABASE detected — refuse unless explicitly approved");
99
+ }
100
+
101
+ // DROP SCHEMA — destructive, especially with CASCADE
102
+ if (/DROP\s+SCHEMA/i.test(scan)) {
103
+ errors.push("DROP SCHEMA detected — refuse unless explicitly approved");
104
+ }
105
+
106
+ // ALTER TABLE ... DROP COLUMN — destructive schema change
107
+ if (/ALTER\s+TABLE\s+[^;]*\bDROP\s+COLUMN\b/i.test(scan)) {
108
+ errors.push("ALTER TABLE ... DROP COLUMN is destructive");
109
+ }
110
+
56
111
  // DELETE without WHERE
57
- if (/DELETE\s+FROM/i.test(content) && !/WHERE/i.test(content)) {
112
+ if (/DELETE\s+FROM/i.test(scan) && !/WHERE/i.test(scan)) {
58
113
  errors.push("DELETE FROM without WHERE clause");
59
114
  }
60
115
 
116
+ // UPDATE without WHERE — affects every row
117
+ if (/\bUPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(scan) && !/WHERE/i.test(scan)) {
118
+ errors.push("UPDATE without WHERE clause — affects every row");
119
+ }
120
+
61
121
  // TRUNCATE (almost always wrong in migrations)
62
- if (/TRUNCATE/i.test(content)) {
122
+ if (/TRUNCATE/i.test(scan)) {
63
123
  errors.push("TRUNCATE detected — are you sure?");
64
124
  }
65
125
 
66
- // CREATE TABLE without RLS
67
- if (/CREATE\s+TABLE/i.test(content) && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(content)) {
126
+ // GRANT ... TO PUBLIC — privilege leak
127
+ if (/GRANT\s+[^;]*\bTO\s+PUBLIC\b/i.test(scan)) {
128
+ errors.push("GRANT ... TO PUBLIC detected — privilege leak");
129
+ }
130
+
131
+ // CREATE TABLE without RLS — but skip TEMP/TEMPORARY tables and partitions.
132
+ // Strategy: enumerate CREATE TABLE statements, drop the ones that don't need RLS,
133
+ // then if any "real" CREATE TABLE remains, require ENABLE ROW LEVEL SECURITY.
134
+ const createTableMatches = scan.match(/CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY|UNLOGGED)?\s*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[^;]*/gi) || [];
135
+ const realCreateTables = createTableMatches.filter((stmt) => {
136
+ // Skip TEMP/TEMPORARY tables — they're session-scoped, no RLS needed.
137
+ if (/CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY)\b/i.test(stmt)) return false;
138
+ // Skip partition tables — RLS lives on the parent table.
139
+ if (/\bPARTITION\s+OF\b/i.test(stmt)) return false;
140
+ return true;
141
+ });
142
+ if (realCreateTables.length > 0 && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(scan)) {
68
143
  errors.push("CREATE TABLE without ENABLE ROW LEVEL SECURITY");
69
144
  }
70
145
 
@@ -11,24 +11,48 @@ const _traceStart = Date.now();
11
11
 
12
12
  const STATE_FILE = path.join(".planning", "STATE.md");
13
13
 
14
+ let _commitStatus = null;
15
+ let _commitReason = "no-state-file";
16
+
14
17
  try {
15
18
  if (fs.existsSync(STATE_FILE)) {
16
19
  console.log("QUALIA: Saving state before compaction...");
20
+ _commitReason = "state-clean";
17
21
  // Check if STATE.md has uncommitted changes
18
22
  const diff = spawnSync("git", ["diff", "--name-only", STATE_FILE], {
19
23
  encoding: "utf8",
20
24
  timeout: 3000,
25
+ shell: process.platform === "win32",
21
26
  });
22
27
  if ((diff.stdout || "").includes("STATE.md")) {
23
- spawnSync("git", ["add", STATE_FILE], { timeout: 3000 });
24
- spawnSync("git", ["commit", "-m", "state: pre-compaction save"], {
28
+ const addRes = spawnSync("git", ["add", STATE_FILE], {
29
+ timeout: 3000,
30
+ shell: process.platform === "win32",
31
+ });
32
+ // Bypass user pre-commit hooks and commit signing so the auto-save
33
+ // never fails silently and STATE.md is always persisted before
34
+ // context compaction. Attribute to the framework bot, not the user.
35
+ const commitRes = spawnSync("git", [
36
+ "commit",
37
+ "--no-verify",
38
+ "--no-gpg-sign",
39
+ "--author=Qualia Framework <bot@qualia.solutions>",
40
+ "-m", "state: pre-compaction save",
41
+ ], {
25
42
  timeout: 5000,
26
- stdio: "ignore",
43
+ stdio: ["ignore", "ignore", "pipe"],
44
+ encoding: "utf8",
45
+ shell: process.platform === "win32",
27
46
  });
47
+ _commitStatus = commitRes.status;
48
+ _commitReason = addRes.status === 0 && commitRes.status === 0
49
+ ? "committed"
50
+ : "commit-failed";
28
51
  }
29
52
  }
30
53
  } catch {
31
54
  // Silent — never block compaction
55
+ _commitReason = "exception";
32
56
  }
33
57
 
34
58
  function _trace(hookName, result, extra) {
@@ -48,5 +72,5 @@ function _trace(hookName, result, extra) {
48
72
  } catch {}
49
73
  }
50
74
 
51
- _trace("pre-compact", "allow");
75
+ _trace("pre-compact", "allow", { commit_status: _commitStatus, commit_reason: _commitReason });
52
76
  process.exit(0);