qualia-framework 6.9.2 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +208 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/agents/verifier.md +1 -1
  6. package/bin/agent-status.js +264 -0
  7. package/bin/analyze-gate.js +318 -0
  8. package/bin/branch-hygiene.js +135 -0
  9. package/bin/command-surface.js +2 -0
  10. package/bin/compile-instructions.js +82 -0
  11. package/bin/eval-runner.js +218 -0
  12. package/bin/host-adapters.js +72 -12
  13. package/bin/install.js +27 -17
  14. package/bin/last-report.js +207 -0
  15. package/bin/project-sync.js +315 -0
  16. package/bin/report-payload.js +7 -0
  17. package/bin/runtime-manifest.js +8 -0
  18. package/bin/state.js +257 -12
  19. package/bin/verify-panel.js +294 -0
  20. package/bin/wave-plan.js +211 -0
  21. package/docs/EMPLOYEE-QUICKSTART.md +3 -3
  22. package/docs/erp-contract.md +168 -0
  23. package/docs/qualia-manual.html +5 -5
  24. package/hooks/branch-guard.js +133 -63
  25. package/hooks/pre-deploy-gate.js +38 -0
  26. package/hooks/task-write-guard.js +165 -0
  27. package/package.json +3 -2
  28. package/rules/codex-goal.md +28 -26
  29. package/rules/infrastructure.md +1 -1
  30. package/skills/qualia/SKILL.md +6 -0
  31. package/skills/qualia-build/SKILL.md +39 -7
  32. package/skills/qualia-eval/SKILL.md +83 -0
  33. package/skills/qualia-feature/SKILL.md +20 -4
  34. package/skills/qualia-fix/SKILL.md +13 -1
  35. package/skills/qualia-milestone/SKILL.md +12 -6
  36. package/skills/qualia-new/REFERENCE.md +6 -4
  37. package/skills/qualia-new/SKILL.md +27 -15
  38. package/skills/qualia-plan/SKILL.md +2 -2
  39. package/skills/qualia-report/SKILL.md +10 -0
  40. package/skills/qualia-scope/SKILL.md +3 -3
  41. package/skills/qualia-ship/SKILL.md +37 -4
  42. package/skills/qualia-update/SKILL.md +100 -0
  43. package/skills/qualia-verify/SKILL.md +51 -24
  44. package/templates/instructions.md +32 -0
  45. package/templates/journey.md +2 -2
  46. package/templates/project-discovery.md +30 -23
  47. package/templates/requirements.md +7 -7
  48. package/tests/agent-status.test.sh +153 -0
  49. package/tests/analyze-gate.test.sh +170 -0
  50. package/tests/bin.test.sh +5 -4
  51. package/tests/branch-hygiene.test.sh +93 -0
  52. package/tests/eval-runner.test.sh +147 -0
  53. package/tests/hooks.test.sh +218 -17
  54. package/tests/install-smoke.test.sh +4 -3
  55. package/tests/instructions.test.sh +109 -0
  56. package/tests/last-report.test.sh +156 -0
  57. package/tests/lib.test.sh +2 -2
  58. package/tests/project-sync.test.sh +175 -0
  59. package/tests/run-all.sh +9 -0
  60. package/tests/runner.js +3 -2
  61. package/tests/state.test.sh +187 -0
  62. package/tests/verify-panel.test.sh +162 -0
  63. package/tests/wave-plan.test.sh +153 -0
  64. package/skills/qualia-discuss/SKILL.md +0 -222
package/bin/state.js CHANGED
@@ -607,9 +607,28 @@ function cmdTransitionIncrement(opts, target) {
607
607
  const c = parseInt(opts.tasks_done) || 0;
608
608
  if (c > 0) t.lifetime.tasks_completed += c;
609
609
  }
610
+ // Scope tagging (anti-drift): /qualia-feature + /qualia-fix declare whether
611
+ // the work served the active milestone (--scope in --ref CORE-03) or was
612
+ // off-road (--scope off). Off-road work is COUNTED + ledgered so it can't
613
+ // drift invisibly — the OWNER + ERP see the tally, mirroring branch-guard.
614
+ const scope = String(opts.scope || "").toLowerCase();
615
+ if (scope === "off" || scope === "in") {
616
+ if (typeof t.lifetime.offroad_count !== "number") t.lifetime.offroad_count = 0;
617
+ if (scope === "off") {
618
+ t.lifetime.offroad_count += 1;
619
+ if (!Array.isArray(t.offroad)) t.offroad = [];
620
+ t.offroad.push({
621
+ at: new Date().toISOString(),
622
+ milestone: parseInt(t.milestone, 10) || null,
623
+ ref: opts.ref || null,
624
+ note: opts.notes || null,
625
+ });
626
+ if (t.offroad.length > 50) t.offroad = t.offroad.slice(-50); // keep recent
627
+ }
628
+ }
610
629
  writeTracking(t);
