autonomous-sdlc-harness 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/commands/init.js +17 -8
  2. package/dist/commands/init.js.map +1 -1
  3. package/dist/config/check.js +27 -5
  4. package/dist/config/check.js.map +1 -1
  5. package/dist/config/model.js +54 -11
  6. package/dist/config/model.js.map +1 -1
  7. package/dist/core/pluginIdentity.js +2 -0
  8. package/dist/core/pluginIdentity.js.map +1 -1
  9. package/dist/core/writer.js +1 -0
  10. package/dist/core/writer.js.map +1 -1
  11. package/dist/doctor/checks.js +317 -40
  12. package/dist/doctor/checks.js.map +1 -1
  13. package/dist/generators/githubWorkflows.js +21 -11
  14. package/dist/generators/githubWorkflows.js.map +1 -1
  15. package/dist/remote/githubActions.js +63 -4
  16. package/dist/remote/githubActions.js.map +1 -1
  17. package/dist/retrieval/pythonBackend.js +114 -0
  18. package/dist/retrieval/pythonBackend.js.map +1 -0
  19. package/dist/retrieval/setup.js +8 -0
  20. package/dist/retrieval/setup.js.map +1 -1
  21. package/package.json +1 -1
  22. package/templates/README.md +1 -1
  23. package/templates/github/workflows/harness-control.yml +184 -0
  24. package/templates/github/workflows/harness-resume.yml +7 -0
  25. package/templates/github/workflows/harness-run.yml +100 -1
  26. package/templates/github/workflows/harness-trigger.yml +5 -4
  27. package/templates/scripts/README.md +1 -1
  28. package/templates/scripts/autonomous-watcher.sh +29 -2
  29. package/templates/scripts/docs-search-server.sh +88 -17
  30. package/templates/scripts/lib/harness-run-lib.sh +205 -26
  31. package/templates/scripts/remote-run.sh +2617 -150
  32. package/templates/scripts/scratch-run.sh +54 -73
  33. package/templates/state-dir/README-root.md +1 -1
  34. package/templates/state-dir/scratch/README.md +4 -2
  35. package/templates/state-dir/user_reviews/README.md +2 -2
@@ -12,7 +12,7 @@
12
12
  # [--park-loop-clear] [--chain <n>] [--repo <root>]
13
13
  # remote-run.sh pause <branch> [--repo <root>]
14
14
  # remote-run.sh warm [--repo <root>]
15
- # remote-run.sh stop <branch> [--repo <root>]
15
+ # remote-run.sh stop <branch> [--actor <login>] [--repo <root>]
16
16
  # remote-run.sh status <branch> [--repo <root>]
17
17
  # remote-run.sh sync <branch> [--repo <root>]
18
18
  # remote-run.sh fetch <branch> <out_dir> [--repo <root>]
@@ -23,42 +23,59 @@
23
23
  # remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]
24
24
  # remote-run.sh run-created-at <run_id> [--repo <root>]
25
25
  # remote-run.sh start <branch> --prompt-file <file> [--repo <root>]
26
- # remote-run.sh review <branch> --review-file <file> [--repo <root>]
26
+ # remote-run.sh review <branch> --review-file <file> [--allow-no-run]
27
+ # [--actor <login>] [--reviewers <login,login,...>]
28
+ # [--source <https-url>] [--repo <root>]
27
29
  # remote-run.sh trigger [--repo <root>] (its own exit map: its paragraph)
28
30
  # remote-run.sh list [--repo <root>]
31
+ # remote-run.sh discard <dir> [--repo <root>]
32
+ # remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]
33
+ # (always 0, 1 only on a usage error: its paragraph)
34
+ # remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]
35
+ # (always 0, 1 only on a usage error: its paragraph)
36
+ # remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
37
+ # (always 0, 1 only on a usage error: its paragraph)
38
+ # remote-run.sh control [--repo <root>] (its own exit map: its paragraph)
29
39
  # 0 sent (for stop: the action=stop marker was dispatched, and every
30
40
  # queued, waiting or in-progress `harness run` run of that branch was
31
41
  # asked to cancel, or there was none); for status and fetch: printed
32
42
  # (for fetch, `state: none` included); for sync: the record is
33
43
  # current (including "no run listed yet", which writes nothing); for
34
- # restore: restored, or no previous bundle (or an expired one, with a
35
- # `::warning::` line) under --resume none|pause;
44
+ # restore: restored, or no previous bundle of the branch's current
45
+ # lineage (or an expired one, with a `::warning::` line) under
46
+ # --resume none|pause;
36
47
  # for save: ALWAYS, whatever happened; for continue: whatever it
37
48
  # decided — every outcome a person must act on is a notification; for
38
49
  # poll: the tick finished; for pause-requested: such a run exists; for
39
50
  # run-created-at: printed; for review: placed, pushed and dispatched;
40
- # for list: printed
51
+ # for list: printed; for discard: <dir> removed, or it did not exist
41
52
  # 1 usage error, or the library or the configuration could not be
42
53
  # resolved; for fetch, <out_dir> is not an existing, empty directory;
54
+ # for discard, <dir>'s parent does not resolve or the removal failed;
43
55
  # for sync and restore, a local copy or write failed; for
44
56
  # pause-requested, also NO such run — a caller that reads 1 as "no
45
57
  # pause" passes arguments it has already validated
46
58
  # 2 refused, nothing sent or written: execution.target is not
47
59
  # github-actions (sending verbs, fetch, review and list); for
48
60
  # review, a protected branch, a review file that is not a readable
49
- # regular file, or a run in flight (its paragraph); for sync, the
61
+ # regular file, or a branch its settledness test reads as not settled
62
+ # (its paragraph); for sync, the
50
63
  # branch's local record does not carry `execution: github-actions`; for
51
64
  # status, a local record that does not carry `execution:
52
65
  # github-actions`, or no record and `execution.target` not
53
66
  # `github-actions`; the record's mirror working copy is missing, or a
54
67
  # downloaded bundle is unrecognised (sync, restore, and status with no
55
68
  # local record); the inputs payload is over the limit; a named answer
56
- # file is missing. For restore under --resume answer, "nothing more":
57
- # no previous bundle, the previous bundle expired (the message names
69
+ # file is missing (a relative --answers-from resolves against the
70
+ # caller's directory). For restore under --resume answer, "nothing more":
71
+ # no previous bundle of the branch's current lineage, the previous
72
+ # bundle expired (the message names
58
73
  # its expiry and the resume command), `HARNESS_INPUT_ANSWERS` not an object of
59
74
  # positive-integer keys to strings, or an answer whose `question_<n>.md`
60
75
  # is not at the top level of the previous bundle — nothing is restored
61
- # and no answer is written
76
+ # and no answer is written. For discard, <dir> does not resolve
77
+ # strictly inside `<state_dir>/scratch/`, is a symlink, or exists and
78
+ # is not a directory; nothing removed
62
79
  # 3 gh failed: not found, or a non-zero exit — the first line of gh's
63
80
  # stderr is named. For poll: the listing or the disable failed. For
64
81
  # pause-requested and run-created-at, also an answer that is not the
@@ -108,14 +125,30 @@
108
125
  # no `answer_<n>.md` beside it, ascending
109
126
  # bundle_dir: <out_dir> when a bundle was downloaded
110
127
  #
128
+ # `discard` REMOVES THE DIRECTORY A COMMAND FETCHED INTO, so the command needs
129
+ # no recursive `rm` of its own. It removes <dir> only when the library's
130
+ # `hr_scratch_path_var` accepts it: strictly inside the checkout's
131
+ # `<state_dir>/scratch/`, not a symlink, and a directory when it exists
132
+ # (2 otherwise). A relative <dir>
133
+ # resolves against the caller's directory; the root is `--repo`, or else
134
+ # `hr_repo_root` of the working directory, as for `restore`. No `gh` call and
135
+ # no `execution.target` gate. A <dir> that does not exist is exit 0. It
136
+ # creates nothing and writes nothing else.
137
+ #
111
138
  # `review` PLACES A USER REVIEW ROUND ON THE BRANCH TIP AND DISPATCHES IT, for
112
139
  # `/autonomous-sdlc-harness:branch-user-review` on a run that executes on
113
140
  # GitHub. In order, stopping at the first failure: refuse a protected branch
114
141
  # and a review file that is not a readable regular file (2; a relative
115
- # --review-file resolves against the caller's directory); refuse a branch whose
116
- # newest `harness run <branch>` run, by `remote_state`, is anything but
117
- # `completed` or `failed` — none listed, `running`, `parked`, `park_loop` or
118
- # `paused`, an expired bundle naming its expiry (2); the bundle it reads is
142
+ # --review-file resolves against the caller's directory); refuse a branch that
143
+ # `branch_settled_var`, the settledness test `control` and `collect` share, reads as not
144
+ # settled (2). Its newest `harness run <branch>` run decides: none listed is
145
+ # settled only under --allow-no-run; a `completed` one is read by
146
+ # `remote_state`; any other is in flight as `running` until its `RUN_JOB_NAME`
147
+ # job has completed, and then read by `remote_state` as a finished run. Settled
148
+ # is `completed` or `failed`; `running`, `parked`, `park_loop` and `paused` are
149
+ # refused, an expired bundle naming its expiry. The refusal stays for this
150
+ # verb because a local round's file exists only on the caller's machine, so no
151
+ # later collection could pick it up. The bundle it reads is
119
152
  # downloaded to `sync`'s directory, the one write a refusal makes. The copy: the main
120
153
  # checkout's remote record's mirror when its `worktree` exists and is on the
121
154
  # branch, never removed; otherwise `create-worktree.sh --existing
@@ -129,8 +162,25 @@
129
162
  # matched, else `<branch>_review_<max+1>.md`. It is placed, committed as
130
163
  # `hr_user_review_subject`'s `chore: add user review for <branch>` and pushed
131
164
  # (each failure 4); the cut copy is removed; then `dispatch --engine
132
- # user_review --resume none --chain 0`. A remote record, when one exists, is
133
- # set `running` / `user_review` in one write after the dispatch.
165
+ # user_review --resume none --chain 0`, after which it holds until a `harness run
166
+ # <branch>` run whose `headSha` is the pushed commit is listed — `run_by_sha_var`,
167
+ # `trigger`'s bounded lookup — so a review job serialized behind it never reads
168
+ # the branch as settled before GitHub lists the new run; a lookup that runs out
169
+ # is one `::warning::` line and still exit 0. A remote record, when one exists, is
170
+ # set `running` / `user_review` in one write after the dispatch. Then the round
171
+ # is reported as `report round`. Under --reviewers (comma-separated logins) the
172
+ # note is `Round <round> from pull request #<pr> by @<a>, @<b>`, <pr> read from
173
+ # --source's `/pull/<n>` or else the branch's open pull request, with ` (<source>)`
174
+ # after it under --source. Otherwise it is `Round <round>`, plus ` from <source>`
175
+ # under --source, and ` by @<actor>` under --actor (a login, as for `stop`),
176
+ # else ` from a local session`.
177
+ # --allow-no-run EXISTS FOR A LOCALLY EXECUTED BRANCH REVIEWED ON GITHUB: such
178
+ # a branch has no `harness run <branch>` run, and its round runs through
179
+ # `WORKFLOW_RUN_FILE` because a GitHub-started round always does. Its local
180
+ # record carries no `execution: github-actions`, so it is neither read as the
181
+ # copy nor written; the round runs remotely, and the branch's local working
182
+ # copy falls behind `origin/<branch>` until the maintainer fast-forwards it.
183
+ # The flag widens only the none-listed refusal.
134
184
  #
135
185
  # `trigger` IS THE GITHUB EVENT ADAPTER, the one step of the trigger
136
186
  # workflow's job: event -> (branch, task text) -> `start`. It handles
@@ -144,12 +194,20 @@
144
194
  # GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID the `gh` target and
145
195
  # the URLs its comments name
146
196
  # HARNESS_REMOTE_STOP non-empty: every start is refused
147
- # HARNESS_TRIGGER_LABEL the trigger label; `DEFAULT_TRIGGER_LABEL` when empty
197
+ # HARNESS_TRIGGER_LABEL the trigger label; when empty, `DEFAULT_TRIGGER_LABEL`
198
+ # or `LEGACY_TRIGGER_LABEL`, per the paragraph below
148
199
  # HARNESS_TRIGGER_ALLOWED_BOTS comma-separated bot logins allowed to start
149
200
  # HARNESS_TRIGGER_LOOKUP_SECS seconds between run lookups; `5` when empty.
150
201
  # A test seam
151
202
  # RUNNER_TEMP where the prompt snapshot is written; a `mktemp -d`
152
203
  # directory when empty
204
+ # THE LEGACY LABEL. The workflow `init` now writes always passes a non-empty
205
+ # `HARNESS_TRIGGER_LABEL`; only the previous release's workflow, which
206
+ # `init --upgrade-workflows` never re-renders, passes it empty, and its `if:`
207
+ # already ran the job for `LEGACY_TRIGGER_LABEL`. So when it is empty either
208
+ # `DEFAULT_TRIGGER_LABEL` or `LEGACY_TRIGGER_LABEL` is accepted, and the comment
209
+ # and the label removal name the one applied; a new workflow starts on
210
+ # `LEGACY_TRIGGER_LABEL` only when the variable names it.
153
211
  # An `action` other than `labeled`, or another label, is one line and exit 0
154
212
  # with no `gh` call. Otherwise refused, in this order, each refusal one issue
155
213
  # comment naming the reason and the way on:
@@ -167,15 +225,23 @@
167
225
  # 6. a `User` whose `collaborators/<login>/permission` is not `admin` or
168
226
  # `write` — `maintain` reads as `write` and `triage` as `read` there; a
169
227
  # failed call is "could not confirm write access", never a pass
228
+ # Refusals 4 to 6 are `authorise_actor`, the one actor check `control` reuses.
170
229
  # Then it fetches `origin <defaultBranch>` (a failure tolerated), derives the
171
- # branch with `hr_derive_branch <title> issue_<number>` (2 or 3 refused), writes
230
+ # branch with `hr_derive_branch <title> issue_<number>`, passing `gh` so a name
231
+ # with run-workflow history counts as taken (2 or 3 refused), writes
172
232
  # the snapshot — `# <title>`, the body's bytes, `---` and a provenance sentence
173
233
  # naming the issue, the labeller, the label and the time — and runs `start` as a
174
- # child. After a start it looks up the `harness run <branch>` run, at most
234
+ # child. After a start it looks up the `harness run <branch>` run whose
235
+ # `headSha` is the `origin/<branch>` commit `start` pushed, at most
175
236
  # `TRIGGER_RUN_LOOKUP_TRIES` times, falling back to the branch's filtered run
176
- # list, and comments the branch and that URL. Every comment is followed by
177
- # removing the label, so re-applying it is deliberate; a removal that fails is
178
- # one `::warning::` line.
237
+ # list, and comments the branch and that URL; the comment never names an older
238
+ # run of the branch. Every comment ends with the marker
239
+ # `<!-- sdlc-harness event=started branch=<branch> -->` on a start and
240
+ # `event=refused` otherwise (`branch=` empty before one is derived), and is
241
+ # followed by removing the label, so re-applying it is deliberate; then a start
242
+ # sets the state label `sdlc-harness: running`. That comment, that removal and
243
+ # that one label are the trigger's only writes to the issue; a refusal sets no
244
+ # label. A removal or a label set that fails is one `::warning::` line.
179
245
  # A `repository_dispatch` reads `.action` (where GitHub puts the `event_type`)
180
246
  # and `client_payload`'s `title`, `body` and `source`, the contract being
181
247
  # {"event_type": TRIGGER_DISPATCH_EVENT_TYPE, "client_payload": {"title": …,
@@ -199,6 +265,253 @@
199
265
  # its dispatch failed (commented with the manual Run-workflow way on)
200
266
  # 4 `start` refused or failed its placement (commented)
201
267
  #
268
+ # `report` TURNS A LIFECYCLE EVENT INTO ONE COMMENT AND ONE STATE LABEL, through
269
+ # the forge surface (its section states the functions). Job-side for its root,
270
+ # as `trigger` is, and outside the sending-verb gate: it does nothing, with one
271
+ # line, unless `hr_forge` is `github` and `hr_execution_target` is
272
+ # `github-actions`. The comment goes to the open same-repository pull request
273
+ # whose head is <branch> when origin's <branch> carries
274
+ # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
275
+ # the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
276
+ # task prompt, else nowhere. The label `STATE_LABEL_PREFIX<state>` replaces any
277
+ # other state label on that issue and that pull request, each when known; the
278
+ # label is a view, and the run list stays the authority. The state map:
279
+ # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
280
+ # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
281
+ # `failed` posts nothing when
282
+ # `remote_branch_stopped` finds the branch stopped, so a cancelled job never
283
+ # overwrites `stopped`. `completed` (deliver's) and `launched` (the trigger's
284
+ # own comment) are one line each, as is any other event. The comment names the
285
+ # next GitHub action — never a slash command — then <note> byte for byte, then
286
+ # this run's URL when `GITHUB_RUN_ID` is set, then the marker line
287
+ # `<!-- sdlc-harness event=<event> branch=<branch> -->`. The pause reason and
288
+ # reset come from the registry record, read only when the registry file exists.
289
+ # `parked` instead posts one comment per open question — `open_questions_in`
290
+ # over `$root`'s state directory, ascending — carrying `question_<n>.md` whole,
291
+ # cut at its last whole line within `QUESTION_COMMENT_MAX_BYTES` and then naming
292
+ # the file in the `STATE_ARTIFACT_NAME` artifact; then the answer form, a
293
+ # comment whose first line is `COMMAND_HANDLE answer <n>` (`<n>` optional when
294
+ # one question is open) and whose following lines are the answer; the marker
295
+ # adds `question=<n>`. With no question open it posts the one notice. The label
296
+ # is set once per target, not per question. On a public repository a question
297
+ # comment and its answer are public, as the artifact already is
298
+ # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
299
+ # repository's Actions runs can see*).
300
+ # It never fails its caller: every problem is one line and exit 0.
301
+ #
302
+ # `deliver` HANDS A COMPLETED RUN TO REVIEW: the run workflow's step after the
303
+ # job's own `push-branch.sh`, which opens no pull request. Job-side for its
304
+ # root and self-gated, as `report` is (`forge_on`, one line when off). Anything
305
+ # but `status: completed` in <bundle_dir>/status.json, or no status.json, is
306
+ # one line and nothing sent. Otherwise, by the forge surface: an open
307
+ # same-repository pull request whose head is <branch> is reused and no second
308
+ # one is opened; else a draft is created against `defaultBranch`, titled from
309
+ # the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
310
+ # <branch>), whose body names the issue as `Started from #<n>.` — a plain
311
+ # mention, never a closing keyword — states what a review requesting changes
312
+ # and the `COMMAND_HANDLE` commands do, and ends with the `pull-request`
313
+ # marker. The create runs with `HARNESS_PR_TOKEN` as `GH_TOKEN` when that is
314
+ # set, so the adopter's CI runs without an approval click; every other call
315
+ # uses the job's token. A pull request so opened is authored by that token's
316
+ # owner, who therefore cannot request changes on it: use a machine account's
317
+ # token, another reviewer, or a local `branch-user-review` round. A create
318
+ # refused with `PR_CREATE_FORBIDDEN` is not retried, and the comment names the
319
+ # Actions setting and `HARNESS_GIT_TOKEN`; any other failure is retried once
320
+ # without `--draft`, and a second failure is named in the comment with the
321
+ # branch's compare URL. A pull-request lookup that fails opens nothing. Then
322
+ # one `completed` comment: on the issue naming the new pull request's URL; on
323
+ # the pull request when there is no issue, or when it existed before this run
324
+ # (saying the round finished); on the issue alone when none could be opened;
325
+ # nowhere when neither is known. It names reviewing and requesting changes as
326
+ # the next action, and with `phases.qa` true the local `branch-qa-test` still
327
+ # owed. Then `sdlc-harness: done` on the issue and the pull request, each when
328
+ # known. It writes at most one pull request, one comment and those labels, and
329
+ # never pushes. It never fails its caller: every problem is one line and exit 0.
330
+ #
331
+ # `collect` STARTS THE NEXT ROUND FROM THE REVIEWS COLLECTED DURING A RUN: the
332
+ # one step of the run workflow's `collect` job, which follows its `run` job, so
333
+ # a review `control` answered "collected" waits at most for the run in flight.
334
+ # Job-side for its root and self-gated, as `report` and `deliver` are
335
+ # (`forge_on`, one line when off). In order, each stop one line and exit 0:
336
+ # `HARNESS_REMOTE_STOP` set; `remote_branch_stopped` finding the branch stopped,
337
+ # or failing; no pull request, from --pr or else `forge_pr_var` (a failed lookup
338
+ # included); `branch_settled_var`, read as `control` reads it, finding the
339
+ # branch in flight — a newer run listed, or this run ending `parked`,
340
+ # `park_loop` or `paused` (a budget chain's included), whose own end collects
341
+ # next — or failing; `round_collect` finding no review requesting changes
342
+ # pending (inline comments alone start no round, as *Comment* starts none; they
343
+ # ride along in the next), or failing, a `::warning::` line. Otherwise `review
344
+ # <branch> --review-file <file> --allow-no-run --reviewers <logins> --source
345
+ # <pull request url>` runs as a child, as `control` runs it, placing,
346
+ # dispatching and reporting the round. A child that exits non-zero gets exactly
347
+ # one comment on the pull request, with the `reply` marker, naming its last
348
+ # stderr line and saying the reviews stay there and that submitting a review
349
+ # requesting changes retries; there is no automatic retry. It never fails its
350
+ # caller: every outcome but a usage error is exit 0.
351
+ #
352
+ # `control` IS THE COMMENT AND REVIEW ADAPTER, the twin of `trigger` and the one
353
+ # step of the `WORKFLOW_CONTROL_FILE` job: one GitHub event -> one action on
354
+ # exactly one branch, carried out by this script's own verbs run as children,
355
+ # so it composes no dispatch itself. Job-side for its root, as `trigger` is,
356
+ # and outside the sending-verb gate: it gates itself after reading the event,
357
+ # so a refusal can still be replied to. The workflow's `if:` only saves a
358
+ # runner; every rule below holds without it. It handles `GITHUB_EVENT_NAME`
359
+ # `issue_comment` and `pull_request_review` (THE REVIEW, below); any other
360
+ # name, or an event file it cannot read, exits 1.
361
+ # It reads `.action`, `.comment.body`, `.issue.number`,
362
+ # `.issue.pull_request.url`, `.sender.login` and `.sender.type`, each by `jq`
363
+ # into a variable (data, never shell source), plus `trigger`'s environment.
364
+ # Ignored, with one line and no `gh` call: an action other than `created`; a
365
+ # body carrying `COMMENT_MARKER` anywhere (the harness's own comment, whoever
366
+ # posted it); and a body whose first line — a trailing CR stripped, leading
367
+ # spaces and tabs skipped — does not open with a word equal to
368
+ # `COMMAND_HANDLE`, compared lowercase. So `pause`, `Let's @sdlc-harness
369
+ # pause` and `> @sdlc-harness pause` start nothing. The verb is the next word,
370
+ # lowercased, and `CONTROL_ARGS` the rest of that line. Then refused, in this
371
+ # order, each a reply and exit 2:
372
+ # 1. `HARNESS_REMOTE_STOP` is set
373
+ # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
374
+ # GitHub nothing about the commenter
375
+ # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run
376
+ # 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
377
+ # yet: the reply lists every command and names `docs/github-run-control.md`
378
+ # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
379
+ # `pr view`: a fork's pull request is refused, because this event carries the
380
+ # repository's secrets, and nothing from its head is checked out or run; one
381
+ # not `OPEN` is refused. On an issue, the branch of the LAST genuine start
382
+ # comment, read by a paginated comment listing: its author is
383
+ # `github-actions[bot]`, its first line opens with the trigger's sentence
384
+ # `Started a harness run on the branch ` and a backticked <b>, and its last
385
+ # non-empty line is byte for byte what `forge_marker started <b>` prints. A
386
+ # marker quoted mid-body, in a comment not opening with that sentence, or by
387
+ # anyone else, is never trusted; none is a refusal. Both paths then pass
388
+ # `control_check_branch`: a branch `hr_branch_is_protected` does not answer 1
389
+ # for is refused, then it is fetched, and one whose origin tip carries no
390
+ # flow-progress ledger (`forge_recognised`) is not a harness branch.
391
+ # THE ARMS. `pause`: the state by a `fetch` child (`control_state_var`); only
392
+ # `running` sends `pause <branch>` as a child and replies that the run yields at
393
+ # its next clean checkpoint; any other state is a refusal naming it. `stop`:
394
+ # state `none` is a refusal; otherwise `stop <branch> --actor <login>` as a
395
+ # child, which posts its own `stopped` comment to the run's target; on 0 a reply
396
+ # is ALWAYS posted where the command was typed too, so a command on the issue of
397
+ # a branch with a pull request is answered there; on 3 the reply says the stop
398
+ # was partial and to comment `stop` again. `resume` accepts only `paused`, any
399
+ # `pause_reason` (`expired` and `killed` included); `park_loop` is refused
400
+ # pointing at `clear`, `parked` pointing at `answer <n>` with the open indexes,
401
+ # `running`, `completed`, `failed` and `none` each naming the state. `clear`
402
+ # accepts only `park_loop`, the GitHub form of `branch-resume`'s confirmation;
403
+ # any other state is a refusal naming it. Each sends the local relay's dispatch,
404
+ # `dispatch <branch> --engine <the state's engine> --resume pause --chain 0`,
405
+ # `clear` with `park_loop_clear` set, and no other command sets it. An empty
406
+ # engine is refused, never guessed (the `engine` input defaults to `task`),
407
+ # naming the Run workflow form. On 0 a reply, then `running` on the run's issue
408
+ # and pull request; on 2 a refusal and exit 2. `answer`: the first line is
409
+ # `answer <n>` and the answer every line below it, a trailing CR stripped from
410
+ # each line and its bytes otherwise unchanged; text after <n> on the first line
411
+ # is the answer when nothing follows below, and with no positive-integer <n>
412
+ # all of that line's text is. <n> may be left out only when exactly one
413
+ # question is open — issue comments have no threads. Refused, each a reply and
414
+ # exit 2: an empty answer; `park_loop` (pointing at `clear`); `paused` /
415
+ # `expired`, quoting its detail and pointing at `resume`, never treated as no
416
+ # park; `running`, naming the run, so a second answer never queues behind a job
417
+ # that a newer pending run in the per-branch `concurrency` group could cancel;
418
+ # any state but `parked`; no open question; several open and no <n>; an <n> not
419
+ # open, listing the open set; an empty engine, naming the Run workflow form.
420
+ # Otherwise the answer is written by `printf` (data, never shell source) to
421
+ # `answer_<n>.md` in a fresh directory under `RUNNER_TEMP`, and sent as
422
+ # `dispatch <branch> --engine <engine> --resume answer --answers-from <dir>
423
+ # --indexes <n> --chain 0`: one answer, one dispatch with one entry. That is
424
+ # safe because an `answer` job whose park is not fully answered stops parked
425
+ # before any session (`autonomous-watcher.sh` -> `run_job`), its bundle then
426
+ # carrying the `answer_<n>.md` restore wrote, so the next answer's job finds the
427
+ # set complete. The payload limit is `dispatch`'s alone: its refusal is quoted,
428
+ # with shortening the answer or committing it to a file on the branch as the
429
+ # way on. On 0 with no other question open, a reply that the run resumes and
430
+ # `running` on its issue and pull request; with others open, a reply naming
431
+ # them, and the label stays `parked`. An answer becomes a comment on the item,
432
+ # public on a public repository, as the question already is.
433
+ # THE REVIEW. A `pull_request_review` event reads `.action`, `.review.state`,
434
+ # `.review.body`, `.review.id`, `.review.html_url`, `.review.submitted_at`,
435
+ # `.pull_request.number`, `.pull_request.head.ref`,
436
+ # `.pull_request.head.repo.full_name`, `.sender.login` and `.sender.type`.
437
+ # Ignored, with one line and no `gh` call: an action other than `submitted`; a
438
+ # state other than `REVIEW_ROUND_STATE`, compared lowercase (the REST API
439
+ # reports it uppercase); a body carrying `COMMENT_MARKER`; and a head
440
+ # repository other than `GITHUB_REPOSITORY`, not even replied to, because a
441
+ # fork's review job holds a read-only token. So *Comment* and *Approve* start
442
+ # nothing, and draft status plays no part. Then gates 1-3 above, in order, each
443
+ # a reply on the pull request, and `control_check_branch` on the head; then a
444
+ # head whose origin tip carries no `<state>/story_plans/<head>_story_plan.md`
445
+ # is refused, because the round reads its story index. A REVIEW IS NEVER
446
+ # REFUSED FOR A RUN IN FLIGHT. The head is read by `branch_settled_var`, no run
447
+ # counting as settled, in a command substitution so a failed read is a reply
448
+ # (exit 3) rather than an exit with none. Not settled: a reply and exit 0,
449
+ # nothing pushed or dispatched — `your review is part of round <n>, which is
450
+ # <state>` when a marker on origin's tip records the event's review id, else
451
+ # `your review was collected`, naming the state and saying the next round
452
+ # starts by itself when that run finishes, plus the state's way on: `answer
453
+ # <n>` for `parked`, `clear` for `park_loop`, `resume` for any `paused` but
454
+ # `usage`, and for `usage` that it resumes after the reset. The review stays on
455
+ # the pull request, and a later round collects it. Settled: a round is CUMULATIVE,
456
+ # built by `round_collect` in a fresh file under `RUNNER_TEMP` from ONE
457
+ # paginated `pulls/<n>/reviews` and ONE paginated `pulls/<n>/comments` listing
458
+ # — never the per-review endpoint, which carries no `line` — the event's own
459
+ # review merged when the listing lacks it. What earlier rounds consumed is the
460
+ # union of the review and comment ids their marker lines record, read from
461
+ # origin's tip; the time boundary is the highest-numbered marked round's
462
+ # `collected_at` less `ROUND_OVERLAP_SECS`, or, when no round is marked, the
463
+ # committer time of the branch's newest `user_reviews/<head>_review[_<n>].md`
464
+ # (none when there is no round). Pending: a submitted review (never
465
+ # `PENDING`) whose id is unrecorded, whose body carries no `COMMENT_MARKER`,
466
+ # whose `submitted_at` is at or after the boundary, and which either has state
467
+ # `REVIEW_ROUND_STATE` or a non-blank body; and an
468
+ # inline comment, by any author and whatever its review's state, whose id is
469
+ # unrecorded, whose body carries no `COMMENT_MARKER`, and whose `created_at` is
470
+ # at or after the boundary or whose review is pending. Every distinct author
471
+ # of a pending item passes `authorise_actor`; a refused author's items are
472
+ # dropped with one line naming the login, `AUTH_WHY` and the count, and a
473
+ # failed permission call fails the collection. A round is placed only when at
474
+ # least one kept review has state `REVIEW_ROUND_STATE`; a *Comment* or
475
+ # *Approve* review rides along in the next round one requesting changes starts.
476
+ # The file: one `## Review by @<login>` section per pending review, oldest
477
+ # `submitted_at` first, holding the body verbatim (or `(The review carries no
478
+ # summary.)`) and a provenance line naming its state — `Requested changes on
479
+ # pull request #<n> (<url>) at <submitted_at>.`, `Commented on pull request
480
+ # …`, `Approved pull request …`, `Reviewed pull request … at <submitted_at>;
481
+ # the review has since been dismissed.`, or for any other state `Reviewed
482
+ # pull request … at <submitted_at> (state <state>).`; then, when any is
483
+ # kept, `## Inline comments`, oldest `created_at` first; then the marker line
484
+ # `<!-- sdlc-harness round collected_at=<utc> reviews=<id,…> comments=<id,…> -->`
485
+ # listing exactly the ids written. Each comment is a `### `<path>`, line
486
+ # <n>` heading (`original line <n> (outdated)` when `line` is null), `Made on
487
+ # commit `<original_commit_id or commit_id>`.`, `By @<login>: <url>`, its body
488
+ # verbatim, and its `diff_hunk` in a `diff` fence one backtick longer than the
489
+ # hunk's longest backtick run, at least three — the commit and hunk let the fix plan re-locate
490
+ # a line the fixes moved. With no review pending, nothing is placed: a reply
491
+ # names the round whose marker records the event's review, else says nothing
492
+ # is pending, and exit 0. Then `review <head> --review-file <file>
493
+ # --allow-no-run --reviewers <logins> --source <pull request url>` runs as a
494
+ # child, which fast-forwards, commits `chore: add user review for <head>`, pushes,
495
+ # dispatches `engine: user_review`, waits for its run to be listed and reports
496
+ # the round itself. Its 0 is exit 0 with nothing more posted; 2 means the branch
497
+ # became unsettled between the two reads, answered with the in-flight reply
498
+ # above (the state read again) and exit 0; 3 a reply quoting the re-send line,
499
+ # exit 3; 4 a reply that placement failed and nothing was dispatched, exit 4.
500
+ # A refusal reads `@<login>: `review` was not run: …`, and no reply to a review
501
+ # asks for it to be submitted again to be kept: a reply that started nothing
502
+ # says the reviews stay on the pull request for the next round, and that
503
+ # submitting a review requesting changes retries now.
504
+ # A child's failure is a reply
505
+ # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
506
+ # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
507
+ # `@<login>: `<verb>` was not run: <reason>. <way on>`.
508
+ # 0 handled (replied), or ignored
509
+ # 1 neither an `issue_comment` nor a `pull_request_review` event, or the event could not be read
510
+ # 2 refused (replied)
511
+ # 3 a `gh` step failed: the reply could not be posted (an `::error::`
512
+ # line), or the action failed and was replied to
513
+ # 4 a review's round could not be placed; nothing was dispatched (replied)
514
+ #
202
515
  # `restore` AND `save` ARE THE JOB-SIDE VERBS: the run workflow calls them in
