mustflow 1.30.0 → 1.31.0

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 (44) hide show
  1. package/README.md +12 -2
  2. package/dist/cli/commands/run.js +221 -48
  3. package/dist/cli/commands/upgrade.js +65 -0
  4. package/dist/cli/commands/verify.js +79 -7
  5. package/dist/cli/i18n/en.js +12 -0
  6. package/dist/cli/i18n/es.js +12 -0
  7. package/dist/cli/i18n/fr.js +12 -0
  8. package/dist/cli/i18n/hi.js +12 -0
  9. package/dist/cli/i18n/ko.js +12 -0
  10. package/dist/cli/i18n/zh.js +12 -0
  11. package/dist/cli/index.js +27 -46
  12. package/dist/cli/lib/command-registry.js +5 -0
  13. package/dist/cli/lib/dashboard-html.js +1 -1
  14. package/dist/cli/lib/local-index.js +11 -8
  15. package/dist/cli/lib/reporter.js +6 -0
  16. package/dist/cli/lib/run-plan.js +20 -3
  17. package/dist/cli/lib/validation.js +110 -1
  18. package/dist/core/bounded-output.js +38 -0
  19. package/dist/core/change-classification.js +6 -2
  20. package/dist/core/change-verification.js +240 -6
  21. package/dist/core/check-issues.js +6 -0
  22. package/dist/core/command-contract-validation.js +20 -0
  23. package/dist/core/command-effects.js +13 -0
  24. package/dist/core/contract-lint.js +95 -1
  25. package/dist/core/dashboard-verification.js +8 -0
  26. package/dist/core/public-json-contracts.js +7 -0
  27. package/dist/core/run-performance-history.js +307 -0
  28. package/dist/core/run-profile.js +87 -0
  29. package/dist/core/run-receipt.js +171 -4
  30. package/dist/core/run-write-drift.js +18 -2
  31. package/dist/core/skill-route-alignment.js +90 -0
  32. package/dist/core/test-selection.js +224 -0
  33. package/dist/core/verification-decision-graph.js +67 -0
  34. package/dist/core/verification-scheduler.js +96 -2
  35. package/package.json +1 -1
  36. package/schemas/README.md +6 -2
  37. package/schemas/change-verification-report.schema.json +153 -3
  38. package/schemas/commands.schema.json +47 -1
  39. package/schemas/contract-lint-report.schema.json +51 -0
  40. package/schemas/dashboard-export.schema.json +273 -0
  41. package/schemas/explain-report.schema.json +2 -0
  42. package/schemas/run-receipt.schema.json +109 -0
  43. package/templates/default/common/.mustflow/config/commands.toml +1 -1
  44. package/templates/default/manifest.toml +1 -1
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Languages: [English](README.md) · [한국어](docs/i18n/ko/README.md) · [中文](docs/i18n/zh/README.md) · [Español](docs/i18n/es/README.md) · [Français](docs/i18n/fr/README.md) · [हिन्दी](docs/i18n/hi/README.md)
4
4
 
5
- mustflow is a workflow CLI designed for LLM coding agents. It guides agents to enter a repository, understand the correct operating context, run only authorized commands, and verify their work without guessing.
5
+ mustflow is a repository-local work contract and verification CLI for LLM coding agents. It keeps agents inside explicit read, command, and verification boundaries without replacing the host agent's sandbox, approval, checkpoint, model, or tool policies.
6
6
 
7
- The core concept is straightforward: place `AGENTS.md` at the project root and keep detailed workflows under `.mustflow/`. Agents start from `AGENTS.md` and then follow the command contract, skills, project context, and verification rules in sequence.
7
+ The core concept is straightforward: place `AGENTS.md` at the project root and keep detailed workflows under `.mustflow/`. Agents start from `AGENTS.md`, then follow the repository command contract, skills, project context, and verification rules in sequence.
8
8
 
9
9
  - Documentation site: <https://0disoft.github.io/mustflow/>
10
10
  - Human-readable project examples: [`examples/`](examples/)
@@ -268,6 +268,14 @@ npx mf update --dry-run
268
268
  npx mf update --apply
269
269
  ```
270
270
 
271
+ After updating the mustflow package, `mf upgrade` combines the package freshness check with the safe project-file update step. It does not install packages by itself; update npm, pnpm, or Bun first.
272
+
273
+ ```sh
274
+ bun update -g mustflow
275
+ mf upgrade --dry-run
276
+ mf upgrade
277
+ ```
278
+
271
279
  Agents should prefer the configured update intents so the repository receives a run receipt.
272
280
 
273
281
  ```sh
@@ -304,6 +312,8 @@ mf run mustflow_update_apply
304
312
  | `mf status` | Inspect installed state and changed or missing files. |
