@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.
- package/agents/cos.json +20 -0
- package/agents/hermes/AGENTS.md +62 -0
- package/agents/hermes/SOUL.md +29 -0
- package/agents/hermes/fix-kimaki/SKILL.md +100 -0
- package/agents/hermes/hermes-fixer.md +115 -0
- package/agents/hermes.json +26 -0
- package/bin/huckleberry.js +4 -0
- package/kanban/SKILL.md +167 -0
- package/kanban/kanban +3 -0
- package/kanban/package.json +9 -0
- package/kanban/src/cli.mjs +553 -0
- package/kanban/src/dispatcher.mjs +479 -0
- package/kanban/src/server.mjs +108 -0
- package/kanban/templates/b2b-outreach.yaml +84 -0
- package/kanban/templates/blog-social-campaign.yaml +1161 -0
- package/kanban/templates/client-onboard.yaml +107 -0
- package/kanban/templates/cro-pipeline.yaml +296 -0
- package/kanban/templates/social-campaign.yaml +356 -0
- package/mcp/gemini-codegen-server.mjs +134 -0
- package/package.json +46 -0
- package/plugins/env-inject.ts +61 -0
- package/plugins/inkstone-gate.ts +55 -0
- package/scripts/heartbeat.sh +41 -0
- package/scripts/install-services.ps1 +82 -0
- package/scripts/install-services.sh +74 -0
- package/scripts/keep-awake.ps1 +48 -0
- package/scripts/keep-awake.sh +31 -0
- package/scripts/keepalive.sh +43 -0
- package/skills/curated.txt +68 -0
- package/src/cli.js +227 -0
- package/src/interview.js +78 -0
- package/src/postinstall.js +22 -0
- package/taskmaster/SKILL.md +78 -0
- package/taskmaster/dispatcher.mjs +146 -0
- package/taskmaster/next-task.js +123 -0
- package/taskmaster/update-status.js +118 -0
- package/templates/AGENTS.md +90 -0
- package/templates/COS_PROTOCOLS.md +71 -0
- package/templates/EXECUTIVE-MODE.md +29 -0
- package/templates/HEARTBEAT.md +42 -0
- package/templates/IDENTITY.md +22 -0
- package/templates/MEMORY.md +17 -0
- package/templates/ORG.md +33 -0
- package/templates/SOUL.md +43 -0
- package/templates/TOOLS.md +66 -0
- package/templates/business-context-gate.md +9 -0
- package/templates/evergreen.md +29 -0
- package/templates/opencode.json +80 -0
- package/wiki/discord.md +52 -0
- package/wiki/inkstone.md +64 -0
- package/wiki/kimaki.md +48 -0
- 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.
|