611
630
  regenerateViews(opts.notes || "Activity logged");
612
- return output({ ok: true, action: target, layout: "increments" });
631
+ return output({ ok: true, action: target, layout: "increments", scope: scope || undefined, offroad_count: t.lifetime.offroad_count });
613
632
  }
614
633
 
615
634
  // Resolve the target increment: explicit --id, else --phase N (back-compat
@@ -706,6 +725,9 @@ function ensureLifetime(t) {
706
725
  total_phases: 0,
707
726
  };
708
727
  }
728
+ // v7 lifecycle (backward compat): pre-v7 tracking.json predates these fields.
729
+ if (t.lifecycle !== "operate" && t.lifecycle !== "build") t.lifecycle = "build";
730
+ if (typeof t.lifetime.updates_completed !== "number") t.lifetime.updates_completed = 0;
709
731
  return t;
710
732
  }
711
733
 
@@ -756,6 +778,31 @@ function normalizeMilestoneName(name) {
756
778
  return String(name == null ? "" : name).trim().toLowerCase();
757
779
  }
758
780
 
781
+ // Parse the REQUIREMENTS.md traceability table for one milestone's REQ-IDs and
782
+ // their status. Rows look like: `| CORE-01 | M2: Name | Phase 3 | Complete |`.
783
+ // Returns { tracked: bool, total, incomplete: [{id, status}] }. tracked=false
784
+ // when REQUIREMENTS.md is absent or has no rows for this milestone (→ gate
785
+ // skips, like analyze-gate's no-scope-file: can't enforce what isn't declared).
786
+ function readMilestoneRequirements(milestoneNum) {
787
+ let md;
788
+ try { md = fs.readFileSync(path.join(PLANNING, "REQUIREMENTS.md"), "utf8"); }
789
+ catch { return { tracked: false, total: 0, incomplete: [] }; }
790
+ const num = parseInt(milestoneNum, 10);
791
+ const rows = [];
792
+ for (const line of md.split(/\r?\n/)) {
793
+ if (!/^\s*\|/.test(line)) continue;
794
+ const cells = line.split("|").map((c) => c.trim()).filter((c, i, a) => !(i === 0 && c === "") && !(i === a.length - 1 && c === ""));
795
+ if (cells.length < 4) continue;
796
+ const [id, milestone, , status] = cells;
797
+ if (!/^[A-Z]+-\d+$/.test(id)) continue; // skip header + non-REQ rows
798
+ const m = milestone.match(/M(\d+)\b/);
799
+ if (!m || parseInt(m[1], 10) !== num) continue;
800
+ rows.push({ id, status: status || "" });
801
+ }
802
+ const incomplete = rows.filter((r) => r.status.trim().toLowerCase() !== "complete");
803
+ return { tracked: rows.length > 0, total: rows.length, incomplete };
804
+ }
805
+
759
806
  function readState() {
760
807
  try {
761
808
  return fs.readFileSync(STATE_FILE, "utf8");
@@ -970,6 +1017,25 @@ function checkPreconditions(current, target, opts) {
970
1017
  const anchors = doneWhenCount + acCount;
971
1018
  if (anchors < taskHeaders.length)
972
1019
  return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`);
1020
+ // v7 kernel: a phase cannot reach `planned` without a machine contract.
1021
+ // checkMachineEvidence() at the `verified` gate only engages when
1022
+ // phase-N-contract.json exists; if a contract could be skipped here, the
1023
+ // entire evidence requirement is bypassable by omission and the prose
1024
+ // verifier (which can PASS on inaction) would govern. Requiring it here is
1025
+ // what makes "I built it" insufficient and "the contract ran clean" required.
1026
+ const contractFile = path.join(PLANNING, `phase-${phase}-contract.json`);
1027
+ if (!fs.existsSync(contractFile))
1028
+ return fail(
1029
+ "MISSING_CONTRACT",
1030
+ `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.`
1031
+ );
1032
+ try {
1033
+ const c = JSON.parse(fs.readFileSync(contractFile, "utf8"));
1034
+ if (!c || !Array.isArray(c.tasks) || c.tasks.length === 0)
1035
+ return fail("INVALID_CONTRACT", `${contractFile} has no tasks[]; not a valid phase contract.`);
1036
+ } catch (e) {
1037
+ return fail("INVALID_CONTRACT", `Could not parse ${contractFile}: ${e.message}`);
1038
+ }
973
1039
  }
974
1040
 
975
1041
  if (target === "verified") {
@@ -994,9 +1060,17 @@ function checkPreconditions(current, target, opts) {
994
1060
  }
995
1061
 
996
1062
  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}`);
1063
+ // v7 lifecycle: the HANDOFF.md requirement is a BUILD-mode convention, not a
1064
+ // universal law. An "operate" project (a launched, repeatedly-shipping
1065
+ // product or retainer) has no single handoff moment, so it must not be
1066
+ // forced to produce one. opts.lifecycle is threaded from tracking by the
1067
+ // caller; absent/"build" keeps the original requirement.
1068
+ const lifecycle = opts.lifecycle || "build";
1069
+ if (lifecycle === "build") {
1070
+ const hFile = path.join(PLANNING, "HANDOFF.md");
1071
+ if (!fs.existsSync(hFile))
1072
+ return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
1073
+ }
1000
1074
  }
1001
1075
 
1002
1076
  // Gap-closure circuit breaker (configurable limit)
@@ -1055,7 +1129,14 @@ function recordLedgerEvent(meta) {
1055
1129
  }
1056
1130
 
1057
1131
  // ─── Next Command Logic ──────────────────────────────────
1058
- function nextCommand(status, phase, totalPhases, verification) {
1132
+ // `lifecycle` (default "build") changes the route once a project has launched.
1133
+ // In "operate" the project is an UPDATE STREAM, not a milestone journey: there is
1134
+ // no polish → ship → handoff terminal chain. After the last phase verifies, the
1135
+ // next move is the next update (/qualia-update), and the project never gets
1136
+ // dragged to a handoff it has outgrown. This is the v7 thesis in miniature —
1137
+ // a behavior that was hard-coded in prose is now a branch on explicit state.
1138
+ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
1139
+ const operate = lifecycle === "operate";
1059
1140
  switch (status) {
1060
1141
  case "setup":
1061
1142
  return `/qualia-plan ${phase}`;
@@ -1066,15 +1147,17 @@ function nextCommand(status, phase, totalPhases, verification) {
1066
1147
  case "verified":
1067
1148
  if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
1068
1149
  if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
1069
- return "/qualia-polish";
1150
+ return operate ? "/qualia-update" : "/qualia-polish";
1070
1151
  case "polished":
1071
1152
  return "/qualia-ship";
1072
1153
  case "shipped":
1073
- return "/qualia-handoff";
1154
+ // In build mode a shipped project hands off once. In operate it loops:
1155
+ // the deploy was just another update.
1156
+ return operate ? "/qualia-update" : "/qualia-handoff";
1074
1157
  case "handed_off":
1075
1158
  return "/qualia-report";
1076
1159
  case "done":
1077
- return "Done.";
1160
+ return operate ? "/qualia-update" : "Done.";
1078
1161
  default:
1079
1162
  return `/qualia`;
1080
1163
  }
@@ -1202,11 +1285,14 @@ function cmdCheck(opts) {
1202
1285
  tasks_done: t.tasks_done || 0,
1203
1286
  tasks_total: t.tasks_total || 0,
1204
1287
  deployed_url: t.deployed_url || "",
1288
+ lifecycle: t.lifecycle || "build",
1289
+ launched_at: t.launched_at || "",
1205
1290
  next_command: nextCommand(
1206
1291
  s.status,
1207
1292
  s.phase,
1208
1293
  s.total_phases,
1209
- t.verification
1294
+ t.verification,
1295
+ t.lifecycle
1210
1296
  ),
1211
1297
  schema_errors: s.schema_errors && s.schema_errors.length ? s.schema_errors : undefined,
1212
1298
  });
@@ -1233,6 +1319,28 @@ function applyNoteOrActivity(target, s, t, opts) {
1233
1319
  t.lifetime.tasks_completed += count;
1234
1320
  }
1235
1321
  }
1322
+ // Scope tagging (anti-drift): /qualia-feature + /qualia-fix declare whether the
1323
+ // work served the active milestone (--scope in --ref CORE-03) or was off-road
1324
+ // (--scope off). Off-road work is COUNTED + ledgered so it can't drift
1325
+ // invisibly — the OWNER + ERP see the tally, mirroring branch-guard.
1326
+ const scope = String(opts.scope || "").toLowerCase();
1327
+ let offroadCount;
1328
+ if (scope === "in" || scope === "off") {
1329
+ ensureLifetime(t);
1330
+ if (typeof t.lifetime.offroad_count !== "number") t.lifetime.offroad_count = 0;
1331
+ if (scope === "off") {
1332
+ t.lifetime.offroad_count += 1;
1333
+ if (!Array.isArray(t.offroad)) t.offroad = [];
1334
+ t.offroad.push({
1335
+ at: new Date().toISOString(),
1336
+ milestone: parseInt(t.milestone, 10) || null,
1337
+ ref: opts.ref || null,
1338
+ note: opts.notes || null,
1339
+ });
1340
+ if (t.offroad.length > 50) t.offroad = t.offroad.slice(-50);
1341
+ }
1342
+ offroadCount = t.lifetime.offroad_count;
1343
+ }
1236
1344
  t.last_updated = new Date().toISOString();
1237
1345
  writeTracking(t);
1238
1346
  s.last_activity = opts.notes || "Activity logged";
@@ -1242,6 +1350,8 @@ function applyNoteOrActivity(target, s, t, opts) {
1242
1350
  phase: s.phase,
1243
1351
  status: s.status,
1244
1352
  action: target,
1353
+ scope: scope || undefined,
1354
+ offroad_count: offroadCount,
1245
1355
  };
1246
1356
  }
