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.
- package/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- 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
|
+
}
|