@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
@@ -0,0 +1,110 @@
1
+ export function generateStackDefaults(inspection) {
2
+ const hasDotnet = inspection.stackHints.solutionFiles.length > 0;
3
+ const hasNode = inspection.stackHints.pnpmLockfile;
4
+ const hasNodeOnly = hasNode && !hasDotnet;
5
+ let verifyCommands;
6
+ let repoRulesBase;
7
+ if (hasDotnet && hasNode) {
8
+ verifyCommands = ".NET: dotnet restore && dotnet build -c Release && dotnet test | Web: pnpm lint && pnpm test && pnpm build";
9
+ repoRulesBase = "Full-stack .NET + React/Next.js repository. Follow Clean Architecture layering: API → Application → Domain. Infrastructure implements Application ports. Do not reference EF Core or ASP.NET from Application. Frontend communicates exclusively via HTTP endpoints. Run both .NET and frontend verification.";
10
+ }
11
+ else if (hasDotnet) {
12
+ verifyCommands = "dotnet restore && dotnet build -c Release --no-restore && dotnet test";
13
+ repoRulesBase = ".NET repository using Clean Architecture. Follow layering: API → Application → Domain. Infrastructure implements Application ports. Do not reference EF Core or ASP.NET from Application.";
14
+ }
15
+ else {
16
+ verifyCommands = "pnpm verify";
17
+ repoRulesBase = "Node.js repository. Follow existing project conventions and import boundaries.";
18
+ }
19
+ return { verifyCommands, repoRulesBase, hasDotnet, hasNodeOnly };
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.
24
+ export function injectStackEnv(content, defaults) {
25
+ return content.replace(/^ VERIFY_COMMANDS: .*$/m, ` VERIFY_COMMANDS: "${defaults.verifyCommands}"`);
26
+ }
27
+ export function generateOpencodeCi(baseContent, inspection) {
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.
31
+ if (inspection.stackHints.solutionFiles.length > 0) {
32
+ const solutionPath = inspection.stackHints.solutionFiles[0].replaceAll("\\", "/");
33
+ if (!result.includes("name: Cache NuGet packages")) {
34
+ const nugetCache = ` - name: Cache NuGet packages
35
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
36
+ with:
37
+ path: ~/.nuget/packages
38
+ key: nuget-\${{ runner.os }}-\${{ hashFiles('**/*.slnx', '**/Directory.Packages.props') }}
39
+ restore-keys: nuget-\${{ runner.os }}-
40
+
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
46
+ run: dotnet restore ${solutionPath}
47
+
48
+ `;
49
+ result = insertBeforeMarker(result, restore, " - name: Install workspace dependencies");
50
+ }
51
+ }
52
+ if (inspection.stackHints.openSpec && !result.includes("name: Install OpenSpec CLI")) {
53
+ const openspecStep = ` - name: Install OpenSpec CLI
54
+ run: |
55
+ set -euo pipefail
56
+ npm install -g "@fission-ai/openspec@1.8.0"
57
+ openspec --version
58
+
59
+ `;
60
+ result = insertBeforeMarker(result, openspecStep, " - name: Install workspace dependencies");
61
+ }
62
+ if (inspection.stackHints.packageJson && !result.includes("--legacy-peer-deps")) {
63
+ result = result.replace(` if ! npm install --prefix .opencode; then\n echo "No plugin deps, skipping."\n exit 0\n fi`, ` if ! npm install --prefix .opencode; then\n echo "::warning::Strict npm install failed on a peer conflict. Retrying with --legacy-peer-deps; check .opencode/package.json."\n npm install --prefix .opencode --legacy-peer-deps\n fi`);
64
+ }
65
+ return result;
66
+ }
67
+ function insertBeforeMarker(content, steps, marker) {
68
+ if (content.includes(marker)) {
69
+ return content.replace(marker, steps + marker);
70
+ }
71
+ return content + "\n" + steps;
72
+ }
73
+ export function generateOpencodeConfig(baseContent, inspection) {
74
+ try {
75
+ const config = JSON.parse(baseContent);
76
+ const hasDotnet = inspection.stackHints.solutionFiles.length > 0;
77
+ if (!hasDotnet && config.lsp !== undefined) {
78
+ delete config.lsp;
79
+ }
80
+ else if (hasDotnet && config.lsp === undefined) {
81
+ config.lsp = {
82
+ csharp: { disabled: true },
83
+ fsharp: { disabled: true },
84
+ razor: { disabled: true },
85
+ };
86
+ }
87
+ const agent = config.agent;
88
+ if (agent !== undefined) {
89
+ const agentEntry = agent["ci-workflow-agent"];
90
+ if (agentEntry !== undefined) {
91
+ let prompt = agentEntry.prompt ?? "";
92
+ if (hasDotnet) {
93
+ if (!prompt.includes(".NET guardrails")) {
94
+ prompt += "\n\n# .NET guardrails\nFollow Clean Architecture layering: API → Application → Domain. Infrastructure implements Application ports. Application must not reference EF Core or ASP.NET. Use Central Package Management (Directory.Packages.props). Build in Release mode for CI.";
95
+ }
96
+ }
97
+ else {
98
+ if (!prompt.includes("Node/React rules")) {
99
+ prompt += "\n\n# Node/React rules\nFollow Feature-Sliced Design import boundaries. Use pnpm, never npm or yarn. All user-facing text must be i18n messages. TypeScript strict mode.";
100
+ }
101
+ }
102
+ agentEntry.prompt = prompt;
103
+ }
104
+ }
105
+ return JSON.stringify(config, null, 2) + "\n";
106
+ }
107
+ catch {
108
+ return baseContent;
109
+ }
110
+ }
package/dist/tui.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { type CatalogEntry } from "./catalog-listing.js";
2
+ export type SelectionStatus = "selecting" | "submitting" | "cancelled";
3
+ export interface SelectionState {
4
+ readonly allItems: readonly CatalogEntry[];
5
+ readonly visibleItems: readonly CatalogEntry[];
6
+ readonly selected: ReadonlySet<string>;
7
+ readonly cursor: number;
8
+ readonly filter: string;
9
+ readonly status: SelectionStatus;
10
+ }
11
+ export interface InteractiveOptions {
12
+ readonly force?: boolean;
13
+ }
14
+ export declare function createSelectionState(entries: readonly CatalogEntry[]): SelectionState;
15
+ export declare function fuzzyMatch(text: string, query: string): boolean;
16
+ export declare function filterItems(items: readonly CatalogEntry[], filter: string): readonly CatalogEntry[];
17
+ export declare function applyFilter(state: SelectionState, filter: string): SelectionState;
18
+ export declare function clearFilter(state: SelectionState): SelectionState;
19
+ export declare function moveCursorUp(state: SelectionState): SelectionState;
20
+ export declare function moveCursorDown(state: SelectionState): SelectionState;
21
+ export declare function toggleSelection(state: SelectionState): SelectionState;
22
+ export declare function submitSelection(state: SelectionState): SelectionState;
23
+ export declare function cancelSelection(state: SelectionState): SelectionState;
24
+ export declare function getItemsToInstall(state: SelectionState, force?: boolean): readonly CatalogEntry[];
25
+ export declare function runInteractive(repositoryPath: string, options?: InteractiveOptions): Promise<number>;
package/dist/tui.js ADDED
@@ -0,0 +1,271 @@
1
+ import * as readline from "node:readline";
2
+ import { formatCatalog, listCatalog } from "./catalog-listing.js";
3
+ import { installCatalog, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
4
+ import { inspectRepository } from "./repository-inspection.js";
5
+ import { routeNames } from "./workflow-catalog.js";
6
+ const ANSI = {
7
+ clear: "\x1b[2J",
8
+ home: "\x1b[H",
9
+ reset: "\x1b[0m",
10
+ bold: "\x1b[1m",
11
+ dim: "\x1b[2m",
12
+ cyan: "\x1b[36m",
13
+ green: "\x1b[32m",
14
+ grey: "\x1b[90m",
15
+ white: "\x1b[37m",
16
+ hideCursor: "\x1b[?25l",
17
+ showCursor: "\x1b[?25h",
18
+ };
19
+ export function createSelectionState(entries) {
20
+ const selected = new Set(entries.filter((entry) => entry.installed).map((entry) => entry.name));
21
+ return {
22
+ allItems: entries,
23
+ visibleItems: entries,
24
+ selected,
25
+ cursor: 0,
26
+ filter: "",
27
+ status: "selecting",
28
+ };
29
+ }
30
+ export function fuzzyMatch(text, query) {
31
+ if (query === "")
32
+ return true;
33
+ const haystack = text.toLowerCase();
34
+ const needle = query.toLowerCase();
35
+ let needleIndex = 0;
36
+ for (let i = 0; i < haystack.length && needleIndex < needle.length; i++) {
37
+ if (haystack[i] === needle[needleIndex])
38
+ needleIndex++;
39
+ }
40
+ return needleIndex === needle.length;
41
+ }
42
+ export function filterItems(items, filter) {
43
+ if (filter === "")
44
+ return items;
45
+ return items.filter((entry) => fuzzyMatch(`${entry.name} ${entry.description}`, filter));
46
+ }
47
+ export function applyFilter(state, filter) {
48
+ const visibleItems = filterItems(state.allItems, filter);
49
+ const cursor = visibleItems.length === 0 ? 0 : Math.min(state.cursor, visibleItems.length - 1);
50
+ return { ...state, visibleItems, filter, cursor };
51
+ }
52
+ export function clearFilter(state) {
53
+ return applyFilter(state, "");
54
+ }
55
+ export function moveCursorUp(state) {
56
+ if (state.visibleItems.length === 0)
57
+ return state;
58
+ const cursor = state.cursor === 0 ? state.visibleItems.length - 1 : state.cursor - 1;
59
+ return { ...state, cursor };
60
+ }
61
+ export function moveCursorDown(state) {
62
+ if (state.visibleItems.length === 0)
63
+ return state;
64
+ const cursor = state.cursor === state.visibleItems.length - 1 ? 0 : state.cursor + 1;
65
+ return { ...state, cursor };
66
+ }
67
+ export function toggleSelection(state) {
68
+ if (state.visibleItems.length === 0)
69
+ return state;
70
+ const entry = state.visibleItems[state.cursor];
71
+ if (entry === undefined)
72
+ return state;
73
+ const selected = new Set(state.selected);
74
+ if (selected.has(entry.name)) {
75
+ selected.delete(entry.name);
76
+ }
77
+ else {
78
+ selected.add(entry.name);
79
+ }
80
+ return { ...state, selected };
81
+ }
82
+ export function submitSelection(state) {
83
+ return { ...state, status: "submitting" };
84
+ }
85
+ export function cancelSelection(state) {
86
+ return { ...state, status: "cancelled" };
87
+ }
88
+ export function getItemsToInstall(state, force = false) {
89
+ return state.allItems.filter((entry) => state.selected.has(entry.name) && (force || !entry.installed));
90
+ }
91
+ function renderEntry(entry, highlighted, checked) {
92
+ const indicator = highlighted ? `${ANSI.bold}${ANSI.white}>${ANSI.reset}` : " ";
93
+ if (checked) {
94
+ const checkbox = `${ANSI.green}[x]${ANSI.reset}`;
95
+ const name = highlighted ? `${ANSI.white}${ANSI.bold}${entry.name}${ANSI.reset}` : entry.name;
96
+ const desc = `${ANSI.dim} — ${entry.description}${ANSI.reset}`;
97
+ return `${indicator} ${checkbox} ${name}${desc}`;
98
+ }
99
+ const checkbox = `${ANSI.grey}[ ]${ANSI.reset}`;
100
+ const name = highlighted ? `${ANSI.white}${ANSI.bold}${entry.name}${ANSI.reset}` : entry.name;
101
+ const desc = `${ANSI.dim} — ${entry.description}${ANSI.reset}`;
102
+ return `${indicator} ${checkbox} ${name}${desc}`;
103
+ }
104
+ function render(state) {
105
+ const lines = [];
106
+ lines.push(`${ANSI.cyan}${ANSI.bold}Plain Concepts Platform — Agentic Workflows${ANSI.reset}`);
107
+ lines.push(`${ANSI.dim}Select items to install. Already-installed items are pre-checked.${ANSI.reset}`);
108
+ lines.push("");
109
+ if (state.filter) {
110
+ lines.push(`${ANSI.cyan}Filter:${ANSI.reset} ${state.filter}${ANSI.grey}█${ANSI.reset}`);
111
+ }
112
+ else {
113
+ lines.push(`${ANSI.dim}Type to filter, arrows to navigate, space to toggle, Enter to install${ANSI.reset}`);
114
+ }
115
+ lines.push("");
116
+ const routes = state.visibleItems.filter((entry) => entry.kind === "route");
117
+ const templates = state.visibleItems.filter((entry) => entry.kind === "template");
118
+ let index = 0;
119
+ if (routes.length > 0) {
120
+ lines.push(`${ANSI.cyan}Workflows${ANSI.reset}`);
121
+ for (const entry of routes) {
122
+ lines.push(renderEntry(entry, index === state.cursor, state.selected.has(entry.name)));
123
+ index++;
124
+ }
125
+ if (templates.length > 0)
126
+ lines.push("");
127
+ }
128
+ if (templates.length > 0) {
129
+ lines.push(`${ANSI.cyan}Templates${ANSI.reset}`);
130
+ for (const entry of templates) {
131
+ lines.push(renderEntry(entry, index === state.cursor, state.selected.has(entry.name)));
132
+ index++;
133
+ }
134
+ }
135
+ if (state.visibleItems.length === 0) {
136
+ lines.push(`${ANSI.grey} No items match the filter.${ANSI.reset}`);
137
+ }
138
+ lines.push("");
139
+ lines.push(`${ANSI.grey}↑↓ navigate space toggle type to filter Enter install Esc clear filter q quit${ANSI.reset}`);
140
+ process.stdout.write(`${ANSI.clear}${ANSI.home}${lines.join("\n")}\n`);
141
+ }
142
+ export async function runInteractive(repositoryPath, options = {}) {
143
+ const force = options.force ?? false;
144
+ if (process.stdin.isTTY !== true) {
145
+ const entries = await listCatalog({ installedPath: repositoryPath });
146
+ console.log(formatCatalog(entries));
147
+ console.log("");
148
+ console.log("Run with a TTY for an interactive selection screen, or use: workflows add [--template <name>] [--force]");
149
+ return 0;
150
+ }
151
+ const entries = await listCatalog({ installedPath: repositoryPath });
152
+ let state = createSelectionState(entries);
153
+ readline.emitKeypressEvents(process.stdin);
154
+ process.stdin.setRawMode(true);
155
+ process.stdin.resume();
156
+ process.stdout.write(ANSI.hideCursor);
157
+ render(state);
158
+ return new Promise((resolve) => {
159
+ const onKeypress = (str, key) => {
160
+ if (key?.ctrl && key?.name === "c") {
161
+ cleanup();
162
+ resolve(0);
163
+ return;
164
+ }
165
+ if (str === "q" && state.filter === "" && !key?.ctrl && !key?.meta && !key?.shift) {
166
+ cleanup();
167
+ resolve(0);
168
+ return;
169
+ }
170
+ if (key?.name === "escape") {
171
+ state = clearFilter(state);
172
+ render(state);
173
+ return;
174
+ }
175
+ if (key?.name === "up") {
176
+ state = moveCursorUp(state);
177
+ render(state);
178
+ return;
179
+ }
180
+ if (key?.name === "down") {
181
+ state = moveCursorDown(state);
182
+ render(state);
183
+ return;
184
+ }
185
+ if (key?.name === "space" || str === " ") {
186
+ state = toggleSelection(state);
187
+ render(state);
188
+ return;
189
+ }
190
+ if (key?.name === "return" || key?.name === "enter") {
191
+ cleanup();
192
+ void installSelected(state, repositoryPath, force).then((exitCode) => resolve(exitCode));
193
+ return;
194
+ }
195
+ if (key?.name === "backspace") {
196
+ state = applyFilter(state, state.filter.slice(0, -1));
197
+ render(state);
198
+ return;
199
+ }
200
+ if (str !== undefined &&
201
+ str.length === 1 &&
202
+ str >= " " &&
203
+ str <= "~" &&
204
+ !key?.ctrl &&
205
+ !key?.meta) {
206
+ state = applyFilter(state, state.filter + str);
207
+ render(state);
208
+ }
209
+ };
210
+ function cleanup() {
211
+ process.stdin.setRawMode(false);
212
+ process.stdin.pause();
213
+ process.stdout.write(`${ANSI.showCursor}${ANSI.reset}`);
214
+ process.stdin.removeListener("keypress", onKeypress);
215
+ }
216
+ process.stdin.on("keypress", onKeypress);
217
+ });
218
+ }
219
+ async function installSelected(state, repositoryPath, force) {
220
+ const items = getItemsToInstall(state, force);
221
+ const templates = items.filter((entry) => entry.kind === "template");
222
+ const routeEntries = state.allItems.filter((entry) => entry.kind === "route");
223
+ const checkedRoutes = routeEntries
224
+ .filter((entry) => state.selected.has(entry.name))
225
+ .map((entry) => entry.name)
226
+ .filter((name) => routeNames.includes(name));
227
+ const installedRouteNames = routeEntries
228
+ .filter((entry) => entry.installed)
229
+ .map((entry) => entry.name)
230
+ .filter((name) => routeNames.includes(name));
231
+ const removedRoutes = installedRouteNames.filter((name) => !checkedRoutes.includes(name));
232
+ const allConflicts = [];
233
+ const allInstalled = [];
234
+ const inspection = await inspectRepository(repositoryPath);
235
+ // Desired state: the checked routes are the target set. Installing it also refreshes every
236
+ // package-managed file that is already there, so Enter on an unchanged selection is an update.
237
+ const result = await installCatalog(repositoryPath, { force, selectedRoutes: checkedRoutes, inspection });
238
+ allInstalled.push(...result.installed);
239
+ const allRemoved = await removeRouteFiles(repositoryPath, removedRoutes);
240
+ for (const template of templates) {
241
+ if (!isTemplateName(template.name))
242
+ continue;
243
+ const templateResult = await installTemplate(repositoryPath, template.name, { force, inspection });
244
+ allConflicts.push(...templateResult.conflicts);
245
+ allInstalled.push(...templateResult.installed);
246
+ }
247
+ if (allConflicts.length > 0 && !force) {
248
+ console.error(`Template conflicts found. Re-run with --force to overwrite:\n${allConflicts.join("\n")}`);
249
+ return 1;
250
+ }
251
+ const changed = result.changes.filter((change) => change.status === "added" || change.status === "updated" || change.status === "removed");
252
+ const skipped = result.changes.filter((change) => change.status === "skipped");
253
+ console.log(`Package ${result.packageVersion}${result.installedVersions.length > 0 ? ` (was ${result.installedVersions.join(", ")})` : ""}`);
254
+ if (changed.length === 0 && allRemoved.length === 0 && templates.length === 0) {
255
+ console.log("Everything is up to date.");
256
+ }
257
+ for (const change of changed) {
258
+ const kept = change.keptEnv?.length ? ` (kept env: ${change.keptEnv.join(", ")})` : "";
259
+ console.log(` ${change.status.padEnd(8)} ${change.target}${kept}`);
260
+ }
261
+ for (const change of skipped) {
262
+ console.log(` skipped ${change.target} (${change.reason ?? "consumer-owned"})`);
263
+ }
264
+ for (const file of allRemoved) {
265
+ console.log(` removed ${file}`);
266
+ }
267
+ for (const template of templates) {
268
+ console.log(` template ${template.name}`);
269
+ }
270
+ return 0;
271
+ }
@@ -0,0 +1,46 @@
1
+ export interface EnvEntry {
2
+ readonly key: string;
3
+ /** Comment and blank lines that precede the key. They belong to the key they describe. */
4
+ readonly leading: readonly string[];
5
+ /** The ` KEY: value` line, followed by any continuation lines of a multi-line value. */
6
+ readonly lines: readonly string[];
7
+ }
8
+ export interface WorkerEnv {
9
+ readonly present: boolean;
10
+ readonly before: readonly string[];
11
+ readonly entries: readonly EnvEntry[];
12
+ readonly trailing: readonly string[];
13
+ readonly after: readonly string[];
14
+ }
15
+ export interface WorkerMergeReport {
16
+ /** Consumer values kept because they differ from the package (and from the baseline, when known). */
17
+ readonly keptEnv: readonly string[];
18
+ /** Package defaults applied because the consumer still had the previous default. */
19
+ readonly updatedDefaults: readonly string[];
20
+ /** Keys only the consumer defines, kept at the end of the block. */
21
+ readonly consumerOnlyEnv: readonly string[];
22
+ /** Keys the package removed that the consumer still carried at the old default. */
23
+ readonly droppedEnv: readonly string[];
24
+ }
25
+ /** The `env:` block of a worker, which lives inside the markdown frontmatter. */
26
+ export declare function parseWorkerEnv(content: string): WorkerEnv;
27
+ /** The top-level `env:` block of a plain YAML workflow, such as the router. */
28
+ export declare function parseYamlEnv(content: string): WorkerEnv;
29
+ export declare function serializeWorkerEnv(env: WorkerEnv): string;
30
+ export declare function entryValue(entry: EnvEntry): string;
31
+ export declare function mergeWorkerEnv(packageContent: string, consumerContent: string, baselineContent?: string, parse?: (content: string) => WorkerEnv): {
32
+ content: string;
33
+ report: WorkerMergeReport;
34
+ };
35
+ /** Copy the router's own `env:` values into the two literal lines that cannot read them. */
36
+ export declare function mirrorRouterLiterals(router: string): string;
37
+ /** The package's router with the consumer's `env:` values put back and mirrored into place. */
38
+ export declare function mergeRouter(packageContent: string, consumerContent: string, baselineContent?: string): {
39
+ content: string;
40
+ report: WorkerMergeReport;
41
+ };
42
+ /** The package's worker with the consumer's customisable values put back. */
43
+ export declare function mergeWorker(packageContent: string, consumerContent: string, baselineContent?: string): {
44
+ content: string;
45
+ report: WorkerMergeReport;
46
+ };
@@ -0,0 +1,179 @@
1
+ // The `env:` block at the top of a worker is the only part of it a consumer may customise.
2
+ // Everything else in the file is the package's, so an update takes the package's file and
3
+ // puts the consumer's env values back. This module does that merge.
4
+ //
5
+ // With a baseline (the package version the consumer installed from) the merge is three-way:
6
+ // a value the consumer never touched follows the package when the default changes, and a
7
+ // value the consumer changed is kept. Without one it is two-way: every consumer value is kept.
8
+ const ENTRY = /^ ([A-Za-z_][A-Za-z0-9_.-]*):(.*)$/;
9
+ /** The `env:` block of a worker, which lives inside the markdown frontmatter. */
10
+ export function parseWorkerEnv(content) {
11
+ const lines = content.split("\n");
12
+ if (lines[0] !== "---")
13
+ return absent(lines);
14
+ const end = lines.findIndex((line, index) => index > 0 && line === "---");
15
+ if (end === -1)
16
+ return absent(lines);
17
+ return parseEnvBlock(lines, lines.findIndex((line, index) => index > 0 && index < end && line === "env:"));
18
+ }
19
+ /** The top-level `env:` block of a plain YAML workflow, such as the router. */
20
+ export function parseYamlEnv(content) {
21
+ const lines = content.split("\n");
22
+ return parseEnvBlock(lines, lines.indexOf("env:"));
23
+ }
24
+ function absent(lines) {
25
+ return { present: false, before: lines, entries: [], trailing: [], after: [] };
26
+ }
27
+ function parseEnvBlock(lines, envIndex) {
28
+ if (envIndex === -1)
29
+ return absent(lines);
30
+ // The block runs to the first line that is neither blank nor indented into it. That is the
31
+ // closing `---` of a frontmatter and the next top-level key of a plain YAML file alike.
32
+ let cursor = envIndex + 1;
33
+ while (cursor < lines.length && (lines[cursor] === "" || lines[cursor].startsWith(" ")))
34
+ cursor += 1;
35
+ const entries = [];
36
+ let leading = [];
37
+ let current;
38
+ for (const line of lines.slice(envIndex + 1, cursor)) {
39
+ const match = ENTRY.exec(line);
40
+ if (match !== null) {
41
+ if (current)
42
+ entries.push(current);
43
+ current = { key: match[1], leading, lines: [line] };
44
+ leading = [];
45
+ }
46
+ else if (current && leading.length === 0 && /^ +\S/.test(line)) {
47
+ current.lines.push(line);
48
+ }
49
+ else {
50
+ leading.push(line);
51
+ }
52
+ }
53
+ if (current)
54
+ entries.push(current);
55
+ return {
56
+ present: true,
57
+ before: lines.slice(0, envIndex + 1),
58
+ entries,
59
+ trailing: leading,
60
+ after: lines.slice(cursor),
61
+ };
62
+ }
63
+ export function serializeWorkerEnv(env) {
64
+ if (!env.present)
65
+ return env.before.join("\n");
66
+ return [
67
+ ...env.before,
68
+ ...env.entries.flatMap((entry) => [...entry.leading, ...entry.lines]),
69
+ ...env.trailing,
70
+ ...env.after,
71
+ ].join("\n");
72
+ }
73
+ export function entryValue(entry) {
74
+ const first = entry.lines[0].replace(ENTRY, "$2").trim();
75
+ return [first, ...entry.lines.slice(1).map((line) => line.trim())].join("\n").trim();
76
+ }
77
+ export function mergeWorkerEnv(packageContent, consumerContent, baselineContent, parse = parseWorkerEnv) {
78
+ const report = { keptEnv: [], updatedDefaults: [], consumerOnlyEnv: [], droppedEnv: [] };
79
+ const pkg = parse(packageContent);
80
+ const consumer = parse(consumerContent);
81
+ if (!pkg.present || !consumer.present)
82
+ return { content: packageContent, report };
83
+ const baseline = baselineContent === undefined ? undefined : parse(baselineContent);
84
+ const consumerByKey = new Map(consumer.entries.map((entry) => [entry.key, entry]));
85
+ const baselineByKey = new Map((baseline?.entries ?? []).map((entry) => [entry.key, entry]));
86
+ const packageKeys = new Set(pkg.entries.map((entry) => entry.key));
87
+ const merged = [];
88
+ for (const entry of pkg.entries) {
89
+ const mine = consumerByKey.get(entry.key);
90
+ if (mine === undefined || entryValue(mine) === entryValue(entry)) {
91
+ merged.push(entry);
92
+ continue;
93
+ }
94
+ const base = baselineByKey.get(entry.key);
95
+ if (base !== undefined && entryValue(base) === entryValue(mine)) {
96
+ // Still the previous default: the package changed it, the consumer never did.
97
+ merged.push(entry);
98
+ report.updatedDefaults.push(entry.key);
99
+ continue;
100
+ }
101
+ merged.push({ key: entry.key, leading: entry.leading, lines: mine.lines });
102
+ report.keptEnv.push(entry.key);
103
+ }
104
+ for (const entry of consumer.entries) {
105
+ if (packageKeys.has(entry.key))
106
+ continue;
107
+ const base = baselineByKey.get(entry.key);
108
+ if (base !== undefined && entryValue(base) === entryValue(entry)) {
109
+ report.droppedEnv.push(entry.key);
110
+ continue;
111
+ }
112
+ merged.push(entry);
113
+ report.consumerOnlyEnv.push(entry.key);
114
+ }
115
+ return { content: serializeWorkerEnv({ ...pkg, entries: merged }), report };
116
+ }
117
+ // Two more per-repository values live outside `env:` because GitHub gives them no other home:
118
+ // `runs-on` cannot read the env context, and the engine's gateway URL is read by the engine
119
+ // before the workflow's env exists.
120
+ function engineEndpoint(content) {
121
+ return /^ OPENAI_BASE_URL: (.+)$/m.exec(content)?.[1];
122
+ }
123
+ // Which self-hosted pool a worker's agent jobs run on. gh-aw cannot share runs-on through an
124
+ // import, so every worker names it. ubuntu-latest is excluded, being GitHub's own runner, used
125
+ // by the deterministic jobs everywhere and chosen by nobody.
126
+ function runnerPools(content) {
127
+ const found = new Set();
128
+ for (const match of content.matchAll(/^\s*runs-on(?:-slim)?: (\S+)\s*$/gm)) {
129
+ if (match[1] !== "ubuntu-latest")
130
+ found.add(match[1]);
131
+ }
132
+ return [...found];
133
+ }
134
+ function preserveRunnerPool(packageContent, consumerContent) {
135
+ const mine = runnerPools(consumerContent);
136
+ const theirs = runnerPools(packageContent);
137
+ // Only an unambiguous swap. A consumer naming several pools has drifted rather than
138
+ // decided, and guessing which it meant is worse than leaving the package's.
139
+ if (mine.length !== 1 || theirs.length !== 1 || mine[0] === theirs[0])
140
+ return packageContent;
141
+ return packageContent.replace(/^(\s*runs-on(?:-slim)?: )(\S+)(\s*)$/gm, (line, prefix, pool, tail) => pool === "ubuntu-latest" ? line : `${prefix}${mine[0]}${tail}`);
142
+ }
143
+ // GitHub evaluates no expression in a `workflow_run.workflows:` list or in a `cron:`, so the two
144
+ // router values that are also needed there cannot be read from `env:` at those two lines. The
145
+ // installer copies them in instead, which is what keeps the pair in step across an update; the
146
+ // route matrix asserts the copies still agree.
147
+ const CI_NAME_TRIGGER = /^(\s*workflows: \[")[^"]*("\]\s*)$/m;
148
+ const AUDIT_CRON_LINE = /^(\s*- cron: ")[^"]*(" # audit slot.*)$/m;
149
+ /** Copy the router's own `env:` values into the two literal lines that cannot read them. */
150
+ export function mirrorRouterLiterals(router) {
151
+ const values = new Map(parseYamlEnv(router).entries.map((entry) => [entry.key, unquote(entryValue(entry))]));
152
+ const ciName = values.get("CI_WORKFLOW_NAME");
153
+ const auditCron = values.get("AUDIT_CRON");
154
+ let result = router;
155
+ if (ciName !== undefined)
156
+ result = result.replace(CI_NAME_TRIGGER, `$1${ciName}$2`);
157
+ if (auditCron !== undefined)
158
+ result = result.replace(AUDIT_CRON_LINE, `$1${auditCron}$2`);
159
+ return result;
160
+ }
161
+ function unquote(value) {
162
+ const match = /^(["'])([\s\S]*)\1$/.exec(value);
163
+ return match === null ? value : match[2];
164
+ }
165
+ /** The package's router with the consumer's `env:` values put back and mirrored into place. */
166
+ export function mergeRouter(packageContent, consumerContent, baselineContent) {
167
+ const { content, report } = mergeWorkerEnv(packageContent, consumerContent, baselineContent, parseYamlEnv);
168
+ return { content: mirrorRouterLiterals(content), report };
169
+ }
170
+ /** The package's worker with the consumer's customisable values put back. */
171
+ export function mergeWorker(packageContent, consumerContent, baselineContent) {
172
+ const { content: envMerged, report } = mergeWorkerEnv(packageContent, consumerContent, baselineContent);
173
+ let result = envMerged;
174
+ const endpoint = engineEndpoint(consumerContent);
175
+ if (endpoint !== undefined) {
176
+ result = result.replace(/^ OPENAI_BASE_URL: .+$/m, ` OPENAI_BASE_URL: ${endpoint}`);
177
+ }
178
+ return { content: preserveRunnerPool(result, consumerContent), report };
179
+ }
@@ -0,0 +1,27 @@
1
+ export declare const routeNames: readonly ["refine", "implement", "triage", "apply-review", "merge-gate", "audit", "release"];
2
+ export type RouteName = (typeof routeNames)[number];
3
+ export interface WorkflowRoute {
4
+ readonly name: RouteName;
5
+ readonly worker: string;
6
+ readonly description: string;
7
+ readonly defaultEnabled: boolean;
8
+ }
9
+ export declare const workflowRoutes: readonly WorkflowRoute[];
10
+ export declare const packageOwnedTargets: readonly [".github/actions", ".github/workflows/agent-*.md", ".github/workflows/shared/platform-defaults.md", ".github/workflows/shared/opencode-ci.md", ".github/workflows/work-router.yml", "scripts/compile-agent-workflows.mjs", "opencode.ci.json"];
11
+ export interface MandatoryFile {
12
+ readonly source: string;
13
+ readonly target: string;
14
+ }
15
+ export declare const mandatoryFiles: readonly MandatoryFile[];
16
+ export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
17
+ export declare const templateNames: readonly ["agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json"];
18
+ export type TemplateName = (typeof templateNames)[number];
19
+ export interface CatalogTemplate {
20
+ readonly name: TemplateName;
21
+ readonly file: string;
22
+ readonly description: string;
23
+ readonly target?: string;
24
+ /** Subdirectory of `templates/` holding the file. Defaults to "agentics". */
25
+ readonly directory?: string;
26
+ }
27
+ export declare const catalogTemplates: readonly CatalogTemplate[];