autonomous-sdlc-harness 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,21 @@
9
9
  # bootstrap it, then PUSH the branch so the remote has it from
10
10
  # the first moment. This is what the watcher calls when a prompt
11
11
  # arrives for a branch that does not exist yet.
12
+ # --no-bootstrap Skip the bootstrap. Alone, a variant of default mode: cut
13
+ # and push the new branch the same way. With `--existing`, check
14
+ # out the existing branch and never push. Its callers are
15
+ # `remote-run.sh start` (a new branch) and `remote-run.sh review`
16
+ # (an existing one), each of which only places and commits one
17
+ # file: such a copy runs nothing, so a dependency install would
18
+ # cost minutes and could fail the placement for a reason
19
+ # unrelated to the task. It installs no worktree-scoped pre-push
20
+ # backstop — the push a caller makes is of a branch it has
21
+ # already judged not protected, and every later commit goes
22
+ # through `commit-on-branch.sh` and `push-branch.sh`, which refuse
23
+ # a protected branch themselves. An existing branch's copy that a
24
+ # person or a local run works in still wants the bootstrap, so
25
+ # `--existing` alone keeps it; `--no-bootstrap` is for a copy that
26
+ # only places and commits one file.
12
27
  # --existing Check out an EXISTING branch — local, or DWIM-created from
13
28
  # `origin/<branch>` when only the remote ref exists — bootstrap
14
29
  # it, and NEVER push. This is what the watcher calls to recreate
@@ -33,7 +48,8 @@
33
48
  # probe stops doing its job. This one is a short, strictly ordered sequence in
34
49
  # which every step is a precondition of the next: bootstrapping a half-created
35
50
  # worktree, or pushing a branch whose dependency install failed, is worse than
36
- # stopping. So a failing step aborts the script with that step's own status —
51
+ # stopping (a `--no-bootstrap` cut simply has no bootstrap step in its
52
+ # sequence). So a failing step aborts the script with that step's own status —
37
53
  # and the worktree it had already created is LEFT IN PLACE for inspection,
38
54
  # because removing a working copy is a decision this script does not get to
39
55
  # make silently.
@@ -62,20 +78,25 @@
62
78
  # is neither a descriptor duplication (`2>&1`, `>&2`, `2>&-`) nor a redirection
63
79
  # to the literal `/dev/null`.
64
80
  #
65
- # Usage: create-worktree.sh [--existing] <branch-name> [worktree-dir]
81
+ # Usage: create-worktree.sh [--existing] [--no-bootstrap] <branch-name> [worktree-dir]
66
82
  # --existing check out an existing branch instead of creating a new one
83
+ # --no-bootstrap skip the bootstrap: alone, create and push a new branch; with
84
+ # --existing, check out the existing branch and never push
67
85
  # <branch-name> the branch to run on
68
86
  # [worktree-dir] optional override; relative paths resolve against $PWD
69
87
  #
70
88
  # Exit map a caller can switch on:
71
89
  #
72
- # 0 the worktree is ready (and, in default mode, the branch was pushed)
90
+ # 0 the worktree is ready (and, in default mode or under `--no-bootstrap`
91
+ # without `--existing`, the branch was pushed)
73
92
  # 1 usage error / the library or the configuration could not be read
74
93
  # 2 refusal — nothing was created: `--existing` and the branch is nowhere;
75
94
  # the branch is already checked out in another working copy (which is
76
95
  # named); or default mode with no `origin` remote, or no
77
96
  # `origin/<default branch>` to branch from
78
- # 4 the working copy was created but could not be bootstrapped — it has no
97
+ # 4 the working copy was created but could not be bootstrapped (never under
98
+ # `--no-bootstrap`, with or without `--existing`, which resolves no
99
+ # bootstrap) — it has no
79
100
  # dependency install and no pre-push backstop, is LEFT IN PLACE for
80
101
  # inspection, and in default mode the branch was NOT pushed, because this
81
102
  # exit precedes the push step. In default mode the local
@@ -104,6 +125,13 @@
104
125
  # new branch bash "$d/scripts/create-worktree.sh" feat/x
105
126
  # -> "$w/demo-feat-x" on feat/x, bootstrapped, and `git -C "$b"
106
127
  # branch` now lists feat/x
128
+ # no-bootstrap bash "$d/scripts/create-worktree.sh" --no-bootstrap feat/q
129
+ # -> "$w/demo-feat-q" on feat/q, no deps.marker, and
130
+ # `git -C "$b" branch` lists feat/q
131
+ # existing, no bootstrap git -C "$d" worktree remove "$w/demo-feat-q"
132
+ # git -C "$d" branch -D feat/q
133
+ # bash "$d/scripts/create-worktree.sh" --existing --no-bootstrap feat/q
134
+ # -> "$w/demo-feat-q" on feat/q, no deps.marker, "$b" UNCHANGED
107
135
  # already there bash "$d/scripts/create-worktree.sh" --existing feat/x
