@milehimikey/em 1.12.0 → 1.13.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 (105) hide show
  1. package/.claude/skills/event-modeling/SKILL.md +4 -2
  2. package/.claude/skills/event-modeling-conform/SKILL.md +20 -9
  3. package/.claude/skills/event-modeling-conform/reference/conform.md +107 -10
  4. package/.claude/skills/event-modeling-design/SKILL.md +10 -2
  5. package/.claude/skills/event-modeling-discover/SKILL.md +1 -1
  6. package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
  7. package/.claude/skills/event-modeling-implement/reference/implement.md +12 -6
  8. package/.claude/skills/event-modeling-review/SKILL.md +5 -1
  9. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +40 -2
  10. package/.claude/skills/event-modeling-shared/reference/operating-principles.md +13 -3
  11. package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +30 -0
  12. package/.claude/skills/event-modeling-shared/templates/conformance-report.md +8 -1
  13. package/.claude/skills/event-modeling-shared/templates/state.md +3 -0
  14. package/README.md +1 -0
  15. package/dist/catalog/docJoin.d.ts +9 -0
  16. package/dist/catalog/docJoin.js +6 -0
  17. package/dist/catalog/docJoin.js.map +1 -1
  18. package/dist/catalog/driftSignal.d.ts +11 -5
  19. package/dist/catalog/driftSignal.js +18 -4
  20. package/dist/catalog/driftSignal.js.map +1 -1
  21. package/dist/catalog/modelVersionValidate.d.ts +9 -0
  22. package/dist/catalog/modelVersionValidate.js +38 -0
  23. package/dist/catalog/modelVersionValidate.js.map +1 -0
  24. package/dist/catalog/sliceDoc.d.ts +12 -0
  25. package/dist/catalog/sliceDoc.js +15 -0
  26. package/dist/catalog/sliceDoc.js.map +1 -1
  27. package/dist/cli/ciInit.js +17 -0
  28. package/dist/cli/ciInit.js.map +1 -1
  29. package/dist/cli/conformScope.d.ts +1 -0
  30. package/dist/cli/conformScope.js +3 -1
  31. package/dist/cli/conformScope.js.map +1 -1
  32. package/dist/cli/conformSupersede.d.ts +24 -2
  33. package/dist/cli/conformSupersede.js +44 -3
  34. package/dist/cli/conformSupersede.js.map +1 -1
  35. package/dist/cli/findings.d.ts +134 -0
  36. package/dist/cli/findings.js +355 -0
  37. package/dist/cli/findings.js.map +1 -0
  38. package/dist/cli/metrics.d.ts +93 -0
  39. package/dist/cli/metrics.js +331 -0
  40. package/dist/cli/metrics.js.map +1 -0
  41. package/dist/cli/modelVersion.d.ts +139 -0
  42. package/dist/cli/modelVersion.js +256 -0
  43. package/dist/cli/modelVersion.js.map +1 -0
  44. package/dist/cli/reratify.d.ts +17 -0
  45. package/dist/cli/reratify.js +25 -1
  46. package/dist/cli/reratify.js.map +1 -1
  47. package/dist/cli/skillCheck.js +2 -2
  48. package/dist/cli/skillCheck.js.map +1 -1
  49. package/dist/cli/sliceConform.d.ts +50 -0
  50. package/dist/cli/sliceConform.js +206 -0
  51. package/dist/cli/sliceConform.js.map +1 -0
  52. package/dist/cli/sliceIndex.d.ts +2 -0
  53. package/dist/cli/sliceIndex.js +15 -3
  54. package/dist/cli/sliceIndex.js.map +1 -1
  55. package/dist/cli/sliceNew.d.ts +12 -0
  56. package/dist/cli/sliceNew.js +23 -0
  57. package/dist/cli/sliceNew.js.map +1 -1
  58. package/dist/cli/sliceStubAll.d.ts +76 -0
  59. package/dist/cli/sliceStubAll.js +189 -0
  60. package/dist/cli/sliceStubAll.js.map +1 -0
  61. package/dist/cli/stateFile.d.ts +63 -7
  62. package/dist/cli/stateFile.js +179 -18
  63. package/dist/cli/stateFile.js.map +1 -1
  64. package/dist/cli/status.d.ts +79 -1
  65. package/dist/cli/status.js +134 -15
  66. package/dist/cli/status.js.map +1 -1
  67. package/dist/cli/upgrade.d.ts +122 -0
  68. package/dist/cli/upgrade.js +476 -0
  69. package/dist/cli/upgrade.js.map +1 -0
  70. package/dist/cli.js +524 -36
  71. package/dist/cli.js.map +1 -1
  72. package/dist/emit/freshnessJson.d.ts +1 -1
  73. package/dist/emit/freshnessJson.js +3 -1
  74. package/dist/emit/freshnessJson.js.map +1 -1
  75. package/dist/emit/json.d.ts +11 -1
  76. package/dist/emit/json.js +35 -1
  77. package/dist/emit/json.js.map +1 -1
  78. package/dist/emit/metricsJson.d.ts +7 -0
  79. package/dist/emit/metricsJson.js +27 -0
  80. package/dist/emit/metricsJson.js.map +1 -0
  81. package/dist/emit/statusJson.d.ts +1 -1
  82. package/dist/emit/statusJson.js +17 -1
  83. package/dist/emit/statusJson.js.map +1 -1
  84. package/dist/emit/upgradeJson.d.ts +5 -0
  85. package/dist/emit/upgradeJson.js +26 -0
  86. package/dist/emit/upgradeJson.js.map +1 -0
  87. package/dist/emit/validateJson.d.ts +6 -2
  88. package/dist/emit/validateJson.js +12 -5
  89. package/dist/emit/validateJson.js.map +1 -1
  90. package/dist/mcp/server.d.ts +1 -1
  91. package/dist/mcp/server.js +114 -3
  92. package/dist/mcp/server.js.map +1 -1
  93. package/dist/model/rules.d.ts +7 -0
  94. package/dist/model/rules.js +12 -0
  95. package/dist/model/rules.js.map +1 -1
  96. package/dist/model/validate.d.ts +20 -0
  97. package/dist/model/validate.js +94 -45
  98. package/dist/model/validate.js.map +1 -1
  99. package/dist/templates.d.ts +2 -2
  100. package/dist/templates.js +7 -1
  101. package/dist/templates.js.map +1 -1
  102. package/dist/util/semver.d.ts +19 -0
  103. package/dist/util/semver.js +54 -0
  104. package/dist/util/semver.js.map +1 -0
  105. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -15,8 +15,8 @@ import { buildSliceDiagram } from "./render/sliceDiagram.js";
15
15
  import { resolveSliceArg, defaultSliceOut } from "./cli/render-inputs.js";
16
16
  import { serializeBuilds, watchFile } from "./render/watch.js";
17
17
  import { startLiveServer } from "./render/serve.js";
18
- import { formatDiagnostic, hasErrors } from "./model/validate.js";
19
- import { buildExport, buildSliceExport } from "./emit/json.js";
18
+ import { formatDiagnostic, hasErrors, publicViewsWithoutInModelReader } from "./model/validate.js";
19
+ import { buildExport, buildSliceExport, GENERATOR_VERSION } from "./emit/json.js";
20
20
  import { buildTypeSpec } from "./emit/typespec.js";
21
21
  import { buildValidateJson, buildSliceReadyJson, buildValidateListJson, collectMarkers } from "./emit/validateJson.js";
22
22
  import { buildDiffJson } from "./emit/diffJson.js";
