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.
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/doctor/checks.js +24 -7
- package/dist/doctor/checks.js.map +1 -1
- package/dist/remote/githubActions.js +42 -1
- package/dist/remote/githubActions.js.map +1 -1
- package/package.json +1 -1
- package/templates/github/workflows/harness-control.yml +114 -16
- package/templates/github/workflows/harness-run.yml +10 -1
- package/templates/scripts/autonomous-watcher.sh +4 -1
- package/templates/scripts/push-branch.sh +45 -4
- package/templates/scripts/remote-run.sh +741 -77
|
@@ -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.
|
|
315
|
-
#
|
|
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.
|
|
338
|
-
#
|
|
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
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
#
|
|
511
|
-
#
|
|
512
|
-
#
|
|
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
|
|
521
|
-
#
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
1107
|
-
#
|
|
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 `
|
|
1425
|
-
#
|
|
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("<!--"; "<!--")
|
|
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
|
|
4593
|
-
#
|
|
4594
|
-
#
|
|
4595
|
-
#
|
|
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
|
-
#
|
|
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 "$
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 "$
|
|
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="$
|
|
4876
|
-
same
|
|
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
|
-
#
|
|
5421
|
-
# it cannot
|
|
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
|
|
6317
|
-
#
|
|
6318
|
-
|
|
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
|
-
| (
|
|
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:]')"
|
|
6585
|
-
|
|
6586
|
-
|
|
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
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
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
|
|
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
|
|
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 ] &&
|
|
6842
|
-
|
|
6843
|
-
|
|
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 ;;
|