@milehimikey/em 1.8.1 → 1.9.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 (104) hide show
  1. package/.claude/skills/event-modeling/SKILL.md +58 -404
  2. package/.claude/skills/event-modeling-conform/SKILL.md +77 -0
  3. package/.claude/skills/{event-modeling → event-modeling-conform}/reference/conform.md +39 -13
  4. package/.claude/skills/event-modeling-design/SKILL.md +139 -0
  5. package/.claude/skills/event-modeling-discover/SKILL.md +89 -0
  6. package/.claude/skills/{event-modeling → event-modeling-discover}/reference/extract.md +3 -2
  7. package/.claude/skills/event-modeling-implement/SKILL.md +40 -0
  8. package/.claude/skills/{event-modeling → event-modeling-implement}/reference/implement.md +3 -2
  9. package/.claude/skills/event-modeling-review/SKILL.md +58 -0
  10. package/.claude/skills/{event-modeling → event-modeling-shared}/reference/em-dsl.md +115 -83
  11. package/.claude/skills/{event-modeling → event-modeling-shared}/reference/methodology.md +6 -5
  12. package/.claude/skills/event-modeling-shared/reference/operating-principles.md +147 -0
  13. package/.claude/skills/{event-modeling → event-modeling-shared}/templates/model-readme.md +2 -2
  14. package/.claude/skills/{event-modeling → event-modeling-shared}/templates/slice.md +51 -8
  15. package/README.md +24 -4
  16. package/dist/catalog/build.js +9 -0
  17. package/dist/catalog/build.js.map +1 -1
  18. package/dist/catalog/docJoin.d.ts +15 -0
  19. package/dist/catalog/docJoin.js +8 -0
  20. package/dist/catalog/docJoin.js.map +1 -1
  21. package/dist/catalog/modelCollisionValidate.d.ts +22 -0
  22. package/dist/catalog/modelCollisionValidate.js +67 -0
  23. package/dist/catalog/modelCollisionValidate.js.map +1 -0
  24. package/dist/catalog/orphanedSliceDocValidate.d.ts +11 -0
  25. package/dist/catalog/orphanedSliceDocValidate.js +98 -0
  26. package/dist/catalog/orphanedSliceDocValidate.js.map +1 -0
  27. package/dist/catalog/sliceDoc.d.ts +15 -0
  28. package/dist/catalog/sliceDoc.js +18 -0
  29. package/dist/catalog/sliceDoc.js.map +1 -1
  30. package/dist/cli/changelogBuild.d.ts +12 -0
  31. package/dist/cli/changelogBuild.js +89 -0
  32. package/dist/cli/changelogBuild.js.map +1 -0
  33. package/dist/cli/ciInit.d.ts +42 -0
  34. package/dist/cli/ciInit.js +298 -0
  35. package/dist/cli/ciInit.js.map +1 -0
  36. package/dist/cli/conformSupersede.d.ts +32 -0
  37. package/dist/cli/conformSupersede.js +102 -0
  38. package/dist/cli/conformSupersede.js.map +1 -0
  39. package/dist/cli/coverage.d.ts +6 -1
  40. package/dist/cli/coverage.js +28 -3
  41. package/dist/cli/coverage.js.map +1 -1
  42. package/dist/cli/frontmatterSurgery.d.ts +28 -0
  43. package/dist/cli/frontmatterSurgery.js +66 -0
  44. package/dist/cli/frontmatterSurgery.js.map +1 -0
  45. package/dist/cli/markImplemented.js +6 -45
  46. package/dist/cli/markImplemented.js.map +1 -1
  47. package/dist/cli/ratify.d.ts +41 -0
  48. package/dist/cli/ratify.js +151 -0
  49. package/dist/cli/ratify.js.map +1 -0
  50. package/dist/cli/reratify.d.ts +33 -0
  51. package/dist/cli/reratify.js +137 -0
  52. package/dist/cli/reratify.js.map +1 -0
  53. package/dist/cli/skillCheck.d.ts +6 -0
  54. package/dist/cli/skillCheck.js +46 -0
  55. package/dist/cli/skillCheck.js.map +1 -1
  56. package/dist/cli/skillDirs.d.ts +15 -0
  57. package/dist/cli/skillDirs.js +34 -0
  58. package/dist/cli/skillDirs.js.map +1 -0
  59. package/dist/cli/skillSync.d.ts +10 -0
  60. package/dist/cli/skillSync.js +15 -0
  61. package/dist/cli/skillSync.js.map +1 -1
  62. package/dist/cli/sliceIndex.d.ts +3 -0
  63. package/dist/cli/sliceIndex.js +6 -2
  64. package/dist/cli/sliceIndex.js.map +1 -1
  65. package/dist/cli/sliceLink.d.ts +52 -0
  66. package/dist/cli/sliceLink.js +152 -0
  67. package/dist/cli/sliceLink.js.map +1 -0
  68. package/dist/cli/status.d.ts +261 -0
  69. package/dist/cli/status.js +536 -0
  70. package/dist/cli/status.js.map +1 -0
  71. package/dist/cli/usageLog.d.ts +75 -0
  72. package/dist/cli/usageLog.js +193 -0
  73. package/dist/cli/usageLog.js.map +1 -0
  74. package/dist/cli.js +501 -130
  75. package/dist/cli.js.map +1 -1
  76. package/dist/emit/freshnessJson.d.ts +6 -0
  77. package/dist/emit/freshnessJson.js +24 -0
  78. package/dist/emit/freshnessJson.js.map +1 -0
  79. package/dist/emit/json.d.ts +62 -8
  80. package/dist/emit/json.js +14 -3
  81. package/dist/emit/json.js.map +1 -1
  82. package/dist/emit/statusJson.d.ts +7 -0
  83. package/dist/emit/statusJson.js +39 -0
  84. package/dist/emit/statusJson.js.map +1 -0
  85. package/dist/emit/typespec.d.ts +26 -0
  86. package/dist/emit/typespec.js +356 -0
  87. package/dist/emit/typespec.js.map +1 -0
  88. package/dist/emit/usageReportJson.d.ts +3 -0
  89. package/dist/emit/usageReportJson.js +23 -0
  90. package/dist/emit/usageReportJson.js.map +1 -0
  91. package/dist/mcp/server.d.ts +1 -1
  92. package/dist/mcp/server.js +301 -11
  93. package/dist/mcp/server.js.map +1 -1
  94. package/dist/model/rules.d.ts +13 -0
  95. package/dist/model/rules.js +20 -0
  96. package/dist/model/rules.js.map +1 -1
  97. package/dist/templates.d.ts +1 -1
  98. package/dist/templates.js +2 -2
  99. package/dist/util/markers.d.ts +16 -4
  100. package/dist/util/markers.js +23 -10
  101. package/dist/util/markers.js.map +1 -1
  102. package/package.json +2 -1
  103. /package/.claude/skills/{event-modeling → event-modeling-shared}/templates/conformance-report.md +0 -0
  104. /package/.claude/skills/{event-modeling → event-modeling-shared}/templates/state.md +0 -0
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { serializeBuilds, watchFile } from "./render/watch.js";
15
15
  import { startLiveServer } from "./render/serve.js";
