tissues 0.4.1 โ†’ 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # tissues
1
+ # tissues ๐Ÿงป
2
2
 
3
3
  AI-enhanced GitHub issue creation with built-in safety guardrails.
4
4
 
@@ -32,11 +32,11 @@ Requires Node.js >= 18.
32
32
  # Authenticate (auto-detects gh CLI token)
33
33
  tissues auth
34
34
 
35
- # Set your active repo
36
- tissues open
35
+ # Set your active repo (optional โ€” saves typing --repo every time)
36
+ tissues use calebogden/tissues
37
37
 
38
- # Create an issue interactively
39
- tissues create
38
+ # Create an issue interactively (title as positional argument โ€” no quotes needed)
39
+ tissues create fix the login bug
40
40
 
41
41
  # Check safety status before running in CI
42
42
  tissues status
@@ -54,13 +54,14 @@ Authenticate with GitHub. Auto-detects your `gh` CLI token if you're already log
54
54
  tissues auth
55
55
  ```
56
56
 
57
- ### `tissues open`
57
+ ### `tissues use`
58
58
 
59
- Set the active repository context. All subsequent commands use this repo unless overridden with `--repo`.
59
+ Set the active repository context. Optional โ€” all commands also accept `--repo` directly. Once set, subsequent commands use this repo automatically unless overridden.
60
60
 
61
61
  ```bash
62
- tissues open
63
- # prompts: pick a repo from your GitHub account
62
+ tissues use calebogden/tissues # set by name
63
+ tissues use # prompts: pick a repo from your GitHub account
64
+ tissues use --repo calebogden/tissues # same, via flag
64
65
  ```
65
66
 
66
67
  ### `tissues create`
@@ -68,7 +69,7 @@ tissues open
68
69
  Create a new GitHub issue. Runs dedup checks and safety gates before creating anything.
69
70
 
70
71
  ```bash
71
- tissues create [options]
72
+ tissues create [title...] [options]
72
73
  ```
73
74
 
74
75
  **Options:**
@@ -78,7 +79,10 @@ tissues create [options]
78
79
  | `--repo <owner/name>` | Override active repo |
79
80
  | `--template <name>` | Template to use: `bug`, `feature`, `security`, `performance`, `refactor` |
80
81
  | `--title <title>` | Issue title (skips interactive prompt) |
81
- | `--body <text>` | Issue body / description (skips interactive prompt) |
82
+ | `--body <text>` | Issue body / description โ€” the actual content describing the issue (skips interactive prompt) |
83
+ | `--instructions <text>` | AI enhancement instructions: guides how the AI writes the issue body (e.g. "keep it under 200 words", "use formal tone"). Does not appear in the created issue. Optional โ€” skips interactive prompt when provided. |
84
+ | `--no-enhance` | Skip AI enhancement, use rendered template as-is |
85
+ | `--batch <file>` | Create multiple issues from a JSON file. Each item supports: `title`, `body`, `template`, `labels`, `agent`, `session` |
82
86
  | `--labels <labels>` | Comma-separated labels to apply |
83
87
  | `--agent <name>` | Agent identifier for attribution (default: `human`) |
84
88
  | `--session <id>` | Session ID for attribution and idempotency |
@@ -88,8 +92,8 @@ tissues create [options]
88
92
  **Examples:**
89
93
 
90
94
  ```bash
91
- # Interactive
92
- tissues create
95
+ # Interactive (title pre-filled from positional argument)
96
+ tissues create fix the login bug
93
97
 
94
98
  # Fully scripted (no prompts)
95
99
  tissues create \
@@ -97,6 +101,7 @@ tissues create \
97
101
  --template bug \
98
102
  --title "Login fails on Safari 17" \
99
103
  --body "Reproducible on fresh profile. Console shows CORS error." \
104
+ --instructions "Keep it under 150 words and include a clear reproduction checklist." \
100
105
  --labels "bug,P1"
101
106
 
102
107
  # From an AI agent
@@ -192,12 +197,11 @@ Every issue created by tissues includes a machine-readable `<!-- tissues-meta --
192
197
 
193
198
  ## Configuration
194
199
 
195
- Configuration is loaded and merged from four sources in ascending priority order:
200
+ Configuration is loaded and merged from three sources in ascending priority order:
196
201
 
197
202
  1. Built-in defaults
198
203
  2. User-level config: `~/.config/tissues/config.json`
199
204
  3. Repo-level config: `.tissues/config.json`
200
- 4. Environment variables: `TISSUES_*`
201
205
 
202
206
  ### Example `.tissues/config.json`
203
207
 
@@ -230,15 +234,20 @@ Configuration is loaded and merged from four sources in ascending priority order
230
234
  }
231
235
  ```
232
236
 
233
- ### Environment Variables
237
+ ### `tissues config`
234
238
 
235
- Environment variables follow the pattern `TISSUES_<SECTION>_<KEY>`:
239
+ Get or set persistent user-level config values using dot notation. Values are stored in `~/.config/tissues/config.json`.
236
240
 
237
241
  ```bash
238
- TISSUES_SAFETY_MAX_PER_HOUR=5
239
- TISSUES_SAFETY_BURST_LIMIT=3
240
- TISSUES_SAFETY_GLOBAL_MAX_PER_HOUR=20
241
- TISSUES_ATTRIBUTION_DEFAULT_AGENT=my-agent
242
+ # Set a value
243
+ tissues config set safety.maxPerHour 5
244
+ tissues config set attribution.defaultAgent my-agent
245
+
246
+ # Read a value
247
+ tissues config get safety.maxPerHour
248
+
249
+ # Show all resolved config
250
+ tissues config list
242
251
  ```
243
252
 
244
253
  ### Hooks
@@ -275,7 +284,7 @@ Template files support `{{variable}}` substitution:
275
284
  | Variable | Value |
276
285
  |---|---|
277
286
  | `{{title}}` | Issue title |
278
- | `{{description}}` | User's description |
287
+ | `{{description}}` | Issue description โ€” the actual content describing the issue |
279
288
  | `{{agent}}` | Agent identifier |
280
289
  | `{{session}}` | Session ID |
281
290
  | `{{date}}` | ISO date (YYYY-MM-DD) |
@@ -316,11 +325,10 @@ Every issue created by tissues includes a machine-readable HTML comment at the b
316
325
  <!-- tissues-meta
317
326
  agent: claude-opus-4-6
318
327
  session: abc123
319
- pid: 84921
320
328
  trigger: cli-create
321
329
  fingerprint: sha256:3f2a1b...
322
330
  created_at: 2026-02-19T15:30:00.000Z
