@milehimikey/em 1.12.0 → 1.13.1

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 (112) hide show
  1. package/.claude/skills/event-modeling/SKILL.md +5 -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 +26 -4
  5. package/.claude/skills/event-modeling-discover/SKILL.md +1 -1
  6. package/.claude/skills/event-modeling-implement/SKILL.md +7 -4
  7. package/.claude/skills/event-modeling-implement/reference/implement.md +26 -8
  8. package/.claude/skills/event-modeling-review/SKILL.md +5 -1
  9. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +45 -6
  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 +39 -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.d.ts +24 -0
  28. package/dist/cli/ciInit.js +85 -15
  29. package/dist/cli/ciInit.js.map +1 -1
  30. package/dist/cli/conformScope.d.ts +1 -0
  31. package/dist/cli/conformScope.js +3 -1
  32. package/dist/cli/conformScope.js.map +1 -1
  33. package/dist/cli/conformSupersede.d.ts +24 -2
  34. package/dist/cli/conformSupersede.js +44 -3
  35. package/dist/cli/conformSupersede.js.map +1 -1
  36. package/dist/cli/coverage.d.ts +16 -2
  37. package/dist/cli/coverage.js +26 -4
  38. package/dist/cli/coverage.js.map +1 -1
  39. package/dist/cli/findings.d.ts +134 -0
  40. package/dist/cli/findings.js +355 -0
  41. package/dist/cli/findings.js.map +1 -0
  42. package/dist/cli/metrics.d.ts +93 -0
  43. package/dist/cli/metrics.js +331 -0
  44. package/dist/cli/metrics.js.map +1 -0
  45. package/dist/cli/modelVersion.d.ts +139 -0
  46. package/dist/cli/modelVersion.js +256 -0
  47. package/dist/cli/modelVersion.js.map +1 -0
  48. package/dist/cli/reratify.d.ts +24 -1
  49. package/dist/cli/reratify.js +73 -17
  50. package/dist/cli/reratify.js.map +1 -1
  51. package/dist/cli/skillCheck.js +2 -2
  52. package/dist/cli/skillCheck.js.map +1 -1
  53. package/dist/cli/sliceConform.d.ts +50 -0
  54. package/dist/cli/sliceConform.js +206 -0
  55. package/dist/cli/sliceConform.js.map +1 -0
  56. package/dist/cli/sliceIndex.d.ts +2 -0
  57. package/dist/cli/sliceIndex.js +15 -3
  58. package/dist/cli/sliceIndex.js.map +1 -1
  59. package/dist/cli/sliceNew.d.ts +12 -0
  60. package/dist/cli/sliceNew.js +23 -0
  61. package/dist/cli/sliceNew.js.map +1 -1
  62. package/dist/cli/sliceStubAll.d.ts +76 -0
  63. package/dist/cli/sliceStubAll.js +189 -0
  64. package/dist/cli/sliceStubAll.js.map +1 -0
  65. package/dist/cli/stateFile.d.ts +63 -7
  66. package/dist/cli/stateFile.js +179 -18
  67. package/dist/cli/stateFile.js.map +1 -1
  68. package/dist/cli/status.d.ts +79 -1
  69. package/dist/cli/status.js +134 -15
  70. package/dist/cli/status.js.map +1 -1
  71. package/dist/cli/upgrade.d.ts +132 -0
  72. package/dist/cli/upgrade.js +524 -0
  73. package/dist/cli/upgrade.js.map +1 -0
  74. package/dist/cli.js +609 -50
  75. package/dist/cli.js.map +1 -1
  76. package/dist/emit/coverageJson.d.ts +5 -2
  77. package/dist/emit/coverageJson.js +6 -2
  78. package/dist/emit/coverageJson.js.map +1 -1
  79. package/dist/emit/freshnessJson.d.ts +1 -1
  80. package/dist/emit/freshnessJson.js +3 -1
  81. package/dist/emit/freshnessJson.js.map +1 -1
  82. package/dist/emit/json.d.ts +11 -1
  83. package/dist/emit/json.js +35 -1
  84. package/dist/emit/json.js.map +1 -1
  85. package/dist/emit/metricsJson.d.ts +7 -0
  86. package/dist/emit/metricsJson.js +27 -0
  87. package/dist/emit/metricsJson.js.map +1 -0
  88. package/dist/emit/statusJson.d.ts +1 -1
  89. package/dist/emit/statusJson.js +17 -1
  90. package/dist/emit/statusJson.js.map +1 -1
  91. package/dist/emit/upgradeJson.d.ts +5 -0
  92. package/dist/emit/upgradeJson.js +29 -0
  93. package/dist/emit/upgradeJson.js.map +1 -0
  94. package/dist/emit/validateJson.d.ts +6 -2
  95. package/dist/emit/validateJson.js +12 -5
  96. package/dist/emit/validateJson.js.map +1 -1
  97. package/dist/mcp/server.d.ts +1 -1
  98. package/dist/mcp/server.js +137 -9
  99. package/dist/mcp/server.js.map +1 -1
  100. package/dist/model/rules.d.ts +7 -0
  101. package/dist/model/rules.js +12 -0
  102. package/dist/model/rules.js.map +1 -1
  103. package/dist/model/validate.d.ts +20 -0
  104. package/dist/model/validate.js +94 -45
  105. package/dist/model/validate.js.map +1 -1
  106. package/dist/templates.d.ts +2 -2
  107. package/dist/templates.js +7 -1
  108. package/dist/templates.js.map +1 -1
  109. package/dist/util/semver.d.ts +19 -0
  110. package/dist/util/semver.js +54 -0
  111. package/dist/util/semver.js.map +1 -0
  112. package/package.json +2 -2
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,16 +28,19 @@ 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";
34
35
  import { planMigration, verifyMigration } from "./cli/migrateReactionShape.js";
35
36
  import { buildLedgerJson } from "./emit/ledgerJson.js";
36
- import { buildCoverageReport, resolveScopedSlices } from "./cli/coverage.js";
37
+ import { buildCoverageReport, resolveScopedSlices, resolveCoverageSliceKey } 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 " +
@@ -577,8 +646,12 @@ slice
577
646
  .command("ratify")
578
647
  .description("flip a slice doc's frontmatter to `status: ready-to-implement` and record `ratifiedBy:`/" +
579
648
  "`ratifiedOn:` — the handoff sign-off (MIL-165, docs/process.md#the-slice-lifecycle-gates) " +
580
- "that makes who ratified, and when, a first-class recorded fact. Refuses a doc that never " +
581
- "passed the review gate (`em slice review`) unless --skip-review is passed. Idempotent on " +
649
+ "that makes who ratified, and when, a first-class recorded fact. The review gate is a check " +
650
+ "on the doc's current `status:`, not on whether `reviewedBy:`/`reviewedOn:` are populated: " +
651
+ "a doc at `reviewed` or `ready-to-implement` passes (the latter is where `em slice " +
652
+ "reratify` leaves a doc, so the follow-up ratify needs no --skip-review); any other status " +
653
+ "— a `draft` that never went through `em slice review` — is refused unless --skip-review " +
654
+ "is passed. Idempotent on " +
582
655
  "the same --by/--on pair; refuses to overwrite a different one already recorded; never " +
583
656
  "touches `version:` or the doc body")
584
657
  .argument("<file>", "input .em file")
@@ -587,7 +660,7 @@ slice
587
660
  .option("--on <date>", "ratification date, YYYY-MM-DD (default: today, local date)")
