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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/assets/agents/briefing/brief.md +89 -0
- package/assets/agents/briefing/brownfield-wu.md +75 -0
- package/assets/agents/briefing/greenfield-wu.md +69 -0
- package/assets/agents/build/backend-engineer.md +54 -0
- package/assets/agents/build/backend-tester.md +52 -0
- package/assets/agents/build/build-orchestrator.md +71 -0
- package/assets/agents/build/frontend-engineer.md +53 -0
- package/assets/agents/build/frontend-tester.md +52 -0
- package/assets/agents/build/qa-implementation.md +54 -0
- package/assets/agents/build/qa-visual.md +44 -0
- package/assets/agents/build/security.md +56 -0
- package/assets/agents/planning/architect.md +61 -0
- package/assets/agents/planning/brand.md +46 -0
- package/assets/agents/planning/component-engineer.md +60 -0
- package/assets/agents/planning/data-engineer.md +60 -0
- package/assets/agents/planning/detail.md +62 -0
- package/assets/agents/planning/phases.md +55 -0
- package/assets/agents/planning/qa-planning.md +56 -0
- package/assets/agents/planning/researcher.md +53 -0
- package/assets/agents/planning/system-architect.md +57 -0
- package/assets/agents/planning/ux-lead.md +56 -0
- package/assets/rules/ticket-completion.md +28 -0
- package/assets/skills/discover/SKILL.md +63 -0
- package/assets/skills/oplan/SKILL.md +37 -0
- package/assets/skills/oreplan/SKILL.md +23 -0
- package/assets/skills/ticket-start/SKILL.md +33 -0
- package/assets/skills/tickets/SKILL.md +21 -0
- package/dist/index.js +1654 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
});
|