@@ -28,6 +28,7 @@ import { validateFrontmatterCoherence } from "./catalog/frontmatterCoherenceVali
28
28
  import { validateNoteBindings } from "./catalog/noteBindingValidate.js";
29
29
  import { validateDocModelConsistency } from "./catalog/docModelConsistencyValidate.js";
30
30
  import { validateOrphanedSliceDocs } from "./catalog/orphanedSliceDocValidate.js";
31
+ import { validateModelVersionStale } from "./catalog/modelVersionValidate.js";
31
32
  import { validateSliceReady, computeSliceReadyGates } from "./catalog/sliceReadyValidate.js";
32
33
  import { detectSliceDocCollisions } from "./catalog/modelCollisionValidate.js";
33
34
  import { checkLedger, readLedgerWaiverTrailers, applyLedgerWaivers } from "./cli/ledgerCheck.js";
@@ -35,9 +36,11 @@ import { planMigration, verifyMigration } from "./cli/migrateReactionShape.js";
35
36
  import { buildLedgerJson } from "./emit/ledgerJson.js";
36
37
  import { buildCoverageReport, resolveScopedSlices } from "./cli/coverage.js";
37
38
  import { buildCoverageJson } from "./emit/coverageJson.js";
38
- import { resolveSliceStatusFacts, countOpenIssues, resolveConformanceEntry, aggregateInvariantTotals, buildStatusReport, formatStatusText, formatStatusMarkdown, buildStatusBadge, formatConformancePart, findSpecifyRoot, } from "./cli/status.js";
39
+ import { resolveSliceStatusFacts, countOpenIssues, resolveConformanceEntry, aggregateInvariantTotals, buildStatusReport, formatStatusText, formatStatusMarkdown, buildStatusBadge, formatConformancePart, findSpecifyRoot, resolveModelVersionStatusEntry, resolveEmVersionStatusEntry, } from "./cli/status.js";
39
40
  import { buildStatusJson } from "./emit/statusJson.js";
40
41
  import { buildFreshnessJson } from "./emit/freshnessJson.js";
42
+ import { computeMetrics, formatMetricsText } from "./cli/metrics.js";
43
+ import { buildMetricsJson } from "./emit/metricsJson.js";
41
44
  import { planSkillSyncBundle, applySkillSyncBundle } from "./cli/skillSync.js";
42
45
  import { checkSkillSyncBundle } from "./cli/skillCheck.js";
43
46
  import { buildSkillCheckJson } from "./emit/skillCheckJson.js";
@@ -57,15 +60,22 @@ import { runRatify } from "./cli/ratify.js";
57
60
  import { runReview } from "./cli/review.js";
58
61
  import { runConformSupersede } from "./cli/conformSupersede.js";
59
62
  import { buildConformScope, changedPathsSince, resolveSliceDocFacts, seedAsisModel } from "./cli/conformScope.js";
60
- import { buildSliceDocContent, isSlicePattern, sliceDocKey, SLICE_PATTERNS } from "./cli/sliceNew.js";
63
+ import { buildSliceDocContent, buildStubDocContent, isSlicePattern, sliceDocKey, SLICE_PATTERNS } from "./cli/sliceNew.js";
61
64
  import { wireSliceNote } from "./cli/sliceLink.js";
65
+ import { isStubStatus, runStubAll, STUB_STATUSES } from "./cli/sliceStubAll.js";
62
66
  import { listModelCommits } from "./cli/changelog-git.js";
63
67
  import { buildChangelogDoc } from "./cli/changelogBuild.js";
64
68
  import { runReratify } from "./cli/reratify.js";
65
- import { STATE_FILE_NAME, PHASES, isPhase, loadStateFile, parseState, setPhase, setConformance, setReview, isValidDateString, modelPathMismatch, } from "./cli/stateFile.js";
69
+ import { runSliceConform } from "./cli/sliceConform.js";
70
+ import { checkFindingsFile, buildCheckFindingsJson, lookupFindingsBesideReport, unruledFindingsInScope, FINDING_LOCI } from "./cli/findings.js";
71
+ import { STATE_FILE_NAME, PHASES, isPhase, loadStateFile, parseState, setPhase, setConformance, setModelVersion, setCertified, setEmVersion, setReview, isValidDateString, modelPathMismatch, } from "./cli/stateFile.js";
72
+ import { findCertifiedVersion, latestModelVersionNumber, modelVersionDrift, runCertifyModelVersion, runModelVersionBump, } from "./cli/modelVersion.js";
66
73
  import { STARTER_EM, starterEmFor, scaffoldReadme, scaffoldStateFile, scaffoldConstitution } from "./templates.js";
67
74
  import { kebabSlug } from "./util/slug.js";
68
75
  import { ciWorkflowPath, conformWorkflowPath, buildCiWorkflowFile, buildConformWorkflowFile, ciManagedBody, conformManagedBody, planCiFile, applyCiFile, findUnsafeCiInitArg, CI_WORKFLOW_MARKER, CONFORM_WORKFLOW_MARKER, } from "./cli/ciInit.js";
76
+ import { resolveRepoRoot, detectUpgrade, applyUpgrade, checkUpgrade } from "./cli/upgrade.js";
77
+ import { buildUpgradeJson } from "./emit/upgradeJson.js";
78
+ import { isOlder } from "./util/semver.js";
69
79
  import { RULES } from "./model/rules.js";
70
80
  import { USAGE_PHASES, isUsagePhase, sortUsagePhases, appendUsageLogEntry, findStateFiles, aggregateUsageReport, formatUsageReportText, } from "./cli/usageLog.js";
71
81
  import { buildUsageReportJson } from "./emit/usageReportJson.js";
@@ -126,7 +136,7 @@ program
126
136
  const today = localIsoDate();
127
137
  writeFileSync(join(dirPath, `${slugName}.em`), starterEmFor(name));
128
138
  writeFileSync(join(dirPath, "README.md"), scaffoldReadme(name, slugName));
129
- writeFileSync(join(dirPath, STATE_FILE_NAME), scaffoldStateFile(name, slugName, today));
139
+ writeFileSync(join(dirPath, STATE_FILE_NAME), scaffoldStateFile(name, slugName, today, PKG_VERSION));
130
140
  // MIL-202: the implementation constitution. One document per project, never two — in a
131
141
  // spec-kit project `.specify/memory/constitution.md` already IS that slot, so em defers to it
132
142
  // and writes nothing rather than seeding a second, competing copy beside the model. Same
@@ -437,6 +447,9 @@ slice
437
447
  .option("-f, --force", "overwrite the file if it already exists")
438
448
  .option("--wire <model-file>", "also insert the `note \"slices/<key>.md\"` line onto the slice's primary element in this " +
439
449
  ".em file (matched by export key), instead of just printing it to paste by hand (MIL-161)")
450
+ .option("--stub", "write a near-free stub instead: same 5 frontmatter keys, but a one-line placeholder body " +
451
+ "instead of the diagram-image stub and every judgment section (MIL-184) — deepen it later " +
452
+ "by re-running without --stub and -f")
440
453
  .action((name, opts) => {
441
454
  if (!isSlicePattern(opts.pattern)) {
442
455
  console.error(`em slice new: invalid --pattern "${opts.pattern}" — expected one of: ${SLICE_PATTERNS.join(", ")}`);
@@ -468,7 +481,10 @@ slice
468
481
  wired = result;
469
482
  }
470
483
  mkdirSync(dir, { recursive: true });
471
- writeFileSync(path, buildSliceDocContent(name, key, opts.pattern, opts.swimlane));
484
+ const content = opts.stub
485
+ ? buildStubDocContent(name, opts.pattern, opts.swimlane)
486
+ : buildSliceDocContent(name, key, opts.pattern, opts.swimlane);
487
+ writeFileSync(path, content);
472
488
  console.log(`wrote ${path}`);
473
489
  if (wired) {
474
490
  writeFileSync(opts.wire, wired.content);
@@ -479,6 +495,59 @@ slice
479
495
  console.log(` note "${path}"`);
480
496
  }
481
497
  });
