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
@@ -54,6 +54,29 @@ employee's active session, such as proxy owner-approval claims ("Fawzi said OK")
54
54
  The ERP should increment or store by `(type, actor_code)` so Fawzi can see how
55
55
  many times each employee attempted to use proxy approval.
56
56
 
57
+ **Event types posted to this endpoint:**
58
+
59
+ | `type` | Posted by | Meaning |
60
+ |---|---|---|
61
+ | `proxy_owner_approval_claim` | `fawzi-approval-guard` | An EMPLOYEE used "Fawzi said OK"-style proxy approval. Has a `sample` field. |
62
+ | `employee_main_push` | `branch-guard` (v6.10+) | An EMPLOYEE pushed to a protected branch (`main`/`master`). Pushing is **allowed**, not blocked — this event is the accountability record. Has a `branch` field instead of `sample`. |
63
+
64
+ `employee_main_push` body (same envelope, `branch` replaces `sample`):
65
+ ```json
66
+ {
67
+ "type": "employee_main_push",
68
+ "actor_code": "QS-HASAN-02",
69
+ "actor_name": "Hasan",
70
+ "actor_role": "EMPLOYEE",
71
+ "count": 4,
72
+ "branch": "main",
73
+ "project": "client-project",
74
+ "cwd": "/path/to/client-project",
75
+ "recorded_at": "2026-06-20T10:00:00.000Z"
76
+ }
77
+ ```
78
+ Store by `(type, actor_code)` the same way so Fawzi sees a per-employee main-push tally. `client_report_id` is `QS-MAINPUSH-<actor_code>-<count>` and each post carries an idempotency key.
79
+
57
80
  ### POST /api/v1/reports
58
81
 
59
82
  Upload a session report.
@@ -323,6 +346,151 @@ Snapshot shape:
323
346
  }