108
136
  # -> exit 2 naming "$w/demo-feat-x"; nothing created
109
137
  # recreate git -C "$d" worktree remove "$w/demo-feat-x"
@@ -150,15 +178,18 @@ fi
150
178
  . "$hr_lib"
151
179
 
152
180
  usage() {
153
- echo " usage: create-worktree.sh [--existing] <branch-name> [worktree-dir]" >&2
181
+ echo " usage: create-worktree.sh [--existing] [--no-bootstrap] <branch-name> [worktree-dir]" >&2
154
182
  }
155
183
 
156
184
  existing=0
157
- if [ "${1:-}" = "--existing" ]; then
158
- existing=1
159
- shift
160
- fi
161
-
185
+ no_bootstrap=0
186
+ while [ "$#" -gt 0 ]; do
187
+ case "$1" in
188
+ --existing) existing=1; shift ;;
189
+ --no-bootstrap) no_bootstrap=1; shift ;;
190
+ *) break ;;
191
+ esac
192
+ done
162
193
  if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
163
194
  echo "create-worktree.sh: no branch name given" >&2
164
195
  usage
@@ -325,6 +356,17 @@ fi
325
356
  # checkout's own copy is not run instead — setup-worktree.sh anchors on its own
326
357
  # location and takes no arguments, so running it would bootstrap THIS checkout
327
358
  # rather than the new one.
359
+ if [ "$no_bootstrap" -eq 1 ]; then
360
+ if [ "$existing" -eq 1 ]; then
361
+ echo "create-worktree.sh: worktree ready at $worktree_dir (not bootstrapped)"
362
+ echo "create-worktree.sh: branch $branch (existing branch; not pushed)"
363
+ else
364
+ git -C "$worktree_dir" push -u origin "$branch"
365
+ echo "create-worktree.sh: worktree ready at $worktree_dir (not bootstrapped)"
366
+ echo "create-worktree.sh: branch $branch (pushed to origin)"
367
+ fi
368
+ exit 0
369
+ fi
328
370
  scripts_rel="$(hr_scripts_dir "$worktree_dir")" || scripts_rel=""
329
371
  if [ -z "$scripts_rel" ]; then
330
372
  scripts_rel="$script_dir_rel"
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env bash
2
2
  # harness-run-lib.sh — the one place every generated outer-loop script resolves
3
3
  # the repository it is operating on, reads that repository's
4
- # `harness.config.json` at run time, answers "is this branch protected?", and
5
- # derives the anchors (main checkout, work root, worktree directory, repo slug,
4
+ # `harness.config.json` at run time, answers "is this branch protected?",
5
+ # routes an inbox filename to its engine and branch (`hr_inbox_route_var`),
6
+ # derives a branch name from a title (`hr_derive_branch`), places a dropped
7
+ # artifact in a working copy and commits and pushes it, and derives the
8
+ # anchors (main checkout, work root, worktree directory, repo slug,
6
9
  # state-dir paths) the scripts would otherwise each re-derive slightly
7
10
  # differently. It also implements the run registry's reads and writes for the
8
11
  # scripts that share that registry, and states the remote state bundle's format
9
- # with the one writer and restorer every remote-execution consumer shares.
12
+ # with the one writer and restorer every remote-execution consumer shares, and
13
+ # the GitHub route a job-side notification names beside its local command.
10
14
  #
11
15
  # WHO SOURCES THIS, AND HOW. Every script in the configured `scriptsDir` that
12
16
  # needs this library sources it by a path computed from `${BASH_SOURCE[0]}` —
@@ -62,8 +66,9 @@
62
66
  # `.stale.*` move-aside and `.break` mutex while a stale one is broken)
63
67
  # and the temp files
64
68
  # `.registry.*` in the registry's own directory. Written only by
65
- # `hr_registry_init`, `hr_registry_set` and the `hr_registry_lock` /
66
- # `hr_registry_unlock` pair `hr_registry_set` calls.
69
+ # `hr_registry_init`, `hr_registry_set`, `hr_remote_record_init` (through
70
+ # `hr_registry_set`) and the `hr_registry_lock` / `hr_registry_unlock`
71
+ # pair `hr_registry_set` calls.
67
72
  # 3. THE REMOTE STATE BUNDLE writes the files its format lists. Fence: inside
68
73
  # `<root>/<state_dir>/` (resolved through `hr_state_dir`), only
69
74
  # `autonomous_logs/remote_status.json`, `clarifications/<branch>/`,
@@ -75,10 +80,21 @@
75
80
  # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
76
81
  # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
77
82
  # there but a writer's own failed temp file is ever removed.