588
661
  .option("--skip-review", "ratify without a recorded review — prints a loud notice on stderr")
589
662
  .action((file, sliceKey, opts) => {
590
- const { model, refs, diagnostics } = compileFile(file);
663
+ const { model, refs, diagnostics, source } = compileFile(file);
591
664
  printDiagnostics(diagnostics);
592
665
  // Scoped the same way `em slice mark-implemented`/`em export --slice`/`em validate
593
666
  // --slice-ready` are: only an error concerning THIS slice refuses.
@@ -622,19 +695,23 @@ slice
622
695
  console.log(result.changed
623
696
  ? `ratified: ${result.path} (ratifiedBy: ${opts.by}, ratifiedOn: ${ratifiedOn})`
624
697
  : `already ratified (no-op): ${result.path}`);
698
+ warnModelVersionDrift(dirname(file), model, refs, source, `ratifying "${sliceKey}"`);
625
699
  });
626
700
  slice
627
701
  .command("reratify")
628
702
  .description("bump `version:` and flip a shipped slice doc's frontmatter back to `status: " +
629
703
  "ready-to-implement` — the re-ratification mechanical edit (MIL-161, mirrors `em slice " +
630
- "mark-implemented`). Only applies to a doc at `status: implemented`; clears any stale " +
704
+ "mark-implemented`). Applies to a doc at `status: implemented`, or (MIL-258) to a " +
705
+ "`ready-to-implement` doc that is ratified but not yet shipped — there it bumps `version:` " +
706
+ "and clears the sign-off but leaves `status:` alone; a `ready-to-implement` doc with no " +
707
+ "`ratifiedBy:` refuses (awaiting `em slice ratify --by`). Clears any stale " +
631
708
  "`ratifiedBy:`/`ratifiedOn:`/`reviewedBy:`/`reviewedOn:` (they describe the PRIOR version's " +
632
709
  "review and sign-off) so a follow-up `em slice ratify --by` applies cleanly — and needs no " +
633
710
  "fresh review; never touches `implementedIn:` or the doc body")
634
711
  .argument("<file>", "input .em file")
635
712
  .argument("<slice-key>", "slice export key (kebab-case)")
636
713
  .action((file, sliceKey) => {
637
- const { model, refs, diagnostics } = compileFile(file);
714
+ const { model, refs, diagnostics, source } = compileFile(file);
638
715
  printDiagnostics(diagnostics);
639
716
  // Scoped the same way `em slice ratify`/`em slice mark-implemented`/`em export --slice`/
640
717
  // `em validate --slice-ready` are: only an error concerning THIS slice refuses.
@@ -648,7 +725,67 @@ slice
648
725
  console.error(`em slice reratify: ${result.message}`);
649
726
  process.exit(1);
650
727
  }
651
- console.log(`reratified: ${result.path} (version: ${result.newVersion}, status: ready-to-implement)`);
728
+ // MIL-214: advisory only, never refuses — see reratify.ts's reratifyAdvisory. Both warnings
729
+ // can fire together (a version that was never certified AND still carries unruled findings).
730
+ if (result.advisory?.neverCertified) {
731
+ console.error(`warn: reratifying "${sliceKey}" whose v${result.newVersion - 1} was never certified`);
732
+ }
733
+ if (result.advisory && result.advisory.unruledFindingsCount > 0) {
734
+ console.error(`warn: reratifying "${sliceKey}" has ${result.advisory.unruledFindingsCount} unruled conformance finding(s)`);
735
+ }
736
+ if (result.kind === "unshipped") {
737
+ // MIL-258: nothing was flipped — say what DID happen, and that the new version is unsigned.
738
+ console.log(`reratified: ${result.path} (version: ${result.newVersion - 1} -> ${result.newVersion}, sign-off cleared, ` +
739
+ `status unchanged: ready-to-implement) — not ratified until \`em slice ratify --by <name>\` records the new sign-off`);
740
+ }
741
+ else {
742
+ console.log(`reratified: ${result.path} (version: ${result.newVersion}, status: ready-to-implement)`);
743
+ }
744
+ warnModelVersionDrift(dirname(file), model, refs, source, `reratifying "${sliceKey}"`);
745
+ });
746
+ slice
747
+ .command("conform")
748
+ .description("record per-slice-per-version conformance certification: sets `conformedVersion:`/" +
749
+ "`conformedAt:`/`conformedOn:` on a slice doc (MIL-214) — the fact `driftSignal: in-sync` " +
750
+ "now depends on. Legal only for `status: implemented` with an `implementedIn:` link. " +
751
+ "Idempotent on the same (version, --at) pair; a different --at simply overwrites (a " +
752
+ "later re-certification is normal — there's no way for em to tell 'later' from 'earlier' " +
753
+ "for an arbitrary revision string, so there's no --force to reach for). Refuses when the " +
754
+ "newest conformance/*-findings.json matching --at still has an unruled finding in scope " +
755
+ "for this slice; --skip-findings-check overrides with a loud notice.")
756
+ .argument("<file>", "input .em file")
757
+ .argument("<slice-key>", "slice export key (kebab-case)")
758
+ .requiredOption("--at <rev>", "the target-repo revision this certification sweep diffed against")
759
+ .option("--on <date>", "certification date, YYYY-MM-DD (default: today, local date)")
760
+ .option("--skip-findings-check", "certify even with unruled findings in scope — prints a loud notice on stderr")
761
+ .action((file, sliceKey, opts) => {
762
+ const { model, refs, diagnostics } = compileFile(file);
763
+ printDiagnostics(diagnostics);
764
+ // Scoped the same way `em slice ratify`/`em slice reratify`/`em slice mark-implemented` are:
765
+ // only an error concerning THIS slice refuses.
766
+ const scopedErrors = diagnostics.filter((d) => d.severity === "error" && d.refs?.some((r) => r === sliceKey || r.startsWith(`${sliceKey}/`)));
767
+ if (scopedErrors.length > 0) {
768
+ console.error(`em slice conform: slice "${sliceKey}" has errors — fix them first`);
769
+ process.exit(1);
770
+ }
771
+ const today = localIsoDate();
772
+ const on = opts.on ?? today;
773
+ if (opts.on !== undefined && !isValidDateString(opts.on)) {
774
+ console.error(`em slice conform: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
775
+ process.exit(1);
776
+ }
777
+ const result = runSliceConform(model, refs, dirname(file), sliceKey, opts.at, on, opts.skipFindingsCheck === true);
778
+ if (!result.ok) {
779
+ console.error(`em slice conform: ${result.message}`);
780
+ process.exit(1);
781
+ }
782
+ if (result.skippedFindingsCheck !== null) {
783
+ console.error(`notice: --skip-findings-check — certifying "${sliceKey}" with ${result.skippedFindingsCheck.length} ` +
784
+ `unruled conformance finding(s) still in scope (id ${result.skippedFindingsCheck.join(", ")})`);
785
+ }
786
+ console.log(result.changed
787
+ ? `certified: ${result.path} (conformedVersion: ${result.version}, conformedAt: ${opts.at}, conformedOn: ${on})`
788
+ : `already certified (no-op): ${result.path}`);
652
789
  });
653
790
  program
654
791
  .command("changelog")
@@ -673,23 +810,45 @@ program
673
810
  }
674
811
  });
675
812
  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) {
813
+ /** Shared by every `em state` writer: load, apply the pure patch, stamp `Em version:` to the
814
+ * running em (MIL-219 ruling A — every state-file writer refreshes it, since each is already
815
+ * rewriting the file), write, report — the only difference between set-phase/set-conformance/
816
+ * set-review/set-model-version is which `mutate` closure they pass. `today` defaults to
817
+ * `localIsoDate()`; a caller that also needs to stamp a SECOND artifact (MIL-218: `em state
818
+ * set-conformance`'s model-version certify step) with the exact same date passes it in
819
+ * explicitly, computed once, rather than each call independently reading the clock and risking
820
+ * a midnight-rollover mismatch between the two. The `Em version:` stamp is best-effort — a
821
+ * failure there (should never happen: `Model file:` is one of the six bullets a successful
822
+ * `loadStateFile`+`mutate` already proves is present) falls back to `mutate`'s own output rather
823
+ * than losing the write the caller actually asked for. */
824
+ function writeStateUpdate(dirOrFile, cmdLabel, mutate, today = localIsoDate()) {
679
825
  const loaded = loadStateFile(dirOrFile);
680
826
  if (!loaded.ok) {
681
827
  console.error(loaded.message);
682
828
  process.exit(1);
683
829
  }
684
- const today = localIsoDate();
685
830
  const result = mutate(loaded.text, today);
686
831
  if (!result.ok) {
687
832
  console.error(`${cmdLabel}: ${result.message}`);
688
833
  process.exit(1);
689
834
  }
690
- writeFileSync(loaded.path, result.text);
835
+ const stamped = setEmVersion(result.text, PKG_VERSION, today);
836
+ writeFileSync(loaded.path, stamped.ok ? stamped.text : result.text);
691
837
  console.log(`wrote ${loaded.path}`);
692
838
  }
839
+ /** MIL-218 ruling C: the one-line advisory `em slice ratify`/`reratify` print when the write
840
+ * they just made pushed the slice-version vector (or, in principle, the `.em` content hash —
841
+ * never true for these two commands, which only ever edit a slice doc's frontmatter, but
842
+ * checked anyway so this helper stays the one true predicate) past the last bumped design
843
+ * version. Silent when no manifest exists at all (`drift.current === null`) — a repo that
844
+ * never bumped a design version has nothing to be stale against, same silence
845
+ * `model-version-stale` (rules.ts) holds. Never refuses; advisory only. */
846
+ function warnModelVersionDrift(baseDir, model, refs, source, cmdLabel) {
847
+ const drift = modelVersionDrift(baseDir, model, refs, source);
848
+ if (drift.current === null || (!drift.hashChanged && drift.slicesChanged.length === 0))
849
+ return;
850
+ console.error(`warn: ${cmdLabel} moved the model past v${drift.current} — run \`em model version bump\` to record it`);
851
+ }
693
852
  const state = program
694
853
  .command("state")
695
854
  .description("read/write the state file's mechanical fields deterministically (see docs/cli.md)");
@@ -725,12 +884,95 @@ state
725
884
  });
