autonomous-sdlc-harness 0.4.2 → 0.6.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.
Files changed (41) hide show
  1. package/dist/commands/init.js +113 -16
  2. package/dist/commands/init.js.map +1 -1
  3. package/dist/config/check.js +28 -6
  4. package/dist/config/check.js.map +1 -1
  5. package/dist/config/model.js +64 -5
  6. package/dist/config/model.js.map +1 -1
  7. package/dist/core/pluginIdentity.js +2 -0
  8. package/dist/core/pluginIdentity.js.map +1 -1
  9. package/dist/core/writer.js +10 -5
  10. package/dist/core/writer.js.map +1 -1
  11. package/dist/core/yamlScalar.js +14 -0
  12. package/dist/core/yamlScalar.js.map +1 -0
  13. package/dist/doctor/checks.js +454 -23
  14. package/dist/doctor/checks.js.map +1 -1
  15. package/dist/generators/githubWorkflows.js +125 -20
  16. package/dist/generators/githubWorkflows.js.map +1 -1
  17. package/dist/generators/repoRoot.js +17 -8
  18. package/dist/generators/repoRoot.js.map +1 -1
  19. package/dist/remote/githubActions.js +110 -7
  20. package/dist/remote/githubActions.js.map +1 -1
  21. package/dist/retrieval/pythonBackend.js +114 -0
  22. package/dist/retrieval/pythonBackend.js.map +1 -0
  23. package/dist/retrieval/setup.js +8 -0
  24. package/dist/retrieval/setup.js.map +1 -1
  25. package/package.json +1 -1
  26. package/templates/README.md +1 -1
  27. package/templates/github/workflows/harness-control.yml +184 -0
  28. package/templates/github/workflows/harness-resume.yml +9 -0
  29. package/templates/github/workflows/harness-run.yml +127 -12
  30. package/templates/github/workflows/harness-trigger.yml +144 -0
  31. package/templates/repo/gitignore +5 -0
  32. package/templates/scripts/README.md +1 -1
  33. package/templates/scripts/autonomous-watcher.sh +121 -249
  34. package/templates/scripts/create-worktree.sh +52 -10
  35. package/templates/scripts/docs-search-server.sh +88 -17
  36. package/templates/scripts/lib/harness-run-lib.sh +614 -14
  37. package/templates/scripts/remote-run.sh +3684 -144
  38. package/templates/scripts/scratch-run.sh +54 -73
  39. package/templates/state-dir/README-root.md +1 -1
  40. package/templates/state-dir/scratch/README.md +4 -2
  41. package/templates/state-dir/user_reviews/README.md +2 -2
@@ -1,12 +1,18 @@
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`), judges whether a
7
+ # path lies strictly inside the state directory's scratch directory
8
+ # (`hr_scratch_path_var`), places a dropped
9
+ # artifact in a working copy and commits and pushes it, and derives the
10
+ # anchors (main checkout, work root, worktree directory, repo slug,
6
11
  # state-dir paths) the scripts would otherwise each re-derive slightly
7
12
  # differently. It also implements the run registry's reads and writes for the
8
13
  # 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.
14
+ # with the one writer and restorer every remote-execution consumer shares, and
15
+ # the GitHub route a job-side notification names beside its local command.
10
16
  #
11
17
  # WHO SOURCES THIS, AND HOW. Every script in the configured `scriptsDir` that
12
18
  # needs this library sources it by a path computed from `${BASH_SOURCE[0]}` —
@@ -62,8 +68,9 @@
62
68
  # `.stale.*` move-aside and `.break` mutex while a stale one is broken)
63
69
  # and the temp files
64
70
  # `.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.
71
+ # `hr_registry_init`, `hr_registry_set`, `hr_remote_record_init` (through
72
+ # `hr_registry_set`) and the `hr_registry_lock` / `hr_registry_unlock`
73
+ # pair `hr_registry_set` calls.
67
74
  # 3. THE REMOTE STATE BUNDLE writes the files its format lists. Fence: inside
68
75
  # `<root>/<state_dir>/` (resolved through `hr_state_dir`), only
69
76
  # `autonomous_logs/remote_status.json`, `clarifications/<branch>/`,
@@ -75,10 +82,21 @@
75
82
  # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
76
83
  # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
77
84
  # there but a writer's own failed temp file is ever removed.
85
+ # 4. THE ARTIFACT PLACEMENT writes one artifact into a working copy. Fence:
86
+ # the caller-named `<worktree>/<rel>`, its parent directories and that
87
+ # path's index entry, plus whatever the two caller-named wrappers do.
88
+ # Written only by `hr_place_artifact`, `hr_commit_placed` and
89
+ # `hr_push_landed`.
90
+ #
91
+ # MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
92
+ # rename there is an edit here, byte for byte:
93
+ # HR_REMOTE_WORKFLOW_RUN_FILE mirrors WORKFLOW_RUN_FILE
94
+ # HR_REMOTE_STATE_ARTIFACT mirrors STATE_ARTIFACT_NAME
78
95
  #
79
96
  # 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
97
+ # `hr_remote_record_init`, `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
98
+ # `hr_remote_bundle_write`, `hr_remote_bundle_restore`, `hr_place_artifact`,
99
+ # `hr_commit_placed` or `hr_push_landed` function still gets a library that only reads. The
82
100
  # lane's ceilings are the only environment values here that carry policy, because
83
101
  # the lane is machine-scoped and has no configuration key to carry them; each is
84
102
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
@@ -173,7 +191,10 @@
173
191
  # `mktemp`'s and `jq`'s own stderr on a failed write to the caller, as the
