autonomous-sdlc-harness 0.6.3 → 0.6.5

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.
@@ -38,7 +38,8 @@
38
38
  # (always 0, 1 only on a usage error: its paragraph)
39
39
  # remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
40
40
  # (always 0, 1 only on a usage error: its paragraph)
41
- # remote-run.sh control [--repo <root>] (its own exit map: its paragraph)
41
+ # remote-run.sh control [--needs-agent] [--repo <root>]
42
+ # (its own exit map: its paragraph)
42
43
  # 0 sent (for stop: the action=stop marker was dispatched, and every
43
44
  # queued, waiting or in-progress `harness run` run of that branch was
44
45
  # asked to cancel, or there was none); for status and fetch: printed
@@ -311,8 +312,10 @@
311
312
  # stays open; otherwise it says the draft stays open, to close to discard the
312
313
  # run, or, when an issue is known, to re-apply the trigger label there for a
313
314
  # 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.
315
+ # its draft stays open and closing it discards the run. A `stopped` report also
316
+ # rewrites the progress comment of each pull request it labels, its
317
+ # `in progress` line becoming `stopped` (`forge_progress_stopped`). No other
318
+ # event changes a pull request's draft state.
316
319
  # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round`,
317
320
  # `failed` and `not_started` — posts nothing and sets no label when `remote_branch_stopped`,
318
321
  # asked afresh, finds the branch stopped, so a job a stop overtook never
@@ -334,8 +337,11 @@
334
337
  # `COMMAND_HANDLE answer <n>` over `<your answer>`; the marker adds
335
338
  # `question=<n>`. With no question open it posts nothing, sets no label and
336
339
  # 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
340
+ # is set once per target, not per question. When the registry's `pause_reason`
341
+ # is `user` (a pause the job dropped and the run never honoured), `parked`
342
+ # appends `PAUSE_FOLDED_NOTE` to <note> and `park_loop` appends
343
+ # `PAUSE_FOLDED_HOLD_NOTE`, each after one blank line. On a public repository a
344
+ # question comment and its answer are public, as the artifact already is
339
345
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
340
346
  # repository's Actions runs can see*).
341
347
  # `progress` (`forge_progress`) keeps ONE comment per run or round on the pull
@@ -356,7 +362,10 @@
356
362
  # is one `forge_comment`; a body equal to the render (carriage returns and
357
363
  # trailing newlines aside) is no call and one `already current` line; otherwise
358
364
  # one `PATCH` of that comment — the only comment this file ever edits. A refused
359
- # listing, create or edit is one `::warning::` line.
365
+ # listing, create or edit is one `::warning::` line. After a stop,
366
+ # `forge_progress_stopped` edits that same comment, so it is still the only
367
+ # comment this file edits, and a resumed job's first progress pass renders it
368
+ # from the ledger again.
360
369
  # It never fails its caller: every problem is one line and exit 0.
361
370
  #
362
371
  # `open` OPENS THE RUN'S DRAFT PULL REQUEST at the run's start, so its issue
@@ -504,12 +513,17 @@
504
513
  # into a variable (data, never shell source), plus `trigger`'s environment.
505
514
  # Ignored, with one line and no `gh` call: an action other than `created`; a
506
515
  # 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:
516
+ # posted it); and a body that does not hold `COMMAND_HANDLE` as a word
517
+ # anywhere, compared lowercase (the ERE `(^|[^a-z0-9])<handle>([^a-z0-9-]|$)`).
518
+ # So `pause` and `foo@sdlc-harnessx` start nothing. THE EXACT FORM is a first
519
+ # line — a trailing CR stripped, leading spaces and tabs skipped — opening with
520
+ # a word equal to `COMMAND_HANDLE`, compared lowercase, whose next word,
521
+ # lowercased, is a `COMMAND_VERBS` word: that is the verb, and `CONTROL_ARGS`
522
+ # the rest of that line. Any other body holding the handle is A MENTION
523
+ # (`CONTROL_MENTION`, no verb): `Let's @sdlc-harness pause`, `> @sdlc-harness
524
+ # pause`, the handle on a later line, and `@sdlc-harness check the question`
525
+ # alike, each read by MENTION below. Then refused, in this order, each a reply
526
+ # and exit 2:
513
527
  # 1. `HARNESS_REMOTE_STOP` is set
514
528
  # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
515
529
  # GitHub nothing about the commenter
@@ -517,8 +531,101 @@
517
531
  # refuses, before the event's own actor is checked
518
532
  # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run — a
519
533
  # 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`
534
+ # 4. the exact form's verb is one no arm carries out (`control_verb_handled`;
535
+ # no such word exists today): the reply lists every command and names
536
+ # `docs/github-run-control.md`. Skipped for a mention.
537
+ # Then THE BRANCH, and the exact form's arm (`control_run_verb`) or MENTION.
538
+ # `--needs-agent` answers only whether MENTION would start a session: the
539
+ # intake above, then refusals 1 to 3 (`control_gates`, shared with `control`),
540
+ # run alone, posting, dispatching and creating nothing; its one `gh` call is
541
+ # `authorise_actor`'s permission call, and the branch is never resolved. One
542
+ # stdout line each: exit 0 `needs-agent: yes, a mention by @<login> on #<n>`;
543
+ # exit 2 `needs-agent: no, <reason>` for an event other than `issue_comment`,
544
+ # an ignored comment (after its own line), the exact form (before any gate,
545
+ # no `gh` call), or a refusal, whose <reason> is that refusal's reply text;
546
+ # exit 3 `needs-agent: undecided, <AUTH_WHY>` when the permission call failed
547
+ # (`control` still refuses that with exit 2); exit 1 as `control`'s.
548
+ # `WORKFLOW_CONTROL_FILE` runs it before installing the agent; the act step,
549
+ # plain `control`, still re-checks everything.
550
+ # MENTION. `verb_control`'s first statements, for every event, copy `IN_OAUTH`
551
+ # / `IN_API` into the non-exported `MENTION_OAUTH` / `MENTION_API` and unset
552
+ # them, with `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`, so no `gh`, `git`
553
+ # or `control_child` process the job spawns inherits a credential; only the
554
+ # agent subshell receives it. After the gates and the branch, `control_mention`
555
+ # reads the state (`control_state_var`; a failed read is exit 3), then refuses
556
+ # with no session: no saved credential (exit 2, the reply listing every
557
+ # command); no `commands/<basename of MENTION_COMMAND>.md` under
558
+ # `HARNESS_MENTION_PLUGIN_DIR` (exit 3, naming `HARNESS_CLI_VERSION` in
559
+ # `harness-run.yml` and `docs/remote-execution.md` -> `### Upgrading`); and an
560
+ # agent binary, `${HARNESS_AGENT_CLI:-claude}`, that `command -v` does not
561
+ # resolve (exit 3). The plugin directory is a path only: nothing under it is
562
+ # sourced or run by this script. THE CONTEXT DIRECTORY, a fresh one under
563
+ # `RUNNER_TEMP` (removed on exit): `comment.md` (`handle: <COMMAND_HANDLE>`,
564
+ # `Comment by @<login> on <issue|pull request> #<n>:`, a blank line, the body
565
+ # verbatim), `run.md` (`branch:`, the `fetch` keys, `stopped: yes|no`, the next
566
+ # ledger entry and its section, or that every entry is ticked or the ledger
567
+ # could not be read), `questions/question_<n>.md` per open question,
568
+ # `item.md` (`kind:`, `number:`, `title:`, `author:`, `url:`, a blank line, the
569
+ # body, from `api repos/<repo>/issues/<n>`), `conversation.md` (the last
570
+ # `MENTION_COMMENTS_MAX` comments before the commenter's own, oldest first,
571
+ # each `### @<login> at <at>`, `(posted by the harness)` when it carries
572
+ # `COMMENT_MARKER`, then its body, from the paginated comment listing) and, on
573
+ # a pull request, `diff.patch` (`pr diff`, text only: nothing is checked out),
574
+ # each capped at `MENTION_FILE_MAX_BYTES` at a whole line with a `(cut at <n>
575
+ # bytes)` line; a failed read is said in the file, never refused. THE SESSION runs once, never retried, in a subshell `cd` into
576
+ # that directory with `GH_TOKEN`, `GITHUB_TOKEN` and `HARNESS_PR_TOKEN` unset
577
+ # and `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` exported from whichever
578
+ # saved value is set: `-p "$MENTION_COMMAND"` (no argument, so no comment text
579
+ # reaches argv) `--plugin-dir <dir> --add-dir <dir>/instructions
580
+ # --output-format json --json-schema <built by jq from MENTION_ACTIONS and
581
+ # COMMAND_VERBS> --tools Read,Grep,Glob --restricted --strict-mcp-config
582
+ # --no-session-persistence --permission-prompts none --model <agentModel>
583
+ # --max-budget-usd "$MENTION_MAX_BUDGET_USD"`, the model read through
584
+ # `hr_agent_model` (a command declares no `model:`) and the flag left off only
585
+ # when the configuration cannot be read; never `--bare`, which reads no
586
+ # OAuth token, and never `--disable-slash-commands`, which would stop the `-p`
587
+ # command expanding. The command is `plugin/commands/harness-read-mention.md`
588
+ # (`MENTION_COMMAND`), and it loads `plugin/instructions/mention_reading.md`,
589
+ # whose `## Output contract` restates the decision contract validated here: a
590
+ # field renamed on either side is an edit to both. A command declares no
591
+ # allowlist, so the read-only closure is the session's `--tools` and
592
+ # `--restricted` alone. EXTRACTION: a non-zero exit, stdout that is not one
593
+ # JSON object, `.is_error` true or a `.subtype` other than `success` is a
594
+ # failure, exit 3; the decision is `.structured_output` when an object, else
595
+ # `.result | fromjson?` when an object, and one stdout line logs it (`mention
596
+ # on #<n> by @<login> read as <action>[ <verb>] from <field>: <reason>`).
597
+ # VALIDATION, by `jq`, the sole authority whatever the schema did: `action` in
598
+ # `MENTION_ACTIONS`; `command` needs a `verb` in `COMMAND_VERBS`; `answer`
599
+ # needs a non-blank `answer` and an optional integer `question` of 1 or more;
600
+ # `reply` and `clarify` need a non-blank `text`; `reason` is a string. Unknown
601
+ # keys are ignored; an invalid decision is refused, exit 2, naming the first
602
+ # rule broken. Then, once and before any action is answered, a `text` or
603
+ # `answer` holding a saved credential value, or the job's `GH_TOKEN` raw or in
604
+ # the base64 form `actions/checkout` persists, verbatim, is an `::error::` line
605
+ # that never prints it, nothing posted, exit 3. THE ANSWERS, each through `control_post`:
606
+ # `none` posts nothing; `reply` / `clarify` post `@<login>: <text>` — `text`
607
+ # through `JQ_DEF_SANITISE` (`<!--` neutralised, every other `@<login>` but the
608
+ # commenter's and the handle given U+200B) and capped at
609
+ # `MENTION_TEXT_MAX_BYTES` with a `(cut)` line — then a footer saying an agent
610
+ # wrote it and changed nothing, listing the commands; `fixes` posts this
611
+ # script's text pointing at a review requesting changes or
612
+ # `/autonomous-sdlc-harness:branch-user-review`; `command` with a verb in
613
+ # `MENTION_ACT_VERBS` is carried out by that verb's own arm (`control_run_verb`)
614
+ # from the exact form's `CONTROL_ARGS` and `CONTROL_BODY` — for `answer`, the
615
+ # command line then the decision's `answer` below it, byte for byte — every
616
+ # reply the arm posts opening with `CONTROL_MENTION_NOTE`, `Read from your
617
+ # mention as` and that form, `answer` adding the answer fenced by
618
+ # `JQ_DEF_FENCE` after `JQ_DEF_SANITISE`; an `answer` carrying a credential
619
+ # value was refused above, so it reaches neither the answer file, the dispatch
620
+ # nor the note; a state the arm refuses is that arm's refusal and exit,
621
+ # unchanged. `command` with a verb in `MENTION_CONFIRM_VERBS` posts a
622
+ # confirmation request naming the exact form it was read as and carries out no
623
+ # verb: `stop` because it is destructive, `clear` because on GitHub typing it
624
+ # is `branch-resume`'s confirmation. Exits: 0 answered or nothing to do; 2
625
+ # refused; 3 a state read, plugin, binary or agent failure, or a credential
626
+ # value in `text` or `answer`; a carried-out verb, its arm's. The workflow's
627
+ # interface: `IN_OAUTH` / `IN_API`, `HARNESS_MENTION_PLUGIN_DIR`, and
628
+ # `control --needs-agent`'s exit 2, on which it skips installing the agent.
522
629
  # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