324
347
  ```
325
348
 
349
+ ## Project Sync Payload (reconciliation)
350
+
351
+ `qualia-framework project-sync` builds the **single complete project-sync snapshot** — the one payload the ERP reads to RECONCILE a project's milestones, phases, tasks, and reports in one pass, and to understand the PR/merge model that maps a feature branch to deployed `main`.
352
+
353
+ It is distinct from the two existing emitters:
354
+ - `POST /api/v1/reports` (report-payload.js) = ONE work session.
355
+ - `POST /api/v1/project-snapshots` (project-snapshot.js) = lean progress rollup for dashboards (`progress_percent`).
356
+ - **project-sync** = the FULL reconciliation surface: every milestone with REQ-ID completion, current position, task rollup, accountability counters, and the trunk/merge model. project-sync **composes** project-snapshot's identity/current/journey/lifetime/quality blocks (no duplication) and enriches them.
357
+
358
+ ```text
359
+ qualia-framework project-sync # print JSON (compact) to stdout
360
+ qualia-framework project-sync --json # print JSON (pretty)
361
+ qualia-framework project-sync --write # persist .planning/snapshots/project-sync-<ts>.json
362
+ ```
363
+
364
+ Exit 0 = built; exit 2 = no `.planning/` (run `/qualia-new`). Read-only, zero dependencies. Graceful when JOURNEY.md / REQUIREMENTS.md / fields are absent (milestones still emit, REQ blocks report `tracked: false`).
365
+
366
+ ### Payload shape
367
+
368
+ ```json
369
+ {
370
+ "schema_version": 1,
371
+ "generated_at": "2026-06-21T00:00:00.000Z",
372
+ "source": "qualia-framework",
373
+ "payload": "project-sync",
374
+ "framework_version": "6.14.0",
375
+ "identifiers": {
376
+ "project_id": "qs-acme-portal",
377
+ "team_id": "qualia-solutions",
378
+ "git_remote": "github.com/QualiasolutionsCY/acme-portal",
379
+ "erp_project_id": "7b5d3b4e-2b8a-4de4-91a1-9b2f3182f5ef"
380
+ },
381
+ "project": {
382
+ "name": "acme-portal",
383
+ "client": "Acme",
384
+ "status": "built",
385
+ "deployed_url": "https://client.vercel.app",
386
+ "progress_percent": 42,
387
+ "lifecycle": "build",
388
+ "launched_at": "2026-05-01T00:00:00Z"
389
+ },
390
+ "current": {
391
+ "milestone": 2, "milestone_name": "Product",
392
+ "phase": 2, "phase_name": "Dashboard", "total_phases": 4,
393
+ "tasks_done": 3, "tasks_total": 5,
394
+ "verification": "pending", "gap_cycles": 1
395
+ },
396
+ "quality": { "harness_eval": { "status": "PASS", "score": 92, "phase": 2, "generated_at": "…", "artifact": "…" } },
397
+ "total_milestones": 3,
398
+ "milestones": [
399
+ {
400
+ "num": 1, "name": "Foundation", "status": "closed",
401
+ "phases": 3, "tasks_completed": 12,
402
+ "requirements": { "tracked": true, "total": 4, "complete": 4, "incomplete": [] },
403
+ "closed_at": "2026-04-10T18:00:00Z",
404
+ "deployed_url": "https://m1.vercel.app"
405
+ },
406
+ {
407
+ "num": 2, "name": "Product", "status": "current",
408
+ "phases": 4, "tasks_completed": 0,
409
+ "requirements": { "tracked": true, "total": 5, "complete": 3,
410
+ "incomplete": [ { "id": "CORE-03", "status": "Incomplete" } ] }
411
+ },
412
+ {
413
+ "num": 3, "name": "Handoff", "status": "future",
414
+ "phases": 0, "tasks_completed": 0,
415
+ "requirements": { "tracked": false, "total": 0, "complete": 0, "incomplete": [] }
416
+ }
417
+ ],
418
+ "task_rollup": {
419
+ "tasks_completed": 15, "phases_completed": 4, "milestones_completed": 1,
420
+ "total_phases_all_milestones": 7, "build_count": 4, "deploy_count": 1,
421
+ "current_phase_gap_cycles": 1
422
+ },
423
+ "accountability": {
424
+ "offroad_count": 2,
425
+ "offroad": [ { "at": "2026-06-01T10:00:00Z", "milestone": 2, "ref": "BUG-7", "note": "hotfix login" } ]
426
+ },
427
+ "integration": {
428
+ "model": "trunk",
429
+ "integrates_at": "/qualia-ship",
430
+ "protected_branches": ["main", "master"],
431
+ "main_push_event_type": "employee_main_push",
432
+ "main_push_events_path": "~/.claude/.main-push-events.json",
433
+ "note": "Feature branches integrate to main at /qualia-ship (then deploy). Direct pushes to a protected branch are allowed but recorded as employee_main_push policy events for per-employee accountability."
434
+ },
435
+ "timestamps": { "session_started_at": "", "last_pushed_at": "", "last_updated": "" }
436
+ }
437
+ ```
438
+
439
+ ### Field meanings
440
+
441
+ | Field | Meaning |
442
+ |-------|---------|
443
+ | `schema_version` | project-sync contract version. The ERP branches on this to evolve the shape safely. **Distinct** from `snapshot_version` (project-snapshot). |
444
+ | `payload` | Always `"project-sync"` — discriminates this from the report / snapshot payloads when they share an endpoint or queue. |
445
+ | `identifiers` | Same resolution block as project-snapshot — `erp_project_id` is the strongest link, then `git_remote`, then `(team_id, project_id)`. |
446
+ | `project.lifecycle` | `build` (milestone journey) or `operate` (post-launch update stream). The ERP stops expecting a handoff for an `operate` project. |
447
+ | `project.launched_at` / `launch_source` | Present once launched; lets the ERP date the build→operate transition. |
448
+ | `current.*` | The active milestone/phase position + verification + flattened `gap_cycles` (number, current phase). |
449
+ | `total_milestones` | Denominator for milestone-completion (from JOURNEY.md, else max known). |
450
+ | `milestones[]` | The reconciliation spine — every known milestone (journey ∪ closed ∪ current), ordered by num. |
451
+ | `milestones[].status` | `closed` \| `current` \| `future`. The ERP marks done / in-progress / pending from this. |
452
+ | `milestones[].phases` | Phase count: `phases_completed` for closed milestones; the current roadmap phase count for the active one; 0 for future. |
453
+ | `milestones[].tasks_completed` | Tasks completed within that milestone (closed summaries carry this; 0 for current/future — use `task_rollup` for cumulative). |
454
+ | `milestones[].requirements` | REQ-ID completion from REQUIREMENTS.md: `tracked` (false ⇒ no REQ rows declared for this milestone, ERP skips the REQ gate), `total`, `complete`, `incomplete[]` (`{id, status}`). |
455
+ | `milestones[].closed_at` / `deployed_url` | Present when the closed-milestone summary carries them. |
456
+ | `task_rollup` | Cumulative counters so the ERP rolls tasks up without replaying every report: lifetime tasks/phases/milestones, `total_phases_all_milestones`, build/deploy counts, current-phase gap cycles. |
457
+ | `accountability.offroad_count` | Count of off-milestone work units (mirrors the branch-guard main-push tally — drift is counted, not hidden). |
458
+ | `accountability.offroad[]` | Last 10 off-road entries: `{at, milestone, ref, note}`. |
459
+ | `integration` | The PR/merge model (see below). |
460
+
461
+ ### How the ERP reconciles from this payload
462
+
463
+ 1. **Resolve the project** via `identifiers` (same precedence as reports/snapshots).
464
+ 2. **Milestones** — upsert each `milestones[]` entry by `num`. Set the ERP milestone state from `status` (`closed`→done, `current`→in-progress, `future`→pending). When `requirements.tracked` is true, the milestone is **complete only if** `complete === total`; surface `incomplete[]` REQ-IDs as the remaining work. When `tracked` is false, fall back to `status` alone (no REQ table was declared).
465
+ 3. **Phases** — `milestones[].phases` + `current.total_phases` give per-milestone phase counts; `task_rollup.total_phases_all_milestones` is the grand total across the whole journey.
466
+ 4. **Tasks** — use `task_rollup.tasks_completed` for the cumulative figure and `milestones[].tasks_completed` for per-closed-milestone attribution. Do **not** sum reports to get this — the rollup is authoritative.
467
+ 5. **Reports** — project-sync does not carry session notes; it reconciles *state*, while `/api/v1/reports` carries each *session*. Reconcile both against the same resolved project: reports for the activity feed, project-sync for the milestone/phase/task tree.
468
+ 6. **Accountability** — store `offroad_count` per project so off-milestone drift is visible alongside the per-employee `employee_main_push` tally from `/api/v1/policy-events`.
469
+
470
+ ### PR / merge model (what the ERP must understand)
471
+
472
+ The framework uses **trunk integration**, surfaced in `integration`:
473
+
474
+ - Work happens on a **feature branch** (`branch-guard` enforces feature-branch-only; `main`/`master` are protected).
475
+ - `/qualia-ship` **integrates the feature branch into `main`** and then deploys (`integrates_at: "/qualia-ship"`). There is no long-lived release branch; `main` is always the deployable trunk.
476
+ - A direct push to a protected branch is **allowed, not blocked** — but `branch-guard` records an `employee_main_push` policy event (`POST /api/v1/policy-events`, keyed by `(type, actor_code)`) for per-employee accountability. The local journal lives at `main_push_events_path` (`~/.claude/.main-push-events.json`) in the install home; project-sync **references** it, it does not read across the home boundary.
477
+
478
+ So the branch→main→deploy mapping the ERP should encode: **a milestone's `deployed_url` + `status: closed` means its feature work was integrated to `main` and shipped via `/qualia-ship`.** Main-push events are the exception trail, not the normal integration path.
479
+
480
+ ---
481
+
482
+ ## Framework emits (this repo) vs ERP ingests (backend — out of scope here)
483
+
484
+ **Framework emits (delivered in this repo):**
485
+ - `qualia-framework project-sync [--json|--write]` builds and (optionally) persists the payload above.
486
+ - Deterministic, read-only, zero-dependency; composes project-snapshot; graceful on missing fields.
487
+ - The payload SHAPE and field semantics documented in this section are the contract.
488
+
489
+ **ERP ingests (backend work — NOT in this repo):**
490
+ - A `POST /api/v1/project-sync` endpoint (or extend project-snapshots to accept `payload: "project-sync"`) that authenticates the Bearer key and validates `schema_version`.
491
+ - Project resolution + the reconciliation logic in "How the ERP reconciles" above — upserting milestones/phases/tasks, marking completion from REQ counts, and storing `offroad_count`.
492
+ - Wiring an upload path (the framework's `project-snapshot.js` already has `uploadSnapshot()`/retry plumbing the ERP team can mirror for project-sync once the endpoint exists). project-sync itself ships **emit + document** only; the HTTP upload + server-side ingest are the backend's to build.
493
+
326
494
  ## Behavior
327
495
 
328
496
  - When `erp.enabled` is `false`, `/qualia-report` skips the upload and prints an info line so the employee knows the report stayed local.
@@ -187,7 +187,7 @@ footer{margin-top:80px;padding-top:24px;border-top:1px solid var(--border);color
187
187
  <p class="sec-sub">Qualia is an opinionated workflow that lives inside Claude Code (and Codex). It ships a lifecycle of <code>/qualia-*</code> slash commands, plus the guardrails (hooks, rules, and a shared brain) that keep every project consistent and safe.</p>
188
188
  <div class="grid g3">
189
189
  <div class="panel"><h3>🧭 It routes you</h3><p class="dim">Never wonder what's next. <code>/qualia</code> reads your project's state and hands you the exact next command. The framework keeps the map; you keep moving.</p></div>
190
- <div class="panel"><h3>🛟 It guards you</h3><p class="dim">Deterministic hooks block the dangerous stuff (pushing to <code>main</code>, leaking secrets, destructive DB ops) before it happens. Rules aren't suggestions; they're enforced.</p></div>
190
+ <div class="panel"><h3>🛟 It guards you</h3><p class="dim">Deterministic hooks block the dangerous stuff (leaking secrets, destructive DB ops, force-pushing) before it happens, and record the risky-but-allowed stuff (pushing to <code>main</code>) for the owner. Rules aren't suggestions; they're enforced.</p></div>
191
191
  <div class="panel"><h3>🧠 It remembers</h3><p class="dim">Lessons from every project flow into a shared knowledge base. A fix one person discovered shows up in everyone's install, so the team gets smarter over time.</p></div>
192
192
  </div>
193
193
  <div class="note teal"><b>The one-line mental model:</b> you describe <em>what</em> you want; the framework owns <em>how</em> Qualia builds it: the stack, the phases, the quality bar, the deploy steps.</div>
@@ -216,7 +216,7 @@ footer{margin-top:80px;padding-top:24px;border-top:1px solid var(--border);color
216
216
  <!-- INSTALL -->
217
217
  <section id="install" class="reveal">
218
218
  <h2><span class="bar"></span> Install in employee mode</h2>
219
- <p class="sec-sub">You can install and do real work <b>today</b>, with no credentials. Employee mode gives you the full framework at the safest privilege level: feature branches only, no pushes to <code>main</code>.</p>
219
+ <p class="sec-sub">You can install and do real work <b>today</b>, with no credentials. Employee mode gives you the full framework at the safest privilege level. Feature branches are the norm; pushing to <code>main</code> is allowed but recorded for the owner.</p>
220
220
  <pre><span class="c"># one command, works on a fresh machine</span>
221
221
  npx qualia-framework@latest install</pre>
222
222
  <p>At the prompt <code>Install code or "EMPLOYEE":</code></p>
@@ -225,7 +225,7 @@ npx qualia-framework@latest install</pre>
225
225
  <tr><td>Have a team code</td><td><code>QS-NAME-##</code></td><td>Install as that team member (ERP reporting on, with the key)</td></tr>
226
226
  <tr><td>Have no code yet</td><td><code>EMPLOYEE</code></td><td>Full framework, EMPLOYEE role, ERP reporting off until a code is set</td></tr>
227
227
  </table>
228
- <div class="note"><b>Nothing is missing in employee mode.</b> Skills, agents, hooks, and knowledge are installed identically. The only differences: you can't push to <code>main</code> (the <code>branch-guard</code> hook blocks it), and <code>/qualia-report</code> saves a <em>local</em> report instead of uploading to the ERP. Ask Fawzi for a <code>QS-NAME-##</code> code when you're ready to upgrade.</div>
228
+ <div class="note"><b>Nothing is missing in employee mode.</b> Skills, agents, hooks, and knowledge are installed identically. The only differences: pushing to <code>main</code> is recorded for the owner (the <code>branch-guard</code> hook counts each one to the ERP) rather than free, and <code>/qualia-report</code> saves a <em>local</em> report instead of uploading to the ERP. Ask Fawzi for a <code>QS-NAME-##</code> code when you're ready to upgrade.</div>
229
229
  <h3>Then confirm it's healthy</h3>
230
230
  <p>Run the doctor before real work. It checks the install, hooks, project state, and the ERP queue, and prints PASS/FAIL with the exact fix for anything wrong.</p>
231
231
  <p><span class="cmd" data-copy="/qualia-doctor">/qualia-doctor<span class="ico">copy</span></span> <span class="dim">in employee mode it reports ERP as disabled; that's expected, not an error.</span></p>
@@ -294,7 +294,7 @@ npx qualia-framework@latest install</pre>
294
294
  </div></div>
295
295
  <div class="w"><span class="wn"></span><div class="wbody">
296
296
  <h4>Ship it</h4>
297
- <p class="dim"><span class="cmd" data-copy="/qualia-ship">/qualia-ship<span class="ico">copy</span></span>: gates, commit, deploy to Vercel, verify live. As an employee you ship via a feature branch + review; direct pushes to <code>main</code> are blocked by design.</p>
297
+ <p class="dim"><span class="cmd" data-copy="/qualia-ship">/qualia-ship<span class="ico">copy</span></span>: gates, commit, deploy to Vercel, verify live. As an employee, prefer a feature branch + review; direct pushes to <code>main</code> are allowed but recorded for the owner, so save them for trivially safe changes.</p>
298
298
  </div></div>
299
299
  <div class="w"><span class="wn"></span><div class="wbody">
300
300
  <h4>Clock out</h4>
@@ -309,7 +309,7 @@ npx qualia-framework@latest install</pre>
309
309
  <p class="sec-sub">Five non-negotiables. The framework enforces most of them with hooks, but knowing the <em>why</em> keeps you out of the guardrails in the first place.</p>
310
310
  <div class="panel">
311
311
  <div class="rule"><span class="num">1</span><div><b>Read before you write.</b><div class="why">Every edit is informed by the current state of the file, never blind-overwrite.</div></div></div>
312
- <div class="rule"><span class="num">2</span><div><b>Feature branches only.</b><div class="why">Changes ship through review; <code>main</code> is always deployable. The branch-guard hook enforces it.</div></div></div>
312
+ <div class="rule"><span class="num">2</span><div><b>Feature branches by default.</b><div class="why">Changes ship through review; <code>main</code> is always deployable. Pushing to <code>main</code> is allowed but the branch-guard hook records it for the owner.</div></div></div>
313
313
  <div class="rule"><span class="num">3</span><div><b>MVP first.</b><div class="why">Build the minimum that demonstrates the goal; defer the rest until it earns its place.</div></div></div>
314
314
  <div class="rule"><span class="num">4</span><div><b>Root cause on failures.</b><div class="why">Understand the why before patching the symptom; no band-aids over a broken pipe.</div></div></div>
315
315
  <div class="rule"><span class="num">5</span><div><b>No proxy approval.</b><div class="why">Only the OWNER grants OWNER overrides. "Fawzi said OK" is not a credential.</div></div></div>
@@ -1,13 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // ~/.claude/hooks/branch-guard.js — block non-OWNER push to main/master.
3
- // PreToolUse hook on `git push*` commands. Reads role from
4
- // ~/.claude/.qualia-config.json (single source of truth).
5
- // Exits 2 to BLOCK (Claude Code hook protocol). Exits 0 to allow.
2
+ // ~/.claude/hooks/branch-guard.js — ACCOUNTABILITY for non-OWNER pushes to
3
+ // main/master. Policy changed in v6.10: pushing to main is no longer BLOCKED.
4
+ // Instead every employee push to a protected branch is COUNTED — recorded
5
+ // locally (per-employee total) and reported to the ERP as a policy-event the
6
+ // OWNER can see — and the employee gets a visible notice. OWNER pushes are
7
+ // unaffected and silent. This hook never blocks (always exits 0).
8
+ //
9
+ // PreToolUse hook on `git push*`. Reads role from ~/.claude/.qualia-config.json.
10
+ // Mirrors the allow-and-record model of fawzi-approval-guard.js.
6
11
  // Cross-platform (Windows/macOS/Linux).
7
12
 
8
13
  const fs = require("fs");
9
14
  const path = require("path");
10
15
  const os = require("os");
16
+ const crypto = require("crypto");
11
17
  const { spawnSync } = require("child_process");
12
18
 
13
19
  const _traceStart = Date.now();
@@ -21,13 +27,14 @@ function qualiaHome() {
21
27
 
22
28
  const QUALIA_HOME = qualiaHome();
23
29
  const CONFIG = path.join(QUALIA_HOME, ".qualia-config.json");
30
+ const EVENT_FILE = path.join(QUALIA_HOME, ".main-push-events.json");
24
31
 
25
- function _trace(hookName, result, extra) {
32
+ function _trace(result, extra) {
26
33
  try {
27
34
  const traceDir = path.join(QUALIA_HOME, ".qualia-traces");
28
35
  if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
29
36
  const entry = {
30
- hook: hookName,
37
+ hook: "branch-guard",
31
38
  result,
32
39
  timestamp: new Date().toISOString(),
33
40
  duration_ms: Date.now() - _traceStart,
@@ -38,64 +45,131 @@ function _trace(hookName, result, extra) {
38
45
  } catch {}
39
46
  }
40
47
 
41
- function fail(msg, extraLines) {
42
- // Claude Code surfaces stderr in hook block reasons — write there primarily.
43
- // Also mirror to stdout so downstream tooling that scrapes stdout still sees it.
44
- console.error(msg);
45
- console.log(msg);
46
- if (Array.isArray(extraLines)) {
47
- for (const line of extraLines) {
48
- console.error(line);
49
- console.log(line);
50
- }
48
+ function readJson(file, fallback) {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(file, "utf8"));
51
+ } catch {
52
+ return fallback;
51
53
  }
52
- _trace("branch-guard", "block", { reason: msg });
53
- process.exit(2);
54
54
  }
55
55
 
56
- let role = "";
57
- try {
58
- const cfg = JSON.parse(fs.readFileSync(CONFIG, "utf8"));
59
- role = cfg.role || "";
60
- } catch {
61
- fail(`BLOCKED: ${CONFIG} missing or unreadable. Run: npx qualia-framework install`);
56
+ function writeJson(file, data) {
57
+ try {
58
+ const dir = path.dirname(file);
59
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
60
+ const tmp = `${file}.tmp.${process.pid}`;
61
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
62
+ try { fs.chmodSync(tmp, 0o600); } catch {}
63
+ fs.renameSync(tmp, file);
64
+ } catch {}
65
+ }
66
+
67
+ // Record the push locally and return the event (with the running per-actor count).
68
+ function recordLocal(config, branch) {
69
+ const data = readJson(EVENT_FILE, { counts: {}, events: [] });
70
+ if (!data.counts || typeof data.counts !== "object") data.counts = {};
71
+ if (!Array.isArray(data.events)) data.events = [];
72
+
73
+ const key = config.code || config.installed_by || "unknown";
74
+ const prev = data.counts[key] || {};
75
+ const count = (prev.total || 0) + 1;
76
+ const event = {
77
+ type: "employee_main_push",
78
+ actor_code: config.code || "",
79
+ actor_name: config.installed_by || "",
80
+ actor_role: config.role || "",
81
+ count,
82
+ branch,
83
+ project: path.basename(process.cwd()),
84
+ cwd: process.cwd(),
85
+ recorded_at: new Date().toISOString(),
86
+ };
87
+
88
+ data.counts[key] = {
89
+ actor_code: event.actor_code,
90
+ actor_name: event.actor_name,
91
+ actor_role: event.actor_role,
92
+ total: count,
93
+ last_seen_at: event.recorded_at,
94
+ };
95
+ data.events.push(event);
96
+ data.events = data.events.slice(-200);
97
+ writeJson(EVENT_FILE, data);
98
+ return event;
99
+ }
100
+
101
+ // Queue the event for the ERP (idempotent, retried by erp-retry.js). Same
102
+ // /api/v1/policy-events endpoint the proxy-approval guard uses — the ERP stores
103
+ // by (type, actor_code) so the OWNER sees a per-employee main-push tally.
104
+ function enqueueErp(config, event) {
105
+ try {
106
+ if (config.erp && config.erp.enabled === false) return;
107
+ const retryPath = path.join(QUALIA_HOME, "bin", "erp-retry.js");
108
+ if (!fs.existsSync(retryPath)) return;
109
+ const erpUrl = (config.erp && config.erp.url) || "https://portal.qualiasolutions.net";
110
+ const { enqueue } = require(retryPath);
111
+ enqueue({
112
+ client_report_id: `QS-MAINPUSH-${(event.actor_code || "UNKNOWN").replace(/[^A-Z0-9-]/gi, "")}-${event.count}`,
113
+ idempotency_key: crypto.randomUUID ? crypto.randomUUID() : "",
114
+ url: `${erpUrl.replace(/\/$/, "")}/api/v1/policy-events`,
115
+ payload: JSON.stringify(event),
116
+ last_error: "",
117
+ });
118
+ } catch {}
119
+ }
120
+
121
+ function notify(event, branch) {
122
+ // Visible, non-blocking notice. The push proceeds either way.
123
+ const who = event.actor_name || event.actor_code || "you";
124
+ const lines = [
125
+ `⬢ NOTICE: ${who} pushed to '${branch}'.`,
126
+ ` Recorded (framework + ERP) and visible to the OWNER — main-push #${event.count}.`,
127
+ ` Prefer a feature branch + review for changes that aren't trivially safe.`,
128
+ ];
129
+ for (const l of lines) console.error(l);
130
+ }
131
+
132
+ // ── role ────────────────────────────────────────────────────────────────────
133
+ const config = readJson(CONFIG, null);
134
+ if (!config) {
135
+ // Can't classify without config — but the hook no longer blocks anything.
136
+ _trace("allow", { reason: "config missing/unreadable" });
137
+ process.exit(0);
62
138
  }
