@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.
@@ -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
- if (!await exists(hookPath))
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 entries = await readdir(repositoryPath, { recursive: true, withFileTypes: true });
50
- return entries
51
- .filter((entry) => entry.isFile() && entry.name.endsWith(".slnx"))
52
- .map((entry) => entry.parentPath === undefined ? entry.name : join(entry.parentPath, entry.name))
53
- .sort();
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 {
@@ -24,7 +24,7 @@ describe("repository inspection", () => {
24
24
  stackHints: {
25
25
  packageJson: true,
26
26
  pnpmLockfile: true,
27
- solutionFiles: [join(repositoryPath, "apps", "api", "Numa.slnx")],
27
+ solutionFiles: [join("apps", "api", "Numa.slnx")],
28
28
  openSpec: true,
29
29
  },
30
30
  });
@@ -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).not.toContain("readonly PROPOSE_CRON");
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("excluded route 'propose'");
266
- expect(matrix).toContain("excluded route 'refine'");
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).not.toContain("readonly PROPOSE_CRON");
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("excluded route 'propose'");
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).not.toContain("readonly AUDIT_CRON");
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([
@@ -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
- - name: Restore .NET dependencies
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/cli@latest
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("does not inject when verifyCommands is pnpm verify (the default)", () => {
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).not.toContain("VERIFY_COMMANDS");
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 dotnet restore steps when .slnx is found", () => {
116
+ it("adds NuGet cache and restores detected solution when .slnx is found", () => {
117
117
  const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
118
- solutionFiles: ["app.slnx"],
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/cli");
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
+ }
@@ -13,7 +13,8 @@
13
13
  "permission": {
14
14
  "read": "allow",
15
15
  "external_directory": {
16
- "/tmp/**": "allow"
16
+ "/tmp/**": "allow",
17
+ "/home/runner/work/_temp/gh-aw/**": "allow"
17
18
  }
18
19
  },
19
20
  "lsp": {
@@ -8,6 +8,8 @@ network:
8
8
  - forge.plainconcepts.com
9
9
  - node
10
10
  - github
11
+ - dotnet
12
+ - fonts
11
13
 
12
14
  safe-outputs:
13
15
  threat-detection: false
@@ -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
- if [ "$ACTOR" = "github-actions[bot]" ]; then
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",