@xn-intenton-z2a/agentic-lib 7.2.5 → 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.
- package/.github/workflows/agentic-lib-init.yml +56 -0
- package/.github/workflows/agentic-lib-test.yml +7 -2
- package/.github/workflows/agentic-lib-workflow.yml +50 -3
- package/agentic-lib.toml +7 -0
- package/package.json +1 -1
- package/src/actions/agentic-step/config-loader.js +9 -0
- package/src/actions/agentic-step/index.js +104 -7
- package/src/actions/agentic-step/tasks/direct.js +428 -0
- package/src/actions/agentic-step/tasks/supervise.js +100 -180
- package/src/agents/agent-director.md +58 -0
- package/src/agents/agent-supervisor.md +22 -50
- package/src/seeds/zero-behaviour.test.js +4 -4
- package/src/seeds/zero-package.json +1 -1
- package/src/seeds/zero-playwright.config.js +1 -0
|
@@ -333,3 +333,59 @@ jobs:
|
|
|
333
333
|
exit 1
|
|
334
334
|
fi
|
|
335
335
|
done
|
|
336
|
+
|
|
337
|
+
# W8: Create initial seed issues after purge so the pipeline has work to do
|
|
338
|
+
- name: Create initial seed issues
|
|
339
|
+
if: github.repository != 'xn-intenton-z2a/agentic-lib' && env.INIT_MODE == 'purge' && needs.params.outputs.dry-run != 'true'
|
|
340
|
+
uses: actions/github-script@v8
|
|
341
|
+
with:
|
|
342
|
+
script: |
|
|
343
|
+
const fs = require('fs');
|
|
344
|
+
const missionContent = fs.existsSync('MISSION.md')
|
|
345
|
+
? fs.readFileSync('MISSION.md', 'utf8')
|
|
346
|
+
: '(no MISSION.md found)';
|
|
347
|
+
|
|
348
|
+
// Ensure labels exist
|
|
349
|
+
for (const label of ['automated', 'ready']) {
|
|
350
|
+
try {
|
|
351
|
+
await github.rest.issues.createLabel({
|
|
352
|
+
...context.repo, name: label,
|
|
353
|
+
color: label === 'automated' ? '0e8a16' : '1d76db',
|
|
354
|
+
description: label === 'automated' ? 'Created by automation' : 'Ready for dev transform',
|
|
355
|
+
});
|
|
356
|
+
} catch (e) { /* label already exists */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// W8a: Initial unit tests issue
|
|
360
|
+
const unitTestBody = [
|
|
361
|
+
'Create a unit test file for each of the major features in the mission ',
|
|
362
|
+
'and put a TODO in a trivial empty passing test in each.',
|
|
363
|
+
'',
|
|
364
|
+
'## MISSION.md',
|
|
365
|
+
'',
|
|
366
|
+
missionContent,
|
|
367
|
+
].join('\n');
|
|
368
|
+
const { data: issue1 } = await github.rest.issues.create({
|
|
369
|
+
...context.repo,
|
|
370
|
+
title: 'Initial unit tests',
|
|
371
|
+
body: unitTestBody,
|
|
372
|
+
labels: ['automated', 'ready'],
|
|
373
|
+
});
|
|
374
|
+
core.info(`Created issue #${issue1.number}: Initial unit tests`);
|
|
375
|
+
|
|
376
|
+
// W8b: Initial web layout issue
|
|
377
|
+
const webLayoutBody = [
|
|
378
|
+
'Create the home page layout to showcase each of the major features in the mission ',
|
|
379
|
+
'and put a TODO in a trivial empty passing test in each.',
|
|
380
|
+
'',
|
|
381
|
+
'## MISSION.md',
|
|
382
|
+
'',
|
|
383
|
+
missionContent,
|
|
384
|
+
].join('\n');
|
|
385
|
+
const { data: issue2 } = await github.rest.issues.create({
|
|
386
|
+
...context.repo,
|
|
387
|
+
title: 'Initial web layout',
|
|
388
|
+
body: webLayoutBody,
|
|
389
|
+
labels: ['automated', 'ready'],
|
|
390
|
+
});
|
|
391
|
+
core.info(`Created issue #${issue2.number}: Initial web layout`);
|
|
@@ -90,8 +90,13 @@ jobs:
|
|
|
90
90
|
- name: Install dependencies
|
|
91
91
|
run: npm ci
|
|
92
92
|
|
|
93
|
-
- name: Run behaviour tests
|
|
94
|
-
run:
|
|
93
|
+
- name: Run behaviour tests (with retry)
|
|
94
|
+
run: |
|
|
95
|
+
npm run test:behaviour || {
|
|
96
|
+
echo "::warning::Behaviour test attempt 1 failed — retrying"
|
|
97
|
+
sleep 2
|
|
98
|
+
npm run test:behaviour
|
|
99
|
+
}
|
|
95
100
|
#env:
|
|
96
101
|
# HOME: /root
|
|
97
102
|
|
|
@@ -621,14 +621,55 @@ jobs:
|
|
|
621
621
|
commit-message: "agentic-step: maintain features and library"
|
|
622
622
|
push-ref: ${{ github.ref_name }}
|
|
623
623
|
|
|
624
|
-
# ───
|
|
625
|
-
|
|
626
|
-
needs: [params,
|
|
624
|
+
# ─── Director: LLM evaluates mission status (complete/failed/in-progress) ──
|
|
625
|
+
director:
|
|
626
|
+
needs: [params, telemetry, maintain]
|
|
627
627
|
if: |
|
|
628
628
|
!cancelled() &&
|
|
629
629
|
(needs.params.outputs.mode == 'full' || needs.params.outputs.mode == 'dev-only') &&
|
|
630
630
|
needs.params.result == 'success'
|
|
631
631
|
runs-on: ubuntu-latest
|
|
632
|
+
outputs:
|
|
633
|
+
decision: ${{ steps.director.outputs.director-decision }}
|
|
634
|
+
analysis: ${{ steps.director.outputs.director-analysis }}
|
|
635
|
+
steps:
|
|
636
|
+
- uses: actions/checkout@v6
|
|
637
|
+
|
|
638
|
+
- uses: actions/setup-node@v6
|
|
639
|
+
with:
|
|
640
|
+
node-version: "24"
|
|
641
|
+
|
|
642
|
+
- name: Self-init (agentic-lib dev only)
|
|
643
|
+
if: hashFiles('scripts/self-init.sh') != '' && hashFiles('.github/agentic-lib/actions/agentic-step/package.json') == ''
|
|
644
|
+
run: bash scripts/self-init.sh
|
|
645
|
+
|
|
646
|
+
- name: Install agentic-step dependencies
|
|
647
|
+
working-directory: .github/agentic-lib/actions/agentic-step
|
|
648
|
+
run: npm ci
|
|
649
|
+
|
|
650
|
+
- name: Run director
|
|
651
|
+
id: director
|
|
652
|
+
if: github.repository != 'xn-intenton-z2a/agentic-lib'
|
|
653
|
+
uses: ./.github/agentic-lib/actions/agentic-step
|
|
654
|
+
env:
|
|
655
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
656
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
|
657
|
+
with:
|
|
658
|
+
task: "direct"
|
|
659
|
+
config: ${{ needs.params.outputs.config-path }}
|
|
660
|
+
instructions: ".github/agentic-lib/agents/agent-director.md"
|
|
661
|
+
model: ${{ needs.params.outputs.model }}
|
|
662
|
+
|
|
663
|
+
# ─── Supervisor: LLM decides what to do (after director evaluates) ──
|
|
664
|
+
supervisor:
|
|
665
|
+
needs: [params, pr-cleanup, telemetry, maintain, director]
|
|
666
|
+
if: |
|
|
667
|
+
!cancelled() &&
|
|
668
|
+
(needs.params.outputs.mode == 'full' || needs.params.outputs.mode == 'dev-only') &&
|
|
669
|
+
needs.params.result == 'success' &&
|
|
670
|
+
needs.director.outputs.decision != 'mission-complete' &&
|
|
671
|
+
needs.director.outputs.decision != 'mission-failed'
|
|
672
|
+
runs-on: ubuntu-latest
|
|
632
673
|
steps:
|
|
633
674
|
- uses: actions/checkout@v6
|
|
634
675
|
|
|
@@ -1175,6 +1216,12 @@ jobs:
|
|
|
1175
1216
|
set +e
|
|
1176
1217
|
npm run --if-present test:behaviour 2>&1 | tail -30
|
|
1177
1218
|
EXIT_CODE=$?
|
|
1219
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
1220
|
+
echo "::warning::Behaviour test attempt 1 failed — retrying"
|
|
1221
|
+
sleep 2
|
|
1222
|
+
npm run --if-present test:behaviour 2>&1 | tail -30
|
|
1223
|
+
EXIT_CODE=$?
|
|
1224
|
+
fi
|
|
1178
1225
|
set -e
|
|
1179
1226
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
1180
1227
|
echo "tests-passed=false" >> $GITHUB_OUTPUT
|
package/agentic-lib.toml
CHANGED
|
@@ -130,5 +130,12 @@ max-attempts-per-issue = 4 # max transform attempts before aband
|
|
|
130
130
|
features-limit = 8 # max feature files in features/ directory
|
|
131
131
|
library-limit = 64 # max library entries in library/ directory
|
|
132
132
|
|
|
133
|
+
[mission-complete]
|
|
134
|
+
# Thresholds for deterministic mission-complete declaration.
|
|
135
|
+
# All conditions must be met simultaneously.
|
|
136
|
+
min-resolved-issues = 3 # minimum closed-as-RESOLVED issues since init
|
|
137
|
+
require-dedicated-tests = true # require test files that import from src/lib/
|
|
138
|
+
max-source-todos = 0 # max TODO comments allowed in ./src (0 = none)
|
|
139
|
+
|
|
133
140
|
[bot]
|
|
134
141
|
log-file = "test/intentïon.md" #@dist "intentïon.md"
|
package/package.json
CHANGED
|
@@ -257,6 +257,14 @@ export function loadConfig(configPath) {
|
|
|
257
257
|
const execution = toml.execution || {};
|
|
258
258
|
const bot = toml.bot || {};
|
|
259
259
|
|
|
260
|
+
// Mission-complete thresholds (with safe defaults)
|
|
261
|
+
const mc = toml["mission-complete"] || {};
|
|
262
|
+
const missionCompleteThresholds = {
|
|
263
|
+
minResolvedIssues: mc["min-resolved-issues"] ?? 3,
|
|
264
|
+
requireDedicatedTests: mc["require-dedicated-tests"] ?? true,
|
|
265
|
+
maxSourceTodos: mc["max-source-todos"] ?? 0,
|
|
266
|
+
};
|
|
267
|
+
|
|
260
268
|
return {
|
|
261
269
|
supervisor: toml.schedule?.supervisor || "daily",
|
|
262
270
|
model: toml.tuning?.model || toml.schedule?.model || "gpt-5-mini",
|
|
@@ -274,6 +282,7 @@ export function loadConfig(configPath) {
|
|
|
274
282
|
},
|
|
275
283
|
init: toml.init || null,
|
|
276
284
|
tdd: toml.tdd === true,
|
|
285
|
+
missionCompleteThresholds,
|
|
277
286
|
writablePaths,
|
|
278
287
|
readOnlyPaths,
|
|
279
288
|
configToml: rawToml,
|
|
@@ -9,7 +9,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 } from "fs";
|
|
12
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
13
14
|
|
|
14
15
|
// Task implementations
|
|
15
16
|
import { resolveIssue } from "./tasks/resolve-issue.js";
|
|
@@ -21,6 +22,7 @@ import { enhanceIssue } from "./tasks/enhance-issue.js";
|
|
|
21
22
|
import { reviewIssue } from "./tasks/review-issue.js";
|
|
22
23
|
import { discussions } from "./tasks/discussions.js";
|
|
23
24
|
import { supervise } from "./tasks/supervise.js";
|
|
25
|
+
import { direct } from "./tasks/direct.js";
|
|
24
26
|
|
|
25
27
|
const TASKS = {
|
|
26
28
|
"resolve-issue": resolveIssue,
|
|
@@ -32,8 +34,38 @@ const TASKS = {
|
|
|
32
34
|
"review-issue": reviewIssue,
|
|
33
35
|
"discussions": discussions,
|
|
34
36
|
"supervise": supervise,
|
|
37
|
+
"direct": direct,
|
|
35
38
|
};
|
|
36
39
|
|
|
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
|
+
|
|
37
69
|
/**
|
|
38
70
|
* Build mission-complete metrics array for the intentïon.md dashboard.
|
|
39
71
|
*/
|
|
@@ -47,10 +79,28 @@ function buildMissionMetrics(config, result, limitsStatus, cumulativeCost, featu
|
|
|
47
79
|
// Count open PRs from result if available
|
|
48
80
|
const openPrs = result.openPrCount || 0;
|
|
49
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: Check for dedicated test files
|
|
90
|
+
const hasDedicatedTests = result.hasDedicatedTests ?? false;
|
|
91
|
+
|
|
92
|
+
// W11: Thresholds from config
|
|
93
|
+
const thresholds = config.missionCompleteThresholds || {};
|
|
94
|
+
const minResolved = thresholds.minResolvedIssues ?? 3;
|
|
95
|
+
const requireTests = thresholds.requireDedicatedTests ?? true;
|
|
96
|
+
const maxTodos = thresholds.maxSourceTodos ?? 0;
|
|
97
|
+
|
|
50
98
|
const metrics = [
|
|
51
99
|
{ metric: "Open issues", value: String(openIssues), target: "0", status: openIssues === 0 ? "MET" : "NOT MET" },
|
|
52
100
|
{ metric: "Open PRs", value: String(openPrs), target: "0", status: openPrs === 0 ? "MET" : "NOT MET" },
|
|
53
|
-
{ metric: "Issues resolved (review or PR merge)", value: String(resolvedCount), target:
|
|
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: hasDedicatedTests ? "YES" : "NO", target: requireTests ? "YES" : "—", status: !requireTests || hasDedicatedTests ? "MET" : "NOT MET" },
|
|
103
|
+
{ metric: "Source TODO count", value: String(todoCount), target: `<= ${maxTodos}`, status: todoCount <= maxTodos ? "MET" : "NOT MET" },
|
|
54
104
|
{ metric: "Transformation budget used", value: `${cumulativeCost}/${budgetCap}`, target: budgetCap > 0 ? `< ${budgetCap}` : "unlimited", status: budgetCap > 0 && cumulativeCost >= budgetCap ? "EXHAUSTED" : "OK" },
|
|
55
105
|
{ metric: "Cumulative transforms", value: String(cumulativeCost), target: ">= 1", status: cumulativeCost >= 1 ? "MET" : "NOT MET" },
|
|
56
106
|
{ metric: "Mission complete declared", value: missionComplete ? "YES" : "NO", target: "—", status: "—" },
|
|
@@ -67,6 +117,8 @@ function buildMissionReadiness(metrics) {
|
|
|
67
117
|
const openIssues = parseInt(metrics.find((m) => m.metric === "Open issues")?.value || "0", 10);
|
|
68
118
|
const openPrs = parseInt(metrics.find((m) => m.metric === "Open PRs")?.value || "0", 10);
|
|
69
119
|
const resolved = parseInt(metrics.find((m) => m.metric === "Issues resolved (review or PR merge)")?.value || "0", 10);
|
|
120
|
+
const hasDedicatedTests = metrics.find((m) => m.metric === "Dedicated test files")?.value === "YES";
|
|
121
|
+
const todoCount = parseInt(metrics.find((m) => m.metric === "Source TODO count")?.value || "0", 10);
|
|
70
122
|
const missionComplete = metrics.find((m) => m.metric === "Mission complete declared")?.value === "YES";
|
|
71
123
|
const missionFailed = metrics.find((m) => m.metric === "Mission failed declared")?.value === "YES";
|
|
72
124
|
|
|
@@ -77,17 +129,23 @@ function buildMissionReadiness(metrics) {
|
|
|
77
129
|
return "Mission has been declared failed.";
|
|
78
130
|
}
|
|
79
131
|
|
|
80
|
-
|
|
132
|
+
// Check all NOT MET conditions
|
|
133
|
+
const notMet = metrics.filter((m) => m.status === "NOT MET");
|
|
134
|
+
const allMet = notMet.length === 0;
|
|
81
135
|
const parts = [];
|
|
82
136
|
|
|
83
|
-
if (
|
|
137
|
+
if (allMet) {
|
|
84
138
|
parts.push("Mission complete conditions ARE met.");
|
|
85
|
-
parts.push(`0 open issues, 0 open PRs, ${resolved} issue(s) resolved.`);
|
|
139
|
+
parts.push(`0 open issues, 0 open PRs, ${resolved} issue(s) resolved, dedicated tests: ${hasDedicatedTests ? "yes" : "no"}, TODOs: ${todoCount}.`);
|
|
86
140
|
} else {
|
|
87
141
|
parts.push("Mission complete conditions are NOT met.");
|
|
88
142
|
if (openIssues > 0) parts.push(`${openIssues} open issue(s) remain.`);
|
|
89
143
|
if (openPrs > 0) parts.push(`${openPrs} open PR(s) remain.`);
|
|
90
|
-
|
|
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
|
+
}
|
|
91
149
|
}
|
|
92
150
|
|
|
93
151
|
return parts.join(" ");
|
|
@@ -166,9 +224,23 @@ async function run() {
|
|
|
166
224
|
const profileName = config.tuning?.profileName || "unknown";
|
|
167
225
|
|
|
168
226
|
// Transformation cost: 1 for code-changing tasks, 0 otherwise
|
|
227
|
+
// W4: Instability transforms (infrastructure fixes) don't count against mission budget
|
|
169
228
|
const COST_TASKS = ["transform", "fix-code", "maintain-features", "maintain-library"];
|
|
170
229
|
const isNop = result.outcome === "nop" || result.outcome === "error";
|
|
171
|
-
|
|
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;
|
|
172
244
|
|
|
173
245
|
// Read cumulative transformation cost from the activity log
|
|
174
246
|
const intentionFilepath = config.intentionBot?.intentionFilepath;
|
|
@@ -190,6 +262,31 @@ async function run() {
|
|
|
190
262
|
? readdirSync(libraryPath).filter((f) => f.endsWith(".md")).length
|
|
191
263
|
: 0;
|
|
192
264
|
|
|
265
|
+
// W3/W10: Detect dedicated test files (centrally, for all tasks)
|
|
266
|
+
let hasDedicatedTests = result.hasDedicatedTests ?? false;
|
|
267
|
+
if (!hasDedicatedTests) {
|
|
268
|
+
try {
|
|
269
|
+
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
|
+
hasDedicatedTests = true;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (hasDedicatedTests) break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch { /* ignore — scanDirectory not available in test environment */ }
|
|
286
|
+
}
|
|
287
|
+
// Inject hasDedicatedTests into result for buildMissionMetrics
|
|
288
|
+
result.hasDedicatedTests = hasDedicatedTests;
|
|
289
|
+
|
|
193
290
|
// Count open automated issues (feature vs maintenance)
|
|
194
291
|
let featureIssueCount = 0;
|
|
195
292
|
let maintenanceIssueCount = 0;
|