726
885
  state
727
886
  .command("set-conformance")
728
- .description("rewrite Last conformance: (and Last updated:) in the exact format reference/conform.md parses")
887
+ .description("rewrite Last conformance: (and Last updated:) in the exact format reference/conform.md " +
888
+ "parses. Refuses (MIL-214) while any `implemented` slice has an unruled conformance " +
889
+ "finding in the findings JSON beside --report (a `slice: null` finding blocks every " +
890
+ "slice); --partial records the marker with a ` (partial)` suffix instead, with a loud " +
891
+ "notice. Without a findings JSON beside --report: warns once and records the marker as " +
892
+ "given (migration path for a report predating `em conform-findings`)")
729
893
  .argument("<revision>", "target-repo revision just diffed against")
730
894
  .argument("[dir]", STATE_DIR_HELP, ".")
731
895
  .requiredOption("--report <path>", "path to the conformance report just written")
896
+ .option("--partial", "record the marker as PARTIAL even though some in-scope findings are still unruled")
732
897
  .action((revision, dir, opts) => {
733
- writeStateUpdate(dir, "em state set-conformance", (text, today) => setConformance(text, revision, opts.report, today));
898
+ const loaded = loadStateFile(dir);
899
+ if (!loaded.ok) {
900
+ console.error(loaded.message);
901
+ process.exit(1);
902
+ }
903
+ const parsed = parseState(loaded.text);
904
+ if (!parsed.ok) {
905
+ console.error(`em state set-conformance: ${parsed.message}`);
906
+ process.exit(1);
907
+ }
908
+ const modelDir = dirname(loaded.path);
909
+ const modelFile = join(modelDir, parsed.state.modelPath);
910
+ // The "in scope" set for the findings gate below — every `implemented` slice, per the
911
+ // ruling. Best-effort: a model that doesn't exist or doesn't compile cleanly can't tell us
912
+ // its implemented slices, so the gate is skipped (not refused) in that case rather than
913
+ // blocking a state-file operation on an unrelated model problem — `em validate` is where
914
+ // that problem gets its own diagnostic. Also the compiled model MIL-218's certify step
915
+ // needs below (kept, not just the derived `inScope` set, since certify needs the model/refs
916
+ // themselves — never re-compiled a second time).
917
+ let inScope = new Set();
918
+ let compiledForCertify = null;
919
+ if (existsSync(modelFile)) {
920
+ const compiled = compileFile(modelFile);
921
+ if (!hasErrors(compiled.diagnostics)) {
922
+ const { facts } = resolveSliceDocFacts(compiled.model, compiled.refs, modelDir);
923
+ inScope = new Set(facts.filter((f) => f.status === "implemented").map((f) => f.key));
924
+ compiledForCertify = { model: compiled.model, refs: compiled.refs, source: compiled.source };
925
+ }
926
+ }
927
+ let unruled = [];
928
+ const lookup = lookupFindingsBesideReport(modelDir, opts.report);
929
+ if (lookup.kind === "invalid") {
930
+ console.error(`em state set-conformance: ${lookup.message}`);
931
+ process.exit(1);
932
+ }
933
+ else if (lookup.kind === "absent") {
934
+ console.error(`warn: no findings JSON found beside ${opts.report} — recording the marker without verifying every ` +
935
+ "finding is ruled (migration path for a report written before `em conform-findings`)");
936
+ }
937
+ else {
938
+ unruled = unruledFindingsInScope(lookup.doc.findings, inScope);
939
+ }
940
+ const partialFlag = opts.partial === true;
941
+ if (unruled.length > 0 && !partialFlag) {
942
+ console.error(`em state set-conformance: ${unruled.length} unruled conformance finding(s) among implemented slices ` +
943
+ `(id ${unruled.map((f) => f.id).join(", ")}) — rule on them (\`em conform-supersede --locus --by\`) or pass --partial`);
944
+ process.exit(1);
945
+ }
946
+ const recordPartial = partialFlag && unruled.length > 0;
947
+ if (recordPartial) {
948
+ console.error(`notice: conformance marker recorded as PARTIAL — ${unruled.length} finding(s) unruled`);
949
+ }
950
+ const today = localIsoDate();
951
+ // MIL-218 ruling D: a FULL (non-`--partial`) marker also certifies the current design
952
+ // version's model-versions/*.json manifest. Checked (and, on success, WRITTEN) before the
953
+ // `Last conformance:` bullet below — a refusal here (no manifest yet, or the vector has
954
+ // drifted since it was bumped) must leave the state file untouched, same "refuse before any
955
+ // write" discipline every other em command holds; never leaves `Last conformance:` updated
956
+ // with no matching `Certified:` to show for it. Best-effort, same posture as the `inScope`
957
+ // computation above, for exactly one case: a model that doesn't compile cleanly can't be
958
+ // certified against, so certification is silently SKIPPED (not a `set-conformance` failure)
959
+ // rather than blocking the conformance marker on an unrelated model problem — `em validate`
960
+ // is where that problem gets its own diagnostic.
961
+ let certifiedVersion = null;
962
+ if (!recordPartial && compiledForCertify) {
963
+ const findingsPath = lookup.kind === "found" ? lookup.path : null;
964
+ const certifyResult = runCertifyModelVersion(modelDir, compiledForCertify.model, compiledForCertify.refs, compiledForCertify.source, revision, today, opts.report, findingsPath);
965
+ if (!certifyResult.ok) {
966
+ console.error(`em state set-conformance: ${certifyResult.message}`);
967
+ process.exit(1);
968
+ }
969
+ certifiedVersion = certifyResult.version;
970
+ console.log(`certified: ${certifyResult.path} (v${certifyResult.version} @ ${revision})`);
971
+ }
972
+ writeStateUpdate(dir, "em state set-conformance", (text, t) => setConformance(text, revision, opts.report, t, recordPartial), today);
973
+ if (certifiedVersion !== null) {
974
+ writeStateUpdate(dir, "em state set-conformance", (text, t) => setCertified(text, certifiedVersion, revision, t, t), today);
975
+ }
734
976
  });
