@mindrian_os/install 1.13.0-beta.12 → 1.13.0-beta.14

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 (123) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +57 -10
  3. package/README.md +74 -572
  4. package/commands/act.md +1 -0
  5. package/commands/admin.md +1 -0
  6. package/commands/analyze-needs.md +1 -0
  7. package/commands/analyze-systems.md +1 -0
  8. package/commands/analyze-timing.md +1 -0
  9. package/commands/auto-explore.md +2 -0
  10. package/commands/beautiful-question.md +1 -0
  11. package/commands/brain-derive.md +1 -0
  12. package/commands/build-knowledge.md +1 -0
  13. package/commands/build-thesis.md +1 -0
  14. package/commands/causal.md +1 -0
  15. package/commands/challenge-assumptions.md +1 -0
  16. package/commands/compare-ventures.md +1 -0
  17. package/commands/dashboard.md +1 -0
  18. package/commands/deep-grade.md +1 -0
  19. package/commands/diagnose.md +1 -0
  20. package/commands/diagnostics.md +1 -0
  21. package/commands/doctor.md +2 -1
  22. package/commands/dominant-designs.md +1 -0
  23. package/commands/explain-decision.md +1 -0
  24. package/commands/explore-domains.md +1 -0
  25. package/commands/explore-futures.md +1 -0
  26. package/commands/explore-trends.md +1 -0
  27. package/commands/export.md +1 -0
  28. package/commands/feynman-timeline-refresh.md +78 -0
  29. package/commands/file-meeting.md +1 -0
  30. package/commands/find-analogies.md +1 -0
  31. package/commands/find-bottlenecks.md +1 -0
  32. package/commands/find-connections.md +1 -0
  33. package/commands/funding.md +1 -0
  34. package/commands/grade.md +1 -0
  35. package/commands/graph.md +1 -0
  36. package/commands/hat-briefing.md +1 -0
  37. package/commands/heal.md +1 -0
  38. package/commands/help.md +1 -0
  39. package/commands/hmi-status.md +1 -0
  40. package/commands/jtbd.md +1 -0
  41. package/commands/leadership.md +1 -0
  42. package/commands/lean-canvas.md +1 -0
  43. package/commands/macro-trends.md +1 -0
  44. package/commands/map-unknowns.md +1 -0
  45. package/commands/memory.md +1 -0
  46. package/commands/models.md +1 -0
  47. package/commands/mos-reason.md +1 -0
  48. package/commands/mullins.md +1 -0
  49. package/commands/new-project.md +1 -0
  50. package/commands/onboard.md +1 -0
  51. package/commands/operator.md +2 -1
  52. package/commands/opportunities.md +1 -0
  53. package/commands/organize.md +1 -0
  54. package/commands/persona.md +1 -0
  55. package/commands/pipeline.md +1 -0
  56. package/commands/present.md +1 -0
  57. package/commands/publish.md +1 -0
  58. package/commands/query.md +1 -0
  59. package/commands/radar.md +1 -0
  60. package/commands/reanalyze.md +1 -0
  61. package/commands/research.md +1 -0
  62. package/commands/room.md +1 -0
  63. package/commands/rooms.md +1 -0
  64. package/commands/root-cause.md +1 -0
  65. package/commands/rs-experts.md +1 -0
  66. package/commands/rs-explain.md +1 -0
  67. package/commands/rs-fetch.md +1 -0
  68. package/commands/rs-thesis.md +1 -0
  69. package/commands/scenario-plan.md +1 -0
  70. package/commands/scheduled-tasks.md +1 -0
  71. package/commands/score-innovation.md +1 -0
  72. package/commands/scout.md +1 -0
  73. package/commands/setup.md +8 -3
  74. package/commands/snapshot.md +1 -0
  75. package/commands/speakers.md +1 -0
  76. package/commands/splash.md +1 -0
  77. package/commands/status.md +1 -0
  78. package/commands/structure-argument.md +1 -0
  79. package/commands/suggest-next.md +1 -0
  80. package/commands/systems-thinking.md +1 -0
  81. package/commands/think-hats.md +1 -0
  82. package/commands/update.md +1 -0
  83. package/commands/user-needs.md +1 -0
  84. package/commands/validate.md +1 -0
  85. package/commands/value-proposition.md +1 -0
  86. package/commands/vault.md +1 -0
  87. package/commands/visualize.md +1 -0
  88. package/commands/whitespace.md +1 -0
  89. package/commands/wiki.md +1 -0
  90. package/lib/brain/framework-chain-slice.cjs +193 -0
  91. package/lib/core/active-plugin-root.cjs +71 -6
  92. package/lib/core/brain-client.cjs +451 -36
  93. package/lib/core/cache-prune.cjs +208 -0
  94. package/lib/core/feynman/ROOM.md +25 -0
  95. package/lib/core/feynman/timeline-renderer.cjs +197 -0
  96. package/lib/core/feynman/timeline-runner.cjs +281 -0
  97. package/lib/core/navigation/edges.cjs +86 -0
  98. package/lib/core/navigation/insights.cjs +37 -0
  99. package/lib/core/navigation/memory-events.cjs +56 -1
  100. package/lib/core/navigation/neighborhood.cjs +5 -4
  101. package/lib/core/navigation/packet.cjs +176 -10
  102. package/lib/core/navigation/projections.cjs +201 -0
  103. package/lib/core/navigation.cjs +31 -0
  104. package/lib/core/resolve-brain-key.cjs +201 -0
  105. package/lib/mcp/larry-server-instructions.md +1 -1
  106. package/lib/memory/brain-cypher-chain-slice.test.cjs +368 -0
  107. package/lib/memory/f-selector-ranker.test.cjs +593 -0
  108. package/lib/memory/navigation-projections.test.cjs +241 -0
  109. package/lib/memory/navigation-write-edge.test.cjs +206 -0
  110. package/lib/memory/packet-chain-hint.test.cjs +407 -0
  111. package/lib/memory/packet-schema-validation.test.cjs +317 -0
  112. package/lib/memory/per-command-jtbd-derivation.test.cjs +130 -0
  113. package/lib/memory/per-command-teaching.test.cjs +110 -0
  114. package/lib/memory/run-feynman-tests.cjs +121 -0
  115. package/lib/memory/security-trifecta.test.cjs +23 -6
  116. package/lib/memory/selector-decisions.test.cjs +417 -0
  117. package/lib/memory/selector-miss.test.cjs +290 -0
  118. package/lib/workflow/f-selector-ranker.cjs +420 -0
  119. package/lib/workflow/selector-decisions.cjs +368 -0
  120. package/package.json +4 -1
  121. package/references/design/email-template-standard.md +1 -1
  122. package/references/user-research/2026-04-05-leah-lawrence-session.md +3 -3
  123. package/skills/brain-connector/SKILL.md +9 -3
