tissues 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +12 -1
  4. package/src/commands/ai.js +149 -173
  5. package/src/commands/config.js +143 -69
  6. package/src/commands/create.js +16 -9
  7. package/src/commands/create.test.js +381 -0
  8. package/src/commands/enhancements.js +282 -0
  9. package/src/commands/flush.test.js +299 -0
  10. package/src/commands/list.js +3 -2
  11. package/src/commands/providers.js +347 -0
  12. package/src/commands/providers.test.js +28 -0
  13. package/src/commands/storage.js +167 -0
  14. package/src/commands/sync.js +225 -0
  15. package/src/daemon/sync.js +189 -0
  16. package/src/lib/ai/adapters/claude-cli.js +55 -0
  17. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  18. package/src/lib/ai/adapters/codex-cli.js +77 -0
  19. package/src/lib/ai/adapters/command.js +23 -13
  20. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  21. package/src/lib/ai/adapters/openclaw.js +91 -0
  22. package/src/lib/ai/agent-actions.js +271 -0
  23. package/src/lib/ai/agent.js +323 -0
  24. package/src/lib/ai/body-template.js +15 -0
  25. package/src/lib/ai/discovery.js +89 -0
  26. package/src/lib/ai/discovery.test.js +74 -0
  27. package/src/lib/ai/enhance.js +48 -11
  28. package/src/lib/ai/enhancement-adapter.js +109 -0
  29. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  30. package/src/lib/ai/index.js +2 -2
  31. package/src/lib/ai/pipeline.js +20 -2
  32. package/src/lib/ai/pipeline.test.js +257 -0
  33. package/src/lib/ai/prompt.test.js +30 -0
  34. package/src/lib/ai/router.js +118 -7
  35. package/src/lib/ai/router.test.js +481 -0
  36. package/src/lib/ai/steps.js +23 -3
  37. package/src/lib/ai/steps.test.js +335 -0
  38. package/src/lib/attribution.test.js +64 -0
  39. package/src/lib/cache.js +408 -0
  40. package/src/lib/db.js +42 -0
  41. package/src/lib/dedup.js +44 -48
  42. package/src/lib/dedup.test.js +227 -0
  43. package/src/lib/defaults.js +37 -1
  44. package/src/lib/defaults.test.js +217 -0
  45. package/src/lib/drafts-perf.test.js +203 -0
  46. package/src/lib/drafts.test.js +300 -0
  47. package/src/lib/enhancements.js +436 -0
  48. package/src/lib/enhancements.test.js +294 -0
  49. package/src/lib/gh.js +76 -10
  50. package/src/lib/safety.test.js +217 -0
  51. package/src/lib/storage.js +298 -0
  52. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Performance and adversarial tests for drafts.js.