523
630
  # `pr view`: a fork's pull request is refused, because this event carries the
524
631
  # repository's secrets, and nothing from its head is checked out or run; one
@@ -717,7 +824,8 @@
717
824
  # 5. `hr_branch_is_protected` does not answer 1
718
825
  # 6. on `pull_request`, the head branch absent on origin (`remote_branch_exists`
719
826
  # 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
827
+ # deleted, and the `delete` event's job stops the run and reports it on
828
+ # this pull request; an `ls-remote` that
721
829
  # cannot answer is one line and proceeds
722
830
  # There is no harness-branch check (`forge_recognised`): a merged or deleted
723
831
  # branch may no longer carry its task prompt or ledger, and a listed `harness run <b>` run is the
@@ -1064,7 +1172,11 @@
1064
1172
  # task prompt at the newest `harness run <branch>` run's `headSha` through the
1065
1173
  # contents API rather than from `origin/<branch>` (a failed read is one line
1066
1174
  # 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
1175
+ # be resumed, and that its workflow runs and artifacts are kept. (4) also
1176
+ # reports on each pull request of the branch from this repository that is not
1177
+ # merged and still carries `running`, `parked` or `paused`: each gets the same
1178
+ # comment, the `stopped` label and its progress comment marked stopped
1179
+ # (`forge_gone_prs_var`, `forge_progress_stopped`). The issue read
1068
1180
  # rests on GitHub serving a commit no branch points at, which is unverified
1069
1181
  # (`docs/github-run-control.md` -> `## 8. What is not verified here`).
1070
1182
  #
@@ -1077,7 +1189,8 @@
1077
1189
  # repository from that checkout's remote. The configuration read is that
1078
1190
  # root's `harness.config.json`.
1079
1191
  #
1080
- # WHAT IT NEVER DOES. It never launches a local session, never writes the
1192
+ # WHAT IT NEVER DOES. It never launches a local session but `control`'s one
1193
+ # read-only mention session (MENTION, in `control`'s paragraph), never writes the
1081
1194
  # inbox, and never watches a run it sent beyond the bounded lookup of its
1082
1195
  # listing that `trigger` and `review` make. Only `start` and `review` push, and
1083
1196
  # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
@@ -1103,8 +1216,9 @@
1103
1216
  # comment files under `RUNNER_TEMP` (removed), at most one pull request, its
1104
1217
  # `running` label and one comment on the issue. `deliver` writes its body and
1105
1218
  # 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
1219
+ # comment and the state labels. `control` writes its reply file, its `fetch`
1220
+ # directory and, for a mention, its context directory and the session's output
1221
+ # files under `RUNNER_TEMP` (removed) and one reply comment, plus
1108
1222
  # what the child verb it runs writes, and the fetch of origin's <branch> that
1109
1223
  # force-writes `refs/remotes/origin/<branch>` in its checkout. `collect` writes its round file and its
1110
1224
  # settledness directory under `RUNNER_TEMP` (removed), at most one comment, on
@@ -1148,6 +1262,10 @@
1148
1262
  # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
1149
1263
  # COMMAND_HANDLE mirrors COMMAND_HANDLE
1150
1264
  # COMMAND_VERBS mirrors COMMAND_VERBS, space-separated
1265
+ # MENTION_ACTIONS mirrors MENTION_ACTIONS, space-separated, same order
1266
+ # MENTION_ACT_VERBS mirrors MENTION_ACT_VERBS, space-separated, same order
1267
+ # MENTION_CONFIRM_VERBS mirrors MENTION_CONFIRM_VERBS, space-separated, same order
1268
+ # MENTION_COMMAND mirrors MENTION_COMMAND, the plugin-qualified slash command
1151
1269
  # COMMENT_MARKER mirrors COMMENT_MARKER
1152
1270
  # REVIEW_ROUND_STATE mirrors REVIEW_ROUND_STATE
1153
1271
  # STATE_LABEL_PREFIX mirrors STATE_LABEL_PREFIX
@@ -1421,8 +1539,27 @@
1421
1539
  # pause bash scripts/remote-run.sh control -> 0; "$s.log" gains `workflow
1422
1540
  # run harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1423
1541
  # 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
1542
+ # ignored c.json's body `pause` -> 0, one line, "$s.log" unchanged
1543
+ # mention c.json's body `Let's @sdlc-harness pause`, with
1544
+ # HARNESS_MENTION_PLUGIN_DIR=<a throwaway directory holding an
1545
+ # empty commands/harness-read-mention.md>, IN_OAUTH=x and
1546
+ # HARNESS_AGENT_CLI=<a stub printing {"type":"result",
1547
+ # "subtype":"success","is_error":false,"structured_output":
1548
+ # {"action":"reply","text":"It is running.","reason":"r"}}>
1549
+ # -> 0; "$s.log" gains one comment on 12 opening `@alice: It is
1550
+ # running.`, and no `workflow run`
1551
+ # act the same with the stub's structured_output {"action":"command",
1552
+ # "verb":"pause","reason":"r"} -> 0; "$s.log" gains `workflow run
1553
+ # harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1554
+ # then one comment on 12 opening `Read from your mention as
1555
+ # `@sdlc-harness pause`.`
1556
+ # needs-agent the mention's c.json, `control --needs-agent` -> 0, one line
1557
+ # `needs-agent: yes, a mention by @alice on #12`; "$s.log" holds
1558
+ # only the permission call
1559
+ # needs-agent c.json's body `@sdlc-harness pause` -> 2, one `needs-agent: no`
1560
+ # exact line naming the exact form; "$s.log" unchanged
1561
+ # needs-agent the mention with HARNESS_RUN_ACTORS=bob -> 2, one `needs-agent:
1562
+ # unlisted no` line naming HARNESS_RUN_ACTORS; nothing posted
1426
1563
 
1427
1564
  set -u
1428
1565
 
@@ -1445,6 +1582,10 @@ TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1445
1582
  WORKFLOW_CONTROL_FILE='harness-control.yml'
1446
1583
  COMMAND_HANDLE='@sdlc-harness'
1447
1584
  COMMAND_VERBS='answer pause resume stop clear status'
