tissues 0.6.0 → 0.6.2

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 (52) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +12 -1
  4. package/src/commands/ai.js +149 -173
  5. package/src/commands/config.js +143 -69
  6. package/src/commands/create.js +16 -9
  7. package/src/commands/create.test.js +381 -0
  8. package/src/commands/enhancements.js +282 -0
  9. package/src/commands/flush.test.js +299 -0
  10. package/src/commands/list.js +3 -2
  11. package/src/commands/providers.js +347 -0
  12. package/src/commands/providers.test.js +28 -0
  13. package/src/commands/storage.js +167 -0
  14. package/src/commands/sync.js +225 -0
  15. package/src/daemon/sync.js +189 -0
  16. package/src/lib/ai/adapters/claude-cli.js +55 -0
  17. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  18. package/src/lib/ai/adapters/codex-cli.js +77 -0
  19. package/src/lib/ai/adapters/command.js +23 -13
  20. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  21. package/src/lib/ai/adapters/openclaw.js +91 -0
  22. package/src/lib/ai/agent-actions.js +271 -0
  23. package/src/lib/ai/agent.js +323 -0
  24. package/src/lib/ai/body-template.js +15 -0
  25. package/src/lib/ai/discovery.js +89 -0
  26. package/src/lib/ai/discovery.test.js +74 -0
  27. package/src/lib/ai/enhance.js +48 -11
  28. package/src/lib/ai/enhancement-adapter.js +109 -0
  29. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  30. package/src/lib/ai/index.js +2 -2
  31. package/src/lib/ai/pipeline.js +20 -2
  32. package/src/lib/ai/pipeline.test.js +257 -0
  33. package/src/lib/ai/prompt.test.js +30 -0
  34. package/src/lib/ai/router.js +118 -7
  35. package/src/lib/ai/router.test.js +481 -0
  36. package/src/lib/ai/steps.js +23 -3
  37. package/src/lib/ai/steps.test.js +335 -0
  38. package/src/lib/attribution.test.js +64 -0
  39. package/src/lib/cache.js +408 -0
  40. package/src/lib/db.js +42 -0
  41. package/src/lib/dedup.js +44 -48
  42. package/src/lib/dedup.test.js +227 -0
  43. package/src/lib/defaults.js +37 -1
  44. package/src/lib/defaults.test.js +217 -0
  45. package/src/lib/drafts-perf.test.js +203 -0
  46. package/src/lib/drafts.test.js +300 -0
  47. package/src/lib/enhancements.js +436 -0
  48. package/src/lib/enhancements.test.js +294 -0
  49. package/src/lib/gh.js +76 -10
  50. package/src/lib/safety.test.js +217 -0
  51. package/src/lib/storage.js +298 -0
  52. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,133 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { GeminiCliAdapter } from './gemini-cli.js'
