@loicngr/kobo 1.5.1 → 1.5.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 (50) hide show
  1. package/README.md +64 -0
  2. package/dist/server/index.js +2 -0
  3. package/dist/server/routes/plans.js +3 -3
  4. package/dist/server/routes/sentry.js +20 -0
  5. package/dist/server/routes/workspaces.js +99 -3
  6. package/dist/server/services/notion-service.js +59 -188
  7. package/dist/server/services/pr-watcher-service.js +16 -0
  8. package/dist/server/services/sentry-service.js +130 -0
  9. package/dist/server/utils/mcp-client.js +191 -0
  10. package/package.json +1 -1
  11. package/src/client/dist/spa/assets/{ActivityFeed-CMlUg42q.js → ActivityFeed-C7knqOOL.js} +1 -1
  12. package/src/client/dist/spa/assets/CreatePage-Owxm6Tpi.js +2 -0
  13. package/src/client/dist/spa/assets/CreatePage-y7wOGccu.css +1 -0
  14. package/src/client/dist/spa/assets/{DiffViewer-BiGsv9dO.js → DiffViewer-mLBk-G-D.js} +2 -2
  15. package/src/client/dist/spa/assets/{MainLayout-TNUcP5Yq.css → MainLayout-DH5FgF9v.css} +1 -1
  16. package/src/client/dist/spa/assets/{MainLayout-B9a2mmgw.js → MainLayout-z-GTmsNk.js} +17 -17
  17. package/src/client/dist/spa/assets/{QExpansionItem-K1q9P_EC.js → QExpansionItem-BoxRv2sW.js} +1 -1
  18. package/src/client/dist/spa/assets/{QList-jw9RuMIz.js → QList-C0MyMgKh.js} +1 -1
  19. package/src/client/dist/spa/assets/{QMenu-_Z0TxAxE.js → QMenu-DayfIdY0.js} +1 -1
  20. package/src/client/dist/spa/assets/{QPage-rbGaJ2cR.js → QPage-D7SSe7S1.js} +1 -1
  21. package/src/client/dist/spa/assets/{SettingsPage-BJN15q-J.js → SettingsPage-BzJ9LphQ.js} +1 -1
  22. package/src/client/dist/spa/assets/{WorkspacePage-fFHT1mz2.js → WorkspacePage-Dv_vuDsw.js} +3 -3
  23. package/src/client/dist/spa/assets/{cssMode-D7f5NOFi.js → cssMode-4TH_2zaD.js} +1 -1
  24. package/src/client/dist/spa/assets/{editor.api-BEvyDmPD.js → editor.api-CyJb27tS.js} +1 -1
  25. package/src/client/dist/spa/assets/{editor.main-C3fYYKpq.js → editor.main-oZnnOWQQ.js} +3 -3
  26. package/src/client/dist/spa/assets/{freemarker2-BTPOfUHU.js → freemarker2-QspagBXp.js} +1 -1
  27. package/src/client/dist/spa/assets/{handlebars-C-E8T8yz.js → handlebars-DNK4bfk9.js} +1 -1
  28. package/src/client/dist/spa/assets/{html-BQVT0h1N.js → html-DoAFaOJE.js} +1 -1
  29. package/src/client/dist/spa/assets/{htmlMode-rlGPBuXx.js → htmlMode-Dd2XBcML.js} +1 -1
  30. package/src/client/dist/spa/assets/i18n-NIfrKJms.js +1 -0
  31. package/src/client/dist/spa/assets/i18n-ckoKODmn.js +1 -0
  32. package/src/client/dist/spa/assets/index-B4-QwpHI.js +5 -0
  33. package/src/client/dist/spa/assets/{javascript-ixmyyZe4.js → javascript-C8gxtWJG.js} +1 -1
  34. package/src/client/dist/spa/assets/{jsonMode-iSqte9q7.js → jsonMode-BW-w1oEn.js} +1 -1
  35. package/src/client/dist/spa/assets/{liquid-BtIa2lKm.js → liquid-CQDlB55c.js} +1 -1
  36. package/src/client/dist/spa/assets/{marked.esm-Cid1-PgZ.js → marked.esm-CGMwQXW0.js} +1 -1
  37. package/src/client/dist/spa/assets/{mdx-F0U_AQKl.js → mdx-DUS2o1l2.js} +1 -1
  38. package/src/client/dist/spa/assets/{monaco.contribution-CffkEucM.js → monaco.contribution-BSsdGujG.js} +2 -2
  39. package/src/client/dist/spa/assets/{python-DULmhOm4.js → python-DVWLF-An.js} +1 -1
  40. package/src/client/dist/spa/assets/{razor-B383fmQ6.js → razor-bodgahcD.js} +1 -1
  41. package/src/client/dist/spa/assets/{tsMode-CRCGK1Fk.js → tsMode-C7oNyY1g.js} +1 -1
  42. package/src/client/dist/spa/assets/{typescript-GvsL7ngZ.js → typescript-C_X20eQu.js} +1 -1
  43. package/src/client/dist/spa/assets/{xml-Chm6YPh1.js → xml-BVbLd8T0.js} +1 -1
  44. package/src/client/dist/spa/assets/{yaml-D_yl_W0w.js → yaml-D5WOobuH.js} +1 -1
  45. package/src/client/dist/spa/index.html +2 -2
  46. package/src/client/dist/spa/assets/CreatePage-BTFc1WXO.css +0 -1
  47. package/src/client/dist/spa/assets/CreatePage-CmoZpcGV.js +0 -2
  48. package/src/client/dist/spa/assets/i18n-BMjw8DJ5.js +0 -1
  49. package/src/client/dist/spa/assets/i18n-D5LoHj4O.js +0 -1
  50. package/src/client/dist/spa/assets/index-wen3XwaX.js +0 -5
