@plainconceptsplatform/workflows 0.5.1 → 0.6.1

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.
Files changed (58) hide show
  1. package/dist/action-validation.test.d.ts +1 -0
  2. package/dist/action-validation.test.js +87 -0
  3. package/dist/catalog-installation.d.ts +30 -0
  4. package/dist/catalog-installation.js +422 -0
  5. package/dist/catalog-installation.test.d.ts +1 -0
  6. package/dist/catalog-installation.test.js +485 -0
  7. package/dist/catalog-listing.d.ts +13 -0
  8. package/dist/catalog-listing.js +70 -0
  9. package/dist/catalog-listing.test.d.ts +1 -0
  10. package/dist/catalog-listing.test.js +150 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +218 -0
  13. package/dist/index.test.d.ts +1 -0
  14. package/dist/index.test.js +273 -0
  15. package/dist/repository-inspection.d.ts +23 -0
  16. package/dist/repository-inspection.js +113 -0
  17. package/dist/repository-inspection.test.d.ts +1 -0
  18. package/dist/repository-inspection.test.js +77 -0
  19. package/dist/route-processing.d.ts +6 -0
  20. package/dist/route-processing.js +149 -0
  21. package/dist/route-processing.test.d.ts +1 -0
  22. package/dist/route-processing.test.js +283 -0
  23. package/dist/stack-defaults.d.ts +11 -0
  24. package/dist/stack-defaults.js +104 -0
  25. package/dist/stack-defaults.test.d.ts +1 -0
  26. package/dist/stack-defaults.test.js +266 -0
  27. package/dist/tui.d.ts +25 -0
  28. package/dist/tui.js +287 -0
  29. package/dist/tui.test.d.ts +1 -0
  30. package/dist/tui.test.js +249 -0
  31. package/dist/workflow-catalog.d.ts +25 -0
  32. package/dist/workflow-catalog.js +55 -0
  33. package/dist/workflow-catalog.test.d.ts +1 -0
  34. package/dist/workflow-catalog.test.js +29 -0
  35. package/loops/actions/add-issue-labels/action.yml +2 -2
  36. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  37. package/loops/actions/classify-route/action.yml +7 -0
  38. package/loops/actions/classify-route/classify-route.sh +20 -5
  39. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  40. package/loops/actions/identify-gate-subject/action.yml +3 -1
  41. package/loops/actions/remove-issue-labels/action.yml +4 -2
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  43. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  44. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +300 -16
  45. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  46. package/loops/scripts/merge-changelog.mjs +76 -0
  47. package/loops/templates/agentics/actionlint.yaml +13 -0
  48. package/loops/templates/agentics/agentics-checks.yml +203 -86
  49. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  50. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  51. package/loops/templates/opencode/opencode.ci.json +2 -2
  52. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  53. package/loops/workflows/agent-apply-review.md +5 -1
  54. package/loops/workflows/agent-implement.md +105 -3
  55. package/loops/workflows/agent-merge-gate.md +139 -25
  56. package/loops/workflows/agent-refine.md +26 -2
  57. package/loops/workflows/work-router.yml +227 -51
  58. package/package.json +1 -1
