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.
@@ -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 targetMatchesRoute(target, route, skillContent) {
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 || normalizedTarget === route.skillPath) {
64
- return true;
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 false;
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
- return (readFrontmatterScalar(skillContent, 'name') === target ||
70
- readFrontmatterScalar(skillContent, 'skill_id') === target ||
71
- readFrontmatterScalar(skillContent, 'mustflow_doc') === target);
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
- if (!targetMatchesRoute(target, route, skillContent)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustflow",
3
- "version": "2.22.13",
3
+ "version": "2.22.14",
4
4
  "description": "Agent workflow documents and CLI for mustflow repository roots.",
5
5
  "type": "module",
6
6
  "license": "MIT-0",
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 surface --json`. Verify explanations include the shared `decisionGraph` evidence model.
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" },