16
16
  import { formatDiagnostic, hasErrors } from "./model/validate.js";
17
17
  import { buildExport, buildSliceExport } from "./emit/json.js";
18
+ import { buildTypeSpec } from "./emit/typespec.js";
18
19
  import { buildValidateJson, buildSliceReadyJson, buildValidateListJson, collectMarkers } from "./emit/validateJson.js";
19
20
  import { buildDiffJson } from "./emit/diffJson.js";
20
21
  import { diffModels, formatModelDiff, hasChanges } from "./model/diff.js";
@@ -24,16 +25,22 @@ import { validateLineage } from "./catalog/lineageValidate.js";
24
25
  import { validateFrontmatterCoherence } from "./catalog/frontmatterCoherenceValidate.js";
25
26
  import { validateNoteBindings } from "./catalog/noteBindingValidate.js";
26
27
  import { validateDocModelConsistency } from "./catalog/docModelConsistencyValidate.js";
28
+ import { validateOrphanedSliceDocs } from "./catalog/orphanedSliceDocValidate.js";
27
29
  import { validateSliceReady, computeSliceReadyGates } from "./catalog/sliceReadyValidate.js";
30
+ import { detectSliceDocCollisions } from "./catalog/modelCollisionValidate.js";
28
31
  import { checkLedger } from "./cli/ledgerCheck.js";
29
32
  import { planMigration, verifyMigration } from "./cli/migrateReactionShape.js";
30
33
  import { buildLedgerJson } from "./emit/ledgerJson.js";
31
34
  import { buildCoverageReport } from "./cli/coverage.js";
32
35
  import { buildCoverageJson } from "./emit/coverageJson.js";
33
- import { planSkillSync, applySkillSync } from "./cli/skillSync.js";
34
- import { checkSkillSync } from "./cli/skillCheck.js";
36
+ import { resolveSliceStatusFacts, countOpenIssues, resolveConformanceEntry, aggregateInvariantTotals, buildStatusReport, formatStatusText, formatStatusMarkdown, buildStatusBadge, formatConformancePart, } from "./cli/status.js";
37
+ import { buildStatusJson } from "./emit/statusJson.js";
38
+ import { buildFreshnessJson } from "./emit/freshnessJson.js";
39
+ import { planSkillSyncBundle, applySkillSyncBundle } from "./cli/skillSync.js";
40
+ import { checkSkillSyncBundle } from "./cli/skillCheck.js";
35
41
  import { buildSkillCheckJson } from "./emit/skillCheckJson.js";
36
42
  import { readContract } from "./cli/contract.js";
43
+ import { EM_SKILL_DIR_NAMES, EM_SHARED_DIR_NAMES, EM_ALL_SKILL_BUNDLE_DIRS, EM_SKILL_ANCHOR_DIR } from "./cli/skillDirs.js";
37
44
  import { createServer as createMcpServer } from "./mcp/server.js";
38
45
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
39
46
  import { syncAgentsMd } from "./cli/agentsMd.js";
@@ -44,13 +51,21 @@ import { buildCatalog } from "./catalog/build.js";
44
51
  import { planCatalogArgs } from "./cli/catalog-inputs.js";
45
52
  import { runSliceIndex } from "./cli/sliceIndex.js";
46
53
  import { runMarkImplemented } from "./cli/markImplemented.js";
54
+ import { runRatify } from "./cli/ratify.js";
55
+ import { runConformSupersede } from "./cli/conformSupersede.js";
47
56
  import { buildConformScope, changedPathsSince, resolveSliceDocFacts, seedAsisModel } from "./cli/conformScope.js";
48
57
  import { buildSliceDocContent, isSlicePattern, sliceDocKey, SLICE_PATTERNS } from "./cli/sliceNew.js";
49
- import { listModelCommits, readFileAtCommit } from "./cli/changelog-git.js";
50
- import { buildChangelog, parseDecisionsLog } from "./emit/changelog.js";
58
+ import { wireSliceNote } from "./cli/sliceLink.js";
59
+ import { listModelCommits } from "./cli/changelog-git.js";
60
+ import { buildChangelogDoc } from "./cli/changelogBuild.js";
61
+ import { runReratify } from "./cli/reratify.js";
51
62
  import { STATE_FILE_NAME, PHASES, isPhase, loadStateFile, parseState, setPhase, setConformance, setReview, isValidDateString, } from "./cli/stateFile.js";
52
63
  import { STARTER_EM, starterEmFor, scaffoldReadme, scaffoldStateFile } from "./templates.js";
53
64
  import { kebabSlug } from "./util/slug.js";
65
+ import { ciWorkflowPath, conformWorkflowPath, buildCiWorkflowFile, buildConformWorkflowFile, ciManagedBody, conformManagedBody, planCiFile, applyCiFile, findUnsafeCiInitArg, CI_WORKFLOW_MARKER, CONFORM_WORKFLOW_MARKER, } from "./cli/ciInit.js";
66
+ import { RULES } from "./model/rules.js";
67
+ import { USAGE_PHASES, isUsagePhase, sortUsagePhases, appendUsageLogEntry, findStateFiles, aggregateUsageReport, formatUsageReportText, } from "./cli/usageLog.js";
68
+ import { buildUsageReportJson } from "./emit/usageReportJson.js";
54
69
  const program = new Command();
55
70
  // Single source of truth for the version (also read by src/emit/json.ts for
56
71
  // `generator.version` in `em export`): package.json, one level up from both
@@ -76,25 +91,30 @@ program
76
91
  program
77
92
  .command("scaffold")
78
93
  .description("scaffold a full project: <slug>/<slug>.em, README.md, .event-modeling.md " +
79
- "(see docs/cli.md — for just a starter .em, use `em init`)")
94
+ "(see docs/cli.md — for just a starter .em, use `em init`; for a multi-model project, " +
95
+ "pass --under to nest it under a shared parent directory)")
80
96
  .argument("<name>", "model display name — kebab-cased for the directory and file names, used as-is for titles/prose")
81
97
  .option("-f, --force", "overwrite the directory's contents if it already exists")
