@worca/app 1.2.0-rc.3 → 1.3.0-rc.2

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 +3736 -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 +1589 -212
  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 +447 -29
  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 +1499 -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 +1304 -194
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 ──────────────────────────────────
@@ -183,7 +213,27 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
183
213
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
184
214
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
185
215
  const require = createRequire(import.meta.url);
186
- const PKG_VERSION = require('../package.json').version;
216
+ const PKG = require('../package.json');
217
+ const PKG_VERSION = PKG.version;
218
+ // Settings ▸ About identity, fixed at module load. repository.url is normalised
219
+ // from npm's git forms (git+https, ssh://git@, git://, scp-style git@host:path)
220
+ // to a browsable https URL; releaseUrl points at the tag the release workflow
221
+ // publishes from (.github/workflows/release-npm-app.yml: worca-app-v<version>).
222
+ const repoWebUrl = (raw) => String(raw || '')
223
+ .replace(/^git\+/, '')
224
+ .replace(/^ssh:\/\/git@/, 'https://')
225
+ .replace(/^git:\/\//, 'https://')
226
+ .replace(/^git@([^:/]+):/, 'https://$1/')
227
+ .replace(/\.git$/, '');
228
+ const APP_REPO_URL = repoWebUrl(PKG.repository && PKG.repository.url);
229
+ const APP_INFO = Object.freeze({
230
+ version: PKG_VERSION || '',
231
+ repoUrl: APP_REPO_URL,
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` : ''),
236
+ });
187
237
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
188
238
  // Primaries plus the sub-language grammars their instances register
189
239
  // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
@@ -439,6 +489,8 @@ function emitChanged(type, action) {
439
489
  broadcast({ type, action: action || null });
440
490
  }
441
491
 
492
+ metricsEvents.on('changed', (e) => emitChanged('team-metrics-changed', e && e.action ? e.action : null));
493
+
442
494
  // Every comment mutation in THIS process (the REST routes below) pokes the open
443
495
  // Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
444
496
  // ordering concerns; the client refetches and repaints its CARDS, never the diff.
@@ -491,6 +543,44 @@ function resolvePending(entry, { id = null, reason = 'resolved' } = {}) {
491
543
  return true;
492
544
  }
493
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
+
494
584
  function summarizeRuns() {
495
585
  return [...runs.values()].map((r) => ({
496
586
  runId: r.id,
@@ -591,6 +681,14 @@ function wireRun(entry) {
591
681
  // ...and WHAT went wrong for an error-pause, reset alongside it.
592
682
  entry.pauseDetail = (payload && payload.detail) || null;
593
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
+ }
594
692
  if (payload?.reason === 'cost_pipeline' || payload?.reason === 'cost_total') {
595
693
  emitChanged('budget-changed');
596
694
  }
@@ -848,6 +946,32 @@ app.use('/src/shared', (_req, res) => {
848
946
  res.status(404).type('text/plain').send('Not found');
849
947
  });
850
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
+
851
975
  app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
852
976
 
853
977
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
@@ -1034,7 +1158,7 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
1034
1158
  if (linkPatch.pipelineId && row && row.commentIds.length) {
1035
1159
  try { stampSentRunId(row.commentIds, linkPatch.pipelineId); } catch { /* best effort */ }
1036
1160
  }
1037
- if (patch.cardFailed) {
1161
+ if (patch.cardFailed && cardId) {
1038
1162
  flipCard(threadId, cardId, { state: 'failed', error: patch.cardFailed });
1039
1163
  }
1040
1164
  broadcast({
@@ -1062,13 +1186,82 @@ function attachAskFollower(orch, { threadId, runId, cardId }) {
1062
1186
  set.add(follower);
1063
1187
  }
1064
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
+
1065
1258
  // ---------------------------------------------------------------------------
1066
1259
  // POST /api/run -> start a new orchestration run
1067
1260
  // body (single-project): { projectDir, prompt?, promptMarkdown?, title?, mock? }
1068
1261
  // body (workspace): { workspaceId, prompt?, ... } — mutually exclusive with
1069
1262
  // projectDir (§2.6). Single-project behavior is byte-identical.
1070
1263
  // ---------------------------------------------------------------------------
1071
- app.post('/api/run', async (req, res) => {
1264
+ const startRunHandler = async (req, res) => {
1072
1265
  try {
1073
1266
  const body = req.body || {};
1074
1267
 
@@ -1169,6 +1362,21 @@ app.post('/api/run', async (req, res) => {
1169
1362
  return badRequest(res, err && err.message ? err.message : String(err));
1170
1363
  }
1171
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
+
1172
1380
  // Optional guardrailsId selects the named guardrail set that IS this run's
1173
1381
  // policy (applied uniformly to every member — guardrails are per-run only).
1174
1382
  // Absent/blank/null normalizes to 'permissive' — the empty policy,
@@ -1314,6 +1522,13 @@ app.post('/api/run', async (req, res) => {
1314
1522
 
1315
1523
  const fileProblem = await promptFileProblem(effectiveSource, projectDir);
1316
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);
1317
1532
 
1318
1533
  orch = await createOrchestratorFor({
1319
1534
  projectDir,
@@ -1326,6 +1541,8 @@ app.post('/api/run', async (req, res) => {
1326
1541
  template: workflowRow,
1327
1542
  guardrailsId,
1328
1543
  branch,
1544
+ humanInLoop,
1545
+ ...(memoryScope ? { memoryScope } : {}),
1329
1546
  claude: { permissionMode: 'acceptEdits', mock },
1330
1547
  });
1331
1548
 
@@ -1402,7 +1619,8 @@ app.post('/api/run', async (req, res) => {
1402
1619
  } catch (err) {
1403
1620
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
1404
1621
  }
1405
- });
1622
+ };
1623
+ app.post('/api/run', startRunHandler);
1406
1624
 
1407
1625
  // ---------------------------------------------------------------------------
1408
1626
  // Chat connectivity (chat-connectivity-design.md): persistent channel workers
@@ -1679,7 +1897,15 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
1679
1897
  if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
1680
1898
  }
1681
1899
 
1682
- 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);
1683
1909
  const runId = randomUUID();
1684
1910
  const orch = await createOrchestratorFor({
1685
1911
  projectDir,
@@ -1849,6 +2075,19 @@ app.get('/api/runs/:id/artifact', async (req, res) => {
1849
2075
  }
1850
2076
  });
1851
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
+
1852
2091
  // Shared query-scope resolver for the retained-work routes (recovery-patch GET +
1853
2092
  // discard POST). Returns null after writing the error response itself. The older
1854
2093
  // DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
@@ -1949,6 +2188,24 @@ app.get('/api/history', async (_req, res) => {
1949
2188
  // machine-wide history just to update a badge. Running is derived client-side from the
1950
2189
  // in-memory runs map (live via WS), so it is not included here. Synchronous: the three
1951
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
+
1952
2209
  app.get('/api/counts', (_req, res) => {
1953
2210
  try {
1954
2211
  res.json({
@@ -1975,6 +2232,154 @@ app.get('/api/stats', (req, res) => {
1975
2232
  }
1976
2233
  });
1977
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
+
1978
2383
  // ---------------------------------------------------------------------------
1979
2384
  // POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
1980
2385
  // over the WS as batched `history-pr` events (reuses broadcast(), the same
@@ -2130,7 +2535,27 @@ function commentsPatch(req, res, storeKey, id, cid) {
2130
2535
  const raw = (req.body || {}).resolved;
2131
2536
  if (typeof raw !== 'boolean') return badRequest(res, 'resolved must be a boolean');
2132
2537
  res.json({ comment: setDiffCommentResolved(cid, raw) });
2133
- } 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
+ }
2134
2559
  }
2135
2560
 
2136
2561
  function commentsDelete(res, storeKey, id, cid) {
@@ -2159,6 +2584,11 @@ app.delete('/api/history/:key/:id/comments/:cid', (req, res) => {
2159
2584
  const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2160
2585
  commentsDelete(res, key, req.params.id, cid);
2161
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
+ });
2162
2592
 
2163
2593
  app.get('/api/workspaces/:id/runs/:runId/comments', async (req, res) => {
2164
2594
  const key = commentsWorkspaceKey(res, req.params.id); if (!key) return;
@@ -2178,6 +2608,11 @@ app.delete('/api/workspaces/:id/runs/:runId/comments/:cid', (req, res) => {
2178
2608
  const cid = commentIdParam(res, req.params.cid); if (!cid) return;
2179
2609
  commentsDelete(res, key, req.params.runId, cid);
2180
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
+ });
2181
2616
 
2182
2617
  // Unresolved counts for every run, for the History list pill. Its own endpoint
2183
2618
  // rather than a field on /api/history: that response has a localStorage skeleton
@@ -2311,37 +2746,87 @@ app.post('/api/runs/:id/discard-worktree', async (req, res) => {
2311
2746
  });
2312
2747
 
2313
2748
  // ---------------------------------------------------------------------------
2314
- // POST /api/pr -> push the pipeline's feature branch (if needed) and open a PR
2315
- // against its source branch via the GitHub CLI. Mergeability is read back only
2316
- // 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.
2317
2754
  // ---------------------------------------------------------------------------
2318
- app.post('/api/pr', async (req, res) => {
2319
- const body = req.body || {};
2320
- const id = typeof body.id === 'string' ? body.id.trim() : '';
2321
- if (!id) return badRequest(res, 'id is required');
2322
- if (!(await hasGh())) {
2323
- return res.status(409).json({ error: 'GitHub CLI (gh) is not available' });
2324
- }
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
+ }
2325
2764
 
2326
- // 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; }
2327
2772
  let state = null;
2328
2773
  try {
2329
- if (typeof body.projectKey === 'string' && body.projectKey.trim()) {
2330
- if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(body.projectKey)) {
2331
- 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;
2332
2778
  }
2333
- const data = await readPipelineByKey(body.projectKey, id);
2779
+ const data = await readPipelineByKey(src.projectKey, id);
2334
2780
  state = data && data.state;
2335
2781
  } else {
2336
- const projectDir = resolveProjectDir(body.projectDir);
2337
- 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; }
2338
2784
  const data = await readPipeline(projectDir, id);
2339
2785
  state = data && data.state;
2340
2786
  }
2341
2787
  } catch (err) {
2342
- 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;
2343
2790
  }
2344
- 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;
2345
2830
 
2346
2831
  const repoDir = state.projectDir;
2347
2832
  const feature = state.branch && state.branch.feature;
@@ -2350,12 +2835,49 @@ app.post('/api/pr', async (req, res) => {
2350
2835
  return badRequest(res, 'pipeline has no branch info to open a PR');
2351
2836
  }
2352
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
+
2353
2873
  // Push (idempotent) -> create PR -> read mergeability. All args are passed as
2354
- // an argv array (no shell), so branch/source names cannot inject.
2355
- 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);
2356
2876
  if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
2357
2877
 
2358
- 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
+ });
2359
2881
  if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
2360
2882
 
2361
2883
  // Persist the PR facts we just learned, so History/stats survive a gh outage.
@@ -2364,8 +2886,12 @@ app.post('/api/pr', async (req, res) => {
2364
2886
  if (pipelineIdForPr) {
2365
2887
  persistPrState(pipelineIdForPr, { url: pr.url, number: parsePrNumber(pr.url), state: 'OPEN' });
2366
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
+ }
2367
2893
 
2368
- const mergeable = await prMergeable({ projectDir: repoDir, head: feature });
2894
+ const mergeable = await prMergeable({ projectDir: repoDir, head: feature, repo, headOwner, prUrl: pr.url || null });
2369
2895
  res.json({ ok: true, url: pr.url, mergeable, existed: !!pr.existed });
2370
2896
  });
2371
2897
 
@@ -2404,7 +2930,11 @@ app.post('/api/pr/mergeable', async (req, res) => {
2404
2930
  const feature = state && state.branch && state.branch.feature;
2405
2931
  if (!repoDir || !feature) return res.json({ ok: true, mergeable: 'UNKNOWN' });
2406
2932
 
2407
- 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 });
2408
2938
  res.json({ ok: true, mergeable });
2409
2939
  } catch {
2410
2940
  res.json({ ok: true, mergeable: 'UNKNOWN' }); // best-effort: never error the refresh
@@ -2457,6 +2987,9 @@ app.post('/api/projects', async (req, res) => {
2457
2987
  try {
2458
2988
  const projects = await addProject({ name: body.name, path: body.path });
2459
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 */ });
2460
2993
  res.json({ projects });
2461
2994
  } catch (err) {
2462
2995
  // addProject only throws on validation (empty/duplicate/not-a-directory), so
@@ -2478,6 +3011,155 @@ app.delete('/api/projects', async (req, res) => {
2478
3011
  }
2479
3012
  });
2480
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
+
2481
3163
  // ---------------------------------------------------------------------------
2482
3164
  // Filesystem browsing for the add-project folder selector. Hybrid picker:
2483
3165
  // POST /api/fs/pick-folder opens the native OS dialog (the server runs on the
@@ -2540,9 +3222,13 @@ app.post('/api/workspaces', async (req, res) => {
2540
3222
  : [];
2541
3223
  if (projectPaths.length < 2) return badRequest(res, 'a workspace needs at least 2 member projects');
2542
3224
  try {
2543
- 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 });
2544
3230
  emitChanged('workspaces-changed', 'created');
2545
- res.status(201).json({ workspace });
3231
+ res.status(201).json({ workspace, metricsHomeAuto: !explicit && !!metricsProject });
2546
3232
  } catch (err) {
2547
3233
  const status = workspaceErrorStatus(err && err.code);
2548
3234
  return res.status(status).json({ error: err && err.message ? err.message : String(err) });
@@ -2555,14 +3241,21 @@ app.patch('/api/workspaces/:id', async (req, res) => {
2555
3241
  const body = req.body || {};
2556
3242
  // Immutability (defense-in-depth, §2.3): the project set never changes via PATCH.
2557
3243
  if ('projectPaths' in body || 'projectKeys' in body) {
2558
- 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');
2559
3245
  }
2560
3246
  // Pass through only the editable fields.
2561
3247
  const patch = {};
2562
3248
  if (typeof body.name === 'string') patch.name = body.name;
2563
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
+ }
2564
3256
  try {
2565
3257
  const workspace = await updateWorkspace(id, patch);
3258
+ if ('metricsProject' in patch) emitChanged('workspaces-changed', 'metrics-home');
2566
3259
  res.json({ workspace });
2567
3260
  } catch (err) {
2568
3261
  const status = workspaceErrorStatus(err && err.code);
@@ -2777,6 +3470,10 @@ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
2777
3470
  // projectsRootDefault : what applies when projectsRoot is blank — the env tier
2778
3471
  // when exported, else defaultRoot(). The UI placeholder.
2779
3472
  // Additive; `default` keeps its `root` meaning.
3473
+ // app : { version, repoUrl, releaseUrl } — static identity for
3474
+ // the About card (APP_INFO, from package.json). GET-only:
3475
+ // it is not a setting, so POST keeps echoing
3476
+ // settingsState() + chat unchanged.
2780
3477
  // POST /api/settings -> set either key and return the resulting full state.
2781
3478
  // Only keys PRESENT in the body are written, so a projectsRoot-only POST can
2782
3479
  // never reset `root` (and vice versa). An explicitly empty value still resets
@@ -2799,7 +3496,20 @@ const settingsState = () => ({
2799
3496
  titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
2800
3497
  titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
2801
3498
  hideBuiltinModels: hideBuiltinModels(),
2802
- });
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
+ }
2803
3513
 
2804
3514
  // ---------------------------------------------------------------------------
2805
3515
  // Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
@@ -2844,8 +3554,8 @@ app.post('/api/shutdown', (req, res) => {
2844
3554
  setImmediate(() => uiControl.onShutdown('request'));
2845
3555
  });
2846
3556
 
2847
- app.get('/api/settings', (_req, res) => {
2848
- res.json({ ...settingsState(), chat: chatPrefs() });
3557
+ app.get('/api/settings', async (_req, res) => {
3558
+ res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs(), app: APP_INFO });
2849
3559
  });
2850
3560
 
2851
3561
  app.get('/api/budget', (_req, res) => {
@@ -2860,6 +3570,9 @@ app.post('/api/settings', async (req, res) => {
2860
3570
  const hasDebugSpawnKey = has('debugSpawnEnabled');
2861
3571
  const hasTitleModelKey = has('titleModel');
2862
3572
  const hasHideBuiltinKey = has('hideBuiltinModels');
3573
+ const hasThemeKey = has('theme');
3574
+ const hasAutoKey = has('autoWorkflowModel');
3575
+ const autoModels = hasAutoKey ? await listModels('') : null;
2863
3576
  // #422: the title model is a SELECT over the catalog, so an id that is not a
2864
3577
  // catalog member is a client bug (or a stale option) — refuse it here rather
2865
3578
  // than store an id resolveModelEnv could never route.
@@ -2892,6 +3605,8 @@ app.post('/api/settings', async (req, res) => {
2892
3605
  }
2893
3606
  }
2894
3607
  if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
3608
+ if (hasThemeKey) assertThemeInput(body.theme);
3609
+ if (hasAutoKey) assertAutoWorkflowModelInput(body.autoWorkflowModel ?? '', autoModels);
2895
3610
  // Root first: it is the one key whose setter can still fail AFTER the asserts
2896
3611
  // above (an unusable path), so every other key's write must come after it or
2897
3612
  // a mixed POST would answer 400 with those keys already applied on disk.
@@ -2912,11 +3627,13 @@ app.post('/api/settings', async (req, res) => {
2912
3627
  if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2913
3628
  if (hasTitleModelKey) await setTitleModel(titleModelInput);
2914
3629
  if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
3630
+ if (hasThemeKey) await setTheme(body.theme);
3631
+ if (hasAutoKey) await setAutoWorkflowModel(body.autoWorkflowModel ?? '', { models: autoModels });
2915
3632
  if (hasBudgetKey) emitChanged('budget-changed');
2916
3633
  // Other open tabs repaint their Settings cards (a stale tab could otherwise
2917
3634
  // "save" its old checkbox state over this one with no feedback to either).
2918
- if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
2919
- res.json({ ...settingsState(), chat: chatPrefs() });
3635
+ if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasAutoKey) emitChanged('settings-changed');
3636
+ res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
2920
3637
  } catch (err) {
2921
3638
  // The setters throw only on an unusable path -> client error (400).
2922
3639
  return badRequest(res, err && err.message ? err.message : String(err));
@@ -2995,7 +3712,7 @@ app.post('/api/config', async (req, res) => {
2995
3712
  // validates model/effort against the effective catalog exactly like setStep
2996
3713
  // (configurable-models-design.md §4.5) -> 400; setFeedbackCycles still COERCES
2997
3714
  // maxCycles to >= 1 (it never throws).
2998
- // 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? }
2999
3716
  // ---------------------------------------------------------------------------
3000
3717
  app.patch('/api/config', async (req, res) => {
3001
3718
  const body = req.body || {};
@@ -3045,6 +3762,7 @@ app.patch('/api/config', async (req, res) => {
3045
3762
  if (!isSafeWorkflowId(active)) return badRequest(res, 'invalid workflowId');
3046
3763
  await setActiveWorkflow(projectDir, active);
3047
3764
  }
3765
+ if (typeof body.humanInLoop === 'boolean') await setHumanInLoop(projectDir, body.humanInLoop);
3048
3766
  const config = await readRunConfig(projectDir);
3049
3767
  res.json({ config });
3050
3768
  } catch (err) {
@@ -3416,9 +4134,9 @@ app.get('/api/workflows', async (req, res) => {
3416
4134
  const all = await listWorkflows({ includeArchived: true });
3417
4135
  return res.json({ workflows: all.filter((w) => w.archivedAt) });
3418
4136
  }
3419
- // CONTRACT: [ GRAPH_DEFAULT_WORKFLOW, ...listWorkflows() ]. The built-in is
3420
- // never a persisted row (listWorkflows filters its id), so it cannot appear twice.
3421
- 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())] });
3422
4140
  } catch (err) {
3423
4141
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3424
4142
  }
@@ -3529,6 +4247,7 @@ app.delete('/api/workflows/:id', async (req, res) => {
3529
4247
  const id = req.params.id;
3530
4248
  // The built-in default is not in the user store and must never be deleted.
3531
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');
3532
4251
  try {
3533
4252
  const removed = await deleteWorkflow(id); // CONV-1: await
3534
4253
  if (!removed) return res.status(404).json({ error: 'workflow not found' });
@@ -3715,12 +4434,58 @@ const askJobs = new Map(); // threadId -> {turn, messageId, userMessageId,
3715
4434
  const askDeleting = new Set();
3716
4435
  const askFollowers = new Map(); // threadId -> Set<{detach}>
3717
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
+ }
3718
4468
 
3719
4469
  function askInFlight(threadId) {
3720
4470
  const job = askJobs.get(threadId);
3721
4471
  return job && job.status === 'running' ? job : null;
3722
4472
  }
3723
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
+
3724
4489
  function askRunningCount() {
3725
4490
  let n = 0;
3726
4491
  for (const job of askJobs.values()) if (job.status === 'running') n += 1;
@@ -3793,7 +4558,10 @@ app.get('/api/ask/threads', (req, res) => {
3793
4558
  try {
3794
4559
  const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
3795
4560
  const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 50;
3796
- 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
+ });
3797
4565
  // total = EVERY saved chat (the History popover's meter), not the capped page above.
3798
4566
  res.json({ threads, total: askCountThreads() });
3799
4567
  } catch (err) {
@@ -3884,6 +4652,21 @@ app.get('/api/ask/threads/:id', (req, res) => {
3884
4652
  }
3885
4653
  });
3886
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
+
3887
4670
  app.patch('/api/ask/threads/:id', (req, res) => {
3888
4671
  const id = askIdParam(res, req.params.id, 'thread');
3889
4672
  if (!id) return;
@@ -3929,6 +4712,9 @@ app.patch('/api/ask/threads/:id', (req, res) => {
3929
4712
  // Returns { deleted, worktrees } — worktrees = rows removeThreadWorktrees removed.
3930
4713
  async function deleteAskThreadFully(id) {
3931
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);
3932
4718
  try {
3933
4719
  const stopJob = () => {
3934
4720
  const job = askJobs.get(id);
@@ -4098,6 +4884,12 @@ function askValidateScope(raw) {
4098
4884
  return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
4099
4885
  }
4100
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
+
4101
4893
  /** Resolve the VALIDATED client context into the server-side shape
4102
4894
  * buildContextHeader consumes (§6.5: server-resolved rows only — never
4103
4895
  * client-supplied titles or paths). Every lookup is individually guarded:
@@ -4130,6 +4922,18 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4130
4922
  }
4131
4923
  }
4132
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 */ }
4133
4937
  try {
4134
4938
  if (ctx.pipelineId) {
4135
4939
  const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
@@ -4159,12 +4963,24 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
4159
4963
  for (const m of askListMessages(threadId)) {
4160
4964
  if (!Array.isArray(m.blocks)) continue;
4161
4965
  for (const b of m.blocks) {
4162
- if (b && b.kind === 'card') {
4163
- 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
+ : {
4164
4981
  id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
4165
4982
  targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
4166
4983
  });
4167
- }
4168
4984
  }
4169
4985
  }
4170
4986
  if (cards.length) out.cards = cards.slice(-ASK_LIMITS.headerCards);
@@ -4189,7 +5005,164 @@ function mockAskCard(ctx = {}, text = '') {
4189
5005
  const target = ctx.workspaceId
4190
5006
  ? { workspaceId: ctx.workspaceId }
4191
5007
  : { projectKey: ctx.projectKey || 'mock-project-00000000' };
4192
- 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 };
4193
5166
  }
4194
5167
 
4195
5168
  app.post('/api/ask/threads/:id/messages', async (req, res) => {
@@ -4270,133 +5243,15 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
4270
5243
  }
4271
5244
  }
4272
5245
 
4273
- // §6.2.2 ATOMIC re-check + slot reservation. Today every await between the
4274
- // top 409/429 pair and here resolves in microtasks (validateModelEffort ->
4275
- // composeCatalog; askBuildCatalog -> three synchronous better-sqlite3
4276
- // reads), so the route is macrotask-atomic and two POSTs cannot interleave
4277
- // (empirically instrumented). The reservation is what keeps that true if
4278
- // any of those readers ever becomes genuinely async: it is synchronous —
4279
- // check-and-set cannot interleave — and runs BEFORE the first write, so a
4280
- // loser leaves no rows.
4281
- if (askDeleting.has(id)) return res.status(409).json({ error: 'thread is being deleted' });
4282
- if (askInFlight(id)) return res.status(409).json({ error: 'turn in flight' });
4283
- if (askRunningCount() >= ASK_LIMITS.turnsGlobal) {
4284
- return res.status(429).json({ error: `at most ${ASK_LIMITS.turnsGlobal} turns may run at once` });
4285
- }
4286
- const prev = askJobs.get(id);
4287
- if (prev && prev.graceTimer) clearTimeout(prev.graceTimer); // atomic replace of a grace entry (§8.3)
4288
- const job = {
4289
- turn: null, messageId: null, userMessageId: null, // ids filled once the rows exist;
4290
- events: [], seq: 0, status: 'running', // askHello()/GET inFlight skip a null messageId
4291
- startedAt: new Date().toISOString(), graceTimer: null,
4292
- };
4293
- askJobs.set(id, job);
4294
-
4295
- let asstMsg = null;
4296
- let turn;
4297
- let echoAttachments = [];
4298
- try {
4299
- // Writes. Store the LAST context + model/effort on the thread (§6.5 tail, D8).
4300
- // `ctx` (pin-merged) rather than cv.context: the stored row is what restores
4301
- // the selector on reopen and what the MCP child reads for tool defaulting.
4302
- askUpdateThread(id, { context: ctx, model: mv.model, effort: mv.effort });
4303
- // §7.4 — NOTHING is stamped on the row before the 202: the thread stays
4304
- // untitled (the header reads "Ask Worca") until the D13 background title
4305
- // announces itself. titleWasAuto gates that call: a title given at THREAD
4306
- // CREATION is the user's, and the haiku call must never fire for it
4307
- // (§17 Q&A 1). deterministicTitle is only the turn's fallback for an
4308
- // empty haiku result (turn.mjs _kickoffTitle), never written here.
4309
- const titleWasAuto = thread.title == null;
4310
- const deterministicTitle = titleWasAuto ? (askSanitizeTitle(text.slice(0, 80)) || 'New chat') : thread.title;
4311
- const userMsg = askAppendMessage(id, { role: 'user', text });
4312
- job.userMessageId = userMsg.id;
4313
- const attRows = files.map((f) => askAddAttachment(id, userMsg.id, { name: f.name, kind: f.kind, mime: f.mime, text: f.text, data: f.data }));
4314
- // The decoded binary bodies are on disk now. `files` is captured by this
4315
- // scope's closures (settleJob, the turn listeners, onOutOfTurn) for the whole
4316
- // turn plus jobGraceMs, so up to 25 MB of dead Buffers would otherwise stay
4317
- // reachable per running thread.
4318
- for (const f of files) f.data = null;
4319
- echoAttachments = attRows.map((a) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime }));
4320
- if (attRows.length) {
4321
- // `kind` is the BLOCK kind, so the attachment's own kind rides as attKind
4322
- // (the UI keys image thumbnails off it, #398).
4323
- askSetMessageBlocks(userMsg.id, attRows.map((a) => ({ kind: 'attachment', id: a.id, name: a.name, bytes: a.bytes, attKind: a.kind, mime: a.mime })));
4324
- }
4325
- broadcast({ type: 'ask-message', threadId: id, message: askGetMessage(userMsg.id) }); // echo for other tabs
4326
- asstMsg = askAppendMessage(id, { role: 'assistant', text: '', status: 'streaming', model: mv.model, effort: mv.effort });
4327
- job.messageId = asstMsg.id;
4328
-
4329
- // Prompt assembly (§6.5) — the route owns it; the turn only spawns.
4330
- const catalog = await askBuildCatalog();
4331
- const systemPrompt = askBuildSystemPrompt(catalog);
4332
- 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 }));
4333
- const { inline, listed } = askSelectInlineAttachments(withText);
4334
- const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id);
4335
- const header = askBuildContextHeader(headerCtx);
4336
- const prompt = askBuildTurnPrompt(header, text, inline);
4337
- const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
4338
- const restoredPrompt = askBuildRestoredPrompt(prior, prompt);
4339
- const attachmentNames = {};
4340
- for (const a of askListAttachments(id)) attachmentNames[a.id] = a.name;
4341
-
4342
- turn = createAskTurn({
4343
- threadId: id, assistantMessageId: asstMsg.id, userMessageId: userMsg.id,
4344
- prompt, systemPrompt, restoredPrompt,
4345
- model: mv.model, effort: mv.effort,
4346
- resumeSessionId: thread.sessionId || null,
4347
- firstTurn: userMsg.seq === 1 && titleWasAuto, // D13 guard: never replace a user-authored title
4348
- firstText: text,
4349
- deterministicTitle,
4350
- pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
4351
- mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
4352
- attachmentNames,
4353
- deps: {
4354
- onFrame: stampAskFrames(id, job),
4355
- onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
4356
- onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
4357
- onWorktreeMutation: () => { emitAskWorktrees(id); },
4358
- },
4359
- });
4360
- job.turn = turn;
4361
- } catch (err) {
4362
- // A write/assembly failure must release the reserved slot and never leave
4363
- // a `streaming` row for the boot sweep to find.
4364
- if (askJobs.get(id) === job) askJobs.delete(id);
4365
- if (asstMsg) {
4366
- try {
4367
- askFinishMessage(asstMsg.id, {
4368
- text: '', blocks: [{ kind: 'notice', text: 'failed to start the turn' }],
4369
- status: 'error', reason: null, usage: null, costUsd: null, durationMs: null,
4370
- });
4371
- } catch { /* thread gone */ }
4372
- }
4373
- return res.status(500).json({ error: err && err.message ? err.message : String(err) });
4374
- }
4375
- const settleJob = (status) => {
4376
- if (askJobs.get(id) !== job) return;
4377
- job.status = status;
4378
- job.graceTimer = setTimeout(() => {
4379
- if (askJobs.get(id) === job) askJobs.delete(id);
4380
- }, ASK_LIMITS.jobGraceMs);
4381
- job.graceTimer.unref?.();
4382
- };
4383
- turn.on('done', () => settleJob('done'));
4384
- turn.on('error', () => settleJob('error'));
4385
- // Fire-and-forget with a backstop (startAgentGen shape) — run() never throws.
4386
- Promise.resolve()
4387
- .then(() => turn.run())
4388
- .catch((err) => {
4389
- console.error(`[worca-ui] ask turn crashed: ${err && err.message ? err.message : err}`);
4390
- settleJob('error');
4391
- });
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 } : {}) });
4392
5248
  // `attachments` carries the store-minted ids so the sender's own echo can key
4393
5249
  // image thumbnails and the thread budget off them (the ask-message broadcast
4394
5250
  // may have raced ahead of this response, or been missed on a brand-new thread).
4395
- 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 });
4396
5252
  } catch (err) {
4397
- // Only pre-reservation throws land here (`job` is block-scoped to the outer
4398
- // try and every post-reservation failure returned from the inner catch), so
4399
- // 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.
4400
5255
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
4401
5256
  }
4402
5257
  });
@@ -4423,7 +5278,11 @@ function flipCard(threadId, cardId, patch) {
4423
5278
  const block = askUpdateCardBlock(threadId, cardId, patch);
4424
5279
  if (!block) return null;
4425
5280
  const job = askInFlight(threadId);
4426
- 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;
4427
5286
  if (!live) {
4428
5287
  const found = askFindCard(threadId, cardId);
4429
5288
  if (found) broadcast({ type: 'ask-message', threadId, message: found.message });
@@ -4431,27 +5290,197 @@ function flipCard(threadId, cardId, patch) {
4431
5290
  return block;
4432
5291
  }
4433
5292
 
4434
- // D14 dismiss ("Not now" keeps a stub — the client renders state:'dismissed').
4435
- 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) => {
4436
5414
  const id = askIdParam(res, req.params.id, 'thread');
4437
5415
  if (!id) return;
4438
5416
  const cardId = askIdParam(res, req.params.cardId, 'card');
4439
5417
  if (!cardId) return;
4440
5418
  try {
4441
5419
  if (!askGetThread(id)) return res.status(404).json({ error: 'thread not found' });
4442
- if ((req.body || {}).state !== 'dismissed') return badRequest(res, 'state must be "dismissed"');
5420
+ const body = req.body || {};
4443
5421
  const found = askFindCard(id, cardId);
4444
5422
  if (!found) return res.status(404).json({ error: 'card not found' });
4445
- if (found.block.state !== 'proposed') {
4446
- 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"');
4447
5467
  }
4448
- const block = flipCard(id, cardId, { state: 'dismissed' });
4449
- // Dismiss is terminal: the card's parked comment ids can never reach a run,
4450
- // so drop them here exactly as the launch path does at its own success point
4451
- // (:1155). Own try/catch — comment bookkeeping must never fail the dismiss.
4452
- try { clearPendingCardComments(cardId); }
4453
- catch (e) { console.error('[diff-comments] dismiss cleanup failed:', e && e.message ? e.message : e); }
4454
- 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 });
4455
5484
  } catch (err) {
4456
5485
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
4457
5486
  }
@@ -5247,6 +6276,86 @@ app.post('/api/pipelines/:id/report-result', async (req, res) => {
5247
6276
  }
5248
6277
  });
5249
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
+
5250
6359
  // ---------------------------------------------------------------------------
5251
6360
  // Install logic (mirrors scripts/install.mjs): copy agents/*.md and
5252
6361
  // skills/worca/** into <projectDir>/.claude/...
@@ -5393,9 +6502,7 @@ app.use((req, res, next) => {
5393
6502
  if (req.method !== 'GET') return next();
5394
6503
  if (req.path.startsWith('/api/') || req.path.startsWith('/ws')) return next();
5395
6504
  if (req.path === '/vendor' || req.path.startsWith('/vendor/')) return next();
5396
- res.sendFile(path.join(PUBLIC_DIR, 'index.html'), (err) => {
5397
- if (err) next();
5398
- });
6505
+ sendIndex(res);
5399
6506
  });
5400
6507
 
5401
6508
  // ---------------------------------------------------------------------------
@@ -5601,6 +6708,8 @@ if (isMain) {
5601
6708
  try { channelHost.start(); } catch (err) {
5602
6709
  console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
5603
6710
  }
6711
+ try { startTeamMetricsBackground({ log: (m) => console.warn(m) }); }
6712
+ catch (err) { console.warn(`[worca-ui] team metrics background: ${err?.message || err}`); }
5604
6713
  });
5605
6714
  }
5606
6715
 
@@ -5610,5 +6719,6 @@ export const _testing = {
5610
6719
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5611
6720
  chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5612
6721
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
6722
+ askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
5613
6723
  uiControl, bearerMatches,
5614
6724
  };