@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
@@ -4,7 +4,10 @@ import { AUTOMATION_KINDS } from "../parser/ast.js";
4
4
  import { collectModelInvariants, collectTags, MODEL_INVARIANT_ID_RE, PUBLIC_SCALAR_TYPE_NAMES, normalizeName, resolvePublicType, resolveTypeRef, } from "./model.js";
5
5
  import { pushDiag } from "./rules.js";
6
6
  import { viewSourceNames } from "./continuation.js";
7
- import { connectionKind, resolveLoopsToTarget } from "./edges.js";
7
+ import { connectionKind, resolveLoopsToTarget, semanticEdges } from "./edges.js";
8
+ import { composedButRenderedBackward, findStoryline, isEarlierOnTimeline, isLaterOnTimeline, latestOnTimeline, } from "./storylines.js";
9
+ import { classifySlicePattern } from "../catalog/classify.js";
10
+ import { shapeDiagnostics } from "./shapeLint.js";
8
11
  import { kebabSlug } from "../util/slug.js";
9
12
  /** A GitHub user (`@alice`) or team (`@org/team`) — the shape CODEOWNERS accepts. */
10
13
  export const OWNER_HANDLE_RE = /^@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\/[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)?$/;
@@ -19,9 +22,13 @@ export function validate(model, grid, refs) {
19
22
  const refOf = (id) => refs.refById.get(id);
20
23
  // Grid collisions: two elements of the same band landed in one slice cell.
21
24
  for (const c of grid.collisions) {
25
+ const bothEvents = c.dropped.kind === "event" && c.kept.kind === "event";
22
26
  pushDiag(diags, "grid-collision", {
23
27
  message: `"${c.dropped.name}" collides with "${c.kept.name}" in the same ` +
24
- `slice/lane (${c.rowKey}); split them into separate slices`,
28
+ `slice/lane (${c.rowKey}); split them into separate slices` +
29
+ (bothEvents
30
+ ? ` — or, if they are two outcomes of one command on one context, write \`event A | B @Context\``
31
+ : ""),
25
32
  line: c.dropped.line,
26
33
  refs: [refOf(c.dropped.id), refOf(c.kept.id)],
27
34
  });
@@ -47,13 +54,29 @@ export function validate(model, grid, refs) {
47
54
  for (const ui of slice.elements.filter((e) => e.kind === "ui")) {
48
55
  pushDiag(diags, "ui-shares-slice-with-automation", {
49
56
  message: `ui "${ui.name}" shares slice "${slice.name}" with ${auto.kind} "${auto.name}"; ` +
50
- `a \`ui\` only wires to a \`command\` a person issues and renders disconnected here — ` +
51
- `move it to the slice that displays the read model, or drop it`,
57
+ `a \`ui\` only wires to a \`command\` a person issues and renders disconnected here` +
58
+ ((auto.from ?? []).length > 0
59
+ ? ` (and it sits between the ${auto.kind} and the row below it, so the ${auto.kind}'s ` +
60
+ `inbound edge has to be routed around it)`
61
+ : ``) +
62
+ ` — move it to the slice that displays the read model, or drop it`,
52
63
  line: ui.line,
53
64
  refs: [refOf(ui.id), refOf(auto.id)],
54
65
  });
55
66
  }
56
67
  }
68
+ // GH #278: `event A | B @Ctx` alternatives are the outcomes of ONE command's decision.
69
+ if (!command) {
70
+ const alt = events.find((e) => e.alternativeGroup !== undefined);
71
+ if (alt) {
72
+ pushDiag(diags, "alternative-events-no-command", {
73
+ message: `alternative events ${events.filter((e) => e.alternativeGroup === alt.alternativeGroup).map((e) => `"${e.name}"`).join(" | ")} share no command in slice "${slice.name}"; ` +
74
+ `alternatives are the possible outcomes of one command — add the command that records one of them`,
75
+ line: alt.line,
76
+ refs: events.filter((e) => e.alternativeGroup === alt.alternativeGroup).map((e) => refOf(e.id)),
77
+ });
78
+ }
79
+ }
57
80
  // A command should record at least one event.
58
81
  if (command && events.length === 0) {
59
82
  pushDiag(diags, "both-ends-of-a-flow/command-no-event", {
@@ -89,7 +112,9 @@ export function validate(model, grid, refs) {
89
112
  refs: other ? [refOf(view.id), refOf(other.id)] : [refOf(view.id)],
90
113
  });
91
114
  }
92
- else if (evt.sliceIndex > view.sliceIndex) {
115
+ else if (isLaterOnTimeline(model, evt.sliceIndex, view.sliceIndex) &&
116
+ // GH #279: any recording site of the logical event on or before the view suffices.
117
+ !(bucket ?? []).some((e) => e.kind === "event" && e.logicalId === evt.logicalId && !isLaterOnTimeline(model, e.sliceIndex, view.sliceIndex))) {
93
118
  pushDiag(diags, "view-from-future-event", {
94
119
  message: `time flows left to right: event "${evt.name}" (slice ${evt.sliceIndex + 1}) happens ` +
95
120
  `after read model "${view.name}" (slice ${view.sliceIndex + 1}); move this source to a ` +
@@ -163,6 +188,41 @@ export function validate(model, grid, refs) {
163
188
  }
164
189
  }
165
190
  }
191
+ // Collection fields (GH #288): `values: AttributeValue[] derived from "E1", "E2"` embeds a
192
+ // child entity in the view. The field itself is exempt (derived), but each field of the
193
+ // element type must trace to a field on at least one NAMED child event. Only checked when
194
+ // every resolvable named event declares `{ fields }` (a fieldless one may be the provider),
195
+ // and unresolved names are left to `derived-from-unresolved` above.
196
+ for (const f of view.fields) {
197
+ if (!f.derived || !f.derivedFrom || f.derivedFrom.length === 0)
198
+ continue;
199
+ const resolved = resolveTypeRef(f.type, model.typesByName);
200
+ if (!resolved || !resolved.array)
201
+ continue;
202
+ const named = [];
203
+ for (const name of f.derivedFrom) {
204
+ const evt = (model.byName.get(normalizeName(name)) ?? []).find((e) => e.kind === "event");
205
+ if (evt)
206
+ named.push(evt);
207
+ }
208
+ if (named.length === 0 || !named.every((e) => (e.fields ?? []).length > 0))
209
+ continue;
210
+ const union = new Set();
211
+ for (const e of named)
212
+ for (const ef of e.fields ?? [])
213
+ union.add(normalizeName(ef.name));
214
+ const list = named.map((e) => `"${e.name}"`).join(", ");
215
+ for (const tf of resolved.typeDecl.fields) {
216
+ if (union.has(normalizeName(tf.name)))
217
+ continue;
218
+ pushDiag(diags, "fields-completeness/view-field-no-source", {
219
+ message: `view "${view.name}" collection field "${f.name}" (${f.type}) element field ` +
220
+ `"${tf.name}" of type "${resolved.typeDecl.name}" has no source in ${list}`,
221
+ line: view.line,
222
+ refs: [refOf(view.id), ...named.map((e) => refOf(e.id))],
223
+ });
224
+ }
225
+ }
166
226
  }
167
227
  }
168
228
  // Fields completeness: every event field in a slice should trace to a field on
@@ -226,7 +286,7 @@ export function validate(model, grid, refs) {
226
286
  refs: asEvent ? [refOf(el.id), refOf(asEvent.id)] : [refOf(el.id)],
227
287
  });
228
288
  }
229
- else if (!views.some((v) => v.sliceIndex <= el.sliceIndex)) {
289
+ else if (!views.some((v) => !isLaterOnTimeline(model, v.sliceIndex, el.sliceIndex))) {
230
290
  pushDiag(diags, "reaction-from-future-view", {
231
291
  message: `time flows left to right: ${el.kind} "${el.name}" (slice ${el.sliceIndex + 1}) reads ` +
232
292
  `"${src}" before any instance of it exists; declare the view in or before that slice`,
@@ -263,7 +323,7 @@ export function validate(model, grid, refs) {
263
323
  const target = resolveLoopsToTarget(model, el, name);
264
324
  if (!target) {
265
325
  const nearest = views
266
- .filter((v) => v.sliceIndex >= el.sliceIndex)
326
+ .filter((v) => !isEarlierOnTimeline(model, v.sliceIndex, el.sliceIndex))
267
327
  .sort((a, b) => a.sliceIndex - b.sliceIndex)[0];
268
328
  pushDiag(diags, "loops-to-forward", {
269
329
  message: `time flows left to right: event "${el.name}" (slice ${el.sliceIndex + 1}) loops-to ` +
@@ -455,13 +515,26 @@ export function validate(model, grid, refs) {
455
515
  for (const el of model.elements) {
456
516
  if (el.kind !== "event")
457
517
  continue;
458
- if (readEvents.has(normalizeName(el.name)))
518
+ if (readEvents.has(normalizeName(el.name)) && !isSharedEventSite(model, el))
459
519
  continue;
460
520
  // A `public` event is part of the published integration surface — its reader is
461
521
  // outside this model, possibly outside this system entirely, so there's nothing local
462
522
  // to project it into.
463
523
  if (el.public)
464
524
  continue;
525
+ if (isSharedEventSite(model, el) && readEvents.has(normalizeName(el.name))) {
526
+ // GH #279: every recording site of a shared event needs a reader at or after it.
527
+ if (sharedSiteHasReader(model, el))
528
+ continue;
529
+ const slice = model.slices[el.sliceIndex];
530
+ pushDiag(diags, "both-ends-of-a-flow/event-unread", {
531
+ message: `this recording of "${el.name}" (slice "${slice?.name ?? el.sliceIndex + 1}") has no reader after it; ` +
532
+ `add \`view V again from "${el.name}"\` after it`,
533
+ line: el.line,
534
+ refs: [refOf(el.id)],
535
+ });
536
+ continue;
537
+ }
465
538
  pushDiag(diags, "both-ends-of-a-flow/event-unread", {
466
539
  message: `event "${el.name}" is not read by any read model; project it into a view ` +
467
540
  `(\`view X from "${el.name}"\`), or reconsider recording it`,
@@ -474,7 +547,7 @@ export function validate(model, grid, refs) {
474
547
  if (a.fromId && a.toId) {
475
548
  const fromEl = model.byId.get(a.fromId);
476
549
  const toEl = model.byId.get(a.toId);
477
- if (fromEl && toEl && toEl.sliceIndex < fromEl.sliceIndex) {
550
+ if (fromEl && toEl && isEarlierOnTimeline(model, toEl.sliceIndex, fromEl.sliceIndex)) {
478
551
  pushDiag(diags, "arrow-backward", {
479
552
  message: `time flows left to right: arrow "${a.from}" -> "${a.to}" points backward ` +
480
553
  `(slice ${fromEl.sliceIndex + 1} -> ${toEl.sliceIndex + 1}); restructure so the target comes later`,
@@ -506,6 +579,78 @@ export function validate(model, grid, refs) {
506
579
  ...(a.fromId ? { refs: [refOf(a.fromId)] } : {}),
507
580
  });
508
581
  }
582
+ // Storylines (GH #277). Advisory grouping, plus the `after "<storyline>"` clause that composes
583
+ // the timeline (model/storylines.ts) — whose misuse (unknown/cyclic reference, or a reference
584
+ // that would draw right-to-left in the full-model render) is an error.
585
+ const storylines = model.storylines ?? [];
586
+ if (storylines.length > 0) {
587
+ for (const slice of model.slices) {
588
+ if (slice.storyline !== undefined)
589
+ continue;
590
+ pushDiag(diags, "slice-outside-storyline", {
591
+ message: `slice "${slice.name}" belongs to no storyline, but this model declares ` +
592
+ `${storylines.length} (${storylines.map((s) => `"${s.name}"`).join(", ")}); move it inside one`,
593
+ line: slice.line,
594
+ refs: [refs.sliceKeys[slice.index]],
595
+ });
596
+ }
597
+ for (const st of storylines) {
598
+ if (st.after === undefined)
599
+ continue;
600
+ const target = findStoryline(model, st.after);
601
+ if (!target) {
602
+ pushDiag(diags, "storyline-after-unknown", {
603
+ message: `storyline "${st.name}" is declared \`after\` unknown storyline "${st.after}"`,
604
+ line: st.line,
605
+ });
606
+ continue;
607
+ }
608
+ // A cycle (including `after` itself) leaves the storyline with no usable chain.
609
+ let cur = target;
610
+ const seen = new Set();
611
+ let cyclic = target === st;
612
+ while (!cyclic && cur && cur.after !== undefined && !seen.has(cur)) {
613
+ seen.add(cur);
614
+ cur = findStoryline(model, cur.after);
615
+ if (cur === st)
616
+ cyclic = true;
617
+ }
618
+ if (cyclic) {
619
+ pushDiag(diags, "storyline-after-cycle", {
620
+ message: `storyline "${st.name}" is declared \`after\` "${st.after}", which (transitively) comes after ` +
621
+ `"${st.name}" — a story cannot follow itself; remove one \`after\` clause`,
622
+ line: st.line,
623
+ });
624
+ }
625
+ }
626
+ // The composed timeline may read a flow forward that the full-model render (file order)
627
+ // draws right-to-left; that is an error, never a special render.
628
+ for (const edge of semanticEdges(model)) {
629
+ if (edge.source === "loops-to")
630
+ continue;
631
+ const from = model.byId.get(edge.from);
632
+ const to = model.byId.get(edge.to);
633
+ if (!from || !to || from.sliceIndex === to.sliceIndex)
634
+ continue;
635
+ if (!composedButRenderedBackward(model, from.sliceIndex, to.sliceIndex))
636
+ continue;
637
+ const fromStory = model.slices[from.sliceIndex].storyline;
638
+ const toStory = model.slices[to.sliceIndex].storyline;
639
+ const toAfter = findStoryline(model, toStory ?? "")?.after;
640
+ const fromSlice = model.slices[from.sliceIndex].name;
641
+ pushDiag(diags, "storyline-after-out-of-order", {
642
+ message: `time flows left to right: "${from.name}" (slice "${fromSlice}", storyline "${fromStory}") feeds ` +
643
+ `"${to.name}" (slice "${model.slices[to.sliceIndex].name}", storyline "${toStory}"), but ` +
644
+ (toAfter !== undefined ? `\`after "${toAfter}"\` ` : "the storyline's `after` clause ") +
645
+ `places "${toStory}" later on the composed timeline than slice "${fromSlice}", while the file declares it above, ` +
646
+ `so the full-model render would draw this arrow right-to-left; move this storyline block below slice "${fromSlice}"`,
647
+ line: to.line,
648
+ refs: [refOf(from.id), refOf(to.id)],
649
+ });
650
+ }
651
+ }
652
+ // Advisory shape lints (GH #292): outcome-split reactions, retry slices.
653
+ shapeDiagnostics(model, refs, diags);
509
654
  // Open issues (`issue "text"`) are always surfaced as a warning — the red-note
510
655
  // device for a question that hasn't been resolved yet.
511
656
  for (const el of model.elements) {
@@ -609,11 +754,65 @@ export function validate(model, grid, refs) {
609
754
  });
610
755
  }
611
756
  }
757
+ // GH #279: a logical event recorded at several sites must agree on its declaration. Later
758
+ // sites with no fields / tags / `public` inherited the first's (model.ts); one that declares
759
+ // a different one is an error.
760
+ const tagSig = (t) => JSON.stringify((t ?? []).map((x) => [x.key, x.kind, x.fields ?? null, x.description ?? null]));
761
+ for (const el of model.elements) {
762
+ if (el.kind !== "event" || el.logicalId === el.id)
763
+ continue;
764
+ const first = model.byId.get(el.logicalId);
765
+ if (!first)
766
+ continue;
767
+ const differs = [];
768
+ if (el.fields !== first.fields && JSON.stringify(el.fields ?? []) !== JSON.stringify(first.fields ?? []))
769
+ differs.push("different fields");
770
+ if (el.tags !== first.tags && tagSig(el.tags) !== tagSig(first.tags))
771
+ differs.push("different tags");
772
+ if ((el.public === true) !== (first.public === true))
773
+ differs.push("a different public marker");
774
+ if (differs.length === 0)
775
+ continue;
776
+ pushDiag(diags, "shared-event-field-mismatch", {
777
+ message: `event "${el.name}" is recorded again in slice ${el.sliceIndex + 1} with ${differs.join(" and ")} ` +
778
+ `than its first declaration (slice ${first.sliceIndex + 1}); repeat the event bare ` +
779
+ `to inherit its fields, tags and public, or make them identical`,
780
+ line: el.line,
781
+ refs: [refOf(el.id), refOf(first.id)],
782
+ });
783
+ }
784
+ // GH #278: one command repeated across slices — a column per outcome. Continuation slices
785
+ // (every non-`ui` element an `again` view) declare no command, so none can contribute here.
786
+ const commandSlices = new Map();
787
+ for (const el of model.elements) {
788
+ if (el.kind !== "command")
789
+ continue;
790
+ const k = normalizeName(el.name);
791
+ const bucket = commandSlices.get(k);
792
+ if (bucket)
793
+ bucket.push(el);
794
+ else
795
+ commandSlices.set(k, [el]);
796
+ }
797
+ for (const els of commandSlices.values()) {
798
+ const slices = [...new Set(els.map((e) => e.sliceIndex))];
799
+ if (slices.length < 2)
800
+ continue;
801
+ pushDiag(diags, "command-repeated-across-slices", {
802
+ message: `command "${els[0].name}" is declared in ${slices.length} slices (${slices.map((i) => `"${model.slices[i].name}"`).join(", ")}); ` +
803
+ `one command with several possible outcomes is ONE slice — record them as ` +
804
+ `\`event A | B @Context\` alternatives instead of one column per event`,
805
+ line: els[1].line,
806
+ refs: els.map((e) => refOf(e.id)),
807
+ });
808
+ }
612
809
  // Ambiguous names (used by arrows / view sources) get a heads-up.
613
810
  for (const [key, els] of model.byName) {
614
811
  // Later \`again\` instances of a view are the SAME logical read model reappearing on the
615
812
  // timeline — deliberate, not an ambiguity. Only warn when a duplicate is NOT an instance.
616
- const nonInstances = els.filter((e, i) => i === 0 || !(e.kind === "view" && e.again));
813
+ // Likewise an event recorded again in the same context is one logical event (GH #279), so
814
+ // only a same-name event in a DIFFERENT context (or a non-event) counts as a collision.
815
+ const nonInstances = els.filter((e, i) => i === 0 || !((e.kind === "view" && e.again) || (e.kind === "event" && e.logicalId !== e.id)));
617
816
  if (nonInstances.length > 1 && isReferenced(model, key)) {
618
817
  pushDiag(diags, "duplicate-name", {
619
818
  message: `name "${els[0].name}" is defined ${nonInstances.length} times; ` +
@@ -743,6 +942,82 @@ export function validate(model, grid, refs) {
743
942
  refs: [...new Set(decls.map((d) => refOf(d.element.id)))],
744
943
  });
745
944
  }
945
+ diags.push(...timelineEntityClustered(model, refs.sliceKeys));
946
+ return diags;
947
+ }
948
+ /** Minimum run length for `timeline-entity-clustered` (GH #281). A future config knob; the
949
+ * value is set by measurement (see the PR): no shipped example, skill em block or real model
950
+ * has a qualifying run even at 2, so 3 is conservative. */
951
+ export const TIMELINE_ENTITY_CLUSTER_MIN_RUN = 3;
952
+ /** `timeline-entity-clustered` (GH #281): a run of >= N consecutive State Change slices whose
953
+ * events all sit on ONE context, where the first view-bearing slice from the run onward reads
954
+ * only that context and no view fed by another context sits inside the run — the
955
+ * lifecycle-per-entity (CRUD-on-a-timeline) shape. Exempt below 2 distinct write contexts. A trailing
956
+ * `again` view is judged by the logical view's union of sources. Fires once per maximal run, with `refs`
957
+ * naming every slice key in the run. */
958
+ function timelineEntityClustered(model, sliceKeys) {
959
+ const diags = [];
960
+ // Interleaving contexts is the fix, so a model with fewer than 2 distinct write contexts
961
+ // (no `@Context` at all, or every event on one) has nothing to interleave: exempt.
962
+ const writeContexts = new Set();
963
+ for (const slice of model.slices)
964
+ for (const e of slice.elements)
965
+ if (e.kind === "event")
966
+ writeContexts.add(e.context);
967
+ if (writeContexts.size < 2)
968
+ return diags;
969
+ const sliceInfo = model.slices.map((slice) => {
970
+ const events = slice.elements.filter((e) => e.kind === "event");
971
+ const views = slice.elements.filter((e) => e.kind === "view");
972
+ const eventContexts = new Set(events.map((e) => e.context));
973
+ const viewCtx = new Set();
974
+ for (const v of views) {
975
+ const names = viewSourceNames(model, v);
976
+ if (names.length === 0)
977
+ for (const c of eventContexts)
978
+ viewCtx.add(c);
979
+ for (const n of names)
980
+ viewCtx.add(model.byName.get(normalizeName(n))?.find((e) => e.kind === "event")?.context);
981
+ }
982
+ const isStateChange = classifySlicePattern(slice) === "state-change" && events.length > 0;
983
+ return {
984
+ slice,
985
+ views: views.length,
986
+ viewCtx,
987
+ // The single context this slice writes, or null when it is not a single-context State Change slice.
988
+ context: isStateChange && eventContexts.size === 1 ? ([...eventContexts][0] ?? null) : null,
989
+ };
990
+ });
991
+ let i = 0;
992
+ while (i < sliceInfo.length) {
993
+ const ctx = sliceInfo[i].context;
994
+ if (ctx === null || ![...sliceInfo[i].viewCtx].every((c) => c === ctx)) {
995
+ i++;
996
+ continue;
997
+ }
998
+ let j = i;
999
+ // Extend while the slice writes ctx and any view it carries reads only ctx.
1000
+ while (j < sliceInfo.length && sliceInfo[j].context === ctx && [...sliceInfo[j].viewCtx].every((c) => c === ctx))
1001
+ j++;
1002
+ const run = sliceInfo.slice(i, j);
1003
+ if (run.length >= TIMELINE_ENTITY_CLUSTER_MIN_RUN) {
1004
+ const trailing = sliceInfo.slice(i).find((s) => s.views > 0);
1005
+ if (trailing && [...trailing.viewCtx].every((c) => c === ctx)) {
1006
+ const first = run[0].slice;
1007
+ const last = run[run.length - 1].slice;
1008
+ pushDiag(diags, "timeline-entity-clustered", {
1009
+ message: `${run.length} consecutive State Change slices ("${first.name}" through "${last.name}") all write ` +
1010
+ `context "${ctx}" and only that context is read next. If these are create/update/archive operations ` +
1011
+ `on one entity, order the slices by the business journey, interleaving contexts as the story does ` +
1012
+ `(see docs/storyline.md); if they are consecutive steps of one journey, show the screen the user ` +
1013
+ `sees between them (a view after each step)`,
1014
+ line: first.line,
1015
+ refs: run.map((s) => sliceKeys[s.slice.index]),
1016
+ });
1017
+ }
1018
+ }
1019
+ i = j;
1020
+ }
746
1021
  return diags;
747
1022
  }
748
1023
  const PUBLIC_TYPE_TABLE_TEXT = PUBLIC_SCALAR_TYPE_NAMES.join(", ");
@@ -825,9 +1100,7 @@ function computeViewReaders(model) {
825
1100
  if (!bucket)
826
1101
  continue;
827
1102
  const views = bucket.filter((x) => x.kind === "view");
828
- const atOrBefore = views
829
- .filter((x) => x.sliceIndex <= el.sliceIndex)
830
- .sort((a, b) => b.sliceIndex - a.sliceIndex);
1103
+ const atOrBefore = views.filter((x) => !isLaterOnTimeline(model, x.sliceIndex, el.sliceIndex));
831
1104
  if (atOrBefore.length === 0) {
832
1105
  if (views[0])
833
1106
  readByArrowOrFrom.add(views[0].id);
@@ -837,7 +1110,7 @@ function computeViewReaders(model) {
837
1110
  readByArrowOrFrom.add(v.id);
838
1111
  }
839
1112
  else {
840
- readByArrowOrFrom.add(atOrBefore[0].id);
1113
+ readByArrowOrFrom.add(latestOnTimeline(model, atOrBefore, el.sliceIndex).id);
841
1114
  }
842
1115
  }
843
1116
  }
@@ -974,6 +1247,36 @@ function withArticle(kind) {
974
1247
  const label = kindLabel(kind);
975
1248
  return `${/^[aeiou]/.test(label) ? "an" : "a"} ${label}`;
976
1249
  }
1250
+ /** True when the event is one of several recording sites of one logical event (GH #279). */
1251
+ function isSharedEventSite(model, el) {
1252
+ if (el.kind !== "event")
1253
+ return false;
1254
+ return (model.byName.get(normalizeName(el.name)) ?? []).some((x) => x.id !== el.id && x.kind === "event" && x.logicalId === el.logicalId);
1255
+ }
1256
+ /** A recording site is read when a view at or after its slice names the event (`from`, or a
1257
+ * `from`-less view in the same slice), a resolved `loops-to` leaves it, or an explicit arrow
1258
+ * leads from the event to a view at or after it. */
1259
+ function sharedSiteHasReader(model, site) {
1260
+ const key = normalizeName(site.name);
1261
+ for (const v of model.elements) {
1262
+ if (v.kind !== "view" || isEarlierOnTimeline(model, v.sliceIndex, site.sliceIndex))
1263
+ continue;
1264
+ const from = v.from ?? [];
1265
+ if (from.length ? from.some((n) => normalizeName(n) === key) : v.sliceIndex === site.sliceIndex)
1266
+ return true;
1267
+ }
1268
+ for (const name of site.loopsTo ?? [])
1269
+ if (resolveLoopsToTarget(model, site, name))
1270
+ return true;
1271
+ for (const a of model.arrows) {
1272
+ if (normalizeName(a.from) !== key)
1273
+ continue;
1274
+ const to = a.toId ? model.byId.get(a.toId) : undefined;
1275
+ if (to?.kind === "view" && !isEarlierOnTimeline(model, to.sliceIndex, site.sliceIndex))
1276
+ return true;
1277
+ }
1278
+ return false;
1279
+ }
977
1280
  function isReferenced(model, key) {
978
1281
  for (const a of model.arrows) {
979
1282
  if (normalizeName(a.from) === key || normalizeName(a.to) === key)