@plainconceptsplatform/workflows 1.6.23 → 1.6.41

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.
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js"
5
5
  import { packageVersion } from "./package-baseline.js";
6
6
  import { routeNames, templateNames } from "./workflow-catalog.js";
7
7
  import { runInteractive } from "./tui.js";
8
+ import { realpathSync } from "node:fs";
8
9
  import { resolve } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
10
11
  const HELP_TEXT = `Workflows CLI install and update Plain Concepts Platform agentic workflows.
@@ -269,7 +270,17 @@ function fail(message) {
269
270
  console.error(message);
270
271
  return 1;
271
272
  }
272
- if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
273
+ // pnpm installs the package behind a symlink: argv[1] is the link and import.meta.url the real file,
274
+ // so a plain comparison never matched and the installed CLI exited silently, --version included.
275
+ function realPath(path) {
276
+ try {
277
+ return realpathSync(path);
278
+ }
279
+ catch {
280
+ return resolve(path);
281
+ }
282
+ }
283
+ if (process.argv[1] !== undefined && realPath(process.argv[1]) === realPath(fileURLToPath(import.meta.url))) {
273
284
  void run(process.argv.slice(2)).then((exitCode) => {
274
285
  process.exitCode = exitCode;
275
286
  });
@@ -61,9 +61,9 @@ runs:
61
61
  run: |
62
62
  set -euo pipefail
63
63
  # The agent job belongs to this same run: a called workflow shares the caller's run id.
64
- read -r job_id started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
64
+ read -r job_id started finished conclusion <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
65
65
  --jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
66
- | "\(.id // "") \(.started_at // "") \(.completed_at // "")"')"
66
+ | "\(.id // "") \(.started_at // "") \(.completed_at // "") \(.conclusion // "")"')"
67
67
  minutes=-1
68
68
  if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
69
69
  minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
@@ -91,6 +91,14 @@ runs:
91
91
  fi
92
92
  rm -rf "$work"
93
93
  fi
94
+ # A cancelled agent job is a person or a newer run taking the work back, not an outage.
95
+ # Pliny-Bot #816: refine was cancelled by hand and its label removed, then this step read
96
+ # the eight-minute run as a provider failure, sent it back through the router and parked
97
+ # the issue as stalled.
98
+ if [ "${conclusion:-}" = cancelled ]; then
99
+ retry=false
100
+ cause=cancelled
101
+ fi
94
102
  {
95
103
  echo "retry=$retry"
96
104
  echo "next=$((attempts + 1))"
@@ -311,7 +311,17 @@ runs:
311
311
  });
312
312
  const told = comments.find((c) => (c.body ?? '').includes('<!-- stack-complete -->'));
313
313
  if (!told) {
314
- needsHuman.push({ kind: 'stack-ready', issue, pull: root, waited: age(issue.updated_at) });
314
+ // The router announces a finished stack when a child's pull request merges into it.
315
+ // A child closed any other way (by hand, or by this sweep) never raised that event,
316
+ // and Pliny-Bot #781 sat finished with no announcement, which is what the merge train
317
+ // waits for before it lets the root merge. Announce it here; the root's own gate and
318
+ // CI still decide whether it merges.
319
+ await act(`#${rootPr}: announcing the finished stack of #${issue.number}`, async () => {
320
+ await github.rest.issues.createComment({
321
+ ...context.repo, issue_number: rootPr,
322
+ body: `Stack complete: every child of #${issue.number} has landed on the feature branch. This root pull request is ready to merge into ${context.payload.repository?.default_branch ?? 'main'}: the merge gate decides it next, and a person is asked only when its blast radius or a finding says so. <!-- stack-complete -->`,
323
+ });
324
+ });
315
325
  } else if (age(told.created_at) > stalePrMs) {
316
326
  needsHuman.push({ kind: 'stack-held', issue, pull: root, waited: age(told.created_at) });
317
327
  }
@@ -28,6 +28,10 @@ inputs:
28
28
  description: How many times a blocked verdict, or a merge that did not happen, is retried after the base branch moves.
29
29
  required: false
30
30
  default: '3'
31
+ stack-root-automerge:
32
+ description: A stack root whose children have all landed is merged by the gate like any other pull request, unless its blast radius or a finding sends it to a person. false keeps every root for a human.
33
+ required: false
34
+ default: 'true'
31
35
  max-repairs:
32
36
  description: How many pull requests may be in repair at once. Merges stay one at a time.
33
37
  required: false
@@ -69,6 +73,7 @@ runs:
69
73
  HOLD_LABEL: ${{ inputs.hold-label }}
70
74
  MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
71
75
  MAX_RETRIES: ${{ inputs.max-retries }}
76
+ STACK_ROOT_AUTOMERGE: ${{ inputs.stack-root-automerge }}
72
77
  STUCK_HOURS: ${{ inputs.stuck-hours }}
