@plainconceptsplatform/workflows 0.2.1 → 0.3.0
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/dist/catalog-installation.d.ts +7 -1
- package/dist/catalog-installation.js +91 -11
- package/dist/index.js +3 -2
- package/dist/route-processing.d.ts +6 -0
- package/dist/route-processing.js +121 -0
- package/dist/route-processing.test.d.ts +1 -0
- package/dist/route-processing.test.js +310 -0
- package/dist/stack-defaults.d.ts +11 -0
- package/dist/stack-defaults.js +105 -0
- package/dist/stack-defaults.test.d.ts +1 -0
- package/dist/stack-defaults.test.js +257 -0
- package/dist/tui.js +12 -2
- package/loops/workflows/shared/platform-defaults.md +0 -2
- package/loops/workflows/work-router.yml +33 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type TemplateName } from "./workflow-catalog.js";
|
|
1
|
+
import { type RouteName, type TemplateName } from "./workflow-catalog.js";
|
|
2
|
+
import type { RepositoryInspection } from "./repository-inspection.js";
|
|
2
3
|
export interface CatalogInstallResult {
|
|
3
4
|
readonly installed: readonly string[];
|
|
4
5
|
readonly conflicts: readonly string[];
|
|
@@ -6,6 +7,8 @@ export interface CatalogInstallResult {
|
|
|
6
7
|
export interface CatalogInstallOptions {
|
|
7
8
|
readonly force?: boolean;
|
|
8
9
|
readonly sourcePath?: string;
|
|
10
|
+
readonly selectedRoutes?: readonly RouteName[];
|
|
11
|
+
readonly inspection?: RepositoryInspection;
|
|
9
12
|
}
|
|
10
13
|
interface CatalogFile {
|
|
11
14
|
readonly source: string;
|
|
@@ -18,4 +21,7 @@ export declare function installCatalog(repositoryPath: string, options?: Catalog
|
|
|
18
21
|
export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
19
22
|
export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
20
23
|
export declare function isTemplateName(value: string): value is TemplateName;
|
|
24
|
+
export declare function ensurePreCommitHook(repositoryPath: string): Promise<void>;
|
|
25
|
+
export declare function runCompileIfAvailable(repositoryPath: string): Promise<void>;
|
|
26
|
+
export declare function exists(path: string): Promise<boolean>;
|
|
21
27
|
export {};
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { catalogTemplates, mandatoryFiles, routeNames, templateNames } from "./workflow-catalog.js";
|
|
8
|
+
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
9
|
+
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
6
11
|
const sourceMappings = [
|
|
7
12
|
["actions", ".github/actions"],
|
|
8
13
|
["workflows", ".github/workflows"],
|
|
@@ -20,23 +25,68 @@ export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
|
20
25
|
}
|
|
21
26
|
export async function installCatalog(repositoryPath, options = {}) {
|
|
22
27
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
28
|
+
const selectedRoutes = options.selectedRoutes ?? routeNames;
|
|
29
|
+
const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
|
|
30
|
+
const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
|
|
31
|
+
const excluded = excludedWorkerFiles(selectedRoutes);
|
|
32
|
+
const filtered = deduplicated.filter((file) => {
|
|
33
|
+
const fileName = file.target.split("/").pop() ?? "";
|
|
34
|
+
return !excluded.has(fileName);
|
|
35
|
+
});
|
|
36
|
+
const managedFiles = filtered.filter((file) => file.managed);
|
|
26
37
|
const conflicts = (await Promise.all(managedFiles.map(async (file) => {
|
|
27
38
|
const destination = join(repositoryPath, file.target);
|
|
28
39
|
return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
|
|
29
40
|
}))).filter((file) => file !== undefined);
|
|
30
41
|
if (conflicts.length > 0 && !options.force)
|
|
31
42
|
return { installed: [], conflicts };
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
const fileContents = new Map();
|
|
44
|
+
for (const file of filtered) {
|
|
45
|
+
fileContents.set(file.target, await readFile(file.source, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
let processedContents = processRoutes(fileContents, selectedRoutes);
|
|
48
|
+
if (options.inspection !== undefined) {
|
|
49
|
+
const defaults = generateStackDefaults(options.inspection);
|
|
50
|
+
processedContents = injectStackIntoWorkers(processedContents, defaults);
|
|
51
|
+
processedContents = transformOpencodeFiles(processedContents, options.inspection);
|
|
52
|
+
}
|
|
53
|
+
await Promise.all([...processedContents.entries()].map(async ([target, content]) => {
|
|
54
|
+
const destination = join(repositoryPath, target);
|
|
55
|
+
const originalFile = filtered.find((f) => f.target === target);
|
|
56
|
+
if (originalFile !== undefined && !originalFile.managed && await exists(destination))
|
|
35
57
|
return;
|
|
36
58
|
await mkdir(dirname(destination), { recursive: true });
|
|
37
|
-
await
|
|
59
|
+
await writeFile(destination, content, "utf8");
|
|
38
60
|
}));
|
|
39
|
-
|
|
61
|
+
await ensurePreCommitHook(repositoryPath);
|
|
62
|
+
try {
|
|
63
|
+
await runCompileIfAvailable(repositoryPath);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// compile failure is non-fatal
|
|
67
|
+
}
|
|
68
|
+
return { installed: [...processedContents.keys()].sort(), conflicts };
|
|
69
|
+
}
|
|
70
|
+
function injectStackIntoWorkers(files, defaults) {
|
|
71
|
+
const result = new Map(files);
|
|
72
|
+
for (const [key, content] of result) {
|
|
73
|
+
if (key.startsWith(".github/workflows/agent-") && key.endsWith(".md")) {
|
|
74
|
+
result.set(key, injectStackEnv(content, defaults));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function transformOpencodeFiles(files, inspection) {
|
|
80
|
+
const result = new Map(files);
|
|
81
|
+
for (const [key, content] of result) {
|
|
82
|
+
if (key.endsWith("opencode-ci.md")) {
|
|
83
|
+
result.set(key, generateOpencodeCi(content, inspection));
|
|
84
|
+
}
|
|
85
|
+
else if (key === "opencode.ci.json") {
|
|
86
|
+
result.set(key, generateOpencodeConfig(content, inspection));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
40
90
|
}
|
|
41
91
|
export async function installTemplate(repositoryPath, template, options = {}) {
|
|
42
92
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
@@ -49,6 +99,17 @@ export async function installTemplate(repositoryPath, template, options = {}) {
|
|
|
49
99
|
return { installed: [], conflicts };
|
|
50
100
|
await mkdir(dirname(destination), { recursive: true });
|
|
51
101
|
await copyFile(source, destination);
|
|
102
|
+
if (options.inspection !== undefined && template === "opencode.ci.json") {
|
|
103
|
+
const baseContent = await readFile(source, "utf8");
|
|
104
|
+
const transformed = generateOpencodeConfig(baseContent, options.inspection);
|
|
105
|
+
await writeFile(destination, transformed, "utf8");
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await runCompileIfAvailable(repositoryPath);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// compile failure is non-fatal
|
|
112
|
+
}
|
|
52
113
|
return { installed: [target], conflicts };
|
|
53
114
|
}
|
|
54
115
|
export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
@@ -70,6 +131,25 @@ export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
|
70
131
|
export function isTemplateName(value) {
|
|
71
132
|
return templateNames.includes(value);
|
|
72
133
|
}
|
|
134
|
+
export async function ensurePreCommitHook(repositoryPath) {
|
|
135
|
+
const hookPath = join(repositoryPath, ".husky", "pre-commit");
|
|
136
|
+
if (!await exists(hookPath))
|
|
137
|
+
return;
|
|
138
|
+
const content = await readFile(hookPath, "utf8");
|
|
139
|
+
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
140
|
+
if (content.includes("compile-agent-workflows"))
|
|
141
|
+
return;
|
|
142
|
+
const newContent = content.endsWith("\n") || content === ""
|
|
143
|
+
? `${content}${compileLine}\n`
|
|
144
|
+
: `${content}\n${compileLine}\n`;
|
|
145
|
+
await writeFile(hookPath, newContent, "utf8");
|
|
146
|
+
}
|
|
147
|
+
export async function runCompileIfAvailable(repositoryPath) {
|
|
148
|
+
const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
|
|
149
|
+
if (await exists(script)) {
|
|
150
|
+
await execFileAsync("node", [script, "--force"], { cwd: repositoryPath });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
73
153
|
function catalogTemplateMeta(template) {
|
|
74
154
|
const entry = catalogTemplates.find((item) => item.name === template);
|
|
75
155
|
if (entry === undefined)
|
|
@@ -113,7 +193,7 @@ async function filesMatch(source, destination) {
|
|
|
113
193
|
return false;
|
|
114
194
|
}
|
|
115
195
|
}
|
|
116
|
-
async function exists(path) {
|
|
196
|
+
export async function exists(path) {
|
|
117
197
|
try {
|
|
118
198
|
await access(path, constants.F_OK);
|
|
119
199
|
return true;
|
package/dist/index.js
CHANGED
|
@@ -78,9 +78,10 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
78
78
|
if (template === "invalid")
|
|
79
79
|
return fail(`${command} accepts only --force or --template agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|opencode.ci.json.`);
|
|
80
80
|
const force = options.includes("--force");
|
|
81
|
+
const inspection = await inspectRepository(repositoryPath);
|
|
81
82
|
const result = template === undefined
|
|
82
|
-
? await installCatalog(repositoryPath, { force })
|
|
83
|
-
: await installTemplate(repositoryPath, template, { force });
|
|
83
|
+
? await installCatalog(repositoryPath, { force, inspection })
|
|
84
|
+
: await installTemplate(repositoryPath, template, { force, inspection });
|
|
84
85
|
if (result.conflicts.length > 0 && !force) {
|
|
85
86
|
console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${result.conflicts.join("\n")}`);
|
|
86
87
|
return 1;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RouteName } from "./workflow-catalog.js";
|
|
2
|
+
export declare function stripRouteFromRouter(yaml: string, route: RouteName): string;
|
|
3
|
+
export declare function stripRouteFromClassifier(shell: string, route: RouteName): string;
|
|
4
|
+
export declare function addRouteExclusion(matrix: string, route: RouteName): string;
|
|
5
|
+
export declare function processRoutes(files: Map<string, string>, selectedRoutes: readonly RouteName[]): Map<string, string>;
|
|
6
|
+
export declare function excludedWorkerFiles(selectedRoutes: readonly RouteName[]): Set<string>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { routeNames, workflowRoutes } from "./workflow-catalog.js";
|
|
2
|
+
const routeCrons = {
|
|
3
|
+
audit: "17 1 * * 1",
|
|
4
|
+
propose: "29 7 * * *",
|
|
5
|
+
};
|
|
6
|
+
export function stripRouteFromRouter(yaml, route) {
|
|
7
|
+
let result = yaml;
|
|
8
|
+
const cron = routeCrons[route];
|
|
9
|
+
if (cron !== undefined) {
|
|
10
|
+
result = result.replace(new RegExp(`^ - cron: "${escapeRegex(cron)}"\\n`, "gm"), "");
|
|
11
|
+
}
|
|
12
|
+
result = removeJobBlock(result, `call-${route}`);
|
|
13
|
+
result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
export function stripRouteFromClassifier(shell, route) {
|
|
17
|
+
const constName = route.replace(/-/g, "_").toUpperCase() + "_CRON";
|
|
18
|
+
let result = shell;
|
|
19
|
+
result = result.replace(new RegExp(`^readonly ${constName}="[^"]*"\\n`, "gm"), "");
|
|
20
|
+
const cron = routeCrons[route];
|
|
21
|
+
if (cron !== undefined) {
|
|
22
|
+
result = result.replace(new RegExp(`\\s*"\\$${constName}"\\) route="${escapeRegex(route)}" ;;\\n`, "g"), "");
|
|
23
|
+
}
|
|
24
|
+
result = result.replace(new RegExp(` \\| ${escapeRegex(route)}\\)`, "g"), ")");
|
|
25
|
+
result = result.replace(new RegExp(`^(\\s+)${escapeRegex(route)} \\| `, "gm"), "$1");
|
|
26
|
+
if (!result.includes(`| ${route}`) && !result.includes(`${route} |`)) {
|
|
27
|
+
result = removeAloneDispatchCase(result, route);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function removeAloneDispatchCase(shell, route) {
|
|
32
|
+
const lines = shell.split("\n");
|
|
33
|
+
const pattern = new RegExp(`^(\\s+)${escapeRegex(route)}\\)\\s*$`);
|
|
34
|
+
const result = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
while (i < lines.length) {
|
|
37
|
+
const match = lines[i].match(pattern);
|
|
38
|
+
if (match) {
|
|
39
|
+
const indent = match[1];
|
|
40
|
+
i++;
|
|
41
|
+
while (i < lines.length) {
|
|
42
|
+
if (new RegExp(`^${escapeRegex(indent)};;\\s*$`).test(lines[i])) {
|
|
43
|
+
i++;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
result.push(lines[i]);
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result.join("\n");
|
|
55
|
+
}
|
|
56
|
+
export function addRouteExclusion(matrix, route) {
|
|
57
|
+
if (matrix.includes(`excluded route '${route}'`))
|
|
58
|
+
return matrix;
|
|
59
|
+
let result = matrix;
|
|
60
|
+
result = result.replace(new RegExp(` ${escapeRegex(route)} `, "g"), " ");
|
|
61
|
+
const exclusionBlock = `\necho "── Excluded routes ──────────────────────────────────────────────────────"\nif ! grep -q "route == '${route}'" "$ROUTER_YML"; then\n PASS=$((PASS + 1))\n echo " ${route} correctly excluded from work-router.yml"\nelse\n FAIL=$((FAIL + 1))\n echo "FAIL: excluded route '${route}' is still in work-router.yml" >&2\nfi\n`;
|
|
62
|
+
result = result.replace(/(\necho\nif \[ "\$FAIL" -eq 0 \])/, `${exclusionBlock}$1`);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
export function processRoutes(files, selectedRoutes) {
|
|
66
|
+
const excludedRoutes = routeNames.filter((r) => !selectedRoutes.includes(r));
|
|
67
|
+
if (excludedRoutes.length === 0)
|
|
68
|
+
return files;
|
|
69
|
+
const result = new Map(files);
|
|
70
|
+
for (const route of excludedRoutes) {
|
|
71
|
+
const routerKey = findFileKey(result, "work-router.yml");
|
|
72
|
+
if (routerKey !== undefined) {
|
|
73
|
+
result.set(routerKey, stripRouteFromRouter(result.get(routerKey), route));
|
|
74
|
+
}
|
|
75
|
+
const classifierKey = findFileKey(result, "classify-route.sh");
|
|
76
|
+
if (classifierKey !== undefined) {
|
|
77
|
+
result.set(classifierKey, stripRouteFromClassifier(result.get(classifierKey), route));
|
|
78
|
+
}
|
|
79
|
+
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
80
|
+
if (matrixKey !== undefined) {
|
|
81
|
+
result.set(matrixKey, addRouteExclusion(result.get(matrixKey), route));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
export function excludedWorkerFiles(selectedRoutes) {
|
|
87
|
+
return new Set(workflowRoutes
|
|
88
|
+
.filter((route) => !selectedRoutes.includes(route.name))
|
|
89
|
+
.map((route) => route.worker));
|
|
90
|
+
}
|
|
91
|
+
function removeJobBlock(yaml, jobName) {
|
|
92
|
+
const lines = yaml.split("\n");
|
|
93
|
+
const startPattern = new RegExp(`^ ${escapeRegex(jobName)}:`);
|
|
94
|
+
const result = [];
|
|
95
|
+
let skipping = false;
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (skipping) {
|
|
98
|
+
if (/^ \S/.test(line) || /^[^\s]/.test(line)) {
|
|
99
|
+
skipping = false;
|
|
100
|
+
result.push(line);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (startPattern.test(line)) {
|
|
104
|
+
skipping = true;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
result.push(line);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result.join("\n");
|
|
111
|
+
}
|
|
112
|
+
function findFileKey(files, endsWith) {
|
|
113
|
+
for (const key of files.keys()) {
|
|
114
|
+
if (key.endsWith(endsWith))
|
|
115
|
+
return key;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
function escapeRegex(str) {
|
|
120
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { addRouteExclusion, excludedWorkerFiles, processRoutes, stripRouteFromClassifier, stripRouteFromRouter, } from "./route-processing.js";
|
|
3
|
+
import { routeNames } from "./workflow-catalog.js";
|
|
4
|
+
const ROUTER_YAML = `# header
|
|
5
|
+
name: "All Work Router"
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: "17 1 * * 1"
|
|
10
|
+
- cron: "43 3 * * *"
|
|
11
|
+
- cron: "0 6 * * *"
|
|
12
|
+
- cron: "0 */2 * * *"
|
|
13
|
+
- cron: "29 7 * * *"
|
|
14
|
+
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
inputs:
|
|
17
|
+
operation:
|
|
18
|
+
description: "Operation to run"
|
|
19
|
+
required: true
|
|
20
|
+
type: choice
|
|
21
|
+
options:
|
|
22
|
+
- refine
|
|
23
|
+
- implement
|
|
24
|
+
- direct
|
|
25
|
+
- apply-review
|
|
26
|
+
- merge-gate
|
|
27
|
+
- audit
|
|
28
|
+
- propose
|
|
29
|
+
- audit-close
|
|
30
|
+
- cleanup-artifacts
|
|
31
|
+
- stale-recovery
|
|
32
|
+
- validate
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
call-refine:
|
|
36
|
+
needs: [classify, authorize]
|
|
37
|
+
if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
|
|
38
|
+
uses: ./.github/workflows/agent-refine.lock.yml
|
|
39
|
+
secrets:
|
|
40
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
41
|
+
|
|
42
|
+
call-implement:
|
|
43
|
+
needs: [classify, authorize]
|
|
44
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
|
|
45
|
+
uses: ./.github/workflows/agent-implement.lock.yml
|
|
46
|
+
secrets:
|
|
47
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
48
|
+
|
|
49
|
+
call-audit:
|
|
50
|
+
needs: classify
|
|
51
|
+
if: needs.classify.outputs.route == 'audit'
|
|
52
|
+
uses: ./.github/workflows/agent-audit.lock.yml
|
|
53
|
+
secrets:
|
|
54
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
55
|
+
|
|
56
|
+
call-propose:
|
|
57
|
+
needs: classify
|
|
58
|
+
if: needs.classify.outputs.route == 'propose'
|
|
59
|
+
uses: ./.github/workflows/agent-propose.lock.yml
|
|
60
|
+
secrets:
|
|
61
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
62
|
+
|
|
63
|
+
audit-close:
|
|
64
|
+
needs: classify
|
|
65
|
+
if: needs.classify.outputs.route == 'audit-close'
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
`;
|
|
68
|
+
const CLASSIFIER_SH = `#!/usr/bin/env bash
|
|
69
|
+
set -euo pipefail
|
|
70
|
+
|
|
71
|
+
readonly AUDIT_CRON="17 1 * * 1"
|
|
72
|
+
readonly AUDIT_CLOSE_CRON="43 3 * * *"
|
|
73
|
+
readonly CLEANUP_ARTIFACTS_CRON="0 6 * * *"
|
|
74
|
+
readonly STALE_RECOVERY_CRON="0 */2 * * *"
|
|
75
|
+
readonly PROPOSE_CRON="29 7 * * *"
|
|
76
|
+
|
|
77
|
+
classify_route() {
|
|
78
|
+
local route="none" error=""
|
|
79
|
+
|
|
80
|
+
case "\${EVENT:-}" in
|
|
81
|
+
schedule)
|
|
82
|
+
trigger_kind="scheduled"
|
|
83
|
+
case "\${SCHEDULE:-}" in
|
|
84
|
+
"\$AUDIT_CRON") route="audit" ;;
|
|
85
|
+
"\$AUDIT_CLOSE_CRON") route="audit-close" ;;
|
|
86
|
+
"\$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
|
|
87
|
+
"\$STALE_RECOVERY_CRON") route="stale-recovery" ;;
|
|
88
|
+
"\$PROPOSE_CRON") route="propose" ;;
|
|
89
|
+
*) error="no route for cron '\${SCHEDULE:-}'" ;;
|
|
90
|
+
esac
|
|
91
|
+
;;
|
|
92
|
+
|
|
93
|
+
workflow_dispatch)
|
|
94
|
+
trigger_kind="manual"
|
|
95
|
+
case "\${OPERATION:-}" in
|
|
96
|
+
refine | implement | direct)
|
|
97
|
+
route="\${OPERATION}"
|
|
98
|
+
;;
|
|
99
|
+
apply-review)
|
|
100
|
+
route="apply-review"
|
|
101
|
+
;;
|
|
102
|
+
merge-gate)
|
|
103
|
+
route="merge-gate"
|
|
104
|
+
;;
|
|
105
|
+
audit | propose)
|
|
106
|
+
route="\${OPERATION}"
|
|
107
|
+
trigger_kind="\${INPUT_TRIGGER_KIND:-manual}"
|
|
108
|
+
;;
|
|
109
|
+
audit-close | cleanup-artifacts | stale-recovery | validate)
|
|
110
|
+
route="\${OPERATION}"
|
|
111
|
+
;;
|
|
112
|
+
*)
|
|
113
|
+
error="unknown operation '\${OPERATION:-}'"
|
|
114
|
+
;;
|
|
115
|
+
esac
|
|
116
|
+
;;
|
|
117
|
+
esac
|
|
118
|
+
|
|
119
|
+
cat <<EOF
|
|
120
|
+
route=\${route}
|
|
121
|
+
error=\${error}
|
|
122
|
+
EOF
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if [ "\${BASH_SOURCE[0]}" = "\$0" ]; then
|
|
126
|
+
classify_route
|
|
127
|
+
fi
|
|
128
|
+
`;
|
|
129
|
+
const MATRIX_SH = `#!/usr/bin/env bash
|
|
130
|
+
set -euo pipefail
|
|
131
|
+
|
|
132
|
+
echo "── Router wiring ─────────────────────────────────────────────────────────"
|
|
133
|
+
for route in refine implement direct apply-review merge-gate audit propose bot-approve \\
|
|
134
|
+
audit-close cleanup-artifacts stale-recovery validate; do
|
|
135
|
+
if grep -q "route == '\${route}'" "\$ROUTER_YML"; then
|
|
136
|
+
PASS=\$((PASS + 1))
|
|
137
|
+
else
|
|
138
|
+
FAIL=\$((FAIL + 1))
|
|
139
|
+
echo "FAIL: work-router.yml has no job for route '\${route}'" >&2
|
|
140
|
+
fi
|
|
141
|
+
done
|
|
142
|
+
|
|
143
|
+
while read -r operation; do
|
|
144
|
+
if grep -q "route == '\${operation}'" "\$ROUTER_YML"; then
|
|
145
|
+
PASS=\$((PASS + 1))
|
|
146
|
+
else
|
|
147
|
+
FAIL=\$((FAIL + 1))
|
|
148
|
+
echo "FAIL: dispatch operation '\${operation}' has no job in work-router.yml" >&2
|
|
149
|
+
fi
|
|
150
|
+
done < <(sed -n '/^ operation:/,/^ issue-number:/p' "\$ROUTER_YML" | sed -n 's/^ - //p')
|
|
151
|
+
|
|
152
|
+
echo
|
|
153
|
+
if [ "\$FAIL" -eq 0 ]; then
|
|
154
|
+
echo "Route matrix: \${PASS} passed"
|
|
155
|
+
else
|
|
156
|
+
echo "Route matrix: \${PASS} passed, \${FAIL} FAILED" >&2
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
exit \$((FAIL > 0))
|
|
160
|
+
`;
|
|
161
|
+
describe("stripRouteFromRouter", () => {
|
|
162
|
+
it("removes the propose cron entry", () => {
|
|
163
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "propose");
|
|
164
|
+
expect(result).not.toContain('cron: "29 7 * * *"');
|
|
165
|
+
expect(result).toContain('cron: "17 1 * * 1"');
|
|
166
|
+
});
|
|
167
|
+
it("removes the call-propose job block", () => {
|
|
168
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "propose");
|
|
169
|
+
expect(result).not.toContain("call-propose");
|
|
170
|
+
expect(result).toContain("call-refine");
|
|
171
|
+
expect(result).toContain("call-audit");
|
|
172
|
+
});
|
|
173
|
+
it("removes propose from the dispatch options", () => {
|
|
174
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "propose");
|
|
175
|
+
expect(result).not.toMatch(/^\s+- propose$/m);
|
|
176
|
+
expect(result).toMatch(/^\s+- refine$/m);
|
|
177
|
+
expect(result).toMatch(/^\s+- audit$/m);
|
|
178
|
+
});
|
|
179
|
+
it("removes the audit cron entry", () => {
|
|
180
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "audit");
|
|
181
|
+
expect(result).not.toContain('cron: "17 1 * * 1"');
|
|
182
|
+
expect(result).toContain('cron: "29 7 * * *"');
|
|
183
|
+
});
|
|
184
|
+
it("removes the call-audit job block", () => {
|
|
185
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "audit");
|
|
186
|
+
expect(result).not.toContain("call-audit");
|
|
187
|
+
expect(result).toContain("call-refine");
|
|
188
|
+
});
|
|
189
|
+
it("preserves the audit-close job when removing audit", () => {
|
|
190
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "audit");
|
|
191
|
+
expect(result).toContain("audit-close");
|
|
192
|
+
});
|
|
193
|
+
it("does not modify the yaml when stripping a route that has no cron", () => {
|
|
194
|
+
const yamlWithoutCron = ROUTER_YAML.replace(/ - cron: "17 1 \* \* 1"\n/, "");
|
|
195
|
+
const result = stripRouteFromRouter(yamlWithoutCron, "audit");
|
|
196
|
+
expect(result).not.toContain("call-audit");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe("stripRouteFromClassifier", () => {
|
|
200
|
+
it("removes the PROPOSE_CRON constant", () => {
|
|
201
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "propose");
|
|
202
|
+
expect(result).not.toContain('readonly PROPOSE_CRON');
|
|
203
|
+
expect(result).toContain('readonly AUDIT_CRON');
|
|
204
|
+
});
|
|
205
|
+
it("removes the propose schedule case", () => {
|
|
206
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "propose");
|
|
207
|
+
expect(result).not.toContain('"$PROPOSE_CRON") route="propose"');
|
|
208
|
+
expect(result).toContain('"$AUDIT_CRON") route="audit"');
|
|
209
|
+
});
|
|
210
|
+
it("removes the AUDIT_CRON constant", () => {
|
|
211
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "audit");
|
|
212
|
+
expect(result).not.toContain('readonly AUDIT_CRON');
|
|
213
|
+
expect(result).toContain('readonly PROPOSE_CRON');
|
|
214
|
+
});
|
|
215
|
+
it("removes the audit schedule case", () => {
|
|
216
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "audit");
|
|
217
|
+
expect(result).not.toContain('"$AUDIT_CRON") route="audit"');
|
|
218
|
+
expect(result).toContain('"$PROPOSE_CRON") route="propose"');
|
|
219
|
+
});
|
|
220
|
+
it("removes propose from the dispatch case union", () => {
|
|
221
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "propose");
|
|
222
|
+
expect(result).not.toContain("audit | propose)");
|
|
223
|
+
expect(result).toContain("audit)");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe("addRouteExclusion", () => {
|
|
227
|
+
it("adds an exclusion assertion for the route", () => {
|
|
228
|
+
const result = addRouteExclusion(MATRIX_SH, "propose");
|
|
229
|
+
expect(result).toContain("excluded route 'propose'");
|
|
230
|
+
expect(result).toContain("propose correctly excluded from work-router.yml");
|
|
231
|
+
});
|
|
232
|
+
it("does not add the exclusion twice", () => {
|
|
233
|
+
const once = addRouteExclusion(MATRIX_SH, "propose");
|
|
234
|
+
const twice = addRouteExclusion(once, "propose");
|
|
235
|
+
const matchCount = (twice.match(/excluded route 'propose'/g) ?? []).length;
|
|
236
|
+
expect(matchCount).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe("processRoutes", () => {
|
|
240
|
+
it("returns the same map when all routes are selected", () => {
|
|
241
|
+
const files = new Map([
|
|
242
|
+
["work-router.yml", ROUTER_YAML],
|
|
243
|
+
["classify-route.sh", CLASSIFIER_SH],
|
|
244
|
+
["verify-route-matrix.sh", MATRIX_SH],
|
|
245
|
+
]);
|
|
246
|
+
const result = processRoutes(files, [...routeNames]);
|
|
247
|
+
expect(result).toBe(files);
|
|
248
|
+
});
|
|
249
|
+
it("strips propose from all three files when unselected", () => {
|
|
250
|
+
const files = new Map([
|
|
251
|
+
[".github/workflows/work-router.yml", ROUTER_YAML],
|
|
252
|
+
[".github/actions/classify-route/classify-route.sh", CLASSIFIER_SH],
|
|
253
|
+
[".github/actions/verify-route-matrix/verify-route-matrix.sh", MATRIX_SH],
|
|
254
|
+
]);
|
|
255
|
+
const selectedRoutes = routeNames.filter((r) => r !== "propose");
|
|
256
|
+
const result = processRoutes(files, selectedRoutes);
|
|
257
|
+
const router = result.get(".github/workflows/work-router.yml");
|
|
258
|
+
expect(router).not.toContain("call-propose");
|
|
259
|
+
expect(router).not.toContain('cron: "29 7 * * *"');
|
|
260
|
+
expect(router).not.toMatch(/^\s+- propose$/m);
|
|
261
|
+
const classifier = result.get(".github/actions/classify-route/classify-route.sh");
|
|
262
|
+
expect(classifier).not.toContain("readonly PROPOSE_CRON");
|
|
263
|
+
expect(classifier).not.toContain('"$PROPOSE_CRON")');
|
|
264
|
+
const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
|
|
265
|
+
expect(matrix).toContain("excluded route 'propose'");
|
|
266
|
+
});
|
|
267
|
+
it("strips audit from all three files when unselected", () => {
|
|
268
|
+
const files = new Map([
|
|
269
|
+
["work-router.yml", ROUTER_YAML],
|
|
270
|
+
["classify-route.sh", CLASSIFIER_SH],
|
|
271
|
+
["verify-route-matrix.sh", MATRIX_SH],
|
|
272
|
+
]);
|
|
273
|
+
const selectedRoutes = routeNames.filter((r) => r !== "audit");
|
|
274
|
+
const result = processRoutes(files, selectedRoutes);
|
|
275
|
+
const router = result.get("work-router.yml");
|
|
276
|
+
expect(router).not.toContain("call-audit");
|
|
277
|
+
expect(router).not.toContain('cron: "17 1 * * 1"');
|
|
278
|
+
const classifier = result.get("classify-route.sh");
|
|
279
|
+
expect(classifier).not.toContain("readonly AUDIT_CRON");
|
|
280
|
+
expect(classifier).not.toContain('"$AUDIT_CRON")');
|
|
281
|
+
});
|
|
282
|
+
it("processes multiple excluded routes at once", () => {
|
|
283
|
+
const files = new Map([
|
|
284
|
+
["work-router.yml", ROUTER_YAML],
|
|
285
|
+
["classify-route.sh", CLASSIFIER_SH],
|
|
286
|
+
["verify-route-matrix.sh", MATRIX_SH],
|
|
287
|
+
]);
|
|
288
|
+
const selectedRoutes = ["refine", "implement"];
|
|
289
|
+
const result = processRoutes(files, selectedRoutes);
|
|
290
|
+
const router = result.get("work-router.yml");
|
|
291
|
+
expect(router).toContain("call-refine");
|
|
292
|
+
expect(router).toContain("call-implement");
|
|
293
|
+
expect(router).not.toContain("call-audit");
|
|
294
|
+
expect(router).not.toContain("call-propose");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
describe("excludedWorkerFiles", () => {
|
|
298
|
+
it("returns worker files for unselected routes", () => {
|
|
299
|
+
const selectedRoutes = ["refine", "implement"];
|
|
300
|
+
const excluded = excludedWorkerFiles(selectedRoutes);
|
|
301
|
+
expect(excluded.has("agent-refine.md")).toBe(false);
|
|
302
|
+
expect(excluded.has("agent-implement.md")).toBe(false);
|
|
303
|
+
expect(excluded.has("agent-audit.md")).toBe(true);
|
|
304
|
+
expect(excluded.has("agent-propose.md")).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
it("returns an empty set when all routes are selected", () => {
|
|
307
|
+
const excluded = excludedWorkerFiles([...routeNames]);
|
|
308
|
+
expect(excluded.size).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RepositoryInspection } from "./repository-inspection.js";
|
|
2
|
+
export interface StackDefaults {
|
|
3
|
+
readonly verifyCommands: string;
|
|
4
|
+
readonly repoRulesBase: string;
|
|
5
|
+
readonly hasDotnet: boolean;
|
|
6
|
+
readonly hasNodeOnly: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function generateStackDefaults(inspection: RepositoryInspection): StackDefaults;
|
|
9
|
+
export declare function injectStackEnv(content: string, defaults: StackDefaults): string;
|
|
10
|
+
export declare function generateOpencodeCi(baseContent: string, inspection: RepositoryInspection): string;
|
|
11
|
+
export declare function generateOpencodeConfig(baseContent: string, inspection: RepositoryInspection): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export function generateStackDefaults(inspection) {
|
|
2
|
+
const hasDotnet = inspection.stackHints.solutionFiles.length > 0;
|
|
3
|
+
const hasNode = inspection.stackHints.pnpmLockfile;
|
|
4
|
+
const hasNodeOnly = hasNode && !hasDotnet;
|
|
5
|
+
let verifyCommands;
|
|
6
|
+
let repoRulesBase;
|
|
7
|
+
if (hasDotnet && hasNode) {
|
|
8
|
+
verifyCommands = ".NET: dotnet restore && dotnet build -c Release && dotnet test | Web: pnpm lint && pnpm test && pnpm build";
|
|
9
|
+
repoRulesBase = "Full-stack .NET + React/Next.js repository. Follow Clean Architecture layering: API → Application → Domain. Infrastructure implements Application ports. Do not reference EF Core or ASP.NET from Application. Frontend communicates exclusively via HTTP endpoints. Run both .NET and frontend verification.";
|
|
10
|
+
}
|
|
11
|
+
else if (hasDotnet) {
|
|
12
|
+
verifyCommands = "dotnet restore && dotnet build -c Release --no-restore && dotnet test";
|
|
13
|
+
repoRulesBase = ".NET repository using Clean Architecture. Follow layering: API → Application → Domain. Infrastructure implements Application ports. Do not reference EF Core or ASP.NET from Application.";
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
verifyCommands = "pnpm verify";
|
|
17
|
+
repoRulesBase = "Node.js repository. Follow existing project conventions and import boundaries.";
|
|
18
|
+
}
|
|
19
|
+
return { verifyCommands, repoRulesBase, hasDotnet, hasNodeOnly };
|
|
20
|
+
}
|
|
21
|
+
export function injectStackEnv(content, defaults) {
|
|
22
|
+
let result = content;
|
|
23
|
+
if (defaults.verifyCommands === "pnpm verify")
|
|
24
|
+
return result;
|
|
25
|
+
if (result.includes("VERIFY_COMMANDS:")) {
|
|
26
|
+
result = result.replace(/ VERIFY_COMMANDS: ".*"/, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
|
|
27
|
+
}
|
|
28
|
+
else if (/^env:\n/m.test(result)) {
|
|
29
|
+
result = result.replace(/^env:\n/m, `env:\n VERIFY_COMMANDS: "${defaults.verifyCommands}"\n`);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
export function generateOpencodeCi(baseContent, inspection) {
|
|
34
|
+
let result = baseContent;
|
|
35
|
+
if (inspection.stackHints.solutionFiles.length > 0) {
|
|
36
|
+
const nugetSteps = ` - name: Cache NuGet packages
|
|
37
|
+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
38
|
+
with:
|
|
39
|
+
path: ~/.nuget/packages
|
|
40
|
+
key: nuget-\${{ runner.os }}-\${{ hashFiles('**/*.slnx', '**/Directory.Packages.props') }}
|
|
41
|
+
restore-keys: nuget-\${{ runner.os }}-
|
|
42
|
+
|
|
43
|
+
- name: Restore .NET dependencies
|
|
44
|
+
run: dotnet restore
|
|
45
|
+
`;
|
|
46
|
+
result = insertBeforeMarker(result, nugetSteps, " - name: Install workspace dependencies");
|
|
47
|
+
}
|
|
48
|
+
if (inspection.stackHints.openSpec) {
|
|
49
|
+
const openspecStep = ` - name: Install OpenSpec CLI
|
|
50
|
+
run: |
|
|
51
|
+
set -euo pipefail
|
|
52
|
+
npm install -g @openspec/cli@latest
|
|
53
|
+
openspec --version
|
|
54
|
+
`;
|
|
55
|
+
result = insertBeforeMarker(result, openspecStep, " - name: Install workspace dependencies");
|
|
56
|
+
}
|
|
57
|
+
if (inspection.stackHints.packageJson && !result.includes("--legacy-peer-deps")) {
|
|
58
|
+
result = result.replace(` if ! npm install --prefix .opencode; then\n echo "No plugin deps, skipping."\n exit 0\n fi`, ` if ! npm install --prefix .opencode; then\n echo "::warning::Strict npm install failed on a peer conflict. Retrying with --legacy-peer-deps; check .opencode/package.json."\n npm install --prefix .opencode --legacy-peer-deps\n fi`);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function insertBeforeMarker(content, steps, marker) {
|
|
63
|
+
if (content.includes(marker)) {
|
|
64
|
+
return content.replace(marker, steps + marker);
|
|
65
|
+
}
|
|
66
|
+
return content + "\n" + steps;
|
|
67
|
+
}
|
|
68
|
+
export function generateOpencodeConfig(baseContent, inspection) {
|
|
69
|
+
try {
|
|
70
|
+
const config = JSON.parse(baseContent);
|
|
71
|
+
const hasDotnet = inspection.stackHints.solutionFiles.length > 0;
|
|
72
|
+
if (!hasDotnet && config.lsp !== undefined) {
|
|
73
|
+
delete config.lsp;
|
|
74
|
+
}
|
|
75
|
+
else if (hasDotnet && config.lsp === undefined) {
|
|
76
|
+
config.lsp = {
|
|
77
|
+
csharp: { disabled: true },
|
|
78
|
+
fsharp: { disabled: true },
|
|
79
|
+
razor: { disabled: true },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const agent = config.agent;
|
|
83
|
+
if (agent !== undefined) {
|
|
84
|
+
const agentEntry = agent["ci-workflow-agent"];
|
|
85
|
+
if (agentEntry !== undefined) {
|
|
86
|
+
let prompt = agentEntry.prompt ?? "";
|
|
87
|
+
if (hasDotnet) {
|
|
88
|
+
if (!prompt.includes(".NET guardrails")) {
|
|
89
|
+
prompt += "\n\n# .NET guardrails\nFollow Clean Architecture layering: API → Application → Domain. Infrastructure implements Application ports. Application must not reference EF Core or ASP.NET. Use Central Package Management (Directory.Packages.props). Build in Release mode for CI.";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
if (!prompt.includes("Node/React rules")) {
|
|
94
|
+
prompt += "\n\n# Node/React rules\nFollow Feature-Sliced Design import boundaries. Use pnpm, never npm or yarn. All user-facing text must be i18n messages. TypeScript strict mode.";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
agentEntry.prompt = prompt;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return JSON.stringify(config, null, 2) + "\n";
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return baseContent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv, } from "./stack-defaults.js";
|
|
3
|
+
function makeInspection(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
repositoryPath: "/repo",
|
|
6
|
+
existingAgentWorkflows: [],
|
|
7
|
+
stackHints: {
|
|
8
|
+
packageJson: false,
|
|
9
|
+
pnpmLockfile: false,
|
|
10
|
+
solutionFiles: [],
|
|
11
|
+
openSpec: false,
|
|
12
|
+
...overrides,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe("generateStackDefaults", () => {
|
|
17
|
+
it("returns .NET verify commands when .slnx is found", () => {
|
|
18
|
+
const defaults = generateStackDefaults(makeInspection({
|
|
19
|
+
solutionFiles: ["app.slnx"],
|
|
20
|
+
}));
|
|
21
|
+
expect(defaults.verifyCommands).toBe("dotnet restore && dotnet build -c Release --no-restore && dotnet test");
|
|
22
|
+
expect(defaults.hasDotnet).toBe(true);
|
|
23
|
+
expect(defaults.hasNodeOnly).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it("returns pnpm verify when pnpm-lock.yaml is found without .slnx", () => {
|
|
26
|
+
const defaults = generateStackDefaults(makeInspection({
|
|
27
|
+
pnpmLockfile: true,
|
|
28
|
+
}));
|
|
29
|
+
expect(defaults.verifyCommands).toBe("pnpm verify");
|
|
30
|
+
expect(defaults.hasDotnet).toBe(false);
|
|
31
|
+
expect(defaults.hasNodeOnly).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it("returns both .NET and Web verify commands for a full-stack repo", () => {
|
|
34
|
+
const defaults = generateStackDefaults(makeInspection({
|
|
35
|
+
solutionFiles: ["app.slnx"],
|
|
36
|
+
pnpmLockfile: true,
|
|
37
|
+
}));
|
|
38
|
+
expect(defaults.verifyCommands).toContain(".NET");
|
|
39
|
+
expect(defaults.verifyCommands).toContain("dotnet restore");
|
|
40
|
+
expect(defaults.verifyCommands).toContain("dotnet build -c Release");
|
|
41
|
+
expect(defaults.verifyCommands).toContain("dotnet test");
|
|
42
|
+
expect(defaults.verifyCommands).toContain("Web");
|
|
43
|
+
expect(defaults.verifyCommands).toContain("pnpm lint");
|
|
44
|
+
expect(defaults.verifyCommands).toContain("pnpm test");
|
|
45
|
+
expect(defaults.verifyCommands).toContain("pnpm build");
|
|
46
|
+
expect(defaults.hasDotnet).toBe(true);
|
|
47
|
+
expect(defaults.hasNodeOnly).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
it("returns pnpm verify when neither .slnx nor pnpm-lock.yaml is present", () => {
|
|
50
|
+
const defaults = generateStackDefaults(makeInspection());
|
|
51
|
+
expect(defaults.verifyCommands).toBe("pnpm verify");
|
|
52
|
+
expect(defaults.hasDotnet).toBe(false);
|
|
53
|
+
expect(defaults.hasNodeOnly).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it("includes a repo rules base string with architecture context", () => {
|
|
56
|
+
const dotnetDefaults = generateStackDefaults(makeInspection({
|
|
57
|
+
solutionFiles: ["app.slnx"],
|
|
58
|
+
}));
|
|
59
|
+
expect(dotnetDefaults.repoRulesBase).toContain("Clean Architecture");
|
|
60
|
+
const nodeDefaults = generateStackDefaults(makeInspection({
|
|
61
|
+
pnpmLockfile: true,
|
|
62
|
+
}));
|
|
63
|
+
expect(nodeDefaults.repoRulesBase).toContain("Node.js");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("injectStackEnv", () => {
|
|
67
|
+
it("injects VERIFY_COMMANDS into a worker that has an env block", () => {
|
|
68
|
+
const content = `---
|
|
69
|
+
env:
|
|
70
|
+
REPO_RULES: "some rules"
|
|
71
|
+
description: test
|
|
72
|
+
---`;
|
|
73
|
+
const defaults = generateStackDefaults(makeInspection({
|
|
74
|
+
solutionFiles: ["app.slnx"],
|
|
75
|
+
}));
|
|
76
|
+
const result = injectStackEnv(content, defaults);
|
|
77
|
+
expect(result).toContain('VERIFY_COMMANDS: "dotnet restore && dotnet build -c Release --no-restore && dotnet test"');
|
|
78
|
+
});
|
|
79
|
+
it("does not inject when verifyCommands is pnpm verify (the default)", () => {
|
|
80
|
+
const content = `---
|
|
81
|
+
env:
|
|
82
|
+
REPO_RULES: "some rules"
|
|
83
|
+
---`;
|
|
84
|
+
const defaults = generateStackDefaults(makeInspection({
|
|
85
|
+
pnpmLockfile: true,
|
|
86
|
+
}));
|
|
87
|
+
const result = injectStackEnv(content, defaults);
|
|
88
|
+
expect(result).not.toContain("VERIFY_COMMANDS");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const OPENCODE_CI_MD = `---
|
|
92
|
+
env:
|
|
93
|
+
AGENTMEMORY_VERSION: "0.9.28"
|
|
94
|
+
CODEGRAPH_VERSION: "1.5.0"
|
|
95
|
+
description: Shared CI setup.
|
|
96
|
+
|
|
97
|
+
pre-agent-steps:
|
|
98
|
+
- name: Install RTK
|
|
99
|
+
run: |
|
|
100
|
+
rtk --version
|
|
101
|
+
rtk init -g --opencode --auto-patch
|
|
102
|
+
- name: Install opencode plugin dependencies
|
|
103
|
+
run: |
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
if [ ! -f .opencode/package.json ]; then
|
|
106
|
+
echo "No .opencode/package.json, nothing to install"
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
- name: Install workspace dependencies
|
|
110
|
+
run: pnpm install --frozen-lockfile
|
|
111
|
+
- name: Merge the CI-only OpenCode provider into opencode.jsonc
|
|
112
|
+
run: |
|
|
113
|
+
jq -e . "$FRAGMENT" > /dev/null
|
|
114
|
+
---`;
|
|
115
|
+
describe("generateOpencodeCi", () => {
|
|
116
|
+
it("adds NuGet cache and dotnet restore steps when .slnx is found", () => {
|
|
117
|
+
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
118
|
+
solutionFiles: ["app.slnx"],
|
|
119
|
+
}));
|
|
120
|
+
expect(result).toContain("Cache NuGet packages");
|
|
121
|
+
expect(result).toContain("Restore .NET dependencies");
|
|
122
|
+
expect(result).toContain("dotnet restore");
|
|
123
|
+
});
|
|
124
|
+
it("adds OpenSpec CLI install step when openspec/ directory exists", () => {
|
|
125
|
+
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
126
|
+
openSpec: true,
|
|
127
|
+
}));
|
|
128
|
+
expect(result).toContain("Install OpenSpec CLI");
|
|
129
|
+
expect(result).toContain("@openspec/cli");
|
|
130
|
+
});
|
|
131
|
+
it("adds both NuGet and OpenSpec steps when both are detected", () => {
|
|
132
|
+
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
133
|
+
solutionFiles: ["app.slnx"],
|
|
134
|
+
openSpec: true,
|
|
135
|
+
}));
|
|
136
|
+
expect(result).toContain("Cache NuGet packages");
|
|
137
|
+
expect(result).toContain("Install OpenSpec CLI");
|
|
138
|
+
});
|
|
139
|
+
it("does not add NuGet steps when no .slnx is present", () => {
|
|
140
|
+
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
141
|
+
pnpmLockfile: true,
|
|
142
|
+
}));
|
|
143
|
+
expect(result).not.toContain("Cache NuGet packages");
|
|
144
|
+
expect(result).not.toContain("Restore .NET dependencies");
|
|
145
|
+
});
|
|
146
|
+
it("preserves the original merge step", () => {
|
|
147
|
+
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
148
|
+
solutionFiles: ["app.slnx"],
|
|
149
|
+
openSpec: true,
|
|
150
|
+
}));
|
|
151
|
+
expect(result).toContain("Merge the CI-only OpenCode provider");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
const OPENCODE_CI_JSON = `{
|
|
155
|
+
"$schema": "https://opencode.ai/config.json",
|
|
156
|
+
"model": "plainconcepts/glm-5-2",
|
|
157
|
+
"plugin": [],
|
|
158
|
+
"default_agent": "ci-workflow-agent",
|
|
159
|
+
"agent": {
|
|
160
|
+
"ci-workflow-agent": {
|
|
161
|
+
"description": "Executes GitHub Agentic Workflow tasks in CI.",
|
|
162
|
+
"mode": "primary",
|
|
163
|
+
"prompt": "You execute the GitHub Agentic Workflow task in the user prompt."
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"permission": {
|
|
167
|
+
"read": "allow"
|
|
168
|
+
},
|
|
169
|
+
"lsp": {
|
|
170
|
+
"csharp": {
|
|
171
|
+
"disabled": true
|
|
172
|
+
},
|
|
173
|
+
"fsharp": {
|
|
174
|
+
"disabled": true
|
|
175
|
+
},
|
|
176
|
+
"razor": {
|
|
177
|
+
"disabled": true
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"provider": {
|
|
181
|
+
"plainconcepts": {
|
|
182
|
+
"api": "http://172.30.0.30:10000",
|
|
183
|
+
"options": {
|
|
184
|
+
"apiKey": "awf-openai-proxy"
|
|
185
|
+
},
|
|
186
|
+
"models": {
|
|
187
|
+
"glm-5-2": {
|
|
188
|
+
"name": "GLM 5.2"
|
|
189
|
+
},
|
|
190
|
+
"glm-5-1": {
|
|
191
|
+
"name": "GLM 5.1"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
`;
|
|
198
|
+
describe("generateOpencodeConfig", () => {
|
|
199
|
+
it("keeps LSP section when .slnx is present", () => {
|
|
200
|
+
const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
|
|
201
|
+
solutionFiles: ["app.slnx"],
|
|
202
|
+
}));
|
|
203
|
+
const parsed = JSON.parse(result);
|
|
204
|
+
expect(parsed.lsp).toBeDefined();
|
|
205
|
+
expect(parsed.lsp.csharp.disabled).toBe(true);
|
|
206
|
+
expect(parsed.lsp.fsharp.disabled).toBe(true);
|
|
207
|
+
expect(parsed.lsp.razor.disabled).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
it("removes LSP section when no .slnx is present", () => {
|
|
210
|
+
const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
|
|
211
|
+
pnpmLockfile: true,
|
|
212
|
+
}));
|
|
213
|
+
const parsed = JSON.parse(result);
|
|
214
|
+
expect(parsed.lsp).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
it("adds .NET guardrails to agent prompt when .slnx is present and prompt lacks them", () => {
|
|
217
|
+
const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
|
|
218
|
+
solutionFiles: ["app.slnx"],
|
|
219
|
+
}));
|
|
220
|
+
const parsed = JSON.parse(result);
|
|
221
|
+
expect(parsed.agent["ci-workflow-agent"].prompt).toContain(".NET guardrails");
|
|
222
|
+
expect(parsed.agent["ci-workflow-agent"].prompt).toContain("Clean Architecture");
|
|
223
|
+
});
|
|
224
|
+
it("adds Node/React rules to agent prompt when no .slnx", () => {
|
|
225
|
+
const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
|
|
226
|
+
pnpmLockfile: true,
|
|
227
|
+
}));
|
|
228
|
+
const parsed = JSON.parse(result);
|
|
229
|
+
expect(parsed.agent["ci-workflow-agent"].prompt).toContain("Node/React rules");
|
|
230
|
+
expect(parsed.agent["ci-workflow-agent"].prompt).toContain("Feature-Sliced Design");
|
|
231
|
+
});
|
|
232
|
+
it("preserves both models in the provider", () => {
|
|
233
|
+
const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
|
|
234
|
+
solutionFiles: ["app.slnx"],
|
|
235
|
+
}));
|
|
236
|
+
const parsed = JSON.parse(result);
|
|
237
|
+
expect(parsed.provider.plainconcepts.models["glm-5-2"]).toBeDefined();
|
|
238
|
+
expect(parsed.provider.plainconcepts.models["glm-5-1"]).toBeDefined();
|
|
239
|
+
});
|
|
240
|
+
it("preserves the plainconcepts provider and its API URL", () => {
|
|
241
|
+
const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
|
|
242
|
+
solutionFiles: ["app.slnx"],
|
|
243
|
+
}));
|
|
244
|
+
const parsed = JSON.parse(result);
|
|
245
|
+
expect(parsed.provider.plainconcepts.api).toBe("http://172.30.0.30:10000");
|
|
246
|
+
expect(parsed.provider.plainconcepts.options.apiKey).toBe("awf-openai-proxy");
|
|
247
|
+
});
|
|
248
|
+
it("does not add .NET guardrails twice when already present", () => {
|
|
249
|
+
const withGuardrails = OPENCODE_CI_JSON.replace('"You execute the GitHub Agentic Workflow task in the user prompt."', '"You execute the GitHub Agentic Workflow task in the user prompt.\\n\\n# .NET guardrails\\nFollow Clean Architecture."');
|
|
250
|
+
const result = generateOpencodeConfig(withGuardrails, makeInspection({
|
|
251
|
+
solutionFiles: ["app.slnx"],
|
|
252
|
+
}));
|
|
253
|
+
const parsed = JSON.parse(result);
|
|
254
|
+
const guardrailsCount = (parsed.agent["ci-workflow-agent"].prompt.match(/\.NET guardrails/g) ?? []).length;
|
|
255
|
+
expect(guardrailsCount).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
});
|
package/dist/tui.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as readline from "node:readline";
|
|
2
2
|
import { formatCatalog, listCatalog } from "./catalog-listing.js";
|
|
3
3
|
import { installCatalog, installMandatoryFiles, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
4
|
+
import { inspectRepository } from "./repository-inspection.js";
|
|
5
|
+
import { routeNames } from "./workflow-catalog.js";
|
|
4
6
|
const ANSI = {
|
|
5
7
|
clear: "\x1b[2J",
|
|
6
8
|
home: "\x1b[H",
|
|
@@ -220,8 +222,16 @@ async function installSelected(state, repositoryPath, force) {
|
|
|
220
222
|
const templates = items.filter((entry) => entry.kind === "template");
|
|
221
223
|
const allConflicts = [];
|
|
222
224
|
const allInstalled = [];
|
|
225
|
+
const selectedRouteNames = routes.length > 0
|
|
226
|
+
? routes.map((entry) => entry.name).filter((name) => routeNames.includes(name))
|
|
227
|
+
: [...routeNames];
|
|
228
|
+
const inspection = await inspectRepository(repositoryPath);
|
|
223
229
|
if (routes.length > 0) {
|
|
224
|
-
const result = await installCatalog(repositoryPath, {
|
|
230
|
+
const result = await installCatalog(repositoryPath, {
|
|
231
|
+
force,
|
|
232
|
+
selectedRoutes: selectedRouteNames,
|
|
233
|
+
inspection,
|
|
234
|
+
});
|
|
225
235
|
allConflicts.push(...result.conflicts);
|
|
226
236
|
allInstalled.push(...result.installed);
|
|
227
237
|
}
|
|
@@ -233,7 +243,7 @@ async function installSelected(state, repositoryPath, force) {
|
|
|
233
243
|
for (const template of templates) {
|
|
234
244
|
if (!isTemplateName(template.name))
|
|
235
245
|
continue;
|
|
236
|
-
const result = await installTemplate(repositoryPath, template.name, { force });
|
|
246
|
+
const result = await installTemplate(repositoryPath, template.name, { force, inspection });
|
|
237
247
|
allConflicts.push(...result.conflicts);
|
|
238
248
|
allInstalled.push(...result.installed);
|
|
239
249
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
---
|
|
2
2
|
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
-
env:
|
|
4
|
-
VERIFY_COMMANDS: "pnpm verify"
|
|
5
3
|
description: Shared network and safe-output defaults for catalog agent workflows.
|
|
6
4
|
|
|
7
5
|
network:
|
|
@@ -312,6 +312,39 @@ jobs:
|
|
|
312
312
|
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
313
313
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
314
314
|
|
|
315
|
+
bot-approve:
|
|
316
|
+
needs: classify
|
|
317
|
+
if: >
|
|
318
|
+
needs.classify.outputs.route == 'bot-approve' &&
|
|
319
|
+
(github.actor == 'app/github-actions' || github.actor == 'platform-devbox[bot]')
|
|
320
|
+
runs-on: ubuntu-latest
|
|
321
|
+
timeout-minutes: 5
|
|
322
|
+
permissions:
|
|
323
|
+
actions: write
|
|
324
|
+
steps:
|
|
325
|
+
- name: Approve pending workflow runs
|
|
326
|
+
env:
|
|
327
|
+
GH_TOKEN: ${{ github.token }}
|
|
328
|
+
REPO: ${{ github.repository }}
|
|
329
|
+
BRANCH: ${{ github.head_ref }}
|
|
330
|
+
run: |
|
|
331
|
+
set -euo pipefail
|
|
332
|
+
|
|
333
|
+
mapfile -t run_ids < <(
|
|
334
|
+
gh api "repos/$REPO/actions/runs?status=action_required&branch=$BRANCH" \
|
|
335
|
+
--jq '.workflow_runs[].id'
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if [ "${#run_ids[@]}" -eq 0 ]; then
|
|
339
|
+
echo "No runs awaiting approval on branch $BRANCH"
|
|
340
|
+
exit 0
|
|
341
|
+
fi
|
|
342
|
+
|
|
343
|
+
for run_id in "${run_ids[@]}"; do
|
|
344
|
+
echo "Approving run $run_id"
|
|
345
|
+
gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
|
|
346
|
+
done
|
|
347
|
+
|
|
315
348
|
audit-close:
|
|
316
349
|
needs: classify
|
|
317
350
|
if: needs.classify.outputs.route == 'audit-close'
|