98
+ .option("--under <dir>", "parent directory to scaffold into — writes <dir>/<slug>/ instead of ./<slug>/, the " +
99
+ "supported multi-model layout (docs/cli.md, \"Multi-model projects\"): one directory per " +
100
+ "model, so each model's slices/ never collides with a sibling model's")
82
101
  .action(async (name, opts) => {
83
102
  if (name.includes('"') || name.includes("{{")) {
84
103
  console.error(`em scaffold: name must not contain '"' or '{{' (breaks the generated .em/README/state files): ${name}`);
85
104
  process.exit(1);
86
105
  }
87
- const dirName = kebabSlug(name);
88
- if (existsSync(dirName) && !opts.force) {
89
- console.error(`refusing to overwrite ${dirName}/ (use --force)`);
106
+ const slugName = kebabSlug(name);
107
+ const dirPath = opts.under ? join(opts.under, slugName) : slugName;
108
+ if (existsSync(dirPath) && !opts.force) {
109
+ console.error(`refusing to overwrite ${dirPath}/ (use --force)`);
90
110
  process.exit(1);
91
111
  }
92
- await mkdir(dirName, { recursive: true });
112
+ await mkdir(dirPath, { recursive: true });
93
113
  const today = new Date().toISOString().slice(0, 10);
94
- writeFileSync(join(dirName, `${dirName}.em`), starterEmFor(name));
95
- writeFileSync(join(dirName, "README.md"), scaffoldReadme(name, dirName));
96
- writeFileSync(join(dirName, STATE_FILE_NAME), scaffoldStateFile(name, dirName, today));
97
- console.log(`scaffolded ${dirName}/`);
114
+ writeFileSync(join(dirPath, `${slugName}.em`), starterEmFor(name));
115
+ writeFileSync(join(dirPath, "README.md"), scaffoldReadme(name, slugName));
116
+ writeFileSync(join(dirPath, STATE_FILE_NAME), scaffoldStateFile(name, slugName, today));
117
+ console.log(`scaffolded ${dirPath}/`);
98
118
  });
99
119
  program
100
120
  .command("render")
@@ -211,6 +231,31 @@ program
211
231
  process.stdout.write(exported.text + "\n");
212
232
  }
213
233
  });
234
+ program
235
+ .command("typespec")
236
+ .description("EXPERIMENTAL/POC (MIL-159): generate a TypeSpec contract for a model's commands, public " +
237
+ "events, and public views (see docs/cli.md)")
238
+ .argument("<file>", "input .em file")
239
+ .option("-o, --out <path>", "write to a file instead of stdout")
240
+ .action((file, opts) => {
241
+ const { model, refs, diagnostics, source } = compileFile(file);
242
+ printDiagnostics(diagnostics);
243
+ if (hasErrors(diagnostics)) {
244
+ console.error("not generating: fix the errors above");
245
+ process.exit(1);
246
+ }
247
+ const generated = buildTypeSpec(model, refs, diagnostics, source, file);
248
+ printDiagnostics(newDiagnostics(generated.diagnostics, diagnostics));
249
+ for (const note of generated.unmappedTypes)
250
+ console.error(`note: ${note}`);
251
+ if (opts.out) {
252
+ writeFileSync(opts.out, generated.text + "\n");
253
+ console.log(`wrote ${opts.out}`);
254
+ }
255
+ else {
256
+ process.stdout.write(generated.text + "\n");
257
+ }
258
+ });
214
259
  program
215
260
  .command("diff")
216
261
  .description("compare two models structurally (two files, or one file across git revisions)")
@@ -366,6 +411,8 @@ slice
366
411
  .requiredOption("--pattern <pattern>", `slice pattern: ${SLICE_PATTERNS.join(" | ")}`)
367
412
  .requiredOption("--swimlane <swimlane>", 'swimlane, e.g. "Persona → Context"')
368
413
  .option("-f, --force", "overwrite the file if it already exists")
414
+ .option("--wire <model-file>", "also insert the `note \"slices/<key>.md\"` line onto the slice's primary element in this " +
415
+ ".em file (matched by export key), instead of just printing it to paste by hand (MIL-161)")
369
416
  .action((name, opts) => {
370
417
  if (!isSlicePattern(opts.pattern)) {
371
418
  console.error(`em slice new: invalid --pattern "${opts.pattern}" — expected one of: ${SLICE_PATTERNS.join(", ")}`);
@@ -378,11 +425,35 @@ slice
378
425
  console.error(`refusing to overwrite ${path} (use --force)`);
379
426
  process.exit(1);
380
427
  }
428
+ // Resolve (never write anything yet) BEFORE touching the doc file, so a --wire failure
429
+ // (no matching slice, ambiguous primary element, already wired) leaves nothing written at
430
+ // all rather than a doc with no wiring and a non-zero exit.
431
+ let wired = null;
432
+ if (opts.wire) {
433
+ const { model, refs, diagnostics, source } = compileFile(opts.wire);
434
+ printDiagnostics(diagnostics);
435
+ if (hasErrors(diagnostics)) {
436
+ console.error(`em slice new --wire: not wiring — fix the errors in ${opts.wire} above`);
437
+ process.exit(1);
438
+ }
439
+ const result = wireSliceNote(source, model, refs, key, opts.pattern, path);
440
+ if (!result.ok) {
441
+ console.error(`em slice new --wire: ${result.message}`);
442
+ process.exit(1);
443
+ }
444
+ wired = result;
445
+ }
381
446
  mkdirSync(dir, { recursive: true });
382
447
  writeFileSync(path, buildSliceDocContent(name, key, opts.pattern, opts.swimlane));
383
448
  console.log(`wrote ${path}`);
384
- console.log(`add this to the slice's primary element in the .em file:`);
385
- console.log(` note "${path}"`);
449
+ if (wired) {
450
+ writeFileSync(opts.wire, wired.content);
451
+ console.log(`wired ${path} onto ${wired.elementName} in slice "${wired.sliceName}" (${opts.wire})`);
452
+ }
453
+ else {
454
+ console.log(`add this to the slice's primary element in the .em file:`);
455
+ console.log(` note "${path}"`);
456
+ }
386
457
  });
387
458
  slice
388
459
  .command("index")
@@ -441,6 +512,68 @@ slice
441
512
  ? `marked implemented: ${result.path} (implementedIn: ${prUrl})`
442
513
  : `already implemented (no-op): ${result.path}`);
443
514
  });
