@milehimikey/em 1.17.0 → 1.18.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 (183) hide show
  1. package/.claude/skills/event-modeling/SKILL.md +1 -1
  2. package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
  3. package/.claude/skills/event-modeling-design/SKILL.md +46 -5
  4. package/.claude/skills/event-modeling-discover/SKILL.md +43 -8
  5. package/.claude/skills/event-modeling-discover/reference/extract.md +6 -1
  6. package/.claude/skills/event-modeling-discover/reference/prd-rubric.md +102 -0
  7. package/.claude/skills/event-modeling-discover/reference/prd-spec-path.md +171 -0
  8. package/.claude/skills/event-modeling-engagement/SKILL.md +8 -3
  9. package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
  10. package/.claude/skills/event-modeling-implement/reference/implement.md +8 -0
  11. package/.claude/skills/event-modeling-review/SKILL.md +1 -1
  12. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +90 -5
  13. package/.claude/skills/event-modeling-shared/reference/methodology.md +268 -0
  14. package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +96 -2
  15. package/.claude/skills/event-modeling-shared/templates/slice.md +11 -3
  16. package/.claude-plugin/marketplace.json +2 -2
  17. package/dist/catalog/docModelConsistencyValidate.js +160 -0
  18. package/dist/catalog/docModelConsistencyValidate.js.map +1 -1
  19. package/dist/catalog/invariantsValidate.d.ts +2 -0
  20. package/dist/catalog/invariantsValidate.js +67 -1
  21. package/dist/catalog/invariantsValidate.js.map +1 -1
  22. package/dist/catalog/scenarioQualityValidate.d.ts +4 -0
  23. package/dist/catalog/scenarioQualityValidate.js +148 -0
  24. package/dist/catalog/scenarioQualityValidate.js.map +1 -0
  25. package/dist/catalog/scenarios.d.ts +5 -0
  26. package/dist/catalog/scenarios.js +80 -0
  27. package/dist/catalog/scenarios.js.map +1 -1
  28. package/dist/catalog/sliceReadyValidate.d.ts +8 -0
  29. package/dist/catalog/sliceReadyValidate.js +35 -3
  30. package/dist/catalog/sliceReadyValidate.js.map +1 -1
  31. package/dist/catalog/sliceSections.d.ts +3 -2
  32. package/dist/catalog/sliceSections.js +99 -9
  33. package/dist/catalog/sliceSections.js.map +1 -1
  34. package/dist/cli/ciInit.d.ts +4 -11
  35. package/dist/cli/ciInit.js +24 -6
  36. package/dist/cli/ciInit.js.map +1 -1
  37. package/dist/cli/coverage.d.ts +9 -0
  38. package/dist/cli/coverage.js +18 -0
  39. package/dist/cli/coverage.js.map +1 -1
  40. package/dist/cli/defer.d.ts +28 -0
  41. package/dist/cli/defer.js +87 -4
  42. package/dist/cli/defer.js.map +1 -1
  43. package/dist/cli/engagement.d.ts +2 -2
  44. package/dist/cli/engagement.js +24 -1
  45. package/dist/cli/engagement.js.map +1 -1
  46. package/dist/cli/markImplemented.d.ts +14 -10
  47. package/dist/cli/markImplemented.js +49 -1
  48. package/dist/cli/markImplemented.js.map +1 -1
  49. package/dist/cli/prdTriage.d.ts +109 -0
  50. package/dist/cli/prdTriage.js +378 -0
  51. package/dist/cli/prdTriage.js.map +1 -0
  52. package/dist/cli/render-inputs.d.ts +2 -0
  53. package/dist/cli/render-inputs.js +4 -0
  54. package/dist/cli/render-inputs.js.map +1 -1
  55. package/dist/cli/revise.d.ts +2 -0
  56. package/dist/cli/revise.js +7 -2
  57. package/dist/cli/revise.js.map +1 -1
  58. package/dist/cli/shape.d.ts +27 -0
  59. package/dist/cli/shape.js +77 -0
  60. package/dist/cli/shape.js.map +1 -0
  61. package/dist/cli/sliceIndex.d.ts +6 -1
  62. package/dist/cli/sliceIndex.js +28 -13
  63. package/dist/cli/sliceIndex.js.map +1 -1
  64. package/dist/cli/sliceNew.d.ts +4 -1
  65. package/dist/cli/sliceNew.js +26 -9
  66. package/dist/cli/sliceNew.js.map +1 -1
  67. package/dist/cli/status.d.ts +21 -1
  68. package/dist/cli/status.js +25 -3
  69. package/dist/cli/status.js.map +1 -1
  70. package/dist/cli/storylineSuggest.d.ts +26 -0
  71. package/dist/cli/storylineSuggest.js +252 -0
  72. package/dist/cli/storylineSuggest.js.map +1 -0
  73. package/dist/cli/upgrade.d.ts +15 -3
  74. package/dist/cli/upgrade.js +426 -7
  75. package/dist/cli/upgrade.js.map +1 -1
  76. package/dist/cli/upgradeReshape.d.ts +60 -0
  77. package/dist/cli/upgradeReshape.js +425 -0
  78. package/dist/cli/upgradeReshape.js.map +1 -0
  79. package/dist/cli.js +286 -29
  80. package/dist/cli.js.map +1 -1
  81. package/dist/emit/dot.d.ts +2 -0
  82. package/dist/emit/dot.js +44 -8
  83. package/dist/emit/dot.js.map +1 -1
  84. package/dist/emit/engagementJson.d.ts +1 -1
  85. package/dist/emit/engagementJson.js +3 -1
  86. package/dist/emit/engagementJson.js.map +1 -1
  87. package/dist/emit/json.d.ts +21 -1
  88. package/dist/emit/json.js +26 -2
  89. package/dist/emit/json.js.map +1 -1
  90. package/dist/emit/queryJson.d.ts +1 -1
  91. package/dist/emit/queryJson.js +3 -1
  92. package/dist/emit/queryJson.js.map +1 -1
  93. package/dist/emit/statusJson.d.ts +1 -1
  94. package/dist/emit/statusJson.js +6 -1
  95. package/dist/emit/statusJson.js.map +1 -1
  96. package/dist/emit/theme.d.ts +1 -0
  97. package/dist/emit/theme.js +13 -0
  98. package/dist/emit/theme.js.map +1 -1
  99. package/dist/emit/validateJson.d.ts +5 -2
  100. package/dist/emit/validateJson.js +36 -2
  101. package/dist/emit/validateJson.js.map +1 -1
  102. package/dist/layout/grid.d.ts +5 -1
  103. package/dist/layout/grid.js +93 -2
  104. package/dist/layout/grid.js.map +1 -1
  105. package/dist/mcp/server.js +46 -11
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/edges.d.ts +7 -0
  108. package/dist/model/edges.js +33 -16
  109. package/dist/model/edges.js.map +1 -1
  110. package/dist/model/model.d.ts +33 -0
  111. package/dist/model/model.js +87 -3
  112. package/dist/model/model.js.map +1 -1
  113. package/dist/model/queryIndex.js +2 -2
  114. package/dist/model/queryIndex.js.map +1 -1
  115. package/dist/model/refs.js +8 -5
  116. package/dist/model/refs.js.map +1 -1
  117. package/dist/model/rules.d.ts +149 -0
  118. package/dist/model/rules.js +144 -0
  119. package/dist/model/rules.js.map +1 -1
  120. package/dist/model/shapeLint.d.ts +12 -0
  121. package/dist/model/shapeLint.js +170 -0
  122. package/dist/model/shapeLint.js.map +1 -0
  123. package/dist/model/storylines.d.ts +51 -0
  124. package/dist/model/storylines.js +188 -0
  125. package/dist/model/storylines.js.map +1 -0
  126. package/dist/model/validate.d.ts +6 -1
  127. package/dist/model/validate.js +317 -14
  128. package/dist/model/validate.js.map +1 -1
  129. package/dist/parser/againSugar.d.ts +30 -0
  130. package/dist/parser/againSugar.js +105 -0
  131. package/dist/parser/againSugar.js.map +1 -0
  132. package/dist/parser/ast.d.ts +33 -0
  133. package/dist/parser/lexer.d.ts +2 -0
  134. package/dist/parser/lexer.js +4 -0
  135. package/dist/parser/lexer.js.map +1 -1
  136. package/dist/parser/parser.js +284 -4
  137. package/dist/parser/parser.js.map +1 -1
  138. package/dist/query/format.js +1 -0
  139. package/dist/query/format.js.map +1 -1
  140. package/dist/query/verbs.d.ts +4 -0
  141. package/dist/query/verbs.js +30 -3
  142. package/dist/query/verbs.js.map +1 -1
  143. package/dist/render/drawEdges.js +100 -4
  144. package/dist/render/drawEdges.js.map +1 -1
  145. package/dist/render/render.js +20 -2
  146. package/dist/render/render.js.map +1 -1
  147. package/dist/render/sliceDiagram.js +5 -5
  148. package/dist/render/sliceDiagram.js.map +1 -1
  149. package/dist/render/storylineBands.d.ts +11 -0
  150. package/dist/render/storylineBands.js +80 -0
  151. package/dist/render/storylineBands.js.map +1 -0
  152. package/dist/render/storylineDiagram.d.ts +14 -0
  153. package/dist/render/storylineDiagram.js +142 -0
  154. package/dist/render/storylineDiagram.js.map +1 -0
  155. package/dist/roster/check.d.ts +32 -0
  156. package/dist/roster/check.js +92 -0
  157. package/dist/roster/check.js.map +1 -0
  158. package/dist/roster/context.d.ts +3 -0
  159. package/dist/roster/context.js +23 -0
  160. package/dist/roster/context.js.map +1 -0
  161. package/dist/roster/init.d.ts +15 -0
  162. package/dist/roster/init.js +52 -0
  163. package/dist/roster/init.js.map +1 -0
  164. package/dist/roster/roster.d.ts +79 -0
  165. package/dist/roster/roster.js +298 -0
  166. package/dist/roster/roster.js.map +1 -0
  167. package/package.json +1 -1
  168. package/plugin/.claude-plugin/plugin.json +2 -2
  169. package/plugin/shared/reference/em-dsl.md +90 -5
  170. package/plugin/shared/reference/methodology.md +268 -0
  171. package/plugin/shared/reference/slice-doc-schema.md +96 -2
  172. package/plugin/shared/templates/slice.md +11 -3
  173. package/plugin/skills/conform/SKILL.md +1 -1
  174. package/plugin/skills/design/SKILL.md +46 -5
  175. package/plugin/skills/discover/SKILL.md +43 -8
  176. package/plugin/skills/discover/reference/extract.md +6 -1
  177. package/plugin/skills/discover/reference/prd-rubric.md +103 -0
  178. package/plugin/skills/discover/reference/prd-spec-path.md +172 -0
  179. package/plugin/skills/engagement/SKILL.md +8 -3
  180. package/plugin/skills/event-modeling/SKILL.md +1 -1
  181. package/plugin/skills/implement/SKILL.md +1 -1
  182. package/plugin/skills/implement/reference/implement.md +8 -0
  183. package/plugin/skills/review/SKILL.md +1 -1