83
+ # 4. THE ARTIFACT PLACEMENT writes one artifact into a working copy. Fence:
84
+ # the caller-named `<worktree>/<rel>`, its parent directories and that
85
+ # path's index entry, plus whatever the two caller-named wrappers do.
86
+ # Written only by `hr_place_artifact`, `hr_commit_placed` and
87
+ # `hr_push_landed`.
88
+ #
89
+ # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
90
+ # rename there is an edit here, byte for byte:
91
+ # HR_REMOTE_WORKFLOW_RUN_FILE mirrors WORKFLOW_RUN_FILE
92
+ # HR_REMOTE_STATE_ARTIFACT mirrors STATE_ARTIFACT_NAME
78
93
  #
79
94
  # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
80
- # `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
81
- # `hr_remote_bundle_write` or `hr_remote_bundle_restore` function still gets a library that only reads. The
95
+ # `hr_remote_record_init`, `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
96
+ # `hr_remote_bundle_write`, `hr_remote_bundle_restore`, `hr_place_artifact`,
97
+ # `hr_commit_placed` or `hr_push_landed` function still gets a library that only reads. The
82
98
  # lane's ceilings are the only environment values here that carry policy, because
83
99
  # the lane is machine-scoped and has no configuration key to carry them; each is
84
100
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
@@ -173,7 +189,10 @@
173
189
  # `mktemp`'s and `jq`'s own stderr on a failed write to the caller, as the
174
190
  # watcher's bodies they replaced did — that stream is the watcher's log — and
175
191
  # add one line of their own, naming the lock, when `hr_registry_set` cannot
176
- # take the registry lock. That silence is why the lane reports a lock it BROKE through a
192
+ # take the registry lock. The artifact placement's three writers likewise leave
193
+ # `mkdir`'s, `cp`'s, `git add`'s and both wrappers' own output on their stdout
194
+ # and stderr for the caller to redirect into its log.
195
+ # That silence is why the lane reports a lock it BROKE through a
177
196
  # variable instead of a log line — the caller owns the log.
178
197
  #
179
198
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -181,7 +200,10 @@
181
200
  # value WITHOUT a command substitution — a `$(…)` forks a subshell, and the
182
201
  # watcher calls these on every tick: `HR_CFG_PID`, `HR_CFG_ROOT`,
183
202
  # `HR_CFG_STATE`, `HR_CFG_FILE`, `HR_CFG_SCALARS`, `HR_CFG_LISTS`,
184
- # `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`, and the lane's
203
+ # `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`,
204
+ # `HR_INBOX_KIND`, `HR_INBOX_BRANCH`, the branch derivation's
205
+ # `HR_BRANCH_SLUG_MAX`, `HR_BRANCH_SUFFIX_MAX`, `HR_TAKEN_REMOTE`,
206
+ # `HR_TAKEN_ARTIFACTS` and `HR_TAKEN_WHY`, and the lane's
185
207
  # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
186
208
  # `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
187
209
  # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
