mustflow 2.113.0 → 2.114.5

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,4 @@
1
- import { existsSync, renameSync, rmSync } from 'node:fs';
1
+ import { existsSync, readdirSync, renameSync, rmSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import { readCommandContract, readCommandContractIncludePaths, } from '../../core/config-loading.js';
@@ -7,10 +7,13 @@ const EXTERNAL_SKILL_ROOT = '.mustflow/external-skills';
7
7
  const PROVENANCE_FILE = 'mustflow-skill-source.json';
8
8
  const COMMANDS_CONFIG_PATH = '.mustflow/config/commands.toml';
9
9
  const COMMAND_FRAGMENT_DIRECTORY = '.mustflow/config/commands';
10
+ const EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH = '.mustflow/state/external-skills/update-check.json';
10
11
  const DEFAULT_GITHUB_REF = 'HEAD';
11
12
  const MAX_IMPORTED_FILES = 40;
12
13
  const MAX_TOTAL_BYTES = 512 * 1024;
13
14
  const MAX_FILE_BYTES = 256 * 1024;
15
+ const UPDATE_CHECK_STALE_AFTER_DAYS = 7;
16
+ const UPDATE_CHECK_STALE_AFTER_MS = UPDATE_CHECK_STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
14
17
  const ALLOWED_SUPPORT_DIRECTORIES = new Set(['assets', 'references', 'scripts']);
15
18
  const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
16
19
  const GITHUB_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/u;
@@ -365,6 +368,212 @@ function createTarget(skillName) {
365
368
  provenance_path: `${skillDir}/${PROVENANCE_FILE}`,
366
369
  };
367
370
  }