498
+ slice
499
+ .command("stub-all")
500
+ .description("scaffold + wire a near-free stub (`em slice new --stub`'s content, MIL-184) for every " +
501
+ "slice in <file> with no resolvable doc — the fast path to status coloring for an " +
502
+ "exploratory/backbone model without hand-running `slice new` per slice. Skips a " +
503
+ "continuation slice (MIL-208, it has no doc of its own), an already-documented slice, and " +
504
+ "a slice whose pattern can't be classified")
505
+ .argument("<file>", "input .em file")
506
+ .option("--status <status>", `target status for every stub: ${STUB_STATUSES.join(" | ")}`, "draft")
507
+ .option("--by <name>", "identity for reviewedBy/ratifiedBy — required unless --status draft")
508
+ .option("--implemented-in <url>", "PR/commit URL for implementedIn — required with --status implemented")
509
+ .option("--dry-run", "list what would be stubbed/wired without writing anything")
510
+ .action((file, opts) => {
511
+ if (!isStubStatus(opts.status)) {
512
+ console.error(`em slice stub-all: invalid --status "${opts.status}" — expected one of: ${STUB_STATUSES.join(", ")}`);
513
+ process.exit(1);
514
+ }
515
+ if (opts.status !== "draft" && !opts.by) {
516
+ console.error(`em slice stub-all: --status ${opts.status} requires --by <name>`);
517
+ process.exit(1);
518
+ }
519
+ if (opts.status === "implemented" && !opts.implementedIn) {
520
+ console.error("em slice stub-all: --status implemented requires --implemented-in <url>");
521
+ process.exit(1);
522
+ }
523
+ const { model, refs, diagnostics, source } = compileFile(file);
524
+ printDiagnostics(diagnostics);
525
+ if (hasErrors(diagnostics)) {
526
+ console.error("em slice stub-all: not stubbing — fix the errors above");
527
+ process.exit(1);
528
+ }
529
+ mkdirSync(join(dirname(file), "slices"), { recursive: true });
530
+ const result = runStubAll(model, refs, dirname(file), source, {
531
+ status: opts.status,
532
+ by: opts.by ?? null,
533
+ on: localIsoDate(),
534
+ implementedInUrl: opts.implementedIn ?? null,
535
+ dryRun: opts.dryRun === true,
536
+ });
537
+ for (const outcome of result.outcomes) {
538
+ if (outcome.kind === "skip") {
539
+ console.log(`skip ${outcome.sliceKey} — ${outcome.reason}`);
540
+ continue;
541
+ }
542
+ const verb = opts.dryRun ? "would stub" : "stubbed";
543
+ const wireNote = outcome.wired ? `wired onto ${outcome.elementName}` : "already wired";
544
+ console.log(`${verb} ${outcome.path} (${outcome.sliceKey}, pattern: ${outcome.pattern}, swimlane: "${outcome.swimlane}", ` +
545
+ `status: ${outcome.status}, ${wireNote})`);
546
+ }
547
+ if (!opts.dryRun && result.source !== source) {
548
+ writeFileSync(file, result.source);
549
+ }
550
+ });
482
551
  slice
483
552
  .command("index")
