@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,622 @@
1
+ /**
2
+ * engram-coach explicit structured capture — deterministic aggregate
3
+ * candidate construction and preview projection.
4
+ *
5
+ * A skill emits a typed `StructuredChangeSet` (domain input only: no record
6
+ * IDs, statuses, relationships, or retirement targets). This module is the
7
+ * ONLY place those change sets become Engram candidates:
8
+ *
9
+ * 1. The change set is validated field by field into real types.
10
+ * 2. Each item's canonical entity key is derived by the pack via
11
+ * `deriveCanonicalEntityKey` — an unbound key blocks the whole preview
12
+ * rather than guessing identity.
13
+ * 3. Each item gets a deterministic source ID
14
+ * (`<session_id>:<turn_id>:<channel>:<index>`) and record ID
15
+ * (`coach-` + first 24 hex chars of SHA-256 over canonical JSON of
16
+ * `{ sourceId, role, canonicalKey, statement }`).
17
+ * 4. ONE aggregate candidate envelope (`details.captureChannel: "explicit"`)
18
+ * carries the normalized items to the host transaction path; the host's
19
+ * guarded reconciler classifies every item against exact-key related
20
+ * records and returns a plan + hash.
21
+ *
22
+ * `previewStructuredCapture` projects that plan into the Phase 4 preview:
23
+ * a ready result carries the plan hash, the private aggregate candidate
24
+ * (retained by the extension, never shown to the model), per-entity change
25
+ * rows derived from planned roles/statuses/relationship edges, and sorted
26
+ * compatibility artifact paths; a blocked result carries host errors
27
+ * verbatim and never reaches presentation.
28
+ *
29
+ * @module engram-coach-structured-capture
30
+ */
31
+
32
+ import { createHash } from "node:crypto";
33
+ import type { CaptureMutationView, HostCapturePreview } from "@isparling/engram-harness/capture-types";
34
+ import type {
35
+ JsonObject,
36
+ JsonValue,
37
+ KnowledgeEnvelope,
38
+ KnowledgeError,
39
+ } from "@isparling/engram-harness/knowledge-types";
40
+ import {
41
+ SCHEMA_VERSION,
42
+ type BlockedCapturePreview,
43
+ type CaptureChangeView,
44
+ type CapturePreview,
45
+ type ReadyCapturePreview,
46
+ type RecordRole,
47
+ type StructuredChangeSet,
48
+ type StructuredEvent,
49
+ type StructuredReportClaim,
50
+ type StructuredStateChange,
51
+ } from "./engram-coach-capture-types.ts";
52
+ import { ENGRAM_COACH_SKILLS, type EngramCoachSkill } from "./engram-coach-domain.ts";
53
+ import { deriveCanonicalEntityKey, type KeyedEntityType } from "./engram-coach-keys.ts";
54
+
55
+ export const engramCoachPackId = "engram-coach";
56
+ export const engramCoachPackVersion = "0.1.0";
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Normalized items — the pack-owned shape carried on the aggregate candidate
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** Compatibility artifact metadata stored at `details.artifact`. */
63
+ export type CaptureArtifactRef = { kind: string; relativePath: string };
64
+
65
+ /**
66
+ * One normalized change-set item. Carries everything the reconciler and
67
+ * materializers need: pack-derived identity, lifecycle-independent content,
68
+ * and the artifact view it will regenerate. Stored as an entry of the
69
+ * aggregate candidate's `details.items` array.
70
+ */
71
+ export type NormalizedCaptureItem = {
72
+ sourceId: string;
73
+ recordId: string;
74
+ role: RecordRole;
75
+ entityType: string;
76
+ /** Bound canonical key, or null when the role has no keyed identity. */
77
+ entityKey: string | null;
78
+ effectiveAt: string;
79
+ statement: string;
80
+ value: JsonObject;
81
+ artifact: CaptureArtifactRef;
82
+ actionTargets: string[];
83
+ sourceDocument: string | null;
84
+ };
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Canonical JSON + deterministic hashing
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Recursive canonical JSON — THE pack-wide canonical byte form for hashing
92
+ * and value comparison. Object keys are sorted at EVERY depth (`localeCompare`);
93
+ * array order is preserved verbatim because sequences are meaningful;
94
+ * primitives and `null` emit as JSON; `undefined` emits as `null` so partial
95
+ * objects hash deterministically.
96
+ */
97
+ export function canonicalJson(value: unknown): string {
98
+ if (value === undefined || value === null) return "null";
99
+ if (typeof value === "string" || typeof value === "boolean" || typeof value === "number") {
100
+ return JSON.stringify(value);
101
+ }
102
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
103
+ const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
104
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
105
+ }
106
+
107
+ function sha24Hex(text: string): string {
108
+ return createHash("sha256").update(text).digest("hex").slice(0, 24);
109
+ }
110
+
111
+ /**
112
+ * Record ID for one normalized item: `coach-` plus the first 24 hex chars
113
+ * of SHA-256 over canonical JSON of `{ sourceId, role, canonicalKey,
114
+ * statement }`. Identical inputs therefore always produce identical IDs.
115
+ */
116
+ export function deriveRecordId(input: {
117
+ sourceId: string;
118
+ role: RecordRole;
119
+ canonicalKey: string | null;
120
+ statement: string;
121
+ }): string {
122
+ return `coach-${sha24Hex(canonicalJson({
123
+ sourceId: input.sourceId,
124
+ role: input.role,
125
+ canonicalKey: input.canonicalKey,
126
+ statement: input.statement,
127
+ }))}`;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Strict validation of the JSON-facing change set
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function validationError(code: string, field: string, message: string): KnowledgeError {
135
+ return { kind: "validation", code, field, message };
136
+ }
137
+
138
+ type InvalidChangeSet = { ok: false; errors: KnowledgeError[] };
139
+
140
+ function isObject(value: JsonValue): value is { [key: string]: JsonValue } {
141
+ return typeof value === "object" && value !== null && !Array.isArray(value);
142
+ }
143
+
144
+ function nonEmptyString(parent: JsonObject, key: string, field: string, errors: KnowledgeError[]): string | null {
145
+ const value = parent[key];
146
+ if (typeof value !== "string" || value.trim().length === 0 || /[\r\n]/.test(value)) {
147
+ errors.push(validationError("change_set_field_invalid", field, `${field} must be a non-empty single-line string`));
148
+ return null;
149
+ }
150
+ return value;
151
+ }
152
+
153
+ function dateString(parent: JsonObject, key: string, field: string, errors: KnowledgeError[]): string | null {
154
+ const value = parent[key];
155
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
156
+ errors.push(validationError("change_set_field_invalid", field, `${field} must be a parseable date or timestamp`));
157
+ return null;
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function jsonObject(parent: JsonObject, key: string, field: string, errors: KnowledgeError[]): JsonObject {
163
+ const value = parent[key];
164
+ if (!isObject(value)) {
165
+ errors.push(validationError("change_set_field_invalid", field, `${field} must be a JSON object`));
166
+ return {};
167
+ }
168
+ return value;
169
+ }
170
+
171
+ function stringArray(parent: JsonObject, key: string, field: string, errors: KnowledgeError[]): string[] {
172
+ const value = parent[key];
173
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || /[\r\n]/.test(item))) {
174
+ errors.push(validationError("change_set_field_invalid", field, `${field} must be an array of single-line strings`));
175
+ return [];
176
+ }
177
+ return value as string[];
178
+ }
179
+
180
+ const KEYED_ENTITY_TYPES: readonly KeyedEntityType[] = [
181
+ "workout",
182
+ "prescription",
183
+ "threshold",
184
+ "persona",
185
+ "monitoring",
186
+ ];
187
+ const EVENT_ENTITY_TYPES = ["consultation", "workout-adaptation", "monitoring-event"] as const;
188
+ type EventEntityType = (typeof EVENT_ENTITY_TYPES)[number];
189
+ const CLAIM_ENTITY_TYPES = [
190
+ "race-conclusion",
191
+ "block-conclusion",
192
+ "season-conclusion",
193
+ "methodology-conclusion",
194
+ "arc-conclusion",
195
+ ] as const;
196
+
197
+ function validateSource(changeSet: JsonObject, errors: KnowledgeError[]): { skill: EngramCoachSkill; sessionId: string; turnId: number } | null {
198
+ const source = changeSet["source"];
199
+ if (!isObject(source)) {
200
+ errors.push(validationError("change_set_source_invalid", "source", "source must be a JSON object"));
201
+ return null;
202
+ }
203
+ const skill = nonEmptyString(source, "skill", "source.skill", errors);
204
+ if (skill === null || !(ENGRAM_COACH_SKILLS as readonly string[]).includes(skill)) {
205
+ errors.push(validationError("change_set_skill_unknown", "source.skill", `"${String(source["skill"])}" is not a known engram-coach skill`));
206
+ }
207
+ const sessionId = nonEmptyString(source, "session_id", "source.session_id", errors);
208
+ const rawTurn = source["turn_id"];
209
+ if (typeof rawTurn !== "number" || !Number.isInteger(rawTurn) || rawTurn < 0) {
210
+ errors.push(validationError("change_set_field_invalid", "source.turn_id", "source.turn_id must be a non-negative integer"));
211
+ }
212
+ if (skill === null || sessionId === null || typeof rawTurn !== "number" || !Number.isInteger(rawTurn) || rawTurn < 0) {
213
+ return null;
214
+ }
215
+ return { skill: skill as EngramCoachSkill, sessionId, turnId: rawTurn };
216
+ }
217
+
218
+ function validateStateChange(
219
+ raw: JsonValue,
220
+ index: number,
221
+ sessionId: string,
222
+ turnId: number,
223
+ errors: KnowledgeError[],
224
+ ): StructuredStateChange | null {
225
+ const field = `state_changes[${index}]`;
226
+ if (!isObject(raw)) {
227
+ errors.push(validationError("change_set_item_invalid", field, `${field} must be a JSON object`));
228
+ return null;
229
+ }
230
+ const entityType = raw["entity_type"];
231
+ if (typeof entityType !== "string" || !(KEYED_ENTITY_TYPES as readonly string[]).includes(entityType)) {
232
+ errors.push(validationError("change_set_entity_type_invalid", `${field}.entity_type`, `${field}.entity_type must be one of ${KEYED_ENTITY_TYPES.join(", ")}`));
233
+ return null;
234
+ }
235
+ const statement = nonEmptyString(raw, "statement", `${field}.statement`, errors);
236
+ const effectiveAt = dateString(raw, "effective_at", `${field}.effective_at`, errors);
237
+ const keyComponents = jsonObject(raw, "key_components", `${field}.key_components`, errors);
238
+ const details = jsonObject(raw, "details", `${field}.details`, errors);
239
+ if (statement === null || effectiveAt === null) return null;
240
+ return {
241
+ entity_type: entityType as StructuredStateChange["entity_type"],
242
+ key_components: keyComponents,
243
+ effective_at: effectiveAt,
244
+ statement,
245
+ details,
246
+ };
247
+ }
248
+
249
+ function normalizeStateItem(
250
+ change: StructuredStateChange,
251
+ index: number,
252
+ sessionId: string,
253
+ turnId: number,
254
+ ): NormalizedCaptureItem | { unbound: string } {
255
+ const derived = deriveCanonicalEntityKey({ entity_type: change.entity_type, key_components: change.key_components });
256
+ if (derived.kind === "unbound") return { unbound: derived.reason };
257
+ const sourceId = `${sessionId}:${turnId}:state:${index}`;
258
+ return {
259
+ sourceId,
260
+ recordId: deriveRecordId({ sourceId, role: "state", canonicalKey: derived.key, statement: change.statement }),
261
+ role: "state",
262
+ entityType: change.entity_type,
263
+ entityKey: derived.key,
264
+ effectiveAt: change.effective_at,
265
+ statement: change.statement,
266
+ value: change.details,
267
+ artifact: stateArtifact(change.entity_type, change.key_components),
268
+ actionTargets: [],
269
+ sourceDocument: null,
270
+ };
271
+ }
272
+
273
+ function stateArtifact(entityType: KeyedEntityType, components: JsonObject): CaptureArtifactRef {
274
+ const component = (name: string): string => {
275
+ const value = components[name];
276
+ return typeof value === "string" ? value : "current";
277
+ };
278
+ switch (entityType) {
279
+ case "prescription":
280
+ return { kind: "prescription", relativePath: `prescriptions/${component("arc_id")}.yaml` };
281
+ case "workout":
282
+ return { kind: "prescription", relativePath: `prescriptions/${component("session_id")}.yaml` };
283
+ case "monitoring":
284
+ return { kind: "monitoring", relativePath: `monitoring/${component("concern_id")}.md` };
285
+ case "threshold":
286
+ return { kind: "doctor-prep", relativePath: "monitoring/thresholds.md" };
287
+ case "persona":
288
+ return { kind: "doctor-prep", relativePath: "profiles/persona.md" };
289
+ }
290
+ }
291
+
292
+ function validateEvent(
293
+ raw: JsonValue,
294
+ index: number,
295
+ sessionId: string,
296
+ turnId: number,
297
+ errors: KnowledgeError[],
298
+ ): NormalizedCaptureItem | null {
299
+ const field = `events[${index}]`;
300
+ if (!isObject(raw)) {
301
+ errors.push(validationError("change_set_item_invalid", field, `${field} must be a JSON object`));
302
+ return null;
303
+ }
304
+ const entityType = raw["entity_type"];
305
+ if (typeof entityType !== "string" || !(EVENT_ENTITY_TYPES as readonly string[]).includes(entityType)) {
306
+ errors.push(validationError("change_set_entity_type_invalid", `${field}.entity_type`, `${field}.entity_type must be one of ${EVENT_ENTITY_TYPES.join(", ")}`));
307
+ return null;
308
+ }
309
+ const statement = nonEmptyString(raw, "statement", `${field}.statement`, errors);
310
+ const effectiveAt = dateString(raw, "effective_at", `${field}.effective_at`, errors);
311
+ const details = jsonObject(raw, "details", `${field}.details`, errors);
312
+ const actionTargets = stringArray(raw, "action_targets", `${field}.action_targets`, errors);
313
+ if (statement === null || effectiveAt === null) return null;
314
+ const sourceId = `${sessionId}:${turnId}:event:${index}`;
315
+ return {
316
+ sourceId,
317
+ recordId: deriveRecordId({ sourceId, role: "event", canonicalKey: null, statement }),
318
+ role: "event",
319
+ entityType,
320
+ entityKey: null,
321
+ effectiveAt,
322
+ statement,
323
+ value: details,
324
+ artifact: eventArtifact(entityType as EventEntityType),
325
+ actionTargets,
326
+ sourceDocument: null,
327
+ };
328
+ }
329
+
330
+ function eventArtifact(entityType: EventEntityType): CaptureArtifactRef {
331
+ switch (entityType) {
332
+ case "consultation":
333
+ return { kind: "consultation", relativePath: "coaching/consultations.md" };
334
+ case "workout-adaptation":
335
+ return { kind: "adaptation", relativePath: "coaching/adaptations.md" };
336
+ case "monitoring-event":
337
+ return { kind: "monitoring", relativePath: "monitoring/events.md" };
338
+ }
339
+ }
340
+
341
+ function validateReportClaim(
342
+ raw: JsonValue,
343
+ index: number,
344
+ sessionId: string,
345
+ turnId: number,
346
+ errors: KnowledgeError[],
347
+ ): NormalizedCaptureItem | null {
348
+ const field = `report_claims[${index}]`;
349
+ if (!isObject(raw)) {
350
+ errors.push(validationError("change_set_item_invalid", field, `${field} must be a JSON object`));
351
+ return null;
352
+ }
353
+ const entityType = raw["entity_type"];
354
+ if (typeof entityType !== "string" || !(CLAIM_ENTITY_TYPES as readonly string[]).includes(entityType)) {
355
+ errors.push(validationError("change_set_entity_type_invalid", `${field}.entity_type`, `${field}.entity_type must be one of ${CLAIM_ENTITY_TYPES.join(", ")}`));
356
+ return null;
357
+ }
358
+ const statement = nonEmptyString(raw, "statement", `${field}.statement`, errors);
359
+ const effectiveAt = dateString(raw, "effective_at", `${field}.effective_at`, errors);
360
+ const sourceDocument = nonEmptyString(raw, "source_document", `${field}.source_document`, errors);
361
+ const keyComponents = jsonObject(raw, "key_components", `${field}.key_components`, errors);
362
+ const details = jsonObject(raw, "details", `${field}.details`, errors);
363
+ if (statement === null || effectiveAt === null || sourceDocument === null) return null;
364
+
365
+ // A claim supports/refines/supersedes a keyed STATE record, so its
366
+ // identity link is the state entity named inside key_components
367
+ // ({ entity_type, ...components }). An unbound claim key blocks the
368
+ // preview instead of letting an unanchored conclusion touch state.
369
+ const { entity_type: _targetType, ...identityComponents } = keyComponents;
370
+ const targetEntityType = keyComponents["entity_type"];
371
+ let entityKey: string | null = null;
372
+ if (typeof targetEntityType === "string" && (KEYED_ENTITY_TYPES as readonly string[]).includes(targetEntityType)) {
373
+ const derived = deriveCanonicalEntityKey({
374
+ entity_type: targetEntityType as KeyedEntityType,
375
+ key_components: identityComponents,
376
+ });
377
+ if (derived.kind === "bound") entityKey = derived.key;
378
+ }
379
+ if (entityKey === null) {
380
+ errors.push(validationError(
381
+ "change_set_claim_unbound",
382
+ `${field}.key_components`,
383
+ `${field} requires key_components naming a bound state entity (entity_type plus its durable components)`,
384
+ ));
385
+ return null;
386
+ }
387
+
388
+ const sourceId = `${sessionId}:${turnId}:report-claim:${index}`;
389
+ return {
390
+ sourceId,
391
+ recordId: deriveRecordId({ sourceId, role: "report-claim", canonicalKey: entityKey, statement }),
392
+ role: "report-claim",
393
+ entityType,
394
+ entityKey,
395
+ effectiveAt,
396
+ statement,
397
+ value: details,
398
+ artifact: { kind: "doctor-prep", relativePath: `reports/${entityType}.md` },
399
+ actionTargets: [],
400
+ sourceDocument,
401
+ };
402
+ }
403
+
404
+ export type ValidatedChangeSet = {
405
+ ok: true;
406
+ skill: EngramCoachSkill;
407
+ sessionId: string;
408
+ turnId: number;
409
+ items: NormalizedCaptureItem[];
410
+ };
411
+
412
+ /**
413
+ * Validate a raw JSON change set and derive every normalized item. Returns
414
+ * ALL validation errors, with unbound state keys reported as blocking
415
+ * errors — ambiguity never guesses.
416
+ */
417
+ export function validateChangeSet(changeSet: JsonObject): ValidatedChangeSet | InvalidChangeSet {
418
+ const errors: KnowledgeError[] = [];
419
+ if (!isObject(changeSet)) {
420
+ return { ok: false, errors: [validationError("change_set_invalid", "change_set", "change set must be a JSON object")] };
421
+ }
422
+ if (changeSet["schema_version"] !== SCHEMA_VERSION) {
423
+ errors.push(validationError("change_set_schema_version", "schema_version", `schema_version must be ${SCHEMA_VERSION}`));
424
+ }
425
+ const source = validateSource(changeSet, errors);
426
+
427
+ const rawStates = Array.isArray(changeSet["state_changes"]) ? changeSet["state_changes"] : [];
428
+ if (!Array.isArray(changeSet["state_changes"])) {
429
+ errors.push(validationError("change_set_field_invalid", "state_changes", "state_changes must be an array"));
430
+ }
431
+ const rawEvents = Array.isArray(changeSet["events"]) ? changeSet["events"] : [];
432
+ if (!Array.isArray(changeSet["events"])) {
433
+ errors.push(validationError("change_set_field_invalid", "events", "events must be an array"));
434
+ }
435
+ const rawClaims = Array.isArray(changeSet["report_claims"]) ? changeSet["report_claims"] : [];
436
+ if (!Array.isArray(changeSet["report_claims"])) {
437
+ errors.push(validationError("change_set_field_invalid", "report_claims", "report_claims must be an array"));
438
+ }
439
+
440
+ const items: NormalizedCaptureItem[] = [];
441
+ if (source !== null) {
442
+ rawStates.forEach((raw, index) => {
443
+ const change = validateStateChange(raw, index, source.sessionId, source.turnId, errors);
444
+ if (change === null) return;
445
+ const normalized = normalizeStateItem(change, index, source.sessionId, source.turnId);
446
+ if ("unbound" in normalized) {
447
+ errors.push(validationError("change_set_key_unbound", `state_changes[${index}].key_components`, normalized.unbound));
448
+ return;
449
+ }
450
+ items.push(normalized);
451
+ });
452
+ rawEvents.forEach((raw, index) => {
453
+ const item = validateEvent(raw, index, source.sessionId, source.turnId, errors);
454
+ if (item !== null) items.push(item);
455
+ });
456
+ rawClaims.forEach((raw, index) => {
457
+ const item = validateReportClaim(raw, index, source.sessionId, source.turnId, errors);
458
+ if (item !== null) items.push(item);
459
+ });
460
+ }
461
+
462
+ if (errors.length > 0 || source === null) return { ok: false, errors };
463
+ if (items.length === 0) {
464
+ errors.push(validationError("change_set_empty", "state_changes", "a change set must contain at least one state change, event, or report claim"));
465
+ return { ok: false, errors };
466
+ }
467
+ return { ok: true, skill: source.skill, sessionId: source.sessionId, turnId: source.turnId, items };
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Aggregate candidate construction
472
+ // ---------------------------------------------------------------------------
473
+
474
+ /**
475
+ * Build the ONE aggregate candidate envelope for a validated change set.
476
+ * Deterministic: identical change sets produce identical candidate IDs,
477
+ * item record IDs, and (with the same binding and disk state) identical
478
+ * plan hashes. The submission date is derived from the change set's own
479
+ * latest explicit effective date — NEVER from ambient wall-clock time,
480
+ * which would make otherwise identical previews hash differently.
481
+ */
482
+ export function buildAggregateCandidate(valid: ValidatedChangeSet, spaceId: string): KnowledgeEnvelope {
483
+ const today = valid.items
484
+ .map((item) => item.effectiveAt)
485
+ .sort((left, right) => left.localeCompare(right))
486
+ [valid.items.length - 1]!
487
+ .slice(0, 10);
488
+ const idSeed = canonicalJson({
489
+ skill: valid.skill,
490
+ sessionId: valid.sessionId,
491
+ turnId: valid.turnId,
492
+ items: valid.items.map((item) => ({ sourceId: item.sourceId, recordId: item.recordId })),
493
+ });
494
+ const counts = {
495
+ state: valid.items.filter((item) => item.role === "state").length,
496
+ event: valid.items.filter((item) => item.role === "event").length,
497
+ claim: valid.items.filter((item) => item.role === "report-claim").length,
498
+ };
499
+ return {
500
+ id: `coach-${sha24Hex(idSeed)}`,
501
+ kind: "decision",
502
+ status: "candidate",
503
+ statement: `explicit capture: ${valid.skill} (${counts.state} state, ${counts.event} event, ${counts.claim} report claim)`,
504
+ // Pack-owned aggregate details. `items` mirrors NormalizedCaptureItem
505
+ // exactly; the reconciler narrows them back defensively.
506
+ details: {
507
+ captureChannel: "explicit",
508
+ skill: valid.skill,
509
+ turnIndex: valid.turnId,
510
+ extractionConfidence: "high",
511
+ sessionId: valid.sessionId,
512
+ items: valid.items,
513
+ },
514
+ scope: {
515
+ space: spaceId,
516
+ subjects: [],
517
+ topics: ["coaching:capture"],
518
+ contexts: [],
519
+ dimensions: {},
520
+ },
521
+ pack: { id: engramCoachPackId, version: engramCoachPackVersion },
522
+ sources: [{ type: "engram-coach-capture", ref: `${valid.sessionId}:turn:${valid.turnId}` }],
523
+ session: { id: valid.sessionId, host: "omp" },
524
+ submittedAt: today,
525
+ disposition: "new",
526
+ };
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Preview tools + projection
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /**
534
+ * Host mechanics supplied by the OMP extension. The extension owns NO
535
+ * coaching ontology — it runs the guarded core transaction for the
536
+ * binding-selected pack and hands back the DTO union verbatim.
537
+ */
538
+ export type PreviewTools = {
539
+ spaceId: string;
540
+ previewCandidate(candidate: KnowledgeEnvelope): Promise<HostCapturePreview>;
541
+ };
542
+
543
+ function itemRoleFromRecord(record: { details: JsonObject }): RecordRole {
544
+ const role = record.details["recordRole"];
545
+ return role === "event" || role === "report-claim" ? role : "state";
546
+ }
547
+
548
+ function classificationForCreate(record: { details: JsonObject; relationships: { refines: string[]; supersedes: string[]; supports: string[] } }): CaptureChangeView["classification"] {
549
+ if (record.relationships.supersedes.length > 0) return "supersede";
550
+ if (record.relationships.refines.length > 0) return "refine";
551
+ if (record.relationships.supports.length > 0) return "support";
552
+ return itemRoleFromRecord(record) === "event" ? "append" : "new";
553
+ }
554
+
555
+ function projectChanges(mutations: readonly CaptureMutationView[]): CaptureChangeView[] {
556
+ const views: CaptureChangeView[] = [];
557
+ const createsByRecordId = new Map<string, { record: CaptureMutationView["after"]; view: CaptureChangeView }>();
558
+ for (const mutation of mutations) {
559
+ if (mutation.action !== "create") continue;
560
+ const sourceId = mutation.after.details["sourceId"];
561
+ if (typeof sourceId !== "string") continue;
562
+ const view: CaptureChangeView = {
563
+ entityKey: typeof mutation.after.details["entityKey"] === "string" ? mutation.after.details["entityKey"] as string : null,
564
+ recordRole: itemRoleFromRecord(mutation.after),
565
+ classification: classificationForCreate(mutation.after),
566
+ creates: [mutation.recordId],
567
+ retires: [],
568
+ };
569
+ createsByRecordId.set(mutation.recordId, { record: mutation.after, view });
570
+ views.push(view);
571
+ }
572
+ // The CREATED record carries the supersedes/refines edges pointing at the
573
+ // retired record; the retired copy preserves its PRIOR relationships
574
+ // verbatim. So a retirement attaches to whichever change view's created
575
+ // record references it — never as a separate row.
576
+ for (const mutation of mutations) {
577
+ if (mutation.action !== "update" || mutation.after.status !== "retired") continue;
578
+ const owner = [...createsByRecordId.values()].find(({ record }) =>
579
+ record.relationships.supersedes.includes(mutation.recordId)
580
+ || record.relationships.refines.includes(mutation.recordId));
581
+ owner?.view.retires.push(mutation.recordId);
582
+ }
583
+ return views;
584
+ }
585
+
586
+ /**
587
+ * Project an explicit change set into the Phase 4 capture preview.
588
+ *
589
+ * Validates the change set, builds the deterministic aggregate candidate,
590
+ * and hands it to the host's guarded transaction via `tools.previewCandidate`.
591
+ * Host errors come back verbatim as a blocked preview. A ready preview
592
+ * groups planned mutations by `details.sourceId`, derives classifications
593
+ * from relationship edges, lists sorted compatibility artifact paths, and
594
+ * retains the aggregate candidate ONLY in the internal `candidate` field.
595
+ */
596
+ export async function previewStructuredCapture(
597
+ changeSet: JsonObject,
598
+ tools: PreviewTools,
599
+ ): Promise<CapturePreview> {
600
+ const valid = validateChangeSet(changeSet);
601
+ if (!valid.ok) return { schemaVersion: 0, status: "blocked" as const, errors: valid.errors };
602
+ const candidate = buildAggregateCandidate(valid, tools.spaceId);
603
+ const host = await tools.previewCandidate(candidate);
604
+ if (host.status === "blocked") return { schemaVersion: 0, status: "blocked" as const, errors: host.errors };
605
+ const artifacts = [...new Set(
606
+ host.mutations
607
+ .filter((mutation) => mutation.action === "create")
608
+ .map((mutation) => mutation.after.details["artifact"])
609
+ .filter((artifact): artifact is { [key: string]: JsonValue } => isObject(artifact))
610
+ .map((artifact) => artifact["relativePath"])
611
+ .filter((path): path is string => typeof path === "string"),
612
+ )].sort((left, right) => left.localeCompare(right));
613
+ const ready: ReadyCapturePreview = {
614
+ schemaVersion: 0,
615
+ status: "ready",
616
+ planHash: host.planHash,
617
+ candidate,
618
+ changes: projectChanges(host.mutations),
619
+ artifacts,
620
+ };
621
+ return ready;
622
+ }
package/package.json CHANGED
@@ -1,27 +1,60 @@
1
1
  {
2
2
  "name": "@isparling/engram-coach",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Engram external pack for document-driven endurance coaching.",
7
- "exports": { ".": { "import": "./engram-coach-pack.ts" } },
7
+ "exports": {
8
+ ".": {
9
+ "import": "./engram-coach-pack.ts"
10
+ }
11
+ },
12
+ "omp": {},
8
13
  "files": [
9
14
  "engram-coach-domain.ts",
10
- "engram-coach-extractor.ts",
15
+ "engram-coach-capture-types.ts",
16
+ "engram-coach-config.ts",
17
+ "engram-coach-keys.ts",
18
+ "engram-coach-ambient-capture.ts",
19
+ "engram-coach-structured-capture.ts",
20
+ "engram-coach-materialization.ts",
21
+ "engram-coach-migration.ts",
11
22
  "engram-coach-reconciliation.ts",
12
23
  "engram-coach-presentation.ts",
13
24
  "engram-coach-pack.ts",
25
+ "capture-handler.ts",
26
+ "skills/",
27
+ "shared/",
28
+ "analyses/",
29
+ "personas/",
30
+ "templates/",
31
+ "analysis-tools/hrv-trend.ts",
32
+ "analysis-tools/migrate-structured-capture.ts",
33
+ "analysis-tools/race-context.ts",
34
+ "analysis-tools/stream-analyze.ts",
35
+ "analysis-tools/tsb-predict.ts",
36
+ "config.json.example",
37
+ "SETUP.md",
38
+ "SKILL_PACK.md",
14
39
  "README.md"
15
40
  ],
16
- "peerDependencies": { "@isparling/engram-harness": "^0.1.0" },
41
+ "peerDependencies": {
42
+ "@isparling/engram-harness": "^0.2.0"
43
+ },
17
44
  "devDependencies": {
18
45
  "@isparling/engram-harness": "file:../engram/harness",
46
+ "@types/node": "^26.2.0",
19
47
  "typescript": "^7.0.2"
20
48
  },
21
- "publishConfig": { "access": "public" },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
22
52
  "scripts": {
23
- "test": "npm test --prefix tools",
53
+ "test": "npm test --prefix analysis-tools",
24
54
  "typecheck": "tsc --noEmit -p tsconfig.json",
25
55
  "pack:local": "npm pack --json"
56
+ },
57
+ "dependencies": {
58
+ "yaml": "^2.9.0"
26
59
  }
27
60
  }