mustflow 2.22.13 → 2.22.14
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 +1 -1
- package/dist/cli/commands/explain-verify.js +8 -1
- package/dist/cli/commands/explain.js +269 -2
- package/dist/cli/commands/run.js +109 -77
- package/dist/cli/commands/verify.js +16 -1
- package/dist/cli/lib/run-plan.js +31 -5
- package/dist/core/active-run-locks.js +294 -0
- package/dist/core/check-issues.js +8 -0
- package/dist/core/command-contract-validation.js +179 -2
- package/dist/core/command-explanation.js +5 -3
- package/dist/core/command-preconditions.js +261 -0
- package/dist/core/skill-route-explanation.js +115 -9
- package/package.json +1 -1
- package/schemas/README.md +6 -3
- package/schemas/change-verification-report.schema.json +52 -0
- package/schemas/commands.schema.json +41 -0
- package/schemas/explain-report.schema.json +152 -4
- package/templates/default/manifest.toml +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord, readPositiveInteger, readString, readStringArray, } from './config-loading.js';
|
|
2
|
+
import { evaluateCommandPreconditions, } from './command-preconditions.js';
|
|
2
3
|
const COMMAND_CONTRACT_SOURCE_FILES = [
|
|
3
4
|
'AGENTS.md',
|
|
4
5
|
'.mustflow/docs/agent-workflow.md',
|
|
@@ -36,7 +37,7 @@ function resolveCommandMode(intent) {
|
|
|
36
37
|
}
|
|
37
38
|
return 'missing';
|
|
38
39
|
}
|
|
39
|
-
function summarizeIntent(name, intent) {
|
|
40
|
+
function summarizeIntent(name, intent, contract, projectRoot) {
|
|
40
41
|
return {
|
|
41
42
|
name,
|
|
42
43
|
status: readString(intent, 'status') ?? null,
|
|
@@ -51,6 +52,7 @@ function summarizeIntent(name, intent) {
|
|
|
51
52
|
destructive: readOptionalBoolean(intent, 'destructive'),
|
|
52
53
|
successExitCodes: readOptionalIntegerArray(intent, 'success_exit_codes'),
|
|
53
54
|
requiredAfter: readOptionalStringArray(intent, 'required_after'),
|
|
55
|
+
preconditions: projectRoot ? evaluateCommandPreconditions(projectRoot, contract, name) : [],
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
function collectBlockingReasons(summary) {
|
|
@@ -75,7 +77,7 @@ function collectBlockingReasons(summary) {
|
|
|
75
77
|
}
|
|
76
78
|
return reasons;
|
|
77
79
|
}
|
|
78
|
-
export function explainCommandIntent(contract, commandName) {
|
|
80
|
+
export function explainCommandIntent(contract, commandName, options = {}) {
|
|
79
81
|
const intentCandidate = contract.intents[commandName];
|
|
80
82
|
if (!isRecord(intentCandidate)) {
|
|
81
83
|
return {
|
|
@@ -89,7 +91,7 @@ export function explainCommandIntent(contract, commandName) {
|
|
|
89
91
|
intent: null,
|
|
90
92
|
};
|
|
91
93
|
}
|
|
92
|
-
const intent = summarizeIntent(commandName, intentCandidate);
|
|
94
|
+
const intent = summarizeIntent(commandName, intentCandidate, contract, options.projectRoot);
|
|
93
95
|
const blockingReasons = collectBlockingReasons(intent);
|
|
94
96
|
if (blockingReasons.length === 0) {
|
|
95
97
|
return {
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isRecord, readString, readStringArray, } from './config-loading.js';
|
|
4
|
+
import { evaluateCommandIntentEligibility } from './command-intent-eligibility.js';
|
|
5
|
+
export const COMMAND_PRECONDITION_KINDS = new Set(['path_exists', 'artifact_freshness']);
|
|
6
|
+
const IGNORED_WALK_DIRECTORIES = new Set([
|
|
7
|
+
'.git',
|
|
8
|
+
'node_modules',
|
|
9
|
+
'dist',
|
|
10
|
+
'coverage',
|
|
11
|
+
'.next',
|
|
12
|
+
'.turbo',
|
|
13
|
+
'.mustflow/state',
|
|
14
|
+
'.mustflow/cache',
|
|
15
|
+
]);
|
|
16
|
+
function normalizeRelativePath(value) {
|
|
17
|
+
return value.trim().replace(/\\/gu, '/').replace(/^\.\/+/u, '').replace(/\/+$/u, '') || '.';
|
|
18
|
+
}
|
|
19
|
+
function relativePathIsUnsafe(value) {
|
|
20
|
+
const normalized = normalizeRelativePath(value);
|
|
21
|
+
const segments = normalized.split('/').filter((segment) => segment.length > 0);
|
|
22
|
+
return (normalized.length === 0 ||
|
|
23
|
+
normalized.includes('\0') ||
|
|
24
|
+
normalized.startsWith('/') ||
|
|
25
|
+
path.win32.isAbsolute(value) ||
|
|
26
|
+
path.posix.isAbsolute(value) ||
|
|
27
|
+
segments.some((segment) => segment === '.' || segment === '..'));
|
|
28
|
+
}
|
|
29
|
+
function resolveProjectPath(projectRoot, relativePath) {
|
|
30
|
+
if (relativePathIsUnsafe(relativePath)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const resolved = path.resolve(projectRoot, ...normalizeRelativePath(relativePath).split('/'));
|
|
34
|
+
const relative = path.relative(path.resolve(projectRoot), resolved);
|
|
35
|
+
return relative.startsWith('..') || path.isAbsolute(relative) ? null : resolved;
|
|
36
|
+
}
|
|
37
|
+
function readPreconditionDeclarations(intent) {
|
|
38
|
+
if (!Array.isArray(intent.preconditions)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
return intent.preconditions.filter(isRecord).map((precondition) => ({
|
|
42
|
+
kind: readString(precondition, 'kind') ?? '',
|
|
43
|
+
label: readString(precondition, 'label') ?? null,
|
|
44
|
+
path: readString(precondition, 'path') ? normalizeRelativePath(readString(precondition, 'path')) : null,
|
|
45
|
+
artifact: readString(precondition, 'artifact') ? normalizeRelativePath(readString(precondition, 'artifact')) : null,
|
|
46
|
+
sources: readStringArray(precondition, 'sources')?.map(normalizeRelativePath) ?? [],
|
|
47
|
+
satisfyIntent: readString(precondition, 'satisfy_intent') ?? null,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
function createSatisfyIntentSummary(contract, intentName) {
|
|
51
|
+
if (!intentName) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const rawIntent = contract.intents[intentName];
|
|
55
|
+
const eligibility = evaluateCommandIntentEligibility(intentName, rawIntent);
|
|
56
|
+
return {
|
|
57
|
+
intent: intentName,
|
|
58
|
+
declared: isRecord(rawIntent),
|
|
59
|
+
runnable: eligibility.ok,
|
|
60
|
+
status: isRecord(rawIntent) ? readString(rawIntent, 'status') ?? null : null,
|
|
61
|
+
lifecycle: isRecord(rawIntent) ? readString(rawIntent, 'lifecycle') ?? null : null,
|
|
62
|
+
runPolicy: isRecord(rawIntent) ? readString(rawIntent, 'run_policy') ?? null : null,
|
|
63
|
+
detail: eligibility.detail,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function escapeRegExp(value) {
|
|
67
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
68
|
+
}
|
|
69
|
+
function globToRegExp(pattern) {
|
|
70
|
+
let expression = '^';
|
|
71
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
72
|
+
const character = pattern[index];
|
|
73
|
+
const next = pattern[index + 1];
|
|
74
|
+
if (character === '*' && next === '*') {
|
|
75
|
+
expression += '.*';
|
|
76
|
+
index += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (character === '*') {
|
|
80
|
+
expression += '[^/]*';
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
expression += escapeRegExp(character);
|
|
84
|
+
}
|
|
85
|
+
return new RegExp(`${expression}$`, 'u');
|
|
86
|
+
}
|
|
87
|
+
function shouldSkipDirectory(relativePath) {
|
|
88
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
89
|
+
return IGNORED_WALK_DIRECTORIES.has(normalized) || normalized.startsWith('.mustflow/state/') || normalized.startsWith('.mustflow/cache/');
|
|
90
|
+
}
|
|
91
|
+
function listProjectFiles(projectRoot) {
|
|
92
|
+
const files = [];
|
|
93
|
+
function walk(directory) {
|
|
94
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
95
|
+
const absolute = path.join(directory, entry.name);
|
|
96
|
+
const relative = normalizeRelativePath(path.relative(projectRoot, absolute));
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
if (!shouldSkipDirectory(relative)) {
|
|
99
|
+
walk(absolute);
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (entry.isFile()) {
|
|
104
|
+
files.push(relative);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
walk(projectRoot);
|
|
109
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
110
|
+
}
|
|
111
|
+
function matchingSourceFiles(projectRoot, patterns) {
|
|
112
|
+
const safePatterns = patterns.filter((pattern) => !relativePathIsUnsafe(pattern));
|
|
113
|
+
const matchers = safePatterns.map(globToRegExp);
|
|
114
|
+
if (matchers.length === 0) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
return listProjectFiles(projectRoot).filter((filePath) => matchers.some((matcher) => matcher.test(filePath)));
|
|
118
|
+
}
|
|
119
|
+
function evaluatePathExists(projectRoot, declaration, satisfyIntent) {
|
|
120
|
+
const pathValue = declaration.path;
|
|
121
|
+
if (!pathValue) {
|
|
122
|
+
return {
|
|
123
|
+
kind: declaration.kind,
|
|
124
|
+
label: declaration.label,
|
|
125
|
+
status: 'invalid',
|
|
126
|
+
detail: 'path_exists precondition requires path.',
|
|
127
|
+
path: null,
|
|
128
|
+
artifact: null,
|
|
129
|
+
sources: [],
|
|
130
|
+
newestSource: null,
|
|
131
|
+
satisfyIntent,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const resolvedPath = resolveProjectPath(projectRoot, pathValue);
|
|
135
|
+
if (!resolvedPath) {
|
|
136
|
+
return {
|
|
137
|
+
kind: declaration.kind,
|
|
138
|
+
label: declaration.label,
|
|
139
|
+
status: 'invalid',
|
|
140
|
+
detail: `path "${pathValue}" must stay inside the project root.`,
|
|
141
|
+
path: pathValue,
|
|
142
|
+
artifact: null,
|
|
143
|
+
sources: [],
|
|
144
|
+
newestSource: null,
|
|
145
|
+
satisfyIntent,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const exists = existsSync(resolvedPath);
|
|
149
|
+
return {
|
|
150
|
+
kind: declaration.kind,
|
|
151
|
+
label: declaration.label,
|
|
152
|
+
status: exists ? 'satisfied' : 'missing',
|
|
153
|
+
detail: exists ? `path "${pathValue}" exists.` : `path "${pathValue}" is missing.`,
|
|
154
|
+
path: pathValue,
|
|
155
|
+
artifact: null,
|
|
156
|
+
sources: [],
|
|
157
|
+
newestSource: null,
|
|
158
|
+
satisfyIntent,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function evaluateArtifactFreshness(projectRoot, declaration, satisfyIntent) {
|
|
162
|
+
const artifact = declaration.artifact;
|
|
163
|
+
if (!artifact || declaration.sources.length === 0) {
|
|
164
|
+
return {
|
|
165
|
+
kind: declaration.kind,
|
|
166
|
+
label: declaration.label,
|
|
167
|
+
status: 'invalid',
|
|
168
|
+
detail: 'artifact_freshness precondition requires artifact and sources.',
|
|
169
|
+
path: null,
|
|
170
|
+
artifact,
|
|
171
|
+
sources: declaration.sources,
|
|
172
|
+
newestSource: null,
|
|
173
|
+
satisfyIntent,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const artifactPath = resolveProjectPath(projectRoot, artifact);
|
|
177
|
+
if (!artifactPath) {
|
|
178
|
+
return {
|
|
179
|
+
kind: declaration.kind,
|
|
180
|
+
label: declaration.label,
|
|
181
|
+
status: 'invalid',
|
|
182
|
+
detail: `artifact "${artifact}" must stay inside the project root.`,
|
|
183
|
+
path: null,
|
|
184
|
+
artifact,
|
|
185
|
+
sources: declaration.sources,
|
|
186
|
+
newestSource: null,
|
|
187
|
+
satisfyIntent,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (!existsSync(artifactPath)) {
|
|
191
|
+
return {
|
|
192
|
+
kind: declaration.kind,
|
|
193
|
+
label: declaration.label,
|
|
194
|
+
status: 'missing',
|
|
195
|
+
detail: `artifact "${artifact}" is missing.`,
|
|
196
|
+
path: null,
|
|
197
|
+
artifact,
|
|
198
|
+
sources: declaration.sources,
|
|
199
|
+
newestSource: null,
|
|
200
|
+
satisfyIntent,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const sourceFiles = matchingSourceFiles(projectRoot, declaration.sources);
|
|
204
|
+
if (sourceFiles.length === 0) {
|
|
205
|
+
return {
|
|
206
|
+
kind: declaration.kind,
|
|
207
|
+
label: declaration.label,
|
|
208
|
+
status: 'unknown',
|
|
209
|
+
detail: 'no source files matched the freshness precondition.',
|
|
210
|
+
path: null,
|
|
211
|
+
artifact,
|
|
212
|
+
sources: declaration.sources,
|
|
213
|
+
newestSource: null,
|
|
214
|
+
satisfyIntent,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const artifactMtime = statSync(artifactPath).mtimeMs;
|
|
218
|
+
const newest = sourceFiles
|
|
219
|
+
.map((source) => ({ source, mtime: statSync(path.join(projectRoot, ...source.split('/'))).mtimeMs }))
|
|
220
|
+
.sort((left, right) => right.mtime - left.mtime)[0];
|
|
221
|
+
const stale = newest.mtime > artifactMtime;
|
|
222
|
+
return {
|
|
223
|
+
kind: declaration.kind,
|
|
224
|
+
label: declaration.label,
|
|
225
|
+
status: stale ? 'stale' : 'satisfied',
|
|
226
|
+
detail: stale
|
|
227
|
+
? `artifact "${artifact}" is older than source "${newest.source}".`
|
|
228
|
+
: `artifact "${artifact}" is at least as new as ${sourceFiles.length} matched source file(s).`,
|
|
229
|
+
path: null,
|
|
230
|
+
artifact,
|
|
231
|
+
sources: declaration.sources,
|
|
232
|
+
newestSource: newest.source,
|
|
233
|
+
satisfyIntent,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
export function evaluateCommandPreconditions(projectRoot, contract, intentName) {
|
|
237
|
+
const intent = contract.intents[intentName];
|
|
238
|
+
if (!isRecord(intent)) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
return readPreconditionDeclarations(intent).map((declaration) => {
|
|
242
|
+
const satisfyIntent = createSatisfyIntentSummary(contract, declaration.satisfyIntent);
|
|
243
|
+
if (declaration.kind === 'path_exists') {
|
|
244
|
+
return evaluatePathExists(projectRoot, declaration, satisfyIntent);
|
|
245
|
+
}
|
|
246
|
+
if (declaration.kind === 'artifact_freshness') {
|
|
247
|
+
return evaluateArtifactFreshness(projectRoot, declaration, satisfyIntent);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
kind: declaration.kind,
|
|
251
|
+
label: declaration.label,
|
|
252
|
+
status: 'invalid',
|
|
253
|
+
detail: `unknown precondition kind "${declaration.kind}".`,
|
|
254
|
+
path: declaration.path,
|
|
255
|
+
artifact: declaration.artifact,
|
|
256
|
+
sources: declaration.sources,
|
|
257
|
+
newestSource: null,
|
|
258
|
+
satisfyIntent,
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isRecord, readMustflowOwnedTomlFile } from './config-loading.js';
|
|
3
4
|
import { readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
4
5
|
import { parseSkillIndexRoutes } from './skill-route-alignment.js';
|
|
5
6
|
const MUSTFLOW_TEXT_MAX_BYTES = 1024 * 1024;
|
|
6
7
|
const SKILL_INDEX_PATH = '.mustflow/skills/INDEX.md';
|
|
8
|
+
const SKILL_ROUTES_METADATA_PATH = '.mustflow/skills/routes.toml';
|
|
7
9
|
const SKILL_ROUTE_SOURCE_FILES = [
|
|
8
10
|
SKILL_INDEX_PATH,
|
|
11
|
+
SKILL_ROUTES_METADATA_PATH,
|
|
9
12
|
'.mustflow/skills/<skill>/SKILL.md',
|
|
10
13
|
'.mustflow/config/commands.toml',
|
|
11
14
|
];
|
|
@@ -57,18 +60,32 @@ function skillNameFromPath(skillPath) {
|
|
|
57
60
|
const match = /^\.mustflow\/skills\/([^/]+)\/SKILL\.md$/u.exec(skillPath);
|
|
58
61
|
return match?.[1] ?? skillPath;
|
|
59
62
|
}
|
|
60
|
-
function
|
|
63
|
+
function collectTargetMatches(target, route, skillContent) {
|
|
64
|
+
const matches = [];
|
|
61
65
|
const skillName = skillNameFromPath(route.skillPath);
|
|
62
66
|
const normalizedTarget = target.replace(/\\/gu, '/');
|
|
63
|
-
if (normalizedTarget === skillName
|
|
64
|
-
|
|
67
|
+
if (normalizedTarget === skillName) {
|
|
68
|
+
matches.push(`skill_name:${skillName}`);
|
|
69
|
+
}
|
|
70
|
+
if (normalizedTarget === route.skillPath) {
|
|
71
|
+
matches.push(`skill_path:${route.skillPath}`);
|
|
65
72
|
}
|
|
66
73
|
if (!skillContent) {
|
|
67
|
-
return
|
|
74
|
+
return matches;
|
|
75
|
+
}
|
|
76
|
+
const frontmatterName = readFrontmatterScalar(skillContent, 'name');
|
|
77
|
+
const skillId = readFrontmatterScalar(skillContent, 'skill_id');
|
|
78
|
+
const mustflowDoc = readFrontmatterScalar(skillContent, 'mustflow_doc');
|
|
79
|
+
if (frontmatterName === target) {
|
|
80
|
+
matches.push(`frontmatter.name:${frontmatterName}`);
|
|
81
|
+
}
|
|
82
|
+
if (skillId === target) {
|
|
83
|
+
matches.push(`frontmatter.skill_id:${skillId}`);
|
|
68
84
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
if (mustflowDoc === target) {
|
|
86
|
+
matches.push(`frontmatter.mustflow_doc:${mustflowDoc}`);
|
|
87
|
+
}
|
|
88
|
+
return matches;
|
|
72
89
|
}
|
|
73
90
|
function routeToSummary(route, skillContent) {
|
|
74
91
|
const declaredCommandIntents = skillContent ? readFrontmatterList(skillContent, 'command_intents') : [];
|
|
@@ -84,21 +101,108 @@ function routeToSummary(route, skillContent) {
|
|
|
84
101
|
declaredCommandIntents,
|
|
85
102
|
};
|
|
86
103
|
}
|
|
104
|
+
function readStringArrayFromTable(table, key) {
|
|
105
|
+
const value = table[key];
|
|
106
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
107
|
+
? value.map((entry) => entry.trim()).filter(Boolean)
|
|
108
|
+
: [];
|
|
109
|
+
}
|
|
110
|
+
function readSkillRouteMetadata(projectRoot) {
|
|
111
|
+
const metadata = new Map();
|
|
112
|
+
try {
|
|
113
|
+
const parsed = readMustflowOwnedTomlFile(projectRoot, SKILL_ROUTES_METADATA_PATH);
|
|
114
|
+
if (!isRecord(parsed) || !isRecord(parsed.routes)) {
|
|
115
|
+
return metadata;
|
|
116
|
+
}
|
|
117
|
+
for (const [skillName, route] of Object.entries(parsed.routes)) {
|
|
118
|
+
if (!isRecord(route)) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const priority = Number.isInteger(route.priority) ? Number(route.priority) : 0;
|
|
122
|
+
const category = typeof route.category === 'string' ? route.category : undefined;
|
|
123
|
+
const routeType = typeof route.route_type === 'string' ? route.route_type : undefined;
|
|
124
|
+
metadata.set(skillName, {
|
|
125
|
+
category,
|
|
126
|
+
routeType,
|
|
127
|
+
priority,
|
|
128
|
+
appliesToReasons: readStringArrayFromTable(route, 'applies_to_reasons'),
|
|
129
|
+
mutuallyExclusiveWith: readStringArrayFromTable(route, 'mutually_exclusive_with'),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return metadata;
|
|
135
|
+
}
|
|
136
|
+
return metadata;
|
|
137
|
+
}
|
|
138
|
+
function valuesOverlap(left, right) {
|
|
139
|
+
const rightValues = new Set(right);
|
|
140
|
+
return left.some((value) => rightValues.has(value));
|
|
141
|
+
}
|
|
142
|
+
function findCandidateAdjuncts(skillName, routeMetadata) {
|
|
143
|
+
const current = routeMetadata.get(skillName);
|
|
144
|
+
if (!current) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
return [...routeMetadata.entries()]
|
|
148
|
+
.filter(([candidateName, candidate]) => {
|
|
149
|
+
return (candidateName !== skillName &&
|
|
150
|
+
candidate.routeType === 'adjunct' &&
|
|
151
|
+
candidate.category === current.category &&
|
|
152
|
+
!current.mutuallyExclusiveWith.includes(candidateName) &&
|
|
153
|
+
valuesOverlap(candidate.appliesToReasons, current.appliesToReasons));
|
|
154
|
+
})
|
|
155
|
+
.sort((left, right) => {
|
|
156
|
+
const priority = right[1].priority - left[1].priority;
|
|
157
|
+
return priority === 0 ? left[0].localeCompare(right[0]) : priority;
|
|
158
|
+
})
|
|
159
|
+
.map(([candidateName]) => candidateName);
|
|
160
|
+
}
|
|
161
|
+
function splitRequiredInput(requiredInput) {
|
|
162
|
+
return requiredInput.trim().length > 0 ? [requiredInput.trim()] : [];
|
|
163
|
+
}
|
|
164
|
+
function buildMatchedSkillEvidence(summary, matchedBy, candidateAdjuncts) {
|
|
165
|
+
return {
|
|
166
|
+
matchedBy,
|
|
167
|
+
requiredInputs: splitRequiredInput(summary.requiredInput),
|
|
168
|
+
missingInputs: [],
|
|
169
|
+
candidateAdjuncts,
|
|
170
|
+
unmatchedPaths: [],
|
|
171
|
+
gapNotes: [
|
|
172
|
+
'mf explain skill has no task paths or requirement text, so unmatched_paths and missing_inputs are only static route evidence.',
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function buildMissingSkillEvidence(target) {
|
|
177
|
+
return {
|
|
178
|
+
matchedBy: [],
|
|
179
|
+
requiredInputs: [],
|
|
180
|
+
missingInputs: [`No skill route matched "${target}".`],
|
|
181
|
+
candidateAdjuncts: [],
|
|
182
|
+
unmatchedPaths: [],
|
|
183
|
+
gapNotes: [
|
|
184
|
+
'No route was selected; update .mustflow/skills/INDEX.md and .mustflow/skills/routes.toml only if a repeatable procedure exists.',
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
87
188
|
export function explainSkillRoute(projectRoot, target) {
|
|
88
189
|
const indexPath = path.join(projectRoot, ...SKILL_INDEX_PATH.split('/'));
|
|
89
190
|
const indexContent = existsSync(indexPath)
|
|
90
191
|
? readUtf8FileInsideWithoutSymlinks(projectRoot, indexPath, { maxBytes: MUSTFLOW_TEXT_MAX_BYTES })
|
|
91
192
|
: '';
|
|
92
193
|
const routes = parseSkillIndexRoutes(indexContent);
|
|
194
|
+
const routeMetadata = readSkillRouteMetadata(projectRoot);
|
|
93
195
|
for (const route of routes) {
|
|
94
196
|
const absoluteSkillPath = path.join(projectRoot, ...route.skillPath.split('/'));
|
|
95
197
|
const skillContent = existsSync(absoluteSkillPath)
|
|
96
198
|
? readUtf8FileInsideWithoutSymlinks(projectRoot, absoluteSkillPath, { maxBytes: MUSTFLOW_TEXT_MAX_BYTES })
|
|
97
199
|
: null;
|
|
98
|
-
|
|
200
|
+
const matchedBy = collectTargetMatches(target, route, skillContent);
|
|
201
|
+
if (matchedBy.length === 0) {
|
|
99
202
|
continue;
|
|
100
203
|
}
|
|
101
204
|
const summary = routeToSummary(route, skillContent);
|
|
205
|
+
const candidateAdjuncts = findCandidateAdjuncts(summary.skill, routeMetadata);
|
|
102
206
|
return {
|
|
103
207
|
kind: 'skill_route',
|
|
104
208
|
inputSkill: target,
|
|
@@ -106,8 +210,9 @@ export function explainSkillRoute(projectRoot, target) {
|
|
|
106
210
|
reason: 'the skill index contains a route for the requested skill and exposes its trigger, scope, risk, checks, and output contract.',
|
|
107
211
|
effectiveAction: `Read ${summary.skillPath} before editing work that matches: ${summary.trigger}`,
|
|
108
212
|
countsAsMustflowVerification: false,
|
|
109
|
-
sourceFiles: [SKILL_INDEX_PATH, route.skillPath, '.mustflow/config/commands.toml'],
|
|
213
|
+
sourceFiles: [SKILL_INDEX_PATH, SKILL_ROUTES_METADATA_PATH, route.skillPath, '.mustflow/config/commands.toml'],
|
|
110
214
|
route: summary,
|
|
215
|
+
selectionEvidence: buildMatchedSkillEvidence(summary, matchedBy, candidateAdjuncts),
|
|
111
216
|
};
|
|
112
217
|
}
|
|
113
218
|
return {
|
|
@@ -119,5 +224,6 @@ export function explainSkillRoute(projectRoot, target) {
|
|
|
119
224
|
countsAsMustflowVerification: false,
|
|
120
225
|
sourceFiles: SKILL_ROUTE_SOURCE_FILES,
|
|
121
226
|
route: null,
|
|
227
|
+
selectionEvidence: buildMissingSkillEvidence(target),
|
|
122
228
|
};
|
|
123
229
|
}
|
package/package.json
CHANGED
package/schemas/README.md
CHANGED
|
@@ -11,7 +11,9 @@ Current schemas:
|
|
|
11
11
|
- `run-receipt.schema.json`: output of `mf run <intent> --json` and `.mustflow/state/runs/latest.json`,
|
|
12
12
|
including bounded declared-write drift metadata, a safe latest-run performance summary, and optional
|
|
13
13
|
structured phase timings and selection summaries
|
|
14
|
-
- `commands.schema.json`: parsed `.mustflow/config/commands.toml
|
|
14
|
+
- `commands.schema.json`: parsed `.mustflow/config/commands.toml`, including validation-only
|
|
15
|
+
typed intent input metadata and explanatory preconditions that do not authorize parameterized
|
|
16
|
+
command execution or automatic dependency execution
|
|
15
17
|
- `test-selection.schema.json`: parsed optional `.mustflow/config/test-selection.toml`
|
|
16
18
|
- `contract-lint-report.schema.json`: output of `mf contract-lint --json`
|
|
17
19
|
- `dashboard-export.schema.json`: bounded static export written by `mf dashboard --export-json <path>`,
|
|
@@ -33,7 +35,8 @@ Current schemas:
|
|
|
33
35
|
- `docs-review-list.schema.json`: output of `mf docs review list --json`
|
|
34
36
|
- `explain-report.schema.json`: output of `mf explain authority --json`, `mf explain command --json`,
|
|
35
37
|
`mf explain verify --reason <event> --json`, `mf explain retention --json`, `mf explain skills --json`,
|
|
36
|
-
and `mf explain
|
|
38
|
+
`mf explain surface --json`, and `mf explain why <target> --json`. Verify explanations include the shared
|
|
39
|
+
`decisionGraph` evidence model; latest-failure explanations include bounded latest-run metadata only.
|
|
37
40
|
- `verify-report.schema.json`: output of `mf verify --reason <event> --json`, including an
|
|
38
41
|
explicit execution aggregate, evidence-based completion verdict, and evidence model with a
|
|
39
42
|
conservative coverage matrix for the selected receipts and skipped checks
|
|
@@ -43,7 +46,7 @@ Current schemas:
|
|
|
43
46
|
`mf verify --from-classification <classify-report.json> --plan-only --json`, including the `decision_graph` that links
|
|
44
47
|
changed surfaces, classification reasons, command candidates, eligibility, selected or not-selected state,
|
|
45
48
|
effects, and gaps.
|
|
46
|
-
Local-index command-effect graphs are explanation-only and cannot grant command authority.
|
|
49
|
+
Local-index command-effect graphs and command preconditions are explanation-only and cannot grant command authority.
|
|
47
50
|
|
|
48
51
|
These schemas define stable, automation-facing fields. Human-readable command
|
|
49
52
|
output is intentionally excluded.
|
|
@@ -513,9 +513,61 @@
|
|
|
513
513
|
},
|
|
514
514
|
"effectGraph": {
|
|
515
515
|
"$ref": "#/$defs/commandEffectGraph"
|
|
516
|
+
},
|
|
517
|
+
"preconditions": {
|
|
518
|
+
"type": "array",
|
|
519
|
+
"items": { "$ref": "#/$defs/commandPrecondition" }
|
|
516
520
|
}
|
|
517
521
|
}
|
|
518
522
|
},
|
|
523
|
+
"commandPrecondition": {
|
|
524
|
+
"type": "object",
|
|
525
|
+
"additionalProperties": false,
|
|
526
|
+
"required": [
|
|
527
|
+
"kind",
|
|
528
|
+
"label",
|
|
529
|
+
"status",
|
|
530
|
+
"detail",
|
|
531
|
+
"path",
|
|
532
|
+
"artifact",
|
|
533
|
+
"sources",
|
|
534
|
+
"newestSource",
|
|
535
|
+
"satisfyIntent"
|
|
536
|
+
],
|
|
537
|
+
"properties": {
|
|
538
|
+
"kind": { "type": "string" },
|
|
539
|
+
"label": { "type": ["string", "null"] },
|
|
540
|
+
"status": { "enum": ["satisfied", "missing", "stale", "unknown", "invalid"] },
|
|
541
|
+
"detail": { "type": "string" },
|
|
542
|
+
"path": { "type": ["string", "null"] },
|
|
543
|
+
"artifact": { "type": ["string", "null"] },
|
|
544
|
+
"sources": {
|
|
545
|
+
"type": "array",
|
|
546
|
+
"items": { "type": "string" }
|
|
547
|
+
},
|
|
548
|
+
"newestSource": { "type": ["string", "null"] },
|
|
549
|
+
"satisfyIntent": {
|
|
550
|
+
"anyOf": [
|
|
551
|
+
{ "type": "null" },
|
|
552
|
+
{ "$ref": "#/$defs/preconditionSatisfyIntent" }
|
|
553
|
+
]
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
"preconditionSatisfyIntent": {
|
|
558
|
+
"type": "object",
|
|
559
|
+
"additionalProperties": false,
|
|
560
|
+
"required": ["intent", "declared", "runnable", "status", "lifecycle", "runPolicy", "detail"],
|
|
561
|
+
"properties": {
|
|
562
|
+
"intent": { "type": "string" },
|
|
563
|
+
"declared": { "type": "boolean" },
|
|
564
|
+
"runnable": { "type": "boolean" },
|
|
565
|
+
"status": { "type": ["string", "null"] },
|
|
566
|
+
"lifecycle": { "type": ["string", "null"] },
|
|
567
|
+
"runPolicy": { "type": ["string", "null"] },
|
|
568
|
+
"detail": { "type": ["string", "null"] }
|
|
569
|
+
}
|
|
570
|
+
},
|
|
519
571
|
"commandEffectGraph": {
|
|
520
572
|
"type": "object",
|
|
521
573
|
"additionalProperties": false,
|
|
@@ -100,6 +100,29 @@
|
|
|
100
100
|
"escalate_to": { "$ref": "#/$defs/stringArray" }
|
|
101
101
|
}
|
|
102
102
|
},
|
|
103
|
+
"intentInputs": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"propertyNames": { "pattern": "^[a-z][a-z0-9_]*$" },
|
|
106
|
+
"additionalProperties": { "$ref": "#/$defs/intentInput" }
|
|
107
|
+
},
|
|
108
|
+
"intentInput": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"required": ["type"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"type": { "enum": ["path", "enum", "boolean", "integer", "literal"] },
|
|
114
|
+
"description": { "type": "string" },
|
|
115
|
+
"required": { "type": "boolean" },
|
|
116
|
+
"placeholder": { "type": "string" },
|
|
117
|
+
"secret": { "type": "boolean" },
|
|
118
|
+
"allowed_roots": { "$ref": "#/$defs/stringArray" },
|
|
119
|
+
"allowed_extensions": { "$ref": "#/$defs/stringArray" },
|
|
120
|
+
"allowed_values": { "$ref": "#/$defs/stringArray" },
|
|
121
|
+
"value": { "type": ["string", "number", "boolean"] },
|
|
122
|
+
"min": { "type": "integer" },
|
|
123
|
+
"max": { "type": "integer" }
|
|
124
|
+
}
|
|
125
|
+
},
|
|
103
126
|
"costHints": {
|
|
104
127
|
"type": "object",
|
|
105
128
|
"additionalProperties": false,
|
|
@@ -110,6 +133,19 @@
|
|
|
110
133
|
"cost_tier": { "type": "string" }
|
|
111
134
|
}
|
|
112
135
|
},
|
|
136
|
+
"intentPrecondition": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"additionalProperties": false,
|
|
139
|
+
"required": ["kind"],
|
|
140
|
+
"properties": {
|
|
141
|
+
"kind": { "enum": ["path_exists", "artifact_freshness"] },
|
|
142
|
+
"label": { "type": "string" },
|
|
143
|
+
"path": { "type": "string" },
|
|
144
|
+
"artifact": { "type": "string" },
|
|
145
|
+
"sources": { "$ref": "#/$defs/stringArray" },
|
|
146
|
+
"satisfy_intent": { "type": "string" }
|
|
147
|
+
}
|
|
148
|
+
},
|
|
113
149
|
"relationHints": {
|
|
114
150
|
"type": "object",
|
|
115
151
|
"additionalProperties": false,
|
|
@@ -165,6 +201,11 @@
|
|
|
165
201
|
},
|
|
166
202
|
"covers": { "$ref": "#/$defs/coverageHints" },
|
|
167
203
|
"selection": { "$ref": "#/$defs/selectionHints" },
|
|
204
|
+
"inputs": { "$ref": "#/$defs/intentInputs" },
|
|
205
|
+
"preconditions": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"items": { "$ref": "#/$defs/intentPrecondition" }
|
|
208
|
+
},
|
|
168
209
|
"cost": { "$ref": "#/$defs/costHints" },
|
|
169
210
|
"relations": { "$ref": "#/$defs/relationHints" },
|
|
170
211
|
"reason": { "type": "string" },
|