mustflow 2.84.0 → 2.85.4

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 (48) hide show
  1. package/README.md +11 -2
  2. package/dist/cli/commands/script-pack.js +4 -0
  3. package/dist/cli/i18n/en.js +79 -0
  4. package/dist/cli/i18n/es.js +79 -0
  5. package/dist/cli/i18n/fr.js +79 -0
  6. package/dist/cli/i18n/hi.js +79 -0
  7. package/dist/cli/i18n/ko.js +79 -0
  8. package/dist/cli/i18n/zh.js +79 -0
  9. package/dist/cli/lib/repo-map.js +27 -6
  10. package/dist/cli/lib/run-root-trust.js +15 -1
  11. package/dist/cli/lib/script-pack-registry.js +114 -0
  12. package/dist/cli/lib/validation/index.js +2 -2
  13. package/dist/cli/lib/validation/primitives.js +4 -1
  14. package/dist/cli/script-packs/code-change-impact.js +172 -0
  15. package/dist/cli/script-packs/code-dependency-graph.js +181 -0
  16. package/dist/cli/script-packs/repo-env-contract.js +156 -0
  17. package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
  18. package/dist/core/change-impact.js +383 -0
  19. package/dist/core/change-verification.js +32 -5
  20. package/dist/core/config-loading.js +121 -4
  21. package/dist/core/dependency-graph.js +490 -0
  22. package/dist/core/env-contract.js +450 -0
  23. package/dist/core/line-endings.js +26 -13
  24. package/dist/core/public-json-contracts.js +51 -0
  25. package/dist/core/route-outline.js +57 -5
  26. package/dist/core/script-pack-suggestions.js +21 -1
  27. package/dist/core/secret-risk-scan.js +440 -0
  28. package/package.json +1 -1
  29. package/schemas/README.md +16 -0
  30. package/schemas/change-impact-report.schema.json +150 -0
  31. package/schemas/commands.schema.json +12 -0
  32. package/schemas/dependency-graph-report.schema.json +149 -0
  33. package/schemas/env-contract-report.schema.json +203 -0
  34. package/schemas/secret-risk-scan-report.schema.json +152 -0
  35. package/templates/default/i18n.toml +16 -10
  36. package/templates/default/locales/en/.mustflow/skills/INDEX.md +7 -2
  37. package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
  38. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
  39. package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +23 -8
  40. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
  41. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
  42. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
  43. package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
  44. package/templates/default/locales/en/.mustflow/skills/routes.toml +15 -9
  45. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
  46. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +314 -0
  47. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +13 -10
  48. package/templates/default/manifest.toml +8 -1
