@jairodri/huckleberry-cli 1.0.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.
Files changed (52) hide show
  1. package/agents/cos.json +20 -0
  2. package/agents/hermes/AGENTS.md +62 -0
  3. package/agents/hermes/SOUL.md +29 -0
  4. package/agents/hermes/fix-kimaki/SKILL.md +100 -0
  5. package/agents/hermes/hermes-fixer.md +115 -0
  6. package/agents/hermes.json +26 -0
  7. package/bin/huckleberry.js +4 -0
  8. package/kanban/SKILL.md +167 -0
  9. package/kanban/kanban +3 -0
  10. package/kanban/package.json +9 -0
  11. package/kanban/src/cli.mjs +553 -0
  12. package/kanban/src/dispatcher.mjs +479 -0
  13. package/kanban/src/server.mjs +108 -0
  14. package/kanban/templates/b2b-outreach.yaml +84 -0
  15. package/kanban/templates/blog-social-campaign.yaml +1161 -0
  16. package/kanban/templates/client-onboard.yaml +107 -0
  17. package/kanban/templates/cro-pipeline.yaml +296 -0
  18. package/kanban/templates/social-campaign.yaml +356 -0
  19. package/mcp/gemini-codegen-server.mjs +134 -0
  20. package/package.json +46 -0
  21. package/plugins/env-inject.ts +61 -0
  22. package/plugins/inkstone-gate.ts +55 -0
  23. package/scripts/heartbeat.sh +41 -0
  24. package/scripts/install-services.ps1 +82 -0
  25. package/scripts/install-services.sh +74 -0
  26. package/scripts/keep-awake.ps1 +48 -0
  27. package/scripts/keep-awake.sh +31 -0
  28. package/scripts/keepalive.sh +43 -0
  29. package/skills/curated.txt +68 -0
  30. package/src/cli.js +227 -0
  31. package/src/interview.js +78 -0
  32. package/src/postinstall.js +22 -0
  33. package/taskmaster/SKILL.md +78 -0
  34. package/taskmaster/dispatcher.mjs +146 -0
  35. package/taskmaster/next-task.js +123 -0
  36. package/taskmaster/update-status.js +118 -0
  37. package/templates/AGENTS.md +90 -0
  38. package/templates/COS_PROTOCOLS.md +71 -0
  39. package/templates/EXECUTIVE-MODE.md +29 -0
  40. package/templates/HEARTBEAT.md +42 -0
  41. package/templates/IDENTITY.md +22 -0
  42. package/templates/MEMORY.md +17 -0
  43. package/templates/ORG.md +33 -0
  44. package/templates/SOUL.md +43 -0
  45. package/templates/TOOLS.md +66 -0
  46. package/templates/business-context-gate.md +9 -0
  47. package/templates/evergreen.md +29 -0
  48. package/templates/opencode.json +80 -0
  49. package/wiki/discord.md +52 -0
  50. package/wiki/inkstone.md +64 -0
  51. package/wiki/kimaki.md +48 -0
  52. package/wiki/opencode.md +43 -0
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { resolve, dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { Database } from 'bun:sqlite'
7
+ import {
8
+ connectDb,
9
+ countRunningTasks,
10
+ failRun,
11
+ getDefaultDbPath,
12
+ getDispatchable,
13
+ getTask,
14
+ initDb,
15
+ startRun,
16
+ } from './kanban.mjs'
17
+
18
+ const CONFIG_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'config', 'dispatcher-config.json')
19
+ const DAILY_LIMITS_PATH = `${process.env.HOME || process.env.USERPROFILE}/.kimaki/daily-limits.json`
20
+ const NOTIFY_USER_ID = process.env.KIMAKI_NOTIFY_USER_ID || '826516320904151040'
21
+
22
+ function loadDispatcherConfig() {
23
+ try {
24
+ const raw = readFileSync(CONFIG_PATH, 'utf8')
25
+ return JSON.parse(raw)
26
+ } catch {
27
+ return {}
28
+ }
29
+ }
30
+
31
+ function loadDailyLimits() {
32
+ try {
33
+ const raw = readFileSync(DAILY_LIMITS_PATH, 'utf8')
34
+ return JSON.parse(raw)
35
+ } catch {
36
+ return { date: new Date().toISOString().split('T')[0], perTenant: {}, perAssignee: {}, global: 0 }
37
+ }
38
+ }
39
+
40
+ function saveDailyLimits(limits) {
41
+ writeFileSync(DAILY_LIMITS_PATH, JSON.stringify(limits, null, 2))
42
+ }
43
+
44
+ async function sendDiscordNotification(channelId, content) {
45
+ const token = process.env.KIMAKI_BOT_TOKEN
46
+ if (!token || !channelId) return
47
+ try {
48
+ const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
49
+ method: 'POST',
50
+ headers: {
51
+ 'Authorization': `Bot ${token}`,
52
+ 'Content-Type': 'application/json',
53
+ },
54
+ body: JSON.stringify({ content }),
55
+ })
56
+ if (!res.ok) console.log(`[NOTIFY] Discord API returned ${res.status}`)
57
+ } catch (err) {
58
+ console.log(`[NOTIFY] Failed: ${err instanceof Error ? err.message : err}`)
59
+ }
60
+ }
61
+
62
+ function checkTaskNotification(task) {
63
+ const title = (task.title || '').toLowerCase()
64
+ const isHooks = title.includes('generate hooks') || title.startsWith('hooks:')
65
+ const isPublish = title.includes('publish to wordpress') || title.startsWith('publish:')
66
+ const mention = `<@${NOTIFY_USER_ID}>`
67
+ const topic = task.title.includes(':') ? task.title.split(':').slice(1).join(':').trim() : task.title
68
+
69
+ if (isHooks) {
70
+ return `${mention} **Hooks ready** for "${topic}". Review and pick your favorite before polish continues.\n` +
71
+ `Thread: <#${task.thread_id || notifyChannel}>`
72
+ }
73
+ if (isPublish) {
74
+ const url = task.result?.match(/https?:\/\/[^\s]+/)
75
+ return `${mention} **Article published**: "${topic}"${url ? `\nURL: ${url[0]}` : ''}`
76
+ }
77
+ // Notify on ALL task completions so Jairo can review
78
+ const brand = task.tenant || 'unknown'
79
+ const threadLink = task.thread_id ? `\nThread: <#${task.thread_id}>` : ''
80
+ return `${mention} **Task done** [${brand}]: "${topic}"${threadLink}`
81
+ }
82
+
83
+ function resetDailyLimitsIfNewDay(limits) {
84
+ const today = new Date().toISOString().split('T')[0]
85
+ if (limits.date !== today) {
86
+ return { date: today, perTenant: {}, perAssignee: {}, global: 0 }
87
+ }
88
+ return limits
89
+ }
90
+
91
+ function checkDailyLimits(task, config, limits) {
92
+ const tenantLimit = config.dailyLimits?.perTenant?.[task.tenant] ?? config.dailyLimits?.perTenant?.default ?? 2
93
+ const assigneeLimit = config.dailyLimits?.perAssignee?.[task.assignee] ?? config.dailyLimits?.perAssignee?.default ?? 3
94
+ const globalLimit = config.dailyLimits?.global ?? 10
95
+
96
+ const tenantUsed = limits.perTenant[task.tenant] || 0
97
+ const assigneeUsed = limits.perAssignee[task.assignee] || 0
98
+ const globalUsed = limits.global || 0
99
+
100
+ if (tenantUsed >= tenantLimit) return { allowed: false, reason: `Tenant ${task.tenant} daily limit reached (${tenantUsed}/${tenantLimit})` }
101
+ if (assigneeUsed >= assigneeLimit) return { allowed: false, reason: `Assignee ${task.assignee} daily limit reached (${assigneeUsed}/${assigneeLimit})` }
102
+ if (globalUsed >= globalLimit) return { allowed: false, reason: `Global daily limit reached (${globalUsed}/${globalLimit})` }
103
+ return { allowed: true }
104
+ }
105
+
106
+ function incrementDailyLimits(task, limits) {
107
+ limits.perTenant[task.tenant] = (limits.perTenant[task.tenant] || 0) + 1
108
+ limits.perAssignee[task.assignee] = (limits.perAssignee[task.assignee] || 0) + 1
109
+ limits.global = (limits.global || 0) + 1
110
+ saveDailyLimits(limits)
111
+ }
112
+
113
+ function getDefaultSessionDbPath() {
114
+ return `${process.env.HOME || process.env.USERPROFILE}/.kimaki/discord-sessions.db`
115
+ }
116
+
117
+ function lookupSessionId(threadId, sessionDbPath) {
118
+ if (!threadId || !existsSync(sessionDbPath)) return null
119
+ try {
120
+ const sdb = new Database(sessionDbPath)
121
+ sdb.exec('PRAGMA busy_timeout = 3000')
122
+ const row = sdb.query('SELECT session_id FROM thread_sessions WHERE thread_id = ?').get(threadId)
123
+ sdb.close()
124
+ return row ? row.session_id : null
125
+ } catch { return null }
126
+ }
127
+
128
+ function loadAgentMapping() {
129
+ const mappingPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'config', 'agent-mapping.json')
130
+ try {
131
+ const raw = readFileSync(mappingPath, 'utf8')
132
+ return JSON.parse(raw)
133
+ } catch {
134
+ return {}
135
+ }
136
+ }
137
+
138
+ const MODEL_OVERRIDES = {
139
+ 'harper': 'zai/glm-5.1',
140
+ 'writer': 'zai/glm-5.1',
141
+ }
142
+
143
+ function getModelForAssignee(assignee) {
144
+ return MODEL_OVERRIDES[assignee] || null
145
+ }
146
+
147
+ function getNextProvider(assignee) {
148
+ const providers = {
149
+ 'researcher': ['gemini', 'sonnet', 'opus'],
150
+ 'writer': ['sonnet', 'opus', 'gemini'],
151
+ 'build': ['sonnet', 'gemini', 'opus'],
152
+ 'cro-auditor': ['sonnet', 'opus'],
153
+ 'default': ['sonnet', 'gemini', 'opus']
154
+ }
155
+ const list = providers[assignee] || providers.default
156
+ return list[Math.floor(Math.random() * list.length)]
157
+ }
158
+
159
+ const TENANT_CHANNELS = {
160
+ 'ai-employee-setup': '1517784870225449102',
161
+ 'natureshares': '1517784525327831120',
162
+ 'enchantents': '1517784706278621244',
163
+ 'nomadixgear': '1517784331458711602',
164
+ 'nomadixgear-sa': '1517784331458711602',
165
+ 'aiwithjai': '1485339133608988753',
166
+ 'madamgroovy': '1517785073187819681',
167
+ 'harper': '1503480258442301571',
168
+ 'teyoapa': '1481846036786843853',
169
+ }
170
+
171
+ function getChannelForTenant(tenant) {
172
+ return TENANT_CHANNELS[tenant] || null
173
+ }
174
+
175
+ function resolveAgent(assignee, mapping, fallback = 'build') {
176
+ if (!assignee || assignee === 'unassigned') return fallback
177
+ for (const [agent, roles] of Object.entries(mapping)) {
178
+ if (roles.includes(assignee)) return agent
179
+ }
180
+ return fallback
181
+ }
182
+
183
+ const TENANT_WORKDIRS = {
184
+ 'ai-employee-setup': '/Users/clawdbot/clawd/businesses/aiwithjai/ai-employee-setup',
185
+ 'natureshares': '/Users/clawdbot/clawd/businesses/natureshares',
186
+ 'enchantents': '/Users/clawdbot/clawd/businesses/nomadixgear-sa/enchantents',
187
+ 'nomadixgear': '/Users/clawdbot/clawd/businesses/nomadixgear-sa',
188
+ 'nomadixgear-sa': '/Users/clawdbot/clawd/businesses/nomadixgear-sa',
189
+ 'aiwithjai': '/Users/clawdbot/clawd/businesses/aiwithjai',
190
+ 'madamgroovy': '/Users/clawdbot/clawd/businesses/madamgroovy',
191
+ 'harper': '/Users/clawdbot/clawd/businesses/harper',
192
+ 'teyoapa': '/Users/clawdbot/clawd/businesses/teyoapa',
193
+ }
194
+
195
+ function getWorkdirForTenant(tenant, defaultWorkdir) {
196
+ return TENANT_WORKDIRS[tenant] || defaultWorkdir
197
+ }
198
+
199
+ const args = process.argv.slice(2)
200
+
201
+ function parseFlags(argv) {
202
+ const flags = { interval: '30', once: false }
203
+ for (let i = 0; i < argv.length; i++) {
204
+ const arg = argv[i]
205
+ if (arg === '--once') flags.once = true
206
+ else if (arg === '--daily-limits') flags.dailyLimits = true
207
+ else if (arg === '--reset-daily-limits') flags.resetDailyLimits = true
208
+ else if (arg.startsWith('--')) {
209
+ flags[arg.slice(2)] = argv[i + 1]
210
+ i++
211
+ }
212
+ }
213
+ return flags
214
+ }
215
+
216
+ const flags = parseFlags(args)
217
+ const dbPath = flags.db || getDefaultDbPath()
218
+ const sessionDbPath = flags['session-db'] || getDefaultSessionDbPath()
219
+ const intervalMs = Number(flags.interval || 30) * 1000
220
+ const kimakiBin = flags.kimaki || process.env.KIMAKI_BIN || 'kimaki'
221
+ const notifyChannel = flags.channel || process.env.KIMAKI_KANBAN_CHANNEL
222
+ const notifyThread = flags.thread || process.env.KIMAKI_KANBAN_THREAD
223
+ const notifyUser = flags.user || process.env.KIMAKI_KANBAN_USER
224
+ const workerAgent = flags.agent || process.env.KIMAKI_KANBAN_AGENT || 'build'
225
+ const maxConcurrency = Number(flags['max-concurrency'] || flags.maxConcurrency || 1)
226
+ const tenantFilter = flags.tenant || process.env.KIMAKI_KANBAN_TENANT || null
227
+
228
+ const config = loadDispatcherConfig()
229
+ let dailyLimits = loadDailyLimits()
230
+ dailyLimits = resetDailyLimitsIfNewDay(dailyLimits)
231
+
232
+ if (flags.dailyLimits) {
233
+ console.log('=== DAILY LIMITS STATUS ===')
234
+ console.log(`Date: ${dailyLimits.date}`)
235
+ console.log(`Global: ${dailyLimits.global || 0}/${config.dailyLimits?.global || 10}`)
236
+ console.log('')
237
+ console.log('Per Tenant:')
238
+ for (const [tenant, limit] of Object.entries(config.dailyLimits?.perTenant || {})) {
239
+ const used = dailyLimits.perTenant[tenant] || 0
240
+ console.log(` ${tenant}: ${used}/${limit}`)
241
+ }
242
+ console.log('')
243
+ console.log('Per Assignee:')
244
+ for (const [assignee, limit] of Object.entries(config.dailyLimits?.perAssignee || {})) {
245
+ const used = dailyLimits.perAssignee[assignee] || 0
246
+ console.log(` ${assignee}: ${used}/${limit}`)
247
+ }
248
+ process.exit(0)
249
+ }
250
+
251
+ if (flags.resetDailyLimits) {
252
+ dailyLimits = { date: new Date().toISOString().split('T')[0], perTenant: {}, perAssignee: {}, global: 0 }
253
+ saveDailyLimits(dailyLimits)
254
+ console.log('Daily limits reset for today')
255
+ process.exit(0)
256
+ }
257
+
258
+ initDb(dbPath)
259
+ const agentMapping = loadAgentMapping()
260
+
261
+ function buildPrompt(task) {
262
+ const full = getTask(connectDb(dbPath), task.id)
263
+ const parentResults = (full.parentResults || [])
264
+ .map(p => {
265
+ let line = `- ${p.id} (${p.status}) ${p.title}: ${p.result || ''}`
266
+ // Extract deliverable file paths from parent metadata
267
+ const parentMeta = p.metadata || {}
268
+ const parentDels = parentMeta.deliverables
269
+ if (parentDels && Array.isArray(parentDels) && parentDels.length > 0) {
270
+ const paths = parentDels.map(d => typeof d === 'string' ? d : d.path).filter(Boolean)
271
+ if (paths.length) line += `\n Deliverable files: ${paths.join(', ')}`
272
+ }
273
+ return line
274
+ })
275
+ .join('\n')
276
+
277
+ const comments = (full.comments || []).slice(-3).map(c => `> ${c.author}: ${c.body}`).join('\n')
278
+
279
+ const skills = (full.skills || []).filter(Boolean)
280
+ const skillInstructions = skills.length
281
+ ? `Load these skills via the \`skill()\` tool and follow their instructions:\n${skills.map(s => ` - \`skill("${s}")\` — load and follow`).join('\n')}\n`
282
+ : ''
283
+
284
+ const workspaceRoot = process.env.KIMAKI_KANBAN_WORKSPACE || '/Users/clawdbot/clawd'
285
+
286
+ return `[Kanban] ${task.title}
287
+
288
+ ${task.body || ''}
289
+
290
+ ${skillInstructions}
291
+
292
+ ${parentResults ? `Parent results:\n${parentResults}` : ''}
293
+ ${comments ? `\nHistory:\n${comments}` : ''}
294
+
295
+ ## File paths
296
+ Workspace root: ${workspaceRoot}
297
+ All deliverable paths are relative to the workspace root. Read input files from there and write outputs there.
298
+ Example: \`businesses/aiwithjai/content/_drafts/article.md\` means \`${workspaceRoot}/businesses/aiwithjai/content/_drafts/article.md\`
299
+
300
+ ## Scope boundary — READ THIS
301
+ You are assigned ONE task. Complete ONLY this task. Do NOT start other tasks, do NOT optimize other pages, do NOT fix other issues. Stop after finishing this task.
302
+
303
+ ## Context budget — CRITICAL
304
+ - Write outputs to disk IMMEDIATELY. Do not hold content in your conversation memory.
305
+ - Do NOT load image/media files into context. Save generated images to disk and reference by path.
306
+ - If your context is getting large, PRIORITIZE writing your deliverable file over doing additional research.
307
+ - Use ctx_execute_file for file analysis instead of reading raw file contents.
308
+ - If you receive a "context was compacted" message, you have lost your task. Call \`kanban block ${task.id} --reason "context compacted"\` immediately.
309
+
310
+ When done: bun ${resolve('src/cli.mjs')} complete ${task.id} --summary "..." --deliverable "path/to/output.md"
311
+ (use --deliverable for each file you produced — this lets the audit verify completion)
312
+ The system will VERIFY that deliverable files exist and meet minimum size requirements before accepting completion.
313
+ Do NOT call complete without first writing your output file to disk.
314
+ If blocked: bun ${resolve('src/cli.mjs')} block ${task.id} --reason "..."`
315
+ }
316
+
317
+ function runKimakiTask(task) {
318
+ if (kimakiBin.includes('/') && !existsSync(kimakiBin)) {
319
+ throw new Error(`kimaki binary not found: ${kimakiBin}`)
320
+ }
321
+ if (!notifyThread && !notifyChannel) {
322
+ throw new Error('dispatcher needs --thread or --channel to spawn a Kimaki worker session')
323
+ }
324
+
325
+ const db = connectDb(dbPath)
326
+ const runId = startRun(db, task.id, { assignee: task.assignee })
327
+ const threadId = notifyThread || notifyChannel
328
+ db.query('UPDATE tasks SET thread_id = ? WHERE id = ?').run(String(threadId), task.id)
329
+ db.close()
330
+
331
+ const fullTask = getTask(connectDb(dbPath), task.id)
332
+ const provider = fullTask.metadata?.provider || null
333
+ const providerArg = provider ? ['--provider', provider] : []
334
+
335
+ const tenantChannel = getChannelForTenant(task.tenant)
336
+ const channelArg = tenantChannel ? ['--channel', tenantChannel] : ['--channel', notifyChannel]
337
+
338
+ const prompt = buildPrompt(task)
339
+ const userArgs = notifyUser ? ['--user', notifyUser] : []
340
+ const resolvedAgent = resolveAgent(task.assignee, agentMapping, workerAgent)
341
+ const agentArg = ['--agent', resolvedAgent]
342
+ const modelOverride = getModelForAssignee(task.assignee)
343
+ const modelArg = modelOverride ? ['--model', modelOverride] : []
344
+
345
+ const child = spawn(kimakiBin, ['send', '--prompt', prompt, ...agentArg, ...modelArg, ...userArgs, ...channelArg, ...providerArg], {
346
+ cwd: undefined,
347
+ env: {
348
+ ...process.env,
349
+ KIMAKI_KANBAN_TASK: task.id,
350
+ KIMAKI_KANBAN_DB: dbPath,
351
+ KIMAKI_KANBAN_RUN: String(runId),
352
+ KIMAKI_KANBAN_NOTIFY_CHANNEL: tenantChannel || notifyChannel || '',
353
+ },
354
+ stdio: ['ignore', 'pipe', 'pipe'],
355
+ })
356
+
357
+ let stderr = ''
358
+ let stdout = ''
359
+ child.stderr.on('data', chunk => { stderr += chunk.toString() })
360
+ child.stdout.on('data', chunk => {
361
+ stdout += chunk.toString()
362
+ process.stdout.write(`[${task.id}] ${chunk}`)
363
+ })
364
+
365
+ let idPolled = false
366
+ const pollInterval = setInterval(() => {
367
+ if (idPolled) { clearInterval(pollInterval); return }
368
+ const threadMatch = stdout.match(/\/channels\/\d+\/(\d+)/)
369
+ if (!threadMatch || threadMatch[1] === notifyChannel) return
370
+ const threadId = threadMatch[1]
371
+ const sessionId = lookupSessionId(threadId, sessionDbPath)
372
+ if (!sessionId) return
373
+ const db2 = connectDb(dbPath)
374
+ db2.query('UPDATE tasks SET thread_id = ?, session_id = ? WHERE id = ?').run(threadId, sessionId, task.id)
375
+ db2.query("UPDATE task_runs SET session_id = ? WHERE task_id = ? AND status = 'running'").run(sessionId, task.id)
376
+ db2.close()
377
+ idPolled = true
378
+ clearInterval(pollInterval)
379
+ console.log(`[${task.id}] resolved thread=${threadId} session=${sessionId}`)
380
+ }, 2000)
381
+
382
+ child.on('exit', code => {
383
+ const db2 = connectDb(dbPath)
384
+ const latest = getTask(db2, task.id)
385
+ if (code !== 0 && latest && latest.status === 'running') {
386
+ const errorMsg = `worker exited with code ${code}; ${stderr.slice(-1000)}`
387
+
388
+ const isRateLimited = /rate.?limit|too many requests|429|quota exceeded/i.test(stderr)
389
+
390
+ if (isRateLimited && latest.consecutive_failures < (latest.max_retries || 3)) {
391
+ const newProvider = getNextProvider(latest.assignee)
392
+ console.log(`[${task.id}] Rate limited, switching to ${newProvider} and retrying...`)
393
+
394
+ db2.query('UPDATE tasks SET metadata = json_set(COALESCE(metadata, "{}"), "$.provider", ?) WHERE id = ?').run(newProvider, task.id)
395
+ db2.query('UPDATE tasks SET cooldown_until = 0, consecutive_failures = consecutive_failures + 1 WHERE id = ?').run(task.id)
396
+ } else {
397
+ failRun(db2, task.id, errorMsg)
398
+ }
399
+ } else {
400
+ // Task succeeded - clear provider override
401
+ db2.query('UPDATE tasks SET metadata = json_remove(COALESCE(metadata, "{}"), "$.provider") WHERE id = ?').run(task.id)
402
+
403
+ const threadMatch = stdout.match(/\/channels\/\d+\/(\d+)/)
404
+ if (threadMatch && threadMatch[1] !== notifyChannel) {
405
+ const threadId = threadMatch[1]
406
+ db2.query('UPDATE tasks SET thread_id = ? WHERE id = ?').run(threadId, task.id)
407
+ const sessionId = lookupSessionId(threadId, sessionDbPath)
408
+ if (sessionId) {
409
+ db2.query('UPDATE tasks SET session_id = ? WHERE id = ?').run(sessionId, task.id)
410
+ db2.query("UPDATE task_runs SET session_id = ? WHERE task_id = ? AND status = 'running'").run(sessionId, task.id)
411
+ }
412
+ }
413
+ }
414
+ // Send user notification for hooks/publish task completion
415
+ const completed = getTask(db2, task.id)
416
+ if (completed && completed.status === 'done') {
417
+ const notif = checkTaskNotification(completed)
418
+ if (notif) {
419
+ sendDiscordNotification(notifyChannel, notif)
420
+ console.log(`[${task.id}] sent user notification: hooks/publish completed`)
421
+ }
422
+ }
423
+ db2.close()
424
+ })
425
+
426
+ console.log(`spawned ${task.id} (${task.assignee}) via kimaki send pid=${child.pid} [channel: ${tenantChannel || notifyChannel}]`)
427
+ }
428
+
429
+ const recentlySpawned = new Map()
430
+
431
+ function dispatchOnce() {
432
+ const db = connectDb(dbPath)
433
+ const running = countRunningTasks(db)
434
+ const available = Math.max(0, maxConcurrency - running)
435
+ if (available <= 0) { db.close(); return 0 }
436
+ const opts = {}
437
+ if (tenantFilter) opts.tenant = tenantFilter
438
+
439
+ dailyLimits = resetDailyLimitsIfNewDay(dailyLimits)
440
+
441
+ const tasks = getDispatchable(db, opts).filter(t => {
442
+ const lastSpawn = recentlySpawned.get(t.id)
443
+ if (lastSpawn && Date.now() - lastSpawn < intervalMs * 2) return false
444
+
445
+ const limitCheck = checkDailyLimits(t, config, dailyLimits)
446
+ if (!limitCheck.allowed) {
447
+ console.log(`[${t.id}] SKIPPED: ${limitCheck.reason}`)
448
+ return false
449
+ }
450
+ return true
451
+ }).slice(0, available)
452
+ db.close()
453
+ for (const task of tasks) {
454
+ try {
455
+ recentlySpawned.set(task.id, Date.now())
456
+ incrementDailyLimits(task, dailyLimits)
457
+ runKimakiTask(task)
458
+ } catch (err) {
459
+ recentlySpawned.delete(task.id)
460
+ const db2 = connectDb(dbPath)
461
+ failRun(db2, task.id, err instanceof Error ? err.message : String(err))
462
+ db2.close()
463
+ }
464
+ }
465
+ for (const [id, ts] of recentlySpawned) {
466
+ if (Date.now() - ts > intervalMs * 3) recentlySpawned.delete(id)
467
+ }
468
+ return tasks.length
469
+ }
470
+
471
+ const count = dispatchOnce()
472
+ console.log(`dispatch tick: ${count} task(s)`)
473
+
474
+ if (!flags.once) {
475
+ setInterval(() => {
476
+ const n = dispatchOnce()
477
+ if (n) console.log(`dispatch tick: ${n} task(s)`)
478
+ }, intervalMs)
479
+ }
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import Fastify from 'fastify'
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import {
7
+ addComment,
8
+ assignTask,
9
+ blockTask,
10
+ completeTask,
11
+ connectDb,
12
+ createTask,
13
+ getDefaultDbPath,
14
+ getTask,
15
+ initDb,
16
+ listTasks,
17
+ promoteTask,
18
+ setTaskStatus,
19
+ unblockTask,
20
+ } from './kanban.mjs'
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ const dbPath = process.env.KIMAKI_KANBAN_DB || getDefaultDbPath()
24
+ initDb(dbPath)
25
+
26
+ const app = Fastify({ logger: false })
27
+
28
+ function db() { return connectDb(dbPath) }
29
+
30
+ app.get('/api/tasks', async (req) => {
31
+ const d = db()
32
+ const tasks = listTasks(d, req.query || {})
33
+ d.close()
34
+ return tasks
35
+ })
36
+
37
+ app.get('/api/tasks/:id', async (req, reply) => {
38
+ const d = db()
39
+ const task = getTask(d, req.params.id)
40
+ d.close()
41
+ if (!task) return reply.code(404).send({ error: 'not found' })
42
+ return task
43
+ })
44
+
45
+ app.post('/api/tasks', async (req) => {
46
+ const d = db()
47
+ const task = createTask(d, req.body || {})
48
+ d.close()
49
+ return task
50
+ })
51
+
52
+ app.post('/api/tasks/:id/complete', async (req) => {
53
+ const d = db()
54
+ const task = completeTask(d, req.params.id, req.body || {})
55
+ d.close()
56
+ return task
57
+ })
58
+
59
+ app.post('/api/tasks/:id/block', async (req) => {
60
+ const d = db()
61
+ const task = blockTask(d, req.params.id, req.body || {})
62
+ d.close()
63
+ return task
64
+ })
65
+
66
+ app.post('/api/tasks/:id/unblock', async (req) => {
67
+ const d = db()
68
+ const task = unblockTask(d, req.params.id)
69
+ d.close()
70
+ return task
71
+ })
72
+
73
+ app.patch('/api/tasks/:id', async (req, reply) => {
74
+ const d = db()
75
+ let task = null
76
+ if (req.body?.status) task = setTaskStatus(d, req.params.id, req.body.status)
77
+ if (req.body?.assignee !== undefined || req.body?.skills !== undefined) {
78
+ task = assignTask(d, req.params.id, req.body.assignee, { skills: req.body.skills })
79
+ }
80
+ d.close()
81
+ if (!task) return reply.code(404).send({ error: 'not found' })
82
+ return task
83
+ })
84
+
85
+ app.post('/api/tasks/:id/promote', async (req, reply) => {
86
+ const d = db()
87
+ const task = promoteTask(d, req.params.id, req.body || {})
88
+ d.close()
89
+ if (!task) return reply.code(404).send({ error: 'not found' })
90
+ return task
91
+ })
92
+
93
+ app.post('/api/tasks/:id/comments', async (req) => {
94
+ const d = db()
95
+ const comment = addComment(d, req.params.id, req.body?.author || 'user', req.body?.body || '')
96
+ d.close()
97
+ return comment
98
+ })
99
+
100
+ app.get('/', async (_req, reply) => {
101
+ const html = readFileSync(join(__dirname, '..', 'dashboard', 'index.html'), 'utf8')
102
+ reply.type('text/html').send(html)
103
+ })
104
+
105
+ const port = Number(process.env.PORT || 4717)
106
+ app.listen({ port, host: '127.0.0.1' }).then(() => {
107
+ console.log(`Kimaki Kanban dashboard: http://127.0.0.1:${port}`)
108
+ })
@@ -0,0 +1,84 @@
1
+ name: B2B Cold Outreach
2
+ description: Full B2B cold outreach pipeline from lead scraping to sent emails. Brand-agnostic — works for any vertical.
3
+
4
+ variables:
5
+ - name: vertical
6
+ required: true
7
+ description: Target industry/vertical (e.g. "resorts", "saas", "agencies")
8
+ - name: tenant
9
+ required: true
10
+ description: Brand/project tenant name
11
+ - name: region
12
+ default: US
13
+ description: Geographic region for leads
14
+ - name: pain_point
15
+ required: false
16
+ description: Specific pain point to target in outreach
17
+ - name: funnel_id
18
+ required: false
19
+ description: Funnel ID (e.g. A for Enchantents cold email). If set, outreach aligns to this funnel's conversion path.
20
+
21
+ tasks:
22
+ - id: scrape
23
+ title: "Scrape {{vertical}} leads in {{region}}"
24
+ assignee: scraper
25
+ skills: [b2b-cold-outreach]
26
+ priority: 3
27
+ body: |
28
+ Scrape qualified {{vertical}} leads in {{region}}.
29
+ Use Google Maps, industry directories, and LinkedIn.
30
+ Output a structured CSV with: company, contact, email, size, notes.
31
+ Target 50-100 leads.
32
+
33
+ - id: research
34
+ title: "Deep-research {{vertical}} leads"
35
+ assignee: researcher
36
+ skills: [competitive-intel]
37
+ parents: [scrape]
38
+ priority: 2
39
+ body: |
40
+ For each lead from the scrape, research: recent news, tech stack,
41
+ pain points, decision maker names.{{#pain_point}}
42
+ Focus specifically on "{{pain_point}}" as the outreach hook.{{/pain_point}}
43
+ Enrich the CSV with research data.
44
+
45
+ - id: emails
46
+ title: "Craft outreach emails for {{vertical}}"
47
+ assignee: writer
48
+ skills: [lead-email-craft, cold-email]
49
+ parents: [research]
50
+ priority: 1
51
+ body: |
52
+ Write hyper-personalized cold emails for each enriched lead.
53
+ Use brand voice from brand/voice-profile.md.{{#funnel_id}}
54
+ FUNNEL: Outreach serves funnel {{funnel_id}}. Read funnels/{{funnel_id}}-*.md for CTA and conversion path.
55
+ Each email's CTA must align with the funnel's next step. Specify buyer_stage and icp_segment per email.{{/funnel_id}}
56
+ Each email must reference specific research about the prospect.
57
+ Include 3 email sequence (initial + 2 follow-ups).
58
+ Output ready-to-send drafts in the outreach directory.
59
+
60
+ - id: send
61
+ title: "Send {{vertical}} outreach batch"
62
+ assignee: sender
63
+ skills: [send-email]
64
+ parents: [emails]
65
+ priority: 1
66
+ body: |
67
+ Send the crafted outreach emails using Resend or SMTP.
68
+ Track open rates and replies.
69
+ Log results back to the leads CSV.
70
+ BLOCK this task — human must approve emails before sending.
71
+
72
+ # Tournament Mode (Quality Gate v2.0)
73
+ #
74
+ # For any task that produces high-impact short-form copy (headlines, subject lines,
75
+ # hooks, CTAs, social post first lines), run the copywriting tournament BEFORE
76
+ # standard quality-gate:
77
+ #
78
+ # 1. Generate 3-5 variants
79
+ # 2. Spawn 5 parallel judge subagents (skeptic, scanner, buyer, expert, outsider)
80
+ # Each loads persona from: ~/.claude/skills/quality-gate/judge-personas/{name}.md
81
+ # 3. Aggregate scores, pick winner
82
+ # 4. Winner enters standard quality-gate (single reviewer, pass/fail)
83
+ #
84
+ # See: Skill quality-gate for full tournament prompt templates and integration patterns.