@plainconceptsplatform/workflows 0.6.1 → 0.16.2

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/README.md +107 -88
  2. package/dist/catalog-installation.d.ts +39 -2
  3. package/dist/catalog-installation.js +172 -109
  4. package/dist/index.js +128 -73
  5. package/dist/package-baseline.d.ts +25 -0
  6. package/dist/package-baseline.js +138 -0
  7. package/dist/route-processing.d.ts +0 -2
  8. package/dist/route-processing.js +22 -91
  9. package/dist/stack-defaults.js +18 -12
  10. package/dist/tui.js +27 -43
  11. package/dist/worker-env.d.ts +46 -0
  12. package/dist/worker-env.js +179 -0
  13. package/dist/workflow-catalog.d.ts +4 -2
  14. package/dist/workflow-catalog.js +5 -3
  15. package/loops/actions/add-issue-labels/action.yml +20 -0
  16. package/loops/actions/audit-close/action.yml +180 -128
  17. package/loops/actions/classify-route/classify-route.sh +8 -2
  18. package/loops/actions/housekeeping/action.yml +251 -0
  19. package/loops/actions/merge-agent-pr/action.yml +13 -0
  20. package/loops/actions/report-workflow-errors/action.yml +414 -0
  21. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
  22. package/loops/actions/validate-triage-output/action.yml +1 -1
  23. package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
  24. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
  25. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +911 -170
  26. package/loops/templates/agentics/agentics-error-report.yml +98 -0
  27. package/loops/templates/opencode/opencode.ci.json +1 -1
  28. package/loops/workflows/agent-apply-review.md +438 -469
  29. package/loops/workflows/agent-audit.md +200 -213
  30. package/loops/workflows/agent-implement.md +591 -640
  31. package/loops/workflows/agent-merge-gate.md +826 -844
  32. package/loops/workflows/agent-refine.md +590 -633
  33. package/loops/workflows/agent-release.md +244 -258
  34. package/loops/workflows/agent-triage.md +476 -447
  35. package/loops/workflows/authorize-bot-work.yml +26 -6
  36. package/loops/workflows/shared/platform-defaults.md +18 -1
  37. package/loops/workflows/work-router.yml +1185 -1038
  38. package/package.json +9 -8
  39. package/dist/action-validation.test.d.ts +0 -1
  40. package/dist/action-validation.test.js +0 -87
  41. package/dist/catalog-installation.test.d.ts +0 -1
  42. package/dist/catalog-installation.test.js +0 -485
  43. package/dist/catalog-listing.test.d.ts +0 -1
  44. package/dist/catalog-listing.test.js +0 -150
  45. package/dist/index.test.d.ts +0 -1
  46. package/dist/index.test.js +0 -273
  47. package/dist/repository-inspection.test.d.ts +0 -1
  48. package/dist/repository-inspection.test.js +0 -77
  49. package/dist/route-processing.test.d.ts +0 -1
  50. package/dist/route-processing.test.js +0 -283
  51. package/dist/stack-defaults.test.d.ts +0 -1
  52. package/dist/stack-defaults.test.js +0 -266
  53. package/dist/tui.test.d.ts +0 -1
  54. package/dist/tui.test.js +0 -249
  55. package/dist/workflow-catalog.test.d.ts +0 -1
  56. package/dist/workflow-catalog.test.js +0 -29
  57. package/loops/actions/stale-recovery/action.yml +0 -288
  58. package/loops/actions/update-changelog/action.yml +0 -113
package/dist/index.js CHANGED
@@ -2,58 +2,68 @@
2
2
  import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
