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,272 @@
1
+ # Encore — lifecycle invariants
2
+
3
+ This document records the **non-negotiable rules** that the Encore
4
+ reconciler, tick, and dispatch handlers all rely on. Each rule has a
5
+ short rationale (the failure mode it prevents) and a pointer to the
6
+ code that enforces it today.
7
+
8
+ Treat this as the rulebook future refactors must keep intact. Most
9
+ classes of bug we have seen in Encore — duplicate-publish, orphaned
10
+ bells, dead chats, dropped clicks — come from one of these rules
11
+ quietly breaking.
12
+
13
+ If you are about to violate one of these, **stop and read the
14
+ rationale first**. Some of them have non-obvious failure modes that
15
+ only surface days later (orphan prune at 30d) or under crash
16
+ interleavings.
17
+
18
+ ---
19
+
20
+ ## 1. The reconciler owns the bell.
21
+
22
+ `reconcileCycleNotifications` is the **sole** owner of:
23
+ - `encoreNotifier.publish` (creating a bell entry)
24
+ - `encoreNotifier.clear` (removing a bell entry)
25
+ - ticket file writes and unlinks under `tickets/<pendingId>.json`
26
+
27
+ No other code path is allowed to call `encoreNotifier.publish` or
28
+ write a ticket. The reconciler re-derives the desired bell state from
29
+ disk on every call and reconciles to match — handlers don't patch bell
30
+ state; they write cycle facts and call the reconciler.
31
+
32
+ **Documented exceptions** (only two; do not add more without updating
33
+ this list):
34
+
35
+ - `handlers/resolveNotification.ts` → `handleOrphanResolve` calls
36
+ `encoreNotifier.clear` directly when the user clicks a bell whose
37
+ ticket was already swept. There is no ticket to reconcile against,
38
+ so the reconciler cannot know the bell entry exists.
39
+ - `tick.ts` → `pruneOneTicket` clears bells for orphan tickets older
40
+ than 30 days. This is age-based, not state-based, so it lives in
41
+ the time-driven tick rather than the state-driven reconciler.
42
+
43
+ **Why**: handlers used to carry their own copies of trim/escalate
44
+ logic (snooze had a tick-skip workaround, amend had a bespoke wipe
45
+ path, `markStepDone` had a pendingId-walker). Those copies drifted —
46
+ duplicate-publish and stale-bell regressions traced back to one path
47
+ patching state while the other re-derived it.
48
+
49
+ **Enforced by**: `test/plugins/test_encore_reconcile.ts` greps for
50
+ forbidden notifier imports in handler files.
51
+
52
+ ---
53
+
54
+ ## 2. Every state mutation funnels through `persistAndReconcile`.
55
+
56
+ State-mutating handlers (`markStepDone`, `markTargetSkipped`,
57
+ `recordValues`, `snooze`, `unsnooze`) MUST funnel through
58
+ `persistAndReconcile` (`handlers/shared.ts`). That helper writes the
59
+ cycle file and immediately runs `reconcileCycleNotifications` under
60
+ the same lock.
61
+
62
+ Direct calls to `writeText(cycleFilePath(...), serializeCycleFile(...))`
63
+ in handler code, without a reconcile under the same lock, are
64
+ forbidden — they create state on disk that the bell doesn't reflect
65
+ until the next tick (up to one hour later).
66
+
67
+ `setup` and `amendDefinition` use the index-write path (not
68
+ `persistAndReconcile`), but each explicitly calls
69
+ `reconcileCycleNotifications` immediately after its `writeText`. Same
70
+ contract: writes are not visible to the user until the reconciler runs
71
+ on the same disk state.
72
+
73
+ **Why**: keeps cycle-file disk state and bell state in sync within a
74
+ single SSE turn. Without it, the user records a step but the bell
75
+ doesn't clear until the hourly tick.
76
+
77
+ ---
78
+
79
+ ## 3. The per-plugin lock covers mutation AND reconcile together.
80
+
81
+ `withLock` in `dispatch.ts` wraps each dispatch call. The reconcile
82
+ inside `persistAndReconcile` (and `handleSetup` / `handleAmend`) runs
83
+ under the same lock — not a separate critical section.
84
+
85
+ Two consequences that must be preserved:
86
+
87
+ - Two concurrent mutations can't race on `writeFileAtomic` against the
88
+ same cycle file.
89
+ - A handler-side reconcile can't double-publish with the hourly
90
+ heartbeat (`tick.ts` calls reconcile from the same per-plugin lock).
91
+
92
+ **Why**: the reconciler reads disk state, computes a diff against
93
+ tickets, and writes both bell and ticket state. Splitting that across
94
+ locks would let another writer interleave between the read and the
95
+ write, producing a stale-diff publish.
96
+
97
+ **Enforced by**: `lock.ts` exports one shared mutex; both
98
+ `dispatch.ts` and `tick.ts` use it.
99
+
100
+ ---
101
+
102
+ ## 4. Clear-before-unlink. Never the reverse.
103
+
104
+ When removing a ticket-bell pair, the bell clear MUST succeed before
105
+ the ticket is unlinked. If the clear fails, leave the ticket on disk
106
+ so the next reconcile or sweep can retry.
107
+
108
+ This is enforced consistently by `safeClearBell` (`reconcile.ts`):
109
+
110
+ ```ts
111
+ if (await safeClearBell(ticket.notificationId, reason, log)) {
112
+ await unlink(ticketPath(ticket.pendingId));
113
+ }
114
+ ```
115
+
116
+ **Why**: an unlinked ticket with a still-live bell is an **orphan
117
+ bell** — visible in the bell UI but with no record pointing to it. The
118
+ next reconcile sees an un-fired (target, step) pair on the cycle file
119
+ and publishes a duplicate. The user sees two bells for one obligation.
120
+
121
+ The exception (clearing the bell BEFORE unlinking) is in `tick.ts` →
122
+ `pruneOneTicket`: an orphan ticket is by definition past its 30-day
123
+ horizon, and we want the bell gone even if the ticket survives.
124
+
125
+ ---
126
+
127
+ ## 5. Publish-then-write-or-rollback.
128
+
129
+ When publishing a fresh bell, the ticket write MUST succeed; if it
130
+ fails, the just-published bell entry MUST be cleared (rollback).
131
+
132
+ Both publish call-sites use the same pattern:
133
+
134
+ ```ts
135
+ const { id: notificationId } = await encoreNotifier.publish(...);
136
+ try {
137
+ await writeTicket(...);
138
+ } catch (err) {
139
+ await safeClearBell(notificationId, "rollback: ...", log);
140
+ throw err;
141
+ }
142
+ ```
143
+
144
+ **Why**: a live bell with no matching ticket would be seen by the next
145
+ reconcile as "un-fired" (the cycle-file pair isn't covered by any
146
+ ticket), and a duplicate bell would be published.
147
+
148
+ ---
149
+
150
+ ## 6. A ticket asserts a live bell.
151
+
152
+ A ticket's existence on disk is a claim that a bell with that
153
+ `notificationId` is alive in the notifier. The reconciler validates
154
+ that claim every pass with `encoreNotifier.bellExists` — when the bell
155
+ has been dismissed out-of-band (host UI dismiss, crashed
156
+ `active.json`), the reconciler republishes at a new id using the
157
+ ticket's existing `pendingId`, `seedPrompt`, `createdAt`, and
158
+ `chatSessionId`.
159
+
160
+ **Why**: keeps manual bell-dismiss usable as a "see-it-next-tick"
161
+ gesture instead of permanently silencing the obligation. `snooze` is
162
+ the explicit verb for time-bound silence.
163
+
164
+ The republished entry's `notificationId` changes (the host always
165
+ assigns a new id); everything else on the ticket survives.
166
+
167
+ ---
168
+
169
+ ## 7. `chatSessionId` survives republish.
170
+
171
+ `chatSessionId` is the only ticket field that is NOT regeneratable
172
+ from disk. It binds the ticket to a user chat that the LLM is
173
+ participating in.
174
+
175
+ When the reconciler republishes a ticket (severity escalation, ghost
176
+ republish, bundle trim), it MUST preserve `chatSessionId`. The
177
+ republish patch in `clearAndRepublish` does this implicitly via
178
+ `{ ...ticket, notificationId: newId, severity: newSeverity, ... }`.
179
+
180
+ `resolveNotification`'s idempotency path (reuse existing
181
+ `chatSessionId` on double-click) depends on this — losing the field
182
+ would spawn a duplicate chat on every reconcile.
183
+
184
+ ---
185
+
186
+ ## 8. Cycle closure is derived, never stored.
187
+
188
+ `isCycleClosed` (in `closure.ts`) computes closure from
189
+ `state.records` — completed steps and skipped targets. There is no
190
+ `closed: true` flag on the cycle file.
191
+
192
+ When you add a new "step state" notion, add a predicate that reads
193
+ from `records`; do **not** denormalize a status field. Status flags
194
+ drift; predicates don't.
195
+
196
+ **Why**: status flags are the canonical source of stale-bell bugs.
197
+ Pre-refactor cycle files had `closed` + `lastPublishedSeverity`; both
198
+ could disagree with the records, and the disagreement was invisible
199
+ until a user saw an unexpected bell.
200
+
201
+ ---
202
+
203
+ ## 9. Immutable DSL fields.
204
+
205
+ `amendDefinition` rejects changes to:
206
+
207
+ - `id` (would desync directory name from `dsl.id`)
208
+ - `createdAt` (server-generated, not LLM-controlled)
209
+ - `type` (changes the validation discriminator and breaks prior
210
+ cycle-file naming)
211
+ - `currency` on `type: "payment"` (would invalidate prior cycle
212
+ records mid-life)
213
+ - `cadence.type` (changes cycle-file naming convention — `2026-05` vs
214
+ `2026-W20` vs `2026-05-18`)
215
+
216
+ The retire-and-create path (set `status: "inactive"` on the old
217
+ obligation, `defineEncore` a new one) is the supported way to change
218
+ any of these.
219
+
220
+ Even when an LLM patch includes `id` or `createdAt` by mistake,
221
+ `handleAmend` force-restores the existing values rather than rejecting
222
+ the call — those mistakes are common and surface in the result.
223
+
224
+ ---
225
+
226
+ ## 10. The tick sweep covers non-latest cycles.
227
+
228
+ `reconcileCycleNotifications` only touches the obligation's latest
229
+ cycle (and a just-provisioned successor on cycle-close). Tickets on
230
+ older cycles can persist when `safeClearBell` had a transient failure
231
+ during the closing reconcile.
232
+
233
+ `tick.ts` → `sweepStuckTickets` walks `tickets/*.json` and reconciles
234
+ every `(obligationId, cycleId)` pair found there, regardless of
235
+ "latest". This is the retry path for prior clear failures.
236
+
237
+ **Why**: without it, a transient notifier hiccup at cycle-close would
238
+ leave a non-latest ticket+bell pair untouched until the 30-day prune.
239
+
240
+ ---
241
+
242
+ ## 11. Orphan prune is age-based and lives in `tick.ts`.
243
+
244
+ The 30-day orphan-prune in `tick.ts` → `pruneOrphanTickets` is the
245
+ last-resort cleanup for tickets that escaped every state-driven path
246
+ (e.g. cycle file manually deleted, host crashed mid-write). It is
247
+ age-based, not state-based, which is why it lives in the time-driven
248
+ tick rather than the state-driven reconciler.
249
+
250
+ Do NOT move this into the reconciler: the reconciler is called from
251
+ mutation paths with `now = new Date()` but its job is to make the bell
252
+ match disk state, not to garbage-collect by wall-clock age. Mixing
253
+ those concerns reintroduces the kind of drift this document exists to
254
+ prevent.
255
+
256
+ ---
257
+
258
+ ## Quick reference: where each rule is enforced
259
+
260
+ | Rule | Enforced in |
261
+ |---|---|
262
+ | 1. Reconciler owns the bell | `reconcile.ts`, `notifier.ts`, lint/test guard |
263
+ | 2. Mutation funnel | `handlers/shared.ts` → `persistAndReconcile` |
264
+ | 3. Lock covers both | `dispatch.ts` → `withLock`; `tick.ts` |
265
+ | 4. Clear-before-unlink | `reconcile.ts` → `safeClearBell` callers |
266
+ | 5. Publish-then-write-or-rollback | `reconcile.ts` → `fireGroup`, `clearAndRepublish` |
267
+ | 6. Ticket asserts live bell | `reconcile.ts` → `bellExists` check in `trimOrEscalateTicket` |
268
+ | 7. `chatSessionId` survives republish | `reconcile.ts` → `clearAndRepublish` spread |
269
+ | 8. Closure derived, not stored | `closure.ts`; cycle file shape |
270
+ | 9. Immutable DSL fields | `handlers/amend.ts` |
271
+ | 10. Sweep covers non-latest cycles | `tick.ts` → `sweepStuckTickets` |
272
+ | 11. Orphan prune is age-based | `tick.ts` → `pruneOrphanTickets` |
@@ -0,0 +1,39 @@
1
+ // Wire the Encore tick into the host task-manager at server boot.
2
+ // Called from `server/index.ts` after the task-manager is created
3
+ // but before `taskManager.start()`.
4
+ //
5
+ // The tick goes through the per-plugin mutex (`kickTickLocked`) so
6
+ // it serialises with handler-side mutations. Same hourly heartbeat
7
+ // any other Encore tick would use; the dispatch handlers' kick is a
8
+ // best-effort optimisation that surfaces newly-due notifications
9
+ // within the same SSE turn as the mutation that caused them.
10
+
11
+ import { log } from "../system/logger/index.js";
12
+ import type { ITaskManager } from "../events/task-manager/index.js";
13
+ import { SCHEDULE_TYPES } from "@receptron/task-scheduler";
14
+ import { ONE_HOUR_MS } from "../utils/time.js";
15
+ import { kickTickLocked } from "./lock.js";
16
+
17
+ const ENCORE_TICK_ID = "encore-tick";
18
+
19
+ export function registerEncoreTick(taskManager: ITaskManager): void {
20
+ taskManager.registerTask({
21
+ id: ENCORE_TICK_ID,
22
+ description: "Encore — fire and escalate notifications based on each obligation's firingPlan.",
23
+ schedule: { type: SCHEDULE_TYPES.interval, intervalMs: ONE_HOUR_MS },
24
+ run: async ({ now }) => {
25
+ await kickTickLocked({ now }, "encore-tick heartbeat");
26
+ },
27
+ });
28
+
29
+ // Fire-on-boot. Without this, a phase that came due during a
30
+ // downtime window (e.g. a daily obligation whose cycle-start is
31
+ // today, but the server was off all morning) wouldn't surface in
32
+ // the bell until the next hourly heartbeat — up to ~60 minutes
33
+ // of silence after reboot. Fire-and-forget so we don't block the
34
+ // rest of boot; errors are swallowed by tickUnlocked's inner
35
+ // try/catch and surfaced via log.warn.
36
+ kickTickLocked({ now: new Date() }, "encore-tick boot").catch((err) => {
37
+ log.warn("encore", "boot tick: unhandled error", { error: err instanceof Error ? err.message : String(err) });
38
+ });
39
+ }
@@ -0,0 +1,36 @@
1
+ // Closure derivation — pure functions over the cycle file's
2
+ // recorded-data shape.
3
+ //
4
+ // On-disk per-cycle state holds only DATA, not status flags:
5
+ // - values[fieldName] — what the user told us
6
+ // - skipped — explicit "this target is opted out for this cycle"
7
+ // - completedSteps[stepId] — timestamp set by markStepDone
8
+ //
9
+ // "Closed" is derived from those three. There's no `status: closed`
10
+ // field anywhere; if there were, it could disagree with the data
11
+ // (and did, in the original landing — bug fixed by this refactor).
12
+
13
+ import type { EncoreDsl, StepDef } from "../../src/types/encore-dsl/schema.js";
14
+ import type { TargetRecord, CycleState } from "./cycle.js";
15
+
16
+ /** A step is closed if the target is skipped, OR if markStepDone
17
+ * has explicitly recorded completion in `completedSteps[step.id]`.
18
+ * The recorded `values` are evidence the user collected — they're
19
+ * data, not the closure signal. markStepDone is the one verb that
20
+ * says "this step is done"; recordValues writes partial info
21
+ * without closure. */
22
+ export function isStepClosed(record: TargetRecord | undefined, step: StepDef): boolean {
23
+ if (!record) return false;
24
+ if (record.skipped) return true;
25
+ return Boolean(record.completedSteps?.[step.id]);
26
+ }
27
+
28
+ export function isTargetClosed(record: TargetRecord | undefined, dsl: EncoreDsl): boolean {
29
+ if (!record) return false;
30
+ if (record.skipped) return true;
31
+ return dsl.steps.every((step) => isStepClosed(record, step));
32
+ }
33
+
34
+ export function isCycleClosed(state: CycleState, dsl: EncoreDsl): boolean {
35
+ return dsl.targets.every((target) => isTargetClosed(state.records[target.id], dsl));
36
+ }
@@ -0,0 +1,276 @@
1
+ // Per-cycle state file.
2
+ //
3
+ // One cycle = one markdown file at obligations/<id>/<cycleId>.md.
4
+ // Frontmatter holds ONLY user-recorded data — no status flags, no
5
+ // notification ids. Closure ("is this step/target/cycle done?") is
6
+ // derived on the fly by `./closure.ts` from the recorded data; if
7
+ // it were stored as a flag, the flag could disagree with the data
8
+ // (and did — see PR #1416 follow-up).
9
+ //
10
+ // Three things on disk per target:
11
+ // - `values` — what was collected (markStepDone / recordValues)
12
+ // - `skipped` — explicit per-target skip for this cycle
13
+ // - `completedSteps[stepId]` — timestamp set by markStepDone
14
+ //
15
+ // Notification-bell tracking (`activeNotificationId`,
16
+ // `lastPublishedSeverity`) lives in tickets, NOT in this file.
17
+ // One ticket = one live bell entry; the tick scans
18
+ // `tickets/*.json` to know what's active.
19
+
20
+ import { parseEncoreFrontmatter as parseFrontmatter, serializeEncoreFrontmatter as serializeWithFrontmatter } from "./yaml-fm.js";
21
+ import type { EncoreDsl } from "../../src/types/encore-dsl/schema.js";
22
+ import type { CycleSlot } from "../../src/types/encore-dsl/cadence.js";
23
+ import { cycleDeadline, cycleStart, formatCycleId } from "../../src/types/encore-dsl/cadence.js";
24
+
25
+ export interface TargetRecord {
26
+ /** Per-cycle field values keyed by formSchema field name.
27
+ * Sparse — fields the user hasn't told us about yet are absent
28
+ * (or null). Optional pre-fill from `targets[].defaults`. */
29
+ values?: Record<string, unknown>;
30
+ /** Explicit "skip this target for this cycle" marker. Set by
31
+ * markTargetSkipped. Presence means the entire target counts
32
+ * as closed regardless of values / completedSteps. */
33
+ skipped?: string;
34
+ /** stepId → ISO timestamp when markStepDone was called for this
35
+ * (target, step). Set by markStepDone; the only signal that a
36
+ * step is closed. Steps with no required fields can only close
37
+ * via this map; steps with required fields ALSO require this
38
+ * marker (recordValues alone never closes anything). */
39
+ completedSteps?: Record<string, string>;
40
+ /** stepId → ISO timestamp until which the step is snoozed. The
41
+ * tick skips steps whose snooze hasn't expired (treats them as
42
+ * un-firable). Set by `snooze`; once the timestamp passes, the
43
+ * next tick re-evaluates and may re-fire. NOT closure — a
44
+ * snoozed step is still considered open by closure.ts. */
45
+ snoozedSteps?: Record<string, string>;
46
+ }
47
+
48
+ export interface CycleState {
49
+ cycleId: string;
50
+ cycleStart: string;
51
+ cycleDeadline: string;
52
+ /** Sparse — targets the user hasn't touched are absent. */
53
+ records: Record<string, TargetRecord>;
54
+ }
55
+
56
+ /** Build a fresh CycleState for a new cycle of the obligation.
57
+ * Pre-fills per-target defaults into `values` where the DSL
58
+ * provides them. */
59
+ export function buildCycleState(dsl: EncoreDsl, slot: CycleSlot): CycleState {
60
+ const startIso = cycleStart(dsl.cadence, slot);
61
+ const deadlineIso = cycleDeadline(dsl.cadence, slot);
62
+
63
+ const records: Record<string, TargetRecord> = {};
64
+ for (const target of dsl.targets) {
65
+ if (target.defaults && Object.keys(target.defaults).length > 0) {
66
+ records[target.id] = { values: { ...target.defaults } };
67
+ }
68
+ }
69
+
70
+ return {
71
+ cycleId: formatCycleId(slot),
72
+ cycleStart: startIso,
73
+ cycleDeadline: deadlineIso,
74
+ records,
75
+ };
76
+ }
77
+
78
+ // ── pure mutators — write data, never flags ──────────────────────
79
+
80
+ /** Record a step as done for one target. Stamps
81
+ * `completedSteps[stepId]` AND merges any provided values. The
82
+ * closure-derivation reads `completedSteps`; values are kept as
83
+ * data the LLM can quote back later. */
84
+ export function recordStepDone(state: CycleState, targetId: string, stepId: string, values?: Record<string, unknown>): CycleState {
85
+ const next = cloneState(state);
86
+ const record = upsertRecord(next, targetId);
87
+ record.completedSteps = { ...(record.completedSteps ?? {}), [stepId]: new Date().toISOString() };
88
+ if (values && Object.keys(values).length > 0) {
89
+ record.values = { ...(record.values ?? {}), ...values };
90
+ }
91
+ return next;
92
+ }
93
+
94
+ /** Skip an entire target for this cycle. Derivation treats this
95
+ * as closed without inspecting individual steps. */
96
+ export function recordTargetSkip(state: CycleState, targetId: string): CycleState {
97
+ const next = cloneState(state);
98
+ const record = upsertRecord(next, targetId);
99
+ record.skipped = new Date().toISOString();
100
+ return next;
101
+ }
102
+
103
+ /** Mark a step snoozed until `untilIso` for one target. The tick
104
+ * will skip it until the timestamp passes. Not closure — once
105
+ * the snooze expires the step is firable again. */
106
+ export function recordStepSnooze(state: CycleState, targetId: string, stepId: string, untilIso: string): CycleState {
107
+ const next = cloneState(state);
108
+ const record = upsertRecord(next, targetId);
109
+ record.snoozedSteps = { ...(record.snoozedSteps ?? {}), [stepId]: untilIso };
110
+ return next;
111
+ }
112
+
113
+ /** Inverse of `recordStepSnooze`. Idempotent — no-op if the entry
114
+ * was already absent. Used by the `unsnooze` dispatch kind so the
115
+ * bell can republish in the same turn (the reconciler sees the
116
+ * pair eligible to fire again). */
117
+ export function recordStepUnsnooze(state: CycleState, targetId: string, stepId: string): CycleState {
118
+ const next = cloneState(state);
119
+ const record = next.records[targetId];
120
+ if (record?.snoozedSteps && stepId in record.snoozedSteps) {
121
+ const rest: Record<string, string> = {};
122
+ for (const [key, value] of Object.entries(record.snoozedSteps)) {
123
+ if (key !== stepId) rest[key] = value;
124
+ }
125
+ record.snoozedSteps = Object.keys(rest).length > 0 ? rest : undefined;
126
+ }
127
+ return next;
128
+ }
129
+
130
+ /** True iff the target's `snoozedSteps[stepId]` is present AND its
131
+ * ISO timestamp hasn't passed yet. Used by the reconciler both for
132
+ * "should this bundle target trim?" and "is this un-fired pair
133
+ * eligible to fire?" — the symmetry the pre-reconciler code lacked.
134
+ *
135
+ * Use a full ISO timestamp for `nowIso` (not date-only `YYYY-MM-DD`).
136
+ * `snoozedUntil` is written by `recordStepSnooze` via
137
+ * `toISOString()`; comparing it lexically against a date-only
138
+ * string would over-block by ~24h for any snooze that doesn't land
139
+ * on midnight. */
140
+ export function isStepSnoozed(record: TargetRecord | undefined, stepId: string, nowIso: string): boolean {
141
+ const until = record?.snoozedSteps?.[stepId];
142
+ return Boolean(until && until > nowIso);
143
+ }
144
+
145
+ /** Merge new field values onto a target without marking any step
146
+ * done. This is `recordValues` semantics — partial info, no
147
+ * closure. */
148
+ export function applyValues(state: CycleState, targetId: string, values: Record<string, unknown>): CycleState {
149
+ const next = cloneState(state);
150
+ const record = upsertRecord(next, targetId);
151
+ record.values = { ...(record.values ?? {}), ...values };
152
+ return next;
153
+ }
154
+
155
+ function upsertRecord(state: CycleState, targetId: string): TargetRecord {
156
+ let record = state.records[targetId];
157
+ if (!record) {
158
+ record = {};
159
+ state.records[targetId] = record;
160
+ }
161
+ return record;
162
+ }
163
+
164
+ function cloneState(state: CycleState): CycleState {
165
+ return JSON.parse(JSON.stringify(state)) as CycleState;
166
+ }
167
+
168
+ // ── parse / serialize ─────────────────────────────────────────────
169
+
170
+ /** Parse a cycle file's raw markdown into (state, body). Tolerant
171
+ * of extra fields in the frontmatter (old-shape `status` /
172
+ * `activeNotificationId` / etc. from pre-refactor files are
173
+ * silently dropped). The first write through `serializeCycleFile`
174
+ * normalises the file to the new shape. */
175
+ export function parseCycleFile(raw: string): { state: CycleState; body: string } {
176
+ const parsed = parseFrontmatter(raw);
177
+ if (!parsed.hasHeader) {
178
+ throw new Error("cycle file: missing YAML frontmatter");
179
+ }
180
+ const meta = parsed.meta as Partial<CycleState> & Record<string, unknown>;
181
+ if (
182
+ typeof meta.cycleId !== "string" ||
183
+ typeof meta.cycleStart !== "string" ||
184
+ typeof meta.cycleDeadline !== "string" ||
185
+ typeof meta.records !== "object" ||
186
+ meta.records === null ||
187
+ Array.isArray(meta.records)
188
+ ) {
189
+ throw new Error("cycle file: frontmatter missing required fields (cycleId/cycleStart/cycleDeadline/records)");
190
+ }
191
+ return {
192
+ state: {
193
+ cycleId: meta.cycleId,
194
+ cycleStart: meta.cycleStart,
195
+ cycleDeadline: meta.cycleDeadline,
196
+ records: normaliseRecords(meta.records as Record<string, unknown>),
197
+ },
198
+ body: parsed.body,
199
+ };
200
+ }
201
+
202
+ /** Strip old-shape fields (status, steps with stepDeadline/
203
+ * activeNotificationId/lastPublishedSeverity) from each target
204
+ * record. New-shape fields pass through verbatim. */
205
+ /** Pick only string-valued entries from a map. Used to validate
206
+ * `completedSteps` / `snoozedSteps` so a malformed object/boolean
207
+ * doesn't get treated as a truthy completion marker. */
208
+ function pickStringMap(raw: unknown): Record<string, string> | undefined {
209
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
210
+ const filtered: Record<string, string> = {};
211
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
212
+ if (typeof value === "string") filtered[key] = value;
213
+ }
214
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
215
+ }
216
+
217
+ function normaliseSkipped(raw: unknown): string | undefined {
218
+ if (typeof raw === "string") return raw;
219
+ // Tolerate boolean form from any intermediate iteration.
220
+ if (raw === true) return new Date(0).toISOString();
221
+ return undefined;
222
+ }
223
+
224
+ function normaliseOneRecord(raw: Record<string, unknown>): TargetRecord {
225
+ const normalised: TargetRecord = {};
226
+ // Reject arrays here — `typeof [] === "object"` would otherwise
227
+ // accept a malformed `values: [...]` and normalize it into an
228
+ // invalid field-map.
229
+ if (raw.values && typeof raw.values === "object" && !Array.isArray(raw.values)) {
230
+ normalised.values = raw.values as Record<string, unknown>;
231
+ }
232
+ const skipped = normaliseSkipped(raw.skipped);
233
+ if (skipped) normalised.skipped = skipped;
234
+ const completed = pickStringMap(raw.completedSteps);
235
+ if (completed) normalised.completedSteps = completed;
236
+ const snoozed = pickStringMap(raw.snoozedSteps);
237
+ if (snoozed) normalised.snoozedSteps = snoozed;
238
+ return normalised;
239
+ }
240
+
241
+ function normaliseRecords(raw: Record<string, unknown>): Record<string, TargetRecord> {
242
+ const out: Record<string, TargetRecord> = {};
243
+ for (const [targetId, value] of Object.entries(raw)) {
244
+ // `typeof [] === "object"` would otherwise accept a malformed
245
+ // `records.<targetId>: []` and normalise it to `{}`, silently
246
+ // swallowing the malformed shape. Reject arrays here.
247
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
248
+ out[targetId] = normaliseOneRecord(value as Record<string, unknown>);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /** Serialize a CycleState + body back to markdown. Empty per-target
254
+ * `values` / `completedSteps` maps are dropped so the file stays
255
+ * minimal (a target the user hasn't touched serialises as `{}`,
256
+ * or is absent entirely from `records` — both shapes round-trip). */
257
+ export function serializeCycleFile(state: CycleState, body: string): string {
258
+ const records: Record<string, unknown> = {};
259
+ for (const [targetId, record] of Object.entries(state.records)) {
260
+ const out: Record<string, unknown> = {};
261
+ if (record.values && Object.keys(record.values).length > 0) out.values = record.values;
262
+ if (record.skipped) out.skipped = record.skipped;
263
+ if (record.completedSteps && Object.keys(record.completedSteps).length > 0) out.completedSteps = record.completedSteps;
264
+ if (record.snoozedSteps && Object.keys(record.snoozedSteps).length > 0) out.snoozedSteps = record.snoozedSteps;
265
+ records[targetId] = out;
266
+ }
267
+ return serializeWithFrontmatter(
268
+ {
269
+ cycleId: state.cycleId,
270
+ cycleStart: state.cycleStart,
271
+ cycleDeadline: state.cycleDeadline,
272
+ records,
273
+ },
274
+ body,
275
+ );
276
+ }