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.
- package/dist/commands/init.js +101 -13
- package/dist/commands/init.js.map +1 -1
- package/dist/config/check.js +1 -1
- package/dist/config/check.js.map +1 -1
- package/dist/config/model.js +17 -1
- package/dist/config/model.js.map +1 -1
- package/dist/core/writer.js +9 -5
- package/dist/core/writer.js.map +1 -1
- package/dist/core/yamlScalar.js +14 -0
- package/dist/core/yamlScalar.js.map +1 -0
- package/dist/doctor/checks.js +169 -15
- package/dist/doctor/checks.js.map +1 -1
- package/dist/generators/githubWorkflows.js +115 -20
- package/dist/generators/githubWorkflows.js.map +1 -1
- package/dist/generators/repoRoot.js +17 -8
- package/dist/generators/repoRoot.js.map +1 -1
- package/dist/remote/githubActions.js +51 -7
- package/dist/remote/githubActions.js.map +1 -1
- package/package.json +1 -1
- package/templates/README.md +1 -1
- package/templates/github/workflows/harness-resume.yml +2 -0
- package/templates/github/workflows/harness-run.yml +27 -11
- package/templates/github/workflows/harness-trigger.yml +143 -0
- package/templates/repo/gitignore +5 -0
- package/templates/scripts/autonomous-watcher.sh +92 -247
- package/templates/scripts/create-worktree.sh +52 -10
- package/templates/scripts/lib/harness-run-lib.sh +432 -11
- package/templates/scripts/remote-run.sh +1179 -106
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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?",
|
|
5
|
-
#
|
|
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
|
|
66
|
-
# `
|
|
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
|
|
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.
|
|
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`,
|
|
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 `
|
|
1327
|
-
# below is a variable `hr_remote_names_var` assigns
|
|
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>
|