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.
- package/README.md +96 -51
- package/agents/builder.md +25 -14
- package/agents/plan-checker.md +29 -16
- package/agents/planner.md +33 -24
- package/agents/research-synthesizer.md +25 -12
- package/agents/roadmapper.md +89 -84
- package/agents/verifier.md +11 -2
- package/bin/cli.js +13 -2
- package/bin/install.js +28 -5
- package/bin/qualia-ui.js +267 -1
- package/bin/state.js +377 -52
- package/bin/statusline.js +40 -20
- package/docs/erp-contract.md +23 -2
- package/guide.md +84 -21
- 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/SKILL.md +3 -1
- package/skills/qualia-build/SKILL.md +40 -5
- package/skills/qualia-handoff/SKILL.md +87 -12
- package/skills/qualia-idk/SKILL.md +155 -3
- package/skills/qualia-map/SKILL.md +4 -4
- package/skills/qualia-milestone/SKILL.md +122 -79
- package/skills/qualia-new/SKILL.md +151 -230
- package/skills/qualia-optimize/SKILL.md +4 -4
- package/skills/qualia-plan/SKILL.md +14 -9
- package/skills/qualia-quick/SKILL.md +1 -1
- package/skills/qualia-report/SKILL.md +12 -0
- package/skills/qualia-verify/SKILL.md +59 -5
- package/templates/help.html +98 -31
- package/templates/journey.md +113 -0
- package/templates/plan.md +56 -11
- package/templates/requirements.md +82 -22
- package/templates/roadmap.md +41 -14
- package/templates/tracking.json +12 -1
- package/tests/runner.js +560 -0
- package/tests/state.test.sh +40 -0
package/docs/erp-contract.md
CHANGED
|
@@ -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
|
|
9
|
+
/qualia-new ← Set up project (once). Produces JOURNEY.md — all milestones to handoff.
|
|
9
10
|
↓
|
|
10
|
-
For each phase:
|
|
11
|
-
/qualia-plan
|
|
12
|
-
/qualia-build
|
|
13
|
-
/qualia-verify
|
|
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-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
61
|
-
- **
|
|
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
|
-
- **
|
|
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) |
|
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);
|