174
192
  # watcher's bodies they replaced did — that stream is the watcher's log — and
175
193
  # 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
194
+ # take the registry lock. The artifact placement's three writers likewise leave
195
+ # `mkdir`'s, `cp`'s, `git add`'s and both wrappers' own output on their stdout
196
+ # and stderr for the caller to redirect into its log.
197
+ # That silence is why the lane reports a lock it BROKE through a
177
198
  # variable instead of a log line — the caller owns the log.
178
199
  #
179
200
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -181,7 +202,10 @@
181
202
  # value WITHOUT a command substitution — a `$(…)` forks a subshell, and the
182
203
  # watcher calls these on every tick: `HR_CFG_PID`, `HR_CFG_ROOT`,
183
204
  # `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
205
+ # `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`,
206
+ # `HR_INBOX_KIND`, `HR_INBOX_BRANCH`, the branch derivation's
207
+ # `HR_BRANCH_SLUG_MAX`, `HR_BRANCH_SUFFIX_MAX`, `HR_TAKEN_REMOTE`,
208
+ # `HR_TAKEN_ARTIFACTS` and `HR_TAKEN_WHY`, and the lane's
185
209
  # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
186
210
  # `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
187
211
  # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
@@ -572,9 +596,10 @@ hr_config_load() {
572
596
  # which is what lets an explicitly EMPTY list read as a configured set rather
573
597
  # than as an absent one.
574
598
  #
575
- # A `phases.*` value that is not a boolean is emitted as `invalid`, so the
576
- # string `"true"` — which `tostring` would otherwise make indistinguishable
577
- # from `true` — reaches `hr_phase_enabled` as a value it refuses (2).
599
+ # A `phases.*` or `docs.retrieval` value that is not a boolean is emitted as
600
+ # `invalid`, so the string `"true"` — which `tostring` would otherwise make
601
+ # indistinguishable from `true` — reaches `hr_phase_enabled` or
602
+ # `hr_docs_retrieval_applies` as a value it refuses (2).
578
603
  out=$(jq -n -r '
579
604
  def s($k; $v):
580
605
  if $v == null then empty
@@ -609,6 +634,9 @@ hr_config_load() {
609
634
  s("phases.qa"; try (.phases.qa | if type == "boolean" or . == null then . else "invalid" end) catch null),
610
635
  s("phases.docs"; try (.phases.docs | if type == "boolean" or . == null then . else "invalid" end) catch null),
611
636
  s("execution.target"; try .execution.target catch null),
637
+ s("docs.retrievalBackend"; try .docs.retrievalBackend catch null),
638
+ s("docs.retrieval"; try (.docs.retrieval | if type == "boolean" or . == null then . else "invalid" end) catch null),
639
+ s("forge"; try .forge catch null),
612
640
  s("protectedBranches.present";
613
641
  try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
614
642
  l("protectedBranches"; try .protectedBranches catch null)
@@ -875,6 +903,115 @@ hr_execution_target() {
875
903
  return 2
876
904
  }
877
905
 
906
+ # `forge` — which code-hosting platform the flow integrates with: `github`,
907
+ # `gitlab` or `none`. THE ONE READER OF THE KEY IN THIS FAMILY, and the shell
908
+ # mirror of `cli/src/config/model.ts` → `FORGE_KINDS`: change the enum there and
909
+ # here together. 1 — printing nothing — when the key is absent: "not yet
910
+ # decided", which is not `none`, and the schema withholds a default on purpose.
911
+ # 2 — printing nothing — when the configuration is unresolvable or the value is
912
+ # outside the enum, a refusal rather than a guess.
913
+ hr_forge() {
914
+ local root="${1-}"
915
+ hr_config_load "$root" || return 2
916
+ hr_cfg_scalar_var "forge" || return 1
917
+ case "$HR_CFG_VALUE" in
918
+ github|gitlab|none)
919
+ printf '%s\n' "$HR_CFG_VALUE"
920
+ return 0
921
+ ;;
922
+ esac
923
+ return 2
924
+ }
925
+
926
+ # Whether the docs phase's retrieval step runs: `phases.docs` and
927
+ # `docs.retrieval` both `true`. The shell mirror of `cli/src/config/model.ts` →
928
+ # `retrievalApplies`: change the predicate there and here together. PRINTS
929
+ # NOTHING: the answer is the status, as `hr_phase_enabled`'s is. 0 = both
930
+ # `true`; 1 = either `false` or unset; 2 = the configuration is unresolvable or
931
+ # either value is not a boolean — refused rather than guessed about.
932
+ hr_docs_retrieval_applies() {
933
+ local root="${1-}" status
934
+ hr_phase_enabled "$root" docs
935
+ status=$?
936
+ [ "$status" -eq 0 ] || return "$status"
937
+ hr_cfg_scalar_var "docs.retrieval" || return 1
938
+ case "$HR_CFG_VALUE" in
939
+ true) return 0 ;;
940
+ false) return 1 ;;
941
+ esac
942
+ return 2
943
+ }
944
+
945
+ # `docs.retrievalBackend` — which runtime the docs-retrieval index uses:
946
+ # `typescript` or `python`. THE ONE READER OF THE KEY IN THIS FAMILY, and the
947
+ # shell mirror of `cli/src/config/model.ts` → `RETRIEVAL_BACKENDS` /
948
+ # `DEFAULT_RETRIEVAL_BACKEND`: change the enum there and here together. Schema
949
+ # default `typescript`, so an absent key prints `typescript` and 1 is never
950
+ # returned; a `docs` parent of the wrong type (`"docs": "x"`) reads as absent
951
+ # too, because `hr_config_load`'s `try … catch` nulls that key alone — refusing
952
+ # that shape is the schema's job. 2 — printing nothing — when the configuration
953
+ # is unresolvable or the value is outside the enum, a refusal rather than a
954
+ # guess. It does NOT consult `phases.docs` / `docs.retrieval`: the key is read
955
+ # only inside the gate, so a caller asks `hr_docs_retrieval_applies` first and
956
+ # calls this only on its status 0.
957
+ hr_docs_retrieval_backend() {
958
+ local root="${1-}"
959
+ hr_config_load "$root" || return 2
960
+ if ! hr_cfg_scalar_var "docs.retrievalBackend"; then
961
+ printf 'typescript\n'
962
+ return 0
963
+ fi
964
+ case "$HR_CFG_VALUE" in
965
+ typescript|python)
966
+ printf '%s\n' "$HR_CFG_VALUE"
967
+ return 0
968
+ ;;
969
+ esac
970
+ return 2
971
+ }
972
+
973
+ # ---------------------------------------------------------------------------
974
+ # Inbox routing — the one owner of the drop filename patterns.
975
+ # ---------------------------------------------------------------------------
976
+
977
+ # Route one inbox filename: set `HR_INBOX_KIND` (`task` | `user_review` |
978
+ # `docs`) and `HR_INBOX_BRANCH`, and return 0; on no match return 1 with both
979
+ # empty. Takes a basename, not a path.
980
+ #
981
+ # The task-prompt pattern is tested FIRST (the more specific suffix), but the
982
+ # anchored SUFFIX regexes are mutually exclusive by construction: a filename
983
+ # cannot end in more than one of `_task_prompt.md` / `_review[_<n>].md` /
984
+ # `_docs.md`, so a branch whose own name contains `review` or `task_prompt`
985
+ # cannot be mis-routed — `foo_review_task_prompt.md` is the task engine on branch
986
+ # `foo_review`, and `foo_task_prompt_review.md` is the review engine on branch
987
+ # `foo_task_prompt`. POSIX leftmost-longest matching of the greedy `(.+)` derives
988
+ # the right branch from a round-suffixed name: `foo_review_2.md` -> branch `foo`
989
+ # (the `_2` is consumed by the optional `(_[0-9]+)?`), while
990
+ # `foo_review_2_review.md` -> branch `foo_review_2`. THE WATCHER DERIVES ONLY THE
991
+ # BRANCH, never the round: the engine resolves the latest round itself, inside
992
+ # the working copy, which is why nothing here has to remember one.
993
+ hr_inbox_route_var() {
994
+ local fname="${1-}" branch
995
+ HR_INBOX_KIND=""
996
+ HR_INBOX_BRANCH=""
997
+ [ -n "$fname" ] || return 1
998
+ branch="$(printf '%s' "$fname" | sed -nE 's/^(.+)_task_prompt\.md$/\1/p')"
999
+ if [ -n "$branch" ]; then
1000
+ HR_INBOX_KIND="task"
1001
+ else
1002
+ branch="$(printf '%s' "$fname" | sed -nE 's/^(.+)_review(_[0-9]+)?\.md$/\1/p')"
1003
+ if [ -n "$branch" ]; then
1004
+ HR_INBOX_KIND="user_review"
1005
+ else
1006
+ branch="$(printf '%s' "$fname" | sed -nE 's/^(.+)_docs\.md$/\1/p')"
1007
+ [ -n "$branch" ] || return 1
1008
+ HR_INBOX_KIND="docs"
1009
+ fi
1010
+ fi
1011
+ HR_INBOX_BRANCH="$branch"
1012
+ return 0
1013
+ }
1014
+
878
1015
  # ---------------------------------------------------------------------------
