mustflow 2.23.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +12 -2
  2. package/dist/cli/commands/adapters.js +11 -9
  3. package/dist/cli/commands/api.js +263 -113
  4. package/dist/cli/commands/check.js +11 -7
  5. package/dist/cli/commands/classify.js +16 -42
  6. package/dist/cli/commands/context.js +18 -31
  7. package/dist/cli/commands/contract-lint.js +12 -7
  8. package/dist/cli/commands/dashboard.js +65 -114
  9. package/dist/cli/commands/docs.js +43 -26
  10. package/dist/cli/commands/doctor.js +11 -7
  11. package/dist/cli/commands/evidence.js +642 -0
  12. package/dist/cli/commands/explain-verify.js +1 -59
  13. package/dist/cli/commands/explain.js +84 -36
  14. package/dist/cli/commands/handoff.js +13 -17
  15. package/dist/cli/commands/impact.js +14 -20
  16. package/dist/cli/commands/index.js +15 -9
  17. package/dist/cli/commands/init.js +56 -70
  18. package/dist/cli/commands/line-endings.js +15 -9
  19. package/dist/cli/commands/map.js +30 -42
  20. package/dist/cli/commands/next.js +300 -0
  21. package/dist/cli/commands/onboard.js +136 -0
  22. package/dist/cli/commands/run.js +47 -42
  23. package/dist/cli/commands/search.js +43 -69
  24. package/dist/cli/commands/status.js +9 -6
  25. package/dist/cli/commands/update.js +16 -10
  26. package/dist/cli/commands/upgrade.js +9 -6
  27. package/dist/cli/commands/verify/args.js +55 -249
  28. package/dist/cli/commands/verify.js +2 -1
  29. package/dist/cli/commands/version-sources.js +9 -6
  30. package/dist/cli/commands/version.js +9 -6
  31. package/dist/cli/commands/workspace.js +564 -0
  32. package/dist/cli/i18n/en.js +60 -1
  33. package/dist/cli/i18n/es.js +60 -1
  34. package/dist/cli/i18n/fr.js +60 -1
  35. package/dist/cli/i18n/hi.js +60 -1
  36. package/dist/cli/i18n/ko.js +60 -1
  37. package/dist/cli/i18n/zh.js +60 -1
  38. package/dist/cli/index.js +28 -25
  39. package/dist/cli/lib/agent-context.js +8 -9
  40. package/dist/cli/lib/command-registry.js +24 -0
  41. package/dist/cli/lib/dashboard-html/client-script.js +1 -1
  42. package/dist/cli/lib/local-index/database-path.js +5 -0
  43. package/dist/cli/lib/local-index/database-read.js +88 -0
  44. package/dist/cli/lib/local-index/effect-graph-read-model.js +112 -0
  45. package/dist/cli/lib/local-index/freshness.js +60 -0
  46. package/dist/cli/lib/local-index/index.js +12 -1866
  47. package/dist/cli/lib/local-index/path-surface-read-model.js +134 -0
  48. package/dist/cli/lib/local-index/populate.js +474 -0
  49. package/dist/cli/lib/local-index/schema.js +413 -0
  50. package/dist/cli/lib/local-index/search-read-model.js +533 -0
  51. package/dist/cli/lib/local-index/search-text.js +79 -0
  52. package/dist/cli/lib/option-parser.js +93 -0
  53. package/dist/cli/lib/repo-map.js +2 -2
  54. package/dist/cli/lib/run-plan.js +5 -22
  55. package/dist/core/change-verification.js +11 -5
  56. package/dist/core/command-effects.js +1 -3
  57. package/dist/core/command-intent-eligibility.js +14 -0
  58. package/dist/core/command-preconditions.js +8 -4
  59. package/dist/core/command-run-constraints.js +43 -0
  60. package/dist/core/public-json-contracts.js +57 -0
  61. package/dist/core/test-selection.js +8 -2
  62. package/dist/core/verification-plan.js +32 -4
  63. package/package.json +1 -1
  64. package/schemas/README.md +16 -0
  65. package/schemas/api-serve-response.schema.json +89 -0
  66. package/schemas/change-verification-report.schema.json +4 -1
  67. package/schemas/contract-lint-report.schema.json +1 -0
  68. package/schemas/evidence-report.schema.json +287 -0
  69. package/schemas/explain-report.schema.json +4 -0
  70. package/schemas/next-report.schema.json +121 -0
  71. package/schemas/onboard-commands-report.schema.json +100 -0
  72. package/schemas/workspace-command-catalog.schema.json +172 -0
  73. package/schemas/workspace-status.schema.json +141 -0
  74. package/schemas/workspace-verification-plan.schema.json +195 -0
  75. package/templates/default/i18n.toml +1 -1
  76. package/templates/default/locales/en/.mustflow/skills/INDEX.md +2 -1
  77. package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +183 -0
  78. package/templates/default/locales/en/.mustflow/skills/routes.toml +7 -1
  79. package/templates/default/locales/en/.mustflow/skills/structure-discovery-gate/SKILL.md +63 -20
  80. package/templates/default/manifest.toml +8 -1
