mulmoclaude 0.6.2 → 0.6.4

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 (182) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
  4. package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
  5. package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
  7. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
  8. package/client/assets/index-CyBr8Mkr.css +2 -0
  9. package/client/assets/index-zZIqEbNX.js +5106 -0
  10. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
  11. package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
  13. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  14. package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
  15. package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
  16. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  17. package/client/index.html +10 -10
  18. package/package.json +9 -8
  19. package/server/agent/backend/claude-code.ts +34 -0
  20. package/server/agent/backend/fake-echo.ts +370 -0
  21. package/server/agent/backend/index.ts +16 -1
  22. package/server/agent/config.ts +74 -24
  23. package/server/agent/index.ts +104 -80
  24. package/server/agent/mcpFailureMonitor.ts +167 -0
  25. package/server/agent/mcpPreflight.ts +185 -0
  26. package/server/agent/prompt.ts +50 -359
  27. package/server/agent/stdioHttpShim.ts +171 -0
  28. package/server/agent/stream.ts +12 -1
  29. package/server/api/routes/encore.ts +55 -0
  30. package/server/api/routes/files.ts +22 -0
  31. package/server/api/routes/mulmo-script.ts +19 -1
  32. package/server/api/routes/schedulerHandlers.ts +52 -4
  33. package/server/api/routes/sessions.ts +15 -0
  34. package/server/api/routes/skills.ts +263 -0
  35. package/server/build/dispatcher.mjs +299 -0
  36. package/server/encore/INVARIANTS.md +272 -0
  37. package/server/encore/boot.ts +39 -0
  38. package/server/encore/closure.ts +36 -0
  39. package/server/encore/cycle.ts +276 -0
  40. package/server/encore/dispatch.ts +103 -0
  41. package/server/encore/handlers/amend.ts +99 -0
  42. package/server/encore/handlers/appendNote.ts +74 -0
  43. package/server/encore/handlers/defineEncore.ts +42 -0
  44. package/server/encore/handlers/listTickets.ts +107 -0
  45. package/server/encore/handlers/markStepDone.ts +41 -0
  46. package/server/encore/handlers/markTargetSkipped.ts +33 -0
  47. package/server/encore/handlers/query.ts +138 -0
  48. package/server/encore/handlers/recordValues.ts +44 -0
  49. package/server/encore/handlers/resolveNotification.ts +121 -0
  50. package/server/encore/handlers/setup.ts +81 -0
  51. package/server/encore/handlers/shared.ts +137 -0
  52. package/server/encore/handlers/snooze.ts +87 -0
  53. package/server/encore/handlers/startObligationChat.ts +64 -0
  54. package/server/encore/handlers/startSetupChat.ts +50 -0
  55. package/server/encore/lock.ts +61 -0
  56. package/server/encore/notifier.ts +123 -0
  57. package/server/encore/obligation.ts +25 -0
  58. package/server/encore/paths.ts +78 -0
  59. package/server/encore/reconcile.ts +661 -0
  60. package/server/encore/tick.ts +191 -0
  61. package/server/encore/yaml-fm.ts +63 -0
  62. package/server/events/notifications.ts +19 -91
  63. package/server/index.ts +94 -9
  64. package/server/notifier/engine.ts +102 -1
  65. package/server/notifier/macosReminderAdapter.ts +30 -0
  66. package/server/notifier/runtime-api.ts +41 -1
  67. package/server/notifier/types.ts +15 -2
  68. package/server/plugins/runtime.ts +11 -2
  69. package/server/prompts/index.ts +39 -0
  70. package/server/prompts/system/journal-pointer.md +12 -0
  71. package/server/prompts/system/memory-management-atomic.md +33 -0
  72. package/server/prompts/system/memory-management-topic.md +60 -0
  73. package/server/prompts/system/news-concierge.md +24 -0
  74. package/server/prompts/system/sandbox-tools.md +10 -0
  75. package/server/prompts/system/sources-context.md +16 -0
  76. package/server/prompts/system/system.md +91 -0
  77. package/server/system/announceOptionalDeps.ts +57 -0
  78. package/server/system/appVersion.ts +34 -0
  79. package/server/system/config.ts +17 -1
  80. package/server/system/docker.ts +14 -6
  81. package/server/system/env.ts +18 -5
  82. package/server/system/optionalDeps.ts +129 -0
  83. package/server/utils/cli-flags.d.mts +14 -0
  84. package/server/utils/cli-flags.mjs +53 -0
  85. package/server/utils/files/encore-io.ts +111 -0
  86. package/server/utils/time.ts +6 -0
  87. package/server/workspace/helps/business.md +2 -2
  88. package/server/workspace/helps/encore-dsl.md +482 -0
  89. package/server/workspace/helps/index.md +15 -13
  90. package/server/workspace/helps/mulmoscript.md +3 -3
  91. package/server/workspace/helps/sandbox.md +2 -2
  92. package/server/workspace/hooks/dispatcher.ts +7 -5
  93. package/server/workspace/hooks/provision.ts +6 -3
  94. package/server/workspace/paths.ts +13 -4
  95. package/server/workspace/skills/catalog.ts +355 -0
  96. package/server/workspace/skills/external/catalog.ts +283 -0
  97. package/server/workspace/skills/external/clone.ts +129 -0
  98. package/server/workspace/skills/external/id.ts +194 -0
  99. package/server/workspace/skills/external/install.ts +417 -0
  100. package/server/workspace/skills/external/presets.ts +50 -0
  101. package/server/workspace/skills-preset.ts +29 -17
  102. package/server/workspace/workspace.ts +10 -5
  103. package/src/App.vue +37 -8
  104. package/src/components/FileContentRenderer.vue +102 -9
  105. package/src/components/JsonEditor.vue +160 -0
  106. package/src/components/NotificationBell.vue +35 -3
  107. package/src/components/PluginLauncher.vue +20 -41
  108. package/src/components/RightSidebar.vue +19 -0
  109. package/src/components/SettingsMcpTab.vue +58 -11
  110. package/src/components/SettingsModal.vue +22 -1
  111. package/src/components/StackView.vue +10 -1
  112. package/src/components/TodoExplorer.vue +16 -0
  113. package/src/components/todo/TodoKanbanView.vue +34 -6
  114. package/src/composables/useNotifications.ts +21 -1
  115. package/src/config/apiRoutes.ts +0 -6
  116. package/src/config/mcpCatalog.ts +12 -7
  117. package/src/config/mcpTypes.ts +5 -0
  118. package/src/config/roles.ts +52 -15
  119. package/src/config/systemFileDescriptors.ts +12 -0
  120. package/src/lang/de.ts +108 -12
  121. package/src/lang/en.ts +105 -11
  122. package/src/lang/es.ts +106 -11
  123. package/src/lang/fr.ts +106 -11
  124. package/src/lang/ja.ts +104 -11
  125. package/src/lang/ko.ts +105 -11
  126. package/src/lang/pt-BR.ts +106 -11
  127. package/src/lang/zh.ts +103 -11
  128. package/src/main.ts +1 -0
  129. package/src/plugins/_generated/metas.ts +4 -0
  130. package/src/plugins/_generated/registrations.ts +2 -0
  131. package/src/plugins/_generated/server-bindings.ts +5 -0
  132. package/src/plugins/encore/EncoreDashboard.vue +504 -0
  133. package/src/plugins/encore/EncoreRedirect.vue +116 -0
  134. package/src/plugins/encore/View.vue +36 -0
  135. package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
  136. package/src/plugins/encore/defineEncoreMeta.ts +13 -0
  137. package/src/plugins/encore/index.ts +93 -0
  138. package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
  139. package/src/plugins/encore/manageEncoreMeta.ts +36 -0
  140. package/src/plugins/manageSkills/View.vue +832 -30
  141. package/src/plugins/manageSkills/categories.ts +125 -0
  142. package/src/plugins/manageSkills/meta.ts +30 -0
  143. package/src/plugins/markdown/definition.ts +3 -3
  144. package/src/plugins/meta-types.ts +5 -0
  145. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  146. package/src/plugins/presentMulmoScript/View.vue +157 -33
  147. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  148. package/src/plugins/scheduler/View.vue +45 -9
  149. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  150. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  151. package/src/plugins/skill/View.vue +1 -5
  152. package/src/plugins/spreadsheet/View.vue +3 -3
  153. package/src/plugins/spreadsheet/definition.ts +1 -1
  154. package/src/plugins/textResponse/Preview.vue +14 -1
  155. package/src/plugins/textResponse/View.vue +39 -24
  156. package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
  157. package/src/router/index.ts +11 -0
  158. package/src/router/pageRoutes.ts +1 -0
  159. package/src/types/encore-dsl/at-expression.ts +120 -0
  160. package/src/types/encore-dsl/at-resolver.ts +32 -0
  161. package/src/types/encore-dsl/cadence.ts +289 -0
  162. package/src/types/encore-dsl/schema.ts +288 -0
  163. package/src/types/notification.ts +2 -1
  164. package/src/types/session.ts +6 -0
  165. package/src/types/sse.ts +5 -0
  166. package/src/types/toolCallHistory.ts +7 -0
  167. package/src/utils/agent/eventDispatch.ts +26 -5
  168. package/src/utils/agent/mcpHint.ts +50 -0
  169. package/src/utils/image/htmlSrcAttrs.ts +117 -13
  170. package/src/utils/session/sessionEntries.ts +8 -32
  171. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  172. package/client/assets/chunk-CernVdwh.js +0 -1
  173. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  174. package/client/assets/index-BwrlMMHr.js +0 -5005
  175. package/client/assets/index-CvvNuegU.css +0 -2
  176. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  177. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  178. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  179. package/server/api/routes/notifications.ts +0 -195
  180. package/server/notifier/legacy-adapters.ts +0 -76
  181. package/server/workspace/hooks/dispatcher.mjs +0 -300
  182. package/src/composables/useSelectedResult.ts +0 -49
