@xn-intenton-z2a/agentic-lib 7.2.22 → 7.4.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.
@@ -30,12 +30,6 @@ const flags = args.slice(1);
30
30
 
31
31
  let initChanges = 0;
32
32
  const TASK_COMMANDS = ["transform", "maintain-features", "maintain-library", "fix-code"];
33
- const TASK_AGENT_MAP = {
34
- "transform": "agent-issue-resolution",
35
- "fix-code": "agent-apply-fix",
36
- "maintain-features": "agent-maintain-features",
37
- "maintain-library": "agent-maintain-library",
38
- };
39
33
  const INIT_COMMANDS = ["init", "update", "reset"];
40
34
  const ALL_COMMANDS = [...INIT_COMMANDS, ...TASK_COMMANDS, "version", "mcp", "iterate"];
41
35
 
@@ -372,30 +366,21 @@ async function runIterate() {
372
366
  // ─── Task Runner ─────────────────────────────────────────────────────
373
367
 
374
368
  async function runTask(taskName) {
375
- // Task commands are now aliases for iterate --agent <agent-name>
376
- const agentName = TASK_AGENT_MAP[taskName];
377
- if (!agentName) {
378
- console.error(`Unknown task: ${taskName}`);
379
- return 1;
380
- }
381
-
382
369
  console.log("");
383
- console.log(`=== agentic-lib ${taskName} (→ iterate --agent ${agentName}) ===`);
370
+ console.log(`=== agentic-lib ${taskName} ===`);
384
371
  console.log(`Target: ${target}`);
385
372
  console.log(`Model: ${model}`);
386
373
  console.log(`Dry-run: ${dryRun}`);
387
374
  console.log("");
388
375
 
376
+ // Load config from shared module
389
377
  const { loadConfig } = await import("../src/copilot/config.js");
390
- let config;
391
- try {
392
- config = loadConfig(resolve(target, "agentic-lib.toml"));
393
- } catch {
394
- config = { tuning: {}, model: "gpt-5-mini", paths: {}, writablePaths: [], readOnlyPaths: [] };
395
- }
396
- const effectiveModel = model || config.model || "gpt-5-mini";
378
+ const config = loadConfig(resolve(target, "agentic-lib.toml"));
379
+ const effectiveModel = model || config.model;
397
380
 
398
- console.log(`[config] writable=${(config.writablePaths || []).join(", ")}`);
381
+ console.log(`[config] supervisor=${config.supervisor}`);
382
+ console.log(`[config] writable=${config.writablePaths.join(", ")}`);
383
+ console.log(`[config] test=${config.testScript}`);
399
384
  console.log("");
400
385
 
401
386
  if (dryRun) {
@@ -403,50 +388,62 @@ async function runTask(taskName) {
403
388
  return 0;
404
389
  }
405
390
 
406
- try {
407
- const { loadAgentPrompt } = await import("../src/copilot/agents.js");
408
- const { runHybridSession } = await import("../src/copilot/hybrid-session.js");
409
- const { gatherLocalContext, gatherGitHubContext, buildUserPrompt } = await import("../src/copilot/context.js");
410
-
411
- const agentPrompt = loadAgentPrompt(agentName);
412
- const localContext = gatherLocalContext(target, config);
413
-
414
- let githubContext;
415
- if (issueNumber || prNumber) {
416
- githubContext = gatherGitHubContext({
417
- issueNumber: issueNumber || undefined,
418
- prNumber: prNumber || undefined,
419
- workspacePath: target,
420
- });
421
- }
422
-
423
- const userPrompt = buildUserPrompt(agentName, localContext, githubContext, { tuning: config.tuning });
391
+ // Change to target directory so relative paths in config work
392
+ const originalCwd = process.cwd();
393
+ process.chdir(target);
424
394
 
425
- const result = await runHybridSession({
426
- workspacePath: target,
395
+ try {
396
+ const context = {
397
+ config,
398
+ writablePaths: config.writablePaths,
427
399
  model: effectiveModel,
428
- tuning: config.tuning || {},
429
- timeoutMs,
430
- agentPrompt,
431
- userPrompt,
432
- writablePaths: config.writablePaths?.length > 0 ? config.writablePaths : undefined,
433
- });
400
+ testCommand: config.testScript,
401
+ logger: { info: console.log, warning: console.warn, error: console.error, debug: () => {} },
402
+ };
403
+
404
+ let result;
405
+ switch (taskName) {
406
+ case "transform": {
407
+ const { transform } = await import("../src/copilot/tasks/transform.js");
408
+ result = await transform(context);
409
+ break;
410
+ }
411
+ case "maintain-features": {
412
+ const { maintainFeatures } = await import("../src/copilot/tasks/maintain-features.js");
413
+ result = await maintainFeatures(context);
414
+ break;
415
+ }
416
+ case "maintain-library": {
417
+ const { maintainLibrary } = await import("../src/copilot/tasks/maintain-library.js");
418
+ result = await maintainLibrary(context);
419
+ break;
420
+ }
421
+ case "fix-code": {
422
+ const { fixCode } = await import("../src/copilot/tasks/fix-code.js");
423
+ result = await fixCode(context);
424
+ break;
425
+ }
426
+ default:
427
+ console.error(`Unknown task: ${taskName}`);
428
+ return 1;
429
+ }
434
430
 
435
431
  console.log("");
436
432
  console.log(`=== ${taskName} completed ===`);
437
- console.log(`Success: ${result.success}`);
438
- console.log(`Session time: ${result.sessionTime}s`);
439
- console.log(`Tool calls: ${result.toolCalls}`);
440
- console.log(`Tokens: ${result.tokensIn + result.tokensOut} (in=${result.tokensIn} out=${result.tokensOut})`);
433
+ console.log(`Outcome: ${result.outcome}`);
434
+ if (result.details) console.log(`Details: ${result.details}`);
435
+ if (result.tokensUsed) console.log(`Tokens: ${result.tokensUsed} (in=${result.inputTokens} out=${result.outputTokens})`);
441
436
  if (result.narrative) console.log(`Narrative: ${result.narrative}`);
442
437
  console.log("");
443
- return result.success ? 0 : 1;
438
+ return 0;
444
439
  } catch (err) {
445
440
  console.error("");
446
441
  console.error(`=== ${taskName} FAILED ===`);
447
442
  console.error(err.message);
448
443
  if (err.stack) console.error(err.stack);
449
444
  return 1;
445
+ } finally {
446
+ process.chdir(originalCwd);
450
447
  }
451
448
  }
452
449
 
@@ -1232,7 +1229,6 @@ function runInit() {
1232
1229
 
1233
1230
  initWorkflows();
1234
1231
  initActions(agenticDir);
1235
- initDirContents("copilot", resolve(agenticDir, "copilot"), "Copilot (shared modules)");
1236
1232
  initDirContents("agents", resolve(agenticDir, "agents"), "Agents");
1237
1233
  initDirContents("seeds", resolve(agenticDir, "seeds"), "Seeds");
1238
1234
  initScripts(agenticDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xn-intenton-z2a/agentic-lib",
3
- "version": "7.2.22",
3
+ "version": "7.4.0",
4
4
  "description": "Agentic-lib Agentic Coding Systems SDK powering automated GitHub workflows.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,9 +1,9 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Copyright (C) 2025-2026 Polycode Limited
3
- // config-loader.js — Thin re-export from shared src/copilot/config.js
3
+ // config-loader.js — Parse agentic-lib.toml and resolve paths
4
4
  //
5
- // Phase 4: Configuration logic now lives in src/copilot/config.js.
6
- // This file re-exports for backwards compatibility with existing imports.
5
+ // TOML-only configuration. The config file is required.
6
+ // All defaults are defined here in one place.
7
7
 
8
8
  import { readFileSync, existsSync } from "fs";
9
9
  import { dirname, join } from "path";
@@ -306,4 +306,3 @@ export function getWritablePaths(config, override) {
306
306
  }
307
307
  return config.writablePaths;
308
308
  }
309
- export { loadConfig, getWritablePaths } from "../../copilot/config.js";
@@ -1,63 +1,545 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Copyright (C) 2025-2026 Polycode Limited
3
- // copilot.js — Thin re-export layer from shared src/copilot/ module.
3
+ // copilot.js — Shared utilities for Copilot SDK task handlers
4
4
  //
5
- // Phase 4: All Copilot SDK logic now lives in src/copilot/.
6
- // This file re-exports with an @actions/core logger for backwards compatibility
7
- // with existing task handlers that import from "../copilot.js".
5
+ // Extracts repeated patterns from the 8 task handlers into reusable functions.
8
6
 
7
+ import { CopilotClient, approveAll } from "@github/copilot-sdk";
8
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
9
+ import { join } from "path";
10
+ import { createAgentTools } from "./tools.js";
9
11
  import * as core from "@actions/core";
10
12
 
11
- // Re-export pure functions directly no logger needed
12
- export {
13
- cleanSource,
14
- generateOutline,
15
- filterIssues,
16
- summariseIssue,
17
- extractFeatureSummary,
18
- extractNarrative,
19
- NARRATIVE_INSTRUCTION,
20
- isRateLimitError,
21
- retryDelayMs,
22
- supportsReasoningEffort,
23
- } from "../../copilot/session.js";
24
-
25
- // Re-export path formatting directly — pure function
26
- export { formatPathsSection } from "../../copilot/session.js";
27
-
28
- // Logger that delegates to @actions/core
29
- const actionsLogger = {
30
- info: (...args) => core.info(args.join(" ")),
31
- warning: (...args) => core.warning(args.join(" ")),
32
- error: (...args) => core.error(args.join(" ")),
33
- debug: (...args) => core.debug(args.join(" ")),
34
- };
35
-
36
- // Wrap functions that take a logger parameter to inject the @actions/core logger
37
- import {
38
- readOptionalFile as _readOptionalFile,
39
- scanDirectory as _scanDirectory,
40
- buildClientOptions as _buildClientOptions,
41
- logTuningParam as _logTuningParam,
42
- runCopilotTask as _runCopilotTask,
43
- } from "../../copilot/session.js";
13
+ // Models known to support the reasoningEffort SessionConfig parameter.
14
+ // Updated from Copilot SDK ModelInfo.supportedReasoningEfforts (v0.1.30).
15
+ // When in doubt, omit reasoning-effort — the SDK uses its default.
16
+ const MODELS_SUPPORTING_REASONING_EFFORT = new Set(["gpt-5-mini", "o4-mini"]);
44
17
 
45
- export function readOptionalFile(filePath, limit) {
46
- return _readOptionalFile(filePath, limit);
18
+ /**
19
+ * Strip noise from source code that has zero information value.
20
+ * Removes license headers, collapses blank lines, strips linter directives.
21
+ *
22
+ * @param {string} raw - Raw source code
23
+ * @returns {string} Cleaned source code
24
+ */
25
+ export function cleanSource(raw) {
26
+ let cleaned = raw;
27
+ cleaned = cleaned.replace(/^\/\/\s*SPDX-License-Identifier:.*\n/gm, "");
28
+ cleaned = cleaned.replace(/^\/\/\s*Copyright.*\n/gm, "");
29
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
30
+ cleaned = cleaned.replace(/^\s*\/\/\s*eslint-disable.*\n/gm, "");
31
+ cleaned = cleaned.replace(/^\s*\/\*\s*eslint-disable[\s\S]*?\*\/\s*\n/gm, "");
32
+ return cleaned.trimStart();
47
33
  }
48
34
 
49
- export function scanDirectory(dirPath, extensions, options = {}) {
50
- return _scanDirectory(dirPath, extensions, options, actionsLogger);
35
+ /**
36
+ * Generate a structural outline of a source file using regex-based extraction.
37
+ * Captures imports, exports, function/class declarations with line numbers.
38
+ *
39
+ * @param {string} raw - Raw source code
40
+ * @param {string} filePath - File path for the header line
41
+ * @returns {string} Structural outline
42
+ */
43
+ export function generateOutline(raw, filePath) {
44
+ const lines = raw.split("\n");
45
+ const sizeKB = (raw.length / 1024).toFixed(1);
46
+ const parts = [`// file: ${filePath} (${lines.length} lines, ${sizeKB}KB)`];
47
+
48
+ const importSources = [];
49
+ for (const l of lines) {
50
+ const m = l.match(/^import\s.*from\s+["']([^"']+)["']/);
51
+ if (m) importSources.push(m[1]);
52
+ }
53
+ if (importSources.length > 0) parts.push(`// imports: ${importSources.join(", ")}`);
54
+
55
+ const exportNames = [];
56
+ for (const l of lines) {
57
+ const m = l.match(/^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+(\w+)/);
58
+ if (m) exportNames.push(m[1]);
59
+ }
60
+ if (exportNames.length > 0) parts.push(`// exports: ${exportNames.join(", ")}`);
61
+
62
+ parts.push("//");
63
+
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const l = lines[i];
66
+ const funcMatch = l.match(/^(export\s+)?(async\s+)?function\s+(\w+)\s*\(/);
67
+ if (funcMatch) {
68
+ parts.push(`// function ${funcMatch[3]}() — line ${i + 1}`);
69
+ continue;
70
+ }
71
+ const classMatch = l.match(/^(export\s+)?(default\s+)?class\s+(\w+)/);
72
+ if (classMatch) {
73
+ parts.push(`// class ${classMatch[3]} — line ${i + 1}`);
74
+ continue;
75
+ }
76
+ const methodMatch = l.match(/^\s+(async\s+)?(\w+)\s*\([^)]*\)\s*\{/);
77
+ if (methodMatch && !["if", "for", "while", "switch", "catch", "try"].includes(methodMatch[2])) {
78
+ parts.push(`// ${methodMatch[2]}() — line ${i + 1}`);
79
+ }
80
+ }
81
+
82
+ return parts.join("\n");
83
+ }
84
+
85
+ /**
86
+ * Filter issues by recency, init epoch, and label quality.
87
+ *
88
+ * @param {Array} issues - GitHub issue objects
89
+ * @param {Object} [options]
90
+ * @param {number} [options.staleDays=30] - Issues older than this with no activity are excluded
91
+ * @param {boolean} [options.excludeBotOnly=true] - Exclude issues with only bot labels
92
+ * @param {string} [options.initTimestamp] - ISO timestamp; exclude issues created before this epoch
93
+ * @returns {Array} Filtered issues
94
+ */
95
+ export function filterIssues(issues, options = {}) {
96
+ const { staleDays = 30, excludeBotOnly = true, initTimestamp } = options;
97
+ const cutoff = Date.now() - staleDays * 86400000;
98
+ const initEpoch = initTimestamp ? new Date(initTimestamp).getTime() : 0;
99
+
100
+ return issues.filter((issue) => {
101
+ // Exclude issues created before the most recent init
102
+ if (initEpoch > 0) {
103
+ const created = new Date(issue.created_at).getTime();
104
+ if (created < initEpoch) return false;
105
+ }
106
+
107
+ const lastActivity = new Date(issue.updated_at || issue.created_at).getTime();
108
+ if (lastActivity < cutoff) return false;
109
+
110
+ if (excludeBotOnly) {
111
+ const labels = (issue.labels || []).map((l) => (typeof l === "string" ? l : l.name));
112
+ const botLabels = ["automated", "stale", "bot", "wontfix"];
113
+ if (labels.length > 0 && labels.every((l) => botLabels.includes(l))) return false;
114
+ }
115
+
116
+ return true;
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Create a compact summary of an issue for inclusion in prompts.
122
+ *
123
+ * @param {Object} issue - GitHub issue object
124
+ * @param {number} [bodyLimit=500] - Max chars for issue body
125
+ * @returns {string} Compact issue summary
126
+ */
127
+ export function summariseIssue(issue, bodyLimit = 500) {
128
+ const labels = (issue.labels || []).map((l) => (typeof l === "string" ? l : l.name)).join(", ") || "no labels";
129
+ const age = Math.floor((Date.now() - new Date(issue.created_at).getTime()) / 86400000);
130
+ const body = (issue.body || "").substring(0, bodyLimit).replace(/\n+/g, " ").trim();
131
+ return `#${issue.number}: ${issue.title} [${labels}] (${age}d old)${body ? `\n ${body}` : ""}`;
132
+ }
133
+
134
+ /**
135
+ * Extract a structured summary from a feature markdown file.
136
+ *
137
+ * @param {string} content - Feature file content
138
+ * @param {string} fileName - Feature file name
139
+ * @returns {string} Structured feature summary
140
+ */
141
+ export function extractFeatureSummary(content, fileName) {
142
+ const lines = content.split("\n");
143
+ const title = lines.find((l) => l.startsWith("#"))?.replace(/^#+\s*/, "") || fileName;
144
+ const checked = (content.match(/- \[x\]/gi) || []).length;
145
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
146
+ const total = checked + unchecked;
147
+
148
+ const parts = [`Feature: ${title}`];
149
+ if (total > 0) {
150
+ parts.push(`Status: ${checked}/${total} items complete`);
151
+ const remaining = [];
152
+ for (const line of lines) {
153
+ if (/- \[ \]/.test(line)) {
154
+ remaining.push(line.replace(/^[\s-]*\[ \]\s*/, "").trim());
155
+ }
156
+ }
157
+ if (remaining.length > 0) {
158
+ parts.push(`Remaining: ${remaining.map((r) => `[ ] ${r}`).join(", ")}`);
159
+ }
160
+ }
161
+ return parts.join("\n");
51
162
  }
52
163
 
164
+ /**
165
+ * Build the CopilotClient options for authentication.
166
+ *
167
+ * Auth strategy (in order of preference):
168
+ * 1. COPILOT_GITHUB_TOKEN env var → override GITHUB_TOKEN/GH_TOKEN in subprocess env
169
+ * so the Copilot CLI's auto-login finds the PAT instead of the Actions token.
170
+ * 2. Fall back to whatever auth is available (GITHUB_TOKEN, gh CLI login, etc.)
171
+ *
172
+ * Note: Passing githubToken directly to CopilotClient causes 400 on models.list.
173
+ * Instead we override the env vars so the CLI subprocess picks up the right token
174
+ * via its auto-login flow (useLoggedInUser: true).
175
+ *
176
+ * @param {string} [githubToken] - Optional token; falls back to COPILOT_GITHUB_TOKEN env var.
177
+ */
53
178
  export function buildClientOptions(githubToken) {
54
- return _buildClientOptions(githubToken, actionsLogger);
179
+ const copilotToken = githubToken || process.env.COPILOT_GITHUB_TOKEN;
180
+ if (!copilotToken) {
181
+ throw new Error("COPILOT_GITHUB_TOKEN is required. Set it as a repository secret.");
182
+ }
183
+ core.info("[copilot] COPILOT_GITHUB_TOKEN found — overriding subprocess env");
184
+ const env = { ...process.env };
185
+ // Override both GITHUB_TOKEN and GH_TOKEN so the Copilot CLI
186
+ // subprocess uses the Copilot PAT for its auto-login flow
187
+ env.GITHUB_TOKEN = copilotToken;
188
+ env.GH_TOKEN = copilotToken;
189
+ return { env };
55
190
  }
56
191
 
192
+ /**
193
+ * Log tuning parameter application with profile context.
194
+ *
195
+ * @param {string} param - Parameter name
196
+ * @param {*} value - Resolved value being applied
197
+ * @param {string} profileName - Profile the default came from
198
+ * @param {string} model - Model being used
199
+ * @param {Object} [clip] - Optional clipping info { available, requested }
200
+ */
57
201
  export function logTuningParam(param, value, profileName, model, clip) {
58
- return _logTuningParam(param, value, profileName, model, clip, actionsLogger);
202
+ const clipInfo = clip
203
+ ? ` (requested=${clip.requested}, available=${clip.available}, excess=${clip.requested - clip.available})`
204
+ : "";
205
+ core.info(`[tuning] ${param}=${value} profile=${profileName} model=${model}${clipInfo}`);
206
+ }
207
+
208
+ /**
209
+ * Check if a model supports reasoningEffort.
210
+ * Uses the static allowlist; at runtime the SDK's models.list() could be used
211
+ * but that requires an authenticated client which isn't available at config time.
212
+ *
213
+ * @param {string} model - Model name
214
+ * @returns {boolean}
215
+ */
216
+ export function supportsReasoningEffort(model) {
217
+ return MODELS_SUPPORTING_REASONING_EFFORT.has(model);
218
+ }
219
+
220
+ /**
221
+ * Detect whether an error is an HTTP 429 Too Many Requests (rate limit) response.
222
+ * The Copilot SDK may surface this as an Error with a status, statusCode, or message.
223
+ *
224
+ * @param {unknown} err - The error to test
225
+ * @returns {boolean}
226
+ */
227
+ export function isRateLimitError(err) {
228
+ if (!err || typeof err !== "object") return false;
229
+ const status = err.status ?? err.statusCode ?? err.code;
230
+ if (status === 429 || status === "429") return true;
231
+ const msg = (err.message || "").toLowerCase();
232
+ return msg.includes("429") || msg.includes("too many requests") || msg.includes("rate limit");
233
+ }
234
+
235
+ /**
236
+ * Extract the retry delay in milliseconds from a rate-limit error.
237
+ * Tries the Retry-After header (seconds) first, then falls back to exponential backoff.
238
+ *
239
+ * @param {unknown} err - The error (may have headers or retryAfter)
240
+ * @param {number} attempt - Zero-based attempt number (0 = first retry)
241
+ * @param {number} [baseDelayMs=60000] - Base delay for exponential backoff (ms)
242
+ * @returns {number} Milliseconds to wait before retrying
243
+ */
244
+ export function retryDelayMs(err, attempt, baseDelayMs = 60000) {
245
+ const retryAfterHeader =
246
+ err?.headers?.["retry-after"] ?? err?.retryAfter ?? err?.response?.headers?.["retry-after"];
247
+ if (retryAfterHeader != null) {
248
+ const parsed = Number(retryAfterHeader);
249
+ if (!isNaN(parsed) && parsed > 0) return parsed * 1000;
250
+ }
251
+ return baseDelayMs * Math.pow(2, attempt);
252
+ }
253
+
254
+ /**
255
+ * Run a Copilot SDK session and return the response.
256
+ * Handles the full lifecycle: create client → create session → send → stop.
257
+ * Retries automatically on HTTP 429 rate-limit responses (up to maxRetries times).
258
+ *
259
+ * @param {Object} options
260
+ * @param {string} options.model - Copilot SDK model name
261
+ * @param {string} options.systemMessage - System message content
262
+ * @param {string} options.prompt - The prompt to send
263
+ * @param {string[]} options.writablePaths - Paths the agent may modify
264
+ * @param {string} [options.githubToken] - Optional token; falls back to COPILOT_GITHUB_TOKEN env var.
265
+ * @param {Object} [options.tuning] - Tuning config (reasoningEffort, infiniteSessions)
266
+ * @param {string} [options.profileName] - Profile name for logging
267
+ * @param {number} [options.maxRetries=3] - Maximum number of retry attempts on rate-limit errors
268
+ * @returns {Promise<{content: string, tokensUsed: number}>}
269
+ */
270
+ export async function runCopilotTask({
271
+ model,
272
+ systemMessage,
273
+ prompt,
274
+ writablePaths,
275
+ githubToken,
276
+ tuning,
277
+ profileName,
278
+ maxRetries = 3,
279
+ }) {
280
+ const profile = profileName || tuning?.profileName || "unknown";
281
+
282
+ // Attempt 0 is the initial call; attempts 1..maxRetries are retries after 429s.
283
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
284
+ try {
285
+ return await _runCopilotTaskOnce({ model, systemMessage, prompt, writablePaths, githubToken, tuning, profileName: profile });
286
+ } catch (err) {
287
+ if (isRateLimitError(err) && attempt < maxRetries) {
288
+ const delayMs = retryDelayMs(err, attempt);
289
+ core.warning(
290
+ `[copilot] Rate limit (429) hit — waiting ${Math.round(delayMs / 1000)}s before retry ${attempt + 1}/${maxRetries}`,
291
+ );
292
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
293
+ } else {
294
+ throw err;
295
+ }
296
+ }
297
+ }
298
+ // Unreachable: loop always returns or throws, but satisfies static analysis.
299
+ throw new Error("[copilot] runCopilotTask: all retry attempts exhausted");
300
+ }
301
+
302
+ async function _runCopilotTaskOnce({
303
+ model,
304
+ systemMessage,
305
+ prompt,
306
+ writablePaths,
307
+ githubToken,
308
+ tuning,
309
+ profileName,
310
+ }) {
311
+ const profile = profileName || tuning?.profileName || "unknown";
312
+ core.info(
313
+ `[copilot] Creating client (model=${model}, promptLen=${prompt.length}, writablePaths=${writablePaths.length}, tuning=${tuning?.reasoningEffort || "default"}, profile=${profile})`,
314
+ );
315
+
316
+ const clientOptions = buildClientOptions(githubToken);
317
+ const client = new CopilotClient(clientOptions);
318
+
319
+ try {
320
+ core.info("[copilot] Creating session...");
321
+ const sessionConfig = {
322
+ model,
323
+ systemMessage: { content: systemMessage },
324
+ tools: createAgentTools(writablePaths),
325
+ onPermissionRequest: approveAll,
326
+ workingDirectory: process.cwd(),
327
+ };
328
+
329
+ // Only set reasoningEffort for models that support it
330
+ if (tuning?.reasoningEffort && tuning.reasoningEffort !== "none") {
331
+ if (supportsReasoningEffort(model)) {
332
+ sessionConfig.reasoningEffort = tuning.reasoningEffort;
333
+ logTuningParam("reasoningEffort", tuning.reasoningEffort, profile, model);
334
+ } else {
335
+ core.info(
336
+ `[copilot] Skipping reasoningEffort="${tuning.reasoningEffort}" — not supported by model "${model}". Only supported by: ${[...MODELS_SUPPORTING_REASONING_EFFORT].join(", ")}`,
337
+ );
338
+ }
339
+ }
340
+
341
+ if (tuning?.infiniteSessions === true) {
342
+ sessionConfig.infiniteSessions = {};
343
+ logTuningParam("infiniteSessions", true, profile, model);
344
+ }
345
+
346
+ // Log scan/context tuning params
347
+ if (tuning?.featuresScan) logTuningParam("featuresScan", tuning.featuresScan, profile, model);
348
+ if (tuning?.sourceScan) logTuningParam("sourceScan", tuning.sourceScan, profile, model);
349
+ if (tuning?.sourceContent) logTuningParam("sourceContent", tuning.sourceContent, profile, model);
350
+ if (tuning?.issuesScan) logTuningParam("issuesScan", tuning.issuesScan, profile, model);
351
+ if (tuning?.documentSummary) logTuningParam("documentSummary", tuning.documentSummary, profile, model);
352
+ if (tuning?.discussionComments) logTuningParam("discussionComments", tuning.discussionComments, profile, model);
353
+
354
+ const session = await client.createSession(sessionConfig);
355
+ core.info(`[copilot] Session created: ${session.sessionId}`);
356
+
357
+ // Check auth status now that client is connected
358
+ try {
359
+ const authStatus = await client.getAuthStatus();
360
+ core.info(`[copilot] Auth status: ${JSON.stringify(authStatus)}`);
361
+ } catch (authErr) {
362
+ core.warning(`[copilot] Auth check failed: ${authErr.message}`);
363
+ }
364
+
365
+ // Accumulate usage from assistant.usage events (tokens are NOT on the sendAndWait response)
366
+ let totalInputTokens = 0;
367
+ let totalOutputTokens = 0;
368
+ let totalCost = 0;
369
+
370
+ // Register wildcard event handler for ALL events
371
+ session.on((event) => {
372
+ const eventType = event?.type || "unknown";
373
+ if (eventType === "assistant.message") {
374
+ const preview = event?.data?.content?.substring(0, 100) || "(no content)";
375
+ core.info(`[copilot] event=${eventType}: ${preview}...`);
376
+ } else if (eventType === "assistant.usage") {
377
+ const d = event?.data || {};
378
+ const input = d.inputTokens || 0;
379
+ const output = d.outputTokens || 0;
380
+ const cacheRead = d.cacheReadTokens || 0;
381
+ const cost = d.cost || 0;
382
+ totalInputTokens += input;
383
+ totalOutputTokens += output;
384
+ totalCost += cost;
385
+ core.info(
386
+ `[copilot] event=${eventType}: model=${d.model} input=${input} output=${output} cacheRead=${cacheRead} cost=${cost}`,
387
+ );
388
+ } else if (eventType === "session.idle") {
389
+ core.info(`[copilot] event=${eventType}`);
390
+ } else if (eventType === "session.error") {
391
+ core.error(`[copilot] event=${eventType}: ${JSON.stringify(event?.data || event)}`);
392
+ } else {
393
+ core.info(`[copilot] event=${eventType}: ${JSON.stringify(event?.data || event).substring(0, 200)}`);
394
+ }
395
+ });
396
+
397
+ core.info("[copilot] Sending prompt and waiting for idle...");
398
+ const response = await session.sendAndWait({ prompt }, 600000);
399
+ core.info(`[copilot] sendAndWait resolved`);
400
+ const tokensUsed = totalInputTokens + totalOutputTokens;
401
+ const content = response?.data?.content || "";
402
+
403
+ return { content, tokensUsed, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost };
404
+ } finally {
405
+ await client.stop();
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Extract a [NARRATIVE] line from an LLM response.
411
+ * Returns the text after the tag, or a fallback summary.
412
+ *
413
+ * @param {string} content - Raw LLM response content
414
+ * @param {string} [fallback] - Fallback if no [NARRATIVE] tag found
415
+ * @returns {string} The narrative sentence
416
+ */
417
+ export function extractNarrative(content, fallback) {
418
+ if (!content) return fallback || "";
419
+ const match = content.match(/\[NARRATIVE\]\s*(.+)/);
420
+ if (match) return match[1].trim();
421
+ return fallback || "";
422
+ }
423
+
424
+ /**
425
+ * Narrative solicitation to append to system messages.
426
+ * Asks the LLM to end its response with a one-sentence summary.
427
+ */
428
+ export const NARRATIVE_INSTRUCTION =
429
+ "\n\nAfter completing your task, end your response with a line starting with [NARRATIVE] followed by one plain English sentence describing what you did and why, for the activity log.";
430
+
431
+ /**
432
+ * Read a file, returning empty string on failure. For optional context files.
433
+ *
434
+ * @param {string} filePath - Path to read
435
+ * @param {number} [limit] - Maximum characters to return
436
+ * @returns {string}
437
+ */
438
+ export function readOptionalFile(filePath, limit) {
439
+ try {
440
+ const content = readFileSync(filePath, "utf8");
441
+ return limit ? content.substring(0, limit) : content;
442
+ } catch (err) {
443
+ core.debug(`[readOptionalFile] ${filePath}: ${err.message}`);
444
+ return "";
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Scan a directory for files matching an extension, returning name+content pairs.
450
+ *
451
+ * @param {string} dirPath - Directory to scan
452
+ * @param {string|string[]} extensions - File extension(s) to match (e.g. '.md', ['.js', '.ts'])
453
+ * @param {Object} [options]
454
+ * @param {number} [options.fileLimit=10] - Max files to return
455
+ * @param {number} [options.contentLimit] - Max chars per file content
456
+ * @param {boolean} [options.recursive=false] - Scan recursively
457
+ * @param {boolean} [options.sortByMtime=false] - Sort files by modification time (most recent first)
458
+ * @param {boolean} [options.clean=false] - Strip source noise (license headers, blank lines, linter directives)
459
+ * @param {boolean} [options.outline=false] - Generate structural outline when content exceeds limit
460
+ * @returns {Array<{name: string, content: string}>}
461
+ */
462
+ export function scanDirectory(dirPath, extensions, options = {}) {
463
+ const { fileLimit = 10, contentLimit, recursive = false, sortByMtime = false, clean = false, outline = false } = options;
464
+ const exts = Array.isArray(extensions) ? extensions : [extensions];
465
+
466
+ if (!existsSync(dirPath)) return [];
467
+
468
+ const allFiles = readdirSync(dirPath, recursive ? { recursive: true } : undefined).filter((f) =>
469
+ exts.some((ext) => String(f).endsWith(ext)),
470
+ );
471
+
472
+ if (sortByMtime) {
473
+ allFiles.sort((a, b) => {
474
+ try {
475
+ return statSync(join(dirPath, String(b))).mtimeMs - statSync(join(dirPath, String(a))).mtimeMs;
476
+ } catch {
477
+ return 0;
478
+ }
479
+ });
480
+ }
481
+
482
+ const clipped = allFiles.slice(0, fileLimit);
483
+ if (allFiles.length > fileLimit) {
484
+ core.info(
485
+ `[scanDirectory] Clipped ${dirPath}: ${allFiles.length} files found, returning ${fileLimit} (excess=${allFiles.length - fileLimit})`,
486
+ );
487
+ }
488
+
489
+ return clipped.map((f) => {
490
+ const fileName = String(f);
491
+ try {
492
+ let raw = readFileSync(join(dirPath, fileName), "utf8");
493
+ if (clean) raw = cleanSource(raw);
494
+
495
+ let content;
496
+ if (outline && contentLimit && raw.length > contentLimit) {
497
+ const outlineText = generateOutline(raw, fileName);
498
+ const halfLimit = Math.floor(contentLimit / 2);
499
+ content = outlineText + "\n\n" + raw.substring(0, halfLimit);
500
+ core.info(
501
+ `[scanDirectory] Outlined ${fileName}: ${raw.length} chars → outline + ${halfLimit} chars`,
502
+ );
503
+ } else {
504
+ content = contentLimit ? raw.substring(0, contentLimit) : raw;
505
+ if (contentLimit && raw.length > contentLimit) {
506
+ core.info(
507
+ `[scanDirectory] Clipped ${fileName}: ${raw.length} chars, returning ${contentLimit} (excess=${raw.length - contentLimit})`,
508
+ );
509
+ }
510
+ }
511
+ return { name: fileName, content };
512
+ } catch (err) {
513
+ core.debug(`[scanDirectory] ${join(dirPath, fileName)}: ${err.message}`);
514
+ return { name: fileName, content: "" };
515
+ }
516
+ });
59
517
  }
60
518
 
61
- export async function runCopilotTask(options) {
62
- return _runCopilotTask({ ...options, logger: actionsLogger });
519
+ /**
520
+ * Format the writable/read-only paths section for a prompt.
521
+ *
522
+ * @param {string[]} writablePaths
523
+ * @param {string[]} [readOnlyPaths=[]]
524
+ * @param {Object} [contextFiles] - Optional raw file contents to include
525
+ * @param {string} [contextFiles.configToml] - Raw agentic-lib.toml text
526
+ * @param {string} [contextFiles.packageJson] - Raw package.json text
527
+ * @returns {string}
528
+ */
529
+ export function formatPathsSection(writablePaths, readOnlyPaths = [], contextFiles) {
530
+ const lines = [
531
+ "## File Paths",
532
+ "### Writable (you may modify these)",
533
+ writablePaths.length > 0 ? writablePaths.map((p) => `- ${p}`).join("\n") : "- (none)",
534
+ "",
535
+ "### Read-Only (for context only, do NOT modify)",
536
+ readOnlyPaths.length > 0 ? readOnlyPaths.map((p) => `- ${p}`).join("\n") : "- (none)",
537
+ ];
538
+ if (contextFiles?.configToml) {
539
+ lines.push("", "### Configuration (agentic-lib.toml)", "```toml", contextFiles.configToml, "```");
540
+ }
541
+ if (contextFiles?.packageJson) {
542
+ lines.push("", "### Dependencies (package.json)", "```json", contextFiles.packageJson, "```");
543
+ }
544
+ return lines.join("\n");
63
545
  }
@@ -1,27 +1,142 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Copyright (C) 2025-2026 Polycode Limited
3
- // tools.js — Thin re-export from shared src/copilot/tools.js
3
+ // tools.js — Shared tool definitions for agentic-step task handlers
4
4
  //
5
- // Phase 4: Tool definitions now live in src/copilot/tools.js.
6
- // This file re-exports for backwards compatibility.
7
- //
8
- // Note: The shared tools.js uses a (writablePaths, logger, defineToolFn) signature.
9
- // The old Actions tools.js used (writablePaths) with @actions/core and @github/copilot-sdk.
10
- // This wrapper adapts the signature for existing callers in copilot.js.
5
+ // Defines file I/O and shell tools using the Copilot SDK's defineTool().
6
+ // The agent has no built-in filesystem access — these tools give it the
7
+ // ability to read, write, list files, and run commands.
11
8
 
12
- import * as core from "@actions/core";
13
9
  import { defineTool } from "@github/copilot-sdk";
14
- import { createAgentTools as _createAgentTools, isPathWritable } from "../../copilot/tools.js";
15
-
16
- const actionsLogger = {
17
- info: (...args) => core.info(args.join(" ")),
18
- warning: (...args) => core.warning(args.join(" ")),
19
- error: (...args) => core.error(args.join(" ")),
20
- debug: (...args) => core.debug(args.join(" ")),
21
- };
10
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "fs";
11
+ import { execSync } from "child_process";
12
+ import { dirname, resolve } from "path";
13
+ import { isPathWritable } from "./safety.js";
14
+ import * as core from "@actions/core";
22
15
 
16
+ /**
17
+ * Create the standard set of agent tools.
18
+ *
19
+ * @param {string[]} writablePaths - Paths the agent is allowed to write to
20
+ * @returns {import('@github/copilot-sdk').Tool[]} Array of tools for createSession()
21
+ */
23
22
  export function createAgentTools(writablePaths) {
24
- return _createAgentTools(writablePaths, actionsLogger, defineTool);
25
- }
23
+ const readFile = defineTool("read_file", {
24
+ description: "Read the contents of a file at the given path.",
25
+ parameters: {
26
+ type: "object",
27
+ properties: {
28
+ path: { type: "string", description: "Absolute or relative file path to read" },
29
+ },
30
+ required: ["path"],
31
+ },
32
+ handler: ({ path }) => {
33
+ const resolved = resolve(path);
34
+ core.info(`[tool] read_file: ${resolved}`);
35
+ if (!existsSync(resolved)) {
36
+ return { error: `File not found: ${resolved}` };
37
+ }
38
+ try {
39
+ const content = readFileSync(resolved, "utf8");
40
+ return { content };
41
+ } catch (err) {
42
+ return { error: `Failed to read ${resolved}: ${err.message}` };
43
+ }
44
+ },
45
+ });
26
46
 
27
- export { isPathWritable };
47
+ const writeFile = defineTool("write_file", {
48
+ description:
49
+ "Write content to a file. The file will be created if it does not exist. Parent directories will be created automatically. Only writable paths are allowed.",
50
+ parameters: {
51
+ type: "object",
52
+ properties: {
53
+ path: { type: "string", description: "Absolute or relative file path to write" },
54
+ content: { type: "string", description: "The full content to write to the file" },
55
+ },
56
+ required: ["path", "content"],
57
+ },
58
+ handler: ({ path, content }) => {
59
+ const resolved = resolve(path);
60
+ core.info(`[tool] write_file: ${resolved}`);
61
+ if (!isPathWritable(resolved, writablePaths)) {
62
+ return { error: `Path is not writable: ${path}. Writable paths: ${writablePaths.join(", ")}` };
63
+ }
64
+ try {
65
+ const dir = dirname(resolved);
66
+ if (!existsSync(dir)) {
67
+ mkdirSync(dir, { recursive: true });
68
+ }
69
+ writeFileSync(resolved, content, "utf8");
70
+ return { success: true, path: resolved };
71
+ } catch (err) {
72
+ return { error: `Failed to write ${resolved}: ${err.message}` };
73
+ }
74
+ },
75
+ });
76
+
77
+ const listFiles = defineTool("list_files", {
78
+ description: "List files and directories at the given path. Returns names with a trailing / for directories.",
79
+ parameters: {
80
+ type: "object",
81
+ properties: {
82
+ path: { type: "string", description: "Directory path to list" },
83
+ recursive: { type: "boolean", description: "Whether to list recursively (default false)" },
84
+ },
85
+ required: ["path"],
86
+ },
87
+ handler: ({ path, recursive }) => {
88
+ const resolved = resolve(path);
89
+ core.info(`[tool] list_files: ${resolved} (recursive=${!!recursive})`);
90
+ if (!existsSync(resolved)) {
91
+ return { error: `Directory not found: ${resolved}` };
92
+ }
93
+ try {
94
+ const entries = readdirSync(resolved, { withFileTypes: true, recursive: !!recursive });
95
+ const names = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
96
+ return { files: names };
97
+ } catch (err) {
98
+ return { error: `Failed to list ${resolved}: ${err.message}` };
99
+ }
100
+ },
101
+ });
102
+
103
+ const runCommand = defineTool("run_command", {
104
+ description:
105
+ "Run a shell command and return its stdout and stderr. Use this to run tests, build, lint, or inspect the environment.",
106
+ parameters: {
107
+ type: "object",
108
+ properties: {
109
+ command: { type: "string", description: "The shell command to execute" },
110
+ cwd: { type: "string", description: "Working directory for the command (default: current directory)" },
111
+ },
112
+ required: ["command"],
113
+ },
114
+ handler: ({ command, cwd }) => {
115
+ const workDir = cwd ? resolve(cwd) : process.cwd();
116
+ core.info(`[tool] run_command: ${command} (cwd=${workDir})`);
117
+ const blocked = /\bgit\s+(commit|push|add|reset|checkout|rebase|merge|stash)\b/;
118
+ if (blocked.test(command)) {
119
+ core.info(`[tool] BLOCKED git write command: ${command}`);
120
+ return { error: "Git write commands are not allowed. Use read_file/write_file tools instead." };
121
+ }
122
+ try {
123
+ const stdout = execSync(command, {
124
+ cwd: workDir,
125
+ encoding: "utf8",
126
+ timeout: 120000,
127
+ maxBuffer: 1024 * 1024,
128
+ });
129
+ return { stdout, exitCode: 0 };
130
+ } catch (err) {
131
+ return {
132
+ stdout: err.stdout || "",
133
+ stderr: err.stderr || "",
134
+ exitCode: err.status || 1,
135
+ error: err.message,
136
+ };
137
+ }
138
+ },
139
+ });
140
+
141
+ return [readFile, writeFile, listFiles, runCommand];
142
+ }
@@ -10,13 +10,11 @@ import { resolve, dirname } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
 
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const pkgRoot = resolve(__dirname, "../..");
13
14
 
14
- // Search for the SDK relative to this file's location. Works in both:
15
- // - npm package: src/copilot/sdk.js → ../../node_modules/ or ../actions/agentic-step/node_modules/
16
- // - consumer repo: .github/agentic-lib/copilot/sdk.js → ../actions/agentic-step/node_modules/
17
15
  const SDK_LOCATIONS = [
18
- resolve(__dirname, "../../node_modules/@github/copilot-sdk/dist/index.js"),
19
- resolve(__dirname, "../actions/agentic-step/node_modules/@github/copilot-sdk/dist/index.js"),
16
+ resolve(pkgRoot, "node_modules/@github/copilot-sdk/dist/index.js"),
17
+ resolve(pkgRoot, "src/actions/agentic-step/node_modules/@github/copilot-sdk/dist/index.js"),
20
18
  ];
21
19
 
22
20
  let _sdk = null;
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/tasks/fix-code.js — Fix failing tests (shared)
4
+ //
5
+ // In CLI/local mode: runs tests, feeds failures to agent.
6
+ // In Actions mode: can also resolve merge conflicts and PR check failures.
7
+
8
+ import { execSync } from "child_process";
9
+ import {
10
+ runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection,
11
+ extractNarrative, NARRATIVE_INSTRUCTION,
12
+ } from "../session.js";
13
+ import { defaultLogger } from "../logger.js";
14
+
15
+ /**
16
+ * Fix failing code — local mode (no GitHub context needed).
17
+ * Runs tests, feeds failure output to the agent.
18
+ */
19
+ export async function fixCode(context) {
20
+ const { config, instructions, writablePaths, testCommand, model, logger = defaultLogger } = context;
21
+ const t = config.tuning || {};
22
+ const workDir = context.workingDirectory || process.cwd();
23
+
24
+ // Run tests to get failure output
25
+ let testOutput;
26
+ let testsPassed = false;
27
+ try {
28
+ testOutput = execSync(testCommand, { cwd: workDir, encoding: "utf8", timeout: 120000 });
29
+ testsPassed = true;
30
+ } catch (err) {
31
+ testOutput = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
32
+ }
33
+
34
+ if (testsPassed) {
35
+ logger.info("Tests already pass — nothing to fix");
36
+ return { outcome: "nop", details: "Tests already pass" };
37
+ }
38
+
39
+ const mission = readOptionalFile(config.paths.mission.path);
40
+ const sourceFiles = scanDirectory(config.paths.source.path, [".js", ".ts"], {
41
+ fileLimit: t.sourceScan || 10,
42
+ contentLimit: t.sourceContent || 5000,
43
+ recursive: true, sortByMtime: true, clean: true,
44
+ }, logger);
45
+
46
+ const agentInstructions = instructions || "Fix the failing tests by modifying the source code.";
47
+
48
+ const prompt = [
49
+ "## Instructions", agentInstructions, "",
50
+ ...(mission ? ["## Mission", mission, ""] : []),
51
+ "## Failing Test Output",
52
+ "```", testOutput.substring(0, 8000), "```", "",
53
+ `## Current Source Files (${sourceFiles.length})`,
54
+ ...sourceFiles.map((f) => `### ${f.name}\n\`\`\`\n${f.content}\n\`\`\``), "",
55
+ formatPathsSection(writablePaths, config.readOnlyPaths, config), "",
56
+ "## Constraints",
57
+ `- Run \`${testCommand}\` to validate your fixes`,
58
+ "- Make minimal changes to fix the failing tests",
59
+ "- Do not introduce new features — focus on making the build green",
60
+ ].join("\n");
61
+
62
+ const { content: resultContent, tokensUsed, inputTokens, outputTokens, cost } = await runCopilotTask({
63
+ model,
64
+ systemMessage: "You are an autonomous coding agent fixing failing tests. Analyze the error output and make minimal, targeted changes to fix it." + NARRATIVE_INSTRUCTION,
65
+ prompt, writablePaths, tuning: t, logger, workingDirectory: workDir,
66
+ });
67
+
68
+ return {
69
+ outcome: "fix-applied", tokensUsed, inputTokens, outputTokens, cost, model,
70
+ details: `Applied fix based on test failures`,
71
+ narrative: extractNarrative(resultContent, "Fixed failing tests."),
72
+ };
73
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/tasks/maintain-features.js — Feature lifecycle (shared)
4
+
5
+ import { existsSync } from "fs";
6
+ import {
7
+ runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection,
8
+ extractFeatureSummary, extractNarrative, NARRATIVE_INSTRUCTION,
9
+ } from "../session.js";
10
+ import { defaultLogger } from "../logger.js";
11
+
12
+ export async function maintainFeatures(context) {
13
+ const { config, instructions, writablePaths, model, logger = defaultLogger } = context;
14
+ const t = config.tuning || {};
15
+
16
+ if (existsSync("MISSION_COMPLETE.md") && config.supervisor !== "maintenance") {
17
+ return { outcome: "nop", details: "Mission already complete" };
18
+ }
19
+
20
+ const mission = readOptionalFile(config.paths.mission.path);
21
+ const featuresPath = config.paths.features.path;
22
+ const featureLimit = config.paths.features.limit;
23
+ const features = scanDirectory(featuresPath, ".md", { fileLimit: t.featuresScan || 10 }, logger);
24
+
25
+ features.sort((a, b) => {
26
+ const aInc = /- \[ \]/.test(a.content) ? 0 : 1;
27
+ const bInc = /- \[ \]/.test(b.content) ? 0 : 1;
28
+ return aInc - bInc || a.name.localeCompare(b.name);
29
+ });
30
+
31
+ const libraryDocs = scanDirectory(config.paths.library.path, ".md", { contentLimit: t.documentSummary || 1000 }, logger);
32
+
33
+ const prompt = [
34
+ "## Instructions", instructions || "Maintain the feature set by creating, updating, or pruning features.", "",
35
+ "## Mission", mission, "",
36
+ `## Current Features (${features.length}/${featureLimit} max)`,
37
+ ...features.map((f) => `### ${f.name}\n${f.content}`), "",
38
+ ...(libraryDocs.length > 0 ? [
39
+ `## Library Documents (${libraryDocs.length})`,
40
+ ...libraryDocs.map((d) => `### ${d.name}\n${d.content}`), "",
41
+ ] : []),
42
+ "## Your Task",
43
+ `1. Review each existing feature — delete if implemented or irrelevant.`,
44
+ `2. If fewer than ${featureLimit} features, create new ones aligned with the mission.`,
45
+ "3. Ensure each feature has clear, testable acceptance criteria.", "",
46
+ formatPathsSection(writablePaths, config.readOnlyPaths, config), "",
47
+ "## Constraints", `- Maximum ${featureLimit} feature files`,
48
+ ].join("\n");
49
+
50
+ const { content: resultContent, tokensUsed, inputTokens, outputTokens, cost } = await runCopilotTask({
51
+ model,
52
+ systemMessage: "You are a feature lifecycle manager. Create, update, and prune feature specification files to keep the project focused on its mission." + NARRATIVE_INSTRUCTION,
53
+ prompt, writablePaths, tuning: t, logger,
54
+ });
55
+
56
+ return {
57
+ outcome: "features-maintained", tokensUsed, inputTokens, outputTokens, cost, model,
58
+ details: `Maintained features (${features.length} existing, limit ${featureLimit})`,
59
+ narrative: extractNarrative(resultContent, `Maintained ${features.length} features.`),
60
+ };
61
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/tasks/maintain-library.js — Library management (shared)
4
+
5
+ import { existsSync } from "fs";
6
+ import {
7
+ runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection,
8
+ extractNarrative, NARRATIVE_INSTRUCTION,
9
+ } from "../session.js";
10
+ import { defaultLogger } from "../logger.js";
11
+
12
+ export async function maintainLibrary(context) {
13
+ const { config, instructions, writablePaths, model, logger = defaultLogger } = context;
14
+ const t = config.tuning || {};
15
+
16
+ if (existsSync("MISSION_COMPLETE.md") && config.supervisor !== "maintenance") {
17
+ logger.info("Mission complete — skipping library maintenance");
18
+ return { outcome: "nop", details: "Mission already complete" };
19
+ }
20
+
21
+ const sourcesPath = config.paths.librarySources.path;
22
+ const sources = readOptionalFile(sourcesPath);
23
+ const mission = readOptionalFile(config.paths.mission.path);
24
+ const hasUrls = /https?:\/\//.test(sources);
25
+
26
+ const libraryPath = config.paths.library.path;
27
+ const libraryLimit = config.paths.library.limit;
28
+ const libraryDocs = scanDirectory(libraryPath, ".md", { contentLimit: t.documentSummary || 500 }, logger);
29
+
30
+ let prompt;
31
+ if (!hasUrls) {
32
+ prompt = [
33
+ "## Instructions", instructions || "Maintain the library by updating documents from sources.", "",
34
+ "## Mission", mission || "(no mission)", "",
35
+ "## Current SOURCES.md", sources || "(empty)", "",
36
+ "## Your Task",
37
+ `Populate ${sourcesPath} with 3-8 relevant reference URLs.`, "",
38
+ formatPathsSection(writablePaths, config.readOnlyPaths, config),
39
+ ].join("\n");
40
+ } else {
41
+ prompt = [
42
+ "## Instructions", instructions || "Maintain the library by updating documents from sources.", "",
43
+ "## Sources", sources, "",
44
+ `## Current Library Documents (${libraryDocs.length}/${libraryLimit} max)`,
45
+ ...libraryDocs.map((d) => `### ${d.name}\n${d.content}`), "",
46
+ "## Your Task",
47
+ "1. Read each URL in SOURCES.md and extract technical content.",
48
+ "2. Create or update library documents.", "",
49
+ formatPathsSection(writablePaths, config.readOnlyPaths, config), "",
50
+ "## Constraints", `- Maximum ${libraryLimit} library documents`,
51
+ ].join("\n");
52
+ }
53
+
54
+ const { content: resultContent, tokensUsed, inputTokens, outputTokens, cost } = await runCopilotTask({
55
+ model,
56
+ systemMessage: "You are a knowledge librarian. Maintain a library of technical documents extracted from web sources." + NARRATIVE_INSTRUCTION,
57
+ prompt, writablePaths, tuning: t, logger,
58
+ });
59
+
60
+ return {
61
+ outcome: hasUrls ? "library-maintained" : "sources-discovered",
62
+ tokensUsed, inputTokens, outputTokens, cost, model,
63
+ details: hasUrls ? `Maintained library (${libraryDocs.length} docs)` : "Discovered sources from mission",
64
+ narrative: extractNarrative(resultContent, hasUrls ? "Maintained library." : "Discovered sources."),
65
+ };
66
+ }
@@ -0,0 +1,120 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // src/copilot/tasks/transform.js — Transform task (shared)
4
+ //
5
+ // Ported from src/actions/agentic-step/tasks/transform.js.
6
+ // GitHub context (octokit, issues) is optional for local CLI use.
7
+
8
+ import { existsSync } from "fs";
9
+ import {
10
+ runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection,
11
+ filterIssues, summariseIssue, extractFeatureSummary, extractNarrative, NARRATIVE_INSTRUCTION,
12
+ } from "../session.js";
13
+ import { defaultLogger } from "../logger.js";
14
+
15
+ export async function transform(context) {
16
+ const { config, instructions, writablePaths, testCommand, model, logger = defaultLogger } = context;
17
+ // octokit + repo are optional (absent in CLI mode)
18
+ const octokit = context.octokit || null;
19
+ const repo = context.repo || null;
20
+ const issueNumber = context.issueNumber || null;
21
+ const t = config.tuning || {};
22
+
23
+ const mission = readOptionalFile(config.paths.mission.path);
24
+ if (!mission) {
25
+ logger.warning(`No mission file found at ${config.paths.mission.path}`);
26
+ return { outcome: "nop", details: "No mission file found" };
27
+ }
28
+
29
+ if (existsSync("MISSION_COMPLETE.md") && config.supervisor !== "maintenance") {
30
+ logger.info("Mission complete — skipping transformation");
31
+ return { outcome: "nop", details: "Mission already complete" };
32
+ }
33
+
34
+ const features = scanDirectory(config.paths.features.path, ".md", { fileLimit: t.featuresScan || 10 }, logger);
35
+ const sourceFiles = scanDirectory(config.paths.source.path, [".js", ".ts"], {
36
+ fileLimit: t.sourceScan || 10,
37
+ contentLimit: t.sourceContent || 5000,
38
+ recursive: true, sortByMtime: true, clean: true, outline: true,
39
+ }, logger);
40
+ const webFiles = scanDirectory(config.paths.web?.path || "src/web/", [".html", ".css", ".js"], {
41
+ fileLimit: t.sourceScan || 10,
42
+ contentLimit: t.sourceContent || 5000,
43
+ recursive: true, sortByMtime: true, clean: true,
44
+ }, logger);
45
+
46
+ // GitHub issues (optional)
47
+ let openIssues = [];
48
+ let rawIssuesCount = 0;
49
+ if (octokit && repo) {
50
+ const { data: rawIssues } = await octokit.rest.issues.listForRepo({ ...repo, state: "open", per_page: t.issuesScan || 20 });
51
+ rawIssuesCount = rawIssues.length;
52
+ openIssues = filterIssues(rawIssues, { staleDays: t.staleDays || 30 });
53
+ }
54
+
55
+ let targetIssue = null;
56
+ if (issueNumber && octokit && repo) {
57
+ try {
58
+ const { data: issue } = await octokit.rest.issues.get({ ...repo, issue_number: Number(issueNumber) });
59
+ targetIssue = issue;
60
+ } catch (err) {
61
+ logger.warning(`Could not fetch target issue #${issueNumber}: ${err.message}`);
62
+ }
63
+ }
64
+
65
+ const agentInstructions = instructions || "Transform the repository toward its mission by identifying the next best action.";
66
+
67
+ const prompt = [
68
+ "## Instructions", agentInstructions, "",
69
+ ...(targetIssue ? [
70
+ `## Target Issue #${targetIssue.number}: ${targetIssue.title}`,
71
+ targetIssue.body || "(no description)",
72
+ `Labels: ${targetIssue.labels.map((l) => l.name).join(", ") || "none"}`,
73
+ "", "**Focus your transformation on resolving this specific issue.**", "",
74
+ ] : []),
75
+ "## Mission", mission, "",
76
+ `## Current Features (${features.length})`,
77
+ ...features.map((f) => `### ${f.name}\n${extractFeatureSummary(f.content, f.name)}`), "",
78
+ `## Current Source Files (${sourceFiles.length})`,
79
+ ...sourceFiles.map((f) => `### ${f.name}\n\`\`\`\n${f.content}\n\`\`\``), "",
80
+ ...(webFiles.length > 0 ? [
81
+ `## Website Files (${webFiles.length})`,
82
+ ...webFiles.map((f) => `### ${f.name}\n\`\`\`\n${f.content}\n\`\`\``), "",
83
+ ] : []),
84
+ ...(openIssues.length > 0 ? [
85
+ `## Open Issues (${openIssues.length})`,
86
+ ...openIssues.slice(0, t.issuesScan || 20).map((i) => summariseIssue(i, t.issueBodyLimit || 500)), "",
87
+ ] : []),
88
+ "## Output Artifacts",
89
+ `Save output artifacts to \`${config.paths.examples?.path || "examples/"}\`.`, "",
90
+ "## Your Task",
91
+ "Analyze the mission, features, source code, and open issues.",
92
+ "Determine the single most impactful next step to transform this repository.", "Then implement that step.", "",
93
+ "## When NOT to make changes",
94
+ "If the existing code already satisfies all requirements:", "- Do NOT make cosmetic changes", "- Instead, report that the mission is satisfied", "",
95
+ formatPathsSection(writablePaths, config.readOnlyPaths, config), "",
96
+ "## Constraints", `- Run \`${testCommand}\` to validate your changes`,
97
+ ].join("\n");
98
+
99
+ logger.info(`Transform prompt length: ${prompt.length} chars`);
100
+
101
+ const { content: resultContent, tokensUsed, inputTokens, outputTokens, cost } = await runCopilotTask({
102
+ model,
103
+ systemMessage: "You are an autonomous code transformation agent. Your goal is to advance the repository toward its mission by making the most impactful change possible in a single step." + NARRATIVE_INSTRUCTION,
104
+ prompt, writablePaths, tuning: t, logger,
105
+ });
106
+
107
+ const promptBudget = [
108
+ { section: "mission", size: mission.length, files: "1", notes: "full" },
109
+ { section: "features", size: features.reduce((s, f) => s + f.content.length, 0), files: `${features.length}`, notes: "" },
110
+ { section: "source", size: sourceFiles.reduce((s, f) => s + f.content.length, 0), files: `${sourceFiles.length}`, notes: "" },
111
+ { section: "issues", size: openIssues.length * 80, files: `${openIssues.length}`, notes: `${rawIssuesCount - openIssues.length} filtered` },
112
+ ];
113
+
114
+ return {
115
+ outcome: "transformed", tokensUsed, inputTokens, outputTokens, cost, model,
116
+ details: resultContent.substring(0, 500),
117
+ narrative: extractNarrative(resultContent, "Transformation step completed."),
118
+ promptBudget,
119
+ };
120
+ }
@@ -17,7 +17,7 @@
17
17
  "author": "",
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
- "@xn-intenton-z2a/agentic-lib": "^7.2.22"
20
+ "@xn-intenton-z2a/agentic-lib": "^7.4.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@playwright/test": "^1.58.0",