autonomous-sdlc-harness 0.6.2 → 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.
@@ -32,6 +32,8 @@
32
32
  # remote-run.sh discard <dir> [--repo <root>]
33
33
  # remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]
34
34
  # (always 0, 1 only on a usage error: its paragraph)
35
+ # remote-run.sh open <branch> [--repo <root>]
36
+ # (always 0, 1 only on a usage error: its paragraph)
35
37
  # remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]
36
38
  # (always 0, 1 only on a usage error: its paragraph)
37
39
  # remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
@@ -53,9 +55,7 @@
53
55
  # 1 usage error, or the library or the configuration could not be
54
56
  # resolved; for fetch, <out_dir> is not an existing, empty directory;
55
57
  # for discard, <dir>'s parent does not resolve or the removal failed;
56
- # for sync and restore, a local copy or write failed; for
57
- # pause-requested, also NO such run — a caller that reads 1 as "no
58
- # pause" passes arguments it has already validated
58
+ # for sync and restore, a local copy or write failed
59
59
  # 2 refused, nothing sent or written: execution.target is not
60
60
  # github-actions (sending verbs, fetch, review and list); for
61
61
  # review, a protected branch, a review file that is not a readable
@@ -91,6 +91,7 @@
91
91
  # copy (the branch checked out in another working copy included), the
92
92
  # fast-forward, the commit or the push — and nothing was dispatched; a
93
93
  # copy it cut was removed
94
+ # 5 pause-requested only: the read succeeded and found no such run
94
95
  #
95
96
  # `start` IS THE ADAPTERS' ONE ENTRY: every trigger (an issue event, a forge
96
97
  # dispatch, anything later) reduces to a branch and a task text and ends here.
@@ -285,17 +286,37 @@
285
286
  # as `trigger` is, and outside the sending-verb gate: it does nothing, with one
286
287
  # line, unless `hr_forge` is `github` and `hr_execution_target` is
287
288
  # `github-actions`. The comment goes to the open same-repository pull request
288
- # whose head is <branch> when origin's <branch> carries
289
- # `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
290
- # the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
289
+ # whose head is <branch> when origin's <branch> carries its task prompt or
290
+ # `<state_dir>/flow_progress/<branch>_progress.md` (`forge_recognised`), else to
291
+ # the issue named by the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
291
292
  # task prompt, else nowhere (`stop`'s `--pr` and `--branch-gone` change this
292
293
  # for its own report: its paragraph). The label `STATE_LABEL_PREFIX<state>` replaces any
293
294
  # other state label on that issue and that pull request, each when known; the
294
295
  # label is a view, and the run list stays the authority. The state map:
295
296
  # `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
296
- # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
297
- # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round` and
298
- # `failed` — posts nothing and sets no label when `remote_branch_stopped`,
297
+ # `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running,
298
+ # `not_started` (collect's) -> paused or failed, as its caller says. A
299
+ # `not_started` comment says GitHub did not start the run's job, so nothing ran
300
+ # and the branch is unchanged, and names its way on: `COMMAND_HANDLE resume`
301
+ # when the state is paused and an engine was recovered, else the **Run
302
+ # workflow** form with that engine, or with the three to choose from.
303
+ # On a pull-request target that is not a draft, `round` runs `gh pr ready
304
+ # --undo` before its comment, with the caller's own token, and its comment says
305
+ # the pull request is a draft again until the round completes; a refusal (a
306
+ # plan without drafts) is one `::warning::` line naming gh's error, and the
307
+ # comment then says turning it back was refused and it stays ready for review
308
+ # while the round works. A `failed` comment on a pull request reads the
309
+ # registry record's `engine`: for
310
+ # `user_review` it names a review requesting changes and says the pull request
311
+ # stays open; otherwise it says the draft stays open, to close to discard the
312
+ # run, or, when an issue is known, to re-apply the trigger label there for a
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. 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.
318
+ # Every job event — `parked`, `park_loop`, `paused`, `resumed`, `round`,
319
+ # `failed` and `not_started` — posts nothing and sets no label when `remote_branch_stopped`,
299
320
  # asked afresh, finds the branch stopped, so a job a stop overtook never
300
321
  # overwrites `stopped`; a failed listing reports anyway, and `stopped` is never
301
322
  # withheld. `completed` (deliver's) and `launched` (the trigger's
@@ -315,40 +336,137 @@
315
336
  # `COMMAND_HANDLE answer <n>` over `<your answer>`; the marker adds
316
337
  # `question=<n>`. With no question open it posts nothing, sets no label and
317
338
  # prints one `::error::` line naming the branch. The label
318
- # is set once per target, not per question. On a public repository a question
319
- # comment and its answer are public, as the artifact already is
339
+ # is set once per target, not per question. When the registry's `pause_reason`
340
+ # is `user` (a pause the job dropped and the run never honoured), `parked`
341
+ # appends `PAUSE_FOLDED_NOTE` to <note> and `park_loop` appends
342
+ # `PAUSE_FOLDED_HOLD_NOTE`, each after one blank line. On a public repository a
343
+ # question comment and its answer are public, as the artifact already is
320
344
  # (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
321
345
  # repository's Actions runs can see*).
346
+ # `progress` (`forge_progress`) keeps ONE comment per run or round on the pull
347
+ # request, never on the issue, and sets no label. Gated in order, each stop one
348
+ # line: `forge_on`; `hr_progress_comments` (`execution.progressComments` false,
349
+ # or unreadable); `remote_branch_stopped`, as for the job events above; no task
350
+ # or user-review ledger at `$root`'s `<state_dir>/flow_progress/<branch>_progress.md`
351
+ # (`hr_ledger_phases` — a docs-engine ledger posts nothing); no open
352
+ # same-repository pull request that `forge_recognised` holds for. The body is
353
+ # *Progress of the harness run on `<branch>`:* or *Progress of user-review round
354
+ # <n> on `<branch>`:*, then `- <label>: done`, `in progress` (the first pending
355
+ # phase) or `not started` for `Planning`, `Implementation`, `Branch review`,
356
+ # `Done` (a round: `Fix plan`, `Fix implementation`, `Branch review`, `Done`) —
357
+ # plain list items, no timestamp, no run URL — then the marker
358
+ # `<!-- sdlc-harness event=progress branch=<branch> -->`, gaining ` round=<n>`
359
+ # for a round. One paginated comment listing picks the newest
360
+ # `github-actions[bot]` comment whose last non-empty line is that marker: none
361
+ # is one `forge_comment`; a body equal to the render (carriage returns and
362
+ # trailing newlines aside) is no call and one `already current` line; otherwise
363
+ # one `PATCH` of that comment — the only comment this file ever edits. A refused
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.
322
368
  # It never fails its caller: every problem is one line and exit 0.
323
369
  #
370
+ # `open` OPENS THE RUN'S DRAFT PULL REQUEST at the run's start, so its issue
371
+ # links it from the moment it exists. Job-side for its root and self-gated, as
372
+ # `deliver` is (`forge_on`, one line when off); it takes <branch> and no
373
+ # bundle. A pull-request lookup that fails is one `::warning::` line and opens
374
+ # nothing; an open same-repository pull request whose head is <branch> is one
375
+ # line, and none is opened. Otherwise `forge_open_pr` — the one opener, which
376
+ # `deliver` also calls — creates it against `defaultBranch` with `deliver`'s
377
+ # title, body, token and retry rules: a `--draft` create with
378
+ # `HARNESS_PR_TOKEN` as `GH_TOKEN` when set, no retry on `PR_CREATE_FORBIDDEN`,
379
+ # one retry without `--draft` on any other failure. Opened, the pull request is
380
+ # labelled `STATE_LABEL_PREFIX`running, and when the task prompt names an issue
381
+ # one comment there names it — as a draft, or as not a draft because the
382
+ # repository's plan may not offer drafts — says the run's questions, lifecycle
383
+ # comments and progress go to it from now on while `COMMAND_HANDLE` commands
384
+ # keep working on the issue, adds this run's URL when `GITHUB_RUN_ID` is set,
385
+ # and ends with the `opened` marker. With no issue nothing is posted. Not
386
+ # opened is one `::warning::` line naming why and saying the run goes on, its
387
+ # comments reach the issue and `deliver` tries again at the run's end; nothing
388
+ # is posted, since `deliver`'s `completed` comment is the one notification.
389
+ # It never fails its caller: every outcome but a usage error is exit 0.
390
+ #
324
391
  # `deliver` HANDS A COMPLETED RUN TO REVIEW: the run workflow's step after the
325
392
  # job's own `push-branch.sh`, which opens no pull request. Job-side for its
326
393
  # root and self-gated, as `report` is (`forge_on`, one line when off). Anything
327
394
  # but `status: completed` in <bundle_dir>/status.json, or no status.json, is
328
- # one line and nothing sent. Otherwise, by the forge surface: an open
329
- # same-repository pull request whose head is <branch> is reused and no second
330
- # one is opened; else a draft is created against `defaultBranch`, titled from
331
- # the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
332
- # <branch>), whose body names the issue as `Started from #<n>.` — a plain
333
- # mention, never a closing keyword — states what a review requesting changes
334
- # and the `COMMAND_HANDLE` commands do, and ends with the `pull-request`
335
- # marker. The create runs with `HARNESS_PR_TOKEN` as `GH_TOKEN` when that is
336
- # set, so the adopter's CI runs without an approval click; every other call
337
- # uses the job's token. A pull request so opened is authored by that token's
338
- # owner, who therefore cannot request changes on it: use a machine account's
339
- # token, another reviewer, or a local `branch-user-review` round. A create
340
- # refused with `PR_CREATE_FORBIDDEN` is not retried, and the comment names the
341
- # Actions setting and `HARNESS_GIT_TOKEN`; any other failure is retried once
342
- # without `--draft`, and a second failure is named in the comment with the
343
- # branch's compare URL. A pull-request lookup that fails opens nothing. Then
344
- # one `completed` comment: on the issue naming the new pull request's URL; on
345
- # the pull request when there is no issue, or when it existed before this run
346
- # (saying the round finished); on the issue alone when none could be opened;
347
- # nowhere when neither is known. It names reviewing and requesting changes as
348
- # the next action, and with `phases.qa` true the local `branch-qa-test` still
349
- # owed. Then `sdlc-harness: done` on the issue and the pull request, each when
350
- # known. It writes at most one pull request, one comment and those labels, and
351
- # never pushes. It never fails its caller: every problem is one line and exit 0.
395
+ # one line and nothing sent. The bundle's `engine` names what completed:
396
+ # `user_review` is a round, anything else a run. An open same-repository pull
397
+ # request whose head is <branch> is reused and no second one is opened. With
398
+ # none open and a lookup that succeeded, `forge_open_pr` is the fallback — one
399
+ # line says none was open at completion, because the start's attempt failed or
400
+ # the workflow predates `open`. It is created against `defaultBranch`, titled
401
+ # from the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
402
+ # <branch>), with a body naming the issue as `Started from #<n>.` — a plain
403
+ # mention, never a closing keyword — stating what a review requesting changes
404
+ # and the `COMMAND_HANDLE` commands do, and ending with the `pull-request`
405
+ # marker: a `--draft` create with `HARNESS_PR_TOKEN` as `GH_TOKEN` when set, so
406
+ # the adopter's CI runs without an approval click, no retry on
407
+ # `PR_CREATE_FORBIDDEN`, one retry without `--draft` on any other failure. A
408
+ # pull request so opened is authored by that token's owner, who therefore
409
+ # cannot request changes on it: use a machine account's token, another
410
+ # reviewer, or a local `branch-user-review` round. A lookup that fails opens
411
+ # nothing. Then the ready flip, with the job's token and never
412
+ # `HARNESS_PR_TOKEN`: a draft gets one `gh pr ready` (flipped), and a refusal
413
+ # is one `::warning::` line naming gh's error (refused); a pull request that is
414
+ # not a draft — no drafts on the plan, or a person marked it ready — gets no
415
+ # call (not a draft) and is left alone. A round's review threads are handled
416
+ # next, after the flip and before the comments, with the job's token, per THE
417
+ # REVIEW-COMMENT LINE below: only when the pull request is known, the
418
+ # highest-numbered round file on origin's tip is the highest-numbered marked
419
+ # one (`round_markers_read`) — a round placed by the local command consumes no
420
+ # pull-request item — and its marker's `comments=` is non-empty; otherwise one
421
+ # line. Its fix plan is `<branch>_fix_plan.md` and `<branch>_fix_plan/` for
422
+ # round 1, `<branch>_fix_plan_<n>.md` and `<branch>_fix_plan_<n>/` for round
423
+ # <n>, read from origin's tip. A verdict is kept only for an id the round
424
+ # collected: `fixed <sha>` for an implemented finding, `reason <text>` for an
425
+ # out-of-scope bullet; a collected id with none is one line, left alone. One
426
+ # paginated `api graphql` listing of the pull request's `reviewThreads` (`id`,
427
+ # `isResolved`, each comment's `databaseId`, `createdAt` and `body`); the
428
+ # thread holding a collected id is that id's thread. Left alone, one line
429
+ # each: a thread already resolved; a thread with a comment created after the
430
+ # round's `collected_at` that is not a collected id and carries no
431
+ # `COMMENT_MARKER` — the reviewer replied after the collection; and a thread
432
+ # already carrying this round's `thread` marker (` round=<n>` included), so a
433
+ # re-run of the same round replies nothing twice while a later round still
434
+ # handles a thread an earlier round replied to. Otherwise one reply, through
435
+ # `pulls/<pr>/comments/<id>/replies` to the thread's first comment, ending with
436
+ # `forge_marker thread <branch>` and the round's ` round=<n>` — whose
437
+ # `COMMENT_MARKER` keeps the next `round_collect` from collecting it: for
438
+ # `fixed`, ``Addressed in `<sha>`.`` and, only once that reply is posted, one
439
+ # `resolveReviewThread` mutation; for `reason`, `Not changed in this round:
440
+ # <reason>`, the thread left open. A refused listing, reply or resolve is one
441
+ # `::warning::` line. It never dismisses a review.
442
+ # THE REVIEW-COMMENT LINE is this file's contract, written by one writer,
443
+ # `plugin/agents/user-review-fix-plan-writer.md`, and read by `deliver` alone:
444
+ # - in a per-finding file `<state_dir>/user_reviews/<branch>_fix_plan[_<n>]/
445
+ # finding_<K>.md`, one line beginning `**Review comments:** ` followed by
446
+ # one or more comment ids separated by `, `, e.g.
447
+ # `**Review comments:** 2735551234, 2735551240`;
448
+ # - in the fix-plan index's `## Out of scope / verified-OK` section, a bullet
449
+ # (a line beginning `- `) ending with ` **Review comments:** <id>[, <id>…]`,
450
+ # its text before that suffix the reason the reply quotes;
451
+ # - the ids are the round marker's `comments=` ids, GitHub's review-comment
452
+ # `id`s; an observation from no inline comment carries no such line;
453
+ # - finding <K> is implemented when the index carries the literal
454
+ # `[x] **Finding <K>**`, and its fix commit is the oldest commit on
455
+ # `origin/<branch>` that introduced that literal into the index (`git log
456
+ # --reverse -S`), else origin's tip — the committer flips the entry in the
457
+ # fix's own commit.
458
+ # Then one `completed` comment
459
+ # on the pull request, its readiness clause by the flip's outcome, and one on
460
+ # the issue naming the pull request's number and URL, each when known; with no
461
+ # pull request, one comment on the issue alone naming why none could be opened
462
+ # — the Actions setting and `HARNESS_GIT_TOKEN` on `PR_CREATE_FORBIDDEN`, else
463
+ # gh's error — with the branch's compare URL; nowhere when neither is known.
464
+ # Each names reviewing and requesting changes as the next action, and with
465
+ # `phases.qa` true the local `branch-qa-test` still owed. Then
466
+ # `sdlc-harness: done` on the issue and the pull request, each when known. It
467
+ # writes at most one pull request, one flip, a round's thread replies and
468
+ # resolves, two comments and those labels, and never pushes. It never fails its caller: every problem, a refused flip
469
+ # included, is one line and exit 0.
352
470
  #
353
471
  # `collect` STARTS THE NEXT ROUND FROM THE REVIEWS COLLECTED DURING A RUN: the
354
472
  # one step of the run workflow's `collect` job, which follows its `run` job, so
@@ -356,19 +474,27 @@
356
474
  # Job-side for its root and self-gated, as `report` and `deliver` are
357
475
  # (`forge_on`, one line when off). In order, each stop one line and exit 0:
358
476
  # `HARNESS_REMOTE_STOP` set; `remote_branch_stopped` finding the branch stopped,
359
- # or failing; no pull request, from --pr or else `forge_pr_var` (a failed lookup
360
- # included); `branch_settled_var`, read as `control` reads it, finding the
477
+ # or failing; `branch_settled_var`, read as `control` reads it, failing; the
478
+ # newest run's job never started — when that run is this one (or
479
+ # `GITHUB_RUN_ID` is unset), `report`'s `not_started` on its pull request or
480
+ # issue, with the state and engine the read derived, and one push notification,
481
+ # no round collected, since the reviews stay for the resumed run's own end;
482
+ # when it is another run, one line, since that run's own `collect` reports it;
483
+ # no pull request, from --pr or else `forge_pr_var` (a failed lookup
484
+ # included); the settledness read finding the
361
485
  # branch in flight — a newer run listed, or this run ending `parked`,
362
486
  # `park_loop` or `paused` (a budget chain's included), whose own end collects
363
- # next — or failing; `round_collect` finding no review requesting changes
487
+ # next; `round_collect` finding no review requesting changes
364
488
  # pending (inline comments alone start no round, as *Comment* starts none; they
365
489
  # ride along in the next), or failing, a `::warning::` line. Otherwise `review
366
490
  # <branch> --review-file <file> --allow-no-run --reviewers <logins> --source
367
491
  # <pull request url>` runs as a child, as `control` runs it, placing,
368
492
  # dispatching and reporting the round. A child that exits non-zero gets exactly
369
493
  # one comment on the pull request, with the `reply` marker, naming its last
370
- # stderr line and saying the reviews stay there and that submitting a review
371
- # requesting changes retries; there is no automatic retry. It never fails its
494
+ # stderr line and saying the reviews stay there and that, when `GITHUB_RUN_ID`
495
+ # is set, re-running this run's `collect` job retries with no new review, and
496
+ # that submitting a review requesting changes retries; there is no automatic
497
+ # retry. It never fails its
372
498
  # caller: every outcome but a usage error is exit 0.
373
499
  #
374
500
  # `control` IS THE COMMENT AND REVIEW ADAPTER, the twin of `trigger` and the one
@@ -386,12 +512,17 @@
386
512
  # into a variable (data, never shell source), plus `trigger`'s environment.
387
513
  # Ignored, with one line and no `gh` call: an action other than `created`; a
388
514
  # body carrying `COMMENT_MARKER` anywhere (the harness's own comment, whoever
389
- # posted it); and a body whose first line — a trailing CR stripped, leading
390
- # spaces and tabs skipped — does not open with a word equal to
391
- # `COMMAND_HANDLE`, compared lowercase. So `pause`, `Let's @sdlc-harness
392
- # pause` and `> @sdlc-harness pause` start nothing. The verb is the next word,
393
- # lowercased, and `CONTROL_ARGS` the rest of that line. Then refused, in this
394
- # order, each a reply and exit 2:
515
+ # posted it); and a body that does not hold `COMMAND_HANDLE` as a word
516
+ # anywhere, compared lowercase (the ERE `(^|[^a-z0-9])<handle>([^a-z0-9-]|$)`).
517
+ # So `pause` and `foo@sdlc-harnessx` start nothing. THE EXACT FORM is a first
518
+ # line — a trailing CR stripped, leading spaces and tabs skipped — opening with
519
+ # a word equal to `COMMAND_HANDLE`, compared lowercase, whose next word,
520
+ # lowercased, is a `COMMAND_VERBS` word: that is the verb, and `CONTROL_ARGS`
521
+ # the rest of that line. Any other body holding the handle is A MENTION
522
+ # (`CONTROL_MENTION`, no verb): `Let's @sdlc-harness pause`, `> @sdlc-harness
523
+ # pause`, the handle on a later line, and `@sdlc-harness check the question`
524
+ # alike, each read by MENTION below. Then refused, in this order, each a reply
525
+ # and exit 2:
395
526
  # 1. `HARNESS_REMOTE_STOP` is set
