@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.
- package/README.md +64 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/plans.js +3 -3
- package/dist/server/routes/sentry.js +20 -0
- package/dist/server/routes/workspaces.js +99 -3
- package/dist/server/services/notion-service.js +59 -188
- package/dist/server/services/pr-watcher-service.js +16 -0
- package/dist/server/services/sentry-service.js +130 -0
- package/dist/server/utils/mcp-client.js +191 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CMlUg42q.js → ActivityFeed-C7knqOOL.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Owxm6Tpi.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-y7wOGccu.css +1 -0
- package/src/client/dist/spa/assets/{DiffViewer-BiGsv9dO.js → DiffViewer-mLBk-G-D.js} +2 -2
- package/src/client/dist/spa/assets/{MainLayout-TNUcP5Yq.css → MainLayout-DH5FgF9v.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-B9a2mmgw.js → MainLayout-z-GTmsNk.js} +17 -17
- package/src/client/dist/spa/assets/{QExpansionItem-K1q9P_EC.js → QExpansionItem-BoxRv2sW.js} +1 -1
- package/src/client/dist/spa/assets/{QList-jw9RuMIz.js → QList-C0MyMgKh.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-_Z0TxAxE.js → QMenu-DayfIdY0.js} +1 -1
- package/src/client/dist/spa/assets/{QPage-rbGaJ2cR.js → QPage-D7SSe7S1.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-BJN15q-J.js → SettingsPage-BzJ9LphQ.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-fFHT1mz2.js → WorkspacePage-Dv_vuDsw.js} +3 -3
- package/src/client/dist/spa/assets/{cssMode-D7f5NOFi.js → cssMode-4TH_2zaD.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-BEvyDmPD.js → editor.api-CyJb27tS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-C3fYYKpq.js → editor.main-oZnnOWQQ.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-BTPOfUHU.js → freemarker2-QspagBXp.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-C-E8T8yz.js → handlebars-DNK4bfk9.js} +1 -1
- package/src/client/dist/spa/assets/{html-BQVT0h1N.js → html-DoAFaOJE.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-rlGPBuXx.js → htmlMode-Dd2XBcML.js} +1 -1
- package/src/client/dist/spa/assets/i18n-NIfrKJms.js +1 -0
- package/src/client/dist/spa/assets/i18n-ckoKODmn.js +1 -0
- package/src/client/dist/spa/assets/index-B4-QwpHI.js +5 -0
- package/src/client/dist/spa/assets/{javascript-ixmyyZe4.js → javascript-C8gxtWJG.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-iSqte9q7.js → jsonMode-BW-w1oEn.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-BtIa2lKm.js → liquid-CQDlB55c.js} +1 -1
- package/src/client/dist/spa/assets/{marked.esm-Cid1-PgZ.js → marked.esm-CGMwQXW0.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-F0U_AQKl.js → mdx-DUS2o1l2.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-CffkEucM.js → monaco.contribution-BSsdGujG.js} +2 -2
- package/src/client/dist/spa/assets/{python-DULmhOm4.js → python-DVWLF-An.js} +1 -1
- package/src/client/dist/spa/assets/{razor-B383fmQ6.js → razor-bodgahcD.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-CRCGK1Fk.js → tsMode-C7oNyY1g.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-GvsL7ngZ.js → typescript-C_X20eQu.js} +1 -1
- package/src/client/dist/spa/assets/{xml-Chm6YPh1.js → xml-BVbLd8T0.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-D_yl_W0w.js → yaml-D5WOobuH.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- package/src/client/dist/spa/assets/CreatePage-BTFc1WXO.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-CmoZpcGV.js +0 -2
- package/src/client/dist/spa/assets/i18n-BMjw8DJ5.js +0 -1
- package/src/client/dist/spa/assets/i18n-D5LoHj4O.js +0 -1
- 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
|
```
|
package/dist/server/index.js
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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 —
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
44
|
+
function buildNotionMcpConfig() {
|
|
115
45
|
const notionToken = process.env.NOTION_API_TOKEN ?? process.env.NOTION_TOKEN ?? readNotionTokenFromClaudeConfig();
|
|
116
|
-
const
|
|
117
|
-
const
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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', {
|