mustflow 2.58.1 → 2.68.6

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 (47) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commands/context.js +81 -6
  3. package/dist/cli/commands/quality.js +89 -0
  4. package/dist/cli/commands/skill.js +116 -0
  5. package/dist/cli/i18n/en.js +16 -0
  6. package/dist/cli/i18n/es.js +16 -0
  7. package/dist/cli/i18n/fr.js +16 -0
  8. package/dist/cli/i18n/hi.js +16 -0
  9. package/dist/cli/i18n/ko.js +16 -0
  10. package/dist/cli/i18n/zh.js +16 -0
  11. package/dist/cli/index.js +1 -0
  12. package/dist/cli/lib/agent-context.js +981 -8
  13. package/dist/cli/lib/command-registry.js +12 -0
  14. package/dist/cli/lib/local-index/constants.js +4 -5
  15. package/dist/cli/lib/local-index/freshness.js +5 -1
  16. package/dist/cli/lib/local-index/index.js +1 -1
  17. package/dist/cli/lib/repo-map.js +7 -1
  18. package/dist/cli/lib/validation/constants.js +3 -0
  19. package/dist/cli/lib/validation/index.js +41 -2
  20. package/dist/core/check-issues.js +5 -0
  21. package/dist/core/prompt-cache-rendering.js +19 -0
  22. package/dist/core/public-json-contracts.js +35 -0
  23. package/dist/core/quality-gaming.js +304 -0
  24. package/dist/core/skill-route-fixtures.js +173 -0
  25. package/dist/core/skill-route-resolution.js +398 -0
  26. package/dist/core/source-anchors.js +91 -5
  27. package/package.json +1 -1
  28. package/schemas/README.md +9 -1
  29. package/schemas/context-report.schema.json +442 -0
  30. package/schemas/quality-gaming-report.schema.json +96 -0
  31. package/schemas/route-fixture.schema.json +57 -0
  32. package/schemas/skill-route-report.schema.json +242 -0
  33. package/templates/default/common/.mustflow/config/commands.toml +34 -2
  34. package/templates/default/common/.mustflow/config/mustflow.toml +16 -10
  35. package/templates/default/i18n.toml +14 -8
  36. package/templates/default/locales/en/.mustflow/context/PROJECT.md +5 -3
  37. package/templates/default/locales/en/.mustflow/docs/agent-workflow.md +13 -9
  38. package/templates/default/locales/en/.mustflow/skills/INDEX.md +3 -2
  39. package/templates/default/locales/en/.mustflow/skills/llm-token-cost-control-review/SKILL.md +5 -2
  40. package/templates/default/locales/en/.mustflow/skills/quality-gaming-guard/SKILL.md +165 -0
  41. package/templates/default/locales/en/.mustflow/skills/router.toml +67 -0
  42. package/templates/default/locales/en/.mustflow/skills/routes.toml +6 -0
  43. package/templates/default/locales/en/AGENTS.md +15 -7
  44. package/templates/default/locales/ko/.mustflow/context/PROJECT.md +4 -2
  45. package/templates/default/locales/ko/.mustflow/docs/agent-workflow.md +18 -12
  46. package/templates/default/locales/ko/AGENTS.md +8 -6
  47. package/templates/default/manifest.toml +9 -1
