@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.
- package/README.md +85 -18
- package/SETUP.md +559 -0
- package/SKILL_PACK.md +75 -0
- package/analyses/catalog.md +257 -0
- package/analysis-tools/hrv-trend.ts +592 -0
- package/analysis-tools/migrate-structured-capture.ts +234 -0
- package/analysis-tools/race-context.ts +96 -0
- package/analysis-tools/stream-analyze.ts +1008 -0
- package/analysis-tools/tsb-predict.ts +117 -0
- package/capture-handler.ts +301 -0
- package/config.json.example +21 -0
- package/engram-coach-ambient-capture.ts +336 -0
- package/engram-coach-capture-types.ts +185 -0
- package/engram-coach-config.ts +268 -0
- package/engram-coach-domain.ts +7 -2
- package/engram-coach-keys.ts +189 -0
- package/engram-coach-materialization.ts +638 -0
- package/engram-coach-migration.ts +1078 -0
- package/engram-coach-pack.ts +17 -12
- package/engram-coach-presentation.ts +10 -1
- package/engram-coach-reconciliation.ts +305 -2
- package/engram-coach-structured-capture.ts +622 -0
- package/package.json +39 -6
- package/personas/aggressive-monitoring.md +121 -0
- package/personas/aggressive.json +85 -0
- package/personas/conservative-monitoring.md +133 -0
- package/personas/conservative.json +93 -0
- package/personas/polarized-monitoring.md +112 -0
- package/personas/polarized.json +72 -0
- package/personas/volume-monitoring.md +85 -0
- package/personas/volume.json +108 -0
- package/shared/retrieval.md +71 -0
- package/shared/setup.md +207 -0
- package/skills/.gitkeep +0 -0
- package/skills/adapt-plan/SKILL.md +263 -0
- package/skills/block-review/SKILL.md +275 -0
- package/skills/consult/SKILL.md +176 -0
- package/skills/intake/SKILL.md +315 -0
- package/skills/lactate-analyze/SKILL.md +230 -0
- package/skills/lessons-rollup/SKILL.md +196 -0
- package/skills/monitoring-rollup/SKILL.md +208 -0
- package/skills/race-analysis/SKILL.md +219 -0
- package/skills/season-retrospective/SKILL.md +200 -0
- package/skills/set-goal/SKILL.md +297 -0
- package/templates/base.md +55 -0
- package/templates/build-1.md +57 -0
- package/templates/build-2.md +62 -0
- package/templates/race-report.md +51 -0
- package/templates/race-specificity.md +62 -0
- package/templates/season-review.md +40 -0
- 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
|
+
|