mustflow 2.22.13 → 2.22.15

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 CHANGED
@@ -275,7 +275,7 @@ mf run mustflow_update_apply
275
275
  | `mf verify --reason <event>` | Run configured verification intents selected by `required_after` metadata. |
276
276
  | `mf verify --reason <event> --plan-only --json` | Print the required verification plan without running commands. |
277
277
  | `mf explain authority [path]` | Explain managed Markdown authority decisions without modifying files. |
278
- | `mf explain skill <skill_id>` | Explain the trigger, scope, risk, checks, and output contract for one skill route. |
278
+ | `mf explain skill <skill_id>` | Explain the trigger, scope, risk, checks, output contract, and selection evidence for one skill route. |
279
279
  | `mf explain skills` | Explain the strict skill index/body alignment summary used by `mf doctor --strict`. |
280
280
  | `mf explain surface [path]` | Explain how a path maps to public-surface and validation categories. |
281
281
 
@@ -107,7 +107,7 @@ export async function getVerifyExplainOutput(schemaVersion, projectRoot, reasons
107
107
  const readModel = await readLatestLocalVerificationReadModelQueries(projectRoot);
108
108
  const requirements = plans.map((plan) => {
109
109
  const candidates = plan.candidates.map((candidate) => {
110
- const command = candidate.intent ? explainCommandIntent(contract, candidate.intent).intent : null;
110
+ const command = candidate.intent ? explainCommandIntent(contract, candidate.intent, { projectRoot }).intent : null;
111
111
  return {
112
112
  intent: candidate.intent.length > 0 ? candidate.intent : null,
113
113
  status: candidate.status,
@@ -211,6 +211,13 @@ export function renderVerifyExplainDecision(decision, lang) {
211
211
  if (candidate.requiredAfter.length > 0) {
212
212
  lines.push(` required_after: ${candidate.requiredAfter.join(', ')}`);
213
213
  }
214
+ if (candidate.command && candidate.command.preconditions.length > 0) {
215
+ lines.push(` preconditions: ${candidate.command.preconditions.length}`);
216
+ for (const precondition of candidate.command.preconditions) {
217
+ const target = precondition.path ?? precondition.artifact ?? precondition.label ?? precondition.kind;
218
+ lines.push(` - ${precondition.kind} ${target}: ${precondition.status}`);
219
+ }
220
+ }
214
221
  if (candidate.effectGraph) {
215
222
  lines.push(` effect_graph: ${candidate.effectGraph.status}`, ` index_fresh: ${candidate.effectGraph.indexFresh ? t(lang, 'value.yes') : t(lang, 'value.no')}`);
216
223
  if (candidate.effectGraph.refreshHint) {
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { printUsageError, renderHelp } from '../lib/cli-output.js';
2
4
  import { t } from '../lib/i18n.js';
3
5
  import { resolveMustflowRoot } from '../lib/project-root.js';
@@ -13,9 +15,10 @@ import { checkMustflowProject } from '../lib/validation.js';
13
15
  import { readLocalCommandEffectGraph, readLocalPathSurface, } from '../lib/local-index.js';
14
16
  import { explainVerifyArgErrorMessage, explainVerifyPlanErrorMessage, getVerifyExplainOutput, parseExplainVerifyArgs, readExplainVerifyPlanReasons, renderVerifyExplainDecision, } from './explain-verify.js';
15
17
  const EXPLAIN_SCHEMA_VERSION = '1';
18
+ const LATEST_RUN_RECEIPT_RELATIVE_PATH = '.mustflow/state/runs/latest.json';
16
19
  export function getExplainHelp(lang = 'en') {
17
20
  return renderHelp({
18
- usage: 'mf explain <topic> [target] [options] | mf explain verify --reason <event> [options] | mf explain verify --from-plan <path> [options]',
21
+ usage: 'mf explain <topic> [target] [options] | mf explain verify --reason <event> [options] | mf explain why <target> [options]',
19
22
  summary: t(lang, 'explain.help.summary'),
20
23
  options: [
21
24
  { label: '--json', description: t(lang, 'cli.option.json') },
@@ -34,6 +37,9 @@ export function getExplainHelp(lang = 'en') {
34
37
  'mf explain retention --json',
35
38
  'mf explain verify --reason code_change',
36
39
  'mf explain verify --from-plan verify-plan.json --json',
40
+ 'mf explain why command test --json',
41
+ 'mf explain why verify --reason code_change --json',
42
+ 'mf explain why latest-failure --json',
37
43
  'mf explain skill code-review',
38
44
  'mf explain skill mustflow.core.code-review --json',
39
45
  'mf explain skills',
@@ -76,7 +82,7 @@ function getAnchorExplainOutput(projectRoot, anchorId) {
76
82
  };
77
83
  }
78
84
  async function getCommandExplainOutput(projectRoot, commandName) {
79
- const decision = explainCommandIntent(readCommandContract(projectRoot), commandName);
85
+ const decision = explainCommandIntent(readCommandContract(projectRoot), commandName, { projectRoot });
80
86
  const effectGraph = decision.intent ? await readLocalCommandEffectGraph(projectRoot, commandName) : undefined;
81
87
  return {
82
88
  schema_version: EXPLAIN_SCHEMA_VERSION,
@@ -124,6 +130,237 @@ async function getSurfaceExplainOutput(projectRoot, pathArg) {
124
130
  decision: { ...decision, readModel },
125
131
  };
126
132
  }
133
+ function isJsonRecord(value) {
134
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
135
+ }
136
+ function stringField(record, key) {
137
+ const value = record[key];
138
+ return typeof value === 'string' ? value : null;
139
+ }
140
+ function integerField(record, key) {
141
+ const value = record[key];
142
+ return typeof value === 'number' && Number.isInteger(value) ? value : null;
143
+ }
144
+ function recordField(record, key) {
145
+ const value = record[key];
146
+ return isJsonRecord(value) ? value : null;
147
+ }
148
+ function latestFailureDecision(latestFailure, decision, reason, effectiveAction) {
149
+ return {
150
+ kind: 'latest_failure',
151
+ decision,
152
+ reason,
153
+ effectiveAction,
154
+ countsAsMustflowVerification: false,
155
+ sourceFiles: [
156
+ LATEST_RUN_RECEIPT_RELATIVE_PATH,
157
+ '.mustflow/config/mustflow.toml',
158
+ '.mustflow/docs/agent-workflow.md',
159
+ ],
160
+ latestFailure,
161
+ };
162
+ }
163
+ function getLatestFailureExplainOutput(projectRoot) {
164
+ const latestPath = path.join(projectRoot, ...LATEST_RUN_RECEIPT_RELATIVE_PATH.split('/'));
165
+ const sourcePath = LATEST_RUN_RECEIPT_RELATIVE_PATH;
166
+ if (!existsSync(latestPath)) {
167
+ return {
168
+ schema_version: EXPLAIN_SCHEMA_VERSION,
169
+ command: 'explain',
170
+ topic: 'why',
171
+ mustflow_root: projectRoot,
172
+ decision: latestFailureDecision({
173
+ path: sourcePath,
174
+ present: false,
175
+ valid: false,
176
+ failed: false,
177
+ status: null,
178
+ intent: null,
179
+ exitCode: null,
180
+ errorKind: null,
181
+ durationMs: null,
182
+ summary: 'No latest mf run receipt is available.',
183
+ }, 'latest run receipt is missing', 'mustflow has no bounded latest run metadata to explain.', 'Run a configured mf run or mf verify command before treating latest-run state as evidence.'),
184
+ };
185
+ }
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(readFileSync(latestPath, 'utf8'));
189
+ }
190
+ catch {
191
+ return {
192
+ schema_version: EXPLAIN_SCHEMA_VERSION,
193
+ command: 'explain',
194
+ topic: 'why',
195
+ mustflow_root: projectRoot,
196
+ decision: latestFailureDecision({
197
+ path: sourcePath,
198
+ present: true,
199
+ valid: false,
200
+ failed: false,
201
+ status: null,
202
+ intent: null,
203
+ exitCode: null,
204
+ errorKind: null,
205
+ durationMs: null,
206
+ summary: 'The latest mf run receipt is not valid JSON.',
207
+ }, 'latest run receipt is invalid', 'mustflow could not read bounded latest run metadata safely.', 'Ignore the latest-run state and rerun the intended configured command to create a fresh receipt.'),
208
+ };
209
+ }
210
+ if (!isJsonRecord(parsed)) {
211
+ return {
212
+ schema_version: EXPLAIN_SCHEMA_VERSION,
213
+ command: 'explain',
214
+ topic: 'why',
215
+ mustflow_root: projectRoot,
216
+ decision: latestFailureDecision({
217
+ path: sourcePath,
218
+ present: true,
219
+ valid: false,
220
+ failed: false,
221
+ status: null,
222
+ intent: null,
223
+ exitCode: null,
224
+ errorKind: null,
225
+ durationMs: null,
226
+ summary: 'The latest mf run receipt is not a JSON object.',
227
+ }, 'latest run receipt is invalid', 'mustflow could not read bounded latest run metadata safely.', 'Ignore the latest-run state and rerun the intended configured command to create a fresh receipt.'),
228
+ };
229
+ }
230
+ const status = stringField(parsed, 'status');
231
+ const intent = stringField(parsed, 'intent');
232
+ const exitCode = integerField(parsed, 'exit_code');
233
+ const performance = recordField(parsed, 'performance');
234
+ const resultSummary = performance ? recordField(performance, 'result_summary') : null;
235
+ const errorKind = resultSummary ? stringField(resultSummary, 'error_kind') : null;
236
+ const durationMs = integerField(parsed, 'duration_ms') ?? (performance ? integerField(performance, 'duration_ms') : null);
237
+ if (!status) {
238
+ return {
239
+ schema_version: EXPLAIN_SCHEMA_VERSION,
240
+ command: 'explain',
241
+ topic: 'why',
242
+ mustflow_root: projectRoot,
243
+ decision: latestFailureDecision({
244
+ path: sourcePath,
245
+ present: true,
246
+ valid: false,
247
+ failed: false,
248
+ status: null,
249
+ intent,
250
+ exitCode,
251
+ errorKind,
252
+ durationMs,
253
+ summary: 'The latest mf run receipt does not include a status field.',
254
+ }, 'latest run receipt is invalid', 'mustflow could not read bounded latest run metadata safely.', 'Ignore the latest-run state and rerun the intended configured command to create a fresh receipt.'),
255
+ };
256
+ }
257
+ const failed = status !== 'passed';
258
+ return {
259
+ schema_version: EXPLAIN_SCHEMA_VERSION,
260
+ command: 'explain',
261
+ topic: 'why',
262
+ mustflow_root: projectRoot,
263
+ decision: latestFailureDecision({
264
+ path: sourcePath,
265
+ present: true,
266
+ valid: true,
267
+ failed,
268
+ status,
269
+ intent,
270
+ exitCode,
271
+ errorKind,
272
+ durationMs,
273
+ summary: `Latest bounded run receipt status is ${status}.`,
274
+ }, failed ? 'latest run did not pass' : 'latest run is not a failure', failed
275
+ ? 'the latest bounded run receipt reports a non-passing status without exposing stdout or stderr tails.'
276
+ : 'the latest bounded run receipt does not report a failure status.', failed
277
+ ? 'Inspect the configured command result and rerun the narrowest relevant intent after fixing the cause.'
278
+ : 'Do not treat this as a failure explanation; choose the next verification from current changes and command contracts.'),
279
+ };
280
+ }
281
+ function withWhyTopic(output) {
282
+ return { ...output, topic: 'why' };
283
+ }
284
+ async function getWhyExplainOutput(projectRoot, targetArg, rest, lang, reporter) {
285
+ switch (targetArg) {
286
+ case 'command':
287
+ case 'intent': {
288
+ const [commandName, ...extra] = rest;
289
+ if (!commandName) {
290
+ printUsageError(reporter, t(lang, 'explain.error.missingCommand'), 'mf explain --help', getExplainHelp(lang), lang);
291
+ return null;
292
+ }
293
+ if (extra.length > 0) {
294
+ printUsageError(reporter, t(lang, 'cli.error.unexpectedArgument', { argument: extra[0] }), 'mf explain --help', getExplainHelp(lang), lang);
295
+ return null;
296
+ }
297
+ return withWhyTopic(await getCommandExplainOutput(projectRoot, commandName));
298
+ }
299
+ case 'verify': {
300
+ const parsed = parseExplainVerifyArgs([...rest]);
301
+ if (parsed.error) {
302
+ printUsageError(reporter, explainVerifyArgErrorMessage(parsed.error, lang), 'mf explain --help', getExplainHelp(lang), lang);
303
+ return null;
304
+ }
305
+ const selectedInputCount = [parsed.reason, parsed.fromPlan].filter(Boolean).length;
306
+ if (selectedInputCount > 1) {
307
+ printUsageError(reporter, t(lang, 'verify.error.conflictingInputs'), 'mf explain --help', getExplainHelp(lang), lang);
308
+ return null;
309
+ }
310
+ if (selectedInputCount === 0) {
311
+ printUsageError(reporter, t(lang, 'verify.error.missingReason'), 'mf explain --help', getExplainHelp(lang), lang);
312
+ return null;
313
+ }
314
+ try {
315
+ if (parsed.fromPlan) {
316
+ const reasons = readExplainVerifyPlanReasons(projectRoot, parsed.fromPlan);
317
+ return withWhyTopic(await getVerifyExplainOutput(EXPLAIN_SCHEMA_VERSION, projectRoot, reasons, null, parsed.fromPlan));
318
+ }
319
+ return withWhyTopic(await getVerifyExplainOutput(EXPLAIN_SCHEMA_VERSION, projectRoot, [parsed.reason], parsed.reason, null));
320
+ }
321
+ catch (error) {
322
+ printUsageError(reporter, explainVerifyPlanErrorMessage(error, lang), 'mf explain --help', getExplainHelp(lang), lang);
323
+ return null;
324
+ }
325
+ }
326
+ case 'skill': {
327
+ const [skillName, ...extra] = rest;
328
+ if (!skillName) {
329
+ printUsageError(reporter, t(lang, 'explain.error.missingSkill'), 'mf explain --help', getExplainHelp(lang), lang);
330
+ return null;
331
+ }
332
+ if (extra.length > 0) {
333
+ printUsageError(reporter, t(lang, 'cli.error.unexpectedArgument', { argument: extra[0] }), 'mf explain --help', getExplainHelp(lang), lang);
334
+ return null;
335
+ }
336
+ return withWhyTopic(getSkillExplainOutput(projectRoot, skillName));
337
+ }
338
+ case 'skills':
339
+ if (rest.length > 0) {
340
+ printUsageError(reporter, t(lang, 'cli.error.unexpectedArgument', { argument: rest[0] }), 'mf explain --help', getExplainHelp(lang), lang);
341
+ return null;
342
+ }
343
+ return withWhyTopic(getSkillsExplainOutput(projectRoot));
344
+ case 'surface': {
345
+ const [pathArg, ...extra] = rest;
346
+ if (extra.length > 0) {
347
+ printUsageError(reporter, t(lang, 'cli.error.unexpectedArgument', { argument: extra[0] }), 'mf explain --help', getExplainHelp(lang), lang);
348
+ return null;
349
+ }
350
+ return withWhyTopic(await getSurfaceExplainOutput(projectRoot, pathArg));
351
+ }
352
+ case 'latest-failure':
353
+ case 'latest-run':
354
+ if (rest.length > 0) {
355
+ printUsageError(reporter, t(lang, 'cli.error.unexpectedArgument', { argument: rest[0] }), 'mf explain --help', getExplainHelp(lang), lang);
356
+ return null;
357
+ }
358
+ return getLatestFailureExplainOutput(projectRoot);
359
+ default:
360
+ printUsageError(reporter, t(lang, targetArg ? 'explain.error.unknownTopic' : 'explain.error.missingTopic', { topic: targetArg ?? '' }), 'mf explain --help', getExplainHelp(lang), lang);
361
+ return null;
362
+ }
363
+ }
127
364
  function formatNullable(value, lang) {
128
365
  if (value === null) {
129
366
  return t(lang, 'value.none');
@@ -152,6 +389,10 @@ function renderExplainDecision(output, lang) {
152
389
  if ('verification' in output.decision) {
153
390
  lines.push(...renderVerifyExplainDecision(output.decision, lang));
154
391
  }
392
+ if ('latestFailure' in output.decision) {
393
+ const latest = output.decision.latestFailure;
394
+ lines.push('', 'Latest run failure', `- path: ${latest.path}`, `- present: ${latest.present ? t(lang, 'value.yes') : t(lang, 'value.no')}`, `- valid: ${latest.valid ? t(lang, 'value.yes') : t(lang, 'value.no')}`, `- failed: ${latest.failed ? t(lang, 'value.yes') : t(lang, 'value.no')}`, `- status: ${latest.status ?? t(lang, 'value.none')}`, `- intent: ${latest.intent ?? t(lang, 'value.none')}`, `- exit_code: ${latest.exitCode ?? t(lang, 'value.none')}`, `- error_kind: ${latest.errorKind ?? t(lang, 'value.none')}`, `- duration_ms: ${latest.durationMs ?? t(lang, 'value.none')}`, `- summary: ${latest.summary}`);
395
+ }
155
396
  if ('boundary' in output.decision) {
156
397
  lines.push('', t(lang, 'explain.label.authorityBoundary'), `- role: ${output.decision.boundary.role}`);
157
398
  if (output.decision.boundary.canDefine.length > 0) {
@@ -170,6 +411,17 @@ function renderExplainDecision(output, lang) {
170
411
  if ('intent' in output.decision && output.decision.intent) {
171
412
  const intent = output.decision.intent;
172
413
  lines.push('', t(lang, 'explain.label.commandIntent'), `- ${t(lang, 'explain.label.commandName')}: ${intent.name}`, `- status: ${intent.status ?? t(lang, 'value.none')}`, `- lifecycle: ${intent.lifecycle ?? t(lang, 'value.none')}`, `- run_policy: ${intent.runPolicy ?? t(lang, 'value.none')}`, `- stdin: ${intent.stdin ?? t(lang, 'value.none')}`, `- timeout_seconds: ${intent.timeoutSeconds ?? t(lang, 'value.none')}`, `- mode: ${intent.mode}`, `- cwd: ${intent.cwd ?? t(lang, 'value.none')}`, `- writes: ${intent.writes.join(', ') || t(lang, 'value.none')}`, `- network: ${formatNullable(intent.network, lang)}`, `- destructive: ${formatNullable(intent.destructive, lang)}`, `- success_exit_codes: ${intent.successExitCodes.join(', ') || t(lang, 'value.none')}`, `- required_after: ${intent.requiredAfter.join(', ') || t(lang, 'value.none')}`);
414
+ if (intent.preconditions.length > 0) {
415
+ lines.push('- preconditions:');
416
+ for (const precondition of intent.preconditions) {
417
+ const target = precondition.path ?? precondition.artifact ?? precondition.label ?? precondition.kind;
418
+ lines.push(` - ${precondition.kind} ${target}: ${precondition.status}`);
419
+ lines.push(` detail: ${precondition.detail}`);
420
+ if (precondition.satisfyIntent) {
421
+ lines.push(` satisfy_intent: ${precondition.satisfyIntent.intent} (${precondition.satisfyIntent.runnable ? 'runnable' : 'not_runnable'})`);
422
+ }
423
+ }
424
+ }
173
425
  }
174
426
  if ('effectGraph' in output.decision && output.decision.effectGraph) {
175
427
  const graph = output.decision.effectGraph;
@@ -216,6 +468,8 @@ function renderExplainDecision(output, lang) {
216
468
  else {
217
469
  lines.push(`- skill: ${route.skill}`, `- path: ${route.skillPath}`, `- trigger: ${route.trigger}`, `- required_input: ${route.requiredInput}`, `- edit_scope: ${route.editScope}`, `- risk: ${route.risk}`, `- verification_intents: ${route.verificationIntents.join(', ') || t(lang, 'value.none')}`, `- declared_command_intents: ${route.declaredCommandIntents.join(', ') || t(lang, 'value.none')}`, `- expected_output: ${route.expectedOutput}`);
218
470
  }
471
+ const evidence = output.decision.selectionEvidence;
472
+ lines.push('', 'Skill selection evidence', `- matched_by: ${evidence.matchedBy.join(', ') || t(lang, 'value.none')}`, `- required_inputs: ${evidence.requiredInputs.join(', ') || t(lang, 'value.none')}`, `- missing_inputs: ${evidence.missingInputs.join(', ') || t(lang, 'value.none')}`, `- candidate_adjuncts: ${evidence.candidateAdjuncts.join(', ') || t(lang, 'value.none')}`, `- unmatched_paths: ${evidence.unmatchedPaths.join(', ') || t(lang, 'value.none')}`, `- gap_notes: ${evidence.gapNotes.join(' | ') || t(lang, 'value.none')}`);
219
473
  }
220
474
  if ('surface' in output.decision) {
221
475
  const surface = output.decision.surface;
@@ -254,6 +508,19 @@ export async function runExplain(args, reporter, lang = 'en') {
254
508
  const json = args.includes('--json');
255
509
  const positional = args.filter((arg) => arg !== '--json');
256
510
  const [topic, targetArg, ...rest] = positional;
511
+ if (topic === 'why') {
512
+ const projectRoot = resolveMustflowRoot();
513
+ const output = await getWhyExplainOutput(projectRoot, targetArg, rest, lang, reporter);
514
+ if (!output) {
515
+ return 1;
516
+ }
517
+ if (json) {
518
+ reporter.stdout(JSON.stringify(output, null, 2));
519
+ return 0;
520
+ }
521
+ reporter.stdout(renderExplainDecision(output, lang));
522
+ return 0;
523
+ }
257
524
  if (topic === 'verify') {
258
525
  const verifyArgs = targetArg === undefined ? rest : [targetArg, ...rest];
259
526
  const parsed = parseExplainVerifyArgs(verifyArgs);
@@ -1,4 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { performance } from 'node:perf_hooks';
3
+ import { acquireActiveRunLock } from '../../core/active-run-locks.js';
2
4
  import { createCommandEnv } from '../../core/command-env.js';
3
5
  import { createCorrelationId } from '../../core/correlation-id.js';
4
6
  import { printUsageError, renderCliError, renderHelp } from '../lib/cli-output.js';
@@ -86,6 +88,20 @@ function writeLatestProfile(profiler, options, input) {
86
88
  }
87
89
  profiler.writeLatest(input);
88
90
  }
91
+ function createPlanCommandHash(plan) {
92
+ const payload = {
93
+ mode: plan.mode,
94
+ cwd: plan.relativeCwd,
95
+ argv: plan.commandArgv ?? null,
96
+ cmd: plan.shellCommand ?? null,
97
+ };
98
+ return `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
99
+ }
100
+ function renderActiveLockConflictMessage(intentName, conflicts) {
101
+ const [first] = conflicts;
102
+ const detail = first ? `${first.lock} conflicts with active intent ${first.conflictsWithIntent} (pid ${first.conflictsWithPid})` : 'unknown active lock conflict';
103
+ return `mf run ${intentName} is blocked by an active run lock: ${detail}`;
104
+ }
89
105
  export function getRunHelp(lang = 'en') {
90
106
  return renderHelp({
91
107
  usage: 'mf run <intent> [options]',
@@ -187,88 +203,104 @@ export async function runRun(args, reporter, lang = 'en', options = {}) {
187
203
  });
188
204
  return 1;
189
205
  }
190
- const runReceiptPolicy = profiler.measure('retention_policy', () => resolveRunReceiptRetentionPolicy(readMustflowConfigIfExists(projectRoot)));
191
- const env = profiler.measure('environment', () => createCommandEnv(projectRoot, { policy: plan.envPolicy, allowlist: plan.envAllowlist }));
192
- const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName, {
193
- additionalDeclaredPaths: options.additionalDeclaredWritePaths,
194
- }));
195
- const stdoutTailBytes = Math.min(runReceiptPolicy.stdoutTailBytes, plan.maxOutputBytes);
196
- const stderrTailBytes = Math.min(runReceiptPolicy.stderrTailBytes, plan.maxOutputBytes);
197
- let streamedOutput = false;
198
- const childStartedAtMs = performance.now();
199
- const startedAt = new Date();
200
- const result = await profiler.measureAsync('child_command', async () => {
201
- if (plan.commandArgv) {
206
+ const activeRunLock = profiler.measure('active_lock_acquire', () => acquireActiveRunLock(projectRoot, contract, intentName, { commandHash: createPlanCommandHash(plan) }));
207
+ if (!activeRunLock.ok) {
208
+ reporter.stderr(renderCliError(renderActiveLockConflictMessage(intentName, activeRunLock.conflicts), 'mf run --dry-run --json', lang));
209
+ writeLatestProfile(profiler, options, {
210
+ projectRoot,
211
+ intent: intentName,
212
+ status: 'blocked',
213
+ previewMode: null,
214
+ });
215
+ return 1;
216
+ }
217
+ try {
218
+ const runReceiptPolicy = profiler.measure('retention_policy', () => resolveRunReceiptRetentionPolicy(readMustflowConfigIfExists(projectRoot)));
219
+ const env = profiler.measure('environment', () => createCommandEnv(projectRoot, { policy: plan.envPolicy, allowlist: plan.envAllowlist }));
220
+ const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName, {
221
+ additionalDeclaredPaths: options.additionalDeclaredWritePaths,
222
+ }));
223
+ const stdoutTailBytes = Math.min(runReceiptPolicy.stdoutTailBytes, plan.maxOutputBytes);
224
+ const stderrTailBytes = Math.min(runReceiptPolicy.stderrTailBytes, plan.maxOutputBytes);
225
+ let streamedOutput = false;
226
+ const childStartedAtMs = performance.now();
227
+ const startedAt = new Date();
228
+ const result = await profiler.measureAsync('child_command', async () => {
229
+ if (plan.commandArgv) {
230
+ streamedOutput = !json;
231
+ return runArgvCommandStreaming(plan.argvCommand, plan.cwd, env, plan.timeoutSeconds, plan.killAfterSeconds, plan.maxOutputBytes, stdoutTailBytes, stderrTailBytes, reporter, !json, true);
232
+ }
202
233
  streamedOutput = !json;
203
- return runArgvCommandStreaming(plan.argvCommand, plan.cwd, env, plan.timeoutSeconds, plan.killAfterSeconds, plan.maxOutputBytes, stdoutTailBytes, stderrTailBytes, reporter, !json, true);
234
+ return runShellCommandStreaming(plan.shellCommand, plan.cwd, env, plan.timeoutSeconds, plan.killAfterSeconds, plan.maxOutputBytes, stdoutTailBytes, stderrTailBytes, reporter, !json, true);
235
+ });
236
+ const childDurationMs = performance.now() - childStartedAtMs;
237
+ const finishedAt = new Date();
238
+ const writeDrift = profiler.measure('write_drift_after', () => finishRunWriteTracking(writeTracker));
239
+ const exitCode = typeof result.status === 'number' ? result.status : null;
240
+ const runStatus = getRunStatus(result.error, exitCode, plan.successExitCodes);
241
+ let killMethod = null;
242
+ let termination = null;
243
+ if (runStatus === 'timed_out') {
244
+ termination = result.termination ?? createPendingTimeoutTermination(getKillMethod());
245
+ killMethod = termination.method;
246
+ if (!result.termination && result.pid) {
247
+ terminateProcessTree(result.pid);
248
+ }
204
249
  }
205
- streamedOutput = !json;
206
- return runShellCommandStreaming(plan.shellCommand, plan.cwd, env, plan.timeoutSeconds, plan.killAfterSeconds, plan.maxOutputBytes, stdoutTailBytes, stderrTailBytes, reporter, !json, true);
207
- });
208
- const childDurationMs = performance.now() - childStartedAtMs;
209
- const finishedAt = new Date();
210
- const writeDrift = profiler.measure('write_drift_after', () => finishRunWriteTracking(writeTracker));
211
- const exitCode = typeof result.status === 'number' ? result.status : null;
212
- const runStatus = getRunStatus(result.error, exitCode, plan.successExitCodes);
213
- let killMethod = null;
214
- let termination = null;
215
- if (runStatus === 'timed_out') {
216
- termination = result.termination ?? createPendingTimeoutTermination(getKillMethod());
217
- killMethod = termination.method;
218
- if (!result.termination && result.pid) {
219
- terminateProcessTree(result.pid);
250
+ const receipt = profiler.measure('receipt_create', () => assembleRunReceipt({
251
+ correlationId: options.correlationId ?? createCorrelationId('run'),
252
+ intentName,
253
+ runStatus,
254
+ startedAt,
255
+ finishedAt,
256
+ projectRoot,
257
+ plan,
258
+ result,
259
+ exitCode,
260
+ killMethod,
261
+ termination,
262
+ writeDrift,
263
+ executorOverheadMs: Math.max(0, Math.round((performance.now() - executorStartedAtMs - childDurationMs) * 1000) / 1000),
264
+ phaseTimings: profiler.getReceiptPhases(),
265
+ stdoutTailBytes: runReceiptPolicy.stdoutTailBytes,
266
+ stderrTailBytes: runReceiptPolicy.stderrTailBytes,
267
+ }));
268
+ if (options.writeLatestReceipt !== false) {
269
+ profiler.measure('receipt_write', () => writeRunReceipt(projectRoot, receipt));
220
270
  }
221
- }
222
- const receipt = profiler.measure('receipt_create', () => assembleRunReceipt({
223
- correlationId: options.correlationId ?? createCorrelationId('run'),
224
- intentName,
225
- runStatus,
226
- startedAt,
227
- finishedAt,
228
- projectRoot,
229
- plan,
230
- result,
231
- exitCode,
232
- killMethod,
233
- termination,
234
- writeDrift,
235
- executorOverheadMs: Math.max(0, Math.round((performance.now() - executorStartedAtMs - childDurationMs) * 1000) / 1000),
236
- phaseTimings: profiler.getReceiptPhases(),
237
- stdoutTailBytes: runReceiptPolicy.stdoutTailBytes,
238
- stderrTailBytes: runReceiptPolicy.stderrTailBytes,
239
- }));
240
- if (options.writeLatestReceipt !== false) {
241
- profiler.measure('receipt_write', () => writeRunReceipt(projectRoot, receipt));
242
- }
243
- if (options.recordPerformanceHistory !== false) {
244
- profiler.measure('performance_history_write', () => recordRunPerformanceHistory(projectRoot, receipt));
245
- }
246
- writeLatestProfile(profiler, options, {
247
- projectRoot,
248
- intent: intentName,
249
- status: runStatus,
250
- previewMode: null,
251
- });
252
- if (json) {
253
- reporter.stdout(JSON.stringify(receipt, null, 2));
254
- return runStatus === 'passed' ? 0 : 1;
255
- }
256
- if (!streamedOutput) {
257
- emitOutput(reporter, result.stdout, 'stdout');
258
- emitOutput(reporter, result.stderr, 'stderr');
259
- }
260
- if (result.error) {
261
- const errorWithCode = result.error;
262
- if (errorWithCode.code === 'ETIMEDOUT') {
263
- reporter.stderr(t(lang, 'run.error.timedOut', { intent: intentName, seconds: plan.timeoutSeconds }));
264
- return 1;
271
+ if (options.recordPerformanceHistory !== false) {
272
+ profiler.measure('performance_history_write', () => recordRunPerformanceHistory(projectRoot, receipt));
273
+ }
274
+ writeLatestProfile(profiler, options, {
275
+ projectRoot,
276
+ intent: intentName,
277
+ status: runStatus,
278
+ previewMode: null,
279
+ });
280
+ if (json) {
281
+ reporter.stdout(JSON.stringify(receipt, null, 2));
282
+ return runStatus === 'passed' ? 0 : 1;
265
283
  }
266
- if (isOutputLimitExceededError(result.error)) {
267
- reporter.stderr(t(lang, 'run.error.outputLimitExceeded', { intent: intentName, message: result.error.message }));
284
+ if (!streamedOutput) {
285
+ emitOutput(reporter, result.stdout, 'stdout');
286
+ emitOutput(reporter, result.stderr, 'stderr');
287
+ }
288
+ if (result.error) {
289
+ const errorWithCode = result.error;
290
+ if (errorWithCode.code === 'ETIMEDOUT') {
291
+ reporter.stderr(t(lang, 'run.error.timedOut', { intent: intentName, seconds: plan.timeoutSeconds }));
292
+ return 1;
293
+ }
294
+ if (isOutputLimitExceededError(result.error)) {
295
+ reporter.stderr(t(lang, 'run.error.outputLimitExceeded', { intent: intentName, message: result.error.message }));
296
+ return 1;
297
+ }
298
+ reporter.stderr(t(lang, 'run.error.startFailed', { intent: intentName, message: result.error.message }));
268
299
  return 1;
269
300
  }
270
- reporter.stderr(t(lang, 'run.error.startFailed', { intent: intentName, message: result.error.message }));
271
- return 1;
301
+ return runStatus === 'passed' ? 0 : 1;
302
+ }
303
+ finally {
304
+ activeRunLock.handle.release();
272
305
  }
273
- return runStatus === 'passed' ? 0 : 1;
274
306
  }
@@ -12,6 +12,7 @@ import { createScopeDiffRisks } from '../../core/scope-risk.js';
12
12
  import { countValidationRatchetVerdictEffects, createValidationRatchetRisks, } from '../../core/validation-ratchet.js';
13
13
  import { finishRunWriteBatchTracking, startRunWriteBatchTracking, } from '../../core/run-write-drift.js';
14
14
  import { readCommandContract } from '../../core/config-loading.js';
15
+ import { evaluateCommandPreconditions, } from '../../core/command-preconditions.js';
15
16
  import { DEFAULT_VERIFY_PARALLELISM, parseVerifyArgs, resolveVerifyParallelism, } from './verify/args.js';
16
17
  import { createInputFromChanged, createSyntheticClassificationReport, planErrorMessageKey, readInputFromClassificationReport, resolveVerifyInputPath, writeChangedPlan, } from './verify/input.js';
17
18
  import { readExternalEvidenceFile, readReproEvidenceFile } from './verify/evidence-input.js';
@@ -879,9 +880,22 @@ async function createPlanOnlyOutput(input, projectRoot) {
879
880
  }
880
881
  const scheduledIntents = Array.from(new Set(report.schedule.entries.map((entry) => entry.intent)));
881
882
  const graphsByIntent = await readLocalCommandEffectGraphs(projectRoot, scheduledIntents);
883
+ const preconditionsByIntent = new Map(scheduledIntents.map((intent) => [intent, evaluateCommandPreconditions(projectRoot, contract, intent)]));
882
884
  const firstGraph = graphsByIntent.get(firstEntry.intent);
883
885
  if (!firstGraph) {
884
- return { ...report, correlation_id: input.correlationId, verification_plan_id: verificationPlanId, requirements };
886
+ return {
887
+ ...report,
888
+ correlation_id: input.correlationId,
889
+ verification_plan_id: verificationPlanId,
890
+ requirements,
891
+ schedule: {
892
+ ...report.schedule,
893
+ entries: report.schedule.entries.map((entry) => ({
894
+ ...entry,
895
+ preconditions: preconditionsByIntent.get(entry.intent) ?? [],
896
+ })),
897
+ },
898
+ };
885
899
  }
886
900
  return {
887
901
  ...report,
@@ -893,6 +907,7 @@ async function createPlanOnlyOutput(input, projectRoot) {
893
907
  entries: report.schedule.entries.map((entry) => ({
894
908
  ...entry,
895
909
  effectGraph: graphsByIntent.get(entry.intent) ?? firstGraph,
910
+ preconditions: preconditionsByIntent.get(entry.intent) ?? [],
896
911
  })),
897
912
  },
898
913
  };