@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,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
+ })()