@xn-intenton-z2a/agentic-lib 7.2.4 → 7.2.6

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.
@@ -0,0 +1,428 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // tasks/direct.js — Director: mission-complete/failed evaluation via LLM
4
+ //
5
+ // Gathers mission metrics, builds an advisory assessment, asks the LLM
6
+ // to decide mission-complete, mission-failed, or produce a gap analysis.
7
+ // The director does NOT dispatch workflows or create issues — that's the supervisor's job.
8
+
9
+ import * as core from "@actions/core";
10
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
11
+ import { runCopilotTask, readOptionalFile, scanDirectory, filterIssues } from "../copilot.js";
12
+
13
+ /**
14
+ * Count TODO comments recursively in a directory.
15
+ */
16
+ function countTodos(dir) {
17
+ let n = 0;
18
+ if (!existsSync(dir)) return 0;
19
+ try {
20
+ const entries = readdirSync(dir);
21
+ for (const entry of entries) {
22
+ if (entry === "node_modules" || entry.startsWith(".")) continue;
23
+ const fp = `${dir}/${entry}`;
24
+ try {
25
+ const stat = statSync(fp);
26
+ if (stat.isDirectory()) {
27
+ n += countTodos(fp);
28
+ } else if (/\.(js|ts|mjs)$/.test(entry)) {
29
+ const content = readFileSync(fp, "utf8");
30
+ const m = content.match(/\bTODO\b/gi);
31
+ if (m) n += m.length;
32
+ }
33
+ } catch { /* skip */ }
34
+ }
35
+ } catch { /* skip */ }
36
+ return n;
37
+ }
38
+
39
+ /**
40
+ * Detect dedicated test files that import from src/lib/.
41
+ */
42
+ function detectDedicatedTests() {
43
+ let hasDedicatedTests = false;
44
+ const dedicatedTestFiles = [];
45
+ const testDirs = ["tests", "__tests__"];
46
+ for (const dir of testDirs) {
47
+ if (existsSync(dir)) {
48
+ try {
49
+ const testFiles = scanDirectory(dir, [".js", ".ts", ".mjs"], { limit: 20 });
50
+ for (const tf of testFiles) {
51
+ if (/^(main|web|behaviour)\.test\.[jt]s$/.test(tf.name)) continue;
52
+ const content = readFileSync(tf.path, "utf8");
53
+ if (/from\s+['"].*src\/lib\//.test(content) || /require\s*\(\s*['"].*src\/lib\//.test(content)) {
54
+ hasDedicatedTests = true;
55
+ dedicatedTestFiles.push(tf.name);
56
+ }
57
+ }
58
+ } catch { /* ignore */ }
59
+ }
60
+ }
61
+ return { hasDedicatedTests, dedicatedTestFiles };
62
+ }
63
+
64
+ /**
65
+ * Build the metric-based mission-complete advisory string.
66
+ * This is the mechanical check — purely rule-based, no LLM.
67
+ */
68
+ function buildMetricAssessment(ctx, config) {
69
+ const thresholds = config.missionCompleteThresholds || {};
70
+ const minResolved = thresholds.minResolvedIssues ?? 3;
71
+ const requireTests = thresholds.requireDedicatedTests ?? true;
72
+ const maxTodos = thresholds.maxSourceTodos ?? 0;
73
+
74
+ const metrics = [
75
+ { metric: "Open issues", value: ctx.issuesSummary.length, target: 0, met: ctx.issuesSummary.length === 0 },
76
+ { metric: "Open PRs", value: ctx.prsSummary.length, target: 0, met: ctx.prsSummary.length === 0 },
77
+ { metric: "Issues resolved", value: ctx.resolvedCount, target: minResolved, met: ctx.resolvedCount >= minResolved },
78
+ { metric: "Dedicated tests", value: ctx.hasDedicatedTests ? "YES" : "NO", target: requireTests ? "YES" : "—", met: !requireTests || ctx.hasDedicatedTests },
79
+ { metric: "Source TODOs", value: ctx.sourceTodoCount, target: maxTodos, met: ctx.sourceTodoCount <= maxTodos },
80
+ { metric: "Budget", value: ctx.cumulativeTransformationCost, target: ctx.transformationBudget || "unlimited", met: !(ctx.transformationBudget > 0 && ctx.cumulativeTransformationCost >= ctx.transformationBudget) },
81
+ ];
82
+
83
+ const allMet = metrics.every((m) => m.met);
84
+ const notMet = metrics.filter((m) => !m.met);
85
+
86
+ const table = [
87
+ "| Metric | Value | Target | Status |",
88
+ "|--------|-------|--------|--------|",
89
+ ...metrics.map((m) => `| ${m.metric} | ${m.value} | ${typeof m.target === "number" ? (m.metric.includes("TODO") ? `<= ${m.target}` : m.metric.includes("resolved") ? `>= ${m.target}` : `${m.target}`) : m.target} | ${m.met ? "MET" : "NOT MET"} |`),
90
+ ].join("\n");
91
+
92
+ let assessment;
93
+ if (allMet) {
94
+ assessment = "ALL METRICS MET — mission-complete conditions are satisfied.";
95
+ } else {
96
+ assessment = `${notMet.length} metric(s) NOT MET: ${notMet.map((m) => `${m.metric}=${m.value}`).join(", ")}.`;
97
+ }
98
+
99
+ return { metrics, allMet, notMet, table, assessment };
100
+ }
101
+
102
+ /**
103
+ * Build the director prompt.
104
+ */
105
+ function buildPrompt(ctx, agentInstructions, metricAssessment) {
106
+ return [
107
+ "## Instructions",
108
+ agentInstructions,
109
+ "",
110
+ "## Mission",
111
+ ctx.mission || "(no mission defined)",
112
+ "",
113
+ "## Metric Based Mission Complete Assessment",
114
+ metricAssessment.assessment,
115
+ "",
116
+ "### Mission-Complete Metrics",
117
+ metricAssessment.table,
118
+ "",
119
+ "## Repository State",
120
+ `### Open Issues (${ctx.issuesSummary.length})`,
121
+ ctx.issuesSummary.join("\n") || "none",
122
+ "",
123
+ `### Recently Closed Issues (${ctx.recentlyClosedSummary.length})`,
124
+ ctx.recentlyClosedSummary.join("\n") || "none",
125
+ "",
126
+ `### Open PRs (${ctx.prsSummary.length})`,
127
+ ctx.prsSummary.join("\n") || "none",
128
+ "",
129
+ ...(ctx.sourceExports?.length > 0
130
+ ? [
131
+ `### Source Exports`,
132
+ ...ctx.sourceExports.map((e) => `- ${e}`),
133
+ "",
134
+ ]
135
+ : []),
136
+ `### Test Coverage`,
137
+ ctx.hasDedicatedTests
138
+ ? `Dedicated test files: ${ctx.dedicatedTestFiles.join(", ")}`
139
+ : "**No dedicated test files found.**",
140
+ "",
141
+ `### Source TODO Count: ${ctx.sourceTodoCount}`,
142
+ "",
143
+ `### Transformation Budget: ${ctx.cumulativeTransformationCost}/${ctx.transformationBudget || "unlimited"}`,
144
+ "",
145
+ `### Recent Activity`,
146
+ ctx.recentActivity || "none",
147
+ "",
148
+ ].join("\n");
149
+ }
150
+
151
+ /**
152
+ * Parse the director's LLM response.
153
+ */
154
+ function parseDirectorResponse(content) {
155
+ const decisionMatch = content.match(/\[DECISION\]([\s\S]*?)\[\/DECISION\]/);
156
+ const reasonMatch = content.match(/\[REASON\]([\s\S]*?)\[\/REASON\]/);
157
+ const analysisMatch = content.match(/\[ANALYSIS\]([\s\S]*?)\[\/ANALYSIS\]/);
158
+
159
+ const decision = decisionMatch ? decisionMatch[1].trim().toLowerCase() : "in-progress";
160
+ const reason = reasonMatch ? reasonMatch[1].trim() : "";
161
+ const analysis = analysisMatch ? analysisMatch[1].trim() : content.substring(0, 500);
162
+
163
+ return { decision, reason, analysis };
164
+ }
165
+
166
+ /**
167
+ * Execute mission-complete: write signal file and commit via Contents API.
168
+ */
169
+ async function executeMissionComplete(octokit, repo, reason) {
170
+ const signal = [
171
+ "# Mission Complete",
172
+ "",
173
+ `- **Timestamp:** ${new Date().toISOString()}`,
174
+ `- **Detected by:** director`,
175
+ `- **Reason:** ${reason}`,
176
+ "",
177
+ "This file was created automatically. To restart transformations, delete this file or run `npx @xn-intenton-z2a/agentic-lib init --reseed`.",
178
+ ].join("\n");
179
+ writeFileSync("MISSION_COMPLETE.md", signal);
180
+
181
+ try {
182
+ const contentBase64 = Buffer.from(signal).toString("base64");
183
+ let existingSha;
184
+ try {
185
+ const { data } = await octokit.rest.repos.getContent({ ...repo, path: "MISSION_COMPLETE.md", ref: "main" });
186
+ existingSha = data.sha;
187
+ } catch { /* doesn't exist yet */ }
188
+ await octokit.rest.repos.createOrUpdateFileContents({
189
+ ...repo,
190
+ path: "MISSION_COMPLETE.md",
191
+ message: "mission-complete: " + reason.substring(0, 72),
192
+ content: contentBase64,
193
+ branch: "main",
194
+ ...(existingSha ? { sha: existingSha } : {}),
195
+ });
196
+ core.info("MISSION_COMPLETE.md committed to main");
197
+ } catch (err) {
198
+ core.warning(`Could not commit MISSION_COMPLETE.md: ${err.message}`);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Execute mission-failed: write signal file and commit via Contents API.
204
+ */
205
+ async function executeMissionFailed(octokit, repo, reason) {
206
+ const signal = [
207
+ "# Mission Failed",
208
+ "",
209
+ `- **Timestamp:** ${new Date().toISOString()}`,
210
+ `- **Detected by:** director`,
211
+ `- **Reason:** ${reason}`,
212
+ "",
213
+ "This file was created automatically. To restart, delete this file and run `npx @xn-intenton-z2a/agentic-lib init --reseed`.",
214
+ ].join("\n");
215
+ writeFileSync("MISSION_FAILED.md", signal);
216
+
217
+ try {
218
+ const contentBase64 = Buffer.from(signal).toString("base64");
219
+ let existingSha;
220
+ try {
221
+ const { data } = await octokit.rest.repos.getContent({ ...repo, path: "MISSION_FAILED.md", ref: "main" });
222
+ existingSha = data.sha;
223
+ } catch { /* doesn't exist yet */ }
224
+ await octokit.rest.repos.createOrUpdateFileContents({
225
+ ...repo,
226
+ path: "MISSION_FAILED.md",
227
+ message: "mission-failed: " + reason.substring(0, 72),
228
+ content: contentBase64,
229
+ branch: "main",
230
+ ...(existingSha ? { sha: existingSha } : {}),
231
+ });
232
+ core.info("MISSION_FAILED.md committed to main");
233
+ } catch (err) {
234
+ core.warning(`Could not commit MISSION_FAILED.md: ${err.message}`);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Director task: evaluate mission readiness and produce a decision or gap analysis.
240
+ *
241
+ * @param {Object} context - Task context from index.js
242
+ * @returns {Promise<Object>} Result with outcome, tokensUsed, model
243
+ */
244
+ export async function direct(context) {
245
+ const { octokit, repo, config, instructions, model } = context;
246
+ const t = config.tuning || {};
247
+
248
+ // --- Gather context (similar to supervisor but focused on metrics) ---
249
+ const mission = readOptionalFile(config.paths.mission.path);
250
+ const intentionLogFull = readOptionalFile(config.intentionBot.intentionFilepath);
251
+ const recentActivity = intentionLogFull.split("\n").slice(-20).join("\n");
252
+
253
+ const costMatches = intentionLogFull.matchAll(/\*\*agentic-lib transformation cost:\*\* (\d+)/g);
254
+ const cumulativeTransformationCost = [...costMatches].reduce((sum, m) => sum + parseInt(m[1], 10), 0);
255
+
256
+ const missionComplete = existsSync("MISSION_COMPLETE.md");
257
+ const missionFailed = existsSync("MISSION_FAILED.md");
258
+ const transformationBudget = config.transformationBudget || 0;
259
+
260
+ // If already decided, skip
261
+ if (missionComplete) {
262
+ return { outcome: "nop", details: "Mission already complete (MISSION_COMPLETE.md exists)" };
263
+ }
264
+ if (missionFailed) {
265
+ return { outcome: "nop", details: "Mission already failed (MISSION_FAILED.md exists)" };
266
+ }
267
+
268
+ // Skip in maintenance mode
269
+ if (config.supervisor === "maintenance") {
270
+ return { outcome: "nop", details: "Maintenance mode — director skipped" };
271
+ }
272
+
273
+ const initTimestamp = config.init?.timestamp || null;
274
+
275
+ const { data: openIssues } = await octokit.rest.issues.listForRepo({
276
+ ...repo, state: "open", per_page: t.issuesScan || 20, sort: "created", direction: "asc",
277
+ });
278
+ const issuesOnly = openIssues.filter((i) => !i.pull_request);
279
+ const filteredIssues = filterIssues(issuesOnly, { staleDays: t.staleDays || 30, initTimestamp });
280
+ const issuesSummary = filteredIssues.map((i) => {
281
+ const labels = i.labels.map((l) => l.name).join(", ");
282
+ return `#${i.number}: ${i.title} [${labels || "no labels"}]`;
283
+ });
284
+
285
+ // Recently closed issues
286
+ let recentlyClosedSummary = [];
287
+ let resolvedCount = 0;
288
+ try {
289
+ const { data: closedIssuesRaw } = await octokit.rest.issues.listForRepo({
290
+ ...repo, state: "closed", labels: "automated", per_page: 10, sort: "updated", direction: "desc",
291
+ });
292
+ const initEpoch = initTimestamp ? new Date(initTimestamp).getTime() : 0;
293
+ const closedFiltered = closedIssuesRaw.filter((i) =>
294
+ !i.pull_request && (initEpoch <= 0 || new Date(i.created_at).getTime() >= initEpoch)
295
+ );
296
+ for (const ci of closedFiltered) {
297
+ let closeReason = "closed";
298
+ try {
299
+ const { data: comments } = await octokit.rest.issues.listComments({
300
+ ...repo, issue_number: ci.number, per_page: 5, sort: "created", direction: "desc",
301
+ });
302
+ if (comments.some((c) => c.body?.includes("Automated Review Result"))) {
303
+ closeReason = "RESOLVED";
304
+ } else {
305
+ const { data: events } = await octokit.rest.issues.listEvents({
306
+ ...repo, issue_number: ci.number, per_page: 10,
307
+ });
308
+ if (events.some((e) => e.event === "closed" && e.commit_id)) {
309
+ closeReason = "RESOLVED";
310
+ }
311
+ }
312
+ } catch { /* ignore */ }
313
+ if (closeReason === "RESOLVED") resolvedCount++;
314
+ recentlyClosedSummary.push(`#${ci.number}: ${ci.title} — ${closeReason}`);
315
+ }
316
+ } catch (err) {
317
+ core.warning(`Could not fetch recently closed issues: ${err.message}`);
318
+ }
319
+
320
+ // Open PRs
321
+ const { data: openPRs } = await octokit.rest.pulls.list({
322
+ ...repo, state: "open", per_page: 10, sort: "updated", direction: "desc",
323
+ });
324
+ const prsSummary = openPRs.map((pr) => `#${pr.number}: ${pr.title} (${pr.head.ref})`);
325
+
326
+ // Source exports
327
+ let sourceExports = [];
328
+ try {
329
+ const sourcePath = config.paths.source?.path || "src/lib/";
330
+ if (existsSync(sourcePath)) {
331
+ const sourceFiles = scanDirectory(sourcePath, [".js", ".ts"], { limit: 5 });
332
+ for (const sf of sourceFiles) {
333
+ const content = readFileSync(sf.path, "utf8");
334
+ const exports = [...content.matchAll(/export\s+(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/g)]
335
+ .map((m) => m[1]);
336
+ if (exports.length > 0) {
337
+ sourceExports.push(`${sf.name}: ${exports.join(", ")}`);
338
+ }
339
+ }
340
+ }
341
+ } catch { /* ignore */ }
342
+
343
+ // Dedicated tests
344
+ const { hasDedicatedTests, dedicatedTestFiles } = detectDedicatedTests();
345
+
346
+ // TODO count
347
+ const sourcePath = config.paths.source?.path || "src/lib/";
348
+ const sourceDir = sourcePath.endsWith("/") ? sourcePath.slice(0, -1) : sourcePath;
349
+ const srcRoot = sourceDir.includes("/") ? sourceDir.split("/").slice(0, -1).join("/") || "src" : "src";
350
+ const sourceTodoCount = countTodos(srcRoot);
351
+
352
+ // Build context
353
+ const ctx = {
354
+ mission,
355
+ recentActivity,
356
+ issuesSummary,
357
+ recentlyClosedSummary,
358
+ resolvedCount,
359
+ prsSummary,
360
+ sourceExports,
361
+ hasDedicatedTests,
362
+ dedicatedTestFiles,
363
+ sourceTodoCount,
364
+ cumulativeTransformationCost,
365
+ transformationBudget,
366
+ };
367
+
368
+ // Build metric-based advisory
369
+ const metricAssessment = buildMetricAssessment(ctx, config);
370
+ core.info(`Metric assessment: ${metricAssessment.assessment}`);
371
+
372
+ // --- LLM decision ---
373
+ const agentInstructions = instructions || "You are the director. Evaluate mission readiness.";
374
+ const prompt = buildPrompt(ctx, agentInstructions, metricAssessment);
375
+
376
+ const { content, tokensUsed, inputTokens, outputTokens, cost } = await runCopilotTask({
377
+ model,
378
+ systemMessage:
379
+ "You are the director of an autonomous coding repository. Your job is to evaluate whether the mission is complete, failed, or in progress. You produce a structured assessment — you do NOT dispatch workflows or create issues.",
380
+ prompt,
381
+ writablePaths: [],
382
+ tuning: t,
383
+ });
384
+
385
+ const { decision, reason, analysis } = parseDirectorResponse(content);
386
+ core.info(`Director decision: ${decision} — ${reason}`);
387
+
388
+ // Execute the decision
389
+ let outcome = "directed";
390
+ if (decision === "mission-complete" && metricAssessment.allMet) {
391
+ if (process.env.GITHUB_REPOSITORY !== "xn-intenton-z2a/agentic-lib") {
392
+ await executeMissionComplete(octokit, repo, reason);
393
+ outcome = "mission-complete";
394
+ }
395
+ } else if (decision === "mission-complete" && !metricAssessment.allMet) {
396
+ core.info("Director chose mission-complete but metrics are NOT all met — overriding to in-progress");
397
+ outcome = "directed";
398
+ } else if (decision === "mission-failed") {
399
+ if (process.env.GITHUB_REPOSITORY !== "xn-intenton-z2a/agentic-lib") {
400
+ await executeMissionFailed(octokit, repo, reason);
401
+ outcome = "mission-failed";
402
+ }
403
+ }
404
+
405
+ // Set output for downstream jobs to check
406
+ core.setOutput("director-decision", decision);
407
+ core.setOutput("director-analysis", analysis.substring(0, 500));
408
+
409
+ return {
410
+ outcome,
411
+ tokensUsed,
412
+ inputTokens,
413
+ outputTokens,
414
+ cost,
415
+ model,
416
+ details: `Decision: ${decision}\nReason: ${reason}\nAnalysis: ${analysis.substring(0, 300)}`,
417
+ narrative: `Director: ${reason}`,
418
+ metricAssessment: metricAssessment.assessment,
419
+ directorAnalysis: analysis,
420
+ hasDedicatedTests,
421
+ resolvedCount,
422
+ changes: outcome === "mission-complete"
423
+ ? [{ action: "mission-complete", file: "MISSION_COMPLETE.md", sizeInfo: reason.substring(0, 100) }]
424
+ : outcome === "mission-failed"
425
+ ? [{ action: "mission-failed", file: "MISSION_FAILED.md", sizeInfo: reason.substring(0, 100) }]
426
+ : [],
427
+ };
428
+ }