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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +316 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/bin/agent-status.js +24 -11
- package/bin/batch-plan.js +111 -0
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +2 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/design-tokens.js +131 -0
- package/bin/erp-event.js +177 -0
- package/bin/erp-retry.js +12 -1
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +84 -12
- package/bin/install.js +44 -13
- package/bin/knowledge-flush.js +6 -3
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/recall.js +172 -0
- package/bin/repo-map.js +188 -0
- package/bin/runtime-manifest.js +12 -0
- package/bin/state.js +112 -1
- package/bin/vault-access.js +82 -0
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/erp-contract.md +180 -0
- package/mcp/memory-mcp/server.js +257 -0
- package/package.json +6 -3
- package/qualia-design/design-dials.md +72 -0
- package/qualia-design/design-reference.md +24 -0
- package/rules/access.md +42 -0
- package/rules/codex-goal.md +28 -26
- package/rules/infrastructure.md +1 -1
- package/skills/qualia/SKILL.md +6 -0
- package/skills/qualia-build/SKILL.md +43 -9
- package/skills/qualia-eval/SKILL.md +83 -0
- package/skills/qualia-feature/SKILL.md +20 -4
- package/skills/qualia-fix/SKILL.md +13 -1
- package/skills/qualia-map/SKILL.md +15 -0
- package/skills/qualia-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +41 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-polish/SKILL.md +3 -2
- package/skills/qualia-recall/SKILL.md +76 -0
- package/skills/qualia-report/SKILL.md +10 -0
- package/skills/qualia-scope/SKILL.md +3 -3
- package/skills/qualia-ship/SKILL.md +34 -4
- package/skills/qualia-update/SKILL.md +4 -0
- package/skills/qualia-verify/SKILL.md +53 -24
- package/templates/DESIGN.md +15 -0
- package/templates/instructions.md +32 -0
- package/templates/journey.md +1 -1
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +15 -0
- package/tests/batch-plan.test.sh +56 -0
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/design-tokens.test.sh +53 -0
- package/tests/erp-event.test.sh +78 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +29 -4
- package/tests/project-sync.test.sh +175 -0
- package/tests/recall.test.sh +91 -0
- package/tests/repo-map.test.sh +70 -0
- package/tests/run-all.sh +12 -0
- package/tests/runner.js +363 -33
- package/tests/state.test.sh +92 -0
- package/tests/verify-panel.test.sh +162 -0
- 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
|
+
}
|
package/bin/command-surface.js
CHANGED
|
@@ -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();
|
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,
|