@@ -1174,6 +1174,41 @@ const TEST_FILES = [
1174
1174
  // (migration used to crash openRoomDb for any room.db carrying the Phase-89
1175
1175
  // rs_discoveries view: "error in view rs_discoveries: no such table: main.nodes").
1176
1176
  path.join(REPO_ROOT, 'tests', 'test-navigation-migration-views.cjs'),
1177
+ // Phase 109 SQL Context-Memory Navigation Spine: the 15 test suites that the
1178
+ // Plan 109-00 Task 4 registration originally specified but that a later refactor
1179
+ // dropped from this array before main HEAD (the originals ran via direct
1180
+ // `node tests/test-*.cjs` and `bash tests/run-all.sh` throughout the phase).
1181
+ // Re-registered by Plan 109-12 (bookkeeping reconciliation). Mapping:
1182
+ // test-navigation-acceptance.cjs -> 109-10 (NAV-109-09 load-bearing acceptance gate; zero non-SQLite reads)
1183
+ // test-navigation-focus.cjs -> 109-02 (NAV-109-01 session_focus + auto-focus cascade)
1184
+ // test-navigation-neighborhood.cjs -> 109-04 (NAV-109-02 recursive-CTE ranking correctness)
1185
+ // test-navigation-perf-10k.cjs -> 109-04 (NAV-109-02 perf: cold p95 <200ms / warm <50ms)
1186
+ // test-navigation-memory-events.cjs -> 109-03 (NAV-109-03 closed-15 event enum + findRecentChanges)
1187
+ // test-navigation-insights.cjs -> 109-05 (NAV-109-04 7 insight primitives + templated explanations)
1188
+ // test-navigation-chokepoint-hook.cjs -> 109-06 (NAV-109-05 no direct room-db.cjs imports outside allow-list)
1189
+ // test-navigation-packet-builder.cjs -> 109-07 (NAV-109-06 buildBrainPacket shape per D-06)
1190
+ // test-navigation-packet-part8-leak.cjs -> 109-07 (NAV-109-06 Part 8 JSON.stringify leak tripwires)
1191
+ // test-brain-ingestion-part-9-invariant.cjs -> 109-08 (NAV-109-07 storeBrainSuggestions proposed-only + invariant SQL = 0)
1192
+ // test-room-home-vs-brain-derivation-regression.cjs -> 109-09 (NAV-109-08 getRoomHomeView + Phase 90 deriveSection regression fence)
1193
+ // test-canon-part-9-ratification.cjs -> 109-11 (NAV-109-09 Canon Part 9 structural assertions)
1194
+ // test-navigation-migration-idempotent.cjs -> 109-01 (migration twice = no-op; 12-column nodes schema)
1195
+ // test-navigation-migration-backfill.cjs -> 109-01 (properties JSON backfill + status_aliases mapping)
1196
+ // test-navigation-migration-coexistence.cjs -> 109-01 (navigation API + assumptions.validity coexist mid-migration)
1197
+ path.join(REPO_ROOT, 'tests', 'test-navigation-acceptance.cjs'),
1198
+ path.join(REPO_ROOT, 'tests', 'test-navigation-focus.cjs'),
1199
+ path.join(REPO_ROOT, 'tests', 'test-navigation-neighborhood.cjs'),
1200
+ path.join(REPO_ROOT, 'tests', 'test-navigation-perf-10k.cjs'),
1201
+ path.join(REPO_ROOT, 'tests', 'test-navigation-memory-events.cjs'),
1202
+ path.join(REPO_ROOT, 'tests', 'test-navigation-insights.cjs'),
1203
+ path.join(REPO_ROOT, 'tests', 'test-navigation-chokepoint-hook.cjs'),
1204
+ path.join(REPO_ROOT, 'tests', 'test-navigation-packet-builder.cjs'),
1205
+ path.join(REPO_ROOT, 'tests', 'test-navigation-packet-part8-leak.cjs'),
1206
+ path.join(REPO_ROOT, 'tests', 'test-brain-ingestion-part-9-invariant.cjs'),
1207
+ path.join(REPO_ROOT, 'tests', 'test-room-home-vs-brain-derivation-regression.cjs'),
1208
+ path.join(REPO_ROOT, 'tests', 'test-canon-part-9-ratification.cjs'),
1209
+ path.join(REPO_ROOT, 'tests', 'test-navigation-migration-idempotent.cjs'),
1210
+ path.join(REPO_ROOT, 'tests', 'test-navigation-migration-backfill.cjs'),
1211
+ path.join(REPO_ROOT, 'tests', 'test-navigation-migration-coexistence.cjs'),
1177
1212
  // Phase 89-07 Wave 0 (graph-native HARD RULE; ReverseSalientAgent dual-surface).
1178
1213
  path.join(REPO_ROOT, 'tests', 'test-reverse-salient-agent.cjs'),
1179
1214
  path.join(REPO_ROOT, 'tests', 'test-reverse-salient-cascade-emit.cjs'),
@@ -1225,6 +1260,92 @@ const TEST_FILES = [
1225
1260
  // -> validateChainAutonomy stop-point) + the Canon Part 8 zero-Brain-mutation
1226
1261
  // grep sweep.
1227
1262
  path.join(REPO_ROOT, 'lib', 'memory', 'workflow-layer-e2e.test.cjs'),
1263
+ // Phase 110-00: Brain Context Packet Contract Wave 0 substrate (4 stubs filled by Plans 110-01 / 110-04 / 110-05).
1264
+ // test-brain-packet-schema-check.cjs -> 110-01 (PACKET-110-01 + -02: the --check schema tripwire)
1265
+ // test-brain-packet-validation-per-job.cjs -> 110-05 (PACKET-110-03 + -04 + -07 + -08: 12-job in/out + privacy + dual-path)
1266
+ // test-brain-packet-part8-invariant-per-job.cjs -> 110-05 (PACKET-110-06 round-trip + D-11(d) adversarial sweep)
1267
+ // test-brain-packet-precommit-hook.cjs -> 110-04 (PACKET-110-05 D-08 layer-2 hook)
1268
+ path.join(REPO_ROOT, 'tests', 'test-brain-packet-schema-check.cjs'),
1269
+ path.join(REPO_ROOT, 'tests', 'test-brain-packet-validation-per-job.cjs'),
1270
+ path.join(REPO_ROOT, 'tests', 'test-brain-packet-part8-invariant-per-job.cjs'),
1271
+ path.join(REPO_ROOT, 'tests', 'test-brain-packet-precommit-hook.cjs'),
1272
+ // Phase 123 (install-lifecycle-harness) block.
1273
+ // Plan 123-01: release.sh semver bump algebra + two-commit form + dirty-repo guard + Step 9.5 rename.
1274
+ // Tests A-E (semver assertions) GREEN immediately; Tests F/G (release.sh
1275
+ // structural) RED until Plan 123-01 Task 2 rewrites release.sh -- intended RED->GREEN.
1276
+ // Plan 123-02: install-state record + data/deployment-surfaces.json manifest +
1277
+ // active-plugin-root.cjs topology classification. Tests 1+4+6 (topology /
1278
+ // Canon Part 8 / early-write ordering) GREEN after Task 1; Tests 2+3
1279
+ // (record write hermetic / idempotent re-run) GREEN after Task 2;
1280
+ // Test 5 (manifest schema) GREEN after Task 3.
1281
+ // Plan 123-03: doctor classes I (install-state + topology + 6-way version-
1282
+ // of-record consistency) + J (deployment-surface manifest reconciliation)
1283
+ // + aggressive --fix (legacy migration backup-verify-remove; never
1284
+ // touches a dev-clone) + Bug-7 fix (marketplace-cache topology is
1285
+ // HEALTHY, not drift). Tests RED until Task 2 lands class I + class J
1286
+ // in scripts/doctor.cjs -- intended RED->GREEN.
1287
+ // Plan 123-04: doctor --acceptance (release-gate-as-a-command) -- 5-point
1288
+ // pre-tag checklist + 7-point full checklist + --light-npx opt-in; wired
1289
+ // into release.sh as hard aborts (Step 6.6 pre-tag, Step 9.6 post-publish);
1290
+ // scripts/release-beta-smoke.sh retired. Tests RED until Task 2 lands
1291
+ // --acceptance in scripts/doctor.cjs AND Task 3 wires release.sh + deletes
1292
+ // release-beta-smoke.sh -- intended RED->GREEN.
1293
+ // Plan 123-05: cache-prune helper (HARNESS-123-13) + doc/test sweep
1294
+ // (HARNESS-123-14). 6 hermetic scenarios for pruneMarketplaceCache
1295
+ // (active + N most-recent kept; corrupt installed_plugins.json -> skip;
1296
+ // dryRun -> no mutation; belt+suspenders active-dir protection; Canon
1297
+ // Part 8 grep clean). Tests GREEN after Task 1 lands
1298
+ // lib/core/cache-prune.cjs.
1299
+ // Plan 123-07: resolve-brain-key.cjs (HARNESS-123-15) + brain-client.cjs
1300
+ // getApiKey() delegation (HARNESS-123-16). 9 hermetic scenarios cover
1301
+ // order (env -> ~/.mindrian.env -> CWD .env -> not-found), SEC-02
1302
+ // POSIX 0o077 reject, Canon Part 8 zero-network grep, the brain-client
1303
+ // delegation spy, brain-client preconditions, and the FLAG-3
1304
+ // env-aware-home structural assertion. rbk.1-6 + rbk.9 GREEN after
1305
+ // Task 1; rbk.7 + rbk.8 GREEN after Task 2 (brain-client rewire).
1306
+ path.join(REPO_ROOT, 'tests', 'test-release-bump-algebra.cjs'),
1307
+ path.join(REPO_ROOT, 'tests', 'test-install-state-record.cjs'),
1308
+ path.join(REPO_ROOT, 'tests', 'test-doctor-class-i.cjs'),
1309
+ path.join(REPO_ROOT, 'tests', 'test-doctor-class-j.cjs'),
1310
+ path.join(REPO_ROOT, 'tests', 'test-doctor-acceptance.cjs'),
1311
+ path.join(REPO_ROOT, 'tests', 'test-cache-prune.cjs'),
1312
+ path.join(REPO_ROOT, 'tests', 'test-resolve-brain-key.cjs'),
1313
+ // Phase 124-00: FEYNMAN.md Temporal Awareness Wave 0 substrate (4 stubs filled by Plans 124-01 / 124-02 / 124-04).
1314
+ // test-feynman-timeline-renderer.cjs -> 124-01 (TEMPORAL-124-02 + -04 + -05 + -07: renderer unit + D-05 template + thresholds + section scoping)
1315
+ // test-feynman-timeline-empty-state.cjs -> 124-01 (TEMPORAL-124-04: empty-state placeholder)
1316
+ // test-feynman-timeline-runner.cjs -> 124-02 (TEMPORAL-124-01 + -03 + -08 + -09: sentinel-bounded merge + body byte-identical + watermark + idempotent + memory_event + EVENT_TYPES +2)
1317
+ // test-feynman-timeline-canon-part-9-invariant.cjs -> 124-04 (TEMPORAL-124-10: forbidden-substring sweep + fs-instrument allow-list)
1318
+ path.join(REPO_ROOT, 'tests', 'test-feynman-timeline-renderer.cjs'),
1319
+ path.join(REPO_ROOT, 'tests', 'test-feynman-timeline-empty-state.cjs'),
1320
+ path.join(REPO_ROOT, 'tests', 'test-feynman-timeline-runner.cjs'),
1321
+ path.join(REPO_ROOT, 'tests', 'test-feynman-timeline-canon-part-9-invariant.cjs'),
1322
+ // Phase 125 F-Selector Ranker (8 plans: Plan 00 writeEdge through Plan 07 miss).
1323
+ // navigation-write-edge.test.cjs -> Plan 125-00 (writeEdge primitive)
1324
+ // navigation-projections.test.cjs -> Plan 125-01 (projection helpers)
1325
+ // brain-cypher-chain-slice.test.cjs -> Plan 125-02 (Brain Cypher slice)
1326
+ // packet-chain-hint.test.cjs -> Plan 125-03 (packet builder ext)
1327
+ // packet-schema-validation.test.cjs -> Plan 125-04 (schema superset)
1328
+ // f-selector-ranker.test.cjs -> Plan 125-05 + 125-07 D8 label tests (34 tests)
1329
+ // selector-decisions.test.cjs -> Plan 125-06 (D7 decisions + decay; 17 tests)
1330
+ // selector-miss.test.cjs -> Plan 125-07 (D8 miss capture; 10 tests)
1331
+ // Aggregator: tests/run-all-125.sh.
1332
+ path.join(REPO_ROOT, 'lib', 'memory', 'navigation-write-edge.test.cjs'),
1333
+ path.join(REPO_ROOT, 'lib', 'memory', 'navigation-projections.test.cjs'),
1334
+ path.join(REPO_ROOT, 'lib', 'memory', 'brain-cypher-chain-slice.test.cjs'),
1335
+ path.join(REPO_ROOT, 'lib', 'memory', 'packet-chain-hint.test.cjs'),
1336
+ path.join(REPO_ROOT, 'lib', 'memory', 'packet-schema-validation.test.cjs'),
1337
+ path.join(REPO_ROOT, 'lib', 'memory', 'f-selector-ranker.test.cjs'),
1338
+ path.join(REPO_ROOT, 'lib', 'memory', 'selector-decisions.test.cjs'),
1339
+ path.join(REPO_ROOT, 'lib', 'memory', 'selector-miss.test.cjs'),
1340
+ // Phase 104.1 (per-command-teaching-content) block. RED-by-design until
1341
+ // Plan 02 lands the 86 teaching strings; the jtbd-derivation suite is
1342
+ // GREEN immediately after Plan 01 Task 1.
1343
+ // per-command-teaching.test.cjs -> 104.1-01 Task 2 (RED today; GREEN after Plan 02)
1344
+ // per-command-jtbd-derivation.test.cjs -> 104.1-01 Task 3 (GREEN immediately)
1345
+ // Aggregator: tests/run-all-104.1.sh. Documented at
1346
+ // .planning/phases/104.1-per-command-teaching-content/104.1-01-PLAN.md.
1347
+ path.join(REPO_ROOT, 'lib', 'memory', 'per-command-teaching.test.cjs'),
1348
+ path.join(REPO_ROOT, 'lib', 'memory', 'per-command-jtbd-derivation.test.cjs'),
1228
1349
  ];
1229
1350
 
1230
1351
  // Exit code convention for child tests:
@@ -290,14 +290,31 @@ test('brain-client.cjs has zero legacy .replace(/"/g, ...) injection patterns',
290
290
  );
291
291
  });
