qualia-framework 6.9.2 → 6.22.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 (64) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +208 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/agents/verifier.md +1 -1
  6. package/bin/agent-status.js +264 -0
  7. package/bin/analyze-gate.js +318 -0
  8. package/bin/branch-hygiene.js +135 -0
  9. package/bin/command-surface.js +2 -0
  10. package/bin/compile-instructions.js +82 -0
  11. package/bin/eval-runner.js +218 -0
  12. package/bin/host-adapters.js +72 -12
  13. package/bin/install.js +27 -17
  14. package/bin/last-report.js +207 -0
  15. package/bin/project-sync.js +315 -0
  16. package/bin/report-payload.js +7 -0
  17. package/bin/runtime-manifest.js +8 -0
  18. package/bin/state.js +257 -12
  19. package/bin/verify-panel.js +294 -0
  20. package/bin/wave-plan.js +211 -0
  21. package/docs/EMPLOYEE-QUICKSTART.md +3 -3
  22. package/docs/erp-contract.md +168 -0
  23. package/docs/qualia-manual.html +5 -5
  24. package/hooks/branch-guard.js +133 -63
  25. package/hooks/pre-deploy-gate.js +38 -0
  26. package/hooks/task-write-guard.js +165 -0
  27. package/package.json +3 -2
  28. package/rules/codex-goal.md +28 -26
  29. package/rules/infrastructure.md +1 -1
  30. package/skills/qualia/SKILL.md +6 -0
  31. package/skills/qualia-build/SKILL.md +39 -7
  32. package/skills/qualia-eval/SKILL.md +83 -0
  33. package/skills/qualia-feature/SKILL.md +20 -4
  34. package/skills/qualia-fix/SKILL.md +13 -1
  35. package/skills/qualia-milestone/SKILL.md +12 -6
  36. package/skills/qualia-new/REFERENCE.md +6 -4
  37. package/skills/qualia-new/SKILL.md +27 -15
  38. package/skills/qualia-plan/SKILL.md +2 -2
  39. package/skills/qualia-report/SKILL.md +10 -0
  40. package/skills/qualia-scope/SKILL.md +3 -3
  41. package/skills/qualia-ship/SKILL.md +37 -4
  42. package/skills/qualia-update/SKILL.md +100 -0
  43. package/skills/qualia-verify/SKILL.md +51 -24
  44. package/templates/instructions.md +32 -0
  45. package/templates/journey.md +2 -2
  46. package/templates/project-discovery.md +30 -23
  47. package/templates/requirements.md +7 -7
  48. package/tests/agent-status.test.sh +153 -0
  49. package/tests/analyze-gate.test.sh +170 -0
  50. package/tests/bin.test.sh +5 -4
  51. package/tests/branch-hygiene.test.sh +93 -0
  52. package/tests/eval-runner.test.sh +147 -0
  53. package/tests/hooks.test.sh +218 -17
  54. package/tests/install-smoke.test.sh +4 -3
  55. package/tests/instructions.test.sh +109 -0
  56. package/tests/last-report.test.sh +156 -0
  57. package/tests/lib.test.sh +2 -2
  58. package/tests/project-sync.test.sh +175 -0
  59. package/tests/run-all.sh +9 -0
  60. package/tests/runner.js +3 -2
  61. package/tests/state.test.sh +187 -0
  62. package/tests/verify-panel.test.sh +162 -0
  63. package/tests/wave-plan.test.sh +153 -0
  64. package/skills/qualia-discuss/SKILL.md +0 -222
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ // analyze-gate.js — cross-artifact consistency + coverage gate (Spec-Kit's
3
+ // most-copied feature). Runs BETWEEN plan and build: diffs the plan contract
4
+ // against the scope's acceptance criteria and the CONTEXT.md glossary, and
5
+ // flags requirements the plan under-covers plus contradictions it introduces.
6
+ //
7
+ // WHY: Qualia validates each artifact in isolation — plan-contract.js proves the
8
+ // contract is internally well-formed, harness-eval scores the built phase — but
9
+ // nothing diffs scope ↔ plan. That's exactly where a junior's idea silently
10
+ // loses intent: the scope asks for X, the plan quietly drops it, and no
11
+ // deterministic check notices. This is that check.
12
+ //
13
+ // DETERMINISTIC: keyword/token coverage, not an LLM. Same inputs → same output.
14
+ // Heuristic by nature (token overlap), so it is a *flag-for-review* gate: the
15
+ // caller decides hard-block (strict profile) vs advisory (standard). Exit 0 =
16
+ // clean, 1 = findings, 2 = invocation error.
17
+ //
18
+ // Inputs (auto-discovered from --phase, or passed explicitly):
19
+ // contract .planning/phase-{N}-contract.json (required)
20
+ // scope .planning/phase-{N}-context.md (optional)
21
+ // context .planning/CONTEXT.md (optional, glossary)
22
+ //
23
+ // Zero npm dependencies. Library + CLI.
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const pc = require("./plan-contract.js");
28
+
29
+ // Tokens shorter than this, or in the stop list, carry no coverage signal.
30
+ const MIN_TOKEN_LEN = 4;
31
+ const STOPWORDS = new Set([
32
+ "the", "and", "for", "with", "that", "this", "from", "into", "your", "you",
33
+ "are", "was", "were", "will", "shall", "should", "must", "when", "then",
34
+ "each", "every", "any", "all", "not", "but", "via", "per", "page", "user",
35
+ "users", "data", "system", "feature", "support", "able", "ensure", "show",
36
+ "display", "have", "has", "can", "its", "their", "they", "them", "which",
37
+ "where", "what", "who", "how", "use", "uses", "used", "using", "new",
38
+ ]);
39
+
40
+ // A requirement is "covered" when at least this fraction of its significant
41
+ // tokens appear somewhere in the plan corpus, OR ABS_OVERLAP distinct tokens do.
42
+ const COVERAGE_RATIO = 0.5;
43
+ const ABS_OVERLAP = 3;
44
+
45
+ function tokenize(text) {
46
+ return String(text || "")
47
+ .toLowerCase()
48
+ .split(/[^a-z0-9]+/)
49
+ .filter((t) => t.length >= MIN_TOKEN_LEN && !STOPWORDS.has(t));
50
+ }
51
+
52
+ function significantTokens(text) {
53
+ return new Set(tokenize(text));
54
+ }
55
+
56
+ // covered ⇔ ≥COVERAGE_RATIO of the requirement's tokens are in the corpus, or
57
+ // ≥ABS_OVERLAP of them are. Requirements with no significant tokens are treated
58
+ // as covered (nothing to match — e.g. a one-word "Works.").
59
+ function coverage(reqText, corpusTokenSet) {
60
+ const reqTokens = [...significantTokens(reqText)];
61
+ if (reqTokens.length === 0) return { covered: true, overlap: 0, total: 0, ratio: 1 };
62
+ const overlap = reqTokens.filter((t) => corpusTokenSet.has(t)).length;
63
+ const ratio = overlap / reqTokens.length;
64
+ return {
65
+ covered: ratio >= COVERAGE_RATIO || overlap >= ABS_OVERLAP,
66
+ overlap,
67
+ total: reqTokens.length,
68
+ ratio,
69
+ };
70
+ }
71
+
72
+ // ── Scope parsing ───────────────────────────────────────────────────────
73
+ // Pull the bullets under "## Acceptance Criteria" (testable observables) from a
74
+ // phase-context / scope markdown file. Bullets look like "- AC1 — {criterion}"
75
+ // or plain "- {criterion}". Stops at the next "## " heading.
76
+ function parseScopeAcceptanceCriteria(md) {
77
+ if (!md) return [];
78
+ const lines = md.split(/\r?\n/);
79
+ const out = [];
80
+ let inSection = false;
81
+ for (const line of lines) {
82
+ if (/^##\s+/.test(line)) {
83
+ inSection = /^##\s+Acceptance Criteria/i.test(line);
84
+ continue;
85
+ }
86
+ if (!inSection) continue;
87
+ const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
88
+ if (!m) continue;
89
+ // Strip a leading "AC12 —"/"AC12:"/"AC12 -" label so it isn't matched as a token.
90
+ const text = m[1].replace(/^AC\d+\s*[—:\-]\s*/i, "").trim();
91
+ if (text) out.push(text);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // Pull "Avoid:" aliases from CONTEXT.md glossary lines. The glossary bans
97
+ // alternative terms ("**Avoid:** AuthUser vs Customer"); using a banned alias in
98
+ // the plan is a genuine, deterministic cross-artifact contradiction.
99
+ function parseGlossaryBannedTerms(md) {
100
+ if (!md) return [];
101
+ const banned = new Set();
102
+ const lines = md.split(/\r?\n/);
103
+ for (const line of lines) {
104
+ const m = line.match(/\*{0,2}Avoid:?\*{0,2}\s*(.+)$/i);
105
+ if (!m) continue;
106
+ // "AuthUser vs Customer (unless disambiguated)" → ["AuthUser", "Customer"]
107
+ const body = m[1].replace(/\([^)]*\)/g, " ");
108
+ for (const part of body.split(/\bvs\b|,|\/|;/i)) {
109
+ const term = part.trim().replace(/[.*_`]/g, "");
110
+ // Only meaningful identifier-like aliases (skip prose).
111
+ if (/^[A-Za-z][A-Za-z0-9 _-]{2,}$/.test(term) && !STOPWORDS.has(term.toLowerCase())) {
112
+ banned.add(term.trim());
113
+ }
114
+ }
115
+ }
116
+ return [...banned];
117
+ }
118
+
119
+ // ── Core analysis ─────────────────────────────────────────────────────────
120
+ function analyze({ contract, scopeMd, contextMd }) {
121
+ const findings = [];
122
+
123
+ const tasks = (contract && contract.tasks) || [];
124
+ const successCriteria = (contract && contract.success_criteria) || [];
125
+
126
+ // The plan corpus = everything the plan promises to do.
127
+ const planText = [
128
+ contract.goal,
129
+ contract.why,
130
+ ...successCriteria,
131
+ ...tasks.map((t) => t.title),
132
+ ...tasks.map((t) => t.action),
133
+ ...tasks.flatMap((t) => t.acceptance_criteria || []),
134
+ ].filter(Boolean).join("\n");
135
+ const planTokens = significantTokens(planText);
136
+
137
+ // Per-task corpora for success-criterion → task mapping.
138
+ const taskCorpora = tasks.map((t) => ({
139
+ id: t.id,
140
+ tokens: significantTokens([t.title, t.action, ...(t.acceptance_criteria || [])].filter(Boolean).join("\n")),
141
+ }));
142
+
143
+ // 1. Scope acceptance criteria under-covered by the plan (the core check).
144
+ const scopeACs = parseScopeAcceptanceCriteria(scopeMd);
145
+ for (const ac of scopeACs) {
146
+ const cov = coverage(ac, planTokens);
147
+ if (!cov.covered) {
148
+ findings.push({
149
+ type: "uncovered-scope-ac",
150
+ severity: "HIGH",
151
+ message: `Scope acceptance criterion under-covered by the plan: "${ac}"`,
152
+ detail: `${cov.overlap}/${cov.total} key terms found in the contract (need ≥${Math.ceil(cov.total * COVERAGE_RATIO)} or ${ABS_OVERLAP}). The plan may have dropped this requirement.`,
153
+ });
154
+ }
155
+ }
156
+
157
+ // 2. Contract success criteria with no task home (orphan requirement).
158
+ for (const sc of successCriteria) {
159
+ const best = taskCorpora
160
+ .map((tc) => ({ id: tc.id, cov: coverage(sc, tc.tokens) }))
161
+ .sort((a, b) => b.cov.ratio - a.cov.ratio)[0];
162
+ if (!best || !best.cov.covered) {
163
+ findings.push({
164
+ type: "uncovered-success-criterion",
165
+ severity: "MEDIUM",
166
+ message: `Success criterion maps to no task: "${sc}"`,
167
+ detail: "No task's title/action/acceptance-criteria covers this success criterion. Either a task is missing or the criterion is unowned.",
168
+ });
169
+ }
170
+ }
171
+
172
+ // 3. Glossary contradictions — a banned alias used in the plan text.
173
+ const banned = parseGlossaryBannedTerms(contextMd);
174
+ for (const term of banned) {
175
+ const re = new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
176
+ if (re.test(planText)) {
177
+ findings.push({
178
+ type: "glossary-violation",
179
+ severity: "MEDIUM",
180
+ message: `Plan uses a term CONTEXT.md bans: "${term}"`,
181
+ detail: "The glossary lists this under Avoid:. Use the canonical term so spec, plan, and code agree.",
182
+ });
183
+ }
184
+ }
185
+
186
+ // 4. Scope-reduction phrases anywhere in the plan's free text (reuse the
187
+ // plan-contract scanner — surfaced here as part of the cross-artifact gate).
188
+ for (const t of tasks) {
189
+ const fields = [["action", t.action], ...(t.acceptance_criteria || []).map((a, i) => [`acceptance_criteria[${i}]`, a])];
190
+ for (const [field, val] of fields) {
191
+ const hits = pc.findScopeReductionPhrases(val);
192
+ if (hits.length) {
193
+ findings.push({
194
+ type: "scope-reduction",
195
+ severity: "HIGH",
196
+ message: `Task ${t.id} ${field} contains scope-reduction language: ${hits.join(", ")}`,
197
+ detail: "The plan waters down the spec. Deliver the actual requirement or split it via the locked-decision channel.",
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ const bySeverity = (s) => findings.filter((f) => f.severity === s).length;
204
+ return {
205
+ ok: findings.length === 0,
206
+ phase: contract.phase,
207
+ counts: {
208
+ total: findings.length,
209
+ high: bySeverity("HIGH"),
210
+ medium: bySeverity("MEDIUM"),
211
+ scope_acs_checked: scopeACs.length,
212
+ success_criteria_checked: successCriteria.length,
213
+ glossary_terms_checked: banned.length,
214
+ },
215
+ scope_present: scopeMd != null,
216
+ context_present: contextMd != null,
217
+ findings,
218
+ };
219
+ }
220
+
221
+ // ── CLI ───────────────────────────────────────────────────────────────────
222
+ function readMaybe(p) {
223
+ try { return fs.readFileSync(p, "utf8"); } catch { return null; }
224
+ }
225
+
226
+ function parseArgs(argv) {
227
+ const args = { _: [] };
228
+ for (let i = 2; i < argv.length; i++) {
229
+ const a = argv[i];
230
+ if (a === "--json") args.json = true;
231
+ else if (a === "--cwd") args.cwd = argv[++i];
232
+ else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
233
+ else if (a === "--contract") args.contract = argv[++i];
234
+ else if (a.startsWith("--contract=")) args.contract = a.slice(11);
235
+ else if (a === "--scope") args.scope = argv[++i];
236
+ else if (a.startsWith("--scope=")) args.scope = a.slice(8);
237
+ else if (a === "--context") args.context = argv[++i];
238
+ else if (a.startsWith("--context=")) args.context = a.slice(10);
239
+ else if (a === "--phase") args.phase = argv[++i];
240
+ else if (a.startsWith("--phase=")) args.phase = a.slice(8);
241
+ else args._.push(a);
242
+ }
243
+ if (args.phase == null && args._.length && /^\d+$/.test(args._[0])) args.phase = args._[0];
244
+ return args;
245
+ }
246
+
247
+ function usage() {
248
+ console.error([
249
+ "Usage:",
250
+ " analyze-gate.js <phase> [--cwd DIR] [--json]",
251
+ " analyze-gate.js --contract <c.json> [--scope <s.md>] [--context <ctx.md>] [--json]",
252
+ "",
253
+ "Cross-artifact coverage gate between plan and build.",
254
+ "Exit 0 = clean, 1 = findings, 2 = invocation error.",
255
+ ].join("\n"));
256
+ }
257
+
258
+ function main(argv) {
259
+ const args = parseArgs(argv);
260
+ const root = path.resolve(args.cwd || process.cwd());
261
+ const planning = path.join(root, ".planning");
262
+
263
+ let contractPath = args.contract;
264
+ let scopePath = args.scope;
265
+ let contextPath = args.context;
266
+ if (!contractPath && args.phase != null) {
267
+ contractPath = path.join(planning, `phase-${args.phase}-contract.json`);
268
+ if (scopePath == null) scopePath = path.join(planning, `phase-${args.phase}-context.md`);
269
+ if (contextPath == null) contextPath = path.join(planning, "CONTEXT.md");
270
+ }
271
+ if (!contractPath) { usage(); return 2; }
272
+
273
+ const loaded = pc.readContractFile(contractPath);
274
+ if (!loaded.ok) {
275
+ if (args.json) console.log(JSON.stringify({ ok: false, ...loaded }, null, 2));
276
+ else console.error(`${loaded.error}: ${loaded.message}`);
277
+ return 2;
278
+ }
279
+
280
+ const result = analyze({
281
+ contract: loaded.contract,
282
+ scopeMd: scopePath ? readMaybe(scopePath) : null,
283
+ contextMd: contextPath ? readMaybe(contextPath) : null,
284
+ });
285
+
286
+ if (args.json) {
287
+ console.log(JSON.stringify(result, null, 2));
288
+ return result.ok ? 0 : 1;
289
+ }
290
+
291
+ const c = result.counts;
292
+ if (result.ok) {
293
+ console.log(`ANALYZE PASS phase ${result.phase}: scope↔plan consistent ` +
294
+ `(${c.scope_acs_checked} scope AC, ${c.success_criteria_checked} success criteria, ${c.glossary_terms_checked} glossary terms checked)`);
295
+ if (!result.scope_present) console.log(" note: no scope file (phase-N-context.md) — scope-coverage check skipped");
296
+ return 0;
297
+ }
298
+
299
+ console.error(`ANALYZE FINDINGS phase ${result.phase}: ${c.total} (${c.high} HIGH, ${c.medium} MEDIUM)`);
300
+ if (!result.scope_present) console.error(" note: no scope file — scope-coverage check was skipped");
301
+ for (const f of result.findings) {
302
+ console.error(` [${f.severity}] ${f.message}`);
303
+ console.error(` ${f.detail}`);
304
+ }
305
+ return 1;
306
+ }
307
+
308
+ module.exports = {
309
+ analyze,
310
+ coverage,
311
+ tokenize,
312
+ parseScopeAcceptanceCriteria,
313
+ parseGlossaryBannedTerms,
314
+ };
315
+
316
+ if (require.main === module) {
317
+ process.exit(main(process.argv));
318
+ }
@@ -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
+ }
@@ -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",
@@ -21,6 +22,7 @@ const ACTIVE_SKILLS = [
21
22
  "qualia-polish",
22
23
  "qualia-test",
23
24
  "qualia-milestone",
25
+ "qualia-update",
24
26
  "qualia-ship",
25
27
  "qualia-handoff",
26
28
  "qualia-report",
@@ -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
+ }