@worca/app 1.2.0 → 1.3.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 (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
package/ui/server.mjs CHANGED
@@ -21,19 +21,20 @@ import { preflightNode } from '../src/core/preflight-node.mjs';
21
21
  import { createOrchestratorFor } from '../src/core/engine-select.mjs';
22
22
  import {
23
23
  listPipelines, readPipeline, listAllPipelines, readPipelineByKey,
24
- enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState,
24
+ enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState, readPrState,
25
25
  readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
26
- listArtifacts, lookupPipelineRow, findPipelineRowById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
26
+ listArtifacts, listRunArtifacts, lookupPipelineRow, findPipelineRowById, readPipelineStateById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
27
27
  readPromptFile,
28
28
  } from '../src/core/artifacts.mjs';
29
29
  import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
30
30
  import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
31
31
  import {
32
- addDiffComment, listDiffComments, getDiffComment, setDiffCommentResolved,
32
+ addDiffComment, addDiffCommentReply, listDiffComments, getDiffComment, setDiffCommentResolved,
33
33
  deleteDiffComment, unresolvedCounts, onDiffCommentsChanged, stampSentRunId,
34
34
  peekPendingCardComments, clearPendingCardComments, DiffCommentError, DC_ID_RE,
35
35
  } from '../src/core/diff-comments.mjs';
36
36
  import { listProjects, addProject, removeProject, normalizeProjectPath, countProjects, worcaHome } from '../src/core/projects.mjs';
37
+ import { renderIndexHtml, INDEX_THEME_ANCHOR } from '../src/core/index-html.mjs';
37
38
  import {
38
39
  getWorcaRoot, setWorcaRoot, setProjectsRoot, defaultRoot,
39
40
  rawProjectsRoot, defaultProjectsRoot, runRootMode,
@@ -44,6 +45,8 @@ import {
44
45
  debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
45
46
  titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
46
47
  hideBuiltinModels, setHideBuiltinModels, assertHideBuiltinModelsInput,
48
+ theme as storedTheme, setTheme, assertThemeInput,
49
+ autoWorkflowModel as storedAutoWorkflowModel, setAutoWorkflowModel, assertAutoWorkflowModelInput,
47
50
  } from '../src/core/settings.mjs';
48
51
  import { describeTitleModel } from '../src/core/title.mjs';
49
52
  import {
@@ -84,13 +87,20 @@ import { attachRunFollower } from '../src/core/ask/follow.mjs';
84
87
  import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
85
88
  import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
86
89
  import { getStats } from '../src/core/stats.mjs';
90
+ import {
91
+ enableTeamMetrics, setRecordMyRuns, flushSlug, flushAll, flushProject, scheduleFlush, discoverProject,
92
+ discoverAll, scanMembers, routeWorkspaceMembers, projectMetricsStatus, startTeamMetricsBackground,
93
+ metricsEvents, slugDirName, autoMetricsHome,
94
+ } from '../src/core/metrics/sync.mjs';
95
+ import { readScope, listScopes, parseScopeParam, aggregate, resolveRange, GROUP_BYS, PROJECT_KEY_RE as TM_PROJECT_KEY_RE } from '../src/core/metrics/read.mjs';
87
96
  import { pickFolderNative } from '../src/core/folder-dialog.mjs';
88
97
  import { listFolders } from '../src/core/fs-browse.mjs';
89
98
  import {
90
99
  readConfig, setStep, addCustomModel, removeCustomModel, listModels,
91
100
  PREDEFINED_MODELS, agentSteps, EFFORTS, catalogHasModel,
92
- readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
101
+ readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, setHumanInLoop, resetWorkflowConfig,
93
102
  globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
103
+ readPrRemotePrefs, setPrRemotePrefs,
94
104
  } from '../src/core/config.mjs';
95
105
  import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
96
106
  import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
@@ -105,9 +115,15 @@ import {
105
115
  writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
106
116
  } from '../src/core/guardrail-store.mjs';
107
117
  import {
108
- GRAPH_DEFAULT_WORKFLOW, listWorkflows, deleteWorkflow, isSafeWorkflowId,
109
- setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow,
118
+ GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, listWorkflows, deleteWorkflow, isSafeWorkflowId,
119
+ setWorkflowNodeDefaults, workflowNodeDefaults, assertRunnableWorkflow, writeGraphWorkflow, readWorkflow,
110
120
  } from '../src/core/workflows.mjs';
121
+ import { mintAutoWorkflowId, sanitizeProposalAnswer } from '../src/core/auto/proposal.mjs';
122
+ import {
123
+ revalidateWorkflowProposal, applyTunables, workflowEventPrompt, workflowNoticeText,
124
+ } from '../src/core/ask/workflow-deps.mjs';
125
+ import { applyMetricsChange } from '../src/core/ask/metrics-deps.mjs';
126
+ import { metricsEventPrompt, metricsNoticeText } from '../src/core/ask/metrics-proposal.mjs';
111
127
  import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
112
128
  import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
113
129
  import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
@@ -118,7 +134,7 @@ import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
118
134
  import {
119
135
  listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
120
136
  } from '../src/core/worktree.mjs';
121
- import { hasGh, pushBranch, createPr, prMergeable } from '../src/core/git-info.mjs';
137
+ import { hasGh, pushBranch, createPr, createIssue, prMergeable, listRemotes, sameRepo } from '../src/core/git-info.mjs';
122
138
  import { archivePipeline, discardRetainedWorktrees } from '../src/core/pipeline-delete.mjs';
123
139
  import {
124
140
  listWorkspaces, readWorkspace, createWorkspace,
@@ -126,7 +142,15 @@ import {
126
142
  } from '../src/core/workspaces.mjs';
127
143
  import { listWorkspacePipelines, readWorkspacePipeline } from '../src/core/artifacts.mjs';
128
144
  import { generateOverview } from '../src/core/overview-agent.mjs';
129
- import { projectKey } from '../src/core/store.mjs';
145
+ import { projectKey, PROJECT_KEY_RE } from '../src/core/store.mjs';
146
+ import { validateMemoryScope, withStoreLock } from '../src/core/memory-sync.mjs';
147
+ import {
148
+ memoryRoot, GLOBAL_SCOPE, projectScope, scopeKey, isValidMemoryName, MEMORY_NAME_HELP, memoryScopeReport,
149
+ readMemory, writeMemory, removeMemory, listSnapshots, restoreSnapshot,
150
+ } from '../src/core/memory-store.mjs';
151
+ import { memoryCaps } from '../src/core/settings.mjs';
152
+ import { onboardingPrefs, setOnboardingPrefs } from '../src/core/settings.mjs';
153
+ import { onboardingStatus } from '../src/core/onboarding.mjs'; // a THIRD settings import line (the two blocks above are unrelated readers)
130
154
  import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
131
155
  import { createAgentGen } from '../src/core/agent-gen.mjs';
132
156
  import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
@@ -158,6 +182,12 @@ import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs'
158
182
  import { normalizeManifest, validatePluginDir, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
159
183
  import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
160
184
  import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
185
+ import { resolveAutoModel, AUTO_MODEL_ENV } from '../src/core/auto/model.mjs';
186
+ import {
187
+ buildRunReport, buildIssueUrl, reportFilename, renderIssueBodyFull, issueTitle,
188
+ repoSlugFromBugsUrl, BUGS_URL,
189
+ } from '../src/core/run-report.mjs';
190
+ import { REPORT_REASON_IDS } from '../src/shared/report-reasons.mjs';
161
191
  import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
162
192
 
163
193
  // ── node:sqlite runtime guard + warning filter ──────────────────────────────────
@@ -200,6 +230,9 @@ const APP_INFO = Object.freeze({
200
230
  version: PKG_VERSION || '',
201
231
  repoUrl: APP_REPO_URL,
202
232
  releaseUrl: APP_REPO_URL && PKG_VERSION ? `${APP_REPO_URL}/releases/tag/worca-app-v${PKG_VERSION}` : '',
233
+ // package.json bugs.url, via run-report.mjs so the Settings links and the
234
+ // prefilled issue URL can never disagree. Falls back to <repo>/issues.
235
+ bugsUrl: BUGS_URL || (APP_REPO_URL ? `${APP_REPO_URL}/issues` : ''),
203
236
  });
204
237
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
205
238
  // Primaries plus the sub-language grammars their instances register
@@ -456,6 +489,8 @@ function emitChanged(type, action) {
456
489
  broadcast({ type, action: action || null });
457
490
  }
458
491
 
492
+ metricsEvents.on('changed', (e) => emitChanged('team-metrics-changed', e && e.action ? e.action : null));
493
+
459
494
  // Every comment mutation in THIS process (the REST routes below) pokes the open
460
495
  // Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
461
496
  // ordering concerns; the client refetches and repaints its CARDS, never the diff.
@@ -508,6 +543,44 @@ function resolvePending(entry, { id = null, reason = 'resolved' } = {}) {
508
543
  return true;
509
544
  }
510
545
 
546
+ /** Statuses under which an entry no longer drives its pipeline (the resumeRun double-resume guard's list, :1713). */
547
+ const SETTLED_RUN = new Set(['done', 'stopped', 'error', 'paused', 'interrupted']);
548
+ /** A runs-Map PIPELINE entry by its UUID or by its 8-hex History id (scans / agentgens never match). */
549
+ function liveRunEntry(id) {
550
+ if (typeof id !== 'string' || !id) return null;
551
+ const isRun = (r) => r && r.orch && (r.kind === 'run' || r.kind === 'workspace-run' || r.kind == null);
552
+ const direct = runs.get(id);
553
+ if (isRun(direct)) return direct;
554
+ // D23: resumeRun evicts only the paused/interrupted lineage; a same-pipeline entry left done/stopped/error sits
555
+ // EARLIER in Map order — the entry still driving the pipeline wins, else the newest (last inserted).
556
+ let best = null;
557
+ for (const r of runs.values()) {
558
+ if (!isRun(r) || r.pipelineId !== id) continue;
559
+ if (!best || !SETTLED_RUN.has(String(r.status || '')) || SETTLED_RUN.has(String(best.status || ''))) best = r;
560
+ }
561
+ return best;
562
+ }
563
+
564
+ /** 'global' | 'projects/<key>' — the store scope key a defragment run works on (memory-store.mjs scopeKey). */
565
+ function memoryScopeKey(memoryScope, projectDir) {
566
+ return memoryScope === 'global' ? 'global' : `projects/${projectKey(projectDir)}`;
567
+ }
568
+ /** The live (i.e. not SETTLED_RUN) defragment run on that scope key, or null. A paused defrag is
569
+ * NOT live — resumeRun refuses to resume it while another one runs.
570
+ * Amendment B20: "one live defragment run per scope" is enforced by THIS server process over ITS
571
+ * runs Map. A CLI-started defragment, or a second `worca ui` process on the same home, is not
572
+ * registered here; such a collision resolves like any two runs today — last sync wins and the
573
+ * loser's store copy is in `.history` (spec §5 concurrency). A DB-level check is impossible: the
574
+ * pipelines row carries no memoryScope. */
575
+ function liveDefragRun(scopeKeyStr) {
576
+ for (const e of runs.values()) {
577
+ if ((e.kind || 'run') !== 'run' || !e.orch?.memoryScope) continue;
578
+ if (SETTLED_RUN.has(String(e.status || ''))) continue;
579
+ if (memoryScopeKey(e.orch.memoryScope, e.projectDir) === scopeKeyStr) return e;
580
+ }
581
+ return null;
582
+ }
583
+
511
584
  function summarizeRuns() {
512
585
  return [...runs.values()].map((r) => ({
513
586
  runId: r.id,
@@ -608,6 +681,14 @@ function wireRun(entry) {
608
681
  // ...and WHAT went wrong for an error-pause, reset alongside it.
609
682
  entry.pauseDetail = (payload && payload.detail) || null;
610
683
  resolvePending(entry, { reason: entry.status });
684
+ // B29: ANY run that mounted memory may have synced into its scopes (P1 syncs the mount back
685
+ // at the run end on done, error, stopped and paused alike) — poke every mounted scope so open
686
+ // Memory views refetch. Never gated on `entry.status`: it is mirrored from the earlier `state`
687
+ // frame and already reads 'done' BEFORE _buildResults()/_stampDefrag() have run, while THIS
688
+ // event fires after both (so a defragment frame always follows its stamp).
689
+ if (orch.memory?.dirs?.length) {
690
+ for (const d of orch.memory.dirs) emitMemoryChanged(scopeKey(d.scope));
691
+ }
611
692
  if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
612
693
  emitChanged('budget-changed');
613
694
  }
@@ -865,6 +946,32 @@ app.use('/src/shared', (_req, res) => {
865
946
  res.status(404).type('text/plain').send('Not found');
866
947
  });
867
948
 
949
+ // The shell is rendered, not static: the stored theme mode goes into
950
+ // <html data-theme> so the first paint is already dark or light (dark-mode
951
+ // design §5.2). Read per request (100 KB, local) so an index.html edit is live
952
+ // without a restart, exactly like static serving was. no-store: a theme change
953
+ // must never be served from the browser cache.
954
+ const INDEX_FILE = path.join(PUBLIC_DIR, 'index.html');
955
+ if (!fs.readFileSync(INDEX_FILE, 'utf8').includes(INDEX_THEME_ANCHOR)) {
956
+ throw new Error(`ui/public/index.html lost its theme anchor ${INDEX_THEME_ANCHOR}`);
957
+ }
958
+ function sendIndex(res) {
959
+ let html;
960
+ try { html = renderIndexHtml(fs.readFileSync(INDEX_FILE, 'utf8'), storedTheme()); }
961
+ catch (err) { return res.status(500).json({ error: err && err.message ? err.message : 'shell unavailable' }); }
962
+ res.set('Content-Type', 'text/html; charset=utf-8');
963
+ res.set('Cache-Control', 'no-store');
964
+ res.set('X-Content-Type-Options', 'nosniff');
965
+ return res.send(html);
966
+ }
967
+ // `/index` (express.static's `extensions:['html']`) and `/Index.html` on a case-insensitive
968
+ // file system would otherwise reach the raw file: route every spelling here.
969
+ app.use((req, res, next) => {
970
+ if (req.method !== 'GET') return next();
971
+ if (req.path === '/' || /^\/index(\.html)?$/i.test(req.path)) return sendIndex(res);
972
+ return next();
973
+ });
974
+
868
975
  app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
869
976
 
870
977
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
@@ -1051,7 +1158,7 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
1051
1158
  if (linkPatch.pipelineId && row && row.commentIds.length) {
1052
1159
  try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
1053
1160
  }
1054
- if (patch.cardFailed) {
1161
+ if (patch.cardFailed && cardId) {
1055
1162
  flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
1056
1163
  }
1057
1164
  broadcast({
@@ -1079,13 +1186,82 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
1079
1186
  set.add(follower);
1080
1187
  }
1081
1188
 
1189
+ /** An undetached follower for this (thread, runId) already exists — the proposal launch attached it, or an earlier track_run. */
1190
+ function askFollowerAttached(threadId, runId) {
1191
+ const set = askFollowers.get(threadId);
1192
+ return !!set && [...set].some((f) => f.runId === runId && !f.detached);
1193
+ }
1194
+
1195
+ /**
1196
+ * track_run's parent-side half (the MCP child only resolves — src/core/ask/tools.mjs is write-free by contract):
1197
+ * resolve the run (live entry by UUID or 8-hex, else the pipelines table — the user-pinned scope first, like
1198
+ * resolveRow), link it to the thread ONCE per pipeline (D5: a run with no live UUID keys the row by its pipeline
1199
+ * id until a live run takes it over; the dedupe is application-level — the PK is (thread, run_id) and linkRun
1200
+ * throws on a collision), attach a follower to a live run ONCE per runId (D6), and hand back the progress card's
1201
+ * identity. Never throws; a failure is a model-readable {ok:false, error}.
1202
+ */
1203
+ function askTrackRun(threadId, input, pin) {
1204
+ const raw = input && typeof input.id === 'string' ? input.id.trim() : '';
1205
+ if (!raw) return { ok: false, error: 'id is required' };
1206
+ let entry = liveRunEntry(raw);
1207
+ let state = null;
1208
+ if (entry) {
1209
+ if (!entry.pipelineId) return { ok: false, error: 'the run has no pipeline id yet — try again in a moment' };
1210
+ state = readPipelineStateById(entry.pipelineId);
1211
+ } else {
1212
+ const scopeKey = typeof input.projectKey === 'string' && input.projectKey ? input.projectKey
1213
+ : typeof input.workspaceId === 'string' && input.workspaceId ? `workspaces/${input.workspaceId}`
1214
+ : pin && pin.projectKey ? pin.projectKey : pin && pin.workspaceId ? `workspaces/${pin.workspaceId}` : null;
1215
+ const row = (scopeKey ? lookupPipelineRow(scopeKey, raw) : null) || findPipelineRowById(raw);
1216
+ if (!row) return { ok: false, error: 'run not found' };
1217
+ // The row lookups canonicalise (lower case, the `…-<8hex>` dir-name form); the runs-Map scan compares verbatim.
1218
+ // Re-ask with the canonical id so an uppercase or dir-name id still finds the live lineage.
1219
+ entry = liveRunEntry(row.id) || null;
1220
+ state = readPipelineStateById(row.id);
1221
+ }
1222
+ if (!state) return { ok: false, error: 'run not found' };
1223
+ const pipelineId = state.id;
1224
+ const isWs = !!((entry && entry.workspaceId) || state.target === 'workspace');
1225
+ const workspaceId = isWs ? ((entry && entry.workspaceId) || state.workspaceId || null) : null;
1226
+ const projKey = isWs ? null : ((entry && entry.projectDir) ? projectKey(entry.projectDir) : (state.projectKey || null));
1227
+ const label = isWs
1228
+ ? (state.workspaceName || (Array.isArray(state.projects) ? state.projects.map((p) => p.projectName).filter(Boolean).join(' · ') : '') || '')
1229
+ : String((entry && entry.projectDir) || state.projectDir || '').split(/[\\/]/).filter(Boolean).pop() || (projKey || '');
1230
+ const title = (entry && entry.title) || state.title || pipelineId;
1231
+ const status = (entry && entry.status) || state.status || null;
1232
+ const liveRunId = entry ? entry.id : null;
1233
+ // ONE row per (thread, pipeline): by the live UUID first (a proposal launch whose block threw after askLinkRun leaves a
1234
+ // uuid row with pipeline_id NULL that no follower fills — adopt it), then by pipeline id. UUID-first also means the
1235
+ // patch below never moves a run_id onto a key that already exists (the (thread_id, run_id) PK would throw).
1236
+ const links = askListRunLinks(threadId);
1237
+ const existing = (liveRunId ? links.find((l) => l.runId === liveRunId) : null) || links.find((l) => l.pipelineId === pipelineId) || null;
1238
+ try {
1239
+ if (!existing) askLinkRun(threadId, { runId: liveRunId || pipelineId, pipelineId, status });
1240
+ else {
1241
+ const patch = { status };
1242
+ if (!existing.pipelineId) patch.pipelineId = pipelineId;
1243
+ if (liveRunId && existing.runId !== liveRunId) patch.runId = liveRunId;
1244
+ askUpdateRunLink(threadId, existing.runId, patch);
1245
+ }
1246
+ } catch (err) {
1247
+ return { ok: false, error: `could not link the run: ${err && err.message ? err.message : String(err)}` };
1248
+ }
1249
+ // Only a run that still drives its pipeline gets a follower: attachRunFollower subscribes unconditionally
1250
+ // (follow.mjs:111) and a settled orchestrator never emits again, so it would sit in askFollowers for the life
1251
+ // of the thread — and pin a paused lineage's dead orchestrator once resumeRun evicts the entry.
1252
+ if (entry && !SETTLED_RUN.has(String(entry.status || '')) && !askFollowerAttached(threadId, entry.id)) {
1253
+ attachAskFollower(entry.orch, { threadId, runId: entry.id, cardId: null });
1254
+ }
1255
+ return { ok: true, card: { type: 'progress', pipelineId, runId: liveRunId, projectKey: projKey, workspaceId, title, label, status } };
1256
+ }
1257
+
1082
1258
  // ---------------------------------------------------------------------------
1083
1259
  // POST /api/run -> start a new orchestration run
1084
1260
  // body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
1085
1261
  // body (workspace): { workspaceId, prompt?, ... } — mutually exclusive with
1086
1262
  // projectDir (§2.6). Single-project behavior is byte-identical.
1087
1263
  // ---------------------------------------------------------------------------
1088
- app.post('/api/run', async (req, res) => {
1264
+ const startRunHandler = async (req, res) => {
1089
1265
  try {
1090
1266
  const body = req.body || {};
1091
1267
 
@@ -1186,6 +1362,21 @@ app.post('/api/run', async (req, res) => {
1186
1362
  return badRequest(res, err && err.message ? err.message : String(err));
1187
1363
  }
1188
1364
 
1365
+ // Auto workflow (spec D19): project targets only in v1. Before the workspace lookup,
1366
+ // so an Auto request for ANY workspace id answers 400, never 404.
1367
+ if (workflowId === AUTO_WORKFLOW_ID && hasWorkspace) {
1368
+ return badRequest(res, 'Auto workflow is not available for workspace targets yet');
1369
+ }
1370
+ // Agent memory (§7.3): the defragment run option — ONE gate for every entry point (the CLI
1371
+ // and Ask's proposal validator call the same helper). Before the target lookup, like Auto.
1372
+ if (body.memoryScope != null && typeof body.memoryScope !== 'string') return badRequest(res, 'memoryScope must be "global" or "project"');
1373
+ const memoryScope = typeof body.memoryScope === 'string' && body.memoryScope.trim() ? body.memoryScope.trim() : null;
1374
+ const scopeReason = validateMemoryScope({ workflowId, memoryScope, isWorkspace: !!hasWorkspace });
1375
+ if (scopeReason) return badRequest(res, scopeReason);
1376
+ // Human in the loop (spec D15): the body wins, else the project's stored
1377
+ // switch, else on. Resolved per target below (it needs the project dir).
1378
+ const bodyHumanInLoop = typeof body.humanInLoop === 'boolean' ? body.humanInLoop : null;
1379
+
1189
1380
  // Optional guardrailsId selects the named guardrail set that IS this run's
1190
1381
  // policy (applied uniformly to every member — guardrails are per-run only).
1191
1382
  // Absent/blank/null normalizes to 'permissive' — the empty policy,
@@ -1331,6 +1522,13 @@ app.post('/api/run', async (req, res) => {
1331
1522
 
1332
1523
  const fileProblem = await promptFileProblem(effectiveSource, projectDir);
1333
1524
  if (fileProblem) return badRequest(res, fileProblem);
1525
+ // One live defragment run per scope (§7.3, amendment B20: this server process only).
1526
+ if (memoryScope) {
1527
+ const live = liveDefragRun(memoryScopeKey(memoryScope, projectDir));
1528
+ if (live) return res.status(409).json({ error: 'a defragment run for this memory scope is already live', runId: live.id });
1529
+ }
1530
+
1531
+ const humanInLoop = bodyHumanInLoop ?? ((await readRunConfig(projectDir)).humanInLoop !== false);
1334
1532
 
1335
1533
  orch = await createOrchestratorFor({
1336
1534
  projectDir,
@@ -1343,6 +1541,8 @@ app.post('/api/run', async (req, res) => {
1343
1541
  template: workflowRow,
1344
1542
  guardrailsId,
1345
1543
  branch,
1544
+ humanInLoop,
1545
+ ...(memoryScope ? { memoryScope } : {}),
1346
1546
  claude: { permissionMode: 'acceptEdits', mock },
1347
1547
  });
1348
1548
 
@@ -1419,7 +1619,8 @@ app.post('/api/run', async (req, res) => {
1419
1619
  } catch (err) {
1420
1620
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
1421
1621
  }
1422
- });
1622
+ };
1623
+ app.post('/api/run', startRunHandler);
1423
1624
 
1424
1625
  // ---------------------------------------------------------------------------
1425
1626
  // Chat connectivity (chat-connectivity-design.md): persistent channel workers
@@ -1696,7 +1897,15 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1696
1897
  if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
1697
1898
  }
1698
1899
 
1699
- const effMock = mock || isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
1900
+ // A paused defragment run is not live; another one on the same scope may have started since.
1901
+ // Resuming the first would sync its stale mount over the second's work (spec §5 concurrency).
1902
+ const rpScope = saved.resumePoint?.memoryScope || null;
1903
+ if (rpScope && !workspace) {
1904
+ const live = liveDefragRun(memoryScopeKey(rpScope, projectDir));
1905
+ if (live) throw new ResumeError(409, { error: 'a defragment run for this memory scope is already live', runId: live.id });
1906
+ }
1907
+
1908
+ const effMock = mock ||isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK);
1700
1909
  const runId = randomUUID();
1701
1910
  const orch = await createOrchestratorFor({
1702
1911
  projectDir,
@@ -1866,6 +2075,19 @@ app.get('/api/runs/:id/artifact', async (req, res) => {
1866
2075
  }
1867
2076
  });
1868
2077
 
2078
+ app.get('/api/runs/:id/artifacts', async (req, res) => {
2079
+ try {
2080
+ const row = findPipelineRowById(req.params.id);
2081
+ if (!row) return res.status(404).json({ error: 'pipeline not found' });
2082
+ // Cap the row set (each row costs a synchronous statSync for its byte size, on
2083
+ // the event loop) at the same ceiling the ask tool uses.
2084
+ const artifacts = await listRunArtifacts(row.id, { limit: ASK_LIMITS.artifactsListMaxLimit });
2085
+ res.json({ runId: row.id, artifacts });
2086
+ } catch (err) {
2087
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2088
+ }
2089
+ });
2090
+
1869
2091
  // Shared query-scope resolver for the retained-work routes (recovery-patch GET +
1870
2092
  // discard POST). Returns null after writing the error response itself. The older
1871
2093
  // DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
@@ -1966,6 +2188,24 @@ app.get('/api/history', async (_req, res) => {
1966
2188
  // machine-wide history just to update a badge. Running is derived client-side from the
1967
2189
  // in-memory runs map (live via WS), so it is not included here. Synchronous: the three
1968
2190
  // helpers are sync getDb().prepare(...).get() calls.
2191
+ // ---------------------------------------------------------------------------
2192
+ // Getting started (docs/getting-started.md). GET is the derived checklist —
2193
+ // eight ticks computed from the store + PATH, never stored — plus the two flags.
2194
+ // POST writes ONLY those flags ({hidden?, welcomeSeen?}; booleans; unknown keys
2195
+ // 400) and answers with the same full payload, so one round trip repaints.
2196
+ // ---------------------------------------------------------------------------
2197
+ app.get('/api/onboarding', async (_req, res) => {
2198
+ try { res.json(await onboardingStatus()); }
2199
+ catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err) }); }
2200
+ });
2201
+ app.post('/api/onboarding', async (req, res) => {
2202
+ try { await setOnboardingPrefs(req.body || {}); }
2203
+ catch (err) { return badRequest(res, err && err.message ? err.message : String(err)); }
2204
+ emitChanged('onboarding-changed');
2205
+ try { res.json(await onboardingStatus()); }
2206
+ catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err), ...onboardingPrefs() }); }
2207
+ });
2208
+
1969
2209
  app.get('/api/counts', (_req, res) => {
1970
2210
  try {
1971
2211
  res.json({
@@ -1992,6 +2232,154 @@ app.get('/api/stats', (req, res) => {
1992
2232
  }
1993
2233
  });
1994
2234
 
2235
+ // ---- Team metrics (team-metrics-design.md §4.6–§4.10) ----------------------------------
2236
+ function metricsErrorStatus(code) {
2237
+ switch (code) {
2238
+ case 'BAD_REQUEST': case 'NO_ORIGIN': return 400;
2239
+ case 'DELEGATE_INVALID': return 409; // a configuration state, not a malformed request
2240
+ case 'NOT_FOUND': case 'NOT_ENABLED': return 404;
2241
+ case 'PUSH_REJECTED': case 'FETCH_FAILED': case 'REMOTE_UNREACHABLE': case 'PUSH_RETRIES_EXHAUSTED': return 502;
2242
+ case 'LOCK_TIMEOUT': return 503;
2243
+ default: return 500;
2244
+ }
2245
+ }
2246
+ function sendMetricsError(res, err) {
2247
+ const code = (err && err.code) || 'INTERNAL';
2248
+ res.status(metricsErrorStatus(code)).json({
2249
+ error: err && err.message ? err.message : String(err), code,
2250
+ ...(err && err.stderr ? { stderr: err.stderr } : {}),
2251
+ ...(err && err.hint ? { hint: err.hint } : {}),
2252
+ });
2253
+ }
2254
+ async function tmProject(req, res) {
2255
+ if (!TM_PROJECT_KEY_RE.test(req.params.key)) { badRequest(res, 'invalid project key'); return null; }
2256
+ const p = (await listProjects()).find((x) => x.key === req.params.key);
2257
+ if (!p) { res.status(404).json({ error: 'project not found', code: 'NOT_FOUND' }); return null; }
2258
+ return p;
2259
+ }
2260
+
2261
+ app.get('/api/team-metrics/scopes', async (req, res) => {
2262
+ try { res.json(await listScopes({ discover: req.query.discover === '1' })); }
2263
+ catch (err) { sendMetricsError(res, err); }
2264
+ });
2265
+
2266
+ app.get('/api/team-metrics', async (req, res) => {
2267
+ const scope = parseScopeParam(req.query.scope);
2268
+ if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
2269
+ const range = typeof req.query.range === 'string' && req.query.range ? req.query.range : 'this-month';
2270
+ const groupBy = typeof req.query.groupBy === 'string' && req.query.groupBy ? req.query.groupBy : 'workflow';
2271
+ const from = typeof req.query.from === 'string' ? req.query.from : null;
2272
+ const to = typeof req.query.to === 'string' ? req.query.to : null;
2273
+ try {
2274
+ resolveRange(range, { from, to }); // 400 before touching git
2275
+ if (!GROUP_BYS.includes(groupBy)) throw new RangeError(`unknown groupBy "${groupBy}"`);
2276
+ } catch (err) { return badRequest(res, err.message); }
2277
+ try {
2278
+ // defer=1 (the page): serve the worktree now and run a due fetch afterwards; the fetch's
2279
+ // `changed` event tells the page to reload. Other clients (Ask tools, CLI, tests) keep the
2280
+ // inline fetch and get fresh data in one round trip.
2281
+ const read = await readScope(scope, { refresh: req.query.refresh === '1', defer: req.query.defer === '1' });
2282
+ // Flush trigger: page open (§4.5). reason:'page-open' backs off for 60 s after a failed flush,
2283
+ // so a failing push cannot loop through flush-failed → WS → page reload → GET → flush.
2284
+ for (const s of read.sync) if (s.pending > 0) scheduleFlush(s.slug, { reason: 'page-open' });
2285
+ res.json({
2286
+ scope: read.scope,
2287
+ records: read.records,
2288
+ // The page re-aggregates client-side (§4.9 "one fetch serves the session") and asks with
2289
+ // aggregate=0: at 12k records the unused aggregate added ~3.9 MB to a ~9.9 MB response.
2290
+ // 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 }),
2292
+ stats: read.stats,
2293
+ sync: read.sync,
2294
+ refresh: read.refresh,
2295
+ fetchError: read.fetchError,
2296
+ });
2297
+ } catch (err) { sendMetricsError(res, err); }
2298
+ });
2299
+
2300
+ // "Push now" / "Retry": body { slug } flushes one outbox, { scope } flushes that scope's sinks,
2301
+ // an empty body flushes every outbox on this machine (same as `worca metrics push`).
2302
+ app.post('/api/team-metrics/flush', async (req, res) => {
2303
+ const body = req.body || {};
2304
+ try {
2305
+ let results;
2306
+ if (typeof body.slug === 'string' && body.slug) {
2307
+ // slugDirName throws BAD_REQUEST inside flushSlug, which would answer 200 with ok:false.
2308
+ try { slugDirName(body.slug); } catch { return badRequest(res, 'invalid slug'); }
2309
+ results = [await flushSlug(body.slug)];
2310
+ }
2311
+ else if (typeof body.scope === 'string' && body.scope) {
2312
+ const scope = parseScopeParam(body.scope);
2313
+ if (!scope) return badRequest(res, 'invalid scope');
2314
+ if (scope.kind === 'project') {
2315
+ const p = (await listProjects()).find((x) => x.key === scope.id);
2316
+ if (!p) return res.status(404).json({ error: 'project not found', code: 'NOT_FOUND' });
2317
+ results = [await flushProject(p.path)];
2318
+ } else {
2319
+ const sinks = (await readScope(scope)).sinks;
2320
+ results = [];
2321
+ for (const slug of sinks) results.push(await flushSlug(slug));
2322
+ }
2323
+ } else results = await flushAll();
2324
+ res.json({ results });
2325
+ } catch (err) { sendMetricsError(res, err); }
2326
+ });
2327
+
2328
+ // Empty state "Check now" (§4.10): force discovery everywhere.
2329
+ app.post('/api/team-metrics/discover', async (_req, res) => {
2330
+ try { await discoverAll({ force: true }); res.json(await listScopes()); }
2331
+ catch (err) { sendMetricsError(res, err); }
2332
+ });
2333
+
2334
+ app.get('/api/projects/:key/team-metrics', async (req, res) => {
2335
+ const p = await tmProject(req, res); if (!p) return;
2336
+ try { res.json({ status: await projectMetricsStatus(p, { discover: req.query.discover === '1' }) }); }
2337
+ catch (err) { sendMetricsError(res, err); }
2338
+ });
2339
+
2340
+ app.patch('/api/projects/:key/team-metrics', async (req, res) => {
2341
+ const p = await tmProject(req, res); if (!p) return;
2342
+ const body = req.body || {};
2343
+ if (typeof body.record !== 'boolean') return badRequest(res, 'record must be a boolean');
2344
+ try { setRecordMyRuns(p.path, body.record); res.json({ status: await projectMetricsStatus(p) }); }
2345
+ catch (err) { sendMetricsError(res, err); }
2346
+ });
2347
+
2348
+ app.post('/api/projects/:key/team-metrics/enable', async (req, res) => {
2349
+ const p = await tmProject(req, res); if (!p) return;
2350
+ const body = req.body || {};
2351
+ const mode = body.mode === 'delegate' ? 'delegate' : body.mode === 'here' || body.mode == null ? 'here' : null;
2352
+ if (!mode) return badRequest(res, 'mode must be "here" or "delegate"');
2353
+ if (mode === 'here' && body.attribution != null && body.attribution !== 'git-user' && body.attribution !== 'none') {
2354
+ return badRequest(res, 'attribution must be "git-user" or "none"');
2355
+ }
2356
+ try {
2357
+ const result = await enableTeamMetrics(p.path, { mode, attribution: body.attribution || 'git-user', delegateTo: body.delegateTo || null, change: body.change === true });
2358
+ res.json({ ...result, status: await projectMetricsStatus(p) });
2359
+ } catch (err) { sendMetricsError(res, err); }
2360
+ });
2361
+
2362
+ // Wizard step (§4.8). No POST /api/workspaces/:id route exists, so nothing shadows this path.
2363
+ app.post('/api/workspaces/metrics-scan', async (req, res) => {
2364
+ const paths = req.body && Array.isArray(req.body.projectPaths) ? req.body.projectPaths.filter((p) => typeof p === 'string' && p) : null;
2365
+ if (!paths || !paths.length) return badRequest(res, 'projectPaths must be a non-empty array of paths');
2366
+ if (paths.length > 50) return badRequest(res, 'at most 50 projectPaths');
2367
+ const normalized = paths.map((p) => resolveProjectDir(p)).filter(Boolean);
2368
+ // Same checks as POST /api/workspaces/scan (:~2948): never discover (and write a config row for) a junk path.
2369
+ for (const p of normalized) {
2370
+ if (!fs.existsSync(p)) return badRequest(res, `member path is missing: ${p}`);
2371
+ if (!isGitRepo(p)) return badRequest(res, `member is not a git repository: ${p}`);
2372
+ }
2373
+ try { res.json(await scanMembers(normalized)); }
2374
+ catch (err) { sendMetricsError(res, err); }
2375
+ });
2376
+
2377
+ app.post('/api/workspaces/:id/metrics-route', async (req, res) => {
2378
+ if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'workspace not found', code: 'NOT_FOUND' });
2379
+ try { res.json(await routeWorkspaceMembers(req.params.id)); }
2380
+ catch (err) { sendMetricsError(res, err); }
2381
+ });
2382
+
1995
2383
  // ---------------------------------------------------------------------------
1996
2384
  // POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
1997
2385
  // over the WS as batched `history-pr` events (reuses broadcast(), the same
@@ -2147,7 +2535,27 @@ function commentsPatch(req, res, storeKey, id, cid) {
2147
2535
  const raw = (req.body || {}).resolved;
2148
2536
  if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
2149
2537
  res.json({ comment: setDiffCommentResolved(cid, raw) });
2150
- } catch (err) { commentsFail(res, err); }
2538
+ } catch (err) {
2539
+ // A reply id: the store refuses (D2) and the browser shows the reason inline.
2540
+ if (err instanceof DiffCommentError) return badRequest(res, err.message);
2541
+ commentsFail(res, err);
2542
+ }
2543
+ }
2544
+
2545
+ /** Reply inside a thread. The parent must belong to THIS run (commentOfRun); the
2546
+ * store enforces one level, the body cap and the author. No patch gate: a reply
2547
+ * anchors to nothing new, so there is no anchor to re-resolve and no patch to read
2548
+ * it from. (Not an archived-run affordance — archiving deletes a run's comments,
2549
+ * src/core/pipeline-delete.mjs.) */
2550
+ function commentsReply(req, res, storeKey, id, cid) {
2551
+ try {
2552
+ if (!commentOfRun(res, storeKey, id, cid)) return;
2553
+ const comment = addDiffCommentReply({ parentId: cid, body: (req.body || {}).body, author: 'user' });
2554
+ res.status(201).json({ comment });
2555
+ } catch (err) {
2556
+ if (err instanceof DiffCommentError) return badRequest(res, err.message);
2557
+ commentsFail(res, err);
2558
+ }
2151
2559
  }
2152
2560
 
2153
2561
  function commentsDelete(res, storeKey, id, cid) {
@@ -2176,6 +2584,11 @@ app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
2176
2584
  const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2177
2585
  commentsDelete(res, key, req.params.id, cid);
2178
2586
  });
2587
+ app.post('/api/history/:key/:id/comments/:cid/replies', (req, res) => {
2588
+ const key = commentsHistoryKey(res, req.params.key); if (!key) return;
2589
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2590
+ commentsReply(req, res, key, req.params.id, cid);
2591
+ });
2179
2592
 
2180
2593
  app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
2181
2594
  const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
@@ -2195,6 +2608,11 @@ app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
2195
2608
  const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2196
2609
  commentsDelete(res, key, req.params.runId, cid);
2197
2610
  });
2611
+ app.post('/api/workspaces/:id/runs/:runId/comments/:cid/replies', (req, res) => {
2612
+ const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
2613
+ const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2614
+ commentsReply(req, res, key, req.params.runId, cid);
2615
+ });
2198
2616
 
2199
2617
  // Unresolved counts for every run, for the History list pill. Its own endpoint
2200
2618
  // rather than a field on /api/history: that response has a localStorage skeleton
@@ -2328,37 +2746,87 @@ app.post('/api/runs/:id/discard-worktree', async (req, res) => {
2328
2746
  });
2329
2747
 
2330
2748
  // ---------------------------------------------------------------------------
2331
- // POST /api/pr -> push the pipeline's feature branch (if needed) and open a PR
2332
- // against its source branch via the GitHub CLI. Mergeability is read back only
2333
- // here (never during list rendering). body: { id, projectDir? , projectKey? }
2749
+ // PR remotes (fork support). The ship-it dialog picks (1) the remote the feature
2750
+ // branch is pushed to and (2) the remote whose repo the PR is opened in — GitHub's
2751
+ // "compare across forks". Names are user-defined, so nothing is special-cased
2752
+ // beyond fallbacks: the project's remembered choice (when those remotes still
2753
+ // exist) -> `upstream` for the base when one exists -> `origin` -> first remote.
2334
2754
  // ---------------------------------------------------------------------------
2335
- app.post('/api/pr', async (req, res) => {
2336
- const body = req.body || {};
2337
- const id = typeof body.id === 'string' ? body.id.trim() : '';
2338
- if (!id) return badRequest(res, 'id is required');
2339
- if (!(await hasGh())) {
2340
- return res.status(409).json({ error: 'GitHub CLI (gh) is not available' });
2341
- }
2755
+ function defaultPrRemotes(remotes, remembered) {
2756
+ const names = remotes.map((r) => r.name);
2757
+ const has = (n) => !!n && names.includes(n);
2758
+ const first = names[0] || null;
2759
+ const pushRemote = has(remembered?.pushRemote) ? remembered.pushRemote : (has('origin') ? 'origin' : first);
2760
+ const baseRemote = has(remembered?.baseRemote) ? remembered.baseRemote
2761
+ : (has('upstream') ? 'upstream' : (has('origin') ? 'origin' : first));
2762
+ return { pushRemote, baseRemote };
2763
+ }
2342
2764
 
2343
- // Resolve the pipeline state (by store key, else by project dir).
2765
+ // Resolve a pipeline for the PR routes (store key first, else project dir) from a
2766
+ // body or a query object. Writes the error response itself and returns null.
2767
+ // (/api/pr/mergeable keeps its own copy: its bad-key/not-found cases answer 200
2768
+ // UNKNOWN, not 404.)
2769
+ async function resolvePrPipeline(src, res) {
2770
+ const id = typeof src.id === 'string' ? src.id.trim() : '';
2771
+ if (!id) { badRequest(res, 'id is required'); return null; }
2344
2772
  let state = null;
2345
2773
  try {
2346
- if (typeof body.projectKey === 'string' && body.projectKey.trim()) {
2347
- if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(body.projectKey)) {
2348
- return res.status(404).json({ error: 'pipeline not found' });
2774
+ if (typeof src.projectKey === 'string' && src.projectKey.trim()) {
2775
+ if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(src.projectKey)) {
2776
+ res.status(404).json({ error: 'pipeline not found' });
2777
+ return null;
2349
2778
  }
2350
- const data = await readPipelineByKey(body.projectKey, id);
2779
+ const data = await readPipelineByKey(src.projectKey, id);
2351
2780
  state = data && data.state;
2352
2781
  } else {
2353
- const projectDir = resolveProjectDir(body.projectDir);
2354
- if (!projectDir) return badRequest(res, 'projectDir or projectKey is required');
2782
+ const projectDir = resolveProjectDir(src.projectDir);
2783
+ if (!projectDir) { badRequest(res, 'projectDir or projectKey is required'); return null; }
2355
2784
  const data = await readPipeline(projectDir, id);
2356
2785
  state = data && data.state;
2357
2786
  }
2358
2787
  } catch (err) {
2359
- return res.status(500).json({ error: err && err.message ? err.message : String(err) });
2788
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
2789
+ return null;
2360
2790
  }
2361
- if (!state) return res.status(404).json({ error: 'pipeline not found' });
2791
+ if (!state) { res.status(404).json({ error: 'pipeline not found' }); return null; }
2792
+ return { id, state };
2793
+ }
2794
+
2795
+ // ---------------------------------------------------------------------------
2796
+ // GET /api/pr/remotes?id=&projectKey=|projectDir= -> the project's git remotes for
2797
+ // the ship-it dialog plus the defaults POST /api/pr applies when the body names
2798
+ // none. Same pipeline resolution as POST /api/pr (the repo dir comes from the
2799
+ // pipeline's store_meta, never from the query). gh is not required here.
2800
+ // -> { ok, remotes:[{name,fetchUrl,pushUrl,host,owner,repo,slug}],
2801
+ // defaults:{pushRemote,baseRemote}, remembered:{pushRemote,baseRemote}|null }
2802
+ // ---------------------------------------------------------------------------
2803
+ app.get('/api/pr/remotes', async (req, res) => {
2804
+ const resolved = await resolvePrPipeline(req.query || {}, res);
2805
+ if (!resolved) return;
2806
+ const repoDir = resolved.state.projectDir; // null when store_meta is missing
2807
+ if (!repoDir) return badRequest(res, 'pipeline has no project directory');
2808
+ const rl = await listRemotes(repoDir);
2809
+ if (!rl.ok) return res.status(500).json({ error: `git remote failed: ${rl.error}` });
2810
+ const remembered = readPrRemotePrefs(repoDir);
2811
+ res.json({ ok: true, remotes: rl.remotes, defaults: defaultPrRemotes(rl.remotes, remembered), remembered });
2812
+ });
2813
+
2814
+ // ---------------------------------------------------------------------------
2815
+ // 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).
2820
+ // ---------------------------------------------------------------------------
2821
+ app.post('/api/pr', async (req, res) => {
2822
+ const body = req.body || {};
2823
+ if (!(typeof body.id === 'string' && body.id.trim())) return badRequest(res, 'id is required');
2824
+ if (!(await hasGh())) {
2825
+ return res.status(409).json({ error: 'GitHub CLI (gh) is not available' });
2826
+ }
2827
+ const resolved = await resolvePrPipeline(body, res);
2828
+ if (!resolved) return;
2829
+ const { id, state } = resolved;
2362
2830
 
2363
2831
  const repoDir = state.projectDir;
2364
2832
  const feature = state.branch && state.branch.feature;
@@ -2367,12 +2835,49 @@ app.post('/api/pr', async (req, res) => {
2367
2835
  return badRequest(res, 'pipeline has no branch info to open a PR');
2368
2836
  }
2369
2837
 
2838
+ // Remote selection. A named remote must exist; unnamed ones take the dialog's
2839
+ // defaults. With no usable remote list (no remotes, unparseable URLs) the legacy
2840
+ // argv applies: push `origin`, no --repo, bare head. A git failure is only fatal
2841
+ // when the body actually names a remote (otherwise legacy argv, as before).
2842
+ const named = (v) => !(v === undefined || v === null || v === '');
2843
+ const rl = await listRemotes(repoDir);
2844
+ if (!rl.ok && (named(body.pushRemote) || named(body.baseRemote))) {
2845
+ return res.status(500).json({ error: `git remote failed: ${rl.error}` });
2846
+ }
2847
+ const remotes = rl.ok ? rl.remotes : [];
2848
+ const byName = new Map(remotes.map((r) => [r.name, r]));
2849
+ const pick = (field, label) => {
2850
+ const v = body[field];
2851
+ if (!named(v)) return { name: null };
2852
+ if (typeof v !== 'string' || !byName.has(v.trim())) {
2853
+ return { error: `unknown ${label} remote: ${String(v).slice(0, 80)}` };
2854
+ }
2855
+ return { name: v.trim() };
2856
+ };
2857
+ const pushPick = pick('pushRemote', 'push');
2858
+ if (pushPick.error) return badRequest(res, pushPick.error);
2859
+ const basePick = pick('baseRemote', 'base');
2860
+ if (basePick.error) return badRequest(res, basePick.error);
2861
+ const defaults = defaultPrRemotes(remotes, readPrRemotePrefs(repoDir));
2862
+ const pushRemote = pushPick.name || defaults.pushRemote || 'origin';
2863
+ const baseRemote = basePick.name || defaults.baseRemote || 'origin';
2864
+ const pushR = byName.get(pushRemote) || null;
2865
+ const baseR = byName.get(baseRemote) || null;
2866
+ // Always target the chosen base repo explicitly (gh's default-repo guess prefers
2867
+ // a remote named upstream over origin); use the owner:branch head only when the
2868
+ // branch lives in a different repository than the PR (gh matches by head label).
2869
+ const repo = baseR?.slug || null;
2870
+ const crossRepo = !!(pushR?.slug && baseR?.slug && !sameRepo(pushR, baseR));
2871
+ const headOwner = crossRepo ? pushR.owner : null;
2872
+
2370
2873
  // Push (idempotent) -> create PR -> read mergeability. All args are passed as
2371
- // an argv array (no shell), so branch/source names cannot inject.
2372
- const pushed = await pushBranch(repoDir, feature);
2874
+ // an argv array (no shell), so branch/remote/source names cannot inject.
2875
+ const pushed = await pushBranch(repoDir, feature, pushRemote);
2373
2876
  if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
2374
2877
 
2375
- const pr = await createPr({ projectDir: repoDir, base: source, head: feature, title: state.title || feature });
2878
+ const pr = await createPr({
2879
+ projectDir: repoDir, base: source, head: feature, title: state.title || feature, repo, headOwner,
2880
+ });
2376
2881
  if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
2377
2882
 
2378
2883
  // Persist the PR facts we just learned, so History/stats survive a gh outage.
@@ -2381,8 +2886,12 @@ app.post('/api/pr', async (req, res) => {
2381
2886
  if (pipelineIdForPr) {
2382
2887
  persistPrState(pipelineIdForPr, { url: pr.url, number: parsePrNumber(pr.url), state: 'OPEN' });
2383
2888
  }
2889
+ // Remember the choice for this project (only once a PR was actually created).
2890
+ if (remotes.length) {
2891
+ try { await setPrRemotePrefs(repoDir, { pushRemote, baseRemote }); } catch { /* best-effort */ }
2892
+ }
2384
2893
 
2385
- const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
2894
+ const mergeable = await prMergeable({ projectDir: repoDir, head: feature, repo, headOwner, prUrl: pr.url || null });
2386
2895
  res.json({ ok: true, url: pr.url, mergeable, existed: !!pr.existed });
2387
2896
  });
2388
2897
 
@@ -2421,7 +2930,11 @@ app.post('/api/pr/mergeable', async (req, res) => {
2421
2930
  const feature = state && state.branch && state.branch.feature;
2422
2931
  if (!repoDir || !feature) return res.json({ ok: true, mergeable: 'UNKNOWN' });
2423
2932
 
2424
- const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
2933
+ // A persisted pr_url is repo-agnostic (a fork PR lives in the base repo, which
2934
+ // need not be gh's default for this checkout); the head selector is only the
2935
+ // fallback for rows that never recorded a PR.
2936
+ const prUrl = readPrState(state.id || id)?.url || null;
2937
+ const mergeable = await prMergeable({ projectDir: repoDir, head: feature, prUrl });
2425
2938
  res.json({ ok: true, mergeable });
2426
2939
  } catch {
2427
2940
  res.json({ ok: true, mergeable: 'UNKNOWN' }); // best-effort: never error the refresh
@@ -2474,6 +2987,9 @@ app.post('/api/projects', async (req, res) => {
2474
2987
  try {
2475
2988
  const projects = await addProject({ name: body.name, path: body.path });
2476
2989
  emitChanged('projects-changed', 'created');
2990
+ discoverProject(normalizeProjectPath(body.path), { force: true })
2991
+ .then(() => emitChanged('team-metrics-changed', 'discovered'))
2992
+ .catch(() => { /* offline or not a git repo: discovery retries hourly */ });
2477
2993
  res.json({ projects });
2478
2994
  } catch (err) {
2479
2995
  // addProject only throws on validation (empty/duplicate/not-a-directory), so
@@ -2495,6 +3011,155 @@ app.delete('/api/projects', async (req, res) => {
2495
3011
  }
2496
3012
  });
2497
3013
 
3014
+ // ---------------------------------------------------------------------------
3015
+ // /api/memory/* -> worca's agent memory store (agent-memory-design.md §11). Thin
3016
+ // handlers over src/core/memory-store.mjs: names are validated BEFORE any fs call,
3017
+ // MemoryError codes map to statuses, every write snapshots (the store does it) and
3018
+ // broadcasts `memory-changed`. Two route families — `global` and `projects/:key` —
3019
+ // share ONE handler set through resolveMemoryScope().
3020
+ // ---------------------------------------------------------------------------
3021
+ function memoryHttpStatus(err) {
3022
+ const code = err && err.code;
3023
+ if (code === 'ENAME') return 400;
3024
+ if (code === 'ECASE' || code === 'EFULL') return 409;
3025
+ if (code === 'ETOOBIG') return 413;
3026
+ if (code === 'ENOSCOPE') return 404;
3027
+ return 500;
3028
+ }
3029
+ function memoryError(res, err) {
3030
+ const status = memoryHttpStatus(err);
3031
+ const message = String(err && err.message ? err.message : err).replace(/^memory: /, '');
3032
+ if (status === 500) console.error(`[worca-ui] memory: ${err && err.stack ? err.stack : message}`);
3033
+ return res.status(status).json({ error: status === 500 ? 'memory store error' : message });
3034
+ }
3035
+
3036
+ /** Thread-less, seq-less frame (B5): an Ask tool, a REST write or a run that mounted memory
3037
+ * changed a scope; open Memory views refetch. Best effort. Exported through _testing. */
3038
+ function emitMemoryChanged(scopeKeyStr) {
3039
+ try { broadcast({ type: 'memory-changed', scope: scopeKeyStr }); return true; } catch { return false; }
3040
+ }
3041
+
3042
+ /** { scope, key, project } for the global family or a REGISTERED project key; null ⇒ 404.
3043
+ * `family` is passed by registerMemoryRoutes rather than inferred (I2-#19): express populates
3044
+ * `req.params.key` only for the `projects/:key` prefix, so `req.params.key === undefined` WOULD
3045
+ * separate the two registrations — an implicit path-shape coincidence where one named argument
3046
+ * reads as intent. The two prefixes never collide: every second segment is a literal. */
3047
+ async function resolveMemoryScope(req, family) {
3048
+ if (family === 'global') return { scope: GLOBAL_SCOPE, key: 'global', project: null };
3049
+ const key = String(req.params.key || '');
3050
+ if (!PROJECT_KEY_RE.test(key)) return null; // the shape store.mjs#projectKey produces
3051
+ const p = (await listProjects()).find((x) => x.key === key);
3052
+ if (!p) return null;
3053
+ return { scope: projectScope(key), key: `projects/${key}`, project: { key: p.key, name: p.name, path: p.path } };
3054
+ }
3055
+
3056
+ /** Start a defragment run through the ONE run handler (§7.3): the wrapper only builds the body.
3057
+ * startRunHandler reads `req.body` exactly once, at its top, so rewriting it here is sound. */
3058
+ async function defragRequest(req, res, { memoryScope, projectKey: key }) {
3059
+ if (!PROJECT_KEY_RE.test(String(key || ''))) return res.status(404).json({ error: 'project not found' });
3060
+ const p = (await listProjects()).find((x) => x.key === key);
3061
+ if (!p) return res.status(404).json({ error: 'project not found' });
3062
+ if (!p.exists) return badRequest(res, `project path is missing: ${p.path}`); // else startRunHandler would mkdir it
3063
+ req.body = {
3064
+ projectDir: p.path,
3065
+ prompt: memoryScope === 'global' ? 'Defragment global memory.' : `Defragment the memory of project ${p.name}.`,
3066
+ title: memoryScope === 'global' ? 'Memory defragment (global)' : `Memory defragment: ${p.name}`,
3067
+ workflowId: MEMORY_DEFRAG_WORKFLOW_ID,
3068
+ guardrailsId: 'normal',
3069
+ memoryScope,
3070
+ ...(req.body && req.body.mock === true ? { mock: true } : {}),
3071
+ };
3072
+ return startRunHandler(req, res);
3073
+ }
3074
+
3075
+ function registerMemoryRoutes(prefix, { family }) {
3076
+ const scoped = (handler) => async (req, res) => {
3077
+ try {
3078
+ const ctx = await resolveMemoryScope(req, family);
3079
+ if (!ctx) return res.status(404).json({ error: 'project not found' });
3080
+ return await handler(req, res, ctx);
3081
+ } catch (err) { return memoryError(res, err); }
3082
+ };
3083
+ const named = (req, res) => {
3084
+ const name = String(req.params.name || '');
3085
+ if (!isValidMemoryName(name)) { badRequest(res, `invalid memory name — ${MEMORY_NAME_HELP}`); return null; }
3086
+ return name;
3087
+ };
3088
+ /** B23: while a defragment run is live on this scope, that run's final sync would silently
3089
+ * overwrite a REST write ("the run's version wins", memory-sync.mjs) — refuse instead. */
3090
+ const defragLocked = (res, key) => {
3091
+ const live = liveDefragRun(key);
3092
+ if (!live) return false;
3093
+ res.status(409).json({ error: 'a defragment run is live on this memory scope — wait for it to finish', runId: live.id });
3094
+ return true;
3095
+ };
3096
+ const onError = (p, err) => console.warn(`[worca-ui] memory: ${p}: ${err && err.message ? err.message : err}`);
3097
+
3098
+ app.get(prefix, scoped(async (_req, res, { scope, key, project }) => {
3099
+ 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 });
3101
+ }));
3102
+ app.get(`${prefix}/files/:name`, scoped(async (req, res, { scope }) => {
3103
+ const name = named(req, res); if (name === null) return;
3104
+ const f = await readMemory(memoryRoot(), scope, name);
3105
+ if (!f) return res.status(404).json({ error: 'memory file not found' });
3106
+ res.json({ name, text: f.text, meta: f.meta, body: f.body });
3107
+ }));
3108
+ app.put(`${prefix}/files/:name`, scoped(async (req, res, { scope, key }) => {
3109
+ const name = named(req, res); if (name === null) return;
3110
+ const text = req.body && typeof req.body.text === 'string' ? req.body.text : null;
3111
+ if (text === null) return badRequest(res, 'text (string) is required');
3112
+ if (defragLocked(res, key)) return;
3113
+ const r = await withStoreLock(memoryRoot(), () => writeMemory(memoryRoot(), scope, name, text, { source: 'user', caps: memoryCaps() }));
3114
+ emitMemoryChanged(key);
3115
+ res.json({ ok: true, name, created: r.created, bytes: r.bytes });
3116
+ }));
3117
+ app.delete(`${prefix}/files/:name`, scoped(async (req, res, { scope, key }) => {
3118
+ const name = named(req, res); if (name === null) return;
3119
+ if (defragLocked(res, key)) return;
3120
+ const removed = await withStoreLock(memoryRoot(), () => removeMemory(memoryRoot(), scope, name, { source: 'user' }));
3121
+ if (!removed) return res.status(404).json({ error: 'memory file not found' });
3122
+ emitMemoryChanged(key);
3123
+ res.json({ ok: true });
3124
+ }));
3125
+ app.get(`${prefix}/history`, scoped(async (_req, res, { scope }) => {
3126
+ const snapshots = (await listSnapshots(memoryRoot(), scope)).map((s) => ({ id: s.id, files: s.files }));
3127
+ res.json({ snapshots });
3128
+ }));
3129
+ app.post(`${prefix}/history/:id/restore`, scoped(async (req, res, { scope, key }) => {
3130
+ if (defragLocked(res, key)) return;
3131
+ await withStoreLock(memoryRoot(), () => restoreSnapshot(memoryRoot(), scope, String(req.params.id || ''), { source: 'user' })); // ENAME -> 400, ENOSCOPE -> 404
3132
+ emitMemoryChanged(key);
3133
+ res.json({ ok: true });
3134
+ }));
3135
+ app.post(`${prefix}/defragment`, async (req, res) => {
3136
+ try {
3137
+ if (family === 'projects') return await defragRequest(req, res, { memoryScope: 'project', projectKey: String(req.params.key || '') });
3138
+ const key = req.body && typeof req.body.projectKey === 'string' ? req.body.projectKey.trim() : '';
3139
+ if (!key) return badRequest(res, 'projectKey is required — a global defragment run is hosted by a project');
3140
+ return await defragRequest(req, res, { memoryScope: 'global', projectKey: key });
3141
+ } catch (err) { return memoryError(res, err); }
3142
+ });
3143
+ }
3144
+
3145
+ // Every scope's health in one read. Fetched ON DEMAND — when the Memory tab or a Projects
3146
+ // expander opens, and on a `memory-changed` frame — never on a timer: each call reads and
3147
+ // hashes every file of every registered project's scope (I2-#16).
3148
+ app.get('/api/memory/health', async (_req, res) => {
3149
+ try {
3150
+ const caps = memoryCaps();
3151
+ const g = await memoryScopeReport(memoryRoot(), GLOBAL_SCOPE, caps);
3152
+ const projects = [];
3153
+ for (const p of await listProjects()) {
3154
+ const r = await memoryScopeReport(memoryRoot(), projectScope(p.key), caps);
3155
+ projects.push({ key: p.key, name: p.name, health: r.health, defragRunId: liveDefragRun(`projects/${p.key}`)?.id || null });
3156
+ }
3157
+ res.json({ global: { health: g.health, defragRunId: liveDefragRun('global')?.id || null }, projects });
3158
+ } catch (err) { return memoryError(res, err); }
3159
+ });
3160
+ registerMemoryRoutes('/api/memory/global', { family: 'global' });
3161
+ registerMemoryRoutes('/api/memory/projects/:key', { family: 'projects' });
3162
+
2498
3163
  // ---------------------------------------------------------------------------
2499
3164
  // Filesystem browsing for the add-project folder selector. Hybrid picker:
2500
3165
  // POST /api/fs/pick-folder opens the native OS dialog (the server runs on the
@@ -2557,9 +3222,13 @@ app.post('/api/workspaces', async (req, res) => {
2557
3222
  : [];
2558
3223
  if (projectPaths.length < 2) return badRequest(res, 'a workspace needs at least 2 member projects');
2559
3224
  try {
2560
- const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description });
3225
+ // No explicit home (the create wizard no longer asks): adopt the one member that already
3226
+ // records, if there is exactly one; every other case is "Choose…" on the workspace card.
3227
+ const explicit = typeof body.metricsProject === 'string' && body.metricsProject ? body.metricsProject : null;
3228
+ const metricsProject = explicit ?? await autoMetricsHome(projectPaths);
3229
+ const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description, metricsProject });
2561
3230
  emitChanged('workspaces-changed', 'created');
2562
- res.status(201).json({ workspace });
3231
+ res.status(201).json({ workspace, metricsHomeAuto: !explicit && !!metricsProject });
2563
3232
  } catch (err) {
2564
3233
  const status = workspaceErrorStatus(err && err.code);
2565
3234
  return res.status(status).json({ error: err && err.message ? err.message : String(err) });
@@ -2572,14 +3241,21 @@ app.patch('/api/workspaces/:id', async (req, res) => {
2572
3241
  const body = req.body || {};
2573
3242
  // Immutability (defense-in-depth, §2.3): the project set never changes via PATCH.
2574
3243
  if ('projectPaths' in body || 'projectKeys' in body) {
2575
- return badRequest(res, 'a workspace project set is immutable; PATCH accepts only name/description');
3244
+ return badRequest(res, 'a workspace project set is immutable; PATCH accepts only name/description/metricsProject');
2576
3245
  }
2577
3246
  // Pass through only the editable fields.
2578
3247
  const patch = {};
2579
3248
  if (typeof body.name === 'string') patch.name = body.name;
2580
3249
  if (typeof body.description === 'string') patch.description = body.description;
3250
+ if ('metricsProject' in body) {
3251
+ if (body.metricsProject !== null && typeof body.metricsProject !== 'string') {
3252
+ return badRequest(res, 'metricsProject must be a member project path or null');
3253
+ }
3254
+ patch.metricsProject = body.metricsProject;
3255
+ }
2581
3256
  try {
2582
3257
  const workspace = await updateWorkspace(id, patch);
3258
+ if ('metricsProject' in patch) emitChanged('workspaces-changed', 'metrics-home');
2583
3259
  res.json({ workspace });
2584
3260
  } catch (err) {
2585
3261
  const status = workspaceErrorStatus(err && err.code);
@@ -2820,7 +3496,20 @@ const settingsState = () => ({
2820
3496
  titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
2821
3497
  titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
2822
3498
  hideBuiltinModels: hideBuiltinModels(),
2823
- });
3499
+ theme: storedTheme(), // system | light | dark (dark-mode design §6)
3500
+ });
3501
+
3502
+ /** Settings ▸ Auto workflow model: the stored id + what the classifier will actually use
3503
+ * (env override > stored catalog id > the Sonnet-class default). Async because the
3504
+ * catalog is. */
3505
+ async function autoModelState() {
3506
+ const models = await listModels('');
3507
+ const stored = storedAutoWorkflowModel();
3508
+ const model = resolveAutoModel(models, { setting: stored });
3509
+ const source = process.env[AUTO_MODEL_ENV]?.trim() ? 'env'
3510
+ : (stored && model.toLowerCase() === stored.toLowerCase()) ? 'settings' : 'default';
3511
+ return { autoWorkflowModel: stored, autoWorkflowModelEffective: { model, source } };
3512
+ }
2824
3513
 
2825
3514
  // ---------------------------------------------------------------------------
2826
3515
  // Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
@@ -2865,8 +3554,8 @@ app.post('/api/shutdown', (req, res) => {
2865
3554
  setImmediate(() => uiControl.onShutdown('request'));
2866
3555
  });
2867
3556
 
2868
- app.get('/api/settings', (_req, res) => {
2869
- res.json({ ...settingsState(), chat: chatPrefs(), app: APP_INFO });
3557
+ app.get('/api/settings', async (_req, res) => {
3558
+ res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs(), app: APP_INFO });
2870
3559
  });
2871
3560
 
2872
3561
  app.get('/api/budget', (_req, res) => {
@@ -2881,6 +3570,9 @@ app.post('/api/settings', async (req, res) => {
2881
3570
  const hasDebugSpawnKey = has('debugSpawnEnabled');
2882
3571
  const hasTitleModelKey = has('titleModel');
2883
3572
  const hasHideBuiltinKey = has('hideBuiltinModels');
3573
+ const hasThemeKey = has('theme');
3574
+ const hasAutoKey = has('autoWorkflowModel');
3575
+ const autoModels = hasAutoKey ? await listModels('') : null;
2884
3576
  // #422: the title model is a SELECT over the catalog, so an id that is not a
2885
3577
  // catalog member is a client bug (or a stale option) — refuse it here rather
2886
3578
  // than store an id resolveModelEnv could never route.
@@ -2913,6 +3605,8 @@ app.post('/api/settings', async (req, res) => {
2913
3605
  }
2914
3606
  }
2915
3607
  if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
3608
+ if (hasThemeKey) assertThemeInput(body.theme);
3609
+ if (hasAutoKey) assertAutoWorkflowModelInput(body.autoWorkflowModel ?? '', autoModels);
2916
3610
  // Root first: it is the one key whose setter can still fail AFTER the asserts
2917
3611
  // above (an unusable path), so every other key's write must come after it or
2918
3612
  // a mixed POST would answer 400 with those keys already applied on disk.
@@ -2933,11 +3627,13 @@ app.post('/api/settings', async (req, res) => {
2933
3627
  if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2934
3628
  if (hasTitleModelKey) await setTitleModel(titleModelInput);
2935
3629
  if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
3630
+ if (hasThemeKey) await setTheme(body.theme);
3631
+ if (hasAutoKey) await setAutoWorkflowModel(body.autoWorkflowModel ?? '', { models: autoModels });
2936
3632
  if (hasBudgetKey) emitChanged('budget-changed');
2937
3633
  // Other open tabs repaint their Settings cards (a stale tab could otherwise
2938
3634
  // "save" its old checkbox state over this one with no feedback to either).
2939
- if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
2940
- res.json({ ...settingsState(), chat: chatPrefs() });
3635
+ if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasAutoKey) emitChanged('settings-changed');
3636
+ res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
2941
3637
  } catch (err) {
2942
3638
  // The setters throw only on an unusable path -> client error (400).
2943
3639
  return badRequest(res, err && err.message ? err.message : String(err));
@@ -3016,7 +3712,7 @@ app.post('/api/config', async (req, res) => {
3016
3712
  // validates model/effort against the effective catalog exactly like setStep
3017
3713
  // (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
3018
3714
  // maxCycles to >= 1 (it never throws).
3019
- // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId? }
3715
+ // body: { projectDir, workflowId, nodes?:{[id]:{model,effort}}, feedbacks?:{[id]:{maxCycles}}, wires?:{[wireId]:{maxCycles}}, activeWorkflowId?, humanInLoop? }
3020
3716
  // ---------------------------------------------------------------------------
3021
3717
  app.patch('/api/config', async (req, res) => {
3022
3718
  const body = req.body || {};
@@ -3066,6 +3762,7 @@ app.patch('/api/config', async (req, res) => {
3066
3762
  if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
3067
3763
  await setActiveWorkflow(projectDir, active);
3068
3764
  }
3765
+ if (typeof body.humanInLoop === 'boolean') await setHumanInLoop(projectDir, body.humanInLoop);
3069
3766
  const config = await readRunConfig(projectDir);
3070
3767
  res.json({ config });
3071
3768
  } catch (err) {
@@ -3437,9 +4134,9 @@ app.get('/api/workflows', async (req, res) => {
3437
4134
  const all = await listWorkflows({ includeArchived: true });
3438
4135
  return res.json({ workflows: all.filter((w) => w.archivedAt) });
3439
4136
  }
3440
- // CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The built-in is
3441
- // never a persisted row (listWorkflows filters its id), so it cannot appear twice.
3442
- res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, ...(await listWorkflows())] });
4137
+ // CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, GRAPH_MEMORY_DEFRAG_WORKFLOW, ...listWorkflows() ]. The
4138
+ // built-ins are never persisted rows (listWorkflows filters their ids), so none appears twice.
4139
+ res.json({ workflows: [GRAPH_DEFAULT_WORKFLOW, GRAPH_MEMORY_DEFRAG_WORKFLOW, ...(await listWorkflows())] });
3443
4140
  } catch (err) {
3444
4141
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3445
4142
  }
@@ -3550,6 +4247,7 @@ app.delete('/api/workflows/:id', async (req, res) => {
3550
4247
  const id = req.params.id;
3551
4248
  // The built-in default is not in the user store and must never be deleted.
3552
4249
  if (id === 'wf_default') return badRequest(res, 'the default workflow cannot be deleted');
4250
+ if (id === MEMORY_DEFRAG_WORKFLOW_ID) return badRequest(res, 'the Memory defragment workflow cannot be deleted');
3553
4251
  try {
3554
4252
  const removed = await deleteWorkflow(id); // CONV-1: await
3555
4253
  if (!removed) return res.status(404).json({ error: 'workflow not found' });
@@ -3736,12 +4434,58 @@ const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId,
3736
4434
  const askDeleting = new Set();
3737
4435
  const askFollowers = new Map(); // threadId -> Set<{detach}>
3738
4436
  const ASK_JOB_MAX_BUFFER = 5000; // same arithmetic as MAX_BUFFER: deltas dominate; eviction ⇒ client seq-gap re-sync
4437
+ const askDeferred = new Map(); // threadId → Array<() => Promise>: workflow-card event turns that arrived while a turn was running (PD5/PD26), FIFO
4438
+
4439
+ /** A `role:'system'` notice row + its broadcast — the shape attachAskFollower posts. */
4440
+ function postAskSystemNotice(threadId, text) {
4441
+ try {
4442
+ const m = askAppendMessage(threadId, { role: 'system', text, blocks: [{ kind: 'notice', text }] });
4443
+ broadcast({ type: 'ask-message', threadId, message: m });
4444
+ } catch { /* thread deleted */ }
4445
+ }
4446
+
4447
+ /**
4448
+ * Start the oldest queued workflow-card event turn (PD26). A starter resolves {ok:false,status,error}
4449
+ * — it never throws — when the reservation fails at START time (403: the turn the user waited on spent
4450
+ * the last of the total cost window, `_complete` writes the ledger BEFORE `_emit('done')`; 429: the
4451
+ * global cap; 409: a typed message won the race). A failed starter posts a system notice and the NEXT
4452
+ * one is tried; a started turn's own settleJob drains the rest. The queue is re-read per iteration: a
4453
+ * starter pushed while `await next()` ran lives in a fresh array (the queue is re-created after the
4454
+ * delete below).
4455
+ */
4456
+ async function drainAskDeferred(threadId) {
4457
+ for (;;) {
4458
+ const queue = askDeferred.get(threadId);
4459
+ if (!queue || !queue.length) return;
4460
+ const next = queue.shift();
4461
+ if (!queue.length) askDeferred.delete(threadId);
4462
+ let r = null;
4463
+ try { r = await next(); } catch (e) { r = { ok: false, error: e && e.message ? e.message : String(e) }; }
4464
+ if (r && r.ok) return; // its settleJob continues the chain
4465
+ postAskSystemNotice(threadId, `Ask Worca could not reply to the workflow card: ${(r && r.error) || 'unknown error'}`);
4466
+ }
4467
+ }
3739
4468
 
3740
4469
  function askInFlight(threadId) {
3741
4470
  const job = askJobs.get(threadId);
3742
4471
  return job && job.status === 'running' ? job : null;
3743
4472
  }
3744
4473
 
4474
+ /** How many live runs this thread still follows: undetached followers (askFollowers is the truth, the way
4475
+ * askInFlight reads askJobs — ask_run_links.status is written BY the follower and stays `running` across a
4476
+ * restart that dropped every follower) whose runs-Map entry exists and has not settled. */
4477
+ function askTrackingCount(threadId) {
4478
+ const set = askFollowers.get(threadId);
4479
+ if (!set) return 0;
4480
+ let n = 0;
4481
+ for (const f of set) {
4482
+ if (f.detached) continue;
4483
+ const entry = liveRunEntry(f.runId);
4484
+ if (entry && !SETTLED_RUN.has(String(entry.status || ''))) n += 1;
4485
+ }
4486
+ return n;
4487
+ }
4488
+
3745
4489
  function askRunningCount() {
3746
4490
  let n = 0;
3747
4491
  for (const job of askJobs.values()) if (job.status === 'running') n += 1;
@@ -3814,7 +4558,10 @@ app.get('/api/ask/threads', (req, res) => {
3814
4558
  try {
3815
4559
  const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
3816
4560
  const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
3817
- const threads = askListThreads({ limit }).map((t) => ({ ...t, inFlight: !!askInFlight(t.id) }));
4561
+ const threads = askListThreads({ limit }).map((t) => {
4562
+ const trackingRuns = askTrackingCount(t.id);
4563
+ return { ...t, inFlight: !!askInFlight(t.id), tracking: trackingRuns > 0, trackingRuns };
4564
+ });
3818
4565
  // total = EVERY saved chat (the History popover's meter), not the capped page above.
3819
4566
  res.json({ threads, total: askCountThreads() });
3820
4567
  } catch (err) {
@@ -3905,6 +4652,21 @@ app.get('/api/ask/threads/:id', (req, res) => {
3905
4652
  }
3906
4653
  });
3907
4654
 
4655
+ // Ask Worca progress card hydration (plan D9): the run's detail state by pipeline id OR live run id, no store key.
4656
+ const ASK_RUN_REF_RE = /^(?:[0-9a-f]{8}|[0-9a-zA-Z-]{9,64})$/;
4657
+ app.get('/api/ask/runs/:id', (req, res) => {
4658
+ const id = String(req.params.id || '');
4659
+ if (!ASK_RUN_REF_RE.test(id)) return res.status(400).json({ error: 'invalid run id' });
4660
+ try {
4661
+ const entry = liveRunEntry(id);
4662
+ const state = readPipelineStateById(entry && entry.pipelineId ? entry.pipelineId : id);
4663
+ if (!state) return res.status(404).json({ error: 'run not found' });
4664
+ res.json({ state, live: entry ? { runId: entry.id, status: entry.status } : null });
4665
+ } catch (err) {
4666
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
4667
+ }
4668
+ });
4669
+
3908
4670
  app.patch('/api/ask/threads/:id', (req, res) => {
3909
4671
  const id = askIdParam(res, req.params.id, 'thread');
3910
4672
  if (!id) return;
@@ -3950,6 +4712,9 @@ app.patch('/api/ask/threads/:id', (req, res) => {
3950
4712
  // Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
3951
4713
  async function deleteAskThreadFully(id) {
3952
4714
  askDeleting.add(id);
4715
+ // Outside the `if (job)` block below: a thread deleted while it had a queued
4716
+ // event turn but no live job entry would otherwise keep its queue forever.
4717
+ askDeferred.delete(id);
3953
4718
  try {
3954
4719
  const stopJob = () => {
3955
4720
  const job = askJobs.get(id);
@@ -4119,6 +4884,12 @@ function askValidateScope(raw) {
4119
4884
  return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
4120
4885
  }
4121
4886
 
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. */
4889
+ async function askSystemPromptFor(catalog) {
4890
+ return askBuildSystemPrompt(catalog);
4891
+ }
4892
+
4122
4893
  /** Resolve the VALIDATED client context into the server-side shape
4123
4894
  * buildContextHeader consumes (§6.5: server-resolved rows only — never
4124
4895
  * client-supplied titles or paths). Every lookup is individually guarded:
@@ -4151,6 +4922,18 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4151
4922
  }
4152
4923
  }
4153
4924
  } catch { /* absent line */ }
4925
+ // The Team metrics page's selection (validated slugs); only the scope NAME is resolved here.
4926
+ try {
4927
+ if (ctx.tmScope) {
4928
+ const [kind, tmId] = ctx.tmScope.split(':');
4929
+ let name = null;
4930
+ if (kind === 'project') name = (await listProjects()).find((x) => x.key === tmId)?.name ?? null;
4931
+ else if (kind === 'workspace') name = (await readWorkspace(tmId))?.name ?? null;
4932
+ if (name != null) {
4933
+ out.teamMetrics = { kind, id: tmId, name, range: ctx.tmRange || 'this-month', groupBy: ctx.tmGroupBy || null, filter: ctx.tmFilter || null };
4934
+ }
4935
+ }
4936
+ } catch { /* absent line */ }
4154
4937
  try {
4155
4938
  if (ctx.pipelineId) {
4156
4939
  const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
@@ -4180,12 +4963,24 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4180
4963
  for (const m of askListMessages(threadId)) {
4181
4964
  if (!Array.isArray(m.blocks)) continue;
4182
4965
  for (const b of m.blocks) {
4183
- if (b && b.kind === 'card') {
4184
- cards.push({
4966
+ if (!(b && b.kind === 'card')) continue;
4967
+ // P3 (PD25): a workflow card names the workflow it proposes and only owns a
4968
+ // workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
4969
+ const wf = !!(b.card && b.card.type === 'workflow');
4970
+ 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 || '' });
4973
+ continue;
4974
+ }
4975
+ cards.push(wf
4976
+ ? {
4977
+ id: b.id, type: 'workflow', state: b.state, name: (b.card && b.card.name) || '',
4978
+ workflowId: b.workflowId || null, targetName: (b.card && b.card.projectName) || '',
4979
+ }
4980
+ : {
4185
4981
  id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
4186
4982
  targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
4187
4983
  });
4188
- }
4189
4984
  }
4190
4985
  }
4191
4986
  if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
@@ -4210,7 +5005,164 @@ function mockAskCard(ctx = {}, text = '') {
4210
5005
  const target = ctx.workspaceId
4211
5006
  ? { workspaceId: ctx.workspaceId }
4212
5007
  : { projectKey: ctx.projectKey || 'mock-project-00000000' };
4213
- return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run' };
5008
+ return { ...target, workflowId: 'wf_default', guardrailsId: 'normal', brief: text.slice(0, 200) || 'Mock run',
5009
+ note: 'Mock proposal — a fixed shape so the offline card can be exercised.' };
5010
+ }
5011
+
5012
+ /**
5013
+ * The ONE turn starter (spec §8.4): the typed-message route and the workflow-card event path both land here.
5014
+ * SYNCHRONOUS until the first write — the §6.2.2 guards + the slot reservation run before any await, so two callers
5015
+ * cannot interleave (the route's earlier checks are only the fast-path 4xx). Resolves {ok:false,status,error} for the
5016
+ * 403/409/429 cases the message route maps to HTTP; {ok:true,…} once the turn is running.
5017
+ * @param {{threadId:string, thread:object, ctx:object, model:string, effort:string, text:string,
5018
+ * files?:Array, synthetic?:{notice:string}|null}} o
5019
+ * text what the MODEL gets as the user message: the typed text, or the `[worca event] …` line (synthetic)
5020
+ * synthetic the user row renders as a notice (never a bubble) and never titles the thread (PD6)
5021
+ */
5022
+ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, files = [], synthetic = null }) {
5023
+ // §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
5024
+ // top 409/429 pair and here resolves in microtasks (validateModelEffort ->
5025
+ // composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
5026
+ // reads), so the route is macrotask-atomic and two POSTs cannot interleave
5027
+ // (empirically instrumented). The reservation is what keeps that true if
5028
+ // any of those readers ever becomes genuinely async: it is synchronous —
5029
+ // check-and-set cannot interleave — and runs BEFORE the first write, so a
5030
+ // loser leaves no rows.
5031
+ if (askDeleting.has(id)) return { ok: false, status: 409, error: 'thread is being deleted' };
5032
+ if (askInFlight(id)) return { ok: false, status: 409, error: 'turn in flight' };
5033
+ const budget = budgetStatus(); // P3: the event path needs the same gate the route head applies
5034
+ if (budget.blocked) return { ok: false, status: 403, error: 'total cost limit reached', budget };
5035
+ if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
5036
+ return { ok: false, status: 429, error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` };
5037
+ }
5038
+ const prev = askJobs.get(id);
5039
+ if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
5040
+ const job = {
5041
+ turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
5042
+ events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
5043
+ startedAt: new Date().toISOString(), graceTimer: null,
5044
+ };
5045
+ askJobs.set(id, job);
5046
+
5047
+ let asstMsg = null;
5048
+ let turn;
5049
+ let echoAttachments = [];
5050
+ try {
5051
+ // Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
5052
+ // `ctx` (pin-merged) rather than cv.context: the stored row is what restores
5053
+ // the selector on reopen and what the MCP child reads for tool defaulting.
5054
+ askUpdateThread(id, { context: ctx, model, effort });
5055
+ // §7.4 — NOTHING is stamped on the row before the 202: the thread stays
5056
+ // untitled (the header reads "Ask Worca") until the D13 background title
5057
+ // announces itself. titleWasAuto gates that call: a title given at THREAD
5058
+ // CREATION is the user's, and the haiku call must never fire for it
5059
+ // (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
5060
+ // empty haiku result (turn.mjs _kickoffTitle), never written here.
5061
+ const titleWasAuto = thread.title == null;
5062
+ const deterministicTitle = titleWasAuto && !synthetic ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
5063
+ // P3: a synthetic row keeps the EVENT as its text (buildRestoredPrompt replays it truthfully) and carries the
5064
+ // human notice as a block the panel renders instead of a bubble.
5065
+ const userMsg = askAppendMessage(id, synthetic
5066
+ ? { role: 'user', text, blocks: [{ kind: 'notice', synthetic: true, text: synthetic.notice }] }
5067
+ : { role: 'user', text });
5068
+ job.userMessageId = userMsg.id;
5069
+ const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
5070
+ // The decoded binary bodies are on disk now. `files` is captured by this
5071
+ // scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
5072
+ // turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
5073
+ // reachable per running thread.
5074
+ for (const f of files) f.data = null;
5075
+ echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
5076
+ if (attRows.length) {
5077
+ // `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
5078
+ // (the UI keys image thumbnails off it, #398).
5079
+ // P3: keep the synthetic notice in front of the attachment blocks (unreachable today — event turns pass no files — but
5080
+ // an unconditional overwrite would silently drop the notice for a future caller).
5081
+ askSetMessageBlocks(userMsg.id, [
5082
+ ...(synthetic ? [{ kind: 'notice', synthetic: true, text: synthetic.notice }] : []),
5083
+ ...attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })),
5084
+ ]);
5085
+ }
5086
+ broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
5087
+ asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model, effort });
5088
+ job.messageId = asstMsg.id;
5089
+
5090
+ // Prompt assembly (§6.5) — the route owns it; the turn only spawns. The header context
5091
+ // resolves the project FIRST so the turn can mount its memory (native rules).
5092
+ const catalog = await askBuildCatalog();
5093
+ const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
5094
+ const { inline, listed } = askSelectInlineAttachments(withText);
5095
+ const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
5096
+ const systemPrompt = await askSystemPromptFor(catalog);
5097
+ const header = askBuildContextHeader(headerCtx);
5098
+ const prompt = askBuildTurnPrompt(header, text, inline);
5099
+ const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
5100
+ const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
5101
+ const attachmentNames = {};
5102
+ for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
5103
+
5104
+ turn = createAskTurn({
5105
+ threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
5106
+ prompt, systemPrompt, restoredPrompt,
5107
+ model, effort,
5108
+ resumeSessionId: thread.sessionId || null,
5109
+ firstTurn: !synthetic && userMsg.seq === 1 && titleWasAuto, // P3: an event never titles the thread (D13 guard kept)
5110
+ firstText: text,
5111
+ deterministicTitle,
5112
+ pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
5113
+ 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
+ mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
5115
+ attachmentNames,
5116
+ deps: {
5117
+ onFrame: stampAskFrames(id, job),
5118
+ onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
5119
+ onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
5120
+ onWorktreeMutation: () => { emitAskWorktrees(id); },
5121
+ // A remember/forget in the MCP child is the same scope change a REST write makes (B29).
5122
+ // The key is parsed out of worca's OWN tool result, never written by the model; shape-check
5123
+ // it anyway before it rides a broadcast (I2-#22).
5124
+ onMemoryMutation: ({ scope }) => {
5125
+ if (scope === 'global' || (typeof scope === 'string' && scope.startsWith('projects/') && PROJECT_KEY_RE.test(scope.slice('projects/'.length)))) emitMemoryChanged(scope);
5126
+ },
5127
+ trackRun: (input, { pin } = {}) => askTrackRun(id, input, pin ?? null),
5128
+ },
5129
+ });
5130
+ job.turn = turn;
5131
+ } catch (err) {
5132
+ // A write/assembly failure must release the reserved slot and never leave
5133
+ // a `streaming` row for the boot sweep to find.
5134
+ if (askJobs.get(id) === job) askJobs.delete(id);
5135
+ if (asstMsg) {
5136
+ try {
5137
+ askFinishMessage(asstMsg.id, {
5138
+ text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
5139
+ status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
5140
+ });
5141
+ } catch { /* thread gone */ }
5142
+ }
5143
+ return { ok: false, status: 500, error: err && err.message ? err.message : String(err) };
5144
+ }
5145
+ const settleJob = (status) => {
5146
+ if (askJobs.get(id) !== job) return;
5147
+ job.status = status;
5148
+ job.graceTimer = setTimeout(() => {
5149
+ if (askJobs.get(id) === job) askJobs.delete(id);
5150
+ }, ASK_LIMITS.jobGraceMs);
5151
+ job.graceTimer.unref?.();
5152
+ // P3 (PD5/PD26): the workflow-card events that arrived while this turn ran start now — status is already terminal,
5153
+ // so askInFlight() is null and the starter's own reservation succeeds; the started turn's settleJob continues the chain.
5154
+ Promise.resolve().then(() => drainAskDeferred(id)).catch((e) => console.error(`[worca-ui] deferred ask turn failed: ${e && e.message ? e.message : e}`));
5155
+ };
5156
+ turn.on('done', () => settleJob('done'));
5157
+ turn.on('error', () => settleJob('error'));
5158
+ // Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
5159
+ Promise.resolve()
5160
+ .then(() => turn.run())
5161
+ .catch((err) => {
5162
+ console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
5163
+ settleJob('error');
5164
+ });
5165
+ return { ok: true, job, userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments };
4214
5166
  }
4215
5167
 
4216
5168
  app.post('/api/ask/threads/:id/messages', async (req, res) => {
@@ -4291,133 +5243,15 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
4291
5243
  }
4292
5244
  }
4293
5245
 
4294
- // §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
4295
- // top 409/429 pair and here resolves in microtasks (validateModelEffort ->
4296
- // composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
4297
- // reads), so the route is macrotask-atomic and two POSTs cannot interleave
4298
- // (empirically instrumented). The reservation is what keeps that true if
4299
- // any of those readers ever becomes genuinely async: it is synchronous —
4300
- // check-and-set cannot interleave — and runs BEFORE the first write, so a
4301
- // loser leaves no rows.
4302
- if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
4303
- if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
4304
- if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
4305
- return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
4306
- }
4307
- const prev = askJobs.get(id);
4308
- if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
4309
- const job = {
4310
- turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
4311
- events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
4312
- startedAt: new Date().toISOString(), graceTimer: null,
4313
- };
4314
- askJobs.set(id, job);
4315
-
4316
- let asstMsg = null;
4317
- let turn;
4318
- let echoAttachments = [];
4319
- try {
4320
- // Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
4321
- // `ctx` (pin-merged) rather than cv.context: the stored row is what restores
4322
- // the selector on reopen and what the MCP child reads for tool defaulting.
4323
- askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
4324
- // §7.4 — NOTHING is stamped on the row before the 202: the thread stays
4325
- // untitled (the header reads "Ask Worca") until the D13 background title
4326
- // announces itself. titleWasAuto gates that call: a title given at THREAD
4327
- // CREATION is the user's, and the haiku call must never fire for it
4328
- // (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
4329
- // empty haiku result (turn.mjs _kickoffTitle), never written here.
4330
- const titleWasAuto = thread.title == null;
4331
- const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
4332
- const userMsg = askAppendMessage(id, { role: 'user', text });
4333
- job.userMessageId = userMsg.id;
4334
- const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
4335
- // The decoded binary bodies are on disk now. `files` is captured by this
4336
- // scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
4337
- // turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
4338
- // reachable per running thread.
4339
- for (const f of files) f.data = null;
4340
- echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
4341
- if (attRows.length) {
4342
- // `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
4343
- // (the UI keys image thumbnails off it, #398).
4344
- askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
4345
- }
4346
- broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
4347
- asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
4348
- job.messageId = asstMsg.id;
4349
-
4350
- // Prompt assembly (§6.5) — the route owns it; the turn only spawns.
4351
- const catalog = await askBuildCatalog();
4352
- const systemPrompt = askBuildSystemPrompt(catalog);
4353
- const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
4354
- const { inline, listed } = askSelectInlineAttachments(withText);
4355
- const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
4356
- const header = askBuildContextHeader(headerCtx);
4357
- const prompt = askBuildTurnPrompt(header, text, inline);
4358
- const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
4359
- const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
4360
- const attachmentNames = {};
4361
- for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
4362
-
4363
- turn = createAskTurn({
4364
- threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
4365
- prompt, systemPrompt, restoredPrompt,
4366
- model: mv.model, effort: mv.effort,
4367
- resumeSessionId: thread.sessionId || null,
4368
- firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
4369
- firstText: text,
4370
- deterministicTitle,
4371
- pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
4372
- mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
4373
- attachmentNames,
4374
- deps: {
4375
- onFrame: stampAskFrames(id, job),
4376
- onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
4377
- onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
4378
- onWorktreeMutation: () => { emitAskWorktrees(id); },
4379
- },
4380
- });
4381
- job.turn = turn;
4382
- } catch (err) {
4383
- // A write/assembly failure must release the reserved slot and never leave
4384
- // a `streaming` row for the boot sweep to find.
4385
- if (askJobs.get(id) === job) askJobs.delete(id);
4386
- if (asstMsg) {
4387
- try {
4388
- askFinishMessage(asstMsg.id, {
4389
- text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
4390
- status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
4391
- });
4392
- } catch { /* thread gone */ }
4393
- }
4394
- return res.status(500).json({ error: err && err.message ? err.message : String(err) });
4395
- }
4396
- const settleJob = (status) => {
4397
- if (askJobs.get(id) !== job) return;
4398
- job.status = status;
4399
- job.graceTimer = setTimeout(() => {
4400
- if (askJobs.get(id) === job) askJobs.delete(id);
4401
- }, ASK_LIMITS.jobGraceMs);
4402
- job.graceTimer.unref?.();
4403
- };
4404
- turn.on('done', () => settleJob('done'));
4405
- turn.on('error', () => settleJob('error'));
4406
- // Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
4407
- Promise.resolve()
4408
- .then(() => turn.run())
4409
- .catch((err) => {
4410
- console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
4411
- settleJob('error');
4412
- });
5246
+ const r = await startAskTurn({ threadId: id, thread, ctx, model: mv.model, effort: mv.effort, text, files });
5247
+ if (!r.ok) return res.status(r.status).json({ error: r.error, ...(r.budget ? { budget: r.budget } : {}) });
4413
5248
  // `attachments` carries the store-minted ids so the sender's own echo can key
4414
5249
  // image thumbnails and the thread budget off them (the ask-message broadcast
4415
5250
  // may have raced ahead of this response, or been missed on a brand-new thread).
4416
- res.status(202).json({ userMessageId: job.userMessageId, assistantMessageId: job.messageId, attachments: echoAttachments });
5251
+ res.status(202).json({ userMessageId: r.userMessageId, assistantMessageId: r.assistantMessageId, attachments: r.attachments });
4417
5252
  } catch (err) {
4418
- // Only pre-reservation throws land here (`job` is block-scoped to the outer
4419
- // try and every post-reservation failure returned from the inner catch), so
4420
- // there is no slot to release.
5253
+ // startAskTurn never throws (it returns {ok:false,…}); only the route's own
5254
+ // pre-checks can land here, so there is no slot to release.
4421
5255
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
4422
5256
  }
4423
5257
  });
@@ -4444,7 +5278,11 @@ function flipCard(threadId, cardId, patch) {
4444
5278
  const block = askUpdateCardBlock(threadId, cardId, patch);
4445
5279
  if (!block) return null;
4446
5280
  const job = askInFlight(threadId);
4447
- const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, patch) : null;
5281
+ // The store SHALLOW-merges `patch.card` on a workflow card; the reducer's
5282
+ // updateBlock replaces `card` wholesale — hand it the MERGED one (PD23),
5283
+ // else the live frame loses every key this patch did not name.
5284
+ const livePatch = patch && patch.card ? { ...patch, card: block.card } : patch;
5285
+ const live = job && job.turn && job.turn.reducer ? job.turn.reducer.updateBlock(cardId, livePatch) : null;
4448
5286
  if (!live) {
4449
5287
  const found = askFindCard(threadId, cardId);
4450
5288
  if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
@@ -4452,27 +5290,197 @@ function flipCard(threadId, cardId, patch) {
4452
5290
  return block;
4453
5291
  }
4454
5292
 
4455
- // D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed').
4456
- app.post('/api/ask/threads/:id/cards/:cardId', (req, res) => {
5293
+ // Card ids with a Save in flight: the mint + the row write await, so the
5294
+ // `state === 'proposed'` check alone would let two Saves both pass and write two rows.
5295
+ const askCardBusy = new Set();
5296
+
5297
+ /** Save a PROPOSED workflow card: adopt the twin (nothing written) or write a new origin:'auto' row (spec §8.4, PD9). */
5298
+ async function saveWorkflowCard(threadId, block, body = {}) {
5299
+ const card = block.card || {};
5300
+ const registry = loadAgentRegistry(AGENTS_DIR);
5301
+ const models = await listModels('');
5302
+ // A non-string `name` would be stringified by cleanText ("[object Object]" as the
5303
+ // workflow name) — only a string counts; anything else falls back to the card's own.
5304
+ const ans = sanitizeProposalAnswer(
5305
+ { decision: 'accept', name: typeof body.name === 'string' ? body.name : undefined, nodes: body.nodes },
5306
+ { proposal: card, models, registry },
5307
+ ) || { name: card.name, nodes: {} };
5308
+ let workflowId; let name; let matched = false; let nodes = card.nodes;
5309
+ // `card.match` is a snapshot from proposal time; a row archived or deleted since
5310
+ // (composer delete) must not be "adopted" as a dangling id — re-check it still
5311
+ // reads (readWorkflow returns null for archived/missing rows; wf_default always reads).
5312
+ if (card.match && card.match.id && (await readWorkflow(card.match.id))) {
5313
+ workflowId = card.match.id; name = card.match.name; matched = true; // adopt: name/nodes ignored, row untouched
5314
+ } else {
5315
+ const r = await revalidateWorkflowProposal({
5316
+ shape: { ...card.shape, name: ans.name }, projectKey: card.projectKey, models, registry,
5317
+ });
5318
+ if (r.match) { workflowId = r.match.id; name = r.match.name; matched = true; } // a twin appeared since the proposal
5319
+ else {
5320
+ name = ans.name;
5321
+ workflowId = await mintAutoWorkflowId(name, async (wid) => !!(await readWorkflow(wid, { includeArchived: true })));
5322
+ await writeGraphWorkflow({ ...applyTunables(r.template, ans.nodes), id: workflowId, name, domain: 'coding', origin: 'auto' });
5323
+ nodes = { ...(card.nodes || {}) };
5324
+ for (const [nid, sel] of Object.entries(ans.nodes)) nodes[nid] = { ...(nodes[nid] || {}), ...sel };
5325
+ }
5326
+ }
5327
+ // `adopted` tells the saved card which line to show ("Uses your saved workflow"
5328
+ // vs "Saved as a new workflow, tagged Auto") — card.match is set on BOTH paths.
5329
+ const flipped = flipCard(threadId, block.id, {
5330
+ state: 'saved', workflowId, card: { name, nodes, match: { id: workflowId, name }, adopted: matched },
5331
+ });
5332
+ return { block: flipped, workflowId, name, matched };
5333
+ }
5334
+
5335
+ /**
5336
+ * The IMMEDIATE twin of drainAskDeferred's failure arm (PD29). The route answers the
5337
+ * flip and `turn:{error,status}` in one 200, but by then the flip frame has already
5338
+ * rebuilt the card element in every tab, so the panel's inline "Ask Worca could not
5339
+ * reply" lands on a detached node and the human sees a saved card and no reply at all.
5340
+ * The system notice row is the only channel that survives the rebuild; the response
5341
+ * keeps `turn.error` for API clients.
5342
+ */
5343
+ function failedEventTurn(threadId, turn) {
5344
+ postAskSystemNotice(threadId, `Ask Worca could not reply to the workflow card: ${turn.error || 'unknown error'}`);
5345
+ return turn;
5346
+ }
5347
+
5348
+ /** Store the synthetic user-row notice and start (or queue) the assistant turn whose prompt is the event (spec §8.4, PD5/PD6/PD26). */
5349
+ async function startWorkflowEventTurn(threadId, block, { declined = false, thenRun = false } = {}) {
5350
+ const thread = askGetThread(threadId);
5351
+ if (!thread) return null;
5352
+ const card = block.card || {};
5353
+ const state = declined ? 'declined' : 'saved';
5354
+ const text = workflowEventPrompt({
5355
+ cardId: block.id, state, workflowId: block.workflowId, name: card.name, thenRun, projectKey: card.projectKey || '',
5356
+ });
5357
+ const notice = workflowNoticeText({ state, name: card.name, matched: !declined && card.adopted === true, thenRun });
5358
+ let mv = await validateModelEffort(thread.model, thread.effort);
5359
+ if (!mv.ok) {
5360
+ const d = (await askCatalog({ withSecrets: false })).default;
5361
+ if (!d) return failedEventTurn(threadId, { error: 'no model available', status: 503 });
5362
+ mv = { ok: true, ...d };
5363
+ }
5364
+ const start = () => startAskTurn({
5365
+ threadId, thread: askGetThread(threadId) || thread, ctx: thread.context || {},
5366
+ model: mv.model, effort: mv.effort, text, synthetic: { notice },
5367
+ });
5368
+ if (askInFlight(threadId)) {
5369
+ // PD5/PD26: the flip stands and the reply waits — settleJob drains the queue
5370
+ // when the running turn ends, and a starter that cannot reserve a slot then
5371
+ // posts a system notice rather than vanishing.
5372
+ if (!askDeferred.has(threadId)) askDeferred.set(threadId, []);
5373
+ askDeferred.get(threadId).push(start);
5374
+ return { deferred: true };
5375
+ }
5376
+ const r = await start();
5377
+ if (r.ok) return { assistantMessageId: r.assistantMessageId };
5378
+ return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
5379
+ }
5380
+
5381
+ /** The metrics card's event turn: the synthetic notice row + the "[worca event] metrics card …" prompt (same queueing as workflow cards). */
5382
+ async function startMetricsEventTurn(threadId, block) {
5383
+ const thread = askGetThread(threadId);
5384
+ if (!thread) return null;
5385
+ const card = block.card || {};
5386
+ const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
5387
+ const result = card.result || null;
5388
+ const text = metricsEventPrompt({ cardId: block.id, state, card, result });
5389
+ const notice = metricsNoticeText({ state, card, result });
5390
+ let mv = await validateModelEffort(thread.model, thread.effort);
5391
+ if (!mv.ok) {
5392
+ const d = (await askCatalog({ withSecrets: false })).default;
5393
+ if (!d) return failedEventTurn(threadId, { error: 'no model available', status: 503 });
5394
+ mv = { ok: true, ...d };
5395
+ }
5396
+ const start = () => startAskTurn({
5397
+ threadId, thread: askGetThread(threadId) || thread, ctx: thread.context || {},
5398
+ model: mv.model, effort: mv.effort, text, synthetic: { notice },
5399
+ });
5400
+ if (askInFlight(threadId)) {
5401
+ if (!askDeferred.has(threadId)) askDeferred.set(threadId, []);
5402
+ askDeferred.get(threadId).push(start);
5403
+ return { deferred: true };
5404
+ }
5405
+ const r = await start();
5406
+ if (r.ok) return { assistantMessageId: r.assistantMessageId };
5407
+ return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
5408
+ }
5409
+
5410
+ // D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed') for a RUN card;
5411
+ // the workflow-card state machine (spec §8.4) for a workflow one. The card is looked up BEFORE
5412
+ // the body is validated because the legal verb set depends on the card's type.
5413
+ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
4457
5414
  const id = askIdParam(res, req.params.id, 'thread');
4458
5415
  if (!id) return;
4459
5416
  const cardId = askIdParam(res, req.params.cardId, 'card');
4460
5417
  if (!cardId) return;
4461
5418
  try {
4462
5419
  if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
4463
- if ((req.body || {}).state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
5420
+ const body = req.body || {};
4464
5421
  const found = askFindCard(id, cardId);
4465
5422
  if (!found) return res.status(404).json({ error: 'card not found' });
4466
- if (found.block.state !== 'proposed') {
4467
- return res.status(409).json({ error: `card is ${found.block.state}` });
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.
5427
+ if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
5428
+ if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
5429
+ if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
5430
+ if (body.state === 'declined') {
5431
+ const block = flipCard(id, cardId, { state: 'declined' });
5432
+ if (!block) return res.status(409).json({ error: 'card vanished' });
5433
+ const turn = await startMetricsEventTurn(id, block);
5434
+ return res.json({ block, turn });
5435
+ }
5436
+ askCardBusy.add(cardId);
5437
+ let block;
5438
+ try {
5439
+ let result;
5440
+ try { result = await applyMetricsChange(found.block.card); }
5441
+ catch (err) {
5442
+ 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
+ }
5444
+ block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
5445
+ } finally { askCardBusy.delete(cardId); }
5446
+ if (!block) return res.status(409).json({ error: 'card vanished' });
5447
+ const turn = await startMetricsEventTurn(id, block);
5448
+ return res.json({ block, turn });
5449
+ }
5450
+ if (!(found.block.card && found.block.card.type === 'workflow')) {
5451
+ if (body.state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
5452
+ if (found.block.state !== 'proposed') {
5453
+ return res.status(409).json({ error: `card is ${found.block.state}` });
5454
+ }
5455
+ const block = flipCard(id, cardId, { state: 'dismissed' });
5456
+ // Dismiss is terminal: the card's parked comment ids can never reach a run,
5457
+ // so drop them here exactly as the launch path does at its own success point
5458
+ // (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
5459
+ try { clearPendingCardComments(cardId); }
5460
+ catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
5461
+ return res.json({ block });
5462
+ }
5463
+ // Workflow card state machine (spec §8.4): proposed → saved | declined. A saved card has no
5464
+ // verb — its event turn proposes the run itself (thenRun) or offers one in chat.
5465
+ if (body.state !== 'saved' && body.state !== 'declined') {
5466
+ return badRequest(res, 'state must be "saved" or "declined"');
4468
5467
  }
4469
- const block = flipCard(id, cardId, { state: 'dismissed' });
4470
- // Dismiss is terminal: the card's parked comment ids can never reach a run,
4471
- // so drop them here exactly as the launch path does at its own success point
4472
- // (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
4473
- try { clearPendingCardComments(cardId); }
4474
- catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
4475
- res.json({ block });
5468
+ if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
5469
+ if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being saved' });
5470
+ if (body.state === 'declined') {
5471
+ const block = flipCard(id, cardId, { state: 'declined' });
5472
+ if (!block) return res.status(409).json({ error: 'card vanished' }); // the thread was deleted between the lookup and the flip
5473
+ const turn = await startWorkflowEventTurn(id, block, { declined: true });
5474
+ return res.json({ block, turn });
5475
+ }
5476
+ askCardBusy.add(cardId);
5477
+ let saved;
5478
+ try { saved = await saveWorkflowCard(id, found.block, body); }
5479
+ finally { askCardBusy.delete(cardId); }
5480
+ // flipCard() null — the row (if written) stays, the card is gone with its thread.
5481
+ if (!saved.block) return res.status(409).json({ error: 'card vanished' });
5482
+ const turn = await startWorkflowEventTurn(id, saved.block, { thenRun: !!found.block.card.thenRun });
5483
+ res.json({ block: saved.block, turn });
4476
5484
  } catch (err) {
4477
5485
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
4478
5486
  }
@@ -5268,6 +6276,86 @@ app.post('/api/pipelines/:id/report-result', async (req, res) => {
5268
6276
  }
5269
6277
  });
5270
6278
 
6279
+ // POST /api/pipelines/:id/report — the metadata-only run report a user can paste
6280
+ // into a GitHub issue. buildRunReport is a pure read-by-id (run-report.mjs) that
6281
+ // resolves BOTH project and workspace runs, so this one route covers both families
6282
+ // and needs no /api/workspaces twin. POST, not GET: the body carries the reporter's
6283
+ // free text and the three opt-in flags, which do not belong in a logged URL.
6284
+ // Worca makes NO network call here — it returns a URL the browser opens.
6285
+ // Like its report-result neighbour above, a malformed id 404s rather than 400s: a
6286
+ // stale bookmark must read as not-found (see resolveRunScope, :2006).
6287
+
6288
+ // The repo `gh issue create` targets, from the SAME bugs.url the prefilled link uses
6289
+ // (APP_INFO.bugsUrl, which falls back to <repo>/issues). Empty for a non-github.com
6290
+ // bugs.url, which makes /report-issue degrade to the browser link.
6291
+ const BUGS_SLUG = repoSlugFromBugsUrl(APP_INFO.bugsUrl);
6292
+
6293
+ /**
6294
+ * The preamble both report routes share: validate the reason, build the payload.
6295
+ * Returns null after ALREADY answering `res` (400 or 404), so callers just bail.
6296
+ */
6297
+ async function reportPayloadOr(res, req) {
6298
+ const body = req.body || {};
6299
+ const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
6300
+ if (!REPORT_REASON_IDS.includes(reason)) {
6301
+ badRequest(res, `reason must be one of: ${REPORT_REASON_IDS.join(', ')}`);
6302
+ return null;
6303
+ }
6304
+ const payload = await buildRunReport(req.params.id, {
6305
+ reason,
6306
+ expectation: typeof body.expectation === 'string' ? body.expectation : '',
6307
+ include: body.include,
6308
+ });
6309
+ if (!payload) {
6310
+ res.status(404).json({ error: 'pipeline not found' });
6311
+ return null;
6312
+ }
6313
+ return payload;
6314
+ }
6315
+
6316
+ /** The browser fallback: a prefilled issues/new URL plus the download filename. */
6317
+ function prefilledIssue(payload) {
6318
+ return { ...buildIssueUrl(payload, { bugsUrl: APP_INFO.bugsUrl }), filename: reportFilename(payload) };
6319
+ }
6320
+
6321
+ app.post('/api/pipelines/:id/report', async (req, res) => {
6322
+ try {
6323
+ const payload = await reportPayloadOr(res, req);
6324
+ if (!payload) return;
6325
+ res.json({ payload, issue: prefilledIssue(payload) });
6326
+ } catch (err) {
6327
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
6328
+ }
6329
+ });
6330
+
6331
+ // POST /api/pipelines/:id/report-issue — the same report, but worca FILES it through
6332
+ // `gh issue create` instead of handing the browser a prefilled link. That exists
6333
+ // because the link cannot carry the report: a prefilled `body=` dies at ~8 KB encoded
6334
+ // and half of all real runs serialize larger than that, so the browser path can only
6335
+ // ever ask the reporter to paste the JSON in by hand. A --body-file has no such cap.
6336
+ //
6337
+ // The payload is REBUILT here from the run id; the client's copy is never accepted.
6338
+ // The redaction contract (run-report.mjs) has exactly one enforcement point, and a
6339
+ // route that trusted a posted payload would be a second, weaker one.
6340
+ //
6341
+ // A gh failure is a 200 with ok:false, not a 5xx: the response carries the prefilled
6342
+ // link so the modal can degrade to today's copy-and-paste flow in one round trip.
6343
+ app.post('/api/pipelines/:id/report-issue', async (req, res) => {
6344
+ try {
6345
+ const payload = await reportPayloadOr(res, req);
6346
+ if (!payload) return;
6347
+ const filed = await createIssue({
6348
+ repo: BUGS_SLUG,
6349
+ title: issueTitle(payload),
6350
+ body: renderIssueBodyFull(payload),
6351
+ });
6352
+ if (filed.ok) return res.json({ ok: true, url: filed.url, labeled: filed.labeled });
6353
+ res.json({ ok: false, kind: filed.kind, error: filed.error, issue: prefilledIssue(payload) });
6354
+ } catch (err) {
6355
+ res.status(500).json({ error: err && err.message ? err.message : String(err) });
6356
+ }
6357
+ });
6358
+
5271
6359
  // ---------------------------------------------------------------------------
5272
6360
  // Install logic (mirrors scripts/install.mjs): copy agents/*.md and
5273
6361
  // skills/worca/** into <projectDir>/.claude/...
@@ -5414,9 +6502,7 @@ app.use((req, res, next) => {
5414
6502
  if (req.method !== 'GET') return next();
5415
6503
  if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
5416
6504
  if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
5417
- res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
5418
- if (err) next();
5419
- });
6505
+ sendIndex(res);
5420
6506
  });
5421
6507
 
5422
6508
  // ---------------------------------------------------------------------------
@@ -5622,6 +6708,8 @@ if (isMain) {
5622
6708
  try { channelHost.start(); } catch (err) {
5623
6709
  console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
5624
6710
  }
6711
+ try { startTeamMetricsBackground({ log: (m) => console.warn(m) }); }
6712
+ catch (err) { console.warn(`[worca-ui] team metrics background: ${err?.message || err}`); }
5625
6713
  });
5626
6714
  }
5627
6715
 
@@ -5631,5 +6719,6 @@ export const _testing = {
5631
6719
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5632
6720
  chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5633
6721
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
6722
+ askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
5634
6723
  uiControl, bearerMatches,
5635
6724
  };