autonomous-sdlc-harness 0.6.3 → 0.6.4

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.
@@ -311,8 +311,10 @@
311
311
  # stays open; otherwise it says the draft stays open, to close to discard the
312
312
  # run, or, when an issue is known, to re-apply the trigger label there for a
313
313
  # new run on the next indexed branch. A plain `stopped` on a pull request says
314
- # its draft stays open and closing it discards the run. No other event changes
315
- # a pull request's draft state.
314
+ # its draft stays open and closing it discards the run. A `stopped` report also
315
+ # rewrites the progress comment of each pull request it labels, its
316
+ # `in progress` line becoming `stopped` (`forge_progress_stopped`). No other
317
+ # event changes a pull request's draft state.
316
318
  # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round`,
317
319
  # `failed` and `not_started` — posts nothing and sets no label when `remote_branch_stopped`,
318
320
  # asked afresh, finds the branch stopped, so a job a stop overtook never
@@ -334,8 +336,11 @@
334
336
  # `COMMAND_HANDLE answer <n>` over `<your answer>`; the marker adds
335
337
  # `question=<n>`. With no question open it posts nothing, sets no label and
336
338
  # prints one `::error::` line naming the branch. The label
337
- # is set once per target, not per question. On a public repository a question
338
- # comment and its answer are public, as the artifact already is
339
+ # is set once per target, not per question. When the registry's `pause_reason`
340
+ # is `user` (a pause the job dropped and the run never honoured), `parked`
341
+ # appends `PAUSE_FOLDED_NOTE` to <note> and `park_loop` appends
342
+ # `PAUSE_FOLDED_HOLD_NOTE`, each after one blank line. On a public repository a
343
+ # question comment and its answer are public, as the artifact already is
339
344
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
340
345
  # repository's Actions runs can see*).
341
346
  # `progress` (`forge_progress`) keeps ONE comment per run or round on the pull
@@ -356,7 +361,10 @@
356
361
  # is one `forge_comment`; a body equal to the render (carriage returns and
357
362
  # trailing newlines aside) is no call and one `already current` line; otherwise
358
363
  # one `PATCH` of that comment — the only comment this file ever edits. A refused
359
- # listing, create or edit is one `::warning::` line.
364
+ # listing, create or edit is one `::warning::` line. After a stop,
365
+ # `forge_progress_stopped` edits that same comment, so it is still the only
366
+ # comment this file edits, and a resumed job's first progress pass renders it
367
+ # from the ledger again.
360
368
  # It never fails its caller: every problem is one line and exit 0.
361
369
  #
362
370
  # `open` OPENS THE RUN'S DRAFT PULL REQUEST at the run's start, so its issue
@@ -504,12 +512,17 @@
504
512
  # into a variable (data, never shell source), plus `trigger`'s environment.
505
513
  # Ignored, with one line and no `gh` call: an action other than `created`; a
506
514
  # body carrying `COMMENT_MARKER` anywhere (the harness's own comment, whoever
507
- # posted it); and a body whose first line — a trailing CR stripped, leading
508
- # spaces and tabs skipped — does not open with a word equal to
509
- # `COMMAND_HANDLE`, compared lowercase. So `pause`, `Let's @sdlc-harness
510
- # pause` and `> @sdlc-harness pause` start nothing. The verb is the next word,
511
- # lowercased, and `CONTROL_ARGS` the rest of that line. Then refused, in this
512
- # order, each a reply and exit 2:
515
+ # posted it); and a body that does not hold `COMMAND_HANDLE` as a word
516
+ # anywhere, compared lowercase (the ERE `(^|[^a-z0-9])<handle>([^a-z0-9-]|$)`).
517
+ # So `pause` and `foo@sdlc-harnessx` start nothing. THE EXACT FORM is a first
518
+ # line — a trailing CR stripped, leading spaces and tabs skipped — opening with
519
+ # a word equal to `COMMAND_HANDLE`, compared lowercase, whose next word,
520
+ # lowercased, is a `COMMAND_VERBS` word: that is the verb, and `CONTROL_ARGS`
521
+ # the rest of that line. Any other body holding the handle is A MENTION
522
+ # (`CONTROL_MENTION`, no verb): `Let's @sdlc-harness pause`, `> @sdlc-harness
523
+ # pause`, the handle on a later line, and `@sdlc-harness check the question`
524
+ # alike, each read by MENTION below. Then refused, in this order, each a reply
525
+ # and exit 2:
513
526
  # 1. `HARNESS_REMOTE_STOP` is set
514
527
  # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
515
528
  # GitHub nothing about the commenter
@@ -517,8 +530,89 @@
517
530
  # refuses, before the event's own actor is checked
518
531
  # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run — a
519
532
  # writer the `HARNESS_RUN_ACTORS` allow-list admits, or a listed bot
520
- # 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
521
- # yet: the reply lists every command and names `docs/github-run-control.md`
533
+ # 4. the exact form's verb is one no arm carries out (`control_verb_handled`;
534
+ # no such word exists today): the reply lists every command and names
535
+ # `docs/github-run-control.md`. Skipped for a mention.
536
+ # Then THE BRANCH, and the exact form's arm (`control_run_verb`) or MENTION.
537
+ # MENTION. `verb_control`'s first statements, for every event, copy `IN_OAUTH`
538
+ # / `IN_API` into the non-exported `MENTION_OAUTH` / `MENTION_API` and unset
539
+ # them, with `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`, so no `gh`, `git`
540
+ # or `control_child` process the job spawns inherits a credential; only the
541
+ # agent subshell receives it. After the gates and the branch, `control_mention`
542
+ # reads the state (`control_state_var`; a failed read is exit 3), then refuses
543
+ # with no session: no saved credential (exit 2, the reply listing every
544
+ # command); no `commands/<basename of MENTION_COMMAND>.md` under
545
+ # `HARNESS_MENTION_PLUGIN_DIR` (exit 3, naming `HARNESS_CLI_VERSION` in
546
+ # `harness-run.yml` and `docs/remote-execution.md` -> `### Upgrading`); and an
547
+ # agent binary, `${HARNESS_AGENT_CLI:-claude}`, that `command -v` does not
548
+ # resolve (exit 3). The plugin directory is a path only: nothing under it is
549
+ # sourced or run by this script. THE CONTEXT DIRECTORY, a fresh one under
550
+ # `RUNNER_TEMP` (removed on exit): `comment.md` (`handle: <COMMAND_HANDLE>`,
551
+ # `Comment by @<login> on <issue|pull request> #<n>:`, a blank line, the body
552
+ # verbatim), `run.md` (`branch:`, the `fetch` keys, `stopped: yes|no`, the next
553
+ # ledger entry and its section, or that every entry is ticked or the ledger
554
+ # could not be read), `questions/question_<n>.md` per open question,
555
+ # `item.md` (`kind:`, `number:`, `title:`, `author:`, `url:`, a blank line, the
556
+ # body, from `api repos/<repo>/issues/<n>`), `conversation.md` (the last
557
+ # `MENTION_COMMENTS_MAX` comments before the commenter's own, oldest first,
558
+ # each `### @<login> at <at>`, `(posted by the harness)` when it carries
559
+ # `COMMENT_MARKER`, then its body, from the paginated comment listing) and, on
560
+ # a pull request, `diff.patch` (`pr diff`, text only: nothing is checked out),
561
+ # each capped at `MENTION_FILE_MAX_BYTES` at a whole line with a `(cut at <n>
562
+ # bytes)` line; a failed read is said in the file, never refused. THE SESSION runs once, never retried, in a subshell `cd` into
563
+ # that directory with `GH_TOKEN`, `GITHUB_TOKEN` and `HARNESS_PR_TOKEN` unset
564
+ # and `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` exported from whichever
565
+ # saved value is set: `-p "$MENTION_COMMAND"` (no argument, so no comment text
566
+ # reaches argv) `--plugin-dir <dir> --add-dir <dir>/instructions
567
+ # --output-format json --json-schema <built by jq from MENTION_ACTIONS and
568
+ # COMMAND_VERBS> --tools Read,Grep,Glob --restricted --strict-mcp-config
569
+ # --no-session-persistence --permission-prompts none --model <agentModel>
570
+ # --max-budget-usd "$MENTION_MAX_BUDGET_USD"`, the model read through
571
+ # `hr_agent_model` (a command declares no `model:`) and the flag left off only
572
+ # when the configuration cannot be read; never `--bare`, which reads no
573
+ # OAuth token, and never `--disable-slash-commands`, which would stop the `-p`
574
+ # command expanding. The command is `plugin/commands/harness-read-mention.md`
575
+ # (`MENTION_COMMAND`), and it loads `plugin/instructions/mention_reading.md`,
576
+ # whose `## Output contract` restates the decision contract validated here: a
577
+ # field renamed on either side is an edit to both. A command declares no
578
+ # allowlist, so the read-only closure is the session's `--tools` and
579
+ # `--restricted` alone. EXTRACTION: a non-zero exit, stdout that is not one
580
+ # JSON object, `.is_error` true or a `.subtype` other than `success` is a
581
+ # failure, exit 3; the decision is `.structured_output` when an object, else
582
+ # `.result | fromjson?` when an object, and one stdout line logs it (`mention
583
+ # on #<n> by @<login> read as <action>[ <verb>] from <field>: <reason>`).
584
+ # VALIDATION, by `jq`, the sole authority whatever the schema did: `action` in
585
+ # `MENTION_ACTIONS`; `command` needs a `verb` in `COMMAND_VERBS`; `answer`
586
+ # needs a non-blank `answer` and an optional integer `question` of 1 or more;
587
+ # `reply` and `clarify` need a non-blank `text`; `reason` is a string. Unknown
588
+ # keys are ignored; an invalid decision is refused, exit 2, naming the first
589
+ # rule broken. Then, once and before any action is answered, a `text` or
590
+ # `answer` holding a saved credential value, or the job's `GH_TOKEN` raw or in
591
+ # the base64 form `actions/checkout` persists, verbatim, is an `::error::` line
592
+ # that never prints it, nothing posted, exit 3. THE ANSWERS, each through `control_post`:
593
+ # `none` posts nothing; `reply` / `clarify` post `@<login>: <text>` — `text`
594
+ # through `JQ_DEF_SANITISE` (`<!--` neutralised, every other `@<login>` but the
595
+ # commenter's and the handle given U+200B) and capped at
596
+ # `MENTION_TEXT_MAX_BYTES` with a `(cut)` line — then a footer saying an agent
597
+ # wrote it and changed nothing, listing the commands; `fixes` posts this
598
+ # script's text pointing at a review requesting changes or
599
+ # `/autonomous-sdlc-harness:branch-user-review`; `command` with a verb in
600
+ # `MENTION_ACT_VERBS` is carried out by that verb's own arm (`control_run_verb`)
601
+ # from the exact form's `CONTROL_ARGS` and `CONTROL_BODY` — for `answer`, the
602
+ # command line then the decision's `answer` below it, byte for byte — every
603
+ # reply the arm posts opening with `CONTROL_MENTION_NOTE`, `Read from your
604
+ # mention as` and that form, `answer` adding the answer fenced by
605
+ # `JQ_DEF_FENCE` after `JQ_DEF_SANITISE`; an `answer` carrying a credential
606
+ # value was refused above, so it reaches neither the answer file, the dispatch
607
+ # nor the note; a state the arm refuses is that arm's refusal and exit,
608
+ # unchanged. `command` with a verb in `MENTION_CONFIRM_VERBS` posts a
609
+ # confirmation request naming the exact form it was read as and carries out no
610
+ # verb: `stop` because it is destructive, `clear` because on GitHub typing it
611
+ # is `branch-resume`'s confirmation. Exits: 0 answered or nothing to do; 2
612
+ # refused; 3 a state read, plugin, binary or agent failure, or a credential
613
+ # value in `text` or `answer`; a carried-out verb, its arm's. The workflow's
614
+ # interface: `IN_OAUTH` / `IN_API` and
615
+ # `HARNESS_MENTION_PLUGIN_DIR`.
522
616
  # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
