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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +17 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +56 -0
- package/CLAUDE.md +1 -1
- package/README.md +17 -4
- package/TROUBLESHOOTING.md +8 -7
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +115 -11
- package/bin/auto-report.js +15 -7
- package/bin/cli.js +173 -4
- package/bin/erp-retry.js +92 -8
- package/bin/install.js +134 -6
- package/bin/qualia-doctor.js +115 -1
- package/bin/state.js +102 -13
- package/bin/verify-panel.js +409 -0
- package/docs/onboarding.html +1 -1
- package/hooks/branch-guard.js +19 -5
- package/hooks/fawzi-approval-guard.js +16 -3
- package/hooks/hooks.json +60 -0
- package/hooks/migration-guard.js +143 -66
- package/hooks/session-start.js +27 -0
- package/package.json +3 -1
- package/skills/qualia/SKILL.md +26 -13
- package/skills/qualia-build/SKILL.md +20 -9
- package/skills/qualia-verify/SKILL.md +43 -5
- package/templates/instructions.md +2 -2
- package/tests/bin.test.sh +183 -0
- package/tests/hooks.test.sh +124 -0
- package/tests/install-smoke.test.sh +14 -0
- package/tests/instructions.test.sh +2 -2
- package/tests/lib.test.sh +149 -0
- package/tests/plugin-manifest.test.sh +168 -0
- package/tests/refs.test.sh +64 -0
- package/tests/run-all.sh +1 -0
- package/tests/state.test.sh +174 -0
- package/tests/verify-panel.test.sh +236 -0
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.
|
|
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}
|
|
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
|
-
|
|
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
|
|
540
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
},
|