@vibe-hero/server 0.1.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 (150) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +151 -0
  3. package/dist/catalog/bundled/claude-code/.gitkeep +0 -0
  4. package/dist/catalog/bundled/claude-code/context-management.yaml +302 -0
  5. package/dist/catalog/bundled/claude-code/planning.yaml +313 -0
  6. package/dist/catalog/bundled/claude-code/subagents.yaml +357 -0
  7. package/dist/catalog/bundled/general/.gitkeep +0 -0
  8. package/dist/catalog/bundled/general/_placeholder.yaml +39 -0
  9. package/dist/catalog/bundled/general/task-decomposition.yaml +390 -0
  10. package/dist/catalog/bundled/index.d.ts +39 -0
  11. package/dist/catalog/bundled/index.d.ts.map +1 -0
  12. package/dist/catalog/bundled/index.js +41 -0
  13. package/dist/catalog/bundled/index.js.map +1 -0
  14. package/dist/catalog/fetcher.d.ts +201 -0
  15. package/dist/catalog/fetcher.d.ts.map +1 -0
  16. package/dist/catalog/fetcher.js +452 -0
  17. package/dist/catalog/fetcher.js.map +1 -0
  18. package/dist/catalog/loader.d.ts +165 -0
  19. package/dist/catalog/loader.d.ts.map +1 -0
  20. package/dist/catalog/loader.js +241 -0
  21. package/dist/catalog/loader.js.map +1 -0
  22. package/dist/catalog/resolve.d.ts +85 -0
  23. package/dist/catalog/resolve.d.ts.map +1 -0
  24. package/dist/catalog/resolve.js +103 -0
  25. package/dist/catalog/resolve.js.map +1 -0
  26. package/dist/cli/getOffer.d.ts +38 -0
  27. package/dist/cli/getOffer.d.ts.map +1 -0
  28. package/dist/cli/getOffer.js +150 -0
  29. package/dist/cli/getOffer.js.map +1 -0
  30. package/dist/cli/index.d.ts +46 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +88 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/config.d.ts +34 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +63 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/engine/elo.d.ts +76 -0
  39. package/dist/engine/elo.d.ts.map +1 -0
  40. package/dist/engine/elo.js +79 -0
  41. package/dist/engine/elo.js.map +1 -0
  42. package/dist/engine/graduation.d.ts +108 -0
  43. package/dist/engine/graduation.d.ts.map +1 -0
  44. package/dist/engine/graduation.js +161 -0
  45. package/dist/engine/graduation.js.map +1 -0
  46. package/dist/engine/lapse.d.ts +80 -0
  47. package/dist/engine/lapse.d.ts.map +1 -0
  48. package/dist/engine/lapse.js +125 -0
  49. package/dist/engine/lapse.js.map +1 -0
  50. package/dist/engine/selection.d.ts +84 -0
  51. package/dist/engine/selection.d.ts.map +1 -0
  52. package/dist/engine/selection.js +119 -0
  53. package/dist/engine/selection.js.map +1 -0
  54. package/dist/grading/deterministic.d.ts +102 -0
  55. package/dist/grading/deterministic.d.ts.map +1 -0
  56. package/dist/grading/deterministic.js +118 -0
  57. package/dist/grading/deterministic.js.map +1 -0
  58. package/dist/grading/freeform.d.ts +64 -0
  59. package/dist/grading/freeform.d.ts.map +1 -0
  60. package/dist/grading/freeform.js +85 -0
  61. package/dist/grading/freeform.js.map +1 -0
  62. package/dist/index.d.ts +52 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +91 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/observation/hookEvents.d.ts +113 -0
  67. package/dist/observation/hookEvents.d.ts.map +1 -0
  68. package/dist/observation/hookEvents.js +170 -0
  69. package/dist/observation/hookEvents.js.map +1 -0
  70. package/dist/observation/offers.d.ts +215 -0
  71. package/dist/observation/offers.d.ts.map +1 -0
  72. package/dist/observation/offers.js +327 -0
  73. package/dist/observation/offers.js.map +1 -0
  74. package/dist/observation/source.d.ts +133 -0
  75. package/dist/observation/source.d.ts.map +1 -0
  76. package/dist/observation/source.js +105 -0
  77. package/dist/observation/source.js.map +1 -0
  78. package/dist/profile/migrate.d.ts +122 -0
  79. package/dist/profile/migrate.d.ts.map +1 -0
  80. package/dist/profile/migrate.js +147 -0
  81. package/dist/profile/migrate.js.map +1 -0
  82. package/dist/profile/store.d.ts +84 -0
  83. package/dist/profile/store.d.ts.map +1 -0
  84. package/dist/profile/store.js +267 -0
  85. package/dist/profile/store.js.map +1 -0
  86. package/dist/schemas/common.d.ts +95 -0
  87. package/dist/schemas/common.d.ts.map +1 -0
  88. package/dist/schemas/common.js +106 -0
  89. package/dist/schemas/common.js.map +1 -0
  90. package/dist/schemas/content.d.ts +828 -0
  91. package/dist/schemas/content.d.ts.map +1 -0
  92. package/dist/schemas/content.js +219 -0
  93. package/dist/schemas/content.js.map +1 -0
  94. package/dist/schemas/profile.d.ts +599 -0
  95. package/dist/schemas/profile.d.ts.map +1 -0
  96. package/dist/schemas/profile.js +177 -0
  97. package/dist/schemas/profile.js.map +1 -0
  98. package/dist/schemas/tools.d.ts +1581 -0
  99. package/dist/schemas/tools.d.ts.map +1 -0
  100. package/dist/schemas/tools.js +286 -0
  101. package/dist/schemas/tools.js.map +1 -0
  102. package/dist/tools/config.d.ts +51 -0
  103. package/dist/tools/config.d.ts.map +1 -0
  104. package/dist/tools/config.js +104 -0
  105. package/dist/tools/config.js.map +1 -0
  106. package/dist/tools/gate.d.ts +50 -0
  107. package/dist/tools/gate.d.ts.map +1 -0
  108. package/dist/tools/gate.js +67 -0
  109. package/dist/tools/gate.js.map +1 -0
  110. package/dist/tools/guidance.d.ts +36 -0
  111. package/dist/tools/guidance.d.ts.map +1 -0
  112. package/dist/tools/guidance.js +117 -0
  113. package/dist/tools/guidance.js.map +1 -0
  114. package/dist/tools/listTopics.d.ts +55 -0
  115. package/dist/tools/listTopics.d.ts.map +1 -0
  116. package/dist/tools/listTopics.js +78 -0
  117. package/dist/tools/listTopics.js.map +1 -0
  118. package/dist/tools/offers.d.ts +60 -0
  119. package/dist/tools/offers.d.ts.map +1 -0
  120. package/dist/tools/offers.js +152 -0
  121. package/dist/tools/offers.js.map +1 -0
  122. package/dist/tools/placeholders.d.ts +27 -0
  123. package/dist/tools/placeholders.d.ts.map +1 -0
  124. package/dist/tools/placeholders.js +49 -0
  125. package/dist/tools/placeholders.js.map +1 -0
  126. package/dist/tools/recordObservation.d.ts +52 -0
  127. package/dist/tools/recordObservation.d.ts.map +1 -0
  128. package/dist/tools/recordObservation.js +87 -0
  129. package/dist/tools/recordObservation.js.map +1 -0
  130. package/dist/tools/startQuiz.d.ts +82 -0
  131. package/dist/tools/startQuiz.d.ts.map +1 -0
  132. package/dist/tools/startQuiz.js +180 -0
  133. package/dist/tools/startQuiz.js.map +1 -0
  134. package/dist/tools/status.d.ts +59 -0
  135. package/dist/tools/status.d.ts.map +1 -0
  136. package/dist/tools/status.js +133 -0
  137. package/dist/tools/status.js.map +1 -0
  138. package/dist/tools/submitAnswer.d.ts +156 -0
  139. package/dist/tools/submitAnswer.d.ts.map +1 -0
  140. package/dist/tools/submitAnswer.js +402 -0
  141. package/dist/tools/submitAnswer.js.map +1 -0
  142. package/dist/tools/types.d.ts +82 -0
  143. package/dist/tools/types.d.ts.map +1 -0
  144. package/dist/tools/types.js +48 -0
  145. package/dist/tools/types.js.map +1 -0
  146. package/dist/tools/us2/standing.d.ts +111 -0
  147. package/dist/tools/us2/standing.d.ts.map +1 -0
  148. package/dist/tools/us2/standing.js +143 -0
  149. package/dist/tools/us2/standing.js.map +1 -0
  150. package/package.json +62 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @file Profile schema-version / migration policy (T056, analyze finding E6).
