@gpzhang2001/sharpkit-preset 0.2.1

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/src/index.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Pentest preset — the M3 prompt/knowledge layer: renders the strix system
3
+ * prompt (VERBATIM template + jinja-subset renderer + the skill selection
4
+ * policy) from scan configuration, registers it through the dsh
5
+ * system-prompt seam, and exposes scan-mode semantics (budget/turn
6
+ * defaults) as `pentestPreset` for the orchestrator. Golden-locked against
7
+ * renders from the original jinja template.
8
+ * @module @gpzhang2001/sharpkit-preset
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import type {} from '@deepseek-ai/dsh-system-prompt'
16
+ import type Schema from '@deepseek-ai/schemastery'
17
+ import z from '@deepseek-ai/schemastery'
18
+ import { bundledSkillsRoot as skillsPackageRoot } from '@gpzhang2001/sharpkit-skills'
19
+ import { renderTemplate } from './jinja.ts'
20
+ import { getAvailableSkills, loadSkills, resolveSkills } from './skills.ts'
21
+
22
+ export { renderTemplate } from './jinja.ts'
23
+ export { getAvailableSkills, loadSkills, resolveSkills, validateRequestedSkills, parseSkillContent } from './skills.ts'
24
+
25
+ /** The template file shipped verbatim from strix. */
26
+ const TEMPLATE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'system_prompt.jinja'), 'utf8')
27
+
28
+ /** Scan-mode budget semantics (manual §6 M3; orchestrator consumes). */
29
+ export interface ScanModeSemantics {
30
+ readonly maxTurns: number
31
+ readonly maxBudgetUsd: number
32
+ }
33
+
34
+ /** The quick/deep budget matrix (initial values; M6 tunes). */
35
+ const SCAN_MODE_SEMANTICS: Record<string, ScanModeSemantics> = {
36
+ quick: { maxTurns: 60, maxBudgetUsd: 5 },
37
+ deep: { maxTurns: 250, maxBudgetUsd: 25 },
38
+ }
39
+
40
+ /** Deployment-tunable configuration (cordis resolves defaults before apply). */
41
+ export interface Config {
42
+ /** Scan mode: quick or deep. */
43
+ readonly scanMode?: string
44
+ /** Whether this agent is the root orchestrator. */
45
+ readonly isRoot?: boolean
46
+ /** Whether the scan has source access (whitebox). */
47
+ readonly isWhitebox?: boolean
48
+ /** Whether the session is interactive. */
49
+ readonly interactive?: boolean
50
+ /** Whether the scan is scoped to a change set. */
51
+ readonly isDiffScoped?: boolean
52
+ /** Requested skills (≤5, category-qualified). */
53
+ readonly skills?: readonly string[]
54
+ /** Scan targets rendered into the scope block. */
55
+ readonly authorizedTargets?: ReadonlyArray<{ readonly type: string; readonly value: string; readonly workspace_path?: string }>
56
+ readonly scopeSource?: string
57
+ readonly authorizationSource?: string
58
+ /** MCP connections rendered into the mcp block. */
59
+ readonly mcpConnections?: ReadonlyArray<{ readonly name: string; readonly tool_count: number; readonly purpose?: string }>
60
+ /** Skills tree root; defaults to the bundled pentest-suite skills. */
61
+ readonly skillsRoot?: string
62
+ }
63
+
64
+ export const name = 'pentest-preset'
65
+
66
+ export const inject: string[] = []
67
+
68
+ export const Config: Schema<Config> = z.object({
69
+ scanMode: z.string().default('deep'),
70
+ isRoot: z.boolean().default(true),
71
+ isWhitebox: z.boolean().default(false),
72
+ interactive: z.boolean().default(true),
73
+ isDiffScoped: z.boolean().default(false),
74
+ skills: z.array(z.string()),
75
+ authorizedTargets: z.array(z.object({
76
+ type: z.string(),
77
+ value: z.string(),
78
+ workspace_path: z.string(),
79
+ })),
80
+ scopeSource: z.string(),
81
+ authorizationSource: z.string(),
82
+ mcpConnections: z.array(z.object({
83
+ name: z.string(),
84
+ tool_count: z.number(),
85
+ purpose: z.string(),
86
+ })),
87
+ skillsRoot: z.string(),
88
+ }) as unknown as Schema<Config>
89
+
90
+ /** Default skills root: the skills package's bundled corpus (npm-layout safe). */
91
+ export function bundledSkillsRoot(): string {
92
+ return skillsPackageRoot()
93
+ }
94
+
95
+ /**
96
+ * Render the system prompt for one configuration (pure given the inputs).
97
+ * @param config - resolved preset config.
98
+ * @returns the rendered markdown prompt.
99
+ */
100
+ export function renderPresetPrompt(config: Config): string {
101
+ const skillsRoot = config.skillsRoot ?? bundledSkillsRoot()
102
+ const requested = config.skills ?? []
103
+ const skillNames = resolveSkills({
104
+ requested,
105
+ scanMode: config.scanMode ?? 'deep',
106
+ isRoot: config.isRoot ?? true,
107
+ isWhitebox: config.isWhitebox ?? false,
108
+ isDiffScoped: config.isDiffScoped ?? false,
109
+ })
110
+ const skillContent = loadSkills(skillNames, skillsRoot)
111
+ const targets = (config.authorizedTargets ?? []).map(target => ({ ...target }))
112
+ const hasScope = targets.length > 0 || config.scopeSource !== undefined
113
+ const mcpConnections = (config.mcpConnections ?? []).map(connection => ({ ...connection }))
114
+ return renderTemplate(TEMPLATE, {
115
+ is_root: config.isRoot ?? true,
116
+ interactive: config.interactive ?? true,
117
+ loaded_skill_names: [...Object.keys(skillContent)],
118
+ available_skills: getAvailableSkills(skillsRoot),
119
+ get_skill: (name: string): string => skillContent[name] ?? '',
120
+ system_prompt_context: {
121
+ ...(hasScope ? { authorized_targets: targets, scope_source: config.scopeSource, authorization_source: config.authorizationSource } : {}),
122
+ ...(config.mcpConnections !== undefined && config.mcpConnections.length > 0 ? { mcp_available: true, mcp_connections: mcpConnections } : {}),
123
+ },
124
+ ...skillContent,
125
+ })
126
+ }
127
+
128
+ /** Scan-mode semantics lookup (quick default when the mode is unknown). */
129
+ export function scanModeSemantics(scanMode: string): ScanModeSemantics {
130
+ return SCAN_MODE_SEMANTICS[scanMode] ?? { maxTurns: 60, maxBudgetUsd: 5 }
131
+ }
132
+
133
+ export function apply(ctx: Context, config: Config = {}): void {
134
+ const prompt = renderPresetPrompt(config)
135
+ // The persona section exists on host compositions; the headless one-shot
136
+ // tree mounts no system-prompt service, where the preset still provides
137
+ // pentestPreset (the rendered text is available to SDK consumers).
138
+ void ctx.inject(['systemPrompt'], (promptCtx: unknown) => {
139
+ ;(promptCtx as Context).systemPrompt.section({
140
+ // PERSONA_SECTION is the slot an agent preset shadows (system-prompt docs).
141
+ name: 'deployment:persona',
142
+ order: (promptCtx as Context).systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA'),
143
+ text: prompt,
144
+ })
145
+ })
146
+ ctx.provide('pentestPreset', {
147
+ scanMode: config.scanMode ?? 'deep',
148
+ isRoot: config.isRoot ?? true,
149
+ ...scanModeSemantics(config.scanMode ?? 'deep'),
150
+ /** Consumed by tool-proxy's repeat_request target enforcement. */
151
+ authorizedTargets: (config.authorizedTargets ?? []).map(target => ({ ...target })),
152
+ })
153
+ }
154
+
package/src/jinja.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * A minimal Jinja-subset renderer covering exactly the constructs the
3
+ * strix system prompt template uses: line-level {% if %}/{% else %}/
4
+ * {% endif %}/{% for x in y %}/{% endfor %}, inline {% if %}...{% endif %},
5
+ * and {{ dotted.expr }} interpolation (with `a and b` truthiness and the
6
+ * `available_skills | dictsort` filter). The template file ships VERBATIM
7
+ * from strix — no hand transcription — and whitespace semantics follow
8
+ * default Jinja (a lone tag line renders as an empty line).
9
+ * @module @gpzhang2001/sharpkit-preset/jinja
10
+ */
11
+
12
+ /** A rendered value: strings, numbers, booleans, null, arrays, objects. */
13
+ export type JinjaValue = string | number | boolean | null | JinjaValue[] | { [key: string]: JinvaRecordValue } | JinvaRecordValue[]
14
+ type JinvaRecordValue = string | number | boolean | null | JinvaRecordValue[] | { [key: string]: JinvaRecordValue }
15
+
16
+ /** Truthiness per Jinja: undefined/null/false/empty string/empty array are falsy. */
17
+ function isTruthy(value: unknown): boolean {
18
+ if (value === undefined || value === null || value === false) return false
19
+ if (typeof value === 'string') return value !== ''
20
+ if (Array.isArray(value)) return value.length > 0
21
+ return true
22
+ }
23
+
24
+ /** Resolve a dotted path — or a zero/one-arg call like get_skill(x) — against the scope chain. */
25
+ function lookup(scopes: ReadonlyArray<Record<string, unknown>>, path: string): unknown {
26
+ const call = /^([^(]+)\(([^)]*)\)$/.exec(path)
27
+ if (call !== null) {
28
+ const fn = resolveName(scopes, (call[1] ?? '').trim())
29
+ if (typeof fn !== 'function') return undefined
30
+ const argExpression = (call[2] ?? '').trim()
31
+ const arg = argExpression === '' ? undefined : lookup(scopes, argExpression)
32
+ return (fn as (value?: unknown) => unknown)(arg)
33
+ }
34
+ return resolveName(scopes, path)
35
+ }
36
+
37
+ /** Resolve a dotted name against a scope chain (top of stack first). */
38
+ function resolveName(scopes: ReadonlyArray<Record<string, unknown>>, path: string): unknown {
39
+ const parts = path.split('.')
40
+ const head = parts[0] ?? ''
41
+ const rest = parts.slice(1)
42
+ for (let index = scopes.length - 1; index >= 0; index--) {
43
+ const scope = scopes[index]
44
+ if (scope === undefined || !(head in scope)) continue
45
+ let current: unknown = scope[head]
46
+ for (const part of rest) {
47
+ if (typeof current !== 'object' || current === null) return undefined
48
+ current = (current as Record<string, unknown>)[part]
49
+ }
50
+ return current
51
+ }
52
+ return undefined
53
+ }
54
+
55
+ /** Evaluate one condition expression: `a`, `a.b`, `x and y`. */
56
+ function evaluateCondition(scopes: ReadonlyArray<Record<string, unknown>>, expression: string): boolean {
57
+ const conjuncts = expression.split(' and ').map(part => part.trim())
58
+ return conjuncts.every(part => isTruthy(lookup(scopes, part)))
59
+ }
60
+
61
+ /** Interpolate {{ expr }} occurrences (dotted lookup; undefined renders as empty per strix usage guard). */
62
+ function interpolate(scopes: ReadonlyArray<Record<string, unknown>>, text: string): string {
63
+ return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, expression: string) => {
64
+ const value = lookup(scopes, expression.trim())
65
+ return value === undefined || value === null ? '' : String(value)
66
+ })
67
+ }
68
+
69
+ /** dictsort: sort object entries by key (jinja dictsort default). */
70
+ function dictSort(value: unknown): Array<{ key: string; value: unknown }> {
71
+ if (typeof value !== 'object' || value === null) return []
72
+ return Object.entries(value as Record<string, unknown>)
73
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
74
+ .map(([key, entryValue]) => ({ key, value: entryValue }))
75
+ }
76
+
77
+ /** One parsed template directive. */
78
+ type Line =
79
+ | { readonly kind: 'text'; readonly text: string }
80
+ | { readonly kind: 'if'; readonly condition: string }
81
+ | { readonly kind: 'else' }
82
+ | { readonly kind: 'endif' }
83
+ | { readonly kind: 'for'; readonly variable: string; readonly iterable: string; readonly rightTrim: boolean }
84
+ | { readonly kind: 'endfor' }
85
+
86
+ /** Parse template text into lines, splitting inline {% if %}/{% endif %} fragments. */
87
+ function parseLines(text: string): Line[] {
88
+ const lines: Line[] = []
89
+ for (const rawLine of text.split('\n')) {
90
+ // Line-alone block tags take precedence over inline-fragment splitting.
91
+ const trimmed = rawLine.trim()
92
+ const loneIf = /^\{%-?\s*if\s+(.+?)\s*-?%\}$/.exec(trimmed)
93
+ const loneElse = /^\{%-?\s*else\s*-?%\}$/.test(trimmed)
94
+ const loneEndif = /^\{%-?\s*endif\s*-?%\}$/.test(trimmed)
95
+ const loneFor = /^\{%-?\s*for\s+(.+?)\s*-?%\}$/.exec(trimmed)
96
+ const loneEndfor = /^\{%-?\s*endfor\s*-?%\}$/.test(trimmed)
97
+ if (loneIf !== null) {
98
+ lines.push({ kind: 'if', condition: loneIf[1] ?? '' })
99
+ continue
100
+ }
101
+ if (loneElse) {
102
+ lines.push({ kind: 'else' })
103
+ continue
104
+ }
105
+ if (loneEndif) {
106
+ lines.push({ kind: 'endif' })
107
+ continue
108
+ }
109
+ if (loneFor !== null) {
110
+ const rightTrim = loneFor[0].endsWith('-%}')
111
+ const declaration = loneFor[1] ?? ''
112
+ const two = /^(\w+),\s*(\w+)\s+in\s+(.+?)\s*\|\s*(\w+)$/.exec(declaration)
113
+ const one = /^(\w+)\s+in\s+(.+?)\s*\|\s*(\w+)$/.exec(declaration)
114
+ const onePlain = /^(\w+)\s+in\s+(.+?)$/.exec(declaration)
115
+ if (two !== null) {
116
+ lines.push({ kind: 'for', variable: `${two[1]},${two[2]}`, iterable: `${two[3]}|${two[4] ?? ''}`, rightTrim })
117
+ } else if (one !== null) {
118
+ lines.push({ kind: 'for', variable: one[1] ?? '', iterable: `${one[2] ?? ''}|${one[3] ?? ''}`, rightTrim })
119
+ } else if (onePlain !== null) {
120
+ lines.push({ kind: 'for', variable: onePlain[1] ?? '', iterable: onePlain[2] ?? '', rightTrim })
121
+ }
122
+ continue
123
+ }
124
+ if (loneEndfor) {
125
+ lines.push({ kind: 'endfor' })
126
+ continue
127
+ }
128
+ const inlineParts = rawLine.split(/(\{%-?\s*(?:if|endif)\s+[^%]*?-?%\}|\{\{-?[^-]*?-?\}\})/g)
129
+ if (inlineParts.length > 1 && inlineParts.some(part => part.includes('{%'))) {
130
+ // A line mixing text and inline if/endif tags: expand into sub-lines
131
+ // while preserving the join (rendered pieces re-join with '').
132
+ let buffer = ''
133
+ const segments: Array<{ readonly tag: string | null; readonly text: string }> = []
134
+ for (const part of inlineParts) {
135
+ const ifMatch = /^\{%-?\s*if\s+(.+?)\s*-?%\}$/.exec(part)
136
+ const endifMatch = /^\{%-?\s*endif\s*-?%\}$/.exec(part)
137
+ if (ifMatch !== null) {
138
+ if (buffer !== '') segments.push({ tag: null, text: buffer })
139
+ buffer = ''
140
+ segments.push({ tag: `if ${ifMatch[1]}`, text: '' })
141
+ } else if (endifMatch !== null) {
142
+ if (buffer !== '') segments.push({ tag: null, text: buffer })
143
+ buffer = ''
144
+ segments.push({ tag: 'endif', text: '' })
145
+ } else {
146
+ buffer += part
147
+ }
148
+ }
149
+ if (buffer !== '') segments.push({ tag: null, text: buffer })
150
+ // Emit as a synthetic single line handled by renderSegments.
151
+ lines.push({ kind: 'text', text: `\u0000SEG${JSON.stringify(segments)}\u0000` })
152
+ continue
153
+ }
154
+ const ifMatch = /^\{%-?\s*if\s+(.+?)\s*-?%\}$/.exec(rawLine.trim())
155
+ if (ifMatch !== null) {
156
+ lines.push({ kind: 'if', condition: ifMatch[1] ?? '' })
157
+ continue
158
+ }
159
+ if (/^\{%-?\s*else\s*-?%\}$/.test(rawLine.trim())) {
160
+ lines.push({ kind: 'else' })
161
+ continue
162
+ }
163
+ if (/^\{%-?\s*endif\s*-?%\}$/.test(rawLine.trim())) {
164
+ lines.push({ kind: 'endif' })
165
+ continue
166
+ }
167
+ lines.push({ kind: 'text', text: rawLine })
168
+ }
169
+ return lines
170
+ }
171
+
172
+ /** Render segment-marked text lines (inline if fragments). */
173
+ function renderSegments(text: string, scopes: ReadonlyArray<Record<string, unknown>>): string {
174
+ const payload = text.slice(4, -1)
175
+ const segments = JSON.parse(payload) as Array<{ tag: string | null; text: string }>
176
+ let out = ''
177
+ let include = true
178
+ for (const segment of segments) {
179
+ if (segment.tag !== null) {
180
+ include = segment.tag.startsWith('if') ? evaluateCondition(scopes, segment.tag.slice(3)) : false
181
+ continue
182
+ }
183
+ if (include) out += interpolate(scopes, segment.text)
184
+ }
185
+ return out
186
+ }
187
+
188
+ /**
189
+ * Render the template text with a variable scope.
190
+ * @param template - the raw jinja template text.
191
+ * @param variables - top-level template variables.
192
+ * @returns the rendered output.
193
+ */
194
+ export function renderTemplate(template: string, variables: Record<string, unknown>): string {
195
+ const scopes: Record<string, unknown>[] = [variables]
196
+ // Jinja default keep_trailing_newline=False: the template's final newline
197
+ // is removed before rendering.
198
+ const lines = parseLines(template.replace(/\n$/, ''))
199
+ const output: string[] = []
200
+ // Block stack for if/for with instruction pointers.
201
+ const emit = (from: number, to: number): void => {
202
+ let index = from
203
+ while (index < to) {
204
+ const line = lines[index]
205
+ index++
206
+ if (line === undefined) break
207
+ if (line.kind === 'text') {
208
+ output.push(line.text.startsWith('\u0000SEG') ? renderSegments(line.text, scopes) : interpolate(scopes, line.text))
209
+ continue
210
+ }
211
+ if (line.kind === 'if') {
212
+ const taken = evaluateCondition(scopes, line.condition)
213
+ // Find matching else/endif at the same depth.
214
+ let depth = 0
215
+ let elseAt = -1
216
+ let endifAt = -1
217
+ let cursor = index
218
+ while (cursor < to) {
219
+ const probe = lines[cursor]
220
+ if (probe?.kind === 'if') depth++
221
+ if (probe?.kind === 'endif') {
222
+ if (depth === 0) {
223
+ endifAt = cursor
224
+ break
225
+ }
226
+ depth--
227
+ }
228
+ if (probe?.kind === 'else' && depth === 0 && elseAt === -1) elseAt = cursor
229
+ cursor++
230
+ }
231
+ if (endifAt === -1) endifAt = to
232
+ // Jinja default whitespace: a block tag adjacent to the TAKEN side
233
+ // keeps its line's newline (renders as one empty line); everything
234
+ // inside a FALSE region — including its opening tag — vanishes,
235
+ // except the closing endif, which always leaves one blank line.
236
+ if (taken) {
237
+ output.push('')
238
+ emit(index, elseAt === -1 ? endifAt : elseAt)
239
+ } else if (elseAt !== -1) {
240
+ // The else tag's trailing newline belongs to the taken side.
241
+ output.push('')
242
+ emit(elseAt + 1, endifAt)
243
+ }
244
+ output.push('')
245
+ index = endifAt + 1
246
+ continue
247
+ }
248
+ if (line.kind === 'for') {
249
+ const [iterableSource, filter = ''] = line.iterable.split('|')
250
+ const raw = lookup(scopes, iterableSource ?? '')
251
+ void filter
252
+ // Find endfor at the same depth.
253
+ let depth = 0
254
+ let endforAt = to
255
+ let cursor = index
256
+ while (cursor < to) {
257
+ const probe = lines[cursor]
258
+ if (probe?.kind === 'for') depth++
259
+ if (probe?.kind === 'endfor') {
260
+ if (depth === 0) {
261
+ endforAt = cursor
262
+ break
263
+ }
264
+ depth--
265
+ }
266
+ cursor++
267
+ }
268
+ const [nameA, nameB] = line.variable.split(',')
269
+ const items: Array<{ readonly key: string; readonly value: unknown }> = nameB !== undefined && nameA !== undefined
270
+ ? dictSort(raw)
271
+ : (Array.isArray(raw) ? raw : []).map(item => ({ key: '', value: item }))
272
+ if (items.length > 0) {
273
+ for (const entry of items) {
274
+ // The for tag's trailing newline is INSIDE the loop body (default
275
+ // Jinja); `-%}` right-trims it away for every iteration.
276
+ if (!line.rightTrim) output.push('')
277
+ if (nameB !== undefined && nameA !== undefined) {
278
+ const scope: Record<string, unknown> = {}
279
+ scope[nameA] = entry.key
280
+ scope[nameB] = entry.value
281
+ scopes.push(scope)
282
+ } else {
283
+ scopes.push({ [line.variable]: entry.value })
284
+ }
285
+ emit(index, endforAt)
286
+ scopes.pop()
287
+ }
288
+ if (!line.rightTrim) output.push('')
289
+ }
290
+ index = endforAt + 1
291
+ continue
292
+ }
293
+ }
294
+ }
295
+ emit(0, lines.length)
296
+ return output.join('\n')
297
+ }
298
+
package/src/skills.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Skill resolution — port of strix skills/__init__.py `_resolve_skills`,
3
+ * `load_skills`, `get_available_skills`, and `validate_requested_skills`
4
+ * over a filesystem skills root with the `<root>/<category>/<name>.md`
5
+ * layout (the bundled pentest-suite skills tree, or the strix original for
6
+ * golden tests).
7
+ * @module @gpzhang2001/sharpkit-preset/skills
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ /** Internal categories excluded from the selectable catalog (strix parity). */
14
+ const INTERNAL_SKILL_CATEGORIES = new Set(['scan_modes', 'coordination', 'analysis'])
15
+
16
+ /** Frontmatter regex (strix `_FRONTMATTER_PATTERN`). */
17
+ const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---\s*\n/
18
+
19
+ /** Parsed skill metadata + body. */
20
+ export interface SkillFile {
21
+ readonly metadata: Record<string, string>
22
+ readonly body: string
23
+ }
24
+
25
+ /** Parse frontmatter and body (strix `_parse_skill_content`). */
26
+ export function parseSkillContent(content: string): SkillFile {
27
+ const match = FRONTMATTER.exec(content)
28
+ if (match === null) return { metadata: {}, body: content.replace(/^\s+/, '') }
29
+ const body = match[1] ?? ''
30
+ const metadata: Record<string, string> = {}
31
+ for (const line of body.split('\n')) {
32
+ const colon = line.indexOf(':')
33
+ if (colon === -1) continue
34
+ const key = line.slice(0, colon).trim()
35
+ let value = line.slice(colon + 1).trim()
36
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
37
+ if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1)
38
+ metadata[key] = value
39
+ }
40
+ const rest = content.slice(match[0].length)
41
+ return { metadata, body: rest.replace(/^\s+/, '') }
42
+ }
43
+
44
+ /** One selectable skill in the catalog. */
45
+ export interface SkillCatalogEntry {
46
+ readonly category: string
47
+ readonly name: string
48
+ readonly description: string
49
+ }
50
+
51
+ /**
52
+ * List selectable skills (strix `_iter_user_skill_files` + frontmatter):
53
+ * root-level `*.md` plus category directories, excluding internal
54
+ * categories from the catalog.
55
+ * @param skillsRoot - the skills tree root.
56
+ */
57
+ export function getAvailableSkills(skillsRoot: string): Record<string, Array<{ name: string; description: string }>> {
58
+ const grouped: Record<string, Array<{ name: string; description: string }>> = {}
59
+ if (!existsSync(skillsRoot)) return grouped
60
+ const seen = new Set<string>()
61
+ const consider = (category: string, name: string): void => {
62
+ const key = `${category}/${name}`
63
+ if (seen.has(key)) return
64
+ const path = category === 'root' ? join(skillsRoot, `${name}.md`) : join(skillsRoot, category, `${name}.md`)
65
+ if (!existsSync(path)) return
66
+ const { metadata } = parseSkillContent(readFileSync(path, 'utf8'))
67
+ const description = (metadata['description'] ?? '').split(/\s+/).filter(Boolean).join(' ')
68
+ grouped[category] ??= []
69
+ grouped[category]?.push({ name, description })
70
+ seen.add(key)
71
+ }
72
+ const entries = readdirSync(skillsRoot, { withFileTypes: true })
73
+ const rootFiles = entries.filter(entry => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('__') && entry.name !== 'README.md').map(entry => entry.name).sort()
74
+ for (const name of rootFiles) consider('root', name.replace(/\.md$/, ''))
75
+ const categoryDirs = entries.filter(entry => entry.isDirectory() && !entry.name.startsWith('__')).map(entry => entry.name).sort()
76
+ for (const category of categoryDirs) {
77
+ if (INTERNAL_SKILL_CATEGORIES.has(category)) continue
78
+ const files = readdirSync(join(skillsRoot, category)).filter(name => name.endsWith('.md')).sort()
79
+ for (const file of files) consider(category, file.replace(/\.md$/, ''))
80
+ }
81
+ return grouped
82
+ }
83
+
84
+ /**
85
+ * Resolve the deduped, ordered skills list (strix `_resolve_skills`).
86
+ * @param options - requested skills and the shape flags.
87
+ */
88
+ export function resolveSkills(options: {
89
+ readonly requested?: readonly string[]
90
+ readonly scanMode: string
91
+ readonly isRoot: boolean
92
+ readonly isWhitebox: boolean
93
+ readonly isDiffScoped: boolean
94
+ }): string[] {
95
+ const ordered: string[] = [...(options.requested ?? [])]
96
+ ordered.push(`scan_modes/${options.scanMode}`)
97
+ if (options.isDiffScoped) ordered.push('scan_modes/diff')
98
+ ordered.push('tooling/agent_browser')
99
+ ordered.push('tooling/python')
100
+ ordered.push('analysis/counterevidence')
101
+ ordered.push('analysis/severity_calibration')
102
+ if (options.isRoot) ordered.push('coordination/root_agent')
103
+ if (options.isWhitebox) {
104
+ ordered.push('coordination/source_aware_whitebox')
105
+ ordered.push('custom/source_aware_sast')
106
+ ordered.push('analysis/source_aware_discovery')
107
+ ordered.push('analysis/fix_verification')
108
+ }
109
+ const deduped: string[] = []
110
+ const seen = new Set<string>()
111
+ for (const skill of ordered) {
112
+ if (skill !== '' && !seen.has(skill)) {
113
+ deduped.push(skill)
114
+ seen.add(skill)
115
+ }
116
+ }
117
+ return deduped
118
+ }
119
+
120
+ /**
121
+ * Load skill bodies keyed by their BARE names (strix `load_skills` returns
122
+ * {'deep': ...}, not {'scan_modes/deep': ...} — the `<skill_name>` tags in
123
+ * the prompt use bare names). Missing skills resolve to ''.
124
+ * @param names - qualified skill names (category/name).
125
+ * @param skillsRoot - the skills tree root.
126
+ */
127
+ export function loadSkills(names: readonly string[], skillsRoot: string): Record<string, string> {
128
+ const content: Record<string, string> = {}
129
+ for (const name of names) {
130
+ const [category, fileName] = name.split('/')
131
+ if (category === undefined || fileName === undefined) {
132
+ content[name] = ''
133
+ continue
134
+ }
135
+ const path = join(skillsRoot, category, `${fileName}.md`)
136
+ if (existsSync(path)) content[fileName] = parseSkillContent(readFileSync(path, 'utf8')).body
137
+ }
138
+ return content
139
+ }
140
+
141
+ /**
142
+ * Validate a requested skill list (strix `validate_requested_skills`).
143
+ * @param skillList - the requested names.
144
+ * @param skillsRoot - the skills tree root.
145
+ * @returns an error message, or null when valid.
146
+ */
147
+ export function validateRequestedSkills(skillList: readonly string[], skillsRoot: string, maxSkills = 5): string | null {
148
+ if (skillList.length > maxSkills) {
149
+ return `Cannot specify more than ${String(maxSkills)} skills per agent; got ${String(skillList.length)}. Aim for 1-3 related skills per specialist.`
150
+ }
151
+ if (skillList.length === 0) return null
152
+ const catalog = getAvailableSkills(skillsRoot)
153
+ const availableNames = new Set(Object.values(catalog).flat().map(entry => entry.name))
154
+ const availableKeys = new Set(Object.entries(catalog).flatMap(([category, entries]) => entries.map(entry => `${category}/${entry.name}`)))
155
+ const invalid = [...new Set(skillList.filter(skill => !availableNames.has(skill) && !availableKeys.has(skill)))].sort()
156
+ if (invalid.length > 0) {
157
+ return `Invalid skill name(s): ${JSON.stringify(invalid)}. Available skills: ${JSON.stringify(Object.values(catalog).flat().map(entry => entry.name).sort())}`
158
+ }
159
+ const ambiguousNames = new Set<string>()
160
+ const nameCounts = new Map<string, number>()
161
+ for (const entries of Object.values(catalog)) {
162
+ for (const entry of entries) nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1)
163
+ }
164
+ for (const [name, count] of nameCounts) {
165
+ if (count > 1) ambiguousNames.add(name)
166
+ }
167
+ const ambiguous = skillList.filter(skill => !skill.includes('/') && ambiguousNames.has(skill)).sort()
168
+ if (ambiguous.length > 0) {
169
+ return `Ambiguous skill name(s): ${JSON.stringify(ambiguous)}. Use category-qualified names from: ${JSON.stringify([...availableKeys].sort())}`
170
+ }
171
+ return null
172
+ }