735
977
  state
736
978
  .command("set-review")
@@ -767,8 +1009,8 @@ state
767
1009
  process.exit(1);
768
1010
  }
769
1011
  const phases = sortUsagePhases(rawPhases);
770
- const { model, diagnostics, refs } = compileFile(file);
771
- const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics);
1012
+ const { model, diagnostics, refs, source } = compileFile(file);
1013
+ const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics, source);
772
1014
  const categorySet = new Set(allDiagnostics.map((d) => RULES[d.code].usageCategory));
773
1015
  const categories = categorySet.size > 0 ? [...categorySet].sort() : ["none"];
774
1016
  const loaded = loadStateFile(dirname(file));
@@ -785,6 +1027,67 @@ state
785
1027
  writeFileSync(loaded.path, result.text);
786
1028
  console.log(`wrote ${loaded.path} — phases: ${phases.join(", ")} — validate: ${categories.join(", ")}`);
787
1029
  });
1030
+ const model = program.command("model").description("model-level version: design version + certified version (see docs/model-versions.md)");
1031
+ const modelVersionCmd = model.command("version").description("bump/inspect the model's design version (MIL-218)");
1032
+ modelVersionCmd
1033
+ .command("bump")
1034
+ .description("bump the model's design version: write model-versions/v<N+1>.json (sibling of slices/, " +
1035
+ "conformance/) and rewrite the state file's `Model version:` bullet (MIL-218). Refuses " +
1036
+ "when the state file is missing (run `em scaffold` first), when --by is empty, and when " +
1037
+ "nothing has changed since the current version (same model content hash AND same " +
1038
+ "slice-version vector) unless --force — a no-op bump is the one case --force is right for.")
1039
+ .argument("<file>", "input .em file")
1040
+ .requiredOption("--by <name>", "the bumper's name")
1041
+ .option("--on <date>", "bump date, YYYY-MM-DD (default: today, local date)")
1042
+ .option("--force", "bump even though nothing has changed since the current version")
1043
+ .action((file, opts) => {
1044
+ const baseDir = dirname(file);
1045
+ const loaded = loadStateFile(baseDir);
1046
+ if (!loaded.ok) {
1047
+ console.error(`em model version bump: no state file — run \`em scaffold\` first (${loaded.message})`);
1048
+ process.exit(1);
1049
+ }
1050
+ if (opts.on !== undefined && !isValidDateString(opts.on)) {
1051
+ console.error(`em model version bump: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
1052
+ process.exit(1);
1053
+ }
1054
+ const today = localIsoDate();
1055
+ const on = opts.on ?? today;
1056
+ const { model: compiledModel, refs, diagnostics, source } = compileFile(file);
1057
+ printDiagnostics(diagnostics);
1058
+ if (hasErrors(diagnostics)) {
1059
+ console.error(`em model version bump: "${file}" has errors — fix them first`);
1060
+ process.exit(1);
1061
+ }
1062
+ const result = runModelVersionBump(baseDir, basename(file), compiledModel, refs, source, opts.by, on, GENERATOR_VERSION, opts.force === true);
1063
+ if (!result.ok) {
1064
+ console.error(`em model version bump: ${result.message}`);
1065
+ process.exit(1);
1066
+ }
1067
+ writeStateUpdate(baseDir, "em model version bump", (text, t) => setModelVersion(text, result.version, t), today);
1068
+ console.log(`bumped: ${result.path} (v${result.version})`);
1069
+ });
1070
+ modelVersionCmd
1071
+ .command("show")
1072
+ .description("print the model's current design version and the most recently certified version, if any " +
1073
+ "(model-versions/*.json — MIL-218)")
1074
+ .argument("<file>", "input .em file")
1075
+ .option("--json", "print a JSON document instead of text")
1076
+ .action((file, opts) => {
1077
+ const baseDir = dirname(file);
1078
+ const design = latestModelVersionNumber(baseDir);
1079
+ const certifiedFound = findCertifiedVersion(baseDir);
1080
+ const certified = certifiedFound
1081
+ ? { version: certifiedFound.version, at: certifiedFound.certified.at, on: certifiedFound.certified.on }
1082
+ : null;
1083
+ if (opts.json) {
1084
+ console.log(JSON.stringify({ file, design, certified }, null, 2));
1085
+ }
1086
+ else {
1087
+ console.log(`design version: ${design ?? "none"}`);
1088
+ console.log(`certified: ${certified ? `v${certified.version} @ ${certified.at} (${certified.on})` : "never"}`);
1089
+ }
1090
+ });
788
1091
  program
789
1092
  .command("usage-report")
790
1093
  .description("aggregate every .event-modeling.md's Usage log under [root] into phase/diagnostic-category " +
@@ -870,12 +1173,17 @@ program
870
1173
  "at ratification time so a reader following the report's file:line citations knows they " +
871
1174
  "describe an ancestor of the current model. Additive-only splice, never a rewrite of the " +
872
1175
  "report; idempotent on the same --as-of/--findings/--on stamp; refuses if the report " +
873
- "doesn't exist")
1176
+ "doesn't exist. --locus/--by (MIL-214, both required together) additionally record the " +
1177
+ "ruling itself — locus/resolvedBy/resolvedOn — on the findings named by --findings in the " +
1178
+ "sibling conformance/<date>-findings.json, when one exists (warns once and stamps the " +
1179
+ "banner only when it doesn't — the migration path for a report predating em conform-findings)")
874
1180
  .argument("<file>", "input .em file (used only to resolve the report path's base directory)")
875
1181
  .argument("<report-path>", "path to the conformance report, relative to the model's directory")
876
1182
  .requiredOption("--as-of <rev>", "the revision this ruling was made against — same value passed to `em state set-conformance`")
877
1183
  .requiredOption("--findings <spec>", 'which finding number(s) this stamps as ruled, e.g. "1-3" or "1,2,4"')
878
1184
  .option("--on <date>", "ruling date, YYYY-MM-DD (default: today, local date)")
1185
+ .option("--locus <locus>", `who/what the named finding(s) say is wrong: ${FINDING_LOCI.join(" | ")} (requires --by)`)
1186
+ .option("--by <name>", "who ruled on the named finding(s) (requires --locus)")
879
1187
  .action((file, reportPath, opts) => {
880
1188
  const today = localIsoDate();
881
1189
  const on = opts.on ?? today;
@@ -883,15 +1191,57 @@ program
883
1191
  console.error(`em conform-supersede: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
884
1192
  process.exit(1);
885
1193
  }
886
- const result = runConformSupersede(dirname(file), reportPath, opts.asOf, opts.findings, on);
1194
+ if ((opts.locus !== undefined) !== (opts.by !== undefined)) {
1195
+ console.error("em conform-supersede: --locus and --by must be given together");
1196
+ process.exit(1);
1197
+ }
1198
+ if (opts.locus !== undefined && !FINDING_LOCI.includes(opts.locus)) {
1199
+ console.error(`em conform-supersede: invalid --locus "${opts.locus}" — expected one of: ${FINDING_LOCI.join(", ")}`);
1200
+ process.exit(1);
1201
+ }
1202
+ const ruling = opts.locus !== undefined && opts.by !== undefined ? { locus: opts.locus, by: opts.by } : undefined;
1203
+ const result = runConformSupersede(dirname(file), reportPath, opts.asOf, opts.findings, on, ruling);
887
1204
  if (!result.ok) {
888
1205
  console.error(`em conform-supersede: ${result.message}`);
889
1206
  process.exit(1);
890
1207
  }
1208
+ if (result.findingsRuling.kind === "no-findings-file") {
1209
+ console.error(`warn: ${result.findingsRuling.warning}`);
1210
+ }
1211
+ else if (result.findingsRuling.kind === "applied") {
1212
+ console.log(result.findingsRuling.changed
1213
+ ? `ruled: ${result.findingsRuling.path} (finding(s) ${result.findingsRuling.ids.join(", ")} -> locus: ${opts.locus}, by: ${opts.by})`
1214
+ : `already ruled (no-op): ${result.findingsRuling.path}`);
1215
+ }
891
1216
  console.log(result.changed
892
1217
  ? `stamped superseded: ${result.path} (as of ${opts.asOf}, findings ${opts.findings}, on ${on})`
893
1218
  : `already stamped (no-op): ${result.path}`);
894
1219
  });
1220
+ const conformFindings = program.command("conform-findings").description("read-only checks for the conformance findings record (MIL-214)");
1221
+ conformFindings
1222
+ .command("check")
1223
+ .description("shape-validate a conformance/<date>-findings.json file (findingsSchemaVersion, required " +
1224
+ "fields, enum values, ids sorted+unique, a ruled finding carries resolvedBy+resolvedOn) — " +
1225
+ "exit 1 on shape errors. The conform skill writes this file directly alongside its report; " +
1226
+ "this is how a headless run verifies what it just wrote.")
1227
+ .argument("<path>", "path to a conformance/<date>-findings.json file")
1228
+ .option("--json", "print a JSON document instead of the text report")
1229
+ .action((path, opts) => {
1230
+ const result = checkFindingsFile(path);
1231
+ if (opts.json) {
1232
+ console.log(buildCheckFindingsJson(result));
1233
+ }
1234
+ else if (result.ok) {
1235
+ console.log(`ok — ${result.path}: ${result.findingsCount} finding(s), shape valid`);
1236
+ }
1237
+ else {
1238
+ console.error(`em conform-findings check: ${result.path} — shape errors:`);
1239
+ for (const e of result.errors)
1240
+ console.error(` - ${e}`);
1241
+ }
1242
+ if (!result.ok)
1243
+ process.exit(1);
1244
+ });
895
1245
  program
896
1246
  .command("watch")
897
1247
  .description("re-render on every save")
@@ -980,8 +1330,8 @@ program
980
1330
  .option("--json", "print a JSON document instead of text — works on a model WITH errors, unlike `em export` " +
981
1331
  "(MIL-128, see docs/cli.md); exit codes are unchanged")
982
1332
  .action((file, opts) => {
983
- const { model, diagnostics, refs } = compileFile(file);
984
- const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics);
1333
+ const { model, diagnostics, refs, source } = compileFile(file);
1334
+ const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics, source);
985
1335
  if (opts.sliceReady) {
986
1336
  // MIL-87: a targeted, single-slice readiness gate, not part of the unconditional
987
1337
  // diagnostic set above (see sliceReadyValidate.ts's header for why). Folds in MIL-85's
@@ -1173,9 +1523,13 @@ program
1173
1523
  "definition-of-done citation check; advisory by default, --strict for CI; " +
1174
1524
  "--include-ready also counts ready-to-implement docs (forward-looking report); a " +
1175
1525
  "continuation slice (an `again` view instance with no doc of its own, MIL-208) is excluded " +
1176
- "from the report — its invariants, if any, live in the originating slice's own doc")
1526
+ "from the report — its invariants, if any, live in the originating slice's own doc; " +
1527
+ "--slice <key> scopes the report to one slice whatever its status (MIL-255), the " +
1528
+ "pre-merge definition-of-done check")
1177
1529
  .argument("<file>", "input .em file")