203
516
  # its job, before and (under `always()`) after the harness step. Without
204
517
  # `--repo` they act on the checkout of the working directory (`hr_repo_root`),
@@ -232,7 +545,21 @@
232
545
  # with the exact bytes, after checking every entry first; and with
233
546
  # `HARNESS_INPUT_PARK_LOOP_CLEAR` exactly `true` it sets `park_loop_cycles` to
234
547
  # "0" in the restored `autonomous_logs/remote_status.json`. No bundle in any
235
- # run is an ordinary first job (exit 0, one line) except under --resume answer.
548
+ # candidate run is an ordinary first job (exit 0, one line) except under
549
+ # --resume answer, whose refusal names the current lineage.
550
+ #
551
+ # THE CANDIDATES ARE BOUNDED TO THE BRANCH'S CURRENT LINEAGE, so a branch
552
+ # recreated under a reused name never restores an earlier, unrelated run's
553
+ # bundle. The listing reads each run's `headSha`, and `lineage_commits_var`
554
+ # lists `git rev-list refs/remotes/origin/<defaultBranch>..HEAD` in the job's
555
+ # checkout; a run whose `headSha` is not among those commits, or that carries
556
+ # none, is dropped before the walk, so the expired-bundle stop applies to
557
+ # lineage runs only. When any was dropped it prints `skipped <n> finished
558
+ # run(s) of <branch> from before its current lineage`. When the lineage cannot
559
+ # be listed — the configuration unreadable, `origin/<defaultBranch>` not
560
+ # present, or HEAD carrying no commit beyond it — every finished run is a
561
+ # candidate, as before the bound, and it prints `the lineage of <branch> is
562
+ # not bounded (<reason>); every finished run of it is a candidate`.
236
563
  #
237
564
  # `save` WRAPS `hr_remote_bundle_write` into <out_dir>, and with
238
565
  # `GITHUB_STEP_SUMMARY` set appends a Markdown table of the bundle's `status`,
@@ -264,7 +591,8 @@
264
591
  # so it names the repository rather than a runner path
265
592
  # GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_REPOSITORY the run URL
266
593
  # Notifications go through the sibling `autonomous-notify.sh`, as `paused` or
267
- # `failed`. A re-dispatch is `dispatch <branch> --engine <status.json engine>
594
+ # `failed`, and each is then reported as `report` reports that event, with a
595
+ # note of its own that names no slash command and no shell command. A re-dispatch is `dispatch <branch> --engine <status.json engine>
268
596
  # --resume pause --chain <chain + 1>`, composed by `dispatch` itself.
269
597
  #
270
598
  # `chain` HAS ONE SOURCE: the bundle's `status.json`, whose `chain` is the
@@ -436,7 +764,7 @@
436
764
  # is a wire: `pause` and `stop` send the branch as their `branch` input for
437
765
  # exactly that reason.
438
766
  #
439
- # `stop` DOES THREE THINGS, IN THIS ORDER. (1) It ALWAYS dispatches action=stop
767
+ # `stop` DOES FOUR THINGS, IN THIS ORDER. (1) It ALWAYS dispatches action=stop
440
768
  # on the branch — a jobless run titled `harness stop <branch>` that GitHub keeps
441
769
  # as the stop marker `continue` and `poll` read. It is first because it is the
442
770
  # only part that reaches a usage-paused run waiting on the resume poller, which
@@ -448,7 +776,12 @@
448
776
  # after a failure. (3) Only when (1) and (2) all succeeded, and only when a
449
777
  # local registry record exists, it writes `remote_stopped_at` and sets `status`
450
778
  # to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
451
- # and exits 3, so running `stop` again is the remedy.
779
+ # and exits 3, so running `stop` again is the remedy. (4) A complete stop is then
780
+ # reported as `report stopped` with the note `Stopped by @<actor>.` under
781
+ # `--actor` (a login, the trigger's shape plus an optional `[bot]`; anything else
782
+ # is a usage error), else one naming a local stop; a partial stop reports
783
+ # nothing. The cancelled job's own `failed` is then posted nowhere, because
784
+ # `report` finds the branch stopped.
452
785
  #
453
786
  # `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
454
787
  # --json defaultBranchRef`), which may differ from the configured
@@ -460,18 +793,28 @@
460
793
  # root's `harness.config.json`.
461
794
  #
462
795
  # WHAT IT NEVER DOES. It never launches a local session, never writes the
463
- # inbox, and never watches a run it sent. Only `start` and `review` push, and
796
+ # inbox, and never watches a run it sent beyond the bounded lookup of its
797
+ # listing that `trigger` and `review` make. Only `start` and `review` push, and
464
798
  # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
465
799
  # the prompt committed on `origin/<branch>`, through a working copy and a local
466
800
  # branch it removes before it returns; `review`'s are the round committed on
467
801
  # `origin/<branch>`, through the record's mirror or a copy and a local branch
468
- # it removes, the record's `status` / `engine`, and the bundle download
469
- # directory `sync` uses. A user's chain-0 `dispatch --resume answer|pause`
802
+ # it removes, the record's `status` / `engine`, the bundle download
803
+ # directory `sync` uses, and `report`'s writes for the round. A user's chain-0 `dispatch --resume answer|pause`
470
804
  # writes the record's `status`, `resumed_at` and `resume_kind` in one write
471
805
  # when the main checkout's registry file exists and holds a record with
472
806
  # `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
473
807
  # files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
474
- # for a dispatch event a block in the step summary. Every other verb's only writes are the
808
+ # for a dispatch event a block in the step summary. `report` writes its comment
809
+ # file under `RUNNER_TEMP` (removed), one comment and the state labels on the
810
+ # issue and the pull request, and nothing local. `deliver` writes its body and
811
+ # comment files under `RUNNER_TEMP` (removed), at most one pull request, one
812
+ # comment and the state labels. `control` writes its reply file and its
813
+ # `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
814
+ # what the child verb it runs writes. `collect` writes its round file and its
815
+ # settledness directory under `RUNNER_TEMP` (removed), at most one comment on
816
+ # the pull request, plus what its `review` child writes. `stop`, `continue` and `poll` also make
817
+ # `report`'s writes for each event they report. Every other verb's only writes are the
475
818
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
476
819
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
477
820
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
@@ -481,7 +824,8 @@
481
824
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
482
825
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
483
826
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
484
- # and `current/`. For `fetch`, <out_dir> only. `pause-requested`,
827
+ # and `current/`. For `fetch`, <out_dir> only. For `discard`, the removal of
828
+ # <dir> only. `pause-requested`,
485
829
  # `run-created-at`, `list` and `status` write nothing; a no-record `status`
486
830
  # downloads into a temporary directory it removes on exit.
487
831
  #
@@ -494,8 +838,18 @@
494
838
  # HARNESS_GH_CLI mirrors GH_CLI_VARIABLE (the binary run as `gh`)
495
839
  # HARNESS_TRIGGER_LABEL mirrors TRIGGER_LABEL_VARIABLE
496
840
  # DEFAULT_TRIGGER_LABEL mirrors DEFAULT_TRIGGER_LABEL
841
+ # LEGACY_TRIGGER_LABEL mirrors LEGACY_TRIGGER_LABEL
497
842
  # HARNESS_TRIGGER_ALLOWED_BOTS mirrors TRIGGER_ALLOWED_BOTS_VARIABLE
498
843
  # TRIGGER_DISPATCH_EVENT_TYPE mirrors TRIGGER_DISPATCH_EVENT_TYPE ('harness-task')
844
+ # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
845
+ # COMMAND_HANDLE mirrors COMMAND_HANDLE
846
+ # COMMAND_VERBS mirrors COMMAND_VERBS, space-separated
847
+ # COMMENT_MARKER mirrors COMMENT_MARKER
848
+ # REVIEW_ROUND_STATE mirrors REVIEW_ROUND_STATE
849
+ # STATE_LABEL_PREFIX mirrors STATE_LABEL_PREFIX
850
+ # RUN_STATES mirrors RUN_STATES, space-separated, same order
851
+ # PR_CREATE_SETTING mirrors PR_CREATE_SETTING
852
+ # PR_CREATE_SETTING_PATH mirrors PR_CREATE_SETTING_PATH
499
853
  #
500
854
  # `set -u` WITHOUT `-e`: every refusal is reported with its own exit code rather
501
855
  # than aborting mid-decision.
@@ -542,11 +896,24 @@
542
896
  # bash scripts/remote-run.sh review feat_x --review-file /tmp/r.md
543
897
  # -> 0; origin/feat_x gains `chore: add user review for feat_x`
544
898
  # placing feat_x_review.md, then one `-f engine=user_review`
545
- # dispatch; no copy or local feat_x is left
546
- # in flight the newest run `in_progress` -> 2, nothing pushed or sent
899
+ # dispatch; no copy or local feat_x is left; the stub lists no
900
+ # run of the pushed `headSha`, so one `::warning::` line follows
901
+ # (export HARNESS_TRIGGER_LOOKUP_SECS=0 to skip the waits)
902
+ # in flight the newest run `in_progress`, its jobs listing no completed
903
+ # `run` job -> 2, nothing pushed or sent
904
+ # no run no `harness run feat_x` run listed -> 2, nothing pushed; with
905
+ # --allow-no-run -> 0, placed and dispatched as `review` above
906
+ # reported report's setup, then review ... --actor alice --source
907
+ # https://github.com/o/r/pull/12#pullrequestreview-1 -> 0; one
908
+ # comment on 7 naming `Round <n>`, the source and `@alice`, then
909
+ # `sdlc-harness: running` on 7
547
910
  # fetch t=$(mktemp -d); bash scripts/remote-run.sh fetch feat_x "$t"
548
911
  # -> 0; prints `state: running` (or, with no run listed,
549
912
  # `state: none`), every other key present
913
+ # discard mkdir -p sdlc-harness/scratch/branch-pause-feat_x, then
914
+ # bash scripts/remote-run.sh discard sdlc-harness/scratch/branch-pause-feat_x
915
+ # -> 0, the directory gone; discard sdlc-harness/autonomous_logs
916
+ # -> 2, nothing removed; no gh call either way
550
917
  #
551
918
  # no record with no registry, the "a bundle" stub below with question_1.md:
552
919
  # bash scripts/remote-run.sh status feat_x -> 0; prints `state:
@@ -594,6 +961,13 @@
594
961
  # first job a `run list` answer with no finished run: --resume none -> 0;
595
962
  # --resume answer -> 2
596
963
  # own run GITHUB_RUN_ID=<the bundle run's id> -> that run is skipped
964
+ # lineage the checkout on feat_x one commit beyond origin/main, and a `run
965
+ # list` answer holding only an older completed run whose `headSha`
966
+ # is another commit: --resume none -> 0, prints `skipped 1 finished
967
+ # run(s) of feat_x from before its current lineage` and `this is
968
+ # its first job`, no `run download`
969
+ # own lineage that same branch plus a newer completed run whose `headSha`
970
+ # is that commit -> that run's bundle is restored
597
971
  # expired the artifact list answering {"artifacts":[{"name":"harness-state",
598
972
  # "expired":true,"expires_at":"2026-01-02T00:00:00Z"}]}: --resume
599
973
  # pause -> 0, a `::warning::` line, no `run download`, no older
@@ -648,7 +1022,7 @@
648
1022
  # run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
649
1023
  #
650
1024
  # trigger needs start's setup plus `"forge": "github"`, an event file e.json
651
- # {"action":"labeled","label":{"name":"harness"},"sender":{"login":"alice",
1025
+ # {"action":"labeled","label":{"name":"sdlc-harness"},"sender":{"login":"alice",
652
1026
  # "type":"User"},"issue":{"number":7,"title":"Add comments","body":"x",
653
1027
  # "html_url":"https://github.com/o/r/issues/7","state":"open"}}, and a stub
654
1028
  # answering `api repos/o/r/collaborators/alice/permission` with
@@ -657,7 +1031,7 @@
657
1031
  # trigger bash scripts/remote-run.sh trigger -> 0; origin/add_comments gains
658
1032
  # the prompt commit, "$s.log" gains `workflow run harness-run.yml
659
1033
  # --ref add_comments ...`, `issue comment 7 ...` naming the branch,
660
- # then `issue edit 7 ... --remove-label harness`
1034
+ # then `issue edit 7 ... --remove-label sdlc-harness`
661
1035
  # read the permission answer {"permission":"read"} -> 2, no `workflow
662
1036
  # run`, one comment naming write access, the label removed
663
1037
  # ignored e.json's label name `bug` -> 0, one line, "$s.log" unchanged
@@ -666,6 +1040,48 @@
666
1040
  # "client_payload":{"title":"Add tags","body":"x","source":"jira"}}
667
1041
  # -> 0; `workflow run ... --ref add_tags ...`, /tmp/s names
668
1042
  # add_tags, no `issue` call; without "title" -> 2, no `workflow run`
1043
+ #
1044
+ # report needs trigger's setup, a branch feat_x pushed carrying its task
1045
+ # prompt (ending in a `Started from https://github.com/o/r/issues/7 by @alice`
1046
+ # line) and `flow_progress/feat_x_progress.md`, and a stub answering `pr
1047
+ # list` and `api repos/o/r/issues/7/labels` with []; GITHUB_REPOSITORY=o/r:
1048
+ # paused bash scripts/remote-run.sh report paused feat_x -> 0; "$s.log"
1049
+ # gains `api --method POST repos/o/r/issues/7/comments -F body=@…`
1050
+ # naming `@sdlc-harness resume`, then `api --method POST
1051
+ # repos/o/r/issues/7/labels -f labels[]=sdlc-harness: paused`
1052
+ # forge off `"forge": "none"`, then the same -> 0, one line, "$s.log" unchanged
1053
+ #
1054
+ # deliver needs report's setup, a stub answering the create with
1055
+ # https://github.com/o/r/pull/12, and <b> a bundle directory whose status.json
1056
+ # carries schema "1" and status completed:
1057
+ # deliver bash scripts/remote-run.sh deliver feat_x <b> -> 0; "$s.log" gains
1058
+ # a `--draft` create with `--base main --head feat_x`, then one
1059
+ # comment on issue 7 naming /pull/12, then `sdlc-harness: done` on
1060
+ # 7 and 12
1061
+ # not done status parked, or an empty <b> -> 0, one line, "$s.log" unchanged
1062
+ #
1063
+ # collect needs deliver's setup, a story index on feat_x, a stub answering
1064
+ # `pr list` with [{"number":12,"isCrossRepository":false}], `run list` with a
1065
+ # `completed` `harness run feat_x` run whose bundle says `completed`, `api
1066
+ # --paginate repos/o/r/pulls/12/reviews` with one `CHANGES_REQUESTED` review
1067
+ # by alice, and the permission call with {"permission":"write"}:
1068
+ # collect bash scripts/remote-run.sh collect feat_x -> 0; origin/feat_x
1069
+ # gains `chore: add user review for feat_x`, then one `-f
1070
+ # engine=user_review` dispatch; with no review listed -> 0, one
1071
+ # line, nothing pushed or dispatched
1072
+ #
1073
+ # control needs report's setup, a stub answering `pr view 12 ...` with
1074
+ # {"headRefName":"feat_x","isCrossRepository":false,"state":"OPEN"}, the
1075
+ # permission call with {"permission":"write"}, and `run list` with an
1076
+ # `in_progress` `harness run feat_x` run; an event file c.json
1077
+ # {"action":"created","comment":{"body":"@sdlc-harness pause"},"issue":{"number":12,
1078
+ # "pull_request":{"url":"x"}},"sender":{"login":"alice","type":"User"}};
1079
+ # export GITHUB_EVENT_NAME=issue_comment GITHUB_EVENT_PATH=c.json GITHUB_REPOSITORY=o/r:
1080
+ # pause bash scripts/remote-run.sh control -> 0; "$s.log" gains `workflow
1081
+ # run harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1082
+ # then one comment on 12 naming @alice
1083
+ # ignored c.json's body `Let's @sdlc-harness pause` -> 0, one line,
1084
+ # "$s.log" unchanged
669
1085
 
670
1086
  set -u
671
1087
 
@@ -682,8 +1098,23 @@ WORKFLOW_RUN_FILE='harness-run.yml'
682
1098
  WORKFLOW_RESUME_FILE='harness-resume.yml'
683
1099
  STATE_ARTIFACT_NAME='harness-state'
684
1100
  POLL_STATE_ARTIFACT_NAME='harness-poll-state'
685
- DEFAULT_TRIGGER_LABEL='harness'
1101
+ DEFAULT_TRIGGER_LABEL='sdlc-harness'
1102
+ LEGACY_TRIGGER_LABEL='harness'
686
1103
  TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1104
