qualia-framework 6.9.2 → 6.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +208 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +264 -0
- package/bin/analyze-gate.js +318 -0
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +2 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +72 -12
- package/bin/install.js +27 -17
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/report-payload.js +7 -0
- package/bin/runtime-manifest.js +8 -0
- package/bin/state.js +257 -12
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/EMPLOYEE-QUICKSTART.md +3 -3
- package/docs/erp-contract.md +168 -0
- package/docs/qualia-manual.html +5 -5
- package/hooks/branch-guard.js +133 -63
- package/hooks/pre-deploy-gate.js +38 -0
- package/hooks/task-write-guard.js +165 -0
- package/package.json +3 -2
- package/rules/codex-goal.md +28 -26
- package/rules/infrastructure.md +1 -1
- package/skills/qualia/SKILL.md +6 -0
- package/skills/qualia-build/SKILL.md +39 -7
- package/skills/qualia-eval/SKILL.md +83 -0
- package/skills/qualia-feature/SKILL.md +20 -4
- package/skills/qualia-fix/SKILL.md +13 -1
- package/skills/qualia-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +27 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-report/SKILL.md +10 -0
- package/skills/qualia-scope/SKILL.md +3 -3
- package/skills/qualia-ship/SKILL.md +37 -4
- package/skills/qualia-update/SKILL.md +100 -0
- package/skills/qualia-verify/SKILL.md +51 -24
- package/templates/instructions.md +32 -0
- package/templates/journey.md +2 -2
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +153 -0
- package/tests/analyze-gate.test.sh +170 -0
- package/tests/bin.test.sh +5 -4
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/hooks.test.sh +218 -17
- package/tests/install-smoke.test.sh +4 -3
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +2 -2
- package/tests/project-sync.test.sh +175 -0
- package/tests/run-all.sh +9 -0
- package/tests/runner.js +3 -2
- package/tests/state.test.sh +187 -0
- package/tests/verify-panel.test.sh +162 -0
- package/tests/wave-plan.test.sh +153 -0
- package/skills/qualia-discuss/SKILL.md +0 -222
package/docs/erp-contract.md
CHANGED
|
@@ -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.
|
package/docs/qualia-manual.html
CHANGED
|
@@ -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 (
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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>
|
package/hooks/branch-guard.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ~/.claude/hooks/branch-guard.js —
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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(
|
|
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:
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
57
|
-
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
159
|
+
// No stdin or non-JSON — fall through to the --show-current check.
|
|
80
160
|
}
|
|
81
161
|
|
|
82
|
-
//
|
|
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
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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);
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -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");
|