4
+ import { ClaudeCliAdapter } from './claude-cli.js'
5
+ import { CodexCliAdapter } from './codex-cli.js'
6
+ import { OpenClawAdapter } from './openclaw.js'
7
+
8
+ describe('GeminiCliAdapter', () => {
9
+ it('has correct name', () => {
10
+ const adapter = new GeminiCliAdapter({ binary: 'gemini' })
11
+ assert.equal(adapter.name, 'gemini-cli')
12
+ })
13
+
14
+ it('isConfigured returns true when binary is set', () => {
15
+ const adapter = new GeminiCliAdapter({ binary: 'gemini' })
16
+ assert.equal(adapter.isConfigured(), true)
17
+ })
18
+
19
+ it('isConfigured returns false when binary is not set', () => {
20
+ const adapter = new GeminiCliAdapter({})
21
+ assert.equal(adapter.isConfigured(), false)
22
+ })
23
+
24
+ it('complete rejects when binary does not exist', async () => {
25
+ const adapter = new GeminiCliAdapter({ binary: 'nonexistent-gemini-binary-xyz' })
26
+ await assert.rejects(
27
+ () => adapter.complete([{ role: 'user', content: 'test' }]),
28
+ /gemini-cli error/,
29
+ )
30
+ })
31
+ })
32
+
33
+ describe('ClaudeCliAdapter', () => {
34
+ it('has correct name', () => {
35
+ const adapter = new ClaudeCliAdapter({ binary: 'claude' })
36
+ assert.equal(adapter.name, 'claude-cli')
37
+ })
38
+
39
+ it('isConfigured returns true when binary is set', () => {
40
+ const adapter = new ClaudeCliAdapter({ binary: 'claude' })
41
+ assert.equal(adapter.isConfigured(), true)
42
+ })
43
+
44
+ it('isConfigured returns false when binary is not set', () => {
45
+ const adapter = new ClaudeCliAdapter({})
46
+ assert.equal(adapter.isConfigured(), false)
47
+ })
48
+
49
+ it('complete rejects when binary does not exist', async () => {
50
+ const adapter = new ClaudeCliAdapter({ binary: 'nonexistent-claude-binary-xyz' })
51
+ await assert.rejects(
52
+ () => adapter.complete([{ role: 'user', content: 'test' }]),
53
+ /claude-cli error/,
54
+ )
55
+ })
56
+ })
57
+
58
+ describe('CodexCliAdapter', () => {
59
+ it('has correct name', () => {
60
+ const adapter = new CodexCliAdapter({ binary: 'codex' })
61
+ assert.equal(adapter.name, 'codex-cli')
62
+ })
63
+
64
+ it('isConfigured returns true when binary is set', () => {
65
+ const adapter = new CodexCliAdapter({ binary: 'codex' })
66
+ assert.equal(adapter.isConfigured(), true)
67
+ })
68
+
69
+ it('isConfigured returns false when binary is not set', () => {
70
+ const adapter = new CodexCliAdapter({})
71
+ assert.equal(adapter.isConfigured(), false)
72
+ })
73
+
74
+ it('complete rejects when binary does not exist', async () => {
75
+ const adapter = new CodexCliAdapter({ binary: 'nonexistent-codex-binary-xyz' })
76
+ await assert.rejects(
77
+ () => adapter.complete([{ role: 'user', content: 'test' }]),
78
+ /codex-cli error|git/,
79
+ )
80
+ })
81
+ })
82
+
83
+ describe('OpenClawAdapter', () => {
84
+ it('has correct name', () => {
85
+ const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790', token: 'test' })
86
+ assert.equal(adapter.name, 'openclaw')
87
+ })
88
+
89
+ it('isConfigured returns true with url and token', () => {
90
+ const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790', token: 'test' })
91
+ assert.equal(adapter.isConfigured(), true)
92
+ })
93
+
94
+ it('isConfigured returns false without token', () => {
95
+ const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790' })
96
+ assert.equal(adapter.isConfigured(), false)
97
+ })
98
+
99
+ it('isConfigured returns false without url', () => {
100
+ const adapter = new OpenClawAdapter({ token: 'test' })
101
+ assert.equal(adapter.isConfigured(), false)
102
+ })
103
+
104
+ it('healthCheck returns false for unreachable gateway', async () => {
105
+ const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:1', token: 'test' })
106
+ const healthy = await adapter.healthCheck()
107
+ assert.equal(healthy, false)
108
+ })
109
+
110
+ it('complete rejects without gateway url', async () => {
111
+ const adapter = new OpenClawAdapter({ token: 'test' })
112
+ await assert.rejects(
113
+ () => adapter.complete([{ role: 'user', content: 'test' }]),
114
+ /gateway URL not configured/,
115
+ )
116
+ })
117
+
118
+ it('complete rejects without token', async () => {
119
+ const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790' })
120
+ await assert.rejects(
121
+ () => adapter.complete([{ role: 'user', content: 'test' }]),
122
+ /token not configured/,
123
+ )
124
+ })
125
+
126
+ it('complete rejects when gateway is unreachable', async () => {
127
+ const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:1', token: 'test' })
128
+ await assert.rejects(
129
+ () => adapter.complete([{ role: 'user', content: 'test' }]),
130
+ /not reachable/,
131
+ )
132
+ })
133
+ })
@@ -0,0 +1,77 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { BaseAdapter } from './base.js'
6
+
7
+ const DEFAULT_TIMEOUT_MS = 120_000
8
+
9
+ /**
10
+ * Adapter for the OpenAI Codex CLI.
11
+ * Codex requires a git repo, so we create a temp dir with git init.
12
+ * Uses: codex exec 'prompt'
13
+ */
14
+ export class CodexCliAdapter extends BaseAdapter {
15
+ get name() {
16
+ return 'codex-cli'
17
+ }
18
+
19
+ isConfigured() {
20
+ return !!this.config.binary
21
+ }
22
+
23
+ async complete(messages, opts = {}) {
24
+ const binary = this.config.binary || 'codex'
25
+ const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
26
+
27
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
28
+ const promptText = lastUserMsg?.content || ''
29
+
30
+ const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content)
31
+ const fullPrompt = systemParts.length
32
+ ? `${systemParts.join('\n\n')}\n\n${promptText}`
33
+ : promptText
34
+
35
+ // Codex requires a git repository — create a temp one
36
+ const tempDir = mkdtempSync(join(tmpdir(), 'tissues-codex-'))
37
+
38
+ try {
39
+ // Initialize git repo in temp dir
40
+ await this._exec('git', ['init', '--quiet'], { cwd: tempDir, timeout: 10_000 })
41
+
42
+ const args = ['exec', fullPrompt]
43
+
44
+ return await new Promise((resolve, reject) => {
45
+ execFile(binary, args, {
46
+ cwd: tempDir,
47
+ timeout: timeoutMs,
48
+ maxBuffer: 10 * 1024 * 1024,
49
+ }, (err, stdout, stderr) => {
50
+ if (err) {
51
+ const msg = stderr?.trim() || err.message
52
+ reject(new Error(`codex-cli error: ${this.sanitizeErrorBody(msg)}`))
53
+ return
54
+ }
55
+ const text = stdout.trim()
56
+ if (!text) {
57
+ reject(new Error('codex-cli returned no output'))
58
+ return
59
+ }
60
+ resolve(text)
61
+ })
62
+ })
63
+ } finally {
64
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* cleanup best-effort */ }
65
+ }
66
+ }
67
+
68
+ /** @private */
69
+ _exec(cmd, args, opts) {
70
+ return new Promise((resolve, reject) => {
71
+ execFile(cmd, args, opts, (err, stdout) => {
72
+ if (err) reject(err)
73
+ else resolve(stdout)
74
+ })
75
+ })
76
+ }
77
+ }
@@ -16,27 +16,35 @@ export class CommandAdapter extends BaseAdapter {
16
16
  const command = this.config.command
17
17
  if (!command) throw new Error('Command not configured (ai.command)')
18
18
 
19
- const timeoutMs = opts.maxTokens
20
- ? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
21
- : DEFAULT_TIMEOUT_MS
19
+ const timeoutMs = this.config.timeout
20
+ || (opts.maxTokens
21
+ ? Math.max(DEFAULT_TIMEOUT_MS, opts.maxTokens * 15) // rough heuristic
22
+ : DEFAULT_TIMEOUT_MS)
22
23
 
23
- const payload = JSON.stringify({
24
+ // Extract the last user message as the prompt text
25
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
26
+ const promptText = lastUserMsg?.content || ''
27
+
28
+ // Check if command uses {prompt} placeholder (arg mode) or JSON stdin
29
+ const usesPlaceholder = command.includes('{prompt}')
30
+
31
+ const resolvedCommand = usesPlaceholder
32
+ ? command.replace(/\{prompt\}/g, promptText)
33
+ : command
34
+
35
+ const payload = usesPlaceholder ? null : JSON.stringify({
24
36
  messages: messages.map((m) => ({ role: m.role, content: m.content })),
25
37
  model: opts.model || null,
26
38
  maxTokens: opts.maxTokens || 4096,
27
39
  })
28
40
 
29
41
  return new Promise((resolve, reject) => {
30
- // Split command string into executable + args
31
- const parts = command.split(/\s+/)
32
- const exe = parts[0]
33
- const args = parts.slice(1)
34
-
35
- const isWindows = process.platform === 'win32'
36
- const child = execFile(exe, args, {
42
+ // Always use shell to support quoted args, pipes, builtins, etc.
43
+ const shell = process.platform === 'win32' ? true : '/bin/sh'
44
+ const child = execFile('sh', ['-c', resolvedCommand], {
37
45
  timeout: timeoutMs,
38
46
  maxBuffer: 10 * 1024 * 1024, // 10 MB
39
- shell: isWindows,
47
+ shell: false, // we're already wrapping in sh -c
40
48
  }, (err, stdout, stderr) => {
41
49
  if (err) {
42
50
  const msg = stderr?.trim() || err.message
@@ -51,7 +59,9 @@ export class CommandAdapter extends BaseAdapter {
51
59
  resolve(text)
52
60
  })
53
61
 
54
- child.stdin.write(payload)
62
+ if (payload) {
63
+ child.stdin.write(payload)
64
+ }
55
65
  child.stdin.end()
56
66
  })
57
67
  }
@@ -0,0 +1,55 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { BaseAdapter } from './base.js'
3
+
4
+ const DEFAULT_TIMEOUT_MS = 120_000
5
+
6
+ /**
7
+ * Adapter for the Gemini CLI (google-gemini/gemini-cli).
8
+ * Uses: gemini -p 'prompt' --model MODEL -s
9
+ */
10
+ export class GeminiCliAdapter extends BaseAdapter {
11
+ get name() {
12
+ return 'gemini-cli'
13
+ }
14
+
15
+ isConfigured() {
16
+ return !!this.config.binary
17
+ }
18
+
19
+ async complete(messages, opts = {}) {
20
+ const binary = this.config.binary || 'gemini'
21
+ const model = opts.model || this.config.model || null
22
+ const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
23
+
24
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
25
+ const promptText = lastUserMsg?.content || ''
26
+
27
+ // Build system context from system messages
28
+ const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content)
29
+ const fullPrompt = systemParts.length
30
+ ? `${systemParts.join('\n\n')}\n\n${promptText}`
31
+ : promptText
32
+
33
+ const args = ['-p', fullPrompt, '-s']
34
+ if (model) args.push('--model', model)
35
+
36
+ return new Promise((resolve, reject) => {
37
+ execFile(binary, args, {
38
+ timeout: timeoutMs,
39
+ maxBuffer: 10 * 1024 * 1024,
40
+ }, (err, stdout, stderr) => {
41
+ if (err) {
42
+ const msg = stderr?.trim() || err.message
43
+ reject(new Error(`gemini-cli error: ${this.sanitizeErrorBody(msg)}`))
44
+ return
45
+ }
46
+ const text = stdout.trim()
47
+ if (!text) {
48
+ reject(new Error('gemini-cli returned no output'))
49
+ return
50
+ }
51
+ resolve(text)
52
+ })
53
+ })
54
+ }
55
+ }
@@ -0,0 +1,91 @@
1
+ import { BaseAdapter } from './base.js'
2
+
3
+ const DEFAULT_TIMEOUT_MS = 120_000
4
+
5
+ /**
6
+ * Adapter for OpenClaw gateway.
7
+ * Connects via HTTP API to the local (or remote) OpenClaw gateway.
8
+ */
9
+ export class OpenClawAdapter extends BaseAdapter {
10
+ get name() {
11
+ return 'openclaw'
12
+ }
13
+
14
+ isConfigured() {
15
+ return !!this.config.gatewayUrl && !!this.config.token
16
+ }
17
+
18
+ /**
19
+ * Check if the OpenClaw gateway is healthy.
20
+ * @returns {Promise<boolean>}
21
+ */
22
+ async healthCheck() {
23
+ try {
24
+ const url = `${this.config.gatewayUrl}/api/v1/health`
25
+ const res = await fetch(url, {
26
+ method: 'GET',
27
+ headers: this._headers(),
28
+ signal: AbortSignal.timeout(5000),
29
+ })
30
+ return res.ok
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ async complete(messages, opts = {}) {
37
+ const gatewayUrl = this.config.gatewayUrl
38
+ if (!gatewayUrl) throw new Error('OpenClaw gateway URL not configured')
39
+ if (!this.config.token) throw new Error('OpenClaw gateway token not configured')
40
+
41
+ // Check gateway health before making the request
42
+ const healthy = await this.healthCheck()
43
+ if (!healthy) {
44
+ throw new Error(`OpenClaw gateway not reachable at ${gatewayUrl}`)
45
+ }
46
+
47
+ const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
48
+ const agentId = this.config.agentId || null
49
+
50
+ const body = {
51
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
52
+ model: opts.model || this.config.model || null,
53
+ max_tokens: opts.maxTokens || 4096,
54
+ }
55
+ if (agentId) body.agent_id = agentId
56
+
57
+ const url = `${gatewayUrl}/api/v1/inference`
58
+ const res = await fetch(url, {
59
+ method: 'POST',
60
+ headers: {
61
+ ...this._headers(),
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ body: JSON.stringify(body),
65
+ signal: AbortSignal.timeout(timeoutMs),
66
+ })
67
+
68
+ if (!res.ok) {
69
+ const errText = await res.text().catch(() => '')
70
+ throw new Error(`OpenClaw error (${res.status}): ${this.sanitizeErrorBody(errText)}`)
71
+ }
72
+
73
+ const data = await res.json()
74
+
75
+ // Support both { text } and { choices[0].message.content } response shapes
76
+ const text = data.text
77
+ || data.choices?.[0]?.message?.content
78
+ || data.content
79
+ || ''
80
+
81
+ if (!text) throw new Error('OpenClaw returned no output')
82
+ return text.trim()
83
+ }
84
+
85
+ /** @private */
86
+ _headers() {
87
+ return {
88
+ Authorization: `Bearer ${this.config.token}`,
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Action handlers for the AI agent.
3
+ *
4
+ * Each handler wraps an existing library function and returns a
5
+ * result object suitable for feeding back into the conversation.
6
+ */
7
+
8
+ import { store } from '../config.js'
9
+ import { loadConfig, getConfigValue, findRepoRoot } from '../defaults.js'
10
+ import { ensureFresh, getCachedIssues, getCachedLabels, upsertCachedIssue } from '../cache.js'
11
+ import { readDrafts } from '../drafts.js'
12
+ import { listTemplates } from '../templates.js'
13
+ import { listEnhancements } from '../enhancements.js'
14
+ import { runCreate } from '../../commands/create.js'
15
+ import { checkSafety, getSafetyStatus } from '../safety.js'
16
+ import { bold, dim, cyan, green, yellow, red } from '../color.js'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Action registry
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Action schema definitions for the AI system prompt.
24
+ * Each entry: { name, description, params, requiresConfirmation }
25
+ */
26
+ export const ACTION_SCHEMAS = [
27
+ {
28
+ name: 'create_issue',
29
+ description: 'Create a new GitHub issue',
30
+ params: { title: 'string (required)', body: 'string', labels: 'string (comma-separated)', template: 'string', repo: 'string' },
31
+ requiresConfirmation: true,
32
+ },
33
+ {
34
+ name: 'list_issues',
35
+ description: 'List issues from the local cache',
36
+ params: { repo: 'string', state: '"open" | "closed"', limit: 'number' },
37
+ requiresConfirmation: false,
38
+ },
39
+ {
40
+ name: 'list_labels',
41
+ description: 'List labels for a repo',
42
+ params: { repo: 'string' },
43
+ requiresConfirmation: false,
44
+ },
45
+ {
46
+ name: 'list_drafts',
47
+ description: 'List pending issue drafts',
48
+ params: {},
49
+ requiresConfirmation: false,
50
+ },
51
+ {
52
+ name: 'show_config',
53
+ description: 'Show a config value',
54
+ params: { key: 'string (dot-notation, e.g. "ai.provider")' },
55
+ requiresConfirmation: false,
56
+ },
57
+ {
58
+ name: 'set_config',
59
+ description: 'Set a runtime config value',
60
+ params: { key: 'string', value: 'any' },
61
+ requiresConfirmation: true,
62
+ },
63
+ {
64
+ name: 'switch_repo',
65
+ description: 'Switch the active repository',
66
+ params: { repo: 'string (owner/name)' },
67
+ requiresConfirmation: true,
68
+ },
69
+ {
70
+ name: 'list_templates',
71
+ description: 'List available issue templates',
72
+ params: {},
73
+ requiresConfirmation: false,
74
+ },
75
+ {
76
+ name: 'list_enhancements',
77
+ description: 'List available AI enhancements',
78
+ params: {},
79
+ requiresConfirmation: false,
80
+ },
81
+ {
82
+ name: 'show_status',
83
+ description: 'Show safety status (circuit breaker, rate limits)',
84
+ params: { repo: 'string' },
85
+ requiresConfirmation: false,
86
+ },
87
+ {
88
+ name: 'answer',
89
+ description: 'Respond to the user with text (use when no action is needed)',
90
+ params: { text: 'string' },
91
+ requiresConfirmation: false,
92
+ },
93
+ ]
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Action handlers
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Execute an action by name with the given params.
101
+ *
102
+ * @param {string} actionName
103
+ * @param {object} params
104
+ * @param {{ activeRepo: string, config: object, repoRoot: string }} context
105
+ * @returns {Promise<{ success: boolean, result: any, display?: string }>}
106
+ */
107
+ export async function executeAction(actionName, params, context) {
108
+ const handler = handlers[actionName]
109
+ if (!handler) {
110
+ return { success: false, result: `Unknown action: ${actionName}` }
111
+ }
112
+ try {
113
+ return await handler(params, context)
114
+ } catch (err) {
115
+ return { success: false, result: err.message }
116
+ }
117
+ }
118
+
119
+ const handlers = {
120
+ async create_issue(params, context) {
121
+ const repo = params.repo || context.activeRepo
122
+ if (!repo) return { success: false, result: 'No repo specified and no active repo set.' }
123
+
124
+ const result = await runCreate({
125
+ repo,
126
+ title: params.title,
127
+ body: params.body || undefined,
128
+ labels: params.labels || undefined,
129
+ template: params.template || undefined,
130
+ enhance: true,
131
+ })
132
+
133
+ if (result) {
134
+ return {
135
+ success: true,
136
+ result: { number: result.number, url: result.url },
137
+ display: `${green('Issue created:')} ${cyan(result.url)}`,
138
+ }
139
+ }
140
+ return { success: true, result: 'Issue creation completed (dry-run or draft mode).' }
141
+ },
142
+
143
+ async list_issues(params, context) {
144
+ const repo = params.repo || context.activeRepo
145
+ if (!repo) return { success: false, result: 'No repo specified.' }
146
+
147
+ const state = params.state || 'open'
148
+ const limit = params.limit || 20
149
+ const issues = ensureFresh(repo, 'issues', { state, limit, acceptStale: true })
150
+ const display = issues.length === 0
151
+ ? dim('No issues found.')
152
+ : issues.map(i => ` #${i.number} ${i.title}${i.labels?.length ? ' ' + dim(i.labels.join(', ')) : ''}`).join('\n')
153
+
154
+ return {
155
+ success: true,
156
+ result: issues.map(i => ({ number: i.number, title: i.title, labels: i.labels, state: i.state })),
157
+ display: `${bold(`${state} issues in ${repo}`)} (${issues.length})\n${display}`,
158
+ }
159
+ },
160
+
161
+ async list_labels(params, context) {
162
+ const repo = params.repo || context.activeRepo
163
+ if (!repo) return { success: false, result: 'No repo specified.' }
164
+
165
+ const labels = ensureFresh(repo, 'labels', { acceptStale: true })
166
+ return {
167
+ success: true,
168
+ result: labels,
169
+ display: labels.length === 0
170
+ ? dim('No labels.')
171
+ : `${bold(`Labels in ${repo}`)} (${labels.length})\n ${labels.join(', ')}`,
172
+ }
173
+ },
174
+
175
+ async list_drafts(_params, context) {
176
+ const drafts = readDrafts(context.repoRoot)
177
+ const pending = drafts.filter(d => d.status === 'draft' || d.status === 'pending')
178
+ return {
179
+ success: true,
180
+ result: pending.map(d => ({ id: d.id, title: d.title, status: d.status, repo: d.repo })),
181
+ display: pending.length === 0
182
+ ? dim('No pending drafts.')
183
+ : pending.map(d => ` ${d.status === 'pending' ? yellow('pending') : dim('draft')} ${d.title}`).join('\n'),
184
+ }
185
+ },
186
+
187
+ async show_config(params, _context) {
188
+ const key = params.key
189
+ if (!key) return { success: false, result: 'No config key specified.' }
190
+ const value = getConfigValue(key)
191
+ return {
192
+ success: true,
193
+ result: value,
194
+ display: `${dim(key + ':')} ${typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}`,
195
+ }
196
+ },
197
+
198
+ async set_config(params, _context) {
199
+ const { key, value } = params
200
+ if (!key) return { success: false, result: 'No config key specified.' }
201
+ store.set(key, value)
202
+ return {
203
+ success: true,
204
+ result: { key, value },
205
+ display: `${green('Set')} ${key} = ${JSON.stringify(value)}`,
206
+ }
207
+ },
208
+
209
+ async switch_repo(params, _context) {
210
+ const { repo } = params
211
+ if (!repo) return { success: false, result: 'No repo specified.' }
212
+ store.set('activeRepo', repo)
213
+ return {
214
+ success: true,
215
+ result: { activeRepo: repo },
216
+ display: `${green('Switched to')} ${cyan(repo)}`,
217
+ }
218
+ },
219
+
220
+ async list_templates(_params, context) {
221
+ const templates = listTemplates(context.repoRoot)
222
+ const seen = new Set()
223
+ const unique = templates.filter(t => {
224
+ if (seen.has(t.key)) return false
225
+ seen.add(t.key)
226
+ return true
227
+ })
228
+ return {
229
+ success: true,
230
+ result: unique.map(t => ({ key: t.key, name: t.name, source: t.source })),
231
+ display: unique.map(t => ` ${t.key} ${dim(`(${t.source})`)}`).join('\n'),
232
+ }
233
+ },
234
+
235
+ async list_enhancements(_params, context) {
236
+ const enhancements = listEnhancements(context.repoRoot)
237
+ const seen = new Set()
238
+ const unique = enhancements.filter(e => {
239
+ if (seen.has(e.key)) return false
240
+ seen.add(e.key)
241
+ return true
242
+ })
243
+ return {
244
+ success: true,
245
+ result: unique.map(e => ({ key: e.key, name: e.name, mode: e.mode, source: e.source })),
246
+ display: unique.map(e => ` ${e.key} ${dim(e.mode)} ${dim(`(${e.source})`)}`).join('\n'),
247
+ }
248
+ },
249
+
250
+ async show_status(params, context) {
251
+ const repo = params.repo || context.activeRepo
252
+ if (!repo) return { success: false, result: 'No repo specified.' }
253
+
254
+ const config = context.config
255
+ const agent = config.attribution?.defaultAgent || 'human'
256
+ const status = getSafetyStatus(repo, agent, config.safety)
257
+ return {
258
+ success: true,
259
+ result: status,
260
+ display: status,
261
+ }
262
+ },
263
+
264
+ async answer(params, _context) {
265
+ return {
266
+ success: true,
267
+ result: params.text || '',
268
+ display: params.text || '',
269
+ }
270
+ },
271
+ }