+ WORKFLOW_CONTROL_FILE='harness-control.yml'
1105
+ COMMAND_HANDLE='@sdlc-harness'
1106
+ COMMAND_VERBS='answer pause resume stop clear'
1107
+ COMMENT_MARKER='<!-- sdlc-harness'
1108
+ REVIEW_ROUND_STATE='changes_requested'
1109
+ STATE_LABEL_PREFIX='sdlc-harness: '
1110
+ RUN_STATES='running parked paused done failed stopped'
1111
+ PR_CREATE_SETTING='Allow GitHub Actions to create and approve pull requests'
1112
+ PR_CREATE_SETTING_PATH='Settings -> Actions -> General -> Workflow permissions'
1113
+ # The most bytes of a question file one park comment carries. An issue comment
1114
+ # holds 262,144 bytes of UTF-8, and the refusal text's character count is not
1115
+ # to be trusted (docs/github-integration-research.md -> S6); the margin is the
1116
+ # framing lines and the marker.
1117
+ QUESTION_COMMENT_MAX_BYTES=250000
687
1118
  GH="${HARNESS_GH_CLI:-gh}"
688
1119
 
689
1120
  # How many runs `status` prints, and how many `run list` returns for status
@@ -708,6 +1139,13 @@ POLL_STATE_RUNS_LIMIT=10
708
1139
  # between tries is `HARNESS_TRIGGER_LOOKUP_SECS`, a test seam.
709
1140
  TRIGGER_RUN_LOOKUP_TRIES=6
710
1141
  TRIGGER_LOOKUP_SECS_DEFAULT=5
1142
+ # The `run` job's name in WORKFLOW_RUN_FILE: its key, since it has no `name:`.
1143
+ # `branch_settled_var` reads that job's status; harness-run.yml's header
1144
+ # declares the mirror.
1145
+ RUN_JOB_NAME='run'
1146
+ # How far before the previous round's `collected_at` `round_collect` lists
1147
+ # from, absorbing runner-clock skew; the id check drops what it re-lists.
1148
+ ROUND_OVERLAP_SECS=300
711
1149
 
712
1150
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
713
1151
  # maximum payload for inputs is 65,535 characters."
@@ -727,7 +1165,7 @@ usage() {
727
1165
  echo "usage: remote-run.sh dispatch <branch> --engine <task|user_review|docs> [--resume none|answer|pause] [--answers-from <clar_dir> --indexes \"<n> ...\"] [--park-loop-clear] [--chain <n>] [--repo <root>]" >&2
728
1166
  echo " remote-run.sh pause <branch> [--repo <root>]" >&2
729
1167
  echo " remote-run.sh warm [--repo <root>]" >&2
730
- echo " remote-run.sh stop <branch> [--repo <root>]" >&2
1168
+ echo " remote-run.sh stop <branch> [--actor <login>] [--repo <root>]" >&2
731
1169
  echo " remote-run.sh status <branch> [--repo <root>]" >&2
732
1170
  echo " remote-run.sh sync <branch> [--repo <root>]" >&2
733
1171
  echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
@@ -738,9 +1176,14 @@ usage() {
738
1176
  echo " remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]" >&2
739
1177
  echo " remote-run.sh run-created-at <run_id> [--repo <root>]" >&2
740
1178
  echo " remote-run.sh start <branch> --prompt-file <file> [--repo <root>]" >&2
741
- echo " remote-run.sh review <branch> --review-file <file> [--repo <root>]" >&2
1179
+ echo " remote-run.sh review <branch> --review-file <file> [--allow-no-run] [--actor <login>] [--reviewers <login,login,...>] [--source <https-url>] [--repo <root>]" >&2
742
1180
  echo " remote-run.sh trigger [--repo <root>]" >&2
743
1181
  echo " remote-run.sh list [--repo <root>]" >&2
1182
+ echo " remote-run.sh discard <dir> [--repo <root>]" >&2
1183
+ echo " remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]" >&2
1184
+ echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1185
+ echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1186
+ echo " remote-run.sh control [--repo <root>]" >&2
744
1187
  [ "${verb-}" != save ] || exit "$EXIT_OK"
745
1188
  exit "$EXIT_USAGE"
746
1189
  }
@@ -751,7 +1194,22 @@ usage() {
751
1194
  GH_OUT=""
752
1195
  GH_ERR=""
753
1196
  gh_call() {
754
- local errfile status
1197
+ gh_run 0 "" "$@"
1198
+ }
1199
+
1200
+ # gh_call_token <token> <args...> — gh_call with GH_TOKEN set to <token> for
1201
+ # that one gh process only; every other call keeps the environment's token.
1202
+ gh_call_token() {
1203
+ local token="$1"
1204
+ shift
1205
+ gh_run 1 "$token" "$@"
1206
+ }
1207
+
1208
+ # gh_run <0|1> <token> <args...> — gh_call's body; with 1, GH_TOKEN is a prefix
1209
+ # assignment on the external command, never on a function, so it cannot leak.
1210
+ gh_run() {
1211
+ local with_token="$1" token="$2" errfile status
1212
+ shift 2
755
1213
  GH_OUT=""
756
1214
  GH_ERR=""
757
1215
  if ! command -v "$GH" >/dev/null 2>&1; then
@@ -759,7 +1217,11 @@ gh_call() {
759
1217
  return 127
760
1218
  fi
761
1219
  errfile=$(mktemp) || { GH_ERR="mktemp failed"; return 1; }
762
- GH_OUT=$("$GH" "$@" 2>"$errfile")
1220
+ if [ "$with_token" = 1 ]; then
1221
+ GH_OUT=$(GH_TOKEN="$token" "$GH" "$@" 2>"$errfile")
1222
+ else
1223
+ GH_OUT=$("$GH" "$@" 2>"$errfile")
1224
+ fi
763
1225
  status=$?
764
1226
  if [ "$status" -ne 0 ]; then
765
1227
  IFS= read -r GH_ERR <"$errfile" || :
@@ -791,7 +1253,7 @@ verb=""
791
1253
  verb="$1"
792
1254
  shift
793
1255
  case "$verb" in
794
- dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list) ;;
1256
+ dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list|discard|report|deliver|collect|control) ;;
795
1257
  *) usage "unknown verb '$verb'" ;;
796
1258
  esac
797
1259
 
@@ -811,6 +1273,15 @@ since_arg=""
811
1273
  run_id_arg=""
812
1274
  prompt_file=""
813
1275
  review_file=""
1276
+ discard_dir=""
1277
+ discard_base=""
1278
+ report_event=""
1279
+ report_note=""
1280
+ actor_arg=""
1281
+ source_arg=""
1282
+ reviewers_arg=""
1283
+ allow_no_run=0
1284
+ pr_arg=""
814
1285
 
815
1286
  while [ "$#" -gt 0 ]; do
816
1287
  case "$1" in
@@ -842,21 +1313,52 @@ while [ "$#" -gt 0 ]; do
842
1313
  [ "$verb" = review ] || usage "$1 is a review option"
843
1314
  [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
844
1315
  review_file="$2"; shift 2 ;;
1316
+ --note)
1317
+ [ "$verb" = report ] || usage "$1 is a report option"
1318
+ [ "$#" -ge 2 ] || usage "$1 needs a value"
1319
+ report_note="$2"; shift 2 ;;
1320
+ --actor)
1321
+ [ "$verb" = stop ] || [ "$verb" = review ] || usage "$1 is a stop or review option"
1322
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1323
+ actor_arg="$2"; shift 2 ;;
1324
+ --source)
1325
+ [ "$verb" = review ] || usage "$1 is a review option"
1326
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1327
+ source_arg="$2"; shift 2 ;;
1328
+ --reviewers)
1329
+ [ "$verb" = review ] || usage "$1 is a review option"
1330
+ [ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
1331
+ reviewers_arg="$2"; shift 2 ;;
1332
+ --allow-no-run)
1333
+ [ "$verb" = review ] || usage "$1 is a review option"
1334
+ allow_no_run=1; shift ;;
1335
+ --pr)
1336
+ [ "$verb" = collect ] || usage "$1 is a collect option"
1337
+ [ "$#" -ge 2 ] || usage "$1 needs a value"
1338
+ case "$2" in
1339
+ ''|*[!0-9]*|0*) usage "--pr needs a positive pull request number" ;;
1340
+ esac
1341
+ pr_arg="$2"; shift 2 ;;
845
1342
  -*)
846
1343
  usage "unknown option '$1'" ;;
847
1344
  *)
848
1345
  [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != trigger ] \
849
- && [ "$verb" != list ] || usage "$verb takes no branch"
1346
+ && [ "$verb" != list ] && [ "$verb" != control ] || usage "$verb takes no branch"
850
1347
  if [ "$verb" = run-created-at ]; then
851
1348
  [ -z "$run_id_arg" ] || usage "unexpected argument '$1'"
852
1349
  run_id_arg="$1"
1350
+ elif [ "$verb" = discard ]; then
1351
+ [ -z "$discard_dir" ] || usage "unexpected argument '$1'"
1352
+ discard_dir="$1"
1353
+ elif [ "$verb" = report ] && [ -z "$report_event" ]; then
1354
+ report_event="$1"
853
1355
  elif [ -z "$branch" ]; then
854
1356
  branch="$1"
855
1357
  elif [ "$verb" = pause-requested ] && [ -z "$since_arg" ]; then
856
1358
  since_arg="$1"
857
1359
  elif { [ "$verb" = save ] || [ "$verb" = fetch ]; } && [ -z "$out_dir" ]; then
858
1360
  out_dir="$1"
859
- elif [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
1361
+ elif { [ "$verb" = continue ] || [ "$verb" = deliver ]; } && [ -z "$bundle_dir" ]; then
860
1362
  bundle_dir="$1"
861
1363
  else
862
1364
  usage "unexpected argument '$1'"
@@ -866,10 +1368,38 @@ while [ "$#" -gt 0 ]; do
866
1368
  done
867
1369
 
868
1370
  if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ] && [ "$verb" != trigger ] \
869
- && [ "$verb" != list ]; then
1371
+ && [ "$verb" != list ] && [ "$verb" != discard ] && [ "$verb" != control ]; then
870
1372
  valid_branch "$branch" || usage "$verb needs a <branch>"
871
1373
  fi
872
1374
 
1375
+ if [ "$verb" = discard ] && [ -z "$discard_dir" ]; then
1376
+ usage "discard needs a <dir>"
1377
+ fi
1378
+
1379
+ # The event lands in the comment marker, so it is a word.
1380
+ if [ "$verb" = report ] && ! [[ "$report_event" =~ ^[a-z][a-z_]*$ ]]; then
1381
+ usage "report needs an <event> of lowercase letters and underscores"
1382
+ fi
1383
+
1384
+ # The actor lands in the stop or round comment, so it is a login: the trigger's shape.
1385
+ if [ -n "$actor_arg" ] && ! [[ "$actor_arg" =~ ^[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?$ ]]; then
1386
+ usage "--actor needs a GitHub login"
1387
+ fi
1388
+
1389
+ # The reviewers land in the round comment, so each is a login.
1390
+ if [ -n "$reviewers_arg" ]; then
1391
+ [[ ",$reviewers_arg," =~ ^(,[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?)+,$ ]] \
1392
+ || usage "--reviewers needs comma-separated GitHub logins"
1393
+ fi
1394
+
1395
+ # The source lands in the round comment as a link.
1396
+ if [ -n "$source_arg" ]; then
1397
+ case "$source_arg" in
1398
+ https://*) ;;
1399
+ *) usage "--source needs an https:// URL" ;;
1400
+ esac
1401
+ fi
1402
+
873
1403
  if [ "$verb" = pause-requested ]; then
874
1404
  case "$since_arg" in
875
1405
  ''|*[!0-9]*) usage "pause-requested needs a <since_epoch> that is a non-negative integer" ;;
@@ -884,8 +1414,8 @@ if [ "$verb" = run-created-at ]; then
884
1414
  esac
885
1415
  fi
886
1416
 
887
- if [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
888
- usage "continue needs a <bundle_dir>"
1417
+ if { [ "$verb" = continue ] || [ "$verb" = deliver ]; } && [ -z "$bundle_dir" ]; then
1418
+ usage "$verb needs a <bundle_dir>"
889
1419
  fi
890
1420
 
891
1421
  if [ "$verb" = save ] && [ -z "$out_dir" ]; then
@@ -943,17 +1473,20 @@ fi
943
1473
  # The repository and its configuration.
944
1474
  # ---------------------------------------------------------------------------
945
1475
 
946
- # setup_fail <message> — a configuration problem: exit 1, except for save.
1476
+ # setup_fail <message> — a configuration problem: exit 1, except for save,
1477
+ # report, deliver and collect, which never fail the step that calls them.
947
1478
  setup_fail() {
948
1479
  echo "remote-run.sh: $1" >&2
949
- [ "$verb" != save ] || exit "$EXIT_OK"
1480
+ [ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != deliver ] && [ "$verb" != collect ] || exit "$EXIT_OK"
950
1481
  exit "$EXIT_USAGE"
951
1482
  }
952
1483
 
953
1484
  if [ -n "$repo_arg" ]; then
954
1485
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
955
1486
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
956
- || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ]; then
1487
+ || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ] \
1488
+ || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = deliver ] || [ "$verb" = collect ] \
1489
+ || [ "$verb" = control ]; then
957
1490
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
958
1491
  else
959
1492
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -963,15 +1496,18 @@ hr_config_load "$root" || :
963
1496
  registry=""
964
1497
  status_no_record=0
965
1498
  case "$verb" in
966
- restore|save|continue|poll)
1499
+ restore|save|continue|poll|discard)
967
1500
  hr_state_path "$root" >/dev/null || setup_fail "cannot resolve '$root/harness.config.json'"
968
1501
  ;;
969
1502
  pause-requested|run-created-at)
970
1503
  # Read verbs: no gate, and nothing of the configuration is read.
971
1504
  ;;
972
- trigger)
1505
+ trigger|control)
973
1506
  # Gates itself, after reading the event, so a refusal can still be commented.
974
1507
  ;;
1508
+ report|deliver|collect)
1509
+ # Gates itself (`forge_on`) and exits 0 on every outcome.
1510
+ ;;
975
1511
  status|sync)