523
617
  # `pr view`: a fork's pull request is refused, because this event carries the
524
618
  # repository's secrets, and nothing from its head is checked out or run; one
@@ -717,7 +811,8 @@
717
811
  # 5. `hr_branch_is_protected` does not answer 1
718
812
  # 6. on `pull_request`, the head branch absent on origin (`remote_branch_exists`
719
813
  # answers 1): GitHub closed the pull request because the branch was
720
- # deleted, and the `delete` event's job stops the run; an `ls-remote` that
814
+ # deleted, and the `delete` event's job stops the run and reports it on
815
+ # this pull request; an `ls-remote` that
721
816
  # cannot answer is one line and proceeds
722
817
  # There is no harness-branch check (`forge_recognised`): a merged or deleted
723
818
  # branch may no longer carry its task prompt or ledger, and a listed `harness run <b>` run is the
@@ -1064,7 +1159,11 @@
1064
1159
  # task prompt at the newest `harness run <branch>` run's `headSha` through the
1065
1160
  # contents API rather than from `origin/<branch>` (a failed read is one line
1066
1161
  # and no issue), and its text says the branch was deleted, so the run cannot
1067
- # be resumed, and that its workflow runs and artifacts are kept. That read
1162
+ # be resumed, and that its workflow runs and artifacts are kept. (4) also
1163
+ # reports on each pull request of the branch from this repository that is not
1164
+ # merged and still carries `running`, `parked` or `paused`: each gets the same
1165
+ # comment, the `stopped` label and its progress comment marked stopped
1166
+ # (`forge_gone_prs_var`, `forge_progress_stopped`). The issue read
1068
1167
  # rests on GitHub serving a commit no branch points at, which is unverified
1069
1168
  # (`docs/github-run-control.md` -> `## 8. What is not verified here`).
1070
1169
  #
@@ -1077,7 +1176,8 @@
1077
1176
  # repository from that checkout's remote. The configuration read is that
1078
1177
  # root's `harness.config.json`.
1079
1178
  #
1080
- # WHAT IT NEVER DOES. It never launches a local session, never writes the
1179
+ # WHAT IT NEVER DOES. It never launches a local session but `control`'s one
1180
+ # read-only mention session (MENTION, in `control`'s paragraph), never writes the
1081
1181
  # inbox, and never watches a run it sent beyond the bounded lookup of its
1082
1182
  # listing that `trigger` and `review` make. Only `start` and `review` push, and
1083
1183
  # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
@@ -1103,8 +1203,9 @@
1103
1203
  # comment files under `RUNNER_TEMP` (removed), at most one pull request, its
1104
1204
  # `running` label and one comment on the issue. `deliver` writes its body and
1105
1205
  # comment files under `RUNNER_TEMP` (removed), at most one pull request, one
1106
- # comment and the state labels. `control` writes its reply file and its
1107
- # `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
1206
+ # comment and the state labels. `control` writes its reply file, its `fetch`
1207
+ # directory and, for a mention, its context directory and the session's output
1208
+ # files under `RUNNER_TEMP` (removed) and one reply comment, plus
1108
1209
  # what the child verb it runs writes, and the fetch of origin's <branch> that
1109
1210
  # force-writes `refs/remotes/origin/<branch>` in its checkout. `collect` writes its round file and its
1110
1211
  # settledness directory under `RUNNER_TEMP` (removed), at most one comment, on
@@ -1148,6 +1249,10 @@
1148
1249
  # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
1149
1250
  # COMMAND_HANDLE mirrors COMMAND_HANDLE
1150
1251
  # COMMAND_VERBS mirrors COMMAND_VERBS, space-separated
1252
+ # MENTION_ACTIONS mirrors MENTION_ACTIONS, space-separated, same order
1253
+ # MENTION_ACT_VERBS mirrors MENTION_ACT_VERBS, space-separated, same order
1254
+ # MENTION_CONFIRM_VERBS mirrors MENTION_CONFIRM_VERBS, space-separated, same order
1255
+ # MENTION_COMMAND mirrors MENTION_COMMAND, the plugin-qualified slash command
1151
1256
  # COMMENT_MARKER mirrors COMMENT_MARKER
1152
1257
  # REVIEW_ROUND_STATE mirrors REVIEW_ROUND_STATE
1153
1258
  # STATE_LABEL_PREFIX mirrors STATE_LABEL_PREFIX
@@ -1421,8 +1526,20 @@
1421
1526
  # pause bash scripts/remote-run.sh control -> 0; "$s.log" gains `workflow
1422
1527
  # run harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1423
1528
  # then one comment on 12 naming @alice
1424
- # ignored c.json's body `Let's @sdlc-harness pause` -> 0, one line,
1425
- # "$s.log" unchanged
1529
+ # ignored c.json's body `pause` -> 0, one line, "$s.log" unchanged
1530
+ # mention c.json's body `Let's @sdlc-harness pause`, with
1531
+ # HARNESS_MENTION_PLUGIN_DIR=<a throwaway directory holding an
1532
+ # empty commands/harness-read-mention.md>, IN_OAUTH=x and
1533
+ # HARNESS_AGENT_CLI=<a stub printing {"type":"result",
1534
+ # "subtype":"success","is_error":false,"structured_output":
1535
+ # {"action":"reply","text":"It is running.","reason":"r"}}>
1536
+ # -> 0; "$s.log" gains one comment on 12 opening `@alice: It is
1537
+ # running.`, and no `workflow run`
1538
+ # act the same with the stub's structured_output {"action":"command",
1539
+ # "verb":"pause","reason":"r"} -> 0; "$s.log" gains `workflow run
1540
+ # harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1541
+ # then one comment on 12 opening `Read from your mention as
1542
+ # `@sdlc-harness pause`.`
1426
1543
 
1427
1544
  set -u
1428
1545
 
@@ -1445,6 +1562,10 @@ TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1445
1562
  WORKFLOW_CONTROL_FILE='harness-control.yml'
1446
1563
  COMMAND_HANDLE='@sdlc-harness'
1447
1564
  COMMAND_VERBS='answer pause resume stop clear status'
1565
+ MENTION_ACTIONS='command reply clarify fixes none'
1566
+ MENTION_ACT_VERBS='answer pause resume status'
1567
+ MENTION_CONFIRM_VERBS='stop clear'
1568
+ MENTION_COMMAND='/autonomous-sdlc-harness:harness-read-mention'
1448
1569
  COMMENT_MARKER='<!-- sdlc-harness'
