kodi-dev 0.1.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 (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/assets/agents/briefing/brief.md +89 -0
  4. package/assets/agents/briefing/brownfield-wu.md +75 -0
  5. package/assets/agents/briefing/greenfield-wu.md +69 -0
  6. package/assets/agents/build/backend-engineer.md +54 -0
  7. package/assets/agents/build/backend-tester.md +52 -0
  8. package/assets/agents/build/build-orchestrator.md +71 -0
  9. package/assets/agents/build/frontend-engineer.md +53 -0
  10. package/assets/agents/build/frontend-tester.md +52 -0
  11. package/assets/agents/build/qa-implementation.md +54 -0
  12. package/assets/agents/build/qa-visual.md +44 -0
  13. package/assets/agents/build/security.md +56 -0
  14. package/assets/agents/planning/architect.md +61 -0
  15. package/assets/agents/planning/brand.md +46 -0
  16. package/assets/agents/planning/component-engineer.md +60 -0
  17. package/assets/agents/planning/data-engineer.md +60 -0
  18. package/assets/agents/planning/detail.md +62 -0
  19. package/assets/agents/planning/phases.md +55 -0
  20. package/assets/agents/planning/qa-planning.md +56 -0
  21. package/assets/agents/planning/researcher.md +53 -0
  22. package/assets/agents/planning/system-architect.md +57 -0
  23. package/assets/agents/planning/ux-lead.md +56 -0
  24. package/assets/rules/ticket-completion.md +28 -0
  25. package/assets/skills/discover/SKILL.md +63 -0
  26. package/assets/skills/oplan/SKILL.md +37 -0
  27. package/assets/skills/oreplan/SKILL.md +23 -0
  28. package/assets/skills/ticket-start/SKILL.md +33 -0
  29. package/assets/skills/tickets/SKILL.md +21 -0
  30. package/dist/index.js +1654 -0
  31. package/package.json +53 -0
package/dist/index.js ADDED
@@ -0,0 +1,1654 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/add.ts
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ statSync,
14
+ writeFileSync
15
+ } from "fs";
16
+ import { basename, dirname, join } from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { parse as parseYaml } from "yaml";
19
+ import { z } from "zod";
20
+ var PackManifestSchema = z.object({
21
+ name: z.string().min(1),
22
+ role: z.string().optional(),
23
+ framework: z.string().optional(),
24
+ language: z.string().optional(),
25
+ /** Fragment merged into the thin root CLAUDE.md (stack line, gate commands, …). */
26
+ claude_md: z.string().optional()
27
+ });
28
+ function bundledPacksDir() {
29
+ return fileURLToPath(new URL("../packs/", import.meta.url));
30
+ }
31
+ function resolvePackDir(ref) {
32
+ if (existsSync(ref) && statSync(ref).isDirectory()) return ref;
33
+ const bundled = join(bundledPacksDir(), ref);
34
+ if (existsSync(bundled)) return bundled;
35
+ throw new Error(
36
+ `skill-pack "${ref}" not found. Pass a path to a pack directory (no bundled packs yet).`
37
+ );
38
+ }
39
+ function copyTree(src, dest, force) {
40
+ const written = [];
41
+ const walk = (s, d) => {
42
+ for (const entry of readdirSync(s)) {
43
+ const sp = join(s, entry);
44
+ const dp = join(d, entry);
45
+ if (statSync(sp).isDirectory()) walk(sp, dp);
46
+ else {
47
+ if (existsSync(dp) && !force) continue;
48
+ mkdirSync(dirname(dp), { recursive: true });
49
+ copyFileSync(sp, dp);
50
+ written.push(dp);
51
+ }
52
+ }
53
+ };
54
+ if (existsSync(src)) walk(src, dest);
55
+ return written;
56
+ }
57
+ function mergeClaudeMd(existing, name, fragment) {
58
+ const open = `<!-- kodi-pack:${name} -->`;
59
+ const close = `<!-- /kodi-pack:${name} -->`;
60
+ const block = `${open}
61
+ ${fragment.trim()}
62
+ ${close}`;
63
+ const re = new RegExp(`${open}[\\s\\S]*?${close}`);
64
+ if (re.test(existing)) return existing.replace(re, block);
65
+ const base = existing.trimEnd();
66
+ return (base ? base + "\n\n" : "") + block + "\n";
67
+ }
68
+ function installPack(root, packDir, force = false) {
69
+ const manifest = PackManifestSchema.parse(parseYaml(readFileSync(join(packDir, "manifest.yaml"), "utf-8")));
70
+ const skills = copyTree(join(packDir, "skills"), join(root, ".claude", "skills"), force);
71
+ let claudeMdMerged = false;
72
+ if (manifest.claude_md) {
73
+ const claudeMdPath = join(root, "CLAUDE.md");
74
+ const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, "utf-8") : "";
75
+ writeFileSync(claudeMdPath, mergeClaudeMd(current, manifest.name, manifest.claude_md), "utf-8");
76
+ claudeMdMerged = true;
77
+ }
78
+ const packsPath = join(root, ".claude", "kodi", "packs.yaml");
79
+ const installed = existsSync(packsPath) ? parseYaml(readFileSync(packsPath, "utf-8"))?.installed ?? [] : [];
80
+ if (!installed.includes(manifest.name)) installed.push(manifest.name);
81
+ mkdirSync(dirname(packsPath), { recursive: true });
82
+ writeFileSync(packsPath, `installed:
83
+ ${installed.map((p) => ` - ${p}`).join("\n")}
84
+ `, "utf-8");
85
+ return { name: manifest.name, skills: skills.map((s) => basename(s)), claudeMdMerged };
86
+ }
87
+ function registerAddCommand(program2) {
88
+ program2.command("add <pack>").description("Install a skill-pack (bundle of skills + CLAUDE.md fragment) \u2014 explicit only").option("-d, --dir <path>", "target project directory", process.cwd()).option("--force", "overwrite existing skills", false).action((pack, o) => {
89
+ const res = installPack(String(o.dir), resolvePackDir(pack), o.force);
90
+ process.stdout.write(
91
+ `Installed skill-pack "${res.name}"
92
+ skills: ${res.skills.length ? res.skills.join(", ") : "(none new)"}
93
+ CLAUDE.md: ${res.claudeMdMerged ? "merged fragment" : "no fragment"}
94
+ `
95
+ );
96
+ });
97
+ }
98
+
99
+ // src/bootstrap.ts
100
+ var ORCHESTRATOR_BOOTSTRAP = `# You are the kodi orchestrator
101
+
102
+ You run the kodi.dev agent orchestration for THIS project, hosted natively in
103
+ Claude Code. You are the main-loop orchestrator: you talk to the human, adopt
104
+ the phase-orchestrator role when a phase skill runs, and spawn sub-agents to do
105
+ the work. You coordinate through durable artifacts in \`docs/\` and the ticket
106
+ board \u2014 there is no message bus.
107
+
108
+ ## Laws (never violated, even in autonomous mode)
109
+
110
+ 1. **Ask, never assume.** Any genuine decision \u2014 an ADR change, approving a
111
+ PRD / plan / phase split, a provider config, discovery answers, a scope
112
+ ambiguity, overwriting a human-approved artifact, or mutating a remote board
113
+ / PR \u2014 is ALWAYS taken to the human. Autonomy covers only mechanical
114
+ execution.
115
+ 2. **ADR is law.** Follow existing ADRs. Changing an ADR requires explicit
116
+ human approval, including under automatic mode.
117
+
118
+ ## Phase entry points (explicit \u2014 you do not auto-advance)
119
+
120
+ - \`/discover\` \u2014 Briefing. You run the grill on the main thread; \`greenfield-wu\`
121
+ / \`brownfield-wu\` only investigate (no interviewing). Produces \`briefing.md\`
122
+ + a thin \`CLAUDE.md\`.
123
+ - \`/oplan\` \u2014 Planning. You (main-loop) drive the hub loop: spawn a manager, it
124
+ returns a plan naming its leaves, you spawn the leaves, you validate; loop
125
+ until \`qa-planning\` passes. Order: \`detail\` (PRD) then \`architect\` \u2225 \`ux\`,
126
+ then \`phases\`, then \`qa-planning\`.
127
+ - \`/oreplan <phase>\` \u2014 Re-plan or expand ONE phase; show the diff and get
128
+ sign-off before overwriting. Never touches the board.
129
+ - \`/tickets\` \u2014 Generate board tickets from the consolidated plan (per phase, on
130
+ demand) via the \`kodi tickets\` CLI.
131
+ - \`/ticket-start\` \u2014 Build. Spawn the \`build-orchestrator\` sub-agent to drive one
132
+ ticket as a vertical slice; it closes only when every gate is green.
133
+
134
+ ## Tools
135
+
136
+ - Manage tickets and PRs ONLY through the \`kodi\` CLI (\`kodi tickets \u2026\`,
137
+ \`kodi pr \u2026\`) \u2014 it proxies \`gh\`/\`az\` and enforces the templates. Remote
138
+ mutations are dry-run unless \`--yes\`.
139
+ - The thin \`CLAUDE.md\` is the single source of truth for the stack, gate
140
+ commands, provider, and installed skill-packs.
141
+ `;
142
+
143
+ // src/commands/hook.ts
144
+ function registerHookCommand(program2) {
145
+ const hook = program2.command("hook").description("Emit Claude Code hook output (internal)");
146
+ hook.command("session-start").description("Emit the orchestrator bootstrap as SessionStart additionalContext").action(() => {
147
+ const payload = {
148
+ hookSpecificOutput: {
149
+ hookEventName: "SessionStart",
150
+ additionalContext: ORCHESTRATOR_BOOTSTRAP
151
+ }
152
+ };
153
+ process.stdout.write(JSON.stringify(payload));
154
+ });
155
+ return hook;
156
+ }
157
+
158
+ // src/commands/init.ts
159
+ import {
160
+ copyFileSync as copyFileSync2,
161
+ existsSync as existsSync3,
162
+ mkdirSync as mkdirSync2,
163
+ readdirSync as readdirSync2,
164
+ readFileSync as readFileSync3,
165
+ statSync as statSync2,
166
+ writeFileSync as writeFileSync2
167
+ } from "fs";
168
+ import { dirname as dirname3, join as join3, relative } from "path";
169
+ import { fileURLToPath as fileURLToPath2 } from "url";
170
+ import { stringify as stringifyYaml } from "yaml";
171
+
172
+ // src/config.ts
173
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
174
+ import { dirname as dirname2, join as join2 } from "path";
175
+ import { parse as parseYaml2 } from "yaml";
176
+ var DEFAULTS = { provider: "local", prefix: "KODI" };
177
+ var STATE_FILE = "kodi-dev.yaml";
178
+ function stateFilePath(root) {
179
+ return join2(root, ".claude", STATE_FILE);
180
+ }
181
+ function findProjectRoot(cwd = process.cwd()) {
182
+ let dir = cwd;
183
+ while (true) {
184
+ if (existsSync2(stateFilePath(dir))) return dir;
185
+ const parent = dirname2(dir);
186
+ if (parent === dir) return cwd;
187
+ dir = parent;
188
+ }
189
+ }
190
+ function loadBoardConfig(cwd = process.cwd()) {
191
+ const path = stateFilePath(findProjectRoot(cwd));
192
+ if (!existsSync2(path)) return { ...DEFAULTS };
193
+ try {
194
+ const raw = parseYaml2(readFileSync2(path, "utf-8")) ?? {};
195
+ return { ...DEFAULTS, ...raw };
196
+ } catch {
197
+ return { ...DEFAULTS };
198
+ }
199
+ }
200
+ function localPaths(cwd = process.cwd()) {
201
+ const root = join2(findProjectRoot(cwd), "docs", "tickets");
202
+ return {
203
+ root,
204
+ backlog: join2(root, "backlog"),
205
+ done: join2(root, "done"),
206
+ index: join2(root, "tickets.md")
207
+ };
208
+ }
209
+
210
+ // src/exec.ts
211
+ import { spawnSync } from "child_process";
212
+ function quote(args) {
213
+ return args.map((a) => /^[A-Za-z0-9_./:@=-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`).join(" ");
214
+ }
215
+ function execRead(args) {
216
+ const [cmd, ...rest] = args;
217
+ const r = spawnSync(cmd, rest, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
218
+ if (r.status !== 0) {
219
+ throw new Error(`\`${quote(args)}\` failed (exit ${r.status}): ${r.stderr?.trim() || ""}`);
220
+ }
221
+ return r.stdout ?? "";
222
+ }
223
+ function execMutate(args, dryRun) {
224
+ const command = quote(args);
225
+ if (dryRun) {
226
+ process.stderr.write(`[dry-run] ${command}
227
+ (re-run with --yes to execute)
228
+ `);
229
+ return { command, ran: false, stdout: "", stderr: "", code: 0 };
230
+ }
231
+ const [cmd, ...rest] = args;
232
+ const r = spawnSync(cmd, rest, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
233
+ if (r.status !== 0) {
234
+ throw new Error(`\`${command}\` failed (exit ${r.status}): ${r.stderr?.trim() || ""}`);
235
+ }
236
+ return { command, ran: true, stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status ?? 0 };
237
+ }
238
+
239
+ // src/html.ts
240
+ function escapeHtml(s) {
241
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
242
+ }
243
+ function inline(s) {
244
+ return escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
245
+ }
246
+ function mdToHtml(md) {
247
+ const out2 = [];
248
+ let list = null;
249
+ const closeList = () => {
250
+ if (list) {
251
+ out2.push("</ul>");
252
+ list = null;
253
+ }
254
+ };
255
+ for (const raw of md.split("\n")) {
256
+ const line = raw.trimEnd();
257
+ if (/^<!--/.test(line.trim())) {
258
+ closeList();
259
+ out2.push(line);
260
+ continue;
261
+ }
262
+ if (line.trim() === "") {
263
+ closeList();
264
+ continue;
265
+ }
266
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
267
+ if (heading) {
268
+ closeList();
269
+ const level = heading[1].length;
270
+ out2.push(`<h${level}>${inline(heading[2])}</h${level}>`);
271
+ continue;
272
+ }
273
+ const item = /^[-*]\s+(.*)$/.exec(line);
274
+ if (item) {
275
+ if (!list) {
276
+ out2.push("<ul>");
277
+ list = "ul";
278
+ }
279
+ out2.push(`<li>${inline(item[1])}</li>`);
280
+ continue;
281
+ }
282
+ closeList();
283
+ out2.push(`<p>${inline(line)}</p>`);
284
+ }
285
+ closeList();
286
+ return out2.join("\n");
287
+ }
288
+
289
+ // src/templates/ticket.ts
290
+ import { z as z2 } from "zod";
291
+ var TICKET_STATUSES = [
292
+ "Pending",
293
+ "In progress",
294
+ "To review",
295
+ "Done",
296
+ "Blocked"
297
+ ];
298
+ var TicketStatusSchema = z2.enum(TICKET_STATUSES);
299
+ var TicketDriversSchema = z2.object({
300
+ /** PRD this ticket advances, e.g. "docs/prd/0001". */
301
+ prd: z2.string().optional(),
302
+ /** Governing ADR(s), e.g. ["docs/adr/0003"]. */
303
+ adr: z2.array(z2.string()).default([]),
304
+ /** Security finding remediated, e.g. "docs/security/AUTH-014". */
305
+ security: z2.string().optional()
306
+ });
307
+ var TicketSchema = z2.object({
308
+ key: z2.string().regex(/^[A-Z][A-Z0-9]*-\d+$/, "key must look like PREFIX-123").optional(),
309
+ title: z2.string().min(3, "title must be at least 3 characters"),
310
+ slug: z2.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case").optional(),
311
+ status: TicketStatusSchema.default("Pending"),
312
+ summary: z2.string().min(1, "summary is required"),
313
+ acceptanceCriteria: z2.array(z2.string().min(1)).min(1, "at least one acceptance criterion is required"),
314
+ nonGoals: z2.array(z2.string().min(1)).default([]),
315
+ /** Ticket keys that must reach Done before this one can start. */
316
+ dependencies: z2.array(z2.string()).default([]),
317
+ drivers: TicketDriversSchema.default({}),
318
+ /** Linked pull request (branch, URL, or id) — set by `link-pr` / `hand-off`. */
319
+ prUrl: z2.string().optional(),
320
+ notes: z2.string().optional()
321
+ });
322
+ function slugify(title) {
323
+ return title.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
324
+ }
325
+ function renderTicketMarkdown(t) {
326
+ const lines = [];
327
+ lines.push(`# ${t.key} \u2014 ${t.title}`, "");
328
+ lines.push(`**Status:** ${t.status}`);
329
+ if (t.dependencies.length) {
330
+ lines.push(`**Depends on:** ${t.dependencies.join(", ")}`);
331
+ }
332
+ const drivers = [];
333
+ if (t.drivers.prd) drivers.push(`PRD ${t.drivers.prd}`);
334
+ if (t.drivers.adr.length) drivers.push(`ADR ${t.drivers.adr.join(", ")}`);
335
+ if (t.drivers.security) drivers.push(`Security ${t.drivers.security}`);
336
+ if (drivers.length) lines.push(`**Drivers:** ${drivers.join(" \xB7 ")}`);
337
+ if (t.prUrl) lines.push(`**PR:** ${t.prUrl}`);
338
+ lines.push("", "## Summary", "", t.summary, "");
339
+ lines.push("## Acceptance criteria", "");
340
+ for (const ac of t.acceptanceCriteria) lines.push(`- [ ] ${ac}`);
341
+ lines.push("");
342
+ if (t.nonGoals.length) {
343
+ lines.push("## Non-goals", "");
344
+ for (const ng of t.nonGoals) lines.push(`- ${ng}`);
345
+ lines.push("");
346
+ }
347
+ if (t.notes) lines.push("## Notes", "", t.notes, "");
348
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
349
+ }
350
+
351
+ // src/providers/azure.ts
352
+ var MARKER_RE = /kodi:ticket:([A-Za-z0-9+/=]+)/;
353
+ var DEFAULT_COLUMNS = {
354
+ todo: "To Do",
355
+ inProgress: "In Progress",
356
+ toReview: "To Review",
357
+ done: "Done"
358
+ };
359
+ function columnForStatus(status, cols) {
360
+ switch (status) {
361
+ case "In progress":
362
+ return cols.inProgress ?? DEFAULT_COLUMNS.inProgress;
363
+ case "To review":
364
+ return cols.toReview ?? DEFAULT_COLUMNS.toReview;
365
+ case "Done":
366
+ return cols.done ?? DEFAULT_COLUMNS.done;
367
+ default:
368
+ return cols.todo;
369
+ }
370
+ }
371
+ function statusFromColumn(column, cols) {
372
+ if (column === cols.todo) return "Pending";
373
+ if (column === (cols.inProgress ?? DEFAULT_COLUMNS.inProgress)) return "In progress";
374
+ if (column === (cols.toReview ?? DEFAULT_COLUMNS.toReview)) return "To review";
375
+ if (column === (cols.done ?? DEFAULT_COLUMNS.done)) return "Done";
376
+ return void 0;
377
+ }
378
+ function descriptionHtml(t) {
379
+ const canonical = Buffer.from(JSON.stringify({ ...t, key: void 0 })).toString("base64");
380
+ return `${mdToHtml(renderTicketMarkdown(t))}
381
+ <pre>kodi:ticket:${canonical}</pre>`;
382
+ }
383
+ function parseWorkItem(fields, id, cols = DEFAULT_COLUMNS) {
384
+ const desc = fields["System.Description"] ?? "";
385
+ const m = MARKER_RE.exec(desc);
386
+ if (!m) return null;
387
+ let json;
388
+ try {
389
+ json = Buffer.from(m[1], "base64").toString("utf-8");
390
+ } catch {
391
+ return null;
392
+ }
393
+ const parsed = TicketSchema.safeParse(JSON.parse(json));
394
+ if (!parsed.success) return null;
395
+ const column = fields["System.State"] ?? fields["System.BoardColumn"] ?? "";
396
+ const status = statusFromColumn(column, cols) ?? parsed.data.status;
397
+ return { ...parsed.data, key: String(id), slug: parsed.data.slug ?? slugify(parsed.data.title), status };
398
+ }
399
+ function createArgs(coords, title, html, column) {
400
+ const args = [
401
+ "az",
402
+ "boards",
403
+ "work-item",
404
+ "create",
405
+ "--title",
406
+ title,
407
+ "--type",
408
+ "Issue",
409
+ "--fields",
410
+ `System.State=${column}`,
411
+ "--description",
412
+ html,
413
+ "--output",
414
+ "json"
415
+ ];
416
+ if (coords.organization) args.push("--organization", coords.organization);
417
+ if (coords.project) args.push("--project", coords.project);
418
+ return args;
419
+ }
420
+ var AzureTicketProvider = class {
421
+ constructor(opts) {
422
+ this.opts = opts;
423
+ this.columns = opts.columns ?? DEFAULT_COLUMNS;
424
+ }
425
+ opts;
426
+ name = "azure";
427
+ columns;
428
+ coords() {
429
+ return { organization: this.opts.organization, project: this.opts.project };
430
+ }
431
+ // `az boards work-item show/update` accept ONLY --organization; `delete` and
432
+ // `query` also need --project. `az` flag support is inconsistent per subcommand.
433
+ orgArgs() {
434
+ return this.opts.organization ? ["--organization", this.opts.organization] : [];
435
+ }
436
+ scopeArgs() {
437
+ const a = [...this.orgArgs()];
438
+ if (this.opts.project) a.push("--project", this.opts.project);
439
+ return a;
440
+ }
441
+ async nextId() {
442
+ return "(assigned by azure on create)";
443
+ }
444
+ async create(input) {
445
+ const slug = input.slug ?? slugify(input.title);
446
+ const draft = { ...input, key: "(pending)", slug };
447
+ const html = descriptionHtml(draft);
448
+ const res = execMutate(
449
+ createArgs(this.coords(), input.title, html, columnForStatus(input.status, this.columns)),
450
+ this.opts.dryRun
451
+ );
452
+ if (!res.ran) return { ...draft, key: "(dry-run)" };
453
+ const id = JSON.parse(res.stdout).id;
454
+ return { ...draft, key: String(id) };
455
+ }
456
+ async get(key) {
457
+ const out2 = execRead(["az", "boards", "work-item", "show", "--id", key, "--output", "json", ...this.orgArgs()]);
458
+ const wi = JSON.parse(out2);
459
+ return parseWorkItem(wi.fields ?? {}, wi.id, this.columns);
460
+ }
461
+ async list() {
462
+ const wiql = "SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Issue' ORDER BY [System.Id]";
463
+ const out2 = execRead(["az", "boards", "query", "--wiql", wiql, "--output", "json", ...this.scopeArgs()]);
464
+ const rows = JSON.parse(out2);
465
+ const refs = [];
466
+ for (const row of rows) {
467
+ const id = row.id ?? row.fields?.["System.Id"];
468
+ if (id == null) continue;
469
+ const t = await this.get(String(id));
470
+ if (t) refs.push(toRef(t));
471
+ }
472
+ return refs;
473
+ }
474
+ async listReady() {
475
+ const all = await this.list();
476
+ const done = new Set(all.filter((t) => t.status === "Done").map((t) => t.key));
477
+ const ready = [];
478
+ const blocked = [];
479
+ for (const t of all) {
480
+ if (t.status !== "Pending") continue;
481
+ const unmet = t.dependencies.filter((d) => !done.has(d));
482
+ if (unmet.length === 0) ready.push(t);
483
+ else blocked.push({ ticket: t, blockedBy: unmet });
484
+ }
485
+ return { ready, blocked };
486
+ }
487
+ async setStatus(key, status) {
488
+ const current = await this.get(key);
489
+ if (!current) throw new Error(`work-item ${key} not found`);
490
+ execMutate(
491
+ ["az", "boards", "work-item", "update", "--id", key, "--fields", `System.State=${columnForStatus(status, this.columns)}`, ...this.orgArgs()],
492
+ this.opts.dryRun
493
+ );
494
+ return { ...current, status };
495
+ }
496
+ async start(key, _p) {
497
+ return this.setStatus(key, "In progress");
498
+ }
499
+ async amend(key, patch) {
500
+ const current = await this.get(key);
501
+ if (!current) throw new Error(`work-item ${key} not found`);
502
+ const merged = { ...current, ...patch, key, slug: current.slug };
503
+ const fields = [`System.Description=${descriptionHtml(merged)}`];
504
+ if (patch.title) fields.push(`System.Title=${patch.title}`);
505
+ const args = ["az", "boards", "work-item", "update", "--id", key];
506
+ for (const f of fields) args.push("--fields", f);
507
+ execMutate([...args, ...this.orgArgs()], this.opts.dryRun);
508
+ return merged;
509
+ }
510
+ async delete(key) {
511
+ execMutate(["az", "boards", "work-item", "delete", "--id", key, "--yes", ...this.scopeArgs()], this.opts.dryRun);
512
+ }
513
+ };
514
+ function toRef(t) {
515
+ return { key: t.key, title: t.title, status: t.status, slug: t.slug, dependencies: t.dependencies };
516
+ }
517
+
518
+ // src/providers/azure-discovery.ts
519
+ var defaultRunner = (args) => execRead(args);
520
+ function normalizeOrgUrl(input) {
521
+ const s = input.trim().replace(/\/+$/, "");
522
+ if (!s) return "";
523
+ if (/^https?:\/\//i.test(s)) return s;
524
+ if (/\b(dev\.azure\.com|visualstudio\.com)\b/i.test(s)) return `https://${s.replace(/^\/+/, "")}`;
525
+ return `https://dev.azure.com/${s}`;
526
+ }
527
+ function parseProjects(json) {
528
+ const data = JSON.parse(json);
529
+ const items = Array.isArray(data) ? data : data.value ?? [];
530
+ return items.map((p) => p?.name).filter((n) => typeof n === "string");
531
+ }
532
+ function listProjects(org, run = defaultRunner) {
533
+ const out2 = run(["az", "devops", "project", "list", "--org", org, "--output", "json"]);
534
+ return parseProjects(out2);
535
+ }
536
+ function parseProjectInfo(json) {
537
+ const d = JSON.parse(json);
538
+ return { processTemplate: d?.capabilities?.processTemplate?.templateName };
539
+ }
540
+ function getProjectInfo(org, project, run = defaultRunner) {
541
+ try {
542
+ return parseProjectInfo(
543
+ run(["az", "devops", "project", "show", "--project", project, "--org", org, "--output", "json"])
544
+ );
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+ function processSupportsIssues(template) {
550
+ return template !== "Agile" && template !== "Scrum";
551
+ }
552
+ function parseStates(json) {
553
+ const d = JSON.parse(json);
554
+ const items = Array.isArray(d) ? d : d.value ?? [];
555
+ return items.map((s) => ({ name: s?.name, category: s?.category })).filter((s) => typeof s.name === "string");
556
+ }
557
+ function listIssueStates(org, project, run = defaultRunner) {
558
+ const out2 = run([
559
+ "az",
560
+ "devops",
561
+ "invoke",
562
+ "--area",
563
+ "wit",
564
+ "--resource",
565
+ "workItemTypeStates",
566
+ "--route-parameters",
567
+ `project=${project}`,
568
+ "type=Issue",
569
+ "--org",
570
+ org,
571
+ "--detect",
572
+ "false",
573
+ "--output",
574
+ "json"
575
+ ]);
576
+ return parseStates(out2);
577
+ }
578
+ function statesInCategory(states, category) {
579
+ return states.filter((s) => s.category === category).map((s) => s.name);
580
+ }
581
+
582
+ // src/providers/github-discovery.ts
583
+ var defaultRunner2 = (args) => execRead(args);
584
+ function parseProjects2(json) {
585
+ const data = JSON.parse(json);
586
+ const items = Array.isArray(data) ? data : data.projects ?? [];
587
+ return items.map((p) => ({ number: p?.number, title: p?.title ?? "", id: p?.id ?? "" })).filter((p) => typeof p.number === "number");
588
+ }
589
+ function listProjects2(owner, run = defaultRunner2) {
590
+ const out2 = run(["gh", "project", "list", "--owner", owner, "--format", "json", "--limit", "100"]);
591
+ return parseProjects2(out2);
592
+ }
593
+ function resolveViewerLogin(run = defaultRunner2) {
594
+ return run(["gh", "api", "user", "-q", ".login"]).trim();
595
+ }
596
+ function tokenScopes(run = defaultRunner2) {
597
+ let out2;
598
+ try {
599
+ out2 = run(["gh", "api", "-i", "user"]);
600
+ } catch {
601
+ return null;
602
+ }
603
+ const m = /^x-oauth-scopes:\s*(.*)$/im.exec(out2);
604
+ if (!m) return null;
605
+ return m[1].split(",").map((s) => s.trim()).filter(Boolean);
606
+ }
607
+ function hasProjectWriteScope(run = defaultRunner2) {
608
+ const scopes = tokenScopes(run);
609
+ if (scopes == null) return null;
610
+ return scopes.includes("project");
611
+ }
612
+ function detectRepo(run = defaultRunner2) {
613
+ return run(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
614
+ }
615
+ function parseRepos(json) {
616
+ const data = JSON.parse(json);
617
+ const items = Array.isArray(data) ? data : data.repositories ?? [];
618
+ return items.map((r) => r?.nameWithOwner).filter((n) => typeof n === "string");
619
+ }
620
+ function listRepos(owner, run = defaultRunner2) {
621
+ const out2 = run(["gh", "repo", "list", owner, "--json", "nameWithOwner", "--limit", "200"]);
622
+ return parseRepos(out2);
623
+ }
624
+ function parseStatusField(json) {
625
+ const data = JSON.parse(json);
626
+ const fields = Array.isArray(data) ? data : data.fields ?? [];
627
+ const status = fields.find(
628
+ (f) => typeof f?.name === "string" && f.name.toLowerCase() === "status" && Array.isArray(f?.options)
629
+ );
630
+ if (!status) return null;
631
+ const options = status.options.map((o) => ({ id: o?.id, name: o?.name })).filter((o) => typeof o.id === "string" && typeof o.name === "string");
632
+ return { id: status.id, options };
633
+ }
634
+ function listStatusField(owner, number, run = defaultRunner2) {
635
+ const out2 = run([
636
+ "gh",
637
+ "project",
638
+ "field-list",
639
+ String(number),
640
+ "--owner",
641
+ owner,
642
+ "--format",
643
+ "json",
644
+ "--limit",
645
+ "100"
646
+ ]);
647
+ return parseStatusField(out2);
648
+ }
649
+ function parseProjectId(json) {
650
+ return JSON.parse(json)?.id ?? "";
651
+ }
652
+ function fetchProjectMeta(owner, number, run = defaultRunner2) {
653
+ const projectId = parseProjectId(
654
+ run(["gh", "project", "view", String(number), "--owner", owner, "--format", "json"])
655
+ );
656
+ const statusField = listStatusField(owner, number, run);
657
+ if (!statusField) {
658
+ throw new Error(`project #${number} (owner ${owner}) has no single-select "Status" field`);
659
+ }
660
+ return { projectId, statusField };
661
+ }
662
+ function optionIdFor(field, columnName) {
663
+ return field.options.find((o) => o.name.toLowerCase() === columnName.toLowerCase())?.id;
664
+ }
665
+
666
+ // src/prompt.ts
667
+ import { confirm as inqConfirm, input as inqInput, select as inqSelect } from "@inquirer/prompts";
668
+ function readlinePrompter() {
669
+ return {
670
+ async select(message, choices) {
671
+ if (choices.length === 0) throw new Error(`${message}: no choices available`);
672
+ if (!process.stdin.isTTY) {
673
+ throw new Error(`cannot prompt "${message}" in non-interactive mode \u2014 pass the corresponding flag`);
674
+ }
675
+ return inqSelect({
676
+ message,
677
+ choices: choices.map((c) => ({ value: c, name: c })),
678
+ loop: false
679
+ });
680
+ },
681
+ async input(message, def) {
682
+ if (!process.stdin.isTTY) return def ?? "";
683
+ return inqInput({ message, default: def });
684
+ },
685
+ async confirm(message, def = true) {
686
+ if (!process.stdin.isTTY) return def;
687
+ return inqConfirm({ message, default: def });
688
+ },
689
+ close() {
690
+ }
691
+ };
692
+ }
693
+
694
+ // src/commands/init.ts
695
+ var HOOK_COMMAND = "kodi hook session-start";
696
+ var SESSION_MATCHER = "startup|resume|clear|compact";
697
+ function mergeSessionStartHook(settings) {
698
+ settings.hooks ??= {};
699
+ const arr = settings.hooks.SessionStart ??= [];
700
+ const already = arr.some((e) => e.hooks?.some((h) => h.command === HOOK_COMMAND));
701
+ if (already) return false;
702
+ arr.push({ matcher: SESSION_MATCHER, hooks: [{ type: "command", command: HOOK_COMMAND }] });
703
+ return true;
704
+ }
705
+ function defaultAssetsDir() {
706
+ return fileURLToPath2(new URL("../assets/", import.meta.url));
707
+ }
708
+ function copyTree2(srcRoot, destRoot, force, reportBase) {
709
+ const written = [];
710
+ if (!existsSync3(srcRoot)) return written;
711
+ const walk = (src, dest) => {
712
+ for (const entry of readdirSync2(src)) {
713
+ const s = join3(src, entry);
714
+ const d = join3(dest, entry);
715
+ if (statSync2(s).isDirectory()) walk(s, d);
716
+ else {
717
+ if (existsSync3(d) && !force) continue;
718
+ mkdirSync2(dirname3(d), { recursive: true });
719
+ copyFileSync2(s, d);
720
+ written.push(join3(reportBase, relative(destRoot, d)));
721
+ }
722
+ }
723
+ };
724
+ walk(srcRoot, destRoot);
725
+ return written;
726
+ }
727
+ function copyMarkdownFlat(srcRoot, destDir, force, reportBase) {
728
+ const written = [];
729
+ if (!existsSync3(srcRoot)) return written;
730
+ const walk = (dir) => {
731
+ for (const entry of readdirSync2(dir)) {
732
+ const s = join3(dir, entry);
733
+ if (statSync2(s).isDirectory()) walk(s);
734
+ else if (entry.endsWith(".md") && entry !== "README.md") {
735
+ const d = join3(destDir, entry);
736
+ if (existsSync3(d) && !force) continue;
737
+ mkdirSync2(destDir, { recursive: true });
738
+ copyFileSync2(s, d);
739
+ written.push(join3(reportBase, entry));
740
+ }
741
+ }
742
+ };
743
+ walk(srcRoot);
744
+ return written;
745
+ }
746
+ function installHarness(root, opts = {}) {
747
+ const force = opts.force ?? false;
748
+ const assetsDir = opts.assetsDir ?? defaultAssetsDir();
749
+ const claude = join3(root, ".claude");
750
+ const changed = [];
751
+ mkdirSync2(claude, { recursive: true });
752
+ const settingsPath = join3(claude, "settings.json");
753
+ const settings = existsSync3(settingsPath) ? JSON.parse(readFileSync3(settingsPath, "utf-8")) : {};
754
+ if (mergeSessionStartHook(settings)) {
755
+ writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
756
+ changed.push(".claude/settings.json (SessionStart hook)");
757
+ }
758
+ changed.push(
759
+ ...copyTree2(join3(assetsDir, "skills"), join3(claude, "skills"), force, ".claude/skills"),
760
+ ...copyMarkdownFlat(join3(assetsDir, "agents"), join3(claude, "agents"), force, ".claude/agents"),
761
+ ...copyTree2(join3(assetsDir, "rules"), join3(claude, "rules"), force, ".claude/rules")
762
+ );
763
+ for (const sub of ["prd", "adr", "diagrams", "plan", "tickets", "security"]) {
764
+ mkdirSync2(join3(root, "docs", sub), { recursive: true });
765
+ }
766
+ return changed;
767
+ }
768
+ var InitAbort = class extends Error {
769
+ };
770
+ async function configureBoard(prompter, opts = {}) {
771
+ const provider = opts.provider ?? await prompter.select("Which board provider?", ["local", "github", "azure"]);
772
+ if (provider === "local") {
773
+ const prefix = opts.prefix ?? await prompter.input("Ticket key prefix", "KODI");
774
+ return { provider: "local", prefix: prefix || "KODI" };
775
+ }
776
+ if (provider === "github") {
777
+ return configureGithub(prompter, opts);
778
+ }
779
+ const orgInput = opts.org ?? await prompter.input("Azure DevOps organization (name or URL, e.g. acme)");
780
+ const org = normalizeOrgUrl(orgInput);
781
+ if (!org) throw new InitAbort("missing: organization.");
782
+ let projects;
783
+ try {
784
+ projects = listProjects(org, opts.runner);
785
+ } catch (e) {
786
+ throw new InitAbort(
787
+ `could not list projects for ${org}. Is \`az\` installed and logged in (az login / az devops login)? ${e instanceof Error ? e.message : ""}`
788
+ );
789
+ }
790
+ if (projects.length === 0) throw new InitAbort(`no projects found in ${org}.`);
791
+ let project = opts.project;
792
+ if (project) {
793
+ if (!projects.includes(project)) {
794
+ throw new InitAbort(`project "${project}" not found in ${org} (found: ${projects.join(", ")}).`);
795
+ }
796
+ } else {
797
+ project = await prompter.select("Select a project", projects);
798
+ }
799
+ const info = getProjectInfo(org, project, opts.runner);
800
+ if (!info) throw new InitAbort(`project "${project}" is not reachable in ${org}.`);
801
+ if (!processSupportsIssues(info.processTemplate)) {
802
+ throw new InitAbort(
803
+ `project "${project}" uses the ${info.processTemplate} process, which has no "Issue" work-item type. kodi creates tickets as Issues \u2014 use a Basic-process project (or add the Issue type), then re-run \`kodi init\`.`
804
+ );
805
+ }
806
+ let states = [];
807
+ try {
808
+ states = listIssueStates(org, project, opts.runner);
809
+ } catch {
810
+ }
811
+ const proposed = statesInCategory(states, "Proposed");
812
+ const inProg = statesInCategory(states, "InProgress");
813
+ const completed = statesInCategory(states, "Completed");
814
+ const pick = async (flag, message, candidates, def) => {
815
+ if (flag) return flag;
816
+ if (candidates.length === 1) return candidates[0];
817
+ if (candidates.length > 1) return prompter.select(message, candidates);
818
+ return await prompter.input(message, def) || def;
819
+ };
820
+ let todo;
821
+ if (opts.todoColumn) {
822
+ todo = opts.todoColumn;
823
+ } else if (states.length > 0 && proposed.length === 0) {
824
+ throw new InitAbort(
825
+ 'no To Do-type column: the Issue type on this board has no "Proposed" state where new issues can land. Add a To Do column/state to the board, then re-run `kodi init`.'
826
+ );
827
+ } else if (proposed.length > 0) {
828
+ todo = proposed.length === 1 ? proposed[0] : await prompter.select("To Do column (where new issues are created)", proposed);
829
+ } else {
830
+ todo = await prompter.input("To Do column (where new issues are created)", DEFAULT_COLUMNS.todo);
831
+ if (!todo) throw new InitAbort("missing: the To Do column.");
832
+ }
833
+ const columns = {
834
+ todo,
835
+ inProgress: await pick(opts.inProgressColumn, "In Progress column", inProg, DEFAULT_COLUMNS.inProgress),
836
+ toReview: await pick(opts.toReviewColumn, "To Review column", inProg, DEFAULT_COLUMNS.toReview),
837
+ done: await pick(opts.doneColumn, "Done column", completed, DEFAULT_COLUMNS.done)
838
+ };
839
+ const repository = opts.repository ?? (await prompter.input("Repository name for pull requests", project) || project);
840
+ return { provider: "azure", prefix: "KODI", organization: org, project, repository, columns };
841
+ }
842
+ async function configureGithub(prompter, opts) {
843
+ if (hasProjectWriteScope(opts.runner) === false) {
844
+ throw new InitAbort(
845
+ "your gh token can read Projects but not write to them (has read:project, missing project). Run `gh auth refresh -s project --hostname github.com`, then re-run `kodi init`."
846
+ );
847
+ }
848
+ let owner = opts.projectOwner;
849
+ if (!owner) {
850
+ const ownerType = opts.ownerType ?? await prompter.select("Is the board owned by an organization or a user?", ["organization", "user"]);
851
+ if (ownerType === "user") {
852
+ let login = "";
853
+ try {
854
+ login = resolveViewerLogin(opts.runner);
855
+ } catch {
856
+ }
857
+ owner = await prompter.input("GitHub user login (board owner)", login) || login;
858
+ } else {
859
+ owner = await prompter.input("GitHub organization login (board owner)");
860
+ }
861
+ }
862
+ if (!owner) throw new InitAbort("missing: project owner.");
863
+ let projects;
864
+ try {
865
+ projects = listProjects2(owner, opts.runner);
866
+ } catch (e) {
867
+ throw new InitAbort(
868
+ `could not list projects for ${owner}. Is \`gh\` installed and logged in (gh auth login), and does your token have the Projects scope (gh auth refresh -s project --hostname github.com)? ${e instanceof Error ? e.message : ""}`
869
+ );
870
+ }
871
+ if (projects.length === 0) throw new InitAbort(`no Projects v2 boards found for owner ${owner}.`);
872
+ let number = opts.projectNumber;
873
+ if (number != null) {
874
+ if (!projects.some((p) => p.number === number)) {
875
+ throw new InitAbort(
876
+ `project #${number} not found for ${owner} (found: ${projects.map((p) => `#${p.number} ${p.title}`).join(", ")}).`
877
+ );
878
+ }
879
+ } else {
880
+ const choice = await prompter.select(
881
+ "Select a project",
882
+ projects.map((p) => `#${p.number} ${p.title}`)
883
+ );
884
+ number = Number(choice.match(/#(\d+)/)[1]);
885
+ }
886
+ const statusField = listStatusField(owner, number, opts.runner);
887
+ if (!statusField) {
888
+ throw new InitAbort(
889
+ `project #${number} has no single-select "Status" field. Add a Status field to the board (every built-in board template has one), then re-run \`kodi init\`.`
890
+ );
891
+ }
892
+ const options = statusField.options.map((o) => o.name);
893
+ if (options.length === 0) throw new InitAbort(`the Status field on project #${number} has no options.`);
894
+ const pick = async (flag, message) => {
895
+ if (flag) return flag;
896
+ return options.length === 1 ? options[0] : prompter.select(message, options);
897
+ };
898
+ const columns = {
899
+ todo: await pick(opts.todoColumn, "To Do column (where new issues are created)"),
900
+ inProgress: await pick(opts.inProgressColumn, "In Progress column"),
901
+ toReview: await pick(opts.toReviewColumn, "To Review column"),
902
+ done: await pick(opts.doneColumn, "Done column")
903
+ };
904
+ let repository = opts.repository;
905
+ if (!repository) {
906
+ let detected = "";
907
+ try {
908
+ detected = detectRepo(opts.runner);
909
+ } catch {
910
+ }
911
+ let repos = [];
912
+ try {
913
+ repos = listRepos(owner, opts.runner);
914
+ } catch {
915
+ }
916
+ if (repos.length > 0) {
917
+ if (detected && repos.includes(detected)) repos = [detected, ...repos.filter((r) => r !== detected)];
918
+ repository = await prompter.select("Repository for issues", repos);
919
+ } else {
920
+ repository = await prompter.input("Repository for issues (owner/repo)", detected) || detected;
921
+ }
922
+ }
923
+ if (!repository) throw new InitAbort("missing: repository (owner/repo).");
924
+ return { provider: "github", prefix: "KODI", repository, projectOwner: owner, projectNumber: number, columns };
925
+ }
926
+ function writeState(root, config) {
927
+ const path = stateFilePath(root);
928
+ mkdirSync2(dirname3(path), { recursive: true });
929
+ writeFileSync2(path, stringifyYaml(config), "utf-8");
930
+ return path;
931
+ }
932
+ function registerInitCommand(program2) {
933
+ program2.command("init").description("Install the kodi harness and configure the board (interactive)").option("-d, --dir <path>", "target project directory", process.cwd()).option("--force", "overwrite existing agents/skills", false).option("--provider <local|github|azure>", "skip the provider prompt").option("--prefix <prefix>", "local ticket key prefix (default KODI)").option("--org <url>", "azure org URL (non-interactive)").option("--project <name>", "azure project (non-interactive)").option("--owner-type <org|user>", "github board owner type (non-interactive)").option("--project-owner <login>", "github Projects owner login (org or user)").option("--project-number <n>", "github Projects board number", (v) => Number(v)).option("--todo-column <name>", "To Do column (non-interactive)").option("--in-progress-column <name>", "In Progress column").option("--to-review-column <name>", "To Review column").option("--done-column <name>", "Done column").option("--repository <name>", "repository for PRs (azure: name; github: owner/repo)").action(async (o) => {
934
+ const root = String(o.dir);
935
+ let provider = o.provider;
936
+ if (!provider && !process.stdin.isTTY) provider = "local";
937
+ const prompter = readlinePrompter();
938
+ let config;
939
+ try {
940
+ config = await configureBoard(prompter, {
941
+ provider,
942
+ prefix: o.prefix,
943
+ org: o.org,
944
+ project: o.project,
945
+ ownerType: o.ownerType,
946
+ projectOwner: o.projectOwner,
947
+ projectNumber: o.projectNumber,
948
+ todoColumn: o.todoColumn,
949
+ inProgressColumn: o.inProgressColumn,
950
+ toReviewColumn: o.toReviewColumn,
951
+ doneColumn: o.doneColumn,
952
+ repository: o.repository
953
+ });
954
+ } catch (e) {
955
+ if (e instanceof InitAbort) {
956
+ process.stderr.write(`
957
+ kodi init aborted \u2014 ${e.message}
958
+ `);
959
+ process.exitCode = 1;
960
+ return;
961
+ }
962
+ throw e;
963
+ } finally {
964
+ prompter.close();
965
+ }
966
+ const changed = installHarness(root, { force: o.force });
967
+ const statePath = writeState(root, config);
968
+ process.stdout.write(
969
+ `
970
+ kodi init: installed
971
+ ${changed.map((c) => ` + ${c}`).join("\n")}
972
+ + ${relative(root, statePath)} (provider: ${config.provider})
973
+
974
+ SessionStart wired to \`${HOOK_COMMAND}\` (matchers: ${SESSION_MATCHER}).
975
+ `
976
+ );
977
+ });
978
+ }
979
+
980
+ // src/commands/pr.ts
981
+ import { mkdtempSync, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
982
+ import { tmpdir } from "os";
983
+ import { join as join4 } from "path";
984
+
985
+ // src/templates/pr.ts
986
+ import { z as z3 } from "zod";
987
+ var PrSchema = z3.object({
988
+ title: z3.string().min(3, "title must be at least 3 characters"),
989
+ summary: z3.string().min(1, "summary is required"),
990
+ includedChanges: z3.array(z3.string().min(1)).default([]),
991
+ features: z3.array(z3.string().min(1)).default([]),
992
+ fixes: z3.array(z3.string().min(1)).default([]),
993
+ improvements: z3.array(z3.string().min(1)).default([]),
994
+ relatedIssues: z3.array(z3.string().min(1)).default([]),
995
+ notes: z3.string().optional(),
996
+ reviewers: z3.array(z3.string().min(1)).default([])
997
+ });
998
+ function bulletSection(title, items) {
999
+ if (!items.length) return [];
1000
+ return ["", `## ${title}`, "", ...items.map((i) => `- ${i}`)];
1001
+ }
1002
+ function renderPrMarkdown(pr) {
1003
+ const lines = ["## Summary", "", pr.summary];
1004
+ lines.push(...bulletSection("Included changes", pr.includedChanges));
1005
+ lines.push(...bulletSection("Features", pr.features));
1006
+ lines.push(...bulletSection("Fixes", pr.fixes));
1007
+ lines.push(...bulletSection("Improvements", pr.improvements));
1008
+ lines.push(...bulletSection("Related issues", pr.relatedIssues));
1009
+ if (pr.notes) lines.push("", "## Notes", "", pr.notes);
1010
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1011
+ }
1012
+ function renderPrHtml(pr) {
1013
+ return mdToHtml(renderPrMarkdown(pr));
1014
+ }
1015
+
1016
+ // src/commands/pr.ts
1017
+ function resolveTarget(explicit) {
1018
+ if (explicit === "github" || explicit === "azure") return explicit;
1019
+ const p = loadBoardConfig().provider;
1020
+ if (p === "github" || p === "azure") return p;
1021
+ throw new Error(
1022
+ `no PR target: the active provider is "${p}". Configure github/azure with \`kodi init\`, or pass --provider.`
1023
+ );
1024
+ }
1025
+ function draftFromOptions(o) {
1026
+ if (o.file) return PrSchema.parse(JSON.parse(readFileSync4(String(o.file), "utf-8")));
1027
+ return PrSchema.parse({
1028
+ title: o.title,
1029
+ summary: o.summary,
1030
+ features: o.feature ?? [],
1031
+ fixes: o.fix ?? [],
1032
+ improvements: o.improvement ?? [],
1033
+ includedChanges: o.change ?? [],
1034
+ relatedIssues: o.issue ?? [],
1035
+ notes: o.notes,
1036
+ reviewers: o.reviewer ?? []
1037
+ });
1038
+ }
1039
+ function githubCreateArgs(pr, bodyFile, source, target, repo) {
1040
+ const args = ["gh", "pr", "create", "--title", pr.title, "--body-file", bodyFile, "--base", target, "--head", source];
1041
+ for (const r of pr.reviewers) args.push("--reviewer", r);
1042
+ if (repo) args.push("--repo", repo);
1043
+ return args;
1044
+ }
1045
+ function azureCreateArgs(pr, html, source, target, repo) {
1046
+ const args = [
1047
+ "az",
1048
+ "repos",
1049
+ "pr",
1050
+ "create",
1051
+ "--title",
1052
+ pr.title,
1053
+ "--description",
1054
+ html,
1055
+ "--source-branch",
1056
+ source,
1057
+ "--target-branch",
1058
+ target
1059
+ ];
1060
+ if (repo) args.push("--repository", repo);
1061
+ return args;
1062
+ }
1063
+ function registerPrCommand(program2) {
1064
+ const pr = program2.command("pr").description("Manage pull requests (proxy gh/az) with a validated template");
1065
+ pr.command("create").description("Create a PR from a validated template draft").option("-f, --file <path>", "JSON PR draft (validated against the template)").option("-t, --title <title>").option("-s, --summary <summary>").option("--feature <text>", "feature (repeatable)", collect, []).option("--fix <text>", "fix (repeatable)", collect, []).option("--improvement <text>", "improvement (repeatable)", collect, []).option("--change <text>", "included change (repeatable)", collect, []).option("--issue <ref>", "related issue (repeatable)", collect, []).option("--reviewer <name>", "reviewer (repeatable)", collect, []).option("--notes <text>").requiredOption("--source <branch>", "branch the PR is opened from").requiredOption("--target <branch>", "branch the PR merges into").option("--provider <github|azure>", "override the PR provider").option("--repository <repo>", "repository (gh: OWNER/REPO; az: name)").option("--yes", "execute (default: dry-run)", false).action((o) => {
1066
+ const draft = draftFromOptions(o);
1067
+ const target = resolveTarget(o.provider);
1068
+ const repo = o.repository ?? loadBoardConfig().repository;
1069
+ let args;
1070
+ if (target === "github") {
1071
+ const bodyFile = join4(mkdtempSync(join4(tmpdir(), "kodi-pr-")), "body.md");
1072
+ writeFileSync3(bodyFile, renderPrMarkdown(draft), "utf-8");
1073
+ args = githubCreateArgs(draft, bodyFile, o.source, o.target, repo);
1074
+ } else {
1075
+ args = azureCreateArgs(draft, renderPrHtml(draft), o.source, o.target, repo);
1076
+ }
1077
+ const res = execMutate(args, !o.yes);
1078
+ if (res.ran) process.stdout.write((res.stdout.trim() || "PR created") + "\n");
1079
+ });
1080
+ pr.command("list").description("List open pull requests").option("--provider <github|azure>", "override the PR provider").option("--repository <repo>").action((o) => {
1081
+ const target = resolveTarget(o.provider);
1082
+ const repo = o.repository ?? loadBoardConfig().repository;
1083
+ const args = target === "github" ? ["gh", "pr", "list", ...repo ? ["--repo", repo] : []] : ["az", "repos", "pr", "list", ...repo ? ["--repository", repo] : []];
1084
+ process.stdout.write(execRead(args));
1085
+ });
1086
+ pr.command("abandon <id>").description("Abandon/close a pull request").option("--provider <github|azure>", "override the PR provider").option("--repository <repo>").option("--yes", "execute (default: dry-run)", false).action((id, o) => {
1087
+ const target = resolveTarget(o.provider);
1088
+ const repo = o.repository ?? loadBoardConfig().repository;
1089
+ const args = target === "github" ? ["gh", "pr", "close", id, ...repo ? ["--repo", repo] : []] : ["az", "repos", "pr", "update", "--id", id, "--status", "abandoned"];
1090
+ execMutate(args, !o.yes);
1091
+ });
1092
+ return pr;
1093
+ }
1094
+ function collect(value, previous) {
1095
+ return previous.concat([value]);
1096
+ }
1097
+
1098
+ // src/commands/tickets.ts
1099
+ import { readFileSync as readFileSync6 } from "fs";
1100
+
1101
+ // src/providers/github.ts
1102
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync4 } from "fs";
1103
+ import { tmpdir as tmpdir2 } from "os";
1104
+ import { join as join5 } from "path";
1105
+ var MARKER_RE2 = /<!--\s*kodi:ticket\s+(\{[\s\S]*?\})\s*-->/;
1106
+ var DEFAULT_COLUMNS2 = {
1107
+ todo: "Todo",
1108
+ inProgress: "In Progress",
1109
+ toReview: "In Progress",
1110
+ done: "Done"
1111
+ };
1112
+ function columnForStatus2(status, cols) {
1113
+ switch (status) {
1114
+ case "In progress":
1115
+ return cols.inProgress ?? DEFAULT_COLUMNS2.inProgress;
1116
+ case "To review":
1117
+ return cols.toReview ?? DEFAULT_COLUMNS2.toReview;
1118
+ case "Done":
1119
+ return cols.done ?? DEFAULT_COLUMNS2.done;
1120
+ default:
1121
+ return cols.todo;
1122
+ }
1123
+ }
1124
+ function statusFromColumn2(column, cols) {
1125
+ if (column === cols.todo) return "Pending";
1126
+ if (column === (cols.inProgress ?? DEFAULT_COLUMNS2.inProgress)) return "In progress";
1127
+ if (column === (cols.toReview ?? DEFAULT_COLUMNS2.toReview)) return "To review";
1128
+ if (column === (cols.done ?? DEFAULT_COLUMNS2.done)) return "Done";
1129
+ return void 0;
1130
+ }
1131
+ function serializeBody(t) {
1132
+ const canonical = JSON.stringify({ ...t, key: void 0 });
1133
+ return `${renderTicketMarkdown(t)}
1134
+ <!-- kodi:ticket ${canonical} -->
1135
+ `;
1136
+ }
1137
+ function parseMarker(body) {
1138
+ const m = body ? MARKER_RE2.exec(body) : null;
1139
+ if (!m) return null;
1140
+ const parsed = TicketSchema.safeParse(JSON.parse(m[1]));
1141
+ return parsed.success ? parsed.data : null;
1142
+ }
1143
+ function parseItems(json) {
1144
+ const data = JSON.parse(json);
1145
+ const items = Array.isArray(data) ? data : data.items ?? [];
1146
+ const out2 = [];
1147
+ for (const it of items) {
1148
+ const content = it?.content ?? {};
1149
+ if (content.type !== "Issue" || typeof content.number !== "number" || typeof it?.id !== "string") continue;
1150
+ out2.push({
1151
+ itemId: it.id,
1152
+ issueNumber: content.number,
1153
+ statusName: typeof it?.status === "string" ? it.status : void 0,
1154
+ body: typeof content.body === "string" ? content.body : void 0
1155
+ });
1156
+ }
1157
+ return out2;
1158
+ }
1159
+ function createIssueArgs(repo, title, bodyFile) {
1160
+ const args = ["gh", "issue", "create", "--title", title, "--body-file", bodyFile];
1161
+ if (repo) args.push("--repo", repo);
1162
+ return args;
1163
+ }
1164
+ function itemAddArgs(owner, number, issueUrl) {
1165
+ return ["gh", "project", "item-add", String(number), "--owner", owner, "--url", issueUrl, "--format", "json"];
1166
+ }
1167
+ function itemEditArgs(projectId, itemId, fieldId, optionId) {
1168
+ return [
1169
+ "gh",
1170
+ "project",
1171
+ "item-edit",
1172
+ "--id",
1173
+ itemId,
1174
+ "--project-id",
1175
+ projectId,
1176
+ "--field-id",
1177
+ fieldId,
1178
+ "--single-select-option-id",
1179
+ optionId
1180
+ ];
1181
+ }
1182
+ var GithubTicketProvider = class {
1183
+ constructor(opts) {
1184
+ this.opts = opts;
1185
+ this.columns = opts.columns ?? DEFAULT_COLUMNS2;
1186
+ }
1187
+ opts;
1188
+ name = "github";
1189
+ columns;
1190
+ meta;
1191
+ itemsCache;
1192
+ repoArgs() {
1193
+ return this.opts.repo ? ["--repo", this.opts.repo] : [];
1194
+ }
1195
+ /** Resolve (and cache) the project + Status field node ids needed for writes. */
1196
+ projectMeta() {
1197
+ return this.meta ??= fetchProjectMeta(this.opts.owner, this.opts.number);
1198
+ }
1199
+ /** Read (and cache) all board items in one call. */
1200
+ items() {
1201
+ if (this.itemsCache) return this.itemsCache;
1202
+ const out2 = execRead([
1203
+ "gh",
1204
+ "project",
1205
+ "item-list",
1206
+ String(this.opts.number),
1207
+ "--owner",
1208
+ this.opts.owner,
1209
+ "--format",
1210
+ "json",
1211
+ "--limit",
1212
+ "500"
1213
+ ]);
1214
+ return this.itemsCache = parseItems(out2);
1215
+ }
1216
+ /** Body for an item — from item-list when present, else a per-issue fallback fetch. */
1217
+ bodyFor(item) {
1218
+ if (item.body != null) return item.body;
1219
+ return execRead(["gh", "issue", "view", String(item.issueNumber), "--json", "body", "-q", ".body", ...this.repoArgs()]);
1220
+ }
1221
+ toStored(item) {
1222
+ const t = parseMarker(this.bodyFor(item));
1223
+ if (!t) return null;
1224
+ const mapped = item.statusName ? statusFromColumn2(item.statusName, this.columns) : void 0;
1225
+ const status = mapped ?? t.status;
1226
+ return { ...t, key: String(item.issueNumber), slug: t.slug ?? slugify(t.title), status };
1227
+ }
1228
+ async nextId() {
1229
+ return "(assigned by github on create)";
1230
+ }
1231
+ async create(input) {
1232
+ const slug = input.slug ?? slugify(input.title);
1233
+ const draft = { ...input, key: "(pending)", slug };
1234
+ const bodyFile = writeTempBody(serializeBody(draft));
1235
+ const r1 = execMutate(createIssueArgs(this.opts.repo, input.title, bodyFile), this.opts.dryRun);
1236
+ if (!r1.ran) {
1237
+ execMutate(itemAddArgs(this.opts.owner, this.opts.number, "<issue-url>"), this.opts.dryRun);
1238
+ execMutate(itemEditArgs("<project-id>", "<item-id>", "<status-field-id>", "<option-id>"), this.opts.dryRun);
1239
+ return { ...draft, key: "(dry-run)" };
1240
+ }
1241
+ const url = r1.stdout.trim().split("\n").pop() ?? "";
1242
+ const num = url.match(/\/(\d+)\/?$/)?.[1] ?? "?";
1243
+ try {
1244
+ const add = execMutate(itemAddArgs(this.opts.owner, this.opts.number, url), false);
1245
+ const itemId = JSON.parse(add.stdout).id;
1246
+ const meta = this.projectMeta();
1247
+ const optionId = optionIdFor(meta.statusField, columnForStatus2(input.status, this.columns));
1248
+ if (optionId) execMutate(itemEditArgs(meta.projectId, itemId, meta.statusField.id, optionId), false);
1249
+ } catch (e) {
1250
+ throw new Error(
1251
+ `issue #${num} was created (${url}) but could not be added to project #${this.opts.number}: ${e instanceof Error ? e.message : String(e)}
1252
+ If your gh token lacks the \`project\` scope, run \`gh auth refresh -s project --hostname github.com\`, then attach it with:
1253
+ gh project item-add ${this.opts.number} --owner ${this.opts.owner} --url ${url}`
1254
+ );
1255
+ }
1256
+ return { ...draft, key: num };
1257
+ }
1258
+ async get(key) {
1259
+ const item = this.items().find((i) => String(i.issueNumber) === key);
1260
+ return item ? this.toStored(item) : null;
1261
+ }
1262
+ async list() {
1263
+ return this.items().map((i) => this.toStored(i)).filter((t) => t !== null).map(toRef2);
1264
+ }
1265
+ async listReady() {
1266
+ const all = await this.list();
1267
+ const done = new Set(all.filter((t) => t.status === "Done").map((t) => t.key));
1268
+ const ready = [];
1269
+ const blocked = [];
1270
+ for (const t of all) {
1271
+ if (t.status !== "Pending") continue;
1272
+ const unmet = t.dependencies.filter((d) => !done.has(d));
1273
+ if (unmet.length === 0) ready.push(t);
1274
+ else blocked.push({ ticket: t, blockedBy: unmet });
1275
+ }
1276
+ return { ready, blocked };
1277
+ }
1278
+ async setStatus(key, status) {
1279
+ const item = this.items().find((i) => String(i.issueNumber) === key);
1280
+ if (!item) throw new Error(`issue ${key} is not on project #${this.opts.number}`);
1281
+ const current = this.toStored(item);
1282
+ if (!current) throw new Error(`issue ${key} has no kodi marker`);
1283
+ const meta = this.projectMeta();
1284
+ const optionId = optionIdFor(meta.statusField, columnForStatus2(status, this.columns));
1285
+ if (!optionId) throw new Error(`no Status option maps to "${status}" on project #${this.opts.number}`);
1286
+ execMutate(itemEditArgs(meta.projectId, item.itemId, meta.statusField.id, optionId), this.opts.dryRun);
1287
+ return { ...current, status };
1288
+ }
1289
+ async start(key, _p) {
1290
+ return this.setStatus(key, "In progress");
1291
+ }
1292
+ async amend(key, patch) {
1293
+ const current = await this.get(key);
1294
+ if (!current) throw new Error(`issue ${key} not found`);
1295
+ const merged = { ...current, ...patch, key, slug: current.slug };
1296
+ const bodyFile = writeTempBody(serializeBody(merged));
1297
+ const args = ["gh", "issue", "edit", key, "--body-file", bodyFile, ...this.repoArgs()];
1298
+ if (patch.title) args.push("--title", patch.title);
1299
+ execMutate(args, this.opts.dryRun);
1300
+ return merged;
1301
+ }
1302
+ async delete(key) {
1303
+ execMutate(["gh", "issue", "delete", key, "--yes", ...this.repoArgs()], this.opts.dryRun);
1304
+ }
1305
+ };
1306
+ function toRef2(t) {
1307
+ return { key: t.key, title: t.title, status: t.status, slug: t.slug, dependencies: t.dependencies };
1308
+ }
1309
+ function writeTempBody(body) {
1310
+ const file = join5(mkdtempSync2(join5(tmpdir2(), "kodi-gh-")), "body.md");
1311
+ writeFileSync4(file, body, "utf-8");
1312
+ return file;
1313
+ }
1314
+
1315
+ // src/providers/local.ts
1316
+ import {
1317
+ existsSync as existsSync4,
1318
+ mkdirSync as mkdirSync3,
1319
+ readdirSync as readdirSync3,
1320
+ readFileSync as readFileSync5,
1321
+ rmSync,
1322
+ writeFileSync as writeFileSync5
1323
+ } from "fs";
1324
+ import { join as join6 } from "path";
1325
+ import { parse as parseYaml3, stringify as stringifyYaml2 } from "yaml";
1326
+ var FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/;
1327
+ var LocalTicketProvider = class {
1328
+ name = "local";
1329
+ prefix;
1330
+ paths;
1331
+ constructor(prefix, cwd = process.cwd()) {
1332
+ this.prefix = prefix;
1333
+ this.paths = localPaths(cwd);
1334
+ }
1335
+ ensureDirs() {
1336
+ mkdirSync3(this.paths.backlog, { recursive: true });
1337
+ mkdirSync3(this.paths.done, { recursive: true });
1338
+ }
1339
+ fileName(t) {
1340
+ return `${t.key}-${t.slug}.md`;
1341
+ }
1342
+ /** Directory a ticket file lives in for a given status. */
1343
+ dirFor(status) {
1344
+ return status === "Done" ? this.paths.done : this.paths.backlog;
1345
+ }
1346
+ allFiles() {
1347
+ const out2 = [];
1348
+ for (const dir of [this.paths.backlog, this.paths.done]) {
1349
+ if (!existsSync4(dir)) continue;
1350
+ for (const file of readdirSync3(dir)) {
1351
+ if (file.endsWith(".md")) out2.push({ dir, file });
1352
+ }
1353
+ }
1354
+ return out2;
1355
+ }
1356
+ parseFile(dir, file) {
1357
+ const raw = readFileSync5(join6(dir, file), "utf-8");
1358
+ const m = FRONTMATTER.exec(raw);
1359
+ if (!m) return null;
1360
+ const meta = parseYaml3(m[1]) ?? {};
1361
+ const parsed = TicketSchema.safeParse(meta);
1362
+ if (!parsed.success || !parsed.data.key || !parsed.data.slug) return null;
1363
+ return parsed.data;
1364
+ }
1365
+ locate(key) {
1366
+ for (const { dir, file } of this.allFiles()) {
1367
+ const ticket = this.parseFile(dir, file);
1368
+ if (ticket && ticket.key === key) return { dir, file, ticket };
1369
+ }
1370
+ return null;
1371
+ }
1372
+ persist(ticket) {
1373
+ this.ensureDirs();
1374
+ const existing = this.locate(ticket.key);
1375
+ if (existing) rmSync(join6(existing.dir, existing.file));
1376
+ const dir = this.dirFor(ticket.status);
1377
+ const body = `---
1378
+ ${stringifyYaml2(sortTicket(ticket)).trimEnd()}
1379
+ ---
1380
+
1381
+ ` + renderTicketMarkdown(ticket);
1382
+ writeFileSync5(join6(dir, this.fileName(ticket)), body, "utf-8");
1383
+ this.writeIndex();
1384
+ }
1385
+ async nextId(prefix = this.prefix) {
1386
+ let max = 0;
1387
+ for (const { dir, file } of this.allFiles()) {
1388
+ const t = this.parseFile(dir, file);
1389
+ if (!t) continue;
1390
+ const m = /^([A-Z][A-Z0-9]*)-(\d+)$/.exec(t.key);
1391
+ if (m && m[1] === prefix) max = Math.max(max, Number(m[2]));
1392
+ }
1393
+ return `${prefix}-${String(max + 1).padStart(3, "0")}`;
1394
+ }
1395
+ async create(input) {
1396
+ const key = input.key ?? await this.nextId(this.prefix);
1397
+ if (this.locate(key)) throw new Error(`ticket ${key} already exists`);
1398
+ const slug = input.slug ?? slugify(input.title);
1399
+ const ticket = { ...input, key, slug };
1400
+ this.persist(ticket);
1401
+ return ticket;
1402
+ }
1403
+ async get(key) {
1404
+ return this.locate(key)?.ticket ?? null;
1405
+ }
1406
+ async list() {
1407
+ const refs = [];
1408
+ for (const { dir, file } of this.allFiles()) {
1409
+ const t = this.parseFile(dir, file);
1410
+ if (t) refs.push(toRef3(t));
1411
+ }
1412
+ return refs.sort((a, b) => a.key.localeCompare(b.key, void 0, { numeric: true }));
1413
+ }
1414
+ async listReady() {
1415
+ const all = await this.list();
1416
+ const doneKeys = new Set(all.filter((t) => t.status === "Done").map((t) => t.key));
1417
+ const ready = [];
1418
+ const blocked = [];
1419
+ for (const t of all) {
1420
+ if (t.status !== "Pending") continue;
1421
+ const unmet = t.dependencies.filter((d) => !doneKeys.has(d));
1422
+ if (unmet.length === 0) ready.push(t);
1423
+ else blocked.push({ ticket: t, blockedBy: unmet });
1424
+ }
1425
+ return { ready, blocked };
1426
+ }
1427
+ async setStatus(key, status) {
1428
+ const found = this.locate(key);
1429
+ if (!found) throw new Error(`ticket ${key} not found`);
1430
+ const ticket = { ...found.ticket, status };
1431
+ this.persist(ticket);
1432
+ return ticket;
1433
+ }
1434
+ async start(key, _provenance) {
1435
+ return this.setStatus(key, "In progress");
1436
+ }
1437
+ async amend(key, patch) {
1438
+ const found = this.locate(key);
1439
+ if (!found) throw new Error(`ticket ${key} not found`);
1440
+ const ticket = { ...found.ticket, ...patch, key, slug: found.ticket.slug };
1441
+ this.persist(ticket);
1442
+ return ticket;
1443
+ }
1444
+ async delete(key) {
1445
+ const found = this.locate(key);
1446
+ if (!found) throw new Error(`ticket ${key} not found`);
1447
+ rmSync(join6(found.dir, found.file));
1448
+ this.writeIndex();
1449
+ }
1450
+ writeIndex() {
1451
+ const refs = [];
1452
+ for (const { dir, file } of this.allFiles()) {
1453
+ const t = this.parseFile(dir, file);
1454
+ if (t) refs.push(toRef3(t));
1455
+ }
1456
+ refs.sort((a, b) => a.key.localeCompare(b.key, void 0, { numeric: true }));
1457
+ const lines = ["# Tickets", "", "| Key | Title | Status | Depends on |", "|---|---|---|---|"];
1458
+ for (const t of refs) {
1459
+ lines.push(`| ${t.key} | ${t.title} | ${t.status} | ${t.dependencies.join(", ") || "\u2014"} |`);
1460
+ }
1461
+ lines.push("");
1462
+ writeFileSync5(this.paths.index, lines.join("\n"), "utf-8");
1463
+ }
1464
+ };
1465
+ function toRef3(t) {
1466
+ return { key: t.key, title: t.title, status: t.status, slug: t.slug, dependencies: t.dependencies };
1467
+ }
1468
+ function sortTicket(t) {
1469
+ return {
1470
+ key: t.key,
1471
+ title: t.title,
1472
+ slug: t.slug,
1473
+ status: t.status,
1474
+ dependencies: t.dependencies,
1475
+ drivers: t.drivers,
1476
+ summary: t.summary,
1477
+ acceptanceCriteria: t.acceptanceCriteria,
1478
+ nonGoals: t.nonGoals,
1479
+ ...t.prUrl ? { prUrl: t.prUrl } : {},
1480
+ ...t.notes ? { notes: t.notes } : {}
1481
+ };
1482
+ }
1483
+
1484
+ // src/providers/index.ts
1485
+ function resolveProvider(cwd = process.cwd(), opts = {}) {
1486
+ const cfg = loadBoardConfig(cwd);
1487
+ switch (cfg.provider) {
1488
+ case "local":
1489
+ return new LocalTicketProvider(cfg.prefix, cwd);
1490
+ case "github":
1491
+ return new GithubTicketProvider({
1492
+ repo: cfg.repository,
1493
+ owner: cfg.projectOwner ?? "",
1494
+ number: cfg.projectNumber ?? 0,
1495
+ columns: cfg.columns,
1496
+ dryRun: !opts.yes,
1497
+ cwd
1498
+ });
1499
+ case "azure":
1500
+ return new AzureTicketProvider({
1501
+ organization: cfg.organization,
1502
+ project: cfg.project,
1503
+ columns: cfg.columns,
1504
+ dryRun: !opts.yes,
1505
+ cwd
1506
+ });
1507
+ default:
1508
+ return new LocalTicketProvider(cfg.prefix, cwd);
1509
+ }
1510
+ }
1511
+
1512
+ // src/commands/tickets.ts
1513
+ function out(data, json, human) {
1514
+ if (json) process.stdout.write(JSON.stringify(data) + "\n");
1515
+ else process.stdout.write(human() + "\n");
1516
+ }
1517
+ function draftFromOptions2(o) {
1518
+ if (o.file) {
1519
+ const raw = JSON.parse(readFileSync6(String(o.file), "utf-8"));
1520
+ return TicketSchema.parse(raw);
1521
+ }
1522
+ return TicketSchema.parse({
1523
+ title: o.title,
1524
+ summary: o.summary,
1525
+ acceptanceCriteria: o.ac ?? [],
1526
+ nonGoals: o.nonGoal ?? [],
1527
+ dependencies: o.dep ?? [],
1528
+ drivers: {
1529
+ prd: o.prd,
1530
+ adr: o.adr ?? [],
1531
+ security: o.security
1532
+ },
1533
+ notes: o.notes
1534
+ });
1535
+ }
1536
+ function registerTicketsCommand(program2) {
1537
+ const tickets = program2.command("tickets").description("Manage tickets across the active provider (local / github / azure)");
1538
+ tickets.command("create").description("Create a ticket (validates the template)").option("-f, --file <path>", "JSON draft file (validated against the ticket template)").option("-t, --title <title>").option("-s, --summary <summary>").option("--ac <criterion>", "acceptance criterion (repeatable)", collect2, []).option("--non-goal <text>", "non-goal (repeatable)", collect2, []).option("--dep <key>", "dependency ticket key (repeatable)", collect2, []).option("--prd <ref>", "PRD driver").option("--adr <ref>", "ADR driver (repeatable)", collect2, []).option("--security <ref>", "security driver").option("--notes <text>").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (o) => {
1539
+ const draft = draftFromOptions2(o);
1540
+ const created = await resolveProvider(process.cwd(), { yes: o.yes }).create(draft);
1541
+ out(created, o.json, () => `Created ${created.key} (${created.status}) \u2014 ${created.title}`);
1542
+ });
1543
+ tickets.command("list").description("List all tickets").option("--json", "machine-readable output", false).action(async (o) => {
1544
+ const refs = await resolveProvider().list();
1545
+ out(
1546
+ refs,
1547
+ o.json,
1548
+ () => refs.length ? refs.map((t) => `${t.key} ${t.status.padEnd(12)} ${t.title}`).join("\n") : "No tickets."
1549
+ );
1550
+ });
1551
+ tickets.command("list-ready").description("List tickets ready to start (no unmet dependency) + the blocked set").option("--json", "machine-readable output", false).action(async (o) => {
1552
+ const res = await resolveProvider().listReady();
1553
+ out(res, o.json, () => {
1554
+ const ready = res.ready.map((t) => ` ${t.key} ${t.title}`).join("\n") || " (none)";
1555
+ const blocked = res.blocked.map((b) => ` ${b.ticket.key} ${b.ticket.title} \u2190 ${b.blockedBy.join(", ")}`).join("\n") || " (none)";
1556
+ return `Ready:
1557
+ ${ready}
1558
+
1559
+ Blocked:
1560
+ ${blocked}`;
1561
+ });
1562
+ });
1563
+ tickets.command("get <key>").description("Show one ticket").option("--json", "machine-readable output", false).action(async (key, o) => {
1564
+ const t = await resolveProvider().get(key);
1565
+ if (!t) {
1566
+ process.stderr.write(`ticket ${key} not found
1567
+ `);
1568
+ process.exitCode = 1;
1569
+ return;
1570
+ }
1571
+ out(t, o.json, () => JSON.stringify(t, null, 2));
1572
+ });
1573
+ tickets.command("set-status <key> <status>").description(`Transition a ticket (${TICKET_STATUSES.join(" | ")})`).option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, status, o) => {
1574
+ if (!TICKET_STATUSES.includes(status)) {
1575
+ process.stderr.write(`invalid status "${status}". One of: ${TICKET_STATUSES.join(", ")}
1576
+ `);
1577
+ process.exitCode = 1;
1578
+ return;
1579
+ }
1580
+ const t = await resolveProvider(process.cwd(), { yes: o.yes }).setStatus(key, status);
1581
+ out(t, o.json, () => `${t.key} \u2192 ${t.status}`);
1582
+ });
1583
+ tickets.command("start <key>").description("Mark a ticket started (In progress)").option("--branch <name>").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
1584
+ const t = await resolveProvider(process.cwd(), { yes: o.yes }).start(key, { branch: o.branch });
1585
+ out(t, o.json, () => `${t.key} \u2192 ${t.status}`);
1586
+ });
1587
+ tickets.command("delete <key>").description("Delete a ticket").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
1588
+ await resolveProvider(process.cwd(), { yes: o.yes }).delete(key);
1589
+ out({ deleted: key }, o.json, () => `Deleted ${key}`);
1590
+ });
1591
+ tickets.command("next-id").description("Compute the next ticket key").option("--json", "machine-readable output", false).action(async (o) => {
1592
+ const id = await resolveProvider().nextId();
1593
+ out({ nextId: id }, o.json, () => id);
1594
+ });
1595
+ tickets.command("amend <key>").description("Edit a ticket (from --file patch or flags)").option("-f, --file <path>", "JSON patch (validated against the template)").option("-s, --summary <text>").option("--notes <text>").option("--ac <criterion>", "replace acceptance criteria (repeatable)", collect2, []).option("--dep <key>", "replace dependencies (repeatable)", collect2, []).option("--pr <ref>", "link a PR").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
1596
+ const patch = o.file ? TicketSchema.partial().parse(JSON.parse(readFileSync6(String(o.file), "utf-8"))) : buildPatch(o);
1597
+ const t = await resolveProvider(process.cwd(), { yes: o.yes }).amend(key, patch);
1598
+ out(t, o.json, () => `Amended ${t.key}`);
1599
+ });
1600
+ tickets.command("link-pr <key> <pr>").description("Bind a pull request to a ticket").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, pr, o) => {
1601
+ const t = await resolveProvider(process.cwd(), { yes: o.yes }).amend(key, { prUrl: pr });
1602
+ out(t, o.json, () => `${t.key} \u2194 ${pr}`);
1603
+ });
1604
+ tickets.command("deps <key>").description("Read a ticket's dependencies, or declare new ones with --add").option("--add <key>", "declare a dependency (repeatable)", collect2, []).option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
1605
+ const provider = resolveProvider(process.cwd(), { yes: o.yes });
1606
+ const current = await provider.get(key);
1607
+ if (!current) {
1608
+ process.stderr.write(`ticket ${key} not found
1609
+ `);
1610
+ process.exitCode = 1;
1611
+ return;
1612
+ }
1613
+ if (o.add.length) {
1614
+ const deps = Array.from(/* @__PURE__ */ new Set([...current.dependencies, ...o.add]));
1615
+ const t = await provider.amend(key, { dependencies: deps });
1616
+ out(t.dependencies, o.json, () => `${key} deps: ${t.dependencies.join(", ") || "(none)"}`);
1617
+ } else {
1618
+ out(current.dependencies, o.json, () => `${key} deps: ${current.dependencies.join(", ") || "(none)"}`);
1619
+ }
1620
+ });
1621
+ tickets.command("hand-off <key>").description("End of slice: move to To review and optionally link the PR").option("--pr <ref>", "link the PR at hand-off").option("--yes", "execute remote mutations (default: dry-run)", false).option("--json", "machine-readable output", false).action(async (key, o) => {
1622
+ const provider = resolveProvider(process.cwd(), { yes: o.yes });
1623
+ if (o.pr) await provider.amend(key, { prUrl: o.pr });
1624
+ const t = await provider.setStatus(key, "To review");
1625
+ out(t, o.json, () => `${t.key} \u2192 ${t.status}${o.pr ? ` (PR ${o.pr})` : ""}`);
1626
+ });
1627
+ return tickets;
1628
+ }
1629
+ function collect2(value, previous) {
1630
+ return previous.concat([value]);
1631
+ }
1632
+ function buildPatch(o) {
1633
+ const patch = {};
1634
+ if (o.summary !== void 0) patch.summary = o.summary;
1635
+ if (o.notes !== void 0) patch.notes = o.notes;
1636
+ if (o.ac?.length) patch.acceptanceCriteria = o.ac;
1637
+ if (o.dep?.length) patch.dependencies = o.dep;
1638
+ if (o.pr) patch.prUrl = o.pr;
1639
+ return patch;
1640
+ }
1641
+
1642
+ // src/index.ts
1643
+ var program = new Command();
1644
+ program.name("kodi").description("kodi.dev \u2014 Claude Code-native agent orchestrator").version("0.0.0");
1645
+ registerTicketsCommand(program);
1646
+ registerPrCommand(program);
1647
+ registerHookCommand(program);
1648
+ registerInitCommand(program);
1649
+ registerAddCommand(program);
1650
+ program.parseAsync(process.argv).catch((err) => {
1651
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
1652
+ `);
1653
+ process.exitCode = 1;
1654
+ });