@milehimikey/em 1.14.2 → 1.16.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 (125) hide show
  1. package/.claude/agents/em-implementer.md +12 -2
  2. package/.claude/agents/em-validator.md +2 -1
  3. package/.claude/skills/event-modeling/SKILL.md +3 -3
  4. package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
  5. package/.claude/skills/event-modeling-design/SKILL.md +29 -18
  6. package/.claude/skills/event-modeling-discover/SKILL.md +1 -1
  7. package/.claude/skills/event-modeling-engagement/SKILL.md +60 -56
  8. package/.claude/skills/event-modeling-engagement/reference/engagement.md +13 -19
  9. package/.claude/skills/event-modeling-implement/SKILL.md +3 -3
  10. package/.claude/skills/event-modeling-implement/reference/implement.md +40 -27
  11. package/.claude/skills/event-modeling-review/SKILL.md +13 -3
  12. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +234 -228
  13. package/.claude/skills/event-modeling-shared/reference/operating-principles.md +3 -3
  14. package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +69 -27
  15. package/.claude/skills/event-modeling-shared/templates/constitution.md +5 -7
  16. package/.claude-plugin/marketplace.json +2 -2
  17. package/dist/catalog/deferQueue.d.ts +20 -0
  18. package/dist/catalog/deferQueue.js +63 -0
  19. package/dist/catalog/deferQueue.js.map +1 -0
  20. package/dist/catalog/docJoin.d.ts +22 -1
  21. package/dist/catalog/docJoin.js +13 -4
  22. package/dist/catalog/docJoin.js.map +1 -1
  23. package/dist/catalog/docModelConsistencyValidate.js +4 -8
  24. package/dist/catalog/docModelConsistencyValidate.js.map +1 -1
  25. package/dist/catalog/driftSignal.d.ts +16 -12
  26. package/dist/catalog/driftSignal.js +14 -9
  27. package/dist/catalog/driftSignal.js.map +1 -1
  28. package/dist/catalog/sliceDoc.d.ts +51 -0
  29. package/dist/catalog/sliceDoc.js +47 -0
  30. package/dist/catalog/sliceDoc.js.map +1 -1
  31. package/dist/catalog/sliceReadyValidate.js +19 -1
  32. package/dist/catalog/sliceReadyValidate.js.map +1 -1
  33. package/dist/catalog/sliceSections.d.ts +5 -0
  34. package/dist/catalog/sliceSections.js +11 -2
  35. package/dist/catalog/sliceSections.js.map +1 -1
  36. package/dist/cli/conformScope.d.ts +4 -0
  37. package/dist/cli/conformScope.js +2 -2
  38. package/dist/cli/conformScope.js.map +1 -1
  39. package/dist/cli/coverage.js +7 -3
  40. package/dist/cli/coverage.js.map +1 -1
  41. package/dist/cli/defer.d.ts +106 -2
  42. package/dist/cli/defer.js +357 -24
  43. package/dist/cli/defer.js.map +1 -1
  44. package/dist/cli/engagement.d.ts +26 -5
  45. package/dist/cli/engagement.js +55 -28
  46. package/dist/cli/engagement.js.map +1 -1
  47. package/dist/cli/engagementFile.d.ts +2 -0
  48. package/dist/cli/engagementFile.js +5 -3
  49. package/dist/cli/engagementFile.js.map +1 -1
  50. package/dist/cli/findings.d.ts +2 -2
  51. package/dist/cli/findings.js +2 -2
  52. package/dist/cli/frontmatterSurgery.d.ts +3 -3
  53. package/dist/cli/frontmatterSurgery.js +3 -3
  54. package/dist/cli/frontmatterSurgery.js.map +1 -1
  55. package/dist/cli/markImplemented.d.ts +23 -18
  56. package/dist/cli/markImplemented.js +99 -15
  57. package/dist/cli/markImplemented.js.map +1 -1
  58. package/dist/cli/ratify.d.ts +36 -6
  59. package/dist/cli/ratify.js +130 -35
  60. package/dist/cli/ratify.js.map +1 -1
  61. package/dist/cli/review.js +3 -3
  62. package/dist/cli/review.js.map +1 -1
  63. package/dist/cli/revise.d.ts +67 -0
  64. package/dist/cli/revise.js +229 -0
  65. package/dist/cli/revise.js.map +1 -0
  66. package/dist/cli/sliceConform.d.ts +4 -3
  67. package/dist/cli/sliceConform.js +29 -12
  68. package/dist/cli/sliceConform.js.map +1 -1
  69. package/dist/cli/status.d.ts +30 -6
  70. package/dist/cli/status.js +59 -19
  71. package/dist/cli/status.js.map +1 -1
  72. package/dist/cli/upgrade.js +17 -12
  73. package/dist/cli/upgrade.js.map +1 -1
  74. package/dist/cli.js +173 -85
  75. package/dist/cli.js.map +1 -1
  76. package/dist/emit/engagementJson.d.ts +7 -4
  77. package/dist/emit/engagementJson.js +11 -4
  78. package/dist/emit/engagementJson.js.map +1 -1
  79. package/dist/emit/json.d.ts +1 -1
  80. package/dist/emit/json.js +18 -5
  81. package/dist/emit/json.js.map +1 -1
  82. package/dist/emit/statusJson.d.ts +1 -1
  83. package/dist/emit/statusJson.js +6 -1
  84. package/dist/emit/statusJson.js.map +1 -1
  85. package/dist/mcp/server.js +5 -5
  86. package/dist/mcp/server.js.map +1 -1
  87. package/dist/model/continuation.d.ts +11 -1
  88. package/dist/model/continuation.js +29 -0
  89. package/dist/model/continuation.js.map +1 -1
  90. package/dist/model/resolveSlice.d.ts +19 -0
  91. package/dist/model/resolveSlice.js +69 -0
  92. package/dist/model/resolveSlice.js.map +1 -0
  93. package/dist/model/rules.d.ts +8 -0
  94. package/dist/model/rules.js +8 -0
  95. package/dist/model/rules.js.map +1 -1
  96. package/dist/model/sliceDocIndex.d.ts +5 -0
  97. package/dist/model/sliceDocIndex.js +6 -4
  98. package/dist/model/sliceDocIndex.js.map +1 -1
  99. package/dist/model/validate.js +5 -2
  100. package/dist/model/validate.js.map +1 -1
  101. package/dist/parser/parser.js +55 -1
  102. package/dist/parser/parser.js.map +1 -1
  103. package/dist/templates.d.ts +1 -1
  104. package/dist/templates.js +5 -7
  105. package/dist/templates.js.map +1 -1
  106. package/package.json +1 -1
  107. package/plugin/.claude-plugin/plugin.json +2 -2
  108. package/plugin/agents/em-implementer.md +12 -2
  109. package/plugin/agents/em-validator.md +2 -1
  110. package/plugin/shared/reference/em-dsl.md +234 -228
  111. package/plugin/shared/reference/operating-principles.md +3 -3
  112. package/plugin/shared/reference/slice-doc-schema.md +69 -27
  113. package/plugin/shared/templates/constitution.md +5 -7
  114. package/plugin/skills/conform/SKILL.md +1 -1
  115. package/plugin/skills/design/SKILL.md +29 -18
  116. package/plugin/skills/discover/SKILL.md +1 -1
  117. package/plugin/skills/engagement/SKILL.md +60 -56
  118. package/plugin/skills/engagement/reference/engagement.md +13 -19
  119. package/plugin/skills/event-modeling/SKILL.md +3 -3
  120. package/plugin/skills/implement/SKILL.md +3 -3
  121. package/plugin/skills/implement/reference/implement.md +40 -27
  122. package/plugin/skills/review/SKILL.md +13 -3
  123. package/dist/cli/reratify.d.ts +0 -58
  124. package/dist/cli/reratify.js +0 -238
  125. package/dist/cli/reratify.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { NormalizedModel } from "../model/model.js";
