qualia-framework 7.0.1 → 7.1.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 CHANGED
@@ -8,6 +8,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  > Note: git tags for historical versions were not retained; commit references are approximate
9
9
  > and dates reflect commit history rather than npm publish timestamps.
10
10
 
11
+ ## [7.1.0] - 2026-06-25 (harness gates — hallucinated deps, secrets, model routing)
12
+
13
+ Hardening pass: reviewed Google's "The New SDLC With Vibe Coding" whitepaper
14
+ against the framework, then ran a deep multi-agent audit of the result and fixed
15
+ what it surfaced. Theme: move enforcement out of model-read prose and into
16
+ deterministic gates.
17
+
18
+ ### Added
19
+ - **`bin/dep-verify.mjs` — hallucinated/slopsquatted dependency gate.** Flags an
20
+ import only when its package is BOTH undeclared in `package.json` AND absent
21
+ from `node_modules` — the paper's #1 named AI-generated-code security failure
22
+ mode. A string/regex/comment-aware parser with statement-anchored matching
23
+ keeps false positives near zero. Wired into `/qualia-verify`, `/qualia-ship`,
24
+ and the `pre-deploy-gate` hook (OWNER-only `QUALIA_SKIP_DEPCHECK` escape).
25
+ - **`hooks/secret-guard.js` — commit-time secret gate.** Fail-closed PreToolUse
26
+ hook on `git commit*` that scans staged content for service_role keys, private
27
+ keys, AWS/OpenAI/GitHub tokens, and staged `.env` files; blocks (exit 2),
28
+ never prints the matched value, OWNER-only `QUALIA_SECRET_SKIP` escape.
29
+ Enforces two constitution non-negotiables that were prose-only. Registered on
30
+ both runtimes; added to `trust-score` REQUIRED_HOOKS.
31
+ - **Intelligent model routing.** `/qualia-build` builders default to `sonnet`
32
+ (escalate to frontier for architect / high-complexity / auth-payment-migration;
33
+ `haiku` for mechanical sweeps); `/qualia-verify` panel verifiers run on
34
+ `sonnet`, skeptic adjudication on frontier. The token-economy OpEx lever.
35
+ - **`tests/runtime-parity.test.sh`.** Asserts the Codex blocking-gate set is a
36
+ superset of the critical gates so Claude/Codex hook drift can't recur.
37
+
38
+ ### Fixed
39
+ - **Deploy gates now fail CLOSED.** `pre-deploy-gate` blocked only on exit 1, so a
40
+ scanner crash or timeout printed "✓" and deployed anyway; both the anti-slop and
41
+ dep-verify blocks now block on any non-clean outcome.
42
+ - **Codex gate parity.** `task-write-guard.js` (the off-contract-write gate) was
43
+ registered on Claude but omitted from the Codex hooks — so on Codex a builder
44
+ could write fabricated/out-of-scope files unblocked. Now registered on both.
45
+ - **A5 failed-status gap closure (`state.js`).** The A5 increment path sets status
46
+ `"failed"` on a failed verify (legacy keeps `"verified"`+fail); the gap-cycle
47
+ circuit breaker, the re-plan transition (`VALID_FROM`), the gap-cycle counter,
48
+ and `next_command` all only recognized `"verified"` — so the breaker never fired
49
+ (unbounded gap cycles), re-plan was rejected, and routing dead-ended at
50
+ `/qualia`. All four spots now recognize `"failed"`.
51
+
52
+ ### Tests
53
+ - New suites: dep-verify, secret-guard, runtime-parity; A5 regression cases in
54
+ `state.test.sh`; pre-deploy-gate fail-closed + dep cases in `hooks.test.sh`.
55
+ - Full suite green: **27 shell suites + 179 node tests**.
56
+
11
57
  ## [7.0.1] - 2026-06-22 (/qualia-recall is OWNER-only)
12
58
 
13
59
  ### Changed — `/qualia-recall` restricted to OWNER
