qualia-framework 3.4.0 → 4.0.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.
Files changed (42) hide show
  1. package/README.md +96 -51
  2. package/agents/builder.md +25 -14
  3. package/agents/plan-checker.md +29 -16
  4. package/agents/planner.md +33 -24
  5. package/agents/research-synthesizer.md +25 -12
  6. package/agents/roadmapper.md +89 -84
  7. package/agents/verifier.md +11 -2
  8. package/bin/cli.js +13 -2
  9. package/bin/install.js +28 -5
  10. package/bin/qualia-ui.js +267 -1
  11. package/bin/state.js +377 -52
  12. package/bin/statusline.js +40 -20
  13. package/docs/erp-contract.md +23 -2
  14. package/guide.md +84 -21
  15. package/hooks/auto-update.js +54 -70
  16. package/hooks/branch-guard.js +64 -6
  17. package/hooks/migration-guard.js +85 -10
  18. package/hooks/pre-compact.js +28 -4
  19. package/hooks/pre-deploy-gate.js +46 -6
  20. package/hooks/pre-push.js +94 -27
  21. package/hooks/session-start.js +6 -0
  22. package/package.json +1 -1
  23. package/skills/qualia/SKILL.md +3 -1
  24. package/skills/qualia-build/SKILL.md +40 -5
  25. package/skills/qualia-handoff/SKILL.md +87 -12
  26. package/skills/qualia-idk/SKILL.md +155 -3
  27. package/skills/qualia-map/SKILL.md +4 -4
  28. package/skills/qualia-milestone/SKILL.md +122 -79
  29. package/skills/qualia-new/SKILL.md +151 -230
  30. package/skills/qualia-optimize/SKILL.md +4 -4
  31. package/skills/qualia-plan/SKILL.md +14 -9
  32. package/skills/qualia-quick/SKILL.md +1 -1
  33. package/skills/qualia-report/SKILL.md +12 -0
  34. package/skills/qualia-verify/SKILL.md +59 -5
  35. package/templates/help.html +98 -31
  36. package/templates/journey.md +113 -0
  37. package/templates/plan.md +56 -11
  38. package/templates/requirements.md +82 -22
  39. package/templates/roadmap.md +41 -14
  40. package/templates/tracking.json +12 -1
  41. package/tests/runner.js +560 -0
  42. package/tests/state.test.sh +40 -0
@@ -34,6 +34,9 @@ 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",
38
41
  "milestone": 2,
39
42
  "phase": 2,
@@ -44,14 +47,19 @@ Content-Type: application/json
44
47
  "tasks_total": 5,
45
48
  "verification": "pass",
46
49
  "gap_cycles": 0,
50
+ "build_count": 12,
51
+ "deploy_count": 3,
47
52
  "deployed_url": "https://client.vercel.app",
48
53
  "lifetime": {
49
54
  "tasks_completed": 23,
50
55
  "phases_completed": 8,
51
56
  "milestones_completed": 1,
52
- "total_phases": 8
57
+ "total_phases": 8,
58
+ "last_closed_milestone": 1
53
59
  },
60
+ "session_started_at": "2026-04-12T13:45:00Z",
54
61
  "session_duration_minutes": 45,
62
+ "last_pushed_at": "2026-04-12T14:25:00Z",
55
63
  "commits": ["abc1234", "def5678"],
56
64
  "notes": "Completed auth flow, dashboard layout, and API routes.",
57
65
  "submitted_by": "Fawzi Goussous",
@@ -59,6 +67,12 @@ Content-Type: application/json
59
67
  }
60
68
  ```
61
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
+
62
76
  **Response (200 OK):**
63
77
  ```json
