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
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.0",
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
@@ -7,8 +7,12 @@ import { createCommand, draftCommand } from './commands/create.js'
7
7
  import { listCommand } from './commands/list.js'
8
8
  import { statusCommand } from './commands/status.js'
9
9
  import { templatesCommand } from './commands/templates.js'
10
+ import { enhancementsCommand } from './commands/enhancements.js'
10
11
  import { draftsCommand } from './commands/drafts.js'
11
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'
12
16
  import { store } from './lib/config.js'
13
17
  import { requireGh } from './lib/gh.js'
14
18
  import { dim } from './lib/color.js'
@@ -21,7 +25,7 @@ function authDescription() {
21
25
 
22
26
  const SKIP_REPO_BANNER = new Set([
23
27
  'auth', 'login', 'status', 'switch', 'logout',
24
- 'config', 'drafts', 'publish', 'templates', 'ai',
28
+ 'config', 'drafts', 'publish', 'templates', 'enhancements', 'ai', 'sync', 'storage', 'providers',
25
29
  ])
26
30
 
27
31
  program
@@ -41,6 +45,9 @@ program
41
45
  // Ensure gh is installed before any command
42
46
  requireGh()
43
47
 
48
+ // Record activity timestamp for adaptive sync interval
49
+ try { recordActivity() } catch { /* non-fatal */ }
50
+
44
51
  // Show active repo context on relevant commands only
45
52
  const parentName = actionCommand.parent?.name?.()
46
53
  if (!SKIP_REPO_BANNER.has(name) && !SKIP_REPO_BANNER.has(parentName)) {
@@ -67,5 +74,9 @@ program.addCommand(draftCommand)
67
74
  program.addCommand(listCommand)
68
75
  program.addCommand(statusCommand)
69
76
  program.addCommand(templatesCommand)
77
+ program.addCommand(enhancementsCommand)
70
78
  program.addCommand(draftsCommand)
71
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,150 +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')
51
+ .option('--enhancements <names>', 'Comma-separated enhancement names to run')
107
52
  .option('--provider <name>', 'AI provider override')
108
53
  .option('--model <name>', 'AI model override')
109
54
  .action(async (promptWords, opts) => {
110
- if (!promptWords || promptWords.length === 0) {
55
+ const prompt = promptWords?.length ? promptWords.join(' ') : null
56
+ const isChat = opts.chat || false
57
+
58
+ if (!prompt && !isChat) {
111
59
  aiCommand.help()
112
60
  return
113
61
  }
114
- const prompt = promptWords.join(' ')
115
62
 
116
63
  requireAuth()
117
64
 
118
- // Gather context (no spinner — fast local calls)
119
65
  const context = gatherContext(opts)
120
66
  const { config } = context
121
- const repo = opts.repo || context.activeRepo
122
-
123
67
  const aiContext = { provider: opts.provider, model: opts.model }
124
68
 
125
- // Early dedup check — before any LLM call
126
- if (repo) {
127
- const dedupSpinner = ora('Checking for duplicates...').start()
128
- try {
129
- const dedupResult = await checkDuplicate(repo, { title: prompt, agent: 'ai' })
130
- 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
+ }
131
75
 
132
- if (dedupResult.action === 'block') {
133
- const blockReasons = dedupResult.results.filter((r) => r.action === 'block')
134
- for (const r of blockReasons) {
135
- console.error(r.reason)
136
- if (r.existingIssue?.number) {
137
- console.error(dim(` #${r.existingIssue.number}${r.existingIssue.title ? ` — ${r.existingIssue.title}` : ''}`))
138
- if (r.existingIssue.url) console.error(dim(` ${r.existingIssue.url}`))
139
- }
140
- }
141
- console.error(red('Duplicate detected — issue not created.'))
142
- process.exit(1)
143
- }
76
+ // -----------------------------------------------------------------------
77
+ // Chat mode
78
+ // -----------------------------------------------------------------------
79
+ if (isChat) {
80
+ console.log(bold('\ntissues ai') + dim(' — chat mode (type "exit" to quit)\n'))
144
81
 
145
- if (dedupResult.action === 'warn') {
146
- console.warn(yellow('Similar issue(s) found:'))
147
- for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
148
- console.warn(r.reason)
149
- if (r.existingIssue?.number) {
150
- console.warn(dim(` #${r.existingIssue.number}${r.existingIssue.title ? ` — ${r.existingIssue.title}` : ''}`))
151
- if (r.existingIssue.url) console.warn(dim(` ${r.existingIssue.url}`))
152
- }
153
- }
154
- console.warn()
155
- const proceed = await confirm({ message: 'Continue anyway?', default: false, theme })
156
- if (!proceed) {
157
- console.log(dim('Aborted.'))
158
- process.exit(0)
159
- }
160
- }
161
- } catch {
162
- dedupSpinner.stop()
163
- // 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)
164
85
  }
165
- }
166
86
 
167
- // If AI isn't configured at all, go straight to create with the prompt as title
168
- if (!checkAvailable(config, aiContext)) {
169
- console.warn(yellow('AI not configured — using prompt as issue title.'))
170
- console.warn(dim('Run `tissues config` to set up an AI provider.\n'))
171
- 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
172
104
  }
173
105
 
174
- // Ask AI to build the create options
175
- const systemPrompt = buildSystemPrompt(context)
176
- const messages = [
177
- { role: 'system', content: systemPrompt },
178
- { role: 'user', content: prompt },
179
- ]
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
180
118
 
181
- const { adapter, model } = resolveRoute(config, aiContext)
182
- const spinner = ora('Building create command...').start()
183
- let raw
119
+ // Early dedup check for create-like prompts
120
+ if (repo) {
184
121
  try {
185
- raw = await adapter.complete(messages, { model, maxTokens: 2048 })
186
- spinner.stop()
187
- } catch (err) {
188
- spinner.fail('AI failed')
189
- if (/\b401\b/.test(err.message)) {
190
- console.warn(red(' API key is invalid or expired.'))
191
- 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)
192
134
  }
193
- return fallbackToCreate(prompt, opts)
194
- }
195
135
 
196
- let plan
197
- try {
198
- 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
+ }
199
151
  } catch {
200
- return fallbackToCreate(prompt, opts)
152
+ // Non-fatal dedup check
201
153
  }
154
+ }
202
155
 
203
- // Show what create will receive
204
- 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
205
182
  console.log()
206
183
  console.log(bold(' tissues create'))
207
- console.log(` ${dim('--title')} ${plan.title}`)
184
+ console.log(` ${dim('--title')} ${params.title}`)
208
185
  if (planRepo) console.log(` ${dim('--repo')} ${planRepo}`)
209
- if (plan.template) console.log(` ${dim('--template')} ${plan.template}`)
210
- if (plan.labels) console.log(` ${dim('--labels')} ${plan.labels}`)
211
- 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) {
212
189
  console.log(` ${dim('--body')}`)
213
- const preview = plan.body.length > 400
214
- ? plan.body.slice(0, 400) + dim('\n ...(truncated)')
215
- : plan.body
190
+ const preview = params.body.length > 400
191
+ ? params.body.slice(0, 400) + dim('\n ...(truncated)')
192
+ : params.body
216
193
  for (const line of preview.split('\n')) {
217
194
  console.log(` ${dim(line)}`)
218
195
  }
@@ -232,35 +209,34 @@ export const aiCommand = new Command('ai')
232
209
  }
233
210
  }
