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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
}
|