396
527
  # 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
397
528
  # GitHub nothing about the commenter
@@ -399,8 +530,89 @@
399
530
  # refuses, before the event's own actor is checked
400
531
  # 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run — a
401
532
  # writer the `HARNESS_RUN_ACTORS` allow-list admits, or a listed bot
402
- # 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
403
- # yet: the reply lists every command and names `docs/github-run-control.md`
533
+ # 4. the exact form's verb is one no arm carries out (`control_verb_handled`;
534
+ # no such word exists today): the reply lists every command and names
535
+ # `docs/github-run-control.md`. Skipped for a mention.
536
+ # Then THE BRANCH, and the exact form's arm (`control_run_verb`) or MENTION.
537
+ # MENTION. `verb_control`'s first statements, for every event, copy `IN_OAUTH`
538
+ # / `IN_API` into the non-exported `MENTION_OAUTH` / `MENTION_API` and unset
539
+ # them, with `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`, so no `gh`, `git`
540
+ # or `control_child` process the job spawns inherits a credential; only the
541
+ # agent subshell receives it. After the gates and the branch, `control_mention`
542
+ # reads the state (`control_state_var`; a failed read is exit 3), then refuses
543
+ # with no session: no saved credential (exit 2, the reply listing every
544
+ # command); no `commands/<basename of MENTION_COMMAND>.md` under
545
+ # `HARNESS_MENTION_PLUGIN_DIR` (exit 3, naming `HARNESS_CLI_VERSION` in
546
+ # `harness-run.yml` and `docs/remote-execution.md` -> `### Upgrading`); and an
547
+ # agent binary, `${HARNESS_AGENT_CLI:-claude}`, that `command -v` does not
548
+ # resolve (exit 3). The plugin directory is a path only: nothing under it is
549
+ # sourced or run by this script. THE CONTEXT DIRECTORY, a fresh one under
550
+ # `RUNNER_TEMP` (removed on exit): `comment.md` (`handle: <COMMAND_HANDLE>`,
551
+ # `Comment by @<login> on <issue|pull request> #<n>:`, a blank line, the body
552
+ # verbatim), `run.md` (`branch:`, the `fetch` keys, `stopped: yes|no`, the next
553
+ # ledger entry and its section, or that every entry is ticked or the ledger
554
+ # could not be read), `questions/question_<n>.md` per open question,
555
+ # `item.md` (`kind:`, `number:`, `title:`, `author:`, `url:`, a blank line, the
556
+ # body, from `api repos/<repo>/issues/<n>`), `conversation.md` (the last
557
+ # `MENTION_COMMENTS_MAX` comments before the commenter's own, oldest first,
558
+ # each `### @<login> at <at>`, `(posted by the harness)` when it carries
559
+ # `COMMENT_MARKER`, then its body, from the paginated comment listing) and, on
560
+ # a pull request, `diff.patch` (`pr diff`, text only: nothing is checked out),
561
+ # each capped at `MENTION_FILE_MAX_BYTES` at a whole line with a `(cut at <n>
562
+ # bytes)` line; a failed read is said in the file, never refused. THE SESSION runs once, never retried, in a subshell `cd` into
563
+ # that directory with `GH_TOKEN`, `GITHUB_TOKEN` and `HARNESS_PR_TOKEN` unset
564
+ # and `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` exported from whichever
565
+ # saved value is set: `-p "$MENTION_COMMAND"` (no argument, so no comment text
566
+ # reaches argv) `--plugin-dir <dir> --add-dir <dir>/instructions
567
+ # --output-format json --json-schema <built by jq from MENTION_ACTIONS and
568
+ # COMMAND_VERBS> --tools Read,Grep,Glob --restricted --strict-mcp-config
569
+ # --no-session-persistence --permission-prompts none --model <agentModel>
570
+ # --max-budget-usd "$MENTION_MAX_BUDGET_USD"`, the model read through
571
+ # `hr_agent_model` (a command declares no `model:`) and the flag left off only
572
+ # when the configuration cannot be read; never `--bare`, which reads no
573
+ # OAuth token, and never `--disable-slash-commands`, which would stop the `-p`
574
+ # command expanding. The command is `plugin/commands/harness-read-mention.md`
575
+ # (`MENTION_COMMAND`), and it loads `plugin/instructions/mention_reading.md`,
576
+ # whose `## Output contract` restates the decision contract validated here: a
577
+ # field renamed on either side is an edit to both. A command declares no
578
+ # allowlist, so the read-only closure is the session's `--tools` and
579
+ # `--restricted` alone. EXTRACTION: a non-zero exit, stdout that is not one
580
+ # JSON object, `.is_error` true or a `.subtype` other than `success` is a
581
+ # failure, exit 3; the decision is `.structured_output` when an object, else
582
+ # `.result | fromjson?` when an object, and one stdout line logs it (`mention
583
+ # on #<n> by @<login> read as <action>[ <verb>] from <field>: <reason>`).
584
+ # VALIDATION, by `jq`, the sole authority whatever the schema did: `action` in
585
+ # `MENTION_ACTIONS`; `command` needs a `verb` in `COMMAND_VERBS`; `answer`
586
+ # needs a non-blank `answer` and an optional integer `question` of 1 or more;
587
+ # `reply` and `clarify` need a non-blank `text`; `reason` is a string. Unknown
588
+ # keys are ignored; an invalid decision is refused, exit 2, naming the first
589
+ # rule broken. Then, once and before any action is answered, a `text` or
590
+ # `answer` holding a saved credential value, or the job's `GH_TOKEN` raw or in
591
+ # the base64 form `actions/checkout` persists, verbatim, is an `::error::` line
592
+ # that never prints it, nothing posted, exit 3. THE ANSWERS, each through `control_post`:
593
+ # `none` posts nothing; `reply` / `clarify` post `@<login>: <text>` — `text`
594
+ # through `JQ_DEF_SANITISE` (`<!--` neutralised, every other `@<login>` but the
595
+ # commenter's and the handle given U+200B) and capped at
596
+ # `MENTION_TEXT_MAX_BYTES` with a `(cut)` line — then a footer saying an agent
597
+ # wrote it and changed nothing, listing the commands; `fixes` posts this
598
+ # script's text pointing at a review requesting changes or
599
+ # `/autonomous-sdlc-harness:branch-user-review`; `command` with a verb in
600
+ # `MENTION_ACT_VERBS` is carried out by that verb's own arm (`control_run_verb`)
601
+ # from the exact form's `CONTROL_ARGS` and `CONTROL_BODY` — for `answer`, the
602
+ # command line then the decision's `answer` below it, byte for byte — every
603
+ # reply the arm posts opening with `CONTROL_MENTION_NOTE`, `Read from your
604
+ # mention as` and that form, `answer` adding the answer fenced by
605
+ # `JQ_DEF_FENCE` after `JQ_DEF_SANITISE`; an `answer` carrying a credential
606
+ # value was refused above, so it reaches neither the answer file, the dispatch
607
+ # nor the note; a state the arm refuses is that arm's refusal and exit,
608
+ # unchanged. `command` with a verb in `MENTION_CONFIRM_VERBS` posts a
609
+ # confirmation request naming the exact form it was read as and carries out no
610
+ # verb: `stop` because it is destructive, `clear` because on GitHub typing it
611
+ # is `branch-resume`'s confirmation. Exits: 0 answered or nothing to do; 2
612
+ # refused; 3 a state read, plugin, binary or agent failure, or a credential
613
+ # value in `text` or `answer`; a carried-out verb, its arm's. The workflow's
614
+ # interface: `IN_OAUTH` / `IN_API` and
615
+ # `HARNESS_MENTION_PLUGIN_DIR`.
404
616
  # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
405
617
  # `pr view`: a fork's pull request is refused, because this event carries the
406
618
  # repository's secrets, and nothing from its head is checked out or run; one
@@ -412,8 +624,20 @@
412
624
  # marker quoted mid-body, in a comment not opening with that sentence, or by
413
625
  # anyone else, is never trusted; none is a refusal. Both paths then pass
414
626
  # `control_check_branch`: a branch `hr_branch_is_protected` does not answer 1
415
- # for is refused, then it is fetched, and one whose origin tip carries no
416
- # flow-progress ledger (`forge_recognised`) is not a harness branch.
627
+ # for is refused, then it is fetched. It is a harness branch when its origin
628
+ # tip carries its task prompt or the flow-progress ledger (`forge_recognised`)
629
+ # — the task prompt, `start`'s first commit, makes a pull request opened at
630
+ # the run's start commandable before the ledger's first push; or, for a command
631
+ # typed on an issue, when it is the branch that issue's genuine `started`
632
+ # marker names and it still exists on origin (`remote_branch_exists`) — the
633
+ # marker shows the harness started a run on it, so a first run GitHub never
634
+ # started, which committed only its task prompt, is still commandable; an
635
+ # issue keeps its marker after its branch is deleted, so a marker-named branch
636
+ # gone from origin is refused as deleted, and a failed existence check is a
637
+ # refusal naming the read; or when a `harness run <branch>` run is queued,
638
+ # waiting, requested, pending or in progress (`control_run_in_flight`), so a
639
+ # run is commandable from its first minute on its pull request too. Otherwise
640
+ # it is not a harness branch. A failed listing is a refusal naming the read.
417
641
  # THE ARMS. `pause`: the state by a `fetch` child (`control_state_var`); only
418
642
  # `running` sends `pause <branch>` as a child and replies that the run yields at
419
643
  # its next clean checkpoint; any other state is a refusal naming it. `stop`:
@@ -428,9 +652,11 @@
428
652
  # accepts only `park_loop`, the GitHub form of `branch-resume`'s confirmation;
429
653
  # any other state is a refusal naming it. Each sends the local relay's dispatch,
430
654
  # `dispatch <branch> --engine <the state's engine> --resume pause --chain 0`,
431
- # `clear` with `park_loop_clear` set, and no other command sets it. An empty
432
- # engine is refused, never guessed (the `engine` input defaults to `task`),
433
- # naming the Run workflow form. On 0 a reply, then `running` on the run's issue
655
+ # `clear` with `park_loop_clear` set, and no other command sets it. A run whose
656
+ # job GitHub never started first takes the engine its dispatch's comment
657
+ # records (`forge_dispatch_engine_var`); an engine still empty after that is
658
+ # refused, never guessed (the `engine` input defaults to `task`), and the
659
+ # refusal names the Run workflow form. On 0 a reply, then `running` on the run's issue
434
660
  # and pull request; on 2 a refusal and exit 2. `answer`: the first line is
435
661
  # `answer <n>` and the answer every line below it, a trailing CR stripped from
436
662
  # each line and its bytes otherwise unchanged; text after <n> on the first line
@@ -555,8 +781,10 @@
555
781
  # submitting a review requesting changes retries now.
556
782
  # A child's failure is a reply
557
783
  # naming its last stderr line, and exit 3. Every reply goes to the item the comment was
558
- # typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
559
- # `@<login>: `<verb>` was not run: <reason>. <way on>`.
784
+ # typed on, opens `@<login>`, and carries the `reply` marker; a reply posted
785
+ # after a successful dispatch adds ` engine=<engine>` to that marker, the engine
786
+ # the dispatch named, so a run whose job never ran can still be resumed with
787
+ # it. A refusal reads `@<login>: `<verb>` was not run: <reason>. <way on>`.
560
788
  # THE CLOSE. Whatever the run's phase, it is stopped when its issue is closed,
561
789
  # its pull request closed or merged, or its branch deleted; the routing is by
562
790
  # event name, never by verb, so a comment naming `close` is not one. `issues`
@@ -583,10 +811,11 @@
583
811
  # 5. `hr_branch_is_protected` does not answer 1
584
812
  # 6. on `pull_request`, the head branch absent on origin (`remote_branch_exists`
585
813
  # answers 1): GitHub closed the pull request because the branch was
586
- # deleted, and the `delete` event's job stops the run; an `ls-remote` that
814
+ # deleted, and the `delete` event's job stops the run and reports it on
815
+ # this pull request; an `ls-remote` that
587
816
  # cannot answer is one line and proceeds
588
- # There is no ledger-at-tip check (`forge_recognised`): a merged or deleted
589
- # branch may no longer carry one, and a listed `harness run <b>` run is the
817
+ # There is no harness-branch check (`forge_recognised`): a merged or deleted
818
+ # branch may no longer carry its task prompt or ledger, and a listed `harness run <b>` run is the
590
819
  # proof. The state is read by `control_state_var`: only `running`, `parked`,
591
820
  # `park_loop` and `paused` are acted on; `completed`, `failed` and `none` are
592
821
  # `left alone: the run on <b> is <state>`, and one `remote_branch_stopped`
@@ -683,7 +912,9 @@
683
912
  # HARNESS_MAX_CHAIN the automatic-dispatch limit; `24` when empty. Not a
684
913
  # non-negative integer: nothing is dispatched
685
914
  # HARNESS_POLL_MAX_DISPATCH_FAILURES `poll` only: failed re-dispatches of
686
- # one paused run before it gives up; `3` when empty
915
+ # one paused run, and consecutive failed downloads of
916
+ # one run's listed bundle, each counted apart, before it
917
+ # gives up; `3` when empty
687
918
  # HARNESS_POLL_GIVE_UP_AFTER_MINUTES `poll` only: minutes after a run's
688
919
  # `usage_resume_at` past which a failed re-dispatch gives
689
920
  # up; `360` when empty. Either one not a non-negative
@@ -694,7 +925,9 @@
694
925
  # GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_REPOSITORY the run URL
695
926
  # Notifications go through the sibling `autonomous-notify.sh`, as `paused` or
696
927
  # `failed`, and each is then reported as `report` reports that event, with a
697
- # note of its own that names no slash command and no shell command. A re-dispatch is `dispatch <branch> --engine <status.json engine>
928
+ # note of its own that names no slash command and no shell command. The one
929
+ # exception is `poll`'s push-only `bundle_unreadable`, which posts no comment
930
+ # and sets no label. A re-dispatch is `dispatch <branch> --engine <status.json engine>
698
931
  # --resume pause --chain <chain + 1>`, composed by `dispatch` itself.
699
932
  #
700
933
  # `chain` HAS ONE SOURCE: the bundle's `status.json`, whose `chain` is the
@@ -741,7 +974,8 @@
741
974
  # than `GITHUB_RUN_ID` (bounded) carrying an unexpired `harness-poll-state`
742
975
  # artifact is downloaded to `<state_dir>/autonomous_logs/poll_state/previous/`;
743
976
  # any failure to find or read it is one line and an empty state. Its
744
- # `poll_state.json` is {"<branch>": {"run_id", "failures", "notified"}}; an
977
+ # `poll_state.json` is {"<branch>": {"run_id", "failures", "notified",
978
+ # "download_failures"}}, the last absent until a download fails; an
745
979
  # entry whose `run_id` is not the branch's newest `harness run` run is dropped,
746
980
  # so a new run restarts the count. `poll` writes the state to `poll_state/
747
981
  # current/` on every exit, `HARNESS_REMOTE_STOP` included, and the poller
@@ -756,9 +990,16 @@
756
990
  # download that fails for it is one line and waiting. For a `completed` run,
757
991
  # skipped, not waiting: a stopped branch, a branch absent on origin (its state
758
992
  # entry dropped, so a later branch of the name starts clean; an `ls-remote`
759
- # that cannot answer is one line and proceeds), a bundle that cannot be
760
- # downloaded (one line), and anything but `status: paused` / `pause_reason: usage` with an
761
- # integer `usage_resume_at`, and a run whose state entry says `notified`. Due
993
+ # that cannot answer is one line and proceeds), a run whose `run` job GitHub
994
+ # never started (one line; its `collect` job reports it), and anything but
995
+ # `status: paused` / `pause_reason: usage` with an integer `usage_resume_at`,
996
+ # and a run whose state entry says `notified`. A failed artifact lookup on a
997
+ # `completed` run counts as a failed download of its bundle, below. A listed
998
+ # bundle that cannot be downloaded is waiting, counted per run in `download_failures` and reset by a
999
+ # later successful download; at `HARNESS_POLL_MAX_DISPATCH_FAILURES`
1000
+ # consecutive failures it sends exactly one `bundle_unreadable` push
1001
+ # notification, with no comment and no label, the entry marked `notified`, and
1002
+ # is no longer waiting. Due
762
1003
  # (reset passed): re-dispatched under the same chain limit — a refusal is one
763
1004
  # `failed` and not waiting; a success drops the branch's state entry. A
764
1005
  # dispatch that fails counts one more failure for that run and is still
@@ -783,9 +1024,12 @@
783
1024
  # `pause-requested` lists the branch's runs and exits 0 when one whose
784
1025
  # `displayTitle` is exactly `harness pause <branch>` has a `createdAt` at or
785
1026
  # after <since_epoch> — AT, because `createdAt` has one-second resolution and
786
- # the caller takes <since_epoch> just before the query it will next start
787
- # from, so a pause created later in that same second is still seen; seeing one
788
- # twice is harmless, since the caller drops PAUSE once. `run-created-at` prints
1027
+ # the caller passes a bound that starts at the job's own starting bound and
1028
+ # afterwards lags each query by an overlap (`CONTROL_POLL_OVERLAP_SECS` in
1029
+ # `autonomous-watcher.sh`), floored at that starting bound, so a pause created
1030
+ # later in a second already queried is still seen; seeing one twice is
1031
+ # harmless, since the caller drops PAUSE once. Exit 5 is "no pause"; exit 1 is
1032
+ # a refused call and never one. `run-created-at` prints
789
1033
  # `gh run view <run_id> --json createdAt` as an epoch second.
790
1034
  #
791
1035
  # `status` AND `sync` READ THE RECORD, NOT THE KEY. A run keeps the execution
@@ -840,12 +1084,17 @@
840
1084
  # `engine` when the bundle names `task`, `user_review` or `docs`. A
841
1085
  # `mirror` restore places no planning draft
842
1086
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
843
- # older finished run carries one): a job that died before its upload.
1087
+ # older finished run carries one): a job that died before its upload,
1088
+ # or that GitHub never started (the detail says so).
844
1089
  # `paused` / `killed`, `remote_run_id` / `remote_run_url` re-pointed at
845
- # THIS run, nothing restored — so a later sync with no newer run is case 1
1090
+ # THIS run, and, for a run GitHub never started whose dispatch's comment
1091
+ # records its engine, `engine` set to that engine; nothing restored — so
1092
+ # a later sync with no newer run is case 1
846
1093
  # 5. no bundle in any run and an empty `remote_run_id`: `failed`. Not
847
1094
  # `paused`: with no bundle anywhere a pause resume has nothing to restore,
848
- # and re-dropping the artifact is the recovery
1095
+ # and re-dropping the artifact is the recovery, except a run whose job
1096
+ # GitHub never started and whose engine its dispatch's comment records,
1097
+ # which `sync` records as case 4 does, `paused (killed)`
849
1098
  #
850
1099
  # THE `killed` AND `expired` MAPPINGS. A finished run whose bundle still says
851
1100
  # `running` (a kill, a timeout with no chain left) syncs as `status: paused`,
@@ -854,7 +1103,16 @@
854
1103
  # never carries either, because `sync` derives them from the run and its
855
1104
  # artifact list, never from a bundle. Both are `paused` rather than `failed`
856
1105
  # because a `failed` record has no resume path, while the ledger on the branch
857
- # is intact and a resume continues from it.
1106
+ # is intact and a resume continues from it. A finished run with no bundle
1107
+ # whose `run` job GitHub never started (it ended `cancelled` or `failure` with
1108
+ # no step run) maps to the same states as any other run with no bundle:
1109
+ # `paused` / `killed` when an older run carries a bundle, else `failed`. Its
1110
+ # detail names GitHub's reason instead of "killed, cancelled or replaced".
1111
+ # A never-started run with no bundle anywhere whose engine its dispatch's
1112
+ # comment records maps to `paused` / `killed` too, so `resume` accepts it.
1113
+ # When that run was the branch's first, the branch has no ledger yet:
1114
+ # `restore` finds no previous bundle and the resumed job starts as the
1115
+ # branch's first. With no such comment it stays `failed`.
858
1116
  #
