@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,553 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { createInterface } from 'node:readline/promises'
|
|
5
|
+
import {
|
|
6
|
+
addComment,
|
|
7
|
+
archiveTask,
|
|
8
|
+
assignTask,
|
|
9
|
+
blockTask,
|
|
10
|
+
boardStats,
|
|
11
|
+
completeTask,
|
|
12
|
+
connectDb,
|
|
13
|
+
createTask,
|
|
14
|
+
getDefaultDbPath,
|
|
15
|
+
getDispatchable,
|
|
16
|
+
getTask,
|
|
17
|
+
initDb,
|
|
18
|
+
linkTasks,
|
|
19
|
+
listTasks,
|
|
20
|
+
promoteTask,
|
|
21
|
+
setTaskStatus,
|
|
22
|
+
unblockTask,
|
|
23
|
+
} from './kanban.mjs'
|
|
24
|
+
import { loadTemplate, listTemplates, stampTemplate, stampBatch, preflight, applyAnswers, detectBrandFiles, TEMPLATES_DIR } from './stamp.mjs'
|
|
25
|
+
|
|
26
|
+
const NOTIFY_USER_ID = process.env.KIMAKI_NOTIFY_USER_ID || '826516320904151040'
|
|
27
|
+
|
|
28
|
+
async function notifyTaskCompletion(task) {
|
|
29
|
+
if (!task || task.status !== 'done') return
|
|
30
|
+
const title = (task.title || '').toLowerCase()
|
|
31
|
+
const isHooks = title.includes('generate hooks') || title.startsWith('hooks:')
|
|
32
|
+
const isPublish = title.includes('publish to wordpress') || title.startsWith('publish:')
|
|
33
|
+
if (!isHooks && !isPublish) return
|
|
34
|
+
|
|
35
|
+
const token = process.env.KIMAKI_BOT_TOKEN
|
|
36
|
+
const channel = process.env.KIMAKI_KANBAN_NOTIFY_CHANNEL
|
|
37
|
+
if (!token || !channel) return
|
|
38
|
+
|
|
39
|
+
const topic = task.title.includes(':') ? task.title.split(':').slice(1).join(':').trim() : task.title
|
|
40
|
+
const mention = `<@${NOTIFY_USER_ID}>`
|
|
41
|
+
let content
|
|
42
|
+
if (isHooks) {
|
|
43
|
+
content = `${mention} **Hooks ready** for "${topic}". Review and pick your favorite before polish continues.`
|
|
44
|
+
if (task.thread_id) content += `\nThread: <#${task.thread_id}>`
|
|
45
|
+
} else {
|
|
46
|
+
const url = (task.result || '').match(/https?:\/\/[^\s]+/)
|
|
47
|
+
content = `${mention} **Article published**: "${topic}"${url ? `\n${url[0]}` : ''}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`https://discord.com/api/v10/channels/${channel}/messages`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Authorization': `Bot ${token}`, 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ content }),
|
|
55
|
+
})
|
|
56
|
+
if (!res.ok) console.error(`[notify] Discord API ${res.status}`)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.error(`[notify] ${e instanceof Error ? e.message : e}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const args = process.argv.slice(2)
|
|
63
|
+
|
|
64
|
+
function parseFlags(argv) {
|
|
65
|
+
const flags = {}
|
|
66
|
+
const positional = []
|
|
67
|
+
for (let i = 0; i < argv.length; i++) {
|
|
68
|
+
const arg = argv[i]
|
|
69
|
+
if (arg.startsWith('--')) {
|
|
70
|
+
const key = arg.slice(2)
|
|
71
|
+
const next = argv[i + 1]
|
|
72
|
+
if (!next || next.startsWith('--')) {
|
|
73
|
+
flags[key] = true
|
|
74
|
+
} else {
|
|
75
|
+
if (key === 'var') {
|
|
76
|
+
if (!flags.var) flags.var = []
|
|
77
|
+
flags.var.push(next)
|
|
78
|
+
} else if (key === 'skill' || key === 'parent' || key === 'deliverable') {
|
|
79
|
+
if (!flags[key]) flags[key] = []
|
|
80
|
+
flags[key].push(next)
|
|
81
|
+
} else {
|
|
82
|
+
flags[key] = next
|
|
83
|
+
}
|
|
84
|
+
i++
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
positional.push(arg)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { flags, positional }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseStampVars(flags) {
|
|
94
|
+
const vars = {}
|
|
95
|
+
const varList = Array.isArray(flags.var) ? flags.var : flags.var ? [flags.var] : []
|
|
96
|
+
for (const v of varList) {
|
|
97
|
+
const eq = v.indexOf('=')
|
|
98
|
+
if (eq === -1) throw new Error(`Invalid --var format: "${v}". Use --var key=value`)
|
|
99
|
+
vars[v.slice(0, eq)] = v.slice(eq + 1)
|
|
100
|
+
}
|
|
101
|
+
if (flags.tenant) vars.tenant = flags.tenant
|
|
102
|
+
if (flags.project) vars.project = flags.project
|
|
103
|
+
return vars
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readStdin(question) {
|
|
107
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
108
|
+
return rl.question(question).finally(() => rl.close())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function usage() {
|
|
112
|
+
console.log(`kimaki-kanban
|
|
113
|
+
|
|
114
|
+
Usage:
|
|
115
|
+
kanban init [--db path]
|
|
116
|
+
kanban create "title" [--body text] [--assignee name] [--tenant name] [--project name] [--skill a,b] [--parent id,id] [--priority n]
|
|
117
|
+
kanban list [--status ready] [--assignee name] [--tenant name] [--project name]
|
|
118
|
+
kanban show <task_id>
|
|
119
|
+
kanban complete <task_id> --summary text [--result text] [--deliverable path] [--deliverable path2]
|
|
120
|
+
kanban block <task_id> --reason text
|
|
121
|
+
kanban unblock <task_id>
|
|
122
|
+
kanban promote <task_id> [--assignee name] [--skill a,b] [--body text]
|
|
123
|
+
kanban assign <task_id> --assignee name [--skill a,b]
|
|
124
|
+
kanban status <task_id> --status ready
|
|
125
|
+
kanban comment <task_id> --body text [--author name]
|
|
126
|
+
kanban link <parent_id> <child_id>
|
|
127
|
+
kanban archive <task_id>
|
|
128
|
+
kanban stats
|
|
129
|
+
kanban ready
|
|
130
|
+
kanban templates [--details]
|
|
131
|
+
kanban stamp <template-name-or-path> [--var key=value ...] [--tenant name] [--project name] [--force] [--db path]
|
|
132
|
+
kanban stamp-batch <template-name> --file batch.json [--force] [--db path]
|
|
133
|
+
kanban stamp <template-name-or-path> --preflight [--workdir path]
|
|
134
|
+
kanban stamp <template-name-or-path> --interview [--workdir path] [--var key=value ...]
|
|
135
|
+
kanban brand-files [--workdir path]
|
|
136
|
+
kanban stamp-help
|
|
137
|
+
`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function open(flags) {
|
|
141
|
+
return flags.db ? initDb(flags.db) : initDb(getDefaultDbPath())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function printStampHelp() {
|
|
145
|
+
console.log(`
|
|
146
|
+
=== Kimaki Kanban Workflow Templates — Agent Guide ===
|
|
147
|
+
|
|
148
|
+
WHAT ARE TEMPLATES?
|
|
149
|
+
|
|
150
|
+
Workflow templates are brand-agnostic YAML files that define a DAG (directed acyclic graph)
|
|
151
|
+
of tasks with skills, dependencies, and variable placeholders. You stamp a template for a
|
|
152
|
+
specific brand/tenant by providing variable values — the system creates all tasks in the
|
|
153
|
+
kanban DB with correct dependency links, and the dispatcher executes them via Kimaki agents.
|
|
154
|
+
|
|
155
|
+
HOW TO LIST TEMPLATES
|
|
156
|
+
|
|
157
|
+
kanban templates # List available template names
|
|
158
|
+
kanban templates --details # Show variables, tasks, and dependencies for each
|
|
159
|
+
|
|
160
|
+
HOW TO STAMP (RUN) A TEMPLATE
|
|
161
|
+
|
|
162
|
+
kanban stamp <template-name> --var key=value [--var key2=value2 ...]
|
|
163
|
+
|
|
164
|
+
Examples:
|
|
165
|
+
|
|
166
|
+
kanban stamp blog-social-campaign \\
|
|
167
|
+
--var topic="solar chargers" \\
|
|
168
|
+
--var tenant=nomadixgear \\
|
|
169
|
+
--var audience=campers
|
|
170
|
+
|
|
171
|
+
kanban stamp product-launch \\
|
|
172
|
+
--var product="HyperFrames" \\
|
|
173
|
+
--var tenant=aiwithjai \\
|
|
174
|
+
--var vertical="developer tools"
|
|
175
|
+
|
|
176
|
+
kanban stamp b2b-outreach \\
|
|
177
|
+
--var vertical=resorts \\
|
|
178
|
+
--var tenant=nomadixgear \\
|
|
179
|
+
--var region=Caribbean
|
|
180
|
+
|
|
181
|
+
kanban stamp seo-silo \\
|
|
182
|
+
--var seed_keyword="solar chargers" \\
|
|
183
|
+
--var tenant=nomadixgear \\
|
|
184
|
+
--var page_count=30
|
|
185
|
+
|
|
186
|
+
HOW TO BUILD A NEW TEMPLATE
|
|
187
|
+
|
|
188
|
+
Create a YAML file in: ${TEMPLATES_DIR}/
|
|
189
|
+
|
|
190
|
+
Template structure:
|
|
191
|
+
|
|
192
|
+
name: Human-readable name
|
|
193
|
+
description: What this workflow does
|
|
194
|
+
|
|
195
|
+
variables:
|
|
196
|
+
- name: topic # Variable key used in {{topic}} placeholders
|
|
197
|
+
required: true # Must be provided or stamp fails
|
|
198
|
+
description: Help text for the agent
|
|
199
|
+
- name: audience
|
|
200
|
+
default: general # Optional — used if not provided
|
|
201
|
+
description: Target audience
|
|
202
|
+
|
|
203
|
+
tasks:
|
|
204
|
+
- id: research # Unique ID within this template
|
|
205
|
+
title: "Research: {{topic}}" # {{vars}} get interpolated
|
|
206
|
+
assignee: researcher # Maps to a kanban worker role
|
|
207
|
+
skills: [keyword-research] # Skills the worker agent should load
|
|
208
|
+
priority: 3 # Higher = runs first within a tier
|
|
209
|
+
body: | # Detailed instructions for the agent
|
|
210
|
+
Research {{topic}} for {{audience}}...
|
|
211
|
+
|
|
212
|
+
- id: draft
|
|
213
|
+
title: "Write blog: {{topic}}"
|
|
214
|
+
assignee: writer
|
|
215
|
+
skills: [Publishing Blog Articles]
|
|
216
|
+
parents: [research] # Wait for these tasks to complete first
|
|
217
|
+
priority: 2
|
|
218
|
+
body: |
|
|
219
|
+
Write about {{topic}}...
|
|
220
|
+
|
|
221
|
+
RULES FOR TEMPLATES:
|
|
222
|
+
|
|
223
|
+
1. Variables: Use {{variable_name}} in title, body, and description fields.
|
|
224
|
+
Required variables MUST be provided when stamping or it errors.
|
|
225
|
+
Optional variables fall back to their default value.
|
|
226
|
+
|
|
227
|
+
2. Task IDs: Must be unique within the template. Used for parent links.
|
|
228
|
+
These are logical IDs — the DB assigns real task IDs (t_xxxxxxxx).
|
|
229
|
+
|
|
230
|
+
3. Dependencies: Use "parents: [task_id]" to create dependency links.
|
|
231
|
+
Child tasks start as "todo" and auto-promote to "ready" when all parents complete.
|
|
232
|
+
|
|
233
|
+
4. Skills: List skills the worker agent should have loaded. These map to
|
|
234
|
+
the skill names in ~/.claude/skills/ or the available_skills list.
|
|
235
|
+
|
|
236
|
+
5. Assignees: Logical role names (researcher, writer, social, etc.).
|
|
237
|
+
The dispatcher maps these to Kimaki agent sessions.
|
|
238
|
+
|
|
239
|
+
6. Body text: This is the prompt sent to the worker agent. Be specific.
|
|
240
|
+
Include what to produce, where to save it, and what to reference.
|
|
241
|
+
|
|
242
|
+
7. Tenant: The "tenant" variable is special — it namespaces tasks per brand.
|
|
243
|
+
Include it as a required variable in most templates.
|
|
244
|
+
|
|
245
|
+
EXECUTION FLOW:
|
|
246
|
+
|
|
247
|
+
1. You run: kanban stamp <template> --var ...
|
|
248
|
+
2. Tasks are created in the kanban DB with dependency links
|
|
249
|
+
3. Root tasks (no parents) start as "ready"
|
|
250
|
+
4. Child tasks start as "todo"
|
|
251
|
+
5. The dispatcher polls for "ready" tasks and spawns Kimaki workers
|
|
252
|
+
6. When a worker completes a task, its children auto-promote to "ready"
|
|
253
|
+
7. The next dispatch tick picks up the newly ready tasks
|
|
254
|
+
8. This continues until all tasks are done or blocked
|
|
255
|
+
|
|
256
|
+
STARTING THE DISPATCHER:
|
|
257
|
+
|
|
258
|
+
bun src/dispatcher.mjs \\
|
|
259
|
+
--workdir /path/to/brand/project \\
|
|
260
|
+
--channel <discord-channel-id> \\
|
|
261
|
+
--agent general \\
|
|
262
|
+
--interval 30
|
|
263
|
+
|
|
264
|
+
DASHBOARD:
|
|
265
|
+
|
|
266
|
+
bun src/server.mjs
|
|
267
|
+
open http://127.0.0.1:4717
|
|
268
|
+
`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function fmtTime(ts) {
|
|
272
|
+
if (!ts) return ''
|
|
273
|
+
return new Date(ts * 1000).toISOString().replace('T', ' ').slice(0, 19)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function printTask(task) {
|
|
277
|
+
const tenant = task.tenant ? `[${task.tenant}] ` : ''
|
|
278
|
+
const project = task.project ? `/${task.project} ` : ''
|
|
279
|
+
console.log(`${task.id} ${task.status.padEnd(8)} ${(task.assignee || 'unassigned').padEnd(18)} ${tenant}${project}${task.title}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
(async function main() {
|
|
283
|
+
const { flags, positional } = parseFlags(args)
|
|
284
|
+
const cmd = positional[0]
|
|
285
|
+
|
|
286
|
+
if (!cmd || flags.help || flags.h) {
|
|
287
|
+
usage()
|
|
288
|
+
process.exit(0)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
if (cmd === 'init') {
|
|
293
|
+
const path = flags.db || getDefaultDbPath()
|
|
294
|
+
initDb(path)
|
|
295
|
+
console.log(`Initialized kanban DB: ${path}`)
|
|
296
|
+
} else if (cmd === 'create') {
|
|
297
|
+
const db = open(flags)
|
|
298
|
+
const title = positional[1]
|
|
299
|
+
if (!title) throw new Error('create requires a title')
|
|
300
|
+
const skillInput = Array.isArray(flags.skill) ? flags.skill.join(',') : flags.skill
|
|
301
|
+
const parentInput = Array.isArray(flags.parent) ? flags.parent.join(',') : flags.parent
|
|
302
|
+
const skills = skillInput ? String(skillInput).split(',').map(s => s.trim()).filter(Boolean) : []
|
|
303
|
+
const parents = parentInput ? String(parentInput).split(',').map(s => s.trim()).filter(Boolean) : []
|
|
304
|
+
const status = parents.length > 0 ? 'todo' : (flags.status || 'ready')
|
|
305
|
+
const task = createTask(db, {
|
|
306
|
+
title,
|
|
307
|
+
body: flags.body || '',
|
|
308
|
+
assignee: flags.assignee || null,
|
|
309
|
+
tenant: flags.tenant || null,
|
|
310
|
+
project: flags.project || null,
|
|
311
|
+
priority: flags.priority ? Number(flags.priority) : 0,
|
|
312
|
+
status,
|
|
313
|
+
skills,
|
|
314
|
+
parents,
|
|
315
|
+
})
|
|
316
|
+
printTask(task)
|
|
317
|
+
} else if (cmd === 'list') {
|
|
318
|
+
const db = open(flags)
|
|
319
|
+
listTasks(db, {
|
|
320
|
+
status: flags.status,
|
|
321
|
+
assignee: flags.assignee,
|
|
322
|
+
tenant: flags.tenant,
|
|
323
|
+
project: flags.project,
|
|
324
|
+
limit: flags.limit ? Number(flags.limit) : 100,
|
|
325
|
+
}).forEach(printTask)
|
|
326
|
+
} else if (cmd === 'ready') {
|
|
327
|
+
const db = open(flags)
|
|
328
|
+
getDispatchable(db).forEach(printTask)
|
|
329
|
+
} else if (cmd === 'show') {
|
|
330
|
+
const db = open(flags)
|
|
331
|
+
const task = getTask(db, positional[1])
|
|
332
|
+
if (!task) throw new Error(`task not found: ${positional[1]}`)
|
|
333
|
+
console.log(`${task.id}: ${task.title}`)
|
|
334
|
+
console.log(`status: ${task.status}`)
|
|
335
|
+
console.log(`assignee: ${task.assignee || ''}`)
|
|
336
|
+
console.log(`tenant: ${task.tenant || ''}`)
|
|
337
|
+
console.log(`project: ${task.project || ''}`)
|
|
338
|
+
console.log(`skills: ${task.skills.join(', ')}`)
|
|
339
|
+
console.log(`created: ${fmtTime(task.created_at)}`)
|
|
340
|
+
if (task.body) console.log(`\n${task.body}`)
|
|
341
|
+
if (task.parents.length) console.log(`\nparents: ${task.parents.join(', ')}`)
|
|
342
|
+
if (task.children.length) console.log(`children: ${task.children.join(', ')}`)
|
|
343
|
+
if (task.parentResults.length) {
|
|
344
|
+
console.log('\nParent results:')
|
|
345
|
+
for (const p of task.parentResults) console.log(`- ${p.id} (${p.status}) ${p.title}: ${p.result || ''}`)
|
|
346
|
+
}
|
|
347
|
+
if (task.comments.length) {
|
|
348
|
+
console.log('\nComments:')
|
|
349
|
+
for (const c of task.comments) console.log(`- ${fmtTime(c.created_at)} ${c.author}: ${c.body}`)
|
|
350
|
+
}
|
|
351
|
+
if (task.events.length) {
|
|
352
|
+
console.log('\nEvents:')
|
|
353
|
+
for (const e of task.events) console.log(`- ${fmtTime(e.created_at)} ${e.kind} ${e.payload || ''}`)
|
|
354
|
+
}
|
|
355
|
+
} else if (cmd === 'complete') {
|
|
356
|
+
const db = open(flags)
|
|
357
|
+
const deliverables = flags.deliverable ? (Array.isArray(flags.deliverable) ? flags.deliverable : [flags.deliverable]) : undefined
|
|
358
|
+
const task = completeTask(db, positional[1], { summary: flags.summary, result: flags.result, deliverables })
|
|
359
|
+
printTask(task)
|
|
360
|
+
// Send Discord notification for hooks/publish task completion
|
|
361
|
+
notifyTaskCompletion(task)
|
|
362
|
+
} else if (cmd === 'block') {
|
|
363
|
+
const db = open(flags)
|
|
364
|
+
const task = blockTask(db, positional[1], { reason: flags.reason || 'blocked', author: flags.author || 'worker' })
|
|
365
|
+
printTask(task)
|
|
366
|
+
} else if (cmd === 'unblock') {
|
|
367
|
+
const db = open(flags)
|
|
368
|
+
const task = unblockTask(db, positional[1])
|
|
369
|
+
if (!task) throw new Error(`cannot unblock task: ${positional[1]}`)
|
|
370
|
+
printTask(task)
|
|
371
|
+
} else if (cmd === 'promote') {
|
|
372
|
+
const db = open(flags)
|
|
373
|
+
const skillInput = Array.isArray(flags.skill) ? flags.skill.join(',') : flags.skill
|
|
374
|
+
const skills = skillInput ? String(skillInput).split(',').map(s => s.trim()).filter(Boolean) : undefined
|
|
375
|
+
const task = promoteTask(db, positional[1], { assignee: flags.assignee || undefined, skills, body: flags.body || undefined })
|
|
376
|
+
if (!task) throw new Error(`task not found: ${positional[1]}`)
|
|
377
|
+
printTask(task)
|
|
378
|
+
} else if (cmd === 'assign') {
|
|
379
|
+
const db = open(flags)
|
|
380
|
+
const skillInput = Array.isArray(flags.skill) ? flags.skill.join(',') : flags.skill
|
|
381
|
+
const skills = skillInput ? String(skillInput).split(',').map(s => s.trim()).filter(Boolean) : undefined
|
|
382
|
+
const task = assignTask(db, positional[1], flags.assignee || null, { skills })
|
|
383
|
+
if (!task) throw new Error(`task not found: ${positional[1]}`)
|
|
384
|
+
printTask(task)
|
|
385
|
+
} else if (cmd === 'status') {
|
|
386
|
+
const db = open(flags)
|
|
387
|
+
const task = setTaskStatus(db, positional[1], flags.status)
|
|
388
|
+
if (!task) throw new Error(`task not found: ${positional[1]}`)
|
|
389
|
+
printTask(task)
|
|
390
|
+
} else if (cmd === 'comment') {
|
|
391
|
+
const db = open(flags)
|
|
392
|
+
const c = addComment(db, positional[1], flags.author || 'user', flags.body || '')
|
|
393
|
+
console.log(`comment ${c.id} added`)
|
|
394
|
+
} else if (cmd === 'link') {
|
|
395
|
+
const db = open(flags)
|
|
396
|
+
linkTasks(db, positional[1], positional[2])
|
|
397
|
+
console.log(`linked ${positional[1]} -> ${positional[2]}`)
|
|
398
|
+
} else if (cmd === 'archive') {
|
|
399
|
+
const db = open(flags)
|
|
400
|
+
archiveTask(db, positional[1])
|
|
401
|
+
console.log(`archived ${positional[1]}`)
|
|
402
|
+
} else if (cmd === 'stats') {
|
|
403
|
+
const db = open(flags)
|
|
404
|
+
console.log(JSON.stringify(boardStats(db), null, 2))
|
|
405
|
+
} else if (cmd === 'templates') {
|
|
406
|
+
const templates = listTemplates()
|
|
407
|
+
if (!templates.length) {
|
|
408
|
+
console.log(`No templates found in ${TEMPLATES_DIR}`)
|
|
409
|
+
} else if (flags.details) {
|
|
410
|
+
for (const t of templates) {
|
|
411
|
+
try {
|
|
412
|
+
const tpl = loadTemplate(t.file.replace(/\.ya?ml$/, ''))
|
|
413
|
+
console.log(`\n=== ${tpl.name} (${t.file}) ===`)
|
|
414
|
+
if (tpl.description) console.log(tpl.description)
|
|
415
|
+
console.log(`\nVariables:`)
|
|
416
|
+
for (const v of tpl.variables) {
|
|
417
|
+
const req = v.required ? '(required)' : '(optional)'
|
|
418
|
+
const def = v.default ? ` default: ${v.default}` : ''
|
|
419
|
+
console.log(` ${v.name} ${req}${def}${v.description ? ' — ' + v.description : ''}`)
|
|
420
|
+
}
|
|
421
|
+
console.log(`\nTasks (${tpl.tasks.length}):`)
|
|
422
|
+
for (const task of tpl.tasks) {
|
|
423
|
+
const deps = task.parents.length ? ` [after: ${task.parents.join(', ')}]` : ''
|
|
424
|
+
console.log(` ${task.id}: ${task.title} → ${task.assignee || 'unassigned'} | skills: ${task.skills.join(', ')}${deps}`)
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
console.log(` ${t.file}: error loading — ${err.message}`)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
for (const t of templates) {
|
|
432
|
+
console.log(` ${t.file.padEnd(35)} ${t.name}`)
|
|
433
|
+
}
|
|
434
|
+
console.log(`\nUse --details to see variables and tasks.`)
|
|
435
|
+
}
|
|
436
|
+
} else if (cmd === 'stamp') {
|
|
437
|
+
const templateName = positional[1]
|
|
438
|
+
if (!templateName) throw new Error('stamp requires a template name or path. Use "kanban templates" to list available templates.')
|
|
439
|
+
|
|
440
|
+
if (flags.preflight) {
|
|
441
|
+
const tpl = loadTemplate(templateName)
|
|
442
|
+
const vars = parseStampVars(flags)
|
|
443
|
+
const workdir = flags.workdir ? resolve(flags.workdir) : process.cwd()
|
|
444
|
+
const manifest = preflight(tpl, vars, workdir)
|
|
445
|
+
console.log(JSON.stringify(manifest, null, 2))
|
|
446
|
+
console.error(`\nBrand level: L${manifest.brand_level} (${manifest.brand_populated.length} of 10 files populated)`)
|
|
447
|
+
console.error(`${manifest.questions.length} questions needed before stamping.`)
|
|
448
|
+
if (manifest.missing_required.length) {
|
|
449
|
+
console.error(`Missing required variables (no question available): ${manifest.missing_required.join(', ')}`)
|
|
450
|
+
}
|
|
451
|
+
process.exit(0)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (flags.interview) {
|
|
455
|
+
const tpl = loadTemplate(templateName)
|
|
456
|
+
const cliVars = parseStampVars(flags)
|
|
457
|
+
const workdir = flags.workdir ? resolve(flags.workdir) : process.cwd()
|
|
458
|
+
const manifest = preflight(tpl, cliVars, workdir)
|
|
459
|
+
|
|
460
|
+
if (manifest.questions.length === 0 && manifest.missing_required.length === 0) {
|
|
461
|
+
console.error('All variables satisfied. Proceeding to stamp...')
|
|
462
|
+
} else {
|
|
463
|
+
if (manifest.missing_required.length) {
|
|
464
|
+
throw new Error(`Missing required variables with no question: ${manifest.missing_required.join(', ')}`)
|
|
465
|
+
}
|
|
466
|
+
console.error(`\n=== Interview Mode: ${manifest.questions.length} questions ===\n`)
|
|
467
|
+
const answers = {}
|
|
468
|
+
for (const q of manifest.questions) {
|
|
469
|
+
const prompt = q.required ? `[REQUIRED] ` : `[optional] `
|
|
470
|
+
const def = q.default ? ` (default: ${q.default})` : ''
|
|
471
|
+
const pre = q.prefill ? ` [current: ${q.prefill}]` : ''
|
|
472
|
+
console.error(`${prompt}${q.question}${def}${pre}`)
|
|
473
|
+
const input = await readStdin(`${q.var}> `)
|
|
474
|
+
const val = input.trim()
|
|
475
|
+
if (val) {
|
|
476
|
+
answers[q.var] = val
|
|
477
|
+
} else if (q.default) {
|
|
478
|
+
answers[q.var] = q.default
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
cliVars.answers = answers
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const tpl = loadTemplate(templateName)
|
|
488
|
+
const vars = parseStampVars(flags)
|
|
489
|
+
if (flags.tenant) vars.tenant = flags.tenant
|
|
490
|
+
const db = open(flags)
|
|
491
|
+
const result = stampTemplate(db, tpl, vars, { force: !!flags.force })
|
|
492
|
+
console.log(`\nStamped: ${result.template} (${result.tasks.length} tasks, tenant: ${vars.tenant || vars.brand || 'none'}, project: ${vars.project || 'none'})`)
|
|
493
|
+
console.log(`\nTask DAG:`)
|
|
494
|
+
for (const t of result.tasks) {
|
|
495
|
+
console.log(` ${t.dbId} ${t.templateId.padEnd(15)} ${t.title}`)
|
|
496
|
+
}
|
|
497
|
+
console.log(`\nDispatcher will pick up ready tasks automatically.`)
|
|
498
|
+
console.log(`Run: bun src/dispatcher.mjs --workdir /path/to/project --channel <id> --agent general`)
|
|
499
|
+
} else if (cmd === 'stamp-batch') {
|
|
500
|
+
const templateName = positional[1]
|
|
501
|
+
if (!templateName) throw new Error('stamp-batch requires a template name')
|
|
502
|
+
if (!flags.file) throw new Error('stamp-batch requires --file <path> (JSON array of variable objects)')
|
|
503
|
+
|
|
504
|
+
const tpl = loadTemplate(templateName)
|
|
505
|
+
const batchPath = resolve(flags.file)
|
|
506
|
+
if (!existsSync(batchPath)) throw new Error(`Batch file not found: ${batchPath}`)
|
|
507
|
+
|
|
508
|
+
const batchEntries = JSON.parse(readFileSync(batchPath, 'utf-8'))
|
|
509
|
+
if (!Array.isArray(batchEntries)) throw new Error('Batch file must contain a JSON array')
|
|
510
|
+
|
|
511
|
+
const db = open(flags)
|
|
512
|
+
const result = stampBatch(db, tpl, batchEntries, { force: !!flags.force })
|
|
513
|
+
|
|
514
|
+
console.log(`\nBatch stamp: ${result.template}`)
|
|
515
|
+
console.log(` Stamped: ${result.stamped.length} pipelines (${result.totalTasks} total tasks)`)
|
|
516
|
+
console.log(` Skipped: ${result.skipped.length} (duplicates)`)
|
|
517
|
+
|
|
518
|
+
for (const r of result.stamped) {
|
|
519
|
+
console.log(`\n Pipeline (tenant: ${r.tenant || 'none'}):`)
|
|
520
|
+
for (const t of r.tasks) {
|
|
521
|
+
console.log(` ${t.dbId} ${t.templateId.padEnd(15)} ${t.title}`)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (result.skipped.length) {
|
|
526
|
+
console.log(`\nSkipped entries:`)
|
|
527
|
+
for (const s of result.skipped) {
|
|
528
|
+
const slug = s.vars.podcast_slug || s.vars.slug || s.vars.keyword || '?'
|
|
529
|
+
console.log(` ${slug}: ${s.reason}`)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else if (cmd === 'brand-files') {
|
|
533
|
+
const workdir = flags.workdir ? resolve(flags.workdir) : process.cwd()
|
|
534
|
+
const brand = detectBrandFiles(workdir)
|
|
535
|
+
console.log(`Brand directory: ${workdir}/brand/`)
|
|
536
|
+
console.log(`CMO Level: L${brand.level} (${brand.populated.length} of 10 files)`)
|
|
537
|
+
console.log(`\nPopulated files (${brand.populated.length}):`)
|
|
538
|
+
for (const f of brand.populated) console.log(` ✓ ${f}`)
|
|
539
|
+
console.log(`\nMissing or template-only (${brand.missing.length}):`)
|
|
540
|
+
for (const f of brand.missing) console.log(` ✗ ${f}`)
|
|
541
|
+
if (flags.json) {
|
|
542
|
+
console.log(JSON.stringify(brand, null, 2))
|
|
543
|
+
}
|
|
544
|
+
} else if (cmd === 'stamp-help') {
|
|
545
|
+
printStampHelp()
|
|
546
|
+
} else {
|
|
547
|
+
throw new Error(`unknown command: ${cmd}`)
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
551
|
+
process.exit(1)
|
|
552
|
+
}
|
|
553
|
+
})()
|