@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,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
|
+
}
|