@@ -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,8 +39,12 @@ 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";
@@ -277,7 +282,10 @@ function ciBlockFiles(args, ctx) {
277
282
  };
278
283
  }
279
284
  return {
280
- ci: [buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion), ciManagedBody(args.model, args.testsDir, ctx.installedVersion)],
285
+ ci: [
286
+ buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion, findRosterAbove(dirname(resolve(ctx.repoRoot, args.model))) !== null),
287
+ ciManagedBody(args.model, args.testsDir, ctx.installedVersion, findRosterAbove(dirname(resolve(ctx.repoRoot, args.model))) !== null),
288
+ ],
281
289
  conform: [buildConformWorkflowFile(args.model, ctx.installedVersion, usePlugin), conformManagedBody(args.model, ctx.installedVersion, usePlugin)],
282
290
  };
283
291
  }
@@ -363,7 +371,7 @@ function applyConstitution(ctx) {
363
371
  // reratified doc has `version > 1` and is mid-sign-off, which only a human can resolve; see the
364
372
  // `ready-to-implement-no-ratifiedby` human item) with no `ratifiedBy`. A doc with no `version:`
365
373
  // at all is treated as version 1 (the schema's default). ----
366
- export const GRANDFATHERED_RATIFIER = "grandfathered (unsigned before em 1.14)";
374
+ export { GRANDFATHERED_RATIFIER };
367
375
  /** Pure text transform: inserts the grandfather sign-off after the `status:` line, using only
368
376
  * the key lines that are missing (a stray `ratifiedOn:` is kept as-is), in the file's own EOL.
369
377
  * `null` when the doc isn't a candidate (no frontmatter/status, or `ratifiedBy` already set). */
@@ -547,6 +555,298 @@ function applyRatifiedHashScope(ctx) {
547
555
  }
548
556
  return { ok: true, changedFiles };
549
557
  }