515
+ slice
516
+ .command("ratify")
517
+ .description("flip a slice doc's frontmatter to `status: ready-to-implement` and record `ratifiedBy:`/" +
518
+ "`ratifiedOn:` — the handoff sign-off (MIL-165, docs/process.md#what-ratified-means) that " +
519
+ "makes who ratified, and when, a first-class recorded fact. Idempotent on the same " +
520
+ "--by/--on pair; refuses to overwrite a different one already recorded; never touches " +
521
+ "`version:` or the doc body")
522
+ .argument("<file>", "input .em file")
523
+ .argument("<slice-key>", "slice export key (kebab-case)")
524
+ .requiredOption("--by <name>", "the ratifier's name")
525
+ .option("--on <date>", "ratification date, YYYY-MM-DD (default: today)")
526
+ .action((file, sliceKey, opts) => {
527
+ const { model, refs, diagnostics } = compileFile(file);
528
+ printDiagnostics(diagnostics);
529
+ // Scoped the same way `em slice mark-implemented`/`em export --slice`/`em validate
530
+ // --slice-ready` are: only an error concerning THIS slice refuses.
531
+ const scopedErrors = diagnostics.filter((d) => d.severity === "error" && d.refs?.some((r) => r === sliceKey || r.startsWith(`${sliceKey}/`)));
532
+ if (scopedErrors.length > 0) {
533
+ console.error(`em slice ratify: slice "${sliceKey}" has errors — fix them first`);
534
+ process.exit(1);
535
+ }
536
+ const today = new Date().toISOString().slice(0, 10);
537
+ const ratifiedOn = opts.on ?? today;
538
+ if (opts.on !== undefined && !isValidDateString(opts.on)) {
539
+ console.error(`em slice ratify: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
540
+ process.exit(1);
541
+ }
542
+ const result = runRatify(model, refs, dirname(file), sliceKey, opts.by, ratifiedOn);
543
+ if (!result.ok) {
544
+ console.error(`em slice ratify: ${result.message}`);
545
+ process.exit(1);
546
+ }
547
+ console.log(result.changed
548
+ ? `ratified: ${result.path} (ratifiedBy: ${opts.by}, ratifiedOn: ${ratifiedOn})`
549
+ : `already ratified (no-op): ${result.path}`);
550
+ });
551
+ slice
552
+ .command("reratify")
553
+ .description("bump `version:` and flip a shipped slice doc's frontmatter back to `status: " +
554
+ "ready-to-implement` — the re-ratification mechanical edit (MIL-161, mirrors `em slice " +
555
+ "mark-implemented`). Only applies to a doc at `status: implemented`; clears any stale " +
556
+ "`ratifiedBy:`/`ratifiedOn:` (they describe the PRIOR version's sign-off) so a follow-up " +
557
+ "`em slice ratify --by` applies cleanly; never touches `implementedIn:` or the doc body")
558
+ .argument("<file>", "input .em file")
559
+ .argument("<slice-key>", "slice export key (kebab-case)")
560
+ .action((file, sliceKey) => {
561
+ const { model, refs, diagnostics } = compileFile(file);
562
+ printDiagnostics(diagnostics);
563
+ // Scoped the same way `em slice ratify`/`em slice mark-implemented`/`em export --slice`/
564
+ // `em validate --slice-ready` are: only an error concerning THIS slice refuses.
565
+ const scopedErrors = diagnostics.filter((d) => d.severity === "error" && d.refs?.some((r) => r === sliceKey || r.startsWith(`${sliceKey}/`)));
566
+ if (scopedErrors.length > 0) {
567
+ console.error(`em slice reratify: slice "${sliceKey}" has errors — fix them first`);
568
+ process.exit(1);
569
+ }
570
+ const result = runReratify(model, refs, dirname(file), sliceKey);
571
+ if (!result.ok) {
572
+ console.error(`em slice reratify: ${result.message}`);
573
+ process.exit(1);
574
+ }
575
+ console.log(`reratified: ${result.path} (version: ${result.newVersion}, status: ready-to-implement)`);
576
+ });
444
577
  program
445
578
  .command("changelog")
446
579
  .description("render a model's git history as a business-readable ledger (see docs/cli.md)")
@@ -535,6 +668,69 @@ state
535
668
  }
536
669
  writeStateUpdate(dir, "em state set-review", (text, today) => setReview(text, date, today));
537
670
  });
671
+ state
672
+ .command("log-usage")
673
+ .description("append one Usage log line — phase(s) touched + em validate's diagnostic categories hit, " +
674
+ "deduped and canonically formatted (MIL-161, docs/usage-data.md) — the mechanical half of " +
675
+ "'save state at the end of every session' that used to be run-validate-then-hand-format; " +
676
+ "state file resolved next to <file>, same convention as em conform-scope")
677
+ .argument("<file>", "input .em file")
678
+ .requiredOption("--phases <list>", `comma-separated phase(s) touched this session: ${USAGE_PHASES.join(", ")}`)
679
+ .action((file, opts) => {
680
+ const rawPhases = opts.phases
681
+ .split(",")
682
+ .map((s) => s.trim())
683
+ .filter((s) => s.length > 0);
684
+ if (rawPhases.length === 0) {
685
+ console.error("em state log-usage: --phases must name at least one phase");
686
+ process.exit(1);
687
+ }
688
+ const invalid = rawPhases.filter((p) => !isUsagePhase(p));
689
+ if (invalid.length > 0) {
690
+ console.error(`em state log-usage: invalid phase(s) ${invalid.map((p) => `"${p}"`).join(", ")} — expected one of: ${USAGE_PHASES.join(", ")}`);
691
+ process.exit(1);
692
+ }
693
+ const phases = sortUsagePhases(rawPhases);
694
+ const { model, diagnostics, refs } = compileFile(file);
695
+ const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics);
696
+ const categorySet = new Set(allDiagnostics.map((d) => RULES[d.code].usageCategory));
697
+ const categories = categorySet.size > 0 ? [...categorySet].sort() : ["none"];
698
+ const loaded = loadStateFile(dirname(file));
699
+ if (!loaded.ok) {
700
+ console.error(loaded.message);
701
+ process.exit(1);
702
+ }
703
+ const today = new Date().toISOString().slice(0, 10);
704
+ const result = appendUsageLogEntry(loaded.text, today, phases, categories);
705
+ if (!result.ok) {
706
+ console.error(`em state log-usage: ${result.message}`);
707
+ process.exit(1);
708
+ }
709
+ writeFileSync(loaded.path, result.text);
710
+ console.log(`wrote ${loaded.path} — phases: ${phases.join(", ")} — validate: ${categories.join(", ")}`);
711
+ });
712
+ program
713
+ .command("usage-report")
714
+ .description("aggregate every .event-modeling.md's Usage log under [root] into phase/diagnostic-category " +
715
+ "tallies (MIL-161) — replaces docs/usage-data.md's hand-rolled grep/awk/sort pipeline; a " +
716
+ "logged line that doesn't match the canonical em state log-usage format is reported under " +
717
+ "unparseableLines rather than silently mistallied or dropped")
718
+ .argument("[root]", "directory to search recursively for .event-modeling.md files", ".")
719
+ .option("--json", "print a JSON document instead of the text report")
720
+ .action((root, opts) => {
721
+ const files = findStateFiles(root);
722
+ const entries = files.map((relPath) => ({
723
+ file: relPath,
724
+ text: readFileSync(join(root, relPath), "utf8"),
725
+ }));
726
+ const report = aggregateUsageReport(root, entries);
727
+ if (opts.json) {
728
+ process.stdout.write(buildUsageReportJson(report) + "\n");
729
+ }
730
+ else {
731
+ console.log(formatUsageReportText(report));
732
+ }
733
+ });
538
734
  program
539
735
  .command("conform-scope")
540
736
  .description("mechanize conform phase step 1 (reference/conform.md): map the target repo's changed paths " +
@@ -581,6 +777,35 @@ program
581
777
  }
582
778
  console.log(JSON.stringify(output, null, 2));
583
779
  });