3
3
  import { installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
4
4
  import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
5
+ import { packageVersion } from "./package-baseline.js";
5
6
  import { routeNames, templateNames } from "./workflow-catalog.js";
6
7
  import { runInteractive } from "./tui.js";
7
8
  import { resolve } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
- const HELP_TEXT = `Workflows CLI — install and manage Plain Concepts Platform agentic workflows.
10
-
11
- Run with no arguments to launch the interactive TUI, the primary way to select and install
12
- workflows and templates:
13
-
14
- npx @plainconceptsplatform/workflows
15
-
16
- Advanced (non-interactive) commands:
17
-
18
- Usage: workflows <command> [options]
19
-
20
- Commands:
21
- (default) Launch the interactive TUI for selecting and installing items.
22
- init Inspect the repository and report its stack and visibility.
23
- add [routes] [--template <name>] [--force] Install route workers, a template, or mandatory files.
24
- remove <routes> [--force] Uninstall route workers and regenerate the router without them.
25
- update Alias for add.
26
- status Print repository inspection as JSON.
27
- list List all available workflows and templates with install status.
28
- search <query> Filter workflows and templates by name or description.
29
-
30
- Route names (positional arguments to add):
31
- refine, implement, triage, apply-review, merge-gate, audit
32
-
33
- add Mandatory files only (opencode.ci.json, compile script,
34
- shared imports, actions, router, classifier, route matrix).
35
- add implement refine direct Installs those route workers plus mandatory files.
36
- add --template agentics-checks Installs the named template only (no mandatory files).
37
- add refine --template agentics-checks Installs routes + mandatory + the named template.
38
- add refine implement --force Forces re-install of routes plus mandatory, overwriting.
39
- remove propose Uninstalls the propose worker and drops it from the router.
40
-
41
- add and remove keep the router consistent with what is installed: add unions the requested
42
- routes with the routes already present, and remove drops the requested routes from that set.
43
- Both regenerate the router, classifier, and route matrix from the resulting set. Changing the
44
- route set rewrites the package-owned router, so pass --force to overwrite it.
45
-
46
- Options:
47
- --visibility public|private Override repository visibility (init only).
48
- --template <name> Install a standalone template alongside or instead of routes.
49
- Templates: agentics-checks, agentics-maintenance,
50
- app-ci-dotnet-next, app-ci-node-monorepo,
51
- bug-report, feature-request, github-release,
52
- opencode.ci.json.
53
- --force Overwrite managed files that differ from the package source.
54
- -h, --help Show this help text.
55
-
56
- Installed workflows are marked [x] when the corresponding .github/workflows/agent-*.md
10
+ const HELP_TEXT = `Workflows CLI — install and update Plain Concepts Platform agentic workflows.
11
+
12
+ Run with no arguments to launch the interactive TUI, the primary way to select and install
13
+ workflows and templates:
14
+
15
+ npx @plainconceptsplatform/workflows
16
+
17
+ Advanced (non-interactive) commands:
18
+
19
+ Usage: workflows <command> [options]
20
+
21
+ Commands:
22
+ (default) Launch the interactive TUI for selecting and installing items.
23
+ init Inspect the repository and report its stack and visibility.
24
+ add [routes] [--template <name>] Install route workers and refresh every installed package file.
25
+ update Alias for add: refresh what is installed to this package version.
26
+ remove <routes> Uninstall route workers and regenerate the router without them.
27
+ status Print repository inspection as JSON.
28
+ list List all available workflows and templates with install status.
29
+ search <query> Filter workflows and templates by name or description.
30
+
31
+ Route names (positional arguments to add and remove):
32
+ ${routeNames.join(", ")}
33
+
34
+ add Refresh installed files. In an empty repository: actions, router,
35
+ classifier, matrix, shared imports, opencode.ci.json and the compile
36
+ script, with no workers.
37
+ add implement refine Install those workers on top of the ones already installed.
38
+ add --template agentics-checks Install the named template only.
39
+ add refine --template agentics-checks Routes plus mandatory files plus the named template.
40
+ remove audit Uninstall the audit worker and drop it from the router.
41
+
42
+ What an update does to a package-managed file:
43
+ The file is replaced with this version's, and its ownership header records the version.
44
+ In a worker (agent-*.md) the env: block at the top is yours: your values are kept, keys the
45
+ package added arrive with their defaults, keys only you defined stay. When the header records
46
+ the version you installed from, that release is fetched from npm and a value you never changed
47
+ follows the package when its default changes. The agent runner pool and the engine gateway URL
48
+ are kept as well. Everything else in the file is the package's.
49
+ A file whose ownership header was removed is yours and is left alone unless --force is passed.
50
+ Templates are yours after installation and are replaced only with --force.
51
+
52
+ Options:
53
+ --dry-run Print what add or update would change, write nothing.
54
+ --force Also overwrite consumer-owned files and changed templates.
55
+ --baseline <path> Use this loops/ directory as the merge baseline instead of
56
+ fetching the installed version from npm. For rolling out a
57
+ version that is not published yet: without a baseline the
58
+ merge is two-way and keeps every consumer value, including
59
+ the package defaults the consumer never chose.
60
+ --template <name> Install a standalone template alongside or instead of routes.
61
+ Templates: ${templateNames.join(", ")}.
62
+ --visibility public|private Override repository visibility (init only).
63
+ --version Print the package version.
64
+ -h, --help Show this help text.
65
+
66
+ Installed workflows are marked [x] when the corresponding .github/workflows/agent-*.md
57
67
  file exists relative to the current directory.`;
58
68
  export async function run(arguments_, repositoryPath = process.cwd()) {
59
69
  const [command, ...options] = arguments_;
@@ -61,6 +71,10 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
61
71
  console.log(HELP_TEXT);
62
72
  return 0;
63
73
  }
74
+ if (command === "--version" || command === "-v") {
75
+ console.log(await packageVersion());
76
+ return 0;
77
+ }
64
78
  if (command === undefined) {
65
79
  const force = options.includes("--force");
66
80
  return runInteractive(repositoryPath, { force });
@@ -97,32 +111,42 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
97
111
  if (parsed.kind === "invalid")
98
112
  return fail(parsed.message);
99
113
  const inspection = await inspectRepository(repositoryPath);
100
- const routes = parsed.routes;
101
- const template = parsed.template;
102
- const force = parsed.force;
114
+ const { routes, template, force, dryRun } = parsed;
115
+ const baseline = localBaseline(parsed.baseline);
103
116
  const allConflicts = [];
104
117
  const allInstalled = [];
105
- if (routes.length > 0) {
118
+ let catalog;
119
+ const templatesPlanned = [];
120
+ // The installed set is always part of the target set: adding a route never drops another,
121
+ // and a plain update refreshes exactly what is there.
122
+ if (routes.length > 0 || template === undefined) {
106
123
  const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
107
- const result = await installCatalog(repositoryPath, { force, selectedRoutes, inspection });
108
- allConflicts.push(...result.conflicts);
109
- allInstalled.push(...result.installed);
110
- }
111
- else if (template === undefined) {
112
- const result = await installCatalog(repositoryPath, { force, selectedRoutes: [], inspection });
113
- allConflicts.push(...result.conflicts);
114
- allInstalled.push(...result.installed);
124
+ catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
125
+ allConflicts.push(...catalog.conflicts);
126
+ allInstalled.push(...catalog.installed);
115
127
  }
116
128
  if (template !== undefined) {
117
- const result = await installTemplate(repositoryPath, template, { force, inspection });
118
- allConflicts.push(...result.conflicts);
119
- allInstalled.push(...result.installed);
129
+ if (dryRun) {
130
+ templatesPlanned.push(template);
131
+ }
132
+ else {
133
+ const result = await installTemplate(repositoryPath, template, { force, inspection });
134
+ allConflicts.push(...result.conflicts);
135
+ allInstalled.push(...result.installed);
136
+ }
120
137
  }
121
138
  if (allConflicts.length > 0 && !force) {
122
- console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${allConflicts.join("\n")}`);
139
+ console.error(`Template conflicts found. Re-run with --force to overwrite consumer-owned copies:\n${allConflicts.join("\n")}`);
123
140
  return 1;
