@plainconceptsplatform/workflows 0.28.26 → 1.0.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.
@@ -31,31 +31,31 @@ export function generateOpencodeCi(baseContent, inspection) {
31
31
  if (inspection.stackHints.solutionFiles.length > 0) {
32
32
  const solutionPath = inspection.stackHints.solutionFiles[0].replaceAll("\\", "/");
33
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
-
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
41
  `;
42
42
  result = insertBeforeMarker(result, nugetCache, " - name: Install workspace dependencies");
43
43
  }
44
44
  if (!result.includes("name: Restore .NET dependencies")) {
45
- const restore = ` - name: Restore .NET dependencies
46
- run: dotnet restore ${solutionPath}
47
-
45
+ const restore = ` - name: Restore .NET dependencies
46
+ run: dotnet restore ${solutionPath}
47
+
48
48
  `;
49
49
  result = insertBeforeMarker(result, restore, " - name: Install workspace dependencies");
50
50
  }
51
51
  }
52
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
-
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
59
  `;
60
60
  result = insertBeforeMarker(result, openspecStep, " - name: Install workspace dependencies");
61
61
  }
@@ -74,32 +74,24 @@ export function generateOpencodeConfig(baseContent, inspection) {
74
74
  try {
75
75
  const config = JSON.parse(baseContent);
76
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"];
77
+ // OpenCode 2 names the agent map `agents` and the prompt `system`. V2 does
78
+ // not run language servers, so there is no lsp block to add or remove.
79
+ const agents = config.agents;
80
+ if (agents !== undefined) {
81
+ const agentEntry = agents["ci-workflow-agent"];
90
82
  if (agentEntry !== undefined) {
91
- let prompt = agentEntry.prompt ?? "";
83
+ let system = agentEntry.system ?? "";
92
84
  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.";
85
+ if (!system.includes(".NET guardrails")) {
86
+ system += "\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
87
  }
96
88
  }
97
89
  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.";
90
+ if (!system.includes("Node/React rules")) {
91
+ system += "\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
92
  }
101
93
  }
102
- agentEntry.prompt = prompt;
94
+ agentEntry.system = system;
103
95
  }
104
96
  }
105
97
  return JSON.stringify(config, null, 2) + "\n";
@@ -2198,15 +2198,15 @@ echo "── Runner pools ──────────────────
2198
2198
  # RunnerLandingZone while merge-gate and release stayed on agents-arc: the override reached the
2199
2199
  # workers installed at the time and never the ones added later. Nothing reported the split.
2200
2200
  #
2201
- # agent jobs of every worker except release -> agents-arc (the Azure fleet in
2202
- # agentrunner-pro-rg-01,
2203
- # runner group `agentic`)
2204
- # gate and post-processing jobs -> RunnerLandingZone (GitHub-hosted, no fleet
2205
- # capacity consumed by
2206
- # trivial checks)
2207
- # agent-release.md and work-router.yml -> RunnerLandingZone
2208
- # ubuntu-latest -> GitHub's own runner, chosen by nobody, left
2209
- # wherever the package already has it
2201
+ # agent jobs of every worker -> agents-arc (the Azure fleet in
2202
+ # agentrunner-pro-rg-01,
2203
+ # runner group `agentic`)
2204
+ # GitHub-hosted gate jobs (where a worker keeps them) -> RunnerLandingZone
2205
+ # ubuntu-latest -> GitHub's own runner, chosen by nobody, left
2206
+ # wherever the package already has it
2207
+ #
2208
+ # Since 0.28.17 every job runs on agents-arc, release and router included; the rule below
2209
+ # still accepts RunnerLandingZone for a stray gate job but requires agents-arc to be named.
2210
2210
  AGENT_POOL="agents-arc"
2211
2211
  PLUMBING_POOL="RunnerLandingZone"
2212
2212
  POOL_OK=1
@@ -2224,43 +2224,35 @@ for worker_md in "${WORKFLOWS_DIR}"/agent-*.md; do
2224
2224
  [ -f "$worker_md" ] || continue
2225
2225
  worker_name="$(basename "$worker_md" .md)"
2226
2226
  want="$AGENT_POOL"
2227
- [ "$worker_name" = "agent-release" ] && want="$PLUMBING_POOL"
2228
2227
  got="$(pools_named "$worker_md" | tr '\n' ' ' | sed 's/ $//')"
2229
- # Gate jobs and post-processing jobs run on RunnerLandingZone (GitHub-hosted) to avoid
2230
- # consuming self-hosted fleet capacity for trivial checks. The agent job and safe_outputs
2231
- # stay on agents-arc. So a worker may name both pools.
2232
- allowed_pools="${want} ${PLUMBING_POOL}"
2233
- if [ "$worker_name" = "agent-release" ]; then
2234
- if [ "$got" != "$want" ]; then
2235
- POOL_OK=0
2236
- echo "FAIL: ${worker_name} names runner pool(s) '${got}' but must name only '${want}'" >&2
2237
- fi
2238
- else
2239
- # Every pool named must be in the allowed set; agents-arc must be present (the agent job).
2240
- ok=1
2241
- for p in $got; do
2242
- case " $allowed_pools " in *" $p "*) ;; *) ok=0 ;; esac
2243
- done
2244
- if ! echo "$got" | grep -qw "$AGENT_POOL"; then
2245
- # visual-verify gets its agent runs-on from a shared import, not from the .md file,
2246
- # so the pool check only sees the subject gate job on RunnerLandingZone.
2247
- if [ "$worker_name" = "agent-visual-verify" ] && echo "$got" | grep -qw "$PLUMBING_POOL"; then
2248
- :
2249
- else
2250
- ok=0
2251
- fi
2252
- fi
2253
- if [ "$ok" -ne 1 ]; then
2254
- POOL_OK=0
2255
- echo "FAIL: ${worker_name} names runner pool(s) '${got}' but must name only '${AGENT_POOL}' and/or '${PLUMBING_POOL}' (with at least ${AGENT_POOL})" >&2
2228
+ # The package owns which pool every job runs on, and since 0.28.17 every job in
2229
+ # every worker runs on agents-arc, release included. RunnerLandingZone is still
2230
+ # accepted for the occasional GitHub-hosted gate job, but the agent pool must be
2231
+ # present so a worker cannot drift onto a pool no runner carries.
2232
+ allowed_pools="${AGENT_POOL} ${PLUMBING_POOL}"
2233
+ ok=1
2234
+ for p in $got; do
2235
+ case " $allowed_pools " in *" $p "*) ;; *) ok=0 ;; esac
2236
+ done
2237
+ if ! echo "$got" | grep -qw "$AGENT_POOL"; then
2238
+ # visual-verify gets its agent runs-on from a shared import, not from the .md file,
2239
+ # so the pool check only sees the subject gate job on RunnerLandingZone.
2240
+ if [ "$worker_name" = "agent-visual-verify" ] && echo "$got" | grep -qw "$PLUMBING_POOL"; then
2241
+ :
2242
+ else
2243
+ ok=0
2256
2244
  fi
2257
2245
  fi
2246
+ if [ "$ok" -ne 1 ]; then
2247
+ POOL_OK=0
2248
+ echo "FAIL: ${worker_name} names runner pool(s) '${got}' but must name only '${AGENT_POOL}' and/or '${PLUMBING_POOL}' (with at least ${AGENT_POOL})" >&2
2249
+ fi
2258
2250
  done
2259
2251
 
2260
2252
  router_pools="$(pools_named "$ROUTER_YML" | tr '\n' ' ' | sed 's/ $//')"
2261
- if [ "$router_pools" != "$PLUMBING_POOL" ]; then
2253
+ if ! echo "$router_pools" | grep -qw "$AGENT_POOL"; then
2262
2254
  POOL_OK=0
2263
- echo "FAIL: the router names runner pool(s) '${router_pools}' but must name only '${PLUMBING_POOL}'" >&2
2255
+ echo "FAIL: the router names runner pool(s) '${router_pools}' but must name only '${AGENT_POOL}' and/or '${PLUMBING_POOL}' (with at least ${AGENT_POOL})" >&2
2264
2256
  fi
2265
2257
 
2266
2258
  # And the pool must not be reintroduced as a per-consumer value: that is the mechanism that let
@@ -1,347 +1,350 @@
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'
4
- import { spawnSync } from "node:child_process";
5
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
7
-
8
- const workflowDirectory = existsSync("loops/workflows") ? "loops/workflows" : ".github/workflows";
9
-
10
- // On Windows, `gh` resolves to a shim that spawnSync cannot find without a shell.
11
- // Resolve the full path via `where` so spawnSync works with shell: false (security-safe).
12
- function resolveGhPath() {
13
- if (process.platform !== "win32") return "gh";
14
- const result = spawnSync("where", ["gh"], { encoding: "utf8", shell: false });
15
- if (result.status === 0) {
16
- const first = result.stdout.split("\n").map((s) => s.trim()).find(Boolean);
17
- if (first) return first;
18
- }
19
- return "gh";
20
- }
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
-
66
- const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir", workflowDirectory], {
67
- stdio: "inherit",
68
- shell: false,
69
- });
70
-
71
- if (compile.error?.code === "ENOENT" || compile.status === null) {
72
- process.stderr.write("Could not run `gh aw compile`. Install githubnext/gh-aw first.\n");
73
- process.exit(1);
74
- }
75
-
76
- if (compile.status !== 0) process.exit(compile.status ?? 1);
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
-
104
- for (const file of readdirSync(workflowDirectory)) {
105
- if (!file.endsWith(".lock.yml")) continue;
106
-
107
- const path = join(workflowDirectory, file);
108
- const content = readFileSync(path, "utf8");
109
- if (content.includes(SENTINEL)) {
110
- lintLock(file, content);
111
- continue;
112
- }
113
- const patched = content
114
- .replace(/^(# This file was automatically generated by gh-aw[^\n]*\n)/m, `$1${SENTINEL}\n`)
115
- // The engine command is normalized to one shape:
116
- // - OPENCODE_DISABLE_MODELS_FETCH=1: opencode otherwise fetches its model
117
- // registry from https://models.opencode.ai/api.json at startup and blocks
118
- // bootstrap on it. Inside the awf sandbox that host is not on the egress
119
- // allowlist, so the fetch hangs and every agent run stalls right after
120
- // "init" until the runner kills it. The forge provider declares its models
121
- // explicitly, so the remote registry is not needed here.
122
- // - --print-logs + a non-silent level: stripping --print-logs and forcing
123
- // ERROR hid every engine log, so a stalled bootstrap looked like silence.
124
- .replace(/opencode run(?: --print-logs)? --log-level (?:DEBUG|ERROR|INFO)/g, "OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --print-logs --log-level INFO")
125
- .replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
126
- .replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
127
- .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
128
- // The MCP gateway is the only thing the agent publishes on the host loopback, so it is the
129
- // one thing two runners on the same machine cannot share. Honour an inherited port so each
130
- // runner service can pick its own; everything else is namespaced by its Docker daemon.
131
- // awf names its containers awf-agent, awf-squid and awf-api-proxy with no way to change
132
- // them, so two agent jobs on one host recreate each other's containers and the first dies
133
- // with exit 137. Giving each runner its own user and daemon isolated the containers but
134
- // broke everything the runners share through the filesystem: awf and gh-aw both write
135
- // fixed-name files into /tmp, and a file one user creates cannot be overwritten by the
136
- // next. Six separate failures came from that. One host lock is the smaller trade: agent
137
- // jobs run one at a time, every other job still uses all the runners, and every user is
138
- // the same again. The chain is serial anyway, so in practice this only queues one consumer behind
139
- // the other. Held for the whole agent run, which is deliberate. The lock lives outside the
140
- // /tmp/gh-aw namespace on purpose: the keying rewrite below would otherwise make the lock
141
- // file per job, which is silently no lock at all.
142
- // Agent jobs run in parallel again. Each runner has its own Docker-in-Docker daemon, so
143
- // awf's fixed container names (awf-agent, awf-squid, awf-api-proxy, awmg-mcpg) live in
144
- // separate namespaces and cannot recreate each other. gh-aw detects the tcp DOCKER_HOST
145
- // and switches to its ARC/DinD path by itself, so no flag is needed here. The host-wide
146
- // flock this replaces made every agent job wait for the previous one.
147
- // Three host-global resources were left, and concurrent agent jobs fought over all of them.
148
- // The global npm install rewrote the opencode binary another job was executing and killed it
149
- // with SIGKILL mid-run; the warm server and its data directory were shared by every runner.
150
- //
151
- // All of these sites are step-level run: or env:, where the runner context is available. It
152
- // is not available at workflow level, which is what broke the staging path earlier.
153
- //
154
- // Install only when the pinned version is missing, and take a lock so two jobs starting at
155
- // once cannot both write the global prefix.
156
- // gh-aw pins opencode-ai 1.2.14 at every release checked, up to v0.90.0. That version is
157
- // from 2026-02-25. The harness expects 1.18.9 and this is the current release, so a gh-aw
158
- // upgrade does not move it and the pin is ours to choose.
159
- // gh-aw defaults NPM_CONFIG_MIN_RELEASE_AGE to 3 days, which blocks any package
160
- // published in the last three days with ETARGET "No matching version found ... with a
161
- // date before". Lowered to 1 so a fix released yesterday is usable. This is a
162
- // supply-chain cooldown: shortening it accepts a newer package sooner.
163
- .replace(/NPM_CONFIG_MIN_RELEASE_AGE: ['\"]?3['\"]?/g, "NPM_CONFIG_MIN_RELEASE_AGE: '1'")
164
- // The MCP gateway runs inside a per-runner Docker-in-Docker daemon. gh-aw publishes it on
165
- // 127.0.0.1, which is the DinD container's own loopback, while the outer publish forwards to
166
- // that container's eth0, so the two never meet and gh-aw's health check fails 120 times with
167
- // ECONNREFUSED. Binding 0.0.0.0 inside the daemon connects them. The host still exposes the
168
- // port on loopback only, because the DinD container is published as 127.0.0.1:<port>.
169
- .replaceAll("-p 127.0.0.1:'\"${MCP_GATEWAY_PORT}\"':", "-p '\"${MCP_GATEWAY_PORT}\"':")
170
- // Each runner needs its own gateway port: four DinD daemons publish through to one host
171
- // loopback, so two jobs on 8080 would collide. The runner sets it in .env.
172
- // awf chroots into the Docker daemon's filesystem and requires it to be the runner's own
173
- // glibc host. A Docker-in-Docker daemon fails with "Detected Alpine/musl host filesystem
174
- // under /host", and no dind image fixes it: the agent needs the runner's toolchain and
175
- // workspace, which a separate daemon container never has. So every agent job shares one
176
- // daemon, awf's container names are fixed, and the jobs must be serialised.
177
- .replaceAll(' awf --config', ' flock /tmp/agentic-awf.lock awf --config')
178
- .replaceAll('export MCP_GATEWAY_PORT="8080"', 'export MCP_GATEWAY_PORT="${MCP_GATEWAY_PORT:-8080}"')
179
- // The agent's safe outputs land in safeoutputs.jsonl, but the collector reads
180
- // agent_output.json, and nothing in the generated workflow converts one to the other.
181
- // With the OpenCode engine the tool calls go through the MCP server rather than stdout,
182
- // so the log parser never produces agent_output.json and the placeholder step writes
183
- // {"items":[]} over a run that did real work: the job goes green and the pull request
184
- // is silently dropped. Build the collector's input from what the agent actually emitted.
185
- .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")
186
- // The "Copy Safe Outputs" step runs on the host after the awf container exits. At that
187
- // point /tmp/gh-aw/ IS the host's /tmp/gh-aw/, where the safeoutputs MCP container wrote
188
- // patch and bundle files (via the MCP gateway's -v /tmp:/tmp:rw mount). Extend the step
189
- // to also copy those files to RUNNER_TEMP/gh-aw/ so the upload step can include them.
190
- // Without this, the upload step's glob /tmp/gh-aw/aw-*.patch runs inside the awf chroot
191
- // (where /tmp/gh-aw is a different path) and finds nothing. The safe_outputs and conclude
192
- // jobs then receive no patches in the artifact and silently skip the push.
193
- .replace(
194
- ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n',
195
- ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n' +
196
- ' # Copy patch/bundle files generated by the safeoutputs MCP container to\n' +
197
- ' # RUNNER_TEMP/gh-aw/ so the upload step includes them in the artifact.\n' +
198
- ' mkdir -p "${RUNNER_TEMP}/gh-aw"\n' +
199
- ' ls -la /tmp/gh-aw/ 2>/dev/null || true\n' +
200
- ' cp /tmp/gh-aw/aw-*.patch "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
201
- ' cp /tmp/gh-aw/aw-*.bundle "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
202
- ' ls -la "${RUNNER_TEMP}/gh-aw/"aw-* 2>/dev/null || true\n')
203
- // Add patch/bundle paths to the upload glob so the agent artifact carries what
204
- // push-agent-branch needs. The anchor is the two adjacent path lines of the
205
- // "...agent" upload step; the "...agent-output-fallback" upload lists them in the
206
- // opposite order, so it cannot match. Anchoring matters: the first bare
207
- // '/tmp/gh-aw/agent_output.json\n' in the file is the placeholder step's echo line,
208
- // and replacing there corrupted the placeholder into executing the globs as
209
- // commands while the upload list stayed without bundles. The artifact then carried
210
- // no bundle, push-agent-branch silently skipped the push, and the gate reported
211
- // "remediated" on a pull request that was never touched.
212
- // Only the /tmp/gh-aw globs may be added: a ${{ runner.temp }} path as well would
213
- // move the upload's least-common-ancestor from /tmp/gh-aw to /, and every file in
214
- // the artifact (agent_output.json included) would gain a prefix that
215
- // download-agent-output does not look for.
216
- .replace(
217
- ' /tmp/gh-aw/safeoutputs.jsonl\n /tmp/gh-aw/agent_output.json\n',
218
- ' /tmp/gh-aw/safeoutputs.jsonl\n' +
219
- ' /tmp/gh-aw/aw-*.patch\n' +
220
- ' /tmp/gh-aw/aw-*.bundle\n' +
221
- ' /tmp/gh-aw/agent_output.json\n')
222
- // "A failed run is already a red run": report-failure-as-issue: false and
223
- // GH_AW_REPORT_FAILED_JOBS: "false" were supposed to silence issue creation, but
224
- // 0.87.5's engine-failure reporter files an "[aw] ... failed" issue anyway (seen as
225
- // Pliny-Bot #57: an OOM-killed gate produced a fresh issue nobody needs, while the
226
- // retry belt is the actual remediation). `if: false` keeps the step compiled but
227
- // never runs it, so the failure stays visible in the run and in the belt's attempt
228
- // comment, and out of the issue tracker.
229
- .replace(
230
- /^([ \t]+)- name: Report failed jobs\n([ \t]*)id: report_failed_jobs\n[ \t]*if: always\(\)\n/m,
231
- '$1- name: Report failed jobs\n$2id: report_failed_jobs\n$2if: false # never: failures surface in the run and the retry belt\n')
232
-
233
- // v0.90.0's arc-dind mode stages the engine CLI to a daemon-visible path but assumes the
234
- // Copilot engine: command -v copilot is empty under engine: opencode and cp "" fails the
235
- // job before the agent starts. OpenCode needs no staging, because npm -g installs it under
236
- // the tool-cache prefix inside _work, which the dind daemon shares.
237
- // Centralised AgentMemory: an always-on App Service; the shim proxies to it when the
238
- // URL is set and authenticates with the HMAC secret. Local development leaves both
239
- // unset and keeps the local per-run store.
240
- .replace(/^env:\n/m, 'env:\n AGENTMEMORY_URL: https://agentmemory-pro-01.azurewebsites.net\n')
241
- // the secret is scoped to the one step that runs the agent (semgrep: a secret in
242
- // workflow-level env is visible to every job and step)
243
- .replaceAll(' OPENAI_BASE_URL: http://host.docker.internal:10001',
244
- ' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1')
245
- .replaceAll(' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1',
246
- ' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1' + '\n' +
247
- ' AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}')
248
- // the router passes secrets explicitly (semgrep flags secrets: inherit as over-broad),
249
- // so the callee must declare everything it accepts
250
- .replace(/^ COPILOT_GITHUB_TOKEN:/m,
251
- ' AGENTMEMORY_SECRET:' + '\n' + ' required: false' + '\n' + ' COPILOT_GITHUB_TOKEN:')
252
- .replaceAll(' COPILOT_SRC="$(command -v copilot)"',
253
- ' command -v copilot >/dev/null 2>&1 || { echo "no copilot binary (engine is opencode); skipping"; exit 0; }' + '\n' +
254
- ' COPILOT_SRC="$(command -v copilot)"')
255
- // "A failed run is already a red run": report-failure-as-issue: false silences one
256
- // reporter, but gh-aw hardcodes a second one (report_failed_jobs) that files an
257
- // "[aw] Failed jobs" issue per red run. Same philosophy, same off switch.
258
- .replaceAll('GH_AW_REPORT_FAILED_JOBS: "true"', 'GH_AW_REPORT_FAILED_JOBS: "false"')
259
- // gh-aw injects this "Restore .NET dependencies" step whenever a consumer's
260
- // frontend (e.g. VERIFY_COMMANDS) references dotnet. An AI agent can create a
261
- // .claude/worktrees/agent-XXX/ worktree at restore time — the first .slnx find
262
- // matches that scratch dir, and the hardcoded path lands in the lock, breaking
263
- // every agent run with MSB1009. Replace with dynamic discovery that excludes
264
- // agent scratch directories so a stale worktree never poisons the restore.
265
- .replace(
266
- /^( +)- name: Restore \.NET dependencies\n\1 run: dotnet restore .*$/m,
267
- (_m, indent) =>
268
- indent + '- name: Restore .NET dependencies\n' + indent +
269
- ' run: |-\n' + indent +
270
- ' SLNX=$(find . -maxdepth 5 -name "*.slnx" -not -path "./.claude/*" -not -path "./.opencode/*" | head -1)\n' + indent +
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 = '2.0.22'
4
+ const OPENCODE_PACKAGE = '@opencode/cli'
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const workflowDirectory = existsSync("loops/workflows") ? "loops/workflows" : ".github/workflows";
10
+
11
+ // On Windows, `gh` resolves to a shim that spawnSync cannot find without a shell.
12
+ // Resolve the full path via `where` so spawnSync works with shell: false (security-safe).
13
+ function resolveGhPath() {
14
+ if (process.platform !== "win32") return "gh";
15
+ const result = spawnSync("where", ["gh"], { encoding: "utf8", shell: false });
16
+ if (result.status === 0) {
17
+ const first = result.stdout.split("\n").map((s) => s.trim()).find(Boolean);
18
+ if (first) return first;
19
+ }
20
+ return "gh";
21
+ }
22
+
23
+ // gh-aw folds a workflow's top-level `if:` into the activation job but computes activation.needs
24
+ // on its own, so a compiled `if:` can read `needs.<job>` for a job activation never waits for.
25
+ // GitHub does not reject that: the reference resolves to '' at runtime and the clause is silently
26
+ // true (agent-merge-gate's protected_changes guard, Pliny-Bot run 34042143350). actionlint would
27
+ // flag it, but it is deliberately not run on generated files (it does not model concurrency.queue
28
+ // or job.workflow_*). This is the one check that reads the locks, and it needs no dependency:
29
+ // gh-aw emits jobs at two spaces, job keys at four, list items at six.
30
+ function undeclaredNeeds(lock) {
31
+ const violations = [];
32
+ let inJobs = false;
33
+ let job = null;
34
+ let inList = false;
35
+ let jobs = 0;
36
+ let needs = new Set();
37
+ let refs = new Set();
38
+ const flush = () => {
39
+ if (!job) return;
40
+ for (const ref of refs) if (!needs.has(ref)) violations.push({ job, ref, needs: [...needs].sort() });
41
+ };
42
+ for (const line of lock.split("\n")) {
43
+ if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; }
44
+ if (inJobs && /^[^\s#]/.test(line)) inJobs = false;
45
+ if (!inJobs) continue;
46
+ const start = line.match(/^ ([a-z_][a-zA-Z0-9_-]*):\s*$/);
47
+ if (start) { flush(); job = start[1]; jobs += 1; needs = new Set(); refs = new Set(); inList = false; continue; }
48
+ if (!job) continue;
49
+ if (/^ needs:\s*$/.test(line)) { inList = true; continue; }
50
+ const inline = line.match(/^ needs:\s*(.+?)\s*$/);
51
+ if (inline) {
52
+ for (const n of inline[1].replace(/[\[\]]/g, "").split(",")) if (n.trim()) needs.add(n.trim());
53
+ inList = false;
54
+ continue;
55
+ }
56
+ if (inList) {
57
+ const item = line.match(/^ - (\S+)\s*$/);
58
+ if (item) { needs.add(item[1]); continue; }
59
+ inList = false;
60
+ }
61
+ for (const m of line.matchAll(/needs\.([a-z_][a-zA-Z0-9_-]*)\./g)) refs.add(m[1]);
62
+ }
63
+ flush();
64
+ return { jobs, violations };
65
+ }
66
+
67
+ const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir", workflowDirectory], {
68
+ stdio: "inherit",
69
+ shell: false,
70
+ });
71
+
72
+ if (compile.error?.code === "ENOENT" || compile.status === null) {
73
+ process.stderr.write("Could not run `gh aw compile`. Install githubnext/gh-aw first.\n");
74
+ process.exit(1);
75
+ }
76
+
77
+ if (compile.status !== 0) process.exit(compile.status ?? 1);
78
+
79
+ function lintLock(file, text) {
80
+ const lint = undeclaredNeeds(text);
81
+ if (lint.jobs === 0) {
82
+ process.stderr.write(
83
+ `${file}: the needs lint found no jobs; the gh-aw lock layout changed. ` +
84
+ `Re-anchor undeclaredNeeds() in loops/scripts/compile-agent-workflows.mjs.\n`,
85
+ );
86
+ process.exitCode = 1;
87
+ }
88
+ for (const v of lint.violations) {
89
+ process.stderr.write(
90
+ `${file}: job "${v.job}" reads needs.${v.ref} but its needs are [${v.needs.join(", ")}]; ` +
91
+ `the reference resolves to '' at runtime. For activation, list the job under on.needs in the ` +
92
+ `source frontmatter; for a custom job, add it to that job's needs.\n`,
93
+ );
94
+ process.exitCode = 1;
95
+ }
96
+ }
97
+
98
+ // gh aw compile leaves a lock alone when its frontmatter and body hashes still match the source.
99
+ // This script then ran over a lock it had already patched: AGENTMEMORY_URL was inserted a second
100
+ // time, the opencode install line was wrapped in itself, and the consumer freshness check could
101
+ // never be clean (Pliny-Bot agentics-checks run 33963600563). The sentinel marks a processed
102
+ // lock; a lock that carries it is linted and otherwise left as it is.
103
+ const SENTINEL = "# post-processed by scripts/compile-agent-workflows.mjs; run it again and nothing changes";
104
+
105
+ for (const file of readdirSync(workflowDirectory)) {
106
+ if (!file.endsWith(".lock.yml")) continue;
107
+
108
+ const path = join(workflowDirectory, file);
109
+ const content = readFileSync(path, "utf8");
110
+ if (content.includes(SENTINEL)) {
111
+ lintLock(file, content);
112
+ continue;
113
+ }
114
+ const patched = content
115
+ .replace(/^(# This file was automatically generated by gh-aw[^\n]*\n)/m, `$1${SENTINEL}\n`)
116
+ // The engine command is normalized to one shape:
117
+ // - OPENCODE_DISABLE_MODELS_FETCH=1: opencode otherwise fetches its model
118
+ // registry from https://models.opencode.ai/api.json at startup and blocks
119
+ // bootstrap on it. Inside the awf sandbox that host is not on the egress
120
+ // allowlist, so the fetch hangs and every agent run stalls right after
121
+ // "init" until the runner kills it. The forge provider declares its models
122
+ // explicitly, so the remote registry is not needed here.
123
+ // - --print-logs + a non-silent level: stripping --print-logs and forcing
124
+ // ERROR hid every engine log, so a stalled bootstrap looked like silence.
125
+ .replace(/opencode run(?: --print-logs)? --log-level (?:DEBUG|ERROR|INFO)/g, "OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --print-logs --log-level INFO")
126
+ .replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
127
+ .replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
128
+ .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
129
+ // The MCP gateway is the only thing the agent publishes on the host loopback, so it is the
130
+ // one thing two runners on the same machine cannot share. Honour an inherited port so each
131
+ // runner service can pick its own; everything else is namespaced by its Docker daemon.
132
+ // awf names its containers awf-agent, awf-squid and awf-api-proxy with no way to change
133
+ // them, so two agent jobs on one host recreate each other's containers and the first dies
134
+ // with exit 137. Giving each runner its own user and daemon isolated the containers but
135
+ // broke everything the runners share through the filesystem: awf and gh-aw both write
136
+ // fixed-name files into /tmp, and a file one user creates cannot be overwritten by the
137
+ // next. Six separate failures came from that. One host lock is the smaller trade: agent
138
+ // jobs run one at a time, every other job still uses all the runners, and every user is
139
+ // the same again. The chain is serial anyway, so in practice this only queues one consumer behind
140
+ // the other. Held for the whole agent run, which is deliberate. The lock lives outside the
141
+ // /tmp/gh-aw namespace on purpose: the keying rewrite below would otherwise make the lock
142
+ // file per job, which is silently no lock at all.
143
+ // Agent jobs run in parallel again. Each runner has its own Docker-in-Docker daemon, so
144
+ // awf's fixed container names (awf-agent, awf-squid, awf-api-proxy, awmg-mcpg) live in
145
+ // separate namespaces and cannot recreate each other. gh-aw detects the tcp DOCKER_HOST
146
+ // and switches to its ARC/DinD path by itself, so no flag is needed here. The host-wide
147
+ // flock this replaces made every agent job wait for the previous one.
148
+ // Three host-global resources were left, and concurrent agent jobs fought over all of them.
149
+ // The global npm install rewrote the opencode binary another job was executing and killed it
150
+ // with SIGKILL mid-run; the warm server and its data directory were shared by every runner.
151
+ //
152
+ // All of these sites are step-level run: or env:, where the runner context is available. It
153
+ // is not available at workflow level, which is what broke the staging path earlier.
154
+ //
155
+ // Install only when the pinned version is missing, and take a lock so two jobs starting at
156
+ // once cannot both write the global prefix.
157
+ // gh-aw pins opencode-ai 1.2.14 at every release checked, up to v0.90.0. That version is
158
+ // from 2026-02-25. The harness expects 1.18.9 and this is the current release, so a gh-aw
159
+ // upgrade does not move it and the pin is ours to choose.
160
+ // gh-aw defaults NPM_CONFIG_MIN_RELEASE_AGE to 3 days, which blocks any package
161
+ // published in the last three days with ETARGET "No matching version found ... with a
162
+ // date before". Lowered to 1 so a fix released yesterday is usable. This is a
163
+ // supply-chain cooldown: shortening it accepts a newer package sooner.
164
+ .replace(/NPM_CONFIG_MIN_RELEASE_AGE: ['\"]?3['\"]?/g, "NPM_CONFIG_MIN_RELEASE_AGE: '1'")
165
+ // The MCP gateway runs inside a per-runner Docker-in-Docker daemon. gh-aw publishes it on
166
+ // 127.0.0.1, which is the DinD container's own loopback, while the outer publish forwards to
167
+ // that container's eth0, so the two never meet and gh-aw's health check fails 120 times with
168
+ // ECONNREFUSED. Binding 0.0.0.0 inside the daemon connects them. The host still exposes the
169
+ // port on loopback only, because the DinD container is published as 127.0.0.1:<port>.
170
+ .replaceAll("-p 127.0.0.1:'\"${MCP_GATEWAY_PORT}\"':", "-p '\"${MCP_GATEWAY_PORT}\"':")
171
+ // Each runner needs its own gateway port: four DinD daemons publish through to one host
172
+ // loopback, so two jobs on 8080 would collide. The runner sets it in .env.
173
+ // awf chroots into the Docker daemon's filesystem and requires it to be the runner's own
174
+ // glibc host. A Docker-in-Docker daemon fails with "Detected Alpine/musl host filesystem
175
+ // under /host", and no dind image fixes it: the agent needs the runner's toolchain and
176
+ // workspace, which a separate daemon container never has. So every agent job shares one
177
+ // daemon, awf's container names are fixed, and the jobs must be serialised.
178
+ .replaceAll(' awf --config', ' flock /tmp/agentic-awf.lock awf --config')
179
+ .replaceAll('export MCP_GATEWAY_PORT="8080"', 'export MCP_GATEWAY_PORT="${MCP_GATEWAY_PORT:-8080}"')
180
+ // The agent's safe outputs land in safeoutputs.jsonl, but the collector reads
181
+ // agent_output.json, and nothing in the generated workflow converts one to the other.
182
+ // With the OpenCode engine the tool calls go through the MCP server rather than stdout,
183
+ // so the log parser never produces agent_output.json and the placeholder step writes
184
+ // {"items":[]} over a run that did real work: the job goes green and the pull request
185
+ // is silently dropped. Build the collector's input from what the agent actually emitted.
186
+ .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")
187
+ // The "Copy Safe Outputs" step runs on the host after the awf container exits. At that
188
+ // point /tmp/gh-aw/ IS the host's /tmp/gh-aw/, where the safeoutputs MCP container wrote
189
+ // patch and bundle files (via the MCP gateway's -v /tmp:/tmp:rw mount). Extend the step
190
+ // to also copy those files to RUNNER_TEMP/gh-aw/ so the upload step can include them.
191
+ // Without this, the upload step's glob /tmp/gh-aw/aw-*.patch runs inside the awf chroot
192
+ // (where /tmp/gh-aw is a different path) and finds nothing. The safe_outputs and conclude
193
+ // jobs then receive no patches in the artifact and silently skip the push.
194
+ .replace(
195
+ ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n',
196
+ ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n' +
197
+ ' # Copy patch/bundle files generated by the safeoutputs MCP container to\n' +
198
+ ' # RUNNER_TEMP/gh-aw/ so the upload step includes them in the artifact.\n' +
199
+ ' mkdir -p "${RUNNER_TEMP}/gh-aw"\n' +
200
+ ' ls -la /tmp/gh-aw/ 2>/dev/null || true\n' +
201
+ ' cp /tmp/gh-aw/aw-*.patch "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
202
+ ' cp /tmp/gh-aw/aw-*.bundle "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
203
+ ' ls -la "${RUNNER_TEMP}/gh-aw/"aw-* 2>/dev/null || true\n')
204
+ // Add patch/bundle paths to the upload glob so the agent artifact carries what
205
+ // push-agent-branch needs. The anchor is the two adjacent path lines of the
206
+ // "...agent" upload step; the "...agent-output-fallback" upload lists them in the
207
+ // opposite order, so it cannot match. Anchoring matters: the first bare
208
+ // '/tmp/gh-aw/agent_output.json\n' in the file is the placeholder step's echo line,
209
+ // and replacing there corrupted the placeholder into executing the globs as
210
+ // commands while the upload list stayed without bundles. The artifact then carried
211
+ // no bundle, push-agent-branch silently skipped the push, and the gate reported
212
+ // "remediated" on a pull request that was never touched.
213
+ // Only the /tmp/gh-aw globs may be added: a ${{ runner.temp }} path as well would
214
+ // move the upload's least-common-ancestor from /tmp/gh-aw to /, and every file in
215
+ // the artifact (agent_output.json included) would gain a prefix that
216
+ // download-agent-output does not look for.
217
+ .replace(
218
+ ' /tmp/gh-aw/safeoutputs.jsonl\n /tmp/gh-aw/agent_output.json\n',
219
+ ' /tmp/gh-aw/safeoutputs.jsonl\n' +
220
+ ' /tmp/gh-aw/aw-*.patch\n' +
221
+ ' /tmp/gh-aw/aw-*.bundle\n' +
222
+ ' /tmp/gh-aw/agent_output.json\n')
223
+ // "A failed run is already a red run": report-failure-as-issue: false and
224
+ // GH_AW_REPORT_FAILED_JOBS: "false" were supposed to silence issue creation, but
225
+ // 0.87.5's engine-failure reporter files an "[aw] ... failed" issue anyway (seen as
226
+ // Pliny-Bot #57: an OOM-killed gate produced a fresh issue nobody needs, while the
227
+ // retry belt is the actual remediation). `if: false` keeps the step compiled but
228
+ // never runs it, so the failure stays visible in the run and in the belt's attempt
229
+ // comment, and out of the issue tracker.
230
+ .replace(
231
+ /^([ \t]+)- name: Report failed jobs\n([ \t]*)id: report_failed_jobs\n[ \t]*if: always\(\)\n/m,
232
+ '$1- name: Report failed jobs\n$2id: report_failed_jobs\n$2if: false # never: failures surface in the run and the retry belt\n')
233
+
234
+ // v0.90.0's arc-dind mode stages the engine CLI to a daemon-visible path but assumes the
235
+ // Copilot engine: command -v copilot is empty under engine: opencode and cp "" fails the
236
+ // job before the agent starts. OpenCode needs no staging, because npm -g installs it under
237
+ // the tool-cache prefix inside _work, which the dind daemon shares.
238
+ // Centralised AgentMemory: an always-on App Service; the shim proxies to it when the
239
+ // URL is set and authenticates with the HMAC secret. Local development leaves both
240
+ // unset and keeps the local per-run store.
241
+ .replace(/^env:\n/m, 'env:\n AGENTMEMORY_URL: https://agentmemory-pro-01.azurewebsites.net\n')
242
+ // the secret is scoped to the one step that runs the agent (semgrep: a secret in
243
+ // workflow-level env is visible to every job and step)
244
+ .replaceAll(' OPENAI_BASE_URL: http://host.docker.internal:10001',
245
+ ' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1')
246
+ .replaceAll(' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1',
247
+ ' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1' + '\n' +
248
+ ' AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}')
249
+ // the router passes secrets explicitly (semgrep flags secrets: inherit as over-broad),
250
+ // so the callee must declare everything it accepts
251
+ .replace(/^ COPILOT_GITHUB_TOKEN:/m,
252
+ ' AGENTMEMORY_SECRET:' + '\n' + ' required: false' + '\n' + ' COPILOT_GITHUB_TOKEN:')
253
+ .replaceAll(' COPILOT_SRC="$(command -v copilot)"',
254
+ ' command -v copilot >/dev/null 2>&1 || { echo "no copilot binary (engine is opencode); skipping"; exit 0; }' + '\n' +
255
+ ' COPILOT_SRC="$(command -v copilot)"')
256
+ // "A failed run is already a red run": report-failure-as-issue: false silences one
257
+ // reporter, but gh-aw hardcodes a second one (report_failed_jobs) that files an
258
+ // "[aw] Failed jobs" issue per red run. Same philosophy, same off switch.
259
+ .replaceAll('GH_AW_REPORT_FAILED_JOBS: "true"', 'GH_AW_REPORT_FAILED_JOBS: "false"')
260
+ // gh-aw injects this "Restore .NET dependencies" step whenever a consumer's
261
+ // frontend (e.g. VERIFY_COMMANDS) references dotnet. An AI agent can create a
262
+ // .claude/worktrees/agent-XXX/ worktree at restore time — the first .slnx find
263
+ // matches that scratch dir, and the hardcoded path lands in the lock, breaking
264
+ // every agent run with MSB1009. Replace with dynamic discovery that excludes
265
+ // agent scratch directories so a stale worktree never poisons the restore.
266
+ .replace(
267
+ /^( +)- name: Restore \.NET dependencies\n\1 run: dotnet restore .*$/m,
268
+ (_m, indent) =>
269
+ indent + '- name: Restore .NET dependencies\n' + indent +
270
+ ' run: |-\n' + indent +
271
+ ' SLNX=$(find . -maxdepth 5 -name "*.slnx" -not -path "./.claude/*" -not -path "./.opencode/*" | head -1)\n' + indent +
271
272
  ' [ -n "$SLNX" ] && dotnet restore "$SLNX" || echo "No .slnx found, skipping restore"')
272
- .replace(
273
- /^( +)- name: Setup \.NET\n\1 uses: actions\/setup-dotnet@[^\n]*\n\1 with:\n((?:\1 [^\n]*\n)+)(?!\1 env:)/gm,
274
- (_m, indent, withBody) =>
275
- indent + '- name: Setup .NET\n' + indent + ' uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0\n' +
276
- indent + ' with:\n' + withBody +
277
- indent + ' env:\n' + indent + ' DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet\n')
278
- .replace(/opencode-ai@[0-9][0-9.]*/g, 'opencode-ai@' + OPENCODE_VERSION)
279
- // gh-aw installs with --ignore-scripts, which blocks opencode's own postinstall. That
280
- // script downloads the platform binary, so 1.18 fails at first use with "opencode-ai's
281
- // postinstall script was not run". 1.2.14 did not need it. Run only opencode's postinstall
282
- // explicitly, so every other package's scripts stay blocked.
283
- .replace(
284
- /npm install --ignore-scripts -g opencode-ai@([0-9][0-9.]*)/g,
285
- (_m, v) =>
286
- "opencode --version 2>/dev/null | grep -qF '" + v + "' || " +
287
- "flock /tmp/opencode-install.lock sh -c 'npm install --ignore-scripts -g opencode-ai@" + v +
288
- " && cd \"$(npm root -g)/opencode-ai\" && node postinstall.mjs' # opencode-ai@" + v)
289
- // One warm server per runner, on its own port, with its own data directory. Keyed on the
290
- // runner rather than the run so a runner keeps its server warm between its own jobs.
291
- // No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
292
- // would have to be escaped. The default has no spaces, so bare is safe shell.
293
- .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}');
294
-
295
- let rewritten = patched;
296
- // A queued implement executes hours after its run was created, but actions/checkout
297
- // without a ref checks out github.sha, which GitHub pins at run creation. Five children
298
- // queued together at 02:01 all carried the 02:01 base; once the first sibling merged,
299
- // every later push became a rebase onto a moved parent, which gh-aw's signed-commit
300
- // push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
301
- // pull request as an issue instead of a pull request). Checking out the branch tip at
302
- // execution time makes the commit parent current main; the implement-global queue
303
- // already keeps sibling merges out of the window while the agent runs.
304
- if (file === "agent-implement.lock.yml") {
305
- const start = rewritten.indexOf("\n agent:\n");
306
- const rest = start === -1 ? "" : rewritten.slice(start + 1);
307
- const next = rest.search(/\n [a-z_][a-zA-Z_-]*:\n/);
308
- if (start !== -1 && next !== -1) {
309
- const end = start + 1 + next;
310
- const span = rewritten.slice(start, end).replace(
311
- /(uses: actions\/checkout@[^\n]*\n(\s+)with:\n(?:\2 [^\n]+\n)*)/,
312
- (whole, _block, indent) =>
313
- whole.includes("ref:") ? whole : whole + indent + " ref: ${{ github.event.repository.default_branch }}\n",
314
- );
315
- rewritten = rewritten.slice(0, start) + span + rewritten.slice(end);
316
- }
317
- }
318
-
319
- // Guard the two rewrites the compiled agent jobs cannot work without. The anchors are
320
- // upstream template text: when a gh-aw upgrade reshapes either step, the silent
321
- // failure mode returns (an artifact with no bundle, a skipped push, a green gate on
322
- // an untouched pull request). Failing the compile is the only loud signal there is.
323
- for (const check of [
324
- { 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/ },
325
- { 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/ },
326
- { name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
327
- ]) {
328
- if (!check.ok.test(rewritten)) {
329
- process.stderr.write(
330
- `compile patch "${check.name}" no longer matches in ${file}: the gh-aw template changed. ` +
331
- `Re-anchor the rewrite in loops/scripts/compile-agent-workflows.mjs, or agent pushes are silently dropped.\n`,
332
- );
333
- process.exitCode = 1;
334
- }
335
- }
336
-
337
- if (!rewritten.includes(SENTINEL)) {
338
- process.stderr.write(
339
- `${file}: the generated-file header changed and the post-processing sentinel was not placed; ` +
340
- `re-anchor the SENTINEL insert in loops/scripts/compile-agent-workflows.mjs.\n`,
341
- );
342
- process.exitCode = 1;
343
- }
344
- lintLock(file, rewritten);
345
-
346
- if (rewritten !== content) writeFileSync(path, rewritten);
347
- }
273
+ .replace(
274
+ /^( +)- name: Setup \.NET\n\1 uses: actions\/setup-dotnet@[^\n]*\n\1 with:\n((?:\1 [^\n]*\n)+)(?!\1 env:)/gm,
275
+ (_m, indent, withBody) =>
276
+ indent + '- name: Setup .NET\n' + indent + ' uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0\n' +
277
+ indent + ' with:\n' + withBody +
278
+ indent + ' env:\n' + indent + ' DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet\n')
279
+ // OpenCode 2 ships as @opencode/cli, not opencode-ai. gh-aw's engine manifest
280
+ // still names opencode-ai and installs it with --ignore-scripts, which blocks
281
+ // the postinstall that selects the platform binary. Rewrite the install to the
282
+ // V2 package with scripts enabled, then rename every remaining reference,
283
+ // versioned or bare (a path, a comment).
284
+ .replace(
285
+ /npm install --ignore-scripts -g opencode-ai@([0-9][0-9.]*)/g,
286
+ () =>
287
+ "opencode --version 2>/dev/null | grep -qF '" + OPENCODE_VERSION + "' || " +
288
+ "flock /tmp/opencode-install.lock npm install -g " + OPENCODE_PACKAGE + "@" + OPENCODE_VERSION +
289
+ " # " + OPENCODE_PACKAGE + "@" + OPENCODE_VERSION)
290
+ .replace(/opencode-ai@[0-9][0-9.]*/g, OPENCODE_PACKAGE + '@' + OPENCODE_VERSION)
291
+ .replace(/opencode-ai\b/g, OPENCODE_PACKAGE)
292
+ // One warm server per runner, on its own port, with its own data directory. Keyed on the
293
+ // runner rather than the run so a runner keeps its server warm between its own jobs.
294
+ // No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
295
+ // would have to be escaped. The default has no spaces, so bare is safe shell.
296
+ .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}');
297
+
298
+ let rewritten = patched;
299
+ // A queued implement executes hours after its run was created, but actions/checkout
300
+ // without a ref checks out github.sha, which GitHub pins at run creation. Five children
301
+ // queued together at 02:01 all carried the 02:01 base; once the first sibling merged,
302
+ // every later push became a rebase onto a moved parent, which gh-aw's signed-commit
303
+ // push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
304
+ // pull request as an issue instead of a pull request). Checking out the branch tip at
305
+ // execution time makes the commit parent current main; the implement-global queue
306
+ // already keeps sibling merges out of the window while the agent runs.
307
+ if (file === "agent-implement.lock.yml") {
308
+ const start = rewritten.indexOf("\n agent:\n");
309
+ const rest = start === -1 ? "" : rewritten.slice(start + 1);
310
+ const next = rest.search(/\n [a-z_][a-zA-Z_-]*:\n/);
311
+ if (start !== -1 && next !== -1) {
312
+ const end = start + 1 + next;
313
+ const span = rewritten.slice(start, end).replace(
314
+ /(uses: actions\/checkout@[^\n]*\n(\s+)with:\n(?:\2 [^\n]+\n)*)/,
315
+ (whole, _block, indent) =>
316
+ whole.includes("ref:") ? whole : whole + indent + " ref: ${{ github.event.repository.default_branch }}\n",
317
+ );
318
+ rewritten = rewritten.slice(0, start) + span + rewritten.slice(end);
319
+ }
320
+ }
321
+
322
+ // Guard the two rewrites the compiled agent jobs cannot work without. The anchors are
323
+ // upstream template text: when a gh-aw upgrade reshapes either step, the silent
324
+ // failure mode returns (an artifact with no bundle, a skipped push, a green gate on
325
+ // an untouched pull request). Failing the compile is the only loud signal there is.
326
+ for (const check of [
327
+ { 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/ },
328
+ { 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/ },
329
+ { name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
330
+ ]) {
331
+ if (!check.ok.test(rewritten)) {
332
+ process.stderr.write(
333
+ `compile patch "${check.name}" no longer matches in ${file}: the gh-aw template changed. ` +
334
+ `Re-anchor the rewrite in loops/scripts/compile-agent-workflows.mjs, or agent pushes are silently dropped.\n`,
335
+ );
336
+ process.exitCode = 1;
337
+ }
338
+ }
339
+
340
+ if (!rewritten.includes(SENTINEL)) {
341
+ process.stderr.write(
342
+ `${file}: the generated-file header changed and the post-processing sentinel was not placed; ` +
343
+ `re-anchor the SENTINEL insert in loops/scripts/compile-agent-workflows.mjs.\n`,
344
+ );
345
+ process.exitCode = 1;
346
+ }
347
+ lintLock(file, rewritten);
348
+
349
+ if (rewritten !== content) writeFileSync(path, rewritten);
350
+ }
@@ -1,54 +1,60 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "model": "forge/glm-5-2",
4
- "plugin": [],
5
4
  "default_agent": "ci-workflow-agent",
6
- "agent": {
5
+ "agents": {
7
6
  "ci-workflow-agent": {
8
7
  "description": "Executes GitHub Agentic Workflow tasks in CI.",
9
8
  "mode": "primary",
10
- "prompt": "You execute the GitHub Agentic Workflow task in the user prompt. Read AGENTS.md before any non-trivial change. Load only skills the workflow task explicitly names via skill(); when a workflow says load @skill-name, call skill(\"skill-name\"), the @ prefix is just a hint to use the skill tool. Execute only the requested task and required commands. Do not probe or inspect unrelated commands. The GitHub CLI is intentionally unauthenticated: never use `gh` for GitHub reads or writes. Read supplied workflow files first, then use the GitHub MCP tools only when fresh GitHub data is required. Use direct `safeoutputs` commands for GitHub writes. Each safe-output type has a small per-run allowance, and a call that fails still spends it: never send an empty, partial or test payload to discover what a tool accepts, because that probe can be recorded as a success, take the allowance with it, and leave the real call refused as over the limit, ending a run that did all the work and published none of it. Call each safe-output tool once, with the complete final payload, only when you are ready to commit to the outcome. For syntax-only validation of a conventional GitHub Actions YAML file, use Ruby's standard YAML library; do not probe, install, or fall back through PyYAML, js-yaml, actionlint, or Docker. A task is complete only after every required Safe Outputs command succeeds. After the final required Safe Outputs command, stop immediately without further tools, prose, or model turns. Use `mcpscripts` only when the runtime explicitly lists it as available.\n\nOUTPUT DISCIPLINE: Do not narrate. Do not explain what you are about to do before doing it. Do not write prose between tool calls. Call tools silently. The only prose you produce is the final result or a brief error explanation when something fails. Never output sentences like 'Let me check...', 'Now I will...', 'I have enough context...', or 'Next I need to...'. If you are about to call a tool, just call it. If you must think, use the todowrite tool, not prose.\n\nMEMORY: agentmemory is the shared long-term store. Start non-trivial tasks by calling its recall/search tool with the key concepts of the task (component names, error text) and reuse what it returns instead of rediscovering it. Before finishing, store 1-3 concise memories of durable non-obvious learnings: architecture constraints, root causes and their fixes, decisions and their why. Skip trivia the repo already documents. Never store secrets or tokens."
9
+ "system": "You execute the GitHub Agentic Workflow task in the user prompt. Read AGENTS.md before any non-trivial change. Load only skills the workflow task explicitly names via skill(); when a workflow says load @skill-name, call skill(\"skill-name\"), the @ prefix is just a hint to use the skill tool. Execute only the requested task and required commands. Do not probe or inspect unrelated commands. The GitHub CLI is intentionally unauthenticated: never use `gh` for GitHub reads or writes. Read supplied workflow files first, then use the GitHub MCP tools only when fresh GitHub data is required. Use direct `safeoutputs` commands for GitHub writes. Each safe-output type has a small per-run allowance, and a call that fails still spends it: never send an empty, partial or test payload to discover what a tool accepts, because that probe can be recorded as a success, take the allowance with it, and leave the real call refused as over the limit, ending a run that did all the work and published none of it. Call each safe-output tool once, with the complete final payload, only when you are ready to commit to the outcome. For syntax-only validation of a conventional GitHub Actions YAML file, use Ruby's standard YAML library; do not probe, install, or fall back through PyYAML, js-yaml, actionlint, or Docker. A task is complete only after every required Safe Outputs command succeeds. After the final required Safe Outputs command, stop immediately without further tools, prose, or model turns. Use `mcpscripts` only when the runtime explicitly lists it as available.\n\nOUTPUT DISCIPLINE: Do not narrate. Do not explain what you are about to do before doing it. Do not write prose between tool calls. Call tools silently. The only prose you produce is the final result or a brief error explanation when something fails. Never output sentences like 'Let me check...', 'Now I will...', 'I have enough context...', or 'Next I need to...'. If you are about to call a tool, just call it. If you must think, use the todowrite tool, not prose.\n\nMEMORY: agentmemory is the shared long-term store. Start non-trivial tasks by calling its recall/search tool with the key concepts of the task (component names, error text) and reuse what it returns instead of rediscovering it. Before finishing, store 1-3 concise memories of durable non-obvious learnings: architecture constraints, root causes and their fixes, decisions and their why. Skip trivia the repo already documents. Never store secrets or tokens."
11
10
  },
12
11
  "finding-verifier": {
13
12
  "description": "Use to verify a single claim about a code change: whether a suspected defect is real. Reads the code fresh and tries to disprove the claim, with no sight of the reasoning that produced it. Returns whether it is verified, and the evidence.",
14
13
  "mode": "subagent",
15
- "temperature": 0,
16
- "prompt": "You are handed ONE claim about a code change and asked whether it is real. You did not write it and you have not seen the reasoning behind it. Your job is to try to DISPROVE it.\n\nRead the code the claim names, and the code around it. Run the test or the command that would settle it if one exists. Follow the path from entry point to the line in question and check whether the condition the claim depends on can actually hold.\n\nAnswer with a JSON object and nothing else:\n{\"verified\": true|false, \"verification\": \"the command you ran and what it printed, or the code path quoted end to end\", \"severity\": \"critical|high|medium|low\", \"reason\": \"one sentence\"}\n\n`verified: true` means you demonstrated the problem, not that you failed to rule it out. If you could not reproduce it, if the guard the claim says is missing is actually present upstream, or if the claim is about style, taste or a hypothetical caller that does not exist in this repository, answer `verified: false` and say why. Disagreeing with the claim is a useful result and costs you nothing. Do not soften a real defect and do not manufacture one. Never edit a file, never push, never call a safe output."
17
- }
18
- },
19
- "permission": {
20
- "read": "allow",
21
- "external_directory": {
22
- "/tmp/**": "allow",
23
- "/home/runner/work/_temp/gh-aw/**": "allow"
14
+ "system": "You are handed ONE claim about a code change and asked whether it is real. You did not write it and you have not seen the reasoning behind it. Your job is to try to DISPROVE it.\n\nRead the code the claim names, and the code around it. Run the test or the command that would settle it if one exists. Follow the path from entry point to the line in question and check whether the condition the claim depends on can actually hold.\n\nAnswer with a JSON object and nothing else:\n{\"verified\": true|false, \"verification\": \"the command you ran and what it printed, or the code path quoted end to end\", \"severity\": \"critical|high|medium|low\", \"reason\": \"one sentence\"}\n\n`verified: true` means you demonstrated the problem, not that you failed to rule it out. If you could not reproduce it, if the guard the claim says is missing is actually present upstream, or if the claim is about style, taste or a hypothetical caller that does not exist in this repository, answer `verified: false` and say why. Disagreeing with the claim is a useful result and costs you nothing. Do not soften a real defect and do not manufacture one. Never edit a file, never push, never call a safe output.",
15
+ "request": {
16
+ "body": {
17
+ "temperature": 0
18
+ }
19
+ }
24
20
  }
25
21
  },
26
- "lsp": {
27
- "csharp": {
28
- "disabled": true
22
+ "permissions": [
23
+ {
24
+ "action": "read",
25
+ "resource": "*",
26
+ "effect": "allow"
29
27
  },
30
- "fsharp": {
31
- "disabled": true
28
+ {
29
+ "action": "external_directory",
30
+ "resource": "/tmp/**",
31
+ "effect": "allow"
32
32
  },
33
- "razor": {
34
- "disabled": true
33
+ {
34
+ "action": "external_directory",
35
+ "resource": "/home/runner/work/_temp/gh-aw/**",
36
+ "effect": "allow"
35
37
  }
36
- },
37
- "provider": {
38
+ ],
39
+ "providers": {
38
40
  "forge": {
39
- "api": "{env:OPENAI_BASE_URL}",
40
- "options": {
41
+ "package": "aisdk:@ai-sdk/openai-compatible",
42
+ "settings": {
43
+ "baseURL": "{env:OPENAI_BASE_URL}",
41
44
  "apiKey": "{env:OPENAI_API_KEY}"
42
45
  },
43
46
  "models": {
44
47
  "glm-5-2": {
45
48
  "name": "GLM 5.3",
46
- "tool_call": true,
47
- "reasoning": true,
48
- "attachment": true,
49
- "modalities": {
50
- "input": ["text", "image"],
51
- "output": ["text"]
49
+ "capabilities": {
50
+ "tools": true,
51
+ "input": [
52
+ "text",
53
+ "image"
54
+ ],
55
+ "output": [
56
+ "text"
57
+ ]
52
58
  },
53
59
  "limit": {
54
60
  "context": 1048576,
@@ -58,12 +64,15 @@
58
64
  },
59
65
  "quasar-438b": {
60
66
  "name": "GLM 5.2",
61
- "tool_call": true,
62
- "reasoning": true,
63
- "attachment": true,
64
- "modalities": {
65
- "input": ["text", "image"],
66
- "output": ["text"]
67
+ "capabilities": {
68
+ "tools": true,
69
+ "input": [
70
+ "text",
71
+ "image"
72
+ ],
73
+ "output": [
74
+ "text"
75
+ ]
67
76
  },
68
77
  "limit": {
69
78
  "context": 1048576,
@@ -74,4 +83,4 @@
74
83
  }
75
84
  }
76
85
  }
77
- }
86
+ }
@@ -202,5 +202,5 @@ pre-agent-steps:
202
202
  # `$existing * $base`. Base wins on conflicting keys, but it defines neither `model`
203
203
  # nor this provider, so both survive and `awf-proxy` is added alongside.
204
204
  echo "Wrote $CONFIG from $FRAGMENT:"
205
- jq -r '" model: \(.model // "unset")", " plugins: \(.plugin // [] | join(", "))", " providers: \(.provider // {} | keys | join(", "))"' "$CONFIG"
205
+ jq -r '" model: \(.model // "unset")", " plugins: \(.plugins // [] | join(", "))", " providers: \(.providers // {} | keys | join(", "))"' "$CONFIG"
206
206
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.28.26",
3
+ "version": "1.0.1",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",