@ddtcorex/dsh-maestro-review 0.1.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 (110) hide show
  1. package/README.md +53 -0
  2. package/client/settings-card.tsx +4 -0
  3. package/lib/config-store.d.ts +53 -0
  4. package/lib/config-store.d.ts.map +1 -0
  5. package/lib/config-store.js +60 -0
  6. package/lib/config-store.js.map +1 -0
  7. package/lib/events.d.ts +27 -0
  8. package/lib/events.d.ts.map +1 -0
  9. package/lib/events.js +2 -0
  10. package/lib/events.js.map +1 -0
  11. package/lib/gitlab-client.d.ts +14 -0
  12. package/lib/gitlab-client.d.ts.map +1 -0
  13. package/lib/gitlab-client.js +146 -0
  14. package/lib/gitlab-client.js.map +1 -0
  15. package/lib/gitlab-webhook.d.ts +17 -0
  16. package/lib/gitlab-webhook.d.ts.map +1 -0
  17. package/lib/gitlab-webhook.js +144 -0
  18. package/lib/gitlab-webhook.js.map +1 -0
  19. package/lib/govard-tool.d.ts +11 -0
  20. package/lib/govard-tool.d.ts.map +1 -0
  21. package/lib/govard-tool.js +95 -0
  22. package/lib/govard-tool.js.map +1 -0
  23. package/lib/index.d.ts +4 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +15 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/notify.d.ts +23 -0
  28. package/lib/notify.d.ts.map +1 -0
  29. package/lib/notify.js +10 -0
  30. package/lib/notify.js.map +1 -0
  31. package/lib/orchestrator.d.ts +144 -0
  32. package/lib/orchestrator.d.ts.map +1 -0
  33. package/lib/orchestrator.js +655 -0
  34. package/lib/orchestrator.js.map +1 -0
  35. package/lib/pin-store.d.ts +5 -0
  36. package/lib/pin-store.d.ts.map +1 -0
  37. package/lib/pin-store.js +5 -0
  38. package/lib/pin-store.js.map +1 -0
  39. package/lib/providers/github.stub.d.ts +3 -0
  40. package/lib/providers/github.stub.d.ts.map +1 -0
  41. package/lib/providers/github.stub.js +10 -0
  42. package/lib/providers/github.stub.js.map +1 -0
  43. package/lib/providers/gitlab.d.ts +13 -0
  44. package/lib/providers/gitlab.d.ts.map +1 -0
  45. package/lib/providers/gitlab.js +173 -0
  46. package/lib/providers/gitlab.js.map +1 -0
  47. package/lib/providers/interface.d.ts +12 -0
  48. package/lib/providers/interface.d.ts.map +1 -0
  49. package/lib/providers/interface.js +2 -0
  50. package/lib/providers/interface.js.map +1 -0
  51. package/lib/review-findings-tool.d.ts +15 -0
  52. package/lib/review-findings-tool.d.ts.map +1 -0
  53. package/lib/review-findings-tool.js +37 -0
  54. package/lib/review-findings-tool.js.map +1 -0
  55. package/lib/review-history.d.ts +35 -0
  56. package/lib/review-history.d.ts.map +1 -0
  57. package/lib/review-history.js +87 -0
  58. package/lib/review-history.js.map +1 -0
  59. package/lib/review-intake.d.ts +7 -0
  60. package/lib/review-intake.d.ts.map +1 -0
  61. package/lib/review-intake.js +80 -0
  62. package/lib/review-intake.js.map +1 -0
  63. package/lib/review-signals.d.ts +17 -0
  64. package/lib/review-signals.d.ts.map +1 -0
  65. package/lib/review-signals.js +49 -0
  66. package/lib/review-signals.js.map +1 -0
  67. package/lib/secure-compare.d.ts +7 -0
  68. package/lib/secure-compare.d.ts.map +1 -0
  69. package/lib/secure-compare.js +14 -0
  70. package/lib/secure-compare.js.map +1 -0
  71. package/lib/settings-rpc.d.ts +22 -0
  72. package/lib/settings-rpc.d.ts.map +1 -0
  73. package/lib/settings-rpc.js +252 -0
  74. package/lib/settings-rpc.js.map +1 -0
  75. package/lib/skills-tool.d.ts +29 -0
  76. package/lib/skills-tool.d.ts.map +1 -0
  77. package/lib/skills-tool.js +160 -0
  78. package/lib/skills-tool.js.map +1 -0
  79. package/lib/workspace-tool.d.ts +10 -0
  80. package/lib/workspace-tool.d.ts.map +1 -0
  81. package/lib/workspace-tool.js +86 -0
  82. package/lib/workspace-tool.js.map +1 -0
  83. package/package.json +53 -0
  84. package/presets/maestro-auditor/agent.cordis.yml +10 -0
  85. package/presets/maestro-auditor/preset.yml +2 -0
  86. package/presets/maestro-coder/agent.cordis.yml +223 -0
  87. package/presets/maestro-coder/preset.yml +2 -0
  88. package/presets/maestro-reviewer/agent.cordis.yml +14 -0
  89. package/presets/maestro-reviewer/preset.yml +2 -0
  90. package/src/augment.d.ts +47 -0
  91. package/src/config-store.ts +109 -0
  92. package/src/events.ts +25 -0
  93. package/src/gitlab-client.ts +186 -0
  94. package/src/gitlab-webhook.ts +166 -0
  95. package/src/govard-tool.ts +114 -0
  96. package/src/index.ts +15 -0
  97. package/src/notify.ts +30 -0
  98. package/src/orchestrator.ts +740 -0
  99. package/src/pin-store.ts +4 -0
  100. package/src/providers/github.stub.ts +11 -0
  101. package/src/providers/gitlab.ts +190 -0
  102. package/src/providers/interface.ts +2 -0
  103. package/src/review-findings-tool.ts +51 -0
  104. package/src/review-history.ts +112 -0
  105. package/src/review-intake.ts +86 -0
  106. package/src/review-signals.ts +52 -0
  107. package/src/secure-compare.ts +13 -0
  108. package/src/settings-rpc.ts +249 -0
  109. package/src/skills-tool.ts +193 -0
  110. package/src/workspace-tool.ts +90 -0
