tissues 0.6.1 → 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 (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. package/src/lib/templates.test.js +207 -0
package/README.md CHANGED
@@ -122,9 +122,64 @@ Manage issue templates interactively. View, edit, or create templates. Editing a
122
122
  tissues templates
123
123
  ```
124
124
 
125
+ ### `tissues ai`
126
+
127
+ AI-powered issue management. The agent translates natural language into JSON actions with built-in guardrails — it only handles tissues/GitHub operations and declines off-topic requests.
128
+
129
+ ```bash
130
+ # Single-shot: natural language → action
131
+ tissues ai list my open issues
132
+ tissues ai what repos do I have access to
133
+ tissues ai create a bug report about login failing on Safari
134
+
135
+ # Preview without executing
136
+ tissues ai --dry-run add a feature request for dark mode
137
+
138
+ # Multi-turn conversation
139
+ tissues ai --chat
140
+ tissues ai --chat "set up labels for our project"
141
+ ```
142
+
143
+ In chat mode, the agent can list issues, switch repos, manage config, create issues, and more — all from natural language. Test the agent with `npm run test:agent -- --mock`.
144
+
145
+ > See [AI Agent docs](docs/features/ai.md#ai-agent-tissues-ai) for prompt architecture, guardrails, and testing details.
146
+
147
+ ### `tissues sync`
148
+
149
+ Sync issues, labels, and repos to the local cache. The cache makes dedup checks instant and eliminates redundant network calls.
150
+
151
+ ```bash
152
+ # One-shot sync
153
+ tissues sync
154
+ tissues sync --repo owner/other-repo
155
+ tissues sync --force # full re-sync
156
+
157
+ # Background daemon
158
+ tissues sync start # fork daemon process
159
+ tissues sync status # check daemon health
160
+ tissues sync stop # stop daemon
161
+ tissues sync restart
162
+ ```
163
+
164
+ The daemon uses adaptive intervals (2 min when active, up to 60 min when idle) and incremental `since`-based fetching for issues.
165
+
166
+ ### `tissues storage`
167
+
168
+ Inspect and manage all cached/stored data.
169
+
170
+ ```bash
171
+ tissues storage # overview dashboard
172
+ tissues storage issues # per-repo issue breakdown
173
+ tissues storage clear issues # clear cached issues
174
+ tissues storage clear all # clear everything
175
+ tissues storage clear all --include-safety # also reset circuit breaker
176
+ tissues storage export # JSON dump for debugging
177
+ tissues storage --json # machine-readable overview
178
+ ```
179
+
125
180
  ### `tissues list`
126
181
 
127
- Browse open issues for the active repo.
182
+ Browse open issues for the active repo (served from local cache when fresh).
128
183
 
129
184
  ```bash
130
185
  tissues list
@@ -277,6 +332,22 @@ Configuration is loaded and merged from three sources in ascending priority orde
277
332
  }
278
333
  ```
279
334
 
335
+ Background sync configuration (opt-in):
336
+
337
+ ```json
338
+ {
339
+ "sync": {
340
+ "enabled": true,
341
+ "repos": [],
342
+ "interval": 300,
343
+ "adaptiveInterval": true,
344
+ "issueLimit": 500,
345
+ "includeClosedDays": 30,
346
+ "autoStart": false
347
+ }
348
+ }
349
+ ```
350
+
280
351
  AI keys go in user-level config only (`~/.config/tissues/config.json`) — never commit them:
281
352
 
282
353
  ```json
@@ -414,6 +485,12 @@ tissues stores local state in a SQLite database at:
414
485
  - `rate_events` — timestamped creation events (for rate limiting)
415
486
  - `circuit_breaker` — circuit state per repo+agent (status, failure count, cooldown)
416
487
  - `idempotency_keys` — deterministic keys for agent-driven idempotency
488
+ - `cached_issues` — locally cached GitHub issues for instant dedup and offline context
489
+ - `cached_labels` — locally cached repo labels
490
+ - `cached_repos` — locally cached repo list
491
+ - `sync_meta` — sync cursors and timestamps for incremental fetching
492
+
493
+ The cache tables are populated by `tissues sync` (manual or background daemon) and used transparently by `create`, `list`, `ai`, and dedup checks. Run `tissues storage` to inspect cache state.
417
494
 
418
495
  The database file can be committed to share dedup history across a team, or added to `.gitignore` to keep it local. It is created automatically on first use.
