heyspex 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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,790 @@
1
+ /**
2
+ * Shared spec-id helpers for vendor/heyspex-mcp/.
3
+ *
4
+ * `extractSpecSuffix` is the canonical pairing logic between parent specs and
5
+ * their `HS-REV-<suffix>-*.md` review companions. Same regex as
6
+ * `spex-console/src/lib/vault.ts:155-158` — keep them in sync if the supported
7
+ * type list ever changes. See [[HS-TASK-RVDC-heyspex-review-on-deck-mcp-and-hs-review]].
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ /**
14
+ * Extract the family key (the shared random code) from a spec id, in EITHER
15
+ * ordering — dual-read via `parseSpecId` ([[HS-EPIC-DLE3]] / [[HS-EPIC-6SNQ]]).
16
+ *
17
+ * HS-TASK-SK1L → "SK1L" (type-first, legacy)
18
+ * HS-SK1L-TASK → "SK1L" (family-first, canonical)
19
+ * HS-SK1L-REV-… → "SK1L"
20
+ *
21
+ * Returns null if the id is not a two-token HS spec id (e.g. a bare family token
22
+ * or a slug-only memory id).
23
+ */
24
+ export function extractSpecSuffix(id) {
25
+ return parseSpecId(id)?.familyKey?.toUpperCase() ?? null;
26
+ }
27
+
28
+ /**
29
+ * Locate the `HS-REV-<suffix>-*.md` review companion for a parent spec id.
30
+ *
31
+ * Returns the vault-relative path (forward slashes) or null if no companion
32
+ * exists in `specs/reviews/`. Prefers matching by the review doc's `parent:` or
33
+ * `target:` frontmatter field (unambiguous for shared-suffix families); falls back
34
+ * to suffix matching for docs that predate the `parent:` field convention.
35
+ */
36
+ export function findReviewDocForSpec(vaultRoot, parentSpecId) {
37
+ const reviewsDir = join(vaultRoot, 'specs', 'reviews');
38
+ if (!existsSync(reviewsDir)) return null;
39
+ let files;
40
+ try {
41
+ files = readdirSync(reviewsDir);
42
+ } catch {
43
+ return null;
44
+ }
45
+
46
+ // Canonical id of the parent, ordering-independent (dual-read). We normalize to
47
+ // one ordering so a type-first parent id and a family-first review link compare
48
+ // equal. See [[HS-EPIC-DLE3]] AC#4 — review pairing keys on the family key.
49
+ const canonicalSpecId = (value) => {
50
+ const p = parseSpecId(value);
51
+ return p ? `HS-${p.familyKey}-${p.typeLabel}`.toUpperCase() : null;
52
+ };
53
+ const parentIdUpper = canonicalSpecId(parentSpecId);
54
+
55
+ if (parentIdUpper) {
56
+ // First: match by parent: or target: frontmatter field (unambiguous for shared-key families)
57
+ for (const file of files) {
58
+ if (!file.endsWith('.md')) continue;
59
+ try {
60
+ const content = readFileSync(join(reviewsDir, file), 'utf8');
61
+ const m = content.match(/^(?:parent|target):\s*['"]?\[\[([^\]]+)\]\]['"]?/m);
62
+ if (m && canonicalSpecId(m[1]) === parentIdUpper) {
63
+ return `specs/reviews/${file}`;
64
+ }
65
+ } catch {
66
+ // skip unreadable files
67
+ }
68
+ }
69
+ }
70
+
71
+ // Fallback: family-key match (for review docs without a parent: or target: field),
72
+ // in either ordering — a review file is HS-REV-<key>-… or HS-<key>-REV-….
73
+ const suffix = extractSpecSuffix(parentSpecId);
74
+ if (!suffix) return null;
75
+ for (const file of files) {
76
+ if (!file.endsWith('.md')) continue;
77
+ const parts = parseSpecId(file.replace(/\.md$/i, ''));
78
+ if (parts && parts.typeLabel === 'REV' && parts.familyKey.toUpperCase() === suffix) {
79
+ return `specs/reviews/${file}`;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Dual-read identifier anatomy ([[HS-EPIC-6SNQ-dual-read-resolver-grace-period-for-pipe]],
87
+ // Phase 1 of [[HS-MIL-KY1F-pipeline-first-spec-identifier-format-mi]]).
88
+ //
89
+ // During the migration grace window the vault holds spec basenames in ONE of two
90
+ // orderings at a time, while callers (wikilinks, MCP spec_id args, the CLI) may
91
+ // pass either. These helpers parse a spec id/basename into its three canonical
92
+ // atoms — **family key** (the shared feature handle), **type label** (TASK/EPIC/…,
93
+ // a human disambiguator), and **title slug** — and flip between the two orderings:
94
+ //
95
+ // type-first (legacy): HS-<TYPE>-<familyKey>[-<titleSlug>] e.g. HS-TASK-P2TR-foo
96
+ // family-first (new): HS-<familyKey>-<TYPE>[-<titleSlug>] e.g. HS-P2TR-TASK-foo
97
+ //
98
+ // Routing/entity type is always read from `type:` frontmatter, never inferred from
99
+ // the filename token (see vault-id-assignment-protocol §Identifier terminology and
100
+ // format). These helpers exist only so a resolver can find the file regardless of
101
+ // which ordering the on-disk basename currently uses.
102
+ //
103
+ // Mirror: spex-console/src/lib/vault-id.ts (`alternateOrderingBasename` +
104
+ // `KNOWN_TYPE_LABELS`). Keep the two type-label sets in sync.
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Shape of a minted per-file FILE KEY (owner ruling 2026-07-10): 4+ uppercase
109
+ * alphanumerics with AT LEAST ONE LETTER, sitting between the type label and
110
+ * the title slug — `HS-<familyKey>-<TYPE>-<fileKey>-<titleSlug>.md`. All-digit
111
+ * keys are never minted and never parsed as file keys (a year-like slug token
112
+ * such as `2026` must not read as one). Legacy files carry no file key.
113
+ * Mirror: spex-console/src/lib/vault-id.ts `FILE_KEY_RE`.
114
+ */
115
+ export const FILE_KEY_RE = /^(?=[0-9]*[A-Z])[A-Z0-9]{4,}$/;
116
+
117
+ /**
118
+ * File key carried by a spec id/basename in EITHER ordering, or null for
119
+ * legacy no-file-key names. The file key is the first title-tail token when it
120
+ * matches FILE_KEY_RE — uppercase with a letter, which no lowercase title-slug
121
+ * token can be. Mirror: vault-id.ts `fileKeyFromBasename`.
122
+ */
123
+ export function fileKeyFromBasename(input) {
124
+ const p = parseSpecId(input);
125
+ if (!p) return null;
126
+ if (p.era === 3) {
127
+ // File-key-first: the file key is the LEADING token, not derived from
128
+ // titleSlug (unlike era-1/era-2).
129
+ return p.fileKey && FILE_KEY_RE.test(p.fileKey) ? p.fileKey : null;
130
+ }
131
+ if (!p.titleSlug) return null;
132
+ const first = p.titleSlug.split('-', 1)[0];
133
+ return FILE_KEY_RE.test(first) ? first : null;
134
+ }
135
+
136
+ const FILE_KEY_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
137
+ const FILE_KEY_LENGTH = 4;
138
+
139
+ /**
140
+ * Every occupied file key across the vault, by walking `specs/**` BASENAMES
141
+ * (directory listings only — no file reads, so it is cheap enough to run per
142
+ * mint). Frontmatter `file_id:` is the SSOT, but every minted key is also in
143
+ * its filename, so basenames are a complete occupancy source.
144
+ *
145
+ * PRECISE by design: file keys only. Cross-namespace mint safety (a file key
146
+ * must never equal a family key — [[HS-SJOT-AEP1-DIAG]]) is enforced by
147
+ * UNIONING this with {@link collectOccupiedFamilyKeysFromVault} at every mint
148
+ * site (mintFileKey / mintFamilyKey / the direct allocateFileKey callers), so
149
+ * membership tests on this set stay namespace-accurate.
150
+ */
151
+ export function collectOccupiedFileKeysFromVault(vaultRoot) {
152
+ const occupied = new Set();
153
+ const walk = (dir) => {
154
+ let entries;
155
+ try {
156
+ entries = readdirSync(dir, { withFileTypes: true });
157
+ } catch {
158
+ return;
159
+ }
160
+ for (const entry of entries) {
161
+ if (entry.isDirectory()) {
162
+ if (entry.name === '.trash') continue;
163
+ walk(join(dir, entry.name));
164
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
165
+ const key = fileKeyFromBasename(entry.name.replace(/\.md$/i, ''));
166
+ if (key) occupied.add(key);
167
+ }
168
+ }
169
+ };
170
+ walk(join(vaultRoot, 'specs'));
171
+ return occupied;
172
+ }
173
+
174
+ /**
175
+ * Mint a per-file FILE KEY (owner ruling 2026-07-10): {@link FILE_KEY_LENGTH}
176
+ * chars, at least one letter (all-digit rejected — see FILE_KEY_RE), unique
177
+ * vault-wide against `occupied`. Auto-extends on exhaustion. Mirror:
178
+ * spex-console/src/lib/vault-id.ts `allocateFileKey`.
179
+ */
180
+ export function allocateFileKey(occupied, options = {}) {
181
+ const charset = options.charset ?? FILE_KEY_CHARSET;
182
+ const randomUnit = options.randomUnit ?? (() => Math.random());
183
+ let length = options.minLength ?? FILE_KEY_LENGTH;
184
+
185
+ while (true) {
186
+ const maxAttempts = Math.min(charset.length ** length, 10_000);
187
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
188
+ let key = '';
189
+ for (let i = 0; i < length; i++) {
190
+ const idx = Math.floor(randomUnit() * charset.length);
191
+ key += charset[Math.min(idx, charset.length - 1)];
192
+ }
193
+ if (!FILE_KEY_RE.test(key)) continue; // all-digit — never minted
194
+ // Never mint a key equal to a type label: era detection keys on
195
+ // closed-set type-label position, so a key like "TASK" or "EPIC" would
196
+ // silently reparse a basename into the wrong era ([[HS-SJOT-AEP1-DIAG]]).
197
+ if (KNOWN_TYPE_LABELS.has(key)) continue;
198
+ if (!occupied.has(key)) return key;
199
+ }
200
+ length++;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * One-call mint for filing modules: walk the vault for occupied keys, then
206
+ * allocate. `randomUnit` is injectable for tests. Occupancy is the UNION of
207
+ * file keys AND family keys: era-3 ids are bare file keys, so a file key that
208
+ * equals any family key makes a bare `HS-XXXX` citation ambiguous between one
209
+ * file and a whole family ([[HS-SJOT-AEP1-DIAG]]).
210
+ */
211
+ export function mintFileKey(vaultRoot, options = {}) {
212
+ return allocateFileKey(collectOccupiedKeysEitherKind(vaultRoot), options);
213
+ }
214
+
215
+ /**
216
+ * Every occupied family key across the vault, walking `specs/**` BASENAMES
217
+ * only (cheap directory listing, no file reads — mirrors
218
+ * {@link collectOccupiedFileKeysFromVault}). A basename encodes its family key
219
+ * positionally regardless of era ({@link parseSpecId}'s `familyKey`), so this
220
+ * is accurate even for era-3 file-key-first names. Used to mint a FRESH,
221
+ * not-already-meaningful-elsewhere family key for a parentless spec (V3
222
+ * shared-surname model — [[naming-v3-7-rulings-hs-namespace-kept]] /
223
+ * vault-id-assignment-protocol §Family inheritance): family keys are NOT
224
+ * required to be globally unique (any number of specs may legally share one),
225
+ * so this is a "pick something fresh" default, not a uniqueness gate. Mirror:
226
+ * spex-console/src/lib/vault-id.ts `collectOccupiedFamilyKeys`.
227
+ */
228
+ export function collectOccupiedFamilyKeysFromVault(vaultRoot) {
229
+ const occupied = new Set();
230
+ const walk = (dir) => {
231
+ let entries;
232
+ try {
233
+ entries = readdirSync(dir, { withFileTypes: true });
234
+ } catch {
235
+ return;
236
+ }
237
+ for (const entry of entries) {
238
+ if (entry.isDirectory()) {
239
+ if (entry.name === '.trash') continue;
240
+ walk(join(dir, entry.name));
241
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
242
+ const p = parseSpecId(entry.name.replace(/\.md$/i, ''));
243
+ if (p?.familyKey) occupied.add(p.familyKey.toUpperCase());
244
+ }
245
+ }
246
+ };
247
+ walk(join(vaultRoot, 'specs'));
248
+ return occupied;
249
+ }
250
+
251
+ /**
252
+ * Cross-namespace mint occupancy ([[HS-SJOT-AEP1-DIAG]]): the union of every
253
+ * occupied FILE key and FAMILY key. Era-3 ids are bare file keys, so a fresh
254
+ * key of EITHER kind that lands in the other namespace makes a bare `HS-XXXX`
255
+ * citation ambiguous between one file and a whole family. Every mint site
256
+ * allocates against this union; the two per-namespace collectors above stay
257
+ * precise for membership tests.
258
+ */
259
+ export function collectOccupiedKeysEitherKind(vaultRoot) {
260
+ const occupied = collectOccupiedFileKeysFromVault(vaultRoot);
261
+ for (const k of collectOccupiedFamilyKeysFromVault(vaultRoot)) occupied.add(k);
262
+ return occupied;
263
+ }
264
+
265
+ /**
266
+ * Mint a fresh family key for a parentless spec: same shape/collision-check
267
+ * machinery as {@link allocateFileKey} (4 chars, at least one letter),
268
+ * checked against {@link collectOccupiedFamilyKeysFromVault} so a fresh mint
269
+ * doesn't accidentally collide with an existing unrelated family.
270
+ */
271
+ export function mintFamilyKey(vaultRoot, options = {}) {
272
+ return allocateFileKey(collectOccupiedKeysEitherKind(vaultRoot), options);
273
+ }
274
+
275
+ /**
276
+ * Closed set of basename type labels. A token is the **type label** (not the
277
+ * family key) iff it is in this set. Family keys are random 4+ alphanumerics
278
+ * (or legacy 3-digit RES/IDEA / 4-digit EPIC sequences); type labels are a fixed
279
+ * vocabulary of all-caps words, so closed-set membership disambiguates ordering.
280
+ * Derived from the type tokens actually in use as basename prefixes across the
281
+ * vault, plus the memory/idea kinds for completeness.
282
+ */
283
+ export const KNOWN_TYPE_LABELS = new Set([
284
+ 'TASK', 'EPIC', 'MIL', 'ROADMAP', 'RES', 'CAP', 'IDEA', 'REV', 'FLOW',
285
+ 'DIAG', 'AGENT', 'GCASE', 'BIZ', 'PILR', 'DOC', 'PROTO', 'CASE',
286
+ 'DEC', 'LES', 'UIP', 'REF',
287
+ ]);
288
+
289
+ /**
290
+ * Owner-ruled semantic family SURNAMES (Naming V3 Q3 rulings): BIZ for
291
+ * specs/business docs, PILR for pillar-anchored docs. They overlap
292
+ * KNOWN_TYPE_LABELS (both were legacy type labels), so {@link parseSpecId}
293
+ * needs them to disambiguate an era-3 `HS-<fileKey>-BIZ-<CAT>-<slug>` name
294
+ * from a legacy era-2 reading. Mirror: spex-console/src/lib/vault-id.ts.
295
+ */
296
+ export const RULED_FAMILY_SURNAMES = new Set(['BIZ', 'PILR']);
297
+
298
+ /**
299
+ * Type labels whose ids carry a RANDOM family key and are reordered by the
300
+ * family-first migration ([[HS-EPIC-MB2M]] scope; [[HS-EPIC-DLE3]]). ONLY these
301
+ * may be recomposed `HS-<familyKey>-<TYPE>`; slug/mnemonic kinds (PILR, DEC, LES,
302
+ * PROTO, DOC, ROADMAP, FLOW, BIZ, IDEA, …) carry free text in the second token, so
303
+ * reordering would corrupt them. Mirror of FAMILY_KEYED_TYPE_LABELS in
304
+ * spex-console/src/lib/vault-id.ts and MIGRATE_TYPES in migrate-ids-to-family-first.mjs.
305
+ */
306
+ export const FAMILY_KEYED_TYPE_LABELS = new Set([
307
+ 'TASK', 'EPIC', 'MIL', 'RES', 'CAP', 'DIAG', 'REV', 'AGENT', 'GCASE',
308
+ ]);
309
+
310
+ /** True if `token` is a recognized type label (case-insensitive). */
311
+ export function isTypeLabel(token) {
312
+ return KNOWN_TYPE_LABELS.has(String(token || '').toUpperCase());
313
+ }
314
+
315
+ /**
316
+ * Canonical family-first BARE id (`HS-<familyKey>-<TYPE>`) for an id/basename in
317
+ * EITHER ordering — ONLY for family-keyed kinds; null otherwise (so callers pass
318
+ * slug/mnemonic ids through unchanged) and null for unparseable input.
319
+ */
320
+ export function toCanonicalFamilyFirstId(input) {
321
+ const p = parseSpecId(input);
322
+ if (!p || !FAMILY_KEYED_TYPE_LABELS.has(p.typeLabel)) return null;
323
+ // Routes through familyOf for the "family of this spec" derivation (V3 step
324
+ // 1). No frontmatter is available at this call site (string-only input), so
325
+ // this is behavior-preserving today; it becomes the real fallback path once
326
+ // a frontmatter-aware caller passes one through.
327
+ const familyKey = String(familyOf({ id: input }) ?? p.familyKey).toUpperCase();
328
+ return `HS-${familyKey}-${p.typeLabel}`;
329
+ }
330
+
331
+ /**
332
+ * Shape a valid EXPLICIT frontmatter `family` value must match: uppercase
333
+ * alphanumeric, 4+ chars — the same shape every family-key capture group in
334
+ * this repo's id regexes already requires. Not exported: callers that need
335
+ * "is this family-key-shaped" should go through {@link familyOf}.
336
+ */
337
+ const FAMILY_KEY_SHAPE_RE = /^[A-Z0-9]{4,}$/;
338
+
339
+ /**
340
+ * The family key of a spec: explicit `frontmatter.family` when present and
341
+ * well-formed, else the positional family key parsed from `id`/`basename`
342
+ * (`parseSpecId`). This is the ONE place family-key derivation should happen
343
+ * once a managed spec carries an explicit `family:` field — V3 migration step
344
+ * 1 ([[HS-8O69-EPIC-LKF9-relational-family-graph-authoring]],
345
+ * `dev-factory/handoffs/NAMING-V3-MIGRATION-BRIEF.md` §2.1). The positional
346
+ * fallback exists ONLY for specs not yet backfilled with `family:` and is
347
+ * slated for removal in step 4 — do not treat it as a permanent code path.
348
+ *
349
+ * - A malformed or empty explicit value (wrong shape, blank string) is NOT an
350
+ * error: it silently falls back to positional parsing, exactly as if no
351
+ * `family:` field were present at all.
352
+ * - `id` is preferred over `basename` for the positional fallback (the id is
353
+ * the frontmatter-declared identity; a basename can lag a pending rename).
354
+ * - Returns null when nothing parses (no valid explicit field AND no
355
+ * parseable `id`/`basename`).
356
+ *
357
+ * Mirror: `spex-console/src/lib/vault-id.ts` `familyOf` (TypeScript — keep the
358
+ * two in lockstep on any change here).
359
+ *
360
+ * @param {{ id?: string | null, basename?: string | null, frontmatter?: { family?: string | null } | null }} input
361
+ * @returns {string | null}
362
+ */
363
+ export function familyOf(input) {
364
+ const explicit = input?.frontmatter?.family;
365
+ if (typeof explicit === 'string') {
366
+ const trimmed = explicit.trim();
367
+ if (FAMILY_KEY_SHAPE_RE.test(trimmed)) return trimmed;
368
+ }
369
+ if (input?.id) {
370
+ const fromId = parseSpecId(input.id);
371
+ if (fromId) return fromId.familyKey;
372
+ }
373
+ if (input?.basename) {
374
+ const fromBasename = parseSpecId(input.basename);
375
+ if (fromBasename) return fromBasename.familyKey;
376
+ }
377
+ return null;
378
+ }
379
+
380
+ /** Strip a trailing `.md` and surrounding whitespace from an id/basename/path. */
381
+ function normalizeIdInput(input) {
382
+ return String(input || '')
383
+ .replace(/\.md$/i, '')
384
+ .trim();
385
+ }
386
+
387
+ /**
388
+ * Parse a spec id or basename into its canonical atoms.
389
+ *
390
+ * Returns `{ era, ordering, typeLabel, familyKey, fileKey, titleSlug, hs }` or
391
+ * `null` when the input is not a two-token HS spec id (e.g. a bare family token
392
+ * `HS-P2TR`, a bare type `HS-TASK`, or an unprefixed foundational doc like
393
+ * `entity-model`).
394
+ *
395
+ * parseSpecId('HS-TASK-P2TR-foo') → { era:1, ordering:'type-first', typeLabel:'TASK', familyKey:'P2TR', fileKey:null, titleSlug:'foo' }
396
+ * parseSpecId('HS-P2TR-TASK-foo') → { era:2, ordering:'family-first', typeLabel:'TASK', familyKey:'P2TR', fileKey:null, titleSlug:'foo' }
397
+ * parseSpecId('HS-L6Y4-01D7-EPIC-foo') → { era:3, ordering:'file-key-first', typeLabel:'EPIC', familyKey:'01D7', fileKey:'L6Y4', titleSlug:'foo' }
398
+ * parseSpecId('HS-TASK-P2TR') → { …, titleSlug:'' }
399
+ *
400
+ * Tri-era rule ([[HS-8JXX-TASK-3BYT]]): the POSITION of the TYPE token after
401
+ * `HS-` identifies the era — position 1 = legacy type-first, position 2 =
402
+ * family-first (current), position 3 = new file-key-first. Position 3 is only
403
+ * tried when NEITHER position 1 nor position 2 matched, so an era-1/era-2
404
+ * basename is never reinterpreted as era-3 even if its slug's first word
405
+ * happens to collide with a type-label spelling.
406
+ *
407
+ * `typeLabel` / `familyKey` / `fileKey` are upper-cased (canonical). `titleSlug`
408
+ * preserves the input's casing. Matching accepts any case of the `HS-` prefix
409
+ * and type labels (pin paste / agent paste). When both of the first two tokens
410
+ * look like type labels the legacy type-first reading wins (defensive; should
411
+ * not occur with real ids).
412
+ */
413
+ export function parseSpecId(input) {
414
+ const base = normalizeIdInput(input);
415
+ // Case-insensitive HS- prefix so pin/equality helpers accept pasted lowercase ids
416
+ // (e.g. isSpecPinnedToTop("HS-TASK-2WVZ", "hs-task-2wvz")). Mirror: vault-id.ts.
417
+ const m = base.match(/^HS-(.+)$/i);
418
+ if (!m) return null;
419
+ const tokens = m[1].split('-');
420
+ if (tokens.length < 2) return null;
421
+
422
+ const t1IsType = isTypeLabel(tokens[0]);
423
+ const t2IsType = isTypeLabel(tokens[1]);
424
+
425
+ if (t1IsType && !t2IsType) {
426
+ return {
427
+ era: 1,
428
+ ordering: 'type-first',
429
+ typeLabel: tokens[0].toUpperCase(),
430
+ familyKey: tokens[1].toUpperCase(),
431
+ fileKey: null,
432
+ titleSlug: tokens.slice(2).join('-'),
433
+ hs: 'HS',
434
+ };
435
+ }
436
+ if (t2IsType && !t1IsType) {
437
+ // Ruled family SURNAMES (owner Q3 rulings: BIZ for specs/business, PILR for
438
+ // pillar-anchored docs) sit in the FAMILY slot of era-3 names
439
+ // (HS-<fileKey>-BIZ-<CAT>-<slug>) while also living in KNOWN_TYPE_LABELS as
440
+ // legacy type labels. No real era-2 name ever carried BIZ/PILR in position
441
+ // 2 (era-2 business was HS-BIZ-<CAT>-<slug>, type-first), so when the
442
+ // leading token is file-key-shaped and a third token exists, the era-3
443
+ // surname reading is the correct one.
444
+ if (
445
+ RULED_FAMILY_SURNAMES.has(tokens[1].toUpperCase()) &&
446
+ FILE_KEY_RE.test(tokens[0].toUpperCase()) &&
447
+ tokens.length > 2
448
+ ) {
449
+ return {
450
+ era: 3,
451
+ ordering: 'file-key-first',
452
+ typeLabel: tokens[2].toUpperCase(),
453
+ familyKey: tokens[1].toUpperCase(),
454
+ fileKey: tokens[0].toUpperCase(),
455
+ titleSlug: tokens.slice(3).join('-'),
456
+ hs: 'HS',
457
+ };
458
+ }
459
+ return {
460
+ era: 2,
461
+ ordering: 'family-first',
462
+ typeLabel: tokens[1].toUpperCase(),
463
+ familyKey: tokens[0].toUpperCase(),
464
+ fileKey: null,
465
+ titleSlug: tokens.slice(2).join('-'),
466
+ hs: 'HS',
467
+ };
468
+ }
469
+ if (t1IsType && t2IsType) {
470
+ // Ambiguous (both reserved words) — treat as legacy type-first.
471
+ return {
472
+ era: 1,
473
+ ordering: 'type-first',
474
+ typeLabel: tokens[0].toUpperCase(),
475
+ familyKey: tokens[1].toUpperCase(),
476
+ fileKey: null,
477
+ titleSlug: tokens.slice(2).join('-'),
478
+ hs: 'HS',
479
+ };
480
+ }
481
+
482
+ // Neither of the first two tokens is a type label — try position 3
483
+ // (file-key-first era: HS-<fileKey>-<familyKey>-<TYPE>-<slug>).
484
+ if (tokens.length > 2 && isTypeLabel(tokens[2])) {
485
+ return {
486
+ era: 3,
487
+ ordering: 'file-key-first',
488
+ typeLabel: tokens[2].toUpperCase(),
489
+ familyKey: tokens[1].toUpperCase(),
490
+ fileKey: tokens[0].toUpperCase(),
491
+ titleSlug: tokens.slice(3).join('-'),
492
+ hs: 'HS',
493
+ };
494
+ }
495
+
496
+ return null;
497
+ }
498
+
499
+ function rebuildBasename(parts, ordering) {
500
+ const tail = parts.titleSlug ? `-${parts.titleSlug}` : '';
501
+ const middle =
502
+ ordering === 'family-first'
503
+ ? `${parts.familyKey}-${parts.typeLabel}`
504
+ : `${parts.typeLabel}-${parts.familyKey}`;
505
+ return `${parts.hs}-${middle}${tail}`;
506
+ }
507
+
508
+ /** Canonicalize any spec id/basename to the legacy **type-first** ordering. */
509
+ export function toTypeFirstBasename(input) {
510
+ const parts = parseSpecId(input);
511
+ return parts ? rebuildBasename(parts, 'type-first') : null;
512
+ }
513
+
514
+ /** Canonicalize any spec id/basename to the new **family-first** ordering. */
515
+ export function toFamilyFirstBasename(input) {
516
+ const parts = parseSpecId(input);
517
+ return parts ? rebuildBasename(parts, 'family-first') : null;
518
+ }
519
+
520
+ /**
521
+ * Given a spec id/basename in one ordering, return the SAME id/basename in the
522
+ * opposite ordering — or `null` when the input is unparseable or the flip would
523
+ * be a no-op (case-insensitively). This is the single primitive a dual-read
524
+ * resolver needs: try the input as-is, then try `alternateOrderingBasename(input)`.
525
+ *
526
+ * Only flips between the two DUAL-READ-era orderings (type-first ↔
527
+ * family-first, [[HS-EPIC-6SNQ]]); era-3 (file-key-first) has no defined
528
+ * binary flip here — that alternation is scoped to the step-4 rename epic —
529
+ * so a file-key-first input returns `null` rather than a guessed shape.
530
+ */
531
+ export function alternateOrderingBasename(input) {
532
+ const parts = parseSpecId(input);
533
+ if (!parts || parts.era === 3) return null;
534
+ const alt = rebuildBasename(
535
+ parts,
536
+ parts.ordering === 'type-first' ? 'family-first' : 'type-first',
537
+ );
538
+ const orig = normalizeIdInput(input);
539
+ return alt.toLowerCase() === orig.toLowerCase() ? null : alt;
540
+ }
541
+
542
+ /**
543
+ * True when `input` is a **bare family token** — `HS-<familyKey>` with no type
544
+ * label and no title slug (e.g. `HS-P2TR`). Such a token names a whole
545
+ * shared-suffix family, not a single spec, so it is ambiguous for any mutating
546
+ * operation. A bare type token (`HS-TASK`) is NOT a family token.
547
+ */
548
+ export function isBareFamilyToken(input) {
549
+ const base = normalizeIdInput(input);
550
+ const m = base.match(/^HS-([A-Za-z0-9]+)$/);
551
+ if (!m) return false;
552
+ return !isTypeLabel(m[1]);
553
+ }
554
+
555
+ /**
556
+ * Guard for mutating MCP/CLI ops: reject a `spec_id` that is a bare family token
557
+ * (ambiguous — resolves to a family, not one spec). Returns an error object
558
+ * `{ error, message }` to surface to the caller, or `null` when the id is
559
+ * unambiguous enough to act on. Read-only resolvers do NOT call this — they fan
560
+ * a family token out to a set or just miss.
561
+ */
562
+ export function requireResolvableSpecId(input) {
563
+ if (isBareFamilyToken(input)) {
564
+ const base = normalizeIdInput(input);
565
+ return {
566
+ error: 'AMBIGUOUS_SPEC_ID',
567
+ message:
568
+ `"${base}" is a bare family key, which names a whole shared-suffix family ` +
569
+ `rather than one spec. Pass a full basename that includes the type label ` +
570
+ `(e.g. ${base}-TASK-<slug> or HS-TASK-${base.replace(/^HS-/, '')}-<slug>).`,
571
+ };
572
+ }
573
+ return null;
574
+ }
575
+
576
+ // ────────────────────────────────────────────────────────────────────────────
577
+ // Display formatting ([[HS-57WO-094P-RES-long-form-spec-id-display-hs-file-key]])
578
+ //
579
+ // Ids are MINTED and STORED short (`HS-<fileKey>`) because the file key alone is
580
+ // unique. They are READ long: a bare file key hides the family key that names
581
+ // the milestone lineage, and hides the entity type. Everything below is
582
+ // display-only — nothing here participates in minting, lookup, or persistence.
583
+ // ────────────────────────────────────────────────────────────────────────────
584
+
585
+ /**
586
+ * Frontmatter `type:` (lowercase kind) → basename TYPE label. Lets a display
587
+ * caller that holds a loaded spec supply the type without re-parsing a
588
+ * basename. Mirror: spex-console/src/lib/vault-id.ts `FRONTMATTER_TYPE_LABELS`.
589
+ */
590
+ export const FRONTMATTER_TYPE_LABELS = {
591
+ task: 'TASK',
592
+ epic: 'EPIC',
593
+ milestone: 'MIL',
594
+ research: 'RES',
595
+ capture: 'CAP',
596
+ diagnose: 'DIAG',
597
+ review: 'REV',
598
+ agent: 'AGENT',
599
+ 'golden-case': 'GCASE',
600
+ roadmap: 'ROADMAP',
601
+ idea: 'IDEA',
602
+ flow: 'FLOW',
603
+ doc: 'DOC',
604
+ protocol: 'PROTO',
605
+ decision: 'DEC',
606
+ lesson: 'LES',
607
+ 'ui-pattern': 'UIP',
608
+ reference: 'REF',
609
+ };
610
+
611
+ /**
612
+ * TYPE label for a frontmatter `type:` value (or for an already-uppercase label
613
+ * passed through). Returns null for unknown/blank input — callers fall back to
614
+ * the label parsed out of the basename.
615
+ */
616
+ export function typeLabelForKind(kind) {
617
+ const raw = String(kind || '').trim();
618
+ if (!raw) return null;
619
+ const mapped = FRONTMATTER_TYPE_LABELS[raw.toLowerCase()];
620
+ if (mapped) return mapped;
621
+ const upper = raw.toUpperCase();
622
+ return KNOWN_TYPE_LABELS.has(upper) ? upper : null;
623
+ }
624
+
625
+ /**
626
+ * The long, human-facing form of a spec id: `HS-<fileKey>-<familyKey>`, plus the
627
+ * TYPE label when `includeType` is set.
628
+ *
629
+ * formatDisplayId('HS-57WO-094P-RES-long-form-…') → 'HS-57WO-094P'
630
+ * formatDisplayId('HS-57WO-094P-RES-long-form-…', {includeType:1}) → 'HS-57WO-094P-RES'
631
+ * formatDisplayId('HS-57WO', { basename: 'HS-57WO-094P-RES-…' }) → 'HS-57WO-094P'
632
+ * formatDisplayId('HS-57WO', { frontmatter: { family: '094P' } }) → 'HS-57WO-094P'
633
+ *
634
+ * `includeType` exists because Atom 2 of the display contract is
635
+ * context-dependent: a surface whose surrounding prose already names the kind
636
+ * ("Ready to claim **task** `HS-…`") must NOT also suffix `-TASK`. Surfaces with
637
+ * no such prose — log lines, table cells — pass `includeType: true`.
638
+ *
639
+ * Resolution order for the family key and type, cheapest first:
640
+ * 1. `options.frontmatter` / `options.type` (free when the caller holds the spec)
641
+ * 2. positional parse of `options.basename` (free — era-3 basenames carry both)
642
+ * 3. positional parse of `input` itself
643
+ * A caller holding only a short id and no context should look the basename up in
644
+ * `.heyspex/vault-index.json` (`specs[].path`) and pass it as `basename`.
645
+ *
646
+ * **Degradation is total: this never throws and never blocks a render.** When
647
+ * the family key cannot be resolved the input is returned unchanged — a bare id
648
+ * in a log line beats an exception in a claim banner.
649
+ *
650
+ * **Legacy names always carry their TYPE**, regardless of `includeType`: an
651
+ * era-1/era-2 spec has no separate file key, so `HS-<familyKey>` alone would be
652
+ * a bare family token (ambiguous — see {@link isBareFamilyToken}). Keeping the
653
+ * label preserves resolvability.
654
+ *
655
+ * **`familyInContext`** (owner ruling 2026-08-17, how-spec-ids-and-naming-work
656
+ * §2): when the surrounding UI already carries the family — a family-group
657
+ * header, a funnel/tree anchored on one initiative — rows render the BARE file
658
+ * id (`HS-<fileKey>`, the stored `id:` form) instead of repeating the family.
659
+ * `includeType` is IGNORED under this option: a type suffix on a bare file key
660
+ * would re-mint the retired era-2 shape (`HS-<token>-<TYPE>`). Legacy ids (no
661
+ * file key) keep their full family-first form.
662
+ *
663
+ * Mirror: spex-console/src/lib/vault-id.ts `formatDisplayId` — keep in lockstep.
664
+ *
665
+ * @param {string} input id or basename, in any era
666
+ * @param {{ includeType?: boolean, basename?: string|null, frontmatter?: object|null, type?: string|null, familyInContext?: boolean }} [options]
667
+ * @returns {string}
668
+ */
669
+ export function formatDisplayId(input, options = {}) {
670
+ const {
671
+ includeType = false,
672
+ basename = null,
673
+ frontmatter = null,
674
+ type = null,
675
+ familyInContext = false,
676
+ } = options;
677
+ const raw = normalizeIdInput(input);
678
+ if (!raw) return String(input ?? '');
679
+
680
+ const parsed = (basename && parseSpecId(basename)) || parseSpecId(raw);
681
+ const familyKey = familyOf({ id: raw, basename, frontmatter }) ?? parsed?.familyKey ?? null;
682
+ if (!familyKey) return raw; // no family in hand — nothing to lengthen
683
+
684
+ const typeLabel =
685
+ typeLabelForKind(type ?? frontmatter?.type) ?? parsed?.typeLabel ?? null;
686
+ const fileKey = fileKeyFromBasename(basename ?? raw) ?? leadingFileKeyToken(raw);
687
+
688
+ if (!fileKey) {
689
+ // Legacy (no file key): the canonical family-first form IS the display form,
690
+ // and its TYPE is load-bearing for resolvability, so it is never dropped —
691
+ // familyInContext included (there is no shorter unambiguous form to give).
692
+ return typeLabel ? `HS-${familyKey}-${typeLabel}` : raw;
693
+ }
694
+ // Context-scoped bare file id (owner ruling 2026-08-17): the family lives in
695
+ // a surrounding header, so the row shows the stored `id:` form alone. No
696
+ // type suffix ever — `HS-<fileKey>-<TYPE>` is the retired era-2 shape.
697
+ if (familyInContext) return `HS-${fileKey}`;
698
+ const head = `HS-${fileKey}-${familyKey}`;
699
+ return includeType && typeLabel ? `${head}-${typeLabel}` : head;
700
+ }
701
+
702
+ /**
703
+ * File key of a BARE short id (`HS-57WO`) — the era-3 shape `parseSpecId`
704
+ * rejects for having only one token. Returns null when the token is a type
705
+ * label or is not file-key-shaped.
706
+ *
707
+ * Exported (mirror of `bareFileKeyOf` in spex-console/src/lib/vault-id.ts):
708
+ * era-3 `id:` values are the STORED form for every newly minted spec, so
709
+ * validators need a shared way to recognize one rather than re-deriving the
710
+ * shape from a local regex.
711
+ * @param {string|null|undefined} input
712
+ * @returns {string|null}
713
+ */
714
+ export function bareFileKeyOf(input) {
715
+ const m = normalizeIdInput(input).match(/^HS-([A-Za-z0-9]+)$/);
716
+ if (!m) return null;
717
+ const token = m[1].toUpperCase();
718
+ if (KNOWN_TYPE_LABELS.has(token)) return null;
719
+ return FILE_KEY_RE.test(token) ? token : null;
720
+ }
721
+
722
+ const leadingFileKeyToken = bareFileKeyOf;
723
+
724
+ /**
725
+ * Collapse ANY pasted spec-id form to its era-3 FILE KEY — the token the
726
+ * database and every era-3 `id:` actually store ([[HS-XV4W]] promote failure,
727
+ * 2026-08-21: a human pastes the DISPLAY form `HS-<fileKey>-<familyKey>`
728
+ * because that is what agents are told to render, and the DB lookup 404'd it).
729
+ * Owner ruling: resolvers must recognize any potential form of the
730
+ * filename/id, exactly like the local disk lookup does.
731
+ *
732
+ * Accepted forms → file key:
733
+ * - bare short id `HS-XV4W`
734
+ * - display long id `HS-XV4W-HV6H`
735
+ * - full era-3 id/basename `HS-XV4W-HV6H-CAP-some-slug` (with/without .md)
736
+ * Returns null for era-1/era-2 ids (they have no file key), bare type tokens,
737
+ * and anything else unrecognizable — callers then use the input as given.
738
+ * @param {string|null|undefined} input
739
+ * @returns {string|null}
740
+ */
741
+ export function lookupFileKeyOf(input) {
742
+ const base = normalizeIdInput(input);
743
+ if (!base) return null;
744
+ const bare = bareFileKeyOf(base);
745
+ if (bare) return bare;
746
+ const parsed = parseSpecId(base);
747
+ if (parsed) return parsed.era === 3 ? (parsed.fileKey ?? null) : null;
748
+ // Two-token display form: parseSpecId rejects it (neither token is a type
749
+ // label, no third token) — recognize it here. The second token must be
750
+ // key-shaped too, or this would misread era-agnostic garbage.
751
+ const m = base.match(/^HS-([A-Za-z0-9]+)-([A-Za-z0-9]+)$/i);
752
+ if (!m) return null;
753
+ const t0 = m[1].toUpperCase();
754
+ const t1 = m[2].toUpperCase();
755
+ if (KNOWN_TYPE_LABELS.has(t0) || KNOWN_TYPE_LABELS.has(t1)) return null;
756
+ if (!FILE_KEY_RE.test(t0) || !FILE_KEY_RE.test(t1)) return null;
757
+ return t0;
758
+ }
759
+
760
+ /**
761
+ * The short lookup id (`HS-<fileKey>`) for any recognizable era-3 id form, or
762
+ * null. Convenience wrapper over {@link lookupFileKeyOf} for resolvers that
763
+ * want the full id string to query with.
764
+ * @param {string|null|undefined} input
765
+ * @returns {string|null}
766
+ */
767
+ export function shortLookupIdOf(input) {
768
+ const key = lookupFileKeyOf(input);
769
+ return key ? `HS-${key}` : null;
770
+ }
771
+
772
+ /**
773
+ * Is `id` a well-formed **era-3** frontmatter id for the spec stored at
774
+ * `basename`? Mirror of `isEra3SpecId` in spex-console/src/lib/vault-id.ts —
775
+ * era-3 ids are type-less (`HS-<fileKey>`), so a per-type id pattern cannot
776
+ * describe them; the basename's leading token is what makes the bare id
777
+ * verifiable. A bare id on a legacy (era-1/era-2) filename still fails.
778
+ * @param {string|null|undefined} id
779
+ * @param {string|null|undefined} basename
780
+ * @returns {boolean}
781
+ */
782
+ export function isEra3SpecId(id, basename) {
783
+ const raw = normalizeIdInput(id);
784
+ if (!raw) return false;
785
+ if (parseSpecId(raw)?.era === 3) return true;
786
+ const fileKey = bareFileKeyOf(raw);
787
+ if (!fileKey) return false;
788
+ const parsed = parseSpecId(basename ?? '');
789
+ return parsed?.era === 3 && (parsed.fileKey ?? '').toUpperCase() === fileKey;
790
+ }