@isparling/engram-coach 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +85 -18
  2. package/SETUP.md +559 -0
  3. package/SKILL_PACK.md +75 -0
  4. package/analyses/catalog.md +257 -0
  5. package/analysis-tools/hrv-trend.ts +592 -0
  6. package/analysis-tools/migrate-structured-capture.ts +234 -0
  7. package/analysis-tools/race-context.ts +96 -0
  8. package/analysis-tools/stream-analyze.ts +1008 -0
  9. package/analysis-tools/tsb-predict.ts +117 -0
  10. package/capture-handler.ts +301 -0
  11. package/config.json.example +21 -0
  12. package/engram-coach-ambient-capture.ts +336 -0
  13. package/engram-coach-capture-types.ts +185 -0
  14. package/engram-coach-config.ts +268 -0
  15. package/engram-coach-domain.ts +7 -2
  16. package/engram-coach-keys.ts +189 -0
  17. package/engram-coach-materialization.ts +638 -0
  18. package/engram-coach-migration.ts +1078 -0
  19. package/engram-coach-pack.ts +17 -12
  20. package/engram-coach-presentation.ts +10 -1
  21. package/engram-coach-reconciliation.ts +305 -2
  22. package/engram-coach-structured-capture.ts +622 -0
  23. package/package.json +39 -6
  24. package/personas/aggressive-monitoring.md +121 -0
  25. package/personas/aggressive.json +85 -0
  26. package/personas/conservative-monitoring.md +133 -0
  27. package/personas/conservative.json +93 -0
  28. package/personas/polarized-monitoring.md +112 -0
  29. package/personas/polarized.json +72 -0
  30. package/personas/volume-monitoring.md +85 -0
  31. package/personas/volume.json +108 -0
  32. package/shared/retrieval.md +71 -0
  33. package/shared/setup.md +207 -0
  34. package/skills/.gitkeep +0 -0
  35. package/skills/adapt-plan/SKILL.md +263 -0
  36. package/skills/block-review/SKILL.md +275 -0
  37. package/skills/consult/SKILL.md +176 -0
  38. package/skills/intake/SKILL.md +315 -0
  39. package/skills/lactate-analyze/SKILL.md +230 -0
  40. package/skills/lessons-rollup/SKILL.md +196 -0
  41. package/skills/monitoring-rollup/SKILL.md +208 -0
  42. package/skills/race-analysis/SKILL.md +219 -0
  43. package/skills/season-retrospective/SKILL.md +200 -0
  44. package/skills/set-goal/SKILL.md +297 -0
  45. package/templates/base.md +55 -0
  46. package/templates/build-1.md +57 -0
  47. package/templates/build-2.md +62 -0
  48. package/templates/race-report.md +51 -0
  49. package/templates/race-specificity.md +62 -0
  50. package/templates/season-review.md +40 -0
  51. package/engram-coach-extractor.ts +0 -295
