@goodandready/dsh-github-ops 0.1.1 → 0.2.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.
package/lib/github.js CHANGED
@@ -96,7 +96,10 @@ export function createGitHubClient({
96
96
  userAgent = DEFAULT_USER_AGENT,
97
97
  fetchImpl = globalThis.fetch,
98
98
  sleepImpl,
99
+ signalProvider,
99
100
  timeoutMs = 30000,
101
+ cacheTtlMs = 60000,
102
+ cacheLimit = 200,
100
103
  maxRetries = 2,
101
104
  retryBaseMs = 500,
102
105
  maxRetryDelayMs = 10000,
@@ -108,7 +111,30 @@ export function createGitHubClient({
108
111
  ? sleepImpl
109
112
  : (ms) => new Promise((resolve) => setTimeout(resolve, ms))
110
113
 
111
- async function attemptOnce({ method, path, query, body, url, accept, redirect }) {
114
+ // A short TTL cache for reads only: a composed report or a repeated list must not spend
115
+ // the rate limit twice. Writes always go to the network.
116
+ let lastScopesValue = ''
117
+ const cache = new Map()
118
+ const cacheKey = (method, target) => `${method} ${target}`
119
+ function cacheGet(key) {
120
+ const hit = cache.get(key)
121
+ if (!hit) return null
122
+ if (hit.expiresAt <= Date.now()) {
123
+ cache.delete(key)
124
+ return null
125
+ }
126
+ return hit.value
127
+ }
128
+ function cacheSet(key, value) {
129
+ if (!(cacheTtlMs > 0)) return
130
+ if (cache.size >= cacheLimit) {
131
+ const oldest = cache.keys().next().value
132
+ cache.delete(oldest)
133
+ }
134
+ cache.set(key, { value, expiresAt: Date.now() + cacheTtlMs })
135
+ }
136
+
137
+ async function attemptOnce({ method, path, query, body, url, accept, redirect, signal }) {
112
138
  const target = url || buildUrl(baseUrl, path, query)
113
139
  const headers = {
114
140
  accept,
@@ -122,6 +148,16 @@ export function createGitHubClient({
122
148
  const timer = controller && timeoutMs > 0
123
149
  ? setTimeout(() => controller.abort(), timeoutMs)
124
150
  : null
151
+ // The caller's signal (a cancelled turn) must abort the request too, not just the clock.
152
+ const combined = (() => {
153
+ if (!controller) return signal
154
+ if (!signal) return controller.signal
155
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
156
+ return AbortSignal.any([controller.signal, signal])
157
+ }
158
+ signal.addEventListener('abort', () => controller.abort(), { once: true })
159
+ return controller.signal
160
+ })()
125
161
 
126
162
  let response
127
163
  try {
@@ -129,19 +165,29 @@ export function createGitHubClient({
129
165
  method,
130
166
  headers,
131
167
  body: body === undefined ? undefined : JSON.stringify(body),
132
- signal: controller ? controller.signal : undefined,
168
+ signal: combined || undefined,
133
169
  ...(redirect ? { redirect } : {}),
134
170
  })
135
171
  } catch (err) {
136
172
  if (timer) clearTimeout(timer)
137
173
  const aborted = err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')
138
- throw new GitHubError(aborted ? `request timed out after ${timeoutMs}ms` : String((err && err.message) || err), {
139
- code: aborted ? 'timeout' : 'network',
140
- })
174
+ // A caller cancellation (the turn was stopped) is not a timeout: saying "timed out"
175
+ // there sends the model looking for a network problem that does not exist.
176
+ const cancelled = aborted && signal && signal.aborted
177
+ const message = cancelled
178
+ ? 'request cancelled by the caller'
179
+ : aborted
180
+ ? `request timed out after ${timeoutMs}ms`
181
+ : String((err && err.message) || err)
182
+ throw new GitHubError(message, { code: cancelled ? 'cancelled' : aborted ? 'timeout' : 'network' })
141
183
  }
142
184
  if (timer) clearTimeout(timer)
143
185
 
144
186
  const rateLimit = rateLimitFrom(response.headers)
187
+ if (typeof response.headers.get === 'function') {
188
+ const scopes = response.headers.get('x-oauth-scopes')
189
+ if (scopes) lastScopesValue = String(scopes)
190
+ }
145
191
  const text = typeof response.text === 'function' ? await response.text() : ''
146
192
  let payload = null
147
193
  if (text) {
@@ -154,16 +200,25 @@ export function createGitHubClient({
154
200
  return { response, payload, rateLimit }
155
201
  }
156
202
 
157
- async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect }) {
203
+ async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect, signal }) {
204
+ // The signal may come from the caller's turn: the shared client asks the provider, so a
205
+ // cancelled turn aborts the request in flight instead of waiting for the timeout.
206
+ const turnSignal = signal || (typeof signalProvider === 'function' ? signalProvider() : undefined)
158
207
  const upper = method.toUpperCase()
159
208
  const retryable = SAFE_RETRY_METHODS.has(upper)
209
+ const target = url || buildUrl(baseUrl, path, query)
210
+ const key = cacheKey(upper, target)
211
+ if (retryable) {
212
+ const cached = cacheGet(key)
213
+ if (cached) return { ...cached, cached: true }
214
+ }
160
215
  const attemptsAllowed = Math.max(1, Number(maxRetries) + 1)
161
216
  let lastError = null
162
217
 
163
218
  for (let attempt = 1; attempt <= attemptsAllowed; attempt += 1) {
164
219
  let result
165
220
  try {
166
- result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect })
221
+ result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect, signal: turnSignal })
167
222
  } catch (err) {
168
223
  lastError = err
169
224
  if (!retryable || attempt >= attemptsAllowed) {
@@ -176,7 +231,9 @@ export function createGitHubClient({
176
231
 
177
232
  const { response, payload, rateLimit } = result
178
233
  if (response.ok) {
179
- return { data: payload, status: response.status, rateLimit, attempts: attempt }
234
+ const value = { data: payload, status: response.status, rateLimit, attempts: attempt }
235
+ if (retryable) cacheSet(key, value)
236
+ return value
180
237
  }
181
238
 
182
239
  const message = (payload && (payload.message || payload.error)) || `HTTP ${response.status}`
@@ -210,6 +267,21 @@ export function createGitHubClient({
210
267
  return call({ method: 'GET', path, ...(opts || {}) })
211
268
  },
212
269
 
270
+ /** The OAuth scopes GitHub reported on the last answer, when it reported any. */
271
+ lastScopes() {
272
+ return lastScopesValue
273
+ },
274
+
275
+ /** Cache counters, for the settings card and for tests. */
276
+ cacheStats() {
277
+ return { size: cache.size, ttlMs: cacheTtlMs }
278
+ },
279
+
280
+ /** Drop the cache (a write may have invalidated what a read returned). */
281
+ cacheClear() {
282
+ cache.clear()
283
+ },
284
+
213
285
  post(path, body, opts) {
214
286
  return call({ method: 'POST', path, body, ...(opts || {}) })
215
287
  },
@@ -298,6 +370,28 @@ export function parseRepoSpec(spec) {
298
370
  return { owner: match[1], repo: match[2], number: match[3] ? Number(match[3]) : null }
299
371
  }
300
372
 
373
+ /**
374
+ * What a human should try next for a given failure. An error that only says "HTTP 401" makes
375
+ * the model guess; a named next step makes it act.
376
+ */
377
+ export function hintFor(err) {
378
+ if (!err) return ''
379
+ const status = err.status || 0
380
+ if (status === 401) return 'the access value is missing or expired: check the credential, the environment variable or `gh auth status`'
381
+ if (status === 403 && err.rateLimit && err.rateLimit.remaining === 0) {
382
+ return `the rate limit is spent and resets at ${err.rateLimit.resetAt || 'an unknown time'}: wait, or use a token with a higher limit`
383
+ }
384
+ if (status === 403) return 'the access value lacks the scope this call needs'
385
+ if (status === 404) return 'the repository, tag, issue or release does not exist, or the access value cannot see it (a draft release is only visible by id)'
386
+ if (status === 422) return 'GitHub rejected the payload: check the required fields'
387
+ if (status === 409) return 'the state changed under us (a conflict): read the object again and retry'
388
+ if (err.code === 'cancelled') return 'the turn was cancelled before GitHub answered'
389
+ if (err.code === 'timeout') return 'the request timed out: raise timeoutMs or retry'
390
+ if (err.code === 'network') return 'the network failed: retry, and check whether github.com resolves — a wrong /etc/hosts entry for github.com or api.github.com breaks every call'
391
+ if (err.code === 'rate_limited') return 'the rate limit is spent: wait for the reset before retrying'
392
+ return ''
393
+ }
394
+
301
395
  /** Human-readable one-liner for a failure, used by every tool renderer. */
302
396
  export function describeError(err) {
303
397
  if (!err) return 'unknown error'
@@ -309,7 +403,9 @@ export function describeError(err) {
309
403
  if (err.rateLimit && err.rateLimit.remaining === 0) {
310
404
  parts.push(`rate limit exhausted, resets at ${err.rateLimit.resetAt || 'unknown'}`)
311
405
  }
312
- return parts.length ? `${err.message} (${parts.join(', ')})` : err.message
406
+ const base = parts.length ? `${err.message} (${parts.join(', ')})` : err.message
407
+ const hint = hintFor(err)
408
+ return hint ? `${base} — ${hint}` : base
313
409
  }
314
410
  return String(err.message || err)
315
411
  }
@@ -0,0 +1,26 @@
1
+ // What the model is told about this plugin.
2
+ //
3
+ // A plugin with 50+ tools needs one short paragraph about how to use them, otherwise the
4
+ // model guesses: which tool for which question, when a repository argument may be omitted,
5
+ // and that approvals are the host's business rather than something these tools invent.
6
+
7
+ /** Section name in the system prompt; unique, so a reload replaces it instead of clashing. */
8
+ export const GUIDANCE_SECTION = 'dsh-github-ops'
9
+
10
+ /** Placement among the other sections. */
11
+ export const GUIDANCE_ORDER = 60
12
+
13
+ /** The default text. Keep it short: it is paid for on every turn. */
14
+ export const GUIDANCE_TEXT = [
15
+ 'GitHub (dsh-github-ops):',
16
+ '- Unsure which tool fits? gh_help lists every one of them, grouped by area.',
17
+ '- Inside a checkout the repository argument can be omitted: the origin remote decides. Both "owner/repo#12" and a bare "12" are accepted.',
18
+ '- Prefer one composed call over many plain ones: gh_repo_report (what a repository is), gh_weekly_digest (what happened lately), gh_repo_health (what to fix), gh_review (a pull request as one pack), ci_run (a deterministic verdict).',
19
+ '- Reads are free. Deleting a file, a branch, a tag, a release, a secret or a ruleset, forcing a ref, and publishing a mirror need confirm: true; approvals themselves belong to the host, not to these tools.',
20
+ '- A failure states the next step; gh_auth_status says where the access comes from.',
21
+ ].join('\n')
22
+
23
+ /** The text to register: the configured override, or the default. */
24
+ export function guidanceText(configured) {
25
+ return configured && String(configured).trim() ? String(configured).trim() : GUIDANCE_TEXT
26
+ }
package/lib/index.js CHANGED
@@ -21,16 +21,25 @@ import { presentationFor } from './present.js'
21
21
  import { createRecords, startReviewJob } from './jobs.js'
22
22
  import { registerCommands } from './commands.js'
23
23
  import { registerJobTools } from './tools/register-jobs.js'
24
- import { registerApprovalGate, ALL_ACTIONS } from './approval.js'
25
24
  import { registerCoreTools } from './tools/register-core.js'
26
25
  import { registerCollabTools } from './tools/register-collab.js'
27
26
  import { registerMirrorTools } from './tools/register-mirror.js'
28
27
  import { registerOpsTools } from './tools/register-ops.js'
28
+ import { registerInsightTools } from './tools/register-insights.js'
29
+ import { registerWriteTools } from './tools/register-write.js'
30
+ import { accessFilePath, readAccessFile } from './auth-device.js'
31
+ import { DEFAULT_CLIENT_ID, DEFAULT_SCOPE } from './auth-device.js'
32
+ import { GUIDANCE_SECTION, GUIDANCE_ORDER, guidanceText } from './guidance.js'
33
+ import { registerRoutes, STATUS_PATH, SCM_PATH, PANEL_PATH } from './routes.js'
34
+ import { AsyncLocalStorage } from 'node:async_hooks'
29
35
  import { execFile } from 'node:child_process'
30
36
  import { promisify } from 'node:util'
31
37
 
32
38
  const execFileAsync = promisify(execFile)
33
39
 
40
+ // Carries the current tool call's cancellation signal to the shared client.
41
+ const callContext = new AsyncLocalStorage()
42
+
34
43
  /** git runner: never throws, always answers with a code and both streams. */
35
44
  function makeGitRunner() {
36
45
  return async (args, { cwd, env } = {}) => {
@@ -65,10 +74,32 @@ export const OUTPUT_SCHEMA = {
65
74
  },
66
75
  }
67
76
 
68
- const MAX_RENDER_CHARS = 20000
77
+ /** Which area a tool belongs to, derived from its name so a new tool lands somewhere sane. */
78
+ export function groupOf(toolName) {
79
+ const name = String(toolName || '')
80
+ const rules = [
81
+ [/^gh_release_/, 'releases'],
82
+ [/^gh_tag_/, 'tags'],
83
+ [/^gh_run_/, 'runs'],
84
+ [/^gh_secret_/, 'secrets'],
85
+ [/^gh_variable_/, 'variables'],
86
+ [/^gh_ruleset_/, 'rulesets'],
87
+ [/^gh_branch_protection_/, 'branch-protection'],
88
+ [/^gh_mirror_/, 'mirror'],
89
+ [/^gh_review_job|^gh_review$|^review_post$|^ci_run$|^gh_checks$|^pr_/, 'pull-requests'],
90
+ [/^issue_|^gh_issue$|^gh_search$/, 'issues'],
91
+ [/^gh_repo_report|^gh_weekly_digest|^gh_notifications|^gh_repo_health|^gh_compare|^gh_trending|^gh_contributors|^gh_user_repos|^gh_commits/, 'insights'],
92
+ [/^gh_repo|^gh_file/, 'repositories'],
93
+ [/^gh_push_|^gh_upload_|^gh_delete_/, 'write-files'],
94
+ [/^gh_auth_/, 'auth'],
95
+ [/^gh_api$/, 'api'],
96
+ [/^gh_help$/, 'meta'],
97
+ ]
98
+ for (const [pattern, group] of rules) if (pattern.test(name)) return group
99
+ return 'other'
100
+ }
69
101
 
70
- /** Actions this plugin can be asked to perform; exposed for settings validation. */
71
- export const KNOWN_ACTIONS = ALL_ACTIONS
102
+ const MAX_RENDER_CHARS = 20000
72
103
 
73
104
  /** Render a tool result for the model: readable text, bounded so one call cannot flood the turn. */
74
105
  export function renderToolResult(toolName, value) {
@@ -108,12 +139,22 @@ export const Config = Schema.object({
108
139
  .description('Retries for a read (GET/HEAD) on a network failure, a timeout or a 5xx. Writes are never retried automatically.'),
109
140
  reviewRulesJson: Schema.string().default('')
110
141
  .description('Optional review-rule overrides as JSON: sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines.'),
111
- approvalMode: Schema.string().default('auto')
112
- .description('"auto" (default): an agreed, non-destructive write goes through without a prompt, while deleting a release, tag, secret, variable, ruleset, branch protection or cancelling a run always asks. "ask": every write asks. "off": the plugin has no opinion and the host approval contour decides.'),
113
- allowedActions: Schema.array(Schema.string()).default([])
114
- .description('Write actions this plugin may perform; anything else is denied. Empty means every known action is allowed, and each one still asks for confirmation.'),
115
- autoApprove: Schema.array(Schema.string()).default([])
116
- .description('Actions an unattended run (DSH_GITHUB_OPS_UNATTENDED=1) may perform without asking. Destructive actions are never auto-approved.'),
142
+ oauthClientId: Schema.string().default(DEFAULT_CLIENT_ID)
143
+ .description('OAuth application id used by gh_auth_login. The default is the public GitHub CLI application.'),
144
+ oauthScope: Schema.string().default(DEFAULT_SCOPE)
145
+ .description('OAuth scope requested by gh_auth_login.'),
146
+ oauthClientSecretRef: Schema.string().role('credential-ref').default('')
147
+ .description('Optional credential holding the OAuth client secret, for apps that require one.'),
148
+ accessFile: Schema.string().default('')
149
+ .description('Where gh_auth_login stores its sign-in (default: $DSH_HOME/github-ops-auth.json, mode 0600).'),
150
+ interceptLinks: Schema.boolean().default(false)
151
+ .description('Let a github.com link clicked in the conversation also select that repository for the Source control tab and the sidebar panel. Off by default: the link always opens in the browser either way.'),
152
+ guidance: Schema.boolean().default(true)
153
+ .description('Add a short paragraph about this plugin to the system prompt: which tool for which question, when the repository argument can be omitted, and that approvals belong to the host.'),
154
+ guidanceText: Schema.string().default('')
155
+ .description('Replace the default guidance paragraph with your own text.'),
156
+ cacheTtlMs: Schema.number().default(60000)
157
+ .description('How long a read stays cached, in milliseconds. A composed report then costs one request set instead of many; writes always go to the network.'),
117
158
  reviewJobTimeoutMs: Schema.number().default(120000)
118
159
  .description('How long a background review job may run before it is cancelled.'),
119
160
  })
@@ -152,6 +193,12 @@ export function apply(ctx, rawConfig) {
152
193
  }
153
194
  }
154
195
 
196
+ /** Where the plugin's own sign-in lives. */
197
+ function accessPath() {
198
+ const configured = liveConfig().accessFile
199
+ return configured && String(configured).trim() ? String(configured).trim() : accessFilePath()
200
+ }
201
+
155
202
  /** The gh CLI session, when the machine already has `gh auth login` done. */
156
203
  async function ghCliAccess() {
157
204
  const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10000, maxBuffer: 1024 * 1024 })
@@ -162,6 +209,11 @@ export function apply(ctx, rawConfig) {
162
209
  async function resolveToken() {
163
210
  const cfg = liveConfig()
164
211
  return resolveAccess({
212
+ // the sign-in this plugin stored itself (gh_auth_login) is one of the sources
213
+ runFile: async () => {
214
+ const stored = await readAccessFile(accessPath())
215
+ return stored && stored.token ? stored.token : ''
216
+ },
165
217
  // the credentials service takes a branded reference, not a bare name
166
218
  credentials: {
167
219
  resolve: async (name) => {
@@ -179,18 +231,40 @@ export function apply(ctx, rawConfig) {
179
231
  })
180
232
  }
181
233
 
234
+ // One client per configuration: the read cache lives in it, and the signal for the
235
+ // current tool call arrives through the async context.
236
+ let sharedClient = null
237
+ let sharedKey = ''
238
+
182
239
  async function client() {
183
240
  const cfg = liveConfig()
184
241
  const access = await resolveToken()
185
242
  if (!access.value) {
186
243
  throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
187
244
  }
188
- return createGitHubClient({
189
- token: access.value,
190
- baseUrl: cfg.baseUrl || 'https://api.github.com',
191
- timeoutMs: Number(cfg.timeoutMs) || 30000,
192
- maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
193
- })
245
+ const key = [
246
+ access.source,
247
+ access.value.length,
248
+ cfg.baseUrl || '',
249
+ cfg.timeoutMs || '',
250
+ cfg.cacheTtlMs || '',
251
+ cfg.maxRetries ?? '',
252
+ ].join('|')
253
+ if (!sharedClient || sharedKey !== key) {
254
+ sharedClient = createGitHubClient({
255
+ token: access.value,
256
+ baseUrl: cfg.baseUrl || 'https://api.github.com',
257
+ timeoutMs: Number(cfg.timeoutMs) || 30000,
258
+ cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
259
+ maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
260
+ signalProvider: () => {
261
+ const store = callContext.getStore()
262
+ return store && store.signal ? store.signal : undefined
263
+ },
264
+ })
265
+ sharedKey = key
266
+ }
267
+ return sharedClient
194
268
  }
195
269
 
196
270
  /**
@@ -227,6 +301,10 @@ export function apply(ctx, rawConfig) {
227
301
  * plugin must never be the reason a deployment cannot start.
228
302
  */
229
303
  const skippedTools = []
304
+ /** Every registered tool, so gh_help never drifts from reality. */
305
+ const catalog = []
306
+ const WRITE_PREFIXES = ['pr_', 'issue_', 'review_post', 'gh_api', 'gh_run_rerun', 'gh_run_cancel', 'gh_mirror_publish', 'gh_release_', 'gh_tag_', 'gh_repo_create', 'gh_repo_edit', 'gh_secret_', 'gh_variable_', 'gh_ruleset_', 'gh_branch_protection_set', 'gh_branch_protection_delete', 'gh_push_']
307
+
230
308
  function tool(toolName, description, parameters, run) {
231
309
  const definition = defineTool({
232
310
  name: toolName,
@@ -237,9 +315,17 @@ export function apply(ctx, rawConfig) {
237
315
  render: (_args, value) => renderToolResult(toolName, value),
238
316
  },
239
317
  ...presentationFor(toolName),
240
- execute: async (args) => {
318
+ execute: async (args, exec) => {
241
319
  try {
242
- const value = await run(args || {})
320
+ const value = await callContext.run({ signal: exec && exec.signal }, () => run(args || {}))
321
+ // A write may invalidate what a cached read returned.
322
+ if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
323
+ try {
324
+ sharedClient?.cacheClear?.()
325
+ } catch {
326
+ // a cache reset must never fail a successful call
327
+ }
328
+ }
243
329
  return { ok: true, data: value }
244
330
  } catch (err) {
245
331
  const out = { ok: false, error: describeError(err) }
@@ -254,6 +340,7 @@ export function apply(ctx, rawConfig) {
254
340
  })
255
341
  try {
256
342
  ctx.tools.register(definition)
343
+ catalog.push({ name: toolName, description, group: groupOf(toolName) })
257
344
  } catch (err) {
258
345
  if (!/is already registered/.test(String((err && err.message) || err))) throw err
259
346
  skippedTools.push(toolName)
@@ -261,10 +348,35 @@ export function apply(ctx, rawConfig) {
261
348
  }
262
349
  }
263
350
 
264
- const deps = { tool, asRepo, client, liveConfig, reviewRules }
351
+ const pendingFlow = { current: null }
352
+ const authSeam = {
353
+ config: () => {
354
+ const cfg = liveConfig()
355
+ return { clientId: cfg.oauthClientId || DEFAULT_CLIENT_ID, scope: cfg.oauthScope || DEFAULT_SCOPE, secretRef: cfg.oauthClientSecretRef || '' }
356
+ },
357
+ path: accessPath,
358
+ remember: (flow) => { pendingFlow.current = flow },
359
+ pending: () => pendingFlow.current,
360
+ forget: () => { pendingFlow.current = null },
361
+ resolve: resolveToken,
362
+ secret: async () => {
363
+ const ref = liveConfig().oauthClientSecretRef
364
+ if (!ref) return ''
365
+ try {
366
+ const resolved = await ctx.credentials.resolve(credentialRef(ref))
367
+ return (resolved && resolved.value) || ''
368
+ } catch {
369
+ return ''
370
+ }
371
+ },
372
+ }
373
+
374
+ const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
265
375
  registerCoreTools(deps)
266
376
  registerCollabTools(deps)
267
377
  registerOpsTools(deps)
378
+ registerInsightTools(deps)
379
+ registerWriteTools(deps)
268
380
  registerMirrorTools({
269
381
  tool,
270
382
  git: makeGitRunner(),
@@ -293,8 +405,32 @@ export function apply(ctx, rawConfig) {
293
405
  },
294
406
  })
295
407
 
408
+ registerRoutes(ctx, {
409
+ resolveToken,
410
+ client,
411
+ liveConfig,
412
+ describeError,
413
+ makeGitRunner,
414
+ cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
415
+ })
416
+
417
+ // One short paragraph in the system prompt: 50+ tools otherwise leave the model guessing.
418
+ ctx.inject(['systemPrompt'], (sctx) => {
419
+ try {
420
+ const cfg = liveConfig()
421
+ if (cfg.guidance === false) return
422
+ sctx.systemPrompt.section({
423
+ name: GUIDANCE_SECTION,
424
+ order: GUIDANCE_ORDER,
425
+ text: guidanceText(cfg.guidanceText),
426
+ })
427
+ } catch (err) {
428
+ ctx.logger?.warn?.('[dsh-github-ops] system prompt guidance not registered:', err && err.message)
429
+ }
430
+ })
431
+
296
432
  // Slash commands are a fast path for a human: they only hand the model an instruction, so
297
- // the write still goes through the approval gate.
433
+ // the write still passes through the host approval contour.
298
434
  ctx.inject(['commands'], (cctx) => {
299
435
  try {
300
436
  const git = makeGitRunner()
@@ -317,13 +453,31 @@ export function apply(ctx, rawConfig) {
317
453
  }
318
454
  })
319
455
 
320
- // Writes go through the approval contour: a named action, a reason a human can decide
321
- // on, and no auto-approval for anything destructive.
322
- try {
323
- registerApprovalGate(ctx, () => liveConfig())
324
- } catch (err) {
325
- ctx.logger?.warn?.('[dsh-github-ops] approval gate not registered:', err && err.message)
326
- }
456
+
457
+ // The catalog is built from the registrations above, so it cannot go stale.
458
+ tool(
459
+ 'gh_help',
460
+ 'Catalog of every tool this plugin provides, grouped by area, with one line each. Call it when unsure which tool fits — it lists what exists instead of guessing.',
461
+ { group: { type: 'string', description: 'Optional: only this group (repositories, releases, tags, issues, pull-requests, runs, secrets, variables, rulesets, branch-protection, mirror, jobs, api, meta).' } },
462
+ async (args) => {
463
+ const groups = {}
464
+ for (const entry of catalog) {
465
+ if (entry.name === 'gh_help') continue
466
+ groups[entry.group] = groups[entry.group] || []
467
+ groups[entry.group].push({ name: entry.name, description: entry.description })
468
+ }
469
+ if (args.group) {
470
+ const only = groups[args.group]
471
+ if (!only) return { error: `unknown group "${args.group}"`, groups: Object.keys(groups).sort() }
472
+ return { group: args.group, count: only.length, tools: only }
473
+ }
474
+ return {
475
+ count: Object.values(groups).reduce((sum, list) => sum + list.length, 0),
476
+ groups: Object.keys(groups).sort().map((name) => ({ name, count: groups[name].length })),
477
+ tools: groups,
478
+ }
479
+ },
480
+ )
327
481
 
328
482
  if (skippedTools.length) {
329
483
  ctx.logger?.warn?.(