@goodandready/dsh-github-ops 0.1.2 → 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/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
@@ -25,11 +25,22 @@ import { registerCoreTools } from './tools/register-core.js'
25
25
  import { registerCollabTools } from './tools/register-collab.js'
26
26
  import { registerMirrorTools } from './tools/register-mirror.js'
27
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 { lossless } from './lossless.js'
35
+ import { AsyncLocalStorage } from 'node:async_hooks'
28
36
  import { execFile } from 'node:child_process'
29
37
  import { promisify } from 'node:util'
30
38
 
31
39
  const execFileAsync = promisify(execFile)
32
40
 
41
+ // Carries the current tool call's cancellation signal to the shared client.
42
+ const callContext = new AsyncLocalStorage()
43
+
33
44
  /** git runner: never throws, always answers with a code and both streams. */
34
45
  function makeGitRunner() {
35
46
  return async (args, { cwd, env } = {}) => {
@@ -64,6 +75,31 @@ export const OUTPUT_SCHEMA = {
64
75
  },
65
76
  }
66
77
 
78
+ /** Which area a tool belongs to, derived from its name so a new tool lands somewhere sane. */
79
+ export function groupOf(toolName) {
80
+ const name = String(toolName || '')
81
+ const rules = [
82
+ [/^gh_release_/, 'releases'],
83
+ [/^gh_tag_/, 'tags'],
84
+ [/^gh_run_/, 'runs'],
85
+ [/^gh_secret_/, 'secrets'],
86
+ [/^gh_variable_/, 'variables'],
87
+ [/^gh_ruleset_/, 'rulesets'],
88
+ [/^gh_branch_protection_/, 'branch-protection'],
89
+ [/^gh_mirror_/, 'mirror'],
90
+ [/^gh_review_job|^gh_review$|^review_post$|^ci_run$|^gh_checks$|^pr_/, 'pull-requests'],
91
+ [/^issue_|^gh_issue$|^gh_search$/, 'issues'],
92
+ [/^gh_repo_report|^gh_weekly_digest|^gh_notifications|^gh_repo_health|^gh_compare|^gh_trending|^gh_contributors|^gh_user_repos|^gh_commits/, 'insights'],
93
+ [/^gh_repo|^gh_file/, 'repositories'],
94
+ [/^gh_push_|^gh_upload_|^gh_delete_/, 'write-files'],
95
+ [/^gh_auth_/, 'auth'],
96
+ [/^gh_api$/, 'api'],
97
+ [/^gh_help$/, 'meta'],
98
+ ]
99
+ for (const [pattern, group] of rules) if (pattern.test(name)) return group
100
+ return 'other'
101
+ }
102
+
67
103
  const MAX_RENDER_CHARS = 20000
68
104
 
69
105
  /** Render a tool result for the model: readable text, bounded so one call cannot flood the turn. */
@@ -104,6 +140,22 @@ export const Config = Schema.object({
104
140
  .description('Retries for a read (GET/HEAD) on a network failure, a timeout or a 5xx. Writes are never retried automatically.'),
105
141
  reviewRulesJson: Schema.string().default('')
106
142
  .description('Optional review-rule overrides as JSON: sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines.'),
143
+ oauthClientId: Schema.string().default(DEFAULT_CLIENT_ID)
144
+ .description('OAuth application id used by gh_auth_login. The default is the public GitHub CLI application.'),
145
+ oauthScope: Schema.string().default(DEFAULT_SCOPE)
146
+ .description('OAuth scope requested by gh_auth_login.'),
147
+ oauthClientSecretRef: Schema.string().role('credential-ref').default('')
148
+ .description('Optional credential holding the OAuth client secret, for apps that require one.'),
149
+ accessFile: Schema.string().default('')
150
+ .description('Where gh_auth_login stores its sign-in (default: $DSH_HOME/github-ops-auth.json, mode 0600).'),
151
+ interceptLinks: Schema.boolean().default(false)
152
+ .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.'),
153
+ guidance: Schema.boolean().default(true)
154
+ .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.'),
155
+ guidanceText: Schema.string().default('')
156
+ .description('Replace the default guidance paragraph with your own text.'),
157
+ cacheTtlMs: Schema.number().default(60000)
158
+ .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.'),
107
159
  reviewJobTimeoutMs: Schema.number().default(120000)
108
160
  .description('How long a background review job may run before it is cancelled.'),
109
161
  })
@@ -142,6 +194,12 @@ export function apply(ctx, rawConfig) {
142
194
  }
143
195
  }
