qualia-framework 7.2.2 → 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 +42 -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 +102 -2
- 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 +20 -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
|
@@ -758,6 +758,76 @@ function installMemoryTimer() {
|
|
|
758
758
|
}
|
|
759
759
|
}
|
|
760
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
|
+
|
|
761
831
|
// ─── Main ────────────────────────────────────────────────
|
|
762
832
|
async function main() {
|
|
763
833
|
// Piped install: drain stdin once up front. Avoids EOF/'close' racing
|
|
@@ -811,6 +881,7 @@ async function main() {
|
|
|
811
881
|
// Codex-only path: skip the entire Claude install block. Jump straight
|
|
812
882
|
// to the Codex installer + final summary.
|
|
813
883
|
await installCodex(member, target, employeeMode);
|
|
884
|
+
try { linkBareCommand(CODEX_DIR); } catch (e) { warn(`bare command — ${e.message}`); }
|
|
814
885
|
return;
|
|
815
886
|
}
|
|
816
887
|
|
|
@@ -1126,6 +1197,17 @@ async function main() {
|
|
|
1126
1197
|
try { fs.chmodSync(out, 0o755); } catch {}
|
|
1127
1198
|
ok(script.label);
|
|
1128
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}`); }
|
|
1129
1211
|
// v6.8.1: purge retired bin scripts (same belt-and-suspenders as
|
|
1130
1212
|
// DEPRECATED_HOOKS — bin/ never had an orphan pass, so the brain
|
|
1131
1213
|
// experiment's indexer survived reinstalls).
|
|
@@ -1271,7 +1353,10 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1271
1353
|
code: code || "",
|
|
1272
1354
|
installed_by: member.name,
|
|
1273
1355
|
role: member.role,
|
|
1274
|
-
|
|
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,
|
|
1275
1360
|
installed_at: new Date().toISOString().split("T")[0],
|
|
1276
1361
|
erp: {
|
|
1277
1362
|
enabled: !employeeMode,
|
|
@@ -1451,6 +1536,9 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1451
1536
|
{ type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "⬢ Verifying Vercel account..." },
|
|
1452
1537
|
{ type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "⬢ Checking env value..." },
|
|
1453
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..." },
|
|
1454
1542
|
{ type: "command", if: "Bash(git commit*)", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
|
|
1455
1543
|
],
|
|
1456
1544
|
},
|
|
@@ -1630,6 +1718,9 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1630
1718
|
await installCodex(member, target, employeeMode);
|
|
1631
1719
|
}
|
|
1632
1720
|
|
|
1721
|
+
// ─── Bare command (PATH shim) ────────────────────────────
|
|
1722
|
+
try { linkBareCommand(CLAUDE_DIR); } catch (e) { warn(`bare command — ${e.message}`); }
|
|
1723
|
+
|
|
1633
1724
|
// ─── Summary ───────────────────────────────────────────
|
|
1634
1725
|
closeSection();
|
|
1635
1726
|
printSummary({ member, target, claudeInstalled: true });
|
|
@@ -1825,7 +1916,7 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1825
1916
|
code: Object.entries(TEAM).find(([, m]) => m.name === member.name)?.[0] || "",
|
|
1826
1917
|
installed_by: member.name,
|
|
1827
1918
|
role: member.role,
|
|
1828
|
-
version:
|
|
1919
|
+
version: PKG_VERSION, // single source — same constant as the marker + Claude config
|
|
1829
1920
|
installed_at: new Date().toISOString().split("T")[0],
|
|
1830
1921
|
erp: {
|
|
1831
1922
|
// Employee mode (no team code) leaves ERP off so /qualia-report
|
|
@@ -1872,6 +1963,12 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1872
1963
|
copy(src, out);
|
|
1873
1964
|
try { fs.chmodSync(out, 0o755); } catch {}
|
|
1874
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 {}
|
|
1875
1972
|
ok(`bin/ (${scripts.length} scripts)`);
|
|
1876
1973
|
} catch (e) {
|
|
1877
1974
|
warn(`Codex scripts — ${e.message}`);
|
|
@@ -2016,6 +2113,9 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
2016
2113
|
{ type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
|
|
2017
2114
|
{ type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
|
|
2018
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 },
|
|
2019
2119
|
{ type: "command", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
|
|
2020
2120
|
],
|
|
2021
2121
|
},
|