heyspex 0.2.0 → 0.2.2
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/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +181 -15
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +177 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +21 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +312 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/install-copilot-hooks.mjs +288 -0
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -1,724 +1,720 @@
|
|
|
1
|
-
// Vault file IO shared by every tool: frontmatter/YAML parsing, spec-file
|
|
2
|
-
// resolution by id / file key / crosswalk, and the tier-aware spec loaders.
|
|
3
|
-
// Extracted from cli.mjs under
|
|
4
|
-
// [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
|
|
5
|
-
//
|
|
6
|
-
// Read-path selector ([[HS-DJWP-MCPC-TASK-wire-the-mcp-server-s-vault-reads]] of
|
|
7
|
-
// [[HS-T6GS-MCPC-EPIC-mcp-server-reads-the-vault-through-the]]): the ONE tier
|
|
8
|
-
// decision point every wired vault read below goes through. Tier is resolved
|
|
9
|
-
// fresh per tool invocation (never cached at module scope) so a dev-mode file
|
|
10
|
-
// edited between calls takes effect on the very next call.
|
|
11
|
-
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
-
import { alternateOrderingBasename, fileKeyFromBasename, parseSpecId } from './spec-id-lib.mjs';
|
|
15
|
-
import { readViaSelector } from './read-path.mjs';
|
|
16
|
-
import { consoleFetch } from './console-transport.mjs';
|
|
17
|
-
|
|
18
|
-
export function parseFrontmatter(content) {
|
|
19
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
20
|
-
if (!match) return { frontmatter: {}, body: content };
|
|
21
|
-
|
|
22
|
-
const yamlBlock = match[1];
|
|
23
|
-
const body = content.slice(match[0].length).trim();
|
|
24
|
-
const frontmatter = parseSimpleYaml(yamlBlock);
|
|
25
|
-
|
|
26
|
-
return { frontmatter, body };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function parseSimpleYaml(yaml) {
|
|
30
|
-
const result = {};
|
|
31
|
-
const lines = yaml.split(/\r?\n/);
|
|
32
|
-
let currentKey = null;
|
|
33
|
-
let currentArray = null;
|
|
34
|
-
let inNestedObject = false;
|
|
35
|
-
let nestedKey = null;
|
|
36
|
-
let nestedArray = null;
|
|
37
|
-
// Block-scalar collection state (`key: |` / `key: >`). The vast majority of
|
|
38
|
-
// authored `summary:` values in this vault are literal block scalars, so the
|
|
39
|
-
// claim brief must read them as strings — not silently drop them to `[]`,
|
|
40
|
-
// which made the /hs-next card fall into the "author a summary" path for any
|
|
41
|
-
// spec whose summary was a block scalar. [[HS-TASK-ECOI]]
|
|
42
|
-
let blockKey = null;
|
|
43
|
-
let blockLines = null;
|
|
44
|
-
let blockFold = false;
|
|
45
|
-
|
|
46
|
-
const flushBlock = () => {
|
|
47
|
-
if (blockKey === null) return;
|
|
48
|
-
const contentful = blockLines.filter((l) => l.trim() !== '');
|
|
49
|
-
const minIndent = contentful.length
|
|
50
|
-
? Math.min(...contentful.map((l) => l.match(/^(\s*)/)[1].length))
|
|
51
|
-
: 0;
|
|
52
|
-
const stripped = blockLines.map((l) => l.slice(minIndent));
|
|
53
|
-
result[blockKey] = blockFold
|
|
54
|
-
? stripped.map((l) => l.trim()).filter(Boolean).join(' ')
|
|
55
|
-
: stripped.join('\n').replace(/\n+$/, '');
|
|
56
|
-
blockKey = null;
|
|
57
|
-
blockLines = null;
|
|
58
|
-
blockFold = false;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
for (const line of lines) {
|
|
62
|
-
// Collect an active block scalar: blank or indented lines belong to it; a
|
|
63
|
-
// line at the key column ends it and is parsed normally below.
|
|
64
|
-
if (blockKey !== null) {
|
|
65
|
-
if (line.trim() === '' || /^\s/.test(line)) {
|
|
66
|
-
blockLines.push(line);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
flushBlock();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Skip empty lines and comments
|
|
73
|
-
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
74
|
-
|
|
75
|
-
// Detect array item under a key
|
|
76
|
-
const arrayItemMatch = line.match(/^(\s+)-\s*(.*)$/);
|
|
77
|
-
if (arrayItemMatch && currentArray !== null) {
|
|
78
|
-
const indent = arrayItemMatch[1].length;
|
|
79
|
-
const value = arrayItemMatch[2].trim().replace(/^["']|["']$/g, '');
|
|
80
|
-
if (indent <= 2 && currentArray) {
|
|
81
|
-
currentArray.push(value);
|
|
82
|
-
} else if (nestedArray) {
|
|
83
|
-
nestedArray.push(value);
|
|
84
|
-
}
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Detect nested object key (e.g., roadmap_manifest:)
|
|
89
|
-
const nestedKeyMatch = line.match(/^(\s{2,})(\w+):\s*(.*)$/);
|
|
90
|
-
if (nestedKeyMatch && inNestedObject) {
|
|
91
|
-
const key = nestedKeyMatch[2];
|
|
92
|
-
const value = nestedKeyMatch[3].trim();
|
|
93
|
-
if (value) {
|
|
94
|
-
if (!result[nestedKey]) result[nestedKey] = {};
|
|
95
|
-
result[nestedKey][key] = parseYamlValue(value);
|
|
96
|
-
}
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Detect top-level key
|
|
101
|
-
const keyMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
|
|
102
|
-
if (keyMatch) {
|
|
103
|
-
const key = keyMatch[1];
|
|
104
|
-
const value = keyMatch[2].trim();
|
|
105
|
-
|
|
106
|
-
currentArray = null;
|
|
107
|
-
inNestedObject = false;
|
|
108
|
-
nestedKey = null;
|
|
109
|
-
nestedArray = null;
|
|
110
|
-
|
|
111
|
-
if (/^[|>][-+\d]*$/.test(value)) {
|
|
112
|
-
// Block scalar (`|`, `>`, or a header variant like `|-`/`>+`/`|2`):
|
|
113
|
-
// collect the indented continuation lines into a string value.
|
|
114
|
-
currentKey = key;
|
|
115
|
-
blockKey = key;
|
|
116
|
-
blockLines = [];
|
|
117
|
-
blockFold = value.startsWith('>');
|
|
118
|
-
result[key] = '';
|
|
119
|
-
} else if (value === '') {
|
|
120
|
-
// Could be array, nested object, or multiline
|
|
121
|
-
currentKey = key;
|
|
122
|
-
result[key] = [];
|
|
123
|
-
currentArray = result[key];
|
|
124
|
-
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
125
|
-
// Inline array
|
|
126
|
-
result[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
127
|
-
} else {
|
|
128
|
-
result[key] = parseYamlValue(value);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Check if this is roadmap_manifest (needs special handling)
|
|
132
|
-
if (key === 'roadmap_manifest') {
|
|
133
|
-
inNestedObject = true;
|
|
134
|
-
nestedKey = key;
|
|
135
|
-
result[key] = {};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// `pin:` is a nested object ({ spec_id, pinned_at, expires_at, pinned_by })
|
|
139
|
-
// written by the console pin writer (`POST /api/pin`). Without
|
|
140
|
-
// this, parseSimpleYaml only descends into roadmap_manifest, so an empty
|
|
141
|
-
// `pin:` value parses to an array and the modern pin block is invisible to
|
|
142
|
-
// /hs-next — every focus reader downstream (getPinnedSpecIdFromFocus, the
|
|
143
|
-
// milestone/epic drill) sees no pin and falls through to scheduler_default.
|
|
144
|
-
// Guarded on empty value so a scalar `pin:` elsewhere is unaffected.
|
|
145
|
-
if (key === 'pin' && value === '') {
|
|
146
|
-
inNestedObject = true;
|
|
147
|
-
nestedKey = key;
|
|
148
|
-
result[key] = {};
|
|
149
|
-
currentArray = null;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Flush a block scalar that ran to the end of the frontmatter.
|
|
155
|
-
flushBlock();
|
|
156
|
-
|
|
157
|
-
// Handle roadmap_manifest specially - re-parse with full YAML support
|
|
158
|
-
if (result.roadmap_manifest !== undefined) {
|
|
159
|
-
result.roadmap_manifest = parseRoadmapManifest(yaml);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return result;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function parseRoadmapManifest(yaml) {
|
|
166
|
-
// Find roadmap_manifest block and parse it more carefully
|
|
167
|
-
const manifestMatch = yaml.match(/roadmap_manifest:\s*\r?\n([\s\S]*?)(?=\r?\n\w|\r?\n$|$)/);
|
|
168
|
-
if (!manifestMatch) return {};
|
|
169
|
-
|
|
170
|
-
const manifestBlock = manifestMatch[1];
|
|
171
|
-
const manifest = { phases: [], initiatives: [], horizon: [] };
|
|
172
|
-
|
|
173
|
-
// Parse version and default
|
|
174
|
-
const versionMatch = manifestBlock.match(/version:\s*(\d+)/);
|
|
175
|
-
if (versionMatch) manifest.version = parseInt(versionMatch[1], 10);
|
|
176
|
-
|
|
177
|
-
const defaultMatch = manifestBlock.match(/default:\s*(true|false)/);
|
|
178
|
-
if (defaultMatch) manifest.default = defaultMatch[1] === 'true';
|
|
179
|
-
|
|
180
|
-
// Parse phases
|
|
181
|
-
const phasesMatch = manifestBlock.match(/phases:\s*\r?\n([\s\S]*?)(?=\r?\n\s{2}\w|\r?\n\s{2}-\s*id:.*initiatives|\r?\n\s{2}initiatives:|\r?\n\s{2}horizon:|$)/);
|
|
182
|
-
if (phasesMatch) {
|
|
183
|
-
const phasesBlock = phasesMatch[1];
|
|
184
|
-
const phaseBlocks = phasesBlock.split(/\r?\n\s{4}- id:/);
|
|
185
|
-
|
|
186
|
-
for (let i = 0; i < phaseBlocks.length; i++) {
|
|
187
|
-
const block = (i === 0 ? phaseBlocks[i].replace(/^\s*- id:/, '') : phaseBlocks[i]).trim();
|
|
188
|
-
if (!block) continue;
|
|
189
|
-
|
|
190
|
-
const phase = { tasks: [] };
|
|
191
|
-
|
|
192
|
-
const idMatch = block.match(/^(\S+)/);
|
|
193
|
-
if (idMatch) phase.id = idMatch[1];
|
|
194
|
-
|
|
195
|
-
const nameMatch = block.match(/name:\s*(.+)/);
|
|
196
|
-
if (nameMatch) phase.name = nameMatch[1].trim();
|
|
197
|
-
|
|
198
|
-
const summaryMatch = block.match(/summary:\s*(.+)/);
|
|
199
|
-
if (summaryMatch) phase.summary = summaryMatch[1].trim();
|
|
200
|
-
|
|
201
|
-
// Parse tasks array
|
|
202
|
-
const tasksMatch = block.match(/tasks:\s*\r?\n([\s\S]*?)(?=\r?\n\s{4}- id:|$)/);
|
|
203
|
-
if (tasksMatch) {
|
|
204
|
-
const taskLines = tasksMatch[1].match(/^\s*- (\S+)/gm);
|
|
205
|
-
if (taskLines) {
|
|
206
|
-
phase.tasks = taskLines.map(t => t.replace(/^\s*- /, '').trim());
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (phase.id) manifest.phases.push(phase);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Parse initiatives
|
|
215
|
-
const initiativesMatch = manifestBlock.match(/initiatives:\s*\r?\n([\s\S]*?)(?=\r?\n\s{2}\w|$)/);
|
|
216
|
-
if (initiativesMatch) {
|
|
217
|
-
const initLines = initiativesMatch[1].match(/^\s*- (\S+)/gm);
|
|
218
|
-
if (initLines) {
|
|
219
|
-
manifest.initiatives = initLines.map(t => t.replace(/^\s*- /, '').trim());
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Parse horizon
|
|
224
|
-
const horizonMatch = manifestBlock.match(/horizon:\s*\r?\n([\s\S]*?)(?=\r?\n\w|$)/);
|
|
225
|
-
if (horizonMatch) {
|
|
226
|
-
const horizonLines = horizonMatch[1].match(/^\s*- (\S+)/gm);
|
|
227
|
-
if (horizonLines) {
|
|
228
|
-
manifest.horizon = horizonLines.map(t => t.replace(/^\s*- /, '').trim());
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return manifest;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function parseYamlValue(value) {
|
|
236
|
-
if (value === 'true') return true;
|
|
237
|
-
if (value === 'false') return false;
|
|
238
|
-
if (value === 'null' || value === '~') return null;
|
|
239
|
-
if (/^-?\d+$/.test(value)) return parseInt(value, 10);
|
|
240
|
-
if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
|
|
241
|
-
return value.replace(/^["']|["']$/g, '');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function loadSpec(vaultRoot, specPath) {
|
|
245
|
-
const fullPath = join(vaultRoot, specPath);
|
|
246
|
-
if (!existsSync(fullPath)) return null;
|
|
247
|
-
|
|
248
|
-
const content = readFileSync(fullPath, 'utf-8');
|
|
249
|
-
return parseFrontmatter(content);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Raw, single-ordering resolver: locate a spec file whose basename (or
|
|
253
|
-
// frontmatter id) matches `specId` exactly as given. The dual-read wrapper
|
|
254
|
-
// `findSpecFile` calls this once per candidate ordering.
|
|
255
|
-
export function resolveSpecFileRaw(vaultRoot, specId) {
|
|
256
|
-
// Check exact filename matches first
|
|
257
|
-
const locations = [
|
|
258
|
-
`specs/tasks/${specId}.md`,
|
|
259
|
-
`specs/epics/${specId}.md`,
|
|
260
|
-
`specs/milestones/${specId}.md`,
|
|
261
|
-
`specs/roadmaps/${specId}.md`,
|
|
262
|
-
`specs/research/${specId}.md`,
|
|
263
|
-
`specs/reviews/${specId}.md`,
|
|
264
|
-
`specs/adr/${specId}.md`,
|
|
265
|
-
`specs/${specId}.md`,
|
|
266
|
-
];
|
|
267
|
-
// CAP is family-keyed (dual-read via parseSpecId); IDEA is not reordered, so it
|
|
268
|
-
// stays a type-first prefix check ([[HS-EPIC-DLE3]]).
|
|
269
|
-
if (parseSpecId(specId)?.typeLabel === 'CAP' || /^HS-IDEA-/i.test(specId)) {
|
|
270
|
-
for (const bucket of ['captures', 'ideas']) {
|
|
271
|
-
const bucketDir = join(vaultRoot, 'specs', bucket);
|
|
272
|
-
if (!existsSync(bucketDir)) continue;
|
|
273
|
-
try {
|
|
274
|
-
for (const file of readdirSync(bucketDir)) {
|
|
275
|
-
if (file.startsWith(specId) && file.endsWith('.md')) {
|
|
276
|
-
return join('specs', bucket, file);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
} catch {
|
|
280
|
-
/* ignore */
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
for (const loc of locations) {
|
|
286
|
-
const fullPath = join(vaultRoot, loc);
|
|
287
|
-
if (existsSync(fullPath)) return loc;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Search in specs directories by filename prefix. Vault root (`specs/`) is
|
|
291
|
-
// scanned via a sentinel that the helpers treat as a flat .md walk (no
|
|
292
|
-
// subdir recursion) so foundational specs (heyspex-north-star, etc.) and
|
|
293
|
-
// adr/ entries resolve from [[wikilinks]]. [[HS-TASK-NWLR]]
|
|
294
|
-
//
|
|
295
|
-
// Ambiguity guard ([[HS-68XO-DIAG]] / vault-id-assignment-protocol §Shared-suffix
|
|
296
|
-
// families, same contract as [[HS-QEBP-TASK]]): a bare shared-family token
|
|
297
|
-
// (e.g. `HS-W6AD-TASK` when six sibling tasks all carry that id) matches MORE
|
|
298
|
-
// THAN ONE basename at a token boundary. Picking the first in readdir order
|
|
299
|
-
// silently targets the wrong sibling (the diagnosed claim/scheduler bug), so we
|
|
300
|
-
// collect ALL matches and resolve only when exactly one exists — >1 returns null
|
|
301
|
-
// (ambiguous → surfaces upstream rather than guessing). A full basename already
|
|
302
|
-
// returned via the exact-path check above, so it never reaches this scan.
|
|
303
|
-
const specDirs = ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses', 'specs/roadmaps', 'specs/research', 'specs/reviews', 'specs/captures', 'specs/ideas', 'specs/adr', 'specs'];
|
|
304
|
-
const matchesAtBoundary = (file) =>
|
|
305
|
-
file.endsWith('.md') && (file === `${specId}.md` || file.startsWith(`${specId}-`));
|
|
306
|
-
|
|
307
|
-
const prefixMatches = new Set();
|
|
308
|
-
for (const dir of specDirs) {
|
|
309
|
-
const dirPath = join(vaultRoot, dir);
|
|
310
|
-
if (!existsSync(dirPath)) continue;
|
|
311
|
-
try {
|
|
312
|
-
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
313
|
-
if (entry.isFile() && matchesAtBoundary(entry.name)) {
|
|
314
|
-
prefixMatches.add(join(dir, entry.name));
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
} catch {
|
|
318
|
-
// Directory doesn't exist or isn't readable
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (prefixMatches.size === 1) return [...prefixMatches][0];
|
|
322
|
-
if (prefixMatches.size > 1) return null; // ambiguous bare family token — do not guess
|
|
323
|
-
|
|
324
|
-
// Search by frontmatter id field (handles filename != id cases). Same
|
|
325
|
-
// exactly-one rule: a shared id carried by >1 sibling is ambiguous → null.
|
|
326
|
-
const idMatches = new Set();
|
|
327
|
-
for (const dir of specDirs) {
|
|
328
|
-
const dirPath = join(vaultRoot, dir);
|
|
329
|
-
if (!existsSync(dirPath)) continue;
|
|
330
|
-
try {
|
|
331
|
-
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
332
|
-
if (!entry.isFile()) continue;
|
|
333
|
-
const file = entry.name;
|
|
334
|
-
if (!file.endsWith('.md')) continue;
|
|
335
|
-
const filePath = join(dir, file);
|
|
336
|
-
const content = readFileSync(join(vaultRoot, filePath), 'utf-8');
|
|
337
|
-
const { frontmatter } = parseFrontmatter(content);
|
|
338
|
-
if (frontmatter.id === specId) idMatches.add(filePath);
|
|
339
|
-
}
|
|
340
|
-
} catch {
|
|
341
|
-
// Skip files that can't be read
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (idMatches.size === 1) return [...idMatches][0];
|
|
345
|
-
return null; // 0 → not found; >1 → ambiguous shared-family id
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Dual-read spec resolver ([[HS-EPIC-6SNQ-dual-read-resolver-grace-period-for-pipe]],
|
|
349
|
-
// Phase 1 of [[HS-MIL-KY1F-pipeline-first-spec-identifier-format-mi]]).
|
|
350
|
-
//
|
|
351
|
-
// During the format migration grace window the on-disk basenames are in ONE
|
|
352
|
-
// ordering at a time (type-first today, family-first after the bulk rename), but
|
|
353
|
-
// callers may pass either. Resolve the input as-given first; on a miss, flip the
|
|
354
|
-
// ordering once and retry, logging the legacy/cross-ordering hit to stderr so the
|
|
355
|
-
// sunset gate can count residual legacy references. Family-first input that hits
|
|
356
|
-
// directly never touches the fallback (no log).
|
|
357
|
-
export function findSpecFile(vaultRoot, specId) {
|
|
358
|
-
const direct = resolveSpecFileRaw(vaultRoot, specId);
|
|
359
|
-
if (direct) return direct;
|
|
360
|
-
|
|
361
|
-
const alt = alternateOrderingBasename(specId);
|
|
362
|
-
if (alt) {
|
|
363
|
-
const hit = resolveSpecFileRaw(vaultRoot, alt);
|
|
364
|
-
if (hit) {
|
|
365
|
-
process.stderr.write?.(
|
|
366
|
-
`[heyspex-mcp dual-read] resolved "${specId}" via alternate ordering "${alt}" → ${hit}\n`,
|
|
367
|
-
);
|
|
368
|
-
return hit;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// File-key tier ([[HS-ANWD-RHIB-TASK]], parity with the console resolver's
|
|
373
|
-
// buildSpecLookup priority: file key above basename fallbacks). Extract a
|
|
374
|
-
// candidate key from the query — a bare `HS-<key>` citation or the era-aware
|
|
375
|
-
// key of a full basename — and match it against every spec's frontmatter
|
|
376
|
-
// `file_id:` / basename-derived key, in ANY of the three era orderings
|
|
377
|
-
// (era-1/2 keys sit mid-basename, so the prefix scan above never finds them).
|
|
378
|
-
const byKey = resolveSpecFileByFileKey(vaultRoot, specId);
|
|
379
|
-
if (byKey) return byKey;
|
|
380
|
-
|
|
381
|
-
// Crosswalk tier: after the step-4 vault-wide rename, retired basenames/ids
|
|
382
|
-
// live in .heyspex/id-crosswalk.json → re-resolve to the surviving name.
|
|
383
|
-
const viaCrosswalk = resolveSpecFileViaCrosswalk(vaultRoot, specId);
|
|
384
|
-
if (viaCrosswalk) return viaCrosswalk;
|
|
385
|
-
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Directories the file-key tier scans — superset mirror of the specDirs list in
|
|
390
|
-
// resolveSpecFileRaw (kept separate so that function stays untouched).
|
|
391
|
-
export const FILE_KEY_SCAN_DIRS = ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses', 'specs/roadmaps', 'specs/research', 'specs/reviews', 'specs/captures', 'specs/ideas', 'specs/adr', 'specs'];
|
|
392
|
-
|
|
393
|
-
/** Bare `HS-<fileKey>` citation → the key; else the era-aware key of a full basename; else null. */
|
|
394
|
-
export function fileKeyCandidateFromQuery(specId) {
|
|
395
|
-
const trimmed = String(specId ?? '').trim().replace(/\.md$/i, '');
|
|
396
|
-
const bare = /^HS-((?=[0-9]*[A-Z])[A-Z0-9]{4,})$/.exec(trimmed);
|
|
397
|
-
if (bare) return bare[1];
|
|
398
|
-
return fileKeyFromBasename(trimmed);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Resolve a spec by its per-file FILE KEY across all three era orderings.
|
|
403
|
-
* Frontmatter `file_id:` is the SSOT (a lazily-minted key may exist only
|
|
404
|
-
* there), with the basename-derived key as the cheap first check. Fail-closed
|
|
405
|
-
* on ambiguity: >1 match returns null and names the colliding candidates on
|
|
406
|
-
* stderr — never guess ([[vault-id-assignment-protocol]] ambiguity contract).
|
|
407
|
-
*/
|
|
408
|
-
export function resolveSpecFileByFileKey(vaultRoot, specId) {
|
|
409
|
-
const candidate = fileKeyCandidateFromQuery(specId);
|
|
410
|
-
if (!candidate) return null;
|
|
411
|
-
|
|
412
|
-
const matches = new Set();
|
|
413
|
-
for (const dir of FILE_KEY_SCAN_DIRS) {
|
|
414
|
-
const dirPath = join(vaultRoot, dir);
|
|
415
|
-
if (!existsSync(dirPath)) continue;
|
|
416
|
-
try {
|
|
417
|
-
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
418
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
419
|
-
const base = entry.name.replace(/\.md$/i, '');
|
|
420
|
-
if (fileKeyFromBasename(base) === candidate) {
|
|
421
|
-
matches.add(join(dir, entry.name));
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
try {
|
|
425
|
-
const { frontmatter } = parseFrontmatter(readFileSync(join(dirPath, entry.name), 'utf-8'));
|
|
426
|
-
if (frontmatter.file_id === candidate) matches.add(join(dir, entry.name));
|
|
427
|
-
} catch {
|
|
428
|
-
// Unreadable file — skip.
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
} catch {
|
|
432
|
-
// Directory unreadable — skip.
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (matches.size === 1) {
|
|
437
|
-
const hit = [...matches][0];
|
|
438
|
-
process.stderr.write?.(
|
|
439
|
-
`[heyspex-mcp dual-read] resolved "${specId}" via file key "${candidate}" → ${hit}\n`,
|
|
440
|
-
);
|
|
441
|
-
return hit;
|
|
442
|
-
}
|
|
443
|
-
if (matches.size > 1) {
|
|
444
|
-
process.stderr.write?.(
|
|
445
|
-
`[heyspex-mcp dual-read] AMBIGUOUS file key "${candidate}" for "${specId}" — candidates: ${[...matches].join(', ')} (fail-closed, not guessing)\n`,
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Crosswalk fallback ([[HS-ANWD-RHIB-TASK]]): read the retired-id redirect
|
|
453
|
-
* table the step-4 rename writes (`.heyspex/id-crosswalk.json`, rows
|
|
454
|
-
* `{oldBasename, oldId, newBasename, newId}`) and re-resolve a retired
|
|
455
|
-
* basename/id to the surviving spec. Tolerates an absent or malformed file
|
|
456
|
-
* silently (pre-rename vaults have none) — mirror of the indexer's
|
|
457
|
-
* loadIdCrosswalk contract.
|
|
458
|
-
*/
|
|
459
|
-
export function resolveSpecFileViaCrosswalk(vaultRoot, specId) {
|
|
460
|
-
let rows;
|
|
461
|
-
try {
|
|
462
|
-
rows = JSON.parse(readFileSync(join(vaultRoot, '.heyspex', 'id-crosswalk.json'), 'utf-8'));
|
|
463
|
-
} catch {
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
if (!Array.isArray(rows)) return null;
|
|
467
|
-
|
|
468
|
-
const q = String(specId ?? '').trim().replace(/\.md$/i, '');
|
|
469
|
-
const hit = rows.find((r) => r && (r.oldBasename === q || r.oldId === q));
|
|
470
|
-
if (!hit) return null;
|
|
471
|
-
const target = hit.newBasename ?? hit.newId;
|
|
472
|
-
if (!target) return null;
|
|
473
|
-
const resolved = resolveSpecFileRaw(vaultRoot, target);
|
|
474
|
-
if (resolved) {
|
|
475
|
-
process.stderr.write?.(
|
|
476
|
-
`[heyspex-mcp dual-read] resolved retired "${specId}" via crosswalk → ${resolved}\n`,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
return resolved;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Cloud-reliant sibling of `findSpecFile` ([[HS-DJWP-MCPC-TASK]] "spec-id
|
|
484
|
-
* resolution" family). `findSpecFile` itself (and the multi-tier
|
|
485
|
-
* `resolveSpecFileRaw` / `resolveSpecFileByFileKey` / `resolveSpecFileViaCrosswalk`
|
|
486
|
-
* chain it drives) stays disk-only and UNTOUCHED — it is the `diskRead` branch
|
|
487
|
-
* here, verbatim. In `neon` tier this never walks `specs/` directories; it asks
|
|
488
|
-
* `GET /api/specs/resolve?q=` for the `{basename, id, relPath}` triple. A 404
|
|
489
|
-
* (not_found OR ambiguous_id) is a real, reachable-console answer — "no such
|
|
490
|
-
* spec" — and returns `null` exactly like `findSpecFile` does today; only a
|
|
491
|
-
* genuine transport/5xx failure throws (wrapped by `readViaSelector` into
|
|
492
|
-
* `CONSOLE_UNREACHABLE_CLOUD_MODE`).
|
|
493
|
-
*/
|
|
494
|
-
export async function resolveSpecIdViaTier(vaultRoot, specId, { tier, consoleUrl }) {
|
|
495
|
-
return readViaSelector({
|
|
496
|
-
tier,
|
|
497
|
-
// Same contract as findSpecFile: a bare relPath string, or null.
|
|
498
|
-
diskRead: () => findSpecFile(vaultRoot, specId),
|
|
499
|
-
neonRead: async () => {
|
|
500
|
-
const res = await consoleFetch(`/api/specs/resolve?q=${encodeURIComponent(specId)}`);
|
|
501
|
-
if (res.ok) {
|
|
502
|
-
return res.body?.resolved?.relPath ?? null;
|
|
503
|
-
}
|
|
504
|
-
if (res.status === 404) return null; // not_found / ambiguous_id — a real answer, not a failure.
|
|
505
|
-
throw
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
if (!
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* `
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
*
|
|
698
|
-
* `
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (typeof raw !== 'string') return null;
|
|
722
|
-
const m = raw.match(/\[\[([^\]]+)\]\]/);
|
|
723
|
-
return m ? m[1].trim() : raw.trim();
|
|
724
|
-
}
|
|
1
|
+
// Vault file IO shared by every tool: frontmatter/YAML parsing, spec-file
|
|
2
|
+
// resolution by id / file key / crosswalk, and the tier-aware spec loaders.
|
|
3
|
+
// Extracted from cli.mjs under
|
|
4
|
+
// [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
|
|
5
|
+
//
|
|
6
|
+
// Read-path selector ([[HS-DJWP-MCPC-TASK-wire-the-mcp-server-s-vault-reads]] of
|
|
7
|
+
// [[HS-T6GS-MCPC-EPIC-mcp-server-reads-the-vault-through-the]]): the ONE tier
|
|
8
|
+
// decision point every wired vault read below goes through. Tier is resolved
|
|
9
|
+
// fresh per tool invocation (never cached at module scope) so a dev-mode file
|
|
10
|
+
// edited between calls takes effect on the very next call.
|
|
11
|
+
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { alternateOrderingBasename, fileKeyFromBasename, parseSpecId } from './spec-id-lib.mjs';
|
|
15
|
+
import { consoleReadErrorFrom, readViaSelector } from './read-path.mjs';
|
|
16
|
+
import { consoleFetch } from './console-transport.mjs';
|
|
17
|
+
|
|
18
|
+
export function parseFrontmatter(content) {
|
|
19
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
20
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
21
|
+
|
|
22
|
+
const yamlBlock = match[1];
|
|
23
|
+
const body = content.slice(match[0].length).trim();
|
|
24
|
+
const frontmatter = parseSimpleYaml(yamlBlock);
|
|
25
|
+
|
|
26
|
+
return { frontmatter, body };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseSimpleYaml(yaml) {
|
|
30
|
+
const result = {};
|
|
31
|
+
const lines = yaml.split(/\r?\n/);
|
|
32
|
+
let currentKey = null;
|
|
33
|
+
let currentArray = null;
|
|
34
|
+
let inNestedObject = false;
|
|
35
|
+
let nestedKey = null;
|
|
36
|
+
let nestedArray = null;
|
|
37
|
+
// Block-scalar collection state (`key: |` / `key: >`). The vast majority of
|
|
38
|
+
// authored `summary:` values in this vault are literal block scalars, so the
|
|
39
|
+
// claim brief must read them as strings — not silently drop them to `[]`,
|
|
40
|
+
// which made the /hs-next card fall into the "author a summary" path for any
|
|
41
|
+
// spec whose summary was a block scalar. [[HS-TASK-ECOI]]
|
|
42
|
+
let blockKey = null;
|
|
43
|
+
let blockLines = null;
|
|
44
|
+
let blockFold = false;
|
|
45
|
+
|
|
46
|
+
const flushBlock = () => {
|
|
47
|
+
if (blockKey === null) return;
|
|
48
|
+
const contentful = blockLines.filter((l) => l.trim() !== '');
|
|
49
|
+
const minIndent = contentful.length
|
|
50
|
+
? Math.min(...contentful.map((l) => l.match(/^(\s*)/)[1].length))
|
|
51
|
+
: 0;
|
|
52
|
+
const stripped = blockLines.map((l) => l.slice(minIndent));
|
|
53
|
+
result[blockKey] = blockFold
|
|
54
|
+
? stripped.map((l) => l.trim()).filter(Boolean).join(' ')
|
|
55
|
+
: stripped.join('\n').replace(/\n+$/, '');
|
|
56
|
+
blockKey = null;
|
|
57
|
+
blockLines = null;
|
|
58
|
+
blockFold = false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
// Collect an active block scalar: blank or indented lines belong to it; a
|
|
63
|
+
// line at the key column ends it and is parsed normally below.
|
|
64
|
+
if (blockKey !== null) {
|
|
65
|
+
if (line.trim() === '' || /^\s/.test(line)) {
|
|
66
|
+
blockLines.push(line);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
flushBlock();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Skip empty lines and comments
|
|
73
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
74
|
+
|
|
75
|
+
// Detect array item under a key
|
|
76
|
+
const arrayItemMatch = line.match(/^(\s+)-\s*(.*)$/);
|
|
77
|
+
if (arrayItemMatch && currentArray !== null) {
|
|
78
|
+
const indent = arrayItemMatch[1].length;
|
|
79
|
+
const value = arrayItemMatch[2].trim().replace(/^["']|["']$/g, '');
|
|
80
|
+
if (indent <= 2 && currentArray) {
|
|
81
|
+
currentArray.push(value);
|
|
82
|
+
} else if (nestedArray) {
|
|
83
|
+
nestedArray.push(value);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Detect nested object key (e.g., roadmap_manifest:)
|
|
89
|
+
const nestedKeyMatch = line.match(/^(\s{2,})(\w+):\s*(.*)$/);
|
|
90
|
+
if (nestedKeyMatch && inNestedObject) {
|
|
91
|
+
const key = nestedKeyMatch[2];
|
|
92
|
+
const value = nestedKeyMatch[3].trim();
|
|
93
|
+
if (value) {
|
|
94
|
+
if (!result[nestedKey]) result[nestedKey] = {};
|
|
95
|
+
result[nestedKey][key] = parseYamlValue(value);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Detect top-level key
|
|
101
|
+
const keyMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
|
|
102
|
+
if (keyMatch) {
|
|
103
|
+
const key = keyMatch[1];
|
|
104
|
+
const value = keyMatch[2].trim();
|
|
105
|
+
|
|
106
|
+
currentArray = null;
|
|
107
|
+
inNestedObject = false;
|
|
108
|
+
nestedKey = null;
|
|
109
|
+
nestedArray = null;
|
|
110
|
+
|
|
111
|
+
if (/^[|>][-+\d]*$/.test(value)) {
|
|
112
|
+
// Block scalar (`|`, `>`, or a header variant like `|-`/`>+`/`|2`):
|
|
113
|
+
// collect the indented continuation lines into a string value.
|
|
114
|
+
currentKey = key;
|
|
115
|
+
blockKey = key;
|
|
116
|
+
blockLines = [];
|
|
117
|
+
blockFold = value.startsWith('>');
|
|
118
|
+
result[key] = '';
|
|
119
|
+
} else if (value === '') {
|
|
120
|
+
// Could be array, nested object, or multiline
|
|
121
|
+
currentKey = key;
|
|
122
|
+
result[key] = [];
|
|
123
|
+
currentArray = result[key];
|
|
124
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
125
|
+
// Inline array
|
|
126
|
+
result[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
127
|
+
} else {
|
|
128
|
+
result[key] = parseYamlValue(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check if this is roadmap_manifest (needs special handling)
|
|
132
|
+
if (key === 'roadmap_manifest') {
|
|
133
|
+
inNestedObject = true;
|
|
134
|
+
nestedKey = key;
|
|
135
|
+
result[key] = {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// `pin:` is a nested object ({ spec_id, pinned_at, expires_at, pinned_by })
|
|
139
|
+
// written by the console pin writer (`POST /api/pin`). Without
|
|
140
|
+
// this, parseSimpleYaml only descends into roadmap_manifest, so an empty
|
|
141
|
+
// `pin:` value parses to an array and the modern pin block is invisible to
|
|
142
|
+
// /hs-next — every focus reader downstream (getPinnedSpecIdFromFocus, the
|
|
143
|
+
// milestone/epic drill) sees no pin and falls through to scheduler_default.
|
|
144
|
+
// Guarded on empty value so a scalar `pin:` elsewhere is unaffected.
|
|
145
|
+
if (key === 'pin' && value === '') {
|
|
146
|
+
inNestedObject = true;
|
|
147
|
+
nestedKey = key;
|
|
148
|
+
result[key] = {};
|
|
149
|
+
currentArray = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Flush a block scalar that ran to the end of the frontmatter.
|
|
155
|
+
flushBlock();
|
|
156
|
+
|
|
157
|
+
// Handle roadmap_manifest specially - re-parse with full YAML support
|
|
158
|
+
if (result.roadmap_manifest !== undefined) {
|
|
159
|
+
result.roadmap_manifest = parseRoadmapManifest(yaml);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function parseRoadmapManifest(yaml) {
|
|
166
|
+
// Find roadmap_manifest block and parse it more carefully
|
|
167
|
+
const manifestMatch = yaml.match(/roadmap_manifest:\s*\r?\n([\s\S]*?)(?=\r?\n\w|\r?\n$|$)/);
|
|
168
|
+
if (!manifestMatch) return {};
|
|
169
|
+
|
|
170
|
+
const manifestBlock = manifestMatch[1];
|
|
171
|
+
const manifest = { phases: [], initiatives: [], horizon: [] };
|
|
172
|
+
|
|
173
|
+
// Parse version and default
|
|
174
|
+
const versionMatch = manifestBlock.match(/version:\s*(\d+)/);
|
|
175
|
+
if (versionMatch) manifest.version = parseInt(versionMatch[1], 10);
|
|
176
|
+
|
|
177
|
+
const defaultMatch = manifestBlock.match(/default:\s*(true|false)/);
|
|
178
|
+
if (defaultMatch) manifest.default = defaultMatch[1] === 'true';
|
|
179
|
+
|
|
180
|
+
// Parse phases
|
|
181
|
+
const phasesMatch = manifestBlock.match(/phases:\s*\r?\n([\s\S]*?)(?=\r?\n\s{2}\w|\r?\n\s{2}-\s*id:.*initiatives|\r?\n\s{2}initiatives:|\r?\n\s{2}horizon:|$)/);
|
|
182
|
+
if (phasesMatch) {
|
|
183
|
+
const phasesBlock = phasesMatch[1];
|
|
184
|
+
const phaseBlocks = phasesBlock.split(/\r?\n\s{4}- id:/);
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < phaseBlocks.length; i++) {
|
|
187
|
+
const block = (i === 0 ? phaseBlocks[i].replace(/^\s*- id:/, '') : phaseBlocks[i]).trim();
|
|
188
|
+
if (!block) continue;
|
|
189
|
+
|
|
190
|
+
const phase = { tasks: [] };
|
|
191
|
+
|
|
192
|
+
const idMatch = block.match(/^(\S+)/);
|
|
193
|
+
if (idMatch) phase.id = idMatch[1];
|
|
194
|
+
|
|
195
|
+
const nameMatch = block.match(/name:\s*(.+)/);
|
|
196
|
+
if (nameMatch) phase.name = nameMatch[1].trim();
|
|
197
|
+
|
|
198
|
+
const summaryMatch = block.match(/summary:\s*(.+)/);
|
|
199
|
+
if (summaryMatch) phase.summary = summaryMatch[1].trim();
|
|
200
|
+
|
|
201
|
+
// Parse tasks array
|
|
202
|
+
const tasksMatch = block.match(/tasks:\s*\r?\n([\s\S]*?)(?=\r?\n\s{4}- id:|$)/);
|
|
203
|
+
if (tasksMatch) {
|
|
204
|
+
const taskLines = tasksMatch[1].match(/^\s*- (\S+)/gm);
|
|
205
|
+
if (taskLines) {
|
|
206
|
+
phase.tasks = taskLines.map(t => t.replace(/^\s*- /, '').trim());
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (phase.id) manifest.phases.push(phase);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Parse initiatives
|
|
215
|
+
const initiativesMatch = manifestBlock.match(/initiatives:\s*\r?\n([\s\S]*?)(?=\r?\n\s{2}\w|$)/);
|
|
216
|
+
if (initiativesMatch) {
|
|
217
|
+
const initLines = initiativesMatch[1].match(/^\s*- (\S+)/gm);
|
|
218
|
+
if (initLines) {
|
|
219
|
+
manifest.initiatives = initLines.map(t => t.replace(/^\s*- /, '').trim());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Parse horizon
|
|
224
|
+
const horizonMatch = manifestBlock.match(/horizon:\s*\r?\n([\s\S]*?)(?=\r?\n\w|$)/);
|
|
225
|
+
if (horizonMatch) {
|
|
226
|
+
const horizonLines = horizonMatch[1].match(/^\s*- (\S+)/gm);
|
|
227
|
+
if (horizonLines) {
|
|
228
|
+
manifest.horizon = horizonLines.map(t => t.replace(/^\s*- /, '').trim());
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return manifest;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function parseYamlValue(value) {
|
|
236
|
+
if (value === 'true') return true;
|
|
237
|
+
if (value === 'false') return false;
|
|
238
|
+
if (value === 'null' || value === '~') return null;
|
|
239
|
+
if (/^-?\d+$/.test(value)) return parseInt(value, 10);
|
|
240
|
+
if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
|
|
241
|
+
return value.replace(/^["']|["']$/g, '');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function loadSpec(vaultRoot, specPath) {
|
|
245
|
+
const fullPath = join(vaultRoot, specPath);
|
|
246
|
+
if (!existsSync(fullPath)) return null;
|
|
247
|
+
|
|
248
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
249
|
+
return parseFrontmatter(content);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Raw, single-ordering resolver: locate a spec file whose basename (or
|
|
253
|
+
// frontmatter id) matches `specId` exactly as given. The dual-read wrapper
|
|
254
|
+
// `findSpecFile` calls this once per candidate ordering.
|
|
255
|
+
export function resolveSpecFileRaw(vaultRoot, specId) {
|
|
256
|
+
// Check exact filename matches first
|
|
257
|
+
const locations = [
|
|
258
|
+
`specs/tasks/${specId}.md`,
|
|
259
|
+
`specs/epics/${specId}.md`,
|
|
260
|
+
`specs/milestones/${specId}.md`,
|
|
261
|
+
`specs/roadmaps/${specId}.md`,
|
|
262
|
+
`specs/research/${specId}.md`,
|
|
263
|
+
`specs/reviews/${specId}.md`,
|
|
264
|
+
`specs/adr/${specId}.md`,
|
|
265
|
+
`specs/${specId}.md`,
|
|
266
|
+
];
|
|
267
|
+
// CAP is family-keyed (dual-read via parseSpecId); IDEA is not reordered, so it
|
|
268
|
+
// stays a type-first prefix check ([[HS-EPIC-DLE3]]).
|
|
269
|
+
if (parseSpecId(specId)?.typeLabel === 'CAP' || /^HS-IDEA-/i.test(specId)) {
|
|
270
|
+
for (const bucket of ['captures', 'ideas']) {
|
|
271
|
+
const bucketDir = join(vaultRoot, 'specs', bucket);
|
|
272
|
+
if (!existsSync(bucketDir)) continue;
|
|
273
|
+
try {
|
|
274
|
+
for (const file of readdirSync(bucketDir)) {
|
|
275
|
+
if (file.startsWith(specId) && file.endsWith('.md')) {
|
|
276
|
+
return join('specs', bucket, file);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
/* ignore */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const loc of locations) {
|
|
286
|
+
const fullPath = join(vaultRoot, loc);
|
|
287
|
+
if (existsSync(fullPath)) return loc;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Search in specs directories by filename prefix. Vault root (`specs/`) is
|
|
291
|
+
// scanned via a sentinel that the helpers treat as a flat .md walk (no
|
|
292
|
+
// subdir recursion) so foundational specs (heyspex-north-star, etc.) and
|
|
293
|
+
// adr/ entries resolve from [[wikilinks]]. [[HS-TASK-NWLR]]
|
|
294
|
+
//
|
|
295
|
+
// Ambiguity guard ([[HS-68XO-DIAG]] / vault-id-assignment-protocol §Shared-suffix
|
|
296
|
+
// families, same contract as [[HS-QEBP-TASK]]): a bare shared-family token
|
|
297
|
+
// (e.g. `HS-W6AD-TASK` when six sibling tasks all carry that id) matches MORE
|
|
298
|
+
// THAN ONE basename at a token boundary. Picking the first in readdir order
|
|
299
|
+
// silently targets the wrong sibling (the diagnosed claim/scheduler bug), so we
|
|
300
|
+
// collect ALL matches and resolve only when exactly one exists — >1 returns null
|
|
301
|
+
// (ambiguous → surfaces upstream rather than guessing). A full basename already
|
|
302
|
+
// returned via the exact-path check above, so it never reaches this scan.
|
|
303
|
+
const specDirs = ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses', 'specs/roadmaps', 'specs/research', 'specs/reviews', 'specs/captures', 'specs/ideas', 'specs/adr', 'specs'];
|
|
304
|
+
const matchesAtBoundary = (file) =>
|
|
305
|
+
file.endsWith('.md') && (file === `${specId}.md` || file.startsWith(`${specId}-`));
|
|
306
|
+
|
|
307
|
+
const prefixMatches = new Set();
|
|
308
|
+
for (const dir of specDirs) {
|
|
309
|
+
const dirPath = join(vaultRoot, dir);
|
|
310
|
+
if (!existsSync(dirPath)) continue;
|
|
311
|
+
try {
|
|
312
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
313
|
+
if (entry.isFile() && matchesAtBoundary(entry.name)) {
|
|
314
|
+
prefixMatches.add(join(dir, entry.name));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Directory doesn't exist or isn't readable
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (prefixMatches.size === 1) return [...prefixMatches][0];
|
|
322
|
+
if (prefixMatches.size > 1) return null; // ambiguous bare family token — do not guess
|
|
323
|
+
|
|
324
|
+
// Search by frontmatter id field (handles filename != id cases). Same
|
|
325
|
+
// exactly-one rule: a shared id carried by >1 sibling is ambiguous → null.
|
|
326
|
+
const idMatches = new Set();
|
|
327
|
+
for (const dir of specDirs) {
|
|
328
|
+
const dirPath = join(vaultRoot, dir);
|
|
329
|
+
if (!existsSync(dirPath)) continue;
|
|
330
|
+
try {
|
|
331
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
332
|
+
if (!entry.isFile()) continue;
|
|
333
|
+
const file = entry.name;
|
|
334
|
+
if (!file.endsWith('.md')) continue;
|
|
335
|
+
const filePath = join(dir, file);
|
|
336
|
+
const content = readFileSync(join(vaultRoot, filePath), 'utf-8');
|
|
337
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
338
|
+
if (frontmatter.id === specId) idMatches.add(filePath);
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// Skip files that can't be read
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (idMatches.size === 1) return [...idMatches][0];
|
|
345
|
+
return null; // 0 → not found; >1 → ambiguous shared-family id
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Dual-read spec resolver ([[HS-EPIC-6SNQ-dual-read-resolver-grace-period-for-pipe]],
|
|
349
|
+
// Phase 1 of [[HS-MIL-KY1F-pipeline-first-spec-identifier-format-mi]]).
|
|
350
|
+
//
|
|
351
|
+
// During the format migration grace window the on-disk basenames are in ONE
|
|
352
|
+
// ordering at a time (type-first today, family-first after the bulk rename), but
|
|
353
|
+
// callers may pass either. Resolve the input as-given first; on a miss, flip the
|
|
354
|
+
// ordering once and retry, logging the legacy/cross-ordering hit to stderr so the
|
|
355
|
+
// sunset gate can count residual legacy references. Family-first input that hits
|
|
356
|
+
// directly never touches the fallback (no log).
|
|
357
|
+
export function findSpecFile(vaultRoot, specId) {
|
|
358
|
+
const direct = resolveSpecFileRaw(vaultRoot, specId);
|
|
359
|
+
if (direct) return direct;
|
|
360
|
+
|
|
361
|
+
const alt = alternateOrderingBasename(specId);
|
|
362
|
+
if (alt) {
|
|
363
|
+
const hit = resolveSpecFileRaw(vaultRoot, alt);
|
|
364
|
+
if (hit) {
|
|
365
|
+
process.stderr.write?.(
|
|
366
|
+
`[heyspex-mcp dual-read] resolved "${specId}" via alternate ordering "${alt}" → ${hit}\n`,
|
|
367
|
+
);
|
|
368
|
+
return hit;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// File-key tier ([[HS-ANWD-RHIB-TASK]], parity with the console resolver's
|
|
373
|
+
// buildSpecLookup priority: file key above basename fallbacks). Extract a
|
|
374
|
+
// candidate key from the query — a bare `HS-<key>` citation or the era-aware
|
|
375
|
+
// key of a full basename — and match it against every spec's frontmatter
|
|
376
|
+
// `file_id:` / basename-derived key, in ANY of the three era orderings
|
|
377
|
+
// (era-1/2 keys sit mid-basename, so the prefix scan above never finds them).
|
|
378
|
+
const byKey = resolveSpecFileByFileKey(vaultRoot, specId);
|
|
379
|
+
if (byKey) return byKey;
|
|
380
|
+
|
|
381
|
+
// Crosswalk tier: after the step-4 vault-wide rename, retired basenames/ids
|
|
382
|
+
// live in .heyspex/id-crosswalk.json → re-resolve to the surviving name.
|
|
383
|
+
const viaCrosswalk = resolveSpecFileViaCrosswalk(vaultRoot, specId);
|
|
384
|
+
if (viaCrosswalk) return viaCrosswalk;
|
|
385
|
+
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Directories the file-key tier scans — superset mirror of the specDirs list in
|
|
390
|
+
// resolveSpecFileRaw (kept separate so that function stays untouched).
|
|
391
|
+
export const FILE_KEY_SCAN_DIRS = ['specs/tasks', 'specs/epics', 'specs/milestones', 'specs/diagnoses', 'specs/roadmaps', 'specs/research', 'specs/reviews', 'specs/captures', 'specs/ideas', 'specs/adr', 'specs'];
|
|
392
|
+
|
|
393
|
+
/** Bare `HS-<fileKey>` citation → the key; else the era-aware key of a full basename; else null. */
|
|
394
|
+
export function fileKeyCandidateFromQuery(specId) {
|
|
395
|
+
const trimmed = String(specId ?? '').trim().replace(/\.md$/i, '');
|
|
396
|
+
const bare = /^HS-((?=[0-9]*[A-Z])[A-Z0-9]{4,})$/.exec(trimmed);
|
|
397
|
+
if (bare) return bare[1];
|
|
398
|
+
return fileKeyFromBasename(trimmed);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Resolve a spec by its per-file FILE KEY across all three era orderings.
|
|
403
|
+
* Frontmatter `file_id:` is the SSOT (a lazily-minted key may exist only
|
|
404
|
+
* there), with the basename-derived key as the cheap first check. Fail-closed
|
|
405
|
+
* on ambiguity: >1 match returns null and names the colliding candidates on
|
|
406
|
+
* stderr — never guess ([[vault-id-assignment-protocol]] ambiguity contract).
|
|
407
|
+
*/
|
|
408
|
+
export function resolveSpecFileByFileKey(vaultRoot, specId) {
|
|
409
|
+
const candidate = fileKeyCandidateFromQuery(specId);
|
|
410
|
+
if (!candidate) return null;
|
|
411
|
+
|
|
412
|
+
const matches = new Set();
|
|
413
|
+
for (const dir of FILE_KEY_SCAN_DIRS) {
|
|
414
|
+
const dirPath = join(vaultRoot, dir);
|
|
415
|
+
if (!existsSync(dirPath)) continue;
|
|
416
|
+
try {
|
|
417
|
+
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
418
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
419
|
+
const base = entry.name.replace(/\.md$/i, '');
|
|
420
|
+
if (fileKeyFromBasename(base) === candidate) {
|
|
421
|
+
matches.add(join(dir, entry.name));
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const { frontmatter } = parseFrontmatter(readFileSync(join(dirPath, entry.name), 'utf-8'));
|
|
426
|
+
if (frontmatter.file_id === candidate) matches.add(join(dir, entry.name));
|
|
427
|
+
} catch {
|
|
428
|
+
// Unreadable file — skip.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
// Directory unreadable — skip.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (matches.size === 1) {
|
|
437
|
+
const hit = [...matches][0];
|
|
438
|
+
process.stderr.write?.(
|
|
439
|
+
`[heyspex-mcp dual-read] resolved "${specId}" via file key "${candidate}" → ${hit}\n`,
|
|
440
|
+
);
|
|
441
|
+
return hit;
|
|
442
|
+
}
|
|
443
|
+
if (matches.size > 1) {
|
|
444
|
+
process.stderr.write?.(
|
|
445
|
+
`[heyspex-mcp dual-read] AMBIGUOUS file key "${candidate}" for "${specId}" — candidates: ${[...matches].join(', ')} (fail-closed, not guessing)\n`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Crosswalk fallback ([[HS-ANWD-RHIB-TASK]]): read the retired-id redirect
|
|
453
|
+
* table the step-4 rename writes (`.heyspex/id-crosswalk.json`, rows
|
|
454
|
+
* `{oldBasename, oldId, newBasename, newId}`) and re-resolve a retired
|
|
455
|
+
* basename/id to the surviving spec. Tolerates an absent or malformed file
|
|
456
|
+
* silently (pre-rename vaults have none) — mirror of the indexer's
|
|
457
|
+
* loadIdCrosswalk contract.
|
|
458
|
+
*/
|
|
459
|
+
export function resolveSpecFileViaCrosswalk(vaultRoot, specId) {
|
|
460
|
+
let rows;
|
|
461
|
+
try {
|
|
462
|
+
rows = JSON.parse(readFileSync(join(vaultRoot, '.heyspex', 'id-crosswalk.json'), 'utf-8'));
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
if (!Array.isArray(rows)) return null;
|
|
467
|
+
|
|
468
|
+
const q = String(specId ?? '').trim().replace(/\.md$/i, '');
|
|
469
|
+
const hit = rows.find((r) => r && (r.oldBasename === q || r.oldId === q));
|
|
470
|
+
if (!hit) return null;
|
|
471
|
+
const target = hit.newBasename ?? hit.newId;
|
|
472
|
+
if (!target) return null;
|
|
473
|
+
const resolved = resolveSpecFileRaw(vaultRoot, target);
|
|
474
|
+
if (resolved) {
|
|
475
|
+
process.stderr.write?.(
|
|
476
|
+
`[heyspex-mcp dual-read] resolved retired "${specId}" via crosswalk → ${resolved}\n`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return resolved;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Cloud-reliant sibling of `findSpecFile` ([[HS-DJWP-MCPC-TASK]] "spec-id
|
|
484
|
+
* resolution" family). `findSpecFile` itself (and the multi-tier
|
|
485
|
+
* `resolveSpecFileRaw` / `resolveSpecFileByFileKey` / `resolveSpecFileViaCrosswalk`
|
|
486
|
+
* chain it drives) stays disk-only and UNTOUCHED — it is the `diskRead` branch
|
|
487
|
+
* here, verbatim. In `neon` tier this never walks `specs/` directories; it asks
|
|
488
|
+
* `GET /api/specs/resolve?q=` for the `{basename, id, relPath}` triple. A 404
|
|
489
|
+
* (not_found OR ambiguous_id) is a real, reachable-console answer — "no such
|
|
490
|
+
* spec" — and returns `null` exactly like `findSpecFile` does today; only a
|
|
491
|
+
* genuine transport/5xx failure throws (wrapped by `readViaSelector` into
|
|
492
|
+
* `CONSOLE_UNREACHABLE_CLOUD_MODE`).
|
|
493
|
+
*/
|
|
494
|
+
export async function resolveSpecIdViaTier(vaultRoot, specId, { tier, consoleUrl }) {
|
|
495
|
+
return readViaSelector({
|
|
496
|
+
tier,
|
|
497
|
+
// Same contract as findSpecFile: a bare relPath string, or null.
|
|
498
|
+
diskRead: () => findSpecFile(vaultRoot, specId),
|
|
499
|
+
neonRead: async () => {
|
|
500
|
+
const res = await consoleFetch(`/api/specs/resolve?q=${encodeURIComponent(specId)}`);
|
|
501
|
+
if (res.ok) {
|
|
502
|
+
return res.body?.resolved?.relPath ?? null;
|
|
503
|
+
}
|
|
504
|
+
if (res.status === 404) return null; // not_found / ambiguous_id — a real answer, not a failure.
|
|
505
|
+
throw consoleReadErrorFrom(res, `GET /api/specs/resolve returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
|
|
506
|
+
},
|
|
507
|
+
routeLabel: 'GET /api/specs/resolve',
|
|
508
|
+
consoleUrl,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Cloud-reliant "load this spec by id" primitive ([[HS-DJWP-MCPC-TASK]] round
|
|
514
|
+
* 2, AC1 "general spec load"). Disk tier calls `findSpecFile` + `loadSpec`
|
|
515
|
+
* (both untouched, verbatim) and returns `{ specPath, frontmatter, body }` —
|
|
516
|
+
* `null` when not found. Neon tier makes ONE `GET /api/specs/[id]` call (the
|
|
517
|
+
* route resolves AND loads together, per its own docstring: "`:id` accepts
|
|
518
|
+
* the spec `id` or a wikilink basename").
|
|
519
|
+
*
|
|
520
|
+
* Used by `handleNextOnDeck` to pre-fetch the direct `spec_id` lookup branch
|
|
521
|
+
* of `resolveNextOnDeckPayload` — see the `__cloud_direct_spec` injection
|
|
522
|
+
* there for why that branch itself could not be made async directly (14+
|
|
523
|
+
* synchronous call sites in `cli.test.mjs`).
|
|
524
|
+
*/
|
|
525
|
+
export async function loadSpecByIdViaTier(vaultRoot, specId, { tier, consoleUrl }) {
|
|
526
|
+
return readViaSelector({
|
|
527
|
+
tier,
|
|
528
|
+
diskRead: () => {
|
|
529
|
+
const specPath = findSpecFile(vaultRoot, specId);
|
|
530
|
+
if (!specPath) return null;
|
|
531
|
+
const spec = loadSpec(vaultRoot, specPath);
|
|
532
|
+
if (!spec) return null;
|
|
533
|
+
return { specPath, frontmatter: spec.frontmatter, body: spec.body };
|
|
534
|
+
},
|
|
535
|
+
neonRead: async () => {
|
|
536
|
+
const res = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}`);
|
|
537
|
+
if (res.ok) {
|
|
538
|
+
const spec = res.body?.spec;
|
|
539
|
+
if (!spec) return null;
|
|
540
|
+
return { specPath: spec.relPath ?? null, frontmatter: spec.frontmatter || {}, body: spec.body || '' };
|
|
541
|
+
}
|
|
542
|
+
if (res.status === 404) return null; // not_found / ambiguous_id — a real answer, not a failure.
|
|
543
|
+
throw consoleReadErrorFrom(res, `GET /api/specs/${specId} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
|
|
544
|
+
},
|
|
545
|
+
routeLabel: 'GET /api/specs/[id]',
|
|
546
|
+
consoleUrl,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* [[HS-H227-MCPC-TASK]] Pre-fetch, over HTTP, the bounded closure of specs
|
|
552
|
+
* that on-deck brief composition's `loadRef` calls can reach for a given
|
|
553
|
+
* root spec's frontmatter — `extractDecompositionContext`'s `related:` links
|
|
554
|
+
* and its milestone-chain climb (walked by `enrichOnDeckWithClaimTarget` too,
|
|
555
|
+
* via `resolveHierarchy`/`computeClaimTarget` in blocker-resolver.mjs), plus
|
|
556
|
+
* each hop's OWN `related:` links (parent-contract tethering checks the
|
|
557
|
+
* task's related AND its parent's related) and a milestone's sibling `epics:`
|
|
558
|
+
* (decomposition context's milestone-siblings list). Real chains are shallow
|
|
559
|
+
* (task -> epic -> milestone), but the climb is bounded (8 hops) as a
|
|
560
|
+
* belt-and-braces guard against a malformed cycle.
|
|
561
|
+
*
|
|
562
|
+
* Returns a `Map` keyed by every spelling a `loadRef(ref)` call might use for
|
|
563
|
+
* an entry (the ref token it was fetched by, its resolved frontmatter `id`,
|
|
564
|
+
* and its specPath basename) — `loadTaskViaGraph` below does the lookup. The
|
|
565
|
+
* root spec itself (already fetched by the caller as `__cloud_direct_spec`,
|
|
566
|
+
* no second HTTP call here) is seeded under `rootRef` so
|
|
567
|
+
* `enrichOnDeckWithClaimTarget`'s OWN `loadTask(vaultRoot, result.spec_id)`
|
|
568
|
+
* re-load of the on-deck spec also answers from this same cache.
|
|
569
|
+
* `loadSpecByIdViaTier`'s own no-silent-disk-fallback holds here too: a
|
|
570
|
+
* genuine console failure mid-walk throws (propagated to the caller's
|
|
571
|
+
* existing CONSOLE_UNREACHABLE_CLOUD_MODE handling); a 404/not-found ref is
|
|
572
|
+
* just absent from the map — every consumer already treats a missing
|
|
573
|
+
* `loadRef` result as "skip this piece of context", never a hard error.
|
|
574
|
+
*/
|
|
575
|
+
export async function prefetchCloudTaskGraph(vaultRoot, rootRef, rootSpec, { tier, consoleUrl }) {
|
|
576
|
+
const cache = new Map();
|
|
577
|
+
|
|
578
|
+
const register = (key, loaded) => {
|
|
579
|
+
if (!loaded) return null;
|
|
580
|
+
const fm = loaded.frontmatter || {};
|
|
581
|
+
const shaped = {
|
|
582
|
+
frontmatter: fm,
|
|
583
|
+
body: loaded.body || '',
|
|
584
|
+
specPath: loaded.specPath ?? null,
|
|
585
|
+
id: fm.id || key,
|
|
586
|
+
status: fm.status,
|
|
587
|
+
name: fm.name,
|
|
588
|
+
type: fm.type,
|
|
589
|
+
tags: fm.tags || [],
|
|
590
|
+
};
|
|
591
|
+
if (key) cache.set(String(key), shaped);
|
|
592
|
+
if (fm.id) cache.set(String(fm.id), shaped);
|
|
593
|
+
if (loaded.specPath) {
|
|
594
|
+
const base = String(loaded.specPath).split(/[\\/]/).pop().replace(/\.md$/i, '');
|
|
595
|
+
cache.set(base, shaped);
|
|
596
|
+
}
|
|
597
|
+
return shaped;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const fetchRef = async (ref) => {
|
|
601
|
+
const key = wikilinkTarget(ref);
|
|
602
|
+
if (!key) return null;
|
|
603
|
+
if (cache.has(key)) return cache.get(key);
|
|
604
|
+
const loaded = await loadSpecByIdViaTier(vaultRoot, key, { tier, consoleUrl });
|
|
605
|
+
return register(key, loaded);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const relatedOf = (fm) => (Array.isArray(fm?.related) ? fm.related : []);
|
|
609
|
+
|
|
610
|
+
const rootShaped = register(rootRef, rootSpec);
|
|
611
|
+
await Promise.all(relatedOf(rootShaped?.frontmatter).map(fetchRef));
|
|
612
|
+
|
|
613
|
+
let currentFm = rootShaped?.frontmatter || {};
|
|
614
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
615
|
+
const parentRef = currentFm.parent || currentFm.parent_epic || currentFm.milestone;
|
|
616
|
+
if (!parentRef) break;
|
|
617
|
+
const parentShaped = await fetchRef(parentRef);
|
|
618
|
+
if (!parentShaped) break;
|
|
619
|
+
const parentFm = parentShaped.frontmatter || {};
|
|
620
|
+
await Promise.all(relatedOf(parentFm).map(fetchRef));
|
|
621
|
+
if (Array.isArray(parentFm.epics)) {
|
|
622
|
+
await Promise.all(parentFm.epics.map(fetchRef));
|
|
623
|
+
}
|
|
624
|
+
currentFm = parentFm;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return cache;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// [[HS-68XO-DIAG]] residual — pick a UNIQUELY-resolvable handle for a claim target.
|
|
631
|
+
//
|
|
632
|
+
// computeClaimTarget reports a leaf's bare frontmatter `id` as `spec_id`, but a
|
|
633
|
+
// shared-suffix family (>1 sibling carrying the same id, e.g. the six
|
|
634
|
+
// `HS-W6AD-TASK` children of HS-W6AD-EPIC) makes that bare token AMBIGUOUS. The
|
|
635
|
+
// hardened resolver ([[vault-id-assignment-protocol]] §Shared-suffix families)
|
|
636
|
+
// correctly REFUSES a bare ambiguous token — which would 409 the very
|
|
637
|
+
// `heyspex_claim_spec` the on-deck brief tees up (the claim/release/alignment
|
|
638
|
+
// actions are all built off `claim_target.spec_id`). When the bare id no longer
|
|
639
|
+
// resolves to exactly one spec, return the leaf's unique basename instead (it
|
|
640
|
+
// resolves via the resolver's exact-path check), so the claim/status/progress
|
|
641
|
+
// handshake targets exactly this sibling. Unique families still resolve from the
|
|
642
|
+
// bare token → it is returned unchanged (the convention + every blocker-resolver
|
|
643
|
+
// test asserting bare ids is preserved).
|
|
644
|
+
export function disambiguateClaimSpecId(vaultRoot, specId, relPath) {
|
|
645
|
+
if (!specId || !relPath) return specId;
|
|
646
|
+
let resolvesUniquely = false;
|
|
647
|
+
try {
|
|
648
|
+
resolvesUniquely = !!findSpecFile(vaultRoot, specId);
|
|
649
|
+
} catch {
|
|
650
|
+
resolvesUniquely = false;
|
|
651
|
+
}
|
|
652
|
+
if (resolvesUniquely) return specId;
|
|
653
|
+
const base = String(relPath)
|
|
654
|
+
.split(/[\\/]/)
|
|
655
|
+
.pop()
|
|
656
|
+
.replace(/\.md$/i, '');
|
|
657
|
+
return base && base !== specId ? base : specId;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function loadRoadmap(vaultRoot, roadmapId) {
|
|
661
|
+
const specPath = findSpecFile(vaultRoot, roadmapId);
|
|
662
|
+
if (!specPath) return null;
|
|
663
|
+
|
|
664
|
+
const spec = loadSpec(vaultRoot, specPath);
|
|
665
|
+
if (!spec) return null;
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
...spec,
|
|
669
|
+
specPath,
|
|
670
|
+
id: spec.frontmatter.id || roadmapId,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function loadTask(vaultRoot, taskId) {
|
|
675
|
+
const specPath = findSpecFile(vaultRoot, taskId);
|
|
676
|
+
if (!specPath) return null;
|
|
677
|
+
|
|
678
|
+
const spec = loadSpec(vaultRoot, specPath);
|
|
679
|
+
if (!spec) return null;
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
...spec,
|
|
683
|
+
specPath,
|
|
684
|
+
id: spec.frontmatter.id || taskId,
|
|
685
|
+
status: spec.frontmatter.status,
|
|
686
|
+
name: spec.frontmatter.name,
|
|
687
|
+
type: spec.frontmatter.type,
|
|
688
|
+
tags: spec.frontmatter.tags || [],
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* [[HS-H227-MCPC-TASK]] Cloud-aware `loadTask` for on-deck brief composition.
|
|
694
|
+
* `cloudTaskGraph`, when supplied, is the `Map` `prefetchCloudTaskGraph` built
|
|
695
|
+
* over HTTP in neon tier — a miss there returns null, never a disk fallback
|
|
696
|
+
* (no-silent-disk-fallback rule; a genuine console failure already surfaced
|
|
697
|
+
* during the pre-fetch, before this synchronous lookup ever runs). Every
|
|
698
|
+
* OTHER `loadTask` call site (disk-only on-deck flows this task does not
|
|
699
|
+
* touch — blocker/prerequisite resolution, the scheduler cohort path, etc.)
|
|
700
|
+
* never passes a graph and this falls through to the original disk read,
|
|
701
|
+
* unchanged.
|
|
702
|
+
*/
|
|
703
|
+
export function loadTaskViaGraph(vaultRoot, ref, cloudTaskGraph) {
|
|
704
|
+
if (cloudTaskGraph) {
|
|
705
|
+
const key = wikilinkTarget(ref);
|
|
706
|
+
return (key && cloudTaskGraph.get(key)) || null;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
return loadTask(vaultRoot, ref);
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Extract a basename or id from a `[[wikilink]]` (with or without surrounding quotes).
|
|
716
|
+
export function wikilinkTarget(raw) {
|
|
717
|
+
if (typeof raw !== 'string') return null;
|
|
718
|
+
const m = raw.match(/\[\[([^\]]+)\]\]/);
|
|
719
|
+
return m ? m[1].trim() : raw.trim();
|
|
720
|
+
}
|