371
+ function sourceToParsed(source) {
372
+ return {
373
+ host: source.host,
374
+ owner: source.owner,
375
+ repo: source.repo,
376
+ ref: source.ref,
377
+ skillPath: source.skill_path,
378
+ sourceUrl: source.source_url,
379
+ };
380
+ }
381
+ function provenancePathForSkill(skillName) {
382
+ return `${EXTERNAL_SKILL_ROOT}/${skillName}/${PROVENANCE_FILE}`;
383
+ }
384
+ function readJsonFile(projectRoot, relativePath, maxBytes = 512 * 1024) {
385
+ const content = readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...relativePath.split('/')), { maxBytes });
386
+ return JSON.parse(content);
387
+ }
388
+ function assertExternalSkillSource(value, skillName) {
389
+ if (!value || typeof value !== 'object') {
390
+ throw new Error(`External skill provenance is not an object: ${skillName}`);
391
+ }
392
+ const record = value;
393
+ if (record.schema_version !== '1' || record.kind !== 'external_skill_source') {
394
+ throw new Error(`External skill provenance has an unsupported shape: ${skillName}`);
395
+ }
396
+ if (!record.source || typeof record.source !== 'object') {
397
+ throw new Error(`External skill provenance is missing source metadata: ${skillName}`);
398
+ }
399
+ if (!Array.isArray(record.files)) {
400
+ throw new Error(`External skill provenance is missing file hashes: ${skillName}`);
401
+ }
402
+ return record;
403
+ }
404
+ function installedExternalSkillNames(projectRoot) {
405
+ const rootPath = path.join(projectRoot, ...EXTERNAL_SKILL_ROOT.split('/'));
406
+ if (!existsSync(rootPath)) {
407
+ return [];
408
+ }
409
+ return readdirSync(rootPath, { withFileTypes: true })
410
+ .filter((entry) => entry.isDirectory())
411
+ .map((entry) => entry.name)
412
+ .filter((name) => SLUG_PATTERN.test(name) && existsSync(path.join(rootPath, name, PROVENANCE_FILE)))
413
+ .sort((left, right) => left.localeCompare(right));
414
+ }
415
+ function readExternalSkillProvenance(projectRoot, skillName) {
416
+ return assertExternalSkillSource(readJsonFile(projectRoot, provenancePathForSkill(skillName)), skillName);
417
+ }
418
+ function readExternalSkillUpdateCheckStateFile(projectRoot) {
419
+ const statePath = path.join(projectRoot, ...EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH.split('/'));
420
+ if (!existsSync(statePath)) {
421
+ return null;
422
+ }
423
+ try {
424
+ const value = readJsonFile(projectRoot, EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH, 64 * 1024);
425
+ if (!value || typeof value !== 'object') {
426
+ return null;
427
+ }
428
+ const record = value;
429
+ if (record.schema_version !== '1' ||
430
+ record.kind !== 'external_skill_update_check_state' ||
431
+ typeof record.last_checked_at !== 'string' ||
432
+ Number.isNaN(Date.parse(record.last_checked_at))) {
433
+ return null;
434
+ }
435
+ return {
436
+ schema_version: '1',
437
+ kind: 'external_skill_update_check_state',
438
+ last_checked_at: record.last_checked_at,
439
+ };
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ function createExternalSkillUpdateCheckState(projectRoot, now) {
446
+ const previous = readExternalSkillUpdateCheckStateFile(projectRoot);
447
+ const checkedAt = now.toISOString();
448
+ const nextCheckDueAt = new Date(now.getTime() + UPDATE_CHECK_STALE_AFTER_MS).toISOString();
449
+ const previousTime = previous ? Date.parse(previous.last_checked_at) : Number.NaN;
450
+ const hasInstalledExternalSkills = installedExternalSkillNames(projectRoot).length > 0;
451
+ const staleBeforeCommand = hasInstalledExternalSkills && (!previous ||
452
+ Number.isNaN(previousTime) ||
453
+ now.getTime() - previousTime >= UPDATE_CHECK_STALE_AFTER_MS);
454
+ return {
455
+ state_path: EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH,
456
+ stale_after_days: UPDATE_CHECK_STALE_AFTER_DAYS,
457
+ checked_at: checkedAt,
458
+ previous_checked_at: previous?.last_checked_at ?? null,
459
+ next_check_due_at: nextCheckDueAt,
460
+ stale_before_command: staleBeforeCommand,
461
+ };
462
+ }
463
+ function writeExternalSkillUpdateCheckState(projectRoot, checkedAt) {
464
+ writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH.split('/')), {
465
+ schema_version: '1',
466
+ kind: 'external_skill_update_check_state',
467
+ last_checked_at: checkedAt,
468
+ });
469
+ }
470
+ export function createExternalSkillUpdateReminder(projectRoot, now = new Date()) {
471
+ const hasInstalledExternalSkills = installedExternalSkillNames(projectRoot).length > 0;
472
+ if (!hasInstalledExternalSkills) {
473
+ return null;
474
+ }
475
+ const previous = readExternalSkillUpdateCheckStateFile(projectRoot);
476
+ const previousTime = previous ? Date.parse(previous.last_checked_at) : Number.NaN;
477
+ if (previous && !Number.isNaN(previousTime) && now.getTime() - previousTime < UPDATE_CHECK_STALE_AFTER_MS) {
478
+ return null;
479
+ }
480
+ return `External skill update check is stale; run mf skill outdated --json or mf skill update --all --dry-run --json. Last checked: ${previous?.last_checked_at ?? 'never'}.`;
481
+ }
482
+ function readInstalledRelativeFilePaths(projectRoot, target) {
483
+ const targetPath = path.join(projectRoot, ...target.skill_dir.split('/'));
484
+ const pending = [''];
485
+ const files = [];
486
+ const stack = [...pending];
487
+ while (stack.length > 0) {
488
+ const relativeDirectory = stack.pop() ?? '';
489
+ const absoluteDirectory = relativeDirectory
490
+ ? path.join(targetPath, ...relativeDirectory.split('/'))
491
+ : targetPath;
492
+ const entries = readdirSync(absoluteDirectory, { withFileTypes: true })
493
+ .sort((left, right) => left.name.localeCompare(right.name));
494
+ for (const entry of entries) {
495
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
496
+ if (relativePath === PROVENANCE_FILE) {
497
+ continue;
498
+ }
499
+ if (entry.isDirectory()) {
500
+ stack.push(relativePath);
501
+ continue;
502
+ }
503
+ files.push(relativePath);
504
+ if (files.length > MAX_IMPORTED_FILES) {
505
+ throw new Error(`External skill ${target.skill_name} exceeds ${MAX_IMPORTED_FILES} installed files.`);
506
+ }
507
+ }
508
+ }
509
+ return files.sort((left, right) => left.localeCompare(right));
510
+ }
511
+ function readInstalledFileReports(projectRoot, target, provenanceFiles) {
512
+ return provenanceFiles.map((file) => {
513
+ const relativePath = `${target.skill_dir}/${file.relative_path}`;
514
+ const content = readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...relativePath.split('/')), { maxBytes: MAX_FILE_BYTES });
515
+ return {
516
+ relative_path: file.relative_path,
517
+ kind: file.kind,
518
+ bytes: Buffer.byteLength(content, 'utf8'),
519
+ sha256: hashContent(content),
520
+ };
521
+ });
522
+ }
523
+ function collectFileChanges(currentFiles, remoteFiles) {
524
+ const currentByPath = new Map(currentFiles.map((file) => [file.relative_path, file]));
525
+ const remoteByPath = new Map(remoteFiles.map((file) => [file.relative_path, file]));
526
+ const paths = [...new Set([...currentByPath.keys(), ...remoteByPath.keys()])].sort((left, right) => left.localeCompare(right));
527
+ const changes = [];
528
+ for (const relativePath of paths) {
529
+ const current = currentByPath.get(relativePath);
530
+ const remote = remoteByPath.get(relativePath);
531
+ if (!current && remote) {
532
+ changes.push({
533
+ relative_path: relativePath,
534
+ status: 'added',
535
+ current_sha256: null,
536
+ remote_sha256: remote.sha256,
537
+ });
538
+ continue;
539
+ }
540
+ if (current && !remote) {
541
+ changes.push({
542
+ relative_path: relativePath,
543
+ status: 'removed',
544
+ current_sha256: current.sha256,
545
+ remote_sha256: null,
546
+ });
547
+ continue;
548
+ }
549
+ if (current && remote && current.sha256 !== remote.sha256) {
550
+ changes.push({
551
+ relative_path: relativePath,
552
+ status: 'modified',
553
+ current_sha256: current.sha256,
554
+ remote_sha256: remote.sha256,
555
+ });
556
+ }
557
+ }
558
+ return changes;
559
+ }
560
+ function localDriftIssues(skillName, provenanceFiles, installedFiles, installedRelativeFilePaths) {
561
+ const provenanceByPath = new Map(provenanceFiles.map((file) => [file.relative_path, file]));
562
+ const provenancePaths = new Set(provenanceFiles.map((file) => file.relative_path));
563
+ const issues = [];
564
+ for (const installed of installedFiles) {
565
+ const provenance = provenanceByPath.get(installed.relative_path);
566
+ if (!provenance || provenance.sha256 !== installed.sha256) {
567
+ issues.push(`External skill ${skillName} has local file drift: ${installed.relative_path}`);
568
+ }
569
+ }
570
+ for (const relativePath of installedRelativeFilePaths) {
571
+ if (!provenancePaths.has(relativePath)) {
572
+ issues.push(`External skill ${skillName} has local file drift: ${relativePath}`);
573
+ }
574
+ }
575
+ return issues;
576
+ }
368
577
  function scriptNameForIntent(relativePath) {
369
578
  const basename = path.posix.basename(relativePath);
370
579
  const withoutExtension = basename.replace(/\.[^.]+$/u, '');
@@ -402,7 +611,7 @@ function createTrustedScriptIntent(skillName, scriptPath) {
402
611
  approval_required: ['network_access', 'destructive_command'],
403
612
  };
404
613
  }
