@milehimikey/em 1.13.1 → 1.14.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 (235) hide show
  1. package/.claude/agents/em-critic.md +26 -0
  2. package/.claude/agents/em-implementer.md +34 -0
  3. package/.claude/agents/em-reviewer.md +31 -0
  4. package/.claude/agents/em-validator.md +27 -0
  5. package/.claude/skills/event-modeling/SKILL.md +50 -11
  6. package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
  7. package/.claude/skills/event-modeling-design/SKILL.md +127 -18
  8. package/.claude/skills/event-modeling-discover/SKILL.md +1 -1
  9. package/.claude/skills/event-modeling-engagement/SKILL.md +192 -0
  10. package/.claude/skills/event-modeling-engagement/reference/engagement.md +143 -0
  11. package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
  12. package/.claude/skills/event-modeling-implement/reference/implement.md +38 -11
  13. package/.claude/skills/event-modeling-review/SKILL.md +2 -2
  14. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +279 -189
  15. package/.claude/skills/event-modeling-shared/reference/operating-principles.md +61 -0
  16. package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +116 -5
  17. package/.claude/skills/event-modeling-shared/templates/constitution.md +18 -1
  18. package/.claude/skills/event-modeling-shared/templates/slice.md +64 -39
  19. package/.claude/skills/event-modeling-shared/templates/state.md +5 -1
  20. package/.claude-plugin/marketplace.json +14 -0
  21. package/README.md +25 -11
  22. package/dist/catalog/apiFirst.d.ts +28 -0
  23. package/dist/catalog/apiFirst.js +49 -0
  24. package/dist/catalog/apiFirst.js.map +1 -0
  25. package/dist/catalog/docJoin.d.ts +13 -0
  26. package/dist/catalog/docJoin.js +11 -0
  27. package/dist/catalog/docJoin.js.map +1 -1
  28. package/dist/catalog/docModelConsistencyValidate.js +44 -4
  29. package/dist/catalog/docModelConsistencyValidate.js.map +1 -1
  30. package/dist/catalog/invariantsValidate.d.ts +4 -0
  31. package/dist/catalog/invariantsValidate.js +102 -0
  32. package/dist/catalog/invariantsValidate.js.map +1 -0
  33. package/dist/catalog/scenarios.d.ts +15 -0
  34. package/dist/catalog/scenarios.js +97 -0
  35. package/dist/catalog/scenarios.js.map +1 -0
  36. package/dist/catalog/sliceDoc.d.ts +8 -0
  37. package/dist/catalog/sliceDoc.js +9 -0
  38. package/dist/catalog/sliceDoc.js.map +1 -1
  39. package/dist/catalog/sliceReadyValidate.d.ts +31 -4
  40. package/dist/catalog/sliceReadyValidate.js +66 -8
  41. package/dist/catalog/sliceReadyValidate.js.map +1 -1
  42. package/dist/catalog/sliceSections.d.ts +55 -0
  43. package/dist/catalog/sliceSections.js +317 -0
  44. package/dist/catalog/sliceSections.js.map +1 -0
  45. package/dist/cli/agentsMd.js +7 -1
  46. package/dist/cli/agentsMd.js.map +1 -1
  47. package/dist/cli/api.d.ts +90 -0
  48. package/dist/cli/api.js +318 -0
  49. package/dist/cli/api.js.map +1 -0
  50. package/dist/cli/ciInit.d.ts +82 -21
  51. package/dist/cli/ciInit.js +468 -64
  52. package/dist/cli/ciInit.js.map +1 -1
  53. package/dist/cli/conformScope.d.ts +28 -0
  54. package/dist/cli/conformScope.js +67 -0
  55. package/dist/cli/conformScope.js.map +1 -1
  56. package/dist/cli/coverage.d.ts +9 -0
  57. package/dist/cli/coverage.js +60 -8
  58. package/dist/cli/coverage.js.map +1 -1
  59. package/dist/cli/defer.d.ts +55 -0
  60. package/dist/cli/defer.js +244 -0
  61. package/dist/cli/defer.js.map +1 -0
  62. package/dist/cli/engagement.d.ts +210 -0
  63. package/dist/cli/engagement.js +444 -0
  64. package/dist/cli/engagement.js.map +1 -0
  65. package/dist/cli/engagementFile.d.ts +86 -0
  66. package/dist/cli/engagementFile.js +255 -0
  67. package/dist/cli/engagementFile.js.map +1 -0
  68. package/dist/cli/metrics.d.ts +10 -0
  69. package/dist/cli/metrics.js +49 -1
  70. package/dist/cli/metrics.js.map +1 -1
  71. package/dist/cli/pluginPin.d.ts +55 -0
  72. package/dist/cli/pluginPin.js +188 -0
  73. package/dist/cli/pluginPin.js.map +1 -0
  74. package/dist/cli/ratify.d.ts +27 -2
  75. package/dist/cli/ratify.js +112 -13
  76. package/dist/cli/ratify.js.map +1 -1
  77. package/dist/cli/reratify.d.ts +4 -2
  78. package/dist/cli/reratify.js +41 -16
  79. package/dist/cli/reratify.js.map +1 -1
  80. package/dist/cli/scopeInputs.d.ts +48 -0
  81. package/dist/cli/scopeInputs.js +176 -0
  82. package/dist/cli/scopeInputs.js.map +1 -0
  83. package/dist/cli/skillCheck.d.ts +15 -1
  84. package/dist/cli/skillCheck.js +22 -1
  85. package/dist/cli/skillCheck.js.map +1 -1
  86. package/dist/cli/skillDirs.d.ts +5 -0
  87. package/dist/cli/skillDirs.js +13 -1
  88. package/dist/cli/skillDirs.js.map +1 -1
  89. package/dist/cli/skillSync.d.ts +4 -0
  90. package/dist/cli/skillSync.js +23 -0
  91. package/dist/cli/skillSync.js.map +1 -1
  92. package/dist/cli/sliceNew.d.ts +11 -7
  93. package/dist/cli/sliceNew.js +91 -18
  94. package/dist/cli/sliceNew.js.map +1 -1
  95. package/dist/cli/sliceSync.d.ts +50 -0
  96. package/dist/cli/sliceSync.js +170 -0
  97. package/dist/cli/sliceSync.js.map +1 -0
  98. package/dist/cli/status.d.ts +24 -1
  99. package/dist/cli/status.js +20 -1
  100. package/dist/cli/status.js.map +1 -1
  101. package/dist/cli/systemInputs.d.ts +53 -10
  102. package/dist/cli/systemInputs.js +186 -11
  103. package/dist/cli/systemInputs.js.map +1 -1
  104. package/dist/cli/upgrade.d.ts +16 -3
  105. package/dist/cli/upgrade.js +366 -30
  106. package/dist/cli/upgrade.js.map +1 -1
  107. package/dist/cli/usageLog.d.ts +4 -2
  108. package/dist/cli/usageLog.js +4 -2
  109. package/dist/cli/usageLog.js.map +1 -1
  110. package/dist/cli.js +599 -94
  111. package/dist/cli.js.map +1 -1
  112. package/dist/emit/api.d.ts +29 -0
  113. package/dist/emit/api.js +220 -0
  114. package/dist/emit/api.js.map +1 -0
  115. package/dist/emit/apiCheckJson.d.ts +3 -0
  116. package/dist/emit/apiCheckJson.js +21 -0
  117. package/dist/emit/apiCheckJson.js.map +1 -0
  118. package/dist/emit/codeownersJson.d.ts +3 -0
  119. package/dist/emit/codeownersJson.js +19 -0
  120. package/dist/emit/codeownersJson.js.map +1 -0
  121. package/dist/emit/diffJson.d.ts +1 -1
  122. package/dist/emit/diffJson.js +8 -1
  123. package/dist/emit/diffJson.js.map +1 -1
  124. package/dist/emit/engagementJson.d.ts +8 -0
  125. package/dist/emit/engagementJson.js +46 -0
  126. package/dist/emit/engagementJson.js.map +1 -0
  127. package/dist/emit/glossaryJson.d.ts +1 -1
  128. package/dist/emit/glossaryJson.js +4 -1
  129. package/dist/emit/glossaryJson.js.map +1 -1
  130. package/dist/emit/json.d.ts +17 -1
  131. package/dist/emit/json.js +30 -1
  132. package/dist/emit/json.js.map +1 -1
  133. package/dist/emit/metricsJson.d.ts +1 -1
  134. package/dist/emit/metricsJson.js +4 -1
  135. package/dist/emit/metricsJson.js.map +1 -1
  136. package/dist/emit/queryJson.d.ts +1 -1
  137. package/dist/emit/queryJson.js +7 -1
  138. package/dist/emit/queryJson.js.map +1 -1
  139. package/dist/emit/scopeJson.d.ts +12 -0
  140. package/dist/emit/scopeJson.js +26 -0
  141. package/dist/emit/scopeJson.js.map +1 -0
  142. package/dist/emit/skillCheckJson.d.ts +3 -2
  143. package/dist/emit/skillCheckJson.js +10 -2
  144. package/dist/emit/skillCheckJson.js.map +1 -1
  145. package/dist/emit/statusJson.d.ts +1 -1
  146. package/dist/emit/statusJson.js +15 -1
  147. package/dist/emit/statusJson.js.map +1 -1
  148. package/dist/emit/systemJson.d.ts +15 -8
  149. package/dist/emit/systemJson.js +28 -13
  150. package/dist/emit/systemJson.js.map +1 -1
  151. package/dist/emit/validateJson.d.ts +3 -3
  152. package/dist/emit/validateJson.js +10 -4
  153. package/dist/emit/validateJson.js.map +1 -1
  154. package/dist/mcp/server.d.ts +1 -1
  155. package/dist/mcp/server.js +163 -31
  156. package/dist/mcp/server.js.map +1 -1
  157. package/dist/model/diff.d.ts +4 -1
  158. package/dist/model/diff.js +44 -8
  159. package/dist/model/diff.js.map +1 -1
  160. package/dist/model/glossary.d.ts +2 -0
  161. package/dist/model/glossary.js +1 -0
  162. package/dist/model/glossary.js.map +1 -1
  163. package/dist/model/model.d.ts +55 -2
  164. package/dist/model/model.js +57 -0
  165. package/dist/model/model.js.map +1 -1
  166. package/dist/model/qualifiedRef.d.ts +18 -0
  167. package/dist/model/qualifiedRef.js +26 -0
  168. package/dist/model/qualifiedRef.js.map +1 -1
  169. package/dist/model/queryIndex.d.ts +9 -1
  170. package/dist/model/queryIndex.js +18 -2
  171. package/dist/model/queryIndex.js.map +1 -1
  172. package/dist/model/rules.d.ts +143 -2
  173. package/dist/model/rules.js +162 -3
  174. package/dist/model/rules.js.map +1 -1
  175. package/dist/model/validate.d.ts +40 -0
  176. package/dist/model/validate.js +147 -1
  177. package/dist/model/validate.js.map +1 -1
  178. package/dist/parser/ast.d.ts +31 -3
  179. package/dist/parser/parser.js +148 -14
  180. package/dist/parser/parser.js.map +1 -1
  181. package/dist/query/format.js +1 -1
  182. package/dist/query/format.js.map +1 -1
  183. package/dist/query/verbs.d.ts +6 -0
  184. package/dist/query/verbs.js +6 -2
  185. package/dist/query/verbs.js.map +1 -1
  186. package/dist/system/adaptation.d.ts +14 -0
  187. package/dist/system/adaptation.js +49 -0
  188. package/dist/system/adaptation.js.map +1 -0
  189. package/dist/system/codeowners.d.ts +77 -0
  190. package/dist/system/codeowners.js +242 -0
  191. package/dist/system/codeowners.js.map +1 -0
  192. package/dist/system/manifest.d.ts +10 -5
  193. package/dist/system/manifest.js +54 -26
  194. package/dist/system/manifest.js.map +1 -1
  195. package/dist/system/migrateManifest.d.ts +28 -0
  196. package/dist/system/migrateManifest.js +155 -0
  197. package/dist/system/migrateManifest.js.map +1 -0
  198. package/dist/system/scope.d.ts +46 -0
  199. package/dist/system/scope.js +132 -0
  200. package/dist/system/scope.js.map +1 -0
  201. package/dist/system/verify.d.ts +50 -13
  202. package/dist/system/verify.js +179 -58
  203. package/dist/system/verify.js.map +1 -1
  204. package/dist/templates.d.ts +2 -2
  205. package/dist/templates.js +23 -2
  206. package/dist/templates.js.map +1 -1
  207. package/package.json +5 -2
  208. package/plugin/.claude-plugin/plugin.json +18 -0
  209. package/plugin/agents/em-critic.md +27 -0
  210. package/plugin/agents/em-implementer.md +35 -0
  211. package/plugin/agents/em-reviewer.md +32 -0
  212. package/plugin/agents/em-validator.md +28 -0
  213. package/plugin/shared/reference/em-dsl.md +871 -0
  214. package/plugin/shared/reference/methodology.md +329 -0
  215. package/plugin/shared/reference/operating-principles.md +222 -0
  216. package/plugin/shared/reference/slice-doc-schema.md +574 -0
  217. package/plugin/shared/templates/conformance-report.md +85 -0
  218. package/plugin/shared/templates/constitution.md +136 -0
  219. package/plugin/shared/templates/model-readme.md +46 -0
  220. package/plugin/shared/templates/slice.md +251 -0
  221. package/plugin/shared/templates/state.md +77 -0
  222. package/plugin/skills/conform/SKILL.md +93 -0
  223. package/plugin/skills/conform/reference/conform.md +428 -0
  224. package/plugin/skills/design/SKILL.md +298 -0
  225. package/plugin/skills/discover/SKILL.md +90 -0
  226. package/plugin/skills/discover/reference/extract.md +147 -0
  227. package/plugin/skills/engagement/SKILL.md +193 -0
  228. package/plugin/skills/engagement/reference/engagement.md +144 -0
  229. package/plugin/skills/event-modeling/SKILL.md +132 -0
  230. package/plugin/skills/implement/SKILL.md +61 -0
  231. package/plugin/skills/implement/reference/implement.md +439 -0
  232. package/plugin/skills/review/SKILL.md +74 -0
  233. package/dist/emit/typespec.d.ts +0 -26
  234. package/dist/emit/typespec.js +0 -356
  235. package/dist/emit/typespec.js.map +0 -1