3
+ *
4
+ * These tests verify that drafts are correct under load:
5
+ * - 100 concurrent writes produce no collisions
6
+ * - Reading 1000 items completes in < 500ms
7
+ * - Corrupt files don't prevent other items from processing
8
+ * - writeToDrafts during an active readDrafts doesn't cause double-processing
9
+ *
10
+ * Run standalone: node --test src/lib/drafts-perf.test.js
11
+ */
12
+
13
+ import { test, describe, before, after } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import os from 'node:os'
18
+
19
+ import {
20
+ getDraftsDir,
21
+ writeToDrafts,
22
+ readDrafts,
23
+ markComplete,
24
+ hasPending,
25
+ countPending,
26
+ } from './drafts.js'
27
+
28
+ function makeTempRepo() {
29
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-drafts-perf-'))
30
+ fs.mkdirSync(path.join(dir, '.git'))
31
+ return dir
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // 100 concurrent writes
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('100 concurrent writes', () => {
39
+ let tmpDir
40
+
41
+ before(() => { tmpDir = makeTempRepo() })
42
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
43
+
44
+ test('all 100 files exist with unique IDs', async () => {
45
+ const writes = Array.from({ length: 100 }, (_, i) =>
46
+ Promise.resolve(
47
+ writeToDrafts({ repo: 'perf/repo', title: `Issue ${i}`, body: `body ${i}` }, tmpDir),
48
+ ),
49
+ )
50
+
51
+ const items = await Promise.all(writes)
52
+ const ids = new Set(items.map((i) => i.id))
53
+
54
+ assert.equal(ids.size, 100, 'all 100 IDs should be unique')
55
+ assert.equal(countPending(tmpDir), 100, 'all 100 files should be on disk')
56
+ })
57
+ })
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Reading 1000 items
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe('readDrafts() at scale', () => {
64
+ let tmpDir
65
+
66
+ before(() => {
67
+ tmpDir = makeTempRepo()
68
+ const dir = getDraftsDir(tmpDir)
69
+
70
+ // Pre-seed 1000 items directly (faster than writeToDrafts loop)
71
+ for (let i = 0; i < 1000; i++) {
72
+ const id = `perf${String(i).padStart(6, '0')}`
73
+ const record = {
74
+ id,
75
+ repo: 'perf/repo',
76
+ title: `Issue ${i}`,
77
+ body: `body ${i}`,
78
+ labels: [],
79
+ createdAt: new Date(Date.now() - (1000 - i) * 1000).toISOString(),
80
+ status: 'pending',
81
+ attemptCount: 0,
82
+ }
83
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record), 'utf8')
84
+ }
85
+ })
86
+
87
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
88
+
89
+ test('reads 1000 items in < 500ms', () => {
90
+ const start = Date.now()
91
+ const items = readDrafts(tmpDir)
92
+ const elapsed = Date.now() - start
93
+
94
+ assert.equal(items.length, 1000)
95
+ assert.ok(elapsed < 500, `readDrafts took ${elapsed}ms — expected < 500ms`)
96
+ })
97
+
98
+ test('results are sorted by createdAt', () => {
99
+ const items = readDrafts(tmpDir)
100
+ for (let i = 1; i < items.length; i++) {
101
+ const prev = new Date(items[i - 1].createdAt)
102
+ const curr = new Date(items[i].createdAt)
103
+ assert.ok(prev <= curr, `item ${i - 1} should be before item ${i}`)
104
+ }
105
+ })
106
+ })
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Corrupt files don't break the rest
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('adversarial: corrupt files in drafts', () => {
113
+ let tmpDir
114
+
115
+ before(() => {
116
+ tmpDir = makeTempRepo()
117
+ const dir = getDraftsDir(tmpDir)
118
+
119
+ // Write 5 valid items
120
+ for (let i = 0; i < 5; i++) {
121
+ writeToDrafts({ repo: 'a/b', title: `Valid ${i}`, body: '' }, tmpDir)
122
+ }
123
+
124
+ // Inject 3 corrupt files
125
+ fs.writeFileSync(path.join(dir, 'corrupt1.json'), '{ this is not json', 'utf8')
126
+ fs.writeFileSync(path.join(dir, 'corrupt2.json'), '', 'utf8')
127
+ fs.writeFileSync(path.join(dir, 'corrupt3.json'), 'null', 'utf8')
128
+ })
129
+
130
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
131
+
132
+ test('readDrafts() skips corrupt files and returns all valid items', () => {
133
+ const items = readDrafts(tmpDir)
134
+ // Should get 5 valid items (null is valid JSON but has no .id, gets returned anyway)
135
+ const validItems = items.filter((i) => i && i.id)
136
+ assert.equal(validItems.length, 5, 'should return all 5 valid items')
137
+ })
138
+
139
+ test('hasPending() does not throw on corrupt dir', () => {
140
+ assert.doesNotThrow(() => hasPending(tmpDir))
141
+ assert.equal(hasPending(tmpDir), true)
142
+ })
143
+ })
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // New write during read doesn't cause double-processing
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe('concurrent write during read', () => {
150
+ let tmpDir
151
+
152
+ before(() => { tmpDir = makeTempRepo() })
153
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
154
+
155
+ test('item written after readDrafts snapshot is not included in that batch', () => {
156
+ // Write 3 items before read
157
+ writeToDrafts({ repo: 'c/d', title: 'Pre A', body: '' }, tmpDir)
158
+ writeToDrafts({ repo: 'c/d', title: 'Pre B', body: '' }, tmpDir)
159
+ writeToDrafts({ repo: 'c/d', title: 'Pre C', body: '' }, tmpDir)
160
+
161
+ // Snapshot the batch
162
+ const batch = readDrafts(tmpDir)
163
+ assert.equal(batch.length, 3)
164
+
165
+ // Write a new item AFTER reading — simulates concurrent create
166
+ writeToDrafts({ repo: 'c/d', title: 'Post D', body: '' }, tmpDir)
167
+
168
+ // The batch we already read should not include the late arrival
169
+ const lateArrival = batch.find((i) => i.title === 'Post D')
170
+ assert.equal(lateArrival, undefined, 'late-arriving item should not appear in existing batch')
171
+
172
+ // A fresh read should see all 4
173
+ const fresh = readDrafts(tmpDir)
174
+ assert.equal(fresh.length, 4)
175
+ })
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // SIGTERM survival (file persists if process would be killed mid-flush)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe('durability: file survives after write', () => {
183
+ let tmpDir
184
+
185
+ before(() => { tmpDir = makeTempRepo() })
186
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
187
+
188
+ test('draft file exists immediately after writeToDrafts (before any network call)', () => {
189
+ // Simulate: we write draft, then "crash" (don't call createIssue)
190
+ const item = writeToDrafts({ repo: 'e/f', title: 'Crash survivor', body: 'important' }, tmpDir)
191
+
192
+ // The file should exist on disk right now
193
+ const dir = getDraftsDir(tmpDir)
194
+ const filePath = path.join(dir, `${item.id}.json`)
195
+ assert.ok(fs.existsSync(filePath), 'file must exist immediately after write')
196
+
197
+ // Simulate a re-run: readDrafts finds it
198
+ const recovered = readDrafts(tmpDir)
199
+ const mine = recovered.find((i) => i.id === item.id)
200
+ assert.ok(mine, 'file should be recoverable via readDrafts after simulated crash')
201
+ assert.equal(mine.status, 'pending')
202
+ })
203
+ })
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Tests for drafts.js
3
+ *
4
+ * Uses real temp directories — no mocks. Each describe block gets its own
5
+ * isolated tmpDir so tests remain independent and clean up after themselves.
6
+ */
7
+
8
+ import { test, describe, before, after, beforeEach } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import os from 'node:os'
13
+
14
+ import {
15
+ getDraftsDir,
16
+ writeToDrafts,
17
+ readDrafts,
18
+ markComplete,
19
+ markFailed,
20
+ hasPending,
21
+ countPending,
22
+ } from './drafts.js'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helper: create a temp dir that looks like a repo root
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function makeTempRepo() {
29
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-drafts-test-'))
30
+ fs.mkdirSync(path.join(dir, '.git'))
31
+ return dir
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // writeToDrafts
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('writeToDrafts()', () => {
39
+ let tmpDir
40
+
41
+ before(() => { tmpDir = makeTempRepo() })
42
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
43
+
44
+ test('creates a JSON file with correct structure', () => {
45
+ const item = writeToDrafts(
46
+ { repo: 'owner/repo', title: 'My issue', body: 'Some body', labels: ['bug'] },
47
+ tmpDir,
48
+ )
49
+
50
+ assert.equal(item.repo, 'owner/repo')
51
+ assert.equal(item.title, 'My issue')
52
+ assert.equal(item.body, 'Some body')
53
+ assert.deepEqual(item.labels, ['bug'])
54
+ assert.equal(item.status, 'pending')
55
+ assert.equal(item.attemptCount, 0)
56
+ assert.ok(item.id, 'id should be set')
57
+ assert.ok(item.createdAt, 'createdAt should be set')
58
+
59
+ const dir = getDraftsDir(tmpDir)
60
+ const filePath = path.join(dir, `${item.id}.json`)
61
+ assert.ok(fs.existsSync(filePath), 'file should exist on disk')
62
+
63
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
64
+ assert.deepEqual(parsed, item)
65
+ })
66
+
67
+ test('uses empty array for labels when omitted', () => {
68
+ const item = writeToDrafts({ repo: 'owner/repo', title: 'No labels', body: 'body' }, tmpDir)
69
+ assert.deepEqual(item.labels, [])
70
+ })
71
+
72
+ test('two concurrent writeToDrafts() calls produce unique IDs', () => {
73
+ const a = writeToDrafts({ repo: 'a/b', title: 'A', body: '' }, tmpDir)
74
+ const b = writeToDrafts({ repo: 'a/b', title: 'B', body: '' }, tmpDir)
75
+ assert.notEqual(a.id, b.id)
76
+
77
+ const dir = getDraftsDir(tmpDir)
78
+ assert.ok(fs.existsSync(path.join(dir, `${a.id}.json`)))
79
+ assert.ok(fs.existsSync(path.join(dir, `${b.id}.json`)))
80
+ })
81
+ })
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // readDrafts
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('readDrafts()', () => {
88
+ let tmpDir
89
+
90
+ before(() => { tmpDir = makeTempRepo() })
91
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
92
+
93
+ test('returns empty array when drafts is empty', () => {
94
+ const items = readDrafts(tmpDir)
95
+ assert.deepEqual(items, [])
96
+ })
97
+
98
+ test('returns items sorted by createdAt ascending', async () => {
99
+ // Write three items with intentional ordering
100
+ const a = writeToDrafts({ repo: 'r/r', title: 'First', body: '' }, tmpDir)
101
+ await new Promise((r) => setTimeout(r, 5)) // tiny gap so timestamps differ
102
+ const b = writeToDrafts({ repo: 'r/r', title: 'Second', body: '' }, tmpDir)
103
+ await new Promise((r) => setTimeout(r, 5))
104
+ const c = writeToDrafts({ repo: 'r/r', title: 'Third', body: '' }, tmpDir)
105
+
106
+ const items = readDrafts(tmpDir)
107
+ const titles = items.map((i) => i.title)
108
+ assert.ok(titles.indexOf('First') < titles.indexOf('Second'), 'First should be before Second')
109
+ assert.ok(titles.indexOf('Second') < titles.indexOf('Third'), 'Second should be before Third')
110
+ })
111
+
112
+ test('ignores non-JSON files gracefully', () => {
113
+ const dir = getDraftsDir(tmpDir)
114
+ fs.writeFileSync(path.join(dir, '.DS_Store'), 'junk')
115
+ fs.writeFileSync(path.join(dir, 'README.txt'), 'not json')
116
+
117
+ // Should not throw and should not include the non-JSON files
118
+ const items = readDrafts(tmpDir)
119
+ const nonDraft = items.filter((i) => !i.id)
120
+ assert.equal(nonDraft.length, 0)
121
+ })
122
+
123
+ test('skips corrupt JSON files', () => {
124
+ const dir = getDraftsDir(tmpDir)
125
+ fs.writeFileSync(path.join(dir, 'corrupt.json'), '{ invalid json !!!')
126
+
127
+ // Should not throw
128
+ const items = readDrafts(tmpDir)
129
+ // The corrupt file should be silently skipped
130
+ const corrupt = items.find((i) => i === undefined)
131
+ assert.equal(corrupt, undefined)
132
+ })
133
+ })
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // markComplete
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('markComplete()', () => {
140
+ let tmpDir
141
+
142
+ before(() => { tmpDir = makeTempRepo() })
143
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
144
+
145
+ test('deletes the draft file', () => {
146
+ const item = writeToDrafts({ repo: 'r/r', title: 'To delete', body: '' }, tmpDir)
147
+ const dir = getDraftsDir(tmpDir)
148
+ const filePath = path.join(dir, `${item.id}.json`)
149
+
150
+ assert.ok(fs.existsSync(filePath))
151
+ markComplete(item.id, { issueNumber: 42, issueUrl: 'https://github.com/r/r/issues/42' }, tmpDir)
152
+ assert.ok(!fs.existsSync(filePath), 'file should be deleted')
153
+ })
154
+
155
+ test('is idempotent — does not throw if file already gone', () => {
156
+ assert.doesNotThrow(() => markComplete('nonexistent-id', {}, tmpDir))
157
+ })
158
+ })
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // markFailed
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe('markFailed()', () => {
165
+ let tmpDir
166
+
167
+ before(() => { tmpDir = makeTempRepo() })
168
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
169
+
170
+ test('updates file with status=failed and error message', () => {
171
+ const item = writeToDrafts({ repo: 'r/r', title: 'Fail me', body: '' }, tmpDir)
172
+ markFailed(item.id, 'Connection refused', tmpDir)
173
+
174
+ const dir = getDraftsDir(tmpDir)
175
+ const filePath = path.join(dir, `${item.id}.json`)
176
+ assert.ok(fs.existsSync(filePath), 'file should still exist')
177
+
178
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
179
+ assert.equal(parsed.status, 'failed')
180
+ assert.equal(parsed.error, 'Connection refused')
181
+ assert.ok(parsed.failedAt, 'failedAt should be set')
182
+ })
183
+
184
+ test('is idempotent — does not throw if file is gone', () => {
185
+ assert.doesNotThrow(() => markFailed('nonexistent-id', 'error', tmpDir))
186
+ })
187
+ })
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // hasPending / countPending
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('hasPending() and countPending()', () => {
194
+ let tmpDir
195
+
196
+ before(() => { tmpDir = makeTempRepo() })
197
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
198
+
199
+ test('hasPending() returns false when drafts dir does not exist', () => {
200
+ const freshDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-fresh-'))
201
+ fs.mkdirSync(path.join(freshDir, '.git'))
202
+ try {
203
+ assert.equal(hasPending(freshDir), false)
204
+ } finally {
205
+ fs.rmSync(freshDir, { recursive: true, force: true })
206
+ }
207
+ })
208
+
209
+ test('countPending() returns 0 when drafts is empty', () => {
210
+ assert.equal(countPending(tmpDir), 0)
211
+ })
212
+
213
+ test('hasPending() returns true after writing an item', () => {
214
+ writeToDrafts({ repo: 'r/r', title: 'Pending', body: '' }, tmpDir)
215
+ assert.equal(hasPending(tmpDir), true)
216
+ })
217
+
218
+ test('countPending() counts correctly', () => {
219
+ writeToDrafts({ repo: 'r/r', title: 'One more', body: '' }, tmpDir)
220
+ const count = countPending(tmpDir)
221
+ assert.ok(count >= 2, `Expected at least 2 pending, got ${count}`)
222
+ })
223
+
224
+ test('hasPending() returns false after all items are removed', () => {
225
+ const items = readDrafts(tmpDir)
226
+ for (const item of items) {
227
+ markComplete(item.id, {}, tmpDir)
228
+ }
229
+ assert.equal(hasPending(tmpDir), false)
230
+ assert.equal(countPending(tmpDir), 0)
231
+ })
232
+ })
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Full round-trip lifecycle
236
+ // ---------------------------------------------------------------------------
237
+
238
+ describe('Full round-trip: write → read → markComplete', () => {
239
+ let tmpDir
240
+
241
+ before(() => { tmpDir = makeTempRepo() })
242
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
243
+
244
+ test('lifecycle ends with empty drafts', async () => {
245
+ // Write three items
246
+ const a = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue A', body: 'body a' }, tmpDir)
247
+ await new Promise((r) => setTimeout(r, 2))
248
+ const b = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue B', body: 'body b' }, tmpDir)
249
+ await new Promise((r) => setTimeout(r, 2))
250
+ const c = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue C', body: 'body c' }, tmpDir)
251
+
252
+ // Read and verify order
253
+ const items = readDrafts(tmpDir)
254
+ assert.equal(items.length, 3)
255
+ assert.equal(items[0].title, 'Issue A')
256
+ assert.equal(items[1].title, 'Issue B')
257
+ assert.equal(items[2].title, 'Issue C')
258
+
259
+ // Complete all
260
+ for (const item of items) {
261
+ markComplete(item.id, {}, tmpDir)
262
+ }
263
+
264
+ // Drafts should be empty
265
+ assert.equal(hasPending(tmpDir), false)
266
+ assert.deepEqual(readDrafts(tmpDir), [])
267
+ })
268
+ })
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Migration: old outbox dir → drafts
272
+ // ---------------------------------------------------------------------------
273
+
274
+ describe('outbox → drafts migration', () => {
275
+ let tmpDir
276
+
277
+ before(() => { tmpDir = makeTempRepo() })
278
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
279
+
280
+ test('auto-renames .tissues/outbox/ to .tissues/drafts/ on first access', () => {
281
+ // Simulate an old outbox directory
282
+ const oldDir = path.join(tmpDir, '.tissues', 'outbox')
283
+ fs.mkdirSync(oldDir, { recursive: true })
284
+ fs.writeFileSync(path.join(oldDir, 'test-issue.json'), JSON.stringify({
285
+ id: 'test-issue',
286
+ repo: 'r/r',
287
+ title: 'Test issue',
288
+ body: '',
289
+ labels: [],
290
+ createdAt: new Date().toISOString(),
291
+ status: 'pending',
292
+ }))
293
+
294
+ // getDraftsDir should migrate
295
+ const dir = getDraftsDir(tmpDir)
296
+ assert.ok(dir.endsWith('.tissues/drafts'), `Expected drafts dir, got ${dir}`)
297
+ assert.ok(!fs.existsSync(oldDir), 'old outbox dir should be gone')
298
+ assert.ok(fs.existsSync(path.join(dir, 'test-issue.json')), 'migrated file should exist')
299
+ })
300
+ })