@mindrian_os/install 1.13.0-beta.13 → 1.13.0-beta.16
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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -11
- package/README.md +74 -572
- package/commands/act.md +1 -0
- package/commands/admin.md +1 -0
- package/commands/analyze-needs.md +1 -0
- package/commands/analyze-systems.md +1 -0
- package/commands/analyze-timing.md +1 -0
- package/commands/auto-explore.md +1 -0
- package/commands/beautiful-question.md +1 -0
- package/commands/brain-derive.md +1 -0
- package/commands/build-knowledge.md +1 -0
- package/commands/build-thesis.md +1 -0
- package/commands/causal.md +1 -0
- package/commands/challenge-assumptions.md +1 -0
- package/commands/compare-ventures.md +1 -0
- package/commands/dashboard.md +1 -0
- package/commands/deep-grade.md +1 -0
- package/commands/diagnose.md +1 -0
- package/commands/diagnostics.md +1 -0
- package/commands/doctor.md +1 -0
- package/commands/dominant-designs.md +1 -0
- package/commands/explain-decision.md +1 -0
- package/commands/explore-domains.md +1 -0
- package/commands/explore-futures.md +1 -0
- package/commands/explore-trends.md +1 -0
- package/commands/export.md +1 -0
- package/commands/feynman-timeline-refresh.md +78 -0
- package/commands/file-meeting.md +1 -0
- package/commands/find-analogies.md +1 -0
- package/commands/find-bottlenecks.md +1 -0
- package/commands/find-connections.md +1 -0
- package/commands/funding.md +1 -0
- package/commands/grade.md +1 -0
- package/commands/graph.md +1 -0
- package/commands/hat-briefing.md +1 -0
- package/commands/heal.md +1 -0
- package/commands/help.md +1 -0
- package/commands/hmi-status.md +1 -0
- package/commands/jtbd.md +1 -0
- package/commands/leadership.md +1 -0
- package/commands/lean-canvas.md +1 -0
- package/commands/macro-trends.md +1 -0
- package/commands/map-unknowns.md +1 -0
- package/commands/memory.md +1 -0
- package/commands/models.md +1 -0
- package/commands/mos-reason.md +1 -0
- package/commands/mullins.md +1 -0
- package/commands/new-project.md +1 -0
- package/commands/onboard.md +1 -0
- package/commands/operator.md +1 -0
- package/commands/opportunities.md +1 -0
- package/commands/organize.md +1 -0
- package/commands/persona.md +1 -0
- package/commands/pipeline.md +1 -0
- package/commands/present.md +1 -0
- package/commands/publish.md +1 -0
- package/commands/query.md +1 -0
- package/commands/radar.md +1 -0
- package/commands/reanalyze.md +1 -0
- package/commands/research.md +1 -0
- package/commands/room.md +1 -0
- package/commands/rooms.md +1 -0
- package/commands/root-cause.md +1 -0
- package/commands/rs-experts.md +1 -0
- package/commands/rs-explain.md +1 -0
- package/commands/rs-fetch.md +1 -0
- package/commands/rs-thesis.md +1 -0
- package/commands/scenario-plan.md +1 -0
- package/commands/scheduled-tasks.md +1 -0
- package/commands/score-innovation.md +1 -0
- package/commands/scout.md +1 -0
- package/commands/setup.md +1 -0
- package/commands/snapshot.md +1 -0
- package/commands/speakers.md +1 -0
- package/commands/splash.md +1 -0
- package/commands/status.md +1 -0
- package/commands/structure-argument.md +1 -0
- package/commands/suggest-next.md +1 -0
- package/commands/systems-thinking.md +1 -0
- package/commands/think-hats.md +1 -0
- package/commands/update.md +1 -0
- package/commands/user-needs.md +1 -0
- package/commands/validate.md +1 -0
- package/commands/value-proposition.md +1 -0
- package/commands/vault.md +1 -0
- package/commands/visualize.md +1 -0
- package/commands/whitespace.md +1 -0
- package/commands/wiki.md +1 -0
- package/lib/brain/framework-chain-slice.cjs +193 -0
- package/lib/core/cache-prune.cjs +114 -8
- package/lib/core/feynman/ROOM.md +25 -0
- package/lib/core/feynman/timeline-renderer.cjs +197 -0
- package/lib/core/feynman/timeline-runner.cjs +281 -0
- package/lib/core/install-state.cjs +242 -0
- package/lib/core/navigation/edges.cjs +86 -0
- package/lib/core/navigation/insights.cjs +37 -0
- package/lib/core/navigation/memory-events.cjs +39 -0
- package/lib/core/navigation/packet.cjs +89 -9
- package/lib/core/navigation/projections.cjs +201 -0
- package/lib/core/navigation.cjs +25 -0
- package/lib/mcp/larry-server-instructions.md +1 -1
- package/lib/memory/brain-cypher-chain-slice.test.cjs +368 -0
- package/lib/memory/f-selector-ranker.test.cjs +593 -0
- package/lib/memory/navigation-projections.test.cjs +241 -0
- package/lib/memory/navigation-write-edge.test.cjs +206 -0
- package/lib/memory/packet-chain-hint.test.cjs +407 -0
- package/lib/memory/packet-schema-validation.test.cjs +317 -0
- package/lib/memory/per-command-jtbd-derivation.test.cjs +130 -0
- package/lib/memory/per-command-teaching.test.cjs +110 -0
- package/lib/memory/run-feynman-tests.cjs +36 -0
- package/lib/memory/selector-decisions.test.cjs +417 -0
- package/lib/memory/selector-miss.test.cjs +290 -0
- package/lib/workflow/f-selector-ranker.cjs +420 -0
- package/lib/workflow/selector-decisions.cjs +368 -0
- package/package.json +1 -1
- package/references/design/email-template-standard.md +1 -1
- 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
|
+
};
|