@@ -21,32 +21,55 @@
21
21
  // ci-block/constitution check needs. Requires the model directory to be inside a git
22
22
  // repository — same requirement `em conform-scope` holds, for the same reason (there is no
23
23
  // "changed since when" or "commit per step" without one).
24
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
- import { basename, extname, join } from "node:path";
24
+ import { existsSync, readFileSync, realpathSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
25
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
26
+ import { PUBLIC_SCALAR_TYPE_NAMES } from "../model/model.js";
27
+ import { findUnresolvedPublicFieldTypes } from "../model/validate.js";
26
28
  import { realGit } from "./diff-inputs.js";
27
29
  import { resolveSliceDocJoin } from "../catalog/docJoin.js";
28
30
  import { validateOrphanedSliceDocs } from "../catalog/orphanedSliceDocValidate.js";
29
31
  import { planMigration, verifyMigration } from "./migrateReactionShape.js";
30
- import { planSkillSyncBundle, applySkillSyncBundle } from "./skillSync.js";
31
- import { EM_ALL_SKILL_BUNDLE_DIRS, EM_SKILL_ANCHOR_DIR } from "./skillDirs.js";
32
- import { ciWorkflowPath, conformWorkflowPath, buildCiWorkflowFile, buildConformWorkflowFile, ciManagedBody, conformManagedBody, planCiFile, applyCiFile, CI_WORKFLOW_MARKER, CONFORM_WORKFLOW_MARKER, } from "./ciInit.js";
32
+ import { planSkillSyncBundle, applySkillSyncBundle, planAgentSync, applySkillSync } from "./skillSync.js";
33
+ import { EM_ALL_SKILL_BUNDLE_DIRS, EM_SKILL_ANCHOR_DIR, EM_AGENT_FILES } from "./skillDirs.js";
34
+ import { detectPlugin, checkPlugin, claudePluginsDir, mergePluginSettings, pluginInstallCommands, pluginSettingsEntries } from "./pluginPin.js";
35
+ import { syncAgentsMd } from "./agentsMd.js";
36
+ import { ciWorkflowPath, conformWorkflowPath, buildCiWorkflowFile, buildConformWorkflowFile, buildCiWorkflowFileMulti, buildConformWorkflowFileMulti, ciManagedBody, ciManagedBodyMulti, conformManagedBody, conformManagedBodyMulti, ciModelsFromManifest, planCiFile, applyCiFile, CI_WORKFLOW_MARKER, CONFORM_WORKFLOW_MARKER, } from "./ciInit.js";
33
37
  import { findSpecifyRoot } from "./status.js";
34
38
  import { scaffoldConstitution, scaffoldStateFile } from "../templates.js";
35
39
  import { loadStateFile, parseState, setEmVersion, ensureOptionalBullets, resolveStateFilePath } from "./stateFile.js";
36
40
  import { fieldLineRegex, normalizeFieldValue, locateFrontmatterInner } from "./frontmatterSurgery.js";
37
41
  import { localIsoDate } from "../util/localDate.js";
42
+ import { compile } from "../pipeline.js";
43
+ import { hasErrors } from "../model/validate.js";
44
+ import { LEGACY_SYSTEM_MANIFEST_SCHEMA_VERSION, parseManifest } from "../system/manifest.js";
45
+ import { planManifestMigration } from "../system/migrateManifest.js";
46
+ import { loadSource, SYSTEM_MANIFEST_FILE } from "./systemInputs.js";
38
47
  export const UPGRADE_MIN_SUPPORTED_VERSION = "1.6.0";
39
48
  // ---- Step 1: skill-bundle — delegates to `em skill sync`'s own plan/apply. ----
40
49
  function vendoredSkillsRootOf(repoRoot) {
41
50
  return join(repoRoot, ".claude", "skills");
42
51
  }
52
+ function vendoredAgentsRootOf(repoRoot) {
53
+ return join(repoRoot, ".claude", "agents");
54
+ }
55
+ function packagedAgentsRootOf(ctx) {
56
+ return ctx.packagedAgentsRoot ?? join(ctx.packagedSkillsRoot, "..", "agents");
57
+ }
43
58
  function detectSkillBundle(ctx) {
44
59
  const vendoredRoot = vendoredSkillsRootOf(ctx.repoRoot);
60
+ // MIL-231/232: a repo that declares the plugin has no bundle to sync (any stray vendored copy is
61
+ // the `skill-plugin` step's business, not this one's).
62
+ if (detectPlugin(ctx.repoRoot))
63
+ return { applicable: false, reason: "plugin repo — nothing to sync" };
64
+ // MIL-232: the vendored bundle is about to be removed by `skill-plugin` (ordered first) — syncing it would be a throwaway commit.
65
+ if (detectSkillPlugin(ctx).applicable)
66
+ return { applicable: false, reason: "the vendored bundle is migrated to the plugin by skill-plugin — nothing to sync" };
45
67
  if (!existsSync(join(vendoredRoot, EM_SKILL_ANCHOR_DIR))) {
46
68
  return { applicable: false, reason: "no vendored skill bundle installed at .claude/skills/ — run `em skill install` first if you want one" };
47
69
  }
48
70
  const bundlePlan = planSkillSyncBundle(ctx.packagedSkillsRoot, vendoredRoot, EM_ALL_SKILL_BUNDLE_DIRS);
49
- const totalChanges = bundlePlan.reduce((n, { plan }) => n + plan.changes.length, 0);
71
+ const agentPlan = planAgentSync(packagedAgentsRootOf(ctx), vendoredAgentsRootOf(ctx.repoRoot), EM_AGENT_FILES);
72
+ const totalChanges = bundlePlan.reduce((n, { plan }) => n + plan.changes.length, 0) + agentPlan.changes.length;
50
73
  if (totalChanges === 0)
51
74
  return { applicable: false, reason: "vendored skill bundle already matches the installed em" };
52
75
  return { applicable: true, reason: `vendored skill bundle differs from the installed em in ${totalChanges} file(s)` };
@@ -54,11 +77,71 @@ function detectSkillBundle(ctx) {
54
77
  function applySkillBundle(ctx) {
55
78
  const vendoredRoot = vendoredSkillsRootOf(ctx.repoRoot);
56
79
  const bundlePlan = planSkillSyncBundle(ctx.packagedSkillsRoot, vendoredRoot, EM_ALL_SKILL_BUNDLE_DIRS);
57
- const totalChanges = bundlePlan.reduce((n, { plan }) => n + plan.changes.length, 0);
80
+ const vendoredAgents = vendoredAgentsRootOf(ctx.repoRoot);
81
+ const agentPlan = planAgentSync(packagedAgentsRootOf(ctx), vendoredAgents, EM_AGENT_FILES);
82
+ const totalChanges = bundlePlan.reduce((n, { plan }) => n + plan.changes.length, 0) + agentPlan.changes.length;
58
83
  if (totalChanges === 0)
59
84
  return { ok: false, message: "nothing to sync" };
60
85
  applySkillSyncBundle(bundlePlan, ctx.packagedSkillsRoot, vendoredRoot);
61
- const changedFiles = bundlePlan.flatMap(({ dirName, plan }) => plan.changes.map((c) => join(".claude", "skills", dirName, c.relPath)));
86
+ applySkillSync(agentPlan, packagedAgentsRootOf(ctx), vendoredAgents);
87
+ const changedFiles = [
88
+ ...bundlePlan.flatMap(({ dirName, plan }) => plan.changes.map((c) => join(".claude", "skills", dirName, c.relPath))),
89
+ ...agentPlan.changes.map((c) => join(".claude", "agents", c.relPath)),
90
+ ];
91
+ return { ok: true, changedFiles };
92
+ }
93
+ // ---- Step 1b: skill-plugin (MIL-232, R14) — vendored bundle -> pinned em plugin, one commit. ----
94
+ function detectSkillPlugin(ctx) {
95
+ const vendoredRoot = vendoredSkillsRootOf(ctx.repoRoot);
96
+ if (!existsSync(join(vendoredRoot, EM_SKILL_ANCHOR_DIR))) {
97
+ return { applicable: false, reason: "no vendored skill bundle installed at .claude/skills/ — nothing to migrate to the plugin" };
98
+ }
99
+ if (detectPlugin(ctx.repoRoot))
100
+ return { applicable: false, reason: "plugin already declared in .claude/settings.json" };
101
+ const settingsPath = join(ctx.repoRoot, ".claude", "settings.json");
102
+ if (existsSync(settingsPath) && mergePluginSettings(readFileSync(settingsPath, "utf8"), ctx.installedVersion) === null) {
103
+ return { applicable: false, reason: ".claude/settings.json is not a JSON object — fix it by hand, then re-run em upgrade" };
104
+ }
105
+ const e = pluginSettingsEntries(ctx.installedVersion);
106
+ return {
107
+ applicable: true,
108
+ reason: `vendored skill bundle → em plugin: remove ${EM_ALL_SKILL_BUNDLE_DIRS.map((d) => `.claude/skills/${d}`).join(", ")} and any of ${EM_AGENT_FILES.map((f) => `.claude/agents/${f}`).join(", ")}; ` +
109
+ `add extraKnownMarketplaces["${e.name}"] (github ${e.marketplace.source.repo}@${e.marketplace.source.ref}) and enabledPlugins["${e.enabledKey}"] to .claude/settings.json; ` +
110
+ `refresh the AGENTS.md managed section with the /em:* skill names`,
111
+ };
112
+ }
113
+ function applySkillPlugin(ctx) {
114
+ const settingsPath = join(ctx.repoRoot, ".claude", "settings.json");
115
+ const existing = existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : null;
116
+ // Verify before any write: an unparseable settings.json must not cost the user their skills.
117
+ const merged = mergePluginSettings(existing, ctx.installedVersion);
118
+ if (merged === null)
119
+ return { ok: false, message: ".claude/settings.json is not a JSON object — nothing changed" };
120
+ const vendoredRoot = vendoredSkillsRootOf(ctx.repoRoot);
121
+ const changedFiles = [];
122
+ // Only the bundle's own directories; a sibling skill under .claude/skills/ is never touched.
123
+ for (const d of EM_ALL_SKILL_BUNDLE_DIRS) {
124
+ const target = join(vendoredRoot, d);
125
+ if (!existsSync(target))
126
+ continue;
127
+ rmSync(target, { recursive: true, force: true });
128
+ changedFiles.push(join(".claude", "skills", d));
129
+ }
130
+ // MIL-269 (R32): exactly the four em agent files (the plugin ships them as em:em-*); any other
131
+ // agent in the shared directory is never listed or touched.
132
+ for (const f of EM_AGENT_FILES) {
133
+ const target = join(vendoredAgentsRootOf(ctx.repoRoot), f);
134
+ if (!existsSync(target))
135
+ continue;
136
+ rmSync(target, { force: true });
137
+ changedFiles.push(join(".claude", "agents", f));
138
+ }
139
+ mkdirSync(dirname(settingsPath), { recursive: true });
140
+ writeFileSync(settingsPath, merged);
141
+ changedFiles.push(join(".claude", "settings.json"));
142
+ const agents = syncAgentsMd(ctx.repoRoot);
143
+ if (agents.wrote)
144
+ changedFiles.push("AGENTS.md");
62
145
  return { ok: true, changedFiles };
63
146
  }
64
147
  // ---- Step 2: reaction-shape — delegates to `em migrate`'s own plan/verify/apply. ----
@@ -162,32 +245,68 @@ function applyStateFile(ctx) {
162
245
  // Not anchored on a literal "em coverage" — the generated line pins an exact version
163
246
  // (`npx @milehimikey/em@1.9.0 coverage "..."`, ciInit.ts's own `em` template variable), so
164
247
  // "em" and "coverage" are never adjacent in the real file.
165
- const CI_INIT_ARGS_RE = /\bcoverage "([^"]*)" --tests "([^"]*)" --strict\b/;
166
- function extractCiInitArgs(emCiContent) {
167
- const m = CI_INIT_ARGS_RE.exec(emCiContent);
168
- return m ? { model: m[1], testsDir: m[2] } : null;
248
+ const CI_INIT_ARGS_RE = /\bcoverage "([^"]*)" --tests "([^"]*)" --strict\b/g;
249
+ function extractCiInitArgs(emCiContent, repoRoot) {
250
+ const all = [...emCiContent.matchAll(CI_INIT_ARGS_RE)];
251
+ if (all.length === 0)
252
+ return { args: null };
253
+ const named = [...new Set(all.map((m) => m[1]))];
254
+ const base = { model: all[0][1], testsDir: all[0][2] };
255
+ if (named.length < 2)
256
+ return { args: { ...base, models: null } };
257
+ // A multi-model block: regenerate it from the manifest, never from a single model.
258
+ const loaded = ciModelsFromManifest(repoRoot, repoRoot);
259
+ if (!loaded.ok) {
260
+ return {
261
+ args: null,
262
+ blocked: `em-ci.yml covers ${named.length} models (${named.join(", ")}) but no readable system.yaml sits at the repo root (${loaded.message}) - run \`em ci init <system.yaml>\` by hand if it needs refreshing`,
263
+ };
264
+ }
265
+ return { args: { ...base, models: loaded.models } };
266
+ }
267
+ function ciBlockFiles(args, ctx) {
268
+ // MIL-231: same plugin signal `em ci init` uses, so the two never disagree about the conform lines.
269
+ const usePlugin = detectPlugin(ctx.repoRoot) !== null;
270
+ if (args.models) {
271
+ return {
272
+ ci: [buildCiWorkflowFileMulti(SYSTEM_MANIFEST_ARG, args.models, args.testsDir, ctx.installedVersion), ciManagedBodyMulti(args.models, args.testsDir, ctx.installedVersion)],
273
+ conform: [buildConformWorkflowFileMulti(SYSTEM_MANIFEST_ARG, args.models, ctx.installedVersion, usePlugin), conformManagedBodyMulti(args.models, ctx.installedVersion, usePlugin)],
274
+ };
275
+ }
276
+ return {
277
+ ci: [buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion), ciManagedBody(args.model, args.testsDir, ctx.installedVersion)],
278
+ conform: [buildConformWorkflowFile(args.model, ctx.installedVersion, usePlugin), conformManagedBody(args.model, ctx.installedVersion, usePlugin)],
279
+ };
169
280
  }
281
+ /** Only used for the generated header's wording on a from-scratch file; `ci-block` only ever
282
+ * patches the managed block of a file that already exists, so this never reaches disk there. */
283
+ const SYSTEM_MANIFEST_ARG = "system.yaml";
170
284
  function ciBlockPlans(ctx) {
171
285
  const ciPath = ciWorkflowPath(ctx.repoRoot);
172
286
  const conformPath = conformWorkflowPath(ctx.repoRoot);
173
287
  if (!existsSync(ciPath))
174
288
  return { ciPath, ciStale: false, conformPath, conformStale: false, args: null };
175
- const args = extractCiInitArgs(readFileSync(ciPath, "utf8"));
176
- if (!args)
177
- return { ciPath, ciStale: false, conformPath, conformStale: false, args: null };
178
- const ciStatus = planCiFile(ciPath, buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion), ciManagedBody(args.model, args.testsDir, ctx.installedVersion), CI_WORKFLOW_MARKER, false);
289
+ const extracted = extractCiInitArgs(readFileSync(ciPath, "utf8"), ctx.repoRoot);
290
+ if (!extracted.args)
291
+ return { ciPath, ciStale: false, conformPath, conformStale: false, args: null, blocked: "blocked" in extracted ? extracted.blocked : undefined };
292
+ const args = extracted.args;
293
+ const multi = args.models !== null;
294
+ const files = ciBlockFiles(args, ctx);
295
+ const ciStatus = planCiFile(ciPath, files.ci[0], files.ci[1], CI_WORKFLOW_MARKER, false, multi);
179
296
  const ciStale = ciStatus.kind === "stale";
180
297
  let conformStale = false;
181
298
  if (existsSync(conformPath)) {
182
- const conformStatus = planCiFile(conformPath, buildConformWorkflowFile(args.model, ctx.installedVersion), conformManagedBody(args.model, ctx.installedVersion), CONFORM_WORKFLOW_MARKER, false);
299
+ const conformStatus = planCiFile(conformPath, files.conform[0], files.conform[1], CONFORM_WORKFLOW_MARKER, false, multi);
183
300
  conformStale = conformStatus.kind === "stale";
184
301
  }
185
302
  return { ciPath, ciStale, conformPath, conformStale, args };
186
303
  }
187
304
  function detectCiBlock(ctx) {
188
- const { ciPath, ciStale, conformStale, args } = ciBlockPlans(ctx);
305
+ const { ciPath, ciStale, conformStale, args, blocked } = ciBlockPlans(ctx);
189
306
  if (!existsSync(ciPath))
190
307
  return { applicable: false, reason: "no .github/workflows/em-ci.yml — em upgrade never creates one" };
308
+ if (!args && blocked)
309
+ return { applicable: false, reason: blocked };
191
310
  if (!args)
192
311
  return { applicable: false, reason: "can't determine the <model>/--tests arguments em-ci.yml was generated with — run `em ci init <model>` by hand if it needs refreshing" };
193
312
  if (!ciStale && !conformStale)
@@ -198,15 +317,15 @@ function applyCiBlock(ctx) {
198
317
  const { ciPath, ciStale, conformPath, conformStale, args } = ciBlockPlans(ctx);
199
318
  if (!args || (!ciStale && !conformStale))
200
319
  return { ok: false, message: "nothing to refresh" };
320
+ const multi = args.models !== null;
321
+ const files = ciBlockFiles(args, ctx);
201
322
  const changedFiles = [];
202
323
  if (ciStale) {
203
- const status = planCiFile(ciPath, buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion), ciManagedBody(args.model, args.testsDir, ctx.installedVersion), CI_WORKFLOW_MARKER, false);
204
- applyCiFile(ciPath, status);
324
+ applyCiFile(ciPath, planCiFile(ciPath, files.ci[0], files.ci[1], CI_WORKFLOW_MARKER, false, multi));
205
325
  changedFiles.push(ciPath);
206
326
  }
207
327
  if (conformStale) {
208
- const status = planCiFile(conformPath, buildConformWorkflowFile(args.model, ctx.installedVersion), conformManagedBody(args.model, ctx.installedVersion), CONFORM_WORKFLOW_MARKER, false);
209
- applyCiFile(conformPath, status);
328
+ applyCiFile(conformPath, planCiFile(conformPath, files.conform[0], files.conform[1], CONFORM_WORKFLOW_MARKER, false, multi));
210
329
  changedFiles.push(conformPath);
211
330
  }
212
331
  return { ok: true, changedFiles };
@@ -235,12 +354,191 @@ function applyConstitution(ctx) {
235
354
  writeFileSync(path, scaffoldConstitution(ctx.model.name));
236
355
  return { ok: true, changedFiles: [path] };
237
356
  }
357
+ // ---- Step 6: ratified-signoff (MIL-259) — `--slice-ready` now requires a recorded `ratifiedBy`,
358
+ // so a doc that reached `ready-to-implement` before sign-offs were recorded (pre-1.8) would
359
+ // suddenly read not-ready. Grandfather exactly those: `version: 1` (never reratified — a
360
+ // reratified doc has `version > 1` and is mid-sign-off, which only a human can resolve; see the
361
+ // `ready-to-implement-no-ratifiedby` human item) with no `ratifiedBy`. A doc with no `version:`
362
+ // at all is treated as version 1 (the schema's default). ----
363
+ export const GRANDFATHERED_RATIFIER = "grandfathered (unsigned before em 1.14)";
364
+ /** Pure text transform: inserts the grandfather sign-off after the `status:` line, using only
365
+ * the key lines that are missing (a stray `ratifiedOn:` is kept as-is), in the file's own EOL.
366
+ * `null` when the doc isn't a candidate (no frontmatter/status, or `ratifiedBy` already set). */
367
+ export function applyGrandfatherSignoff(raw, ratifiedOn) {
368
+ const range = locateFrontmatterInner(raw);
369
+ if (!range)
370
+ return null;
371
+ const inner = raw.slice(range.innerStart, range.innerEnd);
372
+ const statusMatch = fieldLineRegex("status").exec(inner);
373
+ if (!statusMatch)
374
+ return null;
375
+ const byMatch = fieldLineRegex("ratifiedBy").exec(inner);
376
+ if (byMatch && normalizeFieldValue(byMatch[2]) !== null)
377
+ return null;
378
+ const onMatch = fieldLineRegex("ratifiedOn").exec(inner);
379
+ const statusEnd = statusMatch.index + statusMatch[0].length;
380
+ const eol = inner.slice(statusEnd).startsWith("\r\n") ? "\r\n" : "\n";
381
+ const value = `"${GRANDFATHERED_RATIFIER}"`;
382
+ // Edits against `inner`, applied highest index first so earlier offsets stay valid. A blank
383
+ // `ratifiedBy:` line is filled in place; missing lines are inserted right after `status:`.
384
+ const missing = [];
385
+ if (!byMatch)
386
+ missing.push(`ratifiedBy: ${value}`);
387
+ if (!onMatch)
388
+ missing.push(`ratifiedOn: ${ratifiedOn}`);
389
+ const edits = [];
390
+ if (missing.length > 0)
391
+ edits.push({ index: statusEnd, oldLen: 0, next: eol + missing.join(eol) });
392
+ if (byMatch)
393
+ edits.push({ index: byMatch.index, oldLen: byMatch[0].length, next: `${byMatch[1]}${value}` });
394
+ edits.sort((a, b) => b.index - a.index);
395
+ let updated = inner;
396
+ for (const e of edits)
397
+ updated = updated.slice(0, e.index) + e.next + updated.slice(e.index + e.oldLen);
398
+ return raw.slice(0, range.innerStart) + updated + raw.slice(range.innerEnd);
399
+ }
400
+ function grandfatherCandidates(ctx) {
401
+ const seen = new Set();
402
+ const out = [];
403
+ for (const d of nonContinuationDocs(ctx)) {
404
+ if (d.status !== "ready-to-implement" || d.ratifiedBy)
405
+ continue;
406
+ if ((d.version ?? 1) !== 1)
407
+ continue;
408
+ if (seen.has(d.path))
409
+ continue;
410
+ seen.add(d.path);
411
+ out.push({ key: d.key, path: d.path });
412
+ }
413
+ return out.sort((a, b) => a.key.localeCompare(b.key));
414
+ }
415
+ function detectRatifiedSignoff(ctx) {
416
+ const keys = grandfatherCandidates(ctx).map((c) => c.key);
417
+ if (keys.length === 0)
418
+ return { applicable: false, reason: "no version-1 ready-to-implement doc is missing a ratifiedBy" };
419
+ return {
420
+ applicable: true,
421
+ reason: `${keys.length} ready-to-implement doc(s) with no recorded sign-off (--slice-ready now requires one): ${keys.join(", ")} — em upgrade will record ratifiedBy: "${GRANDFATHERED_RATIFIER}"`,
422
+ };
423
+ }
424
+ function applyRatifiedSignoff(ctx) {
425
+ const candidates = grandfatherCandidates(ctx);
426
+ if (candidates.length === 0)
427
+ return { ok: false, message: "nothing to grandfather" };
428
+ const today = localIsoDate();
429
+ const writes = [];
430
+ for (const c of candidates) {
431
+ const file = join(ctx.baseDir, c.path);
432
+ const next = applyGrandfatherSignoff(readFileSync(file, "utf8"), today);
433
+ if (next === null)
434
+ return { ok: false, message: `${c.path}: could not locate status: in the frontmatter` };
435
+ writes.push({ file, text: next });
436
+ }
437
+ for (const w of writes)
438
+ writeFileSync(w.file, w.text);
439
+ return { ok: true, changedFiles: writes.map((w) => w.file) };
440
+ }
441
+ // ---- Step 7: system-manifest (MIL-235, R3) — system.yaml 1.0 -> 2.0. ----
442
+ // The one mechanical step that writes OTHER models' files: the legacy manifest's seams become
443
+ // `consumes` clauses on each consuming translation and its owners become `owner "…"` on each
444
+ // member model's header, in the same single commit as the manifest rewrite (its `Em-Upgrade:`
445
+ // trailer is what lets MIL-240's scope gate exempt that cross-model change set). Planned by
446
+ // src/system/migrateManifest.ts over export documents; this half finds the manifest, loads the
447
+ // members, verifies every rewritten model still compiles clean, and only then writes anything.
448
+ /** The nearest `system.yaml` from `baseDir` upward, stopping at (and including) `repoRoot`. */
449
+ function findSystemManifest(ctx) {
450
+ // Real paths on both sides: git reports the repo root symlink-resolved (macOS /var -> /private/var).
451
+ const real = (p) => {
452
+ try {
453
+ return realpathSync(p);
454
+ }
455
+ catch {
456
+ return resolve(p);
457
+ }
458
+ };
459
+ const root = real(ctx.repoRoot);
460
+ let dir = real(ctx.baseDir);
461
+ for (;;) {
462
+ const candidate = join(dir, SYSTEM_MANIFEST_FILE);
463
+ if (existsSync(candidate))
464
+ return candidate;
465
+ if (dir === root)
466
+ return null;
467
+ const parent = dirname(dir);
468
+ const rel = relative(root, parent);
469
+ if (parent === dir || rel.startsWith("..") || isAbsolute(rel))
470
+ return null;
471
+ dir = parent;
472
+ }
473
+ }
474
+ function loadLegacyManifest(ctx) {
475
+ const path = findSystemManifest(ctx);
476
+ if (path === null)
477
+ return { path: null, reason: `no ${SYSTEM_MANIFEST_FILE} between the model and the repository root` };
478
+ const text = readFileSync(path, "utf8");
479
+ const parsed = parseManifest(text);
480
+ if (!parsed.ok)
481
+ return { path, reason: `${path} does not parse — run \`em system ${path}\` to see why` };
482
+ if (parsed.manifest.systemSchemaVersion !== LEGACY_SYSTEM_MANIFEST_SCHEMA_VERSION) {
483
+ return { path, reason: `${path} is already systemSchemaVersion "${parsed.manifest.systemSchemaVersion}"` };
484
+ }
485
+ return { path, text, manifest: parsed.manifest };
486
+ }
487
+ function detectSystemManifest(ctx) {
488
+ const loaded = loadLegacyManifest(ctx);
489
+ if (!("manifest" in loaded))
490
+ return { applicable: false, reason: loaded.reason };
491
+ const m = loaded.manifest;
492
+ return {
493
+ applicable: true,
494
+ reason: `${loaded.path} is systemSchemaVersion "1.0" — em upgrade will move its ${m.seams.length} seam(s) into \`consumes\` clauses, ` +
495
+ `its owner(s) onto the ${m.models.length} model header(s), and rewrite it to "2.0"`,
496
+ };
497
+ }
498
+ function applySystemManifest(ctx) {
499
+ const loaded = loadLegacyManifest(ctx);
500
+ if (!("manifest" in loaded))
501
+ return { ok: false, message: loaded.reason };
502
+ const baseDir = dirname(loaded.path);
503
+ const members = [];
504
+ for (const entry of loaded.manifest.models) {
505
+ const file = isAbsolute(entry.source) ? entry.source : join(baseDir, entry.source);
506
+ const source = loadSource(file);
507
+ if ("error" in source)
508
+ return { ok: false, message: `model "${entry.key}": ${source.error}` };
509
+ members.push({ key: entry.key, file, text: source.sourceKind === "em" ? readFileSync(file, "utf8") : null, doc: source.doc });
510
+ }
511
+ const plan = planManifestMigration(loaded.text, loaded.manifest, members);
512
+ if (!plan.ok)
513
+ return { ok: false, message: plan.message };
514
+ // Verify before write: every rewritten model must still compile with no errors.
515
+ for (const f of plan.files) {
516
+ try {
517
+ const { diagnostics } = compile(f.text);
518
+ if (hasErrors(diagnostics))
519
+ return { ok: false, message: `the rewrite of ${f.file} would introduce validation errors — aborting, nothing written` };
520
+ }
521
+ catch (e) {
522
+ return { ok: false, message: `the rewrite of ${f.file} does not parse (${e.message}) — aborting, nothing written` };
523
+ }
524
+ }
525
+ const reparsed = parseManifest(plan.manifestText);
526
+ if (!reparsed.ok)
527
+ return { ok: false, message: `the rewritten ${loaded.path} does not parse as 2.0 — aborting, nothing written` };
528
+ for (const f of plan.files)
529
+ writeFileSync(f.file, f.text);
530
+ writeFileSync(loaded.path, plan.manifestText);
531
+ return { ok: true, changedFiles: [...plan.files.map((f) => f.file), loaded.path] };
532
+ }
238
533
  export const UPGRADE_STEPS = [
534
+ { id: "skill-plugin", sinceVersion: "1.14.0", detect: detectSkillPlugin, apply: applySkillPlugin },
239
535
  { id: "skill-bundle", sinceVersion: "1.7.0", detect: detectSkillBundle, apply: applySkillBundle },
240
536
  { id: "reaction-shape", sinceVersion: "1.8.0", detect: detectReactionShape, apply: applyReactionShape },
241
537
  { id: "state-file", sinceVersion: "1.13.0", detect: detectStateFile, apply: applyStateFile },
242
538
  { id: "ci-block", sinceVersion: "1.9.0", detect: detectCiBlock, apply: applyCiBlock },
243
539
  { id: "constitution", sinceVersion: "1.11.0", detect: detectConstitution, apply: applyConstitution },
540
+ { id: "ratified-signoff", sinceVersion: "1.14.0", detect: detectRatifiedSignoff, apply: applyRatifiedSignoff },
541
+ { id: "system-manifest", sinceVersion: "1.14.0", detect: detectSystemManifest, apply: applySystemManifest },
244
542
  ];
245
543
  function elementRefOf(ctx) {
246
544
  return (id) => ctx.refs.refById.get(id);
@@ -256,7 +554,7 @@ function nonContinuationDocs(ctx) {
256
554
  const { doc, continuationOf } = resolveSliceDocJoin(ctx.model, ctx.refs, slice, key, ctx.baseDir, elementRefOf(ctx));
257
555
  if (continuationOf !== null)
258
556
  return;
259
- results.push({ key, status: doc.status, ratifiedBy: doc.ratifiedBy });
557
+ results.push({ key, status: doc.status, ratifiedBy: doc.ratifiedBy, version: doc.version, path: doc.path });
260
558
  });
261
559
  return results;
262
560
  }
@@ -287,14 +585,14 @@ function detectContinuationHasOwnDoc(ctx) {
287
585
  }
288
586
  function detectReadyNoRatifiedBy(ctx) {
289
587
  const keys = nonContinuationDocs(ctx)
290
- .filter((d) => d.status === "ready-to-implement" && !d.ratifiedBy)
588
+ .filter((d) => d.status === "ready-to-implement" && !d.ratifiedBy && (d.version ?? 1) > 1)
291
589
  .map((d) => d.key)
292
590
  .sort();
293
591
  if (keys.length === 0)
294
592
  return null;
295
593
  return {
296
594
  id: "ready-to-implement-no-ratifiedby",
297
- reason: `${keys.length} ready-to-implement doc(s) with no ratifiedBy: ${keys.join(", ")} — run \`em slice ratify --by <name>\` on each`,
595
+ reason: `${keys.length} ready-to-implement doc(s) with no ratifiedBy (version > 1, reratified and awaiting a fresh sign-off): ${keys.join(", ")} — run \`em slice ratify --by <name>\` on each`,
298
596
  };
299
597
  }
300
598
  function detectCoverageScopeDefault(ctx) {
@@ -337,6 +635,36 @@ function detectPredates16(ctx) {
337
635
  reason: `${plan.refusals.length} old-shape reaction site(s) can't be auto-migrated — run \`em migrate\` by hand, resolve the refusal(s), then re-run \`em upgrade\``,
338
636
  };
339
637
  }
638
+ /** MIL-237: strict public types (briefing R7). Lists every field of a `public` element (or of a
639
+ * declared type reachable from one) whose type is not in the fixed public type table and names
640
+ * no declared type — the same set `em validate`'s `public-field-type-unresolved` errors on.
641
+ * Detect-only: choosing the right type (or dropping `public`) is a modeling decision. */
642
+ function detectPublicFieldTypesUnresolved(ctx) {
643
+ const found = findUnresolvedPublicFieldTypes(ctx.model);
644
+ if (found.length === 0)
645
+ return null;
646
+ const items = found.map((f) => `${f.owner.name}.${f.field}: ${f.type ?? "(no type)"}`);
647
+ return {
648
+ id: "public-field-types-unresolved",
649
+ reason: `${found.length} public field(s) without a public type: ${items.join(", ")} — give each a type from ` +
650
+ `${PUBLIC_SCALAR_TYPE_NAMES.join(", ")}, \`X[]\`, or a declared \`type\` (em 1.14 strict public types), or drop \`public\``,
651
+ };
652
+ }
653
+ /** MIL-232: the machine-level plugin registration is per user and cannot be done by `em upgrade`.
654
+ * Fires when the `skill-plugin` step is pending (it will declare the plugin), or when the repo
655
+ * declares the plugin at the installed version but the machine registry lacks it. */
656
+ function detectPluginInstallLocally(ctx) {
657
+ const commands = pluginInstallCommands(ctx.installedVersion);
658
+ const snippet = `run \`${commands.join(" && ")}\` on each developer machine and in CI (the generated em-ci.yml does it)`;
659
+ if (detectSkillPlugin(ctx).applicable) {
660
+ return { id: "plugin-install-locally", reason: `the em plugin will be declared in .claude/settings.json but is registered per machine — after --apply, ${snippet}` };
661
+ }
662
+ const check = checkPlugin(ctx.repoRoot, ctx.installedVersion, claudePluginsDir());
663
+ if (check && check.findings.some((f) => f.code === "plugin-not-installed-locally")) {
664
+ return { id: "plugin-install-locally", reason: `the em plugin is declared but not registered on this machine — ${snippet}` };
665
+ }
666
+ return null;
667
+ }
340
668
  const HUMAN_DETECTORS = [
341
669
  detectNoModelVersion,
342
670
  detectContinuationHasOwnDoc,
@@ -344,6 +672,8 @@ const HUMAN_DETECTORS = [
344
672
  detectCoverageScopeDefault,
345
673
  detectUnratifiedConstitution,
346
674
  detectPredates16,
675
+ detectPublicFieldTypesUnresolved,
676
+ detectPluginInstallLocally,
347
677
  ];
348
678
  export function detectHumanItems(ctx) {
349
679
  return HUMAN_DETECTORS.map((d) => d(ctx)).filter((x) => x !== null);
@@ -396,11 +726,17 @@ export function hasGitIdentity(repoRoot, runGit = realGit) {
396
726
  const email = runGit(["-C", repoRoot, "config", "user.email"]);
397
727
  return name.status === 0 && name.stdout.trim().length > 0 && email.status === 0 && email.stdout.trim().length > 0;
398
728
  }
399
- function gitCommitAll(repoRoot, message, runGit) {
729
+ /** The commit trailer key every `em upgrade --apply` commit carries (MIL-235, R2):
730
+ * `Em-Upgrade: <step-id>` (or `em-version` for the final stamp commit). Readers use
731
+ * `git log --format=%(trailers:key=Em-Upgrade,valueonly)` — MIL-240's scope gate exempts files
732
+ * changed only by such commits, since a mechanical migration may legitimately span models. */
733
+ export const EM_UPGRADE_TRAILER = "Em-Upgrade";
734
+ function gitCommitAll(repoRoot, message, trailerValue, runGit) {
400
735
  const add = runGit(["-C", repoRoot, "add", "-A"]);
401
736
  if (add.status !== 0)
402
737
  return { ok: false, message: `git add failed: ${(add.stderr || "").trim() || "unknown error"}` };
403
- const commit = runGit(["-C", repoRoot, "commit", "-m", message]);
738
+ // Second `-m` = its own paragraph, which git reads as the trailer block.
739
+ const commit = runGit(["-C", repoRoot, "commit", "-m", message, "-m", `${EM_UPGRADE_TRAILER}: ${trailerValue}`]);
404
740
  if (commit.status !== 0)
405
741
  return { ok: false, message: `git commit failed: ${(commit.stderr || "").trim() || "unknown error"}` };
406
742
  return { ok: true };
@@ -440,7 +776,7 @@ export function detectUpgrade(ctx) {
440
776
  }
441
777
  /**
442
778
  * `--apply`: detect, then apply every applicable mechanical step in fixed order, one commit
443
- * each (`em upgrade: <step-id> (<from> → <to>)`), stopping at the first failure with every
779
+ * each (`em upgrade: <step-id> (<from> → <to>)`, trailer `Em-Upgrade: <step-id>`), stopping at the first failure with every
444
780
  * prior commit intact and the failed step's own partial writes discarded. On full success,
445
781
  * writes `Em version: <to>` as one final commit — always, even when every step above was a
446
782
  * no-op, UNLESS the bullet already reads `<to>` (idempotent second run makes zero commits).
@@ -476,7 +812,7 @@ export function applyUpgrade(ctx, runGit = realGit) {
476
812
  return { ok: false, message: `em upgrade: step "${step.id}" failed — ${result.message}`, report, applied };
477
813
  }
478
814
  const message = `em upgrade: ${step.id} (${report.from.version} → ${report.to})`;
479
- const commitResult = gitCommitAll(ctx.repoRoot, message, runGit);
815
+ const commitResult = gitCommitAll(ctx.repoRoot, message, step.id, runGit);
480
816
  if (!commitResult.ok) {
481
817
  return { ok: false, message: `em upgrade: step "${step.id}" applied but failed to commit — ${commitResult.message}`, report, applied };
482
818
  }
@@ -490,7 +826,7 @@ export function applyUpgrade(ctx, runGit = realGit) {
490
826
  if (stamped.ok && stamped.text !== loaded.text) {
491
827
  writeFileSync(loaded.path, stamped.text);
492
828
  const message = `em upgrade: em-version (${report.from.version} → ${report.to})`;
493
- const commitResult = gitCommitAll(ctx.repoRoot, message, runGit);
829
+ const commitResult = gitCommitAll(ctx.repoRoot, message, "em-version", runGit);
494
830
  if (!commitResult.ok) {
495
831
  return { ok: false, message: `em upgrade: writing Em version: failed to commit — ${commitResult.message}`, report, applied };
496
832
  }