@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
package/ui/server.mjs CHANGED
@@ -15,7 +15,7 @@ import fsp from 'node:fs/promises';
15
15
  import process from 'node:process';
16
16
  import { createRequire } from 'node:module';
17
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
- import { randomUUID, timingSafeEqual } from 'node:crypto';
18
+ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
19
19
 
20
20
  import { preflightNode } from '../src/core/preflight-node.mjs';
21
21
  import { createOrchestratorFor } from '../src/core/engine-select.mjs';
@@ -24,9 +24,11 @@ import {
24
24
  enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState, readPrState,
25
25
  readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
26
26
  listArtifacts, listRunArtifacts, lookupPipelineRow, findPipelineRowById, readPipelineStateById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
27
- readPromptFile,
27
+ readPromptFile, runDirForRow,
28
28
  } from '../src/core/artifacts.mjs';
29
29
  import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
30
+ import { readAskFileEntry, ASK_FILE_MIMES } from '../src/core/ask-files.mjs';
31
+ import { ASK_FILES_DIR } from '../src/core/ask-forms.mjs';
30
32
  import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
31
33
  import {
32
34
  addDiffComment, addDiffCommentReply, listDiffComments, getDiffComment, setDiffCommentResolved,
@@ -40,15 +42,21 @@ import {
40
42
  rawProjectsRoot, defaultProjectsRoot, runRootMode,
41
43
  pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
42
44
  setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
45
+ humanRateUsdPerHour, setHumanRateUsdPerHour, assertHumanRateInput,
43
46
  askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
44
47
  chatPrefs, setChatPrefs,
45
48
  debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
46
49
  titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
47
50
  hideBuiltinModels, setHideBuiltinModels, assertHideBuiltinModelsInput,
48
51
  theme as storedTheme, setTheme, assertThemeInput,
52
+ uiLevel as storedUiLevel, setUiLevel, assertUiLevelInput, defaultUiLevel,
49
53
  autoWorkflowModel as storedAutoWorkflowModel, setAutoWorkflowModel, assertAutoWorkflowModelInput,
54
+ memoryDefragModel, setMemoryDefragModel, assertMemoryDefragModelInput,
55
+ scheduleDefaults, setScheduleDefaults,
50
56
  } from '../src/core/settings.mjs';
57
+ import { resolveDefragModel, defragDefaultModel, defragWorkflowView, checkStartPair } from '../src/core/memory-defrag-model.mjs';
51
58
  import { describeTitleModel } from '../src/core/title.mjs';
59
+ import { effectiveHumanRateUsd } from '../src/core/human-rate.mjs';
52
60
  import {
53
61
  ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
54
62
  listThreads as askListThreads, updateThread as askUpdateThread,
@@ -73,6 +81,7 @@ import {
73
81
  buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
74
82
  selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
75
83
  } from '../src/core/ask/prompt.mjs';
84
+ import { askScriptPromptInput } from '../src/core/ask/script-deps.mjs';
76
85
  import {
77
86
  classifyExtension as askClassifyExtension, sniffMime as askSniffMime,
78
87
  } from '../src/core/ask/attachment-kind.mjs';
@@ -86,13 +95,25 @@ import { createAskTurn } from '../src/core/ask/turn.mjs';
86
95
  import { attachRunFollower } from '../src/core/ask/follow.mjs';
87
96
  import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
88
97
  import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
89
- import { getStats } from '../src/core/stats.mjs';
98
+ import { getStats, budgetWindowSavings } from '../src/core/stats.mjs';
90
99
  import {
91
100
  enableTeamMetrics, setRecordMyRuns, flushSlug, flushAll, flushProject, scheduleFlush, discoverProject,
92
101
  discoverAll, scanMembers, routeWorkspaceMembers, projectMetricsStatus, startTeamMetricsBackground,
93
102
  metricsEvents, slugDirName, autoMetricsHome,
94
103
  } from '../src/core/metrics/sync.mjs';
95
104
  import { readScope, listScopes, parseScopeParam, aggregate, resolveRange, GROUP_BYS, PROJECT_KEY_RE as TM_PROJECT_KEY_RE } from '../src/core/metrics/read.mjs';
105
+ // Team policy (team-policy design §9, §11): the worca-policy branch, its gates and its pages.
106
+ import {
107
+ policyEvents, discoverPolicy, discoverAllPolicies, resolveProjectPolicy, resolveWorkspacePolicy, enableTeamPolicy, publishPolicy,
108
+ projectPolicyStatus, listPolicyScopes, routeWorkspaceMembersPolicy, autoPolicyHome, startTeamPolicyBackground,
109
+ } from '../src/core/policy/sync.mjs';
110
+ import { deviationsFor, fieldsForRun, capSummary } from '../src/core/policy/effective.mjs';
111
+ import { installedPluginsMap, pluginRequirements, blockedPluginFindings, seedPolicyMarketplaces, WORCA_VERSION as POLICY_WORCA_VERSION } from '../src/core/policy/local.mjs';
112
+ import { normalizePolicyDoc } from '../src/core/policy/registry.mjs';
113
+ import { checkTeamTotalGate, checkTeamPipelineGate, teamCapsForTarget } from '../src/core/policy/gate.mjs';
114
+ import { readPolicyState } from '../src/core/policy/state.mjs';
115
+ import { policyForScope, policyPayload } from '../src/core/policy/scope.mjs';
116
+ import { policyCatalogModels } from '../src/core/policy/cache.mjs';
96
117
  import { pickFolderNative } from '../src/core/folder-dialog.mjs';
97
118
  import { listFolders } from '../src/core/fs-browse.mjs';
98
119
  import {
@@ -103,7 +124,14 @@ import {
103
124
  readPrRemotePrefs, setPrRemotePrefs,
104
125
  } from '../src/core/config.mjs';
105
126
  import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
106
- import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
127
+ import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue, UPSTREAM_PROVIDERS } from '../src/core/model-env.mjs';
128
+ import { providerReadiness } from '../src/core/bridge/registry.mjs';
129
+ import { startBridge } from '../src/core/bridge/server.mjs';
130
+ import {
131
+ providersState, patchProvider, acknowledgeTerms, beginCopilotLogin, pollCopilotLogin, copilotLogout,
132
+ copilotModelsForImport, importCopilotModels, testProviderConnection,
133
+ endpointModelsForImport, importEndpointModels,
134
+ } from '../src/core/bridge/provider-ops.mjs';
107
135
  import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
108
136
  import { testModel } from '../src/core/model-test.mjs';
109
137
  import {
@@ -111,7 +139,7 @@ import {
111
139
  } from '../src/core/ui-instance.mjs';
112
140
  import { validateGuardrails } from '../src/core/guardrails.mjs';
113
141
  import {
114
- listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
142
+ listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet, listPolicyGuardrailSets, isPolicyGuardrailSetId,
115
143
  writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
116
144
  } from '../src/core/guardrail-store.mjs';
117
145
  import {
@@ -124,17 +152,25 @@ import {
124
152
  } from '../src/core/ask/workflow-deps.mjs';
125
153
  import { applyMetricsChange } from '../src/core/ask/metrics-deps.mjs';
126
154
  import { metricsEventPrompt, metricsNoticeText } from '../src/core/ask/metrics-proposal.mjs';
155
+ import { applyPolicyChange } from '../src/core/ask/policy-deps.mjs';
156
+ import { policyEventPrompt, policyNoticeText } from '../src/core/ask/policy-proposal.mjs';
157
+ import { scheduleEventPrompt, scheduleNoticeText } from '../src/core/ask/schedule-spec.mjs';
158
+ import { applyModelChange } from '../src/core/ask/model-deps.mjs';
159
+ import { modelEventPrompt, modelNoticeText } from '../src/core/ask/model-proposal.mjs';
127
160
  import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
128
- import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
161
+ import { sweepV1Runs, V1_RUN_RETIRED, getDb } from '../src/core/db.mjs';
129
162
  import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
130
163
  import {
131
164
  saveGraphWorkflow, importGraphWorkflow, exportGraphJson, workflowFileSlug, nodeDefaultsError,
132
165
  } from '../src/core/workflow-share.mjs';
133
166
  import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
167
+ import { loadScriptRegistry } from '../src/core/script-registry.mjs';
168
+ import { probePython, pythonRuntimeState } from '../src/core/graph/python-probe.mjs';
134
169
  import {
135
170
  listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
136
171
  } from '../src/core/worktree.mjs';
137
- import { hasGh, pushBranch, createPr, createIssue, prMergeable, listRemotes, sameRepo } from '../src/core/git-info.mjs';
172
+ import { hasGh, pushBranch, createPr, createIssue, prMergeable, listRemotes, listRemoteBranches, sameRepo } from '../src/core/git-info.mjs';
173
+ import { isSyntacticRef } from '../src/core/ask/proposal.mjs';
138
174
  import { archivePipeline, discardRetainedWorktrees } from '../src/core/pipeline-delete.mjs';
139
175
  import {
140
176
  listWorkspaces, readWorkspace, createWorkspace,
@@ -155,7 +191,14 @@ import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
155
191
  import { createAgentGen } from '../src/core/agent-gen.mjs';
156
192
  import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
157
193
  import {
158
- listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
194
+ listScripts, readScript, createScript, updateScript, deleteScript, duplicateScript, writeCases,
195
+ SCRIPT_KEY_RE,
196
+ } from '../src/core/script-store.mjs';
197
+
198
+ import { createBench, sweepBenchDirs, benchRoot } from '../src/core/script-bench.mjs';
199
+ import { PORT_ID_RE } from '../src/shared/graph/constants.mjs';
200
+ import {
201
+ listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin, pythonNoticeFor,
159
202
  setPluginEnabled, doctorPlugin, linkPlugin,
160
203
  listOrphanPluginData, purgePluginData,
161
204
  } from '../src/core/plugin-store.mjs';
@@ -180,7 +223,22 @@ import { TokenBucket } from '../src/core/chat/rate-limiter.mjs';
180
223
  import { renderTest } from '../src/core/chat/renderers.mjs';
181
224
  import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs';
182
225
  import { normalizeManifest, validatePluginDir, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
183
- import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
226
+ import { listTaskSources, retryWriteback, resolveTaskInput } from '../src/core/sources.mjs';
227
+ import {
228
+ createTicket, getTicket, listTickets, updateTicket, cancelTicket, requestRunNow, setTicketPipeline,
229
+ createSchedule, getSchedule, listSchedules, updateSchedule, pauseSchedule, resumeSchedule, skipNext,
230
+ runScheduleNow, deleteSchedule, cancelForTarget, dependentsOfWorkflow, runDueTickets, recordOutcome,
231
+ recoverScheduler, purgeScheduler, scheduleCounts, scheduleStageDir, scheduleSignature,
232
+ resolveAfterRef, predecessorState, previousBranchesOf, dependentsOfRun, AFTER_POLICIES, afterRefOf,
233
+ chainBaseBranchesOf,
234
+ } from '../src/core/scheduler.mjs';
235
+ import {
236
+ onNotification, listNotifications, unreadCount, latestNotificationId, markRead, markAllRead, purgeNotifications,
237
+ } from '../src/core/notifications.mjs';
238
+ import {
239
+ normalizeRule, nextOccurrence, previewOccurrences, describeRule, parseScheduledFor, localDate,
240
+ isValidTimeZone, formatInstant, OVERLAP_POLICIES, MISSED_POLICIES,
241
+ } from '../src/shared/schedule/recurrence.mjs';
184
242
  import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
185
243
  import { resolveAutoModel, AUTO_MODEL_ENV } from '../src/core/auto/model.mjs';
186
244
  import {
@@ -327,6 +385,12 @@ const SCAN_EVENT_NAMES = ['scan-progress', 'scan-done', 'scan-error'];
327
385
  // a NEW family in the SAME runs Map. createAgentGen emits many agentgen-progress
328
386
  // then exactly one terminal agentgen-done OR agentgen-error.
329
387
  const AGENTGEN_EVENT_NAMES = ['agentgen-progress', 'agentgen-done', 'agentgen-error'];
388
+ // The scriptbench-* family (Scripts workbench §4.1): one more family in the same
389
+ // runs Map. createBench emits many scriptbench-line then exactly one terminal
390
+ // scriptbench-done OR scriptbench-error. A bench is NOT a run: no DB row, no
391
+ // History entry, no ledger and no cost (W15) — only this buffer.
392
+ const SCRIPTBENCH_EVENT_NAMES = ['scriptbench-line', 'scriptbench-done', 'scriptbench-error'];
393
+ const MAX_BENCH_ENTRIES = 8; // finished bench entries kept in the runs Map (startScriptBench evicts the rest)
330
394
  const MAX_BUFFER = 5000;
331
395
 
332
396
  // ---------------------------------------------------------------------------
@@ -373,20 +437,23 @@ wss.on('connection', (ws, req) => {
373
437
  let requestedRunId = null;
374
438
  let requestedScanId = null;
375
439
  let requestedGenId = null;
440
+ let requestedBenchId = null;
376
441
  let requestedThreadId = null;
377
442
  try {
378
443
  const u = new URL(req.url, 'http://localhost');
379
444
  requestedRunId = u.searchParams.get('runId');
380
445
  requestedScanId = u.searchParams.get('scanId');
381
446
  requestedGenId = u.searchParams.get('genId');
447
+ requestedBenchId = u.searchParams.get('benchId');
382
448
  requestedThreadId = u.searchParams.get('threadId');
383
449
  } catch {
384
450
  requestedRunId = null;
385
451
  requestedScanId = null;
386
452
  requestedGenId = null;
453
+ requestedBenchId = null;
387
454
  requestedThreadId = null;
388
455
  }
389
- const id = requestedRunId || requestedScanId || requestedGenId;
456
+ const id = requestedRunId || requestedScanId || requestedGenId || requestedBenchId;
390
457
 
391
458
  send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
392
459
 
@@ -410,7 +477,7 @@ wss.on('connection', (ws, req) => {
410
477
  } catch {
411
478
  return;
412
479
  }
413
- const subId = msg && msg.type === 'subscribe' ? (msg.runId || msg.scanId || msg.genId) : null;
480
+ const subId = msg && msg.type === 'subscribe' ? (msg.runId || msg.scanId || msg.genId || msg.benchId) : null;
414
481
  if (subId && runs.has(subId)) {
415
482
  replayEntry(ws, runs.get(subId));
416
483
  }
@@ -490,6 +557,7 @@ function emitChanged(type, action) {
490
557
  }
491
558
 
492
559
  metricsEvents.on('changed', (e) => emitChanged('team-metrics-changed', e && e.action ? e.action : null));
560
+ policyEvents.on('changed', (e) => emitChanged('team-policy-changed', e && e.action ? e.action : null));
493
561
 
494
562
  // Every comment mutation in THIS process (the REST routes below) pokes the open
495
563
  // Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
@@ -582,7 +650,11 @@ function liveDefragRun(scopeKeyStr) {
582
650
  }
583
651
 
584
652
  function summarizeRuns() {
585
- return [...runs.values()].map((r) => ({
653
+ // W15: a bench is NOT a run. Its entry shares the runs Map for the WS replay
654
+ // plumbing only — leaving it here puts "bench: <key>" in every hello and the
655
+ // client's liveRuns() (which does NOT filter by kind) raises the rail's
656
+ // Running badge over an empty Running list.
657
+ return [...runs.values()].filter((r) => r.kind !== 'scriptbench').map((r) => ({
586
658
  runId: r.id,
587
659
  stepper: r.orch?.state?.stepper ?? null,
588
660
  pipelineId: r.pipelineId || null,
@@ -712,7 +784,36 @@ function wireRun(entry) {
712
784
  // Capture the on-disk pipeline short id the orchestrator stamps onto
713
785
  // state.id after createPipeline. Guard so null in pre-createPipeline
714
786
  // snapshots cannot overwrite a previously-captured value.
715
- if (typeof payload.id === 'string' && payload.id) entry.pipelineId = payload.id;
787
+ if (typeof payload.id === 'string' && payload.id) {
788
+ const first = !entry.pipelineId;
789
+ entry.pipelineId = payload.id;
790
+ // Scheduled runs: the ticket learns the pipeline it became (provenance columns).
791
+ if (first && entry.ticketId) {
792
+ try { setTicketPipeline(entry.ticketId, payload.id); } catch (err) { console.error(`[worca-ui] ticket link failed: ${err && err.message ? err.message : err}`); }
793
+ }
794
+ }
795
+ }
796
+ if (entry.ticketId && (name === 'done' || name === 'error') && !entry._outcomeRecorded) {
797
+ // Scheduled runs: report how the fired run ended (feed item, failure streak).
798
+ // 'error' is the launch-error channel — a failure before the pipeline row exists.
799
+ entry._outcomeRecorded = true;
800
+ try {
801
+ recordOutcome(entry.ticketId, {
802
+ status: name === 'error' ? 'error' : ((payload && payload.status) || 'done'),
803
+ pipelineId: entry.pipelineId || null,
804
+ reason: (payload && payload.reason) || null,
805
+ detail: (payload && (payload.detail || payload.message)) || null,
806
+ });
807
+ emitChanged('schedules-changed', 'outcome');
808
+ } catch (err) { console.error(`[worca-ui] schedule outcome failed: ${err && err.message ? err.message : err}`); }
809
+ }
810
+ if ((name === 'done' || name === 'error') && !entry._chainNudged) {
811
+ // Run chains: a dependent waits on this run — open its gate now rather than at the next 30 s tick.
812
+ entry._chainNudged = true;
813
+ try {
814
+ const waiting = [...(entry.ticketId ? dependentsOfRun({ ticketId: entry.ticketId }) : []), ...(entry.pipelineId ? dependentsOfRun({ pipelineId: entry.pipelineId }) : [])];
815
+ if (waiting.length) setTimeout(() => { void schedulerTick(); }, 0);
816
+ } catch (err) { console.error(`[worca-ui] chain nudge failed: ${err && err.message ? err.message : err}`); }
716
817
  }
717
818
  if (name === 'title' && payload && typeof payload.title === 'string') {
718
819
  // Keep the in-memory run fresh so a late-joining client's hello
@@ -788,6 +889,47 @@ function wireAgentGen(entry) {
788
889
  }
789
890
  }
790
891
 
892
+ // ---------------------------------------------------------------------------
893
+ // Wire a Bench's events onto the WebSocket, tagged with benchId. The
894
+ // scriptbench-* family: same runs Map, same ring-buffer/replay plumbing as
895
+ // wireAgentGen. createBench emits many scriptbench-line then exactly one
896
+ // terminal scriptbench-done OR scriptbench-error (run() never throws). The
897
+ // terminal result is kept on the entry: the output route reads its PATHS from
898
+ // there, never from the URL, and a reconnecting tab replays the buffer.
899
+ // ---------------------------------------------------------------------------
900
+ function wireScriptBench(entry) {
901
+ const { benchId, orch } = entry;
902
+
903
+ // Every frame carries a per-bench sequence number. The page learns its benchId
904
+ // from the POST answer and THEN subscribes, so for one round trip it receives a
905
+ // frame both live (the broadcast) and replayed (the buffer): `seq` is what lets
906
+ // it keep exactly one. It lives on the entry, not on events.length — the ring
907
+ // buffer is spliced at MAX_BUFFER.
908
+ const record = (event) => {
909
+ entry.seq = (entry.seq || 0) + 1;
910
+ const tagged = { ...event, benchId, seq: entry.seq };
911
+ entry.events.push(tagged);
912
+ if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
913
+ broadcast(tagged);
914
+ return tagged;
915
+ };
916
+
917
+ for (const name of SCRIPTBENCH_EVENT_NAMES) {
918
+ subscribe(orch, name, (payload) => {
919
+ const event = { type: name, ...(payload && typeof payload === 'object' ? payload : { value: payload }) };
920
+ if (name === 'scriptbench-line') { if (entry.status === 'running' || entry.status === 'created') entry.status = 'running'; }
921
+ else if (name === 'scriptbench-done') {
922
+ // A stop still ends in `done` with a stopped RESULT; the stop route has
923
+ // already marked the entry, so it is not overwritten here.
924
+ if (entry.status !== 'stopped') entry.status = 'done';
925
+ entry.result = (payload && payload.result) || null;
926
+ } else if (name === 'scriptbench-error') entry.status = 'error';
927
+ record(event);
928
+ });
929
+ }
930
+ }
931
+
932
+
791
933
  // ---------------------------------------------------------------------------
792
934
  // Teams ingress (chat-connectivity-design.md §4.7) — the ONE deliberate,
793
935
  // auditable exemption from the loopback guard below: Bot Framework can only
@@ -876,6 +1018,9 @@ app.use((req, res, next) => {
876
1018
  // over-budget upload still reaches the route's OWN clear 400/413, not a raw
877
1019
  // parser error.
878
1020
  app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
1021
+ // A script's saved cases are inline text: 32 cases x 256 KiB PER PORT is legal (workbench
1022
+ // spec §3.2) and does not fit the global 8 MB, so the one route that saves them all gets room.
1023
+ app.put('/api/scripts/:key/cases', express.json({ limit: '64mb' }));
879
1024
  app.use(express.json({ limit: '8mb' }));
880
1025
 
881
1026
  if (HLJS_ASSETS) {
@@ -951,13 +1096,30 @@ app.use('/src/shared', (_req, res) => {
951
1096
  // design §5.2). Read per request (100 KB, local) so an index.html edit is live
952
1097
  // without a restart, exactly like static serving was. no-store: a theme change
953
1098
  // must never be served from the browser cache.
1099
+ // Interface mode (docs/ui-levels.md): the stored choice, else the install's default. "Fresh" =
1100
+ // the welcome dialog was never dismissed and the store holds no project and no run; anything
1101
+ // else is an install that predates the mode (or has outgrown it) and keeps the full UI.
1102
+ function effectiveUiLevel() {
1103
+ const stored = storedUiLevel();
1104
+ if (stored) return stored;
1105
+ let fresh = false;
1106
+ try { fresh = !onboardingPrefs().welcomeSeen && countProjects() === 0 && countPipelines() === 0; }
1107
+ catch { fresh = false; }
1108
+ return defaultUiLevel({ fresh });
1109
+ }
1110
+ // "Fresh" is derived from state the user changes (welcomeSeen, the first project), so a new user's
1111
+ // mode would jump from simple to expert the moment they act. Each of those writes pins the derived
1112
+ // value first; a stored choice is never overwritten.
1113
+ async function pinUiLevel() {
1114
+ if (!storedUiLevel()) await setUiLevel(effectiveUiLevel());
1115
+ }
954
1116
  const INDEX_FILE = path.join(PUBLIC_DIR, 'index.html');
955
1117
  if (!fs.readFileSync(INDEX_FILE, 'utf8').includes(INDEX_THEME_ANCHOR)) {
956
1118
  throw new Error(`ui/public/index.html lost its theme anchor ${INDEX_THEME_ANCHOR}`);
957
1119
  }
958
1120
  function sendIndex(res) {
959
1121
  let html;
960
- try { html = renderIndexHtml(fs.readFileSync(INDEX_FILE, 'utf8'), storedTheme()); }
1122
+ try { html = renderIndexHtml(fs.readFileSync(INDEX_FILE, 'utf8'), storedTheme(), effectiveUiLevel()); }
961
1123
  catch (err) { return res.status(500).json({ error: err && err.message ? err.message : 'shell unavailable' }); }
962
1124
  res.set('Content-Type', 'text/html; charset=utf-8');
963
1125
  res.set('Cache-Control', 'no-store');
@@ -1264,6 +1426,11 @@ function askTrackRun(threadId, input, pin) {
1264
1426
  const startRunHandler = async (req, res) => {
1265
1427
  try {
1266
1428
  const body = req.body || {};
1429
+ // Scheduled runs: `internal` is set ONLY by fireTicket() (never from HTTP) — the
1430
+ // due ticket being started through this same gate. It carries the ticket, whose id
1431
+ // IS the runId, plus the CLI-only options a stored request may hold.
1432
+ const internal = req._internal && typeof req._internal === 'object' ? req._internal : null;
1433
+ const stored = internal && body.internal && typeof body.internal === 'object' ? body.internal : {};
1267
1434
 
1268
1435
  // Mutual exclusion: exactly one of workspaceId / projectDir (§2.6).
1269
1436
  const hasWorkspace = typeof body.workspaceId === 'string' && body.workspaceId.trim();
@@ -1291,12 +1458,24 @@ const startRunHandler = async (req, res) => {
1291
1458
  if (!askGetThread(body.askThreadId)) return badRequest(res, 'unknown askThreadId');
1292
1459
  const found = askFindCard(body.askThreadId, body.askCardId);
1293
1460
  if (!found) return badRequest(res, 'unknown askCardId');
1294
- if (found.block.state !== 'proposed') {
1461
+ const ownScheduledCard = internal && found.block.state === 'scheduled' && found.block.runId === internal.ticket.id;
1462
+ if (found.block.state !== 'proposed' && !ownScheduledCard) {
1295
1463
  return res.status(409).json({ error: `card is ${found.block.state}` });
1296
1464
  }
1297
1465
  askLink = { threadId: body.askThreadId, cardId: body.askCardId };
1298
1466
  }
1299
1467
 
1468
+ // Scheduled runs: `scheduledFor` (one-shot) and/or `repeat` (recurring) turn this
1469
+ // request into a TICKET instead of a run. Parsed here so a bad time is a clean 400
1470
+ // before anything else; everything below still validates the request, so a schedule
1471
+ // fails fast now and is validated AGAIN when it starts.
1472
+ let sched = null;
1473
+ if (!internal && (body.scheduledFor != null || body.repeat != null || body.after != null || (body.sourceFromPrevious != null && body.sourceFromPrevious !== false))) {
1474
+ const parsed = parseScheduleRequest(body);
1475
+ if (!parsed.ok) return badRequest(res, parsed.error);
1476
+ sched = parsed;
1477
+ }
1478
+
1300
1479
  // ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
1301
1480
  // NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
1302
1481
  // only and pass through — the orchestrator resolves it exactly once. Absent
@@ -1373,6 +1552,16 @@ const startRunHandler = async (req, res) => {
1373
1552
  const memoryScope = typeof body.memoryScope === 'string' && body.memoryScope.trim() ? body.memoryScope.trim() : null;
1374
1553
  const scopeReason = validateMemoryScope({ workflowId, memoryScope, isWorkspace: !!hasWorkspace });
1375
1554
  if (scopeReason) return badRequest(res, scopeReason);
1555
+ // Settings › Memory: a defragment run may name its model/effort PAIR at start — tier 1, above
1556
+ // the setting (memory-defrag-model.mjs). Defragment runs only: every other workflow picks its
1557
+ // models per node, and a body model there stays ignored exactly as before. The shape here; the
1558
+ // catalog check needs the target project (below).
1559
+ let startPair = null;
1560
+ if (memoryScope) {
1561
+ const shape = checkStartPair(body, null);
1562
+ if (shape.error) return badRequest(res, shape.error);
1563
+ startPair = shape.pair;
1564
+ }
1376
1565
  // Human in the loop (spec D15): the body wins, else the project's stored
1377
1566
  // switch, else on. Resolved per target below (it needs the project dir).
1378
1567
  const bodyHumanInLoop = typeof body.humanInLoop === 'boolean' ? body.humanInLoop : null;
@@ -1395,16 +1584,20 @@ const startRunHandler = async (req, res) => {
1395
1584
 
1396
1585
  // Budget gate: no new pipelines while the total window is spent (F6).
1397
1586
  const budget = budgetStatus();
1398
- if (budget.blocked) {
1587
+ if (budget.blocked && !sched) {
1399
1588
  return res.status(403).json({ error: 'total cost limit reached', budget });
1400
1589
  }
1401
1590
 
1402
- const runId = randomUUID();
1591
+ const runId = internal ? internal.ticket.id : randomUUID();
1403
1592
  const title = (typeof body.title === 'string' && body.title.trim()) || fallbackRunTitle(effectivePrompt, source);
1404
1593
 
1405
1594
  // Materialize any uploaded extra files to a temp dir; the orchestrator's
1406
1595
  // createPipeline copies them into <pipeline>/extras/.
1407
- const extras = await writeExtras(runId, body.extras);
1596
+ // A ticket's extras were staged durably when it was scheduled (the OS temp dir
1597
+ // does not survive a reboot); a schedule stages them below, once it is validated.
1598
+ const extras = internal
1599
+ ? (Array.isArray(stored.extrasPaths) ? stored.extrasPaths.filter((x) => typeof x === 'string' && fs.existsSync(x)) : [])
1600
+ : (sched ? [] : await writeExtras(runId, body.extras));
1408
1601
 
1409
1602
  const branch = {
1410
1603
  source: typeof body.sourceBranch === 'string' && body.sourceBranch.trim()
@@ -1425,6 +1618,12 @@ const startRunHandler = async (req, res) => {
1425
1618
  const ws = await readWorkspace(workspaceId);
1426
1619
  if (!ws) return res.status(404).json({ error: 'workspace not found' });
1427
1620
 
1621
+ // Team total cap (design §7): soft — `pastTeamCap` acknowledges it once per window per home.
1622
+ {
1623
+ const gate = await checkTeamTotalGate({ workspaceId: ws.id }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null });
1624
+ if (gate.blocked) return res.status(gate.code === 'reason_required' ? 400 : 403).json({ error: gate.error, code: gate.code, policy: gate.policy, needsPolicyAck: gate.code === 'team_total' });
1625
+ }
1626
+
1428
1627
  // Resolve member detail. Each member must be an existing git repo (D3:
1429
1628
  // per-project worktrees + checkpoints). A vanished member is a hard 400 —
1430
1629
  // skip-missing is NOT allowed; a workspace run is defined over its full set.
@@ -1466,6 +1665,13 @@ const startRunHandler = async (req, res) => {
1466
1665
  const wsFileProblem = await promptFileProblem(effectiveSource, projects[0].projectDir);
1467
1666
  if (wsFileProblem) return badRequest(res, wsFileProblem);
1468
1667
 
1668
+ if (sched && sched.after) {
1669
+ const r = resolveAfterRef(sched.after, { workspaceId: ws.id, policy: sched.afterPolicy, isLive: liveProbe });
1670
+ if (!r.ok) return badRequest(res, r.error);
1671
+ sched.afterRef = r.after;
1672
+ }
1673
+ if (sched) return res.status(202).json(await scheduleRequest({ body, sched, title, askLink, budget, workspaceId: ws.id, projectDir: projects[0].projectDir }));
1674
+
1469
1675
  orch = await createOrchestratorFor({
1470
1676
  workspace: {
1471
1677
  id: ws.id,
@@ -1483,7 +1689,9 @@ const startRunHandler = async (req, res) => {
1483
1689
  template: workflowRow,
1484
1690
  guardrailsId,
1485
1691
  branch,
1486
- claude: { permissionMode: 'acceptEdits', mock },
1692
+ claude: { permissionMode: stored.permissionMode || 'acceptEdits', ...(stored.model ? { model: stored.model } : {}), mock },
1693
+ // A CLI-made ticket may carry `--yes`: the explicit non-interactive choice survives the wait.
1694
+ ...(stored.auto ? { auto: true } : {}),
1487
1695
  });
1488
1696
 
1489
1697
  entry = {
@@ -1522,14 +1730,35 @@ const startRunHandler = async (req, res) => {
1522
1730
 
1523
1731
  const fileProblem = await promptFileProblem(effectiveSource, projectDir);
1524
1732
  if (fileProblem) return badRequest(res, fileProblem);
1733
+ // The pair against THIS project's catalog (a schedule is checked here too, before it is
1734
+ // stored). A ticket firing takes its already-checked pair verbatim, like a CLI --model.
1735
+ if (startPair && !internal) {
1736
+ const checked = checkStartPair(body, await listModels(projectDir));
1737
+ if (checked.error) return badRequest(res, checked.error);
1738
+ startPair = checked.pair;
1739
+ }
1525
1740
  // One live defragment run per scope (§7.3, amendment B20: this server process only).
1526
1741
  if (memoryScope) {
1527
1742
  const live = liveDefragRun(memoryScopeKey(memoryScope, projectDir));
1528
1743
  if (live) return res.status(409).json({ error: 'a defragment run for this memory scope is already live', runId: live.id });
1529
1744
  }
1530
1745
 
1746
+ // Team total cap (design §7): soft — `pastTeamCap` acknowledges it once per window per home.
1747
+ {
1748
+ const gate = await checkTeamTotalGate({ projectDir }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null });
1749
+ if (gate.blocked) return res.status(gate.code === 'reason_required' ? 400 : 403).json({ error: gate.error, code: gate.code, policy: gate.policy, needsPolicyAck: gate.code === 'team_total' });
1750
+ }
1531
1751
  const humanInLoop = bodyHumanInLoop ?? ((await readRunConfig(projectDir)).humanInLoop !== false);
1532
1752
 
1753
+ if (sched && sched.after) {
1754
+ const r = resolveAfterRef(sched.after, { projectDir, policy: sched.afterPolicy, isLive: liveProbe });
1755
+ if (!r.ok) return badRequest(res, r.error);
1756
+ sched.afterRef = r.after;
1757
+ }
1758
+ // A schedule stores the pair as checked (the catalog's casing, trimmed): its ticket takes it verbatim.
1759
+ const storedBody = startPair ? { ...body, model: startPair.model, effort: startPair.effort || undefined } : body;
1760
+ if (sched) return res.status(202).json(await scheduleRequest({ body: storedBody, sched, title, askLink, budget, projectDir }));
1761
+
1533
1762
  orch = await createOrchestratorFor({
1534
1763
  projectDir,
1535
1764
  prompt: effectivePrompt,
@@ -1543,7 +1772,13 @@ const startRunHandler = async (req, res) => {
1543
1772
  branch,
1544
1773
  humanInLoop,
1545
1774
  ...(memoryScope ? { memoryScope } : {}),
1546
- claude: { permissionMode: 'acceptEdits', mock },
1775
+ claude: {
1776
+ permissionMode: stored.permissionMode || 'acceptEdits',
1777
+ ...(startPair ? { model: startPair.model, ...(startPair.effort ? { effort: startPair.effort } : {}) } : (stored.model ? { model: stored.model } : {})),
1778
+ mock,
1779
+ },
1780
+ // A CLI-made ticket may carry `--yes`: the explicit non-interactive choice survives the wait.
1781
+ ...(stored.auto ? { auto: true } : {}),
1547
1782
  });
1548
1783
 
1549
1784
  entry = {
@@ -1559,6 +1794,11 @@ const startRunHandler = async (req, res) => {
1559
1794
  };
1560
1795
  }
1561
1796
 
1797
+ if (internal) {
1798
+ entry.ticketId = internal.ticket.id;
1799
+ entry.scheduleId = internal.ticket.scheduleId || null;
1800
+ entry.scheduledFor = internal.ticket.runAt;
1801
+ }
1562
1802
  runs.set(runId, entry);
1563
1803
  wireRun(entry);
1564
1804
  if (askLink) {
@@ -1568,7 +1808,8 @@ const startRunHandler = async (req, res) => {
1568
1808
  // must not launch a second pipeline for the same card (review of PR #376).
1569
1809
  // Withdraw the run entry (nothing has run or been announced yet) and 409.
1570
1810
  const still = askFindCard(askLink.threadId, askLink.cardId);
1571
- if (!still || still.block.state !== 'proposed') {
1811
+ const stillOwn = internal && still && still.block.state === 'scheduled' && still.block.runId === runId;
1812
+ if (!still || (still.block.state !== 'proposed' && !stillOwn)) {
1572
1813
  runs.delete(runId);
1573
1814
  return res.status(409).json({ error: `card is no longer proposed (${still ? still.block.state : 'gone'})` });
1574
1815
  }
@@ -1622,6 +1863,582 @@ const startRunHandler = async (req, res) => {
1622
1863
  };
1623
1864
  app.post('/api/run', startRunHandler);
1624
1865
 
1866
+ // ---------------------------------------------------------------------------
1867
+ // Scheduled runs (schema v31, src/core/scheduler.mjs). A schedule is a TICKET, not a
1868
+ // pipeline: POST /api/run with `scheduledFor` and/or `repeat` validates the request
1869
+ // exactly like a run, stores it, and answers 202. The tick below claims due tickets
1870
+ // and pushes each one back through startRunHandler — so a scheduled run is validated
1871
+ // twice and born through the one start path every run uses.
1872
+ // ---------------------------------------------------------------------------
1873
+ const TRANSIENT_SOURCE_KINDS = new Set(['network', 'rate-limit', 'timeout']);
1874
+ const SCHEDULER_TICK_MS = 30_000;
1875
+ const SCHEDULER_STAGGER_MS = 3_000;
1876
+
1877
+ /** Validate `scheduledFor` / `repeat` / `ifMissed` / `graceMin` on a run body. */
1878
+ function parseScheduleRequest(body, { now = Date.now() } = {}) {
1879
+ const defaults = scheduleDefaults();
1880
+ const out = { ok: true, runAtMs: null, repeat: null, after: null, afterPolicy: 'done', sourceFromPrevious: false, ifMissed: defaults.ifMissed, graceMin: defaults.graceMin };
1881
+ const given = ['scheduledFor', 'repeat', 'after'].filter((k) => body[k] != null);
1882
+ if (given.length > 1) return { ok: false, error: 'provide scheduledFor, repeat OR after, not both' };
1883
+ if (body.sourceFromPrevious != null && typeof body.sourceFromPrevious !== 'boolean') return { ok: false, error: 'sourceFromPrevious must be true or false' };
1884
+ if (body.sourceFromPrevious === true && body.after == null) return { ok: false, error: 'sourceFromPrevious needs after' };
1885
+ if (body.after != null) {
1886
+ const a = body.after;
1887
+ if (!a || typeof a !== 'object' || Array.isArray(a) || !['ticket', 'pipeline'].includes(a.kind) || typeof a.id !== 'string' || !a.id.trim()) {
1888
+ return { ok: false, error: 'after must be { kind: ticket | pipeline, id }' };
1889
+ }
1890
+ if (body.ifMissed != null || body.graceMin != null) return { ok: false, error: 'ifMissed and graceMin do not apply to a run after another run' };
1891
+ if (body.afterPolicy != null && !AFTER_POLICIES.includes(body.afterPolicy)) return { ok: false, error: `afterPolicy must be one of ${AFTER_POLICIES.join(' | ')}` };
1892
+ if (body.sourceFromPrevious === true && (body.sourceBranch != null || body.sourceBranchByKey != null)) return { ok: false, error: 'sourceFromPrevious and sourceBranch / sourceBranchByKey cannot both be given' };
1893
+ out.after = { kind: a.kind, id: a.id.trim() };
1894
+ out.afterPolicy = body.afterPolicy || 'done';
1895
+ out.sourceFromPrevious = body.sourceFromPrevious === true;
1896
+ return out;
1897
+ }
1898
+ if (body.ifMissed != null) {
1899
+ if (!MISSED_POLICIES.includes(body.ifMissed)) return { ok: false, error: `ifMissed must be one of ${MISSED_POLICIES.join(' | ')}` };
1900
+ out.ifMissed = body.ifMissed;
1901
+ }
1902
+ if (body.graceMin != null) {
1903
+ if (!Number.isSafeInteger(body.graceMin) || body.graceMin < 0 || body.graceMin > 10080) return { ok: false, error: 'graceMin must be a whole number of minutes from 0 to 10080' };
1904
+ out.graceMin = body.graceMin;
1905
+ }
1906
+ if (body.repeat != null) {
1907
+ if (body.scheduledFor != null) return { ok: false, error: 'provide scheduledFor (run once) OR repeat (recurring), not both' };
1908
+ const rep = body.repeat;
1909
+ if (!rep || typeof rep !== 'object' || Array.isArray(rep)) return { ok: false, error: 'repeat must be an object: { rule, overlap?, maxFailures? }' };
1910
+ const tz = rep.rule && typeof rep.rule === 'object' ? rep.rule.tz : null;
1911
+ const norm = normalizeRule(rep.rule, { todayLocal: isValidTimeZone(tz) ? localDate(now, tz) : null });
1912
+ if (!norm.ok) return { ok: false, error: norm.error };
1913
+ if (rep.overlap != null && !OVERLAP_POLICIES.includes(rep.overlap)) return { ok: false, error: `repeat.overlap must be one of ${OVERLAP_POLICIES.join(' | ')}` };
1914
+ if (rep.maxFailures != null && (!Number.isSafeInteger(rep.maxFailures) || rep.maxFailures < 0 || rep.maxFailures > 100)) {
1915
+ return { ok: false, error: 'repeat.maxFailures must be a whole number from 0 to 100 (0 = never pause)' };
1916
+ }
1917
+ if (nextOccurrence(norm.rule, now) == null) return { ok: false, error: 'repeat.rule has no future occurrence' };
1918
+ out.repeat = { rule: norm.rule, overlap: rep.overlap || 'skip', maxFailures: rep.maxFailures ?? defaults.maxFailures };
1919
+ return out;
1920
+ }
1921
+ const at = parseScheduledFor(body.scheduledFor);
1922
+ if (!at.ok) return { ok: false, error: at.error };
1923
+ if (at.ms < now - 5_000) return { ok: false, error: 'scheduledFor is in the past' };
1924
+ out.runAtMs = at.ms;
1925
+ return out;
1926
+ }
1927
+
1928
+ /** The request a ticket stores: the validated body minus schedule fields and uploads. */
1929
+ async function storedRequestOf(body, stageId, projectDir) {
1930
+ const request = { ...body };
1931
+ for (const k of ['scheduledFor', 'repeat', 'after', 'afterPolicy', 'sourceFromPrevious', 'ifMissed', 'graceMin', 'extras', 'internal']) delete request[k];
1932
+ // Text the user authored is part of the request: a prompt FILE is frozen now, so a
1933
+ // file deleted or half-edited overnight cannot fail an unattended run.
1934
+ if (request.source && request.source.type === 'markdown' && request.source.promptFile && !request.source.promptText) {
1935
+ const promptText = await readPromptFile(projectDir, request.source.promptFile);
1936
+ request.source = { type: 'markdown', promptText };
1937
+ }
1938
+ const extrasPaths = await writeExtras(stageId, body.extras, path.join(scheduleStageDir(stageId), 'extras'));
1939
+ request.internal = { extrasPaths };
1940
+ return request;
1941
+ }
1942
+
1943
+ /** Turn a validated run request into a ticket (or a recurring schedule). 202 body. */
1944
+ async function scheduleRequest({ body, sched, title, askLink, budget, projectDir, workspaceId = null }) {
1945
+ const target = workspaceId ? { workspaceId } : { projectDir };
1946
+ let ticket, schedule = null;
1947
+ if (sched.repeat) {
1948
+ const id = `sch_${randomBytes(4).toString('hex')}`;
1949
+ const request = await storedRequestOf(body, id, projectDir);
1950
+ ({ schedule, ticket } = createSchedule({
1951
+ id, title, ...target, request, rule: sched.repeat.rule, overlap: sched.repeat.overlap,
1952
+ maxFailures: sched.repeat.maxFailures, ifMissed: sched.ifMissed, graceMin: sched.graceMin,
1953
+ askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null,
1954
+ }));
1955
+ // An Ask card that became a repeating schedule follows the SERIES, not one run of it.
1956
+ if (askLink) {
1957
+ try { flipCard(askLink.threadId, askLink.cardId, { state: 'scheduled', runId: null, scheduleId: schedule.id, sentence: schedule.sentence, scheduledFor: ticket ? ticket.runAt : null }); }
1958
+ catch (err) { console.error(`[worca-ui] ask card schedule flip failed: ${err && err.message ? err.message : err}`); }
1959
+ }
1960
+ } else {
1961
+ const id = randomUUID();
1962
+ const request = await storedRequestOf(body, id, projectDir);
1963
+ // Both arms carry ifMissed / graceMin: parseScheduleRequest filled them with the Settings defaults
1964
+ // (an after body may not name them), and a chained ticket later moved to a time shows them.
1965
+ const chain = sched.after ? {
1966
+ after: { kind: sched.afterRef.kind, id: sched.afterRef.id }, afterPolicy: sched.afterPolicy, sourceFromPrevious: sched.sourceFromPrevious, ifMissed: sched.ifMissed, graceMin: sched.graceMin,
1967
+ } : { runAtMs: sched.runAtMs, ifMissed: sched.ifMissed, graceMin: sched.graceMin };
1968
+ ticket = createTicket({ id, title, ...target, request, ...chain, askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null });
1969
+ if (askLink) {
1970
+ try { flipCard(askLink.threadId, askLink.cardId, { state: 'scheduled', runId: id, scheduledFor: sched.after ? null : ticket.runAt, after: sched.after ? { kind: sched.afterRef.kind, id: sched.afterRef.id, title: sched.afterRef.title } : null }); }
1971
+ catch (err) { console.error(`[worca-ui] ask card schedule flip failed: ${err && err.message ? err.message : err}`); }
1972
+ }
1973
+ }
1974
+ emitChanged('schedules-changed', 'created');
1975
+ return {
1976
+ runId: ticket ? ticket.id : null,
1977
+ status: 'scheduled',
1978
+ scheduledFor: ticket && !ticket.after ? ticket.runAt : null,
1979
+ ...(ticket && ticket.after ? { after: { kind: sched.afterRef.kind, id: sched.afterRef.id, title: sched.afterRef.title }, sourceFromPrevious: ticket.sourceFromPrevious } : {}),
1980
+ ...(schedule ? { scheduleId: schedule.id, sentence: schedule.sentence } : {}),
1981
+ ...(budget && budget.blocked ? { budgetWarning: 'The total cost limit is reached right now. The run will only start if the budget allows it at that time.' } : {}),
1982
+ };
1983
+ }
1984
+
1985
+ /** An Ask card that was scheduled goes back to `proposed` when its ticket dies or its series is deleted. */
1986
+ function releaseAskCard(item) {
1987
+ if (!item || !item.askThreadId || !item.askCardId) return;
1988
+ try {
1989
+ const found = askFindCard(item.askThreadId, item.askCardId);
1990
+ const b = found && found.block;
1991
+ if (b && b.state === 'scheduled' && (item.kind === 'recurring' ? b.scheduleId === item.id : b.runId === item.id)) {
1992
+ flipCard(item.askThreadId, item.askCardId, { state: 'proposed', runId: null, scheduledFor: null, scheduleId: null, sentence: null });
1993
+ }
1994
+ } catch (err) { console.error(`[worca-ui] ask card release failed: ${err && err.message ? err.message : err}`); }
1995
+ }
1996
+
1997
+ /** Call startRunHandler without HTTP. Resolves { status, body }. */
1998
+ async function invokeStartRun(body, internal) {
1999
+ let out = { status: 200, body: null };
2000
+ const res = {
2001
+ statusCode: 200,
2002
+ status(code) { this.statusCode = code; return this; },
2003
+ json(payload) { out = { status: this.statusCode, body: payload }; return this; },
2004
+ };
2005
+ await startRunHandler({ body, _internal: internal }, res);
2006
+ return out;
2007
+ }
2008
+
2009
+ /** runDueTickets' `start`: probe an external task first (transient errors retry), then start. */
2010
+ async function fireTicket(ticket) {
2011
+ const body = { ...(ticket.request || {}) };
2012
+ if (body.source && body.source.type === 'plugin') {
2013
+ try {
2014
+ await resolveTaskInput(body.source, { projectDir: ticket.projectDir || undefined });
2015
+ } catch (err) {
2016
+ return { ok: false, error: err && err.message ? err.message : String(err), transient: TRANSIENT_SOURCE_KINDS.has(err && err.kind) };
2017
+ }
2018
+ }
2019
+ if (ticket.after) {
2020
+ const p = predecessorState(ticket.after, { policy: ticket.after.policy, isLive: liveProbe });
2021
+ if (p.state === 'waiting' && !ticket.forced) return { ok: false, error: 'the run before it is still going', transient: true };
2022
+ if (ticket.sourceFromPrevious) {
2023
+ const prev = p.pipelineId ? previousBranchesOf(p.pipelineId) : null;
2024
+ if (!prev) return { ok: false, error: 'the run before it left no branch to start from', transient: false };
2025
+ if (prev.sourceBranch) {
2026
+ if (!(await isValidSourceRef(ticket.projectDir, prev.sourceBranch))) return { ok: false, error: `branch ${prev.sourceBranch} no longer exists`, transient: false };
2027
+ } else {
2028
+ const ws = ticket.workspaceId ? await readWorkspace(ticket.workspaceId) : null;
2029
+ if (!ws) return { ok: false, error: 'workspace not found', transient: false };
2030
+ for (const dir of ws.projectPaths) {
2031
+ const key = projectKey(dir);
2032
+ const br = prev.sourceBranchByKey[key];
2033
+ if (!br) return { ok: false, error: `the run before it has no branch for ${path.basename(dir)}`, transient: false };
2034
+ if (!(await isValidSourceRef(dir, br))) return { ok: false, error: `branch ${br} no longer exists in ${path.basename(dir)}`, transient: false };
2035
+ }
2036
+ }
2037
+ delete body.sourceBranch; delete body.sourceBranchByKey;
2038
+ Object.assign(body, prev);
2039
+ }
2040
+ }
2041
+ // Every occurrence of a series needs its own feature branch.
2042
+ if (ticket.scheduleId && typeof body.featureBranch === 'string' && body.featureBranch.trim()) {
2043
+ const s = getSchedule(ticket.scheduleId);
2044
+ const day = (s && s.tz ? localDate(Date.parse(ticket.runAt), s.tz) : ticket.runAt.slice(0, 10)).replace(/-/g, '');
2045
+ body.featureBranch = `${body.featureBranch.trim()}-${day}`;
2046
+ }
2047
+ const out = await invokeStartRun(body, { ticket });
2048
+ if (out.status === 200 && out.body && out.body.runId) return { ok: true };
2049
+ const error = (out.body && out.body.error) || `the run could not be started (HTTP ${out.status})`;
2050
+ return { ok: false, error, transient: out.status >= 500 };
2051
+ }
2052
+
2053
+ /** The host's in-memory view of a run: by run id, or by the pipeline id it became. liveRunEntry is the
2054
+ * house lookup: it skips scans / agentgens / benches and, on a resumed lineage (D23), prefers the entry
2055
+ * still driving the pipeline over a settled same-pipeline entry that sits earlier in Map order. */
2056
+ function liveProbe({ id, pipelineId }) {
2057
+ const e = liveRunEntry(id) || (pipelineId ? liveRunEntry(pipelineId) : null);
2058
+ return !!e && !SETTLED_RUN.has(String(e.status || ''));
2059
+ }
2060
+
2061
+ let _schedulerBusy = false;
2062
+ let _lastNotificationId = -1;
2063
+ let _lastScheduleSig = null;
2064
+
2065
+ /** One scheduler pass. Exported for tests; the server calls it on a 30 s timer. */
2066
+ export async function schedulerTick({ now = Date.now() } = {}) {
2067
+ if (_schedulerBusy) return null;
2068
+ _schedulerBusy = true;
2069
+ try {
2070
+ const out = await runDueTickets({
2071
+ now,
2072
+ start: fireTicket,
2073
+ isLive: liveProbe,
2074
+ staggerMs: SCHEDULER_STAGGER_MS,
2075
+ });
2076
+ for (const id of [...out.failed, ...out.missed]) releaseAskCard(getTicket(id));
2077
+ if (out.fired.length || out.missed.length || out.skipped.length || out.failed.length || out.retried.length) {
2078
+ emitChanged('schedules-changed', 'tick');
2079
+ }
2080
+ // A `--wait` CLI writes notifications from ITS process: notice them here.
2081
+ const latest = latestNotificationId();
2082
+ if (_lastNotificationId !== -1 && latest !== _lastNotificationId) emitChanged('notifications-changed');
2083
+ _lastNotificationId = latest;
2084
+ // ...and the CLI writes tickets straight into the shared DB: notice those too.
2085
+ const sig = scheduleSignature();
2086
+ if (_lastScheduleSig !== null && sig !== _lastScheduleSig && !out.fired.length) emitChanged('schedules-changed', 'external');
2087
+ _lastScheduleSig = sig;
2088
+ return out;
2089
+ } catch (err) {
2090
+ console.error(`[worca-ui] scheduler tick failed: ${err && err.message ? err.message : err}`);
2091
+ return null;
2092
+ } finally {
2093
+ _schedulerBusy = false;
2094
+ }
2095
+ }
2096
+
2097
+ let _schedulerTimer = null;
2098
+ /** Start the scheduler: recover, run one pass now (boot catch-up), then tick. */
2099
+ export function startScheduler() {
2100
+ if (_schedulerTimer) return () => {};
2101
+ try {
2102
+ const recovered = recoverScheduler();
2103
+ if (recovered) console.log(`[worca-ui] scheduler: returned ${recovered} interrupted ticket(s) to the queue`);
2104
+ const purged = purgeScheduler();
2105
+ purgeNotifications();
2106
+ if (purged.tickets || purged.schedules) console.log(`[worca-ui] scheduler: purged ${purged.tickets} old ticket(s), ${purged.schedules} ended schedule(s)`);
2107
+ } catch (err) { console.error(`[worca-ui] scheduler recovery failed: ${err && err.message ? err.message : err}`); }
2108
+ schedulerTick();
2109
+ _schedulerTimer = setInterval(() => { schedulerTick(); }, SCHEDULER_TICK_MS);
2110
+ _schedulerTimer.unref();
2111
+ return () => { clearInterval(_schedulerTimer); _schedulerTimer = null; };
2112
+ }
2113
+
2114
+ // Notifications written in THIS process reach open tabs at once, and chat.
2115
+ onNotification((n) => {
2116
+ broadcast({ type: 'notification', notification: n });
2117
+ try { chatNotifier.notifySchedule(n); } catch { /* never break the writer */ }
2118
+ });
2119
+
2120
+ /** A schedule item (ticket or series) by id, for the unified /api/schedules routes. */
2121
+ function findScheduleItem(id) {
2122
+ if (typeof id !== 'string' || !id) return null;
2123
+ if (id.startsWith('sch_')) { const s = getSchedule(id); return s ? { kind: 'recurring', item: s } : null; }
2124
+ const t = getTicket(id);
2125
+ return t ? { kind: 'once', item: t } : null;
2126
+ }
2127
+
2128
+ /** A ticket for the wire: its predecessor resolved for display (title, live status, pipeline). */
2129
+ function withAfter(t) {
2130
+ if (!t || !t.after) return t;
2131
+ const p = predecessorState(t.after, { policy: t.after.policy, isLive: liveProbe });
2132
+ return { ...t, after: { ...t.after, title: p.title || null, status: p.status || null, pipelineId: p.pipelineId || null } };
2133
+ }
2134
+
2135
+ // GET /api/schedules[?projectDir=|workspaceId=][&all=1] -> { schedules, tickets, counts, defaults }
2136
+ app.get('/api/schedules', (req, res) => {
2137
+ try {
2138
+ const projectDir = resolveProjectDir(req.query.projectDir) || null;
2139
+ const workspaceId = typeof req.query.workspaceId === 'string' && req.query.workspaceId.trim() ? req.query.workspaceId.trim() : null;
2140
+ const all = req.query.all === '1' || req.query.all === 'true';
2141
+ res.json({
2142
+ schedules: listSchedules({ projectDir, workspaceId }),
2143
+ tickets: listTickets({ projectDir, workspaceId, all }).map(withAfter),
2144
+ counts: { ...scheduleCounts(), unread: unreadCount('schedule') },
2145
+ defaults: scheduleDefaults(),
2146
+ });
2147
+ } catch (err) {
2148
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2149
+ }
2150
+ });
2151
+
2152
+ // POST /api/schedules/preview { rule, count? } -> { sentence, next: [iso…] }
2153
+ app.post('/api/schedules/preview', (req, res) => {
2154
+ const body = req.body || {};
2155
+ const tz = body.rule && typeof body.rule === 'object' ? body.rule.tz : null;
2156
+ const norm = normalizeRule(body.rule, { todayLocal: isValidTimeZone(tz) ? localDate(Date.now(), tz) : null });
2157
+ if (!norm.ok) return badRequest(res, norm.error);
2158
+ const n = Number.isSafeInteger(body.count) ? Math.max(1, Math.min(10, body.count)) : 3;
2159
+ res.json({ rule: norm.rule, sentence: describeRule(norm.rule), next: previewOccurrences(norm.rule, Date.now(), n).map((t) => new Date(t).toISOString()) });
2160
+ });
2161
+
2162
+ // GET /api/schedules/dependents?workflowId=|projectDir=|workspaceId= -> what a removal
2163
+ // would strand, so the confirmation can NAME it.
2164
+ app.get('/api/schedules/dependents', (req, res) => {
2165
+ const q = req.query;
2166
+ const label = (x) => ({ id: x.id, kind: x.kind, title: x.title });
2167
+ if (typeof q.workflowId === 'string' && q.workflowId) return res.json({ dependents: dependentsOfWorkflow(q.workflowId) });
2168
+ if (typeof q.pipelineId === 'string' && q.pipelineId) return res.json({ dependents: dependentsOfRun({ pipelineId: q.pipelineId }) });
2169
+ if (typeof q.ticketId === 'string' && q.ticketId) return res.json({ dependents: dependentsOfRun({ ticketId: q.ticketId }) });
2170
+ const projectDir = resolveProjectDir(q.projectDir) || null;
2171
+ const workspaceId = typeof q.workspaceId === 'string' && q.workspaceId.trim() ? q.workspaceId.trim() : null;
2172
+ if (!projectDir && !workspaceId) return badRequest(res, 'workflowId, projectDir or workspaceId is required');
2173
+ res.json({
2174
+ dependents: [
2175
+ ...listSchedules({ projectDir, workspaceId, includeEnded: false }).map(label),
2176
+ ...listTickets({ projectDir, workspaceId, oneShotOnly: true }).map(label),
2177
+ ],
2178
+ });
2179
+ });
2180
+
2181
+ // GET /api/schedules/after-candidates?projectDir=|workspaceId= -> what a new run may wait for:
2182
+ // live runs of that target and its one-off tickets that have not ended.
2183
+ app.get('/api/schedules/after-candidates', (req, res) => {
2184
+ try {
2185
+ const projectDir = resolveProjectDir(req.query.projectDir) || null;
2186
+ const workspaceId = typeof req.query.workspaceId === 'string' && req.query.workspaceId.trim() ? req.query.workspaceId.trim() : null;
2187
+ if (!projectDir && !workspaceId) return badRequest(res, 'projectDir or workspaceId is required');
2188
+ // The ROWS are the source of truth (spec §5): SETTLED_RUN contains 'paused', and another
2189
+ // process's run is not in this host's runs Map at all. The Map only adds live runIds.
2190
+ const where = workspaceId ? 'workspace_key = ?' : "project_key = ? AND target = 'project'";
2191
+ const rows = getDb().prepare(`SELECT id, title, status FROM pipelines WHERE ${where}
2192
+ AND status IN ('created', 'starting', 'running', 'pausing', 'paused') AND archived_at IS NULL ORDER BY started_at DESC LIMIT 50`)
2193
+ .all(workspaceId || projectKey(projectDir));
2194
+ const byPipeline = new Map(rows.map((r) => [r.id, { pipelineId: r.id, runId: null, title: r.title || null, status: r.status }]));
2195
+ for (const r of runs.values()) {
2196
+ if (!r.pipelineId || (r.kind && r.kind !== 'run' && r.kind !== 'workspace-run')) continue;
2197
+ if (workspaceId ? r.workspaceId !== workspaceId : (r.projectDir !== projectDir || r.workspaceId)) continue;
2198
+ const stillGoing = !SETTLED_RUN.has(String(r.status || '')) || r.status === 'paused';
2199
+ if (!stillGoing) continue;
2200
+ const cur = byPipeline.get(r.pipelineId) || { pipelineId: r.pipelineId, title: r.title || null, status: r.status };
2201
+ byPipeline.set(r.pipelineId, { ...cur, runId: r.id, status: r.status });
2202
+ }
2203
+ // listTickets({ oneShotOnly: true }) returns scheduled | firing | missed. A MISSED ticket is left
2204
+ // out: resolveAfterRef refuses it under either policy ("‘X’ was missed — nothing to wait for"),
2205
+ // so offering it would be a dead pick (spec §5 lists it; this is the one deliberate narrowing).
2206
+ const tickets = listTickets({ projectDir, workspaceId, oneShotOnly: true })
2207
+ .filter((t) => t.status !== 'missed')
2208
+ .map((t) => ({ id: t.id, title: t.title, status: t.status, after: t.after ? { kind: t.after.kind, id: t.after.id } : null }));
2209
+ res.json({ runs: [...byPipeline.values()], tickets });
2210
+ } catch (err) {
2211
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2212
+ }
2213
+ });
2214
+
2215
+ // GET /api/schedules/after/:id -> one predecessor and its target (the #new/after/<id> deep link).
2216
+ // async (listProjects is async) — and therefore wrapped: Express 4 does not catch a rejected
2217
+ // handler, and the deep link's fetch would hang instead of showing an error line.
2218
+ app.get('/api/schedules/after/:id', async (req, res) => {
2219
+ try {
2220
+ const id = req.params.id;
2221
+ if (id.startsWith('sch_')) return badRequest(res, 'after a repeating schedule is not supported — give the id of one of its runs');
2222
+ const ref = afterRefOf(id);
2223
+ if (!ref || ref.scheduleId) return res.status(404).json({ error: 'run not found' });
2224
+ let projectDir = null;
2225
+ if (ref.kind === 'ticket') projectDir = (getTicket(ref.id) || {}).projectDir || null; // a purge between the two reads is a null, not a throw
2226
+ else if (!ref.workspaceId) {
2227
+ const projects = await listProjects();
2228
+ projectDir = (projects.find((p) => projectKey(p.path) === ref.projectKey) || {}).path || null;
2229
+ }
2230
+ res.json({ kind: ref.kind, id: ref.id, title: ref.title, status: ref.status, projectDir, workspaceId: ref.workspaceId || null });
2231
+ } catch (err) {
2232
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2233
+ }
2234
+ });
2235
+
2236
+ app.get('/api/schedules/:id', (req, res) => {
2237
+ const found = findScheduleItem(req.params.id);
2238
+ if (!found) return res.status(404).json({ error: 'schedule not found' });
2239
+ const history = found.kind === 'recurring' ? listTickets({ scheduleId: found.item.id, all: true, limit: 50 }).reverse() : [];
2240
+ res.json({ ...found, item: found.kind === 'once' ? withAfter(found.item) : found.item, history, notifications: listNotifications({ scheduleId: found.kind === 'recurring' ? found.item.id : null, limit: 50 }).filter((n) => found.kind === 'recurring' || n.ticketId === found.item.id) });
2241
+ });
2242
+
2243
+ // One schedule change, for the REST routes AND an applied Ask Worca schedule card — so the
2244
+ // card does exactly what the button on the Schedules page does. Resolves { status, body }.
2245
+ // verb 'patch' a ticket: { scheduledFor?, ifMissed?, graceMin? }
2246
+ // a series: { title?, rule?, overlap?, maxFailures?, ifMissed?, graceMin? }
2247
+ // verb 'delete' cancel a one-off ticket, or delete a series
2248
+ // verb 'run-now' start a ticket now, or one extra occurrence of a series
2249
+ // verb 'pause' | 'resume' | 'skip-next' a series only
2250
+ async function scheduleVerb(verb, id, body = {}) {
2251
+ const found = findScheduleItem(id);
2252
+ const out = (status, payload) => ({ status, body: payload });
2253
+ if (!found) return out(404, { error: 'schedule not found' });
2254
+ if (verb === 'patch') {
2255
+ try {
2256
+ if (found.kind === 'once') {
2257
+ const patch = {};
2258
+ if (body.scheduledFor != null) {
2259
+ const at = parseScheduledFor(body.scheduledFor);
2260
+ if (!at.ok) return out(400, { error: at.error });
2261
+ if (at.ms < Date.now() - 5_000) return out(400, { error: 'scheduledFor is in the past' });
2262
+ patch.runAtMs = at.ms;
2263
+ }
2264
+ if (body.ifMissed != null) {
2265
+ if (!MISSED_POLICIES.includes(body.ifMissed)) return out(400, { error: `ifMissed must be one of ${MISSED_POLICIES.join(' | ')}` });
2266
+ patch.ifMissed = body.ifMissed;
2267
+ }
2268
+ if (body.graceMin != null) patch.graceMin = body.graceMin;
2269
+ if (body.after != null) {
2270
+ if (body.scheduledFor != null) return out(400, { error: 'provide scheduledFor OR after, not both' });
2271
+ if (body.ifMissed != null || body.graceMin != null) return out(400, { error: 'ifMissed and graceMin do not apply to a run after another run' });
2272
+ const policy = body.afterPolicy != null ? body.afterPolicy : (found.item.after ? found.item.after.policy : 'done');
2273
+ if (!AFTER_POLICIES.includes(policy)) return out(400, { error: `afterPolicy must be one of ${AFTER_POLICIES.join(' | ')}` });
2274
+ const r = resolveAfterRef(body.after, { projectDir: found.item.projectDir, workspaceId: found.item.workspaceId, policy, selfId: found.item.id, isLive: liveProbe });
2275
+ if (!r.ok) return out(400, { error: r.error });
2276
+ patch.after = { kind: r.after.kind, id: r.after.id };
2277
+ patch.afterPolicy = policy;
2278
+ } else if (body.afterPolicy != null) {
2279
+ if (!AFTER_POLICIES.includes(body.afterPolicy)) return out(400, { error: `afterPolicy must be one of ${AFTER_POLICIES.join(' | ')}` });
2280
+ // A timed ticket has no policy (updateTicket drops one sent with runAtMs); say so rather than accept and
2281
+ // ignore — for a policy sent WITH a time, and for one sent ALONE to a ticket that has no predecessor
2282
+ // (that wrote after_policy onto a row with after_id NULL: harmless, but a lie in the row).
2283
+ if (patch.runAtMs != null || !found.item.after) return out(400, { error: 'afterPolicy does not apply to a run at a time' });
2284
+ patch.afterPolicy = body.afterPolicy;
2285
+ }
2286
+ if (body.sourceFromPrevious != null) {
2287
+ if (typeof body.sourceFromPrevious !== 'boolean') return out(400, { error: 'sourceFromPrevious must be true or false' });
2288
+ // Needs a predecessor AFTER this patch: none on the row and none coming, or a move back to a time
2289
+ // (which clears after_* — `source_from_previous = 1` with `after_id = NULL` must never be written).
2290
+ if (body.sourceFromPrevious && (patch.runAtMs != null || (!patch.after && !found.item.after))) return out(400, { error: 'sourceFromPrevious needs after' });
2291
+ patch.sourceFromPrevious = body.sourceFromPrevious;
2292
+ }
2293
+ if (found.item.scheduleId && (patch.runAtMs != null || patch.after)) return out(400, { error: 'an occurrence of a repeating schedule cannot be moved — edit the schedule, or skip this occurrence' });
2294
+ const t = updateTicket(found.item.id, patch);
2295
+ if (!t) return out(409, { error: `this run is ${found.item.status} and can no longer be changed` });
2296
+ const item = withAfter(t);
2297
+ if (t.askThreadId && t.askCardId && (patch.runAtMs != null || patch.after)) {
2298
+ try { flipCard(t.askThreadId, t.askCardId, { scheduledFor: t.after ? null : t.runAt, after: t.after ? { kind: t.after.kind, id: t.after.id, title: item.after.title } : null }); } catch { /* display only */ }
2299
+ }
2300
+ emitChanged('schedules-changed', 'updated');
2301
+ emitChanged('notifications-changed');
2302
+ return out(200, { kind: 'once', item });
2303
+ }
2304
+ const patch = {};
2305
+ for (const k of ['title', 'rule', 'overlap', 'maxFailures', 'ifMissed', 'graceMin']) if (body[k] !== undefined) patch[k] = body[k];
2306
+ if (patch.rule && typeof patch.rule === 'object' && !isValidTimeZone(patch.rule.tz)) return out(400, { error: `rule.tz is not a known timezone: ${patch.rule.tz ?? '(missing)'}` });
2307
+ if (patch.maxFailures !== undefined && (!Number.isSafeInteger(patch.maxFailures) || patch.maxFailures < 0 || patch.maxFailures > 100)) {
2308
+ return out(400, { error: 'maxFailures must be a whole number from 0 to 100 (0 = never pause)' });
2309
+ }
2310
+ if (patch.graceMin !== undefined && (!Number.isSafeInteger(patch.graceMin) || patch.graceMin < 0 || patch.graceMin > 10080)) {
2311
+ return out(400, { error: 'graceMin must be a whole number of minutes from 0 to 10080' });
2312
+ }
2313
+ const s = updateSchedule(found.item.id, patch);
2314
+ if (s && s.askThreadId && s.askCardId && patch.rule) {
2315
+ try { flipCard(s.askThreadId, s.askCardId, { sentence: s.sentence, scheduledFor: s.nextRunAt }); } catch { /* display only */ }
2316
+ }
2317
+ emitChanged('schedules-changed', 'updated');
2318
+ return out(200, { kind: 'recurring', item: s });
2319
+ } catch (err) {
2320
+ return out(400, { error: err && err.message ? err.message : String(err) });
2321
+ }
2322
+ }
2323
+ if (verb === 'delete') {
2324
+ if (found.kind === 'recurring') {
2325
+ deleteSchedule(found.item.id);
2326
+ releaseAskCard(found.item);
2327
+ } else {
2328
+ if (found.item.scheduleId) return out(400, { error: 'this is an occurrence of a repeating schedule — skip it instead' });
2329
+ const t = cancelTicket(found.item.id);
2330
+ if (!t) return out(409, { error: `this run is ${found.item.status} and can no longer be canceled` });
2331
+ releaseAskCard(t);
2332
+ }
2333
+ emitChanged('schedules-changed', 'deleted');
2334
+ emitChanged('notifications-changed');
2335
+ return out(200, { ok: true });
2336
+ }
2337
+ if (verb === 'run-now') {
2338
+ if (found.kind === 'once' && found.item.after && found.item.sourceFromPrevious) {
2339
+ const p = predecessorState(found.item.after, { policy: found.item.after.policy, isLive: liveProbe });
2340
+ if (!p.pipelineId || !previousBranchesOf(p.pipelineId)) return out(409, { error: `Start ‘${p.title || 'the run before it'}’ first, or change its source branch` });
2341
+ }
2342
+ const ticket = found.kind === 'recurring' ? runScheduleNow(found.item.id) : requestRunNow(found.item.id);
2343
+ if (!ticket) return out(409, { error: `this ${found.kind === 'recurring' ? 'schedule' : 'run'} is ${found.item.status} and cannot be started` });
2344
+ emitChanged('schedules-changed', 'run-now');
2345
+ emitChanged('notifications-changed');
2346
+ await schedulerTick();
2347
+ const after = getTicket(ticket.id);
2348
+ // A ticket held by a waiting `--wait` terminal is started by that terminal within seconds.
2349
+ return out(200, { runId: ticket.id, status: after ? after.status : 'scheduled', failReason: after ? after.failReason : null, pipelineId: after ? after.pipelineId : null });
2350
+ }
2351
+ if (['pause', 'resume', 'skip-next'].includes(verb)) {
2352
+ if (found.kind !== 'recurring') return out(404, { error: 'repeating schedule not found' });
2353
+ const s = verb === 'pause' ? pauseSchedule(found.item.id) : verb === 'resume' ? resumeSchedule(found.item.id) : skipNext(found.item.id);
2354
+ if (!s) return out(409, { error: `this schedule is ${found.item.status}` });
2355
+ emitChanged('schedules-changed', verb);
2356
+ emitChanged('notifications-changed');
2357
+ return out(200, { kind: 'recurring', item: s });
2358
+ }
2359
+ return out(400, { error: `unknown schedule action ${verb}` });
2360
+ }
2361
+
2362
+ /** Apply a CONFIRMED Ask Worca schedule card (schedule-spec.mjs shape) through scheduleVerb. */
2363
+ async function applyScheduleCard(card) {
2364
+ const map = { run_now: ['run-now', {}], move: ['patch', card.patch || {}], edit: ['patch', card.patch || {}], cancel: ['delete', {}], delete: ['delete', {}] };
2365
+ const m = map[card.action];
2366
+ if (!m) return { ok: false, error: `unknown schedule action "${card.action}"` };
2367
+ const r = await scheduleVerb(m[0], card.id, m[1]);
2368
+ if (r.status !== 200) return { ok: false, error: (r.body && r.body.error) || `failed (${r.status})` };
2369
+ const b = r.body || {};
2370
+ let detail = '';
2371
+ if (card.action === 'run_now') {
2372
+ detail = b.status === 'failed' ? `could not start: ${b.failReason || 'unknown error'}`
2373
+ : b.status === 'fired' ? `started${b.pipelineId ? ` as run ${b.pipelineId}` : ''}` : 'starting within seconds';
2374
+ if (b.status === 'failed') return { ok: false, error: `could not start: ${b.failReason || 'unknown error'}`, runId: b.runId };
2375
+ } else if (card.action === 'move') detail = card.after && card.after.when ? `now at ${card.after.when}` : card.after && card.after.afterRun ? `now after ‘${card.after.afterRun.title || card.after.afterRun.id}’` : 'moved';
2376
+ else if (card.action === 'edit') {
2377
+ const it = b.item;
2378
+ detail = !it ? 'changed'
2379
+ : it.nextRunAt ? `next run ${formatInstant(Date.parse(it.nextRunAt), it.tz || 'UTC')}`
2380
+ : it.status === 'paused' ? 'still paused — resume it to run again' : `the schedule is ${it.status}`;
2381
+ }
2382
+ else detail = card.action === 'cancel' ? 'canceled' : 'deleted';
2383
+ return { ok: true, detail, ...(b.runId ? { runId: b.runId } : {}), ...(b.pipelineId ? { pipelineId: b.pipelineId } : {}) };
2384
+ }
2385
+
2386
+ // PATCH /api/schedules/:id — a ticket: { scheduledFor?, ifMissed?, graceMin? };
2387
+ // a series: { title?, rule?, overlap?, maxFailures?, ifMissed?, graceMin? }.
2388
+ app.patch('/api/schedules/:id', async (req, res) => {
2389
+ const r = await scheduleVerb('patch', req.params.id, req.body || {});
2390
+ res.status(r.status).json(r.body);
2391
+ });
2392
+
2393
+ // DELETE /api/schedules/:id — cancel a one-shot ticket, or delete a series.
2394
+ app.delete('/api/schedules/:id', async (req, res) => {
2395
+ const r = await scheduleVerb('delete', req.params.id);
2396
+ res.status(r.status).json(r.body);
2397
+ });
2398
+
2399
+ // POST /api/schedules/:id/run-now — start a ticket now, or one extra occurrence of a series.
2400
+ app.post('/api/schedules/:id/run-now', async (req, res) => {
2401
+ const r = await scheduleVerb('run-now', req.params.id);
2402
+ res.status(r.status).json(r.body);
2403
+ });
2404
+
2405
+ for (const verb of ['pause', 'resume', 'skip-next']) {
2406
+ app.post(`/api/schedules/:id/${verb}`, async (req, res) => {
2407
+ const r = await scheduleVerb(verb, req.params.id);
2408
+ res.status(r.status).json(r.body);
2409
+ });
2410
+ }
2411
+
2412
+ // GET /api/notifications?scope=schedule[&unread=1][&problems=1] -> { notifications, unread }
2413
+ app.get('/api/notifications', (req, res) => {
2414
+ try {
2415
+ const scope = typeof req.query.scope === 'string' && req.query.scope ? req.query.scope : 'schedule';
2416
+ const flag = (v) => v === '1' || v === 'true';
2417
+ res.json({
2418
+ notifications: listNotifications({ scope, unread: flag(req.query.unread), problems: flag(req.query.problems) }),
2419
+ unread: unreadCount(scope),
2420
+ });
2421
+ } catch (err) {
2422
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2423
+ }
2424
+ });
2425
+
2426
+ app.post('/api/notifications/read-all', (req, res) => {
2427
+ const scope = req.body && typeof req.body.scope === 'string' && req.body.scope ? req.body.scope : 'schedule';
2428
+ markAllRead(scope);
2429
+ emitChanged('notifications-changed');
2430
+ res.json({ unread: unreadCount(scope) });
2431
+ });
2432
+
2433
+ app.post('/api/notifications/:id/read', (req, res) => {
2434
+ const id = Number(req.params.id);
2435
+ if (!Number.isSafeInteger(id)) return badRequest(res, 'invalid notification id');
2436
+ const read = !(req.body && req.body.read === false);
2437
+ if (!markRead(id, { read })) return res.status(404).json({ error: 'notification not found' });
2438
+ emitChanged('notifications-changed');
2439
+ res.json({ unread: unreadCount('schedule') });
2440
+ });
2441
+
1625
2442
  // ---------------------------------------------------------------------------
1626
2443
  // Chat connectivity (chat-connectivity-design.md): persistent channel workers
1627
2444
  // + inbound command router. Workers are dumb transports; every command
@@ -1640,6 +2457,11 @@ const chatContext = {
1640
2457
 
1641
2458
  const chatActions = {
1642
2459
  listRuns: () => summarizeRuns(),
2460
+ // Waiting + missed tickets, soonest first, with the time in this server's zone.
2461
+ listScheduled: () => listTickets().map((t) => ({
2462
+ id: t.id, title: t.title, runAt: t.runAt, status: t.status, projectDir: t.projectDir,
2463
+ when: formatInstant(Date.parse(t.runAt), Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'),
2464
+ })),
1643
2465
  runState: (runId) => { try { return runs.get(runId)?.orch?.getState() ?? null; } catch { return null; } },
1644
2466
  pendingQuestion: (runId) => runs.get(runId)?.pendingQuestion ?? null,
1645
2467
  answer: (runId, id, payload) => answerRun(runId, id, payload),
@@ -1742,6 +2564,18 @@ function reloadChatWorkers(name) {
1742
2564
  // from Discord clears the question card in every browser tab exactly like the
1743
2565
  // UI button does (resolvePending is part of the action, not the route).
1744
2566
  // ---------------------------------------------------------------------------
2567
+ /**
2568
+ * Resolve a pending question. ONE implementation behind the HTTP route and the
2569
+ * chat command router (`chatActions.answer`), so answering from Discord clears the
2570
+ * card in every browser tab exactly like the UI button does.
2571
+ *
2572
+ * Ask forms, gate 3 (ruling X2): `orch.answer` THROWS `Error('invalid answer')`
2573
+ * with `code: 'INVALID_ANSWER'` and `errors: [{ path, code, message }]` when the
2574
+ * values are refused. That throw is RETHROWN unchanged and must never be caught
2575
+ * here — `resolvePending` below must not run, or the question would be cleared in
2576
+ * every tab while the run is still waiting on it. Callers (`POST /api/answer` →
2577
+ * 422, and P4's CLI + chat) branch on `err.code === 'INVALID_ANSWER'`.
2578
+ */
1745
2579
  function answerRun(runId, id, payload) {
1746
2580
  const entry = runs.get(runId);
1747
2581
  if (!entry) throw new Error('unknown runId');
@@ -1776,6 +2610,12 @@ app.post('/api/answer', (req, res) => {
1776
2610
  answerRun(runId, id, payload);
1777
2611
  res.json({ ok: true });
1778
2612
  } catch (err) {
2613
+ // Gate 3 (ask forms, spec §5): the answer was validated and refused, so the
2614
+ // question stays OPEN and the client gets the field errors to mark up. Every
2615
+ // other failure keeps its 500.
2616
+ if (err && err.code === 'INVALID_ANSWER') {
2617
+ return res.status(422).json({ error: 'invalid answer', errors: Array.isArray(err.errors) ? err.errors : [] });
2618
+ }
1779
2619
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
1780
2620
  }
1781
2621
  });
@@ -1828,7 +2668,7 @@ class ResumeError extends Error {
1828
2668
  }
1829
2669
  }
1830
2670
 
1831
- async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {}) {
2671
+ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, pastTeamCap = false, policyReason = null } = {}) {
1832
2672
  if (!pipelineId || typeof pipelineId !== 'string') throw new ResumeError(400, { error: 'pipelineId is required' });
1833
2673
  const saved = readPipelineForResume(pipelineId);
1834
2674
  if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
@@ -1897,6 +2737,19 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1897
2737
  if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
1898
2738
  }
1899
2739
 
2740
+ // Team policy gates (design §7): the total cap once per window per home, the pipeline cap once
2741
+ // per run. Both soft — `pastTeamCap` (with an optional / required reason) records the choice
2742
+ // and lets the resume through; the local gates above stay never-bypassable.
2743
+ const teamTarget = workspace ? { workspaceId: workspace.id } : { projectDir };
2744
+ const totalGate = await checkTeamTotalGate(teamTarget, { pastTeamCap, reason: policyReason });
2745
+ if (totalGate.blocked) {
2746
+ throw new ResumeError(totalGate.code === 'reason_required' ? 400 : 403, { error: totalGate.error, code: totalGate.code, policy: totalGate.policy, needsPolicyAck: totalGate.code === 'team_total' });
2747
+ }
2748
+ const pipeGate = checkTeamPipelineGate(totalGate.caps, { pipelineId, spentSoFar, pastTeamCap, reason: policyReason });
2749
+ if (pipeGate.blocked) {
2750
+ throw new ResumeError(pipeGate.code === 'reason_required' ? 400 : 403, { error: pipeGate.error, code: pipeGate.code, policy: pipeGate.policy, needsPolicyOverride: pipeGate.code === 'team_pipeline' });
2751
+ }
2752
+
1900
2753
  // A paused defragment run is not live; another one on the same scope may have started since.
1901
2754
  // Resuming the first would sync its stale mount over the second's work (spec §5 concurrency).
1902
2755
  const rpScope = saved.resumePoint?.memoryScope || null;
@@ -1986,6 +2839,8 @@ app.post('/api/resume', async (req, res) => {
1986
2839
  const out = await resumeRun(req.body?.pipelineId, {
1987
2840
  ignoreCostCap: req.body?.ignoreCostCap === true,
1988
2841
  mock: !!(req.body && req.body.mock),
2842
+ pastTeamCap: req.body?.pastTeamCap === true,
2843
+ policyReason: typeof req.body?.policyReason === 'string' ? req.body.policyReason : null,
1989
2844
  });
1990
2845
  res.json(out);
1991
2846
  } catch (err) {
@@ -2013,7 +2868,7 @@ app.get('/api/runs', async (req, res) => {
2013
2868
  const live = [...runs.values()]
2014
2869
  .filter((r) => r.workspaceId === ws.id)
2015
2870
  .map((r) => ({ id: r.pipelineId || r.id, runId: r.id, title: r.title, status: r.status, live: true }));
2016
- return res.json({ pipelines, live, ghAvailable: await hasGh() });
2871
+ return res.json({ pipelines, live, scheduled: listTickets({ workspaceId: ws.id }), ghAvailable: await hasGh() });
2017
2872
  } catch (err) {
2018
2873
  return res.status(500).json({ error: err && err.message ? err.message : String(err) });
2019
2874
  }
@@ -2037,7 +2892,8 @@ app.get('/api/runs', async (req, res) => {
2037
2892
  status: r.status,
2038
2893
  live: true,
2039
2894
  }));
2040
- res.json({ pipelines, live, ghAvailable: await hasGh() });
2895
+ // Additive: runs that WAIT for their start time are tickets, not pipelines.
2896
+ res.json({ pipelines, live, scheduled: listTickets({ projectDir }), ghAvailable: await hasGh() });
2041
2897
  } catch (err) {
2042
2898
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2043
2899
  }
@@ -2088,6 +2944,72 @@ app.get('/api/runs/:id/artifacts', async (req, res) => {
2088
2944
  }
2089
2945
  });
2090
2946
 
2947
+ // ---------------------------------------------------------------------------
2948
+ // Ask-form preview files (spec §7, D8). Served by (run, askId, index) — there is
2949
+ // NO path input on this surface: `:askId` is a token askIdToken() minted,
2950
+ // `:index` is a small integer, and the only string joined onto a directory is the
2951
+ // basename the snapshot's own manifest.json recorded. Posture copied from the Ask
2952
+ // attachment route: streamed sendFile, dotfiles denied, the SNIFFED type, nosniff,
2953
+ // inline, immutable private cache. Behind the loopback guard like every route.
2954
+ // ---------------------------------------------------------------------------
2955
+ const ASK_FILES_TOKEN_RE = /^[A-Za-z0-9_-]{1,96}$/;
2956
+
2957
+ async function serveAskFile(res, runDir, askId, index) {
2958
+ if (!runDir) return res.status(404).json({ error: 'pipeline not found' });
2959
+ if (typeof askId !== 'string' || !ASK_FILES_TOKEN_RE.test(askId)) {
2960
+ return res.status(400).json({ error: 'invalid ask id' });
2961
+ }
2962
+ if (typeof index !== 'string' || !/^\d{1,2}$/.test(index)) {
2963
+ return res.status(400).json({ error: 'invalid file index' });
2964
+ }
2965
+ const dir = path.join(runDir, ASK_FILES_DIR, askId);
2966
+ const entry = await readAskFileEntry(dir, Number(index));
2967
+ if (!entry) return res.status(404).json({ error: 'ask file not found' });
2968
+ // The text classes are PROVEN UTF-8 by the sniffer (a fatal decoder), so say so:
2969
+ // under nosniff a bare `text/plain` is decoded with the browser's legacy default
2970
+ // charset on a direct open, and non-ASCII prose garbles. Binary types stay bare.
2971
+ const type = ASK_FILE_MIMES[entry.mime].trust === 'text' ? `${entry.mime}; charset=utf-8` : entry.mime;
2972
+ const headers = {
2973
+ 'Content-Type': type,
2974
+ 'X-Content-Type-Options': 'nosniff',
2975
+ 'Content-Disposition': 'inline',
2976
+ 'Cache-Control': 'private, max-age=31536000, immutable',
2977
+ };
2978
+ // §7's "inline, inert only" row: an SVG is drawn through <img> in the panel,
2979
+ // and a direct hit on this URL is inert too — no script, no fetch, no anything.
2980
+ if (entry.mime === 'image/svg+xml') headers['Content-Security-Policy'] = "sandbox; default-src 'none'";
2981
+ res.sendFile(entry.stored, { root: dir, dotfiles: 'deny', cacheControl: false, headers }, (err) => {
2982
+ if (!err || res.headersSent) return;
2983
+ if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'ask file not found' });
2984
+ res.status(500).json({ error: err.message || String(err) });
2985
+ });
2986
+ }
2987
+
2988
+ /** The run dir behind a LIVE id: the pipeline id (what /api/runs/:id/artifact
2989
+ * takes) or the WebSocket run UUID (what the browser holds). */
2990
+ async function askFilesRunDir(id) {
2991
+ const live = liveRunEntry(id);
2992
+ const row = findPipelineRowById(live?.pipelineId || id);
2993
+ if (!row) return null;
2994
+ try { return await runDirForRow(row); } catch { return null; }
2995
+ }
2996
+
2997
+ app.get('/api/runs/:id/ask-files/:askId/:index', async (req, res) => {
2998
+ try {
2999
+ await serveAskFile(res, await askFilesRunDir(req.params.id), req.params.askId, req.params.index);
3000
+ } catch (err) {
3001
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3002
+ }
3003
+ });
3004
+
3005
+ /** The run dir behind a STORE KEY + run id — the shape both the History and the
3006
+ * workspace twin resolve through. Null (→ 404) when the row or its dir is gone. */
3007
+ async function askFilesStoreDir(key, runId) {
3008
+ const row = lookupPipelineRow(key, runId);
3009
+ if (!row) return null;
3010
+ try { return await runDirForRow(row); } catch { return null; }
3011
+ }
3012
+
2091
3013
  // Shared query-scope resolver for the retained-work routes (recovery-patch GET +
2092
3014
  // discard POST). Returns null after writing the error response itself. The older
2093
3015
  // DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
@@ -2199,7 +3121,10 @@ app.get('/api/onboarding', async (_req, res) => {
2199
3121
  catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err) }); }
2200
3122
  });
2201
3123
  app.post('/api/onboarding', async (req, res) => {
2202
- try { await setOnboardingPrefs(req.body || {}); }
3124
+ try {
3125
+ await pinUiLevel(); // before welcomeSeen flips and ends "fresh install"
3126
+ await setOnboardingPrefs(req.body || {});
3127
+ }
2203
3128
  catch (err) { return badRequest(res, err && err.message ? err.message : String(err)); }
2204
3129
  emitChanged('onboarding-changed');
2205
3130
  try { res.json(await onboardingStatus()); }
@@ -2212,6 +3137,7 @@ app.get('/api/counts', (_req, res) => {
2212
3137
  pipelines: countPipelines(),
2213
3138
  projects: countProjects(),
2214
3139
  workspaces: countWorkspaces(),
3140
+ schedules: { ...scheduleCounts(), unread: unreadCount('schedule') },
2215
3141
  });
2216
3142
  } catch (err) {
2217
3143
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
@@ -2279,16 +3205,18 @@ app.get('/api/team-metrics', async (req, res) => {
2279
3205
  // `changed` event tells the page to reload. Other clients (Ask tools, CLI, tests) keep the
2280
3206
  // inline fetch and get fresh data in one round trip.
2281
3207
  const read = await readScope(scope, { refresh: req.query.refresh === '1', defer: req.query.defer === '1' });
3208
+ const humanRateUsd = effectiveHumanRateUsd(read.rateProjectDir);
2282
3209
  // Flush trigger: page open (§4.5). reason:'page-open' backs off for 60 s after a failed flush,
2283
3210
  // so a failing push cannot loop through flush-failed → WS → page reload → GET → flush.
2284
3211
  for (const s of read.sync) if (s.pending > 0) scheduleFlush(s.slug, { reason: 'page-open' });
2285
3212
  res.json({
2286
3213
  scope: read.scope,
2287
3214
  records: read.records,
3215
+ humanRateUsd, // the client re-aggregates with it (money-saved §9.2)
2288
3216
  // The page re-aggregates client-side (§4.9 "one fetch serves the session") and asks with
2289
3217
  // aggregate=0: at 12k records the unused aggregate added ~3.9 MB to a ~9.9 MB response.
2290
3218
  // Other clients (and the API tests) still get it by default.
2291
- aggregate: req.query.aggregate === '0' ? null : aggregate(read.records, { range, from, to, groupBy }),
3219
+ aggregate: req.query.aggregate === '0' ? null : aggregate(read.records, { range, from, to, groupBy, humanRateUsd }),
2292
3220
  stats: read.stats,
2293
3221
  sync: read.sync,
2294
3222
  refresh: read.refresh,
@@ -2380,6 +3308,112 @@ app.post('/api/workspaces/:id/metrics-route', async (req, res) => {
2380
3308
  catch (err) { sendMetricsError(res, err); }
2381
3309
  });
2382
3310
 
3311
+ // ---------------------------------------------------------------------------
3312
+ // Team policy (team-policy design §11): scopes/statuses, the effective document for one
3313
+ // scope, publish, notes for the New pipeline form, enable / follow, workspace routing.
3314
+ // ---------------------------------------------------------------------------
3315
+ const POLICY_ERROR_STATUS = {
3316
+ BAD_REQUEST: 400, NOT_FOUND: 404, NO_ORIGIN: 400, DELEGATE_INVALID: 400, NOT_HOME: 409, NOT_ENABLED: 409,
3317
+ REMOTE_UNREACHABLE: 502, FETCH_FAILED: 502, WORKTREE_FAILED: 500, COMMIT_FAILED: 500,
3318
+ PUSH_REJECTED: 409, PUSH_RETRIES_EXHAUSTED: 409,
3319
+ };
3320
+ function sendPolicyError(res, err) {
3321
+ const code = (err && err.code) || 'INTERNAL';
3322
+ res.status(POLICY_ERROR_STATUS[code] || 500).json({
3323
+ error: err && err.message ? err.message : String(err), code,
3324
+ ...(err && err.stderr ? { stderr: err.stderr } : {}),
3325
+ ...(err && err.hint ? { hint: err.hint } : {}),
3326
+ ...(err && Array.isArray(err.warnings) ? { warnings: err.warnings } : {}),
3327
+ });
3328
+ }
3329
+
3330
+ app.get('/api/policy/scopes', async (req, res) => {
3331
+ try {
3332
+ const scopes = await listPolicyScopes({ discover: req.query.discover === '1' });
3333
+ // The Plugins page strip and the setup checklist: every home's requirements, folded.
3334
+ const docs = [];
3335
+ for (const s of scopes.projects) if (s.home && !docs.some((d) => d.slug === s.home)) {
3336
+ const r = await resolveProjectPolicy(s.path, { discover: false }).catch(() => null);
3337
+ if (r?.ok) docs.push({ slug: r.home, doc: r.doc });
3338
+ }
3339
+ res.json({ ...scopes, requirements: pluginRequirements(docs), blockedPlugins: blockedPluginFindings(docs) });
3340
+ } catch (err) { sendPolicyError(res, err); }
3341
+ });
3342
+
3343
+ app.get('/api/policy', async (req, res) => {
3344
+ const scope = parseScopeParam(req.query.scope);
3345
+ if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
3346
+ try {
3347
+ const { meta, r, workspaceRun, projectDir } = await policyForScope(scope);
3348
+ if (!r.ok) return res.status(404).json({ error: r.detail || `no team policy for this ${scope.kind}`, code: (r.code || r.reason || 'NOT_ENABLED').toString().toUpperCase().replace(/-/g, '_'), scope: meta });
3349
+ res.json(policyPayload(meta, r, { workspaceRun, projectDir }));
3350
+ } catch (err) { sendPolicyError(res, err); }
3351
+ });
3352
+
3353
+ // New pipeline form (board 8): the notes for a selection — caps, off-policy picks, plugin gaps.
3354
+ app.get('/api/policy/notes', async (req, res) => {
3355
+ const scope = parseScopeParam(req.query.scope);
3356
+ if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
3357
+ try {
3358
+ const { meta, r, workspaceRun } = await policyForScope(scope);
3359
+ if (!r.ok) return res.json({ scope: meta, policy: null, notes: [] });
3360
+ const fields = fieldsForRun(r.doc, { workspaceRun });
3361
+ const guardrailsId = typeof req.query.guardrailsId === 'string' && req.query.guardrailsId ? req.query.guardrailsId : 'permissive';
3362
+ const set = await readGuardrailSet(guardrailsId);
3363
+ const models = typeof req.query.models === 'string' && req.query.models ? req.query.models.split(',').filter(Boolean).map((m) => ({ role: null, model: m })) : [];
3364
+ const dev = deviationsFor(fields, { guardrailsId, guardrailSet: set, stepModels: models, installed: installedPluginsMap(), worcaVersion: POLICY_WORCA_VERSION, metricsRecord: null });
3365
+ res.json({ scope: meta, policy: { home: r.home, sha: r.sha, delegated: r.delegated, from: r.from, caps: capSummary(r.doc, { workspaceRun }) }, notes: dev, guardrailsDefault: fields['guardrails.default']?.value ?? null });
3366
+ } catch (err) { sendPolicyError(res, err); }
3367
+ });
3368
+
3369
+ // Publish (board 5): one commit to the home's worca-policy branch; a rejection comes back verbatim.
3370
+ app.put('/api/policy', async (req, res) => {
3371
+ const scope = parseScopeParam(req.body?.scope);
3372
+ if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
3373
+ if (!req.body || typeof req.body.doc !== 'object' || req.body.doc === null) return badRequest(res, 'doc must be the policy document');
3374
+ try {
3375
+ const { r } = await policyForScope(scope);
3376
+ if (!r.ok) return res.status(404).json({ error: r.detail || 'no team policy for this scope', code: 'NOT_ENABLED' });
3377
+ if (!r.homeDir || !fs.existsSync(r.homeDir)) return res.status(409).json({ error: `the policy home ${r.home} is not checked out on this machine`, code: 'NOT_HOME' });
3378
+ const out = await publishPolicy(r.homeDir, req.body.doc, { message: typeof req.body.message === 'string' ? req.body.message : null });
3379
+ res.json({ ok: true, slug: out.slug, sha: out.sha, unchanged: !!out.unchanged, doc: out.doc });
3380
+ } catch (err) { sendPolicyError(res, err); }
3381
+ });
3382
+
3383
+ // Validate without publishing (the editor's live check): the normaliser's warnings, if any.
3384
+ app.post('/api/policy/validate', (req, res) => {
3385
+ const { doc, warnings, unknownSchema } = normalizePolicyDoc(req.body?.doc);
3386
+ res.json({ ok: !!doc && !unknownSchema && warnings.length === 0, warnings, doc });
3387
+ });
3388
+
3389
+ app.post('/api/policy/discover', async (_req, res) => {
3390
+ try { await discoverAllPolicies({ force: true }); res.json(await listPolicyScopes()); }
3391
+ catch (err) { sendPolicyError(res, err); }
3392
+ });
3393
+
3394
+ app.get('/api/projects/:key/policy', async (req, res) => {
3395
+ const p = await tmProject(req, res); if (!p) return;
3396
+ try { res.json({ status: await projectPolicyStatus(p, { discover: req.query.discover === '1' }) }); }
3397
+ catch (err) { sendPolicyError(res, err); }
3398
+ });
3399
+
3400
+ app.post('/api/projects/:key/policy/enable', async (req, res) => {
3401
+ const p = await tmProject(req, res); if (!p) return;
3402
+ const body = req.body || {};
3403
+ const mode = body.mode === 'follow' ? 'follow' : body.mode === 'here' || body.mode == null ? 'here' : null;
3404
+ if (!mode) return badRequest(res, 'mode must be "here" or "follow"');
3405
+ try {
3406
+ const result = await enableTeamPolicy(p.path, { mode, delegateTo: body.delegateTo || null, change: body.change === true, title: typeof body.title === 'string' ? body.title : '' });
3407
+ res.json({ ...result, status: await projectPolicyStatus(p) });
3408
+ } catch (err) { sendPolicyError(res, err); }
3409
+ });
3410
+
3411
+ app.post('/api/workspaces/:id/policy-route', async (req, res) => {
3412
+ if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'workspace not found', code: 'NOT_FOUND' });
3413
+ try { res.json(await routeWorkspaceMembersPolicy(req.params.id)); }
3414
+ catch (err) { sendPolicyError(res, err); }
3415
+ });
3416
+
2383
3417
  // ---------------------------------------------------------------------------
2384
3418
  // POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
2385
3419
  // over the WS as batched `history-pr` events (reuses broadcast(), the same
@@ -2666,6 +3700,20 @@ app.get('/api/history/:key/:id/artifact', async (req, res) => {
2666
3700
  }
2667
3701
  });
2668
3702
 
3703
+ // GET /api/history/:key/:id/ask-files/:askId/:index -> the same bytes, resolved
3704
+ // through the store key. Same key regex as /diff and /artifact.
3705
+ app.get('/api/history/:key/:id/ask-files/:askId/:index', async (req, res) => {
3706
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
3707
+ return res.status(404).json({ error: 'pipeline not found' });
3708
+ }
3709
+ try {
3710
+ await serveAskFile(res, await askFilesStoreDir(req.params.key, req.params.id),
3711
+ req.params.askId, req.params.index);
3712
+ } catch (err) {
3713
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
3714
+ }
3715
+ });
3716
+
2669
3717
  // ---------------------------------------------------------------------------
2670
3718
  // DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
2671
3719
  // ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
@@ -2797,26 +3845,43 @@ async function resolvePrPipeline(src, res) {
2797
3845
  // the ship-it dialog plus the defaults POST /api/pr applies when the body names
2798
3846
  // none. Same pipeline resolution as POST /api/pr (the repo dir comes from the
2799
3847
  // pipeline's store_meta, never from the query). gh is not required here.
3848
+ // The base-branch choices ride along: `chain` is the run chain's base branches,
3849
+ // root first (a run outside a chain: just its source), `defaultBase` its root, and
3850
+ // `branches` each remote's branches from the LOCAL remote-tracking refs (no fetch),
3851
+ // without HEAD and the run's own feature branch. A git remote failure still carries
3852
+ // the chain so the dialog can offer it.
2800
3853
  // -> { ok, remotes:[{name,fetchUrl,pushUrl,host,owner,repo,slug}],
2801
- // defaults:{pushRemote,baseRemote}, remembered:{pushRemote,baseRemote}|null }
3854
+ // defaults:{pushRemote,baseRemote}, remembered:{pushRemote,baseRemote}|null,
3855
+ // chain:[branch], defaultBase:branch|null, branches:{[remote]:[branch]} }
2802
3856
  // ---------------------------------------------------------------------------
2803
3857
  app.get('/api/pr/remotes', async (req, res) => {
2804
3858
  const resolved = await resolvePrPipeline(req.query || {}, res);
2805
3859
  if (!resolved) return;
2806
3860
  const repoDir = resolved.state.projectDir; // null when store_meta is missing
2807
3861
  if (!repoDir) return badRequest(res, 'pipeline has no project directory');
3862
+ const feature = resolved.state.branch && resolved.state.branch.feature;
3863
+ const source = resolved.state.branch && resolved.state.branch.source;
3864
+ const walked = chainBaseBranchesOf(resolved.state.id || resolved.id);
3865
+ const chain = (walked.length ? walked : (source ? [source] : [])).filter((b) => b !== feature);
3866
+ const defaultBase = chain[0] || null;
2808
3867
  const rl = await listRemotes(repoDir);
2809
- if (!rl.ok) return res.status(500).json({ error: `git remote failed: ${rl.error}` });
3868
+ if (!rl.ok) return res.status(500).json({ error: `git remote failed: ${rl.error}`, chain, defaultBase });
2810
3869
  const remembered = readPrRemotePrefs(repoDir);
2811
- res.json({ ok: true, remotes: rl.remotes, defaults: defaultPrRemotes(rl.remotes, remembered), remembered });
3870
+ const rb = await listRemoteBranches(repoDir, rl.remotes.map((r) => r.name));
3871
+ const branches = {};
3872
+ for (const [name, list] of Object.entries(rb.byRemote)) branches[name] = list.filter((b) => b !== feature);
3873
+ res.json({ ok: true, remotes: rl.remotes, defaults: defaultPrRemotes(rl.remotes, remembered), remembered,
3874
+ chain, defaultBase, branches });
2812
3875
  });
2813
3876
 
2814
3877
  // ---------------------------------------------------------------------------
2815
3878
  // POST /api/pr -> push the pipeline's feature branch (if needed) and open a PR
2816
- // against its source branch via the GitHub CLI. Mergeability is read back only
2817
- // here (never during list rendering).
2818
- // body: { id, projectDir?, projectKey?, pushRemote?, baseRemote? } — remote names
2819
- // are validated against the repo's real remote list (never trusted from the body).
3879
+ // against its source branch (or the dialog's `baseBranch`) via the GitHub CLI.
3880
+ // Mergeability is read back only here (never during list rendering).
3881
+ // body: { id, projectDir?, projectKey?, pushRemote?, baseRemote?, baseBranch? } —
3882
+ // remote names are validated against the repo's real remote list (never trusted
3883
+ // from the body); baseBranch must be a well-formed ref other than the feature
3884
+ // branch (whether the base repo has it is gh's call, its error surfaces as usual).
2820
3885
  // ---------------------------------------------------------------------------
2821
3886
  app.post('/api/pr', async (req, res) => {
2822
3887
  const body = req.body || {};
@@ -2834,6 +3899,15 @@ app.post('/api/pr', async (req, res) => {
2834
3899
  if (!repoDir || !feature || !source) {
2835
3900
  return badRequest(res, 'pipeline has no branch info to open a PR');
2836
3901
  }
3902
+ // The base branch: the dialog's pick (a run chain defaults to its root there),
3903
+ // else the run's own source. Per run — never remembered with the remotes.
3904
+ let base = source;
3905
+ if (body.baseBranch !== undefined && body.baseBranch !== null) {
3906
+ const b = typeof body.baseBranch === 'string' ? body.baseBranch.trim() : '';
3907
+ if (!isSyntacticRef(b)) return badRequest(res, `invalid base branch: ${String(body.baseBranch).slice(0, 80)}`);
3908
+ if (b === feature) return badRequest(res, 'the base branch cannot be the feature branch');
3909
+ base = b;
3910
+ }
2837
3911
 
2838
3912
  // Remote selection. A named remote must exist; unnamed ones take the dialog's
2839
3913
  // defaults. With no usable remote list (no remotes, unparseable URLs) the legacy
@@ -2876,7 +3950,7 @@ app.post('/api/pr', async (req, res) => {
2876
3950
  if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
2877
3951
 
2878
3952
  const pr = await createPr({
2879
- projectDir: repoDir, base: source, head: feature, title: state.title || feature, repo, headOwner,
3953
+ projectDir: repoDir, base, head: feature, title: state.title || feature, repo, headOwner,
2880
3954
  });
2881
3955
  if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
2882
3956
 
@@ -2968,7 +4042,23 @@ app.get('/api/branches', async (req, res) => {
2968
4042
  listLocalBranches(projectDir),
2969
4043
  currentBranch(projectDir),
2970
4044
  ]);
2971
- res.json({ branches, current });
4045
+ // Run branches: this project's pipelines whose feature branch still exists (spec D7).
4046
+ // Throw-safe on purpose: test/branches-api.test.mjs calls this route for a temp directory
4047
+ // that is not a registered project (and has no temp home) — the DB read must never turn the
4048
+ // plain branch list into a 500.
4049
+ const have = new Set(branches);
4050
+ const runsOut = [];
4051
+ try {
4052
+ const rows = getDb().prepare(`SELECT id, title, status, updated_at, branch FROM pipelines
4053
+ WHERE project_key = ? AND target = 'project' AND archived_at IS NULL ORDER BY started_at DESC LIMIT 50`).all(projectKey(projectDir));
4054
+ for (const r of rows) {
4055
+ let b = null; try { b = JSON.parse(r.branch || 'null'); } catch { b = null; }
4056
+ if (b && typeof b.feature === 'string' && have.has(b.feature)) runsOut.push({ branch: b.feature, pipelineId: r.id, title: r.title || null, status: r.status, endedAt: r.updated_at || null });
4057
+ }
4058
+ } catch (err) {
4059
+ console.error(`[worca-ui] run branches lookup failed: ${err && err.message ? err.message : err}`);
4060
+ }
4061
+ res.json({ branches, current, runs: runsOut });
2972
4062
  } catch (err) {
2973
4063
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2974
4064
  }
@@ -2985,6 +4075,7 @@ app.get('/api/projects', async (_req, res) => {
2985
4075
  app.post('/api/projects', async (req, res) => {
2986
4076
  const body = req.body || {};
2987
4077
  try {
4078
+ await pinUiLevel(); // before the first project ends "fresh install"
2988
4079
  const projects = await addProject({ name: body.name, path: body.path });
2989
4080
  emitChanged('projects-changed', 'created');
2990
4081
  discoverProject(normalizeProjectPath(body.path), { force: true })
@@ -3003,7 +4094,13 @@ app.delete('/api/projects', async (req, res) => {
3003
4094
  const name = typeof req.query.name === 'string' ? req.query.name : '';
3004
4095
  if (!name.trim()) return badRequest(res, 'name is required');
3005
4096
  try {
4097
+ // Scheduled runs of a removed project can never start: cancel them with it.
4098
+ const before = (await listProjects()).find((p) => p && p.name === name.trim());
3006
4099
  const projects = await removeProject(name);
4100
+ if (before && before.path) {
4101
+ try { if (cancelForTarget({ projectDir: before.path })) emitChanged('schedules-changed', 'target-removed'); }
4102
+ catch (err) { console.error(`[worca-ui] schedule cleanup failed: ${err && err.message ? err.message : err}`); }
4103
+ }
3007
4104
  emitChanged('projects-changed', 'deleted');
3008
4105
  res.json({ projects });
3009
4106
  } catch (err) {
@@ -3072,6 +4169,20 @@ async function defragRequest(req, res, { memoryScope, projectKey: key }) {
3072
4169
  return startRunHandler(req, res);
3073
4170
  }
3074
4171
 
4172
+ /** Settings › Memory as the health card names it: `{ model, effort, label, stale }`, or null when
4173
+ * unset. Checked the way a run checks it (memory-defrag-model.mjs), against this scope's catalog —
4174
+ * the project's own for a project scope, the project-less one for global. `stale`: the model left
4175
+ * the catalog, so a run degrades to the workflow default. */
4176
+ async function defragModelState(catalogDir) {
4177
+ const stored = memoryDefragModel();
4178
+ if (!stored.model) return null;
4179
+ const models = await listModels(catalogDir);
4180
+ const r = resolveDefragModel({ stored, models });
4181
+ if (!r.model) return { model: stored.model, effort: stored.effort, label: stored.model, stale: true };
4182
+ const hit = models.find((m) => m.id === r.model);
4183
+ return { model: r.model, effort: r.effort, label: (hit && hit.label) || r.model, stale: false };
4184
+ }
4185
+
3075
4186
  function registerMemoryRoutes(prefix, { family }) {
3076
4187
  const scoped = (handler) => async (req, res) => {
3077
4188
  try {
@@ -3097,7 +4208,10 @@ function registerMemoryRoutes(prefix, { family }) {
3097
4208
 
3098
4209
  app.get(prefix, scoped(async (_req, res, { scope, key, project }) => {
3099
4210
  const report = await memoryScopeReport(memoryRoot(), scope, memoryCaps(), { onError });
3100
- res.json({ scope: key, project, files: report.entries, state: report.state, health: report.health, defragRunId: liveDefragRun(key)?.id || null });
4211
+ res.json({
4212
+ scope: key, project, files: report.entries, state: report.state, health: report.health, defragRunId: liveDefragRun(key)?.id || null,
4213
+ defragModel: await defragModelState(project ? project.path : ''),
4214
+ });
3101
4215
  }));
3102
4216
  app.get(`${prefix}/files/:name`, scoped(async (req, res, { scope }) => {
3103
4217
  const name = named(req, res); if (name === null) return;
@@ -3226,7 +4340,14 @@ app.post('/api/workspaces', async (req, res) => {
3226
4340
  // records, if there is exactly one; every other case is "Choose…" on the workspace card.
3227
4341
  const explicit = typeof body.metricsProject === 'string' && body.metricsProject ? body.metricsProject : null;
3228
4342
  const metricsProject = explicit ?? await autoMetricsHome(projectPaths);
3229
- const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description, metricsProject });
4343
+ // Team policy home (design §9): defaults to the metrics home when that member's policy
4344
+ // resolves, else the one member (or shared home) whose policy does; else unset.
4345
+ let policyProject = typeof body.policyProject === 'string' && body.policyProject ? body.policyProject : null;
4346
+ if (!policyProject) {
4347
+ const viaMetrics = metricsProject ? await resolveProjectPolicy(metricsProject, { discover: false }).catch(() => null) : null;
4348
+ policyProject = viaMetrics?.ok ? metricsProject : await autoPolicyHome({ projectPaths }).catch(() => null);
4349
+ }
4350
+ const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description, metricsProject, policyProject });
3230
4351
  emitChanged('workspaces-changed', 'created');
3231
4352
  res.status(201).json({ workspace, metricsHomeAuto: !explicit && !!metricsProject });
3232
4353
  } catch (err) {
@@ -3253,9 +4374,16 @@ app.patch('/api/workspaces/:id', async (req, res) => {
3253
4374
  }
3254
4375
  patch.metricsProject = body.metricsProject;
3255
4376
  }
4377
+ if ('policyProject' in body) {
4378
+ if (body.policyProject !== null && typeof body.policyProject !== 'string') {
4379
+ return badRequest(res, 'policyProject must be a member project path or null');
4380
+ }
4381
+ patch.policyProject = body.policyProject;
4382
+ }
3256
4383
  try {
3257
4384
  const workspace = await updateWorkspace(id, patch);
3258
4385
  if ('metricsProject' in patch) emitChanged('workspaces-changed', 'metrics-home');
4386
+ if ('policyProject' in patch) { emitChanged('workspaces-changed', 'policy-home'); emitChanged('team-policy-changed', 'policy-home'); }
3259
4387
  res.json({ workspace });
3260
4388
  } catch (err) {
3261
4389
  const status = workspaceErrorStatus(err && err.code);
@@ -3274,6 +4402,8 @@ app.delete('/api/workspaces/:id', async (req, res) => {
3274
4402
  if (live) return res.status(409).json({ error: 'cannot delete a workspace with a live run or scan' });
3275
4403
  try {
3276
4404
  const report = await deleteWorkspace(id);
4405
+ try { if (cancelForTarget({ workspaceId: id })) emitChanged('schedules-changed', 'target-removed'); }
4406
+ catch (err) { console.error(`[worca-ui] schedule cleanup failed: ${err && err.message ? err.message : err}`); }
3277
4407
  emitChanged('workspaces-changed', 'deleted');
3278
4408
  res.json({ ok: true, warnings: (report && report.warnings) || [] });
3279
4409
  } catch (err) {
@@ -3454,6 +4584,19 @@ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
3454
4584
  }
3455
4585
  });
3456
4586
 
4587
+ // The ask-form preview twin for a workspace run (see the project route above).
4588
+ // WORKSPACE_KEY_RE is the only validation the id needs; the key is composed here
4589
+ // and never taken from the client, so there is no path-traversal surface.
4590
+ app.get('/api/workspaces/:wid/runs/:id/ask-files/:askId/:index', async (req, res) => {
4591
+ if (!WORKSPACE_KEY_RE.test(req.params.wid)) return res.status(404).json({ error: 'pipeline not found' });
4592
+ try {
4593
+ await serveAskFile(res, await askFilesStoreDir(`workspaces/${req.params.wid}`, req.params.id),
4594
+ req.params.askId, req.params.index);
4595
+ } catch (err) {
4596
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4597
+ }
4598
+ });
4599
+
3457
4600
  // ---------------------------------------------------------------------------
3458
4601
  // GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
3459
4602
  // root : the configured Worca CC data-root base, '' when unset
@@ -3489,6 +4632,7 @@ const settingsState = () => ({
3489
4632
  pipelineCostLimitUsd: pipelineCostLimitUsd(),
3490
4633
  totalCostLimitUsd: totalCostLimitUsd(),
3491
4634
  costLimitResetPeriod: costLimitResetPeriod(),
4635
+ humanRateUsdPerHour: humanRateUsdPerHour(),
3492
4636
  askMaxTurns: askMaxTurns(),
3493
4637
  askMaxBudgetUsd: askMaxBudgetUsd(),
3494
4638
  debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
@@ -3497,6 +4641,10 @@ const settingsState = () => ({
3497
4641
  titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
3498
4642
  hideBuiltinModels: hideBuiltinModels(),
3499
4643
  theme: storedTheme(), // system | light | dark (dark-mode design §6)
4644
+ schedule: scheduleDefaults(), // defaults a NEW schedule inherits
4645
+ uiLevel: effectiveUiLevel(), // simple | advanced | expert (docs/ui-levels.md)
4646
+ memoryDefrag: memoryDefragModel(), // Settings › Memory: the STORED { model, effort } (null = the workflow default)
4647
+ memoryDefragDefault: defragDefaultModel(), // what "(default)" means there: the built-in's own model
3500
4648
  });
3501
4649
 
3502
4650
  /** Settings ▸ Auto workflow model: the stored id + what the classifier will actually use
@@ -3559,20 +4707,32 @@ app.get('/api/settings', async (_req, res) => {
3559
4707
  });
3560
4708
 
3561
4709
  app.get('/api/budget', (_req, res) => {
3562
- res.json(budgetStatus());
4710
+ const budget = budgetStatus();
4711
+ // The sidebar's "Saved this month" figure rides on this snapshot (money-saved design §10).
4712
+ // Additive and best-effort: a failed savings read must never cost the gate figures the
4713
+ // New-view Start button and every cost banner key on, so it degrades to nulls instead.
4714
+ let savings = { windowHumanHours: null, windowSavedUsd: null };
4715
+ try { savings = budgetWindowSavings(budget); } catch { /* keep the nulls */ }
4716
+ res.json({ ...budget, ...savings });
3563
4717
  });
3564
4718
 
3565
4719
  app.post('/api/settings', async (req, res) => {
3566
4720
  const body = req.body || {};
3567
4721
  const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
3568
4722
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
4723
+ const hasHumanRateKey = has('humanRateUsdPerHour');
3569
4724
  const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
3570
4725
  const hasDebugSpawnKey = has('debugSpawnEnabled');
3571
4726
  const hasTitleModelKey = has('titleModel');
3572
4727
  const hasHideBuiltinKey = has('hideBuiltinModels');
3573
4728
  const hasThemeKey = has('theme');
4729
+ const hasUiLevelKey = has('uiLevel');
3574
4730
  const hasAutoKey = has('autoWorkflowModel');
3575
4731
  const autoModels = hasAutoKey ? await listModels('') : null;
4732
+ // Settings › Memory: the defragment { model, effort } pair, checked against the same
4733
+ // project-less catalog the Settings pickers offer (a run re-checks it against its own).
4734
+ const hasMemoryDefragKey = has('memoryDefrag');
4735
+ const defragModels = hasMemoryDefragKey ? (autoModels || await listModels('')) : null;
3576
4736
  // #422: the title model is a SELECT over the catalog, so an id that is not a
3577
4737
  // catalog member is a client bug (or a stale option) — refuse it here rather
3578
4738
  // than store an id resolveModelEnv could never route.
@@ -3596,6 +4756,7 @@ app.post('/api/settings', async (req, res) => {
3596
4756
  if (has('askMaxBudgetUsd')) ask.askMaxBudgetUsd = body.askMaxBudgetUsd === undefined ? '' : body.askMaxBudgetUsd;
3597
4757
  try {
3598
4758
  assertCostLimitInputs(budget);
4759
+ if (hasHumanRateKey) assertHumanRateInput(body.humanRateUsdPerHour ?? '');
3599
4760
  assertAskLimitInputs(ask);
3600
4761
  if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
3601
4762
  if (hasTitleModelKey) {
@@ -3606,7 +4767,9 @@ app.post('/api/settings', async (req, res) => {
3606
4767
  }
3607
4768
  if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
3608
4769
  if (hasThemeKey) assertThemeInput(body.theme);
4770
+ if (hasUiLevelKey) assertUiLevelInput(body.uiLevel);
3609
4771
  if (hasAutoKey) assertAutoWorkflowModelInput(body.autoWorkflowModel ?? '', autoModels);
4772
+ if (hasMemoryDefragKey) assertMemoryDefragModelInput(body.memoryDefrag, defragModels);
3610
4773
  // Root first: it is the one key whose setter can still fail AFTER the asserts
3611
4774
  // above (an unusable path), so every other key's write must come after it or
3612
4775
  // a mixed POST would answer 400 with those keys already applied on disk.
@@ -3622,17 +4785,21 @@ app.post('/api/settings', async (req, res) => {
3622
4785
  if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
3623
4786
  if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
3624
4787
  if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
4788
+ if (hasHumanRateKey) await setHumanRateUsdPerHour(body.humanRateUsdPerHour ?? '');
3625
4789
  if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
3626
4790
  if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
3627
4791
  if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
3628
4792
  if (hasTitleModelKey) await setTitleModel(titleModelInput);
3629
4793
  if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
3630
4794
  if (hasThemeKey) await setTheme(body.theme);
4795
+ if (hasUiLevelKey) await setUiLevel(body.uiLevel);
3631
4796
  if (hasAutoKey) await setAutoWorkflowModel(body.autoWorkflowModel ?? '', { models: autoModels });
4797
+ if (hasMemoryDefragKey) await setMemoryDefragModel(body.memoryDefrag, { models: defragModels });
4798
+ if (has('schedule')) await setScheduleDefaults(body.schedule && typeof body.schedule === 'object' ? body.schedule : {});
3632
4799
  if (hasBudgetKey) emitChanged('budget-changed');
3633
4800
  // Other open tabs repaint their Settings cards (a stale tab could otherwise
3634
4801
  // "save" its old checkbox state over this one with no feedback to either).
3635
- if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasAutoKey) emitChanged('settings-changed');
4802
+ if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasUiLevelKey || hasAutoKey || hasHumanRateKey || hasMemoryDefragKey || has('schedule')) emitChanged('settings-changed');
3636
4803
  res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
3637
4804
  } catch (err) {
3638
4805
  // The setters throw only on an unusable path -> client error (400).
@@ -3820,17 +4987,42 @@ app.delete('/api/config/models', async (req, res) => {
3820
4987
  // ---------------------------------------------------------------------------
3821
4988
 
3822
4989
  const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
3823
- const maskedGlobalModel = (m) => (m.env
3824
- ? { ...m, env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) }
3825
- : m);
4990
+ // A bridged entry's `upstream.apiKey` is masked like an env secret; the rest of
4991
+ // the block is routing config and passes through. `bridged`/`needsSignIn` are
4992
+ // the picker/card facts (model-bridge-design.md §8.5), one readiness check
4993
+ // per provider per response.
4994
+ const bridgeFacts = (m, readiness) => {
4995
+ if (!m.upstream) return {};
4996
+ const p = m.upstream.provider;
4997
+ if (!readiness.has(p)) readiness.set(p, providerReadiness(m.upstream));
4998
+ const r = readiness.get(p);
4999
+ return { bridged: p, needsSignIn: !r.ok, ...(r.ok ? {} : { signInReason: r.reason, signInMessage: r.message }) };
5000
+ };
5001
+ const maskedGlobalModel = (m, readiness = new Map()) => ({
5002
+ ...m,
5003
+ ...(m.env ? { env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) } : {}),
5004
+ ...(m.upstream ? { upstream: { ...m.upstream, ...(m.upstream.apiKey ? { apiKey: maskEnvValue(m.upstream.apiKey) } : {}) } } : {}),
5005
+ ...bridgeFacts(m, readiness),
5006
+ });
3826
5007
  const isMaskedEcho = (v) => typeof v === 'string' && v.startsWith('••');
3827
5008
  const maskedGlobalModels = () => {
3828
5009
  const flagged = costUnreliableModelIds(); // §4.6 observed flag, merged for the editor's badge
5010
+ const readiness = new Map();
3829
5011
  return listGlobalModels().map((m) => ({
3830
- ...maskedGlobalModel(m),
5012
+ ...maskedGlobalModel(m, readiness),
3831
5013
  ...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
3832
5014
  }));
3833
5015
  };
5016
+ /** A POST/PATCH `upstream` body with a masked apiKey echo dropped ("keep"). */
5017
+ const upstreamInput = (u, current) => {
5018
+ if (!u || typeof u !== 'object' || Array.isArray(u)) return u;
5019
+ if (isMaskedEcho(u.apiKey)) {
5020
+ const keep = current && current.upstream ? current.upstream.apiKey : undefined;
5021
+ const { apiKey, ...rest } = u;
5022
+ return keep ? { ...rest, apiKey: keep } : rest;
5023
+ }
5024
+ return u;
5025
+ };
3834
5026
 
3835
5027
  /** Read-only plugin model entries (design §9.7): literals masked with the
3836
5028
  * standard masker, ${VAR} refs readable, {secret} placeholders surfaced as
@@ -3838,6 +5030,7 @@ const maskedGlobalModels = () => {
3838
5030
  const pluginModelsPayload = () => {
3839
5031
  const flagged = costUnreliableModelIds();
3840
5032
  const statusByPlugin = new Map();
5033
+ const readiness = new Map();
3841
5034
  return listPluginModels().map((m) => {
3842
5035
  if (!statusByPlugin.has(m.plugin)) statusByPlugin.set(m.plugin, pluginModelSecretStatus(m.plugin));
3843
5036
  const status = statusByPlugin.get(m.plugin);
@@ -3848,30 +5041,153 @@ const pluginModelsPayload = () => {
3848
5041
  ])),
3849
5042
  secrets: status.filter((s) => m.secrets.includes(s.key)),
3850
5043
  ...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing — config, never a credential
5044
+ ...(m.upstream ? { upstream: { ...m.upstream } } : {}), // a plugin apiKey is a ${VAR} ref by validation — readable
5045
+ ...bridgeFacts(m, readiness),
3851
5046
  ...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
3852
5047
  };
3853
5048
  });
3854
5049
  };
3855
5050
 
3856
5051
  app.get('/api/models', (req, res) => {
5052
+ const readiness = new Map();
3857
5053
  res.json({
3858
5054
  models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS,
3859
5055
  hideBuiltinModels: hideBuiltinModels(), // the Models-view checkbox (#422)
5056
+ // Team policy catalog entries (team-policy design §8): read-only, env masked like a global's.
5057
+ policy: policyCatalogModels().map((m) => maskedGlobalModel(m, readiness)),
5058
+ providers: UPSTREAM_PROVIDERS,
3860
5059
  });
3861
5060
  });
3862
5061
 
3863
5062
  app.post('/api/models', async (req, res) => {
3864
5063
  const b = req.body || {};
3865
5064
  try {
3866
- const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost });
5065
+ const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost, upstream: upstreamInput(b.upstream) });
3867
5066
  res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
3868
5067
  } catch (err) {
3869
5068
  // addGlobalModel throws only on validation (empty/dup id, unknown effort,
3870
- // reserved env key, non-string env value) -> client error.
5069
+ // reserved env key, non-string env value, malformed upstream) -> client error.
3871
5070
  return badRequest(res, err && err.message ? err.message : String(err));
3872
5071
  }
3873
5072
  });
3874
5073
 
5074
+ // ---------------------------------------------------------------------------
5075
+ // Providers + the Copilot import (model-bridge-design.md §9). Never a token in
5076
+ // a response; the sign-in is a device-flow session the client polls.
5077
+ // ---------------------------------------------------------------------------
5078
+ const providerError = (res, err) => {
5079
+ const msg = err && err.message ? err.message : String(err);
5080
+ if (err && (err.code === 'TERMS' || err.code === 'NOT_SIGNED_IN')) return res.status(409).json({ error: msg, code: err.code });
5081
+ return badRequest(res, msg);
5082
+ };
5083
+
5084
+ app.get('/api/providers', async (req, res) => {
5085
+ try {
5086
+ res.json(await providersState({ quota: req.query.quota === '1' }));
5087
+ } catch (err) {
5088
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
5089
+ }
5090
+ });
5091
+
5092
+ app.patch('/api/providers/:name', async (req, res) => {
5093
+ try {
5094
+ await patchProvider(req.params.name, req.body || {});
5095
+ emitChanged('settings-changed');
5096
+ res.json(await providersState());
5097
+ } catch (err) {
5098
+ return providerError(res, err);
5099
+ }
5100
+ });
5101
+
5102
+ app.post('/api/providers/copilot/acknowledge', async (req, res) => {
5103
+ try {
5104
+ await acknowledgeTerms();
5105
+ res.json(await providersState());
5106
+ } catch (err) {
5107
+ return providerError(res, err);
5108
+ }
5109
+ });
5110
+
5111
+ app.post('/api/providers/copilot/login', async (req, res) => {
5112
+ try {
5113
+ res.json(await beginCopilotLogin());
5114
+ } catch (err) {
5115
+ return providerError(res, err);
5116
+ }
5117
+ });
5118
+
5119
+ app.get('/api/providers/copilot/login/:deviceCode', async (req, res) => {
5120
+ try {
5121
+ const r = await pollCopilotLogin(String(req.params.deviceCode));
5122
+ if (r.ok) emitChanged('settings-changed');
5123
+ res.json(r);
5124
+ } catch (err) {
5125
+ return providerError(res, err);
5126
+ }
5127
+ });
5128
+
5129
+ app.post('/api/providers/copilot/logout', async (req, res) => {
5130
+ try {
5131
+ await copilotLogout();
5132
+ emitChanged('settings-changed');
5133
+ res.json(await providersState());
5134
+ } catch (err) {
5135
+ return providerError(res, err);
5136
+ }
5137
+ });
5138
+
5139
+ app.get('/api/providers/copilot/models', async (req, res) => {
5140
+ try {
5141
+ res.json({ models: await copilotModelsForImport() });
5142
+ } catch (err) {
5143
+ return providerError(res, err);
5144
+ }
5145
+ });
5146
+
5147
+ // What an OpenAI-compatible endpoint serves (§8.4): llama.cpp, Ollama, LM Studio, vLLM or a
5148
+ // gateway, asked on its own surface so the rows carry real context windows and tool support.
5149
+ // ?baseUrl= overrides the provider's own, so a second local server can be browsed without
5150
+ // saving it first. Same reach as Test connection: the UI is loopback-only.
5151
+ app.get('/api/providers/openai/models', async (req, res) => {
5152
+ try {
5153
+ res.json(await endpointModelsForImport({ baseUrl: typeof req.query.baseUrl === 'string' ? req.query.baseUrl : '' }));
5154
+ } catch (err) {
5155
+ return providerError(res, err);
5156
+ }
5157
+ });
5158
+
5159
+ app.post('/api/providers/openai/import-models', async (req, res) => {
5160
+ const b = req.body || {};
5161
+ try {
5162
+ const result = await importEndpointModels(b.ids, { baseUrl: typeof b.baseUrl === 'string' ? b.baseUrl : '' });
5163
+ emitChanged('settings-changed');
5164
+ res.json({ ...result, models: maskedGlobalModels() });
5165
+ } catch (err) {
5166
+ return providerError(res, err);
5167
+ }
5168
+ });
5169
+
5170
+ app.post('/api/providers/:name/test', async (req, res) => {
5171
+ // The card tests what is ON SCREEN (§8.1): an unsaved base URL or key is sent with the request,
5172
+ // so "Test connection" answers for the endpoint the user is looking at, not the stored one.
5173
+ const b = req.body || {};
5174
+ res.json(await testProviderConnection(req.params.name, {
5175
+ baseUrl: typeof b.baseUrl === 'string' ? b.baseUrl : '',
5176
+ ...(typeof b.apiKey === 'string' ? { apiKey: b.apiKey } : {}),
5177
+ }));
5178
+ });
5179
+
5180
+ app.post('/api/providers/copilot/import-models', async (req, res) => {
5181
+ const b = req.body || {};
5182
+ if (b.provider && b.provider !== 'copilot') return badRequest(res, 'only the copilot provider supports import');
5183
+ try {
5184
+ const result = await importCopilotModels(b.ids);
5185
+ res.json({ ...result, models: maskedGlobalModels() });
5186
+ } catch (err) {
5187
+ return providerError(res, err);
5188
+ }
5189
+ });
5190
+
3875
5191
  // Promote a legacy per-project custom model to the global catalog (§4.9).
3876
5192
  // Refs survive by construction — see promoteCustomModel. Registered before the
3877
5193
  // :id routes only for readability; POST /api/models/promote shares no method
@@ -4020,7 +5336,8 @@ app.patch('/api/models/:id', async (req, res) => {
4020
5336
  env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
4021
5337
  }
4022
5338
  try {
4023
- const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost });
5339
+ const current = b.upstream ? listGlobalModels().find((m) => m.id.toLowerCase() === String(req.params.id).toLowerCase()) : null;
5340
+ const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost, upstream: upstreamInput(b.upstream, current) });
4024
5341
  res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
4025
5342
  } catch (err) {
4026
5343
  // updateGlobalModel throws only on validation (unknown id, unknown effort,
@@ -4052,6 +5369,9 @@ app.get('/api/models/:id/env-value', (req, res) => {
4052
5369
  app.delete('/api/models/:id', async (req, res) => {
4053
5370
  try {
4054
5371
  const result = await removeGlobalModelAndRefs(req.params.id);
5372
+ // Settings › Memory's defragment model went with the entry: open tabs repaint that card (as
5373
+ // the Ask remove_model path's settings-changed does).
5374
+ if (result.clearedMemoryDefrag) emitChanged('settings-changed');
4055
5375
  res.json({ ...result, models: maskedGlobalModels() });
4056
5376
  } catch (err) {
4057
5377
  // Throws only on an unknown id -> client error.
@@ -4124,6 +5444,9 @@ function sendWorkflowShareError(res, err) {
4124
5444
  // line, verbatim.
4125
5445
  if (code === 'RESERVED_NAME') return res.status(422).json({ error: message });
4126
5446
  if (code === 'ID_TAKEN') return res.status(409).json({ error: message, id: err.id });
5447
+ // P10: a script import the user has not confirmed is well-formed but conflicts
5448
+ // with a confirmation not yet given; `scriptNodes` are the commands to show first.
5449
+ if (code === 'SCRIPTS_UNCONFIRMED') return res.status(409).json({ error: message, code, scriptNodes: err.scriptNodes || [] });
4127
5450
  if (code === 'NOT_FOUND') return res.status(404).json({ error: message });
4128
5451
  return res.status(500).json({ error: message });
4129
5452
  }
@@ -4149,7 +5472,15 @@ app.get('/api/workflows/:id', async (req, res) => {
4149
5472
  // this is a READ (the Composer's Open): a template stranded by an agent-port
4150
5473
  // edit must still load, or the user could never repair it. The RUN path keeps
4151
5474
  // the graph check.
4152
- res.json(await assertRunnableWorkflow(req.params.id, { checkGraph: false }));
5475
+ const wf = await assertRunnableWorkflow(req.params.id, { checkGraph: false });
5476
+ // Settings › Memory: the built-in reads with the pair every defragment run will use, so New
5477
+ // pipeline's agent rows and an Ask card's lane show — and lock — it (memory-defrag-model.mjs).
5478
+ if (wf && wf.id === MEMORY_DEFRAG_WORKFLOW_ID) {
5479
+ const stored = memoryDefragModel();
5480
+ const pair = stored.model ? resolveDefragModel({ stored, models: await listModels('') }) : null;
5481
+ return res.json(defragWorkflowView(wf, pair));
5482
+ }
5483
+ res.json(wf);
4153
5484
  } catch (err) {
4154
5485
  if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
4155
5486
  return res.status(404).json({ error: err.message });
@@ -4193,9 +5524,13 @@ app.post('/api/workflows/import-json', async (req, res) => {
4193
5524
  const src = body.workflow && typeof body.workflow === 'object' && !Array.isArray(body.workflow) ? body.workflow : null;
4194
5525
  if (!src) return badRequest(res, 'workflow (the exported JSON object) is required');
4195
5526
  try {
5527
+ // D18: a dry run validates and lists the script commands the Import dialog must show first.
5528
+ const dryRun = body.dryRun === true;
4196
5529
  const r = await importGraphWorkflow(src, {
4197
- name: typeof body.name === 'string' ? body.name : undefined, agentsDir: AGENTS_DIR,
5530
+ name: typeof body.name === 'string' ? body.name : undefined, agentsDir: AGENTS_DIR, dryRun,
5531
+ acceptScripts: body.acceptScripts === true,
4198
5532
  });
5533
+ if (dryRun) return res.json({ scriptNodes: r.scriptNodes, warnings: r.warnings, requestedName: r.requestedName });
4199
5534
  return res.status(201).json(r);
4200
5535
  } catch (err) {
4201
5536
  return sendWorkflowShareError(res, err);
@@ -4344,7 +5679,8 @@ function sendGuardrailError(res, err) {
4344
5679
  app.get('/api/guardrails', async (_req, res) => {
4345
5680
  try {
4346
5681
  const sets = await listGuardrailSets(); // CONV-1: await
4347
- res.json({ guardrails: [...listBuiltinGuardrailSets(), ...sets] });
5682
+ // Team policy sets (gp:<id>, origin policy:<home>) sit between the built-ins and the user's own.
5683
+ res.json({ guardrails: [...listBuiltinGuardrailSets(), ...listPolicyGuardrailSets(), ...sets] });
4348
5684
  } catch (err) {
4349
5685
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
4350
5686
  }
@@ -4410,6 +5746,7 @@ app.delete('/api/guardrails/:id', async (req, res) => {
4410
5746
  const id = req.params.id;
4411
5747
  // Built-ins are not in the user store and must never be deleted.
4412
5748
  if (isBuiltinGuardrailSetId(id)) return badRequest(res, 'built-in guardrail sets cannot be deleted');
5749
+ if (isPolicyGuardrailSetId(id)) return badRequest(res, 'team policy guardrail sets are edited on the Team policy page, not deleted here');
4413
5750
  try {
4414
5751
  const removed = await deleteGuardrailSet(id); // CONV-1: await; throws ReferencedError while pinned
4415
5752
  if (!removed) return res.status(404).json({ error: 'guardrail set not found' });
@@ -4884,10 +6221,25 @@ function askValidateScope(raw) {
4884
6221
  return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
4885
6222
  }
4886
6223
 
4887
- /** The system prompt of ONE Ask turn: the rules and the catalog, byte-stable. Memory is mounted,
4888
- * not rendered (native-rules revision) — see createAskTurn's memoryProject. */
6224
+ /** The system prompt of ONE Ask turn: the rules, the catalog, and — only when the chat's
6225
+ * "Create and run scripts" pref is on (W20) — the scripts section with the runtimes this host
6226
+ * actually has (the python probe, cached 60 s). Memory is mounted, not rendered. */
4889
6227
  async function askSystemPromptFor(catalog) {
4890
- return askBuildSystemPrompt(catalog);
6228
+ return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput() });
6229
+ }
6230
+
6231
+ /** "scheduled Sat Sep 19, 02:00 (run 1a2b…)" / "repeats: Every weekday at 02:00 (sch_…)" / "proposes: …" — or ''. */
6232
+ function askCardScheduleLine(b, tz = null) {
6233
+ if (b.state === 'scheduled' && b.scheduleId) return `repeats: ${b.sentence || ''} (${b.scheduleId})`;
6234
+ if (b.state === 'scheduled' && b.after) return `after ‘${b.after.title || b.after.id}’ (run ${b.runId})`;
6235
+ if (b.state === 'scheduled' && b.runId) {
6236
+ const ms = Date.parse(b.scheduledFor || '');
6237
+ const when = Number.isFinite(ms) ? formatInstant(ms, isValidTimeZone(tz) ? tz : Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC') : '?';
6238
+ return `scheduled for ${when} (run ${b.runId})`;
6239
+ }
6240
+ const s = b.card && b.card.schedule;
6241
+ if (b.state === 'proposed' && s) return s.kind === 'repeat' ? `proposes: ${s.sentence}` : s.kind === 'after' ? `proposes: ${s.text}` : `proposes: once at ${s.when}`;
6242
+ return '';
4891
6243
  }
4892
6244
 
4893
6245
  /** Resolve the VALIDATED client context into the server-side shape
@@ -4897,6 +6249,7 @@ async function askSystemPromptFor(catalog) {
4897
6249
  async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
4898
6250
  const out = { now: new Date().toISOString() };
4899
6251
  if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
6252
+ if (ctx.timeZone) out.timeZone = ctx.timeZone; // validated IANA name; the header adds the user's clock
4900
6253
  if (ctx.view) out.view = ctx.view;
4901
6254
  if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
4902
6255
  try {
@@ -4934,6 +6287,25 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4934
6287
  }
4935
6288
  }
4936
6289
  } catch { /* absent line */ }
6290
+ // The Team policy page's scope (validated slug): its name and, from the local cache only (no git), its home.
6291
+ try {
6292
+ if (ctx.tpScope) {
6293
+ const [kind, tpId] = ctx.tpScope.split(':');
6294
+ if (kind === 'project') {
6295
+ const p = (await listProjects()).find((x) => x.key === tpId);
6296
+ if (p) {
6297
+ const r = await resolveProjectPolicy(p.path, { discover: false }).catch(() => null);
6298
+ out.teamPolicy = { kind, id: tpId, name: p.name, home: r && r.ok ? r.home : null };
6299
+ }
6300
+ } else if (kind === 'workspace') {
6301
+ const ws = await readWorkspace(tpId);
6302
+ if (ws) {
6303
+ const r = await resolveWorkspacePolicy(ws, { discover: false }).catch(() => null);
6304
+ out.teamPolicy = { kind, id: tpId, name: ws.name, home: r && r.ok ? r.home : null };
6305
+ }
6306
+ }
6307
+ }
6308
+ } catch { /* absent line */ }
4937
6309
  try {
4938
6310
  if (ctx.pipelineId) {
4939
6311
  const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
@@ -4968,8 +6340,12 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4968
6340
  // workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
4969
6341
  const wf = !!(b.card && b.card.type === 'workflow');
4970
6342
  if (wf && b.state === 'building') continue; // transient (no name yet) — never worth a header line
4971
- if (b.card && b.card.type === 'metrics') {
4972
- cards.push({ id: b.id, type: 'metrics', state: b.state, summary: b.card.summary || '' });
6343
+ if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy')) {
6344
+ cards.push({ id: b.id, type: b.card.type, state: b.state, summary: b.card.summary || '' });
6345
+ continue;
6346
+ }
6347
+ if (b.card && b.card.type === 'schedule') {
6348
+ cards.push({ id: b.id, type: 'schedule', state: b.state, summary: b.card.summary || '' });
4973
6349
  continue;
4974
6350
  }
4975
6351
  cards.push(wf
@@ -4980,6 +6356,9 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4980
6356
  : {
4981
6357
  id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
4982
6358
  targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
6359
+ // A scheduled (or schedule-proposing) run card says when, so the model never re-proposes it.
6360
+ ...(askCardScheduleLine(b, ctx.timeZone) ? { schedule: askCardScheduleLine(b, ctx.timeZone) } : {}),
6361
+ ...(b.card && b.card.source ? { task: `${b.card.source.plugin}/${b.card.source.sourceId} ${b.card.source.taskId}` } : {}),
4983
6362
  });
4984
6363
  }
4985
6364
  }
@@ -5110,6 +6489,7 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
5110
6489
  firstText: text,
5111
6490
  deterministicTitle,
5112
6491
  pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
6492
+ timeZone: ctx.timeZone || (thread.context && thread.context.timeZone) || null, // scheduled runs: the user's clock
5113
6493
  memoryProject: headerCtx.project ? { key: headerCtx.project.key, name: headerCtx.project.name || '' } : null, // native-rules revision: the turn mounts global + this project through --add-dir
5114
6494
  mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
5115
6495
  attachmentNames,
@@ -5124,7 +6504,12 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
5124
6504
  onMemoryMutation: ({ scope }) => {
5125
6505
  if (scope === 'global' || (typeof scope === 'string' && scope.startsWith('projects/') && PROJECT_KEY_RE.test(scope.slice('projects/'.length)))) emitMemoryChanged(scope);
5126
6506
  },
6507
+ // A save_script in the MCP child is the same change a REST write makes (spec §3.3):
6508
+ // the open Scripts tabs drop their list and the composer marks its script list dirty.
6509
+ onScriptMutation: () => { emitChanged('scripts-changed', 'updated'); },
5127
6510
  trackRun: (input, { pin } = {}) => askTrackRun(id, input, pin ?? null),
6511
+ // pause / resume / skip / mark-read in the MCP child: the Schedules page and the badges repaint.
6512
+ onScheduleMutation: () => { emitChanged('schedules-changed', 'ask'); emitChanged('notifications-changed'); },
5128
6513
  },
5129
6514
  });
5130
6515
  job.turn = turn;
@@ -5378,15 +6763,20 @@ async function startWorkflowEventTurn(threadId, block, { declined = false, thenR
5378
6763
  return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
5379
6764
  }
5380
6765
 
5381
- /** The metrics card's event turn: the synthetic notice row + the "[worca event] metrics card …" prompt (same queueing as workflow cards). */
6766
+ /** The metrics / policy / schedule card's event turn: the synthetic notice row + the
6767
+ * "[worca event] <type> card …" prompt (same queueing as workflow cards). */
5382
6768
  async function startMetricsEventTurn(threadId, block) {
5383
6769
  const thread = askGetThread(threadId);
5384
6770
  if (!thread) return null;
5385
6771
  const card = block.card || {};
5386
6772
  const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
5387
6773
  const result = card.result || null;
5388
- const text = metricsEventPrompt({ cardId: block.id, state, card, result });
5389
- const notice = metricsNoticeText({ state, card, result });
6774
+ // One event turn for every non-workflow card; the type picks the wording. Metrics is the fallback.
6775
+ const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' ? card.type : 'metrics';
6776
+ const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, metrics: metricsEventPrompt }[kind];
6777
+ const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, metrics: metricsNoticeText }[kind];
6778
+ const text = eventPrompt({ cardId: block.id, state, card, result });
6779
+ const notice = noticeText({ state, card, result });
5390
6780
  let mv = await validateModelEffort(thread.model, thread.effort);
5391
6781
  if (!mv.ok) {
5392
6782
  const d = (await askCatalog({ withSecrets: false })).default;
@@ -5420,10 +6810,34 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
5420
6810
  const body = req.body || {};
5421
6811
  const found = askFindCard(id, cardId);
5422
6812
  if (!found) return res.status(404).json({ error: 'card not found' });
5423
- if (found.block.card && found.block.card.type === 'metrics') {
5424
- // Metrics card (docs/team-metrics.md "Ask Worca"): proposed → applied | failed | declined. The change is
5425
- // the outward-facing part — a branch on origin, a marker on another repo, this machine's switch, the
5426
- // workspace's home — so it happens HERE, behind the click, never in the model's tool.
6813
+ if (found.block.card && found.block.card.type === 'schedule') {
6814
+ // Schedule card (docs/scheduled-runs.md "Ask Worca"): proposed → applied | failed | declined. The change —
6815
+ // start now, move, edit, cancel, delete — happens HERE, behind the click, through the same scheduleVerb
6816
+ // the Schedules page uses; never in the model's tool.
6817
+ if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
6818
+ if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
6819
+ if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
6820
+ if (body.state === 'declined') {
6821
+ const block = flipCard(id, cardId, { state: 'declined' });
6822
+ if (!block) return res.status(409).json({ error: 'card vanished' });
6823
+ const turn = await startMetricsEventTurn(id, block);
6824
+ return res.json({ block, turn });
6825
+ }
6826
+ askCardBusy.add(cardId);
6827
+ let block;
6828
+ try {
6829
+ let result;
6830
+ try { result = await applyScheduleCard(found.block.card); }
6831
+ catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
6832
+ block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
6833
+ } finally { askCardBusy.delete(cardId); }
6834
+ if (!block) return res.status(409).json({ error: 'card vanished' });
6835
+ const turn = await startMetricsEventTurn(id, block);
6836
+ return res.json({ block, turn });
6837
+ }
6838
+ if (found.block.card && found.block.card.type === 'model') {
6839
+ // Model card (docs/models.md "Ask Worca"): proposed → applied | failed | declined. The catalog / provider
6840
+ // write happens HERE, behind the click, through the same setters the Models view uses — each re-validates.
5427
6841
  if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
5428
6842
  if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
5429
6843
  if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
@@ -5437,7 +6851,34 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
5437
6851
  let block;
5438
6852
  try {
5439
6853
  let result;
5440
- try { result = await applyMetricsChange(found.block.card); }
6854
+ try { result = await applyModelChange(found.block.card); emitChanged('settings-changed'); }
6855
+ catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
6856
+ block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
6857
+ } finally { askCardBusy.delete(cardId); }
6858
+ if (!block) return res.status(409).json({ error: 'card vanished' });
6859
+ const turn = await startMetricsEventTurn(id, block);
6860
+ return res.json({ block, turn });
6861
+ }
6862
+ if (found.block.card && (found.block.card.type === 'metrics' || found.block.card.type === 'policy')) {
6863
+ // Metrics / policy card (docs/team-metrics.md, docs/team-policy.md "Ask Worca"): proposed → applied | failed |
6864
+ // declined. The change is the outward-facing part — a branch on origin, a commit to the team's policy, a
6865
+ // marker on another repo, this machine's switch, the workspace's home — so it happens HERE, behind the
6866
+ // click, never in the model's tool.
6867
+ const apply = found.block.card.type === 'policy' ? applyPolicyChange : applyMetricsChange;
6868
+ if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
6869
+ if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
6870
+ if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
6871
+ if (body.state === 'declined') {
6872
+ const block = flipCard(id, cardId, { state: 'declined' });
6873
+ if (!block) return res.status(409).json({ error: 'card vanished' });
6874
+ const turn = await startMetricsEventTurn(id, block);
6875
+ return res.json({ block, turn });
6876
+ }
6877
+ askCardBusy.add(cardId);
6878
+ let block;
6879
+ try {
6880
+ let result;
6881
+ try { result = await apply(found.block.card); }
5441
6882
  catch (err) {
5442
6883
  result = { ok: false, error: err && err.message ? err.message : String(err), code: (err && err.code) || 'ERROR', ...(err && err.hint ? { hint: err.hint } : {}), ...(err && err.stderr ? { stderr: String(err.stderr).slice(0, 2000) } : {}) };
5443
6884
  }
@@ -5514,10 +6955,22 @@ function agentErrorStatus(code) {
5514
6955
  if (code === 'NOT_FOUND') return 404;
5515
6956
  if (code === 'BAD_REQUEST') return 400;
5516
6957
  if (code === 'PLUGIN') return 400;
6958
+ // A declared ask form that fails gate 1 is a well-formed request the server
6959
+ // understood and refused on content — the same 422 POST /api/answer uses for a
6960
+ // gate-3 failure (ask-forms spec §5, §11).
6961
+ if (code === 'ASK_FORM') return 422;
5517
6962
  if (code === 'BUILTIN' || code === 'DUPLICATE' || code === 'REFERENCED') return 409;
5518
6963
  return 500;
5519
6964
  }
5520
6965
 
6966
+ /** The error body: `{ error }` for every failure, plus the structured `errors`
6967
+ * list when the store produced one (gate 1). Never adds an empty `errors`. */
6968
+ function agentErrorBody(err) {
6969
+ const body = { error: err && err.message ? err.message : String(err) };
6970
+ if (Array.isArray(err?.errors) && err.errors.length) body.errors = err.errors;
6971
+ return body;
6972
+ }
6973
+
5521
6974
  /**
5522
6975
  * Fire-and-forget agent generation (mirrors startScan). Mints genId, registers
5523
6976
  * a kind:'agentgen' entry in the SAME runs Map, wires its agentgen-* events,
@@ -5619,7 +7072,7 @@ app.post('/api/agents', async (req, res) => {
5619
7072
  const created = await createAgent({ meta: body.meta, markdown: body.markdown });
5620
7073
  res.status(201).json(created);
5621
7074
  } catch (err) {
5622
- res.status(agentErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
7075
+ res.status(agentErrorStatus(err && err.code)).json(agentErrorBody(err));
5623
7076
  }
5624
7077
  });
5625
7078
 
@@ -5630,7 +7083,7 @@ app.put('/api/agents/:key', async (req, res) => {
5630
7083
  try {
5631
7084
  res.json(await updateAgent(key, { meta: body.meta, markdown: body.markdown }));
5632
7085
  } catch (err) {
5633
- res.status(agentErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
7086
+ res.status(agentErrorStatus(err && err.code)).json(agentErrorBody(err));
5634
7087
  }
5635
7088
  });
5636
7089
 
@@ -5640,10 +7093,269 @@ app.delete('/api/agents/:key', async (req, res) => {
5640
7093
  try {
5641
7094
  res.json(await deleteAgent(key));
5642
7095
  } catch (err) {
5643
- res.status(agentErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
7096
+ res.status(agentErrorStatus(err && err.code)).json(agentErrorBody(err));
7097
+ }
7098
+ });
7099
+
7100
+ // ---------------------------------------------------------------------------
7101
+ // /api/scripts* -> the script registry (spec §8.4): built-in scripts/ + the user
7102
+ // layer + enabled plugins, D16-filtered against the agent registry. The write half
7103
+ // (CRUD, duplicate, cases) is below, delegated to src/core/script-store.mjs.
7104
+ // ---------------------------------------------------------------------------
7105
+ function scriptRegistryNow() {
7106
+ return loadScriptRegistry({ agentKeys: Object.keys(loadAgentRegistry(AGENTS_DIR)) });
7107
+ }
7108
+
7109
+ /**
7110
+ * Workbench spec §7 / W17: a python card this host cannot run is flagged ON THE
7111
+ * WIRE, so the composer's V4 names it the moment the card is placed and the
7112
+ * Scripts list can chip it. The probe is 60 s-cached, so this costs at most one
7113
+ * spawn a minute. The REGISTRY is deliberately never stamped: it is loaded
7114
+ * synchronously and the probe is not, and a run must be gated by its own
7115
+ * preflight — with a fresh probe, at run time — not by a flag baked into a
7116
+ * snapshot taken when some browser last asked for a list.
7117
+ */
7118
+ async function stampRuntimeMissing(list) {
7119
+ if (!list.some((m) => m.runtime === 'python')) return list;
7120
+ const probe = await probePython();
7121
+ if (probe.ok) return list;
7122
+ return list.map((m) => (m.runtime === 'python' ? { ...m, runtimeMissing: true } : m));
7123
+ }
7124
+
7125
+ app.get('/api/scripts', async (req, res) => {
7126
+ try {
7127
+ // listScripts() IS the registry, in the same order, with caseCount stamped
7128
+ // (the list card's case chip). scriptRegistryNow() stays for the graph
7129
+ // routes, which need the raw index for registryPortsFn.
7130
+ res.json({ scripts: await stampRuntimeMissing(await listScripts()) });
7131
+ } catch (err) {
7132
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
7133
+ }
7134
+ });
7135
+
7136
+ // Literal segments are registered BEFORE /api/scripts/:key, so the param route
7137
+ // can never swallow them (the POST /api/agents/generate rule).
7138
+ app.get('/api/scripts/runtimes', async (req, res) => {
7139
+ // C8: the picker is wired once, against the real shape. The python arm is the
7140
+ // real probe (workbench spec §7; never rejects, 60 s-cached); node and shell are
7141
+ // guaranteed by the host itself.
7142
+ res.json({
7143
+ node: { ok: true, version: process.version },
7144
+ shell: { ok: true, path: process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh' },
7145
+ python: pythonRuntimeState(await probePython()),
7146
+ });
7147
+ });
7148
+
7149
+ /**
7150
+ * Fire-and-forget bench run (mirrors startAgentGen). Mints nothing itself: the
7151
+ * engine owns the id and tags every event with it, so the runs-Map key, the
7152
+ * buffered events and the `?benchId=` replay all agree on one id.
7153
+ * @returns {string} benchId
7154
+ */
7155
+ function startScriptBench(request) {
7156
+ const orch = createBench(request);
7157
+ const benchId = orch.id;
7158
+ const entry = {
7159
+ id: benchId, benchId, orch, kind: 'scriptbench', projectDir: null,
7160
+ title: `bench: ${request.key}`, status: 'running',
7161
+ startedAt: new Date().toISOString(), events: [], pendingQuestion: null, result: null, seq: 0,
7162
+ };
7163
+ runs.set(benchId, entry);
7164
+ // Nothing else ever deletes a bench entry (runs.delete fires for pipeline
7165
+ // lineages only), and Run is the page's main verb: keep the newest few so the
7166
+ // output route and a reconnect can still read them, drop the rest with their
7167
+ // <= 5000 buffered lines and 256 KiB-per-output results.
7168
+ const benches = [...runs.values()].filter((e) => e.kind === 'scriptbench');
7169
+ for (const old of benches.slice(0, Math.max(0, benches.length - MAX_BENCH_ENTRIES))) {
7170
+ if (old.status !== 'running') runs.delete(old.id);
7171
+ }
7172
+ wireScriptBench(entry);
7173
+
7174
+ Promise.resolve()
7175
+ .then(() => orch.run())
7176
+ .catch((err) => {
7177
+ // run() never throws (it emits scriptbench-error); this is startScan's
7178
+ // defensive backstop, surfacing an unexpected throw as a tagged error.
7179
+ // It carries the entry's next `seq` like every other frame (C17), or a
7180
+ // subscribe replay would deliver this one twice.
7181
+ entry.seq = (entry.seq || 0) + 1;
7182
+ const event = { benchId, seq: entry.seq, type: 'scriptbench-error', message: err && err.message ? err.message : String(err), code: null };
7183
+ entry.status = 'error';
7184
+ entry.events.push(event);
7185
+ broadcast(event);
7186
+ });
7187
+
7188
+ return benchId;
7189
+ }
7190
+
7191
+ // The §4.2 request rides through untouched: the engine validates it and reports
7192
+ // every refusal (unknown key, bad param, cap hit) as ONE scriptbench-error on
7193
+ // the socket. Only the key shape is checked here, so a malformed :key-shaped
7194
+ // value can never reach the store.
7195
+ app.post('/api/scripts/bench', (req, res) => {
7196
+ const body = req.body || {};
7197
+ const key = typeof body.key === 'string' ? body.key.trim() : '';
7198
+ if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
7199
+ try {
7200
+ res.json({ benchId: startScriptBench({ ...body, key }) });
7201
+ } catch (err) {
7202
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
7203
+ }
7204
+ });
7205
+
7206
+ // Idempotent stop (the /api/agents/generate/stop shape): an unknown or finished
7207
+ // bench still answers ok.
7208
+ app.post('/api/scripts/bench/stop', (req, res) => {
7209
+ const benchId = req.body && typeof req.body.benchId === 'string' ? req.body.benchId : '';
7210
+ const entry = benchId ? runs.get(benchId) : null;
7211
+ if (entry && entry.kind === 'scriptbench' && entry.orch && typeof entry.orch.stop === 'function') {
7212
+ try { entry.orch.stop(); } catch { /* best-effort */ }
7213
+ entry.status = 'stopped';
7214
+ }
7215
+ res.json({ ok: true });
7216
+ });
7217
+
7218
+ // The full text of one output (the WS result carries at most 256 KiB). The file
7219
+ // PATH comes from the entry's stored result — never from the URL — so this route
7220
+ // cannot be walked; `:port` is only a lookup key into that result.
7221
+ app.get('/api/scripts/bench/:benchId/output/:port', async (req, res) => {
7222
+ const entry = runs.get(req.params.benchId);
7223
+ if (!entry || entry.kind !== 'scriptbench' || !entry.result) return res.status(404).json({ error: 'bench not found' });
7224
+ if (!PORT_ID_RE.test(req.params.port)) return res.status(404).json({ error: 'output not found' });
7225
+ const caseId = typeof req.query.caseId === 'string' ? req.query.caseId : '';
7226
+ // A Run-all result is a list of per-case results (§4.3), so it needs ?caseId=.
7227
+ const result = Array.isArray(entry.result.cases)
7228
+ ? (entry.result.cases.find((c) => c.caseId === caseId) || {}).result
7229
+ : entry.result;
7230
+ // hasOwn: PORT_ID_RE admits `constructor` / `toString`, which a plain lookup would find on Object.prototype.
7231
+ const out = result && result.outputs && Object.hasOwn(result.outputs, req.params.port) ? result.outputs[req.params.port] : null;
7232
+ if (!out || !out.path) return res.status(404).json({ error: 'output not found' });
7233
+ // STREAMED, not read into a string: a bench output is whatever the script
7234
+ // wrote. readFile() buffered the whole file (a 600 MB log spiked the server by
7235
+ // half a gigabyte) and past ~512 MiB threw "Invalid string length", which this
7236
+ // catch then reported as a missing output.
7237
+ let st;
7238
+ try { st = await fsp.stat(out.path); } catch { return res.status(404).json({ error: 'output not found' }); }
7239
+ if (!st.isFile()) return res.status(404).json({ error: 'output not found' });
7240
+ res.type('text/plain; charset=utf-8').set('Content-Length', String(st.size));
7241
+ const stream = fs.createReadStream(out.path);
7242
+ stream.on('error', () => { res.destroy(); });
7243
+ res.on('close', () => stream.destroy());
7244
+ stream.pipe(res);
7245
+ });
7246
+
7247
+
7248
+ app.get('/api/scripts/:key', async (req, res) => {
7249
+ const key = req.params.key;
7250
+ if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
7251
+ try {
7252
+ const data = await readScript(key);
7253
+ if (!data) return res.status(404).json({ error: 'script not found' });
7254
+ // The meta is spread FLAT (the Scripts page and the composer read `key`,
7255
+ // `runtime`, `ports` off the top level); the store's reads ride beside it.
7256
+ res.json({
7257
+ ...data.meta,
7258
+ source: data.source,
7259
+ sourceWin32: data.sourceWin32,
7260
+ sourcePath: data.sourcePath,
7261
+ sourceTruncated: data.sourceTruncated,
7262
+ cases: data.cases,
7263
+ userCases: data.userCases,
7264
+ casesWritable: data.casesWritable,
7265
+ });
7266
+ } catch (err) {
7267
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
7268
+ }
7269
+ });
7270
+
7271
+
7272
+ // ---------------------------------------------------------------------------
7273
+ // The write half (spec §3.3–§3.4): user-layer CRUD, duplicate and cases,
7274
+ // delegated to src/core/script-store.mjs. The store's err.code vocabulary IS
7275
+ // the agent store's, so agentErrorStatus maps it unchanged. `by: 'ui'` stamps
7276
+ // every save from this process (W19); the CLI and Ask Worca call core directly
7277
+ // with their own stamp. Each mutation pokes every open tab with the bare
7278
+ // `scripts-changed` broadcast, so a script saved elsewhere appears without a
7279
+ // reload — the client drops its cached list and marks the palette dirty.
7280
+ // ---------------------------------------------------------------------------
7281
+ const STORE_ERROR_CODES = new Set(['NOT_FOUND', 'BAD_REQUEST', 'PLUGIN', 'BUILTIN', 'DUPLICATE', 'REFERENCED']);
7282
+ // Only the store's OWN vocabulary is quoted back to the page. An fs failure
7283
+ // (EACCES on the scripts dir, a full disk) carries the absolute home path in its
7284
+ // message, which no banner may surface (agent-store.mjs#propagateToVariants).
7285
+ const scriptError = (res, err) => {
7286
+ const code = err && err.code;
7287
+ return res
7288
+ .status(agentErrorStatus(code))
7289
+ .json({ error: STORE_ERROR_CODES.has(code) && err.message ? err.message : `the script store failed (${code || 'unknown error'})` });
7290
+ };
7291
+
7292
+ app.post('/api/scripts', async (req, res) => {
7293
+ const body = req.body || {};
7294
+ try {
7295
+ const created = await createScript({ meta: body.meta, source: body.source, sourceWin32: body.sourceWin32, by: 'ui' });
7296
+ emitChanged('scripts-changed', 'created');
7297
+ res.status(201).json(created);
7298
+ } catch (err) {
7299
+ scriptError(res, err);
7300
+ }
7301
+ });
7302
+
7303
+ app.put('/api/scripts/:key', async (req, res) => {
7304
+ const key = req.params.key;
7305
+ if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
7306
+ const body = req.body || {};
7307
+ try {
7308
+ const updated = await updateScript(key, { meta: body.meta, source: body.source, sourceWin32: body.sourceWin32, by: 'ui' });
7309
+ emitChanged('scripts-changed', 'updated');
7310
+ res.json(updated);
7311
+ } catch (err) {
7312
+ scriptError(res, err);
7313
+ }
7314
+ });
7315
+
7316
+ app.delete('/api/scripts/:key', async (req, res) => {
7317
+ const key = req.params.key;
7318
+ if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
7319
+ try {
7320
+ const r = await deleteScript(key);
7321
+ emitChanged('scripts-changed', 'deleted');
7322
+ res.json(r);
7323
+ } catch (err) {
7324
+ scriptError(res, err);
5644
7325
  }
5645
7326
  });
5646
7327
 
7328
+ app.post('/api/scripts/:key/duplicate', async (req, res) => {
7329
+ const key = req.params.key;
7330
+ if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
7331
+ const newKey = req.body && typeof req.body.newKey === 'string' ? req.body.newKey.trim() : '';
7332
+ if (!newKey) return badRequest(res, 'newKey is required');
7333
+ try {
7334
+ const copy = await duplicateScript(key, newKey, 'ui');
7335
+ emitChanged('scripts-changed', 'created');
7336
+ res.status(201).json(copy);
7337
+ } catch (err) {
7338
+ scriptError(res, err);
7339
+ }
7340
+ });
7341
+
7342
+ app.put('/api/scripts/:key/cases', async (req, res) => {
7343
+ const key = req.params.key;
7344
+ if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
7345
+ const cases = req.body && Array.isArray(req.body.cases) ? req.body.cases : null;
7346
+ if (!cases) return badRequest(res, 'cases must be an array');
7347
+ try {
7348
+ // W18: for a built-in or plugin key this lands in the user layer as an
7349
+ // overlay — a tests file with no meta beside it.
7350
+ const r = await writeCases(key, cases);
7351
+ emitChanged('scripts-changed', 'cases');
7352
+ res.json(r);
7353
+ } catch (err) {
7354
+ scriptError(res, err);
7355
+ }
7356
+ });
7357
+
7358
+
5647
7359
  // ---------------------------------------------------------------------------
5648
7360
  // /api/plugins* -> plugin lifecycle, delegated to src/core/plugin-store.mjs /
5649
7361
  // plugin-repo.mjs / plugin-config.mjs (spec §6). Thin wrappers: all policy
@@ -5710,13 +7422,19 @@ function readInstalledManifest(name) {
5710
7422
  }
5711
7423
  }
5712
7424
 
5713
- app.get('/api/plugins', (req, res) => {
7425
+ app.get('/api/plugins', async (req, res) => {
5714
7426
  try {
5715
7427
  const mkts = readMarketplaces().marketplaces;
7428
+ const rows = listInstalledPlugins();
7429
+ // The python notice (spec §8.1) is a fact about THIS host: resolved once per
7430
+ // request, and only when some plugin ships a python script (the probe caches 60 s).
7431
+ const anyPython = rows.some((p) => Number((p.scriptRuntimes || {}).python) > 0);
7432
+ const notice = anyPython ? await pythonNoticeFor([{ runtime: 'python' }]) : null;
5716
7433
  res.json({
5717
- plugins: listInstalledPlugins().map((p) => ({
7434
+ plugins: rows.map((p) => ({
5718
7435
  ...p,
5719
7436
  marketplaceName: p.marketplace && mkts[p.marketplace] ? mkts[p.marketplace].name : null,
7437
+ pythonMissing: !!(notice && Number((p.scriptRuntimes || {}).python) > 0),
5720
7438
  })),
5721
7439
  orphans: listOrphanPluginData(),
5722
7440
  });
@@ -6357,7 +8075,7 @@ app.post('/api/pipelines/:id/report-issue', async (req, res) => {
6357
8075
  });
6358
8076
 
6359
8077
  // ---------------------------------------------------------------------------
6360
- // Install logic (mirrors scripts/install.mjs): copy agents/*.md and
8078
+ // Install logic (mirrors tools/install.mjs): copy agents/*.md and
6361
8079
  // skills/worca/** into <projectDir>/.claude/...
6362
8080
  // ---------------------------------------------------------------------------
6363
8081
  async function installAgents(projectDir) {
@@ -6438,9 +8156,9 @@ async function copyDir(srcDir, destDir, baseForRel, copiedOut) {
6438
8156
  * @param {Array<{name?:string, dataBase64?:string}>} list
6439
8157
  * @returns {Promise<string[]>}
6440
8158
  */
6441
- async function writeExtras(runId, list) {
8159
+ async function writeExtras(runId, list, dirOverride = null) {
6442
8160
  if (!Array.isArray(list) || list.length === 0) return [];
6443
- const dir = path.join(os.tmpdir(), `orchestrator-extras-${runId}`);
8161
+ const dir = dirOverride || path.join(os.tmpdir(), `orchestrator-extras-${runId}`);
6444
8162
  await fsp.mkdir(dir, { recursive: true });
6445
8163
  const out = [];
6446
8164
  let i = 0;
@@ -6553,7 +8271,7 @@ app.use((err, _req, res, next) => {
6553
8271
  * sweep keeps its own console default.
6554
8272
  */
6555
8273
  export async function bootMaintenance({ log } = {}) {
6556
- const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null };
8274
+ const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null, bench: null };
6557
8275
  const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
6558
8276
 
6559
8277
  // Runs left 'running' by a previous process that died before writing a terminal
@@ -6645,6 +8363,19 @@ export async function bootMaintenance({ log } = {}) {
6645
8363
  } catch (err) {
6646
8364
  console.error(`[worca-ui] ask-worktree sweep failed: ${err && err.message ? err.message : err}`);
6647
8365
  }
8366
+
8367
+ // Script bench folders (workbench W11): the newest folder per script key
8368
+ // outlives its run so the output tabs can still read it; 24 h later it is
8369
+ // junk. os.tmpdir() would have been cleaned under us on macOS, hence
8370
+ // <worcaHome>/bench and this sweep.
8371
+ try {
8372
+ const removed = await sweepBenchDirs(benchRoot());
8373
+ summary.bench = { removed: removed.length };
8374
+ if (removed.length) console.log(`[worca-ui] bench sweep: removed ${removed.length} folder(s)`);
8375
+ } catch (err) {
8376
+ summary.bench = { removed: 0 };
8377
+ console.error(`[worca-ui] bench sweep failed: ${err && err.message ? err.message : err}`);
8378
+ }
6648
8379
  return summary;
6649
8380
  }
6650
8381
 
@@ -6710,15 +8441,38 @@ if (isMain) {
6710
8441
  }
6711
8442
  try { startTeamMetricsBackground({ log: (m) => console.warn(m) }); }
6712
8443
  catch (err) { console.warn(`[worca-ui] team metrics background: ${err?.message || err}`); }
8444
+ // Model bridge (model-bridge-design.md §4.1): up before the first bridged
8445
+ // spawn so resolveModelEnv's synchronous start is the exception, not the rule.
8446
+ startBridge({ log: (m) => console.warn(m) }).catch((err) => console.warn(`[worca-ui] model bridge: ${err?.message || err}`));
8447
+ // Team policy discovery (design §9), then the marketplace seeding a policy asks for — metadata
8448
+ // only (a git archive), never an install; installs go through the setup checklist.
8449
+ try {
8450
+ startTeamPolicyBackground({
8451
+ log: (m) => console.warn(m),
8452
+ onTick: async () => {
8453
+ const scopes = await listPolicyScopes();
8454
+ const docs = [];
8455
+ for (const s of scopes.projects) if (s.home && !docs.some((d) => d.slug === s.home)) {
8456
+ const r = await resolveProjectPolicy(s.path, { discover: false }).catch(() => null);
8457
+ if (r?.ok) docs.push({ slug: r.home, doc: r.doc });
8458
+ }
8459
+ const seeded = await seedPolicyMarketplaces(docs);
8460
+ for (const s of seeded) if (s.added) { console.warn(`[worca-ui] team policy: added marketplace ${s.url} (asked for by ${s.homes.join(', ')})`); emitChanged('plugins-changed', 'marketplace-seeded'); }
8461
+ },
8462
+ });
8463
+ } catch (err) { console.warn(`[worca-ui] team policy background: ${err?.message || err}`); }
8464
+ // Scheduled runs: boot catch-up + the 30 s tick (the server IS the scheduler).
8465
+ try { startScheduler(); } catch (err) { console.warn(`[worca-ui] scheduler: ${err?.message || err}`); }
6713
8466
  });
6714
8467
  }
6715
8468
 
6716
8469
  export { app, server, runs };
6717
8470
  export const _testing = {
6718
- wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
6719
- chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
8471
+ wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, wireScriptBench, startScriptBench,
8472
+ chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, answerRun,
6720
8473
  chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
6721
8474
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
6722
8475
  askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
6723
8476
  uiControl, bearerMatches,
8477
+ broadcast, askFilesRunDir,
6724
8478
  };