qualia-framework 6.8.0 → 6.9.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/install.js +212 -17
  3. package/bin/state.js +70 -5
  4. package/docs/EMPLOYEE-QUICKSTART.md +162 -0
  5. package/package.json +2 -1
  6. package/skills/qualia-doctor/SKILL.md +62 -0
  7. package/skills/qualia-new/REFERENCE.md +7 -0
  8. package/skills/qualia-new/SKILL.md +42 -0
  9. package/skills/qualia-report/SKILL.md +13 -0
  10. package/templates/stacks/README.md +110 -0
  11. package/templates/stacks/ai-agent/env.required.json +44 -0
  12. package/templates/stacks/ai-agent/phases.md +53 -0
  13. package/templates/stacks/ai-agent/scaffold/.env.example +12 -0
  14. package/templates/stacks/ai-agent/scaffold/README.md +31 -0
  15. package/templates/stacks/ai-agent/scaffold/app/api/chat/route.ts +33 -0
  16. package/templates/stacks/ai-agent/scaffold/app/globals.css +13 -0
  17. package/templates/stacks/ai-agent/scaffold/app/layout.tsx +19 -0
  18. package/templates/stacks/ai-agent/scaffold/app/page.tsx +12 -0
  19. package/templates/stacks/ai-agent/scaffold/evals/cases.json +23 -0
  20. package/templates/stacks/ai-agent/scaffold/lib/openrouter/client.ts +51 -0
  21. package/templates/stacks/ai-agent/scaffold/lib/prompts/system.ts +6 -0
  22. package/templates/stacks/ai-agent/scaffold/lib/supabase/client.ts +10 -0
  23. package/templates/stacks/ai-agent/scaffold/lib/supabase/server.ts +28 -0
  24. package/templates/stacks/ai-agent/scaffold/next.config.mjs +7 -0
  25. package/templates/stacks/ai-agent/scaffold/package.json +29 -0
  26. package/templates/stacks/ai-agent/scaffold/postcss.config.mjs +7 -0
  27. package/templates/stacks/ai-agent/scaffold/supabase/migrations/0001_init.sql +41 -0
  28. package/templates/stacks/ai-agent/scaffold/tsconfig.json +21 -0
  29. package/templates/stacks/ai-agent/stack.json +9 -0
  30. package/templates/stacks/ai-agent/verify-checklist.md +31 -0
  31. package/templates/stacks/full-app/env.required.json +20 -0
  32. package/templates/stacks/full-app/phases.md +45 -0
  33. package/templates/stacks/full-app/scaffold/.env.example +7 -0
  34. package/templates/stacks/full-app/scaffold/README.md +28 -0
  35. package/templates/stacks/full-app/scaffold/app/globals.css +14 -0
  36. package/templates/stacks/full-app/scaffold/app/layout.tsx +19 -0
  37. package/templates/stacks/full-app/scaffold/app/page.tsx +20 -0
  38. package/templates/stacks/full-app/scaffold/lib/supabase/client.ts +10 -0
  39. package/templates/stacks/full-app/scaffold/lib/supabase/server.ts +31 -0
  40. package/templates/stacks/full-app/scaffold/next.config.mjs +7 -0
  41. package/templates/stacks/full-app/scaffold/package.json +29 -0
  42. package/templates/stacks/full-app/scaffold/postcss.config.mjs +7 -0
  43. package/templates/stacks/full-app/scaffold/supabase/migrations/0001_init.sql +27 -0
  44. package/templates/stacks/full-app/scaffold/tsconfig.json +21 -0
  45. package/templates/stacks/full-app/stack.json +9 -0
  46. package/templates/stacks/full-app/verify-checklist.md +32 -0
  47. package/templates/stacks/internal-tool/env.required.json +20 -0
  48. package/templates/stacks/internal-tool/phases.md +45 -0
  49. package/templates/stacks/internal-tool/scaffold/.env.example +7 -0
  50. package/templates/stacks/internal-tool/scaffold/README.md +29 -0
  51. package/templates/stacks/internal-tool/scaffold/app/globals.css +13 -0
  52. package/templates/stacks/internal-tool/scaffold/app/layout.tsx +20 -0
  53. package/templates/stacks/internal-tool/scaffold/app/page.tsx +22 -0
  54. package/templates/stacks/internal-tool/scaffold/lib/supabase/client.ts +9 -0
  55. package/templates/stacks/internal-tool/scaffold/lib/supabase/server.ts +28 -0
  56. package/templates/stacks/internal-tool/scaffold/next.config.mjs +6 -0
  57. package/templates/stacks/internal-tool/scaffold/package.json +29 -0
  58. package/templates/stacks/internal-tool/scaffold/postcss.config.mjs +7 -0
  59. package/templates/stacks/internal-tool/scaffold/supabase/migrations/0001_init.sql +28 -0
  60. package/templates/stacks/internal-tool/scaffold/tsconfig.json +21 -0
  61. package/templates/stacks/internal-tool/stack.json +9 -0
  62. package/templates/stacks/internal-tool/verify-checklist.md +31 -0
  63. package/templates/stacks/landing-page/env.required.json +8 -0
  64. package/templates/stacks/landing-page/phases.md +42 -0
  65. package/templates/stacks/landing-page/scaffold/.env.example +3 -0
  66. package/templates/stacks/landing-page/scaffold/README.md +25 -0
  67. package/templates/stacks/landing-page/scaffold/app/globals.css +14 -0
  68. package/templates/stacks/landing-page/scaffold/app/layout.tsx +19 -0
  69. package/templates/stacks/landing-page/scaffold/app/page.tsx +21 -0
  70. package/templates/stacks/landing-page/scaffold/next.config.mjs +7 -0
  71. package/templates/stacks/landing-page/scaffold/package.json +26 -0
  72. package/templates/stacks/landing-page/scaffold/postcss.config.mjs +7 -0
  73. package/templates/stacks/landing-page/scaffold/tsconfig.json +21 -0
  74. package/templates/stacks/landing-page/stack.json +9 -0
  75. package/templates/stacks/landing-page/verify-checklist.md +28 -0
  76. package/tests/bin.test.sh +3 -3
  77. package/tests/state.test.sh +83 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ 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
