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,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
+ }
@@ -1,17 +1,43 @@
1
1
  #!/usr/bin/env node
2
- // Host adapter rendering for installed Qualia text surfaces.
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
- function renderText(content, hostName) {
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
- if (hostName === "codex") {
55
- out = out
56
- .replaceAll("Claude Code", "Codex")
57
- .replaceAll("Claude's", "Codex's");
58
- }
59
- return out;
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 only, no main pushes (enforced by branch-guard, which trusts
559
- // the role bit in the 0o600 config). ERP reporting stays OFF until a real team
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, "CLAUDE.md"),
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, "CLAUDE.md");
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: "⬢ Checking branch permissions..." },
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
- // Render AGENTS.md with role substitution. Source is the same AGENTS.md
1592
- // already shipped in the framework root (it's the cross-vendor mirror of
1593
- // CLAUDE.md, kept under 25 lines per Pocock's instruction-budget rule).
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, "AGENTS.md"), "utf8");
1597
- agentsContent = agentsContent
1598
- .replace("{{ROLE}}", member.role)
1599
- .replace("{{ROLE_DESCRIPTION}}", member.description);
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 AGENTS.md: ${e.message}`);
1611
+ warn(`Codex — could not read framework ${codexFile}: ${e.message}`);
1602
1612
  return;
1603
1613
  }
1604
1614
 
1605
- const dest = path.join(CODEX_DIR, "AGENTS.md");
1615
+ const dest = path.join(CODEX_DIR, codexFile);
1606
1616
 
1607
1617
  try {
1608
- backupIfDifferent(dest, agentsContent, "AGENTS.md");
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
+ }