@xn-intenton-z2a/agentic-lib 7.4.5 → 7.4.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.
@@ -1,16 +1,23 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Copyright (C) 2025-2026 Polycode Limited
3
- // index.js — agentic-step GitHub Action entry point
3
+ // index.js — agentic-step GitHub Action entry point (thin adapter)
4
4
  //
5
- // Parses inputs, loads config, runs the appropriate task via the Copilot SDK,
6
- // and sets outputs for downstream workflow steps.
5
+ // Parses inputs, loads config, runs the appropriate task handler,
6
+ // computes metrics via shared telemetry, and sets outputs.
7
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
11
  import { logActivity, generateClosingNotes } from "./logging.js";
12
- import { readFileSync, existsSync, readdirSync, statSync } from "fs";
13
- import { join } from "path";
12
+ import { readFileSync } from "fs";
13
+ import {
14
+ buildMissionMetrics, buildMissionReadiness,
15
+ computeTransformationCost, readCumulativeCost, buildLimitsStatus,
16
+ } from "../../copilot/telemetry.js";
17
+ import {
18
+ checkInstabilityLabel, countDedicatedTests,
19
+ countOpenIssues, countResolvedIssues, countMdFiles,
20
+ } from "./metrics.js";
14
21
 
15
22
  // Task implementations
16
23
  import { resolveIssue } from "./tasks/resolve-issue.js";
@@ -25,377 +32,102 @@ import { supervise } from "./tasks/supervise.js";
25
32
  import { direct } from "./tasks/direct.js";
26
33
 
27
34
  const TASKS = {
28
- "resolve-issue": resolveIssue,
29
- "fix-code": fixCode,
30
- "transform": transform,
31
- "maintain-features": maintainFeatures,
32
- "maintain-library": maintainLibrary,
33
- "enhance-issue": enhanceIssue,
34
- "review-issue": reviewIssue,
35
- "discussions": discussions,
36
- "supervise": supervise,
37
- "direct": direct,
35
+ "resolve-issue": resolveIssue, "fix-code": fixCode, "transform": transform,
36
+ "maintain-features": maintainFeatures, "maintain-library": maintainLibrary,
37
+ "enhance-issue": enhanceIssue, "review-issue": reviewIssue,
38
+ "discussions": discussions, "supervise": supervise, "direct": direct,
38
39
  };
39
40
 