3
+ *
4
+ * Centralizes how a persisted {@link Profile} of *some* on-disk schema version is
5
+ * brought up to the version this build understands ({@link CURRENT_PROFILE_SCHEMA_VERSION}).
6
+ * Before this module the only version handling was an implicit Zod `safeParse`
7
+ * inside the store; there was no explicit forward/back-compat policy, so a
8
+ * profile written by a *newer* build would simply fail validation and be treated
9
+ * as "corrupt" — silently discarding data we don't understand (E6).
10
+ *
11
+ * The policy, expressed as one function ({@link migrateProfile}), is:
12
+ *
13
+ * 1. **Same version, valid** → pass through unchanged (`migrated: false`).
14
+ * 2. **Older known version** → run the ordered {@link MIGRATIONS} steps from the
15
+ * on-disk version up to current, then validate (`migrated: true`). For v1
16
+ * there is exactly one version so the registry is empty (a documented stub);
17
+ * the SHAPE exists so future `v1 → v2` etc. steps slot in without touching
18
+ * callers. Additive fields already default via Zod (e.g. `dwell`,
19
+ * `candidateKeys`), so a forward-compatible additive change needs no step at
20
+ * all — the registry is for *structural* migrations that Zod defaults can't
21
+ * express.
22
+ * 3. **Newer (future) version** → do NOT crash and do NOT discard (FR-023
23
+ * "tolerate gracefully"). We can't safely read a shape this build predates,
24
+ * so we fall back to a fresh empty profile for *this* run and signal
25
+ * `reset: true` with `preserved: true`. The store leaves the on-disk file
26
+ * untouched — overwriting a newer profile we don't understand would destroy
27
+ * a forward-compatible user's data, so we explicitly refuse to.
28
+ * 4. **Corrupt / invalid** (bad shape at the resolved version) → fresh empty
29
+ * profile (`reset: true`), consistent with the store's prior behavior of
30
+ * degrading an unreadable file to {@link emptyProfile}.
31
+ *
32
+ * `migrateProfile` is pure (no IO): the store decides what to persist based on
33
+ * the returned flags. This keeps the version policy unit-testable in isolation.
34
+ *
35
+ * Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-023), data-model.md
36
+ * (§ Profile `schemaVersion` "migration guard"), tasks.md T056.
37
+ */
38
+ import { type Profile } from "../schemas/profile.js";
39
+ /**
40
+ * The profile schema version this build understands. Centralized here (re-exported
41
+ * from the single source, {@link PROFILE_SCHEMA_VERSION}) so version policy lives
42
+ * in one place; the store and tests import it from this module.
43
+ */
44
+ export declare const CURRENT_PROFILE_SCHEMA_VERSION = 1;
45
+ /**
46
+ * A single forward migration step: transforms a parsed profile document from
47
+ * `from` to `from + 1`. Steps operate on `unknown` (the raw parsed JSON, not a
48
+ * validated {@link Profile}) because an older document is, by definition, NOT yet
49
+ * shaped like the *current* schema — the step's job is to reshape it. The final
50
+ * shape is validated once, after all steps run.
51
+ */
52
+ export interface MigrationStep {
53
+ /** The schema version this step upgrades FROM (it produces `from + 1`). */
54
+ readonly from: number;
55
+ /** Pure transform of the raw document toward the next version. */
56
+ readonly up: (raw: Record<string, unknown>) => Record<string, unknown>;
57
+ }
58
+ /**
59
+ * Ordered registry of forward migration steps, keyed by their `from` version.
60
+ *
61
+ * **v1 is the only version**, so this is intentionally empty — a documented stub.
62
+ * The registry SHAPE exists so a future schema bump is purely additive here:
63
+ * add a `{ from: 1, up }` step that reshapes a v1 document into v2 and bump
64
+ * {@link CURRENT_PROFILE_SCHEMA_VERSION} (via the source `PROFILE_SCHEMA_VERSION`).
65
+ * {@link migrateProfile} will then automatically chain `from` → current.
66
+ *
67
+ * Note: purely *additive* fields that a new field's Zod `.default(...)` can fill
68
+ * (the pattern already used for `dwell` and `candidateKeys`) need NO step — an old
69
+ * document validates forward for free. Steps are reserved for structural changes
70
+ * (renames, splits, type changes) Zod defaults cannot express.
71
+ */
72
+ export declare const MIGRATIONS: readonly MigrationStep[];
73
+ /** Result of {@link migrateProfile}. Discriminated on `reset`. */
74
+ export type MigrateResult = {
75
+ /** No reset: the document was usable at (or migrated up to) current. */
76
+ readonly reset: false;
77
+ /** The validated, current-version profile. */
78
+ readonly profile: Profile;
79
+ /** `true` iff at least one migration step ran (older → current). */
80
+ readonly migrated: boolean;
81
+ } | {
82
+ /** Reset: the document was unreadable at the current version. */
83
+ readonly reset: true;
84
+ /** A fresh empty profile to use for this run. */
85
+ readonly profile: Profile;
86
+ /**
87
+ * `true` when the reset is because the document is a NEWER version than
88
+ * this build understands — the on-disk file MUST be preserved (not
89
+ * overwritten). `false` for a corrupt/invalid document, which the store
90
+ * may overwrite on its next write as before.
91
+ */
92
+ readonly preserved: boolean;
93
+ };
94
+ /**
95
+ * Dependency-injection seam for {@link migrateProfile}. Both fields default to
96
+ * the shipped values; they exist ONLY so the older→migrated path is genuinely
97
+ * testable while v1 is the only real version (and so future contributors can
98
+ * unit-test a `v2`/`v3` step in isolation before wiring it in). Production
99
+ * callers pass nothing.
100
+ */
101
+ export interface MigrateOptions {
102
+ /** Override the schema version this run targets (defaults to current). */
103
+ readonly currentVersion?: number;
104
+ /** Override the migration registry (defaults to {@link MIGRATIONS}). */
105
+ readonly migrations?: readonly MigrationStep[];
106
+ }
107
+ /**
108
+ * Route a parsed-JSON profile document of unknown schema version through the
109
+ * forward/back-compat policy documented at the top of this file.
110
+ *
111
+ * Pure: performs no IO and never throws for *data* reasons (only the internal
112
+ * programming-error guard in {@link applyMigrations} can throw, and only if the
113
+ * registry is left inconsistent with the current version). The store interprets
114
+ * the result's flags to decide whether to preserve or overwrite the file.
115
+ *
116
+ * @param raw - The already-parsed JSON of the on-disk profile (not the raw text).
117
+ * @param options - Test/forward-dev seam; production callers omit it.
118
+ * @returns A {@link MigrateResult}: pass-through, migrated, or reset (corrupt vs.
119
+ * newer-preserved).
120
+ */
121
+ export declare const migrateProfile: (raw: unknown, options?: MigrateOptions) => MigrateResult;
122
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../../src/profile/migrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAIL,KAAK,OAAO,EACb,MAAM,uBAAuB,CAAC;AAE/B;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,IAAyB,CAAC;AAErE;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxE;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU,EAAE,SAAS,aAAa,EAAO,CAAC;AAEvD,kEAAkE;AAClE,MAAM,MAAM,aAAa,GACrB;IACE,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,8CAA8C;IAC9C,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B,GACD;IACE,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC;IACrB,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B,CAAC;AA2CN;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B,0EAA0E;IAC1E,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,wEAAwE;IACxE,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;CAChD;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GACzB,KAAK,OAAO,EACZ,UAAS,cAAmB,KAC3B,aAgDF,CAAC"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @file Profile schema-version / migration policy (T056, analyze finding E6).
3
+ *
4
+ * Centralizes how a persisted {@link Profile} of *some* on-disk schema version is
5
+ * brought up to the version this build understands ({@link CURRENT_PROFILE_SCHEMA_VERSION}).
6
+ * Before this module the only version handling was an implicit Zod `safeParse`
7
+ * inside the store; there was no explicit forward/back-compat policy, so a
8
+ * profile written by a *newer* build would simply fail validation and be treated
9
+ * as "corrupt" — silently discarding data we don't understand (E6).
10
+ *
11
+ * The policy, expressed as one function ({@link migrateProfile}), is:
12
+ *
13
+ * 1. **Same version, valid** → pass through unchanged (`migrated: false`).
14
+ * 2. **Older known version** → run the ordered {@link MIGRATIONS} steps from the
15
+ * on-disk version up to current, then validate (`migrated: true`). For v1
16
+ * there is exactly one version so the registry is empty (a documented stub);
17
+ * the SHAPE exists so future `v1 → v2` etc. steps slot in without touching
18
+ * callers. Additive fields already default via Zod (e.g. `dwell`,
19
+ * `candidateKeys`), so a forward-compatible additive change needs no step at
20
+ * all — the registry is for *structural* migrations that Zod defaults can't
21
+ * express.
22
+ * 3. **Newer (future) version** → do NOT crash and do NOT discard (FR-023
23
+ * "tolerate gracefully"). We can't safely read a shape this build predates,
24
+ * so we fall back to a fresh empty profile for *this* run and signal
25
+ * `reset: true` with `preserved: true`. The store leaves the on-disk file
26
+ * untouched — overwriting a newer profile we don't understand would destroy
27
+ * a forward-compatible user's data, so we explicitly refuse to.
28
+ * 4. **Corrupt / invalid** (bad shape at the resolved version) → fresh empty
29
+ * profile (`reset: true`), consistent with the store's prior behavior of
30
+ * degrading an unreadable file to {@link emptyProfile}.
31
+ *
32
+ * `migrateProfile` is pure (no IO): the store decides what to persist based on
33
+ * the returned flags. This keeps the version policy unit-testable in isolation.
34
+ *
35
+ * Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-023), data-model.md
36
+ * (§ Profile `schemaVersion` "migration guard"), tasks.md T056.
37
+ */
38
+ import { ProfileSchema, emptyProfile, PROFILE_SCHEMA_VERSION, } from "../schemas/profile.js";
39
+ /**
40
+ * The profile schema version this build understands. Centralized here (re-exported
41
+ * from the single source, {@link PROFILE_SCHEMA_VERSION}) so version policy lives
42
+ * in one place; the store and tests import it from this module.
43
+ */
44
+ export const CURRENT_PROFILE_SCHEMA_VERSION = PROFILE_SCHEMA_VERSION;
45
+ /**
46
+ * Ordered registry of forward migration steps, keyed by their `from` version.
47
+ *
48
+ * **v1 is the only version**, so this is intentionally empty — a documented stub.
49
+ * The registry SHAPE exists so a future schema bump is purely additive here:
50
+ * add a `{ from: 1, up }` step that reshapes a v1 document into v2 and bump
51
+ * {@link CURRENT_PROFILE_SCHEMA_VERSION} (via the source `PROFILE_SCHEMA_VERSION`).
52
+ * {@link migrateProfile} will then automatically chain `from` → current.
53
+ *
54
+ * Note: purely *additive* fields that a new field's Zod `.default(...)` can fill
55
+ * (the pattern already used for `dwell` and `candidateKeys`) need NO step — an old
56
+ * document validates forward for free. Steps are reserved for structural changes
57
+ * (renames, splits, type changes) Zod defaults cannot express.
58
+ */
59
+ export const MIGRATIONS = [];
60
+ /**
61
+ * Read the declared `schemaVersion` from a parsed-JSON document, or `undefined`
62
+ * if the value is missing or not a positive integer. We read it leniently (not
63
+ * via the full {@link ProfileSchema}) precisely so we can route a *newer* document
64
+ * to the tolerate path rather than failing it as "corrupt".
65
+ */
66
+ const readDeclaredVersion = (raw) => {
67
+ if (typeof raw !== "object" || raw === null)
68
+ return undefined;
69
+ const v = raw.schemaVersion;
70
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
71
+ };
72
+ /**
73
+ * Apply migration steps in order from `fromVersion` up to `currentVersion`,
74
+ * picking each step from `migrations`. Returns the reshaped raw document and
75
+ * whether any step actually ran. Throws if a required step is missing (a
76
+ * programming error — a known-older version with a gap in the registry).
77
+ */
78
+ const applyMigrations = (raw, fromVersion, currentVersion, migrations) => {
79
+ let doc = raw;
80
+ let migrated = false;
81
+ for (let v = fromVersion; v < currentVersion; v++) {
82
+ const step = migrations.find((s) => s.from === v);
83
+ if (step === undefined) {
84
+ throw new Error(`no migration step registered for profile schema v${v} → v${v + 1}`);
85
+ }
86
+ doc = step.up(doc);
87
+ migrated = true;
88
+ }
89
+ // Stamp the version the document now conforms to so the post-migration
90
+ // validation sees the current version regardless of what each step set.
91
+ return { doc: { ...doc, schemaVersion: currentVersion }, migrated };
92
+ };
93
+ /**
94
+ * Route a parsed-JSON profile document of unknown schema version through the
95
+ * forward/back-compat policy documented at the top of this file.
96
+ *
97
+ * Pure: performs no IO and never throws for *data* reasons (only the internal
98
+ * programming-error guard in {@link applyMigrations} can throw, and only if the
99
+ * registry is left inconsistent with the current version). The store interprets
100
+ * the result's flags to decide whether to preserve or overwrite the file.
101
+ *
102
+ * @param raw - The already-parsed JSON of the on-disk profile (not the raw text).
103
+ * @param options - Test/forward-dev seam; production callers omit it.
104
+ * @returns A {@link MigrateResult}: pass-through, migrated, or reset (corrupt vs.
105
+ * newer-preserved).
106
+ */
107
+ export const migrateProfile = (raw, options = {}) => {
108
+ const currentVersion = options.currentVersion ?? CURRENT_PROFILE_SCHEMA_VERSION;
109
+ const migrations = options.migrations ?? MIGRATIONS;
110
+ const declared = readDeclaredVersion(raw);
111
+ // --- newer than we understand → tolerate, preserve on disk (FR-023) -------
112
+ if (declared !== undefined && declared > currentVersion) {
113
+ process.stderr.write(`vibe-hero: profile schema v${declared} is newer than this build supports ` +
114
+ `(v${currentVersion}); using a temporary empty profile and ` +
115
+ `leaving the existing file untouched to avoid downgrading it.\n`);
116
+ return { reset: true, profile: emptyProfile(), preserved: true };
117
+ }
118
+ // --- known version (current or older) → migrate then validate -------------
119
+ if (declared !== undefined) {
120
+ let result;
121
+ try {
122
+ result = applyMigrations(raw, declared, currentVersion, migrations);
123
+ }
124
+ catch {
125
+ // A registry gap for a known-older version: treat as unreadable rather
126
+ // than crash the server (consistent with the corrupt-file degrade path).
127
+ return { reset: true, profile: emptyProfile(), preserved: false };
128
+ }
129
+ const parsed = ProfileSchema.safeParse(result.doc);
130
+ if (parsed.success) {
131
+ return { reset: false, profile: parsed.data, migrated: result.migrated };
132
+ }
133
+ // Migrated/same-version doc that still doesn't validate → corrupt.
134
+ return { reset: true, profile: emptyProfile(), preserved: false };
135
+ }
136
+ // --- no usable version (missing field / not an object / corrupt) ----------
137
+ // Last-chance validation: a well-formed current-version doc whose schemaVersion
138
+ // somehow read as non-positive would have been caught above; anything here is
139
+ // genuinely unreadable. Try a direct parse so a structurally-valid doc isn't
140
+ // discarded on a version-read technicality, then degrade to empty.
141
+ const parsed = ProfileSchema.safeParse(raw);
142
+ if (parsed.success) {
143
+ return { reset: false, profile: parsed.data, migrated: false };
144
+ }
145
+ return { reset: true, profile: emptyProfile(), preserved: false };
146
+ };
147
+ //# sourceMappingURL=migrate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate.js","sourceRoot":"","sources":["../../src/profile/migrate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file Learner-profile persistence (T013).
3
+ *
4
+ * The profile is a single Zod-validated JSON document at
5
+ * `${VIBE_HERO_HOME}/profile.json` (default `~/.vibe-hero/`). It may be written
6
+ * by multiple concurrent host sessions, so every mutation is **atomic and
7
+ * serialized**: writes go through a temp file + `fs.rename` under an advisory
8
+ * lock acquired via `proper-lockfile` (FR-023a). Reads never throw — a missing,
9
+ * unreadable, or corrupt file degrades to a fresh empty profile (FR-023).
10
+ *
11
+ * The fs/lock boundary is intentionally thin; everything else is pure data
12
+ * shuffling around {@link ProfileSchema}.
13
+ *
14
+ * Source of truth: specs/001-vibe-hero-mvp/data-model.md (§ Storage notes),
15
+ * spec.md FR-022 / FR-023 / FR-023a.
16
+ */
17
+ import { type Profile } from "../schemas/profile.js";
18
+ /**
19
+ * Resolve the directory that holds the profile document.
20
+ *
21
+ * @param dirOverride - Explicit directory (test seam). When omitted, falls back
22
+ * to `VIBE_HERO_HOME`, then to `~/.vibe-hero`.
23
+ * @returns Absolute path to the profile directory.
24
+ */
25
+ export declare const profileDir: (dirOverride?: string) => string;
26
+ /**
27
+ * Resolve the absolute path to the profile JSON document.
28
+ *
29
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
30
+ * @returns Absolute path to `profile.json`.
31
+ */
32
+ export declare const profilePath: (dirOverride?: string) => string;
33
+ /**
34
+ * Read and validate the profile.
35
+ *
36
+ * Never throws: a missing, unreadable, or schema-invalid file degrades to a
37
+ * fresh {@link emptyProfile} so the server can always operate (FR-023). Version
38
+ * handling is centralized in {@link migrateProfile} (T056): a same-version doc
39
+ * passes through, an older known version is migrated up, and a *newer* version
40
+ * (written by a future build) degrades to an empty profile for this run WITHOUT
41
+ * overwriting the file — `loadProfile` never writes, so a forward-compatible
42
+ * profile we don't understand is preserved on disk. A corrupt file is logged to
43
+ * stderr (without printing its contents — it may hold profile data) and left on
44
+ * disk untouched; the next successful {@link saveProfile} / {@link updateProfile}
45
+ * overwrites it.
46
+ *
47
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
48
+ * @returns The validated profile, or an empty profile on any failure.
49
+ */
50
+ export declare const loadProfile: (dirOverride?: string) => Promise<Profile>;
51
+ /**
52
+ * Persist a profile atomically and serialized (FR-023a).
53
+ *
54
+ * Ensures the directory exists, acquires the advisory lock, writes to a unique
55
+ * temp file in the same directory, then `fs.rename`s it over `profile.json`
56
+ * (atomic on POSIX since both paths share a filesystem). `updatedAt` is bumped
57
+ * to now. The lock is always released, even on failure.
58
+ *
59
+ * Prefer {@link updateProfile} for read-modify-write mutations — calling
60
+ * {@link loadProfile} then `saveProfile` is racy (the load happens outside the
61
+ * lock and a concurrent writer can interleave).
62
+ *
63
+ * @param profile - The profile to persist.
64
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
65
+ */
66
+ export declare const saveProfile: (profile: Profile, dirOverride?: string) => Promise<void>;
67
+ /**
68
+ * Read-modify-write the profile under the lock so concurrent updaters serialize
69
+ * and no update is lost (FR-023a / edge case E1). This is the primary mutation
70
+ * API.
71
+ *
72
+ * The sequence is: acquire lock → read the *current* on-disk profile (or empty
73
+ * if missing/corrupt) → apply `fn` → atomic write → release. Because both the
74
+ * read and the write happen inside the same lock, two concurrent callers run
75
+ * strictly one-after-another and each sees the other's committed result.
76
+ *
77
+ * @param fn - Pure-ish transform from the current profile to the next one. May
78
+ * be async. It should return a new profile object rather than mutating the
79
+ * argument, though either works since the result is what gets written.
80
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
81
+ * @returns The profile that was written (with `updatedAt` bumped).
82
+ */
83
+ export declare const updateProfile: (fn: (current: Profile) => Profile | Promise<Profile>, dirOverride?: string) => Promise<Profile>;
84
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/profile/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH,OAAO,EAA+B,KAAK,OAAO,EAAE,MAAM,uBAAuB,CAAC;AA+BlF;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,GAAI,cAAc,MAAM,KAAG,MAKjD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,cAAc,MAAM,KAAG,MACG,CAAC;AAEvD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,WAAW,GAAU,cAAc,MAAM,KAAG,OAAO,CAAC,OAAO,CAkCvE,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW,GACtB,SAAS,OAAO,EAChB,cAAc,MAAM,KACnB,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa,GACxB,IAAI,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,EACpD,cAAc,MAAM,KACnB,OAAO,CAAC,OAAO,CAWjB,CAAC"}
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @file Learner-profile persistence (T013).
3
+ *
4
+ * The profile is a single Zod-validated JSON document at
5
+ * `${VIBE_HERO_HOME}/profile.json` (default `~/.vibe-hero/`). It may be written
6
+ * by multiple concurrent host sessions, so every mutation is **atomic and
7
+ * serialized**: writes go through a temp file + `fs.rename` under an advisory
8
+ * lock acquired via `proper-lockfile` (FR-023a). Reads never throw — a missing,
9
+ * unreadable, or corrupt file degrades to a fresh empty profile (FR-023).
10
+ *
11
+ * The fs/lock boundary is intentionally thin; everything else is pure data
12
+ * shuffling around {@link ProfileSchema}.
13
+ *
14
+ * Source of truth: specs/001-vibe-hero-mvp/data-model.md (§ Storage notes),
15
+ * spec.md FR-022 / FR-023 / FR-023a.
16
+ */
17
+ import { randomBytes } from "node:crypto";
18
+ import { homedir } from "node:os";
19
+ import * as path from "node:path";
20
+ import * as fs from "node:fs/promises";
21
+ import * as lockfile from "proper-lockfile";
22
+ import { ProfileSchema, emptyProfile } from "../schemas/profile.js";
23
+ import { migrateProfile } from "./migrate.js";
24
+ /** Basename of the profile document within the profile directory. */
25
+ const PROFILE_FILENAME = "profile.json";
26
+ /** Default profile home when `VIBE_HERO_HOME` is unset (`~/.vibe-hero`). */
27
+ const DEFAULT_DIRNAME = ".vibe-hero";
28
+ /**
29
+ * Lock-acquisition tuning. `proper-lockfile` treats the lock as *stale* after
30
+ * `stale` ms (so a crashed writer can never wedge the profile forever), and a
31
+ * contending writer retries with exponential backoff up to `retries.retries`
32
+ * times. The window is generous enough to serialize bursts of concurrent
33
+ * `updateProfile` calls (FR-023a / edge case E1) without spuriously failing.
34
+ */
35
+ const LOCK_OPTIONS = {
36
+ stale: 15_000,
37
+ // Resolve against the literal path we pass; the file is guaranteed to exist
38
+ // before we lock (see `acquireLock`), but skipping realpath avoids symlink
39
+ // surprises (e.g. macOS `/var` → `/private/var` under os.tmpdir()).
40
+ realpath: false,
41
+ retries: {
42
+ retries: 50,
43
+ factor: 1.5,
44
+ minTimeout: 10,
45
+ maxTimeout: 200,
46
+ randomize: true,
47
+ },
48
+ };
49
+ /**
50
+ * Resolve the directory that holds the profile document.
51
+ *
52
+ * @param dirOverride - Explicit directory (test seam). When omitted, falls back
53
+ * to `VIBE_HERO_HOME`, then to `~/.vibe-hero`.
54
+ * @returns Absolute path to the profile directory.
55
+ */
56
+ export const profileDir = (dirOverride) => {
57
+ if (dirOverride !== undefined && dirOverride !== "")
58
+ return path.resolve(dirOverride);
59
+ const fromEnv = process.env["VIBE_HERO_HOME"];
60
+ if (fromEnv !== undefined && fromEnv !== "")
61
+ return path.resolve(fromEnv);
62
+ return path.join(homedir(), DEFAULT_DIRNAME);
63
+ };
64
+ /**
65
+ * Resolve the absolute path to the profile JSON document.
66
+ *
67
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
68
+ * @returns Absolute path to `profile.json`.
69
+ */
70
+ export const profilePath = (dirOverride) => path.join(profileDir(dirOverride), PROFILE_FILENAME);
71
+ /**
72
+ * Read and validate the profile.
73
+ *
74
+ * Never throws: a missing, unreadable, or schema-invalid file degrades to a
75
+ * fresh {@link emptyProfile} so the server can always operate (FR-023). Version
76
+ * handling is centralized in {@link migrateProfile} (T056): a same-version doc
77
+ * passes through, an older known version is migrated up, and a *newer* version
78
+ * (written by a future build) degrades to an empty profile for this run WITHOUT
79
+ * overwriting the file — `loadProfile` never writes, so a forward-compatible
80
+ * profile we don't understand is preserved on disk. A corrupt file is logged to
81
+ * stderr (without printing its contents — it may hold profile data) and left on
82
+ * disk untouched; the next successful {@link saveProfile} / {@link updateProfile}
83
+ * overwrites it.
84
+ *
85
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
86
+ * @returns The validated profile, or an empty profile on any failure.
87
+ */
88
+ export const loadProfile = async (dirOverride) => {
89
+ const file = profilePath(dirOverride);
90
+ let raw;
91
+ try {
92
+ raw = await fs.readFile(file, "utf8");
93
+ }
94
+ catch (err) {
95
+ // ENOENT is the normal first-run path; anything else (perms, etc.) is also
96
+ // non-fatal — we still hand back a usable empty profile.
97
+ if (!isNotFound(err)) {
98
+ process.stderr.write(`vibe-hero: could not read profile at ${file}; starting from an empty profile.\n`);
99
+ }
100
+ return emptyProfile();
101
+ }
102
+ const json = parseJson(raw);
103
+ if (json === undefined) {
104
+ process.stderr.write(`vibe-hero: profile at ${file} is corrupt or invalid; starting from an empty profile (file left untouched).\n`);
105
+ return emptyProfile();
106
+ }
107
+ // Centralized version policy (T056). migrateProfile owns same/older/newer
108
+ // routing and emits its own newer-version warning. We only add a stderr note
109
+ // for the corrupt-at-version case to preserve the prior diagnostic.
110
+ const result = migrateProfile(json);
111
+ if (result.reset && !result.preserved) {
112
+ process.stderr.write(`vibe-hero: profile at ${file} is corrupt or invalid; starting from an empty profile (file left untouched).\n`);
113
+ }
114
+ return result.profile;
115
+ };
116
+ /**
117
+ * Persist a profile atomically and serialized (FR-023a).
118
+ *
119
+ * Ensures the directory exists, acquires the advisory lock, writes to a unique
120
+ * temp file in the same directory, then `fs.rename`s it over `profile.json`
121
+ * (atomic on POSIX since both paths share a filesystem). `updatedAt` is bumped
122
+ * to now. The lock is always released, even on failure.
123
+ *
124
+ * Prefer {@link updateProfile} for read-modify-write mutations — calling
125
+ * {@link loadProfile} then `saveProfile` is racy (the load happens outside the
126
+ * lock and a concurrent writer can interleave).
127
+ *
128
+ * @param profile - The profile to persist.
129
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
130
+ */
131
+ export const saveProfile = async (profile, dirOverride) => {
132
+ const dir = profileDir(dirOverride);
133
+ const file = path.join(dir, PROFILE_FILENAME);
134
+ const release = await acquireLock(dir, file);
135
+ try {
136
+ await writeAtomic(dir, file, profile);
137
+ }
138
+ finally {
139
+ await release();
140
+ }
141
+ };
142
+ /**
143
+ * Read-modify-write the profile under the lock so concurrent updaters serialize
144
+ * and no update is lost (FR-023a / edge case E1). This is the primary mutation
145
+ * API.
146
+ *
147
+ * The sequence is: acquire lock → read the *current* on-disk profile (or empty
148
+ * if missing/corrupt) → apply `fn` → atomic write → release. Because both the
149
+ * read and the write happen inside the same lock, two concurrent callers run
150
+ * strictly one-after-another and each sees the other's committed result.
151
+ *
152
+ * @param fn - Pure-ish transform from the current profile to the next one. May
153
+ * be async. It should return a new profile object rather than mutating the
154
+ * argument, though either works since the result is what gets written.
155
+ * @param dirOverride - Explicit directory (test seam); see {@link profileDir}.
156
+ * @returns The profile that was written (with `updatedAt` bumped).
157
+ */
158
+ export const updateProfile = async (fn, dirOverride) => {
159
+ const dir = profileDir(dirOverride);
160
+ const file = path.join(dir, PROFILE_FILENAME);
161
+ const release = await acquireLock(dir, file);
162
+ try {
163
+ const current = await readUnderLock(file);
164
+ const next = await fn(current);
165
+ return await writeAtomic(dir, file, next);
166
+ }
167
+ finally {
168
+ await release();
169
+ }
170
+ };
171
+ // --- internals (thin fs/lock boundary) ------------------------------------
172
+ /**
173
+ * Acquire the advisory lock for the profile file.
174
+ *
175
+ * `proper-lockfile` locks by creating `${file}.lock`, but it requires the
176
+ * target path to exist first, so we `mkdir -p` the directory and ensure
177
+ * `profile.json` is present (an empty placeholder is fine — it will be
178
+ * overwritten by the atomic rename, and `readUnderLock` tolerates empty/invalid
179
+ * content).
180
+ */
181
+ const acquireLock = async (dir, file) => {
182
+ await fs.mkdir(dir, { recursive: true });
183
+ await ensureExists(file);
184
+ return lockfile.lock(file, LOCK_OPTIONS);
185
+ };
186
+ /**
187
+ * Read the on-disk profile while holding the lock, degrading to an empty
188
+ * profile if the file is missing, unreadable, or invalid. Unlike
189
+ * {@link loadProfile} this stays quiet (no stderr) — it runs inside the
190
+ * write path where a fresh-start is expected on first write. Version handling
191
+ * goes through the same {@link migrateProfile} policy so an older profile is
192
+ * migrated forward before a read-modify-write, and a newer one degrades to
193
+ * empty (the subsequent write is the caller's explicit choice).
194
+ */
195
+ const readUnderLock = async (file) => {
196
+ let raw;
197
+ try {
198
+ raw = await fs.readFile(file, "utf8");
199
+ }
200
+ catch {
201
+ return emptyProfile();
202
+ }
203
+ const json = parseJson(raw);
204
+ if (json === undefined)
205
+ return emptyProfile();
206
+ return migrateProfile(json).profile;
207
+ };
208
+ /**
209
+ * Write `profile` (with a refreshed `updatedAt`) atomically: serialize → write
210
+ * a unique temp file in the same directory → `fs.rename` over the target. The
211
+ * rename is atomic on the same filesystem, so a concurrent reader sees either
212
+ * the old or the new file, never a partial one.
213
+ *
214
+ * @returns The exact profile object that was persisted.
215
+ */
216
+ const writeAtomic = async (dir, file, profile) => {
217
+ const toWrite = { ...profile, updatedAt: new Date().toISOString() };
218
+ // Validate before persisting so we never write a malformed document.
219
+ const validated = ProfileSchema.parse(toWrite);
220
+ const json = `${JSON.stringify(validated, null, 2)}\n`;
221
+ const tmp = path.join(dir, `.${PROFILE_FILENAME}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
222
+ try {
223
+ await fs.writeFile(tmp, json, { encoding: "utf8", mode: 0o600 });
224
+ await fs.rename(tmp, file);
225
+ }
226
+ catch (err) {
227
+ // Best-effort cleanup of the temp file if the rename never happened.
228
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
229
+ throw err;
230
+ }
231
+ return validated;
232
+ };
233
+ /** Create an empty file if it does not already exist (no-op otherwise). */
234
+ const ensureExists = async (file) => {
235
+ // wx = create + fail if exists; swallow EEXIST so this is idempotent.
236
+ let handle;
237
+ try {
238
+ handle = await fs.open(file, "wx", 0o600);
239
+ }
240
+ catch (err) {
241
+ if (isExists(err))
242
+ return;
243
+ throw err;
244
+ }
245
+ finally {
246
+ await handle?.close();
247
+ }
248
+ };
249
+ /**
250
+ * Parse raw profile text into a JSON value, or `undefined` if it is not valid
251
+ * JSON. Schema/version validation is deferred to {@link migrateProfile} so the
252
+ * version policy (same/older/newer) is centralized in one place (T056).
253
+ */
254
+ const parseJson = (raw) => {
255
+ try {
256
+ return JSON.parse(raw);
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ };
262
+ /** Type-narrowing helper: is this a Node `ENOENT` error? */
263
+ const isNotFound = (err) => hasCode(err, "ENOENT");
264
+ /** Type-narrowing helper: is this a Node `EEXIST` error? */
265
+ const isExists = (err) => hasCode(err, "EEXIST");
266
+ const hasCode = (err, code) => typeof err === "object" && err !== null && err.code === code;
267
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/profile/store.ts"],"names":[],"mappings":"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"}