124
141
  }
125
- console.log(JSON.stringify({ command, installed: allInstalled.sort(), conflicts: allConflicts }, null, 2));
142
+ console.log(JSON.stringify({
143
+ command,
144
+ dryRun,
145
+ ...(catalog === undefined ? {} : summarize(catalog)),
146
+ ...(templatesPlanned.length > 0 ? { templatesPlanned } : {}),
147
+ installed: allInstalled.sort(),
148
+ conflicts: allConflicts,
149
+ }, null, 2));
126
150
  return 0;
127
151
  }
128
152
  if (command === "remove") {
@@ -136,17 +160,34 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
136
160
  const inspection = await inspectRepository(repositoryPath);
137
161
  const installed = await installedRoutes(repositoryPath);
138
162
  const desiredRoutes = installed.filter((route) => !parsed.routes.includes(route));
139
- const result = await installCatalog(repositoryPath, { force: parsed.force, selectedRoutes: desiredRoutes, inspection });
140
- if (result.conflicts.length > 0 && !parsed.force) {
141
- console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${result.conflicts.join("\n")}`);
142
- return 1;
143
- }
144
- const removed = await removeRouteFiles(repositoryPath, parsed.routes);
145
- console.log(JSON.stringify({ command, installed: [...result.installed].sort(), removed, conflicts: result.conflicts }, null, 2));
163
+ const result = await installCatalog(repositoryPath, { force: parsed.force, dryRun: parsed.dryRun, selectedRoutes: desiredRoutes, inspection, baseline: localBaseline(parsed.baseline) });
164
+ const removed = parsed.dryRun
165
+ ? parsed.routes.filter((route) => installed.includes(route)).map((route) => `.github/workflows/agent-${route}.md`)
166
+ : await removeRouteFiles(repositoryPath, parsed.routes);
167
+ console.log(JSON.stringify({ command, dryRun: parsed.dryRun, ...summarize(result), installed: [...result.installed].sort(), removed, conflicts: result.conflicts }, null, 2));
146
168
  return 0;
147
169
  }
148
170
  return fail(`Unknown command: ${command}`);
149
171
  }
172
+ function summarize(result) {
173
+ return {
174
+ packageVersion: result.packageVersion,
175
+ installedVersions: result.installedVersions,
176
+ upToDate: result.upToDate,
177
+ baselines: result.baselines,
178
+ changes: result.changes.filter((change) => change.status !== "unchanged"),
179
+ unchanged: result.changes.filter((change) => change.status === "unchanged").length,
180
+ };
181
+ }
182
+ // A fixed baseline for every recorded version, instead of asking npm. Rolling out a version that
183
+ // is not published yet leaves the merge two-way, which keeps every consumer value, including the
184
+ // package defaults a consumer never chose and would rather have replaced.
185
+ function localBaseline(path) {
186
+ if (path === undefined)
187
+ return undefined;
188
+ const resolved = resolve(path);
189
+ return async () => resolved;
190
+ }
150
191
  function unionRoutes(requested, installed) {
151
192
  const result = [...requested];
152
193
  for (const route of installed) {
@@ -168,6 +209,8 @@ function parseAddOptions(options) {
168
209
  let template;
169
210
  let templateSeen = false;
170
211
  let force = false;
212
+ let dryRun = false;
213
+ let baseline;
171
214
  let i = 0;
172
215
  while (i < options.length) {
173
216
  const token = options[i];
@@ -176,6 +219,18 @@ function parseAddOptions(options) {
176
219
  i++;
177
220
  continue;
178
221
  }
222
+ if (token === "--dry-run") {
223
+ dryRun = true;
224
+ i++;
225
+ continue;
226
+ }
227
+ if (token === "--baseline") {
228
+ if (i + 1 >= options.length)
229
+ return invalid("--baseline requires a path.");
230
+ baseline = options[i + 1];
231
+ i += 2;
232
+ continue;
233
+ }
179
234
  if (token === "--template") {
180
235
  if (templateSeen)
181
236
  return invalid("--template can only be specified once.");
@@ -202,7 +257,7 @@ function parseAddOptions(options) {
202
257
  }
203
258
  return invalid(`Unknown route: ${token}. Valid routes: ${routeNames.join(", ")}.`);
204
259
  }
205
- return { kind: "ok", routes, template, force };
260
+ return { kind: "ok", routes, template, force, dryRun, baseline };
206
261
  }
207
262
  function invalid(message) {
208
263
  return { kind: "invalid", message };
@@ -0,0 +1,25 @@
1
+ export declare const PACKAGE_NAME = "@plainconceptsplatform/workflows";
2
+ export declare function packageVersion(): Promise<string>;
3
+ export declare function isValidVersion(version: string): boolean;
4
+ export declare function hasOwnershipHeader(content: string): boolean;
5
+ /** The version recorded in a file's ownership header, or undefined for an unstamped file. */
6
+ export declare function installedVersion(content: string): string | undefined;
7
+ /** Write the version into the ownership header. Re-stamping replaces the previous version. */
8
+ export declare function stampVersion(content: string, version: string): string;
9
+ export type BaselineFetcher = (version: string) => Promise<string | undefined>;
10
+ export interface FetchBaselineOptions {
11
+ /** Where fetched releases are kept between runs. Defaults to the OS temp directory. */
12
+ readonly cacheRoot?: string;
13
+ /** Runs a shell command. Injected by tests. */
14
+ readonly run?: (command: string) => Promise<void>;
15
+ }
16
+ /**
17
+ * The `loops/` directory of a published release, or undefined when it cannot be had
18
+ * (offline, unpublished version). The caller falls back to a two-way merge.
19
+ */
20
+ export declare function fetchBaseline(version: string, options?: FetchBaselineOptions): Promise<string | undefined>;
21
+ /**
22
+ * Unpack a gzipped ustar/pax archive (what `npm pack` produces) into a directory. Regular files
23
+ * and directories only; anything that would escape the destination is skipped.
24
+ */
25
+ export declare function extractTarball(archive: Buffer, destination: string): void;
@@ -0,0 +1,138 @@
1
+ // Every installed file carries the package version in its ownership header, stamped at
2
+ // install time. That version is the baseline an update merges against: the CLI fetches that
3
+ // exact release from npm and reads the file as it was when the consumer installed it.
4
+ import { exec as execCallback } from "node:child_process";
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { access, mkdir, readdir, readFile } from "node:fs/promises";
7
+ import { constants } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { promisify } from "node:util";
12
+ import { gunzipSync } from "node:zlib";
13
+ const exec = promisify(execCallback);
14
+ export const PACKAGE_NAME = "@plainconceptsplatform/workflows";
15
+ const HEADER = `Managed by ${PACKAGE_NAME}`;
16
+ const VERSION = "\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?";
17
+ const STAMPED = new RegExp(`${escapeRegex(HEADER)}@(${VERSION})\\.`);
18
+ const UNSTAMPED = new RegExp(`${escapeRegex(HEADER)}\\.`);
19
+ export async function packageVersion() {
20
+ const manifest = fileURLToPath(new URL("../package.json", import.meta.url));
21
+ const { version } = JSON.parse(await readFile(manifest, "utf8"));
22
+ return version;
23
+ }
24
+ export function isValidVersion(version) {
25
+ return new RegExp(`^${VERSION}$`).test(version);
26
+ }
27
+ export function hasOwnershipHeader(content) {
28
+ return content.includes(HEADER);
29
+ }
30
+ /** The version recorded in a file's ownership header, or undefined for an unstamped file. */
31
+ export function installedVersion(content) {
32
+ return STAMPED.exec(content)?.[1];
33
+ }
34
+ /** Write the version into the ownership header. Re-stamping replaces the previous version. */
35
+ export function stampVersion(content, version) {
36
+ if (STAMPED.test(content))
37
+ return content.replace(STAMPED, `${HEADER}@${version}.`);
38
+ return content.replace(UNSTAMPED, `${HEADER}@${version}.`);
39
+ }
40
+ /**
41
+ * The `loops/` directory of a published release, or undefined when it cannot be had
42
+ * (offline, unpublished version). The caller falls back to a two-way merge.
43
+ */
44
+ export async function fetchBaseline(version, options = {}) {
45
+ if (!isValidVersion(version))
46
+ return undefined;
47
+ const root = options.cacheRoot ?? join(tmpdir(), "plainconceptsplatform-workflows");
48
+ const directory = join(root, version);
49
+ const loops = join(directory, "package", "loops");
50
+ if (await exists(loops))
51
+ return loops;
52
+ const run = options.run ?? (async (command) => { await exec(command, { windowsHide: true }); });
53
+ try {
54
+ await mkdir(directory, { recursive: true });
55
+ // npm honours the user's registry configuration, which a direct download would not.
56
+ let tarball = await findTarball(directory);
57
+ if (tarball === undefined) {
58
+ await run(`npm pack "${PACKAGE_NAME}@${version}" --pack-destination "${directory}" --silent`);
59
+ tarball = await findTarball(directory);
60
+ }
61
+ if (tarball === undefined)
62
+ return undefined;
63
+ // Extracted here rather than by a `tar` binary: the one on PATH may be GNU tar under
64
+ // MSYS, which reads `C:\...` as a remote host, or missing altogether.
65
+ extractTarball(await readFile(join(directory, tarball)), directory);
66
+ return (await exists(loops)) ? loops : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ async function findTarball(directory) {
73
+ return (await readdir(directory)).find((file) => file.endsWith(".tgz"));
74
+ }
75
+ /**
76
+ * Unpack a gzipped ustar/pax archive (what `npm pack` produces) into a directory. Regular files
77
+ * and directories only; anything that would escape the destination is skipped.
78
+ */
79
+ export function extractTarball(archive, destination) {
80
+ const data = gunzipSync(archive);
81
+ const field = (block, start, length) => {
82
+ const raw = block.subarray(start, start + length);
83
+ const end = raw.indexOf(0);
84
+ return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
85
+ };
86
+ let offset = 0;
87
+ let longName;
88
+ while (offset + 512 <= data.length) {
89
+ const header = data.subarray(offset, offset + 512);
90
+ offset += 512;
91
+ if (header.every((byte) => byte === 0))
92
+ break;
93
+ const size = Number.parseInt(field(header, 124, 12).trim() || "0", 8);
94
+ const type = field(header, 156, 1) || "0";
95
+ const body = data.subarray(offset, offset + size);
96
+ offset += Math.ceil(size / 512) * 512;
97
+ if (type === "x" || type === "g") {
98
+ // pax extended header: records of the form "<length> key=value\n"; `path` names the next entry.
99
+ for (const record of body.toString("utf8").split("\n")) {
100
+ const match = /^\d+ path=(.*)$/.exec(record);
101
+ if (match !== null && type === "x")
102
+ longName = match[1];
103
+ }
104
+ continue;
105
+ }
106
+ if (type === "L") {
107
+ longName = body.toString("utf8").replace(/\0+$/, "");
108
+ continue;
109
+ }
110
+ const prefix = field(header, 345, 155);
111
+ const shortName = field(header, 0, 100);
112
+ const name = longName ?? (prefix === "" ? shortName : `${prefix}/${shortName}`);
113
+ longName = undefined;
114
+ const parts = name.split("/").filter((part) => part !== "" && part !== ".");
115
+ if (parts.length === 0 || parts.includes(".."))
116
+ continue;
117
+ const target = join(destination, ...parts);
118
+ if (type === "5") {
119
+ mkdirSync(target, { recursive: true });
120
+ }
121
+ else if (type === "0") {
122
+ mkdirSync(dirname(target), { recursive: true });
123
+ writeFileSync(target, body);
124
+ }
125
+ }
126
+ }
127
+ async function exists(path) {
128
+ try {
129
+ await access(path, constants.F_OK);
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ function escapeRegex(value) {
137
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
+ }
@@ -1,6 +1,4 @@
1
1
  import { type RouteName } from "./workflow-catalog.js";
2
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
3
  export declare function processRoutes(files: Map<string, string>, selectedRoutes: readonly RouteName[]): Map<string, string>;
6
4
  export declare function excludedWorkerFiles(selectedRoutes: readonly RouteName[]): Set<string>;
@@ -1,64 +1,26 @@
1
1
  import { routeNames, workflowRoutes } from "./workflow-catalog.js";
2
+ // The router is the one derived file: its content is a function of the installed route set.
3
+ // The classifier stays the complete pure function (a route with no job is a no-op run), and the
4
+ // route matrix reads the installed worker files, so neither is rewritten here.
2
5
  const routeCrons = {
3
6
  audit: "17 1 * * 1",
4
7
  };
8
+ // Deterministic router jobs that exist only to serve one worker route. They leave with it, or
9
+ // the route matrix finds a `route == 'implement'` guard in a router with no implement worker.
10
+ const routeHelperJobs = {
11
+ implement: ["check-implement-pr"],
12
+ triage: ["dispatch-triage"],
13
+ };
5
14
  export function stripRouteFromRouter(yaml, route) {
6
15
  let result = yaml;
7
16
  const cron = routeCrons[route];
8
17
  if (cron !== undefined) {
9
18
  result = result.replace(new RegExp(`^ - cron: "${escapeRegex(cron)}"\\n`, "gm"), "");
10
19
  }
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
- }
20
+ for (const job of [`call-${route}`, ...(routeHelperJobs[route] ?? [])]) {
21
+ result = removeJobBlock(result, job);
52
22
  }
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`);
23
+ result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
62
24
  return result;
63
25
  }
