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,44 @@
1
+ // `recordValues` handler — record form values against a target
2
+ // without closing any step, then reconcile.
3
+
4
+ import { z } from "zod";
5
+
6
+ import { applyValues } from "../cycle.js";
7
+ import { log } from "../../system/logger/index.js";
8
+ import { assertKnownTargetAndStep, loadCycle, loadDsl, persistAndReconcile, workspaceRelativePath, type EncoreDispatchResult } from "./shared.js";
9
+
10
+ export const RecordValuesArgs = z.object({
11
+ kind: z.literal("recordValues"),
12
+ obligationId: z.string().trim().min(1),
13
+ cycleId: z.string().trim().min(1),
14
+ targetId: z.string().trim().min(1),
15
+ values: z.record(z.string(), z.unknown()),
16
+ pendingId: z.string().trim().min(1).optional(),
17
+ });
18
+
19
+ export async function handleRecordValues(args: z.infer<typeof RecordValuesArgs>): Promise<EncoreDispatchResult> {
20
+ const dsl = await loadDsl(args.obligationId);
21
+ assertKnownTargetAndStep(dsl, args);
22
+ const { rel, state, body } = await loadCycle(args.obligationId, args.cycleId);
23
+ const nextState = applyValues(state, args.targetId, args.values);
24
+ // recordValues never closes anything, so the reconciler is a no-op
25
+ // for bells — but we still funnel through `persistAndReconcile`
26
+ // for uniformity (no special-case handler shape). The reconciler
27
+ // is cheap when nothing changed: Phase 1 sees the same live targets,
28
+ // Phase 2 sees the same covered keys, no notifier calls.
29
+ await persistAndReconcile(rel, nextState, body, args.obligationId, args.cycleId);
30
+ log.info("encore", "recordValues: values recorded", {
31
+ obligationId: args.obligationId,
32
+ cycleId: args.cycleId,
33
+ targetId: args.targetId,
34
+ keys: Object.keys(args.values),
35
+ });
36
+ return {
37
+ ok: true,
38
+ message: `Encore: recorded ${Object.keys(args.values).length} value(s) on ${args.targetId} in cycle ${args.cycleId}.`,
39
+ obligationId: args.obligationId,
40
+ cycleId: args.cycleId,
41
+ targetId: args.targetId,
42
+ cyclePath: workspaceRelativePath(rel),
43
+ };
44
+ }
@@ -0,0 +1,121 @@
1
+ // `resolveNotification` handler — handle the bell-click path. Seeds
2
+ // a fresh chat session for an active ticket, or clears an orphan
3
+ // bell entry whose ticket was already swept.
4
+ //
5
+ // DOCUMENTED EXCEPTION to the reconciler-owns-the-bell rule:
6
+ // `handleOrphanResolve` calls `encoreNotifier.clear` directly because
7
+ // there's no ticket to reconcile against. See server/encore/INVARIANTS.md.
8
+
9
+ import { z } from "zod";
10
+ import { randomUUID } from "node:crypto";
11
+
12
+ import { ticketPath } from "../paths.js";
13
+ import { readTextOrNull, writeText } from "../../utils/files/encore-io.js";
14
+ import * as encoreNotifier from "../notifier.js";
15
+ import { ENCORE_PLUGIN_PKG } from "../notifier.js";
16
+ import { startChat } from "../../api/routes/agent.js";
17
+ import { PLUGIN_SESSION_ORIGIN_PREFIX } from "../../../src/types/session.js";
18
+ import { ENCORE_SEED_ROLE_ID } from "../../../src/config/roles.js";
19
+ import { log } from "../../system/logger/index.js";
20
+ import type { Ticket } from "../tick.js";
21
+ import { EncoreError, type EncoreDispatchResult } from "./shared.js";
22
+
23
+ export const ResolveNotificationArgs = z.object({
24
+ kind: z.literal("resolveNotification"),
25
+ pendingId: z.string(),
26
+ /** Bell entry id, spliced onto the navigateTarget at click time
27
+ * by the host's NotificationBell.vue. Lets us clear orphan bell
28
+ * entries whose ticket was already swept. */
29
+ notificationId: z.string().optional(),
30
+ });
31
+
32
+ async function handleOrphanResolve(args: z.infer<typeof ResolveNotificationArgs>): Promise<EncoreDispatchResult> {
33
+ // The ticket was already swept (e.g. the LLM resolved the
34
+ // obligation in another chat before this click). Clear the bell
35
+ // entry so it disappears.
36
+ //
37
+ // DOCUMENTED EXCEPTION to the reconciler-owns-the-bell rule:
38
+ // there's no ticket to reconcile against, so the reconciler can't
39
+ // know the bell entry exists. Direct clear is the only way out.
40
+ let cleared = false;
41
+ if (args.notificationId) {
42
+ try {
43
+ await encoreNotifier.clear(args.notificationId);
44
+ cleared = true;
45
+ } catch (err) {
46
+ log.warn("encore", "resolveNotification: orphan clear failed", {
47
+ notificationId: args.notificationId,
48
+ error: err instanceof Error ? err.message : String(err),
49
+ });
50
+ }
51
+ }
52
+ // Distinguish three outcomes in the message so the caller can tell
53
+ // whether action is still needed: cleared, no-id-to-clear-against,
54
+ // or clear attempted but failed (warning was logged).
55
+ const message = orphanMessage(cleared, args.notificationId !== undefined);
56
+ return {
57
+ ok: false,
58
+ orphan: true,
59
+ message,
60
+ error: "ticket not found",
61
+ cleared,
62
+ };
63
+ }
64
+
65
+ function orphanMessage(cleared: boolean, hadNotificationId: boolean): string {
66
+ const head = "Encore: this notification has already been resolved (the ticket is gone).";
67
+ if (cleared) return `${head} Bell entry cleared.`;
68
+ if (hadNotificationId) return `${head} Bell entry clear was attempted but failed; the bell may still be visible.`;
69
+ return head;
70
+ }
71
+
72
+ async function seedChatForTicket(ticket: Ticket, ticketRel: string, pendingId: string): Promise<string> {
73
+ const chatSessionId = randomUUID();
74
+ const result = await startChat({
75
+ message: ticket.seedPrompt,
76
+ roleId: ENCORE_SEED_ROLE_ID,
77
+ chatSessionId,
78
+ origin: `${PLUGIN_SESSION_ORIGIN_PREFIX}${ENCORE_PLUGIN_PKG}`,
79
+ });
80
+ if (result.kind === "error") {
81
+ throw new EncoreError(result.status ?? 500, `resolveNotification: startChat failed — ${result.error}`);
82
+ }
83
+ await writeText(ticketRel, JSON.stringify({ ...ticket, chatSessionId }, null, 2));
84
+ log.info("encore", "resolveNotification: chat seeded", {
85
+ pendingId,
86
+ chatSessionId,
87
+ obligationId: ticket.obligationId,
88
+ cycleId: ticket.cycleId,
89
+ });
90
+ return chatSessionId;
91
+ }
92
+
93
+ export async function handleResolveNotification(args: z.infer<typeof ResolveNotificationArgs>): Promise<EncoreDispatchResult> {
94
+ const ticketRel = ticketPath(args.pendingId);
95
+ const raw = await readTextOrNull(ticketRel);
96
+ if (raw === null) return handleOrphanResolve(args);
97
+
98
+ let ticket: Ticket;
99
+ try {
100
+ ticket = JSON.parse(raw) as Ticket;
101
+ } catch (err) {
102
+ throw new EncoreError(500, `ticket ${JSON.stringify(args.pendingId)} is unparseable`, {
103
+ error: err instanceof Error ? err.message : String(err),
104
+ });
105
+ }
106
+
107
+ // Idempotency: if this ticket already has a chat session, reuse
108
+ // it rather than spawning a duplicate on double-click.
109
+ const { chatSessionId: existing } = ticket;
110
+ const chatSessionId = existing ?? (await seedChatForTicket(ticket, ticketRel, args.pendingId));
111
+ if (existing) {
112
+ log.info("encore", "resolveNotification: reusing existing chat", { pendingId: args.pendingId, chatSessionId });
113
+ }
114
+
115
+ return {
116
+ ok: true,
117
+ message: `Encore: opened chat ${chatSessionId} for ${ticket.obligationId}/${ticket.cycleId}.`,
118
+ chatId: chatSessionId,
119
+ navigateTo: `/chat/${chatSessionId}`,
120
+ };
121
+ }
@@ -0,0 +1,81 @@
1
+ // `setup` handler — provision a new obligation from a DSL document.
2
+
3
+ import { z } from "zod";
4
+
5
+ import { EncoreDslInput, type EncoreDsl } from "../../../src/types/encore-dsl/schema.js";
6
+ import { buildCycleState, serializeCycleFile } from "../cycle.js";
7
+ import { currentCycleSlot } from "../../../src/types/encore-dsl/cadence.js";
8
+ import { serializeIndexFile } from "../obligation.js";
9
+ import { cycleFilePath, obligationIndexPath, slugify } from "../paths.js";
10
+ import { exists, writeText } from "../../utils/files/encore-io.js";
11
+ import { reconcileCycleNotifications } from "../reconcile.js";
12
+ import { log } from "../../system/logger/index.js";
13
+ import { EncoreError, coerceDefinitionToObject, formatZodError, workspaceRelativePath, type EncoreDispatchResult } from "./shared.js";
14
+
15
+ export const SetupArgs = z.object({
16
+ kind: z.literal("setup"),
17
+ definition: z.unknown(),
18
+ });
19
+
20
+ /** Setup-time id allocation. Reject with 409 if the slugified
21
+ * `displayName` collides with an existing obligation — the LLM
22
+ * almost certainly intended to amend an existing obligation but
23
+ * forgot to pass `obligationId`. The previous behavior (silently
24
+ * auto-number `-2`, `-3`) masked that mistake and produced
25
+ * parallel duplicates. The reject message tells the LLM how to
26
+ * recover (pass `obligationId` to make it an amend, or change
27
+ * `displayName`). See plans/feat-encore-define-tool.md. */
28
+ async function requireUniqueObligationId(displayName: string): Promise<string> {
29
+ const slug = slugify(displayName);
30
+ if (!(await exists(obligationIndexPath(slug)))) return slug;
31
+ throw new EncoreError(
32
+ 409,
33
+ `Obligation ${JSON.stringify(slug)} already exists (displayName: ${JSON.stringify(displayName)}). ` +
34
+ `To modify it, call defineEncore with obligationId: ${JSON.stringify(slug)} (this becomes an amend). ` +
35
+ `To create a parallel obligation, change the displayName.`,
36
+ );
37
+ }
38
+
39
+ export async function handleSetup(args: z.infer<typeof SetupArgs>): Promise<EncoreDispatchResult> {
40
+ const definitionObject = coerceDefinitionToObject(args.definition, "setup");
41
+ let dsl: EncoreDsl;
42
+ try {
43
+ dsl = EncoreDslInput.parse(definitionObject);
44
+ } catch (err) {
45
+ if (err instanceof z.ZodError) {
46
+ throw new EncoreError(400, formatZodError(err), { issues: err.issues });
47
+ }
48
+ throw err;
49
+ }
50
+
51
+ const obligationId = await requireUniqueObligationId(dsl.displayName);
52
+ const fullDsl: EncoreDsl = {
53
+ ...dsl,
54
+ id: obligationId,
55
+ createdAt: new Date().toISOString(),
56
+ };
57
+
58
+ // Provision the first cycle synchronously so the obligation has
59
+ // something to fire against on the very next tick.
60
+ const slot = currentCycleSlot(fullDsl.cadence, new Date());
61
+ const cycle = buildCycleState(fullDsl, slot);
62
+
63
+ await writeText(obligationIndexPath(obligationId), serializeIndexFile(fullDsl, ""));
64
+ await writeText(cycleFilePath(obligationId, cycle.cycleId), serializeCycleFile(cycle, ""));
65
+
66
+ // Reconcile so that if the firingPlan's first phase is already due
67
+ // (cycle-start with no offset, for example), the bell surfaces the
68
+ // notification within the same SSE turn.
69
+ await reconcileCycleNotifications({ obligationId, cycleId: cycle.cycleId, now: new Date(), log });
70
+
71
+ log.info("encore", "setup: obligation created", { obligationId, cycleId: cycle.cycleId });
72
+
73
+ return {
74
+ ok: true,
75
+ message: `Encore obligation ${JSON.stringify(dsl.displayName)} created (id: ${obligationId}, first cycle: ${cycle.cycleId}, deadline: ${cycle.cycleDeadline}).`,
76
+ obligationId,
77
+ cycleId: cycle.cycleId,
78
+ cyclePath: workspaceRelativePath(cycleFilePath(obligationId, cycle.cycleId)),
79
+ indexPath: workspaceRelativePath(obligationIndexPath(obligationId)),
80
+ };
81
+ }
@@ -0,0 +1,137 @@
1
+ // Shared types and helpers used across encore handler modules.
2
+ //
3
+ // Each per-kind handler in this directory imports from here so that
4
+ // `dispatch.ts` stays a thin router and handlers stay focused on
5
+ // their own kind. EncoreError + the dispatch envelope types are
6
+ // re-exported from `dispatch.ts` for external callers (route adapter,
7
+ // tests).
8
+
9
+ import path from "node:path";
10
+ import { z } from "zod";
11
+
12
+ import type { EncoreDsl } from "../../../src/types/encore-dsl/schema.js";
13
+ import { parseCycleFile, serializeCycleFile, type CycleState } from "../cycle.js";
14
+ import { parseIndexFile } from "../obligation.js";
15
+ import { cycleFilePath, obligationIndexPath } from "../paths.js";
16
+ import { readTextOrNull, writeText } from "../../utils/files/encore-io.js";
17
+ import { WORKSPACE_DIRS } from "../../workspace/paths.js";
18
+ import { reconcileCycleNotifications } from "../reconcile.js";
19
+ import { log } from "../../system/logger/index.js";
20
+
21
+ // ── error types + envelope ────────────────────────────────────────
22
+
23
+ export interface EncoreDispatchBody {
24
+ kind: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export interface EncoreDispatchResult {
29
+ ok: boolean;
30
+ message: string;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ export class EncoreError extends Error {
35
+ constructor(
36
+ public readonly status: number,
37
+ message: string,
38
+ public readonly details?: unknown,
39
+ ) {
40
+ super(message);
41
+ this.name = "EncoreError";
42
+ }
43
+ }
44
+
45
+ // ── DSL coercion + error formatting ───────────────────────────────
46
+
47
+ export function formatZodError(err: z.ZodError): string {
48
+ // First issue's path + message — Claude reads this and either
49
+ // self-corrects or asks the user. The full issues list is in
50
+ // `details` for clients that want the structured form.
51
+ const [first] = err.issues;
52
+ const pathStr = first.path.length > 0 ? first.path.map((segment) => String(segment)).join(".") : "(root)";
53
+ return `DSL validation failed at ${pathStr}: ${first.message}. Read config/helps/encore-dsl.md for the full grammar.`;
54
+ }
55
+
56
+ /** Accept `definition` as either an object literal OR a JSON-encoded
57
+ * string of one. The LLM commonly JSON.stringify's tool-call
58
+ * arguments (especially for nested objects), and rejecting that
59
+ * shape with "expected object, received string" reads as a schema
60
+ * problem rather than a wire-format problem — the LLM tends to
61
+ * retry with the same shape. Silently coercing eliminates the
62
+ * whole class of mistake. The trade-off: a non-JSON string or a
63
+ * JSON string that decodes to a non-object surfaces with a clear
64
+ * 400 instead of being silently dropped. */
65
+ export function coerceDefinitionToObject(value: unknown, kind: string): Record<string, unknown> {
66
+ let coerced = value;
67
+ if (typeof coerced === "string") {
68
+ try {
69
+ coerced = JSON.parse(coerced);
70
+ } catch (err) {
71
+ throw new EncoreError(
72
+ 400,
73
+ `${kind}: \`definition\` was provided as a string but is not valid JSON: ${err instanceof Error ? err.message : String(err)}. Pass an object literal, or a JSON-encoded string of one.`,
74
+ );
75
+ }
76
+ }
77
+ if (!coerced || typeof coerced !== "object" || Array.isArray(coerced)) {
78
+ const actual = Array.isArray(coerced) ? "array" : coerced === null ? "null" : typeof coerced;
79
+ throw new EncoreError(400, `${kind}: \`definition\` must be an object (or a JSON string of one), got ${actual}.`);
80
+ }
81
+ return coerced as Record<string, unknown>;
82
+ }
83
+
84
+ export function workspaceRelativePath(rel: string): string {
85
+ return path.join(WORKSPACE_DIRS.encore, rel);
86
+ }
87
+
88
+ // ── DSL / cycle loaders ───────────────────────────────────────────
89
+
90
+ export async function loadDsl(obligationId: string): Promise<EncoreDsl | null> {
91
+ const raw = await readTextOrNull(obligationIndexPath(obligationId));
92
+ if (raw === null) return null;
93
+ try {
94
+ return parseIndexFile(raw).dsl;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export async function loadCycle(obligationId: string, cycleId: string): Promise<{ rel: string; raw: string; state: CycleState; body: string }> {
101
+ const rel = cycleFilePath(obligationId, cycleId);
102
+ const raw = await readTextOrNull(rel);
103
+ if (raw === null) {
104
+ throw new EncoreError(404, `cycle file ${obligationId}/${cycleId}.md not found`);
105
+ }
106
+ const { state, body } = parseCycleFile(raw);
107
+ return { rel, raw, state, body };
108
+ }
109
+
110
+ /** The mutating-handler envelope. Write the cycle file, then run
111
+ * the reconciler under the same per-plugin lock that wraps this
112
+ * dispatch. The reconciler re-derives the desired bell state from
113
+ * disk — it's both the trim path (closed/snoozed → out of bundle)
114
+ * and the publish path (un-fired in-bundle pairs → publish). */
115
+ export async function persistAndReconcile(rel: string, state: CycleState, body: string, obligationId: string, cycleId: string): Promise<void> {
116
+ await writeText(rel, serializeCycleFile(state, body));
117
+ await reconcileCycleNotifications({ obligationId, cycleId, now: new Date(), log });
118
+ }
119
+
120
+ /** Reject calls referencing target/step ids that don't exist in
121
+ * the DSL. Without this, a typo (`pat` vs `pay`) would succeed
122
+ * silently — writing a record under the bogus id, leaving the
123
+ * real step still un-closed, and surfacing as "I told the LLM
124
+ * I paid but the bell didn't clear". */
125
+ export function assertKnownTargetAndStep(dsl: EncoreDsl | null, args: { obligationId: string; targetId: string; stepId?: string }): void {
126
+ if (!dsl) {
127
+ throw new EncoreError(404, `obligation ${JSON.stringify(args.obligationId)} not found`);
128
+ }
129
+ if (!dsl.targets.some((target) => target.id === args.targetId)) {
130
+ const known = dsl.targets.map((target) => target.id).join(", ");
131
+ throw new EncoreError(400, `unknown targetId ${JSON.stringify(args.targetId)} for obligation ${JSON.stringify(args.obligationId)}. Known: [${known}]`);
132
+ }
133
+ if (args.stepId !== undefined && !dsl.steps.some((step) => step.id === args.stepId)) {
134
+ const known = dsl.steps.map((step) => step.id).join(", ");
135
+ throw new EncoreError(400, `unknown stepId ${JSON.stringify(args.stepId)} for obligation ${JSON.stringify(args.obligationId)}. Known: [${known}]`);
136
+ }
137
+ }
@@ -0,0 +1,87 @@
1
+ // `snooze` / `unsnooze` handlers — inverse pair that mark a step as
2
+ // suppressed (or unsuppressed) for a cycle. The reconciler treats
3
+ // snoozed pairs as out-of-bundle, so trim/republish flows naturally.
4
+
5
+ import { z } from "zod";
6
+
7
+ import { recordStepSnooze, recordStepUnsnooze } from "../cycle.js";
8
+ import { ONE_HOUR_MS } from "../../utils/time.js";
9
+ import { log } from "../../system/logger/index.js";
10
+ import { assertKnownTargetAndStep, loadCycle, loadDsl, persistAndReconcile, type EncoreDispatchResult } from "./shared.js";
11
+
12
+ export const SnoozeArgs = z.object({
13
+ kind: z.literal("snooze"),
14
+ obligationId: z.string().trim().min(1),
15
+ cycleId: z.string().trim().min(1),
16
+ targetId: z.string().trim().min(1),
17
+ stepId: z.string().trim().min(1),
18
+ pendingId: z.string().trim().min(1).optional(),
19
+ });
20
+
21
+ export const UnsnoozeArgs = z.object({
22
+ kind: z.literal("unsnooze"),
23
+ obligationId: z.string().trim().min(1),
24
+ cycleId: z.string().trim().min(1),
25
+ targetId: z.string().trim().min(1),
26
+ stepId: z.string().trim().min(1),
27
+ });
28
+
29
+ export async function handleSnooze(args: z.infer<typeof SnoozeArgs>): Promise<EncoreDispatchResult> {
30
+ const dsl = await loadDsl(args.obligationId);
31
+ assertKnownTargetAndStep(dsl, args);
32
+ // Snooze under the unified-reconciler model: write
33
+ // `snoozedSteps[stepId]` on the cycle file and let the reconciler
34
+ // do the rest. Because `isPairInBundle` treats snoozed as
35
+ // out-of-bundle, Phase 1 trims this target out of any existing
36
+ // ticket (clearing the bell if the bundle empties); Phase 2 sees
37
+ // the pair as not eligible-to-fire (snooze active), so no
38
+ // republish. The pre-reconciler workaround ("skip the tick after
39
+ // snooze") is no longer needed.
40
+ const snoozeUntilIso = new Date(Date.now() + 24 * ONE_HOUR_MS).toISOString();
41
+ const { rel, state, body } = await loadCycle(args.obligationId, args.cycleId);
42
+ const nextState = recordStepSnooze(state, args.targetId, args.stepId, snoozeUntilIso);
43
+ await persistAndReconcile(rel, nextState, body, args.obligationId, args.cycleId);
44
+ log.info("encore", "snooze: step snoozed", {
45
+ obligationId: args.obligationId,
46
+ cycleId: args.cycleId,
47
+ targetId: args.targetId,
48
+ stepId: args.stepId,
49
+ snoozeUntilIso,
50
+ });
51
+ return {
52
+ ok: true,
53
+ message: `Encore: snoozed ${args.stepId} for ${args.targetId} in cycle ${args.cycleId} of ${args.obligationId}.`,
54
+ obligationId: args.obligationId,
55
+ cycleId: args.cycleId,
56
+ targetId: args.targetId,
57
+ stepId: args.stepId,
58
+ };
59
+ }
60
+
61
+ export async function handleUnsnooze(args: z.infer<typeof UnsnoozeArgs>): Promise<EncoreDispatchResult> {
62
+ const dsl = await loadDsl(args.obligationId);
63
+ assertKnownTargetAndStep(dsl, args);
64
+ // Inverse of snooze: delete `snoozedSteps[stepId]`. The reconciler
65
+ // sees the pair eligible to fire again (assuming the step isn't
66
+ // also closed) and Phase 2 publishes a fresh bell — in the same
67
+ // dispatch turn, no tick wait. If the step was already not
68
+ // snoozed, `recordStepUnsnooze` is a no-op and the reconciler
69
+ // sees no state change → no flicker.
70
+ const { rel, state, body } = await loadCycle(args.obligationId, args.cycleId);
71
+ const nextState = recordStepUnsnooze(state, args.targetId, args.stepId);
72
+ await persistAndReconcile(rel, nextState, body, args.obligationId, args.cycleId);
73
+ log.info("encore", "unsnooze: step unsnoozed", {
74
+ obligationId: args.obligationId,
75
+ cycleId: args.cycleId,
76
+ targetId: args.targetId,
77
+ stepId: args.stepId,
78
+ });
79
+ return {
80
+ ok: true,
81
+ message: `Encore: unsnoozed ${args.stepId} for ${args.targetId} in cycle ${args.cycleId} of ${args.obligationId}.`,
82
+ obligationId: args.obligationId,
83
+ cycleId: args.cycleId,
84
+ targetId: args.targetId,
85
+ stepId: args.stepId,
86
+ };
87
+ }
@@ -0,0 +1,64 @@
1
+ // `startObligationChat` handler — user-initiated chat about a
2
+ // specific obligation. Reached from the dashboard chat button
3
+ // (`/encore` landing); NOT exposed in the LLM-facing tool schema.
4
+ //
5
+ // Compared to `resolveNotification` (the bell-click path):
6
+ // - No ticket. There's no notification to clear and no severity
7
+ // baseline to track — this is purely user-initiated.
8
+ // - No reuse / idempotency. Each click yields a fresh chat. If
9
+ // the user wants to continue a previous conversation, they
10
+ // pick it from the sidebar instead.
11
+ // - Seed prompt is composed here from the DSL (id + displayName)
12
+ // rather than read from a ticket.
13
+
14
+ import { z } from "zod";
15
+ import { randomUUID } from "node:crypto";
16
+
17
+ import { startChat } from "../../api/routes/agent.js";
18
+ import { PLUGIN_SESSION_ORIGIN_PREFIX } from "../../../src/types/session.js";
19
+ import { ENCORE_SEED_ROLE_ID } from "../../../src/config/roles.js";
20
+ import { ENCORE_PLUGIN_PKG } from "../notifier.js";
21
+ import { log } from "../../system/logger/index.js";
22
+ import { EncoreError, loadDsl, type EncoreDispatchResult } from "./shared.js";
23
+
24
+ export const StartObligationChatArgs = z.object({
25
+ kind: z.literal("startObligationChat"),
26
+ obligationId: z.string().min(1),
27
+ });
28
+
29
+ function buildSeedPrompt(obligationId: string, displayName: string): string {
30
+ // Mention the obligationId explicitly so the LLM can call
31
+ // `manageEncore({ kind: "query", obligationId })` to read the
32
+ // current state on its first turn without guessing.
33
+ return `Let's talk about my "${displayName}" obligation (obligationId: ${obligationId}). Please query its current state first, then ask me what I'd like to do.`;
34
+ }
35
+
36
+ export async function handleStartObligationChat(args: z.infer<typeof StartObligationChatArgs>): Promise<EncoreDispatchResult> {
37
+ const dsl = await loadDsl(args.obligationId);
38
+ if (!dsl) {
39
+ throw new EncoreError(404, `obligation ${JSON.stringify(args.obligationId)} not found`);
40
+ }
41
+
42
+ const chatSessionId = randomUUID();
43
+ const result = await startChat({
44
+ message: buildSeedPrompt(args.obligationId, dsl.displayName),
45
+ roleId: ENCORE_SEED_ROLE_ID,
46
+ chatSessionId,
47
+ origin: `${PLUGIN_SESSION_ORIGIN_PREFIX}${ENCORE_PLUGIN_PKG}`,
48
+ });
49
+ if (result.kind === "error") {
50
+ throw new EncoreError(result.status ?? 500, `startObligationChat: startChat failed — ${result.error}`);
51
+ }
52
+
53
+ log.info("encore", "startObligationChat: chat seeded", {
54
+ obligationId: args.obligationId,
55
+ chatSessionId,
56
+ });
57
+
58
+ return {
59
+ ok: true,
60
+ message: `Encore: opened chat ${chatSessionId} for ${args.obligationId}.`,
61
+ chatId: chatSessionId,
62
+ navigateTo: `/chat/${chatSessionId}`,
63
+ };
64
+ }
@@ -0,0 +1,50 @@
1
+ // `startSetupChat` handler — user-initiated chat for creating a new
2
+ // obligation. Reached from the dashboard "+ Add" button (`/encore`
3
+ // landing); NOT exposed in the LLM-facing tool schema.
4
+ //
5
+ // Parallel to `startObligationChat` (which discusses an existing
6
+ // obligation) — this one has no obligation yet, so the seed prompt
7
+ // only asks the LLM to walk the user through setup. The LLM has
8
+ // the encore-dsl help file + `manageEncore({kind:"setup"})` already
9
+ // in its toolbelt; we don't need to teach it the schema here.
10
+
11
+ import { z } from "zod";
12
+ import { randomUUID } from "node:crypto";
13
+
14
+ import { startChat } from "../../api/routes/agent.js";
15
+ import { PLUGIN_SESSION_ORIGIN_PREFIX } from "../../../src/types/session.js";
16
+ import { ENCORE_SEED_ROLE_ID } from "../../../src/config/roles.js";
17
+ import { ENCORE_PLUGIN_PKG } from "../notifier.js";
18
+ import { log } from "../../system/logger/index.js";
19
+ import { EncoreError, type EncoreDispatchResult } from "./shared.js";
20
+
21
+ export const StartSetupChatArgs = z.object({
22
+ kind: z.literal("startSetupChat"),
23
+ });
24
+
25
+ const SEED_PROMPT =
26
+ "I'd like to set up a new recurring obligation in Encore. " +
27
+ "Please walk me through what to track (kind, cadence, targets, fields), " +
28
+ "then compose the DSL and call defineEncore when ready.";
29
+
30
+ export async function handleStartSetupChat(__args: z.infer<typeof StartSetupChatArgs>): Promise<EncoreDispatchResult> {
31
+ const chatSessionId = randomUUID();
32
+ const result = await startChat({
33
+ message: SEED_PROMPT,
34
+ roleId: ENCORE_SEED_ROLE_ID,
35
+ chatSessionId,
36
+ origin: `${PLUGIN_SESSION_ORIGIN_PREFIX}${ENCORE_PLUGIN_PKG}`,
37
+ });
38
+ if (result.kind === "error") {
39
+ throw new EncoreError(result.status ?? 500, `startSetupChat: startChat failed — ${result.error}`);
40
+ }
41
+
42
+ log.info("encore", "startSetupChat: chat seeded", { chatSessionId });
43
+
44
+ return {
45
+ ok: true,
46
+ message: `Encore: opened setup chat ${chatSessionId}.`,
47
+ chatId: chatSessionId,
48
+ navigateTo: `/chat/${chatSessionId}`,
49
+ };
50
+ }
@@ -0,0 +1,61 @@
1
+ // Per-plugin mutex. All dispatch calls and all ticks serialise
2
+ // through one Promise-chain so:
3
+ //
4
+ // - Two concurrent handler calls can't tmp+rename the same cycle
5
+ // file at once (writeFileAtomic ENOENT race).
6
+ // - A handler that kicks the tick can't race the hourly heartbeat
7
+ // into double-publishing the same notification.
8
+ //
9
+ // Implementation split:
10
+ // - `withLock(fn)`: appends fn to the chain; returns its result.
11
+ // Errors don't poison the chain — the next consumer's wait
12
+ // resolves regardless.
13
+ // - `kickTickLocked(reason)`: acquires the lock, runs the tick,
14
+ // releases. Handlers call this AFTER persisting; the hourly
15
+ // heartbeat also goes through this.
16
+ // - `runDispatchLocked(fn)`: thin alias for withLock — exists for
17
+ // readability at call sites in dispatch.ts.
18
+ //
19
+ // Handlers that need to kick the tick from inside their critical
20
+ // section MUST NOT call kickTickLocked (it would deadlock). They
21
+ // invoke `tickUnlocked` directly. The boot wiring + the hourly
22
+ // heartbeat both call kickTickLocked so external callers don't
23
+ // have to think about which one to pick.
24
+
25
+ import { log } from "../system/logger/index.js";
26
+ import { runTick, type TickDeps } from "./tick.js";
27
+
28
+ let pluginLock: Promise<unknown> = Promise.resolve();
29
+
30
+ /** Append `fn` to the per-plugin chain. Returns `fn`'s result. */
31
+ export function withLock<T>(task: () => Promise<T>): Promise<T> {
32
+ const next = pluginLock.catch(() => undefined).then(task);
33
+ pluginLock = next.catch(() => undefined);
34
+ return next;
35
+ }
36
+
37
+ /** Run the tick without acquiring the lock. Callers that already
38
+ * hold the lock (e.g. a handler that wants to kick the tick from
39
+ * inside its critical section) use this; everyone else uses
40
+ * `kickTickLocked`. */
41
+ export async function tickUnlocked(deps: TickDeps, reason: string): Promise<void> {
42
+ try {
43
+ await runTick(deps);
44
+ } catch (err) {
45
+ log.warn("encore", "tick: unhandled error", { reason, error: err instanceof Error ? err.message : String(err) });
46
+ }
47
+ }
48
+
49
+ /** Acquire the lock, run the tick, release. The hourly heartbeat
50
+ * and `kickTick`-from-handler calls both come through here so the
51
+ * serialisation guarantee applies. */
52
+ export async function kickTickLocked(deps: TickDeps, reason: string): Promise<void> {
53
+ await withLock(() => tickUnlocked(deps, reason));
54
+ }
55
+
56
+ /** Reset the lock to a fresh resolved Promise. Test-only — production
57
+ * code never wants this; the chain is meant to live for the process
58
+ * lifetime. */
59
+ export function _resetLockForTesting(): void {
60
+ pluginLock = Promise.resolve();
61
+ }