484
553
  .description("rewrite the model's sibling README.md's GENERATED Slices table from `em export`'s slice " +
@@ -587,7 +656,7 @@ slice
587
656
  .option("--on <date>", "ratification date, YYYY-MM-DD (default: today, local date)")
588
657
  .option("--skip-review", "ratify without a recorded review — prints a loud notice on stderr")
589
658
  .action((file, sliceKey, opts) => {
590
- const { model, refs, diagnostics } = compileFile(file);
659
+ const { model, refs, diagnostics, source } = compileFile(file);
591
660
  printDiagnostics(diagnostics);
592
661
  // Scoped the same way `em slice mark-implemented`/`em export --slice`/`em validate
593
662
  // --slice-ready` are: only an error concerning THIS slice refuses.
@@ -622,6 +691,7 @@ slice
622
691
  console.log(result.changed
623
692
  ? `ratified: ${result.path} (ratifiedBy: ${opts.by}, ratifiedOn: ${ratifiedOn})`
624
693
  : `already ratified (no-op): ${result.path}`);
694
+ warnModelVersionDrift(dirname(file), model, refs, source, `ratifying "${sliceKey}"`);
625
695
  });
626
696
  slice
627
697
  .command("reratify")
@@ -634,7 +704,7 @@ slice
634
704
  .argument("<file>", "input .em file")
635
705
  .argument("<slice-key>", "slice export key (kebab-case)")
636
706
  .action((file, sliceKey) => {
637
- const { model, refs, diagnostics } = compileFile(file);
707
+ const { model, refs, diagnostics, source } = compileFile(file);
638
708
  printDiagnostics(diagnostics);
639
709
  // Scoped the same way `em slice ratify`/`em slice mark-implemented`/`em export --slice`/
640
710
  // `em validate --slice-ready` are: only an error concerning THIS slice refuses.
@@ -648,7 +718,60 @@ slice
648
718
  console.error(`em slice reratify: ${result.message}`);
649
719
  process.exit(1);
650
720
  }
721
+ // MIL-214: advisory only, never refuses — see reratify.ts's reratifyAdvisory. Both warnings
722
+ // can fire together (a version that was never certified AND still carries unruled findings).
723
+ if (result.advisory.neverCertified) {
724
+ console.error(`warn: reratifying "${sliceKey}" whose v${result.newVersion - 1} was never certified`);
725
+ }
726
+ if (result.advisory.unruledFindingsCount > 0) {
727
+ console.error(`warn: reratifying "${sliceKey}" has ${result.advisory.unruledFindingsCount} unruled conformance finding(s)`);
728
+ }
651
729
  console.log(`reratified: ${result.path} (version: ${result.newVersion}, status: ready-to-implement)`);
730
+ warnModelVersionDrift(dirname(file), model, refs, source, `reratifying "${sliceKey}"`);
731
+ });
732
+ slice
733
+ .command("conform")
734
+ .description("record per-slice-per-version conformance certification: sets `conformedVersion:`/" +
735
+ "`conformedAt:`/`conformedOn:` on a slice doc (MIL-214) — the fact `driftSignal: in-sync` " +
736
+ "now depends on. Legal only for `status: implemented` with an `implementedIn:` link. " +
737
+ "Idempotent on the same (version, --at) pair; a different --at simply overwrites (a " +
738
+ "later re-certification is normal — there's no way for em to tell 'later' from 'earlier' " +
739
+ "for an arbitrary revision string, so there's no --force to reach for). Refuses when the " +
740
+ "newest conformance/*-findings.json matching --at still has an unruled finding in scope " +
741
+ "for this slice; --skip-findings-check overrides with a loud notice.")
742
+ .argument("<file>", "input .em file")
743
+ .argument("<slice-key>", "slice export key (kebab-case)")
744
+ .requiredOption("--at <rev>", "the target-repo revision this certification sweep diffed against")
745
+ .option("--on <date>", "certification date, YYYY-MM-DD (default: today, local date)")
746
+ .option("--skip-findings-check", "certify even with unruled findings in scope — prints a loud notice on stderr")
747
+ .action((file, sliceKey, opts) => {
748
+ const { model, refs, diagnostics } = compileFile(file);
749
+ printDiagnostics(diagnostics);
750
+ // Scoped the same way `em slice ratify`/`em slice reratify`/`em slice mark-implemented` are:
751
+ // only an error concerning THIS slice refuses.
752
+ const scopedErrors = diagnostics.filter((d) => d.severity === "error" && d.refs?.some((r) => r === sliceKey || r.startsWith(`${sliceKey}/`)));
753
+ if (scopedErrors.length > 0) {
754
+ console.error(`em slice conform: slice "${sliceKey}" has errors — fix them first`);
755
+ process.exit(1);
756
+ }
757
+ const today = localIsoDate();
758
+ const on = opts.on ?? today;
759
+ if (opts.on !== undefined && !isValidDateString(opts.on)) {
760
+ console.error(`em slice conform: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
761
+ process.exit(1);
762
+ }
763
+ const result = runSliceConform(model, refs, dirname(file), sliceKey, opts.at, on, opts.skipFindingsCheck === true);
764
+ if (!result.ok) {
765
+ console.error(`em slice conform: ${result.message}`);
766
+ process.exit(1);
767
+ }
768
+ if (result.skippedFindingsCheck !== null) {
769
+ console.error(`notice: --skip-findings-check — certifying "${sliceKey}" with ${result.skippedFindingsCheck.length} ` +
770
+ `unruled conformance finding(s) still in scope (id ${result.skippedFindingsCheck.join(", ")})`);
771
+ }
772
+ console.log(result.changed
773
+ ? `certified: ${result.path} (conformedVersion: ${result.version}, conformedAt: ${opts.at}, conformedOn: ${on})`
774
+ : `already certified (no-op): ${result.path}`);
652
775
  });
653
776
  program
654
777
  .command("changelog")
@@ -673,23 +796,45 @@ program
673
796
  }
674
797
  });
675
798
  const STATE_DIR_HELP = "model directory containing .event-modeling.md, or a direct path to that file (default: current directory)";
676
- /** Shared by every `em state` writer: load, apply the pure patch, write, report — the only
677
- * difference between set-phase/set-conformance/set-review is which `mutate` closure they pass. */
678
- function writeStateUpdate(dirOrFile, cmdLabel, mutate) {
799
+ /** Shared by every `em state` writer: load, apply the pure patch, stamp `Em version:` to the
800
+ * running em (MIL-219 ruling A — every state-file writer refreshes it, since each is already
801
+ * rewriting the file), write, report — the only difference between set-phase/set-conformance/
802
+ * set-review/set-model-version is which `mutate` closure they pass. `today` defaults to
803
+ * `localIsoDate()`; a caller that also needs to stamp a SECOND artifact (MIL-218: `em state
804
+ * set-conformance`'s model-version certify step) with the exact same date passes it in
805
+ * explicitly, computed once, rather than each call independently reading the clock and risking
806
+ * a midnight-rollover mismatch between the two. The `Em version:` stamp is best-effort — a
807
+ * failure there (should never happen: `Model file:` is one of the six bullets a successful
808
+ * `loadStateFile`+`mutate` already proves is present) falls back to `mutate`'s own output rather
809
+ * than losing the write the caller actually asked for. */
810
+ function writeStateUpdate(dirOrFile, cmdLabel, mutate, today = localIsoDate()) {
679
811
  const loaded = loadStateFile(dirOrFile);
680
812
  if (!loaded.ok) {
681
813
  console.error(loaded.message);
682
814
  process.exit(1);
683
815
  }
684
- const today = localIsoDate();
685
816
  const result = mutate(loaded.text, today);
686
817
  if (!result.ok) {
687
818
  console.error(`${cmdLabel}: ${result.message}`);
688
819
  process.exit(1);
689
820
  }
690
- writeFileSync(loaded.path, result.text);
821
+ const stamped = setEmVersion(result.text, PKG_VERSION, today);
822
+ writeFileSync(loaded.path, stamped.ok ? stamped.text : result.text);
691
823
  console.log(`wrote ${loaded.path}`);
692
824
  }
825
+ /** MIL-218 ruling C: the one-line advisory `em slice ratify`/`reratify` print when the write
826
+ * they just made pushed the slice-version vector (or, in principle, the `.em` content hash —
827
+ * never true for these two commands, which only ever edit a slice doc's frontmatter, but
828
+ * checked anyway so this helper stays the one true predicate) past the last bumped design
829
+ * version. Silent when no manifest exists at all (`drift.current === null`) — a repo that
830
+ * never bumped a design version has nothing to be stale against, same silence
831
+ * `model-version-stale` (rules.ts) holds. Never refuses; advisory only. */
832
+ function warnModelVersionDrift(baseDir, model, refs, source, cmdLabel) {
833
+ const drift = modelVersionDrift(baseDir, model, refs, source);
834
+ if (drift.current === null || (!drift.hashChanged && drift.slicesChanged.length === 0))
835
+ return;
836
+ console.error(`warn: ${cmdLabel} moved the model past v${drift.current} — run \`em model version bump\` to record it`);
837
+ }
693
838
  const state = program
694
839
  .command("state")
695
840
  .description("read/write the state file's mechanical fields deterministically (see docs/cli.md)");
@@ -725,12 +870,95 @@ state
725
870
  });
726
871
  state
727
872
  .command("set-conformance")
728
- .description("rewrite Last conformance: (and Last updated:) in the exact format reference/conform.md parses")
873
+ .description("rewrite Last conformance: (and Last updated:) in the exact format reference/conform.md " +
874
+ "parses. Refuses (MIL-214) while any `implemented` slice has an unruled conformance " +
875
+ "finding in the findings JSON beside --report (a `slice: null` finding blocks every " +
876
+ "slice); --partial records the marker with a ` (partial)` suffix instead, with a loud " +
877
+ "notice. Without a findings JSON beside --report: warns once and records the marker as " +
878
+ "given (migration path for a report predating `em conform-findings`)")
729
879
  .argument("<revision>", "target-repo revision just diffed against")
730
880
  .argument("[dir]", STATE_DIR_HELP, ".")
731
881
  .requiredOption("--report <path>", "path to the conformance report just written")
