qualia-framework 6.9.0 → 6.14.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/bin/state.js CHANGED
@@ -706,6 +706,9 @@ function ensureLifetime(t) {
706
706
  total_phases: 0,
707
707
  };
708
708
  }
709
+ // v7 lifecycle (backward compat): pre-v7 tracking.json predates these fields.
710
+ if (t.lifecycle !== "operate" && t.lifecycle !== "build") t.lifecycle = "build";
711
+ if (typeof t.lifetime.updates_completed !== "number") t.lifetime.updates_completed = 0;
709
712
  return t;
710
713
  }
711
714
 
@@ -970,6 +973,25 @@ function checkPreconditions(current, target, opts) {
970
973
  const anchors = doneWhenCount + acCount;
971
974
  if (anchors < taskHeaders.length)
972
975
  return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`);
976
+ // v7 kernel: a phase cannot reach `planned` without a machine contract.
977
+ // checkMachineEvidence() at the `verified` gate only engages when
978
+ // phase-N-contract.json exists; if a contract could be skipped here, the
979
+ // entire evidence requirement is bypassable by omission and the prose
980
+ // verifier (which can PASS on inaction) would govern. Requiring it here is
981
+ // what makes "I built it" insufficient and "the contract ran clean" required.
982
+ const contractFile = path.join(PLANNING, `phase-${phase}-contract.json`);
983
+ if (!fs.existsSync(contractFile))
984
+ return fail(
985
+ "MISSING_CONTRACT",
986
+ `Machine contract not found: ${contractFile}. /qualia-plan must compile the plan into a JSON contract before 'planned'. Regenerate with /qualia-plan, or build it via bin/plan-contract.js.`
987
+ );
988
+ try {
989
+ const c = JSON.parse(fs.readFileSync(contractFile, "utf8"));
990
+ if (!c || !Array.isArray(c.tasks) || c.tasks.length === 0)
991
+ return fail("INVALID_CONTRACT", `${contractFile} has no tasks[]; not a valid phase contract.`);
992
+ } catch (e) {
993
+ return fail("INVALID_CONTRACT", `Could not parse ${contractFile}: ${e.message}`);
994
+ }
973
995
  }
974
996
 
975
997
  if (target === "verified") {
@@ -994,9 +1016,17 @@ function checkPreconditions(current, target, opts) {
994
1016
  }
995
1017
 
996
1018
  if (target === "handed_off") {
997
- const hFile = path.join(PLANNING, "HANDOFF.md");
998
- if (!fs.existsSync(hFile))
999
- return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
1019
+ // v7 lifecycle: the HANDOFF.md requirement is a BUILD-mode convention, not a
1020
+ // universal law. An "operate" project (a launched, repeatedly-shipping
1021
+ // product or retainer) has no single handoff moment, so it must not be
1022
+ // forced to produce one. opts.lifecycle is threaded from tracking by the
1023
+ // caller; absent/"build" keeps the original requirement.
1024
+ const lifecycle = opts.lifecycle || "build";
1025
+ if (lifecycle === "build") {
1026
+ const hFile = path.join(PLANNING, "HANDOFF.md");
1027
+ if (!fs.existsSync(hFile))
1028
+ return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
1029
+ }
1000
1030
  }
1001
1031
 
1002
1032
  // Gap-closure circuit breaker (configurable limit)
@@ -1055,7 +1085,14 @@ function recordLedgerEvent(meta) {
1055
1085
  }
1056
1086
 
1057
1087
  // ─── Next Command Logic ──────────────────────────────────
1058
- function nextCommand(status, phase, totalPhases, verification) {
1088
+ // `lifecycle` (default "build") changes the route once a project has launched.
1089
+ // In "operate" the project is an UPDATE STREAM, not a milestone journey: there is
1090
+ // no polish → ship → handoff terminal chain. After the last phase verifies, the
1091
+ // next move is the next update (/qualia-update), and the project never gets
1092
+ // dragged to a handoff it has outgrown. This is the v7 thesis in miniature —
1093
+ // a behavior that was hard-coded in prose is now a branch on explicit state.
1094
+ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
1095
+ const operate = lifecycle === "operate";
1059
1096
  switch (status) {
1060
1097
  case "setup":
1061
1098
  return `/qualia-plan ${phase}`;
@@ -1066,15 +1103,17 @@ function nextCommand(status, phase, totalPhases, verification) {
1066
1103
  case "verified":
1067
1104
  if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
1068
1105
  if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
1069
- return "/qualia-polish";
1106
+ return operate ? "/qualia-update" : "/qualia-polish";
1070
1107
  case "polished":
1071
1108
  return "/qualia-ship";
1072
1109
  case "shipped":
1073
- return "/qualia-handoff";
1110
+ // In build mode a shipped project hands off once. In operate it loops:
1111
+ // the deploy was just another update.
1112
+ return operate ? "/qualia-update" : "/qualia-handoff";
1074
1113
  case "handed_off":
1075
1114
  return "/qualia-report";
1076
1115
  case "done":
1077
- return "Done.";
1116
+ return operate ? "/qualia-update" : "Done.";
1078
1117
  default:
1079
1118
  return `/qualia`;
1080
1119
  }
@@ -1202,11 +1241,14 @@ function cmdCheck(opts) {
1202
1241
  tasks_done: t.tasks_done || 0,
1203
1242
  tasks_total: t.tasks_total || 0,
1204
1243
  deployed_url: t.deployed_url || "",
1244
+ lifecycle: t.lifecycle || "build",
1245
+ launched_at: t.launched_at || "",
1205
1246
  next_command: nextCommand(
1206
1247
  s.status,
1207
1248
  s.phase,
1208
1249
  s.total_phases,
1209
- t.verification
1250
+ t.verification,
1251
+ t.lifecycle
1210
1252
  ),
1211
1253
  schema_errors: s.schema_errors && s.schema_errors.length ? s.schema_errors : undefined,
1212
1254
  });
@@ -1400,8 +1442,8 @@ function cmdTransition(opts) {
1400
1442
 
1401
1443
  const phase = parseInt(opts.phase) || s.phase;
1402
1444
 
1403
- // Precondition check
1404
- const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase });
1445
+ // Precondition check (lifecycle threaded so the handoff gate can relax in operate)
1446
+ const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase, lifecycle: t.lifecycle });
1405
1447
  if (!check.ok) {
1406
1448
  // --force bypasses status-ordering and plan-content errors. The use case
1407
1449
  // is retroactive bookkeeping: a phase was built without /qualia-plan and
@@ -1433,6 +1475,18 @@ function cmdTransition(opts) {
1433
1475
  if (target === "polished") applyPolishedTransition(s);
1434
1476
  if (target === "shipped") applyShippedTransition(t, opts);
1435
1477
 
1478
+ // v7: in operate mode, a verified(pass) on the final phase completes one UPDATE.
1479
+ // This is the operate-mode analogue of closing a milestone in build mode.
1480
+ if (
1481
+ target === "verified" &&
1482
+ opts.verification === "pass" &&
1483
+ t.lifecycle === "operate" &&
1484
+ phase >= (parseInt(s.total_phases) || phase)
1485
+ ) {
1486
+ ensureLifetime(t);
1487
+ t.lifetime.updates_completed = (t.lifetime.updates_completed || 0) + 1;
1488
+ }
1489
+
1436
1490
  // Atomic commit
1437
1491
  const writeError = commitTransitionAtomic(s, t);
1438
1492
  if (writeError) return output(writeError);
@@ -1471,7 +1525,8 @@ function cmdTransition(opts) {
1471
1525
  previous_status: prevStatus,
1472
1526
  verification: t.verification,
1473
1527
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
1474
- next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification),
1528
+ lifecycle: t.lifecycle || "build",
1529
+ next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification, t.lifecycle),
1475
1530
  };
1476
1531
  if (ledger.ok) {
1477
1532
  result.ledger_event_id = ledger.event_id;
@@ -1579,6 +1634,7 @@ function cmdInit(opts) {
1579
1634
  milestones_completed: 0,
1580
1635
  total_phases: 0,
1581
1636
  last_closed_milestone: 0,
1637
+ updates_completed: 0,
1582
1638
  };
1583
1639
  const lifetime = prevLife
1584
1640
  ? { ...defaultLifetime, ...(prevLife.lifetime || {}) }
@@ -1607,6 +1663,11 @@ function cmdInit(opts) {
1607
1663
  total_phases: totalPhases,
1608
1664
  status: "setup",
1609
1665
  profile,
1666
+ // v7 lifecycle: a new project starts in "build" (milestone journey).
1667
+ // `launch` flips it to "operate" (update stream). Preserved across re-init.
1668
+ lifecycle: prevLife ? (prevLife.lifecycle || "build") : "build",
1669
+ launched_at: prevLife ? (prevLife.launched_at || "") : "",
1670
+ launch_source: prevLife ? (prevLife.launch_source || "") : "",
1610
1671
  wave: 0,
1611
1672
  tasks_done: 0,
1612
1673
  tasks_total: 0,
@@ -1665,6 +1726,76 @@ function cmdInit(opts) {
1665
1726
  output(result);
1666
1727
  }
1667
1728
 
1729
+ // v7: the one-time launch event. Flips the project from "build" (milestone
1730
+ // journey) to "operate" (update stream). This is the discrete transition the
1731
+ // ERP can drive when it detects a project is live (is_live / status:Launched),
1732
+ // so "the product is launched" becomes explicit state instead of a milestone the
1733
+ // team is forced to call "handoff". Idempotent: launching an operate project is
1734
+ // a no-op. lifecycle is canonical in tracking.json; cmdCheck surfaces it.
1735
+ function cmdLaunch(opts) {
1736
+ const beforeStateRaw = readState();
1737
+ const beforeTrackingRaw = readTrackingRaw();
1738
+ const t = parseTrackingRaw(beforeTrackingRaw);
1739
+ if (!t) return output(fail("NO_PROJECT", "No .planning/ found. Run /qualia-new."));
1740
+ ensureLifetime(t);
1741
+
1742
+ if (t.lifecycle === "operate") {
1743
+ return output({
1744
+ ok: true,
1745
+ action: "launch",
1746
+ already_launched: true,
1747
+ lifecycle: "operate",
1748
+ launched_at: t.launched_at || "",
1749
+ launch_source: t.launch_source || "",
1750
+ next_command: "/qualia-update",
1751
+ message: "Already launched (operate lifecycle). Nothing to do.",
1752
+ });
1753
+ }
1754
+
1755
+ t.lifecycle = "operate";
1756
+ t.launched_at = new Date().toISOString();
1757
+ t.launch_source = opts.source === "erp" ? "erp" : "manual";
1758
+ if (opts.deployed_url) t.deployed_url = opts.deployed_url;
1759
+ t.last_updated = new Date().toISOString();
1760
+ writeTracking(t);
1761
+
1762
+ const ledger = recordLedgerEvent({
1763
+ action: "launch",
1764
+ phase_before: t.phase || null,
1765
+ phase_after: t.phase || null,
1766
+ status_before: t.status || null,
1767
+ status_after: t.status || null,
1768
+ state_before: parseStateMd(beforeStateRaw),
1769
+ state_after: parseStateMd(readState()),
1770
+ tracking_before: parseTrackingRaw(beforeTrackingRaw),
1771
+ tracking_after: t,
1772
+ state_raw_before: beforeStateRaw,
1773
+ state_raw_after: readState(),
1774
+ tracking_raw_before: beforeTrackingRaw,
1775
+ tracking_raw_after: readTrackingRaw(),
1776
+ evidence_refs: opts.deployed_url ? [opts.deployed_url] : [],
1777
+ });
1778
+
1779
+ const result = {
1780
+ ok: true,
1781
+ action: "launch",
1782
+ lifecycle: "operate",
1783
+ launched_at: t.launched_at,
1784
+ launch_source: t.launch_source,
1785
+ deployed_url: t.deployed_url || "",
1786
+ next_command: "/qualia-update",
1787
+ message:
1788
+ "Launched. The project is now an UPDATE STREAM (operate): no forced polish → ship → handoff. Ship updates with the build/verify/ship loop; the forced handoff is gone.",
1789
+ };
1790
+ if (ledger.ok) {
1791
+ result.ledger_event_id = ledger.event_id;
1792
+ result.ledger_event_hash = ledger.event_hash;
1793
+ } else {
1794
+ result.ledger_error = ledger.error;
1795
+ }
1796
+ output(result);
1797
+ }
1798
+
1668
1799
  function cmdFix(opts) {
1669
1800
  const beforeStateRaw = readState();
1670
1801
  const beforeTrackingRaw = readTrackingRaw();
@@ -2775,6 +2906,9 @@ try {
2775
2906
  case "transition":
2776
2907
  cmdTransition(opts);
2777
2908
  break;
2909
+ case "launch":
2910
+ cmdLaunch(opts);
2911
+ break;
2778
2912
  case "init":
2779
2913
  cmdInit(opts);
2780
2914
  break;
@@ -2,6 +2,8 @@
2
2
 
3
3
  A five-minute path from a fresh machine to a shipped, reported day of work. This is the route for a **new employee who does not have a team install code yet**. You can install and do real work in employee mode today; the OWNER (Fawzi) issues the keys that unlock ERP reporting and any direct provider integrations.
4
4
 
5
+ > Prefer a visual tour? Open **[`docs/qualia-manual.html`](./qualia-manual.html)** — the same path as an interactive Field Manual (command map, first-project walkthrough, copy-to-clipboard commands).
6
+
5
7
  Who issues credentials: **the OWNER (Fawzi) issues every key** — team install codes, the ERP API key, OpenRouter / Supabase / Vercel / Retell / ElevenLabs / Telnyx credentials. If a step says "ask Fawzi", that is who to ask. Never share or reuse another person's key.
6
8
 
7
9
  ---
@@ -27,7 +29,7 @@ Install code or "EMPLOYEE":
27
29
  ```
28
30
 
29
31
  - Have a team code (`QS-NAME-##`)? Enter it — you install as that team member.
30
- - **No code yet?** Type **`EMPLOYEE`**. You install at the least-privilege role: feature branches only, no pushes to `main` (enforced by the `branch-guard` hook). The full framework — skills, agents, hooks, knowledge — is installed exactly the same.
32
+ - **No code yet?** Type **`EMPLOYEE`**. You install at the least-privilege role. Feature branches are the norm; pushing to `main` is **allowed but recorded** — the `branch-guard` hook counts each employee main-push locally and reports it to the ERP so the OWNER can see it. The full framework — skills, agents, hooks, knowledge — is installed exactly the same.
31
33
 
32
34
  What employee mode changes vs. a coded install:
33
35
 
@@ -35,7 +37,7 @@ What employee mode changes vs. a coded install:
35
37
  |---|---|---|
36
38
  | Role | OWNER or EMPLOYEE per code | EMPLOYEE |
37
39
  | Skills / agents / hooks | full | full |
38
- | Push to `main` | OWNER only | blocked |
40
+ | Push to `main` | OWNER (silent) | allowed, recorded to ERP |
39
41
  | ERP reporting | on (with API key) | **off** until a code/key is set |
40
42
  | `/qualia-report` | uploads to ERP | saves a **local** report file |
41
43
 
@@ -118,7 +120,7 @@ Pull these into a project with `vercel env pull` once the project is linked. **A
118
120
  /qualia-ship
119
121
  ```
120
122
 
121
- Runs the quality gates, commits, deploys (Vercel via CLI), and verifies. As an employee you ship through a **feature branch and review** `branch-guard` blocks direct pushes to `main`. Deploys happen only through the CLI; GitHub auto-deploy is intentionally disabled.
123
+ Runs the quality gates, commits, deploys (Vercel via CLI), and verifies. As an employee, prefer shipping through a **feature branch and review**. Direct pushes to `main` are allowed but `branch-guard` records each one (framework + ERP) for the OWNER, so use them only for changes that are trivially safe. Deploys happen only through the CLI; GitHub auto-deploy is intentionally disabled.
122
124
 
123
125
  **Credentials this step needs:**
124
126
  - A Vercel login on the correct team (`vercel whoami`; `vercel link` if the project isn't linked). Ask Fawzi which Vercel team the project belongs to.
@@ -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.
@@ -377,6 +400,8 @@ Snapshot shape:
377
400
  | last_pushed_at | string | optional (v3.6+) | ISO 8601 — distinct from `last_updated` (which fires on local writes too). |
378
401
  | build_count | number | optional (v3.6+) | Lifetime build counter. |
379
402
  | deploy_count | number | optional (v3.6+) | Lifetime deploy counter. |
403
+ | command_usage | object | optional (v6.9+) | Per-session qualia slash-command histogram, e.g. `{ "qualia-build": 3, "qualia-fix": 1 }`. Captured by the `usage-capture` UserPromptSubmit hook into `.planning/.session-usage.json` and cleared at clock-out. Drives the ERP performance audit's command-usage signal. Defaults to `{}`. |
404
+ | prompt_samples | string[] | optional (v6.9+) | Sampled real engineer prompts for the session (opt-in via `erp.capturePrompts`, default on; internal-only). Fed to the ERP prompt-quality judge. Capped at 60 entries / 8000 chars each. Defaults to `[]`. |
380
405
  | client_report_id | string | recommended (v4.0.4+) | Client-side sequential identifier: `QS-REPORT-01`, `QS-REPORT-02`, … per-project. Stable across retries. Preferred dedupe key over the ERP-generated `report_id`; safe to adopt as the ERP's primary report key. |
381
406
  | dry_run | boolean | optional (v4.0.4+) | `true` marks a synthetic ping (from `qualia-framework erp-ping`). Receivers should filter these out of production report views. |
382
407
 
@@ -393,4 +418,11 @@ All other fields are optional but recommended for complete reporting.
393
418
  - API keys are per-user, not per-project
394
419
  - Keys expire after 90 days (re-issue via Fawzi)
395
420
  - All traffic is HTTPS-only
396
- - No PII beyond team member names is transmitted
421
+ - **Free-form content:** beyond team member names, the only free-form content
422
+ transmitted is `prompt_samples` — the engineer's real prompts — and only when
423
+ `erp.capturePrompts` is enabled (default on). This is disclosed to engineers
424
+ at install, in their own `.qualia-config.json`, and via a once-per-shift
425
+ runtime notice; they may opt out at any time. Treat `prompt_samples` as
426
+ data that **may contain PII**: store it access-controlled, keep it out of
427
+ third-party sub-processors that aren't covered by the team's data agreement,
428
+ and honor deletion requests. Every other field is non-PII project metadata.