73
78
  MAIN_RED_WAIT_HOURS: ${{ inputs.main-red-wait-hours }}
74
79
  MAX_REPAIRS: ${{ inputs.max-repairs }}
@@ -114,14 +114,19 @@ train_move() {
114
114
  # BASE_MOVED true when the base branch has a commit newer than the verdict
115
115
  # RETRIES blocked or failed-merge verdicts already given on this head
116
116
  # MAX_RETRIES how many of those the train retries before leaving it to a person
117
- # STACK_ROOT true for a stack's root pull request, which a person always merges
117
+ # STACK_ROOT true for a stack's root pull request, which a person merges unless it is ready
118
+ # STACK_ROOT_READY true for a root whose stack was announced complete after the last verdict, with
119
+ # STACK_ROOT_AUTOMERGE on: the train gives it one more gate run, which merges it
120
+ # unless the blast radius or a finding says a person must
118
121
  train_park() {
119
122
  local park=none
120
123
  if [ "${VERDICT_NEWER:-false}" = true ]; then
121
124
  case "${VERDICT:-}" in
122
125
  remediated | "") park=none ;;
123
126
  blocked | auto-merge)
124
- if [ "${STACK_ROOT:-false}" = true ]; then
127
+ if [ "${STACK_ROOT:-false}" = true ] && [ "${STACK_ROOT_READY:-false}" = true ]; then
128
+ park=none
129
+ elif [ "${STACK_ROOT:-false}" = true ]; then
125
130
  park=held
126
131
  elif [ "${BASE_MOVED:-false}" = true ] && [ "${RETRIES:-0}" -lt "${MAX_RETRIES:-3}" ]; then
127
132
  park=none
@@ -360,15 +365,23 @@ for line in "${candidates[@]}"; do
360
365
  fi
361
366
 
362
367
  stack_root=false
368
+ stack_root_ready=false
363
369
  if [ "$verdict_newer" = true ] && [ -n "$linked_issue" ]; then
364
370
  if gh issue view "$linked_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null |
365
371
  grep -q "<!-- stack-root-pr: $pr -->"; then
366
372
  stack_root=true
373
+ if [ "${STACK_ROOT_AUTOMERGE:-false}" = true ]; then
374
+ announced_at=$(gh api --paginate "repos/$REPO/issues/$pr/comments?per_page=100" \
375
+ --jq '.[] | select(.body | contains("<!-- stack-complete -->")) | .created_at' 2>/dev/null | sort | tail -1)
376
+ if [ -n "$announced_at" ] && [ "$announced_at" \> "$verdict_at" ]; then
377
+ stack_root_ready=true
378
+ fi
379
+ fi
367
380
  fi
368
381
  fi
369
382
 
