@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,268 @@
1
+ /**
2
+ * engram-coach runtime configuration resolution and validation.
3
+ *
4
+ * This is the single canonical config resolver for engram-coach. It replaces
5
+ * the prose resolution order documented in `shared/setup.md` and SETUP.md:
6
+ *
7
+ * 1. `ENGRAM_COACH_CONFIG` (explicit override, nonblank)
8
+ * 2. `<projectRoot>/.engram-coach/config.json`
9
+ * 3. `~/.claude/engram-coach/config.json`
10
+ *
11
+ * The first candidate that exists wins; a config that exists but is
12
+ * malformed or invalid is a configuration error, never a silent fallback.
13
+ * Parsed JSON is validated field by field into real types — no untyped JSON
14
+ * escapes this module.
15
+ *
16
+ * Capture configuration: `model` comes from `ENGRAM_COACH_CAPTURE_MODEL`
17
+ * when nonblank, otherwise `capture.model`; absence of both is an error.
18
+ * Omitted timeout/candidate limits default to 60/3; explicit values must be
19
+ * positive integers with `timeout_seconds <= 60` and
20
+ * `max_candidates_per_turn <= 3`.
21
+ *
22
+ * @module engram-coach-config
23
+ */
24
+
25
+ import { readFile } from "node:fs/promises";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Runtime config types (camelCase — internal runtime surface)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export type CaptureConfig = {
34
+ model: string;
35
+ timeoutSeconds: number;
36
+ maxCandidatesPerTurn: number;
37
+ };
38
+
39
+ export type EngramCoachRuntimeConfig = {
40
+ activeProfile: string;
41
+ coachingDocsDir: string;
42
+ prescriptionsDir: string;
43
+ capture: CaptureConfig;
44
+ };
45
+
46
+ /** Raised when no config resolves or a resolved config fails validation. */
47
+ export class EngramCoachConfigError extends Error {
48
+ constructor(message: string) {
49
+ super(message);
50
+ this.name = "EngramCoachConfigError";
51
+ }
52
+ }
53
+
54
+ export type LoadEngramCoachConfigOptions = {
55
+ /** Environment variables; defaults to `process.env`. */
56
+ env?: NodeJS.ProcessEnv;
57
+ /** Absolute project working directory; defaults to `process.cwd()`. */
58
+ projectRoot?: string;
59
+ /** Absolute user home directory; defaults to `os.homedir()`. */
60
+ homeDir?: string;
61
+ };
62
+
63
+ export const DEFAULT_CAPTURE_TIMEOUT_SECONDS = 60;
64
+ export const DEFAULT_CAPTURE_MAX_CANDIDATES_PER_TURN = 3;
65
+ const MAX_CAPTURE_TIMEOUT_SECONDS = 60;
66
+ const MAX_CAPTURE_CANDIDATES_PER_TURN = 3;
67
+
68
+ const MISSING_MODEL_MESSAGE =
69
+ 'capture model missing: set ENGRAM_COACH_CAPTURE_MODEL or add a nonblank "capture.model" to config.json';
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Field-level validation of parsed file JSON
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Reads a required nonblank string field off a validated object. */
76
+ function requiredString(
77
+ parent: { [key: string]: unknown },
78
+ key: string,
79
+ where: string,
80
+ ): string {
81
+ const value = parent[key];
82
+ if (typeof value !== "string" || value.trim().length === 0) {
83
+ throw new EngramCoachConfigError(`${where}: "${key}" must be a nonblank string`);
84
+ }
85
+ return value;
86
+ }
87
+
88
+ /**
89
+ * Reads an optional positive-integer limit with a default ceiling: omitted
90
+ * falls back to `fallback`; explicit values must be integers in (0, max].
91
+ */
92
+ function optionalLimit(
93
+ parent: { [key: string]: unknown },
94
+ key: string,
95
+ fallback: number,
96
+ max: number,
97
+ where: string,
98
+ ): number {
99
+ const value = parent[key];
100
+ if (value === undefined) {
101
+ return fallback;
102
+ }
103
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0 || value > max) {
104
+ throw new EngramCoachConfigError(
105
+ `${where}: "${key}" must be a positive integer <= ${max} (got ${JSON.stringify(value)})`,
106
+ );
107
+ }
108
+ return value;
109
+ }
110
+
111
+ type RawCoachConfigFile = {
112
+ active_profile: string;
113
+ profiles: { [key: string]: unknown };
114
+ };
115
+
116
+ /**
117
+ * Validates parsed file JSON into the raw shape needed to build the runtime
118
+ * config. Throws {@link EngramCoachConfigError} on any structural problem.
119
+ */
120
+ function parseConfigFile(parsed: unknown): RawCoachConfigFile {
121
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
122
+ throw new EngramCoachConfigError('config.json: top-level value must be an object');
123
+ }
124
+ const topLevel = parsed as { [key: string]: unknown };
125
+ const profiles = topLevel.profiles;
126
+ if (typeof profiles !== "object" || profiles === null || Array.isArray(profiles)) {
127
+ throw new EngramCoachConfigError('config.json: "profiles" must be an object');
128
+ }
129
+ return {
130
+ active_profile: requiredString(topLevel, "active_profile", "config.json"),
131
+ profiles: profiles as { [key: string]: unknown },
132
+ };
133
+ }
134
+
135
+ function profileEntry(
136
+ profiles: { [key: string]: unknown },
137
+ activeProfile: string,
138
+ ): { coaching_docs_dir: string; prescriptions_dir: string } {
139
+ const entry = profiles[activeProfile];
140
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
141
+ throw new EngramCoachConfigError(
142
+ `config.json: no profile named "${activeProfile}" under "profiles"`,
143
+ );
144
+ }
145
+ const fields = entry as { [key: string]: unknown };
146
+ const where = `profiles.${activeProfile}`;
147
+ return {
148
+ coaching_docs_dir: requiredString(fields, "coaching_docs_dir", where),
149
+ prescriptions_dir: requiredString(fields, "prescriptions_dir", where),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Validates the capture block and environment override into the runtime
155
+ * capture config. The env override wins when nonblank; absence of both
156
+ * model sources is a configuration error.
157
+ */
158
+ function parseCaptureConfig(capture: unknown, envModel: string | undefined): CaptureConfig {
159
+ const trimmedEnvModel = envModel?.trim();
160
+ if (trimmedEnvModel === undefined || trimmedEnvModel.length === 0) {
161
+ if (
162
+ typeof capture !== "object" ||
163
+ capture === null ||
164
+ Array.isArray(capture) ||
165
+ typeof (capture as { [key: string]: unknown }).model !== "string" ||
166
+ ((capture as { [key: string]: unknown }).model as string).trim().length === 0
167
+ ) {
168
+ throw new EngramCoachConfigError(MISSING_MODEL_MESSAGE);
169
+ }
170
+ }
171
+ const model =
172
+ trimmedEnvModel !== undefined && trimmedEnvModel.length > 0
173
+ ? trimmedEnvModel
174
+ : ((capture as { [key: string]: unknown }).model as string).trim();
175
+
176
+ const limits: { [key: string]: unknown } =
177
+ typeof capture === "object" && capture !== null && !Array.isArray(capture)
178
+ ? (capture as { [key: string]: unknown })
179
+ : {};
180
+
181
+ return {
182
+ model,
183
+ timeoutSeconds: optionalLimit(
184
+ limits,
185
+ "timeout_seconds",
186
+ DEFAULT_CAPTURE_TIMEOUT_SECONDS,
187
+ MAX_CAPTURE_TIMEOUT_SECONDS,
188
+ "capture",
189
+ ),
190
+ maxCandidatesPerTurn: optionalLimit(
191
+ limits,
192
+ "max_candidates_per_turn",
193
+ DEFAULT_CAPTURE_MAX_CANDIDATES_PER_TURN,
194
+ MAX_CAPTURE_CANDIDATES_PER_TURN,
195
+ "capture",
196
+ ),
197
+ };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Resolution
202
+ // ---------------------------------------------------------------------------
203
+
204
+ function resolveCandidatePaths(options: LoadEngramCoachConfigOptions): string[] {
205
+ const candidates: string[] = [];
206
+ const explicit = options.env?.ENGRAM_COACH_CONFIG;
207
+ if (explicit !== undefined && explicit.trim().length > 0) {
208
+ candidates.push(explicit);
209
+ }
210
+ candidates.push(join(options.projectRoot ?? process.cwd(), ".engram-coach", "config.json"));
211
+ candidates.push(join(options.homeDir ?? homedir(), ".claude", "engram-coach", "config.json"));
212
+ return candidates;
213
+ }
214
+
215
+ async function readFirstExisting(
216
+ paths: string[],
217
+ ): Promise<{ path: string; contents: string }> {
218
+ for (const path of paths) {
219
+ let contents: string;
220
+ try {
221
+ contents = await readFile(path, "utf8");
222
+ } catch {
223
+ continue;
224
+ }
225
+ return { path, contents };
226
+ }
227
+ throw new EngramCoachConfigError(
228
+ "config not found; tried:\n" +
229
+ paths.map((path) => ` - ${path}`).join("\n") +
230
+ "\nRun the intake skill or copy config.json.example to .engram-coach/config.json",
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Loads and validates the engram-coach runtime config using the standard
236
+ * path precedence. Throws {@link EngramCoachConfigError} when no candidate
237
+ * exists or the first existing candidate fails validation.
238
+ */
239
+ export async function loadEngramCoachConfig(
240
+ options: LoadEngramCoachConfigOptions = {},
241
+ ): Promise<EngramCoachRuntimeConfig> {
242
+ const found = await readFirstExisting(resolveCandidatePaths(options));
243
+
244
+ let parsed: unknown;
245
+ try {
246
+ parsed = JSON.parse(found.contents);
247
+ } catch (error) {
248
+ throw new EngramCoachConfigError(
249
+ `${found.path}: invalid JSON (${error instanceof Error ? error.message : String(error)})`,
250
+ );
251
+ }
252
+
253
+ const file = parseConfigFile(parsed);
254
+ const profile = profileEntry(file.profiles, file.active_profile);
255
+ const capture = parseCaptureConfig(
256
+ typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
257
+ ? (parsed as { [key: string]: unknown }).capture
258
+ : undefined,
259
+ options.env?.ENGRAM_COACH_CAPTURE_MODEL ?? process.env.ENGRAM_COACH_CAPTURE_MODEL,
260
+ );
261
+
262
+ return {
263
+ activeProfile: file.active_profile,
264
+ coachingDocsDir: profile.coaching_docs_dir,
265
+ prescriptionsDir: profile.prescriptions_dir,
266
+ capture,
267
+ };
268
+ }
@@ -184,12 +184,17 @@ export type EngramCoachDetails = {
184
184
  /** Turn index within the session for provenance. */
185
185
  turnIndex: number;
186
186
 
187
- /** Confidence: "high" when LLM was used, "low" for deterministic fallback. */
187
+ /**
188
+ * Confidence in the extraction. Ambient LLM candidates are "low" until a
189
+ * human reviews them; explicitly captured, athlete-approved records are
190
+ * "high".
191
+ */
188
192
  extractionConfidence: "high" | "low";
189
193
  };
190
194
 
191
195
  // ---------------------------------------------------------------------------
192
- // Coaching topic hints — expanded set for deterministic fallback extraction.
196
+ // Coaching topic hints — the vocabulary skills and ambient prompts use to tag
197
+ // scope topics.
193
198
  // ---------------------------------------------------------------------------
194
199
 
195
200
  export const COACHING_TOPIC_HINTS = [
@@ -0,0 +1,189 @@
1
+ /**
2
+ * engram-coach canonical entity key derivation.
3
+ *
4
+ * Identity is derived by the pack, never accepted from an LLM or skill. A
5
+ * candidate may provide key components; this module validates and
6
+ * canonicalizes them into one of the pack's exact key formats:
7
+ *
8
+ * workout:<session-id>
9
+ * prescription:<arc-id>:<session-id>
10
+ * threshold:<sport>:lt1
11
+ * threshold:<sport>:lt2
12
+ * persona:<active-profile>
13
+ * monitoring:<concern-id>:<signal>
14
+ *
15
+ * Human components (sports, signals, profile names) are lower-case slug
16
+ * normalized. Durable IDs (session_id, arc_id, concern_id) are validated for
17
+ * safety and preserved verbatim — rescheduling or retitling never changes a
18
+ * key. Dates, titles, week numbers, and workout bodies never participate in
19
+ * identity: they live in `effective_at`, `statement`, and `details` and are
20
+ * ignored here.
21
+ *
22
+ * When a unique key cannot be derived the result is `unbound`; unbound
23
+ * candidates cannot supersede anything automatically and are surfaced as
24
+ * explicit preview blocks.
25
+ *
26
+ * @module engram-coach-keys
27
+ */
28
+
29
+ import type { JsonObject, JsonValue } from "@isparling/engram-harness/knowledge-types";
30
+ import type { StructuredStateChange } from "./engram-coach-capture-types.ts";
31
+
32
+ /** The state entity types that participate in keyed identity. */
33
+ export type KeyedEntityType = StructuredStateChange["entity_type"];
34
+
35
+ /**
36
+ * Input to key derivation: any structured state capture, or the minimal
37
+ * subset of one ({ entity_type, key_components }). Extra structured fields
38
+ * (`effective_at`, `statement`, `details`) are accepted and deliberately
39
+ * excluded from identity.
40
+ */
41
+ export type CanonicalEntityKeyInput =
42
+ & Pick<StructuredStateChange, "entity_type" | "key_components">
43
+ & Partial<Omit<StructuredStateChange, "entity_type" | "key_components">>;
44
+
45
+ export type DerivedCanonicalEntityKey =
46
+ | { kind: "bound"; key: string }
47
+ | { kind: "unbound"; reason: string };
48
+
49
+ /**
50
+ * Durable-ID grammar: nonempty, starts alphanumeric, then alphanumerics,
51
+ * `.`, `_`, `-`, or `~`. Colons and whitespace are excluded because `:` is
52
+ * the key separator and IDs must survive embedding in single-segment keys.
53
+ */
54
+ const DURABLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/;
55
+
56
+ function isRecord(value: JsonValue): value is { [key: string]: JsonValue } {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ /** Reads a string component from the key components object. */
61
+ function component(
62
+ components: CanonicalEntityKeyInput["key_components"],
63
+ name: string,
64
+ ): string | null {
65
+ const value = components[name];
66
+ return typeof value === "string" ? value : null;
67
+ }
68
+
69
+ /**
70
+ * Validates a durable identifier and returns it verbatim. Empty, missing,
71
+ * or unsafe values yield null with a diagnostic naming the component.
72
+ */
73
+ function durableId(components: JsonObject, name: string): string | null {
74
+ const raw = component(components, name);
75
+ if (raw === null || !DURABLE_ID_PATTERN.test(raw)) {
76
+ return null;
77
+ }
78
+ return raw;
79
+ }
80
+
81
+ /**
82
+ * Lower-case slug normalization for human-readable components: trim,
83
+ * lowercase, collapse every run of characters outside [a-z0-9] into `-`,
84
+ * and strip leading/trailing separators. Returns null when nothing usable
85
+ * remains.
86
+ */
87
+ function slug(raw: string): string | null {
88
+ const normalized = raw
89
+ .trim()
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, "-")
92
+ .replace(/^-+|-+$/g, "");
93
+ return normalized.length > 0 ? normalized : null;
94
+ }
95
+
96
+ function humanComponent(
97
+ components: JsonObject,
98
+ name: string,
99
+ ): string | null {
100
+ const raw = component(components, name);
101
+ return raw === null ? null : slug(raw);
102
+ }
103
+
104
+ function unbound(reason: string): DerivedCanonicalEntityKey {
105
+ return { kind: "unbound", reason };
106
+ }
107
+
108
+ function bound(key: string): DerivedCanonicalEntityKey {
109
+ return { kind: "bound", key };
110
+ }
111
+
112
+ /**
113
+ * Derives the canonical entity key for a structured state capture.
114
+ *
115
+ * Returns `{ kind: "bound", key }` with the exact format above, or
116
+ * `{ kind: "unbound", reason }` when required components are missing or
117
+ * invalid — including unknown state entity types, which have no defined
118
+ * identity format.
119
+ */
120
+ export function deriveCanonicalEntityKey(
121
+ input: CanonicalEntityKeyInput,
122
+ ): DerivedCanonicalEntityKey {
123
+ const { entity_type } = input;
124
+ const components: JsonObject = isRecord(input.key_components)
125
+ ? input.key_components
126
+ : {};
127
+
128
+ switch (entity_type) {
129
+ case "workout": {
130
+ const sessionId = durableId(components, "session_id");
131
+ return sessionId === null
132
+ ? unbound("workout requires a valid session_id component")
133
+ : bound(`workout:${sessionId}`);
134
+ }
135
+
136
+ case "prescription": {
137
+ // Prescription keys require BOTH arc and session components; the
138
+ // invoking skill already has arc context from the loaded prescription.
139
+ const arcId = durableId(components, "arc_id");
140
+ if (arcId === null) {
141
+ return unbound("prescription requires a valid arc_id component");
142
+ }
143
+ const sessionId = durableId(components, "session_id");
144
+ if (sessionId === null) {
145
+ return unbound("prescription requires a valid session_id component");
146
+ }
147
+ return bound(`prescription:${arcId}:${sessionId}`);
148
+ }
149
+
150
+ case "threshold": {
151
+ const sport = humanComponent(components, "sport");
152
+ if (sport === null) {
153
+ return unbound("threshold requires a valid sport component");
154
+ }
155
+ const level = component(components, "level");
156
+ if (level === null || (level !== "lt1" && level !== "lt2" && level !== "LT1" && level !== "LT2")) {
157
+ return unbound("threshold level must be lt1 or lt2");
158
+ }
159
+ return bound(`threshold:${sport}:${level.toLowerCase()}`);
160
+ }
161
+
162
+ case "persona": {
163
+ const activeProfile = humanComponent(components, "active_profile");
164
+ return activeProfile === null
165
+ ? unbound("persona requires a valid active_profile component")
166
+ : bound(`persona:${activeProfile}`);
167
+ }
168
+
169
+ case "monitoring": {
170
+ const concernId = durableId(components, "concern_id");
171
+ if (concernId === null) {
172
+ return unbound("monitoring requires a valid concern_id component");
173
+ }
174
+ const signal = humanComponent(components, "signal");
175
+ if (signal === null) {
176
+ return unbound("monitoring requires a valid signal component");
177
+ }
178
+ return bound(`monitoring:${concernId}:${signal}`);
179
+ }
180
+
181
+ default: {
182
+ // Exhaustiveness guard: a new state entity type without a key format
183
+ // here fails derivation instead of inventing identity.
184
+ const exhaustive: never = entity_type;
185
+ void exhaustive;
186
+ return unbound("unknown state entity type has no canonical key format");
187
+ }
188
+ }
189
+ }