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