1449
1570
  REVIEW_ROUND_STATE='changes_requested'
1450
1571
  STATE_LABEL_PREFIX='sdlc-harness: '
@@ -1456,6 +1577,18 @@ PR_CREATE_SETTING_PATH='Settings -> Actions -> General -> Workflow permissions'
1456
1577
  # to be trusted (docs/github-integration-research.md -> S6); the margin is the
1457
1578
  # framing lines and the marker.
1458
1579
  QUESTION_COMMENT_MAX_BYTES=250000
1580
+ # The most bytes of each file in a mention's context directory, and of the
1581
+ # agent-written text a mention reply posts (under QUESTION_COMMENT_MAX_BYTES
1582
+ # with room for the prefix, the footer and the marker).
1583
+ MENTION_FILE_MAX_BYTES=200000
1584
+ MENTION_TEXT_MAX_BYTES=60000
1585
+ # How many of the comments before a mention its context's conversation.md keeps.
1586
+ MENTION_COMMENTS_MAX=30
1587
+ # The mention session's spend bound: a bound on one read, not a measured cost.
1588
+ MENTION_MAX_BUDGET_USD=1
1589
+ # Name no login: the job sees only the `harness pause` run, whose actor is the bot.
1590
+ PAUSE_FOLDED_NOTE='A pause was requested on this run before it parked, so it is folded into this park: the run waits for the answer and continues once it is answered, and no separate `paused` comment follows.'
1591
+ PAUSE_FOLDED_HOLD_NOTE='A pause was requested on this run before it was put on hold, so it is folded into this hold: the run waits for the hold to be cleared and continues once it is, and no separate `paused` comment follows.'
1459
1592
  GH="${HARNESS_GH_CLI:-gh}"
1460
1593
 
1461
1594
  # How many runs `status` prints, and how many `run list` returns for status
@@ -2674,6 +2807,23 @@ ANSWERS_SHAPE='env.HARNESS_INPUT_ANSWERS | fromjson
2674
2807
  and all(to_entries[]; (.value | type) == "string"
2675
2808
  and (.key | explode | length > 0 and .[0] != 48 and all(.[]; . >= 48 and . <= 57)))'
2676
2809
 