976
1512
  registry=$(hr_state_path "$root" autonomous_logs/registry.json) || {
977
1513
  echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
@@ -1020,12 +1556,18 @@ case "$review_file" in
1020
1556
  ''|/*) ;;
1021
1557
  *) review_file="${PWD-.}/$review_file" ;;
1022
1558
  esac
1559
+ case "$answers_from" in
1560
+ ''|/*) ;;
1561
+ *) answers_from="${PWD-.}/$answers_from" ;;
1562
+ esac
1023
1563
  if [ "$verb" = fetch ]; then
1024
1564
  case "$out_dir" in
1025
1565
  /*) ;;
1026
1566
  *) out_dir="${PWD-.}/$out_dir" ;;
1027
1567
  esac
1028
1568
  fi
1569
+ # Kept apart from <dir>, so the library's character tests see it as typed.
1570
+ [ "$verb" != discard ] || discard_base="${PWD-.}"
1029
1571
 
1030
1572
  cd "$root" || setup_fail "cannot enter '$root'"
1031
1573
 
@@ -1135,6 +1677,11 @@ verb_stop() {
1135
1677
  || echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
1136
1678
  fi
1137
1679
  echo "remote-run.sh: stopped $branch"
1680
+ if [ -n "$actor_arg" ]; then
1681
+ forge_report stopped "$branch" "Stopped by @$actor_arg."
1682
+ else
1683
+ forge_report stopped "$branch" "Stopped from a local \`remote-run.sh stop\`."
1684
+ fi
1138
1685
  }
1139
1686
 
1140
1687
  # list_runs — the branch's runs of the workflow into GH_OUT; exits 3 on failure.
@@ -1332,7 +1879,9 @@ sync_expired() {
1332
1879
  # into <download_dir> — `sync`'s per-run directory under the main checkout when
1333
1880
  # empty — skipped when that directory already holds its status.json, and
1334
1881
  # RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
1335
- # existing for case 4. Exits 3 when gh fails, 2 for an unrecognised bundle.
1882
+ # existing for case 4. <finished> 1 reads the newest run as finished whatever
1883
+ # its `status`: `branch_settled_var` passes it once that run's `run` job has
1884
+ # completed. Exits 3 when gh fails, 2 for an unrecognised bundle.
1336
1885
  RS_RUNS=""
1337
1886
  RS_RUN_ID=""
1338
1887
  RS_RUN_URL=""
@@ -1346,7 +1895,7 @@ RS_PARK_LOOP_CYCLES=""
1346
1895
  RS_BUNDLE=0
1347
1896
  RS_DOWNLOAD=""
1348
1897
  remote_state() {
1349
- local download="${1-}" applied="${2-}" newest status_file older bundle_exists=0
1898
+ local download="${1-}" applied="${2-}" finished="${3-0}" newest status_file older bundle_exists=0
1350
1899
  RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
1351
1900
  RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
1352
1901
  RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""
@@ -1359,7 +1908,7 @@ remote_state() {
1359
1908
  RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
1360
1909
  RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
1361
1910
  RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
1362
- if [ "$RS_GH_STATUS" != completed ]; then
1911
+ if [ "$RS_GH_STATUS" != completed ] && [ "$finished" != 1 ]; then
1363
1912
  RS_STATE=running
1364
1913
  return 0
1365
1914
  fi
@@ -1435,6 +1984,48 @@ remote_state() {
1435
1984
  return 0
1436
1985
  }
1437
1986
 
1987
+ # branch_settled_var <download_dir> <allow_no_run 0|1> — the one settledness
1988
+ # test `review`, `control` and `collect` share: whether a user-review round may be placed
1989
+ # on the branch now. Lists the runs itself, then derives RS_* through
1990
+ # `remote_state`. The newest `harness run <branch>` run decides:
1991
+ # none listed settled only under <allow_no_run> 1 (RS_STATE `none`)
1992
+ # `completed` `remote_state` as ever
1993
+ # anything else its jobs are read: while no job named RUN_JOB_NAME exists
1994
+ # (queued) or that job is not `completed`, in flight as
1995
+ # `running`; once it is, the run's bundle decides as for a
1996
+ # finished run, since only a later job of that run is left
1997
+ # SETTLED is 1 for RS_STATE `completed` or `failed` (and `none` as above), else
1998
+ # 0. Exits 3 when gh fails, 2 for an unrecognised bundle, as `remote_state` does.
1999
+ SETTLED=0
2000
+ branch_settled_var() {
2001
+ local download="${1-}" allow="${2-0}" listed newest id status job finished=0
2002
+ SETTLED=0
2003
+ list_runs
2004
+ # `remote_state` reads the listing from GH_OUT, which the jobs call replaces.
2005
+ listed="$GH_OUT"
2006
+ newest=$(titled_runs "harness run $branch" | jq -c '.[0] // empty') || newest=""
2007
+ if [ -n "$newest" ]; then
2008
+ status=$(printf '%s' "$newest" | jq -r '.status // ""')
2009
+ if [ "$status" != completed ]; then
2010
+ id=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
2011
+ gh_call api "repos/{owner}/{repo}/actions/runs/$id/jobs" || gh_fail "reading the jobs of run $id failed"
2012
+ job=$(printf '%s' "$GH_OUT" | jq -r --arg n "$RUN_JOB_NAME" \
2013
+ '[.jobs[]? | select(.name == $n) | .status // ""] | first // ""' 2>/dev/null) || {
2014
+ GH_ERR="its job list is not the expected JSON"
2015
+ gh_fail "reading the jobs of run $id failed"
2016
+ }
2017
+ [ "$job" != completed ] || finished=1
2018
+ fi
2019
+ fi
2020
+ GH_OUT="$listed"
2021
+ remote_state "$download" "" "$finished"
2022
+ case "$RS_STATE" in
2023
+ completed|failed) SETTLED=1 ;;
2024
+ none) [ "$allow" != 1 ] || SETTLED=1 ;;
2025
+ esac
2026
+ return 0
2027
+ }
2028
+
1438
2029
  verb_sync() {
1439
2030
  local worktree id url synced_id now download
1440
2031
  local status reason detail
@@ -1542,26 +2133,71 @@ restore_refuse() {
1542
2133
  exit "$EXIT_REFUSED"
1543
2134
  }
1544
2135
 
2136
+ # lineage_commits_var <checkout> — the branch's current lineage, read from refs
2137
+ # alone: never a fetch, never gh, nothing on stdout. A run is of the current
2138
+ # lineage when its `headSha` is a commit reachable from HEAD and not from
2139
+ # `origin/<defaultBranch>`. The list is complete because `harness-run.yml`
2140
+ # checks out with `fetch-depth: 0`. It survives the branch's own history
2141
+ # edits because `refresh-branch.sh` merges and never rebases and
2142
+ # `push-branch.sh` never forces, so every own run's `headSha` stays an ancestor
2143
+ # of HEAD; a deleted, unmerged branch's commits are not ancestors of a branch
2144
+ # recreated under its name. An empty list leaves the lineage unbounded.
2145
+ # 0: LINEAGE_COMMITS holds the newline-separated full SHAs. 1: it is empty and
2146
+ # LINEAGE_WHY names the reason.
2147
+ LINEAGE_COMMITS=""
2148
+ LINEAGE_WHY=""
2149
+ lineage_commits_var() {
2150
+ local checkout="${1-}" default
2151
+ LINEAGE_COMMITS=""
2152
+ LINEAGE_WHY=""
2153
+ default=$(hr_default_branch "$checkout") && [ -n "$default" ] || {
2154
+ LINEAGE_WHY="the configuration could not be read"
2155
+ return 1
2156
+ }
2157
+ git -C "$checkout" rev-parse --verify --quiet "refs/remotes/origin/$default^{commit}" >/dev/null 2>&1 || {
2158
+ LINEAGE_WHY="origin/$default is not present"
2159
+ return 1
2160
+ }
2161
+ LINEAGE_COMMITS=$(git -C "$checkout" rev-list "refs/remotes/origin/$default..HEAD" 2>/dev/null) || LINEAGE_COMMITS=""
2162
+ [ -n "$LINEAGE_COMMITS" ] || {
2163
+ LINEAGE_WHY="HEAD carries no commit beyond origin/$default"
2164
+ return 1
2165
+ }
2166
+ return 0
2167
+ }
2168
+
1545
2169
  # previous_bundle_run — PREV_RUN_ID is the newest finished `harness run
1546
2170
  # <branch>` run, other than this job's own, carrying a state artifact, and
1547
- # PREV_RUN_STATE is `present`, `expired` or empty when no run carries one. A
1548
- # run with no artifact is walked past; an expired one stops the walk, because
2171
+ # PREV_RUN_STATE is `present`, `expired` or empty when no run carries one. When
2172
+ # `lineage_commits_var` bounds the lineage, a run whose `headSha` is not in it
2173
+ # (or that has none) is dropped before the walk and counted in LINEAGE_SKIPPED.
2174
+ # A run with no artifact is walked past; an expired one stops the walk, because
1549
2175
  # an older copy is staler state. Exits 3 when gh fails.
1550
2176
  PREV_RUN_ID=""
1551
2177
  PREV_RUN_STATE=""
2178
+ LINEAGE_SKIPPED=0
1552
2179
  previous_bundle_run() {
1553
- local ids id
2180
+ local ids id out bounded=0
1554
2181
  PREV_RUN_ID=""
1555
2182
  PREV_RUN_STATE=""
2183
+ LINEAGE_SKIPPED=0
2184
+ lineage_commits_var "$root" && bounded=1
1556
2185
  gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" \
1557
- --json databaseId,displayTitle,status,createdAt --limit "$RUN_LIST_LIMIT" \
2186
+ --json databaseId,displayTitle,status,createdAt,headSha --limit "$RUN_LIST_LIMIT" \
1558
2187
  || gh_fail "listing the runs of '$branch' failed"
1559
- ids=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg self "${GITHUB_RUN_ID-}" '
1560
- [.[] | select(.displayTitle == $t and .status == "completed" and (.databaseId | tostring) != $self)]
1561
- | sort_by([.createdAt, .databaseId]) | reverse | .[].databaseId | tostring' 2>/dev/null) || {
2188
+ # jq 1.5: membership by `any(gen; cond)`, not `index` / `IN`.
2189
+ out=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg self "${GITHUB_RUN_ID-}" \
2190
+ --arg bounded "$bounded" --arg lineage "$LINEAGE_COMMITS" '
2191
+ ($lineage | split("\n")) as $l
2192
+ | [.[] | select(.displayTitle == $t and .status == "completed" and (.databaseId | tostring) != $self)] as $done
2193
+ | [$done[] | select($bounded != "1" or ((.headSha // "") as $h | any($l[]; . == $h)))] as $kept
2194
+ | ((($done | length) - ($kept | length)) | tostring),
2195
+ ($kept | sort_by([.createdAt, .databaseId]) | reverse | .[].databaseId | tostring)' 2>/dev/null) || {
1562
2196
  GH_ERR="its run list is not the expected JSON"
1563
2197
  gh_fail "listing the runs of '$branch' failed"
1564
2198
  }
2199
+ LINEAGE_SKIPPED=$(printf '%s\n' "$out" | head -n 1)
2200
+ ids=$(printf '%s\n' "$out" | tail -n +2)
1565
2201
  for id in $ids; do
1566
2202
  bundle_state "$id"
1567
2203
  if [ "$BUNDLE_STATE" != none ]; then
@@ -1592,13 +2228,18 @@ verb_restore() {
1592
2228
  previous_bundle_run
1593
2229
  id="$PREV_RUN_ID"
1594
2230
  hr_remote_names_var
2231
+ if [ -n "$LINEAGE_WHY" ]; then
2232
+ echo "remote-run.sh: the lineage of $branch is not bounded ($LINEAGE_WHY); every finished run of it is a candidate"
2233
+ elif [ "${LINEAGE_SKIPPED:-0}" -gt 0 ]; then
2234
+ echo "remote-run.sh: skipped $LINEAGE_SKIPPED finished run(s) of $branch from before its current lineage"
2235
+ fi
1595
2236
  if [ "$PREV_RUN_STATE" = expired ]; then
1596
2237
  [ "$resume" != answer ] \
1597
2238
  || restore_refuse "the state bundle of run $id expired on $BUNDLE_EXPIRES_AT, so its questions can no longer be answered here: resume from the committed ledger with $RESUME_HINT $branch, or re-drop the task; nothing written"
1598
2239
  echo "::warning::remote-run.sh: the state bundle of run $id expired on $BUNDLE_EXPIRES_AT: the park-loop, auto-resume and stall counts, the clarification history and any planning drafts not yet committed that it carried are lost; this job continues from the committed ledger"
1599
2240
  elif [ -z "$id" ]; then
1600
2241
  [ "$resume" != answer ] \
1601
- || restore_refuse "--resume answer, but no finished run of $branch carries a state bundle; nothing written"
2242
+ || restore_refuse "--resume answer, but no finished run of $branch's current lineage carries a state bundle; nothing written"
1602
2243
  echo "remote-run.sh: no previous bundle for $branch; this is its first job"
1603
2244
  else
1604
2245
  download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$id") \
@@ -1726,7 +2367,11 @@ verb_save() {
1726
2367
  return 0
1727
2368
  }
1728
2369
 
1729
- # notify <event> <branch> <detail> — one lifecycle notification; never fails.
2370
+ # notify <event> <branch> <detail> <forge_note> — one lifecycle notification,
2371
+ # then `forge_report` of the same event; never fails. Every call site supplies
2372
+ # both texts: <detail> is the push notification's, slash commands included;
2373
+ # <forge_note> is the comment's, naming no slash command and no shell command,
2374
+ # and states only what happened, since `forge_report` adds the next action.
1730
2375
  notify() {
1731
2376
  if [ -n "${HARNESS_REMOTE_SLUG-}" ]; then
1732
2377
  HARNESS_REPO_SLUG="$HARNESS_REMOTE_SLUG"
@@ -1735,6 +2380,8 @@ notify() {
1735
2380
  bash "$script_dir/autonomous-notify.sh" "$1" "$2" "" "$3" \
1736
2381
  || echo "remote-run.sh: the $1 notification for $2 could not be sent" >&2
1737
2382
  echo "remote-run.sh: notified $1 for $2: $3"
2383
+ # stdin closed: `poll` calls this inside a loop reading its run list.
2384
+ forge_report "$1" "$2" "$4" </dev/null
1738
2385
  }
1739
2386
 
1740
2387
  this_run_url() {
@@ -1833,46 +2480,49 @@ valid_engine() {
1833
2480
 
1834
2481
  STOPPED_LINE="a 'harness stop' run is newer than its newest 'harness run' run"
1835
2482
  RESUME_HINT="/autonomous-sdlc-harness:branch-resume"
2483
+ # A `paused` report on a usage pause says the run resumes by itself; a note
2484
+ # saying the automatic resume failed carries the action instead.
2485
+ USAGE_RESUME_NOTE="Comment \`$COMMAND_HANDLE resume\` after the limit resets to continue."
1836
2486
 
1837
2487
  continue_redispatch() {
1838
2488
  local status_file="$1" engine_value
1839
2489
  if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
1840
- notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")."
2490
+ notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: the repository variable \`HARNESS_REMOTE_STOP\` is set; clear it, then resume."
1841
2491
  return 0
1842
2492
  fi
1843
2493
  remote_branch_stopped "$branch"
1844
2494
  case $? in
1845
2495
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); not re-dispatched"; return 0 ;;
1846
- 2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")."; return 0 ;;
2496
+ 2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: whether the run was stopped could not be checked ($GH_ERR)."; return 0 ;;
1847
2497
  esac
1848
2498
  if ! max_chain_var; then
1849
- notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer."
2499
+ notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer." "Not continued: the repository variable \`HARNESS_MAX_CHAIN\` ('$MAX_CHAIN') is not a non-negative integer."
1850
2500
  return 0
1851
2501
  fi
1852
2502
  next_chain_var "$status_file"
1853
2503
  case $? in
1854
- 1) notify failed "$branch" "Not re-dispatched: chain unreadable in status.json."; return 0 ;;
1855
- 2) notify failed "$branch" "Not re-dispatched: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)."; return 0 ;;
2504
+ 1) notify failed "$branch" "Not re-dispatched: chain unreadable in status.json." "Not continued: the chain count in the run's status could not be read."; return 0 ;;
2505
+ 2) notify failed "$branch" "Not re-dispatched: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)." "Not continued: the chain limit was reached ($NEXT_CHAIN over \`HARNESS_MAX_CHAIN\` $MAX_CHAIN)."; return 0 ;;
1856
2506
  esac
1857
2507
  engine_value=$(hr_remote_status_get "$status_file" engine) || engine_value=""
1858
2508
  if ! valid_engine "$engine_value"; then
1859
- notify failed "$branch" "Not re-dispatched: engine '$engine_value' in status.json is not task, user_review or docs."
2509
+ notify failed "$branch" "Not re-dispatched: engine '$engine_value' in status.json is not task, user_review or docs." "Not continued: the engine '$engine_value' in the run's status is not task, user_review or docs."
1860
2510
  return 0
1861
2511
  fi
1862
2512
  redispatch "$engine_value" "$NEXT_CHAIN" \
1863
- || notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "$engine_value")."
2513
+ || notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "$engine_value")." "Not continued: dispatching the next job failed ($REDISPATCH_ERR)."
1864
2514
  }
1865
2515
 
1866
2516
  continue_wait_poller() {
1867
2517
  remote_branch_stopped "$branch"
1868
2518
  case $? in
1869
2519
  0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); the resume poller is not enabled"; return 0 ;;
1870
- 2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")."; return 0 ;;
2520
+ 2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit was not scheduled: whether the run was stopped could not be checked ($GH_ERR). $USAGE_RESUME_NOTE"; return 0 ;;
1871
2521
  esac
1872
2522
  if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
1873
2523
  echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
1874
2524
  else
1875
- notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset; $(hr_github_resume_route "$branch" "")."
2525
+ notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit could not be scheduled ($GH_ERR). $USAGE_RESUME_NOTE"
1876
2526
  fi
1877
2527
  }
1878
2528
 
@@ -1881,7 +2531,7 @@ verb_continue() {
1881
2531
  hr_remote_names_var
1882
2532
  status_file="$bundle_dir/$HR_REMOTE_STATUS_FILE"
1883
2533
  if [ ! -f "$status_file" ]; then
1884
- notify failed "$branch" "The job stopped before the harness run started: $(this_run_url)"
2534
+ notify failed "$branch" "The job stopped before the harness run started: $(this_run_url)" "The job stopped before the run started: $(this_run_url)."
1885
2535
  return 0
1886
2536
  fi
1887
2537
  decision=$(hr_remote_status_get "$status_file" decision) || decision=""
@@ -1889,7 +2539,7 @@ verb_continue() {
1889
2539
  continue) continue_redispatch "$status_file" ;;
1890
2540
  wait-poller) continue_wait_poller ;;
1891
2541
  stop) echo "remote-run.sh: decision stop for $branch; nothing to do" ;;
1892
- *) notify failed "$branch" "Not re-dispatched: status.json carries no recognised decision: $(this_run_url)" ;;
2542
+ *) notify failed "$branch" "Not re-dispatched: status.json carries no recognised decision: $(this_run_url)" "Not continued: the run's status carries no recognised decision." ;;
1893
2543
  esac
1894
2544
  return 0
1895
2545
  }
@@ -2070,12 +2720,12 @@ poll_branch() {
2070
2720
  fi
2071
2721
  next_chain_var "$POLL_STATUS_FILE"
2072
2722
  case $? in
2073
- 1) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain unreadable in status.json."; return 1 ;;
2074
- 2) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)."; return 1 ;;
2723
+ 1) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain unreadable in status.json." "Not resumed after the usage limit: the chain count in the run's status could not be read."; return 1 ;;
2724
+ 2) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)." "Not resumed after the usage limit: the chain limit was reached ($NEXT_CHAIN over \`HARNESS_MAX_CHAIN\` $MAX_CHAIN)."; return 1 ;;
2075
2725
  esac
2076
2726
  engine_value=$(hr_remote_status_get "$POLL_STATUS_FILE" engine) || engine_value=""
2077
2727
  if ! valid_engine "$engine_value"; then
2078
- [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: engine '$engine_value' in status.json is not task, user_review or docs."
2728
+ [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: engine '$engine_value' in status.json is not task, user_review or docs." "Not resumed after the usage limit: the engine '$engine_value' in the run's status is not task, user_review or docs."
2079
2729
  return 1
2080
2730
  fi
2081
2731
  if [ "$may_dispatch" -eq 0 ]; then
@@ -2096,7 +2746,7 @@ poll_branch() {
2096
2746
  if [ "$failures" -ge "$((10#$POLL_MAX_FAILURES))" ] \
2097
2747
  || [ "$now" -gt "$((10#$at + 10#$POLL_GIVE_UP_MINUTES * 60))" ]; then
2098
2748
  poll_state_put "$branch" "$id" "$failures" 1
2099
- notify paused "$branch" "The resume poller could not re-dispatch $branch ($REDISPATCH_ERR) after $failures attempts; automatic resume has stopped. Run $RESUME_HINT $branch; $(hr_github_resume_route "$branch" "$engine_value")."
2749
+ notify paused "$branch" "The resume poller could not re-dispatch $branch ($REDISPATCH_ERR) after $failures attempts; automatic resume has stopped. Run $RESUME_HINT $branch; $(hr_github_resume_route "$branch" "$engine_value")." "Not resumed after the usage limit: dispatching the next job failed $failures times ($REDISPATCH_ERR), and the automatic resume has stopped."
2100
2750
  return 1
2101
2751
  fi
2102
2752
  poll_state_put "$branch" "$id" "$failures" ""
@@ -2139,7 +2789,7 @@ EOF
2139
2789
  # poll_recheck — after the disable: one fresh listing, evaluated without
2140
2790
  # dispatching; re-enables the poller when a branch became waiting meanwhile.
2141
2791
  poll_recheck() {
2142
- local b
2792
+ local b enable_err
2143
2793
  ALL_RUNS_LISTED=0
2144
2794
  if ! list_all_runs || ! poll_pass 0; then
2145
2795
  echo "remote-run.sh: poll: the re-check after disabling $WORKFLOW_RESUME_FILE could not list the runs ($GH_ERR); it stays disabled"
@@ -2152,8 +2802,10 @@ poll_recheck() {
2152
2802
  done
2153
2803
  return 0
2154
2804
  fi
2805
+ # Captured once: each notify's report overwrites GH_ERR.
2806
+ enable_err="$GH_ERR"
2155
2807
  for b in $POLL_WAITING; do
2156
- notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $b after the usage reset; $(hr_github_resume_route "$b" "")."
2808
+ notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($enable_err). Run $RESUME_HINT $b after the usage reset; $(hr_github_resume_route "$b" "")." "The automatic resume after the usage limit could not be scheduled ($enable_err). $USAGE_RESUME_NOTE"
2157
2809
  done
2158
2810
  }
2159
2811
 
@@ -2352,7 +3004,7 @@ remote_record_exists() {
2352
3004
  }
2353
3005
 
2354
3006
  verb_review() {
2355
- local protected=0 status=0 reg="" record_wt="" use_mirror=0 state_rel names name round max=0 next rel
3007
+ local protected=0 status=0 reg="" record_wt="" use_mirror=0 state_rel names name round max=0 next rel note pr pushed
2356
3008
  hr_branch_is_protected "$root" "$branch" || protected=$?
2357
3009
  case "$protected" in
2358
3010
  0)
@@ -2367,25 +3019,24 @@ verb_review() {
2367
3019
  exit "$EXIT_REFUSED"
2368
3020
  fi
2369
3021
 
2370
- # A run in flight takes no review: the local rule, `completed` or `failed` only.
2371
- list_runs
2372
- remote_state ""
2373
- case "$RS_STATE" in
2374
- completed|failed) ;;
2375
- none)
2376
- echo "remote-run.sh: refused, nothing written: no \`harness run $branch\` run on GitHub" >&2
2377
- exit "$EXIT_REFUSED" ;;
2378
- paused)
2379
- if [ "$RS_PAUSE_REASON" = expired ]; then
2380
- echo "remote-run.sh: refused, nothing written: $RS_DETAIL" >&2
2381
- else
2382
- echo "remote-run.sh: refused, nothing written: $branch is paused${RS_PAUSE_REASON:+ ($RS_PAUSE_REASON)} on GitHub; a review waits until its run is completed or failed" >&2
2383
- fi
2384
- exit "$EXIT_REFUSED" ;;
2385
- *)
2386
- echo "remote-run.sh: refused, nothing written: $branch is $RS_STATE on GitHub; a review waits until its run is completed or failed" >&2
2387
- exit "$EXIT_REFUSED" ;;
2388
- esac
3022
+ # An unsettled branch takes no review here: a local round's file exists only
3023
+ # on the caller's machine, so no later collection could pick it up.
3024
+ branch_settled_var "" "$allow_no_run"
3025
+ if [ "$SETTLED" -ne 1 ]; then
3026
+ case "$RS_STATE" in
3027
+ none)
3028
+ echo "remote-run.sh: refused, nothing written: no \`harness run $branch\` run on GitHub" >&2 ;;
3029
+ paused)
3030
+ if [ "$RS_PAUSE_REASON" = expired ]; then
3031
+ echo "remote-run.sh: refused, nothing written: $RS_DETAIL" >&2
3032
+ else
3033
+ echo "remote-run.sh: refused, nothing written: $branch is paused${RS_PAUSE_REASON:+ ($RS_PAUSE_REASON)} on GitHub; a review waits until its run is completed or failed" >&2
3034
+ fi ;;
3035
+ *)
3036
+ echo "remote-run.sh: refused, nothing written: $branch is $RS_STATE on GitHub; a review waits until its run is completed or failed" >&2 ;;
3037
+ esac
3038
+ exit "$EXIT_REFUSED"
3039
+ fi
2389
3040
 
2390
3041
  # The copy: the remote record's mirror when it is on the branch, never
2391
3042
  # removed; else a copy this verb cuts and removes on every exit.
@@ -2446,6 +3097,7 @@ NAMES
2446
3097
  [ "$status" -eq 0 ] || review_fail "committing '$rel'"
2447
3098
  hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
2448
3099
  || review_fail "pushing $branch (origin/$branch is not HEAD)"
3100
+ pushed=$(git -C "$worktree" rev-parse HEAD 2>/dev/null) || pushed=""
2449
3101
  if [ "$use_mirror" -eq 0 ]; then
2450
3102
  start_remove_copy
2451
3103
  trap - EXIT
@@ -2458,10 +3110,41 @@ NAMES
2458
3110
  dispatch_fail_note="; the review is already pushed to origin/$branch, so re-send with: remote-run.sh dispatch $branch --engine user_review"
2459
3111
  verb_dispatch
2460
3112
 
3113
+ # Held until GitHub lists the dispatched run: the next job serialized behind
3114
+ # this one would otherwise read the branch as settled and place a second
3115
+ # round, whose dispatch cancels this one's pending run in the run workflow's
3116
+ # concurrency group.
3117
+ if run_by_sha_var "$pushed"; then
3118
+ echo "remote-run.sh: the dispatched run of $branch is listed: $RUN_BY_SHA_URL"
3119
+ else
3120
+ echo "::warning::remote-run.sh: no \`harness run $branch\` run of ${pushed:-the pushed commit} was listed after $TRIGGER_RUN_LOOKUP_TRIES lookups; the round is dispatched, but a review job reading $branch now may read it as settled"
3121
+ fi
3122
+
2461
3123
  if remote_record_exists "$reg"; then
2462
3124
  hr_registry_set "$reg" "$branch" status running engine user_review \
2463
3125
  || echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
2464
3126
  fi
3127
+
3128
+ if [ -n "$reviewers_arg" ]; then
3129
+ pr=""
3130
+ if [[ "$source_arg" =~ /pull/([0-9]+)([/?#]|$) ]]; then
3131
+ pr="${BASH_REMATCH[1]}"
3132
+ elif forge_on && forge_repo_var && forge_pr_var "$branch"; then
3133
+ pr="$FORGE_PR"
3134
+ fi
3135
+ note="Round $round from pull request${pr:+ #$pr}"
3136
+ [ -z "$source_arg" ] || note="$note ($source_arg)"
3137
+ note="$note by @${reviewers_arg//,/, @}"
3138
+ else
3139
+ note="Round $round"
3140
+ [ -z "$source_arg" ] || note="$note from $source_arg"
3141
+ if [ -n "$actor_arg" ]; then
3142
+ note="$note by @$actor_arg"
3143
+ else
3144
+ note="$note from a local session"
3145
+ fi
3146
+ fi
3147
+ forge_report round "$branch" "$note"
2465
3148
  }
2466
3149
 
2467
3150
  # unrecorded_runs — UNRECORDED: one `<branch>\t<url>` line per branch whose
@@ -2565,13 +3248,16 @@ event_field() {
2565
3248
  EVENT_VALUE=${out%x}
2566
3249
  }
2567
3250
 
2568
- # trigger_finish <exit> <comment> — post <comment> on the issue, remove the
2569
- # trigger label, and exit <exit>. A comment that cannot be posted makes the exit
2570
- # 3; a label that cannot be removed is a warning only. For a dispatch event,
2571
- # print <comment> and append it to GITHUB_STEP_SUMMARY when set; a summary that
2572
- # cannot be appended is a warning only, since stdout already carries it.
3251
+ # trigger_finish <exit> <comment> <event> — post <comment> on the issue with the
3252
+ # marker for <event> (`started` or `refused`; `branch=` is empty before a branch
3253
+ # is derived), remove the trigger label, on `started` set the state label
3254
+ # `running`, and exit <exit>. A comment that cannot be posted makes the exit 3;
3255
+ # a label that cannot be removed or set is a warning only. For a dispatch event,
3256
+ # print <comment> with no marker and append it to GITHUB_STEP_SUMMARY when set;
3257
+ # a summary that cannot be appended is a warning only, since stdout already
3258
+ # carries it.
2573
3259
  trigger_finish() {
2574
- local code="$1" body="$2" file
3260
+ local code="$1" body="$2" event="$3" file
2575
3261
  if [ "$trigger_source" = dispatch ]; then
2576
3262
  printf '%s\n' "$body"
2577
3263
  if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
@@ -2589,7 +3275,7 @@ _Posted by the trigger job ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REP
2589
3275
  echo "::error::remote-run.sh: trigger: cannot create the comment file for issue #$issue_number under '$trigger_tmp'"
2590
3276
  exit "$EXIT_GH"
2591
3277
  fi
2592
- printf '%s\n' "$body" >"$file"
3278
+ { printf '%s\n\n' "$body"; forge_marker "$event" "$branch"; } >"$file"
2593
3279
  if ! gh_call issue comment "$issue_number" --repo "${GITHUB_REPOSITORY-}" --body-file "$file"; then
2594
3280
  echo "::error::remote-run.sh: trigger: the comment on issue #$issue_number could not be posted: $GH_ERR"
2595
3281
  code="$EXIT_GH"
@@ -2598,6 +3284,11 @@ _Posted by the trigger job ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REP
2598
3284
  if ! gh_call issue edit "$issue_number" --repo "${GITHUB_REPOSITORY-}" --remove-label "$trigger_label"; then
2599
3285
  echo "::warning::remote-run.sh: trigger: removing the label '$trigger_label' from issue #$issue_number failed: $GH_ERR"
2600
3286
  fi
3287
+ if [ "$event" = started ]; then
3288
+ if ! { forge_repo_var && forge_set_state "$issue_number" running; }; then
3289
+ echo "::warning::remote-run.sh: trigger: setting the label '${STATE_LABEL_PREFIX}running' on issue #$issue_number failed: $GH_ERR"
3290
+ fi
3291
+ fi
2601
3292
  exit "$code"
2602
3293
  }
2603
3294
 
@@ -2606,7 +3297,7 @@ trigger_refuse() {
2606
3297
  echo "remote-run.sh: trigger: refused, nothing sent: $1" >&2
2607
3298
  trigger_finish "$EXIT_REFUSED" "No run started: $1
2608
3299
 
2609
- $2"
3300
+ $2" refused
2610
3301
  }
2611
3302
 
2612
3303
  # trigger_bot_listed <login> — 0 when <login> is an exact entry of
@@ -2623,27 +3314,78 @@ trigger_bot_listed() {
2623
3314
  return 1
2624
3315
  }
2625
3316
 
2626
- # trigger_run_url — the URL of the `harness run <branch>` run `start` just
2627
- # dispatched, looked up at most TRIGGER_RUN_LOOKUP_TRIES times; the branch's
2628
- # filtered run list when none appears. Never fails.
2629
- trigger_run_url() {
2630
- local try=1 secs url=""
3317
+ # authorise_actor <login> <type> — the one actor check, shared by `trigger` and
3318
+ # `control`: 0 when authorised. Otherwise AUTH_WHY holds one sentence and the
3319
+ # status names the arm: 1 `ghost`, empty or not a login shape; 2 a non-`User`
3320
+ # not listed in HARNESS_TRIGGER_ALLOWED_BOTS, decided with no permission call,
3321
+ # because the permission API answers `none` or 404 for a bot; 3 a `User` whose
3322
+ # permission is not `admin` or `write` (AUTH_PERMISSION holds it); 4 that
3323
+ # permission call failed (GH_ERR holds why). Prints nothing and never posts.
3324
+ AUTH_WHY=""
3325
+ AUTH_PERMISSION=""
3326
+ authorise_actor() {
3327
+ local login="$1" type="$2"
3328
+ AUTH_WHY=""
3329
+ AUTH_PERMISSION=""
3330
+ if [ "$login" = ghost ] || [ -z "$login" ] \
3331
+ || ! { [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] \
3332
+ || { [ "$type" = Bot ] && [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$ ]]; }; }; then
3333
+ AUTH_WHY="the actor is an account GitHub does not name, or not a login."
3334
+ return 1
3335
+ fi
3336
+ if [ "$type" != User ]; then
3337
+ trigger_bot_listed "$login" && return 0
3338
+ AUTH_WHY="@$login is not a person, and is not listed in HARNESS_TRIGGER_ALLOWED_BOTS."
3339
+ return 2
3340
+ fi
3341
+ if ! gh_call api "repos/${GITHUB_REPOSITORY-}/collaborators/$login/permission"; then
3342
+ AUTH_WHY="the permission check for @$login failed ($GH_ERR)."
3343
+ return 4
3344
+ fi
3345
+ AUTH_PERMISSION=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || AUTH_PERMISSION=""
3346
+ case "$AUTH_PERMISSION" in
3347
+ admin|write) return 0 ;;
3348
+ esac
3349
+ AUTH_WHY="GitHub reports the permission of @$login as ${AUTH_PERMISSION:-nothing}, not write or admin."
3350
+ return 3
3351
+ }
3352
+
3353
+ # run_by_sha_var <sha> — RUN_BY_SHA_URL: the URL of the `harness run
3354
+ # <branch>` run whose `headSha` is <sha>, a commit just pushed and dispatched,
3355
+ # looked up at most TRIGGER_RUN_LOOKUP_TRIES times, `HARNESS_TRIGGER_LOOKUP_SECS`
3356
+ # apart; 1 when none was listed within that bound, and at once when <sha> is
3357
+ # empty. Never exits. Matched on `headSha` rather than a `createdAt` bound: the
3358
+ # SHA identifies this dispatch exactly and reads no runner clock, where a time
3359
+ # bound still admits an unrelated run created in the same second.
3360
+ RUN_BY_SHA_URL=""
3361
+ run_by_sha_var() {
3362
+ local sha="${1-}" try=1 secs url=""
3363
+ RUN_BY_SHA_URL=""
2631
3364
  secs="${HARNESS_TRIGGER_LOOKUP_SECS-}"
2632
3365
  case "$secs" in
2633
3366
  ''|*[!0-9]*) secs="$TRIGGER_LOOKUP_SECS_DEFAULT" ;;
2634
3367
  esac
2635
- while :; do
2636
- if gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json url,displayTitle --limit 5; then
2637
- url=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" \
2638
- '[.[]? | select(.displayTitle == $t) | .url | strings] | first // empty' 2>/dev/null) || url=""
3368
+ while [ -n "$sha" ]; do
3369
+ if gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json url,displayTitle,headSha --limit 5; then
3370
+ url=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg s "$sha" \
3371
+ '[.[]? | select(.displayTitle == $t and .headSha == $s) | .url | strings] | first // empty' 2>/dev/null) || url=""
2639
3372
  [ -z "$url" ] || break
2640
3373
  else
2641
- echo "remote-run.sh: trigger: looking up the run of $branch failed: $GH_ERR" >&2
3374
+ echo "remote-run.sh: $verb: looking up the run of $branch failed: $GH_ERR" >&2
2642
3375
  fi
2643
3376
  [ "$try" -lt "$TRIGGER_RUN_LOOKUP_TRIES" ] || break
2644
3377
  try=$((try + 1))
2645
3378
  sleep "$secs"
2646
3379
  done
3380
+ RUN_BY_SHA_URL="$url"
3381
+ [ -n "$url" ]
3382
+ }
3383
+
3384
+ # trigger_run_url <sha> — `run_by_sha_var`'s URL for the commit `start` just
3385
+ # pushed; the branch's filtered run list when none appears. Never fails.
3386
+ trigger_run_url() {
3387
+ local url=""
3388
+ ! run_by_sha_var "${1-}" || url="$RUN_BY_SHA_URL"
2647
3389
  # A derived name is `[a-z0-9_]` only, so it needs no encoding in the query.
2648
3390
  [ -n "$url" ] || url="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/workflows/${WORKFLOW_RUN_FILE}?query=branch%3A$branch"
2649
3391
  printf '%s\n' "$url"
@@ -2652,7 +3394,7 @@ trigger_run_url() {
2652
3394
  verb_trigger() {
2653
3395
  local LC_ALL=C
2654
3396
  local action label title body html_url state login sender_type source=""
2655
- local forge="" target="" default name_file status permission prompt errfile last url
3397
+ local forge="" target="" default name_file status prompt errfile last url sha
2656
3398
  local fallback retry_then retry_again task_what
2657
3399
  case "${GITHUB_EVENT_NAME-}" in
2658
3400
  issues) trigger_source=issue ;;
@@ -2681,6 +3423,9 @@ verb_trigger() {
2681
3423
  exit "$EXIT_USAGE"
2682
3424
  }
2683
3425
  trigger_label="${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}"
3426
+ if [ -z "${HARNESS_TRIGGER_LABEL-}" ] && [ "$label" = "$LEGACY_TRIGGER_LABEL" ]; then
3427
+ trigger_label="$LEGACY_TRIGGER_LABEL"
3428
+ fi
2684
3429
  if [ "$action" != labeled ] || [ "$label" != "$trigger_label" ]; then
2685
3430
  echo "remote-run.sh: trigger: ignored, not the label '$trigger_label' being applied"
2686
3431
  return 0
@@ -2740,31 +3485,19 @@ verb_trigger() {
2740
3485
  trigger_refuse "this issue is not open." "Reopen it, then re-apply the label \`$trigger_label\`."
2741
3486
  fi
2742
3487
 
2743
- if [ "$login" = ghost ] || [ -z "$login" ] \
2744
- || ! { [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] \
2745
- || { [ "$sender_type" = Bot ] && [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$ ]]; }; }; then
2746
- trigger_refuse "the label was applied by an account GitHub does not name (a deleted account shows as \`ghost\`)." \
2747
- "A collaborator with write access can re-apply the label \`$trigger_label\`."
2748
- fi
2749
-
2750
- if [ "$sender_type" != User ]; then
2751
- trigger_bot_listed "$login" || trigger_refuse \
2752
- "@$login is not a person, and is not listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`." \
2753
- "Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`."
2754
- else
2755
- permission=""
2756
- if gh_call api "repos/${GITHUB_REPOSITORY-}/collaborators/$login/permission"; then
2757
- permission=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || permission=""
2758
- case "$permission" in
2759
- admin|write) ;;
2760
- *) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${permission:-nothing}\`." \
2761
- "Only a collaborator with write, maintain or admin access starts a run by labelling an issue; one of them can re-apply the label \`$trigger_label\`." ;;
2762
- esac
2763
- else
2764
- trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
2765
- "Re-apply the label \`$trigger_label\` to try again."
2766
- fi
2767
- fi
3488
+ status=0
3489
+ authorise_actor "$login" "$sender_type" || status=$?
3490
+ case "$status" in
3491
+ 0) ;;
3492
+ 1) trigger_refuse "the label was applied by an account GitHub does not name (a deleted account shows as \`ghost\`)." \
3493
+ "A collaborator with write access can re-apply the label \`$trigger_label\`." ;;
3494
+ 2) trigger_refuse "@$login is not a person, and is not listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`." \
3495
+ "Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`." ;;
3496
+ 3) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${AUTH_PERMISSION:-nothing}\`." \
3497
+ "Only a collaborator with write, maintain or admin access starts a run by labelling an issue; one of them can re-apply the label \`$trigger_label\`." ;;
3498
+ *) trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
3499
+ "Re-apply the label \`$trigger_label\` to try again." ;;
3500
+ esac
2768
3501
  fi
2769
3502
 
2770
3503
  # The name check reads origin/<defaultBranch>; a failed fetch leaves it to say so.
@@ -2775,7 +3508,7 @@ verb_trigger() {
2775
3508
  name_file=$(mktemp "$trigger_tmp/harness-trigger-branch.XXXXXX") || trigger_refuse \
2776
3509
  "the branch name could not be derived (mktemp failed)." "$retry_again"
2777
3510
  status=0
2778
- hr_derive_branch "$root" "$title" "$fallback" >"$name_file" || status=$?
3511
+ hr_derive_branch "$root" "$title" "$fallback" "" "$GH" >"$name_file" || status=$?
2779
3512
  branch=""
2780
3513
  IFS= read -r branch <"$name_file" || :
2781
3514
  rm -f "$name_file"
@@ -2792,7 +3525,7 @@ verb_trigger() {
2792
3525
  esac
2793
3526
 
2794
3527
  prompt=$(mktemp "$trigger_tmp/harness-trigger-prompt.XXXXXX") || trigger_finish "$EXIT_PLACEMENT" \
2795
- "No run started: the task prompt for \`$branch\` could not be written. $retry_again"
3528
+ "No run started: the task prompt for \`$branch\` could not be written. $retry_again" refused
2796
3529
  if [ "$trigger_source" = issue ]; then
2797
3530
  printf '# %s\n\n%s\n\n---\n\nStarted from %s by @%s, who applied the label `%s` at %s. This is the issue'"'"'s text at that moment; later edits to the issue do not reach this run.\n' \
2798
3531
  "$title" "$body" "$html_url" "$login" "$trigger_label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
@@ -2821,7 +3554,7 @@ verb_trigger() {
2821
3554
  $last
2822
3555
  \`\`\`
2823
3556
 
2824
- Start it by hand: **Actions → \`$WORKFLOW_RUN_FILE\` → Run workflow**, with *Use workflow from* set to \`$branch\`, \`action\` \`run\` and \`branch\` \`$branch\`." ;;
3557
+ Start it by hand: **Actions → \`$WORKFLOW_RUN_FILE\` → Run workflow**, with *Use workflow from* set to \`$branch\`, \`action\` \`run\` and \`branch\` \`$branch\`." refused ;;
2825
3558
  *)
2826
3559
  echo "remote-run.sh: trigger: the start of $branch failed (exit $status)" >&2
2827
3560
  trigger_finish "$EXIT_PLACEMENT" "No run started: placing $task_what on the branch \`$branch\` failed:
@@ -2830,20 +3563,1749 @@ Start it by hand: **Actions → \`$WORKFLOW_RUN_FILE\` → Run workflow**, with
2830
3563
  $last
2831
3564
  \`\`\`
2832
3565
 
2833
- $retry_again" ;;
3566
+ $retry_again" refused ;;
2834
3567
  esac
2835
3568
 
2836
- url=$(trigger_run_url)
3569
+ # The ref `start`'s `hr_push_landed` confirmed equal to the pushed `HEAD`;
3570
+ # `start_remove_copy` deletes only the local branch.
3571
+ sha=$(git -C "$root" rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}") || sha=""
3572
+ url=$(trigger_run_url "$sha")
2837
3573
  if [ "$trigger_source" = issue ]; then
2838
3574
  echo "remote-run.sh: trigger: started $branch from issue #$issue_number: $url"
2839
3575
  trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
2840
3576
 
2841
- The task is this issue's title and body as they were when the label \`$trigger_label\` was applied; later edits to the issue do not reach this run. Re-applying the label starts another run, on the next indexed branch."
3577
+ The task is this issue's title and body as they were when the label \`$trigger_label\` was applied; later edits to the issue do not reach this run. Re-applying the label starts another run, on the next indexed branch." started
2842
3578
  fi
2843
3579
  echo "remote-run.sh: trigger: started $branch from a repository_dispatch: $url"
2844
3580
  trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
2845
3581
 
2846
- The task is the dispatch's \`client_payload\` title and body. Sending the same dispatch again starts another run, on the next indexed branch."
3582
+ The task is the dispatch's \`client_payload\` title and body. Sending the same dispatch again starts another run, on the next indexed branch." started
3583
+ }
3584
+
3585
+ # ---------------------------------------------------------------------------
3586
+ # THE FORGE SURFACE — the one place this script reads or writes an issue or a
3587
+ # pull request for a run. Every function sets globals rather than printing,
3588
+ # never exits, and reports a failure as one `remote-run.sh: …` line on stderr.
3589
+ #
3590
+ # THE TARGET RULE. A comment goes to the open same-repository pull request
3591
+ # whose head is the branch when `forge_recognised` holds for that branch, else
3592
+ # to the issue the run was started from (`FORGE_ISSUE`), else nowhere. The
3593
+ # state label goes on that issue and on that pull request, each when known.
3594
+ #
3595
+ # THE LABEL IS A VIEW, NEVER AN AUTHORITY. The run list is the authority
3596
+ # (`remote_state`); the harness overwrites any state label set by hand, and
3597
+ # nothing reads one back to decide anything.
3598
+ # ---------------------------------------------------------------------------
3599
+
3600
+ # forge_on — 0 when `forge` is `github` and `execution.target` is
3601
+ # `github-actions`; the shell mirror of `forgeTriggerApplies`.
3602
+ forge_on() {
3603
+ [ "$(hr_forge "$root" 2>/dev/null)" = github ] \
3604
+ && [ "$(hr_execution_target "$root" 2>/dev/null)" = github-actions ]
3605
+ }
3606
+
3607
+ # forge_repo_var — FORGE_REPO (owner/name) and FORGE_SERVER, from the runner's
3608
+ # environment when it names the repository, else one `gh repo view`; a success
3609
+ # is kept for the invocation.
3610
+ FORGE_REPO=""
3611
+ FORGE_SERVER=""
3612
+ FORGE_REPO_KNOWN=0
3613
+ forge_repo_var() {
3614
+ local repo
3615
+ [ "$FORGE_REPO_KNOWN" -eq 0 ] || return 0
3616
+ if [ -n "${GITHUB_REPOSITORY-}" ]; then
3617
+ repo="$GITHUB_REPOSITORY"
3618
+ else
3619
+ if ! gh_call repo view --json nameWithOwner; then
3620
+ echo "remote-run.sh: reading the repository's name failed: $GH_ERR" >&2
3621
+ return 1
3622
+ fi
3623
+ repo=$(printf '%s' "$GH_OUT" | jq -r '.nameWithOwner // empty' 2>/dev/null) || repo=""
3624
+ fi
3625
+ # Interpolated into every API path below, so its shape is checked once here.
3626
+ if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
3627
+ GH_ERR="the repository name '$repo' is not owner/name"
3628
+ echo "remote-run.sh: reading the repository's name failed: $GH_ERR" >&2
3629
+ return 1
3630
+ fi
3631
+ FORGE_REPO="$repo"
3632
+ FORGE_SERVER="${GITHUB_SERVER_URL:-https://github.com}"
3633
+ FORGE_SERVER="${FORGE_SERVER%/}"
3634
+ FORGE_REPO_KNOWN=1
3635
+ }
3636
+
3637
+ # forge_fetch_branch <branch> — update refs/remotes/origin/<branch>, at most
3638
+ # once per branch per invocation. The refspec is explicit because a single-branch
3639
+ # checkout's bare `fetch origin <branch>` updates only FETCH_HEAD. A failure is
3640
+ # one line and tolerated.
3641
+ FORGE_FETCHED=" "
3642
+ forge_fetch_branch() {
3643
+ local err
3644
+ case "$FORGE_FETCHED" in *" $1 "*) return 0 ;; esac
3645
+ FORGE_FETCHED="$FORGE_FETCHED$1 "
3646
+ if ! err=$(git -C "$root" fetch --quiet origin "+refs/heads/$1:refs/remotes/origin/$1" 2>&1 >/dev/null); then
3647
+ echo "remote-run.sh: fetching origin $1 failed: ${err%%$'\n'*}" >&2
3648
+ fi
3649
+ return 0
3650
+ }
3651
+
3652
+ # forge_marker <event> <branch> [<question>] — the one producer of a comment's
3653
+ # marker line, built from COMMENT_MARKER.
3654
+ forge_marker() {
3655
+ if [ -n "${3-}" ]; then
3656
+ printf '%s event=%s branch=%s question=%s -->\n' "$COMMENT_MARKER" "$1" "$2" "$3"
3657
+ else
3658
+ printf '%s event=%s branch=%s -->\n' "$COMMENT_MARKER" "$1" "$2"
3659
+ fi
3660
+ }
3661
+
3662
+ # forge_issue_var <branch> — FORGE_ISSUE from the last provenance line
3663
+ # `verb_trigger` writes into the branch's committed task prompt, matched against
3664
+ # this repository's own issue URL only; empty when there is none. Also sets
3665
+ # FORGE_TRIGGER_LABEL, the label that same provenance line names; empty when
3666
+ # it names none.
3667
+ FORGE_ISSUE=""
3668
+ FORGE_TRIGGER_LABEL=""
3669
+ forge_issue_var() {
3670
+ local state_rel rel prompt line rest num prefix label
3671
+ FORGE_ISSUE=""
3672
+ FORGE_TRIGGER_LABEL=""
3673
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3674
+ if [ -z "$state_rel" ]; then
3675
+ echo "remote-run.sh: cannot resolve the state directory under '$root'" >&2
3676
+ return 1
3677
+ fi
3678
+ rel=$(hr_task_prompt_rel "$state_rel" "$1")
3679
+ prompt=$(git -C "$root" show "refs/remotes/origin/$1:$rel" 2>/dev/null) || return 0
3680
+ prefix="Started from $FORGE_SERVER/$FORGE_REPO/issues/"
3681
+ while IFS= read -r line; do
3682
+ case "$line" in
3683
+ "$prefix"*) ;;
3684
+ *) continue ;;
3685
+ esac
3686
+ rest=${line#"$prefix"}
3687
+ num=${rest%%[!0-9]*}
3688
+ [ -n "$num" ] || continue
3689
+ case "${rest#"$num"}" in
3690
+ ' by @'*)
3691
+ FORGE_ISSUE="$num"
3692
+ FORGE_TRIGGER_LABEL=""
3693
+ case "$line" in
3694
+ *'who applied the label `'*'`'*)
3695
+ label=${line#*'who applied the label `'}
3696
+ label=${label%%'`'*}
3697
+ [ -z "$label" ] || FORGE_TRIGGER_LABEL="$label" ;;
3698
+ esac ;;
3699
+ esac
3700
+ done <<<"$prompt"
3701
+ return 0
3702
+ }
3703
+
3704
+ # forge_recognised <branch> — the harness-branch test, read from committed
3705
+ # state: 0 when origin's copy of the branch carries its flow-progress ledger.
3706
+ forge_recognised() {
3707
+ local state_rel
3708
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
3709
+ [ -n "$state_rel" ] || return 1
3710
+ git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null
3711
+ }
3712
+
3713
+ # forge_pr_var <branch> — FORGE_PR, the open pull request whose head is
3714
+ # <branch> in this repository (a fork's same-named head is skipped), or empty.
3715
+ FORGE_PR=""
3716
+ forge_pr_var() {
3717
+ FORGE_PR=""
3718
+ if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository --limit 10; then
3719
+ echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
3720
+ return 1
3721
+ fi
3722
+ if ! FORGE_PR=$(printf '%s' "$GH_OUT" | jq -r \
3723
+ 'if type == "array" then [.[] | select(.isCrossRepository == false) | .number | numbers] | first // empty else error end' 2>/dev/null); then
3724
+ FORGE_PR=""
3725
+ GH_ERR="its pr list is not the expected JSON"
3726
+ echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
3727
+ return 1
3728
+ fi
3729
+ return 0
3730
+ }
3731
+
3732
+ # forge_comment <number> <event> <branch> <body_file> [<question>] — append the
3733
+ # marker to <body_file> and post it on issue or pull request <number>.
3734
+ forge_comment() {
3735
+ local number="$1" event="$2" branch="$3" file="$4" question="${5-}" status
3736
+ if ! { printf '\n'; forge_marker "$event" "$branch" "$question"; } >>"$file"; then
3737
+ echo "remote-run.sh: cannot append the marker to '$file'" >&2
3738
+ return 1
3739
+ fi
3740
+ gh_call api --method POST "repos/$FORGE_REPO/issues/$number/comments" -F "body=@$file"
3741
+ status=$?
3742
+ [ "$status" -eq 0 ] || echo "remote-run.sh: the $event comment on #$number could not be posted: $GH_ERR" >&2
3743
+ return "$status"
3744
+ }
3745
+
3746
+ # forge_set_state <number> <state> — leave `STATE_LABEL_PREFIX<state>` as the
3747
+ # one state label on <number>. A failed add creates the label and retries once,
3748
+ # never more.
3749
+ forge_set_state() {
3750
+ local number="$1" state="$2" label names name encoded color try
3751
+ case " $RUN_STATES " in
3752
+ *" $state "*) ;;
3753
+ *) echo "remote-run.sh: '$state' is not one of: $RUN_STATES" >&2; return 1 ;;
3754
+ esac
3755
+ label="$STATE_LABEL_PREFIX$state"
3756
+ if ! gh_call api "repos/$FORGE_REPO/issues/$number/labels"; then
3757
+ echo "remote-run.sh: reading the labels of #$number failed: $GH_ERR" >&2
3758
+ return 1
3759
+ fi
3760
+ if ! names=$(printf '%s' "$GH_OUT" | jq -r --arg p "$STATE_LABEL_PREFIX" --arg t "$label" \
3761
+ 'if type == "array" then .[] | .name | strings | select(startswith($p) and . != $t) else error end' 2>/dev/null); then
3762
+ GH_ERR="its label list is not the expected JSON"
3763
+ echo "remote-run.sh: reading the labels of #$number failed: $GH_ERR" >&2
3764
+ return 1
3765
+ fi
3766
+ while IFS= read -r name; do
3767
+ [ -n "$name" ] || continue
3768
+ encoded=$(jq -rn --arg s "$name" '$s|@uri')
3769
+ gh_call api --method DELETE "repos/$FORGE_REPO/issues/$number/labels/$encoded" \
3770
+ || echo "remote-run.sh: removing the label '$name' from #$number failed: $GH_ERR" >&2
3771
+ done <<<"$names"
3772
+ for try in 1 2; do
3773
+ gh_call api --method POST "repos/$FORGE_REPO/issues/$number/labels" -f "labels[]=$label" && return 0
3774
+ if [ "$try" -eq 2 ]; then
3775
+ echo "remote-run.sh: adding the label '$label' to #$number failed: $GH_ERR" >&2
3776
+ return 1
3777
+ fi
3778
+ case "$state" in
3779
+ running) color=1d76db ;;
3780
+ parked) color=fbca04 ;;
3781
+ paused) color=c5def5 ;;
3782
+ done) color=0e8a16 ;;
3783
+ failed) color=b60205 ;;
3784
+ *) color=6a737d ;;
3785
+ esac
3786
+ gh_call api --method POST "repos/$FORGE_REPO/labels" -f "name=$label" -f "color=$color" \
3787
+ -f "description=Set by the harness from its run list; a hand-applied state label is overwritten." \
3788
+ || echo "remote-run.sh: creating the label '$label' failed: $GH_ERR" >&2
3789
+ done
3790
+ }
3791
+
3792
+ # forge_utc <epoch> — print <epoch> as a UTC time, or nothing. `date -r` takes an
3793
+ # epoch on BSD and a reference file on GNU, hence the `-d @` fallback.
3794
+ forge_utc() {
3795
+ local out
3796
+ case "${1-}" in ''|*[!0-9]*) return 0 ;; esac
3797
+ out=$(date -u -r "$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null) || out=""
3798
+ [ -n "$out" ] || out=$(date -u -d "@$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null) || out=""
3799
+ printf '%s' "$out"
3800
+ }
3801
+
3802
+ # forge_question_body <out_file> <branch> <n> <open_count> <clar_dir> [<note>] —
3803
+ # write question <n>'s park comment, without its marker, into <out_file>: the
3804
+ # file's bytes, cut at the last whole line within QUESTION_COMMENT_MAX_BYTES
3805
+ # when it is over it (measured in bytes; `${#…}` counts characters).
3806
+ forge_question_body() {
3807
+ local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last
3808
+ size=$(wc -c <"$qfile") || return 1
3809
+ size=$((size))
3810
+ {
3811
+ printf 'The run on `%s` is waiting for an answer to question %s.\n\n' "$br" "$n"
3812
+ if [ "$size" -le "$QUESTION_COMMENT_MAX_BYTES" ]; then
3813
+ cat "$qfile"
3814
+ else
3815
+ cut=1
3816
+ head -c "$QUESTION_COMMENT_MAX_BYTES" "$qfile" >"$out.cut"
3817
+ last=$(tail -c 1 "$out.cut")
3818
+ # A non-empty last byte is a partial line; LC_ALL=C keeps sed from
3819
+ # refusing a multi-byte character the byte cut split.
3820
+ if [ -n "$last" ]; then LC_ALL=C sed '$d' "$out.cut"; else cat "$out.cut"; fi
3821
+ fi
3822
+ } >"$out" || return 1
3823
+ {
3824
+ [ -z "$(tail -c 1 "$out")" ] || printf '\n'
3825
+ [ "$cut" -eq 0 ] || printf '\nThis question was cut to fit a comment. The whole file is `%s/%s/question_%s.md` in the run'"'"'s `%s` artifact.\n' \
3826
+ "$HR_REMOTE_CLARIFY_DIR" "$br" "$n" "$STATE_ARTIFACT_NAME"
3827
+ printf '\nAnswer with a comment whose first line is `%s answer %s` and whose following lines are your answer.' "$COMMAND_HANDLE" "$n"
3828
+ [ "$count" -ne 1 ] || printf ' This is the only open question, so `%s` may be left out: `%s answer`.' "$n" "$COMMAND_HANDLE"
3829
+ printf '\n'
3830
+ [ -z "$note" ] || printf '\n%s\n' "$note"
3831
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
3832
+ } >>"$out"
3833
+ }
3834
+
3835
+ # forge_report <event> <branch> [<note>] — one lifecycle comment (on `parked`,
3836
+ # one per open question) and the state label, by the target rule above.
3837
+ # Always 0.
3838
+ forge_report() {
3839
+ local event="$1" br="$2" note="${3-}" state reason="" resume_at="" when registry_file
3840
+ local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
3841
+ case "$event" in
3842
+ parked|park_loop) state=parked ;;
3843
+ paused) state=paused ;;
3844
+ resumed) state=running ;;
3845
+ failed) state=failed ;;
3846
+ stopped) state=stopped ;;
3847
+ round) state=running ;;
3848
+ completed)
3849
+ echo "remote-run.sh: report: completed is posted by deliver; nothing posted"
3850
+ return 0 ;;
3851
+ launched)
3852
+ echo "remote-run.sh: report: launched is the trigger's own comment; nothing posted"
3853
+ return 0 ;;
3854
+ *)
3855
+ echo "remote-run.sh: report: '$event' is not a reported event; nothing posted"
3856
+ return 0 ;;
3857
+ esac
3858
+ if ! forge_on; then
3859
+ echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
3860
+ return 0
3861
+ fi
3862
+ forge_repo_var || return 0
3863
+
3864
+ if [ "$event" = failed ]; then
3865
+ remote_branch_stopped "$br"
3866
+ stopped=$?
3867
+ if [ "$stopped" -eq 0 ]; then
3868
+ echo "remote-run.sh: report: $br was stopped, and the stop already reported the run; nothing posted"
3869
+ return 0
3870
+ fi
3871
+ [ "$stopped" -eq 1 ] \
3872
+ || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the failure" >&2
3873
+ fi
3874
+
3875
+ forge_fetch_branch "$br"
3876
+ forge_issue_var "$br" || FORGE_ISSUE=""
3877
+ forge_pr_var "$br" || FORGE_PR=""
3878
+ if [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
3879
+ echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
3880
+ FORGE_PR=""
3881
+ fi
3882
+ if [ -n "$FORGE_PR" ]; then
3883
+ target="$FORGE_PR"; kind=pr
3884
+ elif [ -n "$FORGE_ISSUE" ]; then
3885
+ target="$FORGE_ISSUE"; kind=issue
3886
+ else
3887
+ echo "remote-run.sh: report: $br has no open pull request and no issue it was started from; nothing posted"
3888
+ return 0
3889
+ fi
3890
+
3891
+ # Tested with -f first: hr_registry_get creates an absent registry.
3892
+ registry_file=$(hr_state_path "$root" autonomous_logs/registry.json 2>/dev/null) || registry_file=""
3893
+ if [ -n "$registry_file" ] && [ -f "$registry_file" ]; then
3894
+ reason=$(hr_registry_get "$registry_file" "$br" pause_reason)
3895
+ resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
3896
+ fi
3897
+
3898
+ case "$event" in
3899
+ paused)
3900
+ if [ "$reason" = usage ]; then
3901
+ when=$(forge_utc "$resume_at")
3902
+ text="The harness run on \`$br\` paused: it reached its usage limit. It resumes by itself after the limit resets${when:+, at $when}."
3903
+ else
3904
+ text="The harness run on \`$br\` paused${reason:+ (reason: \`$reason\`)}. Comment \`${COMMAND_HANDLE} resume\` to continue."
3905
+ fi ;;
3906
+ park_loop)
3907
+ text="The harness run on \`$br\` is on hold: it parked on its questions again and again without progress. Comment \`${COMMAND_HANDLE} clear\` to clear the hold and let it continue." ;;
3908
+ parked)
3909
+ text="The harness run on \`$br\` is waiting for an answer. Its questions are in the run's \`$STATE_ARTIFACT_NAME\` artifact." ;;
3910
+ resumed)
3911
+ text="The harness run on \`$br\` resumed." ;;
3912
+ failed)
3913
+ if [ "$kind" = pr ]; then
3914
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, submit a review on this pull request requesting changes."
3915
+ else
3916
+ trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
3917
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, re-apply the label \`$trigger_label\` to this issue; that starts a new run, on the next indexed branch."
3918
+ fi ;;
3919
+ stopped)
3920
+ text="The harness run on \`$br\` was stopped. Comment \`${COMMAND_HANDLE} resume\` to continue it from its committed ledger. A review that requests changes is collected now, and its round starts once the resumed run finishes." ;;
3921
+ round)
3922
+ text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
3923
+ esac
3924
+
3925
+ OPEN_QUESTIONS=""
3926
+ if [ "$event" = parked ]; then
3927
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
3928
+ [ -z "$state_rel" ] || open_questions_in "$root/${state_rel%/}"
3929
+ fi
3930
+
3931
+ tmp="${RUNNER_TEMP-}"
3932
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
3933
+ tmp=$(mktemp -d) || tmp=""
3934
+ made_tmp="$tmp"
3935
+ fi
3936
+ if [ -n "$OPEN_QUESTIONS" ]; then
3937
+ count=$(printf '%s\n' $OPEN_QUESTIONS | wc -l)
3938
+ count=$((count))
3939
+ for n in $OPEN_QUESTIONS; do
3940
+ if [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
3941
+ if forge_question_body "$file" "$br" "$n" "$count" "$root/${state_rel%/}/$HR_REMOTE_CLARIFY_DIR/$br" "$note"; then
3942
+ forge_comment "$target" "$event" "$br" "$file" "$n" || :
3943
+ else
3944
+ echo "remote-run.sh: report: cannot write question $n's comment for #$target; not posted" >&2
3945
+ fi
3946
+ rm -f "$file" "$file.cut"
3947
+ else
3948
+ echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
3949
+ fi
3950
+ done
3951
+ elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
3952
+ {
3953
+ printf '%s\n' "$text"
3954
+ [ -z "$note" ] || printf '\n%s\n' "$note"
3955
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
3956
+ } >"$file"
3957
+ forge_comment "$target" "$event" "$br" "$file" || :
3958
+ rm -f "$file"
3959
+ else
3960
+ echo "remote-run.sh: report: cannot create the comment file for #$target; no comment posted" >&2
3961
+ fi
3962
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
3963
+
3964
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" "$state" || :
3965
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" "$state" || :
3966
+ echo "remote-run.sh: report: $event on $br reported on #$target"
3967
+ return 0
3968
+ }
3969
+
3970
+ verb_report() {
3971
+ forge_report "$report_event" "$branch" "$report_note"
3972
+ exit "$EXIT_OK"
3973
+ }
3974
+
3975
+ # GitHub's refusal of a pull request created with the job's own token while the
3976
+ # repository's Actions setting is off (docs/github-integration-research.md -> C3).
3977
+ PR_CREATE_FORBIDDEN='GitHub Actions is not permitted to create or approve pull requests'
3978
+ # GitHub's limit on a pull request title.
3979
+ PR_TITLE_MAX_CHARS=256
3980
+
3981
+ # deliver_title_var <branch> — DELIVER_TITLE: the committed task prompt's first
3982
+ # line without its `# ` when it is a heading, cut to PR_TITLE_MAX_CHARS, else <branch>.
3983
+ DELIVER_TITLE=""
3984
+ deliver_title_var() {
3985
+ local state_rel first=""
3986
+ DELIVER_TITLE="$1"
3987
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 0
3988
+ [ -n "$state_rel" ] || return 0
3989
+ IFS= read -r first < <(git -C "$root" show "refs/remotes/origin/$1:$(hr_task_prompt_rel "$state_rel" "$1")" 2>/dev/null) || :
3990
+ case "$first" in
3991
+ '# '?*) DELIVER_TITLE="${first#'# '}"; DELIVER_TITLE="${DELIVER_TITLE:0:$PR_TITLE_MAX_CHARS}" ;;
3992
+ esac
3993
+ return 0
3994
+ }
3995
+
3996
+ # deliver_pr_body <file> <branch> — the pull request's body: what it is, the
3997
+ # issue as a plain mention (never a closing keyword: the flow does not own the
3998
+ # issue's lifecycle), what a reviewer can do here, and the marker.
3999
+ deliver_pr_body() {
4000
+ local v verbs=""
4001
+ for v in $COMMAND_VERBS; do
4002
+ [ "$v" != answer ] || v="answer <n>"
4003
+ verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
4004
+ done
4005
+ {
4006
+ printf 'This pull request carries the harness run on `%s`, ready for your review. The harness never merges it.\n' "$2"
4007
+ [ -z "$FORGE_ISSUE" ] || printf '\nStarted from #%s.\n' "$FORGE_ISSUE"
4008
+ printf '\nA review that requests changes starts a user-review round on this branch.\n'
4009
+ printf 'Comment %s to act on the run; each says when it applies (docs/github-run-control.md in the harness documentation).\n' "$verbs"
4010
+ printf '\n'
4011
+ forge_marker pull-request "$2"
4012
+ } >"$1"
4013
+ }
4014
+
4015
+ # deliver_create <base> <branch> <body_file> [--draft] — one `gh pr create`,
4016
+ # with HARNESS_PR_TOKEN when it is set, else the job's own token.
4017
+ deliver_create() {
4018
+ local base="$1" br="$2" body="$3"
4019
+ shift 3
4020
+ set -- pr create --repo "$FORGE_REPO" --base "$base" --head "$br" "$@" --title "$DELIVER_TITLE" --body-file "$body"
4021
+ if [ -n "${HARNESS_PR_TOKEN-}" ]; then
4022
+ gh_call_token "$HARNESS_PR_TOKEN" "$@"
4023
+ else
4024
+ gh_call "$@"
4025
+ fi
4026
+ }
4027
+
4028
+ # verb_deliver — after a `completed` bundle: find or open the branch's pull
4029
+ # request, post the one `completed` comment, and set `done`. Always exit 0.
4030
+ verb_deliver() {
4031
+ local status base="" pr_url="" opened=0 lookup_failed=0 create_err="" forbidden=0
4032
+ local tmp made_tmp="" body="" file target text compare
4033
+ if ! forge_on; then
4034
+ echo "remote-run.sh: deliver: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
4035
+ exit "$EXIT_OK"
4036
+ fi
4037
+ hr_remote_names_var
4038
+ status=$(hr_remote_status_get "$bundle_dir/$HR_REMOTE_STATUS_FILE" status 2>/dev/null) || status=""
4039
+ if [ "$status" != completed ]; then
4040
+ echo "remote-run.sh: deliver: the bundle's status is '${status:-unreadable}', not completed; nothing posted"
4041
+ exit "$EXIT_OK"
4042
+ fi
4043
+ forge_repo_var || exit "$EXIT_OK"
4044
+
4045
+ forge_fetch_branch "$branch"
4046
+ forge_issue_var "$branch" || FORGE_ISSUE=""
4047
+ forge_pr_var "$branch" || { FORGE_PR=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4048
+
4049
+ tmp="${RUNNER_TEMP-}"
4050
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4051
+ tmp=$(mktemp -d) || tmp=""
4052
+ made_tmp="$tmp"
4053
+ fi
4054
+
4055
+ if [ -n "$FORGE_PR" ]; then
4056
+ pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4057
+ echo "remote-run.sh: deliver: $branch already has pull request #$FORGE_PR; none opened"
4058
+ elif [ "$lookup_failed" -eq 0 ]; then
4059
+ base=$(hr_default_branch "$root" 2>/dev/null) || base=""
4060
+ deliver_title_var "$branch"
4061
+ if [ -z "$base" ]; then
4062
+ create_err="the configured defaultBranch could not be read"
4063
+ elif [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-deliver-body.XXXXXX") || ! deliver_pr_body "$body" "$branch"; then
4064
+ create_err="its body file could not be written"
4065
+ elif deliver_create "$base" "$branch" "$body" --draft; then
4066
+ opened=1
4067
+ else
4068
+ create_err="$GH_ERR"
4069
+ case "$GH_ERR" in
4070
+ *"$PR_CREATE_FORBIDDEN"*) forbidden=1 ;;
4071
+ *)
4072
+ # Drafts depend on the account's plan (C3, not measured): one retry as ready.
4073
+ echo "remote-run.sh: deliver: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
4074
+ if deliver_create "$base" "$branch" "$body"; then
4075
+ opened=1
4076
+ else
4077
+ create_err="$GH_ERR"
4078
+ fi ;;
4079
+ esac
4080
+ fi
4081
+ [ -z "$body" ] || rm -f "$body"
4082
+ if [ "$opened" -eq 1 ]; then
4083
+ FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
4084
+ if [ -n "$FORGE_PR" ]; then
4085
+ pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4086
+ echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
4087
+ else
4088
+ opened=0
4089
+ create_err="gh printed no pull request URL"
4090
+ fi
4091
+ fi
4092
+ fi
4093
+
4094
+ compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4095
+ if [ -z "$pr_url" ]; then
4096
+ target="$FORGE_ISSUE"
4097
+ if [ "$forbidden" -eq 1 ]; then
4098
+ text="The harness run on \`$branch\` completed, but its pull request could not be opened: GitHub Actions is not permitted to create pull requests in this repository. Turn on *$PR_CREATE_SETTING* under $PR_CREATE_SETTING_PATH, or set the \`HARNESS_GIT_TOKEN\` secret, for the next run. For this one, open the pull request from the branch: $compare"
4099
+ else
4100
+ text="The harness run on \`$branch\` completed, but its pull request could not be opened: $create_err. Open it by hand from the branch: $compare"
4101
+ fi
4102
+ elif [ "$opened" -eq 0 ]; then
4103
+ target="$FORGE_PR"
4104
+ text="The harness round on \`$branch\` finished. Review this pull request; a review that requests changes starts another round."
4105
+ elif [ -n "$FORGE_ISSUE" ]; then
4106
+ target="$FORGE_ISSUE"
4107
+ text="The harness run on \`$branch\` completed. Its pull request is ready for your review: $pr_url
4108
+ Review it there; a review that requests changes starts another round."
4109
+ else
4110
+ target="$FORGE_PR"
4111
+ text="The harness run on \`$branch\` completed and opened this pull request. Review it; a review that requests changes starts another round."
4112
+ fi
4113
+ if hr_phase_enabled "$root" qa; then
4114
+ text="$text
4115
+
4116
+ The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
4117
+ fi
4118
+
4119
+ if [ -z "$target" ]; then
4120
+ echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4121
+ elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
4122
+ {
4123
+ printf '%s\n' "$text"
4124
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4125
+ } >"$file"
4126
+ forge_comment "$target" completed "$branch" "$file" || :
4127
+ rm -f "$file"
4128
+ else
4129
+ echo "remote-run.sh: deliver: cannot create the comment file for #$target; no comment posted" >&2
4130
+ fi
4131
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4132
+
4133
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
4134
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
4135
+ echo "remote-run.sh: deliver: completed on $branch reported${target:+ on #$target}"
4136
+ exit "$EXIT_OK"
4137
+ }
4138
+
4139
+ # ---------------------------------------------------------------------------
4140
+ # `control` — the comment adapter: one GitHub event, one harness action on one
4141
+ # branch, through the child verbs. Event text is data, as in `trigger`.
4142
+ # ---------------------------------------------------------------------------
4143
+
4144
+ CONTROL_BRANCH=""
4145
+ CONTROL_NUMBER=""
4146
+ CONTROL_ACTOR=""
4147
+ CONTROL_VERB=""
4148
+ CONTROL_ARGS=""
4149
+ CONTROL_BODY=""
4150
+ control_tmp=""
4151
+ # Directories control_state_var created, removed on exit.
4152
+ control_dirs=""
4153
+
4154
+ control_cleanup() {
4155
+ local d
4156
+ for d in $control_dirs; do
4157
+ rm -rf -- "$d"
4158
+ done
4159
+ return 0
4160
+ }
4161
+
4162
+ # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment;
4163
+ # 1, after an `::error::` line, when it cannot be posted.
4164
+ control_post() {
4165
+ local text="$1" file status=0
4166
+ if ! forge_repo_var; then
4167
+ echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4168
+ return 1
4169
+ fi
4170
+ if ! file=$(mktemp "$control_tmp/harness-control-reply.XXXXXX"); then
4171
+ echo "::error::remote-run.sh: control: cannot create the reply file for #$CONTROL_NUMBER under '$control_tmp'"
4172
+ return 1
4173
+ fi
4174
+ {
4175
+ printf '%s\n' "$text"
4176
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4177
+ } >"$file"
4178
+ if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file"; then
4179
+ echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4180
+ status=1
4181
+ fi
4182
+ rm -f "$file"
4183
+ return "$status"
4184
+ }
4185
+
4186
+ # control_reply <exit> <text> — control_post <text>, then exit <exit>; a reply
4187
+ # that cannot be posted makes the exit 3.
4188
+ control_reply() {
4189
+ control_post "$2" || exit "$EXIT_GH"
4190
+ exit "$1"
4191
+ }
4192
+
4193
+ # control_refuse <exit> <reason> <way on> — the refusal reply, then exit.
4194
+ control_refuse() {
4195
+ echo "remote-run.sh: control: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` refused: $2" >&2
4196
+ control_reply "$1" "@$CONTROL_ACTOR: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` was not run: $2. $3"
4197
+ }
4198
+
4199
+ # control_child <out_file> <args...> — run this script as a child with stderr
4200
+ # captured; CHILD_STATUS is its exit, CHILD_LAST its last non-empty stderr line,
4201
+ # which is also echoed to this process's stderr.
4202
+ CHILD_STATUS=0
4203
+ CHILD_LAST=""
4204
+ control_child() {
4205
+ local out="$1" errfile
4206
+ shift
4207
+ CHILD_STATUS=0
4208
+ CHILD_LAST=""
4209
+ errfile=$(mktemp "$control_tmp/harness-control-err.XXXXXX") || errfile=/dev/null
4210
+ bash "$script_dir/remote-run.sh" "$@" >"$out" 2>"$errfile" || CHILD_STATUS=$?
4211
+ if [ "$errfile" != /dev/null ]; then
4212
+ cat "$errfile" >&2
4213
+ CHILD_LAST=$(grep -v '^[[:space:]]*$' "$errfile" | tail -n 1)
4214
+ rm -f "$errfile"
4215
+ fi
4216
+ [ -n "$CHILD_LAST" ] || CHILD_LAST="exit $CHILD_STATUS, no message"
4217
+ }
4218
+
4219
+ # control_state_var <branch> — the branch's newest remote state, read by a
4220
+ # `fetch` child into a fresh directory: CS_STATE, CS_REASON, CS_ENGINE,
4221
+ # CS_OPEN, CS_DETAIL, CS_URL, CS_RUN_STATUS and CS_DIR. 1 with CS_ERR on a
4222
+ # failed child.
4223
+ CS_STATE=""; CS_REASON=""; CS_ENGINE=""; CS_OPEN=""; CS_DETAIL=""; CS_URL=""
4224
+ CS_RUN_STATUS=""; CS_DIR=""; CS_ERR=""
4225
+ control_state_var() {
4226
+ local out line key value
4227
+ CS_STATE=""; CS_REASON=""; CS_ENGINE=""; CS_OPEN=""; CS_DETAIL=""; CS_URL=""
4228
+ CS_RUN_STATUS=""; CS_DIR=""; CS_ERR=""
4229
+ if ! CS_DIR=$(mktemp -d "$control_tmp/harness-control-fetch.XXXXXX"); then
4230
+ CS_DIR=""
4231
+ CS_ERR="a fetch directory could not be created under '$control_tmp'"
4232
+ return 1
4233
+ fi
4234
+ control_dirs="$control_dirs $CS_DIR"
4235
+ out="$CS_DIR.out"
4236
+ control_dirs="$control_dirs $out"
4237
+ control_child "$out" fetch "$1" "$CS_DIR" --repo "$root"
4238
+ if [ "$CHILD_STATUS" -ne 0 ]; then
4239
+ CS_ERR="$CHILD_LAST"
4240
+ return 1
4241
+ fi
4242
+ while IFS= read -r line; do
4243
+ key=${line%%:*}
4244
+ value=${line#*: }
4245
+ [ "$value" != "$line" ] || value=""
4246
+ case "$key" in
4247
+ state) CS_STATE="$value" ;;
4248
+ pause_reason) CS_REASON="$value" ;;
4249
+ engine) CS_ENGINE="$value" ;;
4250
+ open_questions) CS_OPEN="$value" ;;
4251
+ detail) CS_DETAIL="$value" ;;
4252
+ run_url) CS_URL="$value" ;;
4253
+ run_status) CS_RUN_STATUS="$value" ;;
4254
+ esac
4255
+ done <"$out"
4256
+ return 0
4257
+ }
4258
+
4259
+ # control_check_branch <branch> — the refusals both paths share, in order: a
4260
+ # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4261
+ # whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
4262
+ control_check_branch() {
4263
+ local b="$1" protected=0
4264
+ if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
4265
+ control_refuse "$EXIT_REFUSED" "\`$b\` is not a valid branch name" "Comment on the pull request of the run's branch instead."
4266
+ fi
4267
+ hr_branch_is_protected "$root" "$b" || protected=$?
4268
+ case "$protected" in
4269
+ 1) ;;
4270
+ 0) control_refuse "$EXIT_REFUSED" "\`$b\` is a protected branch, which the harness never acts on" \
4271
+ "Comment on the pull request of the run's own branch instead." ;;
4272
+ *) control_refuse "$EXIT_REFUSED" "whether \`$b\` is protected could not be judged from \`harness.config.json\`" \
4273
+ "Fix the configuration on the default branch, then comment again." ;;
4274
+ esac
4275
+ forge_fetch_branch "$b"
4276
+ if ! forge_recognised "$b"; then
4277
+ control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries no flow-progress ledger" \
4278
+ "Only a branch a harness run works on can be commanded."
4279
+ fi
4280
+ CONTROL_BRANCH="$b"
4281
+ }
4282
+
4283
+ # control_branch_from_pr <number> — the pull request's head, refused for a
4284
+ # fork, a pull request that is not open, and control_check_branch's refusals.
4285
+ control_branch_from_pr() {
4286
+ local head cross state
4287
+ if ! gh_call pr view "$1" --repo "$FORGE_REPO" --json headRefName,isCrossRepository,state; then
4288
+ control_refuse "$EXIT_GH" "pull request #$1 could not be read ($GH_ERR)" "Comment again to retry."
4289
+ fi
4290
+ head=$(printf '%s' "$GH_OUT" | jq -r '.headRefName // empty' 2>/dev/null) || head=""
4291
+ cross=$(printf '%s' "$GH_OUT" | jq -r '.isCrossRepository | tostring' 2>/dev/null) || cross=""
4292
+ state=$(printf '%s' "$GH_OUT" | jq -r '.state // empty' 2>/dev/null) || state=""
4293
+ # A fork's head is never checked out or run: this event carries this
4294
+ # repository's secrets (docs/github-integration-research.md -> C2).
4295
+ if [ "$cross" != false ]; then
4296
+ control_refuse "$EXIT_REFUSED" "pull request #$1 comes from a fork, and the harness never acts on a fork's pull request" \
4297
+ "Push the branch to this repository and open the pull request from there."
4298
+ fi
4299
+ if [ "$state" != OPEN ]; then
4300
+ control_refuse "$EXIT_REFUSED" "pull request #$1 is ${state:-in an unknown state}, not open" "Reopen it, then comment again."
4301
+ fi
4302
+ control_check_branch "$head"
4303
+ }
4304
+
4305
+ # control_branch_from_issue <number> — the branch of the issue's last genuine
4306
+ # start comment: by `github-actions[bot]`, its first line opening with the
4307
+ # trigger's start sentence and a backticked <b>, and its last non-empty line
4308
+ # exactly `forge_marker started <b>`. A marker anywhere else is never trusted.
4309
+ control_branch_from_issue() {
4310
+ local count i login body first last b found="" lead='Started a harness run on the branch `'
4311
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$1/comments" --jq '.[] | {login: .user.login, body: .body}'; then
4312
+ control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry."
4313
+ fi
4314
+ count=$(printf '%s' "$GH_OUT" | jq -s 'length' 2>/dev/null) || count=""
4315
+ case "$count" in
4316
+ ''|*[!0-9]*)
4317
+ GH_ERR="its comment list is not the expected JSON"
4318
+ control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry." ;;
4319
+ esac
4320
+ for ((i = 0; i < count; i++)); do
4321
+ login=$(printf '%s' "$GH_OUT" | jq -s -r --argjson i "$i" '.[$i].login // ""' 2>/dev/null) || continue
4322
+ [ "$login" = 'github-actions[bot]' ] || continue
4323
+ body=$(printf '%s' "$GH_OUT" | jq -s -j --argjson i "$i" '.[$i].body // ""' 2>/dev/null) || continue
4324
+ body=${body//$'\r'$'\n'/$'\n'}
4325
+ body=${body%$'\r'}
4326
+ case "$body" in
4327
+ "$lead"*) ;;
4328
+ *) continue ;;
4329
+ esac
4330
+ b=${body#"$lead"}
4331
+ b=${b%%$'\n'*}
4332
+ case "$b" in
4333
+ *'`'*) b=${b%%'`'*} ;;
4334
+ *) continue ;;
4335
+ esac
4336
+ [ -n "$b" ] || continue
4337
+ last=$(printf '%s\n' "$body" | grep -v '^[[:space:]]*$' | tail -n 1)
4338
+ [ "$last" = "$(forge_marker started "$b")" ] || continue
4339
+ found="$b"
4340
+ done
4341
+ if [ -z "$found" ]; then
4342
+ control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4343
+ "Comment on the pull request of the run's branch instead."
4344
+ fi
4345
+ control_check_branch "$found"
4346
+ }
4347
+
4348
+ # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
4349
+ control_verb_handled() {
4350
+ case "$1" in
4351
+ answer|pause|stop|resume|clear) return 0 ;;
4352
+ esac
4353
+ return 1
4354
+ }
4355
+
4356
+ # control_resume_dispatch <reply> [<dispatch flag>] — the resume dispatch the
4357
+ # local relay sends for CS_ENGINE; on 0, <reply>, then `running` on the run's
4358
+ # issue and pull request. An empty engine is refused, never guessed: the run
4359
+ # workflow's `engine` input defaults to `task`.
4360
+ control_resume_dispatch() {
4361
+ local done_text="$1" out status route clear=""
4362
+ shift
4363
+ if [ -z "$CS_ENGINE" ]; then
4364
+ # The run's own engine is unrecorded, so the route names the choice.
4365
+ route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
4366
+ route=${route#or from GitHub: }
4367
+ [ "$#" -eq 0 ] || clear=", with park_loop_clear true as well"
4368
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`$CS_STATE\`${CS_REASON:+, \`$CS_REASON\`}) records no engine, and the harness does not guess one" \
4369
+ "Resume it with the **Run workflow** form instead: $route$clear."
4370
+ fi
4371
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
4372
+ control_child "$out" dispatch "$CONTROL_BRANCH" --engine "$CS_ENGINE" --resume pause "$@" --chain 0 --repo "$root"
4373
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
4374
+ case "$CHILD_STATUS" in
4375
+ 0) ;;
4376
+ 2) control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again once that is fixed." ;;
4377
+ *) control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again to retry." ;;
4378
+ esac
4379
+ status="$EXIT_OK"
4380
+ control_post "$done_text" || status="$EXIT_GH"
4381
+ # The job posts its own `resumed` comment; the labels say `running` now.
4382
+ forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
4383
+ forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
4384
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
4385
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" running || :
4386
+ exit "$status"
4387
+ }
4388
+
4389
+ control_resume() {
4390
+ local open
4391
+ control_state_var "$CONTROL_BRANCH" \
4392
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4393
+ case "$CS_STATE" in
4394
+ paused)
4395
+ # Every pause reason, `expired` and `killed` included, resumes from the
4396
+ # committed ledger, as the local route does.
4397
+ control_resume_dispatch "Resume requested by @$CONTROL_ACTOR: \`$CONTROL_BRANCH\` continues from its committed ledger." ;;
4398
+ park_loop)
4399
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard" \
4400
+ "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
4401
+ parked)
4402
+ open=""
4403
+ [ -z "$CS_OPEN" ] || open=" (open: $CS_OPEN)"
4404
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`parked\`, waiting for an answer$open" \
4405
+ "Comment \`$COMMAND_HANDLE answer <n>\` with the answer to question <n> on the lines below it." ;;
4406
+ running)
4407
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is already \`running\`" "Nothing needs resuming." ;;
4408
+ *)
4409
+ control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4410
+ "A finished run continues by a review requesting changes on its pull request, or by applying the trigger label to its issue again." ;;
4411
+ esac
4412
+ }
4413
+
4414
+ control_clear() {
4415
+ control_state_var "$CONTROL_BRANCH" \
4416
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4417
+ if [ "$CS_STATE" != park_loop ]; then
4418
+ control_refuse "$EXIT_REFUSED" "there is no park-loop hold to clear: the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4419
+ "Only a run held by the park-loop guard is cleared."
4420
+ fi
4421
+ # On GitHub, typing `clear` is the confirmation `branch-resume` asks for.
4422
+ control_resume_dispatch "Park-loop hold on \`$CONTROL_BRANCH\` cleared by @$CONTROL_ACTOR; the run resumes from its committed ledger." \
4423
+ --park-loop-clear
4424
+ }
4425
+
4426
+ control_pause() {
4427
+ local out
4428
+ control_state_var "$CONTROL_BRANCH" \
4429
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4430
+ if [ "$CS_STATE" != running ]; then
4431
+ control_refuse "$EXIT_REFUSED" "only a running run can be paused, and the run on \`$CONTROL_BRANCH\` is \`$CS_STATE\`" \
4432
+ "Nothing needs pausing."
4433
+ fi
4434
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
4435
+ control_child "$out" pause "$CONTROL_BRANCH" --repo "$root"
4436
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
4437
+ if [ "$CHILD_STATUS" -ne 0 ]; then
4438
+ control_refuse "$EXIT_GH" "the pause could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE pause\` again to retry."
4439
+ fi
4440
+ control_reply "$EXIT_OK" "Pause requested by @$CONTROL_ACTOR; the run on \`$CONTROL_BRANCH\` yields at its next clean checkpoint, and a paused comment follows."
4441
+ }
4442
+
4443
+ control_stop() {
4444
+ local out
4445
+ control_state_var "$CONTROL_BRANCH" \
4446
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4447
+ if [ "$CS_STATE" = none ]; then
4448
+ control_refuse "$EXIT_REFUSED" "there is no harness run on \`$CONTROL_BRANCH\` to stop" "Nothing needs stopping."
4449
+ fi
4450
+ out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
4451
+ control_child "$out" stop "$CONTROL_BRANCH" --actor "$CONTROL_ACTOR" --repo "$root"
4452
+ [ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
4453
+ case "$CHILD_STATUS" in
4454
+ 0)
4455
+ # Always replied here: `stop`'s own `stopped` comment goes to the run's
4456
+ # target, which need not be the item the command was typed on.
4457
+ control_reply "$EXIT_OK" "Stop requested by @$CONTROL_ACTOR; the run on \`$CONTROL_BRANCH\` is stopped." ;;
4458
+ 3)
4459
+ control_refuse "$EXIT_GH" "the stop of \`$CONTROL_BRANCH\` is partial ($CHILD_LAST)" \
4460
+ "Comment \`$COMMAND_HANDLE stop\` again to finish it." ;;
4461
+ *)
4462
+ control_refuse "$EXIT_GH" "the stop could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE stop\` again to retry." ;;
4463
+ esac
4464
+ }
4465
+
4466
+ # control_answer — one answer, one `resume: answer` dispatch with one entry. A
4467
+ # park left partly answered is safe: run_job stops an `answer` job whose park
4468
+ # is not fully answered before any session, and its bundle then carries the
4469
+ # `answer_<n>.md` restore wrote, so the next answer's job finds the set complete.
4470
+ control_answer() {
4471
+ local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK"
4472
+ first=${CONTROL_ARGS%%[$' \t']*}
4473
+ if [[ "$first" =~ ^[1-9][0-9]*$ ]]; then
4474
+ n="$first"
4475
+ short=${CONTROL_ARGS#"$first"}
4476
+ short=${short#"${short%%[!$' \t']*}"}
4477
+ else
4478
+ short="$CONTROL_ARGS"
4479
+ fi
4480
+ case "$CONTROL_BODY" in
4481
+ *$'\n'*) below=${CONTROL_BODY#*$'\n'} ;;
4482
+ esac
4483
+ below=${below//$'\r'$'\n'/$'\n'}
4484
+ below=${below%$'\r'}
4485
+ if [[ "$below" =~ ^[[:space:]]*$ ]]; then
4486
+ text="$short"
4487
+ else
4488
+ text="$below"
4489
+ fi
4490
+ if [[ "$text" =~ ^[[:space:]]*$ ]]; then
4491
+ control_refuse "$EXIT_REFUSED" "the answer is empty" \
4492
+ "Comment \`$COMMAND_HANDLE answer <n>\` with the answer on the lines below it."
4493
+ fi
4494
+
4495
+ control_state_var "$CONTROL_BRANCH" \
4496
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
4497
+ case "$CS_STATE:$CS_REASON" in
4498
+ park_loop:*)
4499
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard, not waiting for an answer" \
4500
+ "Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
4501
+ paused:expired)
4502
+ # An expired bundle is reported as expired, never as no park.
4503
+ control_refuse "$EXIT_REFUSED" "${CS_DETAIL:-the state bundle of the run has expired}; the park's questions can no longer be answered here" \
4504
+ "Comment \`$COMMAND_HANDLE resume\` to resume from the committed ledger." ;;
4505
+ running:*)
4506
+ # Refused rather than queued: a newer pending run in the per-branch
4507
+ # concurrency group could cancel a queued one.
4508
+ control_refuse "$EXIT_REFUSED" "a job of the run on \`$CONTROL_BRANCH\` is in progress${CS_URL:+ ($CS_URL)}" \
4509
+ "Send the answer again once it finishes."
4510
+ ;;
4511
+ parked:*) ;;
4512
+ *)
4513
+ control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
4514
+ "Nothing is waiting for an answer." ;;
4515
+ esac
4516
+ if [ -z "$CS_OPEN" ]; then
4517
+ control_refuse "$EXIT_REFUSED" "only a parked run with an open question can be answered, and the run on \`$CONTROL_BRANCH\` is \`parked\` with none open" \
4518
+ "Nothing is waiting for an answer."
4519
+ fi
4520
+ for v in $CS_OPEN; do
4521
+ open_list="$open_list${open_list:+, }$v"
4522
+ cmds="$cmds${cmds:+, }\`$COMMAND_HANDLE answer $v\`"
4523
+ done
4524
+ if [ -z "$n" ]; then
4525
+ case "$CS_OPEN" in
4526
+ *' '*)
4527
+ control_refuse "$EXIT_REFUSED" "questions $open_list are open, so the command must name one" \
4528
+ "Answer each with its own comment: $cmds." ;;
4529
+ esac
4530
+ n="$CS_OPEN"
4531
+ fi
4532
+ case " $CS_OPEN " in
4533
+ *" $n "*) ;;
4534
+ *)
4535
+ control_refuse "$EXIT_REFUSED" "question $n is not open; the open questions are $open_list" \
4536
+ "Answer one of them: $cmds." ;;
4537
+ esac
4538
+ if [ -z "$CS_ENGINE" ]; then
4539
+ route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
4540
+ route=${route#or from GitHub: }
4541
+ form="resume answer and answers \`{\"$n\": \"<the answer>\"}\`"
4542
+ route=${route/and resume pause/$form}
4543
+ control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`parked\`) records no engine, and the harness does not guess one" \
4544
+ "Answer it with the **Run workflow** form instead: $route."
4545
+ fi
4546
+
4547
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-answer.XXXXXX"); then
4548
+ control_refuse "$EXIT_GH" "an answer directory could not be created under '$control_tmp'" "Comment again to retry."
4549
+ fi
4550
+ control_dirs="$control_dirs $dir"
4551
+ # The answer is untrusted data: written by printf, never sourced.
4552
+ if ! mkdir "$dir/answers" || ! printf '%s' "$text" >"$dir/answers/answer_$n.md"; then
4553
+ control_refuse "$EXIT_GH" "the answer could not be written under '$dir'" "Comment again to retry."
4554
+ fi
4555
+ out="$dir/dispatch.out"
4556
+ control_child "$out" dispatch "$CONTROL_BRANCH" --engine "$CS_ENGINE" --resume answer \
4557
+ --answers-from "$dir/answers" --indexes "$n" --chain 0 --repo "$root"
4558
+ cat "$out" 2>/dev/null || :
4559
+ case "$CHILD_STATUS" in
4560
+ 0) ;;
4561
+ 2)
4562
+ case "$CHILD_LAST" in
4563
+ *"workflow_dispatch limit"*)
4564
+ control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" \
4565
+ "Shorten the answer, or commit it to a file on \`$CONTROL_BRANCH\` and name that file in a shorter answer." ;;
4566
+ esac
4567
+ control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" \
4568
+ "Comment \`$COMMAND_HANDLE answer $n\` again once that is fixed." ;;
4569
+ *)
4570
+ control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" \
4571
+ "Comment \`$COMMAND_HANDLE answer $n\` again to retry." ;;
4572
+ esac
4573
+
4574
+ cmds=""
4575
+ for v in $CS_OPEN; do
4576
+ [ "$v" != "$n" ] || continue
4577
+ rest="$rest${rest:+, }$v"
4578
+ cmds="$cmds${cmds:+, }\`$COMMAND_HANDLE answer $v\`"
4579
+ done
4580
+ if [ -n "$rest" ]; then
4581
+ # The label stays `parked` until the last answer: only that job resumes.
4582
+ control_reply "$EXIT_OK" "Answer to question $n received from @$CONTROL_ACTOR and sent; question(s) $rest still need an answer: $cmds."
4583
+ fi
4584
+ control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so \`$CONTROL_BRANCH\` resumes." \
4585
+ || status="$EXIT_GH"
4586
+ forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
4587
+ forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
4588
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
4589
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" running || :
4590
+ exit "$status"
4591
+ }
4592
+
4593
+ # The fields of a `pull_request_review` event control reads beyond the shared ones.
4594
+ REVIEW_ID=""
4595
+ REVIEW_URL=""
4596
+ REVIEW_AT=""
4597
+ REVIEW_HEAD=""
4598
+
4599
+ # control_review_story — refused unless origin's tip of CONTROL_BRANCH carries
4600
+ # its story index, which the round's statistics step reads.
4601
+ control_review_story() {
4602
+ local state_rel
4603
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
4604
+ state_rel="${state_rel%/}"
4605
+ if [ -z "$state_rel" ] \
4606
+ || ! git -C "$root" cat-file -e "refs/remotes/origin/$CONTROL_BRANCH:$state_rel/story_plans/${CONTROL_BRANCH}_story_plan.md" 2>/dev/null; then
4607
+ control_refuse "$EXIT_REFUSED" "\`$CONTROL_BRANCH\` carries no story index (\`$state_rel/story_plans/${CONTROL_BRANCH}_story_plan.md\`), which a user-review round reads, so the round cannot start on this branch" \
4608
+ "Review a branch whose run has planned its stories."
4609
+ fi
4610
+ }
4611
+
4612
+ # round_collect <pr_number> <out_file> — the cumulative round of the global
4613
+ # `branch`'s pull request <pr_number>, shared by `control` and `collect`: every
4614
+ # submitted review (never `PENDING`) that requests changes or carries a
4615
+ # non-blank body, and every inline comment, no earlier round consumed, by
4616
+ # every author `authorise_actor` accepts. What earlier rounds consumed is
4617
+ # read from the marker lines of their files on origin's tip; with none marked,
4618
+ # the boundary is the committer time of the newest round file. A comment
4619
+ # belonging to a pending review is pending whatever its `created_at`: a draft
4620
+ # comment is created before its review is submitted. RC_EVENT, set by the
4621
+ # caller, is the event's own review as a JSON object, merged when the listing
4622
+ # lacks it. Sets RC_REVIEWS (the kept reviews requesting changes only),
4623
+ # RC_REVIEWERS (distinct logins of every review written, comma-joined, in
4624
+ # order) and RC_EVENT_ROUND (the round whose marker records RC_EVENT's id).
4625
+ # Returns 0 with <out_file> written; 1 when no review requesting changes is
4626
+ # pending, writing nothing and recording nothing, so a *Comment* or *Approve*
4627
+ # review rides along in the next round one requesting changes starts; 3 a listing or a permission call failed; 4 the
4628
+ # previous rounds or <out_file> could not be read or written. RC_ERR holds why.
4629
+ RC_EVENT=""
4630
+ RC_REVIEWS=0
4631
+ RC_REVIEWERS=""
4632
+ RC_EVENT_ROUND=""
4633
+ RC_ERR=""
4634
+ # round_markers_read <event_review_id> — what the global `branch`'s rounds on
4635
+ # origin's tip consumed, from their marker lines: RC_SEEN_R and RC_SEEN_C (the
4636
+ # recorded review and comment ids, comma-wrapped), RC_MARKED_AT (the
4637
+ # highest-numbered marked round's `collected_at`, empty when none is marked),
4638
+ # RC_EVENT_ROUND (the round recording <event_review_id>), and RC_STATE_REL.
4639
+ # 4 with RC_ERR when a round cannot be listed or read. `control` also calls it
4640
+ # on its own, to name the round a review in flight is already part of.
4641
+ RC_SEEN_R=","
4642
+ RC_SEEN_C=","
4643
+ RC_MARKED_AT=""
4644
+ RC_STATE_REL=""
4645
+ round_markers_read() {
4646
+ local event_id="${1-}" names name path n line marked_max=0
4647
+ RC_SEEN_R=","
4648
+ RC_SEEN_C=","
4649
+ RC_MARKED_AT=""
4650
+ RC_EVENT_ROUND=""
4651
+ RC_STATE_REL=$(hr_state_dir "$root" 2>/dev/null) || RC_STATE_REL=""
4652
+ RC_STATE_REL="${RC_STATE_REL%/}"
4653
+ if [ -z "$RC_STATE_REL" ]; then
4654
+ RC_ERR="the state directory under '$root' could not be resolved"
4655
+ return 4
4656
+ fi
4657
+ if ! names=$(git -C "$root" ls-tree --name-only "refs/remotes/origin/$branch" -- "$RC_STATE_REL/user_reviews/" 2>/dev/null); then
4658
+ RC_ERR="the previous rounds of \`$branch\` could not be listed"
4659
+ return 4
4660
+ fi
4661
+ while IFS= read -r path; do
4662
+ name="${path##*/}"
4663
+ [[ "$name" =~ ^(.+)_review(_([0-9]+))?\.md$ ]] || continue
4664
+ [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
4665
+ n="${BASH_REMATCH[3]:-1}"
4666
+ n=$((10#$n))
4667
+ if ! line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null); then
4668
+ RC_ERR="the previous round \`$path\` could not be read"
4669
+ return 4
4670
+ fi
4671
+ # The last marker line wins; a round placed before markers carries none.
4672
+ line=$(printf '%s\n' "$line" | grep -F "$COMMENT_MARKER round collected_at=" | tail -n 1)
4673
+ [[ "$line" =~ ^"$COMMENT_MARKER round collected_at="([0-9T:Z-]+)" reviews="([0-9,]*)" comments="([0-9,]*)" -->"$ ]] || continue
4674
+ RC_SEEN_R="$RC_SEEN_R${BASH_REMATCH[2]}${BASH_REMATCH[2]:+,}"
4675
+ RC_SEEN_C="$RC_SEEN_C${BASH_REMATCH[3]}${BASH_REMATCH[3]:+,}"
4676
+ if [ "$n" -gt "$marked_max" ]; then
4677
+ marked_max="$n"
4678
+ RC_MARKED_AT="${BASH_REMATCH[1]}"
4679
+ fi
4680
+ case ",${BASH_REMATCH[2]}," in
4681
+ *",$event_id,"*) [ -z "$event_id" ] || RC_EVENT_ROUND="$n" ;;
4682
+ esac
4683
+ done <<NAMES
4684
+ $names
4685
+ NAMES
4686
+ return 0
4687
+ }
4688
+
4689
+ round_collect() {
4690
+ local pr="$1" file="$2" collected_at state_rel event_id=""
4691
+ local seen_r seen_c marked_at since="" tmp status
4692
+ local kept authors login type allowed="," count text
4693
+ RC_REVIEWS=0
4694
+ RC_REVIEWERS=""
4695
+ RC_EVENT_ROUND=""
4696
+ RC_ERR=""
4697
+ collected_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
4698
+ [ -z "$RC_EVENT" ] || event_id=$(printf '%s' "$RC_EVENT" | jq -r '.id // empty' 2>/dev/null) || event_id=""
4699
+
4700
+ round_markers_read "$event_id" || return 4
4701
+ state_rel="$RC_STATE_REL"
4702
+ seen_r="$RC_SEEN_R"
4703
+ seen_c="$RC_SEEN_C"
4704
+ marked_at="$RC_MARKED_AT"
4705
+ # Epoch seconds, so the comparison with `submitted_at` / `created_at` reads no time zone.
4706
+ if [ -n "$marked_at" ]; then
4707
+ if ! since=$(jq -n -r --arg t "$marked_at" --argjson o "$ROUND_OVERLAP_SECS" '($t | fromdateiso8601) - $o' 2>/dev/null); then
4708
+ RC_ERR="the previous round's collected_at '$marked_at' is not a UTC time"
4709
+ return 4
4710
+ fi
4711
+ elif ! since=$(git -C "$root" log -1 --format=%ct "refs/remotes/origin/$branch" -- \
4712
+ "$state_rel/user_reviews/${branch}_review.md" \
4713
+ "$state_rel/user_reviews/${branch}_review_[0-9]*.md" 2>/dev/null); then
4714
+ RC_ERR="the previous round of \`$branch\` could not be read"
4715
+ return 4
4716
+ fi
4717
+
4718
+ if ! tmp=$(mktemp -d); then
4719
+ RC_ERR="a collection directory could not be created"
4720
+ return 4
4721
+ fi
4722
+ if ! gh_call api --paginate "repos/$FORGE_REPO/pulls/$pr/reviews"; then
4723
+ RC_ERR="the reviews of pull request #$pr could not be read ($GH_ERR)"
4724
+ rm -rf -- "$tmp"
4725
+ return 3
4726
+ fi
4727
+ printf '%s' "$GH_OUT" >"$tmp/reviews.json"
4728
+ if ! gh_call api --paginate "repos/$FORGE_REPO/pulls/$pr/comments"; then
4729
+ RC_ERR="the inline comments of pull request #$pr could not be read ($GH_ERR)"
4730
+ rm -rf -- "$tmp"
4731
+ return 3
4732
+ fi
4733
+ printf '%s' "$GH_OUT" >"$tmp/comments.json"
4734
+
4735
+ # A paginated listing is one JSON array per page; jq selects, so every
4736
+ # listed field is data and never shell source.
4737
+ if ! kept=$(jq -n -c --slurpfile R "$tmp/reviews.json" --slurpfile C "$tmp/comments.json" \
4738
+ --argjson event "${RC_EVENT:-null}" --arg since "$since" --arg state "$REVIEW_ROUND_STATE" \
4739
+ --arg marker "$COMMENT_MARKER" --arg seen_r "$seen_r" --arg seen_c "$seen_c" '
4740
+ def flat: [ .[] | if type == "array" then .[] else error("not a page") end ];
4741
+ def unseen($ids): ("," + tostring + ",") as $k | ($ids | contains($k)) | not;
4742
+ def since_ok($t): $since == ""
4743
+ or ((($t // "") | try fromdateiso8601 catch 0) >= ($since | tonumber));
4744
+ ($R | flat) as $listed
4745
+ | (if $event == null or any($listed[]; .id == $event.id) then $listed else $listed + [$event] end)
4746
+ | [ .[] | ((.state // "") | ascii_downcase) as $s
4747
+ | select($s != "" and $s != "pending")
4748
+ | select($s == $state or ((.body // "") | test("\\S")))
4749
+ | select(((.body // "") | contains($marker)) | not)
4750
+ | select(.id | unseen($seen_r))
4751
+ | select(since_ok(.submitted_at)) ]
4752
+ | sort_by([.submitted_at, .id]) as $reviews
4753
+ | ($reviews | map(.id)) as $ids
4754
+ | ($C | flat)
4755
+ | [ .[] | select(.id | unseen($seen_c))
4756
+ | select(((.body // "") | contains($marker)) | not)
4757
+ | select(since_ok(.created_at) or (.pull_request_review_id as $r | any($ids[]; . == $r))) ]
4758
+ | sort_by([.created_at, .id]) as $comments
4759
+ | {reviews: $reviews, comments: $comments}' 2>/dev/null); then
4760
+ RC_ERR="the reviews or inline comments of pull request #$pr are not the expected JSON"
4761
+ rm -rf -- "$tmp"
4762
+ return 3
4763
+ fi
4764
+ rm -rf -- "$tmp"
4765
+
4766
+ # One permission answer per distinct author; a refused author's items are
4767
+ # dropped with one line, and a failed call fails the collection.
4768
+ authors=$(printf '%s' "$kept" | jq -r '
4769
+ [ (.reviews[], .comments[]) | {l: (.user.login // ""), t: (.user.type // "")} ]
4770
+ | reduce .[] as $a ([]; if any(.[]; .l == $a.l) then . else . + [$a] end)
4771
+ | .[] | "\(.l)\t\(.t)"')
4772
+ while IFS=$'\t' read -r login type; do
4773
+ [ -n "$login$type" ] || continue
4774
+ status=0
4775
+ authorise_actor "$login" "$type" || status=$?
4776
+ case "$status" in
4777
+ 0) allowed="$allowed$login," ;;
4778
+ 4)
4779
+ RC_ERR="${AUTH_WHY%.}"
4780
+ return 3 ;;
4781
+ *)
4782
+ count=$(printf '%s' "$kept" | jq -r --arg l "$login" '[ (.reviews[], .comments[]) | select((.user.login // "") == $l) ] | length')
4783
+ echo "remote-run.sh: round: dropped $count item(s) by @${login:-(no login)}: $AUTH_WHY" ;;
4784
+ esac
4785
+ done <<AUTHORS
4786
+ $authors
4787
+ AUTHORS
4788
+ kept=$(printf '%s' "$kept" | jq -c --arg allowed "$allowed" '
4789
+ def ok: ("," + (.user.login // "") + ",") as $k | ($allowed | contains($k)) and (.user.login // "") != "";
4790
+ {reviews: [ .reviews[] | select(ok) ], comments: [ .comments[] | select(ok) ]}')
4791
+
4792
+ RC_REVIEWS=$(printf '%s' "$kept" | jq -r --arg s "$REVIEW_ROUND_STATE" \
4793
+ '[ .reviews[] | select(((.state // "") | ascii_downcase) == $s) ] | length')
4794
+ [ "$RC_REVIEWS" -gt 0 ] || return 1
4795
+ RC_REVIEWERS=$(printf '%s' "$kept" | jq -r '
4796
+ reduce (.reviews[] | .user.login) as $l ([]; if any(.[]; . == $l) then . else . + [$l] end) | join(",")')
4797
+
4798
+ # A hunk's fence is one backtick longer than its longest backtick run, at
4799
+ # least three. The trailing `x` keeps the text's final newline through the
4800
+ # substitution.
4801
+ if ! text=$(printf '%s' "$kept" | jq -j --arg pr "$pr" --arg at "$collected_at" --arg marker "$COMMENT_MARKER" --arg state "$REVIEW_ROUND_STATE" '
4802
+ def nl: if endswith("\n") then . else . + "\n" end;
4803
+ .reviews as $rv | .comments as $cm
4804
+ | ($rv | map(
4805
+ ((.state // "") | ascii_downcase) as $s
4806
+ | (" pull request #" + $pr + " (" + (.html_url // "") + ") at " + (.submitted_at // "")) as $on
4807
+ | "## Review by @" + .user.login + "\n\n"
4808
+ + (if (.body // "") == "" then "(The review carries no summary.)\n" else (.body | nl) end)
4809
+ + "\n"
4810
+ + (if $s == $state then "Requested changes on" + $on + ".\n"
4811
+ elif $s == "commented" then "Commented on" + $on + ".\n"
4812
+ elif $s == "approved" then "Approved" + $on + ".\n"
4813
+ elif $s == "dismissed" then "Reviewed" + $on + "; the review has since been dismissed.\n"
4814
+ else "Reviewed" + $on + " (state " + $s + ").\n" end)
4815
+ ) | join("\n"))
4816
+ + (if ($cm | length) == 0 then "" else
4817
+ "\n## Inline comments\n" + ($cm | map(
4818
+ (.diff_hunk // "") as $h
4819
+ | (([$h | match("`+"; "g") | .length] | max) // 0) as $m
4820
+ | ("`" * ([$m + 1, 3] | max)) as $f
4821
+ | "\n### `" + (.path // "") + "`"
4822
+ + (if .line != null then ", line \(.line)"
4823
+ elif .original_line != null then ", original line \(.original_line) (outdated)"
4824
+ else "" end)
4825
+ + "\n\nMade on commit `" + (.original_commit_id // .commit_id // "") + "`.\n"
4826
+ + "By @" + .user.login + ": " + (.html_url // "") + "\n\n"
4827
+ + ((.body // "") | nl)
4828
+ + "\n" + $f + "diff\n" + ($h | nl) + $f + "\n"
4829
+ ) | join(""))
4830
+ end)
4831
+ + "\n" + $marker + " round collected_at=" + $at
4832
+ + " reviews=" + ($rv | map(.id | tostring) | join(","))
4833
+ + " comments=" + ($cm | map(.id | tostring) | join(",")) + " -->\n"
4834
+ + "x"' 2>/dev/null); then
4835
+ RC_ERR="the round could not be rendered"
4836
+ return 4
4837
+ fi
4838
+ if ! printf '%s' "${text%x}" >"$file"; then
4839
+ RC_ERR="the round could not be written to '$file'"
4840
+ return 4
4841
+ fi
4842
+ return 0
4843
+ }
4844
+
4845
+ # The way on of every review reply that started nothing: a review is never
4846
+ # resubmitted to be kept, only to retry now.
4847
+ REVIEW_RETRY_WAY="The reviews stay on the pull request and are collected by the next round; submit any review requesting changes to retry now."
4848
+
4849
+ # control_settled_var — `branch_settled_var` for CONTROL_BRANCH, with no run
4850
+ # counted as settled, run in a command substitution so that its exit on a
4851
+ # failed read reaches this process as a status, not as an exit with no reply:
4852
+ # BS_SETTLED, BS_STATE and BS_REASON; 1 with BS_ERR, its last stderr line, on a
4853
+ # failed read.
4854
+ BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
4855
+ control_settled_var() {
4856
+ local dir errfile line status=0
4857
+ BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
4858
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-settled.XXXXXX"); then
4859
+ BS_ERR="a state directory could not be created under '$control_tmp'"
4860
+ return 1
4861
+ fi
4862
+ errfile="$dir.err"
4863
+ control_dirs="$control_dirs $dir $errfile"
4864
+ branch="$CONTROL_BRANCH"
4865
+ line=$(branch_settled_var "$dir" 1 >/dev/null 2>"$errfile" \
4866
+ && printf '%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON") || status=$?
4867
+ cat "$errfile" >&2 2>/dev/null || :
4868
+ if [ "$status" -ne 0 ] || [ -z "$line" ]; then
4869
+ BS_ERR=$(grep -v '^[[:space:]]*$' "$errfile" 2>/dev/null | tail -n 1)
4870
+ [ -n "$BS_ERR" ] || BS_ERR="exit $status, no message"
4871
+ return 1
4872
+ fi
4873
+ IFS='|' read -r BS_SETTLED BS_STATE BS_REASON <<<"$line"
4874
+ return 0
4875
+ }
4876
+
4877
+ # control_review_in_flight — the reply to a review while CONTROL_BRANCH is in
4878
+ # flight (BS_STATE; empty when unknown), then exit 0: never a refusal, nothing
4879
+ # pushed or dispatched. Names the round whose marker on origin's tip already
4880
+ # records the review, else says it was collected, with the state's way on.
4881
+ control_review_in_flight() {
4882
+ local state="in flight" way=""
4883
+ if [ -n "$BS_STATE" ]; then
4884
+ state="\`$BS_STATE\`"
4885
+ [ -z "$BS_REASON" ] || state="$state (\`$BS_REASON\`)"
4886
+ fi
4887
+ case "$BS_STATE" in
4888
+ parked)
4889
+ way=" The run waits for an answer: comment \`$COMMAND_HANDLE answer <n>\` with the answer to its open question <n> on the lines below it." ;;
4890
+ park_loop)
4891
+ way=" The run is held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold." ;;
4892
+ paused)
4893
+ if [ "$BS_REASON" = usage ]; then
4894
+ way=" The run resumes by itself once the usage limit resets."
4895
+ else
4896
+ way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger."
4897
+ fi ;;
4898
+ esac
4899
+ branch="$CONTROL_BRANCH"
4900
+ if round_markers_read "$REVIEW_ID" && [ -n "$RC_EVENT_ROUND" ]; then
4901
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND, which is $state on \`$CONTROL_BRANCH\`."
4902
+ fi
4903
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review was collected. \`$CONTROL_BRANCH\` is $state; when that run finishes, the next user-review round starts by itself from every review requesting changes and every inline comment left since the previous round, yours included. Nothing needs to be submitted again.$way"
4904
+ }
4905
+
4906
+ # control_review — a review requesting changes. In flight: acknowledged and
4907
+ # left on the pull request for the next round. Settled: the cumulative round,
4908
+ # placed and dispatched by a `review` child, the local relay's own verb.
4909
+ control_review() {
4910
+ local dir file out status
4911
+ control_review_story
4912
+ control_settled_var \
4913
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($BS_ERR)" "$REVIEW_RETRY_WAY"
4914
+ [ "$BS_SETTLED" = 1 ] || control_review_in_flight
4915
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-review.XXXXXX"); then
4916
+ control_refuse "$EXIT_PLACEMENT" "a round directory could not be created under '$control_tmp', so nothing was dispatched" \
4917
+ "$REVIEW_RETRY_WAY"
4918
+ fi
4919
+ control_dirs="$control_dirs $dir"
4920
+ file="$dir/review.md"
4921
+ if ! RC_EVENT=$(jq -n -c --argjson id "$REVIEW_ID" --arg body "$CONTROL_BODY" --arg url "$REVIEW_URL" \
4922
+ --arg at "$REVIEW_AT" --arg login "$CONTROL_ACTOR" --arg type "$CONTROL_SENDER_TYPE" \
4923
+ '{id: $id, state: "CHANGES_REQUESTED", body: $body, html_url: $url, submitted_at: $at, user: {login: $login, type: $type}}'); then
4924
+ control_refuse "$EXIT_PLACEMENT" "the review could not be read into the round, so nothing was dispatched" \
4925
+ "$REVIEW_RETRY_WAY"
4926
+ fi
4927
+ branch="$CONTROL_BRANCH"
4928
+ status=0
4929
+ round_collect "$CONTROL_NUMBER" "$file" || status=$?
4930
+ case "$status" in
4931
+ 0) ;;
4932
+ 1)
4933
+ if [ -n "$RC_EVENT_ROUND" ]; then
4934
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND of \`$CONTROL_BRANCH\`; nothing new was collected."
4935
+ fi
4936
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: no review requesting changes is pending on pull request #$CONTROL_NUMBER since the previous round of \`$CONTROL_BRANCH\`, so no round was started." ;;
4937
+ 3)
4938
+ control_refuse "$EXIT_GH" "the round could not be collected: $RC_ERR" "$REVIEW_RETRY_WAY" ;;
4939
+ *)
4940
+ control_refuse "$EXIT_PLACEMENT" "the round could not be collected: $RC_ERR, so nothing was dispatched" \
4941
+ "$REVIEW_RETRY_WAY" ;;
4942
+ esac
4943
+ out="$dir/review.out"
4944
+ set -- review "$CONTROL_BRANCH" --review-file "$file" --allow-no-run --reviewers "$RC_REVIEWERS"
4945
+ case "$FORGE_SERVER" in
4946
+ https://*) set -- "$@" --source "$FORGE_SERVER/$FORGE_REPO/pull/$CONTROL_NUMBER" ;;
4947
+ esac
4948
+ control_child "$out" "$@" --repo "$root"
4949
+ cat "$out" 2>/dev/null || :
4950
+ case "$CHILD_STATUS" in
4951
+ 0) exit "$EXIT_OK" ;;
4952
+ 2)
4953
+ # The branch became unsettled between the two reads: a run started in
4954
+ # between, so the review waits for it like any review in flight.
4955
+ control_settled_var && [ "$BS_SETTLED" != 1 ] || { BS_STATE=""; BS_REASON=""; }
4956
+ control_review_in_flight ;;
4957
+ 3)
4958
+ control_refuse "$EXIT_GH" "the round is pushed but its dispatch failed ($CHILD_LAST)" \
4959
+ "Re-send the dispatch as that line says." ;;
4960
+ 4)
4961
+ control_refuse "$EXIT_PLACEMENT" "placing the round failed and nothing was dispatched ($CHILD_LAST)" \
4962
+ "$REVIEW_RETRY_WAY" ;;
4963
+ *)
4964
+ control_refuse "$EXIT_GH" "the round could not be started ($CHILD_LAST)" "$REVIEW_RETRY_WAY" ;;
4965
+ esac
4966
+ }
4967
+
4968
+ # control_review_intake — read a `pull_request_review` event; returns 1 when it
4969
+ # is ignored, after one line.
4970
+ control_review_intake() {
4971
+ local action state head_repo
4972
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
4973
+ && event_field '.review.state // ""' && state="$EVENT_VALUE" \
4974
+ && event_field '.review.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
4975
+ && event_field '.review.id // ""' && REVIEW_ID="$EVENT_VALUE" \
4976
+ && event_field '.review.html_url // ""' && REVIEW_URL="$EVENT_VALUE" \
4977
+ && event_field '.review.submitted_at // ""' && REVIEW_AT="$EVENT_VALUE" \
4978
+ && event_field '.pull_request.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
4979
+ && event_field '.pull_request.head.ref // ""' && REVIEW_HEAD="$EVENT_VALUE" \
4980
+ && event_field '.pull_request.head.repo.full_name // ""' && head_repo="$EVENT_VALUE" \
4981
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
4982
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || {
4983
+ echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
4984
+ exit "$EXIT_USAGE"
4985
+ }
4986
+ if [ "$action" != submitted ]; then
4987
+ echo "remote-run.sh: control: ignored, a review $action, not submitted"
4988
+ return 1
4989
+ fi
4990
+ # The REST API reports states in uppercase, the webhook in lowercase.
4991
+ if [ "$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]')" != "$REVIEW_ROUND_STATE" ]; then
4992
+ echo "remote-run.sh: control: ignored, a review whose state is ${state:-empty}, not $REVIEW_ROUND_STATE"
4993
+ return 1
4994
+ fi
4995
+ case "$CONTROL_BODY" in
4996
+ *"$COMMENT_MARKER"*)
4997
+ echo "remote-run.sh: control: ignored, a review carrying the harness's marker"
4998
+ return 1 ;;
4999
+ esac
5000
+ # A fork's review job holds a read-only token, so it is not even replied to.
5001
+ if [ -z "$head_repo" ] || [ "$head_repo" != "${GITHUB_REPOSITORY-}" ]; then
5002
+ echo "remote-run.sh: control: ignored, a review of a head in ${head_repo:-an unnamed repository}, not ${GITHUB_REPOSITORY:-this repository}"
5003
+ return 1
5004
+ fi
5005
+ case "$REVIEW_ID" in
5006
+ ''|*[!0-9]*|0*)
5007
+ echo "remote-run.sh: control: the event carries no review id" >&2
5008
+ exit "$EXIT_USAGE" ;;
5009
+ esac
5010
+ CONTROL_VERB=review
5011
+ return 0
5012
+ }
5013
+
5014
+ # control_comment_intake — read an `issue_comment` event; returns 1 when it is
5015
+ # ignored, after one line.
5016
+ CONTROL_SENDER_TYPE=""
5017
+ CONTROL_IS_PR=""
5018
+ control_comment_intake() {
5019
+ local action first word rest handle
5020
+ { event_field '.action // ""' && action="$EVENT_VALUE" \
5021
+ && event_field '.comment.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
5022
+ && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
5023
+ && event_field '.issue.pull_request.url // ""' && CONTROL_IS_PR="$EVENT_VALUE" \
5024
+ && event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
5025
+ && event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || {
5026
+ echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
5027
+ exit "$EXIT_USAGE"
5028
+ }
5029
+
5030
+ if [ "$action" != created ]; then
5031
+ echo "remote-run.sh: control: ignored, a comment $action, not created"
5032
+ return 1
5033
+ fi
5034
+ case "$CONTROL_BODY" in
5035
+ *"$COMMENT_MARKER"*)
5036
+ echo "remote-run.sh: control: ignored, a comment the harness posted"
5037
+ return 1 ;;
5038
+ esac
5039
+ first=${CONTROL_BODY%%$'\n'*}
5040
+ first=${first%$'\r'}
5041
+ first=${first#"${first%%[!$' \t']*}"}
5042
+ word=${first%%[$' \t']*}
5043
+ rest=${first#"$word"}
5044
+ rest=${rest#"${rest%%[!$' \t']*}"}
5045
+ handle=$(printf '%s' "$COMMAND_HANDLE" | tr '[:upper:]' '[:lower:]')
5046
+ if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" != "$handle" ]; then
5047
+ echo "remote-run.sh: control: ignored, the first line does not open with $COMMAND_HANDLE"
5048
+ return 1
5049
+ fi
5050
+ word=${rest%%[$' \t']*}
5051
+ CONTROL_VERB=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
5052
+ CONTROL_ARGS=${rest#"$word"}
5053
+ CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
5054
+ return 0
5055
+ }
5056
+
5057
+ verb_control() {
5058
+ local LC_ALL=C
5059
+ local review=0 forge="" target="" status verbs="" v
5060
+ case "${GITHUB_EVENT_NAME-}" in
5061
+ issue_comment) ;;
5062
+ pull_request_review) review=1 ;;
5063
+ *)
5064
+ echo "remote-run.sh: control handles GITHUB_EVENT_NAME issue_comment or pull_request_review, not '${GITHUB_EVENT_NAME-}'" >&2
5065
+ exit "$EXIT_USAGE" ;;
5066
+ esac
5067
+ if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
5068
+ echo "remote-run.sh: control: cannot read the event file '${GITHUB_EVENT_PATH-}'" >&2
5069
+ exit "$EXIT_USAGE"
5070
+ fi
5071
+ hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
5072
+
5073
+ if [ "$review" -eq 1 ]; then
5074
+ control_review_intake || return 0
5075
+ else
5076
+ control_comment_intake || return 0
5077
+ fi
5078
+
5079
+ case "$CONTROL_NUMBER" in
5080
+ ''|*[!0-9]*|0*)
5081
+ echo "remote-run.sh: control: the event carries no issue number" >&2
5082
+ exit "$EXIT_USAGE" ;;
5083
+ esac
5084
+
5085
+ control_tmp="${RUNNER_TEMP-}"
5086
+ if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5087
+ control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
5088
+ control_dirs="$control_tmp"
5089
+ fi
5090
+ trap control_cleanup EXIT
5091
+
5092
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5093
+ control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
5094
+ "Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
5095
+ fi
5096
+
5097
+ forge=$(hr_forge "$root") || forge=""
5098
+ target=$(hr_execution_target "$root") || target=""
5099
+ if [ "$forge" != github ] || [ "$target" != github-actions ]; then
5100
+ control_refuse "$EXIT_REFUSED" "the default branch's \`harness.config.json\` does not turn run control on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})" \
5101
+ "Set both keys on the default branch, then comment again."
5102
+ fi
5103
+
5104
+ status=0
5105
+ authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
5106
+ if [ "$status" -ne 0 ]; then
5107
+ control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
5108
+ "Only a collaborator with write, maintain or admin access, or a bot listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
5109
+ fi
5110
+
5111
+ if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
5112
+ for v in $COMMAND_VERBS; do
5113
+ [ "$v" != answer ] || v="answer [<n>]"
5114
+ verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
5115
+ done
5116
+ control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" \
5117
+ "The commands are $verbs; \`docs/github-run-control.md\` in the harness documentation states each."
5118
+ fi
5119
+
5120
+ forge_repo_var || control_reply "$EXIT_GH" "@$CONTROL_ACTOR: \`$CONTROL_VERB\` was not run: the repository's name could not be read."
5121
+ if [ "$review" -eq 1 ]; then
5122
+ control_check_branch "$REVIEW_HEAD"
5123
+ elif [ -n "$CONTROL_IS_PR" ]; then
5124
+ control_branch_from_pr "$CONTROL_NUMBER"
5125
+ else
5126
+ control_branch_from_issue "$CONTROL_NUMBER"
5127
+ fi
5128
+
5129
+ echo "remote-run.sh: control: $CONTROL_VERB on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
5130
+ case "$CONTROL_VERB" in
5131
+ answer) control_answer ;;
5132
+ pause) control_pause ;;
5133
+ stop) control_stop ;;
5134
+ resume) control_resume ;;
5135
+ clear) control_clear ;;
5136
+ review) control_review ;;
5137
+ esac
5138
+ }
5139
+
5140
+ # ---------------------------------------------------------------------------
5141
+ # `collect` — the run workflow's last job: the next round from the reviews
5142
+ # collected while the run was in flight. It reuses `control`'s settledness read,
5143
+ # child runner and cleanup, with CONTROL_BRANCH set to the branch.
5144
+ # ---------------------------------------------------------------------------
5145
+
5146
+ # collect_notify <text> — the one comment `collect` posts, on FORGE_PR with the
5147
+ # `reply` marker; a failure is one line.
5148
+ collect_notify() {
5149
+ local file
5150
+ if ! file=$(mktemp "$control_tmp/harness-collect-comment.XXXXXX"); then
5151
+ echo "remote-run.sh: collect: cannot create the comment file for #$FORGE_PR; no comment posted" >&2
5152
+ return 0
5153
+ fi
5154
+ {
5155
+ printf '%s\n' "$1"
5156
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
5157
+ } >"$file"
5158
+ forge_comment "$FORGE_PR" reply "$branch" "$file" || :
5159
+ rm -f "$file"
5160
+ }
5161
+
5162
+ verb_collect() {
5163
+ local status=0 dir file out
5164
+ if ! forge_on; then
5165
+ echo "remote-run.sh: collect: the forge coupling is off (forge github and execution.target github-actions); nothing collected"
5166
+ exit "$EXIT_OK"
5167
+ fi
5168
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
5169
+ echo "remote-run.sh: collect: HARNESS_REMOTE_STOP is set; no round started"
5170
+ exit "$EXIT_OK"
5171
+ fi
5172
+ if ! hr_have_jq; then
5173
+ echo "remote-run.sh: collect: jq is missing; nothing collected"
5174
+ exit "$EXIT_OK"
5175
+ fi
5176
+ forge_repo_var || exit "$EXIT_OK"
5177
+
5178
+ remote_branch_stopped "$branch" || status=$?
5179
+ case "$status" in
5180
+ 0)
5181
+ echo "remote-run.sh: collect: $branch is stopped; no round started"
5182
+ exit "$EXIT_OK" ;;
5183
+ 2)
5184
+ echo "remote-run.sh: collect: whether $branch is stopped could not be read ($GH_ERR); no round started"
5185
+ exit "$EXIT_OK" ;;
5186
+ esac
5187
+
5188
+ if [ -n "$pr_arg" ]; then
5189
+ FORGE_PR="$pr_arg"
5190
+ elif ! forge_pr_var "$branch"; then
5191
+ echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
5192
+ exit "$EXIT_OK"
5193
+ fi
5194
+ if [ -z "$FORGE_PR" ]; then
5195
+ echo "remote-run.sh: collect: no open pull request; nothing to collect"
5196
+ exit "$EXIT_OK"
5197
+ fi
5198
+
5199
+ control_tmp="${RUNNER_TEMP-}"
5200
+ if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5201
+ control_tmp=$(mktemp -d) || { echo "remote-run.sh: collect: mktemp failed; nothing collected"; exit "$EXIT_OK"; }
5202
+ control_dirs="$control_tmp"
5203
+ fi
5204
+ trap control_cleanup EXIT
5205
+
5206
+ CONTROL_BRANCH="$branch"
5207
+ if ! control_settled_var; then
5208
+ echo "remote-run.sh: collect: the state of the run on $branch could not be read ($BS_ERR); no round started"
5209
+ exit "$EXIT_OK"
5210
+ fi
5211
+ if [ "$BS_SETTLED" != 1 ]; then
5212
+ echo "remote-run.sh: collect: $branch is ${BS_STATE:-in flight}${BS_REASON:+ ($BS_REASON)}; that run's own end collects"
5213
+ exit "$EXIT_OK"
5214
+ fi
5215
+
5216
+ forge_fetch_branch "$branch"
5217
+ if ! dir=$(mktemp -d "$control_tmp/harness-collect.XXXXXX"); then
5218
+ echo "::warning::remote-run.sh: collect: a round directory could not be created under '$control_tmp'; no round started"
5219
+ exit "$EXIT_OK"
5220
+ fi
5221
+ control_dirs="$control_dirs $dir"
5222
+ file="$dir/review.md"
5223
+ RC_EVENT=""
5224
+ status=0
5225
+ round_collect "$FORGE_PR" "$file" || status=$?
5226
+ case "$status" in
5227
+ 0) ;;
5228
+ 1)
5229
+ echo "remote-run.sh: collect: no review requesting changes is pending on #$FORGE_PR since the previous round of $branch; no round started"
5230
+ exit "$EXIT_OK" ;;
5231
+ *)
5232
+ echo "::warning::remote-run.sh: collect: the round of $branch could not be collected: $RC_ERR; no round started"
5233
+ exit "$EXIT_OK" ;;
5234
+ esac
5235
+
5236
+ out="$dir/review.out"
5237
+ set -- review "$branch" --review-file "$file" --allow-no-run --reviewers "$RC_REVIEWERS"
5238
+ case "$FORGE_SERVER" in
5239
+ https://*) set -- "$@" --source "$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR" ;;
5240
+ esac
5241
+ control_child "$out" "$@" --repo "$root"
5242
+ cat "$out" 2>/dev/null || :
5243
+ if [ "$CHILD_STATUS" -eq 0 ]; then
5244
+ echo "remote-run.sh: collect: started the next round of $branch from @${RC_REVIEWERS//,/, @}"
5245
+ exit "$EXIT_OK"
5246
+ fi
5247
+ collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request; submit a review requesting changes to retry."
5248
+ echo "remote-run.sh: collect: review exited $CHILD_STATUS; the pull request was told, and nothing is retried"
5249
+ exit "$EXIT_OK"
5250
+ }
5251
+
5252
+ # ---------------------------------------------------------------------------
5253
+ # `discard` — remove a directory a command fetched into, inside scratch only.
5254
+ # ---------------------------------------------------------------------------
5255
+
5256
+ # The removal lives here rather than in the command because a supervised or
5257
+ # auto-mode session may refuse a recursive `rm` the agent types, and a
5258
+ # user-level `rm -rf` deny cannot be overridden (`.claude/context/
5259
+ # conventions.md` -> `## Shell assets`). The scope is the scratch directory
5260
+ # only, per the lessons ledger's rule that a script "never removes one it did
5261
+ # not create": scratch holds only throwaway files a session itself wrote. Containment is `hr_scratch_path_var`'s alone; this verb
5262
+ # maps its status and acts on `HR_SCRATCH_TARGET`. It never creates anything.
5263
+ verb_discard() {
5264
+ local status
5265
+ hr_scratch_path_var "$root" "$discard_dir" "$discard_base"
5266
+ status=$?
5267
+ case "$status:$HR_SCRATCH_WHY" in
5268
+ 0:*) ;;
5269
+ 1:dotdot)
5270
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' carries '..'" >&2
5271
+ exit "$EXIT_REFUSED" ;;
5272
+ 1:charset)
5273
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' carries a character a scratch path may not" >&2
5274
+ exit "$EXIT_REFUSED" ;;
5275
+ 1:itself)
5276
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is the scratch directory itself" >&2
5277
+ exit "$EXIT_REFUSED" ;;
5278
+ 1:symlink)
5279
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is a symlink" >&2
5280
+ exit "$EXIT_REFUSED" ;;
5281
+ 1:*)
5282
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is not inside '$HR_SCRATCH_DIR/'" >&2
5283
+ exit "$EXIT_REFUSED" ;;
5284
+ 2:*)
5285
+ echo "remote-run.sh: discard: the parent directory of '$discard_dir' cannot be resolved; nothing removed" >&2
5286
+ exit "$EXIT_USAGE" ;;
5287
+ 3:no-scratch)
5288
+ echo "remote-run.sh: discard: the state directory's scratch/ under '$root' does not exist; nothing removed" >&2
5289
+ exit "$EXIT_USAGE" ;;
5290
+ 3:*)
5291
+ echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
5292
+ exit "$EXIT_USAGE" ;;
5293
+ *)
5294
+ usage "discard needs a <dir>" ;;
5295
+ esac
5296
+ if [ ! -e "$HR_SCRATCH_TARGET" ]; then
5297
+ echo "remote-run.sh: $discard_dir does not exist; nothing removed"
5298
+ return 0
5299
+ fi
5300
+ if [ ! -d "$HR_SCRATCH_TARGET" ]; then
5301
+ echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is not a directory" >&2
5302
+ exit "$EXIT_REFUSED"
5303
+ fi
5304
+ if ! rm -rf -- "$HR_SCRATCH_TARGET" || [ -e "$HR_SCRATCH_TARGET" ]; then
5305
+ echo "remote-run.sh: discard: removing '$discard_dir' failed" >&2
5306
+ exit "$EXIT_USAGE"
5307
+ fi
5308
+ echo "remote-run.sh: removed $discard_dir"
2847
5309
  }
2848
5310
 
2849
5311
  case "$verb" in
@@ -2864,5 +5326,10 @@ case "$verb" in
2864
5326
  review) verb_review ;;
2865
5327
  trigger) verb_trigger ;;
2866
5328
  list) verb_list ;;
5329
+ discard) verb_discard ;;
5330
+ report) verb_report ;;
5331
+ deliver) verb_deliver ;;
5332
+ collect) verb_collect ;;
5333
+ control) verb_control ;;
2867
5334
  esac
2868
5335
  exit "$EXIT_OK"