@plainconceptsplatform/workflows 0.5.1 → 0.16.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.
Files changed (60) hide show
  1. package/README.md +105 -88
  2. package/dist/catalog-installation.d.ts +67 -0
  3. package/dist/catalog-installation.js +485 -0
  4. package/dist/catalog-listing.d.ts +13 -0
  5. package/dist/catalog-listing.js +70 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +273 -0
  8. package/dist/package-baseline.d.ts +25 -0
  9. package/dist/package-baseline.js +138 -0
  10. package/dist/repository-inspection.d.ts +23 -0
  11. package/dist/repository-inspection.js +113 -0
  12. package/dist/route-processing.d.ts +4 -0
  13. package/dist/route-processing.js +80 -0
  14. package/dist/stack-defaults.d.ts +11 -0
  15. package/dist/stack-defaults.js +110 -0
  16. package/dist/tui.d.ts +25 -0
  17. package/dist/tui.js +271 -0
  18. package/dist/worker-env.d.ts +46 -0
  19. package/dist/worker-env.js +179 -0
  20. package/dist/workflow-catalog.d.ts +27 -0
  21. package/dist/workflow-catalog.js +57 -0
  22. package/loops/actions/add-issue-labels/action.yml +22 -2
  23. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  24. package/loops/actions/audit-close/action.yml +180 -128
  25. package/loops/actions/classify-route/action.yml +7 -0
  26. package/loops/actions/classify-route/classify-route.sh +28 -7
  27. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  28. package/loops/actions/housekeeping/action.yml +251 -0
  29. package/loops/actions/identify-gate-subject/action.yml +3 -1
  30. package/loops/actions/merge-agent-pr/action.yml +13 -0
  31. package/loops/actions/remove-issue-labels/action.yml +4 -2
  32. package/loops/actions/report-workflow-errors/action.yml +385 -0
  33. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
  34. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  35. package/loops/actions/validate-triage-output/action.yml +1 -1
  36. package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
  37. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
  38. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  39. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1017 -56
  40. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  41. package/loops/scripts/merge-changelog.mjs +76 -0
  42. package/loops/templates/agentics/actionlint.yaml +13 -0
  43. package/loops/templates/agentics/agentics-checks.yml +203 -86
  44. package/loops/templates/agentics/agentics-error-report.yml +97 -0
  45. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  46. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  47. package/loops/templates/opencode/opencode.ci.json +3 -3
  48. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  49. package/loops/workflows/agent-apply-review.md +452 -465
  50. package/loops/workflows/agent-audit.md +201 -213
  51. package/loops/workflows/agent-implement.md +616 -538
  52. package/loops/workflows/agent-merge-gate.md +830 -730
  53. package/loops/workflows/agent-refine.md +599 -609
  54. package/loops/workflows/agent-release.md +244 -258
  55. package/loops/workflows/agent-triage.md +476 -447
  56. package/loops/workflows/authorize-bot-work.yml +26 -6
  57. package/loops/workflows/work-router.yml +1185 -862
  58. package/package.json +9 -8
  59. package/loops/actions/stale-recovery/action.yml +0 -288
  60. package/loops/actions/update-changelog/action.yml +0 -113
