qualia-framework 7.2.1 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -156,6 +156,82 @@ function cmdVersion() {
156
156
  console.log("");
157
157
  }
158
158
 
159
+ // ─── Snapshot / Rollback (update safety net) ─────────────────────
160
+ // `update` re-runs the installer in place; a crash or bad version mid-update
161
+ // used to leave a half-migrated tree with no restore path. We snapshot the
162
+ // framework-owned subtrees into <home>/.qualia-prev/ BEFORE updating, so
163
+ // `rollback` can restore the previous version. User/security files (CLAUDE.md,
164
+ // settings.json, .qualia-config.json, .erp-api-key) are NOT snapshotted — they
165
+ // stay on their existing in-place merge/backup path. Single-deep (last update).
166
+ const SNAPSHOT_DIRS = ["bin", "skills", "agents", "hooks", "rules", "qualia-design", "references", "qualia-references"];
167
+ const SNAPSHOT_FILES = ["package.json", "qualia-guide.md"];
168
+ const PREV_DIR = ".qualia-prev";
169
+
170
+ function homeVersion(home) {
171
+ try { return JSON.parse(fs.readFileSync(configFileForHome(home), "utf8")).version || null; } catch {}
172
+ try { return JSON.parse(fs.readFileSync(path.join(home, "package.json"), "utf8")).version || null; } catch {}
173
+ return null;
174
+ }
175
+
176
+ function snapshotHome(home) {
177
+ const prev = path.join(home, PREV_DIR);
178
+ try { fs.rmSync(prev, { recursive: true, force: true }); } catch {} // single-deep
179
+ fs.mkdirSync(prev, { recursive: true });
180
+ const swapped = [];
181
+ for (const d of SNAPSHOT_DIRS) {
182
+ const src = path.join(home, d);
183
+ if (fs.existsSync(src)) { fs.cpSync(src, path.join(prev, d), { recursive: true }); swapped.push(d); }
184
+ }
185
+ for (const f of SNAPSHOT_FILES) {
186
+ const src = path.join(home, f);
187
+ if (fs.existsSync(src)) { fs.cpSync(src, path.join(prev, f)); swapped.push(f); }
188
+ }
189
+ const manifest = { from_version: homeVersion(home), to_version: null, swapped, ts: new Date().toISOString() };
190
+ fs.writeFileSync(path.join(prev, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
191
+ return manifest;
192
+ }
193
+
194
+ function finalizeSnapshot(home) {
195
+ const mf = path.join(home, PREV_DIR, "manifest.json");
196
+ try {
197
+ const m = JSON.parse(fs.readFileSync(mf, "utf8"));
198
+ m.to_version = homeVersion(home);
199
+ fs.writeFileSync(mf, JSON.stringify(m, null, 2) + "\n");
200
+ } catch {}
201
+ }
202
+
203
+ function restoreHome(home) {
204
+ const prev = path.join(home, PREV_DIR);
205
+ const mf = path.join(prev, "manifest.json");
206
+ if (!fs.existsSync(mf)) return { ok: false, reason: "no snapshot" };
207
+ let m;
208
+ try { m = JSON.parse(fs.readFileSync(mf, "utf8")); } catch (e) { return { ok: false, reason: `unreadable manifest: ${e.message}` }; }
209
+ for (const name of m.swapped || []) {
210
+ const snap = path.join(prev, name);
211
+ const live = path.join(home, name);
212
+ if (!fs.existsSync(snap)) continue;
213
+ try {
214
+ if (fs.statSync(snap).isDirectory()) {
215
+ fs.rmSync(live, { recursive: true, force: true });
216
+ fs.cpSync(snap, live, { recursive: true });
217
+ } else {
218
+ fs.cpSync(snap, live);
219
+ }
220
+ } catch { /* continue; partial restore is re-runnable since the snapshot is kept */ }
221
+ }
222
+ // Reconcile the config version store back to the snapshot's version so doctor's
223
+ // reconciliation check goes green (the marker package.json was restored above).
224
+ if (m.from_version) {
225
+ try {
226
+ const cf = configFileForHome(home);
227
+ const cfg = JSON.parse(fs.readFileSync(cf, "utf8"));
228
+ cfg.version = m.from_version;
229
+ fs.writeFileSync(cf, JSON.stringify(cfg, null, 2) + "\n");
230
+ } catch {}
231
+ }
232
+ return { ok: true, manifest: m };
233
+ }
234
+
159
235
  function cmdUpdate() {
160
236
  banner();
161
237
  const cfg = readConfig();
@@ -166,8 +242,23 @@ function cmdUpdate() {
166
242
  process.exit(1);
167
243
  }
168
244
 
245
+ // Snapshot every installed home BEFORE updating so `rollback` can restore.
246
+ const snapHomes = installedHomes().filter((h) => fs.existsSync(configFileForHome(h)));
247
+ for (const h of snapHomes) { try { snapshotHome(h); } catch { /* non-fatal — update still proceeds */ } }
248
+
249
+ // --snapshot-only: take the safety snapshot without running the update (also
250
+ // the testable seam for the snapshot+rollback path, which otherwise needs npx).
251
+ if (process.argv.includes("--snapshot-only")) {
252
+ for (const h of snapHomes) {
253
+ const label = path.basename(h) === ".codex" ? "Codex" : "Claude";
254
+ console.log(` ${GREEN}✓${RESET} ${label}: snapshot v${homeVersion(h) || "?"} → ${PREV_DIR}/ (restore: ${TEAL}qualia-framework rollback${RESET})`);
255
+ }
256
+ console.log("");
257
+ return;
258
+ }
259
+
169
260
  console.log(` ${DIM}Current:${RESET} ${WHITE}${PKG.version}${RESET}`);
170
- console.log(` ${DIM}Updating...${RESET}`);
261
+ console.log(` ${DIM}Updating...${RESET} ${DIM}(snapshot taken — ${RESET}${TEAL}rollback${RESET}${DIM} available)${RESET}`);
171
262
  console.log("");
172
263
 
173
264
  try {
@@ -180,16 +271,49 @@ function cmdUpdate() {
180
271
  encoding: "utf8",
181
272
  });
182
273
  if (r.status !== 0) {
183
- console.log(` ${RED}✗${RESET} Update failed. Run manually: npx qualia-framework@latest install`);
274
+ console.log(` ${RED}✗${RESET} Update failed. Restore the previous version: ${TEAL}qualia-framework rollback${RESET}`);
275
+ console.log(` ${DIM}Or retry:${RESET} npx qualia-framework@latest install`);
184
276
  process.exit(1);
185
277
  }
278
+ for (const h of snapHomes) finalizeSnapshot(h);
186
279
  } catch (e) {
187
280
  console.log(` ${RED}✗${RESET} Update failed: ${e.message}`);
188
- console.log(` ${DIM}Run manually:${RESET} npx qualia-framework@latest install`);
281
+ console.log(` ${DIM}Restore:${RESET} ${TEAL}qualia-framework rollback${RESET} ${DIM}· Retry:${RESET} npx qualia-framework@latest install`);
189
282
  process.exit(1);
190
283
  }
191
284
  }
192
285
 
286
+ // ─── Rollback ────────────────────────────────────────────────────
287
+ // Restores the framework-owned subtrees from the last <home>/.qualia-prev/
288
+ // snapshot (taken automatically before each `update`), and resets the config
289
+ // version store to the snapshot's version so `doctor` reconciles. Covers all
290
+ // installed homes that carry a snapshot.
291
+ function cmdRollback() {
292
+ banner();
293
+ console.log("");
294
+ const homes = installedHomes().filter((h) => fs.existsSync(path.join(h, PREV_DIR, "manifest.json")));
295
+ if (homes.length === 0) {
296
+ console.log(` ${YELLOW}!${RESET} No snapshot to roll back to.`);
297
+ console.log(` ${DIM}A snapshot is taken automatically before each ${RESET}${TEAL}update${RESET}${DIM}, or take one now:${RESET} ${TEAL}qualia-framework update --snapshot-only${RESET}`);
298
+ console.log("");
299
+ process.exit(1);
300
+ }
301
+ let failed = 0;
302
+ for (const home of homes) {
303
+ const label = path.basename(home) === ".codex" ? "Codex" : "Claude";
304
+ const r = restoreHome(home);
305
+ if (r.ok) {
306
+ console.log(` ${GREEN}✓${RESET} ${label}: rolled back ${r.manifest.to_version || "?"} → ${WHITE}${r.manifest.from_version || "?"}${RESET} (${(r.manifest.swapped || []).length} subtrees)`);
307
+ } else {
308
+ console.log(` ${RED}✗${RESET} ${label}: ${r.reason}`);
309
+ failed++;
310
+ }
311
+ }
312
+ console.log(`\n ${DIM}Verify:${RESET} ${TEAL}qualia-framework doctor${RESET}`);
313
+ console.log("");
314
+ if (failed > 0) process.exit(1);
315
+ }
316
+
193
317
  // ─── Uninstall ───────────────────────────────────────────
194
318
  // Surgical removal of the Qualia Framework from installed homes.
195
319
  // Preserves CLAUDE.md / AGENTS.md (user may have customized them), Codex
@@ -466,14 +590,30 @@ async function cmdUninstall() {
466
590
  safeUnlink(path.join(home, "qualia-guide.md"), counters);
467
591
 
468
592
  safeRmDir(path.join(home, ".qualia-traces"), counters);
593
+ safeRmDir(path.join(home, ".qualia-prev"), counters); // pre-update rollback snapshot
469
594
 
470
595
  // Memory-loop runner (installed alongside bin/, not a .js so not in QUALIA_BIN_FILES).
471
596
  safeUnlink(path.join(home, "bin", "qualia-loop.sh"), counters);
597
+ // cli.js is copied next to the runtime scripts to power the bare-command
598
+ // shim, but it's not in QUALIA_BIN_FILES (binFiles excludes it), so remove
599
+ // it explicitly here.
600
+ safeUnlink(path.join(home, "bin", "cli.js"), counters);
472
601
 
473
602
  if (isCodexHome(home)) cleanCodexHooksJson(home, counters);
474
603
  else cleanSettingsJson(home, counters);
475
604
  }
476
605
 
606
+ // ── Bare-command PATH shim teardown ───────────────────────
607
+ // linkBareCommand (install.js) drops a `qualia-framework` shim into the
608
+ // first writable user-bin dir on PATH. Remove it from every candidate so
609
+ // uninstall leaves no dangling command.
610
+ try {
611
+ const home = require("os").homedir();
612
+ for (const d of [".local/bin", ".npm-global/bin", "bin"]) {
613
+ safeUnlink(path.join(home, d, "qualia-framework"), counters);
614
+ }
615
+ } catch {}
616
+
477
617
  // ── Memory loop systemd --user timer teardown ─────────────
478
618
  // installMemoryTimer() (bin/install.js) enables a nightly timer + writes unit
479
619
  // files. Disable + remove them so uninstall leaves no scheduled job behind.
@@ -1468,6 +1608,31 @@ function cmdDoctor() {
1468
1608
  }
1469
1609
  }
