@plainconceptsplatform/workflows 0.2.0 → 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.
@@ -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 { access, copyFile, mkdir, readFile, readdir } from "node:fs/promises";
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 { catalogTemplates, mandatoryFiles, templateNames } from "./workflow-catalog.js";
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 files = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
24
- const deduplicated = files.filter((file, index) => files.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
25
- const managedFiles = deduplicated.filter((file) => file.managed);
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
- await Promise.all(deduplicated.map(async (file) => {
33
- const destination = join(repositoryPath, file.target);
34
- if (!file.managed && await exists(destination))
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 copyFile(file.source, destination);
59
+ await writeFile(destination, content, "utf8");
38
60
  }));
39
- return { installed: deduplicated.map((file) => file.target), conflicts };
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;