@shawnstack/quickforge 1.7.12 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/AgentProfilesPage-lKGIE5Dj.js +1 -0
  3. package/dist/assets/ChatPanelHost-CWUYIkXG.js +11 -0
  4. package/dist/assets/CloudAccountSettingsPage-BhXH0ups.js +1 -0
  5. package/dist/assets/{PluginsPage-BIbIDfWY.js → PluginsPage-DHWF13mN.js} +1 -1
  6. package/dist/assets/{ScheduledTasksPage-BOqmBNCx.js → ScheduledTasksPage-DLAg-i8c.js} +2 -2
  7. package/dist/assets/{SettingsWorkspacePage-BCRtycRN.js → SettingsWorkspacePage-BhUzL5oi.js} +128 -128
  8. package/dist/assets/ShareLinksSettingsPage-DogGa3lH.js +1 -0
  9. package/dist/assets/SharedConversationPage-7QpeaG_P.js +1 -0
  10. package/dist/assets/{TerminalDock-Cqz_9mTC.js → TerminalDock-CWSKcuiU.js} +2 -2
  11. package/dist/assets/WorkspaceInspector-CEfyYqWy.js +36 -0
  12. package/dist/assets/{index-BWf6y3Kh.css → index-CX36XjVA.css} +1 -1
  13. package/dist/assets/index-CgVTwN15.js +66 -0
  14. package/dist/assets/local-tools-CJVNO1aW.js +388 -0
  15. package/dist/assets/{mcp-servers-dialog-7Xwu2RVW.js → mcp-servers-dialog-xf3UR49J.js} +2 -2
  16. package/dist/assets/{skills-dialog-C_JvWbAa.js → skills-dialog-Bbqtuy2h.js} +1 -1
  17. package/dist/index.html +4 -4
  18. package/dist/licenses/material-icon-theme.txt +8 -8
  19. package/package.json +1 -1
  20. package/server/agent-manager.mjs +175 -47
  21. package/server/context-references.mjs +139 -0
  22. package/server/custom-commands.mjs +43 -0
  23. package/server/index.mjs +1 -1
  24. package/server/project-config.mjs +17 -5
  25. package/server/routes/agent-profiles.mjs +16 -1
  26. package/server/routes/agent.mjs +1 -1
  27. package/server/routes/shared-conversation.mjs +13 -1
  28. package/server/routes/skills.mjs +22 -0
  29. package/server/routes/workspace.mjs +166 -2
  30. package/server/selected-capabilities.mjs +76 -0
  31. package/server/skills.mjs +1 -1
  32. package/server/system-prompt.mjs +1 -3
  33. package/server/tools/definitions.mjs +0 -10
  34. package/server/utils/workspace.mjs +13 -4
  35. package/skills/skill-creator/SKILL.md +485 -485
  36. package/skills/skill-creator/agents/analyzer.md +274 -274
  37. package/skills/skill-creator/agents/comparator.md +202 -202
  38. package/skills/skill-creator/agents/grader.md +223 -223
  39. package/skills/skill-creator/assets/eval_review.html +146 -146
  40. package/skills/skill-creator/eval-viewer/viewer.html +1325 -1325
  41. package/skills/skill-creator/references/schemas.md +430 -430
  42. package/dist/assets/AgentProfilesPage-CApCpsDd.js +0 -1
  43. package/dist/assets/ChatPanelHost-COTO7swS.js +0 -48
  44. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +0 -1
  45. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +0 -1
  46. package/dist/assets/SharedConversationPage-DJa0M0El.js +0 -1
  47. package/dist/assets/WorkspaceInspector-C-GOK81D.js +0 -36
  48. package/dist/assets/index-D3GWkXJR.js +0 -66
  49. package/dist/assets/local-tools-DY0yEU3T.js +0 -350
@@ -33,6 +33,12 @@ const builtinCommandCatalog = [
33
33
  argumentHint: '[scope]',
34
34
  permissionNote: 'no edits',
35
35
  },
