qualia-framework 7.2.2 → 7.3.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.
@@ -17,6 +17,7 @@
17
17
  // 3. wiki/_export rebuilt ≤ 2 days (export is scheduled & running)
18
18
  // 4. export concept count == allowed source set (export covers all concepts)
19
19
  // 5. tags:deprecated == 0 in _export (no deprecated rows leaked out)
20
+ // 6. doc/code coherence docs only reference real cli.js commands + skills
20
21
  //
21
22
  // Zero-dependency. Resolves the vault from $QUALIA_MEMORY else ~/qualia-memory,
22
23
  // and the install home from $QUALIA_HOME else ~/.claude. Missing dirs surface
@@ -209,6 +210,118 @@ function gateNoDeprecated(VAULT) {
209
210
  };
210
211
  }
211
212
 
213
+ // ── Doc/code coherence ──────────────────────────────────────────────────
214
+ // Docs that promise a `qualia-framework <cmd>` CLI command or a `/qualia-*`
215
+ // skill must point at one that actually exists, else a new hire follows a
216
+ // dead reference. This gate resolves the real surface from code (cli.js switch
217
+ // cases + command-surface.js ACTIVE_SKILLS) and flags any doc token that
218
+ // doesn't resolve. Deterministic, filesystem-only, fail-soft if sources are
219
+ // absent (e.g. a partial install) — it WARNs rather than crashing.
220
+
221
+ const COHERENCE_DOCS = [
222
+ "TROUBLESHOOTING.md",
223
+ "README.md",
224
+ "AGENTS.md",
225
+ "CLAUDE.md",
226
+ "docs/EMPLOYEE-QUICKSTART.md",
227
+ "docs/onboarding.html",
228
+ ];
229
+
230
+ // Real CLI commands = the quoted labels of the top-level `switch (cmd)` cases
231
+ // in bin/cli.js. We read the dispatch verbatim so the gate can never drift
232
+ // from the code.
233
+ function cliCommandSet(REPO) {
234
+ const file = path.join(REPO, "bin", "cli.js");
235
+ if (!fs.existsSync(file)) return null;
236
+ let txt;
237
+ try { txt = fs.readFileSync(file, "utf8"); } catch { return null; }
238
+ // Only the dispatch switch matters; bound the scan to after `switch (cmd)`.
239
+ const sw = txt.indexOf("switch (cmd)");
240
+ const body = sw >= 0 ? txt.slice(sw) : txt;
241
+ const set = new Set();
242
+ for (const m of body.matchAll(/^\s*case\s+"([a-z][a-z0-9-]*)":/gim)) {
243
+ set.add(m[1]);
244
+ }
245
+ return set.size ? set : null;
246
+ }
247
+
248
+ function activeSkillSet(REPO) {
249
+ // Prefer the canonical module; fall back to the installed copy beside us.
250
+ for (const candidate of [
251
+ path.join(REPO, "bin", "command-surface.js"),
252
+ path.join(__dirname, "command-surface.js"),
253
+ ]) {
254
+ if (!fs.existsSync(candidate)) continue;
255
+ try {
256
+ const mod = require(candidate);
257
+ const list = mod.activeSkills ? mod.activeSkills() : mod.ACTIVE_SKILLS;
258
+ if (Array.isArray(list) && list.length) return new Set(list);
259
+ } catch {}
260
+ }
261
+ return null;
262
+ }
263
+
264
+ function gateDocCoherence(REPO) {
265
+ const cliCmds = cliCommandSet(REPO);
266
+ const skills = activeSkillSet(REPO);
267
+ if (!cliCmds || !skills) {
268
+ return {
269
+ gate: "doc/code coherence",
270
+ status: "WARN",
271
+ detail: "cli.js or command-surface.js not found — coherence not checked",
272
+ };
273
+ }
274
+ const offenders = [];
275
+ let docsScanned = 0;
276
+ for (const rel of COHERENCE_DOCS) {
277
+ const p = path.join(REPO, rel);
278
+ if (!fs.existsSync(p)) continue;
279
+ let txt;
280
+ try { txt = fs.readFileSync(p, "utf8"); } catch { continue; }
281
+ docsScanned++;
282
+ // (a) `qualia-framework <cmd>` — first word after the binary name.
283
+ for (const m of txt.matchAll(/qualia-framework\s+([a-z][a-z0-9-]*)/g)) {
284
+ const cmd = m[1];
285
+ if (!cliCmds.has(cmd)) offenders.push(`${rel}:qualia-framework ${cmd}`);
286
+ }
287
+ // (b) `/qualia-<skill>` slash-command invocations. The leading slash must
288
+ // be a real command slash (boundary before it — whitespace, `(`, backtick,
289
+ // quote, or line start), NOT a path separator. And the token must not be a
290
+ // file/repo path (followed by `.`, `/`, or `:` → e.g. ./qualia-manual.html,
291
+ // qualia-memory.git, to/qualia-memory). This avoids false positives on the
292
+ // HTML file link and the knowledge-repo path in the quickstart.
293
+ for (const m of txt.matchAll(/(^|[\s(`'">])\/(qualia-[a-z]+)(?![a-z./:-])/gim)) {
294
+ const skill = m[2];
295
+ // `qualia` (the router) and `qualia-framework` (the CLI) are valid; the
296
+ // CLI binary is matched without a slash, so a bare `/qualia-framework`
297
+ // would be wrong — but the negative lookahead already excludes `-`.
298
+ if (skill === "qualia") continue;
299
+ if (!skills.has(skill)) offenders.push(`${rel}:/${skill}`);
300
+ }
301
+ }
302
+ if (docsScanned === 0) {
303
+ return { gate: "doc/code coherence", status: "WARN", detail: "no coherence docs present to scan" };
304
+ }
305
+ if (offenders.length === 0) {
306
+ return {
307
+ gate: "doc/code coherence",
308
+ status: "PASS",
309
+ detail: `${docsScanned} doc(s): all command/skill refs resolve`,
310
+ };
311
+ }
312
+ const uniq = [...new Set(offenders)];
313
+ return {
314
+ gate: "doc/code coherence",
315
+ status: "FAIL",
316
+ detail: `${uniq.length} dead ref(s): ${uniq.slice(0, 4).join(", ")}${uniq.length > 4 ? "…" : ""}`,
317
+ };
318
+ }
319
+
320
+ function repoRoot() {
321
+ // The doctor lives in <root>/bin; the docs/cli/surface sit one level up.
322
+ return path.dirname(__dirname);
323
+ }
324
+
212
325
  function runGates() {
213
326
  const QUALIA_HOME = qualiaHome();
214
327
  const VAULT = vaultRoot();
@@ -218,6 +331,7 @@ function runGates() {
218
331
  gateExportFresh(VAULT),
219
332
  gateExportCoverage(VAULT),
220
333
  gateNoDeprecated(VAULT),
334
+ gateDocCoherence(repoRoot()),
221
335
  ];
222
336
  }
223
337
 
@@ -245,5 +359,5 @@ function main() {
245
359
  if (require.main === module) {
246
360
  main();
247
361
  } else {
248
- module.exports = { runGates, gateDailyLog, gateFlushLog, gateExportFresh, gateExportCoverage, gateNoDeprecated, qualiaHome, vaultRoot };
362
+ module.exports = { runGates, gateDailyLog, gateFlushLog, gateExportFresh, gateExportCoverage, gateNoDeprecated, gateDocCoherence, repoRoot, qualiaHome, vaultRoot };
249
363
  }
package/bin/state.js CHANGED
@@ -5,6 +5,9 @@
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const stateLedger = require("./state-ledger.js");
8
+ // analyze-gate.js is loaded lazily inside checkScopeDrift() (require-in-function)
9
+ // so a missing/broken gate file can never crash state.js at load time — the
10
+ // scope-drift gate fails SOFT (advisory), never fail-closed on its own error.
8
11
 
9
12
  const PLANNING = ".planning";
10
13
  const STATE_FILE = path.join(PLANNING, "STATE.md");
@@ -529,7 +532,7 @@ function regenerateViews(activity) {
529
532
  }));
530
533
  t.releases = listReleases().map((r) => ({ id: r.id, type: r.type, status: r.status, increments: r.increments }));
531
534
  t.next_command = active
532
- ? nextCommand(active.status, active.num, increments.length, active.verification)
535
+ ? nextCommand(active.status, active.num, increments.length, active.verification, t.lifecycle)
533
536
  : "/qualia";
534
537
  t.last_updated = new Date().toISOString();
535
538
  writeTracking(t);
@@ -560,10 +563,10 @@ function cmdCheckIncrement(opts) {
560
563
  const totalPhases = increments.length;
561
564
  const allDone = increments.every(isDone);
562
565
  const next_command = mine
563
- ? nextCommand(mine.status, mine.num, totalPhases, mine.verification)
566
+ ? nextCommand(mine.status, mine.num, totalPhases, mine.verification, t.lifecycle)
564
567
  : next_unclaimed
565
- ? nextCommand(next_unclaimed.status, next_unclaimed.num, totalPhases, next_unclaimed.verification)
566
- : (allDone ? "/qualia-polish" : "/qualia");
568
+ ? nextCommand(next_unclaimed.status, next_unclaimed.num, totalPhases, next_unclaimed.verification, t.lifecycle)
569
+ : (allDone ? (t.lifecycle === "operate" ? "/qualia-update" : "/qualia-polish") : "/qualia");
567
570
 
568
571
  output({
569
572
  ok: true,
@@ -642,9 +645,10 @@ function cmdTransitionIncrement(opts, target) {
642
645
 
643
646
  const phase = inc.num;
644
647
  const prevStatus = inc.status;
645
- const check = checkPreconditions({ phase, status: inc.status, total_phases: increments.length }, target, { ...opts, phase });
648
+ const t0 = ensureLifetime(readTracking() || {});
649
+ const check = checkPreconditions({ phase, status: inc.status, total_phases: increments.length }, target, { ...opts, phase, lifecycle: t0.lifecycle, profile: resolveProfile(null, t0) });
646
650
  if (!check.ok) {
647
- const forceable = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT", "INVALID_PLAN"];
651
+ const forceable = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT", "INVALID_PLAN", "SCOPE_DRIFT"];
648
652
  if (opts.force && forceable.includes(check.error)) {
649
653
  console.error(`WARNING: Forcing transition despite: ${check.message}`);
650
654
  } else {
@@ -698,7 +702,7 @@ function cmdTransitionIncrement(opts, target) {
698
702
  previous_status: prevStatus,
699
703
  verification: inc.verification,
700
704
  gap_cycles: inc.gap_cycles || 0,
701
- next_command: nextCommand(inc.status, inc.num, increments.length, inc.verification),
705
+ next_command: nextCommand(inc.status, inc.num, increments.length, inc.verification, t.lifecycle),
702
706
  };
703
707
  if (ledger.ok) {
704
708
  result.ledger_event_id = ledger.event_id;
@@ -1040,6 +1044,14 @@ function checkPreconditions(current, target, opts) {
1040
1044
  }
1041
1045
  }
1042
1046
 
1047
+ if (target === "built") {
1048
+ // Scope-drift gate (ITEM #5): the planned→built seam is where /qualia-build
1049
+ // used to ASK the agent to run analyze-gate. Now it's enforced. strict +
1050
+ // HIGH finding refuses; standard is advisory; the gate's own failure is soft.
1051
+ const driftCheck = checkScopeDrift(phase, opts.profile);
1052
+ if (!driftCheck.ok) return driftCheck;
1053
+ }
1054
+
1043
1055
  if (target === "verified") {
1044
1056
  const vFile = path.join(PLANNING, `phase-${phase}-verification.md`);
1045
1057
  if (!fs.existsSync(vFile))
@@ -1119,6 +1131,78 @@ function checkMachineEvidence(phase) {
1119
1131
  return { ok: true };
1120
1132
  }
1121
1133
 
1134
+ // Scope-drift gate (ITEM #5): enforce analyze-gate.js at the planned→built seam
1135
+ // instead of leaving it to the qualia-build SKILL prose ("please run analyze-gate").
1136
+ // "A rule worth enforcing is worth a hook" — so the highest-value catch (a plan
1137
+ // silently dropping a scope acceptance criterion) is now deterministic state.
1138
+ //
1139
+ // Fail-SOFT discipline: this gate must never crash a build on its OWN error. A
1140
+ // missing contract, missing analyze-gate.js, a thrown analyze(), or a parse
1141
+ // failure all return { ok: true } (advisory, build proceeds). It fail-CLOSES
1142
+ // only on a genuine finding: a strict-profile project with a HIGH analyze
1143
+ // finding (an under-covered scope AC or scope-reduction language). In
1144
+ // `standard` profile every finding stays advisory, mirroring the SKILL contract.
1145
+ function checkScopeDrift(phase, profile) {
1146
+ const contractFile = path.join(PLANNING, `phase-${phase}-contract.json`);
1147
+ // No contract → nothing to diff against. (planned→built already required a
1148
+ // contract at the `planned` gate, but a forced/legacy build may lack one.)
1149
+ if (!fs.existsSync(contractFile)) return { ok: true };
1150
+
1151
+ let analyzeGate;
1152
+ try {
1153
+ analyzeGate = require("./analyze-gate.js");
1154
+ } catch (e) {
1155
+ try { _trace("scope-drift", "skip", { phase, reason: "gate-unavailable", error: e.message }); } catch {}
1156
+ return { ok: true };
1157
+ }
1158
+
1159
+ // Reuse plan-contract's reader so a malformed contract is a soft skip, not a throw.
1160
+ let result;
1161
+ try {
1162
+ const pc = require("./plan-contract.js");
1163
+ const loaded = pc.readContractFile(contractFile);
1164
+ if (!loaded.ok) {
1165
+ try { _trace("scope-drift", "skip", { phase, reason: "contract-unreadable", error: loaded.error }); } catch {}
1166
+ return { ok: true };
1167
+ }
1168
+ const scopePath = path.join(PLANNING, `phase-${phase}-context.md`);
1169
+ const contextPath = path.join(PLANNING, "CONTEXT.md");
1170
+ const readMaybe = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return null; } };
1171
+ result = analyzeGate.analyze({
1172
+ contract: loaded.contract,
1173
+ scopeMd: readMaybe(scopePath),
1174
+ contextMd: readMaybe(contextPath),
1175
+ });
1176
+ } catch (e) {
1177
+ // The gate's own failure must not block the build — fail soft.
1178
+ try { _trace("scope-drift", "error", { phase, error: e.message }); } catch {}
1179
+ return { ok: true };
1180
+ }
1181
+
1182
+ const highs = (result.findings || []).filter((f) => f.severity === "HIGH");
1183
+ if (highs.length === 0) {
1184
+ try { _trace("scope-drift", "allow", { phase, findings: (result.findings || []).length }); } catch {}
1185
+ return { ok: true };
1186
+ }
1187
+
1188
+ // standard profile: a senior may proceed past findings (advisory). The build
1189
+ // SKILL logs the waiver; state.js only records the trace and lets it through.
1190
+ if (profile === "standard") {
1191
+ try { _trace("scope-drift", "advisory", { phase, profile, high: highs.length }); } catch {}
1192
+ return { ok: true };
1193
+ }
1194
+
1195
+ // strict profile + HIGH finding → REFUSE the advance.
1196
+ const dropped = highs.map((f) => f.message).join("; ");
1197
+ try { _trace("scope-drift", "block", { phase, profile, high: highs.length, findings: highs.map((f) => f.type) }); } catch {}
1198
+ return fail(
1199
+ "SCOPE_DRIFT",
1200
+ `Phase ${phase} plan drifts from scope (${highs.length} HIGH): ${dropped}. ` +
1201
+ `Route to /qualia-plan ${phase} --gaps (plan dropped a requirement) or /qualia-scope ${phase} (scope is wrong). ` +
1202
+ `Override (standard profile / senior waiver) with --force.`
1203
+ );
1204
+ }
1205
+
1122
1206
  function recordLedgerEvent(meta) {
1123
1207
  try {
1124
1208
  return stateLedger.append(process.cwd(), {
@@ -1166,7 +1250,10 @@ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
1166
1250
  case "done":
1167
1251
  return operate ? "/qualia-update" : "Done.";
1168
1252
  default:
1169
- return `/qualia`;
1253
+ // UNRECOGNIZED_STATUS — an unknown status must never recommend /qualia
1254
+ // itself (that's a self-recommending loop: /qualia reads state, state
1255
+ // points back at /qualia). Emit a diagnostic the caller can surface.
1256
+ return `UNRECOGNIZED_STATUS '${status}' — run /qualia-doctor`;
1170
1257
  }
1171
1258
  }
1172
1259
 
@@ -1517,15 +1604,16 @@ function cmdTransition(opts) {
1517
1604
 
1518
1605
  const phase = parseInt(opts.phase) || s.phase;
1519
1606
 
1520
- // Precondition check (lifecycle threaded so the handoff gate can relax in operate)
1521
- const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase, lifecycle: t.lifecycle });
1607
+ // Precondition check (lifecycle threaded so the handoff gate can relax in
1608
+ // operate; profile threaded so the scope-drift gate knows strict vs standard)
1609
+ const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase, lifecycle: t.lifecycle, profile: resolveProfile(s, t) });
1522
1610
  if (!check.ok) {
1523
1611
  // --force bypasses status-ordering and plan-content errors. The use case
1524
1612
  // is retroactive bookkeeping: a phase was built without /qualia-plan and
1525
1613
  // the user is catching STATE.md up to reality. --force never bypasses
1526
1614
  // MISSING_FILE or MISSING_ARG — those would leave the state machine
1527
1615
  // pointing at nothing.
1528
- const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT", "INVALID_PLAN"];
1616
+ const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT", "INVALID_PLAN", "SCOPE_DRIFT"];
1529
1617
  if (opts.force && forceableErrors.includes(check.error)) {
1530
1618
  console.error(`WARNING: Forcing transition despite: ${check.message}`);
1531
1619
  } else {
@@ -2102,7 +2190,7 @@ function cmdMigrate(opts) {
2102
2190
  increment_ids: ids,
2103
2191
  cursor: ids[(Number(t.phase || s.phase) || s.phase) - 1] || ids[0],
2104
2192
  note: "STATE.md + tracking.json are now gitignored generated views. If they were previously committed, run `git rm --cached .planning/STATE.md .planning/tracking.json` to stop tracking them. Revert anytime with `state.js migrate --revert`.",
2105
- next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification),
2193
+ next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification, t.lifecycle),
2106
2194
  });
2107
2195
  }
2108
2196
 
@@ -2158,7 +2246,8 @@ function cmdClaim(opts) {
2158
2246
  writeCursor({ current_increment: id });
2159
2247
  regenerateViews(`Claimed ${id} (${me})`);
2160
2248
  recordLedgerEvent({ action: "claim", phase_before: inc.num, phase_after: inc.num, status_before: inc.status, status_after: inc.status });
2161
- output({ ok: true, action: "claim", id, claimed_by: me, branch: inc.branch, status: inc.status, next_command: nextCommand(inc.status, inc.num, listIncrements().length, inc.verification) });
2249
+ const lifecycle = (readTracking() || {}).lifecycle;
2250
+ output({ ok: true, action: "claim", id, claimed_by: me, branch: inc.branch, status: inc.status, next_command: nextCommand(inc.status, inc.num, listIncrements().length, inc.verification, lifecycle) });
2162
2251
  }
2163
2252
 
2164
2253
  // ─── A5: release (ship) an increment + clear its claim ──