qualia-framework 6.14.0 → 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 (50) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +130 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/bin/agent-status.js +24 -11
  6. package/bin/branch-hygiene.js +135 -0
  7. package/bin/command-surface.js +1 -0
  8. package/bin/compile-instructions.js +82 -0
  9. package/bin/eval-runner.js +218 -0
  10. package/bin/host-adapters.js +72 -12
  11. package/bin/install.js +21 -13
  12. package/bin/last-report.js +207 -0
  13. package/bin/project-sync.js +315 -0
  14. package/bin/runtime-manifest.js +6 -0
  15. package/bin/state.js +112 -1
  16. package/bin/verify-panel.js +294 -0
  17. package/bin/wave-plan.js +211 -0
  18. package/docs/erp-contract.md +145 -0
  19. package/package.json +3 -2
  20. package/rules/codex-goal.md +28 -26
  21. package/rules/infrastructure.md +1 -1
  22. package/skills/qualia/SKILL.md +6 -0
  23. package/skills/qualia-build/SKILL.md +12 -9
  24. package/skills/qualia-eval/SKILL.md +83 -0
  25. package/skills/qualia-feature/SKILL.md +20 -4
  26. package/skills/qualia-fix/SKILL.md +13 -1
  27. package/skills/qualia-milestone/SKILL.md +12 -6
  28. package/skills/qualia-new/REFERENCE.md +6 -4
  29. package/skills/qualia-new/SKILL.md +27 -15
  30. package/skills/qualia-plan/SKILL.md +2 -2
  31. package/skills/qualia-report/SKILL.md +10 -0
  32. package/skills/qualia-scope/SKILL.md +3 -3
  33. package/skills/qualia-ship/SKILL.md +34 -4
  34. package/skills/qualia-update/SKILL.md +4 -0
  35. package/skills/qualia-verify/SKILL.md +45 -24
  36. package/templates/instructions.md +32 -0
  37. package/templates/journey.md +1 -1
  38. package/templates/project-discovery.md +30 -23
  39. package/templates/requirements.md +7 -7
  40. package/tests/agent-status.test.sh +15 -0
  41. package/tests/branch-hygiene.test.sh +93 -0
  42. package/tests/eval-runner.test.sh +147 -0
  43. package/tests/instructions.test.sh +109 -0
  44. package/tests/last-report.test.sh +156 -0
  45. package/tests/lib.test.sh +2 -2
  46. package/tests/project-sync.test.sh +175 -0
  47. package/tests/run-all.sh +7 -0
  48. package/tests/state.test.sh +92 -0
  49. package/tests/verify-panel.test.sh +162 -0
  50. package/tests/wave-plan.test.sh +153 -0
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ // project-sync.js — the single Framework→ERP project-sync snapshot.
3
+ //
4
+ // WHY: the ERP already ingests per-session reports (report-payload.js →
5
+ // /api/v1/reports) and a lean progress rollup (project-snapshot.js →
6
+ // /api/v1/project-snapshots). What was missing is ONE complete payload the ERP
7
+ // can use to RECONCILE a project's milestones, phases, tasks, and reports in a
8
+ // single read — including REQ-ID completion per milestone and the trunk/merge
9
+ // model so the ERP understands how feature branches become deployed main.
10
+ //
11
+ // This composes (never duplicates) the existing snapshot:
12
+ // - bin/project-snapshot.js → identity, current position, journey, lifetime,
13
+ // quality (harness_eval), progress_percent
14
+ // - bin/state.js → readMilestoneRequirements (REQ-ID completion),
15
+ // parseStateMd (roadmap phase rows)
16
+ // and ADDS the B2-specific reconciliation surface on top:
17
+ // - milestones[] enriched with REQ-ID completion (total/complete/incomplete),
18
+ // phases count, tasks_completed, deployed_url, status (closed/current/future)
19
+ // - task_rollup (lifetime tasks/build/deploy + current-phase gap_cycles)
20
+ // - accountability { offroad_count, offroad[] } — off-milestone work tally
21
+ // - integration { model: "trunk", ... } — the PR/merge model the ERP must grok
22
+ // - schema_version so the ERP can evolve the contract safely
23
+ //
24
+ // Read-only. Zero npm dependencies. `--json` (or no flag) prints the object;
25
+ // library buildProjectSync() returns it. Exit 0 = built, 2 = no .planning/.
26
+
27
+ const fs = require("fs");
28
+ const os = require("os");
29
+ const path = require("path");
30
+
31
+ const snapshotLib = require("./project-snapshot.js");
32
+
33
+ // project-sync's contract version. Bump when the SHAPE changes so the ERP can
34
+ // branch on it. (Distinct from project-snapshot's snapshot_version.)
35
+ const SCHEMA_VERSION = 1;
36
+
37
+ function readJson(file, fallback = {}) {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(file, "utf8"));
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ }
44
+
45
+ function readText(file, fallback = "") {
46
+ try {
47
+ return fs.readFileSync(file, "utf8");
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ // REQ-ID completion for one milestone, read from .planning/REQUIREMENTS.md.
54
+ // Mirrors state.js readMilestoneRequirements() exactly (rows like
55
+ // `| CORE-01 | M2: Name | Phase 3 | Complete |`) but is parameterized on the
56
+ // planning dir so tests can point it at a temp fixture. tracked=false when
57
+ // REQUIREMENTS.md is absent or has no rows for the milestone (→ ERP skips the
58
+ // REQ gate for that milestone, can't reconcile what wasn't declared).
59
+ function milestoneRequirements(planningDir, milestoneNum) {
60
+ const md = readText(path.join(planningDir, "REQUIREMENTS.md"), null);
61
+ if (md == null) return { tracked: false, total: 0, complete: 0, incomplete: [] };
62
+ const num = parseInt(milestoneNum, 10);
63
+ const rows = [];
64
+ for (const line of md.split(/\r?\n/)) {
65
+ if (!/^\s*\|/.test(line)) continue;
66
+ const cells = line
67
+ .split("|")
68
+ .map((c) => c.trim())
69
+ .filter((c, i, a) => !(i === 0 && c === "") && !(i === a.length - 1 && c === ""));
70
+ if (cells.length < 4) continue;
71
+ const [id, milestone, , status] = cells;
72
+ if (!/^[A-Z]+-\d+$/.test(id)) continue; // skip header + non-REQ rows
73
+ const m = milestone.match(/M(\d+)\b/);
74
+ if (!m || parseInt(m[1], 10) !== num) continue;
75
+ rows.push({ id, status: status || "" });
76
+ }
77
+ const incomplete = rows
78
+ .filter((r) => r.status.trim().toLowerCase() !== "complete")
79
+ .map((r) => ({ id: r.id, status: r.status }));
80
+ return {
81
+ tracked: rows.length > 0,
82
+ total: rows.length,
83
+ complete: rows.length - incomplete.length,
84
+ incomplete,
85
+ };
86
+ }
87
+
88
+ // Count phase rows declared for the project from STATE.md's roadmap table.
89
+ // Used only as a fallback when a closed milestone summary carries no phase
90
+ // count of its own. Best-effort: returns 0 when STATE.md is absent/malformed.
91
+ function roadmapPhaseCount(planningDir) {
92
+ const md = readText(path.join(planningDir, "STATE.md"), "");
93
+ const m = md.match(/\| # \| Phase \| Goal \| Status \|\n\|[-|]+\|\n([\s\S]*?)(?=\n##|\n$|$)/);
94
+ if (!m) return 0;
95
+ return m[1]
96
+ .trim()
97
+ .split("\n")
98
+ .filter((row) => row.split("|").map((c) => c.trim()).filter(Boolean).length >= 4).length;
99
+ }
100
+
101
+ // Build the unified milestones[] for reconciliation. Source of truth ordering:
102
+ // 1. JOURNEY.md milestones (the full planned arc) — gives num + name + total.
103
+ // 2. tracking.milestones[] (closed summaries) — gives closed_at + per-milestone
104
+ // phases_completed/tasks_completed + deployed_url.
105
+ // 3. tracking.milestone (current) — marks the active one.
106
+ // 4. REQUIREMENTS.md — REQ-ID completion per milestone.
107
+ // Each entry's `status` is closed | current | future so the ERP can reconcile
108
+ // which milestones to mark done, in-progress, or pending.
109
+ function buildMilestones(options) {
110
+ const { journey, closed, currentNum, planningDir } = options;
111
+ const closedByNum = new Map();
112
+ for (const m of closed) {
113
+ const n = Number(m && m.num);
114
+ if (Number.isFinite(n)) closedByNum.set(n, m);
115
+ }
116
+
117
+ // The full set of milestone numbers we know about: journey ∪ closed ∪ current.
118
+ const nums = new Set();
119
+ for (const m of journey) nums.add(Number(m.num));
120
+ for (const n of closedByNum.keys()) nums.add(n);
121
+ if (Number.isFinite(currentNum)) nums.add(currentNum);
122
+ const ordered = [...nums].filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
123
+
124
+ const nameByNum = new Map(journey.map((m) => [Number(m.num), m.name]));
125
+
126
+ return ordered.map((num) => {
127
+ const closedSummary = closedByNum.get(num) || null;
128
+ const status = closedSummary
129
+ ? "closed"
130
+ : num === currentNum
131
+ ? "current"
132
+ : "future";
133
+ const req = milestoneRequirements(planningDir, num);
134
+ const entry = {
135
+ num,
136
+ name:
137
+ (closedSummary && closedSummary.name) ||
138
+ nameByNum.get(num) ||
139
+ `Milestone ${num}`,
140
+ status,
141
+ phases: Number(
142
+ (closedSummary && (closedSummary.phases_completed != null
143
+ ? closedSummary.phases_completed
144
+ : closedSummary.phases)) || 0
145
+ ),
146
+ tasks_completed: Number(
147
+ (closedSummary && closedSummary.tasks_completed) || 0
148
+ ),
149
+ requirements: {
150
+ tracked: req.tracked,
151
+ total: req.total,
152
+ complete: req.complete,
153
+ incomplete: req.incomplete,
154
+ },
155
+ };
156
+ if (closedSummary && closedSummary.closed_at) entry.closed_at = closedSummary.closed_at;
157
+ const deployedUrl = (closedSummary && closedSummary.deployed_url) || "";
158
+ if (deployedUrl) entry.deployed_url = deployedUrl;
159
+ return entry;
160
+ });
161
+ }
162
+
163
+ function buildProjectSync(options = {}) {
164
+ const cwd = options.cwd || process.cwd();
165
+ const home = options.home || os.homedir();
166
+ const planning = options.planningDir || path.join(cwd, ".planning");
167
+ const now = options.now || new Date().toISOString();
168
+
169
+ // Compose the lean dashboard snapshot — reuse its identity/current/journey/
170
+ // lifetime/quality blocks verbatim instead of re-deriving them here.
171
+ const snapshot = snapshotLib.buildSnapshot({
172
+ cwd,
173
+ home,
174
+ planningDir: planning,
175
+ qualiaHome: options.qualiaHome,
176
+ now,
177
+ });
178
+
179
+ const tracking = readJson(path.join(planning, "tracking.json"), {});
180
+ const journeyText = readText(path.join(planning, "JOURNEY.md"), "");
181
+ const journey = snapshotLib.parseJourneyMilestones(journeyText);
182
+ const closed = Array.isArray(tracking.milestones) ? tracking.milestones : [];
183
+ const currentNum = Number(tracking.milestone || snapshot.current.milestone || 1);
184
+ const lifetime = snapshot.lifetime;
185
+
186
+ const milestones = buildMilestones({ journey, closed, currentNum, planningDir: planning });
187
+ // Fill a current/future milestone's phase count from the roadmap when it has
188
+ // none of its own (closed summaries already carry phases_completed).
189
+ const roadmapPhases = roadmapPhaseCount(planning) || Number(tracking.total_phases || 0);
190
+ for (const m of milestones) {
191
+ if (m.status === "current" && !m.phases) m.phases = roadmapPhases;
192
+ }
193
+
194
+ const offroad = Array.isArray(tracking.offroad) ? tracking.offroad : [];
195
+ const offroadCount = Number(
196
+ (tracking.lifetime && tracking.lifetime.offroad_count) || offroad.length || 0
197
+ );
198
+
199
+ return {
200
+ schema_version: SCHEMA_VERSION,
201
+ generated_at: now,
202
+ source: "qualia-framework",
203
+ payload: "project-sync",
204
+ framework_version: snapshot.framework_version,
205
+ identifiers: snapshot.identifiers,
206
+ project: {
207
+ ...snapshot.project,
208
+ lifecycle: tracking.lifecycle || "build",
209
+ ...(tracking.launched_at ? { launched_at: tracking.launched_at } : {}),
210
+ ...(tracking.launch_source ? { launch_source: tracking.launch_source } : {}),
211
+ },
212
+ current: snapshot.current,
213
+ quality: snapshot.quality,
214
+ // Full milestone arc enriched with REQ-ID completion — the spine of ERP
215
+ // reconciliation. journey.total_milestones rides along for the denominator.
216
+ total_milestones: snapshot.journey.total_milestones,
217
+ milestones,
218
+ // Cumulative + current task accounting so the ERP can roll tasks up without
219
+ // replaying every report.
220
+ task_rollup: {
221
+ tasks_completed: lifetime.tasks_completed,
222
+ phases_completed: lifetime.phases_completed,
223
+ milestones_completed: lifetime.milestones_completed,
224
+ total_phases_all_milestones: lifetime.total_phases,
225
+ build_count: lifetime.build_count,
226
+ deploy_count: lifetime.deploy_count,
227
+ current_phase_gap_cycles: snapshot.current.gap_cycles,
228
+ },
229
+ // Accountability: off-milestone work the OWNER + ERP should see (mirrors the
230
+ // branch-guard main-push tally — drift is counted, not hidden).
231
+ accountability: {
232
+ offroad_count: offroadCount,
233
+ offroad: offroad.slice(-10),
234
+ },
235
+ // The PR/merge model the ERP must understand to map branch→main→deploy:
236
+ // feature branches integrate to main at /qualia-ship (trunk model); pushes
237
+ // to a protected branch are recorded for accountability via branch-guard's
238
+ // employee_main_push events (POST /api/v1/policy-events; the local journal
239
+ // lives in the install home, referenced not read here).
240
+ integration: {
241
+ model: "trunk",
242
+ integrates_at: "/qualia-ship",
243
+ protected_branches: ["main", "master"],
244
+ main_push_event_type: "employee_main_push",
245
+ main_push_events_path: "~/.claude/.main-push-events.json",
246
+ note:
247
+ "Feature branches integrate to main at /qualia-ship (then deploy). " +
248
+ "Direct pushes to a protected branch are allowed but recorded as " +
249
+ "employee_main_push policy events for per-employee accountability.",
250
+ },
251
+ timestamps: snapshot.timestamps,
252
+ };
253
+ }
254
+
255
+ function writeProjectSync(sync, options = {}) {
256
+ const cwd = options.cwd || process.cwd();
257
+ const planning = options.planningDir || path.join(cwd, ".planning");
258
+ const outDir = options.outDir || path.join(planning, "snapshots");
259
+ fs.mkdirSync(outDir, { recursive: true });
260
+ const stamp = sync.generated_at.replace(/[:.]/g, "-");
261
+ const file = path.join(outDir, `project-sync-${stamp}.json`);
262
+ fs.writeFileSync(file, `${JSON.stringify(sync, null, 2)}\n`);
263
+ return file;
264
+ }
265
+
266
+ // ── CLI ─────────────────────────────────────────────────────────────────────
267
+ function parseArgs(argv) {
268
+ const args = { cwd: null };
269
+ for (let i = 2; i < argv.length; i++) {
270
+ const a = argv[i];
271
+ if (a === "--json") args.json = true;
272
+ else if (a === "--write") args.write = true;
273
+ else if (a === "--pretty") args.pretty = true;
274
+ else if (a === "--cwd") args.cwd = argv[++i];
275
+ else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
276
+ else if (a === "--now") args.now = argv[++i];
277
+ else if (a.startsWith("--now=")) args.now = a.slice(6);
278
+ }
279
+ return args;
280
+ }
281
+
282
+ function main(argv) {
283
+ const args = parseArgs(argv);
284
+ const cwd = args.cwd || process.cwd();
285
+ const planning = path.join(cwd, ".planning");
286
+ if (!fs.existsSync(planning)) {
287
+ console.error("project-sync: no .planning/ found — run /qualia-new to start.");
288
+ return 2;
289
+ }
290
+ const sync = buildProjectSync({ cwd, now: args.now });
291
+ if (args.write) {
292
+ const file = writeProjectSync(sync, { cwd });
293
+ process.stdout.write(`${file}\n`);
294
+ return 0;
295
+ }
296
+ process.stdout.write(`${JSON.stringify(sync, null, args.pretty || args.json ? 2 : 0)}\n`);
297
+ return 0;
298
+ }
299
+
300
+ module.exports = {
301
+ buildProjectSync,
302
+ buildMilestones,
303
+ milestoneRequirements,
304
+ writeProjectSync,
305
+ SCHEMA_VERSION,
306
+ };
307
+
308
+ if (require.main === module) {
309
+ try {
310
+ process.exit(main(process.argv));
311
+ } catch (error) {
312
+ console.error(`project-sync failed: ${error.message}`);
313
+ process.exit(1);
314
+ }
315
+ }
@@ -15,6 +15,11 @@ const RUNTIME_BIN_SCRIPTS = [
15
15
  { file: "contract-runner.js", label: "contract-runner.js (contract evidence runner)" },
16
16
  { file: "agent-status.js", label: "agent-status.js (per-task build status + wave fan-in barrier)" },
17
17
  { file: "analyze-gate.js", label: "analyze-gate.js (cross-artifact scope↔plan coverage gate)" },
18
+ { file: "verify-panel.js", label: "verify-panel.js (verifier panel + skeptic majority-survives aggregator)" },
19
+ { file: "wave-plan.js", label: "wave-plan.js (dependency-derived, concurrency-capped build schedule)" },
20
+ { file: "eval-runner.js", label: "eval-runner.js (layered assertion runner for AI-feature eval suites)" },
21
+ { file: "branch-hygiene.js", label: "branch-hygiene.js (clock-out sweep — stranded branches + stale PRs)" },
22
+ { file: "last-report.js", label: "last-report.js (router surfacing — newest session-report digest at session start)" },
18
23
  { file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
19
24
  { file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
20
25
  { file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
@@ -22,6 +27,7 @@ const RUNTIME_BIN_SCRIPTS = [
22
27
  { file: "report-payload.js", label: "report-payload.js (Framework -> ERP report payload builder)" },
23
28
  { file: "auto-report.js", label: "auto-report.js (B1 ship-time auto-report)" },
24
29
  { file: "project-snapshot.js", label: "project-snapshot.js (ERP/admin project progress snapshot)" },
30
+ { file: "project-sync.js", label: "project-sync.js (Framework -> ERP full project-sync reconciliation payload)" },
25
31
  { file: "trust-score.js", label: "trust-score.js (harness health scoring)" },
26
32
  { file: "harness-eval.js", label: "harness-eval.js (project eval scoring + evidence artifact)" },
27
33
  { file: "codex-goal.js", label: "codex-goal.js (Codex /goal objective + token-budget suggester)" },
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
@@ -759,6 +778,31 @@ function normalizeMilestoneName(name) {
759
778
  return String(name == null ? "" : name).trim().toLowerCase();
760
779
  }
761
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
+
762
806
  function readState() {
763
807
  try {
764
808
  return fs.readFileSync(STATE_FILE, "utf8");
@@ -1275,6 +1319,28 @@ function applyNoteOrActivity(target, s, t, opts) {
1275
1319
  t.lifetime.tasks_completed += count;
1276
1320
  }
1277
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
+ }
1278
1344
  t.last_updated = new Date().toISOString();
1279
1345
  writeTracking(t);
1280
1346
  s.last_activity = opts.notes || "Activity logged";
@@ -1284,6 +1350,8 @@ function applyNoteOrActivity(target, s, t, opts) {
1284
1350
  phase: s.phase,
1285
1351
  status: s.status,
1286
1352
  action: target,
1353
+ scope: scope || undefined,
1354
+ offroad_count: offroadCount,
1287
1355
  };
1288
1356
  }
1289
1357
 
@@ -2314,6 +2382,27 @@ function cmdValidatePlan(opts) {
2314
2382
  // recently closed milestone so re-running close-milestone (e.g., after a
2315
2383
  // hiccup) does NOT double-count. To re-close a milestone deliberately, pass
2316
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
+
2317
2406
  function cmdCloseMilestone(opts) {
2318
2407
  const beforeStateRaw = readState();
2319
2408
  const beforeTrackingRaw = readTrackingRaw();
@@ -2323,6 +2412,7 @@ function cmdCloseMilestone(opts) {
2323
2412
  return output(fail("NO_PROJECT", "No .planning/ found."));
2324
2413
  }
2325
2414
  ensureLifetime(t);
2415
+ const closeWarnings = [];
2326
2416
 
2327
2417
  // parseInt — legacy tracking.json files carry milestone as a string ("9"),
2328
2418
  // which would corrupt `closedMilestone + 1` ("91") and break num dedupe.
@@ -2367,6 +2457,23 @@ function cmdCloseMilestone(opts) {
2367
2457
  )
2368
2458
  );
2369
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
+ }
2370
2477
  }
2371
2478
 
2372
2479
  // ─── Append a summary to milestones[] so the ERP can render the tree ──
@@ -2485,6 +2592,7 @@ function cmdCloseMilestone(opts) {
2485
2592
  } else {
2486
2593
  result.ledger_error = ledger.error;
2487
2594
  }
2595
+ if (closeWarnings.length) result.warnings = closeWarnings;
2488
2596
  output(result);
2489
2597
  }
2490
2598
 
@@ -2921,6 +3029,9 @@ try {
2921
3029
  case "close-milestone":
2922
3030
  cmdCloseMilestone(opts);
2923
3031
  break;
3032
+ case "reqs-check":
3033
+ cmdReqsCheck(opts);
3034
+ break;
2924
3035
  case "backfill-lifetime":
2925
3036
  cmdBackfillLifetime(opts);
2926
3037
  break;