40
- /**
41
- * Recursively count TODO/FIXME comments in source files under a directory.
42
- * @param {string} dir - Directory to scan
43
- * @param {string[]} [extensions] - File extensions to include (default: .js, .ts, .mjs)
44
- * @returns {number} Total count of TODO/FIXME occurrences
45
- */
46
- export function countSourceTodos(dir, extensions = [".js", ".ts", ".mjs"]) {
47
- let count = 0;
48
- if (!existsSync(dir)) return 0;
49
- try {
50
- const entries = readdirSync(dir);
51
- for (const entry of entries) {
52
- if (entry === "node_modules" || entry.startsWith(".")) continue;
53
- const fullPath = join(dir, entry);
54
- try {
55
- const stat = statSync(fullPath);
56
- if (stat.isDirectory()) {
57
- count += countSourceTodos(fullPath, extensions);
58
- } else if (extensions.some((ext) => entry.endsWith(ext))) {
59
- const content = readFileSync(fullPath, "utf8");
60
- const matches = content.match(/\bTODO\b/gi);
61
- if (matches) count += matches.length;
62
- }
63
- } catch { /* skip unreadable files */ }
64
- }
65
- } catch { /* skip unreadable dirs */ }
66
- return count;
67
- }
68
-
69
- /**
70
- * Build mission-complete metrics array for the intentïon.md dashboard.
71
- */
72
- function buildMissionMetrics(config, result, limitsStatus, cumulativeCost, featureIssueCount, maintenanceIssueCount) {
73
- const openIssues = featureIssueCount + maintenanceIssueCount;
74
- const budgetCap = config.transformationBudget || 0;
75
- const resolvedCount = result.resolvedCount || 0;
76
- const missionComplete = existsSync("MISSION_COMPLETE.md");
77
- const missionFailed = existsSync("MISSION_FAILED.md");
78
-
79
- // Count open PRs from result if available
80
- const openPrs = result.openPrCount || 0;
81
-
82
- // W9: Count TODO comments in source directory
83
- const sourcePath = config.paths?.source?.path || "src/lib/";
84
- const sourceDir = sourcePath.endsWith("/") ? sourcePath.slice(0, -1) : sourcePath;
85
- // Scan the parent src/ directory to catch all source TODOs
86
- const srcRoot = sourceDir.includes("/") ? sourceDir.split("/").slice(0, -1).join("/") || "src" : "src";
87
- const todoCount = countSourceTodos(srcRoot);
88
-
89
- // W3: Count dedicated test files
90
- const dedicatedTestCount = result.dedicatedTestCount ?? 0;
91
-
92
- // W11: Thresholds from config
93
- const thresholds = config.missionCompleteThresholds || {};
94
- const minResolved = thresholds.minResolvedIssues ?? 3;
95
- const minTests = thresholds.minDedicatedTests ?? 1;
96
- const maxTodos = thresholds.maxSourceTodos ?? 0;
97
-
98
- const metrics = [
99
- { metric: "Open issues", value: String(openIssues), target: "0", status: openIssues === 0 ? "MET" : "NOT MET" },
100
- { metric: "Open PRs", value: String(openPrs), target: "0", status: openPrs === 0 ? "MET" : "NOT MET" },
101
- { metric: "Issues resolved (review or PR merge)", value: String(resolvedCount), target: `>= ${minResolved}`, status: resolvedCount >= minResolved ? "MET" : "NOT MET" },
102
- { metric: "Dedicated test files", value: String(dedicatedTestCount), target: `>= ${minTests}`, status: dedicatedTestCount >= minTests ? "MET" : "NOT MET" },
103
- { metric: "Source TODO count", value: String(todoCount), target: `<= ${maxTodos}`, status: todoCount <= maxTodos ? "MET" : "NOT MET" },
104
- { metric: "Transformation budget used", value: `${cumulativeCost}/${budgetCap}`, target: budgetCap > 0 ? `< ${budgetCap}` : "unlimited", status: budgetCap > 0 && cumulativeCost >= budgetCap ? "EXHAUSTED" : "OK" },
105
- { metric: "Cumulative transforms", value: String(cumulativeCost), target: ">= 1", status: cumulativeCost >= 1 ? "MET" : "NOT MET" },
106
- { metric: "Mission complete declared", value: missionComplete ? "YES" : "NO", target: "—", status: "—" },
107
- { metric: "Mission failed declared", value: missionFailed ? "YES" : "NO", target: "—", status: "—" },
108
- ];
109
-
110
- return metrics;
111
- }
112
-
113
- /**
114
- * Build mission-complete readiness narrative from metrics.
115
- */
116
- function buildMissionReadiness(metrics) {
117
- const openIssues = parseInt(metrics.find((m) => m.metric === "Open issues")?.value || "0", 10);
118
- const openPrs = parseInt(metrics.find((m) => m.metric === "Open PRs")?.value || "0", 10);
119
- const resolved = parseInt(metrics.find((m) => m.metric === "Issues resolved (review or PR merge)")?.value || "0", 10);
120
- const dedicatedTests = parseInt(metrics.find((m) => m.metric === "Dedicated test files")?.value || "0", 10);
121
- const todoCount = parseInt(metrics.find((m) => m.metric === "Source TODO count")?.value || "0", 10);
122
- const missionComplete = metrics.find((m) => m.metric === "Mission complete declared")?.value === "YES";
123
- const missionFailed = metrics.find((m) => m.metric === "Mission failed declared")?.value === "YES";
124
-
125
- if (missionComplete) {
126
- return "Mission has been declared complete.";
127
- }
128
- if (missionFailed) {
129
- return "Mission has been declared failed.";
130
- }
131
-
132
- // Check all NOT MET conditions
133
- const notMet = metrics.filter((m) => m.status === "NOT MET");
134
- const allMet = notMet.length === 0;
135
- const parts = [];
136
-
137
- if (allMet) {
138
- parts.push("Mission complete conditions ARE met.");
139
- parts.push(`0 open issues, 0 open PRs, ${resolved} issue(s) resolved, ${dedicatedTests} dedicated test(s), TODOs: ${todoCount}.`);
140
- } else {
141
- parts.push("Mission complete conditions are NOT met.");
142
- if (openIssues > 0) parts.push(`${openIssues} open issue(s) remain.`);
143
- if (openPrs > 0) parts.push(`${openPrs} open PR(s) remain.`);
144
- for (const m of notMet) {
145
- if (m.metric !== "Open issues" && m.metric !== "Open PRs") {
146
- parts.push(`${m.metric}: ${m.value} (target: ${m.target}).`);
147
- }
148
- }
149
- }
150
-
151
- return parts.join(" ");
152
- }
153
-
154
41
  async function run() {
155
42
  try {
156
- // Parse inputs
157
43
  const task = core.getInput("task", { required: true });
158
44
  const configPath = core.getInput("config");
159
45
  const instructionsPath = core.getInput("instructions");
160
46
  const issueNumber = core.getInput("issue-number");
161
- const prNumber = core.getInput("pr-number");
162
- const writablePathsOverride = core.getInput("writable-paths");
163
- const testCommandInput = core.getInput("test-command");
164
- const discussionUrl = core.getInput("discussion-url");
165
- const commentNodeId = core.getInput("comment-node-id");
166
- const commentCreatedAt = core.getInput("comment-created-at");
167
47
  const model = core.getInput("model");
168
-
169
48
  core.info(`agentic-step: task=${task}, model=${model}`);
170
49
 
171
- // Load config
172
50
  const config = loadConfig(configPath);
173
- const writablePaths = getWritablePaths(config, writablePathsOverride);
174
- const testCommand = testCommandInput || config.testScript;
51
+ const writablePaths = getWritablePaths(config, core.getInput("writable-paths"));
52
+ const testCommand = core.getInput("test-command") || config.testScript;
175
53
 
176
- // Load instructions if provided
177
54
  let instructions = "";
178
55
  if (instructionsPath) {
179
- try {
180
- instructions = readFileSync(instructionsPath, "utf8");
181
- } catch (err) {
182
- core.warning(`Could not read instructions file: ${instructionsPath}: ${err.message}`);
183
- }
56
+ try { instructions = readFileSync(instructionsPath, "utf8"); }
57
+ catch (err) { core.warning(`Could not read instructions: ${err.message}`); }
184
58
  }
185
59
 
186
- // Look up the task handler
187
60
  const handler = TASKS[task];
188
- if (!handler) {
189
- throw new Error(`Unknown task: ${task}. Available tasks: ${Object.keys(TASKS).join(", ")}`);
190
- }
61
+ if (!handler) throw new Error(`Unknown task: ${task}. Available: ${Object.keys(TASKS).join(", ")}`);
191
62
 
192
- // Build context for the task
193
63
  const context = {
194
- task,
195
- config,
196
- instructions,
197
- issueNumber,
198
- prNumber,
199
- writablePaths,
200
- testCommand,
201
- discussionUrl,
202
- commentNodeId,
203
- commentCreatedAt,
204
- model,
64
+ task, config, instructions, issueNumber, writablePaths, testCommand, model,
65
+ prNumber: core.getInput("pr-number"),
66
+ discussionUrl: core.getInput("discussion-url"),
67
+ commentNodeId: core.getInput("comment-node-id"),
68
+ commentCreatedAt: core.getInput("comment-created-at"),
205
69
  octokit: github.getOctokit(process.env.GITHUB_TOKEN),
206
- repo: github.context.repo,
207
- github: github.context,
70
+ repo: github.context.repo, github: github.context,
208
71
  };
209
72
 
210
- // Run the task (measure wall-clock duration for cost tracking)
211
73
  const startTime = Date.now();
212
74
  const result = await handler(context);
213
75
  const durationMs = Date.now() - startTime;
214
76
 
215
77
  // Set outputs
216
78
  core.setOutput("result", result.outcome || "completed");
217
- if (result.prNumber) core.setOutput("pr-number", String(result.prNumber));
218
- if (result.tokensUsed) core.setOutput("tokens-used", String(result.tokensUsed));
219
- if (result.model) core.setOutput("model", result.model);
220
- if (result.action) core.setOutput("action", result.action);
221
- if (result.actionArg) core.setOutput("action-arg", result.actionArg);
222
- if (result.narrative) core.setOutput("narrative", result.narrative);
223
-
224
- const profileName = config.tuning?.profileName || "unknown";
79
+ for (const [key, field] of [["pr-number", "prNumber"], ["tokens-used", "tokensUsed"], ["model", "model"], ["action", "action"], ["action-arg", "actionArg"], ["narrative", "narrative"]]) {
80
+ if (result[field]) core.setOutput(key, String(result[field]));
81
+ }
225
82
 
226
- // Transformation cost: 1 for code-changing tasks, 0 otherwise
227
- // W4: Instability transforms (infrastructure fixes) don't count against mission budget
83
+ // Compute metrics
228
84
  const COST_TASKS = ["transform", "fix-code", "maintain-features", "maintain-library"];
229
85
  const isNop = result.outcome === "nop" || result.outcome === "error";
230
- let isInstabilityTransform = false;
231
- if (issueNumber && COST_TASKS.includes(task) && !isNop) {
232
- try {
233
- const { data: issueData } = await context.octokit.rest.issues.get({
234
- ...context.repo,
235
- issue_number: Number(issueNumber),
236
- });
237
- isInstabilityTransform = issueData.labels.some((l) => l.name === "instability");
238
- if (isInstabilityTransform) {
239
- core.info(`Issue #${issueNumber} has instability label — transform does not count against mission budget`);
240
- }
241
- } catch { /* ignore — conservative: count as mission transform */ }
242
- }
243
- const transformationCost = COST_TASKS.includes(task) && !isNop && !isInstabilityTransform ? 1 : 0;
244
-
245
- // Read cumulative transformation cost from the activity log
86
+ const isInstability = issueNumber && COST_TASKS.includes(task) && !isNop
87
+ && await checkInstabilityLabel(context, issueNumber);
88
+ if (isInstability) core.info(`Issue #${issueNumber} has instability label — does not count against budget`);
89
+ const transformationCost = computeTransformationCost(task, result.outcome, isInstability);
246
90
  const intentionFilepath = config.intentionBot?.intentionFilepath;
247
- let cumulativeCost = 0;
248
- if (intentionFilepath && existsSync(intentionFilepath)) {
249
- const logContent = readFileSync(intentionFilepath, "utf8");
250
- const costMatches = logContent.matchAll(/\*\*agentic-lib transformation cost:\*\* (\d+)/g);
251
- cumulativeCost = [...costMatches].reduce((sum, m) => sum + parseInt(m[1], 10), 0);
252
- }
253
- cumulativeCost += transformationCost;
254
-
255
- // Count features and library docs on disk
256
- const featuresPath = config.paths?.features?.path;
257
- const featuresUsed = featuresPath && existsSync(featuresPath)
258
- ? readdirSync(featuresPath).filter((f) => f.endsWith(".md")).length
259
- : 0;
260
- const libraryPath = config.paths?.library?.path;
261
- const libraryUsed = libraryPath && existsSync(libraryPath)
262
- ? readdirSync(libraryPath).filter((f) => f.endsWith(".md")).length
263
- : 0;
91
+ const cumulativeCost = readCumulativeCost(intentionFilepath) + transformationCost;
264
92
 
265
- // W3/W10: Count dedicated test files (centrally, for all tasks)
266
- let dedicatedTestCount = result.dedicatedTestCount ?? 0;
267
- if (dedicatedTestCount === 0) {
93
+ if (result.dedicatedTestCount == null || result.dedicatedTestCount === 0) {
268
94
  try {
269
95
  const { scanDirectory: scanDir } = await import("./copilot.js");
270
- const testDirs = ["tests", "__tests__"];
271
- for (const dir of testDirs) {
272
- if (existsSync(dir)) {
273
- const testFiles = scanDir(dir, [".js", ".ts", ".mjs"], { limit: 20 });
274
- for (const tf of testFiles) {
275
- if (/^(main|web|behaviour)\.test\.[jt]s$/.test(tf.name)) continue;
276
- const content = readFileSync(tf.path, "utf8");
277
- if (/from\s+['"].*src\/lib\//.test(content) || /require\s*\(\s*['"].*src\/lib\//.test(content)) {
278
- dedicatedTestCount++;
279
- }
280
- }
281
- }
282
- }
283
- } catch { /* ignore — scanDirectory not available in test environment */ }
284
- }
285
- result.dedicatedTestCount = dedicatedTestCount;
286
-
287
- // Count open automated issues (feature vs maintenance)
288
- let featureIssueCount = 0;
289
- let maintenanceIssueCount = 0;
290
- try {
291
- const { data: openAutoIssues } = await context.octokit.rest.issues.listForRepo({
292
- ...context.repo,
293
- state: "open",
294
- labels: "automated",
295
- per_page: 50,
296
- });
297
- for (const oi of openAutoIssues.filter((i) => !i.pull_request)) {
298
- const lbls = oi.labels.map((l) => l.name);
299
- if (lbls.includes("maintenance")) maintenanceIssueCount++;
300
- else featureIssueCount++;
301
- }
302
- } catch (_) { /* API not available */ }
303
-
304
- // Count resolved issues (if not already provided by the task)
305
- if (result.resolvedCount == null) {
306
- let resolvedCount = 0;
307
- try {
308
- const initTimestamp = config.init?.timestamp;
309
- const { data: closedIssuesRaw } = await context.octokit.rest.issues.listForRepo({
310
- ...context.repo, state: "closed", labels: "automated", per_page: 10, sort: "updated", direction: "desc",
311
- });
312
- const initEpoch = initTimestamp ? new Date(initTimestamp).getTime() : 0;
313
- const closedFiltered = closedIssuesRaw.filter((i) =>
314
- !i.pull_request && (initEpoch <= 0 || new Date(i.created_at).getTime() >= initEpoch)
315
- );
316
- for (const ci of closedFiltered) {
317
- let isResolved = false;
318
- try {
319
- const { data: comments } = await context.octokit.rest.issues.listComments({
320
- ...context.repo, issue_number: ci.number, per_page: 5, sort: "created", direction: "desc",
321
- });
322
- if (comments.some((c) => c.body?.includes("Automated Review Result"))) {
323
- isResolved = true;
324
- } else {
325
- const { data: events } = await context.octokit.rest.issues.listEvents({
326
- ...context.repo, issue_number: ci.number, per_page: 10,
327
- });
328
- if (events.some((e) => e.event === "closed" && e.commit_id)) {
329
- isResolved = true;
330
- }
331
- }
332
- if (!isResolved) {
333
- const issueLabels = ci.labels.map((l) => (typeof l === "string" ? l : l.name));
334
- if (issueLabels.includes("merged")) isResolved = true;
335
- }
336
- } catch { /* ignore */ }
337
- if (isResolved) resolvedCount++;
338
- }
339
- } catch (_) { /* API not available */ }
340
- result.resolvedCount = resolvedCount;
96
+ result.dedicatedTestCount = countDedicatedTests(scanDir);
97
+ } catch { /* ignore */ }
341
98
  }
342
99
 
343
- const budgetCap = config.transformationBudget || 0;
344
- const featCap = config.paths?.features?.limit || 4;
345
- const libCap = config.paths?.library?.limit || 32;
100
+ const { featureIssueCount, maintenanceIssueCount } = await countOpenIssues(context);
101
+ if (result.resolvedCount == null) result.resolvedCount = await countResolvedIssues(context);
346
102
 
347
- // Compute limits status with actual values
348
- const limitsStatus = [
349
- { name: "transformation-budget", valueNum: cumulativeCost, capacityNum: budgetCap, value: `${cumulativeCost}/${budgetCap}`, remaining: `${Math.max(0, budgetCap - cumulativeCost)}`, status: cumulativeCost >= budgetCap && budgetCap > 0 ? "EXHAUSTED" : "" },
350
- { name: "max-feature-issues", valueNum: featureIssueCount, capacityNum: config.featureDevelopmentIssuesWipLimit, value: `${featureIssueCount}/${config.featureDevelopmentIssuesWipLimit}`, remaining: `${Math.max(0, config.featureDevelopmentIssuesWipLimit - featureIssueCount)}`, status: "" },
351
- { name: "max-maintenance-issues", valueNum: maintenanceIssueCount, capacityNum: config.maintenanceIssuesWipLimit, value: `${maintenanceIssueCount}/${config.maintenanceIssuesWipLimit}`, remaining: `${Math.max(0, config.maintenanceIssuesWipLimit - maintenanceIssueCount)}`, status: "" },
352
- { name: "max-attempts-per-issue", valueNum: 0, capacityNum: config.attemptsPerIssue, value: `?/${config.attemptsPerIssue}`, remaining: "?", status: task === "resolve-issue" ? "" : "n/a" },
353
- { name: "max-attempts-per-branch", valueNum: 0, capacityNum: config.attemptsPerBranch, value: `?/${config.attemptsPerBranch}`, remaining: "?", status: task === "fix-code" ? "" : "n/a" },
354
- { name: "features", valueNum: featuresUsed, capacityNum: featCap, value: `${featuresUsed}/${featCap}`, remaining: `${Math.max(0, featCap - featuresUsed)}`, status: ["maintain-features", "transform"].includes(task) ? "" : "n/a" },
355
- { name: "library", valueNum: libraryUsed, capacityNum: libCap, value: `${libraryUsed}/${libCap}`, remaining: `${Math.max(0, libCap - libraryUsed)}`, status: task === "maintain-library" ? "" : "n/a" },
356
- ];
357
-
358
- // Merge task-reported limits if available
103
+ const limitsStatus = buildLimitsStatus({
104
+ task, cumulativeCost, config, featureIssueCount, maintenanceIssueCount,
105
+ featuresUsed: countMdFiles(config.paths?.features?.path),
106
+ libraryUsed: countMdFiles(config.paths?.library?.path),
107
+ });
359
108
  if (result.limitsStatus) {
360
- for (const reported of result.limitsStatus) {
361
- const existing = limitsStatus.find((ls) => ls.name === reported.name);
362
- if (existing) Object.assign(existing, reported);
109
+ for (const r of result.limitsStatus) {
110
+ const e = limitsStatus.find((ls) => ls.name === r.name);
111
+ if (e) Object.assign(e, r);
363
112
  }
364
113
  }
365
114
 
366
- const closingNotes = result.closingNotes || generateClosingNotes(limitsStatus);
367
-
368
- // Build mission-complete metrics and readiness narrative
369
115
  const missionMetrics = buildMissionMetrics(config, result, limitsStatus, cumulativeCost, featureIssueCount, maintenanceIssueCount);
370
- const missionReadiness = buildMissionReadiness(missionMetrics);
371
116
 
372
- // Log to intentïon.md (commit-if-changed excludes this on non-default branches)
373
117
  if (intentionFilepath) {
374
118
  logActivity({
375
- filepath: intentionFilepath,
376
- task,
377
- outcome: result.outcome || "completed",
378
- issueNumber,
379
- prNumber: result.prNumber,
380
- commitUrl: result.commitUrl,
381
- tokensUsed: result.tokensUsed,
382
- inputTokens: result.inputTokens,
383
- outputTokens: result.outputTokens,
384
- cost: result.cost,
385
- durationMs,
386
- model: result.model || model,
387
- details: result.details,
119
+ filepath: intentionFilepath, task, outcome: result.outcome || "completed",
120
+ issueNumber, prNumber: result.prNumber, commitUrl: result.commitUrl,
121
+ tokensUsed: result.tokensUsed, inputTokens: result.inputTokens,
122
+ outputTokens: result.outputTokens, cost: result.cost, durationMs,
123
+ model: result.model || model, details: result.details,
388
124
  workflowUrl: `${process.env.GITHUB_SERVER_URL}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}`,
389
- profile: profileName,
390
- changes: result.changes,
391
- contextNotes: result.contextNotes,
392
- limitsStatus,
393
- promptBudget: result.promptBudget,
394
- missionReadiness,
395
- missionMetrics,
396
- closingNotes,
397
- transformationCost,
398
- narrative: result.narrative,
125
+ profile: config.tuning?.profileName || "unknown",
126
+ changes: result.changes, contextNotes: result.contextNotes,
127
+ limitsStatus, promptBudget: result.promptBudget,
128
+ missionReadiness: buildMissionReadiness(missionMetrics),
129
+ missionMetrics, closingNotes: result.closingNotes || generateClosingNotes(limitsStatus),
130
+ transformationCost, narrative: result.narrative,
399
131
  });
400
132
  }
401
133
 
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // metrics.js — GitHub API metric gathering for agentic-step
4
+ //
5
+ // Extracts issue counting, resolved issue tracking, and dedicated test
6
+ // counting from index.js so it remains a thin adapter.
7
+
8
+ import { existsSync, readFileSync, readdirSync } from "fs";
9
+
10
+ /**
11
+ * Check if an issue has the instability label.
12
+ *
13
+ * @param {Object} context - Task context with octokit and repo
14
+ * @param {string} issueNumber - Issue number to check
15
+ * @returns {Promise<boolean>} True if the issue has the instability label
16
+ */
17
+ export async function checkInstabilityLabel(context, issueNumber) {
18
+ try {
19
+ const { data: issueData } = await context.octokit.rest.issues.get({
20
+ ...context.repo, issue_number: Number(issueNumber),
21
+ });
22
+ return issueData.labels.some((l) => l.name === "instability");
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Count dedicated test files that import from src/lib/.
30
+ *
31
+ * @param {Function} scanDir - scanDirectory function from copilot.js
32
+ * @returns {number} Count of dedicated test files
33
+ */
34
+ export function countDedicatedTests(scanDir) {
35
+ let count = 0;
36
+ for (const dir of ["tests", "__tests__"]) {
37
+ if (!existsSync(dir)) continue;
38
+ try {
39
+ const testFiles = scanDir(dir, [".js", ".ts", ".mjs"], { limit: 20 });
40
+ for (const tf of testFiles) {
41
+ if (/^(main|web|behaviour)\.test\.[jt]s$/.test(tf.name)) continue;
42
+ const content = readFileSync(tf.path, "utf8");
43
+ if (/from\s+['"].*src\/lib\//.test(content) || /require\s*\(\s*['"].*src\/lib\//.test(content)) {
44
+ count++;
45
+ }
46
+ }
47
+ } catch { /* ignore */ }
48
+ }
49
+ return count;
50
+ }
51
+
52
+ /**
53
+ * Count open automated issues split by feature vs maintenance.
54
+ *
55
+ * @param {Object} context - Task context with octokit and repo
56
+ * @returns {Promise<{featureIssueCount: number, maintenanceIssueCount: number}>}
57
+ */
58
+ export async function countOpenIssues(context) {
59
+ let featureIssueCount = 0, maintenanceIssueCount = 0;
60
+ try {
61
+ const { data: openAutoIssues } = await context.octokit.rest.issues.listForRepo({
62
+ ...context.repo, state: "open", labels: "automated", per_page: 50,
63
+ });
64
+ for (const oi of openAutoIssues.filter((i) => !i.pull_request)) {
65
+ if (oi.labels.map((l) => l.name).includes("maintenance")) maintenanceIssueCount++;
66
+ else featureIssueCount++;
67
+ }
68
+ } catch { /* API not available */ }
69
+ return { featureIssueCount, maintenanceIssueCount };
70
+ }
71
+
72
+ /**
73
+ * Count resolved automated issues since init.
74
+ *
75
+ * @param {Object} context - Task context with octokit, repo, config
76
+ * @returns {Promise<number>} Count of resolved issues
77
+ */
78
+ export async function countResolvedIssues(context) {
79
+ let resolvedCount = 0;
80
+ try {
81
+ const initTimestamp = context.config.init?.timestamp;
82
+ const { data: closedIssues } = await context.octokit.rest.issues.listForRepo({
83
+ ...context.repo, state: "closed", labels: "automated", per_page: 10, sort: "updated", direction: "desc",
84
+ });
85
+ const initEpoch = initTimestamp ? new Date(initTimestamp).getTime() : 0;
86
+ for (const ci of closedIssues.filter((i) => !i.pull_request && (initEpoch <= 0 || new Date(i.created_at).getTime() >= initEpoch))) {
87
+ try {
88
+ const { data: comments } = await context.octokit.rest.issues.listComments({
89
+ ...context.repo, issue_number: ci.number, per_page: 5, sort: "created", direction: "desc",
90
+ });
91
+ if (comments.some((c) => c.body?.includes("Automated Review Result"))) {
92
+ resolvedCount++;
93
+ } else {
94
+ const { data: events } = await context.octokit.rest.issues.listEvents({
95
+ ...context.repo, issue_number: ci.number, per_page: 10,
96
+ });
97
+ if (events.some((e) => e.event === "closed" && e.commit_id)) resolvedCount++;
98
+ else if (ci.labels.map((l) => (typeof l === "string" ? l : l.name)).includes("merged")) resolvedCount++;
99
+ }
100
+ } catch { /* ignore */ }
101
+ }
102
+ } catch { /* API not available */ }
103
+ return resolvedCount;
104
+ }
105
+
106
+ /**
107
+ * Count .md files in a directory.
108
+ *
109
+ * @param {string} dirPath - Directory to count in
110
+ * @returns {number} Count (0 if directory doesn't exist)
111
+ */
112
+ export function countMdFiles(dirPath) {
113
+ if (!dirPath || !existsSync(dirPath)) return 0;
114
+ return readdirSync(dirPath).filter((f) => f.endsWith(".md")).length;
115
+ }