@loicngr/kobo 1.6.12 → 1.6.14
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 +11 -6
- package/dist/mcp-server/kobo-tasks-handlers.js +2 -2
- package/dist/server/db/migrations.js +24 -0
- package/dist/server/db/schema.js +10 -0
- package/dist/server/index.js +27 -4
- package/dist/server/routes/documents.js +2 -2
- package/dist/server/routes/git.js +21 -0
- package/dist/server/routes/health.js +2 -2
- package/dist/server/routes/images.js +3 -3
- package/dist/server/routes/usage.js +18 -0
- package/dist/server/routes/workspaces.js +209 -81
- package/dist/server/services/agent/engines/claude-code/args-builder.js +2 -0
- package/dist/server/services/agent/orchestrator.js +1 -1
- package/dist/server/services/auto-loop-service.js +29 -4
- package/dist/server/services/dev-server-service.js +2 -5
- package/dist/server/services/settings-service.js +55 -2
- package/dist/server/services/usage/db.js +29 -0
- package/dist/server/services/usage/index.js +2 -0
- package/dist/server/services/usage/poller.js +52 -0
- package/dist/server/services/usage/providers/claude-code.js +93 -0
- package/dist/server/services/usage/types.js +1 -0
- package/dist/server/services/wakeup-service.js +2 -2
- package/dist/server/services/websocket-service.js +14 -0
- package/dist/server/services/workspace-service.js +28 -3
- package/dist/server/services/worktree-service.js +50 -0
- package/dist/server/utils/mcp-client.js +7 -0
- package/dist/shared/auto-loop-prompts.js +58 -8
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-Be0QQryJ.css +1 -0
- package/src/client/dist/spa/assets/{ActivityFeed-6Xg7qNfy.js → ActivityFeed-BtIOkIy6.js} +3 -3
- package/src/client/dist/spa/assets/CreatePage-D6Q3nxkX.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-1s165rFm.css +1 -0
- package/src/client/dist/spa/assets/{DiffViewer-T111s7BH.js → DiffViewer-D5u9p7il.js} +2 -2
- package/src/client/dist/spa/assets/{HealthPage-1VakQ0x_.js → HealthPage-Cr7aAUy6.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-w7DoW3yz.js → MainLayout-C3TUaYvQ.js} +17 -17
- package/src/client/dist/spa/assets/MainLayout-CBnSwSfy.css +1 -0
- package/src/client/dist/spa/assets/{SearchPage-CcldJX8i.js → SearchPage-CavRaij6.js} +1 -1
- package/src/client/dist/spa/assets/SearchPage-cVwt0DaQ.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-C13T1l_t.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BEqEuPrb.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +1 -0
- package/src/client/dist/spa/assets/{build-path-tree-DbuI5yRz.js → build-path-tree-BeAS10oa.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-DhpmJAZc.js → cssMode-wNaxOrgG.js} +1 -1
- package/src/client/dist/spa/assets/{documents-fVD9RJth.js → documents-Cw05r3zs.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DCvwHsju.js → editor.api-CcDntllS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-CRtPC0iL.js → editor.main-Chu4hc0J.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-BIra7NIw.js → expand-template-CcQus77v.js} +1 -1
- package/src/client/dist/spa/assets/expand-template-D2yUa54D.css +1 -0
- package/src/client/dist/spa/assets/{freemarker2-C9UOErQw.js → freemarker2-CO_b202E.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-DmZ2-ZcJ.js → handlebars-CJnTWNLs.js} +1 -1
- package/src/client/dist/spa/assets/{html-ButyxlXG.js → html-DeArYseI.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C-defy1b.js → htmlMode-BnNgEgdx.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CuT4b7ns.js +1 -0
- package/src/client/dist/spa/assets/index-CZA4BFN5.js +2 -0
- package/src/client/dist/spa/assets/{javascript-B6zVweIF.js → javascript-C0pxfNu4.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-CttMw-EY.js → jsonMode-ety87201.js} +1 -1
- package/src/client/dist/spa/assets/kobo-commands-Cpl4IFon.js +11 -0
- package/src/client/dist/spa/assets/{liquid-tGpdE1YW.js → liquid-kanevKvC.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-Cy5mpQoy.js → mdx-DkmtbRD7.js} +1 -1
- package/src/client/dist/spa/assets/{models-DdAQDnqk.js → models-CPFeBEQS.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-DtdkkTgR.js → monaco.contribution-DsZsua59.js} +2 -2
- package/src/client/dist/spa/assets/{python-hLOxMbm9.js → python-DrxH1xl7.js} +1 -1
- package/src/client/dist/spa/assets/{razor-tqHFRROa.js → razor-CU4khv8N.js} +1 -1
- package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-MJKgZYsJ.js → tsMode-CQ5yxoz_.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-CWTqB5lb.js → typescript-CSwKmP7l.js} +1 -1
- package/src/client/dist/spa/assets/{xml-ByDBLBVa.js → xml-9bnWANPJ.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BiTCWZ38.js → yaml-sUtDJGxo.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
- package/src/client/dist/spa/assets/ActivityFeed-BHDJ5lUn.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-BQu7mQjm.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-CrRGDs5V.js +0 -2
- package/src/client/dist/spa/assets/DiffViewer-BC81-2me.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-Ci-CETJi.css +0 -1
- package/src/client/dist/spa/assets/SearchPage-DWglAeQv.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CMyeaz63.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-Bw9xhTDR.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-_1mty_a4.css +0 -1
- package/src/client/dist/spa/assets/expand-template-hbnn7St6.css +0 -1
- package/src/client/dist/spa/assets/i18n-C8aJvuyS.js +0 -1
- package/src/client/dist/spa/assets/index-DAbX631s.js +0 -2
- package/src/client/dist/spa/assets/kobo-commands-CD7ERFxp.js +0 -10
- package/src/client/dist/spa/assets/rate-limit-labels-BaD9dQtl.js +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
1
|
+
import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execFileAsync = promisify(execFileCb);
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
-
import {
|
|
7
|
+
import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, PREP_AUTOLOOP_INTRO, } from '../../shared/auto-loop-prompts.js';
|
|
8
8
|
import { getDb } from '../db/index.js';
|
|
9
9
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
10
10
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
@@ -41,8 +41,13 @@ app.get('/', (c) => {
|
|
|
41
41
|
app.post('/', migrationGuard, async (c) => {
|
|
42
42
|
try {
|
|
43
43
|
const body = await c.req.json();
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// workingBranch is derived from git when worktreePath is provided, so
|
|
45
|
+
// it's not required in that flow. The other 3 fields stay mandatory.
|
|
46
|
+
if (!body.name || !body.projectPath || !body.sourceBranch) {
|
|
47
|
+
return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch' }, 400);
|
|
48
|
+
}
|
|
49
|
+
if (!body.worktreePath && !body.workingBranch) {
|
|
50
|
+
return c.json({ error: 'Missing required field: workingBranch' }, 400);
|
|
46
51
|
}
|
|
47
52
|
// Validate the engine id (if provided) against the registry. An unknown
|
|
48
53
|
// engine is rejected up-front so we don't create orphan workspaces that
|
|
@@ -62,6 +67,45 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
62
67
|
const message = err instanceof Error ? err.message : String(err);
|
|
63
68
|
return c.json({ error: message }, 422);
|
|
64
69
|
}
|
|
70
|
+
// Reuse-existing-worktree path. When the caller passes `worktreePath`,
|
|
71
|
+
// Kobo "attaches" to a pre-existing worktree on disk instead of creating
|
|
72
|
+
// a new one. We validate four invariants up-front (path exists, belongs
|
|
73
|
+
// to this repo, is on a real branch, isn't already attached) and derive
|
|
74
|
+
// the working branch from git itself — the body.workingBranch is ignored.
|
|
75
|
+
let useReusedWorktree = false;
|
|
76
|
+
let reusedDerivedBranch = null;
|
|
77
|
+
if (body.worktreePath) {
|
|
78
|
+
if (!fs.existsSync(body.worktreePath)) {
|
|
79
|
+
return c.json({ error: `Worktree path does not exist: ${body.worktreePath}` }, 422);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
|
|
83
|
+
encoding: 'utf-8',
|
|
84
|
+
}).trim();
|
|
85
|
+
const expectedCommonDir = path.join(body.projectPath, '.git');
|
|
86
|
+
if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
|
|
87
|
+
return c.json({ error: `Worktree '${body.worktreePath}' belongs to a different repository` }, 422);
|
|
88
|
+
}
|
|
89
|
+
const branch = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
}).trim();
|
|
92
|
+
if (!branch || branch === 'HEAD') {
|
|
93
|
+
return c.json({ error: 'Worktree is in detached HEAD state and cannot be attached' }, 422);
|
|
94
|
+
}
|
|
95
|
+
reusedDerivedBranch = branch;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
return c.json({ error: `Failed to inspect worktree: ${message}` }, 422);
|
|
100
|
+
}
|
|
101
|
+
// Validate the worktree isn't already attached to another workspace.
|
|
102
|
+
const dbForCheck = getDb();
|
|
103
|
+
const existing = dbForCheck.prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
|
|
104
|
+
if (existing) {
|
|
105
|
+
return c.json({ error: 'This worktree is already attached to another Kōbō workspace' }, 422);
|
|
106
|
+
}
|
|
107
|
+
useReusedWorktree = true;
|
|
108
|
+
}
|
|
65
109
|
// Pre-flight: extract Notion / Sentry before any DB write. A throw here
|
|
66
110
|
// must not leave a half-built workspace behind, so we run extraction
|
|
67
111
|
// before createWorkspace and surface failures as 422.
|
|
@@ -87,8 +131,36 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
87
131
|
}
|
|
88
132
|
// Create workspace record
|
|
89
133
|
const globalSettings = settingsService.getGlobalSettings();
|
|
90
|
-
// workingBranch may be updated after Notion extraction to inject the ticket ID
|
|
91
|
-
|
|
134
|
+
// workingBranch may be updated after Notion extraction to inject the ticket ID,
|
|
135
|
+
// OR overridden by the branch derived from the existing worktree (reuse mode).
|
|
136
|
+
let workingBranch = useReusedWorktree && reusedDerivedBranch ? reusedDerivedBranch : body.workingBranch;
|
|
137
|
+
// Inject ticket ID into the working branch BEFORE creating the workspace,
|
|
138
|
+
// so the worktree_path recorded in the DB reflects the FINAL branch name.
|
|
139
|
+
// Works with or without Notion: ticket ID comes from Notion extraction first,
|
|
140
|
+
// then Sentry, then falls back to a TK-XXXX pattern anywhere in the body.name.
|
|
141
|
+
// Skip when reusing an existing worktree — its branch is already real on disk
|
|
142
|
+
// and we MUST NOT rename it.
|
|
143
|
+
if (!useReusedWorktree) {
|
|
144
|
+
// Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
|
|
145
|
+
// which is what Sentry auto-close recognises in commit messages.
|
|
146
|
+
const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || body.name.match(/[A-Z]+-\d+/i)?.[0];
|
|
147
|
+
if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
|
|
148
|
+
const ticketPrefix = detectedTicketId.toUpperCase();
|
|
149
|
+
const slashIdx = workingBranch.indexOf('/');
|
|
150
|
+
const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
|
|
151
|
+
// Use Notion/Sentry title or body name for the slug — all have proper accented
|
|
152
|
+
// characters that NFD normalization can transliterate (é→e, ç→c, etc.)
|
|
153
|
+
const titleSource = notionContent?.title || sentryContent?.title || body.name;
|
|
154
|
+
const titleSlug = titleSource
|
|
155
|
+
.normalize('NFD')
|
|
156
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
159
|
+
.replace(/^-|-$/g, '')
|
|
160
|
+
.substring(0, 50);
|
|
161
|
+
workingBranch = `${typePrefix}${ticketPrefix}--${titleSlug}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
92
164
|
let workspace = workspaceService.createWorkspace({
|
|
93
165
|
name: body.name,
|
|
94
166
|
projectPath: body.projectPath,
|
|
@@ -97,6 +169,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
97
169
|
notionUrl: body.notionUrl,
|
|
98
170
|
notionPageId: body.notionPageId,
|
|
99
171
|
sentryUrl: body.sentryUrl,
|
|
172
|
+
...(useReusedWorktree ? { worktreePath: body.worktreePath, worktreeOwned: false } : {}),
|
|
100
173
|
model: body.model,
|
|
101
174
|
reasoningEffort: body.reasoningEffort,
|
|
102
175
|
permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
|
|
@@ -176,41 +249,21 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
176
249
|
}
|
|
177
250
|
}
|
|
178
251
|
}
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// then falls back to a TK-XXXX pattern anywhere in the workspace name.
|
|
182
|
-
// The worktree has not been created yet, so a DB update is sufficient.
|
|
183
|
-
{
|
|
184
|
-
// Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
|
|
185
|
-
// which is what Sentry auto-close recognises in commit messages.
|
|
186
|
-
const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
|
|
187
|
-
if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
|
|
188
|
-
const ticketPrefix = detectedTicketId.toUpperCase();
|
|
189
|
-
const slashIdx = workingBranch.indexOf('/');
|
|
190
|
-
const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
|
|
191
|
-
// Use Notion/Sentry title or workspace name for the slug — all have proper accented
|
|
192
|
-
// characters that NFD normalization can transliterate (é→e, ç→c, etc.)
|
|
193
|
-
const titleSource = notionContent?.title || sentryContent?.title || workspace.name;
|
|
194
|
-
const titleSlug = titleSource
|
|
195
|
-
.normalize('NFD')
|
|
196
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
197
|
-
.toLowerCase()
|
|
198
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
199
|
-
.replace(/^-|-$/g, '')
|
|
200
|
-
.substring(0, 50);
|
|
201
|
-
workingBranch = `${typePrefix}${ticketPrefix}--${titleSlug}`;
|
|
202
|
-
workspace = workspaceService.updateWorkingBranch(workspace.id, workingBranch);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// Create git worktree for the working branch
|
|
252
|
+
// Create git worktree for the working branch — unless we're reusing an
|
|
253
|
+
// existing one, in which case the path is taken straight from the body.
|
|
206
254
|
let worktreePath;
|
|
207
|
-
|
|
208
|
-
worktreePath =
|
|
255
|
+
if (useReusedWorktree) {
|
|
256
|
+
worktreePath = body.worktreePath;
|
|
209
257
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
258
|
+
else {
|
|
259
|
+
try {
|
|
260
|
+
worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
264
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
265
|
+
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
266
|
+
}
|
|
214
267
|
}
|
|
215
268
|
// Ensure Kobo-generated files are gitignored. Check both the root
|
|
216
269
|
// .gitignore and .ai/.gitignore to avoid duplicate entries.
|
|
@@ -259,7 +312,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
259
312
|
}
|
|
260
313
|
// Run setup script if configured and not skipped
|
|
261
314
|
let setupScriptFailed = false;
|
|
262
|
-
|
|
315
|
+
// Skip the setup script when reusing an existing worktree — the user
|
|
316
|
+
// already has the environment set up there and rerunning it could be
|
|
317
|
+
// destructive (drop a node_modules they curated, etc.).
|
|
318
|
+
if (effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree) {
|
|
263
319
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
264
320
|
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
265
321
|
try {
|
|
@@ -459,12 +515,21 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
459
515
|
// NOT with implementation. The auto-loop will drive implementation after.
|
|
460
516
|
// The grooming steps + hard rules are shared with the PREP_AUTOLOOP_PROMPT
|
|
461
517
|
// sent by the "Prepare for auto-loop" button (src/shared/auto-loop-prompts.ts).
|
|
518
|
+
// Read per-project E2E settings so the grooming steps can include the
|
|
519
|
+
// E2E review pass when configured. We deliberately use
|
|
520
|
+
// `getProjectSettings` (NOT `getEffectiveSettings`) here because only
|
|
521
|
+
// project-level settings carry the `e2e` shape; if the project hasn't
|
|
522
|
+
// been registered yet, the empty default below is correct.
|
|
523
|
+
const projectSettingsForE2e = settingsService.getProjectSettings(body.projectPath);
|
|
524
|
+
const e2eSettings = projectSettingsForE2e?.e2e ?? { framework: '', skill: '', prompt: '' };
|
|
525
|
+
const finalizationSettings = projectSettingsForE2e?.finalization ?? { prompt: '' };
|
|
462
526
|
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you have a clear plan, create a plan file.
|
|
463
527
|
|
|
464
528
|
Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
|
|
465
529
|
|
|
466
|
-
${
|
|
467
|
-
|
|
530
|
+
${buildAutoLoopGroomingSteps(e2eSettings, finalizationSettings)}
|
|
531
|
+
|
|
532
|
+
When the steps above are complete, output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
|
|
468
533
|
|
|
469
534
|
${AUTO_LOOP_HARD_RULES}`;
|
|
470
535
|
}
|
|
@@ -908,6 +973,30 @@ app.get('/archived', (c) => {
|
|
|
908
973
|
return c.json({ error: message }, 500);
|
|
909
974
|
}
|
|
910
975
|
});
|
|
976
|
+
// GET /:id/prep-autoloop-prompt — compose the project-aware grooming
|
|
977
|
+
// prompt. Used by the "Prepare for auto-loop" button. Place BEFORE
|
|
978
|
+
// `app.get('/:id', ...)` so the more-specific path wins.
|
|
979
|
+
app.get('/:id/prep-autoloop-prompt', (c) => {
|
|
980
|
+
try {
|
|
981
|
+
const id = c.req.param('id');
|
|
982
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
983
|
+
if (!workspace)
|
|
984
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
985
|
+
const projectSettings = settingsService.getProjectSettings(workspace.projectPath);
|
|
986
|
+
const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
|
|
987
|
+
const finalizationSettings = projectSettings?.finalization ?? { prompt: '' };
|
|
988
|
+
const prompt = `${PREP_AUTOLOOP_INTRO}
|
|
989
|
+
|
|
990
|
+
${buildAutoLoopGroomingSteps(e2eSettings, finalizationSettings)}
|
|
991
|
+
|
|
992
|
+
${AUTO_LOOP_HARD_RULES}`;
|
|
993
|
+
return c.json({ prompt });
|
|
994
|
+
}
|
|
995
|
+
catch (err) {
|
|
996
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
997
|
+
return c.json({ error: message }, 500);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
911
1000
|
// GET /api/workspaces/:id — get workspace details with tasks
|
|
912
1001
|
app.get('/:id', (c) => {
|
|
913
1002
|
try {
|
|
@@ -1038,7 +1127,7 @@ app.post('/:id/open-editor', (c) => {
|
|
|
1038
1127
|
if (!globalSettings.editorCommand) {
|
|
1039
1128
|
return c.json({ error: 'No editor command configured' }, 400);
|
|
1040
1129
|
}
|
|
1041
|
-
const worktreePath =
|
|
1130
|
+
const worktreePath = workspace.worktreePath;
|
|
1042
1131
|
if (!fs.existsSync(worktreePath)) {
|
|
1043
1132
|
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
1044
1133
|
}
|
|
@@ -1046,6 +1135,15 @@ app.post('/:id/open-editor', (c) => {
|
|
|
1046
1135
|
detached: true,
|
|
1047
1136
|
stdio: 'ignore',
|
|
1048
1137
|
});
|
|
1138
|
+
// spawn errors fire async on the ChildProcess (ENOENT etc.) — without a
|
|
1139
|
+
// handler the unhandled 'error' event crashes the whole Node process.
|
|
1140
|
+
child.on('error', (err) => {
|
|
1141
|
+
console.error(`[open-editor] spawn '${globalSettings.editorCommand}' failed:`, err.message);
|
|
1142
|
+
wsService.emitEphemeral(workspace.id, 'editor:open-failed', {
|
|
1143
|
+
command: globalSettings.editorCommand,
|
|
1144
|
+
message: err.message,
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1049
1147
|
child.unref();
|
|
1050
1148
|
return c.json({ success: true });
|
|
1051
1149
|
}
|
|
@@ -1077,7 +1175,7 @@ app.post('/:id/run-setup-script', async (c) => {
|
|
|
1077
1175
|
if (!effectiveSettings.setupScript) {
|
|
1078
1176
|
return c.json({ error: 'No setup script configured' }, 400);
|
|
1079
1177
|
}
|
|
1080
|
-
const worktreePath =
|
|
1178
|
+
const worktreePath = workspace.worktreePath;
|
|
1081
1179
|
if (!fs.existsSync(worktreePath)) {
|
|
1082
1180
|
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
1083
1181
|
}
|
|
@@ -1194,20 +1292,25 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
1194
1292
|
// Docker leaves root-owned files inside the worktree, git worktree
|
|
1195
1293
|
// remove fails with EACCES.
|
|
1196
1294
|
const warnings = [];
|
|
1197
|
-
// Remove worktree
|
|
1198
|
-
|
|
1199
|
-
const worktreePath =
|
|
1200
|
-
|
|
1201
|
-
|
|
1295
|
+
// Remove worktree (only if owned — for attached external worktrees we
|
|
1296
|
+
// never created the dir, so we must not delete it on the user's behalf).
|
|
1297
|
+
const worktreePath = workspace.worktreePath;
|
|
1298
|
+
if (workspace.worktreeOwned) {
|
|
1299
|
+
try {
|
|
1300
|
+
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
1301
|
+
}
|
|
1302
|
+
catch (err) {
|
|
1303
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1304
|
+
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
1305
|
+
warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
|
|
1306
|
+
`Fix manually:\n` +
|
|
1307
|
+
` sudo rm -rf '${worktreePath}'\n` +
|
|
1308
|
+
` cd '${workspace.projectPath}' && git worktree prune\n` +
|
|
1309
|
+
`Reason: ${message}`);
|
|
1310
|
+
}
|
|
1202
1311
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
1206
|
-
warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
|
|
1207
|
-
`Fix manually:\n` +
|
|
1208
|
-
` sudo rm -rf '${worktreePath}'\n` +
|
|
1209
|
-
` cd '${workspace.projectPath}' && git worktree prune\n` +
|
|
1210
|
-
`Reason: ${message}`);
|
|
1312
|
+
else {
|
|
1313
|
+
console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
|
|
1211
1314
|
}
|
|
1212
1315
|
// Delete local branch if requested
|
|
1213
1316
|
if (body.deleteLocalBranch) {
|
|
@@ -1283,7 +1386,7 @@ app.post('/:id/start', migrationGuard, async (c) => {
|
|
|
1283
1386
|
catch {
|
|
1284
1387
|
// Agent may not be running — ignore
|
|
1285
1388
|
}
|
|
1286
|
-
const worktreePath =
|
|
1389
|
+
const worktreePath = workspace.worktreePath;
|
|
1287
1390
|
const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId, workspace.reasoningEffort);
|
|
1288
1391
|
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
1289
1392
|
// Persist the user prompt so it survives page refresh.
|
|
@@ -1307,7 +1410,7 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
1307
1410
|
if (!workspace) {
|
|
1308
1411
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1309
1412
|
}
|
|
1310
|
-
const worktreePath =
|
|
1413
|
+
const worktreePath = workspace.worktreePath;
|
|
1311
1414
|
const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
1312
1415
|
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
1313
1416
|
const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
|
|
@@ -1342,7 +1445,7 @@ app.get('/:id/diff', (c) => {
|
|
|
1342
1445
|
if (!workspace) {
|
|
1343
1446
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1344
1447
|
}
|
|
1345
|
-
const worktreePath =
|
|
1448
|
+
const worktreePath = workspace.worktreePath;
|
|
1346
1449
|
const files = mode === 'unpushed'
|
|
1347
1450
|
? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
|
|
1348
1451
|
: gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
|
|
@@ -1376,7 +1479,7 @@ app.get('/:id/diff-file', (c) => {
|
|
|
1376
1479
|
if (!workspace) {
|
|
1377
1480
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1378
1481
|
}
|
|
1379
|
-
const worktreePath =
|
|
1482
|
+
const worktreePath = workspace.worktreePath;
|
|
1380
1483
|
const baseRef = mode === 'unpushed' ? `origin/${workspace.workingBranch}` : workspace.sourceBranch;
|
|
1381
1484
|
const original = gitOps.getFileAtRef(worktreePath, baseRef, filePath);
|
|
1382
1485
|
const modified = gitOps.getFileContent(worktreePath, filePath);
|
|
@@ -1399,7 +1502,7 @@ app.get('/:id/commits', (c) => {
|
|
|
1399
1502
|
}
|
|
1400
1503
|
const limitRaw = c.req.query('limit');
|
|
1401
1504
|
const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
|
|
1402
|
-
const worktreePath =
|
|
1505
|
+
const worktreePath = workspace.worktreePath;
|
|
1403
1506
|
const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
|
|
1404
1507
|
c.header('Cache-Control', 'no-store');
|
|
1405
1508
|
return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
|
|
@@ -1429,11 +1532,23 @@ app.post('/:id/rename-branch', async (c) => {
|
|
|
1429
1532
|
if (!workspace) {
|
|
1430
1533
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1431
1534
|
}
|
|
1535
|
+
if (!workspace.worktreeOwned) {
|
|
1536
|
+
return c.json({
|
|
1537
|
+
error: 'Rename is not available for attached external worktrees. Manage the branch name with git directly.',
|
|
1538
|
+
}, 400);
|
|
1539
|
+
}
|
|
1432
1540
|
if (newName === workspace.workingBranch) {
|
|
1433
1541
|
return c.json(workspace); // no-op
|
|
1434
1542
|
}
|
|
1435
|
-
const oldWorktreePath =
|
|
1436
|
-
|
|
1543
|
+
const oldWorktreePath = workspace.worktreePath;
|
|
1544
|
+
// Sibling rename: keep the same worktrees-root, swap the branch leaf.
|
|
1545
|
+
// Cannot use `path.dirname` directly because branches with slashes
|
|
1546
|
+
// (e.g. `feature/x`) make the dirname end one level too deep.
|
|
1547
|
+
const oldSuffix = `/${workspace.workingBranch}`;
|
|
1548
|
+
const worktreesRoot = oldWorktreePath.endsWith(oldSuffix)
|
|
1549
|
+
? oldWorktreePath.slice(0, -oldSuffix.length)
|
|
1550
|
+
: path.join(workspace.projectPath, '.worktrees');
|
|
1551
|
+
const newWorktreePath = path.join(worktreesRoot, newName);
|
|
1437
1552
|
// Reject early if the target name is already in use — either as a local
|
|
1438
1553
|
// branch or on origin. Avoids git's generic "already exists" error and
|
|
1439
1554
|
// protects against the same silent-fallback trap the create flow has.
|
|
@@ -1453,9 +1568,11 @@ app.post('/:id/rename-branch', async (c) => {
|
|
|
1453
1568
|
// not the dir, for git operations.
|
|
1454
1569
|
try {
|
|
1455
1570
|
gitOps.moveWorktree(workspace.projectPath, oldWorktreePath, newWorktreePath);
|
|
1571
|
+
workspaceService.updateWorktreePath(id, newWorktreePath);
|
|
1456
1572
|
}
|
|
1457
1573
|
catch (err) {
|
|
1458
1574
|
console.error('[workspaces] Failed to move worktree dir (branch renamed anyway):', err);
|
|
1575
|
+
// worktree_path stays at oldWorktreePath, which still exists on disk
|
|
1459
1576
|
}
|
|
1460
1577
|
const updated = workspaceService.updateWorkingBranch(id, newName);
|
|
1461
1578
|
return c.json(updated);
|
|
@@ -1476,7 +1593,12 @@ app.post('/:id/resync-branch', (c) => {
|
|
|
1476
1593
|
if (!workspace) {
|
|
1477
1594
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1478
1595
|
}
|
|
1479
|
-
|
|
1596
|
+
if (!workspace.worktreeOwned) {
|
|
1597
|
+
return c.json({
|
|
1598
|
+
error: 'Resync-branch is not available for attached external worktrees.',
|
|
1599
|
+
}, 400);
|
|
1600
|
+
}
|
|
1601
|
+
const worktreePath = workspace.worktreePath;
|
|
1480
1602
|
let actual;
|
|
1481
1603
|
try {
|
|
1482
1604
|
actual = gitOps.getCurrentBranch(worktreePath).trim();
|
|
@@ -1490,18 +1612,24 @@ app.post('/:id/resync-branch', (c) => {
|
|
|
1490
1612
|
return c.json({ ok: true, changed: false, workingBranch: workspace.workingBranch });
|
|
1491
1613
|
}
|
|
1492
1614
|
// Branch was renamed in-place by the agent (`git branch -m ...`). The
|
|
1493
|
-
// worktree directory is still at
|
|
1494
|
-
// matches the new ref, otherwise Kōbō's path resolver
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
const
|
|
1615
|
+
// worktree directory is still at <worktrees-root>/<old-name>; move it so it
|
|
1616
|
+
// matches the new ref, otherwise Kōbō's path resolver breaks and
|
|
1617
|
+
// subsequent session spawns fail with ENOENT on .mcp.json. Best-effort:
|
|
1618
|
+
// if the move fails (dir already moved, lockfile, dirty tree), we still
|
|
1619
|
+
// update the DB so git ops stay aligned with the current ref name — the
|
|
1620
|
+
// user can repair the dir manually.
|
|
1621
|
+
const oldSuffix = `/${workspace.workingBranch}`;
|
|
1622
|
+
const worktreesRoot = worktreePath.endsWith(oldSuffix)
|
|
1623
|
+
? worktreePath.slice(0, -oldSuffix.length)
|
|
1624
|
+
: path.join(workspace.projectPath, '.worktrees');
|
|
1625
|
+
const newWorktreePath = path.join(worktreesRoot, actual);
|
|
1500
1626
|
try {
|
|
1501
1627
|
gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
|
|
1628
|
+
workspaceService.updateWorktreePath(id, newWorktreePath);
|
|
1502
1629
|
}
|
|
1503
1630
|
catch (err) {
|
|
1504
1631
|
console.error('[workspaces] resync-branch: moveWorktree failed (DB update proceeds):', err);
|
|
1632
|
+
// worktree_path stays at the old path; DB update for working branch still proceeds
|
|
1505
1633
|
}
|
|
1506
1634
|
const updated = workspaceService.updateWorkingBranch(id, actual);
|
|
1507
1635
|
return c.json({ ok: true, changed: true, workingBranch: updated.workingBranch });
|
|
@@ -1521,7 +1649,7 @@ app.post('/:id/push', async (c) => {
|
|
|
1521
1649
|
}
|
|
1522
1650
|
const body = await c.req.json().catch(() => ({}));
|
|
1523
1651
|
const force = body?.force === true;
|
|
1524
|
-
const worktreePath =
|
|
1652
|
+
const worktreePath = workspace.worktreePath;
|
|
1525
1653
|
try {
|
|
1526
1654
|
// Only pass an options arg when force is requested — keeps the
|
|
1527
1655
|
// no-options call shape identical to before for callers/tests that
|
|
@@ -1555,7 +1683,7 @@ app.post('/:id/pull', (c) => {
|
|
|
1555
1683
|
if (!workspace) {
|
|
1556
1684
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1557
1685
|
}
|
|
1558
|
-
const worktreePath =
|
|
1686
|
+
const worktreePath = workspace.worktreePath;
|
|
1559
1687
|
try {
|
|
1560
1688
|
gitOps.pullBranch(worktreePath, workspace.workingBranch);
|
|
1561
1689
|
}
|
|
@@ -1580,7 +1708,7 @@ app.post('/:id/rebase', (c) => {
|
|
|
1580
1708
|
const workspace = workspaceService.getWorkspace(id);
|
|
1581
1709
|
if (!workspace)
|
|
1582
1710
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1583
|
-
const worktreePath =
|
|
1711
|
+
const worktreePath = workspace.worktreePath;
|
|
1584
1712
|
gitOps.rebaseBranch(worktreePath, workspace.sourceBranch);
|
|
1585
1713
|
return c.json({ success: true });
|
|
1586
1714
|
}
|
|
@@ -1599,7 +1727,7 @@ app.post('/:id/merge', (c) => {
|
|
|
1599
1727
|
const workspace = workspaceService.getWorkspace(id);
|
|
1600
1728
|
if (!workspace)
|
|
1601
1729
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1602
|
-
const worktreePath =
|
|
1730
|
+
const worktreePath = workspace.worktreePath;
|
|
1603
1731
|
gitOps.mergeBranch(worktreePath, workspace.sourceBranch);
|
|
1604
1732
|
return c.json({ success: true });
|
|
1605
1733
|
}
|
|
@@ -1618,7 +1746,7 @@ app.post('/:id/git/abort', (c) => {
|
|
|
1618
1746
|
const workspace = workspaceService.getWorkspace(id);
|
|
1619
1747
|
if (!workspace)
|
|
1620
1748
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1621
|
-
const worktreePath =
|
|
1749
|
+
const worktreePath = workspace.worktreePath;
|
|
1622
1750
|
const aborted = gitOps.abortOngoingGitOperation(worktreePath);
|
|
1623
1751
|
return c.json({ success: true, aborted });
|
|
1624
1752
|
}
|
|
@@ -1635,7 +1763,7 @@ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
|
|
|
1635
1763
|
if (!workspace)
|
|
1636
1764
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1637
1765
|
const body = (await c.req.json().catch(() => ({})));
|
|
1638
|
-
const worktreePath =
|
|
1766
|
+
const worktreePath = workspace.worktreePath;
|
|
1639
1767
|
const operation = body.operation ?? gitOps.getOngoingGitOperation(worktreePath) ?? 'merge';
|
|
1640
1768
|
const files = body.files && body.files.length > 0 ? body.files : gitOps.getConflictedFiles(worktreePath);
|
|
1641
1769
|
if (files.length === 0) {
|
|
@@ -1706,7 +1834,7 @@ app.post('/:id/change-pr-base', async (c) => {
|
|
|
1706
1834
|
const workspace = workspaceService.getWorkspace(id);
|
|
1707
1835
|
if (!workspace)
|
|
1708
1836
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1709
|
-
const worktreePath =
|
|
1837
|
+
const worktreePath = workspace.worktreePath;
|
|
1710
1838
|
await execFileAsync('gh', ['pr', 'edit', '--base', body.base], { cwd: worktreePath });
|
|
1711
1839
|
return c.json({ success: true });
|
|
1712
1840
|
}
|
|
@@ -1723,7 +1851,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
1723
1851
|
if (!workspace) {
|
|
1724
1852
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1725
1853
|
}
|
|
1726
|
-
const worktreePath =
|
|
1854
|
+
const worktreePath = workspace.worktreePath;
|
|
1727
1855
|
// Verify branch exists on remote
|
|
1728
1856
|
let lsRemoteOut = '';
|
|
1729
1857
|
try {
|
|
@@ -1818,7 +1946,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
1818
1946
|
catch {
|
|
1819
1947
|
// Agent not running — resume it with the PR prompt
|
|
1820
1948
|
try {
|
|
1821
|
-
const worktreePathForResume =
|
|
1949
|
+
const worktreePathForResume = workspace.worktreePath;
|
|
1822
1950
|
agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
|
|
1823
1951
|
workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
|
|
1824
1952
|
messageSent = true;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const KOBO_MCP_BRIEF = [
|
|
7
7
|
'[Kōbō MCP] This workspace exposes a dedicated MCP server with tools prefixed `kobo__`.',
|
|
8
|
+
"Non-interactive mode: this session runs via `claude -p`. Tools requiring a synchronous human reply (e.g. `AskUserQuestion`) won't complete — never call them. If you need user input, end the turn with a plain-text question; the user replies asynchronously via the chat UI.",
|
|
9
|
+
'Plan mode: when running with `--permission-mode plan`, the read-only restriction applies to MCP tools too, not just built-ins. For Kōbō: `kobo__list_*`, `kobo__read_document`, `kobo__search_codebase`, `kobo__get_*` are fine; `kobo__mark_task_done`, `kobo__log_thought`, `kobo__set_workspace_status` are mutations and must wait until the plan is approved.',
|
|
8
10
|
'Conventions — read these BEFORE starting work, not as a fallback:',
|
|
9
11
|
'• `kobo__list_tasks` first on any non-trivial turn, then `kobo__mark_task_done` as each item completes.',
|
|
10
12
|
'• `kobo__list_documents` / `kobo__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
|
|
@@ -704,7 +704,7 @@ function handleQuota(workspaceId, _agentSessionId) {
|
|
|
704
704
|
autoLoopService.onQuotaBackoffExpired(workspaceId);
|
|
705
705
|
}
|
|
706
706
|
else {
|
|
707
|
-
const freshWorkingDir =
|
|
707
|
+
const freshWorkingDir = freshWs.worktreePath;
|
|
708
708
|
startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
709
709
|
}
|
|
710
710
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
|
|
3
4
|
import { getDb } from '../db/index.js';
|
|
4
5
|
import * as orchestrator from './agent/orchestrator.js';
|
|
6
|
+
import * as settingsService from './settings-service.js';
|
|
5
7
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
6
8
|
import { listTasks } from './workspace-service.js';
|
|
7
9
|
const NO_PROGRESS_STALL_THRESHOLD = 3;
|
|
8
10
|
function getRow(workspaceId) {
|
|
9
11
|
const db = getDb();
|
|
10
12
|
const row = db
|
|
11
|
-
.prepare(`SELECT id, project_path, working_branch, model, permission_mode, reasoning_effort,
|
|
13
|
+
.prepare(`SELECT id, project_path, working_branch, worktree_path, model, permission_mode, reasoning_effort,
|
|
12
14
|
status, auto_loop, auto_loop_ready, no_progress_streak, archived_at
|
|
13
15
|
FROM workspaces WHERE id = ?`)
|
|
14
16
|
.get(workspaceId);
|
|
@@ -137,6 +139,13 @@ export function rehydrate() {
|
|
|
137
139
|
try {
|
|
138
140
|
if (orchestrator.hasController(id))
|
|
139
141
|
continue;
|
|
142
|
+
// Workspaces still in grooming (ready=0) have their session killed by
|
|
143
|
+
// the server reload. Don't disable — the user can re-trigger grooming
|
|
144
|
+
// manually. Auto-disable on missing pending tasks would also fire here
|
|
145
|
+
// if the agent hadn't yet seeded any task before the reload.
|
|
146
|
+
const row = getRow(id);
|
|
147
|
+
if (row?.auto_loop_ready !== 1)
|
|
148
|
+
continue;
|
|
140
149
|
if (countPendingTasks(id) === 0) {
|
|
141
150
|
disable(id, 'completed');
|
|
142
151
|
continue;
|
|
@@ -163,7 +172,7 @@ Current pending task (highest priority, non-acceptance-criterion first):
|
|
|
163
172
|
- Task ID: {taskId}
|
|
164
173
|
- Title: {taskTitle}
|
|
165
174
|
- Is acceptance criterion: {isAcceptanceCriterion}
|
|
166
|
-
|
|
175
|
+
{overrideBlock}
|
|
167
176
|
Your job this iteration:
|
|
168
177
|
1. Read \`kobo__list_tasks\` to see all tasks and the big picture.
|
|
169
178
|
2. Implement the SINGLE task above and nothing else. Do not pick a different task.
|
|
@@ -216,11 +225,27 @@ function spawnNextIteration(workspaceId, opts = {}) {
|
|
|
216
225
|
return;
|
|
217
226
|
}
|
|
218
227
|
const iterationNumber = computeIterationNumber(workspaceId);
|
|
228
|
+
// Override block: replaces the standard iteration prompt body when the task
|
|
229
|
+
// title carries a recognized prefix (case-sensitive, trailing space required).
|
|
230
|
+
// Empty string otherwise so the placeholder collapses cleanly in PROMPT_TEMPLATE.
|
|
231
|
+
// A title cannot literally start with both prefixes, so the order of these
|
|
232
|
+
// branches is purely cosmetic.
|
|
233
|
+
const projectSettings = settingsService.getProjectSettings(row.project_path);
|
|
234
|
+
const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
|
|
235
|
+
const finalizationSettings = projectSettings?.finalization ?? { prompt: '' };
|
|
236
|
+
let overrideBlock = '';
|
|
237
|
+
if (task.title.startsWith('[FINAL] ')) {
|
|
238
|
+
overrideBlock = buildFinalizationIterationBlock(finalizationSettings);
|
|
239
|
+
}
|
|
240
|
+
else if (task.title.startsWith('[E2E] ') && e2eSettings.framework) {
|
|
241
|
+
overrideBlock = buildE2eIterationBlock(e2eSettings);
|
|
242
|
+
}
|
|
219
243
|
const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
|
|
220
244
|
.replaceAll('{taskId}', task.id)
|
|
221
245
|
.replaceAll('{taskTitle}', task.title)
|
|
222
|
-
.replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
|
|
223
|
-
|
|
246
|
+
.replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
|
|
247
|
+
.replaceAll('{overrideBlock}', overrideBlock);
|
|
248
|
+
const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
|
|
224
249
|
// Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
|
|
225
250
|
// tools (kobo__mark_task_done, etc.) and Edit/Write/Bash — everything the
|
|
226
251
|
// iteration needs — so honoring a 'plan' setting here would deadlock the loop.
|
|
@@ -5,9 +5,6 @@ import { getProjectSettings } from './settings-service.js';
|
|
|
5
5
|
import { emitEphemeral } from './websocket-service.js';
|
|
6
6
|
import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
|
|
7
7
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
8
|
-
function getWorktreePath(projectPath, workingBranch) {
|
|
9
|
-
return path.join(projectPath, '.worktrees', workingBranch);
|
|
10
|
-
}
|
|
11
8
|
/** Build a clean env for child processes, stripping Kobo-specific variables. */
|
|
12
9
|
function cleanEnv() {
|
|
13
10
|
const { PORT, SERVER_PORT, ...rest } = process.env;
|
|
@@ -166,7 +163,7 @@ export function startDevServer(workspaceId) {
|
|
|
166
163
|
}
|
|
167
164
|
const instanceName = sanitizeBranchName(workspace.workingBranch);
|
|
168
165
|
// Execute as bash script (supports multi-line scripts)
|
|
169
|
-
const worktreePath =
|
|
166
|
+
const worktreePath = workspace.worktreePath;
|
|
170
167
|
const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
171
168
|
const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
|
|
172
169
|
cwd,
|
|
@@ -232,7 +229,7 @@ export function stopDevServer(workspaceId) {
|
|
|
232
229
|
}
|
|
233
230
|
const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
|
|
234
231
|
const instanceName = config?.instanceName ?? sanitizeBranchName(workspace.workingBranch);
|
|
235
|
-
const worktreePath =
|
|
232
|
+
const worktreePath = workspace.worktreePath;
|
|
236
233
|
const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
237
234
|
// Kill tracked process first (covers Node servers and any spawned process)
|
|
238
235
|
const tracked = trackedProcesses.get(workspaceId);
|