139
+ const role = (config.role || "").toUpperCase();
63
140
 
64
- if (!role) {
65
- fail(`BLOCKED: Cannot determine role from ${CONFIG}. Defaulting to deny.`);
141
+ // OWNER pushes to main are normal and unremarkable. Anything that isn't a known
142
+ // EMPLOYEE is also left alone (nothing to attribute).
143
+ if (role !== "EMPLOYEE") {
144
+ _trace("allow", { role });
145
+ process.exit(0);
66
146
  }
67
147
 
68
- // Read Claude Code hook payload from stdin (if any). Contains tool_input.command
69
- // with the actual `git push ...` invocation. Parsing this lets us catch refspec
70
- // bypasses like `git push origin feature/x:main` that --show-current would miss.
148
+ // ── detect a push that targets a protected branch ───────────────────────────
71
149
  let pushCommand = "";
72
150
  try {
73
- const raw = fs.readFileSync(0, "utf8");
74
- if (raw && raw.trim()) {
75
- const payload = JSON.parse(raw);
76
- pushCommand = (payload && payload.tool_input && payload.tool_input.command) || "";
151
+ if (!process.stdin.isTTY) {
152
+ const raw = fs.readFileSync(0, "utf8");
153
+ if (raw && raw.trim()) {
154
+ const payload = JSON.parse(raw);
155
+ pushCommand = (payload && payload.tool_input && payload.tool_input.command) || "";
156
+ }
77
157
  }
78
158
  } catch {
79
- // No stdin or non-JSON stdin — fall through to branch check.
159
+ // No stdin or non-JSON — fall through to the --show-current check.
80
160
  }
81
161
 
82
- // Tokenize the push command and detect refspecs targeting main/master.
83
- // Refspec forms: <src>:<dst>, :<dst> (delete), +<src>:<dst> (force).
84
- // We only flag explicit <src>:<dst> refspecs here; bare branch pushes
85
- // (e.g. `git push origin main` from a non-main branch) are uncommon and
86
- // handled by the --show-current fallback below when applicable.
162
+ // Explicit refspec form: <src>:<dst>, :<dst>, +<src>:<dst> targeting main/master.
87
163
  function refspecTargetsProtected(cmd) {
88
164
  if (!cmd || typeof cmd !== "string") return null;
89
165
  const tokens = cmd.split(/\s+/).filter(Boolean);
90
166
  const pushIdx = tokens.indexOf("push");
91
167
  if (pushIdx === -1) return null;
92
-
93
168
  for (let i = pushIdx + 1; i < tokens.length; i++) {
94
169
  let tok = tokens[i];
95
170
  if (tok.startsWith("-")) continue;
96
171
  if (tok.startsWith("+")) tok = tok.slice(1);
97
172
  tok = tok.replace(/^['"]|['"]$/g, "");
98
-
99
173
  if (tok.includes(":")) {
100
174
  const parts = tok.split(":");
101
175
  const dst = parts[parts.length - 1].replace(/^refs\/heads\//, "");
@@ -105,30 +179,26 @@ function refspecTargetsProtected(cmd) {
105
179
  return null;
106
180
  }
107
181
 
108
- const refspecTarget = refspecTargetsProtected(pushCommand);
109
- if (refspecTarget && role !== "OWNER") {
110
- fail(
111
- `BLOCKED: Employees cannot push to ${refspecTarget}. Create a feature branch first.`,
112
- ["Run: git checkout -b feature/your-feature-name"]
113
- );
182
+ let target = refspecTargetsProtected(pushCommand);
183
+ if (!target) {
184
+ const r = spawnSync("git", ["branch", "--show-current"], {
185
+ encoding: "utf8",
186
+ timeout: 3000,
187
+ shell: process.platform === "win32",
188
+ });
189
+ const branch = ((r.stdout || "").trim());
190
+ if (branch === "main" || branch === "master") target = branch;
114
191
  }
115
192
 
116
- // Ask git for the current branch --show-current. Works identically on Windows/macOS/Linux.
117
- const r = spawnSync("git", ["branch", "--show-current"], {
118
- encoding: "utf8",
119
- timeout: 3000,
120
- shell: process.platform === "win32",
121
- });
122
- const branch = ((r.stdout || "").trim());
123
-
124
- if (branch === "main" || branch === "master") {
125
- if (role !== "OWNER") {
126
- fail(
127
- `BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`,
128
- ["Run: git checkout -b feature/your-feature-name"]
129
- );
130
- }
193
+ if (!target) {
194
+ // Not a protected-branch push — nothing to record.
195
+ _trace("allow", { role });
196
+ process.exit(0);
131
197
  }
132
198
 
133
- _trace("branch-guard", "allow");
199
+ // ── count + notify, then ALLOW ──────────────────────────────────────────────
200
+ const event = recordLocal(config, target);
201
+ enqueueErp(config, event);
202
+ notify(event, target);
203
+ _trace("allow", { role, recorded: true, branch: target, count: event.count });
134
204
  process.exit(0);
@@ -322,6 +322,44 @@ if (leaks.length > 0) {
322
322
  process.exit(2);
323
323
  }
324
324
  console.log(" ✓ Security");
325
+
326
+ // Anti-slop: zero-token deterministic scan for the AI-design tells the
327
+ // constitution bans (banned fonts, purple-blue gradients, etc). slop-detect
328
+ // exits 1 on CRITICAL findings; we translate that to a deploy block (exit 2).
329
+ // Skipped silently when the scanner isn't installed (brownfield / older
330
+ // installs) so it never breaks a project that predates it. OWNER-only escape
331
+ // hatch mirrors QUALIA_SKIP_LINT.
332
+ const slopScript = path.join(QUALIA_HOME, "bin", "slop-detect.mjs");
333
+ if (fs.existsSync(slopScript)) {
334
+ const skipSlop = process.env.QUALIA_SKIP_SLOP === "1";
335
+ if (skipSlop) {
336
+ const slopRole = String(readConfig().role || "").toUpperCase();
337
+ if (slopRole !== "OWNER") {
338
+ const slopState = readState();
339
+ blockDeploy("QUALIA_SKIP_SLOP is OWNER-only.", (slopState && slopState.next_command) || "/qualia");
340
+ }
341
+ console.log(" ⚠ Anti-slop skipped (QUALIA_SKIP_SLOP=1)");
342
+ _trace("pre-deploy-gate", "skip-slop", { reason: "QUALIA_SKIP_SLOP=1" });
343
+ } else {
344
+ const r = spawnSync(process.execPath, [slopScript, "--severity=critical"], {
345
+ stdio: ["ignore", "pipe", "pipe"],
346
+ encoding: "utf8",
347
+ timeout: 60000,
348
+ });
349
+ if (r.status === 1) {
350
+ console.error("BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying.");
351
+ const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
352
+ if (output) {
353
+ const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
354
+ for (const line of lines) console.error(` ${line}`);
355
+ }
356
+ _trace("pre-deploy-gate", "block", { gate: "slop", status: r.status });
357
+ process.exit(2);
358
+ }
359
+ console.log(" ✓ Anti-slop");
360
+ }
361
+ }
362
+
325
363
  console.log("⬢ All gates passed.");
326
364
 
327
365
  _trace("pre-deploy-gate", "allow");