64
78
  {
@@ -161,7 +175,14 @@ Authorization: Bearer <api-key>
161
175
  | submitted_by | string | yes | Team member name |
162
176
  | submitted_at | string | yes | ISO 8601 timestamp |
163
177
  | milestone | number | recommended | Current milestone number (1-indexed) |
164
- | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases |
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. |
165
186
 
166
187
  All other fields are optional but recommended for complete reporting.
167
188
 
package/guide.md CHANGED
@@ -1,63 +1,126 @@
1
- # Qualia Developer Guide
1
+ # Qualia Developer Guide (v4)
2
2
 
3
3
  > Follow the road. Type the commands. The framework handles the rest.
4
+ > v4 adds a `--auto` flag that chains the whole road end-to-end with only two human checkpoints per project.
4
5
 
5
6
  ## The Road
6
7
 
7
8
  ```
8
- /qualia-new ← Set up project (once)
9
+ /qualia-new ← Set up project (once). Produces JOURNEY.md — all milestones to handoff.
9
10
 
10
- For each phase:
11
- /qualia-plan ← Plan it (planner agent)
12
- /qualia-build ← Build it (builder subagents)
13
- /qualia-verify Verify it works (verifier agent)
11
+ For each phase of the current milestone:
12
+ /qualia-plan ← Plan it (planner + plan-checker, story-file format)
13
+ /qualia-build ← Build it (builder subagents with pre-inlined context)
14
+ /qualia-verify Check it actually works (goal-backward + per-task AC)
14
15
 
15
- /qualia-polish Design pass
16
- /qualia-ship ← Deploy to production
17
- /qualia-handoff ← Deliver to client
16
+ /qualia-milestone Close milestone, open next from JOURNEY.md
17
+
18
+ ...repeat per milestone until the Handoff milestone...
19
+
20
+ /qualia-polish ← Design pass (part of Handoff milestone)
21
+ /qualia-ship ← Deploy to production
22
+ /qualia-handoff ← Enforce the 4 handoff deliverables
18
23
 
19
24
  Done.
20
25
  ```
21
26
 
27
+ ## Auto Mode (v4)
28
+
29
+ Append `--auto` to `/qualia-new` and the framework chains every step:
30
+
31
+ ```
32
+ /qualia-new --auto
33
+ → research runs → JOURNEY.md written → approve the whole journey ONCE
34
+ → auto: plan 1 → build 1 → verify 1 → plan 2 → build 2 → verify 2 → ...
35
+ → pause at each milestone boundary: "Continue to M{N+1}?"
36
+ → resume: plan 1 → build 1 → ... of new milestone
37
+ → eventually reaches Handoff milestone's last phase → ship → handoff → report → done
38
+ ```
39
+
40
+ **Human gates in auto mode (total: 2 per project):**
41
+ 1. Journey approval after `/qualia-new` research
42
+ 2. Each milestone boundary
43
+
44
+ **Plus one halt case:** if a phase fails verification beyond the gap-cycle limit (default 2), the chain stops and asks for human intervention.
45
+
22
46
  ## The 10 Commands
23
47
 
24
48
  | When | Command | What it does |
25
49
  |------|---------|-------------|
26
- | Starting | `/qualia-new` | Set up project from scratch |
50
+ | Starting | `/qualia-new` | Set up project with full journey (all milestones → Handoff) |
51
+ | Starting (auto) | `/qualia-new --auto` | Same + chain through building automatically |
27
52
  | Building | `/qualia-plan` | Plan the current phase |
28
53
  | | `/qualia-build` | Build it (parallel tasks) |
29
54
  | | `/qualia-verify` | Check it actually works |
55
+ | Milestone | `/qualia-milestone` | Close current, open next from JOURNEY.md |
30
56
  | Quick fix | `/qualia-quick` | Skip planning, just do it |
31
57
  | Finishing | `/qualia-polish` | Design and UX pass |
32
58
  | | `/qualia-ship` | Deploy to production |
33
- | | `/qualia-handoff` | Deliver to client |
34
- | Reporting | `/qualia-report` | Log what you did (mandatory) |
35
- | **Lost?** | **`/qualia`** | **Tells you the exact next command** |
59
+ | | `/qualia-handoff` | Deliver to client (4 mandatory deliverables) |
60
+ | Reporting | `/qualia-report` | Log what you did (mandatory before clock-out) |
61
+ | Lost? | `/qualia` | Mechanical next-command router |
62
+ | Confused? | `/qualia-idk` | Diagnostic — scans planning + code, explains what's going on |
63
+
64
+ ## Full Journey Hierarchy (v4)
65
+
66
+ ```
67
+ Project
68
+ └─ Journey (the whole arc — mapped upfront by /qualia-new, lives in .planning/JOURNEY.md)
69
+ └─ Milestone (a release — 2-5 total, Handoff is always last)
70
+ └─ Phase (a feature-sized deliverable, 2-5 tasks)
71
+ └─ Task (atomic unit, one commit, one verification contract)
72
+ ```
73
+
74
+ Hard rules (enforced by `state.js` and the roadmapper):
75
+ - **Milestone count: 2 to 5.** Final milestone is always literally named "Handoff".
76
+ - **≥ 2 phases per non-Handoff milestone** (single-phase "milestones" are phases, not milestones).
77
+ - **Milestone numbering is contiguous** — no skipped numbers.
78
+ - **Handoff milestone has fixed 4 phases:** Polish, Content + SEO, Final QA, Handoff (credentials + walkthrough + archive + ERP report).
36
79
 
37
80
  ## Rules
38
81
 
39
82
  1. **Feature branches only** — never push to main
40
83
  2. **Read before write** — don't edit files you haven't read
41
84
  3. **MVP first** — build what's asked, nothing extra
42
- 4. **`/qualia` is your friend** lost? type it
85
+ 4. **Every task has a `Why`** (story-file format)if you can't explain why a task matters in one sentence, it probably shouldn't exist
86
+ 5. **`/qualia` is your friend** — lost? type it
87
+ 6. **`/qualia-idk` is your deeper friend** — not lost on "what command", but confused about the *situation*? Type `idk`.
43
88
 
44
89
  ## When You're Stuck
45
90
 
46
91
  ```
47
- /qualia ← "what's next?"
92
+ /qualia ← "what command should I run next?" (state-driven, instant)
93
+ /qualia-idk ← "what's actually going on here?" (diagnostic, scans planning + code, ~30s)
48
94
  ```
49
95
 
50
- If that doesn't help, paste the error and ask Claude directly. If Claude can't fix it, tell Fawzi.
96
+ If neither helps, paste the error and ask Claude directly. If Claude can't fix it, tell Fawzi.
51
97
 
52
98
  ## Session Start / End
53
99
 
54
- **Start:** Claude loads your project context automatically.
55
- **End:** Run `/qualia-report` — this is mandatory before clock-out.
100
+ **Start:** Claude loads your project context automatically. The router banner shows your journey position ("M2 of 4 · P2 of 3").
101
+ **End:** Run `/qualia-report` — this is mandatory before clock-out. The report is committed to git and (if ERP is enabled) uploaded to https://portal.qualiasolutions.net.
56
102
 
57
103
  ## How It Works (you don't need to know this, but if curious)
58
104
 
105
+ - **Journey-first planning:** `/qualia-new` produces JOURNEY.md listing every milestone from kickoff to Handoff with exit criteria and phase sketches. The whole team sees the path on day 1.
59
106
  - **Context isolation:** Each task runs in a fresh AI brain. Task 50 gets the same quality as Task 1.
60
- - **Goal-backward verification:** The verifier doesn't trust "I built it." It greps the code to check if things actually work.
61
- - **Plans are prompts:** The plan file IS what the builder reads. No translation loss.
107
+ - **Pre-inlined context at dispatch:** The builder starts with PROJECT.md, DESIGN.md, and all Context @files already loaded no wasted orientation reads.
108
+ - **Goal-backward verification:** The verifier doesn't trust "I built it." It greps the code to check if things actually work AND walks every task's Acceptance Criteria.
109
+ - **Story-file plans:** Every task has Why / Acceptance Criteria / Depends on / Validation inline — the plan IS the brief.
62
110
  - **Wave execution:** Independent tasks run in parallel. Dependent tasks wait.
63
- - **tracking.json:** Updated on every push. The ERP reads it automatically.
111
+ - **Milestone-boundary pauses:** In `--auto` mode, the framework pauses only at real decision points. Everything else runs on rails.
112
+ - **tracking.json:** Updated on every push. The ERP reads it automatically. v4 adds `milestone_name` + `milestones[]` so the ERP renders a proper tree instead of a flat list.
113
+
114
+ ## Quick Reference
115
+
116
+ | Situation | Run |
117
+ |---|---|
118
+ | Starting a new client project | `/qualia-new` (or `/qualia-new --auto` to roll end-to-end) |
119
+ | Starting a quick throwaway | `/qualia-new --quick` |
120
+ | Brownfield project | `/qualia-map` first, then `/qualia-new` |
121
+ | Stuck picking next command | `/qualia` |
122
+ | Confused about the situation | `/qualia-idk` |
123
+ | Finished the last phase of a milestone | `/qualia-milestone` |
124
+ | About to ship | `/qualia-ship` |
125
+ | Client is ready to take over | `/qualia-handoff` |
126
+ | End of workday | `/qualia-report` (mandatory) |
@@ -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);