1585
+ MENTION_ACTIONS='command reply clarify fixes none'
1586
+ MENTION_ACT_VERBS='answer pause resume status'
1587
+ MENTION_CONFIRM_VERBS='stop clear'
1588
+ MENTION_COMMAND='/autonomous-sdlc-harness:harness-read-mention'
1448
1589
  COMMENT_MARKER='<!-- sdlc-harness'
1449
1590
  REVIEW_ROUND_STATE='changes_requested'
1450
1591
  STATE_LABEL_PREFIX='sdlc-harness: '
@@ -1456,6 +1597,18 @@ PR_CREATE_SETTING_PATH='Settings -> Actions -> General -> Workflow permissions'
1456
1597
  # to be trusted (docs/github-integration-research.md -> S6); the margin is the
1457
1598
  # framing lines and the marker.
1458
1599
  QUESTION_COMMENT_MAX_BYTES=250000
1600
+ # The most bytes of each file in a mention's context directory, and of the
1601
+ # agent-written text a mention reply posts (under QUESTION_COMMENT_MAX_BYTES
1602
+ # with room for the prefix, the footer and the marker).
1603
+ MENTION_FILE_MAX_BYTES=200000
1604
+ MENTION_TEXT_MAX_BYTES=60000
1605
+ # How many of the comments before a mention its context's conversation.md keeps.
1606
+ MENTION_COMMENTS_MAX=30
1607
+ # The mention session's spend bound: a bound on one read, not a measured cost.
1608
+ MENTION_MAX_BUDGET_USD=1
1609
+ # Name no login: the job sees only the `harness pause` run, whose actor is the bot.
1610
+ 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.'
1611
+ 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
1612
  GH="${HARNESS_GH_CLI:-gh}"
1460
1613
 
1461
1614
  # How many runs `status` prints, and how many `run list` returns for status
@@ -1531,7 +1684,7 @@ usage() {
1531
1684
  echo " remote-run.sh open <branch> [--repo <root>]" >&2
1532
1685
  echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1533
1686
  echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1534
- echo " remote-run.sh control [--repo <root>]" >&2
1687
+ echo " remote-run.sh control [--needs-agent] [--repo <root>]" >&2
1535
1688
  [ "${verb-}" != save ] || exit "$EXIT_OK"
1536
1689
  exit "$EXIT_USAGE"
1537
1690
  }
@@ -1631,6 +1784,7 @@ reviewers_arg=""
1631
1784
  allow_no_run=0
1632
1785
  pr_arg=""
1633
1786
  branch_gone=0
1787
+ needs_agent=0
1634
1788
 
1635
1789
  while [ "$#" -gt 0 ]; do
1636
1790
  case "$1" in
@@ -1691,6 +1845,9 @@ while [ "$#" -gt 0 ]; do
1691
1845
  --branch-gone)
1692
1846
  [ "$verb" = stop ] || usage "$1 is a stop option"
1693
1847
  branch_gone=1; shift ;;
1848
+ --needs-agent)
1849
+ [ "$verb" = control ] || usage "$1 is a control option"
1850
+ needs_agent=1; shift ;;
1694
1851
  -*)
1695
1852
  usage "unknown option '$1'" ;;
1696
1853
  *)
@@ -2674,6 +2831,23 @@ ANSWERS_SHAPE='env.HARNESS_INPUT_ANSWERS | fromjson
2674
2831
  and all(to_entries[]; (.value | type) == "string"
2675
2832
  and (.key | explode | length > 0 and .[0] != 48 and all(.[]; . >= 48 and . <= 57)))'
2676
2833
 
