qualia-framework 6.14.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +316 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/bin/agent-status.js +24 -11
  6. package/bin/batch-plan.js +111 -0
  7. package/bin/branch-hygiene.js +135 -0
  8. package/bin/command-surface.js +2 -0
  9. package/bin/compile-instructions.js +82 -0
  10. package/bin/design-tokens.js +131 -0
  11. package/bin/erp-event.js +177 -0
  12. package/bin/erp-retry.js +12 -1
  13. package/bin/eval-runner.js +218 -0
  14. package/bin/host-adapters.js +84 -12
  15. package/bin/install.js +44 -13
  16. package/bin/knowledge-flush.js +6 -3
  17. package/bin/last-report.js +207 -0
  18. package/bin/project-sync.js +315 -0
  19. package/bin/recall.js +172 -0
  20. package/bin/repo-map.js +188 -0
  21. package/bin/runtime-manifest.js +12 -0
  22. package/bin/state.js +112 -1
  23. package/bin/vault-access.js +82 -0
  24. package/bin/verify-panel.js +294 -0
  25. package/bin/wave-plan.js +211 -0
  26. package/docs/erp-contract.md +180 -0
  27. package/mcp/memory-mcp/server.js +257 -0
  28. package/package.json +6 -3
  29. package/qualia-design/design-dials.md +72 -0
  30. package/qualia-design/design-reference.md +24 -0
  31. package/rules/access.md +42 -0
  32. package/rules/codex-goal.md +28 -26
  33. package/rules/infrastructure.md +1 -1
  34. package/skills/qualia/SKILL.md +6 -0
  35. package/skills/qualia-build/SKILL.md +43 -9
  36. package/skills/qualia-eval/SKILL.md +83 -0
  37. package/skills/qualia-feature/SKILL.md +20 -4
  38. package/skills/qualia-fix/SKILL.md +13 -1
  39. package/skills/qualia-map/SKILL.md +15 -0
  40. package/skills/qualia-milestone/SKILL.md +12 -6
  41. package/skills/qualia-new/REFERENCE.md +6 -4
  42. package/skills/qualia-new/SKILL.md +41 -15
  43. package/skills/qualia-plan/SKILL.md +2 -2
  44. package/skills/qualia-polish/SKILL.md +3 -2
  45. package/skills/qualia-recall/SKILL.md +76 -0
  46. package/skills/qualia-report/SKILL.md +10 -0
  47. package/skills/qualia-scope/SKILL.md +3 -3
  48. package/skills/qualia-ship/SKILL.md +34 -4
  49. package/skills/qualia-update/SKILL.md +4 -0
  50. package/skills/qualia-verify/SKILL.md +53 -24
  51. package/templates/DESIGN.md +15 -0
  52. package/templates/instructions.md +32 -0
  53. package/templates/journey.md +1 -1
  54. package/templates/project-discovery.md +30 -23
  55. package/templates/requirements.md +7 -7
  56. package/tests/agent-status.test.sh +15 -0
  57. package/tests/batch-plan.test.sh +56 -0
  58. package/tests/branch-hygiene.test.sh +93 -0
  59. package/tests/design-tokens.test.sh +53 -0
  60. package/tests/erp-event.test.sh +78 -0
  61. package/tests/eval-runner.test.sh +147 -0
  62. package/tests/instructions.test.sh +109 -0
  63. package/tests/last-report.test.sh +156 -0
  64. package/tests/lib.test.sh +29 -4
  65. package/tests/project-sync.test.sh +175 -0
  66. package/tests/recall.test.sh +91 -0
  67. package/tests/repo-map.test.sh +70 -0
  68. package/tests/run-all.sh +12 -0
  69. package/tests/runner.js +363 -33
  70. package/tests/state.test.sh +92 -0
  71. package/tests/verify-panel.test.sh +162 -0
  72. package/tests/wave-plan.test.sh +153 -0
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // branch-hygiene.js — the clock-out safety net for trunk integration.
3
+ //
4
+ // WHY: /qualia-ship integrates feature→main on every successful deploy, so the
5
+ // normal path leaves nothing open. But work that was BUILT and never SHIPPED
6
+ // strands on a local branch ahead of main, and the occasional review PR can
7
+ // linger. This surfaces both at /qualia-report so nothing rots silently.
8
+ //
9
+ // Detects:
10
+ // - local branches with commits ahead of main that aren't merged (stranded work)
11
+ // - open PRs (best-effort via `gh`; skipped silently when gh is absent/unauth)
12
+ //
13
+ // Read-only. Never blocks (report never blocks) — exit 0 = clean,
14
+ // 1 = stranded branches and/or stale PRs found, 2 = not a git repo.
15
+ // Zero npm dependencies.
16
+
17
+ const { spawnSync } = require("child_process");
18
+ const path = require("path");
19
+
20
+ function git(args, cwd) {
21
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
22
+ return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
23
+ }
24
+
25
+ function mainBranch(cwd) {
26
+ // Prefer an existing local main, else master, else the remote HEAD target.
27
+ for (const b of ["main", "master"]) {
28
+ if (git(["rev-parse", "--verify", "--quiet", b], cwd).ok) return b;
29
+ }
30
+ const head = git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], cwd);
31
+ if (head.ok && head.out) return head.out.replace(/^origin\//, "");
32
+ return "main";
33
+ }
34
+
35
+ function strandedBranches(cwd, base) {
36
+ const heads = git(["for-each-ref", "--format=%(refname:short)", "refs/heads/"], cwd);
37
+ if (!heads.ok) return [];
38
+ const out = [];
39
+ for (const b of heads.out.split(/\r?\n/).filter(Boolean)) {
40
+ if (b === base) continue;
41
+ // commits on b not in base — unmerged work.
42
+ const count = git(["rev-list", "--count", `${base}..${b}`], cwd);
43
+ const ahead = count.ok ? Number(count.out) : 0;
44
+ if (ahead > 0) {
45
+ const last = git(["log", "-1", "--format=%cI", b], cwd);
46
+ out.push({ branch: b, ahead, last_commit: last.ok ? last.out : null });
47
+ }
48
+ }
49
+ return out.sort((a, b) => b.ahead - a.ahead);
50
+ }
51
+
52
+ // Best-effort open-PR list via gh. Absent/unauth/non-GitHub → [] (never an error).
53
+ function openPRs(cwd, staleDays, nowIso) {
54
+ const r = spawnSync("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName,createdAt", "--limit", "100"],
55
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
56
+ if (r.status !== 0 || !r.stdout) return [];
57
+ let prs;
58
+ try { prs = JSON.parse(r.stdout); } catch { return []; }
59
+ const now = nowIso ? Date.parse(nowIso) : null;
60
+ return prs.map((p) => {
61
+ let ageDays = null;
62
+ if (now != null && p.createdAt) ageDays = Math.floor((now - Date.parse(p.createdAt)) / 86400000);
63
+ return { number: p.number, title: p.title, branch: p.headRefName, age_days: ageDays, stale: ageDays != null && ageDays >= staleDays };
64
+ });
65
+ }
66
+
67
+ function analyze(cwd, opts = {}) {
68
+ const root = path.resolve(cwd || process.cwd());
69
+ if (!git(["rev-parse", "--is-inside-work-tree"], root).ok) {
70
+ return { ok: false, error: "NOT_A_GIT_REPO" };
71
+ }
72
+ const base = mainBranch(root);
73
+ const stranded = strandedBranches(root, base);
74
+ const prs = openPRs(root, opts.staleDays || 7, opts.now);
75
+ const stalePrs = prs.filter((p) => p.stale);
76
+ return {
77
+ ok: stranded.length === 0 && stalePrs.length === 0,
78
+ base,
79
+ stranded,
80
+ open_prs: prs,
81
+ stale_prs: stalePrs,
82
+ };
83
+ }
84
+
85
+ // ── CLI ───────────────────────────────────────────────────────────────────
86
+ function parseArgs(argv) {
87
+ const args = {};
88
+ for (let i = 2; i < argv.length; i++) {
89
+ const a = argv[i];
90
+ if (a === "--json") args.json = true;
91
+ else if (a === "--cwd") args.cwd = argv[++i];
92
+ else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
93
+ else if (a === "--stale-days") args.staleDays = Number(argv[++i]);
94
+ else if (a.startsWith("--stale-days=")) args.staleDays = Number(a.slice(13));
95
+ else if (a === "--now") args.now = argv[++i]; // ISO; tests inject determinism
96
+ else if (a.startsWith("--now=")) args.now = a.slice(6);
97
+ }
98
+ return args;
99
+ }
100
+
101
+ function main(argv) {
102
+ const args = parseArgs(argv);
103
+ const result = analyze(args.cwd, { staleDays: args.staleDays, now: args.now });
104
+
105
+ if (args.json) {
106
+ console.log(JSON.stringify(result, null, 2));
107
+ return result.ok ? 0 : (result.error ? 2 : 1);
108
+ }
109
+
110
+ if (result.error === "NOT_A_GIT_REPO") {
111
+ console.error("branch-hygiene: not a git repository");
112
+ return 2;
113
+ }
114
+ if (result.ok) {
115
+ console.log(`Branch hygiene clean — no work stranded off ${result.base}, no stale PRs.`);
116
+ return 0;
117
+ }
118
+ if (result.stranded.length) {
119
+ console.log(`⚠ ${result.stranded.length} branch(es) with unshipped commits ahead of ${result.base}:`);
120
+ for (const s of result.stranded) {
121
+ console.log(` - ${s.branch} (+${s.ahead} commit${s.ahead === 1 ? "" : "s"}) — /qualia-ship it or merge, or delete if abandoned`);
122
+ }
123
+ }
124
+ if (result.stale_prs.length) {
125
+ console.log(`⚠ ${result.stale_prs.length} stale open PR(s):`);
126
+ for (const p of result.stale_prs) console.log(` - #${p.number} ${p.title} (${p.age_days}d, ${p.branch})`);
127
+ }
128
+ return 1;
129
+ }
130
+
131
+ module.exports = { analyze, strandedBranches, mainBranch };
132
+
133
+ if (require.main === module) {
134
+ process.exit(main(process.argv));
135
+ }
@@ -14,6 +14,7 @@ const ACTIVE_SKILLS = [
14
14
  "qualia-plan",
15
15
  "qualia-build",
16
16
  "qualia-verify",
17
+ "qualia-eval",
17
18
  "qualia-fix",
18
19
  "qualia-feature",
19
20
  "qualia-review",
@@ -28,6 +29,7 @@ const ACTIVE_SKILLS = [
28
29
  "qualia-doctor",
29
30
  "qualia-road",
30
31
  "qualia-learn",
32
+ "qualia-recall",
31
33
  "qualia-postmortem",
32
34
  "qualia-idk",
33
35
  "qualia-secure",
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // compile-instructions.js — single-source the agent-instruction files.
3
+ //
4
+ // CLAUDE.md and AGENTS.md used to be hand-maintained twins, which guarantees
5
+ // drift (and they HAD drifted — the MVP-first line and the substrate list
6
+ // differed). Now both are compiled from one canonical source,
7
+ // templates/instructions.md, via the host adapter. Editors touch the canonical;
8
+ // the per-host files are generated artifacts (committed, like a lockfile).
9
+ //
10
+ // node bin/compile-instructions.js # regenerate CLAUDE.md + AGENTS.md
11
+ // node bin/compile-instructions.js --check # fail (exit 1) if either is stale
12
+ //
13
+ // The --check mode is the drift guard the test suite runs: it makes "edited one
14
+ // twin, forgot the other" impossible to merge.
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const { adapter, compileInstructions } = require("./host-adapters.js");
19
+
20
+ const FRAMEWORK_DIR = path.resolve(__dirname, "..");
21
+ const CANONICAL = path.join(FRAMEWORK_DIR, "templates", "instructions.md");
22
+
23
+ const HEADER =
24
+ "<!-- GENERATED from templates/instructions.md by bin/compile-instructions.js — do not edit directly; edit the canonical source and recompile. -->";
25
+
26
+ // host → output filename, declared by the adapter (single source of per-host facts).
27
+ const TARGETS = ["claude", "codex"];
28
+
29
+ function expectedFor(canonical, host) {
30
+ return `${HEADER}\n\n${compileInstructions(canonical, host)}`;
31
+ }
32
+
33
+ function targets() {
34
+ return TARGETS.map((host) => ({
35
+ host,
36
+ file: adapter(host).instructionFile,
37
+ pathAbs: path.join(FRAMEWORK_DIR, adapter(host).instructionFile),
38
+ }));
39
+ }
40
+
41
+ function main(argv) {
42
+ const check = argv.includes("--check");
43
+ let canonical;
44
+ try {
45
+ canonical = fs.readFileSync(CANONICAL, "utf8");
46
+ } catch (e) {
47
+ console.error(`ERROR: cannot read canonical source ${CANONICAL}: ${e.message}`);
48
+ return 2;
49
+ }
50
+
51
+ const drift = [];
52
+ for (const t of targets()) {
53
+ const expected = expectedFor(canonical, t.host);
54
+ if (check) {
55
+ let actual = null;
56
+ try { actual = fs.readFileSync(t.pathAbs, "utf8"); } catch {}
57
+ if (actual !== expected) {
58
+ drift.push(t.file);
59
+ console.error(`DRIFT: ${t.file} is out of sync with templates/instructions.md`);
60
+ }
61
+ } else {
62
+ fs.writeFileSync(t.pathAbs, expected, "utf8");
63
+ console.log(`wrote ${t.file} (from templates/instructions.md, host=${t.host})`);
64
+ }
65
+ }
66
+
67
+ if (check) {
68
+ if (drift.length) {
69
+ console.error(`\n${drift.length} file(s) stale. Run: node bin/compile-instructions.js`);
70
+ return 1;
71
+ }
72
+ console.log("CLAUDE.md + AGENTS.md are in sync with templates/instructions.md");
73
+ return 0;
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ module.exports = { expectedFor, targets, HEADER, CANONICAL };
79
+
80
+ if (require.main === module) {
81
+ process.exit(main(process.argv));
82
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // design-tokens.js — the per-client design-token registry (R10).
3
+ //
4
+ // First-party brand data is the proven escape from AI design monoculture: ground
5
+ // every builder in a real design system (CSS custom properties) instead of
6
+ // letting each invent its own palette. This compiles a client's brand spec
7
+ // (design-tokens.json) into a tokens.css registry that the scaffold imports and
8
+ // every builder references via var(--…) — never a hardcoded hex (the
9
+ // ABS-HEX-IN-JSX slop ban). The shadcn-registry pattern, zero-dep.
10
+ //
11
+ // Usage:
12
+ // design-tokens.js init [--out FILE] # write a starter registry JSON
13
+ // design-tokens.js compile <tokens.json> [--out FILE] # JSON → tokens.css
14
+ // design-tokens.js compile <tokens.json> --json # emit the flat var map as JSON
15
+ //
16
+ // Exit: 0 ok, 2 bad invocation / unreadable / invalid JSON. Zero deps.
17
+
18
+ const fs = require("fs");
19
+
20
+ const STARTER = {
21
+ $schema: "qualia-design-tokens/1",
22
+ color: {
23
+ bg: "oklch(0.99 0.005 95)",
24
+ fg: "oklch(0.22 0.02 265)",
25
+ accent: "oklch(0.62 0.18 250)",
26
+ muted: "oklch(0.55 0.02 265)",
27
+ border: "oklch(0.9 0.01 265)",
28
+ },
29
+ font: {
30
+ sans: "Geist, ui-sans-serif, system-ui, sans-serif",
31
+ serif: "Fraunces, ui-serif, Georgia, serif",
32
+ mono: "Geist Mono, ui-monospace, monospace",
33
+ },
34
+ radius: { sm: "0.25rem", md: "0.5rem", lg: "1rem" },
35
+ space: { unit: "0.25rem" },
36
+ };
37
+
38
+ // Flatten { group: { key: val } } → { "group-key": val }. One level of nesting;
39
+ // scalar top-level keys (e.g. radius: "0.5rem") become bare vars.
40
+ function flatten(spec) {
41
+ const out = {};
42
+ for (const [group, val] of Object.entries(spec)) {
43
+ if (group.startsWith("$")) continue; // skip $schema etc.
44
+ if (val && typeof val === "object" && !Array.isArray(val)) {
45
+ for (const [key, v] of Object.entries(val)) {
46
+ out[`${group}-${key}`] = String(v);
47
+ }
48
+ } else {
49
+ out[group] = String(val);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function toCss(spec) {
56
+ const vars = flatten(spec);
57
+ const lines = Object.entries(vars).map(([k, v]) => ` --${k}: ${v};`);
58
+ return [
59
+ "/* GENERATED by design-tokens.js from design-tokens.json — the client brand registry.",
60
+ " Builders reference var(--…); never hardcode a hex (slop ban ABS-HEX-IN-JSX). */",
61
+ ":root {",
62
+ ...lines,
63
+ "}",
64
+ "",
65
+ ].join("\n");
66
+ }
67
+
68
+ function readJson(file) {
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(file, "utf8");
72
+ } catch {
73
+ process.stderr.write(`design-tokens.js: cannot read ${file}\n`);
74
+ process.exit(2);
75
+ }
76
+ try {
77
+ return JSON.parse(raw);
78
+ } catch (e) {
79
+ process.stderr.write(`design-tokens.js: invalid JSON in ${file}: ${e.message}\n`);
80
+ process.exit(2);
81
+ }
82
+ }
83
+
84
+ function parseOut(argv) {
85
+ const i = argv.indexOf("--out");
86
+ return i >= 0 ? argv[i + 1] : null;
87
+ }
88
+
89
+ function main() {
90
+ const argv = process.argv.slice(2);
91
+ const cmd = argv[0];
92
+
93
+ if (cmd === "init") {
94
+ const out = parseOut(argv);
95
+ const json = JSON.stringify(STARTER, null, 2) + "\n";
96
+ if (out) {
97
+ fs.writeFileSync(out, json);
98
+ console.log(`wrote starter registry → ${out}`);
99
+ } else {
100
+ process.stdout.write(json);
101
+ }
102
+ process.exit(0);
103
+ }
104
+
105
+ if (cmd === "compile") {
106
+ const file = argv[1];
107
+ if (!file || file.startsWith("--")) {
108
+ process.stderr.write("design-tokens.js compile <tokens.json> [--out FILE] [--json]\n");
109
+ process.exit(2);
110
+ }
111
+ const spec = readJson(file);
112
+ if (argv.includes("--json")) {
113
+ console.log(JSON.stringify(flatten(spec), null, 2));
114
+ process.exit(0);
115
+ }
116
+ const css = toCss(spec);
117
+ const out = parseOut(argv);
118
+ if (out) {
119
+ fs.writeFileSync(out, css);
120
+ console.log(`wrote token registry → ${out}`);
121
+ } else {
122
+ process.stdout.write(css);
123
+ }
124
+ process.exit(0);
125
+ }
126
+
127
+ process.stderr.write("design-tokens.js — per-client design-token registry\n\n init [--out FILE]\n compile <tokens.json> [--out FILE] [--json]\n");
128
+ process.exit(2);
129
+ }
130
+
131
+ main();
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ // erp-event.js — the framework EMIT side of the unified event log (R14).
3
+ //
4
+ // Builds a signed FrameworkEvent envelope and queues it for the ERP's
5
+ // POST /api/v1/events. The HMAC is keyed by the install's API token (the same
6
+ // qlt_ key used for reports), so the ERP — which holds only the token's hash but
7
+ // sees the plaintext Bearer per request — can verify body integrity + replay.
8
+ // Standard-Webhooks shape: id/timestamp/signature ride in headers, the event in
9
+ // the body. Non-blocking and graceful: ERP disabled or no key ⇒ a silent no-op.
10
+ //
11
+ // Mirrors lib/framework-events.ts on the ERP side — signingContent and the
12
+ // HMAC-SHA256/base64 must stay byte-identical or signatures won't verify.
13
+ //
14
+ // Usage:
15
+ // erp-event.js emit <action> [--target type:ref]... [--project <uuid>]
16
+ // [--meta k=v]... [--ctx k=v]... [--json] [--dry-run]
17
+ // # e.g. erp-event.js emit verify_pass --target project:acme --project <uuid>
18
+ //
19
+ // Exit: 0 = queued or skipped (never blocks a session), 2 = bad invocation.
20
+
21
+ const fs = require("fs");
22
+ const os = require("os");
23
+ const path = require("path");
24
+ const crypto = require("crypto");
25
+
26
+ function qualiaHome() {
27
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
28
+ const parent = path.basename(path.dirname(__dirname));
29
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
30
+ return path.join(os.homedir(), ".claude");
31
+ }
32
+ const QUALIA_HOME = qualiaHome();
33
+ const CONFIG_FILE = path.join(QUALIA_HOME, ".qualia-config.json");
34
+ const API_KEY_FILE = path.join(QUALIA_HOME, ".erp-api-key");
35
+
36
+ function readConfig() {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+ function readApiKey() {
44
+ try {
45
+ return fs.readFileSync(API_KEY_FILE, "utf8").trim();
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ // The exact bytes the ERP re-signs: `${id}.${timestamp}.${rawBody}`. rawBody is
52
+ // the EXACT payload string we queue — erp-retry posts it verbatim.
53
+ function signingContent(id, timestamp, rawBody) {
54
+ return `${id}.${timestamp}.${rawBody}`;
55
+ }
56
+ function computeSignature(content, secret) {
57
+ return crypto.createHmac("sha256", secret).update(content).digest("base64");
58
+ }
59
+
60
+ // Build (and optionally sign) the envelope. Pure; injectable id/now for tests.
61
+ function buildSignedEvent(action, opts = {}) {
62
+ const cfg = opts.config || readConfig();
63
+ const apiKey = opts.apiKey != null ? opts.apiKey : readApiKey();
64
+ const id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
65
+ const ts = String(opts.now != null ? opts.now : Math.floor(Date.now() / 1000));
66
+
67
+ const event = {
68
+ action,
69
+ actor: opts.actor || {
70
+ code: cfg.code,
71
+ name: cfg.installed_by,
72
+ role: cfg.role,
73
+ },
74
+ ...(opts.targets && opts.targets.length ? { targets: opts.targets } : {}),
75
+ ...(opts.context ? { context: opts.context } : {}),
76
+ ...(opts.metadata ? { metadata: opts.metadata } : {}),
77
+ occurred_at: opts.occurredAt || new Date(Number(ts) * 1000).toISOString(),
78
+ ...(opts.erpProjectId ? { erp_project_id: opts.erpProjectId } : {}),
79
+ ...(opts.workspaceId ? { workspace_id: opts.workspaceId } : {}),
80
+ };
81
+
82
+ const payload = JSON.stringify(event);
83
+ const headers = { "Qualia-Event-Id": id, "Qualia-Event-Timestamp": ts };
84
+ // Sign only when we have a key — unsigned posts still authenticate via Bearer
85
+ // and the ERP records them signature_valid=false.
86
+ if (apiKey) headers["Qualia-Signature"] = computeSignature(signingContent(id, ts, payload), apiKey);
87
+
88
+ return { id, ts, event, payload, headers };
89
+ }
90
+
91
+ // In-process emit (hooks/skills can require this). Returns a result object;
92
+ // never throws on ERP/network conditions.
93
+ function emitEvent(action, opts = {}) {
94
+ const cfg = opts.config || readConfig();
95
+ if (cfg.erp && cfg.erp.enabled === false) return { ok: true, skipped: "erp-disabled" };
96
+ const erpUrl = (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net";
97
+ const built = buildSignedEvent(action, { ...opts, config: cfg });
98
+ if (opts.dryRun) return { ok: true, dryRun: true, ...built };
99
+
100
+ try {
101
+ const retryPath = path.join(QUALIA_HOME, "bin", "erp-retry.js");
102
+ const enqueue = fs.existsSync(retryPath)
103
+ ? require(retryPath).enqueue
104
+ : require("./erp-retry.js").enqueue;
105
+ enqueue({
106
+ client_report_id: built.id, // event_id is unique → no false dedupe
107
+ idempotency_key: built.id,
108
+ url: `${erpUrl.replace(/\/$/, "")}/api/v1/events`,
109
+ payload: built.payload,
110
+ headers: built.headers,
111
+ });
112
+ return { ok: true, queued: true, ...built };
113
+ } catch (e) {
114
+ return { ok: false, error: e && e.message ? e.message : String(e), ...built };
115
+ }
116
+ }
117
+
118
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
119
+ function parseKv(list) {
120
+ const out = {};
121
+ for (const item of list) {
122
+ const i = item.indexOf("=");
123
+ if (i > 0) out[item.slice(0, i)] = item.slice(i + 1);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function main() {
129
+ const argv = process.argv.slice(2);
130
+ if (argv[0] !== "emit") {
131
+ process.stderr.write("erp-event.js emit <action> [--target type:ref] [--project uuid] [--meta k=v] [--ctx k=v] [--json] [--dry-run]\n");
132
+ process.exit(2);
133
+ }
134
+ const action = argv[1];
135
+ if (!action || action.startsWith("--")) {
136
+ process.stderr.write("erp-event.js: <action> is required (e.g. verify_pass)\n");
137
+ process.exit(2);
138
+ }
139
+ const targets = [];
140
+ const metaKv = [];
141
+ const ctxKv = [];
142
+ const opts = { json: false, dryRun: false };
143
+ for (let i = 2; i < argv.length; i++) {
144
+ const a = argv[i];
145
+ if (a === "--target") {
146
+ const t = argv[++i] || "";
147
+ const c = t.indexOf(":");
148
+ targets.push(c > 0 ? { type: t.slice(0, c), ref: t.slice(c + 1) } : { ref: t });
149
+ } else if (a === "--project") opts.erpProjectId = argv[++i];
150
+ else if (a === "--workspace") opts.workspaceId = argv[++i];
151
+ else if (a === "--meta") metaKv.push(argv[++i] || "");
152
+ else if (a === "--ctx") ctxKv.push(argv[++i] || "");
153
+ else if (a === "--json") opts.json = true;
154
+ else if (a === "--dry-run") opts.dryRun = true;
155
+ }
156
+ if (targets.length) opts.targets = targets;
157
+ if (metaKv.length) opts.metadata = parseKv(metaKv);
158
+ if (ctxKv.length) opts.context = parseKv(ctxKv);
159
+
160
+ const result = emitEvent(action, opts);
161
+ if (opts.json) {
162
+ console.log(JSON.stringify(result, null, 2));
163
+ } else if (result.skipped) {
164
+ console.log(`erp-event: skipped (${result.skipped})`);
165
+ } else if (result.dryRun) {
166
+ console.log(`erp-event: [dry-run] ${action} id=${result.id} signed=${!!result.headers["Qualia-Signature"]}`);
167
+ } else if (result.queued) {
168
+ console.log(`⬢ event queued: ${action} (${result.id})`);
169
+ } else if (!result.ok) {
170
+ console.error(`erp-event: ${result.error}`);
171
+ }
172
+ process.exit(0);
173
+ }
174
+
175
+ module.exports = { emitEvent, buildSignedEvent, signingContent, computeSignature };
176
+
177
+ if (require.main === module) main();
package/bin/erp-retry.js CHANGED
@@ -101,7 +101,7 @@ function writeQueue(data) {
101
101
  fs.renameSync(tmp, QUEUE_FILE);
102
102
  }
103
103
 
104
- function enqueue({ client_report_id, idempotency_key, url, payload, last_error }) {
104
+ function enqueue({ client_report_id, idempotency_key, url, payload, last_error, headers }) {
105
105
  if (!client_report_id || !url || !payload) {
106
106
  throw new Error("enqueue: client_report_id, url, payload are required");
107
107
  }
@@ -113,6 +113,7 @@ function enqueue({ client_report_id, idempotency_key, url, payload, last_error }
113
113
  existing.idempotency_key = idempotency_key || existing.idempotency_key;
114
114
  existing.url = url;
115
115
  existing.payload = payload;
116
+ if (headers) existing.headers = headers;
116
117
  existing.last_error = last_error || existing.last_error || "";
117
118
  existing.attempts = existing.attempts || 0;
118
119
  existing.give_up = false; // unblock a retry if user fixed the underlying issue
@@ -123,6 +124,7 @@ function enqueue({ client_report_id, idempotency_key, url, payload, last_error }
123
124
  idempotency_key: idempotency_key || "",
124
125
  url,
125
126
  payload,
127
+ ...(headers ? { headers } : {}),
126
128
  enqueued_at: new Date().toISOString(),
127
129
  attempts: 0,
128
130
  last_error: last_error || "",
@@ -146,6 +148,15 @@ function postOnce(item, apiKey) {
146
148
  "Content-Length": Buffer.byteLength(item.payload),
147
149
  };
148
150
  if (item.idempotency_key) headers["Idempotency-Key"] = item.idempotency_key;
151
+ // Per-item custom headers (e.g. the signed-event envelope:
152
+ // Qualia-Event-Id / Qualia-Event-Timestamp / Qualia-Signature). Auth +
153
+ // Content-* are framework-owned and not overridable.
154
+ if (item.headers && typeof item.headers === "object") {
155
+ for (const [k, v] of Object.entries(item.headers)) {
156
+ if (/^(authorization|content-type|content-length)$/i.test(k)) continue;
157
+ headers[k] = String(v);
158
+ }
159
+ }
149
160
  const req = lib.request({
150
161
  method: "POST",
151
162
  hostname: u.hostname,