780
+ program
781
+ .command("conform-supersede")
782
+ .description("stamp a conformance report with a \"superseded as of <rev>\" banner once its findings have " +
783
+ "been ruled on (MIL-164, docs/process.md#what-ratified-means) — the companion step to run " +
784
+ "at ratification time so a reader following the report's file:line citations knows they " +
785
+ "describe an ancestor of the current model. Additive-only splice, never a rewrite of the " +
786
+ "report; idempotent on the same --as-of/--findings/--on stamp; refuses if the report " +
787
+ "doesn't exist")
788
+ .argument("<file>", "input .em file (used only to resolve the report path's base directory)")
789
+ .argument("<report-path>", "path to the conformance report, relative to the model's directory")
790
+ .requiredOption("--as-of <rev>", "the revision this ruling was made against — same value passed to `em state set-conformance`")
791
+ .requiredOption("--findings <spec>", 'which finding number(s) this stamps as ruled, e.g. "1-3" or "1,2,4"')
792
+ .option("--on <date>", "ruling date, YYYY-MM-DD (default: today)")
793
+ .action((file, reportPath, opts) => {
794
+ const today = new Date().toISOString().slice(0, 10);
795
+ const on = opts.on ?? today;
796
+ if (opts.on !== undefined && !isValidDateString(opts.on)) {
797
+ console.error(`em conform-supersede: invalid --on date "${opts.on}" — expected YYYY-MM-DD`);
798
+ process.exit(1);
799
+ }
800
+ const result = runConformSupersede(dirname(file), reportPath, opts.asOf, opts.findings, on);
801
+ if (!result.ok) {
802
+ console.error(`em conform-supersede: ${result.message}`);
803
+ process.exit(1);
804
+ }
805
+ console.log(result.changed
806
+ ? `stamped superseded: ${result.path} (as of ${opts.asOf}, findings ${opts.findings}, on ${on})`
807
+ : `already stamped (no-op): ${result.path}`);
808
+ });
584
809
  program
585
810
  .command("watch")
586
811
  .description("re-render on every save")
@@ -670,19 +895,7 @@ program
670
895
  "(MIL-128, see docs/cli.md); exit codes are unchanged")
