@loicngr/kobo 1.7.2 → 1.7.3

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 (75) hide show
  1. package/dist/mcp-server/kobo-tasks-handlers.js +8 -1
  2. package/dist/server/routes/health.js +12 -6
  3. package/dist/server/routes/settings.js +16 -0
  4. package/dist/server/routes/workspaces.js +206 -3
  5. package/dist/server/services/agent/orchestrator.js +3 -0
  6. package/dist/server/services/auto-loop-service.js +7 -1
  7. package/dist/server/services/initial-prompt-template-service.js +48 -0
  8. package/dist/server/services/review-template-service.js +58 -0
  9. package/dist/server/services/settings-service.js +67 -2
  10. package/dist/server/services/wakeup-service.js +9 -1
  11. package/dist/server/services/worktree-service.js +2 -2
  12. package/dist/server/utils/git-ops.js +82 -0
  13. package/dist/server/utils/project-slug.js +52 -0
  14. package/dist/server/utils/worktree-paths.js +12 -10
  15. package/package.json +1 -1
  16. package/src/client/dist/spa/assets/ActivityFeed-CKSqMR2v.js +7 -0
  17. package/src/client/dist/spa/assets/{ActivityFeed-LXnbg3ff.css → ActivityFeed-CroojlsI.css} +1 -1
  18. package/src/client/dist/spa/assets/{ClosePopup-BP025_cK.js → ClosePopup-D_UAdwkA.js} +1 -1
  19. package/src/client/dist/spa/assets/CreatePage-7cP4h19f.js +2 -0
  20. package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +1 -0
  21. package/src/client/dist/spa/assets/DiffViewer-CdamEwIg.js +7 -0
  22. package/src/client/dist/spa/assets/{DiffViewer-D1Sdu307.css → DiffViewer-wFfQ9tcY.css} +1 -1
  23. package/src/client/dist/spa/assets/{HealthPage-CkHv5qMK.js → HealthPage-m4z-x5bo.js} +1 -1
  24. package/src/client/dist/spa/assets/{MainLayout-l91ohFQA.js → MainLayout-CQBqYFNx.js} +17 -17
  25. package/src/client/dist/spa/assets/MainLayout-DKurmqtk.css +1 -0
  26. package/src/client/dist/spa/assets/{QExpansionItem-BaQJkGb-.js → QExpansionItem-CH1ipL9n.js} +1 -1
  27. package/src/client/dist/spa/assets/{QMenu-DgWZe7Uh.js → QMenu-B4xMxMGd.js} +1 -1
  28. package/src/client/dist/spa/assets/{QTabPanels-CjpZTIJg.js → QTabPanels-D2ks0UIA.js} +1 -1
  29. package/src/client/dist/spa/assets/{QTooltip-D_hSPb7r.js → QTooltip-fDNzBEfN.js} +1 -1
  30. package/src/client/dist/spa/assets/{SearchPage-B1WhFCUf.js → SearchPage-DCRSQycR.js} +1 -1
  31. package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +1 -0
  32. package/src/client/dist/spa/assets/SettingsPage-DStBGwIj.js +1 -0
  33. package/src/client/dist/spa/assets/{TouchPan-1PETKHN0.js → TouchPan-DoE24Io3.js} +1 -1
  34. package/src/client/dist/spa/assets/{WorkspacePage-D3MBshNH.js → WorkspacePage-BstBxgN8.js} +3 -3
  35. package/src/client/dist/spa/assets/{WorkspacePage-d_B0-LNG.css → WorkspacePage-eymEd4kx.css} +1 -1
  36. package/src/client/dist/spa/assets/{build-path-tree-w3SEPAbh.js → build-path-tree-B1Lvvqto.js} +1 -1
  37. package/src/client/dist/spa/assets/{cssMode-B6CD4qMI.js → cssMode-o7NS-Oil.js} +1 -1
  38. package/src/client/dist/spa/assets/{editor.api-wizjkvCK.js → editor.api-CNo9KwlJ.js} +1 -1
  39. package/src/client/dist/spa/assets/{editor.main-Bn6fpPLF.js → editor.main-UyvgnhP6.js} +3 -3
  40. package/src/client/dist/spa/assets/{expand-template-Cu5GSLCM.js → expand-template-DqZgks9E.js} +1 -1
  41. package/src/client/dist/spa/assets/{freemarker2-DW-DFUis.js → freemarker2-BKWtNRQ9.js} +1 -1
  42. package/src/client/dist/spa/assets/{handlebars-CSSQFRHS.js → handlebars-BUhKrn3k.js} +1 -1
  43. package/src/client/dist/spa/assets/{html-Ba5lfQna.js → html-CrcvRgdj.js} +1 -1
  44. package/src/client/dist/spa/assets/{htmlMode-ocrlHn5h.js → htmlMode-Djjp-0pZ.js} +1 -1
  45. package/src/client/dist/spa/assets/i18n-DD341qPX.js +1 -0
  46. package/src/client/dist/spa/assets/index-DR1y9t94.js +2 -0
  47. package/src/client/dist/spa/assets/{javascript-DL3j24x3.js → javascript-DN_zCJwt.js} +1 -1
  48. package/src/client/dist/spa/assets/{jsonMode-CtFp2BJe.js → jsonMode-B7uIpwZ9.js} +1 -1
  49. package/src/client/dist/spa/assets/{liquid-B_GGNnlJ.js → liquid-f3BGSOBM.js} +1 -1
  50. package/src/client/dist/spa/assets/{mdx-BXe8MrIz.js → mdx-jpEqsFXp.js} +1 -1
  51. package/src/client/dist/spa/assets/{models-BMOYJtwv.js → models-Bj-hfPO2.js} +1 -1
  52. package/src/client/dist/spa/assets/{monaco.contribution-DSSRKV2r.js → monaco.contribution-D-UK6jlz.js} +2 -2
  53. package/src/client/dist/spa/assets/{notifications-CG-oL2m2.js → notifications-OnPq4FrH.js} +1 -1
  54. package/src/client/dist/spa/assets/purify.es-DyEEb_DH.js +60 -0
  55. package/src/client/dist/spa/assets/{python-DPtBXcrE.js → python-CoiTKs0q.js} +1 -1
  56. package/src/client/dist/spa/assets/{razor-y1p5VjhT.js → razor-BubwMw_m.js} +1 -1
  57. package/src/client/dist/spa/assets/render-chat-markdown-DwKtHD8J.js +1 -0
  58. package/src/client/dist/spa/assets/{tsMode-CV2CQlAd.js → tsMode-k_tAkDr_.js} +1 -1
  59. package/src/client/dist/spa/assets/{typescript-DsjWQLAN.js → typescript-DQQR6Y6R.js} +1 -1
  60. package/src/client/dist/spa/assets/{use-panel-D2MjPZiL.js → use-panel-D-8nAQns.js} +1 -1
  61. package/src/client/dist/spa/assets/{xml-AQhpP8em.js → xml-CaSyI8p6.js} +1 -1
  62. package/src/client/dist/spa/assets/{yaml-zZFlU7RD.js → yaml-BYsGcXIZ.js} +1 -1
  63. package/src/client/dist/spa/index.html +2 -2
  64. package/src/mcp-server/kobo-tasks-handlers.ts +10 -1
  65. package/src/client/dist/spa/assets/ActivityFeed-B85xav_e.js +0 -7
  66. package/src/client/dist/spa/assets/CreatePage-BE3xfQsC.css +0 -1
  67. package/src/client/dist/spa/assets/CreatePage-DyR33jFM.js +0 -2
  68. package/src/client/dist/spa/assets/DiffViewer-CqhpTkym.js +0 -7
  69. package/src/client/dist/spa/assets/MainLayout-B07zv82Z.css +0 -1
  70. package/src/client/dist/spa/assets/QScrollArea-usfgatuS.js +0 -1
  71. package/src/client/dist/spa/assets/SettingsPage-B7S5fXGG.js +0 -1
  72. package/src/client/dist/spa/assets/SettingsPage-kHd651y8.css +0 -1
  73. package/src/client/dist/spa/assets/i18n-CqK8B0Nz.js +0 -1
  74. package/src/client/dist/spa/assets/index-DE3PxEjy.js +0 -2
  75. package/src/client/dist/spa/assets/marked.esm-D4t0_2pc.js +0 -60
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { nanoid } from 'nanoid';
4
+ import * as settingsService from '../server/services/settings-service.js';
5
+ import { slugifyProjectName } from '../server/utils/project-slug.js';
4
6
  import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