36
+ {
37
+ name: 'commit',
38
+ description: 'Validate and commit the current task changes as one local commit.',
39
+ argumentHint: '[message]',
40
+ permissionNote: 'commands allowed; no edits or subagents',
41
+ },
36
42
  {
37
43
  name: 'summary',
38
44
  description: 'Create a new chat with this conversation summarized to reduce context usage.',
@@ -361,6 +367,29 @@ ${expandedBody}
361
367
  </custom_command_invocation>`
362
368
  }
363
369
 
370
+ export function formatSkillCommandPrompt(name, task) {
371
+ const taskBlock = String(task || '').trim() || '(none — call activate_skill first, then ask the user what they would like to do)'
372
+ return `<skill_invocation name="${escapeXml(name)}" source="slash">
373
+ This slash command applies only to the current user request. Follow it unless it conflicts with higher-priority system, safety, user, or project instructions.
374
+
375
+ First call the activate_skill tool with name="${escapeXml(name)}", then carry out the task below following the skill's instructions.
376
+
377
+ Task:
378
+ ${taskBlock}
379
+ </skill_invocation>`
380
+ }
381
+
382
+ export function formatAgentCommandPrompt(name, task) {
383
+ return `<subagent_invocation name="${escapeXml(name)}" source="slash">
384
+ This slash command applies only to the current user request. Follow it unless it conflicts with higher-priority system, safety, user, or project instructions.
385
+
386
+ Call the run_subagent tool with subagent="${escapeXml(name)}" and the task below. Do not perform the task yourself unless the subagent is unavailable. When it completes, summarize its findings for the user.
387
+
388
+ Task:
389
+ ${String(task || '').trim()}
390
+ </subagent_invocation>`
391
+ }
392
+
364
393
  function escapeXml(value) {
365
394
  return String(value ?? '')
366
395
  .replace(/&/g, '&amp;')
@@ -384,12 +413,21 @@ export function parseInternalCommandInvocation(message) {
384
413
  const reviewMatch = text.match(/^\/review(?:\s+([\s\S]*))?$/i)
385
414
  if (reviewMatch) return { type: 'review', args: (reviewMatch[1] || '').trim() }
386
415
 
416
+ const commitMatch = text.match(/^\/commit(?:\s+([\s\S]*))?$/i)
417
+ if (commitMatch) return { type: 'commit', args: (commitMatch[1] || '').trim() }
418
+
387
419
  const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
388
420
  if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
389
421
 
390
422
  const summaryMatch = text.match(/^\/summary(?:\s+([\s\S]*))?$/i)
391
423
  if (summaryMatch) return { type: 'summary', args: (summaryMatch[1] || '').trim() }
392
424
 
425
+ const skillMatch = text.match(/^\/skill(?:\s+([\s\S]*))?$/i)
426
+ if (skillMatch) return { type: 'skill', args: (skillMatch[1] || '').trim() }
427
+
428
+ const agentMatch = text.match(/^\/agent(?:\s+([\s\S]*))?$/i)
429
+ if (agentMatch) return { type: 'agent', args: (agentMatch[1] || '').trim() }
430
+
393
431
  const createMatch = text.match(/^\/command\s+new\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*$/i)
394
432
  if (createMatch) {
395
433
  const name = normalizeCommandName(createMatch[1])
@@ -429,6 +467,11 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
429
467
  return { review: true, args: invocation.args || '' }
430
468
  }
431
469
 
470
+ if (invocation.type === 'commit') {
471
+ if (!workspaceRoot) return 'Commit requires an active project chat.'
472
+ return { commit: true, args: invocation.args || '' }
473
+ }
474
+
432
475
  if (invocation.type === 'clear') {
433
476
  return { clear: true }
434
477
  }
package/server/index.mjs CHANGED
@@ -475,7 +475,7 @@ async function handleApi(req, res, url, requestContext = {}) {
475
475
  }
476
476
 
477
477
  // Project workspace inspector routes
478
- if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/children' || pathname === '/api/workspace/search' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname === '/api/workspace/open-external' || pathname.startsWith('/api/workspace/preview/')) {
478
+ if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/children' || pathname === '/api/workspace/search' || pathname === '/api/workspace/mention-children' || pathname === '/api/workspace/mention-search' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname === '/api/workspace/open-external' || pathname.startsWith('/api/workspace/preview/')) {
479
479
  await handleWorkspaceApi(req, res, url, requestContext)
480
480
  return
481
481
  }
@@ -304,14 +304,14 @@ export async function initializeActiveProject() {
304
304
  setWorkspaceRoot(defaultWorkspaceRoot)
305
305
  }
306
306
 
307
- export async function projectContextFromId(projectId) {
307
+ export async function registeredProjectContextFromId(projectId) {
308
308
  const config = await readProjectConfig()
309
309
  const project = config.projects.find((item) => item.id === projectId)
310
310
  if (!project) {
311
- // Unknown or removed project (e.g. a global conversation's synthetic id)
312
- // fall back to the default workspace so workspace/git REST endpoints keep
313
- // working for global conversations.
314
- return defaultGlobalWorkspaceContext()
311
+ const error = new Error('Unknown project')
312
+ error.statusCode = 404
313
+ error.errorCode = 'PROJECT_NOT_FOUND'
314
+ throw error
315
315
  }
316
316
 
317
317
  await assertDirectory(project.path)
@@ -319,6 +319,18 @@ export async function projectContextFromId(projectId) {
319
319
  return { project, workspaceRoot: path.resolve(project.path) }
320
320
  }
321
321
 
322
+ export async function projectContextFromId(projectId) {
323
+ try {
324
+ return await registeredProjectContextFromId(projectId)
325
+ } catch (error) {
326
+ if (error?.errorCode !== 'PROJECT_NOT_FOUND') throw error
327
+ // Unknown or removed project (e.g. a global conversation's synthetic id) —
328
+ // fall back to the default workspace so workspace/git REST endpoints keep
329
+ // working for global conversations.
330
+ return defaultGlobalWorkspaceContext()
331
+ }
332
+ }
333
+
322
334
  export async function readInstructionsFile(filePath) {
323
335
  const candidates = filePath.endsWith('AGENTS.md')
324
336
  ? [filePath, path.join(path.dirname(filePath), 'agents.md')]
@@ -43,6 +43,17 @@ import {
43
43
  updateBuiltinAgentOverrides,
44
44
  updateCustomAgentProfile,
45
45
  } from '../agent-profiles.mjs'
46
+ import { projectContextFromId } from '../project-config.mjs'
47
+
48
+ async function projectWorkspaceRoot(projectId) {
49
+ if (!projectId) return null
50
+ try {
51
+ const context = await projectContextFromId(projectId)
52
+ return context.workspaceRoot
53
+ } catch {
54
+ return null
55
+ }
56
+ }
46
57
 
47
58
  function requestError(message, statusCode = 400) {
48
59
  const error = new Error(message)
@@ -158,7 +169,11 @@ export async function handleAgentProfilesApi(req, res, url, context = {}) {
158
169
  const parts = url.pathname.split('/').filter(Boolean)
159
170
 
160
171
  if (req.method === 'GET' && url.pathname === '/api/agent-profiles') {
161
- const agents = await listAgentProfiles({ includeDisabled: true })
172
+ // Optional projectId makes project-level agent files (.claude/agents,
173
+ // .quickforge/agents) appear alongside built-in and user profiles; an
174
+ // unknown/absent projectId keeps the previous default behavior.
175
+ const workspaceRoot = await projectWorkspaceRoot(url.searchParams.get('projectId'))
176
+ const agents = await listAgentProfiles({ includeDisabled: true, ...(workspaceRoot ? { workspaceRoot } : {}) })
162
177
  sendJson(res, 200, { agents: agents.map(agentProfileSnapshot) })
163
178
  return
164
179
  }
@@ -92,7 +92,7 @@ export async function handleAgentApi(req, res, url, context = {}) {
92
92
  error.statusCode = 400
93
93
  throw error
94
94
  }
95
- const result = await runPrompt(sessionId, message, body?.selectedCapabilities, body?.command, null, context)
95
+ const result = await runPrompt(sessionId, message, body?.selectedCapabilities, body?.command, null, context, body?.contextReferences)
96
96
  sendJson(res, 200, result)
97
97
  return
98
98
  }
@@ -58,7 +58,13 @@ function publicSharePayload(record) {
58
58
  function sanitizeMessage(message) {
59
59
  if (!message || typeof message !== 'object') return message
60
60
  if (message.role === 'system') return null
61
- return message
61
+ if (!message.details || typeof message.details !== 'object' || Array.isArray(message.details)) return message
62
+ const details = { ...message.details }
63
+ delete details.contextReferences
64
+ const sanitized = { ...message }
65
+ if (Object.keys(details).length > 0) sanitized.details = details
66
+ else delete sanitized.details
67
+ return sanitized
62
68
  }
63
69
 
64
70
  function sanitizeContextCompaction(compaction) {
@@ -369,6 +375,12 @@ export async function handleSharedConversationApi(req, res, url, context = {}) {
369
375
  if (req.method === 'POST' && action === 'message') {
370
376
  assertOperate(record)
371
377
  const body = await readJsonBody(req)
378
+ if (Array.isArray(body?.contextReferences) && body.contextReferences.length > 0) {
379
+ throw Object.assign(new Error('Shared conversations do not support file context references'), {
380
+ statusCode: 409,
381
+ errorCode: 'CONTEXT_REFERENCES_UNSUPPORTED_SHARED',
382
+ })
383
+ }
372
384
  await restoreAgent(record.sessionId)
373
385
  const result = await runPrompt(
374
386
  record.sessionId,
@@ -6,10 +6,14 @@ import {
6
6
  filterKnownProjectSkillNames,
7
7
  findGlobalSkill,
8
8
  findProjectSkill,
9
+ loadSelectedGlobalSkills,
10
+ loadSelectedProjectSkills,
11
+ mergeSkills,
9
12
  projectSkillSearchPaths,
10
13
  listGlobalSkillSummaries,
11
14
  listProjectSkillSummaries,
12
15
  skillSearchPaths,
16
+ summarizeSkills,
13
17
  } from '../skills.mjs'
14
18
 
15
19
  function getProject(config, projectId) {
@@ -46,6 +50,24 @@ export async function handleSkillsApi(req, res, url) {
46
50
  const scope = url.searchParams.get('scope') === 'global' ? 'global' : 'project'
47
51
 
48
52
  if (req.method === 'GET' && url.pathname === '/api/skills') {
53
+ if (url.searchParams.get('available') === 'true') {
54
+ // Merged view of what the current session can activate: global enabled
55
+ // skills always, plus project enabled skills (including plugin
56
+ // contributions) when a valid project is in scope. Mirrors the merge in
57
+ // loadSkillToolContext / activeSkillsForContext (project overrides global).
58
+ const project = projectId ? getProject(config, projectId) : null
59
+ const workspaceRoot = await projectWorkspaceRoot(project?.id)
60
+ const globalSkills = await loadSelectedGlobalSkills(selectedGlobalSkills(config))
61
+ const projectSkills = workspaceRoot
62
+ ? await loadSelectedProjectSkills(selectedSkillsForProject(project), workspaceRoot)
63
+ : []
64
+ sendJson(res, 200, {
65
+ available: true,
66
+ skills: summarizeSkills(mergeSkills(globalSkills, projectSkills)),
67
+ })
68
+ return
69
+ }
70
+
49
71
  if (scope === 'global') {
50
72
  sendJson(res, 200, {
51
73
  scope: 'global',
@@ -5,7 +5,7 @@ import { streamSimpleWithAiHttpLogging } from '../ai-http-logger.mjs'
5
5
  import { resolveModelBinding } from '../model-catalog.mjs'
6
6
  import { DEFAULT_AI_MAX_RETRIES } from '../ai-provider-options.mjs'
7
7
  import { sendJson, readJsonBody } from '../utils/response.mjs'
8
- import { projectContextFromId } from '../project-config.mjs'
8
+ import { projectContextFromId, registeredProjectContextFromId } from '../project-config.mjs'
9
9
  import { readStore } from '../storage.mjs'
10
10
  import { logger } from '../utils/logger.mjs'
11
11
  import { openPathInFileManager, openPathInIDEA, openPathInVSCode } from '../utils/platform.mjs'
@@ -29,6 +29,8 @@ const DEFAULT_CHILDREN_LIMIT = 200
29
29
  const MAX_CHILDREN_LIMIT = 500
30
30
  const DEFAULT_SEARCH_LIMIT = 100
31
31
  const MAX_SEARCH_LIMIT = 200
32
+ const DEFAULT_MENTION_SEARCH_LIMIT = 20
33
+ const MAX_MENTION_SEARCH_LIMIT = 50
32
34
  const MAX_SEARCH_VISITED_ENTRIES = 50000
33
35
  const SKIP_DIRS = new Set(['.git', 'node_modules'])
34
36
 
@@ -897,7 +899,7 @@ async function handleWorkspaceChildren(req, res, url) {
897
899
  export async function readValidatedWorkspaceSearchDirectory(directory, validateWorkspacePath, visitedDirectories, io = {}) {
898
900
  const realpath = io.realpath || fs.realpath
899
901
  const readdir = io.readdir || fs.readdir
900
- await validateWorkspacePath(directory, { allowSensitive: true })
902
+ await validateWorkspacePath(directory, { allowSensitive: io.allowSensitive !== false })
901
903
  const directoryReal = await realpath(directory)
902
904
  const directoryKey = process.platform === 'win32' ? directoryReal.toLocaleLowerCase() : directoryReal
903
905
  if (visitedDirectories.has(directoryKey)) return null
@@ -973,6 +975,160 @@ async function handleWorkspaceSearch(req, res, url) {
973
975
  }))
974
976
  }
975
977
 
978
+ function mentionSearchRank(entry, normalizedQuery) {
979
+ const name = entry.name.toLocaleLowerCase()
980
+ const relativePath = entry.path.toLocaleLowerCase()
981
+ if (name === normalizedQuery) return 0
982
+ if (name.startsWith(normalizedQuery)) return 1
983
+ if (name.includes(normalizedQuery)) return 2
984
+ return relativePath.startsWith(normalizedQuery) ? 3 : 4
985
+ }
986
+
987
+ function compareMentionSearchEntries(left, right, normalizedQuery) {
988
+ return mentionSearchRank(left, normalizedQuery) - mentionSearchRank(right, normalizedQuery)
989
+ || left.path.localeCompare(right.path, undefined, { sensitivity: 'base' })
990
+ || left.path.localeCompare(right.path, undefined, { sensitivity: 'variant' })
991
+ || (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
992
+ }
993
+
994
+ async function mentionSearchEntryFromDirent(directory, dirent, context, validateWorkspacePath) {
995
+ if (dirent.isDirectory() && SKIP_DIRS.has(dirent.name)) return null
996
+ const fullPath = path.join(directory, dirent.name)
997
+ const relativePath = toWorkspaceRelative(fullPath, context)
998
+ try {
999
+ await validateWorkspacePath(fullPath)
1000
+ const stat = await fs.stat(fullPath)
1001
+ if (stat.isDirectory()) {
1002
+ if (SKIP_DIRS.has(dirent.name)) return null
1003
+ if (dirent.isSymbolicLink()) {
1004
+ const workspaceReal = await fs.realpath(context.workspaceRoot)
1005
+ const targetPaths = [
1006
+ [workspaceReal, await fs.realpath(fullPath)],
1007
+ [context.workspaceRoot, path.resolve(directory, await fs.readlink(fullPath))],
1008
+ ]
1009
+ if (targetPaths.some(([root, target]) => path.relative(root, target).split(path.sep).some((part) => SKIP_DIRS.has(part)))) return null
1010
+ }
1011
+ return { name: dirent.name, path: relativePath, type: 'directory', fullPath }
1012
+ }
1013
+ if (stat.isFile()) return { name: dirent.name, path: relativePath, type: 'file' }
1014
+ } catch {
1015
+ // Sensitive, external, broken, or inaccessible entries are omitted.
1016
+ }
1017
+ return null
1018
+ }
1019
+
1020
+ async function readMentionWorkspaceDirectory(context, relativePath) {
1021
+ const requestedPath = relativePath?.trim() || '.'
1022
+ const directory = resolveWorkspacePath(requestedPath, context)
1023
+ const validateWorkspacePath = await createWorkspacePathValidator(context)
1024
+ await validateWorkspacePath(directory)
1025
+ const stat = await fs.stat(directory)
1026
+ if (!stat.isDirectory()) {
1027
+ const error = new Error('Path is not a directory')
1028
+ error.statusCode = 400
1029
+ throw error
1030
+ }
1031
+ return { directory, requestedPath: toWorkspaceRelative(directory, context), validateWorkspacePath }
1032
+ }
1033
+
1034
+ export async function listWorkspaceMentionChildren(context, relativePath = '.') {
1035
+ const { directory, requestedPath, validateWorkspacePath } = await readMentionWorkspaceDirectory(context, relativePath)
1036
+ const dirents = await fs.readdir(directory, { withFileTypes: true })
1037
+ const entries = []
1038
+ for (const dirent of dirents) {
1039
+ const entry = await mentionSearchEntryFromDirent(directory, dirent, context, validateWorkspacePath)
1040
+ if (!entry) continue
1041
+ entries.push({ name: entry.name, path: entry.path, type: entry.type })
1042
+ }
1043
+ entries.sort(compareWorkspaceEntries)
1044
+ return {
1045
+ root: context.project.name,
1046
+ path: requestedPath,
1047
+ entries,
1048
+ }
1049
+ }
1050
+
1051
+ async function handleWorkspaceMentionChildren(req, res, url) {
1052
+ const projectId = url.searchParams.get('projectId')
1053
+ if (!projectId) {
1054
+ const error = new Error('projectId is required')
1055
+ error.statusCode = 400
1056
+ throw error
1057
+ }
1058
+ const context = await registeredProjectContextFromId(projectId)
1059
+ sendJson(res, 200, await listWorkspaceMentionChildren(context, url.searchParams.get('path') || '.'))
1060
+ }
1061
+
1062
+ export async function searchWorkspaceMentions(context, rawQuery, options = {}) {
1063
+ const query = typeof rawQuery === 'string' ? rawQuery.trim() : ''
1064
+ if (query.length < 2) {
1065
+ const error = new Error('query must contain at least 2 characters')
1066
+ error.statusCode = 400
1067
+ throw error
1068
+ }
1069
+ const limit = parseBoundedLimit(options.limit ?? null, DEFAULT_MENTION_SEARCH_LIMIT, MAX_MENTION_SEARCH_LIMIT)
1070
+ const validateWorkspacePath = await createWorkspacePathValidator(context)
1071
+ await validateWorkspacePath(context.workspaceRoot)
1072
+ const normalizedQuery = query.toLocaleLowerCase()
1073
+ const results = []
1074
+ const pending = [context.workspaceRoot]
1075
+ const visitedDirectories = new Set()
1076
+ let visited = 0
1077
+ let traversalTruncated = false
1078
+
1079
+ while (pending.length > 0 && !traversalTruncated) {
1080
+ const directory = pending.pop()
1081
+ let validated
1082
+ try {
1083
+ validated = await readValidatedWorkspaceSearchDirectory(directory, validateWorkspacePath, visitedDirectories, { allowSensitive: false })
1084
+ } catch {
1085
+ continue
1086
+ }
1087
+ if (!validated) continue
1088
+ const { dirents } = validated
1089
+ dirents.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
1090
+ || left.name.localeCompare(right.name, undefined, { sensitivity: 'variant' })
1091
+ || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
1092
+ const childDirectories = []
1093
+ for (const dirent of dirents) {
1094
+ if (visited >= MAX_SEARCH_VISITED_ENTRIES) {
1095
+ traversalTruncated = true
1096
+ break
1097
+ }
1098
+ visited += 1
1099
+ const entry = await mentionSearchEntryFromDirent(directory, dirent, context, validateWorkspacePath)
1100
+ if (!entry) continue
1101
+ if (entry.type === 'directory') {
1102
+ childDirectories.push(entry.fullPath)
1103
+ } else if (entry.path.toLocaleLowerCase().includes(normalizedQuery) || entry.name.toLocaleLowerCase().includes(normalizedQuery)) {
1104
+ results.push(entry)
1105
+ }
1106
+ }
1107
+ for (let index = childDirectories.length - 1; index >= 0; index -= 1) pending.push(childDirectories[index])
1108
+ }
1109
+
1110
+ results.sort((left, right) => compareMentionSearchEntries(left, right, normalizedQuery))
1111
+ return {
1112
+ root: context.project.name,
1113
+ query,
1114
+ entries: results.slice(0, limit),
1115
+ truncated: traversalTruncated || results.length > limit,
1116
+ }
1117
+ }
1118
+
1119
+ async function handleWorkspaceMentionSearch(req, res, url) {
1120
+ const projectId = url.searchParams.get('projectId')
1121
+ if (!projectId) {
1122
+ const error = new Error('projectId is required')
1123
+ error.statusCode = 400
1124
+ throw error
1125
+ }
1126
+ const context = await registeredProjectContextFromId(projectId)
1127
+ sendJson(res, 200, await searchWorkspaceMentions(context, url.searchParams.get('query') || '', {
1128
+ limit: url.searchParams.get('limit'),
1129
+ }))
1130
+ }
1131
+
976
1132
  async function handleWorkspaceFile(req, res, url) {
977
1133
  const context = await projectContextFromUrl(url)
978
1134
  const relativePath = url.searchParams.get('path') || ''
@@ -1414,6 +1570,14 @@ export async function handleWorkspaceApi(req, res, url, requestContext = {}) {
1414
1570
  await handleWorkspaceSearch(req, res, url)
1415
1571
  return
1416
1572
  }
1573
+ if (req.method === 'GET' && url.pathname === '/api/workspace/mention-children') {
1574
+ await handleWorkspaceMentionChildren(req, res, url)
1575
+ return
1576
+ }
1577
+ if (req.method === 'GET' && url.pathname === '/api/workspace/mention-search') {
1578
+ await handleWorkspaceMentionSearch(req, res, url)
1579
+ return
1580
+ }
1417
1581
  if (req.method === 'GET' && url.pathname === '/api/workspace/file') {
1418
1582
  await handleWorkspaceFile(req, res, url)
1419
1583
  return
@@ -0,0 +1,76 @@
1
+ export const MAX_SELECTED_CAPABILITIES = 4
2
+ export const SELECTED_CAPABILITY_PLUGIN_NAME_MAX_LENGTH = 120
3
+ export const SELECTED_CAPABILITY_NAME_MAX_LENGTH = 120
4
+ export const SELECTED_CAPABILITY_LABEL_MAX_LENGTH = 160
5
+ export const SELECTED_CAPABILITY_DESCRIPTION_MAX_LENGTH = 400
6
+ export const SELECTED_CAPABILITIES_DETAILS_KEY = 'selectedCapabilities'
7
+
8
+ const SELECTED_CAPABILITY_TYPES = new Set(['plugin', 'skill', 'tool', 'command'])
9
+
10
+ function normalizedText(value, maxLength) {
11
+ if (typeof value !== 'string') return undefined
12
+ const normalized = value.trim().slice(0, maxLength)
13
+ return normalized || undefined
14
+ }
15
+
16
+ export function selectedCapabilityKey(capability) {
17
+ return `${capability.type}:${capability.pluginName}:${capability.name}`
18
+ }
19
+
20
+ export function normalizeSelectedCapabilities(value) {
21
+ if (!Array.isArray(value)) return []
22
+ const result = []
23
+ const seen = new Set()
24
+ for (const item of value) {
25
+ if (!item || typeof item !== 'object' || Array.isArray(item) || !SELECTED_CAPABILITY_TYPES.has(item.type)) continue
26
+ const pluginName = normalizedText(item.pluginName, SELECTED_CAPABILITY_PLUGIN_NAME_MAX_LENGTH)
27
+ const name = normalizedText(item.name, SELECTED_CAPABILITY_NAME_MAX_LENGTH)
28
+ const label = normalizedText(item.label, SELECTED_CAPABILITY_LABEL_MAX_LENGTH)
29
+ if (!pluginName || !name || !label) continue
30
+ const capability = { type: item.type, pluginName, name, label }
31
+ const description = normalizedText(item.description, SELECTED_CAPABILITY_DESCRIPTION_MAX_LENGTH)
32
+ if (description) capability.description = description
33
+ const key = selectedCapabilityKey(capability)
34
+ if (seen.has(key)) continue
35
+ seen.add(key)
36
+ result.push(capability)
37
+ if (result.length >= MAX_SELECTED_CAPABILITIES) break
38
+ }
39
+ return result
40
+ }
41
+
42
+ export function selectedCapabilitySnapshots(value) {
43
+ return normalizeSelectedCapabilities(value).map(({ type, pluginName, name, label }) => ({ type, pluginName, name, label }))
44
+ }
45
+
46
+ export function selectedCapabilitiesFromMessage(message) {
47
+ const details = message?.details
48
+ if (!details || typeof details !== 'object' || Array.isArray(details)) return []
49
+ return selectedCapabilitySnapshots(details[SELECTED_CAPABILITIES_DETAILS_KEY])
50
+ }
51
+
52
+ export function withCanonicalSelectedCapabilities(message, capabilities) {
53
+ if (!message || typeof message !== 'object') return message
54
+ const snapshots = selectedCapabilitySnapshots(capabilities)
55
+ const details = message.details && typeof message.details === 'object' && !Array.isArray(message.details)
56
+ ? message.details
57
+ : {}
58
+ const nextDetails = { ...details }
59
+ if (snapshots.length > 0) nextDetails[SELECTED_CAPABILITIES_DETAILS_KEY] = snapshots
60
+ else delete nextDetails[SELECTED_CAPABILITIES_DETAILS_KEY]
61
+ const next = { ...message }
62
+ if (Object.keys(nextDetails).length > 0) next.details = nextDetails
63
+ else delete next.details
64
+ return next
65
+ }
66
+
67
+ export function selectedCapabilityPrompt(capabilities) {
68
+ const normalized = normalizeSelectedCapabilities(capabilities)
69
+ if (normalized.length === 0) return null
70
+ const lines = normalized.map((capability) => {
71
+ const toolHint = capability.type === 'tool' ? ` Tool name: plugin__${capability.pluginName}__${capability.name}.` : ''
72
+ const description = capability.description ? ` Description: ${capability.description}` : ''
73
+ return `- ${capability.label} (${capability.type}, plugin: ${capability.pluginName}, name: ${capability.name}).${toolHint}${description}`
74
+ }).join('\n')
75
+ return `The user selected the following QuickForge plugin capability mentions for this turn. Treat them as an explicit preference for routing and context. Use the selected capability when relevant, but do not force it if it is unrelated to the actual request.\n\n${lines}`
76
+ }
package/server/skills.mjs CHANGED
@@ -392,7 +392,7 @@ function searchDirsForList(value) {
392
392
  return value.length === 1 ? value[0] : value.slice()
393
393
  }
394
394
 
395
- function summarizeSkills(skills) {
395
+ export function summarizeSkills(skills) {
396
396
  return skills.map(({ instructions: _instructions, rootDir: _rootDir, location: _location, ...summary }) => summary)
397
397
  }
398
398
 
@@ -2,8 +2,7 @@ export const BASE_SYSTEM_PROMPT = `You are a pragmatic coding assistant.
2
2
 
3
3
  For project tasks:
4
4
  - Do not assume requirements. If ambiguous, state assumptions or ask.
5
- - Prefer the simplest solution that satisfies the request.
6
- - Make surgical changes only. Do not refactor unrelated code.
5
+ - Do not refactor unrelated code.
7
6
  - Match existing style.
8
7
  - When content has room for visual explanation, first consider whether an SVG diagram can improve understanding.
9
8
  - For multi-step work, use a brief plan.
@@ -11,7 +10,6 @@ For project tasks:
11
10
  - Before taking action, confirm with the user.
12
11
  - Unless the change is trivial and localized to an already-known file, use Explore first for read-only repository research before implementation decisions; prefer Explore for file discovery, source location, broad searches, call-chain lookup, pattern lookup, impact analysis, and locating related tests, docs, wiki pages, or build scripts.
13
12
  - For complex multi-step work, use General only for bounded assistance; the parent assistant remains responsible for final decisions, minimal edits, and verification.
14
- - Make minimal, focused changes.
15
13
  - Prefer dedicated workspace tools for reading, editing, and searching files.
16
14
  - If dedicated tools are unavailable or insufficient, use the shell/command tool.
17
15
  - Use Python through the shell for reliable scripting, data processing, or file transformations.
@@ -134,16 +134,6 @@ export const workspaceTools = [
134
134
  }),
