@milehimikey/em 1.17.0 → 1.18.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 (196) hide show
  1. package/.claude/agents/em-implementer.md +7 -4
  2. package/.claude/agents/em-validator.md +2 -2
  3. package/.claude/skills/event-modeling/SKILL.md +1 -1
  4. package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
  5. package/.claude/skills/event-modeling-design/SKILL.md +46 -5
  6. package/.claude/skills/event-modeling-discover/SKILL.md +43 -8
  7. package/.claude/skills/event-modeling-discover/reference/extract.md +6 -1
  8. package/.claude/skills/event-modeling-discover/reference/prd-rubric.md +102 -0
  9. package/.claude/skills/event-modeling-discover/reference/prd-spec-path.md +171 -0
  10. package/.claude/skills/event-modeling-engagement/SKILL.md +21 -9
  11. package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
  12. package/.claude/skills/event-modeling-implement/reference/implement.md +18 -0
  13. package/.claude/skills/event-modeling-review/SKILL.md +4 -2
  14. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +94 -6
  15. package/.claude/skills/event-modeling-shared/reference/methodology.md +268 -0
  16. package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +96 -2
  17. package/.claude/skills/event-modeling-shared/templates/slice.md +11 -3
  18. package/.claude-plugin/marketplace.json +2 -2
  19. package/dist/catalog/docModelConsistencyValidate.js +160 -0
  20. package/dist/catalog/docModelConsistencyValidate.js.map +1 -1
  21. package/dist/catalog/invariantsValidate.d.ts +2 -0
  22. package/dist/catalog/invariantsValidate.js +67 -1
  23. package/dist/catalog/invariantsValidate.js.map +1 -1
  24. package/dist/catalog/scenarioQualityValidate.d.ts +4 -0
  25. package/dist/catalog/scenarioQualityValidate.js +148 -0
  26. package/dist/catalog/scenarioQualityValidate.js.map +1 -0
  27. package/dist/catalog/scenarios.d.ts +5 -0
  28. package/dist/catalog/scenarios.js +80 -0
  29. package/dist/catalog/scenarios.js.map +1 -1
  30. package/dist/catalog/sliceDoc.d.ts +4 -0
  31. package/dist/catalog/sliceDoc.js +16 -7
  32. package/dist/catalog/sliceDoc.js.map +1 -1
  33. package/dist/catalog/sliceReadyValidate.d.ts +11 -2
  34. package/dist/catalog/sliceReadyValidate.js +62 -3
  35. package/dist/catalog/sliceReadyValidate.js.map +1 -1
  36. package/dist/catalog/sliceSections.d.ts +3 -2
  37. package/dist/catalog/sliceSections.js +99 -9
  38. package/dist/catalog/sliceSections.js.map +1 -1
  39. package/dist/cli/ciInit.d.ts +4 -11
  40. package/dist/cli/ciInit.js +24 -6
  41. package/dist/cli/ciInit.js.map +1 -1
  42. package/dist/cli/coverage.d.ts +9 -0
  43. package/dist/cli/coverage.js +18 -0
  44. package/dist/cli/coverage.js.map +1 -1
  45. package/dist/cli/defer.d.ts +28 -0
  46. package/dist/cli/defer.js +87 -4
  47. package/dist/cli/defer.js.map +1 -1
  48. package/dist/cli/engagement.d.ts +13 -2
  49. package/dist/cli/engagement.js +60 -5
  50. package/dist/cli/engagement.js.map +1 -1
  51. package/dist/cli/engagementFile.d.ts +2 -0
  52. package/dist/cli/engagementFile.js +3 -1
  53. package/dist/cli/engagementFile.js.map +1 -1
  54. package/dist/cli/markImplemented.d.ts +14 -10
  55. package/dist/cli/markImplemented.js +49 -1
  56. package/dist/cli/markImplemented.js.map +1 -1
  57. package/dist/cli/prdTriage.d.ts +109 -0
  58. package/dist/cli/prdTriage.js +393 -0
  59. package/dist/cli/prdTriage.js.map +1 -0
  60. package/dist/cli/render-inputs.d.ts +2 -0
  61. package/dist/cli/render-inputs.js +4 -0
  62. package/dist/cli/render-inputs.js.map +1 -1
  63. package/dist/cli/review.d.ts +17 -2
  64. package/dist/cli/review.js +39 -10
  65. package/dist/cli/review.js.map +1 -1
  66. package/dist/cli/revise.d.ts +2 -0
  67. package/dist/cli/revise.js +7 -2
  68. package/dist/cli/revise.js.map +1 -1
  69. package/dist/cli/shape.d.ts +27 -0
  70. package/dist/cli/shape.js +77 -0
  71. package/dist/cli/shape.js.map +1 -0
  72. package/dist/cli/sliceIndex.d.ts +6 -1
  73. package/dist/cli/sliceIndex.js +28 -13
  74. package/dist/cli/sliceIndex.js.map +1 -1
  75. package/dist/cli/sliceNew.d.ts +4 -1
  76. package/dist/cli/sliceNew.js +26 -9
  77. package/dist/cli/sliceNew.js.map +1 -1
  78. package/dist/cli/status.d.ts +21 -1
  79. package/dist/cli/status.js +25 -3
  80. package/dist/cli/status.js.map +1 -1
  81. package/dist/cli/storylineSuggest.d.ts +26 -0
  82. package/dist/cli/storylineSuggest.js +252 -0
  83. package/dist/cli/storylineSuggest.js.map +1 -0
  84. package/dist/cli/upgrade.d.ts +15 -3
  85. package/dist/cli/upgrade.js +516 -7
  86. package/dist/cli/upgrade.js.map +1 -1
  87. package/dist/cli/upgradeReshape.d.ts +60 -0
  88. package/dist/cli/upgradeReshape.js +425 -0
  89. package/dist/cli/upgradeReshape.js.map +1 -0
  90. package/dist/cli.js +311 -35
  91. package/dist/cli.js.map +1 -1
  92. package/dist/emit/dot.d.ts +2 -0
  93. package/dist/emit/dot.js +44 -8
  94. package/dist/emit/dot.js.map +1 -1
  95. package/dist/emit/engagementJson.d.ts +4 -3
  96. package/dist/emit/engagementJson.js +11 -4
  97. package/dist/emit/engagementJson.js.map +1 -1
  98. package/dist/emit/json.d.ts +21 -1
  99. package/dist/emit/json.js +26 -2
  100. package/dist/emit/json.js.map +1 -1
  101. package/dist/emit/queryJson.d.ts +1 -1
  102. package/dist/emit/queryJson.js +3 -1
  103. package/dist/emit/queryJson.js.map +1 -1
  104. package/dist/emit/statusJson.d.ts +1 -1
  105. package/dist/emit/statusJson.js +6 -1
  106. package/dist/emit/statusJson.js.map +1 -1
  107. package/dist/emit/theme.d.ts +1 -0
  108. package/dist/emit/theme.js +13 -0
  109. package/dist/emit/theme.js.map +1 -1
  110. package/dist/emit/validateJson.d.ts +9 -5
  111. package/dist/emit/validateJson.js +40 -5
  112. package/dist/emit/validateJson.js.map +1 -1
  113. package/dist/layout/grid.d.ts +5 -1
  114. package/dist/layout/grid.js +93 -2
  115. package/dist/layout/grid.js.map +1 -1
  116. package/dist/mcp/server.js +46 -11
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/model/edges.d.ts +7 -0
  119. package/dist/model/edges.js +33 -16
  120. package/dist/model/edges.js.map +1 -1
  121. package/dist/model/model.d.ts +33 -0
  122. package/dist/model/model.js +87 -3
  123. package/dist/model/model.js.map +1 -1
  124. package/dist/model/queryIndex.js +2 -2
  125. package/dist/model/queryIndex.js.map +1 -1
  126. package/dist/model/refs.js +8 -5
  127. package/dist/model/refs.js.map +1 -1
  128. package/dist/model/rules.d.ts +149 -0
  129. package/dist/model/rules.js +144 -0
  130. package/dist/model/rules.js.map +1 -1
  131. package/dist/model/shapeLint.d.ts +12 -0
  132. package/dist/model/shapeLint.js +170 -0
  133. package/dist/model/shapeLint.js.map +1 -0
  134. package/dist/model/storylines.d.ts +51 -0
  135. package/dist/model/storylines.js +188 -0
  136. package/dist/model/storylines.js.map +1 -0
  137. package/dist/model/validate.d.ts +6 -1
  138. package/dist/model/validate.js +317 -14
  139. package/dist/model/validate.js.map +1 -1
  140. package/dist/parser/againSugar.d.ts +30 -0
  141. package/dist/parser/againSugar.js +105 -0
  142. package/dist/parser/againSugar.js.map +1 -0
  143. package/dist/parser/ast.d.ts +33 -0
  144. package/dist/parser/lexer.d.ts +2 -0
  145. package/dist/parser/lexer.js +4 -0
  146. package/dist/parser/lexer.js.map +1 -1
  147. package/dist/parser/parser.js +284 -4
  148. package/dist/parser/parser.js.map +1 -1
  149. package/dist/query/format.js +1 -0
  150. package/dist/query/format.js.map +1 -1
  151. package/dist/query/verbs.d.ts +4 -0
  152. package/dist/query/verbs.js +30 -3
  153. package/dist/query/verbs.js.map +1 -1
  154. package/dist/render/drawEdges.js +100 -4
  155. package/dist/render/drawEdges.js.map +1 -1
  156. package/dist/render/render.js +20 -2
  157. package/dist/render/render.js.map +1 -1
  158. package/dist/render/sliceDiagram.js +5 -5
  159. package/dist/render/sliceDiagram.js.map +1 -1
  160. package/dist/render/storylineBands.d.ts +11 -0
  161. package/dist/render/storylineBands.js +80 -0
  162. package/dist/render/storylineBands.js.map +1 -0
  163. package/dist/render/storylineDiagram.d.ts +14 -0
  164. package/dist/render/storylineDiagram.js +142 -0
  165. package/dist/render/storylineDiagram.js.map +1 -0
  166. package/dist/roster/check.d.ts +32 -0
  167. package/dist/roster/check.js +92 -0
  168. package/dist/roster/check.js.map +1 -0
  169. package/dist/roster/context.d.ts +3 -0
  170. package/dist/roster/context.js +23 -0
  171. package/dist/roster/context.js.map +1 -0
  172. package/dist/roster/init.d.ts +15 -0
  173. package/dist/roster/init.js +52 -0
  174. package/dist/roster/init.js.map +1 -0
  175. package/dist/roster/roster.d.ts +79 -0
  176. package/dist/roster/roster.js +298 -0
  177. package/dist/roster/roster.js.map +1 -0
  178. package/package.json +1 -1
  179. package/plugin/.claude-plugin/plugin.json +2 -2
  180. package/plugin/agents/em-implementer.md +7 -4
  181. package/plugin/agents/em-validator.md +2 -2
  182. package/plugin/shared/reference/em-dsl.md +94 -6
  183. package/plugin/shared/reference/methodology.md +268 -0
  184. package/plugin/shared/reference/slice-doc-schema.md +96 -2
  185. package/plugin/shared/templates/slice.md +11 -3
  186. package/plugin/skills/conform/SKILL.md +1 -1
  187. package/plugin/skills/design/SKILL.md +46 -5
  188. package/plugin/skills/discover/SKILL.md +43 -8
  189. package/plugin/skills/discover/reference/extract.md +6 -1
  190. package/plugin/skills/discover/reference/prd-rubric.md +103 -0
  191. package/plugin/skills/discover/reference/prd-spec-path.md +172 -0
  192. package/plugin/skills/engagement/SKILL.md +21 -9
  193. package/plugin/skills/event-modeling/SKILL.md +1 -1
  194. package/plugin/skills/implement/SKILL.md +1 -1
  195. package/plugin/skills/implement/reference/implement.md +18 -0
  196. package/plugin/skills/review/SKILL.md +4 -2