1470
1610
 
1611
+ // ── Version reconciliation (single source of truth) ─────────────
1612
+ // install.js writes the SAME PKG_VERSION to the home-root package.json marker
1613
+ // and each home's .qualia-config.json `version`. They agree right after a clean
1614
+ // install — but a partial/crashed update can leave them disagreeing while every
1615
+ // file-presence check above still passes, so doctor would otherwise report
1616
+ // "healthy" on a half-migrated tree. Catch that drift explicitly.
1617
+ const seenVersions = [];
1618
+ for (const home of homes) {
1619
+ const hlabel = path.basename(home) === ".codex" ? "Codex" : "Claude";
1620
+ let markerV = null, cfgV = null;
1621
+ try { markerV = JSON.parse(fs.readFileSync(path.join(home, "package.json"), "utf8")).version || null; } catch {}
1622
+ try { cfgV = JSON.parse(fs.readFileSync(configFileForHome(home), "utf8")).version || null; } catch {}
1623
+ if (markerV && cfgV) {
1624
+ check(`${hlabel} version reconciled (${cfgV})`, markerV === cfgV,
1625
+ `version drift: package.json marker=${markerV} vs .qualia-config.json=${cfgV} — reinstall: npx qualia-framework@latest install`);
1626
+ }
1627
+ if (markerV) seenVersions.push(markerV);
1628
+ if (cfgV) seenVersions.push(cfgV);
1629
+ }
1630
+ const distinctVersions = [...new Set(seenVersions)];
1631
+ if (distinctVersions.length > 1) {
1632
+ check("installed version consistent across stores", false,
1633
+ `multiple versions present (${distinctVersions.join(", ")}) — reinstall to reconcile: npx qualia-framework@latest install`);
1634
+ }
1635
+
1471
1636
  // ── Version vs. installed ──────────────────────────────