135
135
  executionMode: 'sequential',
136
136
  },
137
- {
138
- name: 'generate_image',
139
- label: 'Generate image',
140
- description: 'Generate images with the configured OpenRouter provider and save them as assets owned by the current conversation. Use this when the user explicitly asks for a generated bitmap image. The operation may incur provider charges.',
141
- parameters: Type.Object({
142
- prompt: Type.String({ description: 'Detailed image-generation prompt.' }),
143
- model: Type.Optional(Type.String({ description: 'Optional OpenRouter image model ID. Defaults to google/gemini-2.5-flash-image.' })),
144
- }),
145
- executionMode: 'sequential',
146
- },
147
137
  {
148
138
  name: 'present_files',
149
139
  label: 'Present files',
@@ -41,7 +41,7 @@ export function toWorkspaceRelative(fullPath, context) {
41
41
 
42
42
  export function isSensitiveWorkspacePath(fullPath, context) {
43
43
  const relative = toWorkspaceRelative(fullPath, context)
44
- const parts = relative.split('/')
44
+ const parts = relative.split('/').map((part) => part.toLocaleLowerCase())
45
45
  const name = parts.at(-1) || ''
46
46
  return (
47
47
  parts.includes('.git') ||
@@ -61,6 +61,13 @@ export function isSensitiveWorkspacePath(fullPath, context) {
61
61
  )
62
62
  }
63
63
 
64
+ function sensitivePathError(fullPath, context) {
65
+ return Object.assign(new Error(`Access to sensitive path is blocked: ${toWorkspaceRelative(fullPath, context)}`), {
66
+ statusCode: 403,
67
+ errorCode: 'WORKSPACE_SENSITIVE_PATH',
68
+ })
69
+ }
70
+
64
71
  async function realpathNearestExistingParent(inputPath) {
65
72
  let current = path.resolve(inputPath)
66
73
  while (true) {
@@ -77,9 +84,7 @@ async function realpathNearestExistingParent(inputPath) {
77
84
 
78
85
  async function assertSafeWorkspacePathWithRoot(fullPath, context, workspaceReal, options = {}) {
79
86
  if (!options.allowSensitive && isSensitiveWorkspacePath(fullPath, context)) {
80
- const error = new Error(`Access to sensitive path is blocked: ${toWorkspaceRelative(fullPath, context)}`)
81
- error.statusCode = 403
82
- throw error
87
+ throw sensitivePathError(fullPath, context)
83
88
  }
84
89
 
85
90
  let targetReal
@@ -98,8 +103,12 @@ async function assertSafeWorkspacePathWithRoot(fullPath, context, workspaceReal,
98
103
  if (!isInside(workspaceReal, targetReal)) {
99
104
  const error = new Error(`Path resolves outside the selected project: ${toWorkspaceRelative(fullPath, context)}`)
100
105
  error.statusCode = 403
106
+ error.errorCode = 'WORKSPACE_PATH_ESCAPE'
101
107
  throw error
102
108
  }
109
+ if (!options.allowSensitive && isSensitiveWorkspacePath(targetReal, { workspaceRoot: workspaceReal })) {
110
+ throw sensitivePathError(fullPath, context)
111
+ }
103
112
  }
104
113
 
105
114
  export async function createWorkspacePathValidator(context) {