@xn-intenton-z2a/agentic-lib 7.1.60 → 7.1.62

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.
@@ -5,11 +5,154 @@
5
5
  // Extracts repeated patterns from the 8 task handlers into reusable functions.
6
6
 
7
7
  import { CopilotClient, approveAll } from "@github/copilot-sdk";
8
- import { readFileSync, readdirSync, existsSync } from "fs";
8
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { createAgentTools } from "./tools.js";
11
11
  import * as core from "@actions/core";
12
12
 
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"]);
17
+
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();
33
+ }
34
+
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 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
+ * @returns {Array} Filtered issues
93
+ */
94
+ export function filterIssues(issues, options = {}) {
95
+ const { staleDays = 30, excludeBotOnly = true } = options;
96
+ const cutoff = Date.now() - staleDays * 86400000;
97
+
98
+ return issues.filter((issue) => {
99
+ const lastActivity = new Date(issue.updated_at || issue.created_at).getTime();
100
+ if (lastActivity < cutoff) return false;
101
+
102
+ if (excludeBotOnly) {
103
+ const labels = (issue.labels || []).map((l) => (typeof l === "string" ? l : l.name));
104
+ const botLabels = ["automated", "stale", "bot", "wontfix"];
105
+ if (labels.length > 0 && labels.every((l) => botLabels.includes(l))) return false;
106
+ }
107
+
108
+ return true;
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Create a compact summary of an issue for inclusion in prompts.
114
+ *
115
+ * @param {Object} issue - GitHub issue object
116
+ * @param {number} [bodyLimit=500] - Max chars for issue body
117
+ * @returns {string} Compact issue summary
118
+ */
119
+ export function summariseIssue(issue, bodyLimit = 500) {
120
+ const labels = (issue.labels || []).map((l) => (typeof l === "string" ? l : l.name)).join(", ") || "no labels";
121
+ const age = Math.floor((Date.now() - new Date(issue.created_at).getTime()) / 86400000);
122
+ const body = (issue.body || "").substring(0, bodyLimit).replace(/\n+/g, " ").trim();
123
+ return `#${issue.number}: ${issue.title} [${labels}] (${age}d old)${body ? `\n ${body}` : ""}`;
124
+ }
125
+
126
+ /**
127
+ * Extract a structured summary from a feature markdown file.
128
+ *
129
+ * @param {string} content - Feature file content
130
+ * @param {string} fileName - Feature file name
131
+ * @returns {string} Structured feature summary
132
+ */
133
+ export function extractFeatureSummary(content, fileName) {
134
+ const lines = content.split("\n");
135
+ const title = lines.find((l) => l.startsWith("#"))?.replace(/^#+\s*/, "") || fileName;
136
+ const checked = (content.match(/- \[x\]/gi) || []).length;
137
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
138
+ const total = checked + unchecked;
139
+
140
+ const parts = [`Feature: ${title}`];
141
+ if (total > 0) {
142
+ parts.push(`Status: ${checked}/${total} items complete`);
143
+ const remaining = [];
144
+ for (const line of lines) {
145
+ if (/- \[ \]/.test(line)) {
146
+ remaining.push(line.replace(/^[\s-]*\[ \]\s*/, "").trim());
147
+ }
148
+ }
149
+ if (remaining.length > 0) {
150
+ parts.push(`Remaining: ${remaining.map((r) => `[ ] ${r}`).join(", ")}`);
151
+ }
152
+ }
153
+ return parts.join("\n");
154
+ }
155
+
13
156
  /**
14
157
  * Build the CopilotClient options for authentication.
15
158
  *
@@ -38,6 +181,34 @@ export function buildClientOptions(githubToken) {
38
181
  return { env };
39
182
  }
40
183
 
184
+ /**
185
+ * Log tuning parameter application with profile context.
186
+ *
187
+ * @param {string} param - Parameter name
188
+ * @param {*} value - Resolved value being applied
189
+ * @param {string} profileName - Profile the default came from
190
+ * @param {string} model - Model being used
191
+ * @param {Object} [clip] - Optional clipping info { available, requested }
192
+ */
193
+ export function logTuningParam(param, value, profileName, model, clip) {
194
+ const clipInfo = clip
195
+ ? ` (requested=${clip.requested}, available=${clip.available}, excess=${clip.requested - clip.available})`
196
+ : "";
197
+ core.info(`[tuning] ${param}=${value} profile=${profileName} model=${model}${clipInfo}`);
198
+ }
199
+
200
+ /**
201
+ * Check if a model supports reasoningEffort.
202
+ * Uses the static allowlist; at runtime the SDK's models.list() could be used
203
+ * but that requires an authenticated client which isn't available at config time.
204
+ *
205
+ * @param {string} model - Model name
206
+ * @returns {boolean}
207
+ */
208
+ export function supportsReasoningEffort(model) {
209
+ return MODELS_SUPPORTING_REASONING_EFFORT.has(model);
210
+ }
211
+
41
212
  /**
42
213
  * Run a Copilot SDK session and return the response.
43
214
  * Handles the full lifecycle: create client → create session → send → stop.
@@ -49,11 +220,21 @@ export function buildClientOptions(githubToken) {
49
220
  * @param {string[]} options.writablePaths - Paths the agent may modify
50
221
  * @param {string} [options.githubToken] - Optional token; falls back to COPILOT_GITHUB_TOKEN env var.
51
222
  * @param {Object} [options.tuning] - Tuning config (reasoningEffort, infiniteSessions)
223
+ * @param {string} [options.profileName] - Profile name for logging
52
224
  * @returns {Promise<{content: string, tokensUsed: number}>}
53
225
  */
54
- export async function runCopilotTask({ model, systemMessage, prompt, writablePaths, githubToken, tuning }) {
226
+ export async function runCopilotTask({
227
+ model,
228
+ systemMessage,
229
+ prompt,
230
+ writablePaths,
231
+ githubToken,
232
+ tuning,
233
+ profileName,
234
+ }) {
235
+ const profile = profileName || "unknown";
55
236
  core.info(
56
- `[copilot] Creating client (model=${model}, promptLen=${prompt.length}, writablePaths=${writablePaths.length}, tuning=${tuning?.reasoningEffort || "default"})`,
237
+ `[copilot] Creating client (model=${model}, promptLen=${prompt.length}, writablePaths=${writablePaths.length}, tuning=${tuning?.reasoningEffort || "default"}, profile=${profile})`,
57
238
  );
58
239
 
59
240
  const clientOptions = buildClientOptions(githubToken);
@@ -68,12 +249,32 @@ export async function runCopilotTask({ model, systemMessage, prompt, writablePat
68
249
  onPermissionRequest: approveAll,
69
250
  workingDirectory: process.cwd(),
70
251
  };
71
- if (tuning?.reasoningEffort) {
72
- sessionConfig.reasoningEffort = tuning.reasoningEffort;
252
+
253
+ // Only set reasoningEffort for models that support it
254
+ if (tuning?.reasoningEffort && tuning.reasoningEffort !== "none") {
255
+ if (supportsReasoningEffort(model)) {
256
+ sessionConfig.reasoningEffort = tuning.reasoningEffort;
257
+ logTuningParam("reasoningEffort", tuning.reasoningEffort, profile, model);
258
+ } else {
259
+ core.info(
260
+ `[copilot] Skipping reasoningEffort="${tuning.reasoningEffort}" — not supported by model "${model}". Only supported by: ${[...MODELS_SUPPORTING_REASONING_EFFORT].join(", ")}`,
261
+ );
262
+ }
73
263
  }
264
+
74
265
  if (tuning?.infiniteSessions === true) {
75
266
  sessionConfig.infiniteSessions = {};
267
+ logTuningParam("infiniteSessions", true, profile, model);
76
268
  }
269
+
270
+ // Log scan/context tuning params
271
+ if (tuning?.featuresScan) logTuningParam("featuresScan", tuning.featuresScan, profile, model);
272
+ if (tuning?.sourceScan) logTuningParam("sourceScan", tuning.sourceScan, profile, model);
273
+ if (tuning?.sourceContent) logTuningParam("sourceContent", tuning.sourceContent, profile, model);
274
+ if (tuning?.issuesScan) logTuningParam("issuesScan", tuning.issuesScan, profile, model);
275
+ if (tuning?.documentSummary) logTuningParam("documentSummary", tuning.documentSummary, profile, model);
276
+ if (tuning?.discussionComments) logTuningParam("discussionComments", tuning.discussionComments, profile, model);
277
+
77
278
  const session = await client.createSession(sessionConfig);
78
279
  core.info(`[copilot] Session created: ${session.sessionId}`);
79
280
 
@@ -105,7 +306,9 @@ export async function runCopilotTask({ model, systemMessage, prompt, writablePat
105
306
  totalInputTokens += input;
106
307
  totalOutputTokens += output;
107
308
  totalCost += cost;
108
- core.info(`[copilot] event=${eventType}: model=${d.model} input=${input} output=${output} cacheRead=${cacheRead} cost=${cost}`);
309
+ core.info(
310
+ `[copilot] event=${eventType}: model=${d.model} input=${input} output=${output} cacheRead=${cacheRead} cost=${cost}`,
311
+ );
109
312
  } else if (eventType === "session.idle") {
110
313
  core.info(`[copilot] event=${eventType}`);
111
314
  } else if (eventType === "session.error") {
@@ -153,26 +356,66 @@ export function readOptionalFile(filePath, limit) {
153
356
  * @param {number} [options.fileLimit=10] - Max files to return
154
357
  * @param {number} [options.contentLimit] - Max chars per file content
155
358
  * @param {boolean} [options.recursive=false] - Scan recursively
359
+ * @param {boolean} [options.sortByMtime=false] - Sort files by modification time (most recent first)
360
+ * @param {boolean} [options.clean=false] - Strip source noise (license headers, blank lines, linter directives)
361
+ * @param {boolean} [options.outline=false] - Generate structural outline when content exceeds limit
156
362
  * @returns {Array<{name: string, content: string}>}
157
363
  */
158
364
  export function scanDirectory(dirPath, extensions, options = {}) {
159
- const { fileLimit = 10, contentLimit, recursive = false } = options;
365
+ const { fileLimit = 10, contentLimit, recursive = false, sortByMtime = false, clean = false, outline = false } = options;
160
366
  const exts = Array.isArray(extensions) ? extensions : [extensions];
161
367
 
162
368
  if (!existsSync(dirPath)) return [];
163
369
 
164
- return readdirSync(dirPath, recursive ? { recursive: true } : undefined)
165
- .filter((f) => exts.some((ext) => f.endsWith(ext)))
166
- .slice(0, fileLimit)
167
- .map((f) => {
370
+ const allFiles = readdirSync(dirPath, recursive ? { recursive: true } : undefined).filter((f) =>
371
+ exts.some((ext) => String(f).endsWith(ext)),
372
+ );
373
+
374
+ if (sortByMtime) {
375
+ allFiles.sort((a, b) => {
168
376
  try {
169
- const content = readFileSync(join(dirPath, f), "utf8");
170
- return { name: f, content: contentLimit ? content.substring(0, contentLimit) : content };
171
- } catch (err) {
172
- core.debug(`[scanDirectory] ${join(dirPath, f)}: ${err.message}`);
173
- return { name: f, content: "" };
377
+ return statSync(join(dirPath, String(b))).mtimeMs - statSync(join(dirPath, String(a))).mtimeMs;
378
+ } catch {
379
+ return 0;
174
380
  }
175
381
  });
382
+ }
383
+
384
+ const clipped = allFiles.slice(0, fileLimit);
385
+ if (allFiles.length > fileLimit) {
386
+ core.info(
387
+ `[scanDirectory] Clipped ${dirPath}: ${allFiles.length} files found, returning ${fileLimit} (excess=${allFiles.length - fileLimit})`,
388
+ );
389
+ }
390
+
391
+ return clipped.map((f) => {
392
+ const fileName = String(f);
393
+ try {
394
+ let raw = readFileSync(join(dirPath, fileName), "utf8");
395
+ if (clean) raw = cleanSource(raw);
396
+
397
+ let content;
398
+ if (outline && contentLimit && raw.length > contentLimit) {
399
+ const outlineText = generateOutline(raw, fileName);
400
+ const halfLimit = Math.floor(contentLimit / 2);
401
+ content = outlineText + "\n\n" + raw.substring(0, halfLimit);
402
+ core.info(
403
+ `[scanDirectory] Outlined ${fileName}: ${raw.length} chars → outline + ${halfLimit} chars`,
404
+ );
405
+ } else {
406
+ content = contentLimit ? raw.substring(0, contentLimit) : raw;
407
+ if (contentLimit && raw.length > contentLimit) {
408
+ core.info(
409
+ `[scanDirectory] Clipped ${fileName}: ${raw.length} chars, returning ${contentLimit} (excess=${raw.length - contentLimit})`,
410
+ );
411
+ }
412
+ }
413
+ return { name: fileName, content };
414
+ } catch (err) {
415
+ core.debug(`[scanDirectory] ${join(dirPath, fileName)}: ${err.message}`);
416
+ return { name: fileName, content: "" };
417
+ }
418
+ });
176
419
  }
177
420
 
178
421
  /**
@@ -8,7 +8,7 @@
8
8
  import * as core from "@actions/core";
9
9
  import * as github from "@actions/github";
10
10
  import { loadConfig, getWritablePaths } from "./config-loader.js";
11
- import { logActivity } from "./logging.js";
11
+ import { logActivity, generateClosingNotes } from "./logging.js";
12
12
  import { readFileSync } from "fs";
13
13
 
14
14
  // Task implementations
@@ -43,7 +43,7 @@ async function run() {
43
43
  const issueNumber = core.getInput("issue-number");
44
44
  const prNumber = core.getInput("pr-number");
45
45
  const writablePathsOverride = core.getInput("writable-paths");
46
- const testCommand = core.getInput("test-command");
46
+ const testCommandInput = core.getInput("test-command");
47
47
  const discussionUrl = core.getInput("discussion-url");
48
48
  const model = core.getInput("model");
49
49
 
@@ -52,6 +52,7 @@ async function run() {
52
52
  // Load config
53
53
  const config = loadConfig(configPath);
54
54
  const writablePaths = getWritablePaths(config, writablePathsOverride);
55
+ const testCommand = testCommandInput || config.testScript;
55
56
 
56
57
  // Load instructions if provided
57
58
  let instructions = "";
@@ -95,6 +96,35 @@ async function run() {
95
96
  if (result.prNumber) core.setOutput("pr-number", String(result.prNumber));
96
97
  if (result.tokensUsed) core.setOutput("tokens-used", String(result.tokensUsed));
97
98
  if (result.model) core.setOutput("model", result.model);
99
+ if (result.action) core.setOutput("action", result.action);
100
+ if (result.actionArg) core.setOutput("action-arg", result.actionArg);
101
+
102
+ // Compute limits status for enriched logging
103
+ const limitsStatus = [
104
+ { name: "transformation-budget", valueNum: 0, capacityNum: config.transformationBudget || 0, value: `0/${config.transformationBudget || 0}`, remaining: `${config.transformationBudget || 0} remaining`, status: "" },
105
+ { name: "max-feature-issues", valueNum: 0, capacityNum: config.featureDevelopmentIssuesWipLimit, value: `?/${config.featureDevelopmentIssuesWipLimit}`, remaining: "?", status: "" },
106
+ { name: "max-maintenance-issues", valueNum: 0, capacityNum: config.maintenanceIssuesWipLimit, value: `?/${config.maintenanceIssuesWipLimit}`, remaining: "?", status: "" },
107
+ { name: "max-attempts-per-issue", valueNum: 0, capacityNum: config.attemptsPerIssue, value: `?/${config.attemptsPerIssue}`, remaining: "?", status: task === "resolve-issue" ? "" : "n/a" },
108
+ { name: "max-attempts-per-branch", valueNum: 0, capacityNum: config.attemptsPerBranch, value: `?/${config.attemptsPerBranch}`, remaining: "?", status: task === "fix-code" ? "" : "n/a" },
109
+ { name: "features", valueNum: 0, capacityNum: config.paths?.features?.limit || 4, value: `?/${config.paths?.features?.limit || 4}`, remaining: "?", status: ["maintain-features", "transform"].includes(task) ? "" : "n/a" },
110
+ { name: "library", valueNum: 0, capacityNum: config.paths?.library?.limit || 32, value: `?/${config.paths?.library?.limit || 32}`, remaining: "?", status: task === "maintain-library" ? "" : "n/a" },
111
+ ];
112
+
113
+ // Merge task-reported limits if available
114
+ if (result.limitsStatus) {
115
+ for (const reported of result.limitsStatus) {
116
+ const existing = limitsStatus.find((ls) => ls.name === reported.name);
117
+ if (existing) Object.assign(existing, reported);
118
+ }
119
+ }
120
+
121
+ const closingNotes = result.closingNotes || generateClosingNotes(limitsStatus);
122
+ const profileName = config.tuning?.profileName || "unknown";
123
+
124
+ // Transformation cost: 1 for code-changing tasks, 0 otherwise
125
+ const COST_TASKS = ["transform", "fix-code"];
126
+ const isNop = result.outcome === "nop" || result.outcome === "error";
127
+ const transformationCost = COST_TASKS.includes(task) && !isNop ? 1 : 0;
98
128
 
99
129
  // Log to intentïon.md (commit-if-changed excludes this on non-default branches)
100
130
  const intentionFilepath = config.intentionBot?.intentionFilepath;
@@ -114,6 +144,13 @@ async function run() {
114
144
  model: result.model || model,
115
145
  details: result.details,
116
146
  workflowUrl: `${process.env.GITHUB_SERVER_URL}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}`,
147
+ profile: profileName,
148
+ changes: result.changes,
149
+ contextNotes: result.contextNotes,
150
+ limitsStatus,
151
+ promptBudget: result.promptBudget,
152
+ closingNotes,
153
+ transformationCost,
117
154
  });
118
155
  }
119
156
 
@@ -27,6 +27,13 @@ import * as core from "@actions/core";
27
27
  * @param {string} [options.model] - Model used
28
28
  * @param {string} [options.details] - Additional details
29
29
  * @param {string} [options.workflowUrl] - URL to the workflow run
30
+ * @param {string} [options.profile] - Active tuning profile name
31
+ * @param {Array} [options.changes] - List of file changes { action, file, sizeInfo }
32
+ * @param {string} [options.contextNotes] - English notes about task observations
33
+ * @param {Array} [options.limitsStatus] - Limit status entries { name, value, remaining, status }
34
+ * @param {Array} [options.promptBudget] - Prompt budget entries { section, size, files, notes }
35
+ * @param {string} [options.closingNotes] - Auto-generated limit concern notes
36
+ * @param {number} [options.transformationCost] - Transformation cost for this entry (0 or 1)
30
37
  */
31
38
  export function logActivity({
32
39
  filepath,
@@ -43,6 +50,13 @@ export function logActivity({
43
50
  model,
44
51
  details,
45
52
  workflowUrl,
53
+ profile,
54
+ changes,
55
+ contextNotes,
56
+ limitsStatus,
57
+ promptBudget,
58
+ closingNotes,
59
+ transformationCost,
46
60
  }) {
47
61
  const dir = dirname(filepath);
48
62
  if (!existsSync(dir)) {
@@ -56,6 +70,7 @@ export function logActivity({
56
70
  if (prNumber) parts.push(`**PR:** #${prNumber}`);
57
71
  if (commitUrl) parts.push(`**Commit:** [${commitUrl}](${commitUrl})`);
58
72
  if (model) parts.push(`**Model:** ${model}`);
73
+ if (profile) parts.push(`**Profile:** ${profile}`);
59
74
  if (tokensUsed) parts.push(`**Token Count:** ${tokensUsed} (in: ${inputTokens || 0}, out: ${outputTokens || 0})`);
60
75
  if (cost) parts.push(`**Model Invocations:** ${cost}`);
61
76
  if (durationMs) {
@@ -63,7 +78,38 @@ export function logActivity({
63
78
  const mins = (durationMs / 60000).toFixed(1);
64
79
  parts.push(`**Duration:** ${secs}s (~${mins} GitHub Actions min)`);
65
80
  }
81
+ if (transformationCost != null) parts.push(`**agentic-lib transformation cost:** ${transformationCost}`);
66
82
  if (workflowUrl) parts.push(`**Workflow:** [${workflowUrl}](${workflowUrl})`);
83
+ if (changes && changes.length > 0) {
84
+ parts.push("", "### What Changed");
85
+ for (const c of changes) {
86
+ parts.push(`- ${c.action}: \`${c.file}\`${c.sizeInfo ? ` (${c.sizeInfo})` : ""}`);
87
+ }
88
+ }
89
+ if (contextNotes) {
90
+ parts.push("", "### Context Notes");
91
+ parts.push(contextNotes);
92
+ }
93
+ if (limitsStatus && limitsStatus.length > 0) {
94
+ parts.push("", "### Limits Status");
95
+ parts.push("| Limit | Value | Capacity | Status |");
96
+ parts.push("|---|---|---|---|");
97
+ for (const ls of limitsStatus) {
98
+ parts.push(`| ${ls.name} | ${ls.value} | ${ls.remaining} remaining | ${ls.status || ""} |`);
99
+ }
100
+ }
101
+ if (promptBudget && promptBudget.length > 0) {
102
+ parts.push("", "### Prompt Budget");
103
+ parts.push("| Section | Size | Files | Notes |");
104
+ parts.push("|---|---|---|---|");
105
+ for (const pb of promptBudget) {
106
+ parts.push(`| ${pb.section} | ${pb.size} chars | ${pb.files || "—"} | ${pb.notes || ""} |`);
107
+ }
108
+ }
109
+ if (closingNotes) {
110
+ parts.push("", "### Closing Notes");
111
+ parts.push(closingNotes);
112
+ }
67
113
  if (details) {
68
114
  parts.push("");
69
115
  parts.push(details);
@@ -92,6 +138,35 @@ export function logActivity({
92
138
  }
93
139
  }
94
140
 
141
+ /**
142
+ * Generate closing notes from limits status, flagging limits at or approaching capacity.
143
+ *
144
+ * @param {Array} limitsStatus - Array of { name, value, remaining, status, valueNum, capacityNum }
145
+ * @returns {string} Closing notes text
146
+ */
147
+ export function generateClosingNotes(limitsStatus) {
148
+ if (!limitsStatus || limitsStatus.length === 0) return "";
149
+
150
+ const concerns = [];
151
+ let anyChecked = false;
152
+ for (const ls of limitsStatus) {
153
+ if (ls.status === "n/a") continue;
154
+ const used = ls.valueNum || 0;
155
+ const capacity = ls.capacityNum || 0;
156
+ if (capacity === 0) continue;
157
+ anyChecked = true;
158
+ const pct = Math.round((used / capacity) * 100);
159
+ if (pct >= 100) {
160
+ concerns.push(`${ls.name} at capacity (${ls.value}) — actions will be blocked.`);
161
+ } else if (pct >= 80) {
162
+ concerns.push(`${ls.name} approaching capacity (${ls.value}).`);
163
+ }
164
+ }
165
+
166
+ if (!anyChecked) return "";
167
+ return concerns.length > 0 ? concerns.join("\n") : "All limits within normal range.";
168
+ }
169
+
95
170
  /**
96
171
  * Log a safety check outcome to the GitHub Actions log.
97
172
  *
@@ -6,7 +6,7 @@
6
6
  // and provides status updates. Uses the Copilot SDK for natural conversation.
7
7
 
8
8
  import * as core from "@actions/core";
9
- import { existsSync } from "fs";
9
+ import { existsSync, writeFileSync } from "fs";
10
10
  import { runCopilotTask, readOptionalFile, scanDirectory } from "../copilot.js";
11
11
 
12
12
  const BOT_LOGINS = ["github-actions[bot]", "github-actions"];
@@ -175,6 +175,22 @@ export async function discussions(context) {
175
175
  const replyBody = content.replace(/\[ACTION:\S+?\].+/, "").trim();
176
176
 
177
177
  core.info(`Discussion bot action: ${action}, arg: ${actionArg}`);
178
+
179
+ // Write MISSION_COMPLETE.md signal when bot declares mission complete
180
+ if (action === "mission-complete") {
181
+ const signal = [
182
+ "# Mission Complete",
183
+ "",
184
+ `- **Timestamp:** ${new Date().toISOString()}`,
185
+ `- **Detected by:** discussions`,
186
+ `- **Reason:** ${actionArg || "Declared via discussion bot"}`,
187
+ "",
188
+ "This file was created automatically. To restart transformations, delete this file or run `npx @xn-intenton-z2a/agentic-lib init --reseed`.",
189
+ ].join("\n");
190
+ writeFileSync("MISSION_COMPLETE.md", signal);
191
+ core.info("Mission complete signal written (MISSION_COMPLETE.md)");
192
+ }
193
+
178
194
  await postReply(octokit, discussion.nodeId, replyBody);
179
195
 
180
196
  const argSuffix = actionArg ? ` (${actionArg})` : "";
@@ -5,7 +5,9 @@
5
5
  // Reviews existing features, creates new ones from mission/library analysis,
6
6
  // prunes completed/irrelevant features, and ensures quality.
7
7
 
8
- import { runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection } from "../copilot.js";
8
+ import { existsSync } from "fs";
9
+ import { runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection, extractFeatureSummary } from "../copilot.js";
10
+ import { checkWipLimit } from "../safety.js";
9
11
 
10
12
  /**
11
13
  * Maintain the feature set — create, update, or prune feature files.
@@ -17,10 +19,28 @@ export async function maintainFeatures(context) {
17
19
  const { config, instructions, writablePaths, model, octokit, repo } = context;
18
20
  const t = config.tuning || {};
19
21
 
22
+ // Check mission-complete signal
23
+ if (existsSync("MISSION_COMPLETE.md")) {
24
+ return { outcome: "nop", details: "Mission already complete (MISSION_COMPLETE.md signal)" };
25
+ }
26
+
27
+ // Check maintenance WIP limit
28
+ const wipCheck = await checkWipLimit(octokit, repo, "maintenance", config.maintenanceIssuesWipLimit);
29
+ if (!wipCheck.allowed) {
30
+ return { outcome: "wip-limit-reached", details: `Maintenance WIP limit reached (${wipCheck.count}/${config.maintenanceIssuesWipLimit})` };
31
+ }
32
+
20
33
  const mission = readOptionalFile(config.paths.mission.path);
21
34
  const featuresPath = config.paths.features.path;
22
35
  const featureLimit = config.paths.features.limit;
23
36
  const features = scanDirectory(featuresPath, ".md", { fileLimit: t.featuresScan || 10 });
37
+
38
+ // Sort features: incomplete (has unchecked items) first, then by name
39
+ features.sort((a, b) => {
40
+ const aIncomplete = /- \[ \]/.test(a.content) ? 0 : 1;
41
+ const bIncomplete = /- \[ \]/.test(b.content) ? 0 : 1;
42
+ return aIncomplete - bIncomplete || a.name.localeCompare(b.name);
43
+ });
24
44
  const libraryDocs = scanDirectory(config.paths.library.path, ".md", {
25
45
  contentLimit: t.documentSummary || 1000,
26
46
  });
@@ -6,6 +6,7 @@
6
6
  // that provides context for feature generation and issue resolution.
7
7
 
8
8
  import * as core from "@actions/core";
9
+ import { existsSync } from "fs";
9
10
  import { runCopilotTask, readOptionalFile, scanDirectory, formatPathsSection } from "../copilot.js";
10
11
 
11
12
  /**
@@ -18,6 +19,12 @@ export async function maintainLibrary(context) {
18
19
  const { config, instructions, writablePaths, model } = context;
19
20
  const t = config.tuning || {};
20
21
 
22
+ // Check mission-complete signal
23
+ if (existsSync("MISSION_COMPLETE.md")) {
24
+ core.info("Mission is complete — skipping library maintenance");
25
+ return { outcome: "nop", details: "Mission already complete (MISSION_COMPLETE.md signal)" };
26
+ }
27
+
21
28
  const sources = readOptionalFile(config.paths.librarySources.path);
22
29
  if (!sources.trim()) {
23
30
  core.info("No sources found. Returning nop.");
@@ -35,11 +35,16 @@ async function reviewSingleIssue({ octokit, repo, config, targetIssueNumber, ins
35
35
  contentLimit: t.sourceContent || 5000,
36
36
  fileLimit: t.sourceScan || 20,
37
37
  recursive: true,
38
+ sortByMtime: true,
39
+ clean: true,
40
+ outline: true,
38
41
  });
39
42
  const testFiles = scanDirectory(config.paths.tests.path, [".test.js", ".test.ts"], {
40
- contentLimit: t.sourceContent || 5000,
43
+ contentLimit: t.testContent || t.sourceContent || 5000,
41
44
  fileLimit: t.sourceScan || 20,
42
45
  recursive: true,
46
+ sortByMtime: true,
47
+ clean: true,
43
48
  });
44
49
  const docsFiles = scanDirectory(config.paths.documentation?.path || "docs/", [".md"], {
45
50
  fileLimit: t.featuresScan || 10,