qualia-framework 7.2.2 → 7.3.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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +17 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +1 -1
- package/README.md +17 -4
- package/TROUBLESHOOTING.md +8 -7
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +115 -11
- package/bin/auto-report.js +15 -7
- package/bin/cli.js +173 -4
- package/bin/erp-retry.js +92 -8
- package/bin/install.js +102 -2
- package/bin/qualia-doctor.js +115 -1
- package/bin/state.js +102 -13
- package/bin/verify-panel.js +409 -0
- package/docs/onboarding.html +1 -1
- package/hooks/branch-guard.js +19 -5
- package/hooks/fawzi-approval-guard.js +16 -3
- package/hooks/hooks.json +60 -0
- package/hooks/migration-guard.js +143 -66
- package/hooks/session-start.js +27 -0
- package/package.json +3 -1
- package/skills/qualia/SKILL.md +20 -13
- package/skills/qualia-build/SKILL.md +20 -9
- package/skills/qualia-verify/SKILL.md +43 -5
- package/templates/instructions.md +2 -2
- package/tests/bin.test.sh +183 -0
- package/tests/hooks.test.sh +124 -0
- package/tests/install-smoke.test.sh +14 -0
- package/tests/instructions.test.sh +2 -2
- package/tests/lib.test.sh +149 -0
- package/tests/plugin-manifest.test.sh +168 -0
- package/tests/refs.test.sh +64 -0
- package/tests/run-all.sh +1 -0
- package/tests/state.test.sh +174 -0
- package/tests/verify-panel.test.sh +236 -0
package/hooks/migration-guard.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ~/.claude/hooks/migration-guard.js — catch dangerous SQL patterns in migrations.
|
|
3
|
-
// PreToolUse hook on Edit/Write tool calls. Reads tool input
|
|
4
|
-
// Exits 2 to BLOCK. Exits 0 to allow.
|
|
3
|
+
// PreToolUse hook on Edit/Write tool calls AND Bash tool calls. Reads tool input
|
|
4
|
+
// as JSON on stdin. Exits 2 to BLOCK. Exits 0 to allow.
|
|
5
|
+
// Edit|Write: scans file content for .sql / migrations/ files.
|
|
6
|
+
// Bash: scans inline SQL that bypasses supabase/migrations/ (heredoc→.sql,
|
|
7
|
+
// psql -c/-f, supabase db execute/push) — same destructive/RLS check.
|
|
5
8
|
// Cross-platform (Windows/macOS/Linux).
|
|
6
9
|
|
|
7
10
|
const fs = require("fs");
|
|
@@ -42,6 +45,7 @@ function readInput() {
|
|
|
42
45
|
|
|
43
46
|
const input = readInput();
|
|
44
47
|
const ti = input.tool_input || {};
|
|
48
|
+
const toolName = String(input.tool_name || "");
|
|
45
49
|
const file = String(ti.file_path || "").replace(/\\/g, "/");
|
|
46
50
|
|
|
47
51
|
// For Edit tool calls, dangerous SQL might live in old_string OR new_string.
|
|
@@ -52,6 +56,11 @@ const content = [ti.old_string, ti.new_string, ti.content]
|
|
|
52
56
|
.map((v) => String(v))
|
|
53
57
|
.join("\n");
|
|
54
58
|
|
|
59
|
+
// For Bash tool calls, dangerous SQL can be written or executed through the
|
|
60
|
+
// shell, bypassing the Edit|Write matcher entirely. We mirror the Bash-content
|
|
61
|
+
// scan pattern from supabase-destructive-guard.js (reads tool_input.command).
|
|
62
|
+
const command = String(ti.command || "");
|
|
63
|
+
|
|
55
64
|
function _trace(hookName, result, extra) {
|
|
56
65
|
try {
|
|
57
66
|
const os = require("os");
|
|
@@ -73,13 +82,6 @@ function _trace(hookName, result, extra) {
|
|
|
73
82
|
} catch {}
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
// Only inspect SQL files or files that live inside a migrations/ directory.
|
|
77
|
-
// Prior regex was over-broad (matched MigrationModal.tsx, migrations.md, etc.).
|
|
78
|
-
if (!/(^|\/)migrations?\//i.test(file) && !/\.sql$/i.test(file)) {
|
|
79
|
-
_trace("migration-guard", "allow", { reason: "non-migration file" });
|
|
80
|
-
process.exit(0);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
85
|
// Strip SQL comments before pattern matching so rolled-back/explanatory
|
|
84
86
|
// statements inside `-- ...` line comments or `/* ... */` block comments
|
|
85
87
|
// don't trigger false positives.
|
|
@@ -91,76 +93,85 @@ function stripSqlComments(src) {
|
|
|
91
93
|
return out;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
|
|
96
|
+
function splitStatements(src) {
|
|
97
|
+
return src.split(/;/g).map((s) => s.trim()).filter(Boolean);
|
|
98
|
+
}
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
// Scan a blob of SQL for destructive / RLS-violating patterns. Returns an array
|
|
101
|
+
// of human-readable error strings (empty = clean). Shared by the Edit|Write
|
|
102
|
+
// path (scans file content) and the Bash path (scans inline shell SQL) so the
|
|
103
|
+
// constitution's destructive-SQL + RLS check applies no matter how the SQL
|
|
104
|
+
// reaches disk or the database.
|
|
105
|
+
function scanSql(rawSql) {
|
|
106
|
+
const scan = stripSqlComments(rawSql);
|
|
107
|
+
const errors = [];
|
|
97
108
|
|
|
98
|
-
// DROP TABLE without IF EXISTS
|
|
99
|
-
if (/DROP\s+TABLE/i.test(scan) && !/IF\s+EXISTS/i.test(scan)) {
|
|
100
|
-
|
|
101
|
-
}
|
|
109
|
+
// DROP TABLE without IF EXISTS
|
|
110
|
+
if (/DROP\s+TABLE/i.test(scan) && !/IF\s+EXISTS/i.test(scan)) {
|
|
111
|
+
errors.push("DROP TABLE without IF EXISTS");
|
|
112
|
+
}
|
|
102
113
|
|
|
103
|
-
// DROP DATABASE — almost never appropriate in app migrations
|
|
104
|
-
if (/DROP\s+DATABASE/i.test(scan)) {
|
|
105
|
-
|
|
106
|
-
}
|
|
114
|
+
// DROP DATABASE — almost never appropriate in app migrations
|
|
115
|
+
if (/DROP\s+DATABASE/i.test(scan)) {
|
|
116
|
+
errors.push("DROP DATABASE detected — refuse unless explicitly approved");
|
|
117
|
+
}
|
|
107
118
|
|
|
108
|
-
// DROP SCHEMA — destructive, especially with CASCADE
|
|
109
|
-
if (/DROP\s+SCHEMA/i.test(scan)) {
|
|
110
|
-
|
|
111
|
-
}
|
|
119
|
+
// DROP SCHEMA — destructive, especially with CASCADE
|
|
120
|
+
if (/DROP\s+SCHEMA/i.test(scan)) {
|
|
121
|
+
errors.push("DROP SCHEMA detected — refuse unless explicitly approved");
|
|
122
|
+
}
|
|
112
123
|
|
|
113
|
-
// ALTER TABLE ... DROP COLUMN — destructive schema change
|
|
114
|
-
if (/ALTER\s+TABLE\s+[^;]*\bDROP\s+COLUMN\b/i.test(scan)) {
|
|
115
|
-
|
|
116
|
-
}
|
|
124
|
+
// ALTER TABLE ... DROP COLUMN — destructive schema change
|
|
125
|
+
if (/ALTER\s+TABLE\s+[^;]*\bDROP\s+COLUMN\b/i.test(scan)) {
|
|
126
|
+
errors.push("ALTER TABLE ... DROP COLUMN is destructive");
|
|
127
|
+
}
|
|
117
128
|
|
|
118
|
-
// DELETE / UPDATE without WHERE — check per-statement, not file-global.
|
|
119
|
-
// Previously a file containing "DELETE FROM foo;" followed by any later
|
|
120
|
-
// "... WHERE ..." (in a SELECT, JOIN, etc.) would pass the check.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
errors.push("DELETE FROM without WHERE clause");
|
|
128
|
-
break;
|
|
129
|
+
// DELETE / UPDATE without WHERE — check per-statement, not file-global.
|
|
130
|
+
// Previously a file containing "DELETE FROM foo;" followed by any later
|
|
131
|
+
// "... WHERE ..." (in a SELECT, JOIN, etc.) would pass the check.
|
|
132
|
+
const statements = splitStatements(scan);
|
|
133
|
+
for (const stmt of statements) {
|
|
134
|
+
if (/^\s*DELETE\s+FROM\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
|
|
135
|
+
errors.push("DELETE FROM without WHERE clause");
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
129
138
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
for (const stmt of statements) {
|
|
140
|
+
if (/^\s*UPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
|
|
141
|
+
errors.push("UPDATE without WHERE clause — affects every row");
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
135
144
|
}
|
|
136
|
-
}
|
|
137
145
|
|
|
138
|
-
// TRUNCATE (almost always wrong in migrations)
|
|
139
|
-
if (/TRUNCATE/i.test(scan)) {
|
|
140
|
-
|
|
141
|
-
}
|
|
146
|
+
// TRUNCATE (almost always wrong in migrations)
|
|
147
|
+
if (/TRUNCATE/i.test(scan)) {
|
|
148
|
+
errors.push("TRUNCATE detected — are you sure?");
|
|
149
|
+
}
|
|
142
150
|
|
|
143
|
-
// GRANT ... TO PUBLIC — privilege leak
|
|
144
|
-
if (/GRANT\s+[^;]*\bTO\s+PUBLIC\b/i.test(scan)) {
|
|
145
|
-
|
|
146
|
-
}
|
|
151
|
+
// GRANT ... TO PUBLIC — privilege leak
|
|
152
|
+
if (/GRANT\s+[^;]*\bTO\s+PUBLIC\b/i.test(scan)) {
|
|
153
|
+
errors.push("GRANT ... TO PUBLIC detected — privilege leak");
|
|
154
|
+
}
|
|
147
155
|
|
|
148
|
-
// CREATE TABLE without RLS — but skip TEMP/TEMPORARY tables and partitions.
|
|
149
|
-
// Strategy: enumerate CREATE TABLE statements, drop the ones that don't need RLS,
|
|
150
|
-
// then if any "real" CREATE TABLE remains, require ENABLE ROW LEVEL SECURITY.
|
|
151
|
-
const createTableMatches = scan.match(/CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY|UNLOGGED)?\s*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[^;]*/gi) || [];
|
|
152
|
-
const realCreateTables = createTableMatches.filter((stmt) => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
159
|
-
if (realCreateTables.length > 0 && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(scan)) {
|
|
160
|
-
|
|
156
|
+
// CREATE TABLE without RLS — but skip TEMP/TEMPORARY tables and partitions.
|
|
157
|
+
// Strategy: enumerate CREATE TABLE statements, drop the ones that don't need RLS,
|
|
158
|
+
// then if any "real" CREATE TABLE remains, require ENABLE ROW LEVEL SECURITY.
|
|
159
|
+
const createTableMatches = scan.match(/CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY|UNLOGGED)?\s*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[^;]*/gi) || [];
|
|
160
|
+
const realCreateTables = createTableMatches.filter((stmt) => {
|
|
161
|
+
// Skip TEMP/TEMPORARY tables — they're session-scoped, no RLS needed.
|
|
162
|
+
if (/CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY)\b/i.test(stmt)) return false;
|
|
163
|
+
// Skip partition tables — RLS lives on the parent table.
|
|
164
|
+
if (/\bPARTITION\s+OF\b/i.test(stmt)) return false;
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
if (realCreateTables.length > 0 && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(scan)) {
|
|
168
|
+
errors.push("CREATE TABLE without ENABLE ROW LEVEL SECURITY");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return errors;
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
|
|
174
|
+
function block(errors) {
|
|
164
175
|
console.error("⬢ Migration guard — dangerous patterns found:");
|
|
165
176
|
for (const e of errors) {
|
|
166
177
|
console.error(` ✗ ${e}`);
|
|
@@ -171,5 +182,71 @@ if (errors.length > 0) {
|
|
|
171
182
|
process.exit(2);
|
|
172
183
|
}
|
|
173
184
|
|
|
185
|
+
// ── Bash path ─────────────────────────────────────────────────────────────
|
|
186
|
+
// SQL written or executed through the shell bypasses the Edit|Write matcher,
|
|
187
|
+
// so it would never reach the destructive/RLS check above. Mirror the Bash-
|
|
188
|
+
// content scan from supabase-destructive-guard.js: extract the inline SQL from
|
|
189
|
+
// the command and run it through the same scanSql() the file path uses.
|
|
190
|
+
// Targets: heredoc redirected into a .sql file, psql -c/-f, and
|
|
191
|
+
// `supabase db execute/push` of inline SQL. Fail-closed on a destructive
|
|
192
|
+
// match; fail-open (allow) on anything we can't confidently parse.
|
|
193
|
+
if (toolName === "Bash" || (command && !file)) {
|
|
194
|
+
if (!command) {
|
|
195
|
+
_trace("migration-guard", "allow", { reason: "no-command" });
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Only bother scanning shell that writes/executes SQL outside the
|
|
200
|
+
// migrations pipeline. `supabase migration new` + applying files under
|
|
201
|
+
// supabase/migrations/ go through the proper flow and are not our target.
|
|
202
|
+
const writesSqlFile = /<<-?\s*['"]?\w+['"]?[\s\S]*?\.sql\b/i.test(command) ||
|
|
203
|
+
/>>?\s*\S*\.sql\b/i.test(command);
|
|
204
|
+
const psqlInline = /\bpsql\b[\s\S]*\s-(?:c|f)\b/i.test(command);
|
|
205
|
+
const supabaseExec = /\b(npx\s+)?supabase\s+db\s+(execute|push)\b/i.test(command);
|
|
206
|
+
|
|
207
|
+
if (!writesSqlFile && !psqlInline && !supabaseExec) {
|
|
208
|
+
_trace("migration-guard", "allow", { reason: "no inline SQL in command" });
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Statement-level checks (DELETE/UPDATE without WHERE) anchor at the start of
|
|
213
|
+
// a SQL statement, so scanning the raw shell line (prefixed by `psql -c "`,
|
|
214
|
+
// `supabase db execute "`, etc.) would never match. Strip the shell wrapper:
|
|
215
|
+
// pull out heredoc bodies and quoted args so what we scan looks like raw SQL.
|
|
216
|
+
const sqlFragments = [];
|
|
217
|
+
// Heredoc bodies: `<<EOF ... EOF` / `<<'TAG' ... TAG` (the delimiter may be
|
|
218
|
+
// quoted; the closing tag sits on its own line).
|
|
219
|
+
let hd;
|
|
220
|
+
const heredocRe = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2\b/g;
|
|
221
|
+
while ((hd = heredocRe.exec(command)) !== null) sqlFragments.push(hd[3]);
|
|
222
|
+
// Quoted argument bodies (single- or double-quoted) — captures the SQL passed
|
|
223
|
+
// to `-c`, `execute`, etc. without the surrounding shell quotes.
|
|
224
|
+
let q;
|
|
225
|
+
const quotedRe = /(['"])([\s\S]*?)\1/g;
|
|
226
|
+
while ((q = quotedRe.exec(command)) !== null) sqlFragments.push(q[2]);
|
|
227
|
+
|
|
228
|
+
// Scan both the raw command (catches DROP/TRUNCATE/GRANT — not statement-
|
|
229
|
+
// anchored) and the extracted fragments (catches DELETE/UPDATE-without-WHERE).
|
|
230
|
+
const errors = scanSql([command, ...sqlFragments].join("\n;\n"));
|
|
231
|
+
if (errors.length > 0) {
|
|
232
|
+
block(errors);
|
|
233
|
+
}
|
|
234
|
+
_trace("migration-guard", "allow", { reason: "bash SQL clean" });
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Edit|Write path ───────────────────────────────────────────────────────
|
|
239
|
+
// Only inspect SQL files or files that live inside a migrations/ directory.
|
|
240
|
+
// Prior regex was over-broad (matched MigrationModal.tsx, migrations.md, etc.).
|
|
241
|
+
if (!/(^|\/)migrations?\//i.test(file) && !/\.sql$/i.test(file)) {
|
|
242
|
+
_trace("migration-guard", "allow", { reason: "non-migration file" });
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const errors = scanSql(content);
|
|
247
|
+
if (errors.length > 0) {
|
|
248
|
+
block(errors);
|
|
249
|
+
}
|
|
250
|
+
|
|
174
251
|
_trace("migration-guard", "allow");
|
|
175
252
|
process.exit(0);
|
package/hooks/session-start.js
CHANGED
|
@@ -167,6 +167,32 @@ function maybeDrainErpQueue() {
|
|
|
167
167
|
} catch {}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
function surfaceErpQueue() {
|
|
171
|
+
// One-line, non-blocking notice when the retry queue is non-empty: count +
|
|
172
|
+
// age of the oldest item. Reads the queue file directly (no spawn) and never
|
|
173
|
+
// throws — the drain itself runs separately in maybeDrainErpQueue(). Quiet
|
|
174
|
+
// when the queue is empty so a healthy session stays clean.
|
|
175
|
+
try {
|
|
176
|
+
if (!fs.existsSync(ERP_QUEUE)) return;
|
|
177
|
+
const parsed = JSON.parse(fs.readFileSync(ERP_QUEUE, "utf8"));
|
|
178
|
+
const q = parsed && Array.isArray(parsed.queue) ? parsed.queue : [];
|
|
179
|
+
if (q.length === 0) return;
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
let oldest = 0;
|
|
182
|
+
let stuck = 0;
|
|
183
|
+
for (const it of q) {
|
|
184
|
+
const t = Date.parse(it && it.enqueued_at);
|
|
185
|
+
if (Number.isFinite(t)) oldest = Math.max(oldest, now - t);
|
|
186
|
+
if (it && it.give_up) stuck++;
|
|
187
|
+
}
|
|
188
|
+
const oldestHours = Math.floor(oldest / (60 * 60 * 1000));
|
|
189
|
+
const stuckNote = stuck > 0 ? `, ${stuck} stuck` : "";
|
|
190
|
+
const msg = `${q.length} ERP report(s) pending upload (oldest ${oldestHours}h${stuckNote}) — runs \`qualia-framework erp-flush\` to retry`;
|
|
191
|
+
if (fs.existsSync(UI)) runUi("warn", msg);
|
|
192
|
+
else console.log(`QUALIA: ${msg}`);
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
function cmpVersions(a, b) {
|
|
171
197
|
// Returns >0 if a>b, <0 if a<b, 0 if equal. Tolerates missing/non-numeric
|
|
172
198
|
// segments by treating them as 0. Pure semver-major.minor.patch compare.
|
|
@@ -217,6 +243,7 @@ function renderHealthWarning(missing) {
|
|
|
217
243
|
try {
|
|
218
244
|
maybeRenderUpdateBanner();
|
|
219
245
|
maybeDrainErpQueue();
|
|
246
|
+
surfaceErpQueue();
|
|
220
247
|
|
|
221
248
|
const healthMissing = checkInstallHealth();
|
|
222
249
|
if (healthMissing) renderHealthWarning(healthMissing);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.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"
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"test:statusline": "bash tests/statusline.test.sh",
|
|
37
37
|
"test:refs": "bash tests/refs.test.sh",
|
|
38
38
|
"test:published-install": "bash tests/published-install-smoke.test.sh",
|
|
39
|
+
"test:plugin": "bash tests/plugin-manifest.test.sh",
|
|
39
40
|
"test:shell": "bash tests/run-all.sh",
|
|
40
41
|
"test:node": "node --test tests/runner.js",
|
|
41
42
|
"compile:instructions": "node bin/compile-instructions.js"
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"bin/",
|
|
45
46
|
"agents/",
|
|
46
47
|
"hooks/",
|
|
48
|
+
".claude-plugin/",
|
|
47
49
|
"mcp/",
|
|
48
50
|
"rules/",
|
|
49
51
|
"qualia-design/",
|
package/skills/qualia/SKILL.md
CHANGED
|
@@ -22,7 +22,7 @@ node ${QUALIA_BIN}/state.js check 2>/dev/null
|
|
|
22
22
|
|
|
23
23
|
The JSON carries a `profile` field (`strict` or `standard`; env `$QUALIA_PROFILE` wins). `strict` = hard gates, no waivers; `standard` = gates advisory, a senior may waive with a reason logged to `.planning/decisions/`. Surface it when a gate is involved.
|
|
24
24
|
|
|
25
|
-
**A5 — multi-person layout.** If the JSON has `"layout": "increments"`, this project is concurrency-aware: route by the increment fields, not the single phase cursor. Continue your own claim first (`my_claim`), else start `next_increment` (the first unclaimed, not-done increment). **Never** route the operator to anything in `claimed_increments[]` — those are held by another person on another branch. The status→command
|
|
25
|
+
**A5 — multi-person layout.** If the JSON has `"layout": "increments"`, this project is concurrency-aware: route by the increment fields, not the single phase cursor. Continue your own claim first (`my_claim`), else start `next_increment` (the first unclaimed, not-done increment). **Never** route the operator to anything in `claimed_increments[]` — those are held by another person on another branch. The status→command routing is already computed into `next_command` (by state.js, the single authority — see step 2); the increment fields just tell you *which* unit is yours. Legacy projects (no `layout` field) route exactly as before.
|
|
26
26
|
|
|
27
27
|
Also gather context:
|
|
28
28
|
```bash
|
|
@@ -49,27 +49,34 @@ Read conversation context — what has the user been doing, what errors occurred
|
|
|
49
49
|
|
|
50
50
|
### 2. Classify and Route
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
**state.js owns status→command routing — do not re-derive it.** The JSON's
|
|
53
|
+
`next_command` field is computed by `nextCommand()` in state.js, the single
|
|
54
|
+
routing authority. It already accounts for status, gap-cycle failures, the
|
|
55
|
+
last-phase terminal move, and the project `lifecycle` (a launched/operate
|
|
56
|
+
project routes to `/qualia-update`, not the build-mode polish→ship→handoff
|
|
57
|
+
chain). **Surface `next_command` verbatim.** Do NOT hand-maintain a status
|
|
58
|
+
table here — that duplicate has drifted before.
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
Override the JSON `next_command` ONLY for context-only situations state.js
|
|
61
|
+
cannot see (it reads tracked state, not your conversation or the working tree):
|
|
62
|
+
|
|
63
|
+
| Situation | Detection (context state.js can't see) | Route |
|
|
55
64
|
|-----------|-----------|-------|
|
|
56
65
|
| `no-project` | state.js returns NO_PROJECT | → `/qualia-new` |
|
|
57
66
|
| `handoff` | `.continue-here.md` exists | → Read it, summarize, route to next step |
|
|
58
67
|
| `mid-work` | Uncommitted changes + phase in progress | → Continue, or write `.continue-here.md` if the user wants to pause |
|
|
59
|
-
| `
|
|
60
|
-
| `ready-to-build` | status == "planned" | → `/qualia-build {N}` |
|
|
61
|
-
| `ready-to-verify` | status == "built" | → `/qualia-verify {N}` |
|
|
62
|
-
| `gaps-found` | status == "verified", verification == "fail", gap_cycles < 2 | → `/qualia-plan {N} --gaps` |
|
|
63
|
-
| `gap-limit` | status == "verified", verification == "fail", gap_cycles >= 2 | → Escalate to Fawzi or re-plan from scratch |
|
|
64
|
-
| `phase-complete` | state.js auto-advanced (status == "setup", phase > 1) | → `/qualia-plan {N}` |
|
|
65
|
-
| `all-verified` | last phase verified pass, status == "verified" | → `/qualia-polish` |
|
|
66
|
-
| `polished` | status == "polished" | → `/qualia-ship` |
|
|
67
|
-
| `shipped` | status == "shipped" | → `/qualia-handoff` |
|
|
68
|
-
| `handed-off` | status == "handed_off" | → `/qualia-report` then done |
|
|
68
|
+
| `gap-limit` | `gap_cycles >= gap_cycle_limit` (both from the JSON) | → Escalate to Fawzi or re-plan from scratch — do NOT keep routing to `--gaps` |
|
|
69
69
|
| `blocked` | STATE.md lists blockers or same error 3+ times | → Diagnose the evidence; `/qualia-fix` if expected behavior is known, `/qualia-review` if broader audit is needed |
|
|
70
70
|
| `bug-loop` | Same files edited 3+ times, user frustrated | → Stop patching; summarize root cause evidence and route to `/qualia-fix` or `/qualia-review` |
|
|
71
71
|
| `need-tests` | User mentions "tests", "coverage", "test this" | → `/qualia-test` |
|
|
72
72
|
|
|
73
|
+
Note `gap_cycles` and `gap_cycle_limit` are both in the JSON (the limit is
|
|
74
|
+
configurable via `tracking.json`/`PROJECT.md`, default 2) — compare them, never
|
|
75
|
+
hardcode the threshold. For every non-override situation, route to
|
|
76
|
+
`next_command` as-is. If `next_command` starts with `UNRECOGNIZED_STATUS`, the
|
|
77
|
+
project state is corrupt — surface the diagnostic and route to `/qualia-doctor`;
|
|
78
|
+
never loop back to `/qualia`.
|
|
79
|
+
|
|
73
80
|
**Employee escalation:** If role is EMPLOYEE and situation is `gap-limit` or `bug-loop`, suggest: "Want to flag this for Fawzi?"
|
|
74
81
|
|
|
75
82
|
### 3. Display
|
|
@@ -70,19 +70,23 @@ node ${QUALIA_BIN}/plan-contract.js validate .planning/phase-{N}-contract.json
|
|
|
70
70
|
|
|
71
71
|
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.
|
|
72
72
|
|
|
73
|
-
### 1a. Analyze Gate (scope ↔ plan
|
|
73
|
+
### 1a. Analyze Gate (scope ↔ plan) — ENFORCED at the planned→built transition
|
|
74
74
|
|
|
75
|
-
`plan-contract.js` proves the contract is internally well-formed;
|
|
75
|
+
`plan-contract.js` proves the contract is internally well-formed; the analyze 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`.
|
|
76
|
+
|
|
77
|
+
This is no longer a "please run" step — **`state.js` enforces it deterministically.** The `state.js transition --to built` call in §5 runs the analyze gate for the phase before it lets the status advance ("a rule worth enforcing is worth a hook"). The behavior is **profile-aware** (the `profile` field from `state.js check`):
|
|
78
|
+
- **strict** → a HIGH analyze finding (an under-covered scope AC or scope-reduction language) **REFUSES** the transition with `error: "SCOPE_DRIFT"`, naming the dropped criterion, and writes a `scope-drift` trace. Route to `/qualia-plan {N} --gaps` (plan dropped a requirement) or `/qualia-scope {N}` (scope itself is wrong). Do not build. To override (senior waiver), re-run the transition with `--force`.
|
|
79
|
+
- **standard** → findings are advisory: the transition proceeds. If you proceed past a HIGH, log the waiver reason to `.planning/decisions/`.
|
|
80
|
+
|
|
81
|
+
The gate is **fail-soft on its own error**: a missing/unreadable contract or no scope file (`phase-{N}-context.md`) means the scope-coverage check is skipped, not a failure — `/qualia-feature` trivia and scope-less phases still build.
|
|
82
|
+
|
|
83
|
+
To preview the findings *before* attempting the build (optional — the transition enforces it regardless), run:
|
|
76
84
|
|
|
77
85
|
```bash
|
|
78
86
|
node ${QUALIA_BIN}/analyze-gate.js {N}
|
|
79
87
|
```
|
|
80
88
|
|
|
81
|
-
Exit 0 → consistent
|
|
82
|
-
- **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.
|
|
83
|
-
- **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.
|
|
84
|
-
|
|
85
|
-
(No scope file = scope-coverage check is skipped, not a failure — `/qualia-feature` trivia and scope-less phases still build.)
|
|
89
|
+
Exit 0 → consistent. Non-zero → it lists under-covered scope criteria, orphan success criteria, glossary violations, and scope-reduction language.
|
|
86
90
|
|
|
87
91
|
### 1b. Recovery Reference
|
|
88
92
|
|
|
@@ -194,10 +198,15 @@ node ${QUALIA_BIN}/qualia-ui.js done {task_num} "{title}" {commit_hash}
|
|
|
194
198
|
**After each batch — fan-in barrier (deterministic, not "did the model notice"):**
|
|
195
199
|
|
|
196
200
|
```bash
|
|
197
|
-
node ${QUALIA_BIN}/agent-status.js barrier --tasks {comma-separated task ids in this batch}
|
|
201
|
+
node ${QUALIA_BIN}/agent-status.js barrier --tasks {comma-separated task ids in this batch} --timeout 900
|
|
198
202
|
```
|
|
199
203
|
|
|
200
|
-
Exit
|
|
204
|
+
Exit codes (always pass `--timeout` — default `900` (15 min); raise it for batches of genuinely large tasks):
|
|
205
|
+
- **0 — PASS.** Every task in the batch wrote `DONE`. Move to the next batch.
|
|
206
|
+
- **1 — HOLD.** A task is still `RUNNING`/`MISSING` *within* the deadline. Transient — wait and re-poll; do NOT spawn the next batch.
|
|
207
|
+
- **3 — FAIL.** A task is `BLOCKED`/`PARTIAL`, or (past `--timeout`) a `RUNNING` heartbeat went stale or a builder returned without writing any status (`STALE`). This is terminal — a crashed/stalled builder is a wave failure. Do NOT re-poll and do NOT spawn the next batch: route straight to §4 (Handle Failures). The barrier names the offending task(s) and their staleness age.
|
|
208
|
+
|
|
209
|
+
The `--timeout` is what stops a crashed builder from stalling an unattended `--auto` wave forever: a builder that dies without writing `DONE`/`BLOCKED` would otherwise hold the barrier indefinitely; past the deadline it becomes a clean FAIL the wave can recover from. The barrier lists which tasks are RUNNING/BLOCKED/PARTIAL/MISSING/STALE; `agent-status.js list` shows the live view. (Gating per batch — not per contract wave — keeps the barrier aligned with the `wave-plan.js` schedule, whose derived waves needn't match the contract's declared wave numbers.)
|
|
201
210
|
|
|
202
211
|
**After each batch:** move to the next batch in the schedule, show summary.
|
|
203
212
|
|
|
@@ -219,12 +228,14 @@ Builder returns deviation/blocker:
|
|
|
219
228
|
- **Minor:** Log, continue
|
|
220
229
|
- **Major:** Show to employee, ask how to proceed
|
|
221
230
|
- **Blocker:** Show, suggest fix or escalation
|
|
231
|
+
- **Stalled (barrier exit 3 with a `STALE` task):** the builder crashed or returned without writing terminal status — no deviation to read. Show the stalled task id(s) and staleness age from the barrier output. On `--auto`, re-spawn that single task once (fresh builder); if it stalls again, escalate as a Blocker. Interactively, surface it and ask how to proceed.
|
|
222
232
|
|
|
223
233
|
### 5. Update State
|
|
224
234
|
|
|
225
235
|
```bash
|
|
226
236
|
node ${QUALIA_BIN}/state.js transition --to built --phase {N} --tasks-done {done} --tasks-total {total} --wave {wave}
|
|
227
237
|
```
|
|
238
|
+
This is also where the **scope-drift gate** (§1a) fires: in a strict-profile project a HIGH analyze finding returns `error: "SCOPE_DRIFT"` and the status does NOT advance. Surface the named dropped criterion and route to `/qualia-plan {N} --gaps` or `/qualia-scope {N}`; re-run with `--force` only for an authorized senior waiver.
|
|
228
239
|
Error → show, stop.
|
|
229
240
|
Do NOT edit STATE.md or tracking.json manually; state.js handles both.
|
|
230
241
|
|
|
@@ -109,7 +109,15 @@ Wait for both verifier + QA before step 3. Playwright MCP unavailable → QA ret
|
|
|
109
109
|
|
|
110
110
|
The panel FINDS; skeptics decide what's REAL; `verify-panel.js` decides the verdict — math, not a vibe.
|
|
111
111
|
|
|
112
|
-
**
|
|
112
|
+
**0. Execution lens (run the app, don't just grep it).** A grep proves a symbol EXISTS; it does not prove the feature RUNS — and a builder can satisfy a grep pattern while the project fails to compile or its tests are red (the dominant reward-hacking failure mode). Run the project's OWN checks and emit them as a panel lens BEFORE assembling, so a red build folds into the verdict as a CRITICAL the panel cannot grep around:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
node ${QUALIA_BIN}/verify-panel.js execution {N} # → .planning/phase-{N}-panel-execution.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This runs `npx tsc --noEmit` (when `tsconfig.json` exists), the `test` script (when `package.json` defines one), and `build` (when defined). **Fail-soft:** an absent tool is SKIPPED, not a failure — its absence is not evidence of breakage. **Fail-closed:** a present check that exits non-zero becomes a CRITICAL finding. Output is the same `[{file,line,severity,title}]` array shape `assemble` consumes, so the next step folds it in with no special-casing.
|
|
119
|
+
|
|
120
|
+
**1. Assemble** the per-lens finding files (including `panel-execution.json` from step 0) into one panel skeleton (votes zeroed):
|
|
113
121
|
|
|
114
122
|
```bash
|
|
115
123
|
node ${QUALIA_BIN}/verify-panel.js assemble {N} # → .planning/phase-{N}-panel.json
|
|
@@ -133,7 +141,16 @@ Return exactly one line: REAL — {file:line reason} OR NOT_REAL — {file:l
|
|
|
133
141
|
|
|
134
142
|
Skeptics deliberately **omit `model=`** so they inherit the session's frontier model: their REAL/NOT_REAL judgment is what flips a CRITICAL/HIGH verdict, and that is the one step in the pipeline where model strength most changes the outcome. Route cheap on the finding pass, never on the adjudication.
|
|
135
143
|
|
|
136
|
-
Tally each finding
|
|
144
|
+
**Tally deterministically — do NOT hand-edit the JSON.** For each finding, collect its skeptics' one-line replies (the `REAL — …` / `NOT_REAL — …` lines, one per line) and pipe them to the tally subcommand. It counts the verdicts and writes `votes.real`/`votes.notReal` onto the matching finding itself — a miscount or dropped reply can no longer silently flip a CRITICAL's survival:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# finding-key is "<file>:<line>:<slugged-title>" — the key verify-panel.js uses
|
|
148
|
+
# internally (slug = lowercased title, non-alphanumerics → spaces, ≤48 chars).
|
|
149
|
+
printf '%s\n' "$SKEPTIC_REPLY_1" "$SKEPTIC_REPLY_2" "$SKEPTIC_REPLY_3" \
|
|
150
|
+
| node ${QUALIA_BIN}/verify-panel.js skeptic {N} "{file}:{line}:{slugged-title}"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Same skeptic replies in → identical counts out; the majority-survives decision stays in the aggregator (step 3).
|
|
137
154
|
|
|
138
155
|
**3. Aggregate** deterministically:
|
|
139
156
|
|
|
@@ -194,22 +211,43 @@ node ${QUALIA_BIN}/qualia-ui.js end "PHASE {N} GAPS FOUND" "/qualia-plan {N} --g
|
|
|
194
211
|
|
|
195
212
|
### 4. Update State
|
|
196
213
|
|
|
197
|
-
Write the deterministic eval artifact before changing state:
|
|
214
|
+
Write the deterministic eval artifact before changing state, and capture its exit into a gate artifact in the same step:
|
|
198
215
|
|
|
199
216
|
```bash
|
|
200
217
|
node ${QUALIA_BIN}/harness-eval.js --phase {N} --run --write
|
|
218
|
+
node ${QUALIA_BIN}/verify-panel.js gate {N} harness-eval --exit $? # hard FAIL → blocking CRITICAL
|
|
201
219
|
```
|
|
202
220
|
|
|
203
|
-
Run the zero-token deterministic gates (same role as `migration-guard`/`branch-guard` — each exits non-zero on a hard fault).
|
|
221
|
+
Run the zero-token deterministic gates (same role as `migration-guard`/`branch-guard` — each exits non-zero on a hard fault), and **record each gate's REAL exit code mechanically** into a normalized gate artifact. The recorder writes the JSON from the gate's exit code — never hand-write it — so a dropped exit code can no longer silently flip a CRITICAL:
|
|
204
222
|
|
|
205
223
|
```bash
|
|
224
|
+
# slop-detect: run at --severity=critical, so exit 1 = CRITICAL slop only.
|
|
206
225
|
node ${QUALIA_BIN}/slop-detect.mjs --severity=critical # CRITICAL design tells (the slop half)
|
|
226
|
+
node ${QUALIA_BIN}/verify-panel.js gate {N} slop-detect --exit $?
|
|
227
|
+
|
|
228
|
+
# dep-verify: a hallucinated/slopsquatted import → blocking CRITICAL (auto-survives, no skeptic).
|
|
207
229
|
node ${QUALIA_BIN}/dep-verify.mjs --severity=critical # hallucinated/slopsquatted imports (the correctness half)
|
|
230
|
+
node ${QUALIA_BIN}/verify-panel.js gate {N} dep-verify --exit $?
|
|
208
231
|
```
|
|
209
232
|
|
|
210
233
|
`dep-verify` flags any import whose package is BOTH undeclared in `package.json` AND absent from `node_modules` — the exact signature of an AI-invented or typosquatted dependency (the #1 named AI-generated-code security failure mode). It is the correctness/security companion to the design-focused `slop-detect`.
|
|
211
234
|
|
|
212
|
-
|
|
235
|
+
If you ran `/qualia-eval` for this phase, record **one gate per failing SUITE** (per-suite, not per-case) so each red suite is its own blocking CRITICAL:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
# for each failing eval suite S (eval-runner.js exit != 0 for that suite):
|
|
239
|
+
node ${QUALIA_BIN}/verify-panel.js gate {N} eval-{S} --exit 1 --title "qualia-eval suite {S} FAILED"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**The phase verdict is one deterministic call** — it replaces the prose "ALL of these agree" AND. `verdict {N}` globs the per-lens panel files (with skeptic survival already applied) AND the recorded `gate-{name}.json` artifacts, folds them with the SAME severity weighting `aggregate()` uses, and exits **0 = PASS / 1 = FAIL**. That exit code IS the phase verdict:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
node ${QUALIA_BIN}/verify-panel.js verdict {N} --write # → .planning/phase-{N}-verdict.{json,md}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
A surviving blocking CRITICAL/HIGH from ANY input (a panel lens, the execution lens, or a recorded gate) → FAIL; non-blocking gate findings (e.g. a soft harness-eval sub-check recorded `--severity MEDIUM`) are recorded and visible but never flip the verdict — the aggregator only makes the existing AND deterministic, it does not start blocking anything that passes today. The state machine still independently refuses PASS when a contract exists but `.planning/evidence/phase-{N}-contract-run.json` is missing/failing, or when the verification report contains `INSUFFICIENT EVIDENCE`.
|
|
249
|
+
|
|
250
|
+
**Browser QA is NOT folded into the verdict (deferred).** The §3b browser-QA result stays a prose `## Browser QA` note in the verification file; `verdict {N}` does not ingest it yet (it has no normalized gate artifact). A BLOCKED browser QA remains a note, not a phase failure, exactly as today. Ingesting browser-QA as a gate is a future increment (see ADR-0002).
|
|
213
251
|
|
|
214
252
|
```bash
|
|
215
253
|
node ${QUALIA_BIN}/state.js transition --to verified --phase {N} --verification {pass|fail} --evidence .planning/evals/harness-eval-*.json
|
|
@@ -25,8 +25,8 @@ Stack: Next.js 16+, React 19, TypeScript, Supabase, Vercel. Voice: Retell + Elev
|
|
|
25
25
|
`/qualia` — state router tells you the next command.
|
|
26
26
|
|
|
27
27
|
<!--QUALIA-HOST claude-->
|
|
28
|
-
<!-- Instruction-budget discipline (per Matt Pocock): this file stays
|
|
28
|
+
<!-- Instruction-budget discipline (per Matt Pocock): this file stays lean — instruction content kept minimal. Steering rules go into discoverable skills, not into the global system prompt. CLI preferences go into hooks. Stack/architecture details are trivially discoverable in package.json/config. -->
|
|
29
29
|
<!--/QUALIA-HOST-->
|
|
30
30
|
<!--QUALIA-HOST codex-->
|
|
31
|
-
<!-- AGENTS.md mirrors CLAUDE.md for cross-vendor compatibility (Codex, Cursor, Continue, Aider, Devin). Both files stay
|
|
31
|
+
<!-- AGENTS.md mirrors CLAUDE.md for cross-vendor compatibility (Codex, Cursor, Continue, Aider, Devin). Both files stay lean per Matt Pocock's instruction-budget discipline (LLMs realistically hold 300–500 instructions; bloating this file hamstrings every spawn). -->
|
|
32
32
|
<!--/QUALIA-HOST-->
|