1247
1357
 
@@ -1400,8 +1510,8 @@ function cmdTransition(opts) {
1400
1510
 
1401
1511
  const phase = parseInt(opts.phase) || s.phase;
1402
1512
 
1403
- // Precondition check
1404
- const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase });
1513
+ // Precondition check (lifecycle threaded so the handoff gate can relax in operate)
1514
+ const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase, lifecycle: t.lifecycle });
1405
1515
  if (!check.ok) {
1406
1516
  // --force bypasses status-ordering and plan-content errors. The use case
1407
1517
  // is retroactive bookkeeping: a phase was built without /qualia-plan and
@@ -1433,6 +1543,18 @@ function cmdTransition(opts) {
1433
1543
  if (target === "polished") applyPolishedTransition(s);
1434
1544
  if (target === "shipped") applyShippedTransition(t, opts);
1435
1545
 
1546
+ // v7: in operate mode, a verified(pass) on the final phase completes one UPDATE.
1547
+ // This is the operate-mode analogue of closing a milestone in build mode.
1548
+ if (
1549
+ target === "verified" &&
1550
+ opts.verification === "pass" &&
1551
+ t.lifecycle === "operate" &&
1552
+ phase >= (parseInt(s.total_phases) || phase)
1553
+ ) {
1554
+ ensureLifetime(t);
1555
+ t.lifetime.updates_completed = (t.lifetime.updates_completed || 0) + 1;
1556
+ }
1557
+
1436
1558
  // Atomic commit
1437
1559
  const writeError = commitTransitionAtomic(s, t);
1438
1560
  if (writeError) return output(writeError);
@@ -1471,7 +1593,8 @@ function cmdTransition(opts) {
1471
1593
  previous_status: prevStatus,
1472
1594
  verification: t.verification,
1473
1595
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
1474
- next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification),
1596
+ lifecycle: t.lifecycle || "build",
1597
+ next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification, t.lifecycle),
1475
1598
  };