882
+ .option("--partial", "record the marker as PARTIAL even though some in-scope findings are still unruled")
732
883
  .action((revision, dir, opts) => {
733
- writeStateUpdate(dir, "em state set-conformance", (text, today) => setConformance(text, revision, opts.report, today));
884
+ const loaded = loadStateFile(dir);
885
+ if (!loaded.ok) {
886
+ console.error(loaded.message);
887
+ process.exit(1);
888
+ }
889
+ const parsed = parseState(loaded.text);
890
+ if (!parsed.ok) {
891
+ console.error(`em state set-conformance: ${parsed.message}`);
892
+ process.exit(1);
893
+ }
894
+ const modelDir = dirname(loaded.path);
895
+ const modelFile = join(modelDir, parsed.state.modelPath);
896
+ // The "in scope" set for the findings gate below — every `implemented` slice, per the
897
+ // ruling. Best-effort: a model that doesn't exist or doesn't compile cleanly can't tell us
898
+ // its implemented slices, so the gate is skipped (not refused) in that case rather than
899
+ // blocking a state-file operation on an unrelated model problem — `em validate` is where
900
+ // that problem gets its own diagnostic. Also the compiled model MIL-218's certify step
901
+ // needs below (kept, not just the derived `inScope` set, since certify needs the model/refs
902
+ // themselves — never re-compiled a second time).
903
+ let inScope = new Set();
904
+ let compiledForCertify = null;
905
+ if (existsSync(modelFile)) {
906
+ const compiled = compileFile(modelFile);
907
+ if (!hasErrors(compiled.diagnostics)) {
908
+ const { facts } = resolveSliceDocFacts(compiled.model, compiled.refs, modelDir);
909
+ inScope = new Set(facts.filter((f) => f.status === "implemented").map((f) => f.key));
910
+ compiledForCertify = { model: compiled.model, refs: compiled.refs, source: compiled.source };
911
+ }
912
+ }
913
+ let unruled = [];
914
+ const lookup = lookupFindingsBesideReport(modelDir, opts.report);
915
+ if (lookup.kind === "invalid") {
916
+ console.error(`em state set-conformance: ${lookup.message}`);
917
+ process.exit(1);
918
+ }
919
+ else if (lookup.kind === "absent") {
920
+ console.error(`warn: no findings JSON found beside ${opts.report} — recording the marker without verifying every ` +
921
+ "finding is ruled (migration path for a report written before `em conform-findings`)");
922
+ }
923
+ else {
924
+ unruled = unruledFindingsInScope(lookup.doc.findings, inScope);
925
+ }
926
+ const partialFlag = opts.partial === true;
927
+ if (unruled.length > 0 && !partialFlag) {
928
+ console.error(`em state set-conformance: ${unruled.length} unruled conformance finding(s) among implemented slices ` +
929
+ `(id ${unruled.map((f) => f.id).join(", ")}) — rule on them (\`em conform-supersede --locus --by\`) or pass --partial`);
930
+ process.exit(1);
931
+ }
932
+ const recordPartial = partialFlag && unruled.length > 0;
933
+ if (recordPartial) {
934
+ console.error(`notice: conformance marker recorded as PARTIAL — ${unruled.length} finding(s) unruled`);
935
+ }
936
+ const today = localIsoDate();
937
+ // MIL-218 ruling D: a FULL (non-`--partial`) marker also certifies the current design
938
+ // version's model-versions/*.json manifest. Checked (and, on success, WRITTEN) before the
939
+ // `Last conformance:` bullet below — a refusal here (no manifest yet, or the vector has
940
+ // drifted since it was bumped) must leave the state file untouched, same "refuse before any
941
+ // write" discipline every other em command holds; never leaves `Last conformance:` updated
942
+ // with no matching `Certified:` to show for it. Best-effort, same posture as the `inScope`
943
+ // computation above, for exactly one case: a model that doesn't compile cleanly can't be
944
+ // certified against, so certification is silently SKIPPED (not a `set-conformance` failure)
945
+ // rather than blocking the conformance marker on an unrelated model problem — `em validate`
946
+ // is where that problem gets its own diagnostic.
947
+ let certifiedVersion = null;
948
+ if (!recordPartial && compiledForCertify) {
949
+ const findingsPath = lookup.kind === "found" ? lookup.path : null;
950
+ const certifyResult = runCertifyModelVersion(modelDir, compiledForCertify.model, compiledForCertify.refs, compiledForCertify.source, revision, today, opts.report, findingsPath);
951
+ if (!certifyResult.ok) {
952
+ console.error(`em state set-conformance: ${certifyResult.message}`);
953
+ process.exit(1);
954
+ }
955
+ certifiedVersion = certifyResult.version;
956
+ console.log(`certified: ${certifyResult.path} (v${certifyResult.version} @ ${revision})`);
957
+ }
958
+ writeStateUpdate(dir, "em state set-conformance", (text, t) => setConformance(text, revision, opts.report, t, recordPartial), today);
959
+ if (certifiedVersion !== null) {
960
+ writeStateUpdate(dir, "em state set-conformance", (text, t) => setCertified(text, certifiedVersion, revision, t, t), today);
961
+ }
734
962
  });
735
963
  state
736
964
  .command("set-review")
@@ -767,8 +995,8 @@ state
767
995
  process.exit(1);
768
996
  }
769
997
  const phases = sortUsagePhases(rawPhases);
770
- const { model, diagnostics, refs } = compileFile(file);
771
- const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics);
998
+ const { model, diagnostics, refs, source } = compileFile(file);
999
+ const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics, source);
772
1000
  const categorySet = new Set(allDiagnostics.map((d) => RULES[d.code].usageCategory));
773
1001
  const categories = categorySet.size > 0 ? [...categorySet].sort() : ["none"];
774
1002
  const loaded = loadStateFile(dirname(file));
@@ -785,6 +1013,67 @@ state
785
1013
  writeFileSync(loaded.path, result.text);
786
1014
  console.log(`wrote ${loaded.path} — phases: ${phases.join(", ")} — validate: ${categories.join(", ")}`);
787
1015
  });