234
211
 
235
- // Hand off to the same runCreate that `tissues create` uses
236
212
  try {
237
213
  const result = await runCreate({
238
214
  repo: planRepo,
239
- title: plan.title,
240
- body: plan.body || undefined,
241
- labels: plan.labels || undefined,
242
- template: plan.template || undefined,
215
+ title: params.title,
216
+ body: params.body || undefined,
217
+ labels: params.labels || undefined,
218
+ template: params.template || undefined,
219
+ enhancements: opts.enhancements || undefined,
243
220
  })
244
221
  if (result === null) process.exit(0)
245
222
  } catch (err) {
246
223
  process.exit(err.isAbort ? 0 : 1)
247
224
  }
248
- })
225
+ return
226
+ }
249
227
 
250
- // ---------------------------------------------------------------------------
251
- // Fallback AI unavailable, run create with the prompt as the title
252
- // ---------------------------------------------------------------------------
228
+ // Handle answer action
229
+ if (action === 'answer') {
230
+ console.log(params.text || params.content || '')
231
+ return
232
+ }
253
233
 
254
- async function fallbackToCreate(prompt, opts) {
255
- try {
256
- const result = await runCreate({
257
- repo: opts.repo || undefined,
258
- _titleDefault: prompt,
259
- dryRun: opts.dryRun || false,
260
- enhance: true, // let create's own AI enhancement try (it handles failure gracefully)
261
- })
262
- if (result === null) process.exit(0)
263
- } catch (err) {
264
- 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))
265
240
  }
266
241
  }
242
+