@worca/ui 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -154,6 +154,356 @@ function renderCostBudgetWarning(envelope) {
154
154
  return mdMsg(parts.join('\n'), 'warning');
155
155
  }
156
156
 
157
+ // ---------------------------------------------------------------------------
158
+ // Fleet event renderers
159
+ // ---------------------------------------------------------------------------
160
+ // Mirror the run-event renderers' shape: short title line + indented meta
161
+ // rows. fleet_id replaces run_id as the primary key; envelopes are
162
+ // fleet-shaped (top-level fleet_id, no `pipeline` wrapper). See
163
+ // src/worca/events/fleet_emitter.py for the envelope schema.
164
+
165
+ function fleetId(envelope) {
166
+ return envelope.fleet_id ?? 'fleet';
167
+ }
168
+
169
+ function projectBasename(p) {
170
+ if (!p) return '';
171
+ const parts = p.split('/').filter(Boolean);
172
+ return parts[parts.length - 1] || p;
173
+ }
174
+
175
+ function renderFleetLaunched(envelope) {
176
+ const p = envelope.payload ?? {};
177
+ const projects = Array.isArray(p.projects) ? p.projects : [];
178
+ const projectsLabel = projects.length
179
+ ? projects.slice(0, 5).map(projectBasename).join(', ') +
180
+ (projects.length > 5 ? `, +${projects.length - 5} more` : '')
181
+ : '(none)';
182
+ const parts = [`\u{1F680} **Fleet launched:** \`${fleetId(envelope)}\``];
183
+ parts.push(` **Projects:** ${projects.length} — ${projectsLabel}`);
184
+ if (p.plan_mode && p.plan_mode !== 'none') {
185
+ parts.push(` **Plan mode:** ${p.plan_mode}`);
186
+ }
187
+ if (p.guide_attached) parts.push(' **Guide:** attached');
188
+ if (p.base_branch) parts.push(` **Base:** ${p.base_branch}`);
189
+ return mdMsg(parts.join('\n'), 'info');
190
+ }
191
+
192
+ function renderFleetHalted(envelope) {
193
+ const p = envelope.payload ?? {};
194
+ const reason = p.halt_reason || 'unknown';
195
+ // Severity matches the reason: circuit_breaker is an error, user/stopped is
196
+ // a warning. Keeps Slack/Discord colour coding consistent with the per-run
197
+ // pipeline.run.interrupted vs pipeline.circuit_breaker.tripped split.
198
+ const sev = reason === 'circuit_breaker' ? 'error' : 'warning';
199
+ const parts = [`\u{1F6D1} **Fleet halted:** \`${fleetId(envelope)}\``];
200
+ parts.push(` **Reason:** ${reason}`);
201
+ if (p.in_flight_count != null) {
202
+ parts.push(` **In-flight at halt:** ${p.in_flight_count}`);
203
+ }
204
+ if (p.pending_count != null && p.pending_count > 0) {
205
+ parts.push(` **Pending (not dispatched):** ${p.pending_count}`);
206
+ }
207
+ return mdMsg(parts.join('\n'), sev);
208
+ }
209
+
210
+ function renderFleetCompleted(envelope) {
211
+ const p = envelope.payload ?? {};
212
+ const parts = [`✅ **Fleet completed:** \`${fleetId(envelope)}\``];
213
+ if (p.child_count != null) {
214
+ parts.push(
215
+ ` **Children:** ${p.completed_count ?? p.child_count}/${p.child_count} completed`,
216
+ );
217
+ }
218
+ if (p.duration_ms != null) {
219
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
220
+ }
221
+ return mdMsg(parts.join('\n'), 'success');
222
+ }
223
+
224
+ function renderFleetFailed(envelope) {
225
+ const p = envelope.payload ?? {};
226
+ const parts = [`❌ **Fleet failed:** \`${fleetId(envelope)}\``];
227
+ if (p.child_count != null) {
228
+ const failed = p.failed_count ?? 0;
229
+ const interrupted = p.interrupted_count ?? 0;
230
+ const completed = p.completed_count ?? 0;
231
+ parts.push(
232
+ ` **Children:** ${completed}/${p.child_count} completed, ${failed} failed, ${interrupted} interrupted`,
233
+ );
234
+ }
235
+ if (p.duration_ms != null) {
236
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
237
+ }
238
+ return mdMsg(parts.join('\n'), 'error');
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Workspace event renderers
243
+ // ---------------------------------------------------------------------------
244
+ // Mirror the fleet renderers' shape: short title line + indented meta rows.
245
+ // workspace_id replaces fleet_id; envelopes carry workspace_id at the top
246
+ // level (no `pipeline` wrapper). See src/worca/events/workspace_emitter.py
247
+ // for the envelope schema.
248
+
249
+ function workspaceId(envelope) {
250
+ return envelope.workspace_id ?? 'workspace';
251
+ }
252
+
253
+ function workspaceTitle(envelope) {
254
+ const name = envelope.payload?.workspace_name;
255
+ return name
256
+ ? `${name} (\`${workspaceId(envelope)}\`)`
257
+ : `\`${workspaceId(envelope)}\``;
258
+ }
259
+
260
+ function renderWorkspaceLaunched(envelope) {
261
+ const p = envelope.payload ?? {};
262
+ const projects = Array.isArray(p.projects) ? p.projects : [];
263
+ const projectsLabel = projects.length
264
+ ? projects.slice(0, 5).join(', ') +
265
+ (projects.length > 5 ? `, +${projects.length - 5} more` : '')
266
+ : '(none)';
267
+ const parts = [
268
+ `\u{1F680} **Workspace launched:** ${workspaceTitle(envelope)}`,
269
+ ];
270
+ parts.push(` **Projects:** ${projects.length} — ${projectsLabel}`);
271
+ if (p.tier_count != null) parts.push(` **Tiers:** ${p.tier_count}`);
272
+ if (p.guide_attached) parts.push(' **Guide:** attached');
273
+ if (p.skip_planning) parts.push(' **Plan:** skipped (per-project plans)');
274
+ return mdMsg(parts.join('\n'), 'info');
275
+ }
276
+
277
+ function renderWorkspaceCompleted(envelope) {
278
+ const p = envelope.payload ?? {};
279
+ const parts = [`✅ **Workspace completed:** ${workspaceTitle(envelope)}`];
280
+ if (p.child_count != null) {
281
+ parts.push(` **Projects:** ${p.child_count}`);
282
+ }
283
+ if (p.integration_passed === false) {
284
+ parts.push(' **Integration test:** not run');
285
+ } else if (p.integration_passed) {
286
+ parts.push(' **Integration test:** passed');
287
+ }
288
+ if (p.duration_ms != null) {
289
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
290
+ }
291
+ if (p.umbrella_issue_url) {
292
+ parts.push(` **Umbrella:** ${p.umbrella_issue_url}`);
293
+ }
294
+ return mdMsg(parts.join('\n'), 'success');
295
+ }
296
+
297
+ function renderWorkspaceFailed(envelope) {
298
+ const p = envelope.payload ?? {};
299
+ const parts = [`❌ **Workspace failed:** ${workspaceTitle(envelope)}`];
300
+ if (p.failed_tier != null) {
301
+ parts.push(` **Failed tier:** ${p.failed_tier}`);
302
+ }
303
+ if (Array.isArray(p.failed_projects) && p.failed_projects.length > 0) {
304
+ parts.push(` **Failed projects:** ${p.failed_projects.join(', ')}`);
305
+ }
306
+ if (p.completed_count != null && p.tier_count != null) {
307
+ parts.push(
308
+ ` **Progress:** ${p.completed_count} project(s) completed, ${p.failed_count ?? 0} failed`,
309
+ );
310
+ }
311
+ if (p.duration_ms != null) {
312
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
313
+ }
314
+ return mdMsg(parts.join('\n'), 'error');
315
+ }
316
+
317
+ function renderWorkspaceHalted(envelope) {
318
+ const p = envelope.payload ?? {};
319
+ const reason = p.halt_reason || 'unknown';
320
+ const sev = reason === 'circuit_breaker' ? 'error' : 'warning';
321
+ const parts = [`\u{1F6D1} **Workspace halted:** ${workspaceTitle(envelope)}`];
322
+ parts.push(` **Reason:** ${reason}`);
323
+ if (p.completed_tiers != null) {
324
+ parts.push(` **Completed tiers:** ${p.completed_tiers}`);
325
+ }
326
+ if (p.pending_tiers != null && p.pending_tiers > 0) {
327
+ parts.push(` **Pending (not dispatched):** ${p.pending_tiers} tier(s)`);
328
+ }
329
+ return mdMsg(parts.join('\n'), sev);
330
+ }
331
+
332
+ function renderWorkspacePaused(envelope) {
333
+ const p = envelope.payload ?? {};
334
+ const parts = [`\u{1F7E1} **Workspace paused:** ${workspaceTitle(envelope)}`];
335
+ if (p.reason) parts.push(` **Reason:** ${p.reason}`);
336
+ return mdMsg(parts.join('\n'), 'warning');
337
+ }
338
+
339
+ function renderWorkspaceResumed(envelope) {
340
+ const p = envelope.payload ?? {};
341
+ const parts = [
342
+ `\u{1F7E2} **Workspace resumed:** ${workspaceTitle(envelope)}`,
343
+ ];
344
+ if (p.from_state) parts.push(` **From state:** ${p.from_state}`);
345
+ if (p.redispatch_count != null && p.redispatch_count > 0) {
346
+ parts.push(` **Re-dispatched:** ${p.redispatch_count} project(s)`);
347
+ }
348
+ if (p.skip_count != null && p.skip_count > 0) {
349
+ parts.push(` **Skipped (already complete):** ${p.skip_count}`);
350
+ }
351
+ return mdMsg(parts.join('\n'), 'info');
352
+ }
353
+
354
+ function renderWorkspaceTierFailed(envelope) {
355
+ const p = envelope.payload ?? {};
356
+ const parts = [`❌ **Workspace tier failed:** ${workspaceTitle(envelope)}`];
357
+ if (p.tier != null) parts.push(` **Tier:** ${p.tier}`);
358
+ if (Array.isArray(p.failed_projects) && p.failed_projects.length > 0) {
359
+ parts.push(` **Failed:** ${p.failed_projects.join(', ')}`);
360
+ }
361
+ if (Array.isArray(p.blocked_projects) && p.blocked_projects.length > 0) {
362
+ parts.push(
363
+ ` **Blocked (dep failure):** ${p.blocked_projects.join(', ')}`,
364
+ );
365
+ }
366
+ if (p.duration_ms != null) {
367
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
368
+ }
369
+ return mdMsg(parts.join('\n'), 'error');
370
+ }
371
+
372
+ function renderWorkspaceIntegrationFailed(envelope) {
373
+ const p = envelope.payload ?? {};
374
+ const parts = [
375
+ `❌ **Workspace integration test failed:** ${workspaceTitle(envelope)}`,
376
+ ];
377
+ if (p.exit_code != null) {
378
+ parts.push(` **Exit code:** ${p.exit_code}`);
379
+ }
380
+ if (p.duration_ms != null) {
381
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
382
+ }
383
+ if (p.log_tail) {
384
+ // Render as a code block so chat clients preserve formatting.
385
+ parts.push(` **Tail:**\n\`\`\`\n${p.log_tail}\n\`\`\``);
386
+ }
387
+ return mdMsg(parts.join('\n'), 'error');
388
+ }
389
+
390
+ function renderWorkspaceUmbrellaIssueCreated(envelope) {
391
+ const p = envelope.payload ?? {};
392
+ const parts = [
393
+ `\u{1F517} **Workspace umbrella issue created:** ${workspaceTitle(envelope)}`,
394
+ ];
395
+ if (p.issue_number != null && p.nwo) {
396
+ parts.push(` **Issue:** [${p.nwo}#${p.issue_number}](${p.issue_url})`);
397
+ } else if (p.issue_url) {
398
+ parts.push(` **Issue:** ${p.issue_url}`);
399
+ }
400
+ if (p.child_pr_count != null) {
401
+ parts.push(` **Linked PRs:** ${p.child_pr_count}`);
402
+ }
403
+ return mdMsg(parts.join('\n'), 'info');
404
+ }
405
+
406
+ function renderWorkspaceCircuitBreakerTripped(envelope) {
407
+ const p = envelope.payload ?? {};
408
+ const parts = [
409
+ `⚠ **Workspace circuit breaker tripped:** ${workspaceTitle(envelope)}`,
410
+ ];
411
+ const ratio =
412
+ p.failure_ratio != null ? `${Math.round(p.failure_ratio * 100)}%` : '?';
413
+ const threshold =
414
+ p.threshold != null ? `${Math.round(p.threshold * 100)}%` : '?';
415
+ parts.push(
416
+ ` **Failures:** ${p.failed_count ?? 0}/${p.terminal_count ?? 0} (${ratio} ≥ threshold ${threshold})`,
417
+ );
418
+ return mdMsg(parts.join('\n'), 'error');
419
+ }
420
+
421
+ // Opt-in workspace renderers — quieter for chat by default but available
422
+ // for webhook consumers that want the full coordinator stream.
423
+
424
+ function renderWorkspacePlanStarted(envelope) {
425
+ const p = envelope.payload ?? {};
426
+ const parts = [`⚙ **Workspace planning:** ${workspaceTitle(envelope)}`];
427
+ parts.push(
428
+ ` **Decomposing** prompt into per-project sub-plans${p.project_count != null ? ` (${p.project_count} project${p.project_count === 1 ? '' : 's'})` : ''}…`,
429
+ );
430
+ return mdMsg(parts.join('\n'), 'info');
431
+ }
432
+
433
+ function renderWorkspacePlanCompleted(envelope) {
434
+ const p = envelope.payload ?? {};
435
+ const parts = [`✅ **Workspace plan ready:** ${workspaceTitle(envelope)}`];
436
+ parts.push(
437
+ ` **Plans written:** ${p.project_count}${p.skipped_count ? ` (+${p.skipped_count} skipped)` : ''}`,
438
+ );
439
+ if (p.duration_ms != null) {
440
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
441
+ }
442
+ return mdMsg(parts.join('\n'), 'success');
443
+ }
444
+
445
+ function renderWorkspacePlanFailed(envelope) {
446
+ const p = envelope.payload ?? {};
447
+ const parts = [`❌ **Workspace plan failed:** ${workspaceTitle(envelope)}`];
448
+ if (p.error_type) parts.push(` **Error type:** ${p.error_type}`);
449
+ if (p.error) parts.push(` **Error:** ${p.error}`);
450
+ return mdMsg(parts.join('\n'), 'error');
451
+ }
452
+
453
+ function renderWorkspaceTierStarted(envelope) {
454
+ const p = envelope.payload ?? {};
455
+ const projects = Array.isArray(p.projects) ? p.projects : [];
456
+ const parts = [`⚙ **Workspace tier started:** ${workspaceTitle(envelope)}`];
457
+ parts.push(` **Tier:** ${p.tier ?? '?'} (${projects.length} project(s))`);
458
+ if (projects.length > 0) {
459
+ parts.push(` **Projects:** ${projects.join(', ')}`);
460
+ }
461
+ return mdMsg(parts.join('\n'), 'info');
462
+ }
463
+
464
+ function renderWorkspaceTierCompleted(envelope) {
465
+ const p = envelope.payload ?? {};
466
+ const projects = Array.isArray(p.projects) ? p.projects : [];
467
+ const parts = [
468
+ `✅ **Workspace tier completed:** ${workspaceTitle(envelope)}`,
469
+ ];
470
+ parts.push(` **Tier:** ${p.tier ?? '?'} (${projects.length} project(s))`);
471
+ if (p.duration_ms != null) {
472
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
473
+ }
474
+ return mdMsg(parts.join('\n'), 'success');
475
+ }
476
+
477
+ function renderWorkspaceIntegrationStarted(envelope) {
478
+ const p = envelope.payload ?? {};
479
+ const parts = [
480
+ `⚙ **Workspace integration test started:** ${workspaceTitle(envelope)}`,
481
+ ];
482
+ if (p.command) parts.push(` **Command:** \`${p.command}\``);
483
+ return mdMsg(parts.join('\n'), 'info');
484
+ }
485
+
486
+ function renderWorkspaceIntegrationPassed(envelope) {
487
+ const p = envelope.payload ?? {};
488
+ const parts = [
489
+ `✅ **Workspace integration test passed:** ${workspaceTitle(envelope)}`,
490
+ ];
491
+ if (p.duration_ms != null) {
492
+ parts.push(` **Duration:** ${fmtMs(p.duration_ms)}`);
493
+ }
494
+ return mdMsg(parts.join('\n'), 'success');
495
+ }
496
+
497
+ function renderWorkspaceGuideConflict(envelope) {
498
+ const p = envelope.payload ?? {};
499
+ const parts = [`⚠ **Workspace guide conflict:** ${workspaceTitle(envelope)}`];
500
+ if (p.stage) parts.push(` **Stage:** ${p.stage}`);
501
+ if (p.source) parts.push(` **Source:** ${p.source}`);
502
+ if (p.run_id) parts.push(` **Run:** \`${p.run_id}\``);
503
+ if (p.message) parts.push(` **Conflict:** ${p.message}`);
504
+ return mdMsg(parts.join('\n'), 'warning');
505
+ }
506
+
157
507
  // ---------------------------------------------------------------------------
158
508
  // Registry
159
509
  // ---------------------------------------------------------------------------
@@ -173,6 +523,42 @@ const EVENT_RENDERERS = {
173
523
  'pipeline.git.pr_merged': renderGitPrMerged,
174
524
  'pipeline.circuit_breaker.tripped': renderCbTripped,
175
525
  'pipeline.cost.budget_warning': renderCostBudgetWarning,
526
+ // fleet.launched is intentionally NOT in this map by default — projects
527
+ // that launch many fleets per day would find it noisy. Opt-in callers
528
+ // can register it themselves via renderEvent's renderer override (or
529
+ // by extending TIER1_EVENTS in a future per-project config).
530
+ 'fleet.halted': renderFleetHalted,
531
+ 'fleet.completed': renderFleetCompleted,
532
+ 'fleet.failed': renderFleetFailed,
533
+ // Workspace events — terminal / attention defaults. See OPT_IN_RENDERERS
534
+ // for the lifecycle-progress events (plan.*, tier.started/.completed,
535
+ // integration_test.started/.passed) — kept out by default because a wide
536
+ // DAG can fire 6+ tier transitions in minutes.
537
+ 'workspace.completed': renderWorkspaceCompleted,
538
+ 'workspace.failed': renderWorkspaceFailed,
539
+ 'workspace.halted': renderWorkspaceHalted,
540
+ 'workspace.paused': renderWorkspacePaused,
541
+ 'workspace.resumed': renderWorkspaceResumed,
542
+ 'workspace.tier.failed': renderWorkspaceTierFailed,
543
+ 'workspace.integration_test.failed': renderWorkspaceIntegrationFailed,
544
+ 'workspace.umbrella_issue.created': renderWorkspaceUmbrellaIssueCreated,
545
+ 'workspace.circuit_breaker.tripped': renderWorkspaceCircuitBreakerTripped,
546
+ 'workspace.guide_conflict': renderWorkspaceGuideConflict,
547
+ };
548
+
549
+ // fleet.launched + workspace progress events ship as opt-in renderers rather
550
+ // than Tier-1 defaults. Callers that want them can pull from this export and
551
+ // register them in their own pipeline.
552
+ export const OPT_IN_RENDERERS = {
553
+ 'fleet.launched': renderFleetLaunched,
554
+ 'workspace.launched': renderWorkspaceLaunched,
555
+ 'workspace.plan.started': renderWorkspacePlanStarted,
556
+ 'workspace.plan.completed': renderWorkspacePlanCompleted,
557
+ 'workspace.plan.failed': renderWorkspacePlanFailed,
558
+ 'workspace.tier.started': renderWorkspaceTierStarted,
559
+ 'workspace.tier.completed': renderWorkspaceTierCompleted,
560
+ 'workspace.integration_test.started': renderWorkspaceIntegrationStarted,
561
+ 'workspace.integration_test.passed': renderWorkspaceIntegrationPassed,
176
562
  };
177
563
 
178
564
  export const TIER1_EVENTS = Object.keys(EVENT_RENDERERS);
@@ -13,5 +13,12 @@ export function createRestClient({ host, port }) {
13
13
  });
