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.
@@ -27,6 +27,8 @@ const HOSTS = {
27
27
  configFile: "settings.json",
28
28
  agentDir: "agents",
29
29
  agentExt: ".md",
30
+ agentCli: "claude", // the CLI that runs a non-interactive turn for this host
31
+ agentExecPrefix: ["-p"], // `claude -p "<prompt>"`
30
32
  naming: {}, // Claude is the canonical voice — no swaps.
31
33
  },
32
34
  codex: {
@@ -36,11 +38,18 @@ const HOSTS = {
36
38
  configFile: "config.toml",
37
39
  agentDir: "agents",
38
40
  agentExt: ".toml",
39
- // Canonical source speaks "Claude Code"; Codex artifacts speak "Codex".
41
+ agentCli: "codex",
42
+ agentExecPrefix: ["exec"], // `codex exec "<prompt>"`
40
43
  naming: { "Claude Code": "Codex", "Claude's": "Codex's" },
41
44
  },
42
45
  };
43
46
 
47
+ // Which host owns this install home? (".codex" → codex, else claude.) The one
48
+ // place that maps a home dir back to a host name — callers ask, never re-derive.
49
+ function hostForHome(home) {
50
+ return path.basename(home) === ".codex" ? "codex" : "claude";
51
+ }
52
+
44
53
  function adapter(name) {
45
54
  const host = HOSTS[name];
46
55
  if (!host) throw new Error(`Unknown Qualia host adapter: ${name}`);
@@ -50,6 +59,8 @@ function adapter(name) {
50
59
  instructionPath: path.join(home, host.instructionFile),
51
60
  configPath: path.join(home, host.configFile),
52
61
  agentPath: path.join(home, host.agentDir),
62
+ // Full argv (minus the binary) to run one non-interactive turn on this host.
63
+ agentExec: (prompt) => [...host.agentExecPrefix, prompt],
53
64
  tokens: {
54
65
  QUALIA_HOME: home,
55
66
  QUALIA_BIN: `${home}/bin`,
@@ -118,6 +129,7 @@ function compileInstructions(canonical, hostName) {
118
129
  module.exports = {
119
130
  HOSTS,
120
131
  adapter,
132
+ hostForHome,
121
133
  applyNaming,
122
134
  applyPaths,
123
135
  renderText,
package/bin/install.js CHANGED
@@ -1442,6 +1442,29 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1442
1442
  ok("MCP: next-devtools (runtime error visibility for Next.js projects)");
1443
1443
  }
1444
1444
 
1445
+ // ─── qualia-memory MCP ──────────────────────────────────
1446
+ // Read-only access to the Karpathy LLM Wiki at ~/qualia-memory/. Three tools:
1447
+ // memory.search, memory.read, memory.list. Zero deps — see docs/MEMORY-MCP.md.
1448
+ // Registered globally so every Claude Code session has it without per-project
1449
+ // .mcp.json. Users override the vault path via QUALIA_MEMORY_ROOT.
1450
+ const memoryServerPath = path.join(FRAMEWORK_DIR, "mcp", "memory-mcp", "server.js");
1451
+ if (!settings.mcpServers["qualia-memory"]) {
1452
+ settings.mcpServers["qualia-memory"] = {
1453
+ command: "node",
1454
+ args: [memoryServerPath],
1455
+ env: {
1456
+ QUALIA_MEMORY_ROOT: path.join(require("os").homedir(), "qualia-memory"),
1457
+ },
1458
+ disabled: false,
1459
+ };
1460
+ ok("MCP: qualia-memory (read-only wiki at ~/qualia-memory/)");
1461
+ } else {
1462
+ // Always refresh the args path — the framework may have moved (npm i -g
1463
+ // location, dev checkout vs published install). Env stays user-controlled.
1464
+ settings.mcpServers["qualia-memory"].command = "node";
1465
+ settings.mcpServers["qualia-memory"].args = [memoryServerPath];
1466
+ }
1467
+
1445
1468
  // v5.0: backup existing settings.json before overwrite. The merge logic above
1446
1469
  // preserves user fields, but a partial-write or merger bug could destroy MCP
1447
1470
  // configs / custom permissions. Atomic write (tmp + rename) avoids partial
@@ -110,8 +110,11 @@ function dailyLogHasRecentEntries(windowDays) {
110
110
  }
111
111
 
112
112
  // ── Preflight ────────────────────────────────────────────
113
- const IS_CODEX_INSTALL = path.basename(QUALIA_HOME) === ".codex";
114
- const agentCli = IS_CODEX_INSTALL ? "codex" : "claude";
113
+ // Per-host facts (which CLI, how to invoke it) come from the adapter — the one
114
+ // place runtime differences live. See bin/host-adapters.js.
115
+ const { adapter, hostForHome } = require("./host-adapters.js");
116
+ const host = adapter(hostForHome(QUALIA_HOME));
117
+ const agentCli = host.agentCli;
115
118
  const agentBin = which(agentCli);
116
119
  if (!agentBin) {
117
120
  logEvent({ event: "skipped", reason: `${agentCli}-cli-not-on-path` });
@@ -143,7 +146,7 @@ const prompt = [
143
146
  "Finish with one line starting exactly: ⬢ Flushed daily-log",
144
147
  ].join("\n");
145
148
 
146
- const cliArgs = IS_CODEX_INSTALL ? ["exec", prompt] : ["-p", prompt];
149
+ const cliArgs = host.agentExec(prompt);
147
150
  const result = spawnSync(agentBin, cliArgs, {
148
151
  encoding: "utf8",
149
152
  timeout: 5 * 60 * 1000, // 5 min hard cap — flush should never take this long
package/bin/recall.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ // recall.js — the read side of Qualia memory. Unified recall across the two
3
+ // memory stores so an agent (or /qualia-recall) gets ONE answer, not two:
4
+ //
5
+ // 1. knowledge layer — ~/.claude/knowledge/ (via knowledge.js, the canonical
6
+ // loader; we delegate so new files stay discoverable)
7
+ // 2. qualia-memory — QUALIA_MEMORY_ROOT/wiki, the Obsidian LLM Wiki, the
8
+ // team's curated cross-project lessons
9
+ //
10
+ // Symmetric counterpart to /qualia-learn (write). Zero deps — shells out to
11
+ // knowledge.js and grep, same posture as state.js / memory-mcp/server.js.
12
+ //
13
+ // Usage:
14
+ // recall.js <query...> # human digest across both stores
15
+ // recall.js <query> --json # machine output
16
+ // recall.js <query> --scope knowledge # one store only (knowledge | vault | all)
17
+ // recall.js <query> --max 20 # cap hits per store (default 50)
18
+ //
19
+ // Exit: 0 = ran (even with zero hits), 2 = bad invocation.
20
+
21
+ const fs = require("fs");
22
+ const os = require("os");
23
+ const path = require("path");
24
+ const { spawnSync } = require("child_process");
25
+ const { resolveRole, loadDenyMatchers, isDenied } = require("./vault-access.js");
26
+
27
+ // ─── Store resolution ───────────────────────────────────────────────────────
28
+ // Mirror knowledge.js's home resolution so a temp QUALIA_HOME (tests) lines up.
29
+ function qualiaHome() {
30
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
31
+ const parent = path.basename(path.dirname(__dirname));
32
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
33
+ return path.join(os.homedir(), ".claude");
34
+ }
35
+ const KNOWLEDGE_JS = path.join(__dirname, "knowledge.js");
36
+ const VAULT_ROOT = process.env.QUALIA_MEMORY_ROOT || path.join(os.homedir(), "qualia-memory");
37
+ const WIKI = path.join(VAULT_ROOT, "wiki");
38
+
39
+ // Vault access control lives in vault-access.js (shared with the memory MCP) so
40
+ // the OWNER_ONLY / CONDITIONAL rule has exactly one implementation.
41
+
42
+ // ─── Knowledge layer (delegate to knowledge.js — single source of truth) ─────
43
+ // knowledge.js prints `relpath:line: text` per hit, or a `(no matches…)` /
44
+ // `(knowledge layer not initialized)` sentinel line. We parse the hit shape and
45
+ // drop the sentinels.
46
+ function searchKnowledge(query, max) {
47
+ const r = spawnSync(process.env.NODE || "node", [KNOWLEDGE_JS, "search", query], {
48
+ encoding: "utf8",
49
+ timeout: 10000,
50
+ });
51
+ if (r.status !== 0 && !r.stdout) return [];
52
+ const hits = [];
53
+ for (const line of (r.stdout || "").split("\n")) {
54
+ const m = line.match(/^(.+?):(\d+):\s?(.*)$/);
55
+ if (!m) continue; // sentinel or blank
56
+ hits.push({ store: "knowledge", file: m[1], line: Number(m[2]), snippet: m[3].trim() });
57
+ if (hits.length >= max) break;
58
+ }
59
+ return hits;
60
+ }
61
+
62
+ // ─── Vault (grep over wiki — mirrors mcp/memory-mcp/server.js) ───────────────
63
+ function searchVault(query, max, role) {
64
+ if (!fs.existsSync(WIKI)) return [];
65
+ const r = spawnSync(
66
+ "grep",
67
+ ["-rniF", "--include=*.md", "--include=*.txt", "--include=*.canvas", "--include=*.base", "--", query, WIKI],
68
+ { encoding: "utf8", timeout: 10000 }
69
+ );
70
+ // grep exit 1 = no matches (not an error); >1 = real error.
71
+ if (r.status !== 0 && r.status !== 1) return [];
72
+ // Non-OWNER roles get OWNER_ONLY / CONDITIONAL vault paths filtered out.
73
+ const matchers = role === "OWNER" ? null : loadDenyMatchers(WIKI);
74
+ const hits = [];
75
+ for (const line of (r.stdout || "").split("\n")) {
76
+ if (!line) continue;
77
+ const m = line.match(/^(.+?):(\d+):(.*)$/);
78
+ if (!m) continue;
79
+ const rel = path.relative(WIKI, m[1]) || path.basename(m[1]);
80
+ // Compare against VAULT-ROOT-relative path, the manifest's frame of reference.
81
+ if (matchers && isDenied(path.join("wiki", rel).split(path.sep).join("/"), matchers)) continue;
82
+ hits.push({ store: "vault", file: rel, line: Number(m[2]), snippet: m[3].trim() });
83
+ if (hits.length >= max) break;
84
+ }
85
+ return hits;
86
+ }
87
+
88
+ // ─── Rank: group by file, busiest file first, lines in order within a file ───
89
+ function rankByFile(hits) {
90
+ const byFile = new Map();
91
+ for (const h of hits) {
92
+ if (!byFile.has(h.file)) byFile.set(h.file, []);
93
+ byFile.get(h.file).push(h);
94
+ }
95
+ return [...byFile.entries()]
96
+ .map(([file, hs]) => ({ file, count: hs.length, hits: hs.sort((a, b) => a.line - b.line) }))
97
+ .sort((a, b) => b.count - a.count);
98
+ }
99
+
100
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
101
+ function parseArgs(argv) {
102
+ const opts = { json: false, scope: "all", max: 50, terms: [] };
103
+ for (let i = 0; i < argv.length; i++) {
104
+ const a = argv[i];
105
+ if (a === "--json") opts.json = true;
106
+ else if (a === "--scope") opts.scope = (argv[++i] || "").toLowerCase();
107
+ else if (a === "--max") opts.max = Math.max(1, parseInt(argv[++i], 10) || 50);
108
+ else if (a === "-h" || a === "--help") opts.help = true;
109
+ else opts.terms.push(a);
110
+ }
111
+ return opts;
112
+ }
113
+
114
+ function usage() {
115
+ process.stderr.write(
116
+ "recall.js — unified recall across the knowledge layer + qualia-memory vault\n\n" +
117
+ " recall.js <query...> [--scope all|knowledge|vault] [--json] [--max N]\n"
118
+ );
119
+ }
120
+
121
+ function main() {
122
+ const opts = parseArgs(process.argv.slice(2));
123
+ if (opts.help) {
124
+ usage();
125
+ process.exit(0);
126
+ }
127
+ const query = opts.terms.join(" ").trim();
128
+ if (!query) {
129
+ usage();
130
+ process.exit(2);
131
+ }
132
+ if (!["all", "knowledge", "vault"].includes(opts.scope)) {
133
+ process.stderr.write(`recall.js: invalid --scope '${opts.scope}' (use all|knowledge|vault)\n`);
134
+ process.exit(2);
135
+ }
136
+
137
+ const role = resolveRole(qualiaHome());
138
+ const knowledge = opts.scope === "vault" ? [] : searchKnowledge(query, opts.max);
139
+ const vault = opts.scope === "knowledge" ? [] : searchVault(query, opts.max, role);
140
+ const total = knowledge.length + vault.length;
141
+
142
+ if (opts.json) {
143
+ console.log(
144
+ JSON.stringify(
145
+ { query, scope: opts.scope, role, total, knowledge: rankByFile(knowledge), vault: rankByFile(vault) },
146
+ null,
147
+ 2
148
+ )
149
+ );
150
+ process.exit(0);
151
+ }
152
+
153
+ // Human digest.
154
+ console.log(`recall "${query}" — ${total} hit${total === 1 ? "" : "s"} (knowledge: ${knowledge.length}, vault: ${vault.length})${role !== "OWNER" ? ` · role ${role}: OWNER-only vault paths hidden` : ""}`);
155
+ const section = (label, hits) => {
156
+ if (!hits.length) return;
157
+ console.log(`\n${label}`);
158
+ for (const grp of rankByFile(hits)) {
159
+ console.log(` ${grp.file} (${grp.count})`);
160
+ for (const h of grp.hits.slice(0, 5)) {
161
+ const snip = h.snippet.length > 120 ? h.snippet.slice(0, 117) + "…" : h.snippet;
162
+ console.log(` L${h.line}: ${snip}`);
163
+ }
164
+ }
165
+ };
166
+ section("Knowledge layer (~/.claude/knowledge/)", knowledge);
167
+ section("Vault (qualia-memory/wiki/)", vault);
168
+ if (total === 0) console.log("\n(no matches — try a broader term, or check the vault/knowledge layer exists)");
169
+ process.exit(0);
170
+ }
171
+
172
+ main();
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // repo-map.js — a cheap, deterministic symbol map of a codebase. The zero-dep
3
+ // answer to Aider's tree-sitter repo-map (R18): give /qualia-map (and any agent
4
+ // onboarding to a brownfield repo) the STRUCTURE — every file's top-level
5
+ // symbols — without reading whole files. Grounds the scan; cuts token cost.
6
+ //
7
+ // Not a parser: language-aware regexes over top-level (column-0) declarations.
8
+ // Good enough to answer "what's in this repo and where" — exports, functions,
9
+ // classes, types — ranked by symbol density so the busiest files surface first.
10
+ //
11
+ // Usage:
12
+ // repo-map.js [dir] # human tree (default cwd)
13
+ // repo-map.js [dir] --json # machine output
14
+ // repo-map.js [dir] --max-files N # cap files shown (default 60)
15
+ //
16
+ // Exit: 0 = ran, 2 = bad invocation / dir missing. Zero deps.
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ const IGNORE_DIRS = new Set([
22
+ ".git", "node_modules", "dist", "build", ".next", "out", "coverage", "vendor",
23
+ ".venv", "venv", "__pycache__", ".turbo", ".cache", "target", "bin/obj",
24
+ ".pytest_cache", ".mypy_cache", ".gradle", "Pods", ".terraform",
25
+ ]);
26
+
27
+ // Per-extension symbol extractors. Each regex captures the symbol name in a
28
+ // named-or-numbered group; `kind` labels it. Anchored to line start (top-level).
29
+ const EXTRACTORS = {
30
+ js: jsLike, jsx: jsLike, ts: jsLike, tsx: jsLike, mjs: jsLike, cjs: jsLike,
31
+ py: py, go: go, rs: rs, rb: rb, java: java, php: php,
32
+ };
33
+
34
+ function scanLines(content, rules) {
35
+ const out = [];
36
+ const lines = content.split("\n");
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i];
39
+ for (const { re, kind } of rules) {
40
+ const m = line.match(re);
41
+ if (m) {
42
+ out.push({ kind, name: m[m.length - 1], line: i + 1 });
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function jsLike(c) {
51
+ return scanLines(c, [
52
+ { re: /^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)/, kind: "fn" },
53
+ { re: /^export\s+(?:default\s+)?class\s+(\w+)/, kind: "class" },
54
+ { re: /^export\s+(?:abstract\s+)?(?:interface|type|enum)\s+(\w+)/, kind: "type" },
55
+ { re: /^export\s+(?:const|let|var)\s+(\w+)/, kind: "const" },
56
+ { re: /^(?:async\s+)?function\s+(\w+)/, kind: "fn" },
57
+ { re: /^class\s+(\w+)/, kind: "class" },
58
+ { re: /^(?:export\s+)?default\s+class\s+(\w+)/, kind: "class" },
59
+ ]);
60
+ }
61
+ function py(c) {
62
+ return scanLines(c, [
63
+ { re: /^(?:async\s+)?def\s+(\w+)/, kind: "fn" },
64
+ { re: /^class\s+(\w+)/, kind: "class" },
65
+ ]);
66
+ }
67
+ function go(c) {
68
+ return scanLines(c, [
69
+ { re: /^func\s+\([^)]*\)\s+(\w+)/, kind: "method" },
70
+ { re: /^func\s+(\w+)/, kind: "fn" },
71
+ { re: /^type\s+(\w+)/, kind: "type" },
72
+ ]);
73
+ }
74
+ function rs(c) {
75
+ return scanLines(c, [
76
+ { re: /^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/, kind: "fn" },
77
+ { re: /^(?:pub\s+)?struct\s+(\w+)/, kind: "struct" },
78
+ { re: /^(?:pub\s+)?enum\s+(\w+)/, kind: "enum" },
79
+ { re: /^(?:pub\s+)?trait\s+(\w+)/, kind: "trait" },
80
+ ]);
81
+ }
82
+ function rb(c) {
83
+ return scanLines(c, [
84
+ { re: /^\s*def\s+([\w.?!]+)/, kind: "fn" },
85
+ { re: /^\s*class\s+(\w+)/, kind: "class" },
86
+ { re: /^\s*module\s+(\w+)/, kind: "module" },
87
+ ]);
88
+ }
89
+ function java(c) {
90
+ return scanLines(c, [
91
+ { re: /^\s*(?:public|private|protected)?\s*(?:abstract\s+)?class\s+(\w+)/, kind: "class" },
92
+ { re: /^\s*(?:public|private|protected)\s+(?:static\s+)?(?:interface|enum)\s+(\w+)/, kind: "type" },
93
+ ]);
94
+ }
95
+ function php(c) {
96
+ return scanLines(c, [
97
+ { re: /^\s*(?:abstract\s+)?class\s+(\w+)/, kind: "class" },
98
+ { re: /^\s*function\s+(\w+)/, kind: "fn" },
99
+ { re: /^\s*(?:interface|trait)\s+(\w+)/, kind: "type" },
100
+ ]);
101
+ }
102
+
103
+ function walk(root, max) {
104
+ const results = [];
105
+ const stack = [root];
106
+ while (stack.length) {
107
+ const dir = stack.pop();
108
+ let entries;
109
+ try {
110
+ entries = fs.readdirSync(dir, { withFileTypes: true });
111
+ } catch {
112
+ continue;
113
+ }
114
+ for (const e of entries) {
115
+ if (e.name.startsWith(".") && e.name !== ".") {
116
+ if (e.isDirectory() && IGNORE_DIRS.has(e.name)) continue;
117
+ }
118
+ const full = path.join(dir, e.name);
119
+ if (e.isDirectory()) {
120
+ if (IGNORE_DIRS.has(e.name)) continue;
121
+ stack.push(full);
122
+ } else if (e.isFile()) {
123
+ const ext = path.extname(e.name).slice(1).toLowerCase();
124
+ const extract = EXTRACTORS[ext];
125
+ if (!extract) continue;
126
+ let content;
127
+ try {
128
+ content = fs.readFileSync(full, "utf8");
129
+ } catch {
130
+ continue;
131
+ }
132
+ if (content.length > 2 * 1024 * 1024) continue; // skip huge/minified
133
+ const symbols = extract(content);
134
+ if (symbols.length) results.push({ file: path.relative(root, full), symbols });
135
+ }
136
+ }
137
+ if (results.length > 5000) break; // hard backstop
138
+ }
139
+ // Busiest files first — the structural backbone surfaces at the top.
140
+ results.sort((a, b) => b.symbols.length - a.symbols.length || a.file.localeCompare(b.file));
141
+ void max;
142
+ return results;
143
+ }
144
+
145
+ function parseArgs(argv) {
146
+ const opts = { dir: ".", json: false, maxFiles: 60 };
147
+ for (let i = 0; i < argv.length; i++) {
148
+ const a = argv[i];
149
+ if (a === "--json") opts.json = true;
150
+ else if (a === "--max-files") opts.maxFiles = Math.max(1, parseInt(argv[++i], 10) || 60);
151
+ else if (a === "-h" || a === "--help") opts.help = true;
152
+ else opts.dir = a;
153
+ }
154
+ return opts;
155
+ }
156
+
157
+ function main() {
158
+ const opts = parseArgs(process.argv.slice(2));
159
+ if (opts.help) {
160
+ process.stdout.write("repo-map.js [dir] [--json] [--max-files N]\n");
161
+ process.exit(0);
162
+ }
163
+ const root = path.resolve(opts.dir);
164
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
165
+ process.stderr.write(`repo-map.js: not a directory: ${opts.dir}\n`);
166
+ process.exit(2);
167
+ }
168
+ const files = walk(root, opts.maxFiles);
169
+ const totalSymbols = files.reduce((n, f) => n + f.symbols.length, 0);
170
+
171
+ if (opts.json) {
172
+ console.log(JSON.stringify({ root, total_files: files.length, total_symbols: totalSymbols, files: files.slice(0, opts.maxFiles) }, null, 2));
173
+ process.exit(0);
174
+ }
175
+
176
+ console.log(`repo-map: ${files.length} source file${files.length === 1 ? "" : "s"}, ${totalSymbols} top-level symbols`);
177
+ if (files.length > opts.maxFiles) console.log(`(showing the ${opts.maxFiles} densest; pass --max-files to widen)`);
178
+ for (const f of files.slice(0, opts.maxFiles)) {
179
+ console.log(`\n${f.file} (${f.symbols.length})`);
180
+ for (const s of f.symbols.slice(0, 24)) {
181
+ console.log(` ${s.kind.padEnd(6)} ${s.name} :${s.line}`);
182
+ }
183
+ if (f.symbols.length > 24) console.log(` … +${f.symbols.length - 24} more`);
184
+ }
185
+ process.exit(0);
186
+ }
187
+
188
+ main();
@@ -10,6 +10,11 @@ const RUNTIME_BIN_SCRIPTS = [
10
10
  { file: "statusline.js", label: "statusline.js (status bar renderer)" },
11
11
  { file: "knowledge.js", label: "knowledge.js (memory-layer loader)" },
12
12
  { file: "knowledge-flush.js", label: "knowledge-flush.js (cron-runnable flush)" },
13
+ { file: "recall.js", label: "recall.js (read-side memory recall — knowledge layer + role-filtered vault)" },
14
+ { file: "vault-access.js", label: "vault-access.js (shared vault access-control — honors wiki/_meta/access.md)" },
15
+ { file: "repo-map.js", label: "repo-map.js (zero-dep symbol map for brownfield onboarding — /qualia-map)" },
16
+ { file: "design-tokens.js", label: "design-tokens.js (per-client CSS-variable token registry — R10)" },
17
+ { file: "batch-plan.js", label: "batch-plan.js (file-disjoint batch split for /qualia-build --batch — R20)" },
13
18
  { file: "state-ledger.js", label: "state-ledger.js (hash-chained state event ledger)" },
14
19
  { file: "plan-contract.js", label: "plan-contract.js (plan JSON validator)" },
15
20
  { file: "contract-runner.js", label: "contract-runner.js (contract evidence runner)" },
@@ -23,6 +28,7 @@ const RUNTIME_BIN_SCRIPTS = [
23
28
  { file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
24
29
  { file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
25
30
  { file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
31
+ { file: "erp-event.js", label: "erp-event.js (signed lifecycle-event emitter → ERP /api/v1/events, R14 client)" },
26
32
  { file: "work-packet.js", label: "work-packet.js (ERP mission/work packet pull + local reader)" },
27
33
  { file: "report-payload.js", label: "report-payload.js (Framework -> ERP report payload builder)" },
28
34
  { file: "auto-report.js", label: "auto-report.js (B1 ship-time auto-report)" },
@@ -0,0 +1,82 @@
1
+ // vault-access.js — the SINGLE source for qualia-memory vault access control.
2
+ //
3
+ // The vault carries an access manifest at wiki/_meta/access.md naming OWNER_ONLY
4
+ // and CONDITIONAL paths. Every deterministic reader of the vault (recall.js, the
5
+ // memory MCP server) enforces it THROUGH THIS MODULE so the security rule has one
6
+ // implementation that can't drift between callers. Fail-closed: an unknown role
7
+ // is treated as non-OWNER (restricted). See rules/access.md.
8
+
9
+ const fs = require("fs");
10
+ const os = require("os");
11
+ const path = require("path");
12
+
13
+ // Resolve the caller's role. QUALIA_ROLE env wins (tests / overrides); else read
14
+ // `role` from <home>/.qualia-config.json; else RESTRICTED (fail-closed).
15
+ function resolveRole(home) {
16
+ if (process.env.QUALIA_ROLE) return process.env.QUALIA_ROLE.toUpperCase();
17
+ const h = home || process.env.QUALIA_HOME || path.join(os.homedir(), ".claude");
18
+ try {
19
+ const cfg = JSON.parse(fs.readFileSync(path.join(h, ".qualia-config.json"), "utf8"));
20
+ if (cfg && typeof cfg.role === "string") return cfg.role.toUpperCase();
21
+ } catch {}
22
+ return "RESTRICTED";
23
+ }
24
+
25
+ // Parse the OWNER_ONLY + CONDITIONAL path tokens from wiki/_meta/access.md into
26
+ // matchers over VAULT-ROOT-relative paths (e.g. "wiki/_meta/access.md").
27
+ // Returns null when no manifest exists (caller decides; wiki is curated-by-design).
28
+ function loadDenyMatchers(wikiDir) {
29
+ let text;
30
+ try {
31
+ text = fs.readFileSync(path.join(wikiDir, "_meta", "access.md"), "utf8");
32
+ } catch {
33
+ return null;
34
+ }
35
+ const matchers = [];
36
+ for (const section of ["## OWNER_ONLY", "## CONDITIONAL"]) {
37
+ const start = text.indexOf(section);
38
+ if (start === -1) continue;
39
+ const rest = text.slice(start + section.length);
40
+ const end = rest.indexOf("\n## ");
41
+ const body = end === -1 ? rest : rest.slice(0, end);
42
+ for (const m of body.matchAll(/`([^`]+)`/g)) {
43
+ const tok = m[1].trim();
44
+ if (!tok.includes("/") && !tok.includes("*")) continue; // skip non-path backticks
45
+ matchers.push(tok);
46
+ }
47
+ }
48
+ return matchers;
49
+ }
50
+
51
+ // Does a VAULT-ROOT-relative path match any deny pattern?
52
+ // "dir/" → prefix match "dir/*.md" → prefix + extension
53
+ // "a/*/b" → glob → regex bare → exact or prefix
54
+ function isDenied(vaultRelPath, matchers) {
55
+ for (const tok of matchers) {
56
+ if (tok.endsWith("/*.md")) {
57
+ const dir = tok.slice(0, -5);
58
+ if (vaultRelPath.startsWith(dir + "/") && vaultRelPath.endsWith(".md")) return true;
59
+ } else if (tok.endsWith("/")) {
60
+ if (vaultRelPath.startsWith(tok)) return true;
61
+ } else if (tok.includes("*")) {
62
+ const re = new RegExp("^" + tok.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$");
63
+ if (re.test(vaultRelPath)) return true;
64
+ } else if (vaultRelPath === tok || vaultRelPath.startsWith(tok + "/")) {
65
+ return true;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+
71
+ // Convenience for callers that work in WIKI-relative paths (recall, memory-mcp).
72
+ // OWNER sees everything; non-OWNER is denied OWNER_ONLY/CONDITIONAL wiki paths.
73
+ // Unknown manifest → allow (wiki is curated; sensitive stores live outside wiki/).
74
+ function isWikiPathAllowed(wikiRelPath, role, wikiDir) {
75
+ if (role === "OWNER") return true;
76
+ const matchers = loadDenyMatchers(wikiDir);
77
+ if (!matchers) return true;
78
+ const vaultRel = path.join("wiki", wikiRelPath).split(path.sep).join("/");
79
+ return !isDenied(vaultRel, matchers);
80
+ }
81
+
82
+ module.exports = { resolveRole, loadDenyMatchers, isDenied, isWikiPathAllowed };
@@ -77,6 +77,41 @@ many times each employee attempted to use proxy approval.
77
77
  ```
78
78
  Store by `(type, actor_code)` the same way so Fawzi sees a per-employee main-push tally. `client_report_id` is `QS-MAINPUSH-<actor_code>-<count>` and each post carries an idempotency key.
79
79
 
80
+ ### POST /api/v1/events (R14 — unified signed event log)
81
+
82
+ The single endpoint for ALL CLI→ERP events: lifecycle/run events
83
+ (`session_started`, `phase_planned`, `build_wave_started`, `verify_pass`,
84
+ `verify_fail`, …) and, going forward, the policy events above. Emitted by
85
+ `bin/erp-event.js` and queued through `erp-retry.js`.
86
+
87
+ **Headers (Standard-Webhooks style):**
88
+ ```
89
+ Authorization: Bearer <api-key>
90
+ Content-Type: application/json
91
+ Qualia-Event-Id: <unique id — also the idempotency key>
92
+ Qualia-Event-Timestamp: <unix seconds or ISO 8601>
93
+ Qualia-Signature: <base64 HMAC-SHA256 of `${id}.${timestamp}.${rawBody}`, keyed by the api-key; omit for unsigned>
94
+ ```
95
+
96
+ **Body (the `FrameworkEvent`):**
97
+ ```json
98
+ {
99
+ "action": "verify_pass",
100
+ "actor": { "code": "QS-FAWZI-11", "name": "Fawzi", "role": "OWNER" },
101
+ "targets": [{ "type": "project", "ref": "acme-portal" }],
102
+ "context": {},
103
+ "metadata": {},
104
+ "occurred_at": "2026-06-22T10:00:00.000Z",
105
+ "erp_project_id": "7b5d3b4e-…"
106
+ }
107
+ ```
108
+
109
+ The HMAC is keyed by the caller's `qlt_` token (the ERP holds only its hash but
110
+ sees the plaintext Bearer per request), so body integrity + replay are verified
111
+ with no extra secret. Server stores to an append-only `framework_events` table,
112
+ idempotent on `Qualia-Event-Id`; unsigned posts are accepted but recorded
113
+ `signature_valid=false`. Accepts `reports:write` or `events:write` scope.
114
+
80
115
  ### POST /api/v1/reports
81
116
 
82
117
  Upload a session report.