879
1016
  # The protected-branch trichotomy.
880
1017
  # ---------------------------------------------------------------------------
@@ -1055,6 +1192,92 @@ hr_state_path() {
1055
1192
  fi
1056
1193
  }
1057
1194
 
1195
+ # Judge whether <path> resolves STRICTLY INSIDE `<root>/<state_dir>/scratch/`.
1196
+ # Usage: hr_scratch_path_var <root> <path> [<base>]
1197
+ #
1198
+ # Two callers, differing only in what they do with an accepted path:
1199
+ # `scratch-run.sh` executes a file there and `remote-run.sh discard` removes a
1200
+ # directory there. So this reports a keyword in `HR_SCRATCH_WHY` and leaves every
1201
+ # message and exit code to its caller.
1202
+ #
1203
+ # The rule, stated once:
1204
+ # - `..` is refused ANYWHERE in the argument, not only as a whole segment —
1205
+ # strictly stronger, and free, because nothing in that directory depends on
1206
+ # a file's name. Then any character outside `A-Za-z0-9._/-` is refused. Both
1207
+ # tests run on <path> as the caller received it, before anything resolves.
1208
+ # - A relative <path> resolves against <base> when given, else <root>; an
1209
+ # absolute one is taken as given.
1210
+ # - Both sides of the comparison are resolved PHYSICALLY (`cd … && pwd -P`), so
1211
+ # a symlinked directory planted inside the scratch tree cannot widen the
1212
+ # fence. The target's parent must be the scratch directory or beneath it, and
1213
+ # a basename of `.` (the scratch directory itself) is refused.
1214
+ # - A target that is itself a symlink is refused rather than followed: its
1215
+ # destination is outside this judgement. The target need not exist.
1216
+ #
1217
+ # Clears the other four, then sets: `HR_SCRATCH_SUBDIR` (`scratch`, mirroring the `scratch` row
1218
+ # of `STATE_DIR_ENTRIES` in `cli/src/generators/stateDir.ts`), `HR_SCRATCH_DIR`
1219
+ # (physical), `HR_SCRATCH_PARENT` (physical), `HR_SCRATCH_TARGET`
1220
+ # (`<parent>/<basename>`, the path a caller acts on) and `HR_SCRATCH_WHY`.
1221
+ # Status / `HR_SCRATCH_WHY`:
1222
+ # 0 accepted (WHY empty)
1223
+ # 1 refused — `dotdot`, `charset`, `outside`, `itself`, `symlink`
1224
+ # 2 the target's parent does not resolve — `no-parent`
1225
+ # 3 the scratch directory cannot be located — `no-config`, `no-scratch`
1226
+ # 4 <root> or <path> is empty — `usage`
1227
+ hr_scratch_path_var() {
1228
+ local root="${1-}" path="${2-}" base="${3-}" scratch candidate name
1229
+ HR_SCRATCH_DIR=""
1230
+ HR_SCRATCH_PARENT=""
1231
+ HR_SCRATCH_TARGET=""
1232
+ HR_SCRATCH_WHY=""
1233
+ HR_SCRATCH_SUBDIR='scratch'
1234
+ if [ -z "$root" ] || [ -z "$path" ]; then
1235
+ HR_SCRATCH_WHY="usage"
1236
+ return 4
1237
+ fi
1238
+ case "$path" in
1239
+ *..*) HR_SCRATCH_WHY="dotdot"; return 1 ;;
1240
+ esac
1241
+ case "$path" in
1242
+ *[!A-Za-z0-9._/-]*) HR_SCRATCH_WHY="charset"; return 1 ;;
1243
+ esac
1244
+ scratch=$(hr_state_path "$root" "$HR_SCRATCH_SUBDIR")
1245
+ if [ "$?" -ne 0 ] || [ -z "$scratch" ]; then
1246
+ HR_SCRATCH_WHY="no-config"
1247
+ return 3
1248
+ fi
1249
+ HR_SCRATCH_DIR=$(cd "$scratch" 2>/dev/null && pwd -P)
1250
+ if [ -z "$HR_SCRATCH_DIR" ]; then
1251
+ HR_SCRATCH_WHY="no-scratch"
1252
+ return 3
1253
+ fi
1254
+ [ -n "$base" ] || base="$root"
1255
+ case "$path" in
1256
+ /*) candidate="$path" ;;
1257
+ *) candidate="${base%/}/$path" ;;
1258
+ esac
1259
+ HR_SCRATCH_PARENT=$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P)
1260
+ if [ -z "$HR_SCRATCH_PARENT" ]; then
1261
+ HR_SCRATCH_WHY="no-parent"
1262
+ return 2
1263
+ fi
1264
+ case "$HR_SCRATCH_PARENT" in
1265
+ "$HR_SCRATCH_DIR" | "$HR_SCRATCH_DIR"/*) ;;
1266
+ *) HR_SCRATCH_WHY="outside"; return 1 ;;
1267
+ esac
1268
+ name=$(basename "$candidate")
1269
+ if [ "$name" = "." ]; then
1270
+ HR_SCRATCH_WHY="itself"
1271
+ return 1
1272
+ fi
1273
+ HR_SCRATCH_TARGET="$HR_SCRATCH_PARENT/$name"
1274
+ if [ -L "$HR_SCRATCH_TARGET" ]; then
1275
+ HR_SCRATCH_WHY="symlink"
1276
+ return 1
1277
+ fi
1278
+ return 0
1279
+ }
1280
+
1058
1281
  # The machine-local settings directory — one place an operator keeps values that
1059
1282
  # vary per machine rather than per repository, which is why it is not a config
1060
1283
  # key and not a repository file. Return 1 when there is no home to anchor it to.
@@ -1319,12 +1542,349 @@ hr_registry_branches() {
1319
1542
  jq -r '.runs | keys[]' "$file" 2>/dev/null
1320
1543
  }
1321
1544
 
1545
+ # hr_remote_record_init <file> <branch> <worktree> <log_path> <engine>
1546
+ # The fields a remote run's record starts with — the one list, written by the
1547
+ # 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
1548
+ # once its run exists. 1 when the write failed.
1549
+ hr_remote_record_init() {
1550
+ local file="${1-}" branch="${2-}"
1551
+ hr_registry_set "$file" "$branch" \
1552
+ worktree "${3-}" log_path "${4-}" engine "${5-}" \
1553
+ execution github-actions \
1554
+ started_at "$(date '+%Y-%m-%dT%H:%M:%S')" \
1555
+ pid "" remote_dispatched_at "" \
1556
+ stall_restarts 0 stall_warned "" stall_killing "" \
1557
+ paused_by "" usage_resume_at "" \
1558
+ resume_kind "" park_loop_cycles 0
1559
+ }
1560
+
1561
+ # ---------------------------------------------------------------------------
1562
+ # DERIVING A BRANCH NAME FROM A TITLE.
1563
+ #
1564
+ # THE RULE. A title becomes a branch name by a fixed fold, with no model and no
1565
+ # confirmation step: lowercase A–Z, turn every run of characters outside
1566
+ # `[a-z0-9]` into one `_`, trim `_` from both ends, cut to `HR_BRANCH_SLUG_MAX`
1567
+ # and trim a trailing `_` the cut exposed. An empty result takes the caller's
1568
+ # <fallback> (`issue_<number>` for an issue, `task_<run id>` for a dispatch). A
1569
+ # taken name takes the lowest free `<name>_<n>`, `_2` through
1570
+ # `HR_BRANCH_SUFFIX_MAX`; the first branch carries no suffix (`Version bump` →
1571
+ # `version_bump`), and `_2` reads as "the second".
1572
+ #
1573
+ # - The fold is ASCII-only under `LC_ALL=C`, so `é` is a separator, never a
1574
+ # letter — `hr_repo_slug`'s precedent. A locale-dependent fold would derive
1575
+ # different names on different runners; a lowercase ASCII name passes every
1576
+ # `git check-ref-format` rule and cannot collide by case on macOS or Windows.
1577
+ # - The cap is 60, cut before the suffix. The name becomes a working-copy
1578
+ # directory component (`<projectName>-<branch>`) and prefixes artifact names
1579
+ # (`<branch>_task_prompt.md`); 60 keeps each far under a 255-byte file-name
1580
+ # limit and readable in the Actions run list. GitHub documents no ref limit.
1581
+ #
1582
+ # THIS SECTION ONLY READS. It fetches nothing and creates no branch or file; a
1583
+ # caller that wants `origin` fresh fetches first. Given a <gh_cli>, it also asks
1584
+ # GitHub one read per candidate — the run workflow's runs under that name — and
1585
+ # still writes nothing. A registry is read only when it already exists, because
1586
+ # `hr_registry_get` creates an absent one.
1587
+ # ---------------------------------------------------------------------------
1588
+
1589
+ # The two limits the rule above names.
1590
+ hr_branch_limits_var() {
1591
+ HR_BRANCH_SLUG_MAX=60
1592
+ HR_BRANCH_SUFFIX_MAX=99
1593
+ }
1594
+
1595
+ # hr_branch_slug <text> — print the slug and return 0; print nothing and return
1596
+ # 1 when the fold leaves nothing (`🚀🚀`).
1597
+ hr_branch_slug() {
1598
+ # `[!a-z0-9]` is a collation range: under a UTF-8 locale it would keep `é`.
1599
+ local LC_ALL=C
1600
+ local text="${1-}" slug
1601
+ hr_branch_limits_var
1602
+ slug=$(printf '%s' "$text" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
1603
+ slug=${slug//[!a-z0-9]/_}
1604
+ while :; do
1605
+ case "$slug" in
1606
+ *__*) slug=${slug//__/_} ;;
1607
+ *) break ;;
1608
+ esac
1609
+ done
1610
+ slug=${slug#_}
1611
+ slug=${slug%_}
1612
+ if [ "${#slug}" -gt "$HR_BRANCH_SLUG_MAX" ]; then
1613
+ slug=${slug:0:$HR_BRANCH_SLUG_MAX}
1614
+ slug=${slug%_}
1615
+ fi
1616
+ [ -n "$slug" ] || return 1
1617
+ printf '%s\n' "$slug"
1618
+ }
1619
+
1620
+ # Read, once, the two listings every `taken` judgement compares against:
1621
+ # `HR_TAKEN_REMOTE` — each branch on `origin`, lowercased — and
1622
+ # `HR_TAKEN_ARTIFACTS` — the basename of every file and directory under
1623
+ # `<state_dir>` on `origin/<defaultBranch>`; one per line in both. 0 when both
1624
+ # were read; 2, with `HR_TAKEN_WHY` naming which, when either could not be.
1625
+ hr_branch_taken_lists_var() {
1626
+ local LC_ALL=C
1627
+ local root="${1-}" heads state default tree line tab
1628
+ tab=$(printf '\t')
1629
+ HR_TAKEN_REMOTE=""
1630
+ HR_TAKEN_ARTIFACTS=""
1631
+ HR_TAKEN_WHY=""
1632
+ if ! heads=$(git -C "$root" ls-remote --heads origin 2>/dev/null); then
1633
+ HR_TAKEN_WHY="the branches on origin could not be listed"
1634
+ return 2
1635
+ fi
1636
+ while IFS= read -r line; do
1637
+ line=${line#*"$tab"refs/heads/}
1638
+ [ -n "$line" ] || continue
1639
+ HR_TAKEN_REMOTE="$HR_TAKEN_REMOTE$line
1640
+ "
1641
+ done <<EOF
1642
+ $heads
1643
+ EOF
1644
+ HR_TAKEN_REMOTE=$(printf '%s' "$HR_TAKEN_REMOTE" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
1645
+
1646
+ if ! state=$(hr_state_dir "$root") || ! default=$(hr_default_branch "$root"); then
1647
+ HR_TAKEN_WHY="the configuration could not be read"
1648
+ return 2
1649
+ fi
1650
+ if ! git -C "$root" rev-parse --verify --quiet "refs/remotes/origin/$default^{commit}" >/dev/null 2>&1; then
1651
+ HR_TAKEN_WHY="origin/$default is not present"
1652
+ return 2
1653
+ fi
1654
+ if ! tree=$(git -C "$root" ls-tree -r -t --name-only "refs/remotes/origin/$default" -- "$state/" 2>/dev/null); then
1655
+ HR_TAKEN_WHY="the tree of origin/$default could not be read"
1656
+ return 2
1657
+ fi
1658
+ while IFS= read -r line; do
1659
+ [ -n "$line" ] || continue
1660
+ HR_TAKEN_ARTIFACTS="$HR_TAKEN_ARTIFACTS${line##*/}
1661
+ "
1662
+ done <<EOF
1663
+ $tree
1664
+ EOF
1665
+ return 0
1666
+ }
1667
+
1668
+ # hr_branch_run_history <root> <gh_cli> <name> — 0 when the run workflow
1669
+ # (`HR_REMOTE_WORKFLOW_RUN_FILE`) lists a run under <name>, 1 when it lists
1670
+ # none, 2 when <gh_cli> cannot run, exits non-zero, or answers anything but a
1671
+ # JSON array. Sets no `HR_` variable: the names are assigned inside the
1672
+ # subshell that `cd`s to <root>.
1673
+ #
1674
+ # GitHub keeps a deleted branch's runs listed under its name, and a later run
1675
+ # of that name inherits them: Gate 12 round 5 found run `36569531374` of the
1676
+ # deleted `feat_invoices` (`docs/development.md` → Gate 12 → Round 5, finding
1677
+ # 1). One bounded read per candidate, not one listing of every branch, because
1678
+ # a bounded all-branch listing could miss an old name. Bash 3.2 and jq 1.5.
1679
+ hr_branch_run_history() {
1680
+ local root="${1-}" gh_cli="${2-}" name="${3-}" answer verdict
1681
+ [ -n "$root" ] && [ -n "$gh_cli" ] && [ -n "$name" ] || return 2
1682
+ answer=$(cd "$root" 2>/dev/null && hr_remote_names_var &&
1683
+ "$gh_cli" run list --workflow "$HR_REMOTE_WORKFLOW_RUN_FILE" --branch "$name" --limit 1 --json databaseId 2>/dev/null) || return 2
1684
+ verdict=$(printf '%s' "$answer" | jq -r 'if type == "array" then (if length > 0 then "history" else "none" end) else "other" end' 2>/dev/null) || return 2
1685
+ case "$verdict" in
1686
+ history) return 0 ;;
1687
+ none) return 1 ;;
1688
+ esac
1689
+ return 2
1690
+ }
1691
+
1692
+ # Judge <name> against the listings `hr_branch_taken_lists_var` last read, then,
1693
+ # given a <gh_cli>, against the run workflow's history — last, so the free local
1694
+ # checks answer first. The answers and `HR_TAKEN_WHY` are
1695
+ # `hr_branch_name_taken`'s.
1696
+ hr_branch_taken_judge() {
1697
+ local LC_ALL=C
1698
+ local root="${1-}" name="${2-}" registry="${3-}" gh_cli="${4-}" lower status nl
1699
+ nl='
1700
+ '
1701
+ HR_TAKEN_WHY=""
1702
+ status=0
1703
+ hr_branch_is_protected "$root" "$name" || status=$?
1704
+ case "$status" in
1705
+ 0) HR_TAKEN_WHY="a protected branch"; return 0 ;;
1706
+ 2) HR_TAKEN_WHY="the protected branches could not be resolved"; return 2 ;;
1707
+ esac
1708
+ lower=$(printf '%s' "$name" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
1709
+ case "$nl$HR_TAKEN_REMOTE$nl" in
1710
+ *"$nl$lower$nl"*) HR_TAKEN_WHY="a branch on origin"; return 0 ;;
1711
+ esac
1712
+ if git -C "$root" show-ref --verify --quiet "refs/heads/$name" 2>/dev/null; then
1713
+ HR_TAKEN_WHY="a local branch"
1714
+ return 0
1715
+ fi
1716
+ case "$nl$HR_TAKEN_ARTIFACTS" in
1717
+ *"$nl$name$nl"* | *"$nl${name}_task_prompt.md$nl"* | *"$nl${name}_story_plan.md$nl"* | *"$nl${name}_docs.md$nl"*)
1718
+ HR_TAKEN_WHY="a run's artifacts on the default branch"
1719
+ return 0
1720
+ ;;
1721
+ esac
1722
+ if [ -n "$registry" ] && [ -f "$registry" ] && [ -n "$(hr_registry_get "$registry" "$name" branch)" ]; then
1723
+ HR_TAKEN_WHY="a run registry record"
1724
+ return 0
1725
+ fi
1726
+ if [ -n "$gh_cli" ]; then
1727
+ status=0
1728
+ hr_branch_run_history "$root" "$gh_cli" "$name" || status=$?
1729
+ case "$status" in
1730
+ 0) HR_TAKEN_WHY="a run of the run workflow listed under that name"; return 0 ;;
1731
+ 1) ;;
1732
+ *) HR_TAKEN_WHY="the run history of $name could not be listed"; return 2 ;;
1733
+ esac
1734
+ fi
1735
+ return 1
1736
+ }
1737
+
1738
+ # hr_branch_name_taken <root> <name> [<registry>] [<gh_cli>] — 0 taken, 1 free,
1739
+ # 2 cannot tell. Sets `HR_TAKEN_WHY` to a short phrase naming the collision or
1740
+ # the failure. A caller with no registry passes `""` before <gh_cli>. Taken: a
1741
+ # protected name; a branch on `origin`, compared case-insensitively; a local
1742
+ # branch; under `<state_dir>` on `origin/<defaultBranch>`, a directory named
1743
+ # <name> or a file `<name>_task_prompt.md`, `<name>_story_plan.md` or
1744
+ # `<name>_docs.md` — which a merged and deleted branch still leaves; a record in
1745
+ # an existing <registry>; given a <gh_cli>, any run of the run workflow listed
1746
+ # under <name> (`hr_branch_run_history`), whose listing failing is a 2.
1747
+ hr_branch_name_taken() {
1748
+ local root="${1-}" name="${2-}" registry="${3-}" gh_cli="${4-}"
1749
+ HR_TAKEN_WHY=""
1750
+ if [ -z "$root" ] || [ -z "$name" ]; then
1751
+ HR_TAKEN_WHY="no branch name to judge"
1752
+ return 2
1753
+ fi
1754
+ hr_config_load "$root" || :
1755
+ hr_branch_taken_lists_var "$root" || return 2
1756
+ hr_branch_taken_judge "$root" "$name" "$registry" "$gh_cli"
1757
+ }
1758
+
1759
+ # hr_derive_branch <root> <text> <fallback> [<registry>] [<gh_cli>] — print the
1760
+ # derived name and return 0; return 2, printing nothing, when a `taken`
1761
+ # judgement could not tell or no base routes back to itself; return 3, printing
1762
+ # nothing, when every suffix through `HR_BRANCH_SUFFIX_MAX` is taken. Never a
1763
+ # guessed name. <gh_cli> reaches every judgement as `hr_branch_name_taken`'s.
1764
+ #
1765
+ # THE BASE MUST ROUTE BACK TO ITSELF: `hr_inbox_route_var` on each drop filename
1766
+ # a run of that name produces has to give the base as its branch, so no derived
1767
+ # name makes the inbox patterns ambiguous. A base that does not is replaced by
1768
+ # <fallback> once.
1769
+ hr_derive_branch() {
1770
+ local root="${1-}" text="${2-}" fallback="${3-}" registry="${4-}" gh_cli="${5-}"
1771
+ local base="" candidate suffix routes n status
1772
+ hr_branch_limits_var
1773
+ candidate=$(hr_branch_slug "$text") || candidate="$fallback"
1774
+ for candidate in "$candidate" "$fallback"; do
1775
+ [ -n "$candidate" ] || continue
1776
+ routes=0
1777
+ for suffix in _task_prompt.md _review.md _review_2.md _docs.md; do
1778
+ if ! hr_inbox_route_var "$candidate$suffix" || [ "$HR_INBOX_BRANCH" != "$candidate" ]; then
1779
+ routes=1
1780
+ break
1781
+ fi
1782
+ done
1783
+ if [ "$routes" -eq 0 ]; then
1784
+ base="$candidate"
1785
+ break
1786
+ fi
1787
+ done
1788
+ [ -n "$base" ] || return 2
1789
+
1790
+ hr_config_load "$root" || :
1791
+ hr_branch_taken_lists_var "$root" || return 2
1792
+ candidate="$base"
1793
+ n=1
1794
+ while :; do
1795
+ status=0
1796
+ hr_branch_taken_judge "$root" "$candidate" "$registry" "$gh_cli" || status=$?
1797
+ case "$status" in
1798
+ 1) printf '%s\n' "$candidate"; return 0 ;;
1799
+ 2) return 2 ;;
1800
+ esac
1801
+ n=$((n + 1))
1802
+ [ "$n" -le "$HR_BRANCH_SUFFIX_MAX" ] || return 3
1803
+ candidate="${base}_$n"
1804
+ done
1805
+ }
1806
+
1807
+ # ---------------------------------------------------------------------------
1808
+ # THE ARTIFACT PLACEMENT.
1809
+ #
1810
+ # THE CONTRACT. The one placement the watcher's inbox pass and a job starting a
1811
+ # run both perform: copy a dropped artifact into a working copy, stage exactly
1812
+ # that path, skip an identical re-drop, commit through the caller-named commit
1813
+ # wrapper, push through the caller-named push wrapper, and read "landed" as
1814
+ # `origin/<branch>` equal to `HEAD`. Every step reports by exit status only;
1815
+ # what a failure means — log and launch anyway, or block the dispatch — is the
1816
+ # caller's decision. The wrapper paths are arguments because this library
1817
+ # resolves no sibling script. Write exception 4 in the header is this section's.
1818
+ # ---------------------------------------------------------------------------
1819
+
1820
+ # hr_task_prompt_rel <state_rel> <branch> — print the task prompt's
1821
+ # repo-relative path, with <state_rel>'s trailing `/` dropped.
1822
+ hr_task_prompt_rel() {
1823
+ local state_rel="${1-}" branch="${2-}"
1824
+ printf '%s/task_prompts/%s_task_prompt.md\n' "${state_rel%/}" "$branch"
1825
+ }
1826
+
1827
+ # hr_task_prompt_subject <branch> — print the task prompt's commit subject. The
1828
+ # one producer of it; `.claude/context/conventions.md` → `## Commit-message
1829
+ # policy` lists it byte for byte.
1830
+ hr_task_prompt_subject() {
1831
+ printf 'chore: add task prompt for %s\n' "${1-}"
1832
+ }
1833
+
1834
+ # hr_user_review_subject <branch> — print a user review round's commit subject.
1835
+ # The one producer of it, for the watcher's remote inbox pass and
1836
+ # `remote-run.sh review`.
1837
+ hr_user_review_subject() {
1838
+ printf 'chore: add user review for %s\n' "${1-}"
1839
+ }
1840
+
1841
+ # hr_place_artifact <worktree> <src_file> <rel> — copy <src_file> to
1842
+ # <worktree>/<rel>, creating its parent. 0, or 1 on a failure.
1843
+ hr_place_artifact() {
1844
+ local worktree="${1-}" src="${2-}" rel="${3-}" dest
1845
+ [ -n "$worktree" ] && [ -n "$src" ] && [ -n "$rel" ] || return 1
1846
+ dest="$worktree/$rel"
1847
+ mkdir -p "${dest%/*}" || return 1
1848
+ cp "$src" "$dest" || return 1
1849
+ return 0
1850
+ }
1851
+
1852
+ # hr_commit_placed <commit_wrapper> <worktree> <rel> <subject> — stage <rel> and
1853
+ # commit it through <commit_wrapper>. 0 committed; 3 nothing staged for <rel> (an
1854
+ # identical re-drop — nothing committed); 1 staging or the wrapper failed.
1855
+ hr_commit_placed() {
1856
+ local wrapper="${1-}" worktree="${2-}" rel="${3-}" subject="${4-}"
1857
+ [ -n "$wrapper" ] && [ -n "$worktree" ] && [ -n "$rel" ] && [ -n "$subject" ] || return 1
1858
+ git -C "$worktree" add -- "$rel" || return 1
1859
+ git -C "$worktree" diff --cached --quiet -- "$rel" && return 3
1860
+ "$wrapper" --repo "$worktree" "$rel" -- "$subject" || return 1
1861
+ return 0
1862
+ }
1863
+
1864
+ # hr_push_landed <push_wrapper> <worktree> <branch> — run <push_wrapper>, then 0
1865
+ # only when `HEAD` and `refs/remotes/origin/<branch>` both resolve and are
1866
+ # equal; 1 otherwise. `push-branch.sh` exits 0 on every path, so its status is
1867
+ # never the answer.
1868
+ hr_push_landed() {
1869
+ local wrapper="${1-}" worktree="${2-}" branch="${3-}" head upstream
1870
+ [ -n "$wrapper" ] && [ -n "$worktree" ] && [ -n "$branch" ] || return 1
1871
+ "$wrapper" "$worktree"
1872
+ head=$(git -C "$worktree" rev-parse --verify --quiet HEAD) || return 1
1873
+ upstream=$(git -C "$worktree" rev-parse --verify --quiet "refs/remotes/origin/$branch") || return 1
1874
+ [ -n "$head" ] && [ "$head" = "$upstream" ]
1875
+ }
1876
+
1322
1877
  # ---------------------------------------------------------------------------