1472
1637
  const cfg = readConfig();
1473
1638
  if (cfg.installed_at) {
@@ -1636,7 +1801,8 @@ function cmdHelp() {
1636
1801
  console.log("");
1637
1802
  console.log(` ${WHITE}Commands:${RESET}`);
1638
1803
  console.log(` qualia-framework ${TEAL}install${RESET} Install or reinstall the framework`);
1639
- console.log(` qualia-framework ${TEAL}update${RESET} Update to the latest version`);
1804
+ console.log(` qualia-framework ${TEAL}update${RESET} Update to the latest version (${DIM}snapshots first${RESET})`);
1805
+ console.log(` qualia-framework ${TEAL}rollback${RESET} Restore the previous version from the pre-update snapshot`);
1640
1806
  console.log(` qualia-framework ${TEAL}version${RESET} Show installed version + check for updates`);
1641
1807
  console.log(` qualia-framework ${TEAL}uninstall${RESET} Clean removal from installed Claude/Codex homes (${DIM}-y to skip prompts${RESET})`);
1642
1808
  console.log(` qualia-framework ${TEAL}migrate${RESET} Wire current hook + env layout into ~/.claude/settings.json`);
@@ -1690,6 +1856,9 @@ switch (cmd) {
1690
1856
  case "upgrade":
1691
1857
  cmdUpdate();
1692
1858
  break;
1859
+ case "rollback":
1860
+ cmdRollback();
1861
+ break;
1693
1862
  case "uninstall":
1694
1863
  case "remove":
1695
1864
  cmdUninstall().catch((e) => {
package/bin/erp-retry.js CHANGED
@@ -18,6 +18,11 @@
18
18
  //
19
19
  // Hard rules:
20
20
  // - Never throw out the queue file on parse error — back it up and start fresh.
21
+ // - Bound the queue: items older than QUEUE_TTL_DAYS are pruned, and the queue
22
+ // is length-capped at QUEUE_MAX_ITEMS (oldest dropped first). Anything pruned
23
+ // is backed up to a .pruned.<ts>.json sidecar — never silently destroyed.
24
+ // - give_up items are kept but never count against the --max drain cap, so a
25
+ // backlog of stuck items can't starve freshly-enqueued reports.
21
26
  // - Max 10 attempts per item before marking give_up=true (stops the cycle).
22
27
  // - 401/422 are permanent failures: keep the item but mark give_up=true so
23
28
  // the user can see it and resolve manually (typically: fix the API key).
@@ -55,6 +60,13 @@ const CONFIG_FILE = path.join(QUALIA_HOME, ".qualia-config.json");
55
60
  const MAX_GIVE_UP_ATTEMPTS = 10;
56
61
  const DEFAULT_TIMEOUT_MS = 5000;
57
62
  const DEFAULT_MAX_ITEMS = 100;
63
+ // Bound the queue so a backlog of stuck/given-up items can't grow without limit.
64
+ // Oldest items past the TTL are pruned; if the queue still exceeds the length
65
+ // cap, the oldest (by enqueued_at) are dropped. Anything pruned is backed up to
66
+ // a .pruned.<ts>.json sidecar first — same never-silently-destroy discipline as
67
+ // the corrupt-queue backup in readQueue(). Override via env for tests.
68
+ const QUEUE_MAX_ITEMS = Number(process.env.ERP_QUEUE_MAX_ITEMS) || 200;
69
+ const QUEUE_TTL_DAYS = Number(process.env.ERP_QUEUE_TTL_DAYS) || 30;
58
70
 
59
71
  // ─── Args ───────────────────────────────────────────────
60
72
  const args = process.argv.slice(2);
@@ -101,6 +113,63 @@ function writeQueue(data) {
101
113
  fs.renameSync(tmp, QUEUE_FILE);
102
114
  }
103
115
 
116
+ // Bound the queue: drop items older than the TTL, then drop the oldest beyond
117
+ // the length cap. Returns { queue, pruned } — pruned items are NOT discarded by
118
+ // this function; the caller must back them up before persisting the trimmed
119
+ // queue. Pure (no I/O) so it's testable in isolation.
120
+ function pruneQueue(queue, { now = Date.now(), ttlDays = QUEUE_TTL_DAYS, maxItems = QUEUE_MAX_ITEMS } = {}) {
121
+ const ttlMs = ttlDays * 24 * 60 * 60 * 1000;
122
+ const ageOf = (it) => {
123
+ const t = Date.parse(it && it.enqueued_at);
124
+ return Number.isFinite(t) ? now - t : 0; // unparseable timestamp → treat as fresh, never auto-drop
125
+ };
126
+ const pruned = [];
127
+ let kept = queue.filter((it) => {
128
+ if (ageOf(it) > ttlMs) { pruned.push(it); return false; }
129
+ return true;
130
+ });
131
+ if (kept.length > maxItems) {
132
+ // Drop the oldest (largest age) first so fresh reports survive.
133
+ const ranked = kept.map((it, i) => ({ it, i, age: ageOf(it) }))
134
+ .sort((a, b) => b.age - a.age || a.i - b.i);
135
+ const dropCount = kept.length - maxItems;
136
+ const dropSet = new Set(ranked.slice(0, dropCount).map((r) => r.i));
137
+ const next = [];
138
+ kept.forEach((it, i) => { if (dropSet.has(i)) pruned.push(it); else next.push(it); });
139
+ kept = next;
140
+ }
141
+ return { queue: kept, pruned };
142
+ }
143
+
144
+ // Back pruned items up to a sidecar, mirroring the corrupt-queue backup path.
145
+ // Best-effort: never throws (a failed backup must not block the drain).
146
+ function backupPruned(pruned) {
147
+ if (!pruned || pruned.length === 0) return "";
148
+ const bak = `${QUEUE_FILE}.pruned.${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
149
+ try {
150
+ fs.writeFileSync(bak, JSON.stringify({ pruned_at: new Date().toISOString(), queue: pruned }, null, 2) + "\n", { mode: 0o600 });
151
+ return bak;
152
+ } catch { return ""; }
153
+ }
154
+
155
+ // One-line health stats for callers (session-start surface, `show`). Reports
156
+ // the total count and the age in whole hours of the oldest item.
157
+ function queueStats(data) {
158
+ const q = (data && Array.isArray(data.queue)) ? data.queue : [];
159
+ if (q.length === 0) return { count: 0, give_up: 0, oldest_hours: 0 };
160
+ const now = Date.now();
161
+ let oldest = 0;
162
+ for (const it of q) {
163
+ const t = Date.parse(it && it.enqueued_at);
164
+ if (Number.isFinite(t)) oldest = Math.max(oldest, now - t);
165
+ }
166
+ return {
167
+ count: q.length,
168
+ give_up: q.filter((it) => it && it.give_up).length,
169
+ oldest_hours: Math.floor(oldest / (60 * 60 * 1000)),
170
+ };
171
+ }
172
+
104
173
  function enqueue({ client_report_id, idempotency_key, url, payload, last_error, headers }) {
105
174
  if (!client_report_id || !url || !payload) {
106
175
  throw new Error("enqueue: client_report_id, url, payload are required");
@@ -188,22 +257,34 @@ function readConfig() {
188
257
  // ─── Actions ────────────────────────────────────────────
189
258
  async function actionDrain() {
190
259
  const data = readQueue();
260
+
261
+ // Bound the queue before draining: TTL-prune + length-cap, backing up anything
262
+ // dropped. This runs even when the drain itself is skipped below (no key / ERP
263
+ // off) so a stranded backlog can't accumulate forever.
264
+ const { queue: bounded, pruned } = pruneQueue(data.queue);
265
+ if (pruned.length > 0) {
266
+ data.queue = bounded;
267
+ const bak = backupPruned(pruned);
268
+ writeQueue({ queue: bounded });
269
+ log(`erp-retry: pruned ${pruned.length} stale item(s)${bak ? ` (backup at ${bak})` : ""}`);
270
+ }
271
+
191
272
  if (data.queue.length === 0) {
192
273
  log("erp-retry: queue empty");
193
- return { drained: 0, kept: 0, give_up: 0 };
274
+ return { drained: 0, kept: 0, give_up: 0, pruned: pruned.length };
194
275
  }
195
276
 
196
277
  const cfg = readConfig();
197
278
  const erpEnabled = !cfg.erp || cfg.erp.enabled !== false;
198
279
  if (!erpEnabled) {
199
280
  log("erp-retry: ERP disabled in config; skipping drain (queue preserved)");
200
- return { drained: 0, kept: data.queue.length, give_up: 0 };
281
+ return { drained: 0, kept: data.queue.length, give_up: 0, pruned: pruned.length };
201
282
  }
202
283
 
203
284
  const apiKey = readApiKey();
204
285
  if (!apiKey) {
205
286
  log("erp-retry: API key missing; skipping drain (queue preserved)");
206
- return { drained: 0, kept: data.queue.length, give_up: 0 };
287
+ return { drained: 0, kept: data.queue.length, give_up: 0, pruned: pruned.length };
207
288
  }
208
289
 
209
290
  let drained = 0;
@@ -212,12 +293,14 @@ async function actionDrain() {
212
293
  let processed = 0;
213
294
 
214
295
  for (const item of data.queue) {
215
- // Already given up — keep but don't try.
296
+ // Already given up — keep but don't try, and DON'T count against the --max
297
+ // cap (processed is not incremented here). A backlog of stuck items must
298
+ // never starve freshly-enqueued reports out of the drain budget.
216
299
  if (item.give_up) {
217
300
  remaining.push(item);
218
301
  continue;
219
302
  }
220
- // Respect the per-run cap.
303
+ // Respect the per-run cap (counts only live, retryable items).
221
304
  if (processed >= MAX_ITEMS) {
222
305
  remaining.push(item);
223
306
  continue;
@@ -257,7 +340,7 @@ async function actionDrain() {
257
340
  if (drained > 0 || give_up > 0 || !QUIET) {
258
341
  log(`erp-retry: drained=${drained} kept=${remaining.length} give_up=${give_up}`);
259
342
  }
260
- return { drained, kept: remaining.length, give_up };
343
+ return { drained, kept: remaining.length, give_up, pruned: pruned.length };
261
344
  }
262
345
 
263
346
  function actionShow() {
@@ -266,7 +349,8 @@ function actionShow() {
266
349
  log("queue empty");
267
350
  return;
268
351
  }
269
- log(`${data.queue.length} item(s) in queue:`);
352
+ const stats = queueStats(data);
353
+ log(`${stats.count} item(s) in queue (oldest ${stats.oldest_hours}h, give_up ${stats.give_up}):`);
270
354
  for (const item of data.queue) {
271
355
  log(` ${item.client_report_id} enqueued=${item.enqueued_at} attempts=${item.attempts || 0}${item.give_up ? " GIVE_UP" : ""}`);
272
356
  if (item.last_error) log(` last_error: ${item.last_error}`);
@@ -288,7 +372,7 @@ function actionClear() {
288
372
  // ─── Export for in-process use (qualia-report skill enqueues directly;
289
373
  // auto-report.js reuses the POST + config/key readers so there is ONE
290
374
  // ERP-upload seam, not two). ──
291
- module.exports = { enqueue, readQueue, writeQueue, postOnce, readApiKey, readConfig };
375
+ module.exports = { enqueue, readQueue, writeQueue, postOnce, readApiKey, readConfig, pruneQueue, backupPruned, queueStats };
292
376
 
293
377
  // ─── CLI entrypoint ─────────────────────────────────────
294
378
  if (require.main === module) {
package/bin/install.js CHANGED
@@ -507,6 +507,29 @@ function closeRl() {
507
507
  if (SHARED_RL) { try { SHARED_RL.close(); } catch {} SHARED_RL = null; }
508
508
  }
509
509
 
510
+ // Ask a question while masking the typed answer with '*' (for install codes).
511
+ // The prompt itself is written before muting, so only keystrokes are hidden.
512
+ function questionMasked(rl, prompt) {
513
+ return new Promise((resolve) => {
514
+ rl.question(prompt, (answer) => {
515
+ rl.stdoutMuted = false;
516
+ rl.output.write("\n");
517
+ resolve(answer);
518
+ });
519
+ rl.stdoutMuted = true;
520
+ if (!rl.__maskPatched) {
521
+ rl.__maskPatched = true;
522
+ const origWrite = rl._writeToOutput.bind(rl);
523
+ rl._writeToOutput = function (str) {
524
+ if (!rl.stdoutMuted) return origWrite(str);
525
+ // Pass control sequences (newlines) through; mask printable input.
526
+ if (str === "\n" || str === "\r\n" || str === "\r") return rl.output.write(str);
527
+ return rl.output.write("*");
528
+ };
529
+ }
530
+ });
531
+ }
532
+
510
533
  // Read every available stdin line into an array. Resolves immediately on
511
534
  // 'end'. Used only when stdin is piped (legacy `echo ... | install`).
512
535
  function bufferStdin() {
@@ -536,8 +559,9 @@ function askCode() {
536
559
  if (!IS_INTERACTIVE) {
537
560
  printHeader();
538
561
  const line = nextPipedLine();
539
- // Echo the prompt + answer for log readability.
540
- process.stdout.write(` ${WHITE}Enter install code (or "EMPLOYEE" for no-code install):${RESET} ${line}\n`);
562
+ // Echo the prompt for log readability, but never reveal the code itself.
563
+ const shown = isEmployeeKeyword(line) ? String(line).trim().toUpperCase() : (line ? "*".repeat(String(line).trim().length || 1) : "");
564
+ process.stdout.write(` ${WHITE}Enter install code (or "EMPLOYEE" for no-code install):${RESET} ${shown}\n`);
541
565
  resolve(String(line || "").trim());
542
566
  return;
543
567
  }
@@ -546,7 +570,7 @@ function askCode() {
546
570
  console.log(` ${DIM}OWNER / team member? Enter your install code (QS-NAME-##).${RESET}`);
547
571
  console.log(` ${DIM}New employee without a code? Type ${RESET}${TEAL}EMPLOYEE${RESET}${DIM} to install in employee mode.${RESET}`);
548
572
  console.log("");
549
- rl.question(` ${WHITE}Install code or "EMPLOYEE":${RESET} `, (answer) => {
573
+ questionMasked(rl, ` ${WHITE}Install code or "EMPLOYEE":${RESET} `).then((answer) => {
550
574
  resolve(String(answer || "").trim());
551
575
  });
552
576
  });
@@ -734,6 +758,76 @@ function installMemoryTimer() {
734
758
  }
735
759
  }
736
760
 
761
+ // ─── Bare `qualia-framework` command (PATH shim) ─────────────
762
+ // `npx qualia-framework install` runs the CLI via npx but leaves no bare
763
+ // command behind — the runtime scripts land in <home>/bin, but the meta-CLI
764
+ // (doctor/version/update/team/traces/uninstall) was never copied, so
765
+ // `qualia-framework doctor` is "command not found". `npm i -g` is the usual
766
+ // workaround but it's fragile for a team: npm's global prefix varies per
767
+ // machine (nvm/pnpm/bun), is frequently not on PATH, and forks a SECOND copy
768
+ // of the package that drifts from whatever `update` last fetched. Instead we
769
+ // self-link: cli.js is copied next to the runtime scripts (above) and we drop
770
+ // a tiny shim into a PATH dir that execs it. One install → a working, offline
771
+ // bare command with a single source of truth; `update` re-points it for free
772
+ // because update re-runs this installer.
773
+ function linkBareCommand(primaryHome) {
774
+ printSection("Bare command");
775
+ const os = require("os");
776
+ const HOME = os.homedir();
777
+
778
+ if (process.platform === "win32") {
779
+ log(`${DIM}Windows — PATH shim skipped. Use:${RESET} ${TEAL}npx qualia-framework <cmd>${RESET}`);
780
+ return;
781
+ }
782
+
783
+ // cli.js must already be sitting next to the runtime scripts (scripts step).
784
+ const cliPath = path.join(primaryHome, "bin", "cli.js");
785
+ if (!fs.existsSync(cliPath)) {
786
+ warn(`bare command — cli.js missing at ${cliPath}; skipped`);
787
+ return;
788
+ }
789
+
790
+ // Prefer a user-bin dir that is already on $PATH and writable; otherwise
791
+ // default to ~/.local/bin (the XDG standard) and tell the user to add it.
792
+ const pathDirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
793
+ const writable = (d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } };
794
+ const localBin = path.join(HOME, ".local", "bin");
795
+ const candidates = [localBin, path.join(HOME, ".npm-global", "bin"), path.join(HOME, "bin")];
796
+
797
+ let target = candidates.find((d) => fs.existsSync(d) && writable(d) && pathDirs.includes(d));
798
+ let onPath = Boolean(target);
799
+ if (!target) {
800
+ target = localBin;
801
+ try { fs.mkdirSync(target, { recursive: true }); } catch {}
802
+ onPath = pathDirs.includes(target);
803
+ }
804
+
805
+ const shimPath = path.join(target, "qualia-framework");
806
+ const shimBody = [
807
+ "#!/usr/bin/env bash",
808
+ "# qualia-framework — self-linked by the installer; execs the local CLI.",
809
+ "# Re-pointed on every install/update so it tracks the latest fetched cli.js.",
810
+ `exec node "${cliPath}" "$@"`,
811
+ "",
812
+ ].join("\n");
813
+
814
+ try {
815
+ fs.writeFileSync(shimPath, shimBody);
816
+ fs.chmodSync(shimPath, 0o755);
817
+ ok(`qualia-framework → ${shimPath.replace(HOME, "~")}`);
818
+ } catch (e) {
819
+ warn(`bare command — ${e.message}`);
820
+ return;
821
+ }
822
+
823
+ if (onPath) {
824
+ log(`${DIM}Try it:${RESET} ${TEAL}qualia-framework doctor${RESET}`);
825
+ } else {
826
+ log(`${YELLOW}!${RESET} ${DIM}${target.replace(HOME, "~")} is not on your PATH yet. Add it:${RESET}`);
827
+ log(` ${TEAL}echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc${RESET}`);
828
+ }
829
+ }
830
+
737
831
  // ─── Main ────────────────────────────────────────────────
738
832
  async function main() {
739
833
  // Piped install: drain stdin once up front. Avoids EOF/'close' racing
@@ -787,6 +881,7 @@ async function main() {
787
881
  // Codex-only path: skip the entire Claude install block. Jump straight
788
882
  // to the Codex installer + final summary.
789
883
  await installCodex(member, target, employeeMode);
884
+ try { linkBareCommand(CODEX_DIR); } catch (e) { warn(`bare command — ${e.message}`); }
790
885
  return;
791
886
  }
792
887
 
@@ -1028,7 +1123,11 @@ async function main() {
1028
1123
  const refDest = path.join(CLAUDE_DIR, "qualia-references");
1029
1124
  if (fs.existsSync(refDir)) {
1030
1125
  if (!fs.existsSync(refDest)) fs.mkdirSync(refDest, { recursive: true });
1031
- for (const file of fs.readdirSync(refDir)) {
1126
+ for (const entry of fs.readdirSync(refDir, { withFileTypes: true })) {
1127
+ // Skip nested dirs (e.g. archetypes/) — they are handled by the canonical
1128
+ // tree copy below. Reading a directory as a text file throws EISDIR.
1129
+ if (entry.isDirectory()) continue;
1130
+ const file = entry.name;
1032
1131
  try {
1033
1132
  copyTextTransform(path.join(refDir, file), path.join(refDest, file), claudeText);
1034
1133
  ok(file);
@@ -1098,6 +1197,17 @@ async function main() {
1098
1197
  try { fs.chmodSync(out, 0o755); } catch {}
1099
1198
  ok(script.label);
1100
1199
  }
1200
+ // Ship cli.js alongside the runtime scripts so the bare `qualia-framework`
1201
+ // command can exec it locally (see linkBareCommand). It is intentionally
1202
+ // NOT in RUNTIME_BIN_SCRIPTS — the skills never call it; only the PATH
1203
+ // shim does, and keeping it out of the manifest keeps trust-score's
1204
+ // REQUIRED_BIN probe (and uninstall) scoped to true runtime scripts.
1205
+ try {
1206
+ const cliOut = path.join(binDest, "cli.js");
1207
+ copy(path.join(FRAMEWORK_DIR, "bin", "cli.js"), cliOut);
1208
+ try { fs.chmodSync(cliOut, 0o755); } catch {}
1209
+ ok("cli.js (meta-command — powers the bare `qualia-framework`)");
1210
+ } catch (e) { warn(`cli.js — ${e.message}`); }
1101
1211
  // v6.8.1: purge retired bin scripts (same belt-and-suspenders as
1102
1212
  // DEPRECATED_HOOKS — bin/ never had an orphan pass, so the brain
1103
1213
  // experiment's indexer survived reinstalls).
@@ -1243,7 +1353,10 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1243
1353
  code: code || "",
1244
1354
  installed_by: member.name,
1245
1355
  role: member.role,
1246
- version: require("../package.json").version,
1356
+ // Single source: the same PKG_VERSION written to the home root package.json
1357
+ // marker — config and marker can never disagree out of a clean install
1358
+ // (doctor's version-reconciliation check enforces this).
1359
+ version: PKG_VERSION,
1247
1360
  installed_at: new Date().toISOString().split("T")[0],
1248
1361
  erp: {
1249
1362
  enabled: !employeeMode,
@@ -1423,6 +1536,9 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1423
1536
  { type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "⬢ Verifying Vercel account..." },
1424
1537
  { type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "⬢ Checking env value..." },
1425
1538
  { type: "command", if: "Bash(supabase*)|Bash(npx supabase*)", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5, statusMessage: "⬢ Checking Supabase safety..." },
1539
+ // Close the Bash bypass: SQL written/executed through the shell (heredoc→.sql,
1540
+ // psql -c/-f, supabase db execute/push) never reaches the Edit|Write migration-guard.
1541
+ { type: "command", if: "Bash(*psql*)|Bash(*.sql*)|Bash(supabase db execute*)|Bash(supabase db push*)|Bash(npx supabase db execute*)|Bash(npx supabase db push*)", command: nodeCmd("migration-guard.js"), timeout: 10, statusMessage: "⬢ Checking migration safety..." },
1426
1542
  { type: "command", if: "Bash(git commit*)", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
1427
1543
  ],
1428
1544
  },
@@ -1602,6 +1718,9 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1602
1718
  await installCodex(member, target, employeeMode);
1603
1719
  }
1604
1720
 
1721
+ // ─── Bare command (PATH shim) ────────────────────────────
1722
+ try { linkBareCommand(CLAUDE_DIR); } catch (e) { warn(`bare command — ${e.message}`); }
1723
+
1605
1724
  // ─── Summary ───────────────────────────────────────────
1606
1725
  closeSection();
1607
1726
  printSummary({ member, target, claudeInstalled: true });
@@ -1797,7 +1916,7 @@ async function installCodex(member, target, employeeMode = false) {
1797
1916
  code: Object.entries(TEAM).find(([, m]) => m.name === member.name)?.[0] || "",
1798
1917
  installed_by: member.name,
1799
1918
  role: member.role,
1800
- version: require("../package.json").version,
1919
+ version: PKG_VERSION, // single source — same constant as the marker + Claude config
1801
1920
  installed_at: new Date().toISOString().split("T")[0],
1802
1921
  erp: {
1803
1922
  // Employee mode (no team code) leaves ERP off so /qualia-report
@@ -1844,6 +1963,12 @@ async function installCodex(member, target, employeeMode = false) {
1844
1963
  copy(src, out);
1845
1964
  try { fs.chmodSync(out, 0o755); } catch {}
1846
1965
  }
1966
+ // cli.js powers the bare `qualia-framework` shim (not a runtime script).
1967
+ try {
1968
+ const cliOut = path.join(binDest, "cli.js");
1969
+ copy(path.join(FRAMEWORK_DIR, "bin", "cli.js"), cliOut);
1970
+ try { fs.chmodSync(cliOut, 0o755); } catch {}
1971
+ } catch {}
1847
1972
  ok(`bin/ (${scripts.length} scripts)`);
1848
1973
  } catch (e) {
1849
1974
  warn(`Codex scripts — ${e.message}`);
@@ -1988,6 +2113,9 @@ async function installCodex(member, target, employeeMode = false) {
1988
2113
  { type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
1989
2114
  { type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
1990
2115
  { type: "command", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5 },
2116
+ // Bash-bypass parity with Claude: migration-guard self-gates on the Bash
2117
+ // path (only blocks when it detects inline SQL bypassing supabase/migrations/).
2118
+ { type: "command", command: nodeCmd("migration-guard.js"), timeout: 10 },
1991
2119
  { type: "command", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
1992
2120
  ],
1993
2121
  },