@@ -0,0 +1,533 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isRecord, readStringArray } from '../command-contract.js';
4
+ import { toPosixPath } from '../filesystem.js';
5
+ import { DEFAULT_PROMPT_CACHE_STABLE_READ, DEFAULT_PROMPT_CACHE_TASK_SOURCES, DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES, LOCAL_INDEX_SCHEMA_VERSION, SEARCH_BACKEND_FTS5, SEARCH_BACKEND_TABLE_SCAN, } from './constants.js';
6
+ import { getLocalIndexDatabasePath } from './database-path.js';
7
+ import { hasTable, queryRows, readStoredSearchCapabilities, searchCapabilities, sqlPlaceholders, toSearchString, } from './database-read.js';
8
+ import { getStalePaths, isLocalIndexRuntimeUnavailableError, isLocalIndexStaleError, } from './freshness.js';
9
+ import { buildFtsQuery, buildSearchNgrams, getMatchSnippet, isMatched, normalizeSearchText, scoreMatch, } from './search-text.js';
10
+ import { loadSqlJs } from './sql.js';
11
+ import { readMustflowToml } from './source-index.js';
12
+ import { collectDocumentsFromPaths, collectSkills, getExistingIndexablePaths, readText, skillRouteKey, splitVerificationIntents, } from './workflow-documents.js';
13
+ const DIRECT_SEARCH_MAX_WORKFLOW_FILES = 200;
14
+ function readNestedTable(table, key) {
15
+ if (!table || !isRecord(table[key])) {
16
+ return undefined;
17
+ }
18
+ return table[key];
19
+ }
20
+ function readOptionalStringArray(table, key) {
21
+ return table ? readStringArray(table, key) ?? null : null;
22
+ }
23
+ function readCacheLayerSets(projectRoot) {
24
+ const mustflow = readMustflowToml(projectRoot);
25
+ const promptCache = readNestedTable(mustflow, 'prompt_cache');
26
+ const layers = readNestedTable(promptCache, 'layers');
27
+ const stable = readNestedTable(layers, 'stable');
28
+ const task = readNestedTable(layers, 'task');
29
+ const volatile = readNestedTable(layers, 'volatile');
30
+ const normalize = (values) => new Set(values.map((value) => toPosixPath(value)));
31
+ return {
32
+ stable: normalize(readOptionalStringArray(stable, 'read') ?? [...DEFAULT_PROMPT_CACHE_STABLE_READ]),
33
+ task: normalize(readOptionalStringArray(task, 'sources') ?? [...DEFAULT_PROMPT_CACHE_TASK_SOURCES]),
34
+ volatile: normalize(readOptionalStringArray(volatile, 'sources') ?? [...DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES]),
35
+ };
36
+ }
37
+ function inferCacheLayer(relativePath, kind, cacheLayers) {
38
+ const normalized = relativePath ? toPosixPath(relativePath) : null;
39
+ if (normalized && cacheLayers.volatile.has(normalized)) {
40
+ return 'volatile';
41
+ }
42
+ if (normalized && cacheLayers.stable.has(normalized)) {
43
+ return 'stable';
44
+ }
45
+ if (kind === 'command_intent' && cacheLayers.stable.has('.mustflow/config/commands.toml')) {
46
+ return 'stable';
47
+ }
48
+ if (normalized &&
49
+ (cacheLayers.task.has(normalized) || normalized.startsWith('.mustflow/context/') || normalized.endsWith('/SKILL.md'))) {
50
+ return 'task';
51
+ }
52
+ if (normalized?.startsWith('.mustflow/state/') || normalized?.startsWith('.mustflow/cache/')) {
53
+ return 'volatile';
54
+ }
55
+ return 'task';
56
+ }
57
+ function withCacheHint(item, cacheLayers) {
58
+ const layer = inferCacheLayer(item.path ?? null, item.kind, cacheLayers);
59
+ return {
60
+ ...item,
61
+ cache_layer: layer,
62
+ volatile: layer === 'volatile',
63
+ };
64
+ }
65
+ function workflowAuthorityForDocument(documentType) {
66
+ if (documentType === 'agent_rules' || documentType === 'config' || documentType === 'workflow_doc') {
67
+ return {
68
+ authority_rank: 2,
69
+ authority_label: 'workflow_authority',
70
+ source_scope: 'workflow',
71
+ navigation_only: false,
72
+ can_instruct_agent: true,
73
+ };
74
+ }
75
+ return {
76
+ authority_rank: 4,
77
+ authority_label: 'workflow_context',
78
+ source_scope: 'workflow',
79
+ navigation_only: false,
80
+ can_instruct_agent: false,
81
+ };
82
+ }
83
+ function skillAuthority() {
84
+ return {
85
+ authority_rank: 3,
86
+ authority_label: 'skill_procedure',
87
+ source_scope: 'workflow',
88
+ navigation_only: false,
89
+ can_instruct_agent: true,
90
+ };
91
+ }
92
+ function commandIntentAuthority() {
93
+ return {
94
+ authority_rank: 1,
95
+ authority_label: 'command_contract',
96
+ source_scope: 'workflow',
97
+ navigation_only: false,
98
+ can_instruct_agent: true,
99
+ };
100
+ }
101
+ function sourceAnchorAuthority() {
102
+ return {
103
+ authority_rank: 5,
104
+ authority_label: 'source_navigation_hint',
105
+ source_scope: 'source',
106
+ navigation_only: true,
107
+ can_instruct_agent: false,
108
+ };
109
+ }
110
+ function getSectionHeadings(database, documentPath) {
111
+ return queryRows(database, 'SELECT heading FROM sections WHERE document_path = ? ORDER BY ordinal', [documentPath]).map((row) => toSearchString(row.heading));
112
+ }
113
+ function getDocumentTerms(database, documentPath) {
114
+ return queryRows(database, 'SELECT term FROM document_terms WHERE document_path = ? ORDER BY term', [documentPath]).map((row) => toSearchString(row.term));
115
+ }
116
+ function commandEffectFromRow(row) {
117
+ return {
118
+ intent: toSearchString(row.intent),
119
+ source: toSearchString(row.source),
120
+ access: toSearchString(row.access),
121
+ mode: toSearchString(row.mode),
122
+ path: row.path === null || row.path === undefined ? null : toSearchString(row.path),
123
+ lock: toSearchString(row.lock),
124
+ concurrency: toSearchString(row.concurrency),
125
+ };
126
+ }
127
+ function queryCandidateRows(database, sql, keyColumn, candidates, indexedMatches) {
128
+ if (!indexedMatches.active || candidates.size === 0) {
129
+ return queryRows(database, sql);
130
+ }
131
+ const keys = [...candidates].sort((left, right) => left.localeCompare(right));
132
+ return queryRows(database, `${sql} WHERE ${keyColumn} IN (${sqlPlaceholders(keys)})`, keys);
133
+ }
134
+ function getCommandEffectsByIntent(database, intents) {
135
+ const uniqueIntents = [...new Set(intents)].sort((left, right) => left.localeCompare(right));
136
+ const effectsByIntent = new Map(uniqueIntents.map((intent) => [intent, []]));
137
+ if (uniqueIntents.length === 0) {
138
+ return effectsByIntent;
139
+ }
140
+ for (const row of queryRows(database, `SELECT intent, source, access, mode, path, lock, concurrency
141
+ FROM command_effects
142
+ WHERE intent IN (${sqlPlaceholders(uniqueIntents)})
143
+ ORDER BY intent, lock, path, mode`, uniqueIntents)) {
144
+ const effect = commandEffectFromRow(row);
145
+ const effects = effectsByIntent.get(effect.intent);
146
+ if (effects) {
147
+ effects.push(effect);
148
+ }
149
+ }
150
+ return effectsByIntent;
151
+ }
152
+ const EMPTY_INDEXED_SEARCH_MATCHES = {
153
+ active: false,
154
+ documents: new Set(),
155
+ skills: new Set(),
156
+ skillRoutes: new Set(),
157
+ commandIntents: new Set(),
158
+ sourceAnchors: new Set(),
159
+ };
160
+ function queryFtsSet(database, sql, ftsQuery, column) {
161
+ return new Set(queryRows(database, sql, [ftsQuery]).map((row) => toSearchString(row[column])));
162
+ }
163
+ function mergeSearchSets(left, right) {
164
+ return new Set([...left, ...right]);
165
+ }
166
+ function mergeIndexedSearchMatches(left, right) {
167
+ return {
168
+ active: left.active || right.active,
169
+ documents: mergeSearchSets(left.documents, right.documents),
170
+ skills: mergeSearchSets(left.skills, right.skills),
171
+ skillRoutes: mergeSearchSets(left.skillRoutes, right.skillRoutes),
172
+ commandIntents: mergeSearchSets(left.commandIntents, right.commandIntents),
173
+ sourceAnchors: mergeSearchSets(left.sourceAnchors, right.sourceAnchors),
174
+ };
175
+ }
176
+ function queryNgramSet(database, targetKind, grams) {
177
+ const placeholders = grams.map(() => '?').join(', ');
178
+ if (!placeholders) {
179
+ return new Set();
180
+ }
181
+ return new Set(queryRows(database, `SELECT target_key
182
+ FROM search_ngrams
183
+ WHERE target_kind = ? AND gram IN (${placeholders})
184
+ GROUP BY target_key
185
+ HAVING COUNT(DISTINCT gram) = ?`, [targetKind, ...grams, grams.length]).map((row) => toSearchString(row.target_key)));
186
+ }
187
+ function getNgramSearchMatches(database, query) {
188
+ if (!hasTable(database, 'search_ngrams')) {
189
+ return EMPTY_INDEXED_SEARCH_MATCHES;
190
+ }
191
+ const grams = buildSearchNgrams([query]);
192
+ if (grams.length === 0) {
193
+ return EMPTY_INDEXED_SEARCH_MATCHES;
194
+ }
195
+ return {
196
+ active: true,
197
+ documents: queryNgramSet(database, 'document', grams),
198
+ skills: queryNgramSet(database, 'skill', grams),
199
+ skillRoutes: queryNgramSet(database, 'skill_route', grams),
200
+ commandIntents: queryNgramSet(database, 'command_intent', grams),
201
+ sourceAnchors: queryNgramSet(database, 'source_anchor', grams),
202
+ };
203
+ }
204
+ function getIndexedSearchMatches(database, query) {
205
+ const capabilities = readStoredSearchCapabilities(database);
206
+ const ftsQuery = capabilities.backend === SEARCH_BACKEND_FTS5 ? buildFtsQuery(query) : null;
207
+ const ngramMatches = getNgramSearchMatches(database, query);
208
+ if (!ftsQuery) {
209
+ return ngramMatches;
210
+ }
211
+ try {
212
+ const ftsMatches = {
213
+ active: true,
214
+ documents: queryFtsSet(database, 'SELECT path FROM search_documents_fts WHERE search_documents_fts MATCH ?', ftsQuery, 'path'),
215
+ skills: queryFtsSet(database, 'SELECT name FROM search_skills_fts WHERE search_skills_fts MATCH ?', ftsQuery, 'name'),
216
+ skillRoutes: queryFtsSet(database, 'SELECT route_key FROM search_skill_routes_fts WHERE search_skill_routes_fts MATCH ?', ftsQuery, 'route_key'),
217
+ commandIntents: queryFtsSet(database, 'SELECT name FROM search_command_intents_fts WHERE search_command_intents_fts MATCH ?', ftsQuery, 'name'),
218
+ sourceAnchors: queryFtsSet(database, 'SELECT id FROM search_source_anchors_fts WHERE search_source_anchors_fts MATCH ?', ftsQuery, 'id'),
219
+ };
220
+ return mergeIndexedSearchMatches(ftsMatches, ngramMatches);
221
+ }
222
+ catch {
223
+ return ngramMatches;
224
+ }
225
+ }
226
+ function matchesIndexedOrTableScan(fields, query, indexedMatches, matchSet, key) {
227
+ return indexedMatches.active && matchSet.size > 0 ? matchSet.has(key) : isMatched(fields, query);
228
+ }
229
+ function scoreIndexedOrTableScan(primaryFields, secondaryFields, query, indexedMatches, matchSet, key) {
230
+ const tableScore = scoreMatch(primaryFields, secondaryFields, query);
231
+ return indexedMatches.active && matchSet.size > 0 && matchSet.has(key) ? Math.max(tableScore, 20) : tableScore;
232
+ }
233
+ function sortLocalSearchResults(results, scope, limit) {
234
+ const sorted = [...results].sort((left, right) => {
235
+ if (scope === 'all' && left.authority_rank !== right.authority_rank) {
236
+ return left.authority_rank - right.authority_rank;
237
+ }
238
+ return right.score - left.score || (left.path ?? left.name ?? '').localeCompare(right.path ?? right.name ?? '');
239
+ });
240
+ const limited = sorted.slice(0, limit);
241
+ if (scope !== 'all' || limited.some((item) => item.kind === 'source_anchor')) {
242
+ return limited;
243
+ }
244
+ const sourceAnchor = sorted.find((item) => item.kind === 'source_anchor');
245
+ if (!sourceAnchor) {
246
+ return limited;
247
+ }
248
+ if (limited.length < limit) {
249
+ return [...limited, sourceAnchor];
250
+ }
251
+ return [...limited.slice(0, Math.max(0, limit - 1)), sourceAnchor];
252
+ }
253
+ function collectBoundedDirectSearchDocuments(projectRoot) {
254
+ const documents = [];
255
+ const relativePaths = getExistingIndexablePaths(projectRoot).slice(0, DIRECT_SEARCH_MAX_WORKFLOW_FILES);
256
+ for (const relativePath of relativePaths) {
257
+ try {
258
+ documents.push(...collectDocumentsFromPaths(projectRoot, [relativePath]));
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ }
264
+ return documents;
265
+ }
266
+ function searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQuery, limit, scope) {
267
+ const cacheLayers = readCacheLayerSets(projectRoot);
268
+ const results = [];
269
+ if (scope === 'workflow' || scope === 'all') {
270
+ const documents = collectBoundedDirectSearchDocuments(projectRoot);
271
+ for (const document of documents) {
272
+ let searchableContent = document.contentSnippet;
273
+ try {
274
+ searchableContent = readText(projectRoot, document.path);
275
+ }
276
+ catch {
277
+ searchableContent = document.contentSnippet;
278
+ }
279
+ const primaryFields = [document.path, document.title];
280
+ const secondaryFields = [document.type, searchableContent, ...document.sections];
281
+ const fields = [...primaryFields, ...secondaryFields];
282
+ if (!isMatched(fields, normalizedQuery)) {
283
+ continue;
284
+ }
285
+ results.push(withCacheHint({
286
+ kind: 'document',
287
+ path: document.path,
288
+ title: document.title,
289
+ document_type: document.type,
290
+ ...workflowAuthorityForDocument(document.type),
291
+ match: getMatchSnippet(fields, normalizedQuery),
292
+ score: scoreMatch(primaryFields, secondaryFields, normalizedQuery),
293
+ }, cacheLayers));
294
+ }
295
+ for (const skill of collectSkills(documents)) {
296
+ const fields = [skill.name, skill.path, skill.title];
297
+ if (!isMatched(fields, normalizedQuery)) {
298
+ continue;
299
+ }
300
+ results.push(withCacheHint({
301
+ kind: 'skill',
302
+ name: skill.name,
303
+ path: skill.path,
304
+ title: skill.title,
305
+ ...skillAuthority(),
306
+ match: getMatchSnippet(fields, normalizedQuery),
307
+ score: scoreMatch(fields, [], normalizedQuery),
308
+ }, cacheLayers));
309
+ }
310
+ }
311
+ const sortedResults = sortLocalSearchResults(results, scope, limit);
312
+ return {
313
+ schema_version: LOCAL_INDEX_SCHEMA_VERSION,
314
+ command: 'search',
315
+ ok: true,
316
+ mustflow_root: path.resolve(projectRoot),
317
+ database_path: databasePath,
318
+ query: normalizedQuery,
319
+ limit,
320
+ scope,
321
+ index_fresh: false,
322
+ stale_paths: [],
323
+ search_backend: SEARCH_BACKEND_TABLE_SCAN,
324
+ search_fts5_available: false,
325
+ result_count: sortedResults.length,
326
+ results: sortedResults,
327
+ };
328
+ }
329
+ /**
330
+ * mf:anchor cli.search.local-index
331
+ * purpose: Search the local index while preserving workflow authority above source navigation hints.
332
+ * search: mf search, scope workflow source all, authority rank, navigation only
333
+ * invariant: Source anchor results remain navigation-only and cannot outrank command or workflow authority.
334
+ * risk: cache, config
335
+ */
336
+ export async function searchLocalIndex(projectRoot, query, options = {}) {
337
+ const normalizedQuery = normalizeSearchText(query);
338
+ const limit = Math.max(1, Math.min(options.limit ?? 10, 50));
339
+ const scope = options.scope ?? 'workflow';
340
+ const databasePath = getLocalIndexDatabasePath(projectRoot);
341
+ if (normalizedQuery.length === 0) {
342
+ throw new Error('Search query must not be empty.');
343
+ }
344
+ if (!existsSync(databasePath)) {
345
+ return searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQuery, limit, scope);
346
+ }
347
+ let database;
348
+ try {
349
+ const SQL = await loadSqlJs();
350
+ database = new SQL.Database(readFileSync(databasePath));
351
+ }
352
+ catch {
353
+ return searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQuery, limit, scope);
354
+ }
355
+ let capabilities = searchCapabilities(false);
356
+ const results = [];
357
+ try {
358
+ const cacheLayers = readCacheLayerSets(projectRoot);
359
+ const stalePaths = getStalePaths(projectRoot, database);
360
+ capabilities = readStoredSearchCapabilities(database);
361
+ const indexedMatches = getIndexedSearchMatches(database, normalizedQuery);
362
+ if (stalePaths.length > 0) {
363
+ throw new Error(`Local mustflow index is stale: ${stalePaths.join(', ')}. Run \`mf index\` before searching. Refresh command: mf index`);
364
+ }
365
+ if (scope === 'workflow' || scope === 'all') {
366
+ for (const row of queryCandidateRows(database, 'SELECT path, type, title, content_snippet FROM documents', 'path', indexedMatches.documents, indexedMatches)) {
367
+ const pathValue = toSearchString(row.path);
368
+ const typeValue = toSearchString(row.type);
369
+ const title = toSearchString(row.title);
370
+ const contentSnippet = toSearchString(row.content_snippet);
371
+ const sectionHeadings = getSectionHeadings(database, pathValue);
372
+ const documentTerms = getDocumentTerms(database, pathValue);
373
+ const primaryFields = [pathValue, title];
374
+ const secondaryFields = [typeValue, contentSnippet, ...sectionHeadings, ...documentTerms];
375
+ const fields = [...primaryFields, ...secondaryFields];
376
+ if (!matchesIndexedOrTableScan(fields, normalizedQuery, indexedMatches, indexedMatches.documents, pathValue)) {
377
+ continue;
378
+ }
379
+ results.push(withCacheHint({
380
+ kind: 'document',
381
+ path: pathValue,
382
+ title,
383
+ document_type: typeValue,
384
+ ...workflowAuthorityForDocument(typeValue),
385
+ match: getMatchSnippet(fields, normalizedQuery),
386
+ score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.documents, pathValue),
387
+ }, cacheLayers));
388
+ }
389
+ for (const row of queryCandidateRows(database, 'SELECT name, path, title FROM skills', 'name', indexedMatches.skills, indexedMatches)) {
390
+ const name = toSearchString(row.name);
391
+ const pathValue = toSearchString(row.path);
392
+ const title = toSearchString(row.title);
393
+ const fields = [name, pathValue, title];
394
+ if (!matchesIndexedOrTableScan(fields, normalizedQuery, indexedMatches, indexedMatches.skills, name)) {
395
+ continue;
396
+ }
397
+ results.push(withCacheHint({
398
+ kind: 'skill',
399
+ name,
400
+ path: pathValue,
401
+ title,
402
+ ...skillAuthority(),
403
+ match: getMatchSnippet(fields, normalizedQuery),
404
+ score: scoreIndexedOrTableScan([name, pathValue, title], [], normalizedQuery, indexedMatches, indexedMatches.skills, name),
405
+ }, cacheLayers));
406
+ }
407
+ const matchedSkillRouteNames = new Set([...indexedMatches.skillRoutes].map((routeKey) => routeKey.split('\u0000')[0] ?? ''));
408
+ for (const row of queryCandidateRows(database, 'SELECT skill_name, skill_path, trigger, required_input, edit_scope, risk, verification_intents, expected_output FROM skill_routes', 'skill_name', matchedSkillRouteNames, indexedMatches)) {
409
+ const name = toSearchString(row.skill_name);
410
+ const pathValue = toSearchString(row.skill_path);
411
+ const trigger = toSearchString(row.trigger);
412
+ const requiredInput = toSearchString(row.required_input);
413
+ const editScope = toSearchString(row.edit_scope);
414
+ const risk = toSearchString(row.risk);
415
+ const verificationIntents = splitVerificationIntents(toSearchString(row.verification_intents));
416
+ const expectedOutput = toSearchString(row.expected_output);
417
+ const primaryFields = [name, trigger];
418
+ const secondaryFields = [pathValue, requiredInput, editScope, risk, expectedOutput];
419
+ const fields = [...primaryFields, ...secondaryFields];
420
+ const routeKey = skillRouteKey({ skillName: name, trigger });
421
+ const indexedRouteMatch = indexedMatches.active && indexedMatches.skillRoutes.has(routeKey);
422
+ if (!indexedRouteMatch && !isMatched(fields, normalizedQuery)) {
423
+ continue;
424
+ }
425
+ results.push(withCacheHint({
426
+ kind: 'skill_route',
427
+ name,
428
+ path: pathValue,
429
+ title: name,
430
+ route_trigger: trigger,
431
+ route_risk: risk,
432
+ verification_intents: verificationIntents,
433
+ ...skillAuthority(),
434
+ match: getMatchSnippet(fields, normalizedQuery),
435
+ score: indexedRouteMatch
436
+ ? Math.max(scoreMatch(primaryFields, secondaryFields, normalizedQuery), 20)
437
+ : scoreMatch(primaryFields, secondaryFields, normalizedQuery),
438
+ }, cacheLayers));
439
+ }
440
+ const commandRows = queryCandidateRows(database, 'SELECT name, status, lifecycle, run_policy, description FROM command_intents', 'name', indexedMatches.commandIntents, indexedMatches);
441
+ const effectsByIntent = getCommandEffectsByIntent(database, commandRows.map((row) => toSearchString(row.name)));
442
+ for (const row of commandRows) {
443
+ const name = toSearchString(row.name);
444
+ const status = toSearchString(row.status);
445
+ const lifecycle = toSearchString(row.lifecycle);
446
+ const runPolicy = toSearchString(row.run_policy);
447
+ const description = toSearchString(row.description);
448
+ const effects = effectsByIntent.get(name) ?? [];
449
+ const effectLocks = [...new Set(effects.map((effect) => effect.lock))].sort((left, right) => left.localeCompare(right));
450
+ const effectPaths = [
451
+ ...new Set(effects.map((effect) => effect.path).filter((effectPath) => effectPath !== null)),
452
+ ].sort((left, right) => left.localeCompare(right));
453
+ const effectModes = [...new Set(effects.map((effect) => effect.mode))].sort((left, right) => left.localeCompare(right));
454
+ const primaryFields = [name];
455
+ const secondaryFields = [status, lifecycle, runPolicy, description, ...effectLocks, ...effectPaths, ...effectModes];
456
+ const fields = [...primaryFields, ...secondaryFields];
457
+ if (!matchesIndexedOrTableScan(fields, normalizedQuery, indexedMatches, indexedMatches.commandIntents, name)) {
458
+ continue;
459
+ }
460
+ results.push(withCacheHint({
461
+ kind: 'command_intent',
462
+ name,
463
+ title: description || name,
464
+ effect_locks: effectLocks,
465
+ effect_paths: effectPaths,
466
+ effect_modes: effectModes,
467
+ ...commandIntentAuthority(),
468
+ match: getMatchSnippet(fields, normalizedQuery),
469
+ score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.commandIntents, name),
470
+ }, cacheLayers));
471
+ }
472
+ }
473
+ if (scope === 'source' || scope === 'all') {
474
+ for (const row of queryCandidateRows(database, 'SELECT source_anchors.id, path, line_start, purpose, search_terms, invariant, risk, source_anchors.navigation_only, source_anchors.can_instruct_agent, status, confidence FROM source_anchors LEFT JOIN source_anchor_status ON source_anchor_status.anchor_id = source_anchors.id', 'source_anchors.id', indexedMatches.sourceAnchors, indexedMatches)) {
475
+ const id = toSearchString(row.id);
476
+ const pathValue = toSearchString(row.path);
477
+ const purpose = toSearchString(row.purpose);
478
+ const searchTerms = toSearchString(row.search_terms);
479
+ const invariant = toSearchString(row.invariant);
480
+ const risk = toSearchString(row.risk);
481
+ const primaryFields = [id, pathValue];
482
+ const secondaryFields = [purpose, searchTerms, invariant, risk];
483
+ const fields = [...primaryFields, ...secondaryFields];
484
+ if (!matchesIndexedOrTableScan(fields, normalizedQuery, indexedMatches, indexedMatches.sourceAnchors, id)) {
485
+ continue;
486
+ }
487
+ results.push(withCacheHint({
488
+ kind: 'source_anchor',
489
+ anchor_id: id,
490
+ name: id,
491
+ path: pathValue,
492
+ line_start: Number(row.line_start),
493
+ title: purpose || id,
494
+ risk,
495
+ ...sourceAnchorAuthority(),
496
+ stale_status: toSearchString(row.status),
497
+ stale_confidence: Number(row.confidence),
498
+ match: getMatchSnippet(fields, normalizedQuery),
499
+ score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.sourceAnchors, id),
500
+ }, cacheLayers));
501
+ }
502
+ }
503
+ }
504
+ catch (error) {
505
+ if (isLocalIndexStaleError(error)) {
506
+ throw error;
507
+ }
508
+ if (isLocalIndexRuntimeUnavailableError(error)) {
509
+ return searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQuery, limit, scope);
510
+ }
511
+ throw error;
512
+ }
513
+ finally {
514
+ database.close();
515
+ }
516
+ const sortedResults = sortLocalSearchResults(results, scope, limit);
517
+ return {
518
+ schema_version: LOCAL_INDEX_SCHEMA_VERSION,
519
+ command: 'search',
520
+ ok: true,
521
+ mustflow_root: path.resolve(projectRoot),
522
+ database_path: databasePath,
523
+ query: normalizedQuery,
524
+ limit,
525
+ scope,
526
+ index_fresh: true,
527
+ stale_paths: [],
528
+ search_backend: capabilities.backend,
529
+ search_fts5_available: capabilities.fts5Available,
530
+ result_count: sortedResults.length,
531
+ results: sortedResults,
532
+ };
533
+ }
@@ -0,0 +1,79 @@
1
+ import { MAX_SEARCH_MATCH_SNIPPET_CHARS, SEARCH_MATCH_CONTEXT_AFTER_CHARS, SEARCH_MATCH_CONTEXT_BEFORE_CHARS, SEARCH_MATCH_TRUNCATION_MARKER, SEARCH_NGRAM_MAX_GRAMS_PER_TARGET, SEARCH_NGRAM_MAX_LENGTH, SEARCH_NGRAM_MAX_TOKEN_CHARS, SEARCH_NGRAM_MIN_LENGTH, } from './constants.js';
2
+ export function normalizeSearchText(value) {
3
+ return value.trim().replace(/\s+/g, ' ');
4
+ }
5
+ export function normalizeSearchTokenText(value) {
6
+ return normalizeSearchText(value).normalize('NFKC').toLowerCase();
7
+ }
8
+ export function extractSearchTokens(value) {
9
+ return [...normalizeSearchTokenText(value).matchAll(/[\p{L}\p{N}]+/gu)]
10
+ .map((match) => match[0])
11
+ .filter((token) => Boolean(token));
12
+ }
13
+ export function buildSearchNgrams(values) {
14
+ const grams = new Set();
15
+ for (const value of values) {
16
+ for (const token of extractSearchTokens(value)) {
17
+ const boundedToken = token.slice(0, SEARCH_NGRAM_MAX_TOKEN_CHARS);
18
+ const maxLength = Math.min(SEARCH_NGRAM_MAX_LENGTH, boundedToken.length);
19
+ for (let length = SEARCH_NGRAM_MIN_LENGTH; length <= maxLength; length += 1) {
20
+ for (let index = 0; index <= boundedToken.length - length; index += 1) {
21
+ grams.add(boundedToken.slice(index, index + length));
22
+ if (grams.size >= SEARCH_NGRAM_MAX_GRAMS_PER_TARGET) {
23
+ return [...grams].sort((left, right) => left.localeCompare(right));
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ return [...grams].sort((left, right) => left.localeCompare(right));
30
+ }
31
+ export function getMatchSnippet(fields, query) {
32
+ const normalized = normalizeSearchText(fields.join(' '));
33
+ const lower = normalized.toLowerCase();
34
+ let start = lower.indexOf(query.toLowerCase());
35
+ let matchLength = query.length;
36
+ if (start === -1) {
37
+ const [firstGram] = buildSearchNgrams([query]).filter((gram) => lower.includes(gram));
38
+ if (!firstGram) {
39
+ return truncateSearchMatchSnippet(normalized);
40
+ }
41
+ start = lower.indexOf(firstGram);
42
+ matchLength = firstGram.length;
43
+ }
44
+ const from = Math.max(0, start - SEARCH_MATCH_CONTEXT_BEFORE_CHARS);
45
+ const to = Math.min(normalized.length, start + matchLength + SEARCH_MATCH_CONTEXT_AFTER_CHARS);
46
+ const prefix = from > 0 ? SEARCH_MATCH_TRUNCATION_MARKER : '';
47
+ const suffix = to < normalized.length ? SEARCH_MATCH_TRUNCATION_MARKER : '';
48
+ return truncateSearchMatchSnippet(`${prefix}${normalized.slice(from, to)}${suffix}`);
49
+ }
50
+ export function truncateSearchMatchSnippet(value) {
51
+ if (value.length <= MAX_SEARCH_MATCH_SNIPPET_CHARS) {
52
+ return value;
53
+ }
54
+ return `${value.slice(0, MAX_SEARCH_MATCH_SNIPPET_CHARS - SEARCH_MATCH_TRUNCATION_MARKER.length)}${SEARCH_MATCH_TRUNCATION_MARKER}`;
55
+ }
56
+ export function scoreMatch(primaryFields, secondaryFields, query) {
57
+ const lowerQuery = query.toLowerCase();
58
+ if (primaryFields.some((field) => field.toLowerCase() === lowerQuery)) {
59
+ return 100;
60
+ }
61
+ if (primaryFields.some((field) => field.toLowerCase().includes(lowerQuery))) {
62
+ return 80;
63
+ }
64
+ if (secondaryFields.some((field) => field.toLowerCase().includes(lowerQuery))) {
65
+ return 40;
66
+ }
67
+ return 0;
68
+ }
69
+ export function isMatched(fields, query) {
70
+ const lowerQuery = query.toLowerCase();
71
+ return fields.some((field) => field.toLowerCase().includes(lowerQuery));
72
+ }
73
+ export function buildFtsQuery(query) {
74
+ const tokens = extractSearchTokens(query);
75
+ if (tokens.length === 0) {
76
+ return null;
77
+ }
78
+ return [...new Set(tokens)].map((token) => `"${token.replaceAll('"', '""')}"`).join(' AND ');
79
+ }