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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +208 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +264 -0
- package/bin/analyze-gate.js +318 -0
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +2 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +72 -12
- package/bin/install.js +27 -17
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/report-payload.js +7 -0
- package/bin/runtime-manifest.js +8 -0
- package/bin/state.js +257 -12
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/EMPLOYEE-QUICKSTART.md +3 -3
- package/docs/erp-contract.md +168 -0
- package/docs/qualia-manual.html +5 -5
- package/hooks/branch-guard.js +133 -63
- package/hooks/pre-deploy-gate.js +38 -0
- package/hooks/task-write-guard.js +165 -0
- package/package.json +3 -2
- 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 +39 -7
- 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-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +27 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-report/SKILL.md +10 -0
- package/skills/qualia-scope/SKILL.md +3 -3
- package/skills/qualia-ship/SKILL.md +37 -4
- package/skills/qualia-update/SKILL.md +100 -0
- package/skills/qualia-verify/SKILL.md +51 -24
- package/templates/instructions.md +32 -0
- package/templates/journey.md +2 -2
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +153 -0
- package/tests/analyze-gate.test.sh +170 -0
- package/tests/bin.test.sh +5 -4
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/hooks.test.sh +218 -17
- package/tests/install-smoke.test.sh +4 -3
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +2 -2
- package/tests/project-sync.test.sh +175 -0
- package/tests/run-all.sh +9 -0
- package/tests/runner.js +3 -2
- package/tests/state.test.sh +187 -0
- package/tests/verify-panel.test.sh +162 -0
- package/tests/wave-plan.test.sh +153 -0
- package/skills/qualia-discuss/SKILL.md +0 -222
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// eval-runner.js — layered assertion engine for AI features (chat / RAG / voice).
|
|
3
|
+
//
|
|
4
|
+
// WHY: Qualia gates UI and code (contract-runner, slop-detect, verify-panel) but
|
|
5
|
+
// has no equivalent for the AI artifacts it BUILDS. "The chatbot answers refund
|
|
6
|
+
// questions" is not checkable by a grep. This runs a suite of cases against
|
|
7
|
+
// captured AI outputs with LAYERED assertions: cheap deterministic checks first
|
|
8
|
+
// (contains / regex / json_path / length / latency / cost), then llm_rubric for
|
|
9
|
+
// the judgments only a model can make — same shape as contract-runner evidence.
|
|
10
|
+
//
|
|
11
|
+
// Deterministic-first by design: every assertion this module can settle WITHOUT
|
|
12
|
+
// a model, it settles here. `llm_rubric` assertions carry a `verdict`
|
|
13
|
+
// (pass|fail) that the /qualia-eval skill fills by spawning a judge BEFORE
|
|
14
|
+
// calling this runner — exactly how verify-panel consumes skeptic votes. An
|
|
15
|
+
// llm_rubric with no verdict is PENDING and fails the suite (never silently
|
|
16
|
+
// passes an unjudged rubric).
|
|
17
|
+
//
|
|
18
|
+
// Suite (JSON — zero-dependency; no YAML parser pulled in):
|
|
19
|
+
// {
|
|
20
|
+
// "feature": "support-chat",
|
|
21
|
+
// "cases": [
|
|
22
|
+
// { "name": "refund window",
|
|
23
|
+
// "input": "what's your refund policy?",
|
|
24
|
+
// "output": "We refund within 30 days...", // or "output_file": "path"
|
|
25
|
+
// "latency_ms": 1200, "cost_usd": 0.008,
|
|
26
|
+
// "assert": [
|
|
27
|
+
// { "type": "contains", "value": "30 days" },
|
|
28
|
+
// { "type": "not_contains", "value": "I cannot help" },
|
|
29
|
+
// { "type": "max_latency_ms", "value": 2000 },
|
|
30
|
+
// { "type": "llm_rubric", "rubric": "grounded in policy, no hallucination", "verdict": "pass" }
|
|
31
|
+
// ] } ] }
|
|
32
|
+
//
|
|
33
|
+
// Exit 0 = all cases pass, 1 = a failure or an unjudged rubric, 2 = bad input.
|
|
34
|
+
// Zero npm dependencies. Library + CLI.
|
|
35
|
+
|
|
36
|
+
const fs = require("fs");
|
|
37
|
+
const path = require("path");
|
|
38
|
+
|
|
39
|
+
function getPath(obj, dotted) {
|
|
40
|
+
const parts = String(dotted).split(".").filter(Boolean);
|
|
41
|
+
let cur = obj;
|
|
42
|
+
for (const p of parts) {
|
|
43
|
+
if (cur == null) return undefined;
|
|
44
|
+
const idx = /^\d+$/.test(p) ? Number(p) : p;
|
|
45
|
+
cur = cur[idx];
|
|
46
|
+
}
|
|
47
|
+
return cur;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeRegex(src) {
|
|
51
|
+
try { return new RegExp(src); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Run one assertion against a case's captured output + metrics.
|
|
55
|
+
// Returns { ok, detail }.
|
|
56
|
+
function runAssertion(a, ctx) {
|
|
57
|
+
const out = ctx.output != null ? String(ctx.output) : "";
|
|
58
|
+
switch (a.type) {
|
|
59
|
+
case "contains":
|
|
60
|
+
return { ok: out.includes(String(a.value)), detail: `contains "${a.value}"` };
|
|
61
|
+
case "not_contains":
|
|
62
|
+
return { ok: !out.includes(String(a.value)), detail: `not_contains "${a.value}"` };
|
|
63
|
+
case "equals":
|
|
64
|
+
return { ok: out === String(a.value), detail: `equals "${a.value}"` };
|
|
65
|
+
case "regex": {
|
|
66
|
+
const re = safeRegex(a.value);
|
|
67
|
+
if (!re) return { ok: false, detail: `invalid regex: ${a.value}` };
|
|
68
|
+
return { ok: re.test(out), detail: `regex /${a.value}/` };
|
|
69
|
+
}
|
|
70
|
+
case "not_regex": {
|
|
71
|
+
const re = safeRegex(a.value);
|
|
72
|
+
if (!re) return { ok: false, detail: `invalid regex: ${a.value}` };
|
|
73
|
+
return { ok: !re.test(out), detail: `not_regex /${a.value}/` };
|
|
74
|
+
}
|
|
75
|
+
case "min_length":
|
|
76
|
+
return { ok: out.length >= Number(a.value), detail: `min_length ${a.value} (got ${out.length})` };
|
|
77
|
+
case "max_length":
|
|
78
|
+
return { ok: out.length <= Number(a.value), detail: `max_length ${a.value} (got ${out.length})` };
|
|
79
|
+
case "json_valid":
|
|
80
|
+
try { JSON.parse(out); return { ok: true, detail: "json_valid" }; }
|
|
81
|
+
catch { return { ok: false, detail: "output is not valid JSON" }; }
|
|
82
|
+
case "json_path": {
|
|
83
|
+
let parsed;
|
|
84
|
+
try { parsed = JSON.parse(out); } catch { return { ok: false, detail: "json_path: output not JSON" }; }
|
|
85
|
+
const val = getPath(parsed, a.path);
|
|
86
|
+
if (val === undefined) return { ok: false, detail: `json_path ${a.path}: missing` };
|
|
87
|
+
if (a.equals !== undefined) return { ok: String(val) === String(a.equals), detail: `json_path ${a.path} equals "${a.equals}" (got "${val}")` };
|
|
88
|
+
if (a.contains !== undefined) return { ok: String(val).includes(String(a.contains)), detail: `json_path ${a.path} contains "${a.contains}"` };
|
|
89
|
+
return { ok: true, detail: `json_path ${a.path} present` };
|
|
90
|
+
}
|
|
91
|
+
case "max_latency_ms":
|
|
92
|
+
if (ctx.latency_ms == null) return { ok: false, detail: "max_latency_ms: no latency_ms recorded on case" };
|
|
93
|
+
return { ok: Number(ctx.latency_ms) <= Number(a.value), detail: `max_latency_ms ${a.value} (got ${ctx.latency_ms})` };
|
|
94
|
+
case "max_cost_usd":
|
|
95
|
+
if (ctx.cost_usd == null) return { ok: false, detail: "max_cost_usd: no cost_usd recorded on case" };
|
|
96
|
+
return { ok: Number(ctx.cost_usd) <= Number(a.value), detail: `max_cost_usd ${a.value} (got ${ctx.cost_usd})` };
|
|
97
|
+
case "llm_rubric": {
|
|
98
|
+
const v = a.verdict != null ? String(a.verdict).toLowerCase() : null;
|
|
99
|
+
if (v === null) return { ok: false, pending: true, detail: `llm_rubric PENDING (no judge verdict): "${a.rubric}"` };
|
|
100
|
+
return { ok: v === "pass", detail: `llm_rubric "${a.rubric}" → ${v}` };
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
return { ok: false, detail: `unknown assertion type: ${a.type}` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveOutput(root, c) {
|
|
108
|
+
if (c.output != null) return c.output;
|
|
109
|
+
if (c.output_file) {
|
|
110
|
+
try { return fs.readFileSync(path.resolve(root, c.output_file), "utf8"); }
|
|
111
|
+
catch (e) { return { __error: `output_file unreadable: ${e.message}` }; }
|
|
112
|
+
}
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function run(suite, opts = {}) {
|
|
117
|
+
const root = path.resolve(opts.cwd || process.cwd());
|
|
118
|
+
const cases = [];
|
|
119
|
+
let pending = 0;
|
|
120
|
+
for (const c of suite.cases || []) {
|
|
121
|
+
const output = resolveOutput(root, c);
|
|
122
|
+
const ctx = { output: (output && output.__error) ? "" : output, latency_ms: c.latency_ms, cost_usd: c.cost_usd };
|
|
123
|
+
const assertions = [];
|
|
124
|
+
if (output && output.__error) {
|
|
125
|
+
assertions.push({ type: "output", ok: false, detail: output.__error });
|
|
126
|
+
}
|
|
127
|
+
for (const a of c.assert || []) {
|
|
128
|
+
const r = runAssertion(a, ctx);
|
|
129
|
+
if (r.pending) pending++;
|
|
130
|
+
assertions.push({ type: a.type, ok: !!r.ok, pending: !!r.pending, detail: r.detail });
|
|
131
|
+
}
|
|
132
|
+
const ok = assertions.length > 0 && assertions.every((x) => x.ok);
|
|
133
|
+
cases.push({ name: c.name || "(unnamed)", ok, assertions });
|
|
134
|
+
}
|
|
135
|
+
const passed = cases.filter((c) => c.ok).length;
|
|
136
|
+
return {
|
|
137
|
+
ok: cases.length > 0 && passed === cases.length,
|
|
138
|
+
feature: suite.feature || "(unnamed feature)",
|
|
139
|
+
total_cases: cases.length,
|
|
140
|
+
passed_cases: passed,
|
|
141
|
+
failed_cases: cases.length - passed,
|
|
142
|
+
pending,
|
|
143
|
+
cases,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
148
|
+
function parseArgs(argv) {
|
|
149
|
+
const args = { _: [] };
|
|
150
|
+
for (let i = 2; i < argv.length; i++) {
|
|
151
|
+
const a = argv[i];
|
|
152
|
+
if (a === "--json") args.json = true;
|
|
153
|
+
else if (a === "--write") args.write = true;
|
|
154
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
155
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
156
|
+
else args._.push(a);
|
|
157
|
+
}
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function usage() {
|
|
162
|
+
console.error([
|
|
163
|
+
"Usage:",
|
|
164
|
+
" eval-runner.js <suite.json> [--json] [--write] [--cwd DIR]",
|
|
165
|
+
"",
|
|
166
|
+
"Layered assertion runner for AI-feature eval suites.",
|
|
167
|
+
"Exit 0 = all cases pass, 1 = failure/unjudged rubric, 2 = invocation error.",
|
|
168
|
+
].join("\n"));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function main(argv) {
|
|
172
|
+
const args = parseArgs(argv);
|
|
173
|
+
const suitePath = args._[0];
|
|
174
|
+
if (!suitePath || suitePath === "-h" || suitePath === "--help") { usage(); return 2; }
|
|
175
|
+
const root = path.resolve(args.cwd || process.cwd());
|
|
176
|
+
|
|
177
|
+
let suite;
|
|
178
|
+
try {
|
|
179
|
+
suite = JSON.parse(fs.readFileSync(path.resolve(root, suitePath), "utf8"));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.error(`ERROR: cannot read eval suite: ${e.message}`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = run(suite, { cwd: root });
|
|
186
|
+
|
|
187
|
+
if (args.write) {
|
|
188
|
+
const dir = path.join(root, ".planning", "evals");
|
|
189
|
+
try {
|
|
190
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
191
|
+
const slug = String(result.feature).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
192
|
+
fs.writeFileSync(path.join(dir, `eval-${slug || "feature"}.json`), JSON.stringify(result, null, 2) + "\n");
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error(`WARN: could not write eval artifact: ${e.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (args.json) {
|
|
199
|
+
console.log(JSON.stringify(result, null, 2));
|
|
200
|
+
return result.ok ? 0 : 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(`EVAL ${result.ok ? "PASS" : "FAIL"} [${result.feature}]: ` +
|
|
204
|
+
`${result.passed_cases}/${result.total_cases} cases` +
|
|
205
|
+
(result.pending ? ` (${result.pending} rubric(s) unjudged)` : ""));
|
|
206
|
+
for (const c of result.cases) {
|
|
207
|
+
if (c.ok) continue;
|
|
208
|
+
console.log(` ✗ ${c.name}`);
|
|
209
|
+
for (const a of c.assertions) if (!a.ok) console.log(` - ${a.detail}`);
|
|
210
|
+
}
|
|
211
|
+
return result.ok ? 0 : 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { run, runAssertion, getPath };
|
|
215
|
+
|
|
216
|
+
if (require.main === module) {
|
|
217
|
+
process.exit(main(process.argv));
|
|
218
|
+
}
|
package/bin/host-adapters.js
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// host-adapters.js — the single internal contract for everything that differs
|
|
3
|
+
// between the runtimes Qualia targets (Claude Code, OpenAI Codex).
|
|
4
|
+
//
|
|
5
|
+
// The rule (ACP shape): NOTHING else branches on runtime. Skills, install, and
|
|
6
|
+
// hooks ask the adapter for a fact ("what's the instruction filename?", "how do
|
|
7
|
+
// I render this text for this host?") instead of hardcoding an `if (codex)`.
|
|
8
|
+
// When a third runtime arrives, it's one entry in HOSTS — not a grep-and-patch
|
|
9
|
+
// across the tree.
|
|
3
10
|
|
|
4
11
|
const path = require("path");
|
|
5
12
|
const os = require("os");
|
|
6
13
|
|
|
14
|
+
// Per-host facts. Each host declares — declaratively, in ONE place:
|
|
15
|
+
// home install root
|
|
16
|
+
// name display name (used in the naming map + UX copy)
|
|
17
|
+
// instructionFile the top-level agent-instructions file this runtime reads
|
|
18
|
+
// configFile the runtime's settings file
|
|
19
|
+
// agentDir where subagent definitions live (relative to home)
|
|
20
|
+
// agentExt the subagent file extension the runtime consumes
|
|
21
|
+
// naming source→host display-string swaps applied to rendered text
|
|
7
22
|
const HOSTS = {
|
|
8
23
|
claude: {
|
|
9
24
|
name: "Claude Code",
|
|
10
25
|
home: path.join(os.homedir(), ".claude"),
|
|
26
|
+
instructionFile: "CLAUDE.md",
|
|
27
|
+
configFile: "settings.json",
|
|
28
|
+
agentDir: "agents",
|
|
29
|
+
agentExt: ".md",
|
|
30
|
+
naming: {}, // Claude is the canonical voice — no swaps.
|
|
11
31
|
},
|
|
12
32
|
codex: {
|
|
13
33
|
name: "OpenAI Codex",
|
|
14
34
|
home: path.join(os.homedir(), ".codex"),
|
|
35
|
+
instructionFile: "AGENTS.md",
|
|
36
|
+
configFile: "config.toml",
|
|
37
|
+
agentDir: "agents",
|
|
38
|
+
agentExt: ".toml",
|
|
39
|
+
// Canonical source speaks "Claude Code"; Codex artifacts speak "Codex".
|
|
40
|
+
naming: { "Claude Code": "Codex", "Claude's": "Codex's" },
|
|
15
41
|
},
|
|
16
42
|
};
|
|
17
43
|
|
|
@@ -21,6 +47,9 @@ function adapter(name) {
|
|
|
21
47
|
const home = host.home;
|
|
22
48
|
return {
|
|
23
49
|
...host,
|
|
50
|
+
instructionPath: path.join(home, host.instructionFile),
|
|
51
|
+
configPath: path.join(home, host.configFile),
|
|
52
|
+
agentPath: path.join(home, host.agentDir),
|
|
24
53
|
tokens: {
|
|
25
54
|
QUALIA_HOME: home,
|
|
26
55
|
QUALIA_BIN: `${home}/bin`,
|
|
@@ -35,32 +64,63 @@ function adapter(name) {
|
|
|
35
64
|
};
|
|
36
65
|
}
|
|
37
66
|
|
|
38
|
-
|
|
67
|
+
// Host-display naming swaps only (e.g. "Claude Code" → "Codex"). Split out from
|
|
68
|
+
// path rendering so the canonical→artifact compile can swap names WITHOUT baking
|
|
69
|
+
// in install-time paths (install resolves those per host afterwards).
|
|
70
|
+
function applyNaming(content, hostName) {
|
|
71
|
+
const host = adapter(hostName);
|
|
72
|
+
let out = String(content);
|
|
73
|
+
for (const [from, to] of Object.entries(host.naming)) {
|
|
74
|
+
out = out.replaceAll(from, to);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Path/token rendering: ${QUALIA_*} tokens + legacy `.claude/` literals → this
|
|
80
|
+
// host's install paths. Idempotent (running twice is a no-op), so install can
|
|
81
|
+
// safely re-render an already-compiled artifact.
|
|
82
|
+
function applyPaths(content, hostName) {
|
|
39
83
|
const host = adapter(hostName);
|
|
40
84
|
let out = String(content);
|
|
41
85
|
for (const [token, value] of Object.entries(host.tokens)) {
|
|
42
86
|
out = out.replaceAll(`\${${token}}`, value);
|
|
43
87
|
}
|
|
44
|
-
|
|
45
|
-
// Backward-compatible rendering while source files migrate from hardcoded
|
|
46
|
-
// Claude paths to explicit ${QUALIA_*} tokens.
|
|
47
|
-
out = out
|
|
88
|
+
return out
|
|
48
89
|
.replaceAll("~/.claude/", `${host.home}/`)
|
|
49
90
|
.replaceAll("$HOME/.claude/", `${host.home}/`)
|
|
50
91
|
.replaceAll("${HOME}/.claude/", `${host.home}/`)
|
|
51
92
|
.replaceAll("@~/.claude/", `@${host.home}/`)
|
|
52
93
|
.replaceAll(".claude/", `${path.basename(host.home)}/`);
|
|
94
|
+
}
|
|
53
95
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
96
|
+
// Full install-time render: paths + naming. Unchanged public behavior.
|
|
97
|
+
function renderText(content, hostName) {
|
|
98
|
+
return applyNaming(applyPaths(content, hostName), hostName);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Host-conditional blocks in a canonical source:
|
|
102
|
+
// <!--QUALIA-HOST claude--> …kept only for claude… <!--/QUALIA-HOST-->
|
|
103
|
+
// Keep the matching host's block (unwrapped), drop every other host's block.
|
|
104
|
+
function stripHostBlocks(content, hostName) {
|
|
105
|
+
const re = /[ \t]*<!--QUALIA-HOST\s+(\w+)-->\n?([\s\S]*?)\n?[ \t]*<!--\/QUALIA-HOST-->\n?/g;
|
|
106
|
+
return String(content).replace(re, (_m, host, body) =>
|
|
107
|
+
host === hostName ? `${body}\n` : ""
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Compile a canonical instruction source into one host's artifact: resolve
|
|
112
|
+
// conditional blocks + apply naming, but DO NOT resolve paths or {{ROLE}} —
|
|
113
|
+
// those stay as tokens for install.js to render per concrete install.
|
|
114
|
+
function compileInstructions(canonical, hostName) {
|
|
115
|
+
return applyNaming(stripHostBlocks(canonical, hostName), hostName);
|
|
60
116
|
}
|
|
61
117
|
|
|
62
118
|
module.exports = {
|
|
63
119
|
HOSTS,
|
|
64
120
|
adapter,
|
|
121
|
+
applyNaming,
|
|
122
|
+
applyPaths,
|
|
65
123
|
renderText,
|
|
124
|
+
stripHostBlocks,
|
|
125
|
+
compileInstructions,
|
|
66
126
|
};
|
package/bin/install.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require("fs");
|
|
|
6
6
|
const ui = require("./qualia-ui.js");
|
|
7
7
|
const { RUNTIME_BIN_SCRIPTS, binFiles } = require("./runtime-manifest.js");
|
|
8
8
|
const { ACTIVE_SKILLS, RETIRED_SKILLS } = require("./command-surface.js");
|
|
9
|
-
const { renderText } = require("./host-adapters.js");
|
|
9
|
+
const { renderText, adapter } = require("./host-adapters.js");
|
|
10
10
|
|
|
11
11
|
// ─── Colors (kept for legacy log lines; new sections route through qualia-ui) ─
|
|
12
12
|
const TEAL = "\x1b[38;2;0;206;209m";
|
|
@@ -555,8 +555,9 @@ function askCode() {
|
|
|
555
555
|
// ─── Employee mode (no team code) ────────────────────────
|
|
556
556
|
// A new employee may not have a QS-NAME-## code yet. Typing "EMPLOYEE" at the
|
|
557
557
|
// install-code prompt installs the full framework at the least-privilege role:
|
|
558
|
-
// feature branches
|
|
559
|
-
//
|
|
558
|
+
// feature branches by default; main pushes are allowed but recorded by
|
|
559
|
+
// branch-guard (counted locally + reported to the ERP, which trusts the role bit
|
|
560
|
+
// in the 0o600 config). ERP reporting stays OFF until a real team
|
|
560
561
|
// code is set, so /qualia-report degrades to a local-only report.
|
|
561
562
|
//
|
|
562
563
|
// Defaulting to EMPLOYEE (not OWNER) on a missing/keyword code is the secure
|
|
@@ -955,13 +956,16 @@ async function main() {
|
|
|
955
956
|
// ─── CLAUDE.md with role ───────────────────────────────
|
|
956
957
|
printSection("Configuration");
|
|
957
958
|
try {
|
|
959
|
+
// Source + dest filenames come from the host adapter (single per-host
|
|
960
|
+
// contract). CLAUDE.md is a generated artifact of templates/instructions.md.
|
|
961
|
+
const claudeFile = adapter("claude").instructionFile;
|
|
958
962
|
let claudeMd = fs.readFileSync(
|
|
959
|
-
path.join(FRAMEWORK_DIR,
|
|
963
|
+
path.join(FRAMEWORK_DIR, claudeFile),
|
|
960
964
|
"utf8"
|
|
961
965
|
);
|
|
962
966
|
claudeMd = claudeMd.replace("{{ROLE}}", member.role);
|
|
963
967
|
claudeMd = claudeMd.replace("{{ROLE_DESCRIPTION}}", member.description);
|
|
964
|
-
const claudeDest = path.join(CLAUDE_DIR,
|
|
968
|
+
const claudeDest = path.join(CLAUDE_DIR, claudeFile);
|
|
965
969
|
// v5.0: backup existing CLAUDE.md before overwrite. Users may have added
|
|
966
970
|
// personal instructions; without a backup, re-install silently destroys
|
|
967
971
|
// them. .bak files are harmless and easy to clean up.
|
|
@@ -1276,7 +1280,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1276
1280
|
"session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
|
|
1277
1281
|
"pre-deploy-gate.js", "migration-guard.js", "pre-compact.js",
|
|
1278
1282
|
"git-guardrails.js", "stop-session-log.js",
|
|
1279
|
-
"fawzi-approval-guard.js",
|
|
1283
|
+
"fawzi-approval-guard.js", "task-write-guard.js",
|
|
1280
1284
|
// v5.0 — insights-driven destructive-op + wrong-account guards
|
|
1281
1285
|
"vercel-account-guard.js", "env-empty-guard.js", "supabase-destructive-guard.js",
|
|
1282
1286
|
// performance-audit telemetry capture (UserPromptSubmit)
|
|
@@ -1305,7 +1309,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1305
1309
|
{ type: "command", command: nodeCmd("auto-update.js"), timeout: 5 },
|
|
1306
1310
|
{ type: "command", command: nodeCmd("git-guardrails.js"), timeout: 5, statusMessage: "⬢ Checking git safety..." },
|
|
1307
1311
|
{ type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
|
|
1308
|
-
{ type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "⬢
|
|
1312
|
+
{ type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "⬢ Recording branch activity..." },
|
|
1309
1313
|
{ type: "command", if: "Bash(git push*)", command: nodeCmd("pre-push.js"), timeout: 15, statusMessage: "⬢ Syncing tracking..." },
|
|
1310
1314
|
{ type: "command", if: "Bash(vercel --prod*)", command: nodeCmd("pre-deploy-gate.js"), timeout: 600, statusMessage: "⬢ Running quality gates..." },
|
|
1311
1315
|
// v5.0 hooks — insights-driven friction prevention
|
|
@@ -1318,6 +1322,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1318
1322
|
matcher: "Edit|Write",
|
|
1319
1323
|
hooks: [
|
|
1320
1324
|
{ type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
|
|
1325
|
+
{ type: "command", command: nodeCmd("task-write-guard.js"), timeout: 5, statusMessage: "⬢ Checking plan-contract file scope..." },
|
|
1321
1326
|
{ type: "command", if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)", command: nodeCmd("migration-guard.js"), timeout: 10, statusMessage: "⬢ Checking migration safety..." },
|
|
1322
1327
|
],
|
|
1323
1328
|
},
|
|
@@ -1588,24 +1593,29 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1588
1593
|
}
|
|
1589
1594
|
}
|
|
1590
1595
|
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1596
|
+
// AGENTS.md is the Codex artifact of templates/instructions.md (compiled by
|
|
1597
|
+
// bin/compile-instructions.js). Source filename + render come from the host
|
|
1598
|
+
// adapter, mirroring the CLAUDE.md path exactly. codexText() resolves
|
|
1599
|
+
// ${QUALIA_*} tokens + .claude→.codex paths — CLAUDE.md got this rendering;
|
|
1600
|
+
// AGENTS.md previously did not (latent drift the single-contract path fixes).
|
|
1601
|
+
const codexFile = adapter("codex").instructionFile;
|
|
1594
1602
|
let agentsContent;
|
|
1595
1603
|
try {
|
|
1596
|
-
agentsContent = fs.readFileSync(path.join(FRAMEWORK_DIR,
|
|
1597
|
-
agentsContent =
|
|
1598
|
-
|
|
1599
|
-
|
|
1604
|
+
agentsContent = fs.readFileSync(path.join(FRAMEWORK_DIR, codexFile), "utf8");
|
|
1605
|
+
agentsContent = codexText(
|
|
1606
|
+
agentsContent
|
|
1607
|
+
.replace("{{ROLE}}", member.role)
|
|
1608
|
+
.replace("{{ROLE_DESCRIPTION}}", member.description)
|
|
1609
|
+
);
|
|
1600
1610
|
} catch (e) {
|
|
1601
|
-
warn(`Codex — could not read framework
|
|
1611
|
+
warn(`Codex — could not read framework ${codexFile}: ${e.message}`);
|
|
1602
1612
|
return;
|
|
1603
1613
|
}
|
|
1604
1614
|
|
|
1605
|
-
const dest = path.join(CODEX_DIR,
|
|
1615
|
+
const dest = path.join(CODEX_DIR, codexFile);
|
|
1606
1616
|
|
|
1607
1617
|
try {
|
|
1608
|
-
backupIfDifferent(dest, agentsContent,
|
|
1618
|
+
backupIfDifferent(dest, agentsContent, codexFile);
|
|
1609
1619
|
atomicWrite(dest, agentsContent);
|
|
1610
1620
|
sectionCount++;
|
|
1611
1621
|
ok(`AGENTS.md (configured as ${member.role})`);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// last-report.js — surface "where work was left off last time" at session start.
|
|
3
|
+
//
|
|
4
|
+
// WHY: the /qualia router classifies state from state.js, .continue-here.md, and
|
|
5
|
+
// git log — but it ignores .planning/reports/, which is the richest "where we
|
|
6
|
+
// left off" signal a team has. A teammate (or future-you) picking the project
|
|
7
|
+
// back up should instantly see the last session's outcome and next step without
|
|
8
|
+
// opening a file. This extracts a compact digest from the newest report so the
|
|
9
|
+
// router can print it at the TOP of its output when a project is loaded.
|
|
10
|
+
//
|
|
11
|
+
// Reports are written by /qualia-report at .planning/reports/report-{YYYY-MM-DD}.md
|
|
12
|
+
// (the date filename may carry a suffix, e.g. report-2026-05-31-session3.md). The
|
|
13
|
+
// markdown has a "# Session Report — {date}" title, a "**Date:**" line, a
|
|
14
|
+
// "## What Was Done" section (the summary), and a "## Next Steps" section.
|
|
15
|
+
//
|
|
16
|
+
// Read-only. Zero npm dependencies.
|
|
17
|
+
// Exit 0 = a report was found, 1 = none found, 2 = bad input.
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
|
|
22
|
+
// Pull a YYYY-MM-DD from a report filename. Returns null when absent so a
|
|
23
|
+
// misnamed file falls back to mtime ordering rather than poisoning the sort.
|
|
24
|
+
function dateFromName(name) {
|
|
25
|
+
const m = name.match(/(\d{4}-\d{2}-\d{2})/);
|
|
26
|
+
return m ? m[1] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Whole-day difference between two ISO-ish dates (report date vs --now). Floors
|
|
30
|
+
// to a non-negative integer; a future report date (clock skew) reads as 0.
|
|
31
|
+
function ageDays(dateStr, nowIso) {
|
|
32
|
+
if (!dateStr) return null;
|
|
33
|
+
const then = Date.parse(dateStr + "T00:00:00Z");
|
|
34
|
+
const now = nowIso ? Date.parse(nowIso) : Date.now();
|
|
35
|
+
if (Number.isNaN(then) || Number.isNaN(now)) return null;
|
|
36
|
+
return Math.max(0, Math.floor((now - then) / 86400000));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Collapse markdown bullets/bold/whitespace into one tight prose line.
|
|
40
|
+
function flatten(s) {
|
|
41
|
+
return s
|
|
42
|
+
.replace(/^\s+/, "") // leading indentation, so list-marker anchors match
|
|
43
|
+
.replace(/\*\*/g, "")
|
|
44
|
+
.replace(/^[-*]\s+/, "") // unordered bullet
|
|
45
|
+
.replace(/^\d+[.)]\s+/, "") // ordered list marker (1. / 1))
|
|
46
|
+
.replace(/`/g, "")
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Lines that carry no signal for a digest (metadata, blank, headings).
|
|
52
|
+
function isNoise(line) {
|
|
53
|
+
const t = line.trim();
|
|
54
|
+
if (!t) return true;
|
|
55
|
+
if (t.startsWith("#")) return true;
|
|
56
|
+
if (/^\*\*[A-Za-z ]+:\*\*/.test(t)) return true; // **Project:** / **Date:** etc.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find the body under a "## {heading}" until the next "## " heading or EOF.
|
|
61
|
+
function sectionBody(lines, headingRe) {
|
|
62
|
+
let i = lines.findIndex((l) => headingRe.test(l.trim()));
|
|
63
|
+
if (i === -1) return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
66
|
+
if (/^##\s/.test(lines[j].trim())) break;
|
|
67
|
+
out.push(lines[j]);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// First meaningful sentence/clause of a section, capped to keep the digest tight.
|
|
73
|
+
function firstMeaningful(bodyLines, cap = 160) {
|
|
74
|
+
for (const raw of bodyLines) {
|
|
75
|
+
if (isNoise(raw)) continue;
|
|
76
|
+
const flat = flatten(raw);
|
|
77
|
+
if (!flat) continue;
|
|
78
|
+
return flat.length > cap ? flat.slice(0, cap - 1).trimEnd() + "…" : flat;
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Extract the digest from a single report's markdown text.
|
|
84
|
+
function digest(text) {
|
|
85
|
+
const lines = text.split(/\r?\n/);
|
|
86
|
+
|
|
87
|
+
// Date: prefer the explicit **Date:** line, else the title's trailing date.
|
|
88
|
+
let date = null;
|
|
89
|
+
const dateLine = lines.find((l) => /^\*\*Date:\*\*/.test(l.trim()));
|
|
90
|
+
if (dateLine) date = (dateFromName(dateLine) || null);
|
|
91
|
+
if (!date) {
|
|
92
|
+
const title = lines.find((l) => /^#\s+Session Report/.test(l.trim()));
|
|
93
|
+
if (title) date = dateFromName(title);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Summary: first meaningful line of "## What Was Done", else first meaningful
|
|
97
|
+
// line of the whole body (so malformed reports still yield something).
|
|
98
|
+
let summary = firstMeaningful(sectionBody(lines, /^##\s+What Was Done/i));
|
|
99
|
+
if (!summary) summary = firstMeaningful(lines);
|
|
100
|
+
|
|
101
|
+
// Next: first meaningful line of "## Next Steps" / "## Next Step".
|
|
102
|
+
let next = firstMeaningful(sectionBody(lines, /^##\s+Next Steps?/i));
|
|
103
|
+
|
|
104
|
+
return { date, summary, next };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Library: find the newest report under root and return its digest object.
|
|
108
|
+
// { found, file, date, summary, next, age_days }
|
|
109
|
+
function latestReport(root, opts = {}) {
|
|
110
|
+
const dir = path.join(path.resolve(root || process.cwd()), ".planning", "reports");
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = fs.readdirSync(dir);
|
|
114
|
+
} catch {
|
|
115
|
+
return { found: false, file: null, date: null, summary: "", next: "", age_days: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const reports = entries
|
|
119
|
+
.filter((f) => /^report-.*\.md$/.test(f))
|
|
120
|
+
.map((f) => {
|
|
121
|
+
const full = path.join(dir, f);
|
|
122
|
+
let mtime = 0;
|
|
123
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch {}
|
|
124
|
+
return { file: f, full, date: dateFromName(f), mtime };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (reports.length === 0) {
|
|
128
|
+
return { found: false, file: null, date: null, summary: "", next: "", age_days: null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Newest by filename date desc; tiebreak (same/absent date) by mtime desc.
|
|
132
|
+
reports.sort((a, b) => {
|
|
133
|
+
const ad = a.date || "";
|
|
134
|
+
const bd = b.date || "";
|
|
135
|
+
if (ad !== bd) return bd.localeCompare(ad);
|
|
136
|
+
return b.mtime - a.mtime;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const top = reports[0];
|
|
140
|
+
let text = "";
|
|
141
|
+
try { text = fs.readFileSync(top.full, "utf8"); } catch {}
|
|
142
|
+
const d = digest(text);
|
|
143
|
+
const date = d.date || top.date || null;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
found: true,
|
|
147
|
+
file: top.file,
|
|
148
|
+
date,
|
|
149
|
+
summary: d.summary,
|
|
150
|
+
next: d.next,
|
|
151
|
+
age_days: ageDays(date, opts.now),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
156
|
+
function parseArgs(argv) {
|
|
157
|
+
const args = {};
|
|
158
|
+
for (let i = 2; i < argv.length; i++) {
|
|
159
|
+
const a = argv[i];
|
|
160
|
+
if (a === "--json") args.json = true;
|
|
161
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
162
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
163
|
+
else if (a === "--now") args.now = argv[++i]; // ISO; tests inject determinism
|
|
164
|
+
else if (a.startsWith("--now=")) args.now = a.slice(6);
|
|
165
|
+
else { args._bad = a; }
|
|
166
|
+
}
|
|
167
|
+
return args;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function main(argv) {
|
|
171
|
+
const args = parseArgs(argv);
|
|
172
|
+
if (args._bad) {
|
|
173
|
+
console.error(`last-report: unknown argument '${args._bad}'`);
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
result = latestReport(args.cwd, { now: args.now });
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.error(`last-report: ${e.message || e}`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (args.json) {
|
|
186
|
+
console.log(JSON.stringify(result, null, 2));
|
|
187
|
+
return result.found ? 0 : 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!result.found) {
|
|
191
|
+
console.log("No session reports yet — nothing to surface.");
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const age = result.age_days == null ? "?" : result.age_days;
|
|
196
|
+
const summary = result.summary || "(no summary in report)";
|
|
197
|
+
let line = `Last session (${result.date || "undated"}, ${age}d ago): ${summary}`;
|
|
198
|
+
if (result.next) line += ` → next: ${result.next}`;
|
|
199
|
+
console.log(line);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { latestReport };
|
|
204
|
+
|
|
205
|
+
if (require.main === module) {
|
|
206
|
+
process.exit(main(process.argv));
|
|
207
|
+
}
|