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