@@ -609,6 +631,7 @@ hr_config_load() {
609
631
  s("phases.qa"; try (.phases.qa | if type == "boolean" or . == null then . else "invalid" end) catch null),
610
632
  s("phases.docs"; try (.phases.docs | if type == "boolean" or . == null then . else "invalid" end) catch null),
611
633
  s("execution.target"; try .execution.target catch null),
634
+ s("forge"; try .forge catch null),
612
635
  s("protectedBranches.present";
613
636
  try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
614
637
  l("protectedBranches"; try .protectedBranches catch null)
@@ -875,6 +898,68 @@ hr_execution_target() {
875
898
  return 2
876
899
  }
877
900
 
901
+ # `forge` — which code-hosting platform the flow integrates with: `github`,
902
+ # `gitlab` or `none`. THE ONE READER OF THE KEY IN THIS FAMILY, and the shell
903
+ # mirror of `cli/src/config/model.ts` → `FORGE_KINDS`: change the enum there and
904
+ # here together. 1 — printing nothing — when the key is absent: "not yet
905
+ # decided", which is not `none`, and the schema withholds a default on purpose.
906
+ # 2 — printing nothing — when the configuration is unresolvable or the value is
907
+ # outside the enum, a refusal rather than a guess.
908
+ hr_forge() {
909
+ local root="${1-}"
910
+ hr_config_load "$root" || return 2
911
+ hr_cfg_scalar_var "forge" || return 1
912
+ case "$HR_CFG_VALUE" in
913
+ github|gitlab|none)
914
+ printf '%s\n' "$HR_CFG_VALUE"
915
+ return 0
916
+ ;;
917
+ esac
918
+ return 2
919
+ }
920
+
921
+ # ---------------------------------------------------------------------------
922
+ # Inbox routing — the one owner of the drop filename patterns.
923
+ # ---------------------------------------------------------------------------
924
+
925
+ # Route one inbox filename: set `HR_INBOX_KIND` (`task` | `user_review` |
926
+ # `docs`) and `HR_INBOX_BRANCH`, and return 0; on no match return 1 with both
927
+ # empty. Takes a basename, not a path.
928
+ #
929
+ # The task-prompt pattern is tested FIRST (the more specific suffix), but the
930
+ # anchored SUFFIX regexes are mutually exclusive by construction: a filename
931
+ # cannot end in more than one of `_task_prompt.md` / `_review[_<n>].md` /
932
+ # `_docs.md`, so a branch whose own name contains `review` or `task_prompt`
933
+ # cannot be mis-routed — `foo_review_task_prompt.md` is the task engine on branch
934
+ # `foo_review`, and `foo_task_prompt_review.md` is the review engine on branch
935
+ # `foo_task_prompt`. POSIX leftmost-longest matching of the greedy `(.+)` derives
936
+ # the right branch from a round-suffixed name: `foo_review_2.md` -> branch `foo`
937
+ # (the `_2` is consumed by the optional `(_[0-9]+)?`), while
938
+ # `foo_review_2_review.md` -> branch `foo_review_2`. THE WATCHER DERIVES ONLY THE
939
+ # BRANCH, never the round: the engine resolves the latest round itself, inside
940
+ # the working copy, which is why nothing here has to remember one.
941
+ hr_inbox_route_var() {
942
+ local fname="${1-}" branch
943
+ HR_INBOX_KIND=""
944
+ HR_INBOX_BRANCH=""
945
+ [ -n "$fname" ] || return 1
946
+ branch="$(printf '%s' "$fname" | sed -nE 's/^(.+)_task_prompt\.md$/\1/p')"
947
+ if [ -n "$branch" ]; then
948
+ HR_INBOX_KIND="task"
949
+ else
950
+ branch="$(printf '%s' "$fname" | sed -nE 's/^(.+)_review(_[0-9]+)?\.md$/\1/p')"
951
+ if [ -n "$branch" ]; then
952
+ HR_INBOX_KIND="user_review"
953
+ else
954
+ branch="$(printf '%s' "$fname" | sed -nE 's/^(.+)_docs\.md$/\1/p')"
955
+ [ -n "$branch" ] || return 1
956
+ HR_INBOX_KIND="docs"
957
+ fi
958
+ fi
959
+ HR_INBOX_BRANCH="$branch"
960
+ return 0
961
+ }
962
+
878
963
  # ---------------------------------------------------------------------------
879
964
  # The protected-branch trichotomy.
880
965
  # ---------------------------------------------------------------------------
@@ -1319,12 +1404,308 @@ hr_registry_branches() {
1319
1404
  jq -r '.runs | keys[]' "$file" 2>/dev/null
1320
1405
  }
1321
1406
 
1407
+ # hr_remote_record_init <file> <branch> <worktree> <log_path> <engine>
1408
+ # The fields a remote run's record starts with — the one list, written by the
1409
+ # watcher's `launch_remote_run` — in one write, so no reader sees half of them. `status` is left to the caller, which writes it only
1410
+ # once its run exists. 1 when the write failed.
1411
+ hr_remote_record_init() {
1412
+ local file="${1-}" branch="${2-}"
1413
+ hr_registry_set "$file" "$branch" \
1414
+ worktree "${3-}" log_path "${4-}" engine "${5-}" \
1415
+ execution github-actions \
1416
+ started_at "$(date '+%Y-%m-%dT%H:%M:%S')" \
1417
+ pid "" remote_dispatched_at "" \
1418
+ stall_restarts 0 stall_warned "" stall_killing "" \
1419
+ paused_by "" usage_resume_at "" \
1420
+ resume_kind "" park_loop_cycles 0
1421
+ }
1422
+
1423
+ # ---------------------------------------------------------------------------
1424
+ # DERIVING A BRANCH NAME FROM A TITLE.
1425
+ #
1426
+ # THE RULE. A title becomes a branch name by a fixed fold, with no model and no
1427
+ # confirmation step: lowercase A–Z, turn every run of characters outside
1428
+ # `[a-z0-9]` into one `_`, trim `_` from both ends, cut to `HR_BRANCH_SLUG_MAX`
1429
+ # and trim a trailing `_` the cut exposed. An empty result takes the caller's
1430
+ # <fallback> (`issue_<number>` for an issue, `task_<run id>` for a dispatch). A
1431
+ # taken name takes the lowest free `<name>_<n>`, `_2` through
1432
+ # `HR_BRANCH_SUFFIX_MAX`; the first branch carries no suffix (`Version bump` →
1433
+ # `version_bump`), and `_2` reads as "the second".
1434
+ #
1435
+ # - The fold is ASCII-only under `LC_ALL=C`, so `é` is a separator, never a
1436
+ # letter — `hr_repo_slug`'s precedent. A locale-dependent fold would derive
1437
+ # different names on different runners; a lowercase ASCII name passes every
1438
+ # `git check-ref-format` rule and cannot collide by case on macOS or Windows.
1439
+ # - The cap is 60, cut before the suffix. The name becomes a working-copy
1440
+ # directory component (`<projectName>-<branch>`) and prefixes artifact names
1441
+ # (`<branch>_task_prompt.md`); 60 keeps each far under a 255-byte file-name
1442
+ # limit and readable in the Actions run list. GitHub documents no ref limit.
1443
+ #
1444
+ # THIS SECTION ONLY READS. It fetches nothing and creates no branch or file; a
1445
+ # caller that wants `origin` fresh fetches first. A registry is read only when
1446
+ # it already exists, because `hr_registry_get` creates an absent one.
1447
+ # ---------------------------------------------------------------------------
1448
+
1449
+ # The two limits the rule above names.
1450
+ hr_branch_limits_var() {
1451
+ HR_BRANCH_SLUG_MAX=60
1452
+ HR_BRANCH_SUFFIX_MAX=99
1453
+ }
1454
+
1455
+ # hr_branch_slug <text> — print the slug and return 0; print nothing and return
1456
+ # 1 when the fold leaves nothing (`🚀🚀`).
1457
+ hr_branch_slug() {
1458
+ # `[!a-z0-9]` is a collation range: under a UTF-8 locale it would keep `é`.
1459
+ local LC_ALL=C
1460
+ local text="${1-}" slug
1461
+ hr_branch_limits_var
1462
+ slug=$(printf '%s' "$text" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
1463
+ slug=${slug//[!a-z0-9]/_}
1464
+ while :; do
1465
+ case "$slug" in
1466
+ *__*) slug=${slug//__/_} ;;
1467
+ *) break ;;
1468
+ esac
1469
+ done
1470
+ slug=${slug#_}
1471
+ slug=${slug%_}
1472
+ if [ "${#slug}" -gt "$HR_BRANCH_SLUG_MAX" ]; then
1473
+ slug=${slug:0:$HR_BRANCH_SLUG_MAX}
1474
+ slug=${slug%_}
1475
+ fi
1476
+ [ -n "$slug" ] || return 1
1477
+ printf '%s\n' "$slug"
1478
+ }
1479
+
1480
+ # Read, once, the two listings every `taken` judgement compares against:
1481
+ # `HR_TAKEN_REMOTE` — each branch on `origin`, lowercased — and
1482
+ # `HR_TAKEN_ARTIFACTS` — the basename of every file and directory under
1483
+ # `<state_dir>` on `origin/<defaultBranch>`; one per line in both. 0 when both
1484
+ # were read; 2, with `HR_TAKEN_WHY` naming which, when either could not be.
1485
+ hr_branch_taken_lists_var() {
1486
+ local LC_ALL=C
1487
+ local root="${1-}" heads state default tree line tab
1488
+ tab=$(printf '\t')
1489
+ HR_TAKEN_REMOTE=""
1490
+ HR_TAKEN_ARTIFACTS=""
1491
+ HR_TAKEN_WHY=""
1492
+ if ! heads=$(git -C "$root" ls-remote --heads origin 2>/dev/null); then
1493
+ HR_TAKEN_WHY="the branches on origin could not be listed"
1494
+ return 2
1495
+ fi
1496
+ while IFS= read -r line; do
1497
+ line=${line#*"$tab"refs/heads/}
1498
+ [ -n "$line" ] || continue
1499
+ HR_TAKEN_REMOTE="$HR_TAKEN_REMOTE$line
1500
+ "
1501
+ done <<EOF
1502
+ $heads
1503
+ EOF
1504
+ HR_TAKEN_REMOTE=$(printf '%s' "$HR_TAKEN_REMOTE" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
1505
+
1506
+ if ! state=$(hr_state_dir "$root") || ! default=$(hr_default_branch "$root"); then
1507
+ HR_TAKEN_WHY="the configuration could not be read"
1508
+ return 2
1509
+ fi
1510
+ if ! git -C "$root" rev-parse --verify --quiet "refs/remotes/origin/$default^{commit}" >/dev/null 2>&1; then
1511
+ HR_TAKEN_WHY="origin/$default is not present"
1512
+ return 2
1513
+ fi
1514
+ if ! tree=$(git -C "$root" ls-tree -r -t --name-only "refs/remotes/origin/$default" -- "$state/" 2>/dev/null); then
1515
+ HR_TAKEN_WHY="the tree of origin/$default could not be read"
1516
+ return 2
1517
+ fi
1518
+ while IFS= read -r line; do
1519
+ [ -n "$line" ] || continue
1520
+ HR_TAKEN_ARTIFACTS="$HR_TAKEN_ARTIFACTS${line##*/}
1521
+ "
1522
+ done <<EOF
1523
+ $tree
1524
+ EOF
1525
+ return 0
1526
+ }
1527
+
1528
+ # Judge <name> against the listings `hr_branch_taken_lists_var` last read. The
1529
+ # answers and `HR_TAKEN_WHY` are `hr_branch_name_taken`'s.
1530
+ hr_branch_taken_judge() {
1531
+ local LC_ALL=C
1532
+ local root="${1-}" name="${2-}" registry="${3-}" lower status nl
1533
+ nl='
1534
+ '
1535
+ HR_TAKEN_WHY=""
1536
+ status=0
1537
+ hr_branch_is_protected "$root" "$name" || status=$?
1538
+ case "$status" in
1539
+ 0) HR_TAKEN_WHY="a protected branch"; return 0 ;;
1540
+ 2) HR_TAKEN_WHY="the protected branches could not be resolved"; return 2 ;;
1541
+ esac
1542
+ lower=$(printf '%s' "$name" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
1543
+ case "$nl$HR_TAKEN_REMOTE$nl" in
1544
+ *"$nl$lower$nl"*) HR_TAKEN_WHY="a branch on origin"; return 0 ;;
1545
+ esac
1546
+ if git -C "$root" show-ref --verify --quiet "refs/heads/$name" 2>/dev/null; then
1547
+ HR_TAKEN_WHY="a local branch"
1548
+ return 0
1549
+ fi
1550
+ case "$nl$HR_TAKEN_ARTIFACTS" in
1551
+ *"$nl$name$nl"* | *"$nl${name}_task_prompt.md$nl"* | *"$nl${name}_story_plan.md$nl"* | *"$nl${name}_docs.md$nl"*)
1552
+ HR_TAKEN_WHY="a run's artifacts on the default branch"
1553
+ return 0
1554
+ ;;
1555
+ esac
1556
+ if [ -n "$registry" ] && [ -f "$registry" ] && [ -n "$(hr_registry_get "$registry" "$name" branch)" ]; then
1557
+ HR_TAKEN_WHY="a run registry record"
1558
+ return 0
1559
+ fi
1560
+ return 1
1561
+ }
1562
+
1563
+ # hr_branch_name_taken <root> <name> [<registry>] — 0 taken, 1 free, 2 cannot
1564
+ # tell. Sets `HR_TAKEN_WHY` to a short phrase naming the collision or the
1565
+ # failure. Taken: a protected name; a branch on `origin`, compared
1566
+ # case-insensitively; a local branch; under `<state_dir>` on
1567
+ # `origin/<defaultBranch>`, a directory named <name> or a file
1568
+ # `<name>_task_prompt.md`, `<name>_story_plan.md` or `<name>_docs.md` — which a
1569
+ # merged and deleted branch still leaves; a record in an existing <registry>.
1570
+ hr_branch_name_taken() {
1571
+ local root="${1-}" name="${2-}" registry="${3-}"
1572
+ HR_TAKEN_WHY=""
1573
+ if [ -z "$root" ] || [ -z "$name" ]; then
1574
+ HR_TAKEN_WHY="no branch name to judge"
1575
+ return 2
1576
+ fi
1577
+ hr_config_load "$root" || :
1578
+ hr_branch_taken_lists_var "$root" || return 2
1579
+ hr_branch_taken_judge "$root" "$name" "$registry"
1580
+ }
1581
+
1582
+ # hr_derive_branch <root> <text> <fallback> [<registry>] — print the derived name
1583
+ # and return 0; return 2, printing nothing, when a `taken` judgement could not
1584
+ # tell or no base routes back to itself; return 3, printing nothing, when every
1585
+ # suffix through `HR_BRANCH_SUFFIX_MAX` is taken. Never a guessed name.
1586
+ #
1587
+ # THE BASE MUST ROUTE BACK TO ITSELF: `hr_inbox_route_var` on each drop filename
1588
+ # a run of that name produces has to give the base as its branch, so no derived
1589
+ # name makes the inbox patterns ambiguous. A base that does not is replaced by
1590
+ # <fallback> once.
1591
+ hr_derive_branch() {
1592
+ local root="${1-}" text="${2-}" fallback="${3-}" registry="${4-}"
1593
+ local base="" candidate suffix routes n status
1594
+ hr_branch_limits_var
1595
+ candidate=$(hr_branch_slug "$text") || candidate="$fallback"
1596
+ for candidate in "$candidate" "$fallback"; do
1597
+ [ -n "$candidate" ] || continue
1598
+ routes=0
1599
+ for suffix in _task_prompt.md _review.md _review_2.md _docs.md; do
1600
+ if ! hr_inbox_route_var "$candidate$suffix" || [ "$HR_INBOX_BRANCH" != "$candidate" ]; then
1601
+ routes=1
1602
+ break
1603
+ fi
1604
+ done
1605
+ if [ "$routes" -eq 0 ]; then
1606
+ base="$candidate"
1607
+ break
1608
+ fi
1609
+ done
1610
+ [ -n "$base" ] || return 2
1611
+
1612
+ hr_config_load "$root" || :
1613
+ hr_branch_taken_lists_var "$root" || return 2
1614
+ candidate="$base"
1615
+ n=1
1616
+ while :; do
1617
+ status=0
1618
+ hr_branch_taken_judge "$root" "$candidate" "$registry" || status=$?
1619
+ case "$status" in
1620
+ 1) printf '%s\n' "$candidate"; return 0 ;;
1621
+ 2) return 2 ;;
1622
+ esac
1623
+ n=$((n + 1))
1624
+ [ "$n" -le "$HR_BRANCH_SUFFIX_MAX" ] || return 3
1625
+ candidate="${base}_$n"
1626
+ done
1627
+ }
1628
+
1629
+ # ---------------------------------------------------------------------------
1630
+ # THE ARTIFACT PLACEMENT.
1631
+ #
1632
+ # THE CONTRACT. The one placement the watcher's inbox pass and a job starting a
1633
+ # run both perform: copy a dropped artifact into a working copy, stage exactly
1634
+ # that path, skip an identical re-drop, commit through the caller-named commit
1635
+ # wrapper, push through the caller-named push wrapper, and read "landed" as
1636
+ # `origin/<branch>` equal to `HEAD`. Every step reports by exit status only;
1637
+ # what a failure means — log and launch anyway, or block the dispatch — is the
1638
+ # caller's decision. The wrapper paths are arguments because this library
1639
+ # resolves no sibling script. Write exception 4 in the header is this section's.
1640
+ # ---------------------------------------------------------------------------
1641
+
1642
+ # hr_task_prompt_rel <state_rel> <branch> — print the task prompt's
1643
+ # repo-relative path, with <state_rel>'s trailing `/` dropped.
1644
+ hr_task_prompt_rel() {
1645
+ local state_rel="${1-}" branch="${2-}"
1646
+ printf '%s/task_prompts/%s_task_prompt.md\n' "${state_rel%/}" "$branch"
1647
+ }
1648
+
1649
+ # hr_task_prompt_subject <branch> — print the task prompt's commit subject. The
1650
+ # one producer of it; `.claude/context/conventions.md` → `## Commit-message
1651
+ # policy` lists it byte for byte.
1652
+ hr_task_prompt_subject() {
1653
+ printf 'chore: add task prompt for %s\n' "${1-}"
1654
+ }
1655
+
1656
+ # hr_user_review_subject <branch> — print a user review round's commit subject.
1657
+ # The one producer of it, for the watcher's remote inbox pass and
1658
+ # `remote-run.sh review`.
1659
+ hr_user_review_subject() {
1660
+ printf 'chore: add user review for %s\n' "${1-}"
1661
+ }
1662
+
1663
+ # hr_place_artifact <worktree> <src_file> <rel> — copy <src_file> to
1664
+ # <worktree>/<rel>, creating its parent. 0, or 1 on a failure.
1665
+ hr_place_artifact() {
1666
+ local worktree="${1-}" src="${2-}" rel="${3-}" dest
1667
+ [ -n "$worktree" ] && [ -n "$src" ] && [ -n "$rel" ] || return 1
1668
+ dest="$worktree/$rel"
1669
+ mkdir -p "${dest%/*}" || return 1
1670
+ cp "$src" "$dest" || return 1
1671
+ return 0
1672
+ }
1673
+
1674
+ # hr_commit_placed <commit_wrapper> <worktree> <rel> <subject> — stage <rel> and
1675
+ # commit it through <commit_wrapper>. 0 committed; 3 nothing staged for <rel> (an
1676
+ # identical re-drop — nothing committed); 1 staging or the wrapper failed.
1677
+ hr_commit_placed() {
1678
+ local wrapper="${1-}" worktree="${2-}" rel="${3-}" subject="${4-}"
1679
+ [ -n "$wrapper" ] && [ -n "$worktree" ] && [ -n "$rel" ] && [ -n "$subject" ] || return 1
1680
+ git -C "$worktree" add -- "$rel" || return 1
1681
+ git -C "$worktree" diff --cached --quiet -- "$rel" && return 3
1682
+ "$wrapper" --repo "$worktree" "$rel" -- "$subject" || return 1
1683
+ return 0
1684
+ }
1685
+
1686
+ # hr_push_landed <push_wrapper> <worktree> <branch> — run <push_wrapper>, then 0
1687
+ # only when `HEAD` and `refs/remotes/origin/<branch>` both resolve and are
1688
+ # equal; 1 otherwise. `push-branch.sh` exits 0 on every path, so its status is
1689
+ # never the answer.
1690
+ hr_push_landed() {
1691
+ local wrapper="${1-}" worktree="${2-}" branch="${3-}" head upstream
1692
+ [ -n "$wrapper" ] && [ -n "$worktree" ] && [ -n "$branch" ] || return 1
1693
+ "$wrapper" "$worktree"
1694
+ head=$(git -C "$worktree" rev-parse --verify --quiet HEAD) || return 1
1695
+ upstream=$(git -C "$worktree" rev-parse --verify --quiet "refs/remotes/origin/$branch") || return 1
1696
+ [ -n "$head" ] && [ "$head" = "$upstream" ]
1697
+ }
1698
+
1322
1699
  # ---------------------------------------------------------------------------
