@plainconceptsplatform/workflows 0.4.34 → 0.6.1

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 (61) hide show
  1. package/README.md +88 -88
  2. package/dist/action-validation.test.js +5 -2
  3. package/dist/catalog-installation.js +50 -7
  4. package/dist/catalog-installation.test.js +59 -22
  5. package/dist/catalog-listing.test.js +4 -4
  6. package/dist/index.js +48 -48
  7. package/dist/index.test.js +9 -9
  8. package/dist/repository-inspection.test.js +2 -2
  9. package/dist/route-processing.js +24 -25
  10. package/dist/route-processing.test.js +166 -233
  11. package/dist/stack-defaults.js +14 -14
  12. package/dist/stack-defaults.test.js +79 -79
  13. package/dist/tui.test.js +10 -7
  14. package/dist/workflow-catalog.d.ts +1 -1
  15. package/dist/workflow-catalog.js +1 -5
  16. package/loops/actions/add-issue-labels/action.yml +2 -2
  17. package/loops/actions/agent-output.cjs +17 -17
  18. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  19. package/loops/actions/audit-close/action.yml +24 -0
  20. package/loops/actions/classify-route/action.yml +8 -3
  21. package/loops/actions/classify-route/classify-route.sh +58 -38
  22. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  23. package/loops/actions/identify-gate-subject/action.yml +3 -1
  24. package/loops/actions/remove-issue-labels/action.yml +4 -2
  25. package/loops/actions/update-changelog/action.yml +113 -0
  26. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  27. package/loops/actions/validate-refine-output/validate-refine-output.sh +34 -5
  28. package/loops/actions/validate-review-output/action.yml +35 -35
  29. package/loops/actions/validate-triage-output/action.yml +36 -36
  30. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  31. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +375 -36
  32. package/loops/scripts/compile-agent-workflows.mjs +290 -6
  33. package/loops/scripts/merge-changelog.mjs +76 -0
  34. package/loops/templates/agentics/actionlint.yaml +13 -0
  35. package/loops/templates/agentics/agentics-checks.yml +125 -8
  36. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  37. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -171
  38. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -178
  39. package/loops/templates/issues/bug_report.yml +109 -109
  40. package/loops/templates/issues/feature_request.yml +75 -75
  41. package/loops/templates/opencode/opencode.ci.json +49 -47
  42. package/loops/templates/opencode/opencode.ci.json.md +49 -41
  43. package/loops/templates/release/github-release.yml +30 -30
  44. package/loops/workflows/agent-apply-review.md +469 -443
  45. package/loops/workflows/agent-audit.md +213 -197
  46. package/loops/workflows/agent-implement.md +640 -414
  47. package/loops/workflows/agent-merge-gate.md +844 -584
  48. package/loops/workflows/agent-refine.md +633 -418
  49. package/loops/workflows/agent-release.md +258 -0
  50. package/loops/workflows/agent-triage.md +447 -439
  51. package/loops/workflows/authorize-bot-work.yml +85 -82
  52. package/loops/workflows/shared/opencode-ci.md +206 -197
  53. package/loops/workflows/shared/platform-defaults.md +19 -16
  54. package/loops/workflows/work-router.yml +1038 -632
  55. package/package.json +7 -8
  56. package/dist/repository-state.d.ts +0 -18
  57. package/dist/repository-state.js +0 -77
  58. package/dist/repository-state.test.d.ts +0 -1
  59. package/dist/repository-state.test.js +0 -96
  60. package/loops/workflows/agent-direct.md +0 -375
  61. package/loops/workflows/agent-propose.md +0 -342
@@ -1,4 +1,6 @@
1
1
  // Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+
3
+ const OPENCODE_VERSION = '1.18.23'
2
4
  import { spawnSync } from "node:child_process";
3
5
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
6
  import { join } from "node:path";
@@ -17,6 +19,50 @@ function resolveGhPath() {
17
19
  return "gh";
18
20
  }
19
21
 