@@ -0,0 +1,383 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { inspectDependencyGraph } from './dependency-graph.js';
6
+ export const CHANGE_IMPACT_PACK_ID = 'code';
7
+ export const CHANGE_IMPACT_SCRIPT_ID = 'change-impact';
8
+ export const CHANGE_IMPACT_SCRIPT_REF = `${CHANGE_IMPACT_PACK_ID}/${CHANGE_IMPACT_SCRIPT_ID}`;
9
+ const DEFAULT_BASE_REF = 'HEAD';
10
+ const DEFAULT_MAX_FILES = 200;
11
+ const DEFAULT_MAX_IMPACTS = 300;
12
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
13
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
14
+ function toPosixPath(value) {
15
+ return value.replace(/\\/gu, '/');
16
+ }
17
+ function normalizeRelativePath(value) {
18
+ return toPosixPath(value).replace(/^\.\/+/u, '') || '.';
19
+ }
20
+ function sha256Tagged(value) {
21
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
22
+ }
23
+ function runGit(root, args) {
24
+ const result = spawnSync('git', [...args], {
25
+ cwd: root,
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ windowsHide: true,
29
+ maxBuffer: 16 * 1024 * 1024,
30
+ });
31
+ return {
32
+ ok: result.status === 0,
33
+ stdout: result.stdout ?? '',
34
+ stderr: result.stderr ?? '',
35
+ status: result.status,
36
+ };
37
+ }
38
+ function isInsideGitWorktree(root) {
39
+ const result = runGit(root, ['rev-parse', '--is-inside-work-tree']);
40
+ return result.ok && result.stdout.trim() === 'true';
41
+ }
42
+ function makeFinding(code, severity, pathValue, message) {
43
+ return { code, severity, path: pathValue, message };
44
+ }
45
+ function surfaceForPath(relativePath) {
46
+ const normalized = normalizeRelativePath(relativePath);
47
+ const extension = path.extname(normalized).toLowerCase();
48
+ const basename = path.basename(normalized).toLowerCase();
49
+ if (normalized.startsWith('.mustflow/')) {
50
+ return 'workflow';
51
+ }
52
+ if (normalized.startsWith('templates/')) {
53
+ return 'template';
54
+ }
55
+ if (normalized.startsWith('schemas/') || basename.endsWith('.schema.json')) {
56
+ return 'schema';
57
+ }
58
+ if (normalized.startsWith('docs') || ['.md', '.mdx'].includes(extension)) {
59
+ return 'docs';
60
+ }
61
+ if (normalized.includes('/i18n/') || normalized.includes('/locales/') || basename.includes('i18n')) {
62
+ return 'i18n';
63
+ }
64
+ if (['package.json', 'bun.lock', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'].includes(normalized)) {
65
+ return 'package';
66
+ }
67
+ if (/^(?:tsconfig|eslint|vite|vitest|jest|playwright|astro|svelte|tailwind)\b/u.test(basename)) {
68
+ return 'config';
69
+ }
70
+ if (normalized.startsWith('tests/') || /\.test\.[cm]?[jt]sx?$/u.test(normalized)) {
71
+ return 'test';
72
+ }
73
+ if (SOURCE_EXTENSIONS.includes(extension)) {
74
+ return 'source';
75
+ }
76
+ return 'unknown';
77
+ }
78
+ function statusFromCode(code) {
79
+ if (code.startsWith('A')) {
80
+ return 'added';
81
+ }
82
+ if (code.startsWith('D')) {
83
+ return 'deleted';
84
+ }
85
+ if (code.startsWith('R')) {
86
+ return 'renamed';
87
+ }
88
+ if (code.startsWith('T')) {
89
+ return 'type_changed';
90
+ }
91
+ return 'modified';
92
+ }
93
+ function parseNameStatus(stdout) {
94
+ const files = [];
95
+ for (const line of stdout.split(/\r?\n/u)) {
96
+ if (!line.trim()) {
97
+ continue;
98
+ }
99
+ const [statusCode = 'M', firstPath = '', secondPath = ''] = line.split('\t');
100
+ const currentPath = normalizeRelativePath(secondPath || firstPath);
101
+ const previousPath = secondPath ? normalizeRelativePath(firstPath) : null;
102
+ files.push({
103
+ path: currentPath,
104
+ previous_path: previousPath,
105
+ status: statusFromCode(statusCode),
106
+ surface: surfaceForPath(currentPath),
107
+ });
108
+ }
109
+ return files;
110
+ }
111
+ function addUntrackedFiles(root, paths, files) {
112
+ const untracked = runGit(root, ['ls-files', '--others', '--exclude-standard', '--', ...paths]);
113
+ if (!untracked.ok) {
114
+ return;
115
+ }
116
+ const known = new Set(files.map((file) => file.path));
117
+ for (const line of untracked.stdout.split(/\r?\n/u)) {
118
+ const relativePath = normalizeRelativePath(line.trim());
119
+ if (!relativePath || relativePath === '.' || known.has(relativePath)) {
120
+ continue;
121
+ }
122
+ files.push({
123
+ path: relativePath,
124
+ previous_path: null,
125
+ status: 'untracked',
126
+ surface: surfaceForPath(relativePath),
127
+ });
128
+ }
129
+ }
130
+ function addImpact(root, impacts, candidate, policy, findings, issues) {
131
+ const normalized = normalizeRelativePath(candidate.path);
132
+ const key = `${candidate.relationship}:${candidate.source_path}:${normalized}`;
133
+ if (impacts.has(key)) {
134
+ return;
135
+ }
136
+ if (impacts.size >= policy.max_impacts) {
137
+ const message = `Change impact found more than ${policy.max_impacts} candidates; remaining candidates were skipped.`;
138
+ findings.push(makeFinding('change_impact_max_impacts_exceeded', 'high', normalized, message));
139
+ issues.push(message);
140
+ return;
141
+ }
142
+ impacts.set(key, {
143
+ ...candidate,
144
+ path: normalized,
145
+ exists: existsSync(path.join(root, ...normalized.split('/'))),
146
+ surface: surfaceForPath(normalized),
147
+ });
148
+ }
149
+ function siblingBase(relativePath) {
150
+ const directory = path.posix.dirname(normalizeRelativePath(relativePath));
151
+ const parsed = path.posix.parse(normalizeRelativePath(relativePath));
152
+ return directory === '.' ? parsed.name : `${directory}/${parsed.name}`;
153
+ }
154
+ function testCandidatesFor(relativePath) {
155
+ const base = siblingBase(relativePath);
156
+ const name = path.posix.basename(base);
157
+ return [
158
+ `${base}.test.ts`,
159
+ `${base}.test.js`,
160
+ `${base}.spec.ts`,
161
+ `${base}.spec.js`,
162
+ `tests/${name}.test.js`,
163
+ `tests/${name}.test.ts`,
164
+ `tests/cli/${name}.test.js`,
165
+ ];
166
+ }
167
+ function sourceCandidatesFor(relativePath) {
168
+ const normalized = normalizeRelativePath(relativePath);
169
+ return [
170
+ normalized.replace(/^tests\/cli\//u, 'src/cli/').replace(/\.test\.[cm]?[jt]sx?$/u, '.ts'),
171
+ normalized.replace(/^tests\//u, 'src/').replace(/\.test\.[cm]?[jt]sx?$/u, '.ts'),
172
+ ];
173
+ }
174
+ function addSurfaceImpacts(root, changedFile, impacts, policy, findings, issues) {
175
+ addImpact(root, impacts, {
176
+ path: changedFile.path,
177
+ relationship: 'changed_file',
178
+ confidence: 1,
179
+ reason: 'Changed file should be reviewed directly.',
180
+ source_path: changedFile.path,
181
+ }, policy, findings, issues);
182
+ if (changedFile.surface === 'source') {
183
+ for (const candidatePath of testCandidatesFor(changedFile.path)) {
184
+ addImpact(root, impacts, {
185
+ path: candidatePath,
186
+ relationship: 'sibling_test',
187
+ confidence: 0.65,
188
+ reason: 'Nearby test candidate for the changed source path.',
189
+ source_path: changedFile.path,
190
+ }, policy, findings, issues);
191
+ }
192
+ }
193
+ if (changedFile.surface === 'test') {
194
+ for (const candidatePath of sourceCandidatesFor(changedFile.path)) {
195
+ addImpact(root, impacts, {
196
+ path: candidatePath,
197
+ relationship: 'sibling_source',
198
+ confidence: 0.55,
199
+ reason: 'Likely source file covered by the changed test path.',
200
+ source_path: changedFile.path,
201
+ }, policy, findings, issues);
202
+ }
203
+ }
204
+ }
205
+ function addContractImpacts(root, changedFile, impacts, policy, findings, issues) {
206
+ const contractPaths = changedFile.surface === 'schema'
207
+ ? ['schemas/README.md', 'src/core/public-json-contracts.ts', 'tests/cli/schema.test.js']
208
+ : changedFile.surface === 'package' || changedFile.surface === 'template'
209
+ ? ['tests/cli/package.test.js', 'templates/default/manifest.toml']
210
+ : changedFile.surface === 'workflow'
211
+ ? ['.mustflow/config/commands.toml', 'tests/cli/authoring-skill-contracts.test.js']
212
+ : [];
213
+ for (const candidatePath of contractPaths) {
214
+ addImpact(root, impacts, {
215
+ path: candidatePath,
216
+ relationship: changedFile.surface === 'workflow' ? 'workflow_surface' : 'contract_test',
217
+ confidence: 0.7,
218
+ reason: `Contract surface related to changed ${changedFile.surface} file.`,
219
+ source_path: changedFile.path,
220
+ }, policy, findings, issues);
221
+ }
222
+ }
223
+ function addDependencyImpacts(root, changedFiles, impacts, policy, findings, issues) {
224
+ const sourcePaths = changedFiles
225
+ .filter((file) => file.surface === 'source' && file.status !== 'deleted')
226
+ .map((file) => file.path);
227
+ if (sourcePaths.length === 0) {
228
+ return;
229
+ }
230
+ const dependencyReport = inspectDependencyGraph(root, {
231
+ paths: sourcePaths,
232
+ maxDepth: 1,
233
+ maxFiles: policy.max_files,
234
+ maxFileBytes: policy.max_file_bytes,
235
+ maxNodes: policy.max_impacts,
236
+ maxEdges: policy.max_impacts,
237
+ });
238
+ if (!dependencyReport.ok) {
239
+ for (const issue of dependencyReport.issues) {
240
+ issues.push(`dependency-graph: ${issue}`);
241
+ }
242
+ }
243
+ const changedPathSet = new Set(sourcePaths);
244
+ for (const edge of dependencyReport.edges) {
245
+ if (!changedPathSet.has(edge.target_path) || changedPathSet.has(edge.source_path)) {
246
+ continue;
247
+ }
248
+ addImpact(root, impacts, {
249
+ path: edge.source_path,
250
+ relationship: 'imports_changed_file',
251
+ confidence: 0.8,
252
+ reason: `Imports changed file ${edge.target_path} via ${edge.specifier}.`,
253
+ source_path: edge.target_path,
254
+ }, policy, findings, issues);
255
+ }
256
+ }
257
+ function createScriptHints(changedFiles) {
258
+ const sourcePaths = changedFiles.filter((file) => file.surface === 'source').map((file) => file.path);
259
+ const hasDocsOrSchema = changedFiles.some((file) => file.surface === 'docs' || file.surface === 'schema');
260
+ const hints = [];
261
+ if (sourcePaths.length > 0) {
262
+ const pathPart = sourcePaths.map((entry) => JSON.stringify(entry)).join(' ');
263
+ hints.push({
264
+ script_ref: 'code/dependency-graph',
265
+ command: `mf script-pack run code/dependency-graph scan ${pathPart} --json`,
266
+ reason: 'Trace importers and direct dependencies for changed source files.',
267
+ confidence: 0.85,
268
+ });
269
+ hints.push({
270
+ script_ref: 'repo/related-files',
271
+ command: `mf script-pack run repo/related-files map ${pathPart} --json`,
272
+ reason: 'Map nearby source, tests, docs, and config files for changed source paths.',
273
+ confidence: 0.75,
274
+ });
275
+ }
276
+ if (hasDocsOrSchema) {
277
+ hints.push({
278
+ script_ref: 'docs/reference-drift',
279
+ command: 'mf script-pack run docs/reference-drift check README.md schemas/README.md --json',
280
+ reason: 'Check documented command, schema, path, and script-pack references after docs or schema changes.',
281
+ confidence: 0.7,
282
+ });
283
+ }
284
+ return hints;
285
+ }
286
+ function createVerificationHints(changedFiles) {
287
+ const surfaces = new Set(changedFiles.map((file) => file.surface));
288
+ const hints = [];
289
+ const add = (intent, reason, confidence) => {
290
+ if (!hints.some((hint) => hint.intent === intent)) {
291
+ hints.push({ intent, reason, confidence });
292
+ }
293
+ };
294
+ if (surfaces.has('source') || surfaces.has('test')) {
295
+ add('test_related', 'Changed source or tests need related behavior coverage.', 0.85);
296
+ add('build', 'Changed TypeScript or JavaScript source may affect compiled CLI output.', 0.7);
297
+ }
298
+ if (surfaces.has('schema') || surfaces.has('package') || surfaces.has('template')) {
299
+ add('test_release', 'Package, template, or public schema surfaces changed.', 0.85);
300
+ }
301
+ if (surfaces.has('docs') || surfaces.has('schema') || surfaces.has('workflow')) {
302
+ add('docs_validate_fast', 'Docs, schema docs, or workflow text changed.', 0.75);
303
+ }
304
+ if (surfaces.has('workflow') || surfaces.has('template')) {
305
+ add('mustflow_check', 'Workflow or install template surfaces need strict mustflow validation.', 0.8);
306
+ }
307
+ return hints;
308
+ }
309
+ function collectChangedFiles(root, policy, findings, issues) {
310
+ if (!isInsideGitWorktree(root)) {
311
+ const message = 'Git worktree is unavailable; change-impact returned an empty impact set.';
312
+ findings.push(makeFinding('change_impact_git_unavailable', 'low', '.', message));
313
+ issues.push(message);
314
+ return [];
315
+ }
316
+ const diffArgs = policy.head_ref
317
+ ? ['diff', '--name-status', '--diff-filter=ACMRTD', policy.base_ref, policy.head_ref, '--', ...policy.path_filters]
318
+ : ['diff', '--name-status', '--diff-filter=ACMRTD', policy.base_ref, '--', ...policy.path_filters];
319
+ const diff = runGit(root, diffArgs);
320
+ if (!diff.ok) {
321
+ const detail = diff.stderr.trim() || diff.stdout.trim() || `git diff exited with ${diff.status}`;
322
+ findings.push(makeFinding('change_impact_invalid_ref', 'high', '.', detail));
323
+ issues.push(detail);
324
+ return [];
325
+ }
326
+ const files = parseNameStatus(diff.stdout);
327
+ if (!policy.head_ref) {
328
+ addUntrackedFiles(root, policy.path_filters, files);
329
+ }
330
+ files.sort((left, right) => left.path.localeCompare(right.path));
331
+ if (files.length > policy.max_files) {
332
+ const message = `Change impact matched ${files.length} files; max_files is ${policy.max_files}.`;
333
+ findings.push(makeFinding('change_impact_max_files_exceeded', 'high', '.', message));
334
+ issues.push(message);
335
+ }
336
+ return files.slice(0, policy.max_files);
337
+ }
338
+ export function inspectChangeImpact(projectRoot, options = {}) {
339
+ const root = path.resolve(projectRoot);
340
+ const policy = {
341
+ base_ref: options.baseRef ?? DEFAULT_BASE_REF,
342
+ head_ref: options.headRef ?? null,
343
+ compare_worktree: options.headRef === undefined || options.headRef === null,
344
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
345
+ max_impacts: options.maxImpacts ?? DEFAULT_MAX_IMPACTS,
346
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
347
+ path_filters: (options.paths ?? []).map(normalizeRelativePath),
348
+ };
349
+ const findings = [];
350
+ const issues = [];
351
+ const changedFiles = collectChangedFiles(root, policy, findings, issues);
352
+ const impacts = new Map();
353
+ for (const changedFile of changedFiles) {
354
+ addSurfaceImpacts(root, changedFile, impacts, policy, findings, issues);
355
+ addContractImpacts(root, changedFile, impacts, policy, findings, issues);
356
+ }
357
+ addDependencyImpacts(root, changedFiles, impacts, policy, findings, issues);
358
+ const status = findings.some((finding) => finding.severity === 'high' || finding.severity === 'critical')
359
+ ? 'failed'
360
+ : 'passed';
361
+ const inputHash = sha256Tagged(JSON.stringify({ policy, changedFiles }));
362
+ return {
363
+ schema_version: '1',
364
+ command: 'script-pack',
365
+ pack_id: CHANGE_IMPACT_PACK_ID,
366
+ script_id: CHANGE_IMPACT_SCRIPT_ID,
367
+ script_ref: CHANGE_IMPACT_SCRIPT_REF,
368
+ action: 'analyze',
369
+ status,
370
+ ok: status === 'passed',
371
+ mustflow_root: root,
372
+ policy,
373
+ input_hash: inputHash,
374
+ changed_files: changedFiles,
375
+ impacts: [...impacts.values()].sort((left, right) => right.confidence - left.confidence || left.path.localeCompare(right.path)),
376
+ script_hints: createScriptHints(changedFiles),
377
+ verification_hints: createVerificationHints(changedFiles),
378
+ truncated: findings.some((finding) => finding.code === 'change_impact_max_files_exceeded' ||
379
+ finding.code === 'change_impact_max_impacts_exceeded'),
380
+ findings,
381
+ issues,
382
+ };
383
+ }
@@ -1,6 +1,7 @@
1
1
  import { isRecord, readStringArray } from './config-loading.js';
2
2
  import { CHANGE_CLASSIFICATION_SURFACE_AUTHORITY, createPathTarget, } from './surface-decision-model.js';
3
3
  import { classifyVerificationCandidate, createVerificationPlan, } from './verification-plan.js';
4
+ import { evaluateCommandPreconditions } from './command-preconditions.js';
4
5
  import { createVerificationDecisionGraph, } from './verification-decision-graph.js';
5
6
  import { createVerificationSchedule, } from './verification-scheduler.js';
6
7
  import { createProjectTestSelectionPlan, } from './test-selection.js';
@@ -83,6 +84,22 @@ function readIntentCostExpectedSeconds(commandContract, intent) {
83
84
  const expectedSeconds = rawIntent.cost.expected_seconds;
84
85
  return Number.isInteger(expectedSeconds) && Number(expectedSeconds) >= 0 ? Number(expectedSeconds) : null;
85
86
  }
87
+ function intentHasPreconditions(commandContract, intent) {
88
+ const rawIntent = commandContract.intents[intent];
89
+ return isRecord(rawIntent) && Array.isArray(rawIntent.preconditions) && rawIntent.preconditions.length > 0;
90
+ }
91
+ function intentPreconditionsSatisfied(commandContract, projectRoot, intent, cache) {
92
+ if (!intentHasPreconditions(commandContract, intent)) {
93
+ return true;
94
+ }
95
+ const cached = cache.get(intent);
96
+ if (cached !== undefined) {
97
+ return cached;
98
+ }
99
+ const satisfied = evaluateCommandPreconditions(projectRoot, commandContract, intent).every((precondition) => precondition.status === 'satisfied');
100
+ cache.set(intent, satisfied);
101
+ return satisfied;
102
+ }
86
103
  function intentCoverageSignature(commandContract, intent) {
87
104
  const rawIntent = commandContract.intents[intent];
88
105
  if (!isRecord(rawIntent) || !isRecord(rawIntent.covers)) {
@@ -202,9 +219,10 @@ function minNumber(values) {
202
219
  }
203
220
  return minimum;
204
221
  }
205
- function selectVerificationCandidates(commandContract, candidates) {
222
+ function selectVerificationCandidates(commandContract, projectRoot, candidates) {
206
223
  const runnableCandidates = candidates.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
207
224
  const selectedIntents = new Set(runnableCandidates.map((candidate) => candidate.intent));
225
+ const preconditionCache = new Map();
208
226
  for (const candidate of runnableCandidates) {
209
227
  const isSubsumed = runnableCandidates.some((other) => {
210
228
  if (other.intent === candidate.intent) {
@@ -233,7 +251,16 @@ function selectVerificationCandidates(commandContract, candidates) {
233
251
  if (group.length < 2) {
234
252
  continue;
235
253
  }
236
- const costs = group.map((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent));
254
+ const preconditionSatisfiedGroup = group.filter((candidate) => intentPreconditionsSatisfied(commandContract, projectRoot, candidate.intent, preconditionCache));
255
+ const selectableGroup = preconditionSatisfiedGroup.length > 0 ? preconditionSatisfiedGroup : group;
256
+ if (preconditionSatisfiedGroup.length > 0 && preconditionSatisfiedGroup.length < group.length) {
257
+ for (const candidate of group) {
258
+ if (!preconditionSatisfiedGroup.some((entry) => entry.intent === candidate.intent)) {
259
+ selectedIntents.delete(candidate.intent);
260
+ }
261
+ }
262
+ }
263
+ const costs = selectableGroup.map((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent));
237
264
  if (costs.some((cost) => cost === null)) {
238
265
  continue;
239
266
  }
@@ -241,11 +268,11 @@ function selectVerificationCandidates(commandContract, candidates) {
241
268
  if (minCost === null) {
242
269
  continue;
243
270
  }
244
- const winners = group.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
271
+ const winners = selectableGroup.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
245
272
  if (winners.length !== 1) {
246
273
  continue;
247
274
  }
248
- for (const candidate of group) {
275
+ for (const candidate of selectableGroup) {
249
276
  if (candidate.intent !== winners[0]?.intent) {
250
277
  selectedIntents.delete(candidate.intent);
251
278
  }
@@ -316,7 +343,7 @@ export function createChangeVerificationReport(classificationReport, commandCont
316
343
  const selectedPlans = plansWithProjectTestSelection.map((plan) => ({
317
344
  ...plan,
318
345
  selectedCandidates: uniqueVerificationCandidates([
319
- ...selectVerificationCandidates(commandContract, plan.candidates),
346
+ ...selectVerificationCandidates(commandContract, projectRoot, plan.candidates),
320
347
  ...testSelectionPlan.selectedCandidates
321
348
  .filter((candidate) => candidate.reason === plan.requirement.reason)
322
349
  .map((candidate) => candidate.candidate),
@@ -6,10 +6,17 @@ export const COMMAND_LIFECYCLES = new Set(['oneshot', 'server', 'watch', 'intera
6
6
  export const LONG_RUNNING_LIFECYCLES = new Set(['server', 'watch', 'interactive', 'browser', 'background']);
7
7
  export const COMMAND_RUN_POLICIES = new Set(['agent_allowed', 'requires_explicit_user_request', 'manual_only']);
8
8
  export const COMMANDS_CONFIG_RELATIVE_PATH = '.mustflow/config/commands.toml';
9
+ export const COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH = '.mustflow/config';
9
10
  export const MUSTFLOW_CONFIG_RELATIVE_PATH = '.mustflow/config/mustflow.toml';
11
+ const COMMAND_INCLUDE_DIRECTORY = 'commands';
12
+ const COMMAND_INCLUDE_ALLOWED_TOP_LEVEL_KEYS = new Set(['intents', 'resources']);
13
+ const WINDOWS_RESERVED_PATH_SEGMENTS = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu;
10
14
  export function isRecord(value) {
11
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
16
  }
17
+ function hasOwn(table, key) {
18
+ return Object.prototype.hasOwnProperty.call(table, key);
19
+ }
13
20
  export function resolveMustflowConfigPath(projectRoot, relativePath) {
14
21
  return path.join(projectRoot, ...relativePath.split('/'));
15
22
  }
@@ -18,6 +25,119 @@ export function readMustflowOwnedTomlFile(projectRoot, relativePath) {
18
25
  maxBytes: 256 * 1024,
19
26
  }));
20
27
  }
28
+ function normalizeCommandIncludePath(rawPath) {
29
+ return rawPath.trim().replace(/\\/gu, '/');
30
+ }
31
+ function commandIncludePathIsUnsafe(rawPath) {
32
+ const normalized = normalizeCommandIncludePath(rawPath);
33
+ const segments = normalized.split('/').filter((segment) => segment.length > 0);
34
+ return (normalized.length === 0 ||
35
+ normalized.includes('\0') ||
36
+ normalized.startsWith('/') ||
37
+ path.win32.isAbsolute(rawPath) ||
38
+ path.posix.isAbsolute(normalized) ||
39
+ segments.some((segment) => segment === '.' || segment === '..' || WINDOWS_RESERVED_PATH_SEGMENTS.test(segment)) ||
40
+ !normalized.startsWith(`${COMMAND_INCLUDE_DIRECTORY}/`) ||
41
+ !normalized.endsWith('.toml'));
42
+ }
43
+ function readCommandIncludePathsFromParsed(root) {
44
+ if (!hasOwn(root, 'include')) {
45
+ return [];
46
+ }
47
+ if (!isRecord(root.include)) {
48
+ throw new Error(`[include] in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a TOML table`);
49
+ }
50
+ const files = root.include.files;
51
+ if (!Array.isArray(files) || files.some((entry) => typeof entry !== 'string' || entry.trim().length === 0)) {
52
+ throw new Error(`[include].files in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a string array`);
53
+ }
54
+ const normalizedPaths = [];
55
+ const seen = new Set();
56
+ for (const file of files) {
57
+ const normalized = normalizeCommandIncludePath(file);
58
+ if (commandIncludePathIsUnsafe(file)) {
59
+ throw new Error(`Command include path "${file}" must be a relative ${COMMAND_INCLUDE_DIRECTORY}/*.toml path under ${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}`);
60
+ }
61
+ if (seen.has(normalized)) {
62
+ throw new Error(`Duplicate command include path "${normalized}" in ${COMMANDS_CONFIG_RELATIVE_PATH}`);
63
+ }
64
+ seen.add(normalized);
65
+ normalizedPaths.push(normalized);
66
+ }
67
+ return normalizedPaths;
68
+ }
69
+ export function readCommandContractIncludePaths(projectRoot) {
70
+ const parsed = readMustflowOwnedTomlFile(projectRoot, COMMANDS_CONFIG_RELATIVE_PATH);
71
+ if (!isRecord(parsed)) {
72
+ throw new Error(`${COMMANDS_CONFIG_RELATIVE_PATH} must contain a TOML table`);
73
+ }
74
+ return readCommandIncludePathsFromParsed(parsed).map((includePath) => `${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath}`);
75
+ }
76
+ function assertCommandIncludeTable(includePath, parsed) {
77
+ if (!isRecord(parsed)) {
78
+ throw new Error(`${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath} must contain a TOML table`);
79
+ }
80
+ for (const key of Object.keys(parsed)) {
81
+ if (!COMMAND_INCLUDE_ALLOWED_TOP_LEVEL_KEYS.has(key)) {
82
+ throw new Error(`${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath} may define only [intents] and [resources] tables`);
83
+ }
84
+ }
85
+ return parsed;
86
+ }
87
+ function mergeCommandSection(target, source, sectionName, sourceLabel, seenSources) {
88
+ if (source === undefined) {
89
+ return;
90
+ }
91
+ if (!isRecord(source)) {
92
+ throw new Error(`${sourceLabel} [${sectionName}] must be a TOML table`);
93
+ }
94
+ for (const [name, value] of Object.entries(source)) {
95
+ const existingSource = seenSources.get(name);
96
+ if (existingSource) {
97
+ throw new Error(`Duplicate command ${sectionName.slice(0, -1)} "${name}" in ${sourceLabel}; already defined in ${existingSource}`);
98
+ }
99
+ target[name] = value;
100
+ seenSources.set(name, sourceLabel);
101
+ }
102
+ }
103
+ export function readResolvedCommandContractToml(projectRoot) {
104
+ const parsed = readMustflowOwnedTomlFile(projectRoot, COMMANDS_CONFIG_RELATIVE_PATH);
105
+ if (!isRecord(parsed)) {
106
+ throw new Error(`${COMMANDS_CONFIG_RELATIVE_PATH} must contain a TOML table`);
107
+ }
108
+ if (hasOwn(parsed, 'intents') && !isRecord(parsed.intents)) {
109
+ throw new Error(`[intents] table in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a TOML table`);
110
+ }
111
+ if (hasOwn(parsed, 'resources') && !isRecord(parsed.resources)) {
112
+ throw new Error(`[resources] table in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a TOML table`);
113
+ }
114
+ const merged = { ...parsed };
115
+ const mergedIntents = {};
116
+ const mergedResources = {};
117
+ const intentSources = new Map();
118
+ const resourceSources = new Map();
119
+ const includePaths = readCommandIncludePathsFromParsed(parsed);
120
+ let sawIntentTable = hasOwn(parsed, 'intents');
121
+ mergeCommandSection(mergedResources, parsed.resources, 'resources', COMMANDS_CONFIG_RELATIVE_PATH, resourceSources);
122
+ mergeCommandSection(mergedIntents, parsed.intents, 'intents', COMMANDS_CONFIG_RELATIVE_PATH, intentSources);
123
+ for (const includePath of includePaths) {
124
+ const includeRelativePath = `${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath}`;
125
+ const includeTable = assertCommandIncludeTable(includePath, readMustflowOwnedTomlFile(projectRoot, includeRelativePath));
126
+ sawIntentTable = sawIntentTable || hasOwn(includeTable, 'intents');
127
+ mergeCommandSection(mergedResources, includeTable.resources, 'resources', includeRelativePath, resourceSources);
128
+ mergeCommandSection(mergedIntents, includeTable.intents, 'intents', includeRelativePath, intentSources);
129
+ }
130
+ if (Object.keys(mergedResources).length > 0) {
131
+ merged.resources = mergedResources;
132
+ }
133
+ if (sawIntentTable) {
134
+ merged.intents = mergedIntents;
135
+ }
136
+ else {
137
+ delete merged.intents;
138
+ }
139
+ return merged;
140
+ }
21
141
  export function readMustflowConfig(projectRoot) {
22
142
  const parsed = readMustflowOwnedTomlFile(projectRoot, MUSTFLOW_CONFIG_RELATIVE_PATH);
23
143
  if (!isRecord(parsed)) {
@@ -30,10 +150,7 @@ export function readMustflowConfigIfExists(projectRoot) {
30
150
  return existsSync(configPath) ? readMustflowConfig(projectRoot) : undefined;
31
151
  }
32
152
  export function readCommandContract(projectRoot) {
33
- const parsed = readMustflowOwnedTomlFile(projectRoot, COMMANDS_CONFIG_RELATIVE_PATH);
34
- if (!isRecord(parsed)) {
35
- throw new Error(`${COMMANDS_CONFIG_RELATIVE_PATH} must contain a TOML table`);
36
- }
153
+ const parsed = readResolvedCommandContractToml(projectRoot);
37
154
  if (!isRecord(parsed.intents)) {
38
155
  throw new Error(`Missing [intents] table in ${COMMANDS_CONFIG_RELATIVE_PATH}`);
39
156
  }