292
292
 
293
- test('brain-client.cjs guards .env reads with checkFilePermissions', () => {
293
+ test('SEC-02 .env gating lives in resolve-brain-key.cjs (Phase 123 Plan-07)', () => {
294
+ // Phase 123 Plan-07: getApiKey() now delegates to lib/core/resolve-brain-key.cjs,
295
+ // which owns the SEC-02 POSIX 0o077 permission check for both ~/.mindrian.env
296
+ // and CWD .env. The brain-client.cjs::checkFilePermissions() helper remains
297
+ // exported via _test for backward-compat (and unit-test surface above), but
298
+ // the live gating call sites moved one layer down. The invariant is:
299
+ // 1. brain-client.cjs requires resolve-brain-key.cjs (delegation lives).
300
+ // 2. resolve-brain-key.cjs contains the 0o077 mask check (SEC-02 lives).
301
+ // Both must hold; either failure is a regression that re-introduces the
302
+ // pre-Plan-07 multiple-resolver disease.
294
303
  const brainPath = path.resolve(__dirname, '..', 'core', 'brain-client.cjs');
295
- const src = fs.readFileSync(brainPath, 'utf8');
296
- const matches = src.match(/checkFilePermissions\s*\(/g) || [];
297
- // 1 definition + 2 getApiKey call sites (cwd .env + ~/.mindrian.env) = 3.
304
+ const resolverPath = path.resolve(__dirname, '..', 'core', 'resolve-brain-key.cjs');
305
+ const brainSrc = fs.readFileSync(brainPath, 'utf8');
306
+ const resolverSrc = fs.readFileSync(resolverPath, 'utf8');
298
307
  assert.ok(
299
- matches.length >= 3,
300
- `expected >= 3 checkFilePermissions occurrences, got ${matches.length}`
308
+ /require\(['"][^'"]*resolve-brain-key[^'"]*['"]\)/.test(brainSrc),
309
+ 'brain-client.cjs must require resolve-brain-key.cjs (Phase 123 Plan-07 delegation)'
310
+ );
311
+ assert.ok(
312
+ /0o077/.test(resolverSrc),
313
+ 'resolve-brain-key.cjs must contain the SEC-02 0o077 mask check'
314
+ );
315
+ assert.ok(
316
+ /process\.platform/.test(resolverSrc),
317
+ 'resolve-brain-key.cjs must short-circuit the SEC-02 check on Windows (process.platform)'
301
318
  );
302
319
  });
303
320
 
@@ -0,0 +1,417 @@
1
+ /*
2
+ * Copyright (c) 2026 Mindrian. BSL 1.1.
3
+ *
4
+ * Phase 125-06 -- lib/workflow/selector-decisions.cjs test suite.
5
+ *
6
+ * Covers 16 acceptance behaviors from 125-06-PLAN.md <behavior> block:
7
+ * Tests 1-7 Decision writes (D7): recordSelectorDecision
8
+ * Tests 8-14 Decay weight (D7): applyDecayWeight + shouldExclude
9
+ * Tests 15-16 Integration with Plan 05 ranker (opts._applyDecayWeight)
10
+ *
11
+ * Three-surface compatibility: pure CJS + node:test. Uses fs.mkdtempSync +
12
+ * openRoomDb fixture pattern from Plan 00's navigation-write-edge.test.cjs.
13
+ * All writes route through the navigation.cjs chokepoint; tests verify both
14
+ * the memory_event row AND the typed cascade edge row land in room.db.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { test } = require('node:test');
20
+ const { ok, equal, deepStrictEqual } = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
26
+ const SELECTOR_DECISIONS_PATH = path.join(REPO_ROOT, 'lib', 'workflow', 'selector-decisions.cjs');
27
+ const RANKER_PATH = path.join(REPO_ROOT, 'lib', 'workflow', 'f-selector-ranker.cjs');
28
+ const { openRoomDb } = require(path.join(REPO_ROOT, 'lib', 'core', 'room-db.cjs'));
29
+ const selectorDecisions = require(SELECTOR_DECISIONS_PATH);
30
+ const ranker = require(RANKER_PATH);
31
+
32
+ function freshDb() {
33
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p125-06-selector-decisions-'));
34
+ const db = openRoomDb(dir);
35
+ return { dir, db };
36
+ }
37
+
38
+ // FK fixture (mirrors Plan 00's seedAnchorNode pattern in
39
+ // lib/memory/navigation-write-edge.test.cjs). The shipped edges table has
40
+ // FK (source, target) -> nodes(id), so we must seed anchor nodes for the
41
+ // command + framework before writeEdge can succeed.
42
+ function seedAnchorNode(db, id, type) {
43
+ const nowMs = Date.now();
44
+ db.prepare(
45
+ "INSERT OR IGNORE INTO nodes (id, type, properties, source_path, created_by, confidence, review_status, created_at, last_seen_at) " +
46
+ "VALUES (?, ?, '{}', 'p125-06-fixture', 'system', NULL, 'confirmed', ?, ?)"
47
+ ).run(id, type, nowMs, nowMs);
48
+ }
49
+
50
+ function seedAnchorsFor(db, command, framework) {
51
+ seedAnchorNode(db, 'cmd:' + command, 'command');
52
+ seedAnchorNode(db, 'framework:' + framework, 'framework');
53
+ }
54
+
55
+ function fetchMemoryEvent(db, decisionId) {
56
+ return db.prepare(
57
+ "SELECT id, type, properties FROM nodes WHERE id = ?"
58
+ ).get(decisionId);
59
+ }
60
+
61
+ function fetchEdge(db, source, target, type) {
62
+ return db.prepare(
63
+ "SELECT source, target, type, properties FROM edges WHERE source = ? AND target = ? AND type = ?"
64
+ ).get(source, target, type);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Tests 1-7: Decision writes (D7)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ test('Test 1: recordSelectorDecision({decision: "defer"}) writes memory_event with decision="defer"', () => {
72
+ const { db } = freshDb();
73
+ seedAnchorsFor(db, 'mos:beautiful-question', 'Beautiful Question Framework');
74
+ const r = selectorDecisions.recordSelectorDecision({
75
+ decision: 'defer',
76
+ command: 'mos:beautiful-question',
77
+ framework: 'Beautiful Question Framework',
78
+ reason: 'not now',
79
+ roomState: { db, investment_level: 0.3 },
80
+ });
81
+ ok(r.ok, 'recordSelectorDecision should return ok: true; got reason=' + (r && r.reason));
82
+ ok(typeof r.decision_id === 'string' && r.decision_id.length > 0, 'decision_id is non-empty string');
83
+ ok(typeof r.edge_id === 'string' && r.edge_id.length > 0, 'edge_id is non-empty string');
84
+ const row = fetchMemoryEvent(db, r.decision_id);
85
+ ok(row, 'memory_event row present in nodes table');
86
+ equal(row.type, 'memory_event');
87
+ const props = JSON.parse(row.properties);
88
+ equal(props.event_type, 'f_selector_decision');
89
+ equal(props.decision, 'defer');
90
+ equal(props.command, 'mos:beautiful-question');
91
+ });
92
+
93
+ test('Test 2: recordSelectorDecision with decision="reject" writes REJECTED edge', () => {
94
+ const { db } = freshDb();
95
+ seedAnchorsFor(db, 'mos:swot', 'SWOT');
96
+ const r = selectorDecisions.recordSelectorDecision({
97
+ decision: 'reject',
98
+ command: 'mos:swot',
99
+ framework: 'SWOT',
100
+ reason: 'wrong approach for our stage',
101
+ roomState: { db, investment_level: 0.5 },
102
+ });
103
+ ok(r.ok);
104
+ // memory_event has decision='reject'.
105
+ const memRow = fetchMemoryEvent(db, r.decision_id);
106
+ ok(memRow);
107
+ const memProps = JSON.parse(memRow.properties);
108
+ equal(memProps.decision, 'reject');
109
+ equal(memProps.edge_semantic, 'REJECTED');
110
+ // edge of type REJECTED is present.
111
+ const edge = fetchEdge(db, 'cmd:mos:swot', 'framework:SWOT', 'REJECTED');
112
+ ok(edge, 'REJECTED edge row should be present');
113
+ equal(edge.type, 'REJECTED');
114
+ });
115
+
116
+ test('Test 3: DEFERRED edge has expires_at ~30 days from now; REJECTED edge does NOT', () => {
117
+ const { db } = freshDb();
118
+ seedAnchorsFor(db, 'mos:c1', 'F1');
119
+ seedAnchorsFor(db, 'mos:c2', 'F2');
120
+
121
+ // Defer: expires_at present and within 5min of now+30 days.
122
+ const r1 = selectorDecisions.recordSelectorDecision({
123
+ decision: 'defer',
124
+ command: 'mos:c1',
125
+ framework: 'F1',
126
+ reason: null,
127
+ roomState: { db },
128
+ });
129
+ ok(r1.ok);
130
+ const e1 = fetchEdge(db, 'cmd:mos:c1', 'framework:F1', 'DEFERRED');
131
+ ok(e1);
132
+ const props1 = JSON.parse(e1.properties);
133
+ ok(typeof props1.expires_at === 'string' && props1.expires_at.length > 0, 'expires_at present on DEFERRED');
134
+ const expiresMs = Date.parse(props1.expires_at);
135
+ const expectedMs = Date.now() + 30 * 24 * 3600 * 1000;
136
+ const deltaMs = Math.abs(expiresMs - expectedMs);
137
+ ok(deltaMs < 5 * 60 * 1000, 'expires_at ~30 days from now (within 5 min tolerance); delta=' + deltaMs);
138
+
139
+ // Reject: expires_at absent.
140
+ const r2 = selectorDecisions.recordSelectorDecision({
141
+ decision: 'reject',
142
+ command: 'mos:c2',
143
+ framework: 'F2',
144
+ reason: null,
145
+ roomState: { db },
146
+ });
147
+ ok(r2.ok);
148
+ const e2 = fetchEdge(db, 'cmd:mos:c2', 'framework:F2', 'REJECTED');
149
+ ok(e2);
150
+ const props2 = JSON.parse(e2.properties);
151
+ equal(Object.prototype.hasOwnProperty.call(props2, 'expires_at'), false, 'REJECTED edge has no expires_at');
152
+ });
153
+
154
+ test('Test 4: edge properties include reason verbatim and decision_id', () => {
155
+ const { db } = freshDb();
156
+ seedAnchorsFor(db, 'mos:c4', 'F4');
157
+ const reasonText = 'team is split; defer until next milestone';
158
+ const r = selectorDecisions.recordSelectorDecision({
159
+ decision: 'defer',
160
+ command: 'mos:c4',
161
+ framework: 'F4',
162
+ reason: reasonText,
163
+ roomState: { db },
164
+ });
165
+ ok(r.ok);
166
+ const edge = fetchEdge(db, 'cmd:mos:c4', 'framework:F4', 'DEFERRED');
167
+ ok(edge);
168
+ const props = JSON.parse(edge.properties);
169
+ equal(props.reason, reasonText);
170
+ equal(props.decision_id, r.decision_id);
171
+ });
172
+
173
+ test('Test 5: invalid decision (e.g. "maybe") returns ok:false; no writes', () => {
174
+ const { db } = freshDb();
175
+ seedAnchorsFor(db, 'mos:c5', 'F5');
176
+ const r = selectorDecisions.recordSelectorDecision({
177
+ decision: 'maybe',
178
+ command: 'mos:c5',
179
+ framework: 'F5',
180
+ roomState: { db },
181
+ });
182
+ equal(r.ok, false);
183
+ equal(r.reason, 'invalid_decision');
184
+ // No edges of any type for this command.
185
+ const edges = db.prepare("SELECT 1 FROM edges WHERE source = ?").all('cmd:mos:c5');
186
+ equal(edges.length, 0, 'no edges should be written on invalid decision');
187
+ // No memory_event for f_selector_decision tied to this command.
188
+ const mems = db.prepare(
189
+ "SELECT 1 FROM nodes WHERE type = 'memory_event' AND json_extract(properties, '$.command') = ?"
190
+ ).all('mos:c5');
191
+ equal(mems.length, 0, 'no memory_event should be written on invalid decision');
192
+ });
193
+
194
+ test('Test 6: reason is optional -- when omitted, payload.reason is null AND edge.properties.reason is null', () => {
195
+ const { db } = freshDb();
196
+ seedAnchorsFor(db, 'mos:c6', 'F6');
197
+ const r = selectorDecisions.recordSelectorDecision({
198
+ decision: 'defer',
199
+ command: 'mos:c6',
200
+ framework: 'F6',
201
+ // reason omitted intentionally
202
+ roomState: { db },
203
+ });
204
+ ok(r.ok);
205
+ const mem = fetchMemoryEvent(db, r.decision_id);
206
+ const memProps = JSON.parse(mem.properties);
207
+ equal(memProps.reason, null, 'payload.reason is null when omitted');
208
+ const edge = fetchEdge(db, 'cmd:mos:c6', 'framework:F6', 'DEFERRED');
209
+ const edgeProps = JSON.parse(edge.properties);
210
+ equal(edgeProps.reason, null, 'edge.properties.reason is null when omitted');
211
+ });
212
+
213
+ test('Test 7: all writes route through navigation.cjs chokepoint (grep audit)', () => {
214
+ const src = fs.readFileSync(SELECTOR_DECISIONS_PATH, 'utf8');
215
+ ok(/require\(['"]\.\.\/core\/navigation\.cjs['"]\)/.test(src),
216
+ "source must require('../core/navigation.cjs')");
217
+ ok(src.indexOf('navigation.writeEdge') !== -1, 'must call navigation.writeEdge');
218
+ ok(src.indexOf('navigation.logMemoryEvent') !== -1, 'must call navigation.logMemoryEvent');
219
+ // No direct room-db.cjs require allowed.
220
+ ok(!/require\([^)]*room-db/.test(src),
221
+ 'must NOT require room-db.cjs directly (Canon Part 8 chokepoint invariant)');
222
+ // No direct internal memory-events module require.
223
+ ok(!/require\([^)]*navigation\/memory-events/.test(src),
224
+ 'must NOT require navigation/memory-events.cjs directly; route via navigation.cjs');
225
+ // No direct better-sqlite3 / sqlite3 require.
226
+ ok(!/require\(['"]better-sqlite3['"]\)/.test(src),
227
+ 'must NOT require better-sqlite3 directly');
228
+ ok(!/require\(['"]sqlite3['"]\)/.test(src),
229
+ 'must NOT require sqlite3 directly');
230
+ });
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Tests 8-14: Decay weight (D7) + shouldExclude helper
234
+ // ---------------------------------------------------------------------------
235
+
236
+ test('Test 8: applyDecayWeight at invocation 0 since decision returns 0 (factor = 0)', () => {
237
+ const roomState = { invocationsSinceDecision: { 'mos:x': 0 } };
238
+ const out = selectorDecisions.applyDecayWeight(0.8, 'mos:x', roomState);
239
+ ok(typeof out === 'number' && isFinite(out), 'returns finite number');
240
+ ok(Math.abs(out - 0) < 1e-9, 'at invocation 0, factor = 0; got ' + out);
241
+ });
242
+
243
+ test('Test 9: applyDecayWeight at invocation 5 returns ~0.632 * base (within 0.05)', () => {
244
+ // factor = 1 - exp(-5/5) = 1 - 1/e ~= 0.6321...
245
+ const roomState = { invocationsSinceDecision: { 'mos:x': 5 } };
246
+ const out = selectorDecisions.applyDecayWeight(0.8, 'mos:x', roomState);
247
+ const expected = 0.8 * (1 - Math.exp(-1));
248
+ ok(Math.abs(out - expected) < 0.05, 'invocation 5 -> ~0.5057; got ' + out);
249
+ });
250
+
251
+ test('Test 10: applyDecayWeight at invocation 10 returns factor ~0.865 (within 0.05)', () => {
252
+ // factor = 1 - exp(-10/5) = 1 - exp(-2) ~= 0.8647
253
+ const roomState = { invocationsSinceDecision: { 'mos:x': 10 } };
254
+ const out = selectorDecisions.applyDecayWeight(1.0, 'mos:x', roomState);
255
+ const expected = 1 - Math.exp(-2);
256
+ ok(Math.abs(out - expected) < 0.05, 'invocation 10 -> ~0.865; got ' + out);
257
+ });
258
+
259
+ test('Test 11: applyDecayWeight at invocation 15 returns >= 0.95 of base (effectively expired)', () => {
260
+ const roomState = { invocationsSinceDecision: { 'mos:x': 15 } };
261
+ const out = selectorDecisions.applyDecayWeight(1.0, 'mos:x', roomState);
262
+ ok(out >= 0.95, 'at invocation 15, factor >= 0.95 of base; got ' + out);
263
+ });
264
+
265
+ test('Test 12: shouldExclude returns true when decay factor < 0.1; false when >= 0.1', () => {
266
+ // factor < 0.1 means 1 - exp(-n/5) < 0.1 => exp(-n/5) > 0.9 => n/5 < ln(1/0.9) ~ 0.1054
267
+ // => n < 0.527. So at n=0 -> exclude; at n=1 -> factor ~= 0.181 -> include.
268
+ const exclude0 = selectorDecisions.shouldExclude('mos:x', { invocationsSinceDecision: { 'mos:x': 0 } });
269
+ equal(exclude0, true, 'at invocation 0 (freshly deferred), shouldExclude=true');
270
+ const include1 = selectorDecisions.shouldExclude('mos:x', { invocationsSinceDecision: { 'mos:x': 1 } });
271
+ equal(include1, false, 'at invocation 1, factor ~= 0.181, shouldExclude=false');
272
+ const include5 = selectorDecisions.shouldExclude('mos:x', { invocationsSinceDecision: { 'mos:x': 5 } });
273
+ equal(include5, false, 'at invocation 5, factor ~= 0.632, shouldExclude=false');
274
+ });
275
+
276
+ test('Test 13: applyDecayWeight is pure -- same inputs produce same output', () => {
277
+ const roomState = { invocationsSinceDecision: { 'mos:x': 3 } };
278
+ const a = selectorDecisions.applyDecayWeight(0.7, 'mos:x', roomState);
279
+ const b = selectorDecisions.applyDecayWeight(0.7, 'mos:x', roomState);
280
+ const c = selectorDecisions.applyDecayWeight(0.7, 'mos:x', roomState);
281
+ equal(a, b);
282
+ equal(b, c);
283
+ });
284
+
285
+ test('Test 14: applyDecayWeight gracefully handles roomState without decision history -- returns base_score unchanged', () => {
286
+ // No invocationsSinceDecision counter; no db; no decision recorded => no decay.
287
+ const out1 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', {});
288
+ equal(out1, 0.65, 'returns base_score when no history');
289
+ // Counter present but missing this command key -> no decay.
290
+ const out2 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', { invocationsSinceDecision: {} });
291
+ equal(out2, 0.65);
292
+ // Null/undefined roomState -> returns base_score gracefully.
293
+ const out3 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', null);
294
+ equal(out3, 0.65);
295
+ const out4 = selectorDecisions.applyDecayWeight(0.65, 'mos:never-deferred', undefined);
296
+ equal(out4, 0.65);
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Tests 15-16: Integration with Plan 05 ranker (opts._applyDecayWeight)
301
+ // ---------------------------------------------------------------------------
302
+
303
+ test('Test 15: ranker with opts._applyDecayWeight applies decay; freshly-deferred command ranks at bottom (or filtered)', () => {
304
+ // Use the ranker's fake-registry seam to inject a deterministic 2-command set.
305
+ ranker._test._resetCaches();
306
+ ranker._test._setRegistry({
307
+ commands: [
308
+ {
309
+ command: '/mos:beautiful-question',
310
+ kind: 'methodology',
311
+ frameworks: ['Beautiful Question Framework'],
312
+ serves_jtbd: ['find-problem'],
313
+ jtbd_label: 'Find root problem',
314
+ jtbd_summary: 'BQ surfaces real problems under the symptom.',
315
+ teaching: 'Widen before narrowing.',
316
+ },
317
+ {
318
+ command: '/mos:swot',
319
+ kind: 'methodology',
320
+ frameworks: ['SWOT'],
321
+ serves_jtbd: ['validate-thesis'],
322
+ jtbd_label: 'Audit S/W/O/T',
323
+ jtbd_summary: 'SWOT structures internal vs external.',
324
+ teaching: 'SWOT after JTBD.',
325
+ },
326
+ ],
327
+ });
328
+ // Mark beautiful-question as freshly deferred (invocation 0 since decision).
329
+ const roomState = {
330
+ invocationsSinceDecision: { '/mos:beautiful-question': 0 },
331
+ };
332
+ const out = ranker.rankForSelector({
333
+ roomState,
334
+ k: 3,
335
+ _applyDecayWeight: selectorDecisions.applyDecayWeight,
336
+ });
337
+ // BQ is at invocation 0 => factor 0 => score multiplied by 0 => 0. SWOT untouched.
338
+ ok(out.length >= 1, 'returns at least one ranked command');
339
+ const bq = out.find((x) => x.command === '/mos:beautiful-question');
340
+ const swot = out.find((x) => x.command === '/mos:swot');
341
+ ok(bq, 'BQ should still appear in the result (decay does not filter from ranker output)');
342
+ ok(swot, 'SWOT should appear');
343
+ ok(bq.score === 0, 'freshly-deferred BQ has score 0 after decay; got ' + bq.score);
344
+ // SWOT outranks BQ when BQ score = 0.
345
+ ok(swot.score >= bq.score, 'SWOT ranks at least as high as decayed BQ');
346
+ // Reset for downstream tests.
347
+ ranker._test._resetCaches();
348
+ });
349
+
350
+ test('Test 16: after 15 invocations since decision, decayed score ~= un-decayed score (decision effectively expired)', () => {
351
+ ranker._test._resetCaches();
352
+ ranker._test._setRegistry({
353
+ commands: [
354
+ {
355
+ command: '/mos:beautiful-question',
356
+ kind: 'methodology',
357
+ frameworks: ['Beautiful Question Framework'],
358
+ serves_jtbd: ['find-problem'],
359
+ jtbd_label: 'Find root problem',
360
+ jtbd_summary: 'BQ surfaces real problems.',
361
+ teaching: 'Widen before narrowing.',
362
+ },
363
+ ],
364
+ });
365
+ // Un-decayed baseline (no decay hook).
366
+ const baseline = ranker.rankForSelector({
367
+ roomState: {},
368
+ k: 3,
369
+ });
370
+ // After 15 invocations since decision, factor ~= 0.9502, effectively expired.
371
+ const decayed = ranker.rankForSelector({
372
+ roomState: { invocationsSinceDecision: { '/mos:beautiful-question': 15 } },
373
+ k: 3,
374
+ _applyDecayWeight: selectorDecisions.applyDecayWeight,
375
+ });
376
+ const baseBq = baseline.find((x) => x.command === '/mos:beautiful-question');
377
+ const decayBq = decayed.find((x) => x.command === '/mos:beautiful-question');
378
+ ok(baseBq);
379
+ ok(decayBq);
380
+ // Decayed score should be within ~5% of baseline (decay factor >= 0.95 at n=15).
381
+ const ratio = decayBq.score / Math.max(baseBq.score, 1e-9);
382
+ ok(ratio >= 0.94, 'decayed score is at least 94% of baseline at n=15; got ratio=' + ratio);
383
+ ranker._test._resetCaches();
384
+ });
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Bonus regression: db-backed _invocationsSinceDecision counts framework_invoked
388
+ // events newer than the most recent f_selector_decision for the command.
389
+ // ---------------------------------------------------------------------------
390
+
391
+ test('Bonus: _invocationsSinceDecision counts framework_invoked events after the last decision', () => {
392
+ const { db } = freshDb();
393
+ seedAnchorsFor(db, 'mos:bonus-decay', 'BonusFramework');
394
+ // Record a decision at t0.
395
+ const r = selectorDecisions.recordSelectorDecision({
396
+ decision: 'defer',
397
+ command: 'mos:bonus-decay',
398
+ framework: 'BonusFramework',
399
+ reason: null,
400
+ roomState: { db },
401
+ });
402
+ ok(r.ok);
403
+ // Sleep at least 2 ms so the framework_invoked rows land strictly after the
404
+ // memory_event row (createdAt > lastDecisionAt). Phase 109's findRecentChanges
405
+ // uses strict '>' on created_at.
406
+ const target = Date.now() + 3;
407
+ while (Date.now() < target) { /* busy-wait <= 3ms */ }
408
+ // Log a few framework_invoked events via the chokepoint.
409
+ const navigation = require(path.join(REPO_ROOT, 'lib', 'core', 'navigation.cjs'));
410
+ navigation.logMemoryEvent(db, 'framework_invoked', { command: 'mos:bonus-decay', framework: 'BonusFramework' });
411
+ navigation.logMemoryEvent(db, 'framework_invoked', { command: 'mos:bonus-decay', framework: 'BonusFramework' });
412
+ // Now applyDecayWeight should compute decay relative to 2 invocations since decision.
413
+ // factor = 1 - exp(-2/5) ~= 0.3297; 0.5 * 0.3297 ~= 0.1648.
414
+ const decayed = selectorDecisions.applyDecayWeight(0.5, 'mos:bonus-decay', { db });
415
+ const expected = 0.5 * (1 - Math.exp(-2 / 5));
416
+ ok(Math.abs(decayed - expected) < 0.1, 'db-backed decay factor with 2 framework_invoked events; got ' + decayed + ' expected ~' + expected);
417
+ });