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