419
496
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tissues",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "AI-enhanced GitHub issue creation with built-in safety guardrails. Wraps gh CLI with circuit breakers, rate limiting, dedup, and templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,8 +12,10 @@
12
12
  "test": "node --experimental-test-module-mocks --test 'src/**/*.test.js'",
13
13
  "audit": "node scripts/audit.js",
14
14
  "build": "node scripts/build.js",
15
+ "prepublishOnly": "node scripts/audit.js",
15
16
  "dev:install": "node scripts/dev-install.js",
16
- "dev:uninstall": "node scripts/dev-install.js --uninstall"
17
+ "dev:uninstall": "node scripts/dev-install.js --uninstall",
18
+ "test:agent": "node scripts/test-agent.js"
17
19
  },
18
20
  "keywords": [
19
21
  "git",
package/src/cli.js CHANGED
@@ -10,6 +10,9 @@ import { templatesCommand } from './commands/templates.js'
10
10
  import { enhancementsCommand } from './commands/enhancements.js'
11
11
  import { draftsCommand } from './commands/drafts.js'
12
12
  import { aiCommand } from './commands/ai.js'
13
+ import { syncCommand, recordActivity } from './commands/sync.js'
14
+ import { storageCommand } from './commands/storage.js'
15
+ import { providersCommand } from './commands/providers.js'
13
16
  import { store } from './lib/config.js'
14
17
  import { requireGh } from './lib/gh.js'
15
18
  import { dim } from './lib/color.js'
@@ -22,7 +25,7 @@ function authDescription() {
22
25
 
23
26
  const SKIP_REPO_BANNER = new Set([
24
27
  'auth', 'login', 'status', 'switch', 'logout',
25
- 'config', 'drafts', 'publish', 'templates', 'enhancements', 'ai',
28
+ 'config', 'drafts', 'publish', 'templates', 'enhancements', 'ai', 'sync', 'storage', 'providers',
26
29
  ])
27
30
 
28
31
  program
@@ -42,6 +45,9 @@ program
42
45
  // Ensure gh is installed before any command
43
46
  requireGh()
44
47
 
48
+ // Record activity timestamp for adaptive sync interval
49
+ try { recordActivity() } catch { /* non-fatal */ }
50
+
45
51
  // Show active repo context on relevant commands only
46
52
  const parentName = actionCommand.parent?.name?.()
47
53
  if (!SKIP_REPO_BANNER.has(name) && !SKIP_REPO_BANNER.has(parentName)) {
@@ -71,3 +77,6 @@ program.addCommand(templatesCommand)
71
77
  program.addCommand(enhancementsCommand)
72
78
  program.addCommand(draftsCommand)
73
79
  program.addCommand(aiCommand)
80
+ program.addCommand(syncCommand)
81
+ program.addCommand(storageCommand)
82
+ program.addCommand(providersCommand)
@@ -1,64 +1,28 @@
1
+ /**
2
+ * AI command — general-purpose CLI operator with local context.
3
+ *
4
+ * Single-shot mode: backward-compatible NL → create_issue translation.
5
+ * Chat mode: multi-turn conversation for any tissues operation.
6
+ */
7
+
1
8
  import { Command } from 'commander'
2
- import { confirm } from '@inquirer/prompts'
9
+ import { input, confirm } from '@inquirer/prompts'
3
10
  import { store } from '../lib/config.js'
4
11
  import { theme } from '../lib/theme.js'
5
12
  import { loadConfig, findRepoRoot } from '../lib/defaults.js'
6
- import { requireAuth, getAuthenticatedUser, listLabels, listRepos } from '../lib/gh.js'
7
- import { listTemplates } from '../lib/templates.js'
13
+ import { requireAuth, getAuthenticatedUser } from '../lib/gh.js'
14
+ import { ensureFresh } from '../lib/cache.js'
8
15
  import { checkDuplicate } from '../lib/dedup.js'
9
16
  import { resolveRoute } from '../lib/ai/router.js'
10
17
  import { checkAvailable } from '../lib/ai/index.js'
18
+ import { runAgentSingleShot, runAgentLoop, parseAgentResponse } from '../lib/ai/agent.js'
19
+ import { ACTION_SCHEMAS, executeAction } from '../lib/ai/agent-actions.js'
11
20
  import { runCreate } from './create.js'
12
21
  import { red, yellow, dim, bold, cyan } from '../lib/color.js'
13
22
  import ora from 'ora'
14
23
 
15
24
  // ---------------------------------------------------------------------------
16
- // System prompt tells the AI how to build a `tissues create` invocation
17
- // ---------------------------------------------------------------------------
18
-
19
- function buildSystemPrompt(context) {
20
- const lines = [
21
- 'You translate natural language into a `tissues create` command.',
22
- 'Return ONLY a JSON object with the fields to pass to the create command.',
23
- '',
24
- 'Available fields:',
25
- ' "title" (required) — concise issue title',
26
- ' "body" (optional) — issue body in markdown',
27
- ' "labels" (optional) — comma-separated label names',
28
- ' "template" (optional) — template key to use',
29
- ' "repo" (optional) — "owner/name", omit to use the active repo',
30
- '',
31
- 'Rules:',
32
- '- Return ONLY valid JSON. No markdown fences, no explanation.',
33
- '- Write a clear, actionable title.',
34
- '- If the user gives enough detail, write a well-structured markdown body.',
35
- '- Only include fields the user mentions or that you can clearly infer.',
36
- '- Pick labels from the available labels when appropriate.',
37
- '- Pick a template that fits the issue type (bug, feature, default).',
38
- ]
39
-
40
- if (context.activeRepo) {
41
- lines.push(`\nActive repo: ${context.activeRepo}`)
42
- }
43
- if (context.user) {
44
- lines.push(`Authenticated user: ${context.user}`)
45
- }
46
- if (context.repos?.length) {
47
- lines.push(`Available repos: ${context.repos.join(', ')}`)
48
- }
49
- if (context.templates?.length) {
50
- const keys = [...new Set(context.templates.map((t) => t.key))]
51
- lines.push(`Available templates: ${keys.join(', ')}`)
52
- }
53
- if (context.labels?.length) {
54
- lines.push(`Labels on active repo: ${context.labels.join(', ')}`)
55
- }
56
-
57
- return lines.join('\n')
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // Gather context for the AI prompt
25
+ // Gather context from cache (zero network calls if warm)
62
26
  // ---------------------------------------------------------------------------
63
27
 
64
28
  function gatherContext(opts) {
@@ -69,151 +33,163 @@ function gatherContext(opts) {
69
33
  const context = { activeRepo, config, repoRoot }
70
34
 
71
35
  try { context.user = getAuthenticatedUser() } catch { /* non-fatal */ }
72
- try { context.templates = listTemplates(repoRoot) } catch { /* non-fatal */ }
73
- try { context.repos = listRepos({ limit: 30 }) } catch { /* non-fatal */ }
74
-
75
- if (activeRepo) {
76
- try { context.labels = listLabels(activeRepo) } catch { /* non-fatal */ }
77
- }
78
36
 
79
37
  return context
80
38
  }
81
39
 
82
- // ---------------------------------------------------------------------------
83
- // Parse AI response into runCreate opts
84
- // ---------------------------------------------------------------------------
85
-
86
- function parseCreateOpts(raw) {
87
- let cleaned = raw.trim()
88
- if (cleaned.startsWith('```')) {
89
- cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '')
90
- }
91
-
92
- const parsed = JSON.parse(cleaned)
93
- if (!parsed.title) throw new Error('AI response missing "title"')
94
- return parsed
95
- }
96
-
97
40
  // ---------------------------------------------------------------------------
98
41
  // Command
99
42
  // ---------------------------------------------------------------------------
100
43
 
101
44
  export const aiCommand = new Command('ai')
102
- .description('Create an issue from a natural language prompt')
103
- .argument('[prompt...]', 'Describe the issue you want to create')
104
- .option('--dry-run', 'Preview the create command without executing')
105
- .option('--yes, -y', 'Skip confirmation, run create immediately')
45
+ .description('AI-powered issue management (single-shot or chat)')
46
+ .argument('[prompt...]', 'Describe what you want to do')
47
+ .option('--chat', 'Start a multi-turn conversation')
48
+ .option('--dry-run', 'Preview actions without executing')
49
+ .option('--yes, -y', 'Skip confirmation, run action immediately')
106
50
  .option('--repo <repo>', 'Override active repo')
107
51
  .option('--enhancements <names>', 'Comma-separated enhancement names to run')
108
52
  .option('--provider <name>', 'AI provider override')
109
53
  .option('--model <name>', 'AI model override')
110
54
  .action(async (promptWords, opts) => {
111
- if (!promptWords || promptWords.length === 0) {
55
+ const prompt = promptWords?.length ? promptWords.join(' ') : null
56
+ const isChat = opts.chat || false
57
+
58
+ if (!prompt && !isChat) {
112
59
  aiCommand.help()
113
60
  return
114
61
  }
115
- const prompt = promptWords.join(' ')
116
62
 
117
63
  requireAuth()
118
64
 
119
- // Gather context (no spinner — fast local calls)
120
65
  const context = gatherContext(opts)
121
66
  const { config } = context
122
- const repo = opts.repo || context.activeRepo
123
-
124
67
  const aiContext = { provider: opts.provider, model: opts.model }
125
68
 
126
- // Early dedup check — before any LLM call
127
- if (repo) {
128
- const dedupSpinner = ora('Checking for duplicates...').start()
129
- try {
130
- const dedupResult = await checkDuplicate(repo, { title: prompt, agent: 'ai' })
131
- dedupSpinner.stop()
69
+ // Check AI is available
70
+ if (!checkAvailable(config, aiContext)) {
71
+ console.warn(yellow('AI not configured.'))
72
+ console.warn(dim('Run `tissues config` to set up an AI provider.\n'))
73
+ return
74
+ }
132
75
 
133
- if (dedupResult.action === 'block') {
134
- const blockReasons = dedupResult.results.filter((r) => r.action === 'block')
135
- for (const r of blockReasons) {
136
- console.error(r.reason)
137
- if (r.existingIssue?.number) {
138
- console.error(dim(` #${r.existingIssue.number}${r.existingIssue.title ? ` — ${r.existingIssue.title}` : ''}`))
139
- if (r.existingIssue.url) console.error(dim(` ${r.existingIssue.url}`))
140
- }
141
- }
142
- console.error(red('Duplicate detected — issue not created.'))
143
- process.exit(1)
144
- }
76
+ // -----------------------------------------------------------------------
77
+ // Chat mode
78
+ // -----------------------------------------------------------------------
79
+ if (isChat) {
80
+ console.log(bold('\ntissues ai') + dim(' — chat mode (type "exit" to quit)\n'))
145
81
 
146
- if (dedupResult.action === 'warn') {
147
- console.warn(yellow('Similar issue(s) found:'))
148
- for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
149
- console.warn(r.reason)
150
- if (r.existingIssue?.number) {
151
- console.warn(dim(` #${r.existingIssue.number}${r.existingIssue.title ? ` — ${r.existingIssue.title}` : ''}`))
152
- if (r.existingIssue.url) console.warn(dim(` ${r.existingIssue.url}`))
153
- }
154
- }
155
- console.warn()
156
- const proceed = await confirm({ message: 'Continue anyway?', default: false, theme })
157
- if (!proceed) {
158
- console.log(dim('Aborted.'))
159
- process.exit(0)
160
- }
161
- }
162
- } catch {
163
- dedupSpinner.stop()
164
- // Non-fatal — proceed without dedup
82
+ // If a prompt was given with --chat, process it first
83
+ if (prompt) {
84
+ await handleSingleTurn(prompt, config, context, opts)
165
85
  }
166
- }
167
86
 
168
- // If AI isn't configured at all, go straight to create with the prompt as title
169
- if (!checkAvailable(config, aiContext)) {
170
- console.warn(yellow('AI not configured — using prompt as issue title.'))
171
- console.warn(dim('Run `tissues config` to set up an AI provider.\n'))
172
- return fallbackToCreate(prompt, opts)
87
+ await runAgentLoop(config, context, {
88
+ provider: opts.provider,
89
+ model: opts.model,
90
+ onInput: async () => {
91
+ try {
92
+ const userInput = await input({ message: '>', theme })
93
+ return userInput
94
+ } catch {
95
+ return null // Ctrl+C
96
+ }
97
+ },
98
+ onOutput: (text) => {
99
+ console.log(text)
100
+ console.log()
101
+ },
102
+ })
103
+ return
173
104
  }
174
105
 
175
- // Ask AI to build the create options
176
- const systemPrompt = buildSystemPrompt(context)
177
- const messages = [
178
- { role: 'system', content: systemPrompt },
179
- { role: 'user', content: prompt },
180
- ]
106
+ // -----------------------------------------------------------------------
107
+ // Single-shot mode (backward compatible)
108
+ // -----------------------------------------------------------------------
109
+ await handleSingleTurn(prompt, config, context, opts)
110
+ })
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Single-turn handler
114
+ // ---------------------------------------------------------------------------
115
+
116
+ async function handleSingleTurn(prompt, config, context, opts) {
117
+ const repo = opts.repo || context.activeRepo
181
118
 
182
- const { adapter, model } = resolveRoute(config, aiContext)
183
- const spinner = ora('Building create command...').start()
184
- let raw
119
+ // Early dedup check for create-like prompts
120
+ if (repo) {
185
121
  try {
186
- raw = await adapter.complete(messages, { model, maxTokens: 2048 })
187
- spinner.stop()
188
- } catch (err) {
189
- spinner.fail('AI failed')
190
- if (/\b401\b/.test(err.message)) {
191
- console.warn(red(' API key is invalid or expired.'))
192
- console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
122
+ const dedupResult = await checkDuplicate(repo, { title: prompt, agent: 'ai' })
123
+ if (dedupResult.action === 'block') {
124
+ const blockReasons = dedupResult.results.filter(r => r.action === 'block')
125
+ for (const r of blockReasons) {
126
+ console.error(r.reason)
127
+ if (r.existingIssue?.number) {
128
+ console.error(dim(` #${r.existingIssue.number}${r.existingIssue.title ? ` ${r.existingIssue.title}` : ''}`))
129
+ if (r.existingIssue.url) console.error(dim(` ${r.existingIssue.url}`))
130
+ }
131
+ }
132
+ console.error(red('Duplicate detected — issue not created.'))
133
+ process.exit(1)
193
134
  }
194
- return fallbackToCreate(prompt, opts)
195
- }
196
135
 
197
- let plan
198
- try {
199
- plan = parseCreateOpts(raw)
136
+ if (dedupResult.action === 'warn') {
137
+ console.warn(yellow('Similar issue(s) found:'))
138
+ for (const r of dedupResult.results.filter(r => r.action === 'warn')) {
139
+ console.warn(r.reason)
140
+ if (r.existingIssue?.number) {
141
+ console.warn(dim(` #${r.existingIssue.number}${r.existingIssue.title ? ` — ${r.existingIssue.title}` : ''}`))
142
+ }
143
+ }
144
+ console.warn()
145
+ const proceed = await confirm({ message: 'Continue anyway?', default: false, theme })
146
+ if (!proceed) {
147
+ console.log(dim('Aborted.'))
148
+ process.exit(0)
149
+ }
150
+ }
200
151
  } catch {
201
- return fallbackToCreate(prompt, opts)
152
+ // Non-fatal dedup check
202
153
  }
154
+ }
203
155
 
204
- // Show what create will receive
205
- const planRepo = plan.repo || context.activeRepo
156
+ // Ask AI
157
+ const spinner = ora('Thinking...').start()
158
+ let agentResult
159
+ try {
160
+ agentResult = await runAgentSingleShot(prompt, config, context, {
161
+ provider: opts.provider,
162
+ model: opts.model,
163
+ })
164
+ spinner.stop()
165
+ } catch (err) {
166
+ spinner.fail('AI failed')
167
+ if (/\b401\b/.test(err.message)) {
168
+ console.warn(red(' API key is invalid or expired.'))
169
+ console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
170
+ } else {
171
+ console.warn(red(` ${err.message}`))
172
+ }
173
+ console.warn(dim(' Tip: use --chat for interactive mode, or try rephrasing.\n'))
174
+ return
175
+ }
176
+
177
+ const { action, params } = agentResult
178
+
179
+ // Handle create_issue action (backward compatible preview + confirm flow)
180
+ if (action === 'create_issue') {
181
+ const planRepo = params.repo || context.activeRepo
206
182
  console.log()
207
183
  console.log(bold(' tissues create'))
208
- console.log(` ${dim('--title')} ${plan.title}`)
184
+ console.log(` ${dim('--title')} ${params.title}`)
209
185
  if (planRepo) console.log(` ${dim('--repo')} ${planRepo}`)
210
- if (plan.template) console.log(` ${dim('--template')} ${plan.template}`)
211
- if (plan.labels) console.log(` ${dim('--labels')} ${plan.labels}`)
212
- if (plan.body) {
186
+ if (params.template) console.log(` ${dim('--template')} ${params.template}`)
187
+ if (params.labels) console.log(` ${dim('--labels')} ${params.labels}`)
188
+ if (params.body) {
213
189
  console.log(` ${dim('--body')}`)
214
- const preview = plan.body.length > 400
215
- ? plan.body.slice(0, 400) + dim('\n ...(truncated)')
216
- : plan.body
190
+ const preview = params.body.length > 400
191
+ ? params.body.slice(0, 400) + dim('\n ...(truncated)')
192
+ : params.body
217
193
  for (const line of preview.split('\n')) {
218
194
  console.log(` ${dim(line)}`)
219
195
  }
@@ -233,36 +209,34 @@ export const aiCommand = new Command('ai')
233
209
  }
234
210
  }
235
211
 
236
- // Hand off to the same runCreate that `tissues create` uses
237
212
  try {
238
213
  const result = await runCreate({
239
214
  repo: planRepo,
240
- title: plan.title,
241
- body: plan.body || undefined,
242
- labels: plan.labels || undefined,
243
- template: plan.template || undefined,
215
+ title: params.title,
216
+ body: params.body || undefined,
217
+ labels: params.labels || undefined,
218
+ template: params.template || undefined,
244
219
  enhancements: opts.enhancements || undefined,
245
220
  })
246
221
  if (result === null) process.exit(0)
247
222
  } catch (err) {
248
223
  process.exit(err.isAbort ? 0 : 1)
249
224
  }
250
- })
225
+ return
226
+ }
251
227
 
252
- // ---------------------------------------------------------------------------
253
- // Fallback AI unavailable, run create with the prompt as the title
254
- // ---------------------------------------------------------------------------
228
+ // Handle answer action
229
+ if (action === 'answer') {
230
+ console.log(params.text || params.content || '')
231
+ return
232
+ }
255
233
 
256
- async function fallbackToCreate(prompt, opts) {
257
- try {
258
- const result = await runCreate({
259
- repo: opts.repo || undefined,
260
- _titleDefault: prompt,
261
- dryRun: opts.dryRun || false,
262
- enhance: true, // let create's own AI enhancement try (it handles failure gracefully)
263
- })
264
- if (result === null) process.exit(0)
265
- } catch (err) {
266
- process.exit(err.isAbort ? 0 : 1)
234
+ // Handle other read-only actions
235
+ const result = await executeAction(action, params || {}, { ...context, config })
236
+ if (result.display) {
237
+ console.log(result.display)
238
+ } else if (result.result) {
239
+ console.log(typeof result.result === 'string' ? result.result : JSON.stringify(result.result, null, 2))
267
240
  }
268
241
  }
242
+
@@ -15,11 +15,11 @@ import {
15
15
  createIssue,
16
16
  verifyIssue,
17
17
  listLabels,
18
- listIssues,
19
18
  createLabel,
20
19
  addLabelsToIssue,
21
20
  uploadImageToRepo,
22
21
  } from '../lib/gh.js'
22
+ import { ensureFresh } from '../lib/cache.js'
23
23
  import { enhance, checkAvailable } from '../lib/ai/index.js'
24
24
  import { runEnhancePipeline } from '../lib/ai/enhance.js'
25
25
  import { clipboardHasImage, saveClipboardImage } from '../lib/clipboard.js'
@@ -420,11 +420,11 @@ export async function runCreate(opts) {
420
420
  // Multi-step pipeline enhancement
421
421
  let aiSpinner = ora('Enhancing with AI pipeline...').start()
422
422
  try {
423
- // Gather existing issues and repo labels for pipeline context
423
+ // Gather existing issues and repo labels for pipeline context (from cache)
424
424
  let existingIssues = []
425
425
  let repoLabels = []
426
- try { existingIssues = listIssues(repo) } catch { /* non-fatal */ }
427
- try { repoLabels = listLabels(repo) } catch { /* non-fatal */ }
426
+ try { existingIssues = ensureFresh(repo, 'issues', { state: 'open', acceptStale: true }) } catch { /* non-fatal */ }
427
+ try { repoLabels = ensureFresh(repo, 'labels', { acceptStale: true }) } catch { /* non-fatal */ }
428
428
 
429
429
  // When --title is explicit, skip triage (no rawInput to analyze)
430
430
  const rawInput = opts.title ? '' : (opts._rawInput || '')
@@ -630,11 +630,11 @@ export async function runCreate(opts) {
630
630
 
631
631
  if (labels.length > 0) {
632
632
  try {
633
- const repoLabelNames = listLabels(repo)
633
+ const repoLabelNames = ensureFresh(repo, 'labels', { acceptStale: true })
634
634
  existingLabels = labels.filter((l) => repoLabelNames.includes(l))
635
635
  missingLabels = labels.filter((l) => !repoLabelNames.includes(l))
636
636
  } catch {
637
- // If listLabels fails, proceed with all labels (original behavior)
637
+ // If label check fails, proceed with all labels (original behavior)
638
638
  }
639
639
  }
640
640