405
- function createScriptTrust(projectRoot, target, files, trustScripts, mode) {
614
+ function createScriptTrust(projectRoot, target, files, trustScripts, mode, existingTrust) {
406
615
  const scripts = files.filter((file) => file.kind === 'script');
407
616
  if (!trustScripts) {
408
617
  return {
@@ -434,7 +643,7 @@ function createScriptTrust(projectRoot, target, files, trustScripts, mode) {
434
643
  if (duplicateIntent) {
435
644
  throw new Error(`External skill trusted script intents contain a duplicate name: ${duplicateIntent}`);
436
645
  }
437
- validateTrustedScriptCommandPlan(projectRoot, includeEntry, fragmentPath, intents);
646
+ validateTrustedScriptCommandPlan(projectRoot, includeEntry, fragmentPath, intents, existingTrust);
438
647
  return {
439
648
  requested: true,
440
649
  status: mode === 'install' ? 'trusted' : 'planned',
@@ -455,17 +664,24 @@ function findDuplicate(values) {
455
664
  }
456
665
  return null;
457
666
  }
458
- function validateTrustedScriptCommandPlan(projectRoot, includeEntry, fragmentPath, intents) {
667
+ function validateTrustedScriptCommandPlan(projectRoot, includeEntry, fragmentPath, intents, existingTrust) {
459
668
  const existingIncludes = new Set(readCommandContractIncludePaths(projectRoot).map((entry) => entry.replace(/^\.mustflow\/config\//u, '')));
460
- if (existingIncludes.has(includeEntry)) {
669
+ const fragmentExists = existsSync(path.join(projectRoot, ...fragmentPath.split('/')));
670
+ const mayReuseExistingPlan = existingTrust?.grants_command_authority === true &&
671
+ existingTrust.include_entry === includeEntry &&
672
+ existingTrust.fragment_path === fragmentPath &&
673
+ existingIncludes.has(includeEntry) &&
674
+ fragmentExists;
675
+ const reusableIntentNames = new Set(mayReuseExistingPlan ? existingTrust.intents.map((intent) => intent.intent) : []);
676
+ if (existingIncludes.has(includeEntry) && !mayReuseExistingPlan) {
461
677
  throw new Error(`External skill trusted script command include already exists: ${includeEntry}`);
462
678
  }
463
- if (existsSync(path.join(projectRoot, ...fragmentPath.split('/')))) {
679
+ if (fragmentExists && !mayReuseExistingPlan) {
464
680
  throw new Error(`External skill trusted script command fragment already exists: ${fragmentPath}`);
465
681
  }
466
682
  const contract = readCommandContract(projectRoot);
467
683
  for (const intent of intents) {
468
- if (Object.prototype.hasOwnProperty.call(contract.intents, intent.intent)) {
684
+ if (Object.prototype.hasOwnProperty.call(contract.intents, intent.intent) && !reusableIntentNames.has(intent.intent)) {
469
685
  throw new Error(`External skill trusted script intent already exists in command contract: ${intent.intent}`);
470
686
  }
471
687
  }
@@ -517,6 +733,12 @@ function updateCommandIncludeText(content, includeEntry) {
517
733
  if (closingLineIndex < 0) {
518
734
  throw new Error(`[include].files in ${COMMANDS_CONFIG_PATH} must be a TOML array`);
519
735
  }
736
+ const existingValues = lines
737
+ .slice(filesLineIndex + 1, closingLineIndex)
738
+ .flatMap((line) => [...line.matchAll(/"([^"]+)"/gu)].map((match) => match[1]));
739
+ if (existingValues.includes(includeEntry)) {
740
+ return lines.join('\n');
741
+ }
520
742
  lines.splice(closingLineIndex, 0, includeLine);
521
743
  return lines.join('\n');
522
744
  }
@@ -565,6 +787,45 @@ function writeImportedSkillFiles(projectRoot, target, source, files, fileReport,
565
787
  throw error;
566
788
  }
567
789
  }
790
+ function writeUpdatedSkillFiles(projectRoot, target, source, files, fileReport, warnings, scriptTrust) {
791
+ const targetPath = path.join(projectRoot, ...target.skill_dir.split('/'));
792
+ if (!existsSync(targetPath)) {
793
+ throw new Error(`External skill is not installed: ${target.skill_dir}`);
794
+ }
795
+ const timestamp = Date.now();
796
+ const tempSkillDir = `${EXTERNAL_SKILL_ROOT}/.${target.skill_name}.update-${process.pid}-${timestamp}`;
797
+ const backupSkillDir = `${EXTERNAL_SKILL_ROOT}/.${target.skill_name}.backup-${process.pid}-${timestamp}`;
798
+ const tempPath = path.join(projectRoot, ...tempSkillDir.split('/'));
799
+ const backupPath = path.join(projectRoot, ...backupSkillDir.split('/'));
800
+ const tempSkillPath = path.join(tempPath, 'SKILL.md');
801
+ ensureFileTargetInsideWithoutSymlinks(projectRoot, tempSkillPath, { allowMissingLeaf: true });
802
+ try {
803
+ for (const file of files) {
804
+ writeUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...tempSkillDir.split('/'), ...file.relativePath.split('/')), file.content);
805
+ }
806
+ writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...tempSkillDir.split('/'), PROVENANCE_FILE), {
807
+ schema_version: '1',
808
+ kind: 'external_skill_source',
809
+ source,
810
+ files: fileReport,
811
+ script_trust: scriptTrust,
812
+ warnings,
813
+ });
814
+ renameSync(targetPath, backupPath);
815
+ renameSync(tempPath, targetPath);
816
+ rmSync(backupPath, { recursive: true, force: true });
817
+ }
818
+ catch (error) {
819
+ rmSync(tempPath, { recursive: true, force: true });
820
+ if (!existsSync(targetPath) && existsSync(backupPath)) {
821
+ renameSync(backupPath, targetPath);
822
+ }
823
+ else {
824
+ rmSync(backupPath, { recursive: true, force: true });
825
+ }
826
+ throw error;
827
+ }
828
+ }
568
829
  function rejectionReport(mode, issue) {
569
830
  return {
570
831
  schema_version: '1',
@@ -582,6 +843,199 @@ function rejectionReport(mode, issue) {
582
843
  wrote_files: false,
583
844
  };
584
845
  }
846
+ function rejectedUpdateItem(skillName, issue) {
847
+ return {
848
+ skill_name: skillName,
849
+ ok: false,
850
+ status: 'rejected',
851
+ source: null,
852
+ target: createTarget(skillName),
853
+ current_files: [],
854
+ remote_files: [],
855
+ changed_files: [],
856
+ warnings: [],
857
+ issues: [issue],
858
+ wrote_files: false,
859
+ };
860
+ }
861
+ async function createExternalSkillUpdateItem(projectRoot, skillName, options) {
862
+ const target = createTarget(skillName);
863
+ try {
864
+ const provenance = readExternalSkillProvenance(projectRoot, skillName);
865
+ const fetchImpl = options.fetch ?? globalThis.fetch;
866
+ if (typeof fetchImpl !== 'function') {
867
+ throw new Error('This runtime does not provide fetch.');
868
+ }
869
+ const installedRelativeFilePaths = readInstalledRelativeFilePaths(projectRoot, target);
870
+ const installedFiles = readInstalledFileReports(projectRoot, target, provenance.files);
871
+ const driftIssues = localDriftIssues(skillName, provenance.files, installedFiles, installedRelativeFilePaths);
872
+ if (driftIssues.length > 0) {
873
+ return {
874
+ skill_name: skillName,
875
+ ok: false,
876
+ status: 'rejected',
877
+ source: provenance.source,
878
+ target,
879
+ current_files: installedFiles,
880
+ remote_files: [],
881
+ changed_files: [],
882
+ script_trust: provenance.script_trust,
883
+ warnings: [],
884
+ issues: driftIssues,
885
+ wrote_files: false,
886
+ };
887
+ }
888
+ const remoteSourceFiles = await loadExternalSkillFiles(fetchImpl, sourceToParsed(provenance.source));
889
+ const normalizedRemoteFiles = normalizeImportedSkillFiles(remoteSourceFiles);
890
+ const remoteReports = fileReports(normalizedRemoteFiles);
891
+ const changedFiles = collectFileChanges(provenance.files, remoteReports);
892
+ const mode = options.mode ?? (options.action === 'outdated' ? 'check' : 'install');
893
+ const trustScripts = options.action === 'update' && options.trustScripts === true;
894
+ const hasScriptFiles = remoteReports.some((file) => file.kind === 'script');
895
+ if (options.action === 'update' &&
896
+ mode === 'install' &&
897
+ hasScriptFiles &&
898
+ provenance.script_trust?.grants_command_authority === true &&
899
+ !trustScripts) {
900
+ return {
901
+ skill_name: skillName,
902
+ ok: false,
903
+ status: 'rejected',
904
+ source: provenance.source,
905
+ target,
906
+ current_files: installedFiles,
907
+ remote_files: remoteReports,
908
+ changed_files: changedFiles,
909
+ script_trust: provenance.script_trust,
910
+ warnings: [],
911
+ issues: [`External skill ${skillName} has trusted scripts; rerun update with --trust-scripts to refresh script files.`],
912
+ wrote_files: false,
913
+ };
914
+ }
915
+ const scriptTrust = createScriptTrust(projectRoot, target, remoteReports, trustScripts, mode === 'install' ? 'install' : 'dry_run', provenance.script_trust);
916
+ const warnings = [
917
+ ...(changedFiles.length === 0 ? ['External skill is already current.'] : []),
918
+ ...(hasScriptFiles && scriptTrust.status !== 'trusted'
919
+ ? ['Imported scripts are inert reference files; mustflow does not grant command authority for external scripts.']
920
+ : []),
921
+ ...(scriptTrust.status === 'trusted'
922
+ ? ['Imported scripts were trusted by request; mustflow created command-contract intents gated by network and destructive approvals.']
923
+ : []),
924
+ ...(scriptTrust.status === 'planned'
925
+ ? ['Imported scripts would be trusted by request during install; dry-run only reports the command-contract plan.']
926
+ : []),
927
+ 'External skills are untrusted until the agent reads and evaluates the selected SKILL.md.',
928
+ ];
929
+ if (options.action === 'update' && mode === 'install' && changedFiles.length > 0) {
930
+ writeUpdatedSkillFiles(projectRoot, target, provenance.source, normalizedRemoteFiles, remoteReports, warnings, scriptTrust);
931
+ writeTrustedScriptCommandContract(projectRoot, target, provenance.source, scriptTrust);
932
+ }
933
+ return {
934
+ skill_name: skillName,
935
+ ok: true,
936
+ status: options.action === 'update' && mode === 'install' && changedFiles.length > 0
937
+ ? 'updated'
938
+ : changedFiles.length > 0
939
+ ? 'outdated'
940
+ : 'current',
941
+ source: provenance.source,
942
+ target,
943
+ current_files: installedFiles,
944
+ remote_files: remoteReports,
945
+ changed_files: changedFiles,
946
+ script_trust: scriptTrust,
947
+ warnings,
948
+ issues: [],
949
+ wrote_files: options.action === 'update' && mode === 'install' && changedFiles.length > 0,
950
+ };
951
+ }
952
+ catch (error) {
953
+ return rejectedUpdateItem(skillName, error instanceof Error ? error.message : String(error));
954
+ }
955
+ }
956
+ function selectedExternalSkillNames(projectRoot, options) {
957
+ if (options.all === true || (options.action === 'outdated' && (!options.skillNames || options.skillNames.length === 0))) {
958
+ return installedExternalSkillNames(projectRoot);
959
+ }
960
+ return [...new Set(options.skillNames ?? [])].sort((left, right) => left.localeCompare(right));
961
+ }
962
+ export async function createExternalSkillUpdateReport(projectRoot, options) {
963
+ const action = options.action;
964
+ const mode = action === 'outdated' ? 'check' : options.mode ?? 'install';
965
+ const checkState = createExternalSkillUpdateCheckState(projectRoot, options.now ?? new Date());
966
+ const warnings = [];
967
+ const issues = [];
968
+ if (action === 'update' && options.all !== true && (!options.skillNames || options.skillNames.length === 0)) {
969
+ issues.push('mf skill update requires a skill name or --all.');
970
+ }
971
+ if (options.all === true && options.skillNames && options.skillNames.length > 0) {
972
+ issues.push(`mf skill ${action} accepts either --all or skill names, not both.`);
973
+ }
974
+ if (action === 'update' && options.skillNames && options.skillNames.length > 1 && options.all !== true) {
975
+ issues.push('mf skill update accepts one skill name at a time unless --all is used.');
976
+ }
977
+ const requestedNames = selectedExternalSkillNames(projectRoot, options);
978
+ for (const skillName of requestedNames) {
979
+ if (!SLUG_PATTERN.test(skillName)) {
980
+ issues.push(`Invalid external skill name: ${skillName}`);
981
+ }
982
+ }
983
+ if (issues.length > 0) {
984
+ return {
985
+ schema_version: '1',
986
+ kind: 'skill_update_report',
987
+ command: 'skill',
988
+ action,
989
+ ok: false,
990
+ mode,
991
+ status: 'rejected',
992
+ check_state: checkState,
993
+ skills: [],
994
+ warnings,
995
+ issues,
996
+ wrote_files: false,
997
+ };
998
+ }
999
+ const installedNames = new Set(installedExternalSkillNames(projectRoot));
1000
+ const missingNames = requestedNames.filter((name) => !installedNames.has(name));
1001
+ const existingNames = requestedNames.filter((name) => installedNames.has(name));
1002
+ const skills = [
1003
+ ...missingNames.map((name) => rejectedUpdateItem(name, `External skill is not installed: ${name}`)),
1004
+ ];
1005
+ for (const skillName of existingNames) {
1006
+ skills.push(await createExternalSkillUpdateItem(projectRoot, skillName, {
1007
+ action,
1008
+ mode,
1009
+ trustScripts: options.trustScripts,
1010
+ fetch: options.fetch,
1011
+ }));
1012
+ }
1013
+ if (installedNames.size > 0 || requestedNames.length > 0) {
1014
+ writeExternalSkillUpdateCheckState(projectRoot, checkState.checked_at);
1015
+ }
1016
+ const wroteFiles = skills.some((skill) => skill.wrote_files);
1017
+ const ok = skills.every((skill) => skill.ok);
1018
+ return {
1019
+ schema_version: '1',
1020
+ kind: 'skill_update_report',
1021
+ command: 'skill',
1022
+ action,
1023
+ ok,
1024
+ mode,
1025
+ status: ok
1026
+ ? wroteFiles
1027
+ ? 'updated'
1028
+ : mode === 'dry_run'
1029
+ ? 'preview'
1030
+ : 'checked'
1031
+ : 'rejected',
1032
+ check_state: checkState,
1033
+ skills,
1034
+ warnings,
1035
+ issues: skills.flatMap((skill) => skill.issues),
1036
+ wrote_files: wroteFiles,
1037
+ };
1038
+ }
585
1039
  export async function createExternalSkillImportReport(projectRoot, inputUrl, options) {
586
1040
  const mode = options.mode;
587
1041
  try {
@@ -685,6 +685,15 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
685
685
  ],
686
686
  expectedExitCodes: [0, 1],
687
687
  },
688
+ {
689
+ id: 'skill-update-report',
690
+ schemaFile: 'skill-update-report.schema.json',
691
+ producer: 'mf skill outdated --json / mf skill update <skill-name>|--all --json',
692
+ packaged: true,
693
+ documented: true,
694
+ installedCommand: ['mf', 'skill', 'outdated', '--json'],
695
+ expectedExitCodes: [0, 1],
696
+ },
688
697
  {
689
698
  id: 'route-fixture',
690
699
  schemaFile: 'route-fixture.schema.json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustflow",
3
- "version": "2.113.0",
3
+ "version": "2.114.5",
4
4
  "description": "Agent workflow documents and CLI for mustflow repository roots.",
5
5
  "type": "module",
6
6
  "license": "MIT-0",
@@ -35,6 +35,8 @@
35
35
  "test:cli": "node scripts/run-cli-tests.mjs --build cli",
36
36
  "test:coverage": "node scripts/run-cli-tests.mjs --build coverage",
37
37
  "test:audit": "node scripts/audit-tests.mjs --json",
38
+ "test:audit:related": "node scripts/audit-related-selection.mjs",
39
+ "test:ops": "node scripts/analyze-test-ops.mjs",
38
40
  "test:release": "node scripts/run-cli-tests.mjs --build release",
39
41
  "test:fast:node": "node scripts/run-cli-tests.mjs --build-runner=npm fast",
40
42
  "test:release:node": "node scripts/run-cli-tests.mjs --build-runner=npm release",
package/schemas/README.md CHANGED
@@ -195,6 +195,10 @@ Current schemas:
195
195
  - `skill-import-report.schema.json`: output of `mf skill import <github-url> --json`, containing
196
196
  GitHub source provenance, target `.mustflow/external-skills/<name>/` paths, imported file hashes,
197
197
  warnings for inert external scripts, rejection issues, and whether files were written
198
+ - `skill-update-report.schema.json`: output of
199
+ `mf skill outdated --json` and `mf skill update <skill-name>|--all --json`, containing saved
200
+ external-skill provenance, current and remote file hashes, changed-file summaries, local-drift
201
+ rejection issues, script-trust status, last update-check state, and whether skill files were written
198
202
  - `route-fixture.schema.json`: parsed `.mustflow/skills/route-fixtures.json`, containing strict
199
203
  skill-route golden cases with required and forbidden route expectations
200
204
  - `latest-run-pointer.schema.json`: `.mustflow/state/runs/latest.json` when `mf verify` writes a