@@ -0,0 +1,23 @@
1
+ export type RepositoryVisibility = "public" | "private";
2
+ export type VisibilitySource = "argument" | "environment" | "github" | "fallback";
3
+ export interface StackHints {
4
+ readonly packageJson: boolean;
5
+ readonly pnpmLockfile: boolean;
6
+ readonly solutionFiles: readonly string[];
7
+ readonly openSpec: boolean;
8
+ }
9
+ export interface RepositoryInspection {
10
+ readonly repositoryPath: string;
11
+ readonly existingAgentWorkflows: readonly string[];
12
+ readonly stackHints: StackHints;
13
+ }
14
+ export interface VisibilityResolution {
15
+ readonly value: RepositoryVisibility;
16
+ readonly source: VisibilitySource;
17
+ }
18
+ export interface CommandRunner {
19
+ readonly run: (command: string, arguments_: readonly string[], cwd: string) => Promise<string>;
20
+ }
21
+ export declare function inspectRepository(repositoryPath: string): Promise<RepositoryInspection>;
22
+ export declare function resolveVisibility(repositoryPath: string, override: RepositoryVisibility | undefined, environment?: NodeJS.ProcessEnv, commandRunner?: CommandRunner): Promise<VisibilityResolution>;
23
+ export declare function parseVisibility(value: string | undefined): RepositoryVisibility | undefined;
@@ -0,0 +1,113 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { access, readdir, stat } from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import { join } from "node:path";
6
+ const execFileAsync = promisify(execFile);
7
+ const defaultCommandRunner = {
8
+ async run(command, arguments_, cwd) {
9
+ const { stdout } = await execFileAsync(command, arguments_, { cwd, windowsHide: true });
10
+ return stdout;
11
+ },
12
+ };
13
+ export async function inspectRepository(repositoryPath) {
14
+ const workflowsPath = join(repositoryPath, ".github", "workflows");
15
+ const workflowEntries = await readDirectoryNames(workflowsPath);
16
+ const solutionFiles = await findSolutionFiles(repositoryPath);
17
+ return {
18
+ repositoryPath,
19
+ existingAgentWorkflows: workflowEntries.filter((entry) => /^agent-.*\.md$/i.test(entry)).sort(),
20
+ stackHints: {
21
+ packageJson: await pathExists(join(repositoryPath, "package.json")),
22
+ pnpmLockfile: await pathExists(join(repositoryPath, "pnpm-lock.yaml")),
23
+ solutionFiles,
24
+ openSpec: await isDirectory(join(repositoryPath, "openspec")),
25
+ },
26
+ };
27
+ }
28
+ export async function resolveVisibility(repositoryPath, override, environment = process.env, commandRunner = defaultCommandRunner) {
29
+ if (override !== undefined)
30
+ return { value: override, source: "argument" };
31
+ const environmentOverride = parseVisibility(environment.PLATFORM_WORKFLOWS_VISIBILITY);
32
+ if (environmentOverride !== undefined)
33
+ return { value: environmentOverride, source: "environment" };
34
+ try {
35
+ const output = await commandRunner.run("gh", ["repo", "view", "--json", "visibility"], repositoryPath);
36
+ const visibility = parseVisibilityFromGitHub(output);
37
+ if (visibility !== undefined)
38
+ return { value: visibility, source: "github" };
39
+ }
40
+ catch {
41
+ // GitHub CLI is optional. Initializing a local repository must still work offline.
42
+ }
43
+ return { value: "private", source: "fallback" };
44
+ }
45
+ export function parseVisibility(value) {
46
+ return value === "public" || value === "private" ? value : undefined;
47
+ }
48
+ async function findSolutionFiles(repositoryPath) {
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
+ }
76
+ }
77
+ async function pathExists(path) {
78
+ try {
79
+ await access(path, constants.F_OK);
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ async function isDirectory(path) {
87
+ try {
88
+ return (await stat(path)).isDirectory();
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ async function readDirectoryNames(path) {
95
+ try {
96
+ return await readdir(path);
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ }
102
+ function parseVisibilityFromGitHub(output) {
103
+ try {
104
+ const parsed = JSON.parse(output);
105
+ if (typeof parsed !== "object" || parsed === null || !("visibility" in parsed))
106
+ return undefined;
107
+ const { visibility } = parsed;
108
+ return typeof visibility === "string" ? parseVisibility(visibility.toLowerCase()) : undefined;
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,77 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { inspectRepository, resolveVisibility } from "./repository-inspection.js";
6
+ import { run } from "./index.js";
7
+ const temporaryDirectories = [];
8
+ afterEach(async () => {
9
+ await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
10
+ });
11
+ describe("repository inspection", () => {
12
+ it("finds agent workflows and supported stack markers", async () => {
13
+ const repositoryPath = await createRepository({
14
+ ".github/workflows/agent-refine.md": "# Refine",
15
+ ".github/workflows/agent-custom.md": "# Custom",
16
+ ".github/workflows/other.md": "# Other",
17
+ "package.json": "{}",
18
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'",
19
+ "apps/api/Contoso.slnx": "<Solution />",
20
+ "openspec/changes/.gitkeep": "",
21
+ });
22
+ await expect(inspectRepository(repositoryPath)).resolves.toMatchObject({
23
+ existingAgentWorkflows: ["agent-custom.md", "agent-refine.md"],
24
+ stackHints: {
25
+ packageJson: true,
26
+ pnpmLockfile: true,
27
+ solutionFiles: [join("apps", "api", "Contoso.slnx")],
28
+ openSpec: true,
29
+ },
30
+ });
31
+ });
32
+ it("uses an explicit visibility before environment or GitHub", async () => {
33
+ const runner = { run: async () => '{"visibility":"public"}' };
34
+ await expect(resolveVisibility("repo", "private", { PLATFORM_WORKFLOWS_VISIBILITY: "public" }, runner)).resolves.toEqual({
35
+ value: "private",
36
+ source: "argument",
37
+ });
38
+ });
39
+ it("falls back to private when GitHub CLI is unavailable", async () => {
40
+ const runner = { run: async () => Promise.reject(new Error("missing gh")) };
41
+ await expect(resolveVisibility("repo", undefined, {}, runner)).resolves.toEqual({ value: "private", source: "fallback" });
42
+ });
43
+ });
44
+ describe("CLI commands", () => {
45
+ it("keeps init available without writing configuration", async () => {
46
+ const repositoryPath = await createRepository({ "package.json": "{}" });
47
+ const output = captureConsole("log");
48
+ await expect(run(["init", "--visibility", "public"], repositoryPath)).resolves.toBe(0);
49
+ expect(output.calls).toHaveLength(1);
50
+ await expect(import("node:fs/promises").then(({ access }) => access(join(repositoryPath, ".github/workflows/shared/repo-config.md")))).rejects.toThrow();
51
+ output.restore();
52
+ });
53
+ it("accepts update as an add alias", async () => {
54
+ const repositoryPath = await createRepository({});
55
+ const error = captureConsole("error");
56
+ await expect(run(["update", "--invalid"], repositoryPath)).resolves.toBe(1);
57
+ expect(error.calls).toEqual(["Unknown option: --invalid"]);
58
+ error.restore();
59
+ });
60
+ });
61
+ function captureConsole(method) {
62
+ const original = console[method];
63
+ const calls = [];
64
+ console[method] = (message) => calls.push(message);
65
+ return { calls, restore: () => { console[method] = original; } };
66
+ }
67
+ async function createRepository(files) {
68
+ const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
69
+ temporaryDirectories.push(repositoryPath);
70
+ await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
71
+ const path = join(repositoryPath, relativePath);
72
+ const { mkdir } = await import("node:fs/promises");
73
+ await mkdir(join(path, ".."), { recursive: true });
74
+ await writeFile(path, content, "utf8");
75
+ }));
76
+ return repositoryPath;
77
+ }
@@ -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,149 @@
1
+ import { routeNames, workflowRoutes } from "./workflow-catalog.js";
2
+ const routeCrons = {
3
+ audit: "17 1 * * 1",
4
+ };
5
+ export function stripRouteFromRouter(yaml, route) {
6
+ let result = yaml;
7
+ const cron = routeCrons[route];
8
+ if (cron !== undefined) {
9
+ result = result.replace(new RegExp(`^ - cron: "${escapeRegex(cron)}"\\n`, "gm"), "");
10
+ }
11
+ result = removeJobBlock(result, `call-${route}`);
12
+ result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
13
+ return result;
14
+ }
15
+ export function stripRouteFromClassifier(shell, route) {
16
+ const constName = route.replace(/-/g, "_").toUpperCase() + "_CRON";
17
+ let result = shell;
18
+ result = result.replace(new RegExp(`^readonly ${constName}="[^"]*"\\n`, "gm"), "");
19
+ const cron = routeCrons[route];
20
+ if (cron !== undefined) {
21
+ result = result.replace(new RegExp(`\\s*"\\$${constName}"\\) route="${escapeRegex(route)}" ;;\\n`, "g"), "");
22
+ }
23
+ result = result.replace(new RegExp(` \\| ${escapeRegex(route)}\\)`, "g"), ")");
24
+ result = result.replace(new RegExp(`^(\\s+)${escapeRegex(route)} \\| `, "gm"), "$1");
25
+ if (!result.includes(`| ${route}`) && !result.includes(`${route} |`)) {
26
+ result = removeAloneDispatchCase(result, route);
27
+ }
28
+ return result;
29
+ }
30
+ function removeAloneDispatchCase(shell, route) {
31
+ const lines = shell.split("\n");
32
+ const pattern = new RegExp(`^(\\s+)${escapeRegex(route)}\\)\\s*$`);
33
+ const result = [];
34
+ let i = 0;
35
+ while (i < lines.length) {
36
+ const match = lines[i].match(pattern);
37
+ if (match) {
38
+ const indent = match[1];
39
+ i++;
40
+ while (i < lines.length) {
41
+ if (new RegExp(`^${escapeRegex(indent)};;\\s*$`).test(lines[i])) {
42
+ i++;
43
+ break;
44
+ }
45
+ i++;
46
+ }
47
+ }
48
+ else {
49
+ result.push(lines[i]);
50
+ i++;
51
+ }
52
+ }
53
+ return result.join("\n");
54
+ }
55
+ export function addRouteExclusion(matrix, route) {
56
+ if (matrix.includes(`excluded route '${route}'`))
57
+ return matrix;
58
+ let result = matrix;
59
+ result = result.replace(new RegExp(` ${escapeRegex(route)} `, "g"), " ");
60
+ 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`;
61
+ result = result.replace(/(\necho\nif \[ "\$FAIL" -eq 0 \])/, `${exclusionBlock}$1`);
62
+ return result;
63
+ }
64
+ export function processRoutes(files, selectedRoutes) {
65
+ const excludedRoutes = routeNames.filter((r) => !selectedRoutes.includes(r));
66
+ if (excludedRoutes.length === 0)
67
+ return files;
68
+ const result = new Map(files);
69
+ for (const route of excludedRoutes) {
70
+ const routerKey = findFileKey(result, "work-router.yml");
71
+ if (routerKey !== undefined) {
72
+ result.set(routerKey, stripRouteFromRouter(result.get(routerKey), route));
73
+ }
74
+ const matrixKey = findFileKey(result, "verify-route-matrix.sh");
75
+ if (matrixKey !== undefined) {
76
+ result.set(matrixKey, addRouteExclusion(result.get(matrixKey), route));
77
+ }
78
+ }
79
+ const matrixKey = findFileKey(result, "verify-route-matrix.sh");
80
+ if (matrixKey !== undefined) {
81
+ result.set(matrixKey, createRouteMatrix(selectedRoutes));
82
+ }
83
+ return result;
84
+ }
85
+ function createRouteMatrix(selectedRoutes) {
86
+ const selected = selectedRoutes.join(" ");
87
+ const excluded = routeNames.filter((route) => !selectedRoutes.includes(route)).join(" ");
88
+ return `#!/usr/bin/env bash
89
+ set -euo pipefail
90
+
91
+ HERE="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
92
+ ROUTER_YML="\${HERE}/../../workflows/work-router.yml"
93
+ CLASSIFIER="\${HERE}/../classify-route/classify-route.sh"
94
+
95
+ bash -n "$CLASSIFIER"
96
+
97
+ for route in ${selected}; do
98
+ grep -q "route == '$route'" "$ROUTER_YML" || {
99
+ echo "FAIL: selected route '$route' has no router job" >&2
100
+ exit 1
101
+ }
102
+ done
103
+
104
+ for route in ${excluded}; do
105
+ if grep -q "route == '$route'" "$ROUTER_YML"; then
106
+ echo "FAIL: excluded route '$route' remains in router" >&2
107
+ exit 1
108
+ fi
109
+ done
110
+
111
+ echo "Route matrix: selected routes valid"
112
+ `;
113
+ }
114
+ export function excludedWorkerFiles(selectedRoutes) {
115
+ return new Set(workflowRoutes
116
+ .filter((route) => !selectedRoutes.includes(route.name))
117
+ .map((route) => route.worker));
118
+ }
119
+ function removeJobBlock(yaml, jobName) {
120
+ const lines = yaml.split("\n");
121
+ const startPattern = new RegExp(`^ ${escapeRegex(jobName)}:`);
122
+ const result = [];
123
+ let skipping = false;
124
+ for (const line of lines) {
125
+ if (skipping) {
126
+ if (/^ \S/.test(line) || /^[^\s]/.test(line)) {
127
+ skipping = false;
128
+ result.push(line);
129
+ }
130
+ }
131
+ else if (startPattern.test(line)) {
132
+ skipping = true;
133
+ }
134
+ else {
135
+ result.push(line);
136
+ }
137
+ }
138
+ return result.join("\n");
139
+ }
140
+ function findFileKey(files, endsWith) {
141
+ for (const key of files.keys()) {
142
+ if (key.endsWith(endsWith))
143
+ return key;
144
+ }
145
+ return undefined;
146
+ }
147
+ function escapeRegex(str) {
148
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,283 @@
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: "*/5 * * * *"
13
+ - cron: "0 */2 * * *"
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
+ - triage
25
+ - apply-review
26
+ - merge-gate
27
+ - audit
28
+ - audit-close
29
+ - cleanup-artifacts
30
+ - reconcile-bot-pr-runs
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-triage:
50
+ needs: [classify, authorize]
51
+ if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
52
+ uses: ./.github/workflows/agent-triage.lock.yml
53
+ secrets:
54
+ OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
55
+
56
+ call-audit:
57
+ needs: classify
58
+ if: needs.classify.outputs.route == 'audit'
59
+ uses: ./.github/workflows/agent-audit.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 RECONCILE_BOT_PR_RUNS_CRON="*/5 * * * *"
75
+ readonly STALE_RECOVERY_CRON="0 */2 * * *"
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
+ "\$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
88
+ "\$STALE_RECOVERY_CRON") route="stale-recovery" ;;
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)
97
+ route="\${OPERATION}"
98
+ ;;
99
+ apply-review)
100
+ route="apply-review"
101
+ ;;
102
+ merge-gate)
103
+ route="merge-gate"
104
+ ;;
105
+ audit)
106
+ route="\${OPERATION}"
107
+ trigger_kind="\${INPUT_TRIGGER_KIND:-manual}"
108
+ ;;
109
+ audit-close | cleanup-artifacts | reconcile-bot-pr-runs | 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 triage apply-review merge-gate audit bot-approve \\
134
+ audit-close cleanup-artifacts reconcile-bot-pr-runs 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 audit cron entry", () => {
163
+ const result = stripRouteFromRouter(ROUTER_YAML, "audit");
164
+ expect(result).not.toContain('cron: "17 1 * * 1"');
165
+ expect(result).toContain('cron: "43 3 * * *"');
166
+ });
167
+ it("removes the call-audit job block", () => {
168
+ const result = stripRouteFromRouter(ROUTER_YAML, "audit");
169
+ expect(result).not.toContain("call-audit");
170
+ expect(result).toContain("call-refine");
171
+ });
172
+ it("preserves the audit-close job when removing audit", () => {
173
+ const result = stripRouteFromRouter(ROUTER_YAML, "audit");
174
+ expect(result).toContain("audit-close");
175
+ });
176
+ it("does not modify the yaml when stripping a route that has no cron", () => {
177
+ const yamlWithoutCron = ROUTER_YAML.replace(/ - cron: "17 1 \* \* 1"\n/, "");
178
+ const result = stripRouteFromRouter(yamlWithoutCron, "audit");
179
+ expect(result).not.toContain("call-audit");
180
+ });
181
+ });
182
+ describe("stripRouteFromClassifier", () => {
183
+ it("removes the AUDIT_CRON constant", () => {
184
+ const result = stripRouteFromClassifier(CLASSIFIER_SH, "audit");
185
+ expect(result).not.toContain('readonly AUDIT_CRON');
186
+ expect(result).toContain('readonly AUDIT_CLOSE_CRON');
187
+ });
188
+ it("removes the audit schedule case", () => {
189
+ const result = stripRouteFromClassifier(CLASSIFIER_SH, "audit");
190
+ expect(result).not.toContain('"$AUDIT_CRON") route="audit"');
191
+ expect(result).toContain('"$AUDIT_CLOSE_CRON") route="audit-close"');
192
+ });
193
+ });
194
+ describe("addRouteExclusion", () => {
195
+ it("adds an exclusion assertion for the route", () => {
196
+ const result = addRouteExclusion(MATRIX_SH, "audit");
197
+ expect(result).toContain("excluded route 'audit'");
198
+ expect(result).toContain("audit correctly excluded from work-router.yml");
199
+ });
200
+ it("does not add the exclusion twice", () => {
201
+ const once = addRouteExclusion(MATRIX_SH, "audit");
202
+ const twice = addRouteExclusion(once, "audit");
203
+ const matchCount = (twice.match(/excluded route 'audit'/g) ?? []).length;
204
+ expect(matchCount).toBe(1);
205
+ });
206
+ });
207
+ describe("processRoutes", () => {
208
+ it("returns the same map when all routes are selected", () => {
209
+ const files = new Map([
210
+ ["work-router.yml", ROUTER_YAML],
211
+ ["classify-route.sh", CLASSIFIER_SH],
212
+ ["verify-route-matrix.sh", MATRIX_SH],
213
+ ]);
214
+ const result = processRoutes(files, [...routeNames]);
215
+ expect(result).toBe(files);
216
+ });
217
+ it("strips every worker route when no routes are selected", () => {
218
+ const files = new Map([
219
+ [".github/workflows/work-router.yml", ROUTER_YAML],
220
+ [".github/actions/classify-route/classify-route.sh", CLASSIFIER_SH],
221
+ [".github/actions/verify-route-matrix/verify-route-matrix.sh", MATRIX_SH],
222
+ ]);
223
+ const result = processRoutes(files, []);
224
+ const router = result.get(".github/workflows/work-router.yml");
225
+ expect(router).not.toContain("call-audit");
226
+ expect(router).not.toContain("- refine");
227
+ const classifier = result.get(".github/actions/classify-route/classify-route.sh");
228
+ expect(classifier).toBe(CLASSIFIER_SH);
229
+ const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
230
+ expect(matrix).toContain("Route matrix: selected routes valid");
231
+ expect(matrix).toContain("for route in refine implement triage apply-review merge-gate audit");
232
+ });
233
+ it("strips audit from all three files when unselected", () => {
234
+ const files = new Map([
235
+ ["work-router.yml", ROUTER_YAML],
236
+ ["classify-route.sh", CLASSIFIER_SH],
237
+ ["verify-route-matrix.sh", MATRIX_SH],
238
+ ]);
239
+ const selectedRoutes = routeNames.filter((r) => r !== "audit");
240
+ const result = processRoutes(files, selectedRoutes);
241
+ const router = result.get("work-router.yml");
242
+ expect(router).not.toContain("call-audit");
243
+ expect(router).not.toContain('cron: "17 1 * * 1"');
244
+ const classifier = result.get("classify-route.sh");
245
+ expect(classifier).toBe(CLASSIFIER_SH);
246
+ });
247
+ it("processes multiple excluded routes at once", () => {
248
+ const files = new Map([
249
+ ["work-router.yml", ROUTER_YAML],
250
+ ["classify-route.sh", CLASSIFIER_SH],
251
+ ["verify-route-matrix.sh", MATRIX_SH],
252
+ ]);
253
+ const selectedRoutes = ["refine", "implement"];
254
+ const result = processRoutes(files, selectedRoutes);
255
+ const router = result.get("work-router.yml");
256
+ expect(router).toContain("call-refine");
257
+ expect(router).toContain("call-implement");
258
+ expect(router).not.toContain("call-audit");
259
+ });
260
+ });
261
+ describe("excludedWorkerFiles", () => {
262
+ it("returns worker files for unselected routes", () => {
263
+ const selectedRoutes = ["refine", "implement"];
264
+ const excluded = excludedWorkerFiles(selectedRoutes);
265
+ expect(excluded.has("agent-refine.md")).toBe(false);
266
+ expect(excluded.has("agent-implement.md")).toBe(false);
267
+ expect(excluded.has("agent-audit.md")).toBe(true);
268
+ });
269
+ it("returns an empty set when all routes are selected", () => {
270
+ const excluded = excludedWorkerFiles([...routeNames]);
271
+ expect(excluded.size).toBe(0);
272
+ });
273
+ it("returns all worker files when no routes are selected", () => {
274
+ const excluded = excludedWorkerFiles([]);
275
+ expect(excluded.size).toBe(6);
276
+ expect(excluded.has("agent-refine.md")).toBe(true);
277
+ expect(excluded.has("agent-implement.md")).toBe(true);
278
+ expect(excluded.has("agent-triage.md")).toBe(true);
279
+ expect(excluded.has("agent-apply-review.md")).toBe(true);
280
+ expect(excluded.has("agent-merge-gate.md")).toBe(true);
281
+ expect(excluded.has("agent-audit.md")).toBe(true);
282
+ });
283
+ });
@@ -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;