qualia-framework 7.0.1 → 7.2.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,82 @@ 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.2.0] - 2026-06-25 (journey spine — lifecycle UX for employees)
12
+
13
+ A cosmetic/UX layer that gives an employee a continuous sense of place and
14
+ momentum across the whole lifecycle — install → start → work → finish — reusing
15
+ the existing teal palette and glyph set. No new commands to learn; the framework
16
+ just shows "where am I, what's my forward motion, what's the one next move" at
17
+ every touchpoint.
18
+
19
+ ### Added
20
+ - **`qualia-ui.js spine`** — compact horizontal milestone ladder
21
+ (`M1 ●──M2 ◆──M3 ○`) with a caret aligned exactly beneath the current
22
+ milestone. Rendered by the session-start banner so the journey map is the
23
+ first thing an employee sees. Self-skips on single-milestone projects.
24
+ - **`qualia-ui.js onboard <name>`** — first-run mental-model card (Plan → Build
25
+ → Verify → Ship + the one first move). Shown after install for non-OWNER
26
+ roles only.
27
+ - **`qualia-ui.js phase-complete <n> <name> [k=v…]`** — per-phase celebration
28
+ with a milestone progress bar and optional streak; wired into `/qualia-ship`.
29
+ - **`qualia-ui.js clockout <name> [k=v…]`** — end-of-day report card (shipped
30
+ counts, milestone %, streak); wired into `/qualia-report`. Every metric
31
+ self-omits when its data is unavailable — never fabricates.
32
+ - **`barTicks(done,total,width)`** — reusable `▰▱` micro-bar helper.
33
+ - **Always-visible progress in the statusline** — the phase pill now carries a
34
+ `▰▰▰▱▱` bar (`P3/5 ▰▰▰▱▱`) so forward motion is ambient while working.
35
+
36
+ ### Tests
37
+ - New suite `journey-spine.test.sh` (33 cases): barTicks scaling/clamping, all
38
+ four cards, spine caret-alignment (exact column match), self-skip paths, and
39
+ the statusline bar. Full suite green: **28 shell suites + 179 node tests**.
40
+
41
+ ## [7.1.0] - 2026-06-25 (harness gates — hallucinated deps, secrets, model routing)
42
+
43
+ Hardening pass: reviewed Google's "The New SDLC With Vibe Coding" whitepaper
44
+ against the framework, then ran a deep multi-agent audit of the result and fixed
45
+ what it surfaced. Theme: move enforcement out of model-read prose and into
46
+ deterministic gates.
47
+
48
+ ### Added
49
+ - **`bin/dep-verify.mjs` — hallucinated/slopsquatted dependency gate.** Flags an
50
+ import only when its package is BOTH undeclared in `package.json` AND absent
51
+ from `node_modules` — the paper's #1 named AI-generated-code security failure
52
+ mode. A string/regex/comment-aware parser with statement-anchored matching
53
+ keeps false positives near zero. Wired into `/qualia-verify`, `/qualia-ship`,
54
+ and the `pre-deploy-gate` hook (OWNER-only `QUALIA_SKIP_DEPCHECK` escape).
55
+ - **`hooks/secret-guard.js` — commit-time secret gate.** Fail-closed PreToolUse
56
+ hook on `git commit*` that scans staged content for service_role keys, private
57
+ keys, AWS/OpenAI/GitHub tokens, and staged `.env` files; blocks (exit 2),
58
+ never prints the matched value, OWNER-only `QUALIA_SECRET_SKIP` escape.
59
+ Enforces two constitution non-negotiables that were prose-only. Registered on
60
+ both runtimes; added to `trust-score` REQUIRED_HOOKS.
61
+ - **Intelligent model routing.** `/qualia-build` builders default to `sonnet`
62
+ (escalate to frontier for architect / high-complexity / auth-payment-migration;
63
+ `haiku` for mechanical sweeps); `/qualia-verify` panel verifiers run on
64
+ `sonnet`, skeptic adjudication on frontier. The token-economy OpEx lever.
65
+ - **`tests/runtime-parity.test.sh`.** Asserts the Codex blocking-gate set is a
66
+ superset of the critical gates so Claude/Codex hook drift can't recur.
67
+
68
+ ### Fixed
69
+ - **Deploy gates now fail CLOSED.** `pre-deploy-gate` blocked only on exit 1, so a
70
+ scanner crash or timeout printed "✓" and deployed anyway; both the anti-slop and
71
+ dep-verify blocks now block on any non-clean outcome.
72
+ - **Codex gate parity.** `task-write-guard.js` (the off-contract-write gate) was
73
+ registered on Claude but omitted from the Codex hooks — so on Codex a builder
74
+ could write fabricated/out-of-scope files unblocked. Now registered on both.
75
+ - **A5 failed-status gap closure (`state.js`).** The A5 increment path sets status
76
+ `"failed"` on a failed verify (legacy keeps `"verified"`+fail); the gap-cycle
77
+ circuit breaker, the re-plan transition (`VALID_FROM`), the gap-cycle counter,
78
+ and `next_command` all only recognized `"verified"` — so the breaker never fired
79
+ (unbounded gap cycles), re-plan was rejected, and routing dead-ended at
80
+ `/qualia`. All four spots now recognize `"failed"`.
81
+
82
+ ### Tests
83
+ - New suites: dep-verify, secret-guard, runtime-parity; A5 regression cases in
84
+ `state.test.sh`; pre-deploy-gate fail-closed + dep cases in `hooks.test.sh`.
85
+ - Full suite green: **27 shell suites + 179 node tests**.
86
+
11
87
  ## [7.0.1] - 2026-06-22 (/qualia-recall is OWNER-only)
12
88
 
13
89
  ### 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
  {
@@ -1569,6 +1572,12 @@ function printSummary({ member, target, claudeInstalled }) {
1569
1572
  console.log(
1570
1573
  ` ${DIM} performance audit. Opt out:${RESET} ${TEAL}erp.capturePrompts=false${RESET} ${DIM}in ~/.claude/.qualia-config.json${RESET}`
1571
1574
  );
1575
+ // New employees get the milestone mental model up front — OWNERs already
1576
+ // know how the flow works, so we skip the card for them to avoid noise.
1577
+ if (member.role !== "OWNER") {
1578
+ try { ui.onboard(member.name); } catch {}
1579
+ }
1580
+
1572
1581
  console.log("");
1573
1582
  console.log(` ${DIM2}${RULE}${RESET}`);
1574
1583
  console.log(` ${TEAL}${BOLD}Welcome to the future with Qualia.${RESET}`);
@@ -1861,6 +1870,7 @@ async function installCodex(member, target, employeeMode = false) {
1861
1870
  { type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
1862
1871
  { type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
1863
1872
  { type: "command", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5 },
1873
+ { type: "command", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
1864
1874
  ],
1865
1875
  },
1866
1876
  {
@@ -1868,6 +1878,11 @@ async function installCodex(member, target, employeeMode = false) {
1868
1878
  hooks: [
1869
1879
  { type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
1870
1880
  { type: "command", command: nodeCmd("migration-guard.js"), timeout: 10 },
1881
+ // Runtime parity with Claude (install.js Edit|Write group): the
1882
+ // off-contract-write gate must run on Codex too, else a Codex
1883
+ // builder can write fabricated/out-of-scope files unblocked.
1884
+ // Enforced by tests/runtime-parity.test.sh.
1885
+ { type: "command", command: nodeCmd("task-write-guard.js"), timeout: 5, statusMessage: "⬢ Checking plan-contract file scope..." },
1871
1886
  ],
1872
1887
  },
1873
1888
  ],