1178
1530
  .requiredOption("--tests <dir>", "directory to scan recursively for test files citing invariant IDs")
1531
+ .option("--slice <key>", "only this slice (export key), whether ready-to-implement or implemented — no --include-ready needed; " +
1532
+ "--strict then fails on this slice's uncited invariants, or if the slice is not in scope (draft/reviewed/unbound: nothing checked) (MIL-255)")
1179
1533
  .option("--strict", "exit non-zero if any invariant ID has zero citations (CI)")
1180
1534
  .option("--include-ready", "also count ready-to-implement docs, not just implemented (MIL-207)")
1181
1535
  .option("--json", "print a JSON document instead of the text report (see docs/cli.md)")
@@ -1188,11 +1542,25 @@ program
1188
1542
  }
1189
1543
  const baseDir = dirname(file);
1190
1544
  const includeReady = !!opts.includeReady;
1545
+ // MIL-255: resolve --slice before anything else — an unknown key is an error (never a
1546
+ // vacuously passing empty report), and a continuation key (MIL-208) scopes to its
1547
+ // originating slice, which the output names (matching `em validate --slice-ready`).
1548
+ let onlyKey;
1549
+ let continuationOfKey = null;
1550
+ if (opts.slice !== undefined) {
1551
+ const target = resolveCoverageSliceKey(model, refs, opts.slice);
1552
+ if (!target) {
1553
+ console.error(`em coverage --slice: no slice with export key "${opts.slice}" in this model`);
1554
+ process.exit(1);
1555
+ }
1556
+ onlyKey = target.key;
1557
+ continuationOfKey = target.continuationOf;
1558
+ }
1191
1559
  // MIL-207: a missing --tests dir is only a defect when something is actually in scope to
1192
1560
  // check — cheap to know without touching the test tree (resolveScopedSlices never reads
1193
1561
  // --tests). A fresh scaffold (or a doc-only ratification PR, with the MIL-207 default scope)
1194
1562
  // has zero implemented docs, so there's nothing yet for a test to cite.
