@xn-intenton-z2a/agentic-lib 7.2.5 → 7.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.github/workflows/agentic-lib-init.yml +56 -0
  2. package/.github/workflows/agentic-lib-test.yml +7 -2
  3. package/.github/workflows/agentic-lib-workflow.yml +50 -3
  4. package/README.md +88 -17
  5. package/agentic-lib.toml +7 -0
  6. package/bin/agentic-lib.js +260 -496
  7. package/package.json +2 -1
  8. package/src/actions/agentic-step/config-loader.js +9 -0
  9. package/src/actions/agentic-step/index.js +104 -7
  10. package/src/actions/agentic-step/tasks/direct.js +435 -0
  11. package/src/actions/agentic-step/tasks/supervise.js +107 -180
  12. package/src/agents/agent-apply-fix.md +5 -2
  13. package/src/agents/agent-director.md +58 -0
  14. package/src/agents/agent-discovery.md +52 -0
  15. package/src/agents/agent-issue-resolution.md +18 -0
  16. package/src/agents/agent-iterate.md +45 -0
  17. package/src/agents/agent-supervisor.md +22 -50
  18. package/src/copilot/agents.js +39 -0
  19. package/src/copilot/config.js +308 -0
  20. package/src/copilot/context.js +318 -0
  21. package/src/copilot/hybrid-session.js +330 -0
  22. package/src/copilot/logger.js +43 -0
  23. package/src/copilot/sdk.js +36 -0
  24. package/src/copilot/session.js +372 -0
  25. package/src/copilot/tasks/fix-code.js +73 -0
  26. package/src/copilot/tasks/maintain-features.js +61 -0
  27. package/src/copilot/tasks/maintain-library.js +66 -0
  28. package/src/copilot/tasks/transform.js +120 -0
  29. package/src/copilot/tools.js +141 -0
  30. package/src/mcp/server.js +43 -25
  31. package/src/seeds/zero-README.md +31 -0
  32. package/src/seeds/zero-behaviour.test.js +12 -4
  33. package/src/seeds/zero-package.json +1 -1
  34. package/src/seeds/zero-playwright.config.js +1 -0
