@kal-elsam/kairo-runtime 0.1.5 → 0.2.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.
@@ -0,0 +1,164 @@
1
+ import {
2
+ BACKEND_IDS,
3
+ COST_CLASSES,
4
+ DEFAULT_OLLAMA_HOST,
5
+ PRIVACY_CLASSES,
6
+ createModelDescriptor
7
+ } from "../types.js";
8
+ import { fetchJson } from "../http.js";
9
+ import { CAPABILITY_STATES } from "../../capability-states.js";
10
+
11
+ export function createOllamaBackend({
12
+ host = null,
13
+ fetchImpl = globalThis.fetch,
14
+ env = process.env
15
+ } = {}) {
16
+ const baseUrl = normalizeOllamaHost(host ?? env.OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST);
17
+
18
+ return {
19
+ id: BACKEND_IDS.OLLAMA,
20
+ label: "Ollama",
21
+ local: true,
22
+
23
+ async detect() {
24
+ const result = await fetchJson(`${baseUrl}/api/tags`, {
25
+ timeoutMs: 2000,
26
+ fetchImpl
27
+ });
28
+
29
+ if (!result.ok) {
30
+ return {
31
+ id: BACKEND_IDS.OLLAMA,
32
+ label: "Ollama",
33
+ state: CAPABILITY_STATES.UNKNOWN,
34
+ detected: false,
35
+ available: false,
36
+ host: baseUrl,
37
+ error: result.error,
38
+ recommendation: `Start Ollama locally (${DEFAULT_OLLAMA_HOST}) or set OLLAMA_HOST.`
39
+ };
40
+ }
41
+
42
+ const models = parseOllamaModels(result.data);
43
+ return {
44
+ id: BACKEND_IDS.OLLAMA,
45
+ label: "Ollama",
46
+ state: models.length > 0 ? CAPABILITY_STATES.AVAILABLE : CAPABILITY_STATES.DETECTED,
47
+ detected: true,
48
+ available: models.length > 0,
49
+ host: baseUrl,
50
+ modelCount: models.length,
51
+ error: null,
52
+ recommendation: models.length > 0
53
+ ? `Ollama ready with ${models.length} local model(s).`
54
+ : "Ollama is running but no models are pulled yet."
55
+ };
56
+ },
57
+
58
+ async listModels() {
59
+ const result = await fetchJson(`${baseUrl}/api/tags`, { fetchImpl });
60
+ if (!result.ok) return [];
61
+ return parseOllamaModels(result.data);
62
+ },
63
+
64
+ async capabilities() {
65
+ const detection = await this.detect();
66
+ return {
67
+ id: BACKEND_IDS.OLLAMA,
68
+ local: true,
69
+ cloud: false,
70
+ requiresApiKey: false,
71
+ requiresConsent: false,
72
+ streaming: true,
73
+ tools: false,
74
+ state: detection.state,
75
+ host: baseUrl
76
+ };
77
+ },
78
+
79
+ async invoke(contextPack, request = {}) {
80
+ const modelId = request.modelId;
81
+ if (!modelId) {
82
+ return invokeError("Ollama invoke requires modelId.");
83
+ }
84
+
85
+ const messages = buildChatMessages(contextPack, request);
86
+ const result = await fetchJson(`${baseUrl}/api/chat`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: {
90
+ model: modelId,
91
+ messages,
92
+ stream: false,
93
+ options: request.options ?? undefined
94
+ },
95
+ timeoutMs: request.timeoutMs ?? 120000,
96
+ fetchImpl
97
+ });
98
+
99
+ if (!result.ok) {
100
+ return invokeError(result.error ?? "Ollama chat failed.");
101
+ }
102
+
103
+ const content = result.data?.message?.content ?? "";
104
+ return {
105
+ ok: true,
106
+ backendId: BACKEND_IDS.OLLAMA,
107
+ model: modelId,
108
+ content,
109
+ usage: {
110
+ inputTokens: result.data?.prompt_eval_count ?? null,
111
+ outputTokens: result.data?.eval_count ?? null,
112
+ cachedTokens: null,
113
+ estimatedCost: 0,
114
+ model: modelId,
115
+ backendId: BACKEND_IDS.OLLAMA,
116
+ fallbackUsed: false
117
+ },
118
+ raw: result.data
119
+ };
120
+ }
121
+ };
122
+ }
123
+
124
+ function parseOllamaModels(data) {
125
+ const models = Array.isArray(data?.models) ? data.models : [];
126
+ return models.map((entry) => createModelDescriptor({
127
+ provider: BACKEND_IDS.OLLAMA,
128
+ modelId: entry.name ?? entry.model,
129
+ local: true,
130
+ costClass: COST_CLASSES.LOCAL,
131
+ privacyClass: PRIVACY_CLASSES.LOCAL,
132
+ contextLimit: null,
133
+ tools: false,
134
+ reasoning: false
135
+ })).filter((entry) => Boolean(entry.modelId));
136
+ }
137
+
138
+ function normalizeOllamaHost(host) {
139
+ return String(host).replace(/\/+$/, "");
140
+ }
141
+
142
+ function buildChatMessages(contextPack, request) {
143
+ const messages = [];
144
+ if (contextPack?.systemPrompt) {
145
+ messages.push({ role: "system", content: contextPack.systemPrompt });
146
+ }
147
+ if (Array.isArray(request.messages) && request.messages.length > 0) {
148
+ messages.push(...request.messages);
149
+ } else if (request.prompt) {
150
+ messages.push({ role: "user", content: request.prompt });
151
+ }
152
+ return messages;
153
+ }
154
+
155
+ function invokeError(message) {
156
+ return {
157
+ ok: false,
158
+ backendId: BACKEND_IDS.OLLAMA,
159
+ model: null,
160
+ content: null,
161
+ error: message,
162
+ usage: null
163
+ };
164
+ }
@@ -0,0 +1,198 @@
1
+ import {
2
+ BACKEND_IDS,
3
+ COST_CLASSES,
4
+ OPENROUTER_FREE_MODEL,
5
+ PRIVACY_CLASSES,
6
+ createModelDescriptor
7
+ } from "../types.js";
8
+ import { fetchJson } from "../http.js";
9
+ import { CAPABILITY_STATES } from "../../capability-states.js";
10
+
11
+ const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
12
+
13
+ export function createOpenRouterBackend({
14
+ fetchImpl = globalThis.fetch,
15
+ env = process.env,
16
+ apiKey = null
17
+ } = {}) {
18
+ const resolvedKey = apiKey ?? env.OPENROUTER_API_KEY ?? null;
19
+
20
+ return {
21
+ id: BACKEND_IDS.OPENROUTER,
22
+ label: "OpenRouter",
23
+ local: false,
24
+
25
+ async detect() {
26
+ if (!resolvedKey) {
27
+ return {
28
+ id: BACKEND_IDS.OPENROUTER,
29
+ label: "OpenRouter",
30
+ state: CAPABILITY_STATES.UNKNOWN,
31
+ detected: false,
32
+ available: false,
33
+ hasApiKey: false,
34
+ error: null,
35
+ recommendation: "Set OPENROUTER_API_KEY in the environment to enable OpenRouter (never stored by Kairo)."
36
+ };
37
+ }
38
+
39
+ return {
40
+ id: BACKEND_IDS.OPENROUTER,
41
+ label: "OpenRouter",
42
+ state: CAPABILITY_STATES.AUTHENTICATED,
43
+ detected: true,
44
+ available: true,
45
+ hasApiKey: true,
46
+ error: null,
47
+ recommendation: "OpenRouter API key detected in environment. Cloud use still requires explicit consent."
48
+ };
49
+ },
50
+
51
+ async listModels({ freeOnly = true } = {}) {
52
+ if (!resolvedKey) return [];
53
+
54
+ const result = await fetchJson(`${OPENROUTER_BASE}/models`, {
55
+ headers: {
56
+ Authorization: `Bearer ${resolvedKey}`,
57
+ "Content-Type": "application/json"
58
+ },
59
+ fetchImpl
60
+ });
61
+
62
+ if (!result.ok) {
63
+ return [
64
+ createModelDescriptor({
65
+ provider: BACKEND_IDS.OPENROUTER,
66
+ modelId: OPENROUTER_FREE_MODEL,
67
+ local: false,
68
+ costClass: COST_CLASSES.FREE,
69
+ privacyClass: PRIVACY_CLASSES.CLOUD,
70
+ opaque: true
71
+ })
72
+ ];
73
+ }
74
+
75
+ const models = Array.isArray(result.data?.data) ? result.data.data : [];
76
+ const mapped = models
77
+ .filter((entry) => {
78
+ if (!freeOnly) return true;
79
+ const id = entry.id ?? "";
80
+ return id.endsWith(":free") || id === OPENROUTER_FREE_MODEL;
81
+ })
82
+ .map((entry) => createModelDescriptor({
83
+ provider: BACKEND_IDS.OPENROUTER,
84
+ modelId: entry.id,
85
+ local: false,
86
+ costClass: COST_CLASSES.FREE,
87
+ privacyClass: PRIVACY_CLASSES.CLOUD,
88
+ contextLimit: entry.context_length ?? null,
89
+ tools: Boolean(entry.supported_parameters?.includes?.("tools")),
90
+ reasoning: false
91
+ }));
92
+
93
+ if (!mapped.some((entry) => entry.modelId === OPENROUTER_FREE_MODEL)) {
94
+ mapped.unshift(createModelDescriptor({
95
+ provider: BACKEND_IDS.OPENROUTER,
96
+ modelId: OPENROUTER_FREE_MODEL,
97
+ local: false,
98
+ costClass: COST_CLASSES.FREE,
99
+ privacyClass: PRIVACY_CLASSES.CLOUD,
100
+ opaque: true
101
+ }));
102
+ }
103
+
104
+ return mapped;
105
+ },
106
+
107
+ async capabilities() {
108
+ const detection = await this.detect();
109
+ return {
110
+ id: BACKEND_IDS.OPENROUTER,
111
+ local: false,
112
+ cloud: true,
113
+ requiresApiKey: true,
114
+ requiresConsent: true,
115
+ streaming: true,
116
+ tools: true,
117
+ freeRouterModel: OPENROUTER_FREE_MODEL,
118
+ state: detection.state,
119
+ hasApiKey: Boolean(resolvedKey)
120
+ };
121
+ },
122
+
123
+ async invoke(contextPack, request = {}) {
124
+ if (!resolvedKey) {
125
+ return invokeError("OPENROUTER_API_KEY is not set. Kairo never stores credentials.");
126
+ }
127
+
128
+ const modelId = request.modelId ?? OPENROUTER_FREE_MODEL;
129
+ const messages = buildChatMessages(contextPack, request);
130
+
131
+ const result = await fetchJson(`${OPENROUTER_BASE}/chat/completions`, {
132
+ method: "POST",
133
+ headers: {
134
+ Authorization: `Bearer ${resolvedKey}`,
135
+ "Content-Type": "application/json",
136
+ "HTTP-Referer": "https://github.com/Kal-elSam/harness",
137
+ "X-OpenRouter-Title": "Kairo Runtime"
138
+ },
139
+ body: {
140
+ model: modelId,
141
+ messages,
142
+ stream: false
143
+ },
144
+ timeoutMs: request.timeoutMs ?? 120000,
145
+ fetchImpl
146
+ });
147
+
148
+ if (!result.ok) {
149
+ return invokeError(result.error ?? "OpenRouter chat failed.");
150
+ }
151
+
152
+ const content = result.data?.choices?.[0]?.message?.content ?? "";
153
+ const usedModel = result.data?.model ?? modelId;
154
+ const usage = result.data?.usage ?? {};
155
+
156
+ return {
157
+ ok: true,
158
+ backendId: BACKEND_IDS.OPENROUTER,
159
+ model: usedModel,
160
+ content,
161
+ usage: {
162
+ inputTokens: usage.prompt_tokens ?? null,
163
+ outputTokens: usage.completion_tokens ?? null,
164
+ cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? null,
165
+ estimatedCost: null,
166
+ model: usedModel,
167
+ backendId: BACKEND_IDS.OPENROUTER,
168
+ fallbackUsed: usedModel !== modelId
169
+ },
170
+ raw: result.data
171
+ };
172
+ }
173
+ };
174
+ }
175
+
176
+ function buildChatMessages(contextPack, request) {
177
+ const messages = [];
178
+ if (contextPack?.systemPrompt) {
179
+ messages.push({ role: "system", content: contextPack.systemPrompt });
180
+ }
181
+ if (Array.isArray(request.messages) && request.messages.length > 0) {
182
+ messages.push(...request.messages);
183
+ } else if (request.prompt) {
184
+ messages.push({ role: "user", content: request.prompt });
185
+ }
186
+ return messages;
187
+ }
188
+
189
+ function invokeError(message) {
190
+ return {
191
+ ok: false,
192
+ backendId: BACKEND_IDS.OPENROUTER,
193
+ model: null,
194
+ content: null,
195
+ error: message,
196
+ usage: null
197
+ };
198
+ }
@@ -0,0 +1,338 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, readFile, realpath } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { detectProject } from "../../project-detection.js";
5
+
6
+ const PRIVATE_PATH_PATTERNS = [
7
+ /^\.env(\.|$)/i,
8
+ /(^|\/)\.env(\.|$)/i,
9
+ /(^|\/)secrets?\//i,
10
+ /(^|\/)credentials?\./i,
11
+ /\.pem$/i,
12
+ /\.key$/i,
13
+ /(^|\/)id_rsa/i,
14
+ /(^|\/)\.npmrc$/i,
15
+ /(^|\/)\.netrc$/i
16
+ ];
17
+
18
+ const STABLE_DOC_CANDIDATES = [
19
+ "AGENTS.md",
20
+ "docs/ai/harness.md",
21
+ "docs/ai/architecture.md",
22
+ "docs/ai/conventions.md",
23
+ "docs/ai/testing.md",
24
+ "docs/ai/spec-driven-development.md",
25
+ "docs/ai/test-driven-development.md",
26
+ "docs/ai/provider-routing.md",
27
+ "docs/ai/model-policy.md",
28
+ "docs/ai/context-budget.md"
29
+ ];
30
+
31
+ const DEFAULT_STABLE_BUDGET = 6000;
32
+ const DEFAULT_REQUEST_BUDGET = 4000;
33
+
34
+ export async function compileContextPack({
35
+ workspaceRoot,
36
+ task = null,
37
+ relevantPaths = [],
38
+ includePrivate = false,
39
+ stableBudgetTokens = DEFAULT_STABLE_BUDGET,
40
+ requestBudgetTokens = DEFAULT_REQUEST_BUDGET
41
+ } = {}) {
42
+ const root = resolve(workspaceRoot ?? process.cwd());
43
+ const workspaceRealRoot = await resolveWorkspaceRoot(root);
44
+ const project = await detectProject(root);
45
+ const evidence = [];
46
+
47
+ const agentsMd = await readEvidenceFile(
48
+ root,
49
+ workspaceRealRoot,
50
+ "AGENTS.md",
51
+ evidence,
52
+ 2000,
53
+ { includePrivate }
54
+ );
55
+ const stableDocs = await collectStableDocs(root, workspaceRealRoot, evidence, stableBudgetTokens, includePrivate);
56
+ const skills = await listSkillIds(root, evidence);
57
+ const graphify = await detectGraphify(root, evidence);
58
+ const engram = await detectEngramHints(root, evidence);
59
+
60
+ const requestFiles = [];
61
+ for (const filePath of relevantPaths) {
62
+ const rel = relative(root, resolve(root, filePath));
63
+ const content = await readEvidenceFile(
64
+ root,
65
+ workspaceRealRoot,
66
+ rel,
67
+ evidence,
68
+ requestBudgetTokens,
69
+ { includePrivate }
70
+ );
71
+ if (content) {
72
+ requestFiles.push({ path: rel, content: content.text, truncated: content.truncated });
73
+ }
74
+ }
75
+
76
+ const stable = {
77
+ project: {
78
+ name: project.name,
79
+ purpose: project.purpose,
80
+ stack: project.stack,
81
+ architecturePattern: project.architecturePattern,
82
+ packageManager: project.packageManager,
83
+ commands: project.commands,
84
+ detectedAdapters: project.detectedAdapters
85
+ },
86
+ agentsMd: agentsMd?.text ?? null,
87
+ docs: stableDocs,
88
+ skills,
89
+ sdd: Boolean(stableDocs.find((doc) => doc.path.includes("spec-driven"))),
90
+ tdd: Boolean(stableDocs.find((doc) => doc.path.includes("test-driven"))),
91
+ graphify,
92
+ engram
93
+ };
94
+
95
+ const perRequest = {
96
+ task,
97
+ files: requestFiles
98
+ };
99
+
100
+ const systemPrompt = buildSystemPrompt(stable, perRequest);
101
+ const estimatedTokens = estimateTokens(systemPrompt);
102
+
103
+ return {
104
+ version: 1,
105
+ workspaceRoot: root,
106
+ stable,
107
+ perRequest,
108
+ evidence,
109
+ systemPrompt,
110
+ estimatedTokens,
111
+ budgets: {
112
+ stableBudgetTokens,
113
+ requestBudgetTokens
114
+ },
115
+ privacy: {
116
+ includePrivate,
117
+ excludedPrivate: evidence
118
+ .filter((entry) => entry.kind === "excluded_private")
119
+ .map((entry) => entry.path)
120
+ }
121
+ };
122
+ }
123
+
124
+ export function isPrivatePath(relativePath) {
125
+ const normalized = relativePath.replace(/\\/g, "/");
126
+ return PRIVATE_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
127
+ }
128
+
129
+ export function estimateTokens(text) {
130
+ if (!text) return 0;
131
+ return Math.ceil(text.length / 4);
132
+ }
133
+
134
+ async function collectStableDocs(root, workspaceRealRoot, evidence, budgetTokens, includePrivate) {
135
+ const docs = [];
136
+ let used = 0;
137
+
138
+ for (const candidate of STABLE_DOC_CANDIDATES) {
139
+ if (candidate === "AGENTS.md") continue;
140
+ const remaining = Math.max(500, budgetTokens - used);
141
+ const content = await readEvidenceFile(
142
+ root,
143
+ workspaceRealRoot,
144
+ candidate,
145
+ evidence,
146
+ remaining,
147
+ { includePrivate }
148
+ );
149
+ if (!content) continue;
150
+ docs.push({ path: candidate, content: content.text, truncated: content.truncated });
151
+ used += estimateTokens(content.text);
152
+ if (used >= budgetTokens) break;
153
+ }
154
+
155
+ return docs;
156
+ }
157
+
158
+ async function listSkillIds(root, evidence) {
159
+ const skillRoots = [
160
+ join(root, "docs", "skills"),
161
+ join(root, ".cursor", "skills"),
162
+ join(root, ".codex", "skills"),
163
+ join(root, ".claude", "skills")
164
+ ];
165
+
166
+ const ids = new Set();
167
+ for (const skillRoot of skillRoots) {
168
+ if (!existsSync(skillRoot)) continue;
169
+ evidence.push({ kind: "skills_root", path: relative(root, skillRoot) });
170
+ try {
171
+ const entries = await readdir(skillRoot, { withFileTypes: true });
172
+ for (const entry of entries) {
173
+ if (entry.isDirectory()) ids.add(entry.name);
174
+ }
175
+ } catch {
176
+ // ignore unreadable skill roots
177
+ }
178
+ }
179
+
180
+ return [...ids].sort();
181
+ }
182
+
183
+ async function detectGraphify(root, evidence) {
184
+ const reportPath = join(root, "graphify-out", "GRAPH_REPORT.md");
185
+ const graphPath = join(root, "graphify-out", "graph.json");
186
+ const present = existsSync(reportPath) || existsSync(graphPath);
187
+ if (present) {
188
+ evidence.push({ kind: "graphify", path: "graphify-out" });
189
+ }
190
+ return { present, report: existsSync(reportPath), graph: existsSync(graphPath) };
191
+ }
192
+
193
+ async function detectEngramHints(root, evidence) {
194
+ const memoryDoc = join(root, "docs", "ai", "memory.md");
195
+ const present = existsSync(memoryDoc);
196
+ if (present) {
197
+ evidence.push({ kind: "engram_doc", path: "docs/ai/memory.md" });
198
+ }
199
+ return { documented: present };
200
+ }
201
+
202
+ async function readEvidenceFile(
203
+ root,
204
+ workspaceRealRoot,
205
+ relativePath,
206
+ evidence,
207
+ maxTokens = 2000,
208
+ { includePrivate = false } = {}
209
+ ) {
210
+ const requested = resolve(root, relativePath);
211
+ const requestedRelative = relative(root, requested) || ".";
212
+ if (!isPathInside(root, requested)) {
213
+ evidence.push({
214
+ kind: "rejected_outside_workspace",
215
+ path: requestedRelative,
216
+ reason: "Path is outside workspaceRoot"
217
+ });
218
+ return null;
219
+ }
220
+
221
+ if (!existsSync(requested)) return null;
222
+
223
+ let target;
224
+ try {
225
+ target = await realpath(requested);
226
+ } catch {
227
+ evidence.push({ kind: "unreadable", path: requestedRelative });
228
+ return null;
229
+ }
230
+
231
+ if (!isPathInside(workspaceRealRoot, target)) {
232
+ evidence.push({
233
+ kind: "rejected_outside_workspace",
234
+ path: requestedRelative,
235
+ reason: "Symlink target is outside workspaceRoot"
236
+ });
237
+ return null;
238
+ }
239
+
240
+ const targetRelative = relative(workspaceRealRoot, target) || ".";
241
+ if ((isPrivatePath(requestedRelative) || isPrivatePath(targetRelative)) && !includePrivate) {
242
+ evidence.push({
243
+ kind: "excluded_private",
244
+ path: requestedRelative,
245
+ reason: "Private path excluded without consent"
246
+ });
247
+ return null;
248
+ }
249
+
250
+ try {
251
+ const raw = await readFile(target, "utf8");
252
+ const maxChars = maxTokens * 4;
253
+ const truncated = raw.length > maxChars;
254
+ const text = truncated ? `${raw.slice(0, maxChars)}\n…[truncated]` : raw;
255
+ evidence.push({
256
+ kind: "file",
257
+ path: requestedRelative,
258
+ truncated,
259
+ chars: text.length
260
+ });
261
+ return { text, truncated };
262
+ } catch {
263
+ evidence.push({ kind: "unreadable", path: requestedRelative });
264
+ return null;
265
+ }
266
+ }
267
+
268
+ async function resolveWorkspaceRoot(root) {
269
+ try {
270
+ return await realpath(root);
271
+ } catch {
272
+ return root;
273
+ }
274
+ }
275
+
276
+ function isPathInside(root, candidate) {
277
+ const rel = relative(root, candidate);
278
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${requirePathSeparator()}`));
279
+ }
280
+
281
+ function requirePathSeparator() {
282
+ return process.platform === "win32" ? "\\" : "/";
283
+ }
284
+
285
+ function buildSystemPrompt(stable, perRequest) {
286
+ const lines = [
287
+ "You are assisting inside Kairo Runtime harness governance.",
288
+ "Prefer architecture before implementation, existing patterns, minimal diffs, tests/specs.",
289
+ "Do not invent speculative code, unnecessary dependencies, or duplicate abstractions.",
290
+ "Never request or echo secrets. Private files require explicit human consent.",
291
+ "",
292
+ "## Project",
293
+ `Name: ${stable.project.name}`,
294
+ `Purpose: ${stable.project.purpose}`,
295
+ `Stack: ${stable.project.stack}`,
296
+ `Architecture: ${stable.project.architecturePattern}`,
297
+ `Package manager: ${stable.project.packageManager}`,
298
+ `Commands: ${JSON.stringify(stable.project.commands)}`,
299
+ `Adapters: ${(stable.project.detectedAdapters ?? []).join(", ") || "none"}`,
300
+ ""
301
+ ];
302
+
303
+ if (stable.agentsMd) {
304
+ lines.push("## AGENTS.md", stable.agentsMd, "");
305
+ }
306
+
307
+ if (stable.docs.length > 0) {
308
+ lines.push("## Stable docs");
309
+ for (const doc of stable.docs) {
310
+ lines.push(`### ${doc.path}`, doc.content, "");
311
+ }
312
+ }
313
+
314
+ if (stable.skills.length > 0) {
315
+ lines.push(`## Skills available: ${stable.skills.join(", ")}`, "");
316
+ }
317
+
318
+ lines.push(
319
+ `## SDD: ${stable.sdd ? "documented" : "not detected"}`,
320
+ `## TDD: ${stable.tdd ? "documented" : "not detected"}`,
321
+ `## Graphify: ${stable.graphify.present ? "present" : "absent"}`,
322
+ `## Engram docs: ${stable.engram.documented ? "present" : "absent"}`,
323
+ ""
324
+ );
325
+
326
+ if (perRequest.task) {
327
+ lines.push("## Task", perRequest.task, "");
328
+ }
329
+
330
+ if (perRequest.files.length > 0) {
331
+ lines.push("## Relevant files");
332
+ for (const file of perRequest.files) {
333
+ lines.push(`### ${file.path}`, file.content, "");
334
+ }
335
+ }
336
+
337
+ return lines.join("\n");
338
+ }