1323
1878
  # THE REMOTE STATE BUNDLE.
1324
1879
  #
1325
1880
  # 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.
1881
+ # reports back, uploaded as the Actions artifact `HR_REMOTE_STATE_ARTIFACT`
1882
+ # names. Every name below is a variable `hr_remote_names_var` assigns, and it
1883
+ # also assigns `HR_REMOTE_WORKFLOW_RUN_FILE`, the run workflow's file name, for
1884
+ # the GitHub-route producers `hr_github_answer_route` and
1885
+ # `hr_github_resume_route` and for the run-history probe `hr_branch_run_history`.
1886
+ # Those two are mirrors of the header's table; no function spells either one,
1887
+ # or any other name here.
1328
1888
  #
1329
1889
  # <bundle>/status.json the job's record, fixed schema below
1330
1890
  # <bundle>/clarifications/<branch>/ the whole branch directory, answered/ included
@@ -1402,6 +1962,46 @@ hr_remote_names_var() {
1402
1962
  HR_REMOTE_STATUS_SOURCE="$HR_REMOTE_LOGS_DIR/remote_status.json"
1403
1963
  HR_REMOTE_SUPERSEDED_DIR="$HR_REMOTE_LOGS_DIR/remote_superseded"
1404
1964
  HR_REMOTE_PLANNING_DIR='planning'
1965
+ HR_REMOTE_WORKFLOW_RUN_FILE='harness-run.yml'
1966
+ HR_REMOTE_STATE_ARTIFACT='harness-state'
1967
+ }
1968
+
1969
+ # hr_github_answer_route <branch> <engine> [park_loop_clear]
1970
+ # hr_github_resume_route <branch> <engine>
1971
+ #
1972
+ # Print the GitHub route for a remote-only reader of a job-side notification,
1973
+ # one clause with no trailing period, for the caller to join after its local
1974
+ # command. The route names the engine because `harness-run.yml`'s `engine`
1975
+ # input defaults to `task`: an empty <engine> prints where to read the run's
1976
+ # own instead. It names the branch twice, as the form's *Use workflow from*
1977
+ # ref and as the `branch` input: a run dispatched from the default branch is
1978
+ # listed under that branch, where every `gh run list --branch <branch>`
1979
+ # lookup (`sync`, `status`, `restore`) misses it. A non-empty third argument to the answer route adds the
1980
+ # park-loop clear. The section cited is `## 1. The lifecycle of a remote run`;
1981
+ # renumbering or retitling it is an edit here.
1982
+ hr_github_answer_route() {
1983
+ local branch="${1-}" engine="${2-}" clear='' eng
1984
+ hr_remote_names_var
1985
+ if [ -n "$engine" ]; then
1986
+ eng="engine \`$engine\`"
1987
+ else
1988
+ eng="engine the run's own (the \`engine\` field of \`$HR_REMOTE_STATUS_FILE\` in its \`$HR_REMOTE_STATE_ARTIFACT\` artifact)"
1989
+ fi
1990
+ [ -n "${3-}" ] && clear=', park_loop_clear true'
1991
+ 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)' \
1992
+ "$HR_REMOTE_STATE_ARTIFACT" "$HR_REMOTE_WORKFLOW_RUN_FILE" "$branch" "$branch" "$eng" "$clear"
1993
+ }
1994
+
1995
+ hr_github_resume_route() {
1996
+ local branch="${1-}" engine="${2-}" eng
1997
+ hr_remote_names_var
1998
+ if [ -n "$engine" ]; then
1999
+ eng="engine \`$engine\`"
2000
+ else
2001
+ eng="engine the run's own (the \`engine\` field of \`$HR_REMOTE_STATUS_FILE\` in its \`$HR_REMOTE_STATE_ARTIFACT\` artifact)"
2002
+ fi
2003
+ 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)' \
2004
+ "$HR_REMOTE_WORKFLOW_RUN_FILE" "$branch" "$branch" "$eng"
1405
2005
  }
1406
2006
 
1407
2007
  # hr_remote_planning_paths <branch>