1323
1700
  # THE REMOTE STATE BUNDLE.
1324
1701
  #
1325
1702
  # THE FORMAT OF RECORD. What a remote job carries across a job boundary and
1326
- # reports back, uploaded as the Actions artifact `harness-state`. Every name
1327
- # below is a variable `hr_remote_names_var` assigns; no function spells one.
1703
+ # reports back, uploaded as the Actions artifact `HR_REMOTE_STATE_ARTIFACT`
1704
+ # names. Every name below is a variable `hr_remote_names_var` assigns, and it
1705
+ # also assigns `HR_REMOTE_WORKFLOW_RUN_FILE`, the run workflow's file name, for
1706
+ # the GitHub-route producers `hr_github_answer_route` and
1707
+ # `hr_github_resume_route`. Those two are mirrors of the header's table; no
1708
+ # function spells either one, or any other name here.
1328
1709
  #
1329
1710
  # <bundle>/status.json the job's record, fixed schema below
1330
1711
  # <bundle>/clarifications/<branch>/ the whole branch directory, answered/ included
@@ -1402,6 +1783,46 @@ hr_remote_names_var() {
1402
1783
  HR_REMOTE_STATUS_SOURCE="$HR_REMOTE_LOGS_DIR/remote_status.json"
1403
1784
  HR_REMOTE_SUPERSEDED_DIR="$HR_REMOTE_LOGS_DIR/remote_superseded"
1404
1785
  HR_REMOTE_PLANNING_DIR='planning'
1786
+ HR_REMOTE_WORKFLOW_RUN_FILE='harness-run.yml'
1787
+ HR_REMOTE_STATE_ARTIFACT='harness-state'
1788
+ }
1789
+
1790
+ # hr_github_answer_route <branch> <engine> [park_loop_clear]
1791
+ # hr_github_resume_route <branch> <engine>
1792
+ #
1793
+ # Print the GitHub route for a remote-only reader of a job-side notification,
1794
+ # one clause with no trailing period, for the caller to join after its local
1795
+ # command. The route names the engine because `harness-run.yml`'s `engine`
1796
+ # input defaults to `task`: an empty <engine> prints where to read the run's
1797
+ # own instead. It names the branch twice, as the form's *Use workflow from*
1798
+ # ref and as the `branch` input: a run dispatched from the default branch is
1799
+ # listed under that branch, where every `gh run list --branch <branch>`
1800
+ # lookup (`sync`, `status`, `restore`) misses it. A non-empty third argument to the answer route adds the
1801
+ # park-loop clear. The section cited is `## 1. The lifecycle of a remote run`;
1802
+ # renumbering or retitling it is an edit here.
1803
+ hr_github_answer_route() {
1804
+ local branch="${1-}" engine="${2-}" clear='' eng
1805
+ hr_remote_names_var
1806
+ if [ -n "$engine" ]; then
1807
+ eng="engine \`$engine\`"
1808
+ else
1809
+ eng="engine the run's own (the \`engine\` field of \`$HR_REMOTE_STATUS_FILE\` in its \`$HR_REMOTE_STATE_ARTIFACT\` artifact)"
1810
+ fi
1811
+ [ -n "${3-}" ] && clear=', park_loop_clear true'
1812
+ printf 'or from GitHub: take the question from the run'"'"'s `%s` artifact, then Run workflow on %s from the branch `%s` (Use workflow from), with action run, branch `%s`, %s, resume answer%s and answers `{"<n>": "<your answer>"}` (docs/remote-execution.md, section 1)' \
1813
+ "$HR_REMOTE_STATE_ARTIFACT" "$HR_REMOTE_WORKFLOW_RUN_FILE" "$branch" "$branch" "$eng" "$clear"
1814
+ }
1815
+
1816
+ hr_github_resume_route() {
1817
+ local branch="${1-}" engine="${2-}" eng
1818
+ hr_remote_names_var
1819
+ if [ -n "$engine" ]; then
1820
+ eng="engine \`$engine\`"
1821
+ else
1822
+ eng="engine the run's own (the \`engine\` field of \`$HR_REMOTE_STATUS_FILE\` in its \`$HR_REMOTE_STATE_ARTIFACT\` artifact)"
1823
+ fi
1824
+ printf 'or from GitHub: Run workflow on %s from the branch `%s` (Use workflow from), with action run, branch `%s`, %s and resume pause (docs/remote-execution.md, section 1)' \
1825
+ "$HR_REMOTE_WORKFLOW_RUN_FILE" "$branch" "$branch" "$eng"
1405
1826
  }
1406
1827
 
1407
1828
  # hr_remote_planning_paths <branch>