2834
+ # sanitise($login; $handle), string to string, for agent-written text a reply
2835
+ # quotes: no `<!--` survives to read as a harness marker, and every `@<login>`
2836
+ # but the commenter's and the handle gets U+200B after the `@`, so it notifies
2837
+ # nobody.
2838
+ JQ_DEF_SANITISE='def sanitise($login; $handle):
2839
+ gsub("<!--"; "&lt;!--")
2840
+ | gsub("@(?<l>[A-Za-z0-9][A-Za-z0-9-]*)";
2841
+ if (.l | ascii_downcase) == ($login | ascii_downcase)
2842
+ or (("@" + .l) | ascii_downcase) == ($handle | ascii_downcase)
2843
+ then "@" + .l else "@​" + .l end);'
2844
+
2845
+ # fence, string to string: the code fence that quotes the input — one backtick
2846
+ # longer than its longest backtick run, at least three.
2847
+ JQ_DEF_FENCE='def fence:
2848
+ (([match("`+"; "g") | .length] | max) // 0) as $m
2849
+ | "`" * ([$m + 1, 3] | max);'
2850
+
2677
2851
  verb_restore() {
2678
2852
  local id download status_file clar n tmp status_source
2679
2853
  if [ "$resume" = answer ]; then
@@ -4415,6 +4589,35 @@ forge_pr_var() {
4415
4589
  return 0
4416
4590
  }
4417
4591
 
4592
+ # forge_gone_prs_var <branch> — FORGE_GONE_PRS, the space-separated numbers of
4593
+ # the pull requests of <branch>, in any state, from this repository, not merged
4594
+ # and still labelled `STATE_LABEL_PREFIX` running, parked or paused: the states
4595
+ # control_close stops, so a pull request an earlier stop or a merge settled is
4596
+ # left out. Stands in for forge_recognised, which cannot read a deleted branch.
4597
+ # A failed listing, or one not the expected JSON, is one line and an empty list.
4598
+ # Always 0.
4599
+ FORGE_GONE_PRS=""
4600
+ forge_gone_prs_var() {
4601
+ local sel
4602
+ FORGE_GONE_PRS=""
4603
+ if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state all --json number,isCrossRepository,mergedAt,labels --limit 10; then
4604
+ echo "remote-run.sh: report: listing the pull requests of $1 in every state failed: $GH_ERR" >&2
4605
+ return 0
4606
+ fi
4607
+ if ! sel=$(printf '%s' "$GH_OUT" | jq -r --arg p "$STATE_LABEL_PREFIX" '
4608
+ if type == "array" then
4609
+ [.[] | select(type == "object" and .isCrossRepository == false and .mergedAt == null
4610
+ and (.number | type) == "number"
4611
+ and ([.labels[]? | .name? | strings] | any(. == ($p + "running") or . == ($p + "parked") or . == ($p + "paused"))))
4612
+ | .number | tostring] | join(" ")
4613
+ else error end' 2>/dev/null); then
4614
+ echo "remote-run.sh: report: listing the pull requests of $1 in every state failed: its pr list is not the expected JSON" >&2
4615
+ return 0
4616
+ fi
4617
+ FORGE_GONE_PRS="$sel"
4618
+ return 0
4619
+ }
4620
+
4418
4621
  # forge_dispatch_engine_var <branch> <created_at_iso> — FORGE_DISPATCH_ENGINE,
4419
4622
  # the engine the dispatch that created the run at <created_at_iso> recorded in
4420
4623
  # its comment on the branch's issue or open pull request, or empty. Only a
@@ -4589,10 +4792,12 @@ forge_question_body() {
4589
4792
  # comment (on `parked`, one per open question) and the state label, by the
4590
4793
  # target rule above. Read on `stopped` only: <pr>, an explicit pull request
4591
4794
  # 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
4795
+ # its issue read from the task prompt at <sha>, and then each pull request
4796
+ # forge_gone_prs_var lists given the same comment, the label and its progress
4797
+ # comment marked stopped; nothing is posted only when neither is known. Every
4798
+ # event but `stopped` is withheld when the branch's newest `harness stop` run is
4799
+ # newer than its newest `harness run` run, read from a fresh listing.
4800
+ # `not_started` reads REPORT_NOT_STARTED_STATE (`paused`, else `failed`) and
4596
4801
  # REPORT_NOT_STARTED_ENGINE (empty or the recovered engine), set by its caller.
4597
4802
  # Always 0.
4598
4803
  REPORT_NOT_STARTED_STATE=""
@@ -4600,6 +4805,7 @@ REPORT_NOT_STARTED_ENGINE=""
4600
4805
  forge_report() {
4601
4806
  local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
4602
4807
  local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n route engine=""
4808
+ local gone_prs="" reported="" folded=""
4603
4809
  case "$event" in
4604
4810
  parked|park_loop) state=parked ;;
4605
4811
  paused) state=paused ;;
@@ -4648,8 +4854,11 @@ forge_report() {
4648
4854
  if [ -n "$pr" ]; then
4649
4855
  FORGE_PR="$pr"
4650
4856
  elif [ -n "$gone" ]; then
4651
- # GitHub closes a pull request whose head is deleted, so none is open.
4857
+ # No pull request of a deleted head is open, so the run's unfinished ones
4858
+ # are found in every state by forge_gone_prs_var.
4652
4859
  FORGE_PR=""
4860
+ forge_gone_prs_var "$br"
4861
+ gone_prs="$FORGE_GONE_PRS"
4653
4862
  else
4654
4863
  forge_pr_var "$br" || FORGE_PR=""
4655
4864
  fi
@@ -4661,6 +4870,8 @@ forge_report() {
4661
4870
  target="$FORGE_PR"; kind=pr
4662
4871
  elif [ -n "$FORGE_ISSUE" ]; then
4663
4872
  target="$FORGE_ISSUE"; kind=issue
4873
+ elif [ -n "$gone_prs" ]; then
4874
+ target=""; kind=pr
4664
4875
  else
4665
4876
  echo "remote-run.sh: report: $br has no open pull request and no issue it was started from; nothing posted"
4666
4877
  return 0
@@ -4673,6 +4884,14 @@ forge_report() {
4673
4884
  resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
4674
4885
  [ "$event" != failed ] || engine=$(hr_registry_get "$registry_file" "$br" engine)
4675
4886
  fi
4887
+ case "$event" in
4888
+ parked|park_loop)
4889
+ if [ "$reason" = user ]; then
4890
+ folded="$PAUSE_FOLDED_NOTE"
4891
+ [ "$event" != park_loop ] || folded="$PAUSE_FOLDED_HOLD_NOTE"
4892
+ if [ -z "$note" ]; then note="$folded"; else note="$note"$'\n\n'"$folded"; fi
4893
+ fi ;;
4894
+ esac
4676
4895
 
4677
4896
  case "$event" in
4678
4897
  paused)
@@ -4758,22 +4977,75 @@ forge_report() {
4758
4977
  echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
4759
4978
  fi
4760
4979
  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
4980
+ elif [ -n "$target" ]; then
4981
+ forge_report_text "$target" "$event" "$br" "$tmp" "$text" "$note"
4771
4982
  fi
4772
- [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4773
4983
 
4774
4984
  [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" "$state" || :
4775
4985
  [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" "$state" || :
4776
- echo "remote-run.sh: report: $event on $br reported on #$target"
4986
+ if [ "$event" = stopped ]; then
4987
+ [ -z "$FORGE_PR" ] || forge_progress_stopped "$FORGE_PR" "$br"
4988
+ fi
4989
+ reported="${target:+#$target}"
4990
+ for n in $gone_prs; do
4991
+ forge_report_text "$n" "$event" "$br" "$tmp" "$text" "$note"
4992
+ forge_set_state "$n" "$state" || :
4993
+ forge_progress_stopped "$n" "$br"
4994
+ reported="${reported:+$reported, }#$n"
4995
+ done
4996
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4997
+ echo "remote-run.sh: report: $event on $br reported on $reported"
4998
+ return 0
4999
+ }
5000
+
5001
+ # forge_report_text <number> <event> <branch> <tmp_dir> <text> <note> — post
5002
+ # forge_report's one comment, <text>, <note> and the run URL, on <number>.
5003
+ forge_report_text() {
5004
+ local number="$1" event="$2" br="$3" tmp="$4" text="$5" note="$6" file
5005
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
5006
+ echo "remote-run.sh: report: cannot create the comment file for #$number; no comment posted" >&2
5007
+ return 0
5008
+ fi
5009
+ {
5010
+ printf '%s\n' "$text"
5011
+ [ -z "$note" ] || printf '\n%s\n' "$note"
5012
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
5013
+ } >"$file"
5014
+ forge_comment "$number" "$event" "$br" "$file" || :
5015
+ rm -f "$file"
5016
+ }
5017
+
5018
+ # forge_progress_comment_var <pr> <marker> [any-round] — the one lookup of a
5019
+ # progress comment: one paginated listing of <pr>'s comments, then the newest
5020
+ # `github-actions[bot]` comment with a numeric id whose last non-empty line is
5021
+ # <marker>, or, with `any-round`, <marker> with ` round=<digits>` before its
5022
+ # ` -->`. Sets PROGRESS_COMMENT_ID and PROGRESS_COMMENT_BODY, both empty when
5023
+ # none is picked. 0 when the listing parsed, picked or not; 1 when gh refused it
5024
+ # (GH_ERR as gh_call set it); 2 when it is not the expected JSON. Prints
5025
+ # nothing. No temporary file: forge_progress needs its own before this lookup,
5026
+ # forge_progress_stopped only after it.
5027
+ PROGRESS_COMMENT_ID=""
5028
+ PROGRESS_COMMENT_BODY=""
5029
+ forge_progress_comment_var() {
5030
+ local pr="$1" marker="$2" any="${3-}" pick id body
5031
+ PROGRESS_COMMENT_ID=""
5032
+ PROGRESS_COMMENT_BODY=""
5033
+ gh_call api --paginate "repos/$FORGE_REPO/issues/$pr/comments" --jq '.[] | {id, login: .user.login, body}' || return 1
5034
+ pick=$(printf '%s' "$GH_OUT" | jq -s -c --arg m "$marker" --arg any "$any" '
5035
+ def hit: . == $m
5036
+ or ($any == "any-round"
5037
+ and (($m | sub(" -->$"; "")) as $stem
5038
+ | startswith($stem) and (.[($stem | length):] | test("^ round=[0-9]+ -->$"))));
5039
+ [.[] | select(type == "object" and .login == "github-actions[bot]" and (.id | type) == "number")
5040
+ | select((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "" | hit)]
5041
+ | max_by(.id) // empty
5042
+ | {id, body: (.body // "")}' 2>/dev/null) || return 2
5043
+ [ -n "$pick" ] || return 0
5044
+ id=$(printf '%s' "$pick" | jq -r '.id' 2>/dev/null) || return 2
5045
+ # The sentinel keeps the body's trailing newlines, which $(…) would strip.
5046
+ body=$(printf '%s' "$pick" | jq -j '.body' 2>/dev/null && printf x) || return 2
5047
+ PROGRESS_COMMENT_ID="$id"
5048
+ PROGRESS_COMMENT_BODY="${body%x}"
4777
5049
  return 0
4778
5050
  }
4779
5051
 
@@ -4783,7 +5055,7 @@ forge_report() {
4783
5055
  # issue fallback, no label. Always 0.
4784
5056
  forge_progress() {
4785
5057
  local br="$1" status state_rel phases engine round p line i first=1 marker
4786
- local tmp made_tmp="" file pick id same
5058
+ local tmp made_tmp="" file id same
4787
5059
  local -a labels states
4788
5060
  if ! forge_on; then
4789
5061
  echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
@@ -4856,24 +5128,23 @@ forge_progress() {
4856
5128
  } >"$file"
4857
5129
  { cat "$file"; printf '\n%s\n' "$marker"; } >"$file.full"
4858
5130
 
4859
- if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$FORGE_PR/comments" --jq '.[] | {id, login: .user.login, body}'; then
5131
+ status=0
5132
+ forge_progress_comment_var "$FORGE_PR" "$marker" || status=$?
5133
+ if [ "$status" -eq 1 ]; then
4860
5134
  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
5135
+ elif [ "$status" -ne 0 ]; then
4867
5136
  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
5137
+ elif [ -z "$PROGRESS_COMMENT_ID" ]; then
4869
5138
  if forge_comment "$FORGE_PR" progress "$br" "$file" "" "" "$round"; then
4870
5139
  echo "remote-run.sh: report: progress on $br posted on #$FORGE_PR"
4871
5140
  else
4872
5141
  echo "::warning::remote-run.sh: report: posting the progress comment on #$FORGE_PR was refused: $GH_ERR"
4873
5142
  fi
4874
5143
  else
4875
- id="${pick%% *}"
4876
- same="${pick#* }"
5144
+ id="$PROGRESS_COMMENT_ID"
5145
+ same=$(jq -n -r --arg body "$PROGRESS_COMMENT_BODY" --arg want "$(cat "$file.full")" '
5146
+ def norm: gsub("\r"; "") | sub("\n+$"; "");
5147
+ if ($body | norm) == ($want | norm) then "same" else "differs" end' 2>/dev/null) || same=differs
4877
5148
  if [ "$same" = same ]; then
4878
5149
  echo "remote-run.sh: report: the progress comment on #$FORGE_PR is already current"
4879
5150
  elif gh_call api --method PATCH "repos/$FORGE_REPO/issues/comments/$id" -F "body=@$file.full"; then
@@ -4887,6 +5158,58 @@ forge_progress() {
4887
5158
  return 0
4888
5159
  }
4889
5160
 
5161
+ # forge_progress_stopped <pr> <branch> — after a stop, rewrite each
5162
+ # `- <label>: in progress` line of <branch>'s progress comment on <pr>, the task
5163
+ # run's or any round's, to `- <label>: stopped`, leaving every other byte; the
5164
+ # cancelled job's last progress pass is withheld, so nothing else does. Gated by
5165
+ # hr_progress_comments; no comment or no such line is no edit. Always 0.
5166
+ forge_progress_stopped() {
5167
+ local pr="$1" br="$2" status count tmp made_tmp="" file
5168
+ status=0
5169
+ hr_progress_comments "$root" || status=$?
5170
+ case "$status" in
5171
+ 0) ;;
5172
+ 1) echo "remote-run.sh: report: progress comments are off by execution.progressComments; nothing marked stopped"; return 0 ;;
5173
+ *) echo "remote-run.sh: report: execution.progressComments is unreadable; nothing marked stopped"; return 0 ;;
5174
+ esac
5175
+ status=0
5176
+ forge_progress_comment_var "$pr" "$(forge_marker progress "$br")" any-round || status=$?
5177
+ if [ "$status" -eq 1 ]; then
5178
+ echo "::warning::remote-run.sh: report: listing the comments of #$pr was refused, so its progress comment is not marked stopped: $GH_ERR"
5179
+ return 0
5180
+ elif [ "$status" -ne 0 ]; then
5181
+ echo "::warning::remote-run.sh: report: the comments of #$pr are not the expected JSON, so its progress comment is not marked stopped"
5182
+ return 0
5183
+ fi
5184
+ # A trailing carriage return is kept, so a CRLF body is rewritten in its own line endings.
5185
+ count=0
5186
+ [ -z "$PROGRESS_COMMENT_ID" ] \
5187
+ || count=$(jq -n -r --arg b "$PROGRESS_COMMENT_BODY" \
5188
+ '[$b | split("\n")[] | select(test("^- .+: in progress\r?$"))] | length' 2>/dev/null) || count=0
5189
+ if [ "$count" = 0 ]; then
5190
+ echo "remote-run.sh: report: #$pr has no progress comment of $br in progress; nothing to mark stopped"
5191
+ return 0
5192
+ fi
5193
+ tmp="${RUNNER_TEMP-}"
5194
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5195
+ tmp=$(mktemp -d) || tmp=""
5196
+ made_tmp="$tmp"
5197
+ fi
5198
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-progress-stopped.XXXXXX"); then
5199
+ echo "::warning::remote-run.sh: report: cannot create the progress comment file for #$pr; nothing marked stopped"
5200
+ elif ! jq -n -j --arg b "$PROGRESS_COMMENT_BODY" '$b | split("\n")
5201
+ | map(sub("^(?<l>- .+): in progress(?<cr>\r?)$"; "\(.l): stopped\(.cr)")) | join("\n")' >"$file" 2>/dev/null; then
5202
+ echo "::warning::remote-run.sh: report: cannot write the stopped progress comment for #$pr; nothing marked stopped"
5203
+ elif gh_call api --method PATCH "repos/$FORGE_REPO/issues/comments/$PROGRESS_COMMENT_ID" -F "body=@$file"; then
5204
+ echo "remote-run.sh: report: progress of $br marked stopped in comment $PROGRESS_COMMENT_ID on #$pr"
5205
+ else
5206
+ echo "::warning::remote-run.sh: report: editing the progress comment $PROGRESS_COMMENT_ID on #$pr was refused: $GH_ERR"
5207
+ fi
5208
+ [ -z "${file-}" ] || rm -f "$file"
5209
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
5210
+ return 0
5211
+ }
5212
+
4890
5213
  verb_report() {
4891
5214
  if [ "$report_event" = progress ]; then
4892
5215
  forge_progress "$branch"
@@ -5416,11 +5739,17 @@ control_cleanup() {
5416
5739
  # non-empty value adds ` engine=<engine>` to every later reply's marker.
5417
5740
  CONTROL_REPLY_ENGINE=""
5418
5741
 
5742
+ # How control_mention read the mention a verb arm is carrying out; non-empty,
5743
+ # it opens every reply, an arm's refusal included.
5744
+ CONTROL_MENTION_NOTE=""
5745
+
5419
5746
  # 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.
5747
+ # after CONTROL_MENTION_NOTE and a blank line when that is set, its marker
5748
+ # carrying CONTROL_REPLY_ENGINE; 1, after an `::error::` line, when it cannot
5749
+ # be posted.
5422
5750
  control_post() {
5423
5751
  local text="$1" file status=0
5752
+ [ -z "$CONTROL_MENTION_NOTE" ] || text="$CONTROL_MENTION_NOTE"$'\n\n'"$text"
5424
5753
  if ! forge_repo_var; then
5425
5754
  echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
5426
5755
  return 1
@@ -6313,10 +6642,9 @@ AUTHORS
6313
6642
  RC_REVIEWERS=$(printf '%s' "$kept" | jq -r '
6314
6643
  reduce (.reviews[] | .user.login) as $l ([]; if any(.[]; . == $l) then . else . + [$l] end) | join(",")')
6315
6644
 
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" '
6645
+ # A hunk is fenced by JQ_DEF_FENCE. The trailing `x` keeps the text's final
6646
+ # newline through the substitution.
6647
+ 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
6648
  def nl: if endswith("\n") then . else . + "\n" end;
6321
6649
  .reviews as $rv | .comments as $cm
6322
6650
  | ($rv | map(
@@ -6334,8 +6662,7 @@ AUTHORS
6334
6662
  + (if ($cm | length) == 0 then "" else
6335
6663
  "\n## Inline comments\n" + ($cm | map(
6336
6664
  (.diff_hunk // "") as $h
6337
- | (([$h | match("`+"; "g") | .length] | max) // 0) as $m
6338
- | ("`" * ([$m + 1, 3] | max)) as $f
6665
+ | ($h | fence) as $f
6339
6666
  | "\n### `" + (.path // "") + "`"
6340
6667
  + (if .line != null then ", line \(.line)"
6341
6668
  elif .original_line != null then ", original line \(.original_line) (outdated)"
@@ -6550,11 +6877,13 @@ control_review_intake() {
6550
6877
  }
6551
6878
 
6552
6879
  # control_comment_intake — read an `issue_comment` event; returns 1 when it is
6553
- # ignored, after one line.
6880
+ # ignored, after one line. The exact form sets CONTROL_VERB / CONTROL_ARGS; any
6881
+ # other mention of the handle as a word sets CONTROL_MENTION and no verb.
6554
6882
  CONTROL_SENDER_TYPE=""
6555
6883
  CONTROL_IS_PR=""
6884
+ CONTROL_MENTION=0
6556
6885
  control_comment_intake() {
6557
- local action first word rest handle
6886
+ local action first word rest handle verb body re
6558
6887
  { event_field '.action // ""' && action="$EVENT_VALUE" \
6559
6888
  && event_field '.comment.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
6560
6889
  && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
@@ -6581,15 +6910,27 @@ control_comment_intake() {
6581
6910
  rest=${first#"$word"}
6582
6911
  rest=${rest#"${rest%%[!$' \t']*}"}
6583
6912
  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
6913
+ if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" = "$handle" ]; then
6914
+ word=${rest%%[$' \t']*}
6915
+ verb=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
6916
+ case " $COMMAND_VERBS " in
6917
+ *" $verb "*)
6918
+ if [ -n "$verb" ]; then
6919
+ CONTROL_VERB="$verb"
6920
+ CONTROL_ARGS=${rest#"$word"}
6921
+ CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
6922
+ return 0
6923
+ fi ;;
6924
+ esac
6587
6925
  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
6926
+ body=$(printf '%s' "$CONTROL_BODY" | tr '[:upper:]' '[:lower:]')
6927
+ re="(^|[^a-z0-9])${handle}([^a-z0-9-]|\$)"
6928
+ if [[ $body =~ $re ]]; then
6929
+ CONTROL_MENTION=1
6930
+ return 0
6931
+ fi
6932
+ echo "remote-run.sh: control: ignored, the comment does not mention $COMMAND_HANDLE"
6933
+ return 1
6593
6934
  }
6594
6935
 
6595
6936
  # The close: CLOSE_KIND (`issue`, `pr_closed`, `pr_merged`, `deleted`) and
@@ -6730,10 +7071,11 @@ control_close() {
6730
7071
  CONTROL_BRANCH="$b"
6731
7072
  if [ "$CLOSE_KIND" = pr_closed ] || [ "$CLOSE_KIND" = pr_merged ]; then
6732
7073
  # 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.
7074
+ # own job stops that run from the default branch and reports it on this
7075
+ # pull request, so this one stays quiet.
6734
7076
  remote_branch_exists "$b"
6735
7077
  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" ;;
7078
+ 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
7079
  2) echo "remote-run.sh: control: whether \`$b\` exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
6738
7080
  esac
6739
7081
  fi
@@ -6771,9 +7113,416 @@ control_close() {
6771
7113
  exit "$EXIT_OK"
6772
7114
  }
6773
7115
 
7116
+ # control_commands_way — prints the way on that lists every command and names
7117
+ # the documentation, the unknown-verb refusal's and every mention refusal's.
7118
+ control_commands_way() {
7119
+ local v verbs=""
7120
+ for v in $COMMAND_VERBS; do
7121
+ [ "$v" != answer ] || v="answer [<n>]"
7122
+ verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
7123
+ done
7124
+ printf '%s' "The commands are $verbs; \`docs/github-run-control.md\` in the harness documentation states each."
7125
+ }
7126
+
7127
+ # The credential `verb_control`'s first statements take out of the environment;
7128
+ # never exported, so only the agent subshell in control_mention_session sees it.
7129
+ MENTION_OAUTH=""
7130
+ MENTION_API=""
7131
+ # The job's `GH_TOKEN` in the base64 form `actions/checkout` persists into the
7132
+ # checkout's git configuration; never exported.
7133
+ MENTION_JOB_TOKEN_B64=""
7134
+
7135
+ # mention_has_credential <string> — 0 when <string> contains, verbatim, a
7136
+ # non-empty saved credential value, or the job's `GH_TOKEN` raw or in its
7137
+ # persisted base64 form. An encoded or split copy is not caught.
7138
+ mention_has_credential() {
7139
+ if [ -n "$MENTION_OAUTH" ]; then
7140
+ case "$1" in *"$MENTION_OAUTH"*) return 0 ;; esac
7141
+ fi
7142
+ if [ -n "$MENTION_API" ]; then
7143
+ case "$1" in *"$MENTION_API"*) return 0 ;; esac
7144
+ fi
7145
+ if [ -n "${GH_TOKEN-}" ]; then
7146
+ case "$1" in *"$GH_TOKEN"*) return 0 ;; esac
7147
+ fi
7148
+ if [ -n "$MENTION_JOB_TOKEN_B64" ]; then
7149
+ case "$1" in *"$MENTION_JOB_TOKEN_B64"*) return 0 ;; esac
7150
+ fi
7151
+ return 1
7152
+ }
7153
+
7154
+ # mention_cap <file> <max bytes> <note> — over <max bytes>, <file> keeps its
7155
+ # first <max bytes> cut back to the last whole line, then the line <note>.
7156
+ mention_cap() {
7157
+ local file="$1" max="$2" note="$3" size
7158
+ size=$(wc -c <"$file") || return 1
7159
+ size=${size//[!0-9]/}
7160
+ [ "${size:-0}" -gt "$max" ] || return 0
7161
+ head -c "$max" "$file" >"$file.cut" || return 1
7162
+ if [ -n "$(tail -c 1 "$file.cut")" ]; then
7163
+ sed '$d' "$file.cut" >"$file.line" || return 1
7164
+ mv -f "$file.line" "$file.cut" || return 1
7165
+ fi
7166
+ printf '%s\n' "$note" >>"$file.cut"
7167
+ mv -f "$file.cut" "$file"
7168
+ }
7169
+
7170
+ # control_mention_context <dir> — comment.md, run.md and questions/ in <dir>,
7171
+ # every file capped at MENTION_FILE_MAX_BYTES; 1 when one cannot be written.
7172
+ control_mention_context() {
7173
+ local dir="$1" kind=issue n src f
7174
+ [ -z "$CONTROL_IS_PR" ] || kind="pull request"
7175
+ {
7176
+ printf 'handle: %s\n' "$COMMAND_HANDLE"
7177
+ printf 'Comment by @%s on %s #%s:\n\n' "$CONTROL_ACTOR" "$kind" "$CONTROL_NUMBER"
7178
+ printf '%s' "$CONTROL_BODY"
7179
+ } >"$dir/comment.md" || return 1
7180
+ {
7181
+ printf 'branch: %s\n' "$CONTROL_BRANCH"
7182
+ printf 'state: %s\n' "$CS_STATE"
7183
+ printf 'pause_reason: %s\n' "$CS_REASON"
7184
+ printf 'engine: %s\n' "$CS_ENGINE"
7185
+ printf 'open_questions: %s\n' "$CS_OPEN"
7186
+ printf 'detail: %s\n' "$CS_DETAIL"
7187
+ printf 'run_url: %s\n' "$CS_URL"
7188
+ printf 'run_status: %s\n' "$CS_RUN_STATUS"
7189
+ if [ "$CS_STOPPED" = 1 ]; then printf 'stopped: yes\n'; else printf 'stopped: no\n'; fi
7190
+ if ! control_ledger_next_var; then
7191
+ printf 'next: the flow-progress ledger could not be read\n'
7192
+ elif [ -z "$LEDGER_NEXT" ]; then
7193
+ printf 'next: every entry of the flow-progress ledger is ticked\n'
7194
+ else
7195
+ printf 'next: %s\n' "$LEDGER_NEXT"
7196
+ printf 'section: %s\n' "$LEDGER_SECTION"
7197
+ fi
7198
+ } >"$dir/run.md" || return 1
7199
+ mkdir "$dir/questions" || return 1
7200
+ for n in $CS_OPEN; do
7201
+ case "$n" in ''|*[!0-9]*) continue ;; esac
7202
+ src="$CS_DIR/clarifications/$CONTROL_BRANCH/question_$n.md"
7203
+ [ -f "$src" ] || continue
7204
+ cp "$src" "$dir/questions/question_$n.md" || return 1
7205
+ done
7206
+ for f in "$dir/comment.md" "$dir/run.md" "$dir"/questions/question_*.md; do
7207
+ [ -f "$f" ] || continue
7208
+ mention_cap "$f" "$MENTION_FILE_MAX_BYTES" "(cut at $MENTION_FILE_MAX_BYTES bytes)" || return 1
7209
+ done
7210
+ return 0
7211
+ }
7212
+
7213
+ # control_mention_context_extra <dir> — item.md, conversation.md and, on a pull
7214
+ # request, diff.patch in <dir>, each capped at MENTION_FILE_MAX_BYTES; a failed
7215
+ # read is one line in its file, never a refusal. 1 when a file cannot be
7216
+ # written. Every forge value reaches a file through `jq` or `printf '%s'` only.
7217
+ control_mention_context_extra() {
7218
+ local dir="$1" kind=issue own f
7219
+ [ -z "$CONTROL_IS_PR" ] || kind="pull request"
7220
+ if ! gh_call api "repos/$FORGE_REPO/issues/$CONTROL_NUMBER"; then
7221
+ printf 'The issue or pull request could not be read (%s).\n' "$GH_ERR" >"$dir/item.md" || return 1
7222
+ elif ! printf '%s' "$GH_OUT" | jq -j --arg kind "$kind" --arg n "$CONTROL_NUMBER" '
7223
+ "kind: \($kind)\nnumber: \($n)\ntitle: \(.title // "" | tostring)\nauthor: @\(.user.login // "" | tostring)\nurl: \(.html_url // "" | tostring)\n\n\(.body // "" | tostring)"' \
7224
+ >"$dir/item.md" 2>/dev/null; then
7225
+ printf 'The issue or pull request could not be read (its answer is not the expected JSON).\n' >"$dir/item.md" || return 1
7226
+ fi
7227
+
7228
+ # The commenter's own body goes to jq through a file: a comment can exceed
7229
+ # the kernel's bound on one argument.
7230
+ own="$dir.own"
7231
+ control_dirs="$control_dirs $own"
7232
+ printf '%s' "$CONTROL_BODY" >"$own" || return 1
7233
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$CONTROL_NUMBER/comments" --jq '.[] | {login: .user.login, at: .created_at, body: .body}'; then
7234
+ printf 'The comments of this %s could not be read (%s).\n' "$kind" "$GH_ERR" >"$dir/conversation.md" || return 1
7235
+ elif ! printf '%s' "$GH_OUT" | jq -s -j --arg actor "$CONTROL_ACTOR" --rawfile own "$own" \
7236
+ --arg marker "$COMMENT_MARKER" --arg kind "$kind" --argjson max "$MENTION_COMMENTS_MAX" '
7237
+ [.[] | objects] as $all
7238
+ | ([range(0; $all | length) | select($all[.].login == $actor and $all[.].body == $own)] | last) as $i
7239
+ | (if $i == null then $all else $all[:$i] end) as $before
7240
+ | (if ($before | length) > $max then $before[($before | length) - $max:] else $before end)
7241
+ | if length == 0 then "No comment precedes this one on the \($kind).\n"
7242
+ else map("### @\(.login // "" | tostring) at \(.at // "" | tostring)\n"
7243
+ + (if ((.body // "" | tostring) | contains($marker)) then "(posted by the harness)\n" else "" end)
7244
+ + "\n\(.body // "" | tostring)\n\n") | join("") end' \
7245
+ >"$dir/conversation.md" 2>/dev/null; then
7246
+ printf 'The comments of this %s could not be read (their listing is not the expected JSON).\n' "$kind" >"$dir/conversation.md" || return 1
7247
+ fi
7248
+
7249
+ if [ -n "$CONTROL_IS_PR" ]; then
7250
+ if ! gh_call pr diff "$CONTROL_NUMBER" --repo "$FORGE_REPO"; then
7251
+ printf "The pull request's diff could not be read (%s).\n" "$GH_ERR" >"$dir/diff.patch" || return 1
7252
+ else
7253
+ printf '%s\n' "$GH_OUT" >"$dir/diff.patch" || return 1
7254
+ fi
7255
+ fi
7256
+
7257
+ for f in "$dir/item.md" "$dir/conversation.md" "$dir/diff.patch"; do
7258
+ [ -f "$f" ] || continue
7259
+ mention_cap "$f" "$MENTION_FILE_MAX_BYTES" "(cut at $MENTION_FILE_MAX_BYTES bytes)" || return 1
7260
+ done
7261
+ return 0
7262
+ }
7263
+
7264
+ # control_mention_session <dir> <out> <err> — the one read-only session, run
7265
+ # once in a subshell in <dir>; AGENT_STATUS is its exit.
7266
+ AGENT_STATUS=0
7267
+ control_mention_session() {
7268
+ local dir="$1" out="$2" err="$3" schema model model_args
7269
+ AGENT_STATUS=0
7270
+ # Omitted rather than passed empty, so the CLI applies its own default; the
7271
+ # `${arr[@]+…}` form keeps an empty array safe under `set -u` on bash 3.2.
7272
+ model=$(hr_agent_model "$root" 2>/dev/null) || model=""
7273
+ model_args=()
7274
+ [ -z "$model" ] || model_args=(--model "$model")
7275
+ schema=$(jq -n -c --arg actions "$MENTION_ACTIONS" --arg verbs "$COMMAND_VERBS" '
7276
+ def words: split(" ") | map(select(length > 0));
7277
+ { type: "object", additionalProperties: false, required: ["action", "reason"],
7278
+ properties: {
7279
+ action: { type: "string", enum: ($actions | words) },
7280
+ verb: { type: "string", enum: ($verbs | words) },
7281
+ question: { type: "integer", minimum: 1 },
7282
+ answer: { type: "string" },
7283
+ text: { type: "string" },
7284
+ reason: { type: "string" } } }') || { AGENT_STATUS=1; return 0; }
7285
+ (
7286
+ cd "$dir" || exit 1
7287
+ unset GH_TOKEN GITHUB_TOKEN HARNESS_PR_TOKEN
7288
+ [ -z "$MENTION_OAUTH" ] || export CLAUDE_CODE_OAUTH_TOKEN="$MENTION_OAUTH"
7289
+ [ -z "$MENTION_API" ] || export ANTHROPIC_API_KEY="$MENTION_API"
7290
+ exec "$AGENT_CLI" -p "$MENTION_COMMAND" \
7291
+ --plugin-dir "$HARNESS_MENTION_PLUGIN_DIR" \
7292
+ --add-dir "$HARNESS_MENTION_PLUGIN_DIR/instructions" \
7293
+ --output-format json \
7294
+ --json-schema "$schema" \
7295
+ --tools Read,Grep,Glob --restricted \
7296
+ --strict-mcp-config --no-session-persistence --permission-prompts none \
7297
+ ${model_args[@]+"${model_args[@]}"} --max-budget-usd "$MENTION_MAX_BUDGET_USD"
7298
+ ) </dev/null >"$out" 2>"$err" || AGENT_STATUS=$?
7299
+ return 0
7300
+ }
7301
+
7302
+ # control_mention — MENTION (the header): the state, the credential, the plugin
7303
+ # and the binary, the context directory, one session, then its decision
7304
+ # extracted, validated, checked for a credential and answered. Always exits.
7305
+ control_mention() {
7306
+ local base dir out err why decision from rule action verb question reason text answer file reply quote
7307
+ control_state_var "$CONTROL_BRANCH" \
7308
+ || control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
7309
+ control_state_word_var
7310
+
7311
+ if [ -z "$MENTION_OAUTH" ] && [ -z "$MENTION_API" ]; then
7312
+ control_refuse "$EXIT_REFUSED" "\`$WORKFLOW_CONTROL_FILE\` passes the agent that reads a mention no credential, so a mention is not read" \
7313
+ "$(control_commands_way)"
7314
+ fi
7315
+ base=${MENTION_COMMAND##*:}
7316
+ if [ -z "${HARNESS_MENTION_PLUGIN_DIR-}" ] || [ ! -f "$HARNESS_MENTION_PLUGIN_DIR/commands/$base.md" ]; then
7317
+ control_refuse "$EXIT_GH" "the harness plugin carrying the mention command is not available to this job" \
7318
+ "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)"
7319
+ fi
7320
+ AGENT_CLI="${HARNESS_AGENT_CLI:-claude}"
7321
+ if ! command -v "$AGENT_CLI" >/dev/null 2>&1; then
7322
+ control_refuse "$EXIT_GH" "the agent binary \`$AGENT_CLI\` is not on this job's PATH" \
7323
+ "Comment a command instead. $(control_commands_way)"
7324
+ fi
7325
+
7326
+ if ! dir=$(mktemp -d "$control_tmp/harness-control-mention.XXXXXX"); then
7327
+ control_refuse "$EXIT_GH" "a context directory could not be created under '$control_tmp'" "Comment again to retry."
7328
+ fi
7329
+ out="$dir.out"
7330
+ err="$dir.err"
7331
+ control_dirs="$control_dirs $dir $out $err"
7332
+ { control_mention_context "$dir" && control_mention_context_extra "$dir"; } \
7333
+ || control_refuse "$EXIT_GH" "the mention's context could not be written under '$dir'" "Comment again to retry."
7334
+
7335
+ control_mention_session "$dir" "$out" "$err"
7336
+
7337
+ why=$(jq -r -s '
7338
+ if length != 1 or (.[0] | type) != "object" then "its output is not one JSON object"
7339
+ elif .[0].is_error == true then "it reported an error (\(.[0].subtype // "no subtype"))"
7340
+ elif (.[0].subtype // "") != "success" then "it ended as \(.[0].subtype // "no subtype")"
7341
+ else "" end' "$out" 2>/dev/null) || why="its output is not one JSON object"
7342
+ if [ "$AGENT_STATUS" -ne 0 ] || [ -n "$why" ]; then
7343
+ reason=$(grep -v '^[[:space:]]*$' "$err" 2>/dev/null | tail -n 1)
7344
+ ! mention_has_credential "$reason" || reason=""
7345
+ [ "$AGENT_STATUS" -eq 0 ] || why="it exited $AGENT_STATUS${reason:+: $reason}"
7346
+ control_refuse "$EXIT_GH" "the agent that reads a mention failed: $why" \
7347
+ "Comment again, or comment a command. $(control_commands_way)"
7348
+ fi
7349
+
7350
+ decision=$(jq -c -s '.[0] as $r
7351
+ | if ($r.structured_output | type) == "object" then { from: "structured_output", d: $r.structured_output }
7352
+ else ([$r.result | strings | fromjson? | objects] | first) as $p
7353
+ | if $p != null then { from: "result", d: $p } else { from: "", d: null } end
7354
+ end' "$out") || decision='{"from":"","d":null}'
7355
+ from=$(printf '%s' "$decision" | jq -r '.from')
7356
+ if [ -z "$from" ]; then
7357
+ control_refuse "$EXIT_REFUSED" "the agent's reading of the mention carries no decision object" \
7358
+ "Comment again, or comment a command. $(control_commands_way)"
7359
+ fi
7360
+ action=$(printf '%s' "$decision" | jq -r '.d.action | if type == "string" then gsub("[\r\n]+"; " ") else tojson end')
7361
+ verb=$(printf '%s' "$decision" | jq -r '.d.verb | strings | gsub("[\r\n]+"; " ")')
7362
+ reason=$(printf '%s' "$decision" | jq -r '.d.reason | strings | gsub("[\r\n]+"; " ") | .[0:200]')
7363
+ ! mention_has_credential "$reason" || reason="(withheld: it carries a credential value)"
7364
+ echo "remote-run.sh: control: mention on #$CONTROL_NUMBER by @$CONTROL_ACTOR read as $action${verb:+ $verb} from $from: $reason"
7365
+
7366
+ rule=$(printf '%s' "$decision" | jq -r --arg actions "$MENTION_ACTIONS" --arg verbs "$COMMAND_VERBS" '
7367
+ def words: split(" ") | map(select(length > 0));
7368
+ def nonblank: type == "string" and test("\\S");
7369
+ .d as $d
7370
+ | ($actions | words) as $A
7371
+ | ($verbs | words) as $V
7372
+ | if ($d | type) != "object" then "it is not an object"
7373
+ elif ($A | any(.[]; . == $d.action) | not) then "its `action` is not one of \($A | join(", "))"
7374
+ elif $d.action == "command" and ($V | any(.[]; . == $d.verb) | not) then "a `command` names no `verb` among \($V | join(", "))"
7375
+ elif $d.action == "command" and $d.verb == "answer" and ($d.answer | nonblank | not) then "an `answer` carries no non-blank `answer`"
7376
+ elif $d.action == "command" and $d.verb == "answer" and $d.question != null
7377
+ 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"
7378
+ elif ($d.action == "reply" or $d.action == "clarify") and ($d.text | nonblank | not) then "a `\($d.action)` carries no non-blank `text`"
7379
+ elif ($d.reason | type) != "string" then "it carries no `reason`"
7380
+ else "" end') || rule="it could not be read"
7381
+ if [ -n "$rule" ]; then
7382
+ control_refuse "$EXIT_REFUSED" "the agent's reading of the mention is not a valid decision: $rule" \
7383
+ "Comment again, or comment a command. $(control_commands_way)"
7384
+ fi
7385
+
7386
+ # Once, before any action is answered: no branch, this one or a later one,
7387
+ # sees a decision whose posted or dispatched text carries a credential.
7388
+ text=$(printf '%s' "$decision" | jq -r '.d.text | strings')
7389
+ answer=$(printf '%s' "$decision" | jq -r '.d.answer | strings')
7390
+ if mention_has_credential "$text" || mention_has_credential "$answer"; then
7391
+ 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"
7392
+ exit "$EXIT_GH"
7393
+ fi
7394
+
7395
+ case "$action" in
7396
+ none)
7397
+ echo "remote-run.sh: control: the mention on #$CONTROL_NUMBER needs no answer"
7398
+ exit "$EXIT_OK" ;;
7399
+ reply|clarify)
7400
+ file="$dir.text"
7401
+ control_dirs="$control_dirs $file"
7402
+ printf '%s' "$decision" | jq -r --arg login "$CONTROL_ACTOR" --arg handle "$COMMAND_HANDLE" \
7403
+ "$JQ_DEF_SANITISE"' .d.text | sanitise($login; $handle)' >"$file" \
7404
+ && mention_cap "$file" "$MENTION_TEXT_MAX_BYTES" "(cut)" \
7405
+ || control_refuse "$EXIT_GH" "the agent's reply could not be prepared under '$control_tmp'" "Comment again to retry."
7406
+ text=$(cat "$file")
7407
+ control_reply "$EXIT_OK" "@$CONTROL_ACTOR: $text"$'\n\n'"_Written by an agent that read your mention; it changed nothing. $(control_commands_way)_" ;;
7408
+ fixes)
7409
+ 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.\`)." ;;
7410
+ command)
7411
+ question=""
7412
+ [ "$verb" != answer ] || question=$(printf '%s' "$decision" | jq -r '.d.question | numbers | floor')
7413
+ case " $MENTION_CONFIRM_VERBS " in
7414
+ *" $verb "*)
7415
+ reply="@$CONTROL_ACTOR: your mention reads as \`$COMMAND_HANDLE $verb${question:+ $question}\`. Comment that command to carry it out"
7416
+ [ "$verb" != answer ] || reply="$reply, with the answer on the lines below it"
7417
+ control_reply "$EXIT_OK" "$reply." ;;
7418
+ esac
7419
+ case " $MENTION_ACT_VERBS " in
7420
+ *" $verb "*)
7421
+ # What control_answer reads from the exact form: the command line,
7422
+ # then the answer below it. The credential check above has already
7423
+ # refused an `answer` carrying a saved credential.
7424
+ CONTROL_BODY="$COMMAND_HANDLE $verb${question:+ $question}"
7425
+ reply="Read from your mention as \`$CONTROL_BODY\`."
7426
+ if [ "$verb" = answer ]; then
7427
+ # The `x` keeps the answer's final newlines through the substitution.
7428
+ answer=$(printf '%s' "$decision" | jq -j '.d.answer' && printf x) \
7429
+ || control_refuse "$EXIT_GH" "the agent's answer could not be read" "Comment again to retry."
7430
+ answer=${answer%x}
7431
+ quote=$(printf '%s' "$decision" | jq -r --arg login "$CONTROL_ACTOR" --arg handle "$COMMAND_HANDLE" \
7432
+ "$JQ_DEF_SANITISE $JQ_DEF_FENCE"' .d.answer | sanitise($login; $handle)
7433
+ | (if endswith("\n") then . else . + "\n" end) as $q
7434
+ | ($q | fence) as $f
7435
+ | $f + "\n" + $q + $f') \
7436
+ || control_refuse "$EXIT_GH" "the agent's answer could not be quoted" "Comment again to retry."
7437
+ CONTROL_BODY="$CONTROL_BODY"$'\n'"$answer"
7438
+ reply="Read from your mention as \`$COMMAND_HANDLE $verb${question:+ $question}\`, with this answer:"$'\n\n'"$quote"
7439
+ fi
7440
+ CONTROL_VERB="$verb"
7441
+ CONTROL_ARGS="$question"
7442
+ CONTROL_MENTION_NOTE="$reply"
7443
+ control_run_verb ;;
7444
+ esac ;;
7445
+ esac
7446
+ echo "::error::remote-run.sh: control: the validated action \`$action\` has no answer"
7447
+ exit "$EXIT_GH"
7448
+ }
7449
+
7450
+ # control_gates — refusals 1 to 3 of `control`, in its order, for both
7451
+ # `control` and `--needs-agent`. 0 when all pass; otherwise 1 with CG_EXIT,
7452
+ # CG_WHY and CG_WAY set as `control_refuse` takes them, and CG_AUTH the
7453
+ # `authorise_actor` status (0 when an earlier gate refused). Posts nothing.
7454
+ CG_EXIT=0
7455
+ CG_WHY=""
7456
+ CG_WAY=""
7457
+ CG_AUTH=0
7458
+ control_gates() {
7459
+ local forge target
7460
+ CG_EXIT="$EXIT_REFUSED"
7461
+ CG_WHY=""
7462
+ CG_WAY=""
7463
+ CG_AUTH=0
7464
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
7465
+ CG_WHY="the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command"
7466
+ CG_WAY="Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
7467
+ return 1
7468
+ fi
7469
+
7470
+ forge=$(hr_forge "$root") || forge=""
7471
+ target=$(hr_execution_target "$root") || target=""
7472
+ if [ "$forge" != github ] || [ "$target" != github-actions ]; then
7473
+ CG_WHY="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})"
7474
+ CG_WAY="Set both keys on the default branch, then comment again."
7475
+ return 1
7476
+ fi
7477
+
7478
+ if ! rerun_actor_listed; then
7479
+ CG_WHY="${RUN_ACTORS_WHY%.}"
7480
+ CG_WAY="Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can comment again."
7481
+ return 1
7482
+ fi
7483
+
7484
+ authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || CG_AUTH=$?
7485
+ if [ "$CG_AUTH" -ne 0 ]; then
7486
+ CG_WHY="${AUTH_WHY%.}"
7487
+ CG_WAY="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."
7488
+ return 1
7489
+ fi
7490
+ return 0
7491
+ }
7492
+
7493
+ # control_needs_agent_no <reason> — the `--needs-agent` answer that no session
7494
+ # starts: one line, exit 2.
7495
+ control_needs_agent_no() {
7496
+ echo "remote-run.sh: control: needs-agent: no, $1"
7497
+ exit "$EXIT_REFUSED"
7498
+ }
7499
+
7500
+ # control_needs_agent — `--needs-agent` after the comment intake: the exact
7501
+ # form, then `control_gates`, answered as one line and an exit. Never returns.
7502
+ control_needs_agent() {
7503
+ [ -z "$CONTROL_VERB" ] \
7504
+ || control_needs_agent_no "the comment is the exact form \`$COMMAND_HANDLE $CONTROL_VERB\`"
7505
+ if ! control_gates; then
7506
+ if [ "$CG_AUTH" -eq 4 ]; then
7507
+ echo "remote-run.sh: control: needs-agent: undecided, $AUTH_WHY"
7508
+ exit "$EXIT_GH"
7509
+ fi
7510
+ control_needs_agent_no "$CG_WHY"
7511
+ fi
7512
+ echo "remote-run.sh: control: needs-agent: yes, a mention by @$CONTROL_ACTOR on #$CONTROL_NUMBER"
7513
+ exit "$EXIT_OK"
7514
+ }
7515
+
6774
7516
  verb_control() {
6775
7517
  local LC_ALL=C
6776
- local review=0 close=0 forge="" target="" status verbs="" v
7518
+ local review=0 close=0
7519
+ # Unset first so an inherited export of either name cannot keep it exported.
7520
+ unset MENTION_OAUTH MENTION_API MENTION_JOB_TOKEN_B64
7521
+ MENTION_OAUTH="${IN_OAUTH-}"
7522
+ MENTION_API="${IN_API-}"
7523
+ MENTION_JOB_TOKEN_B64=""
7524
+ [ -z "${GH_TOKEN-}" ] || MENTION_JOB_TOKEN_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
7525
+ unset IN_OAUTH IN_API CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY
6777
7526
  case "${GITHUB_EVENT_NAME-}" in
6778
7527
  issue_comment) ;;
6779
7528
  pull_request_review) review=1 ;;
@@ -6788,9 +7537,17 @@ verb_control() {
6788
7537
  fi
6789
7538
  hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
6790
7539
 
7540
+ if [ "$needs_agent" -eq 1 ] && [ "$GITHUB_EVENT_NAME" != issue_comment ]; then
7541
+ control_needs_agent_no "a \`$GITHUB_EVENT_NAME\` event is not a comment"
7542
+ fi
7543
+
6791
7544
  case "$GITHUB_EVENT_NAME" in
6792
7545
  pull_request_review) control_review_intake || return 0 ;;
6793
- issue_comment) control_comment_intake || return 0 ;;
7546
+ issue_comment)
7547
+ if ! control_comment_intake; then
7548
+ [ "$needs_agent" -eq 0 ] || control_needs_agent_no "the comment is ignored"
7549
+ return 0
7550
+ fi ;;
6794
7551
  issues) control_issues_intake || return 0 ;;
6795
7552
  pull_request) control_pull_request_intake || return 0 ;;
6796
7553
  delete) control_delete_intake || return 0 ;;
@@ -6804,6 +7561,8 @@ verb_control() {
6804
7561
  esac
6805
7562
  fi
6806
7563
 
7564
+ [ "$needs_agent" -eq 0 ] || control_needs_agent
7565
+
6807
7566
  control_tmp="${RUNNER_TEMP-}"
6808
7567
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
6809
7568
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
@@ -6814,40 +7573,14 @@ verb_control() {
6814
7573
  # Decided by the event name, never CONTROL_VERB: a comment can name `close`.
6815
7574
  [ "$close" -eq 0 ] || control_close
6816
7575
 
6817
- if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
6818
- control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
6819
- "Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
6820
- fi
6821
-
6822
- forge=$(hr_forge "$root") || forge=""
6823
- target=$(hr_execution_target "$root") || target=""
6824
- if [ "$forge" != github ] || [ "$target" != github-actions ]; then
6825
- 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})" \
6826
- "Set both keys on the default branch, then comment again."
6827
- fi
6828
-
6829
- if ! rerun_actor_listed; then
6830
- control_refuse "$EXIT_REFUSED" "${RUN_ACTORS_WHY%.}" \
6831
- "Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can comment again."
6832
- fi
7576
+ control_gates || control_refuse "$CG_EXIT" "$CG_WHY" "$CG_WAY"
6833
7577
 
6834
- status=0
6835
- authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
6836
- if [ "$status" -ne 0 ]; then
6837
- control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
6838
- "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."
7578
+ if [ "$review" -eq 0 ] && [ "$CONTROL_MENTION" != 1 ] \
7579
+ && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
7580
+ control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" "$(control_commands_way)"
6839
7581
  fi
6840
7582
 
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."
6848
- fi
6849
-
6850
- forge_repo_var || control_reply "$EXIT_GH" "@$CONTROL_ACTOR: \`$CONTROL_VERB\` was not run: the repository's name could not be read."
7583
+ 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
7584
  if [ "$review" -eq 1 ]; then
6852
7585
  control_check_branch "$REVIEW_HEAD"
6853
7586
  elif [ -n "$CONTROL_IS_PR" ]; then
@@ -6856,7 +7589,16 @@ verb_control() {
6856
7589
  control_branch_from_issue "$CONTROL_NUMBER"
6857
7590
  fi
6858
7591
 
7592
+ if [ "$CONTROL_MENTION" = 1 ]; then
7593
+ echo "remote-run.sh: control: a mention on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
7594
+ control_mention
7595
+ fi
6859
7596
  echo "remote-run.sh: control: $CONTROL_VERB on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
7597
+ control_run_verb
7598
+ }
7599
+
7600
+ # control_run_verb — the arm carrying out CONTROL_VERB.
7601
+ control_run_verb() {
6860
7602
  case "$CONTROL_VERB" in
6861
7603
  answer) control_answer ;;
6862
7604
  pause) control_pause ;;