14
14
  return { status: r.status, data: r.ok ? await r.json() : null };
15
15
  },
16
+ // DELETE used by /fleet-halt (DELETE /api/fleet-runs/:id) — added when
17
+ // chat fleet commands landed. Mirrors the get/post pair: no body, returns
18
+ // parsed JSON on 2xx, null on error status.
19
+ async delete(path) {
20
+ const r = await fetch(`${base}${path}`, { method: 'DELETE' });
21
+ return { status: r.status, data: r.ok ? await r.json() : null };
22
+ },
16
23
  };
17
24
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Lazy resolvers for ~/.worca/ subdirectories.
3
+ *
4
+ * Mirror of src/worca/utils/paths.py — every helper re-reads the
5
+ * environment on each call so $WORCA_HOME is honored consistently by
6
+ * both halves of the system. See issue #162.
7
+ *
8
+ * Resolution order for every subdir helper:
9
+ * 1. `override` arg (e.g. a constant set by the caller / tests)
10
+ * 2. $WORCA_HOME/<subdir>
11
+ * 3. ~/.worca/<subdir>
12
+ */
13
+
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ /**
18
+ * @returns {string} The worca state directory. Honors $WORCA_HOME, else ~/.worca.
19
+ */
20
+ export function worcaHome() {
21
+ const override = process.env.WORCA_HOME;
22
+ if (override) return override;
23
+ return join(homedir(), '.worca');
24
+ }
25
+
26
+ /**
27
+ * @param {string=} override Caller-supplied path that wins over $WORCA_HOME.
28
+ * @returns {string} Absolute path to the fleet-runs directory.
29
+ */
30
+ export function fleetRunsDir(override) {
31
+ if (override) return override;
32
+ return join(worcaHome(), 'fleet-runs');
33
+ }
34
+
35
+ /**
36
+ * @param {string=} override
37
+ * @returns {string} Absolute path to the workspace-runs directory.
38
+ */
39
+ export function workspaceRunsDir(override) {
40
+ if (override) return override;
41
+ return join(worcaHome(), 'workspace-runs');
42
+ }
43
+
44
+ /**
45
+ * @param {string=} override
46
+ * @returns {string} Absolute path to the workspaces.d directory.
47
+ */
48
+ export function workspacesDir(override) {
49
+ if (override) return override;
50
+ return join(worcaHome(), 'workspaces.d');
51
+ }
52
+
53
+ /**
54
+ * @param {string=} override
55
+ * @returns {string} Absolute path to the preferences/prefs root (= worca home).
56
+ */
57
+ export function prefsDir(override) {
58
+ if (override) return override;
59
+ return worcaHome();
60
+ }
61
+
62
+ /**
63
+ * @param {string=} override
64
+ * @returns {string} Absolute path to the user templates directory.
65
+ */
66
+ export function templatesDir(override) {
67
+ if (override) return override;
68
+ return join(worcaHome(), 'templates');
69
+ }
70
+
71
+ /**
72
+ * @param {string=} override
73
+ * @returns {string} Absolute path to preferences.json.
74
+ */
75
+ export function preferencesPath(override) {
76
+ if (override) return override;
77
+ return join(worcaHome(), 'preferences.json');
78
+ }
@@ -16,7 +16,6 @@ import {
16
16
  unlinkSync,
17
17
  writeFileSync,
18
18
  } from 'node:fs';
