qualia-framework 6.14.0 → 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 +130 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/bin/agent-status.js +24 -11
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +1 -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 +21 -13
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/runtime-manifest.js +6 -0
- package/bin/state.js +112 -1
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/erp-contract.md +145 -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 +12 -9
- package/skills/qualia-eval/SKILL.md +83 -0
- package/skills/qualia-feature/SKILL.md +20 -4
- package/skills/qualia-fix/SKILL.md +13 -1
- package/skills/qualia-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 +34 -4
- package/skills/qualia-update/SKILL.md +4 -0
- package/skills/qualia-verify/SKILL.md +45 -24
- package/templates/instructions.md +32 -0
- package/templates/journey.md +1 -1
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +15 -0
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +2 -2
- package/tests/project-sync.test.sh +175 -0
- package/tests/run-all.sh +7 -0
- package/tests/state.test.sh +92 -0
- package/tests/verify-panel.test.sh +162 -0
- package/tests/wave-plan.test.sh +153 -0
|
@@ -0,0 +1,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";
|
|
@@ -956,13 +956,16 @@ async function main() {
|
|
|
956
956
|
// ─── CLAUDE.md with role ───────────────────────────────
|
|
957
957
|
printSection("Configuration");
|
|
958
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;
|
|
959
962
|
let claudeMd = fs.readFileSync(
|
|
960
|
-
path.join(FRAMEWORK_DIR,
|
|
963
|
+
path.join(FRAMEWORK_DIR, claudeFile),
|
|
961
964
|
"utf8"
|
|
962
965
|
);
|
|
963
966
|
claudeMd = claudeMd.replace("{{ROLE}}", member.role);
|
|
964
967
|
claudeMd = claudeMd.replace("{{ROLE_DESCRIPTION}}", member.description);
|
|
965
|
-
const claudeDest = path.join(CLAUDE_DIR,
|
|
968
|
+
const claudeDest = path.join(CLAUDE_DIR, claudeFile);
|
|
966
969
|
// v5.0: backup existing CLAUDE.md before overwrite. Users may have added
|
|
967
970
|
// personal instructions; without a backup, re-install silently destroys
|
|
968
971
|
// them. .bak files are harmless and easy to clean up.
|
|
@@ -1590,24 +1593,29 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1590
1593
|
}
|
|
1591
1594
|
}
|
|
1592
1595
|
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
//
|
|
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;
|
|
1596
1602
|
let agentsContent;
|
|
1597
1603
|
try {
|
|
1598
|
-
agentsContent = fs.readFileSync(path.join(FRAMEWORK_DIR,
|
|
1599
|
-
agentsContent =
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
+
);
|
|
1602
1610
|
} catch (e) {
|
|
1603
|
-
warn(`Codex — could not read framework
|
|
1611
|
+
warn(`Codex — could not read framework ${codexFile}: ${e.message}`);
|
|
1604
1612
|
return;
|
|
1605
1613
|
}
|
|
1606
1614
|
|
|
1607
|
-
const dest = path.join(CODEX_DIR,
|
|
1615
|
+
const dest = path.join(CODEX_DIR, codexFile);
|
|
1608
1616
|
|
|
1609
1617
|
try {
|
|
1610
|
-
backupIfDifferent(dest, agentsContent,
|
|
1618
|
+
backupIfDifferent(dest, agentsContent, codexFile);
|
|
1611
1619
|
atomicWrite(dest, agentsContent);
|
|
1612
1620
|
sectionCount++;
|
|
1613
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
|
+
}
|