1476
1599
  if (ledger.ok) {
1477
1600
  result.ledger_event_id = ledger.event_id;
@@ -1579,6 +1702,7 @@ function cmdInit(opts) {
1579
1702
  milestones_completed: 0,
1580
1703
  total_phases: 0,
1581
1704
  last_closed_milestone: 0,
1705
+ updates_completed: 0,
1582
1706
  };
1583
1707
  const lifetime = prevLife
1584
1708
  ? { ...defaultLifetime, ...(prevLife.lifetime || {}) }
@@ -1607,6 +1731,11 @@ function cmdInit(opts) {
1607
1731
  total_phases: totalPhases,
1608
1732
  status: "setup",
1609
1733
  profile,
1734
+ // v7 lifecycle: a new project starts in "build" (milestone journey).
1735
+ // `launch` flips it to "operate" (update stream). Preserved across re-init.
1736
+ lifecycle: prevLife ? (prevLife.lifecycle || "build") : "build",
1737
+ launched_at: prevLife ? (prevLife.launched_at || "") : "",
1738
+ launch_source: prevLife ? (prevLife.launch_source || "") : "",
1610
1739
  wave: 0,
1611
1740
  tasks_done: 0,
1612
1741
  tasks_total: 0,
@@ -1665,6 +1794,76 @@ function cmdInit(opts) {
1665
1794
  output(result);
1666
1795
  }
1667
1796
 
1797
+ // v7: the one-time launch event. Flips the project from "build" (milestone
1798
+ // journey) to "operate" (update stream). This is the discrete transition the
1799
+ // ERP can drive when it detects a project is live (is_live / status:Launched),
1800
+ // so "the product is launched" becomes explicit state instead of a milestone the
1801
+ // team is forced to call "handoff". Idempotent: launching an operate project is
1802
+ // a no-op. lifecycle is canonical in tracking.json; cmdCheck surfaces it.
1803
+ function cmdLaunch(opts) {
1804
+ const beforeStateRaw = readState();
1805
+ const beforeTrackingRaw = readTrackingRaw();
1806
+ const t = parseTrackingRaw(beforeTrackingRaw);
1807
+ if (!t) return output(fail("NO_PROJECT", "No .planning/ found. Run /qualia-new."));
1808
+ ensureLifetime(t);
1809
+
1810
+ if (t.lifecycle === "operate") {
1811
+ return output({
1812
+ ok: true,
1813
+ action: "launch",
1814
+ already_launched: true,
1815
+ lifecycle: "operate",
1816
+ launched_at: t.launched_at || "",
1817
+ launch_source: t.launch_source || "",
1818
+ next_command: "/qualia-update",
1819
+ message: "Already launched (operate lifecycle). Nothing to do.",
1820
+ });
1821
+ }
1822
+
1823
+ t.lifecycle = "operate";
1824
+ t.launched_at = new Date().toISOString();
1825
+ t.launch_source = opts.source === "erp" ? "erp" : "manual";
1826
+ if (opts.deployed_url) t.deployed_url = opts.deployed_url;
1827
+ t.last_updated = new Date().toISOString();
1828
+ writeTracking(t);
1829
+
1830
+ const ledger = recordLedgerEvent({
1831
+ action: "launch",
1832
+ phase_before: t.phase || null,
1833
+ phase_after: t.phase || null,
1834
+ status_before: t.status || null,
1835
+ status_after: t.status || null,
1836
+ state_before: parseStateMd(beforeStateRaw),
1837
+ state_after: parseStateMd(readState()),
1838
+ tracking_before: parseTrackingRaw(beforeTrackingRaw),
1839
+ tracking_after: t,
1840
+ state_raw_before: beforeStateRaw,
1841
+ state_raw_after: readState(),
1842
+ tracking_raw_before: beforeTrackingRaw,
1843
+ tracking_raw_after: readTrackingRaw(),
1844
+ evidence_refs: opts.deployed_url ? [opts.deployed_url] : [],
1845
+ });
1846
+
1847
+ const result = {
1848
+ ok: true,
1849
+ action: "launch",
1850
+ lifecycle: "operate",
1851
+ launched_at: t.launched_at,
1852
+ launch_source: t.launch_source,
1853
+ deployed_url: t.deployed_url || "",
1854
+ next_command: "/qualia-update",
1855
+ message:
1856
+ "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.",
1857
+ };
1858
+ if (ledger.ok) {
1859
+ result.ledger_event_id = ledger.event_id;
1860
+ result.ledger_event_hash = ledger.event_hash;
1861
+ } else {
1862
+ result.ledger_error = ledger.error;
1863
+ }
1864
+ output(result);
1865
+ }
1866
+
1668
1867
  function cmdFix(opts) {
1669
1868
  const beforeStateRaw = readState();
1670
1869
  const beforeTrackingRaw = readTrackingRaw();
@@ -2183,6 +2382,27 @@ function cmdValidatePlan(opts) {
2183
2382
  // recently closed milestone so re-running close-milestone (e.g., after a
2184
2383
  // hiccup) does NOT double-count. To re-close a milestone deliberately, pass
2185
2384
  // --force.
2385
+ // Report REQ-ID completion for a milestone (defaults to the current one). The
2386
+ // same check close-milestone gates on — exposed so /qualia-milestone can show
2387
+ // coverage before closing, and so it's directly testable. Exit 0 = all complete
2388
+ // (or untracked), 1 = incomplete requirements remain.
2389
+ function cmdReqsCheck(opts) {
2390
+ const t = readTracking();
2391
+ const milestone = opts.milestone != null ? parseInt(opts.milestone, 10) : (t ? parseInt(t.milestone, 10) || 1 : 1);
2392
+ const reqs = readMilestoneRequirements(milestone);
2393
+ const ok = !reqs.tracked || reqs.incomplete.length === 0;
2394
+ output({
2395
+ ok,
2396
+ action: "reqs-check",
2397
+ milestone,
2398
+ tracked: reqs.tracked,
2399
+ total: reqs.total,
2400
+ complete: reqs.total - reqs.incomplete.length,
2401
+ incomplete: reqs.incomplete,
2402
+ });
2403
+ process.exitCode = ok ? 0 : 1;
2404
+ }
2405
+
2186
2406
  function cmdCloseMilestone(opts) {
2187
2407
  const beforeStateRaw = readState();
2188
2408
  const beforeTrackingRaw = readTrackingRaw();
@@ -2192,6 +2412,7 @@ function cmdCloseMilestone(opts) {
2192
2412
  return output(fail("NO_PROJECT", "No .planning/ found."));
2193
2413
  }
2194
2414
  ensureLifetime(t);
2415
+ const closeWarnings = [];
2195
2416
 
2196
2417
  // parseInt — legacy tracking.json files carry milestone as a string ("9"),
2197
2418
  // which would corrupt `closedMilestone + 1` ("91") and break num dedupe.
@@ -2236,6 +2457,23 @@ function cmdCloseMilestone(opts) {
2236
2457
  )
2237
2458
  );
2238
2459
  }
2460
+ // REQ-ID coverage gate: a milestone isn't "done" just because its phases
2461
+ // verified — its agreed requirements must actually be Complete. Stops
2462
+ // "finishing a milestone with scope left open". strict blocks; standard warns.
2463
+ const reqs = readMilestoneRequirements(closedMilestone);
2464
+ if (reqs.tracked && reqs.incomplete.length > 0) {
2465
+ const profile = resolveProfile(s, t);
2466
+ const list = reqs.incomplete.map((r) => `${r.id}:${r.status || "Pending"}`).join(", ");
2467
+ if (profile === "strict") {
2468
+ return output(
2469
+ fail(
2470
+ "MILESTONE_REQS_INCOMPLETE",
2471
+ `Milestone ${closedMilestone} has ${reqs.incomplete.length}/${reqs.total} requirement(s) not Complete in REQUIREMENTS.md: ${list}. Finish or explicitly defer them (move to Out of Scope), or use --force.`
2472
+ )
2473
+ );
2474
+ }
2475
+ closeWarnings.push(`${reqs.incomplete.length}/${reqs.total} requirement(s) not Complete: ${list} (standard profile — proceeding; record why in the report).`);
2476
+ }
2239
2477
  }
2240
2478
 
2241
2479
  // ─── Append a summary to milestones[] so the ERP can render the tree ──
@@ -2354,6 +2592,7 @@ function cmdCloseMilestone(opts) {
2354
2592
  } else {
2355
2593
  result.ledger_error = ledger.error;
2356
2594
  }
2595
+ if (closeWarnings.length) result.warnings = closeWarnings;
2357
2596
  output(result);
2358
2597
  }
2359
2598
 
@@ -2775,6 +3014,9 @@ try {
2775
3014
  case "transition":
2776
3015
  cmdTransition(opts);
2777
3016
  break;
3017
+ case "launch":
3018
+ cmdLaunch(opts);
3019
+ break;
2778
3020
  case "init":
2779
3021
  cmdInit(opts);
2780
3022
  break;
@@ -2787,6 +3029,9 @@ try {
2787
3029
  case "close-milestone":
2788
3030
  cmdCloseMilestone(opts);
2789
3031
  break;
3032
+ case "reqs-check":
3033
+ cmdReqsCheck(opts);
3034
+ break;
2790
3035
  case "backfill-lifetime":
2791
3036
  cmdBackfillLifetime(opts);
2792
3037
  break;