@mindrian_os/install 1.13.0-beta.13 → 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 (116) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +16 -11
  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 +1 -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 +1 -0
  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 +1 -0
  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 +1 -0
  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/feynman/ROOM.md +25 -0
  92. package/lib/core/feynman/timeline-renderer.cjs +197 -0
  93. package/lib/core/feynman/timeline-runner.cjs +281 -0
  94. package/lib/core/navigation/edges.cjs +86 -0
  95. package/lib/core/navigation/insights.cjs +37 -0
  96. package/lib/core/navigation/memory-events.cjs +39 -0
  97. package/lib/core/navigation/packet.cjs +89 -9
  98. package/lib/core/navigation/projections.cjs +201 -0
  99. package/lib/core/navigation.cjs +25 -0
  100. package/lib/mcp/larry-server-instructions.md +1 -1
  101. package/lib/memory/brain-cypher-chain-slice.test.cjs +368 -0
  102. package/lib/memory/f-selector-ranker.test.cjs +593 -0
  103. package/lib/memory/navigation-projections.test.cjs +241 -0
  104. package/lib/memory/navigation-write-edge.test.cjs +206 -0
  105. package/lib/memory/packet-chain-hint.test.cjs +407 -0
  106. package/lib/memory/packet-schema-validation.test.cjs +317 -0
  107. package/lib/memory/per-command-jtbd-derivation.test.cjs +130 -0
  108. package/lib/memory/per-command-teaching.test.cjs +110 -0
  109. package/lib/memory/run-feynman-tests.cjs +36 -0
  110. package/lib/memory/selector-decisions.test.cjs +417 -0
  111. package/lib/memory/selector-miss.test.cjs +290 -0
  112. package/lib/workflow/f-selector-ranker.cjs +420 -0
  113. package/lib/workflow/selector-decisions.cjs +368 -0
  114. package/package.json +1 -1
  115. package/references/design/email-template-standard.md +1 -1
  116. package/references/user-research/2026-04-05-leah-lawrence-session.md +3 -3