5
7
  /** Allowed task status values. */
6
8
  export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
@@ -164,7 +166,12 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
164
166
  projectPath: row.project_path,
165
167
  sourceBranch: row.source_branch,
166
168
  workingBranch: row.working_branch,
167
- worktreePath: row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch),
169
+ worktreePath: (() => {
170
+ const gs = settingsService.getGlobalSettings();
171
+ const ps = settingsService.getProjectSettings(row.project_path);
172
+ const slug = gs.worktreesPrefixByProject ? slugifyProjectName(ps?.displayName ?? '', row.project_path) : undefined;
173
+ return (row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch, gs.worktreesPath, slug));
174
+ })(),
168
175
  status: row.status,
169
176
  model: row.model,
170
177
  notionUrl: row.notion_url,
@@ -3,8 +3,9 @@ import fs from 'node:fs';
3
3
  import { Hono } from 'hono';
4
4
  import { getDb } from '../db/index.js';
5
5
  import { SCHEMA_VERSION } from '../db/migrations.js';
6
- import { getGlobalSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
6
+ import { getGlobalSettings, getProjectSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
7
7
  import { getDbPath, getKoboHome } from '../utils/paths.js';
8
+ import { slugifyProjectName } from '../utils/project-slug.js';
8
9
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
9
10
  const app = new Hono();
10
11
  function checkClaudeCli() {
@@ -47,11 +48,17 @@ app.get('/report', (c) => {
47
48
  const workspaces = db
48
49
  .prepare('SELECT id, name, project_path, working_branch, worktree_path, archived_at FROM workspaces')
49
50
  .all();
51
+ const healthGlobalSettings = getGlobalSettings();
50
52
  const worktreesMissing = [];
51
53
  for (const ws of workspaces) {
52
54
  if (ws.archived_at)
53
55
  continue;
54
- const wtPath = ws.worktree_path ?? resolveWorkspaceWorktreePath(ws.project_path, ws.working_branch);
56
+ const wsProjectSettings = getProjectSettings(ws.project_path);
57
+ const wsProjectSlug = healthGlobalSettings.worktreesPrefixByProject
58
+ ? slugifyProjectName(wsProjectSettings?.displayName ?? '', ws.project_path)
59
+ : undefined;
60
+ const wtPath = ws.worktree_path ??
61
+ resolveWorkspaceWorktreePath(ws.project_path, ws.working_branch, healthGlobalSettings.worktreesPath, wsProjectSlug);
55
62
  if (!fs.existsSync(wtPath)) {
56
63
  worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
57
64
  }
@@ -65,7 +72,6 @@ app.get('/report', (c) => {
65
72
  if (s.pid && !isProcessAlive(s.pid))
66
73
  orphaned++;
67
74
  }
68
- const globalSettings = getGlobalSettings();
69
75
  const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
70
76
  const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
71
77
  const report = {
@@ -85,9 +91,9 @@ app.get('/report', (c) => {
85
91
  },
86
92
  agentSessions: { orphaned },
87
93
  integrations: {
88
- notion: { configured: Boolean(globalSettings.notionMcpKey) },
89
- sentry: { configured: Boolean(globalSettings.sentryMcpKey) },
90
- editor: { configured: Boolean(globalSettings.editorCommand) },
94
+ notion: { configured: Boolean(healthGlobalSettings.notionMcpKey) },
95
+ sentry: { configured: Boolean(healthGlobalSettings.sentryMcpKey) },
96
+ editor: { configured: Boolean(healthGlobalSettings.editorCommand) },
91
97
  },
92
98
  };
93
99
  return c.json(report);
@@ -1,5 +1,8 @@
1
1
  import { Hono } from 'hono';
2
+ import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
3
+ import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-service.js';
2
4
  import * as settingsService from '../services/settings-service.js';
5
+ import { DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
3
6
  import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
4
7
  /** Hono sub-router for global and per-project settings CRUD. */
5
8
  const app = new Hono();
@@ -25,6 +28,19 @@ app.get('/global', (c) => {
25
28
  return c.json({ error: message }, 500);
26
29
  }
27
30
  });
31
+ // GET /api/settings/defaults — expose the in-code DEFAULT_* constants for
32
+ // global text-template settings (PR / review / git conventions / Notion /
33
+ // Sentry initial prompts) so the UI can offer a "reset to default" button
34
+ // without duplicating the strings on the frontend.
35
+ app.get('/defaults', (c) => {
36
+ return c.json({
37
+ prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
38
+ reviewPromptTemplate: DEFAULT_REVIEW_PROMPT_TEMPLATE,
39
+ gitConventions: DEFAULT_GIT_CONVENTIONS,
40
+ notionInitialPromptTemplate: DEFAULT_NOTION_INITIAL_PROMPT,
41
+ sentryInitialPromptTemplate: DEFAULT_SENTRY_INITIAL_PROMPT,
42
+ });
43
+ });
28
44
  // GET /api/settings/mcp-servers — list active MCP servers from Claude config
29
45
  app.get('/mcp-servers', (c) => {
30
46
  try {
@@ -11,9 +11,11 @@ import { listEngines } from '../services/agent/engines/registry.js';
11
11
  import * as agentManager from '../services/agent/orchestrator.js';
12
12
  import * as autoLoopService from '../services/auto-loop-service.js';
13
13
  import * as devServerService from '../services/dev-server-service.js';
14
+ import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNotionInitialPrompt, renderSentryInitialPrompt, } from '../services/initial-prompt-template-service.js';
14
15
  import * as notionService from '../services/notion-service.js';
15
16
  import { renderPrTemplate } from '../services/pr-template-service.js';
16
17
  import { getAllPrStates } from '../services/pr-watcher-service.js';
18
+ import { DEFAULT_REVIEW_PROMPT_TEMPLATE, renderReviewTemplate } from '../services/review-template-service.js';
17
19
  import * as sentryService from '../services/sentry-service.js';
18
20
  import * as settingsService from '../services/settings-service.js';
19
21
  import { runSetupScript } from '../services/setup-script-service.js';
@@ -23,7 +25,8 @@ import * as wsService from '../services/websocket-service.js';
23
25
  import * as workspaceService from '../services/workspace-service.js';
24
26
  import * as worktreeService from '../services/worktree-service.js';
25
27
  import * as gitOps from '../utils/git-ops.js';
26
- import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
28
+ import { slugifyProjectName } from '../utils/project-slug.js';
29
+ import { resolveSiblingWorkspaceWorktreePath, resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
27
30
  /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
28
31
  const app = new Hono();
29
32
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
@@ -185,6 +188,26 @@ app.post('/', migrationGuard, async (c) => {
185
188
  workingBranch = `${typePrefix}${ticketPrefix}--${titleSlug}`;
186
189
  }
187
190
  }
191
+ // Compute the project slug once and reuse it for both the prospective
192
+ // path check and the actual worktree creation below.
193
+ const projectSettings = settingsService.getProjectSettings(body.projectPath);
194
+ const projectSlug = globalSettings.worktreesPrefixByProject
195
+ ? slugifyProjectName(projectSettings?.displayName ?? '', body.projectPath)
196
+ : undefined;
197
+ // Resolve the prospective worktree path unconditionally so that:
198
+ // 1. We can refuse the request before any DB write when the path exists.
199
+ // 2. createWorkspace always receives the correct slug-prefixed path and
200
+ // never falls back to the no-slug resolver inside workspace-service.
201
+ let prospectiveWorktreePath;
202
+ if (useReusedWorktree) {
203
+ prospectiveWorktreePath = body.worktreePath;
204
+ }
205
+ else {
206
+ prospectiveWorktreePath = resolveWorkspaceWorktreePath(body.projectPath, workingBranch, globalSettings.worktreesPath, projectSlug);
207
+ if (fs.existsSync(prospectiveWorktreePath)) {
208
+ return c.json({ error: `Worktree path already exists: ${prospectiveWorktreePath}` }, 409);
209
+ }
210
+ }
188
211
  let workspace = workspaceService.createWorkspace({
189
212
  name: body.name,
190
213
  projectPath: body.projectPath,
@@ -193,7 +216,8 @@ app.post('/', migrationGuard, async (c) => {
193
216
  notionUrl: body.notionUrl,
194
217
  notionPageId: body.notionPageId,
195
218
  sentryUrl: body.sentryUrl,
196
- ...(useReusedWorktree ? { worktreePath: body.worktreePath, worktreeOwned: false } : {}),
219
+ worktreePath: prospectiveWorktreePath,
220
+ worktreeOwned: !useReusedWorktree,
197
221
  model: body.model,
198
222
  reasoningEffort: body.reasoningEffort,
199
223
  agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings),
@@ -289,7 +313,7 @@ app.post('/', migrationGuard, async (c) => {
289
313
  }
290
314
  else {
291
315
  try {
292
- worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch, globalSettings.worktreesPath);
316
+ worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch, globalSettings.worktreesPath, projectSlug);
293
317
  }
294
318
  catch (err) {
295
319
  const message = err instanceof Error ? err.message : String(err);
@@ -484,6 +508,12 @@ app.post('/', migrationGuard, async (c) => {
484
508
  if (!setupScriptFailed) {
485
509
  // Transition to brainstorming and build the initial agent prompt
486
510
  workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
511
+ // Resolve the per-feature initial-prompt templates with single-fallback
512
+ // semantics: project || global is already handled inside getEffectiveSettings,
513
+ // and a whitespace-only string acts as a user escape hatch (skip injection).
514
+ // An empty string falls back to the hard-coded default below at injection time.
515
+ const notionTpl = effectiveSettings.notionInitialPromptTemplate || DEFAULT_NOTION_INITIAL_PROMPT;
516
+ const sentryTpl = effectiveSettings.sentryInitialPromptTemplate || DEFAULT_SENTRY_INITIAL_PROMPT;
487
517
  // Build prompt with tasks and acceptance criteria
488
518
  const allTasks = workspaceService.listTasks(workspace.id);
489
519
  const todos = allTasks.filter((t) => !t.isAcceptanceCriterion);
@@ -501,9 +531,18 @@ app.post('/', migrationGuard, async (c) => {
501
531
  brainstormPrompt += `\nGoal: ${notionContent.goal}\n`;
502
532
  }
503
533
  brainstormPrompt += `\nBranch: ${workingBranch}\nSource branch: ${body.sourceBranch}\nIMPORTANT: When creating a pull request, always use --base ${body.sourceBranch} to target the correct source branch.\n`;
534
+ brainstormPrompt += `\nWorking directory: ${worktreePath}\n`;
504
535
  if (notionFilePath) {
505
536
  brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
506
537
  brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
538
+ if (notionFilePath !== null && notionTpl.trim().length > 0) {
539
+ const renderedNotion = renderNotionInitialPrompt(notionTpl, {
540
+ ticketId: notionContent?.ticketId ?? '',
541
+ notionUrl: body.notionUrl ?? '',
542
+ notionFilePath,
543
+ });
544
+ brainstormPrompt += `\n${renderedNotion}\n`;
545
+ }
507
546
  }
508
547
  if (sentryFilePath && sentryContent) {
509
548
  brainstormPrompt += `\nSentry issue: ${body.sentryUrl}`;
@@ -522,6 +561,14 @@ app.post('/', migrationGuard, async (c) => {
522
561
  `- mcp__sentry__get_sentry_resource(url, resourceType) — fetch the issue, breadcrumbs, replay or trace\n` +
523
562
  `- mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
524
563
  `- mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
564
+ if (sentryTpl.trim().length > 0) {
565
+ const renderedSentry = renderSentryInitialPrompt(sentryTpl, {
566
+ issueId: sentryContent.issueId,
567
+ sentryUrl: body.sentryUrl ?? '',
568
+ sentryFilePath,
569
+ });
570
+ brainstormPrompt += `\n${renderedSentry}\n`;
571
+ }
525
572
  }
526
573
  if (todos.length > 0) {
527
574
  brainstormPrompt += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
@@ -1559,13 +1606,24 @@ app.get('/:id/git-stats', async (c) => {
1559
1606
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1560
1607
  }
1561
1608
  const worktreePath = workspace.worktreePath;
1609
+ const freshFetch = c.req.query('freshFetch') === '1';
1610
+ if (freshFetch) {
1611
+ await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
1612
+ }
1613
+ else {
1614
+ // Fire-and-forget: explicitly catch to avoid unhandled rejection if the
1615
+ // helper ever changes contract and starts rejecting.
1616
+ void gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch).catch(() => { });
1617
+ }
1562
1618
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1619
+ const behindCount = gitOps.getCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1563
1620
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1564
1621
  const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
1565
1622
  const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath);
1566
1623
  const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
1567
1624
  return c.json({
1568
1625
  commitCount,
1626
+ behindCount,
1569
1627
  filesChanged: diffStats.filesChanged,
1570
1628
  insertions: diffStats.insertions,
1571
1629
  deletions: diffStats.deletions,
@@ -1674,6 +1732,36 @@ app.post('/:id/rollback-file', async (c) => {
1674
1732
  return c.json({ error: message }, 500);
1675
1733
  }
1676
1734
  });
1735
+ // GET /api/workspaces/:id/branch-divergence?limit=50
1736
+ // Returns commits on the working branch ahead of source (`ahead`) and
1737
+ // commits on source not yet on the working branch (`behind`). One round-trip
1738
+ // for the BranchDivergenceDialog.
1739
+ app.get('/:id/branch-divergence', (c) => {
1740
+ try {
1741
+ const id = c.req.param('id');
1742
+ const workspace = workspaceService.getWorkspace(id);
1743
+ if (!workspace) {
1744
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1745
+ }
1746
+ const limitRaw = c.req.query('limit');
1747
+ const parsed = parseInt(limitRaw ?? '50', 10);
1748
+ const limit = Math.min(Math.max(1, Number.isNaN(parsed) ? 50 : parsed), 200);
1749
+ const worktreePath = workspace.worktreePath;
1750
+ const ahead = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
1751
+ const behind = gitOps.listCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
1752
+ c.header('Cache-Control', 'no-store');
1753
+ return c.json({
1754
+ ahead,
1755
+ behind,
1756
+ sourceBranch: workspace.sourceBranch,
1757
+ workingBranch: workspace.workingBranch,
1758
+ });
1759
+ }
1760
+ catch (err) {
1761
+ const message = err instanceof Error ? err.message : String(err);
1762
+ return c.json({ error: message }, 500);
1763
+ }
1764
+ });
1677
1765
  // GET /api/workspaces/:id/commits?limit=50 — list commits between sourceBranch
1678
1766
  // and HEAD, each tagged with whether it's already pushed to origin/<branch>.
1679
1767
  app.get('/:id/commits', (c) => {
@@ -1727,6 +1815,10 @@ app.post('/:id/rename-branch', async (c) => {
1727
1815
  // Sibling rename: keep the same worktrees-root, swap the branch leaf.
1728
1816
  // Cannot use `path.dirname` directly because branches with slashes
1729
1817
  // (e.g. `feature/x`) make the dirname end one level too deep.
1818
+ // Note: we don't pass a `projectSlug` argument here on purpose — the
1819
+ // sibling resolver auto-detects whether the existing path was prefixed
1820
+ // by inspecting its suffix, so prefixed and legacy worktrees both keep
1821
+ // their layout across rename without us having to know the slug here.
1730
1822
  const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, oldWorktreePath, workspace.workingBranch, newName);
1731
1823
  // Reject early if the target name is already in use — either as a local
1732
1824
  // branch or on origin. Avoids git's generic "already exists" error and
@@ -1797,6 +1889,9 @@ app.post('/:id/resync-branch', (c) => {
1797
1889
  // if the move fails (dir already moved, lockfile, dirty tree), we still
1798
1890
  // update the DB so git ops stay aligned with the current ref name — the
1799
1891
  // user can repair the dir manually.
1892
+ // Same auto-detection rationale as the rename path above: the resolver
1893
+ // recovers the (possibly slug-prefixed) root from the existing path, so
1894
+ // we don't pass `projectSlug` here either.
1800
1895
  const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, worktreePath, workspace.workingBranch, actual);
1801
1896
  try {
1802
1897
  gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
@@ -2138,6 +2233,114 @@ app.post('/:id/open-pr', async (c) => {
2138
2233
  return c.json({ error: message }, 500);
2139
2234
  }
2140
2235
  });
2236
+ // POST /api/workspaces/:id/start-review — ask the agent to review committed + uncommitted changes
2237
+ app.post('/:id/start-review', async (c) => {
2238
+ try {
2239
+ const id = c.req.param('id');
2240
+ const workspace = workspaceService.getWorkspace(id);
2241
+ if (!workspace) {
2242
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2243
+ }
2244
+ const body = await c.req
2245
+ .json()
2246
+ .catch(() => ({}));
2247
+ const additionalInstructions = (body.additionalInstructions ?? '').trim();
2248
+ const newSession = body.newSession === true;
2249
+ const worktreePath = workspace.worktreePath;
2250
+ // Best-effort fetch so the base ref is fresh
2251
+ try {
2252
+ await execFileAsync('git', ['fetch', 'origin', workspace.sourceBranch], { cwd: worktreePath });
2253
+ }
2254
+ catch (err) {
2255
+ const msg = err instanceof Error ? err.message : String(err);
2256
+ console.warn(`[start-review] git fetch origin ${workspace.sourceBranch} failed: ${msg}`);
2257
+ }
2258
+ // Resolve base commit
2259
+ let baseCommit;
2260
+ try {
2261
+ const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${workspace.sourceBranch}`], {
2262
+ cwd: worktreePath,
2263
+ });
2264
+ baseCommit = stdout.trim();
2265
+ }
2266
+ catch (err) {
2267
+ const msg = err instanceof Error ? err.message : String(err);
2268
+ return c.json({ error: `Cannot resolve base commit for branch ${workspace.sourceBranch}: ${msg}` }, 500);
2269
+ }
2270
+ // Build context
2271
+ const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
2272
+ const committedStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
2273
+ const workingTreeStats = gitOps.getWorkingTreeDiffStats(worktreePath);
2274
+ const diffStats = workingTreeStats.trim().length > 0
2275
+ ? `${committedStats}\n\n— Working tree (uncommitted) —\n${workingTreeStats}`
2276
+ : committedStats;
2277
+ const effective = settingsService.getEffectiveSettings(workspace.projectPath);
2278
+ const template = effective.reviewPromptTemplate || DEFAULT_REVIEW_PROMPT_TEMPLATE;
2279
+ const rendered = renderReviewTemplate(template, {
2280
+ workspace,
2281
+ commits,
2282
+ diffStats,
2283
+ baseCommit,
2284
+ additionalInstructions,
2285
+ });
2286
+ try {
2287
+ wakeupService.cancel(workspace.id, 'user-message');
2288
+ }
2289
+ catch {
2290
+ /* swallow */
2291
+ }
2292
+ let messageSent = false;
2293
+ let emitSessionId;
2294
+ if (newSession) {
2295
+ // Stop current agent (best-effort) then start fresh.
2296
+ try {
2297
+ agentManager.stopAgent(workspace.id);
2298
+ }
2299
+ catch (err) {
2300
+ const msg = err instanceof Error ? err.message : String(err);
2301
+ console.error(`[start-review] stopAgent failed (continuing): ${msg}`);
2302
+ }
2303
+ try {
2304
+ const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, false /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
2305
+ workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
2306
+ emitSessionId = agent?.agentSessionId;
2307
+ messageSent = true;
2308
+ }
2309
+ catch (err) {
2310
+ const msg = err instanceof Error ? err.message : String(err);
2311
+ return c.json({ error: `Failed to start review session: ${msg}` }, 500);
2312
+ }
2313
+ }
2314
+ else {
2315
+ const session = workspaceService.getActiveSession(workspace.id);
2316
+ emitSessionId = session?.id;
2317
+ try {
2318
+ agentManager.sendMessage(workspace.id, rendered);
2319
+ messageSent = true;
2320
+ }
2321
+ catch {
2322
+ try {
2323
+ const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, true /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
2324
+ workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
2325
+ emitSessionId = agent?.agentSessionId ?? emitSessionId;
2326
+ messageSent = true;
2327
+ }
2328
+ catch (resumeErr) {
2329
+ const msg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
2330
+ return c.json({ error: `Failed to dispatch review prompt: ${msg}` }, 500);
2331
+ }
2332
+ }
2333
+ }
2334
+ // Emit AFTER dispatch so the user:message lands in the correct session id —
2335
+ // for newSession=true that's the freshly created session, not the previous one.
2336
+ wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, emitSessionId);
2337
+ return c.json({ ok: true, messageSent, newSession });
2338
+ }
2339
+ catch (err) {
2340
+ const message = err instanceof Error ? err.message : String(err);
2341
+ return c.json({ error: message }, 500);
2342
+ }
2343
+ });
2141
2344
  /** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
2142
2345
  app.post('/:id/mark-read', (c) => {
2143
2346
  try {
@@ -300,6 +300,9 @@ function readEffectiveSettingsSafe(projectPath) {
300
300
  model: 'claude-opus-4-7',
301
301
  dangerouslySkipPermissions: true,
302
302
  prPromptTemplate: '',
303
+ reviewPromptTemplate: '',
304
+ notionInitialPromptTemplate: '',
305
+ sentryInitialPromptTemplate: '',
303
306
  gitConventions: '',
304
307
  sourceBranch: 'main',
305
308
  devServer: null,
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
3
3
  import { getDb } from '../db/index.js';
4
+ import { slugifyProjectName } from '../utils/project-slug.js';
4
5
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
5
6
  import * as orchestrator from './agent/orchestrator.js';
6
7
  import * as settingsService from './settings-service.js';
@@ -252,7 +253,12 @@ function spawnNextIteration(workspaceId, opts = {}) {
252
253
  .replaceAll('{taskTitle}', task.title)
253
254
  .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
254
255
  .replaceAll('{overrideBlock}', overrideBlock);
255
- const worktreePath = row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch);
256
+ const globalSettings = settingsService.getGlobalSettings();
257
+ const projectSlug = globalSettings.worktreesPrefixByProject
258
+ ? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
259
+ : undefined;
260
+ const worktreePath = row.worktree_path ??
261
+ resolveWorkspaceWorktreePath(row.project_path, row.working_branch, globalSettings.worktreesPath, projectSlug);
256
262
  // Plan mode would deadlock the loop (blocks MCP + edits) — promote to bypass.
257
263
  // Other modes (bypass/strict/interactive) are honored.
258
264
  const stored = (row.agent_permission_mode ?? 'bypass');
@@ -0,0 +1,48 @@
1
+ export const DEFAULT_NOTION_INITIAL_PROMPT = `MANDATORY context-enrichment for Notion ticket {ticket_id}. Run this BEFORE any codebase exploration, sub-agent dispatch, brainstorming skill, or ExitPlanMode call.
2
+
3
+ 1. Read {notion_file_path}.
4
+ 2. Fetch every linked Notion resource via the Notion MCP tools: sub-tickets, references, linked blocks, linked databases. Recurse one level into anything that looks task-relevant.
5
+ 3. Persist EVERYTHING you found to {notion_file_path}. Inline the sub-page content, extracted requirements, acceptance criteria, dependencies, key field values. The file becomes the single source of truth — anything not written there is invisible to the downstream agent.
6
+ - If Edit/Write is available right now: use it immediately on {notion_file_path}, then move on.
7
+ - If you are in plan mode and Edit/Write is blocked: the very FIRST line of your implementation plan MUST be a verbatim Edit/Write call on {notion_file_path} with the full enriched content. Not a paraphrase, not a TODO — the literal tool call with the file path and the new content. Place it BEFORE any code change in the plan.
8
+ 4. After the file is written (or after ExitPlanMode if you were in plan mode), re-read {notion_file_path} to confirm.
9
+
10
+ HARD RULES:
11
+ - Do NOT call ExitPlanMode until step 2 has fetched the linked resources and you know what content step 3 will write.
12
+ - Do NOT skip step 3. "I have the context in mind" is NOT acceptable — write it to disk.
13
+ - Do NOT dispatch sub-agents to explore the codebase before {notion_file_path} is enriched (or planned to be enriched as line 1 of your plan).`;
14
+ export const DEFAULT_SENTRY_INITIAL_PROMPT = `MANDATORY context-enrichment for Sentry issue {issue_id}. Run this BEFORE locating the bug, writing tests, or implementing the fix.
15
+
16
+ 1. Read {sentry_file_path}.
17
+ 2. Use the Sentry MCP tools to fetch the latest events, breadcrumbs, tags, runtime/environment details, related issues and any reproduction hints.
18
+ 3. Persist EVERYTHING you found to {sentry_file_path}. Inline stack frames, frequent breadcrumb sequences, environment matrix, related events, hypotheses. The file becomes the single source of truth — anything not written there is invisible to the downstream fix.
19
+ - If Edit/Write is available right now: use it immediately on {sentry_file_path}.
20
+ - If you are in plan mode and Edit/Write is blocked: the very FIRST line of your implementation plan MUST be a verbatim Edit/Write call on {sentry_file_path} with the full enriched content. Not a paraphrase, not a TODO — the literal tool call with the file path and the new content. Place it BEFORE any code change in the plan.
21
+ 4. After the file is written, re-read {sentry_file_path} to confirm.
22
+
23
+ HARD RULES:
24
+ - Do NOT skip step 3. "I have the context in mind" is NOT acceptable — write it to disk.
25
+ - Do NOT explore the codebase or write a failing test before {sentry_file_path} is enriched (or planned to be enriched as line 1 of your plan).`;
26
+ function renderSimple(template, vars) {
27
+ return template.replace(/\{(\w+)\}/g, (match, name) => {
28
+ if (Object.hasOwn(vars, name))
29
+ return vars[name];
30
+ return match;
31
+ });
32
+ }
33
+ /** Render the Notion initial prompt by substituting {var} placeholders. Pure. */
34
+ export function renderNotionInitialPrompt(template, ctx) {
35
+ return renderSimple(template, {
36
+ ticket_id: ctx.ticketId,
37
+ notion_url: ctx.notionUrl,
38
+ notion_file_path: ctx.notionFilePath,
39
+ });
40
+ }
41
+ /** Render the Sentry initial prompt by substituting {var} placeholders. Pure. */
42
+ export function renderSentryInitialPrompt(template, ctx) {
43
+ return renderSimple(template, {
44
+ issue_id: ctx.issueId,
45
+ sentry_url: ctx.sentryUrl,
46
+ sentry_file_path: ctx.sentryFilePath,
47
+ });
48
+ }
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ export const DEFAULT_REVIEW_PROMPT_TEMPLATE = `You are reviewing code changes on workspace "{{workspace_name}}" in project {{project_name}}.
3
+
4
+ Branch: {{branch_name}} (base: {{source_branch}})
5
+ Base commit: {{base_commit}}
6
+
7
+ If a code-review skill is available (e.g. superpowers:requesting-code-review), invoke it to drive this review. Otherwise follow the steps below directly.
8
+
9
+ ## Scope
10
+
11
+ Review ALL changes — both committed and uncommitted in the working tree:
12
+ - \`git diff {{base_commit}}..HEAD\` — committed changes on this branch
13
+ - \`git status\` and \`git diff\` — uncommitted changes (staged + unstaged)
14
+
15
+ ## Diff summary
16
+ {{diff_stats}}
17
+
18
+ ## Commits
19
+ {{commits}}
20
+
21
+ ## Additional instructions
22
+ {{additional_instructions}}
23
+
24
+ ## Output
25
+
26
+ If no review skill is available, structure your reply as:
27
+ 1. Summary — what changed and why
28
+ 2. Issues — bugs, regressions, security or perf concerns (with file:line)
29
+ 3. Suggestions — refactor / improvement opportunities
30
+ 4. Tests — coverage gaps
31
+ 5. Verdict — ship / fix-then-ship / blocked
32
+ `;
33
+ function buildVariableMap(ctx) {
34
+ return {
35
+ project_name: path.basename(ctx.workspace.projectPath),
36
+ workspace_name: ctx.workspace.name,
37
+ branch_name: ctx.workspace.workingBranch,
38
+ source_branch: ctx.workspace.sourceBranch,
39
+ base_commit: ctx.baseCommit,
40
+ commits: ctx.commits,
41
+ diff_stats: ctx.diffStats,
42
+ notion_url: ctx.workspace.notionUrl ?? '',
43
+ additional_instructions: ctx.additionalInstructions.length > 0 ? ctx.additionalInstructions : '(none)',
44
+ };
45
+ }
46
+ /**
47
+ * Render a review prompt template by substituting {{variable}} placeholders.
48
+ * Pure: no I/O, no side effects. Unknown variables are left intact.
49
+ */
50
+ export function renderReviewTemplate(template, ctx) {
51
+ const vars = buildVariableMap(ctx);
52
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
53
+ if (Object.hasOwn(vars, name)) {
54
+ return vars[name];
55
+ }
56
+ return match;
57
+ });
58
+ }