autonomous-sdlc-harness 0.4.1 → 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.
@@ -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,21 +66,35 @@
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>/`,
70
- # `PAUSE_PROGRESS.md`, `.flow_walker_state` and the move-aside directory
71
- # `autonomous_logs/remote_superseded/`; outside it, only the caller-named
72
- # `<out_dir>` of `hr_remote_bundle_write` and the caller-named `<out_json>`
75
+ # `PAUSE_PROGRESS.md`, `.flow_walker_state`, the move-aside directory
76
+ # `autonomous_logs/remote_superseded/` and, only where nothing exists yet,
77
+ # files under the eight planning paths `hr_remote_planning_paths` assigns;
78
+ # outside it, only the caller-named `<out_dir>` (its `planning/` included)
79
+ # of `hr_remote_bundle_write` and the caller-named `<out_json>`
73
80
  # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
74
81
  # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
75
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
76
93
  #
77
94
  # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
78
- # `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
79
- # `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
80
98
  # lane's ceilings are the only environment values here that carry policy, because
81
99
  # the lane is machine-scoped and has no configuration key to carry them; each is
82
100
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
@@ -171,7 +189,10 @@
171
189
  # `mktemp`'s and `jq`'s own stderr on a failed write to the caller, as the
172
190
  # watcher's bodies they replaced did — that stream is the watcher's log — and
173
191
  # add one line of their own, naming the lock, when `hr_registry_set` cannot
174
- # 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
175
196
  # variable instead of a log line — the caller owns the log.
176
197
  #
177
198
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -179,11 +200,16 @@
179
200
  # value WITHOUT a command substitution — a `$(…)` forks a subshell, and the
180
201
  # watcher calls these on every tick: `HR_CFG_PID`, `HR_CFG_ROOT`,
181
202
  # `HR_CFG_STATE`, `HR_CFG_FILE`, `HR_CFG_SCALARS`, `HR_CFG_LISTS`,
182
- # `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
183
207
  # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
184
208
  # `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
185
209
  # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
186
- # names, which `hr_remote_names_var` assigns. Every one of them is assigned
210
+ # names, which `hr_remote_names_var` assigns, with `HR_REMOTE_PLANNING_PATHS`
211
+ # (`hr_remote_planning_paths`) and `HR_REMOTE_PLANNING_PLACED` /
212
+ # `HR_REMOTE_PLANNING_KEPT` (`hr_remote_bundle_restore`). Every one of them is assigned
187
213
  # before it is read by the function that owns it, so an inherited value from a
188
214
  # parent process is overwritten rather than believed.
189
215
  #