2
2
  import { RefsResult } from "../model/refs.js";
3
+ import { QueuedItem } from "../catalog/deferQueue.js";
3
4
  /** `v<int>` → int, else null. */
4
5
  export declare function parseUntil(s: string): number | null;
5
6
  export interface DeferInput {
@@ -25,6 +26,12 @@ export type ApplyDeferResult = {
25
26
  * Already deferred to the same version → `{ok: true, changed: false}` (idempotent no-op).
26
27
  */
27
28
  export declare function applyDefer(raw: string, input: DeferInput): ApplyDeferResult;
29
+ /** Appends `bullet` at the end of the section under `heading` (before the next `## ` heading or
30
+ * EOF, after the section's last non-blank line) unless an identical bullet line exists. */
31
+ export declare function appendBullet(lines: string[], heading: RegExp, bullet: string, eol: string): {
32
+ lines: string[];
33
+ changed: boolean;
34
+ } | null;
28
35
  export interface StateMirrorInput {
29
36
  question: string;
30
37
  sliceKey: string;
@@ -36,20 +43,117 @@ export interface StateMirrorInput {
36
43
  }
37
44
  /** Pure: mirrors the deferral into the state file's parking lot and Decisions log. */
38
45
  export declare function applyDeferStateFile(raw: string, input: StateMirrorInput): ApplyDeferResult;
39
- /** Pure (reratify): re-opens every `- [x] … — v<old>: …; deferred to v<newVersion> (…)` item under
46
+ /** Pure (revise): re-opens every `- [x] … — v<old>: …; deferred to v<newVersion> (…)` item under
40
47
  * `## Open Questions` as `- [ ] <original text>`. Items deferred to a later version are untouched. */
41
48
  export declare function reopenDeferred(raw: string, newVersion: number): {
42
49
  content: string;
43
50
  count: number;
44
51
  };
52
+ /** True when the doc's body is a signed spec that defer must not touch: `status: implemented`, or
53
+ * `status: ready-to-implement` with a `ratifiedBy:`. A `draft`/`reviewed` doc — including the
54
+ * next version of a shipped slice — is still being written, so defer edits it as ever. */
55
+ export declare function isLockedDoc(raw: string): boolean;
56
+ /** Pure: mirrors a QUEUED deferral into the state file — the queued parking-lot bullet and a
57
+ * dated Decisions-log bullet. Idempotent on an identical bullet. */
58
+ export declare function applyQueueStateFile(raw: string, input: StateMirrorInput): ApplyDeferResult;
59
+ /** Pure (revise / mark-implemented): appends every queued item to `## Open Questions` of the
60
+ * freshly opened draft (creating the section at EOF when the doc has none), skipping a line
61
+ * already present. Returns how many items were written. */
62
+ export declare function injectQueuedQuestions(raw: string, items: QueuedItem[]): {
63
+ content: string;
64
+ count: number;
65
+ };
66
+ /** Pure: "clears" the queue for `<sliceKey>@v<targetVersion>` in the state file — each queued
67
+ * parking-lot bullet becomes the ordinary `- <q> (from slice <key>, deferred to v<until>)`
68
+ * mirror a draft-doc defer writes. Other slices' and other versions' bullets are untouched. */
69
+ export declare function drainQueueInState(raw: string, sliceKey: string, targetVersion: number): {
70
+ content: string;
71
+ count: number;
72
+ };
73
+ /** Reads the queue for `<sliceKey>@v<targetVersion>` from `<baseDir>/.event-modeling.md`; empty
74
+ * when the file is absent. */
75
+ export declare function readQueue(baseDir: string, sliceKey: string, targetVersion: number): {
76
+ items: QueuedItem[];
77
+ statePath: string;
78
+ stateRaw: string | null;
79
+ };
80
+ /** Writes the drained state file once the draft carrying the queue is on disk. */
81
+ export declare function clearQueue(baseDir: string, sliceKey: string, targetVersion: number): void;
82
+ /** An open `issue "..."` clause on one of the slice's own elements. */
83
+ export interface IssueCandidate {
84
+ kind: string;
85
+ name: string;
86
+ /** The element's declaration line in the `.em` (1-based) — where the clause search starts. */
87
+ line: number;
88
+ issue: string;
89
+ }
90
+ /** Open issues on the slice's own elements whose text contains `text` (case-sensitive substring,
91
+ * the same matching the positional path applies to Open Questions). An issue on a sibling
92
+ * slice's element is never a candidate — the deferral lands on THIS slice's doc. */
93
+ export declare function findIssueCandidates(model: NormalizedModel, sliceIndex: number, text: string): IssueCandidate[];
94
+ export declare function formatIssueCandidate(c: IssueCandidate): string;
95
+ export type RemoveIssueResult = {
96
+ ok: true;
97
+ content: string;
98
+ removedLine: number;
99
+ } | {
100
+ ok: false;
101
+ message: string;
102
+ };
103
+ /**
104
+ * Pure: removes the one `issue "<issueText>"` clause that belongs to the element declared at
105
+ * `startLine` (1-based). The clause may sit on the declaration line or trail the element's
106
+ * `{ … }` field block (parser.ts's `extractClauses` accepts both), so the scan runs forward from
107
+ * `startLine` and stops at the first line carrying an `issue` clause that decodes to
108
+ * `issueText`. The clause span (plus the one whitespace run before it) is cut out of that line;
109
+ * when nothing but whitespace is left, the whole line goes, EOL included. Everything else —
110
+ * other clauses on the line, every other line, the file's EOL style — is copied through
111
+ * verbatim. The caller re-parses the result before accepting it (`runDeferFromIssue`).
112
+ */
113
+ export declare function removeIssueClause(raw: string, startLine: number, issueText: string): RemoveIssueResult;
114
+ /**
115
+ * Pure: records `issueText` on the doc as an already-deferred Open Question. If an unchecked
116
+ * item containing the text already exists (someone wrote it down by hand), that item is deferred
117
+ * in place via `applyDefer` — never a duplicate. Otherwise the `[x]` item is appended to
118
+ * `## Open Questions`, creating the section at EOF when the doc has none (a pre-template doc).
119
+ * An identical item already present → `changed: false`.
120
+ */
121
+ export declare function applyDeferFromIssueDoc(raw: string, issueText: string, input: Omit<DeferInput, "question">): ApplyDeferResult;
45
122
  export type RunDeferResult = {
46
123
  ok: true;
47
124
  path: string;
48
125
  changed: boolean;
126
+ queued: QueuedNote | null;
49
127
  } | {
50
128
  ok: false;
51
129
  message: string;
52
130
  };
53
- /** Resolves `sliceKey` to its bound doc (same join as ratify/reratify), computes both edits, and
131
+ /** GH #236: set when defer QUEUED the question (a ratified/shipped doc) instead of editing the
132
+ * doc — `forVersion` is the draft that will carry it, `docVersion` the version left untouched. */
133
+ export interface QueuedNote {
134
+ forVersion: number;
135
+ docVersion: number;
136
+ }
137
+ /** Resolves `sliceKey` to its bound doc (same join as ratify/revise), computes both edits, and
54
138
  * writes only when every precondition holds (the state file is required and never created). */
55
139
  export declare function runDefer(model: NormalizedModel, refs: RefsResult, baseDir: string, sliceKey: string, input: Omit<DeferInput, "currentVersion">): RunDeferResult;
140
+ export type RunDeferFromIssueResult = {
141
+ ok: true;
142
+ path: string;
143
+ changed: boolean;
144
+ issue: string;
145
+ modelLine: number | null;
146
+ queued: QueuedNote | null;
147
+ } | {
148
+ ok: false;
149
+ message: string;
150
+ };
151
+ /**
152
+ * `--from-issue <text>` (MIL-281): the single open `issue "..."` on one of the slice's own
153
+ * elements whose text contains `text` becomes a deferred Open Question on the doc, is mirrored
154
+ * into the state file, and its clause is removed from `modelFile`. All three edits are computed
155
+ * first; nothing is written unless every one succeeds. Zero candidates with the question already
156
+ * deferred on the doc → the idempotent no-op; zero otherwise → refuse; two or more → refuse
157
+ * naming each with its `.em` line.
158
+ */
159
+ export declare function runDeferFromIssue(model: NormalizedModel, refs: RefsResult, baseDir: string, modelFile: string, sliceKey: string, text: string, input: Omit<DeferInput, "question" | "currentVersion">): RunDeferFromIssueResult;
package/dist/cli/defer.js CHANGED
@@ -2,17 +2,41 @@
2
2
  // `em slice defer` (MIL-275): the missing verb behind docs/process.md's "resolved or explicitly
3
3
  // deferred". Rewrites ONE unchecked `## Open Questions` item as a checked, dated "deferred to
4
4
  // v<n>" item, and mirrors it into the model's `.event-modeling.md` (the `## Open questions /
5
- // parking lot` bullet and a dated `## Decisions log` bullet), idempotently. `em slice reratify`
6
- // reads the `deferred to v<n>` marker back and re-opens the item (`reopenDeferred`, below).
5
+ // parking lot` bullet and a dated `## Decisions log` bullet), idempotently. `em slice revise`
6
+ // (and `mark-implemented`'s auto-open, MIL-283) reads the `deferred to v<n>` marker back and re-opens the item (`reopenDeferred`, below).
7
7
  //
8
- // Write strategy: surgical line edits (the same discipline as ratify.ts/reratify.ts) — every
8
+ // Write strategy: surgical line edits (the same discipline as ratify.ts/revise.ts) — every
9
9
  // byte outside the edited line / appended bullet, including the file's own EOL style, is copied
10
10
  // through verbatim. Pure `apply*` functions here; the fs orchestration (`runDefer`) is thin.
11
+ //
12
+ // MIL-281 — `--from-issue`: the question a review session actually produces is an `issue "..."`
13
+ // clause in the `.em` (what the walkthrough captures live, what `em validate --list-issues`
14
+ // lists), not an item under the doc's `## Open Questions`; defer could only see the latter, so a
15
+ // room-raised question took a hand edit of the doc before it could be deferred. `--from-issue
16
+ // <text>` promotes the single matching open issue on the slice's own elements into the doc as an
17
+ // already-deferred `[x]` item, mirrors it into the state file exactly like the positional path,
18
+ // and REMOVES the `issue` clause from the `.em` — the deferral record replaces the red note, so
19
+ // the diagram stops flagging a question the doc now tracks with a return path. Three files, all
20
+ // computed before any is written; the `.em` edit is a single clause span (or the whole line when
21
+ // the clause was alone on it), re-parsed before it is accepted. Not a generic "move issue to doc"
22
+ // verb: the only thing a promoted issue can become is a deferred question.
23
+ //
24
+ // GH #236 — a RATIFIED or SHIPPED doc is never edited by defer. Its body is the signed spec
25
+ // (`ratifiedHash`), so on `status: ready-to-implement` + `ratifiedBy:` or `status: implemented`
26
+ // both paths QUEUE the question instead: one bullet in the state file's parking lot plus the dated
27
+ // Decisions-log bullet, the doc untouched (catalog/deferQueue.ts has the bullet format).
28
+ // `revise`/`mark-implemented` inject the queue into the v<N+1> draft they open
29
+ // (`injectQueuedQuestions`) and `drainQueueInState` turns the bullets back into ordinary
30
+ // deferral mirrors. `draft`/`reviewed` docs behave exactly as before.
11
31
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
32
  import { join } from "node:path";
13
33
  import { continuationOf } from "../model/continuation.js";
14
34
  import { resolveSliceDocJoin } from "../catalog/docJoin.js";
35
+ import { decodeQuoted, matchQuote } from "../parser/lexer.js";
36
+ import { compile } from "../pipeline.js";
15
37
  import { STATE_FILE_NAME } from "./stateFile.js";
38
+ import { fieldLineRegex, locateFrontmatterInner, normalizeFieldValue } from "./frontmatterSurgery.js";
39
+ import { parseQueuedBullet, queuedBullet, queuedItems } from "../catalog/deferQueue.js";
16
40
  const OPEN_QUESTIONS_HEADING = /^##\s+Open Questions\s*$/i;
17
41
  const TASK_ITEM = /^([ \t]*[-*]\s*)\[([ xX])\](.*)$/;
18
42
  const PARKING_LOT_HEADING = /^##\s+Open questions \/ parking lot\s*$/i;
@@ -115,7 +139,7 @@ export function applyDefer(raw, input) {
115
139
  }
116
140
  /** Appends `bullet` at the end of the section under `heading` (before the next `## ` heading or
117
141
  * EOF, after the section's last non-blank line) unless an identical bullet line exists. */
118
- function appendBullet(lines, heading, bullet, eol) {
142
+ export function appendBullet(lines, heading, bullet, eol) {
119
143
  const start = lines.findIndex((l) => heading.test(stripEol(l).body));
120
144
  if (start === -1)
121
145
  return null;
@@ -135,7 +159,9 @@ function appendBullet(lines, heading, bullet, eol) {
135
159
  const out = lines.slice();
136
160
  if (out[last] !== undefined && !/\n$/.test(out[last]))
137
161
  out[last] = out[last] + eol;
138
- out.splice(last + 1, 0, bullet + eol);
162
+ // An empty section (nothing but the heading) keeps a blank line between heading and first
163
+ // item — the shape the slice-doc template and every hand-written section use.
164
+ out.splice(last + 1, 0, ...(last === start ? [eol, bullet + eol] : [bullet + eol]));
139
165
  return { lines: out, changed: true };
140
166
  }
141
167
  /** Pure: mirrors the deferral into the state file's parking lot and Decisions log. */
@@ -154,7 +180,7 @@ export function applyDeferStateFile(raw, input) {
154
180
  return { ok: false, message: `${STATE_FILE_NAME} has no "## Decisions log" heading` };
155
181
  return { ok: true, content: b.lines.join(""), changed: a.changed || b.changed };
156
182
  }
157
- /** Pure (reratify): re-opens every `- [x] … — v<old>: …; deferred to v<newVersion> (…)` item under
183
+ /** Pure (revise): re-opens every `- [x] … — v<old>: …; deferred to v<newVersion> (…)` item under
158
184
  * `## Open Questions` as `- [ ] <original text>`. Items deferred to a later version are untouched. */
159
185
  export function reopenDeferred(raw, newVersion) {
160
186
  const lines = splitKeepEol(raw);
@@ -182,9 +208,204 @@ export function reopenDeferred(raw, newVersion) {
182
208
  }
183
209
  return { content: lines.join(""), count };
184
210
  }
185
- /** Resolves `sliceKey` to its bound doc (same join as ratify/reratify), computes both edits, and
186
- * writes only when every precondition holds (the state file is required and never created). */
187
- export function runDefer(model, refs, baseDir, sliceKey, input) {
211
+ // ---- GH #236: the queue for a ratified / shipped doc --------------------------------------
212
+ /** True when the doc's body is a signed spec that defer must not touch: `status: implemented`, or
213
+ * `status: ready-to-implement` with a `ratifiedBy:`. A `draft`/`reviewed` doc — including the
214
+ * next version of a shipped slice — is still being written, so defer edits it as ever. */
215
+ export function isLockedDoc(raw) {
216
+ const range = locateFrontmatterInner(raw);
217
+ if (!range)
218
+ return false;
219
+ const inner = raw.slice(range.innerStart, range.innerEnd);
220
+ const statusMatch = fieldLineRegex("status").exec(inner);
221
+ const status = statusMatch ? normalizeFieldValue(statusMatch[2])?.toLowerCase() : null;
222
+ if (status === "implemented")
223
+ return true;
224
+ if (status !== "ready-to-implement")
225
+ return false;
226
+ const by = fieldLineRegex("ratifiedBy").exec(inner);
227
+ return by !== null && normalizeFieldValue(by[2]) !== null;
228
+ }
229
+ /** Pure: mirrors a QUEUED deferral into the state file — the queued parking-lot bullet and a
230
+ * dated Decisions-log bullet. Idempotent on an identical bullet. */
231
+ export function applyQueueStateFile(raw, input) {
232
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
233
+ const target = input.currentVersion + 1;
234
+ const parking = queuedBullet({
235
+ question: input.question,
236
+ sliceKey: input.sliceKey,
237
+ targetVersion: target,
238
+ until: input.until,
239
+ on: input.on,
240
+ ...(input.by !== undefined ? { by: input.by.trim() } : {}),
241
+ decision: input.decision.trim(),
242
+ });
243
+ const decision = `- ${input.on}: queued "${input.question}" on ${input.sliceKey} for ${input.sliceKey}@v${target} (until v${input.until}) — ` +
244
+ `v${input.currentVersion}: ${input.decision.trim()}${input.by !== undefined ? ` — by ${input.by.trim()}` : ""}`;
245
+ let lines = splitKeepEol(raw);
246
+ const a = appendBullet(lines, PARKING_LOT_HEADING, parking, eol);
247
+ if (!a)
248
+ return { ok: false, message: `${STATE_FILE_NAME} has no "## Open questions / parking lot" heading` };
249
+ lines = a.lines;
250
+ const b = appendBullet(lines, DECISIONS_HEADING, decision, eol);
251
+ if (!b)
252
+ return { ok: false, message: `${STATE_FILE_NAME} has no "## Decisions log" heading` };
253
+ return { ok: true, content: b.lines.join(""), changed: a.changed || b.changed };
254
+ }
255
+ /** Pure: the Open Questions line a queued item becomes in the v<target> draft. Aimed at the
256
+ * draft's own version it is an unchecked `- [ ]` carrying its decision; aimed further out
257
+ * (`--until` beyond it) it is the ordinary checked deferral, so the draft is not asked to
258
+ * answer what the room already pushed past it. */
259
+ function injectedLine(item) {
260
+ const prior = item.targetVersion - 1;
261
+ const who = `${item.on}${item.by !== undefined ? `, ${item.by}` : ""}`;
262
+ return item.until > item.targetVersion
263
+ ? `- [x] ${item.question} — v${prior}: ${item.decision}; deferred to v${item.until} (${who})`
264
+ : `- [ ] ${item.question} — v${prior}: ${item.decision} (queued ${who})`;
265
+ }
266
+ /** Pure (revise / mark-implemented): appends every queued item to `## Open Questions` of the
267
+ * freshly opened draft (creating the section at EOF when the doc has none), skipping a line
268
+ * already present. Returns how many items were written. */
269
+ export function injectQueuedQuestions(raw, items) {
270
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
271
+ let content = raw;
272
+ let count = 0;
273
+ for (const item of items) {
274
+ const bullet = injectedLine(item);
275
+ const appended = appendBullet(splitKeepEol(content), OPEN_QUESTIONS_HEADING, bullet, eol);
276
+ if (appended) {
277
+ if (appended.changed)
278
+ count++;
279
+ content = appended.lines.join("");
280
+ continue;
281
+ }
282
+ if (content.length > 0 && !/\r?\n$/.test(content))
283
+ content += eol;
284
+ if (content.length > 0 && !/(\r?\n){2}$/.test(content))
285
+ content += eol;
286
+ content += `## Open Questions${eol}${eol}${bullet}${eol}`;
287
+ count++;
288
+ }
289
+ return { content, count };
290
+ }
291
+ /** Pure: "clears" the queue for `<sliceKey>@v<targetVersion>` in the state file — each queued
292
+ * parking-lot bullet becomes the ordinary `- <q> (from slice <key>, deferred to v<until>)`
293
+ * mirror a draft-doc defer writes. Other slices' and other versions' bullets are untouched. */
294
+ export function drainQueueInState(raw, sliceKey, targetVersion) {
295
+ const lines = splitKeepEol(raw);
296
+ let count = 0;
297
+ for (let j = 0; j < lines.length; j++) {
298
+ const { body, eol } = stripEol(lines[j]);
299
+ const item = parseQueuedBullet(body);
300
+ if (!item || item.sliceKey !== sliceKey || item.targetVersion !== targetVersion)
301
+ continue;
302
+ lines[j] = `- ${item.question} (from slice ${item.sliceKey}, deferred to v${item.until})${eol}`;
303
+ count++;
304
+ }
305
+ return { content: lines.join(""), count };
306
+ }
307
+ /** Reads the queue for `<sliceKey>@v<targetVersion>` from `<baseDir>/.event-modeling.md`; empty
308
+ * when the file is absent. */
309
+ export function readQueue(baseDir, sliceKey, targetVersion) {
310
+ const statePath = join(baseDir, STATE_FILE_NAME);
311
+ if (!existsSync(statePath))
312
+ return { items: [], statePath, stateRaw: null };
313
+ const stateRaw = readFileSync(statePath, "utf8");
314
+ return { items: queuedItems(stateRaw, sliceKey, targetVersion), statePath, stateRaw };
315
+ }
316
+ /** Writes the drained state file once the draft carrying the queue is on disk. */
317
+ export function clearQueue(baseDir, sliceKey, targetVersion) {
318
+ const { stateRaw, statePath } = readQueue(baseDir, sliceKey, targetVersion);
319
+ if (stateRaw === null)
320
+ return;
321
+ const drained = drainQueueInState(stateRaw, sliceKey, targetVersion);
322
+ if (drained.count > 0)
323
+ writeFileSync(statePath, drained.content, "utf8");
324
+ }
325
+ /** Open issues on the slice's own elements whose text contains `text` (case-sensitive substring,
326
+ * the same matching the positional path applies to Open Questions). An issue on a sibling
327
+ * slice's element is never a candidate — the deferral lands on THIS slice's doc. */
328
+ export function findIssueCandidates(model, sliceIndex, text) {
329
+ return model.elements
330
+ .filter((el) => el.sliceIndex === sliceIndex && el.issue !== undefined && el.issue.includes(text))
331
+ .map((el) => ({ kind: el.kind, name: el.name, line: el.line, issue: el.issue }));
332
+ }
333
+ export function formatIssueCandidate(c) {
334
+ return `${c.kind} "${c.name}" :${c.line} issue "${c.issue}"`;
335
+ }
336
+ /**
337
+ * Pure: removes the one `issue "<issueText>"` clause that belongs to the element declared at
338
+ * `startLine` (1-based). The clause may sit on the declaration line or trail the element's
339
+ * `{ … }` field block (parser.ts's `extractClauses` accepts both), so the scan runs forward from
340
+ * `startLine` and stops at the first line carrying an `issue` clause that decodes to
341
+ * `issueText`. The clause span (plus the one whitespace run before it) is cut out of that line;
342
+ * when nothing but whitespace is left, the whole line goes, EOL included. Everything else —
343
+ * other clauses on the line, every other line, the file's EOL style — is copied through
344
+ * verbatim. The caller re-parses the result before accepting it (`runDeferFromIssue`).
345
+ */
346
+ export function removeIssueClause(raw, startLine, issueText) {
347
+ const lines = splitKeepEol(raw);
348
+ const keyword = /(?:^|\s)issue\s+(?=")/gi;
349
+ for (let j = Math.max(0, startLine - 1); j < lines.length; j++) {
350
+ const { body, eol } = stripEol(lines[j]);
351
+ keyword.lastIndex = 0;
352
+ let m;
353
+ while ((m = keyword.exec(body)) !== null) {
354
+ const openIdx = m.index + m[0].length;
355
+ const closeIdx = matchQuote(body, openIdx);
356
+ if (closeIdx < 0)
357
+ break; // unterminated — the parser already refused this file
358
+ if (decodeQuoted(body.slice(openIdx + 1, closeIdx)) !== issueText)
359
+ continue;
360
+ const before = body.slice(0, m.index);
361
+ const after = body.slice(closeIdx + 1);
362
+ const rest = (before + after).replace(/[ \t]+$/, "");
363
+ const out = lines.slice();
364
+ if (rest.trim() === "")
365
+ out.splice(j, 1);
366
+ else
367
+ out[j] = rest + eol;
368
+ return { ok: true, content: out.join(""), removedLine: j + 1 };
369
+ }
370
+ }
371
+ return { ok: false, message: `no \`issue "${issueText}"\` clause found at or after line ${startLine}` };
372
+ }
373
+ /**
374
+ * Pure: records `issueText` on the doc as an already-deferred Open Question. If an unchecked
375
+ * item containing the text already exists (someone wrote it down by hand), that item is deferred
376
+ * in place via `applyDefer` — never a duplicate. Otherwise the `[x]` item is appended to
377
+ * `## Open Questions`, creating the section at EOF when the doc has none (a pre-template doc).
378
+ * An identical item already present → `changed: false`.
379
+ */
380
+ export function applyDeferFromIssueDoc(raw, issueText, input) {
381
+ const full = { ...input, question: issueText };
382
+ const v = validateInput(full);
383
+ if (!v.ok)
384
+ return v;
385
+ const existing = applyDefer(raw, full);
386
+ if (existing.ok)
387
+ return existing;
388
+ if (!existing.message.startsWith("no unchecked Open Question matching"))
389
+ return existing; // ambiguous / already checked
390
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
391
+ const bullet = `- [x] ${issueText} — v${input.currentVersion}: ${input.decision.trim()}; deferred to v${v.until} ` +
392
+ `(${input.on}${input.by !== undefined ? `, ${input.by.trim()}` : ""})`;
393
+ const lines = splitKeepEol(raw);
394
+ const appended = appendBullet(lines, OPEN_QUESTIONS_HEADING, bullet, eol);
395
+ if (appended)
396
+ return { ok: true, content: appended.lines.join(""), changed: appended.changed };
397
+ // No `## Open Questions` section at all: open one at the end of the doc.
398
+ let content = raw;
399
+ if (content.length > 0 && !/\r?\n$/.test(content))
400
+ content += eol;
401
+ if (content.length > 0 && !/(\r?\n){2}$/.test(content))
402
+ content += eol;
403
+ content += `## Open Questions${eol}${eol}${bullet}${eol}`;
404
+ return { ok: true, content, changed: true };
405
+ }
406
+ /** The doc resolution both paths share (same join as ratify/revise): continuation and
407
+ * binding refusals, frontmatter validity, the doc's current version. */
408
+ function resolveDeferDoc(model, refs, baseDir, sliceKey) {
188
409
  const sliceIndex = refs.sliceKeys.indexOf(sliceKey);
189
410
  if (sliceIndex === -1)
190
411
  return { ok: false, message: `no slice with export key "${sliceKey}" in this model` };
@@ -214,31 +435,143 @@ export function runDefer(model, refs, baseDir, sliceKey, input) {
214
435
  message: `slice doc "${doc.path}" has missing or invalid frontmatter — run \`em validate\` for details`,
215
436
  };
216
437
  }
217
- const full = { ...input, currentVersion: doc.version };
218
- const absDoc = join(baseDir, doc.path);
219
- const result = applyDefer(readFileSync(absDoc, "utf8"), full);
220
- if (!result.ok)
221
- return { ok: false, message: `${doc.path}: ${result.message}` };
438
+ return { ok: true, sliceIndex, path: doc.path, version: doc.version };
439
+ }
440
+ /** The state-file mirror both paths share; refuses (never creates) when the file is absent. */
441
+ function mirrorIntoStateFile(baseDir, sliceKey, question, input, currentVersion, queue = false) {
222
442
  const statePath = join(baseDir, STATE_FILE_NAME);
223
443
  if (!existsSync(statePath)) {
224
444
  return { ok: false, message: `no ${STATE_FILE_NAME} beside the model — defer mirrors into it and never creates it` };
225
445
  }
226
- const until = parseUntil(input.until);
227
- const stateResult = applyDeferStateFile(readFileSync(statePath, "utf8"), {
228
- question: input.question,
446
+ const r = (queue ? applyQueueStateFile : applyDeferStateFile)(readFileSync(statePath, "utf8"), {
447
+ question,
229
448
  sliceKey,
230
- until,
449
+ until: parseUntil(input.until),
231
450
  decision: input.decision,
232
451
  by: input.by,
233
452
  on: input.on,
234
- currentVersion: doc.version,
453
+ currentVersion,
235
454
  });
236
- if (!stateResult.ok)
237
- return stateResult;
455
+ if (!r.ok)
456
+ return r;
457
+ return { ok: true, path: statePath, content: r.content, changed: r.changed };
458
+ }
459
+ /** Resolves `sliceKey` to its bound doc (same join as ratify/revise), computes both edits, and
460
+ * writes only when every precondition holds (the state file is required and never created). */
461
+ export function runDefer(model, refs, baseDir, sliceKey, input) {
462
+ const doc = resolveDeferDoc(model, refs, baseDir, sliceKey);
463
+ if (!doc.ok)
464
+ return doc;
465
+ const absDoc = join(baseDir, doc.path);
466
+ const docRaw = readFileSync(absDoc, "utf8");
467
+ // GH #236: a ratified / shipped doc is not edited — the question is queued for v<N+1>.
468
+ if (isLockedDoc(docRaw)) {
469
+ const v = validateInput({ ...input, currentVersion: doc.version });
470
+ if (!v.ok)
471
+ return { ok: false, message: `${doc.path}: ${v.message}` };
472
+ const state = mirrorIntoStateFile(baseDir, sliceKey, input.question, input, doc.version, true);
473
+ if (!state.ok)
474
+ return state;
475
+ if (state.changed)
476
+ writeFileSync(state.path, state.content, "utf8");
477
+ return { ok: true, path: doc.path, changed: state.changed, queued: { forVersion: doc.version + 1, docVersion: doc.version } };
478
+ }
479
+ const result = applyDefer(docRaw, { ...input, currentVersion: doc.version });
480
+ if (!result.ok)
481
+ return { ok: false, message: `${doc.path}: ${result.message}` };
482
+ const state = mirrorIntoStateFile(baseDir, sliceKey, input.question, input, doc.version);
483
+ if (!state.ok)
484
+ return state;
238
485
  if (result.changed)
239
486
  writeFileSync(absDoc, result.content, "utf8");
240
- if (stateResult.changed)
241
- writeFileSync(statePath, stateResult.content, "utf8");
242
- return { ok: true, path: doc.path, changed: result.changed || stateResult.changed };
487
+ if (state.changed)
488
+ writeFileSync(state.path, state.content, "utf8");
489
+ return { ok: true, path: doc.path, changed: result.changed || state.changed, queued: null };
490
+ }
491
+ /**
492
+ * `--from-issue <text>` (MIL-281): the single open `issue "..."` on one of the slice's own
493
+ * elements whose text contains `text` becomes a deferred Open Question on the doc, is mirrored
494
+ * into the state file, and its clause is removed from `modelFile`. All three edits are computed
495
+ * first; nothing is written unless every one succeeds. Zero candidates with the question already
496
+ * deferred on the doc → the idempotent no-op; zero otherwise → refuse; two or more → refuse
497
+ * naming each with its `.em` line.
498
+ */
499
+ export function runDeferFromIssue(model, refs, baseDir, modelFile, sliceKey, text, input) {
500
+ if (text.trim() === "")
501
+ return { ok: false, message: "--from-issue needs the issue's text (a case-sensitive substring)" };
502
+ const doc = resolveDeferDoc(model, refs, baseDir, sliceKey);
503
+ if (!doc.ok)
504
+ return doc;
505
+ const absDoc = join(baseDir, doc.path);
506
+ const docRaw = readFileSync(absDoc, "utf8");
507
+ const locked = isLockedDoc(docRaw);
508
+ const queued = locked ? { forVersion: doc.version + 1, docVersion: doc.version } : null;
509
+ const candidates = findIssueCandidates(model, doc.sliceIndex, text);
510
+ if (candidates.length === 0) {
511
+ // Already promoted on an earlier run? Then the doc carries it as deferred to this version —
512
+ // or, on a ratified/shipped doc (GH #236), the state file carries it as queued.
513
+ if (locked) {
514
+ if (readQueue(baseDir, sliceKey, doc.version + 1).items.some((q) => q.question.includes(text))) {
515
+ return { ok: true, path: doc.path, changed: false, issue: text, modelLine: null, queued };
516
+ }
517
+ return { ok: false, message: `no open issue matching "${text}" on slice "${sliceKey}" (see \`em validate --list-issues\`)` };
518
+ }
519
+ const probe = applyDefer(docRaw, { ...input, question: text, currentVersion: doc.version });
520
+ if (probe.ok && !probe.changed)
521
+ return { ok: true, path: doc.path, changed: false, issue: text, modelLine: null, queued: null };
522
+ if (probe.ok) {
523
+ // The question is on the doc as a hand-written `- [ ]` item, never on the diagram: that is
524
+ // the positional form's job.
525
+ return {
526
+ ok: false,
527
+ message: `no open issue matching "${text}" on slice "${sliceKey}" — but ${doc.path} has an unchecked Open Question ` +
528
+ `containing it; defer that with the positional form: em slice defer <model> ${sliceKey} "${text}" --until ${input.until} --decision "..."`,
529
+ };
530
+ }
531
+ return { ok: false, message: `no open issue matching "${text}" on slice "${sliceKey}" (see \`em validate --list-issues\`)` };
532
+ }
533
+ if (candidates.length > 1) {
534
+ return { ok: false, message: `ambiguous — matches: ${candidates.map(formatIssueCandidate).join(" | ")}` };
535
+ }
536
+ const [cand] = candidates;
537
+ // GH #236: on a ratified/shipped doc the promoted issue is queued, not written into the body.
538
+ const docResult = locked
539
+ ? (() => {
540
+ const v = validateInput({ ...input, question: cand.issue, currentVersion: doc.version });
541
+ return v.ok ? { ok: true, content: docRaw, changed: false } : v;
542
+ })()
543
+ : applyDeferFromIssueDoc(docRaw, cand.issue, { ...input, currentVersion: doc.version });
544
+ if (!docResult.ok)
545
+ return { ok: false, message: `${doc.path}: ${docResult.message}` };
546
+ const state = mirrorIntoStateFile(baseDir, sliceKey, cand.issue, input, doc.version, locked);
547
+ if (!state.ok)
548
+ return state;
549
+ const emRaw = readFileSync(modelFile, "utf8");
550
+ const removed = removeIssueClause(emRaw, cand.line, cand.issue);
551
+ if (!removed.ok)
552
+ return { ok: false, message: `${modelFile}: ${removed.message}` };
553
+ // Verify before write: the edited model must still compile, and this issue must be gone from
554
+ // the slice — the same discipline `em migrate` holds for its own `.em` rewrite. (The candidate
555
+ // was the slice's only issue containing its own text — a second one would have been ambiguous
556
+ // above — so "gone" is simply "no element of the slice still carries it".)
557
+ let clean;
558
+ try {
559
+ const after = compile(removed.content);
560
+ clean =
561
+ !after.diagnostics.some((d) => d.severity === "error") &&
562
+ !after.model.elements.some((el) => el.sliceIndex === doc.sliceIndex && el.issue === cand.issue);
563
+ }
564
+ catch {
565
+ clean = false;
566
+ }
567
+ if (!clean) {
568
+ return { ok: false, message: `${modelFile}: removing the issue clause at line ${removed.removedLine} would not leave a clean model — nothing written` };
569
+ }
570
+ if (docResult.changed)
571
+ writeFileSync(absDoc, docResult.content, "utf8");
572
+ if (state.changed)
573
+ writeFileSync(state.path, state.content, "utf8");
574
+ writeFileSync(modelFile, removed.content, "utf8");
575
+ return { ok: true, path: doc.path, changed: true, issue: cand.issue, modelLine: removed.removedLine, queued };
243
576
  }
244
577
  //# sourceMappingURL=defer.js.map