@kitsy/coop 0.0.1

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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kitsy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @kitsy/coop
2
+
3
+ CLI package for COOP.
4
+
5
+ Commands:
6
+ - `coop`
7
+
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,1049 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import fs6 from "fs";
5
+ import path7 from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { Command } from "commander";
8
+
9
+ // src/commands/create.ts
10
+ import fs2 from "fs";
11
+ import path2 from "path";
12
+ import {
13
+ IdeaStatus,
14
+ TaskStatus,
15
+ TaskType,
16
+ stringifyFrontmatter,
17
+ validateStructural,
18
+ writeTask
19
+ } from "@kitsy/coop-core";
20
+
21
+ // src/utils/prompt.ts
22
+ import readline from "readline/promises";
23
+ import process2 from "process";
24
+ async function ask(question, defaultValue = "") {
25
+ const rl = readline.createInterface({
26
+ input: process2.stdin,
27
+ output: process2.stdout
28
+ });
29
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
30
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
31
+ rl.close();
32
+ return answer || defaultValue;
33
+ }
34
+
35
+ // src/utils/not-implemented.ts
36
+ function printNotImplemented(command, phase) {
37
+ console.log(`${command}: Not yet implemented - coming in Phase ${phase}.`);
38
+ }
39
+
40
+ // src/utils/repo.ts
41
+ import fs from "fs";
42
+ import path from "path";
43
+ import { parseTaskFile, parseYamlFile } from "@kitsy/coop-core";
44
+ function resolveRepoRoot(start = process.cwd()) {
45
+ let current = path.resolve(start);
46
+ while (true) {
47
+ if (fs.existsSync(path.join(current, ".git"))) {
48
+ return current;
49
+ }
50
+ const parent = path.dirname(current);
51
+ if (parent === current) {
52
+ return path.resolve(start);
53
+ }
54
+ current = parent;
55
+ }
56
+ }
57
+ function coopDir(root) {
58
+ return path.join(root, ".coop");
59
+ }
60
+ function ensureCoopInitialized(root) {
61
+ const dir = coopDir(root);
62
+ if (!fs.existsSync(dir)) {
63
+ throw new Error(`Missing .coop directory at ${dir}. Run 'coop init'.`);
64
+ }
65
+ return dir;
66
+ }
67
+ function readCoopConfig(root) {
68
+ const configPath = path.join(coopDir(root), "config.yml");
69
+ if (!fs.existsSync(configPath)) {
70
+ return { ideaPrefix: "IDEA", taskPrefix: "PM" };
71
+ }
72
+ const config = parseYamlFile(configPath);
73
+ const idPrefixesRaw = config.id_prefixes;
74
+ const idPrefixes = typeof idPrefixesRaw === "object" && idPrefixesRaw !== null ? idPrefixesRaw : {};
75
+ const ideaPrefix = typeof idPrefixes.idea === "string" ? idPrefixes.idea : "IDEA";
76
+ const taskPrefix = typeof idPrefixes.task === "string" ? idPrefixes.task : "PM";
77
+ return { ideaPrefix, taskPrefix };
78
+ }
79
+ function walkFiles(dirPath, extensions) {
80
+ if (!fs.existsSync(dirPath)) return [];
81
+ const out = [];
82
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
83
+ for (const entry of entries) {
84
+ const fullPath = path.join(dirPath, entry.name);
85
+ if (entry.isDirectory()) {
86
+ out.push(...walkFiles(fullPath, extensions));
87
+ continue;
88
+ }
89
+ if (!entry.isFile()) continue;
90
+ const ext = path.extname(entry.name).toLowerCase();
91
+ if (extensions.has(ext)) {
92
+ out.push(fullPath);
93
+ }
94
+ }
95
+ return out.sort((a, b) => a.localeCompare(b));
96
+ }
97
+ function listTaskFiles(root) {
98
+ return walkFiles(path.join(ensureCoopInitialized(root), "tasks"), /* @__PURE__ */ new Set([".md"]));
99
+ }
100
+ function listIdeaFiles(root) {
101
+ return walkFiles(path.join(ensureCoopInitialized(root), "ideas"), /* @__PURE__ */ new Set([".md"]));
102
+ }
103
+ function findTaskFileById(root, id) {
104
+ const target = `${id}.md`.toLowerCase();
105
+ const match = listTaskFiles(root).find((filePath) => path.basename(filePath).toLowerCase() === target);
106
+ if (!match) {
107
+ throw new Error(`Task '${id}' not found in .coop/tasks.`);
108
+ }
109
+ return match;
110
+ }
111
+ function nextNumericId(prefix, ids) {
112
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113
+ const re = new RegExp(`^${escaped}-(\\d+)$`);
114
+ let max = 0;
115
+ for (const id of ids) {
116
+ const match = re.exec(id);
117
+ if (!match) continue;
118
+ const value = Number(match[1]);
119
+ if (Number.isInteger(value) && value > max) max = value;
120
+ }
121
+ return `${prefix}-${String(max + 1).padStart(3, "0")}`;
122
+ }
123
+ function todayIsoDate() {
124
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
125
+ }
126
+
127
+ // src/commands/create.ts
128
+ function parseCsv(value) {
129
+ if (!value) return [];
130
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
131
+ }
132
+ function registerCreateCommand(program2) {
133
+ const create = program2.command("create").description("Create COOP entities");
134
+ create.command("task").description("Create a task").option("--id <id>", "Task id").option("--title <title>", "Task title").option("--type <type>", `Task type (${Object.values(TaskType).join(", ")})`).option("--status <status>", `Task status (${Object.values(TaskStatus).join(", ")})`).option("--track <track>", "Track id", "unassigned").option("--priority <priority>", "Task priority", "p2").option("--body <body>", "Markdown body").action(async (options) => {
135
+ const root = resolveRepoRoot();
136
+ const coop = ensureCoopInitialized(root);
137
+ const { taskPrefix } = readCoopConfig(root);
138
+ const existingIds = listTaskFiles(root).map((filePath2) => path2.basename(filePath2, ".md"));
139
+ const id = (options.id?.trim() || nextNumericId(taskPrefix, existingIds)).toUpperCase();
140
+ const title = options.title?.trim() || await ask("Task title");
141
+ if (!title) throw new Error("Task title is required.");
142
+ const typeInput = (options.type?.trim() || await ask("Task type", "feature")).toLowerCase();
143
+ const statusInput = (options.status?.trim() || await ask("Task status", "todo")).toLowerCase();
144
+ const track = options.track?.trim() || await ask("Track", "unassigned");
145
+ const priority = options.priority?.trim() || await ask("Priority", "p2");
146
+ const body = options.body ?? await ask("Task body (optional)", "");
147
+ const date = todayIsoDate();
148
+ const task = {
149
+ id,
150
+ title,
151
+ type: typeInput,
152
+ status: statusInput,
153
+ created: date,
154
+ updated: date,
155
+ track,
156
+ priority
157
+ };
158
+ const filePath = path2.join(coop, "tasks", `${id}.md`);
159
+ const structuralIssues = validateStructural(task, { filePath });
160
+ if (structuralIssues.length > 0) {
161
+ const message = structuralIssues.map((issue) => `- ${issue.message}`).join("\n");
162
+ throw new Error(`Task failed structural validation:
163
+ ${message}`);
164
+ }
165
+ writeTask(task, {
166
+ body,
167
+ filePath
168
+ });
169
+ console.log(`Created task: ${path2.relative(root, filePath)}`);
170
+ });
171
+ create.command("idea").description("Create an idea").option("--id <id>", "Idea id").option("--title <title>", "Idea title").option("--author <author>", "Idea author").option("--source <source>", "Idea source", "manual").option("--status <status>", `Idea status (${Object.values(IdeaStatus).join(", ")})`).option("--tags <tags>", "Comma-separated tags").option("--body <body>", "Markdown body").action(async (options) => {
172
+ const root = resolveRepoRoot();
173
+ const coop = ensureCoopInitialized(root);
174
+ const { ideaPrefix } = readCoopConfig(root);
175
+ const existingIds = listIdeaFiles(root).map((filePath2) => path2.basename(filePath2, ".md"));
176
+ const id = (options.id?.trim() || nextNumericId(ideaPrefix, existingIds)).toUpperCase();
177
+ const title = options.title?.trim() || await ask("Idea title");
178
+ if (!title) throw new Error("Idea title is required.");
179
+ const author = options.author?.trim() || await ask("Author", "unknown");
180
+ const source = options.source?.trim() || await ask("Source", "manual");
181
+ const status = (options.status?.trim() || await ask("Idea status", "captured")).toLowerCase();
182
+ const tags = options.tags ? parseCsv(options.tags) : parseCsv(await ask("Tags (comma-separated)", ""));
183
+ const body = options.body ?? await ask("Idea body (optional)", "");
184
+ const frontmatter = {
185
+ id,
186
+ title,
187
+ created: todayIsoDate(),
188
+ author,
189
+ status,
190
+ tags,
191
+ source,
192
+ linked_tasks: []
193
+ };
194
+ if (!Object.values(IdeaStatus).includes(status)) {
195
+ throw new Error(`Invalid idea status '${status}'.`);
196
+ }
197
+ const filePath = path2.join(coop, "ideas", `${id}.md`);
198
+ fs2.writeFileSync(filePath, stringifyFrontmatter(frontmatter, body), "utf8");
199
+ console.log(`Created idea: ${path2.relative(root, filePath)}`);
200
+ });
201
+ create.command("track").description("Create a track (Phase 2)").action(() => {
202
+ printNotImplemented("coop create track", 2);
203
+ });
204
+ create.command("delivery").description("Create a delivery (Phase 2)").action(() => {
205
+ printNotImplemented("coop create delivery", 2);
206
+ });
207
+ }
208
+
209
+ // src/commands/graph.ts
210
+ import chalk from "chalk";
211
+ import {
212
+ compute_readiness_with_corrections,
213
+ load_graph,
214
+ topological_sort,
215
+ validate_graph
216
+ } from "@kitsy/coop-core";
217
+
218
+ // src/utils/table.ts
219
+ function formatTable(headers, rows) {
220
+ const table = [headers, ...rows];
221
+ const widths = [];
222
+ for (let col = 0; col < headers.length; col += 1) {
223
+ let width = headers[col].length;
224
+ for (const row of table) {
225
+ width = Math.max(width, (row[col] ?? "").length);
226
+ }
227
+ widths.push(width + 2);
228
+ }
229
+ return table.map((row) => row.map((cell, index) => (cell ?? "").padEnd(widths[index])).join("").trimEnd()).join("\n");
230
+ }
231
+
232
+ // src/commands/graph.ts
233
+ function statusColor(status) {
234
+ switch (status) {
235
+ case "done":
236
+ return chalk.green(status);
237
+ case "in_progress":
238
+ return chalk.cyan(status);
239
+ case "in_review":
240
+ return chalk.blue(status);
241
+ case "blocked":
242
+ return chalk.yellow(status);
243
+ case "canceled":
244
+ return chalk.gray(status);
245
+ default:
246
+ return status;
247
+ }
248
+ }
249
+ function priorityRank(priority) {
250
+ switch (priority) {
251
+ case "p0":
252
+ return 0;
253
+ case "p1":
254
+ return 1;
255
+ case "p2":
256
+ return 2;
257
+ case "p3":
258
+ return 3;
259
+ default:
260
+ return 9;
261
+ }
262
+ }
263
+ function renderAsciiDag(tasks, order) {
264
+ const memo = /* @__PURE__ */ new Map();
265
+ const depth = (taskId) => {
266
+ const existing = memo.get(taskId);
267
+ if (existing !== void 0) return existing;
268
+ const task = tasks.get(taskId);
269
+ if (!task || !task.depends_on || task.depends_on.length === 0) {
270
+ memo.set(taskId, 0);
271
+ return 0;
272
+ }
273
+ const next = 1 + Math.max(...task.depends_on.map((depId) => depth(depId)));
274
+ memo.set(taskId, next);
275
+ return next;
276
+ };
277
+ const lines = [];
278
+ for (const taskId of order) {
279
+ const task = tasks.get(taskId);
280
+ if (!task) continue;
281
+ const indent = " ".repeat(Math.max(0, depth(taskId)));
282
+ const deps = task.depends_on && task.depends_on.length > 0 ? ` <- ${task.depends_on.join(", ")}` : "";
283
+ lines.push(`${indent}- ${task.id} [${task.status}] ${task.title}${deps}`);
284
+ }
285
+ return lines.join("\n");
286
+ }
287
+ function runValidate() {
288
+ const root = resolveRepoRoot();
289
+ const graph = load_graph(coopDir(root));
290
+ const issues = validate_graph(graph);
291
+ if (issues.length === 0) {
292
+ console.log(chalk.green("Graph is healthy. No invariant violations found."));
293
+ return;
294
+ }
295
+ for (const issue of issues) {
296
+ const label = issue.level === "error" ? chalk.red("ERROR") : chalk.yellow("WARN");
297
+ const ids = issue.task_ids && issue.task_ids.length > 0 ? ` (${issue.task_ids.join(", ")})` : "";
298
+ console.log(`${label} [${issue.invariant}] ${issue.message}${ids}`);
299
+ }
300
+ throw new Error(`Graph validation failed with ${issues.length} issue(s).`);
301
+ }
302
+ function runNext() {
303
+ const root = resolveRepoRoot();
304
+ const graph = load_graph(coopDir(root));
305
+ const readiness = compute_readiness_with_corrections(graph);
306
+ const ready = [...readiness.partitions.ready].sort((a, b) => {
307
+ const byPriority = priorityRank(a.priority) - priorityRank(b.priority);
308
+ if (byPriority !== 0) return byPriority;
309
+ return a.id.localeCompare(b.id);
310
+ });
311
+ if (ready.length === 0) {
312
+ console.log("No ready tasks found.");
313
+ } else {
314
+ console.log(
315
+ formatTable(
316
+ ["ID", "Title", "Status", "Priority", "Track", "Readiness"],
317
+ ready.map((task) => [
318
+ task.id,
319
+ task.title,
320
+ statusColor(task.status),
321
+ task.priority ?? "-",
322
+ task.track ?? "-",
323
+ "ready"
324
+ ])
325
+ )
326
+ );
327
+ }
328
+ if (readiness.corrections.length > 0) {
329
+ console.log(`
330
+ Corrections (${readiness.corrections.length}):`);
331
+ for (const correction of readiness.corrections) {
332
+ console.log(`- ${correction.task_id}: ${correction.from} -> ${correction.to} (${correction.reason})`);
333
+ }
334
+ }
335
+ if (readiness.warnings.length > 0) {
336
+ console.log(`
337
+ Warnings (${readiness.warnings.length}):`);
338
+ for (const warning of readiness.warnings) {
339
+ console.log(`- ${warning.task_id}: ${warning.message}`);
340
+ }
341
+ }
342
+ }
343
+ function runShow() {
344
+ const root = resolveRepoRoot();
345
+ const graph = load_graph(coopDir(root));
346
+ const order = topological_sort(graph);
347
+ console.log(renderAsciiDag(graph.nodes, order));
348
+ }
349
+ function registerGraphCommand(program2) {
350
+ const graph = program2.command("graph").description("Task graph commands");
351
+ graph.command("validate").description("Validate graph invariants").action(() => {
352
+ runValidate();
353
+ });
354
+ graph.command("next").description("Show ready tasks").action(() => {
355
+ runNext();
356
+ });
357
+ graph.command("show").description("Render ASCII DAG").action(() => {
358
+ runShow();
359
+ });
360
+ graph.command("critical-path").description("Show critical path for a delivery (Phase 2)").argument("<delivery>", "Delivery name").action(() => {
361
+ printNotImplemented("coop graph critical-path", 2);
362
+ });
363
+ }
364
+
365
+ // src/commands/init.ts
366
+ import fs4 from "fs";
367
+ import path4 from "path";
368
+ import { CURRENT_SCHEMA_VERSION, write_schema_version } from "@kitsy/coop-core";
369
+
370
+ // src/hooks/pre-commit.ts
371
+ import fs3 from "fs";
372
+ import path3 from "path";
373
+ import { spawnSync } from "child_process";
374
+ import { detect_cycle, parseTaskContent, parseTaskFile as parseTaskFile2, validateStructural as validateStructural2 } from "@kitsy/coop-core";
375
+ var HOOK_BLOCK_START = "# COOP_PRE_COMMIT_START";
376
+ var HOOK_BLOCK_END = "# COOP_PRE_COMMIT_END";
377
+ function runGit(repoRoot, args, allowFailure = false) {
378
+ const result = spawnSync("git", args, {
379
+ cwd: repoRoot,
380
+ encoding: "utf8"
381
+ });
382
+ const status = result.status ?? 1;
383
+ const stdout = result.stdout ?? "";
384
+ const stderr = result.stderr ?? "";
385
+ if (status !== 0 && !allowFailure) {
386
+ throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || "unknown git error"}`);
387
+ }
388
+ return { status, stdout, stderr };
389
+ }
390
+ function toPosixPath(filePath) {
391
+ return filePath.replace(/\\/g, "/");
392
+ }
393
+ function stagedTaskFiles(repoRoot) {
394
+ const { stdout } = runGit(repoRoot, ["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
395
+ return stdout.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean).map(toPosixPath).filter((entry) => entry.startsWith(".coop/tasks/") && entry.endsWith(".md"));
396
+ }
397
+ function readGitBlob(repoRoot, spec) {
398
+ const result = runGit(repoRoot, ["show", spec], true);
399
+ if (result.status !== 0) return null;
400
+ return result.stdout;
401
+ }
402
+ function parseTaskFromBlob(content, source) {
403
+ return parseTaskContent(content, source).task;
404
+ }
405
+ function parseStagedTasks(repoRoot, relativePaths) {
406
+ const errors = [];
407
+ const staged = [];
408
+ for (const relativePath of relativePaths) {
409
+ const absolutePath = path3.join(repoRoot, ...relativePath.split("/"));
410
+ const stagedBlob = readGitBlob(repoRoot, `:${relativePath}`);
411
+ if (!stagedBlob) {
412
+ errors.push(`[COOP] Unable to read staged task '${relativePath}' from git index.`);
413
+ continue;
414
+ }
415
+ let task;
416
+ try {
417
+ task = parseTaskFromBlob(stagedBlob, `staged:${relativePath}`);
418
+ } catch (error) {
419
+ const message = error instanceof Error ? error.message : String(error);
420
+ errors.push(`[COOP] ${message}`);
421
+ continue;
422
+ }
423
+ const issues = validateStructural2(task, { filePath: absolutePath });
424
+ for (const issue of issues) {
425
+ errors.push(`[COOP] ${relativePath}: ${issue.message}`);
426
+ }
427
+ staged.push({
428
+ relativePath,
429
+ absolutePath,
430
+ task
431
+ });
432
+ }
433
+ return { staged, errors };
434
+ }
435
+ function buildGraphForCycleCheck(tasks) {
436
+ const nodes = /* @__PURE__ */ new Map();
437
+ for (const task of tasks) {
438
+ nodes.set(task.id, task);
439
+ }
440
+ const forward = /* @__PURE__ */ new Map();
441
+ const reverse = /* @__PURE__ */ new Map();
442
+ for (const id of nodes.keys()) {
443
+ forward.set(id, /* @__PURE__ */ new Set());
444
+ reverse.set(id, /* @__PURE__ */ new Set());
445
+ }
446
+ for (const task of tasks) {
447
+ const deps = new Set(task.depends_on ?? []);
448
+ forward.set(task.id, deps);
449
+ for (const depId of deps) {
450
+ if (!nodes.has(depId)) continue;
451
+ reverse.get(depId)?.add(task.id);
452
+ }
453
+ }
454
+ return {
455
+ nodes,
456
+ forward,
457
+ reverse,
458
+ topological_order: [],
459
+ tracks: /* @__PURE__ */ new Map(),
460
+ resources: /* @__PURE__ */ new Map(),
461
+ deliveries: /* @__PURE__ */ new Map()
462
+ };
463
+ }
464
+ function collectTasksForCycleCheck(repoRoot, stagedTasks) {
465
+ const stagedByPath = /* @__PURE__ */ new Map();
466
+ for (const staged of stagedTasks) {
467
+ stagedByPath.set(toPosixPath(staged.absolutePath), staged.task);
468
+ }
469
+ const tasks = [];
470
+ for (const filePath of listTaskFiles(repoRoot)) {
471
+ const normalized = toPosixPath(path3.resolve(filePath));
472
+ const stagedTask = stagedByPath.get(normalized);
473
+ if (stagedTask) {
474
+ tasks.push(stagedTask);
475
+ continue;
476
+ }
477
+ tasks.push(parseTaskFile2(filePath).task);
478
+ }
479
+ return tasks;
480
+ }
481
+ function runPreCommitChecks(repoRoot) {
482
+ const relativeTaskFiles = stagedTaskFiles(repoRoot);
483
+ if (relativeTaskFiles.length === 0) {
484
+ return { ok: true, errors: [] };
485
+ }
486
+ const parsed = parseStagedTasks(repoRoot, relativeTaskFiles);
487
+ const errors = [...parsed.errors];
488
+ if (parsed.staged.length > 0 && errors.length === 0) {
489
+ try {
490
+ const tasks = collectTasksForCycleCheck(repoRoot, parsed.staged);
491
+ const graph = buildGraphForCycleCheck(tasks);
492
+ const cycle = detect_cycle(graph);
493
+ if (cycle) {
494
+ errors.push(`[COOP] Dependency cycle detected: ${cycle.join(" -> ")}.`);
495
+ }
496
+ } catch (error) {
497
+ const message = error instanceof Error ? error.message : String(error);
498
+ errors.push(`[COOP] Failed to run dependency cycle check: ${message}`);
499
+ }
500
+ }
501
+ return {
502
+ ok: errors.length === 0,
503
+ errors
504
+ };
505
+ }
506
+ function hookScriptBlock() {
507
+ return [
508
+ HOOK_BLOCK_START,
509
+ "if command -v coop >/dev/null 2>&1; then",
510
+ ' coop hook pre-commit --repo "$PWD" || exit $?',
511
+ 'elif [ -x "./node_modules/.bin/coop" ]; then',
512
+ ' ./node_modules/.bin/coop hook pre-commit --repo "$PWD" || exit $?',
513
+ "elif command -v pnpm >/dev/null 2>&1; then",
514
+ ' pnpm -s exec coop hook pre-commit --repo "$PWD" || exit $?',
515
+ "else",
516
+ ` echo "[COOP] Unable to run pre-commit checks: 'coop' command not found."`,
517
+ " exit 1",
518
+ "fi",
519
+ HOOK_BLOCK_END,
520
+ ""
521
+ ].join("\n");
522
+ }
523
+ function installPreCommitHook(repoRoot) {
524
+ const hookPath = path3.join(repoRoot, ".git", "hooks", "pre-commit");
525
+ const hookDir = path3.dirname(hookPath);
526
+ if (!fs3.existsSync(hookDir)) {
527
+ return {
528
+ installed: false,
529
+ hookPath,
530
+ message: "Skipped pre-commit hook installation (.git/hooks not found)."
531
+ };
532
+ }
533
+ const block = hookScriptBlock();
534
+ if (!fs3.existsSync(hookPath)) {
535
+ const content = ["#!/bin/sh", "", block].join("\n");
536
+ fs3.writeFileSync(hookPath, content, "utf8");
537
+ } else {
538
+ const existing = fs3.readFileSync(hookPath, "utf8");
539
+ if (!existing.includes(HOOK_BLOCK_START)) {
540
+ const suffix = existing.endsWith("\n") ? "" : "\n";
541
+ fs3.writeFileSync(hookPath, `${existing}${suffix}${block}`, "utf8");
542
+ }
543
+ }
544
+ try {
545
+ fs3.chmodSync(hookPath, 493);
546
+ } catch {
547
+ }
548
+ return {
549
+ installed: true,
550
+ hookPath,
551
+ message: "Installed COOP pre-commit hook."
552
+ };
553
+ }
554
+
555
+ // src/commands/init.ts
556
+ var DEFAULT_CONFIG = `version: 2
557
+ project:
558
+ name: "My Project"
559
+ id: "my-project"
560
+
561
+ id_prefixes:
562
+ idea: "IDEA"
563
+ task: "PM"
564
+ delivery: "DEL"
565
+ run: "RUN"
566
+
567
+ defaults:
568
+ task:
569
+ type: feature
570
+ priority: p2
571
+ complexity: medium
572
+ determinism: medium
573
+ track: unassigned
574
+
575
+ scheduling:
576
+ algorithm: critical-path
577
+ velocity_window_weeks: 4
578
+ overhead_factor: 1.2
579
+
580
+ ai:
581
+ default_executor: claude-sonnet
582
+ sandbox: true
583
+ max_concurrent_agents: 4
584
+ token_budget_per_task: 50000
585
+
586
+ plugins:
587
+ enabled: []
588
+
589
+ hooks:
590
+ on_task_transition: .coop/hooks/on-task-transition.sh
591
+ on_delivery_complete: .coop/hooks/on-delivery-complete.sh
592
+ `;
593
+ var TASK_TEMPLATE = `---
594
+ id: PM-001
595
+ title: "Implement feature"
596
+ type: feature
597
+ status: todo
598
+ created: 2026-03-06
599
+ updated: 2026-03-06
600
+ priority: p2
601
+ track: unassigned
602
+ depends_on: []
603
+ ---
604
+
605
+ ## Context
606
+ Why this task exists.
607
+
608
+ ## Technical Notes
609
+ Implementation details.
610
+
611
+ ## Open Questions
612
+ - Question here
613
+ `;
614
+ var IDEA_TEMPLATE = `---
615
+ id: IDEA-001
616
+ title: "New idea"
617
+ created: 2026-03-06
618
+ author: your-name
619
+ status: captured
620
+ tags: []
621
+ source: manual
622
+ linked_tasks: []
623
+ ---
624
+
625
+ ## Problem
626
+ What problem are you solving?
627
+
628
+ ## Hypothesis
629
+ What do you believe will happen?
630
+ `;
631
+ function ensureDir(dirPath) {
632
+ fs4.mkdirSync(dirPath, { recursive: true });
633
+ }
634
+ function writeIfMissing(filePath, content) {
635
+ if (!fs4.existsSync(filePath)) {
636
+ fs4.writeFileSync(filePath, content, "utf8");
637
+ }
638
+ }
639
+ function ensureGitignoreEntry(root, entry) {
640
+ const gitignorePath = path4.join(root, ".gitignore");
641
+ if (!fs4.existsSync(gitignorePath)) {
642
+ fs4.writeFileSync(gitignorePath, `${entry}
643
+ `, "utf8");
644
+ return;
645
+ }
646
+ const content = fs4.readFileSync(gitignorePath, "utf8");
647
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
648
+ if (!lines.includes(entry)) {
649
+ const suffix = content.endsWith("\n") ? "" : "\n";
650
+ fs4.writeFileSync(gitignorePath, `${content}${suffix}${entry}
651
+ `, "utf8");
652
+ }
653
+ }
654
+ function registerInitCommand(program2) {
655
+ program2.command("init").description("Initialize .coop/ structure in the current repository").action(() => {
656
+ const root = resolveRepoRoot();
657
+ const coop = coopDir(root);
658
+ const dirs = [
659
+ "ideas",
660
+ "tasks",
661
+ "tracks",
662
+ "deliveries",
663
+ "resources",
664
+ "templates",
665
+ "plugins",
666
+ "hooks",
667
+ "runs",
668
+ "decisions",
669
+ "history/tasks",
670
+ "history/deliveries",
671
+ ".index"
672
+ ];
673
+ for (const dir of dirs) {
674
+ ensureDir(path4.join(coop, dir));
675
+ }
676
+ writeIfMissing(path4.join(coop, "config.yml"), DEFAULT_CONFIG);
677
+ if (!fs4.existsSync(path4.join(coop, "schema-version"))) {
678
+ write_schema_version(coop, CURRENT_SCHEMA_VERSION);
679
+ }
680
+ writeIfMissing(path4.join(coop, "templates/task.md"), TASK_TEMPLATE);
681
+ writeIfMissing(path4.join(coop, "templates/idea.md"), IDEA_TEMPLATE);
682
+ ensureGitignoreEntry(root, ".coop/.index/");
683
+ const hook = installPreCommitHook(root);
684
+ console.log("Initialized COOP workspace.");
685
+ console.log(`- Root: ${root}`);
686
+ console.log(`- ${hook.message}`);
687
+ if (hook.installed) {
688
+ console.log(`- Hook: ${path4.relative(root, hook.hookPath)}`);
689
+ }
690
+ console.log("- Next steps:");
691
+ console.log(" 1. coop create idea");
692
+ console.log(" 2. coop create task");
693
+ console.log(" 3. coop graph validate");
694
+ });
695
+ }
696
+
697
+ // src/commands/list.ts
698
+ import path5 from "path";
699
+ import { parseIdeaFile, parseTaskFile as parseTaskFile3 } from "@kitsy/coop-core";
700
+ import chalk2 from "chalk";
701
+ function statusColor2(status) {
702
+ switch (status) {
703
+ case "done":
704
+ return chalk2.green(status);
705
+ case "in_progress":
706
+ return chalk2.cyan(status);
707
+ case "in_review":
708
+ return chalk2.blue(status);
709
+ case "blocked":
710
+ return chalk2.yellow(status);
711
+ case "canceled":
712
+ return chalk2.gray(status);
713
+ default:
714
+ return status;
715
+ }
716
+ }
717
+ function sortByIdAsc(items) {
718
+ return [...items].sort((a, b) => a.id.localeCompare(b.id));
719
+ }
720
+ function loadTasks(root) {
721
+ return listTaskFiles(root).map((filePath) => ({
722
+ task: parseTaskFile3(filePath).task,
723
+ filePath
724
+ }));
725
+ }
726
+ function loadIdeas(root) {
727
+ return listIdeaFiles(root).map((filePath) => ({
728
+ idea: parseIdeaFile(filePath).idea,
729
+ filePath
730
+ }));
731
+ }
732
+ function listTasks(options) {
733
+ const root = resolveRepoRoot();
734
+ ensureCoopInitialized(root);
735
+ const rows = loadTasks(root).filter(({ task }) => {
736
+ if (options.status && task.status !== options.status) return false;
737
+ if (options.track && (task.track ?? "unassigned") !== options.track) return false;
738
+ if (options.priority && (task.priority ?? "p2") !== options.priority) return false;
739
+ return true;
740
+ }).map(({ task, filePath }) => ({
741
+ id: task.id,
742
+ title: task.title,
743
+ status: task.status,
744
+ priority: task.priority ?? "-",
745
+ track: task.track ?? "-",
746
+ filePath
747
+ }));
748
+ const sorted = sortByIdAsc(rows);
749
+ if (sorted.length === 0) {
750
+ console.log("No tasks found.");
751
+ return;
752
+ }
753
+ console.log(
754
+ formatTable(
755
+ ["ID", "Title", "Status", "Priority", "Track", "File"],
756
+ sorted.map((entry) => [
757
+ entry.id,
758
+ entry.title,
759
+ statusColor2(entry.status),
760
+ entry.priority,
761
+ entry.track,
762
+ path5.relative(root, entry.filePath)
763
+ ])
764
+ )
765
+ );
766
+ console.log(`
767
+ Total tasks: ${sorted.length}`);
768
+ }
769
+ function listIdeas(options) {
770
+ const root = resolveRepoRoot();
771
+ ensureCoopInitialized(root);
772
+ const rows = loadIdeas(root).filter(({ idea }) => {
773
+ if (options.status && idea.status !== options.status) return false;
774
+ return true;
775
+ }).map(({ idea, filePath }) => ({
776
+ id: idea.id,
777
+ title: idea.title,
778
+ status: idea.status,
779
+ priority: "-",
780
+ track: "-",
781
+ filePath
782
+ }));
783
+ const sorted = sortByIdAsc(rows);
784
+ if (sorted.length === 0) {
785
+ console.log("No ideas found.");
786
+ return;
787
+ }
788
+ console.log(
789
+ formatTable(
790
+ ["ID", "Title", "Status", "Priority", "Track", "File"],
791
+ sorted.map((entry) => [
792
+ entry.id,
793
+ entry.title,
794
+ statusColor2(entry.status),
795
+ entry.priority,
796
+ entry.track,
797
+ path5.relative(root, entry.filePath)
798
+ ])
799
+ )
800
+ );
801
+ console.log(`
802
+ Total ideas: ${sorted.length}`);
803
+ }
804
+ function registerListCommand(program2) {
805
+ const list = program2.command("list").description("List COOP entities");
806
+ list.command("tasks").description("List tasks").option("--status <status>", "Filter by status").option("--track <track>", "Filter by track").option("--priority <priority>", "Filter by priority").action((options) => {
807
+ listTasks(options);
808
+ });
809
+ list.command("ideas").description("List ideas").option("--status <status>", "Filter by status").action((options) => {
810
+ listIdeas(options);
811
+ });
812
+ list.command("deliveries").description("List deliveries (Phase 2)").action(() => {
813
+ printNotImplemented("coop list deliveries", 2);
814
+ });
815
+ }
816
+
817
+ // src/commands/show.ts
818
+ import fs5 from "fs";
819
+ import path6 from "path";
820
+ import { parseIdeaFile as parseIdeaFile2, parseTaskFile as parseTaskFile4 } from "@kitsy/coop-core";
821
+ function stringify(value) {
822
+ if (value === null || value === void 0) return "-";
823
+ if (Array.isArray(value)) return value.length > 0 ? value.join(", ") : "-";
824
+ if (typeof value === "object") return JSON.stringify(value, null, 2);
825
+ return String(value);
826
+ }
827
+ function loadComputedFromIndex(root, taskId) {
828
+ const indexPath = path6.join(root, ".coop", ".index", "tasks.json");
829
+ if (!fs5.existsSync(indexPath)) {
830
+ return null;
831
+ }
832
+ let parsed;
833
+ try {
834
+ parsed = JSON.parse(fs5.readFileSync(indexPath, "utf8"));
835
+ } catch {
836
+ return null;
837
+ }
838
+ if (Array.isArray(parsed)) {
839
+ const match = parsed.find((entry) => {
840
+ if (!entry || typeof entry !== "object") return false;
841
+ return entry.id === taskId;
842
+ });
843
+ if (!match || typeof match !== "object") return null;
844
+ const record = match;
845
+ const computed = record.computed;
846
+ return computed && typeof computed === "object" ? computed : record;
847
+ }
848
+ if (parsed && typeof parsed === "object") {
849
+ const record = parsed;
850
+ const tasks = record.tasks;
851
+ if (Array.isArray(tasks)) {
852
+ const match = tasks.find((entry) => {
853
+ if (!entry || typeof entry !== "object") return false;
854
+ return entry.id === taskId;
855
+ });
856
+ if (match && typeof match === "object") {
857
+ const asRecord = match;
858
+ const computed = asRecord.computed;
859
+ return computed && typeof computed === "object" ? computed : asRecord;
860
+ }
861
+ }
862
+ const direct = record[taskId];
863
+ if (direct && typeof direct === "object") {
864
+ return direct;
865
+ }
866
+ }
867
+ return null;
868
+ }
869
+ function showTask(taskId) {
870
+ const root = resolveRepoRoot();
871
+ const coop = ensureCoopInitialized(root);
872
+ const taskFile = findTaskFileById(root, taskId.toUpperCase());
873
+ const parsed = parseTaskFile4(taskFile);
874
+ const task = parsed.task;
875
+ const body = parsed.body.trim();
876
+ const computed = loadComputedFromIndex(root, task.id);
877
+ const lines = [
878
+ `Task: ${task.id}`,
879
+ `Title: ${task.title}`,
880
+ `Status: ${task.status}`,
881
+ `Type: ${task.type}`,
882
+ `Priority: ${task.priority ?? "-"}`,
883
+ `Track: ${task.track ?? "-"}`,
884
+ `Assignee: ${task.assignee ?? "-"}`,
885
+ `Delivery: ${task.delivery ?? "-"}`,
886
+ `Depends On: ${stringify(task.depends_on)}`,
887
+ `Tags: ${stringify(task.tags)}`,
888
+ `Created: ${task.created}`,
889
+ `Updated: ${task.updated}`,
890
+ `File: ${path6.relative(root, taskFile)}`,
891
+ "",
892
+ "Body:",
893
+ body || "-",
894
+ "",
895
+ "Computed:"
896
+ ];
897
+ if (!computed) {
898
+ lines.push(`index not built (${path6.relative(root, path6.join(coop, ".index", "tasks.json"))} missing)`);
899
+ } else {
900
+ for (const [key, value] of Object.entries(computed)) {
901
+ lines.push(`- ${key}: ${stringify(value)}`);
902
+ }
903
+ }
904
+ console.log(lines.join("\n"));
905
+ }
906
+ function findIdeaFileById(root, ideaId) {
907
+ const target = `${ideaId.toUpperCase()}.md`;
908
+ const match = listIdeaFiles(root).find((filePath) => path6.basename(filePath).toUpperCase() === target);
909
+ if (!match) {
910
+ throw new Error(`Idea '${ideaId}' not found in .coop/ideas.`);
911
+ }
912
+ return match;
913
+ }
914
+ function showIdea(ideaId) {
915
+ const root = resolveRepoRoot();
916
+ ensureCoopInitialized(root);
917
+ const ideaFile = findIdeaFileById(root, ideaId);
918
+ const parsed = parseIdeaFile2(ideaFile);
919
+ const idea = parsed.idea;
920
+ const body = parsed.body.trim();
921
+ const lines = [
922
+ `Idea: ${idea.id}`,
923
+ `Title: ${idea.title}`,
924
+ `Status: ${idea.status}`,
925
+ `Author: ${idea.author}`,
926
+ `Source: ${idea.source}`,
927
+ `Tags: ${stringify(idea.tags)}`,
928
+ `Linked Tasks: ${stringify(idea.linked_tasks)}`,
929
+ `Created: ${idea.created}`,
930
+ `File: ${path6.relative(root, ideaFile)}`,
931
+ "",
932
+ "Body:",
933
+ body || "-"
934
+ ];
935
+ console.log(lines.join("\n"));
936
+ }
937
+ function registerShowCommand(program2) {
938
+ const show = program2.command("show").description("Show detailed COOP entities");
939
+ show.command("task").description("Show task details").argument("<id>", "Task ID").action((id) => {
940
+ showTask(id);
941
+ });
942
+ show.command("idea").description("Show idea details").argument("<id>", "Idea ID").action((id) => {
943
+ showIdea(id);
944
+ });
945
+ show.command("delivery").description("Show delivery details (Phase 2)").argument("<name>", "Delivery name").action(() => {
946
+ printNotImplemented("coop show delivery", 2);
947
+ });
948
+ }
949
+
950
+ // src/commands/transition.ts
951
+ import { TaskStatus as TaskStatus2, load_graph as load_graph2, parseTaskFile as parseTaskFile5, transition, validateStructural as validateStructural3, writeTask as writeTask2 } from "@kitsy/coop-core";
952
+ function dependencyStatusMapForTask(taskId, graph) {
953
+ const task = graph.nodes.get(taskId);
954
+ const statuses = /* @__PURE__ */ new Map();
955
+ for (const depId of task?.depends_on ?? []) {
956
+ const depTask = graph.nodes.get(depId);
957
+ if (!depTask) continue;
958
+ statuses.set(depId, depTask.status);
959
+ }
960
+ return statuses;
961
+ }
962
+ function registerTransitionCommand(program2) {
963
+ const transitionCommand = program2.command("transition").description("Transition COOP entities");
964
+ transitionCommand.command("task").description("Transition task status").argument("<id>", "Task ID").argument("<status>", "Target status").option("--actor <actor>", "Actor performing the transition").action((id, status, options) => {
965
+ const target = status.toLowerCase();
966
+ if (!Object.values(TaskStatus2).includes(target)) {
967
+ throw new Error(`Invalid target status '${status}'.`);
968
+ }
969
+ const root = resolveRepoRoot();
970
+ const graph = load_graph2(coopDir(root));
971
+ const normalizedId = id.toUpperCase();
972
+ const existing = graph.nodes.get(normalizedId);
973
+ if (!existing) {
974
+ throw new Error(`Task '${normalizedId}' not found.`);
975
+ }
976
+ const filePath = findTaskFileById(root, normalizedId);
977
+ const parsed = parseTaskFile5(filePath);
978
+ const result = transition(parsed.task, target, {
979
+ actor: options.actor,
980
+ dependencyStatuses: dependencyStatusMapForTask(normalizedId, graph)
981
+ });
982
+ if (!result.success) {
983
+ throw new Error(result.error ?? "Transition failed.");
984
+ }
985
+ const structuralIssues = validateStructural3(result.task, { filePath });
986
+ if (structuralIssues.length > 0) {
987
+ const errors = structuralIssues.map((issue) => `- ${issue.message}`).join("\n");
988
+ throw new Error(`Updated task is structurally invalid:
989
+ ${errors}`);
990
+ }
991
+ writeTask2(result.task, {
992
+ body: parsed.body,
993
+ raw: parsed.raw,
994
+ filePath
995
+ });
996
+ console.log(`Updated ${result.task.id}: ${parsed.task.status} -> ${result.task.status}`);
997
+ });
998
+ }
999
+
1000
+ // src/index.ts
1001
+ function readVersion() {
1002
+ const currentFile = fileURLToPath(import.meta.url);
1003
+ const packageJsonPath = path7.resolve(path7.dirname(currentFile), "..", "package.json");
1004
+ try {
1005
+ const parsed = JSON.parse(fs6.readFileSync(packageJsonPath, "utf8"));
1006
+ return parsed.version ?? "0.0.0";
1007
+ } catch {
1008
+ return "0.0.0";
1009
+ }
1010
+ }
1011
+ function registerPhasePlaceholder(program2, name, phase, description) {
1012
+ program2.command(name).description(description).action(() => {
1013
+ printNotImplemented(`coop ${name}`, phase);
1014
+ });
1015
+ }
1016
+ var program = new Command();
1017
+ program.name("coop");
1018
+ program.version(readVersion());
1019
+ program.description("COOP CLI");
1020
+ registerInitCommand(program);
1021
+ registerCreateCommand(program);
1022
+ registerListCommand(program);
1023
+ registerShowCommand(program);
1024
+ registerTransitionCommand(program);
1025
+ registerGraphCommand(program);
1026
+ registerPhasePlaceholder(program, "plan", 2, "Planning commands");
1027
+ registerPhasePlaceholder(program, "run", 4, "Runbook execution commands");
1028
+ registerPhasePlaceholder(program, "view", 2, "View/dashboard commands");
1029
+ registerPhasePlaceholder(program, "assign", 3, "Task assignment commands");
1030
+ registerPhasePlaceholder(program, "index", 3, "Index commands");
1031
+ registerPhasePlaceholder(program, "migrate", 2, "Schema migration commands");
1032
+ registerPhasePlaceholder(program, "status", 3, "Project status dashboard");
1033
+ registerPhasePlaceholder(program, "ext", 3, "Plugin extension commands");
1034
+ var hooks = program.command("hook");
1035
+ hooks.command("pre-commit").option("--repo <path>", "Repository root", process.cwd()).action((options) => {
1036
+ const repoRoot = options.repo ?? process.cwd();
1037
+ const result = runPreCommitChecks(repoRoot);
1038
+ if (!result.ok) {
1039
+ for (const error of result.errors) {
1040
+ console.error(error);
1041
+ }
1042
+ process.exitCode = 1;
1043
+ return;
1044
+ }
1045
+ if (result.errors.length === 0) {
1046
+ console.log("[COOP] pre-commit checks passed.");
1047
+ }
1048
+ });
1049
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@kitsy/coop",
3
+ "description": "COOP command-line interface.",
4
+ "version": "0.0.1",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/kitsy/coop.git",
13
+ "directory": "packages/cli"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/kitsy/coop/issues"
17
+ },
18
+ "homepage": "https://github.com/kitsy/coop#readme",
19
+ "keywords": [
20
+ "coop",
21
+ "cli",
22
+ "planning",
23
+ "task-graph",
24
+ "orchestration"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "bin": {
30
+ "coop": "dist/index.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "dependencies": {
38
+ "chalk": "^5.6.2",
39
+ "commander": "^14.0.0",
40
+ "@kitsy/coop-core": "0.0.1"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^24.12.0",
44
+ "tsup": "^8.5.1",
45
+ "tsx": "^4.21.0",
46
+ "typescript": "^5.9.3"
47
+ },
48
+ "scripts": {
49
+ "coop": "tsx src/index.ts",
50
+ "build": "tsup src/index.ts --format esm --dts --clean --out-dir dist",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit"
52
+ }
53
+ }