2810
+ # sanitise($login; $handle), string to string, for agent-written text a reply
2811
+ # quotes: no `<!--` survives to read as a harness marker, and every `@<login>`
2812
+ # but the commenter's and the handle gets U+200B after the `@`, so it notifies
2813
+ # nobody.
2814
+ JQ_DEF_SANITISE='def sanitise($login; $handle):
2815
+ gsub("<!--"; "&lt;!--")
2816
+ | gsub("@(?<l>[A-Za-z0-9][A-Za-z0-9-]*)";
2817
+ if (.l | ascii_downcase) == ($login | ascii_downcase)
2818
+ or (("@" + .l) | ascii_downcase) == ($handle | ascii_downcase)
2819
+ then "@" + .l else "@​" + .l end);'
2820
+
2821
+ # fence, string to string: the code fence that quotes the input — one backtick
2822
+ # longer than its longest backtick run, at least three.
2823
+ JQ_DEF_FENCE='def fence:
2824
+ (([match("`+"; "g") | .length] | max) // 0) as $m
2825
+ | "`" * ([$m + 1, 3] | max);'
2826
+
2677
2827
  verb_restore() {
2678
2828
  local id download status_file clar n tmp status_source
2679
2829
  if [ "$resume" = answer ]; then
@@ -4415,6 +4565,35 @@ forge_pr_var() {
4415
4565
  return 0
4416
4566
  }
4417
4567
 
4568
+ # forge_gone_prs_var <branch> — FORGE_GONE_PRS, the space-separated numbers of
4569
+ # the pull requests of <branch>, in any state, from this repository, not merged
4570
+ # and still labelled `STATE_LABEL_PREFIX` running, parked or paused: the states
4571
+ # control_close stops, so a pull request an earlier stop or a merge settled is
4572
+ # left out. Stands in for forge_recognised, which cannot read a deleted branch.
4573
+ # A failed listing, or one not the expected JSON, is one line and an empty list.
4574
+ # Always 0.
4575
+ FORGE_GONE_PRS=""
4576
+ forge_gone_prs_var() {
4577
+ local sel
4578
+ FORGE_GONE_PRS=""
4579
+ if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state all --json number,isCrossRepository,mergedAt,labels --limit 10; then
4580
+ echo "remote-run.sh: report: listing the pull requests of $1 in every state failed: $GH_ERR" >&2
4581
+ return 0
4582
+ fi
4583
+ if ! sel=$(printf '%s' "$GH_OUT" | jq -r --arg p "$STATE_LABEL_PREFIX" '
4584
+ if type == "array" then
4585
+ [.[] | select(type == "object" and .isCrossRepository == false and .mergedAt == null
4586
+ and (.number | type) == "number"
4587
+ and ([.labels[]? | .name? | strings] | any(. == ($p + "running") or . == ($p + "parked") or . == ($p + "paused"))))
4588
+ | .number | tostring] | join(" ")
4589
+ else error end' 2>/dev/null); then
4590
+ echo "remote-run.sh: report: listing the pull requests of $1 in every state failed: its pr list is not the expected JSON" >&2
4591
+ return 0
4592
+ fi
4593
+ FORGE_GONE_PRS="$sel"
4594
+ return 0
4595
+ }
4596
+
4418
4597
  # forge_dispatch_engine_var <branch> <created_at_iso> — FORGE_DISPATCH_ENGINE,
4419
4598
  # the engine the dispatch that created the run at <created_at_iso> recorded in
4420
4599
  # its comment on the branch's issue or open pull request, or empty. Only a
@@ -4589,10 +4768,12 @@ forge_question_body() {
4589
4768
  # comment (on `parked`, one per open question) and the state label, by the
4590
4769
  # target rule above. Read on `stopped` only: <pr>, an explicit pull request
4591
4770
  # that is the target whatever its state; `gone`, the branch deleted on GitHub,
4592
- # its issue read from the task prompt at <sha>. Every event but `stopped` is
4593
- # withheld when the branch's newest `harness stop` run is newer than its newest
4594
- # `harness run` run, read from a fresh listing. `not_started` reads
4595
- # REPORT_NOT_STARTED_STATE (`paused`, else `failed`) and
4771
+ # its issue read from the task prompt at <sha>, and then each pull request
4772
+ # forge_gone_prs_var lists given the same comment, the label and its progress
4773
+ # comment marked stopped; nothing is posted only when neither is known. Every
4774
+ # event but `stopped` is withheld when the branch's newest `harness stop` run is
4775
+ # newer than its newest `harness run` run, read from a fresh listing.
4776
+ # `not_started` reads REPORT_NOT_STARTED_STATE (`paused`, else `failed`) and
4596
4777
  # REPORT_NOT_STARTED_ENGINE (empty or the recovered engine), set by its caller.
4597
4778
  # Always 0.
4598
4779
  REPORT_NOT_STARTED_STATE=""
@@ -4600,6 +4781,7 @@ REPORT_NOT_STARTED_ENGINE=""
4600
4781
  forge_report() {
4601
4782
  local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
4602
4783
  local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n route engine=""
4784
+ local gone_prs="" reported="" folded=""
4603
4785
  case "$event" in
4604
4786
  parked|park_loop) state=parked ;;
4605
4787
  paused) state=paused ;;
@@ -4648,8 +4830,11 @@ forge_report() {
4648
4830
  if [ -n "$pr" ]; then
4649
4831
  FORGE_PR="$pr"
4650
4832
  elif [ -n "$gone" ]; then
4651
- # GitHub closes a pull request whose head is deleted, so none is open.
4833
+ # No pull request of a deleted head is open, so the run's unfinished ones
4834
+ # are found in every state by forge_gone_prs_var.
4652
4835
  FORGE_PR=""
4836
+ forge_gone_prs_var "$br"
4837
+ gone_prs="$FORGE_GONE_PRS"
4653
4838
  else
4654
4839
  forge_pr_var "$br" || FORGE_PR=""
4655
4840
  fi
@@ -4661,6 +4846,8 @@ forge_report() {
4661
4846
  target="$FORGE_PR"; kind=pr
4662
4847
  elif [ -n "$FORGE_ISSUE" ]; then
4663
4848
  target="$FORGE_ISSUE"; kind=issue
4849
+ elif [ -n "$gone_prs" ]; then
4850
+ target=""; kind=pr
4664
4851
  else
4665
4852
  echo "remote-run.sh: report: $br has no open pull request and no issue it was started from; nothing posted"
4666
4853
  return 0
@@ -4673,6 +4860,14 @@ forge_report() {
4673
4860
  resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
4674
4861
  [ "$event" != failed ] || engine=$(hr_registry_get "$registry_file" "$br" engine)
4675
4862
  fi
4863
+ case "$event" in
4864
+ parked|park_loop)
4865
+ if [ "$reason" = user ]; then
4866
+ folded="$PAUSE_FOLDED_NOTE"
4867
+ [ "$event" != park_loop ] || folded="$PAUSE_FOLDED_HOLD_NOTE"
4868
+ if [ -z "$note" ]; then note="$folded"; else note="$note"$'\n\n'"$folded"; fi
4869
+ fi ;;
4870
+ esac
4676
4871
 
4677
4872
  case "$event" in
4678
4873
  paused)
@@ -4758,22 +4953,75 @@ forge_report() {
4758
4953
  echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
4759
4954
  fi
4760
4955
  done
4761
- elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
4762
- {
4763
- printf '%s\n' "$text"
4764
- [ -z "$note" ] || printf '\n%s\n' "$note"
4765
- [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4766
- } >"$file"
4767
- forge_comment "$target" "$event" "$br" "$file" || :
4768
- rm -f "$file"
4769
- else
4770
- echo "remote-run.sh: report: cannot create the comment file for #$target; no comment posted" >&2
4956
+ elif [ -n "$target" ]; then
4957
+ forge_report_text "$target" "$event" "$br" "$tmp" "$text" "$note"
4771
4958
  fi
4772
- [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4773
4959
 
4774
4960
  [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" "$state" || :
4775
4961
  [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" "$state" || :
4776
- echo "remote-run.sh: report: $event on $br reported on #$target"
4962
+ if [ "$event" = stopped ]; then
4963
+ [ -z "$FORGE_PR" ] || forge_progress_stopped "$FORGE_PR" "$br"
4964
+ fi
4965
+ reported="${target:+#$target}"
4966
+ for n in $gone_prs; do
4967
+ forge_report_text "$n" "$event" "$br" "$tmp" "$text" "$note"
4968
+ forge_set_state "$n" "$state" || :
4969
+ forge_progress_stopped "$n" "$br"
4970
+ reported="${reported:+$reported, }#$n"
4971
+ done
4972
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4973
+ echo "remote-run.sh: report: $event on $br reported on $reported"
4974
+ return 0
4975
+ }
4976
+
4977
+ # forge_report_text <number> <event> <branch> <tmp_dir> <text> <note> — post
4978
+ # forge_report's one comment, <text>, <note> and the run URL, on <number>.
4979
+ forge_report_text() {
4980
+ local number="$1" event="$2" br="$3" tmp="$4" text="$5" note="$6" file
4981
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
4982
+ echo "remote-run.sh: report: cannot create the comment file for #$number; no comment posted" >&2
4983
+ return 0
4984
+ fi
4985
+ {
4986
+ printf '%s\n' "$text"
4987
+ [ -z "$note" ] || printf '\n%s\n' "$note"
4988
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4989
+ } >"$file"
4990
+ forge_comment "$number" "$event" "$br" "$file" || :
4991
+ rm -f "$file"
4992
+ }
4993
+
4994
+ # forge_progress_comment_var <pr> <marker> [any-round] — the one lookup of a
4995
+ # progress comment: one paginated listing of <pr>'s comments, then the newest
4996
+ # `github-actions[bot]` comment with a numeric id whose last non-empty line is
4997
+ # <marker>, or, with `any-round`, <marker> with ` round=<digits>` before its
4998
+ # ` -->`. Sets PROGRESS_COMMENT_ID and PROGRESS_COMMENT_BODY, both empty when
4999
+ # none is picked. 0 when the listing parsed, picked or not; 1 when gh refused it
5000
+ # (GH_ERR as gh_call set it); 2 when it is not the expected JSON. Prints
5001
+ # nothing. No temporary file: forge_progress needs its own before this lookup,
5002
+ # forge_progress_stopped only after it.
5003
+ PROGRESS_COMMENT_ID=""
5004
+ PROGRESS_COMMENT_BODY=""
5005
+ forge_progress_comment_var() {
5006
+ local pr="$1" marker="$2" any="${3-}" pick id body
5007
+ PROGRESS_COMMENT_ID=""
5008
+ PROGRESS_COMMENT_BODY=""
5009
+ gh_call api --paginate "repos/$FORGE_REPO/issues/$pr/comments" --jq '.[] | {id, login: .user.login, body}' || return 1
5010
+ pick=$(printf '%s' "$GH_OUT" | jq -s -c --arg m "$marker" --arg any "$any" '
5011
+ def hit: . == $m
5012
+ or ($any == "any-round"
5013
+ and (($m | sub(" -->$"; "")) as $stem
5014
+ | startswith($stem) and (.[($stem | length):] | test("^ round=[0-9]+ -->$"))));
5015
+ [.[] | select(type == "object" and .login == "github-actions[bot]" and (.id | type) == "number")
5016
+ | select((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "" | hit)]
5017
+ | max_by(.id) // empty
5018
+ | {id, body: (.body // "")}' 2>/dev/null) || return 2
5019
+ [ -n "$pick" ] || return 0
5020
+ id=$(printf '%s' "$pick" | jq -r '.id' 2>/dev/null) || return 2
5021
+ # The sentinel keeps the body's trailing newlines, which $(…) would strip.
5022
+ body=$(printf '%s' "$pick" | jq -j '.body' 2>/dev/null && printf x) || return 2
5023
+ PROGRESS_COMMENT_ID="$id"
5024
+ PROGRESS_COMMENT_BODY="${body%x}"
4777
5025
  return 0
4778
5026
  }
4779
5027
 
@@ -4783,7 +5031,7 @@ forge_report() {
4783
5031
  # issue fallback, no label. Always 0.
4784
5032
  forge_progress() {
4785
5033
  local br="$1" status state_rel phases engine round p line i first=1 marker
4786
- local tmp made_tmp="" file pick id same
5034
+ local tmp made_tmp="" file id same
4787
5035
  local -a labels states
4788
5036
  if ! forge_on; then
4789
5037
  echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
@@ -4856,24 +5104,23 @@ forge_progress() {
4856
5104
  } >"$file"
4857
5105
  { cat "$file"; printf '\n%s\n' "$marker"; } >"$file.full"
4858
5106
 
4859
- if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$FORGE_PR/comments" --jq '.[] | {id, login: .user.login, body}'; then
5107
+ status=0
5108
+ forge_progress_comment_var "$FORGE_PR" "$marker" || status=$?
5109
+ if [ "$status" -eq 1 ]; then
4860
5110
  echo "::warning::remote-run.sh: report: listing the comments of #$FORGE_PR was refused, so the progress is not posted: $GH_ERR"
4861
- elif ! pick=$(printf '%s' "$GH_OUT" | jq -s -r --arg m "$marker" --arg want "$(cat "$file.full")" '
4862
- def norm: gsub("\r"; "") | sub("\n+$"; "");
4863
- [.[] | select(type == "object" and .login == "github-actions[bot]" and (.id | type) == "number")
4864
- | select(((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "") == $m)]
4865
- | max_by(.id) // empty
4866
- | "\(.id) \(if ((.body // "") | norm) == ($want | norm) then "same" else "differs" end)"' 2>/dev/null); then
5111
+ elif [ "$status" -ne 0 ]; then
4867
5112
  echo "::warning::remote-run.sh: report: the comments of #$FORGE_PR are not the expected JSON, so the progress is not posted"
4868
- elif [ -z "$pick" ]; then
5113
+ elif [ -z "$PROGRESS_COMMENT_ID" ]; then
4869
5114
  if forge_comment "$FORGE_PR" progress "$br" "$file" "" "" "$round"; then
4870
5115
  echo "remote-run.sh: report: progress on $br posted on #$FORGE_PR"
4871
5116
  else
4872
5117
  echo "::warning::remote-run.sh: report: posting the progress comment on #$FORGE_PR was refused: $GH_ERR"
4873
5118
  fi
4874
5119
  else
4875
- id="${pick%% *}"
4876
- same="${pick#* }"
5120
+ id="$PROGRESS_COMMENT_ID"
5121
+ same=$(jq -n -r --arg body "$PROGRESS_COMMENT_BODY" --arg want "$(cat "$file.full")" '
5122
+ def norm: gsub("\r"; "") | sub("\n+$"; "");
5123
+ if ($body | norm) == ($want | norm) then "same" else "differs" end' 2>/dev/null) || same=differs
4877
5124
  if [ "$same" = same ]; then
4878
5125
  echo "remote-run.sh: report: the progress comment on #$FORGE_PR is already current"
4879
5126
  elif gh_call api --method PATCH "repos/$FORGE_REPO/issues/comments/$id" -F "body=@$file.full"; then
@@ -4887,6 +5134,58 @@ forge_progress() {
4887
5134
  return 0
4888
5135
  }
4889
5136
 
5137
+ # forge_progress_stopped <pr> <branch> — after a stop, rewrite each
5138
+ # `- <label>: in progress` line of <branch>'s progress comment on <pr>, the task
5139
+ # run's or any round's, to `- <label>: stopped`, leaving every other byte; the
5140
+ # cancelled job's last progress pass is withheld, so nothing else does. Gated by
5141
+ # hr_progress_comments; no comment or no such line is no edit. Always 0.
5142
+ forge_progress_stopped() {
5143
+ local pr="$1" br="$2" status count tmp made_tmp="" file
5144
+ status=0
5145
+ hr_progress_comments "$root" || status=$?
5146
+ case "$status" in
5147
+ 0) ;;
5148
+ 1) echo "remote-run.sh: report: progress comments are off by execution.progressComments; nothing marked stopped"; return 0 ;;
5149
+ *) echo "remote-run.sh: report: execution.progressComments is unreadable; nothing marked stopped"; return 0 ;;
5150
+ esac
5151
+ status=0
5152
+ forge_progress_comment_var "$pr" "$(forge_marker progress "$br")" any-round || status=$?
5153
+ if [ "$status" -eq 1 ]; then
5154
+ echo "::warning::remote-run.sh: report: listing the comments of #$pr was refused, so its progress comment is not marked stopped: $GH_ERR"
5155
+ return 0
5156
+ elif [ "$status" -ne 0 ]; then
5157
+ echo "::warning::remote-run.sh: report: the comments of #$pr are not the expected JSON, so its progress comment is not marked stopped"
5158
+ return 0
5159
+ fi
5160
+ # A trailing carriage return is kept, so a CRLF body is rewritten in its own line endings.
5161
+ count=0
5162
+ [ -z "$PROGRESS_COMMENT_ID" ] \
5163
+ || count=$(jq -n -r --arg b "$PROGRESS_COMMENT_BODY" \
5164
+ '[$b | split("\n")[] | select(test("^- .+: in progress\r?$"))] | length' 2>/dev/null) || count=0
5165
+ if [ "$count" = 0 ]; then
5166
+ echo "remote-run.sh: report: #$pr has no progress comment of $br in progress; nothing to mark stopped"
5167
+ return 0
5168
+ fi
5169
+ tmp="${RUNNER_TEMP-}"
5170
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5171
+ tmp=$(mktemp -d) || tmp=""
5172
+ made_tmp="$tmp"
5173
+ fi
5174
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-progress-stopped.XXXXXX"); then
5175
+ echo "::warning::remote-run.sh: report: cannot create the progress comment file for #$pr; nothing marked stopped"
5176
+ elif ! jq -n -j --arg b "$PROGRESS_COMMENT_BODY" '$b | split("\n")
5177
+ | map(sub("^(?<l>- .+): in progress(?<cr>\r?)$"; "\(.l): stopped\(.cr)")) | join("\n")' >"$file" 2>/dev/null; then
5178
+ echo "::warning::remote-run.sh: report: cannot write the stopped progress comment for #$pr; nothing marked stopped"
5179
+ elif gh_call api --method PATCH "repos/$FORGE_REPO/issues/comments/$PROGRESS_COMMENT_ID" -F "body=@$file"; then
5180
+ echo "remote-run.sh: report: progress of $br marked stopped in comment $PROGRESS_COMMENT_ID on #$pr"
5181
+ else
5182
+ echo "::warning::remote-run.sh: report: editing the progress comment $PROGRESS_COMMENT_ID on #$pr was refused: $GH_ERR"
5183
+ fi
5184
+ [ -z "${file-}" ] || rm -f "$file"
5185
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
5186
+ return 0
5187
+ }
5188
+
4890
5189
  verb_report() {
4891
5190
  if [ "$report_event" = progress ]; then
4892
5191
  forge_progress "$branch"
@@ -5416,11 +5715,17 @@ control_cleanup() {
5416
5715
  # non-empty value adds ` engine=<engine>` to every later reply's marker.
5417
5716
  CONTROL_REPLY_ENGINE=""
5418
5717
 
5718
+ # How control_mention read the mention a verb arm is carrying out; non-empty,
5719
+ # it opens every reply, an arm's refusal included.
5720
+ CONTROL_MENTION_NOTE=""
5721
+
5419
5722
  # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment,
5420
- # its marker carrying CONTROL_REPLY_ENGINE; 1, after an `::error::` line, when
5421
- # it cannot be posted.
5723
+ # after CONTROL_MENTION_NOTE and a blank line when that is set, its marker
5724
+ # carrying CONTROL_REPLY_ENGINE; 1, after an `::error::` line, when it cannot
5725
+ # be posted.
5422
5726
  control_post() {
5423
5727
  local text="$1" file status=0
5728
+ [ -z "$CONTROL_MENTION_NOTE" ] || text="$CONTROL_MENTION_NOTE"$'\n\n'"$text"
5424
5729
  if ! forge_repo_var; then
5425
5730
  echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
5426
5731
  return 1
@@ -6313,10 +6618,9 @@ AUTHORS
6313
6618
  RC_REVIEWERS=$(printf '%s' "$kept" | jq -r '
6314
6619
  reduce (.reviews[] | .user.login) as $l ([]; if any(.[]; . == $l) then . else . + [$l] end) | join(",")')
6315
6620
 
6316
- # A hunk's fence is one backtick longer than its longest backtick run, at
6317
- # least three. The trailing `x` keeps the text's final newline through the
6318
- # substitution.
6319
- if ! text=$(printf '%s' "$kept" | jq -j --arg pr "$pr" --arg at "$collected_at" --arg marker "$COMMENT_MARKER" --arg state "$REVIEW_ROUND_STATE" '
6621
+ # A hunk is fenced by JQ_DEF_FENCE. The trailing `x` keeps the text's final
6622
+ # newline through the substitution.
6623
+ if ! text=$(printf '%s' "$kept" | jq -j --arg pr "$pr" --arg at "$collected_at" --arg marker "$COMMENT_MARKER" --arg state "$REVIEW_ROUND_STATE" "$JQ_DEF_FENCE"'
6320
6624
  def nl: if endswith("\n") then . else . + "\n" end;
6321
6625
  .reviews as $rv | .comments as $cm
6322
6626
  | ($rv | map(
@@ -6334,8 +6638,7 @@ AUTHORS
6334
6638
  + (if ($cm | length) == 0 then "" else
6335
6639
  "\n## Inline comments\n" + ($cm | map(
6336
6640
  (.diff_hunk // "") as $h
6337
- | (([$h | match("`+"; "g") | .length] | max) // 0) as $m
6338
- | ("`" * ([$m + 1, 3] | max)) as $f
6641
+ | ($h | fence) as $f
6339
6642
  | "\n### `" + (.path // "") + "`"
6340
6643
  + (if .line != null then ", line \(.line)"
6341
6644
  elif .original_line != null then ", original line \(.original_line) (outdated)"
@@ -6550,11 +6853,13 @@ control_review_intake() {
6550
6853
  }
6551
6854
 
6552
6855
  # control_comment_intake — read an `issue_comment` event; returns 1 when it is
6553
- # ignored, after one line.
6856
+ # ignored, after one line. The exact form sets CONTROL_VERB / CONTROL_ARGS; any
6857
+ # other mention of the handle as a word sets CONTROL_MENTION and no verb.
6554
6858
  CONTROL_SENDER_TYPE=""
6555
6859
  CONTROL_IS_PR=""
6860
+ CONTROL_MENTION=0
6556
6861
  control_comment_intake() {
6557
- local action first word rest handle
6862
+ local action first word rest handle verb body re
6558
6863
  { event_field '.action // ""' && action="$EVENT_VALUE" \
6559
6864
  && event_field '.comment.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
6560
6865
  && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
@@ -6581,15 +6886,27 @@ control_comment_intake() {
6581
6886
  rest=${first#"$word"}
6582
6887
  rest=${rest#"${rest%%[!$' \t']*}"}
6583
6888
  handle=$(printf '%s' "$COMMAND_HANDLE" | tr '[:upper:]' '[:lower:]')
6584
- if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" != "$handle" ]; then
6585
- echo "remote-run.sh: control: ignored, the first line does not open with $COMMAND_HANDLE"
6586
- return 1
6889
+ if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" = "$handle" ]; then
6890
+ word=${rest%%[$' \t']*}
6891
+ verb=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
6892
+ case " $COMMAND_VERBS " in
6893
+ *" $verb "*)
6894
+ if [ -n "$verb" ]; then
6895
+ CONTROL_VERB="$verb"
6896
+ CONTROL_ARGS=${rest#"$word"}
6897
+ CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
6898
+ return 0
6899
+ fi ;;
6900
+ esac
6587
6901
  fi
6588
- word=${rest%%[$' \t']*}
6589
- CONTROL_VERB=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
6590
- CONTROL_ARGS=${rest#"$word"}
6591
- CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
6592
- return 0
6902
+ body=$(printf '%s' "$CONTROL_BODY" | tr '[:upper:]' '[:lower:]')
6903
+ re="(^|[^a-z0-9])${handle}([^a-z0-9-]|\$)"
6904
+ if [[ $body =~ $re ]]; then
6905
+ CONTROL_MENTION=1
6906
+ return 0
6907
+ fi
6908
+ echo "remote-run.sh: control: ignored, the comment does not mention $COMMAND_HANDLE"
6909
+ return 1
6593
6910
  }
6594
6911
 
6595
6912
  # The close: CLOSE_KIND (`issue`, `pr_closed`, `pr_merged`, `deleted`) and
@@ -6730,10 +7047,11 @@ control_close() {
6730
7047
  CONTROL_BRANCH="$b"
6731
7048
  if [ "$CLOSE_KIND" = pr_closed ] || [ "$CLOSE_KIND" = pr_merged ]; then
6732
7049
  # GitHub closes a pull request whose head is deleted; the `delete` event's
6733
- # own job stops that run from the default branch, so this one stays quiet.
7050
+ # own job stops that run from the default branch and reports it on this
7051
+ # pull request, so this one stays quiet.
6734
7052
  remote_branch_exists "$b"
6735
7053
  case $? in
6736
- 1) control_close_ignore "the branch \`$b\` of pull request #$CONTROL_NUMBER is gone from origin; the deletion's own job stops the run" ;;
7054
+ 1) control_close_ignore "the branch \`$b\` of pull request #$CONTROL_NUMBER is gone from origin; the deletion's own job stops the run and reports it here" ;;
6737
7055
  2) echo "remote-run.sh: control: whether \`$b\` exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
6738
7056
  esac
6739
7057
  fi
@@ -6771,9 +7089,350 @@ control_close() {
6771
7089
  exit "$EXIT_OK"
6772
7090
  }
6773
7091
 
7092
+ # control_commands_way — prints the way on that lists every command and names
7093
+ # the documentation, the unknown-verb refusal's and every mention refusal's.
7094
+ control_commands_way() {
7095
+ local v verbs=""
7096
+ for v in $COMMAND_VERBS; do
7097
+ [ "$v" != answer ] || v="answer [<n>]"
7098
+ verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
7099
+ done
7100
+ printf '%s' "The commands are $verbs; \`docs/github-run-control.md\` in the harness documentation states each."
7101
+ }
7102
+
7103
+ # The credential `verb_control`'s first statements take out of the environment;
7104
+ # never exported, so only the agent subshell in control_mention_session sees it.
7105
+ MENTION_OAUTH=""
7106
+ MENTION_API=""
7107
+ # The job's `GH_TOKEN` in the base64 form `actions/checkout` persists into the
7108
+ # checkout's git configuration; never exported.
7109
+ MENTION_JOB_TOKEN_B64=""
7110
+
7111
+ # mention_has_credential <string> — 0 when <string> contains, verbatim, a
7112
+ # non-empty saved credential value, or the job's `GH_TOKEN` raw or in its
7113
+ # persisted base64 form. An encoded or split copy is not caught.
7114
+ mention_has_credential() {
7115
+ if [ -n "$MENTION_OAUTH" ]; then
7116
+ case "$1" in *"$MENTION_OAUTH"*) return 0 ;; esac
7117
+ fi
7118
+ if [ -n "$MENTION_API" ]; then
7119
+ case "$1" in *"$MENTION_API"*) return 0 ;; esac
7120
+ fi
7121
+ if [ -n "${GH_TOKEN-}" ]; then
7122
+ case "$1" in *"$GH_TOKEN"*) return 0 ;; esac
7123
+ fi
7124
+ if [ -n "$MENTION_JOB_TOKEN_B64" ]; then
7125
+ case "$1" in *"$MENTION_JOB_TOKEN_B64"*) return 0 ;; esac
7126
+ fi
7127
+ return 1
7128
+ }
7129
+
7130
+ # mention_cap <file> <max bytes> <note> — over <max bytes>, <file> keeps its
7131
+ # first <max bytes> cut back to the last whole line, then the line <note>.
7132
+ mention_cap() {
7133
+ local file="$1" max="$2" note="$3" size
7134
+ size=$(wc -c <"$file") || return 1
7135
+ size=${size//[!0-9]/}
7136
+ [ "${size:-0}" -gt "$max" ] || return 0
7137
+ head -c "$max" "$file" >"$file.cut" || return 1
7138
+ if [ -n "$(tail -c 1 "$file.cut")" ]; then
7139
+ sed '$d' "$file.cut" >"$file.line" || return 1
7140
+ mv -f "$file.line" "$file.cut" || return 1
7141
+ fi
7142
+ printf '%s\n' "$note" >>"$file.cut"
7143
+ mv -f "$file.cut" "$file"
7144
+ }
7145
+
7146
+ # control_mention_context <dir> — comment.md, run.md and questions/ in <dir>,
7147
+ # every file capped at MENTION_FILE_MAX_BYTES; 1 when one cannot be written.
7148
+ control_mention_context() {
7149
+ local dir="$1" kind=issue n src f
7150
+ [ -z "$CONTROL_IS_PR" ] || kind="pull request"
7151
+ {
7152
+ printf 'handle: %s\n' "$COMMAND_HANDLE"
7153
+ printf 'Comment by @%s on %s #%s:\n\n' "$CONTROL_ACTOR" "$kind" "$CONTROL_NUMBER"
7154
+ printf '%s' "$CONTROL_BODY"
7155
+ } >"$dir/comment.md" || return 1
7156
+ {
7157
+ printf 'branch: %s\n' "$CONTROL_BRANCH"
7158
+ printf 'state: %s\n' "$CS_STATE"
7159
+ printf 'pause_reason: %s\n' "$CS_REASON"
7160
+ printf 'engine: %s\n' "$CS_ENGINE"
7161
+ printf 'open_questions: %s\n' "$CS_OPEN"
7162
+ printf 'detail: %s\n' "$CS_DETAIL"
7163
+ printf 'run_url: %s\n' "$CS_URL"
7164
+ printf 'run_status: %s\n' "$CS_RUN_STATUS"
7165
+ if [ "$CS_STOPPED" = 1 ]; then printf 'stopped: yes\n'; else printf 'stopped: no\n'; fi
7166
+ if ! control_ledger_next_var; then
7167
+ printf 'next: the flow-progress ledger could not be read\n'
7168
+ elif [ -z "$LEDGER_NEXT" ]; then
7169
+ printf 'next: every entry of the flow-progress ledger is ticked\n'
7170
+ else
7171
+ printf 'next: %s\n' "$LEDGER_NEXT"
7172
+ printf 'section: %s\n' "$LEDGER_SECTION"
7173
+ fi
7174
+ } >"$dir/run.md" || return 1
7175
+ mkdir "$dir/questions" || return 1
7176
+ for n in $CS_OPEN; do
7177
+ case "$n" in ''|*[!0-9]*) continue ;; esac
7178
+ src="$CS_DIR/clarifications/$CONTROL_BRANCH/question_$n.md"
7179
+ [ -f "$src" ] || continue
7180
+ cp "$src" "$dir/questions/question_$n.md" || return 1
7181
+ done
7182
+ for f in "$dir/comment.md" "$dir/run.md" "$dir"/questions/question_*.md; do
7183
+ [ -f "$f" ] || continue
7184
+ mention_cap "$f" "$MENTION_FILE_MAX_BYTES" "(cut at $MENTION_FILE_MAX_BYTES bytes)" || return 1
7185
+ done
7186
+ return 0
7187
+ }
7188
+
7189
+ # control_mention_context_extra <dir> — item.md, conversation.md and, on a pull
7190
+ # request, diff.patch in <dir>, each capped at MENTION_FILE_MAX_BYTES; a failed
7191
+ # read is one line in its file, never a refusal. 1 when a file cannot be
7192
+ # written. Every forge value reaches a file through `jq` or `printf '%s'` only.
7193
+ control_mention_context_extra() {
7194
+ local dir="$1" kind=issue own f
7195
+ [ -z "$CONTROL_IS_PR" ] || kind="pull request"
7196
+ if ! gh_call api "repos/$FORGE_REPO/issues/$CONTROL_NUMBER"; then
7197
+ printf 'The issue or pull request could not be read (%s).\n' "$GH_ERR" >"$dir/item.md" || return 1
7198
+ elif ! printf '%s' "$GH_OUT" | jq -j --arg kind "$kind" --arg n "$CONTROL_NUMBER" '
7199
+ "kind: \($kind)\nnumber: \($n)\ntitle: \(.title // "" | tostring)\nauthor: @\(.user.login // "" | tostring)\nurl: \(.html_url // "" | tostring)\n\n\(.body // "" | tostring)"' \
7200
+ >"$dir/item.md" 2>/dev/null; then
7201
+ printf 'The issue or pull request could not be read (its answer is not the expected JSON).\n' >"$dir/item.md" || return 1
7202
+ fi
7203
+
7204
+ # The commenter's own body goes to jq through a file: a comment can exceed
7205
+ # the kernel's bound on one argument.
7206
+ own="$dir.own"
7207
+ control_dirs="$control_dirs $own"
7208
+ printf '%s' "$CONTROL_BODY" >"$own" || return 1
7209
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$CONTROL_NUMBER/comments" --jq '.[] | {login: .user.login, at: .created_at, body: .body}'; then
7210
+ printf 'The comments of this %s could not be read (%s).\n' "$kind" "$GH_ERR" >"$dir/conversation.md" || return 1
7211
+ elif ! printf '%s' "$GH_OUT" | jq -s -j --arg actor "$CONTROL_ACTOR" --rawfile own "$own" \
7212
+ --arg marker "$COMMENT_MARKER" --arg kind "$kind" --argjson max "$MENTION_COMMENTS_MAX" '
7213
+ [.[] | objects] as $all
7214
+ | ([range(0; $all | length) | select($all[.].login == $actor and $all[.].body == $own)] | last) as $i
7215
+ | (if $i == null then $all else $all[:$i] end) as $before
7216
+ | (if ($before | length) > $max then $before[($before | length) - $max:] else $before end)
7217
+ | if length == 0 then "No comment precedes this one on the \($kind).\n"
7218
+ else map("### @\(.login // "" | tostring) at \(.at // "" | tostring)\n"
7219
+ + (if ((.body // "" | tostring) | contains($marker)) then "(posted by the harness)\n" else "" end)
7220
+ + "\n\(.body // "" | tostring)\n\n") | join("") end' \
7221
+ >"$dir/conversation.md" 2>/dev/null; then
7222
+ printf 'The comments of this %s could not be read (their listing is not the expected JSON).\n' "$kind" >"$dir/conversation.md" || return 1
7223
+ fi
7224
+
7225
+ if [ -n "$CONTROL_IS_PR" ]; then
7226
+ if ! gh_call pr diff "$CONTROL_NUMBER" --repo "$FORGE_REPO"; then
7227
+ printf "The pull request's diff could not be read (%s).\n" "$GH_ERR" >"$dir/diff.patch" || return 1
7228
+ else
7229
+ printf '%s\n' "$GH_OUT" >"$dir/diff.patch" || return 1
7230
+ fi
7231
+ fi
7232
+
7233
+ for f in "$dir/item.md" "$dir/conversation.md" "$dir/diff.patch"; do
7234
+ [ -f "$f" ] || continue
7235
+ mention_cap "$f" "$MENTION_FILE_MAX_BYTES" "(cut at $MENTION_FILE_MAX_BYTES bytes)" || return 1
7236
+ done
7237
+ return 0
7238
+ }
7239
+
7240
+ # control_mention_session <dir> <out> <err> — the one read-only session, run
7241
+ # once in a subshell in <dir>; AGENT_STATUS is its exit.
7242
+ AGENT_STATUS=0
7243
+ control_mention_session() {
7244
+ local dir="$1" out="$2" err="$3" schema model model_args
7245
+ AGENT_STATUS=0
7246
+ # Omitted rather than passed empty, so the CLI applies its own default; the
7247
+ # `${arr[@]+…}` form keeps an empty array safe under `set -u` on bash 3.2.
7248
+ model=$(hr_agent_model "$root" 2>/dev/null) || model=""
7249
+ model_args=()
7250
+ [ -z "$model" ] || model_args=(--model "$model")
7251
+ schema=$(jq -n -c --arg actions "$MENTION_ACTIONS" --arg verbs "$COMMAND_VERBS" '
7252
+ def words: split(" ") | map(select(length > 0));
7253
+ { type: "object", additionalProperties: false, required: ["action", "reason"],
7254
+ properties: {
7255
+ action: { type: "string", enum: ($actions | words) },
7256
+ verb: { type: "string", enum: ($verbs | words) },
7257
+ question: { type: "integer", minimum: 1 },
7258
+ answer: { type: "string" },
7259
+ text: { type: "string" },
7260
+ reason: { type: "string" } } }') || { AGENT_STATUS=1; return 0; }
7261
+ (
7262
+ cd "$dir" || exit 1
7263
+ unset GH_TOKEN GITHUB_TOKEN HARNESS_PR_TOKEN
7264
+ [ -z "$MENTION_OAUTH" ] || export CLAUDE_CODE_OAUTH_TOKEN="$MENTION_OAUTH"
7265
+ [ -z "$MENTION_API" ] || export ANTHROPIC_API_KEY="$MENTION_API"
7266
+ exec "$AGENT_CLI" -p "$MENTION_COMMAND" \
7267
+ --plugin-dir "$HARNESS_MENTION_PLUGIN_DIR" \
7268
+ --add-dir "$HARNESS_MENTION_PLUGIN_DIR/instructions" \
7269
+ --output-format json \
7270
+ --json-schema "$schema" \
7271
+ --tools Read,Grep,Glob --restricted \
7272
+ --strict-mcp-config --no-session-persistence --permission-prompts none \
7273
+ ${model_args[@]+"${model_args[@]}"} --max-budget-usd "$MENTION_MAX_BUDGET_USD"
7274
+ ) </dev/null >"$out" 2>"$err" || AGENT_STATUS=$?
7275
+ return 0
7276
+ }
7277
+
7278
+ # control_mention — MENTION (the header): the state, the credential, the plugin
7279
+ # and the binary, the context directory, one session, then its decision
7280
+ # extracted, validated, checked for a credential and answered. Always exits.
7281
+ control_mention() {
7282
+ local base dir out err why decision from rule action verb question reason text answer file reply quote
7283
+ control_state_var "$CONTROL_BRANCH" \
7284
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
7285
+ control_state_word_var
7286
+
7287
+ if [ -z "$MENTION_OAUTH" ] && [ -z "$MENTION_API" ]; then
7288
+ control_refuse "$EXIT_REFUSED" "\`$WORKFLOW_CONTROL_FILE\` passes the agent that reads a mention no credential, so a mention is not read" \
7289
+ "$(control_commands_way)"
7290
+ fi
7291
+ base=${MENTION_COMMAND##*:}
7292
+ if [ -z "${HARNESS_MENTION_PLUGIN_DIR-}" ] || [ ! -f "$HARNESS_MENTION_PLUGIN_DIR/commands/$base.md" ]; then
7293
+ control_refuse "$EXIT_GH" "the harness plugin carrying the mention command is not available to this job" \
7294
+ "The control job fetches the plugin at the version \`HARNESS_CLI_VERSION\` pins in \`harness-run.yml\`; see \`docs/remote-execution.md\` → \`### Upgrading\`, then comment again, or comment a command. $(control_commands_way)"
7295
+ fi
7296
+ AGENT_CLI="${HARNESS_AGENT_CLI:-claude}"
7297
+ if ! command -v "$AGENT_CLI" >/dev/null 2>&1; then
7298
+ control_refuse "$EXIT_GH" "the agent binary \`$AGENT_CLI\` is not on this job's PATH" \
7299
+ "Comment a command instead. $(control_commands_way)"
7300
+ fi
7301
+
7302
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-mention.XXXXXX"); then
7303
+ control_refuse "$EXIT_GH" "a context directory could not be created under '$control_tmp'" "Comment again to retry."
7304
+ fi
7305
+ out="$dir.out"
7306
+ err="$dir.err"
7307
+ control_dirs="$control_dirs $dir $out $err"
7308
+ { control_mention_context "$dir" && control_mention_context_extra "$dir"; } \
7309
+ || control_refuse "$EXIT_GH" "the mention's context could not be written under '$dir'" "Comment again to retry."
7310
+
7311
+ control_mention_session "$dir" "$out" "$err"
7312
+
7313
+ why=$(jq -r -s '
7314
+ if length != 1 or (.[0] | type) != "object" then "its output is not one JSON object"
7315
+ elif .[0].is_error == true then "it reported an error (\(.[0].subtype // "no subtype"))"
7316
+ elif (.[0].subtype // "") != "success" then "it ended as \(.[0].subtype // "no subtype")"
7317
+ else "" end' "$out" 2>/dev/null) || why="its output is not one JSON object"
7318
+ if [ "$AGENT_STATUS" -ne 0 ] || [ -n "$why" ]; then
7319
+ reason=$(grep -v '^[[:space:]]*$' "$err" 2>/dev/null | tail -n 1)
7320
+ ! mention_has_credential "$reason" || reason=""
7321
+ [ "$AGENT_STATUS" -eq 0 ] || why="it exited $AGENT_STATUS${reason:+: $reason}"
7322
+ control_refuse "$EXIT_GH" "the agent that reads a mention failed: $why" \
7323
+ "Comment again, or comment a command. $(control_commands_way)"
7324
+ fi
7325
+
7326
+ decision=$(jq -c -s '.[0] as $r
7327
+ | if ($r.structured_output | type) == "object" then { from: "structured_output", d: $r.structured_output }
7328
+ else ([$r.result | strings | fromjson? | objects] | first) as $p
7329
+ | if $p != null then { from: "result", d: $p } else { from: "", d: null } end
7330
+ end' "$out") || decision='{"from":"","d":null}'
7331
+ from=$(printf '%s' "$decision" | jq -r '.from')
7332
+ if [ -z "$from" ]; then
7333
+ control_refuse "$EXIT_REFUSED" "the agent's reading of the mention carries no decision object" \
7334
+ "Comment again, or comment a command. $(control_commands_way)"
7335
+ fi
7336
+ action=$(printf '%s' "$decision" | jq -r '.d.action | if type == "string" then gsub("[\r\n]+"; " ") else tojson end')
7337
+ verb=$(printf '%s' "$decision" | jq -r '.d.verb | strings | gsub("[\r\n]+"; " ")')
7338
+ reason=$(printf '%s' "$decision" | jq -r '.d.reason | strings | gsub("[\r\n]+"; " ") | .[0:200]')
7339
+ ! mention_has_credential "$reason" || reason="(withheld: it carries a credential value)"
7340
+ echo "remote-run.sh: control: mention on #$CONTROL_NUMBER by @$CONTROL_ACTOR read as $action${verb:+ $verb} from $from: $reason"
7341
+
7342
+ rule=$(printf '%s' "$decision" | jq -r --arg actions "$MENTION_ACTIONS" --arg verbs "$COMMAND_VERBS" '
7343
+ def words: split(" ") | map(select(length > 0));
7344
+ def nonblank: type == "string" and test("\\S");
7345
+ .d as $d
7346
+ | ($actions | words) as $A
7347
+ | ($verbs | words) as $V
7348
+ | if ($d | type) != "object" then "it is not an object"
7349
+ elif ($A | any(.[]; . == $d.action) | not) then "its `action` is not one of \($A | join(", "))"
7350
+ elif $d.action == "command" and ($V | any(.[]; . == $d.verb) | not) then "a `command` names no `verb` among \($V | join(", "))"
7351
+ elif $d.action == "command" and $d.verb == "answer" and ($d.answer | nonblank | not) then "an `answer` carries no non-blank `answer`"
7352
+ elif $d.action == "command" and $d.verb == "answer" and $d.question != null
7353
+ and (($d.question | type) != "number" or $d.question != ($d.question | floor) or $d.question < 1) then "its `question` is not an integer of 1 or more"
7354
+ elif ($d.action == "reply" or $d.action == "clarify") and ($d.text | nonblank | not) then "a `\($d.action)` carries no non-blank `text`"
7355
+ elif ($d.reason | type) != "string" then "it carries no `reason`"
7356
+ else "" end') || rule="it could not be read"
7357
+ if [ -n "$rule" ]; then
7358
+ control_refuse "$EXIT_REFUSED" "the agent's reading of the mention is not a valid decision: $rule" \
7359
+ "Comment again, or comment a command. $(control_commands_way)"
7360
+ fi
7361
+
7362
+ # Once, before any action is answered: no branch, this one or a later one,
7363
+ # sees a decision whose posted or dispatched text carries a credential.
7364
+ text=$(printf '%s' "$decision" | jq -r '.d.text | strings')
7365
+ answer=$(printf '%s' "$decision" | jq -r '.d.answer | strings')
7366
+ if mention_has_credential "$text" || mention_has_credential "$answer"; then
7367
+ echo "::error::remote-run.sh: control: the decision on the mention on #$CONTROL_NUMBER carries a credential value in its text or answer; nothing it wrote is posted"
7368
+ exit "$EXIT_GH"
7369
+ fi
7370
+
7371
+ case "$action" in
7372
+ none)
7373
+ echo "remote-run.sh: control: the mention on #$CONTROL_NUMBER needs no answer"
7374
+ exit "$EXIT_OK" ;;
7375
+ reply|clarify)
7376
+ file="$dir.text"
7377
+ control_dirs="$control_dirs $file"
7378
+ printf '%s' "$decision" | jq -r --arg login "$CONTROL_ACTOR" --arg handle "$COMMAND_HANDLE" \
7379
+ "$JQ_DEF_SANITISE"' .d.text | sanitise($login; $handle)' >"$file" \
7380
+ && mention_cap "$file" "$MENTION_TEXT_MAX_BYTES" "(cut)" \
7381
+ || control_refuse "$EXIT_GH" "the agent's reply could not be prepared under '$control_tmp'" "Comment again to retry."
7382
+ text=$(cat "$file")
7383
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: $text"$'\n\n'"_Written by an agent that read your mention; it changed nothing. $(control_commands_way)_" ;;
7384
+ fixes)
7385
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: a mention does not start a round of fixes. A review that requests changes on the run's pull request starts one (\`docs/github-run-control.md\` → \`## 2.\`). An author who cannot request changes on their own pull request starts the round locally with \`/autonomous-sdlc-harness:branch-user-review\` (\`## 4.\`)." ;;
7386
+ command)
7387
+ question=""
7388
+ [ "$verb" != answer ] || question=$(printf '%s' "$decision" | jq -r '.d.question | numbers | floor')
7389
+ case " $MENTION_CONFIRM_VERBS " in
7390
+ *" $verb "*)
7391
+ reply="@$CONTROL_ACTOR: your mention reads as \`$COMMAND_HANDLE $verb${question:+ $question}\`. Comment that command to carry it out"
7392
+ [ "$verb" != answer ] || reply="$reply, with the answer on the lines below it"
7393
+ control_reply "$EXIT_OK" "$reply." ;;
7394
+ esac
7395
+ case " $MENTION_ACT_VERBS " in
7396
+ *" $verb "*)
7397
+ # What control_answer reads from the exact form: the command line,
7398
+ # then the answer below it. The credential check above has already
7399
+ # refused an `answer` carrying a saved credential.
7400
+ CONTROL_BODY="$COMMAND_HANDLE $verb${question:+ $question}"
7401
+ reply="Read from your mention as \`$CONTROL_BODY\`."
7402
+ if [ "$verb" = answer ]; then
7403
+ # The `x` keeps the answer's final newlines through the substitution.
7404
+ answer=$(printf '%s' "$decision" | jq -j '.d.answer' && printf x) \
7405
+ || control_refuse "$EXIT_GH" "the agent's answer could not be read" "Comment again to retry."
7406
+ answer=${answer%x}
7407
+ quote=$(printf '%s' "$decision" | jq -r --arg login "$CONTROL_ACTOR" --arg handle "$COMMAND_HANDLE" \
7408
+ "$JQ_DEF_SANITISE $JQ_DEF_FENCE"' .d.answer | sanitise($login; $handle)
7409
+ | (if endswith("\n") then . else . + "\n" end) as $q
7410
+ | ($q | fence) as $f
7411
+ | $f + "\n" + $q + $f') \
7412
+ || control_refuse "$EXIT_GH" "the agent's answer could not be quoted" "Comment again to retry."
7413
+ CONTROL_BODY="$CONTROL_BODY"$'\n'"$answer"
7414
+ reply="Read from your mention as \`$COMMAND_HANDLE $verb${question:+ $question}\`, with this answer:"$'\n\n'"$quote"
7415
+ fi
7416
+ CONTROL_VERB="$verb"
7417
+ CONTROL_ARGS="$question"
7418
+ CONTROL_MENTION_NOTE="$reply"
7419
+ control_run_verb ;;
7420
+ esac ;;
7421
+ esac
7422
+ echo "::error::remote-run.sh: control: the validated action \`$action\` has no answer"
7423
+ exit "$EXIT_GH"
7424
+ }
7425
+
6774
7426
  verb_control() {
6775
7427
  local LC_ALL=C
6776
- local review=0 close=0 forge="" target="" status verbs="" v
7428
+ local review=0 close=0 forge="" target="" status
7429
+ # Unset first so an inherited export of either name cannot keep it exported.
7430
+ unset MENTION_OAUTH MENTION_API MENTION_JOB_TOKEN_B64
7431
+ MENTION_OAUTH="${IN_OAUTH-}"
7432
+ MENTION_API="${IN_API-}"
7433
+ MENTION_JOB_TOKEN_B64=""
7434
+ [ -z "${GH_TOKEN-}" ] || MENTION_JOB_TOKEN_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
7435
+ unset IN_OAUTH IN_API CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY
6777
7436
  case "${GITHUB_EVENT_NAME-}" in
6778
7437
  issue_comment) ;;
6779
7438
  pull_request_review) review=1 ;;
@@ -6838,16 +7497,12 @@ verb_control() {
6838
7497
  "Only a collaborator with write, maintain or admin access whom the repository variable \`HARNESS_RUN_ACTORS\` admits (when unset, the owner alone of a repository a personal account owns, and nobody in an organisation-owned one), or a bot listed in \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
6839
7498
  fi
6840
7499
 
6841
- if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
6842
- for v in $COMMAND_VERBS; do
6843
- [ "$v" != answer ] || v="answer [<n>]"
6844
- verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
6845
- done
6846
- control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" \
6847
- "The commands are $verbs; \`docs/github-run-control.md\` in the harness documentation states each."
7500
+ if [ "$review" -eq 0 ] && [ "$CONTROL_MENTION" != 1 ] \
7501
+ && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
7502
+ control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" "$(control_commands_way)"
6848
7503
  fi
6849
7504
 
6850
- forge_repo_var || control_reply "$EXIT_GH" "@$CONTROL_ACTOR: \`$CONTROL_VERB\` was not run: the repository's name could not be read."
7505
+ forge_repo_var || control_reply "$EXIT_GH" "@$CONTROL_ACTOR: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` was not run: the repository's name could not be read."
6851
7506
  if [ "$review" -eq 1 ]; then
6852
7507
  control_check_branch "$REVIEW_HEAD"
6853
7508
  elif [ -n "$CONTROL_IS_PR" ]; then
@@ -6856,7 +7511,16 @@ verb_control() {
6856
7511
  control_branch_from_issue "$CONTROL_NUMBER"
6857
7512
  fi
6858
7513
 
7514
+ if [ "$CONTROL_MENTION" = 1 ]; then
7515
+ echo "remote-run.sh: control: a mention on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
7516
+ control_mention
7517
+ fi
6859
7518
  echo "remote-run.sh: control: $CONTROL_VERB on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
7519
+ control_run_verb
7520
+ }
7521
+
7522
+ # control_run_verb — the arm carrying out CONTROL_VERB.
7523
+ control_run_verb() {
6860
7524
  case "$CONTROL_VERB" in
6861
7525
  answer) control_answer ;;
6862
7526
  pause) control_pause ;;