558
+ // ---- Step 8-: collapse-alternative-events (GH #290 / #278) and fold-continuations (GH #290 / #287) ----
559
+ // The two steps that rewrite the `.em` itself (planned by upgradeReshape.ts: text surgery, verified by
560
+ // recompiling before anything is written). They run before the doc/index steps, which re-read the
561
+ // model `applyUpgrade` recompiles after each step. `fold-continuations` is OPT-IN (see below).
562
+ function readModelText(ctx) {
563
+ return readFileSync(ctx.modelFile, "utf8");
564
+ }
565
+ function freshCompile(ctx) {
566
+ try {
567
+ const c = compile(readModelText(ctx));
568
+ return { model: c.model, refs: c.refs };
569
+ }
570
+ catch {
571
+ return null;
572
+ }
573
+ }
574
+ /** Signed docs (ratified or shipped) whose generated regions would be stale against `nextText`, i.e. that
575
+ * a rewrite leaves behind a signature the regions no longer match. Read-only. */
576
+ function signedDocsStaleAfter(ctx, nextText) {
577
+ const staleSigned = (src) => {
578
+ const out = new Map();
579
+ let c;
580
+ try {
581
+ c = compile(src);
582
+ }
583
+ catch {
584
+ return out;
585
+ }
586
+ const r = runSliceSync(c.model, c.refs, ctx.baseDir, undefined, true);
587
+ if (!r.ok)
588
+ return out;
589
+ for (const d of r.docs) {
590
+ if (d.status === "stale" && isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8"))) {
591
+ out.set(d.path, `${d.path} (${d.regions.filter((g) => g.status === "stale").map((g) => g.name).join(", ")})`);
592
+ }
593
+ }
594
+ return out;
595
+ };
596
+ const before = staleSigned(readModelText(ctx));
597
+ return [...staleSigned(nextText)].filter(([path]) => !before.has(path)).map(([, label]) => label); // newly stale only
598
+ }
599
+ const CARRIED_SECTIONS = /^##\s+(Scenarios|Invariants|Open Questions|Decisions(?: log)?)\s*$/i;
600
+ /** Sections of a retiring doc whose AUTHORED content a human must carry to the kept doc (generated
601
+ * regions are rebuilt from the model, so they are not content). */
602
+ function carriedSections(raw) {
603
+ const body = raw.replace(/<!-- GENERATED:[^>]*:start[^>]*-->[\s\S]*?<!-- GENERATED:[^>]*:end -->/g, "");
604
+ const lines = body.split("\n");
605
+ const found = [];
606
+ for (let i = 0; i < lines.length; i++) {
607
+ const m = CARRIED_SECTIONS.exec(lines[i]);
608
+ if (!m)
609
+ continue;
610
+ let j = i + 1;
611
+ let content = false;
612
+ for (; j < lines.length && !/^#{1,2}\s/.test(lines[j]); j++)
613
+ if (lines[j].trim() !== "" && !lines[j].includes("{{"))
614
+ content = true;
615
+ if (content)
616
+ found.push(m[1]);
617
+ }
618
+ return found;
619
+ }
620
+ /** Where a collapsed slice's retired doc goes: beside the model, outside `slices/` (so it is not an
621
+ * orphaned slice doc), keeping its file name. */
622
+ const ARCHIVE_DIR = join("archive", "slices");
623
+ function retiredDocs(ctx, groups) {
624
+ const out = [];
625
+ const refOf = elementRefOf(ctx);
626
+ const joinOf = (key) => {
627
+ const i = ctx.refs.sliceKeys.indexOf(key);
628
+ return i === -1 ? null : resolveSliceDocJoin(ctx.model, ctx.refs, ctx.model.slices[i], key, ctx.baseDir, refOf).doc;
629
+ };
630
+ for (const g of groups) {
631
+ const kept = joinOf(g.keys[0]);
632
+ const keptDoc = kept && kept.found && kept.reason === null ? kept.path : null;
633
+ const keptVersion = kept && keptDoc ? kept.version : null;
634
+ for (const key of g.keys.slice(1)) {
635
+ const bound = joinOf(key);
636
+ const rel = bound && bound.found && bound.reason === null ? bound.path : `slices/${key}.md`;
637
+ const from = join(ctx.baseDir, rel);
638
+ if (!existsSync(from) || (keptDoc !== null && rel === keptDoc))
639
+ continue;
640
+ const raw = readFileSync(from, "utf8");
641
+ const range = locateFrontmatterInner(raw);
642
+ const retiredVersion = Number(/^version:\s*(\d+)/m.exec(raw)?.[1] ?? "1");
643
+ const target = keptDoc !== null ? basename(keptDoc, ".md") : g.keys[0];
644
+ let next = raw;
645
+ if (range) {
646
+ const fenceEnd = raw.indexOf("\n", range.innerEnd) + 1 || raw.length;
647
+ 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`;
648
+ next = raw.slice(0, range.innerEnd) + `superseded-by: ${target}@v${keptVersion ?? 1}\n` + raw.slice(range.innerEnd, fenceEnd) + banner + raw.slice(fenceEnd);
649
+ }
650
+ out.push({ from, to: join(ctx.baseDir, ARCHIVE_DIR, basename(rel)), key, keptKey: g.keys[0], keptDoc, keptVersion, retiredVersion, text: next, carried: carriedSections(raw) });
651
+ }
652
+ }
653
+ return out;
654
+ }
655
+ function carriedNotes(retired) {
656
+ 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`}`);
657
+ }
658
+ function detectCollapseAlternativeEvents(ctx) {
659
+ const fresh = freshCompile(ctx);
660
+ const plan = fresh ? planCollapseAlternativeEvents(fresh.model, fresh.refs, readModelText(ctx)) : null;
661
+ if (plan === null)
662
+ return { applicable: false, reason: "no run of adjacent slices repeats one command and differs only in the recorded event" };
663
+ 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(", ")} }`}`);
664
+ const retired = retiredDocs(ctx, plan.groups);
665
+ const notes = carriedNotes(retired);
666
+ const stale = signedDocsStaleAfter(ctx, plan.next);
667
+ return {
668
+ applicable: true,
669
+ 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)` +
670
+ (notes.length > 0 ? `; ${notes.join("; ")}` : "") +
671
+ (stale.length > 0 ? `; signed doc(s) will not be synced and go stale: ${stale.join(", ")} — em slice revise <key>, then em slice sync` : ""),
672
+ };
673
+ }
674
+ /** Frontmatter-only edit: adds `key: value` before the closing fence (a no-op without frontmatter). */
675
+ function addFrontmatterLine(raw, line) {
676
+ const range = locateFrontmatterInner(raw);
677
+ return range ? raw.slice(0, range.innerEnd) + line + "\n" + raw.slice(range.innerEnd) : raw;
678
+ }
679
+ function applyCollapseAlternativeEvents(ctx) {
680
+ const fresh = freshCompile(ctx);
681
+ const text = readModelText(ctx);
682
+ const plan = fresh ? planCollapseAlternativeEvents(fresh.model, fresh.refs, text) : null;
683
+ if (plan === null)
684
+ return { ok: false, message: "the model no longer collapses cleanly" };
685
+ const changed = [ctx.modelFile];
686
+ const retired = retiredDocs(ctx, plan.groups); // computed on the OLD model (joins, versions)
687
+ const notes = carriedNotes(retired);
688
+ writeFileSync(ctx.modelFile, plan.next, "utf8");
689
+ for (const r of retired) {
690
+ mkdirSync(dirname(r.to), { recursive: true });
691
+ writeFileSync(r.to, r.text, "utf8");
692
+ rmSync(r.from);
693
+ changed.push(r.from, r.to);
694
+ }
695
+ // Lineage back-link on the kept doc: `merged-from: <retired>@v<N>` (frontmatter only, outside the
696
+ // ratified hash). A SIGNED kept doc is not touched; `lineage-backlink-missing` lists it.
697
+ for (const r of retired) {
698
+ if (r.keptDoc === null)
699
+ continue;
700
+ const abs = join(ctx.baseDir, r.keptDoc);
701
+ const raw = readFileSync(abs, "utf8");
702
+ if (isSignedDoc(raw))
703
+ continue;
704
+ const ref = `${basename(r.to, ".md")}@v${r.retiredVersion}`;
705
+ const existing = /^merged-from:\s*(.*)$/m.exec(raw);
706
+ 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}`);
707
+ writeFileSync(abs, next, "utf8");
708
+ if (!changed.includes(abs))
709
+ changed.push(abs);
710
+ }
711
+ // Regenerate the kept slice's event region (one `Records one of:` region now) unless its doc is signed
712
+ // (then `slice-doc-stale-signed` is the human item that says what to do).
713
+ const after = freshCompile(ctx);
714
+ if (after) {
715
+ for (const g of plan.groups) {
716
+ const kept = retired.find((r) => r.keptKey === g.keys[0])?.keptDoc ?? `slices/${g.keys[0]}.md`;
717
+ const doc = join(ctx.baseDir, kept);
718
+ if (existsSync(doc) && !isSignedDoc(readFileSync(doc, "utf8"))) {
719
+ const r = runSliceSync(after.model, after.refs, ctx.baseDir, g.keys[0], false);
720
+ if (r.ok && r.docs.some((d) => d.status === "synced") && !changed.includes(doc))
721
+ changed.push(doc);
722
+ }
723
+ }
724
+ }
725
+ return { ok: true, changedFiles: changed, notes };
726
+ }
727
+ // ---- fold-continuations is OPT-IN (`em upgrade --apply --fold-continuations`) ----
728
+ // Default form (a): consecutive continuations of ONE view stack into the first slice (its key, name,
729
+ // `continuationOf`, doc join and index row stay). `--to-sugar` instead moves them into the host
730
+ // command slice as `view X again … shown on` lines; that CHANGES `continuationOf` and the doc join to
731
+ // the host (`<host>--again`) and removes the index rows.
732
+ const FOLD_FLAG = "em upgrade --apply --fold-continuations";
733
+ function foldPlan(ctx) {
734
+ const fresh = freshCompile(ctx);
735
+ if (!fresh)
736
+ return { kind: "stack", plan: null };
737
+ const text = readModelText(ctx);
738
+ return ctx.toSugar === true
739
+ ? { kind: "sugar", plan: planFoldContinuations(fresh.model, fresh.refs, text) }
740
+ : { kind: "stack", plan: planStackContinuations(fresh.model, fresh.refs, text) };
741
+ }
742
+ function detectFoldContinuations(ctx) {
743
+ const f = foldPlan(ctx);
744
+ if (f.plan === null)
745
+ 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" };
746
+ if (f.kind === "sugar" && f.plan.groups.length === 0) {
747
+ const r = f.plan.refused;
748
+ return { applicable: false, reason: `--to-sugar: nothing folded; refused: ${r.map((x) => `${x.hostKey} (${x.reason})`).join("; ")}` };
749
+ }
750
+ const optIn = ctx.foldContinuations === true || ctx.toSugar === true;
751
+ const prefix = optIn ? "" : `OPT-IN, not applied by a plain --apply (run \`${FOLD_FLAG}\`): `;
752
+ const stale = signedDocsStaleAfter(ctx, f.plan.next);
753
+ 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` : "";
754
+ if (f.kind === "stack") {
755
+ const p = f.plan;
756
+ 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(", ")})`);
757
+ const skipped = p.skipped.length > 0 ? `; left unstacked: ${p.skipped.map((s) => `${s.key} (${s.reason})`).join(", ")}` : "";
758
+ return { applicable: true, reason: `${prefix}${desc.join("; ")} — continuationOf, the doc join and the kept key's index row are unchanged${skipped}${staleNote}` };
759
+ }
760
+ const p = f.plan;
761
+ 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)`);
762
+ const refusedNote = p.refused.length > 0 ? `; refused: ${p.refused.map((x) => `${x.hostKey} (${x.reason})`).join("; ")}` : "";
763
+ 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}` };
764
+ }
765
+ function applyFoldContinuations(ctx) {
766
+ const f = foldPlan(ctx);
767
+ if (f.plan === null || ("groups" in f.plan && f.plan.groups.length === 0))
768
+ return { ok: false, message: "the model no longer folds cleanly" };
769
+ writeFileSync(ctx.modelFile, f.plan.next, "utf8");
770
+ return { ok: true, changedFiles: [ctx.modelFile] };
771
+ }
772
+ function staleViewDocs(ctx) {
773
+ const open = [];
774
+ const signed = [];
775
+ const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, undefined, true);
776
+ if (!r.ok)
777
+ return { open, signed };
778
+ for (const d of r.docs) {
779
+ if (d.status !== "stale" || !d.regions.some((g) => g.status === "stale" && g.name.startsWith("em-slice-view")))
780
+ continue;
781
+ (isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8")) ? signed : open).push({ key: d.key, path: d.path });
782
+ }
783
+ return { open, signed };
784
+ }
785
+ function detectViewDocSourcesStale(ctx) {
786
+ const { open, signed } = staleViewDocs(ctx);
787
+ const left = signed.length > 0 ? `; left alone (ratified or shipped — em slice revise, then em slice sync): ${signed.map((d) => d.path).join(", ")}` : "";
788
+ if (open.length === 0) {
789
+ return { applicable: false, reason: `no bound slice doc's view region is stale against the model${left}` };
790
+ }
791
+ return {
792
+ applicable: true,
793
+ 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}`,
794
+ };
795
+ }
796
+ function applyViewDocSourcesStale(ctx) {
797
+ const { open } = staleViewDocs(ctx);
798
+ const changedFiles = [];
799
+ for (const d of open) {
800
+ const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, d.key, false);
801
+ if (!r.ok)
802
+ return { ok: false, message: r.message };
803
+ changedFiles.push(join(ctx.baseDir, d.path));
804
+ }
805
+ return { ok: true, changedFiles };
806
+ }
807
+ // ---- Step 8b: slice-index-stale (GH #290 / #275) — the README Slices table. ----
808
+ function detectSliceIndexStale(ctx) {
809
+ const r = runSliceIndex(ctx.model, ctx.refs, ctx.modelFile, true);
810
+ if (r.ok)
811
+ return { applicable: false, reason: "the README Slices table is current" };
812
+ if (r.kind === "stale")
813
+ return { applicable: true, reason: `${join(ctx.baseDir, "README.md")}'s Slices table is stale (em slice index --check) — em upgrade will regenerate it` };
814
+ 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" };
815
+ }
816
+ function applySliceIndexStale(ctx) {
817
+ const r = runSliceIndex(ctx.model, ctx.refs, ctx.modelFile, false);
818
+ if (!r.ok)
819
+ return { ok: false, message: r.message };
820
+ return { ok: true, changedFiles: r.wrote ? [r.readmePath] : [] };
821
+ }
822
+ // ---- Step 8c: parking-lot-fragments (GH #290 / #283) — substring bullets become the item's label. ----
823
+ function parkingLotDocs(ctx) {
824
+ const docs = new Map();
825
+ for (const d of nonContinuationDocs(ctx)) {
826
+ const file = join(ctx.baseDir, d.path);
827
+ if (d.path !== "" && existsSync(file))
828
+ docs.set(d.key, readFileSync(file, "utf8"));
829
+ }
830
+ return docs;
831
+ }
832
+ function detectParkingLotFragments(ctx) {
833
+ const statePath = join(ctx.baseDir, STATE_FILE_NAME);
834
+ if (!existsSync(statePath))
835
+ return { applicable: false, reason: `no ${STATE_FILE_NAME} beside the model` };
836
+ const { rewrites } = rewriteParkingLotFragments(readFileSync(statePath, "utf8"), parkingLotDocs(ctx));
837
+ if (rewrites.length === 0)
838
+ return { applicable: false, reason: "no parking-lot bullet is a fragment of an Open Questions item" };
839
+ return {
840
+ applicable: true,
841
+ 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`,
842
+ };
843
+ }
844
+ function applyParkingLotFragments(ctx) {
845
+ const statePath = join(ctx.baseDir, STATE_FILE_NAME);
846
+ const { content } = rewriteParkingLotFragments(readFileSync(statePath, "utf8"), parkingLotDocs(ctx));
847
+ writeFileSync(statePath, content, "utf8");
848
+ return { ok: true, changedFiles: [statePath] };
849
+ }
550
850
  // ---- Step 8: system-manifest (MIL-235, R3) — system.yaml 1.0 -> 2.0. ----
551
851
  // The one mechanical step that writes OTHER models' files: the legacy manifest's seams become
552
852
  // `consumes` clauses on each consuming translation and its owners become `owner "…"` on each
@@ -648,6 +948,11 @@ export const UPGRADE_STEPS = [
648
948
  { id: "constitution", sinceVersion: "1.11.0", detect: detectConstitution, apply: applyConstitution },
649
949
  { id: "ratified-signoff", sinceVersion: "1.14.0", detect: detectRatifiedSignoff, apply: applyRatifiedSignoff },
650
950
  { id: "ratified-hash-scope", sinceVersion: "1.17.0", detect: detectRatifiedHashScope, apply: applyRatifiedHashScope },
951
+ { id: "collapse-alternative-events", sinceVersion: "1.18.0", detect: detectCollapseAlternativeEvents, apply: applyCollapseAlternativeEvents },
952
+ { id: "fold-continuations", sinceVersion: "1.18.0", detect: detectFoldContinuations, apply: applyFoldContinuations, optedIn: (c) => c.foldContinuations === true || c.toSugar === true },
953
+ { id: "view-doc-sources-stale", sinceVersion: "1.18.0", detect: detectViewDocSourcesStale, apply: applyViewDocSourcesStale },
954
+ { id: "slice-index-stale", sinceVersion: "1.18.0", detect: detectSliceIndexStale, apply: applySliceIndexStale },
955
+ { id: "parking-lot-fragments", sinceVersion: "1.18.0", detect: detectParkingLotFragments, apply: applyParkingLotFragments },
651
956
  { id: "system-manifest", sinceVersion: "1.14.0", detect: detectSystemManifest, apply: applySystemManifest },
652
957
  ];
653
958
  function elementRefOf(ctx) {
@@ -780,6 +1085,101 @@ function detectPluginInstallLocally(ctx) {
780
1085
  }
781
1086
  return null;
782
1087
  }
1088
+ /** GH #290: a SIGNED doc (ratified or shipped) whose generated regions are stale against the model —
1089
+ * `view-doc-sources-stale` and `collapse-alternative-events` leave it alone because syncing would move
1090
+ * its `ratifiedHash`. Raised for any region (view, event, command, invariants). */
1091
+ function detectSliceDocStaleSigned(ctx) {
1092
+ const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, undefined, true);
1093
+ if (!r.ok)
1094
+ return null;
1095
+ const docs = r.docs.filter((d) => d.status === "stale" && isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8")));
1096
+ if (docs.length === 0)
1097
+ return null;
1098
+ return {
1099
+ id: "slice-doc-stale-signed",
1100
+ 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\``,
1101
+ };
1102
+ }
1103
+ /** GH #290 / #278: a retired doc under archive/slices/ names its successor in `superseded-by`, but the
1104
+ * successor (a signed doc `em upgrade` did not touch) lacks the `merged-from` back-link. */
1105
+ function detectLineageBacklinkMissing(ctx) {
1106
+ const dir = join(ctx.baseDir, ARCHIVE_DIR);
1107
+ if (!existsSync(dir))
1108
+ return null;
1109
+ const missing = [];
1110
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".md")).sort()) {
1111
+ const retired = parseSliceDoc(readFileSync(join(dir, f), "utf8"));
1112
+ for (const ref of retired.supersededBy) {
1113
+ if (!ref.sliceKey)
1114
+ continue;
1115
+ const target = join(ctx.baseDir, "slices", `${ref.sliceKey}.md`);
1116
+ if (!existsSync(target))
1117
+ continue;
1118
+ const doc = parseSliceDoc(readFileSync(target, "utf8"));
1119
+ const key = basename(f, ".md");
1120
+ if (!doc.mergedFrom.some((m) => m.sliceKey === key))
1121
+ missing.push(`slices/${ref.sliceKey}.md needs merged-from: ${key}@v${retired.version ?? 1}`);
1122
+ }
1123
+ }
1124
+ if (missing.length === 0)
1125
+ return null;
1126
+ 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)` };
1127
+ }
1128
+ /** GH #290 / #287: `fold-continuations` is detected and listed by every run but needs the flag. */
1129
+ function detectFoldContinuationsOptIn(ctx) {
1130
+ if (ctx.foldContinuations === true || ctx.toSugar === true)
1131
+ return null;
1132
+ const d = detectFoldContinuations(ctx);
1133
+ return d.applicable ? { id: "fold-continuations-opt-in", reason: d.reason } : null;
1134
+ }
1135
+ function compareVersions(a, b) {
1136
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
1137
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
1138
+ for (let i = 0; i < 3; i++)
1139
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
1140
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
1141
+ return 0;
1142
+ }
1143
+ /** The version the repo is upgrading FROM (recorded `Em version:` bullet, else inferred). */
1144
+ function recordedFromVersion(ctx) {
1145
+ const loaded = loadStateFile(ctx.baseDir);
1146
+ let recorded = null;
1147
+ if (loaded.ok) {
1148
+ const parsed = parseState(loaded.text);
1149
+ if (parsed.ok)
1150
+ recorded = parsed.state.emVersion;
1151
+ }
1152
+ return resolveFromVersion(ctx, recorded).version;
1153
+ }
1154
+ /** GH #290 / #259: signatures are a human decision, so the roster is never generated silently. */
1155
+ function detectRosterNotInitialised(ctx) {
1156
+ if (findRosterAbove(ctx.baseDir) !== null)
1157
+ return null;
1158
+ const { ratifiers } = observeSigners(ctx.model, ctx.refs, ctx.baseDir);
1159
+ if (ratifiers.length === 0)
1160
+ return null;
1161
+ return {
1162
+ id: "roster-not-initialised",
1163
+ 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`,
1164
+ };
1165
+ }
1166
+ /** GH #290 / #279: information only. Since 1.18.0 the same event recorded by several commands (same
1167
+ * name and context) is one logical event and `duplicate-name` no longer fires; nothing to change. */
1168
+ function detectSharedEventNowLegal(ctx) {
1169
+ // The transition only: a repo already stamped 1.18.0+ has seen this note.
1170
+ if (compareVersions(recordedFromVersion(ctx), "1.18.0") >= 0)
1171
+ return null;
1172
+ const shared = new Set();
1173
+ for (const el of ctx.model.elements)
1174
+ if (el.kind === "event" && el.logicalId !== el.id)
1175
+ shared.add(el.name);
1176
+ if (shared.size === 0)
1177
+ return null;
1178
+ return {
1179
+ id: "shared-event-now-legal",
1180
+ 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`,
1181
+ };
1182
+ }
783
1183
  const HUMAN_DETECTORS = [
784
1184
  detectNoModelVersion,
785
1185
  detectContinuationHasOwnDoc,
@@ -789,6 +1189,11 @@ const HUMAN_DETECTORS = [
789
1189
  detectPredates16,
790
1190
  detectPublicFieldTypesUnresolved,
791
1191
  detectPluginInstallLocally,
1192
+ detectSliceDocStaleSigned,
1193
+ detectLineageBacklinkMissing,
1194
+ detectFoldContinuationsOptIn,
1195
+ detectSharedEventNowLegal,
1196
+ detectRosterNotInitialised,
792
1197
  ];
793
1198
  export function detectHumanItems(ctx) {
794
1199
  return HUMAN_DETECTORS.map((d) => d(ctx)).filter((x) => x !== null);
@@ -884,7 +1289,8 @@ export function detectUpgrade(ctx) {
884
1289
  const from = resolveFromVersion(ctx, recorded);
885
1290
  const steps = UPGRADE_STEPS.map((s) => {
886
1291
  const d = s.detect(ctx);
887
- return { id: s.id, sinceVersion: s.sinceVersion, applicable: d.applicable, human: d.human === true, reason: d.reason };
1292
+ const optIn = s.optedIn !== undefined && !s.optedIn(ctx) && d.applicable;
1293
+ return { id: s.id, sinceVersion: s.sinceVersion, applicable: d.applicable, human: d.human === true, reason: d.reason, ...(optIn ? { optIn: true } : {}) };
888
1294
  });
889
1295
  const human = detectHumanItems(ctx);
890
1296
  return { from, to: ctx.installedVersion, steps, human, stateFileError };
@@ -921,6 +1327,10 @@ export function applyUpgrade(ctx, runGit = realGit) {
921
1327
  applied.push({ id: step.id, applied: false, changedFiles: [], commit: null });
922
1328
  continue;
923
1329
  }
1330
+ if (step.optedIn && !step.optedIn(ctx)) {
1331
+ applied.push({ id: step.id, applied: false, changedFiles: [], commit: null });
1332
+ continue;
1333
+ }
924
1334
  const result = step.apply(ctx);
925
1335
  if (!result.ok) {
926
1336
  gitDiscardAll(ctx.repoRoot, runGit);
@@ -931,8 +1341,17 @@ export function applyUpgrade(ctx, runGit = realGit) {
931
1341
  if (!commitResult.ok) {
932
1342
  return { ok: false, message: `em upgrade: step "${step.id}" applied but failed to commit — ${commitResult.message}`, report, applied };
933
1343
  }
934
- applied.push({ id: step.id, applied: true, changedFiles: result.changedFiles, commit: message });
1344
+ applied.push({ id: step.id, applied: true, changedFiles: result.changedFiles, commit: message, ...(result.notes && result.notes.length > 0 ? { notes: result.notes } : {}) });
1345
+ // A step may have rewritten the model (or its docs): later steps must see the new one.
1346
+ const fresh = freshCompile(ctx);
1347
+ if (fresh) {
1348
+ ctx.model = fresh.model;
1349
+ ctx.refs = fresh.refs;
1350
+ }
935
1351
  }
1352
+ // The pre-apply human list can be stale (a rewritten model, a doc now out of sync): report the upgraded
1353
+ // repo's, computed before the `Em version:` stamp so transition-only notes still show.
1354
+ report.human = detectHumanItems(ctx);
936
1355
  const loaded = loadStateFile(ctx.baseDir);
937
1356
  let emVersionCommit = null;
938
1357
  if (loaded.ok) {