@@ -0,0 +1,638 @@
1
+ /**
2
+ * engram-coach materialization — deterministic compatibility-view rendering.
3
+ *
4
+ * After a hash-bound apply commits records, this module regenerates the
5
+ * non-authoritative compatibility views athletes, coaches, and skills still
6
+ * read today: prescription YAML blocks, consultation/adaptation logs,
7
+ * monitoring logs, and doctor-prep summaries. Rendering is a pure function of
8
+ * the active, temporally effective record set: same records in any order
9
+ * produce byte-identical files, and rerunning materialization against an
10
+ * unchanged record set reapplies nothing (`replaceArtifact` byte-compare
11
+ * reports every path unchanged).
12
+ *
13
+ * Record contract consumed here (pack-owned `details`, written by explicit
14
+ * capture):
15
+ * { recordRole, entityType, entityKey, effectiveAt, sourceId, value,
16
+ * artifact: { kind, relativePath }, captureChannel }
17
+ *
18
+ * `details.artifact.kind` selects the renderer:
19
+ * - "prescription": one active state record per planned session; value
20
+ * carries blockName, goal?, order?, sessionId, week, day, sessionDate,
21
+ * sessionName, modality?, totalDurationMin?, effortZone?, warmup?,
22
+ * cooldown?, intervals?. Sessions group by relativePath into one YAML file
23
+ * whose field order matches PRESCRIPTION_FORMAT.md exactly.
24
+ * - "consultation": append-only event records; value carries
25
+ * legacyMarkdown? (preserved verbatim) or title?/summary.
26
+ * - "adaptation": append-only event records; same value contract as
27
+ * consultation.
28
+ * - "monitoring": monitoring state and event records for one log file;
29
+ * value carries concernId, signal, and either legacyMarkdown? or
30
+ * status?/note?.
31
+ * - "doctor-prep": declaration records naming the summary target; the
32
+ * summary itself renders from all active monitoring state plus
33
+ * chronological monitoring events.
34
+ *
35
+ * A failing artifact never aborts the remaining independent artifacts: every
36
+ * failed view lands in `stale` and retries idempotently. Record commit stays
37
+ * authoritative regardless of materialization outcome.
38
+ */
39
+
40
+ import { existsSync, readFileSync } from "node:fs";
41
+ import { createRequire } from "node:module";
42
+ import { dirname, isAbsolute, resolve } from "node:path";
43
+ import { fileURLToPath } from "node:url";
44
+ import type {
45
+ JsonObject,
46
+ JsonValue,
47
+ KnowledgeRecord,
48
+ } from "@isparling/engram-harness/knowledge-types";
49
+ import type {
50
+ AppliedCapturePlan,
51
+ ArtifactReplacementResult,
52
+ MaterializationResult,
53
+ } from "./engram-coach-capture-types.ts";
54
+ import { loadEngramCoachConfig, type EngramCoachRuntimeConfig } from "./engram-coach-config.ts";
55
+ import { canonicalJson } from "./engram-coach-structured-capture.ts";
56
+ type YamlModule = typeof import("yaml");
57
+
58
+ /**
59
+ * Bun-compiled OMP cannot resolve bare dependencies from a pack imported
60
+ * after extension startup. Prefer native resolution, then anchor the same
61
+ * CommonJS load to the dependency's on-disk package manifest.
62
+ */
63
+ function loadYamlModule(): YamlModule {
64
+ const requireFromPack = createRequire(import.meta.url);
65
+ try {
66
+ return requireFromPack("yaml") as YamlModule;
67
+ } catch (resolutionError) {
68
+ let directory = dirname(fileURLToPath(import.meta.url));
69
+ while (true) {
70
+ const manifestPath = resolve(directory, "node_modules", "yaml", "package.json");
71
+ if (existsSync(manifestPath)) {
72
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { main?: unknown };
73
+ if (typeof manifest.main === "string") {
74
+ return requireFromPack(resolve(dirname(manifestPath), manifest.main)) as YamlModule;
75
+ }
76
+ }
77
+ const parent = dirname(directory);
78
+ if (parent === directory) throw resolutionError;
79
+ directory = parent;
80
+ }
81
+ }
82
+ }
83
+
84
+ const { stringify: stringifyYaml } = loadYamlModule();
85
+
86
+ /** Host mechanics supplied by the OMP extension — no coaching ontology here. */
87
+ export type MaterializeTools = {
88
+ listRecords(): Promise<KnowledgeRecord[]>;
89
+ replaceArtifact(request: {
90
+ root: string;
91
+ relativePath: string;
92
+ content: string;
93
+ }): Promise<ArtifactReplacementResult>;
94
+ projectRoot: string;
95
+ /** One captured apply timestamp (ISO); bounds temporal effectiveness. */
96
+ appliedAt: string;
97
+ };
98
+
99
+ const PRESCRIPTION_HEADER = "# GENERATED FROM ENGRAM ACTIVE RECORDS. DO NOT EDIT DIRECTLY.\n";
100
+ const MARKDOWN_HEADER = "<!-- GENERATED FROM ENGRAM ACTIVE RECORDS. DO NOT EDIT DIRECTLY. -->\n";
101
+
102
+ export const ARTIFACT_KINDS = [
103
+ "prescription",
104
+ "consultation",
105
+ "adaptation",
106
+ "monitoring",
107
+ "doctor-prep",
108
+ ] as const;
109
+ export type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Narrowing helpers over untyped JSON payloads
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function isObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
117
+ }
118
+
119
+ function asString(value: JsonValue | undefined): string | null {
120
+ return typeof value === "string" ? value : null;
121
+ }
122
+
123
+ function asNumber(value: JsonValue | undefined): number | null {
124
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Active-view record selection
129
+ // ---------------------------------------------------------------------------
130
+
131
+ type MaterializationRecord = {
132
+ record: KnowledgeRecord;
133
+ role: string;
134
+ artifactKind: ArtifactKind;
135
+ relativePath: string;
136
+ effectiveAt: string;
137
+ sourceId: string;
138
+ id: string;
139
+ value: { [key: string]: JsonValue };
140
+ };
141
+
142
+ /**
143
+ * Keeps only active, temporally effective records carrying a pack-owned
144
+ * artifact pointer. Records with an unparseable effective time never become
145
+ * temporally effective, so they are excluded rather than guessed about.
146
+ */
147
+ export function selectMaterializableRecords(
148
+ records: readonly KnowledgeRecord[],
149
+ appliedAt: string,
150
+ ): MaterializationRecord[] {
151
+ const appliedMs = Date.parse(appliedAt);
152
+ const selected: MaterializationRecord[] = [];
153
+ for (const record of records) {
154
+ if (record.status !== "active") continue;
155
+ const details = isObject(record.details) ? record.details : undefined;
156
+ if (!details || details.captureChannel !== "explicit") continue;
157
+ const artifact = details.artifact;
158
+ if (!isObject(artifact)) continue;
159
+ const kind = asString(artifact.kind);
160
+ const relativePath = asString(artifact.relativePath);
161
+ const effectiveAt = asString(details.effectiveAt);
162
+ const sourceId = asString(details.sourceId);
163
+ if (
164
+ kind === null ||
165
+ relativePath === null ||
166
+ effectiveAt === null ||
167
+ sourceId === null ||
168
+ !(ARTIFACT_KINDS as readonly string[]).includes(kind)
169
+ ) {
170
+ continue;
171
+ }
172
+ const effectiveMs = Date.parse(effectiveAt);
173
+ if (Number.isNaN(effectiveMs) || Number.isNaN(appliedMs) || effectiveMs > appliedMs) continue;
174
+ selected.push({
175
+ record,
176
+ role: asString(details.recordRole) ?? "",
177
+ artifactKind: kind as ArtifactKind,
178
+ relativePath,
179
+ effectiveAt,
180
+ sourceId,
181
+ id: record.id,
182
+ value: isObject(details.value) ? details.value : {},
183
+ });
184
+ }
185
+ return selected;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Prescription YAML rendering
190
+ // ---------------------------------------------------------------------------
191
+
192
+ type PowerBand = { lowPct: number | null; highPct: number | null };
193
+
194
+ type PrescriptionSession = {
195
+ blockName: string;
196
+ goal: JsonValue | null;
197
+ order: number;
198
+ sessionId: string;
199
+ week: number;
200
+ day: string;
201
+ sessionDate: string;
202
+ sessionName: string;
203
+ modality: string | null;
204
+ totalDurationMin: number | null;
205
+ effortZone: string | null;
206
+ warmup: PowerBand;
207
+ cooldown: PowerBand;
208
+ intervals: Array<{
209
+ durationMin: number;
210
+ powerLowPct: number;
211
+ powerHighPct: number;
212
+ count: number;
213
+ recoveryMin: number;
214
+ recoveryPowerLowPct: number | null;
215
+ recoveryPowerHighPct: number | null;
216
+ }>;
217
+ };
218
+
219
+ function parsePowerBand(value: JsonValue | undefined): PowerBand {
220
+ if (!isObject(value)) return { lowPct: null, highPct: null };
221
+ return { lowPct: asNumber(value.powerLowPct), highPct: asNumber(value.powerHighPct) };
222
+ }
223
+
224
+ /** Parses one prescription session value; null means structurally invalid. */
225
+ function parsePrescriptionSession(value: { [key: string]: JsonValue }): PrescriptionSession | null {
226
+ const blockName = asString(value.blockName);
227
+ const sessionId = asString(value.sessionId);
228
+ const week = asNumber(value.week);
229
+ const day = asString(value.day);
230
+ const sessionDate = asString(value.sessionDate);
231
+ const sessionName = asString(value.sessionName);
232
+ if (
233
+ blockName === null ||
234
+ sessionId === null ||
235
+ week === null ||
236
+ day === null ||
237
+ sessionDate === null ||
238
+ sessionName === null
239
+ ) {
240
+ return null;
241
+ }
242
+ const rawIntervals = value.intervals;
243
+ const intervals: PrescriptionSession["intervals"] = [];
244
+ if (rawIntervals !== undefined) {
245
+ if (!Array.isArray(rawIntervals)) return null;
246
+ for (const rawInterval of rawIntervals) {
247
+ if (!isObject(rawInterval)) return null;
248
+ const durationMin = asNumber(rawInterval.durationMin);
249
+ const powerLowPct = asNumber(rawInterval.powerLowPct);
250
+ const powerHighPct = asNumber(rawInterval.powerHighPct);
251
+ const count = asNumber(rawInterval.count);
252
+ const recoveryMin = asNumber(rawInterval.recoveryMin);
253
+ if (
254
+ durationMin === null ||
255
+ powerLowPct === null ||
256
+ powerHighPct === null ||
257
+ count === null ||
258
+ recoveryMin === null
259
+ ) {
260
+ return null;
261
+ }
262
+ intervals.push({
263
+ durationMin,
264
+ powerLowPct,
265
+ powerHighPct,
266
+ count,
267
+ recoveryMin,
268
+ recoveryPowerLowPct: asNumber(rawInterval.recoveryPowerLowPct),
269
+ recoveryPowerHighPct: asNumber(rawInterval.recoveryPowerHighPct),
270
+ });
271
+ }
272
+ }
273
+ return {
274
+ blockName,
275
+ goal: value.goal ?? null,
276
+ order: asNumber(value.order) ?? 0,
277
+ sessionId,
278
+ week,
279
+ day,
280
+ sessionDate,
281
+ sessionName,
282
+ modality: asString(value.modality),
283
+ totalDurationMin: asNumber(value.totalDurationMin),
284
+ effortZone: asString(value.effortZone),
285
+ warmup: parsePowerBand(value.warmup),
286
+ cooldown: parsePowerBand(value.cooldown),
287
+ intervals,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Renders the prescription YAML for one relative path from its active session
293
+ * records. Field order follows PRESCRIPTION_FORMAT.md: block_name, goal,
294
+ * sessions ordered by `order` then `sessionId`; within a session, week, day,
295
+ * session_date, session_name, modality, total_duration_min, effort_zone,
296
+ * warmup/cooldown bands, then intervals. Inconsistent blockName or goal across
297
+ * the group is an error, never a silent pick.
298
+ */
299
+ export function renderPrescriptionView(
300
+ records: readonly MaterializationRecord[],
301
+ ): { ok: true; content: string } | { ok: false; reason: string } {
302
+ const where = records[0]?.relativePath ?? "<unknown>";
303
+ const sessions: PrescriptionSession[] = [];
304
+ for (const entry of records) {
305
+ const session = parsePrescriptionSession(entry.value);
306
+ if (session === null) {
307
+ return { ok: false, reason: `invalid prescription session value in record ${entry.id}` };
308
+ }
309
+ sessions.push(session);
310
+ }
311
+ const blockNames = new Set(sessions.map((session) => session.blockName));
312
+ if (blockNames.size !== 1) {
313
+ return { ok: false, reason: `inconsistent blockName across active records for ${where}` };
314
+ }
315
+ const goals = new Set(sessions.map((session) => canonicalJson(session.goal)));
316
+ if (goals.size !== 1) {
317
+ return { ok: false, reason: `inconsistent goal across active records for ${where}` };
318
+ }
319
+ sessions.sort((left, right) =>
320
+ left.order !== right.order ? left.order - right.order : left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0,
321
+ );
322
+
323
+ const document: { [key: string]: JsonValue } = { block_name: sessions[0].blockName };
324
+ const goal = sessions[0].goal;
325
+ if (isObject(goal)) document.goal = goal;
326
+ document.sessions = sessions.map((session): JsonValue => {
327
+ const rendered: { [key: string]: JsonValue } = {
328
+ session_id: session.sessionId,
329
+ week: session.week,
330
+ day: session.day,
331
+ session_date: session.sessionDate,
332
+ session_name: session.sessionName,
333
+ };
334
+ if (session.modality !== null) rendered.modality = session.modality;
335
+ if (session.totalDurationMin !== null) rendered.total_duration_min = session.totalDurationMin;
336
+ if (session.effortZone !== null) rendered.effort_zone = session.effortZone;
337
+ if (session.warmup.lowPct !== null) rendered.warmup_power_low_pct = session.warmup.lowPct;
338
+ if (session.warmup.highPct !== null) rendered.warmup_power_high_pct = session.warmup.highPct;
339
+ if (session.cooldown.lowPct !== null) rendered.cooldown_power_low_pct = session.cooldown.lowPct;
340
+ if (session.cooldown.highPct !== null) rendered.cooldown_power_high_pct = session.cooldown.highPct;
341
+ if (session.intervals.length > 0) {
342
+ rendered.intervals = session.intervals.map((interval): JsonValue => {
343
+ const item: { [key: string]: JsonValue } = {
344
+ duration_min: interval.durationMin,
345
+ power_low_pct: interval.powerLowPct,
346
+ power_high_pct: interval.powerHighPct,
347
+ count: interval.count,
348
+ recovery_min: interval.recoveryMin,
349
+ };
350
+ if (interval.recoveryPowerLowPct !== null) item.recovery_power_low_pct = interval.recoveryPowerLowPct;
351
+ if (interval.recoveryPowerHighPct !== null) item.recovery_power_high_pct = interval.recoveryPowerHighPct;
352
+ return item;
353
+ });
354
+ }
355
+ return rendered;
356
+ });
357
+
358
+ // yaml.stringify terminates with exactly one LF; lineWidth 0 keeps long
359
+ // statements unwrapped so output is stable across yaml versions.
360
+ return { ok: true, content: PRESCRIPTION_HEADER + stringifyYaml(document, { lineWidth: 0 }) };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Chronological Markdown rendering
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /** Sort key shared by every chronological renderer: (effectiveAt, sourceId, id). */
368
+ function chronological(left: MaterializationRecord, right: MaterializationRecord): number {
369
+ if (left.effectiveAt !== right.effectiveAt) return left.effectiveAt < right.effectiveAt ? -1 : 1;
370
+ if (left.sourceId !== right.sourceId) return left.sourceId < right.sourceId ? -1 : 1;
371
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
372
+ }
373
+
374
+ /** Body of a legacy import is preserved verbatim; new events render from typed fields. */
375
+ function eventBody(entry: MaterializationRecord): string {
376
+ const legacy = asString(entry.value.legacyMarkdown);
377
+ if (legacy !== null) return legacy.trimEnd();
378
+ const title = asString(entry.value.title);
379
+ const summary = asString(entry.value.summary);
380
+ const lines: string[] = [];
381
+ if (title !== null) lines.push(`### ${title}`, "");
382
+ if (summary !== null) lines.push(summary);
383
+ else if (title === null) lines.push(entry.record.statement);
384
+ return lines.join("\n");
385
+ }
386
+
387
+ function renderChronologicalEntries(entries: readonly MaterializationRecord[]): string {
388
+ return [...entries]
389
+ .sort(chronological)
390
+ .map((entry) => {
391
+ // Only verbatim legacy imports carry their ORIGINAL heading label as
392
+ // `value.title` next to `legacyMarkdown`; preferring it over the opaque
393
+ // source id lets a legacy round trip re-render byte-identically.
394
+ // Typed events always render the source id.
395
+ const isLegacyImport =
396
+ asString(entry.value.legacyMarkdown) !== null && asString(entry.value.sourcePath) !== null;
397
+ const label = isLegacyImport ? asString(entry.value.title) ?? entry.sourceId : entry.sourceId;
398
+ return `## ${entry.effectiveAt} — ${label}\n\n${eventBody(entry)}`;
399
+ })
400
+ .join("\n\n");
401
+ }
402
+
403
+ /** Consultation log: consultation events for one relative path, chronological. */
404
+ export function renderConsultationLog(records: readonly MaterializationRecord[]): string {
405
+ return MARKDOWN_HEADER + "# Consultations\n\n" + renderChronologicalEntries(records) + "\n";
406
+ }
407
+
408
+ /** Adaptation log: workout-adaptation events for one relative path, chronological. */
409
+ export function renderAdaptationLog(records: readonly MaterializationRecord[]): string {
410
+ return MARKDOWN_HEADER + "# Workout Adaptations\n\n" + renderChronologicalEntries(records) + "\n";
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Monitoring log and doctor-prep summary rendering
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function concernKey(entry: MaterializationRecord): string {
418
+ const concernId = asString(entry.value.concernId) ?? "unassigned";
419
+ const signal = asString(entry.value.signal) ?? "unspecified";
420
+ return `${concernId} / ${signal}`;
421
+ }
422
+
423
+ function monitoringLine(entry: MaterializationRecord): string {
424
+ const legacy = asString(entry.value.legacyMarkdown);
425
+ if (legacy !== null) return legacy.trimEnd();
426
+ const status = asString(entry.value.status);
427
+ const note = asString(entry.value.note);
428
+ const parts = [
429
+ `- ${entry.effectiveAt}`,
430
+ entry.role === "state" ? "state" : "event",
431
+ entry.sourceId,
432
+ status ?? "",
433
+ note ?? "",
434
+ ].filter((part) => part.length > 0);
435
+ return parts.join(" — ").replace(/^(.*?) — /, "$1 ").trimEnd();
436
+ }
437
+
438
+ /**
439
+ * Monitoring log: monitoring state plus events for one relative path,
440
+ * grouped by concern/signal, chronological within each group, groups sorted
441
+ * alphabetically.
442
+ */
443
+ export function renderMonitoringLog(records: readonly MaterializationRecord[]): string {
444
+ const groups = new Map<string, MaterializationRecord[]>();
445
+ for (const entry of records) {
446
+ const key = concernKey(entry);
447
+ const group = groups.get(key);
448
+ if (group === undefined) groups.set(key, [entry]);
449
+ else group.push(entry);
450
+ }
451
+ const sections = [...groups.keys()].sort().map((key) => {
452
+ const entries = [...(groups.get(key) ?? [])].sort(chronological);
453
+ return `## ${key}\n\n${entries.map(monitoringLine).join("\n")}`;
454
+ });
455
+ return MARKDOWN_HEADER + "# Monitoring Log\n\n" + sections.join("\n\n") + "\n";
456
+ }
457
+
458
+ function latestStateByConcern(records: readonly MaterializationRecord[]): Map<string, MaterializationRecord> {
459
+ const latest = new Map<string, MaterializationRecord>();
460
+ for (const entry of records) {
461
+ if (entry.role !== "state") continue;
462
+ const key = concernKey(entry);
463
+ const current = latest.get(key);
464
+ if (current === undefined || chronological(current, entry) < 0) latest.set(key, entry);
465
+ }
466
+ return latest;
467
+ }
468
+
469
+ /**
470
+ * Doctor-prep summary: current monitoring state per concern followed by the
471
+ * full chronological monitoring history. Consumes the doctor-prep declaration
472
+ * records only to learn the target paths; the content derives entirely from
473
+ * active monitoring state plus chronological events.
474
+ */
475
+ export function renderDoctorPrepSummary(
476
+ declarations: readonly MaterializationRecord[],
477
+ monitoring: readonly MaterializationRecord[],
478
+ ): string {
479
+ void declarations;
480
+ const states = latestStateByConcern(monitoring);
481
+ const events = [...monitoring].filter((entry) => entry.role !== "state").sort(chronological);
482
+ const sections = [...states.entries()]
483
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
484
+ .map(([key, state]) => {
485
+ const status = asString(state.value.status);
486
+ const note = asString(state.value.note);
487
+ const summaryLines = [`Current state: ${status ?? "unknown"}${note === null ? "" : ` — ${note}`}`];
488
+ const concernEvents = events.filter((entry) => concernKey(entry) === key);
489
+ if (concernEvents.length > 0) {
490
+ summaryLines.push("", concernEvents.map(monitoringLine).join("\n"));
491
+ }
492
+ return `## ${key}\n\n${summaryLines.join("\n")}`;
493
+ });
494
+ return MARKDOWN_HEADER + "# Doctor Preparation Summary\n\n" + sections.join("\n\n") + "\n";
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Orchestration
499
+ // ---------------------------------------------------------------------------
500
+
501
+ type DesiredView = { kind: ArtifactKind; relativePath: string; absoluteTarget: string; content: string };
502
+
503
+ function pathWithinArtifactRoot(kind: ArtifactKind, relativePath: string): string {
504
+ return kind === "prescription"
505
+ ? relativePath.replace(/^prescriptions\//, "")
506
+ : relativePath;
507
+ }
508
+
509
+ /**
510
+ * Computes the complete desired view set from the active record set.
511
+ * Render-level problems (invalid values, inconsistent metadata) surface as
512
+ * stale entries keyed by relative path instead of aborting other views.
513
+ */
514
+ export function computeDesiredViews(
515
+ records: readonly KnowledgeRecord[],
516
+ appliedAt: string,
517
+ coachingDocsRoot: string,
518
+ prescriptionsRoot: string,
519
+ ): { views: DesiredView[]; stale: Array<{ path: string; reason: string }> } {
520
+ const usable = selectMaterializableRecords(records, appliedAt);
521
+
522
+ const byKind = new Map<ArtifactKind, Map<string, MaterializationRecord[]>>();
523
+ for (const entry of usable) {
524
+ let paths = byKind.get(entry.artifactKind);
525
+ if (paths === undefined) {
526
+ paths = new Map();
527
+ byKind.set(entry.artifactKind, paths);
528
+ }
529
+ const group = paths.get(entry.relativePath);
530
+ if (group === undefined) paths.set(entry.relativePath, [entry]);
531
+ else group.push(entry);
532
+ }
533
+
534
+ const stale: Array<{ path: string; reason: string }> = [];
535
+ const renderInto = (
536
+ kind: ArtifactKind,
537
+ relativePath: string,
538
+ group: MaterializationRecord[],
539
+ render: () => { ok: true; content: string } | { ok: false; reason: string },
540
+ ) => {
541
+ const outcome = render();
542
+ if (outcome.ok) {
543
+ const root = kind === "prescription" ? prescriptionsRoot : coachingDocsRoot;
544
+ views.push({
545
+ kind,
546
+ relativePath,
547
+ absoluteTarget: resolve(root, pathWithinArtifactRoot(kind, relativePath)),
548
+ content: outcome.content,
549
+ });
550
+ } else {
551
+ stale.push({ path: relativePath, reason: outcome.reason });
552
+ }
553
+ };
554
+
555
+ const views: DesiredView[] = [];
556
+ for (const [kind, paths] of byKind) {
557
+ for (const [relativePath, group] of paths) {
558
+ if (kind === "prescription") {
559
+ renderInto(kind, relativePath, group, () => renderPrescriptionView(group));
560
+ } else if (kind === "consultation") {
561
+ renderInto(kind, relativePath, group, () => ({ ok: true, content: renderConsultationLog(group) }));
562
+ } else if (kind === "adaptation") {
563
+ renderInto(kind, relativePath, group, () => ({ ok: true, content: renderAdaptationLog(group) }));
564
+ } else if (kind === "monitoring") {
565
+ renderInto(kind, relativePath, group, () => ({ ok: true, content: renderMonitoringLog(group) }));
566
+ }
567
+ }
568
+ }
569
+
570
+ const declarations = usable.filter((entry) => entry.artifactKind === "doctor-prep");
571
+ const monitoring = usable.filter((entry) => entry.artifactKind === "monitoring");
572
+ for (const declaration of declarations) {
573
+ views.push({
574
+ kind: "doctor-prep",
575
+ relativePath: declaration.relativePath,
576
+ absoluteTarget: resolve(coachingDocsRoot, declaration.relativePath),
577
+ content: renderDoctorPrepSummary(declarations, monitoring),
578
+ });
579
+ }
580
+
581
+ // Deterministic write order regardless of record or grouping iteration order.
582
+ views.sort((left, right) =>
583
+ left.absoluteTarget < right.absoluteTarget ? -1 : left.absoluteTarget > right.absoluteTarget ? 1 : 0,
584
+ );
585
+ return { views, stale };
586
+ }
587
+
588
+ function resolveRoot(dir: string, projectRoot: string): string {
589
+ return isAbsolute(dir) ? dir : resolve(projectRoot, dir);
590
+ }
591
+
592
+ /**
593
+ * Regenerates every compatibility view from the active record set. Loads
594
+ * records once, computes the complete desired view set, sorts artifact
595
+ * operations by absolute target, and applies each through the host's
596
+ * byte-comparing replacement. Never touches qmd. One artifact failure does
597
+ * not stop the others: failures land in `stale` and retry idempotently.
598
+ * The optional injected config exists for tests; production callers omit it.
599
+ */
600
+ export async function materialize(
601
+ appliedPlan: AppliedCapturePlan,
602
+ tools: MaterializeTools,
603
+ options: { config?: EngramCoachRuntimeConfig } = {},
604
+ ): Promise<MaterializationResult> {
605
+ void appliedPlan;
606
+ const config = options.config ?? await loadEngramCoachConfig();
607
+ const prescriptionsRoot = resolveRoot(config.prescriptionsDir, tools.projectRoot);
608
+ const coachingDocsRoot = resolveRoot(config.coachingDocsDir, tools.projectRoot);
609
+ const records = await tools.listRecords();
610
+ const { views, stale } = computeDesiredViews(
611
+ records,
612
+ tools.appliedAt,
613
+ coachingDocsRoot,
614
+ prescriptionsRoot,
615
+ );
616
+
617
+ const written: ArtifactReplacementResult[] = [];
618
+ const unchanged: ArtifactReplacementResult[] = [];
619
+ const staleResults = [...stale];
620
+ for (const view of views) {
621
+ try {
622
+ const outcome = await tools.replaceArtifact({
623
+ root: view.kind === "prescription" ? prescriptionsRoot : coachingDocsRoot,
624
+ relativePath: pathWithinArtifactRoot(view.kind, view.relativePath),
625
+ content: view.content,
626
+ });
627
+ if (outcome.status === "replaced") written.push(outcome);
628
+ else unchanged.push(outcome);
629
+ } catch (error) {
630
+ staleResults.push({
631
+ path: view.relativePath,
632
+ reason: error instanceof Error ? error.message : String(error),
633
+ });
634
+ }
635
+ }
636
+ return { written, unchanged, stale: staleResults };
637
+ }
638
+