305
313
  | `mf update --dry-run` | Calculate a template update plan without writing files. |
306
314
  | `mf update --apply` | Apply template updates when nothing is blocked. |
315
+ | `mf upgrade` | Check package freshness, then apply safe bundled template updates when the package is current. |
316
+ | `mf upgrade --dry-run` | Check package freshness and print the safe project update plan without writing files. |
307
317
  | `mf help <topic>` | Show installed mustflow help. |
308
318
  | `mf dashboard` | Start a local inspection dashboard for status, verification recommendations, release/version-source status, template update readiness, latest run receipt, skill routes, safe preferences, and documentation review. Use `--export-json <path>` or `--export <path>` for a bounded static report. It does not execute commands or apply fixes. |
309
319
  | `mf version` | Print the installed mustflow package version. |
@@ -1,17 +1,8 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { runCheck } from './check.js';
3
- import { runClassify } from './classify.js';
4
- import { runContext } from './context.js';
5
- import { runDoctor } from './doctor.js';
6
- import { runHelp } from './help.js';
7
- import { runImpact } from './impact.js';
8
- import { runLineEndings } from './line-endings.js';
9
- import { runMap } from './map.js';
10
- import { runStatus } from './status.js';
11
- import { runUpdate } from './update.js';
12
- import { runVersionSources } from './version-sources.js';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { performance } from 'node:perf_hooks';
13
3
  import { canRunMustflowBuiltinInProcess, isMustflowBinName } from '../../core/command-classification.js';
14
4
  import { createCommandEnv } from '../../core/command-env.js';
5
+ import { BoundedOutputBuffer } from '../../core/bounded-output.js';
15
6
  import { printUsageError, renderCliError, renderHelp } from '../lib/cli-output.js';
16
7
  import { readCommandContract, readMustflowConfigIfExists } from '../../core/config-loading.js';
17
8
  import { resolveRunReceiptRetentionPolicy } from '../../core/retention-policy.js';
@@ -20,12 +11,14 @@ import { getPackageVersion } from '../lib/package-info.js';
20
11
  import { resolveMustflowRoot } from '../lib/project-root.js';
21
12
  import { createRunPlan, createRunPreview, isMustflowBuiltinIntent, renderRunPreviewText, } from '../lib/run-plan.js';
22
13
  import { createRunReceipt, writeRunReceipt } from '../../core/run-receipt.js';
14
+ import { recordRunPerformanceHistory } from '../../core/run-performance-history.js';
15
+ import { RunProfiler } from '../../core/run-profile.js';
23
16
  import { finishRunWriteTracking, startRunWriteTracking } from '../../core/run-write-drift.js';
24
17
  function emitOutput(reporter, output, stream) {
25
18
  if (!output) {
26
19
  return;
27
20
  }
28
- const text = output.toString().trimEnd();
21
+ const text = (typeof output === 'object' && 'tail' in output ? output.tail : output.toString()).trimEnd();
29
22
  if (text.length === 0) {
30
23
  return;
31
24
  }
@@ -94,37 +87,37 @@ async function runKnownBuiltinCommand(args, reporter, lang) {
94
87
  return undefined;
95
88
  }
96
89
  if (command === 'check') {
97
- return runCheck(commandArgs, reporter, lang);
90
+ return (await import('./check.js')).runCheck(commandArgs, reporter, lang);
98
91
  }
99
92
  if (command === 'classify') {
100
- return runClassify(commandArgs, reporter, lang);
93
+ return (await import('./classify.js')).runClassify(commandArgs, reporter, lang);
101
94
  }
102
95
  if (command === 'context') {
103
- return runContext(commandArgs, reporter, lang);
96
+ return (await import('./context.js')).runContext(commandArgs, reporter, lang);
104
97
  }
105
98
  if (command === 'doctor') {
106
- return runDoctor(commandArgs, reporter, lang);
99
+ return (await import('./doctor.js')).runDoctor(commandArgs, reporter, lang);
107
100
  }
108
101
  if (command === 'help') {
109
- return runHelp(commandArgs, reporter, lang);
102
+ return (await import('./help.js')).runHelp(commandArgs, reporter, lang);
110
103
  }
111
104
  if (command === 'impact') {
112
- return runImpact(commandArgs, reporter, lang);
105
+ return (await import('./impact.js')).runImpact(commandArgs, reporter, lang);
113
106
  }
114
107
  if (command === 'line-endings') {
115
- return runLineEndings(commandArgs, reporter, lang);
108
+ return (await import('./line-endings.js')).runLineEndings(commandArgs, reporter, lang);
116
109
  }
117
110
  if (command === 'map') {
118
- return runMap(commandArgs, reporter, lang);
111
+ return (await import('./map.js')).runMap(commandArgs, reporter, lang);
119
112
  }
120
113
  if (command === 'status') {
121
- return runStatus(commandArgs, reporter, lang);
114
+ return (await import('./status.js')).runStatus(commandArgs, reporter, lang);
122
115
  }
123
116
  if (command === 'update') {
124
- return runUpdate(commandArgs, reporter, lang);
117
+ return (await import('./update.js')).runUpdate(commandArgs, reporter, lang);
125
118
  }
126
119
  if (command === 'version-sources') {
127
- return runVersionSources(commandArgs, reporter, lang);
120
+ return (await import('./version-sources.js')).runVersionSources(commandArgs, reporter, lang);
128
121
  }
129
122
  return undefined;
130
123
  }
