qualia-framework 6.22.0 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,204 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  > Note: git tags for historical versions were not retained; commit references are approximate
9
9
  > and dates reflect commit history rather than npm publish timestamps.
10
10
 
11
+ ## [7.0.1] - 2026-06-22 (/qualia-recall is OWNER-only)
12
+
13
+ ### Changed — `/qualia-recall` restricted to OWNER
14
+ - `recall.js` now resolves the install role up front and **refuses any role other
15
+ than `OWNER`** (exit 3, clear message) — a deterministic gate, not a prose note.
16
+ Previously the command was available to everyone and only the *vault content*
17
+ was role-filtered. Employees keep `/qualia-learn` (write side) and the read-only
18
+ memory MCP for ALL_ROLES wiki content; the curated cross-project recall is the
19
+ OWNER's surface. Skill description + body mark it OWNER-only; the employee
20
+ manual drops it from the command reference. `tests/recall.test.sh` now asserts
21
+ OWNER→allowed, EMPLOYEE/MANAGER/unknown→refused (20 cases). All 24 suites green.
22
+
23
+ ## [7.0.0] - 2026-06-22 (v7 — the restructure is complete)
24
+
25
+ The v7 milestone release. The 6.12→6.29 line landed the whole v7 restructure
26
+ brief; this marks it shipped. Highlights across the arc:
27
+
28
+ - **v7.0 lifecycle** — build→operate, forced-handoff removed; the three missing
29
+ runtime primitives wired as deterministic gates: R1 file-disjointness pre-write
30
+ hook, R2 `.agent-status` fan-in barrier, R3 cross-artifact `/analyze` gate.
31
+ - **v7.1 memory** — `/qualia-recall` (read side of `/qualia-learn`), a single
32
+ shared vault access-control module honoring `wiki/_meta/access.md` (enforced by
33
+ both recall and the read-only memory MCP), and `bin/repo-map.js` (R18).
34
+ - **v7.2 verification** — the framework's OWN test surface is now gated in CI
35
+ (R6: `runner.js` joined `npm test`); R7 `/qualia-eval`, R8 verifier panel, R19
36
+ voice DoD confirmed.
37
+ - **v7.3 codex-adapter** — CLAUDE.md + AGENTS.md from one source (R4); every
38
+ runtime-behavior branch routed through `host-adapters.js` (R5).
39
+ - **Design + scale** — tunable design dials + ban→do-instead pre-flight (R9),
40
+ per-client token registry (R10), `/qualia-build --batch` migration lane (R20).
41
+ - **ERP event integration (R14/R12)** — one signed, append-only `framework_events`
42
+ log: the framework signs and emits lifecycle events; the ERP verifies (HMAC
43
+ byte-matched across repos) and renders a live run-tree. Live in production.
44
+
45
+ Test surface at release: 24 shell suites + 179 cross-platform node tests, both
46
+ gated by `npm test`. No functional change in this bump — it tags the milestone.
47
+
48
+ ## [6.29.0] - 2026-06-22 (R14 client — the framework now emits signed events to the ERP)
49
+
50
+ The ERP side of R14 (the `framework_events` table + `/api/v1/events` +
51
+ `/api/v1/policy-events`) shipped in the `qualia-erp` repo and is live in
52
+ production. This is the framework EMIT side that closes the loop.
53
+
54
+ ### Added — `bin/erp-event.js`: signed lifecycle-event emitter
55
+ - Builds a `FrameworkEvent` envelope and queues it for `POST /api/v1/events`.
56
+ HMAC-SHA256 keyed by the install's `qlt_` token over `${id}.${timestamp}.${body}`
57
+ — **byte-identical** to the ERP's `lib/framework-events.ts` verifier (tested as
58
+ a cross-repo contract), so signatures verify with no extra secret. Headers
59
+ carry id/timestamp/signature (Standard-Webhooks shape); the event is the body.
60
+ - Non-blocking + graceful: ERP disabled or no API key ⇒ silent no-op; unsigned
61
+ posts still authenticate via Bearer (recorded `signature_valid=false`).
62
+ `emit <action> [--target type:ref] [--project uuid] [--meta k=v] [--json]
63
+ [--dry-run]`, plus an `emitEvent()` export for in-process callers.
64
+
65
+ ### Changed
66
+ - `bin/erp-retry.js`: the retry queue now carries optional per-item `headers`
67
+ (so the signed-event envelope rides the existing idempotent retry rails);
68
+ Auth/Content-* stay framework-owned and non-overridable.
69
+ - `/qualia-verify`: emits `verify_pass` / `verify_fail` after the state
70
+ transition — the first live lifecycle signal into the ERP run-tree. Other
71
+ points (`session_started`, `phase_planned`, `build_wave_started`) documented.
72
+ - `docs/erp-contract.md`: new "POST /api/v1/events" section (envelope, headers,
73
+ signing).
74
+ - `tests/erp-event.test.sh` (14 cases incl. the cross-repo signature match);
75
+ run-all now **24 suites**, all green (+179 node).
76
+
77
+ ## [6.28.0] - 2026-06-22 (R20 — the migration lane for /qualia-build)
78
+
79
+ ### Added — `bin/batch-plan.js` + `/qualia-build --batch`
80
+ - The wave model fits a phase; it doesn't fit a 50–500-file codemod. `--batch` is
81
+ a map-reduce over a flat file list: `batch-plan.js` splits it into
82
+ FILE-DISJOINT batches (each a worktree-isolated worker with tiny context that
83
+ opens its own commit) grouped into concurrency-capped waves, fanned in through
84
+ ONE staging branch. Deterministic split (dedup, complete, disjoint, waves ≤
85
+ max-workers); the orchestration lives in the skill.
86
+ - `--from FILE`/stdin, `--batch-size`, `--max-workers`, `--staging`, `--json`.
87
+ Zero-dep. `/qualia-build --batch` documents the full orchestration (staging
88
+ branch → per-wave worktree workers → fan-in → test once → ship), including
89
+ "log any dropped batch — no silent partial migrations."
90
+ - `tests/batch-plan.test.sh` (15); run-all now **23 suites**, all green (+179 node).
91
+
92
+ ## [6.27.0] - 2026-06-22 (R9 + R10 — design dials, ban→do-instead pre-flight, per-client token registry)
93
+
94
+ Two framework-area design enhancements: steer generation away from slop upfront,
95
+ and ground every builder in a real per-client design system.
96
+
97
+ ### Added — R9: tunable design dials + generation-time pre-flight
98
+ - `qualia-design/design-dials.md`: DESIGN_VARIANCE / MOTION_INTENSITY /
99
+ VISUAL_DENSITY (LOW|MED|HIGH) with concrete mappings + register/project-type
100
+ gating, plus a ban→do-instead table mirroring slop-detect's rules WITH the
101
+ positive alternative (avoids the pink-elephant backfire). The generation-time
102
+ half of the anti-slop contract; slop-detect remains the post-hoc gate.
103
+ - `templates/DESIGN.md` §0 declares the dials; `/qualia-polish` Stage 0 resolves
104
+ them and cites the bans before editing.
105
+
106
+ ### Added — R10: per-client design-token registry
107
+ - `bin/design-tokens.js`: compiles a client brand spec (`design-tokens.json`)
108
+ into a CSS-variable registry (`tokens.css`). `/qualia-new` scaffolds it;
109
+ builders reference `var(--…)`, never a literal hex. The shadcn-registry
110
+ pattern — first-party brand data is the escape from design monoculture.
111
+ - `tests/design-tokens.test.sh` (14) + the dials contract test in lib.test.sh;
112
+ run-all now **22 suites**, all green (+ 179 node).
113
+
114
+ ## [6.26.0] - 2026-06-22 (R18 — a cheap symbol map for brownfield onboarding)
115
+
116
+ ### Added — `bin/repo-map.js`, wired into `/qualia-map`
117
+ - Aider's repo-map idea, in the framework's zero-dep idiom: a deterministic
118
+ symbol map of a codebase (top-level exports/functions/classes/types per file,
119
+ busiest files first) via language-aware regexes for JS/TS, Python, Go, Rust,
120
+ Ruby, Java, PHP. NO tree-sitter native dependency — the brief named tree-sitter
121
+ but that fights the framework's zero-dep posture; regex extraction gets the
122
+ "what's in this repo and where" signal at zero cost.
123
+ - `/qualia-map` now runs it BEFORE the mapper agents (`repo-map.json`), so they
124
+ onboard from a structural index instead of reading the tree blind — cheaper,
125
+ better-grounded brownfield scans. `--json`, `--max-files`; skips
126
+ node_modules/dist/.git/etc.; exit 2 on bad dir. Registered in
127
+ runtime-manifest + lib install set; `tests/repo-map.test.sh` (14 cases).
128
+ run-all now **21 suites**.
129
+
130
+ ## [6.25.0] - 2026-06-21 (v7.3 codex-adapter — the last runtime branch moves into the seam)
131
+
132
+ v7.3 is complete. R4 (emit AGENTS.md alongside CLAUDE.md from one canonical
133
+ source) was already shipped via `bin/compile-instructions.js` — both files
134
+ compile from `templates/instructions.md` through the host adapter, with a
135
+ `--check` drift guard gated by the `instructions` suite. This release finishes R5.
136
+
137
+ ### Changed — R5: agent-CLI invocation is now an adapter fact (no more stray runtime branch)
138
+ - `host-adapters.js` was already the real seam (declarative `HOSTS`, per-host
139
+ paths/naming/instruction-file). The one genuine `if (codex)` behavior branch
140
+ still living outside it was in `knowledge-flush.js`: it hardcoded
141
+ `codex`/`claude` as the CLI and `exec`/`-p` as the invocation. Those are
142
+ per-host FACTS — moved into the adapter: `agentCli` + `agentExecPrefix`
143
+ (declarative) → `adapter().agentExec(prompt)` (computed argv), plus
144
+ `hostForHome(home)` so the home→host mapping has one owner.
145
+ - `knowledge-flush.js` now asks the adapter instead of branching. When a third
146
+ runtime arrives it's one `HOSTS` entry, not a grep-and-patch. lib.test.sh
147
+ covers the new facts.
148
+ - Left intentionally: the `qualiaHome()` install-root idiom repeated across
149
+ bins/hooks treats both hosts IDENTICALLY (it's home resolution, not a behavior
150
+ branch) — sweeping 20 files for a no-op-difference helper is high-risk,
151
+ low-value, and would touch hot paths. Noted, not done.
152
+
153
+ ## [6.24.0] - 2026-06-21 (v7.2 verification — gate the framework's own test surface)
154
+
155
+ v7.2 verification is complete. R7 (`/qualia-eval` lane), R8 (verify-panel +
156
+ adversarial skeptics), and R19 (voice-agent archetype DoD) shipped earlier and
157
+ are confirmed green. This release closes R6.
158
+
159
+ ### Fixed — R6: the framework's own checks can no longer silently regress
160
+ - The cross-platform `tests/runner.js` harness (179 node:test cases, incl. the
161
+ memory-MCP suite) was **never run by `npm test`** — only the shell suite was.
162
+ That is exactly why it silently drifted to 21 failures: nothing ran it. Now
163
+ `npm test` runs BOTH (`test:shell && test:node`), so CI (`run: npm test`)
164
+ gates the full surface — 20 shell suites + 179 node tests. Added a `test:node`
165
+ script. Verified CI-safe (179/179 with an empty HOME, no install required).
166
+ - This is R6's real intent for a deterministic/offline framework: a machine gate
167
+ on the framework's OWN checks. The LLM-prompt-level eval of skills is out of
168
+ scope for offline CI; `/qualia-eval` (R7) already provides that lane for the AI
169
+ artifacts projects build.
170
+
171
+ ## [6.23.0] - 2026-06-21 (v7.1 memory loop — recall, vault access control, harness health)
172
+
173
+ Completes the v7.1 memory work: the read side now exists, the vault has real
174
+ access control, and the cross-platform test harness is green again.
175
+
176
+ ### Added — `/qualia-recall` + `bin/recall.js`: the read side of memory
177
+ - Kills the `/qualia-recall` ghost (referenced in `agents/researcher.md` and
178
+ `skills/qualia-research` but never built). `recall.js` unifies BOTH stores in
179
+ one ranked digest: the knowledge layer (delegates to `knowledge.js` so new
180
+ files stay discoverable) + the qualia-memory vault (grep over `wiki/`).
181
+ `--json`, `--scope all|knowledge|vault`, `--max`. Zero deps. Registered in
182
+ `command-surface` ACTIVE_SKILLS + `runtime-manifest`.
183
+
184
+ ### Added — vault access control (`bin/vault-access.js`, `rules/access.md`)
185
+ - The vault's `wiki/_meta/access.md` manifest named `/qualia-recall` as an
186
+ enforcer, referenced an enforcing rule at `~/.claude/rules/access.md`, and
187
+ expected readers to honor OWNER_ONLY / CONDITIONAL paths — but **none of those
188
+ existed**. Now: `vault-access.js` is the single implementation (parse manifest,
189
+ resolve role, deny non-OWNER reads of OWNER_ONLY/CONDITIONAL paths,
190
+ fail-closed on unknown role); `rules/access.md` is the always-on enforcing
191
+ rule; **both** `recall.js` and the always-on memory MCP enforce it through that
192
+ one module so the security rule can't drift. No-manifest ⇒ no filtering (the
193
+ wiki is curated-by-design; sensitive stores live outside `wiki/`).
194
+
195
+ ### Fixed — test harness health
196
+ - `tests/runner.js` (cross-platform node:test harness) had drifted to 154/21;
197
+ re-synced to current bin/hook contracts (MISSING_CONTRACT/evidence,
198
+ SUSPICIOUS_NAME, set-erp-key stdin, branch-guard v6.10 allowed+recorded) with
199
+ no assertions weakened. Now **179**, with the new memory-MCP access cases.
200
+ - `tests/recall.test.sh` (18 cases) added; `run-all` now **20 suites**, all green.
201
+
202
+ ### Fixed — vault docs (qualia-memory repo)
203
+ - `CLAUDE.md` named a non-existent `flush.py` for session capture (the real
204
+ mechanism is the claude-memory-compiler SessionEnd hook) and carried a stale
205
+ `/home/qualia-new/` path. Corrected. No framework write-back leg was built:
206
+ the vault's session loop is external and already exists; the framework's
207
+ knowledge tier is separate by design.
208
+
11
209
  ## [6.22.0] - 2026-06-21 (session continuity + ERP project-sync — built by two parallel worktree agents)
