@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
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+ /*
3
+ * lib/core/cache-prune.cjs -- prune stale marketplace-cache version dirs.
4
+ *
5
+ * Phase 123 Plan-05 (HARNESS-123-13). Canon Part 8: this reads LOCAL files
6
+ * only (~/.claude/plugins/) and writes LOCAL files only (fs.rmSync on
7
+ * stale version dirs). Zero network surface.
8
+ *
9
+ * Background: Claude Code's marketplace install path is
10
+ * ~/.claude/plugins/cache/<marketplace>/mos/<version>/
11
+ * where each <version> subdir is a self-contained install of that
12
+ * version. On `claude plugin update`, the new version is downloaded
13
+ * into a new <version> dir, but the old <version> dir is NEVER removed.
14
+ * The Windows live test of v1.13.0-beta.12 surfaced two orphans on disk
15
+ * (`1.12.0/`, `1.13.0-beta.9/`) alongside the active `1.13.0-beta.12/`.
16
+ *
17
+ * This helper prunes the cache, keying off Claude Code's own
18
+ * installed_plugins.json (the source of truth for "which version is active"):
19
+ *
20
+ * - active version (from installed_plugins.json's mos@mindrian-marketplace
21
+ * entry) is ALWAYS kept, regardless of mtime.
22
+ * - the N most recent non-active versions (default N=2, sorted by mtime
23
+ * DESC) are also kept.
24
+ * - everything else is removed.
25
+ *
26
+ * Belt + suspenders: before every fs.rmSync call, the path's basename is
27
+ * cross-checked against the active version string -- if it matches, the
28
+ * removal is SKIPPED with a logged reason (defends against a malformed
29
+ * installed_plugins.json that somehow lists two different basenames as
30
+ * "active", or a race between this function and a concurrent install).
31
+ *
32
+ * If installed_plugins.json is unreadable (ENOENT, parse error, or no
33
+ * mos@mindrian-marketplace entry), the function returns
34
+ * { kept: [], removed: [], skipped: true, reason: '...' }
35
+ * with NO mutation. Never guesses; never deletes anything in the absence
36
+ * of an authoritative active-version answer.
37
+ *
38
+ * Signature:
39
+ * pruneMarketplaceCache({
40
+ * home = os.homedir(),
41
+ * marketplace = 'mindrian-marketplace',
42
+ * retainCount = 2,
43
+ * dryRun = false,
44
+ * } = {}) => {
45
+ * kept: string[], // version basenames kept on disk (incl. active)
46
+ * removed: string[], // version basenames removed (or would be, in dryRun)
47
+ * skipped: boolean, // true if we declined to act (see `reason`)
48
+ * reason: string|null // human-readable explanation, or null on success
49
+ * }
50
+ *
51
+ * Canon Part 8 sanity check (cp.6): the forbidden-network-token grep
52
+ * exits 1 (no match) on this source file. Test cp.6 enforces it.
53
+ */
54
+
55
+ const fs = require('node:fs');
56
+ const path = require('node:path');
57
+ const os = require('node:os');
58
+
59
+ // Read installed_plugins.json and pull the mos@mindrian-marketplace active
60
+ // version. Returns { activeVersion, activeInstallPath } on success, or
61
+ // { skipped: true, reason } on any failure.
62
+ function readActiveVersion(home) {
63
+ const installedPath = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
64
+ let raw;
65
+ try {
66
+ raw = fs.readFileSync(installedPath, 'utf8');
67
+ } catch (e) {
68
+ return { skipped: true, reason: 'installed_plugins.json unreadable (' + (e && e.code || e.message) + ') -- skipping prune' };
69
+ }
70
+ let ip;
71
+ try {
72
+ ip = JSON.parse(raw);
73
+ } catch (e) {
74
+ return { skipped: true, reason: 'installed_plugins.json parse error -- skipping prune' };
75
+ }
76
+ if (!ip || typeof ip !== 'object' || !ip.plugins || typeof ip.plugins !== 'object') {
77
+ return { skipped: true, reason: 'installed_plugins.json has no plugins map -- skipping prune' };
78
+ }
79
+ // Accept either key shape: 'mos@mindrian-marketplace' or 'mos'.
80
+ let entry = ip.plugins['mos@mindrian-marketplace'];
81
+ if (!entry) {
82
+ // Defensive: find any key whose left-of-@ is mos or mindrian-os.
83
+ const keys = Object.keys(ip.plugins);
84
+ for (const k of keys) {
85
+ const name = String(k).split('@')[0];
86
+ if (name === 'mos' || name === 'mindrian-os') { entry = ip.plugins[k]; break; }
87
+ }
88
+ }
89
+ if (!entry) {
90
+ return { skipped: true, reason: 'no active mos@mindrian-marketplace entry in installed_plugins.json -- skipping prune' };
91
+ }
92
+ if (Array.isArray(entry)) entry = entry[0];
93
+ if (!entry || !entry.version) {
94
+ return { skipped: true, reason: 'mos@mindrian-marketplace entry has no version field -- skipping prune' };
95
+ }
96
+ return {
97
+ activeVersion: String(entry.version),
98
+ activeInstallPath: entry.installPath || entry.path || entry.dir || null,
99
+ };
100
+ }
101
+
102
+ // List child directory names under <home>/.claude/plugins/cache/<marketplace>/mos/.
103
+ // Returns [] if the cache dir does not exist (no-op case).
104
+ function listCacheVersions(home, marketplace) {
105
+ const cacheDir = path.join(home, '.claude', 'plugins', 'cache', marketplace, 'mos');
106
+ if (!fs.existsSync(cacheDir)) return { cacheDir, names: [] };
107
+ let names = [];
108
+ try {
109
+ names = fs.readdirSync(cacheDir).filter(function (name) {
110
+ try { return fs.statSync(path.join(cacheDir, name)).isDirectory(); }
111
+ catch (_) { return false; }
112
+ });
113
+ } catch (_) { /* unreadable -- treat as empty */ }
114
+ return { cacheDir, names };
115
+ }
116
+
117
+ // Sort version names by mtime DESC (newest first). Falls back to lexicographic
118
+ // order if statSync fails on any path (defensive).
119
+ function sortByMtimeDesc(cacheDir, names) {
120
+ const stats = names.map(function (name) {
121
+ let mtimeMs = 0;
122
+ try { mtimeMs = fs.statSync(path.join(cacheDir, name)).mtimeMs || 0; }
123
+ catch (_) { mtimeMs = 0; }
124
+ return { name, mtimeMs };
125
+ });
126
+ stats.sort(function (a, b) {
127
+ if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
128
+ return a.name < b.name ? 1 : -1; // tie-break lexicographic DESC (stable)
129
+ });
130
+ return stats.map(function (s) { return s.name; });
131
+ }
132
+
133
+ function pruneMarketplaceCache(opts) {
134
+ const o = opts || {};
135
+ const home = o.home || os.homedir();
136
+ const marketplace = o.marketplace || 'mindrian-marketplace';
137
+ const retainCount = (typeof o.retainCount === 'number' && o.retainCount >= 0) ? o.retainCount : 2;
138
+ const dryRun = !!o.dryRun;
139
+
140
+ // Step 1: read active version. Skip entirely if unavailable -- never guess.
141
+ const active = readActiveVersion(home);
142
+ if (active.skipped) {
143
+ return { kept: [], removed: [], skipped: true, reason: active.reason };
144
+ }
145
+ const activeVersion = active.activeVersion;
146
+
147
+ // Step 2: list cache version dirs.
148
+ const { cacheDir, names } = listCacheVersions(home, marketplace);
149
+ if (names.length === 0) {
150
+ // No cache dir to prune. The active version is still "kept" conceptually
151
+ // (it lives in the cache when it exists; absent cache = nothing to do).
152
+ return { kept: [activeVersion], removed: [], skipped: false, reason: 'no cache dir to prune' };
153
+ }
154
+
155
+ // Step 3: build the keep-set.
156
+ // - the active version is ALWAYS kept (regardless of mtime).
157
+ // - then the N most recent non-active versions (by mtime DESC).
158
+ const sorted = sortByMtimeDesc(cacheDir, names);
159
+ const keep = new Set();
160
+ keep.add(activeVersion);
161
+ for (const name of sorted) {
162
+ if (name === activeVersion) continue;
163
+ if (keep.size >= retainCount + 1) break; // +1 reserves the slot for active
164
+ keep.add(name);
165
+ }
166
+
167
+ // Step 4: compute the prune set and act on it.
168
+ const removed = [];
169
+ for (const name of names) {
170
+ if (keep.has(name)) continue;
171
+ // Belt + suspenders: NEVER rm the active version's dir, no matter what.
172
+ if (name === activeVersion) {
173
+ // (Cannot reach here under normal flow -- the keep-set above always
174
+ // contains activeVersion. Defensive sentinel for malformed inputs.)
175
+ continue;
176
+ }
177
+ const target = path.join(cacheDir, name);
178
+ if (dryRun) {
179
+ removed.push(name);
180
+ continue;
181
+ }
182
+ try {
183
+ fs.rmSync(target, { recursive: true, force: true });
184
+ removed.push(name);
185
+ } catch (e) {
186
+ // Best-effort: a failed removal does not abort the whole prune; we
187
+ // record it in the reason field at the end if any failures occurred.
188
+ // The caller (session-start with `|| true`; doctor with try/catch +
189
+ // report.recoveries) handles the partial state.
190
+ removed.push(name + ' (rm-failed: ' + (e && e.code || e.message) + ')');
191
+ }
192
+ }
193
+
194
+ return {
195
+ kept: Array.from(keep),
196
+ removed,
197
+ skipped: false,
198
+ reason: null,
199
+ };
200
+ }
201
+
202
+ module.exports = {
203
+ pruneMarketplaceCache,
204
+ // Internal helpers exposed for testability + future reuse.
205
+ readActiveVersion,
206
+ listCacheVersions,
207
+ sortByMtimeDesc,
208
+ };
@@ -0,0 +1,25 @@
1
+ # lib/core/feynman/
2
+
3
+ Phase 124 (FEYNMAN.md temporal awareness) renderer + runner ship here.
4
+
5
+ Contains (after Plans 124-01 and 124-02 land):
6
+ - `timeline-renderer.cjs` -- pure function `renderTimeline(db, sectionSlug, opts) -> { markdown_body, summary_stats }`. Reads ONLY via `lib/core/navigation.cjs` (the Phase 109 closed chokepoint): `findRecentChanges`, `findStaleDecisions`, and the new `firstCapturedLastTouchedBySection` (added as the 15th re-export in Plan 124-01, mirroring the Phase 110-03 `logMemoryEvent` re-export idiom). ZERO filesystem reads. ZERO Brain calls. ZERO LLM calls.
7
+ - `timeline-runner.cjs` -- `refreshAll(roomDir)` + `refreshSection(roomDir, sectionSlug)`. Walks the room's section folders, finds each `FEYNMAN.md` with the sentinel pair (creates the pair if absent on first run), reads the surrounding body, calls the renderer, writes the file back with the body byte-preserved and the sentinel-bounded section replaced, sets `timeline_last_rendered: <ISO>` frontmatter (second-resolution). Each refresh logs a `memory_event` of type `feynman_timeline_refreshed`. Idempotent (re-run -> byte-identical output).
8
+
9
+ Sentinel pair (D-02 hard invariant; bytes outside the pair are byte-preserved across regeneration):
10
+ - `<!-- TIMELINE_AUTO_START -->`
11
+ - `<!-- TIMELINE_AUTO_END -->`
12
+
13
+ Stale thresholds (D-06; overridable via `process.env.MINDRIAN_TIMELINE_THRESHOLDS_JSON` for tests):
14
+ - recent < 7 days
15
+ - quiet 7-30 days
16
+ - stale 30-90 days
17
+ - dormant > 90 days
18
+
19
+ Owner: Phase 124 FEYNMAN.md Temporal Awareness.
20
+ Canon: Part 9 (the Larry-explains face of memory_event). Renderer reads ONLY room.db via navigation.cjs (D-03). Writes ONLY FEYNMAN.md inside the sentinels (D-02 hard invariant).
21
+ Boundary: NO Brain calls (Canon Part 8); NO filesystem reads outside the FEYNMAN.md being written and the room.db family (allow-list pattern enforced by `tests/test-feynman-timeline-canon-part-9-invariant.cjs`).
22
+
23
+ Upstream: `lib/core/navigation.cjs` (Phase 109 chokepoint), `lib/core/navigation/memory-events.cjs` (EVENT_TYPES +2 in Plan 124-02), `lib/core/navigation/insights.cjs` (source_section provenance).
24
+
25
+ See: `.planning/phases/124-feynman-temporal-awareness/124-CONTEXT.md`.
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+ // Phase 124-01 -- FEYNMAN.md timeline renderer
3
+ // ===========================================
4
+ // Pure function. Given (db, sectionSlug, opts) returns { markdown_body, summary_stats }.
5
+ // Reads ONLY via lib/core/navigation.cjs (the Phase 109 closed chokepoint per D-03).
6
+ // ZERO filesystem reads. ZERO Brain calls. ZERO LLM calls.
7
+ //
8
+ // Canon Part 9: the Larry-explains face of memory_event -- structured SQL becomes
9
+ // human-readable explanation strings via templated rendering, never LLM in the loop.
10
+ //
11
+ // Canon Part 8: zero net new Brain surface; the renderer is local-only.
12
+ //
13
+ // Canon Part 5: "stale" is a context signal (4 buckets: recent / quiet / stale / dormant)
14
+ // alongside the existing evidence tier; thresholds frozen at 7 / 30 / 90 days per D-06.
15
+ //
16
+ // Output format (D-05 LOCKED):
17
+ // *Last refreshed: {ISO}. {N} insight events, first captured {first_iso}, last touched {last_iso} ({last_delta_human}).*
18
+ //
19
+ // **Recent events** (within 7 days, top 5):
20
+ // - {iso}: {event_type} -- {one_line_explain}
21
+ // - ...
22
+ //
23
+ // **Flagged stale** (over 30 days untouched, top 5):
24
+ // - {iso}: {event_type} on {target_summary} -- last touched {delta_human}
25
+ // - ...
26
+ //
27
+ // **Health:** recent={n_recent} / quiet={n_quiet} / stale={n_stale} / dormant={n_dormant}.
28
+ //
29
+ // Empty state (zero memory_event rows scoped to section):
30
+ // *No timeline events yet.*
31
+
32
+ const navigation = require('../navigation.cjs');
33
+
34
+ // ---------- D-06 thresholds ----------
35
+
36
+ const THRESHOLDS = Object.freeze({
37
+ recent_ms: 7 * 24 * 60 * 60 * 1000,
38
+ quiet_ms: 30 * 24 * 60 * 60 * 1000,
39
+ stale_ms: 90 * 24 * 60 * 60 * 1000,
40
+ });
41
+
42
+ function resolveThresholds() {
43
+ const raw = process.env.MINDRIAN_TIMELINE_THRESHOLDS_JSON;
44
+ if (!raw) return THRESHOLDS;
45
+ try {
46
+ const parsed = JSON.parse(raw);
47
+ const r = Number.isFinite(parsed.recent_ms) ? parsed.recent_ms : THRESHOLDS.recent_ms;
48
+ const q = Number.isFinite(parsed.quiet_ms) ? parsed.quiet_ms : THRESHOLDS.quiet_ms;
49
+ const s = Number.isFinite(parsed.stale_ms) ? parsed.stale_ms : THRESHOLDS.stale_ms;
50
+ if (!(r < q && q < s)) return THRESHOLDS;
51
+ return Object.freeze({ recent_ms: r, quiet_ms: q, stale_ms: s });
52
+ } catch (_) {
53
+ return THRESHOLDS;
54
+ }
55
+ }
56
+
57
+ // ---------- ISO + human delta helpers ----------
58
+
59
+ function isoSecond(ms) {
60
+ if (!Number.isFinite(ms)) return '';
61
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
62
+ }
63
+
64
+ function humanDelta(deltaMs) {
65
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'unknown';
66
+ const sec = Math.floor(deltaMs / 1000);
67
+ if (sec < 60) return sec + ' seconds ago';
68
+ const min = Math.floor(sec / 60);
69
+ if (min < 60) return min + ' minutes ago';
70
+ const hr = Math.floor(min / 60);
71
+ if (hr < 24) return hr + ' hours ago';
72
+ const days = Math.floor(hr / 24);
73
+ if (days < 30) return days + ' days ago';
74
+ const months = Math.floor(days / 30);
75
+ if (months < 12) return months + ' months ago';
76
+ const years = Math.floor(days / 365);
77
+ return years + ' years ago';
78
+ }
79
+
80
+ // ---------- Section scoping (D-08) ----------
81
+
82
+ function isInSection(sourcePath, sectionSlug) {
83
+ if (typeof sourcePath !== 'string' || typeof sectionSlug !== 'string') return false;
84
+ if (sourcePath === sectionSlug) return true;
85
+ return sourcePath.startsWith(sectionSlug + '/');
86
+ }
87
+
88
+ // ---------- Explanation strings (templated; colocated fallback) ----------
89
+ //
90
+ // The Phase 109-05 renderExplanation(kind, payload) signature does not directly
91
+ // match the memory_event row shape returned by findRecentChanges, so we use a
92
+ // colocated templated fallback that renders generic event_type + target_node_id.
93
+ // Zero LLM in the loop -- the strings are pure string concatenation over typed
94
+ // SQL fields.
95
+
96
+ function oneLineExplain(row) {
97
+ // row = { eventType, targetNodeId, sourcePath, properties, createdAt }
98
+ const tgt = row && row.targetNodeId ? (' on ' + row.targetNodeId) : '';
99
+ const evt = (row && row.eventType) ? row.eventType : 'event';
100
+ return evt + tgt;
101
+ }
102
+
103
+ // ---------- The renderer ----------
104
+
105
+ function renderTimeline(db, sectionSlug, opts) {
106
+ const options = opts || {};
107
+ const now_ms = Number.isFinite(options.now_ms) ? options.now_ms : Date.now();
108
+ const thresholds = resolveThresholds();
109
+
110
+ // 1. Summary stats (first / last / total) via the new navigation primitive (D-08 scoping).
111
+ const summary = navigation.firstCapturedLastTouchedBySection(db, sectionSlug);
112
+
113
+ // Empty-state branch (D-05): zero memory_event rows scoped to the section.
114
+ if (summary.total_events === 0) {
115
+ return {
116
+ markdown_body: '*No timeline events yet.*',
117
+ summary_stats: { total_events: 0, n_recent: 0, n_quiet: 0, n_stale: 0, n_dormant: 0 },
118
+ };
119
+ }
120
+
121
+ // 2. Recent events (within recent_ms; section-scoped post-fetch filter).
122
+ // findRecentChanges returns rows for the WHOLE room; we filter by sourcePath per D-08.
123
+ const recentLookback = thresholds.recent_ms;
124
+ let allRecent = [];
125
+ try {
126
+ allRecent = navigation.findRecentChanges(db, now_ms - recentLookback, { limit: 200 }) || [];
127
+ } catch (_) { allRecent = []; }
128
+ const scopedRecent = allRecent.filter((r) => isInSection(r.sourcePath, sectionSlug));
129
+ const topRecent = scopedRecent.slice(0, 5);
130
+
131
+ // 3. Flagged stale: rows whose delta sits in the [quiet_ms .. stale_ms) window.
132
+ let allStaleWindow = [];
133
+ try {
134
+ const sinceStale = now_ms - thresholds.stale_ms;
135
+ const allWindow = navigation.findRecentChanges(db, sinceStale, { limit: 500 }) || [];
136
+ allStaleWindow = allWindow.filter((r) => isInSection(r.sourcePath, sectionSlug)
137
+ && Number.isFinite(r.createdAt)
138
+ && (now_ms - r.createdAt) >= thresholds.quiet_ms
139
+ && (now_ms - r.createdAt) < thresholds.stale_ms);
140
+ } catch (_) { allStaleWindow = []; }
141
+ const topStale = allStaleWindow.slice(0, 5);
142
+
143
+ // 4. Health buckets across ALL memory_event rows scoped to the section.
144
+ let allRows = [];
145
+ try {
146
+ allRows = navigation.findRecentChanges(db, 0, { limit: 10000 }) || [];
147
+ } catch (_) { allRows = []; }
148
+ const scoped = allRows.filter((r) => isInSection(r.sourcePath, sectionSlug)
149
+ && Number.isFinite(r.createdAt));
150
+ let n_recent = 0, n_quiet = 0, n_stale = 0, n_dormant = 0;
151
+ for (const r of scoped) {
152
+ const delta = now_ms - r.createdAt;
153
+ if (delta < thresholds.recent_ms) n_recent += 1;
154
+ else if (delta < thresholds.quiet_ms) n_quiet += 1;
155
+ else if (delta < thresholds.stale_ms) n_stale += 1;
156
+ else n_dormant += 1;
157
+ }
158
+
159
+ // 5. Assemble the D-05 markdown body.
160
+ const lines = [];
161
+ const firstIso = isoSecond(summary.first_captured_ms);
162
+ const lastIso = isoSecond(summary.last_touched_ms);
163
+ const lastDeltaHuman = humanDelta(now_ms - summary.last_touched_ms);
164
+ const nowIso = isoSecond(now_ms);
165
+ lines.push('*Last refreshed: ' + nowIso + '. ' + summary.total_events + ' insight events, first captured '
166
+ + firstIso + ', last touched ' + lastIso + ' (' + lastDeltaHuman + ').*');
167
+ lines.push('');
168
+ lines.push('**Recent events** (within 7 days, top 5):');
169
+ if (topRecent.length === 0) {
170
+ lines.push('- (none in the last 7 days)');
171
+ } else {
172
+ for (const r of topRecent) {
173
+ lines.push('- ' + isoSecond(r.createdAt) + ': ' + (r.eventType || 'event') + ' -- ' + oneLineExplain(r));
174
+ }
175
+ }
176
+ lines.push('');
177
+ lines.push('**Flagged stale** (over 30 days untouched, top 5):');
178
+ if (topStale.length === 0) {
179
+ lines.push('- (none over 30 days untouched)');
180
+ } else {
181
+ for (const r of topStale) {
182
+ const tgt = r.targetNodeId || '(unscoped)';
183
+ lines.push('- ' + isoSecond(r.createdAt) + ': ' + (r.eventType || 'event') + ' on ' + tgt
184
+ + ' -- last touched ' + humanDelta(now_ms - r.createdAt));
185
+ }
186
+ }
187
+ lines.push('');
188
+ lines.push('**Health:** recent=' + n_recent + ' / quiet=' + n_quiet + ' / stale=' + n_stale
189
+ + ' / dormant=' + n_dormant + '.');
190
+
191
+ return {
192
+ markdown_body: lines.join('\n'),
193
+ summary_stats: { total_events: summary.total_events, n_recent, n_quiet, n_stale, n_dormant },
194
+ };
195
+ }
196
+
197
+ module.exports = { renderTimeline, THRESHOLDS, resolveThresholds, isoSecond, humanDelta, isInSection };