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.
- package/CHANGELOG.md +32 -0
- package/bin/install.js +212 -17
- package/bin/state.js +70 -5
- package/docs/EMPLOYEE-QUICKSTART.md +162 -0
- package/package.json +2 -1
- package/skills/qualia-doctor/SKILL.md +62 -0
- package/skills/qualia-new/REFERENCE.md +7 -0
- package/skills/qualia-new/SKILL.md +42 -0
- package/skills/qualia-report/SKILL.md +13 -0
- package/templates/stacks/README.md +110 -0
- package/templates/stacks/ai-agent/env.required.json +44 -0
- package/templates/stacks/ai-agent/phases.md +53 -0
- package/templates/stacks/ai-agent/scaffold/.env.example +12 -0
- package/templates/stacks/ai-agent/scaffold/README.md +31 -0
- package/templates/stacks/ai-agent/scaffold/app/api/chat/route.ts +33 -0
- package/templates/stacks/ai-agent/scaffold/app/globals.css +13 -0
- package/templates/stacks/ai-agent/scaffold/app/layout.tsx +19 -0
- package/templates/stacks/ai-agent/scaffold/app/page.tsx +12 -0
- package/templates/stacks/ai-agent/scaffold/evals/cases.json +23 -0
- package/templates/stacks/ai-agent/scaffold/lib/openrouter/client.ts +51 -0
- package/templates/stacks/ai-agent/scaffold/lib/prompts/system.ts +6 -0
- package/templates/stacks/ai-agent/scaffold/lib/supabase/client.ts +10 -0
- package/templates/stacks/ai-agent/scaffold/lib/supabase/server.ts +28 -0
- package/templates/stacks/ai-agent/scaffold/next.config.mjs +7 -0
- package/templates/stacks/ai-agent/scaffold/package.json +29 -0
- package/templates/stacks/ai-agent/scaffold/postcss.config.mjs +7 -0
- package/templates/stacks/ai-agent/scaffold/supabase/migrations/0001_init.sql +41 -0
- package/templates/stacks/ai-agent/scaffold/tsconfig.json +21 -0
- package/templates/stacks/ai-agent/stack.json +9 -0
- package/templates/stacks/ai-agent/verify-checklist.md +31 -0
- package/templates/stacks/full-app/env.required.json +20 -0
- package/templates/stacks/full-app/phases.md +45 -0
- package/templates/stacks/full-app/scaffold/.env.example +7 -0
- package/templates/stacks/full-app/scaffold/README.md +28 -0
- package/templates/stacks/full-app/scaffold/app/globals.css +14 -0
- package/templates/stacks/full-app/scaffold/app/layout.tsx +19 -0
- package/templates/stacks/full-app/scaffold/app/page.tsx +20 -0
- package/templates/stacks/full-app/scaffold/lib/supabase/client.ts +10 -0
- package/templates/stacks/full-app/scaffold/lib/supabase/server.ts +31 -0
- package/templates/stacks/full-app/scaffold/next.config.mjs +7 -0
- package/templates/stacks/full-app/scaffold/package.json +29 -0
- package/templates/stacks/full-app/scaffold/postcss.config.mjs +7 -0
- package/templates/stacks/full-app/scaffold/supabase/migrations/0001_init.sql +27 -0
- package/templates/stacks/full-app/scaffold/tsconfig.json +21 -0
- package/templates/stacks/full-app/stack.json +9 -0
- package/templates/stacks/full-app/verify-checklist.md +32 -0
- package/templates/stacks/internal-tool/env.required.json +20 -0
- package/templates/stacks/internal-tool/phases.md +45 -0
- package/templates/stacks/internal-tool/scaffold/.env.example +7 -0
- package/templates/stacks/internal-tool/scaffold/README.md +29 -0
- package/templates/stacks/internal-tool/scaffold/app/globals.css +13 -0
- package/templates/stacks/internal-tool/scaffold/app/layout.tsx +20 -0
- package/templates/stacks/internal-tool/scaffold/app/page.tsx +22 -0
- package/templates/stacks/internal-tool/scaffold/lib/supabase/client.ts +9 -0
- package/templates/stacks/internal-tool/scaffold/lib/supabase/server.ts +28 -0
- package/templates/stacks/internal-tool/scaffold/next.config.mjs +6 -0
- package/templates/stacks/internal-tool/scaffold/package.json +29 -0
- package/templates/stacks/internal-tool/scaffold/postcss.config.mjs +7 -0
- package/templates/stacks/internal-tool/scaffold/supabase/migrations/0001_init.sql +28 -0
- package/templates/stacks/internal-tool/scaffold/tsconfig.json +21 -0
- package/templates/stacks/internal-tool/stack.json +9 -0
- package/templates/stacks/internal-tool/verify-checklist.md +31 -0
- package/templates/stacks/landing-page/env.required.json +8 -0
- package/templates/stacks/landing-page/phases.md +42 -0
- package/templates/stacks/landing-page/scaffold/.env.example +3 -0
- package/templates/stacks/landing-page/scaffold/README.md +25 -0
- package/templates/stacks/landing-page/scaffold/app/globals.css +14 -0
- package/templates/stacks/landing-page/scaffold/app/layout.tsx +19 -0
- package/templates/stacks/landing-page/scaffold/app/page.tsx +21 -0
- package/templates/stacks/landing-page/scaffold/next.config.mjs +7 -0
- package/templates/stacks/landing-page/scaffold/package.json +26 -0
- package/templates/stacks/landing-page/scaffold/postcss.config.mjs +7 -0
- package/templates/stacks/landing-page/scaffold/tsconfig.json +21 -0
- package/templates/stacks/landing-page/stack.json +9 -0
- package/templates/stacks/landing-page/verify-checklist.md +28 -0
- package/tests/bin.test.sh +3 -3
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
824
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
2241
|
-
|
|
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
|
-
|
|
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);
|