+ ## [6.9.0] - 2026-06-13 (employee on-ramps — stack presets, employee-mode install, shared knowledge pull)
12
+
13
+ Implements Part A of the 2026-06-13 restructure plan: turn the framework from an owner-only tool into something a new employee can take idea→deployed without tribal knowledge. **Additive only** — no skill, agent, hook, or rule was rewritten; these are on-ramps built *around* the existing loop.
14
+
15
+ ### Added — project-type stack presets (`templates/stacks/`)
16
+ - **Four presets matching real Qualia work:** `landing-page` (Next.js SSG, no DB), `full-app` (Next.js + Supabase auth/RLS + Tailwind), `ai-agent` (adds OpenRouter + optional Retell/ElevenLabs/Telnyx voice), `internal-tool` (Supabase portal-style auth). Each preset ships `stack.json`, `env.required.json` (`{name, purpose, howToObtain, ownerIssued}`), `phases.md`, `verify-checklist.md`, and a minimal-but-runnable `scaffold/` (full-app/internal-tool include `lib/supabase` server+client adapters and an RLS-from-first-migration `0001_init.sql`; ai-agent adds a `lib/openrouter` adapter + `app/api/chat` route + `evals/`). `templates/stacks/README.md` documents the contract. This kills the "every project gets identical generic phases / no working skeleton" gaps.
17
+
18
+ ### Changed — `/qualia-new` instantiates a preset
19
+ - Adds one project-type question, then lays down the chosen preset's scaffold, folds `phases.md` into the plan, and copies `env.required.json` to the new project's `.planning/env.required.json`. `--quick` skips it. Existing flow otherwise unchanged.
20
+
21
+ ### Changed — `/qualia-doctor` validates the preset's env + CLI auth
22
+ - Reads `.planning/env.required.json` (fallback `./env.required.json`), checks each var (env or `.env.local`) and CLI logins (`vercel`, `supabase`, `gh`), and prints PASS/FAIL with the exact fix command. For `ownerIssued: true` vars it routes to "ask the OWNER (Fawzi)" instead of a self-serve command.
23
+
24
+ ### Added — two-mode install (OWNER / EMPLOYEE)
25
+ - `npx qualia-framework install` accepts the explicit keyword `EMPLOYEE` at the install-code prompt: installs the full framework with **no team code**, `erp.enabled=false`. OWNER/team-code installs are byte-for-byte unchanged, and a genuinely bogus or empty code is still rejected (no silent self-elevation). `/qualia-report` degrades gracefully when no team code is set — it writes a **local** report file and says so, instead of failing. New `docs/EMPLOYEE-QUICKSTART.md` walks install→doctor→new→build/verify→ship→report with the credential each step needs.
26
+
27
+ ### Added — shared knowledge pull (Part C bridge)
28
+ - On install/update the framework pulls a **read-only** copy of the `qualia-memory` vault's sanitized `wiki/_export/` (configurable via `QUALIA_KNOWLEDGE_SOURCE` / `QUALIA_KNOWLEDGE_SUBPATH`, git URL or local path) into the knowledge dir, skipping gracefully when unavailable. Freshness comes from the vault's nightly ERP-report ingest, not sync infra. The export is financial-data-sanitized at the source (see qualia-memory `scripts/export-team-wiki.py`).
29
+
30
+ ### Fixed — test drift
31
+ - `tests/bin.test.sh` (Codex backup discipline #122/#123) now greps the `backups/` subdir, matching the v6.8.1 `bakPath()` relocation. The behavior was correct; the test path was stale.
32
+
33
+ ## [6.8.1] - 2026-06-10 (installer hygiene — global hook sweep, bin purge, bak routing)
34
+
35
+ Found via a live audit of an installed `~/.claude`: the retired brain experiment's `brain-inject.js` was still wired under `UserPromptSubmit`, firing a failing node process on every user prompt, and had survived four releases because the settings merge never looked at that event.
36
+
37
+ ### Fixed — installer
38
+ - **Settings hook sweep is now global.** The merge previously only cleaned events present in `qualiaHooks`, so deprecated entries under other events (`UserPromptSubmit`, etc.) were never pruned. The installer now sweeps every hook event for (a) retired Qualia hook commands and (b) `node "<CLAUDE_DIR>/..."` commands whose target file no longer exists, dropping empty blocks/events. User hooks (non-node or outside `CLAUDE_DIR`) are untouched.
39
+ - **`brain-inject.js` added to `DEPRECATED_HOOKS`** — the UserPromptSubmit half of the brain experiment was missing from the v6.8.0 prune list.
40
+ - **`bin/` orphan purge** — `DEPRECATED_BIN` removes the retired `build-brain-index.js`; bin/ previously had no deprecation pass at all.
41
+ - **`.bak` files routed to `backups/`** — `settings.json.bak.*` / `CLAUDE.md.bak.*` no longer accumulate in the `~/.claude` root across reinstalls; all three backup sites now write into a `backups/` subdir via a shared `bakPath()` helper.
42
+
11
43
  ## [6.8.0] - 2026-06-06 (audit remediation — installer, guards, trust-score, polish)
12
44
 
13
45
  Closes the verified findings from the 2026-06-06 framework audit (4 CRITICAL · 7 HIGH · 33 MEDIUM, adversarially verified). Root cause of most CRITICALs was a stale/incomplete install, not source rot — fixed in the installer so a clean reinstall is complete and correct.
package/bin/install.js CHANGED
@@ -171,6 +171,95 @@ function copyTreeTransform(src, dest, transform) {
171
171
  }
172
172
  }