859
1117
  # THE WORKFLOW INPUT CONTRACT (the workflow template declares the same inputs):
860
1118
  #
@@ -901,7 +1159,11 @@
901
1159
  # task prompt at the newest `harness run <branch>` run's `headSha` through the
902
1160
  # contents API rather than from `origin/<branch>` (a failed read is one line
903
1161
  # and no issue), and its text says the branch was deleted, so the run cannot
904
- # be resumed, and that its workflow runs and artifacts are kept. That read
1162
+ # be resumed, and that its workflow runs and artifacts are kept. (4) also
1163
+ # reports on each pull request of the branch from this repository that is not
1164
+ # merged and still carries `running`, `parked` or `paused`: each gets the same
1165
+ # comment, the `stopped` label and its progress comment marked stopped
1166
+ # (`forge_gone_prs_var`, `forge_progress_stopped`). The issue read
905
1167
  # rests on GitHub serving a commit no branch points at, which is unverified
906
1168
  # (`docs/github-run-control.md` -> `## 8. What is not verified here`).
907
1169
  #
@@ -914,42 +1176,62 @@
914
1176
  # repository from that checkout's remote. The configuration read is that
915
1177
  # root's `harness.config.json`.
916
1178
  #
917
- # WHAT IT NEVER DOES. It never launches a local session, never writes the
1179
+ # WHAT IT NEVER DOES. It never launches a local session but `control`'s one
1180
+ # read-only mention session (MENTION, in `control`'s paragraph), never writes the
918
1181
  # inbox, and never watches a run it sent beyond the bounded lookup of its
919
1182
  # listing that `trigger` and `review` make. Only `start` and `review` push, and
920
1183
  # only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
921
1184
  # the prompt committed on `origin/<branch>`, through a working copy and a local
922
- # branch it removes before it returns; `review`'s are the round committed on
923
- # `origin/<branch>`, through the record's mirror or a copy and a local branch
924
- # it removes, the record's `status` / `engine`, the bundle download
925
- # directory `sync` uses, and `report`'s writes for the round. A user's chain-0 `dispatch --resume answer|pause`
1185
+ # branch it removes before it returns, and, after a push that did not land, a
1186
+ # fetch that force-writes that copy's `refs/remotes/origin/<branch>`
1187
+ # (`hr_push_landed`'s, to name the commit the remote moved to); `review`'s are
1188
+ # the round committed on `origin/<branch>`, through the record's mirror or a
1189
+ # copy and a local branch it removes, and, after a push that did not land, the
1190
+ # same `hr_push_landed` fetch force-writing `refs/remotes/origin/<branch>` in
1191
+ # that mirror or copy, the record's `status` / `engine`, the bundle download
1192
+ # directory `sync` uses, and `report`'s writes for the round. Its settledness
1193
+ # read, when the newest run's job GitHub never started, also fetches origin's
1194
+ # <branch>, force-writing `refs/remotes/origin/<branch>` in the root checkout:
1195
+ # for a local `/autonomous-sdlc-harness:branch-user-review`, the main checkout. A user's chain-0 `dispatch --resume answer|pause`
926
1196
  # writes the record's `status`, `resumed_at` and `resume_kind` in one write
927
1197
  # when the main checkout's registry file exists and holds a record with
928
1198
  # `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
929
1199
  # files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
930
1200
  # for a dispatch event a block in the step summary. `report` writes its comment
931
1201
  # file under `RUNNER_TEMP` (removed), one comment and the state labels on the
932
- # issue and the pull request, and nothing local. `deliver` writes its body and
1202
+ # issue and the pull request, and nothing local. `open` writes its body and
1203
+ # comment files under `RUNNER_TEMP` (removed), at most one pull request, its
1204
+ # `running` label and one comment on the issue. `deliver` writes its body and
933
1205
  # comment files under `RUNNER_TEMP` (removed), at most one pull request, one
934
- # comment and the state labels. `control` writes its reply file and its
935
- # `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
936
- # what the child verb it runs writes. `collect` writes its round file and its
937
- # settledness directory under `RUNNER_TEMP` (removed), at most one comment on
938
- # the pull request, plus what its `review` child writes. `stop`, `continue` and `poll` also make
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
1209
+ # what the child verb it runs writes, and the fetch of origin's <branch> that
1210
+ # force-writes `refs/remotes/origin/<branch>` in its checkout. `collect` writes its round file and its
1211
+ # settledness directory under `RUNNER_TEMP` (removed), at most one comment, on
1212
+ # the pull request, or on the issue for a run whose job never started, and for
1213
+ # that event `report`'s state labels on both items and one push notification,
1214
+ # the fetch of origin's <branch> its settledness read makes for such a run,
1215
+ # force-writing `refs/remotes/origin/<branch>`, plus what its `review` child
1216
+ # writes. `stop`, `continue` and `poll` also make
939
1217
  # `report`'s writes for each event they report. Every other verb's only writes are the
940
1218
  # registry record (`stop`, `sync`) and, for `sync`, the download directory
941
1219
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
942
1220
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
943
- # `hr_remote_bundle_restore` performs in the record's `worktree`; for
1221
+ # `hr_remote_bundle_restore` performs in the record's `worktree`, and, when
1222
+ # the newest run's job GitHub never started, a fetch force-writing
1223
+ # `refs/remotes/origin/<branch>` in the main checkout; for
944
1224
  # `restore`, that download directory, the job restore (the planning drafts
945
1225
  # among it), `answer_<n>.md` and the
946
1226
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
947
1227
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
948
1228
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
949
- # and `current/`. For `fetch`, <out_dir> only. For `discard`, the removal of
950
- # <dir> only. `pause-requested`,
951
- # `run-created-at`, `list` and `status` write nothing; a no-record `status`
952
- # downloads into a temporary directory it removes on exit.
1229
+ # and `current/`. For `fetch`, <out_dir> and, when the newest run's job GitHub
1230
+ # never started, a fetch force-writing `refs/remotes/origin/<branch>` in the
1231
+ # root checkout. For `discard`, the removal of <dir> only. `pause-requested`,
1232
+ # `run-created-at` and `list` write nothing, and `status` writes nothing but,
1233
+ # with no record, that same fetch; a no-record `status` downloads into a
1234
+ # temporary directory it removes on exit.
953
1235
  #
954
1236
  # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
955
1237
  # rename there is an edit here, byte for byte:
@@ -967,6 +1249,10 @@
967
1249
  # WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
968
1250
  # COMMAND_HANDLE mirrors COMMAND_HANDLE
969
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
970
1256
  # COMMENT_MARKER mirrors COMMENT_MARKER
971
1257
  # REVIEW_ROUND_STATE mirrors REVIEW_ROUND_STATE
972
1258
  # STATE_LABEL_PREFIX mirrors STATE_LABEL_PREFIX
@@ -1139,7 +1425,9 @@
1139
1425
  # the job's reads: a `run list` answer whose run has `displayTitle` `harness
1140
1426
  # pause feat_x` and `createdAt` `2026-01-01T00:00:10Z` (epoch 1767225610):
1141
1427
  # pause-requested bash scripts/remote-run.sh pause-requested feat_x 1767225600
1142
- # -> 0; with 1767225620 -> 1; a stub failing `run list` -> 3
1428
+ # -> 0; with 1767225620 -> 5; a stub failing `run list` -> 3;
1429
+ # bash scripts/remote-run.sh pause-requested feat_x (no
1430
+ # <since_epoch>) -> 1
1143
1431
  # run-created-at a stub answering `run view 42 --json createdAt` with
1144
1432
  # {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
1145
1433
  # run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
@@ -1178,15 +1466,45 @@
1178
1466
  # naming `@sdlc-harness resume`, then `api --method POST
1179
1467
  # repos/o/r/issues/7/labels -f labels[]=sdlc-harness: paused`
1180
1468
  # forge off `"forge": "none"`, then the same -> 0, one line, "$s.log" unchanged
1469
+ # progress a task ledger at sdlc-harness/flow_progress/feat_x_progress.md in
1470
+ # the checkout and the stub answering `pr list` with [{"number":12,
1471
+ # "isCrossRepository":false}]: bash scripts/remote-run.sh report
1472
+ # progress feat_x -> 0; "$s.log" gains `api --paginate
1473
+ # repos/o/r/issues/12/comments ...`, then one POST on 12 ending in
1474
+ # `event=progress branch=feat_x -->`; with that comment listed, no
1475
+ # write; with the ledger changed, one `PATCH repos/o/r/issues/comments/<id>`
1181
1476
  #
1182
1477
  # deliver needs report's setup, a stub answering the create with
1183
1478
  # https://github.com/o/r/pull/12, and <b> a bundle directory whose status.json
1184
1479
  # carries schema "1" and status completed:
1185
1480
  # deliver bash scripts/remote-run.sh deliver feat_x <b> -> 0; "$s.log" gains
1186
- # a `--draft` create with `--base main --head feat_x`, then one
1187
- # comment on issue 7 naming /pull/12, then `sdlc-harness: done` on
1188
- # 7 and 12
1481
+ # a `--draft` create with `--base main --head feat_x`, then `pr
1482
+ # ready 12` with the job's token, then one comment on 12 saying
1483
+ # it is now marked ready, then one on issue 7 naming #12 and
1484
+ # /pull/12, then `sdlc-harness: done` on 7 and 12
1485
+ # reused the stub answering `pr list` with [{"number":12,
1486
+ # "isCrossRepository":false,"isDraft":false}] -> 0, no create, no
1487
+ # `pr ready`, the pull request's comment saying it is not a draft
1488
+ # refused the stub failing `pr ready` -> 0, one `::warning::` line, both
1489
+ # comments posted, the pull request's saying the flip was refused
1189
1490
  # not done status parked, or an empty <b> -> 0, one line, "$s.log" unchanged
1491
+ # threads <b>'s engine user_review; on feat_x a `feat_x_review.md` ending in
1492
+ # the marker `comments=101,103`, a `feat_x_fix_plan.md` whose
1493
+ # `1. [x] **Finding 1**` lands in its own commit and whose `## Out
1494
+ # of scope / verified-OK` bullet ends ` **Review comments:** 103`,
1495
+ # and `feat_x_fix_plan/finding_1.md` carrying `**Review comments:**
1496
+ # 101`; a stub answering `api graphql` with one open thread per id
1497
+ # -> 0; "$s.log" gains a reply on comment 101 naming that commit,
1498
+ # then one `resolveReviewThread`, then a reply on 103 quoting the
1499
+ # bullet and no resolve; no dismissal call
1500
+ #
1501
+ # open needs deliver's setup without the bundle:
1502
+ # open bash scripts/remote-run.sh open feat_x -> 0; "$s.log" gains a
1503
+ # `--draft` create with `--base main --head feat_x`, then
1504
+ # `sdlc-harness: running` on 12, then one comment on issue 7
1505
+ # naming /pull/12 and ending in `event=opened branch=feat_x`
1506
+ # reused the stub answering `pr list` with [{"number":12,
1507
+ # "isCrossRepository":false}] -> 0, one line, no create, no comment
1190
1508
  #
1191
1509
  # collect needs deliver's setup, a story index on feat_x, a stub answering
1192
1510
  # `pr list` with [{"number":12,"isCrossRepository":false}], `run list` with a
@@ -1208,8 +1526,20 @@
1208
1526
  # pause bash scripts/remote-run.sh control -> 0; "$s.log" gains `workflow
1209
1527
  # run harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1210
1528
  # then one comment on 12 naming @alice
1211
- # ignored c.json's body `Let's @sdlc-harness pause` -> 0, one line,
1212
- # "$s.log" unchanged
1529
+ # ignored c.json's body `pause` -> 0, one line, "$s.log" unchanged
1530
+ # mention c.json's body `Let's @sdlc-harness pause`, with
1531
+ # HARNESS_MENTION_PLUGIN_DIR=<a throwaway directory holding an
1532
+ # empty commands/harness-read-mention.md>, IN_OAUTH=x and
1533
+ # HARNESS_AGENT_CLI=<a stub printing {"type":"result",
1534
+ # "subtype":"success","is_error":false,"structured_output":
1535
+ # {"action":"reply","text":"It is running.","reason":"r"}}>
1536
+ # -> 0; "$s.log" gains one comment on 12 opening `@alice: It is
1537
+ # running.`, and no `workflow run`
1538
+ # act the same with the stub's structured_output {"action":"command",
1539
+ # "verb":"pause","reason":"r"} -> 0; "$s.log" gains `workflow run
1540
+ # harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1541
+ # then one comment on 12 opening `Read from your mention as
1542
+ # `@sdlc-harness pause`.`
1213
1543
 
1214
1544
  set -u
1215
1545
 
@@ -1232,6 +1562,10 @@ TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
1232
1562
  WORKFLOW_CONTROL_FILE='harness-control.yml'
1233
1563
  COMMAND_HANDLE='@sdlc-harness'
1234
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'
1235
1569
  COMMENT_MARKER='<!-- sdlc-harness'
1236
1570
  REVIEW_ROUND_STATE='changes_requested'
1237
1571
  STATE_LABEL_PREFIX='sdlc-harness: '
@@ -1243,6 +1577,18 @@ PR_CREATE_SETTING_PATH='Settings -> Actions -> General -> Workflow permissions'
1243
1577
  # to be trusted (docs/github-integration-research.md -> S6); the margin is the
1244
1578
  # framing lines and the marker.
1245
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.'
1246
1592
  GH="${HARNESS_GH_CLI:-gh}"
1247
1593
 
1248
1594
  # How many runs `status` prints, and how many `run list` returns for status
@@ -1274,6 +1620,11 @@ RUN_JOB_NAME='run'
1274
1620
  # How far before the previous round's `collected_at` `round_collect` lists
1275
1621
  # from, absorbing runner-clock skew; the id check drops what it re-lists.
1276
1622
  ROUND_OVERLAP_SECS=300
1623
+ # How far before a run's `createdAt` `forge_dispatch_engine_var` still trusts
1624
+ # a dispatcher's comment: the comment is posted after the dispatch request and
1625
+ # GitHub creates the run asynchronously, so either may carry the earlier
1626
+ # timestamp. Small enough that an older dispatch's comment falls outside it.
1627
+ DISPATCH_MARKER_SLACK_SECS=120
1277
1628
 
1278
1629
  # GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
1279
1630
  # maximum payload for inputs is 65,535 characters."
@@ -1285,8 +1636,9 @@ EXIT_USAGE=1
1285
1636
  EXIT_REFUSED=2
1286
1637
  EXIT_GH=3
1287
1638
  EXIT_PLACEMENT=4
1288
- # pause-requested only: the read succeeded and found no pause.
1289
- EXIT_NO_PAUSE=1
1639
+ # pause-requested only: the read succeeded and found no pause; distinct from
1640
+ # EXIT_USAGE so a caller never reads a refused call as no pause.
1641
+ EXIT_NO_PAUSE=5
1290
1642
 
1291
1643
  usage() {
1292
1644
  echo "remote-run.sh: $1" >&2
@@ -1309,6 +1661,7 @@ usage() {
1309
1661
  echo " remote-run.sh list [--repo <root>]" >&2
1310
1662
  echo " remote-run.sh discard <dir> [--repo <root>]" >&2
1311
1663
  echo " remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]" >&2
1664
+ echo " remote-run.sh open <branch> [--repo <root>]" >&2
1312
1665
  echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1313
1666
  echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1314
1667
  echo " remote-run.sh control [--repo <root>]" >&2
@@ -1381,7 +1734,7 @@ verb=""
1381
1734
  verb="$1"
1382
1735
  shift
1383
1736
  case "$verb" in
1384
- dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list|discard|report|deliver|collect|control) ;;
1737
+ dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list|discard|report|open|deliver|collect|control) ;;
1385
1738
  *) usage "unknown verb '$verb'" ;;
1386
1739
  esac
1387
1740
 
@@ -1606,10 +1959,11 @@ fi
1606
1959
  # ---------------------------------------------------------------------------
1607
1960
 
1608
1961
  # setup_fail <message> — a configuration problem: exit 1, except for save,
1609
- # report, deliver and collect, which never fail the step that calls them.
1962
+ # report, open, deliver and collect, which never fail the step that calls them.
1610
1963
  setup_fail() {
1611
1964
  echo "remote-run.sh: $1" >&2
1612
- [ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != deliver ] && [ "$verb" != collect ] || exit "$EXIT_OK"
1965
+ [ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != open ] && [ "$verb" != deliver ] \
1966
+ && [ "$verb" != collect ] || exit "$EXIT_OK"
1613
1967
  exit "$EXIT_USAGE"
1614
1968
  }
1615
1969
 
@@ -1617,8 +1971,8 @@ if [ -n "$repo_arg" ]; then
1617
1971
  root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
1618
1972
  elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
1619
1973
  || [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ] \
1620
- || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = deliver ] || [ "$verb" = collect ] \
1621
- || [ "$verb" = control ]; then
1974
+ || [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = open ] || [ "$verb" = deliver ] \
1975
+ || [ "$verb" = collect ] || [ "$verb" = control ]; then
1622
1976
  root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
1623
1977
  else
1624
1978
  root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
@@ -1637,7 +1991,7 @@ case "$verb" in
1637
1991
  trigger|control)
1638
1992
  # Gates itself, after reading the event, so a refusal can still be commented.
1639
1993
  ;;
1640
- report|deliver|collect)
1994
+ report|open|deliver|collect)
1641
1995
  # Gates itself (`forge_on`) and exits 0 on every outcome.
1642
1996
  ;;
1643
1997
  status|sync)
@@ -1917,6 +2271,54 @@ expired_line() {
1917
2271
  printf '%s' "the state bundle of run $1 expired on $BUNDLE_EXPIRES_AT: resume from the committed ledger with $RESUME_HINT $branch, or re-drop the task"
1918
2272
  }
1919
2273
 
