@plainconceptsplatform/workflows 0.6.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 (57) hide show
  1. package/README.md +105 -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 +385 -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 +847 -170
  26. package/loops/templates/agentics/agentics-error-report.yml +97 -0
  27. package/loops/templates/opencode/opencode.ci.json +1 -1
  28. package/loops/workflows/agent-apply-review.md +452 -469
  29. package/loops/workflows/agent-audit.md +201 -213
  30. package/loops/workflows/agent-implement.md +616 -640
  31. package/loops/workflows/agent-merge-gate.md +830 -844
  32. package/loops/workflows/agent-refine.md +599 -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/work-router.yml +1185 -1038
  37. package/package.json +9 -8
  38. package/dist/action-validation.test.d.ts +0 -1
  39. package/dist/action-validation.test.js +0 -87
  40. package/dist/catalog-installation.test.d.ts +0 -1
  41. package/dist/catalog-installation.test.js +0 -485
  42. package/dist/catalog-listing.test.d.ts +0 -1
  43. package/dist/catalog-listing.test.js +0 -150
  44. package/dist/index.test.d.ts +0 -1
  45. package/dist/index.test.js +0 -273
  46. package/dist/repository-inspection.test.d.ts +0 -1
  47. package/dist/repository-inspection.test.js +0 -77
  48. package/dist/route-processing.test.d.ts +0 -1
  49. package/dist/route-processing.test.js +0 -283
  50. package/dist/stack-defaults.test.d.ts +0 -1
  51. package/dist/stack-defaults.test.js +0 -266
  52. package/dist/tui.test.d.ts +0 -1
  53. package/dist/tui.test.js +0 -249
  54. package/dist/workflow-catalog.test.d.ts +0 -1
  55. package/dist/workflow-catalog.test.js +0 -29
  56. package/loops/actions/stale-recovery/action.yml +0 -288
  57. package/loops/actions/update-changelog/action.yml +0 -113