@@ -605,6 +631,7 @@ hr_config_load() {
605
631
  s("phases.qa"; try (.phases.qa | if type == "boolean" or . == null then . else "invalid" end) catch null),
606
632
  s("phases.docs"; try (.phases.docs | if type == "boolean" or . == null then . else "invalid" end) catch null),
607
633
  s("execution.target"; try .execution.target catch null),
634
+ s("forge"; try .forge catch null),
608
635
  s("protectedBranches.present";
609
636
  try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
610
637
  l("protectedBranches"; try .protectedBranches catch null)
@@ -871,6 +898,68 @@ hr_execution_target() {
871
898
  return 2
872
899
  }
873
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
+
874
963
  # ---------------------------------------------------------------------------
875
964
  # The protected-branch trichotomy.
876
965
  # ---------------------------------------------------------------------------
@@ -1315,30 +1404,351 @@ hr_registry_branches() {
1315
1404
  jq -r '.runs | keys[]' "$file" 2>/dev/null
1316
1405
  }
1317
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
+
1318
1699
  # ---------------------------------------------------------------------------
1319
1700
  # THE REMOTE STATE BUNDLE.
1320
1701
  #
1321
1702
  # THE FORMAT OF RECORD. What a remote job carries across a job boundary and
1322
- # reports back, uploaded as the Actions artifact `harness-state`. Every name
1323
- # 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.
1324
1709
  #
1325
1710
  # <bundle>/status.json the job's record, fixed schema below
1326
1711
  # <bundle>/clarifications/<branch>/ the whole branch directory, answered/ included
1327
1712
  # <bundle>/PAUSE_PROGRESS.md when present
1328
1713
  # <bundle>/flow_walker_state <state_dir>/.flow_walker_state, WITHOUT its dot
1329
1714
  # <bundle>/run.log <state_dir>/autonomous_logs/<branch>.log; never restored
1715
+ # <bundle>/planning/<path> each of these under <state_dir>, when present:
1716
+ # story_plans/<branch>_story_plan.md
1717
+ # task_plans/<branch>
1718
+ # ui_test_plans/<branch>_ui_test_plan.md
1719
+ # ui_test_plans/<branch>
1720
+ # task_plan_reviews/<branch>
1721
+ # business_parity_reviews/<branch>
1722
+ # architecture_reviews/<branch>
1723
+ # ui_test_plan_reviews/<branch>
1330
1724
  #
1331
1725
  # The walker state loses its dot because `actions/upload-artifact` skips hidden
1332
- # files by default. NOTHING IN THE BUNDLE IS EVER COMMITTED: every file in it is
1333
- # gitignored machine-local state, and the remote-status and move-aside paths sit
1334
- # under `autonomous_logs/`, whose ignore rule already covers them.
1726
+ # files by default. THE BUNDLE ITSELF IS NEVER COMMITTED. Every file outside
1727
+ # `planning/` is gitignored machine-local state, and the remote-status and
1728
+ # move-aside paths sit under `autonomous_logs/`, whose ignore rule already
1729
+ # covers them. The files under `planning/` are untracked drafts that the flow
1730
+ # commits itself at its P1/P3 convergence; the bundle only carries them.
1731
+ #
1732
+ # THE PLANNING PATHS ARE A MIRROR of the contracts that write and stage them:
1733
+ # `plugin/instructions/task_plan_writing_instructions_autonomous.md` →
1734
+ # `## Override 3` and `## Override 4` staging lists, and
1735
+ # `<scripts_dir>/flows/task_plan_writing.graph.json` → each node's `findingsFolder`. A path
1736
+ # added there is an edit to `hr_remote_planning_paths`. Only planning is
1737
+ # carried because the walker's one graph is `task_plan_writing.graph.json`;
1738
+ # implementation-phase per-unit review folders never span a pause, which waits
1739
+ # for a clean tracked tree. `HR_REMOTE_STATE_SCHEMA` stays `'1'`: `planning/`
1740
+ # is additive, an older reader ignores it, and a newer reader of an older
1741
+ # bundle finds none.
1335
1742
  #
1336
1743
  # WHO READS EACH FILE. `status.json`: `remote-run.sh sync` / `status` (into the
1337
1744
  # local registry), `continue` / `poll` (the decision, `chain`, the reset time)
1338
1745
  # and the next job's seed. The clarification directory and `PAUSE_PROGRESS.md`:
1339
1746
  # the next job, and the user's local mirror. The walker state: the next job
1340
- # only. `run.log`: the user only — `sync` copies it to the main checkout's logs
1341
- # directory itself, and no restore places it.
1747
+ # only. `planning/`: the next job only, never a mirror — an untracked draft left
1748
+ # in the mirror would make its later fast-forward to `origin/<branch>` refuse,
1749
+ # because the draft's own convergence commit adds the same path. `run.log`: the
1750
+ # user only — `sync` copies it to the main checkout's logs directory itself,
1751
+ # and no restore places it.
1342
1752
  #
1343
1753
  # `status.json` — schema `HR_REMOTE_STATE_SCHEMA`; every value a JSON string:
1344
1754
  # schema a reader that does not recognise it treats the bundle as absent
@@ -1372,6 +1782,67 @@ hr_remote_names_var() {
1372
1782
  HR_REMOTE_LOGS_DIR='autonomous_logs'
1373
1783
  HR_REMOTE_STATUS_SOURCE="$HR_REMOTE_LOGS_DIR/remote_status.json"
1374
1784
  HR_REMOTE_SUPERSEDED_DIR="$HR_REMOTE_LOGS_DIR/remote_superseded"
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"
1826
+ }
1827
+
1828
+ # hr_remote_planning_paths <branch>
1829
+ #
1830
+ # Assigns `HR_REMOTE_PLANNING_PATHS`: the eight planning paths of the format
1831
+ # above, relative to <state_dir>, newline-separated, no trailing slash. 1 with
1832
+ # an empty value for an empty <branch>.
1833
+ hr_remote_planning_paths() {
1834
+ local branch="${1-}"
1835
+ HR_REMOTE_PLANNING_PATHS=''
1836
+ [ -n "$branch" ] || return 1
1837
+ HR_REMOTE_PLANNING_PATHS="story_plans/${branch}_story_plan.md
1838
+ task_plans/$branch
1839
+ ui_test_plans/${branch}_ui_test_plan.md
1840
+ ui_test_plans/$branch
1841
+ task_plan_reviews/$branch
1842
+ business_parity_reviews/$branch
1843
+ architecture_reviews/$branch
1844
+ ui_test_plan_reviews/$branch"
1845
+ return 0
1375
1846
  }
1376
1847
 
1377
1848
  # hr_remote_status_write <registry_file> <branch> <out_json> <decision> <detail>
@@ -1475,10 +1946,12 @@ hr_remote_status_get() {
1475
1946
  # <root>'s configured state directory. `status.json` is the job's own
1476
1947
  # `autonomous_logs/remote_status.json` when present; otherwise it is written
1477
1948
  # from <branch>'s registry record with decision `stop`, because a job that never
1478
- # wrote its status never decided to continue. 0 written; 1 a missing argument,
1949
+ # wrote its status never decided to continue. Each planning path present is
1950
+ # copied under `planning/`, whether or not the branch tracks it: the restore
1951
+ # never overwrites, so a tracked copy is inert. 0 written; 1 a missing argument,
1479
1952
  # a non-empty <out_dir> or a failed copy; 2 <root>'s configuration unresolvable.
1480
1953
  hr_remote_bundle_write() {
1481
- local root="${1-}" branch="${2-}" registry="${3-}" out="${4-}" state base clarify
1954
+ local root="${1-}" branch="${2-}" registry="${3-}" out="${4-}" state base clarify rel dst
1482
1955
  [ -n "$root" ] && [ -n "$branch" ] && [ -n "$registry" ] && [ -n "$out" ] || return 1
1483
1956
  state=$(hr_state_dir "$root") || return 2
1484
1957
  hr_remote_names_var
@@ -1511,15 +1984,36 @@ hr_remote_bundle_write() {
1511
1984
  if [ -f "$base/$HR_REMOTE_LOGS_DIR/$branch.log" ]; then
1512
1985
  cp "$base/$HR_REMOTE_LOGS_DIR/$branch.log" "$out/$HR_REMOTE_LOG_FILE" 2>/dev/null || return 1
1513
1986
  fi
1987
+ hr_remote_planning_paths "$branch" || return 1
1988
+ while IFS= read -r rel; do
1989
+ dst="$out/$HR_REMOTE_PLANNING_DIR/$rel"
1990
+ if [ -f "$base/$rel" ]; then
1991
+ mkdir -p "${dst%/*}" 2>/dev/null || return 1
1992
+ cp "$base/$rel" "$dst" 2>/dev/null || return 1
1993
+ elif [ -d "$base/$rel" ]; then
1994
+ mkdir -p "${dst%/*}" 2>/dev/null || return 1
1995
+ cp -R "$base/$rel" "$dst" 2>/dev/null || return 1
1996
+ fi
1997
+ done <<EOF
1998
+ $HR_REMOTE_PLANNING_PATHS
1999
+ EOF
1514
2000
  return 0
1515
2001
  }
1516
2002
 
1517
2003
  # hr_remote_bundle_restore <bundle_dir> <root> <branch> <mode>
1518
2004
  #
1519
2005
  # <mode> `job` places the clarification directory, `PAUSE_PROGRESS.md`, the
1520
- # walker state (back under its dotted name) and `status.json` (as
1521
- # `autonomous_logs/remote_status.json`); `mirror` places the first two only. The
1522
- # run log is placed by neither.
2006
+ # walker state (back under its dotted name), the planning drafts and
2007
+ # `status.json` (as `autonomous_logs/remote_status.json`); `mirror` places the
2008
+ # first two only. The run log is placed by neither.
2009
+ #
2010
+ # A PLANNING DRAFT NEVER OVERWRITES. A regular file under `planning/` whose
2011
+ # path lies in `HR_REMOTE_PLANNING_PATHS` and has no `..` segment is placed only
2012
+ # where nothing exists, counted in `HR_REMOTE_PLANNING_PLACED`; one whose target
2013
+ # exists is left byte-identical — the checkout's copy is the branch's committed
2014
+ # record — and counted in `HR_REMOTE_PLANNING_KEPT`. A symlink, a non-regular
2015
+ # entry or a path outside the set counts in neither. Both are `0` at entry and
2016
+ # stay `0` in `mirror` mode.
1523
2017
  #
1524
2018
  # THE CLARIFICATION DIRECTORY IS REPLACED WHOLESALE, AND NOTHING IS DELETED. An
1525
2019
  # existing target is moved aside with one `mv` into
@@ -1535,7 +2029,9 @@ hr_remote_bundle_write() {
1535
2029
  # <branch>) or <root>'s configuration is unresolvable.
1536
2030
  hr_remote_bundle_restore() {
1537
2031
  local bundle="${1-}" root="${2-}" branch="${3-}" mode="${4-}"
1538
- local state base named target epoch aside n tmp
2032
+ local state base named target epoch aside n tmp pdir file rel p inset
2033
+ HR_REMOTE_PLANNING_PLACED=0
2034
+ HR_REMOTE_PLANNING_KEPT=0
1539
2035
  [ -n "$bundle" ] && [ -n "$root" ] && [ -n "$branch" ] || return 1
1540
2036
  case "$mode" in
1541
2037
  job|mirror) ;;
@@ -1575,6 +2071,37 @@ hr_remote_bundle_restore() {
1575
2071
  mkdir -p "$base" 2>/dev/null || return 1
1576
2072
  cp "$bundle/$HR_REMOTE_WALKER_FILE" "$base/$HR_REMOTE_WALKER_SOURCE" 2>/dev/null || return 1
1577
2073
  fi
2074
+ pdir="$bundle/$HR_REMOTE_PLANNING_DIR"
2075
+ if [ -d "$pdir" ] && [ ! -L "$pdir" ]; then
2076
+ hr_remote_planning_paths "$branch" || return 1
2077
+ # Process substitution, not a pipe: the loop must run in this shell so the
2078
+ # counters and `return 1` reach the caller.
2079
+ while IFS= read -r file; do
2080
+ # Re-tested per line: a name holding a newline arrives split and fails here.
2081
+ [ -f "$file" ] && [ ! -L "$file" ] || continue
2082
+ rel=${file#"$pdir"/}
2083
+ case "/$rel/" in
2084
+ */../*) continue ;;
2085
+ esac
2086
+ inset=0
2087
+ while IFS= read -r p; do
2088
+ case "$rel" in
2089
+ "$p"|"$p"/*) inset=1; break ;;
2090
+ esac
2091
+ done <<EOF
2092
+ $HR_REMOTE_PLANNING_PATHS
2093
+ EOF
2094
+ [ "$inset" = 1 ] || continue
2095
+ if [ -e "$base/$rel" ] || [ -L "$base/$rel" ]; then
2096
+ HR_REMOTE_PLANNING_KEPT=$((HR_REMOTE_PLANNING_KEPT + 1))
2097
+ continue
2098
+ fi
2099
+ target="$base/$rel"
2100
+ mkdir -p "${target%/*}" 2>/dev/null || return 1
2101
+ cp "$file" "$target" 2>/dev/null || return 1
2102
+ HR_REMOTE_PLANNING_PLACED=$((HR_REMOTE_PLANNING_PLACED + 1))
2103
+ done < <(find "$pdir" -type f 2>/dev/null)
2104
+ fi
1578
2105
  mkdir -p "$base/$HR_REMOTE_LOGS_DIR" 2>/dev/null || return 1
1579
2106
  tmp=$(mktemp "$base/$HR_REMOTE_STATUS_SOURCE.tmp.XXXXXX" 2>/dev/null) || return 1
1580
2107
  if cp "$bundle/$HR_REMOTE_STATUS_FILE" "$tmp" 2>/dev/null \