2274
+ # run_not_started_var <run_id> — whether GitHub never started the run's
2275
+ # RUN_JOB_NAME job: 0 when that job's `conclusion` is `cancelled` or `failure`
2276
+ # and its `steps` array is absent or empty, with NOT_STARTED_REASON set; 1 when
2277
+ # the job started or none of that name is listed; 2 with GH_ERR set when the
2278
+ # jobs lookup failed or was not the expected JSON. Never exits. The reason is
2279
+ # the first message of the job's check-run annotations (a job's `id` is its
2280
+ # check run's id); that lookup is best-effort, and a failed or empty one falls
2281
+ # back to a line naming the conclusion. Replaces GH_OUT.
2282
+ NOT_STARTED_REASON=""
2283
+ run_not_started_var() {
2284
+ local answer job_id conclusion message
2285
+ NOT_STARTED_REASON=""
2286
+ gh_call api "repos/{owner}/{repo}/actions/runs/$1/jobs" || return 2
2287
+ answer=$(printf '%s' "$GH_OUT" | jq -r --arg n "$RUN_JOB_NAME" '
2288
+ if type == "object" and (.jobs | type) == "array" then
2289
+ ([.jobs[] | select(type == "object" and .name == $n)] | first) as $j
2290
+ | if $j == null then "absent"
2291
+ elif ($j.conclusion == "cancelled" or $j.conclusion == "failure")
2292
+ and (($j.steps // []) | if type == "array" then length else 1 end) == 0
2293
+ then "not_started\t" + ($j.id | tostring) + "\t" + $j.conclusion
2294
+ else "started" end
2295
+ else "invalid" end' 2>/dev/null)
2296
+ case "$answer" in
2297
+ absent|started) return 1 ;;
2298
+ not_started$'\t'*) ;;
2299
+ *) GH_ERR="its job list is not the expected JSON"; return 2 ;;
2300
+ esac
2301
+ answer="${answer#*$'\t'}"
2302
+ job_id="${answer%%$'\t'*}"
2303
+ conclusion="${answer#*$'\t'}"
2304
+ message=""
2305
+ case "$job_id" in
2306
+ ''|*[!0-9]*) ;;
2307
+ *)
2308
+ if gh_call api "repos/{owner}/{repo}/check-runs/$job_id/annotations"; then
2309
+ message=$(printf '%s' "$GH_OUT" | jq -r '
2310
+ [.[]? | .message? | select(type == "string" and length > 0) | split("\n")[0]] | first // ""' 2>/dev/null) || message=""
2311
+ fi
2312
+ ;;
2313
+ esac
2314
+ if [ -n "$message" ]; then
2315
+ NOT_STARTED_REASON="$message"
2316
+ else
2317
+ NOT_STARTED_REASON="its \`$RUN_JOB_NAME\` job ended \`$conclusion\` with no step run"
2318
+ fi
2319
+ return 0
2320
+ }
2321
+
1920
2322
  set_or_fail() {
1921
2323
  hr_registry_set "$registry" "$branch" "$1" "$2" || {
1922
2324
  echo "remote-run.sh: writing $1 of $branch to '$registry' failed" >&2
@@ -2035,10 +2437,17 @@ sync_expired() {
2035
2437
  # RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
2036
2438
  # existing for case 4. <finished> 1 reads the newest run as finished whatever
2037
2439
  # its `status`: `branch_settled_var` passes it once that run's `run` job has
2038
- # completed. Exits 3 when gh fails, 2 for an unrecognised bundle.
2440
+ # completed. RS_RUN_CREATED_AT is the newest run's `createdAt` (ISO 8601).
2441
+ # RS_NOT_STARTED is 1 only in cases 4 and 5, when `run_not_started_var` finds
2442
+ # GitHub never started that run's RUN_JOB_NAME job; RS_ENGINE is then
2443
+ # `forge_dispatch_engine_var`'s answer, and case 5 with one is `paused` /
2444
+ # `killed`. Replaces GH_OUT in cases 4 and 5. Exits 3 when gh fails, 2
2445
+ # for an unrecognised bundle.
2039
2446
  RS_RUNS=""
2040
2447
  RS_RUN_ID=""
2041
2448
  RS_RUN_URL=""
2449
+ RS_RUN_CREATED_AT=""
2450
+ RS_NOT_STARTED=0
2042
2451
  RS_GH_STATUS=""
2043
2452
  RS_STATE=""
2044
2453
  RS_PAUSE_REASON=""
@@ -2052,7 +2461,8 @@ remote_state() {
2052
2461
  local download="${1-}" applied="${2-}" finished="${3-0}" newest status_file older bundle_exists=0
2053
2462
  RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
2054
2463
  RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
2055
- RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""
2464
+ RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""; RS_RUN_CREATED_AT=""
2465
+ RS_NOT_STARTED=0
2056
2466
  RS_RUNS=$(titled_runs "harness run $branch") || RS_RUNS='[]'
2057
2467
  newest=$(printf '%s' "$RS_RUNS" | jq -c '.[0] // empty')
2058
2468
  if [ -z "$newest" ]; then
@@ -2062,6 +2472,7 @@ remote_state() {
2062
2472
  RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
2063
2473
  RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
2064
2474
  RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
2475
+ RS_RUN_CREATED_AT=$(printf '%s' "$newest" | jq -r '.createdAt // ""')
2065
2476
  if [ "$RS_GH_STATUS" != completed ] && [ "$finished" != 1 ]; then
2066
2477
  RS_STATE=running
2067
2478
  return 0
@@ -2129,12 +2540,32 @@ remote_state() {
2129
2540
  RS_STATE=paused
2130
2541
  RS_PAUSE_REASON=killed
2131
2542
  RS_DETAIL="run $RS_RUN_ID ended with no state bundle (killed, cancelled or replaced): $RS_RUN_URL"
2132
- return 0
2543
+ else
2544
+ # Case 5 — no bundle in any run.
2545
+ RS_STATE=failed
2546
+ RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
2133
2547
  fi
2134
2548
 
2135
- # Case 5 — no bundle in any run.
2136
- RS_STATE=failed
2137
- RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
2549
+ # Cases 4 and 5 — a job GitHub never started keeps its case's state; only
2550
+ # the detail changes. A failed jobs lookup changes nothing but says so.
2551
+ run_not_started_var "$RS_RUN_ID"
2552
+ case $? in
2553
+ 0)
2554
+ RS_NOT_STARTED=1
2555
+ RS_DETAIL="GitHub did not start the job of run $RS_RUN_ID ($NOT_STARTED_REASON): $RS_RUN_URL"
2556
+ # No bundle names the engine, so the dispatch's own comment does; with
2557
+ # one, case 5 becomes resumable from the ledger as case 4 is.
2558
+ if [ -z "$RS_ENGINE" ]; then
2559
+ forge_dispatch_engine_var "$branch" "$RS_RUN_CREATED_AT" || :
2560
+ RS_ENGINE="$FORGE_DISPATCH_ENGINE"
2561
+ if [ "$RS_STATE" = failed ] && [ -n "$RS_ENGINE" ]; then
2562
+ RS_STATE=paused
2563
+ RS_PAUSE_REASON=killed
2564
+ fi
2565
+ fi
2566
+ ;;
2567
+ 2) echo "remote-run.sh: could not tell whether GitHub started the job of run $RS_RUN_ID: $GH_ERR" >&2 ;;
2568
+ esac
2138
2569
  return 0
2139
2570
  }
2140
2571
 
@@ -2264,10 +2695,16 @@ verb_sync() {
2264
2695
  return 0
2265
2696
  fi
2266
2697
 
2267
- # Case 4 — a newer run with no bundle, while some bundle exists.
2698
+ # Case 4 — a newer run with no bundle, while some bundle exists; also a run
2699
+ # GitHub never started whose engine its dispatch's comment records.
2268
2700
  if [ "$RS_STATE" = paused ]; then
2269
- set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2270
- remote_detail "$RS_DETAIL" remote_synced_at "$now"
2701
+ if [ "$RS_NOT_STARTED" = 1 ] && valid_engine "$RS_ENGINE"; then
2702
+ set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2703
+ remote_detail "$RS_DETAIL" remote_synced_at "$now" engine "$RS_ENGINE"
2704
+ else
2705
+ set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
2706
+ remote_detail "$RS_DETAIL" remote_synced_at "$now"
2707
+ fi
2271
2708
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
2272
2709
  return 0
2273
2710
  fi
@@ -2370,6 +2807,23 @@ ANSWERS_SHAPE='env.HARNESS_INPUT_ANSWERS | fromjson
2370
2807
  and all(to_entries[]; (.value | type) == "string"
2371
2808
  and (.key | explode | length > 0 and .[0] != 48 and all(.[]; . >= 48 and . <= 57)))'
2372
2809
 
2810
+ # sanitise($login; $handle), string to string, for agent-written text a reply
2811
+ # quotes: no `<!--` survives to read as a harness marker, and every `@<login>`
2812
+ # but the commenter's and the handle gets U+200B after the `@`, so it notifies
2813
+ # nobody.
2814
+ JQ_DEF_SANITISE='def sanitise($login; $handle):
2815
+ gsub("<!--"; "&lt;!--")
2816
+ | gsub("@(?<l>[A-Za-z0-9][A-Za-z0-9-]*)";
2817
+ if (.l | ascii_downcase) == ($login | ascii_downcase)
2818
+ or (("@" + .l) | ascii_downcase) == ($handle | ascii_downcase)
2819
+ then "@" + .l else "@​" + .l end);'
2820
+
2821
+ # fence, string to string: the code fence that quotes the input — one backtick
2822
+ # longer than its longest backtick run, at least three.
2823
+ JQ_DEF_FENCE='def fence:
2824
+ (([match("`+"; "g") | .length] | max) // 0) as $m
2825
+ | "`" * ([$m + 1, 3] | max);'
2826
+
2373
2827
  verb_restore() {
2374
2828
  local id download status_file clar n tmp status_source
2375
2829
  if [ "$resume" = answer ]; then
@@ -2521,12 +2975,9 @@ verb_save() {
2521
2975
  return 0
2522
2976
  }
2523
2977
 
2524
- # notify <event> <branch> <detail> <forge_note> — one lifecycle notification,
2525
- # then `forge_report` of the same event; never fails. Every call site supplies
2526
- # both texts: <detail> is the push notification's, slash commands included;
2527
- # <forge_note> is the comment's, naming no slash command and no shell command,
2528
- # and states only what happened, since `forge_report` adds the next action.
2529
- notify() {
2978
+ # notify_push <event> <branch> <detail> — the push notification alone: it
2979
+ # posts no comment and sets no label. Never fails.
2980
+ notify_push() {
2530
2981
  if [ -n "${HARNESS_REMOTE_SLUG-}" ]; then
2531
2982
  HARNESS_REPO_SLUG="$HARNESS_REMOTE_SLUG"
2532
2983
  export HARNESS_REPO_SLUG
@@ -2534,6 +2985,15 @@ notify() {
2534
2985
  bash "$script_dir/autonomous-notify.sh" "$1" "$2" "" "$3" \
2535
2986
  || echo "remote-run.sh: the $1 notification for $2 could not be sent" >&2
2536
2987
  echo "remote-run.sh: notified $1 for $2: $3"
2988
+ }
2989
+
2990
+ # notify <event> <branch> <detail> <forge_note> — notify_push, then
2991
+ # `forge_report` of the same event; never fails. Every call site supplies
2992
+ # both texts: <detail> is the push notification's, slash commands included;
2993
+ # <forge_note> is the comment's, naming no slash command and no shell command,
2994
+ # and states only what happened, since `forge_report` adds the next action.
2995
+ notify() {
2996
+ notify_push "$1" "$2" "$3"
2537
2997
  # stdin closed: `poll` calls this inside a loop reading its run list.
2538
2998
  forge_report "$1" "$2" "$4" </dev/null
2539
2999
  }
@@ -2730,7 +3190,10 @@ verb_continue() {
2730
3190
  }
2731
3191
 
2732
3192
  # POLL_STATE — the poller state carried between ticks, one object keyed by
2733
- # branch: {"<branch>": {"run_id", "failures", "notified"}}, every value a string.
3193
+ # branch: {"<branch>": {"run_id", "failures", "notified", "download_failures"}},
3194
+ # every value a string. `failures` counts failed re-dispatches only;
3195
+ # `download_failures`, absent until a download fails, counts consecutive failed
3196
+ # downloads of the run's listed bundle.
2734
3197
  POLL_STATE='{}'
2735
3198
  POLL_STATE_FILE_NAME='poll_state.json'
2736
3199
 
@@ -2738,9 +3201,21 @@ poll_state_get() {
2738
3201
  printf '%s' "$POLL_STATE" | jq -r --arg b "$1" --arg f "$2" '.[$b][$f] // "" | tostring'
2739
3202
  }
2740
3203
 
3204
+ # poll_state_put <branch> <run_id> <failures> <notified> — keeps the entry's
3205
+ # `download_failures` when its `run_id` is the same.
2741
3206
  poll_state_put() {
2742
- POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg f "$3" --arg n "$4" \
2743
- '.[$b] = {run_id: $r, failures: $f, notified: $n}')
3207
+ POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg f "$3" --arg n "$4" '
3208
+ (.[$b] // {}) as $o
3209
+ | .[$b] = {run_id: $r, failures: $f, notified: $n}
3210
+ + (if $o.run_id == $r and ($o | has("download_failures")) then {download_failures: $o.download_failures} else {} end)')
3211
+ }
3212
+
3213
+ # poll_state_downloads_put <branch> <run_id> <count> — sets `download_failures`,
3214
+ # on a fresh entry when the stored `run_id` differs.
3215
+ poll_state_downloads_put() {
3216
+ POLL_STATE=$(printf '%s' "$POLL_STATE" | jq -c --arg b "$1" --arg r "$2" --arg c "$3" '
3217
+ (.[$b] // {}) as $o
3218
+ | .[$b] = (if $o.run_id == $r then $o else {run_id: $r, failures: "", notified: ""} end) + {download_failures: $c}')
2744
3219
  }
2745
3220
 
2746
3221
  poll_state_drop() {
@@ -2859,7 +3334,7 @@ poll_usage_paused() {
2859
3334
  # With may_dispatch 0 nothing is sent and nothing notified: a due run that
2860
3335
  # would be dispatched counts as waiting, one that would be refused does not.
2861
3336
  poll_branch() {
2862
- local id="$1" state="$2" may_dispatch="$3" at engine_value now failures
3337
+ local id="$1" state="$2" may_dispatch="$3" at engine_value now failures downloads listed
2863
3338
  if [ "$may_dispatch" -eq 1 ] && [ -n "$(poll_state_get "$branch" run_id)" ] \
2864
3339
  && [ "$(poll_state_get "$branch" run_id)" != "$id" ]; then
2865
3340
  poll_state_drop "$branch"
@@ -2894,10 +3369,40 @@ poll_branch() {
2894
3369
  echo "remote-run.sh: poll: $branch's run $id was already reported as not re-dispatchable; skipped"
2895
3370
  return 1
2896
3371
  fi
2897
- if ! poll_fetch "$id"; then
2898
- echo "remote-run.sh: poll: $branch skipped"
3372
+ bundle_listed "$id"
3373
+ listed=$?
3374
+ if [ "$listed" -eq 1 ]; then
3375
+ if run_not_started_var "$id"; then
3376
+ echo "remote-run.sh: poll: run $id of $branch never started ($NOT_STARTED_REASON); its collect job reports it; not waiting"
3377
+ else
3378
+ echo "remote-run.sh: poll: $branch skipped"
3379
+ fi
2899
3380
  return 1
2900
3381
  fi
3382
+ [ "$listed" -ne 2 ] || echo "remote-run.sh: poll: reading the artifacts of run $id ($branch) failed ($GH_ERR)"
3383
+ downloads=""
3384
+ [ "$(poll_state_get "$branch" run_id)" != "$id" ] || downloads=$(poll_state_get "$branch" download_failures)
3385
+ if [ "$listed" -eq 2 ] || ! poll_fetch "$id"; then
3386
+ case "$downloads" in
3387
+ ''|*[!0-9]*) downloads=0 ;;
3388
+ esac
3389
+ downloads=$((10#$downloads + 1))
3390
+ if [ "$downloads" -lt "$((10#$POLL_MAX_FAILURES))" ]; then
3391
+ [ "$may_dispatch" -eq 0 ] || poll_state_downloads_put "$branch" "$id" "$downloads"
3392
+ echo "remote-run.sh: poll: the bundle of run $id ($branch) could not be downloaded, attempt $downloads of $POLL_MAX_FAILURES; still waiting"
3393
+ return 0
3394
+ fi
3395
+ if [ "$may_dispatch" -eq 1 ]; then
3396
+ poll_state_put "$branch" "$id" "$(poll_state_get "$branch" failures)" 1
3397
+ # Push only, under a word outside autonomous-notify.sh's events: the run's
3398
+ # state is unknown, so neither a `failed` report nor a label is true of it.
3399
+ notify_push bundle_unreadable "$branch" "The resume poller could not download the state bundle of run $id after $downloads attempts ($GH_ERR); it no longer watches $branch. Its state is unknown: check the run, then run $RESUME_HINT $branch if it is paused; $(hr_github_resume_route "$branch" "")."
3400
+ fi
3401
+ return 1
3402
+ fi
3403
+ if [ -n "$downloads" ] && [ "$may_dispatch" -eq 1 ]; then
3404
+ poll_state_downloads_put "$branch" "$id" ""
3405
+ fi
2901
3406
  poll_usage_paused || return 1
2902
3407
  at=$(hr_remote_status_get "$POLL_STATUS_FILE" usage_resume_at) || at=""
2903
3408
  case "$at" in
@@ -3131,8 +3636,12 @@ verb_start() {
3131
3636
  status=0
3132
3637
  hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$subject" >&2 || status=$?
3133
3638
  [ "$status" -ne 1 ] || placement_fail "committing '$rel'"
3134
- hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3135
- || placement_fail "pushing $branch (origin/$branch is not HEAD)"
3639
+ status=0
3640
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 || status=$?
3641
+ [ "$status" -ne 2 ] \
3642
+ || placement_fail "pushing $branch: origin/$branch moved to $HR_PUSH_REMOTE_TIP, which this branch does not have (something else pushed)"
3643
+ [ "$status" -eq 0 ] \
3644
+ || placement_fail "pushing $branch: the remote refused the push (push-branch.sh's lines in this job's log name why)"
3136
3645
  start_remove_copy
3137
3646
  trap - EXIT
3138
3647
 
@@ -3285,8 +3794,12 @@ NAMES
3285
3794
  status=0
3286
3795
  hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$(hr_user_review_subject "$branch")" >&2 || status=$?
3287
3796
  [ "$status" -eq 0 ] || review_fail "committing '$rel'"
3288
- hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
3289
- || review_fail "pushing $branch (origin/$branch is not HEAD)"
3797
+ status=0
3798
+ hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 || status=$?
3799
+ [ "$status" -ne 2 ] \
3800
+ || review_fail "pushing $branch: origin/$branch moved to $HR_PUSH_REMOTE_TIP, which this branch does not have (something else pushed)"
3801
+ [ "$status" -eq 0 ] \
3802
+ || review_fail "pushing $branch: the remote refused the push (push-branch.sh's lines in this job's log name why)"
3290
3803
  pushed=$(git -C "$worktree" rev-parse HEAD 2>/dev/null) || pushed=""
3291
3804
  if [ "$use_mirror" -eq 0 ]; then
3292
3805
  start_remove_copy
@@ -3865,9 +4378,12 @@ The task is the dispatch's \`client_payload\` title and body. Sending the same d
3865
4378
  # never exits, and reports a failure as one `remote-run.sh: …` line on stderr.
3866
4379
  #
3867
4380
  # THE TARGET RULE. A comment goes to the open same-repository pull request
3868
- # whose head is the branch when `forge_recognised` holds for that branch, else
3869
- # to the issue the run was started from (`FORGE_ISSUE`), else nowhere. The
3870
- # state label goes on that issue and on that pull request, each when known.
4381
+ # whose head is the branch when `forge_recognised` holds for that branch — its
4382
+ # origin tip carries its task prompt or its flow-progress ledger — else to the
4383
+ # issue the run was started from (`FORGE_ISSUE`), else nowhere. The task prompt
4384
+ # counts so that a pull request opened at the run's start is a target before
4385
+ # the ledger's first push. The state label goes on that issue and on that pull
4386
+ # request, each when known.
3871
4387
  #
3872
4388
  # THE LABEL IS A VIEW, NEVER AN AUTHORITY. The run list is the authority
3873
4389
  # (`remote_state`); the harness overwrites any state label set by hand, and
@@ -3926,14 +4442,12 @@ forge_fetch_branch() {
3926
4442
  return 0
3927
4443
  }
3928
4444
 
3929
- # forge_marker <event> <branch> [<question>] — the one producer of a comment's
3930
- # marker line, built from COMMENT_MARKER.
4445
+ # forge_marker <event> <branch> [<question> [<engine> [<round>]]] — the one
4446
+ # producer of a comment's marker line, built from COMMENT_MARKER: ` question=<n>`,
4447
+ # ` engine=<engine>` then ` round=<n>`, each only when non-empty, before ` -->`.
3931
4448
  forge_marker() {
3932
- if [ -n "${3-}" ]; then
3933
- printf '%s event=%s branch=%s question=%s -->\n' "$COMMENT_MARKER" "$1" "$2" "$3"
3934
- else
3935
- printf '%s event=%s branch=%s -->\n' "$COMMENT_MARKER" "$1" "$2"
3936
- fi
4449
+ printf '%s event=%s branch=%s%s%s%s -->\n' "$COMMENT_MARKER" "$1" "$2" \
4450
+ "${3:+ question=$3}" "${4:+ engine=$4}" "${5:+ round=$5}"
3937
4451
  }
3938
4452
 
3939
4453
  # forge_issue_var <branch> — FORGE_ISSUE from the last provenance line
@@ -4014,38 +4528,134 @@ forge_provenance_parse() {
4014
4528
  }
4015
4529
 
4016
4530
  # forge_recognised <branch> — the harness-branch test, read from committed
4017
- # state: 0 when origin's copy of the branch carries its flow-progress ledger.
4531
+ # state: 0 when origin's copy of the branch carries its task prompt or its
4532
+ # flow-progress ledger, 1 otherwise.
4018
4533
  forge_recognised() {
4019
4534
  local state_rel
4020
4535
  state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
4021
4536
  [ -n "$state_rel" ] || return 1
4022
- git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null
4537
+ git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null \
4538
+ || git -C "$root" cat-file -e "refs/remotes/origin/$1:$(hr_task_prompt_rel "$state_rel" "$1")" 2>/dev/null
4023
4539
  }
4024
4540
 
4025
4541
  # forge_pr_var <branch> — FORGE_PR, the open pull request whose head is
4026
- # <branch> in this repository (a fork's same-named head is skipped), or empty.
4542
+ # <branch> in this repository (a fork's same-named head is skipped), or empty;
4543
+ # FORGE_PR_DRAFT, that same pull request's `isDraft` as `true` or `false`, or
4544
+ # empty when FORGE_PR is.
4027
4545
  FORGE_PR=""
4546
+ FORGE_PR_DRAFT=""
4028
4547
  forge_pr_var() {
4548
+ local sel
4029
4549
  FORGE_PR=""
4030
- if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository --limit 10; then
4550
+ FORGE_PR_DRAFT=""
4551
+ if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository,isDraft --limit 10; then
4031
4552
  echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
4032
4553
  return 1
4033
4554
  fi
4034
- if ! FORGE_PR=$(printf '%s' "$GH_OUT" | jq -r \
4035
- 'if type == "array" then [.[] | select(.isCrossRepository == false) | .number | numbers] | first // empty else error end' 2>/dev/null); then
4036
- FORGE_PR=""
4555
+ if ! sel=$(printf '%s' "$GH_OUT" | jq -r \
4556
+ 'if type == "array" then [.[] | select(.isCrossRepository == false and (.number | type) == "number")] | first // empty | "\(.number) \(.isDraft == true)" else error end' 2>/dev/null); then
4037
4557
  GH_ERR="its pr list is not the expected JSON"
4038
4558
  echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
4039
4559
  return 1
4040
4560
  fi
4561
+ if [ -n "$sel" ]; then
4562
+ FORGE_PR="${sel%% *}"
4563
+ FORGE_PR_DRAFT="${sel#* }"
4564
+ fi
4565
+ return 0
4566
+ }
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
+
4597
+ # forge_dispatch_engine_var <branch> <created_at_iso> — FORGE_DISPATCH_ENGINE,
4598
+ # the engine the dispatch that created the run at <created_at_iso> recorded in
4599
+ # its comment on the branch's issue or open pull request, or empty. Only a
4600
+ # `github-actions[bot]` comment counts, whose last non-empty line is exactly a
4601
+ # `started` or `round` marker for <branch>, either with or without an engine,
4602
+ # or a `reply` marker for <branch> carrying an engine, and whose `created_at`
4603
+ # is no earlier than <created_at_iso> less DISPATCH_MARKER_SLACK_SECS. The
4604
+ # newest one answers: `started` is `task`, `round` is `user_review`, `reply`
4605
+ # its own engine. 1 when the forge is off or the repository is unknown. Fetches
4606
+ # origin's <branch> into the root checkout; replaces GH_OUT.
4607
+ FORGE_DISPATCH_ENGINE=""
4608
+ forge_dispatch_engine_var() {
4609
+ local b="$1" created="${2-}" since markers listed="" n engine answer
4610
+ FORGE_DISPATCH_ENGINE=""
4611
+ forge_on || return 1
4612
+ forge_repo_var || return 1
4613
+ if ! since=$(jq -n -r --arg t "$created" --argjson o "$DISPATCH_MARKER_SLACK_SECS" '($t | fromdateiso8601) - $o' 2>/dev/null) \
4614
+ || [ -z "$since" ]; then
4615
+ echo "remote-run.sh: the newest run of $b has no readable createdAt ('$created'); its dispatch's engine is not read" >&2
4616
+ return 0
4617
+ fi
4618
+ # Every marker line that qualifies, each with the engine it answers.
4619
+ markers=""
4620
+ for engine in task user_review docs; do
4621
+ markers="$markers$(forge_marker started "$b" "" "$engine")"$'\t'task$'\n'
4622
+ markers="$markers$(forge_marker round "$b" "" "$engine")"$'\t'user_review$'\n'
4623
+ markers="$markers$(forge_marker reply "$b" "" "$engine")"$'\t'"$engine"$'\n'
4624
+ done
4625
+ markers="$markers$(forge_marker started "$b")"$'\t'task$'\n'
4626
+ markers="$markers$(forge_marker round "$b")"$'\t'user_review$'\n'
4627
+ markers=$(printf '%s' "$markers" | jq -R -s -c \
4628
+ '[split("\n")[] | select(length > 0) | split("\t") | {(.[0]): .[1]}] | add')
4629
+ forge_fetch_branch "$b"
4630
+ forge_issue_var "$b" || FORGE_ISSUE=""
4631
+ forge_pr_var "$b" || FORGE_PR=""
4632
+ for n in $FORGE_ISSUE $FORGE_PR; do
4633
+ if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$n/comments" --jq '.[] | {login: .user.login, at: .created_at, body: .body}'; then
4634
+ echo "remote-run.sh: listing the comments of #$n failed, so the engine of $b's dispatch is not read: $GH_ERR" >&2
4635
+ return 0
4636
+ fi
4637
+ listed="$listed$GH_OUT"$'\n'
4638
+ done
4639
+ if ! answer=$(printf '%s' "$listed" | jq -s -r --argjson m "$markers" --argjson since "$since" '
4640
+ [.[] | select(type == "object" and .login == "github-actions[bot]" and (.at | type) == "string")
4641
+ | ((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "") as $last
4642
+ | select($m[$last] != null)
4643
+ | (.at | try fromdateiso8601 catch null) as $t
4644
+ | select($t != null and $t >= $since)
4645
+ | {t: $t, e: $m[$last]}]
4646
+ | sort_by(.t) | last | .e // ""' 2>/dev/null); then
4647
+ echo "remote-run.sh: the comments of $b's issue and pull request are not the expected JSON, so the engine of its dispatch is not read" >&2
4648
+ return 0
4649
+ fi
4650
+ FORGE_DISPATCH_ENGINE="$answer"
4041
4651
  return 0
4042
4652
  }
4043
4653
 
4044
- # forge_comment <number> <event> <branch> <body_file> [<question>] — append the
4045
- # marker to <body_file> and post it on issue or pull request <number>.
4654
+ # forge_comment <number> <event> <branch> <body_file> [<question> [<engine> [<round>]]]
4655
+ # — append the marker to <body_file> and post it on issue or pull request <number>.
4046
4656
  forge_comment() {
4047
- local number="$1" event="$2" branch="$3" file="$4" question="${5-}" status
4048
- if ! { printf '\n'; forge_marker "$event" "$branch" "$question"; } >>"$file"; then
4657
+ local number="$1" event="$2" branch="$3" file="$4" question="${5-}" engine="${6-}" round="${7-}" status
4658
+ if ! { printf '\n'; forge_marker "$event" "$branch" "$question" "$engine" "$round"; } >>"$file"; then
4049
4659
  echo "remote-run.sh: cannot append the marker to '$file'" >&2
4050
4660
  return 1
4051
4661
  fi
@@ -4158,15 +4768,25 @@ forge_question_body() {
4158
4768
  # comment (on `parked`, one per open question) and the state label, by the
4159
4769
  # target rule above. Read on `stopped` only: <pr>, an explicit pull request
4160
4770
  # that is the target whatever its state; `gone`, the branch deleted on GitHub,
4161
- # its issue read from the task prompt at <sha>. Every event but `stopped` is
4162
- # withheld when the branch's newest `harness stop` run is newer than its newest
4163
- # `harness run` run, read from a fresh listing. Always 0.
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
4777
+ # REPORT_NOT_STARTED_ENGINE (empty or the recovered engine), set by its caller.
4778
+ # Always 0.
4779
+ REPORT_NOT_STARTED_STATE=""
4780
+ REPORT_NOT_STARTED_ENGINE=""
4164
4781
  forge_report() {
4165
4782
  local event="$1" br="$2" note="${3-}" pr="${4-}" gone="${5-}" gone_sha="${6-}" state reason="" resume_at="" when registry_file
4166
- local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
4783
+ local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n route engine=""
4784
+ local gone_prs="" reported="" folded=""
4167
4785
  case "$event" in
4168
4786
  parked|park_loop) state=parked ;;
4169
4787
  paused) state=paused ;;
4788
+ not_started)
4789
+ if [ "$REPORT_NOT_STARTED_STATE" = paused ]; then state=paused; else state=failed; fi ;;
4170
4790
  resumed) state=running ;;
4171
4791
  failed) state=failed ;;
4172
4792
  stopped) state=stopped ;;
@@ -4210,19 +4830,24 @@ forge_report() {
4210
4830
  if [ -n "$pr" ]; then
4211
4831
  FORGE_PR="$pr"
4212
4832
  elif [ -n "$gone" ]; then
4213
- # GitHub closes a pull request whose head is deleted, so none is open.
4833
+ # No pull request of a deleted head is open, so the run's unfinished ones
4834
+ # are found in every state by forge_gone_prs_var.
4214
4835
  FORGE_PR=""
4836
+ forge_gone_prs_var "$br"
4837
+ gone_prs="$FORGE_GONE_PRS"
4215
4838
  else
4216
4839
  forge_pr_var "$br" || FORGE_PR=""
4217
4840
  fi
4218
4841
  if [ -z "$pr" ] && [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
4219
- echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
4842
+ echo "remote-run.sh: report: pull request #$FORGE_PR's head carries neither a task prompt nor a flow-progress ledger; it is not a target"
4220
4843
  FORGE_PR=""
4221
4844
  fi
4222
4845
  if [ -n "$FORGE_PR" ]; then
4223
4846
  target="$FORGE_PR"; kind=pr
4224
4847
  elif [ -n "$FORGE_ISSUE" ]; then
4225
4848
  target="$FORGE_ISSUE"; kind=issue
4849
+ elif [ -n "$gone_prs" ]; then
4850
+ target=""; kind=pr
4226
4851
  else
4227
4852
  echo "remote-run.sh: report: $br has no open pull request and no issue it was started from; nothing posted"
4228
4853
  return 0
@@ -4233,7 +4858,16 @@ forge_report() {
4233
4858
  if [ -n "$registry_file" ] && [ -f "$registry_file" ]; then
4234
4859
  reason=$(hr_registry_get "$registry_file" "$br" pause_reason)
4235
4860
  resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
4861
+ [ "$event" != failed ] || engine=$(hr_registry_get "$registry_file" "$br" engine)
4236
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
4237
4871
 
4238
4872
  case "$event" in
4239
4873
  paused)
@@ -4248,10 +4882,13 @@ forge_report() {
4248
4882
  resumed)
4249
4883
  text="The harness run on \`$br\` resumed." ;;
4250
4884
  failed)
4251
- if [ "$kind" = pr ]; then
4252
- text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, submit a review on this pull request requesting changes."
4885
+ trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
4886
+ if [ "$kind" = pr ] && [ "$engine" = user_review ]; then
4887
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, submit a review on this pull request requesting changes. This pull request stays open."
4888
+ elif [ "$kind" = pr ]; then
4889
+ # No review route: a round would start over a task run that never completed.
4890
+ text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. This draft pull request stays open: close it to discard the run${FORGE_ISSUE:+, or re-apply the label \`$trigger_label\` to issue #$FORGE_ISSUE to start a new run on the next indexed branch}."
4253
4891
  else
4254
- trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
4255
4892
  text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, re-apply the label \`$trigger_label\` to this issue; that starts a new run, on the next indexed branch."
4256
4893
  fi ;;
4257
4894
  stopped)
@@ -4262,9 +4899,28 @@ forge_report() {
4262
4899
  text="The harness run on \`$br\` was stopped. While the branch exists, reopen this pull request and comment \`${COMMAND_HANDLE} resume\` here, or comment it on the run's issue, to continue it from its committed ledger. A merged pull request cannot be reopened; after a merge, use the issue."
4263
4900
  else
4264
4901
  text="The harness run on \`$br\` was stopped. Comment \`${COMMAND_HANDLE} resume\` to continue it from its committed ledger. A review that requests changes is collected now, and its round starts once the resumed run finishes."
4902
+ [ "$kind" != pr ] || text="$text Its draft pull request stays open; closing it discards the run."
4265
4903
  fi ;;
4266
4904
  round)
4267
- text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
4905
+ text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again."
4906
+ # The undo runs before the comment is written, so the comment states its outcome.
4907
+ if [ "$kind" = pr ] && [ "$FORGE_PR_DRAFT" = false ]; then
4908
+ if gh_call pr ready "$FORGE_PR" --repo "$FORGE_REPO" --undo; then
4909
+ text="$text This pull request is a draft again until the round completes."
4910
+ else
4911
+ echo "::warning::remote-run.sh: report: turning pull request #$FORGE_PR back to a draft was refused: $GH_ERR"
4912
+ text="$text Turning this pull request back to a draft was refused, so it stays ready for review while the round works."
4913
+ fi
4914
+ fi ;;
4915
+ not_started)
4916
+ text="GitHub did not start the job of the harness run on \`$br\`, so nothing ran and the branch is unchanged."
4917
+ if [ "$state" = paused ] && [ -n "$REPORT_NOT_STARTED_ENGINE" ]; then
4918
+ text="$text Comment \`${COMMAND_HANDLE} resume\` to start it again."
4919
+ else
4920
+ # A job that never started uploaded no artifact to read an engine from.
4921
+ route=$(hr_github_resume_route "$br" "${REPORT_NOT_STARTED_ENGINE:-<task, user_review or docs: the one the run was started with>}")
4922
+ text="$text Start it again with the **Run workflow** form: ${route#or from GitHub: }."
4923
+ fi ;;
4268
4924
  esac
4269
4925
 
4270
4926
  OPEN_QUESTIONS=""
@@ -4297,27 +4953,245 @@ forge_report() {
4297
4953
  echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
4298
4954
  fi
4299
4955
  done
4300
- elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
4301
- {
4302
- printf '%s\n' "$text"
4303
- [ -z "$note" ] || printf '\n%s\n' "$note"
4304
- [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4305
- } >"$file"
4306
- forge_comment "$target" "$event" "$br" "$file" || :
4307
- rm -f "$file"
4308
- else
4309
- 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"
4310
4958
  fi
4311
- [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4312
4959
 
4313
4960
  [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" "$state" || :
4314
4961
  [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" "$state" || :
4315
- echo "remote-run.sh: report: $event on $br reported on #$target"
4962
+ if [ "$event" = stopped ]; then
4963
+ [ -z "$FORGE_PR" ] || forge_progress_stopped "$FORGE_PR" "$br"
4964
+ fi
4965
+ reported="${target:+#$target}"
4966
+ for n in $gone_prs; do
4967
+ forge_report_text "$n" "$event" "$br" "$tmp" "$text" "$note"
4968
+ forge_set_state "$n" "$state" || :
4969
+ forge_progress_stopped "$n" "$br"
4970
+ reported="${reported:+$reported, }#$n"
4971
+ done
4972
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4973
+ echo "remote-run.sh: report: $event on $br reported on $reported"
4974
+ return 0
4975
+ }
4976
+
4977
+ # forge_report_text <number> <event> <branch> <tmp_dir> <text> <note> — post
4978
+ # forge_report's one comment, <text>, <note> and the run URL, on <number>.
4979
+ forge_report_text() {
4980
+ local number="$1" event="$2" br="$3" tmp="$4" text="$5" note="$6" file
4981
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
4982
+ echo "remote-run.sh: report: cannot create the comment file for #$number; no comment posted" >&2
4983
+ return 0
4984
+ fi
4985
+ {
4986
+ printf '%s\n' "$text"
4987
+ [ -z "$note" ] || printf '\n%s\n' "$note"
4988
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4989
+ } >"$file"
4990
+ forge_comment "$number" "$event" "$br" "$file" || :
4991
+ rm -f "$file"
4992
+ }
4993
+
4994
+ # forge_progress_comment_var <pr> <marker> [any-round] — the one lookup of a
4995
+ # progress comment: one paginated listing of <pr>'s comments, then the newest
4996
+ # `github-actions[bot]` comment with a numeric id whose last non-empty line is
4997
+ # <marker>, or, with `any-round`, <marker> with ` round=<digits>` before its
4998
+ # ` -->`. Sets PROGRESS_COMMENT_ID and PROGRESS_COMMENT_BODY, both empty when
4999
+ # none is picked. 0 when the listing parsed, picked or not; 1 when gh refused it
5000
+ # (GH_ERR as gh_call set it); 2 when it is not the expected JSON. Prints
5001
+ # nothing. No temporary file: forge_progress needs its own before this lookup,
5002
+ # forge_progress_stopped only after it.
5003
+ PROGRESS_COMMENT_ID=""
5004
+ PROGRESS_COMMENT_BODY=""
5005
+ forge_progress_comment_var() {
5006
+ local pr="$1" marker="$2" any="${3-}" pick id body
5007
+ PROGRESS_COMMENT_ID=""
5008
+ PROGRESS_COMMENT_BODY=""
5009
+ gh_call api --paginate "repos/$FORGE_REPO/issues/$pr/comments" --jq '.[] | {id, login: .user.login, body}' || return 1
5010
+ pick=$(printf '%s' "$GH_OUT" | jq -s -c --arg m "$marker" --arg any "$any" '
5011
+ def hit: . == $m
5012
+ or ($any == "any-round"
5013
+ and (($m | sub(" -->$"; "")) as $stem
5014
+ | startswith($stem) and (.[($stem | length):] | test("^ round=[0-9]+ -->$"))));
5015
+ [.[] | select(type == "object" and .login == "github-actions[bot]" and (.id | type) == "number")
5016
+ | select((.body // "") | gsub("\r"; "") | split("\n") | map(select(test("^\\s*$") | not)) | last // "" | hit)]
5017
+ | max_by(.id) // empty
5018
+ | {id, body: (.body // "")}' 2>/dev/null) || return 2
5019
+ [ -n "$pick" ] || return 0
5020
+ id=$(printf '%s' "$pick" | jq -r '.id' 2>/dev/null) || return 2
5021
+ # The sentinel keeps the body's trailing newlines, which $(…) would strip.
5022
+ body=$(printf '%s' "$pick" | jq -j '.body' 2>/dev/null && printf x) || return 2
5023
+ PROGRESS_COMMENT_ID="$id"
5024
+ PROGRESS_COMMENT_BODY="${body%x}"
5025
+ return 0
5026
+ }
5027
+
5028
+ # forge_progress <branch> — upsert the one progress comment of the run or round
5029
+ # on its pull request, rendered from the job checkout's flow-progress ledger:
5030
+ # created when none is listed, edited in place only when its body differs. No
5031
+ # issue fallback, no label. Always 0.
5032
+ forge_progress() {
5033
+ local br="$1" status state_rel phases engine round p line i first=1 marker
5034
+ local tmp made_tmp="" file id same
5035
+ local -a labels states
5036
+ if ! forge_on; then
5037
+ echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
5038
+ return 0
5039
+ fi
5040
+ status=0
5041
+ hr_progress_comments "$root" || status=$?
5042
+ case "$status" in
5043
+ 0) ;;
5044
+ 1) echo "remote-run.sh: report: progress comments are off by execution.progressComments; nothing posted"; return 0 ;;
5045
+ *) echo "remote-run.sh: report: execution.progressComments is unreadable; nothing posted"; return 0 ;;
5046
+ esac
5047
+ forge_repo_var || return 0
5048
+ ALL_RUNS_LISTED=0
5049
+ status=0
5050
+ remote_branch_stopped "$br" || status=$?
5051
+ if [ "$status" -eq 0 ]; then
5052
+ echo "remote-run.sh: report: $br was stopped, and the stop already reported the run; nothing posted"
5053
+ return 0
5054
+ fi
5055
+ [ "$status" -eq 1 ] \
5056
+ || echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the progress" >&2
5057
+ state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
5058
+ if [ -z "$state_rel" ] || ! phases=$(hr_ledger_phases "$root/${state_rel%/}/flow_progress/${br}_progress.md"); then
5059
+ echo "remote-run.sh: report: $br has no task or user-review ledger; nothing posted"
5060
+ return 0
5061
+ fi
5062
+ read -r engine round states[0] states[1] states[2] states[3] <<<"$phases"
5063
+ forge_fetch_branch "$br"
5064
+ forge_pr_var "$br" || FORGE_PR=""
5065
+ if [ -z "$FORGE_PR" ] || ! forge_recognised "$br"; then
5066
+ echo "remote-run.sh: report: $br has no recognised open pull request; nothing posted"
5067
+ return 0
5068
+ fi
5069
+
5070
+ if [ "$engine" = user_review ]; then
5071
+ labels=('Fix plan' 'Fix implementation' 'Branch review' 'Done')
5072
+ line="Progress of user-review round $round on \`$br\`:"
5073
+ else
5074
+ labels=('Planning' 'Implementation' 'Branch review' 'Done')
5075
+ line="Progress of the harness run on \`$br\`:"
5076
+ round=""
5077
+ fi
5078
+ marker=$(forge_marker progress "$br" "" "" "$round")
5079
+
5080
+ tmp="${RUNNER_TEMP-}"
5081
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5082
+ tmp=$(mktemp -d) || tmp=""
5083
+ made_tmp="$tmp"
5084
+ fi
5085
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-progress-comment.XXXXXX"); then
5086
+ echo "::warning::remote-run.sh: report: cannot create the progress comment file for #$FORGE_PR; nothing posted"
5087
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
5088
+ return 0
5089
+ fi
5090
+ # No timestamp and no run URL: an unchanged ledger must render byte-identical.
5091
+ # Plain list items: a `- [ ]` box is clickable by anyone with write access.
5092
+ {
5093
+ printf '%s\n\n' "$line"
5094
+ for i in 0 1 2 3; do
5095
+ if [ "${states[$i]}" = done ]; then
5096
+ p=done
5097
+ elif [ "$first" -eq 1 ]; then
5098
+ p="in progress"; first=0
5099
+ else
5100
+ p="not started"
5101
+ fi
5102
+ printf -- '- %s: %s\n' "${labels[$i]}" "$p"
5103
+ done
5104
+ } >"$file"
5105
+ { cat "$file"; printf '\n%s\n' "$marker"; } >"$file.full"
5106
+
5107
+ status=0
5108
+ forge_progress_comment_var "$FORGE_PR" "$marker" || status=$?
5109
+ if [ "$status" -eq 1 ]; then
5110
+ echo "::warning::remote-run.sh: report: listing the comments of #$FORGE_PR was refused, so the progress is not posted: $GH_ERR"
5111
+ elif [ "$status" -ne 0 ]; then
5112
+ echo "::warning::remote-run.sh: report: the comments of #$FORGE_PR are not the expected JSON, so the progress is not posted"
5113
+ elif [ -z "$PROGRESS_COMMENT_ID" ]; then
5114
+ if forge_comment "$FORGE_PR" progress "$br" "$file" "" "" "$round"; then
5115
+ echo "remote-run.sh: report: progress on $br posted on #$FORGE_PR"
5116
+ else
5117
+ echo "::warning::remote-run.sh: report: posting the progress comment on #$FORGE_PR was refused: $GH_ERR"
5118
+ fi
5119
+ else
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
5124
+ if [ "$same" = same ]; then
5125
+ echo "remote-run.sh: report: the progress comment on #$FORGE_PR is already current"
5126
+ elif gh_call api --method PATCH "repos/$FORGE_REPO/issues/comments/$id" -F "body=@$file.full"; then
5127
+ echo "remote-run.sh: report: progress on $br edited in comment $id on #$FORGE_PR"
5128
+ else
5129
+ echo "::warning::remote-run.sh: report: editing the progress comment $id on #$FORGE_PR was refused: $GH_ERR"
5130
+ fi
5131
+ fi
5132
+ rm -f "$file" "$file.full"
5133
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
5134
+ return 0
5135
+ }
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 || :
4316
5186
  return 0
4317
5187
  }
4318
5188
 
4319
5189
  verb_report() {
4320
- forge_report "$report_event" "$branch" "$report_note"
5190
+ if [ "$report_event" = progress ]; then
5191
+ forge_progress "$branch"
5192
+ else
5193
+ forge_report "$report_event" "$branch" "$report_note"
5194
+ fi
4321
5195
  exit "$EXIT_OK"
4322
5196
  }
4323
5197
 
@@ -4352,7 +5226,7 @@ deliver_pr_body() {
4352
5226
  verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
4353
5227
  done
4354
5228
  {
4355
- printf 'This pull request carries the harness run on `%s`, ready for your review. The harness never merges it.\n' "$2"
5229
+ printf 'This pull request carries the harness run on `%s`. It stays a draft while the run works, and the harness marks it ready for review when the run completes; the harness never merges it.\n' "$2"
4356
5230
  [ -z "$FORGE_ISSUE" ] || printf '\nStarted from #%s.\n' "$FORGE_ISSUE"
4357
5231
  printf '\nA review that requests changes starts a user-review round on this branch.\n'
4358
5232
  printf 'Comment %s to act on the run; each says when it applies (docs/github-run-control.md in the harness documentation).\n' "$verbs"
@@ -4374,11 +5248,280 @@ deliver_create() {
4374
5248
  fi
4375
5249
  }
4376
5250
 
4377
- # verb_deliver — after a `completed` bundle: find or open the branch's pull
4378
- # request, post the one `completed` comment, and set `done`. Always exit 0.
5251
+ # forge_open_pr <branch> — the one opener of a run's pull request, against
5252
+ # `defaultBranch`: a `--draft` create, then on any failure but
5253
+ # PR_CREATE_FORBIDDEN one retry without `--draft`. The caller has set
5254
+ # FORGE_REPO, FORGE_SERVER and FORGE_ISSUE. 0 when opened, setting FORGE_PR,
5255
+ # FORGE_PR_URL and FORGE_PR_DRAFT (`false` after the retry); 1 when not,
5256
+ # setting OPEN_ERR (one line) and OPEN_FORBIDDEN (1 on PR_CREATE_FORBIDDEN).
5257
+ # Always sets OPEN_BASE, empty when unreadable. Never posts, never exits.
5258
+ FORGE_PR_URL=""
5259
+ OPEN_ERR=""
5260
+ OPEN_FORBIDDEN=0
5261
+ OPEN_BASE=""
5262
+ forge_open_pr() {
5263
+ local br="$1" tmp made_tmp="" body="" draft=""
5264
+ FORGE_PR=""
5265
+ FORGE_PR_URL=""
5266
+ FORGE_PR_DRAFT=""
5267
+ OPEN_ERR=""
5268
+ OPEN_FORBIDDEN=0
5269
+ OPEN_BASE=$(hr_default_branch "$root" 2>/dev/null) || OPEN_BASE=""
5270
+ deliver_title_var "$br"
5271
+ if [ -z "$OPEN_BASE" ]; then
5272
+ OPEN_ERR="the configured defaultBranch could not be read"
5273
+ return 1
5274
+ fi
5275
+ tmp="${RUNNER_TEMP-}"
5276
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5277
+ tmp=$(mktemp -d) || tmp=""
5278
+ made_tmp="$tmp"
5279
+ fi
5280
+ if [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-pr-body.XXXXXX") || ! deliver_pr_body "$body" "$br"; then
5281
+ OPEN_ERR="its body file could not be written"
5282
+ elif deliver_create "$OPEN_BASE" "$br" "$body" --draft; then
5283
+ draft=true
5284
+ else
5285
+ OPEN_ERR="$GH_ERR"
5286
+ case "$GH_ERR" in
5287
+ *"$PR_CREATE_FORBIDDEN"*) OPEN_FORBIDDEN=1 ;;
5288
+ *)
5289
+ # Drafts depend on the account's plan (C3, not measured): one retry as ready.
5290
+ echo "remote-run.sh: $verb: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
5291
+ if deliver_create "$OPEN_BASE" "$br" "$body"; then
5292
+ draft=false
5293
+ else
5294
+ OPEN_ERR="$GH_ERR"
5295
+ fi ;;
5296
+ esac
5297
+ fi
5298
+ [ -z "$body" ] || rm -f "$body"
5299
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
5300
+ [ -n "$draft" ] || return 1
5301
+ FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
5302
+ if [ -z "$FORGE_PR" ]; then
5303
+ OPEN_ERR="gh printed no pull request URL"
5304
+ return 1
5305
+ fi
5306
+ FORGE_PR_URL="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
5307
+ FORGE_PR_DRAFT="$draft"
5308
+ return 0
5309
+ }
5310
+
5311
+ # deliver_comment <number> <tmp> <text> — one `completed` comment on <number>:
5312
+ # <text>, the `phases.qa` sentence when that phase is on, this run's URL when
5313
+ # GITHUB_RUN_ID is set, then the marker. A failure is one line.
5314
+ deliver_comment() {
5315
+ local number="$1" tmp="$2" text="$3" file
5316
+ if hr_phase_enabled "$root" qa; then
5317
+ text="$text
5318
+
5319
+ The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
5320
+ fi
5321
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
5322
+ echo "remote-run.sh: deliver: cannot create the comment file for #$number; no comment posted" >&2
5323
+ return 0
5324
+ fi
5325
+ {
5326
+ printf '%s\n' "$text"
5327
+ [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
5328
+ } >"$file"
5329
+ forge_comment "$number" completed "$branch" "$file" || :
5330
+ rm -f "$file"
5331
+ }
5332
+
5333
+ # deliver_thread_reply <tmp> <comment_id> <text> — one reply to the review
5334
+ # thread holding <comment_id>, <text> then the `thread` marker. 0 when posted;
5335
+ # otherwise one `::warning::` line and 1.
5336
+ deliver_thread_reply() {
5337
+ local tmp="$1" id="$2" text="$3" file
5338
+ if [ -z "$tmp" ] || ! file=$(mktemp "$tmp/harness-thread-reply.XXXXXX"); then
5339
+ echo "::warning::remote-run.sh: deliver: cannot create the reply file for comment $id; no reply posted"
5340
+ return 1
5341
+ fi
5342
+ if ! { printf '%s\n\n' "$text"; forge_marker thread "$branch" "" "" "$RC_MARKED_N"; } >"$file"; then
5343
+ rm -f "$file"
5344
+ echo "::warning::remote-run.sh: deliver: cannot write the reply file for comment $id; no reply posted"
5345
+ return 1
5346
+ fi
5347
+ if ! gh_call api --method POST "repos/$FORGE_REPO/pulls/$FORGE_PR/comments/$id/replies" -F "body=@$file"; then
5348
+ rm -f "$file"
5349
+ echo "::warning::remote-run.sh: deliver: the reply to review comment $id was refused: $GH_ERR"
5350
+ return 1
5351
+ fi
5352
+ rm -f "$file"
5353
+ return 0
5354
+ }
5355
+
5356
+ # deliver_round_threads <tmp> — at a round's completion, handle each inline
5357
+ # review thread the round collected by the fix plan's verdict on its comment:
5358
+ # `fixed` is a reply naming the fix commit, then the thread resolved; `reason`
5359
+ # is a reply quoting it, the thread left open. The wire it reads is the
5360
+ # header's THE REVIEW-COMMENT LINE. Never dismisses a review; never fails.
5361
+ deliver_round_threads() {
5362
+ local tmp="$1" plan index_rel dir_rel index names path name k line rest id ids
5363
+ local sha tip since pages threads row t_id t_resolved t_ids t_root t_later t_own
5364
+ local i found handled="," own_marker re
5365
+ local -a v_id=() v_kind=() v_val=()
5366
+ if ! round_markers_read ""; then
5367
+ echo "::warning::remote-run.sh: deliver: the round's review threads were not read: $RC_ERR"
5368
+ return 0
5369
+ fi
5370
+ if [ "$RC_NEWEST_N" -eq 0 ]; then
5371
+ echo "remote-run.sh: deliver: $branch has no round file; no review thread handled"
5372
+ return 0
5373
+ fi
5374
+ if [ "$RC_NEWEST_N" -ne "$RC_MARKED_N" ]; then
5375
+ echo "remote-run.sh: deliver: round $RC_NEWEST_N of $branch was not placed from a pull-request review; no review thread handled"
5376
+ return 0
5377
+ fi
5378
+ if [ -z "$RC_MARKED_C" ]; then
5379
+ echo "remote-run.sh: deliver: round $RC_MARKED_N of $branch collected no inline comment; no review thread handled"
5380
+ return 0
5381
+ fi
5382
+
5383
+ plan="${branch}_fix_plan"
5384
+ [ "$RC_MARKED_N" -eq 1 ] || plan="${plan}_$RC_MARKED_N"
5385
+ index_rel="$RC_STATE_REL/user_reviews/$plan.md"
5386
+ dir_rel="$RC_STATE_REL/user_reviews/$plan"
5387
+ if ! index=$(git -C "$root" show "refs/remotes/origin/$branch:$index_rel" 2>/dev/null); then
5388
+ echo "remote-run.sh: deliver: round $RC_MARKED_N of $branch has no fix plan at $index_rel; no review thread handled"
5389
+ return 0
5390
+ fi
5391
+ tip=$(git -C "$root" rev-parse "refs/remotes/origin/$branch" 2>/dev/null) || tip=""
5392
+
5393
+ # Verdicts, kept only for ids the round collected; the first verdict an id gets wins.
5394
+ names=$(git -C "$root" ls-tree --name-only "refs/remotes/origin/$branch" -- "$dir_rel/" 2>/dev/null) || names=""
5395
+ while IFS= read -r path; do
5396
+ name="${path##*/}"
5397
+ [[ "$name" =~ ^finding_([0-9]+)\.md$ ]] || continue
5398
+ k=$((10#${BASH_REMATCH[1]}))
5399
+ case "$index" in *"[x] **Finding $k**"*) ;; *) continue ;; esac
5400
+ line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null | grep -E '^\*\*Review comments:\*\* ' | head -n 1) || line=""
5401
+ [ -n "$line" ] || continue
5402
+ sha=$(git -C "$root" log --reverse --format=%H -S "[x] **Finding $k**" "refs/remotes/origin/$branch" -- "$index_rel" 2>/dev/null | head -n 1) || sha=""
5403
+ [ -n "$sha" ] || sha="$tip"
5404
+ rest="${line#'**Review comments:** '}"
5405
+ rest="${rest%$'\r'}"
5406
+ ids="${rest//[[:space:]]/}"
5407
+ for id in ${ids//,/ }; do
5408
+ [[ "$id" =~ ^[0-9]+$ ]] || continue
5409
+ v_id+=("$id"); v_kind+=(fixed); v_val+=("$sha")
5410
+ done
5411
+ done <<NAMES
5412
+ $names
5413
+ NAMES
5414
+ re='^- (.*) \*\*Review comments:\*\* ([0-9][0-9, ]*)$'
5415
+ found=0
5416
+ while IFS= read -r line; do
5417
+ line="${line%$'\r'}"
5418
+ case "$line" in
5419
+ '## Out of scope / verified-OK'*) found=1; continue ;;
5420
+ '## '*) found=0; continue ;;
5421
+ esac
5422
+ [ "$found" -eq 1 ] || continue
5423
+ [[ "$line" =~ $re ]] || continue
5424
+ rest="${BASH_REMATCH[1]}"
5425
+ ids="${BASH_REMATCH[2]//[[:space:]]/}"
5426
+ for id in ${ids//,/ }; do
5427
+ [[ "$id" =~ ^[0-9]+$ ]] || continue
5428
+ v_id+=("$id"); v_kind+=(reason); v_val+=("$rest")
5429
+ done
5430
+ done <<INDEX
5431
+ $index
5432
+ INDEX
5433
+
5434
+ if ! since=$(jq -n -r --arg t "$RC_MARKED_AT" '$t | fromdateiso8601' 2>/dev/null) || [ -z "$since" ]; then
5435
+ echo "::warning::remote-run.sh: deliver: round $RC_MARKED_N's collected_at '$RC_MARKED_AT' is not a UTC time; no review thread handled"
5436
+ return 0
5437
+ fi
5438
+ if ! gh_call api graphql --paginate -f "owner=${FORGE_REPO%%/*}" -f "name=${FORGE_REPO#*/}" -F "number=$FORGE_PR" \
5439
+ -f 'query=query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100, after: $endCursor) { pageInfo { hasNextPage endCursor } nodes { id isResolved comments(first: 100) { nodes { databaseId createdAt body } } } } } } }'; then
5440
+ echo "::warning::remote-run.sh: deliver: listing the review threads of pull request #$FORGE_PR was refused: $GH_ERR"
5441
+ return 0
5442
+ fi
5443
+ pages="$GH_OUT"
5444
+ own_marker=$(forge_marker thread "$branch" "" "" "$RC_MARKED_N")
5445
+ # One `|`-joined row per thread (a GraphQL node id carries no `|`): id,
5446
+ # resolved, its comment ids, its first comment's id, whether a comment came
5447
+ # after the round, whether it carries this harness's reply.
5448
+ if ! threads=$(printf '%s' "$pages" | jq -s -r --argjson since "$since" --arg collected ",$RC_MARKED_C," \
5449
+ --arg marker "$COMMENT_MARKER" --arg own "$own_marker" '
5450
+ [ .[] | .data.repository.pullRequest.reviewThreads.nodes[] ]
5451
+ | .[] | (.comments.nodes // []) as $c
5452
+ | [ .id, (.isResolved == true | tostring),
5453
+ ($c | map(.databaseId | tostring) | join(",")),
5454
+ (($c[0].databaseId // "") | tostring),
5455
+ (any($c[]; ("," + (.databaseId | tostring) + ",") as $k
5456
+ | ((.createdAt // "") | try fromdateiso8601 catch 0) > $since
5457
+ and (($collected | contains($k)) | not)
5458
+ and (((.body // "") | contains($marker)) | not)) | tostring),
5459
+ (any($c[]; (.body // "") | contains($own)) | tostring) ]
5460
+ | join("|")' 2>/dev/null); then
5461
+ echo "::warning::remote-run.sh: deliver: the review threads of pull request #$FORGE_PR are not the expected JSON; no review thread handled"
5462
+ return 0
5463
+ fi
5464
+
5465
+ for id in ${RC_MARKED_C//,/ }; do
5466
+ i=0; found=""
5467
+ while [ "$i" -lt "${#v_id[@]}" ]; do
5468
+ if [ "${v_id[$i]}" = "$id" ]; then found="$i"; break; fi
5469
+ i=$((i + 1))
5470
+ done
5471
+ if [ -z "$found" ]; then
5472
+ echo "remote-run.sh: deliver: review comment $id has no verdict in the fix plan; left alone"
5473
+ continue
5474
+ fi
5475
+ row=""
5476
+ while IFS= read -r line; do
5477
+ [ -n "$line" ] || continue
5478
+ IFS='|' read -r t_id t_resolved t_ids t_root t_later t_own <<<"$line"
5479
+ case ",$t_ids," in *",$id,"*) row="$line"; break ;; esac
5480
+ done <<THREADS
5481
+ $threads
5482
+ THREADS
5483
+ if [ -z "$row" ]; then
5484
+ echo "remote-run.sh: deliver: review comment $id is in no review thread of pull request #$FORGE_PR; left alone"
5485
+ continue
5486
+ fi
5487
+ case "$handled" in
5488
+ *",$t_id,"*) echo "remote-run.sh: deliver: review comment $id's thread was handled for another comment; left alone"; continue ;;
5489
+ esac
5490
+ handled="$handled$t_id,"
5491
+ if [ "$t_resolved" = true ]; then
5492
+ echo "remote-run.sh: deliver: review comment $id's thread is already resolved; left alone"
5493
+ continue
5494
+ fi
5495
+ if [ "$t_later" = true ]; then
5496
+ echo "remote-run.sh: deliver: review comment $id's thread has a reply newer than round $RC_MARKED_N; left alone"
5497
+ continue
5498
+ fi
5499
+ if [ "$t_own" = true ]; then
5500
+ echo "remote-run.sh: deliver: review comment $id's thread already carries this harness's reply; left alone"
5501
+ continue
5502
+ fi
5503
+ # Replies attach to a thread's first comment: GitHub does not take a reply to a reply.
5504
+ if [ "${v_kind[$found]}" = fixed ]; then
5505
+ deliver_thread_reply "$tmp" "$t_root" "Addressed in \`${v_val[$found]}\`." || continue
5506
+ if gh_call api graphql -f 'query=mutation($id: ID!) { resolveReviewThread(input: {threadId: $id}) { thread { isResolved } } }' -f "id=$t_id"; then
5507
+ echo "remote-run.sh: deliver: replied to review comment $id and resolved its thread"
5508
+ else
5509
+ echo "::warning::remote-run.sh: deliver: resolving review comment $id's thread was refused: $GH_ERR"
5510
+ fi
5511
+ else
5512
+ deliver_thread_reply "$tmp" "$t_root" "Not changed in this round: ${v_val[$found]}" || continue
5513
+ echo "remote-run.sh: deliver: replied to review comment $id with the reason; its thread stays open"
5514
+ fi
5515
+ done
5516
+ return 0
5517
+ }
5518
+
5519
+ # verb_deliver — after a `completed` bundle: reuse the branch's pull request or
5520
+ # open it, mark it ready, post `completed` on it and on the issue, and set
5521
+ # `done`. Always exit 0.
4379
5522
  verb_deliver() {
4380
- local status base="" pr_url="" opened=0 lookup_failed=0 create_err="" forbidden=0
4381
- local tmp made_tmp="" body="" file target text compare
5523
+ local status engine noun verb_done pr_url="" lookup_failed=0 create_err="" forbidden=0 base=""
5524
+ local tmp made_tmp="" ready="" text readiness compare posted=""
4382
5525
  if ! forge_on; then
4383
5526
  echo "remote-run.sh: deliver: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
4384
5527
  exit "$EXIT_OK"
@@ -4389,99 +5532,159 @@ verb_deliver() {
4389
5532
  echo "remote-run.sh: deliver: the bundle's status is '${status:-unreadable}', not completed; nothing posted"
4390
5533
  exit "$EXIT_OK"
4391
5534
  fi
5535
+ engine=$(hr_remote_status_get "$bundle_dir/$HR_REMOTE_STATUS_FILE" engine 2>/dev/null) || engine=""
5536
+ if [ "$engine" = user_review ]; then
5537
+ noun=round; verb_done=finished
5538
+ else
5539
+ noun=run; verb_done=completed
5540
+ fi
4392
5541
  forge_repo_var || exit "$EXIT_OK"
4393
5542
 
4394
5543
  forge_fetch_branch "$branch"
4395
5544
  forge_issue_var "$branch" || FORGE_ISSUE=""
4396
- forge_pr_var "$branch" || { FORGE_PR=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4397
-
4398
- tmp="${RUNNER_TEMP-}"
4399
- if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
4400
- tmp=$(mktemp -d) || tmp=""
4401
- made_tmp="$tmp"
4402
- fi
5545
+ forge_pr_var "$branch" || { FORGE_PR=""; FORGE_PR_DRAFT=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
4403
5546
 
4404
5547
  if [ -n "$FORGE_PR" ]; then
4405
5548
  pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4406
5549
  echo "remote-run.sh: deliver: $branch already has pull request #$FORGE_PR; none opened"
4407
5550
  elif [ "$lookup_failed" -eq 0 ]; then
4408
- base=$(hr_default_branch "$root" 2>/dev/null) || base=""
4409
- deliver_title_var "$branch"
4410
- if [ -z "$base" ]; then
4411
- create_err="the configured defaultBranch could not be read"
4412
- elif [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-deliver-body.XXXXXX") || ! deliver_pr_body "$body" "$branch"; then
4413
- create_err="its body file could not be written"
4414
- elif deliver_create "$base" "$branch" "$body" --draft; then
4415
- opened=1
5551
+ echo "remote-run.sh: deliver: $branch has no open pull request at completion — the start's attempt failed or the workflow predates the open step; opening it now"
5552
+ if forge_open_pr "$branch"; then
5553
+ pr_url="$FORGE_PR_URL"
5554
+ echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
4416
5555
  else
4417
- create_err="$GH_ERR"
4418
- case "$GH_ERR" in
4419
- *"$PR_CREATE_FORBIDDEN"*) forbidden=1 ;;
4420
- *)
4421
- # Drafts depend on the account's plan (C3, not measured): one retry as ready.
4422
- echo "remote-run.sh: deliver: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
4423
- if deliver_create "$base" "$branch" "$body"; then
4424
- opened=1
4425
- else
4426
- create_err="$GH_ERR"
4427
- fi ;;
4428
- esac
5556
+ create_err="$OPEN_ERR"
5557
+ forbidden="$OPEN_FORBIDDEN"
4429
5558
  fi
4430
- [ -z "$body" ] || rm -f "$body"
4431
- if [ "$opened" -eq 1 ]; then
4432
- FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
4433
- if [ -n "$FORGE_PR" ]; then
4434
- pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
4435
- echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
5559
+ base="$OPEN_BASE"
5560
+ fi
5561
+
5562
+ # The flip runs with the job's token, never HARNESS_PR_TOKEN.
5563
+ if [ -n "$FORGE_PR" ]; then
5564
+ if [ "$FORGE_PR_DRAFT" = true ]; then
5565
+ if gh_call pr ready "$FORGE_PR" --repo "$FORGE_REPO"; then
5566
+ ready=flipped
5567
+ echo "remote-run.sh: deliver: marked pull request #$FORGE_PR ready for review"
4436
5568
  else
4437
- opened=0
4438
- create_err="gh printed no pull request URL"
5569
+ ready=refused
5570
+ echo "::warning::remote-run.sh: deliver: marking pull request #$FORGE_PR ready for review was refused: $GH_ERR"
4439
5571
  fi
5572
+ else
5573
+ ready=not-draft
4440
5574
  fi
4441
5575
  fi
4442
5576
 
4443
- compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4444
- if [ -z "$pr_url" ]; then
4445
- target="$FORGE_ISSUE"
5577
+ tmp="${RUNNER_TEMP-}"
5578
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5579
+ tmp=$(mktemp -d) || tmp=""
5580
+ made_tmp="$tmp"
5581
+ fi
5582
+
5583
+ # After the flip, before the comments.
5584
+ if [ "$noun" = round ] && [ -n "$FORGE_PR" ]; then
5585
+ deliver_round_threads "$tmp"
5586
+ fi
5587
+
5588
+ if [ -n "$FORGE_PR" ]; then
5589
+ case "$ready" in
5590
+ flipped)
5591
+ if [ "$noun" = round ]; then
5592
+ readiness="The harness round on \`$branch\` finished, and this pull request is marked ready for review again."
5593
+ else
5594
+ readiness="The harness run on \`$branch\` completed, and this pull request is now marked ready for your review."
5595
+ fi ;;
5596
+ not-draft)
5597
+ readiness="The harness $noun on \`$branch\` $verb_done. This pull request is not a draft — drafts may not be available on this repository's plan, or someone marked it ready — so it was left as it is." ;;
5598
+ *)
5599
+ readiness="The harness $noun on \`$branch\` $verb_done. Marking this draft ready for review was refused; mark it ready by hand." ;;
5600
+ esac
5601
+ deliver_comment "$FORGE_PR" "$tmp" "$readiness A review that requests changes starts another round."
5602
+ posted="#$FORGE_PR"
5603
+ if [ -n "$FORGE_ISSUE" ]; then
5604
+ if [ "$ready" = refused ]; then
5605
+ text="The harness $noun on \`$branch\` $verb_done. Its pull request #$FORGE_PR is still a draft — marking it ready for review was refused, so mark it ready by hand: $pr_url"
5606
+ elif [ "$noun" = round ]; then
5607
+ text="The harness round on \`$branch\` finished. Pull request #$FORGE_PR is ready for review again: $pr_url"
5608
+ else
5609
+ text="The harness run on \`$branch\` completed. Its pull request #$FORGE_PR is ready for your review: $pr_url"
5610
+ fi
5611
+ deliver_comment "$FORGE_ISSUE" "$tmp" "$text
5612
+ Review it there; a review that requests changes starts another round."
5613
+ posted="$posted and #$FORGE_ISSUE"
5614
+ fi
5615
+ elif [ -n "$FORGE_ISSUE" ]; then
5616
+ compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
4446
5617
  if [ "$forbidden" -eq 1 ]; then
4447
- text="The harness run on \`$branch\` completed, but its pull request could not be opened: GitHub Actions is not permitted to create pull requests in this repository. Turn on *$PR_CREATE_SETTING* under $PR_CREATE_SETTING_PATH, or set the \`HARNESS_GIT_TOKEN\` secret, for the next run. For this one, open the pull request from the branch: $compare"
5618
+ text="The harness $noun on \`$branch\` $verb_done, but its pull request could not be opened: GitHub Actions is not permitted to create pull requests in this repository. Turn on *$PR_CREATE_SETTING* under $PR_CREATE_SETTING_PATH, or set the \`HARNESS_GIT_TOKEN\` secret, for the next run. For this one, open the pull request from the branch: $compare"
4448
5619
  else
4449
- text="The harness run on \`$branch\` completed, but its pull request could not be opened: $create_err. Open it by hand from the branch: $compare"
5620
+ text="The harness $noun on \`$branch\` $verb_done, but its pull request could not be opened: $create_err. Open it by hand from the branch: $compare"
4450
5621
  fi
4451
- elif [ "$opened" -eq 0 ]; then
4452
- target="$FORGE_PR"
4453
- text="The harness round on \`$branch\` finished. Review this pull request; a review that requests changes starts another round."
4454
- elif [ -n "$FORGE_ISSUE" ]; then
4455
- target="$FORGE_ISSUE"
4456
- text="The harness run on \`$branch\` completed. Its pull request is ready for your review: $pr_url
4457
- Review it there; a review that requests changes starts another round."
5622
+ deliver_comment "$FORGE_ISSUE" "$tmp" "$text"
5623
+ posted="#$FORGE_ISSUE"
4458
5624
  else
4459
- target="$FORGE_PR"
4460
- text="The harness run on \`$branch\` completed and opened this pull request. Review it; a review that requests changes starts another round."
5625
+ echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4461
5626
  fi
4462
- if hr_phase_enabled "$root" qa; then
4463
- text="$text
5627
+ [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4464
5628
 
4465
- The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
4466
- fi
5629
+ [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
5630
+ [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
5631
+ echo "remote-run.sh: deliver: completed on $branch reported${posted:+ on $posted}"
5632
+ exit "$EXIT_OK"
5633
+ }
4467
5634
 
4468
- if [ -z "$target" ]; then
4469
- echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
4470
- elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
5635
+ # verb_open — at the run's start: open the branch's draft pull request when none
5636
+ # is open, set it `running`, and name it once on the source issue. Always exit 0.
5637
+ verb_open() {
5638
+ local tmp made_tmp="" file
5639
+ if ! forge_on; then
5640
+ echo "remote-run.sh: open: the forge coupling is off (forge github and execution.target github-actions); nothing opened"
5641
+ exit "$EXIT_OK"
5642
+ fi
5643
+ forge_repo_var || exit "$EXIT_OK"
5644
+ forge_fetch_branch "$branch"
5645
+ forge_issue_var "$branch" || FORGE_ISSUE=""
5646
+ if ! forge_pr_var "$branch"; then
5647
+ echo "::warning::remote-run.sh: open: whether $branch already has a pull request could not be read ($GH_ERR); none opened"
5648
+ exit "$EXIT_OK"
5649
+ fi
5650
+ if [ -n "$FORGE_PR" ]; then
5651
+ echo "remote-run.sh: open: $branch already has pull request #$FORGE_PR; none opened"
5652
+ exit "$EXIT_OK"
5653
+ fi
5654
+ if ! forge_open_pr "$branch"; then
5655
+ echo "::warning::remote-run.sh: open: the pull request of $branch could not be opened: $OPEN_ERR. The run goes on and its comments reach its issue; deliver tries again when the run completes"
5656
+ exit "$EXIT_OK"
5657
+ fi
5658
+ echo "remote-run.sh: open: opened pull request #$FORGE_PR for $branch"
5659
+ forge_set_state "$FORGE_PR" running || :
5660
+ if [ -z "$FORGE_ISSUE" ]; then
5661
+ echo "remote-run.sh: open: $branch was started from no issue; nothing posted"
5662
+ exit "$EXIT_OK"
5663
+ fi
5664
+ tmp="${RUNNER_TEMP-}"
5665
+ if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
5666
+ tmp=$(mktemp -d) || tmp=""
5667
+ made_tmp="$tmp"
5668
+ fi
5669
+ if [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-open-comment.XXXXXX"); then
4471
5670
  {
4472
- printf '%s\n' "$text"
5671
+ if [ "$FORGE_PR_DRAFT" = false ]; then
5672
+ printf 'The harness run on `%s` opened its pull request #%s, not as a draft — this repository'"'"'s plan may not offer drafts: %s\n' \
5673
+ "$branch" "$FORGE_PR" "$FORGE_PR_URL"
5674
+ else
5675
+ printf 'The harness run on `%s` opened its draft pull request #%s: %s\n' "$branch" "$FORGE_PR" "$FORGE_PR_URL"
5676
+ fi
5677
+ printf '\nThe run'"'"'s questions, lifecycle comments and progress are posted there from now on; `%s` commands keep working on this issue.\n' \
5678
+ "$COMMAND_HANDLE"
4473
5679
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4474
5680
  } >"$file"
4475
- forge_comment "$target" completed "$branch" "$file" || :
5681
+ forge_comment "$FORGE_ISSUE" opened "$branch" "$file" || :
4476
5682
  rm -f "$file"
4477
5683
  else
4478
- echo "remote-run.sh: deliver: cannot create the comment file for #$target; no comment posted" >&2
5684
+ echo "remote-run.sh: open: cannot create the comment file for #$FORGE_ISSUE; no comment posted" >&2
4479
5685
  fi
4480
5686
  [ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
4481
-
4482
- [ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
4483
- [ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
4484
- echo "remote-run.sh: deliver: completed on $branch reported${target:+ on #$target}"
5687
+ echo "remote-run.sh: open: pull request #$FORGE_PR named on #$FORGE_ISSUE"
4485
5688
  exit "$EXIT_OK"
4486
5689
  }
4487
5690
 
@@ -4508,10 +5711,21 @@ control_cleanup() {
4508
5711
  return 0
4509
5712
  }
4510
5713
 
4511
- # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment;
4512
- # 1, after an `::error::` line, when it cannot be posted.
5714
+ # The engine a dispatch child named, set only once that child exited 0; a
5715
+ # non-empty value adds ` engine=<engine>` to every later reply's marker.
5716
+ CONTROL_REPLY_ENGINE=""
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
+
5722
+ # control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment,
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.
4513
5726
  control_post() {
4514
5727
  local text="$1" file status=0
5728
+ [ -z "$CONTROL_MENTION_NOTE" ] || text="$CONTROL_MENTION_NOTE"$'\n\n'"$text"
4515
5729
  if ! forge_repo_var; then
4516
5730
  echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4517
5731
  return 1
@@ -4524,7 +5738,7 @@ control_post() {
4524
5738
  printf '%s\n' "$text"
4525
5739
  [ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
4526
5740
  } >"$file"
4527
- if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file"; then
5741
+ if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file" "" "$CONTROL_REPLY_ENGINE"; then
4528
5742
  echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
4529
5743
  status=1
4530
5744
  fi
@@ -4661,11 +5875,47 @@ control_stopped_refuse() {
4661
5875
  esac
4662
5876
  }
4663
5877
 
4664
- # control_check_branch <branch> — the refusals both paths share, in order: a
4665
- # branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
4666
- # whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
5878
+ # control_run_in_flight <branch> — 0 when a run titled exactly
5879
+ # `harness run <branch>` is queued, in_progress, waiting, requested or
5880
+ # pending; 1 when none is; 2 with GH_ERR set when the listing failed or was
5881
+ # not a JSON array. Passes <branch> explicitly rather than reading the global
5882
+ # `branch` list_runs reads, and leaves GH_OUT as it found it.
5883
+ control_run_in_flight() {
5884
+ local saved="$GH_OUT" verdict status=0
5885
+ if ! gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$1" \
5886
+ --json displayTitle,status --limit "$RUN_LIST_LIMIT"; then
5887
+ GH_OUT="$saved"
5888
+ return 2
5889
+ fi
5890
+ verdict=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $1" '
5891
+ if type != "array" then error("not an array") else
5892
+ if any(.[]; .displayTitle == $t
5893
+ and (.status == "queued" or .status == "in_progress" or .status == "waiting"
5894
+ or .status == "requested" or .status == "pending"))
5895
+ then "yes" else "no" end
5896
+ end' 2>/dev/null) || verdict=""
5897
+ GH_OUT="$saved"
5898
+ case "$verdict" in
5899
+ yes) status=0 ;;
5900
+ no) status=1 ;;
5901
+ *) GH_ERR="its run list is not the expected JSON"; status=2 ;;
5902
+ esac
5903
+ return "$status"
5904
+ }
5905
+
5906
+ # control_check_branch <branch> [started] — the refusals every path shares, in
5907
+ # order: a branch not answered 1 by hr_branch_is_protected, then (after a
5908
+ # fetch) one that is not a harness branch. A branch passes with its task
5909
+ # prompt or the flow-progress ledger on its origin tip (`forge_recognised`);
5910
+ # or, with `started` (passed only by
5911
+ # control_branch_from_issue, whose issue's genuine `started` marker names it),
5912
+ # while it still exists on origin — gone is refused as deleted, a failed
5913
+ # existence check names the read, and no run list is read; or with a
5914
+ # `harness run <branch>` run in flight. Otherwise the refusal reads "neither a
5915
+ # task prompt nor a ledger, and no `harness run` in flight", and a failed listing names the read. Each
5916
+ # refusal is a reply and exit 2, or 3 for a failed read.
4667
5917
  control_check_branch() {
4668
- local b="$1" protected=0
5918
+ local b="$1" protected=0 exists=0 inflight=0
4669
5919
  if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
4670
5920
  control_refuse "$EXIT_REFUSED" "\`$b\` is not a valid branch name" "Comment on the pull request of the run's branch instead."
4671
5921
  fi
@@ -4679,8 +5929,25 @@ control_check_branch() {
4679
5929
  esac
4680
5930
  forge_fetch_branch "$b"
4681
5931
  if ! forge_recognised "$b"; then
4682
- control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries no flow-progress ledger" \
4683
- "Only a branch a harness run works on can be commanded."
5932
+ if [ "${2-}" = started ]; then
5933
+ remote_branch_exists "$b" || exists=$?
5934
+ case "$exists" in
5935
+ 0) ;;
5936
+ 1) control_refuse "$EXIT_REFUSED" "\`$b\` no longer exists on origin, so its run cannot be resumed or commanded" \
5937
+ "Start a new run from a new issue or the Run workflow form." ;;
5938
+ *) control_refuse "$EXIT_GH" "whether \`$b\` still exists on origin could not be read ($REMOTE_BRANCH_ERR)" \
5939
+ "Comment again to retry." ;;
5940
+ esac
5941
+ else
5942
+ control_run_in_flight "$b" || inflight=$?
5943
+ case "$inflight" in
5944
+ 0) ;;
5945
+ 1) control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries neither a task prompt nor a flow-progress ledger, and no \`harness run $b\` run is queued or in progress" \
5946
+ "Only a branch a harness run works on can be commanded." ;;
5947
+ *) control_refuse "$EXIT_GH" "whether \`$b\` has a harness run in flight could not be read ($GH_ERR)" \
5948
+ "Comment again to retry." ;;
5949
+ esac
5950
+ fi
4684
5951
  fi
4685
5952
  CONTROL_BRANCH="$b"
4686
5953
  }
@@ -4756,7 +6023,8 @@ control_issue_branch_var() {
4756
6023
  }
4757
6024
 
4758
6025
  # control_branch_from_issue <number> — control_issue_branch_var, its failures
4759
- # refused, then control_check_branch's refusals.
6026
+ # refused, then control_check_branch with `started`: the verified marker makes
6027
+ # the branch a harness branch while it still exists on origin.
4760
6028
  control_branch_from_issue() {
4761
6029
  if ! control_issue_branch_var "$1"; then
4762
6030
  if [ -n "$ISSUE_BRANCH_ERR" ]; then
@@ -4765,7 +6033,7 @@ control_branch_from_issue() {
4765
6033
  control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
4766
6034
  "Comment on the pull request of the run's branch instead."
4767
6035
  fi
4768
- control_check_branch "$ISSUE_BRANCH"
6036
+ control_check_branch "$ISSUE_BRANCH" started
4769
6037
  }
4770
6038
 
4771
6039
  # control_verb_handled <verb> — 0 when an arm below carries out <verb>.
@@ -4800,6 +6068,7 @@ control_resume_dispatch() {
4800
6068
  *) control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again to retry." ;;
4801
6069
  esac
4802
6070
  status="$EXIT_OK"
6071
+ CONTROL_REPLY_ENGINE="$CS_ENGINE"
4803
6072
  control_post "$done_text" || status="$EXIT_GH"
4804
6073
  # The job posts its own `resumed` comment; the labels say `running` now.
4805
6074
  forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
@@ -5102,6 +6371,7 @@ control_answer() {
5102
6371
  control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" \
5103
6372
  "Comment \`$COMMAND_HANDLE answer $n\` again to retry." ;;
5104
6373
  esac
6374
+ CONTROL_REPLY_ENGINE="$CS_ENGINE"
5105
6375
 
5106
6376
  cmds=""
5107
6377
  for v in $CS_OPEN; do
@@ -5177,18 +6447,27 @@ RC_ERR=""
5177
6447
  # origin's tip consumed, from their marker lines: RC_SEEN_R and RC_SEEN_C (the
5178
6448
  # recorded review and comment ids, comma-wrapped), RC_MARKED_AT (the
5179
6449
  # highest-numbered marked round's `collected_at`, empty when none is marked),
5180
- # RC_EVENT_ROUND (the round recording <event_review_id>), and RC_STATE_REL.
5181
- # 4 with RC_ERR when a round cannot be listed or read. `control` also calls it
5182
- # on its own, to name the round a review in flight is already part of.
6450
+ # RC_MARKED_N and RC_MARKED_C (that round's number, 0 when none, and its
6451
+ # `comments=` ids as written), RC_NEWEST_N (the highest round number of any
6452
+ # round file, marked or not, 0 when none), RC_EVENT_ROUND (the round recording
6453
+ # <event_review_id>), and RC_STATE_REL. 4 with RC_ERR when a round cannot be
6454
+ # listed or read. `control` also calls it on its own, to name the round a
6455
+ # review in flight is already part of, and `deliver` to find a round's threads.
5183
6456
  RC_SEEN_R=","
5184
6457
  RC_SEEN_C=","
5185
6458
  RC_MARKED_AT=""
6459
+ RC_MARKED_N=0
6460
+ RC_MARKED_C=""
6461
+ RC_NEWEST_N=0
5186
6462
  RC_STATE_REL=""
5187
6463
  round_markers_read() {
5188
- local event_id="${1-}" names name path n line marked_max=0
6464
+ local event_id="${1-}" names name path n line
5189
6465
  RC_SEEN_R=","
5190
6466
  RC_SEEN_C=","
5191
6467
  RC_MARKED_AT=""
6468
+ RC_MARKED_N=0
6469
+ RC_MARKED_C=""
6470
+ RC_NEWEST_N=0
5192
6471
  RC_EVENT_ROUND=""
5193
6472
  RC_STATE_REL=$(hr_state_dir "$root" 2>/dev/null) || RC_STATE_REL=""
5194
6473
  RC_STATE_REL="${RC_STATE_REL%/}"
@@ -5206,6 +6485,7 @@ round_markers_read() {
5206
6485
  [ "${BASH_REMATCH[1]}" = "$branch" ] || continue
5207
6486
  n="${BASH_REMATCH[3]:-1}"
5208
6487
  n=$((10#$n))
6488
+ [ "$n" -le "$RC_NEWEST_N" ] || RC_NEWEST_N="$n"
5209
6489
  if ! line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null); then
5210
6490
  RC_ERR="the previous round \`$path\` could not be read"
5211
6491
  return 4
@@ -5215,9 +6495,10 @@ round_markers_read() {
5215
6495
  [[ "$line" =~ ^"$COMMENT_MARKER round collected_at="([0-9T:Z-]+)" reviews="([0-9,]*)" comments="([0-9,]*)" -->"$ ]] || continue
5216
6496
  RC_SEEN_R="$RC_SEEN_R${BASH_REMATCH[2]}${BASH_REMATCH[2]:+,}"
5217
6497
  RC_SEEN_C="$RC_SEEN_C${BASH_REMATCH[3]}${BASH_REMATCH[3]:+,}"
5218
- if [ "$n" -gt "$marked_max" ]; then
5219
- marked_max="$n"
6498
+ if [ "$n" -gt "$RC_MARKED_N" ]; then
6499
+ RC_MARKED_N="$n"
5220
6500
  RC_MARKED_AT="${BASH_REMATCH[1]}"
6501
+ RC_MARKED_C="${BASH_REMATCH[3]}"
5221
6502
  fi
5222
6503
  case ",${BASH_REMATCH[2]}," in
5223
6504
  *",$event_id,"*) [ -z "$event_id" ] || RC_EVENT_ROUND="$n" ;;
@@ -5337,10 +6618,9 @@ AUTHORS
5337
6618
  RC_REVIEWERS=$(printf '%s' "$kept" | jq -r '
5338
6619
  reduce (.reviews[] | .user.login) as $l ([]; if any(.[]; . == $l) then . else . + [$l] end) | join(",")')
5339
6620
 
5340
- # A hunk's fence is one backtick longer than its longest backtick run, at
5341
- # least three. The trailing `x` keeps the text's final newline through the
5342
- # substitution.
5343
- 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"'
5344
6624
  def nl: if endswith("\n") then . else . + "\n" end;
5345
6625
  .reviews as $rv | .comments as $cm
5346
6626
  | ($rv | map(
@@ -5358,8 +6638,7 @@ AUTHORS
5358
6638
  + (if ($cm | length) == 0 then "" else
5359
6639
  "\n## Inline comments\n" + ($cm | map(
5360
6640
  (.diff_hunk // "") as $h
5361
- | (([$h | match("`+"; "g") | .length] | max) // 0) as $m
5362
- | ("`" * ([$m + 1, 3] | max)) as $f
6641
+ | ($h | fence) as $f
5363
6642
  | "\n### `" + (.path // "") + "`"
5364
6643
  + (if .line != null then ", line \(.line)"
5365
6644
  elif .original_line != null then ", original line \(.original_line) (outdated)"
@@ -5391,12 +6670,16 @@ REVIEW_RETRY_WAY="The reviews stay on the pull request and are collected by the
5391
6670
  # control_settled_var — `branch_settled_var` for CONTROL_BRANCH, with no run
5392
6671
  # counted as settled, run in a command substitution so that its exit on a
5393
6672
  # failed read reaches this process as a status, not as an exit with no reply:
5394
- # BS_SETTLED, BS_STATE and BS_REASON; 1 with BS_ERR, its last stderr line, on a
5395
- # failed read.
6673
+ # BS_SETTLED, BS_STATE and BS_REASON, and RS_NOT_STARTED, RS_RUN_ID, RS_ENGINE
6674
+ # and RS_DETAIL as BS_NOT_STARTED, BS_RUN_ID, BS_ENGINE and BS_DETAIL; 1 with
6675
+ # BS_ERR, its last stderr line, on a failed read. BS_DETAIL is read last, so a
6676
+ # `|` inside it shifts no other field.
5396
6677
  BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
6678
+ BS_NOT_STARTED=0; BS_RUN_ID=""; BS_ENGINE=""; BS_DETAIL=""
5397
6679
  control_settled_var() {
5398
6680
  local dir errfile line status=0
5399
6681
  BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
6682
+ BS_NOT_STARTED=0; BS_RUN_ID=""; BS_ENGINE=""; BS_DETAIL=""
5400
6683
  if ! dir=$(mktemp -d "$control_tmp/harness-control-settled.XXXXXX"); then
5401
6684
  BS_ERR="a state directory could not be created under '$control_tmp'"
5402
6685
  return 1
@@ -5405,14 +6688,15 @@ control_settled_var() {
5405
6688
  control_dirs="$control_dirs $dir $errfile"
5406
6689
  branch="$CONTROL_BRANCH"
5407
6690
  line=$(branch_settled_var "$dir" 1 >/dev/null 2>"$errfile" \
5408
- && printf '%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON") || status=$?
6691
+ && printf '%s|%s|%s|%s|%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON" \
6692
+ "$RS_NOT_STARTED" "$RS_RUN_ID" "$RS_ENGINE" "$RS_DETAIL") || status=$?
5409
6693
  cat "$errfile" >&2 2>/dev/null || :
5410
6694
  if [ "$status" -ne 0 ] || [ -z "$line" ]; then
5411
6695
  BS_ERR=$(grep -v '^[[:space:]]*$' "$errfile" 2>/dev/null | tail -n 1)
5412
6696
  [ -n "$BS_ERR" ] || BS_ERR="exit $status, no message"
5413
6697
  return 1
5414
6698
  fi
5415
- IFS='|' read -r BS_SETTLED BS_STATE BS_REASON <<<"$line"
6699
+ IFS='|' read -r BS_SETTLED BS_STATE BS_REASON BS_NOT_STARTED BS_RUN_ID BS_ENGINE BS_DETAIL <<<"$line"
5416
6700
  return 0
5417
6701
  }
5418
6702
 
@@ -5569,11 +6853,13 @@ control_review_intake() {
5569
6853
  }
5570
6854
 
5571
6855
  # control_comment_intake — read an `issue_comment` event; returns 1 when it is
5572
- # 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.
5573
6858
  CONTROL_SENDER_TYPE=""
5574
6859
  CONTROL_IS_PR=""
6860
+ CONTROL_MENTION=0
5575
6861
  control_comment_intake() {
5576
- local action first word rest handle
6862
+ local action first word rest handle verb body re
5577
6863
  { event_field '.action // ""' && action="$EVENT_VALUE" \
5578
6864
  && event_field '.comment.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
5579
6865
  && event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
@@ -5600,15 +6886,27 @@ control_comment_intake() {
5600
6886
  rest=${first#"$word"}
5601
6887
  rest=${rest#"${rest%%[!$' \t']*}"}
5602
6888
  handle=$(printf '%s' "$COMMAND_HANDLE" | tr '[:upper:]' '[:lower:]')
5603
- if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" != "$handle" ]; then
5604
- echo "remote-run.sh: control: ignored, the first line does not open with $COMMAND_HANDLE"
5605
- return 1
6889
+ if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" = "$handle" ]; then
6890
+ word=${rest%%[$' \t']*}
6891
+ verb=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
6892
+ case " $COMMAND_VERBS " in
6893
+ *" $verb "*)
6894
+ if [ -n "$verb" ]; then
6895
+ CONTROL_VERB="$verb"
6896
+ CONTROL_ARGS=${rest#"$word"}
6897
+ CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
6898
+ return 0
6899
+ fi ;;
6900
+ esac
5606
6901
  fi
5607
- word=${rest%%[$' \t']*}
5608
- CONTROL_VERB=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
5609
- CONTROL_ARGS=${rest#"$word"}
5610
- CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
5611
- return 0
6902
+ body=$(printf '%s' "$CONTROL_BODY" | tr '[:upper:]' '[:lower:]')
6903
+ re="(^|[^a-z0-9])${handle}([^a-z0-9-]|\$)"
6904
+ if [[ $body =~ $re ]]; then
6905
+ CONTROL_MENTION=1
6906
+ return 0
6907
+ fi
6908
+ echo "remote-run.sh: control: ignored, the comment does not mention $COMMAND_HANDLE"
6909
+ return 1
5612
6910
  }
5613
6911
 
5614
6912
  # The close: CLOSE_KIND (`issue`, `pr_closed`, `pr_merged`, `deleted`) and
@@ -5749,16 +7047,17 @@ control_close() {
5749
7047
  CONTROL_BRANCH="$b"
5750
7048
  if [ "$CLOSE_KIND" = pr_closed ] || [ "$CLOSE_KIND" = pr_merged ]; then
5751
7049
  # GitHub closes a pull request whose head is deleted; the `delete` event's
5752
- # own job stops that run from the default branch, so this one stays quiet.
7050
+ # own job stops that run from the default branch and reports it on this
7051
+ # pull request, so this one stays quiet.
5753
7052
  remote_branch_exists "$b"
5754
7053
  case $? in
5755
- 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" ;;
5756
7055
  2) echo "remote-run.sh: control: whether \`$b\` exists on origin could not be checked ($REMOTE_BRANCH_ERR); proceeding" ;;
5757
7056
  esac
5758
7057
  fi
5759
7058
 
5760
7059
  # No forge_recognised check: a merged or deleted branch may no longer carry
5761
- # its ledger, and a listed `harness run <b>` run is what proves a harness run.
7060
+ # its task prompt or ledger, and a listed `harness run <b>` run is what proves a harness run.
5762
7061
  if ! control_state_var "$b"; then
5763
7062
  echo "::error::remote-run.sh: control: the state of the run on \`$b\` could not be read ($CS_ERR)"
5764
7063
  exit "$EXIT_GH"
@@ -5790,9 +7089,350 @@ control_close() {
5790
7089
  exit "$EXIT_OK"
5791
7090
  }
5792
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
+
5793
7426
  verb_control() {
5794
7427
  local LC_ALL=C
5795
- local review=0 close=0 forge="" target="" status verbs="" v
7428
+ local review=0 close=0 forge="" target="" status
7429
+ # Unset first so an inherited export of either name cannot keep it exported.
7430
+ unset MENTION_OAUTH MENTION_API MENTION_JOB_TOKEN_B64
7431
+ MENTION_OAUTH="${IN_OAUTH-}"
7432
+ MENTION_API="${IN_API-}"
7433
+ MENTION_JOB_TOKEN_B64=""
7434
+ [ -z "${GH_TOKEN-}" ] || MENTION_JOB_TOKEN_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
7435
+ unset IN_OAUTH IN_API CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY
5796
7436
  case "${GITHUB_EVENT_NAME-}" in
5797
7437
  issue_comment) ;;
5798
7438
  pull_request_review) review=1 ;;
@@ -5857,16 +7497,12 @@ verb_control() {
5857
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."
5858
7498
  fi
5859
7499
 
5860
- if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
5861
- for v in $COMMAND_VERBS; do
5862
- [ "$v" != answer ] || v="answer [<n>]"
5863
- verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
5864
- done
5865
- control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" \
5866
- "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)"
5867
7503
  fi
5868
7504
 
5869
- 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."
5870
7506
  if [ "$review" -eq 1 ]; then
5871
7507
  control_check_branch "$REVIEW_HEAD"
5872
7508
  elif [ -n "$CONTROL_IS_PR" ]; then
@@ -5875,7 +7511,16 @@ verb_control() {
5875
7511
  control_branch_from_issue "$CONTROL_NUMBER"
5876
7512
  fi
5877
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
5878
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() {
5879
7524
  case "$CONTROL_VERB" in
5880
7525
  answer) control_answer ;;
5881
7526
  pause) control_pause ;;
@@ -5910,7 +7555,7 @@ collect_notify() {
5910
7555
  }
5911
7556
 
5912
7557
  verb_collect() {
5913
- local status=0 dir file out
7558
+ local status=0 dir file out reason route way
5914
7559
  if ! forge_on; then
5915
7560
  echo "remote-run.sh: collect: the forge coupling is off (forge github and execution.target github-actions); nothing collected"
5916
7561
  exit "$EXIT_OK"
@@ -5935,17 +7580,7 @@ verb_collect() {
5935
7580
  exit "$EXIT_OK" ;;
5936
7581
  esac
5937
7582
 
5938
- if [ -n "$pr_arg" ]; then
5939
- FORGE_PR="$pr_arg"
5940
- elif ! forge_pr_var "$branch"; then
5941
- echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
5942
- exit "$EXIT_OK"
5943
- fi
5944
- if [ -z "$FORGE_PR" ]; then
5945
- echo "remote-run.sh: collect: no open pull request; nothing to collect"
5946
- exit "$EXIT_OK"
5947
- fi
5948
-
7583
+ # Read before the pull request is required: a first run has none.
5949
7584
  control_tmp="${RUNNER_TEMP-}"
5950
7585
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
5951
7586
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: collect: mktemp failed; nothing collected"; exit "$EXIT_OK"; }
@@ -5958,6 +7593,37 @@ verb_collect() {
5958
7593
  echo "remote-run.sh: collect: the state of the run on $branch could not be read ($BS_ERR); no round started"
5959
7594
  exit "$EXIT_OK"
5960
7595
  fi
7596
+ if [ "$BS_NOT_STARTED" = 1 ]; then
7597
+ if [ -n "${GITHUB_RUN_ID-}" ] && [ "$BS_RUN_ID" != "$GITHUB_RUN_ID" ]; then
7598
+ echo "remote-run.sh: collect: run $BS_RUN_ID of $branch never started; its own collect reports it"
7599
+ exit "$EXIT_OK"
7600
+ fi
7601
+ reason="${BS_DETAIL#"GitHub did not start the job of run $BS_RUN_ID ("}"
7602
+ if [ "$reason" = "$BS_DETAIL" ]; then reason=""; else reason="${reason%"): "*}"; fi
7603
+ REPORT_NOT_STARTED_STATE="$BS_STATE"
7604
+ REPORT_NOT_STARTED_ENGINE="$BS_ENGINE"
7605
+ route=$(hr_github_resume_route "$branch" "${BS_ENGINE:-<task, user_review or docs: the one the run was started with>}")
7606
+ if [ "$BS_STATE" = paused ]; then
7607
+ way="Run $RESUME_HINT $branch to start it again; $route."
7608
+ else
7609
+ way="Start it again ${route#or }."
7610
+ fi
7611
+ notify not_started "$branch" "$BS_DETAIL. $way" "$reason"
7612
+ # The reviews stay for the resumed run's own end.
7613
+ echo "remote-run.sh: collect: run ${BS_RUN_ID} of $branch never started; reported, and no round collected"
7614
+ exit "$EXIT_OK"
7615
+ fi
7616
+
7617
+ if [ -n "$pr_arg" ]; then
7618
+ FORGE_PR="$pr_arg"
7619
+ elif ! forge_pr_var "$branch"; then
7620
+ echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
7621
+ exit "$EXIT_OK"
7622
+ fi
7623
+ if [ -z "$FORGE_PR" ]; then
7624
+ echo "remote-run.sh: collect: no open pull request; nothing to collect"
7625
+ exit "$EXIT_OK"
7626
+ fi
5961
7627
  if [ "$BS_SETTLED" != 1 ]; then
5962
7628
  echo "remote-run.sh: collect: $branch is ${BS_STATE:-in flight}${BS_REASON:+ ($BS_REASON)}; that run's own end collects"
5963
7629
  exit "$EXIT_OK"
@@ -5994,7 +7660,11 @@ verb_collect() {
5994
7660
  echo "remote-run.sh: collect: started the next round of $branch from @${RC_REVIEWERS//,/, @}"
5995
7661
  exit "$EXIT_OK"
5996
7662
  fi
5997
- collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request; submit a review requesting changes to retry."
7663
+ if [ -n "${GITHUB_RUN_ID-}" ]; then
7664
+ collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request. To retry, re-run this run's \`collect\` job (no new review is needed), or submit a review requesting changes."
7665
+ else
7666
+ collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request; submit a review requesting changes to retry."
7667
+ fi
5998
7668
  echo "remote-run.sh: collect: review exited $CHILD_STATUS; the pull request was told, and nothing is retried"
5999
7669
  exit "$EXIT_OK"
6000
7670
  }
@@ -6078,6 +7748,7 @@ case "$verb" in
6078
7748
  list) verb_list ;;
6079
7749
  discard) verb_discard ;;
6080
7750
  report) verb_report ;;
7751
+ open) verb_open ;;
6081
7752
  deliver) verb_deliver ;;
6082
7753
  collect) verb_collect ;;
6083
7754
  control) verb_control ;;