@@ -0,0 +1,661 @@
1
+ // Encore reconciler — the sole owner of bell state.
2
+ //
3
+ // Every state-mutating handler funnels through `reconcileCycleNotifications`,
4
+ // and the time-driven tick walks all obligations through the same call. This
5
+ // is the ONLY production code path that invokes `encoreNotifier.publish` /
6
+ // `encoreNotifier.clear` and writes/unlinks tickets — the one
7
+ // documented exception is `handleOrphanResolve` in dispatch.ts (recovery
8
+ // path for a click that lost its ticket).
9
+ //
10
+ // Re-deriving from disk on every call is intentional: each handler used to
11
+ // patch bell state with its own copy of the trim/escalate logic, and the
12
+ // copies drifted (snooze had to skip the tick to avoid double-publish; amend
13
+ // needed a bespoke wipe path; markStepDone had a pendingId-walker). One
14
+ // reconciler removes all three workarounds because the unifying predicate
15
+ // `isPairInBundle` treats closed AND snoozed as out-of-bundle.
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import path from "node:path";
19
+
20
+ import { log as defaultLog } from "../system/logger/index.js";
21
+ import { compareIsoDates, formatCycleId, isoDate, nextSlot, type CycleSlot } from "../../src/types/encore-dsl/cadence.js";
22
+ import { parseAtExpression } from "../../src/types/encore-dsl/at-expression.js";
23
+ import { resolveAtExpression } from "../../src/types/encore-dsl/at-resolver.js";
24
+ import type { EncoreDsl, Severity, StepDef } from "../../src/types/encore-dsl/schema.js";
25
+ import { parseIndexFile } from "./obligation.js";
26
+ import { buildCycleState, isStepSnoozed, parseCycleFile, serializeCycleFile, type CycleState, type TargetRecord } from "./cycle.js";
27
+ import { isCycleClosed, isStepClosed } from "./closure.js";
28
+ import { cycleFilePath, obligationDir, obligationIndexPath, ticketPath, TICKETS_DIRNAME } from "./paths.js";
29
+ import { exists, readDir, readTextOrNull, writeText, unlink } from "../utils/files/encore-io.js";
30
+ import * as encoreNotifier from "./notifier.js";
31
+ import type { Ticket } from "./tick.js";
32
+
33
+ export interface ReconcileDeps {
34
+ obligationId: string;
35
+ /** Specific cycle the caller just mutated. Omitted (tick path) →
36
+ * the latest cycle on disk. */
37
+ cycleId?: string;
38
+ now: Date;
39
+ log?: typeof defaultLog;
40
+ }
41
+
42
+ /** Single entry point. Re-derive desired bell state from disk and
43
+ * reconcile to match. Safe to call concurrently only under the
44
+ * per-plugin lock (see `lock.ts`). */
45
+ export async function reconcileCycleNotifications(deps: ReconcileDeps): Promise<void> {
46
+ const log = deps.log ?? defaultLog;
47
+ const todayIso = isoDate(deps.now);
48
+ const nowIso = deps.now.toISOString();
49
+
50
+ const dsl = await loadDsl(deps.obligationId);
51
+ if (!dsl) return;
52
+
53
+ if (dsl.status !== "active") {
54
+ await clearAllForObligation(deps.obligationId, "obligation inactive", log);
55
+ return;
56
+ }
57
+
58
+ const primary = await loadPrimaryCycle(deps.obligationId, deps.cycleId);
59
+ if (!primary) return;
60
+
61
+ // `amendDefinition` used to set `invalidateAllBells: true` here to
62
+ // force every ticket+bell to be cleared and republished — the trim
63
+ // path couldn't republish a title-only edit because state hadn't
64
+ // moved. With the notifier engine's `update` op, the trim path now
65
+ // detects title/body drift directly and patches each bell in place
66
+ // (same notificationId, no history record). The flag and the
67
+ // `clearAllForCycle` helper it owned have been removed.
68
+ await reconcileOneCycle(dsl, primary.state, todayIso, nowIso, log);
69
+
70
+ // Cycle-close transition. If the primary cycle is now closed
71
+ // (handler closed the last step, or the tick saw closure happen
72
+ // naturally), provision (or reuse) its successor and reconcile
73
+ // that too — same dispatch turn, same lock, no second tick wait.
74
+ if (isCycleClosed(primary.state, dsl)) {
75
+ const successor = await provisionSuccessor(dsl, primary.state, log);
76
+ if (successor) {
77
+ await reconcileOneCycle(dsl, successor.state, todayIso, nowIso, log);
78
+ }
79
+ }
80
+ }
81
+
82
+ // ── loaders ───────────────────────────────────────────────────────
83
+
84
+ async function loadDsl(obligationId: string): Promise<EncoreDsl | null> {
85
+ const raw = await readTextOrNull(obligationIndexPath(obligationId));
86
+ if (raw === null) return null;
87
+ try {
88
+ return parseIndexFile(raw).dsl;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ interface PrimaryCycle {
95
+ rel: string;
96
+ state: CycleState;
97
+ body: string;
98
+ }
99
+
100
+ async function loadPrimaryCycle(obligationId: string, hint: string | undefined): Promise<PrimaryCycle | null> {
101
+ const cycleId = hint ?? (await pickLatestCycleId(obligationId));
102
+ if (!cycleId) return null;
103
+ const rel = cycleFilePath(obligationId, cycleId);
104
+ const raw = await readTextOrNull(rel);
105
+ if (raw === null) return null;
106
+ try {
107
+ const { state, body } = parseCycleFile(raw);
108
+ return { rel, state, body };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ async function pickLatestCycleId(obligationId: string): Promise<string | null> {
115
+ const entries = await readDir(obligationDir(obligationId));
116
+ const cycleFiles = entries.filter((name) => name !== "index.md" && name.endsWith(".md")).sort();
117
+ if (cycleFiles.length === 0) return null;
118
+ return cycleFiles[cycleFiles.length - 1].replace(/\.md$/, "");
119
+ }
120
+
121
+ // ── unified "is this (target, step) pair currently in any bundle?" ──
122
+
123
+ /** The load-bearing predicate. A pair belongs in a bundle iff the
124
+ * step is OPEN (not closed) AND not currently snoozed. Both the
125
+ * trim phase (existing ticket → still-live targets) and the publish
126
+ * phase (un-fired pairs → eligible to fire) consult this. The
127
+ * pre-reconciler code had two parallel checks that diverged: snooze
128
+ * was special-cased in the publish path (`isStepEligibleToFire`)
129
+ * but ignored on the trim path (`maybeEscalate`), so a snooze had
130
+ * to skip the tick entirely to avoid re-publishing the entry it
131
+ * had just cleared. */
132
+ function isPairInBundle(record: TargetRecord | undefined, step: StepDef, nowIso: string): boolean {
133
+ if (isStepClosed(record, step)) return false;
134
+ if (isStepSnoozed(record, step.id, nowIso)) return false;
135
+ return true;
136
+ }
137
+
138
+ // ── one-cycle reconcile (phase 1 + phase 2) ───────────────────────
139
+
140
+ async function reconcileOneCycle(dsl: EncoreDsl, state: CycleState, todayIso: string, nowIso: string, log: typeof defaultLog): Promise<void> {
141
+ const tickets = await ticketsForCycle(dsl.id ?? "", state.cycleId);
142
+
143
+ // Phase 1: trim or escalate existing tickets. Anything covered by
144
+ // a still-live ticket is OFF the publish-eligibility list for
145
+ // Phase 2 (we don't want to publish a duplicate while the existing
146
+ // entry is still up).
147
+ const coveredKeys = new Set<string>();
148
+ for (const ticket of dedupeTickets(tickets)) {
149
+ const survivor = await trimOrEscalateTicket(dsl, state, ticket, todayIso, nowIso, log);
150
+ if (survivor) {
151
+ for (const targetId of survivor.targets) {
152
+ coveredKeys.add(`${ticket.stepId}:${targetId}`);
153
+ }
154
+ }
155
+ }
156
+
157
+ // Phase 2: publish for un-fired, in-bundle (target, step) pairs.
158
+ const unfired = collectUnfired(dsl, state, coveredKeys, todayIso, nowIso);
159
+ for (const group of groupForBundling(unfired)) {
160
+ await fireGroup(dsl, state, group, log);
161
+ }
162
+ }
163
+
164
+ // ── Phase 1: trim or escalate ─────────────────────────────────────
165
+
166
+ interface TicketSurvivor {
167
+ targets: string[];
168
+ }
169
+
170
+ /** Recompute the still-live target set for this ticket; rewrite,
171
+ * escalate, or clear as appropriate. Returns the survivor's target
172
+ * set so Phase 2 can know which (step, target) pairs are already
173
+ * covered. */
174
+ async function trimOrEscalateTicket(
175
+ dsl: EncoreDsl,
176
+ state: CycleState,
177
+ ticket: Ticket,
178
+ todayIso: string,
179
+ nowIso: string,
180
+ log: typeof defaultLog,
181
+ ): Promise<TicketSurvivor | null> {
182
+ const stepDef = dsl.steps.find((step) => step.id === ticket.stepId);
183
+ if (!stepDef) {
184
+ // Step no longer exists in the DSL (e.g., amend dropped it).
185
+ // Clear the bell and unlink the ticket so we don't leak. If the
186
+ // clear failed, keep the ticket on disk so the next reconcile
187
+ // retries — unlinking an un-cleared bell would orphan it.
188
+ if (await safeClearBell(ticket.notificationId, "step removed", log)) {
189
+ await unlink(ticketPath(ticket.pendingId));
190
+ }
191
+ return null;
192
+ }
193
+
194
+ const liveTargets = ticket.targets.filter((targetId) => isPairInBundle(state.records[targetId], stepDef, nowIso));
195
+
196
+ if (liveTargets.length === 0) {
197
+ if (await safeClearBell(ticket.notificationId, "bundle drained", log)) {
198
+ await unlink(ticketPath(ticket.pendingId));
199
+ }
200
+ return null;
201
+ }
202
+
203
+ const phase = currentPhaseFor(stepDef, state, todayIso);
204
+ const newSeverity = phase?.severity ?? ticket.severity;
205
+ const members = liveTargets.map((targetId) => ({ targetId }));
206
+ const desiredTitle = bundleTitle(dsl, stepDef, members);
207
+ const desiredBody = bundleBody(dsl, stepDef, members);
208
+
209
+ // Ghost-ticket detection. The reconciler historically treated
210
+ // ticket-existence as proof of bell-existence, so a bell dismissed
211
+ // out-of-band by the host UI (or wiped by a crashed active.json)
212
+ // stayed gone until severity escalation or the 30-day orphan
213
+ // prune. Cross-checking the notifier here lets the next tick
214
+ // republish using the ticket's existing pendingId / seedPrompt /
215
+ // chatSessionId — the user sees the bell again, the chat
216
+ // continuity survives, and manual dismiss becomes a "see it next
217
+ // tick" gesture rather than an accidental silencer. `snooze` is
218
+ // still the explicit verb for time-bound silence.
219
+ const bellAlive = await encoreNotifier.bellExists(ticket.notificationId);
220
+ if (!bellAlive) {
221
+ await refreshGhostTicket({ dsl, state, ticket, stepDef, liveTargets, newSeverity, desiredTitle, desiredBody, log });
222
+ return { targets: liveTargets };
223
+ }
224
+
225
+ // Drift detection. Title / body drift surfaces every kind of
226
+ // content-only DSL edit (`amendDefinition` rewriting a
227
+ // `displayName`, a target rename, a bundle shrinking to a count
228
+ // that changes the rendered text). Severity drift surfaces phase
229
+ // escalation. Either way the response is an in-place update —
230
+ // same notificationId, no history pollution, no flicker.
231
+ //
232
+ // Tickets written before this build don't carry `title` / `body`,
233
+ // so the !== checks read both as "always different on first
234
+ // sight". The first reconcile pass after the upgrade therefore
235
+ // calls `update` once per ticket (idempotent on the bell — host
236
+ // active.json is rewritten with the same values; only the ticket
237
+ // file gains the missing fields) and subsequent passes short-
238
+ // circuit.
239
+ const severityDrift = newSeverity !== ticket.severity;
240
+ const titleDrift = ticket.title !== desiredTitle;
241
+ const bodyDrift = ticket.body !== desiredBody;
242
+ const targetsChanged = liveTargets.length !== ticket.targets.length;
243
+
244
+ if (severityDrift || titleDrift || bodyDrift) {
245
+ await updateBellInPlace({ dsl, state, ticket, stepDef, liveTargets, newSeverity, desiredTitle, desiredBody, log });
246
+ return { targets: liveTargets };
247
+ }
248
+
249
+ if (targetsChanged) {
250
+ // Rare branch — target list shrunk but the formatters render
251
+ // the same title/body. Persist the trimmed target list so the
252
+ // ticket stays consistent with cycle state. No bell update
253
+ // needed since presentation didn't change.
254
+ await writeTicket({ ...ticket, targets: liveTargets, title: desiredTitle, body: desiredBody });
255
+ log.info("encore", "reconcile: trimmed bundle (no content drift)", {
256
+ pendingId: ticket.pendingId,
257
+ notificationId: ticket.notificationId,
258
+ remaining: liveTargets,
259
+ });
260
+ return { targets: liveTargets };
261
+ }
262
+
263
+ return { targets: liveTargets };
264
+ }
265
+
266
+ interface ReconcileRefreshArgs {
267
+ dsl: EncoreDsl;
268
+ state: CycleState;
269
+ ticket: Ticket;
270
+ stepDef: StepDef;
271
+ liveTargets: string[];
272
+ newSeverity: Severity;
273
+ desiredTitle: string;
274
+ desiredBody: string;
275
+ log: typeof defaultLog;
276
+ }
277
+
278
+ /** Update a still-live bell entry in place — same notificationId,
279
+ * fresh severity / title / body. No clear, no republish, no
280
+ * history record. Used for the common case where an obligation's
281
+ * DSL changed (most often via `amendDefinition`) or a phase
282
+ * escalation pushed severity up.
283
+ *
284
+ * The on-disk ticket is rewritten to reflect the new state so the
285
+ * next reconcile's drift check short-circuits. */
286
+ async function updateBellInPlace(args: ReconcileRefreshArgs): Promise<void> {
287
+ const { dsl, state, ticket, stepDef, liveTargets, newSeverity, desiredTitle, desiredBody, log } = args;
288
+ await encoreNotifier.update(ticket.notificationId, { severity: newSeverity, title: desiredTitle, body: desiredBody });
289
+ await writeTicket({ ...ticket, severity: newSeverity, title: desiredTitle, body: desiredBody, targets: liveTargets });
290
+ log.info("encore", "reconcile: bell updated in place", {
291
+ obligationId: dsl.id,
292
+ cycleId: state.cycleId,
293
+ stepId: stepDef.id,
294
+ notificationId: ticket.notificationId,
295
+ fromSeverity: ticket.severity,
296
+ toSeverity: newSeverity,
297
+ });
298
+ }
299
+
300
+ /** Bring back a bell that was dismissed out-of-band. The bell is
301
+ * gone, so we publish a fresh entry at a new notificationId; the
302
+ * ticket's pendingId / seedPrompt / chatSessionId / createdAt
303
+ * carry over so the user lands in the same chat conversation on
304
+ * click. With `update`, this is the ONLY reason the trim path
305
+ * still allocates a new notificationId — every other refresh
306
+ * (severity escalation, title amend, body drift) goes through
307
+ * `updateBellInPlace` and keeps the original id stable. */
308
+ async function refreshGhostTicket(args: ReconcileRefreshArgs): Promise<void> {
309
+ const { dsl, state, ticket, stepDef, liveTargets, newSeverity, desiredTitle, desiredBody, log } = args;
310
+ const navigateTarget = encoreUrlFor(ticket.pendingId);
311
+ const { id: newId } = await encoreNotifier.publish({ severity: newSeverity, title: desiredTitle, body: desiredBody, navigateTarget });
312
+ try {
313
+ await writeTicket({ ...ticket, notificationId: newId, severity: newSeverity, title: desiredTitle, body: desiredBody, targets: liveTargets });
314
+ } catch (err) {
315
+ await safeClearBell(newId, "rollback: ticket write failed after ghost-ticket republish", log);
316
+ throw err;
317
+ }
318
+ log.info("encore", "reconcile: ghost-ticket republish", {
319
+ obligationId: dsl.id,
320
+ cycleId: state.cycleId,
321
+ stepId: stepDef.id,
322
+ fromNotificationId: ticket.notificationId,
323
+ toNotificationId: newId,
324
+ });
325
+ }
326
+
327
+ function dedupeTickets(tickets: Ticket[]): Ticket[] {
328
+ const seen = new Set<string>();
329
+ const out: Ticket[] = [];
330
+ for (const ticket of tickets) {
331
+ if (seen.has(ticket.pendingId)) continue;
332
+ seen.add(ticket.pendingId);
333
+ out.push(ticket);
334
+ }
335
+ return out;
336
+ }
337
+
338
+ // ── Phase 2: un-fired collection + publish ────────────────────────
339
+
340
+ interface UnfiredPair {
341
+ targetId: string;
342
+ stepId: string;
343
+ stepDef: StepDef;
344
+ severity: Severity;
345
+ fireDate: string;
346
+ }
347
+
348
+ function collectUnfired(dsl: EncoreDsl, state: CycleState, coveredKeys: Set<string>, todayIso: string, nowIso: string): UnfiredPair[] {
349
+ const out: UnfiredPair[] = [];
350
+ for (const target of dsl.targets) {
351
+ const record = state.records[target.id];
352
+ if (record?.skipped) continue;
353
+ for (const step of dsl.steps) {
354
+ if (coveredKeys.has(`${step.id}:${target.id}`)) continue;
355
+ if (!isPairInBundle(record, step, nowIso)) continue;
356
+ const phase = currentPhaseFor(step, state, todayIso);
357
+ if (!phase) continue;
358
+ out.push({ targetId: target.id, stepId: step.id, stepDef: step, severity: phase.severity, fireDate: phase.fireDate });
359
+ }
360
+ }
361
+ return out;
362
+ }
363
+
364
+ interface BundleGroup {
365
+ stepId: string;
366
+ stepDef: StepDef;
367
+ severity: Severity;
368
+ fireDate: string;
369
+ members: { targetId: string }[];
370
+ }
371
+
372
+ function groupForBundling(unfired: UnfiredPair[]): BundleGroup[] {
373
+ const byKey = new Map<string, BundleGroup>();
374
+ for (const pair of unfired) {
375
+ const key = `${pair.stepId} ${pair.severity} ${pair.fireDate}`;
376
+ const existing = byKey.get(key);
377
+ if (existing) {
378
+ existing.members.push({ targetId: pair.targetId });
379
+ } else {
380
+ byKey.set(key, {
381
+ stepId: pair.stepId,
382
+ stepDef: pair.stepDef,
383
+ severity: pair.severity,
384
+ fireDate: pair.fireDate,
385
+ members: [{ targetId: pair.targetId }],
386
+ });
387
+ }
388
+ }
389
+ return [...byKey.values()];
390
+ }
391
+
392
+ async function fireGroup(dsl: EncoreDsl, state: CycleState, group: BundleGroup, log: typeof defaultLog): Promise<void> {
393
+ const pendingId = randomUUID();
394
+ const navigateTarget = encoreUrlFor(pendingId);
395
+ const title = bundleTitle(dsl, group.stepDef, group.members);
396
+ const body = bundleBody(dsl, group.stepDef, group.members);
397
+ const { id: notificationId } = await encoreNotifier.publish({ severity: group.severity, title, body, navigateTarget });
398
+ const ticket: Ticket = {
399
+ pendingId,
400
+ obligationId: dsl.id ?? "",
401
+ cycleId: state.cycleId,
402
+ notificationId,
403
+ stepId: group.stepId,
404
+ targets: group.members.map((member) => member.targetId),
405
+ severity: group.severity,
406
+ title,
407
+ body,
408
+ seedPrompt: buildSeedPrompt(dsl, group, pendingId, state.cycleId),
409
+ createdAt: new Date().toISOString(),
410
+ };
411
+ // Roll back the just-published bell entry if the ticket write
412
+ // fails — without rollback the bell would be live with no ticket,
413
+ // and the next reconcile would re-publish a duplicate.
414
+ try {
415
+ await writeTicket(ticket);
416
+ } catch (err) {
417
+ await safeClearBell(notificationId, "rollback: ticket write failed after publish", log);
418
+ throw err;
419
+ }
420
+ log.info("encore", "reconcile: published bundled notification", {
421
+ obligationId: dsl.id,
422
+ cycleId: state.cycleId,
423
+ stepId: group.stepId,
424
+ severity: group.severity,
425
+ targets: group.members.map((member) => member.targetId),
426
+ notificationId,
427
+ pendingId,
428
+ });
429
+ }
430
+
431
+ // ── successor provisioning ────────────────────────────────────────
432
+
433
+ async function provisionSuccessor(dsl: EncoreDsl, state: CycleState, log: typeof defaultLog): Promise<{ state: CycleState } | null> {
434
+ const slot = slotFromCycleId(dsl.cadence, state.cycleId);
435
+ if (!slot) {
436
+ log.warn("encore", "reconcile: could not parse cycleId for next slot", { obligationId: dsl.id, cycleId: state.cycleId });
437
+ return null;
438
+ }
439
+ const next = nextSlot(dsl.cadence, slot);
440
+ const nextRel = cycleFilePath(dsl.id ?? "", formatCycleId(next));
441
+ if (!(await exists(nextRel))) {
442
+ await writeText(nextRel, serializeCycleFile(buildCycleState(dsl, next), ""));
443
+ log.info("encore", "reconcile: provisioned next cycle", { obligationId: dsl.id, fromCycleId: state.cycleId, toCycleId: formatCycleId(next) });
444
+ }
445
+ const nextRaw = await readTextOrNull(nextRel);
446
+ if (nextRaw === null) return null;
447
+ try {
448
+ const parsed = parseCycleFile(nextRaw);
449
+ return { state: parsed.state };
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
455
+ function slotFromCycleId(cadence: EncoreDsl["cadence"], cycleId: string): CycleSlot | null {
456
+ if (cadence.type === "annual") {
457
+ const year = Number.parseInt(cycleId, 10);
458
+ if (!Number.isFinite(year)) return null;
459
+ return { kind: "annual", year };
460
+ }
461
+ if (cadence.type === "biannual") {
462
+ const match = cycleId.match(/^(\d{4})-h([12])$/);
463
+ if (!match) return null;
464
+ return { kind: "biannual", year: Number.parseInt(match[1], 10), half: Number.parseInt(match[2], 10) as 1 | 2 };
465
+ }
466
+ if (cadence.type === "monthly") {
467
+ const match = cycleId.match(/^(\d{4})-(\d{2})$/);
468
+ if (!match) return null;
469
+ return { kind: "monthly", year: Number.parseInt(match[1], 10), month: Number.parseInt(match[2], 10) };
470
+ }
471
+ if (cadence.type === "weekly") {
472
+ const match = cycleId.match(/^(\d{4})-W(\d{2})$/);
473
+ if (!match) return null;
474
+ return { kind: "weekly", year: Number.parseInt(match[1], 10), week: Number.parseInt(match[2], 10) };
475
+ }
476
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(cycleId)) return null;
477
+ return { kind: "daily", iso: cycleId };
478
+ }
479
+
480
+ // ── clear-all helpers ─────────────────────────────────────────────
481
+
482
+ async function clearAllForObligation(obligationId: string, reason: string, log: typeof defaultLog): Promise<void> {
483
+ const entries = await readDir(TICKETS_DIRNAME);
484
+ let cleared = 0;
485
+ for (const entry of entries) {
486
+ if (!entry.endsWith(".json")) continue;
487
+ const rel = path.join(TICKETS_DIRNAME, entry);
488
+ const raw = await readTextOrNull(rel);
489
+ if (!raw) continue;
490
+ let ticket: Ticket;
491
+ try {
492
+ ticket = JSON.parse(raw) as Ticket;
493
+ } catch {
494
+ continue;
495
+ }
496
+ if (ticket.obligationId !== obligationId) continue;
497
+ // Only unlink the ticket if the bell clear succeeded; otherwise
498
+ // a transient notifier failure would orphan the host bell entry.
499
+ // Leaving the ticket lets the next reconcile retry.
500
+ if (await safeClearBell(ticket.notificationId, reason, log)) {
501
+ await unlink(rel);
502
+ cleared += 1;
503
+ }
504
+ }
505
+ if (cleared > 0) log.info("encore", "reconcile: cleared all for obligation", { obligationId, reason, cleared });
506
+ }
507
+
508
+ // ── phase eval ────────────────────────────────────────────────────
509
+
510
+ interface ResolvedPhase {
511
+ severity: Severity;
512
+ fireDate: string;
513
+ }
514
+
515
+ function currentPhaseFor(stepDef: StepDef, cycleState: CycleState, todayIso: string): ResolvedPhase | null {
516
+ let stepDeadline: string;
517
+ try {
518
+ stepDeadline = resolveAtExpression(parseAtExpression(stepDef.deadline, { allowStepDeadline: false }), {
519
+ cycleStart: cycleState.cycleStart,
520
+ cycleDeadline: cycleState.cycleDeadline,
521
+ });
522
+ } catch {
523
+ return null;
524
+ }
525
+ const anchors = { cycleStart: cycleState.cycleStart, cycleDeadline: cycleState.cycleDeadline, stepDeadline };
526
+ let latest: ResolvedPhase | null = null;
527
+ for (const phase of stepDef.firingPlan) {
528
+ let resolved: string;
529
+ try {
530
+ resolved = resolveAtExpression(parseAtExpression(phase.at, { allowStepDeadline: true }), anchors);
531
+ } catch {
532
+ continue;
533
+ }
534
+ if (compareIsoDates(resolved, todayIso) <= 0) {
535
+ latest = { severity: phase.severity, fireDate: resolved };
536
+ } else {
537
+ break;
538
+ }
539
+ }
540
+ return latest;
541
+ }
542
+
543
+ // ── titles + bodies + seed prompts ────────────────────────────────
544
+
545
+ function bundleTitle(dsl: EncoreDsl, stepDef: StepDef, members: { targetId: string }[]): string {
546
+ if (members.length === 1) {
547
+ const [{ targetId }] = members;
548
+ const target = dsl.targets.find((entry) => entry.id === targetId);
549
+ return `${dsl.displayName} — ${stepDef.displayName} (${target?.displayName ?? targetId})`;
550
+ }
551
+ return `${dsl.displayName} — ${stepDef.displayName} (${members.length} targets)`;
552
+ }
553
+
554
+ function bundleBody(dsl: EncoreDsl, _stepDef: StepDef, members: { targetId: string }[]): string {
555
+ if (members.length === 1) return "";
556
+ return members
557
+ .map((member) => {
558
+ const target = dsl.targets.find((entry) => entry.id === member.targetId);
559
+ return target?.displayName ?? member.targetId;
560
+ })
561
+ .join(", ");
562
+ }
563
+
564
+ function buildSeedPrompt(dsl: EncoreDsl, group: BundleGroup, pendingId: string, cycleId: string): string {
565
+ const targetLines = group.members
566
+ .map((member) => {
567
+ const target = dsl.targets.find((entry) => entry.id === member.targetId);
568
+ return `- ${target?.displayName ?? member.targetId} (id: ${member.targetId})`;
569
+ })
570
+ .join("\n");
571
+ const fieldList = group.stepDef.fields.length === 0 ? "(no fields to record for this step)" : group.stepDef.fields.map((name) => `- ${name}`).join("\n");
572
+ const firstTargetId = group.members[0]?.targetId ?? "<targetId>";
573
+ const obligationId = dsl.id ?? "";
574
+ const exampleCall = JSON.stringify(
575
+ {
576
+ kind: "markStepDone",
577
+ pendingId,
578
+ obligationId,
579
+ cycleId,
580
+ targetId: firstTargetId,
581
+ stepId: group.stepId,
582
+ values: Object.fromEntries(group.stepDef.fields.map((name) => [name, "<value>"])),
583
+ },
584
+ null,
585
+ 2,
586
+ );
587
+ return [
588
+ `An Encore reminder for the obligation "${dsl.displayName}" (id: ${obligationId}, cycle: ${cycleId}).`,
589
+ "",
590
+ `Step: ${group.stepDef.displayName} (id: ${group.stepId})`,
591
+ `Severity: ${group.severity}. Fire date: ${group.fireDate}.`,
592
+ "",
593
+ `Targets covered by this notification:`,
594
+ targetLines,
595
+ "",
596
+ `Fields to collect on each target's record:`,
597
+ fieldList,
598
+ "",
599
+ `Help the user record what happened, then call manageEncore — ONCE PER TARGET — with one of:`,
600
+ `- kind: "markStepDone" — step is complete (pass field values via \`values\`).`,
601
+ `- kind: "markTargetSkipped" — user is skipping this target for this cycle.`,
602
+ `- kind: "recordValues" — partial info only, no closing.`,
603
+ `- kind: "snooze" — defer the bell entry (default 24h).`,
604
+ `- kind: "unsnooze" — re-enable a previously snoozed step before its timer expires.`,
605
+ "",
606
+ `Call-shape rules (the parser will 400 on these common mistakes):`,
607
+ `- \`targetId\` is SINGULAR (a string), NOT \`targetIds\` (array). If the notification covers multiple targets, make one call per target.`,
608
+ `- \`values\` is a FLAT field-map: \`{ fieldName: value, ... }\`. NEVER nest it by target id (\`{ <targetId>: { ... } }\` is wrong).`,
609
+ `- Always pass \`pendingId\`, \`obligationId\`, and \`cycleId\` as shown below — they're what clears the bell entry when the cycle progresses.`,
610
+ "",
611
+ `Example for ${firstTargetId}:`,
612
+ "```json",
613
+ exampleCall,
614
+ "```",
615
+ ].join("\n");
616
+ }
617
+
618
+ // ── ticket I/O ────────────────────────────────────────────────────
619
+
620
+ async function writeTicket(ticket: Ticket): Promise<void> {
621
+ await writeText(ticketPath(ticket.pendingId), JSON.stringify(ticket, null, 2));
622
+ }
623
+
624
+ async function ticketsForCycle(obligationId: string, cycleId: string): Promise<Ticket[]> {
625
+ const entries = await readDir(TICKETS_DIRNAME);
626
+ const out: Ticket[] = [];
627
+ for (const entry of entries) {
628
+ if (!entry.endsWith(".json")) continue;
629
+ const raw = await readTextOrNull(path.join(TICKETS_DIRNAME, entry));
630
+ if (!raw) continue;
631
+ try {
632
+ const ticket = JSON.parse(raw) as Ticket;
633
+ if (ticket.obligationId === obligationId && ticket.cycleId === cycleId) {
634
+ out.push(ticket);
635
+ }
636
+ } catch {
637
+ continue;
638
+ }
639
+ }
640
+ return out;
641
+ }
642
+
643
+ /** Try to clear the bell entry. Returns `true` on success, `false`
644
+ * on failure (logged). Callers MUST consult the return value before
645
+ * destructive follow-ups (unlinking the ticket, republishing at a
646
+ * new id, etc.); swallowing a clear failure and proceeding would
647
+ * orphan the host bell entry — the pre-refactor escalation aborted
648
+ * on clear failure for exactly this reason. */
649
+ async function safeClearBell(notificationId: string, reason: string, log: typeof defaultLog): Promise<boolean> {
650
+ try {
651
+ await encoreNotifier.clear(notificationId);
652
+ return true;
653
+ } catch (err) {
654
+ log.warn("encore", "reconcile: notifier.clear failed", { reason, notificationId, error: err instanceof Error ? err.message : String(err) });
655
+ return false;
656
+ }
657
+ }
658
+
659
+ function encoreUrlFor(pendingId: string): string {
660
+ return `/encore?pendingId=${encodeURIComponent(pendingId)}`;
661
+ }