323
- created_via: tissues-cli/0.3.0
331
+ created_via: tissues-cli/0.5
324
332
  -->
325
333
  ```
326
334
 
@@ -332,6 +340,8 @@ The block is used by tissues to:
332
340
 
333
341
  You can pass additional fields via `--agent`, `--session`, and the programmatic API.
334
342
 
343
+ `pid` is available but opt-in โ€” pass it explicitly via the programmatic API (`pid: process.pid`). It is omitted by default to avoid leaking process info into public issues.
344
+
335
345
  ---
336
346
 
337
347
  ## State Database
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tissues",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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": {
package/src/cli.js CHANGED
@@ -2,7 +2,8 @@ import { Command } from 'commander'
2
2
  import { createRequire } from 'module'
3
3
  const { version } = createRequire(import.meta.url)('../package.json')
4
4
  import { authCommand } from './commands/auth.js'
5
- import { openCommand } from './commands/open.js'
5
+ import { configCommand } from './commands/config.js'
6
+ import { useCommand } from './commands/use.js'
6
7
  import { createCommand } from './commands/create.js'
7
8
  import { listCommand } from './commands/list.js'
8
9
  import { statusCommand } from './commands/status.js'
@@ -39,7 +40,7 @@ program
39
40
  requireGh()
40
41
 
41
42
  // Show active repo context on every command (except auth/open)
42
- if (name !== 'auth' && name !== 'open' && name !== 'login' && name !== 'status' && name !== 'switch' && name !== 'logout') {
43
+ if (name !== 'auth' && name !== 'use' && name !== 'login' && name !== 'status' && name !== 'switch' && name !== 'logout') {
43
44
  const activeRepo = store.get('activeRepo')
44
45
  if (activeRepo) {
45
46
  console.log(chalk.dim(`Working in: ${activeRepo}\n`))
@@ -61,7 +62,8 @@ program.hook('preAction', () => {
61
62
  authCommand.description(authDescription())
62
63
  })
63
64
  program.addCommand(authCommand)
64
- program.addCommand(openCommand)
65
+ program.addCommand(configCommand)
66
+ program.addCommand(useCommand)
65
67
  program.addCommand(createCommand)
66
68
  program.addCommand(listCommand)
67
69
  program.addCommand(statusCommand)
@@ -0,0 +1,104 @@
1
+ import { Command } from 'commander'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import chalk from 'chalk'
5
+ import { loadConfig, userConfigPath, BUILT_IN_DEFAULTS } from '../lib/defaults.js'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function readUserConfig() {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(userConfigPath(), 'utf8'))
14
+ } catch {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ function writeUserConfig(obj) {
20
+ const filePath = userConfigPath()
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
22
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', 'utf8')
23
+ }
24
+
25
+ function getNestedValue(obj, dotKey) {
26
+ return dotKey.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj)
27
+ }
28
+
29
+ function setNestedValue(obj, dotKey, value) {
30
+ const parts = dotKey.split('.')
31
+ const result = { ...obj }
32
+ let cursor = result
33
+ for (let i = 0; i < parts.length - 1; i++) {
34
+ const k = parts[i]
35
+ cursor[k] = cursor[k] && typeof cursor[k] === 'object' ? { ...cursor[k] } : {}
36
+ cursor = cursor[k]
37
+ }
38
+ cursor[parts[parts.length - 1]] = value
39
+ return result
40
+ }
41
+
42
+ function coerceValue(dotKey, raw) {
43
+ const parts = dotKey.split('.')
44
+ if (parts.length !== 2) return raw
45
+ const [section, field] = parts
46
+ const defaultVal = BUILT_IN_DEFAULTS[section]?.[field]
47
+ if (typeof defaultVal === 'number') {
48
+ const n = Number(raw)
49
+ if (isNaN(n)) throw new Error(`Expected a number for ${dotKey}, got: ${raw}`)
50
+ return n
51
+ }
52
+ if (typeof defaultVal === 'boolean') {
53
+ if (raw === 'true' || raw === '1') return true
54
+ if (raw === 'false' || raw === '0') return false
55
+ throw new Error(`Expected true/false for ${dotKey}, got: ${raw}`)
56
+ }
57
+ if (raw === 'null') return null
58
+ return raw
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Command
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const configCommand = new Command('config')
66
+ .description('Get or set persistent configuration values')
67
+
68
+ configCommand
69
+ .command('get <key>')
70
+ .description('Get a config value (dot notation, e.g. safety.maxPerHour)')
71
+ .action((key) => {
72
+ const cfg = loadConfig()
73
+ const value = getNestedValue(cfg, key)
74
+ if (value === undefined) {
75
+ console.error(chalk.red(`Unknown config key: ${key}`))
76
+ process.exit(1)
77
+ }
78
+ console.log(value)
79
+ })
80
+
81
+ configCommand
82
+ .command('set <key> <value>')
83
+ .description('Set a config value (dot notation, e.g. safety.maxPerHour 5)')
84
+ .action((key, rawValue) => {
85
+ let value
86
+ try {
87
+ value = coerceValue(key, rawValue)
88
+ } catch (err) {
89
+ console.error(chalk.red(err.message))
90
+ process.exit(1)
91
+ }
92
+ const current = readUserConfig()
93
+ const updated = setNestedValue(current, key, value)
94
+ writeUserConfig(updated)
95
+ console.log(chalk.green(`โœ” Set ${key} = ${value}`))
96
+ })
97
+
98
+ configCommand
99
+ .command('list')
100
+ .description('Show all resolved config values')
101
+ .action(() => {
102
+ const cfg = loadConfig()
103
+ console.log(JSON.stringify(cfg, null, 2))
104
+ })
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process'
2
+ import fs from 'node:fs'
2
3
  import { Command } from 'commander'
3
4
  import { input, select, confirm } from '@inquirer/prompts'
4
5
  import { store } from '../lib/config.js'
@@ -9,7 +10,13 @@ import { listTemplates, loadTemplate, renderTemplate } from '../lib/templates.js
9
10
  import { renderAttribution } from '../lib/attribution.js'
10
11
  import { computeFingerprint } from '../lib/dedup.js'
11
12
  import { pickRepo } from '../lib/repo-picker.js'
12
- import { requireAuth, createIssue } from '../lib/gh.js'
13
+ import {
14
+ requireAuth,
15
+ createIssue,
16
+ listLabels,
17
+ createLabel,
18
+ addLabelsToIssue,
19
+ } from '../lib/gh.js'
13
20
  import chalk from 'chalk'
14
21
  import ora from 'ora'
15
22
 
@@ -81,280 +88,427 @@ function runPostCreateHook(hookCmd, ctx) {
81
88
  * Enhance issue body with AI. Currently a structured placeholder.
82
89
  * Wire up real AI (OpenAI / Anthropic) here.
83
90
  *
84
- * @param {string} title
85
- * @param {string} description
91
+ * @param {string} title - issue title
92
+ * @param {string} description - the actual issue content describing what the issue is about;
93
+ * this is rendered into the template body via {{description}} and forms the core of the issue
86
94
  * @param {string} templateBody - already-rendered template body to use as context
95
+ * @param {string} [instructions] - optional AI prompt instructions that guide *how* the AI
96
+ * writes or post-processes the issue body (e.g. "keep it under 200 words",
97
+ * "after creating, send to this webhook: https://..."). Does NOT appear in the issue itself.
87
98
  * @returns {Promise<string>}
88
99
  */
89
- async function enhanceWithAI(title, description, templateBody) {
100
+ async function enhanceWithAI(title, description, templateBody, instructions) {
90
101
  // TODO: wire up real AI (OpenAI / Anthropic)
91
- // templateBody is provided as context for the AI to refine
102
+ // - description goes INTO the issue body (rendered via {{description}} in the template)
103
+ // - instructions guide the AI behaviour but are NOT included in the output
92
104
  return templateBody
93
105
  }
94
106
 
95
107
  // ---------------------------------------------------------------------------
96
- // Command
108
+ // AbortError โ€” thrown for user-initiated cancellations (exit 0)
97
109
  // ---------------------------------------------------------------------------
98
110
 
99
- export const createCommand = new Command('create')
100
- .description('Create a new GitHub issue')
101
- .option('--repo <repo>', 'Repository override (owner/name)')
102
- .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
103
- .option('--agent <name>', 'Agent identifier for attribution (default: human)')
104
- .option('--session <id>', 'Session ID for attribution')
105
- .option('--title <title>', 'Issue title (skips interactive prompt)')
106
- .option('--body <body>', 'Issue body / description (skips interactive prompt)')
107
- .option('--labels <labels>', 'Comma-separated labels to apply')
108
- .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
109
- .option('--dry-run', 'Check dedup and safety without creating the issue')
110
- .action(async (opts) => {
111
- // -----------------------------------------------------------------------
112
- // 0. Ensure authenticated
113
- // -----------------------------------------------------------------------
114
- requireAuth()
111
+ class AbortError extends Error {
112
+ constructor(msg) {
113
+ super(msg)
114
+ this.isAbort = true
115
+ }
116
+ }
115
117
 
116
- // -----------------------------------------------------------------------
117
- // 1. Resolve repo
118
- // -----------------------------------------------------------------------
119
- let repo = opts.repo
120
- if (!repo) {
121
- repo = store.get('activeRepo')
122
- }
123
- if (!repo) {
124
- console.log(chalk.yellow('No active repo. Pick one:\n'))
125
- repo = await pickRepo()
126
- console.log()
127
- }
118
+ // ---------------------------------------------------------------------------
119
+ // Core create logic (extracted so batch mode can reuse it)
120
+ // ---------------------------------------------------------------------------
128
121
 
129
- // -----------------------------------------------------------------------
130
- // 2. Load config
131
- // -----------------------------------------------------------------------
132
- const repoRoot = findRepoRoot()
133
- const config = loadConfig(repoRoot)
122
+ /**
123
+ * Run the full issue-creation flow.
124
+ *
125
+ * @param {object} opts - command options (same shape as Commander opts)
126
+ * @returns {Promise<{ url: string, number: number } | null>} null for dry-run
127
+ * @throws {AbortError} when the user cancels interactively
128
+ * @throws {Error} on hard failures
129
+ */
130
+ async function runCreate(opts) {
131
+ // -----------------------------------------------------------------------
132
+ // 0. Ensure authenticated
133
+ // -----------------------------------------------------------------------
134
+ requireAuth()
135
+
136
+ // -----------------------------------------------------------------------
137
+ // 1. Resolve repo
138
+ // -----------------------------------------------------------------------
139
+ let repo = opts.repo
140
+ if (!repo) {
141
+ repo = store.get('activeRepo')
142
+ }
143
+ if (!repo) {
144
+ console.log(chalk.yellow('No active repo. Pick one:\n'))
145
+ repo = await pickRepo()
146
+ console.log()
147
+ }
134
148
 
135
- // -----------------------------------------------------------------------
136
- // 3. Check safety
137
- // -----------------------------------------------------------------------
138
- const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
139
- const safetyResult = checkSafety(repo, agent, config.safety)
149
+ // -----------------------------------------------------------------------
150
+ // 2. Load config
151
+ // -----------------------------------------------------------------------
152
+ const repoRoot = findRepoRoot()
153
+ const config = loadConfig(repoRoot)
154
+
155
+ // -----------------------------------------------------------------------
156
+ // 3. Check safety
157
+ // -----------------------------------------------------------------------
158
+ const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
159
+ const safetyResult = checkSafety(repo, agent, config.safety)
140
160
 
141
- warnIfCircuitNotClosed(safetyResult.circuitState)
161
+ warnIfCircuitNotClosed(safetyResult.circuitState)
162
+
163
+ if (!safetyResult.allowed) {
164
+ recordFailure(repo, agent, config.safety)
165
+ console.error(chalk.red('Safety check failed:'), safetyResult.reason)
166
+ throw new Error('Safety check failed')
167
+ }
168
+
169
+ // -----------------------------------------------------------------------
170
+ // 4. Get inputs
171
+ // -----------------------------------------------------------------------
172
+ const title =
173
+ opts.title ?? (await input({ message: 'Issue title', default: opts._titleDefault }))
174
+ if (!title || !title.trim()) {
175
+ console.error(chalk.red('Title is required.'))
176
+ throw new Error('Title is required')
177
+ }
178
+
179
+ const description =
180
+ opts.body ??
181
+ (await input({
182
+ message: 'Issue description (optional)',
183
+ }))
184
+
185
+ const instructions =
186
+ opts.instructions ??
187
+ (await input({
188
+ message: 'Issue instruction (optional, guides AI enhancement)',
189
+ }))
190
+
191
+ // -----------------------------------------------------------------------
192
+ // 5. Pick template
193
+ // -----------------------------------------------------------------------
194
+ let templateName = opts.template
195
+
196
+ // Fall back to config default
197
+ if (!templateName) {
198
+ templateName = config.templates?.default ?? null
199
+ }
142
200
 
143
- if (!safetyResult.allowed) {
144
- recordFailure(repo, agent, config.safety)
145
- console.error(chalk.red('Safety check failed:'), safetyResult.reason)
146
- process.exit(1)
201
+ // If still unresolved and interactive, let user pick
202
+ if (!templateName) {
203
+ const available = listTemplates(repoRoot)
204
+ // Deduplicate by key (higher-priority sources shadow lower ones)
205
+ const seen = new Set()
206
+ const choices = []
207
+ for (const tpl of available) {
208
+ if (!seen.has(tpl.key)) {
209
+ seen.add(tpl.key)
210
+ choices.push({
211
+ name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
212
+ value: tpl.key,
213
+ })
214
+ }
147
215
  }
148
216
 
149
- // -----------------------------------------------------------------------
150
- // 4. Get inputs
151
- // -----------------------------------------------------------------------
152
- const title = opts.title ?? (await input({ message: 'Issue title' }))
153
- if (!title || !title.trim()) {
154
- console.error(chalk.red('Title is required.'))
155
- process.exit(1)
217
+ if (choices.length > 0) {
218
+ templateName = await select({
219
+ message: 'Choose a template',
220
+ choices,
221
+ })
222
+ } else {
223
+ templateName = 'default'
156
224
  }
225
+ }
157
226
 
158
- const description =
159
- opts.body ??
160
- (await input({
161
- message: 'Brief description (optional, used for AI enhancement)',
162
- }))
227
+ let template
228
+ try {
229
+ template = loadTemplate(templateName, repoRoot)
230
+ } catch (err) {
231
+ console.error(chalk.red(`Template error: ${err.message}`))
232
+ throw new Error(`Template error: ${err.message}`)
233
+ }
163
234
 
164
- // -----------------------------------------------------------------------
165
- // 5. Pick template
166
- // -----------------------------------------------------------------------
167
- let templateName = opts.template
235
+ // -----------------------------------------------------------------------
236
+ // 6. Check dedup
237
+ // -----------------------------------------------------------------------
238
+ const session = opts.session ?? null
239
+ const dedupSpinner = ora('Checking for duplicates...').start()
240
+ let dedupResult
241
+ try {
242
+ dedupResult = await checkDuplicate(repo, {
243
+ title,
244
+ body: description,
245
+ agent,
246
+ // No idempotency key by default โ€” callers who need deterministic keys
247
+ // can pass --session and combine with agent + title in future work
248
+ })
249
+ dedupSpinner.stop()
250
+ } catch (err) {
251
+ dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
252
+ dedupResult = { action: 'allow', results: [] }
253
+ }
168
254
 
169
- // Fall back to config default
170
- if (!templateName) {
171
- templateName = config.templates?.default ?? null
255
+ if (dedupResult.action === 'block') {
256
+ recordFailure(repo, agent, config.safety)
257
+ console.error(chalk.red('Duplicate detected โ€” issue not created.'))
258
+ for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
259
+ console.error(formatDedupResult(r))
172
260
  }
261
+ throw new Error('Duplicate detected')
262
+ }
173
263
 
174
- // If still unresolved and interactive, let user pick
175
- if (!templateName) {
176
- const available = listTemplates(repoRoot)
177
- // Deduplicate by key (higher-priority sources shadow lower ones)
178
- const seen = new Set()
179
- const choices = []
180
- for (const tpl of available) {
181
- if (!seen.has(tpl.key)) {
182
- seen.add(tpl.key)
183
- choices.push({
184
- name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
185
- value: tpl.key,
186
- })
187
- }
188
- }
264
+ if (dedupResult.action === 'warn' && !opts.force) {
265
+ console.warn(chalk.yellow('\nSimilar issue(s) found:'))
266
+ for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
267
+ console.warn(formatDedupResult(r))
268
+ }
269
+ console.warn()
189
270
 
190
- if (choices.length > 0) {
191
- templateName = await select({
192
- message: 'Choose a template',
193
- choices,
194
- })
195
- } else {
196
- templateName = 'default'
197
- }
271
+ const proceed = await confirm({
272
+ message: 'Create anyway?',
273
+ default: false,
274
+ })
275
+
276
+ if (!proceed) {
277
+ console.log(chalk.dim('Aborted.'))
278
+ throw new AbortError('Aborted by user')
198
279
  }
280
+ }
199
281
 
200
- let template
282
+ // -----------------------------------------------------------------------
283
+ // 7. Render template
284
+ // -----------------------------------------------------------------------
285
+ const renderedTemplate = renderTemplate(template.body, {
286
+ title,
287
+ description: description || '',
288
+ agent,
289
+ session: session || '',
290
+ date: new Date().toISOString().slice(0, 10),
291
+ repo,
292
+ })
293
+
294
+ // -----------------------------------------------------------------------
295
+ // 8. AI enhancement (skip for --no-enhance or --dry-run)
296
+ // -----------------------------------------------------------------------
297
+ let body = renderedTemplate
298
+ if (opts.enhance !== false && !opts.dryRun) {
299
+ const aiSpinner = ora('Enhancing with AI...').start()
201
300
  try {
202
- template = loadTemplate(templateName, repoRoot)
203
- } catch (err) {
204
- console.error(chalk.red(`Template error: ${err.message}`))
205
- process.exit(1)
301
+ body = await enhanceWithAI(title, description, renderedTemplate, instructions)
302
+ aiSpinner.succeed('Enhanced')
303
+ } catch {
304
+ aiSpinner.warn('AI enhancement unavailable โ€” using template as-is')
206
305
  }
306
+ }
207
307
 
208
- // -----------------------------------------------------------------------
209
- // 6. Check dedup
210
- // -----------------------------------------------------------------------
211
- const session = opts.session ?? null
212
- const dedupSpinner = ora('Checking for duplicates...').start()
213
- let dedupResult
308
+ // -----------------------------------------------------------------------
309
+ // 9. Add attribution
310
+ // -----------------------------------------------------------------------
311
+ const fingerprint = computeFingerprint(title, body)
312
+ const attributionBlock = renderAttribution({
313
+ agent,
314
+ session: session ?? undefined,
315
+ trigger: 'cli-create',
316
+ fingerprint: `sha256:${fingerprint}`,
317
+ })
318
+
319
+ body = body.trimEnd() + '\n\n' + attributionBlock
320
+
321
+ // -----------------------------------------------------------------------
322
+ // 10. Parse labels
323
+ // -----------------------------------------------------------------------
324
+ const labels = opts.labels
325
+ ? opts.labels
326
+ .split(',')
327
+ .map((l) => l.trim())
328
+ .filter(Boolean)
329
+ : []
330
+
331
+ // -----------------------------------------------------------------------
332
+ // Dry run โ€” stop here
333
+ // -----------------------------------------------------------------------
334
+ if (opts.dryRun) {
335
+ const border = 'โ”€'.repeat(44)
336
+ console.log(chalk.cyan(`\nโ”€โ”€โ”€ DRY RUN ${border.slice(11)}`))
337
+ console.log(` ${chalk.bold('Repo: ')} ${repo}`)
338
+ console.log(` ${chalk.bold('Title: ')} ${title}`)
339
+ console.log(` ${chalk.bold('Agent: ')} ${agent}`)
340
+ console.log(` ${chalk.bold('Template:')} ${template.name} (${template.source})`)
341
+ if (labels.length > 0) console.log(` ${chalk.bold('Labels: ')} ${labels.join(', ')}`)
342
+ console.log(`\n ${chalk.bold('Body:')}`)
343
+ console.log(renderedTemplate)
344
+ console.log(`\n ${chalk.dim('โ”€โ”€โ”€ attribution (invisible on GitHub) โ”€โ”€โ”€')}`)
345
+ console.log(chalk.dim(attributionBlock))
346
+ console.log(chalk.cyan(`${border}\n No issue created.\n`))
347
+ return null
348
+ }
349
+
350
+ // -----------------------------------------------------------------------
351
+ // 11. Create issue (with graceful label handling)
352
+ // -----------------------------------------------------------------------
353
+ let existingLabels = labels
354
+ let missingLabels = []
355
+
356
+ if (labels.length > 0) {
214
357
  try {
215
- dedupResult = await checkDuplicate(repo, {
216
- title,
217
- body: description,
218
- agent,
219
- // No idempotency key by default โ€” callers who need deterministic keys
220
- // can pass --session and combine with agent + title in future work
221
- })
222
- dedupSpinner.stop()
223
- } catch (err) {
224
- dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
225
- dedupResult = { action: 'allow', results: [] }
358
+ const repoLabelNames = listLabels(repo)
359
+ existingLabels = labels.filter((l) => repoLabelNames.includes(l))
360
+ missingLabels = labels.filter((l) => !repoLabelNames.includes(l))
361
+ } catch {
362
+ // If listLabels fails, proceed with all labels (original behavior)
226
363
  }
364
+ }
227
365
 
228
- if (dedupResult.action === 'block') {
229
- recordFailure(repo, agent, config.safety)
230
- console.error(chalk.red('Duplicate detected โ€” issue not created.'))
231
- for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
232
- console.error(formatDedupResult(r))
233
- }
234
- process.exit(1)
235
- }
366
+ const createSpinner = ora('Creating issue...').start()
367
+ let issue
368
+ try {
369
+ issue = await createIssue(repo, { title, body, labels: existingLabels })
370
+ createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
371
+ } catch (err) {
372
+ // GitHub API failures are NOT safety failures โ€” do not record to circuit
373
+ createSpinner.fail(`Failed to create issue: ${err.message}`)
374
+ throw new Error(`Failed to create issue: ${err.message}`)
375
+ }
236
376
 
237
- if (dedupResult.action === 'warn' && !opts.force) {
238
- console.warn(chalk.yellow('\nSimilar issue(s) found:'))
239
- for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
240
- console.warn(formatDedupResult(r))
377
+ if (missingLabels.length > 0) {
378
+ console.log(
379
+ chalk.yellow(`Labels not found: ${missingLabels.join(', ')} โ€” issue created without them`),
380
+ )
381
+ const createMissing = await confirm({
382
+ message: 'Create missing labels and apply them?',
383
+ default: true,
384
+ })
385
+ if (createMissing) {
386
+ for (const labelName of missingLabels) {
387
+ const labelSpinner = ora(`Creating label "${labelName}"...`).start()
388
+ try {
389
+ createLabel(repo, labelName)
390
+ labelSpinner.succeed(`Label created: ${labelName}`)
391
+ } catch (err) {
392
+ labelSpinner.warn(`Failed to create label "${labelName}": ${err.message}`)
393
+ }
241
394
  }
242
- console.warn()
243
-
244
- const proceed = await confirm({
245
- message: 'Create anyway?',
246
- default: false,
247
- })
248
-
249
- if (!proceed) {
250
- console.log(chalk.dim('Aborted.'))
251
- process.exit(0)
395
+ try {
396
+ addLabelsToIssue(repo, issue.number, missingLabels)
397
+ console.log(chalk.green('โœ“ Labels created and applied'))
398
+ } catch (err) {
399
+ console.warn(chalk.yellow(`Failed to apply labels: ${err.message}`))
252
400
  }
253
401
  }
402
+ }
254
403
 
255
- // -----------------------------------------------------------------------
256
- // 7. Render template
257
- // -----------------------------------------------------------------------
258
- const renderedTemplate = renderTemplate(template.body, {
404
+ // -----------------------------------------------------------------------
405
+ // 12. Record success
406
+ // -----------------------------------------------------------------------
407
+ try {
408
+ await recordCreation(repo, {
259
409
  title,
260
- description: description || '',
410
+ body,
411
+ issueNumber: issue.number,
261
412
  agent,
262
- session: session || '',
263
- date: new Date().toISOString().slice(0, 10),
264
- repo,
265
413
  })
414
+ } catch (err) {
415
+ // Non-fatal โ€” dedup DB write failure shouldn't abort the command
416
+ console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
417
+ }
266
418
 
267
- // -----------------------------------------------------------------------
268
- // 8. AI enhancement (stub โ€” passes rendered template as context)
269
- // -----------------------------------------------------------------------
270
- let body = renderedTemplate
271
- const aiSpinner = ora('Enhancing with AI...').start()
272
- try {
273
- body = await enhanceWithAI(title, description, renderedTemplate)
274
- aiSpinner.succeed('Enhanced')
275
- } catch {
276
- aiSpinner.warn('AI enhancement unavailable โ€” using template as-is')
277
- }
419
+ recordSuccess(repo, agent)
278
420
 
279
- // -----------------------------------------------------------------------
280
- // 9. Add attribution
281
- // -----------------------------------------------------------------------
282
- const fingerprint = computeFingerprint(title, body)
283
- const attribution = renderAttribution({
284
- agent,
285
- session: session ?? undefined,
286
- trigger: 'cli-create',
287
- fingerprint: `sha256:${fingerprint}`,
421
+ // -----------------------------------------------------------------------
422
+ // Run postCreate hook if configured
423
+ // -----------------------------------------------------------------------
424
+ const hookCmd = config.hooks?.postCreate
425
+ if (hookCmd) {
426
+ runPostCreateHook(hookCmd, {
427
+ repo,
428
+ issueNumber: issue.number,
429
+ issueUrl: issue.url,
288
430
  })
431
+ }
289
432
 
290
- body = body.trimEnd() + '\n\n' + attribution
433
+ return { url: issue.url, number: issue.number }
434
+ }
291
435
 
292
- // -----------------------------------------------------------------------
293
- // 10. Parse labels
294
- // -----------------------------------------------------------------------
295
- const labels = opts.labels
296
- ? opts.labels
297
- .split(',')
298
- .map((l) => l.trim())
299
- .filter(Boolean)
300
- : []
436
+ // ---------------------------------------------------------------------------
437
+ // Command
438
+ // ---------------------------------------------------------------------------
301
439
 
440
+ export const createCommand = new Command('create')
441
+ .description('Create a new GitHub issue')
442
+ .argument('[title...]', 'Issue title (positional shorthand for --title)')
443
+ .option('--repo <repo>', 'Repository override (owner/name)')
444
+ .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
445
+ .option('--agent <name>', 'Agent identifier for attribution (default: human)')
446
+ .option('--session <id>', 'Session ID for attribution')
447
+ .option('--title <title>', 'Issue title (skips interactive prompt)')
448
+ .option('--body <body>', 'Issue body / description (skips interactive prompt)')
449
+ .option('--instructions <text>', 'AI enhancement instruction โ€” a prompt that guides how the AI writes the issue body (skips interactive prompt)')
450
+ .option('--labels <labels>', 'Comma-separated labels to apply')
451
+ .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
452
+ .option('--dry-run', 'Check dedup and safety without creating the issue')
453
+ .option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
454
+ .option(
455
+ '--batch <file>',
456
+ 'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
457
+ )
458
+ .action(async (titleArg, opts) => {
459
+ // Positional words โ†’ prefill the prompt (user can still edit)
460
+ // --title flag โ†’ skip the prompt entirely (scripted/batch use)
461
+ if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
302
462
  // -----------------------------------------------------------------------
303
- // Dry run โ€” stop here
463
+ // Batch mode
304
464
  // -----------------------------------------------------------------------
305
- if (opts.dryRun) {
306
- console.log(chalk.cyan('\n--- DRY RUN ---'))
307
- console.log(chalk.bold('Repo: '), repo)
308
- console.log(chalk.bold('Title: '), title)
309
- console.log(chalk.bold('Agent: '), agent)
310
- console.log(chalk.bold('Template:'), `${template.name} (${template.source})`)
311
- if (labels.length > 0) console.log(chalk.bold('Labels: '), labels.join(', '))
312
- console.log(chalk.bold('\nBody preview:'))
313
- console.log(chalk.dim(body))
314
- console.log(chalk.cyan('--- END DRY RUN (no issue created) ---\n'))
315
- process.exit(0)
316
- }
465
+ if (opts.batch) {
466
+ let items
467
+ try {
468
+ items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
469
+ } catch (err) {
470
+ console.error(chalk.red(`Failed to read batch file: ${err.message}`))
471
+ process.exit(1)
472
+ }
317
473
 
318
- // -----------------------------------------------------------------------
319
- // 11. Create issue
320
- // -----------------------------------------------------------------------
321
- const createSpinner = ora('Creating issue...').start()
322
- let issue
323
- try {
324
- issue = await createIssue(repo, { title, body, labels })
325
- createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
326
- } catch (err) {
327
- // GitHub API failures are NOT safety failures โ€” do not record to circuit
328
- createSpinner.fail(`Failed to create issue: ${err.message}`)
329
- process.exit(1)
474
+ if (!Array.isArray(items)) {
475
+ console.error(chalk.red('Batch file must contain a JSON array'))
476
+ process.exit(1)
477
+ }
478
+
479
+ const results = []
480
+ for (const [i, item] of items.entries()) {
481
+ console.log(chalk.dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
482
+ // item fields override CLI opts; labels array โ†’ comma-string for runCreate
483
+ const itemOpts = {
484
+ ...opts,
485
+ ...item,
486
+ labels: item.labels?.join(',') ?? opts.labels,
487
+ }
488
+ try {
489
+ const result = await runCreate(itemOpts)
490
+ results.push({ title: item.title, url: result?.url, ok: true })
491
+ } catch (err) {
492
+ results.push({ title: item.title, error: err.message, ok: false })
493
+ }
494
+ }
495
+
496
+ console.log(chalk.bold('\nBatch summary:'))
497
+ for (const r of results) {
498
+ if (r.ok) console.log(chalk.green(` โœ” ${r.title}`) + chalk.dim(` โ€” ${r.url}`))
499
+ else console.log(chalk.red(` โœ– ${r.title}`) + chalk.dim(` โ€” ${r.error}`))
500
+ }
501
+ process.exit(results.every((r) => r.ok) ? 0 : 1)
502
+ return
330
503
  }
331
504
 
332
505
  // -----------------------------------------------------------------------
333
- // 12. Record success
506
+ // Single mode
334
507
  // -----------------------------------------------------------------------
335
508
  try {
336
- await recordCreation(repo, {
337
- title,
338
- body,
339
- issueNumber: issue.number,
340
- agent,
341
- })
509
+ const result = await runCreate(opts)
510
+ if (result === null) process.exit(0) // dry-run
342
511
  } catch (err) {
343
- // Non-fatal โ€” dedup DB write failure shouldn't abort the command
344
- console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
345
- }
346
-
347
- recordSuccess(repo, agent)
348
-
349
- // -----------------------------------------------------------------------
350
- // Run postCreate hook if configured
351
- // -----------------------------------------------------------------------
352
- const hookCmd = config.hooks?.postCreate
353
- if (hookCmd) {
354
- runPostCreateHook(hookCmd, {
355
- repo,
356
- issueNumber: issue.number,
357
- issueUrl: issue.url,
358
- })
512
+ process.exit(err.isAbort ? 0 : 1)
359
513
  }
360
514
  })
@@ -83,7 +83,7 @@ export const statusCommand = new Command('status')
83
83
  }
84
84
 
85
85
  if (!repo) {
86
- console.error(chalk.red('No active repo. Set one with: tissues open'))
86
+ console.error(chalk.red('No active repo. Set one with: tissues use <owner/repo>'))
87
87
  process.exit(1)
88
88
  }
89
89
 
@@ -3,10 +3,12 @@ import { pickRepo } from '../lib/repo-picker.js'
3
3
  import { setConfig } from '../lib/config.js'
4
4
  import chalk from 'chalk'
5
5
 
6
- export const openCommand = new Command('open')
6
+ export const useCommand = new Command('use')
7
7
  .description('Set the active repository context')
8
8
  .argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
9
- .action(async (repo) => {
9
+ .option('--repo <repo>', 'Repository in owner/name format (alias for positional argument)')
10
+ .action(async (repoArg, opts) => {
11
+ const repo = repoArg || opts.repo
10
12
  if (repo) {
11
13
  setConfig({ activeRepo: repo })
12
14
  console.log(chalk.green(`โœ“ Active repo set to ${repo}`))
@@ -56,7 +56,7 @@ function nowISO() {
56
56
  * @typedef {object} AttributionOpts
57
57
  * @property {string} [agent] - agent identifier (e.g. 'claude-opus-4-6')
58
58
  * @property {string} [session] - session or conversation ID
59
- * @property {number} [pid] - process ID of the creating process
59
+ * @property {number} [pid] - process ID (opt-in; omitted by default)
60
60
  * @property {string} [model] - AI model used (if any)
61
61
  * @property {string} [trigger] - how the issue was created (e.g. 'cli-create')
62
62
  * @property {string} [fingerprint] - content fingerprint (sha256:...)
@@ -72,7 +72,8 @@ function nowISO() {
72
72
  * Build a normalized attribution metadata object from raw options.
73
73
  *
74
74
  * The returned object includes all provided fields plus automatic defaults
75
- * (`created_at`, `created_via`, `pid`). Undefined/null fields are omitted.
75
+ * (`created_at`, `created_via`). Undefined/null fields are omitted.
76
+ * `pid` is opt-in โ€” it is only included if explicitly passed.
76
77
  *
77
78
  * @param {AttributionOpts} opts
78
79
  * @returns {object} metadata record
@@ -100,8 +101,8 @@ export function buildAttribution(opts = {}) {
100
101
  if (session != null) meta.session = String(session)
101
102
  if (model != null) meta.model = String(model)
102
103
 
103
- // Process info
104
- meta.pid = pid != null ? Number(pid) : process.pid
104
+ // Process info (pid is opt-in โ€” pass it explicitly to include)
105
+ if (pid != null) meta.pid = Number(pid)
105
106
  meta.trigger = trigger != null ? String(trigger) : 'cli-create'
106
107
 
107
108
  // Deduplication handles
@@ -120,7 +121,7 @@ export function buildAttribution(opts = {}) {
120
121
 
121
122
  // Timestamps / versioning
122
123
  meta.created_at = createdAt ?? nowISO()
123
- meta.created_via = `tissues-cli/${PKG_VERSION}`
124
+ meta.created_via = `tissues-cli/${PKG_VERSION.split('.').slice(0, 2).join('.')}`
124
125
 
125
126
  return meta
126
127
  }
@@ -137,11 +138,10 @@ export function buildAttribution(opts = {}) {
137
138
  * <!-- tissues-meta
138
139
  * agent: claude-opus-4-6
139
140
  * session: abc123
140
- * pid: 12345
141
141
  * trigger: cli-create
142
142
  * fingerprint: sha256:deadbeef
143
143
  * created_at: 2026-02-19T15:30:00Z
144
- * created_via: tissues-cli/0.1.0
144
+ * created_via: tissues-cli/0.1
145
145
  * -->
146
146
  *
147
147
  * @param {AttributionOpts} opts
@@ -170,7 +170,7 @@ export function renderAttribution(opts = {}) {
170
170
  * Returns the parsed key/value pairs as a plain object, or `null` if no
171
171
  * attribution block is present in `issueBody`.
172
172
  *
173
- * Numeric fields (`pid`, `risk`, `complexity`, `confidence`) are coerced to
173
+ * Numeric fields (`risk`, `complexity`, `confidence`) are coerced to
174
174
  * numbers. The `context_tags` field is split back into an array.
175
175
  *
176
176
  * @param {string} issueBody - raw GitHub issue body markdown
package/src/lib/db.js CHANGED
@@ -277,6 +277,25 @@ export function getCircuitState(repo, agent = 'human') {
277
277
  }
278
278
  }
279
279
 
280
+ /**
281
+ * Increment the failure count for a circuit without opening it.
282
+ * Used to persist intermediate failures before the trip threshold is reached.
283
+ *
284
+ * @param {string} repo
285
+ * @param {string} [agent='human']
286
+ */
287
+ export function incrementFailureCount(repo, agent = 'human') {
288
+ const db = getDb()
289
+ const id = `${repo}:${agent}`
290
+ db.prepare(
291
+ `INSERT INTO circuit_breaker (id, repo, agent, status, failure_count, updated_at)
292
+ VALUES (?, ?, ?, 'closed', 1, datetime('now'))
293
+ ON CONFLICT(id) DO UPDATE SET
294
+ failure_count = failure_count + 1,
295
+ updated_at = datetime('now')`,
296
+ ).run(id, repo, agent)
297
+ }
298
+
280
299
  /**
281
300
  * Trip (open) the circuit breaker and set a cooldown window.
282
301
  *
@@ -6,7 +6,7 @@ import os from 'node:os'
6
6
  // Built-in defaults
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const BUILT_IN_DEFAULTS = {
9
+ export const BUILT_IN_DEFAULTS = {
10
10
  // Safety
11
11
  safety: {
12
12
  maxPerHour: 10,
@@ -50,12 +50,6 @@ const BUILT_IN_DEFAULTS = {
50
50
  },
51
51
  }
52
52
 
53
- // ---------------------------------------------------------------------------
54
- // Env var prefix
55
- // ---------------------------------------------------------------------------
56
-
57
- const ENV_PREFIX = 'TISSUES_'
58
-
59
53
  // ---------------------------------------------------------------------------
60
54
  // Helpers
61
55
  // ---------------------------------------------------------------------------
@@ -113,64 +107,10 @@ function readJsonFile(filePath) {
113
107
  *
114
108
  * @returns {string}
115
109
  */
116
- function userConfigPath() {
110
+ export function userConfigPath() {
117
111
  return path.join(os.homedir(), '.config', 'tissues', 'config.json')
118
112
  }
119
113
 
120
- /**
121
- * Convert a flat `TISSUES_SECTION_KEY=value` environment variable map into
122
- * a nested object matching the config shape.
123
- *
124
- * Variable names are lowercased and split on `_` to build the path:
125
- * TISSUES_SAFETY_MAX_PER_HOUR=20 โ†’ { safety: { maxPerHour: 20 } }
126
- *
127
- * Simple camelCase reconstruction: after stripping the prefix and splitting on
128
- * `_`, the first segment is the section; the remaining segments are joined in
129
- * camelCase.
130
- *
131
- * Only variables whose section exists in BUILT_IN_DEFAULTS are included so we
132
- * do not accidentally pollute the config with unrelated env vars.
133
- *
134
- * @returns {object}
135
- */
136
- function configFromEnv() {
137
- const result = {}
138
- const knownSections = new Set(Object.keys(BUILT_IN_DEFAULTS))
139
-
140
- for (const [rawKey, rawValue] of Object.entries(process.env)) {
141
- if (!rawKey.startsWith(ENV_PREFIX)) continue
142
- const stripped = rawKey.slice(ENV_PREFIX.length).toLowerCase()
143
- const parts = stripped.split('_')
144
- if (parts.length < 2) continue
145
-
146
- const section = parts[0]
147
- if (!knownSections.has(section)) continue
148
-
149
- // Join remaining parts as camelCase
150
- const remainingParts = parts.slice(1)
151
- const fieldName = remainingParts
152
- .map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
153
- .join('')
154
-
155
- // Coerce value type based on built-in defaults
156
- let value = rawValue
157
- const builtInSection = BUILT_IN_DEFAULTS[section]
158
- if (builtInSection && fieldName in builtInSection) {
159
- const defaultVal = builtInSection[fieldName]
160
- if (typeof defaultVal === 'number') {
161
- value = Number(rawValue)
162
- } else if (typeof defaultVal === 'boolean') {
163
- value = rawValue === 'true' || rawValue === '1'
164
- }
165
- }
166
-
167
- if (!result[section]) result[section] = {}
168
- result[section][fieldName] = value
169
- }
170
-
171
- return result
172
- }
173
-
174
114
  // ---------------------------------------------------------------------------
175
115
  // Public API
176
116
  // ---------------------------------------------------------------------------
@@ -200,8 +140,7 @@ export function findRepoRoot(startDir) {
200
140
  * 1. Built-in defaults (lowest)
201
141
  * 2. User-level config (~/.config/tissues/config.json)
202
142
  * 3. Repo-level config (<repoRoot>/.tissues/config.json)
203
- * 4. Environment vars (TISSUES_*)
204
- * 5. CLI flags (passed as `cliOverrides`, highest)
143
+ * 4. CLI flags (passed as `cliOverrides`, highest)
205
144
  *
206
145
  * @param {string} [repoRoot] - path to the repo root; auto-detected if omitted
207
146
  * @param {object} [cliOverrides] - values from parsed CLI flags (already nested)
@@ -223,11 +162,7 @@ export function loadConfig(repoRoot, cliOverrides) {
223
162
  if (repoCfg) merged = deepMerge(merged, repoCfg)
224
163
  }
225
164
 
226
- // 4. Environment variables
227
- const envCfg = configFromEnv()
228
- if (Object.keys(envCfg).length > 0) merged = deepMerge(merged, envCfg)
229
-
230
- // 5. CLI overrides
165
+ // 4. CLI overrides
231
166
  if (cliOverrides && typeof cliOverrides === 'object') {
232
167
  merged = deepMerge(merged, cliOverrides)
233
168
  }
package/src/lib/gh.js CHANGED
@@ -44,10 +44,6 @@ export function requireGh() {
44
44
  * @returns {string | null}
45
45
  */
46
46
  export function getToken() {
47
- // Env vars take priority (CI/CD)
48
- const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
49
- if (envToken) return envToken
50
-
51
47
  try {
52
48
  return execFileSync('gh', ['auth', 'token'], {
53
49
  encoding: 'utf8',
@@ -221,6 +217,59 @@ export function createIssue(repo, { title, body, labels }) {
221
217
  return { number, url }
222
218
  }
223
219
 
220
+ /**
221
+ * List all label names for a repo.
222
+ * @param {string} repo - owner/name
223
+ * @returns {string[]}
224
+ */
225
+ export function listLabels(repo) {
226
+ const raw = execFileSync('gh', [
227
+ 'label', 'list',
228
+ '--repo', repo,
229
+ '--json', 'name',
230
+ '--jq', '.[].name',
231
+ ], {
232
+ encoding: 'utf8',
233
+ stdio: ['ignore', 'pipe', 'ignore'],
234
+ }).trim()
235
+
236
+ if (!raw) return []
237
+ return raw.split('\n').filter(Boolean)
238
+ }
239
+
240
+ /**
241
+ * Create a label in a repo with a default color.
242
+ * @param {string} repo - owner/name
243
+ * @param {string} name - label name
244
+ */
245
+ export function createLabel(repo, name) {
246
+ execFileSync('gh', [
247
+ 'label', 'create', name,
248
+ '--repo', repo,
249
+ '--color', '#0075ca',
250
+ ], {
251
+ encoding: 'utf8',
252
+ stdio: ['ignore', 'pipe', 'pipe'],
253
+ })
254
+ }
255
+
256
+ /**
257
+ * Apply labels to an existing issue.
258
+ * @param {string} repo - owner/name
259
+ * @param {number} issueNumber
260
+ * @param {string[]} labels
261
+ */
262
+ export function addLabelsToIssue(repo, issueNumber, labels) {
263
+ execFileSync('gh', [
264
+ 'issue', 'edit', String(issueNumber),
265
+ '--repo', repo,
266
+ '--add-label', labels.join(','),
267
+ ], {
268
+ encoding: 'utf8',
269
+ stdio: ['ignore', 'pipe', 'pipe'],
270
+ })
271
+ }
272
+
224
273
  /**
225
274
  * List open issues for a repo.
226
275
  * @param {string} repo - owner/name
package/src/lib/safety.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  probeCircuit,
17
17
  recordRateEvent,
18
18
  countRecentEvents,
19
+ incrementFailureCount,
19
20
  getDb,
20
21
  } from './db.js'
21
22
 
@@ -206,10 +207,8 @@ export function recordFailure(repo, agent, config = {}) {
206
207
  `Cooldown: ${cfg.cooldownMinutes} minutes.`
207
208
  )
208
209
  } else {
209
- // Update failure count without tripping โ€” db.tripCircuit handles the trip;
210
- // for intermediate increments we record a 'failure' rate event so the count
211
- // is persisted across calls.
212
- recordRateEvent(repo, agent, 'failure')
210
+ // Persist intermediate failure count without opening the circuit yet
211
+ incrementFailureCount(repo, agent)
213
212
  }
214
213
  }
215
214