@@ -21,8 +21,9 @@
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, realpathSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
24
+ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
25
25
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
26
+ import { findRosterAbove, GRANDFATHERED_RATIFIER } from "../roster/roster.js";
26
27
  import { PUBLIC_SCALAR_TYPE_NAMES } from "../model/model.js";
27
28
  import { findUnresolvedPublicFieldTypes } from "../model/validate.js";
28
29
  import { realGit } from "./diff-inputs.js";
@@ -38,11 +39,17 @@ import { findSpecifyRoot, locateConstitution } from "./status.js";
38
39
  import { scaffoldConstitution, scaffoldStateFile } from "../templates.js";
39
40
  import { loadStateFile, parseState, setEmVersion, ensureOptionalBullets, resolveStateFilePath } from "./stateFile.js";
40
41
  import { fieldLineRegex, normalizeFieldValue, locateFrontmatterInner, replaceFieldValue } from "./frontmatterSurgery.js";
41
- import { ratifiedHashScope, sliceDocBodyHash } from "../catalog/sliceDoc.js";
42
- import { applyDeferOrAppend, drainQueueInState, finishSignedDocEdit, isSignedDoc, readQueue } from "./defer.js";
42
+ import { parseSliceDoc, ratifiedHashScope, sliceDocBodyHash } from "../catalog/sliceDoc.js";
43
+ import { applyDeferOrAppend, drainQueueInState, finishSignedDocEdit, isSignedDoc, readQueue, rewriteParkingLotFragments } from "./defer.js";
44
+ import { runSliceSync } from "./sliceSync.js";
45
+ import { runSliceIndex } from "./sliceIndex.js";
46
+ import { planCollapseAlternativeEvents, planFoldContinuations, planStackContinuations } from "./upgradeReshape.js";
47
+ import { observeSigners } from "../roster/init.js";
43
48
  import { STATE_FILE_NAME } from "./stateFile.js";