@@ -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 };
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+ // Phase 124-02 -- FEYNMAN.md timeline runner
3
+ // =========================================
4
+ // The side-effect orchestrator. Walks a room's section folders, finds each FEYNMAN.md,
5
+ // applies the D-02 sentinel-bounded merge over the body returned by the pure renderer
6
+ // (Plan 124-01), writes back atomically (.tmp + rename), updates the timeline_last_rendered
7
+ // frontmatter, and logs a memory_event on every refresh attempt (success or failure).
8
+ //
9
+ // Honors the D-09 watermark contract: skip when the frontmatter timeline_last_rendered ISO
10
+ // is >= the SQL MAX(memory_event.created_at) ISO for the section (second-resolution).
11
+ //
12
+ // The renderer (timeline-renderer.cjs) is the pure function. The runner is the impure shell
13
+ // around it. tests/test-feynman-timeline-runner.cjs is the integration test for this module.
14
+ //
15
+ // Canon Part 9: every refresh logs a typed memory_event making the regenerate-vs-skip
16
+ // decision legible in the local mind. Canon Part 8: zero net new Brain surface.
17
+ // Canon Part 7: reuses the atomic .tmp + rename idiom from scripts/vault-section-minto-generator.cjs;
18
+ // the frontmatter line-walk parser idiom from scripts/frontmatter-schema-validator.cjs.
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const crypto = require('node:crypto');
23
+
24
+ const renderer = require('./timeline-renderer.cjs');
25
+ const navigation = require('../navigation.cjs');
26
+
27
+ // ---------- Sentinel contract (D-02) ----------
28
+
29
+ const SENTINEL_START = '<!-- TIMELINE_AUTO_START -->';
30
+ const SENTINEL_END = '<!-- TIMELINE_AUTO_END -->';
31
+ const HEADER = '## Timeline (auto)';
32
+
33
+ // ---------- Frontmatter helpers (hand-rolled; no gray-matter) ----------
34
+
35
+ function parseFrontmatter(content) {
36
+ // Returns { fm: { [key]: value }, fmOrder: [keys...], body: '<rest>', hadFrontmatter: bool }.
37
+ const lines = content.split('\n');
38
+ if (lines.length === 0 || lines[0].trim() !== '---') {
39
+ return { fm: {}, fmOrder: [], body: content, hadFrontmatter: false };
40
+ }
41
+ let endIdx = -1;
42
+ for (let i = 1; i < lines.length; i++) {
43
+ if (lines[i].trim() === '---') { endIdx = i; break; }
44
+ }
45
+ if (endIdx === -1) {
46
+ return { fm: {}, fmOrder: [], body: content, hadFrontmatter: false };
47
+ }
48
+ const fmLines = lines.slice(1, endIdx);
49
+ const bodyLines = lines.slice(endIdx + 1);
50
+ const fm = {};
51
+ const fmOrder = [];
52
+ for (const ln of fmLines) {
53
+ const m = ln.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
54
+ if (!m) continue;
55
+ const key = m[1];
56
+ const raw = m[2];
57
+ let val;
58
+ if (raw === '' || raw === 'null') val = null;
59
+ else if (raw === 'true') val = true;
60
+ else if (raw === 'false') val = false;
61
+ else if (/^-?\d+(\.\d+)?$/.test(raw)) val = Number(raw);
62
+ else if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) val = raw.slice(1, -1);
63
+ else val = raw;
64
+ fm[key] = val;
65
+ if (fmOrder.indexOf(key) === -1) fmOrder.push(key);
66
+ }
67
+ return { fm: fm, fmOrder: fmOrder, body: bodyLines.join('\n'), hadFrontmatter: true };
68
+ }
69
+
70
+ function serializeFrontmatter(fm, fmOrder, body, hadFrontmatter) {
71
+ // If fm is empty AND there was no original frontmatter -> return body unchanged.
72
+ const keys = Object.keys(fm);
73
+ if (keys.length === 0 && !hadFrontmatter) return body;
74
+ // Honor fmOrder for existing keys; append any new keys not in fmOrder at the end.
75
+ const order = fmOrder.slice();
76
+ for (const k of keys) {
77
+ if (order.indexOf(k) === -1) order.push(k);
78
+ }
79
+ const out = ['---'];
80
+ for (const k of order) {
81
+ if (!(k in fm)) continue;
82
+ const v = fm[k];
83
+ if (v === null) out.push(k + ':');
84
+ else if (typeof v === 'boolean' || typeof v === 'number') out.push(k + ': ' + String(v));
85
+ else out.push(k + ': ' + String(v));
86
+ }
87
+ out.push('---');
88
+ return out.join('\n') + '\n' + body;
89
+ }
90
+
91
+ // ---------- Sentinel-bounded merge (D-02 hard invariant) ----------
92
+
93
+ function mergeSentinelSection(body, renderedBody) {
94
+ // Case A: the sentinel pair exists -- replace the content between.
95
+ const startIdx = body.indexOf(SENTINEL_START);
96
+ const endIdx = body.indexOf(SENTINEL_END);
97
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
98
+ const before = body.slice(0, startIdx + SENTINEL_START.length);
99
+ const after = body.slice(endIdx);
100
+ return before + '\n' + renderedBody + '\n' + after;
101
+ }
102
+ // Case B: no sentinel pair -- append at end-of-file (ensure trailing newline).
103
+ const sep = body.endsWith('\n') ? '' : '\n';
104
+ return body + sep + '\n' + HEADER + '\n\n' + SENTINEL_START + '\n' + renderedBody + '\n' + SENTINEL_END + '\n';
105
+ }
106
+
107
+ function bodyOutsideSentinels(body) {
108
+ // For the SHA256 byte-preservation check. Returns the body with the sentinel block excised
109
+ // (everything from SENTINEL_START to SENTINEL_END inclusive removed, plus the lines they
110
+ // sit on). If no sentinels, returns the body unchanged.
111
+ const startIdx = body.indexOf(SENTINEL_START);
112
+ const endIdx = body.indexOf(SENTINEL_END);
113
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return body;
114
+ // Find the start of the line containing SENTINEL_START (back up to the previous newline)
115
+ // and the end of the line containing SENTINEL_END (forward to the next newline).
116
+ let lineStart = body.lastIndexOf('\n', startIdx);
117
+ if (lineStart === -1) lineStart = 0;
118
+ let lineEnd = body.indexOf('\n', endIdx + SENTINEL_END.length);
119
+ if (lineEnd === -1) lineEnd = body.length;
120
+ return body.slice(0, lineStart) + body.slice(lineEnd);
121
+ }
122
+
123
+ function sha256Hex(s) {
124
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
125
+ }
126
+
127
+ // ---------- Atomic write (D-02 hard invariant: no partial-write corruption) ----------
128
+
129
+ function atomicWrite(targetPath, content) {
130
+ const dir = path.dirname(targetPath);
131
+ const base = path.basename(targetPath);
132
+ const tmp = path.join(dir, '.' + base + '.tmp.' + process.pid + '.' + Date.now());
133
+ const fd = fs.openSync(tmp, 'w');
134
+ try {
135
+ fs.writeSync(fd, content, 0, 'utf8');
136
+ try { fs.fsyncSync(fd); } catch (_) { /* fsync best-effort on platforms without it */ }
137
+ } finally {
138
+ fs.closeSync(fd);
139
+ }
140
+ fs.renameSync(tmp, targetPath);
141
+ }
142
+
143
+ // ---------- Section walk ----------
144
+
145
+ function safeIsDir(p) { try { return fs.statSync(p).isDirectory(); } catch (_) { return false; } }
146
+ function safeIsFile(p) { try { return fs.statSync(p).isFile(); } catch (_) { return false; } }
147
+
148
+ function findFeynmanSections(roomDir) {
149
+ // Subdirectories of roomDir that contain a FEYNMAN.md at <roomDir>/<section>/FEYNMAN.md.
150
+ if (!safeIsDir(roomDir)) return [];
151
+ let entries;
152
+ try {
153
+ entries = fs.readdirSync(roomDir, { withFileTypes: true });
154
+ } catch (_) {
155
+ return [];
156
+ }
157
+ const out = [];
158
+ for (const ent of entries) {
159
+ if (!ent.isDirectory()) continue;
160
+ if (ent.name.startsWith('.')) continue;
161
+ if (ent.name === 'node_modules') continue;
162
+ const feyPath = path.join(roomDir, ent.name, 'FEYNMAN.md');
163
+ if (safeIsFile(feyPath)) out.push({ slug: ent.name, feyPath: feyPath });
164
+ }
165
+ out.sort((a, b) => a.slug.localeCompare(b.slug));
166
+ return out;
167
+ }
168
+
169
+ // ---------- Watermark check (D-09) ----------
170
+
171
+ function shouldSkipWatermark(db, sectionSlug, fmRendered) {
172
+ if (typeof fmRendered !== 'string' || fmRendered.length === 0) return false;
173
+ let summary;
174
+ try {
175
+ summary = navigation.firstCapturedLastTouchedBySection(db, sectionSlug);
176
+ } catch (_) {
177
+ return false;
178
+ }
179
+ if (!summary || summary.total_events === 0) return false; // no events; refresh to empty-state if needed.
180
+ const sqlIso = renderer.isoSecond(summary.last_touched_ms);
181
+ if (!sqlIso) return false;
182
+ // ISO 8601 second-resolution strings are lex-sortable.
183
+ return sqlIso <= fmRendered;
184
+ }
185
+
186
+ // ---------- The orchestrator ----------
187
+
188
+ function refreshSection(roomDir, sectionSlug, opts) {
189
+ const options = opts || {};
190
+ const db = options.db;
191
+ const now_ms = Number.isFinite(options.now_ms) ? options.now_ms : Date.now();
192
+ const force = options.force === true;
193
+ const feyPath = path.join(roomDir, sectionSlug, 'FEYNMAN.md');
194
+
195
+ if (!safeIsFile(feyPath)) {
196
+ return { status: 'skipped_no_feynman', reason: 'no_feynman_md_in_section_dir' };
197
+ }
198
+
199
+ try {
200
+ const rawContent = fs.readFileSync(feyPath, 'utf8');
201
+ const parsed = parseFrontmatter(rawContent);
202
+
203
+ // D-09 watermark check.
204
+ if (!force && db && typeof parsed.fm.timeline_last_rendered === 'string') {
205
+ if (shouldSkipWatermark(db, sectionSlug, parsed.fm.timeline_last_rendered)) {
206
+ return { status: 'skipped_watermark', reason: 'sql_older_than_rendered' };
207
+ }
208
+ }
209
+
210
+ // Render (D-05). Reads ONLY via navigation.cjs.
211
+ const rendered = renderer.renderTimeline(db, sectionSlug, { now_ms: now_ms });
212
+
213
+ // Merge (D-02 hard invariant).
214
+ const newBody = mergeSentinelSection(parsed.body, rendered.markdown_body);
215
+
216
+ // Update frontmatter watermark (D-09).
217
+ const newIso = renderer.isoSecond(now_ms);
218
+ const newFm = Object.assign({}, parsed.fm, { timeline_last_rendered: newIso });
219
+ // Force frontmatter emission on every refresh (true so the watermark always lands at
220
+ // the top, even when the original file had no fences).
221
+ const newContent = serializeFrontmatter(newFm, parsed.fmOrder, newBody, true);
222
+
223
+ // Atomic write.
224
+ atomicWrite(feyPath, newContent);
225
+
226
+ // Log success memory_event (D-10).
227
+ if (db) {
228
+ try {
229
+ navigation.logMemoryEvent(db, 'feynman_timeline_refreshed', {
230
+ source_path: 'feynman:' + sectionSlug,
231
+ created_by: 'system',
232
+ });
233
+ } catch (_) { /* logging failure does not corrupt the refresh; the write already landed */ }
234
+ }
235
+
236
+ return { status: 'refreshed', written_path: feyPath, watermark: newIso };
237
+ } catch (err) {
238
+ const reason = (err && err.message) ? String(err.message).slice(0, 200) : 'unknown_error';
239
+ if (db) {
240
+ try {
241
+ navigation.logMemoryEvent(db, 'feynman_timeline_refresh_failed', {
242
+ source_path: 'feynman:' + sectionSlug,
243
+ created_by: 'system',
244
+ reason: reason,
245
+ });
246
+ } catch (_) { /* secondary failure is swallowed */ }
247
+ }
248
+ return { status: 'failed', reason: reason };
249
+ }
250
+ }
251
+
252
+ function refreshAll(roomDir, opts) {
253
+ const options = opts || {};
254
+ const refreshed = [];
255
+ const skipped = [];
256
+ const failed = [];
257
+ const sections = findFeynmanSections(roomDir);
258
+ for (const s of sections) {
259
+ const r = refreshSection(roomDir, s.slug, options);
260
+ if (r.status === 'refreshed') refreshed.push({ slug: s.slug, written_path: r.written_path, watermark: r.watermark });
261
+ else if (r.status === 'failed') failed.push({ slug: s.slug, reason: r.reason });
262
+ else skipped.push({ slug: s.slug, reason: r.reason || r.status });
263
+ }
264
+ return { refreshed: refreshed, skipped: skipped, failed: failed };
265
+ }
266
+
267
+ module.exports = {
268
+ refreshAll,
269
+ refreshSection,
270
+ parseFrontmatter,
271
+ serializeFrontmatter,
272
+ mergeSentinelSection,
273
+ bodyOutsideSentinels,
274
+ sha256Hex,
275
+ findFeynmanSections,
276
+ shouldSkipWatermark,
277
+ atomicWrite,
278
+ SENTINEL_START,
279
+ SENTINEL_END,
280
+ HEADER,
281
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ // Phase 125-00 -- navigation.cjs edge-write primitive (per CONTEXT.md Pass 3 GAP-2
3
+ // resolution). Adds writeEdge to the navigation.cjs closed surface as an additive
4
+ // extension following the Phase 110-03 logMemoryEvent precedent.
5
+ //
6
+ // Canon Part 4: every choice is graph data; this is the chokepoint primitive that
7
+ // lets Plan 06 selector-decisions.cjs + future Phases 116/117/118 write typed
8
+ // cascade edges without bypassing the closed surface.
9
+ //
10
+ // Canon Part 7: reuse-before-build -- the UPSERT statement mirrors
11
+ // lib/core/lazygraph-ops.cjs::upsertEdge (lines 990-1019) so sibling agents
12
+ // emit edges via the same UPSERT shape and the navigation chokepoint stays
13
+ // the single door for write traffic.
14
+ //
15
+ // Canon Part 8 invariant: writeEdge takes (db, params) -- a db handle owned by
16
+ // the caller (via openRoomDb) -- so this module never opens room.db itself. Zero
17
+ // direct room-db.cjs require here. The navigation pre-commit hook treats this
18
+ // file as part of the navigation/* allow-list, NOT as a bypass.
19
+
20
+ const crypto = require('node:crypto');
21
+
22
+ // Closed edge-type allowlist enforced by writeEdge. Mirrors the EVENT_TYPES Set
23
+ // pattern in lib/core/navigation/memory-events.cjs. Phase 125 ships DEFERRED +
24
+ // REJECTED (D7 typed cascade edge surface for F.1 defer / F.2 reject). Future
25
+ // phases (e.g. Phase 116 tension resolution, Phase 117 auto-explore, Phase 118
26
+ // MVA) extend this Set additively without canon amendment -- same idiom as the
27
+ // EVENT_TYPES additive blocks for Phases 88.2-00 / 89-07 / 116-00 / 117-00 /
28
+ // 110-02.
29
+ //
30
+ // Tests assert a FLOOR (DEFERRED + REJECTED present) and Set-instance shape --
31
+ // not an exact size -- so additive extensions cannot regress baseline.
32
+ const ALLOWED_EDGE_TYPES = Object.freeze(new Set([
33
+ // Phase 125 D7 -- F-selector decision edges (LOCKED LOCAL per Canon Part 8).
34
+ 'DEFERRED',
35
+ 'REJECTED',
36
+ ]));
37
+
38
+ function isPlainObject(v) {
39
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
40
+ }
41
+
42
+ // writeEdge(db, params) -- the 15th-style additive re-export on the
43
+ // navigation.cjs closed surface (see navigation.cjs header comment for the
44
+ // canonical re-export pattern alongside logMemoryEvent + firstCapturedLastTouchedBySection).
45
+ //
46
+ // Positional db (first arg, owned by caller via openRoomDb), params object
47
+ // (second arg) with: { source_id, target_id, edge_type, properties }.
48
+ //
49
+ // Returns { ok: true, edge_id, type, source, target } on success, or
50
+ // { ok: false, reason, detail? } on validation / write failure. Defensive --
51
+ // never throws on caller input. The underlying prepare/run is sync per the
52
+ // node:sqlite contract (matches the rest of the navigation module).
53
+ function writeEdge(db, params) {
54
+ if (!params || typeof params !== 'object') {
55
+ return { ok: false, reason: 'invalid_params' };
56
+ }
57
+ const { source_id, target_id, edge_type, properties } = params;
58
+ if (typeof source_id !== 'string' || source_id.length === 0) {
59
+ return { ok: false, reason: 'invalid_source_id' };
60
+ }
61
+ if (typeof target_id !== 'string' || target_id.length === 0) {
62
+ return { ok: false, reason: 'invalid_target_id' };
63
+ }
64
+ if (typeof edge_type !== 'string' || !ALLOWED_EDGE_TYPES.has(edge_type)) {
65
+ return { ok: false, reason: 'invalid_edge_type', detail: String(edge_type).slice(0, 40) };
66
+ }
67
+ const props = isPlainObject(properties) ? properties : {};
68
+ let propsJson;
69
+ try {
70
+ propsJson = JSON.stringify(props);
71
+ } catch (_e) {
72
+ return { ok: false, reason: 'properties_serialize_failed' };
73
+ }
74
+ const edgeId = 'edge:' + edge_type + ':' + Date.now() + ':' + crypto.randomBytes(4).toString('hex');
75
+ try {
76
+ db.prepare(
77
+ 'INSERT INTO edges (source, target, type, properties) VALUES (?, ?, ?, ?) ' +
78
+ 'ON CONFLICT(source, target, type) DO UPDATE SET properties = excluded.properties'
79
+ ).run(source_id, target_id, edge_type, propsJson);
80
+ } catch (e) {
81
+ return { ok: false, reason: 'edge_write_failed', detail: String(e.message || '').slice(0, 80) };
82
+ }
83
+ return { ok: true, edge_id: edgeId, type: edge_type, source: source_id, target: target_id };
84
+ }
85
+
86
+ module.exports = { ALLOWED_EDGE_TYPES, writeEdge };
@@ -339,6 +339,42 @@ function findSurfaceableTensions(db, roomId, opts) {
339
339
  return candidates;
340
340
  }
341
341
 
342
+ /**
343
+ * Phase 124-01 Plan: firstCapturedLastTouchedBySection
344
+ * ----------------------------------------------------
345
+ * For a given section slug (the source_section identity for a folder), return
346
+ * { first_captured_ms, last_touched_ms, total_events } across all memory_event
347
+ * rows scoped to that section (source_path === slug OR source_path LIKE slug/%).
348
+ *
349
+ * Used by lib/core/feynman/timeline-renderer.cjs for the D-05 summary line
350
+ * ("first captured {first_iso}, last touched {last_iso}"). Returns nulls + 0
351
+ * count when the section has zero memory_event rows -- the empty-state cue.
352
+ *
353
+ * Canon Part 9: pure SQL read against the local room.db; zero Brain surface;
354
+ * zero filesystem reads. Canon Part 8-safe by construction.
355
+ */
356
+ function firstCapturedLastTouchedBySection(db, sectionSlug) {
357
+ if (!db || typeof db.prepare !== 'function' || typeof sectionSlug !== 'string' || sectionSlug.length === 0) {
358
+ return { first_captured_ms: null, last_touched_ms: null, total_events: 0 };
359
+ }
360
+ try {
361
+ const row = db.prepare(
362
+ "SELECT MIN(created_at) AS first_ms, MAX(created_at) AS last_ms, COUNT(*) AS n " +
363
+ "FROM nodes " +
364
+ "WHERE type = 'memory_event' " +
365
+ "AND (source_path = ? OR source_path LIKE ?)"
366
+ ).get(sectionSlug, sectionSlug + '/%');
367
+ const total = (row && Number.isFinite(row.n)) ? row.n : 0;
368
+ return {
369
+ first_captured_ms: total > 0 && Number.isFinite(row.first_ms) ? row.first_ms : null,
370
+ last_touched_ms: total > 0 && Number.isFinite(row.last_ms) ? row.last_ms : null,
371
+ total_events: total,
372
+ };
373
+ } catch (_) {
374
+ return { first_captured_ms: null, last_touched_ms: null, total_events: 0 };
375
+ }
376
+ }
377
+
342
378
  module.exports = {
343
379
  findContradictions,
344
380
  findUnsupportedClaims,
@@ -347,4 +383,5 @@ module.exports = {
347
383
  findOpenQuestions,
348
384
  findRelevantOpportunities,
349
385
  findSurfaceableTensions,
386
+ firstCapturedLastTouchedBySection,
350
387
  };
@@ -70,6 +70,45 @@ const EVENT_TYPES = Object.freeze(new Set([
70
70
  'brain_packet_rejected',
71
71
  'brain_response_rejected',
72
72
  'brain_legacy_path_used',
73
+ // Phase 124-02 extension (FEYNMAN.md Temporal Awareness; D-10 telemetry mirror):
74
+ // feynman_timeline_refreshed -> the runner successfully rendered + wrote the
75
+ // sentinel-bounded ## Timeline (auto) section for one
76
+ // FEYNMAN.md (per section, per refresh).
77
+ // feynman_timeline_refresh_failed -> the runner caught an exception during render or write;
78
+ // watermark NOT updated; FEYNMAN.md NOT corrupted (atomic
79
+ // .tmp write means the original is preserved on failure).
80
+ // Additive extension only; mirrors the Phase 110-02 3-string idiom verbatim. logEvent already
81
+ // rejects event_type values outside EVENT_TYPES -- so these are accepted only because they
82
+ // are now IN the Set. Set size grows by 2 (was 35 before Phase 124; now 37 baseline; coexists
83
+ // with the Phase 125-01 framework_invoked extension which adds +1 in parallel).
84
+ 'feynman_timeline_refreshed',
85
+ 'feynman_timeline_refresh_failed',
86
+ // Phase 125-01 extension (counter source for D3 continuous investment gradient).
87
+ // Each /mos:* methodology command run logs one framework_invoked event with payload
88
+ // {framework, command, timestamp}. computeInvestmentLevel(roomState) projects
89
+ // COUNT(memory_event WHERE event_type='framework_invoked') -> investment_level.
90
+ // The actual emission site is a follow-on instrumentation pass (lib/render/render-v2.cjs
91
+ // or per-command hook); Plan 01 only ships the event_type allowlist entry so
92
+ // computeInvestmentLevel can call findRecentChanges with this filter and get 0 (cold)
93
+ // back from a fresh room. The instrumentation is non-blocking for Plan 01 acceptance
94
+ // since computeInvestmentLevel reads from roomState.framework_invocations (which a
95
+ // separate caller will populate by calling findRecentChanges + counting); when no
96
+ // such count is available, the helper returns level: 0 -- the cold-start path.
97
+ 'framework_invoked',
98
+ // Phase 125-06 extension (D7: F-selector reject/defer become typed graph signal).
99
+ // Emitted by lib/workflow/selector-decisions.cjs::recordSelectorDecision.
100
+ // Payload: {decision: 'defer'|'reject', command, framework, reason, edge_semantic,
101
+ // expires_at|null, score_at_decision, investment_level_at_decision}. The corresponding
102
+ // DEFERRED/REJECTED cascade edge is written via navigation.writeEdge (Plan 00).
103
+ // Canon Part 4: every choice is graph data. The F-selector's adaptive-questioning
104
+ // surface relies on this signal to apply decay-weight in subsequent rankings.
105
+ 'f_selector_decision',
106
+ // Phase 125-07 extension (D8: none-fit affordance + ranker-miss capture).
107
+ // Emitted by lib/workflow/selector-decisions.cjs::recordSelectorMiss.
108
+ // Payload: {top_k_offered: [{command, score}, ...], user_intent: <verbatim user text>,
109
+ // investment_level_at_decision}. NO cascade edge written (miss is temporal-only).
110
+ // Canon Part 8: user_intent stays LOCAL; never sent to Brain.
111
+ 'f_selector_miss',
73
112
  ]));
74
113
 
75
114
  function isPlainObject(v) {