package/dist/index.js ADDED
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
3
+ import { installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
4
+ import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
5
+ import { packageVersion } from "./package-baseline.js";
6
+ import { routeNames, templateNames } from "./workflow-catalog.js";
7
+ import { runInteractive } from "./tui.js";
8
+ import { resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
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
67
+ file exists relative to the current directory.`;
68
+ export async function run(arguments_, repositoryPath = process.cwd()) {
69
+ const [command, ...options] = arguments_;
70
+ if (command === "--help" || command === "-h") {
71
+ console.log(HELP_TEXT);
72
+ return 0;
73
+ }
74
+ if (command === "--version" || command === "-v") {
75
+ console.log(await packageVersion());
76
+ return 0;
77
+ }
78
+ if (command === undefined) {
79
+ const force = options.includes("--force");
80
+ return runInteractive(repositoryPath, { force });
81
+ }
82
+ if (command === "list") {
83
+ const entries = await listCatalog({ installedPath: repositoryPath });
84
+ console.log(formatCatalog(entries));
85
+ return 0;
86
+ }
87
+ if (command === "search") {
88
+ if (options.length === 0 || options.length > 1)
89
+ return fail("search requires exactly one query argument.");
90
+ const allEntries = await listCatalog({ installedPath: repositoryPath });
91
+ const results = searchCatalog(allEntries, options[0]);
92
+ console.log(formatCatalog(results));
93
+ return 0;
94
+ }
95
+ if (command === "init") {
96
+ const visibility = readVisibilityOption(options);
97
+ if (visibility === "invalid")
98
+ return fail("--visibility must be public or private.");
99
+ const inspection = await inspectRepository(repositoryPath);
100
+ const resolvedVisibility = await resolveVisibility(repositoryPath, visibility);
101
+ console.log(JSON.stringify({ command, inspection, visibility: resolvedVisibility }, null, 2));
102
+ return 0;
103
+ }
104
+ if (command === "status") {
105
+ const inspection = await inspectRepository(repositoryPath);
106
+ console.log(JSON.stringify({ command, inspection }, null, 2));
107
+ return 0;
108
+ }
109
+ if (command === "add" || command === "update") {
110
+ const parsed = parseAddOptions(options);
111
+ if (parsed.kind === "invalid")
112
+ return fail(parsed.message);
113
+ const inspection = await inspectRepository(repositoryPath);
114
+ const { routes, template, force, dryRun } = parsed;
115
+ const baseline = localBaseline(parsed.baseline);
116
+ const allConflicts = [];
117
+ const allInstalled = [];
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) {
123
+ const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
124
+ catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
125
+ allConflicts.push(...catalog.conflicts);
126
+ allInstalled.push(...catalog.installed);
127
+ }
128
+ if (template !== undefined) {
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
+ }
137
+ }
138
+ if (allConflicts.length > 0 && !force) {
139
+ console.error(`Template conflicts found. Re-run with --force to overwrite consumer-owned copies:\n${allConflicts.join("\n")}`);
140
+ return 1;
141
+ }
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));
150
+ return 0;
151
+ }
152
+ if (command === "remove") {
153
+ const parsed = parseAddOptions(options);
154
+ if (parsed.kind === "invalid")
155
+ return fail(parsed.message);
156
+ if (parsed.template !== undefined)
157
+ return fail("remove does not accept --template.");
158
+ if (parsed.routes.length === 0)
159
+ return fail("remove requires at least one route.");
160
+ const inspection = await inspectRepository(repositoryPath);
161
+ const installed = await installedRoutes(repositoryPath);
162
+ const desiredRoutes = installed.filter((route) => !parsed.routes.includes(route));
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));
168
+ return 0;
169
+ }
170
+ return fail(`Unknown command: ${command}`);
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
+ }
191
+ function unionRoutes(requested, installed) {
192
+ const result = [...requested];
193
+ for (const route of installed) {
194
+ if (!result.includes(route))
195
+ result.push(route);
196
+ }
197
+ return result;
198
+ }
199
+ function readVisibilityOption(options) {
200
+ if (options.length === 0)
201
+ return undefined;
202
+ if (options.length !== 2 || options[0] !== "--visibility")
203
+ return "invalid";
204
+ return parseVisibility(options[1]) ?? "invalid";
205
+ }
206
+ const TEMPLATE_NAMES = templateNames.join("|");
207
+ function parseAddOptions(options) {
208
+ const routes = [];
209
+ let template;
210
+ let templateSeen = false;
211
+ let force = false;
212
+ let dryRun = false;
213
+ let baseline;
214
+ let i = 0;
215
+ while (i < options.length) {
216
+ const token = options[i];
217
+ if (token === "--force") {
218
+ force = true;
219
+ i++;
220
+ continue;
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
+ }
234
+ if (token === "--template") {
235
+ if (templateSeen)
236
+ return invalid("--template can only be specified once.");
237
+ templateSeen = true;
238
+ if (i + 1 >= options.length)
239
+ return invalid("--template requires a name.");
240
+ const name = options[i + 1];
241
+ if (!isTemplateName(name))
242
+ return invalid(`--template must be one of: ${TEMPLATE_NAMES}.`);
243
+ template = name;
244
+ i += 2;
245
+ continue;
246
+ }
247
+ if (token.startsWith("--")) {
248
+ return invalid(`Unknown option: ${token}`);
249
+ }
250
+ if (routeNames.includes(token)) {
251
+ const route = token;
252
+ if (routes.includes(route))
253
+ return invalid(`Duplicate route: ${route}.`);
254
+ routes.push(route);
255
+ i++;
256
+ continue;
257
+ }
258
+ return invalid(`Unknown route: ${token}. Valid routes: ${routeNames.join(", ")}.`);
259
+ }
260
+ return { kind: "ok", routes, template, force, dryRun, baseline };
261
+ }
262
+ function invalid(message) {
263
+ return { kind: "invalid", message };
264
+ }
265
+ function fail(message) {
266
+ console.error(message);
267
+ return 1;
268
+ }
269
+ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
270
+ void run(process.argv.slice(2)).then((exitCode) => {
271
+ process.exitCode = exitCode;
272
+ });
273
+ }
@@ -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
+ }
@@ -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,4 @@
1
+ import { type RouteName } from "./workflow-catalog.js";
2
+ export declare function stripRouteFromRouter(yaml: string, route: RouteName): string;
3
+ export declare function processRoutes(files: Map<string, string>, selectedRoutes: readonly RouteName[]): Map<string, string>;
4
+ export declare function excludedWorkerFiles(selectedRoutes: readonly RouteName[]): Set<string>;
@@ -0,0 +1,80 @@
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.
5
+ const routeCrons = {
6
+ audit: "17 1 * * 1",
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
+ };
14
+ export function stripRouteFromRouter(yaml, route) {
15
+ let result = yaml;
16
+ const cron = routeCrons[route];
17
+ if (cron !== undefined) {
18
+ result = result.replace(new RegExp(`^ - cron: "${escapeRegex(cron)}"\\n`, "gm"), "");
19
+ }
20
+ for (const job of [`call-${route}`, ...(routeHelperJobs[route] ?? [])]) {
21
+ result = removeJobBlock(result, job);
22
+ }
23
+ result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
24
+ return result;
25
+ }
26
+ export function processRoutes(files, selectedRoutes) {
27
+ const excludedRoutes = routeNames.filter((r) => !selectedRoutes.includes(r));
28
+ if (excludedRoutes.length === 0)
29
+ return files;
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);
35
+ for (const route of excludedRoutes) {
36
+ router = stripRouteFromRouter(router, route);
37
+ }
38
+ result.set(routerKey, router);
39
+ return result;
40
+ }
41
+ export function excludedWorkerFiles(selectedRoutes) {
42
+ return new Set(workflowRoutes
43
+ .filter((route) => !selectedRoutes.includes(route.name))
44
+ .map((route) => route.worker));
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.
48
+ function removeJobBlock(yaml, jobName) {
49
+ const lines = yaml.split("\n");
50
+ const startPattern = new RegExp(`^ ${escapeRegex(jobName)}:`);
51
+ const result = [];
52
+ let skipping = false;
53
+ for (const line of lines) {
54
+ if (skipping) {
55
+ if (/^ \S/.test(line) || /^[^\s]/.test(line)) {
56
+ skipping = false;
57
+ result.push(line);
58
+ }
59
+ }
60
+ else if (startPattern.test(line)) {
61
+ while (result.length > 0 && /^ #/.test(result[result.length - 1]))
62
+ result.pop();
63
+ skipping = true;
64
+ }
65
+ else {
66
+ result.push(line);
67
+ }
68
+ }
69
+ return result.join("\n");
70
+ }
71
+ function findFileKey(files, endsWith) {
72
+ for (const key of files.keys()) {
73
+ if (key.endsWith(endsWith))
74
+ return key;
75
+ }
76
+ return undefined;
77
+ }
78
+ function escapeRegex(str) {
79
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ }
@@ -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;