64
26
  export function processRoutes(files, selectedRoutes) {
@@ -66,56 +28,23 @@ export function processRoutes(files, selectedRoutes) {
66
28
  if (excludedRoutes.length === 0)
67
29
  return files;
68
30
  const result = new Map(files);
31
+ const routerKey = findFileKey(result, "work-router.yml");
32
+ if (routerKey === undefined)
33
+ return result;
34
+ let router = result.get(routerKey);
69
35
  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));
36
+ router = stripRouteFromRouter(router, route);
82
37
  }
38
+ result.set(routerKey, router);
83
39
  return result;
84
40
  }
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
41
  export function excludedWorkerFiles(selectedRoutes) {
115
42
  return new Set(workflowRoutes
116
43
  .filter((route) => !selectedRoutes.includes(route.name))
117
44
  .map((route) => route.worker));
118
45
  }
46
+ // Remove a top-level job (two-space indent) together with the comment lines that introduce it.
47
+ // A job's comment describes that job; left behind it describes nothing.
119
48
  function removeJobBlock(yaml, jobName) {
120
49
  const lines = yaml.split("\n");
121
50
  const startPattern = new RegExp(`^ ${escapeRegex(jobName)}:`);
@@ -129,6 +58,8 @@ function removeJobBlock(yaml, jobName) {
129
58
  }
130
59
  }
