@worca/app 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -12,6 +12,12 @@ import { validateMemoryScope } from '../memory-sync.mjs';
12
12
  import { sanitizeBranchName, suggestBranchName } from '../worktree.mjs';
13
13
  import { sanitizeTitle } from '../title.mjs';
14
14
  import { ASK_LIMITS } from './limits.mjs';
15
+ import { resolveScheduleSpec } from './schedule-spec.mjs';
16
+ import { validateRunSource, checkTask } from './source-spec.mjs';
17
+ import { listTaskSources as realListTaskSources } from '../sources.mjs';
18
+ import { afterRefOf as realAfterRefOf } from '../scheduler.mjs';
19
+ import { resolveProfile as realResolveProfile } from '../source-bindings.mjs';
20
+ import { listProfileIds as realListProfileIds } from '../plugin-config.mjs';
15
21
 
16
22
  export const PROPOSAL_ERRORS = Object.freeze({
17
23
  bothTargets: 'provide workspaceId OR projectKey, not both',
@@ -27,6 +33,8 @@ export const PROPOSAL_ERRORS = Object.freeze({
27
33
  unknownGuardrails: (id) => `unknown guardrailsId "${id}"`,
28
34
  permissive: 'guardrailsId "permissive" is not allowed for proposed runs — use "normal" or a stricter set',
29
35
  briefRequired: 'brief is required',
36
+ briefAndSource: 'give brief OR source, not both — with a task source the run reads the task itself; put what you learned in the note',
37
+ autoWorkspace: 'Auto workflow is not available for workspace targets yet',
30
38
  briefTooLong: `brief exceeds ${ASK_LIMITS.briefMaxChars} characters`,
31
39
  badSource: (v) => `unknown or invalid sourceBranch: ${v}`,
32
40
  byKeyUnknown: (k) => `sourceBranchByKey has an unknown project key: ${k}`,
@@ -80,6 +88,8 @@ function cleanNote(v) {
80
88
  return s || null;
81
89
  }
82
90
 
91
+ function safeIds(plugin) { try { return realListProfileIds(plugin); } catch { return []; } }
92
+
83
93
  /**
84
94
  * @param {{listProjects?:Function, readWorkspace?:Function, readWorkflow?:Function, assertRunnableWorkflow?:Function, readGuardrailSet?:Function, isGitRepo?:Function, pathExists?:Function}} [deps]
85
95
  */
@@ -92,13 +102,20 @@ export function createProposalValidator({
92
102
  readGuardrailSet = realReadGuardrailSet,
93
103
  isGitRepo = realIsGitRepo,
94
104
  pathExists = existsSync,
105
+ // Plugin task sources (source-spec.mjs): the installed sources with each one's profile roster.
106
+ listTaskSources = () => realListTaskSources().map((s) => (s.type === 'plugin' && s.multiProfile ? { ...s, profiles: safeIds(s.plugin) } : s)),
107
+ resolveProfile = realResolveProfile,
108
+ // Run chains: the predecessor reader (core afterRefOf; tests inject a stub). The MCP child gets
109
+ // the same default — tool-deps.mjs re-exports this module's default-bound validateProposal.
110
+ afterRef = realAfterRefOf,
95
111
  } = {}) {
96
112
  /**
97
113
  * @param {object} input the propose_run tool input
98
- * @param {{cardId?:string|null}} [opts] the server passes the minted card id (feature-branch uniqueness)
114
+ * @param {{cardId?:string|null, timeZone?:string|null, nowMs?:number, scheduleDefaults?:object}} [opts] the server passes the
115
+ * minted card id (feature-branch uniqueness); timeZone is the user's (the schedule fields are read in it)
99
116
  * @returns {Promise<{ok:true, card:object}|{ok:false, errors:string[]}>}
100
117
  */
101
- async function validateProposal(input, { cardId = null, attachments = [] } = {}) {
118
+ async function validateProposal(input, { cardId = null, attachments = [], timeZone = null, nowMs = Date.now(), scheduleDefaults = {}, lookupTask = null } = {}) {
102
119
  const inp = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
103
120
  const errors = [];
104
121
  const fail = () => ({ ok: false, errors });
@@ -163,9 +180,28 @@ export function createProposalValidator({
163
180
  if (guardrailsId === 'permissive') errors.push(PROPOSAL_ERRORS.permissive);
164
181
  else if (guardrailsId && !(await readGuardrailSet(guardrailsId))) errors.push(PROPOSAL_ERRORS.unknownGuardrails(guardrailsId));
165
182
 
183
+ // ── Auto: project targets only, like POST /api/run ─────────────────────
184
+ if (wf && wf.id === 'wf_auto' && target.target === 'workspace') errors.push(PROPOSAL_ERRORS.autoWorkspace);
185
+
186
+ // ── task source (source-spec.mjs): a reference the run fetches at start ──
187
+ const src = validateRunSource(inp.source, { target, listTaskSources, resolveProfile });
188
+ if (!src.ok) errors.push(...src.errors);
189
+ let runSource = src.ok ? src.source : null;
190
+ let sourceWarning = null;
191
+ if (runSource && !errors.length) {
192
+ const chk = await checkTask(runSource, lookupTask);
193
+ if (!chk.ok) errors.push(chk.error);
194
+ else {
195
+ if (chk.task) runSource = { ...runSource, ...(chk.task.title ? { title: chk.task.title } : {}), ...(chk.task.url ? { url: chk.task.url } : {}) };
196
+ if (chk.warning) sourceWarning = chk.warning;
197
+ }
198
+ }
199
+
166
200
  // ── brief ──────────────────────────────────────────────────────────────
167
201
  const brief = String(inp.brief ?? '').trim();
168
- if (!brief) errors.push(PROPOSAL_ERRORS.briefRequired);
202
+ if (runSource || (inp.source !== undefined && inp.source !== null)) {
203
+ if (brief) errors.push(PROPOSAL_ERRORS.briefAndSource);
204
+ } else if (!brief) errors.push(PROPOSAL_ERRORS.briefRequired);
169
205
  else if (brief.length > ASK_LIMITS.briefMaxChars) errors.push(PROPOSAL_ERRORS.briefTooLong);
170
206
 
171
207
  // ── branches (syntactic only) ──────────────────────────────────────────
@@ -196,18 +232,31 @@ export function createProposalValidator({
196
232
  // ── title + feature branch ─────────────────────────────────────────────
197
233
  const title = sanitizeTitle(typeof inp.title === 'string' ? inp.title : '')
198
234
  || sanitizeTitle(brief.split(/\r?\n/)[0].slice(0, 80))
235
+ || (runSource ? sanitizeTitle(runSource.title || `${runSource.taskId}`) : '')
199
236
  || 'Proposed run';
200
237
  let featureBranch = typeof inp.featureBranch === 'string' ? sanitizeBranchName(inp.featureBranch) : '';
201
238
  if (!featureBranch) {
202
239
  const m = typeof cardId === 'string' ? CARD_HEX_RE.exec(cardId) : null;
203
- featureBranch = suggestBranchName({ prompt: brief, title, pipelineId: m ? m[1] : '' });
240
+ featureBranch = suggestBranchName({ prompt: brief || title, title, pipelineId: m ? m[1] : '' });
241
+ }
242
+
243
+ // ── schedule (docs/scheduled-runs.md "Ask Worca"): when | every, read in the user's zone ──
244
+ const spec = resolveScheduleSpec({ ...inp, projectKey: target.projectKey || '', workspaceId: target.workspaceId || '' }, { nowMs, timeZone, defaults: scheduleDefaults, afterRef });
245
+ if (!spec.ok) errors.push(...spec.errors);
246
+ if (spec.ok && spec.schedule && spec.schedule.kind === 'after' && spec.schedule.sourceFromPrevious && (sourceBranch || (sourceBranchByKey && Object.keys(sourceBranchByKey).length))) {
247
+ errors.push('sourceFromPrevious and sourceBranch / sourceBranchByKey cannot both be given');
204
248
  }
205
249
 
206
250
  if (errors.length) return fail();
207
251
  return {
208
252
  ok: true,
209
253
  card: { ...target, workflowId: wf.id, workflowName: wf.name, guardrailsId, memoryScope, brief, title, sourceBranch, featureBranch, sourceBranchByKey,
210
- note: cleanNote(inp.note), attachments: pickCardAttachments(inp.attachmentIds, attachments) },
254
+ note: cleanNote(inp.note), attachments: pickCardAttachments(inp.attachmentIds, attachments),
255
+ // Only a scheduled proposal carries the key: a plain run card keeps its shape byte for byte.
256
+ ...(spec.schedule ? { schedule: spec.schedule } : {}),
257
+ // Likewise a proposal whose task is a plugin task (an issue), not a brief.
258
+ ...(runSource ? { source: runSource } : {}),
259
+ ...(sourceWarning ? { sourceWarning } : {}) },
211
260
  };
212
261
  }
213
262
  return { validateProposal };
@@ -0,0 +1,83 @@
1
+ // src/core/ask/schedule-deps.mjs
2
+ // The scheduled-runs dependency bundle of the Ask Worca tools (docs/scheduled-runs.md
3
+ // "Ask Worca"): the ONE module that touches the scheduler on the tools' behalf. tools.mjs
4
+ // imports nothing, so every reader, the validators and the four small writers live here.
5
+ // Two callers:
6
+ // • the MCP child (mcp-stdio.mjs): deps.schedules.* behind list_schedules, get_schedule,
7
+ // list_schedule_activity, preview_schedule, propose_schedule_change (validate only — the
8
+ // card is the user's to apply) and the direct pause / resume / skip / mark-read tools;
9
+ // • the parent: turn.mjs re-validates a change with validateScheduleChange.
10
+ // The writers are the reversible ones that never start a run. Anything that starts, moves,
11
+ // edits or removes a run is a card, applied by ui/server.mjs behind the user's click.
12
+ import { getThread } from './store.mjs';
13
+ import {
14
+ listSchedules, listTickets, getSchedule, getTicket, pauseSchedule, resumeSchedule, skipNext, scheduleCounts, afterRefOf,
15
+ } from '../scheduler.mjs';
16
+ import { listNotifications, markRead, markAllRead, unreadCount } from '../notifications.mjs';
17
+ import { scheduleDefaults } from '../settings.mjs';
18
+ import { createScheduleChangeValidator, resolveScheduleSpec, effectiveTimeZone, whenText } from './schedule-spec.mjs';
19
+ import { previewOccurrences } from '../../shared/schedule/recurrence.mjs';
20
+
21
+ /** A schedule (sch_…) or a scheduled run (its runId) by id. */
22
+ export function getScheduleItem(id) {
23
+ if (typeof id !== 'string' || !id) return null;
24
+ if (id.startsWith('sch_')) { const s = getSchedule(id); return s ? { kind: 'recurring', item: s } : null; }
25
+ const t = getTicket(id);
26
+ return t ? { kind: 'once', item: t } : null;
27
+ }
28
+
29
+ /** The authoritative validator over the real rows (the turn's default; the child's too). */
30
+ export const validateScheduleChange = createScheduleChangeValidator({ getItem: getScheduleItem, afterRef: afterRefOf });
31
+
32
+ /** The timezone the user's browser reported for this thread, else this machine's. */
33
+ export function threadTimeZone(threadId) {
34
+ let tz = null;
35
+ try { tz = threadId ? getThread(threadId)?.context?.timeZone ?? null : null; } catch { tz = null; }
36
+ return effectiveTimeZone(tz);
37
+ }
38
+
39
+ /**
40
+ * @param {{threadId?:string|null}} [o]
41
+ */
42
+ export function defaultScheduleDeps({ threadId = null } = {}) {
43
+ return {
44
+ schedules: {
45
+ timeZone: () => threadTimeZone(threadId),
46
+ now: () => Date.now(),
47
+ defaults: () => scheduleDefaults(),
48
+ /** An ISO instant as the user reads it: "Sat Sep 19, 02:00" in their zone. */
49
+ when: (iso) => { const ms = Date.parse(iso); return Number.isFinite(ms) ? whenText(ms, threadTimeZone(threadId), Date.now()) : null; },
50
+ /** A series' next dates, in the series' own zone. */
51
+ nextDates: (rule, firedCount = 0) => (rule ? previewOccurrences(rule, Date.now(), 3, { firedCount })
52
+ .map((ms) => ({ at: new Date(ms).toISOString(), when: whenText(ms, rule.tz, Date.now()) })) : []),
53
+ list: ({ includeEnded = false } = {}) => ({
54
+ schedules: listSchedules({ includeEnded }),
55
+ runs: listTickets({ all: includeEnded, oneShotOnly: false, limit: 500 }),
56
+ counts: { ...scheduleCounts(), unread: unreadCount('schedule') },
57
+ }),
58
+ get: (id) => {
59
+ const found = getScheduleItem(id);
60
+ if (!found) return null;
61
+ const history = found.kind === 'recurring' ? listTickets({ scheduleId: found.item.id, all: true, limit: 50 }).reverse() : [];
62
+ const notifications = listNotifications({ scheduleId: found.kind === 'recurring' ? found.item.id : null, limit: 50 })
63
+ .filter((n) => found.kind === 'recurring' || n.ticketId === found.item.id);
64
+ return { ...found, history, notifications };
65
+ },
66
+ activity: ({ unread = false, problems = false, limit = 30 } = {}) => ({
67
+ notifications: listNotifications({ scope: 'schedule', unread, problems, limit }),
68
+ unread: unreadCount('schedule'),
69
+ }),
70
+ preview: (input, { nowMs = Date.now() } = {}) => resolveScheduleSpec(input, {
71
+ nowMs, timeZone: threadTimeZone(threadId), defaults: scheduleDefaults(), afterRef: afterRefOf,
72
+ }),
73
+ validateChange: (input) => validateScheduleChange(input, { timeZone: threadTimeZone(threadId) }),
74
+ getItem: getScheduleItem,
75
+ pause: (id) => pauseSchedule(id),
76
+ resume: (id) => resumeSchedule(id),
77
+ skipNext: (id) => skipNext(id),
78
+ markRead: (ids) => ids.map((id) => markRead(id)).filter(Boolean).length,
79
+ markAllRead: () => markAllRead('schedule'),
80
+ unread: () => unreadCount('schedule'),
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,310 @@
1
+ // src/core/ask/schedule-spec.mjs
2
+ // Scheduled runs in Ask Worca (docs/scheduled-runs.md "Ask Worca"). Pure: the clock, the
3
+ // user's timezone and every reader are injected, so the MCP child validates for the model's
4
+ // self-correction and the parent re-validates authoritatively — the split proposal.mjs and
5
+ // metrics-proposal.mjs make. Nothing here writes.
6
+ //
7
+ // The model never does date arithmetic. It passes the user's words in the CLI's forms
8
+ // ("tomorrow 02:00", "+90m", "weekdays 02:00") and gets back the instant, the sentence and
9
+ // the next dates, computed by the same shared module the schedule editor uses.
10
+ import { basename } from 'node:path';
11
+ import {
12
+ parseAt, parseEvery, normalizeRule, previewOccurrences, describeRule, formatInstant, localDate,
13
+ isValidTimeZone, zonedParts, OVERLAP_POLICIES,
14
+ } from '../../shared/schedule/recurrence.mjs';
15
+ import { ASK_LIMITS } from './limits.mjs';
16
+
17
+ /** A one-off run further out than this is almost certainly a misread date. */
18
+ export const MAX_AHEAD_MS = 366 * 86_400_000;
19
+ export const SCHEDULE_CHANGE_ACTIONS = Object.freeze(['run_now', 'move', 'edit', 'cancel', 'delete']);
20
+
21
+ /** Run chains: a predecessor that already ended, by its row's status — the words core's predecessorState
22
+ * uses (BAD_TICKET_REASON / BAD_PIPELINE_REASON), so the pinned sentence reads the same from Ask. */
23
+ const ENDED_TICKET = Object.freeze({ canceled: 'was canceled', missed: 'was missed', failed: 'could not start', skipped: 'was skipped' });
24
+ const ENDED_PIPELINE = Object.freeze({ error: 'ended with an error', stopped: 'was stopped', interrupted: 'was interrupted' });
25
+
26
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
27
+ // eslint-disable-next-line no-control-regex
28
+ const BREAKS_RE = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g;
29
+ const clip = (v, n) => String(v ?? '').replace(BREAKS_RE, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
30
+ /** The CLI parsers name their flag; the model passed a field, so the field is named instead. */
31
+ const fieldError = (msg, field) => String(msg || '').replace(/--(?:every|at)\b:?\s*/g, '').replace(/^/, `${field}: `);
32
+
33
+ /** The zone to read the user's words in: theirs when the browser sent a valid one, else this machine's. */
34
+ export function effectiveTimeZone(tz) {
35
+ if (isValidTimeZone(tz)) return tz;
36
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; }
37
+ }
38
+
39
+ /** "Sat Sep 19, 02:00" — with the year only when it is not this year. */
40
+ export function whenText(ms, tz, nowMs) {
41
+ return formatInstant(ms, tz, { withYear: zonedParts(ms, tz).y !== zonedParts(nowMs, tz).y });
42
+ }
43
+
44
+ function endOf(input, errors) {
45
+ const until = str(input.until);
46
+ const hasCount = input.count !== undefined && input.count !== null && input.count !== '';
47
+ if (until && hasCount) { errors.push('give until OR count, not both'); return null; }
48
+ if (until) {
49
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(until)) { errors.push('until must be a date, YYYY-MM-DD'); return null; }
50
+ return { type: 'until', until };
51
+ }
52
+ if (hasCount) {
53
+ const c = Number(input.count);
54
+ if (!Number.isInteger(c) || c < 1 || c > 1000) { errors.push('count must be a whole number from 1 to 1000'); return null; }
55
+ return { type: 'count', count: c };
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** A rule's preview: the sentence and the next dates, as the card and the tool show them. */
61
+ export function describeSeries(rule, nowMs, n = 3) {
62
+ return {
63
+ sentence: describeRule(rule),
64
+ next: previewOccurrences(rule, nowMs, n).map((ms) => ({ at: new Date(ms).toISOString(), when: whenText(ms, rule.tz, nowMs) })),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Resolve the schedule fields of a propose_run / preview_schedule input.
70
+ * @param {{when?:string, every?:string, until?:string, count?:number, overlap?:string, maxFailures?:number}} input
71
+ * @param {{nowMs:number, timeZone?:string, defaults?:{maxFailures?:number}, afterRef?:Function}} opts
72
+ * @returns {{ok:true, schedule:null|object}|{ok:false, errors:string[]}}
73
+ */
74
+ export function resolveScheduleSpec(input = {}, { nowMs, timeZone = null, defaults = {}, afterRef = null } = {}) {
75
+ const inp = input && typeof input === 'object' ? input : {};
76
+ const tz = effectiveTimeZone(timeZone);
77
+ const when = str(inp.when);
78
+ const every = str(inp.every);
79
+ const after = str(inp.after);
80
+ const errors = [];
81
+ const repeatOnly = ['until', 'count', 'overlap', 'maxFailures'].filter((k) => inp[k] !== undefined && inp[k] !== null && inp[k] !== '');
82
+ // `sourceFromPrevious: false` is NOT "given" — a plain timed proposal may carry it.
83
+ const afterOnly = ['afterPolicy', 'sourceFromPrevious'].filter((k) => (k === 'sourceFromPrevious' ? inp[k] === true : inp[k] !== undefined && inp[k] !== null && inp[k] !== ''));
84
+ if ([when, every, after].filter(Boolean).length > 1) return { ok: false, errors: ['give when (run once), every (repeat) OR after (another run), not both'] };
85
+ if (!after && afterOnly.length) return { ok: false, errors: [`${afterOnly.join(', ')} only appl${afterOnly.length === 1 ? 'ies' : 'y'} with after`] };
86
+ if (after) {
87
+ if (repeatOnly.length) return { ok: false, errors: [`${repeatOnly.join(', ')} only apply with every`] };
88
+ if (typeof afterRef !== 'function') return { ok: false, errors: ['after: scheduled runs are unavailable here'] };
89
+ const policy = inp.afterPolicy === undefined || inp.afterPolicy === null || inp.afterPolicy === '' ? 'done' : inp.afterPolicy;
90
+ if (!['done', 'any'].includes(policy)) return { ok: false, errors: ['afterPolicy must be one of done | any'] };
91
+ if (inp.sourceFromPrevious !== undefined && inp.sourceFromPrevious !== null && typeof inp.sourceFromPrevious !== 'boolean') return { ok: false, errors: ['sourceFromPrevious must be true or false'] };
92
+ if (after.startsWith('sch_')) return { ok: false, errors: ['after: a repeating schedule is not supported — give the id of one of its runs'] };
93
+ const ref = afterRef(after);
94
+ if (!ref) return { ok: false, errors: [`after: no run or scheduled run has id ${after}`] };
95
+ if (ref.scheduleId) return { ok: false, errors: ['after: a repeating schedule is not supported — give the id of one of its runs'] };
96
+ const name = `‘${ref.title || after.slice(0, 8)}’`;
97
+ const projectKey = str(inp.projectKey);
98
+ const workspaceId = str(inp.workspaceId);
99
+ if (workspaceId && !ref.workspaceId) return { ok: false, errors: [`after: ${name} targets a project; this run targets a workspace`] };
100
+ if (workspaceId && ref.workspaceId !== workspaceId) return { ok: false, errors: [`after: ${name} targets another workspace; this run targets ${workspaceId}`] };
101
+ if (!workspaceId && ref.workspaceId) return { ok: false, errors: [`after: ${name} targets a workspace; this run targets a project`] };
102
+ if (projectKey && ref.projectKey && ref.projectKey !== projectKey) return { ok: false, errors: [`after: ${name} targets another project`] };
103
+ // The outcome, from the row's status alone (no DB here): a ticket that already ended can never be waited
104
+ // for; a pipeline that ended badly only under `any`. A fired ticket whose pipeline ended badly is not
105
+ // visible here (its status is `fired`) — POST /api/run's resolveAfterRef is the final gate for that one.
106
+ const ended = ref.kind === 'ticket' ? ENDED_TICKET[ref.status] : (policy === 'any' ? null : ENDED_PIPELINE[ref.status]);
107
+ if (ended) return { ok: false, errors: [`after: ${name} ${ended} — nothing to wait for`] };
108
+ return { ok: true, schedule: { kind: 'after', after: { kind: ref.kind, id: ref.id, title: ref.title || null, status: ref.status || null }, policy,
109
+ sourceFromPrevious: inp.sourceFromPrevious === true, text: `After ${name} finishes` } };
110
+ }
111
+ if (!when && !every) {
112
+ if (repeatOnly.length) return { ok: false, errors: [`${repeatOnly.join(', ')} only apply with every`] };
113
+ return { ok: true, schedule: null };
114
+ }
115
+ if (when) {
116
+ if (repeatOnly.length) return { ok: false, errors: [`${repeatOnly.join(', ')} only apply with every`] };
117
+ const at = parseAt(when, { nowMs, tz });
118
+ if (!at.ok) return { ok: false, errors: [fieldError(at.error, 'when')] };
119
+ if (at.ms < nowMs - 5_000) return { ok: false, errors: [`when: ${whenText(at.ms, tz, nowMs)} (${tz}) is in the past`] };
120
+ if (at.ms > nowMs + MAX_AHEAD_MS) return { ok: false, errors: ['when: more than a year ahead — check the date'] };
121
+ return { ok: true, schedule: { kind: 'once', runAt: new Date(at.ms).toISOString(), when: whenText(at.ms, tz, nowMs), timeZone: tz } };
122
+ }
123
+ const parsed = parseEvery(every);
124
+ if (!parsed.ok) return { ok: false, errors: [fieldError(parsed.error, 'every')] };
125
+ const end = endOf(inp, errors);
126
+ let overlap = 'skip';
127
+ if (inp.overlap !== undefined && inp.overlap !== null && inp.overlap !== '') {
128
+ if (!OVERLAP_POLICIES.includes(inp.overlap)) errors.push(`overlap must be one of ${OVERLAP_POLICIES.join(' | ')}`);
129
+ else overlap = inp.overlap;
130
+ }
131
+ let maxFailures = Number.isSafeInteger(defaults.maxFailures) ? defaults.maxFailures : 3;
132
+ if (inp.maxFailures !== undefined && inp.maxFailures !== null && inp.maxFailures !== '') {
133
+ const n = Number(inp.maxFailures);
134
+ if (!Number.isSafeInteger(n) || n < 0 || n > 100) errors.push('maxFailures must be a whole number from 0 to 100 (0 = never pause)');
135
+ else maxFailures = n;
136
+ }
137
+ if (errors.length) return { ok: false, errors };
138
+ const norm = normalizeRule({ ...parsed.rule, tz, ...(end ? { end } : {}) }, { todayLocal: localDate(nowMs, tz) });
139
+ if (!norm.ok) return { ok: false, errors: [fieldError(norm.error, 'every')] };
140
+ const series = describeSeries(norm.rule, nowMs);
141
+ if (!series.next.length) return { ok: false, errors: ['every: this schedule has no future run — check until / count'] };
142
+ return { ok: true, schedule: { kind: 'repeat', rule: norm.rule, ...series, overlap, maxFailures, timeZone: tz } };
143
+ }
144
+
145
+ /** The POST /api/run fields a card's schedule turns into. */
146
+ export function scheduleRequestFields(schedule) {
147
+ if (!schedule || typeof schedule !== 'object') return {};
148
+ if (schedule.kind === 'once') return { scheduledFor: schedule.runAt };
149
+ if (schedule.kind === 'repeat') return { repeat: { rule: schedule.rule, overlap: schedule.overlap, maxFailures: schedule.maxFailures } };
150
+ if (schedule.kind === 'after') return { after: { kind: schedule.after.kind, id: schedule.after.id }, afterPolicy: schedule.policy || 'done', ...(schedule.sourceFromPrevious ? { sourceFromPrevious: true } : {}) };
151
+ return {};
152
+ }
153
+
154
+ const targetName = (item) => (item.workspaceId ? item.workspaceId : item.projectDir ? basename(item.projectDir) : '');
155
+ const itemTitle = (item) => clip(item.title || (item.summary && item.summary.prompt) || 'Scheduled run', 120);
156
+
157
+ /**
158
+ * propose_schedule_change: validate one change to an existing schedule and build its card.
159
+ * Readers: getItem(id) -> {kind:'once'|'recurring', item} | null (scheduler rows).
160
+ * @returns {Promise<{ok:true, card:object}|{ok:false, errors:string[]}>}
161
+ */
162
+ export function createScheduleChangeValidator({ getItem, afterRef = null, now = Date.now }) {
163
+ return async function validateScheduleChange(input = {}, { timeZone = null } = {}) {
164
+ const raw = input && typeof input === 'object' ? input : {};
165
+ const id = str(raw.id);
166
+ const action = str(raw.action);
167
+ if (!SCHEDULE_CHANGE_ACTIONS.includes(action)) return { ok: false, errors: [`action must be one of ${SCHEDULE_CHANGE_ACTIONS.join(', ')}`] };
168
+ if (!id) return { ok: false, errors: ['id is required (a schedule id sch_… or a scheduled run id from list_schedules)'] };
169
+ const found = await getItem(id);
170
+ if (!found) return { ok: false, errors: [`no schedule or scheduled run "${id}" — list_schedules shows the ids`] };
171
+ const { kind, item } = found;
172
+ const nowMs = now();
173
+ const title = itemTitle(item);
174
+ const occurrence = kind === 'once' && !!item.scheduleId;
175
+ const card = {
176
+ type: 'schedule', action, id: item.id, itemKind: kind, title, targetName: clip(targetName(item), 120),
177
+ status: item.status, scheduleId: kind === 'recurring' ? item.id : item.scheduleId || null,
178
+ note: clip(raw.note, ASK_LIMITS.proposalNoteMaxChars), summary: '', before: null, after: null, patch: null,
179
+ };
180
+ const tzOf = () => (kind === 'recurring' ? item.rule?.tz : effectiveTimeZone(timeZone));
181
+ const beforeOnce = () => ({ when: whenText(Date.parse(item.runAt), effectiveTimeZone(timeZone), nowMs), at: item.runAt });
182
+ // Run chains: an after-ticket's runAt is the 9999 sentinel — its "before" is the run it waits for, by
183
+ // TITLE through the injected reader (rowToTicket's `after` carries only the id), with `at: null`
184
+ // (there is no time). Every arm that names the current state of a one-off reads this, never beforeOnce.
185
+ const beforeCurrent = () => {
186
+ if (!item.after) return beforeOnce();
187
+ const prevRef = typeof afterRef === 'function' ? afterRef(item.after.id) : null;
188
+ return { when: `after ‘${(prevRef && prevRef.title) || item.after.id.slice(0, 8)}’`, at: null };
189
+ };
190
+ const beforeSeries = () => ({ sentence: item.sentence, when: item.nextRunAt ? whenText(Date.parse(item.nextRunAt), item.rule.tz, nowMs) : null, at: item.nextRunAt });
191
+ const errors = [];
192
+ switch (action) {
193
+ case 'run_now': {
194
+ if (kind === 'recurring' && item.status === 'ended') return { ok: false, errors: ['this schedule has ended'] };
195
+ if (kind === 'once' && !['scheduled', 'missed'].includes(item.status)) return { ok: false, errors: [`this run is ${item.status} and cannot be started`] };
196
+ card.before = kind === 'recurring' ? beforeSeries() : beforeCurrent();
197
+ card.summary = kind === 'recurring' ? `Run "${title}" once now — the schedule keeps its times` : `Start "${title}" now instead of ${card.before.when}`;
198
+ break;
199
+ }
200
+ case 'move': {
201
+ if (kind !== 'once') return { ok: false, errors: ['move changes a one-off run — for a repeating schedule use action "edit" with every'] };
202
+ if (occurrence) return { ok: false, errors: ['this is one occurrence of a repeating schedule — edit the schedule, or skip this occurrence with skip_next_run'] };
203
+ if (!['scheduled', 'missed'].includes(item.status)) return { ok: false, errors: [`this run is ${item.status} and can no longer be moved`] };
204
+ if (str(raw.after)) {
205
+ if (str(raw.when)) return { ok: false, errors: ['move: give when OR after, not both'] };
206
+ const aspec = resolveScheduleSpec({ after: raw.after, afterPolicy: raw.afterPolicy ?? (item.after ? item.after.policy : undefined), sourceFromPrevious: raw.sourceFromPrevious ?? item.sourceFromPrevious, // a re-chaining keeps the ticket's branch choice, like afterPolicy
207
+ projectKey: item.projectKey, workspaceId: item.workspaceId }, { nowMs, timeZone: tzOf(), afterRef });
208
+ if (!aspec.ok) return { ok: false, errors: aspec.errors.map((e) => `move: ${e.replace(/^after: /, '')}`) }; // no `move: after: …` double prefix
209
+ if (aspec.schedule.after.kind === 'ticket' && aspec.schedule.after.id === item.id) return { ok: false, errors: ['move: a run cannot wait for itself'] };
210
+ card.before = beforeCurrent(); // an after-ticket's "before" is its predecessor, never the 9999 sentinel
211
+ // `card.after` means "the state AFTER the change" in this validator — the predecessor
212
+ // itself rides under `afterRun` so the two senses never collide.
213
+ card.after = { afterRun: aspec.schedule.after, policy: aspec.schedule.policy, sourceFromPrevious: aspec.schedule.sourceFromPrevious, text: aspec.schedule.text };
214
+ // scheduleRequestFields omits a falsy sourceFromPrevious — right for POST (the column defaults
215
+ // to 0), wrong for a PATCH (omitted = keep): say it explicitly whenever the change flips it.
216
+ card.patch = { ...scheduleRequestFields(aspec.schedule), ...(aspec.schedule.sourceFromPrevious !== !!item.sourceFromPrevious ? { sourceFromPrevious: aspec.schedule.sourceFromPrevious } : {}) };
217
+ card.summary = `${title}: ${aspec.schedule.text.replace(/^After/, 'after')}`;
218
+ break;
219
+ }
220
+ const spec = resolveScheduleSpec({ when: raw.when }, { nowMs, timeZone: tzOf() });
221
+ if (!spec.ok) return spec;
222
+ if (!spec.schedule) return { ok: false, errors: ['move needs when'] };
223
+ card.before = beforeCurrent();
224
+ card.after = { when: spec.schedule.when, at: spec.schedule.runAt, timeZone: spec.schedule.timeZone };
225
+ card.patch = { scheduledFor: spec.schedule.runAt };
226
+ card.summary = `Move "${title}" from ${card.before.when} to ${card.after.when}`;
227
+ break;
228
+ }
229
+ case 'edit': {
230
+ if (kind !== 'recurring') return { ok: false, errors: ['edit changes a repeating schedule — for a one-off run use action "move"'] };
231
+ if (item.status === 'ended') return { ok: false, errors: ['this schedule has ended — propose a new run with every instead'] };
232
+ const patch = {};
233
+ const hasEnd = ['until', 'count'].some((k) => raw[k] !== undefined && raw[k] !== null && raw[k] !== '');
234
+ if (str(raw.every) || hasEnd) {
235
+ let rule;
236
+ if (str(raw.every)) {
237
+ const parsed = parseEvery(str(raw.every));
238
+ if (!parsed.ok) return { ok: false, errors: [fieldError(parsed.error, 'every')] };
239
+ rule = { ...parsed.rule, tz: item.rule.tz, anchor: item.rule.anchor, end: item.rule.end };
240
+ } else rule = { ...item.rule };
241
+ const end = endOf(raw, errors);
242
+ if (errors.length) return { ok: false, errors };
243
+ if (end) rule.end = end;
244
+ const norm = normalizeRule(rule, { todayLocal: localDate(nowMs, item.rule.tz) });
245
+ if (!norm.ok) return { ok: false, errors: [fieldError(norm.error, 'every')] };
246
+ if (!previewOccurrences(norm.rule, nowMs, 1).length) return { ok: false, errors: ['the edited schedule has no future run — check until / count'] };
247
+ patch.rule = norm.rule;
248
+ }
249
+ if (raw.overlap !== undefined && raw.overlap !== null && raw.overlap !== '') {
250
+ if (!OVERLAP_POLICIES.includes(raw.overlap)) errors.push(`overlap must be one of ${OVERLAP_POLICIES.join(' | ')}`);
251
+ else if (raw.overlap !== item.overlap) patch.overlap = raw.overlap;
252
+ }
253
+ if (raw.maxFailures !== undefined && raw.maxFailures !== null && raw.maxFailures !== '') {
254
+ const n = Number(raw.maxFailures);
255
+ if (!Number.isSafeInteger(n) || n < 0 || n > 100) errors.push('maxFailures must be a whole number from 0 to 100 (0 = never pause)');
256
+ else if (n !== item.maxFailures) patch.maxFailures = n;
257
+ }
258
+ const newTitle = clip(raw.title, 120);
259
+ if (newTitle && newTitle !== item.title) patch.title = newTitle;
260
+ if (errors.length) return { ok: false, errors };
261
+ if (!Object.keys(patch).length) return { ok: false, errors: ['nothing to change — give every, until, count, overlap, maxFailures or title with a new value'] };
262
+ card.before = { ...beforeSeries(), overlap: item.overlap, maxFailures: item.maxFailures, title: item.title };
263
+ const rule = patch.rule || item.rule;
264
+ card.after = { ...describeSeries(rule, nowMs), overlap: patch.overlap ?? item.overlap, maxFailures: patch.maxFailures ?? item.maxFailures, title: patch.title ?? item.title };
265
+ card.patch = patch;
266
+ const parts = [];
267
+ if (patch.rule) parts.push(`${card.after.sentence}`);
268
+ if (patch.overlap) parts.push(`overlap ${patch.overlap}`);
269
+ if (patch.maxFailures !== undefined) parts.push(patch.maxFailures === 0 ? 'never pause on failures' : `pause after ${patch.maxFailures} failures in a row`);
270
+ if (patch.title) parts.push(`rename to "${patch.title}"`);
271
+ card.summary = `Change "${title}": ${parts.join('; ')}`;
272
+ break;
273
+ }
274
+ case 'cancel': {
275
+ if (kind !== 'once') return { ok: false, errors: ['cancel is for a one-off run — a repeating schedule is paused (pause_schedule) or deleted (action "delete")'] };
276
+ if (occurrence) return { ok: false, errors: ['this is one occurrence of a repeating schedule — skip it with skip_next_run instead'] };
277
+ if (!['scheduled', 'missed'].includes(item.status)) return { ok: false, errors: [`this run is ${item.status} and can no longer be canceled`] };
278
+ card.before = beforeCurrent();
279
+ card.summary = item.after ? `Cancel "${title}", waiting for ${card.before.when.replace(/^after /, '')}` : `Cancel "${title}", scheduled for ${card.before.when}`;
280
+ break;
281
+ }
282
+ case 'delete': {
283
+ if (kind !== 'recurring') return { ok: false, errors: ['delete removes a repeating schedule — a one-off run is canceled (action "cancel")'] };
284
+ card.before = beforeSeries();
285
+ card.summary = `Delete the schedule "${title}" (${item.sentence})`;
286
+ break;
287
+ }
288
+ default: break;
289
+ }
290
+ return { ok: true, card };
291
+ };
292
+ }
293
+
294
+ const eventText = (s, n) => clip(s, n).replace(/"/g, "'").replace(/\[(\/?)worca context\]/gi, '($1worca context)');
295
+
296
+ /** `[worca event] …` — the synthetic turn's prompt after the user acted on a schedule card. */
297
+ export function scheduleEventPrompt({ cardId, state, card = {}, result = null }) {
298
+ const summary = eventText(card.summary, 200);
299
+ if (state === 'declined') return `[worca event] schedule card ${cardId} declined; "${summary}"`;
300
+ if (state === 'failed') return `[worca event] schedule card ${cardId} failed: ${eventText(result?.error || 'unknown error', 200)}; "${summary}"`;
301
+ return `[worca event] schedule card ${cardId} applied; "${summary}"${result?.detail ? `; ${eventText(result.detail, 300)}` : ''}`;
302
+ }
303
+
304
+ /** The user-row notice above the event turn. */
305
+ export function scheduleNoticeText({ state, card = {}, result = null }) {
306
+ const s = clip(card.summary, 160);
307
+ if (state === 'declined') return `Declined — ${s}`;
308
+ if (state === 'failed') return `Could not apply — ${s}: ${clip(result?.error || 'unknown error', 200)}`;
309
+ return `Applied — ${s}${result?.detail ? ` · ${clip(result.detail, 200)}` : ''}`;
310
+ }