@kitsy/coop-ai 0.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,988 @@
1
- // src/index.ts
2
- var coopAiRuntime = {
3
- package: "@kitsy/coop-ai",
4
- status: "planned"
1
+ // src/contracts/contract-builder.ts
2
+ var DEFAULT_FORBIDDEN_COMMANDS = ["rm -rf", "git push", "deploy"];
3
+ var DEFAULT_OUTPUT_REQUIREMENTS = [
4
+ "All changes on a feature branch",
5
+ "PR description with acceptance criteria mapping",
6
+ "Test coverage report"
7
+ ];
8
+ function asRecord(value) {
9
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
10
+ return {};
11
+ }
12
+ return value;
13
+ }
14
+ function asStringArray(value) {
15
+ if (!Array.isArray(value)) return null;
16
+ const out = value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean);
17
+ return out;
18
+ }
19
+ function asBoolean(value, fallback) {
20
+ if (typeof value === "boolean") return value;
21
+ return fallback;
22
+ }
23
+ function asFiniteNumber(value) {
24
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
25
+ return value;
26
+ }
27
+ function normalizePath(input) {
28
+ const collapsed = input.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\.\//, "").replace(/\/$/, "").trim();
29
+ return collapsed.length > 0 ? collapsed : ".";
30
+ }
31
+ function isSubPath(candidate, parent) {
32
+ const pathValue = normalizePath(candidate);
33
+ const parentValue = normalizePath(parent);
34
+ if (parentValue === "." || parentValue === "") return true;
35
+ return pathValue === parentValue || pathValue.startsWith(`${parentValue}/`);
36
+ }
37
+ function normalizeCommand(command) {
38
+ return command.trim().replace(/\s+/g, " ").toLowerCase();
39
+ }
40
+ function commandMatchesRule(command, rule) {
41
+ const normalizedCommand = normalizeCommand(command);
42
+ const normalizedRule = normalizeCommand(rule);
43
+ return normalizedCommand === normalizedRule || normalizedCommand.startsWith(`${normalizedRule} `);
44
+ }
45
+ function dedupe(values) {
46
+ return Array.from(new Set(values));
47
+ }
48
+ function taskAcceptance(task) {
49
+ const maybe = asRecord(task).acceptance;
50
+ return asStringArray(maybe) ?? [];
51
+ }
52
+ function collectRelatedArtifacts(task, graph) {
53
+ const context = asRecord(task.execution?.context ?? {});
54
+ const relatedTaskIds = dedupe(asStringArray(context.tasks) ?? []);
55
+ const artifacts = [];
56
+ for (const taskId of relatedTaskIds) {
57
+ const related = graph.nodes.get(taskId);
58
+ if (!related) continue;
59
+ const produces = related.artifacts?.produces ?? [];
60
+ artifacts.push({
61
+ task_id: related.id,
62
+ artifacts: produces.map((artifact) => ({
63
+ type: artifact.type,
64
+ target: artifact.target,
65
+ path: artifact.path
66
+ }))
67
+ });
68
+ }
69
+ return artifacts;
70
+ }
71
+ function computeGoal(task) {
72
+ const acceptance = taskAcceptance(task);
73
+ if (acceptance.length === 0) {
74
+ return task.title.trim();
75
+ }
76
+ return `${task.title.trim()}. Acceptance criteria: ${acceptance.join("; ")}`;
77
+ }
78
+ function readAiConfig(config) {
79
+ const root = asRecord(config);
80
+ const ai = asRecord(root.ai);
81
+ const permissions = asRecord(ai.permissions);
82
+ const constraints = asRecord(ai.constraints);
83
+ const default_executor = typeof ai.default_executor === "string" && ai.default_executor.trim().length > 0 ? ai.default_executor.trim() : "claude-sonnet";
84
+ const token_budget_per_task = asFiniteNumber(ai.token_budget_per_task) ?? 5e4;
85
+ return {
86
+ default_executor,
87
+ token_budget_per_task,
88
+ permissions,
89
+ constraints
90
+ };
91
+ }
92
+ function validateContract(contract) {
93
+ if (contract.constraints.max_tokens <= 0) {
94
+ throw new Error("Invalid contract: constraints.max_tokens must be positive.");
95
+ }
96
+ for (const writePath of contract.permissions.write_paths) {
97
+ const allowed = contract.permissions.read_paths.some(
98
+ (readPath) => isSubPath(writePath, readPath)
99
+ );
100
+ if (!allowed) {
101
+ throw new Error(
102
+ `Invalid contract: write path '${writePath}' is outside declared read paths.`
103
+ );
104
+ }
105
+ }
106
+ }
107
+ function build_contract(task, graph, config) {
108
+ const ai = readAiConfig(config);
109
+ const execution = task.execution;
110
+ const executionContext = asRecord(execution?.context ?? {});
111
+ const defaultReadPaths = asStringArray(ai.permissions.read_paths) ?? ["."];
112
+ const defaultWritePaths = asStringArray(ai.permissions.write_paths) ?? defaultReadPaths;
113
+ const defaultAllowedCommands = asStringArray(ai.permissions.allowed_commands) ?? [];
114
+ const defaultForbiddenCommands = asStringArray(ai.permissions.forbidden_commands) ?? DEFAULT_FORBIDDEN_COMMANDS;
115
+ const read_paths = dedupe(
116
+ (execution?.permissions?.read_paths ?? defaultReadPaths).map((entry) => normalizePath(entry))
117
+ );
118
+ const write_paths = dedupe(
119
+ (execution?.permissions?.write_paths ?? defaultWritePaths).map((entry) => normalizePath(entry))
120
+ );
121
+ const forbidden_commands = dedupe(
122
+ (execution?.permissions?.forbidden_commands ?? defaultForbiddenCommands).map(
123
+ (entry) => entry.trim()
124
+ )
125
+ );
126
+ const candidate_allowed = dedupe(
127
+ (execution?.permissions?.allowed_commands ?? defaultAllowedCommands).map(
128
+ (entry) => entry.trim()
129
+ )
130
+ );
131
+ const allowed_commands = candidate_allowed.filter(
132
+ (allowed) => !forbidden_commands.some((forbidden) => commandMatchesRule(allowed, forbidden))
133
+ );
134
+ const constraintsConfig = ai.constraints;
135
+ const max_tokens = execution?.constraints?.max_tokens ?? asFiniteNumber(constraintsConfig.max_tokens) ?? ai.token_budget_per_task;
136
+ const max_duration_minutes = execution?.constraints?.max_duration_minutes ?? asFiniteNumber(constraintsConfig.max_duration_minutes) ?? 30;
137
+ const max_file_changes = execution?.constraints?.max_file_changes ?? asFiniteNumber(constraintsConfig.max_file_changes) ?? 20;
138
+ const require_tests = asBoolean(
139
+ execution?.constraints?.require_tests ?? constraintsConfig.require_tests,
140
+ true
141
+ );
142
+ const taskContextPaths = asStringArray(executionContext.paths) ?? [];
143
+ const taskContextFiles = asStringArray(executionContext.files) ?? [];
144
+ const taskContextTasks = asStringArray(executionContext.tasks) ?? [];
145
+ const acceptance_criteria = taskAcceptance(task);
146
+ const related_task_artifacts = collectRelatedArtifacts(task, graph);
147
+ const output_requirements = [
148
+ ...DEFAULT_OUTPUT_REQUIREMENTS,
149
+ ...(task.artifacts?.produces ?? []).map((artifact) => {
150
+ if (artifact.path) {
151
+ return `Produce ${artifact.type} artifact at ${artifact.path}`;
152
+ }
153
+ if (artifact.target) {
154
+ return `Produce ${artifact.type} artifact targeting ${artifact.target}`;
155
+ }
156
+ return `Produce ${artifact.type} artifact`;
157
+ })
158
+ ];
159
+ const contract = {
160
+ task_id: task.id,
161
+ goal: computeGoal(task),
162
+ executor: execution?.agent ?? ai.default_executor,
163
+ permissions: {
164
+ read_paths,
165
+ write_paths,
166
+ allowed_commands,
167
+ forbidden_commands
168
+ },
169
+ constraints: {
170
+ max_tokens,
171
+ max_duration_minutes,
172
+ max_file_changes,
173
+ require_tests
174
+ },
175
+ context: {
176
+ paths: taskContextPaths,
177
+ files: taskContextFiles,
178
+ tasks: taskContextTasks,
179
+ acceptance_criteria,
180
+ related_task_artifacts
181
+ },
182
+ output_requirements
183
+ };
184
+ validateContract(contract);
185
+ return contract;
186
+ }
187
+
188
+ // src/routing/router.ts
189
+ function asRecord2(value) {
190
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
191
+ return {};
192
+ }
193
+ return value;
194
+ }
195
+ function readRoutingConfig(config) {
196
+ const root = asRecord2(config);
197
+ const ai = asRecord2(root.ai);
198
+ const routing = asRecord2(ai.routing);
199
+ const defaultAgent = typeof routing.default_agent === "string" && routing.default_agent.trim() || typeof ai.default_executor === "string" && ai.default_executor.trim() || "claude-sonnet";
200
+ const reviewAgent = typeof routing.review_agent === "string" && routing.review_agent.trim() || defaultAgent;
201
+ const complexAgent = typeof routing.complex_agent === "string" && routing.complex_agent.trim() || typeof routing.opus_agent === "string" && routing.opus_agent.trim() || "claude-opus";
202
+ return {
203
+ default_agent: defaultAgent,
204
+ review_agent: reviewAgent,
205
+ complex_agent: complexAgent
206
+ };
207
+ }
208
+ function select_agent(task, config) {
209
+ if (task.execution?.agent && task.execution.agent.trim().length > 0) {
210
+ return task.execution.agent.trim();
211
+ }
212
+ const routing = readRoutingConfig(config);
213
+ if (task.complexity === "large" || task.complexity === "unknown" || task.type === "spike") {
214
+ return routing.complex_agent;
215
+ }
216
+ const hasReviewStep = (task.execution?.runbook ?? []).some((step) => step.action === "review");
217
+ if (hasReviewStep) {
218
+ return routing.review_agent;
219
+ }
220
+ return routing.default_agent;
221
+ }
222
+
223
+ // src/sandbox/sandbox.ts
224
+ function normalizePath2(input) {
225
+ const collapsed = input.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\.\//, "").replace(/\/$/, "").trim();
226
+ return collapsed.length > 0 ? collapsed : ".";
227
+ }
228
+ function isSubPath2(candidate, parent) {
229
+ const pathValue = normalizePath2(candidate);
230
+ const parentValue = normalizePath2(parent);
231
+ if (parentValue === "." || parentValue === "") return true;
232
+ return pathValue === parentValue || pathValue.startsWith(`${parentValue}/`);
233
+ }
234
+ function normalizeCommand2(command) {
235
+ return command.trim().replace(/\s+/g, " ").toLowerCase();
236
+ }
237
+ function commandMatchesRule2(command, rule) {
238
+ const normalizedCommand = normalizeCommand2(command);
239
+ const normalizedRule = normalizeCommand2(rule);
240
+ return normalizedCommand === normalizedRule || normalizedCommand.startsWith(`${normalizedRule} `);
241
+ }
242
+ function hasBlockedShellSyntax(command) {
243
+ return /(\|\||&&|[|;]|`|\$\()/.test(command);
244
+ }
245
+ function validate_command(command, contract) {
246
+ if (!command || command.trim().length === 0) return false;
247
+ if (hasBlockedShellSyntax(command)) return false;
248
+ if (contract.permissions.forbidden_commands.some((rule) => commandMatchesRule2(command, rule))) {
249
+ return false;
250
+ }
251
+ const allowed = contract.permissions.allowed_commands;
252
+ if (allowed.length === 0) {
253
+ return true;
254
+ }
255
+ return allowed.some((rule) => commandMatchesRule2(command, rule));
256
+ }
257
+ function validate_file_access(pathValue, mode, contract) {
258
+ if (!pathValue || pathValue.trim().length === 0) return false;
259
+ const normalized = normalizePath2(pathValue);
260
+ const canRead = contract.permissions.read_paths.some((readPath) => isSubPath2(normalized, readPath));
261
+ if (!canRead) return false;
262
+ if (mode === "read") return true;
263
+ return contract.permissions.write_paths.some((writePath) => isSubPath2(normalized, writePath));
264
+ }
265
+ function constraint_violation_reasons(runState, contract) {
266
+ const reasons = [];
267
+ if (runState.tokens_used > contract.constraints.max_tokens) {
268
+ reasons.push("token budget exceeded");
269
+ }
270
+ if (runState.duration_minutes > contract.constraints.max_duration_minutes) {
271
+ reasons.push("duration limit exceeded");
272
+ }
273
+ if (runState.file_changes > contract.constraints.max_file_changes) {
274
+ reasons.push("file change limit exceeded");
275
+ }
276
+ return reasons;
277
+ }
278
+ function enforce_constraints(runState, contract) {
279
+ return constraint_violation_reasons(runState, contract).length === 0;
280
+ }
281
+
282
+ // src/logging/run-logger.ts
283
+ import fs from "fs";
284
+ import path from "path";
285
+ import crypto from "crypto";
286
+ import { writeYamlFile } from "@kitsy/coop-core";
287
+ function randomSuffix() {
288
+ return crypto.randomBytes(2).toString("hex").toUpperCase();
289
+ }
290
+ function compactDateToken(now) {
291
+ return now.toISOString().replace(/[-:]/g, "").slice(0, 15);
292
+ }
293
+ function generateRunId(now) {
294
+ return `RUN-${compactDateToken(now)}-${randomSuffix()}`;
295
+ }
296
+ function cloneRun(run) {
297
+ return {
298
+ ...run,
299
+ steps: run.steps.map((step) => ({ ...step })),
300
+ resources_consumed: { ...run.resources_consumed }
301
+ };
302
+ }
303
+ function create_run(task, executor, now = /* @__PURE__ */ new Date()) {
304
+ return {
305
+ id: generateRunId(now),
306
+ task: task.id,
307
+ executor,
308
+ status: "running",
309
+ started: now.toISOString(),
310
+ steps: [],
311
+ resources_consumed: {
312
+ ai_tokens: 0,
313
+ compute_minutes: 0,
314
+ file_changes: 0
315
+ }
316
+ };
317
+ }
318
+ function log_step(run, step) {
319
+ const next = cloneRun(run);
320
+ next.steps.push({ ...step });
321
+ return next;
322
+ }
323
+ function finalize_run(run, status, now = /* @__PURE__ */ new Date()) {
324
+ const next = cloneRun(run);
325
+ next.status = status;
326
+ next.completed = now.toISOString();
327
+ return next;
328
+ }
329
+ function write_run(run, coopDir) {
330
+ const runsDir = path.join(coopDir, "runs");
331
+ fs.mkdirSync(runsDir, { recursive: true });
332
+ const filePath = path.join(runsDir, `${run.id}.yml`);
333
+ writeYamlFile(filePath, run);
334
+ return filePath;
335
+ }
336
+
337
+ // src/executor/executor.ts
338
+ import { spawnSync } from "child_process";
339
+ import path2 from "path";
340
+ function defaultAgentClient() {
341
+ return {
342
+ async generate(input) {
343
+ const tokenEstimate = Math.max(1, Math.ceil(input.prompt.length / 4));
344
+ return {
345
+ summary: `Generated draft for '${input.step.step}'.`,
346
+ tokens_used: tokenEstimate,
347
+ file_changes: 0
348
+ };
349
+ },
350
+ async review(input) {
351
+ const tokenEstimate = Math.max(1, Math.ceil(input.prompt.length / 6));
352
+ return {
353
+ summary: `AI review for '${input.step.step}' completed.`,
354
+ tokens_used: tokenEstimate,
355
+ file_changes: 0
356
+ };
357
+ }
358
+ };
359
+ }
360
+ function nowIso() {
361
+ return (/* @__PURE__ */ new Date()).toISOString();
362
+ }
363
+ function durationSeconds(startMs) {
364
+ return Math.max(0, Math.round((Date.now() - startMs) / 1e3));
365
+ }
366
+ function countChangedFiles(repoRoot) {
367
+ const result = spawnSync("git", ["status", "--porcelain"], {
368
+ cwd: repoRoot,
369
+ encoding: "utf8",
370
+ windowsHide: true
371
+ });
372
+ if ((result.status ?? 1) !== 0) {
373
+ return 0;
374
+ }
375
+ return (result.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean).length;
376
+ }
377
+ function executeCommand(command, repoRoot) {
378
+ const result = spawnSync(command, {
379
+ cwd: repoRoot,
380
+ shell: true,
381
+ encoding: "utf8",
382
+ windowsHide: true
383
+ });
384
+ const stdout = (result.stdout ?? "").trim();
385
+ const stderr = (result.stderr ?? "").trim();
386
+ const exitCode = result.status ?? 1;
387
+ const summary = [stdout, stderr].filter(Boolean).join("\n").trim();
388
+ return {
389
+ ok: exitCode === 0,
390
+ summary: summary || `Command exited with code ${exitCode}.`,
391
+ exitCode
392
+ };
393
+ }
394
+ function buildPrompt(task, contract, stepId, description) {
395
+ const sections = [
396
+ `Task: ${task.id}`,
397
+ `Goal: ${contract.goal}`,
398
+ `Step: ${stepId}`,
399
+ description ? `Description: ${description}` : "",
400
+ contract.context.acceptance_criteria.length > 0 ? `Acceptance: ${contract.context.acceptance_criteria.join(" | ")}` : "",
401
+ contract.context.paths.length > 0 ? `Context paths: ${contract.context.paths.join(", ")}` : "",
402
+ contract.context.files.length > 0 ? `Context files: ${contract.context.files.join(", ")}` : ""
403
+ ].filter(Boolean);
404
+ return sections.join("\n");
405
+ }
406
+ async function execute_task(task, contract, _graph, options = {}) {
407
+ const runbook = task.execution?.runbook ?? [];
408
+ if (runbook.length === 0) {
409
+ throw new Error(`Task '${task.id}' has no execution.runbook steps.`);
410
+ }
411
+ const selectedSteps = options.step ? runbook.filter((step) => step.step === options.step) : runbook;
412
+ if (selectedSteps.length === 0) {
413
+ throw new Error(`Step '${options.step}' not found in task '${task.id}'.`);
414
+ }
415
+ const repoRoot = options.repo_root ?? process.cwd();
416
+ const coopDir = options.coop_dir ?? path2.join(repoRoot, ".coop");
417
+ const onProgress = options.on_progress ?? (() => {
418
+ });
419
+ const agent = options.agent_client ?? defaultAgentClient();
420
+ const runState = {
421
+ tokens_used: 0,
422
+ duration_minutes: 0,
423
+ file_changes: countChangedFiles(repoRoot)
424
+ };
425
+ let run = create_run(task, contract.executor);
426
+ let finalStatus = "running";
427
+ for (const step of selectedSteps) {
428
+ const started = nowIso();
429
+ const startMs = Date.now();
430
+ onProgress(`Running step '${step.step}' (${step.action})...`);
431
+ let stepStatus = "completed";
432
+ let outputSummary = "";
433
+ let notes;
434
+ let exitCode;
435
+ let stepTokens = 0;
436
+ if (step.action === "run" || step.action === "test") {
437
+ if (!step.command) {
438
+ throw new Error(`Step '${step.step}' requires a command for action '${step.action}'.`);
439
+ }
440
+ if (!validate_command(step.command, contract)) {
441
+ stepStatus = "failed";
442
+ outputSummary = `Command rejected by sandbox: ${step.command}`;
443
+ } else {
444
+ const command = executeCommand(step.command, repoRoot);
445
+ stepStatus = command.ok ? "completed" : "failed";
446
+ outputSummary = command.summary;
447
+ exitCode = command.exitCode;
448
+ }
449
+ } else if (step.action === "generate") {
450
+ const prompt = buildPrompt(task, contract, step.step, step.description ?? "");
451
+ const response = await agent.generate({ task, step, contract, prompt });
452
+ stepTokens = response.tokens_used ?? 0;
453
+ runState.tokens_used += stepTokens;
454
+ outputSummary = response.summary;
455
+ runState.file_changes += response.file_changes ?? 0;
456
+ } else if (step.action === "review") {
457
+ if ((step.reviewer ?? "human") === "human") {
458
+ stepStatus = "paused";
459
+ notes = "Waiting for human reviewer.";
460
+ outputSummary = notes;
461
+ } else {
462
+ const prompt = buildPrompt(task, contract, step.step, step.description ?? "Perform review");
463
+ const response = await agent.review({ task, step, contract, prompt });
464
+ stepTokens = response.tokens_used ?? 0;
465
+ runState.tokens_used += stepTokens;
466
+ outputSummary = response.summary;
467
+ }
468
+ } else {
469
+ stepStatus = "failed";
470
+ outputSummary = `Unsupported action '${step.action}'.`;
471
+ }
472
+ const duration = durationSeconds(startMs);
473
+ runState.duration_minutes += duration / 60;
474
+ runState.file_changes = Math.max(runState.file_changes, countChangedFiles(repoRoot));
475
+ const stepResult = {
476
+ step: step.step,
477
+ action: step.action,
478
+ status: stepStatus,
479
+ started,
480
+ completed: nowIso(),
481
+ duration_seconds: duration,
482
+ output_summary: outputSummary,
483
+ reviewer: step.reviewer,
484
+ notes,
485
+ exit_code: exitCode,
486
+ tokens_used: stepTokens || void 0
487
+ };
488
+ run = log_step(run, stepResult);
489
+ run.resources_consumed.ai_tokens = runState.tokens_used;
490
+ run.resources_consumed.compute_minutes = Number(runState.duration_minutes.toFixed(2));
491
+ run.resources_consumed.file_changes = runState.file_changes;
492
+ if (!enforce_constraints(runState, contract)) {
493
+ finalStatus = "paused";
494
+ const reasons = constraint_violation_reasons(runState, contract);
495
+ run = log_step(run, {
496
+ step: `${step.step}-constraint-check`,
497
+ action: "constraint",
498
+ status: "paused",
499
+ started: nowIso(),
500
+ completed: nowIso(),
501
+ duration_seconds: 0,
502
+ notes: reasons.join("; "),
503
+ output_summary: reasons.join("; ")
504
+ });
505
+ break;
506
+ }
507
+ if (stepStatus === "failed") {
508
+ finalStatus = "failed";
509
+ break;
510
+ }
511
+ if (stepStatus === "paused") {
512
+ finalStatus = "paused";
513
+ break;
514
+ }
515
+ }
516
+ if (finalStatus === "running") {
517
+ finalStatus = "completed";
518
+ }
519
+ run = finalize_run(run, finalStatus);
520
+ const logPath = write_run(run, coopDir);
521
+ return {
522
+ status: finalStatus,
523
+ run,
524
+ log_path: logPath
525
+ };
526
+ }
527
+
528
+ // src/decomposition/decompose.ts
529
+ function nonEmptyLines(input) {
530
+ return input.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
531
+ }
532
+ function extractBulletIdeas(body) {
533
+ const lines = nonEmptyLines(body);
534
+ return lines.filter((line) => /^[-*]\s+/.test(line)).map((line) => line.replace(/^[-*]\s+/, "").trim()).filter(Boolean).slice(0, 5);
535
+ }
536
+ function sentenceCase(value) {
537
+ if (value.length === 0) return value;
538
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
539
+ }
540
+ function defaultDecomposition(input) {
541
+ const bullets = extractBulletIdeas(input.body);
542
+ if (bullets.length > 0) {
543
+ return bullets.slice(0, 3).map((bullet, index) => ({
544
+ title: sentenceCase(bullet),
545
+ body: `Derived from idea ${input.idea_id}: ${input.title}`,
546
+ type: index === 0 ? "spike" : "feature",
547
+ priority: index === 0 ? "p1" : "p2",
548
+ track: "unassigned"
549
+ }));
550
+ }
551
+ return [
552
+ {
553
+ title: `Define scope and acceptance for ${input.title}`,
554
+ body: `Derived from idea ${input.idea_id}. Capture constraints and success criteria.`,
555
+ type: "spike",
556
+ priority: "p1",
557
+ track: "unassigned"
558
+ },
559
+ {
560
+ title: `Implement ${input.title}`,
561
+ body: `Derived from idea ${input.idea_id}. Build the primary functionality.`,
562
+ type: "feature",
563
+ priority: "p1",
564
+ track: "unassigned"
565
+ },
566
+ {
567
+ title: `Validate and rollout ${input.title}`,
568
+ body: `Derived from idea ${input.idea_id}. Add tests and release checklist.`,
569
+ type: "chore",
570
+ priority: "p2",
571
+ track: "unassigned"
572
+ }
573
+ ];
574
+ }
575
+ function build_decomposition_prompt(input) {
576
+ return [
577
+ "You are a planning agent for COOP.",
578
+ "Decompose the idea into 2-5 implementation tasks.",
579
+ "Each task needs: title, type(feature|bug|chore|spike), priority(p0-p3), and body.",
580
+ `Idea ID: ${input.idea_id}`,
581
+ `Title: ${input.title}`,
582
+ "Body:",
583
+ input.body || "(empty)"
584
+ ].join("\n");
585
+ }
586
+ async function decompose_idea_to_tasks(input, client) {
587
+ const prompt = build_decomposition_prompt(input);
588
+ if (!client) {
589
+ return defaultDecomposition(input);
590
+ }
591
+ const drafts = await client.decompose(prompt, input);
592
+ if (!Array.isArray(drafts) || drafts.length === 0) {
593
+ return defaultDecomposition(input);
594
+ }
595
+ return drafts;
596
+ }
597
+
598
+ // src/providers/config.ts
599
+ var DEFAULT_MODELS = {
600
+ openai: "gpt-5-mini",
601
+ anthropic: "claude-3-5-sonnet-latest",
602
+ gemini: "gemini-2.0-flash",
603
+ ollama: "llama3.2"
5
604
  };
605
+ var DEFAULT_KEY_ENV = {
606
+ openai: "OPENAI_API_KEY",
607
+ anthropic: "ANTHROPIC_API_KEY",
608
+ gemini: "GEMINI_API_KEY"
609
+ };
610
+ var DEFAULT_BASE_URL = {
611
+ openai: "https://api.openai.com/v1",
612
+ anthropic: "https://api.anthropic.com/v1",
613
+ gemini: "https://generativelanguage.googleapis.com/v1beta",
614
+ ollama: "http://localhost:11434"
615
+ };
616
+ function asRecord3(value) {
617
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
618
+ return {};
619
+ }
620
+ return value;
621
+ }
622
+ function asString(value) {
623
+ if (typeof value !== "string") return null;
624
+ const trimmed = value.trim();
625
+ return trimmed.length > 0 ? trimmed : null;
626
+ }
627
+ function asFinite(value) {
628
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
629
+ return value;
630
+ }
631
+ function readProvider(value) {
632
+ const normalized = asString(value)?.toLowerCase();
633
+ if (normalized === "openai" || normalized === "anthropic" || normalized === "gemini" || normalized === "ollama" || normalized === "mock") {
634
+ return normalized;
635
+ }
636
+ return "mock";
637
+ }
638
+ function lookupProviderSection(ai, provider) {
639
+ return asRecord3(ai[provider]);
640
+ }
641
+ function resolve_provider_config(config) {
642
+ const root = asRecord3(config);
643
+ const ai = asRecord3(root.ai);
644
+ const provider = readProvider(ai.provider);
645
+ if (provider === "mock") {
646
+ return {
647
+ provider,
648
+ model: "mock-local",
649
+ temperature: 0.2,
650
+ max_output_tokens: 1024,
651
+ timeout_ms: 6e4
652
+ };
653
+ }
654
+ const section = lookupProviderSection(ai, provider);
655
+ const model = asString(section.model) ?? asString(ai.model) ?? DEFAULT_MODELS[provider];
656
+ const base_url = asString(section.base_url) ?? asString(ai.base_url) ?? DEFAULT_BASE_URL[provider];
657
+ const temperature = asFinite(section.temperature) ?? asFinite(ai.temperature) ?? 0.2;
658
+ const max_output_tokens = asFinite(section.max_output_tokens) ?? asFinite(ai.max_output_tokens) ?? 1024;
659
+ const timeout_ms = asFinite(section.timeout_ms) ?? asFinite(ai.timeout_ms) ?? 6e4;
660
+ let api_key;
661
+ if (provider !== "ollama") {
662
+ const envName = asString(section.api_key_env) ?? asString(ai.api_key_env) ?? DEFAULT_KEY_ENV[provider];
663
+ const envValue = envName ? asString(process.env[envName]) : null;
664
+ api_key = asString(section.api_key) ?? asString(ai.api_key) ?? envValue ?? void 0;
665
+ if (!api_key) {
666
+ throw new Error(
667
+ `Missing API key for provider '${provider}'. Set ${envName ?? "the configured api_key_env"} or ai.${provider}.api_key.`
668
+ );
669
+ }
670
+ }
671
+ return {
672
+ provider,
673
+ model,
674
+ base_url,
675
+ api_key,
676
+ temperature,
677
+ max_output_tokens,
678
+ timeout_ms
679
+ };
680
+ }
681
+
682
+ // src/providers/http.ts
683
+ async function post_json(url, init) {
684
+ const controller = new AbortController();
685
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, init.timeout_ms));
686
+ try {
687
+ const response = await fetch(url, {
688
+ method: "POST",
689
+ headers: {
690
+ "content-type": "application/json",
691
+ ...init.headers ?? {}
692
+ },
693
+ body: JSON.stringify(init.body),
694
+ signal: controller.signal
695
+ });
696
+ const text = await response.text();
697
+ if (!response.ok) {
698
+ throw new Error(`Provider HTTP ${response.status}: ${text.slice(0, 500)}`);
699
+ }
700
+ try {
701
+ return JSON.parse(text);
702
+ } catch (error) {
703
+ const message = error instanceof Error ? error.message : String(error);
704
+ throw new Error(`Provider returned invalid JSON: ${message}`);
705
+ }
706
+ } finally {
707
+ clearTimeout(timeout);
708
+ }
709
+ }
710
+
711
+ // src/providers/anthropic.ts
712
+ var AnthropicProvider = class {
713
+ constructor(config) {
714
+ this.config = config;
715
+ }
716
+ name = "anthropic";
717
+ async complete(input) {
718
+ const base = this.config.base_url ?? "https://api.anthropic.com/v1";
719
+ const json = await post_json(`${base}/messages`, {
720
+ headers: {
721
+ "x-api-key": this.config.api_key ?? "",
722
+ "anthropic-version": "2023-06-01"
723
+ },
724
+ body: {
725
+ model: this.config.model,
726
+ max_tokens: this.config.max_output_tokens,
727
+ temperature: this.config.temperature,
728
+ system: input.system,
729
+ messages: [{ role: "user", content: input.prompt }]
730
+ },
731
+ timeout_ms: this.config.timeout_ms
732
+ });
733
+ const text = (json.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text?.trim() ?? "").filter(Boolean).join("\n").trim();
734
+ if (!text) {
735
+ throw new Error("Anthropic response did not include completion text.");
736
+ }
737
+ const tokens = (json.usage?.input_tokens ?? 0) + (json.usage?.output_tokens ?? 0);
738
+ return {
739
+ text,
740
+ total_tokens: tokens > 0 ? tokens : void 0
741
+ };
742
+ }
743
+ };
744
+
745
+ // src/providers/gemini.ts
746
+ var GeminiProvider = class {
747
+ constructor(config) {
748
+ this.config = config;
749
+ }
750
+ name = "gemini";
751
+ async complete(input) {
752
+ const base = this.config.base_url ?? "https://generativelanguage.googleapis.com/v1beta";
753
+ const key = this.config.api_key ?? "";
754
+ const model = encodeURIComponent(this.config.model);
755
+ const json = await post_json(`${base}/models/${model}:generateContent?key=${key}`, {
756
+ body: {
757
+ systemInstruction: {
758
+ parts: [{ text: input.system }]
759
+ },
760
+ generationConfig: {
761
+ temperature: this.config.temperature,
762
+ maxOutputTokens: this.config.max_output_tokens
763
+ },
764
+ contents: [
765
+ {
766
+ role: "user",
767
+ parts: [{ text: input.prompt }]
768
+ }
769
+ ]
770
+ },
771
+ timeout_ms: this.config.timeout_ms
772
+ });
773
+ const text = (json.candidates?.[0]?.content?.parts ?? []).map((part) => part.text?.trim() ?? "").filter(Boolean).join("\n").trim();
774
+ if (!text) {
775
+ throw new Error("Gemini response did not include completion text.");
776
+ }
777
+ return {
778
+ text,
779
+ total_tokens: json.usageMetadata?.totalTokenCount
780
+ };
781
+ }
782
+ };
783
+
784
+ // src/providers/mock.ts
785
+ var MockProvider = class {
786
+ name = "mock";
787
+ async complete(input) {
788
+ const preview = input.prompt.trim().split(/\r?\n/).slice(0, 3).join(" ").slice(0, 120);
789
+ const text = `MOCK_RESPONSE: ${preview}`;
790
+ return {
791
+ text,
792
+ total_tokens: Math.max(1, Math.ceil((input.system.length + input.prompt.length) / 4))
793
+ };
794
+ }
795
+ };
796
+
797
+ // src/providers/ollama.ts
798
+ var OllamaProvider = class {
799
+ constructor(config) {
800
+ this.config = config;
801
+ }
802
+ name = "ollama";
803
+ async complete(input) {
804
+ const base = this.config.base_url ?? "http://localhost:11434";
805
+ const json = await post_json(`${base}/api/chat`, {
806
+ body: {
807
+ model: this.config.model,
808
+ stream: false,
809
+ options: {
810
+ temperature: this.config.temperature,
811
+ num_predict: this.config.max_output_tokens
812
+ },
813
+ messages: [
814
+ { role: "system", content: input.system },
815
+ { role: "user", content: input.prompt }
816
+ ]
817
+ },
818
+ timeout_ms: this.config.timeout_ms
819
+ });
820
+ const text = json.message?.content?.trim();
821
+ if (!text) {
822
+ throw new Error("Ollama response did not include completion text.");
823
+ }
824
+ const tokens = (json.prompt_eval_count ?? 0) + (json.eval_count ?? 0);
825
+ return {
826
+ text,
827
+ total_tokens: tokens > 0 ? tokens : void 0
828
+ };
829
+ }
830
+ };
831
+
832
+ // src/providers/openai.ts
833
+ var OpenAiProvider = class {
834
+ constructor(config) {
835
+ this.config = config;
836
+ }
837
+ name = "openai";
838
+ async complete(input) {
839
+ const base = this.config.base_url ?? "https://api.openai.com/v1";
840
+ const json = await post_json(`${base}/chat/completions`, {
841
+ headers: {
842
+ authorization: `Bearer ${this.config.api_key ?? ""}`
843
+ },
844
+ body: {
845
+ model: this.config.model,
846
+ temperature: this.config.temperature,
847
+ max_tokens: this.config.max_output_tokens,
848
+ messages: [
849
+ { role: "system", content: input.system },
850
+ { role: "user", content: input.prompt }
851
+ ]
852
+ },
853
+ timeout_ms: this.config.timeout_ms
854
+ });
855
+ const text = json.choices?.[0]?.message?.content?.trim();
856
+ if (!text) {
857
+ throw new Error("OpenAI response did not include completion text.");
858
+ }
859
+ return {
860
+ text,
861
+ total_tokens: json.usage?.total_tokens
862
+ };
863
+ }
864
+ };
865
+
866
+ // src/providers/factory.ts
867
+ function create_provider(config) {
868
+ const resolved = resolve_provider_config(config);
869
+ switch (resolved.provider) {
870
+ case "openai":
871
+ return new OpenAiProvider(resolved);
872
+ case "anthropic":
873
+ return new AnthropicProvider(resolved);
874
+ case "gemini":
875
+ return new GeminiProvider(resolved);
876
+ case "ollama":
877
+ return new OllamaProvider(resolved);
878
+ case "mock":
879
+ default:
880
+ return new MockProvider();
881
+ }
882
+ }
883
+
884
+ // src/providers/provider-client.ts
885
+ function parseJsonArray(text) {
886
+ const trimmed = text.trim();
887
+ try {
888
+ const direct = JSON.parse(trimmed);
889
+ if (Array.isArray(direct)) return direct;
890
+ } catch {
891
+ }
892
+ const fence = trimmed.match(/```json\s*([\s\S]*?)```/i);
893
+ if (fence?.[1]) {
894
+ try {
895
+ const parsed = JSON.parse(fence[1]);
896
+ if (Array.isArray(parsed)) return parsed;
897
+ } catch {
898
+ }
899
+ }
900
+ const start = trimmed.indexOf("[");
901
+ const end = trimmed.lastIndexOf("]");
902
+ if (start >= 0 && end > start) {
903
+ const candidate = trimmed.slice(start, end + 1);
904
+ try {
905
+ const parsed = JSON.parse(candidate);
906
+ if (Array.isArray(parsed)) return parsed;
907
+ } catch {
908
+ }
909
+ }
910
+ return null;
911
+ }
912
+ function toTaskDrafts(value) {
913
+ const out = [];
914
+ for (const entry of value) {
915
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
916
+ const record = entry;
917
+ const title = typeof record.title === "string" ? record.title.trim() : "";
918
+ if (!title) continue;
919
+ out.push({
920
+ title,
921
+ body: typeof record.body === "string" ? record.body : void 0,
922
+ type: record.type === "feature" || record.type === "bug" || record.type === "chore" || record.type === "spike" ? record.type : void 0,
923
+ priority: record.priority === "p0" || record.priority === "p1" || record.priority === "p2" || record.priority === "p3" ? record.priority : void 0,
924
+ track: typeof record.track === "string" ? record.track : void 0
925
+ });
926
+ }
927
+ return out;
928
+ }
929
+ function asAgentResponse(text, tokens) {
930
+ return {
931
+ summary: text.trim(),
932
+ tokens_used: tokens
933
+ };
934
+ }
935
+ function create_provider_agent_client(config) {
936
+ const provider = create_provider(config);
937
+ return {
938
+ async generate(input) {
939
+ const result = await provider.complete({
940
+ system: "You are an implementation agent. Return a concise execution summary for the requested step.",
941
+ prompt: input.prompt
942
+ });
943
+ return asAgentResponse(result.text, result.total_tokens);
944
+ },
945
+ async review(input) {
946
+ const result = await provider.complete({
947
+ system: "You are a code-review agent. Return concise review findings, risks, and verdict.",
948
+ prompt: input.prompt
949
+ });
950
+ return asAgentResponse(result.text, result.total_tokens);
951
+ }
952
+ };
953
+ }
954
+ function create_provider_idea_decomposer(config) {
955
+ const provider = create_provider(config);
956
+ return {
957
+ async decompose(prompt) {
958
+ const result = await provider.complete({
959
+ system: "Return ONLY JSON array. Each item must include title, type, priority, body, track.",
960
+ prompt
961
+ });
962
+ const parsed = parseJsonArray(result.text);
963
+ if (!parsed) {
964
+ return [];
965
+ }
966
+ return toTaskDrafts(parsed);
967
+ }
968
+ };
969
+ }
6
970
  export {
7
- coopAiRuntime
971
+ build_contract,
972
+ build_decomposition_prompt,
973
+ constraint_violation_reasons,
974
+ create_provider,
975
+ create_provider_agent_client,
976
+ create_provider_idea_decomposer,
977
+ create_run,
978
+ decompose_idea_to_tasks,
979
+ enforce_constraints,
980
+ execute_task,
981
+ finalize_run,
982
+ log_step,
983
+ resolve_provider_config,
984
+ select_agent,
985
+ validate_command,
986
+ validate_file_access,
987
+ write_run
8
988
  };