package/bin/cli.js CHANGED
@@ -214,6 +214,7 @@ const QUALIA_HOOK_FILES = [
214
214
  "env-empty-guard.js",
215
215
  "supabase-destructive-guard.js",
216
216
  "vercel-account-guard.js",
217
+ "secret-guard.js",
217
218
  ];
218
219
  const QUALIA_LEGACY_HOOK_FILES = [
219
220
  "block-env-edit.js", // removed in v3.2.0
@@ -712,6 +713,7 @@ function cmdMigrate() {
712
713
  "vercel-account-guard.js",
713
714
  "env-empty-guard.js",
714
715
  "supabase-destructive-guard.js",
716
+ "secret-guard.js",
715
717
  ];
716
718
  const requiredEditHooks = ["migration-guard.js"];
717
719
 
@@ -747,6 +749,7 @@ function cmdMigrate() {
747
749
  if (hookFile === "vercel-account-guard.js") { hookDef.if = "Bash(vercel --prod*)|Bash(vercel deploy*)"; hookDef.timeout = 8; hookDef.statusMessage = "⬢ Verifying Vercel account..."; }
748
750
  if (hookFile === "env-empty-guard.js") { hookDef.if = "Bash(vercel env*)"; hookDef.statusMessage = "⬢ Checking env value..."; }
749
751
  if (hookFile === "supabase-destructive-guard.js") { hookDef.if = "Bash(supabase*)|Bash(npx supabase*)"; hookDef.statusMessage = "⬢ Checking Supabase safety..."; }
752
+ if (hookFile === "secret-guard.js") { hookDef.if = "Bash(git commit*)"; hookDef.timeout = 10; hookDef.statusMessage = "⬢ Scanning staged content for secrets..."; }
750
753
  bashEntry.hooks.push(hookDef);
751
754
  changes++;
752
755
  console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Bash`);
@@ -1011,7 +1014,9 @@ async function cmdErpPing() {
1011
1014
  project: "qualia-framework-erp-ping",
1012
1015
  project_id: "ping",
1013
1016
  team_id: "qualia-solutions",
1014
- client_report_id: "QS-PING-00",
1017
+ // client_report_id intentionally omitted: ERP only accepts a QS-REPORT-NN
1018
+ // form or an absent field. A synthetic "QS-PING-NN" is rejected (422). With
1019
+ // it absent the server assigns a throwaway UUID — exactly what a dry-run ping wants.
1015
1020
  phase: 0,
1016
1021
  phase_name: "ping",
1017
1022
  status: "setup",
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dep-verify — Hallucinated / slopsquatted dependency scanner for Qualia projects.
4
+ *
5
+ * The harness gate that AI-generated code most often trips: importing a package
6
+ * that does not exist. The model invents a plausible name (`react-use-toast`),
7
+ * or typosquats a real one (`lodahs`), and the import only fails at install or
8
+ * runtime — long after the agent reported success. This scanner catches it at
9
+ * the verify/ship gate, deterministically and with zero network calls.
10
+ *
11
+ * The signal is precise: a bare import is flagged ONLY when its package is
12
+ * BOTH undeclared in package.json AND absent from node_modules. That is the
13
+ * exact signature of a hallucinated or slopsquatted dependency — and it keeps
14
+ * false positives near zero (phantom/hoisted deps that are actually installed
15
+ * are left alone).
16
+ *
17
+ * Usage:
18
+ * node bin/dep-verify.mjs # scan repo from cwd
19
+ * node bin/dep-verify.mjs src/app/page.tsx # scan one file
20
+ * node bin/dep-verify.mjs src/ # scan a directory
21
+ * node bin/dep-verify.mjs --json # machine-readable output
22
+ * node bin/dep-verify.mjs --severity=critical # parity flag (all findings are critical)
23
+ *
24
+ * Exit codes:
25
+ * 0 no undeclared/uninstalled imports
26
+ * 1 one or more hallucinated/slopsquatted imports found
27
+ * 2 invocation error
28
+ *
29
+ * Builder agents and the /qualia-verify + /qualia-ship gates call this BEFORE
30
+ * commit/deploy. A non-zero exit blocks. Pairs with slop-detect.mjs (design
31
+ * tells) — this is the correctness/security half of the same gate.
32
+ */
33
+
34
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
35
+ import { join, extname, relative, resolve, dirname } from "node:path";
36
+ import { argv, exit, cwd } from "node:process";
37
+ import { builtinModules } from "node:module";
38
+
39
+ // ── Config ────────────────────────────────────────────────────────────
40
+ const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
41
+ const SKIP_DIRS = new Set([
42
+ "node_modules", ".git", "dist", "build", ".next", "out",
43
+ "coverage", ".turbo", ".vercel", "vendor", ".cache",
44
+ ]);
45
+ // Node builtins, with and without the `node:` prefix.
46
+ const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);
47
+
48
+ // ── Import extraction ─────────────────────────────────────────────────
49
+ // A context-free comment regex is unsafe here: a `//` inside a string or
50
+ // regex literal would erase the rest of the line (dropping a real import → a
51
+ // silent miss), and import-looking text *inside* a string would be matched as
52
+ // a live import (a false block). So we strip comments with a small scanner
53
+ // that tracks string, template, and regex-literal state, then match imports
54
+ // only at statement boundaries so syntax embedded in a string never counts.
55
+
56
+ // Heuristic for whether a `/` begins a regex literal: it does when the last
57
+ // significant code character is an operator/opener (not a value/identifier).
58
+ function isRegexStart(prev) {
59
+ return prev === "" || "(,=:[!&|?{};+-*%^~<>".includes(prev);
60
+ }
61
+
62
+ // Remove // and /* */ comments while preserving strings, template literals,
63
+ // and regex literals (and preserving newlines so line numbers are stable).
64
+ function stripComments(src) {
65
+ let out = "";
66
+ let i = 0;
67
+ const n = src.length;
68
+ let state = "code"; // code | line | block | regex | sq | dq | tpl
69
+ let prevSig = ""; // last significant (non-whitespace) code char emitted
70
+ while (i < n) {
71
+ const c = src[i];
72
+ const c2 = src[i + 1];
73
+ if (state === "code") {
74
+ if (c === "/" && c2 === "/") { state = "line"; i += 2; continue; }
75
+ if (c === "/" && c2 === "*") { state = "block"; i += 2; continue; }
76
+ if (c === "/" && isRegexStart(prevSig)) { state = "regex"; out += c; i++; continue; }
77
+ if (c === "'") { state = "sq"; out += c; prevSig = c; i++; continue; }
78
+ if (c === '"') { state = "dq"; out += c; prevSig = c; i++; continue; }
79
+ if (c === "`") { state = "tpl"; out += c; prevSig = c; i++; continue; }
80
+ out += c;
81
+ if (!/\s/.test(c)) prevSig = c;
82
+ i++;
83
+ continue;
84
+ }
85
+ if (state === "line") {
86
+ if (c === "\n") { state = "code"; out += c; }
87
+ i++;
88
+ continue;
89
+ }
90
+ if (state === "block") {
91
+ if (c === "*" && c2 === "/") { state = "code"; i += 2; continue; }
92
+ if (c === "\n") out += c;
93
+ i++;
94
+ continue;
95
+ }
96
+ // regex literal or string: copy verbatim, honoring escapes.
97
+ out += c;
98
+ if (c === "\\") {
99
+ if (i + 1 < n) { out += src[i + 1]; i += 2; continue; }
100
+ i++;
101
+ continue;
102
+ }
103
+ if (state === "regex" && c === "/") { state = "code"; prevSig = "/"; }
104
+ else if (state === "sq" && c === "'") { state = "code"; prevSig = c; }
105
+ else if (state === "dq" && c === '"') { state = "code"; prevSig = c; }
106
+ else if (state === "tpl" && c === "`") { state = "code"; prevSig = c; }
107
+ i++;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Statement-anchored so import/export keywords inside a string literal don't
113
+ // match (they are preceded by a quote, not a statement boundary). `m` flag so
114
+ // `^` anchors each line; the negated class spans multi-line import bodies.
115
+ const ES_FROM_RE = /(?:^|[;{}])\s*(?:import|export)\b[^'";]*?from\s*['"]([^'"]+)['"]/gm;
116
+ const BARE_IMPORT_RE = /(?:^|[;{}])\s*import\s*['"]([^'"]+)['"]/gm;
117
+ // require()/import() can be inline; exclude member/identifier prefixes
118
+ // (myRequire(, foo.import() ) via lookbehind.
119
+ const CALL_RE = /(?<![\w$.])(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
120
+
121
+ function extractSpecifiers(source) {
122
+ const cleaned = stripComments(source);
123
+ const out = new Set();
124
+ for (const re of [ES_FROM_RE, BARE_IMPORT_RE, CALL_RE]) {
125
+ re.lastIndex = 0;
126
+ let m;
127
+ while ((m = re.exec(cleaned)) !== null) {
128
+ if (m[1]) out.add(m[1]);
129
+ }
130
+ }
131
+ return [...out];
132
+ }
133
+
134
+ // Resolve a raw specifier to its installable package name, or null if it is
135
+ // not an external package (relative, absolute, alias, or subpath import).
136
+ function packageName(spec) {
137
+ if (!spec) return null;
138
+ if (spec.startsWith(".") || spec.startsWith("/")) return null; // relative / absolute
139
+ if (spec.includes(":")) return null; // protocol specifiers: node:, virtual:, https:, data:, blob:
140
+ if (spec.startsWith("@/") || spec.startsWith("~/") || spec.startsWith("#")) return null; // common aliases / subpath imports
141
+ if (spec.startsWith("@")) {
142
+ const parts = spec.split("/");
143
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null; // @scope/name
144
+ }
145
+ return spec.split("/")[0]; // name (drop subpath)
146
+ }
147
+
148
+ // ── package.json + tsconfig discovery (walk up, cached) ───────────────
149
+ const declaredCache = new Map();
150
+ const aliasCache = new Map();
151
+
152
+ function readJSONSafe(p) {
153
+ try {
154
+ return JSON.parse(readFileSync(p, "utf8"));
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // Union of declared deps from the nearest package.json walking up, plus the
161
+ // top-most (repo-root) package.json to tolerate monorepo hoisting.
162
+ function declaredDepsFor(fileDir) {
163
+ if (declaredCache.has(fileDir)) return declaredCache.get(fileDir);
164
+ const names = new Set();
165
+ let dir = fileDir;
166
+ let last = null;
167
+ while (true) {
168
+ const pj = join(dir, "package.json");
169
+ if (existsSync(pj)) {
170
+ const json = readJSONSafe(pj);
171
+ if (json) {
172
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
173
+ for (const k of Object.keys(json[field] || {})) names.add(k);
174
+ }
175
+ }
176
+ last = dir;
177
+ }
178
+ const parent = dirname(dir);
179
+ if (parent === dir) break;
180
+ dir = parent;
181
+ }
182
+ declaredCache.set(fileDir, names);
183
+ return names;
184
+ }
185
+
186
+ // tsconfig/jsconfig path-alias prefixes (e.g. "@app/*" → "@app/").
187
+ function aliasPrefixesFor(fileDir) {
188
+ if (aliasCache.has(fileDir)) return aliasCache.get(fileDir);
189
+ const prefixes = [];
190
+ let dir = fileDir;
191
+ while (true) {
192
+ for (const name of ["tsconfig.json", "jsconfig.json"]) {
193
+ const cfg = readJSONSafe(join(dir, name));
194
+ const paths = cfg?.compilerOptions?.paths;
195
+ if (paths) {
196
+ for (const key of Object.keys(paths)) {
197
+ // A wildcard alias ("@app/*") suppresses its subtree; a non-wildcard
198
+ // alias ("@env") suppresses only the exact specifier. Without this
199
+ // distinction a bare "@utils" would wrongly swallow "@utils-extended/server".
200
+ if (key.endsWith("*")) {
201
+ const p = key.replace(/\*$/, "");
202
+ prefixes.push({ wild: true, value: p.endsWith("/") ? p : `${p}/` });
203
+ } else {
204
+ prefixes.push({ wild: false, value: key });
205
+ }
206
+ }
207
+ }
208
+ }
209
+ const parent = dirname(dir);
210
+ if (parent === dir) break;
211
+ dir = parent;
212
+ }
213
+ aliasCache.set(fileDir, prefixes);
214
+ return prefixes;
215
+ }
216
+
217
+ // Is the package physically installed in any node_modules up the tree?
218
+ function isInstalled(fileDir, pkg) {
219
+ let dir = fileDir;
220
+ while (true) {
221
+ if (existsSync(join(dir, "node_modules", pkg))) return true;
222
+ const parent = dirname(dir);
223
+ if (parent === dir) break;
224
+ dir = parent;
225
+ }
226
+ return false;
227
+ }
228
+
229
+ // ── File walking ──────────────────────────────────────────────────────
230
+ function collectFiles(target) {
231
+ let st;
232
+ try {
233
+ st = statSync(target);
234
+ } catch {
235
+ return null; // signal: path does not exist
236
+ }
237
+ if (st.isFile()) return SOURCE_EXT.has(extname(target)) ? [target] : [];
238
+ const files = [];
239
+ const walk = (d) => {
240
+ let entries;
241
+ try {
242
+ entries = readdirSync(d, { withFileTypes: true });
243
+ } catch {
244
+ return;
245
+ }
246
+ for (const e of entries) {
247
+ if (e.isDirectory()) {
248
+ if (!SKIP_DIRS.has(e.name)) walk(join(d, e.name));
249
+ } else if (e.isFile() && SOURCE_EXT.has(extname(e.name))) {
250
+ files.push(join(d, e.name));
251
+ }
252
+ }
253
+ };
254
+ walk(target);
255
+ return files;
256
+ }
257
+
258
+ // ── Scan ──────────────────────────────────────────────────────────────
259
+ function scanFile(file) {
260
+ const findings = [];
261
+ let source;
262
+ try {
263
+ source = readFileSync(file, "utf8");
264
+ } catch {
265
+ return findings;
266
+ }
267
+ const fileDir = dirname(resolve(file));
268
+ const declared = declaredDepsFor(fileDir);
269
+ const aliases = aliasPrefixesFor(fileDir);
270
+ const seen = new Set();
271
+ for (const spec of extractSpecifiers(source)) {
272
+ if (aliases.some((a) => (a.wild ? spec.startsWith(a.value) : spec === a.value))) continue;
273
+ const pkg = packageName(spec);
274
+ if (!pkg) continue;
275
+ if (BUILTINS.has(pkg) || BUILTINS.has(spec)) continue;
276
+ if (declared.has(pkg)) continue;
277
+ if (isInstalled(fileDir, pkg)) continue;
278
+ if (seen.has(pkg)) continue; // one finding per package per file
279
+ seen.add(pkg);
280
+ findings.push({ file: relative(cwd(), file), package: pkg, specifier: spec });
281
+ }
282
+ return findings;
283
+ }
284
+
285
+ // ── CLI ───────────────────────────────────────────────────────────────
286
+ function main() {
287
+ const args = argv.slice(2);
288
+ if (args.includes("--help") || args.includes("-h")) {
289
+ console.log("dep-verify — flags imports that are neither declared in package.json nor installed.");
290
+ console.log("Usage: node bin/dep-verify.mjs [paths...] [--json] [--severity=critical]");
291
+ exit(0);
292
+ }
293
+ const json = args.includes("--json");
294
+ const paths = args.filter((a) => !a.startsWith("--") && !a.startsWith("-"));
295
+ const targets = paths.length ? paths : [cwd()];
296
+
297
+ const allFiles = [];
298
+ for (const t of targets) {
299
+ const files = collectFiles(t);
300
+ if (files === null) {
301
+ if (json) console.log(JSON.stringify({ ok: false, error: "ENOENT", path: t }, null, 2));
302
+ else console.error(`dep-verify: path not found: ${t}`);
303
+ exit(2);
304
+ }
305
+ allFiles.push(...files);
306
+ }
307
+
308
+ const findings = [];
309
+ for (const f of allFiles) findings.push(...scanFile(f));
310
+
311
+ if (json) {
312
+ console.log(JSON.stringify({ ok: findings.length === 0, scanned: allFiles.length, findings }, null, 2));
313
+ } else if (findings.length === 0) {
314
+ console.log(`dep-verify: ${allFiles.length} file(s) scanned — no hallucinated dependencies.`);
315
+ } else {
316
+ console.error(`dep-verify: ${findings.length} hallucinated/slopsquatted import(s) — undeclared AND not installed:\n`);
317
+ for (const f of findings) {
318
+ console.error(` ✗ ${f.file}`);
319
+ console.error(` imports "${f.specifier}" → package "${f.package}" is not in package.json and not in node_modules`);
320
+ }
321
+ console.error(`\n Fix: confirm the package name is real, then add it to package.json (npm/pnpm/yarn add),`);
322
+ console.error(` or correct the import. AI commonly invents or typosquats package names.`);
323
+ }
324
+
325
+ exit(findings.length === 0 ? 0 : 1);
326
+ }
327
+
328
+ main();
package/bin/install.js CHANGED
@@ -1283,6 +1283,8 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1283
1283
  "fawzi-approval-guard.js", "task-write-guard.js",
1284
1284
  // v5.0 — insights-driven destructive-op + wrong-account guards
1285
1285
  "vercel-account-guard.js", "env-empty-guard.js", "supabase-destructive-guard.js",
1286
+ // commit-time secret gate (constitution: never commit service_role / .env)
1287
+ "secret-guard.js",
1286
1288
  // performance-audit telemetry capture (UserPromptSubmit)
1287
1289
  "usage-capture.js",
1288
1290
  ]);
@@ -1316,6 +1318,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1316
1318
  { type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "⬢ Verifying Vercel account..." },
1317
1319
  { type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "⬢ Checking env value..." },
1318
1320
  { type: "command", if: "Bash(supabase*)|Bash(npx supabase*)", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5, statusMessage: "⬢ Checking Supabase safety..." },
1321
+ { type: "command", if: "Bash(git commit*)", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
1319
1322
  ],
1320
1323
  },
1321
1324
  {
@@ -1861,6 +1864,7 @@ async function installCodex(member, target, employeeMode = false) {
1861
1864
  { type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
1862
1865
  { type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
1863
1866
  { type: "command", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5 },
1867
+ { type: "command", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
1864
1868
  ],
1865
1869
  },
1866
1870
  {
@@ -1868,6 +1872,11 @@ async function installCodex(member, target, employeeMode = false) {
1868
1872
  hooks: [
1869
1873
  { type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
1870
1874
  { type: "command", command: nodeCmd("migration-guard.js"), timeout: 10 },
1875
+ // Runtime parity with Claude (install.js Edit|Write group): the
1876
+ // off-contract-write gate must run on Codex too, else a Codex
1877
+ // builder can write fabricated/out-of-scope files unblocked.
1878
+ // Enforced by tests/runtime-parity.test.sh.
1879
+ { type: "command", command: nodeCmd("task-write-guard.js"), timeout: 5, statusMessage: "⬢ Checking plan-contract file scope..." },
1871
1880
  ],
1872
1881
  },
1873
1882
  ],
@@ -113,7 +113,10 @@ function buildPayload(options = {}) {
113
113
  ? { assignment_deadline: workPacket.deadline_date }
114
114
  : {}),
115
115
  client: tracking.client || "",
116
- client_report_id: env.CLIENT_REPORT_ID || "",
116
+ // ERP validates client_report_id as either a QS-REPORT-NN form or ABSENT.
117
+ // An empty string is rejected (422). Omit when unset so the server assigns
118
+ // a UUID, per docs/erp-contract.md (optional field).
119
+ ...(env.CLIENT_REPORT_ID ? { client_report_id: env.CLIENT_REPORT_ID } : {}),
117
120
  framework_version: config.version || "",
118
121
  milestone: tracking.milestone || 1,
119
122
  milestone_name: tracking.milestone_name || "",
@@ -27,6 +27,7 @@ const RUNTIME_BIN_SCRIPTS = [
27
27
  { file: "last-report.js", label: "last-report.js (router surfacing — newest session-report digest at session start)" },
28
28
  { file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
29
29
  { file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
30
+ { file: "dep-verify.mjs", label: "dep-verify.mjs (hallucinated/slopsquatted dependency scanner — verify + ship gate)" },
30
31
  { file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
31
32
  { file: "erp-event.js", label: "erp-event.js (signed lifecycle-event emitter → ERP /api/v1/events, R14 client)" },
32
33
  { file: "work-packet.js", label: "work-packet.js (ERP mission/work packet pull + local reader)" },
package/bin/state.js CHANGED
@@ -655,7 +655,9 @@ function cmdTransitionIncrement(opts, target) {
655
655
  const t = ensureLifetime(readTracking() || {});
656
656
  inc.status = target;
657
657
  if (target === "planned") {
658
- if (prevStatus === "verified") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
658
+ // A5 sets status "failed" on a failed verify (vs the legacy path which keeps
659
+ // "verified" + verification="fail"); count a gap cycle for either.
660
+ if (prevStatus === "verified" || prevStatus === "failed") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
659
661
  } else if (target === "built") {
660
662
  inc.tasks_done = parseInt(opts.tasks_done) || 0;
661
663
  inc.tasks_total = parseInt(opts.tasks_total) || 0;
@@ -956,7 +958,7 @@ Resume: ${s.resume || "—"}
956
958
 
957
959
  // ─── Precondition Checks ─────────────────────────────────
958
960
  const VALID_FROM = {
959
- planned: ["setup", "verified"], // verified(fail) → planned = gap closure
961
+ planned: ["setup", "verified", "failed"], // verified(fail) [legacy] or failed [A5] → planned = gap closure
960
962
  built: ["planned"],
961
963
  verified: ["built"],
962
964
  polished: ["verified"],
@@ -1073,8 +1075,9 @@ function checkPreconditions(current, target, opts) {
1073
1075
  }
1074
1076
  }
1075
1077
 
1076
- // Gap-closure circuit breaker (configurable limit)
1077
- if (target === "planned" && current.status === "verified") {
1078
+ // Gap-closure circuit breaker (configurable limit). Fires from either the
1079
+ // legacy "verified"(+fail) status or the A5 "failed" status.
1080
+ if (target === "planned" && (current.status === "verified" || current.status === "failed")) {
1078
1081
  const t = readTracking() || {};
1079
1082
  const cycles = (t.gap_cycles || {})[String(phase)] || 0;
1080
1083
  const limit = getGapCycleLimit();
@@ -1148,6 +1151,10 @@ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
1148
1151
  if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
1149
1152
  if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
1150
1153
  return operate ? "/qualia-update" : "/qualia-polish";
1154
+ // A5 increments set status "failed" on a failed verify (legacy keeps
1155
+ // "verified"+fail). Route both to gap closure instead of dead-ending at /qualia.
1156
+ case "failed":
1157
+ return `/qualia-plan ${phase} --gaps`;
1151
1158
  case "polished":
1152
1159
  return "/qualia-ship";
1153
1160
  case "shipped":
@@ -27,7 +27,7 @@ const REQUIRED_HOOKS = [
27
27
  "session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
28
28
  "pre-deploy-gate.js", "migration-guard.js", "git-guardrails.js",
29
29
  "stop-session-log.js", "fawzi-approval-guard.js", "vercel-account-guard.js", "env-empty-guard.js",
30
- "supabase-destructive-guard.js",
30
+ "supabase-destructive-guard.js", "secret-guard.js",
31
31
  ];
32
32
 
33
33
  const REQUIRED_DESIGN_FILES = [
@@ -346,20 +346,71 @@ if (fs.existsSync(slopScript)) {
346
346
  encoding: "utf8",
347
347
  timeout: 60000,
348
348
  });
349
- if (r.status === 1) {
350
- console.error("BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying.");
349
+ // Fail CLOSED (same contract as the dep-verify gate below): only a clean
350
+ // exit 0 passes; a CRITICAL finding (1) or a crash/timeout blocks.
351
+ if (r.status !== 0) {
352
+ console.error(
353
+ r.status === 1
354
+ ? "BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying."
355
+ : `BLOCKED: anti-slop scan did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
356
+ );
351
357
  const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
352
358
  if (output) {
353
359
  const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
354
360
  for (const line of lines) console.error(` ${line}`);
355
361
  }
356
- _trace("pre-deploy-gate", "block", { gate: "slop", status: r.status });
362
+ _trace("pre-deploy-gate", "block", { gate: "slop", status: r.status, error: r.error ? r.error.message : undefined });
357
363
  process.exit(2);
358
364
  }
359
365
  console.log(" ✓ Anti-slop");
360
366
  }
361
367
  }
362
368
 
369
+ // Dependency verification: zero-token, zero-network scan for imports of
370
+ // packages that are BOTH undeclared in package.json AND absent from
371
+ // node_modules — the signature of an AI-hallucinated or slopsquatted
372
+ // dependency (the #1 named AI-generated-code security failure mode). The
373
+ // correctness/security companion to the design-focused anti-slop scan above.
374
+ // Skipped silently when the scanner isn't installed (brownfield / older
375
+ // installs). OWNER-only escape hatch mirrors QUALIA_SKIP_SLOP.
376
+ const depScript = path.join(QUALIA_HOME, "bin", "dep-verify.mjs");
377
+ if (fs.existsSync(depScript)) {
378
+ const skipDep = process.env.QUALIA_SKIP_DEPCHECK === "1";
379
+ if (skipDep) {
380
+ const depRole = String(readConfig().role || "").toUpperCase();
381
+ if (depRole !== "OWNER") {
382
+ const depState = readState();
383
+ blockDeploy("QUALIA_SKIP_DEPCHECK is OWNER-only.", (depState && depState.next_command) || "/qualia");
384
+ }
385
+ console.log(" ⚠ Dependency check skipped (QUALIA_SKIP_DEPCHECK=1)");
386
+ _trace("pre-deploy-gate", "skip-depcheck", { reason: "QUALIA_SKIP_DEPCHECK=1" });
387
+ } else {
388
+ const r = spawnSync(process.execPath, [depScript, "--severity=critical"], {
389
+ stdio: ["ignore", "pipe", "pipe"],
390
+ encoding: "utf8",
391
+ timeout: 60000,
392
+ });
393
+ // Fail CLOSED: only a clean exit 0 passes. Findings (1), invocation
394
+ // errors (2), or a crash/timeout (status null + r.error) all block — a
395
+ // security gate that silently passes when it cannot run is not a gate.
396
+ if (r.status !== 0) {
397
+ console.error(
398
+ r.status === 1
399
+ ? "BLOCKED: hallucinated/slopsquatted imports found. Fix before deploying."
400
+ : `BLOCKED: dep-verify did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
401
+ );
402
+ const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
403
+ if (output) {
404
+ const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
405
+ for (const line of lines) console.error(` ${line}`);
406
+ }
407
+ _trace("pre-deploy-gate", "block", { gate: "dep-verify", status: r.status, error: r.error ? r.error.message : undefined });
408
+ process.exit(2);
409
+ }
410
+ console.log(" ✓ Dependencies");
411
+ }
412
+ }
413
+
363
414
  console.log("⬢ All gates passed.");
364
415
 
365
416
  _trace("pre-deploy-gate", "allow");