qualia-framework 6.9.2 β 6.14.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/CHANGELOG.md +78 -0
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +251 -0
- package/bin/analyze-gate.js +318 -0
- package/bin/command-surface.js +1 -0
- package/bin/install.js +6 -4
- package/bin/report-payload.js +7 -0
- package/bin/runtime-manifest.js +2 -0
- package/bin/state.js +145 -11
- package/docs/EMPLOYEE-QUICKSTART.md +3 -3
- package/docs/erp-contract.md +23 -0
- package/docs/qualia-manual.html +5 -5
- package/hooks/branch-guard.js +133 -63
- package/hooks/pre-deploy-gate.js +38 -0
- package/hooks/task-write-guard.js +165 -0
- package/package.json +1 -1
- package/skills/qualia-build/SKILL.md +30 -1
- package/skills/qualia-ship/SKILL.md +3 -0
- package/skills/qualia-update/SKILL.md +96 -0
- package/skills/qualia-verify/SKILL.md +7 -1
- package/templates/journey.md +1 -1
- package/tests/agent-status.test.sh +138 -0
- package/tests/analyze-gate.test.sh +170 -0
- package/tests/bin.test.sh +5 -4
- package/tests/hooks.test.sh +218 -17
- package/tests/install-smoke.test.sh +4 -3
- package/tests/lib.test.sh +2 -2
- package/tests/run-all.sh +2 -0
- package/tests/runner.js +3 -2
- package/tests/state.test.sh +95 -0
- package/skills/qualia-discuss/SKILL.md +0 -222
package/docs/erp-contract.md
CHANGED
|
@@ -54,6 +54,29 @@ employee's active session, such as proxy owner-approval claims ("Fawzi said OK")
|
|
|
54
54
|
The ERP should increment or store by `(type, actor_code)` so Fawzi can see how
|
|
55
55
|
many times each employee attempted to use proxy approval.
|
|
56
56
|
|
|
57
|
+
**Event types posted to this endpoint:**
|
|
58
|
+
|
|
59
|
+
| `type` | Posted by | Meaning |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `proxy_owner_approval_claim` | `fawzi-approval-guard` | An EMPLOYEE used "Fawzi said OK"-style proxy approval. Has a `sample` field. |
|
|
62
|
+
| `employee_main_push` | `branch-guard` (v6.10+) | An EMPLOYEE pushed to a protected branch (`main`/`master`). Pushing is **allowed**, not blocked β this event is the accountability record. Has a `branch` field instead of `sample`. |
|
|
63
|
+
|
|
64
|
+
`employee_main_push` body (same envelope, `branch` replaces `sample`):
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"type": "employee_main_push",
|
|
68
|
+
"actor_code": "QS-HASAN-02",
|
|
69
|
+
"actor_name": "Hasan",
|
|
70
|
+
"actor_role": "EMPLOYEE",
|
|
71
|
+
"count": 4,
|
|
72
|
+
"branch": "main",
|
|
73
|
+
"project": "client-project",
|
|
74
|
+
"cwd": "/path/to/client-project",
|
|
75
|
+
"recorded_at": "2026-06-20T10:00:00.000Z"
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
Store by `(type, actor_code)` the same way so Fawzi sees a per-employee main-push tally. `client_report_id` is `QS-MAINPUSH-<actor_code>-<count>` and each post carries an idempotency key.
|
|
79
|
+
|
|
57
80
|
### POST /api/v1/reports
|
|
58
81
|
|
|
59
82
|
Upload a session report.
|
package/docs/qualia-manual.html
CHANGED
|
@@ -187,7 +187,7 @@ footer{margin-top:80px;padding-top:24px;border-top:1px solid var(--border);color
|
|
|
187
187
|
<p class="sec-sub">Qualia is an opinionated workflow that lives inside Claude Code (and Codex). It ships a lifecycle of <code>/qualia-*</code> slash commands, plus the guardrails (hooks, rules, and a shared brain) that keep every project consistent and safe.</p>
|
|
188
188
|
<div class="grid g3">
|
|
189
189
|
<div class="panel"><h3>π§ It routes you</h3><p class="dim">Never wonder what's next. <code>/qualia</code> reads your project's state and hands you the exact next command. The framework keeps the map; you keep moving.</p></div>
|
|
190
|
-
<div class="panel"><h3>π It guards you</h3><p class="dim">Deterministic hooks block the dangerous stuff (
|
|
190
|
+
<div class="panel"><h3>π It guards you</h3><p class="dim">Deterministic hooks block the dangerous stuff (leaking secrets, destructive DB ops, force-pushing) before it happens, and record the risky-but-allowed stuff (pushing to <code>main</code>) for the owner. Rules aren't suggestions; they're enforced.</p></div>
|
|
191
191
|
<div class="panel"><h3>π§ It remembers</h3><p class="dim">Lessons from every project flow into a shared knowledge base. A fix one person discovered shows up in everyone's install, so the team gets smarter over time.</p></div>
|
|
192
192
|
</div>
|
|
193
193
|
<div class="note teal"><b>The one-line mental model:</b> you describe <em>what</em> you want; the framework owns <em>how</em> Qualia builds it: the stack, the phases, the quality bar, the deploy steps.</div>
|
|
@@ -216,7 +216,7 @@ footer{margin-top:80px;padding-top:24px;border-top:1px solid var(--border);color
|
|
|
216
216
|
<!-- INSTALL -->
|
|
217
217
|
<section id="install" class="reveal">
|
|
218
218
|
<h2><span class="bar"></span> Install in employee mode</h2>
|
|
219
|
-
<p class="sec-sub">You can install and do real work <b>today</b>, with no credentials. Employee mode gives you the full framework at the safest privilege level
|
|
219
|
+
<p class="sec-sub">You can install and do real work <b>today</b>, with no credentials. Employee mode gives you the full framework at the safest privilege level. Feature branches are the norm; pushing to <code>main</code> is allowed but recorded for the owner.</p>
|
|
220
220
|
<pre><span class="c"># one command, works on a fresh machine</span>
|
|
221
221
|
npx qualia-framework@latest install</pre>
|
|
222
222
|
<p>At the prompt <code>Install code or "EMPLOYEE":</code></p>
|
|
@@ -225,7 +225,7 @@ npx qualia-framework@latest install</pre>
|
|
|
225
225
|
<tr><td>Have a team code</td><td><code>QS-NAME-##</code></td><td>Install as that team member (ERP reporting on, with the key)</td></tr>
|
|
226
226
|
<tr><td>Have no code yet</td><td><code>EMPLOYEE</code></td><td>Full framework, EMPLOYEE role, ERP reporting off until a code is set</td></tr>
|
|
227
227
|
</table>
|
|
228
|
-
<div class="note"><b>Nothing is missing in employee mode.</b> Skills, agents, hooks, and knowledge are installed identically. The only differences:
|
|
228
|
+
<div class="note"><b>Nothing is missing in employee mode.</b> Skills, agents, hooks, and knowledge are installed identically. The only differences: pushing to <code>main</code> is recorded for the owner (the <code>branch-guard</code> hook counts each one to the ERP) rather than free, and <code>/qualia-report</code> saves a <em>local</em> report instead of uploading to the ERP. Ask Fawzi for a <code>QS-NAME-##</code> code when you're ready to upgrade.</div>
|
|
229
229
|
<h3>Then confirm it's healthy</h3>
|
|
230
230
|
<p>Run the doctor before real work. It checks the install, hooks, project state, and the ERP queue, and prints PASS/FAIL with the exact fix for anything wrong.</p>
|
|
231
231
|
<p><span class="cmd" data-copy="/qualia-doctor">/qualia-doctor<span class="ico">copy</span></span> <span class="dim">in employee mode it reports ERP as disabled; that's expected, not an error.</span></p>
|
|
@@ -294,7 +294,7 @@ npx qualia-framework@latest install</pre>
|
|
|
294
294
|
</div></div>
|
|
295
295
|
<div class="w"><span class="wn"></span><div class="wbody">
|
|
296
296
|
<h4>Ship it</h4>
|
|
297
|
-
<p class="dim"><span class="cmd" data-copy="/qualia-ship">/qualia-ship<span class="ico">copy</span></span>: gates, commit, deploy to Vercel, verify live. As an employee
|
|
297
|
+
<p class="dim"><span class="cmd" data-copy="/qualia-ship">/qualia-ship<span class="ico">copy</span></span>: gates, commit, deploy to Vercel, verify live. As an employee, prefer a feature branch + review; direct pushes to <code>main</code> are allowed but recorded for the owner, so save them for trivially safe changes.</p>
|
|
298
298
|
</div></div>
|
|
299
299
|
<div class="w"><span class="wn"></span><div class="wbody">
|
|
300
300
|
<h4>Clock out</h4>
|
|
@@ -309,7 +309,7 @@ npx qualia-framework@latest install</pre>
|
|
|
309
309
|
<p class="sec-sub">Five non-negotiables. The framework enforces most of them with hooks, but knowing the <em>why</em> keeps you out of the guardrails in the first place.</p>
|
|
310
310
|
<div class="panel">
|
|
311
311
|
<div class="rule"><span class="num">1</span><div><b>Read before you write.</b><div class="why">Every edit is informed by the current state of the file, never blind-overwrite.</div></div></div>
|
|
312
|
-
<div class="rule"><span class="num">2</span><div><b>Feature branches
|
|
312
|
+
<div class="rule"><span class="num">2</span><div><b>Feature branches by default.</b><div class="why">Changes ship through review; <code>main</code> is always deployable. Pushing to <code>main</code> is allowed but the branch-guard hook records it for the owner.</div></div></div>
|
|
313
313
|
<div class="rule"><span class="num">3</span><div><b>MVP first.</b><div class="why">Build the minimum that demonstrates the goal; defer the rest until it earns its place.</div></div></div>
|
|
314
314
|
<div class="rule"><span class="num">4</span><div><b>Root cause on failures.</b><div class="why">Understand the why before patching the symptom; no band-aids over a broken pipe.</div></div></div>
|
|
315
315
|
<div class="rule"><span class="num">5</span><div><b>No proxy approval.</b><div class="why">Only the OWNER grants OWNER overrides. "Fawzi said OK" is not a credential.</div></div></div>
|
package/hooks/branch-guard.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ~/.claude/hooks/branch-guard.js β
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// ~/.claude/hooks/branch-guard.js β ACCOUNTABILITY for non-OWNER pushes to
|
|
3
|
+
// main/master. Policy changed in v6.10: pushing to main is no longer BLOCKED.
|
|
4
|
+
// Instead every employee push to a protected branch is COUNTED β recorded
|
|
5
|
+
// locally (per-employee total) and reported to the ERP as a policy-event the
|
|
6
|
+
// OWNER can see β and the employee gets a visible notice. OWNER pushes are
|
|
7
|
+
// unaffected and silent. This hook never blocks (always exits 0).
|
|
8
|
+
//
|
|
9
|
+
// PreToolUse hook on `git push*`. Reads role from ~/.claude/.qualia-config.json.
|
|
10
|
+
// Mirrors the allow-and-record model of fawzi-approval-guard.js.
|
|
6
11
|
// Cross-platform (Windows/macOS/Linux).
|
|
7
12
|
|
|
8
13
|
const fs = require("fs");
|
|
9
14
|
const path = require("path");
|
|
10
15
|
const os = require("os");
|
|
16
|
+
const crypto = require("crypto");
|
|
11
17
|
const { spawnSync } = require("child_process");
|
|
12
18
|
|
|
13
19
|
const _traceStart = Date.now();
|
|
@@ -21,13 +27,14 @@ function qualiaHome() {
|
|
|
21
27
|
|
|
22
28
|
const QUALIA_HOME = qualiaHome();
|
|
23
29
|
const CONFIG = path.join(QUALIA_HOME, ".qualia-config.json");
|
|
30
|
+
const EVENT_FILE = path.join(QUALIA_HOME, ".main-push-events.json");
|
|
24
31
|
|
|
25
|
-
function _trace(
|
|
32
|
+
function _trace(result, extra) {
|
|
26
33
|
try {
|
|
27
34
|
const traceDir = path.join(QUALIA_HOME, ".qualia-traces");
|
|
28
35
|
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
29
36
|
const entry = {
|
|
30
|
-
hook:
|
|
37
|
+
hook: "branch-guard",
|
|
31
38
|
result,
|
|
32
39
|
timestamp: new Date().toISOString(),
|
|
33
40
|
duration_ms: Date.now() - _traceStart,
|
|
@@ -38,64 +45,131 @@ function _trace(hookName, result, extra) {
|
|
|
38
45
|
} catch {}
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (Array.isArray(extraLines)) {
|
|
47
|
-
for (const line of extraLines) {
|
|
48
|
-
console.error(line);
|
|
49
|
-
console.log(line);
|
|
50
|
-
}
|
|
48
|
+
function readJson(file, fallback) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return fallback;
|
|
51
53
|
}
|
|
52
|
-
_trace("branch-guard", "block", { reason: msg });
|
|
53
|
-
process.exit(2);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
function writeJson(file, data) {
|
|
57
|
+
try {
|
|
58
|
+
const dir = path.dirname(file);
|
|
59
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
62
|
+
try { fs.chmodSync(tmp, 0o600); } catch {}
|
|
63
|
+
fs.renameSync(tmp, file);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Record the push locally and return the event (with the running per-actor count).
|
|
68
|
+
function recordLocal(config, branch) {
|
|
69
|
+
const data = readJson(EVENT_FILE, { counts: {}, events: [] });
|
|
70
|
+
if (!data.counts || typeof data.counts !== "object") data.counts = {};
|
|
71
|
+
if (!Array.isArray(data.events)) data.events = [];
|
|
72
|
+
|
|
73
|
+
const key = config.code || config.installed_by || "unknown";
|
|
74
|
+
const prev = data.counts[key] || {};
|
|
75
|
+
const count = (prev.total || 0) + 1;
|
|
76
|
+
const event = {
|
|
77
|
+
type: "employee_main_push",
|
|
78
|
+
actor_code: config.code || "",
|
|
79
|
+
actor_name: config.installed_by || "",
|
|
80
|
+
actor_role: config.role || "",
|
|
81
|
+
count,
|
|
82
|
+
branch,
|
|
83
|
+
project: path.basename(process.cwd()),
|
|
84
|
+
cwd: process.cwd(),
|
|
85
|
+
recorded_at: new Date().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
data.counts[key] = {
|
|
89
|
+
actor_code: event.actor_code,
|
|
90
|
+
actor_name: event.actor_name,
|
|
91
|
+
actor_role: event.actor_role,
|
|
92
|
+
total: count,
|
|
93
|
+
last_seen_at: event.recorded_at,
|
|
94
|
+
};
|
|
95
|
+
data.events.push(event);
|
|
96
|
+
data.events = data.events.slice(-200);
|
|
97
|
+
writeJson(EVENT_FILE, data);
|
|
98
|
+
return event;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Queue the event for the ERP (idempotent, retried by erp-retry.js). Same
|
|
102
|
+
// /api/v1/policy-events endpoint the proxy-approval guard uses β the ERP stores
|
|
103
|
+
// by (type, actor_code) so the OWNER sees a per-employee main-push tally.
|
|
104
|
+
function enqueueErp(config, event) {
|
|
105
|
+
try {
|
|
106
|
+
if (config.erp && config.erp.enabled === false) return;
|
|
107
|
+
const retryPath = path.join(QUALIA_HOME, "bin", "erp-retry.js");
|
|
108
|
+
if (!fs.existsSync(retryPath)) return;
|
|
109
|
+
const erpUrl = (config.erp && config.erp.url) || "https://portal.qualiasolutions.net";
|
|
110
|
+
const { enqueue } = require(retryPath);
|
|
111
|
+
enqueue({
|
|
112
|
+
client_report_id: `QS-MAINPUSH-${(event.actor_code || "UNKNOWN").replace(/[^A-Z0-9-]/gi, "")}-${event.count}`,
|
|
113
|
+
idempotency_key: crypto.randomUUID ? crypto.randomUUID() : "",
|
|
114
|
+
url: `${erpUrl.replace(/\/$/, "")}/api/v1/policy-events`,
|
|
115
|
+
payload: JSON.stringify(event),
|
|
116
|
+
last_error: "",
|
|
117
|
+
});
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function notify(event, branch) {
|
|
122
|
+
// Visible, non-blocking notice. The push proceeds either way.
|
|
123
|
+
const who = event.actor_name || event.actor_code || "you";
|
|
124
|
+
const lines = [
|
|
125
|
+
`β¬’ NOTICE: ${who} pushed to '${branch}'.`,
|
|
126
|
+
` Recorded (framework + ERP) and visible to the OWNER β main-push #${event.count}.`,
|
|
127
|
+
` Prefer a feature branch + review for changes that aren't trivially safe.`,
|
|
128
|
+
];
|
|
129
|
+
for (const l of lines) console.error(l);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ββ role ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
133
|
+
const config = readJson(CONFIG, null);
|
|
134
|
+
if (!config) {
|
|
135
|
+
// Can't classify without config β but the hook no longer blocks anything.
|
|
136
|
+
_trace("allow", { reason: "config missing/unreadable" });
|
|
137
|
+
process.exit(0);
|
|
62
138
|
}
|
|
139
|
+
const role = (config.role || "").toUpperCase();
|
|
63
140
|
|
|
64
|
-
|
|
65
|
-
|
|
141
|
+
// OWNER pushes to main are normal and unremarkable. Anything that isn't a known
|
|
142
|
+
// EMPLOYEE is also left alone (nothing to attribute).
|
|
143
|
+
if (role !== "EMPLOYEE") {
|
|
144
|
+
_trace("allow", { role });
|
|
145
|
+
process.exit(0);
|
|
66
146
|
}
|
|
67
147
|
|
|
68
|
-
//
|
|
69
|
-
// with the actual `git push ...` invocation. Parsing this lets us catch refspec
|
|
70
|
-
// bypasses like `git push origin feature/x:main` that --show-current would miss.
|
|
148
|
+
// ββ detect a push that targets a protected branch βββββββββββββββββββββββββββ
|
|
71
149
|
let pushCommand = "";
|
|
72
150
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
151
|
+
if (!process.stdin.isTTY) {
|
|
152
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
153
|
+
if (raw && raw.trim()) {
|
|
154
|
+
const payload = JSON.parse(raw);
|
|
155
|
+
pushCommand = (payload && payload.tool_input && payload.tool_input.command) || "";
|
|
156
|
+
}
|
|
77
157
|
}
|
|
78
158
|
} catch {
|
|
79
|
-
// No stdin or non-JSON
|
|
159
|
+
// No stdin or non-JSON β fall through to the --show-current check.
|
|
80
160
|
}
|
|
81
161
|
|
|
82
|
-
//
|
|
83
|
-
// Refspec forms: <src>:<dst>, :<dst> (delete), +<src>:<dst> (force).
|
|
84
|
-
// We only flag explicit <src>:<dst> refspecs here; bare branch pushes
|
|
85
|
-
// (e.g. `git push origin main` from a non-main branch) are uncommon and
|
|
86
|
-
// handled by the --show-current fallback below when applicable.
|
|
162
|
+
// Explicit refspec form: <src>:<dst>, :<dst>, +<src>:<dst> targeting main/master.
|
|
87
163
|
function refspecTargetsProtected(cmd) {
|
|
88
164
|
if (!cmd || typeof cmd !== "string") return null;
|
|
89
165
|
const tokens = cmd.split(/\s+/).filter(Boolean);
|
|
90
166
|
const pushIdx = tokens.indexOf("push");
|
|
91
167
|
if (pushIdx === -1) return null;
|
|
92
|
-
|
|
93
168
|
for (let i = pushIdx + 1; i < tokens.length; i++) {
|
|
94
169
|
let tok = tokens[i];
|
|
95
170
|
if (tok.startsWith("-")) continue;
|
|
96
171
|
if (tok.startsWith("+")) tok = tok.slice(1);
|
|
97
172
|
tok = tok.replace(/^['"]|['"]$/g, "");
|
|
98
|
-
|
|
99
173
|
if (tok.includes(":")) {
|
|
100
174
|
const parts = tok.split(":");
|
|
101
175
|
const dst = parts[parts.length - 1].replace(/^refs\/heads\//, "");
|
|
@@ -105,30 +179,26 @@ function refspecTargetsProtected(cmd) {
|
|
|
105
179
|
return null;
|
|
106
180
|
}
|
|
107
181
|
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
182
|
+
let target = refspecTargetsProtected(pushCommand);
|
|
183
|
+
if (!target) {
|
|
184
|
+
const r = spawnSync("git", ["branch", "--show-current"], {
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
timeout: 3000,
|
|
187
|
+
shell: process.platform === "win32",
|
|
188
|
+
});
|
|
189
|
+
const branch = ((r.stdout || "").trim());
|
|
190
|
+
if (branch === "main" || branch === "master") target = branch;
|
|
114
191
|
}
|
|
115
192
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
shell: process.platform === "win32",
|
|
121
|
-
});
|
|
122
|
-
const branch = ((r.stdout || "").trim());
|
|
123
|
-
|
|
124
|
-
if (branch === "main" || branch === "master") {
|
|
125
|
-
if (role !== "OWNER") {
|
|
126
|
-
fail(
|
|
127
|
-
`BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`,
|
|
128
|
-
["Run: git checkout -b feature/your-feature-name"]
|
|
129
|
-
);
|
|
130
|
-
}
|
|
193
|
+
if (!target) {
|
|
194
|
+
// Not a protected-branch push β nothing to record.
|
|
195
|
+
_trace("allow", { role });
|
|
196
|
+
process.exit(0);
|
|
131
197
|
}
|
|
132
198
|
|
|
133
|
-
|
|
199
|
+
// ββ count + notify, then ALLOW ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
200
|
+
const event = recordLocal(config, target);
|
|
201
|
+
enqueueErp(config, event);
|
|
202
|
+
notify(event, target);
|
|
203
|
+
_trace("allow", { role, recorded: true, branch: target, count: event.count });
|
|
134
204
|
process.exit(0);
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -322,6 +322,44 @@ if (leaks.length > 0) {
|
|
|
322
322
|
process.exit(2);
|
|
323
323
|
}
|
|
324
324
|
console.log(" β Security");
|
|
325
|
+
|
|
326
|
+
// Anti-slop: zero-token deterministic scan for the AI-design tells the
|
|
327
|
+
// constitution bans (banned fonts, purple-blue gradients, etc). slop-detect
|
|
328
|
+
// exits 1 on CRITICAL findings; we translate that to a deploy block (exit 2).
|
|
329
|
+
// Skipped silently when the scanner isn't installed (brownfield / older
|
|
330
|
+
// installs) so it never breaks a project that predates it. OWNER-only escape
|
|
331
|
+
// hatch mirrors QUALIA_SKIP_LINT.
|
|
332
|
+
const slopScript = path.join(QUALIA_HOME, "bin", "slop-detect.mjs");
|
|
333
|
+
if (fs.existsSync(slopScript)) {
|
|
334
|
+
const skipSlop = process.env.QUALIA_SKIP_SLOP === "1";
|
|
335
|
+
if (skipSlop) {
|
|
336
|
+
const slopRole = String(readConfig().role || "").toUpperCase();
|
|
337
|
+
if (slopRole !== "OWNER") {
|
|
338
|
+
const slopState = readState();
|
|
339
|
+
blockDeploy("QUALIA_SKIP_SLOP is OWNER-only.", (slopState && slopState.next_command) || "/qualia");
|
|
340
|
+
}
|
|
341
|
+
console.log(" β Anti-slop skipped (QUALIA_SKIP_SLOP=1)");
|
|
342
|
+
_trace("pre-deploy-gate", "skip-slop", { reason: "QUALIA_SKIP_SLOP=1" });
|
|
343
|
+
} else {
|
|
344
|
+
const r = spawnSync(process.execPath, [slopScript, "--severity=critical"], {
|
|
345
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
346
|
+
encoding: "utf8",
|
|
347
|
+
timeout: 60000,
|
|
348
|
+
});
|
|
349
|
+
if (r.status === 1) {
|
|
350
|
+
console.error("BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying.");
|
|
351
|
+
const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
352
|
+
if (output) {
|
|
353
|
+
const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
|
|
354
|
+
for (const line of lines) console.error(` ${line}`);
|
|
355
|
+
}
|
|
356
|
+
_trace("pre-deploy-gate", "block", { gate: "slop", status: r.status });
|
|
357
|
+
process.exit(2);
|
|
358
|
+
}
|
|
359
|
+
console.log(" β Anti-slop");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
325
363
|
console.log("β¬’ All gates passed.");
|
|
326
364
|
|
|
327
365
|
_trace("pre-deploy-gate", "allow");
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/task-write-guard.js β runtime enforcement of the plan
|
|
3
|
+
// contract's declared file sets. PreToolUse hook on Edit/Write.
|
|
4
|
+
// Exits 2 to BLOCK. Exits 0 to allow. Cross-platform (Windows/macOS/Linux).
|
|
5
|
+
//
|
|
6
|
+
// WHY: plan-contract.js proves file-disjointness across parallel tasks at PLAN
|
|
7
|
+
// time, but nothing stops a builder writing outside its declared set at RUN
|
|
8
|
+
// time β the documented #1 cause of cross-wave merge conflicts and AI entropy
|
|
9
|
+
// (files nobody planned). This turns the static check into a deterministic
|
|
10
|
+
// guardrail ("a rule worth enforcing is worth a hook" β constitution).
|
|
11
|
+
//
|
|
12
|
+
// SCOPE & HONEST LIMITATION: Claude Code fires the same stateless hook for
|
|
13
|
+
// every subagent and gives it no task identity, so this hook cannot attribute a
|
|
14
|
+
// write to a *specific* task. What it CAN enforce β and does β is that, while a
|
|
15
|
+
// build is in flight, every Edit/Write targets a path DECLARED by SOME task in
|
|
16
|
+
// the active phase contract (files_modify βͺ files_create). Plan-time
|
|
17
|
+
// disjointness already guarantees no two tasks share a path, and the builder's
|
|
18
|
+
// <wave_context> prompt tells it which set is its own; so the residual gap
|
|
19
|
+
// ("T3 edits T4's declared file") is prompt-guarded while the high-frequency
|
|
20
|
+
// vector ("builder invents/edits a file nobody planned") is hard-blocked.
|
|
21
|
+
//
|
|
22
|
+
// The guard is SCOPED: it is a no-op unless a build is active (β₯1 RUNNING entry
|
|
23
|
+
// in .agent-status/). Outside a build it never fires, so it can't interfere with
|
|
24
|
+
// the orchestrator, the verifier, or ordinary editing. Fails OPEN on any error.
|
|
25
|
+
|
|
26
|
+
const fs = require("fs");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
|
|
29
|
+
const _traceStart = Date.now();
|
|
30
|
+
|
|
31
|
+
// ββ stdin reader (same robust pattern as the other guards) ββββββββββββββ
|
|
32
|
+
function sleepSync(ms) {
|
|
33
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {}
|
|
34
|
+
}
|
|
35
|
+
function readInput() {
|
|
36
|
+
const deadline = Date.now() + 1000;
|
|
37
|
+
const buf = Buffer.alloc(65536);
|
|
38
|
+
let data = "";
|
|
39
|
+
try {
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
let n = 0;
|
|
42
|
+
try {
|
|
43
|
+
n = fs.readSync(0, buf, 0, buf.length, null);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { sleepSync(1); continue; }
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (n === 0) break;
|
|
49
|
+
data += buf.slice(0, n).toString("utf8");
|
|
50
|
+
}
|
|
51
|
+
if (!data) return {};
|
|
52
|
+
return JSON.parse(data);
|
|
53
|
+
} catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _trace(result, extra) {
|
|
59
|
+
try {
|
|
60
|
+
const os = require("os");
|
|
61
|
+
const parent = path.basename(path.dirname(__dirname));
|
|
62
|
+
const qualiaHome = process.env.QUALIA_HOME ||
|
|
63
|
+
(parent === ".codex" || parent === ".claude" ? path.dirname(__dirname) : path.join(os.homedir(), ".claude"));
|
|
64
|
+
const traceDir = path.join(qualiaHome, ".qualia-traces");
|
|
65
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
66
|
+
const entry = { hook: "task-write-guard", result, timestamp: new Date().toISOString(), duration_ms: Date.now() - _traceStart, ...extra };
|
|
67
|
+
fs.appendFileSync(path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`), JSON.stringify(entry) + "\n");
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function allow(reason, extra) { _trace("allow", { reason, ...extra }); process.exit(0); }
|
|
72
|
+
|
|
73
|
+
// OWNER / debugging escape hatch, mirroring git-guardrails' QUALIA_ALLOW_*.
|
|
74
|
+
if (process.env.QUALIA_ALLOW_OUTSIDE_CONTRACT === "1") allow("escape-hatch");
|
|
75
|
+
|
|
76
|
+
const input = readInput();
|
|
77
|
+
const ti = input.tool_input || {};
|
|
78
|
+
const rawPath = String(ti.file_path || "");
|
|
79
|
+
if (!rawPath) allow("no file_path");
|
|
80
|
+
|
|
81
|
+
const root = process.cwd();
|
|
82
|
+
|
|
83
|
+
// Reuse the status + contract libraries that ship alongside this hook (bin/ is a
|
|
84
|
+
// sibling of hooks/ in both the repo and the installed layout). If they're not
|
|
85
|
+
// resolvable (older/partial install), fail open.
|
|
86
|
+
let agentStatus, planContract;
|
|
87
|
+
try {
|
|
88
|
+
agentStatus = require(path.join(__dirname, "..", "bin", "agent-status.js"));
|
|
89
|
+
planContract = require(path.join(__dirname, "..", "bin", "plan-contract.js"));
|
|
90
|
+
} catch {
|
|
91
|
+
allow("libs unavailable");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// SCOPE: only enforce during an active build (β₯1 RUNNING agent-status entry).
|
|
95
|
+
let running;
|
|
96
|
+
try {
|
|
97
|
+
running = agentStatus.listStatuses(root).filter((s) => s.status === "RUNNING");
|
|
98
|
+
} catch {
|
|
99
|
+
allow("status unreadable");
|
|
100
|
+
}
|
|
101
|
+
if (!running || running.length === 0) allow("no active build");
|
|
102
|
+
|
|
103
|
+
// Locate the active phase contract. Prefer the phase declared by a RUNNING
|
|
104
|
+
// builder; fall back to the sole phase-*-contract.json if unambiguous.
|
|
105
|
+
function findContractPath() {
|
|
106
|
+
const phases = [...new Set(running.map((s) => s.phase).filter((p) => p != null))];
|
|
107
|
+
for (const p of phases) {
|
|
108
|
+
const cp = path.join(root, ".planning", `phase-${p}-contract.json`);
|
|
109
|
+
if (fs.existsSync(cp)) return cp;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const dir = path.join(root, ".planning");
|
|
113
|
+
const matches = fs.readdirSync(dir).filter((f) => /^phase-\d+-contract\.json$/.test(f));
|
|
114
|
+
if (matches.length === 1) return path.join(dir, matches[0]);
|
|
115
|
+
} catch {}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const contractPath = findContractPath();
|
|
120
|
+
if (!contractPath) allow("no active contract");
|
|
121
|
+
|
|
122
|
+
let contract;
|
|
123
|
+
try {
|
|
124
|
+
const loaded = planContract.readContractFile(contractPath);
|
|
125
|
+
if (!loaded.ok) allow("contract unreadable");
|
|
126
|
+
contract = loaded.contract;
|
|
127
|
+
} catch {
|
|
128
|
+
allow("contract parse error");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build the union of writable declared paths across all tasks.
|
|
132
|
+
// Edit/Write create or modify; deletes are out of band for this tool family.
|
|
133
|
+
function norm(p) {
|
|
134
|
+
return String(p).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
135
|
+
}
|
|
136
|
+
const declared = new Set();
|
|
137
|
+
for (const t of contract.tasks || []) {
|
|
138
|
+
for (const f of t.files_modify || []) declared.add(norm(f));
|
|
139
|
+
for (const f of t.files_create || []) declared.add(norm(f));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve the target to a path relative to the project root.
|
|
143
|
+
const abs = path.isAbsolute(rawPath) ? rawPath : path.resolve(root, rawPath);
|
|
144
|
+
const rel = norm(path.relative(root, abs));
|
|
145
|
+
|
|
146
|
+
// Out of project root β not this guard's concern (other guards handle secrets).
|
|
147
|
+
if (rel.startsWith("../") || rel === "" || path.isAbsolute(rel)) allow("outside project root", { rel });
|
|
148
|
+
|
|
149
|
+
// Framework scratch / planning artifacts are always writable during a build:
|
|
150
|
+
// the status protocol, evidence, deviations, plan and contract files.
|
|
151
|
+
if (rel.startsWith(".agent-status/") || rel.startsWith(".planning/")) allow("framework path", { rel });
|
|
152
|
+
|
|
153
|
+
if (declared.has(rel)) allow("declared", { rel });
|
|
154
|
+
|
|
155
|
+
// Not declared by any task β block.
|
|
156
|
+
console.error("β¬’ task-write-guard β write outside the plan contract:");
|
|
157
|
+
console.error(` β ${rel}`);
|
|
158
|
+
console.error("");
|
|
159
|
+
console.error(` No task in ${path.relative(root, contractPath)} declares this file`);
|
|
160
|
+
console.error(" (files_modify / files_create). Builders may only write files");
|
|
161
|
+
console.error(" their task planned. If this file is genuinely needed, add it to");
|
|
162
|
+
console.error(" the contract via the locked-decision channel, or re-plan the phase.");
|
|
163
|
+
console.error(" OWNER override: QUALIA_ALLOW_OUTSIDE_CONTRACT=1");
|
|
164
|
+
_trace("block", { rel, contract: path.relative(root, contractPath) });
|
|
165
|
+
process.exit(2);
|
package/package.json
CHANGED
|
@@ -38,6 +38,20 @@ node ${QUALIA_BIN}/plan-contract.js validate .planning/phase-{N}-contract.json
|
|
|
38
38
|
|
|
39
39
|
Parse tasks, waves, file refs. Prefer the JSON contract for task ids, dependencies, file lists, and verification checks; use the Markdown plan as the human-readable context.
|
|
40
40
|
|
|
41
|
+
### 1a. Analyze Gate (scope β plan, before any build)
|
|
42
|
+
|
|
43
|
+
`plan-contract.js` proves the contract is internally well-formed; this gate diffs it **against intent** β scope acceptance criteria (`phase-{N}-context.md`) + the CONTEXT.md glossary β to catch requirements the plan silently dropped or contradicted. This is the planβbuild seam Spec-Kit calls `/analyze`.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
node ${QUALIA_BIN}/analyze-gate.js {N}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Exit 0 β consistent, proceed. Non-zero β it lists under-covered scope criteria, orphan success criteria, glossary violations, and scope-reduction language. **Profile-aware** (the `profile` field from `state.js check`):
|
|
50
|
+
- **strict** β a HIGH finding is a stop. Route to `/qualia-plan {N} --gaps` (plan dropped a requirement) or `/qualia-scope {N}` (scope itself is wrong). Do not build.
|
|
51
|
+
- **standard** β surface findings to the operator and proceed only with an explicit ack; log the waiver reason to `.planning/decisions/` if you proceed past a HIGH.
|
|
52
|
+
|
|
53
|
+
(No scope file = scope-coverage check is skipped, not a failure β `/qualia-feature` trivia and scope-less phases still build.)
|
|
54
|
+
|
|
41
55
|
### 1b. Recovery Reference
|
|
42
56
|
|
|
43
57
|
Tag HEAD before executing. Reference only, no auto-rollback.
|
|
@@ -117,7 +131,13 @@ Parallel tasks Wave {W} (do NOT touch their files):
|
|
|
117
131
|
</task_contract>
|
|
118
132
|
|
|
119
133
|
Context tags already loaded. Only Read project code you modify.
|
|
120
|
-
|
|
134
|
+
|
|
135
|
+
Status protocol (machine-readable fan-in β do this, do not skip):
|
|
136
|
+
- First action: `node ${QUALIA_BIN}/agent-status.js write {task_id} RUNNING --phase {N} --wave {W}`
|
|
137
|
+
- Last action, after committing: `node ${QUALIA_BIN}/agent-status.js write {task_id} DONE --commit $(git rev-parse --short HEAD)`
|
|
138
|
+
(use BLOCKED or PARTIAL with `--note \"why\"` instead of DONE if you could not finish)
|
|
139
|
+
|
|
140
|
+
Execute. Commit. Write your DONE/BLOCKED/PARTIAL status. Return DONE/BLOCKED/PARTIAL.
|
|
121
141
|
", subagent_type="qualia-builder", description="Task {N}: {title}")
|
|
122
142
|
```
|
|
123
143
|
|
|
@@ -130,6 +150,14 @@ Execute. Commit. Return DONE/BLOCKED/PARTIAL.
|
|
|
130
150
|
node ${QUALIA_BIN}/qualia-ui.js done {task_num} "{title}" {commit_hash}
|
|
131
151
|
```
|
|
132
152
|
|
|
153
|
+
**After each wave β fan-in barrier (deterministic, not "did the model notice"):**
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
node ${QUALIA_BIN}/agent-status.js barrier .planning/phase-{N}-contract.json --wave {W}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Exit 0 β every task in the wave wrote `DONE`. Non-zero β the barrier lists which tasks are RUNNING/BLOCKED/PARTIAL/MISSING. Do NOT advance to the next wave until the barrier passes; a BLOCKED/PARTIAL task is a wave failure (Β§4). `agent-status.js list` shows the live wave view.
|
|
160
|
+
|
|
133
161
|
**After each wave:** move to next, show summary.
|
|
134
162
|
|
|
135
163
|
### 3. Wave Completion
|
|
@@ -141,6 +169,7 @@ node ${QUALIA_BIN}/qualia-ui.js divider
|
|
|
141
169
|
node ${QUALIA_BIN}/qualia-ui.js ok "Tasks: {done}/{total}"
|
|
142
170
|
node ${QUALIA_BIN}/qualia-ui.js ok "Commits: {count}"
|
|
143
171
|
node ${QUALIA_BIN}/qualia-ui.js ok "Waves: {count}"
|
|
172
|
+
node ${QUALIA_BIN}/agent-status.js clear # drop ephemeral .agent-status/ scratch
|
|
144
173
|
```
|
|
145
174
|
|
|
146
175
|
### 4. Handle Failures
|
|
@@ -65,8 +65,11 @@ npx tsc --noEmit # TypeScript β must pass
|
|
|
65
65
|
if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.lint?0:1)"; then npm run lint; fi
|
|
66
66
|
if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)"; then npm test; fi
|
|
67
67
|
npm run build # Build β must succeed
|
|
68
|
+
node ${QUALIA_BIN}/slop-detect.mjs --severity=critical # Anti-slop β CRITICAL design tells block ship
|
|
68
69
|
```
|
|
69
70
|
|
|
71
|
+
The `pre-deploy-gate.js` hook re-runs the anti-slop scan at `vercel --prod` time as the hard, non-bypassable gate (OWNER-only `QUALIA_SKIP_SLOP=1` escape). This step surfaces failures early so they're fixed before the deploy command.
|
|
72
|
+
|
|
70
73
|
On failure:
|
|
71
74
|
1. Summarize what failed in plain language
|
|
72
75
|
2. Auto-fix
|