1195
- const anyInScope = resolveScopedSlices(model, refs, baseDir, includeReady).some((s) => s.inScope);
1563
+ const anyInScope = resolveScopedSlices(model, refs, baseDir, includeReady, onlyKey).some((s) => s.inScope);
1196
1564
  if (!existsSync(opts.tests)) {
1197
1565
  if (anyInScope) {
1198
1566
  console.error(`em coverage: --tests directory not found: ${opts.tests}`);
@@ -1204,17 +1572,36 @@ program
1204
1572
  console.error(`em coverage: --tests is not a directory: ${opts.tests}`);
1205
1573
  process.exit(1);
1206
1574
  }
1207
- const report = buildCoverageReport(model, refs, baseDir, opts.tests, includeReady);
1575
+ const report = buildCoverageReport(model, refs, baseDir, opts.tests, includeReady, onlyKey);
1576
+ // MIL-255: a scoped slice whose doc is draft/reviewed/unbound/unusable is out of scope — the
1577
+ // same way the unscoped report treats it — but under --strict "nothing was checked" must not
1578
+ // exit 0, or the pre-merge check passes vacuously (the very bug --slice exists to fix). So:
1579
+ // advisory (no --strict) prints the note on stdout and exits 0; --strict prints it on stderr
1580
+ // and exits 1 (also in --json mode, whose document shape is unchanged — consumers read the
1581
+ // slice's `inScope` alongside `ok`). An in-scope doc with zero invariants stays a pass.
1582
+ const unchecked = onlyKey === undefined ? undefined : report.slices.find((sl) => !sl.inScope);
1583
+ const uncheckedNote = unchecked
1584
+ ? `slice "${unchecked.key}" is not in scope (${unchecked.status ?? unchecked.docReason ?? "no doc"}) — nothing checked; only ready-to-implement/implemented docs are checked`
1585
+ : null;
1586
+ if (uncheckedNote !== null && (opts.strict || !opts.json)) {
1587
+ (opts.strict ? console.error : console.log)(uncheckedNote);
1588
+ }
1208
1589
  if (opts.json) {
1209
- process.stdout.write(buildCoverageJson(file, opts.tests, report, includeReady) + "\n");
1590
+ const sliceScope = opts.slice === undefined ? null : { requested: opts.slice, continuationOf: continuationOfKey };
1591
+ process.stdout.write(buildCoverageJson(file, opts.tests, report, includeReady, sliceScope) + "\n");
1210
1592
  }
1211
1593
  else {
1594
+ if (continuationOfKey !== null) {
1595
+ console.log(`slice "${opts.slice}" is a continuation of "${continuationOfKey}" — checking "${continuationOfKey}"`);
1596
+ }
1212
1597
  for (const slice of report.slices) {
1213
1598
  if (!slice.inScope)
1214
1599
  continue;
1215
1600
  console.log(`slice "${slice.key}" (${slice.status}):`);
1216
1601
  if (slice.invariants.length === 0) {
1217
- console.log(` (no INV-* invariant IDs found in the doc body)`);
1602
+ // MIL-255: said as "checked" so an in-scope doc defining zero invariants (a legitimate
1603
+ // pass) is distinguishable from the "not in scope — nothing checked" case above.
1604
+ console.log(` (checked — the doc defines 0 INV-* invariant IDs)`);
1218
1605
  continue;
1219
1606
  }
1220
1607
  for (const inv of slice.invariants) {
@@ -1233,7 +1620,7 @@ program
1233
1620
  // Advisory by default (exit 0 even with uncovered IDs) — --strict is the opt-in CI gate,
1234
1621
  // same "set exitCode, don't truncate stdout" rationale as em ledger/em diff for the --json
1235
1622
  // form, and consistent for the text form too.
1236
- if (opts.strict && report.uncoveredCount > 0)
1623
+ if (opts.strict && (report.uncoveredCount > 0 || uncheckedNote !== null))
1237
1624
  process.exitCode = 1;
1238
1625
  });
1239
1626
  program
@@ -1265,11 +1652,11 @@ program
1265
1652
  const compiled = [];
1266
1653
  let anyErrors = false;
1267
1654
  for (const file of files) {
1268
- const { model, refs, diagnostics } = compileFile(file);
1655
+ const { model, refs, diagnostics, source } = compileFile(file);
1269
1656
  printDiagnosticsFor(file, diagnostics);
1270
1657
  if (hasErrors(diagnostics))
1271
1658
  anyErrors = true;
1272
- compiled.push({ file, model, refs });
1659
+ compiled.push({ file, model, refs, source });
1273
1660
  }
1274
1661
  if (anyErrors) {
1275
1662
  console.error("em status: not reporting status — fix the errors above");
@@ -1326,7 +1713,13 @@ program
1326
1713
  const sliceDocFacts = facts.map((f) => ({ key: f.key, status: f.rawStatus, implementedIn: f.implementedIn }));
1327
1714
  return resolveConformanceEntry(file, opts.repo, sliceDocFacts);
1328
1715
  });
1329
- const report = buildStatusReport(files, sliceFacts, openIssuesCount, invariants, conformance, statusDiagnostics);
1716
+ // MIL-218: one model-version entry per input file.
1717
+ const modelVersion = compiled.map(({ file, model, refs, source }) => resolveModelVersionStatusEntry(file, model, refs, source));
1718
+ // MIL-219: one em-version entry per input file whose state file resolved.
1719
+ const emVersion = compiled
1720
+ .map(({ file }) => resolveEmVersionStatusEntry(file, PKG_VERSION))
1721
+ .filter((e) => e !== null);
1722
+ const report = buildStatusReport(files, sliceFacts, openIssuesCount, invariants, conformance, statusDiagnostics, modelVersion, emVersion);
1330
1723
  let output;
1331
1724
  if (opts.json)
1332
1725
  output = buildStatusJson(report);
@@ -1371,6 +1764,32 @@ program
1371
1764
  console.log(formatConformancePart(entry));
1372
1765
  }
1373
1766
  });
1767
+ program
1768
+ .command("metrics")
1769
+ .description("the pilot metrics named in advance by the register, computed from git history alone " +
1770
+ "(MIL-170): ratification turnaround (reviewed -> ratified -> implemented), conform-cycle " +
1771
+ "cadence + finding counts, and status-vs-reality disagreement over time — plus a fourth, " +
1772
+ "reported as not computable from history (see docs/usage-data.md). `<file>` is an anchor " +
1773
+ ".em file, used only to locate slices/, conformance/, and .event-modeling.md relative to " +
1774
+ "it — same convention as em ledger; never parsed or compiled")
1775
+ .argument("<file>", "anchor .em file")
1776
+ .requiredOption("--from <rev>", "baseline revision")
1777
+ .option("--to <rev>", "compare revision (default: HEAD)")
1778
+ .option("--json", "print a JSON document instead of the text report (see docs/cli.md)")
1779
+ .action((file, opts) => {
1780
+ const to = opts.to ?? "HEAD";
1781
+ const computed = computeMetrics(file, opts.from, to);
1782
+ if (!computed.ok) {
1783
+ console.error(computed.message);
1784
+ process.exit(1);
1785
+ }
1786
+ if (opts.json) {
1787
+ process.stdout.write(buildMetricsJson(computed.result) + "\n");
1788
+ }
1789
+ else {
1790
+ console.log(formatMetricsText(computed.result));
1791
+ }
1792
+ });
1374
1793
  // `em query` (MIL-168): deterministic graph queries over the compiled model — scoped, token-
1375
1794
  // cheap answers instead of a whole-model `em export`. Compiles every input file through the