173
173
 
174
+ // ─── Shared knowledge pull (read-only mirror of qualia-memory) ───
175
+ // Copy-on-install/update only — NOT a sync engine. Pulls a read-only snapshot
176
+ // of the team's shared knowledge wiki into <home>/knowledge/shared/ so every
177
+ // install ships the same accumulated patterns. Source is configurable:
178
+ // QUALIA_KNOWLEDGE_SOURCE — a local path OR a git URL (https/ssh/file)
179
+ // QUALIA_KNOWLEDGE_SUBPATH — subdir inside the source to copy (default wiki/_export)
180
+ // Degrades GRACEFULLY: any failure (no git, no network, missing path, private-
181
+ // repo auth fail) warns and continues. Never throws, never exits the installer.
182
+ //
183
+ // Returns { status, detail } for the caller to log. status is one of:
184
+ // "copied" | "skipped" | "error" (all non-fatal).
185
+ const DEFAULT_KNOWLEDGE_SUBPATH = "wiki/_export";
186
+
187
+ function copyTreeReadOnly(src, dest) {
188
+ // Copy a tree and lock every FILE 0o444 — this mirror is read-only; local
189
+ // edits belong in the user's own knowledge files, not here. Directories stay
190
+ // writable (0o755) so the next install/update can replace the mirror cleanly
191
+ // (deleting an entry needs write on its parent dir, not on the entry).
192
+ if (!fs.existsSync(src)) return 0;
193
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
194
+ let count = 0;
195
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
196
+ if (entry.name.startsWith(".")) continue;
197
+ const s = path.join(src, entry.name);
198
+ const d = path.join(dest, entry.name);
199
+ if (entry.isDirectory()) {
200
+ count += copyTreeReadOnly(s, d);
201
+ } else if (entry.isFile()) {
202
+ fs.copyFileSync(s, d);
203
+ try { fs.chmodSync(d, 0o444); } catch {}
204
+ count++;
205
+ }
206
+ }
207
+ return count;
208
+ }
209
+
210
+ function pullSharedKnowledge(knowledgeDest) {
211
+ const source = (process.env.QUALIA_KNOWLEDGE_SOURCE || "").trim();
212
+ if (!source) {
213
+ return { status: "skipped", detail: "QUALIA_KNOWLEDGE_SOURCE not set — shared knowledge pull disabled" };
214
+ }
215
+ const subpath = (process.env.QUALIA_KNOWLEDGE_SUBPATH || DEFAULT_KNOWLEDGE_SUBPATH).trim();
216
+ const sharedDest = path.join(knowledgeDest, "shared");
217
+ const os = require("os");
218
+ let tmpClone = null;
219
+ try {
220
+ let sourceRoot;
221
+ const looksGit = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(source) || source.endsWith(".git");
222
+ if (looksGit) {
223
+ // Shallow clone into a temp dir. spawnSync with argv (no shell) so the
224
+ // URL can't be misinterpreted. Short timeout — never hang an install.
225
+ const { spawnSync } = require("child_process");
226
+ tmpClone = fs.mkdtempSync(path.join(os.tmpdir(), "qualia-knowledge-"));
227
+ const r = spawnSync("git", ["clone", "--depth", "1", "--quiet", source, tmpClone], {
228
+ stdio: ["ignore", "ignore", "pipe"],
229
+ timeout: 30000,
230
+ encoding: "utf8",
231
+ });
232
+ if (r.status !== 0) {
233
+ const why = (r.stderr || "").trim().split("\n").pop() || `git exited ${r.status}`;
234
+ return { status: "skipped", detail: `clone failed (${why}) — skipped, continuing` };
235
+ }
236
+ sourceRoot = tmpClone;
237
+ } else {
238
+ // Local path source.
239
+ if (!fs.existsSync(source)) {
240
+ return { status: "skipped", detail: `source path not found: ${source} — skipped, continuing` };
241
+ }
242
+ sourceRoot = source;
243
+ }
244
+
245
+ const exportDir = path.join(sourceRoot, subpath);
246
+ if (!fs.existsSync(exportDir)) {
247
+ return { status: "skipped", detail: `subpath '${subpath}' absent in source — skipped, continuing` };
248
+ }
249
+
250
+ // Replace the prior mirror wholesale (it's read-only and fully derived).
251
+ if (fs.existsSync(sharedDest)) {
252
+ try { fs.rmSync(sharedDest, { recursive: true, force: true }); } catch {}
253
+ }
254
+ const n = copyTreeReadOnly(exportDir, sharedDest);
255
+ return { status: "copied", detail: `${n} file(s) → knowledge/shared/ (read-only mirror)` };
256
+ } catch (e) {
257
+ return { status: "error", detail: `${e.message} — skipped, continuing` };
258
+ } finally {
259
+ if (tmpClone) { try { fs.rmSync(tmpClone, { recursive: true, force: true }); } catch {} }
260
+ }
261
+ }
262
+
174
263
  function ensureCodexStatusLineConfig(existing) {
175
264
  const statusLine = `status_line = ${JSON.stringify(CODEX_STATUS_LINE)}`;
176
265
  const colors = "status_line_use_colors = true";
@@ -210,13 +299,22 @@ function ensureCodexStatusLineConfig(existing) {
210
299
  return `${next.slice(0, tuiMatch.index)}${tuiBlock}${next.slice(tuiMatch.index + tuiMatch[0].length)}`;
211
300
  }
212
301
 
302
+ // v6.8.1: .bak files go into a backups/ subdir next to the target instead of
303
+ // littering the target's directory (a dozen settings.json.bak.* files were
304
+ // accumulating in ~/.claude root across reinstalls).
305
+ function bakPath(dest) {
306
+ const dir = path.join(path.dirname(dest), "backups");
307
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
308
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
309
+ return path.join(dir, `${path.basename(dest)}.bak.${ts}`);
310
+ }
311
+
213
312
  function backupIfDifferent(dest, nextContent, label) {
214
313
  if (!fs.existsSync(dest)) return false;
215
314
  try {
216
315
  const existing = fs.readFileSync(dest, "utf8");
217
316
  if (existing === nextContent) return false;
218
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
219
- const bak = `${dest}.bak.${ts}`;
317
+ const bak = bakPath(dest);
220
318
  fs.copyFileSync(dest, bak);
221
319
  ok(`Backed up existing ${label} -> ${path.basename(bak)}`);
222
320
  return true;
@@ -439,18 +537,42 @@ function askCode() {
439
537
  printHeader();
440
538
  const line = nextPipedLine();
441
539
  // Echo the prompt + answer for log readability.
442
- process.stdout.write(` ${WHITE}Enter install code:${RESET} ${line}\n`);
540
+ process.stdout.write(` ${WHITE}Enter install code (or "EMPLOYEE" for no-code install):${RESET} ${line}\n`);
443
541
  resolve(String(line || "").trim());
444
542
  return;
445
543
  }
446
544
  const rl = getRl();
447
545
  printHeader();
448
- rl.question(` ${WHITE}Enter install code:${RESET} `, (answer) => {
546
+ console.log(` ${DIM}OWNER / team member? Enter your install code (QS-NAME-##).${RESET}`);
547
+ console.log(` ${DIM}New employee without a code? Type ${RESET}${TEAL}EMPLOYEE${RESET}${DIM} to install in employee mode.${RESET}`);
548
+ console.log("");
549
+ rl.question(` ${WHITE}Install code or "EMPLOYEE":${RESET} `, (answer) => {
449
550
  resolve(String(answer || "").trim());
450
551
  });
451
552
  });
452
553
  }
453
554
 
555
+ // ─── Employee mode (no team code) ────────────────────────
556
+ // A new employee may not have a QS-NAME-## code yet. Typing "EMPLOYEE" at the
557
+ // install-code prompt installs the full framework at the least-privilege role:
558
+ // feature branches only, no main pushes (enforced by branch-guard, which trusts
559
+ // the role bit in the 0o600 config). ERP reporting stays OFF until a real team
560
+ // code is set, so /qualia-report degrades to a local-only report.
561
+ //
562
+ // Defaulting to EMPLOYEE (not OWNER) on a missing/keyword code is the secure
563
+ // failure mode: a bogus code that is NOT the EMPLOYEE keyword is still rejected
564
+ // (see main()), so this can't be used to silently self-elevate.
565
+ const EMPLOYEE_KEYWORDS = new Set(["EMPLOYEE", "EMPLOYEE-MODE", "NO-CODE", "NOCODE"]);
566
+ const EMPLOYEE_MEMBER = {
567
+ name: "Qualia Employee",
568
+ role: "EMPLOYEE",
569
+ description: "Developer. Feature branches only. Cannot push to main or edit .env files.",
570
+ };
571
+
572
+ function isEmployeeKeyword(raw) {
573
+ return EMPLOYEE_KEYWORDS.has(String(raw || "").trim().toUpperCase());
574
+ }
575
+
454
576
  // ─── Prompt for install target (Claude / Codex / Both) ──
455
577
  // Backward-compat: a piped install with only the team code (single stdin
456
578
  // line) closes stdin before this prompt; we silently default to "1"
@@ -516,12 +638,24 @@ async function main() {
516
638
  }
517
639
  const rawCode = await askCode();
518
640
  const code = resolveTeamCode(rawCode);
519
- const member = code ? TEAM[code] : null;
641
+ let member = code ? TEAM[code] : null;
642
+
643
+ // Employee mode: the user typed the EMPLOYEE keyword instead of a team code.
644
+ // Synthesize a least-privilege EMPLOYEE member. The team code stays empty,
645
+ // which keeps ERP reporting off (set later via a real code or set-erp-key).
646
+ const employeeMode = !member && isEmployeeKeyword(rawCode);
647
+ if (employeeMode) {
648
+ member = EMPLOYEE_MEMBER;
649
+ }
520
650
 
521
651
  if (!member) {
652
+ // A genuine bad code (not the EMPLOYEE keyword) is still rejected — typing
653
+ // garbage must never silently install. This preserves the install-code gate
654
+ // for team members while only the explicit EMPLOYEE keyword bypasses it.
522
655
  console.log("");
523
656
  log(`${RED}✗${RESET} Invalid code: "${rawCode}". Get your install code from Fawzi.`);
524
657
  log(`${DIM} Tip: codes use digit zero, not letter O. Format: QS-NAME-##${RESET}`);
658
+ log(`${DIM} No code yet? Type ${RESET}${TEAL}EMPLOYEE${RESET}${DIM} to install in employee mode.${RESET}`);
525
659
  console.log("");
526
660
  process.exit(1);
527
661
  }
@@ -530,6 +664,9 @@ async function main() {
530
664
  const roleColor = member.role === "OWNER" ? TEAL : GREEN;
531
665
  console.log(` ${GREEN}✓${RESET} ${WHITE}${BOLD}Welcome, ${member.name}${RESET}`);
532
666
  console.log(` ${DIM} Role:${RESET} ${roleColor}${member.role}${RESET}`);
667
+ if (employeeMode) {
668
+ console.log(` ${DIM} Mode:${RESET} ${YELLOW}employee (no team code) — ERP reporting off until a code is set${RESET}`);
669
+ }
533
670
 
534
671
  // ─── Ask install target (Claude / Codex / Both) ────────
535
672
  const target = await askTarget();
@@ -543,7 +680,7 @@ async function main() {
543
680
  if (!installClaude) {
544
681
  // Codex-only path: skip the entire Claude install block. Jump straight
545
682
  // to the Codex installer + final summary.
546
- await installCodex(member, target);
683
+ await installCodex(member, target, employeeMode);
547
684
  return;
548
685
  }
549
686
 
@@ -683,6 +820,8 @@ async function main() {
683
820
  "brain-pre-compact.js",
684
821
  "brain-session-end.js",
685
822
  "brain-session-start.js",
823
+ // v6.8.1: the UserPromptSubmit half of the brain experiment was missed.
824
+ "brain-inject.js",
686
825
  ];
687
826
  for (const f of DEPRECATED_HOOKS) {
688
827
  const p = path.join(hooksDest, f);
@@ -768,6 +907,15 @@ async function main() {
768
907
  }
769
908
  }
770
909
 
910
+ // ─── Shared knowledge pull (read-only mirror of qualia-memory wiki) ──
911
+ // Copy-on-install only. Configure with QUALIA_KNOWLEDGE_SOURCE (git URL or
912
+ // local path). Skips gracefully when unset/unreachable — never blocks install.
913
+ {
914
+ const pull = pullSharedKnowledge(knowledgeDest);
915
+ if (pull.status === "copied") ok(`shared knowledge — ${pull.detail}`);
916
+ else log(`${DIM}shared knowledge — ${pull.detail}${RESET}`);
917
+ }
918
+
771
919
  // ─── References (methodology docs loaded by skills at runtime) ────
772
920
  printSection("References");
773
921
  const refDir = path.join(FRAMEWORK_DIR, "references");
@@ -820,9 +968,8 @@ async function main() {
820
968
  if (fs.existsSync(claudeDest)) {
821
969
  const existing = fs.readFileSync(claudeDest, "utf8");
822
970
  if (existing !== claudeMd) {
823
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
824
- const bak = `${claudeDest}.bak.${ts}`;
825
- try { fs.copyFileSync(claudeDest, bak); ok(`Backed up existing CLAUDE.md → ${path.basename(bak)}`); } catch {}
971
+ const bak = bakPath(claudeDest);
972
+ try { fs.copyFileSync(claudeDest, bak); ok(`Backed up existing CLAUDE.md → backups/${path.basename(bak)}`); } catch {}
826
973
  }
827
974
  }
828
975
  fs.writeFileSync(claudeDest, claudeText(claudeMd), "utf8");
@@ -842,6 +989,14 @@ async function main() {
842
989
  try { fs.chmodSync(out, 0o755); } catch {}
843
990
  ok(script.label);
844
991
  }
992
+ // v6.8.1: purge retired bin scripts (same belt-and-suspenders as
993
+ // DEPRECATED_HOOKS — bin/ never had an orphan pass, so the brain
994
+ // experiment's indexer survived reinstalls).
995
+ const DEPRECATED_BIN = ["build-brain-index.js"];
996
+ for (const f of DEPRECATED_BIN) {
997
+ const p = path.join(binDest, f);
998
+ try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch {}
999
+ }
845
1000
  // Write a minimal root package.json so the installed CLI's `require("../package.json")`
846
1001
  // resolves post-install (bin/ lives at CLAUDE_DIR/bin, so the parent is CLAUDE_DIR).
847
1002
  fs.writeFileSync(
@@ -970,15 +1125,19 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
970
1125
  }
971
1126
 
972
1127
  // ─── Save config (for update command) ──────────────────
1128
+ // Employee mode (no team code): code is the empty string, and ERP reporting
1129
+ // is disabled by default — there's no team code to attribute reports to, so
1130
+ // /qualia-report degrades to a local-only report. A real team code (or
1131
+ // `qualia-framework set-erp-key`) flips ERP back on later.
973
1132
  const configFile = path.join(CLAUDE_DIR, ".qualia-config.json");
974
1133
  const config = {
975
- code,
1134
+ code: code || "",
976
1135
  installed_by: member.name,
977
1136
  role: member.role,
978
1137
  version: require("../package.json").version,
979
1138
  installed_at: new Date().toISOString().split("T")[0],
980
1139
  erp: {
981
- enabled: true,
1140
+ enabled: !employeeMode,
982
1141
  url: "https://portal.qualiasolutions.net",
983
1142
  api_key_file: ".erp-api-key",
984
1143
  },
@@ -1204,6 +1363,35 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1204
1363
  }
1205
1364
  }
1206
1365
 
1366
+ // v6.8.1: sweep ALL hook events for retired Qualia hooks and dead node
1367
+ // paths. The merge above only visits events present in qualiaHooks, so
1368
+ // entries under other events (e.g. the retired brain-inject.js under
1369
+ // UserPromptSubmit) survived every reinstall — firing a failing node
1370
+ // process on each user prompt. Runs after the hooks/ install, so an
1371
+ // existsSync miss means the file is truly gone, not not-yet-copied.
1372
+ const DEPRECATED_HOOK_CMDS = [
1373
+ "brain-inject.js", "build-brain-index.js", "block-env-edit.js",
1374
+ "brain-pre-compact.js", "brain-session-end.js", "brain-session-start.js",
1375
+ ];
1376
+ const isDeadHookCmd = (cmd) => {
1377
+ if (typeof cmd !== "string") return false;
1378
+ if (DEPRECATED_HOOK_CMDS.some((f) => cmd.includes(f))) return true;
1379
+ const m = cmd.match(/^node "([^"]+)"$/);
1380
+ if (m && m[1].startsWith(CLAUDE_DIR + path.sep) && !fs.existsSync(m[1])) return true;
1381
+ return false;
1382
+ };
1383
+ for (const event of Object.keys(settings.hooks)) {
1384
+ const blocks = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
1385
+ const cleaned = [];
1386
+ for (const block of blocks) {
1387
+ if (!block || !Array.isArray(block.hooks)) continue;
1388
+ const kept = block.hooks.filter((h) => !isDeadHookCmd(h && h.command));
1389
+ if (kept.length > 0) cleaned.push({ ...block, hooks: kept });
1390
+ }
1391
+ if (cleaned.length > 0) settings.hooks[event] = cleaned;
1392
+ else delete settings.hooks[event];
1393
+ }
1394
+
1207
1395
  // Permissions stay permissive; Qualia policy enforcement happens in hooks so
1208
1396
  // OWNER overrides and EMPLOYEE blocks can share one source of truth. We still
1209
1397
  // seed a scoped baseline allow-list (union-merged, never clobbering user
@@ -1238,9 +1426,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1238
1426
  // configs / custom permissions. Atomic write (tmp + rename) avoids partial
1239
1427
  // writes; the .bak file is the recovery point if the merger ever misbehaves.
1240
1428
  if (fs.existsSync(settingsPath)) {
1241
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
1242
- const bak = `${settingsPath}.bak.${ts}`;
1243
- try { fs.copyFileSync(settingsPath, bak); } catch {}
1429
+ try { fs.copyFileSync(settingsPath, bakPath(settingsPath)); } catch {}
1244
1430
  }
1245
1431
  const settingsTmp = `${settingsPath}.tmp.${process.pid}`;
1246
1432
  fs.writeFileSync(settingsTmp, JSON.stringify(settings, null, 2));
@@ -1252,7 +1438,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1252
1438
 
1253
1439
  // ─── Codex (optional second target) ──────────────────────
1254
1440
  if (installCodexTarget) {
1255
- await installCodex(member, target);
1441
+ await installCodex(member, target, employeeMode);
1256
1442
  }
1257
1443
 
1258
1444
  // ─── Summary ───────────────────────────────────────────
@@ -1346,7 +1532,7 @@ function printSummary({ member, target, claudeInstalled }) {
1346
1532
  // Claude install. The only thing intentionally not claimed is a Claude-style
1347
1533
  // persistent statusLine setting; Codex exposes hook status messages today, not
1348
1534
  // an equivalent global status-line command.
1349
- async function installCodex(member, target) {
1535
+ async function installCodex(member, target, employeeMode = false) {
1350
1536
  console.log("");
1351
1537
  console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}Codex${RESET}`);
1352
1538
  console.log(` ${DIM2}${"─".repeat(40)}${RESET}`);
@@ -1435,7 +1621,9 @@ async function installCodex(member, target) {
1435
1621
  version: require("../package.json").version,
1436
1622
  installed_at: new Date().toISOString().split("T")[0],
1437
1623
  erp: {
1438
- enabled: true,
1624
+ // Employee mode (no team code) leaves ERP off so /qualia-report
1625
+ // degrades to a local-only report instead of failing.
1626
+ enabled: !employeeMode,
1439
1627
  url: "https://portal.qualiasolutions.net",
1440
1628
  api_key_file: ".erp-api-key",
1441
1629
  },
@@ -1559,6 +1747,13 @@ async function installCodex(member, target) {
1559
1747
  if (!fs.existsSync(out)) copyTextTransform(path.join(knowledgeSrc, file), out, codexText);
1560
1748
  }
1561
1749
  }
1750
+ // Shared knowledge mirror (read-only) — same copy-on-install semantics as
1751
+ // the Claude path. Skips gracefully when QUALIA_KNOWLEDGE_SOURCE is unset.
1752
+ {
1753
+ const pull = pullSharedKnowledge(knowledgeDest);
1754
+ if (pull.status === "copied") ok(`shared knowledge — ${pull.detail}`);
1755
+ else log(`${DIM}shared knowledge — ${pull.detail}${RESET}`);
1756
+ }
1562
1757
  copyTextTransform(path.join(FRAMEWORK_DIR, "guide.md"), path.join(CODEX_DIR, "qualia-guide.md"), codexText);
1563
1758
  ok("templates/ + knowledge/ + references/ + guide");
1564
1759
  } catch (e) {
package/bin/state.js CHANGED
@@ -729,6 +729,33 @@ function readNextMilestoneNameFromJourney(milestoneNum) {
729
729
  }
730
730
  }
731
731
 
732
+ // A milestone name is a "placeholder" when it's blank or the literal
733
+ // "Milestone N" fallback that close-milestone historically emitted when
734
+ // tracking.json carried no real name. Placeholders must never beat a real
735
+ // name from JOURNEY.md (observed in ERP reports as {"num":"9","name":"Milestone 9"}).
736
+ function isPlaceholderMilestoneName(name, num) {
737
+ const trimmed = String(name == null ? "" : name).trim();
738
+ if (!trimmed) return true;
739
+ return new RegExp(`^Milestone\\s+${num}$`, "i").test(trimmed);
740
+ }
741
+
742
+ // Resolve the human name for milestone `num`. Preference order:
743
+ // 1. `candidate` (e.g. tracking.json milestone_name) when it's a real name
744
+ // 2. the `## Milestone N · Name` header in JOURNEY.md
745
+ // 3. the "Milestone N" placeholder — last resort only
746
+ function resolveMilestoneName(num, candidate) {
747
+ const trimmed = String(candidate == null ? "" : candidate).trim();
748
+ if (!isPlaceholderMilestoneName(trimmed, num)) return trimmed;
749
+ const fromJourney = readNextMilestoneNameFromJourney(num);
750
+ if (fromJourney) return fromJourney;
751
+ return `Milestone ${num}`;
752
+ }
753
+
754
+ // Normalize a milestone name for dedupe comparisons.
755
+ function normalizeMilestoneName(name) {
756
+ return String(name == null ? "" : name).trim().toLowerCase();
757
+ }
758
+
732
759
  function readState() {
733
760
  try {
734
761
  return fs.readFileSync(STATE_FILE, "utf8");
@@ -2166,7 +2193,9 @@ function cmdCloseMilestone(opts) {
2166
2193
  }
2167
2194
  ensureLifetime(t);
2168
2195
 
2169
- const closedMilestone = t.milestone || 1;
2196
+ // parseInt legacy tracking.json files carry milestone as a string ("9"),
2197
+ // which would corrupt `closedMilestone + 1` ("91") and break num dedupe.
2198
+ const closedMilestone = parseInt(t.milestone, 10) || 1;
2170
2199
  if (
2171
2200
  !opts.force &&
2172
2201
  typeof t.lifetime.last_closed_milestone === "number" &&
@@ -2227,9 +2256,13 @@ function cmdCloseMilestone(opts) {
2227
2256
  0,
2228
2257
  (parseInt(t.lifetime && t.lifetime.tasks_completed) || 0) - priorMilestoneTasks
2229
2258
  );
2259
+ // Resolve the REAL milestone name: non-placeholder tracking.json
2260
+ // milestone_name first, then the JOURNEY.md `## Milestone N · Name` header,
2261
+ // and only as a last resort the "Milestone N" placeholder.
2262
+ const milestoneName = resolveMilestoneName(closedMilestone, t.milestone_name);
2230
2263
  const summary = {
2231
2264
  num: closedMilestone,
2232
- name: t.milestone_name || `Milestone ${closedMilestone}`,
2265
+ name: milestoneName,
2233
2266
  total_phases: parseInt(t.total_phases) || s.phases.length || 0,
2234
2267
  phases_completed: phasesCompleted,
2235
2268
  tasks_completed: tasksCompletedThisMilestone,
@@ -2237,8 +2270,18 @@ function cmdCloseMilestone(opts) {
2237
2270
  closed_at: new Date().toISOString(),
2238
2271
  };
2239
2272
  t.milestones = Array.isArray(t.milestones) ? t.milestones : [];
2240
- // Idempotency: don't duplicate if the same milestone number is already logged.
2241
- const existing = t.milestones.findIndex((m) => m && m.num === closedMilestone);
2273
+ // Idempotency + dedupe: update in place when the same milestone number is
2274
+ // already logged (num may be a string in legacy entries), OR when the same
2275
+ // real name is already logged under a different num (renumbering drift —
2276
+ // observed as duplicate names in ERP session_reports.milestones).
2277
+ let existing = t.milestones.findIndex(
2278
+ (m) => m && parseInt(m.num, 10) === closedMilestone
2279
+ );
2280
+ if (existing < 0 && !isPlaceholderMilestoneName(milestoneName, closedMilestone)) {
2281
+ existing = t.milestones.findIndex(
2282
+ (m) => m && normalizeMilestoneName(m.name) === normalizeMilestoneName(milestoneName)
2283
+ );
2284
+ }
2242
2285
  if (existing >= 0) {
2243
2286
  t.milestones[existing] = summary;
2244
2287
  } else {
@@ -2533,13 +2576,35 @@ function cmdBackfillMilestones(opts) {
2533
2576
  };
2534
2577
  closedSummaries.push({ num: row.num, name: row.name, phases: phaseCount });
2535
2578
 
2536
- const existing = t.milestones.findIndex((mm) => mm && mm.num === row.num);
2579
+ // Match by num first (parseInt legacy entries store num as a string),
2580
+ // then by normalized real name under a different num (renumbering drift).
2581
+ let existing = t.milestones.findIndex(
2582
+ (mm) => mm && parseInt(mm.num, 10) === row.num
2583
+ );
2584
+ if (existing < 0 && !isPlaceholderMilestoneName(row.name, row.num)) {
2585
+ existing = t.milestones.findIndex(
2586
+ (mm) => mm && normalizeMilestoneName(mm.name) === normalizeMilestoneName(row.name)
2587
+ );
2588
+ }
2537
2589
  if (existing >= 0) {
2538
2590
  // Don't override entries that came from real /qualia-milestone close
2539
2591
  // (they have richer data). Only overwrite previously-backfilled entries.
2540
2592
  if (t.milestones[existing].backfilled) {
2541
2593
  t.milestones[existing] = summary;
2542
2594
  updated++;
2595
+ } else if (
2596
+ isPlaceholderMilestoneName(
2597
+ t.milestones[existing].name,
2598
+ parseInt(t.milestones[existing].num, 10)
2599
+ ) ||
2600
+ parseInt(t.milestones[existing].num, 10) !== row.num
2601
+ ) {
2602
+ // Real close entry but with a placeholder name ("Milestone 9") or a
2603
+ // stale num — repair the identity fields in place, keep the richer
2604
+ // close data (tasks_completed, shipped_url, closed_at).
2605
+ t.milestones[existing].num = row.num;
2606
+ t.milestones[existing].name = row.name;
2607
+ updated++;
2543
2608
  }
2544
2609
  } else {
2545
2610
  t.milestones.push(summary);