@@ -0,0 +1,318 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/context.js — Context gathering and user prompt assembly
4
+ //
5
+ // Builds user prompts for each agent type from available local and GitHub context.
6
+ // Works with or without GitHub data — local-only context is always sufficient.
7
+
8
+ import { resolve } from "path";
9
+ import { execSync } from "child_process";
10
+ import { scanDirectory, readOptionalFile, extractFeatureSummary, formatPathsSection, summariseIssue, filterIssues } from "./session.js";
11
+ import { defaultLogger } from "./logger.js";
12
+
13
+ /**
14
+ * Context requirements per agent. Defines what context each agent needs.
15
+ * All fields are optional — the builder includes whatever is available.
16
+ */
17
+ const AGENT_CONTEXT = {
18
+ "agent-iterate": { mission: true, source: true, tests: true, features: true },
19
+ "agent-discovery": { source: true, tests: true },
20
+ "agent-issue-resolution": { mission: true, source: true, tests: true, features: true, issues: true },
21
+ "agent-apply-fix": { source: true, tests: true },
22
+ "agent-maintain-features": { mission: true, features: true, issues: true },
23
+ "agent-maintain-library": { library: true, librarySources: true },
24
+ "agent-ready-issue": { mission: true, features: true, issues: true },
25
+ "agent-review-issue": { source: true, tests: true, issues: true },
26
+ "agent-discussion-bot": { mission: true, features: true },
27
+ "agent-supervisor": { mission: true, features: true, issues: true },
28
+ "agent-director": { mission: true, features: true, issues: true, source: true, tests: true },
29
+ };
30
+
31
+ /**
32
+ * Gather local context from the workspace filesystem.
33
+ *
34
+ * @param {string} workspacePath - Path to the workspace
35
+ * @param {Object} config - Parsed agentic config (from config.js)
36
+ * @param {Object} [options]
37
+ * @param {Object} [options.logger]
38
+ * @returns {Object} Context object with all available local data
39
+ */
40
+ export function gatherLocalContext(workspacePath, config, { logger = defaultLogger } = {}) {
41
+ const wsPath = resolve(workspacePath);
42
+ const paths = config.paths || {};
43
+ const tuning = config.tuning || {};
44
+
45
+ const context = {};
46
+
47
+ // Mission
48
+ const missionPath = paths.mission?.path || "MISSION.md";
49
+ context.mission = readOptionalFile(resolve(wsPath, missionPath));
50
+
51
+ // Source files
52
+ const sourcePath = paths.source?.path || "src/lib/";
53
+ const sourceDir = resolve(wsPath, sourcePath);
54
+ context.sourceFiles = scanDirectory(sourceDir, [".js", ".ts", ".mjs", ".cjs"], {
55
+ fileLimit: tuning.sourceScan || 10,
56
+ contentLimit: tuning.sourceContent || 5000,
57
+ sortByMtime: true,
58
+ clean: true,
59
+ outline: true,
60
+ }, logger);
61
+
62
+ // Test files
63
+ const testsPath = paths.tests?.path || "tests/";
64
+ const testsDir = resolve(wsPath, testsPath);
65
+ context.testFiles = scanDirectory(testsDir, [".js", ".ts", ".test.js", ".test.ts", ".spec.js"], {
66
+ fileLimit: tuning.sourceScan || 10,
67
+ contentLimit: tuning.testContent || 3000,
68
+ sortByMtime: true,
69
+ clean: true,
70
+ }, logger);
71
+
72
+ // Features
73
+ const featuresPath = paths.features?.path || "features/";
74
+ const featuresDir = resolve(wsPath, featuresPath);
75
+ const featureFiles = scanDirectory(featuresDir, [".md"], {
76
+ fileLimit: tuning.featuresScan || 10,
77
+ sortByMtime: true,
78
+ }, logger);
79
+ context.features = featureFiles.map((f) => extractFeatureSummary(f.content, f.name));
80
+
81
+ // Library
82
+ const libraryPath = paths.library?.path || "library/";
83
+ const libraryDir = resolve(wsPath, libraryPath);
84
+ context.libraryFiles = scanDirectory(libraryDir, [".md"], {
85
+ fileLimit: 10,
86
+ contentLimit: tuning.documentSummary || 2000,
87
+ }, logger);
88
+
89
+ // Library sources
90
+ const sourcesPath = paths.librarySources?.path || "SOURCES.md";
91
+ context.librarySources = readOptionalFile(resolve(wsPath, sourcesPath));
92
+
93
+ // Contributing guide
94
+ const contributingPath = paths.contributing?.path || "CONTRIBUTING.md";
95
+ context.contributing = readOptionalFile(resolve(wsPath, contributingPath), 2000);
96
+
97
+ // Package.json
98
+ context.packageJson = config.packageJson || readOptionalFile(resolve(wsPath, "package.json"), 3000);
99
+
100
+ // Config TOML
101
+ context.configToml = config.configToml || "";
102
+
103
+ // Paths
104
+ context.writablePaths = config.writablePaths || [];
105
+ context.readOnlyPaths = config.readOnlyPaths || [];
106
+
107
+ // Initial test output
108
+ try {
109
+ context.testOutput = execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
110
+ } catch (err) {
111
+ context.testOutput = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
112
+ }
113
+
114
+ return context;
115
+ }
116
+
117
+ /**
118
+ * Fetch GitHub context using the `gh` CLI.
119
+ * Returns null fields when gh is unavailable or data can't be fetched.
120
+ *
121
+ * @param {Object} options
122
+ * @param {number} [options.issueNumber] - Issue number to fetch
123
+ * @param {number} [options.prNumber] - PR number to fetch
124
+ * @param {string} [options.discussionUrl] - Discussion URL to fetch
125
+ * @param {string} [options.workspacePath] - CWD for gh commands
126
+ * @param {Object} [options.logger]
127
+ * @returns {Object} GitHub context
128
+ */
129
+ export function gatherGitHubContext({ issueNumber, prNumber, discussionUrl, workspacePath, logger = defaultLogger } = {}) {
130
+ const github = { issues: [], issueDetail: null, prDetail: null, discussionDetail: null };
131
+ const cwd = workspacePath || process.cwd();
132
+
133
+ try {
134
+ // Fetch open issues list
135
+ const issuesJson = execSync("gh issue list --state open --limit 20 --json number,title,labels,body,createdAt,updatedAt", {
136
+ cwd,
137
+ encoding: "utf8",
138
+ timeout: 30000,
139
+ });
140
+ const rawIssues = JSON.parse(issuesJson);
141
+ github.issues = filterIssues(rawIssues.map((i) => ({
142
+ number: i.number,
143
+ title: i.title,
144
+ body: i.body,
145
+ labels: i.labels,
146
+ created_at: i.createdAt,
147
+ updated_at: i.updatedAt,
148
+ })));
149
+ } catch (err) {
150
+ logger.info(`[context] Could not fetch issues: ${err.message}`);
151
+ }
152
+
153
+ // Fetch specific issue detail
154
+ if (issueNumber) {
155
+ try {
156
+ const issueJson = execSync(`gh issue view ${issueNumber} --json number,title,body,labels,comments,createdAt`, {
157
+ cwd,
158
+ encoding: "utf8",
159
+ timeout: 30000,
160
+ });
161
+ github.issueDetail = JSON.parse(issueJson);
162
+ } catch (err) {
163
+ logger.info(`[context] Could not fetch issue #${issueNumber}: ${err.message}`);
164
+ }
165
+ }
166
+
167
+ // Fetch specific PR detail
168
+ if (prNumber) {
169
+ try {
170
+ const prJson = execSync(`gh pr view ${prNumber} --json number,title,body,files,statusCheckRollup`, {
171
+ cwd,
172
+ encoding: "utf8",
173
+ timeout: 30000,
174
+ });
175
+ github.prDetail = JSON.parse(prJson);
176
+ } catch (err) {
177
+ logger.info(`[context] Could not fetch PR #${prNumber}: ${err.message}`);
178
+ }
179
+ }
180
+
181
+ // Fetch discussion
182
+ if (discussionUrl) {
183
+ try {
184
+ // Extract discussion number from URL
185
+ const match = discussionUrl.match(/discussions\/(\d+)/);
186
+ if (match) {
187
+ const num = match[1];
188
+ const discussionJson = execSync(
189
+ `gh api graphql -f query='{ repository(owner:"{owner}", name:"{repo}") { discussion(number: ${num}) { title body comments(last: 10) { nodes { body author { login } createdAt } } } } }'`,
190
+ { cwd, encoding: "utf8", timeout: 30000 },
191
+ );
192
+ github.discussionDetail = JSON.parse(discussionJson);
193
+ }
194
+ } catch (err) {
195
+ logger.info(`[context] Could not fetch discussion: ${err.message}`);
196
+ }
197
+ }
198
+
199
+ return github;
200
+ }
201
+
202
+ /**
203
+ * Build a user prompt for the given agent from available context.
204
+ *
205
+ * @param {string} agentName - Agent name (e.g. "agent-iterate")
206
+ * @param {Object} localContext - From gatherLocalContext()
207
+ * @param {Object} [githubContext] - From gatherGitHubContext() (optional)
208
+ * @param {Object} [options]
209
+ * @param {Object} [options.tuning] - Tuning config for limits
210
+ * @returns {string} Assembled user prompt
211
+ */
212
+ export function buildUserPrompt(agentName, localContext, githubContext, { tuning } = {}) {
213
+ const needs = AGENT_CONTEXT[agentName] || AGENT_CONTEXT["agent-iterate"];
214
+ const sections = [];
215
+
216
+ // Mission
217
+ if (needs.mission && localContext.mission) {
218
+ sections.push(`# Mission\n\n${localContext.mission}`);
219
+ }
220
+
221
+ // Current test state
222
+ if (localContext.testOutput) {
223
+ const testPreview = localContext.testOutput.substring(0, 4000);
224
+ sections.push(`# Current Test State\n\n\`\`\`\n${testPreview}\n\`\`\``);
225
+ }
226
+
227
+ // Source files
228
+ if (needs.source && localContext.sourceFiles?.length > 0) {
229
+ const sourceSection = [`# Source Files (${localContext.sourceFiles.length})`];
230
+ for (const f of localContext.sourceFiles) {
231
+ sourceSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
232
+ }
233
+ sections.push(sourceSection.join("\n\n"));
234
+ }
235
+
236
+ // Test files
237
+ if (needs.tests && localContext.testFiles?.length > 0) {
238
+ const testSection = [`# Test Files (${localContext.testFiles.length})`];
239
+ for (const f of localContext.testFiles) {
240
+ testSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
241
+ }
242
+ sections.push(testSection.join("\n\n"));
243
+ }
244
+
245
+ // Features
246
+ if (needs.features && localContext.features?.length > 0) {
247
+ const featureSection = [`# Features (${localContext.features.length})`];
248
+ for (const f of localContext.features) {
249
+ featureSection.push(f);
250
+ }
251
+ sections.push(featureSection.join("\n\n"));
252
+ }
253
+
254
+ // Library
255
+ if (needs.library && localContext.libraryFiles?.length > 0) {
256
+ const libSection = [`# Library Files (${localContext.libraryFiles.length})`];
257
+ for (const f of localContext.libraryFiles) {
258
+ libSection.push(`## ${f.name}\n${f.content}`);
259
+ }
260
+ sections.push(libSection.join("\n\n"));
261
+ }
262
+
263
+ // Library sources
264
+ if (needs.librarySources && localContext.librarySources) {
265
+ sections.push(`# Sources\n\n${localContext.librarySources}`);
266
+ }
267
+
268
+ // Issues (from GitHub context)
269
+ if (needs.issues && githubContext?.issues?.length > 0) {
270
+ const issueSection = [`# Open Issues (${githubContext.issues.length})`];
271
+ for (const issue of githubContext.issues) {
272
+ issueSection.push(summariseIssue(issue, tuning?.issueBodyLimit || 500));
273
+ }
274
+ sections.push(issueSection.join("\n\n"));
275
+ }
276
+
277
+ // Specific issue detail
278
+ if (githubContext?.issueDetail) {
279
+ const issue = githubContext.issueDetail;
280
+ const issueSection = [`# Issue #${issue.number}: ${issue.title}\n\n${issue.body || "(no body)"}`];
281
+ if (issue.comments?.length > 0) {
282
+ issueSection.push("## Comments");
283
+ for (const c of issue.comments.slice(-10)) {
284
+ issueSection.push(`**${c.author?.login || "unknown"}**: ${c.body}`);
285
+ }
286
+ }
287
+ sections.push(issueSection.join("\n\n"));
288
+ }
289
+
290
+ // Specific PR detail
291
+ if (githubContext?.prDetail) {
292
+ const pr = githubContext.prDetail;
293
+ const prSection = [`# PR #${pr.number}: ${pr.title}\n\n${pr.body || "(no body)"}`];
294
+ if (pr.files?.length > 0) {
295
+ prSection.push(`## Changed Files\n${pr.files.map((f) => `- ${f.path}`).join("\n")}`);
296
+ }
297
+ sections.push(prSection.join("\n\n"));
298
+ }
299
+
300
+ // File paths section
301
+ if (localContext.writablePaths?.length > 0 || localContext.readOnlyPaths?.length > 0) {
302
+ sections.push(formatPathsSection(
303
+ localContext.writablePaths || [],
304
+ localContext.readOnlyPaths || [],
305
+ { configToml: localContext.configToml, packageJson: localContext.packageJson },
306
+ ));
307
+ }
308
+
309
+ // Instructions
310
+ sections.push([
311
+ "Implement this mission. Read the existing source code and tests,",
312
+ "make the required changes, run run_tests to verify, and iterate until all tests pass.",
313
+ "",
314
+ "Start by reading the existing files, then implement the solution.",
315
+ ].join("\n"));
316
+
317
+ return sections.join("\n\n");
318
+ }
@@ -0,0 +1,330 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/hybrid-session.js — Single-session hybrid iterator (Phase 2)
4
+ //
5
+ // Replaces the old multi-session runIterationLoop with a single persistent
6
+ // Copilot SDK session that drives its own tool loop. Hooks provide
7
+ // observability and budget control.
8
+ //
9
+ // Phase 1b: Full tool set, writable-path safety, narrative extraction,
10
+ // rate-limit retry, config-driven context.
11
+
12
+ import { existsSync, readFileSync } from "fs";
13
+ import { resolve } from "path";
14
+ import { execSync } from "child_process";
15
+ import { defaultLogger } from "./logger.js";
16
+ import { getSDK } from "./sdk.js";
17
+ import { createAgentTools, isPathWritable } from "./tools.js";
18
+ import { readOptionalFile, extractNarrative, NARRATIVE_INSTRUCTION, isRateLimitError, retryDelayMs } from "./session.js";
19
+
20
+ /**
21
+ * Format tool arguments for human-readable logging.
22
+ */
23
+ function formatToolArgs(toolName, args) {
24
+ if (!args) return "";
25
+ switch (toolName) {
26
+ case "view":
27
+ return args.filePath ? ` → ${args.filePath}` : (args.path ? ` → ${args.path}` : "");
28
+ case "bash":
29
+ return args.command ? ` → ${args.command.substring(0, 120)}` : "";
30
+ case "write_file":
31
+ case "create_file":
32
+ case "edit_file":
33
+ return args.file_path ? ` → ${args.file_path}` : (args.path ? ` → ${args.path}` : "");
34
+ case "read_file":
35
+ return args.file_path ? ` → ${args.file_path}` : (args.path ? ` → ${args.path}` : "");
36
+ case "run_tests":
37
+ return "";
38
+ case "run_command":
39
+ return args.command ? ` → ${args.command.substring(0, 120)}` : "";
40
+ case "list_files":
41
+ return args.path ? ` → ${args.path}` : "";
42
+ case "report_intent":
43
+ return args.intent ? ` → "${args.intent.substring(0, 80)}"` : "";
44
+ default: {
45
+ // Generic: show first string-valued arg
46
+ const firstVal = Object.values(args).find((v) => typeof v === "string");
47
+ return firstVal ? ` → ${firstVal.substring(0, 100)}` : "";
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Run a hybrid iteration: single Copilot SDK session drives mission to completion.
54
+ *
55
+ * @param {Object} options
56
+ * @param {string} options.workspacePath - Path to the workspace
57
+ * @param {string} [options.model="gpt-5-mini"] - Copilot SDK model
58
+ * @param {string} [options.githubToken] - COPILOT_GITHUB_TOKEN
59
+ * @param {Object} [options.tuning] - Tuning config (reasoningEffort, infiniteSessions)
60
+ * @param {number} [options.timeoutMs=600000] - Session timeout
61
+ * @param {string} [options.agentPrompt] - Agent system prompt (loaded from agent .md file)
62
+ * @param {string} [options.userPrompt] - Override user prompt (instead of default mission prompt)
63
+ * @param {string[]} [options.writablePaths] - Writable paths for tool safety (default: workspace)
64
+ * @param {number} [options.maxRetries=2] - Max retries on rate-limit errors
65
+ * @param {Object} [options.logger]
66
+ * @returns {Promise<HybridResult>}
67
+ */
68
+ export async function runHybridSession({
69
+ workspacePath,
70
+ model = "gpt-5-mini",
71
+ githubToken,
72
+ tuning = {},
73
+ timeoutMs = 600000,
74
+ agentPrompt,
75
+ userPrompt,
76
+ writablePaths,
77
+ maxRetries = 2,
78
+ logger = defaultLogger,
79
+ }) {
80
+ const { CopilotClient, approveAll, defineTool } = await getSDK();
81
+
82
+ const copilotToken = githubToken || process.env.COPILOT_GITHUB_TOKEN;
83
+ if (!copilotToken) {
84
+ throw new Error("COPILOT_GITHUB_TOKEN is required. Set it in your environment.");
85
+ }
86
+
87
+ const wsPath = resolve(workspacePath);
88
+
89
+ // ── Writable paths ──────────────────────────────────────────────────
90
+ // Default: entire workspace is writable (local CLI mode)
91
+ const effectiveWritablePaths = writablePaths || [wsPath + "/"];
92
+
93
+ // ── Read mission context (only if no userPrompt override) ─────────
94
+ let missionText;
95
+ let initialTestOutput;
96
+ if (!userPrompt) {
97
+ const missionPath = resolve(wsPath, "MISSION.md");
98
+ missionText = existsSync(missionPath) ? readFileSync(missionPath, "utf8") : "No MISSION.md found";
99
+
100
+ try {
101
+ initialTestOutput = execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
102
+ } catch (err) {
103
+ initialTestOutput = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
104
+ }
105
+ }
106
+
107
+ // ── Metrics ─────────────────────────────────────────────────────────
108
+ const metrics = {
109
+ toolCalls: [],
110
+ testRuns: 0,
111
+ filesWritten: new Set(),
112
+ errors: [],
113
+ startTime: Date.now(),
114
+ };
115
+
116
+ // ── Define run_tests tool ───────────────────────────────────────────
117
+ const runTestsTool = defineTool("run_tests", {
118
+ description: "Run the test suite (npm test) and return pass/fail with output. Call this after making changes to verify correctness.",
119
+ parameters: { type: "object", properties: {}, required: [] },
120
+ handler: async () => {
121
+ metrics.testRuns++;
122
+ try {
123
+ const stdout = execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
124
+ return { textResultForLlm: `TESTS PASSED:\n${stdout}`, resultType: "success" };
125
+ } catch (err) {
126
+ const output = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
127
+ return { textResultForLlm: `TESTS FAILED:\n${output}`, resultType: "success" };
128
+ }
129
+ },
130
+ });
131
+
132
+ // ── Build full tool set ─────────────────────────────────────────────
133
+ // 4 standard tools (read_file, write_file, list_files, run_command) + run_tests
134
+ const agentTools = createAgentTools(effectiveWritablePaths, logger, defineTool);
135
+ const allTools = [...agentTools, runTestsTool];
136
+
137
+ // ── Build system prompt with narrative instruction ─────────────────
138
+ const basePrompt = agentPrompt || [
139
+ "You are an autonomous code transformation agent.",
140
+ "Your workspace is the current working directory.",
141
+ "Implement the MISSION described in the user prompt.",
142
+ "Read existing code, write implementations and tests, then run run_tests to verify.",
143
+ "Keep going until all tests pass or you've exhausted your options.",
144
+ ].join("\n");
145
+ const systemPrompt = basePrompt + NARRATIVE_INSTRUCTION;
146
+
147
+ // ── Session config ─────────────────────────────────────────────────
148
+ logger.info(`[hybrid] Creating session (model=${model}, workspace=${wsPath})`);
149
+
150
+ const client = new CopilotClient({
151
+ env: { ...process.env, GITHUB_TOKEN: copilotToken, GH_TOKEN: copilotToken },
152
+ });
153
+
154
+ const sessionConfig = {
155
+ model,
156
+ systemMessage: { mode: "replace", content: systemPrompt },
157
+ tools: allTools,
158
+ onPermissionRequest: approveAll,
159
+ workingDirectory: wsPath,
160
+ hooks: {
161
+ onPreToolUse: (input) => {
162
+ const n = metrics.toolCalls.length + 1;
163
+ const elapsed = ((Date.now() - metrics.startTime) / 1000).toFixed(0);
164
+ metrics.toolCalls.push({ tool: input.toolName, time: Date.now(), args: input.toolArgs });
165
+ const detail = formatToolArgs(input.toolName, input.toolArgs);
166
+ logger.info(` [tool #${n} +${elapsed}s] ${input.toolName}${detail}`);
167
+ },
168
+ onPostToolUse: (input) => {
169
+ if (/write|edit|create/i.test(input.toolName)) {
170
+ const path = input.toolArgs?.file_path || input.toolArgs?.path || "unknown";
171
+ metrics.filesWritten.add(path);
172
+ logger.info(` → wrote ${path}`);
173
+ }
174
+ if (input.toolName === "run_tests" || input.toolName === "run_command" || input.toolName === "bash") {
175
+ const result = input.toolResult?.textResultForLlm || input.toolResult || "";
176
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
177
+ const passed = /TESTS PASSED|passed|✓|0 fail/i.test(resultStr);
178
+ const failed = /TESTS FAILED|failed|✗|FAIL/i.test(resultStr);
179
+ if (passed && !failed) {
180
+ logger.info(` → tests PASSED`);
181
+ } else if (failed) {
182
+ const failMatch = resultStr.match(/(\d+)\s*(failed|fail)/i);
183
+ logger.info(` → tests FAILED${failMatch ? ` (${failMatch[1]} failures)` : ""}`);
184
+ }
185
+ }
186
+ },
187
+ onErrorOccurred: (input) => {
188
+ metrics.errors.push({ error: input.error, context: input.errorContext, time: Date.now() });
189
+ logger.error(` [error] ${input.errorContext}: ${input.error}`);
190
+ if (input.recoverable) return { errorHandling: "retry", retryCount: 2 };
191
+ return { errorHandling: "abort" };
192
+ },
193
+ },
194
+ };
195
+
196
+ // Infinite sessions for context management
197
+ if (tuning.infiniteSessions !== false) {
198
+ sessionConfig.infiniteSessions = { enabled: true };
199
+ }
200
+
201
+ // Reasoning effort
202
+ if (tuning.reasoningEffort && tuning.reasoningEffort !== "none") {
203
+ const SUPPORTED = new Set(["gpt-5-mini", "o4-mini"]);
204
+ if (SUPPORTED.has(model)) {
205
+ sessionConfig.reasoningEffort = tuning.reasoningEffort;
206
+ }
207
+ }
208
+
209
+ // ── Create session with rate-limit retry ───────────────────────────
210
+ let session;
211
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
212
+ try {
213
+ session = await client.createSession(sessionConfig);
214
+ logger.info(`[hybrid] Session: ${session.sessionId}`);
215
+ break;
216
+ } catch (err) {
217
+ if (isRateLimitError(err) && attempt < maxRetries) {
218
+ const delayMs = retryDelayMs(err, attempt);
219
+ logger.warning(`[hybrid] Rate limit on session creation — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
220
+ await new Promise((r) => setTimeout(r, delayMs));
221
+ } else {
222
+ logger.error(`[hybrid] Failed to create session: ${err.message}`);
223
+ await client.stop();
224
+ throw err;
225
+ }
226
+ }
227
+ }
228
+
229
+ // ── Token tracking ──────────────────────────────────────────────────
230
+ let tokensIn = 0;
231
+ let tokensOut = 0;
232
+
233
+ session.on("assistant.usage", (event) => {
234
+ const inTok = event.data?.inputTokens || 0;
235
+ const outTok = event.data?.outputTokens || 0;
236
+ tokensIn += inTok;
237
+ tokensOut += outTok;
238
+ if (inTok || outTok) {
239
+ logger.info(` [tokens] +${inTok} in / +${outTok} out (cumulative: ${tokensIn} in / ${tokensOut} out)`);
240
+ }
241
+ });
242
+ session.on("assistant.message", (event) => {
243
+ const content = (event.data?.content || "").trim();
244
+ if (content) {
245
+ const firstLine = content.split("\n")[0];
246
+ const preview = firstLine.length > 200 ? firstLine.substring(0, 200) + "..." : firstLine;
247
+ logger.info(` [assistant] ${preview}`);
248
+ }
249
+ });
250
+
251
+ // ── Try autopilot mode ──────────────────────────────────────────────
252
+ try {
253
+ await session.rpc.mode.set({ mode: "autopilot" });
254
+ logger.info("[hybrid] Autopilot mode: active");
255
+ } catch {
256
+ logger.info("[hybrid] Autopilot mode not available — using default mode");
257
+ }
258
+
259
+ // ── Send mission prompt ─────────────────────────────────────────────
260
+ logger.info("[hybrid] Sending mission...\n");
261
+
262
+ const prompt = userPrompt || [
263
+ `# Mission\n\n${missionText}`,
264
+ `# Current test state\n\n\`\`\`\n${initialTestOutput.substring(0, 4000)}\n\`\`\``,
265
+ "",
266
+ "Implement this mission. Read the existing source code and tests,",
267
+ "make the required changes, run run_tests to verify, and iterate until all tests pass.",
268
+ "",
269
+ "Start by reading the existing files, then implement the solution.",
270
+ ].join("\n\n");
271
+
272
+ const t0 = Date.now();
273
+ let response;
274
+ let endReason = "complete";
275
+
276
+ // Send with rate-limit retry
277
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
278
+ try {
279
+ response = await session.sendAndWait({ prompt }, timeoutMs);
280
+ break;
281
+ } catch (err) {
282
+ if (isRateLimitError(err) && attempt < maxRetries) {
283
+ const delayMs = retryDelayMs(err, attempt);
284
+ logger.warning(`[hybrid] Rate limit on sendAndWait — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
285
+ await new Promise((r) => setTimeout(r, delayMs));
286
+ } else {
287
+ logger.error(`[hybrid] Session error: ${err.message}`);
288
+ response = null;
289
+ endReason = "error";
290
+ break;
291
+ }
292
+ }
293
+ }
294
+ const sessionTime = (Date.now() - t0) / 1000;
295
+
296
+ // ── Extract narrative from response ────────────────────────────────
297
+ const agentContent = response?.data?.content || "";
298
+ const narrative = extractNarrative(agentContent, null);
299
+
300
+ // ── Final test run ──────────────────────────────────────────────────
301
+ let finalTestsPassed = false;
302
+ try {
303
+ execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
304
+ finalTestsPassed = true;
305
+ } catch {
306
+ // Tests still failing
307
+ }
308
+
309
+ // ── Cleanup ─────────────────────────────────────────────────────────
310
+ await client.stop();
311
+
312
+ const totalTime = (Date.now() - metrics.startTime) / 1000;
313
+
314
+ return {
315
+ success: finalTestsPassed,
316
+ testsPassed: finalTestsPassed,
317
+ sessionTime: Math.round(sessionTime),
318
+ totalTime: Math.round(totalTime),
319
+ toolCalls: metrics.toolCalls.length,
320
+ testRuns: metrics.testRuns,
321
+ filesWritten: metrics.filesWritten.size,
322
+ tokensIn,
323
+ tokensOut,
324
+ errors: metrics.errors,
325
+ endReason,
326
+ model,
327
+ narrative,
328
+ agentMessage: agentContent.substring(0, 500) || null,
329
+ };
330
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/logger.js — Logger abstraction for shared Copilot module
4
+ //
5
+ // In Actions: wraps @actions/core. In CLI: wraps console.
6
+
7
+ /**
8
+ * Create a logger instance.
9
+ * @param {"actions"|"console"} [backend="console"]
10
+ * @returns {{ info: Function, warning: Function, error: Function, debug: Function }}
11
+ */
12
+ export function createLogger(backend = "console") {
13
+ if (backend === "console") {
14
+ return {
15
+ info: (...args) => console.log(...args),
16
+ warning: (...args) => console.warn(...args),
17
+ error: (...args) => console.error(...args),
18
+ debug: () => {},
19
+ };
20
+ }
21
+ // "actions" backend — lazy-load @actions/core
22
+ let _core;
23
+ const getCore = () => {
24
+ if (!_core) {
25
+ try {
26
+ // Dynamic require — only available in Actions runtime
27
+ _core = require("@actions/core");
28
+ } catch {
29
+ _core = { info: console.log, warning: console.warn, error: console.error, debug: () => {} };
30
+ }
31
+ }
32
+ return _core;
33
+ };
34
+ return {
35
+ info: (...args) => getCore().info(args.join(" ")),
36
+ warning: (...args) => getCore().warning(args.join(" ")),
37
+ error: (...args) => getCore().error(args.join(" ")),
38
+ debug: (...args) => getCore().debug(args.join(" ")),
39
+ };
40
+ }
41
+
42
+ /** Default console logger */
43
+ export const defaultLogger = createLogger("console");