@@ -0,0 +1,249 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from '@deepseek-ai/dsh-client-connection'
3
+ import type { RpcErrorDetailsMap, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
4
+ import { existsSync, statSync } from 'node:fs'
5
+ import { loadUserConfig, saveUserConfig, type MaestroUserConfig } from './config-store.js'
6
+ import { listReviews } from './review-history.js'
7
+ import { readPin, rotatePin, readLanPin, rotateLanPin } from './pin-store.js'
8
+ import { pinRotationText, type NotifierLike } from './notify.js'
9
+
10
+ export const name = 'maestro-settings-rpc'
11
+ export const inject = ['connection', 'maestroTunnel']
12
+
13
+ /** Keys the Settings card may persist; anything else is a rejected save. */
14
+ const SAVABLE_KEYS = new Set<keyof MaestroUserConfig>([
15
+ 'gitlabBaseUrl', 'gitlabToken', 'botUsername', 'webhookSecret', 'webhookPort',
16
+ 'projectMappings', 'reviewModel', 'autoRereviewOnPush', 'agentTimeoutMs', 'reviewSessionRetentionDays',
17
+ 'tunnelMode', 'quickTarget', 'tunnelId', 'tunnelCredentialsFile', 'tunnelHostname',
18
+ 'proxyPort', 'proxyHost', 'lanPinEnabled', 'telegramBotToken', 'telegramChatId',
19
+ 'telegramReviewNotifications',
20
+ ])
21
+
22
+ function validateReviewModel(value: unknown): string | null {
23
+ if (value === null || value === undefined) return null
24
+ if (typeof value !== 'object' || Array.isArray(value)) return 'reviewModel must be an object.'
25
+ const { provider, model, reasoningEffort } = value as Record<string, unknown>
26
+ if (typeof provider !== 'string' || provider.trim() === '') return 'reviewModel.provider must be a non-empty string.'
27
+ if (typeof model !== 'string' || model.trim() === '') return 'reviewModel.model must be a non-empty string.'
28
+ if (reasoningEffort !== undefined && typeof reasoningEffort !== 'string') return 'reviewModel.reasoningEffort must be a string.'
29
+ if (typeof reasoningEffort === 'string' && reasoningEffort.trim() === '') return 'reviewModel.reasoningEffort must be a non-empty string when provided.'
30
+ return null
31
+ }
32
+
33
+ /** Secrets never returned to the client; the UI learns only their presence. */
34
+ const SECRET_KEYS = ['gitlabToken', 'webhookSecret', 'telegramBotToken'] as const
35
+
36
+ function maskSecrets(config: MaestroUserConfig): MaestroUserConfig & Record<string, boolean> {
37
+ const masked: Record<string, unknown> = { ...config }
38
+ for (const key of SECRET_KEYS) {
39
+ if (typeof config[key] === 'string' && config[key] !== '') {
40
+ delete masked[key]
41
+ masked[`has${key.charAt(0).toUpperCase()}${key.slice(1)}`] = true
42
+ }
43
+ }
44
+ return masked as MaestroUserConfig & Record<string, boolean>
45
+ }
46
+
47
+ /**
48
+ * Validate a save payload from the Settings card. Secrets follow a
49
+ * three-state rule: absent keeps the stored value, `''` clears it, a
50
+ * non-empty string replaces it. A mapping's `localRepoPath` must be an
51
+ * existing directory containing a `.git` entry, because reviews check out
52
+ * worktrees from it.
53
+ */
54
+ function validateSavePayload(payload: unknown): { ok: true; patch: Partial<MaestroUserConfig> } | { ok: false; message: string } {
55
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
56
+ return { ok: false, message: 'Settings payload must be a JSON object.' }
57
+ }
58
+ const patch: Partial<MaestroUserConfig> = {}
59
+ for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
60
+ if (!SAVABLE_KEYS.has(key as keyof MaestroUserConfig)) {
61
+ return { ok: false, message: `Unknown settings key "${key}".` }
62
+ }
63
+ if (key === 'projectMappings') {
64
+ if (!Array.isArray(value)) return { ok: false, message: 'projectMappings must be an array.' }
65
+ for (const mapping of value) {
66
+ if (typeof mapping !== 'object' || mapping === null) return { ok: false, message: 'Each project mapping must be an object.' }
67
+ const { projectPath, localRepoPath, reviewModel } = mapping as Record<string, unknown>
68
+ if (typeof projectPath !== 'string' || projectPath.trim() === '') return { ok: false, message: 'Each mapping needs a non-empty projectPath.' }
69
+ if (typeof localRepoPath !== 'string' || localRepoPath.trim() === '') return { ok: false, message: 'Each mapping needs a non-empty localRepoPath.' }
70
+ if (localRepoPath !== '/' && !(localRepoPath as string).startsWith('/')) {
71
+ return { ok: false, message: `localRepoPath "${String(localRepoPath)}" must be an absolute path.` }
72
+ }
73
+ if (!existsSync(localRepoPath) || !statSync(localRepoPath).isDirectory() || !existsSync(`${localRepoPath}/.git`)) {
74
+ return { ok: false, message: `localRepoPath "${localRepoPath}" is not an existing git repository checkout.` }
75
+ }
76
+ if (reviewModel !== undefined) {
77
+ const err = validateReviewModel(reviewModel)
78
+ if (err !== null) return { ok: false, message: `projectMappings reviewModel: ${err}` }
79
+ }
80
+ }
81
+ }
82
+ if (key === 'reviewModel') {
83
+ if (value === null) {
84
+ patch[key as keyof MaestroUserConfig] = undefined as never
85
+ continue
86
+ }
87
+ const err = validateReviewModel(value)
88
+ if (err !== null) return { ok: false, message: err }
89
+ }
90
+ if (key === 'webhookPort' && value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535)) {
91
+ return { ok: false, message: 'webhookPort must be an integer between 1 and 65535.' }
92
+ }
93
+ if (key === 'agentTimeoutMs' && value !== undefined && (typeof value !== 'number' || value < 1000)) {
94
+ return { ok: false, message: 'agentTimeoutMs must be at least 1000 ms.' }
95
+ }
96
+ if (key === 'reviewSessionRetentionDays' && value !== undefined && (typeof value !== 'number' || value < 0)) {
97
+ return { ok: false, message: 'reviewSessionRetentionDays must be 0 (off) or a positive number of days.' }
98
+ }
99
+ patch[key as keyof MaestroUserConfig] = value as never
100
+ }
101
+ return { ok: true, patch }
102
+ }
103
+
104
+ export const MAESTRO_RPC_CHANNEL = '/dsh-maestro-review'
105
+ export const MAESTRO_ENDPOINTS = Object.freeze({
106
+ status: 'maestro.status',
107
+ getConfig: 'maestro.getConfig',
108
+ saveConfig: 'maestro.saveConfig',
109
+ tunnelStart: 'maestro.tunnelStart',
110
+ tunnelStop: 'maestro.tunnelStop',
111
+ proxyStatus: 'maestro.proxyStatus',
112
+ getPin: 'maestro.getPin',
113
+ rotatePin: 'maestro.rotatePin',
114
+ lanPinStatus: 'maestro.lanPin.status',
115
+ lanPinSetEnabled: 'maestro.lanPin.setEnabled',
116
+ lanPinRotate: 'maestro.lanPin.rotate',
117
+ reviewsList: 'maestro.reviews.list',
118
+ modelsList: 'maestro.models.list',
119
+ modelsCurrent: 'maestro.models.current',
120
+ })
121
+
122
+ function ok<T>(value: T): RpcResult<T> {
123
+ return { ok: true, value }
124
+ }
125
+
126
+ function fail(message: string): RpcResult<never> {
127
+ return {
128
+ ok: false,
129
+ error: {
130
+ code: 'bad-request',
131
+ message,
132
+ // Synthetic details, not real Zod validation output: `bad-request`'s real `details`
133
+ // shape is `{ issues: ZodIssue[] }` from the `zod` package (a specific discriminated
134
+ // union), but this is an app-level "unknown endpoint" error being shoehorned into
135
+ // DSH's shared RPC error taxonomy, not an actual Zod-validated payload. Constructing
136
+ // a byte-perfect ZodIssue here would be disproportionate, so we cast this one value
137
+ // to the narrow per-code details type (not the wider `RpcError['details']` union,
138
+ // which doesn't satisfy the 'bad-request' discriminant on its own).
139
+ details: { issues: [{ message }] } as RpcErrorDetailsMap['bad-request'],
140
+ },
141
+ }
142
+ }
143
+
144
+ export function apply(ctx: Context): void {
145
+ const handler = async (endpoint: string, payload: unknown) => {
146
+ if (endpoint === MAESTRO_ENDPOINTS.status) {
147
+ return ok(ctx.maestroTunnel.status())
148
+ }
149
+ if (endpoint === MAESTRO_ENDPOINTS.getConfig) {
150
+ return ok(maskSecrets(await loadUserConfig()))
151
+ }
152
+ if (endpoint === MAESTRO_ENDPOINTS.saveConfig) {
153
+ const validated = validateSavePayload(payload)
154
+ if (!validated.ok) return fail(validated.message)
155
+ const merged = await saveUserConfig(validated.patch)
156
+ return ok(maskSecrets(merged))
157
+ }
158
+ if (endpoint === MAESTRO_ENDPOINTS.tunnelStart) {
159
+ return ok(await ctx.maestroTunnel.start())
160
+ }
161
+ if (endpoint === MAESTRO_ENDPOINTS.tunnelStop) {
162
+ return ok(await ctx.maestroTunnel.stop())
163
+ }
164
+ if (endpoint === MAESTRO_ENDPOINTS.proxyStatus) {
165
+ return ok(ctx.maestroTunnel.proxyStatus())
166
+ }
167
+ if (endpoint === MAESTRO_ENDPOINTS.getPin) {
168
+ return ok({ pin: await readPin() })
169
+ }
170
+ if (endpoint === MAESTRO_ENDPOINTS.rotatePin) {
171
+ const pin = await rotatePin()
172
+ // Delivery is deliberately detached: a slow/unavailable notifier cannot make the
173
+ // explicit security operation appear to fail or hold the Settings UI open.
174
+ void loadUserConfig().then((config) => {
175
+ const notifier = ctx.get?.('maestroNotifier') as NotifierLike | undefined
176
+ if (notifier === undefined) return undefined
177
+ return notifier.send(
178
+ 'telegram',
179
+ { botToken: config.telegramBotToken, chatId: config.telegramChatId },
180
+ { text: pinRotationText(pin) },
181
+ )
182
+ }).then((delivery) => {
183
+ if (!delivery) return
184
+ if (delivery.sent) {
185
+ ctx.logger?.info?.('maestro-telegram: PIN rotation notification delivered')
186
+ } else if (delivery.reason === 'request-failed') {
187
+ ctx.logger?.warn?.('maestro-telegram: PIN rotation notification failed')
188
+ }
189
+ }).catch(() => {
190
+ ctx.logger?.warn?.('maestro-telegram: PIN rotation notification failed')
191
+ })
192
+ return ok({ pin })
193
+ }
194
+ if (endpoint === MAESTRO_ENDPOINTS.lanPinStatus) {
195
+ const config = await loadUserConfig()
196
+ // Disabled by default; the LAN PIN is only read (and generated) once the
197
+ // user opts in, so an untouched install keeps LAN access open.
198
+ if (config.lanPinEnabled !== true) return ok({ enabled: false })
199
+ return ok({ enabled: true, pin: await readLanPin() })
200
+ }
201
+ if (endpoint === MAESTRO_ENDPOINTS.lanPinSetEnabled) {
202
+ const enabled = (payload as { enabled?: unknown } | undefined)?.enabled === true
203
+ await saveUserConfig({ lanPinEnabled: enabled })
204
+ // The proxy reads lanPinEnabled at boot; a reload applies the new gate
205
+ // without waiting for a harness restart.
206
+ await ctx.maestroTunnel.reloadConfig()
207
+ return ok({ enabled })
208
+ }
209
+ if (endpoint === MAESTRO_ENDPOINTS.lanPinRotate) {
210
+ return ok({ pin: await rotateLanPin() })
211
+ }
212
+ if (endpoint === MAESTRO_ENDPOINTS.reviewsList) {
213
+ return ok(await listReviews(20))
214
+ }
215
+ if (endpoint === MAESTRO_ENDPOINTS.modelsCurrent) {
216
+ const agentDefaultModel = ctx.get('agentDefaultModel') as { currentSelection(): { provider: string; model: string; reasoningEffort?: string } } | undefined
217
+ const fallback = agentDefaultModel?.currentSelection() ?? { provider: 'deepseek-official', model: 'deepseek-chat' }
218
+ return ok(fallback)
219
+ }
220
+ if (endpoint === MAESTRO_ENDPOINTS.modelsList) {
221
+ const agentDefaultModel = ctx.get('agentDefaultModel') as { currentSelection(): { provider: string; model: string; reasoningEffort?: string } } | undefined
222
+ const fallbackSelection = agentDefaultModel?.currentSelection() ?? { provider: 'deepseek-official', model: 'deepseek-chat' }
223
+ const llm = ctx.get('llm') as { listProviders?: () => Array<{ id: string; name: string }>; listModels?: (provider: string) => Promise<Array<{ id: string; name?: string }>> } | undefined
224
+ if (llm?.listProviders !== undefined && llm?.listModels !== undefined) {
225
+ try {
226
+ const providers = llm.listProviders()
227
+ const groups: Array<{ provider: string; name: string; models: string[] }> = []
228
+ for (const p of providers) {
229
+ try {
230
+ const models = await llm.listModels(p.id)
231
+ groups.push({ provider: p.id, name: p.name, models: models.map(m => m.id) })
232
+ } catch {
233
+ groups.push({ provider: p.id, name: p.name, models: [] })
234
+ }
235
+ }
236
+ if (groups.length > 0) return ok({ groups, current: fallbackSelection })
237
+ } catch {
238
+ // fall through to fallback
239
+ }
240
+ }
241
+ // Fallback: single group containing the current selection so dropdown still works
242
+ return ok({ groups: [{ provider: fallbackSelection.provider, name: fallbackSelection.provider, models: [fallbackSelection.model] }], current: fallbackSelection })
243
+ }
244
+ return fail(`Unknown endpoint: ${endpoint}`)
245
+ }
246
+ const disposeRpc = ctx.connection.rpc.handle(MAESTRO_RPC_CHANNEL, handler, { authority: 'loopback' })
247
+
248
+ ctx.effect(() => () => { disposeRpc(); }, 'maestro-settings-rpc teardown')
249
+ }
@@ -0,0 +1,193 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import type {} from '@deepseek-ai/dsh-agent'
4
+
5
+ export const name = 'maestro-skills-tool'
6
+ export const inject = ['skills', 'tools']
7
+
8
+ export type ReviewSkillProfile = 'magento2' | 'generic'
9
+
10
+ /**
11
+ * Exact skill names rather than a fuzzy search: a review must not silently
12
+ * omit a security or quality pass just because a similarly named skill exists.
13
+ */
14
+ export const REVIEW_PROFILE_SKILLS: Record<ReviewSkillProfile, readonly string[]> = {
15
+ magento2: [
16
+ 'govard-toolbox',
17
+ 'govard-magento',
18
+ 'magento2-dev-core',
19
+ 'magento2-code-review',
20
+ 'magento2-linter',
21
+ 'magento2-security-scan',
22
+ 'magento2-performance-audit',
23
+ ],
24
+ /** Diff review against general best practices; no project skill set required. */
25
+ generic: [],
26
+ }
27
+
28
+ export const MAESTRO_SKILLS_INSTALL_COMMAND = 'curl -fsSL https://raw.githubusercontent.com/ddtcorex/maestro-skills/master/install.sh | bash -s -- --scope personal --target dsh --skills govard-toolbox,govard-magento,magento2-dev-core,magento2-code-review,magento2-linter,magento2-security-scan,magento2-performance-audit -y'
29
+
30
+ // This is deliberately process-local and keyed by the reviewer Agent object,
31
+ // which every Cordis child context inherits. Preset mounting inserts child
32
+ // contexts, so keying on the plugin's context would make a successful load
33
+ // invisible to the orchestrator's agent context.
34
+ const loadedReviewProfiles = new WeakMap<object, ReviewSkillProfile>()
35
+
36
+ export function loadedReviewProfile(ctx: Context): ReviewSkillProfile | undefined {
37
+ return ctx.agent === undefined ? undefined : loadedReviewProfiles.get(ctx.agent)
38
+ }
39
+
40
+ // Mirrors @deepseek-ai/dsh-skill's consumer-facing `SkillRegistry` (verified
41
+ // against ../../deepseek-harness/packages/skill/skill/lib/types/index.d.ts):
42
+ // `list()` returns bare `SkillSummary`-shaped entries (no `locator`/`rank` —
43
+ // those belong only to the `SkillProvider` interface a provider implements
44
+ // inside `registerProvider()`), and `get()` takes a plain name string, not a
45
+ // candidate object.
46
+ interface SkillSummaryLike {
47
+ name: string
48
+ description: string
49
+ }
50
+
51
+ interface SkillDefinitionLike {
52
+ name: string
53
+ description: string
54
+ content: string
55
+ }
56
+
57
+ interface SkillsServiceLike {
58
+ list(): Promise<SkillSummaryLike[]>
59
+ get(name: string): Promise<SkillDefinitionLike | undefined>
60
+ }
61
+
62
+ /** Single owned access path to the skills service (declared via `inject`). */
63
+ export function skillsService(ctx: Context): SkillsServiceLike {
64
+ return (ctx as unknown as { skills: SkillsServiceLike }).skills
65
+ }
66
+
67
+ export function apply(ctx: Context): void {
68
+ ctx.tools.register(defineTool({
69
+ name: 'maestro_get_skills',
70
+ description: 'Search the DSH skill knowledge base by keyword (matches skill name or description) and return the full content of every matching skill.',
71
+ parameters: {
72
+ query: { type: 'string', required: true, description: 'Keyword to match against skill name/description, e.g. "magento2" or "govard-laravel".' },
73
+ },
74
+ output: {
75
+ // The resolved skill content is part of the validated schema (matching
76
+ // dsh-tools' own `job_output` pattern of declaring every render-needed
77
+ // field on `output.schema`) rather than smuggled past validation on a
78
+ // key excluded from `properties`: `render(args, value)` has no third
79
+ // `extra` argument (verified against
80
+ // node_modules/@deepseek-ai/dsh-tools/lib/types/schema.d.ts), and an
81
+ // `additionalProperties: false` object schema REJECTS undeclared keys
82
+ // outright rather than merely leaving them unvalidated, so a hidden
83
+ // field would fail every call at the registry's output-validation step.
84
+ schema: {
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ properties: {
88
+ matches: { type: 'number', required: true },
89
+ skills: {
90
+ type: 'array',
91
+ required: true,
92
+ items: {
93
+ type: 'object',
94
+ additionalProperties: false,
95
+ properties: {
96
+ name: { type: 'string', required: true },
97
+ content: { type: 'string', required: true },
98
+ },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ render: (_args, value) => {
104
+ if (value.skills.length === 0) {
105
+ return [{ type: 'text', text: 'No skills matched that query.' }]
106
+ }
107
+ const text = value.skills
108
+ .map(s => `## ${s.name}\n\n${s.content}`)
109
+ .join('\n\n---\n\n')
110
+ return [{ type: 'text', text }]
111
+ },
112
+ },
113
+ async execute(args) {
114
+ const skills = skillsService(ctx)
115
+ const summaries = await skills.list()
116
+ const query = args.query.toLowerCase()
117
+ const matched = summaries.filter(s =>
118
+ s.name.toLowerCase().includes(query) || s.description.toLowerCase().includes(query))
119
+ if (matched.length === 0) {
120
+ throw new Error(`No skill matched query "${args.query}".`)
121
+ }
122
+ const resolved: SkillDefinitionLike[] = []
123
+ for (const summary of matched) {
124
+ const def = await skills.get(summary.name)
125
+ if (def !== undefined) resolved.push(def)
126
+ }
127
+ return {
128
+ matches: resolved.length,
129
+ skills: resolved.map(s => ({ name: s.name, content: s.content })),
130
+ }
131
+ },
132
+ }))
133
+
134
+ ctx.tools.register(defineTool({
135
+ name: 'maestro_load_review_profile',
136
+ description: 'Load the complete, exact skill set required for a configured review profile. Fails if any required skill is unavailable.',
137
+ parameters: {
138
+ profile: { type: 'string', required: true, description: 'Review profile configured for the project. Supported: "magento2" (full Magento skill set), "generic" (no skills required).' },
139
+ },
140
+ output: {
141
+ schema: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: {
145
+ profile: { type: 'string', required: true },
146
+ skills: {
147
+ type: 'array',
148
+ required: true,
149
+ items: {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ properties: {
153
+ name: { type: 'string', required: true },
154
+ content: { type: 'string', required: true },
155
+ },
156
+ },
157
+ },
158
+ },
159
+ },
160
+ render: (_args, value) => [{
161
+ type: 'text',
162
+ text: value.skills.length === 0
163
+ ? `Review profile "${value.profile}" loaded. No project-specific skills are required; review the diff against general correctness, security, and performance best practices.`
164
+ : value.skills.map(s => `## ${s.name}\n\n${s.content}`).join('\n\n---\n\n'),
165
+ }],
166
+ },
167
+ async execute(args, exec) {
168
+ const required = REVIEW_PROFILE_SKILLS[args.profile as ReviewSkillProfile]
169
+ if (required === undefined) {
170
+ throw new Error(`Unsupported review skill profile "${args.profile}". Supported profiles: ${Object.keys(REVIEW_PROFILE_SKILLS).map(name => `"${name}"`).join(', ')}.`)
171
+ }
172
+ if (exec.agent === undefined) {
173
+ throw new Error('Review profile loader is not running inside an agent context.')
174
+ }
175
+ const skills = skillsService(ctx)
176
+ const available = new Set((await skills.list()).map(skill => skill.name))
177
+ const missing = required.filter(skill => !available.has(skill))
178
+ if (missing.length > 0) {
179
+ throw new Error(`Missing required review skill(s) for profile "${args.profile}": ${missing.join(', ')}. Install maestro-skills for DSH with: ${MAESTRO_SKILLS_INSTALL_COMMAND}`)
180
+ }
181
+ const resolved = await Promise.all(required.map(skill => skills.get(skill)))
182
+ const unavailable = required.filter((_, index) => resolved[index] === undefined)
183
+ if (unavailable.length > 0) {
184
+ throw new Error(`Required review skill(s) could not be loaded: ${unavailable.join(', ')}. Reinstall maestro-skills for DSH with: ${MAESTRO_SKILLS_INSTALL_COMMAND}`)
185
+ }
186
+ loadedReviewProfiles.set(exec.agent, args.profile as ReviewSkillProfile)
187
+ return {
188
+ profile: args.profile,
189
+ skills: resolved.map(skill => ({ name: skill!.name, content: skill!.content })),
190
+ }
191
+ },
192
+ }))
193
+ }
@@ -0,0 +1,90 @@
1
+ import { basename, dirname, resolve, sep } from 'node:path'
2
+ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import z from '@deepseek-ai/schemastery'
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+
7
+ export const name = 'maestro-workspace-tool'
8
+ export const inject = ['tools']
9
+
10
+ export interface Config {
11
+ rootPath?: string
12
+ }
13
+
14
+ export const Config: z<Config> = z.object({
15
+ rootPath: z.string(),
16
+ })
17
+
18
+ async function resolveInRoot(rootPath: string, requestedPath: string): Promise<string | undefined> {
19
+ const absoluteRoot = resolve(rootPath)
20
+ const target = resolve(absoluteRoot, requestedPath)
21
+ if (target !== absoluteRoot && !target.startsWith(absoluteRoot + sep)) return undefined
22
+
23
+ let realRoot: string
24
+ try {
25
+ realRoot = await realpath(absoluteRoot)
26
+ } catch {
27
+ return undefined
28
+ }
29
+
30
+ let existing = target
31
+ const pending: string[] = []
32
+ while (true) {
33
+ let real: string
34
+ try {
35
+ real = await realpath(existing)
36
+ } catch (err) {
37
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined
38
+ const parent = dirname(existing)
39
+ if (parent === existing) return undefined
40
+ pending.unshift(basename(existing))
41
+ existing = parent
42
+ continue
43
+ }
44
+ const realTarget = pending.length > 0 ? resolve(real, ...pending) : real
45
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) return undefined
46
+ return target
47
+ }
48
+ }
49
+
50
+ export function apply(ctx: Context, config: Config = {}): void {
51
+ const rootPath = config.rootPath ?? process.cwd()
52
+
53
+ ctx.tools.register(defineTool({
54
+ name: 'maestro_read_file',
55
+ description: 'Read a text file relative to the current workspace root.',
56
+ parameters: {
57
+ path: { type: 'string', required: true, description: 'Path relative to the workspace root.' },
58
+ },
59
+ output: {
60
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
61
+ render: (_args, value) => [{ type: 'text', text: value.text }],
62
+ },
63
+ async execute(args) {
64
+ const target = await resolveInRoot(rootPath, args.path)
65
+ if (target === undefined) return { isError: true, text: `Path "${args.path}" escapes the workspace root.` } as never
66
+ const text = await readFile(target, 'utf-8')
67
+ return { text }
68
+ },
69
+ }))
70
+
71
+ ctx.tools.register(defineTool({
72
+ name: 'maestro_write_file',
73
+ description: 'Write a text file relative to the current workspace root, creating parent directories as needed.',
74
+ parameters: {
75
+ path: { type: 'string', required: true, description: 'Path relative to the workspace root.' },
76
+ content: { type: 'string', required: true, description: 'File content to write.' },
77
+ },
78
+ output: {
79
+ schema: { type: 'object', additionalProperties: false, properties: { written: { type: 'boolean', required: true } } },
80
+ render: (args) => [{ type: 'text', text: `Wrote ${args.path}` }],
81
+ },
82
+ async execute(args) {
83
+ const target = await resolveInRoot(rootPath, args.path)
84
+ if (target === undefined) return { isError: true, text: `Path "${args.path}" escapes the workspace root.` } as never
85
+ await mkdir(dirname(target), { recursive: true })
86
+ await writeFile(target, args.content, 'utf-8')
87
+ return { written: true }
88
+ },
89
+ }))
90
+ }