tissues 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,257 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { runPipeline } from './pipeline.js'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock adapter
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function mockAdapter(responses = {}) {
10
+ return {
11
+ name: 'mock',
12
+ complete(messages, opts) {
13
+ const lastUser = messages.find((m) => m.role === 'user')?.content || ''
14
+ // Return pre-configured response or a default
15
+ for (const [key, val] of Object.entries(responses)) {
16
+ if (lastUser.includes(key) || opts._stepName === key) return Promise.resolve(val)
17
+ }
18
+ return Promise.resolve('{}')
19
+ },
20
+ }
21
+ }
22
+
23
+ function makeStep(name, { shouldRun = true, response = '{}', parseResult = {}, failOnParse = false, provider = null } = {}) {
24
+ return {
25
+ name,
26
+ displayName: name,
27
+ maxTokens: 512,
28
+ provider,
29
+ shouldRun: () => shouldRun,
30
+ buildMessages: (ctx) => [
31
+ { role: 'system', content: 'test' },
32
+ { role: 'user', content: `step:${name} title:${ctx.title}` },
33
+ ],
34
+ parseResponse: (raw, ctx) => {
35
+ if (failOnParse) throw new Error('parse failed')
36
+ Object.assign(ctx, parseResult)
37
+ },
38
+ }
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tests
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe('runPipeline', () => {
46
+ test('steps execute in order, context accumulates', async () => {
47
+ const order = []
48
+ const steps = [
49
+ makeStep('a', { parseResult: { aResult: 'from-a' } }),
50
+ makeStep('b', { parseResult: { bResult: 'from-b' } }),
51
+ ]
52
+
53
+ const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
54
+ const route = { adapter: mockAdapter(), model: 'test-model' }
55
+
56
+ await runPipeline(steps, ctx, route, null, null, {
57
+ onStepStart(step) { order.push(`start:${step.name}`) },
58
+ onStepDone(step) { order.push(`done:${step.name}`) },
59
+ })
60
+
61
+ assert.deepEqual(order, ['start:a', 'done:a', 'start:b', 'done:b'])
62
+ assert.equal(ctx.aResult, 'from-a')
63
+ assert.equal(ctx.bResult, 'from-b')
64
+ })
65
+
66
+ test('shouldRun: false skips steps correctly', async () => {
67
+ const skipped = []
68
+ const steps = [
69
+ makeStep('a', { shouldRun: false }),
70
+ makeStep('b', { parseResult: { bDone: true } }),
71
+ ]
72
+
73
+ const ctx = { title: 'test', _stepConfigs: { a: 'auto', b: 'always' } }
74
+ const route = { adapter: mockAdapter(), model: 'test' }
75
+
76
+ await runPipeline(steps, ctx, route, null, null, {
77
+ onStepSkip(step, reason) { skipped.push({ name: step.name, reason }) },
78
+ })
79
+
80
+ assert.equal(skipped.length, 1)
81
+ assert.equal(skipped[0].name, 'a')
82
+ assert.equal(ctx.bDone, true)
83
+ assert.equal(ctx.aResult, undefined)
84
+ })
85
+
86
+ test('step config "never" skips regardless of shouldRun', async () => {
87
+ const skipped = []
88
+ const steps = [makeStep('a', { shouldRun: true, parseResult: { aResult: true } })]
89
+
90
+ const ctx = { title: 'test', _stepConfigs: { a: 'never' } }
91
+ const route = { adapter: mockAdapter(), model: 'test' }
92
+
93
+ await runPipeline(steps, ctx, route, null, null, {
94
+ onStepSkip(step) { skipped.push(step.name) },
95
+ })
96
+
97
+ assert.equal(skipped.length, 1)
98
+ assert.equal(ctx.aResult, undefined)
99
+ })
100
+
101
+ test('failed step does not block subsequent steps', async () => {
102
+ const failed = []
103
+ const done = []
104
+ const steps = [
105
+ makeStep('a'),
106
+ makeStep('b', { parseResult: { bDone: true } }),
107
+ ]
108
+
109
+ // 'a' will fail because the adapter throws, 'b' uses a working adapter
110
+ // Actually both use the same adapter. Let's make a smarter adapter.
111
+ let callCount = 0
112
+ const smartAdapter = {
113
+ complete() {
114
+ callCount++
115
+ if (callCount === 1) return Promise.reject(new Error('API error'))
116
+ return Promise.resolve('{}')
117
+ },
118
+ }
119
+
120
+ const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
121
+ const route = { adapter: smartAdapter, model: 'test' }
122
+
123
+ await runPipeline(steps, ctx, route, null, null, {
124
+ onStepFail(step) { failed.push(step.name) },
125
+ onStepDone(step) { done.push(step.name) },
126
+ })
127
+
128
+ assert.deepEqual(failed, ['a'])
129
+ assert.deepEqual(done, ['b'])
130
+ })
131
+
132
+ test('budget exceeded mid-pipeline skips remaining non-format steps', async () => {
133
+ const failed = []
134
+ const done = []
135
+ const skipped = []
136
+
137
+ const steps = [
138
+ makeStep('context', { parseResult: { contextDone: true } }),
139
+ makeStep('scope', { parseResult: { scopeDone: true } }),
140
+ makeStep('risk', { parseResult: { riskDone: true } }),
141
+ { ...makeStep('format', { parseResult: { body: 'formatted' } }), name: 'format' },
142
+ ]
143
+
144
+ const ctx = { title: 'test', _stepConfigs: { context: 'always', scope: 'always', risk: 'always', format: 'always' } }
145
+ const route = { adapter: mockAdapter(), model: 'test' }
146
+
147
+ // Budget check fails after first step
148
+ let budgetCalls = 0
149
+ const budgetHelpers = {
150
+ checkBudgets() {
151
+ budgetCalls++
152
+ if (budgetCalls > 1) throw new Error('budget exceeded')
153
+ },
154
+ recordUsage() {},
155
+ }
156
+
157
+ await runPipeline(steps, ctx, route, null, {}, {
158
+ onStepDone(step) { done.push(step.name) },
159
+ onStepSkip(step, reason) { skipped.push(step.name) },
160
+ onStepFail(step) { failed.push(step.name) },
161
+ }, budgetHelpers)
162
+
163
+ // context runs successfully
164
+ assert.ok(done.includes('context'))
165
+ // scope fails on budget check
166
+ assert.ok(failed.includes('scope'))
167
+ // risk is skipped because budgetExhausted is true and it's not format
168
+ assert.ok(skipped.includes('risk'))
169
+ // format still runs (allowed even when budget exhausted)
170
+ assert.ok(done.includes('format'))
171
+ })
172
+
173
+ test('callbacks fire with correct arguments', async () => {
174
+ const events = []
175
+ const steps = [
176
+ makeStep('a', { parseResult: { x: 1 } }),
177
+ ]
178
+
179
+ const ctx = { title: 'test', _stepConfigs: { a: 'always' } }
180
+ const route = { adapter: mockAdapter(), model: 'test' }
181
+
182
+ await runPipeline(steps, ctx, route, null, null, {
183
+ onStepStart(step) { events.push({ type: 'start', step: step.name }) },
184
+ onStepDone(step, resultCtx) { events.push({ type: 'done', step: step.name, hasCtx: !!resultCtx }) },
185
+ })
186
+
187
+ assert.equal(events.length, 2)
188
+ assert.equal(events[0].type, 'start')
189
+ assert.equal(events[0].step, 'a')
190
+ assert.equal(events[1].type, 'done')
191
+ assert.equal(events[1].hasCtx, true)
192
+ })
193
+
194
+ test('step with provider override uses different adapter', async () => {
195
+ const adapterNames = []
196
+
197
+ // Override adapter that records its name
198
+ const overrideAdapter = {
199
+ name: 'override',
200
+ complete() { return Promise.resolve('{}') },
201
+ isConfigured() { return true },
202
+ }
203
+
204
+ const steps = [
205
+ makeStep('a', { parseResult: { aDone: true }, provider: 'my-cli' }),
206
+ makeStep('b', { parseResult: { bDone: true } }),
207
+ ]
208
+
209
+ const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
210
+ const defaultAdapter = mockAdapter()
211
+ const route = { adapter: defaultAdapter, model: 'test-model' }
212
+
213
+ // Config with a custom provider that resolveProviderAdapter will find
214
+ const config = {
215
+ ai: {
216
+ providers: {
217
+ 'my-cli': { command: 'echo test' },
218
+ },
219
+ },
220
+ }
221
+
222
+ const done = []
223
+ await runPipeline(steps, ctx, route, config, null, {
224
+ onStepDone(step) { done.push(step.name) },
225
+ })
226
+
227
+ // Both steps should complete (step a uses the override provider, step b uses default)
228
+ assert.deepEqual(done, ['a', 'b'])
229
+ assert.equal(ctx.aDone, true)
230
+ assert.equal(ctx.bDone, true)
231
+ })
232
+
233
+ test('step with invalid provider falls back to default route', async () => {
234
+ const failed = []
235
+ const done = []
236
+
237
+ const steps = [
238
+ makeStep('a', { parseResult: { aDone: true }, provider: 'nonexistent-provider' }),
239
+ ]
240
+
241
+ const ctx = { title: 'test', _stepConfigs: { a: 'always' } }
242
+ const route = { adapter: mockAdapter(), model: 'test' }
243
+ const config = { ai: {} }
244
+
245
+ await runPipeline(steps, ctx, route, config, null, {
246
+ onStepFail(step, err) { failed.push({ name: step.name, msg: err.message }) },
247
+ onStepDone(step) { done.push(step.name) },
248
+ })
249
+
250
+ // Provider override fails — onStepFail called with warning, but step continues with default
251
+ assert.equal(failed.length, 1)
252
+ assert.ok(failed[0].msg.includes('nonexistent-provider'))
253
+ // Step still runs with default adapter
254
+ assert.deepEqual(done, ['a'])
255
+ assert.equal(ctx.aDone, true)
256
+ })
257
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildMessages } from './prompt.js'
4
+
5
+ describe('buildMessages', () => {
6
+ it('builds system and user messages', () => {
7
+ const msgs = buildMessages('Fix login', 'Login is broken', '## Bug\n\nLogin is broken')
8
+ assert.equal(msgs.length, 2)
9
+ assert.equal(msgs[0].role, 'system')
10
+ assert.equal(msgs[1].role, 'user')
11
+ assert.ok(msgs[1].content.includes('Fix login'))
12
+ assert.ok(msgs[1].content.includes('Login is broken'))
13
+ })
14
+
15
+ it('includes instructions in system message', () => {
16
+ const msgs = buildMessages('Fix it', 'desc', 'body', 'keep it short')
17
+ assert.ok(msgs[0].content.includes('keep it short'))
18
+ })
19
+
20
+ it('omits instructions when not provided', () => {
21
+ const msgs = buildMessages('Fix it', 'desc', 'body')
22
+ assert.ok(!msgs[0].content.includes('Additional instructions'))
23
+ })
24
+
25
+ it('handles empty description', () => {
26
+ const msgs = buildMessages('Title', '', '## Template')
27
+ assert.equal(msgs.length, 2)
28
+ assert.ok(msgs[1].content.includes('Title'))
29
+ })
30
+ })
@@ -4,6 +4,10 @@ import { GeminiAdapter } from './adapters/gemini.js'
4
4
  import { OllamaAdapter } from './adapters/ollama.js'
5
5
  import { OpenAICompatAdapter } from './adapters/openai-compat.js'
6
6
  import { CommandAdapter } from './adapters/command.js'
7
+ import { GeminiCliAdapter } from './adapters/gemini-cli.js'
8
+ import { ClaudeCliAdapter } from './adapters/claude-cli.js'
9
+ import { CodexCliAdapter } from './adapters/codex-cli.js'
10
+ import { OpenClawAdapter } from './adapters/openclaw.js'
7
11
 
8
12
  const ADAPTER_MAP = {
9
13
  anthropic: AnthropicAdapter,
@@ -12,6 +16,10 @@ const ADAPTER_MAP = {
12
16
  ollama: OllamaAdapter,
13
17
  'openai-compat': OpenAICompatAdapter,
14
18
  command: CommandAdapter,
19
+ 'gemini-cli': GeminiCliAdapter,
20
+ 'claude-cli': ClaudeCliAdapter,
21
+ 'codex-cli': CodexCliAdapter,
22
+ openclaw: OpenClawAdapter,
15
23
  }
16
24
 
17
25
  /**
@@ -164,6 +172,21 @@ function buildAdapterConfig(ai, provider) {
164
172
  return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
165
173
  case 'command':
166
174
  return { command: ai.command || null }
175
+ case 'gemini-cli':
176
+ return { binary: 'gemini', model: ai.models?.['gemini-cli'] || null }
177
+ case 'claude-cli':
178
+ return { binary: 'claude', model: ai.models?.['claude-cli'] || null }
179
+ case 'codex-cli':
180
+ return { binary: 'codex', model: ai.models?.['codex-cli'] || null }
181
+ case 'openclaw': {
182
+ const port = process.env.OPENCLAW_GATEWAY_PORT || '18790'
183
+ return {
184
+ gatewayUrl: ai.openclaw?.gatewayUrl || `http://localhost:${port}`,
185
+ token: ai.openclaw?.token || process.env.OPENCLAW_GATEWAY_TOKEN || null,
186
+ agentId: ai.openclaw?.agentId || null,
187
+ model: ai.models?.openclaw || null,
188
+ }
189
+ }
167
190
  default:
168
191
  return { apiKey: resolveApiKey(ai, provider) }
169
192
  }