1016
+ const model = program.command("model").description("model-level version: design version + certified version (see docs/model-versions.md)");
1017
+ const modelVersionCmd = model.command("version").description("bump/inspect the model's design version (MIL-218)");
1018
+ modelVersionCmd
1019
+ .command("bump")
1020
+ .description("bump the model's design version: write model-versions/v<N+1>.json (sibling of slices/, " +
1021
+ "conformance/) and rewrite the state file's `Model version:` bullet (MIL-218). Refuses " +
1022
+ "when the state file is missing (run `em scaffold` first), when --by is empty, and when " +
1023
+ "nothing has changed since the current version (same model content hash AND same " +
1024
+ "slice-version vector) unless --force — a no-op bump is the one case --force is right for.")
1025
+ .argument("<file>", "input .em file")
1026
+ .requiredOption("--by <name>", "the bumper's name")
1027
+ .option("--on <date>", "bump date, YYYY-MM-DD (default: today, local date)")
1028
+ .option("--force", "bump even though nothing has changed since the current version")
1029
+ .action((file, opts) => {
1030
+ const baseDir = dirname(file);
1031
+ const loaded = loadStateFile(baseDir);
1032
+ if (!loaded.ok) {
1033
+ console.error(`em model version bump: no state file — run \`em scaffold\` first (${loaded.message})`);
1034
+ process.exit(1);
1035
+ }
1036
+ if (opts.on !== undefined && !isValidDateString(opts.on)) {
1037
+ console.error(`em model version bump: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
1038
+ process.exit(1);
1039
+ }
1040
+ const today = localIsoDate();
1041
+ const on = opts.on ?? today;
1042
+ const { model: compiledModel, refs, diagnostics, source } = compileFile(file);
1043
+ printDiagnostics(diagnostics);
1044
+ if (hasErrors(diagnostics)) {
1045
+ console.error(`em model version bump: "${file}" has errors — fix them first`);
1046
+ process.exit(1);
1047
+ }
1048
+ const result = runModelVersionBump(baseDir, basename(file), compiledModel, refs, source, opts.by, on, GENERATOR_VERSION, opts.force === true);
1049
+ if (!result.ok) {
1050
+ console.error(`em model version bump: ${result.message}`);
1051
+ process.exit(1);
1052
+ }
1053
+ writeStateUpdate(baseDir, "em model version bump", (text, t) => setModelVersion(text, result.version, t), today);
1054
+ console.log(`bumped: ${result.path} (v${result.version})`);
1055
+ });
1056
+ modelVersionCmd
1057
+ .command("show")
1058
+ .description("print the model's current design version and the most recently certified version, if any " +
1059
+ "(model-versions/*.json — MIL-218)")
1060
+ .argument("<file>", "input .em file")
1061
+ .option("--json", "print a JSON document instead of text")
1062
+ .action((file, opts) => {
1063
+ const baseDir = dirname(file);
1064
+ const design = latestModelVersionNumber(baseDir);
1065
+ const certifiedFound = findCertifiedVersion(baseDir);
1066
+ const certified = certifiedFound
1067
+ ? { version: certifiedFound.version, at: certifiedFound.certified.at, on: certifiedFound.certified.on }
1068
+ : null;
1069
+ if (opts.json) {
1070
+ console.log(JSON.stringify({ file, design, certified }, null, 2));
1071
+ }
1072
+ else {
1073
+ console.log(`design version: ${design ?? "none"}`);
1074
+ console.log(`certified: ${certified ? `v${certified.version} @ ${certified.at} (${certified.on})` : "never"}`);
1075
+ }
1076
+ });
788
1077
  program
789
1078
  .command("usage-report")
790
1079
  .description("aggregate every .event-modeling.md's Usage log under [root] into phase/diagnostic-category " +
@@ -870,12 +1159,17 @@ program
870
1159
  "at ratification time so a reader following the report's file:line citations knows they " +
871
1160
  "describe an ancestor of the current model. Additive-only splice, never a rewrite of the " +
872
1161
  "report; idempotent on the same --as-of/--findings/--on stamp; refuses if the report " +
873
- "doesn't exist")
1162
+ "doesn't exist. --locus/--by (MIL-214, both required together) additionally record the " +
1163
+ "ruling itself — locus/resolvedBy/resolvedOn — on the findings named by --findings in the " +
1164
+ "sibling conformance/<date>-findings.json, when one exists (warns once and stamps the " +
1165
+ "banner only when it doesn't — the migration path for a report predating em conform-findings)")
874
1166
  .argument("<file>", "input .em file (used only to resolve the report path's base directory)")
875
1167
  .argument("<report-path>", "path to the conformance report, relative to the model's directory")
876
1168
  .requiredOption("--as-of <rev>", "the revision this ruling was made against — same value passed to `em state set-conformance`")
877
1169
  .requiredOption("--findings <spec>", 'which finding number(s) this stamps as ruled, e.g. "1-3" or "1,2,4"')
878
1170
  .option("--on <date>", "ruling date, YYYY-MM-DD (default: today, local date)")
1171
+ .option("--locus <locus>", `who/what the named finding(s) say is wrong: ${FINDING_LOCI.join(" | ")} (requires --by)`)
1172
+ .option("--by <name>", "who ruled on the named finding(s) (requires --locus)")
879
1173
  .action((file, reportPath, opts) => {
880
1174
  const today = localIsoDate();
881
1175
  const on = opts.on ?? today;
@@ -883,15 +1177,57 @@ program
883
1177
  console.error(`em conform-supersede: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
884
1178
  process.exit(1);
885
1179
  }
886
- const result = runConformSupersede(dirname(file), reportPath, opts.asOf, opts.findings, on);
1180
+ if ((opts.locus !== undefined) !== (opts.by !== undefined)) {
1181
+ console.error("em conform-supersede: --locus and --by must be given together");
1182
+ process.exit(1);
1183
+ }
1184
+ if (opts.locus !== undefined && !FINDING_LOCI.includes(opts.locus)) {
1185
+ console.error(`em conform-supersede: invalid --locus "${opts.locus}" — expected one of: ${FINDING_LOCI.join(", ")}`);
1186
+ process.exit(1);
1187
+ }
1188
+ const ruling = opts.locus !== undefined && opts.by !== undefined ? { locus: opts.locus, by: opts.by } : undefined;
1189
+ const result = runConformSupersede(dirname(file), reportPath, opts.asOf, opts.findings, on, ruling);
887
1190
  if (!result.ok) {
888
1191
  console.error(`em conform-supersede: ${result.message}`);
889
1192
  process.exit(1);
890
1193
  }
1194
+ if (result.findingsRuling.kind === "no-findings-file") {
1195
+ console.error(`warn: ${result.findingsRuling.warning}`);
1196
+ }
1197
+ else if (result.findingsRuling.kind === "applied") {
1198
+ console.log(result.findingsRuling.changed
1199
+ ? `ruled: ${result.findingsRuling.path} (finding(s) ${result.findingsRuling.ids.join(", ")} -> locus: ${opts.locus}, by: ${opts.by})`
1200
+ : `already ruled (no-op): ${result.findingsRuling.path}`);
1201
+ }
891
1202
  console.log(result.changed
892
1203
  ? `stamped superseded: ${result.path} (as of ${opts.asOf}, findings ${opts.findings}, on ${on})`
893
1204
  : `already stamped (no-op): ${result.path}`);
894
1205
  });
1206
+ const conformFindings = program.command("conform-findings").description("read-only checks for the conformance findings record (MIL-214)");
1207
+ conformFindings
1208
+ .command("check")
1209
+ .description("shape-validate a conformance/<date>-findings.json file (findingsSchemaVersion, required " +
1210
+ "fields, enum values, ids sorted+unique, a ruled finding carries resolvedBy+resolvedOn) — " +
1211
+ "exit 1 on shape errors. The conform skill writes this file directly alongside its report; " +
1212
+ "this is how a headless run verifies what it just wrote.")
1213
+ .argument("<path>", "path to a conformance/<date>-findings.json file")
1214
+ .option("--json", "print a JSON document instead of the text report")
1215
+ .action((path, opts) => {
1216
+ const result = checkFindingsFile(path);
1217
+ if (opts.json) {
1218
+ console.log(buildCheckFindingsJson(result));
1219
+ }
1220
+ else if (result.ok) {
1221
+ console.log(`ok — ${result.path}: ${result.findingsCount} finding(s), shape valid`);
1222
+ }
1223
+ else {
1224
+ console.error(`em conform-findings check: ${result.path} — shape errors:`);
1225
+ for (const e of result.errors)
1226
+ console.error(` - ${e}`);
1227
+ }
1228
+ if (!result.ok)
1229
+ process.exit(1);
1230
+ });
895
1231
  program
896
1232
  .command("watch")
897
1233
  .description("re-render on every save")
@@ -980,8 +1316,8 @@ program
980
1316
  .option("--json", "print a JSON document instead of text — works on a model WITH errors, unlike `em export` " +
981
1317
  "(MIL-128, see docs/cli.md); exit codes are unchanged")
982
1318
  .action((file, opts) => {
983
- const { model, diagnostics, refs } = compileFile(file);
984
- const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics);
1319
+ const { model, diagnostics, refs, source } = compileFile(file);
1320
+ const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics, source);
985
1321
  if (opts.sliceReady) {
986
1322
  // MIL-87: a targeted, single-slice readiness gate, not part of the unconditional
987
1323
  // diagnostic set above (see sliceReadyValidate.ts's header for why). Folds in MIL-85's
@@ -1265,11 +1601,11 @@ program
1265
1601
  const compiled = [];
1266
1602
  let anyErrors = false;
1267
1603
  for (const file of files) {
1268
- const { model, refs, diagnostics } = compileFile(file);
1604
+ const { model, refs, diagnostics, source } = compileFile(file);
1269
1605
  printDiagnosticsFor(file, diagnostics);
1270
1606
  if (hasErrors(diagnostics))
1271
1607
  anyErrors = true;
1272
- compiled.push({ file, model, refs });
1608
+ compiled.push({ file, model, refs, source });
1273
1609
  }
1274
1610
  if (anyErrors) {
1275
1611
  console.error("em status: not reporting status — fix the errors above");
@@ -1326,7 +1662,13 @@ program
1326
1662
  const sliceDocFacts = facts.map((f) => ({ key: f.key, status: f.rawStatus, implementedIn: f.implementedIn }));
1327
1663
  return resolveConformanceEntry(file, opts.repo, sliceDocFacts);
1328
1664
  });
1329
- const report = buildStatusReport(files, sliceFacts, openIssuesCount, invariants, conformance, statusDiagnostics);
1665
+ // MIL-218: one model-version entry per input file.
1666
+ const modelVersion = compiled.map(({ file, model, refs, source }) => resolveModelVersionStatusEntry(file, model, refs, source));
1667
+ // MIL-219: one em-version entry per input file whose state file resolved.
1668
+ const emVersion = compiled
1669
+ .map(({ file }) => resolveEmVersionStatusEntry(file, PKG_VERSION))
1670
+ .filter((e) => e !== null);
1671
+ const report = buildStatusReport(files, sliceFacts, openIssuesCount, invariants, conformance, statusDiagnostics, modelVersion, emVersion);
1330
1672
  let output;
1331
1673
  if (opts.json)
1332
1674
  output = buildStatusJson(report);
@@ -1371,6 +1713,32 @@ program
1371
1713
  console.log(formatConformancePart(entry));
1372
1714
  }