22
+ // gh-aw folds a workflow's top-level `if:` into the activation job but computes activation.needs
23
+ // on its own, so a compiled `if:` can read `needs.<job>` for a job activation never waits for.
24
+ // GitHub does not reject that: the reference resolves to '' at runtime and the clause is silently
25
+ // true (agent-merge-gate's protected_changes guard, Pliny-Bot run 34042143350). actionlint would
26
+ // flag it, but it is deliberately not run on generated files (it does not model concurrency.queue
27
+ // or job.workflow_*). This is the one check that reads the locks, and it needs no dependency:
28
+ // gh-aw emits jobs at two spaces, job keys at four, list items at six.
29
+ function undeclaredNeeds(lock) {
30
+ const violations = [];
31
+ let inJobs = false;
32
+ let job = null;
33
+ let inList = false;
34
+ let jobs = 0;
35
+ let needs = new Set();
36
+ let refs = new Set();
37
+ const flush = () => {
38
+ if (!job) return;
39
+ for (const ref of refs) if (!needs.has(ref)) violations.push({ job, ref, needs: [...needs].sort() });
40
+ };
41
+ for (const line of lock.split("\n")) {
42
+ if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; }
43
+ if (inJobs && /^[^\s#]/.test(line)) inJobs = false;
44
+ if (!inJobs) continue;
45
+ const start = line.match(/^ ([a-z_][a-zA-Z0-9_-]*):\s*$/);
46
+ if (start) { flush(); job = start[1]; jobs += 1; needs = new Set(); refs = new Set(); inList = false; continue; }
47
+ if (!job) continue;
48
+ if (/^ needs:\s*$/.test(line)) { inList = true; continue; }
49
+ const inline = line.match(/^ needs:\s*(.+?)\s*$/);
50
+ if (inline) {
51
+ for (const n of inline[1].replace(/[\[\]]/g, "").split(",")) if (n.trim()) needs.add(n.trim());
52
+ inList = false;
53
+ continue;
54
+ }
55
+ if (inList) {
56
+ const item = line.match(/^ - (\S+)\s*$/);
57
+ if (item) { needs.add(item[1]); continue; }
58
+ inList = false;
59
+ }
60
+ for (const m of line.matchAll(/needs\.([a-z_][a-zA-Z0-9_-]*)\./g)) refs.add(m[1]);
61
+ }
62
+ flush();
63
+ return { jobs, violations };
64
+ }
65
+
20
66
  const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir", workflowDirectory], {
21
67
  stdio: "inherit",
22
68
  shell: false,
@@ -29,19 +75,257 @@ if (compile.error?.code === "ENOENT" || compile.status === null) {
29
75
 
30
76
  if (compile.status !== 0) process.exit(compile.status ?? 1);
31
77
 
78
+ function lintLock(file, text) {
79
+ const lint = undeclaredNeeds(text);
80
+ if (lint.jobs === 0) {
81
+ process.stderr.write(
82
+ `${file}: the needs lint found no jobs; the gh-aw lock layout changed. ` +
83
+ `Re-anchor undeclaredNeeds() in loops/scripts/compile-agent-workflows.mjs.\n`,
84
+ );
85
+ process.exitCode = 1;
86
+ }
87
+ for (const v of lint.violations) {
88
+ process.stderr.write(
89
+ `${file}: job "${v.job}" reads needs.${v.ref} but its needs are [${v.needs.join(", ")}]; ` +
90
+ `the reference resolves to '' at runtime. For activation, list the job under on.needs in the ` +
91
+ `source frontmatter; for a custom job, add it to that job's needs.\n`,
92
+ );
93
+ process.exitCode = 1;
94
+ }
95
+ }
96
+
97
+ // gh aw compile leaves a lock alone when its frontmatter and body hashes still match the source.
98
+ // This script then ran over a lock it had already patched: AGENTMEMORY_URL was inserted a second
99
+ // time, the opencode install line was wrapped in itself, and the consumer freshness check could
100
+ // never be clean (Pliny-Bot agentics-checks run 33963600563). The sentinel marks a processed
101
+ // lock; a lock that carries it is linted and otherwise left as it is.
102
+ const SENTINEL = "# post-processed by scripts/compile-agent-workflows.mjs; run it again and nothing changes";
103
+
32
104
  for (const file of readdirSync(workflowDirectory)) {
33
105
  if (!file.endsWith(".lock.yml")) continue;
34
106
 
35
107
  const path = join(workflowDirectory, file);
36
108
  const content = readFileSync(path, "utf8");
109
+ if (content.includes(SENTINEL)) {
110
+ lintLock(file, content);
111
+ continue;
112
+ }
37
113
  const patched = content
38
- .replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --port 4096 --log-level ERROR")
39
- .replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --port 4096 --log-level ERROR")
40
- .replaceAll("opencode run --log-level ERROR", "opencode run --port 4096 --log-level ERROR")
114
+ .replace(/^(# This file was automatically generated by gh-aw[^\n]*\n)/m, `$1${SENTINEL}\n`)
115
+ .replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --log-level ERROR")
116
+ .replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --log-level ERROR")
117
+ .replaceAll("opencode run --log-level ERROR", "opencode run --log-level ERROR")
41
118
  .replaceAll("--log-level DEBUG", "--log-level ERROR")
42
119
  .replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
43
120
  .replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
44
- .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'');
121
+ .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
122
+ // The MCP gateway is the only thing the agent publishes on the host loopback, so it is the
123
+ // one thing two runners on the same machine cannot share. Honour an inherited port so each
124
+ // runner service can pick its own; everything else is namespaced by its Docker daemon.
125
+ // awf names its containers awf-agent, awf-squid and awf-api-proxy with no way to change
126
+ // them, so two agent jobs on one host recreate each other's containers and the first dies
127
+ // with exit 137. Giving each runner its own user and daemon isolated the containers but
128
+ // broke everything the runners share through the filesystem: awf and gh-aw both write
129
+ // fixed-name files into /tmp, and a file one user creates cannot be overwritten by the
130
+ // next. Six separate failures came from that. One host lock is the smaller trade: agent
131
+ // jobs run one at a time, every other job still uses all the runners, and every user is
132
+ // the same again. The chain is serial anyway, so in practice this only queues one consumer behind
133
+ // the other. Held for the whole agent run, which is deliberate. The lock lives outside the
134
+ // /tmp/gh-aw namespace on purpose: the keying rewrite below would otherwise make the lock
135
+ // file per job, which is silently no lock at all.
136
+ // Agent jobs run in parallel again. Each runner has its own Docker-in-Docker daemon, so
137
+ // awf's fixed container names (awf-agent, awf-squid, awf-api-proxy, awmg-mcpg) live in
138
+ // separate namespaces and cannot recreate each other. gh-aw detects the tcp DOCKER_HOST
139
+ // and switches to its ARC/DinD path by itself, so no flag is needed here. The host-wide
140
+ // flock this replaces made every agent job wait for the previous one.
141
+ // Three host-global resources were left, and concurrent agent jobs fought over all of them.
142
+ // The global npm install rewrote the opencode binary another job was executing and killed it
143
+ // with SIGKILL mid-run; the warm server and its data directory were shared by every runner.
144
+ //
145
+ // All of these sites are step-level run: or env:, where the runner context is available. It
146
+ // is not available at workflow level, which is what broke the staging path earlier.
147
+ //
148
+ // Install only when the pinned version is missing, and take a lock so two jobs starting at
149
+ // once cannot both write the global prefix.
150
+ // gh-aw pins opencode-ai 1.2.14 at every release checked, up to v0.87.5. That version is
151
+ // from 2026-02-25. The harness expects 1.18.9 and this is the current release, so a gh-aw
152
+ // upgrade does not move it and the pin is ours to choose.
153
+ // gh-aw defaults NPM_CONFIG_MIN_RELEASE_AGE to 3 days, which blocks any package
154
+ // published in the last three days with ETARGET "No matching version found ... with a
155
+ // date before". Lowered to 1 so a fix released yesterday is usable. This is a
156
+ // supply-chain cooldown: shortening it accepts a newer package sooner.
157
+ .replace(/NPM_CONFIG_MIN_RELEASE_AGE: ['\"]?3['\"]?/g, "NPM_CONFIG_MIN_RELEASE_AGE: '1'")
158
+ // The MCP gateway runs inside a per-runner Docker-in-Docker daemon. gh-aw publishes it on
159
+ // 127.0.0.1, which is the DinD container's own loopback, while the outer publish forwards to
160
+ // that container's eth0, so the two never meet and gh-aw's health check fails 120 times with
161
+ // ECONNREFUSED. Binding 0.0.0.0 inside the daemon connects them. The host still exposes the
162
+ // port on loopback only, because the DinD container is published as 127.0.0.1:<port>.
163
+ .replaceAll("-p 127.0.0.1:'\"${MCP_GATEWAY_PORT}\"':", "-p '\"${MCP_GATEWAY_PORT}\"':")
164
+ // Each runner needs its own gateway port: four DinD daemons publish through to one host
165
+ // loopback, so two jobs on 8080 would collide. The runner sets it in .env.
166
+ // awf chroots into the Docker daemon's filesystem and requires it to be the runner's own
167
+ // glibc host. A Docker-in-Docker daemon fails with "Detected Alpine/musl host filesystem
168
+ // under /host", and no dind image fixes it: the agent needs the runner's toolchain and
169
+ // workspace, which a separate daemon container never has. So every agent job shares one
170
+ // daemon, awf's container names are fixed, and the jobs must be serialised.
171
+ .replaceAll(' awf --config', ' flock /tmp/agentic-awf.lock awf --config')
172
+ .replaceAll('export MCP_GATEWAY_PORT="8080"', 'export MCP_GATEWAY_PORT="${MCP_GATEWAY_PORT:-8080}"')
173
+ // The agent's safe outputs land in safeoutputs.jsonl, but the collector reads
174
+ // agent_output.json, and nothing in the generated workflow converts one to the other.
175
+ // With the OpenCode engine the tool calls go through the MCP server rather than stdout,
176
+ // so the log parser never produces agent_output.json and the placeholder step writes
177
+ // {"items":[]} over a run that did real work: the job goes green and the pull request
178
+ // is silently dropped. Build the collector's input from what the agent actually emitted.
179
+ .replaceAll("echo '{\"items\":[]}' > /tmp/gh-aw-${{ github.run_id }}/agent_output.json", "if [ -s /tmp/gh-aw-${{ github.run_id }}/safeoutputs.jsonl ]; then jq -s '{items: .}' /tmp/gh-aw-${{ github.run_id }}/safeoutputs.jsonl > /tmp/gh-aw-${{ github.run_id }}/agent_output.json; else echo '{\"items\":[]}' > /tmp/gh-aw-${{ github.run_id }}/agent_output.json; fi")
180
+ // The "Copy Safe Outputs" step runs on the host after the awf container exits. At that
181
+ // point /tmp/gh-aw/ IS the host's /tmp/gh-aw/, where the safeoutputs MCP container wrote
182
+ // patch and bundle files (via the MCP gateway's -v /tmp:/tmp:rw mount). Extend the step
183
+ // to also copy those files to RUNNER_TEMP/gh-aw/ so the upload step can include them.
184
+ // Without this, the upload step's glob /tmp/gh-aw/aw-*.patch runs inside the awf chroot
185
+ // (where /tmp/gh-aw is a different path) and finds nothing. The safe_outputs and conclude
186
+ // jobs then receive no patches in the artifact and silently skip the push.
187
+ .replace(
188
+ ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n',
189
+ ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n' +
190
+ ' # Copy patch/bundle files generated by the safeoutputs MCP container to\n' +
191
+ ' # RUNNER_TEMP/gh-aw/ so the upload step includes them in the artifact.\n' +
192
+ ' mkdir -p "${RUNNER_TEMP}/gh-aw"\n' +
193
+ ' ls -la /tmp/gh-aw/ 2>/dev/null || true\n' +
194
+ ' cp /tmp/gh-aw/aw-*.patch "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
195
+ ' cp /tmp/gh-aw/aw-*.bundle "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
196
+ ' ls -la "${RUNNER_TEMP}/gh-aw/"aw-* 2>/dev/null || true\n')
197
+ // Add patch/bundle paths to the upload glob so the agent artifact carries what
198
+ // push-agent-branch needs. The anchor is the two adjacent path lines of the
199
+ // "...agent" upload step; the "...agent-output-fallback" upload lists them in the
200
+ // opposite order, so it cannot match. Anchoring matters: the first bare
201
+ // '/tmp/gh-aw/agent_output.json\n' in the file is the placeholder step's echo line,
202
+ // and replacing there corrupted the placeholder into executing the globs as
203
+ // commands while the upload list stayed without bundles. The artifact then carried
204
+ // no bundle, push-agent-branch silently skipped the push, and the gate reported
205
+ // "remediated" on a pull request that was never touched.
206
+ // Only the /tmp/gh-aw globs may be added: a ${{ runner.temp }} path as well would
207
+ // move the upload's least-common-ancestor from /tmp/gh-aw to /, and every file in
208
+ // the artifact (agent_output.json included) would gain a prefix that
209
+ // download-agent-output does not look for.
210
+ .replace(
211
+ ' /tmp/gh-aw/safeoutputs.jsonl\n /tmp/gh-aw/agent_output.json\n',
212
+ ' /tmp/gh-aw/safeoutputs.jsonl\n' +
213
+ ' /tmp/gh-aw/aw-*.patch\n' +
214
+ ' /tmp/gh-aw/aw-*.bundle\n' +
215
+ ' /tmp/gh-aw/agent_output.json\n')
216
+ // "A failed run is already a red run": report-failure-as-issue: false and
217
+ // GH_AW_REPORT_FAILED_JOBS: "false" were supposed to silence issue creation, but
218
+ // 0.87.5's engine-failure reporter files an "[aw] ... failed" issue anyway (seen as
219
+ // Pliny-Bot #57: an OOM-killed gate produced a fresh issue nobody needs, while the
220
+ // retry belt is the actual remediation). `if: false` keeps the step compiled but
221
+ // never runs it, so the failure stays visible in the run and in the belt's attempt
222
+ // comment, and out of the issue tracker.
223
+ .replace(
224
+ /^([ \t]+)- name: Report failed jobs\n([ \t]*)id: report_failed_jobs\n[ \t]*if: always\(\)\n/m,
225
+ '$1- name: Report failed jobs\n$2id: report_failed_jobs\n$2if: false # never: failures surface in the run and the retry belt\n')
226
+
227
+ // v0.87.5's arc-dind mode stages the engine CLI to a daemon-visible path but assumes the
228
+ // Copilot engine: command -v copilot is empty under engine: opencode and cp "" fails the
229
+ // job before the agent starts. OpenCode needs no staging, because npm -g installs it under
230
+ // the tool-cache prefix inside _work, which the dind daemon shares.
231
+ // Centralised AgentMemory: an always-on App Service; the shim proxies to it when the
232
+ // URL is set and authenticates with the HMAC secret. Local development leaves both
233
+ // unset and keeps the local per-run store.
234
+ .replace(/^env:\n/m, 'env:\n AGENTMEMORY_URL: https://agentmemory-pro-01.azurewebsites.net\n')
235
+ // the secret is scoped to the one step that runs the agent (semgrep: a secret in
236
+ // workflow-level env is visible to every job and step)
237
+ .replaceAll(' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1',
238
+ ' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1' + '\n' +
239
+ ' AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}')
240
+ // the router passes secrets explicitly (semgrep flags secrets: inherit as over-broad),
241
+ // so the callee must declare everything it accepts
242
+ .replace(/^ COPILOT_GITHUB_TOKEN:/m,
243
+ ' AGENTMEMORY_SECRET:' + '\n' + ' required: false' + '\n' + ' COPILOT_GITHUB_TOKEN:')
244
+ .replaceAll(' COPILOT_SRC="$(command -v copilot)"',
245
+ ' command -v copilot >/dev/null 2>&1 || { echo "no copilot binary (engine is opencode); skipping"; exit 0; }' + '\n' +
246
+ ' COPILOT_SRC="$(command -v copilot)"')
247
+ // "A failed run is already a red run": report-failure-as-issue: false silences one
248
+ // reporter, but gh-aw hardcodes a second one (report_failed_jobs) that files an
249
+ // "[aw] Failed jobs" issue per red run. Same philosophy, same off switch.
250
+ .replaceAll('GH_AW_REPORT_FAILED_JOBS: "true"', 'GH_AW_REPORT_FAILED_JOBS: "false"')
251
+ // gh-aw sees a custom step calling dotnet and injects its own setup-dotnet, which carries
252
+ // no DOTNET_INSTALL_DIR and so tries to write /usr/share/dotnet. The runner user has no
253
+ // sudo, so the job dies with "Permission denied" before the agent starts. The shared
254
+ // baseline step already redirects to the tool cache; this gives every other one the same
255
+ // env, including ones a future gh-aw release injects.
256
+ .replace(
257
+ /^( +)- name: Setup \.NET\n\1 uses: actions\/setup-dotnet@[^\n]*\n\1 with:\n((?:\1 [^\n]*\n)+)(?!\1 env:)/gm,
258
+ (_m, indent, withBody) =>
259
+ indent + '- name: Setup .NET\n' + indent + ' uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0\n' +
260
+ indent + ' with:\n' + withBody +
261
+ indent + ' env:\n' + indent + ' DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet\n')
262
+ .replace(/opencode-ai@[0-9][0-9.]*/g, 'opencode-ai@' + OPENCODE_VERSION)
263
+ // gh-aw installs with --ignore-scripts, which blocks opencode's own postinstall. That
264
+ // script downloads the platform binary, so 1.18 fails at first use with "opencode-ai's
265
+ // postinstall script was not run". 1.2.14 did not need it. Run only opencode's postinstall
266
+ // explicitly, so every other package's scripts stay blocked.
267
+ .replace(
268
+ /npm install --ignore-scripts -g opencode-ai@([0-9][0-9.]*)/g,
269
+ (_m, v) =>
270
+ "opencode --version 2>/dev/null | grep -qF '" + v + "' || " +
271
+ "flock /tmp/opencode-install.lock sh -c 'npm install --ignore-scripts -g opencode-ai@" + v +
272
+ " && cd \"$(npm root -g)/opencode-ai\" && node postinstall.mjs' # opencode-ai@" + v)
273
+ // One warm server per runner, on its own port, with its own data directory. Keyed on the
274
+ // runner rather than the run so a runner keeps its server warm between its own jobs.
275
+ // No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
276
+ // would have to be escaped. The default has no spaces, so bare is safe shell.
277
+ .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}');
45
278
 
46
- if (patched !== content) writeFileSync(path, patched);
47
- }
279
+ let rewritten = patched;
280
+ // A queued implement executes hours after its run was created, but actions/checkout
281
+ // without a ref checks out github.sha, which GitHub pins at run creation. Five children
282
+ // queued together at 02:01 all carried the 02:01 base; once the first sibling merged,
283
+ // every later push became a rebase onto a moved parent, which gh-aw's signed-commit
284
+ // push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
285
+ // pull request as an issue instead of a pull request). Checking out the branch tip at
286
+ // execution time makes the commit parent current main; the implement-global queue
287
+ // already keeps sibling merges out of the window while the agent runs.
288
+ if (file === "agent-implement.lock.yml") {
289
+ const start = rewritten.indexOf("\n agent:\n");
290
+ const rest = start === -1 ? "" : rewritten.slice(start + 1);
291
+ const next = rest.search(/\n [a-z_][a-zA-Z_-]*:\n/);
292
+ if (start !== -1 && next !== -1) {
293
+ const end = start + 1 + next;
294
+ const span = rewritten.slice(start, end).replace(
295
+ /(uses: actions\/checkout@[^\n]*\n(\s+)with:\n(?:\2 [^\n]+\n)*)/,
296
+ (whole, _block, indent) =>
297
+ whole.includes("ref:") ? whole : whole + indent + " ref: ${{ github.event.repository.default_branch }}\n",
298
+ );
299
+ rewritten = rewritten.slice(0, start) + span + rewritten.slice(end);
300
+ }
301
+ }
302
+
303
+ // Guard the two rewrites the compiled agent jobs cannot work without. The anchors are
304
+ // upstream template text: when a gh-aw upgrade reshapes either step, the silent
305
+ // failure mode returns (an artifact with no bundle, a skipped push, a green gate on
306
+ // an untouched pull request). Failing the compile is the only loud signal there is.
307
+ for (const check of [
308
+ { name: "bundle upload glob", ok: /\n( +)- name: Upload agent artifacts\n\1 if: always\(\)\n\1 continue-on-error: true\n\1 uses: actions\/upload-artifact@[^\n]*\n\1 with:\n\1 name: \$\{\{ needs\.activation\.outputs\.artifact_prefix \}\}agent\n\1 path: \|\n(?:\1 [^\n]*\n)*\1 \/tmp\/gh-aw\/aw-\*\.bundle\n/ },
309
+ { name: "placeholder step intact", ok: /\n( +)- name: Write agent output placeholder if missing\n\1 if: always\(\)\n\1 run: \|\n\1 if \[ ! -f \/tmp\/gh-aw\/agent_output\.json \]; then\n\1 echo '\{"items":\[\]\}' > \/tmp\/gh-aw\/agent_output\.json\n\1 fi\n/ },
310
+ { name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
311
+ ]) {
312
+ if (!check.ok.test(rewritten)) {
313
+ process.stderr.write(
314
+ `compile patch "${check.name}" no longer matches in ${file}: the gh-aw template changed. ` +
315
+ `Re-anchor the rewrite in loops/scripts/compile-agent-workflows.mjs, or agent pushes are silently dropped.\n`,
316
+ );
317
+ process.exitCode = 1;
318
+ }
319
+ }
320
+
321
+ if (!rewritten.includes(SENTINEL)) {
322
+ process.stderr.write(
323
+ `${file}: the generated-file header changed and the post-processing sentinel was not placed; ` +
324
+ `re-anchor the SENTINEL insert in loops/scripts/compile-agent-workflows.mjs.\n`,
325
+ );
326
+ process.exitCode = 1;
327
+ }
328
+ lintLock(file, rewritten);
329
+
330
+ if (rewritten !== content) writeFileSync(path, rewritten);
331
+ }
@@ -0,0 +1,76 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/scripts/merge-changelog.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ //
3
+ // A git merge driver for a newest-first changelog array.
4
+ //
5
+ // Every merge into the default branch invalidates every other open pull request that added a
6
+ // changelog entry, because they all insert at the top of the same list. The conflict is real
7
+ // but it is never interesting: both entries belong, newest first. Left to the default driver
8
+ // it costs a full model run per sibling pull request, or a person, and it recurs on every
9
+ // merge for as long as there is more than one pull request in flight.
10
+ //
11
+ // Registered from .gitattributes as `merge=changelog`, so git calls this instead of producing
12
+ // conflict markers. Falls back to exit 1 (a normal conflict) whenever it cannot be sure, so a
13
+ // genuine edit to the same entry is still escalated rather than silently mangled.
14
+ //
15
+ // Usage (git's merge driver contract): merge-changelog.mjs %O %A %B
16
+ // %O ancestor, %A ours (written back with the result), %B theirs.
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+
19
+ const [ancestorPath, oursPath, theirsPath] = process.argv.slice(2);
20
+
21
+ /** Parse, or return undefined so the caller can bail out to a normal conflict. */
22
+ function read(path) {
23
+ try {
24
+ const value = JSON.parse(readFileSync(path, "utf8"));
25
+ return Array.isArray(value?.changes) ? value : undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ const ours = read(oursPath);
32
+ const theirs = read(theirsPath);
33
+ const ancestor = read(ancestorPath) ?? { changes: [] };
34
+
35
+ if (!ours || !theirs) {
36
+ process.stderr.write("merge-changelog: not a changelog document on both sides; leaving the conflict\n");
37
+ process.exit(1);
38
+ }
39
+
40
+ // An entry is identified by its commit when it has one, and by the whole record otherwise.
41
+ const identify = (entry) => entry?.commit ?? JSON.stringify(entry);
42
+
43
+ // Anything either side changed relative to the ancestor, plus everything the ancestor had.
44
+ // Union by identity: an append on both sides is the case this exists for.
45
+ const merged = new Map();
46
+ for (const entry of [...ancestor.changes, ...theirs.changes, ...ours.changes]) {
47
+ merged.set(identify(entry), entry);
48
+ }
49
+
50
+ // An entry the ancestor had and both sides removed should stay removed.
51
+ const kept = [...merged.values()].filter((entry) => {
52
+ const id = identify(entry);
53
+ const inAncestor = ancestor.changes.some((candidate) => identify(candidate) === id);
54
+ if (!inAncestor) return true;
55
+ return ours.changes.some((candidate) => identify(candidate) === id) ||
56
+ theirs.changes.some((candidate) => identify(candidate) === id);
57
+ });
58
+
59
+ // If the same identity carries different content on the two sides, somebody edited an entry
60
+ // rather than adding one. That is a real conflict and a person should look at it.
61
+ for (const entry of kept) {
62
+ const id = identify(entry);
63
+ const mine = ours.changes.find((candidate) => identify(candidate) === id);
64
+ const yours = theirs.changes.find((candidate) => identify(candidate) === id);
65
+ if (mine && yours && JSON.stringify(mine) !== JSON.stringify(yours)) {
66
+ process.stderr.write(`merge-changelog: entry ${id} differs on both sides; leaving the conflict\n`);
67
+ process.exit(1);
68
+ }
69
+ }
70
+
71
+ // Newest first, which is the order the file is read in and the order the page renders.
72
+ kept.sort((a, b) => String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? "")));
73
+
74
+ const result = { ...theirs, ...ours, changes: kept };
75
+ writeFileSync(oursPath, `${JSON.stringify(result, null, 2)}\n`);
76
+ process.stderr.write(`merge-changelog: combined ${ours.changes.length} + ${theirs.changes.length} entries into ${kept.length}\n`);
@@ -0,0 +1,13 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/agentics/actionlint.yaml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # Runners this repository actually has.
3
+ #
4
+ # actionlint knows GitHub's own labels and rejects everything else, so a workflow that
5
+ # names one of our runner groups was reported as an error on every pull request. Both
6
+ # of these are real: agents-arc runs the agentic workflows, RunnerLandingZone runs the
7
+ # work router. Listing them here is how actionlint is told a label is self-hosted
8
+ # rather than a typo, and it keeps that knowledge in a file the workflow generator
9
+ # does not overwrite.
10
+ self-hosted-runner:
11
+ labels:
12
+ - agents-arc
13
+ - RunnerLandingZone
@@ -9,34 +9,90 @@ on:
9
9
  - .github/workflows/*.yml
10
10
  - .github/workflows/aw.json
11
11
  - .github/actions/**
12
+ # A push to the default branch that bypassed review is exactly the case that needs the check:
13
+ # seven broken locks reached Pliny-Bot's main in twenty minutes with no pull request and no
14
+ # run of this workflow. A red run here is the detection.
15
+ push:
16
+ branches: [main]
17
+ paths:
18
+ - .github/workflows/*.md
19
+ - .github/workflows/shared/*.md
20
+ - .github/workflows/*.yml
21
+ - .github/workflows/aw.json
22
+ - .github/actions/**
12
23
 
13
24
  env:
14
- GH_AW_VERSION: v0.83.4
25
+ # Must match the version the locks were compiled with (first line of every .lock.yml) and the
26
+ # `github/gh-aw/...@vX` import pin in every worker. A different compiler regenerates every lock
27
+ # and the freshness diff measures the version, not the change under review.
28
+ GH_AW_VERSION: v0.87.5
15
29
  ACTIONLINT_VERSION: 1.7.7
16
30
  ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
17
- AGENTICS_COMPILE_COMMAND: gh aw compile --strict
31
+ SHELLCHECK_VERSION: 0.10.0
32
+ SHELLCHECK_SHA256: 6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87
33
+ # The same script a developer runs. It wraps `gh aw compile --strict` and applies the
34
+ # repository's post-processing; a bare `gh aw compile` on this side would regenerate every lock
35
+ # without the transform, and the diff below could never be clean.
36
+ AGENTICS_COMPILE_COMMAND: node scripts/compile-agent-workflows.mjs
18
37
 
19
38
  permissions:
20
39
  contents: read
21
40
 
22
41
  concurrency:
23
- group: agentics-checks-${{ github.event.pull_request.number }}
42
+ group: agentics-checks-${{ github.event.pull_request.number || github.ref }}
24
43
  cancel-in-progress: true
25
44
 
26
45
  jobs:
27
46
  verify-lockfiles:
28
47
  name: Verify generated agent lockfiles
29
- runs-on: RunnerLandingZone
48
+ runs-on: ubuntu-latest
30
49
  timeout-minutes: 10
50
+ env:
51
+ # gh-aw resolves the `github/gh-aw/...` imports through the GitHub API on every compile,
52
+ # and an unauthenticated gh on a hosted runner refuses API calls.
53
+ GH_TOKEN: ${{ github.token }}
31
54
  steps:
32
55
  - name: Checkout repository
33
56
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34
57
  with:
35
58
  persist-credentials: false
36
59
  - name: Install gh-aw
37
- uses: github/gh-aw-actions/setup@e89c65e17eb281bbd5ff2ff9e9199a03e96654c7 # v0.83.4
60
+ shell: bash
61
+ env:
62
+ GH_TOKEN: ${{ github.token }}
63
+ run: |
64
+ set -euo pipefail
65
+ gh extension install github/gh-aw --pin "$GH_AW_VERSION"
66
+ gh aw --version
67
+ # The compile command is a node script. Hosted images have node; a setup step pins the
68
+ # major version so the script sees the same runtime everywhere.
69
+ - name: Set up Node
70
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
38
71
  with:
39
- version: ${{ env.GH_AW_VERSION }}
72
+ node-version: 22
73
+ - name: Verify the locks and the workers agree on the gh-aw version
74
+ shell: bash
75
+ run: |
76
+ set -euo pipefail
77
+ # `gh aw --version` writes to stderr; without 2>&1 the grep saw nothing and set -e
78
+ # ended the step with no message at all.
79
+ want="$(gh aw --version 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 || true)"
80
+ [ "$want" = "$GH_AW_VERSION" ] || { echo "::error::gh aw reports '${want:-nothing}', this workflow pins $GH_AW_VERSION"; exit 1; }
81
+ status=0
82
+ for lock in .github/workflows/*.lock.yml; do
83
+ have="$(sed -n '1s/.*"compiler_version":"\([^"]*\)".*/\1/p' "$lock")"
84
+ if [ "$have" != "$want" ]; then
85
+ echo "::error::$lock was compiled by ${have:-an unknown version}; this job runs $want. Recompile with the pinned version, or bump GH_AW_VERSION and the worker import pins together."
86
+ status=1
87
+ fi
88
+ done
89
+ while read -r file pin; do
90
+ if [ "$pin" != "$want" ]; then
91
+ echo "::error::$file imports gh-aw shared files at $pin; this job runs $want."
92
+ status=1
93
+ fi
94
+ done < <(grep -HoE 'github/gh-aw/[^@]*@v[0-9]+\.[0-9]+\.[0-9]+' .github/workflows/*.md | sed -E 's/^([^:]*):.*@(v[0-9.]+)$/\1 \2/' | sort -u)
95
+ exit "$status"
40
96
  - name: Compile agent workflows strictly
41
97
  run: ${{ env.AGENTICS_COMPILE_COMMAND }}
42
98
  - name: Verify generated lockfiles are current
@@ -53,10 +109,24 @@ jobs:
53
109
  echo "::error::Generated agent lockfiles are stale. Run '${AGENTICS_COMPILE_COMMAND}' and commit the resulting .lock.yml files."
54
110
  git diff -- .github/workflows/*.lock.yml
55
111
  exit 1
112
+ # gh aw compile leaves a lock alone when its hashes still match the source, so the script
113
+ # must recognise a lock it already processed. If a second run changes anything, the
114
+ # post-processing is not idempotent and every consumer's freshness check is measuring the
115
+ # transform instead of the drift.
116
+ - name: Verify a second compile changes nothing
117
+ shell: bash
118
+ run: |
119
+ set -euo pipefail
120
+ cp -R .github/workflows "$RUNNER_TEMP/first-pass"
121
+ ${AGENTICS_COMPILE_COMMAND}
122
+ if ! diff -r -I '^[[:space:]]*GH_AW_INFO_MODEL_COSTS:' "$RUNNER_TEMP/first-pass" .github/workflows; then
123
+ echo "::error::Running '${AGENTICS_COMPILE_COMMAND}' twice produced different locks; the post-processing is not idempotent."
124
+ exit 1
125
+ fi
56
126
 
57
127
  lint-workflows:
58
128
  name: Lint authored workflows and shell
59
- runs-on: RunnerLandingZone
129
+ runs-on: ubuntu-latest
60
130
  timeout-minutes: 10
61
131
  steps:
62
132
  - name: Checkout repository
@@ -73,14 +143,61 @@ jobs:
73
143
  mkdir -p "$RUNNER_TEMP/bin"
74
144
  tar -xzf "$tarball" -C "$RUNNER_TEMP/bin" actionlint
75
145
  echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
146
+ # Pinned rather than taken from the image: a copy of this job that ran on a self-hosted
147
+ # runner died with "shellcheck: No such file or directory".
148
+ - name: Install shellcheck
149
+ shell: bash
150
+ run: |
151
+ set -euo pipefail
152
+ tarball="$RUNNER_TEMP/shellcheck.tar.xz"
153
+ curl -fsSL -o "$tarball" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
154
+ echo "${SHELLCHECK_SHA256} $tarball" | sha256sum --check --strict
155
+ mkdir -p "$RUNNER_TEMP/bin"
156
+ tar -xJf "$tarball" -C "$RUNNER_TEMP/bin" --strip-components=1 "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
157
+ echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
158
+ # actionlint 1.7.7 predates `concurrency.queue`, which GitHub accepts and the router uses;
159
+ # that one message is a known false positive and is ignored by its exact text so anything
160
+ # else under `concurrency` still fails. shellcheck style notes (SC2129 and friends) are
161
+ # opinions about consumer scripts, not defects; warnings and errors still fail the job.
76
162
  - name: Lint authored workflows
77
163
  shell: bash
164
+ env:
165
+ SHELLCHECK_OPTS: -S warning
78
166
  run: |
79
167
  set -euo pipefail
80
168
  mapfile -t authored < <(git ls-files '.github/workflows/*.yml' | grep -vE '\.lock\.yml$|agentics-maintenance\.yml$')
81
169
  if [ "${#authored[@]}" -gt 0 ]; then
82
- actionlint "${authored[@]}"
170
+ actionlint -ignore 'unexpected key "queue" for "concurrency" section' "${authored[@]}"
83
171
  fi
84
172
  - name: Lint composite action shell
85
173
  shell: bash
86
174
  run: find .github/actions -name '*.sh' -print0 | xargs -0 -r shellcheck -x
175
+ # A password written into a workflow is a finding whatever it protects, and one sat in
176
+ # these files for weeks: a throwaway SQL container credential, repeated inline in six
177
+ # to nine places per repository, found by a policy scan rather than by us. Anything a
178
+ # job needs at container-start time belongs in an expression or a secret, and this is
179
+ # the check that says so before a reviewer has to.
180
+ - name: No written-down passwords in workflows
181
+ shell: bash
182
+ run: |
183
+ set -euo pipefail
184
+ mapfile -t authored < <(git ls-files '.github/workflows/*.yml' '.github/actions/**' 2>/dev/null || true)
185
+ [ "${#authored[@]}" -gt 0 ] || exit 0
186
+ # Two passes, because "is this a literal" is the hard half. The first finds every
187
+ # assignment to a password-ish name whose value is quoted; the second keeps only
188
+ # the lines with no dollar sign, so a value built from an expression or read from a
189
+ # shell variable passes and a written-down word does not. A literal that happens to
190
+ # contain a dollar sign slips through; erring that way beats a check people learn to
191
+ # route around.
192
+ # Two shapes: a password-ish name assigned a quoted value, and a command-line
193
+ # flag given one. The literal these repositories carried appeared as both.
194
+ hits=$(grep -nEi \
195
+ -e "(password|passwd|pwd)[\"']?[[:space:]]*[:=][[:space:]]*[\"'][^\"']" \
196
+ -e "(^|[[:space:]])(-P|--password)[[:space:]=]*[\"'][^\"']" \
197
+ "${authored[@]}" | grep -v '[$]' || true)
198
+ if [ -n "$hits" ]; then
199
+ printf '%s\n' "$hits"
200
+ echo "::error::A password is written into a workflow. Use a secret, or derive it per run from an expression."
201
+ exit 1
202
+ fi
203
+ echo "No written-down passwords."