44
49
  import { localIsoDate } from "../util/localDate.js";
45
50
  import { compile } from "../pipeline.js";
51
+ import { computeModelKey } from "../model/qualifiedRef.js";
52
+ import { contractPathFor, generateContract } from "./api.js";
46
53
  import { hasErrors } from "../model/validate.js";
47
54
  import { LEGACY_SYSTEM_MANIFEST_SCHEMA_VERSION, parseManifest } from "../system/manifest.js";
48
55
  import { planManifestMigration } from "../system/migrateManifest.js";
@@ -277,7 +284,10 @@ function ciBlockFiles(args, ctx) {
277
284
  };
278
285
  }
279
286
  return {
280
- ci: [buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion), ciManagedBody(args.model, args.testsDir, ctx.installedVersion)],
287
+ ci: [
288
+ buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion, findRosterAbove(dirname(resolve(ctx.repoRoot, args.model))) !== null),
289
+ ciManagedBody(args.model, args.testsDir, ctx.installedVersion, findRosterAbove(dirname(resolve(ctx.repoRoot, args.model))) !== null),
290
+ ],
281
291
  conform: [buildConformWorkflowFile(args.model, ctx.installedVersion, usePlugin), conformManagedBody(args.model, ctx.installedVersion, usePlugin)],
282
292
  };
283
293
  }
@@ -333,6 +343,82 @@ function applyCiBlock(ctx) {
333
343
  }
334
344
  return { ok: true, changedFiles };
335
345
  }
346
+ /** Every model covered by an `api-check` job whose `contracts/<model key>.tsp` is absent. The job
347
+ * counts when it is in the on-disk em-ci.yml OR when `ci-block` is pending (a stale block is
348
+ * rewritten to the current preset, which carries `api-check` - so a pre-1.12 workflow is caught by
349
+ * the dry run, not only after `--apply`). Models that can't be read or compile cleanly are
350
+ * returned in `blocked` (never skipped silently). */
351
+ function contractPlan(ctx) {
352
+ const none = { missing: [], blocked: [] };
353
+ const ciPath = ciWorkflowPath(ctx.repoRoot);
354
+ if (!existsSync(ciPath))
355
+ return none;
356
+ const text = readFileSync(ciPath, "utf8");
357
+ const plans = ciBlockPlans(ctx);
358
+ const hasJob = /^ {2}api-check(-[\w-]+)?:/m.test(text) || plans.ciStale;
359
+ if (!hasJob || !plans.args)
360
+ return none;
361
+ const paths = plans.args.models ? plans.args.models.map((m) => m.path) : [plans.args.model];
362
+ const missing = [];
363
+ const blocked = [];
364
+ for (const rel of paths) {
365
+ const modelFile = resolve(ctx.repoRoot, rel);
366
+ let source;
367
+ try {
368
+ source = readFileSync(modelFile, "utf8");
369
+ }
370
+ catch {
371
+ blocked.push({ model: rel, why: "the model file can't be read" });
372
+ continue;
373
+ }
374
+ try {
375
+ const compiled = compile(source);
376
+ if (hasErrors(compiled.diagnostics)) {
377
+ // Only blocked when the contract is actually absent (a model mid-edit that already has one is fine).
378
+ if (!existsSync(contractPathFor(modelFile, computeModelKey(compiled.model, modelFile))))
379
+ blocked.push({ model: rel, why: "it has compile errors" });
380
+ continue;
381
+ }
382
+ const g = generateContract(modelFile, source, compiled);
383
+ if (!existsSync(g.contractPath))
384
+ missing.push({ modelFile, contractPath: g.contractPath, modelKey: g.modelKey });
385
+ }
386
+ catch (e) {
387
+ blocked.push({ model: rel, why: `it does not parse (${e.message})` });
388
+ }
389
+ }
390
+ return { missing, blocked };
391
+ }
392
+ function blockedText(blocked) {
393
+ return blocked.map((b) => `${b.model} (${b.why})`).join("; ");
394
+ }
395
+ function detectApiContractMissing(ctx) {
396
+ const { missing, blocked } = contractPlan(ctx);
397
+ if (missing.length === 0) {
398
+ if (blocked.length)
399
+ return { applicable: false, reason: `the api-check contract could not be generated for ${blockedText(blocked)} - see the api-contract-blocked item` };
400
+ return { applicable: false, reason: "no api-check job lacking its contracts/<model key>.tsp" };
401
+ }
402
+ const skipped = blocked.length ? `; not generated for ${blockedText(blocked)} (see the api-contract-blocked item)` : "";
403
+ return {
404
+ applicable: true,
405
+ reason: `the api-check job needs ${missing.map((m) => relative(ctx.repoRoot, m.contractPath)).join(", ")}, which ${missing.length === 1 ? "is" : "are"} missing - em upgrade will run em api generate${skipped}`,
406
+ };
407
+ }
408
+ function applyApiContractMissing(ctx) {
409
+ const { missing } = contractPlan(ctx);
410
+ if (missing.length === 0)
411
+ return { ok: false, message: "nothing to generate" };
412
+ const changedFiles = [];
413
+ for (const m of missing) {
414
+ const source = readFileSync(m.modelFile, "utf8");
415
+ const generated = generateContract(m.modelFile, source, compile(source));
416
+ mkdirSync(dirname(generated.contractPath), { recursive: true });
417
+ writeFileSync(generated.contractPath, generated.text);
418
+ changedFiles.push(generated.contractPath);
419
+ }
420
+ return { ok: true, changedFiles };
421
+ }
336
422
  // ---- Step 5: constitution — scaffold a draft when absent and no .specify/ owns the slot. ----
