qualia-framework 6.14.0 → 7.0.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 +316 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/bin/agent-status.js +24 -11
- package/bin/batch-plan.js +111 -0
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +2 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/design-tokens.js +131 -0
- package/bin/erp-event.js +177 -0
- package/bin/erp-retry.js +12 -1
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +84 -12
- package/bin/install.js +44 -13
- package/bin/knowledge-flush.js +6 -3
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/recall.js +172 -0
- package/bin/repo-map.js +188 -0
- package/bin/runtime-manifest.js +12 -0
- package/bin/state.js +112 -1
- package/bin/vault-access.js +82 -0
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/erp-contract.md +180 -0
- package/mcp/memory-mcp/server.js +257 -0
- package/package.json +6 -3
- package/qualia-design/design-dials.md +72 -0
- package/qualia-design/design-reference.md +24 -0
- package/rules/access.md +42 -0
- 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 +43 -9
- package/skills/qualia-eval/SKILL.md +83 -0
- package/skills/qualia-feature/SKILL.md +20 -4
- package/skills/qualia-fix/SKILL.md +13 -1
- package/skills/qualia-map/SKILL.md +15 -0
- package/skills/qualia-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +41 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-polish/SKILL.md +3 -2
- package/skills/qualia-recall/SKILL.md +76 -0
- package/skills/qualia-report/SKILL.md +10 -0
- package/skills/qualia-scope/SKILL.md +3 -3
- package/skills/qualia-ship/SKILL.md +34 -4
- package/skills/qualia-update/SKILL.md +4 -0
- package/skills/qualia-verify/SKILL.md +53 -24
- package/templates/DESIGN.md +15 -0
- package/templates/instructions.md +32 -0
- package/templates/journey.md +1 -1
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +15 -0
- package/tests/batch-plan.test.sh +56 -0
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/design-tokens.test.sh +53 -0
- package/tests/erp-event.test.sh +78 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +29 -4
- package/tests/project-sync.test.sh +175 -0
- package/tests/recall.test.sh +91 -0
- package/tests/repo-map.test.sh +70 -0
- package/tests/run-all.sh +12 -0
- package/tests/runner.js +363 -33
- package/tests/state.test.sh +92 -0
- package/tests/verify-panel.test.sh +162 -0
- package/tests/wave-plan.test.sh +153 -0
package/docs/erp-contract.md
CHANGED
|
@@ -77,6 +77,41 @@ many times each employee attempted to use proxy approval.
|
|
|
77
77
|
```
|
|
78
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
79
|
|
|
80
|
+
### POST /api/v1/events (R14 — unified signed event log)
|
|
81
|
+
|
|
82
|
+
The single endpoint for ALL CLI→ERP events: lifecycle/run events
|
|
83
|
+
(`session_started`, `phase_planned`, `build_wave_started`, `verify_pass`,
|
|
84
|
+
`verify_fail`, …) and, going forward, the policy events above. Emitted by
|
|
85
|
+
`bin/erp-event.js` and queued through `erp-retry.js`.
|
|
86
|
+
|
|
87
|
+
**Headers (Standard-Webhooks style):**
|
|
88
|
+
```
|
|
89
|
+
Authorization: Bearer <api-key>
|
|
90
|
+
Content-Type: application/json
|
|
91
|
+
Qualia-Event-Id: <unique id — also the idempotency key>
|
|
92
|
+
Qualia-Event-Timestamp: <unix seconds or ISO 8601>
|
|
93
|
+
Qualia-Signature: <base64 HMAC-SHA256 of `${id}.${timestamp}.${rawBody}`, keyed by the api-key; omit for unsigned>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Body (the `FrameworkEvent`):**
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"action": "verify_pass",
|
|
100
|
+
"actor": { "code": "QS-FAWZI-11", "name": "Fawzi", "role": "OWNER" },
|
|
101
|
+
"targets": [{ "type": "project", "ref": "acme-portal" }],
|
|
102
|
+
"context": {},
|
|
103
|
+
"metadata": {},
|
|
104
|
+
"occurred_at": "2026-06-22T10:00:00.000Z",
|
|
105
|
+
"erp_project_id": "7b5d3b4e-…"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The HMAC is keyed by the caller's `qlt_` token (the ERP holds only its hash but
|
|
110
|
+
sees the plaintext Bearer per request), so body integrity + replay are verified
|
|
111
|
+
with no extra secret. Server stores to an append-only `framework_events` table,
|
|
112
|
+
idempotent on `Qualia-Event-Id`; unsigned posts are accepted but recorded
|
|
113
|
+
`signature_valid=false`. Accepts `reports:write` or `events:write` scope.
|
|
114
|
+
|
|
80
115
|
### POST /api/v1/reports
|
|
81
116
|
|
|
82
117
|
Upload a session report.
|
|
@@ -346,6 +381,151 @@ Snapshot shape:
|
|
|
346
381
|
}
|
|
347
382
|
```
|
|
348
383
|
|
|
384
|
+
## Project Sync Payload (reconciliation)
|
|
385
|
+
|
|
386
|
+
`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`.
|
|
387
|
+
|
|
388
|
+
It is distinct from the two existing emitters:
|
|
389
|
+
- `POST /api/v1/reports` (report-payload.js) = ONE work session.
|
|
390
|
+
- `POST /api/v1/project-snapshots` (project-snapshot.js) = lean progress rollup for dashboards (`progress_percent`).
|
|
391
|
+
- **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.
|
|
392
|
+
|
|
393
|
+
```text
|
|
394
|
+
qualia-framework project-sync # print JSON (compact) to stdout
|
|
395
|
+
qualia-framework project-sync --json # print JSON (pretty)
|
|
396
|
+
qualia-framework project-sync --write # persist .planning/snapshots/project-sync-<ts>.json
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
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`).
|
|
400
|
+
|
|
401
|
+
### Payload shape
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"schema_version": 1,
|
|
406
|
+
"generated_at": "2026-06-21T00:00:00.000Z",
|
|
407
|
+
"source": "qualia-framework",
|
|
408
|
+
"payload": "project-sync",
|
|
409
|
+
"framework_version": "6.14.0",
|
|
410
|
+
"identifiers": {
|
|
411
|
+
"project_id": "qs-acme-portal",
|
|
412
|
+
"team_id": "qualia-solutions",
|
|
413
|
+
"git_remote": "github.com/QualiasolutionsCY/acme-portal",
|
|
414
|
+
"erp_project_id": "7b5d3b4e-2b8a-4de4-91a1-9b2f3182f5ef"
|
|
415
|
+
},
|
|
416
|
+
"project": {
|
|
417
|
+
"name": "acme-portal",
|
|
418
|
+
"client": "Acme",
|
|
419
|
+
"status": "built",
|
|
420
|
+
"deployed_url": "https://client.vercel.app",
|
|
421
|
+
"progress_percent": 42,
|
|
422
|
+
"lifecycle": "build",
|
|
423
|
+
"launched_at": "2026-05-01T00:00:00Z"
|
|
424
|
+
},
|
|
425
|
+
"current": {
|
|
426
|
+
"milestone": 2, "milestone_name": "Product",
|
|
427
|
+
"phase": 2, "phase_name": "Dashboard", "total_phases": 4,
|
|
428
|
+
"tasks_done": 3, "tasks_total": 5,
|
|
429
|
+
"verification": "pending", "gap_cycles": 1
|
|
430
|
+
},
|
|
431
|
+
"quality": { "harness_eval": { "status": "PASS", "score": 92, "phase": 2, "generated_at": "…", "artifact": "…" } },
|
|
432
|
+
"total_milestones": 3,
|
|
433
|
+
"milestones": [
|
|
434
|
+
{
|
|
435
|
+
"num": 1, "name": "Foundation", "status": "closed",
|
|
436
|
+
"phases": 3, "tasks_completed": 12,
|
|
437
|
+
"requirements": { "tracked": true, "total": 4, "complete": 4, "incomplete": [] },
|
|
438
|
+
"closed_at": "2026-04-10T18:00:00Z",
|
|
439
|
+
"deployed_url": "https://m1.vercel.app"
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
"num": 2, "name": "Product", "status": "current",
|
|
443
|
+
"phases": 4, "tasks_completed": 0,
|
|
444
|
+
"requirements": { "tracked": true, "total": 5, "complete": 3,
|
|
445
|
+
"incomplete": [ { "id": "CORE-03", "status": "Incomplete" } ] }
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"num": 3, "name": "Handoff", "status": "future",
|
|
449
|
+
"phases": 0, "tasks_completed": 0,
|
|
450
|
+
"requirements": { "tracked": false, "total": 0, "complete": 0, "incomplete": [] }
|
|
451
|
+
}
|
|
452
|
+
],
|
|
453
|
+
"task_rollup": {
|
|
454
|
+
"tasks_completed": 15, "phases_completed": 4, "milestones_completed": 1,
|
|
455
|
+
"total_phases_all_milestones": 7, "build_count": 4, "deploy_count": 1,
|
|
456
|
+
"current_phase_gap_cycles": 1
|
|
457
|
+
},
|
|
458
|
+
"accountability": {
|
|
459
|
+
"offroad_count": 2,
|
|
460
|
+
"offroad": [ { "at": "2026-06-01T10:00:00Z", "milestone": 2, "ref": "BUG-7", "note": "hotfix login" } ]
|
|
461
|
+
},
|
|
462
|
+
"integration": {
|
|
463
|
+
"model": "trunk",
|
|
464
|
+
"integrates_at": "/qualia-ship",
|
|
465
|
+
"protected_branches": ["main", "master"],
|
|
466
|
+
"main_push_event_type": "employee_main_push",
|
|
467
|
+
"main_push_events_path": "~/.claude/.main-push-events.json",
|
|
468
|
+
"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."
|
|
469
|
+
},
|
|
470
|
+
"timestamps": { "session_started_at": "", "last_pushed_at": "", "last_updated": "" }
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Field meanings
|
|
475
|
+
|
|
476
|
+
| Field | Meaning |
|
|
477
|
+
|-------|---------|
|
|
478
|
+
| `schema_version` | project-sync contract version. The ERP branches on this to evolve the shape safely. **Distinct** from `snapshot_version` (project-snapshot). |
|
|
479
|
+
| `payload` | Always `"project-sync"` — discriminates this from the report / snapshot payloads when they share an endpoint or queue. |
|
|
480
|
+
| `identifiers` | Same resolution block as project-snapshot — `erp_project_id` is the strongest link, then `git_remote`, then `(team_id, project_id)`. |
|
|
481
|
+
| `project.lifecycle` | `build` (milestone journey) or `operate` (post-launch update stream). The ERP stops expecting a handoff for an `operate` project. |
|
|
482
|
+
| `project.launched_at` / `launch_source` | Present once launched; lets the ERP date the build→operate transition. |
|
|
483
|
+
| `current.*` | The active milestone/phase position + verification + flattened `gap_cycles` (number, current phase). |
|
|
484
|
+
| `total_milestones` | Denominator for milestone-completion (from JOURNEY.md, else max known). |
|
|
485
|
+
| `milestones[]` | The reconciliation spine — every known milestone (journey ∪ closed ∪ current), ordered by num. |
|
|
486
|
+
| `milestones[].status` | `closed` \| `current` \| `future`. The ERP marks done / in-progress / pending from this. |
|
|
487
|
+
| `milestones[].phases` | Phase count: `phases_completed` for closed milestones; the current roadmap phase count for the active one; 0 for future. |
|
|
488
|
+
| `milestones[].tasks_completed` | Tasks completed within that milestone (closed summaries carry this; 0 for current/future — use `task_rollup` for cumulative). |
|
|
489
|
+
| `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}`). |
|
|
490
|
+
| `milestones[].closed_at` / `deployed_url` | Present when the closed-milestone summary carries them. |
|
|
491
|
+
| `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. |
|
|
492
|
+
| `accountability.offroad_count` | Count of off-milestone work units (mirrors the branch-guard main-push tally — drift is counted, not hidden). |
|
|
493
|
+
| `accountability.offroad[]` | Last 10 off-road entries: `{at, milestone, ref, note}`. |
|
|
494
|
+
| `integration` | The PR/merge model (see below). |
|
|
495
|
+
|
|
496
|
+
### How the ERP reconciles from this payload
|
|
497
|
+
|
|
498
|
+
1. **Resolve the project** via `identifiers` (same precedence as reports/snapshots).
|
|
499
|
+
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).
|
|
500
|
+
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.
|
|
501
|
+
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.
|
|
502
|
+
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.
|
|
503
|
+
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`.
|
|
504
|
+
|
|
505
|
+
### PR / merge model (what the ERP must understand)
|
|
506
|
+
|
|
507
|
+
The framework uses **trunk integration**, surfaced in `integration`:
|
|
508
|
+
|
|
509
|
+
- Work happens on a **feature branch** (`branch-guard` enforces feature-branch-only; `main`/`master` are protected).
|
|
510
|
+
- `/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.
|
|
511
|
+
- 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.
|
|
512
|
+
|
|
513
|
+
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.
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## Framework emits (this repo) vs ERP ingests (backend — out of scope here)
|
|
518
|
+
|
|
519
|
+
**Framework emits (delivered in this repo):**
|
|
520
|
+
- `qualia-framework project-sync [--json|--write]` builds and (optionally) persists the payload above.
|
|
521
|
+
- Deterministic, read-only, zero-dependency; composes project-snapshot; graceful on missing fields.
|
|
522
|
+
- The payload SHAPE and field semantics documented in this section are the contract.
|
|
523
|
+
|
|
524
|
+
**ERP ingests (backend work — NOT in this repo):**
|
|
525
|
+
- 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`.
|
|
526
|
+
- Project resolution + the reconciliation logic in "How the ERP reconciles" above — upserting milestones/phases/tasks, marking completion from REQ counts, and storing `offroad_count`.
|
|
527
|
+
- 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.
|
|
528
|
+
|
|
349
529
|
## Behavior
|
|
350
530
|
|
|
351
531
|
- When `erp.enabled` is `false`, `/qualia-report` skips the upload and prints an info line so the employee knows the report stayed local.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Qualia Memory MCP — read-only access to the Obsidian wiki at QUALIA_MEMORY_ROOT.
|
|
3
|
+
// Zero dependencies. Implements MCP over stdio (JSON-RPC 2.0, line-delimited).
|
|
4
|
+
//
|
|
5
|
+
// Tools:
|
|
6
|
+
// memory.search(query, scope?) — case-insensitive grep over wiki, returns hits with file:line
|
|
7
|
+
// memory.read(path) — read a single file under wiki/ as text
|
|
8
|
+
// memory.list(folder?) — list folder contents (one level), default = wiki root
|
|
9
|
+
//
|
|
10
|
+
// Safety:
|
|
11
|
+
// - All paths are resolved under QUALIA_MEMORY_ROOT/wiki and rejected if they escape it.
|
|
12
|
+
// - No write tools. Memory is curated by humans + qualia-framework SessionEnd hooks only.
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const { spawnSync } = require("child_process");
|
|
17
|
+
|
|
18
|
+
const ROOT = process.env.QUALIA_MEMORY_ROOT || path.join(require("os").homedir(), "qualia-memory");
|
|
19
|
+
const WIKI = path.join(ROOT, "wiki");
|
|
20
|
+
|
|
21
|
+
// ─── Vault access control (shared with bin/recall.js) ─────
|
|
22
|
+
// Honor wiki/_meta/access.md so a non-OWNER MCP client can't read OWNER_ONLY
|
|
23
|
+
// paths. Same single implementation as recall.js; fail-closed if it can't load.
|
|
24
|
+
let accessGuard = null;
|
|
25
|
+
try {
|
|
26
|
+
accessGuard = require(path.join(__dirname, "..", "..", "bin", "vault-access.js"));
|
|
27
|
+
} catch {
|
|
28
|
+
/* fall back to OWNER-only access below */
|
|
29
|
+
}
|
|
30
|
+
const ROLE = accessGuard ? accessGuard.resolveRole() : "RESTRICTED";
|
|
31
|
+
function allowed(rel) {
|
|
32
|
+
return accessGuard ? accessGuard.isWikiPathAllowed(rel, ROLE, WIKI) : ROLE === "OWNER";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
36
|
+
const SERVER_INFO = { name: "qualia-memory", version: "0.1.0" };
|
|
37
|
+
|
|
38
|
+
const TOOLS = [
|
|
39
|
+
{
|
|
40
|
+
name: "memory.search",
|
|
41
|
+
description:
|
|
42
|
+
"Case-insensitive search across the Qualia memory wiki. Returns matches as " +
|
|
43
|
+
"{ path, line, snippet }. Use this before /qualia-plan or /qualia-build to pull " +
|
|
44
|
+
"prior decisions, client preferences, and reusable patterns.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
query: { type: "string", description: "Search term — plain text, no regex." },
|
|
49
|
+
scope: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description:
|
|
52
|
+
"Optional subfolder of wiki/ to limit the search (e.g. 'concepts', 'entities', " +
|
|
53
|
+
"'sessions'). Omit to search the whole wiki.",
|
|
54
|
+
},
|
|
55
|
+
max_results: { type: "integer", description: "Cap on hits (default 30).", default: 30 },
|
|
56
|
+
},
|
|
57
|
+
required: ["query"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "memory.read",
|
|
62
|
+
description: "Read a single wiki page as text. Path is relative to wiki/ root.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
path: { type: "string", description: "Relative path under wiki/, e.g. 'concepts/llm-wiki.md'." },
|
|
67
|
+
},
|
|
68
|
+
required: ["path"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "memory.list",
|
|
73
|
+
description: "List one level of contents (files + subfolders) under a wiki folder.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
folder: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "Relative path under wiki/. Omit or '' for the wiki root.",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// ─── Path safety ──────────────────────────────────────────
|
|
87
|
+
// Resolve `rel` under WIKI and refuse anything that escapes (.. tricks, abs paths).
|
|
88
|
+
function safeResolve(rel) {
|
|
89
|
+
const resolved = path.resolve(WIKI, rel || ".");
|
|
90
|
+
if (resolved !== WIKI && !resolved.startsWith(WIKI + path.sep)) {
|
|
91
|
+
throw new Error(`Path escapes wiki root: ${rel}`);
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Tool implementations ────────────────────────────────
|
|
97
|
+
function toolSearch({ query, scope, max_results }) {
|
|
98
|
+
if (!query || typeof query !== "string") throw new Error("query is required");
|
|
99
|
+
const cap = Math.max(1, Math.min(200, Number(max_results) || 30));
|
|
100
|
+
const target = scope ? safeResolve(scope) : WIKI;
|
|
101
|
+
if (!fs.existsSync(target)) throw new Error(`Scope not found: ${scope}`);
|
|
102
|
+
|
|
103
|
+
// grep is universally available on POSIX; -r recursive, -n line numbers, -i case-insensitive.
|
|
104
|
+
// -F treats query as fixed string (no regex surprises). --include limits to text-ish files.
|
|
105
|
+
const r = spawnSync(
|
|
106
|
+
"grep",
|
|
107
|
+
[
|
|
108
|
+
"-rniF",
|
|
109
|
+
"--include=*.md",
|
|
110
|
+
"--include=*.txt",
|
|
111
|
+
"--include=*.canvas",
|
|
112
|
+
"--include=*.base",
|
|
113
|
+
"--",
|
|
114
|
+
query,
|
|
115
|
+
target,
|
|
116
|
+
],
|
|
117
|
+
{ encoding: "utf8", maxBuffer: 8 * 1024 * 1024, timeout: 10_000 },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (r.status !== 0 && r.status !== 1) {
|
|
121
|
+
// status 1 = no matches (normal). Anything else is a real failure.
|
|
122
|
+
throw new Error(`grep failed: ${r.stderr || "exit " + r.status}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hits = (r.stdout || "")
|
|
126
|
+
.split("\n")
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map((line) => {
|
|
129
|
+
// grep output: <abs_path>:<lineno>:<text>
|
|
130
|
+
const firstColon = line.indexOf(":");
|
|
131
|
+
const secondColon = line.indexOf(":", firstColon + 1);
|
|
132
|
+
if (firstColon < 0 || secondColon < 0) return null;
|
|
133
|
+
const abs = line.slice(0, firstColon);
|
|
134
|
+
const lineno = Number(line.slice(firstColon + 1, secondColon));
|
|
135
|
+
const snippet = line.slice(secondColon + 1).trim();
|
|
136
|
+
return {
|
|
137
|
+
path: path.relative(WIKI, abs),
|
|
138
|
+
line: lineno,
|
|
139
|
+
snippet: snippet.length > 240 ? snippet.slice(0, 240) + "…" : snippet,
|
|
140
|
+
};
|
|
141
|
+
})
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.filter((h) => allowed(h.path)) // honor access.md BEFORE capping
|
|
144
|
+
.slice(0, cap);
|
|
145
|
+
|
|
146
|
+
return { query, scope: scope || null, total: hits.length, hits };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toolRead({ path: rel }) {
|
|
150
|
+
if (!rel || typeof rel !== "string") throw new Error("path is required");
|
|
151
|
+
const abs = safeResolve(rel);
|
|
152
|
+
if (!allowed(path.relative(WIKI, abs))) {
|
|
153
|
+
throw new Error("That path is OWNER-only. Ask Fawzi if you need this.");
|
|
154
|
+
}
|
|
155
|
+
const stat = fs.statSync(abs);
|
|
156
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${rel}`);
|
|
157
|
+
// Cap at 256KB so a runaway page doesn't blow the JSON-RPC frame.
|
|
158
|
+
const MAX = 256 * 1024;
|
|
159
|
+
let content = fs.readFileSync(abs, "utf8");
|
|
160
|
+
let truncated = false;
|
|
161
|
+
if (content.length > MAX) {
|
|
162
|
+
content = content.slice(0, MAX);
|
|
163
|
+
truncated = true;
|
|
164
|
+
}
|
|
165
|
+
return { path: rel, bytes: stat.size, truncated, content };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toolList({ folder }) {
|
|
169
|
+
const target = safeResolve(folder || ".");
|
|
170
|
+
if (!fs.existsSync(target)) throw new Error(`Folder not found: ${folder || "(root)"}`);
|
|
171
|
+
const entries = fs.readdirSync(target, { withFileTypes: true });
|
|
172
|
+
return {
|
|
173
|
+
folder: path.relative(WIKI, target) || ".",
|
|
174
|
+
entries: entries
|
|
175
|
+
// hide OWNER_ONLY / CONDITIONAL entries from non-OWNER listings
|
|
176
|
+
.filter((e) => allowed(path.relative(WIKI, path.join(target, e.name))))
|
|
177
|
+
.map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" }))
|
|
178
|
+
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1)),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const TOOL_HANDLERS = {
|
|
183
|
+
"memory.search": toolSearch,
|
|
184
|
+
"memory.read": toolRead,
|
|
185
|
+
"memory.list": toolList,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ─── MCP / JSON-RPC plumbing ──────────────────────────────
|
|
189
|
+
function rpcResult(id, result) {
|
|
190
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
191
|
+
}
|
|
192
|
+
function rpcError(id, code, message, data) {
|
|
193
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, ...(data && { data }) } });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function handleRequest(req) {
|
|
197
|
+
const { id, method, params } = req;
|
|
198
|
+
try {
|
|
199
|
+
if (method === "initialize") {
|
|
200
|
+
return rpcResult(id, {
|
|
201
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
202
|
+
serverInfo: SERVER_INFO,
|
|
203
|
+
capabilities: { tools: {} },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (method === "tools/list") {
|
|
207
|
+
return rpcResult(id, { tools: TOOLS });
|
|
208
|
+
}
|
|
209
|
+
if (method === "tools/call") {
|
|
210
|
+
const { name, arguments: args } = params || {};
|
|
211
|
+
const handler = TOOL_HANDLERS[name];
|
|
212
|
+
if (!handler) return rpcError(id, -32601, `Unknown tool: ${name}`);
|
|
213
|
+
const out = handler(args || {});
|
|
214
|
+
return rpcResult(id, {
|
|
215
|
+
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (method === "notifications/initialized" || method === "notifications/cancelled") {
|
|
219
|
+
return null; // notifications get no response
|
|
220
|
+
}
|
|
221
|
+
return rpcError(id, -32601, `Unknown method: ${method}`);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return rpcError(id, -32000, err.message || String(err));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── stdio loop ───────────────────────────────────────────
|
|
228
|
+
let buffer = "";
|
|
229
|
+
process.stdin.setEncoding("utf8");
|
|
230
|
+
process.stdin.on("data", (chunk) => {
|
|
231
|
+
buffer += chunk;
|
|
232
|
+
let nl;
|
|
233
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
234
|
+
const line = buffer.slice(0, nl).trim();
|
|
235
|
+
buffer = buffer.slice(nl + 1);
|
|
236
|
+
if (!line) continue;
|
|
237
|
+
let req;
|
|
238
|
+
try {
|
|
239
|
+
req = JSON.parse(line);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
process.stdout.write(rpcError(null, -32700, "Parse error: " + err.message) + "\n");
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const out = handleRequest(req);
|
|
245
|
+
if (out !== null) process.stdout.write(out + "\n");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
process.stdin.on("end", () => process.exit(0));
|
|
250
|
+
|
|
251
|
+
// Surface root config on startup so misconfigured installs are visible in MCP logs.
|
|
252
|
+
process.stderr.write(`[qualia-memory] wiki root: ${WIKI}\n`);
|
|
253
|
+
if (!fs.existsSync(WIKI)) {
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
`[qualia-memory] WARNING: wiki root does not exist. Set QUALIA_MEMORY_ROOT or create ${ROOT}/wiki/.\n`,
|
|
256
|
+
);
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Claude Code and Codex workflow framework by Qualia Solutions. Plan, build, verify, ship.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualia-framework": "./bin/cli.js"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/Qualiasolutions/qualia-framework#readme",
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "npm run test:shell",
|
|
26
|
+
"test": "npm run test:shell && npm run test:node",
|
|
27
27
|
"test:state": "bash tests/state.test.sh",
|
|
28
28
|
"test:hooks": "bash tests/hooks.test.sh",
|
|
29
29
|
"test:bin": "bash tests/bin.test.sh",
|
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
"test:statusline": "bash tests/statusline.test.sh",
|
|
34
34
|
"test:refs": "bash tests/refs.test.sh",
|
|
35
35
|
"test:published-install": "bash tests/published-install-smoke.test.sh",
|
|
36
|
-
"test:shell": "bash tests/run-all.sh"
|
|
36
|
+
"test:shell": "bash tests/run-all.sh",
|
|
37
|
+
"test:node": "node --test tests/runner.js",
|
|
38
|
+
"compile:instructions": "node bin/compile-instructions.js"
|
|
37
39
|
},
|
|
38
40
|
"files": [
|
|
39
41
|
"bin/",
|
|
40
42
|
"agents/",
|
|
41
43
|
"hooks/",
|
|
44
|
+
"mcp/",
|
|
42
45
|
"rules/",
|
|
43
46
|
"qualia-design/",
|
|
44
47
|
"skills/",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Design Dials + Negative-Constraint Pre-Flight
|
|
2
|
+
|
|
3
|
+
Loaded by `/qualia-polish` (Stage 0) and read when generating any DESIGN.md.
|
|
4
|
+
Two jobs: (1) three tunable **dials** that set a project's aesthetic risk budget,
|
|
5
|
+
(2) a **pre-flight ban-list with positive alternatives** that breaks the model's
|
|
6
|
+
default "AI slop" trajectory *before* code is written — not after, where
|
|
7
|
+
`slop-detect.mjs` catches what slipped through.
|
|
8
|
+
|
|
9
|
+
> Why pre-flight beats post-hoc: a ban paired with a *positive* alternative
|
|
10
|
+
> ("don't do X — do Y instead") steers generation. A bare ban ("don't do X") is
|
|
11
|
+
> the pink-elephant backfire — the model fixates on X. Always pair.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. The three dials
|
|
16
|
+
|
|
17
|
+
Each dial is `LOW | MEDIUM | HIGH`. Set them in DESIGN.md §0; gate them by
|
|
18
|
+
**register + project type** (see §3). The dials are intent, not enforcement —
|
|
19
|
+
they tell a builder/polish agent how much aesthetic risk the project wants.
|
|
20
|
+
|
|
21
|
+
| Dial | LOW | MEDIUM | HIGH |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| **DESIGN_VARIANCE** — how far from convention | Conventional, safe, familiar layouts. Respect existing/client tokens exactly. | One signature move per screen; otherwise conventional. | Editorial/experimental layouts, asymmetry, unexpected grids. Earns attention. |
|
|
24
|
+
| **MOTION_INTENSITY** — how much movement | Functional only (focus, state). No decorative motion. `prefers-reduced-motion` honored. | Purposeful entrance/transition on key elements. | Signature motion as identity (Brand register only) — orchestrated, still ≤ budget. |
|
|
25
|
+
| **VISUAL_DENSITY** — information per screen | Generous whitespace, few elements, one idea per screen. | Balanced; grouped sections. | Dense, dashboard-grade; every pixel earns its place. |
|
|
26
|
+
|
|
27
|
+
A builder reads the resolved values and makes choices *within* that budget. A
|
|
28
|
+
HIGH on every dial is not "better" — it's a different, riskier product.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 2. Pre-flight ban-list (ban → do instead)
|
|
33
|
+
|
|
34
|
+
Read this BEFORE writing any component or token. Each ban mirrors a
|
|
35
|
+
`slop-detect.mjs` rule (the deterministic post-hoc gate) — this is the
|
|
36
|
+
generation-time half of the same contract, with the positive alternative the
|
|
37
|
+
scanner can't give you.
|
|
38
|
+
|
|
39
|
+
| Ban (the slop tell) | Do instead |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `Inter` / `system-ui` / `Arial` as the brand face | The project's chosen typeface (DESIGN.md §3) with ≥2 weights; a real type pairing |
|
|
42
|
+
| Purple→blue/pink hero gradient | The brand palette from DESIGN.md §2 (OKLCH); a single committed accent |
|
|
43
|
+
| Gradient *text* | Solid ink at AA contrast; reserve color for one accent moment |
|
|
44
|
+
| Pure `#000` on pure `#fff` | Near-black on warm/cool off-white from the token scale (§2) |
|
|
45
|
+
| Hardcoded hex in JSX | A CSS variable / token (the per-client registry — see design-reference) |
|
|
46
|
+
| Glassmorphism (`backdrop-blur` everywhere) | Solid surfaces with real elevation tokens (DESIGN.md §6) |
|
|
47
|
+
| `card-grid` of identical rounded boxes | A layout with hierarchy — feature one item, vary span/scale |
|
|
48
|
+
| Spring/bounce easing as the default | Calm `ease-out` / project easing tokens (DESIGN.md §7); bounce only as a Brand signature |
|
|
49
|
+
| `outline: none` with no replacement | A visible `:focus-visible` ring (accessibility, non-negotiable) |
|
|
50
|
+
| Generic CTA copy ("Get Started", "Learn More") | Specific, product-true microcopy (rubric dim 7) |
|
|
51
|
+
|
|
52
|
+
When VISUAL_DENSITY=HIGH the whitespace ban relaxes; when MOTION_INTENSITY=LOW
|
|
53
|
+
the motion alternatives default to "none." The dials tune the list; they never
|
|
54
|
+
unlock a hard accessibility ban (focus ring, alt text, contrast).
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 3. Gating by register + project type (don't fight client tokens)
|
|
59
|
+
|
|
60
|
+
The dials default by what the project IS — a corporate client's app must not get
|
|
61
|
+
HIGH variance that overrides their brand system:
|
|
62
|
+
|
|
63
|
+
| Project type | VARIANCE | MOTION | DENSITY | Note |
|
|
64
|
+
|---|---|---|---|---|
|
|
65
|
+
| Client app on an existing brand system | LOW | LOW–MED | per product | Respect their tokens; the per-client registry (design-reference) is law. |
|
|
66
|
+
| Internal tool / admin / dashboard | LOW | LOW | HIGH | Clarity over flair; dense is correct. |
|
|
67
|
+
| Greenfield product (Product register) | MEDIUM | MEDIUM | MEDIUM | One signature move; earn the rest. |
|
|
68
|
+
| Marketing / landing / portfolio (Brand register) | HIGH | HIGH | LOW–MED | Attention is the job; signature motion allowed. |
|
|
69
|
+
|
|
70
|
+
If a client ships a design-token registry, **VARIANCE is capped at LOW for
|
|
71
|
+
color/type** regardless of register — you may be expressive with layout/motion,
|
|
72
|
+
never with their palette or face. See `design-reference.md`.
|
|
@@ -178,3 +178,27 @@ When building reusable components:
|
|
|
178
178
|
- Explicit variants: `<Alert.Destructive>` instead of `<Alert isDestructive>`
|
|
179
179
|
- Children over render props: `children` for composition, not `renderHeader`/`renderFooter`
|
|
180
180
|
- React 19: use `use()` instead of `useContext()`, skip `forwardRef` (ref is a regular prop)
|
|
181
|
+
|
|
182
|
+
## Per-Client Token Registry (R10)
|
|
183
|
+
|
|
184
|
+
The proven escape from AI design monoculture: ground generation in a real design
|
|
185
|
+
system — first-party brand tokens as CSS custom properties — not each builder's
|
|
186
|
+
invented palette.
|
|
187
|
+
|
|
188
|
+
- **Source of truth:** `.planning/design-tokens.json` (the client's brand —
|
|
189
|
+
colors in OKLCH, type stack, radius, spacing). Generated by `/qualia-new` and
|
|
190
|
+
edited to the client.
|
|
191
|
+
- **Artifact:** `app/styles/tokens.css` (or the stack's equivalent), compiled by
|
|
192
|
+
`bin/design-tokens.js compile`. A `:root { --color-…; --font-…; … }` block.
|
|
193
|
+
- **The contract for builders:** reference `var(--color-accent)` /
|
|
194
|
+
`var(--font-sans)` — NEVER a literal hex (`ABS-HEX-IN-JSX` slop ban). The
|
|
195
|
+
registry is the only place brand values live; swap the JSON, recompile, the
|
|
196
|
+
whole app re-skins.
|
|
197
|
+
- **Dial gating:** a client brand registry caps `DESIGN_VARIANCE` at LOW for
|
|
198
|
+
color/type (`design-dials.md` §3) — be expressive with layout/motion, never
|
|
199
|
+
with their palette or face.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
node bin/design-tokens.js init --out .planning/design-tokens.json
|
|
203
|
+
node bin/design-tokens.js compile .planning/design-tokens.json --out app/styles/tokens.css
|
|
204
|
+
```
|
package/rules/access.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Vault Access (security-critical)
|
|
2
|
+
|
|
3
|
+
Governs every read of the `~/qualia-memory/` vault — by `/qualia-recall`, by any
|
|
4
|
+
skill or agent that greps the vault, and by direct `Read`/`Grep`/`Bash` calls.
|
|
5
|
+
The single source of truth for *which* paths are sensitive is the vault's own
|
|
6
|
+
manifest at `~/qualia-memory/wiki/_meta/access.md`. This rule says how to honor it.
|
|
7
|
+
|
|
8
|
+
## The rule
|
|
9
|
+
|
|
10
|
+
Role is read from `~/.claude/.qualia-config.json` (`role` field). When
|
|
11
|
+
`role != OWNER`, paths the manifest lists as **OWNER_ONLY** or **CONDITIONAL**
|
|
12
|
+
MUST NOT be read, grepped, listed, or summarized:
|
|
13
|
+
|
|
14
|
+
- `Finances/` — live invoicing, VAT, outstanding balances
|
|
15
|
+
- `Clients/*.md` — rates, contacts, Zoho IDs, billing addresses
|
|
16
|
+
- `Team/*.md` — compensation / performance context
|
|
17
|
+
- `raw/sessions/` — verbatim session logs (commercial + internal decisions)
|
|
18
|
+
- `memory/` — personal AI context layer
|
|
19
|
+
- `wiki/_meta/access.md` — the manifest itself (prevents trivial bypass)
|
|
20
|
+
- CONDITIONAL (DEFAULT DENY for non-OWNER): `Projects/*.md`, `wiki/sessions/qa/`
|
|
21
|
+
|
|
22
|
+
**ALL_ROLES** paths (`wiki/concepts/`, `wiki/entities/`, `wiki/analysis/`,
|
|
23
|
+
`Research/`, navigational pages, etc.) are open to every authenticated user.
|
|
24
|
+
|
|
25
|
+
When following `[[wikilinks]]` out of ALL_ROLES content INTO an OWNER_ONLY path,
|
|
26
|
+
**stop at the boundary** — do not follow the link.
|
|
27
|
+
|
|
28
|
+
On refusal, say plainly: *"That path is OWNER-only. Ask Fawzi if you need this."*
|
|
29
|
+
|
|
30
|
+
## Deterministic enforcement (don't rely on this prose alone)
|
|
31
|
+
|
|
32
|
+
`${QUALIA_BIN}/recall.js` enforces the manifest in code: it parses
|
|
33
|
+
`wiki/_meta/access.md`, resolves the role, and filters OWNER_ONLY / CONDITIONAL
|
|
34
|
+
vault hits for non-OWNER callers (fail-closed on unknown role). **Prefer
|
|
35
|
+
`/qualia-recall` (or `recall.js`) over hand-rolled `grep ~/qualia-memory`** — the
|
|
36
|
+
tool can't forget the policy the way a prompt can. "A rule worth enforcing is
|
|
37
|
+
worth a gate" (constitution).
|
|
38
|
+
|
|
39
|
+
## When this rule applies
|
|
40
|
+
|
|
41
|
+
Always-on for any session that may touch `~/qualia-memory/`. If the vault does not
|
|
42
|
+
exist on the machine, this rule is inert. OWNER role ⇒ full access (no filtering).
|