1376
1795
  // LIGHTER query sub-pipeline (query/pipeline.ts: parse -> normalize -> refs -> validate against
@@ -1721,6 +2140,18 @@ skill
1721
2140
  console.log(f.message);
1722
2141
  console.log(result.ok ? `ok — vendored skill matches em ${PKG_VERSION}` : `${result.findings.length} mismatch(es)`);
1723
2142
  }
2143
+ // MIL-219 ruling A: `em skill check` also warns when [path]'s own state file records an
2144
+ // `Em version:` behind the installed em — best-effort (no state file, or an unparseable
2145
+ // one, is silently skipped; that's `em upgrade`'s own hard-incompatibility case to report,
2146
+ // not this command's). Always stderr, independent of --json/exit code — advisory only, and
2147
+ // this command's own exit code stays about skill-bundle drift, never about this.
2148
+ const loaded = loadStateFile(resolve(path));
2149
+ if (loaded.ok) {
2150
+ const parsed = parseState(loaded.text);
2151
+ if (parsed.ok && isOlder(parsed.state.emVersion, PKG_VERSION)) {
2152
+ console.error(`warn: recorded Em version (${parsed.state.emVersion}) is behind installed em (${PKG_VERSION}) — run \`em upgrade\``);
2153
+ }
2154
+ }
1724
2155
  // Set the code rather than process.exit(): same rationale as em ledger/em diff — stdout to
1725
2156
  // a pipe (a --json document) shouldn't risk truncation.
1726
2157
  if (!result.ok)
@@ -1735,7 +2166,7 @@ ci.command("init")
1735
2166
  "idempotent, --check for CI self-verification (MIL-166, see docs/ci.md)")
1736
2167
  .argument("<model>", "anchor .em file the coverage/ledger/slice-index/status steps point at")
1737
2168
  .option("--tests <dir>", "test directory the coverage/status steps scan for INV-* citations", "test")
1738
- .option("-f, --force", "replace an existing workflow file that has no GENERATED markers")
2169
+ .option("-f, --force", "replace an existing workflow file that has no GENERATED markers, or a managed block generated for a different model")
1739
2170
  .option("--check", "verify both files match the current preset; exit non-zero on drift without writing (CI)")
1740
2171
  .action((model, opts) => {
1741
2172
  const unsafe = findUnsafeCiInitArg(model) ?? findUnsafeCiInitArg(opts.tests);
@@ -1757,10 +2188,17 @@ ci.command("init")
1757
2188
  planCiFile(conformPath, buildConformWorkflowFile(model, PKG_VERSION), conformManagedBody(model, PKG_VERSION), CONFORM_WORKFLOW_MARKER, !!opts.force),
1758
2189
  ],
1759
2190
  ];
2191
+ // MIL-256 (#174): the managed block is single-model (multi-model: MIL-233). Another model's
2192
+ // block is its own condition - never `stale`, never silently replaced.
2193
+ const otherModel = (previous, path) => `${path} was generated for ${previous}, not ${model} - the managed block is single-model until multi-model support lands (MIL-233)`;
1760
2194
  if (opts.check) {
1761
2195
  let drift = false;
1762
2196
  for (const [path, status] of files) {
1763
- if (status.kind === "create") {
2197
+ if (status.kind === "other-model" || status.kind === "replace-model") {
2198
+ console.log(`different model: ${otherModel(status.previous, path)} (re-run \`em ci init ${model} --force\` to replace it)`);
2199
+ drift = true;
2200
+ }
2201
+ else if (status.kind === "create") {
1764
2202
  console.log(`missing: ${path} — run \`em ci init ${model}\` to create it`);
1765
2203
  drift = true;
1766
2204
  }
@@ -1781,10 +2219,22 @@ ci.command("init")
1781
2219
  process.exitCode = 1;
1782
2220
  return;
1783
2221
  }
2222
+ // Refuse before writing anything, so a refusal on one file never leaves the other updated.
2223
+ const refused = files.filter(([, s]) => s.kind === "other-model");
2224
+ if (refused.length > 0) {
2225
+ for (const [path, status] of refused) {
2226
+ if (status.kind === "other-model")
2227
+ console.error(`em ci init: ${otherModel(status.previous, path)}; re-run with --force to replace it`);
2228
+ }
2229
+ process.exitCode = 1;
2230
+ return;
2231
+ }
1784
2232
  mkdirSync(dirname(ciPath), { recursive: true });
1785
2233
  for (const [path, status] of files) {
1786
2234
  applyCiFile(path, status);
1787
- if (status.kind === "create")
2235
+ if (status.kind === "replace-model")
2236
+ console.log(`replaced ${path} (--force; was generated for ${status.previous})`);
2237
+ else if (status.kind === "create")
1788
2238
  console.log(`installed ${path}`);
1789
2239
  else if (status.kind === "stale")
1790
2240
  console.log(`updated ${path}`);
@@ -1796,6 +2246,106 @@ ci.command("init")
1796
2246
  console.log(`${path} already up to date`);
1797
2247
  }
1798
2248
  });