package/README.md CHANGED
@@ -15,6 +15,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
15
15
  - **Live agent output** — stream `stdout`/`stderr` from Claude Code to the browser via WebSocket, with persisted event replay on reconnect
16
16
  - **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
17
17
  - **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria
18
+ - **Sentry integration** — paste a Sentry issue URL to spin up a dedicated "fix workspace" with the stacktrace, tags, and offending spans written to `.ai/thoughts/SENTRY-<id>.md`; the agent is primed with a TDD fix workflow and has access to the Sentry MCP tools for deeper digging
18
19
  - **Per-workspace dev servers** — start/stop Docker or Node dev servers scoped to each branch, with log streaming
19
20
  - **Conventional-commit enforcement** — project-level git conventions are written to `.ai/.git-conventions.md` inside every workspace so Claude follows them during commits
20
21
  - **Pull request automation** — one-click `push`, `pull`, and `open-pr` endpoints integrate with the GitHub CLI, using a configurable prompt template
@@ -41,6 +42,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
41
42
  - Optional: Docker (if you configure per-workspace dev servers)
42
43
  - Optional: `gh` CLI (if you use the PR automation)
43
44
  - Optional: a Notion integration token (only if you want to import workspace missions from Notion pages — see [Notion integration](#notion-integration))
45
+ - Optional: a Sentry auth token (only if you want to create fix workspaces from Sentry issue URLs — see [Sentry integration](#sentry-integration))
44
46
 
45
47
  ### Run via `npx` (recommended)
46
48
 
@@ -137,6 +139,68 @@ If you need to pin a specific version of the Notion MCP server, use a fork, or a
137
139
 
138
140
  Without a valid token configured, the Notion import field in the workspace creation form will return an error when you click **Refresh** or submit a Notion URL — the rest of Kōbō (workspaces, agents, tasks, Git integration) keeps working independently.
139
141
 
142
+ ## Sentry integration
143
+
144
+ Kōbō can turn a Sentry issue into a dedicated "fix workspace" — you paste the issue URL at workspace creation and Kōbō extracts the stacktrace, culprit, tags, offending spans and extra context, writes them as a local markdown file inside the worktree (`.ai/thoughts/SENTRY-<id>.md`), and primes the Claude agent with a TDD fix workflow that points at that file. The agent also keeps access to the Sentry MCP tools (`search_issue_events`, `get_issue_tag_values`, `get_sentry_resource`) so it can dig deeper on its own. **This feature is opt-in and reuses the Sentry MCP configuration you already have for Claude Code** — Kōbō does not manage a Sentry token separately.
145
+
146
+ Under the hood, Kōbō spawns the official [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server) as a child process using the exact `command`, `args`, and `env` from your `~/.claude.json`, then calls `get_sentry_resource` over stdio. No token handling inside Kōbō — if you change the token or the host in your Claude Code config, Kōbō follows automatically.
147
+
148
+ ### Getting a Sentry auth token
149
+
150
+ 1. In Sentry, go to **Settings → Developer Settings → Custom Integrations** (or **User Auth Tokens** for personal use)
151
+ 2. Create a token with at least these scopes: `project:read`, `event:read`, `org:read`
152
+ 3. Copy the token (format `sntryu_...` for user tokens)
153
+
154
+ ### Configuring the Sentry MCP in Claude Code
155
+
156
+ The recommended setup is to register the Sentry MCP once in Claude Code — Kōbō picks it up automatically:
157
+
158
+ ```bash
159
+ claude mcp add sentry -s user \
160
+ -e SENTRY_ACCESS_TOKEN=sntryu_your_token_here \
161
+ -e SENTRY_HOST=your-org.sentry.io \
162
+ -- npx -y @sentry/mcp-server@latest
163
+ ```
164
+
165
+ For self-hosted Sentry, set `SENTRY_HOST` to your Sentry hostname (e.g. `sentry.mycompany.com`).
166
+
167
+ ### How Kōbō picks the entry
168
+
169
+ Kōbō reads `~/.claude.json` and uses the first entry under `mcpServers` whose key contains `sentry` (case-insensitive) **and is not disabled**. This means:
170
+
171
+ - A single `sentry` entry → used as-is
172
+ - Multiple entries whose key contains `sentry` → the first matching non-disabled key wins
173
+ - Toggle `"disabled": true` on an entry to make Kōbō skip it
174
+
175
+ ### Usage
176
+
177
+ 1. In the workspace creation form, click **Import Sentry**
178
+ 2. Paste the issue URL (e.g. `https://your-org.sentry.io/issues/112081699`)
179
+ 3. Submit — Kōbō extracts the issue, writes `.ai/thoughts/SENTRY-<numericId>.md`, creates a `Fix: <title>` task, and boots the agent with the fix workflow
180
+
181
+ The Sentry issue Short-ID (e.g. `ACME-API-3` — the canonical identifier Sentry assigns to each issue) is used as the ticket prefix for the working branch (e.g. `fix/ACME-API-3--slow-db-query` or `bugfix/ACME-API-3--slow-db-query`, depending on the branch prefix you chose at creation). The Short-ID is also what Sentry recognises in commit messages like `Fixes ACME-API-3` to auto-close the issue on merge. The local copy of the issue is written to `.ai/thoughts/SENTRY-<shortId>.md` (e.g. `SENTRY-ACME-API-3.md`). When Sentry is active, the description field becomes optional — the extracted context is enough to start work.
182
+
183
+ If the MCP server is slow to initialize (e.g. cold `npx` fetch, self-hosted host validation), bump the handshake timeout with `KOBO_MCP_INIT_TIMEOUT_MS` (default: `30000`).
184
+
185
+ Without a valid Sentry MCP configured in `~/.claude.json`, the Sentry import field returns a clear error when you submit — the rest of Kōbō keeps working.
186
+
187
+ ## Recommended: Superpowers plugin for Claude Code
188
+
189
+ For the best experience, we recommend installing the [**superpowers**](https://github.com/obra/superpowers) plugin in Claude Code. Kōbō is designed to work well with it out of the box:
190
+
191
+ - **Brainstorming → spec → plan → execute** workflow — superpowers produces design specs in `docs/superpowers/specs/` and implementation plans in `docs/superpowers/plans/`; Kōbō's **Plan browser** (right-side drawer) lists both so you can review them without leaving the UI
192
+ - **Subagent-driven development** — executes plans task-by-task via parallel subagents; Kōbō surfaces sub-agent activity in the chat feed and the *Agent busy* banner so you always know what's running
193
+ - **Test-driven development, systematic debugging, code review** — all integrated with Kōbō's task tracking and git workflow
194
+
195
+ Install inside Claude Code:
196
+
197
+ ```bash
198
+ /plugin marketplace add obra/superpowers-marketplace
199
+ /plugin install superpowers@superpowers-marketplace
200
+ ```
201
+
202
+ Then start a new workspace in Kōbō — the agent will pick up the skills automatically.
203
+
140
204
  ## Architecture
141
205
 
142
206
  ```
@@ -12,6 +12,7 @@ import gitRouter from './routes/git.js';
12
12
  import imagesRouter from './routes/images.js';
13
13
  import notionRouter from './routes/notion.js';
14
14
  import plansRouter from './routes/plans.js';
15
+ import sentryRouter from './routes/sentry.js';
15
16
  import settingsRouter from './routes/settings.js';
16
17
  import templatesRouter from './routes/templates.js';
17
18
  import workspacesRouter from './routes/workspaces.js';
@@ -47,6 +48,7 @@ app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion(
47
48
  app.route('/api/workspaces', workspacesRouter);
48
49
  app.route('/api/workspaces', imagesRouter);
49
50
  app.route('/api/notion', notionRouter);
51
+ app.route('/api/sentry', sentryRouter);
50
52
  app.route('/api/git', gitRouter);
51
53
  app.route('/api/settings', settingsRouter);
52
54
  app.route('/api/dev-server', devServerRouter);
@@ -4,8 +4,8 @@ import { Hono } from 'hono';
4
4
  import * as workspaceService from '../services/workspace-service.js';
5
5
  /** Hono sub-router for workspace plan file browsing (read-only). */
6
6
  const app = new Hono();
7
- /** Directories inside the worktree where plan files may live. */
8
- const PLAN_DIRS = ['docs/plans', 'docs/superpowers/plans'];
7
+ /** Directories inside the worktree where plan / design doc files may live. */
8
+ const PLAN_DIRS = ['docs/plans', 'docs/superpowers/plans', 'docs/superpowers/specs'];
9
9
  /** Only .md files are listed. */
10
10
  const MD_EXT = '.md';
11
11
  // GET /:id/plans — list plan files in the workspace worktree
@@ -71,7 +71,7 @@ app.get('/:id/plan-file', (c) => {
71
71
  // Security: normalize the path and verify it stays within allowed directories
72
72
  const normalized = path.normalize(filePath);
73
73
  if (normalized.includes('..') || !PLAN_DIRS.some((dir) => normalized.startsWith(dir))) {
74
- return c.json({ error: 'Invalid path: must be under docs/plans/ or docs/superpowers/plans/' }, 400);
74
+ return c.json({ error: 'Invalid path: must be under docs/plans/, docs/superpowers/plans/, or docs/superpowers/specs/' }, 400);
75
75
  }
76
76
  const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
77
77
  const absPath = path.join(worktreePath, normalized);
@@ -0,0 +1,20 @@
1
+ import { Hono } from 'hono';
2
+ import { extractSentryIssue } from '../services/sentry-service.js';
3
+ /** Hono sub-router for Sentry issue extraction (preflight). */
4
+ const app = new Hono();
5
+ // POST /api/sentry/extract — extract a Sentry issue by URL
6
+ app.post('/extract', async (c) => {
7
+ try {
8
+ const body = await c.req.json();
9
+ if (!body.url) {
10
+ return c.json({ error: 'Missing required field: url' }, 400);
11
+ }
12
+ const content = await extractSentryIssue(body.url);
13
+ return c.json(content);
14
+ }
15
+ catch (err) {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ return c.json({ error: message }, 500);
18
+ }
19
+ });
20
+ export default app;
@@ -9,6 +9,7 @@ import * as agentManager from '../services/agent-manager.js';
9
9
  import * as devServerService from '../services/dev-server-service.js';
10
10
  import * as notionService from '../services/notion-service.js';
11
11
  import { renderPrTemplate } from '../services/pr-template-service.js';
12
+ import * as sentryService from '../services/sentry-service.js';
12
13
  import * as settingsService from '../services/settings-service.js';
13
14
  import { runSetupScript } from '../services/setup-script-service.js';
14
15
  import * as terminalService from '../services/terminal-service.js';
@@ -52,6 +53,7 @@ app.post('/', async (c) => {
52
53
  permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
53
54
  });
54
55
  let notionContent = null;
56
+ let sentryContent = null;
55
57
  // Extract Notion page content if a URL was provided
56
58
  if (body.notionUrl) {
57
59
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
@@ -63,6 +65,23 @@ app.post('/', async (c) => {
63
65
  console.error(`[workspaces] Failed to extract Notion page: ${message}`);
64
66
  }
65
67
  }
68
+ // Extract Sentry issue content if a URL was provided. Done early (before
69
+ // worktree creation) so the issue ID can be injected into the branch name.
70
+ if (body.sentryUrl) {
71
+ workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
72
+ try {
73
+ sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
74
+ }
75
+ catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ console.error(`[workspaces] Failed to extract Sentry issue: ${message}`);
78
+ }
79
+ }
80
+ // Update workspace name with Sentry issue title if the user did not provide
81
+ // a custom name and Notion hasn't already filled it.
82
+ if (sentryContent?.title && !notionContent?.title && workspace.name === 'workspace') {
83
+ workspace = workspaceService.updateWorkspaceName(workspace.id, sentryContent.title);
84
+ }
66
85
  // Create tasks from extracted Notion data
67
86
  if (notionContent) {
68
87
  let sortOrder = 0;
@@ -116,14 +135,16 @@ app.post('/', async (c) => {
116
135
  // then falls back to a TK-XXXX pattern anywhere in the workspace name.
117
136
  // The worktree has not been created yet, so a DB update is sufficient.
118
137
  {
119
- const detectedTicketId = notionContent?.ticketId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
138
+ // Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
139
+ // which is what Sentry auto-close recognises in commit messages.
140
+ const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
120
141
  if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
121
142
  const ticketPrefix = detectedTicketId.toUpperCase();
122
143
  const slashIdx = workingBranch.indexOf('/');
123
144
  const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
124
- // Use Notion title or workspace name for the slug — both have proper accented
145
+ // Use Notion/Sentry title or workspace name for the slug — all have proper accented
125
146
  // characters that NFD normalization can transliterate (é→e, ç→c, etc.)
126
- const titleSource = notionContent?.title || workspace.name;
147
+ const titleSource = notionContent?.title || sentryContent?.title || workspace.name;
127
148
  const titleSlug = titleSource
128
149
  .normalize('NFD')
129
150
  .replace(/[\u0300-\u036f]/g, '')
@@ -234,6 +255,12 @@ app.post('/', async (c) => {
234
255
  md += `## Source\n\n`;
235
256
  md += `- Notion: ${body.notionUrl}\n`;
236
257
  md += `- Retrieved: ${today}\n\n`;
258
+ // Persist the user's initial instructions (typed in the "Description"
259
+ // field at creation time) so the agent can refer back to them later —
260
+ // e.g. additional Notion sub-pages, parent PRs, constraints, etc.
261
+ if (body.description?.trim()) {
262
+ md += `## User instructions\n\n${body.description.trim()}\n\n`;
263
+ }
237
264
  if (notionContent.goal) {
238
265
  md += `## Goal\n\n${notionContent.goal}\n\n`;
239
266
  }
@@ -256,6 +283,57 @@ app.post('/', async (c) => {
256
283
  console.error('[workspaces] Failed to save Notion content:', err);
257
284
  }
258
285
  }
286
+ // --- Sentry file + task (extraction already done before worktree creation) --
287
+ let sentryFilePath = null;
288
+ if (sentryContent) {
289
+ try {
290
+ const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
291
+ fs.mkdirSync(thoughtsDir, { recursive: true });
292
+ // File is named SENTRY-<shortId>.md (e.g. SENTRY-ACME-API-3.md) — the
293
+ // Short-ID is the canonical Sentry identifier. Falls back to the numeric
294
+ // ID if the Short-ID could not be parsed from the MCP response.
295
+ const idForFile = sentryContent.issueId || sentryContent.issueNumericId;
296
+ sentryFilePath = path.join(thoughtsDir, `SENTRY-${idForFile}.md`);
297
+ const today = new Date().toISOString().split('T')[0];
298
+ const tags = sentryContent.tags;
299
+ const env = tags.environment ?? 'unknown';
300
+ const tagsBlock = Object.entries(tags)
301
+ .map(([k, v]) => `- ${k}: ${v}`)
302
+ .join('\n') || '- (none)';
303
+ const spansBlock = sentryContent.offendingSpans.length > 0 ? sentryContent.offendingSpans.map((s) => `- ${s}`).join('\n') : 'N/A';
304
+ const extra = sentryContent.extraContext || 'N/A';
305
+ const md = `# Fix: ${sentryContent.title || sentryContent.issueId || sentryContent.issueNumericId}\n\n` +
306
+ `## Source\n` +
307
+ `- Sentry: ${body.sentryUrl}\n` +
308
+ `- Issue Short-ID: ${sentryContent.issueId} (use in commit messages for auto-close)\n` +
309
+ `- Issue numeric ID: ${sentryContent.issueNumericId}\n` +
310
+ `- Retrieved: ${today}\n\n` +
311
+ `## Summary\n` +
312
+ `- **Culprit**: ${sentryContent.culprit}\n` +
313
+ `- **Platform**: ${sentryContent.platform}\n` +
314
+ `- **Environment**: ${env}\n` +
315
+ `- **Occurrences**: ${sentryContent.occurrences} (first: ${sentryContent.firstSeen}, last: ${sentryContent.lastSeen})\n\n` +
316
+ `## Tags\n${tagsBlock}\n\n` +
317
+ `## Error Detail / Offending Spans\n${spansBlock}\n\n` +
318
+ `## Additional Context\n${extra}\n\n` +
319
+ `## MCP Tools for deeper analysis\n` +
320
+ `If you need more context, the following Sentry MCP tools are available:\n` +
321
+ `- \`mcp__sentry__get_sentry_resource(url, resourceType)\` — fetch the issue, breadcrumbs, replay, or trace\n` +
322
+ `- \`mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
323
+ `- \`mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
324
+ fs.writeFileSync(sentryFilePath, md, 'utf-8');
325
+ workspaceService.createTask(workspace.id, {
326
+ title: `Fix: ${sentryContent.title || sentryContent.issueId || `Sentry #${sentryContent.issueNumericId}`}`,
327
+ isAcceptanceCriterion: false,
328
+ sortOrder: 9999,
329
+ });
330
+ }
331
+ catch (err) {
332
+ console.error('[workspaces] Failed to save Sentry content:', err);
333
+ sentryFilePath = null;
334
+ }
335
+ }
336
+ // ------------------------------------------------------------------------
259
337
  // Update Notion status if both property name and value are configured
260
338
  const notionStatusProp = effectiveSettings.notionStatusProperty;
261
339
  const notionTargetStatus = effectiveSettings.notionInProgressStatus;
@@ -293,6 +371,24 @@ app.post('/', async (c) => {
293
371
  brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
294
372
  brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
295
373
  }
374
+ if (sentryFilePath && sentryContent) {
375
+ brainstormPrompt += `\nSentry issue: ${body.sentryUrl}`;
376
+ brainstormPrompt += `\nIssue Short-ID: ${sentryContent.issueId} (canonical, use in commit messages for auto-close)`;
377
+ brainstormPrompt += `\nIssue numeric ID: ${sentryContent.issueNumericId}`;
378
+ brainstormPrompt += `\nLocal copy: ${sentryFilePath}\n`;
379
+ brainstormPrompt +=
380
+ `\nFix workflow:\n` +
381
+ `1. Read the local Sentry file above for full context\n` +
382
+ `2. Locate the bug from the stacktrace / culprit\n` +
383
+ `3. Write a failing test that reproduces the bug (TDD)\n` +
384
+ `4. Implement the minimal fix\n` +
385
+ `5. Confirm the test passes, run related tests\n` +
386
+ `6. Commit referencing the Sentry Short-ID (e.g. "fix(scope): description (${sentryContent.issueId})") — Sentry auto-closes the issue when the commit is merged\n` +
387
+ `\nIf you need more context, Sentry MCP tools are available:\n` +
388
+ `- mcp__sentry__get_sentry_resource(url, resourceType) — fetch the issue, breadcrumbs, replay or trace\n` +
389
+ `- mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
390
+ `- mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
391
+ }
296
392
  if (todos.length > 0) {
297
393
  brainstormPrompt += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
298
394
  }
@@ -1,12 +1,10 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
3
- import { getPackageVersion } from '../utils/paths.js';
1
+ import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
4
2
  // Gherkin keywords (French and English)
5
3
  const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
6
- const nextRpcId = (() => {
7
- let counter = 1;
8
- return () => counter++;
9
- })();
4
+ // Keywords that start a NEW scenario and must flush the current block.
5
+ // NOTE: `Feature`/`Fonctionnalité` are top-level containers, not a new scenario,
6
+ // so they stay attached to the first scenario rather than triggering a split.
7
+ const SCENARIO_START_PATTERN = /^(Scénario|Scenario)/i;
10
8
  /**
11
9
  * Parse a Notion URL and extract the page_id in UUID format (with dashes).
12
10
  * Handles:
@@ -31,192 +29,36 @@ export function parseNotionUrl(url) {
31
29
  // Convert 32 hex chars to UUID format: 8-4-4-4-12
32
30
  return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
33
31
  }
34
- /** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
35
- export async function callMcpTool(mcpProcess, toolName, args) {
36
- const id = nextRpcId();
37
- const request = JSON.stringify({
38
- jsonrpc: '2.0',
39
- id,
40
- method: 'tools/call',
41
- params: {
42
- name: toolName,
43
- arguments: args,
44
- },
45
- });
46
- return new Promise((resolve, reject) => {
47
- if (!mcpProcess.stdin || !mcpProcess.stdout) {
48
- reject(new Error('MCP process stdin/stdout not available'));
49
- return;
50
- }
51
- let buffer = '';
52
- const timeout = setTimeout(() => {
53
- mcpProcess.stdout?.removeListener('data', onData);
54
- mcpProcess.stdout?.removeListener('error', onError);
55
- mcpProcess.kill();
56
- reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
57
- }, 30_000);
58
- const onData = (chunk) => {
59
- buffer += chunk.toString();
60
- // Try to parse complete JSON lines
61
- const lines = buffer.split('\n');
62
- // Keep the last (potentially incomplete) line in the buffer
63
- buffer = lines.pop() ?? '';
64
- for (const line of lines) {
65
- const trimmed = line.trim();
66
- if (!trimmed)
67
- continue;
68
- try {
69
- const parsed = JSON.parse(trimmed);
70
- if (parsed.id === id) {
71
- clearTimeout(timeout);
72
- mcpProcess.stdout?.removeListener('data', onData);
73
- mcpProcess.stdout?.removeListener('error', onError);
74
- if (parsed.error) {
75
- reject(new Error(`MCP tool '${toolName}' error: ${parsed.error.message} (code: ${parsed.error.code})`));
76
- }
77
- else {
78
- resolve(parsed.result);
79
- }
80
- }
81
- }
82
- catch {
83
- // Ignore JSON parse errors for partial lines
84
- }
85
- }
86
- };
87
- const onError = (err) => {
88
- clearTimeout(timeout);
89
- mcpProcess.stdout?.removeListener('data', onData);
90
- reject(err);
91
- };
92
- mcpProcess.stdout.on('data', onData);
93
- mcpProcess.stdout.once('error', onError);
94
- mcpProcess.stdin.write(`${request}\n`);
95
- });
96
- }
97
32
  /**
98
- * Read the Notion token from Claude Code's config file as a fallback.
33
+ * Read the Notion token from the user's Claude Code config as a fallback.
34
+ * Picks the first enabled `mcpServers` entry named `notion` (disabled entries
35
+ * are skipped). Returns an empty string when none is found.
99
36
  */
100
37
  function readNotionTokenFromClaudeConfig() {
101
- try {
102
- const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
103
- const configPath = `${homedir}/.claude.json`;
104
- const raw = readFileSync(configPath, 'utf-8');
105
- const config = JSON.parse(raw);
106
- const mcpServers = config.mcpServers;
107
- const notionServer = mcpServers?.notion;
108
- return notionServer?.env?.NOTION_TOKEN ?? notionServer?.env?.NOTION_API_TOKEN ?? '';
109
- }
110
- catch {
38
+ const match = readClaudeMcpEntry((k) => k === 'notion');
39
+ if (!match)
111
40
  return '';
112
- }
41
+ const env = match.entry.env ?? {};
42
+ return env.NOTION_TOKEN ?? env.NOTION_API_TOKEN ?? '';
113
43
  }
114
- function spawnMcpProcess() {
44
+ function buildNotionMcpConfig() {
115
45
  const notionToken = process.env.NOTION_API_TOKEN ?? process.env.NOTION_TOKEN ?? readNotionTokenFromClaudeConfig();
116
- const mcpCommand = process.env.NOTION_MCP_COMMAND ?? 'npx';
117
- const mcpArgs = process.env.NOTION_MCP_ARGS
46
+ const command = process.env.NOTION_MCP_COMMAND ?? 'npx';
47
+ const args = process.env.NOTION_MCP_ARGS
118
48
  ? process.env.NOTION_MCP_ARGS.split(' ')
119
49
  : ['-y', '@notionhq/notion-mcp-server'];
120
- const mcpProcess = spawn(mcpCommand, mcpArgs, {
121
- stdio: ['pipe', 'pipe', 'pipe'],
122
- env: {
123
- ...process.env,
124
- OPENAPI_MCP_HEADERS: JSON.stringify({
125
- Authorization: `Bearer ${notionToken}`,
126
- 'Notion-Version': '2022-06-28',
127
- }),
128
- },
129
- });
130
- mcpProcess.stderr?.on('data', (data) => {
131
- // Silently consume stderr to avoid cluttering logs
132
- const text = data.toString();
133
- if (process.env.DEBUG_NOTION_MCP) {
134
- console.error('[notion-mcp stderr]', text);
135
- }
136
- });
137
- return mcpProcess;
50
+ const env = {
51
+ ...process.env,
52
+ OPENAPI_MCP_HEADERS: JSON.stringify({
53
+ Authorization: `Bearer ${notionToken}`,
54
+ 'Notion-Version': '2022-06-28',
55
+ }),
56
+ };
57
+ return { command, args, env };
138
58
  }
139
- /** Initialize the MCP server by sending an initialize handshake (10s timeout). */
140
- async function initializeMcp(mcpProcess) {
141
- const id = nextRpcId();
142
- const request = JSON.stringify({
143
- jsonrpc: '2.0',
144
- id,
145
- method: 'initialize',
146
- params: {
147
- protocolVersion: '2024-11-05',
148
- capabilities: {},
149
- clientInfo: { name: 'kobo', version: getPackageVersion() },
150
- },
151
- });
152
- await new Promise((resolve, reject) => {
153
- if (!mcpProcess.stdin || !mcpProcess.stdout) {
154
- reject(new Error('MCP process not ready'));
155
- return;
156
- }
157
- let buffer = '';
158
- const timeout = setTimeout(() => {
159
- mcpProcess.stdout?.removeListener('data', onData);
160
- mcpProcess.kill();
161
- reject(new Error('initializeMcp timed out after 10s'));
162
- }, 10_000);
163
- const onData = (chunk) => {
164
- buffer += chunk.toString();
165
- const lines = buffer.split('\n');
166
- buffer = lines.pop() ?? '';
167
- for (const line of lines) {
168
- const trimmed = line.trim();
169
- if (!trimmed)
170
- continue;
171
- try {
172
- const parsed = JSON.parse(trimmed);
173
- if (parsed.id === id) {
174
- clearTimeout(timeout);
175
- mcpProcess.stdout?.removeListener('data', onData);
176
- const initialized = JSON.stringify({
177
- jsonrpc: '2.0',
178
- method: 'notifications/initialized',
179
- });
180
- mcpProcess.stdin?.write(`${initialized}\n`);
181
- resolve();
182
- }
183
- }
184
- catch {
185
- // ignore
186
- }
187
- }
188
- };
189
- const onError = (err) => {
190
- clearTimeout(timeout);
191
- mcpProcess.stdout?.removeListener('data', onData);
192
- reject(err);
193
- };
194
- mcpProcess.stdout.on('data', onData);
195
- mcpProcess.stdout.once('error', onError);
196
- mcpProcess.stdin.write(`${request}\n`);
197
- });
198
- }
199
- /**
200
- * Unwrap MCP tool response.
201
- * MCP returns { content: [{ type: "text", text: "..." }] }
202
- * where text is a JSON-stringified API response.
203
- */
204
- function unwrapMcpResult(result) {
205
- if (result && typeof result === 'object') {
206
- const obj = result;
207
- if (Array.isArray(obj.content)) {
208
- const first = obj.content[0];
209
- if (first?.type === 'text' && first.text) {
210
- try {
211
- return JSON.parse(first.text);
212
- }
213
- catch {
214
- return first.text;
215
- }
216
- }
217
- }
218
- }
219
- return result;
59
+ function spawnNotionMcp() {
60
+ const { command, args, env } = buildNotionMcpConfig();
61
+ return spawnMcpProcess(command, args, env);
220
62
  }
221
63
  function extractTextFromRichText(richText) {
222
64
  if (!Array.isArray(richText))
@@ -268,7 +110,17 @@ export function parseBlocks(blocks) {
268
110
  continue;
269
111
  }
270
112
  // Check if this is a Gherkin line
271
- if (GHERKIN_PATTERN.test(text.trim())) {
113
+ const trimmed = text.trim();
114
+ if (GHERKIN_PATTERN.test(trimmed)) {
115
+ // A new "Scenario:" line starts a fresh block — flush any in-progress
116
+ // scenario first so multiple consecutive scenarios aren't merged.
117
+ // Only flush when the current block already contains a Scenario line
118
+ // (a leading "Feature:" alone should stay attached to the first scenario).
119
+ if (SCENARIO_START_PATTERN.test(trimmed) &&
120
+ currentGherkinBlock.some((line) => SCENARIO_START_PATTERN.test(line.trim()))) {
121
+ gherkinFeatures.push(currentGherkinBlock.join('\n'));
122
+ currentGherkinBlock = [];
123
+ }
272
124
  currentGherkinBlock.push(text);
273
125
  }
274
126
  else if (currentGherkinBlock.length > 0) {
@@ -286,7 +138,26 @@ export function parseBlocks(blocks) {
286
138
  gherkinFeatures.push(currentGherkinBlock.join('\n'));
287
139
  currentGherkinBlock = [];
288
140
  }
289
- gherkinFeatures.push(codeText);
141
+ // Split the code block on each "Scenario:"/"Scénario:" boundary so that
142
+ // multiple scenarios in a single Notion code block become separate
143
+ // acceptance criteria. A leading "Feature:" (if present) stays attached
144
+ // to the first scenario.
145
+ const sections = [];
146
+ let current = [];
147
+ for (const line of codeText.split('\n')) {
148
+ if (SCENARIO_START_PATTERN.test(line.trim()) && current.some((l) => SCENARIO_START_PATTERN.test(l.trim()))) {
149
+ sections.push(current.join('\n').trimEnd());
150
+ current = [];
151
+ }
152
+ current.push(line);
153
+ }
154
+ if (current.length > 0) {
155
+ sections.push(current.join('\n').trimEnd());
156
+ }
157
+ for (const section of sections) {
158
+ if (section.trim())
159
+ gherkinFeatures.push(section);
160
+ }
290
161
  }
291
162
  insideObjectif = false;
292
163
  continue;
@@ -307,7 +178,7 @@ export function parseBlocks(blocks) {
307
178
  */
308
179
  export async function extractNotionPage(notionUrl) {
309
180
  const pageId = parseNotionUrl(notionUrl);
310
- const mcpProcess = spawnMcpProcess();
181
+ const mcpProcess = spawnNotionMcp();
311
182
  // Give the process a moment to start
312
183
  await new Promise((resolve, reject) => {
313
184
  const timeout = setTimeout(() => resolve(), 1000);
@@ -372,7 +243,7 @@ export async function extractNotionPage(notionUrl) {
372
243
  /** Update a status property on a Notion page. Best-effort, does not throw. */
373
244
  export async function updateNotionStatus(notionUrl, propertyName, statusValue) {
374
245
  const pageId = parseNotionUrl(notionUrl);
375
- const mcpProcess = spawnMcpProcess();
246
+ const mcpProcess = spawnNotionMcp();
376
247
  try {
377
248
  await new Promise((resolve, reject) => {
378
249
  const timeout = setTimeout(() => resolve(), 1000);
@@ -1,4 +1,6 @@
1
1
  import { getPrStatusAsync } from '../utils/git-ops.js';
2
+ import { stopDevServer } from './dev-server-service.js';
3
+ import { destroyTerminal } from './terminal-service.js';
2
4
  import { emitEphemeral } from './websocket-service.js';
3
5
  import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
4
6
  // ── PR Watcher ────────────────────────────────────────────────────────────────
@@ -35,6 +37,20 @@ async function checkPrStatuses() {
35
37
  // Only archive on a transition FROM OPEN — not on first sight of CLOSED/MERGED
36
38
  if (prev === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
37
39
  console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
40
+ // Best-effort cleanup (same as manual archive): stop dev server + terminal.
41
+ // Agent is already not running here (guarded above).
42
+ try {
43
+ stopDevServer(ws.id);
44
+ }
45
+ catch (err) {
46
+ console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
47
+ }
48
+ try {
49
+ destroyTerminal(ws.id);
50
+ }
51
+ catch {
52
+ // Terminal may not exist — ignore
53
+ }
38
54
  archiveWorkspace(ws.id);
39
55
  lastKnownState.delete(ws.id);
40
56
  emitEphemeral(ws.id, 'workspace:archived', {