12
210
 
13
211
  Two independent continuity features, built concurrently in isolated git worktrees and integrated together.
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // batch-plan.js — the deterministic core of /qualia-build --batch (R20).
3
+ //
4
+ // The wave model fits feature builds (a handful of dependency-linked tasks); it
5
+ // does NOT fit a 50–500-file mechanical migration (a codemod). This splits a
6
+ // flat file list into worktree-isolated, FILE-DISJOINT batches — each a small
7
+ // worker with tiny context that opens its own commit — then groups them into
8
+ // concurrency-capped waves that fan in through one staging branch. Map-reduce
9
+ // over files. Zero-dep; the orchestration (spawning workers, merging) lives in
10
+ // the /qualia-build skill — this owns the split so it's deterministic + testable.
11
+ //
12
+ // Usage:
13
+ // batch-plan.js <file> [<file> …] # files as args
14
+ // batch-plan.js --from LIST # newline-delimited file (- = stdin)
15
+ // … --batch-size N (default 20) --max-workers K (default 5)
16
+ // --staging BRANCH (default batch/staging) --json
17
+ //
18
+ // Exit: 0 ok (even 0 files), 2 bad invocation. Zero deps.
19
+
20
+ const fs = require("fs");
21
+
22
+ function parseArgs(argv) {
23
+ const o = { files: [], batchSize: 20, maxWorkers: 5, staging: "batch/staging", json: false, from: null };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === "--batch-size") o.batchSize = Math.max(1, parseInt(argv[++i], 10) || 20);
27
+ else if (a === "--max-workers") o.maxWorkers = Math.max(1, parseInt(argv[++i], 10) || 5);
28
+ else if (a === "--staging") o.staging = argv[++i] || o.staging;
29
+ else if (a === "--from") o.from = argv[++i];
30
+ else if (a === "--json") o.json = true;
31
+ else if (a === "-h" || a === "--help") o.help = true;
32
+ else o.files.push(a);
33
+ }
34
+ return o;
35
+ }
36
+
37
+ function chunk(arr, size) {
38
+ const out = [];
39
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
40
+ return out;
41
+ }
42
+
43
+ function readList(from) {
44
+ let raw;
45
+ if (from === "-") {
46
+ try { raw = fs.readFileSync(0, "utf8"); } catch { raw = ""; }
47
+ } else {
48
+ try { raw = fs.readFileSync(from, "utf8"); } catch {
49
+ process.stderr.write(`batch-plan.js: cannot read ${from}\n`);
50
+ process.exit(2);
51
+ }
52
+ }
53
+ return raw.split("\n").map((s) => s.trim()).filter(Boolean);
54
+ }
55
+
56
+ function buildPlan(files, opts) {
57
+ // De-dup while preserving order — a file must land in exactly one batch.
58
+ const seen = new Set();
59
+ const unique = [];
60
+ for (const f of files) {
61
+ if (!seen.has(f)) { seen.add(f); unique.push(f); }
62
+ }
63
+ const groups = chunk(unique, opts.batchSize);
64
+ const pad = String(groups.length).length;
65
+ const batches = groups.map((batchFiles, i) => {
66
+ const id = String(i + 1).padStart(pad, "0");
67
+ return { id, branch: `batch/${opts.staging.replace(/^batch\//, "")}-${id}`, file_count: batchFiles.length, files: batchFiles };
68
+ });
69
+ // Batches are file-disjoint → all parallelizable; cap concurrency into waves.
70
+ const waves = chunk(batches.map((b) => b.id), opts.maxWorkers);
71
+ return {
72
+ total_files: unique.length,
73
+ batch_size: opts.batchSize,
74
+ max_workers: opts.maxWorkers,
75
+ batch_count: batches.length,
76
+ wave_count: waves.length,
77
+ staging_branch: opts.staging,
78
+ waves,
79
+ batches,
80
+ };
81
+ }
82
+
83
+ function main() {
84
+ const opts = parseArgs(process.argv.slice(2));
85
+ if (opts.help) {
86
+ process.stdout.write("batch-plan.js <file…> | --from LIST [--batch-size N] [--max-workers K] [--staging B] [--json]\n");
87
+ process.exit(0);
88
+ }
89
+ let files = opts.files;
90
+ if (opts.from) files = files.concat(readList(opts.from));
91
+ const plan = buildPlan(files, opts);
92
+
93
+ if (opts.json) {
94
+ console.log(JSON.stringify(plan, null, 2));
95
+ process.exit(0);
96
+ }
97
+
98
+ console.log(`batch-plan: ${plan.total_files} files → ${plan.batch_count} batches (≤${plan.batch_size} each) → ${plan.wave_count} waves (≤${plan.max_workers} workers)`);
99
+ console.log(`staging branch: ${plan.staging_branch}`);
100
+ for (let w = 0; w < plan.waves.length; w++) {
101
+ console.log(`\nwave ${w + 1}: ${plan.waves[w].join(", ")}`);
102
+ for (const id of plan.waves[w]) {
103
+ const b = plan.batches.find((x) => x.id === id);
104
+ console.log(` batch ${id} → ${b.branch} (${b.file_count} files)`);
105
+ }
106
+ }
107
+ if (plan.batch_count === 0) console.log("(no files — nothing to migrate)");
108
+ process.exit(0);
109
+ }
110
+
111
+ main();
@@ -29,6 +29,7 @@ const ACTIVE_SKILLS = [
29
29
  "qualia-doctor",
30
30
  "qualia-road",
31
31
  "qualia-learn",
32
+ "qualia-recall",
32
33
  "qualia-postmortem",
33
34
  "qualia-idk",
34
35
  "qualia-secure",
@@ -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,