2249
+ program
2250
+ .command("upgrade")
2251
+ .description("bring a model repo authored under an older em (1.6 forward) up to the installed version: " +
2252
+ "an ordered list of mechanical steps (skill bundle, reaction shape, state-file bullets, " +
2253
+ "generated CI blocks, constitution scaffold) plus a human list of things no command can " +
2254
+ "safely decide by itself (MIL-219, see docs/upgrading.md). Dry-run by default; --apply " +
2255
+ "makes one git commit per applicable step, refusing on a dirty working tree")
2256
+ .argument("<file>", "input .em model file")
2257
+ .option("--apply", "apply every applicable mechanical step, one git commit each, then a final `Em version:` commit")
2258
+ .option("--check", "exit non-zero only on a hard incompatibility (an existing but unparseable state file, an un-migratable .em shape) — writes nothing; what CI runs; the final line says why")
2259
+ .option("--json", "print a JSON document instead of text (dry-run/--check only, never with --apply)")
2260
+ .action((file, opts) => {
2261
+ if (opts.apply && opts.check) {
2262
+ console.error("em upgrade: --apply and --check are mutually exclusive");
2263
+ process.exit(1);
2264
+ }
2265
+ const { model, refs, diagnostics, source } = compileFile(file);
2266
+ printDiagnostics(diagnostics);
2267
+ // Deliberately does NOT refuse on hasErrors(diagnostics), unlike every other command that
2268
+ // compiles a model first: the old pre-1.7.1 two-slice reaction shape `reaction-shape`
2269
+ // migrates is itself a validation ERROR under the current rules (MIL-120 changed what
2270
+ // `em validate` accepts) — refusing here would make that step, and the `predates-1.6` human
2271
+ // item, unreachable for exactly the repos that need them. Every step/human-detector either
2272
+ // reads the raw source directly (reaction-shape/predates-1.6, via planMigration) or a
2273
+ // best-effort compiled model that's still structurally usable even with validation errors
2274
+ // present (compile() never throws for a semantic error, only a parse error — already
2275
+ // handled by compileFile() above).
2276
+ if (hasErrors(diagnostics)) {
2277
+ console.error(`em upgrade: "${file}" has validation errors — proceeding anyway (this is often exactly what needs upgrading)`);
2278
+ }
2279
+ void source; // compileFile's own source read; steps re-read from disk themselves (module header)
2280
+ const baseDir = dirname(file);
2281
+ const repoRootResult = resolveRepoRoot(baseDir);
2282
+ if (!repoRootResult.ok) {
2283
+ console.error(repoRootResult.message);
2284
+ process.exit(1);
2285
+ }
2286
+ const ctx = {
2287
+ modelFile: file,
2288
+ baseDir,
2289
+ repoRoot: repoRootResult.repoRoot,
2290
+ installedVersion: PKG_VERSION,
2291
+ packagedSkillsRoot: packagedSkillsRoot(),
2292
+ model,
2293
+ refs,
2294
+ };
2295
+ if (opts.check) {
2296
+ const { ok, report, reasons } = checkUpgrade(ctx);
2297
+ console.error(`em upgrade --check: from ${report.from.version}${report.from.inferred ? ` (inferred: ${report.from.basis})` : ""} → to ${report.to}`);
2298
+ for (const s of report.steps) {
2299
+ console.error(` [${s.applicable ? "x" : " "}] ${s.id} (since ${s.sinceVersion}): ${s.reason}`);
2300
+ }
2301
+ for (const h of report.human)
2302
+ console.error(` human: ${h.id}: ${h.reason}`);
2303
+ // MIL-257: the final line always says why (previously visible only via --json).
2304
+ console.error(ok ? "ok — no hard incompatibility" : `em upgrade --check: hard incompatibility found: ${reasons.join("; ")}`);
2305
+ if (!ok)
2306
+ process.exitCode = 1;
2307
+ return;
2308
+ }
2309
+ if (opts.apply) {
2310
+ const result = applyUpgrade(ctx);
2311
+ for (const s of result.applied) {
2312
+ if (s.applied)
2313
+ console.log(`applied: ${s.id} — ${s.changedFiles.join(", ")} (${s.commit})`);
2314
+ else
2315
+ console.log(`skipped: ${s.id}`);
2316
+ }
2317
+ if (!result.ok) {
2318
+ console.error(result.message);
2319
+ process.exit(1);
2320
+ }
2321
+ if (result.emVersionCommit)
2322
+ console.log(`recorded: ${result.emVersionCommit}`);
2323
+ else
2324
+ console.log("Em version: already up to date — no commit needed");
2325
+ for (const h of result.report.human)
2326
+ console.log(`human: ${h.id}: ${h.reason}`);
2327
+ return;
2328
+ }
2329
+ const report = detectUpgrade(ctx);
2330
+ if (opts.json) {
2331
+ console.log(buildUpgradeJson(file, report));
2332
+ return;
2333
+ }
2334
+ console.log(`em upgrade: from ${report.from.version}${report.from.inferred ? ` (inferred: ${report.from.basis})` : ""} → to ${report.to}`);
2335
+ if (report.stateFileError)
2336
+ console.log(`state file: ${report.stateFileError}`);
2337
+ console.log("Steps:");
2338
+ for (const s of report.steps) {
2339
+ console.log(` [${s.applicable ? "x" : " "}] ${s.id} (since ${s.sinceVersion}): ${s.reason}`);
2340
+ }
2341
+ console.log("Human:");
2342
+ if (report.human.length === 0)
2343
+ console.log(" (none)");
2344
+ for (const h of report.human)
2345
+ console.log(` ${h.id}: ${h.reason}`);
2346
+ const applicableCount = report.steps.filter((s) => s.applicable).length;
2347
+ console.log(`${applicableCount} step(s) applicable, ${report.human.length} human item(s) — re-run with --apply to apply mechanical steps`);
2348
+ });
1799
2349
  // Exported so dev tooling (e.g. scripts/generate-skill-docs.ts) can introspect the registered
1800
2350
  // commands/options without triggering a real CLI run. Guarded the same way Node's CJS
1801
2351
  // `require.main === module` used to: only actually parse argv when this file is the process's
@@ -1838,15 +2388,17 @@ function compileFile(file, opts = {},
1838
2388
  }
1839
2389
  }
1840
2390
  /** 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) {
2391
+ * (MIL-126), doc↔model consistency (MIL-124), orphaned slice docs (MIL-183), and model-version
2392
+ * staleness (MIL-218) are `em validate`'s fs-aware rules — every other diagnostic in
2393
+ * `diagnostics` (from `compileFile`) is a pure function of the .em source. All six read
2394
+ * `slices/*.md`/`model-versions/*.json` alongside the model. Doc↔model consistency,
2395
+ * orphaned-slice-doc, and model-version-stale are deliberately validate-only (unlike
2396
+ * note-binding, which `em render` also folds in) — all three are conform-phase concerns, not
2397
+ * something every render needs to recheck. Shared by `em validate` and `em state log-usage`
2398
+ * (MIL-161) — the latter needs the exact same diagnostic set `em validate --json` would
2399
+ * report, since its whole job is logging which categories fired. `source` is the `.em` file's
2400
+ * own text — only `validateModelVersionStale` reads it (the modelHash comparison). */
2401
+ function computeAllDiagnostics(file, model, refs, diagnostics, source) {
1850
2402
  return [
1851
2403
  ...diagnostics,
1852
2404
  ...validateLineage(model, refs, dirname(file)),
@@ -1854,6 +2406,7 @@ function computeAllDiagnostics(file, model, refs, diagnostics) {
1854
2406
  ...validateNoteBindings(model, refs, dirname(file)),
1855
2407
  ...validateDocModelConsistency(model, refs, dirname(file)),
1856
2408
  ...validateOrphanedSliceDocs(model, refs, dirname(file)),
2409
+ ...validateModelVersionStale(model, refs, dirname(file), source),
1857
2410
  ];
1858
2411
  }
1859
2412
  /** Read a file's text, or exit with a clear error — shared by `em diff` (both forms) and
@@ -1953,16 +2506,22 @@ function printDivergences(model) {
1953
2506
  console.log(` divergence :${el.line} slice "${slice.name}" ${el.kind} "${el.name}": ${el.divergence}`);
1954
2507
  }
1955
2508
  }
1956
- /** Print only elements marked `public`: slice, kind, name, line — for an integration-surface audit. */
2509
+ /** Print only elements marked `public`: slice, kind, name, line — for an integration-surface
2510
+ * audit. A `public` view also gets a "(no in-model reader)" note (MIL-215) when no `ui`/
2511
+ * reaction anywhere on its timeline reads it — an audit hint, not a warning: a genuinely
2512
+ * cross-model `public` view's reader legitimately lives outside this file (see
2513
+ * `publicViewsWithoutInModelReader`'s own header for why this stays advisory). */
1957
2514
  function printPublicElements(model) {
1958
2515
  const pub = model.elements.filter((el) => el.public && (el.kind === "event" || el.kind === "view"));
1959
2516
  if (pub.length === 0) {
1960
2517
  console.log("no public elements");
1961
2518
  return;
1962
2519
  }
2520
+ const noReader = publicViewsWithoutInModelReader(model);
1963
2521
  for (const el of pub) {
1964
2522
  const slice = model.slices[el.sliceIndex];
1965
- console.log(` public :${el.line} slice "${slice.name}" ${el.kind} "${el.name}"`);
2523
+ const note = el.kind === "view" && noReader.has(el.logicalId) ? " (no in-model reader)" : "";
2524
+ console.log(` public :${el.line} slice "${slice.name}" ${el.kind} "${el.name}"${note}`);
1966
2525
  }
1967
2526
  }
1968
2527
  /** Diagnostics in `all` not already present (by reference identity) in `already` — avoids