1373
1715
  });
1716
+ program
1717
+ .command("metrics")
1718
+ .description("the pilot metrics named in advance by the register, computed from git history alone " +
1719
+ "(MIL-170): ratification turnaround (reviewed -> ratified -> implemented), conform-cycle " +
1720
+ "cadence + finding counts, and status-vs-reality disagreement over time — plus a fourth, " +
1721
+ "reported as not computable from history (see docs/usage-data.md). `<file>` is an anchor " +
1722
+ ".em file, used only to locate slices/, conformance/, and .event-modeling.md relative to " +
1723
+ "it — same convention as em ledger; never parsed or compiled")
1724
+ .argument("<file>", "anchor .em file")
1725
+ .requiredOption("--from <rev>", "baseline revision")
1726
+ .option("--to <rev>", "compare revision (default: HEAD)")
1727
+ .option("--json", "print a JSON document instead of the text report (see docs/cli.md)")
1728
+ .action((file, opts) => {
1729
+ const to = opts.to ?? "HEAD";
1730
+ const computed = computeMetrics(file, opts.from, to);
1731
+ if (!computed.ok) {
1732
+ console.error(computed.message);
1733
+ process.exit(1);
1734
+ }
1735
+ if (opts.json) {
1736
+ process.stdout.write(buildMetricsJson(computed.result) + "\n");
1737
+ }
1738
+ else {
1739
+ console.log(formatMetricsText(computed.result));
1740
+ }
1741
+ });
1374
1742
  // `em query` (MIL-168): deterministic graph queries over the compiled model — scoped, token-
1375
1743
  // cheap answers instead of a whole-model `em export`. Compiles every input file through the
1376
1744
  // LIGHTER query sub-pipeline (query/pipeline.ts: parse -> normalize -> refs -> validate against
@@ -1721,6 +2089,18 @@ skill
1721
2089
  console.log(f.message);
1722
2090
  console.log(result.ok ? `ok — vendored skill matches em ${PKG_VERSION}` : `${result.findings.length} mismatch(es)`);
1723
2091
  }
2092
+ // MIL-219 ruling A: `em skill check` also warns when [path]'s own state file records an
2093
+ // `Em version:` behind the installed em — best-effort (no state file, or an unparseable
2094
+ // one, is silently skipped; that's `em upgrade`'s own hard-incompatibility case to report,
2095
+ // not this command's). Always stderr, independent of --json/exit code — advisory only, and
2096
+ // this command's own exit code stays about skill-bundle drift, never about this.
2097
+ const loaded = loadStateFile(resolve(path));
2098
+ if (loaded.ok) {
2099
+ const parsed = parseState(loaded.text);
2100
+ if (parsed.ok && isOlder(parsed.state.emVersion, PKG_VERSION)) {
2101
+ console.error(`warn: recorded Em version (${parsed.state.emVersion}) is behind installed em (${PKG_VERSION}) — run \`em upgrade\``);
2102
+ }
2103
+ }
1724
2104
  // Set the code rather than process.exit(): same rationale as em ledger/em diff — stdout to
1725
2105
  // a pipe (a --json document) shouldn't risk truncation.
1726
2106
  if (!result.ok)
@@ -1796,6 +2176,105 @@ ci.command("init")
1796
2176
  console.log(`${path} already up to date`);
1797
2177
  }
1798
2178
  });
2179
+ program
2180
+ .command("upgrade")
2181
+ .description("bring a model repo authored under an older em (1.6 forward) up to the installed version: " +
2182
+ "an ordered list of mechanical steps (skill bundle, reaction shape, state-file bullets, " +
2183
+ "generated CI blocks, constitution scaffold) plus a human list of things no command can " +
2184
+ "safely decide by itself (MIL-219, see docs/upgrading.md). Dry-run by default; --apply " +
2185
+ "makes one git commit per applicable step, refusing on a dirty working tree")
2186
+ .argument("<file>", "input .em model file")
2187
+ .option("--apply", "apply every applicable mechanical step, one git commit each, then a final `Em version:` commit")
2188
+ .option("--check", "exit non-zero only on a hard incompatibility (unparseable state file, an un-migratable .em shape) — writes nothing; what CI runs")
2189
+ .option("--json", "print a JSON document instead of text (dry-run/--check only, never with --apply)")
2190
+ .action((file, opts) => {
2191
+ if (opts.apply && opts.check) {
2192
+ console.error("em upgrade: --apply and --check are mutually exclusive");
2193
+ process.exit(1);
2194
+ }
2195
+ const { model, refs, diagnostics, source } = compileFile(file);
2196
+ printDiagnostics(diagnostics);
2197
+ // Deliberately does NOT refuse on hasErrors(diagnostics), unlike every other command that
2198
+ // compiles a model first: the old pre-1.7.1 two-slice reaction shape `reaction-shape`
2199
+ // migrates is itself a validation ERROR under the current rules (MIL-120 changed what
2200
+ // `em validate` accepts) — refusing here would make that step, and the `predates-1.6` human
2201
+ // item, unreachable for exactly the repos that need them. Every step/human-detector either
2202
+ // reads the raw source directly (reaction-shape/predates-1.6, via planMigration) or a
2203
+ // best-effort compiled model that's still structurally usable even with validation errors
2204
+ // present (compile() never throws for a semantic error, only a parse error — already
2205
+ // handled by compileFile() above).
2206
+ if (hasErrors(diagnostics)) {
2207
+ console.error(`em upgrade: "${file}" has validation errors — proceeding anyway (this is often exactly what needs upgrading)`);
2208
+ }
2209
+ void source; // compileFile's own source read; steps re-read from disk themselves (module header)
2210
+ const baseDir = dirname(file);
2211
+ const repoRootResult = resolveRepoRoot(baseDir);
2212
+ if (!repoRootResult.ok) {
2213
+ console.error(repoRootResult.message);
2214
+ process.exit(1);
2215
+ }
2216
+ const ctx = {
2217
+ modelFile: file,
2218
+ baseDir,
2219
+ repoRoot: repoRootResult.repoRoot,
2220
+ installedVersion: PKG_VERSION,
2221
+ packagedSkillsRoot: packagedSkillsRoot(),
2222
+ model,
2223
+ refs,
2224
+ };
2225
+ if (opts.check) {
2226
+ const { ok, report } = checkUpgrade(ctx);
2227
+ console.error(`em upgrade --check: from ${report.from.version}${report.from.inferred ? ` (inferred: ${report.from.basis})` : ""} → to ${report.to}`);
2228
+ for (const s of report.steps) {
2229
+ console.error(` [${s.applicable ? "x" : " "}] ${s.id} (since ${s.sinceVersion}): ${s.reason}`);
2230
+ }
2231
+ for (const h of report.human)
2232
+ console.error(` human: ${h.id}: ${h.reason}`);
2233
+ console.error(ok ? "ok — no hard incompatibility" : "em upgrade --check: hard incompatibility found");
2234
+ if (!ok)
2235
+ process.exitCode = 1;
2236
+ return;
2237
+ }
2238
+ if (opts.apply) {
2239
+ const result = applyUpgrade(ctx);
2240
+ for (const s of result.applied) {
2241
+ if (s.applied)
2242
+ console.log(`applied: ${s.id} — ${s.changedFiles.join(", ")} (${s.commit})`);
2243
+ else
2244
+ console.log(`skipped: ${s.id}`);
2245
+ }
2246
+ if (!result.ok) {
2247
+ console.error(result.message);
2248
+ process.exit(1);
2249
+ }
2250
+ if (result.emVersionCommit)
2251
+ console.log(`recorded: ${result.emVersionCommit}`);
2252
+ else
2253
+ console.log("Em version: already up to date — no commit needed");
2254
+ for (const h of result.report.human)
2255
+ console.log(`human: ${h.id}: ${h.reason}`);
2256
+ return;
2257
+ }
2258
+ const report = detectUpgrade(ctx);
2259
+ if (opts.json) {
2260
+ console.log(buildUpgradeJson(file, report));
2261
+ return;
2262
+ }
2263
+ console.log(`em upgrade: from ${report.from.version}${report.from.inferred ? ` (inferred: ${report.from.basis})` : ""} → to ${report.to}`);
2264
+ if (report.stateFileError)
2265
+ console.log(`state file: ${report.stateFileError}`);
2266
+ console.log("Steps:");
2267
+ for (const s of report.steps) {
2268
+ console.log(` [${s.applicable ? "x" : " "}] ${s.id} (since ${s.sinceVersion}): ${s.reason}`);
2269
+ }
2270
+ console.log("Human:");
2271
+ if (report.human.length === 0)
2272
+ console.log(" (none)");
2273
+ for (const h of report.human)
2274
+ console.log(` ${h.id}: ${h.reason}`);
2275
+ const applicableCount = report.steps.filter((s) => s.applicable).length;
2276
+ console.log(`${applicableCount} step(s) applicable, ${report.human.length} human item(s) — re-run with --apply to apply mechanical steps`);
2277
+ });
1799
2278
  // Exported so dev tooling (e.g. scripts/generate-skill-docs.ts) can introspect the registered