370
383
  park=$(VERDICT="$verdict" VERDICT_NEWER="$verdict_newer" BASE_MOVED="$base_moved" \
371
- RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
384
+ RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" STACK_ROOT_READY="$stack_root_ready" train_park)
372
385
 
373
386
  attempts=0
374
387
  silent_gate=false
@@ -0,0 +1,41 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/rebase-stale-patch/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Rebase a stale agent patch
3
+ description: When an agent's patch no longer applies because its base branch moved, rebase the run's bundle onto the base and open the pull request the agent asked for. A real conflict is left for a person.
4
+ inputs:
5
+ token:
6
+ description: App token that can push a branch, open a pull request and close an issue.
7
+ required: true
8
+ run-id:
9
+ description: The workflow run whose agent artifact holds the bundle.
10
+ required: true
11
+ artifact:
12
+ description: Name of the agent artifact, with the run's artifact prefix.
13
+ required: true
14
+ issue-number:
15
+ description: The issue the agent worked on.
16
+ required: true
17
+ default-branch:
18
+ description: Base used when the issue is not part of a stack.
19
+ required: true
20
+ outputs:
21
+ rescued:
22
+ description: '"true" when a pull request was opened from the rebased bundle.'
23
+ value: ${{ steps.rebase.outputs.rescued }}
24
+ pr-number:
25
+ description: The pull request opened, or empty.
26
+ value: ${{ steps.rebase.outputs.pr_number }}
27
+ runs:
28
+ using: composite
29
+ steps:
30
+ - name: Rebase the bundle and open its pull request
31
+ id: rebase
32
+ shell: bash
33
+ env:
34
+ GH_TOKEN: ${{ inputs.token }}
35
+ DOWNLOAD_TOKEN: ${{ github.token }}
36
+ REPO: ${{ github.repository }}
37
+ RUN_ID: ${{ inputs.run-id }}
38
+ ARTIFACT: ${{ inputs.artifact }}
39
+ ISSUE: ${{ inputs.issue-number }}
40
+ DEFAULT_BRANCH: ${{ inputs.default-branch }}
41
+ run: bash "${{ github.action_path }}/rebase-stale-patch.sh"
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bash
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/rebase-stale-patch/rebase-stale-patch.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ # gh-aw pushes an agent's commits onto the base as it is when the run ends. When the base moved
4
+ # under a long run and the commits no longer apply, gh-aw files the patch as an issue and the work
5
+ # waits for a person, although most of the time a plain rebase applies it: Pliny-Bot #787 and #794
6
+ # were both rebased by hand in minutes. This does that rebase here and opens the pull request the
7
+ # agent asked for. A conflict a rebase cannot settle stays with a person, exactly as before.
8
+ #
9
+ # Needs: GH_TOKEN (App), DOWNLOAD_TOKEN (the run's own token, for its artifacts), REPO, RUN_ID, ARTIFACT, ISSUE, DEFAULT_BRANCH, GITHUB_OUTPUT.
10
+
11
+ set -euo pipefail
12
+
13
+ say() { echo "$1"; }
14
+ done_with() {
15
+ echo "rescued=$1" >> "$GITHUB_OUTPUT"
16
+ echo "pr_number=${2:-}" >> "$GITHUB_OUTPUT"
17
+ exit 0
18
+ }
19
+
20
+ work="$(mktemp -d)"
21
+ if ! GH_TOKEN="${DOWNLOAD_TOKEN:-$GH_TOKEN}" gh run download "$RUN_ID" --repo "$REPO" --name "$ARTIFACT" --dir "$work" >/dev/null 2>&1; then
22
+ say "No agent artifact to rebase from."
23
+ done_with false
24
+ fi
25
+
26
+ bundle="$(find "$work" -maxdepth 1 -name '*.bundle' | head -1)"
27
+ output="$work/agent_output.json"
28
+ if [ -z "$bundle" ] || [ ! -f "$output" ]; then
29
+ say "The artifact holds no bundle or no agent output."
30
+ done_with false
31
+ fi
32
+
33
+ request="$(jq -c '[.items[]? | select(.type == "create_pull_request")] | first // empty' "$output")"
34
+ if [ -z "$request" ]; then
35
+ say "The agent did not ask for a pull request."
36
+ done_with false
37
+ fi
38
+ branch="$(jq -r '.branch // empty' <<<"$request")"
39
+ title="$(jq -r '.title // empty' <<<"$request")"
40
+ jq -r '.body // ""' <<<"$request" > "$work/body.md"
41
+ if [ -z "$branch" ] || [ -z "$title" ]; then
42
+ say "The pull request request has no branch or title."
43
+ done_with false
44
+ fi
45
+
46
+ # A stack child targets its feature branch while that branch exists; otherwise the default branch.
47
+ base="$DEFAULT_BRANCH"
48
+ stack="$(gh issue view "$ISSUE" --repo "$REPO" --json body --jq '.body // ""' |
49
+ sed -nE 's/.*<!-- stack-root-branch: ([^ ]+) -->.*/\1/p' | head -1)"
50
+ if [ -n "$stack" ] && gh api "repos/$REPO/branches/$stack" >/dev/null 2>&1; then
51
+ base="$stack"
52
+ fi
53
+
54
+ remote="https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"
55
+ # Full history: the bundle's prerequisites are commits of the base, and a rebase needs their blobs.
56
+ if git rev-parse --is-shallow-repository | grep -q true; then
57
+ git fetch --quiet --unshallow "$remote" "+refs/heads/${base}:refs/remotes/rescue-base/${base}"
58
+ else
59
+ git fetch --quiet "$remote" "+refs/heads/${base}:refs/remotes/rescue-base/${base}"
60
+ fi
61
+ git fetch --quiet "$bundle" "+refs/heads/*:refs/remotes/rescue-bundle/*"
62
+ head="refs/remotes/rescue-bundle/${branch}"
63
+ if ! git rev-parse --verify --quiet "$head" >/dev/null; then
64
+ head="$(git for-each-ref --format='%(refname)' refs/remotes/rescue-bundle | head -1)"
65
+ fi
66
+
67
+ rescued_branch="${branch}-rebased-${RUN_ID}"
68
+ git checkout --quiet -B "$rescued_branch" "$head"
69
+ if ! git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
70
+ rebase --quiet "refs/remotes/rescue-base/${base}"; then
71
+ git rebase --abort || true
72
+ say "The rebase onto ${base} conflicts: left for a person."
73
+ done_with false
74
+ fi
75
+
76
+ git push --quiet "$remote" "HEAD:refs/heads/${rescued_branch}"
77
+
78
+ grep -qiE "(closes|fixes|resolves) #${ISSUE}\b" "$work/body.md" || printf '\n\nCloses #%s\n' "$ISSUE" >> "$work/body.md"
79
+ printf '\n\nRebased onto `%s` by the workflow: the agent'"'"'s commits no longer applied after `%s` moved during the run.\n' \
80
+ "$base" "$base" >> "$work/body.md"
81
+ [[ "$title" == "[bot] "* ]] || title="[bot] $title"
82
+ url="$(gh pr create --repo "$REPO" --base "$base" --head "$rescued_branch" --title "$title" --body-file "$work/body.md")"
83
+ number="${url##*/}"
84
+
85
+ # The issue gh-aw filed with the patch is superseded by the pull request.
86
+ stray="$(gh issue list --repo "$REPO" --state open --limit 30 --json number,title 2>/dev/null |
87
+ jq -r --arg t "$title" '[.[] | select(.title == $t)] | first | .number // empty' || true)"
88
+ if [ -n "$stray" ]; then
89
+ gh issue close "$stray" --repo "$REPO" --reason "not planned" \
90
+ --comment "Superseded by #${number}: the patch was rebased onto ${base} and opened as a pull request." >/dev/null
91
+ fi
92
+
93
+ say "Rebased onto ${base} and opened #${number}."
94
+ done_with true "$number"
@@ -41,6 +41,9 @@ outputs:
41
41
  pushed:
42
42
  description: Whether the agent proposed a push to the pull request branch, applied or not.
43
43
  value: ${{ steps.validate.outputs.pushed }}
44
+ empty:
45
+ description: Whether the agent produced no safe output at all, which is a run that quit, not a report that was refused.
46
+ value: ${{ steps.validate.outputs.empty }}
44
47
  runs:
45
48
  using: composite
46
49
  steps:
@@ -66,6 +69,8 @@ runs:
66
69
  echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
67
70
  pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
68
71
  echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
72
+ items=$(jq -r '[.items[]?] | length' "$OUTPUT_FILE" 2>/dev/null || echo 0)
73
+ echo "empty=$([ "${items:-0}" = 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
69
74
 
70
75
  if [ "$outcome" = 'invalid' ]; then
71
76
  echo "::warning::Agent output has no usable merge-gate report. It will not be applied."
@@ -1792,11 +1792,11 @@ fi
1792
1792
  # full verification at minute forty-one after six scoped runs, and #751 spent fifteen of twenty-two
1793
1793
  # minutes in twelve tiny worker tasks. These sentences are what keep a run near twenty minutes.
1794
1794
  if worker_installed implement; then
1795
- if grep -qF 'Run them once, on the final tree.' "$IMPLEMENT_WORKER_MD" && grep -qF 'on both paths, before the commands above.' "$IMPLEMENT_WORKER_MD" && grep -qF 'Ask it for few, coarse tasks' "$IMPLEMENT_WORKER_MD" && grep -qF 'WORK IN FEW TURNS' "$IMPLEMENT_WORKER_MD"; then
1795
+ if grep -qF 'Run them once, on the final tree.' "$IMPLEMENT_WORKER_MD" && grep -qF 'on both paths, before the commands above.' "$IMPLEMENT_WORKER_MD" && grep -qF 'tasks that do not depend on each' "$IMPLEMENT_WORKER_MD" && grep -qF 'WORK IN FEW TURNS' "$IMPLEMENT_WORKER_MD"; then
1796
1796
  PASS=$((PASS + 1))
1797
1797
  else
1798
1798
  FAIL=$((FAIL + 1))
1799
- echo "FAIL: the implement prompt must verify once on the final tree, ask for coarse tasks and work in few turns" >&2
1799
+ echo "FAIL: the implement prompt must verify once on the final tree, let independent tasks run in parallel and work in few turns" >&2
1800
1800
  fi
1801
1801
  fi
1802
1802
  # gh-aw leaves OPENCODE_MODEL empty, so every lead ran on opencode.ci.json's one model. Each worker
@@ -1824,6 +1824,9 @@ fi
1824
1824
  if ! grep -qF 'jq --arg model "$AGENT_LEAD_MODEL"' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
1825
1825
  LEAD_OK=0; echo "FAIL: the CI config merge must write AGENT_LEAD_MODEL onto ci-workflow-agent" >&2
1826
1826
  fi
1827
+ if ! grep -qF 'contents/${FRAGMENT}?ref=${DEFAULT_BRANCH}' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
1828
+ LEAD_OK=0; echo "FAIL: the CI config merge must read opencode.ci.json from the default branch" >&2
1829
+ fi
1827
1830
  if worker_installed implement && ! grep -qF 'subagent_type: planner' "$IMPLEMENT_WORKER_MD"; then
1828
1831
  LEAD_OK=0; echo "FAIL: the implement prompt must run propose and archive in the planner agent" >&2
1829
1832
  fi