337
423
  function detectConstitution(ctx) {
338
424
  const existing = locateConstitution(ctx.baseDir);
@@ -363,7 +449,7 @@ function applyConstitution(ctx) {
363
449
  // reratified doc has `version > 1` and is mid-sign-off, which only a human can resolve; see the
364
450
  // `ready-to-implement-no-ratifiedby` human item) with no `ratifiedBy`. A doc with no `version:`
365
451
  // at all is treated as version 1 (the schema's default). ----
366
- export const GRANDFATHERED_RATIFIER = "grandfathered (unsigned before em 1.14)";
452
+ export { GRANDFATHERED_RATIFIER };
367
453
  /** Pure text transform: inserts the grandfather sign-off after the `status:` line, using only
368
454
  * the key lines that are missing (a stray `ratifiedOn:` is kept as-is), in the file's own EOL.
369
455
  * `null` when the doc isn't a candidate (no frontmatter/status, or `ratifiedBy` already set). */
@@ -547,6 +633,298 @@ function applyRatifiedHashScope(ctx) {
547
633
  }
548
634
  return { ok: true, changedFiles };
549
635
  }
636
+ // ---- Step 8-: collapse-alternative-events (GH #290 / #278) and fold-continuations (GH #290 / #287) ----
637
+ // The two steps that rewrite the `.em` itself (planned by upgradeReshape.ts: text surgery, verified by
638
+ // recompiling before anything is written). They run before the doc/index steps, which re-read the
639
+ // model `applyUpgrade` recompiles after each step. `fold-continuations` is OPT-IN (see below).
640
+ function readModelText(ctx) {
641
+ return readFileSync(ctx.modelFile, "utf8");
642
+ }
643
+ function freshCompile(ctx) {
644
+ try {
645
+ const c = compile(readModelText(ctx));
646
+ return { model: c.model, refs: c.refs };
647
+ }
648
+ catch {
649
+ return null;
650
+ }
651
+ }
652
+ /** Signed docs (ratified or shipped) whose generated regions would be stale against `nextText`, i.e. that
653
+ * a rewrite leaves behind a signature the regions no longer match. Read-only. */
654
+ function signedDocsStaleAfter(ctx, nextText) {
655
+ const staleSigned = (src) => {
656
+ const out = new Map();
657
+ let c;
658
+ try {
659
+ c = compile(src);
660
+ }
661
+ catch {
662
+ return out;
663
+ }
664
+ const r = runSliceSync(c.model, c.refs, ctx.baseDir, undefined, true);
665
+ if (!r.ok)
666
+ return out;
667
+ for (const d of r.docs) {
668
+ if (d.status === "stale" && isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8"))) {
669
+ out.set(d.path, `${d.path} (${d.regions.filter((g) => g.status === "stale").map((g) => g.name).join(", ")})`);
670
+ }
671
+ }
672
+ return out;
673
+ };
674
+ const before = staleSigned(readModelText(ctx));
675
+ return [...staleSigned(nextText)].filter(([path]) => !before.has(path)).map(([, label]) => label); // newly stale only
676
+ }
677
+ const CARRIED_SECTIONS = /^##\s+(Scenarios|Invariants|Open Questions|Decisions(?: log)?)\s*$/i;
678
+ /** Sections of a retiring doc whose AUTHORED content a human must carry to the kept doc (generated
679
+ * regions are rebuilt from the model, so they are not content). */
680
+ function carriedSections(raw) {
681
+ const body = raw.replace(/<!-- GENERATED:[^>]*:start[^>]*-->[\s\S]*?<!-- GENERATED:[^>]*:end -->/g, "");
682
+ const lines = body.split("\n");
683
+ const found = [];
684
+ for (let i = 0; i < lines.length; i++) {
685
+ const m = CARRIED_SECTIONS.exec(lines[i]);
686
+ if (!m)
687
+ continue;
688
+ let j = i + 1;
689
+ let content = false;
690
+ for (; j < lines.length && !/^#{1,2}\s/.test(lines[j]); j++)
691
+ if (lines[j].trim() !== "" && !lines[j].includes("{{"))
692
+ content = true;
693
+ if (content)
694
+ found.push(m[1]);
695
+ }
696
+ return found;
697
+ }
698
+ /** Where a collapsed slice's retired doc goes: beside the model, outside `slices/` (so it is not an
699
+ * orphaned slice doc), keeping its file name. */
700
+ const ARCHIVE_DIR = join("archive", "slices");
701
+ function retiredDocs(ctx, groups) {
702
+ const out = [];
703
+ const refOf = elementRefOf(ctx);
704
+ const joinOf = (key) => {
705
+ const i = ctx.refs.sliceKeys.indexOf(key);
706
+ return i === -1 ? null : resolveSliceDocJoin(ctx.model, ctx.refs, ctx.model.slices[i], key, ctx.baseDir, refOf).doc;
707
+ };
708
+ for (const g of groups) {
709
+ const kept = joinOf(g.keys[0]);
710
+ const keptDoc = kept && kept.found && kept.reason === null ? kept.path : null;
711
+ const keptVersion = kept && keptDoc ? kept.version : null;
712
+ for (const key of g.keys.slice(1)) {
713
+ const bound = joinOf(key);
714
+ const rel = bound && bound.found && bound.reason === null ? bound.path : `slices/${key}.md`;
715
+ const from = join(ctx.baseDir, rel);
716
+ if (!existsSync(from) || (keptDoc !== null && rel === keptDoc))
717
+ continue;
718
+ const raw = readFileSync(from, "utf8");
719
+ const range = locateFrontmatterInner(raw);
720
+ const retiredVersion = Number(/^version:\s*(\d+)/m.exec(raw)?.[1] ?? "1");
721
+ const target = keptDoc !== null ? basename(keptDoc, ".md") : g.keys[0];
722
+ let next = raw;
723
+ if (range) {
724
+ const fenceEnd = raw.indexOf("\n", range.innerEnd) + 1 || raw.length;
725
+ const banner = `\n> **SUPERSEDED** by \`${target}\` (\`superseded-by\`): this slice was collapsed into one recording alternative events (GH #278). Kept for history; fold anything worth keeping into \`${keptDoc ?? `slices/${g.keys[0]}.md`}\`.\n`;
726
+ next = raw.slice(0, range.innerEnd) + `superseded-by: ${target}@v${keptVersion ?? 1}\n` + raw.slice(range.innerEnd, fenceEnd) + banner + raw.slice(fenceEnd);
727
+ }
728
+ out.push({ from, to: join(ctx.baseDir, ARCHIVE_DIR, basename(rel)), key, keptKey: g.keys[0], keptDoc, keptVersion, retiredVersion, text: next, carried: carriedSections(raw) });
729
+ }
730
+ }
731
+ return out;
732
+ }
733
+ function carriedNotes(retired) {
734
+ return retired.filter((r) => r.carried.length > 0).map((r) => `${ARCHIVE_DIR}/${basename(r.to)} holds ${r.carried.join(", ")} — fold these into ${r.keptDoc ?? `slices/${r.keptKey}.md`}`);
735
+ }
736
+ function detectCollapseAlternativeEvents(ctx) {
737
+ const fresh = freshCompile(ctx);
738
+ const plan = fresh ? planCollapseAlternativeEvents(fresh.model, fresh.refs, readModelText(ctx)) : null;
739
+ if (plan === null)
740
+ return { applicable: false, reason: "no run of adjacent slices repeats one command and differs only in the recorded event" };
741
+ const desc = plan.groups.map((g) => `${g.slices.map((n) => `"${n}"`).join(" + ")} → "${g.slices[0]}" recording ${g.form === "inline" ? g.events.join(" | ") : `oneof { ${g.events.join(", ")} }`}`);
742
+ const retired = retiredDocs(ctx, plan.groups);
743
+ const notes = carriedNotes(retired);
744
+ const stale = signedDocsStaleAfter(ctx, plan.next);
745
+ return {
746
+ applicable: true,
747
+ reason: `${plan.groups.length} run(s) of repeated-command slices collapse to alternative events: ${desc.join("; ")} — em upgrade will rewrite them (the first slice's key is kept; the others' docs move to ${ARCHIVE_DIR}/ with superseded-by and a SUPERSEDED banner, and the kept doc gains merged-from)` +
748
+ (notes.length > 0 ? `; ${notes.join("; ")}` : "") +
749
+ (stale.length > 0 ? `; signed doc(s) will not be synced and go stale: ${stale.join(", ")} — em slice revise <key>, then em slice sync` : ""),
750
+ };
751
+ }
752
+ /** Frontmatter-only edit: adds `key: value` before the closing fence (a no-op without frontmatter). */
753
+ function addFrontmatterLine(raw, line) {
754
+ const range = locateFrontmatterInner(raw);
755
+ return range ? raw.slice(0, range.innerEnd) + line + "\n" + raw.slice(range.innerEnd) : raw;
756
+ }
757
+ function applyCollapseAlternativeEvents(ctx) {
758
+ const fresh = freshCompile(ctx);
759
+ const text = readModelText(ctx);
760
+ const plan = fresh ? planCollapseAlternativeEvents(fresh.model, fresh.refs, text) : null;
761
+ if (plan === null)
762
+ return { ok: false, message: "the model no longer collapses cleanly" };
763
+ const changed = [ctx.modelFile];
764
+ const retired = retiredDocs(ctx, plan.groups); // computed on the OLD model (joins, versions)
765
+ const notes = carriedNotes(retired);
766
+ writeFileSync(ctx.modelFile, plan.next, "utf8");
767
+ for (const r of retired) {
768
+ mkdirSync(dirname(r.to), { recursive: true });
769
+ writeFileSync(r.to, r.text, "utf8");
770
+ rmSync(r.from);
771
+ changed.push(r.from, r.to);
772
+ }
773
+ // Lineage back-link on the kept doc: `merged-from: <retired>@v<N>` (frontmatter only, outside the
774
+ // ratified hash). A SIGNED kept doc is not touched; `lineage-backlink-missing` lists it.
775
+ for (const r of retired) {
776
+ if (r.keptDoc === null)
777
+ continue;
778
+ const abs = join(ctx.baseDir, r.keptDoc);
779
+ const raw = readFileSync(abs, "utf8");
780
+ if (isSignedDoc(raw))
781
+ continue;
782
+ const ref = `${basename(r.to, ".md")}@v${r.retiredVersion}`;
783
+ const existing = /^merged-from:\s*(.*)$/m.exec(raw);
784
+ const next = existing ? raw.replace(/^merged-from:\s*(.*)$/m, (_m, v) => `merged-from: ${String(v).trim() === "" ? ref : `${String(v).trim()}, ${ref}`}`) : addFrontmatterLine(raw, `merged-from: ${ref}`);
785
+ writeFileSync(abs, next, "utf8");
786
+ if (!changed.includes(abs))
787
+ changed.push(abs);
788
+ }
789
+ // Regenerate the kept slice's event region (one `Records one of:` region now) unless its doc is signed
790
+ // (then `slice-doc-stale-signed` is the human item that says what to do).
791
+ const after = freshCompile(ctx);
792
+ if (after) {
793
+ for (const g of plan.groups) {
794
+ const kept = retired.find((r) => r.keptKey === g.keys[0])?.keptDoc ?? `slices/${g.keys[0]}.md`;
795
+ const doc = join(ctx.baseDir, kept);
796
+ if (existsSync(doc) && !isSignedDoc(readFileSync(doc, "utf8"))) {
797
+ const r = runSliceSync(after.model, after.refs, ctx.baseDir, g.keys[0], false);
798
+ if (r.ok && r.docs.some((d) => d.status === "synced") && !changed.includes(doc))
799
+ changed.push(doc);
800
+ }
801
+ }
802
+ }
803
+ return { ok: true, changedFiles: changed, notes };
804
+ }
805
+ // ---- fold-continuations is OPT-IN (`em upgrade --apply --fold-continuations`) ----
806
+ // Default form (a): consecutive continuations of ONE view stack into the first slice (its key, name,
807
+ // `continuationOf`, doc join and index row stay). `--to-sugar` instead moves them into the host
808
+ // command slice as `view X again … shown on` lines; that CHANGES `continuationOf` and the doc join to
809
+ // the host (`<host>--again`) and removes the index rows.
810
+ const FOLD_FLAG = "em upgrade --apply --fold-continuations";
811
+ function foldPlan(ctx) {
812
+ const fresh = freshCompile(ctx);
813
+ if (!fresh)
814
+ return { kind: "stack", plan: null };
815
+ const text = readModelText(ctx);
816
+ return ctx.toSugar === true
817
+ ? { kind: "sugar", plan: planFoldContinuations(fresh.model, fresh.refs, text) }
818
+ : { kind: "stack", plan: planStackContinuations(fresh.model, fresh.refs, text) };
819
+ }
820
+ function detectFoldContinuations(ctx) {
821
+ const f = foldPlan(ctx);
822
+ if (f.plan === null)
823
+ return { applicable: false, reason: "no consecutive hand-named continuation slices of one view (stack) — or, with --to-sugar, no continuation directly after its host command — in a foldable shape" };
824
+ if (f.kind === "sugar" && f.plan.groups.length === 0) {
825
+ const r = f.plan.refused;
826
+ return { applicable: false, reason: `--to-sugar: nothing folded; refused: ${r.map((x) => `${x.hostKey} (${x.reason})`).join("; ")}` };
827
+ }
828
+ const optIn = ctx.foldContinuations === true || ctx.toSugar === true;
829
+ const prefix = optIn ? "" : `OPT-IN, not applied by a plain --apply (run \`${FOLD_FLAG}\`): `;
830
+ const stale = signedDocsStaleAfter(ctx, f.plan.next);
831
+ const staleNote = stale.length > 0 ? `; signed doc(s) whose generated regions (e.g. the view's \`via\` annotations) go stale and will not be synced: ${stale.join(", ")} — em slice revise <key>, then em slice sync` : "";
832
+ if (f.kind === "stack") {
833
+ const p = f.plan;
834
+ const desc = p.groups.map((g) => `${[g.kept, ...g.dropped].map((n) => `"${n}"`).join(" + ")} → one continuation slice "${g.kept}" (key ${g.keptKey} kept; dropped keys: ${g.droppedKeys.join(", ")})`);
835
+ const skipped = p.skipped.length > 0 ? `; left unstacked: ${p.skipped.map((s) => `${s.key} (${s.reason})`).join(", ")}` : "";
836
+ return { applicable: true, reason: `${prefix}${desc.join("; ")} — continuationOf, the doc join and the kept key's index row are unchanged${skipped}${staleNote}` };
837
+ }
838
+ const p = f.plan;
839
+ const desc = p.groups.map((g) => `${g.folded.map((n) => `"${n}"`).join(" + ")} (keys ${g.foldedKeys.join(", ")}) → \`view … again\` lines in "${g.host}" (column ${g.hostKey}--again)`);
840
+ const refusedNote = p.refused.length > 0 ? `; refused: ${p.refused.map((x) => `${x.hostKey} (${x.reason})`).join("; ")}` : "";
841
+ return { applicable: true, reason: `${prefix}--to-sugar: ${desc.join("; ")}${refusedNote} — CHANGES continuationOf and the doc join to the host (${p.groups.map((g) => `${g.hostKey}--again`).join(", ")}) and removes the index rows of ${p.groups.flatMap((g) => g.foldedKeys).join(", ")} (a stray own doc of a folded slice is left alone)${staleNote}` };
842
+ }
843
+ function applyFoldContinuations(ctx) {
844
+ const f = foldPlan(ctx);
845
+ if (f.plan === null || ("groups" in f.plan && f.plan.groups.length === 0))
846
+ return { ok: false, message: "the model no longer folds cleanly" };
847
+ writeFileSync(ctx.modelFile, f.plan.next, "utf8");
848
+ return { ok: true, changedFiles: [ctx.modelFile] };
849
+ }
850
+ function staleViewDocs(ctx) {
851
+ const open = [];
852
+ const signed = [];
853
+ const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, undefined, true);
854
+ if (!r.ok)
855
+ return { open, signed };
856
+ for (const d of r.docs) {
857
+ if (d.status !== "stale" || !d.regions.some((g) => g.status === "stale" && g.name.startsWith("em-slice-view")))
858
+ continue;
859
+ (isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8")) ? signed : open).push({ key: d.key, path: d.path });
860
+ }
861
+ return { open, signed };
862
+ }
863
+ function detectViewDocSourcesStale(ctx) {
864
+ const { open, signed } = staleViewDocs(ctx);
865
+ const left = signed.length > 0 ? `; left alone (ratified or shipped — em slice revise, then em slice sync): ${signed.map((d) => d.path).join(", ")}` : "";
866
+ if (open.length === 0) {
867
+ return { applicable: false, reason: `no bound slice doc's view region is stale against the model${left}` };
868
+ }
869
+ return {
870
+ applicable: true,
871
+ reason: `em slice sync --check would change the view region of ${open.length} doc(s): ${open.map((d) => d.path).join(", ")} — em upgrade will run em slice sync on them${left}`,
872
+ };
873
+ }
874
+ function applyViewDocSourcesStale(ctx) {
875
+ const { open } = staleViewDocs(ctx);
876
+ const changedFiles = [];
877
+ for (const d of open) {
878
+ const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, d.key, false);
879
+ if (!r.ok)
880
+ return { ok: false, message: r.message };
881
+ changedFiles.push(join(ctx.baseDir, d.path));
882
+ }
883
+ return { ok: true, changedFiles };
884
+ }
885
+ // ---- Step 8b: slice-index-stale (GH #290 / #275) — the README Slices table. ----
886
+ function detectSliceIndexStale(ctx) {
887
+ const r = runSliceIndex(ctx.model, ctx.refs, ctx.modelFile, true);
888
+ if (r.ok)
889
+ return { applicable: false, reason: "the README Slices table is current" };
890
+ if (r.kind === "stale")
891
+ return { applicable: true, reason: `${join(ctx.baseDir, "README.md")}'s Slices table is stale (em slice index --check) — em upgrade will regenerate it` };
892
+ return { applicable: false, reason: r.kind === "missing-readme" ? "no README.md beside the model — nothing to index" : "README.md has no GENERATED:slices markers — nothing to index" };
893
+ }
894
+ function applySliceIndexStale(ctx) {
895
+ const r = runSliceIndex(ctx.model, ctx.refs, ctx.modelFile, false);
896
+ if (!r.ok)
897
+ return { ok: false, message: r.message };
898
+ return { ok: true, changedFiles: r.wrote ? [r.readmePath] : [] };
899
+ }
900
+ // ---- Step 8c: parking-lot-fragments (GH #290 / #283) — substring bullets become the item's label. ----
901
+ function parkingLotDocs(ctx) {
902
+ const docs = new Map();
903
+ for (const d of nonContinuationDocs(ctx)) {
904
+ const file = join(ctx.baseDir, d.path);
905
+ if (d.path !== "" && existsSync(file))
906
+ docs.set(d.key, readFileSync(file, "utf8"));
907
+ }
908
+ return docs;
909
+ }
910
+ function detectParkingLotFragments(ctx) {
911
+ const statePath = join(ctx.baseDir, STATE_FILE_NAME);
912
+ if (!existsSync(statePath))
913
+ return { applicable: false, reason: `no ${STATE_FILE_NAME} beside the model` };
914
+ const { rewrites } = rewriteParkingLotFragments(readFileSync(statePath, "utf8"), parkingLotDocs(ctx));
915
+ if (rewrites.length === 0)
916
+ return { applicable: false, reason: "no parking-lot bullet is a fragment of an Open Questions item" };
917
+ return {
918
+ applicable: true,
919
+ reason: `${rewrites.length} parking-lot bullet(s) hold a fragment of an Open Questions item (the pre-1.18 em slice defer shape): ${rewrites.map((r) => `"${r.from}" → "${r.to}"`).join("; ")} — em upgrade will rewrite each to the item's label`,
920
+ };
921
+ }
922
+ function applyParkingLotFragments(ctx) {
923
+ const statePath = join(ctx.baseDir, STATE_FILE_NAME);
924
+ const { content } = rewriteParkingLotFragments(readFileSync(statePath, "utf8"), parkingLotDocs(ctx));
925
+ writeFileSync(statePath, content, "utf8");
926
+ return { ok: true, changedFiles: [statePath] };
927
+ }
550
928
  // ---- Step 8: system-manifest (MIL-235, R3) — system.yaml 1.0 -> 2.0. ----
551
929
  // The one mechanical step that writes OTHER models' files: the legacy manifest's seams become
552
930
  // `consumes` clauses on each consuming translation and its owners become `owner "…"` on each
@@ -645,9 +1023,15 @@ export const UPGRADE_STEPS = [
645
1023
  { id: "reaction-shape", sinceVersion: "1.8.0", detect: detectReactionShape, apply: applyReactionShape },
646
1024
  { id: "state-file", sinceVersion: "1.13.0", detect: detectStateFile, apply: applyStateFile },
647
1025
  { id: "ci-block", sinceVersion: "1.9.0", detect: detectCiBlock, apply: applyCiBlock },
1026
+ { id: "api-contract-missing", sinceVersion: "1.18.1", detect: detectApiContractMissing, apply: applyApiContractMissing },
648
1027
  { id: "constitution", sinceVersion: "1.11.0", detect: detectConstitution, apply: applyConstitution },
649
1028
  { id: "ratified-signoff", sinceVersion: "1.14.0", detect: detectRatifiedSignoff, apply: applyRatifiedSignoff },
650
1029
  { id: "ratified-hash-scope", sinceVersion: "1.17.0", detect: detectRatifiedHashScope, apply: applyRatifiedHashScope },
1030
+ { id: "collapse-alternative-events", sinceVersion: "1.18.0", detect: detectCollapseAlternativeEvents, apply: applyCollapseAlternativeEvents },
1031
+ { id: "fold-continuations", sinceVersion: "1.18.0", detect: detectFoldContinuations, apply: applyFoldContinuations, optedIn: (c) => c.foldContinuations === true || c.toSugar === true },
1032
+ { id: "view-doc-sources-stale", sinceVersion: "1.18.0", detect: detectViewDocSourcesStale, apply: applyViewDocSourcesStale },
1033
+ { id: "slice-index-stale", sinceVersion: "1.18.0", detect: detectSliceIndexStale, apply: applySliceIndexStale },
1034
+ { id: "parking-lot-fragments", sinceVersion: "1.18.0", detect: detectParkingLotFragments, apply: applyParkingLotFragments },
651
1035
  { id: "system-manifest", sinceVersion: "1.14.0", detect: detectSystemManifest, apply: applySystemManifest },
652
1036
  ];
653
1037
  function elementRefOf(ctx) {
@@ -780,6 +1164,111 @@ function detectPluginInstallLocally(ctx) {
780
1164
  }
781
1165
  return null;
782
1166
  }
1167
+ /** GH #290: a SIGNED doc (ratified or shipped) whose generated regions are stale against the model —
1168
+ * `view-doc-sources-stale` and `collapse-alternative-events` leave it alone because syncing would move
1169
+ * its `ratifiedHash`. Raised for any region (view, event, command, invariants). */
1170
+ function detectSliceDocStaleSigned(ctx) {
1171
+ const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, undefined, true);
1172
+ if (!r.ok)
1173
+ return null;
1174
+ const docs = r.docs.filter((d) => d.status === "stale" && isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8")));
1175
+ if (docs.length === 0)
1176
+ return null;
1177
+ return {
1178
+ id: "slice-doc-stale-signed",
1179
+ reason: `${docs.map((d) => `${d.path} (${d.regions.filter((g) => g.status === "stale").map((g) => g.name).join(", ")})`).join("; ")} — ratified or shipped and stale against the model; run \`em slice revise ${docs.map((d) => d.key).join(" / ")}\`, then \`em slice sync\``,
1180
+ };
1181
+ }
1182
+ /** GH #290 / #278: a retired doc under archive/slices/ names its successor in `superseded-by`, but the
1183
+ * successor (a signed doc `em upgrade` did not touch) lacks the `merged-from` back-link. */
1184
+ function detectLineageBacklinkMissing(ctx) {
1185
+ const dir = join(ctx.baseDir, ARCHIVE_DIR);
1186
+ if (!existsSync(dir))
1187
+ return null;
1188
+ const missing = [];
1189
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".md")).sort()) {
1190
+ const retired = parseSliceDoc(readFileSync(join(dir, f), "utf8"));
1191
+ for (const ref of retired.supersededBy) {
1192
+ if (!ref.sliceKey)
1193
+ continue;
1194
+ const target = join(ctx.baseDir, "slices", `${ref.sliceKey}.md`);
1195
+ if (!existsSync(target))
1196
+ continue;
1197
+ const doc = parseSliceDoc(readFileSync(target, "utf8"));
1198
+ const key = basename(f, ".md");
1199
+ if (!doc.mergedFrom.some((m) => m.sliceKey === key))
1200
+ missing.push(`slices/${ref.sliceKey}.md needs merged-from: ${key}@v${retired.version ?? 1}`);
1201
+ }
1202
+ }
1203
+ if (missing.length === 0)
1204
+ return null;
1205
+ return { id: "lineage-backlink-missing", reason: `${missing.join("; ")} (the doc is signed or was edited since; add the line to its frontmatter — it is outside the ratified hash)` };
1206
+ }
1207
+ /** GH #290 / #287: `fold-continuations` is detected and listed by every run but needs the flag. */
1208
+ function detectFoldContinuationsOptIn(ctx) {
1209
+ if (ctx.foldContinuations === true || ctx.toSugar === true)
1210
+ return null;
1211
+ const d = detectFoldContinuations(ctx);
1212
+ return d.applicable ? { id: "fold-continuations-opt-in", reason: d.reason } : null;
1213
+ }
1214
+ function compareVersions(a, b) {
1215
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
1216
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
1217
+ for (let i = 0; i < 3; i++)
1218
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
1219
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
1220
+ return 0;
1221
+ }
1222
+ /** The version the repo is upgrading FROM (recorded `Em version:` bullet, else inferred). */
1223
+ function recordedFromVersion(ctx) {
1224
+ const loaded = loadStateFile(ctx.baseDir);
1225
+ let recorded = null;
1226
+ if (loaded.ok) {
1227
+ const parsed = parseState(loaded.text);
1228
+ if (parsed.ok)
1229
+ recorded = parsed.state.emVersion;
1230
+ }
1231
+ return resolveFromVersion(ctx, recorded).version;
1232
+ }
1233
+ /** GH #290 / #259: signatures are a human decision, so the roster is never generated silently. */
1234
+ function detectRosterNotInitialised(ctx) {
1235
+ if (findRosterAbove(ctx.baseDir) !== null)
1236
+ return null;
1237
+ const { ratifiers } = observeSigners(ctx.model, ctx.refs, ctx.baseDir);
1238
+ if (ratifiers.length === 0)
1239
+ return null;
1240
+ return {
1241
+ id: "roster-not-initialised",
1242
+ reason: `${ratifiers.length} ratifier(s) have signed slice docs (${ratifiers.join(", ")}) and there is no ratifiers.yaml — decide who may sign, then run \`em roster init ${basename(ctx.modelFile)}\` to seed one from those names and review it before committing`,
1243
+ };
1244
+ }
1245
+ /** GH #290 / #279: information only. Since 1.18.0 the same event recorded by several commands (same
1246
+ * name and context) is one logical event and `duplicate-name` no longer fires; nothing to change. */
1247
+ function detectSharedEventNowLegal(ctx) {
1248
+ // The transition only: a repo already stamped 1.18.0+ has seen this note.
1249
+ if (compareVersions(recordedFromVersion(ctx), "1.18.0") >= 0)
1250
+ return null;
1251
+ const shared = new Set();
1252
+ for (const el of ctx.model.elements)
1253
+ if (el.kind === "event" && el.logicalId !== el.id)
1254
+ shared.add(el.name);
1255
+ if (shared.size === 0)
1256
+ return null;
1257
+ return {
1258
+ id: "shared-event-now-legal",
1259
+ reason: `information only: ${[...shared].join(", ")} ${shared.size === 1 ? "is" : "are"} recorded by more than one command — legal since 1.18.0 (one logical event; duplicate-name no longer fires), no model change needed`,
1260
+ };
1261
+ }
1262
+ /** GH #331: the api-check job needs a contract the mechanical step could not generate. */
1263
+ function detectApiContractBlocked(ctx) {
1264
+ const { blocked } = contractPlan(ctx);
1265
+ if (blocked.length === 0)
1266
+ return null;
1267
+ return {
1268
+ id: "api-contract-blocked",
1269
+ reason: `the contract could not be generated for ${blockedText(blocked)}, so the api-check job will fail until it exists - run \`em validate <model>\` to see why, fix it, then \`em api generate <model>\``,
1270
+ };
1271
+ }
783
1272
  const HUMAN_DETECTORS = [
784
1273
  detectNoModelVersion,
785
1274
  detectContinuationHasOwnDoc,
@@ -789,6 +1278,12 @@ const HUMAN_DETECTORS = [
789
1278
  detectPredates16,
790
1279
  detectPublicFieldTypesUnresolved,
791
1280
  detectPluginInstallLocally,
1281
+ detectSliceDocStaleSigned,
1282
+ detectLineageBacklinkMissing,
1283
+ detectFoldContinuationsOptIn,
1284
+ detectSharedEventNowLegal,
1285
+ detectRosterNotInitialised,
1286
+ detectApiContractBlocked,
792
1287
  ];
793
1288
  export function detectHumanItems(ctx) {
794
1289
  return HUMAN_DETECTORS.map((d) => d(ctx)).filter((x) => x !== null);
@@ -884,7 +1379,8 @@ export function detectUpgrade(ctx) {
884
1379
  const from = resolveFromVersion(ctx, recorded);
885
1380
  const steps = UPGRADE_STEPS.map((s) => {
886
1381
  const d = s.detect(ctx);
887
- return { id: s.id, sinceVersion: s.sinceVersion, applicable: d.applicable, human: d.human === true, reason: d.reason };
1382
+ const optIn = s.optedIn !== undefined && !s.optedIn(ctx) && d.applicable;
1383
+ return { id: s.id, sinceVersion: s.sinceVersion, applicable: d.applicable, human: d.human === true, reason: d.reason, ...(optIn ? { optIn: true } : {}) };
888
1384
  });
889
1385
  const human = detectHumanItems(ctx);
890
1386
  return { from, to: ctx.installedVersion, steps, human, stateFileError };
@@ -921,6 +1417,10 @@ export function applyUpgrade(ctx, runGit = realGit) {
921
1417
  applied.push({ id: step.id, applied: false, changedFiles: [], commit: null });
922
1418
  continue;
923
1419
  }
1420
+ if (step.optedIn && !step.optedIn(ctx)) {
1421
+ applied.push({ id: step.id, applied: false, changedFiles: [], commit: null });
1422
+ continue;
1423
+ }
924
1424
  const result = step.apply(ctx);
925
1425
  if (!result.ok) {
926
1426
  gitDiscardAll(ctx.repoRoot, runGit);
@@ -931,8 +1431,17 @@ export function applyUpgrade(ctx, runGit = realGit) {
931
1431
  if (!commitResult.ok) {
932
1432
  return { ok: false, message: `em upgrade: step "${step.id}" applied but failed to commit — ${commitResult.message}`, report, applied };
933
1433
  }
934
- applied.push({ id: step.id, applied: true, changedFiles: result.changedFiles, commit: message });
1434
+ applied.push({ id: step.id, applied: true, changedFiles: result.changedFiles, commit: message, ...(result.notes && result.notes.length > 0 ? { notes: result.notes } : {}) });
1435
+ // A step may have rewritten the model (or its docs): later steps must see the new one.
1436
+ const fresh = freshCompile(ctx);
1437
+ if (fresh) {
1438
+ ctx.model = fresh.model;
1439
+ ctx.refs = fresh.refs;
1440
+ }
935
1441
  }
1442
+ // The pre-apply human list can be stale (a rewritten model, a doc now out of sync): report the upgraded
1443
+ // repo's, computed before the `Em version:` stamp so transition-only notes still show.
1444
+ report.human = detectHumanItems(ctx);
936
1445
  const loaded = loadStateFile(ctx.baseDir);
937
1446
  let emVersionCommit = null;
938
1447
  if (loaded.ok) {