1800
2279
  // commands/options without triggering a real CLI run. Guarded the same way Node's CJS
1801
2280
  // `require.main === module` used to: only actually parse argv when this file is the process's
@@ -1838,15 +2317,17 @@ function compileFile(file, opts = {},
1838
2317
  }
1839
2318
  }
1840
2319
  /** Lineage-ref resolution (MIL-84), frontmatter-coherence (MIL-85), note-binding mismatches
1841
- * (MIL-126), doc↔model consistency (MIL-124), and orphaned slice docs (MIL-183) are `em
1842
- * validate`'s fs-aware rules — every other diagnostic in `diagnostics` (from `compileFile`) is a
1843
- * pure function of the .em source. All five read `slices/*.md` alongside the model. Doc↔model
1844
- * consistency and orphaned-slice-doc are deliberately validate-only (unlike note-binding, which
1845
- * `em render` also folds in) — both are conform-phase concerns, not something every render needs
1846
- * to recheck. Shared by `em validate` and `em state log-usage` (MIL-161) — the latter needs the
1847
- * exact same diagnostic set `em validate --json` would report, since its whole job is logging
1848
- * which categories fired. */
1849
- function computeAllDiagnostics(file, model, refs, diagnostics) {
2320
+ * (MIL-126), doc↔model consistency (MIL-124), orphaned slice docs (MIL-183), and model-version
2321
+ * staleness (MIL-218) are `em validate`'s fs-aware rules — every other diagnostic in
2322
+ * `diagnostics` (from `compileFile`) is a pure function of the .em source. All six read
2323
+ * `slices/*.md`/`model-versions/*.json` alongside the model. Doc↔model consistency,
2324
+ * orphaned-slice-doc, and model-version-stale are deliberately validate-only (unlike
2325
+ * note-binding, which `em render` also folds in) — all three are conform-phase concerns, not
2326
+ * something every render needs to recheck. Shared by `em validate` and `em state log-usage`
2327
+ * (MIL-161) — the latter needs the exact same diagnostic set `em validate --json` would
2328
+ * report, since its whole job is logging which categories fired. `source` is the `.em` file's
2329
+ * own text — only `validateModelVersionStale` reads it (the modelHash comparison). */
2330
+ function computeAllDiagnostics(file, model, refs, diagnostics, source) {
1850
2331
  return [
1851
2332
  ...diagnostics,
1852
2333
  ...validateLineage(model, refs, dirname(file)),
@@ -1854,6 +2335,7 @@ function computeAllDiagnostics(file, model, refs, diagnostics) {
1854
2335
  ...validateNoteBindings(model, refs, dirname(file)),
1855
2336
  ...validateDocModelConsistency(model, refs, dirname(file)),
1856
2337
  ...validateOrphanedSliceDocs(model, refs, dirname(file)),
2338
+ ...validateModelVersionStale(model, refs, dirname(file), source),
1857
2339
  ];
1858
2340
  }
1859
2341
  /** Read a file's text, or exit with a clear error — shared by `em diff` (both forms) and
@@ -1953,16 +2435,22 @@ function printDivergences(model) {
1953
2435
  console.log(` divergence :${el.line} slice "${slice.name}" ${el.kind} "${el.name}": ${el.divergence}`);
1954
2436
  }
1955
2437
  }
1956
- /** Print only elements marked `public`: slice, kind, name, line — for an integration-surface audit. */
2438
+ /** Print only elements marked `public`: slice, kind, name, line — for an integration-surface
2439
+ * audit. A `public` view also gets a "(no in-model reader)" note (MIL-215) when no `ui`/
2440
+ * reaction anywhere on its timeline reads it — an audit hint, not a warning: a genuinely
2441
+ * cross-model `public` view's reader legitimately lives outside this file (see
2442
+ * `publicViewsWithoutInModelReader`'s own header for why this stays advisory). */
1957
2443
  function printPublicElements(model) {
1958
2444
  const pub = model.elements.filter((el) => el.public && (el.kind === "event" || el.kind === "view"));
1959
2445
  if (pub.length === 0) {
1960
2446
  console.log("no public elements");
1961
2447
  return;
1962
2448
  }
2449
+ const noReader = publicViewsWithoutInModelReader(model);
1963
2450
  for (const el of pub) {
1964
2451
  const slice = model.slices[el.sliceIndex];
1965
- console.log(` public :${el.line} slice "${slice.name}" ${el.kind} "${el.name}"`);
2452
+ const note = el.kind === "view" && noReader.has(el.logicalId) ? " (no in-model reader)" : "";
2453
+ console.log(` public :${el.line} slice "${slice.name}" ${el.kind} "${el.name}"${note}`);
1966
2454
  }
1967
2455
  }
1968
2456
  /** Diagnostics in `all` not already present (by reference identity) in `already` — avoids