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.
@@ -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 as JSON on stdin.
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
- const scan = stripSqlComments(content);
96
+ function splitStatements(src) {
97
+ return src.split(/;/g).map((s) => s.trim()).filter(Boolean);
98
+ }
95
99
 
96
- const errors = [];
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
- errors.push("DROP TABLE without IF EXISTS");
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
- errors.push("DROP DATABASE detected — refuse unless explicitly approved");
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
- errors.push("DROP SCHEMA detected — refuse unless explicitly approved");
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
- errors.push("ALTER TABLE ... DROP COLUMN is destructive");
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
- function splitStatements(src) {
122
- return src.split(/;/g).map((s) => s.trim()).filter(Boolean);
123
- }
124
- const statements = splitStatements(scan);
125
- for (const stmt of statements) {
126
- if (/^\s*DELETE\s+FROM\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
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
- for (const stmt of statements) {
132
- if (/^\s*UPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
133
- errors.push("UPDATE without WHERE clause — affects every row");
134
- break;
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
- errors.push("TRUNCATE detected — are you sure?");
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
- errors.push("GRANT ... TO PUBLIC detected — privilege leak");
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
- // Skip TEMP/TEMPORARY tables — they're session-scoped, no RLS needed.
154
- if (/CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY)\b/i.test(stmt)) return false;
155
- // Skip partition tables — RLS lives on the parent table.
156
- if (/\bPARTITION\s+OF\b/i.test(stmt)) return false;
157
- return true;
158
- });
159
- if (realCreateTables.length > 0 && !/ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(scan)) {
160
- errors.push("CREATE TABLE without ENABLE ROW LEVEL SECURITY");
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
- if (errors.length > 0) {
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);
@@ -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.2.2",
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/",
@@ -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 mapping in the table below still applies (it's computed into `next_command`); the increment fields just tell you *which* unit is yours. Legacy projects (no `layout` field) route exactly as before.
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
- Use the state.js JSON output plus gathered context:
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
- | Situation | Detection | Route |
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
- | `ready-to-plan` | status == "setup" | → `/qualia-plan {N}` |
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, before any build)
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; 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`.
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, 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`):
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 0 ⇔ every task in the batch wrote `DONE`. Non-zero → the barrier lists which tasks are RUNNING/BLOCKED/PARTIAL/MISSING. Do NOT spawn the next batch until the barrier passes; a BLOCKED/PARTIAL task is a wave failure (§4). `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.)
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
- **1. Assemble** the per-lens finding files into one panel skeleton (votes zeroed):
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's votes into `.planning/phase-{N}-panel.json` (`votes.real` / `votes.notReal`).
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). A non-zero exit is a verification FAIL, not a soft note:
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
- The phase is PASS only if ALL of these agree: the panel verdict (§3c `verify-panel.js` exit 0), the harness-eval status, the anti-slop scan, and the dependency scan. If any is FAIL/non-zero, mark the phase FAIL. The state machine also 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`.
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 under 25 lines. 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. -->
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 under 25 lines per Matt Pocock's instruction-budget discipline (LLMs realistically hold 300–500 instructions; bloating this file hamstrings every spawn). -->
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-->