qualia-framework 6.9.0 → 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 +88 -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 +31 -4
- package/bin/report-payload.js +19 -0
- package/bin/runtime-manifest.js +2 -0
- package/bin/state.js +145 -11
- package/docs/EMPLOYEE-QUICKSTART.md +5 -3
- package/docs/erp-contract.md +33 -1
- package/docs/qualia-manual.html +396 -0
- package/hooks/branch-guard.js +133 -63
- package/hooks/pre-deploy-gate.js +38 -0
- package/hooks/session-start.js +24 -4
- package/hooks/task-write-guard.js +165 -0
- package/hooks/usage-capture.js +108 -0
- package/package.json +2 -1
- package/skills/qualia-build/SKILL.md +30 -1
- package/skills/qualia-report/SKILL.md +3 -0
- 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/templates/planning.gitignore +3 -0
- package/tests/agent-status.test.sh +138 -0
- package/tests/analyze-gate.test.sh +170 -0
- package/tests/bin.test.sh +6 -4
- package/tests/hooks.test.sh +250 -17
- package/tests/install-smoke.test.sh +5 -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/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");
|
package/hooks/session-start.js
CHANGED
|
@@ -167,17 +167,37 @@ function maybeDrainErpQueue() {
|
|
|
167
167
|
} catch {}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
function cmpVersions(a, b) {
|
|
171
|
+
// Returns >0 if a>b, <0 if a<b, 0 if equal. Tolerates missing/non-numeric
|
|
172
|
+
// segments by treating them as 0. Pure semver-major.minor.patch compare.
|
|
173
|
+
const pa = String(a || "0").split(".").map((n) => parseInt(n, 10) || 0);
|
|
174
|
+
const pb = String(b || "0").split(".").map((n) => parseInt(n, 10) || 0);
|
|
175
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
176
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
177
|
+
if (d !== 0) return d;
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
function maybeRenderUpdateBanner() {
|
|
171
183
|
// EMPLOYEE-only sticky banner. auto-update.js writes NOTIF_FILE when a new
|
|
172
184
|
// version is detected; we render it every session until the user actually
|
|
173
|
-
// runs `npx qualia-framework@latest install`.
|
|
174
|
-
//
|
|
185
|
+
// runs `npx qualia-framework@latest install`. auto-update.js clears the file
|
|
186
|
+
// when it next runs and finds the version caught up — but it is throttled, so
|
|
187
|
+
// between an install and that next run the notif can be stale. We therefore
|
|
188
|
+
// self-validate here against the installed version (.qualia-config.json) and
|
|
189
|
+
// clear+skip a stale notice rather than show a false "update available".
|
|
175
190
|
if (!fs.existsSync(NOTIF_FILE) || !fs.existsSync(UI)) return;
|
|
176
191
|
try {
|
|
177
192
|
const notif = JSON.parse(fs.readFileSync(NOTIF_FILE, "utf8"));
|
|
178
|
-
if (notif
|
|
179
|
-
|
|
193
|
+
if (!notif || !notif.current || !notif.latest) return;
|
|
194
|
+
const installed = readConfig().version;
|
|
195
|
+
if (installed && cmpVersions(installed, notif.latest) >= 0) {
|
|
196
|
+
// Already at or past the advertised latest — the notice is stale.
|
|
197
|
+
try { fs.unlinkSync(NOTIF_FILE); } catch {}
|
|
198
|
+
return;
|
|
180
199
|
}
|
|
200
|
+
runUi("update", notif.current, notif.latest);
|
|
181
201
|
} catch {}
|
|
182
202
|
}
|
|
183
203
|
|
|
@@ -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);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* UserPromptSubmit hook — per-session framework-usage capture.
|
|
4
|
+
*
|
|
5
|
+
* Records, into `.planning/.session-usage.json` of the active project:
|
|
6
|
+
* - command_usage : a histogram of qualia slash-commands the engineer fired
|
|
7
|
+
* - prompt_samples: the engineer's real prompts (opt-in), for the ERP
|
|
8
|
+
* prompt-quality judge
|
|
9
|
+
*
|
|
10
|
+
* `report-payload.js` folds both into the /qualia-report clock-out payload, and
|
|
11
|
+
* `/qualia-report` clears the file after a successful upload. This is what feeds
|
|
12
|
+
* the ERP performance audit's "framework usage & prompting" pillar (command
|
|
13
|
+
* volume + diversity, and LLM-judged prompt quality).
|
|
14
|
+
*
|
|
15
|
+
* Privacy: prompt capture is opt-in via `erp.capturePrompts` in
|
|
16
|
+
* ~/.claude/.qualia-config.json (default ON for the internal team; set to false
|
|
17
|
+
* to record command counts only). The file is a .planning dotfile and is
|
|
18
|
+
* deleted at clock-out — it must never be committed.
|
|
19
|
+
*
|
|
20
|
+
* Never blocks: any error exits 0 with no stdout (adds no context to the prompt).
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const MAX_SAMPLES = 60;
|
|
27
|
+
const MAX_SAMPLE_LEN = 8000;
|
|
28
|
+
|
|
29
|
+
function readJson(file, fallback) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function capturePromptsEnabled(home) {
|
|
38
|
+
const cfg =
|
|
39
|
+
readJson(path.join(home, '.claude', '.qualia-config.json'), null) ||
|
|
40
|
+
readJson(path.join(home, '.codex', '.qualia-config.json'), {});
|
|
41
|
+
return !cfg || !cfg.erp || cfg.erp.capturePrompts !== false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function main() {
|
|
45
|
+
let raw = '';
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(0, 'utf8');
|
|
48
|
+
} catch {
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
let input = {};
|
|
52
|
+
try {
|
|
53
|
+
input = JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
|
|
59
|
+
const cwd = input.cwd || input.cwd_path || process.cwd();
|
|
60
|
+
if (!prompt.trim()) process.exit(0);
|
|
61
|
+
|
|
62
|
+
// Only record inside a Qualia project (has .planning/).
|
|
63
|
+
const planningDir = path.join(cwd, '.planning');
|
|
64
|
+
if (!fs.existsSync(planningDir)) process.exit(0);
|
|
65
|
+
|
|
66
|
+
const usageFile = path.join(planningDir, '.session-usage.json');
|
|
67
|
+
const usage = readJson(usageFile, { command_usage: {}, prompt_samples: [] });
|
|
68
|
+
if (!usage.command_usage || typeof usage.command_usage !== 'object') usage.command_usage = {};
|
|
69
|
+
if (!Array.isArray(usage.prompt_samples)) usage.prompt_samples = [];
|
|
70
|
+
|
|
71
|
+
// Count a qualia slash-command if one appears as a token in the prompt.
|
|
72
|
+
const m = prompt.match(/(?:^|\s)\/(qualia[a-z0-9-]*)/i);
|
|
73
|
+
if (m) {
|
|
74
|
+
const cmd = m[1].toLowerCase();
|
|
75
|
+
usage.command_usage[cmd] = (usage.command_usage[cmd] || 0) + 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sample the real prompt for the prompt-quality judge (opt-in, disclosed).
|
|
79
|
+
if (capturePromptsEnabled(os.homedir())) {
|
|
80
|
+
// Transparency: tell the engineer once per shift that prompts are recorded
|
|
81
|
+
// and how to turn it off. stderr so it's seen but never enters model
|
|
82
|
+
// context. The flag rides in the usage file, which is cleared at clock-out,
|
|
83
|
+
// so the notice reappears each new shift. Best-effort — never blocks.
|
|
84
|
+
if (!usage.notice_shown) {
|
|
85
|
+
try {
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
'\x1b[38;2;80;90;100m[qualia] This session records your /qualia command usage and ' +
|
|
88
|
+
'prompt samples for the team performance audit. Opt out anytime: set ' +
|
|
89
|
+
'erp.capturePrompts=false in ~/.claude/.qualia-config.json\x1b[0m\n'
|
|
90
|
+
);
|
|
91
|
+
} catch { /* never block the prompt */ }
|
|
92
|
+
usage.notice_shown = true;
|
|
93
|
+
}
|
|
94
|
+
usage.prompt_samples.push(prompt.trim().slice(0, MAX_SAMPLE_LEN));
|
|
95
|
+
if (usage.prompt_samples.length > MAX_SAMPLES) {
|
|
96
|
+
usage.prompt_samples = usage.prompt_samples.slice(-MAX_SAMPLES);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
fs.writeFileSync(usageFile, JSON.stringify(usage));
|
|
102
|
+
} catch {
|
|
103
|
+
/* best-effort; never block the prompt */
|
|
104
|
+
}
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.14.0",
|
|
4
4
|
"description": "Claude Code and Codex workflow framework by Qualia Solutions. Plan, build, verify, ship.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualia-framework": "./bin/cli.js"
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"docs/release.md",
|
|
53
53
|
"docs/changelog-v6.html",
|
|
54
54
|
"docs/onboarding.html",
|
|
55
|
+
"docs/qualia-manual.html",
|
|
55
56
|
"docs/EMPLOYEE-QUICKSTART.md",
|
|
56
57
|
"CLAUDE.md",
|
|
57
58
|
"AGENTS.md",
|