144
196
 
197
+ /** Where the plugin's own sign-in lives. */
198
+ function accessPath() {
199
+ const configured = liveConfig().accessFile
200
+ return configured && String(configured).trim() ? String(configured).trim() : accessFilePath()
201
+ }
202
+
145
203
  /** The gh CLI session, when the machine already has `gh auth login` done. */
146
204
  async function ghCliAccess() {
147
205
  const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10000, maxBuffer: 1024 * 1024 })
@@ -152,6 +210,11 @@ export function apply(ctx, rawConfig) {
152
210
  async function resolveToken() {
153
211
  const cfg = liveConfig()
154
212
  return resolveAccess({
213
+ // the sign-in this plugin stored itself (gh_auth_login) is one of the sources
214
+ runFile: async () => {
215
+ const stored = await readAccessFile(accessPath())
216
+ return stored && stored.token ? stored.token : ''
217
+ },
155
218
  // the credentials service takes a branded reference, not a bare name
156
219
  credentials: {
157
220
  resolve: async (name) => {
@@ -169,18 +232,40 @@ export function apply(ctx, rawConfig) {
169
232
  })
170
233
  }
171
234
 
235
+ // One client per configuration: the read cache lives in it, and the signal for the
236
+ // current tool call arrives through the async context.
237
+ let sharedClient = null
238
+ let sharedKey = ''
239
+
172
240
  async function client() {
173
241
  const cfg = liveConfig()
174
242
  const access = await resolveToken()
175
243
  if (!access.value) {
176
244
  throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
177
245
  }
178
- return createGitHubClient({
179
- token: access.value,
180
- baseUrl: cfg.baseUrl || 'https://api.github.com',
181
- timeoutMs: Number(cfg.timeoutMs) || 30000,
182
- maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
183
- })
246
+ const key = [
247
+ access.source,
248
+ access.value.length,
249
+ cfg.baseUrl || '',
250
+ cfg.timeoutMs || '',
251
+ cfg.cacheTtlMs || '',
252
+ cfg.maxRetries ?? '',
253
+ ].join('|')
254
+ if (!sharedClient || sharedKey !== key) {
255
+ sharedClient = createGitHubClient({
256
+ token: access.value,
257
+ baseUrl: cfg.baseUrl || 'https://api.github.com',
258
+ timeoutMs: Number(cfg.timeoutMs) || 30000,
259
+ cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
260
+ maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
261
+ signalProvider: () => {
262
+ const store = callContext.getStore()
263
+ return store && store.signal ? store.signal : undefined
264
+ },
265
+ })
266
+ sharedKey = key
267
+ }
268
+ return sharedClient
184
269
  }
185
270
 
186
271
  /**
@@ -217,6 +302,10 @@ export function apply(ctx, rawConfig) {
217
302
  * plugin must never be the reason a deployment cannot start.
218
303
  */
219
304
  const skippedTools = []
305
+ /** Every registered tool, so gh_help never drifts from reality. */
306
+ const catalog = []
307
+ 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_']
308
+
220
309
  function tool(toolName, description, parameters, run) {
221
310
  const definition = defineTool({
222
311
  name: toolName,
@@ -227,10 +316,20 @@ export function apply(ctx, rawConfig) {
227
316
  render: (_args, value) => renderToolResult(toolName, value),
228
317
  },
229
318
  ...presentationFor(toolName),
230
- execute: async (args) => {
319
+ execute: async (args, exec) => {
231
320
  try {
232
- const value = await run(args || {})
233
- return { ok: true, data: value }
321
+ const value = await callContext.run({ signal: exec && exec.signal }, () => run(args || {}))
322
+ // A write may invalidate what a cached read returned.
323
+ if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
324
+ try {
325
+ sharedClient?.cacheClear?.()
326
+ } catch {
327
+ // a cache reset must never fail a successful call
328
+ }
329
+ }
330
+ // One place decides what a result may contain: the harness refuses a value that
331
+ // cannot be serialised losslessly, and it refuses the whole call for one undefined.
332
+ return { ok: true, data: lossless(value) }
234
333
  } catch (err) {
235
334
  const out = { ok: false, error: describeError(err) }
236
335
  if (err instanceof GitHubError) {
@@ -238,12 +337,13 @@ export function apply(ctx, rawConfig) {
238
337
  if (err.code) out.code = err.code
239
338
  if (err.rateLimit) out.rateLimit = err.rateLimit
240
339
  }
241
- return out
340
+ return lossless(out)
242
341
  }
243
342
  },
244
343
  })
245
344
  try {
246
345
  ctx.tools.register(definition)
346
+ catalog.push({ name: toolName, description, group: groupOf(toolName) })
247
347
  } catch (err) {
248
348
  if (!/is already registered/.test(String((err && err.message) || err))) throw err
249
349
  skippedTools.push(toolName)
@@ -251,10 +351,35 @@ export function apply(ctx, rawConfig) {
251
351
  }
252
352
  }
253
353
 
254
- const deps = { tool, asRepo, client, liveConfig, reviewRules }
354
+ const pendingFlow = { current: null }
355
+ const authSeam = {
356
+ config: () => {
357
+ const cfg = liveConfig()
358
+ return { clientId: cfg.oauthClientId || DEFAULT_CLIENT_ID, scope: cfg.oauthScope || DEFAULT_SCOPE, secretRef: cfg.oauthClientSecretRef || '' }
359
+ },
360
+ path: accessPath,
361
+ remember: (flow) => { pendingFlow.current = flow },
362
+ pending: () => pendingFlow.current,
363
+ forget: () => { pendingFlow.current = null },
364
+ resolve: resolveToken,
365
+ secret: async () => {
366
+ const ref = liveConfig().oauthClientSecretRef
367
+ if (!ref) return ''
368
+ try {
369
+ const resolved = await ctx.credentials.resolve(credentialRef(ref))
370
+ return (resolved && resolved.value) || ''
371
+ } catch {
372
+ return ''
373
+ }
374
+ },
375
+ }
376
+
377
+ const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
255
378
  registerCoreTools(deps)
256
379
  registerCollabTools(deps)
257
380
  registerOpsTools(deps)
381
+ registerInsightTools(deps)
382
+ registerWriteTools(deps)
258
383
  registerMirrorTools({
259
384
  tool,
260
385
  git: makeGitRunner(),
@@ -283,6 +408,30 @@ export function apply(ctx, rawConfig) {
283
408
  },
284
409
  })
285
410
 
411
+ registerRoutes(ctx, {
412
+ resolveToken,
413
+ client,
414
+ liveConfig,
415
+ describeError,
416
+ makeGitRunner,
417
+ cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
418
+ })
419
+
420
+ // One short paragraph in the system prompt: 50+ tools otherwise leave the model guessing.
421
+ ctx.inject(['systemPrompt'], (sctx) => {
422
+ try {
423
+ const cfg = liveConfig()
424
+ if (cfg.guidance === false) return
425
+ sctx.systemPrompt.section({
426
+ name: GUIDANCE_SECTION,
427
+ order: GUIDANCE_ORDER,
428
+ text: guidanceText(cfg.guidanceText),
429
+ })
430
+ } catch (err) {
431
+ ctx.logger?.warn?.('[dsh-github-ops] system prompt guidance not registered:', err && err.message)
432
+ }
433
+ })
434
+
286
435
  // Slash commands are a fast path for a human: they only hand the model an instruction, so
287
436
  // the write still passes through the host approval contour.
288
437
  ctx.inject(['commands'], (cctx) => {
@@ -308,6 +457,31 @@ export function apply(ctx, rawConfig) {
308
457
  })
309
458
 
310
459
 
460
+ // The catalog is built from the registrations above, so it cannot go stale.
461
+ tool(
462
+ 'gh_help',
463
+ '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.',
464
+ { group: { type: 'string', description: 'Optional: only this group (repositories, releases, tags, issues, pull-requests, runs, secrets, variables, rulesets, branch-protection, mirror, jobs, api, meta).' } },
465
+ async (args) => {
466
+ const groups = {}
467
+ for (const entry of catalog) {
468
+ if (entry.name === 'gh_help') continue
469
+ groups[entry.group] = groups[entry.group] || []
470
+ groups[entry.group].push({ name: entry.name, description: entry.description })
471
+ }
472
+ if (args.group) {
473
+ const only = groups[args.group]
474
+ if (!only) return { error: `unknown group "${args.group}"`, groups: Object.keys(groups).sort() }
475
+ return { group: args.group, count: only.length, tools: only }
476
+ }
477
+ return {
478
+ count: Object.values(groups).reduce((sum, list) => sum + list.length, 0),
479
+ groups: Object.keys(groups).sort().map((name) => ({ name, count: groups[name].length })),
480
+ tools: groups,
481
+ }
482
+ },
483
+ )
484
+
311
485
  if (skippedTools.length) {
312
486
  ctx.logger?.warn?.(
313
487
  `[dsh-github-ops] ${skippedTools.length} tool(s) left to the plugin that already provides them: `
@@ -0,0 +1,90 @@
1
+ // Making every tool result survive JSON without losing anything.
2
+ //
3
+ // The harness refuses a tool result that cannot be serialised losslessly, and it refuses the
4
+ // WHOLE call: one `undefined` deep inside a payload drops the answer the caller waited for. That
5
+ // is how a directory entry without a `size` field took down `gh_repo_tree`, and how the same
6
+ // class of defect took down two tools of another plugin.
7
+ //
8
+ // This module is the single place that decides what a result may contain. It is applied to every
9
+ // tool result in one wrapper, so no individual tool has to remember the rule.
10
+
11
+ const MAX_DEPTH = 40
12
+
13
+ /**
14
+ * Return a value that serialises identically in every JSON implementation:
15
+ * - `undefined` is dropped from objects and becomes `null` inside arrays (JSON has no holes);
16
+ * - `NaN` and the infinities become `null` (they have no JSON form);
17
+ * - a `Date` becomes its ISO string; a `Map` an object; a `Set` and a typed array an array; a
18
+ * `Buffer` a base64 string;
19
+ * - functions and symbols are dropped — they cannot be sent anywhere;
20
+ * - a `BigInt` becomes a string (JSON has no integer that large);
21
+ * - a cycle becomes the string "[circular]" instead of endless recursion.
22
+ */
23
+ export function lossless(value, depth = 0, seen = new WeakSet()) {
24
+ if (value === null) return null
25
+ const type = typeof value
26
+ if (type === 'string' || type === 'boolean') return value
27
+ if (type === 'number') return Number.isFinite(value) ? value : null
28
+ if (type === 'bigint') return value.toString()
29
+ if (type === 'undefined' || type === 'function' || type === 'symbol') return undefined
30
+
31
+ if (depth > MAX_DEPTH) return '[too deep]'
32
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
33
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) return value.toString('base64')
34
+
35
+ if (typeof value === 'object') {
36
+ if (seen.has(value)) return '[circular]'
37
+ seen.add(value)
38
+
39
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
40
+ const out = Array.from(value, (item) => {
41
+ const converted = lossless(item, depth + 1, seen)
42
+ return converted === undefined ? null : converted
43
+ })
44
+ seen.delete(value)
45
+ return out
46
+ }
47
+ if (value instanceof Map) {
48
+ const out = {}
49
+ for (const [key, item] of value.entries()) {
50
+ const converted = lossless(item, depth + 1, seen)
51
+ if (converted !== undefined) out[String(key)] = converted
52
+ }
53
+ seen.delete(value)
54
+ return out
55
+ }
56
+ if (value instanceof Set) {
57
+ const out = []
58
+ for (const item of value.values()) {
59
+ const converted = lossless(item, depth + 1, seen)
60
+ out.push(converted === undefined ? null : converted)
61
+ }
62
+ seen.delete(value)
63
+ return out
64
+ }
65
+
66
+ const out = {}
67
+ for (const key of Object.keys(value)) {
68
+ const converted = lossless(value[key], depth + 1, seen)
69
+ if (converted !== undefined) out[key] = converted
70
+ }
71
+ seen.delete(value)
72
+ return out
73
+ }
74
+
75
+ return undefined
76
+ }
77
+
78
+ /**
79
+ * Whether a value would pass the harness's check. Kept as a separate, tiny predicate so tests can
80
+ * assert the rule instead of comparing strings.
81
+ */
82
+ export function isLossless(value) {
83
+ try {
84
+ const once = JSON.stringify(value)
85
+ if (once === undefined) return false
86
+ return JSON.stringify(lossless(JSON.parse(once))) === once
87
+ } catch {
88
+ return false
89
+ }
90
+ }