@@ -2041,6 +2044,8 @@ if worker_installed merge-gate; then
2041
2044
  assert "blocked holds once its retries are spent" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=3 MAX_RETRIES=3)"
2042
2045
  assert "a merge that did not happen is retried once the base moves" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=1)"
2043
2046
  assert "a stack root is never retried into a merge" held "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=0 STACK_ROOT=true)"
2047
+ assert "a root whose stack was announced complete gets one more gate run" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=false RETRIES=0 STACK_ROOT=true STACK_ROOT_READY=true)"
2048
+ assert "a ready root whose merge failed is held again, not looped" held "$(park VERDICT=owner-review VERDICT_NEWER=true STACK_ROOT=true STACK_ROOT_READY=true)"
2044
2049
 
2045
2050
  assert "hold keeps a pull request off the train" skip "$(move HOLD=true CI_STATUS=completed CI_CONCLUSION=success)"
2046
2051
  assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
@@ -2738,6 +2743,120 @@ if worker_installed implement; then
2738
2743
  echo "FAIL: the implement prompt does not forbid running commands in the background and ending the turn to wait for them" >&2
2739
2744
  fi
2740
2745
  fi
2746
+ # The bash tool moves any command past its timeout to the background and says so. Pliny-Bot #774 took
2747
+ # that for an end, wrote "I'll wait for the verify command" and stopped after sixty-seven minutes. The
2748
+ # prompt must say that the message is not the end and to poll with tool calls.
2749
+ if worker_installed implement; then
2750
+ if grep -qF 'answers "moved to the background"' "${WORKFLOWS_DIR}/agent-implement.md" && grep -qF "sleep 40; tail -n 15" "${WORKFLOWS_DIR}/agent-implement.md"; then
2751
+ PASS=$((PASS + 1))
2752
+ else
2753
+ FAIL=$((FAIL + 1))
2754
+ echo "FAIL: the implement prompt does not tell the agent to keep polling when bash moves a command to the background" >&2
2755
+ fi
2756
+ fi
2757
+ # Every worker is told that a turn ending in text ends the run. The Pliny-Bot merge gate said "I'll read
2758
+ # the required context files first." and exited cleanly after twenty-three seconds, twice on one head.
2759
+ PLATFORM_DEFAULTS_MD="${WORKFLOWS_DIR}/shared/platform-defaults.md"
2760
+ if [ -f "$PLATFORM_DEFAULTS_MD" ]; then
2761
+ if grep -qF "A turn that ends with text and no tool call ends the whole run" "$PLATFORM_DEFAULTS_MD" && grep -qF "every message you write carries a tool call" "$PLATFORM_DEFAULTS_MD"; then
2762
+ PASS=$((PASS + 1))
2763
+ else
2764
+ FAIL=$((FAIL + 1))
2765
+ echo "FAIL: the shared worker defaults do not say that a text-only turn ends the run" >&2
2766
+ fi
2767
+ fi
2768
+ # A gate agent that produced no output at all quit; it did not write a report the validator refused.
2769
+ # It gets the machine budget, not the two-strike human-review of an unusable report.
2770
+ if worker_installed merge-gate; then
2771
+ if grep -qF 'OUTPUT_EMPTY: ${{ needs.validate_output.outputs.empty }}' "${WORKFLOWS_DIR}/agent-merge-gate.md" && grep -qF '[ "${OUTPUT_EMPTY:-false}" != true ]' "${WORKFLOWS_DIR}/agent-merge-gate.md" && grep -qF 'echo "empty=' "${HERE}/../validate-merge-gate-output/action.yml"; then
2772
+ PASS=$((PASS + 1))
2773
+ else
2774
+ FAIL=$((FAIL + 1))
2775
+ echo "FAIL: a gate agent that produced nothing is sent to a person instead of being retried" >&2
2776
+ fi
2777
+ fi
2778
+ # A cancelled agent job is work taken back, not an outage: no retry, no stalled flag. Pliny-Bot #816 was
2779
+ # cancelled by hand, re-dispatched as "a provider outage" and parked as stalled.
2780
+ DECIDE_YML="${HERE}/../decide-agent-retry/action.yml"
2781
+ if [ -f "$DECIDE_YML" ] && grep -qF 'cause=cancelled' "$DECIDE_YML" && grep -qF "steps.decide.outputs.cause != 'cancelled'" "${WORKFLOWS_DIR}/agent-refine.md" && grep -qF "steps.decide.outputs.cause != 'cancelled'" "${WORKFLOWS_DIR}/agent-implement.md"; then
2782
+ PASS=$((PASS + 1))
2783
+ else
2784
+ FAIL=$((FAIL + 1))
2785
+ echo "FAIL: a cancelled agent run is retried or parked as stalled" >&2
2786
+ fi
2787
+ # No prompt rule stopped the model ending a turn in text; the compiled engine command continues the
2788
+ # session when no safe output exists. Pliny-Bot #771 passed verify and exited with nothing delivered.
2789
+ COMPILE_MJS="${HERE}/../../scripts/compile-agent-workflows.mjs"
2790
+ # In a consumer the action sits under .github/ and the script at the repository root.
2791
+ [ -f "$COMPILE_MJS" ] || COMPILE_MJS="${HERE}/../../../scripts/compile-agent-workflows.mjs"
2792
+ if [ -f "$COMPILE_MJS" ] && grep -qF 'opencode run --continue' "$COMPILE_MJS" && grep -qF 'gh-aw/safeoutputs/outputs.jsonl' "$COMPILE_MJS"; then
2793
+ PASS=$((PASS + 1))
2794
+ else
2795
+ FAIL=$((FAIL + 1))
2796
+ echo "FAIL: the compiled engine command does not continue a session that delivered nothing" >&2
2797
+ fi
2798
+ # A patch the base moved under is rebased and opened as a pull request before anyone is asked: Pliny-Bot
2799
+ # #787 and #794 sat as issues until rebased by hand. The rescue must run before the pull request steps,
2800
+ # and the push-failure flags must stay quiet when it worked.
2801
+ IMPL_MD="${WORKFLOWS_DIR}/agent-implement.md"
2802
+ if worker_installed implement; then
2803
+ if grep -qF 'uses: ./.github/actions/rebase-stale-patch' "$IMPL_MD" && grep -qF "steps.rescue.outputs.rescued == 'true'" "$IMPL_MD" && grep -qF "&& steps.rescue.outputs.rescued != 'true'" "$IMPL_MD" && [ -f "${HERE}/../rebase-stale-patch/rebase-stale-patch.sh" ]; then
2804
+ PASS=$((PASS + 1))
2805
+ else
2806
+ FAIL=$((FAIL + 1))
2807
+ echo "FAIL: implement does not rebase a patch its base moved under before parking it" >&2
2808
+ fi
2809
+ fi
2810
+ # The subagent monitor never sees a CI child finish and denies every spawn after the cap.
2811
+ if grep -qF 'for plugin in pc-system-reminders pc-subagent-monitor' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
2812
+ PASS=$((PASS + 1))
2813
+ else
2814
+ FAIL=$((FAIL + 1))
2815
+ echo "FAIL: the CI setup must remove pc-subagent-monitor in CI" >&2
2816
+ fi
2817
+ # a published pull request is never flagged stalled, whatever the agent job's own result.
2818
+ if grep -A8 '^ incomplete:' "$IMPLEMENT_WORKER_MD" | grep -qF "needs.safe_outputs.outputs.created_pr_number == ''"; then
2819
+ PASS=$((PASS + 1))
2820
+ else
2821
+ FAIL=$((FAIL + 1))
2822
+ echo "FAIL: implement flags an issue stalled next to the pull request it just opened" >&2
2823
+ fi
2824
+ # An empty AGENT_TIER_MODEL must remove an override a reused runner kept from an earlier job.
2825
+ if grep -qF 'rm -f .opencode/harness.user.json' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
2826
+ PASS=$((PASS + 1))
2827
+ else
2828
+ FAIL=$((FAIL + 1))
2829
+ echo "FAIL: a stale harness.user.json on a reused runner keeps every tier pinned" >&2
2830
+ fi
2831
+ # A stack whose last child closed outside the router is announced by housekeeping, or its root waits.
2832
+ if grep -qF "announcing the finished stack" "${HERE}/../housekeeping/action.yml"; then
2833
+ PASS=$((PASS + 1))
2834
+ else
2835
+ FAIL=$((FAIL + 1))
2836
+ echo "FAIL: housekeeping does not announce a stack whose children all closed outside the router" >&2
2837
+ fi
2838
+ # A stack child runs the feature branch's copy of the workflows, so the router brings the default
2839
+ # branch in before it dispatches one.
2840
+ if grep -qF 'bring $DEFAULT_BRANCH into $STACK_BRANCH before a child runs' "${WORKFLOWS_DIR}/work-router.yml"; then
2841
+ PASS=$((PASS + 1))
2842
+ else
2843
+ FAIL=$((FAIL + 1))
2844
+ echo "FAIL: stack children run the feature branch's stale workflows" >&2
2845
+ fi
2846
+ # A stack root merges itself once every child has landed, through the same gate as any pull request.
2847
+ # The people it still stops are the gate's own: a blast radius over the limit, a protected path, a
2848
+ # finding. The gate must read the router's completion announcement, and the train must give the root
2849
+ # one more run after it.
2850
+ GATE_MD="${WORKFLOWS_DIR}/agent-merge-gate.md"
2851
+ TRAIN_SH="${HERE}/../merge-train/merge-train.sh"
2852
+ if worker_installed merge-gate && [ -f "$TRAIN_SH" ]; then
2853
+ if grep -qF 'root_complete=$root_complete' "$GATE_MD" && grep -qF "needs.subject.outputs.root_complete == 'true'" "$GATE_MD" && grep -qF 'STACK_ROOT_AUTOMERGE:' "$GATE_MD" && grep -qF 'STACK_ROOT_READY' "$TRAIN_SH"; then
2854
+ PASS=$((PASS + 1))
2855
+ else
2856
+ FAIL=$((FAIL + 1))
2857
+ echo "FAIL: a completed stack root is not merged by the gate and re-run by the train" >&2
2858
+ fi
2859
+ fi
2741
2860
  # The merge train wakes on a finished CI run only. Pliny-Bot had four green pull requests wait an hour