@@ -0,0 +1,173 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isRecord } from './config-loading.js';
4
+ import { readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ import { resolveSkillRoutes } from './skill-route-resolution.js';
6
+ export const SKILL_ROUTE_FIXTURES_PATH = '.mustflow/skills/route-fixtures.json';
7
+ const FIXTURE_MAX_BYTES = 256 * 1024;
8
+ function readStringArray(value, options = {}) {
9
+ if (!Array.isArray(value) ||
10
+ (!options.allowEmpty && value.length === 0) ||
11
+ !value.every((entry) => typeof entry === 'string' && entry.trim().length > 0)) {
12
+ return null;
13
+ }
14
+ return value.map((entry) => entry.trim());
15
+ }
16
+ function readOptionalStringArray(value, pointer, issues) {
17
+ if (value === undefined) {
18
+ return [];
19
+ }
20
+ const entries = readStringArray(value);
21
+ if (!entries) {
22
+ issues.push({ kind: 'invalid', message: `${pointer} must be a non-empty string array when present` });
23
+ return null;
24
+ }
25
+ return entries;
26
+ }
27
+ function readOptionalString(value) {
28
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
29
+ }
30
+ function readOptionalMaxCandidates(value) {
31
+ return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 10 ? Number(value) : undefined;
32
+ }
33
+ function parseFixtureCase(value, index, issues) {
34
+ const pointer = `${SKILL_ROUTE_FIXTURES_PATH} cases[${index}]`;
35
+ if (!isRecord(value)) {
36
+ issues.push({ kind: 'invalid', message: `${pointer} must be an object` });
37
+ return null;
38
+ }
39
+ const id = readOptionalString(value.id);
40
+ if (!id) {
41
+ issues.push({ kind: 'invalid', message: `${pointer}.id must be a non-empty string` });
42
+ return null;
43
+ }
44
+ const paths = readStringArray(value.paths);
45
+ if (!paths) {
46
+ issues.push({ kind: 'invalid', message: `${pointer}.paths must be a non-empty string array` });
47
+ return null;
48
+ }
49
+ const reasons = readStringArray(value.reasons);
50
+ if (!reasons) {
51
+ issues.push({ kind: 'invalid', message: `${pointer}.reasons must be a non-empty string array` });
52
+ return null;
53
+ }
54
+ let task;
55
+ if (value.task === undefined || value.task === null) {
56
+ task = null;
57
+ }
58
+ else {
59
+ const taskValue = readOptionalString(value.task);
60
+ if (!taskValue) {
61
+ issues.push({ kind: 'invalid', message: `${pointer}.task must be a non-empty string, null, or omitted` });
62
+ return null;
63
+ }
64
+ task = taskValue;
65
+ }
66
+ const maxCandidates = value.max_candidates === undefined ? undefined : readOptionalMaxCandidates(value.max_candidates);
67
+ if (value.max_candidates !== undefined && maxCandidates === undefined) {
68
+ issues.push({ kind: 'invalid', message: `${pointer}.max_candidates must be an integer from 1 through 10` });
69
+ return null;
70
+ }
71
+ const requiredCandidates = readOptionalStringArray(value.required_candidates, `${pointer}.required_candidates`, issues);
72
+ const requiredAdjuncts = readOptionalStringArray(value.required_adjuncts, `${pointer}.required_adjuncts`, issues);
73
+ const forbiddenCandidates = readOptionalStringArray(value.forbidden_candidates, `${pointer}.forbidden_candidates`, issues);
74
+ if (!requiredCandidates || !requiredAdjuncts || !forbiddenCandidates) {
75
+ return null;
76
+ }
77
+ const fixture = {
78
+ id,
79
+ task,
80
+ paths,
81
+ reasons,
82
+ maxCandidates,
83
+ requiredMain: readOptionalString(value.required_main),
84
+ requiredCandidates,
85
+ requiredAdjuncts,
86
+ forbiddenCandidates,
87
+ };
88
+ if (!fixture.requiredMain &&
89
+ fixture.requiredCandidates.length === 0 &&
90
+ fixture.requiredAdjuncts.length === 0 &&
91
+ fixture.forbiddenCandidates.length === 0) {
92
+ issues.push({
93
+ kind: 'invalid',
94
+ message: `${pointer} must declare required_main, required_candidates, required_adjuncts, or forbidden_candidates`,
95
+ });
96
+ return null;
97
+ }
98
+ return fixture;
99
+ }
100
+ function formatSkill(value) {
101
+ return value ?? 'none';
102
+ }
103
+ function validateFixtureCase(projectRoot, fixture) {
104
+ const report = resolveSkillRoutes(projectRoot, {
105
+ taskText: fixture.task,
106
+ paths: fixture.paths,
107
+ reasons: fixture.reasons,
108
+ maxCandidates: fixture.maxCandidates,
109
+ });
110
+ const issues = [];
111
+ const candidateSkills = new Set(report.candidates.map((candidate) => candidate.skill));
112
+ const adjunctSkills = new Set(report.selected.adjuncts.map((candidate) => candidate.skill));
113
+ if (fixture.requiredMain && report.selected.main?.skill !== fixture.requiredMain) {
114
+ issues.push({
115
+ kind: 'mismatch',
116
+ message: `Skill route fixture "${fixture.id}" expected selected main "${fixture.requiredMain}" but got "${formatSkill(report.selected.main?.skill)}"`,
117
+ });
118
+ }
119
+ for (const skill of fixture.requiredCandidates) {
120
+ if (!candidateSkills.has(skill)) {
121
+ issues.push({
122
+ kind: 'mismatch',
123
+ message: `Skill route fixture "${fixture.id}" expected candidate "${skill}" within top ${report.input.max_candidates}`,
124
+ });
125
+ }
126
+ }
127
+ for (const skill of fixture.requiredAdjuncts) {
128
+ if (!adjunctSkills.has(skill)) {
129
+ issues.push({
130
+ kind: 'mismatch',
131
+ message: `Skill route fixture "${fixture.id}" expected selected adjunct "${skill}"`,
132
+ });
133
+ }
134
+ }
135
+ for (const skill of fixture.forbiddenCandidates) {
136
+ if (candidateSkills.has(skill) || adjunctSkills.has(skill) || report.selected.main?.skill === skill) {
137
+ issues.push({
138
+ kind: 'mismatch',
139
+ message: `Skill route fixture "${fixture.id}" forbids selected or candidate skill "${skill}"`,
140
+ });
141
+ }
142
+ }
143
+ return issues;
144
+ }
145
+ export function validateSkillRouteFixtures(projectRoot) {
146
+ const fixturePath = path.join(projectRoot, ...SKILL_ROUTE_FIXTURES_PATH.split('/'));
147
+ if (!existsSync(fixturePath)) {
148
+ return [];
149
+ }
150
+ const issues = [];
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(readUtf8FileInsideWithoutSymlinks(projectRoot, fixturePath, { maxBytes: FIXTURE_MAX_BYTES }));
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ return [{ kind: 'invalid', message: `${SKILL_ROUTE_FIXTURES_PATH} is not valid JSON: ${message}` }];
158
+ }
159
+ if (!isRecord(parsed) || parsed.schema_version !== '1' || !Array.isArray(parsed.cases)) {
160
+ return [{
161
+ kind: 'invalid',
162
+ message: `${SKILL_ROUTE_FIXTURES_PATH} must contain schema_version "1" and a cases array`,
163
+ }];
164
+ }
165
+ for (const [index, entry] of parsed.cases.entries()) {
166
+ const fixture = parseFixtureCase(entry, index, issues);
167
+ if (!fixture) {
168
+ continue;
169
+ }
170
+ issues.push(...validateFixtureCase(projectRoot, fixture));
171
+ }
172
+ return issues;
173
+ }
@@ -0,0 +1,398 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isRecord, readMustflowOwnedTomlFile } from './config-loading.js';
4
+ import { readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ const MUSTFLOW_TEXT_MAX_BYTES = 1024 * 1024;
6
+ const SKILL_INDEX_PATH = '.mustflow/skills/INDEX.md';
7
+ const SKILL_ROUTER_PATH = '.mustflow/skills/router.toml';
8
+ const SKILL_ROUTES_METADATA_PATH = '.mustflow/skills/routes.toml';
9
+ const SKILL_FRONTMATTER_SOURCE = '.mustflow/skills/*/SKILL.md';
10
+ const DEFAULT_MAX_CANDIDATES = 5;
11
+ const DEFAULT_MAX_MAIN = 1;
12
+ const DEFAULT_MAX_ADJUNCTS = 2;
13
+ const PATH_SKILL_HINT_SCORE = 25;
14
+ const DOCS_TREE_MARKDOWN_PATH_PATTERN = /(?:^|\/)(?:docs|docs-site|documentation|\.mustflow\/docs|\.mustflow\/context)\/.+\.(?:md|mdx)$/u;
15
+ const ROOT_DOCUMENT_BASENAMES = [
16
+ 'readme',
17
+ 'changelog',
18
+ 'contributing',
19
+ 'security',
20
+ 'support',
21
+ 'governance',
22
+ 'maintainers',
23
+ 'releasing',
24
+ 'release',
25
+ 'testing',
26
+ 'deployment',
27
+ 'operations',
28
+ 'runbook',
29
+ 'configuration',
30
+ 'troubleshooting',
31
+ 'architecture',
32
+ 'api',
33
+ ];
34
+ const ROUTE_TYPE_WEIGHTS = {
35
+ primary: 18,
36
+ authoring: 16,
37
+ adjunct: 8,
38
+ event: 4,
39
+ };
40
+ function normalizeSkillPath(value) {
41
+ return value.replace(/\\/gu, '/');
42
+ }
43
+ function skillNameFromPath(skillPath) {
44
+ const match = /^\.mustflow\/skills\/([^/]+)\/SKILL\.md$/u.exec(skillPath);
45
+ return match?.[1] ?? skillPath;
46
+ }
47
+ function tokenize(value) {
48
+ return [
49
+ ...new Set(value
50
+ .toLowerCase()
51
+ .replace(/\.mustflow\/skills\/[^/\s]+\/skill\.md/giu, ' ')
52
+ .replace(/[^a-z0-9]+/gu, ' ')
53
+ .split(/\s+/u)
54
+ .map((token) => token.trim())
55
+ .filter((token) => token.length >= 3)),
56
+ ].sort((left, right) => left.localeCompare(right));
57
+ }
58
+ function collectPathSkillHints(paths) {
59
+ const hints = new Set();
60
+ for (const pathValue of paths) {
61
+ const lower = pathValue.toLowerCase();
62
+ if (/\.(?:cts|mts|ts|tsx)$/u.test(lower) || lower.endsWith('tsconfig.json')) {
63
+ hints.add('typescript-code-change');
64
+ }
65
+ if (/\.(?:cjs|mjs|js|jsx)$/u.test(lower)) {
66
+ hints.add('javascript-code-change');
67
+ }
68
+ if (/\.py$/u.test(lower) || /(?:^|\/)(?:pyproject\.toml|requirements\.txt|poetry\.lock)$/u.test(lower)) {
69
+ hints.add('python-code-change');
70
+ }
71
+ if (/\.go$/u.test(lower) || /(?:^|\/)go\.(?:mod|sum)$/u.test(lower)) {
72
+ hints.add('go-code-change');
73
+ }
74
+ if (/\.rs$/u.test(lower) || /(?:^|\/)(?:cargo\.toml|cargo\.lock)$/u.test(lower)) {
75
+ hints.add('rust-code-change');
76
+ }
77
+ if (/\.ps1$/u.test(lower)) {
78
+ hints.add('powershell-code-change');
79
+ }
80
+ if (DOCS_TREE_MARKDOWN_PATH_PATTERN.test(lower) || isRootDocumentationPath(lower)) {
81
+ hints.add('docs-update');
82
+ }
83
+ }
84
+ return hints;
85
+ }
86
+ function isRootDocumentationPath(lowercasePath) {
87
+ const basename = lowercasePath.split('/').pop();
88
+ if (!basename?.endsWith('.md')) {
89
+ return false;
90
+ }
91
+ const rootName = basename.replace(/\.md$/u, '');
92
+ return ROOT_DOCUMENT_BASENAMES.includes(rootName);
93
+ }
94
+ function readStringArrayFromTable(table, key) {
95
+ const value = table[key];
96
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
97
+ ? value.map((entry) => entry.trim()).filter(Boolean)
98
+ : [];
99
+ }
100
+ function readFrontmatterBlock(content) {
101
+ if (!content.startsWith('---')) {
102
+ return [];
103
+ }
104
+ const firstLineEnd = content.indexOf('\n');
105
+ if (firstLineEnd < 0) {
106
+ return [];
107
+ }
108
+ const end = content.indexOf('\n---', firstLineEnd + 1);
109
+ if (end < 0) {
110
+ return [];
111
+ }
112
+ return content.slice(firstLineEnd + 1, end).split(/\r?\n/u);
113
+ }
114
+ function readFrontmatterScalar(lines, key) {
115
+ for (const line of lines) {
116
+ const match = /^([a-zA-Z0-9_]+):\s*(.*)$/u.exec(line);
117
+ if (match?.[1] === key) {
118
+ return match[2].trim().replace(/^["']|["']$/gu, '') || null;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ function readFrontmatterList(lines, key) {
124
+ const values = [];
125
+ let inList = false;
126
+ let baseIndent = 0;
127
+ for (const line of lines) {
128
+ const keyMatch = new RegExp(`^(\\s*)${key}:\\s*$`, 'u').exec(line);
129
+ if (keyMatch) {
130
+ inList = true;
131
+ baseIndent = keyMatch[1].length;
132
+ continue;
133
+ }
134
+ if (!inList) {
135
+ continue;
136
+ }
137
+ const itemMatch = /^(\s*)-\s+(.+?)\s*$/u.exec(line);
138
+ if (itemMatch && itemMatch[1].length > baseIndent) {
139
+ values.push(itemMatch[2].trim().replace(/^["']|["']$/gu, ''));
140
+ continue;
141
+ }
142
+ if (line.trim() && !line.startsWith(' '.repeat(baseIndent + 1))) {
143
+ break;
144
+ }
145
+ }
146
+ return values;
147
+ }
148
+ function readSkillFrontmatterSummary(content) {
149
+ const lines = readFrontmatterBlock(content);
150
+ return {
151
+ name: readFrontmatterScalar(lines, 'name'),
152
+ description: readFrontmatterScalar(lines, 'description'),
153
+ commandIntents: readFrontmatterList(lines, 'command_intents'),
154
+ };
155
+ }
156
+ function readSkillRouteMetadata(projectRoot) {
157
+ const metadata = new Map();
158
+ try {
159
+ const parsed = readMustflowOwnedTomlFile(projectRoot, SKILL_ROUTES_METADATA_PATH);
160
+ if (!isRecord(parsed) || !isRecord(parsed.routes)) {
161
+ return metadata;
162
+ }
163
+ for (const [skillName, route] of Object.entries(parsed.routes)) {
164
+ if (!isRecord(route)) {
165
+ continue;
166
+ }
167
+ metadata.set(skillName, {
168
+ category: typeof route.category === 'string' ? route.category : null,
169
+ routeType: typeof route.route_type === 'string' ? route.route_type : 'unknown',
170
+ priority: Number.isInteger(route.priority) ? Number(route.priority) : 0,
171
+ appliesToReasons: readStringArrayFromTable(route, 'applies_to_reasons'),
172
+ mutuallyExclusiveWith: readStringArrayFromTable(route, 'mutually_exclusive_with'),
173
+ });
174
+ }
175
+ }
176
+ catch {
177
+ return metadata;
178
+ }
179
+ return metadata;
180
+ }
181
+ function readSkillFrontmatterRoutes(projectRoot) {
182
+ const skillRoot = path.join(projectRoot, '.mustflow', 'skills');
183
+ if (!existsSync(skillRoot)) {
184
+ return [];
185
+ }
186
+ const routes = [];
187
+ const skillDirectories = readdirSync(skillRoot, { withFileTypes: true })
188
+ .filter((entry) => entry.isDirectory())
189
+ .map((entry) => entry.name)
190
+ .sort((left, right) => left.localeCompare(right));
191
+ for (const skillDirectory of skillDirectories) {
192
+ const skillPath = `.mustflow/skills/${skillDirectory}/SKILL.md`;
193
+ const absoluteSkillPath = path.join(projectRoot, ...skillPath.split('/'));
194
+ if (!existsSync(absoluteSkillPath)) {
195
+ continue;
196
+ }
197
+ const content = readUtf8FileInsideWithoutSymlinks(projectRoot, absoluteSkillPath, {
198
+ maxBytes: MUSTFLOW_TEXT_MAX_BYTES,
199
+ });
200
+ const summary = readSkillFrontmatterSummary(content);
201
+ const skillName = summary.name ?? skillDirectory;
202
+ routes.push({
203
+ trigger: summary.description ?? skillName,
204
+ skillPath,
205
+ requiredInput: '',
206
+ editScope: '',
207
+ risk: '',
208
+ commandIntents: summary.commandIntents,
209
+ expectedOutput: '',
210
+ });
211
+ }
212
+ return routes;
213
+ }
214
+ function countMatches(needles, haystack) {
215
+ const haystackSet = new Set(haystack);
216
+ return needles.filter((needle) => haystackSet.has(needle)).length;
217
+ }
218
+ function routeTextTerms(route, skillName) {
219
+ return tokenize([
220
+ skillName,
221
+ route.trigger,
222
+ route.requiredInput,
223
+ route.editScope,
224
+ route.risk,
225
+ route.expectedOutput,
226
+ route.skillPath,
227
+ ].join(' '));
228
+ }
229
+ function createCandidate(route, metadata, taskTerms, pathTerms, pathSkillHints, reasons) {
230
+ const skill = skillNameFromPath(route.skillPath);
231
+ const terms = routeTextTerms(route, skill);
232
+ const matchedReasons = reasons.filter((reason) => metadata.appliesToReasons.includes(reason));
233
+ const taskMatches = countMatches(taskTerms, terms);
234
+ const pathMatches = countMatches(pathTerms, terms);
235
+ const pathSkillHintMatched = pathSkillHints.has(skill);
236
+ const breakdown = {
237
+ reason_match: matchedReasons.length * 35,
238
+ task_text_match: taskMatches * 6,
239
+ path_match: pathMatches * 6 + (pathSkillHintMatched ? PATH_SKILL_HINT_SCORE : 0),
240
+ route_type_weight: ROUTE_TYPE_WEIGHTS[metadata.routeType] ?? 0,
241
+ priority_weight: Math.max(0, Math.min(metadata.priority, 100)) / 5,
242
+ };
243
+ const score = Object.values(breakdown).reduce((total, value) => total + value, 0);
244
+ const selectionReasons = [
245
+ ...matchedReasons.map((reason) => `reason:${reason}`),
246
+ ...(taskMatches > 0 ? [`task_terms:${taskMatches}`] : []),
247
+ ...(pathMatches > 0 ? [`path_terms:${pathMatches}`] : []),
248
+ ...(pathSkillHintMatched ? [`path_skill_hint:${skill}`] : []),
249
+ `route_type:${metadata.routeType}`,
250
+ `priority:${metadata.priority}`,
251
+ ];
252
+ return {
253
+ skill,
254
+ skill_path: route.skillPath,
255
+ trigger: route.trigger,
256
+ category: metadata.category,
257
+ route_type: metadata.routeType,
258
+ priority: metadata.priority,
259
+ applies_to_reasons: metadata.appliesToReasons,
260
+ score,
261
+ score_breakdown: breakdown,
262
+ selection_reasons: selectionReasons,
263
+ verification_intents: route.commandIntents,
264
+ };
265
+ }
266
+ function sortCandidates(left, right) {
267
+ const score = right.score - left.score;
268
+ if (score !== 0) {
269
+ return score;
270
+ }
271
+ const priority = right.priority - left.priority;
272
+ if (priority !== 0) {
273
+ return priority;
274
+ }
275
+ return left.skill.localeCompare(right.skill);
276
+ }
277
+ function isSelectableMain(candidate) {
278
+ return candidate.route_type === 'primary' || candidate.route_type === 'authoring';
279
+ }
280
+ function selectAdjuncts(main, allCandidates, metadata) {
281
+ if (!main) {
282
+ return [];
283
+ }
284
+ const mainMetadata = metadata.get(main.skill);
285
+ const excluded = new Set([main.skill, ...(mainMetadata?.mutuallyExclusiveWith ?? [])]);
286
+ return allCandidates
287
+ .filter((candidate) => {
288
+ return (candidate.route_type === 'adjunct' &&
289
+ candidate.category === main.category &&
290
+ !excluded.has(candidate.skill));
291
+ })
292
+ .sort(sortCandidates)
293
+ .slice(0, DEFAULT_MAX_ADJUNCTS);
294
+ }
295
+ function uniqueCandidatePaths(candidates) {
296
+ return [...new Set(candidates.map((candidate) => candidate.skill_path))];
297
+ }
298
+ function createReadPlan(maxCandidates, selected, candidates) {
299
+ const selectedCandidates = [selected.main, ...selected.adjuncts].filter((candidate) => candidate !== null);
300
+ return {
301
+ selection_limits: {
302
+ candidates: maxCandidates,
303
+ main: DEFAULT_MAX_MAIN,
304
+ adjuncts: DEFAULT_MAX_ADJUNCTS,
305
+ },
306
+ stable_kernel: [SKILL_ROUTER_PATH],
307
+ selected_skill_paths: uniqueCandidatePaths(selectedCandidates),
308
+ candidate_skill_paths: uniqueCandidatePaths(candidates),
309
+ fallback_route_metadata: {
310
+ path: SKILL_ROUTES_METADATA_PATH,
311
+ read_when: [
312
+ 'router taxonomy is insufficient',
313
+ 'task edits skill routing',
314
+ 'detailed route metadata is needed',
315
+ 'category or confidence is ambiguous',
316
+ 'selected skill paths are empty',
317
+ ],
318
+ },
319
+ expanded_index: {
320
+ path: SKILL_INDEX_PATH,
321
+ read_when: [
322
+ 'full route metadata is insufficient',
323
+ 'task edits the expanded route table',
324
+ 'human-readable trigger evidence is needed',
325
+ ],
326
+ },
327
+ avoid_by_default: [SKILL_INDEX_PATH],
328
+ notes: [
329
+ 'Keep the router kernel in the stable prefix and load selected SKILL.md files in task context.',
330
+ 'Do not add the expanded skill index to the prompt unless a fallback condition applies.',
331
+ 'If rerouting evidence appears, run the resolver again and append only the new task-layer reads.',
332
+ ],
333
+ };
334
+ }
335
+ function clampCandidateLimit(value) {
336
+ if (value === undefined || !Number.isInteger(value)) {
337
+ return DEFAULT_MAX_CANDIDATES;
338
+ }
339
+ return Math.max(1, Math.min(value, 10));
340
+ }
341
+ export function resolveSkillRoutes(projectRoot, input) {
342
+ const maxCandidates = clampCandidateLimit(input.maxCandidates);
343
+ const paths = input.paths.map(normalizeSkillPath);
344
+ const reasons = [...new Set(input.reasons.map((reason) => reason.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right));
345
+ const taskTerms = tokenize(input.taskText ?? '');
346
+ const pathTerms = tokenize(paths.join(' '));
347
+ const pathSkillHints = collectPathSkillHints(paths);
348
+ const routes = readSkillFrontmatterRoutes(projectRoot);
349
+ const metadata = readSkillRouteMetadata(projectRoot);
350
+ const allCandidates = routes
351
+ .map((route) => {
352
+ const skill = skillNameFromPath(route.skillPath);
353
+ return createCandidate(route, metadata.get(skill) ?? {
354
+ category: route.category ?? null,
355
+ routeType: 'unknown',
356
+ priority: 0,
357
+ appliesToReasons: [],
358
+ mutuallyExclusiveWith: [],
359
+ }, taskTerms, pathTerms, pathSkillHints, reasons);
360
+ })
361
+ .filter((candidate) => candidate.score > 0)
362
+ .sort(sortCandidates);
363
+ const candidates = allCandidates.slice(0, maxCandidates);
364
+ const main = candidates.find(isSelectableMain) ?? null;
365
+ const adjuncts = selectAdjuncts(main, candidates, metadata);
366
+ const selected = {
367
+ main,
368
+ adjuncts,
369
+ };
370
+ return {
371
+ schema_version: '1',
372
+ kind: 'skill_route_resolution',
373
+ input: {
374
+ task_text_present: Boolean(input.taskText?.trim()),
375
+ paths,
376
+ reasons,
377
+ max_candidates: maxCandidates,
378
+ },
379
+ signals: {
380
+ task_terms: taskTerms,
381
+ path_terms: pathTerms,
382
+ reasons,
383
+ read_shards: [SKILL_ROUTES_METADATA_PATH, SKILL_FRONTMATTER_SOURCE],
384
+ },
385
+ selected,
386
+ candidates,
387
+ read_plan: createReadPlan(maxCandidates, selected, candidates),
388
+ source_files: [SKILL_ROUTES_METADATA_PATH, SKILL_FRONTMATTER_SOURCE],
389
+ gap_notes: [
390
+ [
391
+ 'This resolver is a read-only routing prepass.',
392
+ 'It narrows skill candidates from route metadata and skill frontmatter',
393
+ 'but does not replace reading the selected SKILL.md.',
394
+ ].join(' '),
395
+ 'Command execution authority still comes only from .mustflow/config/commands.toml.',
396
+ ],
397
+ };
398
+ }