671
896
  .action((file, opts) => {
672
897
  const { model, diagnostics, refs } = compileFile(file);
673
- // Lineage-ref resolution (MIL-84), frontmatter-coherence (MIL-85), note-binding
674
- // mismatches (MIL-126), and doc↔model consistency (MIL-124) are validate's fs-aware
675
- // rules — every other check above is a pure function of the .em source. All four read
676
- // slices/*.md alongside the model. Doc↔model consistency is deliberately validate-only
677
- // (unlike note-binding, which `em render` also folds in) — it's a conform-phase concern,
678
- // not something every render needs to recheck.
679
- const allDiagnostics = [
680
- ...diagnostics,
681
- ...validateLineage(model, refs, dirname(file)),
682
- ...validateFrontmatterCoherence(model, refs, dirname(file)),
683
- ...validateNoteBindings(model, refs, dirname(file)),
684
- ...validateDocModelConsistency(model, refs, dirname(file)),
685
- ];
898
+ const allDiagnostics = computeAllDiagnostics(file, model, refs, diagnostics);
686
899
  if (opts.sliceReady) {
687
900
  // MIL-87: a targeted, single-slice readiness gate, not part of the unconditional
688
901
  // diagnostic set above (see sliceReadyValidate.ts's header for why). Folds in MIL-85's
@@ -899,15 +1112,147 @@ program
899
1112
  if (opts.strict && report.uncoveredCount > 0)
900
1113
  process.exitCode = 1;
901
1114
  });
902
- // Shared by install/sync/check: the skill directory bundled with whatever em package is
1115
+ program
1116
+ .command("status")
1117
+ .description("deterministic state-of-the-system rollup over one or more .em models: slices by " +
1118
+ "lifecycle status, driftSignal breakdown, invariant coverage totals (with --tests), " +
1119
+ "open issue markers + unchecked Open Questions, and last-conformance commits-behind-HEAD " +
1120
+ "(MIL-163, see docs/cli.md)")
1121
+ .argument("<files...>", "input .em files")
1122
+ .option("--tests <dir>", "directory to scan for INV-* test citations — enables invariant coverage totals")
1123
+ .option("--repo <path>", "git repo to compute commits-behind-HEAD in (default: each model's own directory)")
1124
+ .option("--json", "print a JSON document instead of the text report (see docs/cli.md)")
1125
+ .option("--md", "print a markdown block suited for README embedding")
1126
+ .option("--badge", "print a generated SVG badge")
1127
+ .option("-o, --out <path>", "write output to a file instead of stdout")
1128
+ .action((files, opts) => {
1129
+ const modesSelected = [opts.json, opts.md, opts.badge].filter(Boolean).length;
1130
+ if (modesSelected > 1) {
1131
+ console.error("em status: --json, --md, and --badge are mutually exclusive");
1132
+ process.exit(1);
1133
+ }
1134
+ if (opts.tests) {
1135
+ if (!existsSync(opts.tests)) {
1136
+ console.error(`em status: --tests directory not found: ${opts.tests}`);
1137
+ process.exit(1);
1138
+ }
1139
+ if (!statSync(opts.tests).isDirectory()) {
1140
+ console.error(`em status: --tests is not a directory: ${opts.tests}`);
1141
+ process.exit(1);
1142
+ }
1143
+ }
1144
+ const compiled = [];
1145
+ let anyErrors = false;
1146
+ for (const file of files) {
1147
+ const { model, refs, diagnostics } = compileFile(file);
1148
+ printDiagnosticsFor(file, diagnostics);
1149
+ if (hasErrors(diagnostics))
1150
+ anyErrors = true;
1151
+ compiled.push({ file, model, refs });
1152
+ }
1153
+ if (anyErrors) {
1154
+ console.error("em status: not reporting status — fix the errors above");
1155
+ process.exit(1);
1156
+ }
1157
+ const sliceFacts = [];
1158
+ const factsByFile = new Map();
1159
+ const statusDiagnostics = [];
1160
+ // MIL-160: a multi-model run is exactly the case where two `.em` files could share a
1161
+ // directory and collide on a slice key — checked once, up front, over every compiled
1162
+ // input (never fatal, same "warn, don't block" posture as the doc-join diagnostics below).
1163
+ const collisions = detectSliceDocCollisions(compiled.map(({ file, refs }) => ({ file, sliceKeys: refs.sliceKeys })));
1164
+ for (const d of collisions) {
1165
+ const file = d.refs?.[2] ?? files[0];
1166
+ printDiagnosticsFor(file, [d]);
1167
+ statusDiagnostics.push({ file, ...d });
1168
+ }
1169
+ let openIssuesCount = 0;
1170
+ const coverageReports = [];
1171
+ for (const { file, model, refs } of compiled) {
1172
+ const baseDir = dirname(file);
1173
+ const { facts, diagnostics: docDiags } = resolveSliceStatusFacts(file, model, refs, baseDir);
1174
+ printDiagnosticsFor(file, docDiags);
1175
+ sliceFacts.push(...facts);
1176
+ factsByFile.set(file, facts);
1177
+ for (const d of docDiags)
1178
+ statusDiagnostics.push({ file, ...d });
1179
+ // MIL-183: a slices/*.md file left behind by a rename/removal, matching no current
1180
+ // slice's key or `covers:` declaration — same "fold into status's diagnostics list" as
1181
+ // every other doc-join warning above.
1182
+ const orphanDiags = validateOrphanedSliceDocs(model, refs, baseDir);
1183
+ printDiagnosticsFor(file, orphanDiags);
1184
+ for (const d of orphanDiags)
1185
+ statusDiagnostics.push({ file, ...d });
1186
+ openIssuesCount += countOpenIssues(model);
1187
+ if (opts.tests)
1188
+ coverageReports.push(buildCoverageReport(model, refs, baseDir, opts.tests));
1189
+ }
1190
+ const invariants = opts.tests ? aggregateInvariantTotals(opts.tests, coverageReports) : null;
1191
+ // MIL-164: slice-PRs-behind-HEAD is scoped per model — THIS model's own facts, not the
1192
+ // merged `sliceFacts` above (which spans every input file when several are given).
1193
+ const conformance = compiled.map(({ file }) => {
1194
+ const facts = factsByFile.get(file) ?? [];
1195
+ const sliceDocFacts = facts.map((f) => ({ key: f.key, status: f.rawStatus, implementedIn: f.implementedIn }));
1196
+ return resolveConformanceEntry(file, opts.repo, sliceDocFacts);
1197
+ });
1198
+ const report = buildStatusReport(files, sliceFacts, openIssuesCount, invariants, conformance, statusDiagnostics);
1199
+ let output;
1200
+ if (opts.json)
1201
+ output = buildStatusJson(report);
1202
+ else if (opts.md)
1203
+ output = formatStatusMarkdown(report);
1204
+ else if (opts.badge)
1205
+ output = buildStatusBadge(report);
1206
+ else
1207
+ output = formatStatusText(report);
1208
+ if (opts.out) {
1209
+ writeFileSync(opts.out, output + "\n");
1210
+ console.log(`wrote ${opts.out}`);
1211
+ }
1212
+ else {
1213
+ process.stdout.write(output + "\n");
1214
+ }
1215
+ });
1216
+ program
1217
+ .command("freshness")
1218
+ .description("standalone freshness signal for one model's conformance record (MIL-164): \"last conformed " +
1219
+ "<rev> — N commits and M slice-PRs behind HEAD\", computed from the same conform-scope " +
1220
+ "machinery `em status`'s conformance clause uses — for when you want just this fact, no " +
1221
+ "full state-of-the-system rollup (see docs/cli.md)")
1222
+ .argument("<file>", "input .em file")
1223
+ .option("--repo <path>", "git repo to compute behind-HEAD in (default: the model's own directory)")
1224
+ .option("--json", "print a JSON document instead of the text line")
1225
+ .action((file, opts) => {
1226
+ const { model, refs, diagnostics } = compileFile(file);
1227
+ printDiagnostics(diagnostics);
1228
+ if (hasErrors(diagnostics)) {
1229
+ console.error("em freshness: not reporting freshness — fix the errors above");
1230
+ process.exit(1);
1231
+ }
1232
+ const baseDir = dirname(file);
1233
+ const { facts, diagnostics: docDiags } = resolveSliceDocFacts(model, refs, baseDir);
1234
+ printDiagnostics(docDiags);
1235
+ const entry = resolveConformanceEntry(file, opts.repo, facts);
1236
+ if (opts.json) {
1237
+ process.stdout.write(buildFreshnessJson(entry) + "\n");
1238
+ }
1239
+ else {
1240
+ console.log(formatConformancePart(entry));
1241
+ }
1242
+ });
1243
+ // Shared by install/sync/check: the `.claude/skills/` root bundled with whatever em package is
903
1244
  // actually running (works whether em was installed from npm or run from a checkout, and
904
1245
  // regardless of a symlinked global install — see pkgDir's own resolution above for
905
- // PKG_VERSION) and the skill directory a consumer repo vendors it into.
906
- function packagedSkillDir() {
907
- return join(dirname(fileURLToPath(import.meta.url)), "..", ".claude", "skills", "event-modeling");
1246
+ // PKG_VERSION) and the `.claude/skills/` root a consumer repo vendors it into. The em skill
1247
+ // itself lives under several named directories inside this root (MIL-157, src/cli/skillDirs.ts)
1248
+ // rather than being this whole root's only occupant — install/sync/check only ever touch those
1249
+ // named directories, never the root wholesale, so an unrelated skill already vendored alongside
1250
+ // em's is never at risk.
1251
+ function packagedSkillsRoot() {
1252
+ return join(dirname(fileURLToPath(import.meta.url)), "..", ".claude", "skills");
908
1253
  }
909
- function vendoredSkillDir(repoRoot) {
910
- return join(resolve(repoRoot), ".claude", "skills", "event-modeling");
1254
+ function vendoredSkillsRoot(repoRoot) {
1255
+ return join(resolve(repoRoot), ".claude", "skills");
911
1256
  }
912
1257
  program
913
1258
  .command("contract")
@@ -915,7 +1260,7 @@ program
915
1260
  "agent-neutral discovery path for any agent that can run a shell, not just Claude Code " +
916
1261
  "(MIL-129); see docs/cli.md")
917
1262
  .action(() => {
918
- process.stdout.write(readContract(packagedSkillDir()));
1263
+ process.stdout.write(readContract(join(packagedSkillsRoot(), "event-modeling-implement")));
919
1264
  });
920
1265
  program
921
1266
  .command("mcp")
@@ -943,20 +1288,23 @@ const skill = program
943
1288
  .description("manage Claude Code skills bundled with em");
944
1289
  skill
945
1290
  .command("install")
946
- .description("copy the event-modeling skill into .claude/skills/event-modeling/")
1291
+ .description("copy the event-modeling skill bundle into .claude/skills/ (event-modeling, event-modeling-discover/-design/-implement/-conform/-review, event-modeling-shared)")
947
1292
  .option("-f, --force", "overwrite an existing installation")
948
1293
  .option("--no-agents-md", "skip writing/updating the AGENTS.md agent-contract section (on by default, MIL-129)")
949
1294
  .action(async (opts) => {
950
- const src = packagedSkillDir();
951
- const dest = vendoredSkillDir(process.cwd());
952
- if (existsSync(dest) && !opts.force) {
953
- console.log(`skill already installed at ${dest}`);
1295
+ const srcRoot = packagedSkillsRoot();
1296
+ const destRoot = vendoredSkillsRoot(process.cwd());
1297
+ const anchor = join(destRoot, EM_SKILL_ANCHOR_DIR);
1298
+ if (existsSync(anchor) && !opts.force) {
1299
+ console.log(`skill already installed at ${anchor}`);
954
1300
  console.log("re-run with --force to overwrite");
955
1301
  }
956
1302
  else {
957
- await mkdir(join(process.cwd(), ".claude", "skills"), { recursive: true });
958
- await cp(src, dest, { recursive: true });
959
- console.log(`installed event-modeling skill → ${dest}`);
1303
+ await mkdir(destRoot, { recursive: true });
1304
+ for (const name of EM_ALL_SKILL_BUNDLE_DIRS) {
1305
+ await cp(join(srcRoot, name), join(destRoot, name), { recursive: true });
1306
+ }
1307
+ console.log(`installed event-modeling skill bundle → ${destRoot}`);
960
1308
  console.log("in Claude Code, run /event-modeling to start a guided session");
961
1309
  }
962
1310
  if (opts.agentsMd !== false) {
@@ -965,22 +1313,26 @@ skill
965
1313
  });
966
1314
  skill
967
1315
  .command("sync")
968
- .description("update the vendored .claude/skills/event-modeling/ copy in [path] to match the installed " +
969
- "em package (overwrites unconditionally; local edits are never merged, MIL-93)")
1316
+ .description("update the vendored .claude/skills/ event-modeling skill bundle in [path] to match the " +
1317
+ "installed em package (overwrites unconditionally; local edits are never merged, MIL-93)")
970
1318
  .argument("[path]", "consumer repo root", ".")
971
1319
  .option("--no-agents-md", "skip writing/updating the AGENTS.md agent-contract section (on by default, MIL-129)")
972
1320
  .action(async (path, opts) => {
973
- const packagedDir = packagedSkillDir();
974
- const vendoredDir = vendoredSkillDir(path);
975
- const plan = planSkillSync(packagedDir, vendoredDir);
976
- if (plan.changes.length === 0) {
977
- console.log(`up to date — ${vendoredDir} already matches the installed skill (${plan.unchangedCount} file(s))`);
1321
+ const packagedRoot = packagedSkillsRoot();
1322
+ const vendoredRoot = vendoredSkillsRoot(path);
1323
+ const bundlePlan = planSkillSyncBundle(packagedRoot, vendoredRoot, EM_ALL_SKILL_BUNDLE_DIRS);
1324
+ const totalChanges = bundlePlan.reduce((n, { plan }) => n + plan.changes.length, 0);
1325
+ const totalUnchanged = bundlePlan.reduce((n, { plan }) => n + plan.unchangedCount, 0);
1326
+ if (totalChanges === 0) {
1327
+ console.log(`up to date — ${vendoredRoot} already matches the installed skill bundle (${totalUnchanged} file(s))`);
978
1328
  }
979
1329
  else {
980
- applySkillSync(plan, packagedDir, vendoredDir);
981
- for (const c of plan.changes)
982
- console.log(`${c.kind}: ${c.relPath}`);
983
- console.log(`synced ${vendoredDir} — ${plan.changes.length} file(s) changed, ${plan.unchangedCount} unchanged`);
1330
+ applySkillSyncBundle(bundlePlan, packagedRoot, vendoredRoot);
1331
+ for (const { dirName, plan } of bundlePlan) {
1332
+ for (const c of plan.changes)
1333
+ console.log(`${c.kind}: ${dirName}/${c.relPath}`);
1334
+ }
1335
+ console.log(`synced ${vendoredRoot} — ${totalChanges} file(s) changed, ${totalUnchanged} unchanged`);
984
1336
  }
985
1337
  if (opts.agentsMd !== false) {
986
1338
  console.log(agentsMdMessage(syncAgentsMd(resolve(path))));
@@ -988,16 +1340,16 @@ skill
988
1340
  });
989
1341
  skill
990
1342
  .command("check")
991
- .description("check the vendored .claude/skills/event-modeling/ copy in [path] for drift against the " +
992
- "installed em package; exits non-zero on any mismatch (CI-ready, MIL-93)")
1343
+ .description("check the vendored .claude/skills/ event-modeling skill bundle in [path] for drift against " +
1344
+ "the installed em package; exits non-zero on any mismatch (CI-ready, MIL-93)")
993
1345
  .argument("[path]", "consumer repo root", ".")
994
1346
  .option("--json", "print a JSON document instead of the text report (see docs/cli.md)")
995
1347
  .action((path, opts) => {
996
- const packagedDir = packagedSkillDir();
997
- const vendoredDir = vendoredSkillDir(path);
998
- const result = checkSkillSync(packagedDir, vendoredDir, PKG_VERSION);
1348
+ const packagedRoot = packagedSkillsRoot();
1349
+ const vendoredRoot = vendoredSkillsRoot(path);
1350
+ const result = checkSkillSyncBundle(packagedRoot, vendoredRoot, PKG_VERSION, EM_SKILL_DIR_NAMES, EM_SHARED_DIR_NAMES);
999
1351
  if (opts.json) {
1000
- process.stdout.write(buildSkillCheckJson(result, vendoredDir, PKG_VERSION) + "\n");
1352
+ process.stdout.write(buildSkillCheckJson(result, vendoredRoot, PKG_VERSION) + "\n");
1001
1353
  }
1002
1354
  else {
1003
1355
  for (const f of result.findings)
@@ -1009,6 +1361,76 @@ skill
1009
1361
  if (!result.ok)
1010
1362
  process.exitCode = 1;
1011
1363
  });
1364
+ const ci = program.command("ci").description("scaffold/check the CI enforcement preset (MIL-166, see docs/ci.md)");
1365
+ ci.command("init")
1366
+ .description("install .github/workflows/em-ci.yml (PR gates: em validate, em slice index --check, em " +
1367
+ "coverage --strict, em ledger, em skill check, em glossary --fail-on-conflicts, plus a " +
1368
+ "push-triggered status-badge rebuild) and em-conform.yml (scheduled, advisory-only " +
1369
+ "conformance cadence) — same install discipline as `em skill install`: marker-delimited, " +
1370
+ "idempotent, --check for CI self-verification (MIL-166, see docs/ci.md)")
1371
+ .argument("<model>", "anchor .em file the coverage/ledger/slice-index/status steps point at")
1372
+ .option("--tests <dir>", "test directory the coverage/status steps scan for INV-* citations", "test")
1373
+ .option("-f, --force", "replace an existing workflow file that has no GENERATED markers")
1374
+ .option("--check", "verify both files match the current preset; exit non-zero on drift without writing (CI)")
1375
+ .action((model, opts) => {
1376
+ const unsafe = findUnsafeCiInitArg(model) ?? findUnsafeCiInitArg(opts.tests);
1377
+ if (unsafe) {
1378
+ console.error(`em ci init: argument must not contain '"', '\`', '$', or a newline (breaks the ` +
1379
+ `generated workflow's shell steps): ${unsafe}`);
1380
+ process.exit(1);
1381
+ }
1382
+ const repoRoot = process.cwd();
1383
+ const ciPath = ciWorkflowPath(repoRoot);
1384
+ const conformPath = conformWorkflowPath(repoRoot);
1385
+ const files = [
1386
+ [
1387
+ ciPath,
1388
+ planCiFile(ciPath, buildCiWorkflowFile(model, opts.tests, PKG_VERSION), ciManagedBody(model, opts.tests), CI_WORKFLOW_MARKER, !!opts.force),
1389
+ ],
1390
+ [
1391
+ conformPath,
1392
+ planCiFile(conformPath, buildConformWorkflowFile(model, PKG_VERSION), conformManagedBody(model), CONFORM_WORKFLOW_MARKER, !!opts.force),
1393
+ ],
1394
+ ];
1395
+ if (opts.check) {
1396
+ let drift = false;
1397
+ for (const [path, status] of files) {
1398
+ if (status.kind === "create") {
1399
+ console.log(`missing: ${path} — run \`em ci init ${model}\` to create it`);
1400
+ drift = true;
1401
+ }
1402
+ else if (status.kind === "missing-markers") {
1403
+ console.log(`can't verify: ${path} exists without GENERATED markers (re-run with --force to replace it)`);
1404
+ drift = true;
1405
+ }
1406
+ else if (status.kind === "stale") {
1407
+ console.log(`stale: ${path} — run \`em ci init ${model}\` and commit the result`);
1408
+ drift = true;
1409
+ }
1410
+ else {
1411
+ console.log(`ok: ${path}`);
1412
+ }
1413
+ }
1414
+ console.log(drift ? "em ci init --check: drift found" : "ok — both workflow files match the current preset");
1415
+ if (drift)
1416
+ process.exitCode = 1;
1417
+ return;
1418
+ }
1419
+ mkdirSync(dirname(ciPath), { recursive: true });
1420
+ for (const [path, status] of files) {
1421
+ applyCiFile(path, status);
1422
+ if (status.kind === "create")
1423
+ console.log(`installed ${path}`);
1424
+ else if (status.kind === "stale")
1425
+ console.log(`updated ${path}`);
1426
+ else if (status.kind === "would-replace")
1427
+ console.log(`replaced ${path} (--force)`);
1428
+ else if (status.kind === "missing-markers")
1429
+ console.log(`${path} already exists — re-run with --force to overwrite`);
1430
+ else
1431
+ console.log(`${path} already up to date`);
1432
+ }
1433
+ });
1012
1434
  // Exported so dev tooling (e.g. scripts/generate-skill-docs.ts) can introspect the registered
1013
1435
  // commands/options without triggering a real CLI run. Guarded the same way Node's CJS
1014
1436
  // `require.main === module` used to: only actually parse argv when this file is the process's
@@ -1050,6 +1472,25 @@ function compileFile(file, opts = {},
1050
1472
  throw e;
1051
1473
  }
1052
1474
  }
1475
+ /** Lineage-ref resolution (MIL-84), frontmatter-coherence (MIL-85), note-binding mismatches
1476
+ * (MIL-126), doc↔model consistency (MIL-124), and orphaned slice docs (MIL-183) are `em
1477
+ * validate`'s fs-aware rules — every other diagnostic in `diagnostics` (from `compileFile`) is a
1478
+ * pure function of the .em source. All five read `slices/*.md` alongside the model. Doc↔model
1479
+ * consistency and orphaned-slice-doc are deliberately validate-only (unlike note-binding, which
1480
+ * `em render` also folds in) — both are conform-phase concerns, not something every render needs
1481
+ * to recheck. Shared by `em validate` and `em state log-usage` (MIL-161) — the latter needs the
1482
+ * exact same diagnostic set `em validate --json` would report, since its whole job is logging
1483
+ * which categories fired. */
1484
+ function computeAllDiagnostics(file, model, refs, diagnostics) {
1485
+ return [
1486
+ ...diagnostics,
1487
+ ...validateLineage(model, refs, dirname(file)),
1488
+ ...validateFrontmatterCoherence(model, refs, dirname(file)),
1489
+ ...validateNoteBindings(model, refs, dirname(file)),
1490
+ ...validateDocModelConsistency(model, refs, dirname(file)),
1491
+ ...validateOrphanedSliceDocs(model, refs, dirname(file)),
1492
+ ];
1493
+ }
1053
1494
  /** Read a file's text, or exit with a clear error — shared by `em diff` (both forms) and
1054
1495
  * `em glossary`, which each read one or more plain `.em` files off disk before compiling. */
1055
1496
  function readFileOrExit(file) {
@@ -1083,76 +1524,6 @@ function readAtRevision(file, rev) {
1083
1524
  }
1084
1525
  return result.content;
1085
1526
  }
1086
- /**
1087
- * Build the `em changelog` markdown document for an already-resolved commit
1088
- * list (oldest -> newest). Compiles every revision once; per-revision
1089
- * warnings are deliberately never printed (historical revisions can be noisy)
1090
- * — only a compile *failure* (parse error or validation error, same
1091
- * threshold `em diff` uses) surfaces, as that entry's error note, never a
1092
- * crash. Diffs are computed against the previous *parseable* revision, so a
1093
- * single bad revision in the middle of the walk doesn't break every entry
1094
- * after it. Content is read at each commit's own path (`readFileAtCommit`),
1095
- * so the walk survives renames.
1096
- */
1097
- function buildChangelogDoc(file, repoRoot, commits) {
1098
- const models = [];
1099
- const refsList = [];
1100
- const errors = [];
1101
- for (const c of commits) {
1102
- const rev = readFileAtCommit(repoRoot, c);
1103
- if (!rev.ok) {
1104
- models.push(null);
1105
- refsList.push(null);
1106
- errors.push(rev.message);
1107
- continue;
1108
- }
1109
- try {
1110
- const { model, refs, diagnostics } = compile(rev.content);
1111
- if (hasErrors(diagnostics)) {
1112
- models.push(null);
1113
- refsList.push(null);
1114
- errors.push(`validation errors at ${c.shortHash} — fix with \`em validate\` at that revision`);
1115
- }
1116
- else {
1117
- models.push(model);
1118
- refsList.push(refs);
1119
- errors.push(null);
1120
- }
1121
- }
1122
- catch (e) {
1123
- models.push(null);
1124
- refsList.push(null);
1125
- errors.push(e instanceof ParseError ? `parse error: ${e.message}` : e instanceof Error ? e.message : String(e));
1126
- }
1127
- }
1128
- const entries = [];
1129
- let prevParseable = -1;
1130
- commits.forEach((c, i) => {
1131
- const base = { shortHash: c.shortHash, date: c.date, subject: c.subject };
1132
- if (i === 0) {
1133
- entries.push({ ...base, diff: null });
1134
- }
1135
- else if (!models[i]) {
1136
- entries.push({ ...base, diff: null, error: errors[i] });
1137
- }
1138
- else if (prevParseable === -1) {
1139
- entries.push({ ...base, diff: null, error: `no earlier parseable revision to diff against (${commits[0].shortHash}: ${errors[0]})` });
1140
- }
1141
- else {
1142
- entries.push({
1143
- ...base,
1144
- diff: diffModels(models[prevParseable], models[i], refsList[prevParseable], refsList[i]),
1145
- });
1146
- }
1147
- if (models[i])
1148
- prevParseable = i;
1149
- });
1150
- const intro = models[0] ? { slices: models[0].slices.length, elements: models[0].elements.length } : null;
1151
- const introError = models[0] ? undefined : (errors[0] ?? undefined);
1152
- const stateFile = join(dirname(file), STATE_FILE_NAME);
1153
- const decisions = existsSync(stateFile) ? parseDecisionsLog(readFileSync(stateFile, "utf8")) : [];
1154
- return buildChangelog(entries, decisions, { file, intro, introError });
1155
- }
1156
1527
  /** Shared body for both `em diff` forms: compile both sides, gate on errors, print, exit-code. */
1157
1528
  function runDiff(oldSource, oldLabel, newSource, newLabel, exitCode, json, lineage) {
1158
1529
  const oldResult = compileSource(oldSource, oldLabel);