2742
2861
  # because a repair gate dispatched for red CI kept them marked as having a gate, and nothing called the
2743
2862
  # train again once it was cancelled. The hourly sweep is now a heartbeat, and a repair gate whose CI has
@@ -184,9 +184,10 @@ flowchart TD
184
184
 
185
185
  The stack exists only in markers on the issues — `stack-root-branch` and `stack-root-pr` on the
186
186
  feature issue, `stack-root-branch` stamped on each child at hand-over — so a repository with none
187
- of them runs exactly the pipeline above and nothing else. The root is never auto-merged: a person
188
- merges it after every child has landed and the feature branch is green, which is the point where
189
- the feature's own acceptance journey, not one child's scope, is the thing being verified.
187
+ of them runs exactly the pipeline above and nothing else. The root is held while children land.
188
+ Once every child has landed and the feature branch is green, the gate decides it like any pull
189
+ request and merges it, unless the blast radius, a protected path or a finding sends it to a person
190
+ (`STACK_ROOT_AUTOMERGE: "false"` restores a human merge for every root).
190
191
 
191
192
  ---
192
193
 
@@ -385,7 +386,7 @@ flowchart TD
385
386
  gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
386
387
  gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
387
388
  gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
388
- gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root never auto-merges,<br/>a person merges it once every<br/>child has landed and it is green"))
389
+ gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root waits for its children,<br/>then merges like any pull request<br/>unless the gate sends it to a person"))
389
390
  gateHuman(("HUMAN REVIEW<br/>review label,<br/>implement label kept"))
