@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dry-run migration CLI for legacy coaching data.
|
|
3
|
+
*
|
|
4
|
+
* Modes (all dry-run unless apply-baseline):
|
|
5
|
+
*
|
|
6
|
+
* migrate-structured-capture scan --config <path>
|
|
7
|
+
* Plan stable session IDs + generated headers for every legacy
|
|
8
|
+
* prescription and compatibility log. Prints the plan JSON to stdout;
|
|
9
|
+
* mutates nothing.
|
|
10
|
+
*
|
|
11
|
+
* migrate-structured-capture apply-baseline --plan <scan.json> --expect <after-hash>
|
|
12
|
+
* Writes ONLY the planned ID insertions and warning headers. Refuses
|
|
13
|
+
* when the aggregate hash mismatches or any file drifted since scan.
|
|
14
|
+
*
|
|
15
|
+
* migrate-structured-capture emit-change-set --config <path> --output <change-set.json>
|
|
16
|
+
* Plans the legacy import (prescription states + consultation events)
|
|
17
|
+
* and writes the StructuredChangeSet JSON. Mutates nothing.
|
|
18
|
+
*
|
|
19
|
+
* migrate-structured-capture compare --config <path> --render-root <temporary-root>
|
|
20
|
+
* Renders the record-derived compatibility views into the given root
|
|
21
|
+
* and byte-compares them against the current source files. Exits 0 on
|
|
22
|
+
* byte equality, 1 listing every differing relative path. Mutates
|
|
23
|
+
* nothing outside the render root.
|
|
24
|
+
*
|
|
25
|
+
* @module analysis-tools/migrate-structured-capture
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
29
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
30
|
+
import { loadEngramCoachConfig, EngramCoachConfigError } from "../engram-coach-config.ts";
|
|
31
|
+
import {
|
|
32
|
+
applyBaseline,
|
|
33
|
+
migrationActiveRecords,
|
|
34
|
+
planLegacyImport,
|
|
35
|
+
readConcernRegistry,
|
|
36
|
+
scanBaseline,
|
|
37
|
+
MigrationError,
|
|
38
|
+
type BaselineRoots,
|
|
39
|
+
type BaselineScan,
|
|
40
|
+
} from "../engram-coach-migration.ts";
|
|
41
|
+
import { computeDesiredViews } from "../engram-coach-materialization.ts";
|
|
42
|
+
|
|
43
|
+
function usage(): string {
|
|
44
|
+
return [
|
|
45
|
+
"Usage:",
|
|
46
|
+
" migrate-structured-capture scan --config <path>",
|
|
47
|
+
" migrate-structured-capture apply-baseline --plan <scan.json> --expect <after-hash>",
|
|
48
|
+
" migrate-structured-capture emit-change-set --config <path> --output <change-set.json>",
|
|
49
|
+
" migrate-structured-capture compare --config <path> --render-root <temporary-root>",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fail(message: string): never {
|
|
54
|
+
console.error(message);
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Simple argv flag reader: `--name value` pairs plus positional mode. */
|
|
59
|
+
function parseArgs(argv: string[]): { mode: string; flags: Map<string, string> } {
|
|
60
|
+
// npm exec passes a bare `--` separator before script args; strip it.
|
|
61
|
+
const [first, ...tail] = argv[0] === "--" ? argv.slice(1) : argv;
|
|
62
|
+
const mode = first ?? "help";
|
|
63
|
+
const rest = mode === "--help" || mode === "-h" ? ["help"] : tail;
|
|
64
|
+
const flags = new Map<string, string>();
|
|
65
|
+
for (const [index, arg] of rest.entries()) {
|
|
66
|
+
if (!arg.startsWith("--")) continue;
|
|
67
|
+
if (arg === "--help" || arg === "-h") continue;
|
|
68
|
+
const value = rest[index + 1];
|
|
69
|
+
if (value === undefined || value.startsWith("--")) fail(`missing value for ${arg}\n${usage()}`);
|
|
70
|
+
flags.set(arg.slice(2), value);
|
|
71
|
+
}
|
|
72
|
+
return { mode, flags };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requireFlag(flags: Map<string, string>, name: string): string {
|
|
76
|
+
const value = flags.get(name);
|
|
77
|
+
if (value === undefined) fail(`missing required flag --${name}\n${usage()}`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Config dirs may be relative; they resolve against the working directory. */
|
|
82
|
+
function resolveRoot(dir: string): string {
|
|
83
|
+
return isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadRoots(configPath: string): Promise<BaselineRoots> {
|
|
87
|
+
const config = await loadEngramCoachConfig({ env: { ENGRAM_COACH_CONFIG: configPath } });
|
|
88
|
+
return {
|
|
89
|
+
prescriptionsDir: resolveRoot(config.prescriptionsDir),
|
|
90
|
+
coachingDocsDir: resolveRoot(config.coachingDocsDir),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function readSources(roots: BaselineRoots): Promise<{
|
|
94
|
+
prescriptions: Array<{ relativePath: string; text: string }>;
|
|
95
|
+
consultations: Array<{ relativePath: string; text: string }>;
|
|
96
|
+
monitoring: Array<{ relativePath: string; text: string }>;
|
|
97
|
+
monitoringConcernLogPaths: Record<string, string>;
|
|
98
|
+
}> {
|
|
99
|
+
const declarations = await readConcernRegistry(roots.coachingDocsDir);
|
|
100
|
+
const monitoringConcernLogPaths: Record<string, string> = {};
|
|
101
|
+
for (const declaration of declarations) {
|
|
102
|
+
monitoringConcernLogPaths[declaration.concernId] = declaration.logPath;
|
|
103
|
+
}
|
|
104
|
+
let names: string[] = [];
|
|
105
|
+
try {
|
|
106
|
+
names = await readdir(roots.prescriptionsDir);
|
|
107
|
+
} catch {
|
|
108
|
+
names = [];
|
|
109
|
+
}
|
|
110
|
+
const prescriptions: Array<{ relativePath: string; text: string }> = [];
|
|
111
|
+
for (const name of names.filter((entry) => /\.ya?ml$/.test(entry)).sort()) {
|
|
112
|
+
prescriptions.push({
|
|
113
|
+
relativePath: name,
|
|
114
|
+
text: await readFile(join(roots.prescriptionsDir, name), "utf8"),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const candidates = new Set<string>(Object.values(monitoringConcernLogPaths));
|
|
118
|
+
const consultations: Array<{ relativePath: string; text: string }> = [];
|
|
119
|
+
try {
|
|
120
|
+
consultations.push({
|
|
121
|
+
relativePath: "coaching/consultations.md",
|
|
122
|
+
text: await readFile(join(roots.coachingDocsDir, "coaching/consultations.md"), "utf8"),
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// No legacy consultation log — nothing to import.
|
|
126
|
+
}
|
|
127
|
+
// Monitoring: every declared concern log plus any generated shared view
|
|
128
|
+
// under monitoring/ (Doctor-Prep summaries are derived output, never
|
|
129
|
+
// imported).
|
|
130
|
+
const monitoring: Array<{ relativePath: string; text: string }> = [];
|
|
131
|
+
try {
|
|
132
|
+
for (const name of await readdir(join(roots.coachingDocsDir, "monitoring"))) {
|
|
133
|
+
if (/\.md$/.test(name) && !/^doctor-prep/i.test(name)) candidates.add(`monitoring/${name}`);
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// No monitoring directory — nothing to import.
|
|
137
|
+
}
|
|
138
|
+
for (const relativePath of [...candidates].sort()) {
|
|
139
|
+
const text = await readFile(join(roots.coachingDocsDir, relativePath), "utf8").catch(() => null);
|
|
140
|
+
if (text !== null) monitoring.push({ relativePath, text });
|
|
141
|
+
}
|
|
142
|
+
return { prescriptions, consultations, monitoring, monitoringConcernLogPaths };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Renders the record-derived views into `renderRoot` and byte-compares each
|
|
147
|
+
* against the current source file at its mapped location. Returns the exit
|
|
148
|
+
* code: 0 when everything matches, 1 with printed differing paths otherwise.
|
|
149
|
+
*/
|
|
150
|
+
async function runCompare(roots: BaselineRoots, renderRoot: string): Promise<number> {
|
|
151
|
+
const records = migrationActiveRecords(planLegacyImport(await readSources(roots)));
|
|
152
|
+
if (records.length === 0) {
|
|
153
|
+
console.log("nothing imported — no compatibility views to compare");
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const appliedAt = records
|
|
157
|
+
.map((record) => record.submittedAt)
|
|
158
|
+
.reduce((latest, at) => (at > latest ? at : latest));
|
|
159
|
+
const { views, stale } = computeDesiredViews(records, appliedAt, roots.coachingDocsDir, roots.prescriptionsDir);
|
|
160
|
+
for (const staleEntry of stale) {
|
|
161
|
+
console.error(`STALE ${staleEntry.path}: ${staleEntry.reason}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const differences: string[] = [];
|
|
165
|
+
for (const view of views) {
|
|
166
|
+
const renderedPath = join(renderRoot, view.relativePath);
|
|
167
|
+
await mkdir(dirname(renderedPath), { recursive: true });
|
|
168
|
+
await writeFile(renderedPath, view.content, "utf8");
|
|
169
|
+
const current = await readFile(view.absoluteTarget, "utf8").catch(() => null);
|
|
170
|
+
if (current !== view.content) differences.push(view.relativePath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (differences.length > 0 || stale.length > 0) {
|
|
174
|
+
for (const path of [...stale.map((entry) => entry.path), ...differences]) {
|
|
175
|
+
console.log(`DIFF ${path}`);
|
|
176
|
+
}
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
console.log(`all ${views.length} compatibility view(s) match byte-for-byte`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function main(argv: string[]): Promise<number> {
|
|
184
|
+
const { mode, flags } = parseArgs(argv);
|
|
185
|
+
switch (mode) {
|
|
186
|
+
case "scan": {
|
|
187
|
+
const roots = await loadRoots(requireFlag(flags, "config"));
|
|
188
|
+
console.log(JSON.stringify(await scanBaseline(roots), null, 2));
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
case "apply-baseline": {
|
|
192
|
+
// Plan-only by design: the scan carries the artifact roots it was
|
|
193
|
+
// built from, so application needs exactly --plan and --expect.
|
|
194
|
+
const scan = JSON.parse(await readFile(requireFlag(flags, "plan"), "utf8")) as BaselineScan;
|
|
195
|
+
const outcome = await applyBaseline(scan, requireFlag(flags, "expect"));
|
|
196
|
+
console.log(
|
|
197
|
+
JSON.stringify({ afterHash: outcome.afterHash, written: outcome.written, unchanged: outcome.unchanged }, null, 2),
|
|
198
|
+
);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
case "emit-change-set": {
|
|
202
|
+
const roots = await loadRoots(requireFlag(flags, "config"));
|
|
203
|
+
const output = requireFlag(flags, "output");
|
|
204
|
+
const changeSets = planLegacyImport(await readSources(roots));
|
|
205
|
+
await mkdir(dirname(output), { recursive: true });
|
|
206
|
+
await writeFile(output, JSON.stringify(changeSets, null, 2) + "\n", "utf8");
|
|
207
|
+
const states = changeSets.reduce((sum, set) => sum + set.state_changes.length, 0);
|
|
208
|
+
const events = changeSets.reduce((sum, set) => sum + set.events.length, 0);
|
|
209
|
+
console.log(`wrote ${changeSets.length} change set(s): ${states} state change(s), ${events} event(s) to ${output}`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
case "compare": {
|
|
213
|
+
const roots = await loadRoots(requireFlag(flags, "config"));
|
|
214
|
+
return runCompare(roots, requireFlag(flags, "render-root"));
|
|
215
|
+
}
|
|
216
|
+
case "--help":
|
|
217
|
+
case "-h":
|
|
218
|
+
case "help":
|
|
219
|
+
console.log(usage());
|
|
220
|
+
return 0;
|
|
221
|
+
default:
|
|
222
|
+
fail(`unknown mode "${mode}"\n${usage()}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Only auto-run when executed directly, never under vitest imports.
|
|
227
|
+
if (process.argv[1] !== undefined && import.meta.url.endsWith(basename(process.argv[1]))) {
|
|
228
|
+
main(process.argv.slice(2))
|
|
229
|
+
.then((code) => process.exit(code))
|
|
230
|
+
.catch((error: unknown) => {
|
|
231
|
+
if (error instanceof MigrationError || error instanceof EngramCoachConfigError) fail(error.message);
|
|
232
|
+
throw error;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { fileURLToPath } from 'url';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { loadConfig } from './stream-analyze.js';
|
|
4
|
+
import type { IntervalsIcuConfig } from './stream-analyze.js';
|
|
5
|
+
|
|
6
|
+
export interface RaceWeather {
|
|
7
|
+
temp_c: number | null;
|
|
8
|
+
wind_kph: number | null;
|
|
9
|
+
conditions: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RaceElevation {
|
|
13
|
+
gain_m: number;
|
|
14
|
+
max_grade_pct: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RaceCourse {
|
|
18
|
+
name: string;
|
|
19
|
+
distance_km: number;
|
|
20
|
+
duration_sec: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RaceContext {
|
|
24
|
+
weather: RaceWeather | null;
|
|
25
|
+
elevation: RaceElevation;
|
|
26
|
+
course: RaceCourse;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractRaceContext(details: Record<string, unknown>): RaceContext {
|
|
30
|
+
const ws = details.weather_summary as Record<string, unknown> | undefined;
|
|
31
|
+
const weather: RaceWeather | null = ws
|
|
32
|
+
? {
|
|
33
|
+
temp_c: typeof ws.temp_c === 'number' ? ws.temp_c : null,
|
|
34
|
+
wind_kph: typeof ws.wind_kph === 'number' ? ws.wind_kph : null,
|
|
35
|
+
conditions: typeof ws.conditions === 'string' ? ws.conditions : null,
|
|
36
|
+
}
|
|
37
|
+
: null;
|
|
38
|
+
const elevation: RaceElevation = {
|
|
39
|
+
gain_m: typeof details.total_elevation_gain === 'number' ? details.total_elevation_gain : 0,
|
|
40
|
+
max_grade_pct: typeof details.max_grade === 'number' ? details.max_grade : 0,
|
|
41
|
+
};
|
|
42
|
+
const distanceM = typeof details.distance === 'number' ? details.distance : 0;
|
|
43
|
+
const course: RaceCourse = {
|
|
44
|
+
name: typeof details.name === 'string' ? details.name : '',
|
|
45
|
+
distance_km: Math.round((distanceM / 1000) * 10) / 10,
|
|
46
|
+
duration_sec: typeof details.moving_time === 'number' ? details.moving_time : 0,
|
|
47
|
+
};
|
|
48
|
+
return { weather, elevation, course };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function fetchActivityDetails(
|
|
52
|
+
config: IntervalsIcuConfig,
|
|
53
|
+
activityId: string,
|
|
54
|
+
): Promise<Record<string, unknown>> {
|
|
55
|
+
const url = `https://intervals.icu/api/v1/activity/${activityId}`;
|
|
56
|
+
const auth = 'Basic ' + Buffer.from(`API_KEY:${config.api_key}`).toString('base64');
|
|
57
|
+
const resp = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
|
|
58
|
+
if (!resp.ok) {
|
|
59
|
+
throw new Error(`Intervals.icu activity API returned ${resp.status}: ${resp.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
return await resp.json();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArgs(args: string[]): { activityId: string; configPath: string } {
|
|
65
|
+
const flagValue = (name: string): string | undefined => {
|
|
66
|
+
const i = args.indexOf(name);
|
|
67
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
68
|
+
};
|
|
69
|
+
const activityId = flagValue('--activity-id');
|
|
70
|
+
if (!activityId) {
|
|
71
|
+
process.stderr.write('Usage: race-context --activity-id <id> [--config <path>]\n');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
const toolDir = dirname(fileURLToPath(import.meta.url));
|
|
75
|
+
const configPath = flagValue('--config') ?? resolve(toolDir, '..', 'config.json');
|
|
76
|
+
return { activityId, configPath };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isDirectExecution =
|
|
80
|
+
process.argv[1] !== undefined &&
|
|
81
|
+
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
82
|
+
|
|
83
|
+
if (isDirectExecution) {
|
|
84
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
85
|
+
(async () => {
|
|
86
|
+
try {
|
|
87
|
+
const config = await loadConfig(parsed.configPath);
|
|
88
|
+
const details = await fetchActivityDetails(config, parsed.activityId);
|
|
89
|
+
const context = extractRaceContext(details);
|
|
90
|
+
process.stdout.write(JSON.stringify(context, null, 2) + '\n');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
}
|