tissues 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +58 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { store } from '../lib/config.js'
|
|
3
|
+
import { loadConfig, findRepoRoot } from '../lib/defaults.js'
|
|
4
|
+
import { readDrafts, markComplete, markDuplicate, markFailed, countPending } from '../lib/drafts.js'
|
|
5
|
+
import { checkSafety, recordSuccess, recordFailure } from '../lib/safety.js'
|
|
6
|
+
import { checkDuplicate, recordCreation } from '../lib/dedup.js'
|
|
7
|
+
import { createIssue, verifyIssue } from '../lib/gh.js'
|
|
8
|
+
import { bold, dim, red } from '../lib/color.js'
|
|
9
|
+
import ora from 'ora'
|
|
10
|
+
import { select, confirm } from '@inquirer/prompts'
|
|
11
|
+
import { theme } from '../lib/theme.js'
|
|
12
|
+
|
|
13
|
+
function isCancelled(err) {
|
|
14
|
+
return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function promptOrBack(fn) {
|
|
18
|
+
try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function timeAgo(isoString) {
|
|
22
|
+
const ts = new Date(isoString)
|
|
23
|
+
const diffMs = Date.now() - ts.getTime()
|
|
24
|
+
if (diffMs < 0) return 'just now'
|
|
25
|
+
const seconds = Math.floor(diffMs / 1000)
|
|
26
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
27
|
+
const minutes = Math.floor(diffMs / 60_000)
|
|
28
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
29
|
+
const hours = Math.floor(diffMs / 3_600_000)
|
|
30
|
+
if (hours < 24) return `${hours}h ago`
|
|
31
|
+
const days = Math.floor(diffMs / 86_400_000)
|
|
32
|
+
return `${days}d ago`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Publish logic — creates GitHub issues from pending drafts
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
async function publishItems(repo, repoRoot, config, agent) {
|
|
40
|
+
const allItems = readDrafts(repoRoot)
|
|
41
|
+
const flushable = new Set(['pending', 'draft'])
|
|
42
|
+
const items = allItems.filter((item) => item.repo === repo && flushable.has(item.status))
|
|
43
|
+
|
|
44
|
+
if (items.length === 0) {
|
|
45
|
+
const total = countPending(repoRoot)
|
|
46
|
+
if (total > 0) {
|
|
47
|
+
console.log(dim(`No pending items for ${repo}.`))
|
|
48
|
+
console.log(dim(`(${total} item(s) belong to other repos or have failed)`))
|
|
49
|
+
} else {
|
|
50
|
+
console.log(dim('No drafts to publish.'))
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(bold(`Publishing ${items.length} issue${items.length === 1 ? '' : 's'}...\n`))
|
|
56
|
+
|
|
57
|
+
const flushed = []
|
|
58
|
+
const skipped = []
|
|
59
|
+
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const safetyResult = checkSafety(repo, agent, config.safety)
|
|
62
|
+
if (!safetyResult.allowed) {
|
|
63
|
+
const remaining = items.length - flushed.length - skipped.length
|
|
64
|
+
console.log()
|
|
65
|
+
console.log(dim(` ⏸ Sent ${flushed.length}/${items.length}. Rate limit reached — ${remaining} still in drafts.`))
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const dedupSpinner = ora(`Checking: "${item.title}"`).start()
|
|
70
|
+
let dedupResult
|
|
71
|
+
try {
|
|
72
|
+
dedupResult = await checkDuplicate(repo, { title: item.title, body: item.body, agent })
|
|
73
|
+
dedupSpinner.stop()
|
|
74
|
+
} catch (err) {
|
|
75
|
+
dedupSpinner.warn(`Dedup failed (skipping): ${err.message}`)
|
|
76
|
+
dedupResult = { action: 'allow', results: [] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (dedupResult.action === 'block') {
|
|
80
|
+
recordFailure(repo, agent, config.safety)
|
|
81
|
+
console.log(dim(` ⊘ Skipping "${item.title}" — duplicate`))
|
|
82
|
+
const reason = dedupResult.results.filter((r) => r.action === 'block').map((r) => r.reason).join('; ')
|
|
83
|
+
markDuplicate(item.id, reason, repoRoot)
|
|
84
|
+
skipped.push(item)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const createSpinner = ora(`Creating: "${item.title}"`).start()
|
|
89
|
+
let issue
|
|
90
|
+
try {
|
|
91
|
+
issue = createIssue(repo, { title: item.title, body: item.body, labels: item.labels ?? [] })
|
|
92
|
+
createSpinner.succeed(`Created: ${issue.url}`)
|
|
93
|
+
} catch (err) {
|
|
94
|
+
createSpinner.fail(`Failed: "${item.title}" — ${err.message}`)
|
|
95
|
+
markFailed(item.id, err.message, repoRoot)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const verified = verifyIssue(repo, issue.number)
|
|
100
|
+
if (!verified) {
|
|
101
|
+
console.warn(dim(` #${issue.number} could not be verified — keeping in drafts`))
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
markComplete(item.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
|
|
106
|
+
try {
|
|
107
|
+
await recordCreation(repo, { title: item.title, body: item.body, issueNumber: issue.number, url: issue.url, agent })
|
|
108
|
+
} catch { /* non-fatal */ }
|
|
109
|
+
recordSuccess(repo, agent)
|
|
110
|
+
flushed.push({ title: item.title, number: issue.number, url: issue.url })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log()
|
|
114
|
+
if (flushed.length > 0) {
|
|
115
|
+
console.log(dim(` ✓ Published ${flushed.length} issue${flushed.length === 1 ? '' : 's'}`))
|
|
116
|
+
}
|
|
117
|
+
if (skipped.length > 0) {
|
|
118
|
+
console.log(dim(` ⊘ Skipped ${skipped.length} duplicate${skipped.length === 1 ? '' : 's'}`))
|
|
119
|
+
}
|
|
120
|
+
const stillPending = countPending(repoRoot)
|
|
121
|
+
if (stillPending === 0 && flushed.length > 0) {
|
|
122
|
+
console.log(dim(' No drafts remaining.'))
|
|
123
|
+
} else if (stillPending > 0) {
|
|
124
|
+
console.log(dim(` ${stillPending} item(s) still in drafts.`))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Interactive drafts management
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
async function manageDrafts(repo, repoRoot) {
|
|
133
|
+
while (true) {
|
|
134
|
+
const allItems = readDrafts(repoRoot)
|
|
135
|
+
const repoItems = allItems.filter((item) => item.repo === repo)
|
|
136
|
+
const flushable = new Set(['pending', 'draft'])
|
|
137
|
+
const pending = repoItems.filter((item) => flushable.has(item.status))
|
|
138
|
+
const failed = repoItems.filter((item) => item.status === 'failed')
|
|
139
|
+
const duplicates = repoItems.filter((item) => item.status === 'duplicate')
|
|
140
|
+
const aborted = repoItems.filter((item) => item.status === 'aborted')
|
|
141
|
+
const otherRepoCount = allItems.length - repoItems.length
|
|
142
|
+
|
|
143
|
+
console.log()
|
|
144
|
+
if (repoItems.length === 0 && otherRepoCount === 0) {
|
|
145
|
+
console.log(dim(' No drafts.'))
|
|
146
|
+
return
|
|
147
|
+
} else if (repoItems.length === 0) {
|
|
148
|
+
console.log(dim(` No items for ${repo}.`))
|
|
149
|
+
if (otherRepoCount > 0) console.log(dim(` ${otherRepoCount} in other repos`))
|
|
150
|
+
} else {
|
|
151
|
+
if (pending.length > 0) console.log(dim(` ${pending.length} pending for ${repo}`))
|
|
152
|
+
if (failed.length > 0) console.log(dim(` ${failed.length} failed for ${repo}`))
|
|
153
|
+
if (duplicates.length > 0) console.log(dim(` ${duplicates.length} duplicate for ${repo}`))
|
|
154
|
+
if (aborted.length > 0) console.log(dim(` ${aborted.length} aborted for ${repo}`))
|
|
155
|
+
if (otherRepoCount > 0) console.log(dim(` ${otherRepoCount} in other repos`))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const choices = []
|
|
159
|
+
if (pending.length > 0) choices.push({ name: `Publish ${pending.length} pending`, value: 'publish' })
|
|
160
|
+
if (repoItems.length > 0) choices.push({ name: 'View items', value: 'view' })
|
|
161
|
+
if (failed.length > 0) choices.push({ name: `Retry ${failed.length} failed`, value: 'retry' })
|
|
162
|
+
if (repoItems.length > 0) choices.push({ name: 'Delete all', value: 'delete-all' })
|
|
163
|
+
choices.push({ name: 'Done', value: 'done' })
|
|
164
|
+
|
|
165
|
+
const action = await promptOrBack(() => select({ message: 'Drafts', choices, theme }))
|
|
166
|
+
if (action === Symbol.for('back') || action === 'done') return
|
|
167
|
+
|
|
168
|
+
if (action === 'publish') {
|
|
169
|
+
const config = loadConfig(repoRoot)
|
|
170
|
+
const agent = config.attribution?.defaultAgent ?? 'human'
|
|
171
|
+
await publishItems(repo, repoRoot, config, agent)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (action === 'retry') {
|
|
176
|
+
const config = loadConfig(repoRoot)
|
|
177
|
+
const agent = config.attribution?.defaultAgent ?? 'human'
|
|
178
|
+
console.log(bold(`\nRetrying ${failed.length} failed item(s)...\n`))
|
|
179
|
+
for (const item of failed) {
|
|
180
|
+
const safetyResult = checkSafety(repo, agent, config.safety)
|
|
181
|
+
if (!safetyResult.allowed) {
|
|
182
|
+
console.log(dim(' ⏸ Rate limit — try again later.'))
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
const spinner = ora(`Creating: "${item.title}"`).start()
|
|
186
|
+
try {
|
|
187
|
+
const issue = createIssue(repo, { title: item.title, body: item.body, labels: item.labels ?? [] })
|
|
188
|
+
spinner.succeed(`Created: ${issue.url}`)
|
|
189
|
+
markComplete(item.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
|
|
190
|
+
recordSuccess(repo, agent)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
spinner.fail(`Failed again: ${err.message}`)
|
|
193
|
+
markFailed(item.id, err.message, repoRoot)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (action === 'view') {
|
|
200
|
+
const viewChoices = repoItems.map((item) => {
|
|
201
|
+
const statusMap = { failed: 'FAIL', duplicate: 'DUP ', aborted: dim('ABRT'), draft: 'DRFT' }
|
|
202
|
+
const status = statusMap[item.status] ?? 'PEND'
|
|
203
|
+
return {
|
|
204
|
+
name: `${status} ${item.title.slice(0, 40).padEnd(40)} ${dim(timeAgo(item.createdAt))}`,
|
|
205
|
+
value: item.id,
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
viewChoices.push({ name: dim('Back'), value: 'back' })
|
|
209
|
+
|
|
210
|
+
const itemId = await promptOrBack(() => select({ message: 'Draft items', choices: viewChoices, theme }))
|
|
211
|
+
if (itemId === Symbol.for('back') || itemId === 'back') continue
|
|
212
|
+
|
|
213
|
+
const item = repoItems.find((i) => i.id === itemId)
|
|
214
|
+
if (item) {
|
|
215
|
+
console.log(`\n ${bold(item.title)}`)
|
|
216
|
+
console.log(` Status: ${item.status} Created: ${item.createdAt}`)
|
|
217
|
+
if (item.error) console.log(` Error: ${item.error}`)
|
|
218
|
+
if (item.labels?.length) console.log(` Labels: ${item.labels.join(', ')}`)
|
|
219
|
+
console.log(dim(' ─'.repeat(20)))
|
|
220
|
+
console.log(dim(item.body?.slice(0, 300)))
|
|
221
|
+
if (item.body?.length > 300) console.log(dim('...'))
|
|
222
|
+
|
|
223
|
+
const itemAction = await promptOrBack(() =>
|
|
224
|
+
select({
|
|
225
|
+
message: 'Action',
|
|
226
|
+
choices: [
|
|
227
|
+
{ name: 'Delete this item', value: 'delete' },
|
|
228
|
+
{ name: dim('Back'), value: 'back' },
|
|
229
|
+
],
|
|
230
|
+
theme,
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
if (itemAction === 'delete') {
|
|
234
|
+
markComplete(item.id, {}, repoRoot)
|
|
235
|
+
console.log(dim(` Removed "${item.title}"`))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (action === 'delete-all') {
|
|
242
|
+
const ok = await promptOrBack(() =>
|
|
243
|
+
confirm({ message: `Delete all ${repoItems.length} drafts for ${repo}?`, default: false, theme }),
|
|
244
|
+
)
|
|
245
|
+
if (ok === true) {
|
|
246
|
+
for (const item of repoItems) markComplete(item.id, {}, repoRoot)
|
|
247
|
+
console.log(dim(` Deleted ${repoItems.length} items`))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Command
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export const draftsCommand = new Command('drafts')
|
|
258
|
+
.description('Manage issue drafts (view, publish, retry, delete)')
|
|
259
|
+
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
260
|
+
.action(async (opts) => {
|
|
261
|
+
let repo = opts.repo || store.get('activeRepo')
|
|
262
|
+
if (!repo) {
|
|
263
|
+
console.error(red('No active repo. Set one with: tissues config'))
|
|
264
|
+
process.exit(1)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const repoRoot = findRepoRoot()
|
|
268
|
+
await manageDrafts(repo, repoRoot)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// publish subcommand of drafts
|
|
272
|
+
draftsCommand.addCommand(
|
|
273
|
+
new Command('publish')
|
|
274
|
+
.description('Publish pending drafts as GitHub issues')
|
|
275
|
+
.option('--repo <repo>', 'Repository override')
|
|
276
|
+
.option('--agent <name>', 'Agent identifier (default: human)')
|
|
277
|
+
.action(async (opts) => {
|
|
278
|
+
let repo = opts.repo || store.get('activeRepo')
|
|
279
|
+
if (!repo) {
|
|
280
|
+
console.error(red('No active repo. Set one with: tissues config'))
|
|
281
|
+
process.exit(1)
|
|
282
|
+
}
|
|
283
|
+
const repoRoot = findRepoRoot()
|
|
284
|
+
const config = loadConfig(repoRoot)
|
|
285
|
+
const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
|
|
286
|
+
await publishItems(repo, repoRoot, config, agent)
|
|
287
|
+
}),
|
|
288
|
+
)
|
package/src/commands/list.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { search } from '@inquirer/prompts'
|
|
3
3
|
import { store } from '../lib/config.js'
|
|
4
|
+
import { theme } from '../lib/theme.js'
|
|
4
5
|
import { pickRepo } from '../lib/repo-picker.js'
|
|
5
6
|
import { requireAuth, listIssues } from '../lib/gh.js'
|
|
6
|
-
import
|
|
7
|
+
import { yellow, dim, cyan } from '../lib/color.js'
|
|
7
8
|
import ora from 'ora'
|
|
8
9
|
import { execSync } from 'child_process'
|
|
9
10
|
|
|
@@ -19,7 +20,7 @@ export const listCommand = new Command('list')
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
if (!repo) {
|
|
22
|
-
console.log(
|
|
23
|
+
console.log(yellow('No active repo. Pick one:\n'))
|
|
23
24
|
repo = await pickRepo()
|
|
24
25
|
console.log()
|
|
25
26
|
}
|
|
@@ -29,12 +30,12 @@ export const listCommand = new Command('list')
|
|
|
29
30
|
spinner.stop()
|
|
30
31
|
|
|
31
32
|
if (issues.length === 0) {
|
|
32
|
-
console.log(
|
|
33
|
+
console.log(dim('No open issues.'))
|
|
33
34
|
return
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
const selected = await search({
|
|
37
|
-
message: `Open issues in ${
|
|
38
|
+
message: `Open issues in ${cyan(repo)}`,
|
|
38
39
|
source: (input) => {
|
|
39
40
|
const term = (input || '').toLowerCase()
|
|
40
41
|
return issues
|
|
@@ -48,12 +49,13 @@ export const listCommand = new Command('list')
|
|
|
48
49
|
value: i.url,
|
|
49
50
|
}))
|
|
50
51
|
},
|
|
52
|
+
theme,
|
|
51
53
|
})
|
|
52
54
|
|
|
53
55
|
// Open in browser
|
|
54
56
|
try {
|
|
55
57
|
execSync(`open "${selected}"`)
|
|
56
58
|
} catch {
|
|
57
|
-
console.log(`\nOpen: ${
|
|
59
|
+
console.log(`\nOpen: ${cyan(selected)}`)
|
|
58
60
|
}
|
|
59
61
|
})
|
package/src/commands/status.js
CHANGED
|
@@ -4,7 +4,9 @@ import { loadConfig, findRepoRoot } from '../lib/defaults.js'
|
|
|
4
4
|
import { getSafetyStatus, forceReset } from '../lib/safety.js'
|
|
5
5
|
import { getDb } from '../lib/db.js'
|
|
6
6
|
import { getCircuitState, countRecentEvents, probeCircuit } from '../lib/db.js'
|
|
7
|
-
import
|
|
7
|
+
import { countPending } from '../lib/drafts.js'
|
|
8
|
+
import { getAuthStatus } from '../lib/gh.js'
|
|
9
|
+
import { bold, dim, red, green, yellow, cyan } from '../lib/color.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Query fingerprint count and last creation time for a repo from the DB.
|
|
@@ -66,13 +68,24 @@ function timeAgo(isoString) {
|
|
|
66
68
|
* @returns {string}
|
|
67
69
|
*/
|
|
68
70
|
function colorCircuitState(state) {
|
|
69
|
-
if (state === 'closed') return
|
|
70
|
-
if (state === 'open') return
|
|
71
|
-
return
|
|
71
|
+
if (state === 'closed') return green('closed') + ' ' + green('✓')
|
|
72
|
+
if (state === 'open') return red('open') + ' ' + red('✗')
|
|
73
|
+
return yellow('half-open') + ' ' + yellow('~')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function maskKey(value) {
|
|
77
|
+
if (!value || typeof value !== 'string') return null
|
|
78
|
+
if (value.length <= 4) return '****'
|
|
79
|
+
return '****' + value.slice(-4)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatBudget(val) {
|
|
83
|
+
if (val === 0 || !val) return 'unlimited'
|
|
84
|
+
return val.toLocaleString()
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
export const statusCommand = new Command('status')
|
|
75
|
-
.description('Show safety
|
|
88
|
+
.description('Show auth, safety, and config status')
|
|
76
89
|
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
77
90
|
.option('--agent <name>', "Check a specific agent's status (default: 'human')", 'human')
|
|
78
91
|
.option('--reset', 'Force-reset the circuit breaker to closed')
|
|
@@ -83,19 +96,59 @@ export const statusCommand = new Command('status')
|
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
if (!repo) {
|
|
86
|
-
console.error(
|
|
99
|
+
console.error(red('No active repo. Set one with: tissues config'))
|
|
87
100
|
process.exit(1)
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
const repoRoot = findRepoRoot()
|
|
104
|
+
const config = loadConfig(repoRoot)
|
|
90
105
|
const agent = opts.agent
|
|
91
106
|
|
|
107
|
+
// --- Auth ---
|
|
108
|
+
console.log(bold('Auth'))
|
|
109
|
+
try {
|
|
110
|
+
const status = getAuthStatus()
|
|
111
|
+
const active = status.accounts?.find((a) => a.active)
|
|
112
|
+
if (active) {
|
|
113
|
+
console.log(` GitHub: ${green('✔')} ${active.login}`)
|
|
114
|
+
} else {
|
|
115
|
+
console.log(` GitHub: ${red('✗')} not logged in`)
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
console.log(` GitHub: ${red('✗')} gh CLI not available`)
|
|
119
|
+
}
|
|
120
|
+
console.log(` Active repo: ${cyan(repo)}`)
|
|
121
|
+
|
|
122
|
+
// --- AI config ---
|
|
123
|
+
console.log()
|
|
124
|
+
console.log(bold('AI'))
|
|
125
|
+
const aiEnabled = config.ai?.enabled !== false
|
|
126
|
+
console.log(` Enabled: ${aiEnabled ? green('yes') : dim('no')}`)
|
|
127
|
+
if (aiEnabled) {
|
|
128
|
+
console.log(` Provider: ${config.ai?.provider || dim('not set')}`)
|
|
129
|
+
const providers = ['anthropic', 'openai', 'gemini']
|
|
130
|
+
const keys = providers.filter((p) => config.ai?.keys?.[p]).map((p) => `${p} (${maskKey(config.ai.keys[p])})`)
|
|
131
|
+
console.log(` Keys: ${keys.length > 0 ? keys.join(', ') : dim('none')}`)
|
|
132
|
+
const budgets = config.ai?.budgets || {}
|
|
133
|
+
console.log(` Budgets: ${formatBudget(budgets.maxTokensPerRequest)}/req, ${formatBudget(budgets.maxTokensPerHour)}/hr, ${formatBudget(budgets.maxTokensPerDay)}/day`)
|
|
134
|
+
const routes = config.ai?.routes || []
|
|
135
|
+
console.log(` Routes: ${routes.length > 0 ? `${routes.length} rule${routes.length === 1 ? '' : 's'}` : dim('none')}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Template ---
|
|
139
|
+
console.log()
|
|
140
|
+
console.log(bold('Templates'))
|
|
141
|
+
console.log(` Default: ${config.templates?.default || 'default'}`)
|
|
142
|
+
|
|
92
143
|
// Handle --reset before displaying status
|
|
93
144
|
if (opts.reset) {
|
|
94
145
|
forceReset(repo, agent)
|
|
95
|
-
console.log(
|
|
146
|
+
console.log(green(`\nCircuit breaker reset to closed for ${repo} / ${agent}`))
|
|
96
147
|
}
|
|
97
148
|
|
|
98
|
-
|
|
149
|
+
// --- Safety ---
|
|
150
|
+
console.log()
|
|
151
|
+
console.log(bold('Safety'))
|
|
99
152
|
const safetyCfg = config.safety ?? {}
|
|
100
153
|
|
|
101
154
|
const cfg = {
|
|
@@ -130,7 +183,7 @@ export const statusCommand = new Command('status')
|
|
|
130
183
|
const agentHourRemaining = Math.max(0, cfg.maxPerHour - agentHourCount)
|
|
131
184
|
const globalHourRemaining = Math.max(0, cfg.globalMaxPerHour - globalHourCount)
|
|
132
185
|
|
|
133
|
-
console.log(`Circuit
|
|
186
|
+
console.log(` Circuit: ${colorCircuitState(circuitState)}`)
|
|
134
187
|
if (circuitState === 'open' && circuit.cooldownUntil) {
|
|
135
188
|
const until = new Date(
|
|
136
189
|
circuit.cooldownUntil.endsWith('Z') ? circuit.cooldownUntil : circuit.cooldownUntil + 'Z'
|
|
@@ -138,29 +191,38 @@ export const statusCommand = new Command('status')
|
|
|
138
191
|
const diffMs = until - Date.now()
|
|
139
192
|
if (diffMs > 0) {
|
|
140
193
|
const mins = Math.ceil(diffMs / 60_000)
|
|
141
|
-
console.log(
|
|
194
|
+
console.log(dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
|
|
142
195
|
}
|
|
143
196
|
}
|
|
144
197
|
|
|
145
198
|
console.log(
|
|
146
|
-
`Rate
|
|
147
|
-
|
|
199
|
+
` Rate: ${agentHourCount}/${cfg.maxPerHour} per hour` +
|
|
200
|
+
dim(` (${agentHourRemaining} remaining)`)
|
|
148
201
|
)
|
|
149
202
|
console.log(
|
|
150
|
-
`Burst:
|
|
203
|
+
` Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
|
|
151
204
|
)
|
|
152
205
|
console.log(
|
|
153
|
-
`Global:
|
|
154
|
-
|
|
206
|
+
` Global: ${globalHourCount}/${cfg.globalMaxPerHour} per hour` +
|
|
207
|
+
dim(` (${globalHourRemaining} remaining)`)
|
|
155
208
|
)
|
|
156
209
|
|
|
157
210
|
console.log()
|
|
158
|
-
|
|
159
211
|
if (lastCreatedAt) {
|
|
160
|
-
console.log(`Last issue
|
|
212
|
+
console.log(`Last issue: ${timeAgo(lastCreatedAt)}`)
|
|
161
213
|
} else {
|
|
162
|
-
console.log(
|
|
214
|
+
console.log(dim('Last issue: never'))
|
|
163
215
|
}
|
|
164
216
|
|
|
165
|
-
console.log(`Fingerprints
|
|
217
|
+
console.log(`Fingerprints: ${fingerprintCount}`)
|
|
218
|
+
|
|
219
|
+
const pendingCount = countPending(repoRoot)
|
|
220
|
+
if (pendingCount > 0) {
|
|
221
|
+
console.log(
|
|
222
|
+
cyan(`Drafts: ${pendingCount} pending`) +
|
|
223
|
+
dim(' (run ') + cyan('tissues drafts') + dim(' to manage)'),
|
|
224
|
+
)
|
|
225
|
+
} else {
|
|
226
|
+
console.log(dim('Drafts: empty'))
|
|
227
|
+
}
|
|
166
228
|
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { dim, red, green } from '../lib/color.js'
|
|
6
|
+
import { select, input, editor } from '@inquirer/prompts'
|
|
7
|
+
import { listTemplates, loadTemplate, builtInTemplateKeys } from '../lib/templates.js'
|
|
8
|
+
import { findRepoRoot, loadConfig } from '../lib/defaults.js'
|
|
9
|
+
import { theme } from '../lib/theme.js'
|
|
10
|
+
|
|
11
|
+
function isCancelled(err) {
|
|
12
|
+
return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function promptOrBack(fn) {
|
|
16
|
+
try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function userTemplateDir() {
|
|
20
|
+
return path.join(os.homedir(), '.config', 'tissues', 'templates')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const templatesCommand = new Command('templates')
|
|
24
|
+
.description('Manage issue templates (view, edit, create)')
|
|
25
|
+
.action(async () => {
|
|
26
|
+
while (true) {
|
|
27
|
+
const repoRoot = findRepoRoot()
|
|
28
|
+
const templates = listTemplates(repoRoot)
|
|
29
|
+
const builtInKeys = builtInTemplateKeys()
|
|
30
|
+
|
|
31
|
+
// Deduplicate by key
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
const choices = []
|
|
34
|
+
for (const tpl of templates) {
|
|
35
|
+
if (!seen.has(tpl.key)) {
|
|
36
|
+
seen.add(tpl.key)
|
|
37
|
+
choices.push({
|
|
38
|
+
name: `${tpl.key.padEnd(14)} ${dim(tpl.name)} ${dim(`(${tpl.source})`)}`,
|
|
39
|
+
value: tpl.key,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
choices.push({ name: green('Create New Template'), value: '_create' })
|
|
44
|
+
choices.push({ name: dim('Done'), value: 'done' })
|
|
45
|
+
|
|
46
|
+
const chosen = await promptOrBack(() => select({ message: 'Templates', choices, theme }))
|
|
47
|
+
if (chosen === Symbol.for('back') || chosen === 'done') break
|
|
48
|
+
|
|
49
|
+
if (chosen === '_create') {
|
|
50
|
+
await createNewTemplate(builtInKeys, seen)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// View / edit existing template
|
|
55
|
+
await editTemplate(chosen, repoRoot, builtInKeys)
|
|
56
|
+
console.log()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
async function createNewTemplate(builtInKeys, existingKeys) {
|
|
61
|
+
const name = await promptOrBack(() => input({ message: 'Template name (lowercase, no spaces)', theme }))
|
|
62
|
+
if (name === Symbol.for('back') || !name) return
|
|
63
|
+
|
|
64
|
+
const key = name.trim().toLowerCase().replace(/\s+/g, '-')
|
|
65
|
+
if (!key) return
|
|
66
|
+
|
|
67
|
+
// Block reusing exact built-in names
|
|
68
|
+
if (builtInKeys.includes(key)) {
|
|
69
|
+
console.log(red(` "${key}" is a built-in template. Use edit to customize it instead.`))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Block duplicates
|
|
74
|
+
if (existingKeys.has(key)) {
|
|
75
|
+
console.log(red(` Template "${key}" already exists.`))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = await promptOrBack(() =>
|
|
80
|
+
editor({
|
|
81
|
+
message: 'Template body (opens editor)',
|
|
82
|
+
default: `## ${key.charAt(0).toUpperCase() + key.slice(1)}\n\n{{description}}\n\n## Details\n\n`,
|
|
83
|
+
theme,
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
if (body === Symbol.for('back') || !body) return
|
|
87
|
+
|
|
88
|
+
const dir = userTemplateDir()
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
90
|
+
const filePath = path.join(dir, `${key}.md`)
|
|
91
|
+
fs.writeFileSync(filePath, body, 'utf8')
|
|
92
|
+
console.log(green(` ✔ Template "${key}" created: ${filePath}`))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function editTemplate(key, repoRoot, builtInKeys) {
|
|
96
|
+
let tpl
|
|
97
|
+
try {
|
|
98
|
+
tpl = loadTemplate(key, repoRoot)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.log(red(` ${err.message}`))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`\n ${tpl.name} ${dim(`(${tpl.source})`)}`)
|
|
105
|
+
console.log(dim(' ─'.repeat(20)))
|
|
106
|
+
// Show preview (truncated)
|
|
107
|
+
const preview = tpl.body.split('\n').slice(0, 8).join('\n')
|
|
108
|
+
console.log(dim(preview))
|
|
109
|
+
if (tpl.body.split('\n').length > 8) console.log(dim(' ...'))
|
|
110
|
+
console.log()
|
|
111
|
+
|
|
112
|
+
const actionChoices = [
|
|
113
|
+
{ name: 'Edit', value: 'edit' },
|
|
114
|
+
]
|
|
115
|
+
if (builtInKeys.includes(key) && tpl.source === 'built-in') {
|
|
116
|
+
actionChoices[0] = { name: 'Customize (creates user copy)', value: 'edit' }
|
|
117
|
+
}
|
|
118
|
+
// Only allow deleting user templates
|
|
119
|
+
const userFile = path.join(userTemplateDir(), `${key}.md`)
|
|
120
|
+
if (fs.existsSync(userFile)) {
|
|
121
|
+
actionChoices.push({ name: red('Delete user copy'), value: 'delete' })
|
|
122
|
+
}
|
|
123
|
+
actionChoices.push({ name: dim('Back'), value: 'back' })
|
|
124
|
+
|
|
125
|
+
const action = await promptOrBack(() => select({ message: tpl.name, choices: actionChoices, theme }))
|
|
126
|
+
if (action === Symbol.for('back') || action === 'back') return
|
|
127
|
+
|
|
128
|
+
if (action === 'delete') {
|
|
129
|
+
fs.unlinkSync(userFile)
|
|
130
|
+
console.log(green(` ✔ User copy of "${key}" deleted`))
|
|
131
|
+
if (builtInKeys.includes(key)) {
|
|
132
|
+
console.log(dim(` Built-in "${key}" will be used again.`))
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Edit — open in editor with current body
|
|
138
|
+
const newBody = await promptOrBack(() =>
|
|
139
|
+
editor({
|
|
140
|
+
message: `Edit ${key}`,
|
|
141
|
+
default: tpl.body,
|
|
142
|
+
theme,
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
if (newBody === Symbol.for('back') || !newBody) return
|
|
146
|
+
|
|
147
|
+
// Save as user template (even if editing a built-in — creates a user override)
|
|
148
|
+
const dir = userTemplateDir()
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
150
|
+
fs.writeFileSync(path.join(dir, `${key}.md`), newBody, 'utf8')
|
|
151
|
+
|
|
152
|
+
if (tpl.source === 'built-in') {
|
|
153
|
+
console.log(green(` ✔ User copy of "${key}" created (overrides built-in)`))
|
|
154
|
+
} else {
|
|
155
|
+
console.log(green(` ✔ Template "${key}" updated`))
|
|
156
|
+
}
|
|
157
|
+
}
|