heyspex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,2001 @@
1
+ /**
2
+ * Pre-plan grounding for HeySpex MCP (/hs-ground).
3
+ * Holistic vault scan, north-star/pillar hooks, architecture placement,
4
+ * stress-test prompts, enhancement seeds — read-only.
5
+ */
6
+
7
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { join, relative, basename } from 'node:path';
9
+ import {
10
+ analyzeGroundingConflicts,
11
+ computeSafeContextSpecs,
12
+ } from './plan-ground-conflicts.mjs';
13
+
14
+ const STOPWORDS = new Set([
15
+ 'a', 'an', 'the', 'and', 'or', 'for', 'to', 'in', 'on', 'at', 'of', 'with',
16
+ 'is', 'are', 'was', 'were', 'be', 'this', 'that', 'it', 'as', 'from', 'by',
17
+ 'add', 'new', 'use', 'using', 'via', 'into', 'when', 'how', 'what', 'which',
18
+ ]);
19
+
20
+ const ANCHOR_SPECS = [
21
+ { basename: 'heyspex-north-star', why: 'L1 vision / constitution' },
22
+ { basename: 'architecture-pillars', why: 'L2 pillars and milestones (M2–M5)' },
23
+ { basename: 'entity-model', why: 'Entity schema and vault layout' },
24
+ { basename: 'plan-ingest-agent-workflow', why: 'Plan filing workflow before push', subdir: 'protocols' },
25
+ { basename: 'mcp-slash-surface-protocol', why: 'MCP + slash command parity', subdir: 'protocols' },
26
+ // next-session-handoff.md retired (HS-EPIC-HNDR) — current session resume state is the
27
+ // WIP ledger (.heyspex/wip-state.json, surfaced in the /hs-next brief), not a markdown anchor.
28
+ ];
29
+
30
+ const SCAN_DIRS = [
31
+ { rel: 'specs/epics', bucket: 'epics' },
32
+ { rel: 'specs/tasks', bucket: 'tasks' },
33
+ { rel: 'specs/protocols', bucket: 'protocols' },
34
+ { rel: 'specs/adr', bucket: 'adrs' },
35
+ { rel: 'specs/use-cases', bucket: 'use_cases' },
36
+ { rel: 'specs/memory', bucket: 'memories' },
37
+ { rel: 'specs/research', bucket: 'research' },
38
+ // Captures hold the owner's not-yet-specced design vocabulary (e.g. named
39
+ // operating modes). Excluding them was the [[HS-RES-2QX6]] root cause: a
40
+ // pre-plan ground that could not surface [[HS-CAP-UIBF]] let an agent reinvent
41
+ // an already-designed concept. Milestones + diagnoses round out the in-flight
42
+ // surfaces a plan can collide with (mirrors plan-review.mjs OVERLAP_DIRS).
43
+ { rel: 'specs/captures', bucket: 'captures' },
44
+ { rel: 'specs/milestones', bucket: 'milestones' },
45
+ { rel: 'specs/diagnoses', bucket: 'diagnoses' },
46
+ { rel: 'specs/future-considerations', bucket: 'future_considerations' },
47
+ ];
48
+
49
+ const AREA_BOOSTS = {
50
+ console: { paths: ['spex-console/'], tags: ['console', 'ui', 'cockpit'], surfaces: ['console'] }, // legacy-cockpit tag accepted
51
+ companion: { paths: ['companion/'], tags: ['companion', 'windows'], surfaces: ['companion'] },
52
+ mcp: { paths: ['vendor/heyspex-mcp/'], tags: ['mcp', 'slash'], surfaces: ['mcp'] },
53
+ vault: { paths: ['specs/'], tags: ['vault', 'specs'], surfaces: ['vault'] },
54
+ indexer: { paths: ['spex-console/src/lib/indexer/'], tags: ['indexer', 'm2'], surfaces: ['indexer'] },
55
+ };
56
+
57
+ const PILLAR_HINTS = [
58
+ { id: 'M2', label: 'Indexer MVP', keywords: ['indexer', 'postgres', 'sqlite', 'index', 'm2'] },
59
+ { id: 'M3', label: 'Console MVP', keywords: ['console', 'surface', 'm3'] },
60
+ { id: 'M4', label: 'hs CLI / MCP', keywords: ['cli', 'mcp', 'heyspex_', 'slash', 'm4'] },
61
+ { id: 'M5', label: 'Sync mesh', keywords: ['sync', 'phone', 'mesh', 'capture', 'm5'] },
62
+ ];
63
+
64
+ const STRESS_TEST_PROMPTS = [
65
+ 'Is this the best you can do? What would a stronger v2 include? What should be cut?',
66
+ 'What acceptance criteria are missing or untestable?',
67
+ 'What edge cases, non-goals, rollback, and operator docs are missing?',
68
+ 'Does this duplicate open epics or tasks already in the vault?',
69
+ 'Do behavioral claims conflict with linked protocol specs?',
70
+ 'Which memory-lessons or decisions constrain this work?',
71
+ 'Is this in-scope for current milestones, or should it be deferred to horizon?',
72
+ ];
73
+
74
+ const SKIP_DIRS = new Set([
75
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage', '.heyspex',
76
+ ]);
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+ // TF-IDF Search Index (BM25 + Backlinks + Recency)
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ //
82
+ // ## Algorithm Overview (HS-EPIC-74YP)
83
+ //
84
+ // This module implements a Google-like relevance ranking system for the HeySpex
85
+ // spec vault, combining multiple signals into a weighted score:
86
+ //
87
+ // ### Signal 1: BM25 TF-IDF (primary signal, weight: 3.0)
88
+ // - Industry-standard document retrieval algorithm (Okapi BM25)
89
+ // - Rare terms score higher than common terms via IDF (inverse document frequency)
90
+ // - Term frequency saturates to prevent keyword stuffing
91
+ // - Document length normalization prevents long docs from dominating
92
+ //
93
+ // ### Signal 2: Title Match Boost (weight: 2.0)
94
+ // - Full phrase match in title: 10 points
95
+ // - All terms present (any order): 5 points
96
+ // - Partial term matches: 2 points each
97
+ //
98
+ // ### Signal 3: Tag Matching (weight: 1.5)
99
+ // - Explicit tag match: 5 points (user-specified tags in search)
100
+ // - Query term in spec tag: 3 points (implicit matching)
101
+ //
102
+ // ### Signal 4: Area/Surface Match (weight: 1.0)
103
+ // - Boosts specs in relevant code areas (console, companion, mcp, vault, indexer)
104
+ // - Matches by file path or spec tags
105
+ //
106
+ // ### Signal 5: Status Boost (weight: 0.8)
107
+ // - in_progress: 2.0 (actively worked on)
108
+ // - open: 1.5 (actionable)
109
+ // - in_review: 1.0 (pending completion)
110
+ // - closed: 0 (completed, less relevant for planning)
111
+ //
112
+ // ### Signal 6: Backlink Importance (weight: 1.2)
113
+ // - Specs with more inbound wikilinks are more "authoritative"
114
+ // - Uses log scale: log1p(count), capped at 3
115
+ // - Prevents heavily-linked specs from dominating while still boosting them
116
+ //
117
+ // ### Signal 7: Recency (weight: 0.6)
118
+ // - Recently updated specs are more relevant for planning
119
+ // - Decay tiers: <7d=2, <30d=1.5, <90d=1, <365d=0.5, older=0
120
+ //
121
+ // ### Formula
122
+ // Score = (BM25 × 3.0) + (title × 2.0) + (tag × 1.5) + (area × 1.0)
123
+ // + (status × 0.8) + (backlink × 1.2) + (recency × 0.6)
124
+ //
125
+ // ### Tuning
126
+ // - Weights chosen empirically to balance precision vs recall
127
+ // - tfidf is primary signal (3.0) because it captures semantic relevance
128
+ // - title gets 2.0 because explicit matches are high confidence
129
+ // - status/recency are lower (0.6-0.8) as tiebreakers, not primary filters
130
+ //
131
+ // See: [[hs-ground-search-algorithm]] for design decision documentation
132
+ // Tests: vendor/heyspex-mcp/plan-ground.test.mjs (64 unit tests)
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+
135
+ /** Lazy index singleton with cache invalidation */
136
+ let cachedIndex = null;
137
+ let cacheTimestamp = 0;
138
+ let cachedVaultRoot = null;
139
+ const CACHE_TTL_MS = 30_000; // 30 seconds
140
+
141
+ /**
142
+ * Scoring weights for combining relevance signals.
143
+ *
144
+ * These weights were tuned empirically to balance:
145
+ * - tfidf (3.0): Primary signal for semantic relevance
146
+ * - title (2.0): High-confidence explicit matches
147
+ * - tag (1.5): User-defined categorization
148
+ * - area (1.0): Code area relevance
149
+ * - status (0.8): Prioritize actionable specs
150
+ * - backlink (1.2): Graph-based importance
151
+ * - recency (0.6): Time decay as tiebreaker
152
+ *
153
+ * @see [[hs-ground-search-algorithm]] for rationale
154
+ */
155
+ const SCORE_WEIGHTS = {
156
+ tfidf: 3.0, // BM25 contribution (primary signal)
157
+ title: 2.0, // Title match boost
158
+ tag: 1.5, // Tag match
159
+ area: 1.0, // Area/surface match
160
+ status: 0.8, // Status boost
161
+ backlink: 1.2, // Graph importance
162
+ recency: 0.6, // Time decay
163
+ };
164
+
165
+ /**
166
+ * BM25 algorithm parameters.
167
+ *
168
+ * k1 (1.5): Term frequency saturation parameter
169
+ * - Controls how quickly TF contribution saturates
170
+ * - Range: 1.2-2.0 typical; 1.5 is a balanced default
171
+ * - Higher = TF matters more; lower = saturates faster
172
+ *
173
+ * b (0.75): Document length normalization parameter
174
+ * - Controls how much long documents are penalized
175
+ * - Range: 0.0-1.0; 0.75 is the standard default
176
+ * - Higher = more normalization; 0 = no length penalty
177
+ *
178
+ * These are the Robertson/Walker defaults from the original BM25 paper.
179
+ */
180
+ const BM25_K1 = 1.5; // Term frequency saturation
181
+ const BM25_B = 0.75; // Document length normalization
182
+
183
+ /**
184
+ * Build the in-memory search index over all specs.
185
+ * Computes: corpus stats, term frequencies, backlink graph, recency data.
186
+ */
187
+ function buildVaultIndex(vaultRoot) {
188
+ const startMs = Date.now();
189
+ const specs = new Map();
190
+ const corpus = {
191
+ totalDocs: 0,
192
+ documentFrequency: new Map(),
193
+ avgDocLength: 0,
194
+ };
195
+ const backlinkGraph = new Map(); // target -> Set<source>
196
+
197
+ // Phase 1: Parse all specs, extract tokens and links
198
+ const specsRoot = join(vaultRoot, 'specs');
199
+ const allFiles = walkMdFiles(specsRoot);
200
+ let totalLength = 0;
201
+
202
+ for (const filePath of allFiles) {
203
+ try {
204
+ const content = readFileSync(filePath, 'utf8');
205
+ const { frontmatter, body } = parseFrontmatter(content);
206
+ const fileBase = basename(filePath, '.md');
207
+ const key = fileBase.toLowerCase();
208
+
209
+ const titleTokens = tokenize(frontmatter.name || fileBase);
210
+ const bodyTokens = tokenize(body);
211
+ const allTokens = [...titleTokens, ...bodyTokens];
212
+
213
+ // Build term frequency map
214
+ const termFrequency = new Map();
215
+ for (const token of allTokens) {
216
+ termFrequency.set(token, (termFrequency.get(token) || 0) + 1);
217
+ }
218
+
219
+ // Extract wikilinks for backlink graph
220
+ const outgoingLinks = extractWikilinks(body, frontmatter);
221
+ for (const target of outgoingLinks) {
222
+ const normalized = target.toLowerCase();
223
+ if (!backlinkGraph.has(normalized)) {
224
+ backlinkGraph.set(normalized, new Set());
225
+ }
226
+ backlinkGraph.get(normalized).add(key);
227
+ }
228
+
229
+ // Parse timestamps
230
+ const updatedAt = parseIsoDate(frontmatter.updated);
231
+ const createdAt = parseIsoDate(frontmatter.created);
232
+
233
+ specs.set(key, {
234
+ basename: fileBase,
235
+ id: frontmatter.id || fileBase,
236
+ name: frontmatter.name || fileBase,
237
+ type: frontmatter.type || 'unknown',
238
+ status: String(frontmatter.status || 'open').toLowerCase(),
239
+ tags: normalizeTags(frontmatter.tags),
240
+ // Authored frontmatter.summary, carried so candidate cards can prefer it
241
+ // over the body-derived extractSummaryLine() fallback (authored wins).
242
+ summary: typeof frontmatter.summary === 'string' ? frontmatter.summary : null,
243
+ relPath: relative(vaultRoot, filePath).replace(/\\/g, '/'),
244
+ titleTokens,
245
+ termFrequency,
246
+ docLength: allTokens.length,
247
+ backlinkCount: 0, // computed in phase 3
248
+ forwardRefCount: outgoingLinks.size,
249
+ updatedAt,
250
+ createdAt,
251
+ });
252
+
253
+ corpus.totalDocs++;
254
+ totalLength += allTokens.length;
255
+ } catch {
256
+ // Skip unreadable files
257
+ }
258
+ }
259
+
260
+ corpus.avgDocLength = corpus.totalDocs > 0 ? totalLength / corpus.totalDocs : 0;
261
+
262
+ // Phase 2: Compute document frequency (IDF denominator)
263
+ for (const spec of specs.values()) {
264
+ const uniqueTerms = new Set(spec.termFrequency.keys());
265
+ for (const term of uniqueTerms) {
266
+ corpus.documentFrequency.set(
267
+ term,
268
+ (corpus.documentFrequency.get(term) || 0) + 1
269
+ );
270
+ }
271
+ }
272
+
273
+ // Phase 3: Compute backlink counts
274
+ for (const spec of specs.values()) {
275
+ const key = spec.basename.toLowerCase();
276
+ const sources = backlinkGraph.get(key);
277
+ spec.backlinkCount = sources ? sources.size : 0;
278
+ }
279
+
280
+ const buildTimeMs = Date.now() - startMs;
281
+
282
+ return {
283
+ corpus,
284
+ specs,
285
+ backlinkGraph,
286
+ lastBuilt: Date.now(),
287
+ buildTimeMs,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Get or rebuild the cached index.
293
+ */
294
+ function getOrBuildIndex(vaultRoot) {
295
+ const now = Date.now();
296
+ // Invalidate on vaultRoot change as well as TTL: a single process is normally
297
+ // one vault, but the cache must not hand back a different root's index (would
298
+ // silently mis-resolve, and makes the substrate untestable across vaults).
299
+ if (cachedIndex && cachedVaultRoot === vaultRoot && (now - cacheTimestamp) < CACHE_TTL_MS) {
300
+ return cachedIndex;
301
+ }
302
+ cachedIndex = buildVaultIndex(vaultRoot);
303
+ cacheTimestamp = now;
304
+ cachedVaultRoot = vaultRoot;
305
+ return cachedIndex;
306
+ }
307
+
308
+ /**
309
+ * Extract wikilinks from body text and frontmatter.
310
+ */
311
+ function extractWikilinks(body, frontmatter) {
312
+ const links = new Set();
313
+
314
+ // Body wikilinks: [[target]] or [[target|display]]
315
+ const regex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
316
+ let match;
317
+ while ((match = regex.exec(body)) !== null) {
318
+ const target = match[1].trim().toLowerCase().replace(/\.md$/, '');
319
+ if (target) links.add(target);
320
+ }
321
+
322
+ // Frontmatter links: parent, children, related, blocks, etc.
323
+ // `milestone` (epic → parent milestone) and `epics` (milestone → child epics)
324
+ // are the bidirectional milestone↔epic edge ([[HS-EPIC-MILE]]); including them
325
+ // makes those edges visible to the backlink graph and broken-`HS-MIL-*`-link
326
+ // detection, the same way parent/children cover the epic↔task edge.
327
+ const linkFields = ['parent', 'children', 'related', 'blocks', 'blocked_by', 'discovered_from', 'milestone', 'epics'];
328
+ for (const field of linkFields) {
329
+ const val = frontmatter[field];
330
+ if (Array.isArray(val)) {
331
+ for (const v of val) {
332
+ const normalized = normalizeWikilinkTarget(v);
333
+ if (normalized) links.add(normalized);
334
+ }
335
+ } else if (typeof val === 'string') {
336
+ const normalized = normalizeWikilinkTarget(val);
337
+ if (normalized) links.add(normalized);
338
+ }
339
+ }
340
+
341
+ return links;
342
+ }
343
+
344
+ /**
345
+ * Normalize a wikilink target string.
346
+ */
347
+ function normalizeWikilinkTarget(link) {
348
+ if (!link) return null;
349
+ return link
350
+ .replace(/^\[\[|\]\]$/g, '')
351
+ .split('|')[0]
352
+ .trim()
353
+ .toLowerCase()
354
+ .replace(/\.md$/, '');
355
+ }
356
+
357
+ /**
358
+ * Parse ISO date string to timestamp.
359
+ */
360
+ function parseIsoDate(dateStr) {
361
+ if (!dateStr) return 0;
362
+ const d = new Date(dateStr);
363
+ return isNaN(d.getTime()) ? 0 : d.getTime();
364
+ }
365
+
366
+ /**
367
+ * BM25 scoring - industry standard for document retrieval.
368
+ * k1 controls term frequency saturation (1.2-2.0 typical)
369
+ * b controls document length normalization (0.75 typical)
370
+ */
371
+ function computeBM25(queryTerms, spec, corpus) {
372
+ let score = 0;
373
+ const { avgDocLength, totalDocs, documentFrequency } = corpus;
374
+
375
+ for (const term of queryTerms) {
376
+ const tf = spec.termFrequency.get(term) || 0;
377
+ if (tf === 0) continue;
378
+
379
+ const df = documentFrequency.get(term) || 0;
380
+ // IDF with smoothing to handle rare terms
381
+ const idf = Math.log((totalDocs - df + 0.5) / (df + 0.5) + 1);
382
+
383
+ // Length-normalized term frequency
384
+ const lengthNorm = 1 - BM25_B + BM25_B * (spec.docLength / (avgDocLength || 1));
385
+ const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * lengthNorm);
386
+
387
+ score += idf * tfNorm;
388
+ }
389
+
390
+ return score;
391
+ }
392
+
393
+ /**
394
+ * Title match boost - phrase and term matching.
395
+ */
396
+ function computeTitleBoost(queryTerms, spec) {
397
+ const titleLower = spec.name.toLowerCase();
398
+ const fullQuery = queryTerms.join(' ');
399
+
400
+ // Full phrase match (highest)
401
+ if (titleLower.includes(fullQuery) && fullQuery.length > 3) return 10;
402
+
403
+ // All terms present (any order)
404
+ const presentCount = queryTerms.filter((t) => titleLower.includes(t)).length;
405
+ if (presentCount === queryTerms.length && queryTerms.length > 0) return 5;
406
+
407
+ // Partial match
408
+ return presentCount * 2;
409
+ }
410
+
411
+ /**
412
+ * Backlink importance - specs linked by many others are more authoritative.
413
+ * Uses log scale to prevent runaway scores.
414
+ */
415
+ function computeBacklinkScore(spec) {
416
+ const count = spec.backlinkCount;
417
+ if (count === 0) return 0;
418
+ // log1p handles 0 gracefully: 1 link ~= 0.69, 5 ~= 1.79, 10 ~= 2.40, 30 ~= 3.43
419
+ return Math.min(Math.log1p(count), 3);
420
+ }
421
+
422
+ /**
423
+ * Recency score - recently updated specs are more relevant.
424
+ * Decay function: full score for last 7 days, decays over 90 days.
425
+ */
426
+ function computeRecencyScore(spec, now) {
427
+ if (!spec.updatedAt) return 0;
428
+ const ageMs = now - spec.updatedAt;
429
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
430
+
431
+ if (ageDays < 7) return 2; // Last week: full boost
432
+ if (ageDays < 30) return 1.5; // Last month: high boost
433
+ if (ageDays < 90) return 1; // Last quarter: moderate
434
+ if (ageDays < 365) return 0.5; // Last year: low
435
+ return 0; // Older: no boost
436
+ }
437
+
438
+ /**
439
+ * Status boost - open/in_progress work is more relevant for planning.
440
+ */
441
+ function computeStatusBoost(status) {
442
+ switch (status.toLowerCase()) {
443
+ case 'in_progress': return 2;
444
+ case 'open': return 1.5;
445
+ case 'in_review': return 1;
446
+ case 'closed': return 0;
447
+ default: return 0.5;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Tag matching - exact tag matches are high signal.
453
+ */
454
+ function computeTagScore(queryTerms, explicitTags, spec) {
455
+ let score = 0;
456
+
457
+ // Explicit tag matches (highest weight)
458
+ for (const tag of explicitTags) {
459
+ if (spec.tags.includes(tag.toLowerCase())) {
460
+ score += 5;
461
+ }
462
+ }
463
+
464
+ // Query term matches tag
465
+ for (const term of queryTerms) {
466
+ if (spec.tags.some((t) => t.includes(term))) {
467
+ score += 3;
468
+ }
469
+ }
470
+
471
+ return score;
472
+ }
473
+
474
+ /**
475
+ * Area/surface matching.
476
+ */
477
+ function computeAreaScore(areas, spec) {
478
+ let score = 0;
479
+ const pathLower = spec.relPath.toLowerCase();
480
+
481
+ for (const area of areas) {
482
+ const boost = AREA_BOOSTS[area];
483
+ if (!boost) continue;
484
+ if (boost.paths.some((p) => pathLower.includes(p)) || boost.tags.some((t) => spec.tags.includes(t))) {
485
+ score += 2;
486
+ }
487
+ }
488
+
489
+ return score;
490
+ }
491
+
492
+ /**
493
+ * Enhanced scoreSpec using TF-IDF index.
494
+ * Returns combined score with all factors.
495
+ */
496
+ function scoreSpecWithIndex(queryTokens, explicitTags, areas, spec, corpus) {
497
+ const now = Date.now();
498
+ const reasons = [];
499
+
500
+ // Component scores
501
+ const bm25 = computeBM25(queryTokens, spec, corpus);
502
+ const titleBoost = computeTitleBoost(queryTokens, spec);
503
+ const tagScore = computeTagScore(queryTokens, explicitTags, spec);
504
+ const areaScore = computeAreaScore(areas, spec);
505
+ const statusBoost = computeStatusBoost(spec.status);
506
+ const backlinkScore = computeBacklinkScore(spec);
507
+ const recencyScore = computeRecencyScore(spec, now);
508
+
509
+ // Build reasons for debugging/display
510
+ if (bm25 > 0.5) reasons.push(`tfidf:${bm25.toFixed(1)}`);
511
+ if (titleBoost >= 5) reasons.push(`title:${titleBoost}`);
512
+ if (tagScore > 0) reasons.push(`tag:${tagScore}`);
513
+ if (backlinkScore > 1) reasons.push(`links:${spec.backlinkCount}`);
514
+ if (recencyScore > 1) reasons.push('recent');
515
+ if (areaScore > 0) reasons.push(`area`);
516
+
517
+ // Weighted combination
518
+ const score =
519
+ (bm25 * SCORE_WEIGHTS.tfidf) +
520
+ (titleBoost * SCORE_WEIGHTS.title) +
521
+ (tagScore * SCORE_WEIGHTS.tag) +
522
+ (areaScore * SCORE_WEIGHTS.area) +
523
+ (statusBoost * SCORE_WEIGHTS.status) +
524
+ (backlinkScore * SCORE_WEIGHTS.backlink) +
525
+ (recencyScore * SCORE_WEIGHTS.recency);
526
+
527
+ return {
528
+ score,
529
+ reasons,
530
+ bm25,
531
+ titleBoost,
532
+ tagScore,
533
+ areaScore,
534
+ statusBoost,
535
+ backlinkScore,
536
+ recencyScore,
537
+ };
538
+ }
539
+
540
+ function parseFrontmatter(content) {
541
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
542
+ if (!match) return { frontmatter: {}, body: content };
543
+ const yamlBlock = match[1];
544
+ const body = content.slice(match[0].length).trim();
545
+ return { frontmatter: parseSimpleYaml(yamlBlock), body };
546
+ }
547
+
548
+ function parseSimpleYaml(yaml) {
549
+ const result = {};
550
+ const lines = yaml.split(/\r?\n/);
551
+ let currentArray = null;
552
+ // Block-scalar collection state (`key: |` / `key: >`). The vast majority of
553
+ // authored `summary:` values in this vault are literal block scalars, so the
554
+ // brief consumer must read them as strings — not drop them — to agree with the
555
+ // index consumer (which uses a real YAML parser). [[HS-TASK-ECOI]]
556
+ let blockKey = null;
557
+ let blockLines = null;
558
+ let blockFold = false;
559
+
560
+ const flushBlock = () => {
561
+ if (blockKey === null) return;
562
+ // Strip the common leading indentation of the block's content lines, then
563
+ // join: literal (`|`) keeps line breaks; folded (`>`) joins with spaces.
564
+ const contentful = blockLines.filter((l) => l.trim() !== '');
565
+ const minIndent = contentful.length
566
+ ? Math.min(...contentful.map((l) => l.match(/^(\s*)/)[1].length))
567
+ : 0;
568
+ const stripped = blockLines.map((l) => l.slice(minIndent));
569
+ result[blockKey] = blockFold
570
+ ? stripped.map((l) => l.trim()).filter(Boolean).join(' ')
571
+ : stripped.join('\n').replace(/\n+$/, '');
572
+ blockKey = null;
573
+ blockLines = null;
574
+ blockFold = false;
575
+ };
576
+
577
+ for (const line of lines) {
578
+ if (blockKey !== null) {
579
+ // Blank or indented lines belong to the active block scalar; a line at the
580
+ // key column (non-indented, non-blank) ends it and is parsed normally.
581
+ if (line.trim() === '' || /^\s/.test(line)) {
582
+ blockLines.push(line);
583
+ continue;
584
+ }
585
+ flushBlock();
586
+ }
587
+
588
+ if (!line.trim() || line.trim().startsWith('#')) continue;
589
+
590
+ const arrayItemMatch = line.match(/^(\s+)-\s*(.*)$/);
591
+ if (arrayItemMatch && currentArray) {
592
+ const value = arrayItemMatch[2].trim().replace(/^["']|["']$/g, '');
593
+ currentArray.push(value);
594
+ continue;
595
+ }
596
+
597
+ const keyMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
598
+ if (keyMatch) {
599
+ const key = keyMatch[1];
600
+ const value = keyMatch[2].trim();
601
+ currentArray = null;
602
+
603
+ if (/^[|>][-+\d]*$/.test(value)) {
604
+ // Block scalar: `|`, `>`, or a header variant (`|-`, `>+`, `|2`, …).
605
+ blockKey = key;
606
+ blockLines = [];
607
+ blockFold = value.startsWith('>');
608
+ result[key] = '';
609
+ } else if (value === '') {
610
+ result[key] = [];
611
+ currentArray = result[key];
612
+ } else if (value.startsWith('[') && value.endsWith(']')) {
613
+ result[key] = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
614
+ } else {
615
+ result[key] = value.replace(/^["']|["']$/g, '');
616
+ }
617
+ }
618
+ }
619
+ flushBlock();
620
+ return result;
621
+ }
622
+
623
+ function tokenize(text) {
624
+ if (!text) return [];
625
+ return text
626
+ .toLowerCase()
627
+ .replace(/[^a-z0-9]+/g, ' ')
628
+ .split(/\s+/)
629
+ .filter((t) => t.length > 2 && !STOPWORDS.has(t));
630
+ }
631
+
632
+ function walkMdFiles(dir, out = []) {
633
+ if (!existsSync(dir)) return out;
634
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
635
+ if (SKIP_DIRS.has(entry.name)) continue;
636
+ const full = join(dir, entry.name);
637
+ if (entry.isDirectory()) walkMdFiles(full, out);
638
+ else if (entry.name.endsWith('.md')) out.push(full);
639
+ }
640
+ return out;
641
+ }
642
+
643
+ function resolveSpecPath(vaultRoot, anchor) {
644
+ const specsRoot = join(vaultRoot, 'specs');
645
+ const candidates = [
646
+ join(specsRoot, `${anchor.basename}.md`),
647
+ anchor.subdir ? join(specsRoot, anchor.subdir, `${anchor.basename}.md`) : null,
648
+ ].filter(Boolean);
649
+
650
+ for (const p of candidates) {
651
+ if (existsSync(p)) return relative(vaultRoot, p).replace(/\\/g, '/');
652
+ }
653
+
654
+ const all = walkMdFiles(specsRoot);
655
+ const found = all.find((f) => basename(f, '.md') === anchor.basename);
656
+ return found ? relative(vaultRoot, found).replace(/\\/g, '/') : null;
657
+ }
658
+
659
+ function normalizeTags(tags) {
660
+ if (!tags) return [];
661
+ if (Array.isArray(tags)) return tags.map((t) => String(t).toLowerCase());
662
+ return [String(tags).toLowerCase()];
663
+ }
664
+
665
+ /**
666
+ * Score a spec using the TF-IDF index if available, otherwise fall back to simple scoring.
667
+ * This function bridges the old API (file-based) with the new index-based scoring.
668
+ */
669
+ function scoreSpec(filePath, content, tokens, explicitTags, areas, index = null) {
670
+ const fileBase = basename(filePath, '.md');
671
+ const key = fileBase.toLowerCase();
672
+
673
+ // If we have an index, use enhanced TF-IDF scoring
674
+ if (index && index.specs.has(key)) {
675
+ const spec = index.specs.get(key);
676
+ const result = scoreSpecWithIndex(tokens, explicitTags, areas, spec, index.corpus);
677
+
678
+ return {
679
+ score: result.score,
680
+ reason: result.reasons.slice(0, 4).join(', ') || 'low relevance',
681
+ frontmatter: {
682
+ id: spec.id,
683
+ name: spec.name,
684
+ type: spec.type,
685
+ status: spec.status,
686
+ tags: spec.tags,
687
+ summary: spec.summary ?? null,
688
+ },
689
+ basename: spec.basename,
690
+ status: spec.status,
691
+ // Extra debug info for index-based scoring
692
+ _indexed: true,
693
+ _backlinkCount: spec.backlinkCount,
694
+ _bm25: result.bm25,
695
+ };
696
+ }
697
+
698
+ // Fallback: simple scoring for unindexed files (should be rare)
699
+ const { frontmatter, body } = parseFrontmatter(content);
700
+ const name = String(frontmatter.name || frontmatter.title || fileBase).toLowerCase();
701
+ const specTags = normalizeTags(frontmatter.tags);
702
+ const status = String(frontmatter.status || 'open').toLowerCase();
703
+
704
+ let score = 0;
705
+ const reasons = [];
706
+
707
+ for (const tok of tokens) {
708
+ if (name.includes(tok)) {
709
+ score += 2;
710
+ reasons.push(`title:${tok}`);
711
+ }
712
+ const bodyHits = (body.toLowerCase().match(new RegExp(`\\b${tok}\\b`, 'g')) || []).length;
713
+ if (bodyHits > 0) {
714
+ score += Math.min(bodyHits, 3);
715
+ reasons.push(`body:${tok}`);
716
+ }
717
+ }
718
+
719
+ for (const tag of explicitTags) {
720
+ if (specTags.includes(tag.toLowerCase())) {
721
+ score += 3;
722
+ reasons.push(`tag:${tag}`);
723
+ }
724
+ }
725
+
726
+ for (const area of areas) {
727
+ const boost = AREA_BOOSTS[area];
728
+ if (!boost) continue;
729
+ const pathLower = filePath.replace(/\\/g, '/').toLowerCase();
730
+ if (boost.paths.some((p) => pathLower.includes(p)) || boost.tags.some((t) => specTags.includes(t))) {
731
+ score += 2;
732
+ reasons.push(`area:${area}`);
733
+ }
734
+ }
735
+
736
+ if (status === 'open' || status === 'in_progress') {
737
+ score += 1;
738
+ }
739
+
740
+ return {
741
+ score,
742
+ reason: reasons.slice(0, 4).join(', ') || 'low relevance',
743
+ frontmatter,
744
+ basename: fileBase,
745
+ status,
746
+ _indexed: false,
747
+ };
748
+ }
749
+
750
+ function detectPillars(text) {
751
+ const lower = text.toLowerCase();
752
+ return PILLAR_HINTS.filter((p) => p.keywords.some((k) => lower.includes(k))).map((p) => p.id);
753
+ }
754
+
755
+ function detectSurfaces(text, areas) {
756
+ const surfaces = new Set();
757
+ for (const area of areas) {
758
+ const b = AREA_BOOSTS[area];
759
+ if (b) b.surfaces.forEach((s) => surfaces.add(s));
760
+ }
761
+ const lower = text.toLowerCase();
762
+ if (lower.includes('console')) surfaces.add('console');
763
+ if (lower.includes('companion')) surfaces.add('companion');
764
+ if (lower.includes('mcp') || lower.includes('slash')) surfaces.add('mcp');
765
+ if (lower.includes('vault') || lower.includes('spec')) surfaces.add('vault');
766
+ if (lower.includes('indexer')) surfaces.add('indexer');
767
+ return [...surfaces];
768
+ }
769
+
770
+ function findLargeMarkdownFiles(vaultRoot, limit = 8) {
771
+ const roots = [vaultRoot];
772
+ const large = [];
773
+
774
+ for (const root of roots) {
775
+ if (!existsSync(root)) continue;
776
+ const files = walkMdFiles(root);
777
+ for (const f of files) {
778
+ if (f.includes('node_modules')) continue;
779
+ try {
780
+ const content = readFileSync(f, 'utf8');
781
+ const lines = content.split(/\r?\n/).length;
782
+ if (lines >= 500) {
783
+ large.push({
784
+ path: relative(vaultRoot, f).replace(/\\/g, '/'),
785
+ lines,
786
+ });
787
+ }
788
+ } catch {
789
+ /* skip */
790
+ }
791
+ }
792
+ }
793
+
794
+ return large.sort((a, b) => b.lines - a.lines).slice(0, limit);
795
+ }
796
+
797
+ function buildCodebaseHints(topic, summary, areas, tokens) {
798
+ const queries = [...new Set([...tokens.slice(0, 6), topic].filter(Boolean))];
799
+ const globs = new Set();
800
+
801
+ for (const area of areas) {
802
+ const b = AREA_BOOSTS[area];
803
+ if (b) b.paths.forEach((p) => globs.add(`${p}**`));
804
+ }
805
+
806
+ if (queries.length === 0) queries.push('heyspex');
807
+
808
+ return {
809
+ grep_queries: queries.slice(0, 8),
810
+ path_globs: [...globs].length ? [...globs] : ['specs/**', 'spex-console/src/**', 'vendor/heyspex-mcp/**'],
811
+ };
812
+ }
813
+
814
+ function guessManifestPhase(vaultRoot, tokens) {
815
+ const roadmapPath = join(vaultRoot, 'specs/roadmaps/HS-ROADMAP-product-master.md');
816
+ if (!existsSync(roadmapPath) || tokens.length === 0) return null;
817
+
818
+ try {
819
+ const content = readFileSync(roadmapPath, 'utf8').toLowerCase();
820
+ const phases = content.match(/phase[_\s-]*id[:\s]+["']?(P\d+)/gi) || [];
821
+ if (phases.length === 0) return 'See [[HS-ROADMAP-product-master]] — agent should read manifest';
822
+ for (const tok of tokens) {
823
+ if (content.includes(tok)) return 'Match found in product roadmap — read manifest for phase';
824
+ }
825
+ } catch {
826
+ return null;
827
+ }
828
+ return null;
829
+ }
830
+
831
+ function buildPlanContextSection(topic, landscape, hooks, placement, suggestedRelated, conflictAnalysis) {
832
+ const lines = [
833
+ '## Grounding',
834
+ '',
835
+ '### Landscape',
836
+ '',
837
+ ];
838
+
839
+ if (landscape.open_work.length) {
840
+ lines.push('**Open work:**');
841
+ for (const item of landscape.open_work.slice(0, 8)) {
842
+ lines.push(`- [[${item.basename}]] — ${item.reason}`);
843
+ }
844
+ lines.push('');
845
+ }
846
+
847
+ lines.push('### North-star and pillars', '');
848
+ lines.push(`- Review [[heyspex-north-star]] for L1 alignment on: **${topic}**`);
849
+ if (hooks.pillar_ids.length) {
850
+ lines.push(`- Pillars touched: ${hooks.pillar_ids.join(', ')} — read [[architecture-foundations]]`);
851
+ }
852
+ if (hooks.alignment_risks.length) {
853
+ for (const r of hooks.alignment_risks) lines.push(`- ⚠ ${r}`);
854
+ }
855
+ lines.push('');
856
+
857
+ lines.push('### Architecture placement', '');
858
+ if (placement.surfaces.length) {
859
+ lines.push(`- Surfaces: ${placement.surfaces.join(', ')}`);
860
+ }
861
+ if (placement.dependency_specs.length) {
862
+ lines.push('- Dependencies:');
863
+ for (const d of placement.dependency_specs.slice(0, 6)) {
864
+ lines.push(` - [[${d}]]`);
865
+ }
866
+ }
867
+ if (placement.manifest_phase_hint) {
868
+ lines.push(`- Roadmap: ${placement.manifest_phase_hint}`);
869
+ }
870
+ lines.push('');
871
+
872
+ lines.push('### Conflicts and source of truth', '');
873
+ if (conflictAnalysis?.conflict_count > 0) {
874
+ lines.push(`- **${conflictAnalysis.conflict_count} conflict(s)/drift** — resolve before treating specs as authoritative.`);
875
+ for (const c of conflictAnalysis.conflicts.slice(0, 5)) {
876
+ lines.push(`- [[${c.spec_b}]] vs ${c.spec_a}: ${c.epic_claim} ⟂ ${c.spec_claim}`);
877
+ lines.push(` - Recommended authority: **${c.recommended_source_of_truth}** — ${c.rationale}`);
878
+ }
879
+ if (conflictAnalysis.conflicts.length > 5) {
880
+ lines.push(`- …and ${conflictAnalysis.conflicts.length - 5} more (see tool \`conflict_analysis\`)`);
881
+ }
882
+ lines.push(`- Resolve: ${conflictAnalysis.resolve_commands.join(' · ')}`);
883
+ } else {
884
+ lines.push('- No behavioral conflicts detected among draft, focused epic, and top protocols.');
885
+ }
886
+ lines.push('');
887
+
888
+ lines.push('### Stress-test notes', '');
889
+ lines.push('- Complete adversarial review before push (see `/hs-ground` Phase 4).');
890
+ lines.push('');
891
+
892
+ lines.push('### Related (safe for context)', '');
893
+ const safe = conflictAnalysis?.safe_context_specs || suggestedRelated;
894
+ for (const r of safe.slice(0, 15)) {
895
+ lines.push(`- [[${r}]]`);
896
+ }
897
+ if (conflictAnalysis?.conflicted_spec_basenames?.length) {
898
+ lines.push('');
899
+ lines.push('**Do not cite as authoritative until resolved:**');
900
+ for (const b of conflictAnalysis.conflicted_spec_basenames.slice(0, 10)) {
901
+ lines.push(`- [[${b}]]`);
902
+ }
903
+ }
904
+ lines.push('');
905
+
906
+ return lines.join('\n');
907
+ }
908
+
909
+ function buildOptimizedOutline(topic) {
910
+ return [
911
+ `## Goal`,
912
+ '',
913
+ `${topic} — (refine after grounding)`,
914
+ '',
915
+ '## Phases',
916
+ '',
917
+ '1. **Discovery / design** — (from landscape scan)',
918
+ '2. **Implementation** — (code touch map from grounding)',
919
+ '3. **Validation** — tests, validate:vault-ids, linkage',
920
+ '',
921
+ '## Acceptance criteria',
922
+ '',
923
+ '- [ ] Aligns with [[heyspex-north-star]] and relevant pillar milestone',
924
+ '- [ ] Linked protocols and epics in `related:`',
925
+ '- [ ] (add measurable criteria from stress-test)',
926
+ '',
927
+ ].join('\n');
928
+ }
929
+
930
+ /**
931
+ * Extract a one-line summary from a spec's markdown: the first meaningful prose
932
+ * line after the frontmatter block, with heading/callout/link syntax stripped
933
+ * and capped at `maxLen` chars. Used to build body-free candidate cards — the
934
+ * LLM triages on these, then deep-reads only the shortlist it selects
935
+ * ([[retrieval-intake-architecture]] §How the LLM-as-reranker works).
936
+ * @param {string} content
937
+ * @param {number} [maxLen]
938
+ * @returns {string}
939
+ */
940
+ function extractSummaryLine(content, maxLen = 140) {
941
+ let body = String(content || '');
942
+ // Drop a leading YAML frontmatter block.
943
+ if (body.startsWith('---')) {
944
+ const close = body.indexOf('\n---', 3);
945
+ if (close !== -1) {
946
+ const nl = body.indexOf('\n', close + 1);
947
+ body = nl !== -1 ? body.slice(nl + 1) : '';
948
+ }
949
+ }
950
+ const cap = (s) => (s.length > maxLen ? `${s.slice(0, maxLen - 1).trimEnd()}…` : s);
951
+ let firstHeading = ''; // fallback if the doc is heading-only (title echoes anyway)
952
+ for (let raw of body.split('\n')) {
953
+ let line = raw.trim();
954
+ if (!line) continue;
955
+ if (line.startsWith('|') || line.startsWith('<!--') || line.startsWith('---') || line.startsWith('```')) continue;
956
+ const isHeading = /^#{1,6}\s/.test(line);
957
+ line = line.replace(/^#{1,6}\s+/, ''); // de-head
958
+ line = line.replace(/^>+\s?/, '').replace(/^\[!\w+\][-+]?\s*/, ''); // de-callout
959
+ line = line.replace(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g, '$1'); // [[wikilink]] → text
960
+ line = line.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [md](link) → text
961
+ line = line.replace(/[*_`]/g, '').trim(); // strip emphasis
962
+ if (!line) continue;
963
+ // Prefer the first prose/callout line; the leading H1 usually just echoes
964
+ // the title, so skip headings unless that is all the doc has.
965
+ if (isHeading) {
966
+ if (!firstHeading) firstHeading = line;
967
+ continue;
968
+ }
969
+ return cap(line);
970
+ }
971
+ return cap(firstHeading);
972
+ }
973
+
974
+ /**
975
+ * @param {string} vaultRoot
976
+ * @param {object} args
977
+ */
978
+ /**
979
+ * Bounded grounding report (G7R2/G7R4 contract): one capped markdown report with six
980
+ * sections — NOT a shopping list that tells the agent to read the vault. Reuses the
981
+ * already-computed lexical wide-recall + conflict data; applies hard caps and a
982
+ * token budget so the slash trigger ([[HS-TASK-G7R6-hs-ground-mcp-slash-thin-trigger]])
983
+ * can paste it verbatim. Substrate is production-lexical only — see
984
+ * [[retrieval-substrate-lexical-not-embeddings-gate-b-no]].
985
+ */
986
+ function buildGroundReport(ctx) {
987
+ const SPEC_CAP = 12, CODE_CAP = 8, CONFLICT_CAP = 5, GRAPH_CAP = 8, GAP_CAP = 6;
988
+ const estTokens = (s) => Math.ceil(s.length / 4);
989
+ const {
990
+ topic, summary, vaultRoot, groundedAt, candidateCards, openWork, protocols,
991
+ decisionsToHeed, conflictAnalysis, surfaces, pillarIds, codebaseHints,
992
+ gapsAndQuestions, maxTokens,
993
+ } = ctx;
994
+
995
+ const render = (nSpecs) => {
996
+ const L = [];
997
+ L.push(`# Grounding report — ${topic}`);
998
+ if (summary) L.push(`> ${summary.slice(0, 200)}`);
999
+
1000
+ L.push('', '## 1. Scope');
1001
+ L.push(`- Workspace: \`${basename(vaultRoot)}\` · surfaces: ${surfaces.join(', ') || '—'} · pillars: ${pillarIds.join(', ') || '—'}`);
1002
+ L.push(`- Index: in-memory lexical (BM25 + signals), built per call · grounded ${groundedAt}`);
1003
+
1004
+ L.push('', '## 2. Relevant specs');
1005
+ L.push('_Lexical wide-recall — YOU are the reranker. Open only the shortlist you judge relevant; do not read them all._');
1006
+ const specs = candidateCards.slice(0, nSpecs);
1007
+ if (!specs.length) L.push('- (none matched)');
1008
+ for (const c of specs) {
1009
+ const why = (c.why_matched && c.why_matched[0]) || (c.summary ? c.summary.slice(0, 90) : '');
1010
+ L.push(`- [[${c.basename}]] (${c.type}/${c.status})${why ? ` — ${why}` : ''}`);
1011
+ }
1012
+
1013
+ L.push('', '## 3. Relevant code');
1014
+ L.push('_Bounded pointers — open only what the task needs (code chunk store lands in G7R3)._');
1015
+ const globs = (codebaseHints?.path_globs || []).slice(0, CODE_CAP);
1016
+ if (!globs.length) L.push('- (no path hints)');
1017
+ else for (const g of globs) L.push(`- \`${g}\``);
1018
+
1019
+ L.push('', '## 4. Graph hints');
1020
+ const graph = [
1021
+ ...protocols.slice(0, 4).map((p) => `protocol: [[${p.basename}]]`),
1022
+ ...decisionsToHeed.slice(0, 3).map((d) => `decision/lesson: [[${d.basename}]]`),
1023
+ ...openWork.slice(0, 3).map((e) => `open: [[${e.basename}]]`),
1024
+ ].slice(0, GRAPH_CAP);
1025
+ if (!graph.length) L.push('- (no graph neighbors)');
1026
+ else for (const g of graph) L.push(`- ${g}`);
1027
+
1028
+ L.push('', '## 5. Conflicts');
1029
+ if (conflictAnalysis.has_blocking_conflicts) {
1030
+ L.push(`- ⚠ ${conflictAnalysis.conflict_count} blocking — resolve via ${conflictAnalysis.resolve_commands?.[0] || '/hs-resolve'} before using conflicted specs.`);
1031
+ for (const c of (conflictAnalysis.conflicts || []).slice(0, CONFLICT_CAP)) {
1032
+ const desc = c.summary || c.reason || c.message || c.kind || 'conflict';
1033
+ L.push(` - ${String(desc).slice(0, 140)}`);
1034
+ }
1035
+ } else {
1036
+ L.push('- No blocking claim-level conflicts. NOTE: this detector is blind to design-supersession & identifier drift ([[drift-is-two-axes-as-built-and-design-supersession]]) — verify the design-vs-decision axis manually.');
1037
+ }
1038
+
1039
+ L.push('', '## 6. Gaps');
1040
+ const gaps = (gapsAndQuestions || []).slice(0, GAP_CAP);
1041
+ if (!gaps.length) L.push('- (none flagged)');
1042
+ else for (const g of gaps) L.push(`- ${g}`);
1043
+
1044
+ return L.join('\n');
1045
+ };
1046
+
1047
+ // Greedy fit to the token budget: drop the least-relevant specs first (they are the
1048
+ // longest, most expendable section) until the report fits.
1049
+ let nSpecs = SPEC_CAP;
1050
+ let report = render(nSpecs);
1051
+ let truncated = false;
1052
+ while (estTokens(report) > maxTokens && nSpecs > 3) {
1053
+ nSpecs -= 1;
1054
+ report = render(nSpecs);
1055
+ truncated = true;
1056
+ }
1057
+ return { report_markdown: report, token_estimate: estTokens(report), truncated };
1058
+ }
1059
+
1060
+ export function executeGroundPlan(vaultRoot, args = {}) {
1061
+ const topic = String(args.topic || '').trim();
1062
+ if (!topic) {
1063
+ return { error: 'MISSING_TOPIC', message: 'topic is required (short title of feature/request)' };
1064
+ }
1065
+
1066
+ const summary = String(args.summary || '').trim();
1067
+ const explicitTags = normalizeTags(args.tags || []);
1068
+ const areas = Array.isArray(args.areas)
1069
+ ? args.areas.map((a) => {
1070
+ const v = String(a).toLowerCase();
1071
+ return v === 'cockpit' ? 'console' : v; // legacy-cockpit area alias
1072
+ })
1073
+ : [];
1074
+ // Wide-recall window: the user's own LLM is the reranker, so HeySpex retrieves
1075
+ // WIDE (Recall@N is the metric, not nDCG@10 ordering). Default 30, max 50 — per
1076
+ // [[HS-RES-RTLR-retrieval-ab-layer1-results-and-signoff]] §4h (top-30–50 puts
1077
+ // 70–80% of the gold in front of the LLM vs ~53–58% at top-10) and
1078
+ // [[llm-as-reranker-lexical-recall-companion-architecture]].
1079
+ const limit = Math.min(Math.max(Number(args.limit) || 30, 3), 50);
1080
+ const includeCodebaseHints = args.include_codebase_hints !== false;
1081
+ const checkConflicts = args.check_conflicts !== false;
1082
+ // Compact mode is default — reduces response from ~17k to ~4k tokens
1083
+ const compact = args.compact !== false;
1084
+
1085
+ const combinedText = `${topic} ${summary}`;
1086
+ const tokens = [...new Set([...tokenize(topic), ...tokenize(summary)])];
1087
+
1088
+ // Build or retrieve cached TF-IDF index for enhanced scoring
1089
+ const index = getOrBuildIndex(vaultRoot);
1090
+
1091
+ const anchors = ANCHOR_SPECS.map((a) => {
1092
+ const path = resolveSpecPath(vaultRoot, a);
1093
+ return {
1094
+ basename: a.basename,
1095
+ path: path || `specs/${a.subdir ? `${a.subdir}/` : ''}${a.basename}.md`,
1096
+ why: a.why,
1097
+ tier: 'anchor',
1098
+ found: Boolean(path),
1099
+ };
1100
+ });
1101
+
1102
+ const discovered = {
1103
+ epics: [],
1104
+ tasks: [],
1105
+ protocols: [],
1106
+ adrs: [],
1107
+ use_cases: [],
1108
+ memories: [],
1109
+ research: [],
1110
+ captures: [],
1111
+ milestones: [],
1112
+ diagnoses: [],
1113
+ future_considerations: [],
1114
+ };
1115
+
1116
+ const scored = [];
1117
+
1118
+ for (const { rel, bucket } of SCAN_DIRS) {
1119
+ const dir = join(vaultRoot, rel);
1120
+ const files = walkMdFiles(dir);
1121
+ for (const filePath of files) {
1122
+ try {
1123
+ const content = readFileSync(filePath, 'utf8');
1124
+ const { score, reason, frontmatter, basename: fileBase, status } = scoreSpec(
1125
+ filePath,
1126
+ content,
1127
+ tokens,
1128
+ explicitTags,
1129
+ areas,
1130
+ index, // Pass index for TF-IDF scoring
1131
+ );
1132
+ if (score <= 0 && bucket !== 'memories') continue;
1133
+
1134
+ const entry = {
1135
+ basename: fileBase,
1136
+ id: frontmatter.id || fileBase,
1137
+ name: frontmatter.name || fileBase,
1138
+ path: relative(vaultRoot, filePath).replace(/\\/g, '/'),
1139
+ status,
1140
+ relevance: score,
1141
+ reason,
1142
+ type: frontmatter.type || bucket,
1143
+ // Authored frontmatter.summary, carried so the candidate card can
1144
+ // prefer it over the body-derived extractSummaryLine() fallback
1145
+ // (same precedence as the index/brief consumers: authored wins).
1146
+ summary: typeof frontmatter.summary === 'string' ? frontmatter.summary : null,
1147
+ };
1148
+
1149
+ scored.push({ bucket, entry });
1150
+
1151
+ if (discovered[bucket]) {
1152
+ discovered[bucket].push(entry);
1153
+ }
1154
+ } catch {
1155
+ /* skip unreadable */
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ scored.sort((a, b) => b.entry.relevance - a.entry.relevance);
1161
+
1162
+ for (const key of Object.keys(discovered)) {
1163
+ discovered[key].sort((a, b) => b.relevance - a.relevance);
1164
+ discovered[key] = discovered[key].slice(0, limit);
1165
+ }
1166
+
1167
+ const topEntries = scored
1168
+ .filter((s) => s.bucket !== 'future_considerations')
1169
+ .slice(0, limit)
1170
+ .map((s) => s.entry);
1171
+
1172
+ const anchorBasenames = anchors.filter((a) => a.found).map((a) => a.basename);
1173
+ const suggestedRelated = [
1174
+ ...new Set([
1175
+ ...anchorBasenames.filter((b) => !['plan-ingest-agent-workflow'].includes(b)),
1176
+ ...topEntries.map((e) => e.basename),
1177
+ ]),
1178
+ ].slice(0, 20);
1179
+
1180
+ // Wide-recall candidate CARDS — body-free triage rows for the LLM-as-reranker.
1181
+ // The whole top-`limit` (30–50) window, each card carrying just enough to
1182
+ // triage on: id/basename (to Read or [[link]]), title, type, status, a
1183
+ // one-line summary, and the why-matched lexical signals. Bodies are NOT
1184
+ // included — the agent deep-reads only the shortlist it selects (two-stage).
1185
+ const candidateCards = topEntries.map((e) => {
1186
+ // Precedence: authored frontmatter.summary wins over the body-derived
1187
+ // extractSummaryLine() fallback, matching the index (U2) and /hs-next brief
1188
+ // consumers so the same spec reads identically across surfaces. The body
1189
+ // fallback is preserved byte-for-byte for specs with no authored summary.
1190
+ let summary = e.summary && String(e.summary).trim() ? String(e.summary).trim() : '';
1191
+ if (!summary) {
1192
+ try {
1193
+ summary = extractSummaryLine(readFileSync(join(vaultRoot, e.path), 'utf8'));
1194
+ } catch {
1195
+ /* unreadable — leave summary blank */
1196
+ }
1197
+ }
1198
+ return {
1199
+ id: e.id,
1200
+ basename: e.basename,
1201
+ title: e.name,
1202
+ type: e.type,
1203
+ status: e.status,
1204
+ summary,
1205
+ why_matched: e.reason ? e.reason.split(', ').filter(Boolean) : [],
1206
+ relevance: Math.round(e.relevance * 100) / 100,
1207
+ };
1208
+ });
1209
+
1210
+ // The explicit "you are the reranker" contract that rides with the cards.
1211
+ const candidateRerank = {
1212
+ metric: 'Recall@N',
1213
+ window_size: candidateCards.length,
1214
+ you_are_the_reranker: true,
1215
+ instruction:
1216
+ `These ${candidateCards.length} candidate_cards are a WIDE lexical-recall window, not a ranked answer. ` +
1217
+ 'HeySpex retrieves wide and YOUR judgment is the reranker — there is no hosted reranker, vector search, or embedding model. ' +
1218
+ 'Two stages: (1) triage cheaply on the cards (id / title / summary / why_matched) and pick the shortlist that is genuinely relevant; ' +
1219
+ '(2) deep-read ONLY that shortlist (Read those files), then act — write related:/parent links, flag duplicates, or confirm conflicts. ' +
1220
+ 'The order is lexical, not authoritative: do not assume the first card is the answer; judge each on the task in context.',
1221
+ };
1222
+
1223
+ const openWork = [
1224
+ ...discovered.epics,
1225
+ ...discovered.tasks,
1226
+ ]
1227
+ .filter((e) => ['open', 'in_progress'].includes(e.status))
1228
+ .sort((a, b) => b.relevance - a.relevance)
1229
+ .slice(0, 10);
1230
+
1231
+ const possibleDuplicates = openWork.filter((e) => e.relevance >= 4).slice(0, 5);
1232
+
1233
+ const protocols = discovered.protocols.slice(0, 8);
1234
+ const decisionsToHeed = discovered.memories
1235
+ .filter((m) => m.path.includes('/decisions/') || m.path.includes('/lessons/'))
1236
+ .slice(0, 8);
1237
+
1238
+ const horizonItems = discovered.future_considerations.slice(0, 5);
1239
+
1240
+ const pillarIds = detectPillars(combinedText);
1241
+ const alignmentRisks = [];
1242
+ if (possibleDuplicates.length > 0) {
1243
+ alignmentRisks.push(
1244
+ `Possible duplicate work: ${possibleDuplicates.map((d) => `[[${d.basename}]]`).join(', ')}`,
1245
+ );
1246
+ }
1247
+ if (horizonItems.length > 0) {
1248
+ alignmentRisks.push(
1249
+ `Related horizon items exist — confirm in-scope vs defer: ${horizonItems.map((h) => h.basename).join(', ')}`,
1250
+ );
1251
+ }
1252
+ if (pillarIds.length === 0) {
1253
+ alignmentRisks.push('No obvious M2–M5 pillar match — confirm milestone placement in [[architecture-foundations]].');
1254
+ }
1255
+
1256
+ const surfaces = detectSurfaces(combinedText, areas);
1257
+ const dependencySpecs = [
1258
+ ...protocols.map((p) => p.basename),
1259
+ ...discovered.adrs.slice(0, 3).map((a) => a.basename),
1260
+ ].slice(0, 10);
1261
+
1262
+ const architecturePlacement = {
1263
+ surfaces,
1264
+ dependency_specs: dependencySpecs,
1265
+ manifest_phase_hint: guessManifestPhase(vaultRoot, tokens),
1266
+ };
1267
+
1268
+ const northStarPillarHooks = {
1269
+ north_star_sections: [
1270
+ 'Read [[heyspex-north-star]] — identify which L1 goals/themes this request serves or conflicts with.',
1271
+ ],
1272
+ pillar_ids: pillarIds,
1273
+ alignment_risks: alignmentRisks,
1274
+ };
1275
+
1276
+ const topSpecsForDrift = topEntries.map((e) => ({
1277
+ basename: e.basename,
1278
+ relevance: e.relevance,
1279
+ type: e.type,
1280
+ }));
1281
+
1282
+ const conflictAnalysis = checkConflicts
1283
+ ? analyzeGroundingConflicts(vaultRoot, {
1284
+ topic,
1285
+ summary,
1286
+ protocolBasenames: protocols.map((p) => p.basename),
1287
+ topSpecs: topSpecsForDrift,
1288
+ check_focus_epic: true,
1289
+ // Neon layer-2 focus selection, injected at the async handler boundary
1290
+ // ([[HS-TASK-FSEL]] residual); precedence over the dormant focus.yaml.
1291
+ focusSpecPath: args.neon_selection_spec_path || null,
1292
+ })
1293
+ : {
1294
+ has_blocking_conflicts: false,
1295
+ conflict_count: 0,
1296
+ conflicts: [],
1297
+ linked_specs_checked: [],
1298
+ focused_epic: null,
1299
+ source_of_truth_rules: [],
1300
+ conflicted_spec_basenames: [],
1301
+ recommendations: [],
1302
+ resolve_commands: ['/hs-resolve'],
1303
+ safe_context_specs: suggestedRelated,
1304
+ };
1305
+
1306
+ conflictAnalysis.safe_context_specs = computeSafeContextSpecs(
1307
+ suggestedRelated,
1308
+ conflictAnalysis.conflicted_spec_basenames,
1309
+ );
1310
+
1311
+ const gapsAndQuestions = [
1312
+ ...alignmentRisks,
1313
+ ...(possibleDuplicates.length
1314
+ ? [`Review overlapping specs: ${possibleDuplicates.map((d) => d.id || d.basename).join(', ')}`]
1315
+ : []),
1316
+ ...(conflictAnalysis.has_blocking_conflicts
1317
+ ? [
1318
+ `BLOCKER: ${conflictAnalysis.conflict_count} spec conflict(s)/drift — resolve via ${conflictAnalysis.resolve_commands[0]} before using conflicting specs as plan context.`,
1319
+ ]
1320
+ : []),
1321
+ 'Confirm parent epic and manifest phase before /hs-push.',
1322
+ 'Run heyspex_validate_linkage after filing (when tool available).',
1323
+ ];
1324
+
1325
+ const enhancementSuggestions = [];
1326
+ if (protocols.length === 0) {
1327
+ enhancementSuggestions.push('Search for governing protocol specs to add to related:');
1328
+ } else {
1329
+ enhancementSuggestions.push(`Link protocols: ${protocols.map((p) => `[[${p.basename}]]`).join(', ')}`);
1330
+ }
1331
+ if (possibleDuplicates.length > 0) {
1332
+ enhancementSuggestions.push('Merge or explicitly defer duplicate epics before filing new work.');
1333
+ }
1334
+ if (decisionsToHeed.length > 0) {
1335
+ enhancementSuggestions.push(
1336
+ `Heed memories: ${decisionsToHeed.map((d) => `[[${d.basename}]]`).join(', ')}`,
1337
+ );
1338
+ }
1339
+ if (conflictAnalysis.has_blocking_conflicts) {
1340
+ enhancementSuggestions.push(
1341
+ 'Resolve all conflicts in conflict_analysis before enhancing the plan — use safe_context_specs only until resolved.',
1342
+ );
1343
+ }
1344
+ enhancementSuggestions.push('Add child tasks for each implementation phase after stress-test.');
1345
+
1346
+ const codebaseHints = includeCodebaseHints
1347
+ ? {
1348
+ ...buildCodebaseHints(topic, summary, areas, tokens),
1349
+ large_markdown_files: findLargeMarkdownFiles(vaultRoot),
1350
+ }
1351
+ : { grep_queries: [], path_globs: [], large_markdown_files: [] };
1352
+
1353
+ const landscapeSummary = {
1354
+ open_work: openWork.map((e) => ({ basename: e.basename, id: e.id, reason: e.reason })),
1355
+ protocols: protocols.map((p) => ({ basename: p.basename, reason: p.reason })),
1356
+ decisions_to_heed: decisionsToHeed.map((d) => ({ basename: d.basename, reason: d.reason })),
1357
+ possible_duplicates: possibleDuplicates.map((d) => ({ basename: d.basename, id: d.id, reason: d.reason })),
1358
+ horizon_items: horizonItems.map((h) => ({ basename: h.basename, reason: h.reason })),
1359
+ };
1360
+
1361
+ const planContextSectionMarkdown = buildPlanContextSection(
1362
+ topic,
1363
+ landscapeSummary,
1364
+ northStarPillarHooks,
1365
+ architecturePlacement,
1366
+ suggestedRelated,
1367
+ conflictAnalysis,
1368
+ );
1369
+
1370
+ const recallTags = [
1371
+ ...new Set([...explicitTags, ...areas.flatMap((a) => AREA_BOOSTS[a]?.tags || [])]),
1372
+ ].slice(0, 8);
1373
+
1374
+ // Bounded report mode (G7R2/G7R4/G7R6): one capped ≤max_tokens report, no read-everything
1375
+ // scaffold. This is what /hs-ground should call. Substrate is production-lexical only.
1376
+ if (args.report === true) {
1377
+ const maxTokens = Math.min(Math.max(Number(args.max_tokens) || 2000, 500), 4000);
1378
+ const groundedAt = new Date().toISOString();
1379
+ const { report_markdown, token_estimate, truncated } = buildGroundReport({
1380
+ topic,
1381
+ summary,
1382
+ vaultRoot,
1383
+ groundedAt,
1384
+ candidateCards,
1385
+ openWork,
1386
+ protocols,
1387
+ decisionsToHeed,
1388
+ conflictAnalysis,
1389
+ surfaces,
1390
+ pillarIds,
1391
+ codebaseHints,
1392
+ gapsAndQuestions,
1393
+ maxTokens,
1394
+ });
1395
+ return {
1396
+ topic,
1397
+ report_markdown,
1398
+ token_estimate,
1399
+ max_tokens: maxTokens,
1400
+ truncated,
1401
+ has_blocking_conflicts: conflictAnalysis.has_blocking_conflicts,
1402
+ conflict_count: conflictAnalysis.conflict_count,
1403
+ safe_context_specs: (conflictAnalysis.safe_context_specs || []).slice(0, 12),
1404
+ recall_memories_hint: { limit: 10, tags: recallTags.length ? recallTags : undefined },
1405
+ grounded_at: groundedAt,
1406
+ _report: true,
1407
+ };
1408
+ }
1409
+
1410
+ // Build compact or full response based on mode
1411
+ const nextSteps = conflictAnalysis.has_blocking_conflicts
1412
+ ? [
1413
+ `Resolve ${conflictAnalysis.conflict_count} conflict(s) first: ${conflictAnalysis.resolve_commands.join(' · ')}`,
1414
+ 'Re-run heyspex_ground_plan after resolution.',
1415
+ 'Then complete hs-ground phases 2–6 using safe_context_specs only.',
1416
+ 'Hand off to /hs-push with aligned plan and related: (conflicts should be empty).',
1417
+ ]
1418
+ : [
1419
+ 'Complete all six /hs-ground phases in chat (scan → align → place → stress-test → enhance → optimize).',
1420
+ 'Call heyspex_recall_memories with suggested tags.',
1421
+ 'Triage candidate_cards (id/title/summary/why_matched) and deep-read ONLY the shortlist you select — do NOT read every discovered spec or whole large files.',
1422
+ 'Use safe_context_specs from conflict_analysis for authoritative wikilinks.',
1423
+ 'Hand off to /hs-push or /hs-push-next with optimized plan and suggested_related.',
1424
+ ];
1425
+
1426
+ // Compact mode: reduce response size while preserving workflow-critical fields
1427
+ // Main savings: discovered arrays → counts + basenames only (biggest bloat source)
1428
+ if (compact) {
1429
+ // Conflicts: keep full conflicts array (needed for Phase 1b resolution)
1430
+ // but limit to top 5 to prevent explosion
1431
+ const compactConflicts = {
1432
+ has_blocking_conflicts: conflictAnalysis.has_blocking_conflicts,
1433
+ conflict_count: conflictAnalysis.conflict_count,
1434
+ conflicts: (conflictAnalysis.conflicts || []).slice(0, 5),
1435
+ conflicted_spec_basenames: conflictAnalysis.conflicted_spec_basenames?.slice(0, 8) || [],
1436
+ safe_context_specs: conflictAnalysis.safe_context_specs?.slice(0, 12) || [],
1437
+ resolve_commands: conflictAnalysis.resolve_commands,
1438
+ source_of_truth_rules: conflictAnalysis.source_of_truth_rules || [
1439
+ 'protocol > epic for behavioral claims',
1440
+ 'north-star > all for vision/constitution',
1441
+ 'epic supersedes only when explicitly noted',
1442
+ ],
1443
+ };
1444
+
1445
+ return {
1446
+ topic,
1447
+ summary: summary || null,
1448
+
1449
+ // ANCHORS: kept (small static list, workflow Phase 1 requires "read all anchors")
1450
+ anchors: anchors.map((a) => ({ basename: a.basename, why: a.why, found: a.found })),
1451
+
1452
+ // DISCOVERED: counts + basenames only (main token savings)
1453
+ discovered_counts: {
1454
+ epics: discovered.epics.length,
1455
+ tasks: discovered.tasks.length,
1456
+ protocols: discovered.protocols.length,
1457
+ memories: discovered.memories.length,
1458
+ research: discovered.research.length,
1459
+ adrs: discovered.adrs.length,
1460
+ captures: discovered.captures.length,
1461
+ milestones: discovered.milestones.length,
1462
+ diagnoses: discovered.diagnoses.length,
1463
+ total: Object.values(discovered).flat().length,
1464
+ },
1465
+
1466
+ // Wide-recall candidate cards (Recall@N) — the agent is the reranker.
1467
+ candidate_rerank: candidateRerank,
1468
+ candidate_cards: candidateCards,
1469
+
1470
+ // Top items as wikilink basenames (agent can Read these). Now the full
1471
+ // wide window, not a top-10 slice — the LLM does the narrowing.
1472
+ top_specs_to_read: candidateCards.map((c) => c.basename),
1473
+ open_work: openWork.slice(0, 6).map((e) => e.basename),
1474
+ protocols_to_heed: protocols.slice(0, 5).map((p) => p.basename),
1475
+ decisions_to_heed: decisionsToHeed.slice(0, 5).map((d) => d.basename),
1476
+ possible_duplicates: possibleDuplicates.slice(0, 4).map((d) => d.basename),
1477
+
1478
+ // NORTH-STAR/PILLARS: kept for Phase 2
1479
+ north_star_pillar_hooks: {
1480
+ pillar_ids: pillarIds,
1481
+ alignment_risks: alignmentRisks,
1482
+ north_star_sections: ['Read [[heyspex-north-star]] for L1 alignment'],
1483
+ },
1484
+
1485
+ // ARCHITECTURE PLACEMENT: kept for Phase 3
1486
+ architecture_placement: {
1487
+ surfaces,
1488
+ dependency_specs: dependencySpecs.slice(0, 6),
1489
+ manifest_phase_hint: architecturePlacement.manifest_phase_hint,
1490
+ },
1491
+
1492
+ // CONFLICTS: compact but actionable for Phase 1b
1493
+ conflict_analysis: compactConflicts,
1494
+
1495
+ // CODEBASE HINTS: kept for Phase 1 exploration
1496
+ codebase_hints: includeCodebaseHints
1497
+ ? {
1498
+ grep_queries: codebaseHints.grep_queries?.slice(0, 5) || [],
1499
+ path_globs: codebaseHints.path_globs?.slice(0, 4) || [],
1500
+ large_markdown_files: (codebaseHints.large_markdown_files || []).slice(0, 5),
1501
+ }
1502
+ : null,
1503
+
1504
+ // STRESS-TEST PROMPTS: kept for Phase 4 (static list, small)
1505
+ stress_test_prompts: STRESS_TEST_PROMPTS,
1506
+
1507
+ // ENHANCEMENT SUGGESTIONS: kept for Phase 5
1508
+ enhancement_suggestions: enhancementSuggestions.slice(0, 6),
1509
+
1510
+ // GAPS: kept for completeness
1511
+ gaps_and_questions: gapsAndQuestions.slice(0, 5),
1512
+
1513
+ // PLAN CONTEXT MARKDOWN: kept (essential for workflow)
1514
+ plan_context_section_markdown: planContextSectionMarkdown,
1515
+
1516
+ // OPTIMIZED OUTLINE: kept for Phase 6
1517
+ optimized_plan_outline: buildOptimizedOutline(topic),
1518
+
1519
+ // Hints for follow-up
1520
+ recall_memories_hint: {
1521
+ limit: 10,
1522
+ tags: recallTags.length ? recallTags : undefined,
1523
+ },
1524
+ next_steps: nextSteps,
1525
+ grounded_at: new Date().toISOString(),
1526
+ _compact: true,
1527
+ _note:
1528
+ 'Compact mode: discovered arrays reduced to counts+basenames. candidate_cards is the wide ' +
1529
+ 'lexical-recall window (top-30–50) for you to rerank — triage on cards, deep-read only your ' +
1530
+ 'shortlist. Set compact:false for full objects.',
1531
+ };
1532
+ }
1533
+
1534
+ // Full mode: original verbose response
1535
+ return {
1536
+ topic,
1537
+ summary: summary || null,
1538
+ anchors,
1539
+ landscape_summary: landscapeSummary,
1540
+ discovered,
1541
+ north_star_pillar_hooks: northStarPillarHooks,
1542
+ architecture_placement: architecturePlacement,
1543
+ candidate_rerank: candidateRerank,
1544
+ candidate_cards: candidateCards,
1545
+ suggested_related: suggestedRelated,
1546
+ codebase_hints: codebaseHints,
1547
+ stress_test_prompts: STRESS_TEST_PROMPTS,
1548
+ gaps_and_questions: gapsAndQuestions,
1549
+ enhancement_suggestions: enhancementSuggestions,
1550
+ conflict_analysis: conflictAnalysis,
1551
+ plan_context_section_markdown: planContextSectionMarkdown,
1552
+ optimized_plan_outline: buildOptimizedOutline(topic),
1553
+ recall_memories_hint: {
1554
+ limit: 10,
1555
+ tags: recallTags.length ? recallTags : undefined,
1556
+ },
1557
+ next_steps: nextSteps,
1558
+ grounded_at: new Date().toISOString(),
1559
+ _compact: false,
1560
+ };
1561
+ }
1562
+
1563
+ /**
1564
+ * `heyspex_resolve_focus_intent` lexical resolver — [[HS-TASK-JZEJ]] /
1565
+ * [[HS-EPIC-OOPW]] Phase B. Given a free-text focus utterance, run lexical
1566
+ * wide-recall over the vault (titles/bodies/tags) and return a WIDE candidate
1567
+ * window for the calling agent's LLM to rerank. HeySpex hosts no reranker — the
1568
+ * metric is Recall@N, not nDCG@10 ordering
1569
+ * ([[llm-as-reranker-lexical-recall-companion-architecture]]). No embeddings /
1570
+ * vector store (Gate B = NO,
1571
+ * [[retrieval-substrate-lexical-not-embeddings-gate-b-no]]); consumes the same
1572
+ * scoring substrate as hs-ground ([[HS-EPIC-G7R2-hs-ground-three-tier-grounding-report]]).
1573
+ *
1574
+ * The utterance need not contain a spec's exact tag word: utterance tokens match
1575
+ * tags via computeTagScore's substring pass and match titles/bodies via BM25, so
1576
+ * tag-relevant specs still surface — the core Phase-B behavior.
1577
+ *
1578
+ * Returns an empty window (NOT an error) for an empty/stopword-only utterance,
1579
+ * an empty vault, or no matches — the caller's documented fallback is
1580
+ * heyspex_list_known_tags + exact-tag NLU.
1581
+ *
1582
+ * Each candidate carries a `match_kind` — `strict-tag` when the user named one
1583
+ * of the spec's tags exactly, `lexical` when it only surfaced on title/body or a
1584
+ * substring tag hit. The top-level `justification` rolls those into the two
1585
+ * counts (plus a ready-to-read `beat`) the confirmation copy reads back
1586
+ * ([[HS-TASK-5DB2]] / focus-intent-routing §5–6).
1587
+ *
1588
+ * @param {string} vaultRoot - Root of the vault (contains specs/).
1589
+ * @param {{ utterance?: string, top_k?: number }} args
1590
+ * @returns {{ utterance: string, candidates: Array<{ spec: string, tags: string[], match_kind: 'strict-tag' | 'lexical', why: string }>, total: number, justification: { strict_tag_matches: number, lexically_adjacent: number, beat: string } }}
1591
+ */
1592
+ export function resolveFocusIntentLexical(vaultRoot, args = {}) {
1593
+ const utterance = String(args.utterance || '').trim();
1594
+ // Wide-recall window: default 30, capped at 50 — same Recall@N bounds as
1595
+ // executeGroundPlan's limit. top_k narrows the window when the caller asks.
1596
+ const topK = Math.min(Math.max(Number(args.top_k) || 30, 1), 50);
1597
+
1598
+ const tokens = [...new Set(tokenize(utterance))];
1599
+ if (tokens.length === 0) {
1600
+ return { utterance, candidates: [], total: 0, justification: buildFocusJustification(0, 0) };
1601
+ }
1602
+
1603
+ // Graceful degradation ([[HS-TASK-09I3]] / focus-intent-routing §6). The
1604
+ // lexical recall primitive can be unreachable in a constrained environment —
1605
+ // no vault index, an IO error while walking specs/, a corpus that fails to
1606
+ // load. When the index build or scoring throws, the resolver must NOT raise:
1607
+ // it degrades to an empty window flagged `degraded`, routing the caller to the
1608
+ // Phase-A exact-tag NLU floor (heyspex_list_known_tags + closed-vocab mapping)
1609
+ // instead of erroring out. An empty *result* (no matches, empty vault) is a
1610
+ // success and is handled by the normal path below — only a thrown primitive
1611
+ // trips degradation.
1612
+ let index;
1613
+ try {
1614
+ index = getOrBuildIndex(vaultRoot);
1615
+ } catch (err) {
1616
+ return buildDegradedFocusResult(utterance, err);
1617
+ }
1618
+
1619
+ // A candidate is a STRICT-TAG match when the user named one of its tags
1620
+ // exactly — every token of some tag is present in the utterance (so multi-word
1621
+ // tags like `focus-areas` match "focus areas"). Everything else that still
1622
+ // surfaced matched on title/body/substring only — "lexically adjacent". The
1623
+ // split feeds the confirmation copy ([[HS-TASK-5DB2]]).
1624
+ const utteranceSet = new Set(tokens);
1625
+ const isStrictTagHit = (specTags) =>
1626
+ (specTags || []).some((tag) => {
1627
+ const t = String(tag).toLowerCase();
1628
+ if (utteranceSet.has(t)) return true;
1629
+ const parts = tokenize(t);
1630
+ return parts.length > 0 && parts.every((p) => utteranceSet.has(p));
1631
+ });
1632
+
1633
+ let candidates;
1634
+ try {
1635
+ const scored = [];
1636
+ for (const spec of index.specs.values()) {
1637
+ // No explicit tags / areas from a free-text utterance — the utterance
1638
+ // tokens carry the tag matching (computeTagScore substring pass) and the
1639
+ // lexical hit.
1640
+ const result = scoreSpecWithIndex(tokens, [], [], spec, index.corpus);
1641
+ // Gate on the LEXICAL signal (BM25 + title + tag), not the full weighted
1642
+ // score: status/backlink/recency boosts are unconditional, so the full
1643
+ // score is > 0 for every open spec even with no term match. A candidate
1644
+ // must actually match the utterance; the boosts only tie-break among
1645
+ // matches.
1646
+ const lexical = result.bm25 + result.titleBoost + result.tagScore;
1647
+ if (lexical <= 0) continue;
1648
+ scored.push({ spec, result });
1649
+ }
1650
+
1651
+ // Deterministic order: score desc, basename asc as a stable tiebreak.
1652
+ scored.sort(
1653
+ (a, b) => b.result.score - a.result.score || a.spec.basename.localeCompare(b.spec.basename),
1654
+ );
1655
+
1656
+ candidates = scored.slice(0, topK).map(({ spec, result }) => ({
1657
+ spec: spec.id,
1658
+ tags: spec.tags,
1659
+ match_kind: isStrictTagHit(spec.tags) ? 'strict-tag' : 'lexical',
1660
+ why: result.reasons.slice(0, 4).join(', ') || 'low relevance',
1661
+ }));
1662
+ } catch (err) {
1663
+ return buildDegradedFocusResult(utterance, err);
1664
+ }
1665
+
1666
+ const strict = candidates.filter((c) => c.match_kind === 'strict-tag').length;
1667
+ const adjacent = candidates.length - strict;
1668
+
1669
+ return {
1670
+ utterance,
1671
+ candidates,
1672
+ total: candidates.length,
1673
+ justification: buildFocusJustification(strict, adjacent),
1674
+ };
1675
+ }
1676
+
1677
+ /**
1678
+ * The graceful-degradation marker the focus resolver returns when its lexical
1679
+ * recall primitive is unreachable or throws ([[HS-TASK-09I3]]). The shape is a
1680
+ * SUPERSET of the success shape — a caller that ignores `degraded` still reads a
1681
+ * valid empty window (`candidates: []`, `total: 0`) — plus a typed `degraded`
1682
+ * flag and a `fallback` block routing to the Phase-A exact-tag NLU floor
1683
+ * (`heyspex_list_known_tags` + closed-vocab mapping, focus-intent-routing §6).
1684
+ * Never thrown; always returned.
1685
+ *
1686
+ * @param {string} utterance - The original (trimmed) utterance.
1687
+ * @param {unknown} reason - The caught error or a reason string.
1688
+ * @returns {{ utterance: string, candidates: [], total: 0, degraded: true, fallback: { mode: 'exact-tag-nlu', tool: 'heyspex_list_known_tags', reason: string }, justification: { strict_tag_matches: number, lexically_adjacent: number, beat: string } }}
1689
+ */
1690
+ export function buildDegradedFocusResult(utterance, reason) {
1691
+ const message =
1692
+ reason && typeof reason === 'object' && 'message' in reason
1693
+ ? String(reason.message)
1694
+ : String(reason || 'lexical resolver unavailable');
1695
+ return {
1696
+ utterance: String(utterance || '').trim(),
1697
+ candidates: [],
1698
+ total: 0,
1699
+ degraded: true,
1700
+ fallback: {
1701
+ mode: 'exact-tag-nlu',
1702
+ tool: 'heyspex_list_known_tags',
1703
+ reason: message,
1704
+ },
1705
+ justification: buildFocusJustification(0, 0),
1706
+ };
1707
+ }
1708
+
1709
+ /**
1710
+ * Build the strict-tag vs lexically-adjacent split for the focus-intent
1711
+ * confirmation copy ([[HS-TASK-5DB2]]). `beat` is a ready-to-read clause the
1712
+ * calling agent drops into the §5 confirmation beat of focus-intent-routing,
1713
+ * e.g. "3 strict-tag-match specs plus 5 lexically-adjacent specs".
1714
+ */
1715
+ function buildFocusJustification(strictTagMatches, lexicallyAdjacent) {
1716
+ const noun = (n) => (n === 1 ? 'spec' : 'specs');
1717
+ const beat =
1718
+ strictTagMatches === 0 && lexicallyAdjacent === 0
1719
+ ? 'no matching specs'
1720
+ : `${strictTagMatches} strict-tag-match ${noun(strictTagMatches)} plus ` +
1721
+ `${lexicallyAdjacent} lexically-adjacent ${noun(lexicallyAdjacent)}`;
1722
+ return { strict_tag_matches: strictTagMatches, lexically_adjacent: lexicallyAdjacent, beat };
1723
+ }
1724
+
1725
+ // ─────────────────────────────────────────────────────────────────────────────
1726
+ // Tag-drift detection — [[HS-TASK-H0SI]] / [[HS-EPIC-OOPW]].
1727
+ // Flag near-duplicate tag pairs in the closed vocabulary (e.g. `chrome-ext` vs
1728
+ // `chrome-extension`) so the calling agent — or eventually the console — can
1729
+ // propose a merge and the tag set stays clean. Pure LEXICAL string-similarity
1730
+ // only (shared stem, edit distance, token overlap); explicitly NOT embedding
1731
+ // similarity ([[retrieval-substrate-lexical-not-embeddings-gate-b-no]]). The
1732
+ // threshold favors precision over recall: genuinely distinct tags (`mcp` vs
1733
+ // `cli`) must not be flagged, even at the cost of missing a subtle pair.
1734
+ // ─────────────────────────────────────────────────────────────────────────────
1735
+
1736
+ /** Levenshtein edit distance between two strings (iterative, two-row). */
1737
+ function levenshtein(a, b) {
1738
+ if (a === b) return 0;
1739
+ if (!a.length) return b.length;
1740
+ if (!b.length) return a.length;
1741
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1742
+ let curr = new Array(b.length + 1);
1743
+ for (let i = 1; i <= a.length; i++) {
1744
+ curr[0] = i;
1745
+ for (let j = 1; j <= b.length; j++) {
1746
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1747
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
1748
+ }
1749
+ [prev, curr] = [curr, prev];
1750
+ }
1751
+ return prev[b.length];
1752
+ }
1753
+
1754
+ /** Length of the shared leading prefix of two strings. */
1755
+ function commonPrefixLen(a, b) {
1756
+ const max = Math.min(a.length, b.length);
1757
+ let i = 0;
1758
+ while (i < max && a[i] === b[i]) i++;
1759
+ return i;
1760
+ }
1761
+
1762
+ /**
1763
+ * Score one ordered tag pair for drift. Returns the strongest matching signal as
1764
+ * `{ similarity, reason }`, or `null` when the pair looks like two distinct tags.
1765
+ * Each rule has its own conservative internal gate; we take the highest-scoring
1766
+ * rule that fires. `minSimilarity` is the flag threshold for the edit-distance /
1767
+ * token-overlap rules (the categorical rules always clear it).
1768
+ */
1769
+ function scoreTagPair(a, b, minSimilarity) {
1770
+ if (a === b) return null;
1771
+ const bare = (s) => s.replace(/[-_]/g, '');
1772
+ const ba = bare(a);
1773
+ const bb = bare(b);
1774
+ const candidates = [];
1775
+
1776
+ // Rule A — same letters, different punctuation. `multi-agent` vs `multiagent`,
1777
+ // `chrome-ext` vs `chrome_ext`. Strongest signal: identical once hyphens and
1778
+ // underscores are removed.
1779
+ if (ba === bb && ba.length > 0) {
1780
+ candidates.push({ similarity: 1, reason: 'same letters, different hyphenation' });
1781
+ }
1782
+
1783
+ // Rule B — plural/singular variant on the de-punctuated form. `task`/`tasks`,
1784
+ // `area`/`areas`. Require the shorter base to be ≥ 3 chars so 1–2 char tags
1785
+ // don't collide.
1786
+ const [shortBare, longBare] = ba.length <= bb.length ? [ba, bb] : [bb, ba];
1787
+ if (
1788
+ shortBare.length >= 3 &&
1789
+ (longBare === `${shortBare}s` || longBare === `${shortBare}es`)
1790
+ ) {
1791
+ candidates.push({ similarity: 0.95, reason: 'plural/singular variant' });
1792
+ }
1793
+
1794
+ // Rule C — abbreviated form: same hyphen-separated tokens except exactly one
1795
+ // position where one token is a prefix of the other (≥ 3 chars). Reuses
1796
+ // tokenize for token-overlap scoring. `chrome-ext` vs `chrome-extension`.
1797
+ const tokA = tokenize(a);
1798
+ const tokB = tokenize(b);
1799
+ if (tokA.length > 0 && tokA.length === tokB.length) {
1800
+ let mismatches = 0;
1801
+ let prefixMatch = false;
1802
+ for (let i = 0; i < tokA.length; i++) {
1803
+ if (tokA[i] === tokB[i]) continue;
1804
+ mismatches++;
1805
+ const [shortTok, longTok] =
1806
+ tokA[i].length <= tokB[i].length ? [tokA[i], tokB[i]] : [tokB[i], tokA[i]];
1807
+ if (shortTok.length >= 3 && longTok.startsWith(shortTok)) prefixMatch = true;
1808
+ else { prefixMatch = false; break; }
1809
+ }
1810
+ if (mismatches === 1 && prefixMatch) {
1811
+ candidates.push({ similarity: 0.9, reason: 'abbreviated form of the same tag' });
1812
+ }
1813
+ }
1814
+
1815
+ // Rule D — shared stem / near-identical spelling via edit distance. Gated on a
1816
+ // long shared prefix and a small edit distance relative to length so distinct
1817
+ // short tags (`mcp` vs `cli`) never qualify. `schedule` vs `scheduler`.
1818
+ const longLen = Math.max(ba.length, bb.length);
1819
+ const shortLen = Math.min(ba.length, bb.length);
1820
+ if (longLen >= 5) {
1821
+ const dist = levenshtein(ba, bb);
1822
+ const prefix = commonPrefixLen(ba, bb);
1823
+ const sim = 1 - dist / longLen;
1824
+ if (dist <= 2 && prefix >= Math.ceil(0.6 * shortLen) && sim >= minSimilarity) {
1825
+ candidates.push({ similarity: Number(sim.toFixed(2)), reason: `shared stem (edit distance ${dist})` });
1826
+ }
1827
+ }
1828
+
1829
+ // Rule E — strong multi-word overlap. Jaccard over the tokenized tags catches
1830
+ // reordered/extra-word near-dupes the position-sensitive Rule C misses. Gated
1831
+ // high (≥ minSimilarity, ≥ 2 tokens each) to stay precise.
1832
+ if (tokA.length >= 2 && tokB.length >= 2) {
1833
+ const setA = new Set(tokA);
1834
+ const setB = new Set(tokB);
1835
+ let shared = 0;
1836
+ for (const t of setA) if (setB.has(t)) shared++;
1837
+ const union = setA.size + setB.size - shared;
1838
+ const jaccard = union > 0 ? shared / union : 0;
1839
+ if (jaccard >= minSimilarity && jaccard < 1) {
1840
+ candidates.push({ similarity: Number(jaccard.toFixed(2)), reason: 'overlapping words' });
1841
+ }
1842
+ }
1843
+
1844
+ if (candidates.length === 0) return null;
1845
+ return candidates.reduce((best, c) => (c.similarity > best.similarity ? c : best));
1846
+ }
1847
+
1848
+ /**
1849
+ * Detect near-duplicate tag pairs in a vocabulary. `tags` may be an array of tag
1850
+ * strings or of `{ name, count }` objects (the shape `heyspex_list_known_tags`
1851
+ * returns). Returns drift pairs `{ a, b, reason, similarity }` sorted by
1852
+ * similarity desc, then alphabetically — deterministic, no network, no
1853
+ * embeddings. `minSimilarity` (default 0.8) is the precision/recall knob.
1854
+ */
1855
+ function detectTagDrift(tags, options = {}) {
1856
+ const minSimilarity = typeof options.minSimilarity === 'number' ? options.minSimilarity : 0.8;
1857
+ const names = [
1858
+ ...new Set(
1859
+ (Array.isArray(tags) ? tags : [])
1860
+ .map((t) => (typeof t === 'string' ? t : t && t.name))
1861
+ .filter((t) => typeof t === 'string' && t.trim().length > 0)
1862
+ .map((t) => t.trim().toLowerCase()),
1863
+ ),
1864
+ ].sort();
1865
+
1866
+ const drift = [];
1867
+ for (let i = 0; i < names.length; i++) {
1868
+ for (let j = i + 1; j < names.length; j++) {
1869
+ const hit = scoreTagPair(names[i], names[j], minSimilarity);
1870
+ if (hit) drift.push({ a: names[i], b: names[j], reason: hit.reason, similarity: hit.similarity });
1871
+ }
1872
+ }
1873
+
1874
+ drift.sort((x, y) => y.similarity - x.similarity || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
1875
+ return drift;
1876
+ }
1877
+
1878
+ // Export scoring functions for unit testing (HS-TASK-9HIW)
1879
+ export {
1880
+ computeBM25,
1881
+ computeTitleBoost,
1882
+ computeBacklinkScore,
1883
+ computeRecencyScore,
1884
+ computeStatusBoost,
1885
+ computeTagScore,
1886
+ computeAreaScore,
1887
+ scoreSpecWithIndex,
1888
+ tokenize,
1889
+ detectTagDrift,
1890
+ extractWikilinks,
1891
+ normalizeWikilinkTarget,
1892
+ parseIsoDate,
1893
+ SCORE_WEIGHTS,
1894
+ BM25_K1,
1895
+ BM25_B,
1896
+ STOPWORDS,
1897
+ AREA_BOOSTS,
1898
+ };
1899
+
1900
+ export function buildGroundWorkflowMarkdown(options = {}) {
1901
+ const lines = [
1902
+ '# HeySpex hs-ground — holistic pre-plan grounding',
1903
+ '',
1904
+ '> Holistic scan, north-star alignment, architecture placement, plan stress-test, then enhance and optimize before push.',
1905
+ '',
1906
+ '**Plan-quality optimization only — not code performance tuning.**',
1907
+ '',
1908
+ '---',
1909
+ '',
1910
+ '## Six mandatory phases (complete all)',
1911
+ '',
1912
+ '### Phase 1 — Holistic scan of the landscape',
1913
+ '',
1914
+ '1. Call MCP tool **`heyspex_ground_plan`** with `topic`, `summary`, `tags`, `areas`.',
1915
+ '2. Call **`heyspex_recall_memories`** (`limit: 10`, tags from tool `recall_memories_hint`).',
1916
+ '3. Read **all anchors** + top 5–8 discovered specs; any `.md` ≥500 lines in `large_markdown_files` → read fully.',
1917
+ '4. Explore codebase via `codebase_hints` (`rg`, Glob) — not README-only ([[audit-read-plan-files-not-just-readmes]]).',
1918
+ '5. **Deliverable:** `## Landscape scan` — table of specs/code with relevance; duplicates and surprises.',
1919
+ '',
1920
+ '### Phase 1b — Conflict gate (required when `conflict_analysis.conflict_count` > 0)',
1921
+ '',
1922
+ 'Uses the **same detector** as Console On Deck (`GET /api/specs/:id/conflicts`) and `/hs-resolve`.',
1923
+ '',
1924
+ '1. Review `conflict_analysis.conflicts` — sources: `focused_epic`, `draft_plan`, `cross_spec_drift`.',
1925
+ '2. Apply `source_of_truth_rules` — default **protocol** for behavior; **north-star** for vision; epic only when superseding.',
1926
+ '3. **Do not** treat `conflicted_spec_basenames` as authoritative context until resolved.',
1927
+ '4. Run **`/hs-resolve`** (or Console On Deck → Spec Conflicts) for focused-epic conflicts.',
1928
+ '5. Re-run **`heyspex_ground_plan`** after fixes; confirm `has_blocking_conflicts` is false.',
1929
+ '6. **Deliverable:** `## Conflict resolution` — conflict → chosen source of truth → action taken.',
1930
+ '',
1931
+ 'If `conflict_count` is 0, note “no conflicts” and proceed.',
1932
+ '',
1933
+ '### Phase 2 — Tie work to north-star / pillars',
1934
+ '',
1935
+ '1. Quote specific **north-star** goals/themes served or conflicted.',
1936
+ '2. Name **architecture pillar(s)** and milestones (M2–M5) from `north_star_pillar_hooks`.',
1937
+ '3. State **in-scope vs horizon** — defer future-considerations explicitly.',
1938
+ '4. **Deliverable:** `## North-star and pillar alignment` — pass/fail + risks.',
1939
+ '',
1940
+ '### Phase 3 — Place in the architecture',
1941
+ '',
1942
+ '1. **System map** — surfaces, packages, integration points.',
1943
+ '2. **Spec graph** — parent epic candidate, siblings, governing protocols, ADRs.',
1944
+ '3. **Roadmap placement** — manifest phase ([[HS-ROADMAP-product-master]]).',
1945
+ '4. **Deliverable:** `## Architecture placement`.',
1946
+ '',
1947
+ '### Phase 4 — Stress-test the plan',
1948
+ '',
1949
+ 'Work through every `stress_test_prompts` item from the tool output:',
1950
+ '',
1951
+ '- “Is this the best you can do?” — stronger v2 vs cuts',
1952
+ '- Gaps: acceptance criteria, edge cases, non-goals, tests, ops docs',
1953
+ '- Conflicts vs protocols; duplicates; memory constraints',
1954
+ '- Confirm `conflict_analysis` is clear (Phase 1b complete if conflicts existed)',
1955
+ '',
1956
+ '**Deliverable:** `## Stress-test` — findings by severity (blocker / should-fix / nice-to-have).',
1957
+ '',
1958
+ '### Phase 5 — Enhance the plan',
1959
+ '',
1960
+ 'Produce revised goal, scope, phases, child tasks, acceptance criteria, final `related:` wikilinks, code touch map.',
1961
+ 'Merge `enhancement_suggestions` from the tool.',
1962
+ '',
1963
+ '**Deliverable:** `## Enhanced plan` — markdown ready for `/hs-push`.',
1964
+ '',
1965
+ '### Phase 6 — Optimize as a result of analysis',
1966
+ '',
1967
+ 'Tighten scope, reorder phases, sharpen `/hs-push-next` handoff. **Not** code perf.',
1968
+ '',
1969
+ '**Deliverable:** `## Optimized plan` — diff from pre-ground draft + `optimized_plan_outline` from tool.',
1970
+ '',
1971
+ '---',
1972
+ '',
1973
+ '## Output modes',
1974
+ '',
1975
+ '| Mode | Action |',
1976
+ '|------|--------|',
1977
+ '| Chat | Deliver Phases 1–6 in chat; no vault writes |',
1978
+ '| Plan section | Paste `plan_context_section_markdown` + optimized plan into epic body |',
1979
+ '| Research | If `save_research: true` or >8 novel findings → `heyspex_save_research` |',
1980
+ '',
1981
+ '**Handoff:** Grounding complete. Run `/hs-push` or `/hs-push-next` with the optimized plan and `related:` from Phase 6.',
1982
+ '',
1983
+ '## Linkage (after push)',
1984
+ '',
1985
+ '- `npm run validate:vault-ids`',
1986
+ '- `heyspex_validate_linkage` + `heyspex_register_spec` when available',
1987
+ '',
1988
+ ];
1989
+
1990
+ if (options.topic) {
1991
+ lines.push('## Topic', '', options.topic, '');
1992
+ }
1993
+ if (options.summary) {
1994
+ lines.push('## Summary', '', options.summary, '');
1995
+ }
1996
+ if (options.save_research) {
1997
+ lines.push('## save_research', '', 'true — offer heyspex_save_research after landscape scan.', '');
1998
+ }
1999
+
2000
+ return lines.join('\n');
2001
+ }