@@ -179,6 +172,76 @@ function runArgvCommand(command, cwd, maxOutputBytes, env, timeoutSeconds) {
179
172
  windowsHide: true,
180
173
  });
181
174
  }
175
+ function writeStreamChunk(reporter, stream, chunk) {
176
+ if (stream === 'stdout') {
177
+ if (reporter.writeStdout) {
178
+ reporter.writeStdout(chunk);
179
+ return;
180
+ }
181
+ reporter.stdout(chunk.toString().trimEnd());
182
+ return;
183
+ }
184
+ if (reporter.writeStderr) {
185
+ reporter.writeStderr(chunk);
186
+ return;
187
+ }
188
+ reporter.stderr(chunk.toString().trimEnd());
189
+ }
190
+ function runArgvCommandStreaming(command, cwd, env, timeoutSeconds, stdoutTailBytes, stderrTailBytes, reporter) {
191
+ return new Promise((resolve) => {
192
+ const stdout = new BoundedOutputBuffer(stdoutTailBytes);
193
+ const stderr = new BoundedOutputBuffer(stderrTailBytes);
194
+ let settled = false;
195
+ let timedOut = false;
196
+ let childError;
197
+ let childPid;
198
+ let timeout;
199
+ const child = spawn(command?.executable ?? '', command?.args ?? [], {
200
+ cwd,
201
+ env,
202
+ shell: command?.shell ?? false,
203
+ stdio: ['ignore', 'pipe', 'pipe'],
204
+ windowsHide: true,
205
+ detached: process.platform !== 'win32',
206
+ });
207
+ childPid = child.pid;
208
+ const finish = (status, signal) => {
209
+ if (settled) {
210
+ return;
211
+ }
212
+ settled = true;
213
+ if (timeout) {
214
+ clearTimeout(timeout);
215
+ }
216
+ resolve({
217
+ status,
218
+ signal,
219
+ error: timedOut ? Object.assign(new Error('Command timed out'), { code: 'ETIMEDOUT' }) : childError,
220
+ stdout: stdout.toSnapshot(),
221
+ stderr: stderr.toSnapshot(),
222
+ pid: childPid,
223
+ });
224
+ };
225
+ child.stdout?.on('data', (chunk) => {
226
+ stdout.append(chunk);
227
+ writeStreamChunk(reporter, 'stdout', chunk);
228
+ });
229
+ child.stderr?.on('data', (chunk) => {
230
+ stderr.append(chunk);
231
+ writeStreamChunk(reporter, 'stderr', chunk);
232
+ });
233
+ child.once('error', (error) => {
234
+ childError = error;
235
+ });
236
+ child.once('close', (status, signal) => {
237
+ finish(status, signal);
238
+ });
239
+ timeout = setTimeout(() => {
240
+ timedOut = true;
241
+ terminateProcessTree(childPid);
242
+ }, timeoutSeconds * 1000);
243
+ });
244
+ }
182
245
  function runShellCommand(command, cwd, maxOutputBytes, env, timeoutSeconds) {
183
246
  return spawnSync(command ?? '', {
184
247
  cwd,
@@ -192,6 +255,61 @@ function runShellCommand(command, cwd, maxOutputBytes, env, timeoutSeconds) {
192
255
  windowsHide: true,
193
256
  });
194
257
  }
258
+ function runShellCommandStreaming(command, cwd, env, timeoutSeconds, stdoutTailBytes, stderrTailBytes, reporter) {
259
+ return new Promise((resolve) => {
260
+ const stdout = new BoundedOutputBuffer(stdoutTailBytes);
261
+ const stderr = new BoundedOutputBuffer(stderrTailBytes);
262
+ let settled = false;
263
+ let timedOut = false;
264
+ let childError;
265
+ let childPid;
266
+ let timeout;
267
+ const child = spawn(command ?? '', {
268
+ cwd,
269
+ env,
270
+ shell: true,
271
+ stdio: ['ignore', 'pipe', 'pipe'],
272
+ windowsHide: true,
273
+ detached: process.platform !== 'win32',
274
+ });
275
+ childPid = child.pid;
276
+ const finish = (status, signal) => {
277
+ if (settled) {
278
+ return;
279
+ }
280
+ settled = true;
281
+ if (timeout) {
282
+ clearTimeout(timeout);
283
+ }
284
+ resolve({
285
+ status,
286
+ signal,
287
+ error: timedOut ? Object.assign(new Error('Command timed out'), { code: 'ETIMEDOUT' }) : childError,
288
+ stdout: stdout.toSnapshot(),
289
+ stderr: stderr.toSnapshot(),
290
+ pid: childPid,
291
+ });
292
+ };
293
+ child.stdout?.on('data', (chunk) => {
294
+ stdout.append(chunk);
295
+ writeStreamChunk(reporter, 'stdout', chunk);
296
+ });
297
+ child.stderr?.on('data', (chunk) => {
298
+ stderr.append(chunk);
299
+ writeStreamChunk(reporter, 'stderr', chunk);
300
+ });
301
+ child.once('error', (error) => {
302
+ childError = error;
303
+ });
304
+ child.once('close', (status, signal) => {
305
+ finish(status, signal);
306
+ });
307
+ timeout = setTimeout(() => {
308
+ timedOut = true;
309
+ terminateProcessTree(childPid);
310
+ }, timeoutSeconds * 1000);
311
+ });
312
+ }
195
313
  function getRunStatus(error, exitCode, successExitCodes) {
196
314
  const errorWithCode = error;
197
315
  if (errorWithCode?.code === 'ETIMEDOUT') {
@@ -281,7 +399,9 @@ export function getRunHelp(lang = 'en') {
281
399
  * invariant: Execution requires configured status, oneshot lifecycle, agent_allowed policy, and closed stdin.
282
400
  * risk: config, security, state
283
401
  */
284
- export async function runRun(args, reporter, lang = 'en') {
402
+ export async function runRun(args, reporter, lang = 'en', options = {}) {
403
+ const executorStartedAtMs = performance.now();
404
+ const profiler = new RunProfiler();
285
405
  if (args.includes('--help') || args.includes('-h')) {
286
406
  reporter.stdout(getRunHelp(lang));
287
407
  return 0;
@@ -310,36 +430,69 @@ export async function runRun(args, reporter, lang = 'en') {
310
430
  printUsageError(reporter, t(lang, 'cli.error.unexpectedArgument', { argument: extra[0] }), 'mf run --help', getRunHelp(lang), lang);
311
431
  return 1;
312
432
  }
313
- const projectRoot = resolveMustflowRoot();
314
- const contract = readCommandContract(projectRoot);
315
- const plan = createRunPlan(projectRoot, contract, intentName);
433
+ const projectRoot = profiler.measure('root_detection', () => resolveMustflowRoot());
434
+ const contract = profiler.measure('command_contract', () => readCommandContract(projectRoot));
435
+ const plan = profiler.measure('plan_creation', () => createRunPlan(projectRoot, contract, intentName, { testTargets: options.testTargets }));
316
436
  if (previewMode) {
317
- if (json) {
318
- reporter.stdout(JSON.stringify(createRunPreview(plan, previewMode), null, 2));
319
- }
320
- else {
321
- reporter.stdout(renderRunPreviewText(plan, previewMode));
322
- }
437
+ profiler.measure('preview_render', () => {
438
+ if (json) {
439
+ reporter.stdout(JSON.stringify(createRunPreview(plan, previewMode), null, 2));
440
+ }
441
+ else {
442
+ reporter.stdout(renderRunPreviewText(plan, previewMode));
443
+ }
444
+ });
445
+ profiler.writeLatest({
446
+ projectRoot,
447
+ intent: intentName,
448
+ status: plan.ok ? 'previewed' : 'blocked',
449
+ previewMode,
450
+ });
323
451
  return plan.ok ? 0 : 1;
324
452
  }
325
453
  if (!plan.ok) {
326
454
  reportRunPlanFailure(plan, reporter, lang);
455
+ profiler.writeLatest({
456
+ projectRoot,
457
+ intent: intentName,
458
+ status: 'blocked',
459
+ previewMode: null,
460
+ });
327
461
  return 1;
328
462
  }
329
- const runReceiptPolicy = resolveRunReceiptRetentionPolicy(readMustflowConfigIfExists(projectRoot));
330
- const env = createCommandEnv(projectRoot, { policy: plan.envPolicy, allowlist: plan.envAllowlist });
463
+ const runReceiptPolicy = profiler.measure('retention_policy', () => resolveRunReceiptRetentionPolicy(readMustflowConfigIfExists(projectRoot)));
464
+ const env = profiler.measure('environment', () => createCommandEnv(projectRoot, { policy: plan.envPolicy, allowlist: plan.envAllowlist }));
331
465
  const lifecycleValue = plan.lifecycle ?? 'oneshot';
332
466
  const runPolicyValue = plan.runPolicy ?? 'agent_allowed';
333
- const writeTracker = startRunWriteTracking(projectRoot, contract, intentName);
467
+ const writeTracker = profiler.measure('write_drift_before', () => startRunWriteTracking(projectRoot, contract, intentName));
468
+ const stdoutTailBytes = Math.min(runReceiptPolicy.stdoutTailBytes, plan.maxOutputBytes);
469
+ const stderrTailBytes = Math.min(runReceiptPolicy.stderrTailBytes, plan.maxOutputBytes);
470
+ let streamedOutput = false;
471
+ const childStartedAtMs = performance.now();
334
472
  const startedAt = new Date();
335
- const result = plan.commandArgv && isMustflowBuiltinIntent(plan.intent)
336
- ? ((await runBuiltinArgvInProcess(plan.commandArgv, plan.cwd, lang)) ??
337
- runArgvCommand(plan.argvCommand, plan.cwd, plan.maxOutputBytes, env, plan.timeoutSeconds))
338
- : plan.commandArgv
339
- ? runArgvCommand(plan.argvCommand, plan.cwd, plan.maxOutputBytes, env, plan.timeoutSeconds)
340
- : runShellCommand(plan.shellCommand, plan.cwd, plan.maxOutputBytes, env, plan.timeoutSeconds);
473
+ const result = await profiler.measureAsync('child_command', async () => {
474
+ if (plan.commandArgv && isMustflowBuiltinIntent(plan.intent)) {
475
+ const builtinResult = await runBuiltinArgvInProcess(plan.commandArgv, plan.cwd, lang);
476
+ if (builtinResult) {
477
+ return builtinResult;
478
+ }
479
+ }
480
+ if (plan.commandArgv) {
481
+ if (!json) {
482
+ streamedOutput = true;
483
+ return runArgvCommandStreaming(plan.argvCommand, plan.cwd, env, plan.timeoutSeconds, stdoutTailBytes, stderrTailBytes, reporter);
484
+ }
485
+ return runArgvCommand(plan.argvCommand, plan.cwd, plan.maxOutputBytes, env, plan.timeoutSeconds);
486
+ }
487
+ if (!json) {
488
+ streamedOutput = true;
489
+ return runShellCommandStreaming(plan.shellCommand, plan.cwd, env, plan.timeoutSeconds, stdoutTailBytes, stderrTailBytes, reporter);
490
+ }
491
+ return runShellCommand(plan.shellCommand, plan.cwd, plan.maxOutputBytes, env, plan.timeoutSeconds);
492
+ });
493
+ const childDurationMs = performance.now() - childStartedAtMs;
341
494
  const finishedAt = new Date();
342
- const writeDrift = finishRunWriteTracking(writeTracker);
495
+ const writeDrift = profiler.measure('write_drift_after', () => finishRunWriteTracking(writeTracker));
343
496
  const exitCode = typeof result.status === 'number' ? result.status : null;
344
497
  const runStatus = getRunStatus(result.error, exitCode, plan.successExitCodes);
345
498
  let killMethod = null;
@@ -347,7 +500,7 @@ export async function runRun(args, reporter, lang = 'en') {
347
500
  killMethod = getKillMethod();
348
501
  terminateProcessTree(result.pid);
349
502
  }
350
- const receipt = createRunReceipt({
503
+ const receipt = profiler.measure('receipt_create', () => createRunReceipt({
351
504
  intent: intentName,
352
505
  status: runStatus,
353
506
  timedOut: runStatus === 'timed_out',
@@ -372,16 +525,36 @@ export async function runRun(args, reporter, lang = 'en') {
372
525
  stdout: result.stdout,
373
526
  stderr: result.stderr,
374
527
  writeDrift,
528
+ executorOverheadMs: Math.max(0, Math.round((performance.now() - executorStartedAtMs - childDurationMs) * 1000) / 1000),
529
+ phaseTimings: profiler.getReceiptPhases(),
530
+ selectionSummary: {
531
+ strategy: plan.testTargets.length > 0 ? 'project_test_selection' : 'direct_intent',
532
+ changed_file_count: 0,
533
+ changed_surface_counts: {},
534
+ selected_target_count: Math.max(1, plan.testTargets.length),
535
+ fallback_used: false,
536
+ },
375
537
  stdoutTailBytes: runReceiptPolicy.stdoutTailBytes,
376
538
  stderrTailBytes: runReceiptPolicy.stderrTailBytes,
539
+ }));
540
+ if (options.writeLatestReceipt !== false) {
541
+ profiler.measure('receipt_write', () => writeRunReceipt(projectRoot, receipt));
542
+ }
543
+ profiler.measure('performance_history_write', () => recordRunPerformanceHistory(projectRoot, receipt));
544
+ profiler.writeLatest({
545
+ projectRoot,
546
+ intent: intentName,
547
+ status: runStatus,
548
+ previewMode: null,
377
549
  });
378
- writeRunReceipt(projectRoot, receipt);
379
550
  if (json) {
380
551
  reporter.stdout(JSON.stringify(receipt, null, 2));
381
552
  return runStatus === 'passed' ? 0 : 1;
382
553
  }
383
- emitOutput(reporter, result.stdout, 'stdout');
384
- emitOutput(reporter, result.stderr, 'stderr');
554
+ if (!streamedOutput) {
555
+ emitOutput(reporter, result.stdout, 'stdout');
556
+ emitOutput(reporter, result.stderr, 'stderr');
557
+ }
385
558
  if (result.error) {
386
559
  const errorWithCode = result.error;
387
560
  if (errorWithCode.code === 'ETIMEDOUT') {
@@ -0,0 +1,65 @@
1
+ import { printUsageError, renderCliError, renderHelp } from '../lib/cli-output.js';
2
+ import { t } from '../lib/i18n.js';
3
+ import { checkNpmLatestVersion } from '../lib/npm-version-check.js';
4
+ import { readPackageMetadata } from '../lib/package-info.js';
5
+ import { runUpdate } from './update.js';
6
+ export function getUpgradeHelp(lang = 'en') {
7
+ return renderHelp({
8
+ usage: 'mf upgrade [options]',
9
+ summary: t(lang, 'upgrade.help.summary'),
10
+ options: [
11
+ { label: '--dry-run', description: t(lang, 'upgrade.help.option.dryRun') },
12
+ { label: '-h, --help', description: t(lang, 'cli.option.help') },
13
+ ],
14
+ examples: ['mf upgrade', 'mf upgrade --dry-run'],
15
+ exitCodes: [
16
+ { label: '0', description: t(lang, 'upgrade.help.exit.ok') },
17
+ { label: '1', description: t(lang, 'upgrade.help.exit.fail') },
18
+ ],
19
+ }, lang);
20
+ }
21
+ function printPackageCheck(check, reporter, lang) {
22
+ reporter.stdout(`${check.packageName} ${check.currentVersion}`);
23
+ reporter.stdout(check.updateAvailable
24
+ ? t(lang, 'version.check.latestAvailable', { version: check.latestVersion })
25
+ : t(lang, 'version.check.upToDate', { version: check.latestVersion }));
26
+ if (check.updateAvailable) {
27
+ reporter.stdout('');
28
+ reporter.stdout(t(lang, 'version.check.updateCommand'));
29
+ reporter.stdout(check.updateCommand);
30
+ }
31
+ }
32
+ export async function runUpgrade(args, reporter, lang = 'en') {
33
+ if (args.includes('--help') || args.includes('-h')) {
34
+ reporter.stdout(getUpgradeHelp(lang));
35
+ return 0;
36
+ }
37
+ const supported = new Set(['--dry-run']);
38
+ const unsupported = args.filter((arg) => !supported.has(arg));
39
+ if (unsupported.length > 0) {
40
+ printUsageError(reporter, t(lang, 'cli.error.unknownOption', { option: unsupported[0] }), 'mf upgrade --help', getUpgradeHelp(lang), lang);
41
+ return 1;
42
+ }
43
+ const dryRun = args.includes('--dry-run');
44
+ reporter.stdout(t(lang, 'upgrade.title'));
45
+ reporter.stdout('');
46
+ reporter.stdout(t(lang, 'upgrade.packageSection'));
47
+ try {
48
+ const packageCheck = await checkNpmLatestVersion(readPackageMetadata());
49
+ printPackageCheck(packageCheck, reporter, lang);
50
+ if (packageCheck.updateAvailable) {
51
+ reporter.stdout('');
52
+ reporter.stdout(t(lang, 'upgrade.packageUpdateRequired'));
53
+ reporter.stdout(t(lang, 'upgrade.noFilesWritten'));
54
+ return 1;
55
+ }
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ reporter.stderr(renderCliError(t(lang, 'upgrade.warning.versionCheckFailed', { message }), 'mf version --check', lang));
60
+ reporter.stderr(t(lang, 'upgrade.warning.continueWithBundledTemplate'));
61
+ }
62
+ reporter.stdout('');
63
+ reporter.stdout(t(lang, 'upgrade.projectSection'));
64
+ return runUpdate([dryRun ? '--dry-run' : '--apply'], reporter, lang);
65
+ }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createClassifyOutput } from './classify.js';
4
4
  import { runRun } from './run.js';
@@ -9,6 +9,8 @@ import { t } from '../lib/i18n.js';
9
9
  import { readLocalCommandEffectGraph, readLocalPathSurfaces, } from '../lib/local-index.js';
10
10
  import { resolveMustflowRoot } from '../lib/project-root.js';
11
11
  const VERIFY_SCHEMA_VERSION = '1';
12
+ const VERIFY_RUN_DIR = path.join('.mustflow', 'state', 'runs', 'verify-latest');
13
+ const LATEST_RUN_RECEIPT_PATH = path.join('.mustflow', 'state', 'runs', 'latest.json');
12
14
  function createBufferedOutput() {
13
15
  const stdout = [];
14
16
  const stderr = [];
@@ -139,6 +141,13 @@ function parseVerifyArgs(args) {
139
141
  function uniqueStrings(values) {
140
142
  return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
141
143
  }
144
+ function toPosixPath(value) {
145
+ return value.split(path.sep).join('/');
146
+ }
147
+ function sanitizeIntentFilePart(value) {
148
+ const sanitized = value.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
149
+ return sanitized.length > 0 ? sanitized.slice(0, 80) : 'intent';
150
+ }
142
151
  function readStringArray(value) {
143
152
  if (!Array.isArray(value)) {
144
153
  return [];
@@ -343,13 +352,17 @@ function candidateResultKey(candidate) {
343
352
  ? `intent:${candidate.intent}`
344
353
  : `missing:${candidate.reason}:${candidate.skipReason ?? ''}:${candidate.detail ?? ''}`;
345
354
  }
346
- function createSkippedResults(candidates, scheduledIntents) {
355
+ function createSkippedResults(candidates, scheduledIntents, gaps) {
347
356
  const seen = new Set();
348
357
  const results = [];
358
+ const activeGapReasons = new Set(gaps.map((gap) => gap.reason));
349
359
  for (const candidate of candidates) {
350
360
  if (candidate.status === 'runnable' || (candidate.intent && scheduledIntents.has(candidate.intent))) {
351
361
  continue;
352
362
  }
363
+ if (candidate.candidateState === 'gap' && !activeGapReasons.has(candidate.reason)) {
364
+ continue;
365
+ }
353
366
  const key = candidateResultKey(candidate);
354
367
  if (seen.has(key)) {
355
368
  continue;
@@ -363,9 +376,19 @@ function createSkippedResults(candidates, scheduledIntents) {
363
376
  }
364
377
  return results;
365
378
  }
366
- async function runVerificationIntent(intent, lang) {
379
+ function testTargetsByScheduledIntent(report) {
380
+ return new Map(report.test_selection.selected
381
+ .filter((candidate) => candidate.status === 'runnable' &&
382
+ candidate.testTargetsApplied &&
383
+ candidate.appliedTestTargets.length > 0)
384
+ .map((candidate) => [candidate.intent, candidate.appliedTestTargets]));
385
+ }
386
+ async function runVerificationIntent(intent, lang, testTargets = []) {
367
387
  const output = createBufferedOutput();
368
- const exitCode = await runRun([intent, '--json'], output.reporter, lang);
388
+ const exitCode = await runRun([intent, '--json'], output.reporter, lang, {
389
+ writeLatestReceipt: false,
390
+ testTargets,
391
+ });
369
392
  const rawStdout = output.stdout().trim();
370
393
  let receipt = null;
371
394
  let status = exitCode === 0 ? 'passed' : 'failed';
@@ -419,17 +442,64 @@ function getVerificationStatus(summary) {
419
442
  }
420
443
  return 'passed';
421
444
  }
445
+ function writeVerifyRunReceipts(projectRoot, output) {
446
+ const runDir = path.join(projectRoot, VERIFY_RUN_DIR);
447
+ const intentDir = path.join(runDir, 'intents');
448
+ const receipts = [];
449
+ rmSync(runDir, { recursive: true, force: true });
450
+ mkdirSync(intentDir, { recursive: true });
451
+ for (const [index, result] of output.results.entries()) {
452
+ let receiptPath = null;
453
+ if (result.intent && result.receipt) {
454
+ const fileName = `${String(index + 1).padStart(3, '0')}-${sanitizeIntentFilePart(result.intent)}.json`;
455
+ const absoluteReceiptPath = path.join(intentDir, fileName);
456
+ receiptPath = toPosixPath(path.join(VERIFY_RUN_DIR, 'intents', fileName));
457
+ writeFileSync(absoluteReceiptPath, `${JSON.stringify({ ...result.receipt, receipt_path: receiptPath }, null, 2)}\n`, 'utf8');
458
+ }
459
+ receipts.push({
460
+ intent: result.intent,
461
+ status: result.status,
462
+ skipped: result.skipped,
463
+ receipt_path: receiptPath,
464
+ });
465
+ }
466
+ const manifest = {
467
+ schema_version: '1',
468
+ command: 'verify',
469
+ reason: output.reason,
470
+ reasons: output.reasons,
471
+ plan_source: output.plan_source,
472
+ status: output.status,
473
+ summary: output.summary,
474
+ receipts,
475
+ };
476
+ writeFileSync(path.join(runDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
477
+ const latest = {
478
+ schema_version: '1',
479
+ command: 'verify',
480
+ kind: 'verify_run_summary',
481
+ reason: output.reason,
482
+ reasons: output.reasons,
483
+ plan_source: output.plan_source,
484
+ status: output.status,
485
+ summary: output.summary,
486
+ run_dir: toPosixPath(VERIFY_RUN_DIR),
487
+ manifest_path: toPosixPath(path.join(VERIFY_RUN_DIR, 'manifest.json')),
488
+ };
489
+ writeFileSync(path.join(projectRoot, LATEST_RUN_RECEIPT_PATH), `${JSON.stringify(latest, null, 2)}\n`, 'utf8');
490
+ }
422
491
  async function createVerifyOutput(input, planSource, projectRoot, lang) {
423
492
  const contract = readCommandContract(projectRoot);
424
493
  const report = createChangeVerificationReport(input.classificationReport, contract, projectRoot);
425
494
  const scheduledIntents = new Set(report.schedule.entries.map((entry) => entry.intent));
495
+ const scheduledTestTargets = testTargetsByScheduledIntent(report);
426
496
  const results = [];
427
497
  for (const entry of report.schedule.entries) {
428
- results.push(await runVerificationIntent(entry.intent, lang));
498
+ results.push(await runVerificationIntent(entry.intent, lang, scheduledTestTargets.get(entry.intent) ?? []));
429
499
  }
430
- results.push(...createSkippedResults(report.candidates, scheduledIntents));
500
+ results.push(...createSkippedResults(report.candidates, scheduledIntents, report.gaps));
431
501
  const summary = summarizeResults(results);
432
- return {
502
+ const output = {
433
503
  schema_version: VERIFY_SCHEMA_VERSION,
434
504
  command: 'verify',
435
505
  mustflow_root: projectRoot,
@@ -440,6 +510,8 @@ async function createVerifyOutput(input, planSource, projectRoot, lang) {
440
510
  summary,
441
511
  results,
442
512
  };
513
+ writeVerifyRunReceipts(projectRoot, output);
514
+ return output;
443
515
  }
444
516
  async function createPlanOnlyOutput(input, projectRoot) {
445
517
  const contract = readCommandContract(projectRoot);
@@ -27,6 +27,7 @@ export const enMessages = {
27
27
  "command.contractLint.summary": "Lint the command contract",
28
28
  "command.status.summary": "Show local mustflow install status",
29
29
  "command.update.summary": "Preview or apply mustflow workflow updates",
30
+ "command.upgrade.summary": "Check the package version and safely update installed workflow files",
30
31
  "command.map.summary": "Generate REPO_MAP.md",
31
32
  "command.lineEndings.summary": "Inspect and normalize line-ending policy",
32
33
  "command.run.summary": "Run a configured oneshot command",
@@ -658,6 +659,17 @@ Read these files before working:
658
659
  "version.check.upToDate": "latest {version}; already up to date",
659
660
  "version.check.updateCommand": "Update command:",
660
661
  "version.error.checkFailed": "Could not check npm for a newer version: {message}",
662
+ "upgrade.help.summary": "Check whether the installed mustflow package is current, then safely apply bundled workflow template updates when possible.",
663
+ "upgrade.help.option.dryRun": "Check package status and print the project update plan without writing files",
664
+ "upgrade.help.exit.ok": "The package was current and the project update check completed",
665
+ "upgrade.help.exit.fail": "A package update is required, a project update blocker was found, or input was invalid",
666
+ "upgrade.title": "mustflow upgrade",
667
+ "upgrade.packageSection": "Package:",
668
+ "upgrade.projectSection": "Project template:",
669
+ "upgrade.packageUpdateRequired": "Update the mustflow package first, then run `mf upgrade` again.",
670
+ "upgrade.noFilesWritten": "No project files were written.",
671
+ "upgrade.warning.versionCheckFailed": "Could not check npm for a newer version: {message}",
672
+ "upgrade.warning.continueWithBundledTemplate": "Continuing with the bundled template in the current CLI.",
661
673
  "classify.help.summary": "Classify changed paths, public surfaces, and required validation reasons without modifying files.",
662
674
  "classify.help.option.changed": "Read paths from git status --short --untracked-files=all",
663
675
  "classify.help.exit.ok": "Change classification was inspected and printed",