390
391
  gateOwner(("OWNER REVIEW<br/>review + owner-review,<br/>CODEOWNERS asked when named"))
391
392
  gateHuman --> gatePreview
@@ -3,6 +3,14 @@
3
3
  const OPENCODE_VERSION = '2.0.22'
4
4
  const OPENCODE_PACKAGE = '@opencode/cli'
5
5
 
6
+ const OPENCODE_NUDGE =
7
+ '; rc=$?; for nudge in 1 2; do [ -s "${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" ] && break; ' +
8
+ 'echo "No safe output yet, continuing the session ($nudge of 2)"; ' +
9
+ 'OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --continue --print-logs --log-level info ' +
10
+ '"Your last message ended without a tool call, so nothing has been delivered and this run is about to end. ' +
11
+ 'Do not explain or plan: continue the task now and finish with the safe-output call the task names."; ' +
12
+ 'rc=$?; done; exit $rc'
13
+
6
14
  // The install the compiled locks carry, shared so both rewrite sites emit exactly
7
15
  // the same command.
8
16
  //
@@ -153,6 +161,11 @@ for (const file of readdirSync(workflowDirectory)) {
153
161
  // "Invalid value for flag --log-level", aborting before the agent runs.
154
162
  // The old upper-case spelling was valid in v1 and is not in v2.
155
163
  .replace(/opencode run(?: --print-logs)? --log-level (?:DEBUG|ERROR|INFO|debug|error|info)/g, "OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --print-logs --log-level info")
164
+ // A turn that ends in text ends `opencode run`, whatever the text promised, and no prompt rule
165
+ // stopped glm-5-2 doing it: Pliny-Bot #771 passed verify, wrote "let me discover the exact
166
+ // safeoutputs command" and exited with nothing delivered. When no safe output exists yet, the
167
+ // same session is continued, twice at most, and told to finish.
168
+ .replace(/("\$\(cat \/tmp\/gh-aw\/aw-prompts\/prompt\.txt\)")'/g, (_, prompt) => prompt + OPENCODE_NUDGE + "'")
156
169
  .replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
157
170
  .replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
158
171
  .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
@@ -6,9 +6,12 @@ env:
6
6
  # so it names its model here. `model:` below only satisfies the compiler. A repository may
7
7
  # change it to any model its opencode.ci.json declares.
8
8
  AGENT_LEAD_MODEL: "forge/glm-5-2"
9
- # The model every harness tier (plan, build, fast) runs on in CI, planner included. Unset,
10
- # the tiers take harness.json, whose models the AWF proxy may not price.
9
+ # Every harness tier (plan, build, fast) on one model the CI's Forge account is granted.
10
+ # harness.json names quasar-438b and carina-60b, which that account is not granted: Pliny-Bot
11
+ # #823's build tasks failed on quasar. Empty runs the tiers harness.json names.
11
12
  AGENT_TIER_MODEL: "plainconcepts/glm-5-2"
13
+ # The fast tier alone, while the CI's Forge account is not granted carina-60b.
14
+ AGENT_FAST_MODEL: "plainconcepts/glm-5-2"
12
15
  # Printed by the prompt as the verification block. Consumers set their own commands.
13
16
  VERIFY_COMMANDS: ""
14
17
  REPO_RULES: "Apply only actionable outstanding reviewer feedback to the selected bot pull request. Make minimal changes that address each comment. Preserve architecture and do not weaken tests. Run full verification after changes."
@@ -381,7 +384,7 @@ jobs:
381
384
  issue-number: ${{ needs.subject.outputs.issue }}
382
385
  labels: ${{ env.WORKING_LABEL }}
383
386
  - name: Flag for human review
384
- if: steps.decide.outputs.retry != 'true'
387
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
385
388
  uses: ./.github/actions/add-issue-labels
386
389
  with:
387
390
  token: ${{ steps.app-token.outputs.token }}
@@ -390,7 +393,7 @@ jobs:
390
393
  ${{ env.REVIEW_LABEL }}
391
394
  ${{ env.STALLED_LABEL }}
392
395
  - name: Report missing review feedback outcome
393
- if: steps.decide.outputs.retry != 'true'
396
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
394
397
  uses: ./.github/actions/create-issue-comment
395
398
  with:
396
399
  token: ${{ steps.app-token.outputs.token }}
@@ -499,8 +502,8 @@ safe-outputs:
499
502
  # 240 went on to every worker at once when the provider was slow, which fixed the deaths and
500
503
  # made every worker equally expensive to hang. These numbers are per worker: enough headroom
501
504
  # for a slow gateway on the work it actually does, and not four hours for a run that reads one
502
- # issue. Turns remain the guard against a confused agent looping; for a custom model the credit
503
- # ceiling is models.dev fallback pricing and guards nothing.
505
+ # issue. Turns remain the guard against a confused agent looping; the credit ceiling meters the
506
+ # Forge models at Multiverse's list prices, set in shared/opencode-ci.md.
504
507
  #
505
508
  # Applies review comments to an existing branch and verifies the result.
506
509
  timeout-minutes: 90
@@ -6,9 +6,12 @@ env:
6
6
  # so it names its model here. `model:` below only satisfies the compiler. A repository may
7
7
  # change it to any model its opencode.ci.json declares.
8
8
  AGENT_LEAD_MODEL: "forge/glm-5-2"
9
- # The model every harness tier (plan, build, fast) runs on in CI, planner included. Unset,
10
- # the tiers take harness.json, whose models the AWF proxy may not price.
9
+ # Every harness tier (plan, build, fast) on one model the CI's Forge account is granted.
10
+ # harness.json names quasar-438b and carina-60b, which that account is not granted: Pliny-Bot
11
+ # #823's build tasks failed on quasar. Empty runs the tiers harness.json names.
11
12
  AGENT_TIER_MODEL: "plainconcepts/glm-5-2"
13
+ # The fast tier alone, while the CI's Forge account is not granted carina-60b.
14
+ AGENT_FAST_MODEL: "plainconcepts/glm-5-2"
12
15
  REPO_RULES: "Read-only repository audit. Report only reproducible, actionable defects with evidence. Do not modify files, commit, push, or run write operations."
13
16
  # Split out of REPO_RULES, which carried both the read-only discipline above and the list
14
17
  # below. All four consuming repositories had customised the list and none could touch it
@@ -175,8 +178,8 @@ safe-outputs:
175
178
  # 240 went on to every worker at once when the provider was slow, which fixed the deaths and
176
179
  # made every worker equally expensive to hang. These numbers are per worker: enough headroom
177
180
  # for a slow gateway on the work it actually does, and not four hours for a run that reads one
178
- # issue. Turns remain the guard against a confused agent looping; for a custom model the credit
179
- # ceiling is models.dev fallback pricing and guards nothing.
181
+ # issue. Turns remain the guard against a confused agent looping; the credit ceiling meters the
182
+ # Forge models at Multiverse's list prices, set in shared/opencode-ci.md.
180
183
  #
181
184
  # Sweeps the repository read-only and writes one findings issue. Observed around 45 minutes.
182
185
  timeout-minutes: 90