@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.
- package/.github/workflows/agentic-lib-bot.yml +5 -1
- package/.github/workflows/agentic-lib-workflow.yml +30 -6
- package/README.md +2 -2
- package/bin/agentic-lib.js +90 -50
- package/package.json +1 -1
- package/src/actions/agentic-step/config-loader.js +4 -304
- package/src/actions/agentic-step/copilot.js +45 -527
- package/src/actions/agentic-step/index.js +60 -328
- package/src/actions/agentic-step/metrics.js +115 -0
- package/src/actions/agentic-step/tools.js +19 -134
- package/src/copilot/context.js +158 -19
- package/src/copilot/guards.js +87 -0
- package/src/copilot/hybrid-session.js +16 -9
- package/src/copilot/sdk.js +5 -3
- package/src/copilot/telemetry.js +172 -0
- package/src/seeds/zero-package.json +1 -1
- package/src/copilot/tasks/fix-code.js +0 -73
- package/src/copilot/tasks/maintain-features.js +0 -61
- package/src/copilot/tasks/maintain-library.js +0 -66
- package/src/copilot/tasks/transform.js +0 -120
|
@@ -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
|
|
6
|
-
//
|
|
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
|
|
13
|
-
import {
|
|
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
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
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,
|
|
174
|
-
const testCommand =
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
361
|
-
const
|
|
362
|
-
if (
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
}
|