package/README.md CHANGED
@@ -1,88 +1,105 @@
1
- # Workflows CLI
2
-
3
- Install and update shared GitHub Agentic Workflows for Plain Concepts Platform repositories.
4
-
5
- ## Quick start
6
-
7
- The primary entrypoint is the interactive TUI. Run it with no arguments:
8
-
9
- ```bash
10
- npx @plainconceptsplatform/workflows
11
- ```
12
-
13
- The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs. Selecting routes installs only those route workers plus mandatory files (opencode.ci.json, compile script, shared imports, actions, router, classifier, route matrix). Selecting only templates installs just those templates.
14
-
15
- ## Install
16
-
17
- Before installing workflows, install and configure [`PlainConceptsPlatform/agent-harness`](https://github.com/PlainConceptsPlatform/agent-harness) in the consumer repository. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available before compiling workflows.
18
-
19
- For non-interactive use (advanced):
20
-
21
- ```bash
22
- npx @plainconceptsplatform/workflows@latest init
23
- npx @plainconceptsplatform/workflows@latest add
24
- npx @plainconceptsplatform/workflows@latest update
25
- ```
26
-
27
- For a project-local development dependency:
28
-
29
- ```bash
30
- pnpm add -D @plainconceptsplatform/workflows
31
- pnpm exec workflows # launch interactive TUI
32
- pnpm exec workflows init
33
- pnpm exec workflows add
34
- ```
35
-
36
- `init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
37
-
38
- `add` installs mandatory files (opencode.ci.json, compile script, shared imports, actions, router, classifier, route matrix) when called with no route arguments. Pass route names as positional arguments to install specific route workers alongside the mandatory files:
39
-
40
- ```bash
41
- workflows add # mandatory files only, no worker .md files
42
- workflows add implement refine direct # those route workers plus mandatory files
43
- workflows add --template agentics-checks # named template only (no mandatory files)
44
- workflows add refine --template agentics-checks --force # routes + template + mandatory, overwriting conflicts
45
- ```
46
-
47
- Route names: refine, implement, direct, apply-review, merge-gate, audit, propose. Unknown arguments produce an error.
48
-
49
- Use `workflows update --force` to force-overwrite managed files that differ from the package source.
50
-
51
- Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, and `github-release`. CI templates are stack-specific copies, not a combined template. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
52
-
53
- ## List and search
54
-
55
- List all available workflows, routes, and templates with install status:
56
-
57
- ```bash
58
- npx --yes --package @plainconceptsplatform/workflows@latest workflows list
59
- ```
60
-
61
- Each entry is marked `[x]` when the corresponding `.github/workflows/agent-*.md` file already exists in the current directory, or `[ ]` when it is not yet installed.
62
-
63
- Search by name or description:
64
-
65
- ```bash
66
- npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci"
67
- ```
68
-
69
- ## Manual installation
70
-
71
- The package includes `loops/`, a copyable equivalent of `.github/`:
72
-
73
- - `loops/actions/` maps to `.github/actions/`
74
- - `loops/workflows/` maps to `.github/workflows/`
75
- - `loops/scripts/` maps to `scripts/`
76
-
77
- Copy these files manually if you do not use the CLI. Each worker is self-contained. Edit its
78
- top-level `env:` defaults directly for consumer-specific endpoint, model, labels, paths, and checks.
79
-
80
- ## Compile
81
-
82
- Consumer repositories generate and commit `*.lock.yml` files:
83
-
84
- ```bash
85
- node scripts/compile-agent-workflows.mjs --force
86
- ```
87
-
88
- Do not commit generated locks to this package source repository.
1
+ # Workflows CLI
2
+
3
+ Install and update shared GitHub Agentic Workflows for Plain Concepts Platform repositories.
4
+
5
+ ## Quick start
6
+
7
+ The primary entrypoint is the interactive TUI. Run it with no arguments:
8
+
9
+ ```bash
10
+ npx @plainconceptsplatform/workflows
11
+ ```
12
+
13
+ The TUI lists all routes and templates with install status. Arrow keys navigate, space toggles, Enter installs. The checked routes are the target set: checking adds a worker, unchecking removes it, and Enter on an unchanged selection refreshes every installed package file to this version. Selecting only templates installs just those templates.
14
+
15
+ ## Install
16
+
17
+ Before installing workflows, install and configure [`PlainConceptsPlatform/agent-harness`](https://github.com/PlainConceptsPlatform/agent-harness) in the consumer repository. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available before compiling workflows.
18
+
19
+ For non-interactive use (advanced):
20
+
21
+ ```bash
22
+ npx @plainconceptsplatform/workflows@latest init
23
+ npx @plainconceptsplatform/workflows@latest add refine implement
24
+ npx @plainconceptsplatform/workflows@latest update --dry-run
25
+ npx @plainconceptsplatform/workflows@latest update
26
+ ```
27
+
28
+ For a project-local development dependency:
29
+
30
+ ```bash
31
+ pnpm add -D @plainconceptsplatform/workflows
32
+ pnpm exec workflows # launch interactive TUI
33
+ pnpm exec workflows init
34
+ pnpm exec workflows add
35
+ ```
36
+
37
+ `init` inspects the repository and reports its stack and visibility. It does not create or manage repository configuration or a manifest.
38
+
39
+ `add` installs the mandatory files (opencode.ci.json, compile script, shared imports, actions, router, classifier, route matrix) and the workers named as positional arguments, on top of the workers already installed. With no routes it refreshes what is there:
40
+
41
+ ```bash
42
+ workflows add # refresh everything installed; in an empty repository, no workers
43
+ workflows add implement refine # those workers on top of the installed ones
44
+ workflows add --template agentics-checks # named template only (no mandatory files)
45
+ workflows add refine --template agentics-checks --force # routes + template + mandatory; --force also replaces a changed template
46
+ workflows remove audit # uninstall the audit worker and drop it from the router
47
+ ```
48
+
49
+ Route names: refine, implement, triage, apply-review, merge-gate, audit, release. Unknown arguments produce an error.
50
+
51
+ ## Update
52
+
53
+ `update` is `add` with no routes: it refreshes exactly the installed set to this package version. Every package-managed file is replaced and its ownership header records the version it came from:
54
+
55
+ ```
56
+ # Managed by @plainconceptsplatform/workflows@0.7.0. Source: loops/workflows/work-router.yml. ...
57
+ ```
58
+
59
+ The `env:` block at the top of a worker is the repository's and survives: your values are kept, keys the package added arrive with their defaults, keys only you defined stay. When the header records the version you installed from, that release is fetched from npm and used as the merge baseline, so a value you never changed follows the package when its default changes. The agent runner pool and the engine gateway URL are kept as well. Everything else in the file is the package's.
60
+
61
+ - `update --dry-run` prints the plan as JSON and writes nothing.
62
+ - A file whose ownership header was removed is consumer-owned and is skipped unless `--force` is passed.
63
+ - Templates are consumer-owned after installation and are replaced only with `--force`.
64
+ - `--version` prints the package version.
65
+
66
+ ## List and search
67
+
68
+ List all available workflows, routes, and templates with install status:
69
+
70
+ ```bash
71
+ npx --yes --package @plainconceptsplatform/workflows@latest workflows list
72
+ ```
73
+
74
+ Each entry is marked `[x]` when the corresponding `.github/workflows/agent-*.md` file already exists in the current directory, or `[ ]` when it is not yet installed.
75
+
76
+ Search by name or description:
77
+
78
+ ```bash
79
+ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci"
80
+ ```
81
+
82
+ ## Templates
83
+
84
+ Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, and `opencode.ci.json`. CI templates are stack-specific copies, not a combined template. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
85
+
86
+ ## Manual installation
87
+
88
+ The package includes `loops/`, a copyable equivalent of `.github/`:
89
+
90
+ - `loops/actions/` maps to `.github/actions/`
91
+ - `loops/workflows/` maps to `.github/workflows/`
92
+ - `loops/scripts/` maps to `scripts/`
93
+
94
+ Copy these files manually if you do not use the CLI. Each worker is self-contained. Edit its
95
+ top-level `env:` defaults directly for consumer-specific endpoint, model, labels, paths, and checks.
96
+
97
+ ## Compile
98
+
99
+ Consumer repositories generate and commit `*.lock.yml` files:
100
+
101
+ ```bash
102
+ node scripts/compile-agent-workflows.mjs --force
103
+ ```
104
+
105
+ Do not commit generated locks to this package source repository.
@@ -1,15 +1,49 @@
1
1
  import { type RouteName, type TemplateName } from "./workflow-catalog.js";
2
+ import { type BaselineFetcher } from "./package-baseline.js";
2
3
  import type { RepositoryInspection } from "./repository-inspection.js";
4
+ export type ChangeStatus = "added" | "updated" | "unchanged" | "skipped" | "removed";
5
+ export interface FileChange {
6
+ readonly target: string;
7
+ readonly status: ChangeStatus;
8
+ /** Why a file was skipped. */
9
+ readonly reason?: string;
10
+ /** Version the consumer's copy was installed from, when its header records one. */
11
+ readonly installedVersion?: string;
12
+ readonly keptEnv?: readonly string[];
13
+ readonly updatedDefaults?: readonly string[];
14
+ readonly consumerOnlyEnv?: readonly string[];
15
+ readonly droppedEnv?: readonly string[];
16
+ }
17
+ export interface BaselineStatus {
18
+ readonly version: string;
19
+ readonly status: "used" | "unavailable";
20
+ }
3
21
  export interface CatalogInstallResult {
22
+ /** Every package-managed target in the installed set, whether it changed or not. */
4
23
  readonly installed: readonly string[];
24
+ /** Consumer-owned files that differ and were not overwritten. Only templates report these. */
5
25
  readonly conflicts: readonly string[];
26
+ readonly changes: readonly FileChange[];
27
+ readonly packageVersion: string;
28
+ /** Versions found in the consumer's ownership headers before this run. */
29
+ readonly installedVersions: readonly string[];
30
+ readonly baselines: readonly BaselineStatus[];
31
+ readonly upToDate: boolean;
32
+ readonly dryRun: boolean;
6
33
  }
7
34
  export interface CatalogInstallOptions {
35
+ /** Also overwrite files whose ownership header was removed, and changed templates. */
8
36
  readonly force?: boolean;
9
37
  readonly sourcePath?: string;
10
38
  readonly selectedRoutes?: readonly RouteName[];
11
39
  readonly inspection?: RepositoryInspection;
12
40
  readonly compile?: (repositoryPath: string) => Promise<void>;
41
+ /** Compute the plan and write nothing. */
42
+ readonly dryRun?: boolean;
43
+ /** Where the baseline release comes from. Defaults to npm. */
44
+ readonly baseline?: BaselineFetcher;
45
+ /** The version stamped into headers. Defaults to this package's. */
46
+ readonly packageVersion?: string;
13
47
  }
14
48
  interface CatalogFile {
15
49
  readonly source: string;
@@ -19,8 +53,11 @@ interface CatalogFile {
19
53
  export declare function mandatoryFileSpecs(sourcePath: string): CatalogFile[];
20
54
  export declare function catalogSourcePath(modulePath?: string): string;
21
55
  export declare function installCatalog(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
22
- export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
23
- export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
56
+ export interface TemplateInstallResult {
57
+ readonly installed: readonly string[];
58
+ readonly conflicts: readonly string[];
59
+ }
60
+ export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: Pick<CatalogInstallOptions, "force" | "sourcePath" | "inspection">): Promise<TemplateInstallResult>;
24
61
  export declare function installedRoutes(repositoryPath: string): Promise<RouteName[]>;
25
62
  export declare function removeRouteFiles(repositoryPath: string, routes: readonly RouteName[]): Promise<string[]>;
26
63
  export declare function isTemplateName(value: string): value is TemplateName;
@@ -8,6 +8,8 @@ import { parse as parseYaml } from "yaml";
8
8
  import { catalogTemplates, mandatoryFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
9
9
  import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
10
10
  import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
11
+ import { mergeRouter, mergeWorker } from "./worker-env.js";
12
+ import { fetchBaseline, hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
11
13
  const execFileAsync = promisify(execFile);
12
14
  const sourceMappings = [
13
15
  ["actions", ".github/actions"],
@@ -24,116 +26,187 @@ export function mandatoryFileSpecs(sourcePath) {
24
26
  export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
25
27
  return resolve(dirname(modulePath), "..", "loops");
26
28
  }
29
+ const isWorker = (target) => target.startsWith(".github/workflows/agent-") && target.endsWith(".md");
30
+ const isRouter = (target) => target === ".github/workflows/work-router.yml";
31
+ const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs)$/.test(target);
32
+ const normalizeEol = (text) => text.replaceAll("\r\n", "\n");
27
33
  export async function installCatalog(repositoryPath, options = {}) {
34
+ const version = options.packageVersion ?? await packageVersion();
28
35
  const sourcePath = options.sourcePath ?? catalogSourcePath();
29
36
  const selectedRoutes = options.selectedRoutes ?? routeNames;
30
37
  const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
31
38
  const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
32
39
  const excluded = excludedWorkerFiles(selectedRoutes);
33
- const filtered = deduplicated.filter((file) => {
34
- const fileName = file.target.split("/").pop() ?? "";
35
- return !excluded.has(fileName);
36
- });
37
- const fileContents = new Map();
40
+ const filtered = deduplicated.filter((file) => !excluded.has(file.target.split("/").pop() ?? ""));
41
+ const packageContents = new Map();
38
42
  for (const file of filtered) {
39
- fileContents.set(file.target, await readFile(file.source, "utf8"));
43
+ packageContents.set(file.target, normalizeEol(await readFile(file.source, "utf8")));
44
+ }
45
+ let processed = processRoutes(packageContents, selectedRoutes);
46
+ // Read and write LF, always. Every one of these files ends up on a Linux runner, the package
47
+ // and every consumer declare `* text=auto eol=lf`, and a shell script with CRLF fails on its
48
+ // shebang. Preserving whatever a file happened to have instead only carried legacy CRLF
49
+ // forward, which git then reported as needing normalisation on every later commit.
50
+ const existing = new Map();
51
+ for (const target of processed.keys()) {
52
+ const path = join(repositoryPath, target);
53
+ if (!await exists(path))
54
+ continue;
55
+ existing.set(target, normalizeEol(await readFile(path, "utf8")));
40
56
  }
41
- let processedContents = processRoutes(fileContents, selectedRoutes);
42
57
  if (options.inspection !== undefined) {
43
- const defaults = generateStackDefaults(options.inspection);
44
- processedContents = injectStackIntoWorkers(processedContents, defaults);
45
- processedContents = transformOpencodeFiles(processedContents, options.inspection);
58
+ processed = applyStackDefaults(processed, options.inspection, existing);
46
59
  }
47
- processedContents = await preserveConsumerWorkerEnv(repositoryPath, processedContents);
48
- const updates = [...processedContents.entries()]
49
- .filter(([target]) => filtered.find((file) => file.target === target)?.managed ?? true)
50
- .map(([target, content]) => ({ target, content }));
51
- const conflicts = await conflictingTargets(repositoryPath, updates);
52
- if (conflicts.length > 0 && !options.force)
53
- return { installed: [], conflicts };
54
- const stagedLocks = await validateStagedCatalog(repositoryPath, updates, options.compile);
55
- await applyTransaction(repositoryPath, [...updates, ...stagedLocks, await preCommitHookUpdate(repositoryPath)]);
56
- return { installed: [...processedContents.keys()].sort(), conflicts };
57
- }
58
- function injectStackIntoWorkers(files, defaults) {
59
- const result = new Map(files);
60
- for (const [key, content] of result) {
61
- if (key.startsWith(".github/workflows/agent-") && key.endsWith(".md")) {
62
- result.set(key, injectStackEnv(content, defaults));
60
+ const fetcher = options.baseline ?? fetchBaseline;
61
+ const baselineDirectories = new Map();
62
+ const baselineFor = (installed) => {
63
+ let pending = baselineDirectories.get(installed);
64
+ if (pending === undefined) {
65
+ pending = fetcher(installed);
66
+ baselineDirectories.set(installed, pending);
67
+ }
68
+ return pending;
69
+ };
70
+ const changes = [];
71
+ const updates = [];
72
+ const installedVersions = new Set();
73
+ for (const [target, packageText] of processed) {
74
+ const current = existing.get(target);
75
+ let text = packageText;
76
+ let change = { target, status: "added" };
77
+ if (current !== undefined) {
78
+ const recorded = carriesHeader(target) ? installedVersion(current) : undefined;
79
+ if (recorded !== undefined)
80
+ installedVersions.add(recorded);
81
+ if (isWorker(target) || isRouter(target)) {
82
+ const baselineText = recorded === undefined ? undefined : await baselineSource(await baselineFor(recorded), target);
83
+ const merge = isRouter(target) ? mergeRouter : mergeWorker;
84
+ const merged = merge(packageText, current, baselineText);
85
+ text = merged.content;
86
+ change = {
87
+ target,
88
+ status: "updated",
89
+ ...(recorded === undefined ? {} : { installedVersion: recorded }),
90
+ ...(merged.report.keptEnv.length > 0 ? { keptEnv: merged.report.keptEnv } : {}),
91
+ ...(merged.report.updatedDefaults.length > 0 ? { updatedDefaults: merged.report.updatedDefaults } : {}),
92
+ ...(merged.report.consumerOnlyEnv.length > 0 ? { consumerOnlyEnv: merged.report.consumerOnlyEnv } : {}),
93
+ ...(merged.report.droppedEnv.length > 0 ? { droppedEnv: merged.report.droppedEnv } : {}),
94
+ };
95
+ }
96
+ else {
97
+ change = { target, status: "updated", ...(recorded === undefined ? {} : { installedVersion: recorded }) };
98
+ }
99
+ }
100
+ text = stampVersion(text, version);
101
+ if (current !== undefined && text === current) {
102
+ change = { ...change, status: "unchanged" };
63
103
  }
104
+ else if (current !== undefined && carriesHeader(target) && !hasOwnershipHeader(current) && !options.force) {
105
+ // Removing the ownership header is how a consumer takes a file over.
106
+ change = { target, status: "skipped", reason: "consumer-owned: the ownership header was removed; pass --force to reclaim it" };
107
+ }
108
+ else {
109
+ updates.push({ target, content: text });
110
+ }
111
+ changes.push(change);
112
+ }
113
+ const removals = await orphanedManagedFiles(repositoryPath, new Set(processed.keys()));
114
+ for (const target of removals)
115
+ changes.push({ target, status: "removed" });
116
+ const baselines = [];
117
+ for (const [requested, pending] of baselineDirectories) {
118
+ baselines.push({ version: requested, status: (await pending) === undefined ? "unavailable" : "used" });
119
+ }
120
+ const result = {
121
+ installed: [...processed.keys()].sort(),
122
+ conflicts: [],
123
+ changes,
124
+ packageVersion: version,
125
+ installedVersions: [...installedVersions].sort(),
126
+ baselines,
127
+ upToDate: updates.length === 0 && removals.length === 0,
128
+ dryRun: options.dryRun ?? false,
129
+ };
130
+ if (options.dryRun)
131
+ return result;
132
+ // Deletions go first: a pruned action must be gone before the compile reads the tree, or a
133
+ // worker still referencing it would compile against a file that is about to disappear.
134
+ await removeFiles(repositoryPath, removals);
135
+ if (updates.length > 0) {
136
+ const stagedLocks = await validateStagedCatalog(repositoryPath, updates, options.compile);
137
+ await applyTransaction(repositoryPath, [...updates, ...stagedLocks, await preCommitHookUpdate(repositoryPath)]);
138
+ }
139
+ else {
140
+ await applyTransaction(repositoryPath, [await preCommitHookUpdate(repositoryPath)]);
64
141
  }
65
142
  return result;
66
143
  }
67
- async function preserveConsumerWorkerEnv(repositoryPath, files) {
144
+ // Stack defaults are derived from the repository, not chosen by a person, so the derived files
145
+ // (the shared CI setup and the OpenCode config) get them on every run and stay stable. A worker
146
+ // gets its VERIFY_COMMANDS default once, when it is first installed: after that the value is the
147
+ // consumer's, and the env merge keeps it.
148
+ function applyStackDefaults(files, inspection, existing) {
149
+ const defaults = generateStackDefaults(inspection);
68
150
  const result = new Map(files);
69
151
  for (const [target, content] of result) {
70
- if (!target.startsWith(".github/workflows/agent-") || !target.endsWith(".md"))
71
- continue;
72
- const existingPath = join(repositoryPath, target);
73
- if (!await exists(existingPath))
74
- continue;
75
- result.set(target, mergeWorkerEnv(content, await readFile(existingPath, "utf8")));
152
+ if (isWorker(target) && !existing.has(target)) {
153
+ result.set(target, injectStackEnv(content, defaults));
154
+ }
155
+ else if (target.endsWith("opencode-ci.md")) {
156
+ result.set(target, generateOpencodeCi(content, inspection));
157
+ }
158
+ else if (target === "opencode.ci.json") {
159
+ result.set(target, generateOpencodeConfig(content, inspection));
160
+ }
76
161
  }
77
162
  return result;
78
163
  }
79
- function mergeWorkerEnv(packageContent, consumerContent) {
80
- const consumerEnv = workerEnvValues(consumerContent);
81
- let result = packageContent.replace(/^ ([A-Z][A-Z0-9_]*): .+$/gm, (line, key) => consumerEnv.has(key) ? ` ${key}: ${consumerEnv.get(key)}` : line);
82
- const endpoint = engineEndpoint(consumerContent);
83
- if (endpoint !== undefined) {
84
- result = result.replace(/^ OPENAI_BASE_URL: .+$/m, ` OPENAI_BASE_URL: ${endpoint}`);
85
- }
86
- return preserveRunnerPool(result, consumerContent);
87
- }
88
- // Which self-hosted pool a worker's agent jobs run on. gh-aw cannot share runs-on through
89
- // an import, so every worker names it, and it belongs with the env values rather than with
90
- // the prompt: a forced update that rewrote it would move a repository's agents onto another
91
- // pool, and one of ours has two machines in it. ubuntu-latest is excluded, being GitHub's
92
- // own runner, used by the deterministic jobs everywhere and chosen by nobody.
93
- function runnerPools(content) {
94
- const found = new Set();
95
- for (const match of content.matchAll(/^\s*runs-on(?:-slim)?: (\S+)\s*$/gm)) {
96
- if (match[1] !== "ubuntu-latest")
97
- found.add(match[1]);
98
- }
99
- return [...found];
100
- }
101
- function preserveRunnerPool(packageContent, consumerContent) {
102
- const mine = runnerPools(consumerContent);
103
- const theirs = runnerPools(packageContent);
104
- // Only an unambiguous swap. A consumer naming several pools has drifted rather than
105
- // decided, and guessing which it meant is worse than leaving the package's.
106
- if (mine.length !== 1 || theirs.length !== 1 || mine[0] === theirs[0])
107
- return packageContent;
108
- return packageContent.replace(/^(\s*runs-on(?:-slim)?: )(\S+)(\s*)$/gm, (line, prefix, pool, tail) => pool === "ubuntu-latest" ? line : `${prefix}${mine[0]}${tail}`);
109
- }
110
- function workerEnvValues(content) {
111
- const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/m.exec(content)?.[1];
112
- const envBlock = frontmatter === undefined ? undefined : /^env:\r?\n((?: .*\r?\n?)*)/m.exec(frontmatter)?.[1];
113
- const values = new Map();
114
- if (envBlock === undefined)
115
- return values;
116
- for (const line of envBlock.split(/\r?\n/)) {
117
- const match = /^ ([A-Z][A-Z0-9_]*): (.+)$/.exec(line);
118
- if (match !== null)
119
- values.set(match[1], match[2]);
164
+ // Directories that belong wholly to the package. A file here that carries our ownership header
165
+ // and is no longer in the package was deleted upstream, and without this it would sit in every
166
+ // consumer forever: that is how `stale-recovery` and `update-changelog` outlived the code that
167
+ // called them. Deliberately not `.github/workflows/`, where a worker's absence means the route
168
+ // is not installed rather than gone, and never anything without a header, which is a fork.
169
+ const pruneRoots = [".github/actions", ".github/workflows/shared"];
170
+ async function orphanedManagedFiles(repositoryPath, keep) {
171
+ const orphans = [];
172
+ for (const root of pruneRoots) {
173
+ const directory = join(repositoryPath, root);
174
+ if (!await exists(directory))
175
+ continue;
176
+ for (const file of await filesIn(directory)) {
177
+ const target = `${root}/${file.replaceAll("\\", "/")}`;
178
+ if (keep.has(target))
179
+ continue;
180
+ if (!carriesHeader(target) && !target.endsWith(".cjs") && !target.endsWith(".js"))
181
+ continue;
182
+ const content = await readFile(join(directory, file), "utf8");
183
+ if (hasOwnershipHeader(content))
184
+ orphans.push(target);
185
+ }
120
186
  }
121
- return values;
187
+ return orphans.sort();
122
188
  }
123
- function engineEndpoint(content) {
124
- return /^ OPENAI_BASE_URL: (.+)$/m.exec(content)?.[1];
125
- }
126
- function transformOpencodeFiles(files, inspection) {
127
- const result = new Map(files);
128
- for (const [key, content] of result) {
129
- if (key.endsWith("opencode-ci.md")) {
130
- result.set(key, generateOpencodeCi(content, inspection));
189
+ async function removeFiles(repositoryPath, targets) {
190
+ for (const target of targets) {
191
+ await rm(join(repositoryPath, target), { force: true });
192
+ // An action is a directory with one manifest in it; leaving the empty shell behind is litter.
193
+ const directory = dirname(join(repositoryPath, target));
194
+ try {
195
+ if ((await readdir(directory)).length === 0)
196
+ await rm(directory, { recursive: true, force: true });
131
197
  }
132
- else if (key === "opencode.ci.json") {
133
- result.set(key, generateOpencodeConfig(content, inspection));
198
+ catch {
199
+ // the directory is gone or not empty, either of which is fine
134
200
  }
135
201
  }
136
- return result;
202
+ }
203
+ async function baselineSource(loops, target) {
204
+ if (loops === undefined)
205
+ return undefined;
206
+ const path = join(loops, "workflows", target.slice(".github/workflows/".length));
207
+ if (!await exists(path))
208
+ return undefined;
209
+ return normalizeEol(await readFile(path, "utf8"));
137
210
  }
138
211
  // Templates that need a companion config beside the workflow file. actionlint only
139
212
  // knows GitHub-hosted runner labels, so every workflow naming a self-hosted label is an
@@ -180,19 +253,6 @@ export async function installTemplate(repositoryPath, template, options = {}) {
180
253
  }
181
254
  return { installed: (await Promise.all(destinations.map(async (entry) => await exists(entry.source) ? entry.target : undefined))).filter((file) => file !== undefined), conflicts };
182
255
  }
183
- export async function installMandatoryFiles(repositoryPath, options = {}) {
184
- const sourcePath = options.sourcePath ?? catalogSourcePath();
185
- const files = mandatoryFileSpecs(sourcePath).sort((left, right) => left.target.localeCompare(right.target));
186
- const conflicts = (await Promise.all(files.map(async (file) => {
187
- const destination = join(repositoryPath, file.target);
188
- return await exists(destination) && !(await filesMatch(file.source, destination)) ? file.target : undefined;
189
- }))).filter((file) => file !== undefined);
190
- if (conflicts.length > 0 && !options.force)
191
- return { installed: [], conflicts };
192
- const updates = await Promise.all(files.map(async (file) => ({ target: file.target, content: await readFile(file.source, "utf8") })));
193
- await applyTransaction(repositoryPath, [...updates, await preCommitHookUpdate(repositoryPath)]);
194
- return { installed: files.map((file) => file.target), conflicts };
195
- }
196
256
  export async function installedRoutes(repositoryPath) {
197
257
  const found = await Promise.all(workflowRoutes.map(async (route) => (await exists(join(repositoryPath, ".github", "workflows", route.worker))) ? route.name : undefined));
198
258
  return found.filter((name) => name !== undefined);
@@ -253,15 +313,6 @@ export async function runCompileIfAvailable(repositoryPath) {
253
313
  await execFileAsync("node", [script, "--force"], { cwd: repositoryPath });
254
314
  }
255
315
  }
256
- async function conflictingTargets(repositoryPath, updates) {
257
- const conflicts = (await Promise.all(updates.map(async ({ target, content }) => {
258
- const destination = join(repositoryPath, target);
259
- if (!await exists(destination))
260
- return undefined;
261
- return (await readFile(destination, "utf8")) === content ? undefined : target;
262
- }))).filter((target) => target !== undefined);
263
- return [...new Set(conflicts)].sort();
264
- }
265
316
  async function validateStagedCatalog(repositoryPath, updates, compileOverride) {
266
317
  const temporaryRoot = join(repositoryPath, ".opencode", ".tmp");
267
318
  await mkdir(temporaryRoot, { recursive: true });
@@ -280,10 +331,17 @@ async function validateStagedCatalog(repositoryPath, updates, compileOverride) {
280
331
  await rm(stagingPath, { force: true, recursive: true });
281
332
  }
282
333
  }
334
+ // The compile needs the workflows and the compile script. An update that changes neither still
335
+ // recompiles against the consumer's current copies, so both come along.
283
336
  async function copyCompilationInputs(repositoryPath, stagingPath) {
284
337
  const githubPath = join(repositoryPath, ".github");
285
338
  if (await exists(githubPath))
286
339
  await cp(githubPath, join(stagingPath, ".github"), { recursive: true });
340
+ const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
341
+ if (await exists(script)) {
342
+ await mkdir(join(stagingPath, "scripts"), { recursive: true });
343
+ await copyFile(script, join(stagingPath, "scripts", "compile-agent-workflows.mjs"));
344
+ }
287
345
  }
288
346
  async function initializeStagingRepository(stagingPath) {
289
347
  await execFileAsync("git", ["init", "--quiet"], { cwd: stagingPath, windowsHide: true });
@@ -369,7 +427,10 @@ function catalogTemplateMeta(template) {
369
427
  const entry = catalogTemplates.find((item) => item.name === template);
370
428
  if (entry === undefined)
371
429
  throw new Error(`Unknown template: ${template}`);
372
- const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "visual-evidence" ? "visual-evidence" : template === "bug-report" || template === "feature-request" ? "issues" : "agentics";
430
+ // The directory is a field on the entry now. It used to be inferred from the name by a chain
431
+ // of ternaries ending in "agentics", so a new template in any other directory installed the
432
+ // wrong file or none at all, and the default hid it.
433
+ const directory = entry.directory ?? (template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "bug-report" || template === "feature-request" ? "issues" : "agentics");
373
434
  const isWorkflow = entry.file.endsWith(".yml");
374
435
  const inferredTarget = template === "app-ci-dotnet-next"
375
436
  ? ".github/workflows/app-ci.yml"
@@ -380,6 +441,8 @@ function catalogTemplateMeta(template) {
380
441
  async function catalogFiles(sourcePath) {
381
442
  const files = [];
382
443
  for (const [sourceDirectory, targetDirectory] of sourceMappings) {
444
+ if (!await exists(join(sourcePath, sourceDirectory)))
445
+ continue;
383
446
  for (const file of await filesIn(join(sourcePath, sourceDirectory))) {
384
447
  if (isGeneratedFile(file))
385
448
  continue;