qualia-framework 4.1.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +15 -11
  2. package/agents/builder.md +28 -0
  3. package/agents/research-synthesizer.md +7 -0
  4. package/bin/agent-runs.js +233 -0
  5. package/bin/cli.js +355 -16
  6. package/bin/install.js +87 -6
  7. package/bin/knowledge-flush.js +164 -0
  8. package/bin/knowledge.js +317 -0
  9. package/bin/plan-contract.js +220 -0
  10. package/bin/state.js +15 -9
  11. package/docs/agent-runs.md +273 -0
  12. package/docs/journey-demo.html +1008 -0
  13. package/docs/plan-contract.md +321 -0
  14. package/docs/reviews/v4.1.0-audit.html +1488 -0
  15. package/docs/reviews/v4.1.0-audit.md +263 -0
  16. package/hooks/auto-update.js +3 -7
  17. package/hooks/git-guardrails.js +167 -0
  18. package/hooks/pre-compact.js +22 -11
  19. package/hooks/pre-deploy-gate.js +16 -2
  20. package/hooks/pre-push.js +22 -2
  21. package/hooks/stop-session-log.js +180 -0
  22. package/package.json +8 -2
  23. package/skills/qualia-build/SKILL.md +5 -5
  24. package/skills/qualia-debug/SKILL.md +1 -1
  25. package/skills/qualia-design/SKILL.md +15 -0
  26. package/skills/qualia-flush/SKILL.md +200 -0
  27. package/skills/qualia-learn/SKILL.md +47 -37
  28. package/skills/qualia-new/SKILL.md +1 -1
  29. package/skills/qualia-plan/SKILL.md +3 -2
  30. package/skills/qualia-postmortem/SKILL.md +238 -0
  31. package/skills/qualia-quick/SKILL.md +1 -1
  32. package/skills/qualia-report/SKILL.md +1 -1
  33. package/skills/qualia-review/SKILL.md +3 -2
  34. package/skills/qualia-ship/SKILL.md +12 -10
  35. package/skills/qualia-verify/SKILL.md +60 -0
  36. package/templates/help.html +13 -7
  37. package/templates/knowledge/agents.md +71 -0
  38. package/templates/knowledge/index.md +47 -0
  39. package/tests/bin.test.sh +322 -12
  40. package/tests/hooks.test.sh +131 -20
  41. package/tests/lib.test.sh +217 -0
  42. package/tests/runner.js +103 -77
  43. package/tests/state.test.sh +4 -3
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // ~/.claude/bin/knowledge-flush.js — non-interactive memory-layer flush.
3
+ //
4
+ // Wraps `/qualia-flush` so it can run from cron (or systemd timer, or any
5
+ // CI/scheduled job) without an interactive Claude Code session. Closes the
6
+ // memory loop end-to-end:
7
+ //
8
+ // Stop hook (auto, every turn) → ~/.claude/knowledge/daily-log/{date}.md
9
+ // THIS SCRIPT (weekly cron) → spawns `claude -p "/qualia-flush --days 7"`
10
+ // /qualia-flush → promotes raw → curated tier
11
+ // bin/knowledge.js (every spawn) → reads index.md → reaches the right file
12
+ //
13
+ // Usage:
14
+ // node ~/.claude/bin/knowledge-flush.js # 7-day flush
15
+ // node ~/.claude/bin/knowledge-flush.js --days 14 # custom window
16
+ // node ~/.claude/bin/knowledge-flush.js --dry-run # preview only
17
+ // node ~/.claude/bin/knowledge-flush.js --project X # scope to one project
18
+ //
19
+ // Recommended cron entry (weekly Sunday 3 AM local):
20
+ // 0 3 * * 0 node ~/.claude/bin/knowledge-flush.js >> ~/.claude/.qualia-flush.log 2>&1
21
+ //
22
+ // Behavior:
23
+ // • If `claude` CLI isn't on PATH, exits 0 with a logged warning. Cron
24
+ // spam is worse than a missed flush — a real failure surfaces in the
25
+ // log file the user is presumably watching.
26
+ // • If the daily-log dir is empty (nothing to flush), exits 0 silently.
27
+ // • If `claude -p` returns non-zero, exits 1 with the error captured in
28
+ // the log so cron can be configured to alert on it.
29
+ // • Writes one structured JSONL line per run to ~/.claude/.qualia-flush.log
30
+ // so the user can audit "when did the last 5 flushes run, what did they
31
+ // produce?" without parsing free text.
32
+ //
33
+ // Cross-platform (Windows/macOS/Linux). Honors the same args as the skill.
34
+
35
+ const fs = require("fs");
36
+ const path = require("path");
37
+ const os = require("os");
38
+ const { spawnSync } = require("child_process");
39
+
40
+ const HOME = os.homedir();
41
+ const KNOWLEDGE_DIR = path.join(HOME, ".claude", "knowledge");
42
+ const DAILY_DIR = path.join(KNOWLEDGE_DIR, "daily-log");
43
+ const LOG_FILE = path.join(HOME, ".claude", ".qualia-flush.log");
44
+
45
+ const _start = Date.now();
46
+
47
+ function logEvent(event) {
48
+ try {
49
+ const dir = path.dirname(LOG_FILE);
50
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
51
+ const line = JSON.stringify({
52
+ timestamp: new Date().toISOString(),
53
+ duration_ms: Date.now() - _start,
54
+ ...event,
55
+ });
56
+ fs.appendFileSync(LOG_FILE, line + "\n");
57
+ } catch {}
58
+ }
59
+
60
+ function which(cmd) {
61
+ // Cross-platform `which`. Returns the first PATH match or null.
62
+ // We don't shell out to `which` itself because it doesn't exist on Windows
63
+ // (it's `where` there, with different semantics).
64
+ const sep = process.platform === "win32" ? ";" : ":";
65
+ const exts = process.platform === "win32"
66
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")
67
+ : [""];
68
+ const dirs = (process.env.PATH || "").split(sep);
69
+ for (const dir of dirs) {
70
+ if (!dir) continue;
71
+ for (const ext of exts) {
72
+ const candidate = path.join(dir, cmd + ext);
73
+ try {
74
+ if (fs.existsSync(candidate)) return candidate;
75
+ } catch {}
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ // Pass-through args (so `--days 14`, `--dry-run`, `--project X` all reach the
82
+ // skill). We don't parse them ourselves — the skill is the source of truth
83
+ // for argument semantics. We only use `--days` locally to short-circuit when
84
+ // the daily-log is genuinely empty.
85
+ const argv = process.argv.slice(2);
86
+ const flagIdx = argv.indexOf("--days");
87
+ const days = flagIdx >= 0 ? parseInt(argv[flagIdx + 1], 10) || 7 : 7;
88
+
89
+ function dailyLogHasRecentEntries(windowDays) {
90
+ if (!fs.existsSync(DAILY_DIR)) return false;
91
+ const cutoff = new Date();
92
+ cutoff.setDate(cutoff.getDate() - windowDays);
93
+ const cutoffStr = cutoff.toISOString().split("T")[0];
94
+ try {
95
+ const entries = fs.readdirSync(DAILY_DIR);
96
+ for (const f of entries) {
97
+ if (!f.endsWith(".md")) continue;
98
+ const base = f.replace(/\.md$/, "");
99
+ if (base >= cutoffStr) return true;
100
+ }
101
+ } catch {}
102
+ return false;
103
+ }
104
+
105
+ // ── Preflight ────────────────────────────────────────────
106
+ const claudeBin = which("claude");
107
+ if (!claudeBin) {
108
+ logEvent({ event: "skipped", reason: "claude-cli-not-on-path" });
109
+ // Exit 0 — a missing CLI on the host running cron is a config issue, not
110
+ // a flush failure. Don't spam alerts.
111
+ process.exit(0);
112
+ }
113
+
114
+ if (!dailyLogHasRecentEntries(days)) {
115
+ logEvent({ event: "skipped", reason: "no-recent-daily-log", window_days: days });
116
+ process.exit(0);
117
+ }
118
+
119
+ // ── Run ──────────────────────────────────────────────────
120
+ // `claude -p "<prompt>"` runs a single non-interactive turn. The skill body
121
+ // invocation matches what the user would type at the prompt.
122
+ const prompt = `/qualia-flush ${argv.join(" ")}`.trim();
123
+
124
+ const result = spawnSync(claudeBin, ["-p", prompt], {
125
+ encoding: "utf8",
126
+ timeout: 5 * 60 * 1000, // 5 min hard cap — flush should never take this long
127
+ shell: process.platform === "win32",
128
+ stdio: ["ignore", "pipe", "pipe"],
129
+ });
130
+
131
+ const stdout = (result.stdout || "").trim();
132
+ const stderr = (result.stderr || "").trim();
133
+ const status = typeof result.status === "number" ? result.status : -1;
134
+
135
+ if (status !== 0) {
136
+ logEvent({
137
+ event: "failed",
138
+ status,
139
+ prompt,
140
+ stderr_tail: stderr.slice(-1000),
141
+ });
142
+ // Surface to stderr so cron's MAILTO sends an alert.
143
+ console.error(`knowledge-flush: claude -p exited ${status}`);
144
+ if (stderr) console.error(stderr.slice(-2000));
145
+ process.exit(1);
146
+ }
147
+
148
+ // Success: parse stdout for the skill's summary line if present, else log
149
+ // the full output tail.
150
+ const summaryMatch = stdout.match(/⬢ Flushed daily-log .+/);
151
+ logEvent({
152
+ event: "ok",
153
+ prompt,
154
+ summary: summaryMatch ? summaryMatch[0] : stdout.split("\n").slice(-3).join(" | "),
155
+ });
156
+
157
+ // Echo the user-facing summary to stdout so cron logs / interactive runs
158
+ // both surface what happened.
159
+ if (summaryMatch) {
160
+ console.log(summaryMatch[0]);
161
+ } else {
162
+ console.log(stdout.split("\n").slice(-5).join("\n"));
163
+ }
164
+ process.exit(0);
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ // ~/.claude/bin/knowledge.js — unified loader for the memory layer.
3
+ //
4
+ // Replaces ad-hoc `cat ~/.claude/knowledge/X.md` calls scattered across
5
+ // skills. One entry point, deterministic output, every command exits 0 even
6
+ // when the requested file is missing (prints "(no entries)" to stdout) so
7
+ // skills can pipe the output into prompts without breaking on a fresh install.
8
+ //
9
+ // Why this exists (v4.1.0 audit finding #3):
10
+ // Skills hardcode `cat ~/.claude/knowledge/common-fixes.md`. New knowledge
11
+ // files are dead weight — the agent never sees them because the skill never
12
+ // references them by name. This loader gives skills ONE call (`knowledge.js`
13
+ // with no args prints index.md, the entry point) and lets the agent navigate
14
+ // from there. New files reachable from the index get used automatically.
15
+ //
16
+ // Subcommands:
17
+ // knowledge.js → prints index.md (default)
18
+ // knowledge.js load <file> → prints knowledge/<file>
19
+ // knowledge.js list → lists all knowledge files
20
+ // knowledge.js search <query> → grep across all files
21
+ // knowledge.js append --type <pattern|fix|client> --title <T> --body <B>
22
+ // appends a formatted entry
23
+ // knowledge.js path [<file>] → prints absolute path
24
+ //
25
+ // Cross-platform (Windows/macOS/Linux). No shell dependencies.
26
+
27
+ const fs = require("fs");
28
+ const path = require("path");
29
+ const os = require("os");
30
+
31
+ const KNOWLEDGE_DIR = path.join(os.homedir(), ".claude", "knowledge");
32
+ const INDEX_FILE = path.join(KNOWLEDGE_DIR, "index.md");
33
+
34
+ // Type → filename mapping for `append` and convenience aliases used by the
35
+ // existing `/qualia-learn` taxonomy. Keep this list short — every additional
36
+ // type needs a corresponding section in index.md.
37
+ const TYPE_TO_FILE = {
38
+ pattern: "learned-patterns.md",
39
+ patterns: "learned-patterns.md",
40
+ fix: "common-fixes.md",
41
+ fixes: "common-fixes.md",
42
+ client: "client-prefs.md",
43
+ "client-pref": "client-prefs.md",
44
+ "client-prefs": "client-prefs.md",
45
+ "client-preference": "client-prefs.md",
46
+ };
47
+
48
+ function ensureDir() {
49
+ if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
50
+ }
51
+
52
+ function readSafe(p) {
53
+ try {
54
+ return fs.readFileSync(p, "utf8");
55
+ } catch {
56
+ return "";
57
+ }
58
+ }
59
+
60
+ // Look up a knowledge file by friendly name. Accepts: "index", "index.md",
61
+ // "patterns" (alias), "fixes" (alias), a bare filename, a subdirectory-
62
+ // qualified path like "concepts/stripe-checkout", or a name that exists
63
+ // inside a known subdirectory. Returns the resolved absolute path (may
64
+ // not exist on disk).
65
+ //
66
+ // Resolution order:
67
+ // 1. "index" / "index.md" → top-level index.md
68
+ // 2. Known type alias (pattern|fix|client) → mapped top-level filename
69
+ // 3. Path with "/" → treat as relative to knowledge dir (concepts/foo)
70
+ // 4. Bare name → look in top-level first; if missing, search known
71
+ // subdirectories (concepts/, daily-log/) for an exact match. This
72
+ // means /qualia-flush can write to concepts/voice-agent-call-state.md
73
+ // and skills can later run `knowledge.js load voice-agent-call-state`
74
+ // without knowing it lives in a subdirectory.
75
+ function resolveFile(name) {
76
+ if (!name || name === "index" || name === "index.md") return INDEX_FILE;
77
+ const lower = name.toLowerCase();
78
+ if (TYPE_TO_FILE[lower]) {
79
+ return path.join(KNOWLEDGE_DIR, TYPE_TO_FILE[lower]);
80
+ }
81
+ const withExt = name.endsWith(".md") ? name : `${name}.md`;
82
+ // Subdirectory-qualified path: concepts/foo, daily-log/2026-04-26
83
+ if (withExt.includes("/") || withExt.includes(path.sep)) {
84
+ return path.join(KNOWLEDGE_DIR, withExt);
85
+ }
86
+ // Top-level wins if it exists.
87
+ const topLevel = path.join(KNOWLEDGE_DIR, withExt);
88
+ if (fs.existsSync(topLevel)) return topLevel;
89
+ // Otherwise search known subdirectories. Stop at the first match — if
90
+ // multiple subdirs have the same filename, the user should qualify.
91
+ const KNOWN_SUBDIRS = ["concepts", "connections", "daily-log"];
92
+ for (const sub of KNOWN_SUBDIRS) {
93
+ const candidate = path.join(KNOWLEDGE_DIR, sub, withExt);
94
+ if (fs.existsSync(candidate)) return candidate;
95
+ }
96
+ // Fall back to top-level (will trigger the "no entries" stub on read).
97
+ return topLevel;
98
+ }
99
+
100
+ function cmdLoad(arg) {
101
+ ensureDir();
102
+ const target = resolveFile(arg);
103
+ if (!fs.existsSync(target)) {
104
+ // Missing file → print a stub message, never fail. Skills can pipe this
105
+ // safely. The instruction line tells the agent what to do next.
106
+ const rel = path.relative(KNOWLEDGE_DIR, target) || "index.md";
107
+ console.log(`(no entries in ${rel} — use /qualia-learn to add one)`);
108
+ process.exit(0);
109
+ }
110
+ process.stdout.write(readSafe(target));
111
+ process.exit(0);
112
+ }
113
+
114
+ function cmdList() {
115
+ ensureDir();
116
+ if (!fs.existsSync(KNOWLEDGE_DIR)) {
117
+ console.log("(knowledge layer not initialized — run: npx qualia-framework@latest install)");
118
+ process.exit(0);
119
+ }
120
+ const entries = [];
121
+ for (const f of fs.readdirSync(KNOWLEDGE_DIR)) {
122
+ const p = path.join(KNOWLEDGE_DIR, f);
123
+ let stat;
124
+ try { stat = fs.statSync(p); } catch { continue; }
125
+ if (stat.isDirectory()) {
126
+ // For daily-log/, count entries.
127
+ let count = 0;
128
+ try { count = fs.readdirSync(p).filter((x) => x.endsWith(".md")).length; } catch {}
129
+ entries.push({ name: `${f}/`, size: count, mtime: stat.mtimeMs, kind: "dir" });
130
+ } else if (f.endsWith(".md")) {
131
+ entries.push({ name: f, size: stat.size, mtime: stat.mtimeMs, kind: "file" });
132
+ }
133
+ }
134
+ entries.sort((a, b) => a.name.localeCompare(b.name));
135
+ for (const e of entries) {
136
+ const sizeStr = e.kind === "dir" ? `${e.size} entries` : `${e.size}B`;
137
+ const date = new Date(e.mtime).toISOString().split("T")[0];
138
+ console.log(`${e.name.padEnd(28)} ${sizeStr.padEnd(14)} ${date}`);
139
+ }
140
+ process.exit(0);
141
+ }
142
+
143
+ function cmdSearch(query) {
144
+ ensureDir();
145
+ if (!query) {
146
+ console.error("Usage: knowledge.js search <query>");
147
+ process.exit(1);
148
+ }
149
+ if (!fs.existsSync(KNOWLEDGE_DIR)) {
150
+ console.log("(knowledge layer not initialized)");
151
+ process.exit(0);
152
+ }
153
+ const needle = query.toLowerCase();
154
+ const matches = [];
155
+ function walk(dir) {
156
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
157
+ const p = path.join(dir, f.name);
158
+ if (f.isDirectory()) { walk(p); continue; }
159
+ if (!f.name.endsWith(".md")) continue;
160
+ const lines = readSafe(p).split("\n");
161
+ lines.forEach((line, idx) => {
162
+ if (line.toLowerCase().includes(needle)) {
163
+ const rel = path.relative(KNOWLEDGE_DIR, p);
164
+ matches.push(`${rel}:${idx + 1}: ${line.trim()}`);
165
+ }
166
+ });
167
+ }
168
+ }
169
+ walk(KNOWLEDGE_DIR);
170
+ if (matches.length === 0) {
171
+ console.log(`(no matches for "${query}")`);
172
+ } else {
173
+ for (const m of matches) console.log(m);
174
+ }
175
+ process.exit(0);
176
+ }
177
+
178
+ // Parse minimal --flag value pairs from argv. Stops at the first positional
179
+ // argument, returns { flags: { type: "pattern", title: "X" }, rest: [...] }.
180
+ function parseFlags(argv) {
181
+ const flags = {};
182
+ const rest = [];
183
+ for (let i = 0; i < argv.length; i++) {
184
+ const a = argv[i];
185
+ if (a.startsWith("--")) {
186
+ const key = a.slice(2);
187
+ const next = argv[i + 1];
188
+ if (next && !next.startsWith("--")) {
189
+ flags[key] = next;
190
+ i++;
191
+ } else {
192
+ flags[key] = true;
193
+ }
194
+ } else {
195
+ rest.push(a);
196
+ }
197
+ }
198
+ return { flags, rest };
199
+ }
200
+
201
+ function cmdAppend(rawArgs) {
202
+ ensureDir();
203
+ const { flags } = parseFlags(rawArgs);
204
+ const type = String(flags.type || "").toLowerCase();
205
+ const title = String(flags.title || "").trim();
206
+ const body = String(flags.body || "").trim();
207
+ const project = String(flags.project || "general").trim();
208
+ const context = String(flags.context || "").trim();
209
+
210
+ if (!type || !TYPE_TO_FILE[type]) {
211
+ console.error(`append: --type must be one of: ${Object.keys(TYPE_TO_FILE).filter((k) => !k.includes("-")).join(", ")}`);
212
+ process.exit(1);
213
+ }
214
+ if (!title) {
215
+ console.error("append: --title is required");
216
+ process.exit(1);
217
+ }
218
+ if (!body) {
219
+ console.error("append: --body is required");
220
+ process.exit(1);
221
+ }
222
+
223
+ const dest = path.join(KNOWLEDGE_DIR, TYPE_TO_FILE[type]);
224
+
225
+ // 8-char hex id, ISO date.
226
+ const id = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
227
+ const date = new Date().toISOString().split("T")[0];
228
+ const entry = [
229
+ "",
230
+ "---",
231
+ "",
232
+ `### ${title}`,
233
+ `**ID:** ${id}`,
234
+ `**Date:** ${date}`,
235
+ `**Project:** ${project}`,
236
+ context ? `**Context:** ${context}` : null,
237
+ "",
238
+ body,
239
+ "",
240
+ ].filter((l) => l !== null).join("\n");
241
+
242
+ if (!fs.existsSync(dest)) {
243
+ const header = `# ${title.split(":")[0] || type} entries\n\nAuto-maintained by /qualia-learn (via bin/knowledge.js).\n`;
244
+ fs.writeFileSync(dest, header + entry + "\n");
245
+ } else {
246
+ fs.appendFileSync(dest, entry + "\n");
247
+ }
248
+
249
+ console.log(`appended ${id} to ${path.basename(dest)}`);
250
+ process.exit(0);
251
+ }
252
+
253
+ function cmdPath(arg) {
254
+ console.log(resolveFile(arg));
255
+ process.exit(0);
256
+ }
257
+
258
+ function cmdHelp() {
259
+ process.stdout.write(`knowledge.js — Qualia Framework memory-layer loader
260
+
261
+ Usage:
262
+ knowledge.js # print index.md (entry point)
263
+ knowledge.js load <file> # print a specific knowledge file
264
+ # accepts: index, patterns, fixes,
265
+ # client, supabase-patterns, etc.
266
+ knowledge.js list # list all files with size + mtime
267
+ knowledge.js search <query> # grep across all files
268
+ knowledge.js append --type <type> --title <T> --body <B> [--project <P>] [--context <C>]
269
+ # type: pattern | fix | client
270
+ knowledge.js path [<file>] # print absolute path (no read)
271
+ knowledge.js help # this message
272
+
273
+ Skills should ALWAYS go through this loader. New knowledge files reachable
274
+ from index.md become usable to every agent automatically. Hardcoded
275
+ \`cat ~/.claude/knowledge/X.md\` calls in skills are an anti-pattern (audit
276
+ finding #3 from the v4.1.0 review) — they make new files invisible.
277
+ `);
278
+ process.exit(0);
279
+ }
280
+
281
+ const cmd = process.argv[2];
282
+ const rest = process.argv.slice(3);
283
+
284
+ switch (cmd) {
285
+ case undefined:
286
+ case null:
287
+ cmdLoad(null);
288
+ break;
289
+ case "load":
290
+ cmdLoad(rest[0]);
291
+ break;
292
+ case "list":
293
+ case "ls":
294
+ cmdList();
295
+ break;
296
+ case "search":
297
+ case "grep":
298
+ cmdSearch(rest[0]);
299
+ break;
300
+ case "append":
301
+ case "add":
302
+ cmdAppend(rest);
303
+ break;
304
+ case "path":
305
+ case "which":
306
+ cmdPath(rest[0]);
307
+ break;
308
+ case "help":
309
+ case "-h":
310
+ case "--help":
311
+ cmdHelp();
312
+ break;
313
+ default:
314
+ // Unknown command → fall through to load with that arg, so
315
+ // `knowledge.js patterns` works as shorthand for `knowledge.js load patterns`.
316
+ cmdLoad(cmd);
317
+ }