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/cli.js +13 -2
- package/bin/install.js +28 -5
- package/bin/state.js +363 -43
- package/bin/statusline.js +40 -20
- package/docs/erp-contract.md +40 -1
- package/hooks/auto-update.js +54 -70
- package/hooks/branch-guard.js +64 -6
- package/hooks/migration-guard.js +85 -10
- package/hooks/pre-compact.js +28 -4
- package/hooks/pre-deploy-gate.js +46 -6
- package/hooks/pre-push.js +94 -27
- package/hooks/session-start.js +6 -0
- package/package.json +1 -1
- package/skills/qualia-build/SKILL.md +1 -1
- package/skills/qualia-map/SKILL.md +4 -4
- package/skills/qualia-milestone/SKILL.md +14 -2
- package/skills/qualia-optimize/SKILL.md +4 -4
- package/skills/qualia-quick/SKILL.md +2 -2
- package/skills/qualia-report/SKILL.md +38 -7
- package/skills/qualia-task/SKILL.md +1 -1
- package/skills/qualia-verify/SKILL.md +2 -2
- package/templates/help.html +98 -31
- package/templates/tracking.json +17 -1
- package/tests/runner.js +395 -0
- package/tests/state.test.sh +232 -4
- package/skills/qualia-idk/SKILL.md +0 -8
package/bin/statusline.js
CHANGED
|
@@ -93,35 +93,55 @@ try {
|
|
|
93
93
|
let branch = "";
|
|
94
94
|
let changes = 0;
|
|
95
95
|
try {
|
|
96
|
-
|
|
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 (
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/erp-contract.md
CHANGED
|
@@ -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
|
|
package/hooks/auto-update.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require("fs");
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const os = require("os");
|
|
9
|
-
const {
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
package/hooks/branch-guard.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
package/hooks/migration-guard.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
122
|
+
if (/TRUNCATE/i.test(scan)) {
|
|
63
123
|
errors.push("TRUNCATE detected — are you sure?");
|
|
64
124
|
}
|
|
65
125
|
|
|
66
|
-
//
|
|
67
|
-
if (/
|
|
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
|
|
package/hooks/pre-compact.js
CHANGED
|
@@ -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], {
|
|
24
|
-
|
|
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);
|