@plainconceptsplatform/workflows 0.3.2 → 0.4.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.js +5 -2
- package/dist/catalog-installation.test.js +14 -1
- package/dist/repository-inspection.js +27 -5
- package/dist/repository-inspection.test.js +1 -1
- package/dist/route-processing.js +33 -4
- package/dist/route-processing.test.js +7 -9
- package/dist/stack-defaults.js +4 -5
- package/dist/stack-defaults.test.js +7 -7
- package/loops/scripts/compile-agent-workflows.mjs +20 -6
- package/loops/templates/opencode/opencode.ci.json +2 -1
- package/loops/workflows/shared/platform-defaults.md +2 -0
- package/loops/workflows/work-router.yml +5 -2
- package/package.json +1 -1
|
@@ -133,10 +133,13 @@ export function isTemplateName(value) {
|
|
|
133
133
|
}
|
|
134
134
|
export async function ensurePreCommitHook(repositoryPath) {
|
|
135
135
|
const hookPath = join(repositoryPath, ".husky", "pre-commit");
|
|
136
|
-
|
|
136
|
+
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
137
|
+
if (!await exists(hookPath)) {
|
|
138
|
+
await mkdir(dirname(hookPath), { recursive: true });
|
|
139
|
+
await writeFile(hookPath, `${compileLine}\n`, "utf8");
|
|
137
140
|
return;
|
|
141
|
+
}
|
|
138
142
|
const content = await readFile(hookPath, "utf8");
|
|
139
|
-
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
140
143
|
if (content.includes("compile-agent-workflows"))
|
|
141
144
|
return;
|
|
142
145
|
const newContent = content.endsWith("\n") || content === ""
|
|
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { catalogSourcePath, installCatalog, installMandatoryFiles, installTemplate } from "./catalog-installation.js";
|
|
5
|
+
import { catalogSourcePath, ensurePreCommitHook, installCatalog, installMandatoryFiles, installTemplate } from "./catalog-installation.js";
|
|
6
6
|
const temporaryDirectories = [];
|
|
7
7
|
afterEach(async () => {
|
|
8
8
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
@@ -243,6 +243,19 @@ describe("catalog installation", () => {
|
|
|
243
243
|
const compileEntries = result.installed.filter((path) => path === "scripts/compile-agent-workflows.mjs");
|
|
244
244
|
expect(compileEntries).toHaveLength(1);
|
|
245
245
|
});
|
|
246
|
+
it("creates a Husky pre-commit hook when the consumer has none", async () => {
|
|
247
|
+
const repositoryPath = await createDirectory({});
|
|
248
|
+
await ensurePreCommitHook(repositoryPath);
|
|
249
|
+
await expect(readFile(join(repositoryPath, ".husky", "pre-commit"), "utf8"))
|
|
250
|
+
.resolves.toBe("node scripts/compile-agent-workflows.mjs\n");
|
|
251
|
+
});
|
|
252
|
+
it("keeps existing pre-commit commands and appends the compiler once", async () => {
|
|
253
|
+
const repositoryPath = await createDirectory({ ".husky/pre-commit": "pnpm lint\n" });
|
|
254
|
+
await ensurePreCommitHook(repositoryPath);
|
|
255
|
+
await ensurePreCommitHook(repositoryPath);
|
|
256
|
+
await expect(readFile(join(repositoryPath, ".husky", "pre-commit"), "utf8"))
|
|
257
|
+
.resolves.toBe("pnpm lint\nnode scripts/compile-agent-workflows.mjs\n");
|
|
258
|
+
});
|
|
246
259
|
it("installs the opencode.ci.json template to the repository root", async () => {
|
|
247
260
|
const sourcePath = await createDirectory({
|
|
248
261
|
"templates/opencode/opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }\n",
|
|
@@ -46,11 +46,33 @@ export function parseVisibility(value) {
|
|
|
46
46
|
return value === "public" || value === "private" ? value : undefined;
|
|
47
47
|
}
|
|
48
48
|
async function findSolutionFiles(repositoryPath) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
const results = [];
|
|
50
|
+
await scanForSlnx(repositoryPath, "", results, 0);
|
|
51
|
+
return results.sort();
|
|
52
|
+
}
|
|
53
|
+
const MAX_DEPTH = 5;
|
|
54
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "out", "build", ".turbo", ".cache"]);
|
|
55
|
+
async function scanForSlnx(root, relativePath, results, depth) {
|
|
56
|
+
if (depth >= MAX_DEPTH)
|
|
57
|
+
return;
|
|
58
|
+
const currentPath = join(root, relativePath);
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(currentPath, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (SKIP_DIRS.has(entry.name))
|
|
69
|
+
continue;
|
|
70
|
+
await scanForSlnx(root, join(relativePath, entry.name), results, depth + 1);
|
|
71
|
+
}
|
|
72
|
+
else if (entry.isFile() && entry.name.endsWith(".slnx")) {
|
|
73
|
+
results.push(relativePath === "" ? entry.name : join(relativePath, entry.name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
54
76
|
}
|
|
55
77
|
async function pathExists(path) {
|
|
56
78
|
try {
|
package/dist/route-processing.js
CHANGED
|
@@ -72,17 +72,46 @@ export function processRoutes(files, selectedRoutes) {
|
|
|
72
72
|
if (routerKey !== undefined) {
|
|
73
73
|
result.set(routerKey, stripRouteFromRouter(result.get(routerKey), route));
|
|
74
74
|
}
|
|
75
|
-
const classifierKey = findFileKey(result, "classify-route.sh");
|
|
76
|
-
if (classifierKey !== undefined) {
|
|
77
|
-
result.set(classifierKey, stripRouteFromClassifier(result.get(classifierKey), route));
|
|
78
|
-
}
|
|
79
75
|
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
80
76
|
if (matrixKey !== undefined) {
|
|
81
77
|
result.set(matrixKey, addRouteExclusion(result.get(matrixKey), route));
|
|
82
78
|
}
|
|
83
79
|
}
|
|
80
|
+
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
81
|
+
if (matrixKey !== undefined) {
|
|
82
|
+
result.set(matrixKey, createRouteMatrix(selectedRoutes));
|
|
83
|
+
}
|
|
84
84
|
return result;
|
|
85
85
|
}
|
|
86
|
+
function createRouteMatrix(selectedRoutes) {
|
|
87
|
+
const selected = selectedRoutes.join(" ");
|
|
88
|
+
const excluded = routeNames.filter((route) => !selectedRoutes.includes(route)).join(" ");
|
|
89
|
+
return `#!/usr/bin/env bash
|
|
90
|
+
set -euo pipefail
|
|
91
|
+
|
|
92
|
+
HERE="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
93
|
+
ROUTER_YML="\${HERE}/../../workflows/work-router.yml"
|
|
94
|
+
CLASSIFIER="\${HERE}/../classify-route/classify-route.sh"
|
|
95
|
+
|
|
96
|
+
bash -n "$CLASSIFIER"
|
|
97
|
+
|
|
98
|
+
for route in ${selected}; do
|
|
99
|
+
grep -q "route == '$route'" "$ROUTER_YML" || {
|
|
100
|
+
echo "FAIL: selected route '$route' has no router job" >&2
|
|
101
|
+
exit 1
|
|
102
|
+
}
|
|
103
|
+
done
|
|
104
|
+
|
|
105
|
+
for route in ${excluded}; do
|
|
106
|
+
if grep -q "route == '$route'" "$ROUTER_YML"; then
|
|
107
|
+
echo "FAIL: excluded route '$route' remains in router" >&2
|
|
108
|
+
exit 1
|
|
109
|
+
fi
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
echo "Route matrix: selected routes valid"
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
86
115
|
export function excludedWorkerFiles(selectedRoutes) {
|
|
87
116
|
return new Set(workflowRoutes
|
|
88
117
|
.filter((route) => !selectedRoutes.includes(route.name))
|
|
@@ -259,11 +259,10 @@ describe("processRoutes", () => {
|
|
|
259
259
|
expect(router).not.toContain("- propose");
|
|
260
260
|
expect(router).not.toContain("- refine");
|
|
261
261
|
const classifier = result.get(".github/actions/classify-route/classify-route.sh");
|
|
262
|
-
expect(classifier).
|
|
263
|
-
expect(classifier).not.toContain("readonly AUDIT_CRON");
|
|
262
|
+
expect(classifier).toBe(CLASSIFIER_SH);
|
|
264
263
|
const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
|
|
265
|
-
expect(matrix).toContain("
|
|
266
|
-
expect(matrix).toContain("
|
|
264
|
+
expect(matrix).toContain("Route matrix: selected routes valid");
|
|
265
|
+
expect(matrix).toContain("for route in refine implement direct apply-review merge-gate audit propose");
|
|
267
266
|
});
|
|
268
267
|
it("strips propose from all three files when unselected", () => {
|
|
269
268
|
const files = new Map([
|
|
@@ -278,10 +277,10 @@ describe("processRoutes", () => {
|
|
|
278
277
|
expect(router).not.toContain('cron: "29 7 * * *"');
|
|
279
278
|
expect(router).not.toMatch(/^\s+- propose$/m);
|
|
280
279
|
const classifier = result.get(".github/actions/classify-route/classify-route.sh");
|
|
281
|
-
expect(classifier).
|
|
282
|
-
expect(classifier).not.toContain('"$PROPOSE_CRON")');
|
|
280
|
+
expect(classifier).toBe(CLASSIFIER_SH);
|
|
283
281
|
const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
|
|
284
|
-
expect(matrix).toContain("
|
|
282
|
+
expect(matrix).toContain("for route in refine implement direct apply-review merge-gate audit");
|
|
283
|
+
expect(matrix).toContain("for route in propose");
|
|
285
284
|
});
|
|
286
285
|
it("strips audit from all three files when unselected", () => {
|
|
287
286
|
const files = new Map([
|
|
@@ -295,8 +294,7 @@ describe("processRoutes", () => {
|
|
|
295
294
|
expect(router).not.toContain("call-audit");
|
|
296
295
|
expect(router).not.toContain('cron: "17 1 * * 1"');
|
|
297
296
|
const classifier = result.get("classify-route.sh");
|
|
298
|
-
expect(classifier).
|
|
299
|
-
expect(classifier).not.toContain('"$AUDIT_CRON")');
|
|
297
|
+
expect(classifier).toBe(CLASSIFIER_SH);
|
|
300
298
|
});
|
|
301
299
|
it("processes multiple excluded routes at once", () => {
|
|
302
300
|
const files = new Map([
|
package/dist/stack-defaults.js
CHANGED
|
@@ -20,8 +20,6 @@ export function generateStackDefaults(inspection) {
|
|
|
20
20
|
}
|
|
21
21
|
export function injectStackEnv(content, defaults) {
|
|
22
22
|
let result = content;
|
|
23
|
-
if (defaults.verifyCommands === "pnpm verify")
|
|
24
|
-
return result;
|
|
25
23
|
if (result.includes("VERIFY_COMMANDS:")) {
|
|
26
24
|
result = result.replace(/ VERIFY_COMMANDS: ".*"/, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
|
|
27
25
|
}
|
|
@@ -33,6 +31,7 @@ export function injectStackEnv(content, defaults) {
|
|
|
33
31
|
export function generateOpencodeCi(baseContent, inspection) {
|
|
34
32
|
let result = baseContent;
|
|
35
33
|
if (inspection.stackHints.solutionFiles.length > 0) {
|
|
34
|
+
const solutionPath = inspection.stackHints.solutionFiles[0];
|
|
36
35
|
const nugetSteps = ` - name: Cache NuGet packages
|
|
37
36
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
38
37
|
with:
|
|
@@ -40,8 +39,8 @@ export function generateOpencodeCi(baseContent, inspection) {
|
|
|
40
39
|
key: nuget-\${{ runner.os }}-\${{ hashFiles('**/*.slnx', '**/Directory.Packages.props') }}
|
|
41
40
|
restore-keys: nuget-\${{ runner.os }}-
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
run: dotnet restore
|
|
42
|
+
- name: Restore .NET dependencies
|
|
43
|
+
run: dotnet restore ${solutionPath}
|
|
45
44
|
`;
|
|
46
45
|
result = insertBeforeMarker(result, nugetSteps, " - name: Install workspace dependencies");
|
|
47
46
|
}
|
|
@@ -49,7 +48,7 @@ export function generateOpencodeCi(baseContent, inspection) {
|
|
|
49
48
|
const openspecStep = ` - name: Install OpenSpec CLI
|
|
50
49
|
run: |
|
|
51
50
|
set -euo pipefail
|
|
52
|
-
npm install -g @openspec
|
|
51
|
+
npm install -g "@fission-ai/openspec@1.8.0"
|
|
53
52
|
openspec --version
|
|
54
53
|
`;
|
|
55
54
|
result = insertBeforeMarker(result, openspecStep, " - name: Install workspace dependencies");
|
|
@@ -76,7 +76,7 @@ description: test
|
|
|
76
76
|
const result = injectStackEnv(content, defaults);
|
|
77
77
|
expect(result).toContain('VERIFY_COMMANDS: "dotnet restore && dotnet build -c Release --no-restore && dotnet test"');
|
|
78
78
|
});
|
|
79
|
-
it("
|
|
79
|
+
it("injects pnpm verification when no .slnx is present", () => {
|
|
80
80
|
const content = `---
|
|
81
81
|
env:
|
|
82
82
|
REPO_RULES: "some rules"
|
|
@@ -85,7 +85,7 @@ env:
|
|
|
85
85
|
pnpmLockfile: true,
|
|
86
86
|
}));
|
|
87
87
|
const result = injectStackEnv(content, defaults);
|
|
88
|
-
expect(result).
|
|
88
|
+
expect(result).toContain('VERIFY_COMMANDS: "pnpm verify"');
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
const OPENCODE_CI_MD = `---
|
|
@@ -113,20 +113,20 @@ pre-agent-steps:
|
|
|
113
113
|
jq -e . "$FRAGMENT" > /dev/null
|
|
114
114
|
---`;
|
|
115
115
|
describe("generateOpencodeCi", () => {
|
|
116
|
-
it("adds NuGet cache and
|
|
116
|
+
it("adds NuGet cache and restores detected solution when .slnx is found", () => {
|
|
117
117
|
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
118
|
-
solutionFiles: ["
|
|
118
|
+
solutionFiles: ["apps/api/Numa.slnx"],
|
|
119
119
|
}));
|
|
120
120
|
expect(result).toContain("Cache NuGet packages");
|
|
121
121
|
expect(result).toContain("Restore .NET dependencies");
|
|
122
|
-
expect(result).toContain("dotnet restore");
|
|
122
|
+
expect(result).toContain("dotnet restore apps/api/Numa.slnx");
|
|
123
123
|
});
|
|
124
|
-
it("adds OpenSpec CLI install step when openspec/ directory exists", () => {
|
|
124
|
+
it("adds the pinned OpenSpec CLI install step when openspec/ directory exists", () => {
|
|
125
125
|
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
126
126
|
openSpec: true,
|
|
127
127
|
}));
|
|
128
128
|
expect(result).toContain("Install OpenSpec CLI");
|
|
129
|
-
expect(result).toContain("@openspec
|
|
129
|
+
expect(result).toContain("@fission-ai/openspec@1.8.0");
|
|
130
130
|
});
|
|
131
131
|
it("adds both NuGet and OpenSpec steps when both are detected", () => {
|
|
132
132
|
const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
4
5
|
|
|
5
6
|
const workflowDirectory = existsSync("loops/workflows") ? "loops/workflows" : ".github/workflows";
|
|
6
7
|
|
|
@@ -21,9 +22,22 @@ const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir"
|
|
|
21
22
|
shell: false,
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
if (compile.error?.code === "ENOENT" || compile.status === null) {
|
|
25
|
+
if (compile.error?.code === "ENOENT" || compile.status === null) {
|
|
25
26
|
process.stderr.write("Could not run `gh aw compile`. Install githubnext/gh-aw first.\n");
|
|
26
27
|
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
process.exit(compile.status ?? 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (compile.status !== 0) process.exit(compile.status ?? 1);
|
|
31
|
+
|
|
32
|
+
for (const file of readdirSync(workflowDirectory)) {
|
|
33
|
+
if (!file.endsWith(".lock.yml")) continue;
|
|
34
|
+
|
|
35
|
+
const path = join(workflowDirectory, file);
|
|
36
|
+
const content = readFileSync(path, "utf8");
|
|
37
|
+
const patched = content
|
|
38
|
+
.replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --log-level ERROR")
|
|
39
|
+
.replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --log-level ERROR")
|
|
40
|
+
.replaceAll("--log-level DEBUG", "--log-level ERROR");
|
|
41
|
+
|
|
42
|
+
if (patched !== content) writeFileSync(path, patched);
|
|
43
|
+
}
|
|
@@ -39,7 +39,7 @@ on:
|
|
|
39
39
|
pull_request_target:
|
|
40
40
|
types: [opened, synchronize, reopened]
|
|
41
41
|
workflow_run:
|
|
42
|
-
workflows: ["App: CI"]
|
|
42
|
+
workflows: ["App: CI", "CI"]
|
|
43
43
|
types: [completed]
|
|
44
44
|
branches:
|
|
45
45
|
- "fix/*"
|
|
@@ -142,7 +142,10 @@ jobs:
|
|
|
142
142
|
;;
|
|
143
143
|
esac
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
# Platform bot owns Safe Outputs. Its label changes must be able to
|
|
146
|
+
# start the follow-on worker, even though GitHub does not report it
|
|
147
|
+
# as a repository collaborator.
|
|
148
|
+
if [ "$ACTOR" = "github-actions[bot]" ] || [ "$ACTOR" = "platform-devbox[bot]" ]; then
|
|
146
149
|
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
147
150
|
exit 0
|
|
148
151
|
fi
|