131
60
  else if (startPattern.test(line)) {
61
+ while (result.length > 0 && /^ #/.test(result[result.length - 1]))
62
+ result.pop();
132
63
  skipping = true;
133
64
  }
134
65
  else {
@@ -18,38 +18,44 @@ export function generateStackDefaults(inspection) {
18
18
  }
19
19
  return { verifyCommands, repoRulesBase, hasDotnet, hasNodeOnly };
20
20
  }
21
+ // Only a worker that prints a verification block declares VERIFY_COMMANDS. Adding the key to
22
+ // the others made byte-identical workers read as changed on every update, so a worker without
23
+ // the key is left without it.
21
24
  export function injectStackEnv(content, defaults) {
22
- let result = content;
23
- if (result.includes("VERIFY_COMMANDS:")) {
24
- result = result.replace(/ VERIFY_COMMANDS: ".*"/, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
25
- }
26
- else if (/^env:\n/m.test(result)) {
27
- result = result.replace(/^env:\n/m, `env:\n VERIFY_COMMANDS: "${defaults.verifyCommands}"\n`);
28
- }
29
- return result;
25
+ return content.replace(/^ VERIFY_COMMANDS: .*$/m, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
30
26
  }
31
27
  export function generateOpencodeCi(baseContent, inspection) {
32
28
  let result = baseContent;
29
+ // Idempotent on purpose: this runs on every install and update, and the shared file already
30
+ // carries some of these steps. A step is added only when no step of that name exists.
33
31
  if (inspection.stackHints.solutionFiles.length > 0) {
34
32
  const solutionPath = inspection.stackHints.solutionFiles[0].replaceAll("\\", "/");
35
- const nugetSteps = ` - name: Cache NuGet packages
33
+ if (!result.includes("name: Cache NuGet packages")) {
34
+ const nugetCache = ` - name: Cache NuGet packages
36
35
  uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
37
36
  with:
38
37
  path: ~/.nuget/packages
39
38
  key: nuget-\${{ runner.os }}-\${{ hashFiles('**/*.slnx', '**/Directory.Packages.props') }}
40
39
  restore-keys: nuget-\${{ runner.os }}-
41
40
 
42
- - name: Restore .NET dependencies
41
+ `;
42
+ result = insertBeforeMarker(result, nugetCache, " - name: Install workspace dependencies");
43
+ }
44
+ if (!result.includes("name: Restore .NET dependencies")) {
45
+ const restore = ` - name: Restore .NET dependencies
43
46
  run: dotnet restore ${solutionPath}
47
+
44
48
  `;
45
- result = insertBeforeMarker(result, nugetSteps, " - name: Install workspace dependencies");
49
+ result = insertBeforeMarker(result, restore, " - name: Install workspace dependencies");
50
+ }
46
51
  }
47
- if (inspection.stackHints.openSpec) {
52
+ if (inspection.stackHints.openSpec && !result.includes("name: Install OpenSpec CLI")) {
48
53
  const openspecStep = ` - name: Install OpenSpec CLI
49
54
  run: |
50
55
  set -euo pipefail
51
56
  npm install -g "@fission-ai/openspec@1.8.0"
52
57
  openspec --version
58
+
53
59
  `;
54
60
  result = insertBeforeMarker(result, openspecStep, " - name: Install workspace dependencies");
55
61
  }