@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,1078 @@
1
+ /**
2
+ * engram-coach idempotent legacy migration planning.
3
+ *
4
+ * Two concerns, both dry-run by default:
5
+ *
6
+ * 1. Baseline ID insertion — every prescription session gains a durable
7
+ * `session_id` (inserted before `week`) and every compatibility file
8
+ * gains the exact generated-warning header, preserving every other byte
9
+ * (comments, quoting, ordering). Application is hash-bound: the caller
10
+ * must present the `afterHash` shown during preview, and the on-disk
11
+ * bytes must still hash to the previewed `beforeHash`.
12
+ * 2. Legacy import planning — existing prescription sessions become ONE
13
+ * state change each; consultation history becomes append-only events.
14
+ * Source identity derives from the normalized relative path plus the
15
+ * YAML/markdown entry index, so reruns are stable and never duplicate.
16
+ *
17
+ * @module engram-coach-migration
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
22
+ import { basename, dirname, join } from "node:path";
23
+ import { isMap, isScalar, isSeq, parseDocument } from "yaml";
24
+ import type { YAMLMap } from "yaml";
25
+ import type { JsonObject, JsonValue } from "@isparling/engram-harness/knowledge-types";
26
+ import {
27
+ SCHEMA_VERSION,
28
+ type StructuredChangeSet,
29
+ type StructuredEvent,
30
+ type StructuredStateChange,
31
+ } from "./engram-coach-capture-types.ts";
32
+
33
+ /** Exact generated warning headers, byte-identical to the materializers. */
34
+ export const GENERATED_PRESCRIPTION_HEADER =
35
+ "# GENERATED FROM ENGRAM ACTIVE RECORDS. DO NOT EDIT DIRECTLY.\n";
36
+ export const GENERATED_MARKDOWN_HEADER =
37
+ "<!-- GENERATED FROM ENGRAM ACTIVE RECORDS. DO NOT EDIT DIRECTLY. -->\n";
38
+
39
+ /** Skill recorded on migration change sets (import runs through intake). */
40
+ export const MIGRATION_SKILL = "intake" as const;
41
+
42
+ /** Raised on structurally invalid legacy input or refused applications. */
43
+ export class MigrationError extends Error {}
44
+
45
+ function sha256Hex(text: string): string {
46
+ return createHash("sha256").update(text).digest("hex");
47
+ }
48
+
49
+ /**
50
+ * Deterministic workout identity for a legacy session with no explicit
51
+ * `session_id`: derived from the normalized relative path plus the
52
+ * zero-based session index, so the same file always yields the same ID.
53
+ */
54
+ export function migratedSessionId(relativePath: string, index: number): string {
55
+ const sourceId = `prescription:${relativePath}#sessions/${index}`;
56
+ return `workout-${createHash("sha256").update(sourceId).digest("hex").slice(0, 16)}`;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Baseline planning (stable IDs + warning headers)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export type BaselineFilePlan = {
64
+ /** Path of the file relative to its artifact root. */
65
+ relativePath: string;
66
+ beforeHash: string;
67
+ afterHash: string;
68
+ beforeText: string;
69
+ afterText: string;
70
+ changed: boolean;
71
+ };
72
+
73
+ type IdInsertion = { offset: number; text: string };
74
+
75
+ /**
76
+ * Plans one prescription baseline: prepend the generated header when
77
+ * missing, assign deterministic `session_id` values before `week` for every
78
+ * session lacking one, preserve every other byte. Duplicate explicit IDs
79
+ * fail hard instead of silently renaming.
80
+ */
81
+ export function planPrescriptionBaseline(relativePath: string, text: string): BaselineFilePlan {
82
+ const doc = parseDocument(text);
83
+ if (doc.errors.length > 0) {
84
+ throw new MigrationError(`${relativePath}: unparseable prescription YAML (${doc.errors[0]?.message ?? "unknown"})`);
85
+ }
86
+ const sessions = doc.get("sessions", true);
87
+ if (!isSeq(sessions)) {
88
+ throw new MigrationError(`${relativePath}: no sessions list`);
89
+ }
90
+
91
+ const explicitIds: string[] = [];
92
+ for (const item of sessions.items) {
93
+ if (!isMap(item)) continue;
94
+ const raw = item.get("session_id", true);
95
+ if (raw !== undefined && typeof raw.value === "string" && raw.value.trim().length > 0) {
96
+ explicitIds.push(raw.value);
97
+ }
98
+ }
99
+ const seen = new Set<string>();
100
+ for (const id of explicitIds) {
101
+ if (seen.has(id)) {
102
+ throw new MigrationError(`${relativePath}: duplicate session_id "${id}" — fix the source file by hand`);
103
+ }
104
+ seen.add(id);
105
+ }
106
+
107
+ const insertions: IdInsertion[] = [];
108
+ sessions.items.forEach((item, index) => {
109
+ if (!isMap(item)) return;
110
+ // Validate every pair up front — before any early return — because a
111
+ // keyless pair anywhere in the session mapping makes the source
112
+ // structurally unusable even when that session already has an ID and
113
+ // `week` would anchor fine.
114
+ for (const pair of item.items) {
115
+ const key = pair.key;
116
+ // YAML admits two no-key shapes: a pair with no key node at all and
117
+ // an explicitly empty (`:`-only) key. Either makes the session
118
+ // structurally unusable, so raise instead of silently skipping.
119
+ if (key == null || (isScalar(key) && key.value == null)) {
120
+ throw new MigrationError(`${relativePath}: session ${index} has a malformed entry with no key`);
121
+ }
122
+ }
123
+ const existing = item.get("session_id", true);
124
+ if (existing !== undefined && typeof existing.value === "string" && existing.value.trim().length > 0) return;
125
+ const offset = findKeyOffset(item, "week");
126
+ if (offset === null) {
127
+ throw new MigrationError(`${relativePath}: session ${index} has no week field to anchor session_id`);
128
+ }
129
+ const indent = lineIndent(text, offset);
130
+ const id = migratedSessionId(relativePath, index);
131
+ if (seen.has(id)) {
132
+ throw new MigrationError(`${relativePath}: derived session_id "${id}" collides with an existing ID`);
133
+ }
134
+ seen.add(id);
135
+ insertions.push({ offset, text: `session_id: ${id}\n${indent}` });
136
+ });
137
+
138
+ let body = text;
139
+ for (const insertion of [...insertions].sort((left, right) => right.offset - left.offset)) {
140
+ body = body.slice(0, insertion.offset) + insertion.text + body.slice(insertion.offset);
141
+ }
142
+ const afterText = text.startsWith(GENERATED_PRESCRIPTION_HEADER) ? body : GENERATED_PRESCRIPTION_HEADER + body;
143
+ const changed = afterText !== text;
144
+ return {
145
+ relativePath,
146
+ beforeHash: sha256Hex(text),
147
+ afterHash: sha256Hex(afterText),
148
+ beforeText: text,
149
+ afterText,
150
+ changed,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Plans a Markdown compatibility-log baseline: only the generated warning
156
+ * header is added when missing; content, entries, and comments are untouched.
157
+ */
158
+ export function planMarkdownLogBaseline(relativePath: string, text: string): BaselineFilePlan {
159
+ const afterText = text.startsWith(GENERATED_MARKDOWN_HEADER) ? text : GENERATED_MARKDOWN_HEADER + text;
160
+ const changed = afterText !== text;
161
+ return {
162
+ relativePath,
163
+ beforeHash: sha256Hex(text),
164
+ afterHash: sha256Hex(afterText),
165
+ beforeText: text,
166
+ afterText,
167
+ changed,
168
+ };
169
+ }
170
+
171
+ /** Start offset of the scalar key exactly equal to `name` on a parsed mapping, or null when no pair provides a usable anchor. */
172
+ function findKeyOffset(map: YAMLMap, name: string): number | null {
173
+ for (const pair of map.items) {
174
+ const key = pair.key;
175
+ if (key === null || !isScalar(key) || key.value !== name || key.range == null) continue;
176
+ return key.range[0];
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /** Whitespace prefix of the line containing `offset`. */
182
+ function lineIndent(text: string, offset: number): string {
183
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
184
+ // Continuation lines must align with the anchor key's column, not the
185
+ // line's leading whitespace — `week` may sit after a `- ` sequence dash.
186
+ return " ".repeat(Math.max(0, offset - lineStart));
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Scan / apply over artifact roots
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export type BaselineRoots = {
194
+ prescriptionsDir: string;
195
+ coachingDocsDir: string;
196
+ };
197
+
198
+ export type BaselineScan = {
199
+ /** Artifact roots captured at scan time so apply-baseline needs no config. */
200
+ roots: BaselineRoots;
201
+ /** Aggregate hash binding the whole plan: SHA-256 over canonical JSON of per-file `{relativePath, afterHash}` rows. */
202
+ afterHash: string;
203
+ files: Array<{ rootKind: "prescriptions" | "coaching-docs"; plan: BaselineFilePlan }>;
204
+ };
205
+
206
+ const CONSULTATION_LOG_RELATIVE_PATH = "coaching/consultations.md";
207
+
208
+ async function readIfExists(path: string): Promise<string | null> {
209
+ try {
210
+ return await readFile(path, "utf8");
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Dry-run scan of every legacy prescription, compatibility log, and
218
+ * registry-declared monitoring artifact (concern logs plus Doctor-Prep
219
+ * summaries) under the configured roots. Never mutates anything.
220
+ */
221
+ export async function scanBaseline(roots: BaselineRoots): Promise<BaselineScan> {
222
+ const files: BaselineScan["files"] = [];
223
+ let names: string[] = [];
224
+ try {
225
+ names = await readdir(roots.prescriptionsDir);
226
+ } catch {
227
+ names = [];
228
+ }
229
+ for (const name of names.filter((entry) => /\.ya?ml$/.test(entry)).sort()) {
230
+ const text = await readIfExists(join(roots.prescriptionsDir, name));
231
+ if (text === null) continue;
232
+ files.push({ rootKind: "prescriptions", plan: planPrescriptionBaseline(name, text) });
233
+ }
234
+ const consultations = await readIfExists(join(roots.coachingDocsDir, CONSULTATION_LOG_RELATIVE_PATH));
235
+ if (consultations !== null) {
236
+ files.push({
237
+ rootKind: "coaching-docs",
238
+ plan: planMarkdownLogBaseline(CONSULTATION_LOG_RELATIVE_PATH, consultations),
239
+ });
240
+ }
241
+
242
+ // Registry-driven monitoring artifacts join the SAME hash-bound baseline
243
+ // plan: every declared concern log and Doctor-Prep Summary gets the
244
+ // generated header (header-only, content untouched) before cutover.
245
+ const declarations = await readConcernRegistry(roots.coachingDocsDir);
246
+ const monitoringPaths = [
247
+ ...new Set(
248
+ declarations.flatMap((declaration) =>
249
+ [declaration.logPath, declaration.doctorPrepPath].filter(
250
+ (path): path is string => path !== null,
251
+ ),
252
+ ),
253
+ ),
254
+ ].sort();
255
+ for (const relativePath of monitoringPaths) {
256
+ const text = await readIfExists(join(roots.coachingDocsDir, relativePath));
257
+ if (text === null) continue;
258
+ files.push({ rootKind: "coaching-docs", plan: planMarkdownLogBaseline(relativePath, text) });
259
+ }
260
+
261
+ const afterHash = scanAfterHash(files);
262
+ return { roots, afterHash, files };
263
+ }
264
+
265
+ function scanAfterHash(files: BaselineScan["files"]): string {
266
+ const rows = files.map((entry) => ({
267
+ relativePath: entry.plan.relativePath,
268
+ afterHash: entry.plan.afterHash,
269
+ }));
270
+ return sha256Hex(JSON.stringify(rows));
271
+ }
272
+
273
+ export type AppliedBaselineEntry = { relativePath: string; written: boolean };
274
+ export type AppliedBaseline = {
275
+ afterHash: string;
276
+ written: AppliedBaselineEntry[];
277
+ unchanged: string[];
278
+ };
279
+
280
+ /**
281
+ * Applies a scanned baseline to disk. Refuses unless the presented hash
282
+ * matches the scan's aggregate `afterHash` AND every file still hashes to
283
+ * its planned `beforeHash` — any drift means the athlete must re-scan.
284
+ */
285
+ export async function applyBaseline(
286
+ scan: BaselineScan,
287
+ expectAfterHash: string,
288
+ ): Promise<AppliedBaseline> {
289
+ const roots = scan.roots;
290
+ if (expectAfterHash !== scan.afterHash) {
291
+ throw new MigrationError(
292
+ `after-hash mismatch: plan expects ${scan.afterHash}, got ${expectAfterHash} — re-run scan`,
293
+ );
294
+ }
295
+ // Plan self-integrity: every planned text must still hash to its own
296
+ // afterHash, so a tampered or corrupted plan file is refused even when its
297
+ // aggregate hash was recomputed consistently around the tampering.
298
+ const inconsistent = scan.files.filter((entry) => sha256Hex(entry.plan.afterText) !== entry.plan.afterHash);
299
+ if (inconsistent.length > 0) {
300
+ throw new MigrationError(
301
+ `refusing to apply: plan afterText does not match its own afterHash for: ${inconsistent.map((e) => e.plan.relativePath).join(", ")}`,
302
+ );
303
+ }
304
+ const stale: string[] = [];
305
+ for (const entry of scan.files) {
306
+ const absolute = entry.rootKind === "prescriptions"
307
+ ? join(roots.prescriptionsDir, basenamePrescription(entry.plan.relativePath))
308
+ : join(roots.coachingDocsDir, entry.plan.relativePath);
309
+ const current = await readFile(absolute, "utf8").catch(() => null);
310
+ if (current === null || sha256Hex(current) !== entry.plan.beforeHash) {
311
+ stale.push(entry.plan.relativePath);
312
+ }
313
+ }
314
+ if (stale.length > 0) {
315
+ throw new MigrationError(`refusing to apply: source files changed since scan: ${stale.join(", ")}`);
316
+ }
317
+ const written: AppliedBaselineEntry[] = [];
318
+ const unchanged: string[] = [];
319
+ for (const entry of scan.files) {
320
+ if (!entry.plan.changed) {
321
+ unchanged.push(entry.plan.relativePath);
322
+ continue;
323
+ }
324
+ const absolute = entry.rootKind === "prescriptions"
325
+ ? join(roots.prescriptionsDir, basenamePrescription(entry.plan.relativePath))
326
+ : join(roots.coachingDocsDir, entry.plan.relativePath);
327
+ await mkdir(dirname(absolute), { recursive: true });
328
+ await writeFile(absolute, entry.plan.afterText, "utf8");
329
+ written.push({ relativePath: entry.plan.relativePath, written: true });
330
+ }
331
+ return { afterHash: scan.afterHash, written, unchanged };
332
+ }
333
+
334
+ /** Prescription plans are keyed by bare filename within the flat prescriptions dir. */
335
+ function basenamePrescription(relativePath: string): string {
336
+ return basename(relativePath);
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Legacy import planning (StructuredChangeSet)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function migrationSourceSessionId(relativePath: string): string {
344
+ const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 16);
345
+ return `migration-${digest}`;
346
+ }
347
+
348
+ function singleLine(raw: string): string {
349
+ return raw.replace(/[\r\n]+/g, " ").trim();
350
+ }
351
+
352
+ function asString(value: unknown): string | null {
353
+ return typeof value === "string" ? value : null;
354
+ }
355
+
356
+ function asNumber(value: unknown): number | null {
357
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
358
+ }
359
+
360
+ /**
361
+ * Recursively converts an unknown value into the JSON data model. Returns
362
+ * undefined when the value itself is outside the model (undefined, function,
363
+ * symbol, bigint, non-finite number); nested out-of-model entries are dropped.
364
+ * Cyclic references — reachable from YAML anchors/aliases — are dropped the
365
+ * same way, via the set of containers on the current conversion path.
366
+ */
367
+ function toJsonValue(value: unknown, ancestors: Set<object> = new Set()): JsonValue | undefined {
368
+ if (
369
+ value === null ||
370
+ typeof value === "string" ||
371
+ typeof value === "boolean" ||
372
+ (typeof value === "number" && Number.isFinite(value))
373
+ ) {
374
+ return value;
375
+ }
376
+ if (typeof value !== "object" || ancestors.has(value)) return undefined;
377
+ ancestors.add(value);
378
+ try {
379
+ if (Array.isArray(value)) {
380
+ const entries: JsonValue[] = [];
381
+ for (const entry of value) {
382
+ const converted = toJsonValue(entry, ancestors);
383
+ if (converted !== undefined) entries.push(converted);
384
+ }
385
+ return entries;
386
+ }
387
+ const objectValue: JsonObject = {};
388
+ for (const [key, entry] of Object.entries(value)) {
389
+ const converted = toJsonValue(entry, ancestors);
390
+ if (converted !== undefined) objectValue[key] = converted;
391
+ }
392
+ return objectValue;
393
+ } finally {
394
+ ancestors.delete(value);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Converts one parsed legacy session into the camelCase materializer value
400
+ * shape (`details.value`), carrying provenance metadata alongside.
401
+ */
402
+ function sessionValue(
403
+ raw: { [key: string]: unknown },
404
+ meta: { arcId: string; sessionId: string; order: number; relativePath: string },
405
+ docMeta: { blockName: string; goal: JsonValue | null },
406
+ ): JsonObject {
407
+ const value: JsonObject = {
408
+ blockName: docMeta.blockName,
409
+ sessionId: meta.sessionId,
410
+ order: meta.order,
411
+ week: asNumber(raw.week),
412
+ day: singleLine(asString(raw.day) ?? ""),
413
+ sessionDate: asString(raw.session_date) ?? "",
414
+ sessionName: singleLine(asString(raw.session_name) ?? ""),
415
+ };
416
+ if (asString(raw.modality) !== null) value.modality = raw.modality as string;
417
+ if (asNumber(raw.total_duration_min) !== null) value.totalDurationMin = raw.total_duration_min as number;
418
+ if (asString(raw.effort_zone) !== null) value.effortZone = raw.effort_zone as string;
419
+ // Document-level goal wins; a per-session goal is honored as fallback.
420
+ // toJsonValue cuts alias cycles instead of overflowing on them.
421
+ const goalSource = docMeta.goal !== null ? docMeta.goal : raw.goal;
422
+ if (goalSource !== undefined && goalSource !== null && typeof goalSource === "object") {
423
+ const goalValue = toJsonValue(goalSource);
424
+ if (goalValue !== undefined) value.goal = goalValue;
425
+ }
426
+ const warmup = powerBandField(raw.warmup_power_low_pct, raw.warmup_power_high_pct);
427
+ if (warmup !== null) value.warmup = warmup;
428
+ const cooldown = powerBandField(raw.cooldown_power_low_pct, raw.cooldown_power_high_pct);
429
+ if (cooldown !== null) value.cooldown = cooldown;
430
+ if (Array.isArray(raw.intervals)) {
431
+ value.intervals = raw.intervals.map((interval) => intervalValue(interval));
432
+ }
433
+ value.artifactRelativePath = meta.relativePath;
434
+ value.sourcePath = meta.relativePath;
435
+ return value;
436
+ }
437
+
438
+ function intervalValue(raw: unknown): JsonObject {
439
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
440
+ const interval = raw as { [key: string]: unknown };
441
+ const value: JsonObject = {
442
+ durationMin: asNumber(interval.duration_min),
443
+ powerLowPct: asNumber(interval.power_low_pct),
444
+ powerHighPct: asNumber(interval.power_high_pct),
445
+ count: asNumber(interval.count),
446
+ recoveryMin: asNumber(interval.recovery_min),
447
+ };
448
+ if (asNumber(interval.recovery_power_low_pct) !== null) {
449
+ value.recoveryPowerLowPct = interval.recovery_power_low_pct as number;
450
+ }
451
+ if (asNumber(interval.recovery_power_high_pct) !== null) {
452
+ value.recoveryPowerHighPct = interval.recovery_power_high_pct as number;
453
+ }
454
+ return value;
455
+ }
456
+
457
+ function powerBandField(low: unknown, high: unknown): JsonObject | null {
458
+ if (asNumber(low) === null && asNumber(high) === null) return null;
459
+ const band: JsonObject = {};
460
+ if (asNumber(low) !== null) band.powerLowPct = low as number;
461
+ if (asNumber(high) !== null) band.powerHighPct = high as number;
462
+ return band;
463
+ }
464
+
465
+ /** Arc ID for one prescription file: the bare filename without extension. */
466
+ export function arcIdFor(relativePath: string): string {
467
+ return basename(relativePath).replace(/\.ya?ml$/, "");
468
+ }
469
+
470
+ /**
471
+ * Maps every session of one legacy prescription to exactly ONE state change
472
+ * with full `details.value`, arc/session key components, block metadata, and
473
+ * order. Throws {@link MigrationError} on structurally unusable input.
474
+ */
475
+ export function planPrescriptionImport(relativePath: string, text: string): StructuredStateChange[] {
476
+ const doc = parseDocument(text);
477
+ if (doc.errors.length > 0) {
478
+ throw new MigrationError(`${relativePath}: unparseable prescription YAML (${doc.errors[0]?.message ?? "unknown"})`);
479
+ }
480
+ const parsed = doc.toJS() as { [key: string]: unknown };
481
+ const blockName = asString(parsed.block_name);
482
+ const rawSessions = parsed.sessions;
483
+ if (!Array.isArray(rawSessions) || rawSessions.length === 0) {
484
+ throw new MigrationError(`${relativePath}: no sessions list`);
485
+ }
486
+ const arcId = arcIdFor(relativePath);
487
+ return rawSessions.map((rawUnknown, index): StructuredStateChange => {
488
+ if (typeof rawUnknown !== "object" || rawUnknown === null || Array.isArray(rawUnknown)) {
489
+ throw new MigrationError(`${relativePath}: session ${index} is not a mapping`);
490
+ }
491
+ const raw = rawUnknown as { [key: string]: unknown };
492
+ const sessionId = asString(raw.session_id)?.trim() || migratedSessionId(relativePath, index);
493
+ const effectiveAt = asString(raw.session_date);
494
+ if (effectiveAt === null || Number.isNaN(Date.parse(effectiveAt))) {
495
+ throw new MigrationError(`${relativePath}: session ${index} has no parseable session_date`);
496
+ }
497
+ const sessionName = singleLine(asString(raw.session_name) ?? `session ${index}`);
498
+ const statement = singleLine(`Legacy planned session ${sessionName} (${blockName ?? arcId}) on ${effectiveAt}`);
499
+ return {
500
+ entity_type: "prescription",
501
+ key_components: { arc_id: arcId, session_id: sessionId },
502
+ effective_at: effectiveAt,
503
+ statement,
504
+ details: sessionValue(raw, { arcId, sessionId, order: index, relativePath }, {
505
+ blockName: singleLine(blockName ?? arcId),
506
+ goal: typeof parsed.goal === "object" && parsed.goal !== null ? (parsed.goal as JsonValue) : null,
507
+ }),
508
+ };
509
+ });
510
+ }
511
+
512
+ const EARLIEST_ISO_DATE = /\d{4}-\d{2}-\d{2}(T[\d:.]+Z)?/;
513
+ const UNPARSEABLE_EFFECTIVE_AT = "1970-01-01";
514
+
515
+ function earliestDateIn(text: string): string {
516
+ const match = EARLIEST_ISO_DATE.exec(text);
517
+ const candidate = match?.[0];
518
+ return candidate !== undefined && !Number.isNaN(Date.parse(candidate)) ? candidate : UNPARSEABLE_EFFECTIVE_AT;
519
+ }
520
+
521
+ /**
522
+ * Maps legacy consultation history to append-only events, one per
523
+ * `## `-heading entry; when no entry boundaries exist the entire file is
524
+ * imported as ONE event carrying `details.value.legacyMarkdown` verbatim
525
+ * rather than inventing structure.
526
+ */
527
+ export function planConsultationImport(relativePath: string, text: string): StructuredEvent[] {
528
+ const content = text.startsWith(GENERATED_MARKDOWN_HEADER)
529
+ ? text.slice(GENERATED_MARKDOWN_HEADER.length)
530
+ : text;
531
+ if (content.trim().length === 0) return [];
532
+ const lines = content.split("\n");
533
+ const headings: number[] = [];
534
+ for (const [index, line] of lines.entries()) {
535
+ if (/^##\s+\S/.test(line)) headings.push(index);
536
+ }
537
+ if (headings.length === 0) {
538
+ return [
539
+ {
540
+ entity_type: "consultation",
541
+ effective_at: earliestDateIn(content),
542
+ statement: "Legacy consultation history imported verbatim",
543
+ action_targets: [],
544
+ details: { legacyMarkdown: content, sourcePath: relativePath },
545
+ },
546
+ ];
547
+ }
548
+ return headings.map((start, position): StructuredEvent => {
549
+ const end = position + 1 < headings.length ? headings[position + 1] : lines.length;
550
+ const headingRest = singleLine(lines[start].replace(/^##\s+/, ""));
551
+ const firstToken = headingRest.split(/\s+/)[0] ?? "";
552
+ const effectiveAt = !Number.isNaN(Date.parse(firstToken)) ? firstToken : earliestDateIn(lines.slice(start, end).join("\n"));
553
+ const remainder = headingRest.slice(firstToken.length).replace(/^\s*[—-]\s*/, "").trim();
554
+ const body = lines.slice(start + 1, end).join("\n").replace(/^\n/, "").trimEnd();
555
+ const value: JsonObject = { legacyMarkdown: body.length > 0 ? body : headingRest, sourcePath: relativePath };
556
+ if (remainder.length > 0 && remainder !== headingRest) value.title = remainder;
557
+ return {
558
+ entity_type: "consultation",
559
+ effective_at: effectiveAt,
560
+ statement: remainder.length > 0 ? remainder : `Legacy consultation entry ${position}`,
561
+ action_targets: [],
562
+ details: value,
563
+ };
564
+ });
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Monitoring migration — registry-driven concern log / doctor-prep planning
569
+ // ---------------------------------------------------------------------------
570
+
571
+ /** One declared monitoring concern from `tracking/concerns.yaml`. */
572
+ export type MonitoringConcernDeclaration = {
573
+ concernId: string;
574
+ active: boolean;
575
+ /** Concern log path relative to the coaching docs root. */
576
+ logPath: string;
577
+ /** Declared Doctor-Prep Summary path, when the registry declares one. */
578
+ doctorPrepPath: string | null;
579
+ };
580
+
581
+ const CONCERNS_REGISTRY_RELPATH = "tracking/concerns.yaml";
582
+
583
+ function scalarField(map: YAMLMap, name: string): string | null {
584
+ const value = map.get(name);
585
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
586
+ }
587
+
588
+ /**
589
+ * Reads the declared monitoring-concern registry. An absent registry is a
590
+ * clean no-op (`[]`); a malformed one raises instead of guessing.
591
+ */
592
+ export async function readConcernRegistry(
593
+ coachingDocsDir: string,
594
+ ): Promise<MonitoringConcernDeclaration[]> {
595
+ const text = await readIfExists(join(coachingDocsDir, CONCERNS_REGISTRY_RELPATH));
596
+ if (text === null) return [];
597
+ const doc = parseDocument(text);
598
+ if (doc.errors.length > 0) {
599
+ throw new MigrationError(`unparseable concerns registry (${doc.errors[0]?.message ?? "unknown"})`);
600
+ }
601
+ const root = doc.contents;
602
+ if (!isMap(root)) {
603
+ throw new MigrationError(`${CONCERNS_REGISTRY_RELPATH}: top level must be a mapping`);
604
+ }
605
+ const registryDoctorPrep = scalarField(root, "doctor_prep");
606
+ const seq = root.get("concerns");
607
+ if (seq === undefined) return [];
608
+ if (!isSeq(seq)) {
609
+ throw new MigrationError(`${CONCERNS_REGISTRY_RELPATH}: "concerns" must be a list`);
610
+ }
611
+ const declarations: MonitoringConcernDeclaration[] = [];
612
+ for (const item of seq.items) {
613
+ if (!isMap(item)) {
614
+ throw new MigrationError(`${CONCERNS_REGISTRY_RELPATH}: every concern must be a mapping`);
615
+ }
616
+ const concernId = scalarField(item, "id");
617
+ if (concernId === null) {
618
+ throw new MigrationError(`${CONCERNS_REGISTRY_RELPATH}: a concern is missing its id`);
619
+ }
620
+ const active = item.get("active", true);
621
+ const activeValue: unknown = isScalar(active) ? active.value : undefined;
622
+ declarations.push({
623
+ concernId,
624
+ active: activeValue !== false,
625
+ logPath: scalarField(item, "log") ?? `monitoring/${concernId}.md`,
626
+ doctorPrepPath: scalarField(item, "doctor_prep") ?? registryDoctorPrep,
627
+ });
628
+ }
629
+ return declarations;
630
+ }
631
+
632
+ // -- Entry parsing ----------------------------------------------------------
633
+
634
+ const MONITORING_SIGNAL_FALLBACK = "general";
635
+
636
+ /** Lower-case slug for human-readable signal names; empty input falls back. */
637
+ function slugSignal(raw: string): string {
638
+ const slug = raw
639
+ .trim()
640
+ .toLowerCase()
641
+ .replace(/\s+/g, "-")
642
+ .replace(/[^a-z0-9-]/g, "");
643
+ return slug.length > 0 ? slug : MONITORING_SIGNAL_FALLBACK;
644
+ }
645
+
646
+ type ParsedMonitoringEntry = {
647
+ role: "state" | "event";
648
+ effectiveAt: string;
649
+ concernId: string;
650
+ signal: string;
651
+ status: string | null;
652
+ note: string | null;
653
+ /** Verbatim body of an unparseable legacy entry — never re-derived. */
654
+ legacyMarkdown: string | null;
655
+ /** Document order, used only as a tie-break for equal effective times. */
656
+ order: number;
657
+ };
658
+
659
+ function tableCells(line: string): string[] {
660
+ return line
661
+ .replace(/^\s*\|/, "")
662
+ .replace(/\|\s*$/, "")
663
+ .split("|")
664
+ .map((cell) => cell.trim());
665
+ }
666
+
667
+ function isTableLine(line: string): boolean {
668
+ return /^\s*\|/.test(line);
669
+ }
670
+
671
+ /**
672
+ * Parses one legacy concern-log (or generated monitoring view) into ordered
673
+ * entries. Accepted forms:
674
+ * - Markdown tables: header row maps columns (`date`, `signal`, `status`,
675
+ * `notes`/`note`); each data row becomes one typed observation event.
676
+ * Unrecognized columns ride inside the note as `name=value` pairs so no
677
+ * data silently drops.
678
+ * - Generated grouped views: `## <concern> / <signal>` sections whose
679
+ * `- <date> state|event <sourceId> — status — note` bullets parse back
680
+ * into typed entries; any non-bullet prose block stays verbatim.
681
+ * - Dated freeform sections: `## <date> — title` prose becomes ONE event
682
+ * preserving the body in `legacyMarkdown`.
683
+ * `## Doctor-Prep Summary` sections are derived view content and are never
684
+ * imported.
685
+ */
686
+ export function planMonitoringEntries(
687
+ fallbackConcernId: string,
688
+ text: string,
689
+ ): ParsedMonitoringEntry[] {
690
+ const body = text.startsWith(GENERATED_MARKDOWN_HEADER)
691
+ ? text.slice(GENERATED_MARKDOWN_HEADER.length)
692
+ : text;
693
+ const lines = body.split("\n");
694
+
695
+ // Split into `## `-headed sections; leading content shares title null.
696
+ const sections: Array<{ title: string | null; start: number; end: number }> = [];
697
+ let currentTitle: string | null = null;
698
+ let currentStart = 0;
699
+ for (const [index, line] of lines.entries()) {
700
+ const heading = /^##\s+(.+?)\s*$/.exec(line);
701
+ if (heading !== null) {
702
+ sections.push({ title: currentTitle, start: currentStart, end: index });
703
+ currentTitle = heading[1] ?? "";
704
+ currentStart = index + 1;
705
+ }
706
+ }
707
+ sections.push({ title: currentTitle, start: currentStart, end: lines.length });
708
+
709
+ const entries: ParsedMonitoringEntry[] = [];
710
+ let order = 0;
711
+ const push = (entry: Omit<ParsedMonitoringEntry, "order">): void => {
712
+ entries.push({ ...entry, order: order });
713
+ order += 1;
714
+ };
715
+
716
+ for (const section of sections) {
717
+ const sectionLines = lines.slice(section.start, section.end);
718
+ if (section.title !== null && /doctor[- ]prep/i.test(section.title)) continue;
719
+
720
+ const grouped = section.title === null ? null : /^([^/]+)\s*\/\s*(.+)$/.exec(section.title);
721
+ if (grouped !== null) {
722
+ const concernId = (grouped[1] ?? "").trim();
723
+ const signal = slugSignal(grouped[2] ?? "");
724
+ let block: string[] = [];
725
+ const flushBlock = (): void => {
726
+ const prose = block.join("\n").trimEnd();
727
+ block = [];
728
+ if (prose.length === 0) return;
729
+ push({
730
+ role: "event",
731
+ effectiveAt: earliestDateIn(prose),
732
+ concernId,
733
+ signal,
734
+ status: null,
735
+ note: null,
736
+ legacyMarkdown: prose,
737
+ });
738
+ };
739
+ for (const line of sectionLines) {
740
+ // Rendered monitoring lines are `- <date> <role> — <sourceId> —
741
+ // status — note` (the renderer's first-dash rewrite); accept the
742
+ // sourceId with or without the leading dash for robustness.
743
+ const bullet = /^-\s+(\S+)\s+(state|event)\s+(?:—\s+)?\S+(?:\s+—\s+(.*))?$/.exec(line);
744
+ if (bullet !== null) {
745
+ flushBlock();
746
+ const dateToken = bullet[1] ?? "";
747
+ const remainder = bullet[3] ?? "";
748
+ const parts = remainder.split(" — ");
749
+ push({
750
+ role: bullet[2] === "state" ? "state" : "event",
751
+ effectiveAt: !Number.isNaN(Date.parse(dateToken))
752
+ ? dateToken
753
+ : earliestDateIn(line),
754
+ concernId,
755
+ signal,
756
+ status: parts[0]?.length ? parts[0] : null,
757
+ note: parts.length > 1 ? parts.slice(1).join(" — ") : null,
758
+ legacyMarkdown: null,
759
+ });
760
+ continue;
761
+ }
762
+ if (isTableLine(line)) {
763
+ flushBlock();
764
+ continue;
765
+ }
766
+ if (line.trim().length === 0) {
767
+ flushBlock();
768
+ continue;
769
+ }
770
+ block.push(line);
771
+ }
772
+ flushBlock();
773
+ continue;
774
+ }
775
+
776
+ // Legacy form: markdown tables plus dated freeform sections.
777
+ const headerRow = sectionLines.findIndex((line) => isTableLine(line));
778
+ if (headerRow >= 0) {
779
+ const headers = tableCells(sectionLines[headerRow] ?? "").map((name) => name.toLowerCase());
780
+ const column = (name: string): number => headers.indexOf(name);
781
+ const dateColumn = column("date");
782
+ const signalColumn = column("signal");
783
+ const statusColumn = column("status");
784
+ const noteColumn = headers.findIndex((name) => name === "notes" || name === "note");
785
+ for (const line of sectionLines.slice(headerRow + 1)) {
786
+ if (!isTableLine(line)) continue;
787
+ const cells = tableCells(line);
788
+ if (cells.every((cell) => /^[:\s-]*$/.test(cell))) continue; // separator row
789
+ const cellAt = (index: number): string | null =>
790
+ index >= 0 && index < cells.length && cells[index] !== undefined && cells[index] !== ""
791
+ ? cells[index]
792
+ : null;
793
+ const extras: string[] = [];
794
+ for (const [index, name] of headers.entries()) {
795
+ if ([dateColumn, signalColumn, statusColumn, noteColumn].includes(index)) continue;
796
+ const value = cellAt(index);
797
+ if (value !== null) extras.push(`${name}=${value}`);
798
+ }
799
+ const noteParts = [cellAt(noteColumn), ...extras].filter((part) => part !== null);
800
+ const date = cellAt(dateColumn);
801
+ push({
802
+ role: "event",
803
+ effectiveAt:
804
+ date !== null && !Number.isNaN(Date.parse(date))
805
+ ? date
806
+ : earliestDateIn(line),
807
+ concernId: fallbackConcernId,
808
+ signal: slugSignal(cellAt(signalColumn) ?? ""),
809
+ status: cellAt(statusColumn),
810
+ note: noteParts.length > 0 ? noteParts.join("; ") : null,
811
+ legacyMarkdown: null,
812
+ });
813
+ }
814
+ continue;
815
+ }
816
+
817
+ if (section.title !== null) {
818
+ const prose = sectionLines.filter((line) => !isTableLine(line)).join("\n").trim();
819
+ if (prose.length === 0) continue;
820
+ const headingRest = section.title.replace(/^##\s+/, "");
821
+ const firstToken = headingRest.split(/\s+/)[0] ?? "";
822
+ push({
823
+ role: "event",
824
+ effectiveAt: !Number.isNaN(Date.parse(firstToken))
825
+ ? firstToken
826
+ : earliestDateIn(prose),
827
+ concernId: fallbackConcernId,
828
+ signal: slugSignal(/signal:\s*([A-Za-z0-9 _-]+)/.exec(prose)?.[1] ?? ""),
829
+ status: null,
830
+ note: null,
831
+ legacyMarkdown: prose,
832
+ });
833
+ }
834
+ }
835
+ return entries;
836
+ }
837
+
838
+ function monitoringEventStatement(concernId: string, signal: string, effectiveAt: string): string {
839
+ return `Observed ${concernId} ${signal} on ${effectiveAt}`;
840
+ }
841
+
842
+ function monitoringStateStatement(concernId: string, signal: string, status: string | null): string {
843
+ return `Current ${concernId} ${signal} status: ${status ?? "unknown"}`;
844
+ }
845
+
846
+ /**
847
+ * Deterministic change-set session ID for one (log path, concern, signal)
848
+ * import partition: an item's identity therefore derives from the concern
849
+ * ID, its signal, the declared relative path, and the per-role entry index.
850
+ */
851
+ export function migratedMonitoringSourceSessionId(
852
+ relativePath: string,
853
+ concernId: string,
854
+ signal: string,
855
+ ): string {
856
+ return migrationSourceSessionId(`${relativePath}#${concernId}#${signal}`);
857
+ }
858
+
859
+ /**
860
+ * Plans the legacy monitoring import for ONE source file as one change set
861
+ * per (concern × signal) partition. Typed rows become append-only
862
+ * `monitoring-event` observations; when a signal has typed history but no
863
+ * explicit current-state entry, its newest row additionally becomes the
864
+ * keyed `monitoring:<concern-id>:<signal>` state. Unparseable bodies are
865
+ * preserved verbatim in `details.value.legacyMarkdown`.
866
+ *
867
+ * Identity anchoring: every partition hashes the concern's DECLARED legacy
868
+ * log path (from `concernLogPaths`, defaulting to the canonical
869
+ * `monitoring/<concern-id>.md`) plus its concern ID and signal, with a
870
+ * per-role entry index — so re-importing entries that moved into generated
871
+ * shared views yields identical identities and never duplicates.
872
+ */
873
+ export function planMonitoringImport(input: {
874
+ relativePath: string;
875
+ concernId: string;
876
+ text: string;
877
+ /** Declared concern-log path per concern ID (registry-driven override). */
878
+ concernLogPaths?: Record<string, string>;
879
+ }): StructuredChangeSet[] {
880
+ const parsed = planMonitoringEntries(input.concernId, input.text);
881
+ if (parsed.length === 0) return [];
882
+ const byPartition = new Map<string, { concernId: string; signal: string; entries: ParsedMonitoringEntry[] }>();
883
+ for (const entry of parsed) {
884
+ const key = `${entry.concernId}\u0000${entry.signal}`;
885
+ const group = byPartition.get(key);
886
+ if (group === undefined) byPartition.set(key, { concernId: entry.concernId, signal: entry.signal, entries: [entry] });
887
+ else group.entries.push(entry);
888
+ }
889
+
890
+ const changeSets: StructuredChangeSet[] = [];
891
+ for (const partitionKey of [...byPartition.keys()].sort()) {
892
+ const partition = byPartition.get(partitionKey);
893
+ if (partition === undefined) continue;
894
+ const identityPath = input.concernLogPaths?.[partition.concernId] ?? `monitoring/${partition.concernId}.md`;
895
+ const sorted = [...partition.entries].sort((left, right) => {
896
+ if (left.effectiveAt !== right.effectiveAt) {
897
+ return left.effectiveAt < right.effectiveAt ? -1 : 1;
898
+ }
899
+ return left.order - right.order;
900
+ });
901
+
902
+ const events = sorted
903
+ .filter((entry) => entry.role === "event")
904
+ .map((entry): StructuredEvent => {
905
+ const details: JsonObject = { concernId: entry.concernId, signal: entry.signal };
906
+ if (entry.status !== null) details["status"] = entry.status;
907
+ if (entry.note !== null) details["note"] = entry.note;
908
+ if (entry.legacyMarkdown !== null) details["legacyMarkdown"] = entry.legacyMarkdown;
909
+ details["sourcePath"] = input.relativePath;
910
+ return {
911
+ entity_type: "monitoring-event",
912
+ effective_at: entry.effectiveAt,
913
+ statement: monitoringEventStatement(entry.concernId, entry.signal, entry.effectiveAt),
914
+ action_targets: [],
915
+ details,
916
+ };
917
+ });
918
+
919
+ const explicitStates = sorted.filter((entry) => entry.role === "state");
920
+ const stateEntries: ParsedMonitoringEntry[] = [...explicitStates];
921
+ // Current-state derivation belongs to the concern's OWN log: shared
922
+ // generated views re-import history without inventing new state.
923
+ const derivesState = input.relativePath === identityPath;
924
+ if (derivesState && explicitStates.length === 0) {
925
+ // Legacy tables carry no explicit current state: the newest typed row
926
+ // per signal becomes it. Untyped (verbatim) bodies never invent state.
927
+ const newestTyped = [...sorted]
928
+ .reverse()
929
+ .find((entry) => entry.role === "event" && entry.legacyMarkdown === null);
930
+ if (newestTyped !== undefined) {
931
+ stateEntries.push({ ...newestTyped, role: "state", order: newestTyped.order });
932
+ }
933
+ }
934
+ const stateChanges = stateEntries.map((entry): StructuredStateChange => {
935
+ const details: JsonObject = { concernId: entry.concernId, signal: entry.signal };
936
+ if (entry.status !== null) details["status"] = entry.status;
937
+ if (entry.note !== null) details["note"] = entry.note;
938
+ return {
939
+ entity_type: "monitoring",
940
+ key_components: { concern_id: entry.concernId, signal: entry.signal },
941
+ effective_at: entry.effectiveAt,
942
+ statement: monitoringStateStatement(entry.concernId, entry.signal, entry.status),
943
+ details,
944
+ };
945
+ });
946
+
947
+ changeSets.push(
948
+ fileChangeSet(
949
+ input.relativePath,
950
+ stateChanges,
951
+ events,
952
+ migratedMonitoringSourceSessionId(identityPath, partition.concernId, partition.signal),
953
+ ),
954
+ );
955
+ }
956
+ return changeSets;
957
+ }
958
+
959
+ function fileChangeSet(
960
+ relativePath: string,
961
+ stateChanges: StructuredStateChange[],
962
+ events: StructuredEvent[],
963
+ sessionId: string = migrationSourceSessionId(relativePath),
964
+ ): StructuredChangeSet {
965
+ return {
966
+ schema_version: SCHEMA_VERSION,
967
+ // One change set per legacy FILE (or per file × concern × signal
968
+ // partition for monitoring) so every item's derived source ID
969
+ // (`migration-<hash>:0:<role>:<entry-index>`) embeds the identity inputs
970
+ // plus the entry index — adding or removing other legacy files never
971
+ // shifts existing identity.
972
+ source: { skill: MIGRATION_SKILL, session_id: sessionId, turn_id: 0 },
973
+ state_changes: stateChanges,
974
+ events,
975
+ report_claims: [],
976
+ };
977
+ }
978
+
979
+ /**
980
+ * Builds one migration change set PER legacy source file. Source identity is
981
+ * the normalized relative path hashed into the change-set session ID plus
982
+ * the per-file YAML/markdown entry index, so reruns of the same corpus (and
983
+ * corpora that gain or lose unrelated files) are stable and never duplicate.
984
+ */
985
+ export function planLegacyImport(input: {
986
+ prescriptions?: Array<{ relativePath: string; text: string }>;
987
+ consultations?: Array<{ relativePath: string; text: string }>;
988
+ monitoring?: Array<{ relativePath: string; concernId?: string; text: string }>;
989
+ /** Declared concern-log path per concern ID (registry-driven override). */
990
+ monitoringConcernLogPaths?: Record<string, string>;
991
+ }): StructuredChangeSet[] {
992
+ const changeSets: StructuredChangeSet[] = [];
993
+ for (const file of input.prescriptions ?? []) {
994
+ changeSets.push(fileChangeSet(file.relativePath, planPrescriptionImport(file.relativePath, file.text), []));
995
+ }
996
+ for (const file of input.consultations ?? []) {
997
+ const events = planConsultationImport(file.relativePath, file.text);
998
+ if (events.length > 0) changeSets.push(fileChangeSet(file.relativePath, [], events));
999
+ }
1000
+ for (const file of input.monitoring ?? []) {
1001
+ const concernId =
1002
+ file.concernId ?? basename(file.relativePath).replace(/\.md$/, "");
1003
+ changeSets.push(
1004
+ ...planMonitoringImport({
1005
+ relativePath: file.relativePath,
1006
+ concernId,
1007
+ text: file.text,
1008
+ concernLogPaths: input.monitoringConcernLogPaths,
1009
+ }),
1010
+ );
1011
+ }
1012
+ return changeSets;
1013
+ }
1014
+
1015
+ // ---------------------------------------------------------------------------
1016
+ // Import records — the active record set an approved import apply commits
1017
+ // ---------------------------------------------------------------------------
1018
+
1019
+ import type { KnowledgeRecord } from "@isparling/engram-harness/knowledge-types";
1020
+ import {
1021
+ validateChangeSet,
1022
+ engramCoachPackId,
1023
+ engramCoachPackVersion,
1024
+ } from "./engram-coach-structured-capture.ts";
1025
+
1026
+ function toJsonObject(value: JsonValue): JsonObject {
1027
+ return JSON.parse(JSON.stringify(value)) as JsonObject;
1028
+ }
1029
+
1030
+ /**
1031
+ * Validates a planned import change set and projects every normalized item
1032
+ * into the ACTIVE explicit knowledge record an approved apply would commit,
1033
+ * so dry-run comparison renders exactly what production would render.
1034
+ */
1035
+ export function migrationActiveRecords(
1036
+ changeSet: StructuredChangeSet | StructuredChangeSet[],
1037
+ ): KnowledgeRecord[] {
1038
+ const sets = Array.isArray(changeSet) ? changeSet : [changeSet];
1039
+ return sets.flatMap((set) => activeRecordsOfOne(set));
1040
+ }
1041
+
1042
+ function activeRecordsOfOne(changeSet: StructuredChangeSet): KnowledgeRecord[] {
1043
+ const validated = validateChangeSet(toJsonObject(changeSet));
1044
+ if (!validated.ok) {
1045
+ throw new MigrationError(`invalid import change set: ${JSON.stringify(validated.errors)}`);
1046
+ }
1047
+ return validated.items.map((item): KnowledgeRecord => ({
1048
+ schemaVersion: 0,
1049
+ id: item.recordId,
1050
+ kind: "decision",
1051
+ status: "active",
1052
+ statement: item.statement,
1053
+ details: toJsonObject({
1054
+ recordRole: item.role,
1055
+ entityType: item.entityType,
1056
+ entityKey: item.entityKey,
1057
+ effectiveAt: item.effectiveAt,
1058
+ sourceId: item.sourceId,
1059
+ value: item.value,
1060
+ artifact: item.artifact,
1061
+ captureChannel: "explicit",
1062
+ }),
1063
+ scope: {
1064
+ space: "engram-coach",
1065
+ subjects: [],
1066
+ topics: ["coaching:capture"],
1067
+ contexts: [],
1068
+ dimensions: {},
1069
+ },
1070
+ pack: { id: engramCoachPackId, version: engramCoachPackVersion },
1071
+ sources: [{ type: "engram-coach-capture", ref: item.sourceId }],
1072
+ session: { id: changeSet.source.session_id, host: "omp" },
1073
+ submittedAt: item.effectiveAt,
1074
+ disposition: "new",
1075
+ relationships: { supports: [], contradicts: [], refines: [], supersedes: [] },
1076
+ history: [{ event: "created", relatedId: item.recordId, submittedAt: item.effectiveAt }],
1077
+ }));
1078
+ }