19
- import { homedir } from 'node:os';
20
19
  import { dirname, join } from 'node:path';
21
20
  import { Router } from 'express';
22
21
  import lockfile from 'proper-lockfile';
@@ -28,6 +27,7 @@ import { ensureWebhookForUi } from './ensure-webhook.js';
28
27
  import { extractAndStripGlobalKeys } from './global-keys.js';
29
28
  import { LaunchLock } from './launch-lock.js';
30
29
  import { createModelEnvRouter } from './model-env-routes.js';
30
+ import { preferencesPath, templatesDir } from './paths.js';
31
31
  import { readPreferences } from './preferences.js';
32
32
  import { ProcessManager } from './process-manager.js';
33
33
  import { countRunningPipelinesAcrossProjects } from './process-registry.js';
@@ -726,6 +726,71 @@ export function createProjectScopedRoutes({
726
726
  }
727
727
  });
728
728
 
729
+ // GET /api/projects/:projectId/runs/:runId/plan — per-run plan markdown
730
+ //
731
+ // Returns the markdown content of the run's plan file. Two sources, in
732
+ // priority order:
733
+ // 1. stages.plan.plan_file — set by workspace children that received a
734
+ // pre-built per-repo plan from the workspace planner. May point at
735
+ // an absolute path under the workspace run dir.
736
+ // 2. <worktree>/MASTER_PLAN.md — generated by a standalone pipeline's
737
+ // own PLAN stage. status.json doesn't always carry the path so we
738
+ // reconstruct it from the worktree.
739
+ //
740
+ // Returns 404 when neither path exists / is readable. Replies in
741
+ // text/markdown so the client just streams it into the dialog body.
742
+ router.get('/runs/:runId/plan', requireWorcaDir, (req, res) => {
743
+ const { runId } = req.params;
744
+ if (!validateRunId(runId)) {
745
+ return res.status(400).json({ ok: false, error: 'Invalid runId' });
746
+ }
747
+ const { worcaDir } = req.project;
748
+ const statusPath = findRunStatusPath(worcaDir, runId);
749
+ if (!statusPath) {
750
+ return res
751
+ .status(404)
752
+ .json({ ok: false, error: `Run "${runId}" not found` });
753
+ }
754
+ let status;
755
+ try {
756
+ status = JSON.parse(readFileSync(statusPath, 'utf8'));
757
+ } catch (err) {
758
+ return res
759
+ .status(500)
760
+ .json({ ok: false, error: `Failed to read status: ${err.message}` });
761
+ }
762
+ const planFile = status?.stages?.plan?.plan_file ?? null;
763
+ // `status.worktree` is a boolean flag (true when the run is hosted in a
764
+ // worktree), not a path. The actual worktree path lives in the
765
+ // pipelines.d registry entry. Walk it via the overlay so we can offer
766
+ // the MASTER_PLAN.md fallback even when stage.plan_file isn't set.
767
+ const overlay = readPipelineOverlay(worcaDir, runId);
768
+ const worktreePath =
769
+ typeof overlay?.worktree_path === 'string' ? overlay.worktree_path : null;
770
+ const fallback = worktreePath ? join(worktreePath, 'MASTER_PLAN.md') : null;
771
+ const candidates = [planFile, fallback].filter(
772
+ (p) => typeof p === 'string' && p.length > 0,
773
+ );
774
+ for (const p of candidates) {
775
+ if (existsSync(p)) {
776
+ try {
777
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
778
+ res.send(readFileSync(p, 'utf8'));
779
+ return;
780
+ } catch (err) {
781
+ return res.status(500).json({
782
+ ok: false,
783
+ error: `Failed to read plan file: ${err.message}`,
784
+ });
785
+ }
786
+ }
787
+ }
788
+ return res.status(404).json({
789
+ ok: false,
790
+ error: 'No plan file found for this run',
791
+ });
792
+ });
793
+
729
794
  // POST /api/projects/:projectId/runs — start a new pipeline
730
795
  router.post('/runs', requireWorcaDir, async (req, res) => {
731
796
  // Block parallel pipelines on the same project (GH #82)
@@ -1467,7 +1532,7 @@ export function createProjectScopedRoutes({
1467
1532
  const tiers = [
1468
1533
  { tier: 'worca', dir: join(root, '.claude', 'worca', 'templates') },
1469
1534
  { tier: 'project', dir: join(root, '.claude', 'templates') },
1470
- { tier: 'user', dir: join(homedir(), '.worca', 'templates') },
1535
+ { tier: 'user', dir: templatesDir() },
1471
1536
  ];
1472
1537
 
1473
1538
  const templates = [];
@@ -1539,9 +1604,7 @@ export function createProjectScopedRoutes({
1539
1604
  // Fall back to source_repo from global preferences
1540
1605
  if (!source) {
1541
1606
  try {
1542
- const prefs = readPreferences(
1543
- join(homedir(), '.worca', 'preferences.json'),
1544
- );
1607
+ const prefs = readPreferences(preferencesPath());
1545
1608
  source = prefs.source_repo || undefined;
1546
1609
  } catch {
1547
1610
  /* ignore — worca init will use its own resolution chain */